前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >变长参数探究

变长参数探究

作者头像
编程珠玑
发布2019-09-03 11:17:17
6290
发布2019-09-03 11:17:17
举报
文章被收录于专栏:编程珠玑编程珠玑

前言

变长参数,指的是函数参数数量可变,或者说函数接受参数的数量可以不固定。实际上,我们最开始学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.注意字节对齐问题。
本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2018-09-22,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 编程珠玑 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 函数参数入栈顺序
  • 变长参数实现分析
  • 变长参数实现
  • 总结
相关产品与服务
云开发 CloudBase
云开发(Tencent CloudBase,TCB)是腾讯云提供的云原生一体化开发环境和工具平台,为200万+企业和开发者提供高可用、自动弹性扩缩的后端云服务,可用于云端一体化开发多种端应用(小程序、公众号、Web 应用等),避免了应用开发过程中繁琐的服务器搭建及运维,开发者可以专注于业务逻辑的实现,开发门槛更低,效率更高。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档