1、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字节)
因此,函数的所有参数是存储在线性连续的栈空间中的,基于这种存储结构,这样就可以从可变参数函数中必须有的第一个普通参数来寻址后续的所有可变参数的类型及其值。
2. C语言通过几个宏来实现变参的寻址
根据函数调用的栈结构,标准C语言中,一般在stdarg.h头文件定义了下面的几个宏,用于实现变参的寻址及可变函数的设计,其中有可能不同的商业编译器的发行时实现的具体代码可能不一样,但是原理都是一样的。
//Linux 2.18内核
typedef char * va_list;
/*
Storage alignmentproperties -- 堆栈按机器字对齐
其中acpi_native_int是一个机器字,32位机的定义是:typedef u32 acpi_native_int
*/
#define _AUPBND (sizeof(acpi_native_int) - 1)
#define _ADNBND (sizeof(acpi_native_int) - 1)
/* Variable argument list macro definitions -- 变参函数内部实现需要用到的宏 */
#define _bnd(X, bnd) (((sizeof (X)) + (bnd)) & (~(bnd)))
#define va_start(ap, A) (void) ((ap) = (((char *) &(A)) + (_bnd(A,_AUPBND))))
#define va_arg(ap, T) (*(T *)(((ap) += (_bnd (T, _AUPBND))) -(_bnd (T,_ADNBND))))
#define va_end(ap) (void) 0
在X86 32位机器中,以上这几个宏的用途主要是:
C语言传递参数是与__stdcall相同的,C语言传递参数时是用push指令从右到左将参数逐个压栈,因此C语言里通过栈指针来访问参数。虽然X86的push一次可以压2,4或8个字节入栈,C语言在压参数入栈时仍然是机器字的size为最小单位的,也就是说参数的地址都是字对齐的,这就是_bnd(X,bnd)存在的原因。汇编和C,编译出的X86函数一般在进入函数体后立即执行
push ebp
mov ebp, esp
这两条指令。首先把ebp入栈,然后将当前栈指针赋给ebp,以后访问栈里的参数都使用ebp作为基指针。
下面将对上面几个主要的宏进行分析:
① #define _bnd(X, bnd) (((sizeof(X)) + (bnd)) & (~(bnd)))
计算类型为X的参数在栈中占据的字节数,是字对齐后的字节数。acpi_native_unit是一个机器字,32位机的定义是:typedef u32 acpi_native_uint;
显然,_AUPBND ,_ADNBND 的值是 4-1 ==3 == 0x00000003 ,按位取反( ~(bnd))就是0xfffffffc。
因此,_bnd(X,bnd) 宏在32位机下是
( (sizeof(X) + 3)&0xfffffffc )
与&0xfffffffc相与后,最后两位是00,很明显,其作用是实现字对齐。
② #define va_start(ap, A) (void)((ap) = (((char *) &(A)) + (_bnd (A,_AUPBND))))
va_start(ap,A) ,初始化参数指针ap,将函数参数A右边第一个参数的地址赋给ap。 A必须是一个参数的指针,所以此种类型函数至少要有一个普通的参数。
如下图所示:
高地址|-----------------------------|
|函数返回地址 |
|-----------------------------|
| …|
|-----------------------------|
|第n个参数(第一个可变参数) |
|-----------------------------|<--va_start后ap指向
|第n-1个参数(最后一个固定参数)|
低地址|-----------------------------|<--&A
③#define va_arg(ap,T) (*(T *)(((ap) += (_bnd (T,_AUPBND))) - (_bnd (T,_ADNBND))))
获得ap指向参数的值,并使ap指向下一个参数,T用来指明当前参数类型。
由于_AUPBND和_ADNBND是相等的,所以取得的值是ap当前指向的参数值,但是先给ap加了当前参数在字对齐后所占的字节数,使其指向了下一个参数。
如下图所示:
高地址|-----------------------------|
|函数返回地址 |
|-----------------------------|
|...|
|-----------------------------|<--va_arg后ap指向
|第n个参数(第一个可变参数) |
|-----------------------------|<--va_start后ap指向
|第n-1个参数(最后一个固定参数)|
低地址|-----------------------------|<--&A
③ #defineva_end(ap) (void) 0
用于删除指针ap,同时,这样做编译器不会为va_end()产生代码;置零后也防止了悬浮指针的存在。相当于ap=NULL.。
小结:
因此,根据stdarg.h头文件所定义的宏,可以总结出实现一个可变函数设计时所需要的步骤或者说算法:
(1)在程序中将依次用到以下这些宏:
voidva_start( va_list ap ,A);
typeva_arg( va_list ap, T );
voidva_end( va_list ap );
va在这里是variable-argument(可变参数)的意思。
(2)函数里首先定义一个va_list型的变量,这里是ap,这个变量是存储参数地址的指针。因为得到参数的地址之后,再结合参数的类型,才能得到参数的值。
(3)然后用va_start宏初始化(2)中定义的变量ap,这个宏的第二个参数是可变参数列表的前一个参数,即最后一个固定参数(普通参数)。
(4)然后依次用va_arg宏使ap返回可变参数的地址,得到这个地址之后,结合参数的类型,就可以得到参数的值。
(5)设定结束条件。由于被调的函数在调用时一般是不知道可变参数的正确数目的,程序员必须自己在代码中指明结束条件。
3、C中常见的可变参数的函数:printf(),scanf()
(1)printf()函数使用
以printf()函数为典型进行分析,经常使用printf()函数的形式有如下这些:
int intData =2011;
float fData = 88.8;
char *pStr = "Hello world";
printf("print as itself\n");
printf("intData =%d, fData =%.3f, pStr=%s\n", intData,fData, pStr);
等等…
从以上printf()的使用情况来看,不难发现一个规律,就是无论其随后的可变参数有多少个,printf()的第一个参数总是一个字符串。正是这第一个参数,使得它可以确认后面还有有多少个参数尾随。而尾随的每个参数占用的栈空间大小又是通过第一个格式字符串%确定的。
(2)printf()实现代码分析
//start.c
static char sprint_buf[1024];
int printf(char *fmt, ...)
{
va_listargs;
int n;
va_start(args,fmt);
n =vsprintf(sprint_buf, fmt, args);
va_end(args);
write(stdout,sprint_buf, n);
return n;
}
//unistd.h
static inline long write(int fd, const char *buf,off_t count)
{
returnsys_write(fd, buf, count);
}
分析:
从上面的代码来看,printf似乎并不复杂,并且遵循在文中2部分所小结的设计可变参数函数的步骤或算法:它通过一个宏va_start把所有的可变参数放到了由args指向的一块内存中,然后再调用vsprintf. 真正的参数个数以及格式的确定是在vsprintf可以确定的。由于vsprintf的代码比较复杂,也不是这里要讨论的重点,所以下面就不再列出。重点是va_start(ap, A)宏的实现,它对定位从参数A后面的参数有重大的指导意义。现在把 #define va_start(ap, A) (void) ((ap) =(((char *) &(A)) + (_bnd (A,_AUPBND)))) 的含义解释一下如下:
va_start(ap, A)
{
char *ap = ((char *)(&A)) + sizeof(A) /*A类型大小地址对齐*/
}
在printf的va_start(args,fmt)中,fmt的类型为char *, 因此对于一个32位系统 sizeof(char *) = 4, 如果int大小也是32,则va_start(args, fmt);相当于 char *args = (char *)(&fmt) + 4; 此时args的值正好为fmt后第一个参数的地址。
对于如下的可变参数函数
void fun(double d,...)
{
va_list args;
intn;
va_start(args,d);
}
则 va_start(args, d);相当于
char *args = (char *)&d + sizeof(double);
此时args正好指向d后面的第一个参数。如此就可以实现了对所有参数的访问。
4、设计一个简单的可变参数函数
(1)目的:设计一个用于字符串拼接的函数。
(2)功能:参数是可变的,即是可无限拼接字符串。
(3)接口:输入参数最少一个字符串常量指针,中间的参数可以无限个字符串指针,最后一个参数为字符串类型指针且值为NULL。
实现源代码:文件va_strcat.h中
/*
Zengxijin 2011-5-5
Shanghai
*/
/* 文件va_strcat.h*/
#ifndef VA_STRCAT_H
#define VA_STRCAT_H
#include <stdlib.h>
#include <stdarg.h>
#include <string.h>
char *VA_Strcat(const char*format,...);
char *VA_Strcat(const char*format,...)
{
size_t len=0; /*len用于保存计算所有字符的长度*/
char *retBuf=NULL; /* retBuf 指向分配内存缓存区的字符指针、返回值*/
va_list ap; /* 定义指向可变参数的指针变量ap*/
char *p=NULL;
if (format==NULL) /* 函数使用错误,最少需要一个参数,返回NULL*/
return NULL;
len=strlen(format);
va_start(ap,format); /* 开始给ap初始化*/
/* 设计结束条件为该函数遇到最后一个参数为NULL时,对参数列表遍历完成*/
while ((p=va_arg(ap,char *))!=NULL)
{
len+=strlen(p);
}
va_end(ap);/* 完成所有字符串的长度统计,清除变量ap,置NULL*/
retBuf=malloc(len+1); /*动态分配足够的内存*/
if (retBuf==NULL)
{
return NULL;
}
strcpy(retBuf,format);
va_start(ap,format);
while((p=va_arg(ap,char *))!=NULL) /*将参数列表中的所有字符串拼接起来*/
{
strcat(retBuf,p);
}
va_end(ap);
return retBuf;
}
#endif
/********************************************************************/
测试该程序:
/* main.c */
#include <stdio.h>
#include "va_strcat.h"
int main()
{
char*pStr=VA_Strcat("ABC","DEFG","HIJK","LMN",(char*)NULL);
printf("%s\n",pStr);
free(pStr);/* 释放堆内存 */
return 0;
}
输出:ABCDEFGHIJKLMN
5、总结
(1)、根据上文的学习讨论,可以总结出设计可变函数的基本步骤:
①函数声明:要实现类似printf()的可变函数,除了固定参数用常规的函数形参的定义方法表示外,可变参数的表示放在函数最后一个参数中,且用“…”表示,如上文中:
char *VA_Strcat(const char*format,...);
这样编译器才知道该函数是变参函数,这个参数与变参函数的内部实现完全没有关系,只是通知编译器在编译调用此类函数语句的时候,将函数的所有参数都压入函数调用栈中,而不报错。
②必须最少需要一个固定参数,否则无法根据固定参数寻址到变参地址去取值。
③可变函数的内部具体实现需要用到头文件stdarg.h所定义的几个宏来实现。具体使用基本步骤如下:
A、 首先,在函数里定义一个va_list ap类型变量,ap它是指向参数的指针。
B、 其次,用va_start(ap,A)宏初始化变量ap,这个宏的第二个参数A是第一个可变参数的前一个参数,A是固定参数。
C、 然后,用宏va_arg(ap,T)返回可变参数的值,设置一定的结束条件,当读取完所有参数值的时候结束此宏的调用。
D、 最后,清理现场,用va_end(ap)宏结束可变参数的获取。置NULL,防止悬浮指针。
(2)、学习心得:
①进一步学习理解了C函数调用时参数进栈的机制。
②对良好的宏设计印象深刻,尤其像实现字对齐的算法 ( (sizeof(X) + 3)&0xfffffffc ) 的简洁精练、高效设计的使用,使人感到兴奋。
③ 标准C头文件的stdarg.h的基于字对齐跨平台的设计考虑思想,值得学习。
④ 理解可变参数函数的实现基本原理,掌握可变参数函数设计的技术。