2008年(126)
分类: C/C++
2008-04-30 22:24:14
亲密接触C可变参数函数
本文从程序员实践的角度来剖析C可变参数函数在Intel 32位CPU上的实现与原理
作者:林海枫
[*]欢迎转载,但请完整转载并注明作者以及地址,请勿用于任何商业用途。
可变参数函数的实现
如果说C语言具有很多低级语言的特性,那么可变参数函数便是这些特性中的一个。无论是C专家还是C初学者,都对printf标准库函数相当了解,因为它是我们步入C语言的第一个函数。使用printf函数时,就不知不觉地接触到C语言的可变参数函数机制。
printf函数的原型定义如下:
int printf(const char *format, ...);
与此类似,C语言的可变参数函数的定义如下:
type fun( type arg1, type arg2, ...);
其中type表示类型,arg1, arg2表示参数名,而最重要的是可变参数函数的参数列表中出现了“...”符号。符号“...”用来表示参数的个数以及相应的类型都是可变的,相当于多个参数的占位符,可为0个,1个或多个参数,并且要求“...”前至少有一个参数,并且它的后面不能再出现参数。 C语言提供可变参数函数可以根据实际的需要来实现参数个数和类型为可变的情况,在C标准库库中以printf最为出名。而在Unix环境中,exec家族函数就是最好的例证。在此以一个求和函数(sum)来讨论如何实现可变参数函数的实现。sum函数的目标,用于实现可变个整数求和,函数原型:int sum(int num, ...)。用户在使用该函数是非常方便的,只需指定求和的个数以及每个参数,通过调用返回求和的值。但是从以前使用的方法来实现,那是非常因难的,这是因为:
[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)
last为函数形参中"..."前的最后一个形参名字,宏va_start用于根据last的位置(或指针)来初始化变量ap,以供宏ar_arg来依次获得可变参数的值。变量ap在被va_arg或va_end使用前,必须使用va_start初始化。
[*] type va_arg(va_list ap, type)
va_arg宏用来获得下一个参数的值,type为该参数的类型,它的参数ap必须被va_start初始化,通过该宏后,返回参数值并使用ap指向下一个参数,以供va_arg再次使用。如果没有下一个参数时调用va_arg或arg指定的类型不兼容时,会产生可知的错误。
[*]void va_end(va_list ap)
宏va_end与va_start必须要同一函数里面对称使用,调用va_start(ap,last)后ap得到初始化,在完成参数处理后,需要调用va_end(ap)来“释放”ap。
然而ANSIC制定的标准只解决上面遇到问题的第一个,而第二个关于可变参数的个数属于语义问题仍然要通过其它方法来处理。因此很容易就可以实现求和函数sum。
//sum的目标是计算可变个参数的知,要求可变参数的类型是整型的,现不考虑结果溢出的情况。
#include
#include
int sum(int num, int arg, ...);
int sum(int num, int arg, ...)
{
if(num < 1) return 0;
va_list ap;
int n = num;
va_start(ap, arg); //初始化ap
int summary = arg;
while(--n > 0)
{
summary += va_arg(ap, int); //遍历所有可变参数的值
}
va_end(ap); //“释放”ap,与va_start对称出现
return summary;
}
在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函数的声明: int execl(const char *path, const char *arg, ...)。根据该函数的调用约定,用户应按如下的方式来调用: execl("/usr/bin/ls", "ls", "-l", (char *)0)。最后一个参数(char *)0仅用于标志可变参的结束。
通过上面两个例子,大家对如何写可变参数函数有一定的感性和理性理解。其实实现可变参数函数可以不使用标准库(stdarg.h)里面所定义的方法,只要你对CPU和C语言的调用约定有相当的了解就足够了。下面是我在Intel32位CPU下使用自己的方法来重写sum,把新的函数称名为sum_intel。代码如下:
int sum_intel(int num, int arg, ...)
{
if( num < 1) return 0;
int n = num;
int summary = arg;
int* arg_p = &arg + 1;
while(--n > 0)
{
summary += *arg_p;
arg_p++;
}
return summary;
}
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上都可以运行。
可变参数函数实现的原理
如果对“标准”二字理解不清楚肯定会在心里打起锣鼓,使用准标里的方法是否真的可以在不同的CPU上运行。答案是肯定的。ANSIC为可变参数函数提供了标准的头文件stdarg.h,只是一种约定(机制),而非是实现(策略)。ANSIC制定的C语言的标准(规范,specification)和一些标准库,而每个C编译器必须遵循这些标准,并且提供标准库的实现。这样使用标准库接口(函数或宏)的代码,是可跨平台的,但是它所调用的库代码会根据不同的CPU而实现不同。但提供的功能与却是等同的。在面向对象程序设计里面的设计思想“面向接口编程而非实现”,在这里可以深刻地体会出来。使用标准库接口的代码,可以在不同的CPU下编译而不用作任何修改,如上述sum函数可以在不同的CPU上编译通过,而且能正确实现它的功能。
要清楚要分析可变参数函数实现的原理,至少要清楚以下内容:
[1]函数调用栈的生长方向,栈元素大小和对齐方向
[2]C语言的调用约定
由于不同的CPU会对实现有不同,在此以Intel 32位的CPU为分析基础。在Intel CPU中,栈的生长方向是向下的,即栈底在高地址,而栈顶在低地址;从栈底向栈顶看过去,地址是从高地址走向低地址的,因为称它为向下生长,图1显示了这种特性。
图1 某系统或应用程序执行push e语句,栈的变化图。
从上面压栈前后的两个图可明显看到栈的生长方向,在Intel 32位的CPU中,windown或linux都使用了它的保护模式,ss指定栈所有在的段,ebp指向栈基址,esp指向栈顶。显然执行push指令后,esp的值会减4,而pop后,esp值增加4。 栈中每个元素存放空间的大小决定push或pop指令后esp值增减和幅度。Intel 32位CPU中的栈元素大小为16位或32位,由定义堆栈段时定义。在Window和Linux系统中,内核代码已定义好栈元素的大小为32位,即一个字长(sizeof(int))。因此用户空间程栈元素的大小肯定为32位,这样每个栈元素的地址向4字节对齐。
C语言的函数调用约定对编写可变参数函数是非常重要的,只有清楚了,才更欲心所欲地控制程序。在高级程序设计语言中,函数调用约定有如下几种,stdcall,cdecl,fastcall ,thiscal,naked call。cdel是C语言中的标准调用约定,如果在定义函数中不指明调用约定(在函数名前加上约定名称即可),那编译器认为是cdel约定,从上面的几种约定来看,只有cdel约定才可以定义可变参数函数。下面是cdel约定的重要特征:如果函数A调用函数B,那么称函数A为调用者(caller),函数B称为被调用者(callee)。caller把向callee传递的参数存放在栈中,并且压栈顺序按参数列表中从右向左的顺序;callee不负责清理栈,而是由caller清理。 我们用一个简单的例子来说明问题,并采用Nasm的汇编格式写相应的汇编代码,程序段如下:
void callee(int a, int b)
{
int c = 0;
c = a +b;
}
void caller()
{
callee(1,2);
}
来分析一下在调用过程发生了什么事情。程序执行点来到caller时,那将要执行调用callee函数,在跳到callee函数前,它先要把传递的参数压到栈上,并按右到左的顺序,即翻译成汇编指令就是
push 2
push 1
图2 函数栈的变化图
函数栈如图2(a)所示。接着跳到callee函数,即指令call calle。CPU在执行call时,先把当前的EIP寄存器的值压到栈中,然后把EIP值设为callee(地址),这样,栈的图变为如图2(b)。程序执行点跳到了callee函数的第一条指令。C语言在函数调用时,每个函数占用的栈段称为stack frame。用ebp来记住函数stack frame的起始地址。故在执行callee时,最前的两条指令为:
push ebp
mov esp, ebp
经过这两条语句后,callee函数的stack frame就建好了,栈的最新情况如图2(c)所示。 函数callee定义了一个局部变量int c,该变量的储存空间分配在callee函数占用的栈中,大小为4字节(insizeof int)。那么callee会在如下指令:
sub esp, 4
mov [ebp-4], 0
这样栈的情况又发生了变化,最新情况如图2(d)所示。注意esp总是指向栈顶,而ebp作为函数的stack frame基址起到很大的作用。ebp地址向下的空间用于存放局部变量,而它向上的空间存放的是caller传递过来的参数,当然编译器会记住变量c相对ebp的地址偏移量,在这里为-4。跟着执行c = a + b语句,那么指令代码应该类似于:
mov eax , [ebp + 8] ;这里用eax存放第一个传递进来的参数,记住第一个参数与ebp的偏移量肯定为8
add eax, [ebp + 12] ;第二个参数与ebp的偏移量为12,故计算eax = a+b
mov [ebp -4], eax ;执行 c = eax, 即c = a+b
栈又有了新了变化,如图2(e)。至此,函数callee的计算指令执行完毕,但还要做一些事情:释放局部变量占用的栈空间,销除函数的stack-frame过程会生成如下指令:
mov esp, ebp ;把局部变量占用的空间全部略过,即不再使用,ebp以下的空间全部用于局部变量
pop ebp ;弹出caller函数的stack-frame 基址
在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的地址 = 参数a的地址 + a占用栈的大小,可变参数2的地址 = 可变参数1的地址 + 可变参数1占用栈的大小,可变参数3的地址 = 可变参数2的地址 + 可变参数2占用栈的大小,依此类推。
上面式子+号的左边都可从上一个式子得到,关键是右边要求进行计算。每个参数都可以由其类型来决定大小,再结合每个参数必定占用大小为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的地址 = 参数a的地址 + a占用栈的大小”相同。从ap记录的内存地址开始,认为参数的数据类型为type并把它的值读出来;把ap记录的地址指向下一个参数,即ap记录的地址 += occupy_stack(type)
va_arg(va_lit ap, type)
这里是获得可变参数的值,具体工作是:从ap所指向的栈内存中读取类型为type的参数,并让ap根据type的大小记录它的下一个可变参数地址,便于再次使用va_arg宏。从ap记录的内存地址开始,认为存的数据类型为type并把它的值读出来;把ap记录的地址指向下一个参数,即ap记录的地址 += occupy_stack(type)
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就是一个地址,它可以指向
在VC++的实现中,考虑了参数大小和数据对齐问题,使得可变参数的类型不但可以是基本类型,同样适用于用户定义类型。值的注意的是,如果是用户定义类型,最好用typedef定义的名字作为类型名,这样就会减少在va_arg进行宏展开时出错的机率。
可变参数函数相关话题
va_start, va_arg和va_end是C可变参数函数的三剑客,这一事实在ANSIC标准已确立了。ANSIC99标准在此基础上提供了一个新的宏va_copy,它用于拷贝两个va_list变量,定义如下:
void va_copy(va_list dst, va_list src);
va_copy别无它意,只希望程序具有更高的可移植性和安全性,因此在拷贝va_list变量时,必须使用va_copy宏进行,否则在不同的实现上会产生不可预知结果,甚至出错。和va_start相同,由va_copy初始化的va_list在使用结束时必须使用va_end来“释放”。
除了不同的实现引起它相应的va_copy不同之外,还有问题也值得去关注:如何把可变参数传递给下一个可变参数函数。
在可变参数函数中,由va_list变量来记录(或获得)可变参数部分,但是va_list中并没有记录下它们的名字,事实上也是不可能的。要想把可变参数部分传递给下一个函数,唯有通过va_list变量去传递,而可原来定义的函数用"..."来表示可变参数部分,而不是用va_list来表示。为了方便程序的标准化,ANSIC在标准库代码中就作出了很好的榜样:在任何形如: type fun( type arg1, type arg2, ...)的函数,都同时定义一个与它功能完成一样的函数,但用va_list类型来替换"...",即type fun(type arg1, type arg2, va_list ap)。以printf函数为例:
int printf(const char *format, ...);
int vprintf(const char *format, va_list ap);
第一个函数用"..."表示可变参数,第二个用va_list类型表示可变参数,目的是用于被其它可变参数调用,两者在功能功能上是完全上一样。只是在函数名字相差一个'"v"字母。写一个专门用于调试输出的函数,那就非常方便了:
int debug_log(const char * fmt, ...)
{
va_list ap;
va_start(ap, fmt);
int n = vpintf(fmt, ap);
va_end(ap);
return n;
}
值得注意的是,在实现vprintf这类函数时,在函数里面不能对传递进来的va_list调用va_end宏,并且在函数调用完后va_list变量的值同样是不可知的,应该马上调用va_end;如果想在被调用函数返回后继续使用,应先用va_copy拷贝一份再传给函数。下面就是一个很好的例子:
int debug_log(const char * fmt, ...)
{
va_list ap;
va_start(ap, fmt);
va_list another;
va_copy(another, ap);
int n = vpintf(fmt, another);
va_end(another);
... //继续使用ap
va_end(ap);
return n;
}
小结