首发:公众号【编程珠玑】
作者:守望先生
原文:https://www.yanbinghu.com/2018/09/22/24370.html
变长参数,指的是函数参数数量可变,或者说函数接受参数的数量可以不固定。实际上,我们最开始学C语言的时候,就用到了这样的函数:printf,它接受任意数量的参数,向终端格式化输出字符串。本文就来探究一下,变长参数函数的实现机制是怎样的,以及我们自己如何实现一个变长参数函数。在此之前,我们先来了解一下参数入栈顺序是怎样的。
我们可能知道,参数入栈顺序是从右至左,是不是这样的呢?我们可以通过一个小程序验证一下。小程序做的事情很简单,main函数调用了传入8个参数的test函数,test函数打印每个参数的地址。
#include
void test(int a,int b,int c,int d,int e,int f,int g,int h)
{
printf("%p\n%p\n%p\n%p\n%p\n%p\n%p\n%p\n",&a,&b,&c,&d,&e,&f,&g,&h);
}
int main(int argc,char *argv[])
{
int a = 1;
int b = 2;
int c = 3;
int d = 4;
int e = 5;
int f = 6;
int g = 7;
int h = 8;
test(a,b,c,d,e,f,g,h);
return 0;
}
编译成32位程序:
gcc -m32 -o paraTest paraTest.c
运行(不同的机器运行结果不同,且每次运行结果也不一定相同):
0xffdadff0
0xffdadff4
0xffdadff8
0xffdadffc
0xffdae000
0xffdae004
0xffdae008
0xffdae00c
观察打印出来的地址,可以发现,从a到h地址值依次增加4。我们知道,栈是从高地址向低地址增长的,从地址值可以推测h是最先入栈,a是最后入栈的。也就是说,参数是从右往左入栈的(注:并非所有语言都是如此)。
但是如果将函数test参数b改为char 型呢?运行结果如下:
0xffb29500
0xffb294ec
0xffb29508
0xffb2950c
0xffb29510
0xffb29514
0xffb29518
0xffb2951c
观察结果可以发现,b的地址并非是a的地址值加4,也不是在a和c的地址值之间,这是为何?这是编译器出于对空间,压栈速度等因素的考虑,对其进行了优化,但这并不影响变长参数的实现。
对于上面的情况,如果我们编译成64位程序又是什么样的情况呢?
gcc -o paraTest paraTest.c
./paraTest
运行结果如下:
0x7fff4b83cbcc
0x7fff4b83cbc8
0x7fff4b83cbc4
0x7fff4b83cbc0
0x7fff4b83cbbc
0x7fff4b83cbb8
0x7fff4b83cbe0
0x7fff4b83cbe8
通过观察可以发现,从参数a到f,其地址似乎是递减的,而从g到h地址又变成递增的了,这是为什么呢?事实上,对于x86-64,当参数个数超过6时,前6个参数可以通过寄存器传递,而第7~n个参数则会通过栈传递,并且数据大小都向8的倍数对齐。也就是说,对于7~n个参数,依然满足从右往左入栈,只是对于前6个参数,它们是通过寄存器来传递的。另外,寄存器的访问速度相对于内存来说要快得多,因此为了提高空间和时间效率,实际中其实不建议参数超过6个。
对于函数参数入栈顺序我们就了解到这里,但是参数入栈顺序和变长参数又有什么关系呢?
本文来源:公众号【编程珠玑】
通过前面的例子,我们了解到函数参数是从右往左依次入栈的,而且第一个参数位于栈顶。那么,我们就可以通过第一个参数进行地址偏移,来得到第二个,第三个参数的地址,是不是可以实现呢?我们来看一个32位程序的例子。例子同样很简单,我们通过a的地址来获取其他参数的地址:
//来源:公众号【编程珠玑】
#include
void test( int a, char b, int c, int d, int e) { printf("%d\n%d\n%d\n%d\n%d\n\n",a,*(&a+1),*(&a+2),*(&a+3),*(&a+4)); } int main(int argc,char *argv[]) { int a = 1; char b = 2; int c = 3; int d = 4; int e = 5; test(a,b,c,d,e); return 0; }
编译为32位程序运行:
gcc -m32 -o paraTest paraTest.c
./paraTest
1
2
3
4
5
通过观察运行结果我们可以发现,即使只有a的地址也可以访问到其他参数。也就是说,即便传入的参数是多个,只要我们知道每个参数的类型,只需通过第一个参数就能够通过地址偏移正确访问到其他参数。同时我们也注意到,即便b是char类型,访问c的值也是偏移4的倍数地址,这是字节对齐的缘故,有兴趣的可以阅读理一理字节对齐的那些事。
经过前面的理解分析,我们知道,正是由于参数从右往左入栈(但是要注意的是,对于x86-64,它的参数不是完全从右往左入栈,且参数可能不在一个连续的区域中,它的变长参数实现也更为复杂,我们这里不展开)可以实现变长参数。当然了,这一切,C已经有现成可用的一些东西来帮我们实现变长参数。
它主要通过一个类型(va_list)和三个宏(va_start、va_arg、va_end)来实现
va_list :存储参数的类型信息,32位和64位实现不一样。
void va_start ( va_list ap, paramN );
参数:
ap: 可变参数列表地址
paramN: 确定的参数
功能:初始化可变参数列表,会把paraN之后的参数放入ap中
type va_arg ( va_list ap, type );
功能:返回下一个参数的值。
void va_end ( va_list ap );
功能:完成清理工作。
可变参数函数实现的步骤如下:
1.在函数中创建一个va_list类型变量
2.使用va_start对其进行初始化
3.使用va_arg访问参数值
4.使用va_end完成清理工作
接下来我们来实现一个变长参数函数来对给定的一组整数进行求和。程序清单如下:
//来源:公众号【编程珠玑】
#include
/*要使用变长参数的宏,需要包含下面的头文件*/ #include /* * getSum:用于计算一组整数的和 * num:整数的数量 * * */ int getSum(int num,...) { va_list ap;//定义参数列表变量 int sum = 0; int loop = 0; va_start(ap,num); /*遍历参数值*/ for(;loop < num ; loop++) { /*取出并加上下一个参数值*/ sum += va_arg(ap,int); } va_end(ap); return sum; } int main(int argc,char *argv[]) { int sum = 0; sum = getSum(5,1,2,3,4,5); printf("%d\n",sum); return 0; }
上面的小程序接受变长参数,第一个参数表明将要计算和的整数个数,后面的参数是要计算的值。
编译运行可得结果:15。
但是我们要注意的是,这个小程序不像printf那样,对传入的参数做了校验,因此一但传入的参数num和实际参数不匹配,或者传入类型与要计算的int类型不匹配,将会出现不可预知的错误。我们举一个简单的例子,如果第二个参数传入一个浮点数,程序清单如下:
//来源:公众号【编程珠玑】
#include
/*要使用变长参数的宏,需要包含下面的头文件*/ #include /* * getSum:用于计算一组整数的和 * num:整数的数量 * * */ int getSum(int num,...) { va_list ap;//定义参数列表变量 int sum = 0; int loop = 0; int value = 0; va_start(ap,num); for(;loop < num ; loop++) { value = va_arg(ap,int); printf("the %d value is %d\n",loop.value); sum += value; } va_end(ap); return sum; } int main(int argc,char *argv[]) { int sum = 0; float a = 8.25f; printf("a to int=%d\n",*(int*)&a); sum = getSum(5,a,2,3,4,5); printf("%d\n",sum); return 0; }
编译运行:
gcc -m32 -o multiPara multiPara.c
./multiPara
a to int=1090781184
the 0 loop value is 0
the 1 loop value is 1075871744
the 2 loop value is 2
the 3 loop value is 3
the 4 loop value is 4
the sum is1075871753
观察上面的运行结果,发现结果与我们所预期大相径庭,我们可能会有以下几个疑问:
1.把a的地址上的值转换为int,为什么会是1090781184?
2.getSum函数中,为什么第一个值是0?
3.getSum函数中,为什么第二个值是1075871744?
4.getSum函数中,为什么没有获取到5?
5.为什么最后的结果不是我们预期的值?
我们逐一解答
第一个问题,我们不在本文解释,但可以通过对浮点数的一些理解来找到答案。
对于第二个、第三个问题以及第四个问题,涉及到类型提升。也就是说在C语言中,调用一个不带原型声明的函数时,调用者会对每个参数执行“默认实际参数提升",提升规则如下:
——float将提升到double
——char、short和相应的signed、unsigned类型将提升到int
——如果int不能存储原值,则提升到unsigned int
那么也就可以理解了,调用者会将提升之后的参数传给被调用者。也就是说a被提升为了8字节的double类型,自然而然,而我们取值是按int4字节取值,第一次取值取的double的前4字节,第二次取的后4字节,而由于总共取数5次,因此最后的5也就不会被取到。
了解了前面几个问题的答案,那么最后一个问题的答案也就随之而出了。前面取值已经不对了,最后的结果自然不是我们想要的。
通过前面的分析和示例,我们来做一些总结
变长参数实现的基本原理
对于x86来说,函数参数入栈顺序为从右往左,因此,在知道第一个参数地址之后,我们能够通过地址偏移获取其他参数,虽然x86-64在实现上略有不同,但`对于开发者使用来说,实现变长参数函数没有32位和64位的区别。
变长参数实现注意事项
1.…前的参数可以有1个或多个,但前一个必须是确定类型。
2.传入参数会可能会出现类型提升。
3.va_arg的type类型不能是char,short int,float等类型,否则取值不正确,原因为第2点。
4.va_arg不能往回取参数,但可以使用va_copy拷贝va_list,以备后用。
5.变长参数类型注意做好检查,例如可以采用printf的占位符方式等等。
6.即便printf有类型检查,但也要注意参数匹配,例如,将int类型匹配%s打印,将会出现严重问题。
7.当传入参数个数少于使用的个数时,可能会出现严重问题,当传入参数大于使用的个数时,多出的参数不会被处理使用。
8.注意字节对齐问题。
看到这里,你就更能明白《你可能不知道的printf》中问题了。
相关阅读:
理一理字节对齐的那些事
你可能不知道的printf
浮点数在计算机中是如何表示的
关注公众号【编程珠玑】,获取更多Linux/C/C++/Python/Go/算法/工具等原创技术文章。后台免费获取经典电子书和视频资源