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