va_list 可变长参数原理

在 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

那么,这些步骤中到底都做了些什么呢?下面我们来一一剖析。

函数调用栈帧

在讲实现原理之前,不得不先介绍函数调用栈帧的布局,因为这是实现可变参数的基础。

函数栈帧就是函数在被调用时,栈上的布局,比如函数参数,函数返回地址,局部变量等等是如何分布的,代表着函数的活动记录。栈增长的方向是从高往低

在栈帧中,有两个重要的寄存器,espebpesp 始终指向栈顶,ebp 指向当前栈帧的栈底,它里面的值是上一个函数栈帧的 ebp,为了在当前函数返回时恢复上个函数的现场。

不同的编译器有着不同的函数调用约定,比如有的参数从右到左进栈,有的从左到右进栈;在参数出栈时有的是调用者清栈,有的是被调用者清栈。下面我们统一以 cdecl 为标准,即 c 语言默认的调用约定来讲述。它将从右向左进栈,调用者清栈。

下图是一个函数栈帧的示意图:


栈帧示意图

当调用一个函数时,会先将参数压栈,然后是返回地址,再就是函数内部的局部变量。有不太清楚的同学可以先去看看栈帧相关的知识。

实现原理

可变参数就是利用了 cdecl 的调用约定。

  1. 参数从右向左压栈,那么第一个参数最后进栈。其他可变参数相较于第一个参数来说,只需逐个往高地址方向找即可。
  2. 调用者清栈。可变参数只有调用者知道传入了多少个,被调方并不清楚,所以适合调用方来清栈。

假设我们以如下方式调用 sum 函数:

int main(int argc, char *args[])
{
    sum(3, 4, 5, 6);  
  return 0;
}

那么 sum 函数调用时,栈帧大体如下所示:

栈帧.png

参数 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,将指针置空

推荐阅读更多精彩内容