现在的位置: 首页 > 自动控制 > 工业·编程 > 正文

变长参数探究

2019-11-08 08:17 工业·编程 ⁄ 共 4775字 ⁄ 字号 暂无评论

变长参数,指的是函数参数数量可变,或者说函数接受参数的数量可以不固定。实际上,我们最开始学C语言的时候,就用到了这样的函数:printf,它接受任意数量的参数,向终端格式化输出字符串。本文就来探究一下,变长参数函数的实现机制是怎样的,以及我们自己如何实现一个变长参数函数。在此之前,我们先来了解一下参数入栈顺序是怎样的。

函数参数入栈顺序

我们可能知道,参数入栈顺序是从右至左,是不是这样的呢?我们可以通过一个小程序验证一下。小程序做的事情很简单,main函数调用了传入8个参数的test函数,test函数打印每个参数的地址。

#include<stdio.h>

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<stdio.h>

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 <stdio.h>

/*要使用变长参数的宏,需要包含下面的头文件*/

#include <stdarg.h>

/*

* 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 <stdio.h>

/*要使用变长参数的宏,需要包含下面的头文件*/

#include <stdarg.h>

/*

* 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.注意字节对齐问题。

给我留言

留言无头像?