从printf了解C语言中变参函数实现细节

首先需要知道,printf的使用有一个规律,就是无论其可变的参数有多少个,printf的第一个参数总是一个字符串。而正是这第一个参数,使得它可以确认后面还有有多少个参数尾随,而尾随的每个参数占用的栈空间大小又是通过第一个格式字符串确定的。

 


 

 

变参函数printf的原型声明:

--------------------------------------------------------------------------------

int printf(const char *format,  ...);

--------------------------------------------------------------------------------

注意到,在函数中声明其参数是可变的方法是三个点“...”,但同时,这个函数必须要有一个固定的参数,比如printf里面的这个format,也就是说变参函数的参数数目至少是一个。这是由C语言中实现变参的原理---计算堆栈地址---决定的。顺着printf函数我们来看看它的定义是什么:

--------------------------------------------------------------------------------

int __printf(const char *format, ...)

{

    va_list  arg;

    int done;


    va_start(arg,  format);

    done = vfprintf(stdout,  format,  arg);

    va_end(arg);


    return done;

}

 

static inline long write(int fd, const char *buf, off_t count)
{
return sys_write(fd, buf, count);
}
通过一个宏va_start把所有的可变参数放到了由args指向的一块内存中,
然后再调用vsprintf
最后实际上是通过调用sys_write输出到标准输出设备,就是显示器上来实现的
sys_write(unsigned int fd, const char * buf, size_t count);

 

--------------------------------------------------------------------------------

(注意到库函数中内部定义的变量和函数用了双下划线开头,这也是我们写应用程序时尽量不要用双下划线开头的原因,我们也不应该使用单下划线开头的函数和变量,因为那也是系统保留的)

其中发现__printf函数里用了va_list,va_start,va_end等宏,事实上,在__printf中调用的vfpirntf函数还用到了一个叫做va_arg的宏,这几个宏就是编写变参函数的关键。现在我们自己写一个最简单的变参函数,先来个感性认识:

--------------------------------------------------------------------------------

#include <stdio.h>
#include <stdarg.h>


void simple_va_fun(int i, ...)
{
     va_list arg_ptr;  //定义一个用来指向函数变参列表的指针arg_ptr

     int j;

     va_start(arg_ptr, i);  //使arg_ptr指向第一个可变参数
     j = va_arg(arg_ptr, int);  //取得arg_ptr当前所指向的参数的值,并使arg_ptr指向下一个参数
     va_end(arg_ptr);  //指示提取参数结束


     printf("%d %d/n", i, j);
     return;
}

int main(void)
{
     simple_va_fun(3, 4);


     return 0;
}

 --------------------------------------------------------------------------------

如代码中的注释所示,arg_ptr实际上是一个指向函数变参列表的指针,va_list实际上是void指针类型。

va_start用来初始化这个指针,使之指向变参列表中的第一个参数,注意到它的第一个参数是函数中的固定参数,第二个参数是这个固定参数的类型。

va_arg利用已经初始化了的arg_ptr指针来取得变参列表中各个参数的值,第一个参数是变参列表指针,第二个参数是当前参数的类型。

va_end宏用来提示结束参数结束,在LINUX的glibc实现中,va_end实际上就是一个空语句(void)0

各个宏定义在头文件stdarg.h中声明,因此我们需要包含这个头文件。其具体的定义如下:

--------------------------------------------------------------------------------

#define _AUPBND  (sizeof(acpi_native_int) - 1)

#define _ADNBND  (sizeof(acpi_native_int) - 1)


#define _bnd(X, bnd)  (((sizeof(X)) + (bnd)) & (~(bnd)))


#define va_start(ap, A)    (void)((ap) = (((char *)&(A)) + (_bnd(A, _AUPBND)))

#defind va_arg(ap, T)     (*(T*)(((ap) += (_bnd(T, _AUPBND))) - (_bnd(T, _ADNBDN))))

#define va_end(ap)     (void)0

 --------------------------------------------------------------------------------

这些宏定义都比较繁琐,主要目的是为了适应不同系统的地址对齐问题。

上面说过,va_start的功能实际上是使ap指针指向第一个变参,A就是我们的第一个固定参数,不考虑地址对齐,最简单的办法当然如下:

     ap = &A + sizeof(A)

上述代码其实也是实现的这个简单的功能,但经过宏_AUPBND和_bnd之后,就能保证ap指向的地址至少是关于acpi_native_int对齐的,打个比方,如果此时A的地址是0x0003,而且A的类型占用4个字节,而当前系统要求4个字节对齐,那么就让_AUPBND中的sizeof参数为4,经过多次宏替代之后ap的地址值就会是0x0008,而简单地用上面的算式ap = &A + sizeof(A)计算出的结果是0x0007。

同样地,va_arg宏替代在不考虑任何移植性问题时,要取得当前变参的值并使指针指向下一个参数最简单的办法如下:

    *((ap+=sizeof(T)) - sizeof(T))

这个需要稍微解释一下,首先,C里面的参数压栈是从右到左顺序压栈的,因此可以想象,第一个固定参数在栈顶(LINUX进程映像中栈是倒着增长的,这个地址是所有参数中最小的),第二个参数(也就是第一个变参)在紧接着固定参数之上,以此类推。

因此,要想ap指针不断指向下一个参数,就必须让它每次都加上当前指向的变量所占内存的大小即 ap+=sizeof(T) 的含义。

接下来,利用这个地址值又减去sizeof(T),实际上地址值又回到上一个参数处(注意,此时ap指针的值并未改变,也就是说,va_arg宏实现获取第一个变参的值的时候是先使ap指向第二个变参,然后再去获取第一个变参的值),然后取值。

va_end宏就比较简单了,虽然各种平台的实现细节不一样,但是道理都是一样的,在glibc中va_end被简单地实现为一个空语句。

 

 

 

 

 


 


 

 

在linux中: typedef s64 acpi_native_int;

 


 

 

可变参数函数的实现与函数调用的栈结构有关,正常情况下c/c++的函数参数入栈规则为__stdcall, 它是从右到左的,即函数中的最右边的参数最先入栈。对于函数
    void fun(int a, int b, int c)
    {
        int d;
        ...
    }
其栈结构为
    0x1ffc-->d
    0x2000-->a
    0x2004-->b
    0x2008-->c
  对于任何编译器,每个栈单元的大小都是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 fun1(char a, int b, double c, short d)

 

 

  

 


 

 

结论:

1。printf之所以可以识别各种变量类型,是因为你调用它的时候必须用printf修饰符,也就是%d,%f,%s等等来指定你的参数,printf是很笨的,它是不知道的。

2。对于可变参数函数的调用有一点需要注意,实际的可变参数的个数必须比前面模式指定的个数要多,或者不小于, 也即后续参数多一点不要紧,但不能少,如果少了则会访问到函数参数以外的堆栈区域,这可能会把程序搞崩掉。前面模式的类型和后面实际参数的类型不匹配也有可能造成把程序搞崩溃,只要模式指定的数据长度大于后续参数长度,则这种情况就会发生。如:
printf("%.3f, %.3f, %.6e", 1, 2, 3, 4);
  参数1,2,3,4的默认类型为整型,而模式指定的需要为double型,其数据长度比int大,这种情况就有可能访问函数参数堆栈以外的区域,从而造成危险。但是printf("%d, %d, %d", 1.0, 20., 3.0);这种情况虽然结果可能不正确,但是确不会造成灾难性后果。因为实际指定的参数长度比要求的参数长度长,堆栈不会越界。

 

 


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值