亲密接触C可变参数函数
本文从程序员实践的角度来剖析C可变参数函数在Intel 32位CPU上的实现与原理
作者:林海枫
网址:http://blog.csdn.net/linyt/archive/2008/04/02/2243605.aspx
[*]欢迎转载,但请完整转载并注明作者以及地址,请勿用于任何商业用途。
可变参数函数的实现
如果说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 <stdarg.h>
#include <stdio.h>
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 <stdio.h>
#include <stdarg.h>
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中的栈元素大小为