首页 > 学院 > 开发设计 > 正文

C语言中可变参数va_list/va_start/value_arg/va_end的理解

2019-11-06 06:04:21
字体:
来源:转载
供稿:网友

va_list/va_start/va_arg/va_end这几个宏,都是用于函数的可变参数的。

我们来看看在vs2008中,它们是怎么定义的:

   1:  ///stdarg.h
   2:  #define va_start _crt_va_start
   3:  #define va_arg _crt_va_arg
   4:  #define va_end _crt_va_end
   5:   
   6:  ///vadefs.h
   7:  #define _ADDRESSOF(v)   ( &reinterPRet_cast<const char &>(v) )
   8:  typedef char *  va_list;
   9:  #define _INTSIZEOF(n)   ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )
  10:  #define _crt_va_start(ap,v)  ( ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v) )
  11:  #define _crt_va_arg(ap,t)    ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
  12:  #define _crt_va_end(ap)      ( ap = (va_list)0 )
 
再看看各个宏的功能是什么?va_list用于声明一个变量,我们知道函数的可变参数列表其实就是一个字符串,所以va_list才被声明为字符型指针,这个类型用于声明一个指向参数列表的字符型指针变量,例如:va_list ap;//ap:arguement pointerva_start(ap,v),它的第一个参数是指向可变参数字符串的变量,第二个参数是可变参数函数的第一个参数,通常用于指定可变参数列表中参数的个数。va_arg(ap,t),它的第一个参数指向可变参数字符串的变量,第二个参数是可变参数的类型。va_end(ap) 用于将存放可变参数字符串的变量清空(赋值为NULL).

  先来了解C语言中可变参数函数实现原理

C函数调用的栈结构

 可变参数函数的实现与函数调用的栈结构密切相关,正常情况下C的函数参数入栈规则为__stdcall, 它是从右到左的,即函数中的最右边的参数最先入栈。例如,对于函数:

  void fun(int a, int b, int c)  {        int d;        ...  }

其栈结构为

    0x1ffc-->d

    0x2000-->a

    0x2004-->b

    0x2008-->c

对于在32位系统的多数编译器,每个栈单元的大小都是sizeof(int), 而函数的每个参数都至少要占一个栈单元大小,如函数 void fun1(char a, int b, double c, short d) 对一个32的系统其栈的结构就是

    0x1ffc-->a  (4字节)(为了字对齐)

    0x2000-->b  (4字节)

    0x2004-->c  (8字节)

    0x200c-->d  (4字节)

因此,函数的所有参数是存储在线性连续的栈空间中的,基于这种存储结构,这样就可以从可变参数函数中必须有的第一个普通参数来寻址后续的所有可变参数的类型及其值。

先看看固定参数列表函数:

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的地址,并通过函数原型声明了解到a是int类型的。

   但是对于变长参数的函数,我们就没有这么顺利了。还好,按照C标准的说明,支持变长参数的函数在原型声明中,必须有至少一个最左固定参数(这一点与传统C有区别,传统C允许不带任何固定参数的纯变长参数函数),这样我们可以得到其中固定参数的地址,但是依然无法从声明中得到其他变长参数的地址,比如:

void var_args_func(const char * fmt, ...) {    ... ... }

这里我们只能得到fmt这固定参数的地址,仅从函数原型我们是无法确定"..."中有几个参数、参数都是什么类型的。回想一下函数传参的过程,无论"..."中有多少个参数、每个参数是什么类型的,它们都和固定参数的传参过程是一样的,简单来讲都是栈操作,而栈这个东西对我们是开放的。这样一来,一旦我们知道某函数帧的栈上的一个固定参数的位置,我们完全有可能推导出其他变长参数的位置。

我们先用上面的那个fixed_args_func函数确定一下入栈顺序。

复制代码
int main() {    fixed_args_func(17, 5.40, "hello world");    return 0;}a = 0x0022FF50b = 0x0022FF54c = 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);  根据这一结论我们试着实现一个支持可变参数的函数:

复制代码
#include <stdarg.h>#include <stdio.h>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");   return 0;}复制代码

期待输出结果:45hello world


  先来解释一下这个程序。我们用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_ptr,tmp_ptr所占据的4个字节显然不是字符串,而是一个地址。如何让&tmp_ptr是char **类型的,我们将ap进行强制转换(char**)ap <=> &tmp_ptr,这样我们访问tmp_ptr只需要在(char**)ap前面加上一个*即可,即printf("%s/n",  *(char**)ap);


   一切似乎很完美,编译也很顺利通过,但运行上面的代码后,不但得不到预期的结果,反而整个编译器会强行关闭(大家可以尝试着运行一下),原来是ap指针在后来并没有按照预期的要求指向第二个变参数,即并没有指向5所在的首地址,而是指向了未知内存区域,所以编译器会强行关闭。其实错误开始于:ap =  ap + sizeof(int);由于内存对齐,编译器在栈上压入参数时,不是一个紧挨着另一个的,编译器会根据变参的类型将其放到满足类型对齐的地址上的,这样栈上参数之间实际上可能会是有空隙的。(C语言内存对齐详解(1) C语言内存对齐详解(2) C语言内存对齐详解(3))所以此时的ap计算应该改为:ap =  (char *)ap +sizeof(int) + __va_rounded_size(int);

改正后的代码如下:

复制代码
#include<stdio.h>#define __va_rounded_size(TYPE)  /  (((sizeof (TYPE) + sizeof (int) - 1) / sizeof (int)) * sizeof (int))void var_args_func(const char * fmt, ...) {    char *ap;    ap = ((char*)&fmt) + sizeof(fmt);    printf("%d/n", *(int*)ap);              ap = (char *)ap + sizeof(int) + __va_rounded_size(int);    printf("%d/n", *(int*)ap);    ap = ap + sizeof(int) + __va_rounded_size(int);    printf("%s/n", *((char**)ap));}int main(){    var_args_func("%d %d %s/n", 4, 5, "hello world");     return 0;}复制代码

var_args_func只是为了演示,并未根据fmt消息中的格式字符串来判断变参的个数和类型,而是直接在实现中写死了。

为了满足代码的可移植性,C标准库在stdarg.h中提供了诸多便利以供实现变长长度参数时使用。这里也列出一个简单的例子,看看利用标准库是如何支持变长参数的:

复制代码
 1 #include <stdarg.h>#include <stdio.h> 2  3 void std_vararg_func(const char *fmt, ...) { 4         va_list ap; 5         va_start(ap, fmt); 6  7         printf("%d/n", va_arg(ap, int)); 8         printf("%f/n", va_arg(ap, double)); 9         printf("%s/n", va_arg(ap, char*));10 11         va_end(ap);12 }13 14 int main() {15         std_vararg_func("%d %f %s/n", 4, 5.4, "hello world");        return 0;}复制代码

对比一下 std_vararg_func和var_args_func的实现,va_list似乎就是char*, va_start似乎就是 ((char*)&fmt) + sizeof(fmt),va_arg似乎就是得到下一个参数的首地址。没错,多数平台下stdarg.h中va_list, va_start和var_arg的实现就是类似这样的。一般stdarg.h会包含很多宏,看起来比较复杂。

下面我们来探讨如何写一个简单的可变参数的C 函数.

使用可变参数应该有以下步骤: 1)首先在函数里定义一个va_list型的变量,这里是arg_ptr,这个变量是指向参数的指针. 2)然后用va_start宏初始化变量arg_ptr,这个宏的第二个参数是第一个可变参数的前一个参数,是一个固定的参数. 3)然后用va_arg返回可变的参数,并赋值给整数j. va_arg的第二个参数是你要返回的参数的类型,这里是int型. 4)最后用va_end宏结束可变参数的获取.然后你就可以在函数里使用第二个参数了.如果函数有多个可变参数的,依次调用va_arg获取各个参数.

在《C程序设计语言》中,Ritchie提供了一个简易版printf函数:

复制代码
 1 #include<stdarg.h> 2  3 void minprintf(char *fmt, ...) 4 { 5     va_list ap; 6     char *p, *sval; 7     int ival; 8     double dval; 9 10     va_start(ap, fmt);11     for (p = fmt; *p; p++) {12         if(*p != '%') {13             putchar(*p);14             continue;15         }16         switch(*++p) {17         case 'd':18             ival = va_arg(ap, int);19             printf("%d", ival);20             break;21         case 'f':22             dval = va_arg(ap, double);23             printf("%f", dval);24             break;25         case 's':26             for (sval = va_arg(ap, char *); *sval; sval++)27                 putchar(*sval);28             break;29         default:30             putchar(*p);31             break;32         }33     }34     va_end(ap);35 }

我们看一段具有可变参数列表的函数的代码:

  #include <stdio.h>#include <stdarg.h>void simple_va_fun(int i, ...){        va_list ap;        int j = 0;        int k = 0;        va_start(ap, i);        j = va_arg(ap, int);        k = va_arg(ap, int);        va_end(ap);        printf("%d %d %d/n",i,j,k);        return;}int main(void){        simple_va_fun(100);        simple_va_fun(100,200);        simple_va_fun(100,200,300);        return 0;}

 

输出结果:100 730930504 730930520100 200 -965392000100 200 300

va_start的功能是要把,ap指针指向可变参数的第一个参数位置处,

    #define _crt_va_start(ap,v) ( ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v) )

    #define _ADDRESSOF(v) ( &reinterpret_cast<constchar &>(v) ):reinterpret_cast是C++新标准下的强制类型转换,这里将v强制转换为const char*型,然后取其地址。

   先取第一个参数的地址,在sum函数中就是取number的地址并且将其转化为char *的(因为char *的指针进行加减运算后,偏移的字节数才与加的数字相同, 如果为int *p,那么p+1实际上将p移动了4个字节),然后加上4(__INITSIZEOF(number)=(4+3)&~3),这样就将ap指向了可变参数字符串的第一个参数。

 

  #define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )

 

以int所占的字节为标准进行对其操作。如果int占四字节,则以四字节对齐为标准读取数据。

至于为什么会这样计算是考虑到内存对齐

两年之前我写过一篇可变参数学习笔记,里面曾经简单的解释过一句:代码((sizeof(n)+sizeof(int)-1)&~(sizeof(int)-1))的作用是在考虑字节对齐的因素下计算第一个可变参数的起始地址。当时限于时间和水平,未能做更详细的解释。今天(2007-11-26)在csdn论坛上看到了一个帖子http://topic.csdn.net/u/20071123/16/c8d17d3f-9f49-49af-a6d8-1d7a7d84dc1c.html?seed=303711257问题:CRT源码分析中一个关于可变函数参数的问题提问者:Sun_Moon_Stars里面又问到了这个宏,于是决定抽出半天时间,把这个问题详细的说清楚。也算是把我的那篇文章做一个完美的结尾。

二、引子先看一个日常生活中的问题,问题1:假设有要把一批货物放到集装箱里,货物有12件,一个箱子最多能装6件货物,求箱子的数目。解答:显然我们需要12/6=2个箱子,并且每个箱子都是满的。这个连小学生都会算:-)

问题2:       把问题1的条件改一下,假设一个箱子最多能装5件货物,那么现在的箱子数是多少?解答:       12/5=2.4个,但是根据实际情况,箱子的个数必须为整数,(有不知道这个常识的就不要再往下看了,回小学重读吧,呵呵)自然我们就要取3,下面把问题一般化

三、一般数学模型问题3:设一个箱子最多可以装M件货物,且现有N件货物,则至少需要多少个箱子,给出一般的计算公式。这里要注意两点1、箱子的总数必须为整数2、N不一定大于M,很显然,即使N<m,也得需要一只箱子 <="" p="" style="Word-wrap: break-word;">

四、通项公式1、预备知识在讨论之问题3的解答之前,我们先明确一下/运算符的含义。定义/运算为取整运算,即对任意两个整数N,M,必然有且只有唯一的整数X,满足X*M   <=   N   <   (X+1)*M,那么记N/M=X。这个也正是c里/运算的确切含义。x的存在性和唯一性的严格证明可以见数论教材。以后如无额外说明,/运算的含义均和本处一致。

/运算有一个基本的性质若N=MX+Y,则N/M=X+Y/M,证明略

注意:N不是可以随便拆的,设N=A+B,那么一般情况下N/M   不一定等于   A/M+B/M,如果A和B至少有一个是M的倍数,才能保证式子一定成立。

2、分步讨论根据上面的/运算符的定义,我们可以得到问题三的解答,分情况讨论一下已知N/M=X,那么当(1)、当N正好是M的倍数时即N=M*X时,那么箱子数就是X=N/M(2)、如果N不是M的倍数,即N=M*X+Y(1 <=Y <m)时那么显然还要多一个箱子来装余下的Y件货物,则箱子总数为X+1   =   N/M+1

3、一般公式上面的解答虽然完整,但是用起来并不方便,因为每次都要去判断N和M的倍数关系,我们自然就要想一个统一的公式,于是,下面的公式出现了箱子数目为     (N+M-1)/M

这个式子用具体数字去验证是很简单的,留给读者去做。我这里给一个完整的数学推导:现在已经假定   /运算的结果为取整(或者说取模),即N/M=X,则XM   <=N   <(X+1)M那么,(1)、当N=MX时,(N+M-1)/M=   MX/M+(M-1)/M=X(2)、当N=MX+Y(1 <=Y <m)时,由1 <=Y   <   M,同时加上M-1,得到M   <=   Y-1+M   <=   2M-1   <2M根据   /运算的定义   (Y-1+M)   /M   =   1

所以 (N+M-1)/M   =   (MX+Y+M-1)/M=   MX/M+(Y+M-1)/M=   X+1显然   公式   (N+M-1)/M与2中的分步讨论结果一致。可能有的读者还会问,这个公式是怎么想出来的,怎么就想到了加上那个M-1?这个问题可以先去看看数论中的余数理论。

五、对齐代码的分析有了上面的数学基础,我们再来看看开头所说的对齐代码的含义((sizeof(n)+sizeof(int)-1)&~(sizeof(int)-1))意义就很明显了这里。机器字长度sizeof(int)相当于箱子的容量M,变量的真实字节大小相于货物总数N,整个代码就是求n所占的机器字数目。

顺便仔细的解释一下~(sizeof(int)-1))

这里用到了一个位运算的技巧,即若M是2的幂,M=power(2,Y);

则N/M   =  N>>Y  ,

另根据数论中的余数定理,

有N=M*X+Z(1 <   =Z <  M)而注意到这里的N,M,Z都是二进制表示,所以把N的最右边的Y位数字就是余数Z.剩下的左边数字就是模X.

而内存对齐要计算的是占用的总字节数(相当于箱子的最大容量),所以

总字节数 = ( N/M)*M =( N>>Y)<<y <="" p="" style="word-wrap: break-word;">

注意,这里的右移和左移运算并未相互抵消,最后的结果实际上是把N中的余数Z去掉(被清0),

而左边模X得以保持不变。

而当M = power(2,Y) 时

(N >>Y) << Y = (N   &(~(M-1))也是一个恒等式(这个读者也可以用数字验证),

所以,就得到我们前面看到的宏

((sizeof(n)+sizeof(int)-1)&~(sizeof(int)-1))

注意:(1)这里最关键的一点就是M必须是2的幂(有人常常理解成2的倍数也可以,那是不对的),否则上面的结论是不成立的(2)   ~(M-1)更专业的叫法就是掩码(mask)。因为数字和这个掩码进行与运算后,数字的最右边Y位的数字被置0("掩抹"掉了).即掩码最右边的0有多少位,数字最右边就有多少位被清0。

小结:1、字节对齐的数学本质就是数论中的取模运算。在计算机上的含义就是求出一个对象占用的机器字数目。2、在数学上看内存计算的过程就是先右移再左移相同的位数,以得到箱子的最大容量。

3、在c中/运算可以用位运算和掩码来实现以加快速度(省掉了求位数的过程),前提是机器字长度必须为2的幂。

——————————————————

#define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) – 1) & ~(sizeof(int) – 1)

[此问题的推荐答案]~是位取反的意思。_INTSIZEOF(n)整个做的事情就是将n的长度化为int长度的整数倍。比如n为5,二进制就是101b,int长度为4,二进制为100b,那么n化为int长度的整数倍就应该为8。~(sizeof(int) – 1) )就应该为~(4-1)=~(00000011b)=11111100b,这样任何数& ~(sizeof(int) – 1) )后最后两位肯定为0,就肯定是4的整数倍了。(sizeof(n) + sizeof(int) – 1)就是将大于4m但小于等于4(m+1)的数提高到大于等于4(m+1)但小于4(m+2),这样再& ~(sizeof(int) – 1) )后就正好将原长度补齐到4的倍数了。

    

va_arg是要从ap中取下一个参数。

 

       #define _crt_va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )

 

对于这个宏,哥纠结了很久,最后终于搞清楚了,究其原因就是自己C语言功底不扎实,具体表现在没有搞清楚赋值表达式的值是怎么运作的。
我们看这个宏,首先是ap = ap + __INTSIZEOF(t)。注意到,此时ap已经被改变了,它已经指向了下一个参数,我们令x=ap + __INTSIZEOF(t);
那么括号内就变成了(x – __INTSIZEOF(t)),但是这里没有赋值运算符,所以ap的值没有发生变化,此时ap仍然指向的是当前参数的下一个参数的位置,
也就是说ap指向的位置比当前正在处理的位置超前了一个位置。
其实写成下面的形式就简单明了了:

    #define   va_arg(ap,t)   (*(t   *)((ap   +=   _INTSIZEOF(t)),   ap   -   _INTSIZEOF(t))   )

 
分析:为什么要将ap指向当前处理参数的下一个参数了?
经过上面的分析,我们知道va_start(ap,v)已经将ap指向了可变参数列表的第一个参数了,以后我们每一步操作都需要将ap移动到下一个
参数的位置,由于我们每次使用可变参数的顺序是:va_start(ap,v)—>va_arg(ap,t);这样我们在第一次去参数的时候,其实ap已经指向了
第二个参数开始的位置,所以我们用表达式的方式获得一个指向第一个参数的临时指针,这样我们就可以采用这种一致的方式来处理可变参数列表。
(感觉没表达的十分清楚,希望各位朋友纠正~~~~~~)。
下图是我的例子程序中去参数的情况(时间仓促,画得很丑,请原谅):
 
image 
 va_end(ap)  将声明的ap指针置为空,因为指针使用后最后设置为空。
发表评论 共有条评论
用户名: 密码:
验证码: 匿名发表