C语言可变参数以及printf的实现
C 语言的变长参数在平时做开发时很少会在自己设计的接口中用到,但我们最常用的接口printf 就是使用的变长参数接口,在感受到 printf 强大的魅力的同时,是否想挖据一下到底printf 是如何实现的呢?这里我们一起来挖掘一下 C 语言变长参数的奥秘。
=================================
1、可变参数
先考虑这样一个问题:如果我们不使用 C 标准库(libc)中提供的Facilities,我们自己是否可以实现拥有变长参数的函数呢?我们不妨试试:
一步一步进入正题,我们先看看固定参数列表函数:
void fixed_args_func(int a, double b, char *c)
{
printf("a = 0x%p\n", &a);
printf("b = 0x%p\n", &b);
printf("c = 0x%p\n", &c);
}
对于固定参数列表的函数,每个参数的名称、类型都是直接可见的,他们的地址也都是可以直接得到的,比如:通过&a 我们可以得到 a 的地址,并通过函数原型声明了解到 a 是 int类型的; 通过&b 我们可以得到 b 的地址,并通过函数原型声明了解到 b 是 double 类型的; 通过&c 我们可以得到 c 的地址,并通过函数原型声明了解到 c 是 char*类型的。
但是对于变长参数的函数,我们就没有这么顺利了。还好,按照 C 标准的说明,支持变长参数的函数在原型声明中,必须有至少一个最左固定参数(这一点与传统 C 有区别,传统 C允许不带任何固定参数的纯变长参数函数),这样我们可以得到其中固定参数的地址,但是依然无法从声明中得到其他变长参数的地址,比如:
void var_args_func(const char * fmt, ... )
{
... ...
}
这里我们只能得到fmt这固定参数的地址,仅从函数原型我们是无法确定"…“中有几个参数、参数都是什么类型的,自然也就无法确定其位置了。那么如何可以做到呢?在大脑中回想一下函数传参的过程,无论”…"中有多少个参数、每个参数是什么类型的,它们都和固定参数的传参过程是一样的,简单来讲都是栈操作,而栈这个东西对我们是开放的。这样一来,一旦我们知道某函数帧的栈上的一个固定参数的位置,我们完全有可能推导出其他变长参数的位置,顺着这个思路,我们继续往下走,通过一个例子来诠释一下:(这里要说明的是:函数参数进栈以及参数空间地址分配都是"实现相关"的,不同平台、不同编译器都可能不同,所以下面的例子仅在IA-32,Windows XP, MinGW gcc v3.4.2 下成立)
我们先用上面的那个fixed_args_func函数确定一下这个平台下的入栈顺序。
int main()
{
fixed_args_func(17, 5.40, "hello world");
return 0;
}
a = 0x0022FF50
b = 0x0022FF54
c = 0x0022FF5C
从这个结果来看,显然参数是从右到左,逐一压入栈中的(栈的延伸方向是从高地址到低地址,栈底的占领着最高内存地址,先入栈的参数,其地理位置也就最高了)。
我们基本可以得出这样一个结论:
c.addr = b.addr + x_sizeof(b); /*注意: x_sizeof != sizeof,后话再说 */
b.addr = a.addr + x_sizeof(a);
有了以上的"等式",我们似乎可以推导出
void var_args_func(const char * fmt, … )
函数中,可变参数的位置了。起码第一个可变参数的位置应该是:
first_vararg.addr = fmt.addr + x_sizeof(fmt)
根据这一结论我们试着实现一个支持可变参数的函数:
void var_args_func(const char * fmt, ... )
{
char *ap;
ap = ((char*)&fmt) + sizeof(fmt);
printf("%d\n", *(int*)ap);
ap = ap + sizeof(int);
printf("%d\n", *(int*)ap);
ap = ap + sizeof(int);
printf("%s\n", *((char**)ap));
}
int main()
{
var_args_func("%d %d %s\n", 4, 5, "hello world");
}
输出结果:
4
5
hello world
var_args_func只是为了演示,并未根据fmt消息中的格式字符串来判断变参的个数和类型,而是直接在实现中写死了,如果你把这个程序拿到编译器下,运行后,一定得不到正确的结果,为什么呢,后续再说。
先来解释一下这个程序。
我们用ap获取第一个变参的地址,我们知道第一个变参是 4,一个int型,所以我们用(int*)ap以告诉编译器,以ap为首地址的那块内存我们要将之视为一个整型,来使用
*(int*)ap
获得该参数的值。
接下来的变参是5,又一个int型,其地址是ap + sizeof(第一个变参),也就是ap+sizeof(int),同样我们使用 *(int*)ap获得该参数的值。
最后的一个参数是一个字符串,也就是char*,与前两个int型参数不同的是,经过ap+sizeof(int)后,ap指向栈上一个char*类型的内存块(我们暂且称之tmp_ptr,char *tmp_ptr)的首地址,即ap -> &tmp_ptr,而我们要输出的不是printf("%s\n", ap),而是printf("%s\n", tmp_ptr)。
printf("%s\n", ap)是意图将ap所指的内存块作为字符串输出了,但是ap -> &tmp_ptr,tmp_ptr所占据的 4 个字节显然不是字符串,而是一个地址。如何让&tmp_ptr是char **类型的,我们将ap进行强制转换(char**)ap <=> &tmp_ptr,这样我们访问tmp_ptr只需要在(char**)ap前面加上一个*即可,即
printf("%s\n", *(char**)ap);
前面说过,如果将var_args_func放到solaris上,一定是得不到正确结果的?为什么呢?由于内存对齐。编译器在栈上压入参数时,不是一个紧挨着另一个的,编译器会根据变参的类型将其放到满足类型对齐的地址上的,这样栈上参数之间实际上可能会是有空隙的。上述例子中,我是根据反编译后的汇编码得到的参数间隔,还好都是 4,然后在代码中写死了。
为了满足代码的可移植性,C标准库在stdarg.h中提供了诸多Facilities以供实现变长长度参数时使用。这里也列出一个简单的例子,看看利用标准库是如何支持变长参数的:
#include <stdarg.h>
void std_vararg_func(const char *fmt, ... )
{
va_list ap;
va_start(ap, fmt);
printf("%d\n", va_arg(ap, int));
printf("%f\n", va_arg(ap, double));
printf("%s\n", va_arg(ap, char*));
va_end(ap);
}
int main()
{
std_vararg_func("%d %f %s\n", 4, 5.4, "hello world");
}
输出:
4
5.400000
hello world
对比一下 std_vararg_func 和 var_args_func 的实现,va_list 似乎就是 char*, va_start 似乎就是 ((char*)&fmt) + sizeof(fmt),va_arg 似乎就是得到下一个参数的首地址。
没错,多数平台下 stdarg.h 中 va_list, va_start 和 var_arg 的实现就是类似这样的。一般 stdarg.h 会包含很多宏,看起来比较复杂。在有的系统中 stdarg.h 的实现依赖 some special functions built into the the compilation system to handle variable argument lists and stack allocations,多数其他系统的实现与下面很相似:(Visual C++ 6.0 的实现较为清晰,因为 windows 上的应用程序只需要在windows 平台间做移植即可,没有必要考虑太多的平台情况)。
2、C 语言 va_list 与_vsnprintf 的使用
先举一个例子:
#define bufsize 80
char buffer[bufsize];
/* 这个函数用来格式化带参数的字符串*/
int vspf(char *fmt, ...)
{
va_list argptr; //声明一个转换参数的变量
int cnt;
va_start(argptr, fmt); //初始化变量
cnt = vsnprintf(buffer,bufsize ,fmt, argptr);
//将带参数的字符串按照参数列表格式化到 buffer 中
va_end(argptr); //结束变量列表,和 va_start 成对使用
return(cnt);
}
int main(int argc, char* argv[])
{
int inumber = 30;
float fnumber = 90.0;
char string[4] = "abc";
vspf("%d %f %s", inumber, fnumber, string);
printf("%s\n", buffer);
return 0;
}
下面我们来探讨如何写一个简单的可变参数的 C 函数。
写可变参数的 C 函数要在程序中用到以下这些宏:
使用可变参数应该有以下步骤:
1)首先在函数里定义一个 va_list 型的变量,这里是 arg_ptr,这个变量是指向参数的指针.
2)然后用 va_start 宏初始化变量 arg_ptr,这个宏的第二个参数是第一个可变参数的前一个参数,是一个固定的参数.
3)然后用 va_arg 返回可变的参数,并赋值给整数 j. va_arg 的第二个参数是你要返回的参数的类型,这里是 int 型.
4)最后用va_end 宏结束可变参数的获取.然后你就可以在函数里使用第二个参数了.如果函数有多个可变参数的,依次调用 va_arg 获取各个参数.
如果我们用下面三种方法调用的话,都是合法的,但结果却不一样:
可变参数在编译器中的处理
我们知道 va_start,va_arg,va_end 是在 stdarg.h 中被定义成宏的,由于硬件平台和编译器的不同
在Microsoft Visual Studio\VC98\Include\stdarg.h 中:
typedef char * va_list;
把 va_list 被定义成 char*,这是因为在我们目前所用的 PC 机上,字符指针类型可以用来存储内存单元地址。而在有的机器上 va_list 是被定义成 void*的
#define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )
_INTSIZEOF(n)宏是为了考虑那些内存地址需要对齐的系统,从宏的名字来应该是跟sizeof(int)对齐。一般的 sizeof(int)=4,也就是参数在内存中的地址都为 4 的倍数。比如,如果 sizeof(n)在 1-4 之间,那么_INTSIZEOF(n)=4;如果 sizeof(n)在 5-8 之间,那么_INTSIZEOF(n)=8。
#define va_start(ap,v) ( ap = (va_list)&v + _INTSIZEOF(v) )
va_start 的定义为:
&v+_INTSIZEOF(v)
,这里&v 是最后一个固定参数的起始地址,再加上其实际占用大小后,就得到了第一个可变参数的起始内存地址。所以我们运行 va_start (ap, v)以后,ap 指向第一个可变参数在的内存地址。
#define va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
这个宏做了两个事情:
①用户输入的类型名对参数地址进行强制类型转换,得到用户所需要的值
②计算出本参数的实际大小,将指针调到本参数的结尾,也就是下一个参数的首地址,以便后续处理。
#define va_end(ap) ( ap = (va_list)0 )
x86平台定义为 ap=(char*)0;使ap不再指向堆栈,而是跟NULL一样。有些直接定义为((void*)0),这样编译器不会为 va_end 产生代码,例如 gcc 在 linux 的 x86 平台就是这样定义的。在这里大家要注意一个问题:由于参数的地址用于 va_start 宏,所以参数不能声明为寄存器变量或作为函数或数组类型。
这里有两个地方需要深入挖掘一下:
1、
#define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )
我们这里简化一下这个宏:
#define _INTSIZEOF(n) ((sizeof(n) + x) & ~(x))
x = sizeof(int) - 1 = 3 = 0000 0000 0000 0011(b)
~x = 1111 1111 1111 1100(b)
当一个数 & (-x)时,得到的值始终是sizeof(int)的倍数,也就是说_INTSIZEOF(n)的功能是将n圆整(四舍五入)到sizeof(int)的倍数上去。sizeof(n) >= 1, sizeof(n)+sizeof(int)-1 经过圆整后,一定会是>=4 的整数。在其他系统平台上,圆整的目标值有的是 4,有的则是 8,视具体系统而定。
2、
#define va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
其实有了var_args_func的实现,这里也就不难理解了。不过这里有一个trick,很多人一开始肯定对先加上_INTSIZEOF(t),又减去_INTSIZEOF(t)很不理解,其实这里是一点就透的,整个表达式:
(ap += _INTSIZEOF(t)) - _INTSIZEOF(t)
返回的值其实和最初的ap所指向的地址是一致的,关键就是在整个表达式被evaluated后,ap却指向了下一个参数的地址了,就这么简单。
C 语言的函数是从右向左压入堆栈的,图(1)是函数的参数在堆栈中的分布位置.我们看到va_list 被定义成 char*,有一些平台或操作系统定义为 void*。再看 va_start 的定义,定义为:
&v+_INTSIZEOF(v)
而&v 是固定参数在堆栈的地址,所以我们运行 va_start(ap, v)以后,ap 指向第一个可变参数在堆栈的地址,如图:
然后,我们用 va_arg()取得类型 t 的可变参数值,以上例为 int 型为例,我们看一下 va_arg 取 int型的返回值:
j=(*(int*)((ap += _INTSIZEOF(int))-_INTSIZEOF(int)));
首先 ap+=sizeof(int),已经指向下一个参数的地址了.然后返回 ap-sizeof(int)的 int*指针,这正是第一个可变参数在堆栈里的地址(图 2).然后用*取得这个地址的内容(参数值)赋给 j。
最后要说的是 va_end 宏的意思,x86 平台定义为 :
ap=(char*)0;
使 ap 不再指向堆栈,而是跟NULL 一样。有些直接定义为
(void*)0
,这样编译器不会为 va_end 产生代码,例如 gcc 在 linux的 x86 平台就是这样定义的。
在这里大家要注意一个问题:由于参数的地址用于 va_start 宏,所以参数不能声明为寄存器变量或作为函数或数组类型。
关于 va_start,va_arg,va_end 的描述就是这些了,我们要注意的是不同的操作系统和硬件平台的定义有些不同,但原理却是相似的。
可变参数在编程中要注意的问题
因为va_start, va_arg, va_end等定义成宏,所以它显得很愚蠢。
可变参数的类型和个数完全在该函数中由程序代码控制,它并不能智能地识别不同参数的个数和类型。
有人会问:那么 printf 中不是实现了智能识别参数吗?那是因为函数
printf是从固定参数 format 字符串来分析出参数的类型,再调用 va_arg 的来获取可变参数的。
也就是说,你想实现智能识别可变参数的话是要通过在自己的程序里作判断来实现的。
另外有一个问题,因为编译器对可变参数的函数的原型检查不够严格,对编程查错不利。如果:
simple_va_fun()
改为
void simple_va_fun(int i, ...)
{
va_list arg_ptr;
char *s=NULL;
va_start(arg_ptr, i);
s=va_arg(arg_ptr, char*);
va_end(arg_ptr);
printf("%d %s\n", i, s);
return 0;
}
可变参数为 char*型,当我们忘记用两个参数来调用该函数时,就会出现 core dump(Unix) 或者页面非法的错误(window 平台)。也有可能不出错,但错误却是难以发现,不利于我们写出高质量的程序。
以下提一下 va 系列宏的兼容性
System V Unix 把 va_start 定义为只有一个参数的宏:
va_start(va_list arg_ptr);
而 ANSI C 则定义为:
va_start(va_list arg_ptr, prev_param);
如果我们要用system V 的定义,应该用vararg.h 头文件中所定义的宏,ANSI C 的宏跟system V的宏是不兼容的,我们一般都用 ANSI C,所以用 ANSI C 的定义就够了,也便于程序的移植。
小结:
可变参数的函数原理其实很简单,而 va 系列是以宏定义来定义的,实现跟堆栈相关。我们写一个可变函数的 C 函数时,有利也有弊,所以在不必要的场合,我们无需用到可变参数。如果在 C++里,我们应该利用 C++的多态性来实现可变参数的功能,尽量避免用 C 语言的方式来实现。
下面是一个简单的 printf 函数的实现:
#include "stdio.h"
#include "stdlib.h"
void myprintf(char* fmt, ...) //一个简单的类似于 printf 的实现,//参数必须都是 int 类型
{
char* pArg = NULL; //等价于原来的 va_list
char c;
pArg = (char*) &fmt; //注意不要写成 p = fmt !!因为这里要对//参数取址,而不是取值
pArg += sizeof(fmt); //等价于原来的 va_start
do
{
c =*fmt;
if (c != '%')
{
putchar(c); //照原样输出字符
}
else
{
//按格式字符输出数据
switch(*++fmt)
{
case 'd':
printf("%d",*((int*)pArg));
break;
case 'x':
printf("%#x",*((int*)pArg));
break;
default:
break;
}
pArg += sizeof(int); //等价于原来的 va_arg
}
++fmt;
}while (*fmt != '\0');
pArg = NULL; //等价于 va_end
return;
}
int main(int argc, char* argv[])
{
int i = 1234;
int j = 5678;
myprintf("the first test:i=%d\n",i,j);
myprintf("the secend test:i=%d; %x;j=%d;",i,0xabcd,j);
system("pause");
return 0;
}
在 intel + win2k + vc6 的机器执行结果如下:
the first test:i=1234
the secend test:i=1234; 0xabcd;j=5678;