查看原文
其他

每天都在用printf,你知道变长参数是怎么实现的吗

守望先生 编程珠玑 2022-06-18

来源:公众号【编程珠玑】

作者:守望先生

ID:shouwangxiansheng

前言

变长参数,指的是函数参数数量可变,或者说函数接受参数的数量可以不固定。实际上,我们最开始学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
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/算法/工具等原创技术文章。后台免费获取经典电子书和视频资源


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存