作者:林海枫
网址:http://blog.csdn.net/linyt/archive/2008/04/02/2243605.aspx
[*]欢迎转载,但请完整转载并注明作者以及地址,请勿用于任何商业用途。
可变参数函数的实现
printf函数的原型定义如下:
int printf(const char *format, ...);
与此类似,C语言的可变参数函数的定义如下:
type fun( type arg1, type arg2, ...);
其中type表示类型,arg1, arg2表示参数名,而最重要的是可变参数函数的参数列表中出现了“...”符号。符号“...”用来表示参数的个数以及相应的类型都是可变的,相当于多个参数的占位符,可为0个,1个或多个参数,并且要求“...”前至少有一个参数,并且它的后面不能再出现参数。
[1]当用户调用时,运行时每个参数值的难以获得,在普通函数中,通过形参即可获得,但是在可变参数函数的参数列表中只有"...",而不知各个形参的名字。
[2]可变参数的个数是不确定的,虽然可以通过前面的参数来确定后面的可变参数的个数和类型(如sum函数通过num参数来表明后面可变参数的个数,printf函数通过format来决定可变参数的个数以及它的类型),但是这个函数定义的语义问题,C的编译器不能检测到任何相关的错误,并且也可能运行时也可能捕捉不到相关的错误。
如果读者对CPU有相当的了解或者对C语言函数调用的约定熟悉,或者对汇编的经验,那么用C语言(或结合汇编)来写一个可变参数函数并不是很难的。显然,结合汇编来实现可变参数函数会降低程序的可移植性。为了保持C语言的较好的移植性,ANSIC标准制订了可移植的可变参数函数的实现方法。该标准制定了一个专门用于处理可变参数的头文件stdarg.h,为了确保可移植性,该文头件对实现可变参数函数提供三个宏和一种隐式的数据类型。
提供的三个宏分别如下:
void va_start(va_list ap, last);
type va_arg(va_list ap, type);
void va_end(va_list ap);
这种隐式的数据类型是va_list。
上面宏的前缀va表示variable argument,即可变参数的意思。变量ap专门用来记录获取可变参数。下面依次介绍三个宏的意义和用法。
[*] void va_start( va_list ap, last)
[*] type va_arg(va_list ap, type)
[*]void va_end(va_list ap)
然而ANSIC制定的标准只解决上面遇到问题的第一个,而第二个关于可变参数的个数属于语义问题仍然要通过其它方法来处理。因此很容易就可以实现求和函数sum。
#include
#include
int sum(int num, int arg, ...);
int sum(int num, int arg, ...)
{
}
在sum函数中,我们对调用者(caller)有如下的约定:可变参数的类型必须为整形,否则结果不可知;参与求和参数的个数与参数num要一致,否则结果不可知。
下面是比较复杂的函数foo,它与printf相同有几分相似,代码如下:
#include
#include
void foo(char *fmt, ...)
va_list ap;
int d;
char c, *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);
}
foo函数通过第一个字符串参数fmt的内容来决定后面可变参数的个数以及它的类型。如何来确定可变参数的个数,通常是由函数的实现来约定的,与C语言的标准是无关的。如printf函数,是通过第一个参数来决定可变参数的个数和相应的类型。当然,这不是唯一的。在Unix环境的系统函数中,有些是通过判断参数值是否为(char *)0来决定最后一个可变参数。这些函数中以exec函数族最为典型。下面是execl函数的声明:
通过上面两个例子,大家对如何写可变参数函数有一定的感性和理性理解。其实实现可变参数函数可以不使用标准库(stdarg.h)里面所定义的方法,只要你对CPU和C语言的调用约定有相当的了解就足够了。下面是我在Intel32位CPU下使用自己的方法来重写sum,把新的函数称名为sum_intel。代码如下:
int sum_intel(int num, int arg, ...)
{
}
sum_intel函数利用了Intel CPU和C语言的一些特性。首先是Intel CPU的栈是向下生长的,C语言中调用约定为:从最后一个参数开始压栈,栈的清理由调用者来负责,同时Intel CPU的对边界对齐也对它有一定的影响。上面代码可以简单分析为:int* arg_p = &arg+1语句,使用arg_p指向arg的下一个参数,并且arg_p++使得它依次指向下一个参数,而*arp_p获得它每指向参数的值。如果把上面的代码放到某个CPU中,该CPU的栈是向上生长的,那么该代码肯定是运行不正确,除非改变C语言函数的调用约定。上面的代码没有涉及了数据对齐的细节,如果参数传递进来的不是int型,而是其它数据类型(特别是用户定义类型),这会涉及到对齐问题,而不同CPU的对齐方式是不一样。因此,上面的函数基本是不可移植的。
C语言具有很好的可移植性,因此我们的代码也尽量保持较好移植性。那么写可变参数函数时使用标准库是方式法是很有必要的,它会提供代码的可移植性,从而使用在不同架构的CPU上都可以运行。
可变参数函数实现的原理
要清楚要分析可变参数函数实现的原理,至少要清楚以下内容:
[1]函数调用栈的生长方向,栈元素大小和对齐方向
[2]C语言的调用约定
图1
从上面压栈前后的两个图可明显看到栈的生长方向,在Intel 32位的CPU中,windown或linux都使用了它的保护模式,ss指定栈所有在的段,ebp指向栈基址,esp指向栈顶。显然执行push指令后,esp的值会减4,而pop后,esp值增加4。
C语言的函数调用约定对编写可变参数函数是非常重要的,只有清楚了,才更欲心所欲地控制程序。在高级程序设计语言中,函数调用约定有如下几种,stdcall,cdecl,fastcall ,thiscal,naked call。cdel是C语言中的标准调用约定,如果在定义函数中不指明调用约定(在函数名前加上约定名称即可),那编译器认为是cdel约定,从上面的几种约定来看,只有cdel约定才可以定义可变参数函数。下面是cdel约定的重要特征:如果函数A调用函数B,那么称函数A为调用者(caller),函数B称为被调用者(callee)。caller把向callee传递的参数存放在栈中,并且压栈顺序按参数列表中从右向左的顺序;callee不负责清理栈,而是由caller清理。
void callee(int a, int b)
{
}
void caller()
{
}
注:上述AT&格式的汇编代码采用movl而非pushl指令,但它的功能是一样,都是把参数压到栈顶上。
函数栈如图2(a)所示。接着跳到callee函数,即指令call calle。CPU在执行call时,先把当前的EIP寄存器的值压到栈中,然后把EIP值设为callee(地址),这样,栈的图变为如图2(b)。程序执行点跳到了callee函数的第一条指令。C语言在函数调用时,每个函数占用的栈段称为stack frame。用ebp来记住函数stack frame的起始地址。故在执行callee时,最前的两条指令为:
[nasm代码]
push ebp
mov ebp, esp
[AT&T代码]
pushl
经过这两条语句后,callee函数的stack frame就建好了,栈的最新情况如图2(c)所示。
[nasm代码]
sub esp, 4
mov [ebp-4], 0
[AT&T代码]
subl
这样栈的情况又发生了变化,最新情况如图2(d)所示。注意esp总是指向栈顶,而ebp作为函数的stack frame基址起到很大的作用。ebp地址向下的空间用于存放局部变量,而它向上的空间存放的是caller传递过来的参数,当然编译器会记住变量c相对ebp的地址偏移量,在这里为-4。跟着执行c = a + b语句,那么指令代码应该类似于:
[Nasm代码]
mov eax , [ebp +
add eax,
mov [ebp -4], eax
[AT&T代码]
movl
mov esp, ebp
pop ebp
在Intel CPU里上面两条指令可以用指令leave来代替,功能是一样。这样栈的内容如图2(f)所示。最后,要返回到caller函数,因此callee的最后一条指令是
ret
ret指令用于把栈上的保存的断点弹出到EIP寄存器,新的栈内容如图2(g)所示。函数callee的调用与返回全部结束,跟着下来是执行call callee的下一条语句。
从caller函数调用callee前,把传递的参数压到栈中,并且按从右到左的顺序;函数返回时,callee并不清理栈,而是由caller清楚传递参数所占用的栈(如上图,函数返回时,1和2还放在栈中,让caller清理)。栈元素的大小为4个字节,每个参数占用栈空间大小为4字节的倍数,并且任何两个参数都不能共用同一个栈元素。
到这里,函数调用与栈的故事似乎讲完了,要开始分析可变参数函数的原理了。从C语言的函数调用约定可知,参数列表从右向左依次压栈,故可变参数压在栈的地址比最后一个命名参数还大,如下图3所示:
图3
由图3可知,最后一个命名参数a上面都放着可变参数,每个参数占用栈的大小必为4的倍数。因此:可变参数1的地址
上面式子+号的左边都可从上一个式子得到,关键是右边要求进行计算。每个参数都可以由其类型来决定大小,再结合每个参数必定占用大小为4的倍数的栈,因此可用如下公式来计算:occupy_stack(type) = (sizeof(type) + sizeof(int) - 1) & (~sizeof(int))
依此根据上面的公式则,stdarg.h中几个宏的原理就跃然于纸上了。
va_list
va_list是一个隐式类型,意味开发人员不必细研它的具体类型,只要使用va_list类型来出现就不会出错。va_list类型是用于记录可变参数的地址。
va_start(va_list ap, last)
last为最后一个命名参数,va_start宏使ap记录下第一个可变参数的地址,原理与“可变参数1的地址
va_arg(va_lit ap, type)
这里是获得可变参数的值,具体工作是:从ap所指向的栈内存中读取类型为type的参数,并让ap根据type的大小记录它的下一个可变参数地址,便于再次使用va_arg宏。从ap记录的内存地址开始,认为存的数据类型为type并把它的值读出来;把ap记录的地址指向下一个参数,即ap记录的地址
va_end(va_list ap)
用于“释放”ap变量,它与va_start对称使用。在同一个函数内有va_start必须有va_end
谈到这里,大家都对上面的三个宏和va_list有清楚的认识。下面是VC++6.0编译器对va_list和三个宏在Intel CPU下的实现。
typedef char *va_list;
#define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )
#define va_start(ap,v) ( ap = (va_list)&v + _INTSIZEOF(v) )
#define va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
#define va_end(ap) ( ap = (va_list)0 )
在VC++的实现中,把va_list定义为char *类型,这样va_list就是一个地址,它可以指向4G线性空间的任间一个地址。
可变参数函数相关话题
int printf(const char *format, ...);
int vprintf(const char *format, va_list ap);
第一个函数用"..."表示可变参数,第二个用va_list类型表示可变参数,目的是用于被其它可变参数调用,两者在功能功能上是完全上一样。只是在函数名字相差一个'"v"字母。写一个专门用于调试输出的函数,那就非常方便了:
int debug_log(const char * fmt, ...)
{
}
int debug_log(const char * fmt, ...)
{
}
可变参数函数作为C语言的特性之一,在C99标准增加宏va_copy,足以见它还是被受关注的。它的实现原理因CPU架构而异,本文主要介绍在Intel32位CPU上的实现;其它的CPU实现原理与此类似,但背后的机制与原理却是相同的。