C语言的变长参数在平时做开发时很少会在自己设计的接口中用到,但我们最常用的接口printf就是使用的变长参数接口,在感受到printf强大的魅力的同时,是否想挖据一下到底printf是如何实现的呢?这里我们一起来挖掘一下C语言变长参数的奥秘。

先考虑这样一个问题:如果我们不使用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的地址,并通过函数原型声明了解到aint类型的; 通过&b我们可以得到b的地址,并通过函数原型声明了解到bdouble类型的; 通过&c我们可以得到c的地址,并通过函数原型声明了解到cchar*类型的。

但是对于变长参数的函数,我们就没有这么顺利了。还好,按照C标准的说明,支持变长参数的函数在原型声明中,必须有至少一个最左固定参数(这一点与传统C有区别,传统C允许不带任何固定参数的纯变长参数函数),这样我们可以得到其中固定参数的地址,但是依然无法从声明中得到其他变长参数的地址,比如:
void var_args_func(const char * fmt, ... )
{
    ... ...
}
这里我们只能得到fmt这固定参数的地址,仅从函数原型我们是无法确定"..."中有几个参数、参数都是什么类型的,自然也就无法确定其位置了。那么如何可以做到呢?在大脑中回想一下函数传参的过程,无论"..."中有多少个参数、每个参数是什么类型的,它们都和固定参数
的传参过程是一样的,简单来讲都是栈操作,而栈这个东西对我们是开放的。这样一来,一旦我们知道某函数帧的栈上的一个固定参数的位置,我们完全有可能推导出其他变长参数的位置,顺着这个思路,我们继续往下走,通过一个例子来诠释一下:(这里要说明的是:函数参数进栈以及参数空间地址分配都是"实现相关"的,不同平台、不同编译器都可能不同,所以下面的例子仅在IA-32Windows 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消息中的格式字符串来判断变参的个数和类型,而是直接在实现中写死了,如果你把这个程序拿到solaris 9下,运行后,一定得不到正确的结果,为什么呢,后续再说。先来解释一下这个程序。我们用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_ptrtmp_ptr所占据的4个字节显然不是字符串,而是一个地址。如何让&tmp_ptrchar **类型的,我们将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_funcvar_args_func的实现,va_list似乎就是char* va_start似乎就是 ((char*)&fmt) + sizeof(fmt)va_arg似乎就是得到下一个参数的首地址。没错,多数平台下stdarg.hva_list, va_startvar_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平台间做移植即可,没有必要考虑太多的平台情况)

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中被定义成宏的,由于:
1)硬件平台的不同
2)编译器的不同
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指向第一个可变参数在堆栈的地址,如图:
高地址|-------------------------------------------|
|函数返回地址                |
|-------------------------------------------|
|…….                      |
|-------------------------------------------|
|n个参数(第一个可变参数)    |
|-------------------------------------------|<--va_startap指向
|n-1个参数(最后一个固定参数)|
低地址|-------------------------------------------|<-- &v
            ( 1 )
然后,我们用va_arg()取得类型t的可变参数值,以上例为int型为例,我们看一下va_argint型的返回值:    j= ( *(int*)((ap += _INTSIZEOF(int))-_INTSIZEOF(int)) );
首先ap+=sizeof(int),已经指向下一个参数的地址了.然后返回ap-sizeof(int)int*指针,这正是第一个可变参数在堆栈里的地址(2).然后用*取得这个地址的内容(参数值)赋给j.
高地址|--------------------------------------------|
|函数返回地址                |
|--------------------------------------------|
|…….                        |
|--------------------------------------------|<--va_argprintf函数是如何实现的后ap指向
|n个参数(第一个可变参数)    |
|--------------------------------------------|<--va_startap指向
|n-1个参数(最后一个固定参数)|
低地址|--------------------------------------------|<-- &v
            ( 2 )
最后要说的是va_end宏的意思,x86平台定义为ap=(char*)0;使ap不再指向堆栈,而是跟NULL一样.有些直接定义为((void*)0),这样编译器不会为va_end产生代码,例如gcclinuxx86平台就是这样定义的.
在这里大家要注意一个问题:由于参数的地址用于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 Unixva_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研究

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。