在 c 语言中,我们可以使用可变参数来传入多个参数,比如 printf
函数。可变参数的函数需至少定义一个参数值,其余的用 ...
代表可变参数。
举个栗子,定义如下 sum 函数,求出传入整数的总和。其中 n 为传入整数的个数,它必须有,否则无法知道传入了几个整数。
int sum(unsigned n, ...)
{
va_list ap;
va_start(ap, n);
unsigned count = n;
int sum = 0;
while (count > 0)
{
int value = va_arg(ap, int);
sum += value;
count -= 1;
}
va_end(ap);
return sum;
}
从代码中,我们可以看出获取可变参数的步骤如下:
- 定义
va_list
- 调用
va_start
,并传入第一个参数 - 调用
va_arg
获取可变参数的值 - 最后调用
va_end
那么,这些步骤中到底都做了些什么呢?下面我们来一一剖析。
函数调用栈帧
在讲实现原理之前,不得不先介绍函数调用栈帧的布局,因为这是实现可变参数的基础。
函数栈帧就是函数在被调用时,栈上的布局,比如函数参数,函数返回地址,局部变量等等是如何分布的,代表着函数的活动记录。栈增长的方向是从高往低
。
在栈帧中,有两个重要的寄存器,esp
和 ebp
。esp
始终指向栈顶,ebp
指向当前栈帧的栈底,它里面的值是上一个函数栈帧的 ebp
,为了在当前函数返回时恢复上个函数的现场。
不同的编译器有着不同的函数调用约定,比如有的参数从右到左进栈,有的从左到右进栈;在参数出栈时有的是调用者清栈,有的是被调用者清栈。下面我们统一以 cdecl
为标准,即 c 语言默认的调用约定来讲述。它将从右向左进栈,调用者清栈。
下图是一个函数栈帧的示意图:
当调用一个函数时,会先将参数压栈,然后是返回地址,再就是函数内部的局部变量。有不太清楚的同学可以先去看看栈帧相关的知识。
实现原理
可变参数就是利用了 cdecl
的调用约定。
- 参数从右向左压栈,那么第一个参数最后进栈。其他可变参数相较于第一个参数来说,只需逐个往高地址方向找即可。
- 调用者清栈。可变参数只有调用者知道传入了多少个,被调方并不清楚,所以适合调用方来清栈。
假设我们以如下方式调用 sum 函数:
int main(int argc, char *args[])
{
sum(3, 4, 5, 6);
return 0;
}
那么 sum 函数调用时,栈帧大体如下所示:
参数 3、4、5、6 依次从右向左进栈,即6、5、4、3。黄色箭头标记的是第一个参数 3 的地址。
那么 va_xx
之类的宏都做了些什么呢?为什么就能取到可变长参数的值?如果弄懂了栈帧布局,那理解起来也比较简单。
va_list
我们模拟下它的宏定义,它相当于定义了一个 char *
的指针。因为参数的长度是可变的,所以用 char *
类型最合适。
// 定义 char * 指针类型
#define va_list char *
结合栗子来看:
va_list ap;
可转换为如下代码,其实就是定义了 ap 指针:
char *ap;
va_start
主要是做一些准备工作,将 ap 指向传入的第一个可变参数。
从下面代码中可以看到,我们取出了最后一个固定参数的地址,并计算出可变参数的起始地址。这也就是为什么可变参数函数至少需要一个参数的原因,因为需要获取可变参数从哪个地址开始。
// 指向可变参数的第一个
#define va_start(ap, last_arg) (ap = (va_list)&last_arg + sizeof(last_arg))
结合栗子来看:
va_start(ap, n);
可转换为如下代码:
ap = (char *)&n + sizeof(n);
va_arg
这里可能有点迷糊。ap 首先增加了 sizeof(t)
,然后又减去了 sizeof(t)
。主要是为了在一个宏中能让 ap 向上增长,同时又可以获取当前参数的值。
// ap 自增 sizeof(t),然后减去 sizeof(t),顺序获取参数的值
#define va_arg(ap, t) (*(t *)((ap = (ap + sizeof(t))) - sizeof(t)))
结合栗子来看:
int value = va_arg(ap, int);
可转换为如下代码:
// 取值
int value = *(int *)ap;
// 自增
ap += sizeof(t);
va_end
最后一步,就是将指针清零。
// 指针清零
#define va_end(ap) (ap = ((va_list)0))
结合栗子来看:
va_end(ap);
可转换为如下代码:
ap = (char *)0;
总结
到此,va_xx
的宏作用应该是比较清晰了,总结一下:
- va_list,定义
char *
类型指针,以便支持任意类型 - va_start,根据最后一个固定参数地址,定位到第一个可变参数地址
- va_arg,根据可变参数个数,逐渐向高地址方向取出参数
- va_end,将指针置空