目录
1.前言
2.基本使用方法
1.引入
2.相关宏介绍
3.原理剖析
1.传参
2.va_list
3.va_start()
4.va_arg()
5.va_end()
4.注意事项
5.总结
1.前言
在C语言中,对于一般的函数而言,参数列表都是固定的,而且各个参数之间用逗号进行分开。而除了这些函数外,还有些函数的参数列表是不固定的,例如我们常用的printf()函数,会根据我们传入的参数个数来调整最终打印的个数。本期我们会从宏观到微观,从如何使用可变参数列表到可变参数列表实现原理来理解可变参数列表。
2.基本使用方法
1.引入
假设,我们需要实现一个函数FindMax()来比较num个数的最大值,但我们不清楚num的具体值。此时我们就可以通过可变参数列表的形式设计这个函数,如下:
#include<stdio.h>
#include<stdarg.h>
int FindMax(int num, ...)
{
va_list arg;
va_start(arg, num);
int max = va_arg(arg, int);
for (int i = 1; i < num; i++)
{
int cur = va_arg(arg, int);
if (max < cur)
{
max = cur;
}
}
va_end(arg);
return max;
}
int main()
{
int max = FindMax(5, 2, 4, 7, 4, 3);
printf("%d", max);
return 0;
}
首先,我们需要包含stdarg.h头文件,然后我们通过va_list,va_start(),va_arg(),va_end()
这四个宏来实现对可变参数部分的访问,进而实现求出最大值的功能。可变参数部分用...来表示。
需要注意的是,函数中必须要有一个参数,编译器需要通过这个参数的地址来确定可变参数部分的地址(后面讲解)。在本题中,第一个参数用于传入可变参数部分的数量,因为函数内部是无法确定有多少个可变参数需要被访问。
2.相关宏介绍
四个宏的功能如下表所示:
符号及使用 | 说明 |
va_list arg | 定义可以访问可变参数部分的变量,实际上是个 char*类型 |
va_start(arg,num) | 使arg指向可变参数部分(通过压栈的特点) |
va_arg(arg,int) | 通过指针的方式得到参数,int表示每次arg向后读取 4个字节 |
va_end() | arg使用完毕,将arg置为0 |
所以,我们使用可变参数列表的基本步骤分为以下几步:
1.定义一个va_list类型变量
2.使用va_start()使变量指向可变参数部分
3.通过va_arg()访问可变参数
4.访问完毕后使用va_end使va_list变量置0
注意,使用va_arg()访问可变参数时只能按照顺序向后访问,可以中途停止,但是不能返回或者跳跃访问(后面分析)
3.原理剖析
上面我们了解了可变参数列表的基本使用方法,但是在这之中还存在着一些注意事项,下面我们将通过栈帧和底层代码的实现来分析可变参数列表的实现原理(使用上面求最大值的例子)。提示:下面会使用到往期函数栈帧的内容,传送门:C语言之函数栈帧(动图详解)。
1.传参
我们知道,函数调用前会将参数按照从右往左的顺序进行压栈,形成临时变量,可变参数列表也不例外,会将我们传入的参数压入栈中:
对应栈空间如下:
2.va_list
我们查看va_list宏的定义如下:
va_list实际上是一个char*类型的指针,其指向大小为一个字节的空间。因此上面的
va_list arg实际上是char* arg。
3.va_start()
查看va_start()宏的定义如下(底层定义时通过多个宏一起实现,便于封装):
//最终展开相当于
#define va_start(ap,v) ((void)(ap = (char*)(&(v)) + _INTSIZEOF(v)))
对于_INTSIZEOF(n)宏,作用是将n所占字节大小按照4字节进行对齐,即向上取整。如n占2个字节,这个宏的值就为4,n占6个字节,这个宏的值就为8(原因见下面注意事项)。
所以,va_start(arg,num)的作用就是取出num的地址并强转为char*,然后向下偏移4个字节并赋给arg,通过栈空间我们可以发现通过以上操作arg就指向了可变参数部分。
这便说明了为什么可变参数列表为什么至少需要一个参数是已知的,因为需要确定可变参数部分的地址。
4.va_arg()
查看va_arg()宏的定义如下:
//最终展开相当于
#define va_arg(ap,t) (*(t*)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)))
由于压栈形成的临时变量的地址空间是连续的,所以va_arg(arg,int)的作用就是将ap向下偏移4个字节并改变arg的值,然后再回到起初的位置 (arg没有改变)向下读取4个字节的值作为va_arg(arg,int)宏的值。简单来说,va_arg(arg,int)起到了两个作用:1.将arg指向下一个参数 2.取出原位置的参数
动图如下:
通过以上原理,我们就可以发现va_arg()宏的访问顺序是顺序且单向的,无法进行返回或跳跃访问。
5.va_end()
查看va_end()宏的定义如下:
//最终展开相当于
#define va_end(ap) ((void)(ap = (char*)0))
因此,va_end(arg)实际上就是将arg的值置为0,结束可变参数列表的访问
4.注意事项
目前,我们还剩最后一个问题,为什么va_start和va_arg宏在定义时偏移的字节都需向上取整,而不是直接偏移sizeof(n)个字节?我们通过以下例子来说明:
#include<stdio.h>
#include<stdarg.h>
int FindMax(int num, ...)
{
va_list arg;
va_start(arg, num);
int max = va_arg(arg, char);
for (int i = 1; i < num; i++)
{
int cur = va_arg(arg, char);
if (max < cur)
{
max = cur;
}
}
va_end(arg);
return max;
}
int main()
{
char a = 'a';
char b = 'b';
char c = 'c';
char d = 'd';
char e = 'e';
int max = FindMax(5, a, b, c, d,e);
printf("%c", max);
return 0;
}
我们将传入的参数改为字符型变量,其余地方不变,va_arg的参数依旧是int,我们发现最终运行的结果依旧是我们想要看到的结果:
到这里我们可能就疑惑了,char类型的变量占一个字节,而我们va_start(arg,num)或va_arg(arg,int)进行偏移的时候一次是偏移四个字节,而参数间地址差为一个字节,显然不能正确依次访问我们传入的5个可变参数部分。我们查看汇编代码如下:
通过以上汇编,我们发现字符型实参在形成临时变量时使用的是movsx+push命令,不同于整形变量的mov+push命令。
movsx的作用为:
将数据进行符号扩展,再进行传送,即将char类型数据扩展为int类型数据后再进行传送。
类似的还有movzx,其作用为:
将数据进行零扩展,再进行传送,二者的区别就在于扩展时是补符号位还是零。
一般来说,如果传入的参数是短整形,一般要进行int类型提升,汇编指令为movsx(movzx),
如果传入的是float,则会提升为double类型
由此,我们知道了当函数传参形成临时变量时,会先进行扩展提升放入寄存器中,然后再将扩展后的数据压入栈中。即我们最终形成的临时变量实际上不是占一个字节,而是四个字节。因此,实际上va_arg()的参数为int才是正确的,如果为char则最后解引用结果就是向后访问一个字节。(不过由于我们的机器采用小端存储,数据低位放在低地址处,因此两种写法结果一样)
//对于上面的例子
va_arg(arg,int); //正确的
va_arg(arg,char); //严格来说错误
回到之前的问题,为什么偏移的字节需要向上取整?我们可以假设我们传入的类型是char类型,由于传参时扩展提升的存在,可变参数部分之间地址的差距实际上是扩展提升后的字节,即4个字节,而此时如果我们直接使用sizeof,求得的结果为1个字节,最后arg指针只会向下偏移1个字节,以致无法正确指向下一个参数。因此利用_INTSIZEOF(n)宏使偏移量进行向上4字节取整。
5.总结
1.我们可以通过va_list,va_start(),va_arg(),va_end()四个宏来实现可变参数列表
2.使用可变参数列表时定义函数至少要有一个已知变量
3.可变参数列表的访问是顺序且单向的
4.对于短整形和float类型,传参形成临时变量时数据会发生扩展提升
以上,就是本期的全部内容。
制作不易,能否点个赞再走呢qwq