1 函数声明
首先,要实现类似printf()的变参函数,函数的最后一个参数要用 ... 表示,如
int log(char * arg1, ...)
这样编译器才能知道这个函数是变参函数。这个参数与变参函数的内部实现完全没有关系,只是让编译器在编译调用此类函数的语句时不计较参数多少老老实实地把全部参数压栈而不报错,当然...之前至少要有一个普通的参数,这是由实现手段限制的。
2 函数实现
C语言通过几个宏实现变参的寻址。下面是linux2.18内核源码里这几个宏的定义,相信符合C89,C99标准的C语言基本都是这样定义的。
typedef char *va_list;
#define _AUPBND (sizeof (acpi_native_uint) - 1)
#define _ADNBND (sizeof (acpi_native_uint) - 1)
#define _bnd(X, bnd) (((sizeof (X)) + (bnd)) & (~(bnd)))
#define va_arg(ap, T) (*(T *)(((ap) += (_bnd (T, _AUPBND))) - (_bnd (T,_ADNBND))))
#define va_end(ap) (void) 0
#define va_start(ap, A) (void) ((ap) = (((char *) &(A)) + (_bnd (A,_AUPBND))))
下面以x86 32位机为例分析这几个宏的用途
要理解这几个宏需要对C语言如何传递参数有一定了解。与PASCAL相反,与stdcall 相同,C语言传递参数时是用push指令从右到左将参数逐个压栈,因此C语言里通过栈指针来访问参数。虽然X86的push一次可以压2,4或8个字节入栈,C语言在压参数入栈时仍然是机器字的size为最小单位的,也就是说参数的地址都是字对齐的,这就是_bnd(X,bnd)存在的原因。另外补充一点常识,不管是汇编还是C,编译出的X86函数一般在进入函数体后立即执行
push ebp
mov ebp, esp
这两条指令。首先把ebp入栈,然后将当前栈指针赋给ebp,以后访问栈里的参数都使用ebp作为基指针。
一一解释这几个宏的作用。
l _bnd(X,bnd),计算类型为X的参数在栈中占据的字节数,当然是字对齐后的字节数了。acpi_native_unit是一个机器字,32位机的定义是:typedef u32 acpi_native_uint;
显然,_AUPBND ,_ADNBND 的值是 4-1 == 3 == 0x00000003 ,按位取反( ~(bnd))就是0xfffffffc 。
因此,_bnd(X,bnd) 宏在32位机下就是
( (sizeof(X) + 3)&0xfffffffc )
很明显,其作用是--倘若sizeof(X)不是4的整数倍,去余加4。
_bnd(sizeof(char),3) == 4
_bnd(sizeof(struct size7struct),3) == 8
l va_start(ap,A),初始化参数指针ap,将函数参数A右边第一个参数的地址赋给ap。 A必须是一个参数的指针,所以此种类型函数至少要有一个普通的参数啊。像下面的例子函数,就是将第二个参数的指针赋给ap。
l va_arg(ap,T),获得ap指向参数的值,并使ap指向下一个参数,T用来指明当前参数类型。
注意((ap) += (_bnd (T, _AUPBND))) 是被一对括号括起来的,然后才减去(_bnd (T, _ADNBND),
而_AUPBND和_ADNBND是相等的。所以取得的值是ap当前指向的参数值,但是先给ap加了当前参数在字对齐后所占的字节数,使其指向了下一个参数。
l va_end(ap),作用是美观。
3 总结
先用一个 ... 参数声明函数是变参函数,接下来在函数内部以va_start(ap,A)宏初始化参数指针,然后就可以用va_arg(ap,类型)从左到右逐个获取参数值了
分析到此处算是一清二白了,下面给一个例子
int log(char * fmt,...)
{
va_list ap;
int d;
char c, *p, *s;
va_start(ap, fmt);
while (*fmt) {
switch(*fmt++) {
case 's':
s = va_arg(ap, char *);
printf("string %s/n", s);
break;
case 'd':
d = va_arg(ap, int);
printf("int %d/n", d);
break;
case 'c':
c = va_arg(ap, char);
printf("char %c/n", c);
break;
}
}
va_end(ap);
}
另外,还有一篇文章写得也姑且可以,但其实讲得并不清楚
http://learn.akae.cn/media/ch24s06.html
用可变参数实现简单的printf函数
#include <stdio.h>
#include <stdarg.h>
void myprintf(const char *format, ...)
{
va_list ap;
char c;
va_start(ap, format);
while (c = *format++) { //printf()中的格式化字符串,如printf("%s %c %d ",参数列表...)"%s %c %d "在这就相当于第一个参数即const char *format
switch(c) {
case 'c': { // 对应着%c
/* char is promoted to int when passed
through '...' */
char ch = va_arg(ap, int);
putchar(ch); //用真实的参数列表的值去填充格式化字符串的控制字符(如%c用真实值表达或者替换)
break;
}
case 's': { //对应着%s
char *p = va_arg(ap, char *); //所以用char*取出参数(%s)
fputs(p, stdout);
break;
}
default:
putchar(c); //输出(格式化字符串中)不是控制字符的字符
}
}
va_end(ap);
}
int main(void)
{
myprintf("c\ts\n", '1', "hello");
return 0;
}
要处理可变参数,需要用C到标准库的va_list类型和va_start、va_arg、va_end宏,这些定义在stdarg.h头文件中。这些宏是如何取出可变参数的呢?我们首先对照反汇编分析在调用myprintf函数时这些参数的内存布局。
myprintf("c\ts\n", '1', "hello");
80484c5: c7 44 24 08 b0 85 04 movl $0x80485b0,0x8(%esp)
80484cc: 08
80484cd: c7 44 24 04 31 00 00 movl $0x31,0x4(%esp)
80484d4: 00
80484d5: c7 04 24 b6 85 04 08 movl $0x80485b6,(%esp)
80484dc: e8 43 ff ff ff call 8048424 <myprintf>
myprintf函数的参数布局
这些参数是从右向左依次压栈的,所以第一个参数靠近栈顶,第三个参数靠近栈底。这些参数在内存中是连续存放的,每个参数都对齐到4字节边界。第一个和第三个参数都是指针类型,各占4个字节,虽然第二个参数只占一个字节,但为了使第三个参数对齐到4字节边界,所以第二个参数也占4个字节。
在myprintf中定义的va_list ap;其实是一个指针,va_start(ap, format)使ap指向format参数的下一个参数,也就是指向上图中esp+4的位置。然后va_arg(ap, int)把第二个参数的值按int型取出来,同时使ap指向第三个参数,也就是指向上图中esp+8的位置。然后va_arg(ap, char *)把第三个参数的值按char *型取出来,同时使ap指向更高的地址。
va_end(ap)在我们的简单实现中不起任何作用,在有些实现中可能会把ap改写成无效值,C标准要求在函数返回前调用va_end。
如果把myprintf中的
char ch = va_arg(ap, int);
改成
char ch = va_arg(ap, char);
,用我们这个stdarg.h的简单实现是没有问题的。但如果改用libc提供的stdarg.h,在编译时会报错:
$ gcc main.c
main.c: In function ‘myprintf’:
main.c:33: warning: ‘char’ is promoted to ‘int’ when passed through
‘...’
main.c:33: note: (so you should pass ‘int’ not ‘char’ to ‘va_arg’)
main.c:33: note: if this code is reached, the program will abort
$ ./a.out
Illegal instruction
因此要求char型的可变参数必须按int型来取。
从myprintf的例子可以理解printf的实现原理,printf函数根据第一个参数(格式化字符串)来确定后面有几个参数,分别是什么类型。保证参数的类型、个数与格式化字符串的描述相匹配是调用者的责任,实现者只管按格式化字符串的描述从栈上取数据,如果调用者传递的参数类型或个数不正确,实现者是没有办法避免错误的。
还有一种方法可以确定可变参数的个数,就是在参数列表的末尾传一个Sentinel,例如NULL。execl(3)就采用这种方法确定参数的个数。下面实现一个printlist函数,可以打印若干个传入的字符串。
根据Sentinel判断可变参数的个数
#include <stdio.h>
#include <stdarg.h>
void printlist(int begin, ...)
{
va_list ap;
char *p;
va_start(ap, begin);
p = va_arg(ap, char *);
while (p != NULL) {
fputs(p, stdout);
putchar('\n');
p = va_arg(ap, char*);
}
va_end(ap);
}
int main(void)
{
printlist(0, "hello", "world", "foo", "bar", NULL);
return 0;
}
printlist的第一个参数begin的值并没有用到,但是C语言规定至少要定义一个有名字的参数,因为va_start宏要用到参数列表中最后一个有名字的参数,从它的地址开始找可变参数的位置。实现者应该在文档中说明参数列表必须以NULL结尾,如果调用者不遵守这个约定,实现者是没有办法避免错误的。