LV070-可变参数
还记得 printf 吧,它为什么可以传很多的参数进去?这节来了解一下。
一、函数栈帧
1. 概述
1.1 是什么?
在程序运行过程中,函数栈帧(Function Stack Frame) 是栈内存中为单个函数调用分配的一块独立内存区域。主要作用如下:
- ① 用于管理函数的参数、局部变量、返回地址、寄存器状态等信息
- ② 它是函数调用和返回的基础,确保了函数之间的独立执行和数据隔离。
1.2 c 程序内存分布

1.3 栈的概念
我们可以吧栈看做于一支乒乓球桶,我们放入乒乓球通过桶口放入,我们拿出乒乓球通过桶口拿出,底下的乒乓球先被放进去,顶上的乒乓球后被放进去,顶上的乒乓球先被拿出,底下的乒乓球后被拿出。栈的特点就是 后进先出( LIFO ),它是一种特殊的线性存储结构。
其中放入乒乓球被称为 压栈(push) ,拿出乒乓球被称为 出栈(pop)。
![]() | ![]() |
维护栈的指针(两个寄存器):
①EBP(栈底指针):固定指向当前栈帧的 “基地址”,用于定位栈帧内的参数、变量(偏移量固定)。
②ESP(栈顶指针):始终指向栈的 “顶部”,随压栈(
push)/ 弹栈(pop)动态移动。
1.4 CPU 寄存器 AX
AX 寄存器常用于累加器,广泛用于不同类型的运算,如加减乘除。在汇编中子程序中,子程序的返回值也会存放在这个 AX 寄存器中,这个子程序其实就是函数。
1.5 测试代码
#include <stdio.h>
// 被调用函数:计算 x+y
int add(int x, int y) {
int z; // 局部变量
z = x + y; // 计算逻辑
return z; // 返回结果
}
// 调用者函数
int main() {
int a = 2, b = 3; // main 的局部变量
int c = add(a, b); // 调用 add,接收返回值
printf("c=%d", c);
return 0;
}2. 函数调用过程分析
2.1 阶段 1:初始状态
如下图所示:

main 函数执行中,未调用 add 前, 变量 a 先压入栈中,变量 b 后压入栈中,此时 esp 栈顶指针指向下一个即将压入栈中元素的位置,ebp 维护的是 main 函数栈帧中的基底位置。
2.2 阶段 2:调用 add 前
main 准备参数,压栈,C 语言函数参数默认 从右向左 压栈(先压最右边的参数 y,再压左边的 x),然后压入 “返回地址”(add 执行完后,main 要回到的代码位置)。

2.2.1 压入 add 的第二个参数
如下图所示:
- 执行
push 3(将b的值压栈) - ESP 向下移动 4 字节(int 占 4 字节),指向新栈顶

2.2.2 压入 add 的第一个参数
如下图所示:
- 执行
push 2(将a的值压栈) - ESP 再向下移动 4 字节

2.2.3 压入 "返回地址"
如下图所示:
- 当 add 执行完后,需要回到 main 的
int c = add(a,b);下一条指令(即printf("c=%d", c);的地址,记为0x00401234); - 执行
push 0x00401234(压入返回地址); - ESP 再向下移动 4 字节。

2.3 阶段 3:进入 add 函数
创建 add 的栈帧,此时 CPU 跳转到 add 函数的代码,开始初始化 add 的栈帧 —— 核心是 “保存 main 的 EBP” 和 “设置 add 的 EBP”。
2.3.1 保存 main 的 EBP
如图所示:
- 为了 add 执行完后能恢复 main 的栈帧,需要先把 main 的 EBP 压栈;
- 执行
push ebp; - ESP 向下移动 4 字节。

2.3.2 设置 add 的 EBP
如图所示:
- 将当前 ESP 的值赋给 EBP,此时 EBP 成为 add 栈帧的 “基地址”;
- 此时 EBP 固定指向 add 栈帧的 “基地址”,后续 add 访问参数 / 变量都通过 EBP 的偏移量(如
EBP+8是 x,EBP+12是 y)

2.3.3 分配 add 的局部变量空间
- 此时 esp 向低地址处一定的字节大小,同时 add 开辟一定的空间
- add 有局部变量
int z,需要在栈中至少预留 4 字节空间;

2.4 阶段 4:执行 add 函数逻辑
如图所示:计算 z = x + y 核心逻辑:
(1)取 x:访问 EBP+8,值为 2;
(2)取 y:访问 EBP+12,值为 3;
(3)计算 2+3=5,将结果存入 z 的地址

2.5 阶段 5:add 函数返回
如图所示:销毁 add 的栈帧,回到 main。
2.5.1 传递返回值
z = 5 存入 EAX,通过将函数中的返回值存入到 CPU 中的 EAX 寄存器中,然后将其带回主函数。
2.5.2 释放 add 的局部变量空间
将 ESP 拉回 EBP 的位置,相当于 “回收” 局部变量 z 的空间。 简单来说,add 函数所开辟的空间被操作系统回收,用户不在拥有访问权限,后续会被其他在栈上开辟的空间所覆盖。

2.5.3 恢复 main 的 EBP
如图所示:

弹出旧 的 EBP,相当于让 EBP 重新回到了 main 函数基底的位置。
2.5.4 弹出返回地址,回到 main 执行
如图所示:
- 执行
ret:将栈顶(0x1008)的 “返回地址 = 0x00401234” 弹出,CPU 跳转到这个地址; - ESP 向上移动 4 字节,指向 (栈顶现在是参数 x = 2)
2.5.5 清理 add 的参数(main 回收参数空间)
如图所示:
- main 调用完 add 后,需要回收之前压入的参数(x = 2、y = 3);
- 执行
add esp, 8(ESP 向上移动 8 字节,因为两个 int 参数共 8 字节);

2.6 阶段 6:main 接收返回值,继续执行
如图所示:
- main 从
EAX寄存器中取出返回值 5,赋值给局部变量c(int c = 5); - 后续执行
printf("c=%d", c),输出c=5,程序结束。

3. 小结
函数栈帧的核心要点:
① 创建栈帧:调用者压参数 → 压返回地址 → 被调用者压旧 EBP→ 设新 EBP→ 分配局部变量。
② 使用栈帧:通过 EBP 的固定偏移访问参数(
EBP+偏移)和局部变量(EBP-偏移)。③ 销毁栈帧:释放局部变量 → 恢复旧 EBP→ 弹出返回地址 → 清理参数 → 回到调用者。
二、参数可变的原理
1. printk 的定义
看一个老版本的吧,新版本的嵌套太多层,很难追,printk.c - kernel/printk.c - Linux source code v2.6.22.6,老版本虽然老,但是原理都是一样的。
asmlinkage int printk(const char *fmt, ...)
{
va_list args;
int r;
va_start(args, fmt);
r = vprintk(fmt, args);
va_end(args);
return r;
}这里面关键部分是 va_list、va_start 和 va_end。
2. stdarg.h
stdarg.h 头文件定义了一个变量类型 va_list 和三个宏,这三个宏可用于在参数个数未知(即参数个数可变)时获取函数中的参数。可变参数的函数通在参数列表的末尾是使用省略号 ... 定义的。
2.1 va_list
va_list 的本质是定义了一个 char * 指针,并且这个指针需要指向第一个可变参数的地址。因为参数的个数是可变的,所以用 char * 是最合适的。
#define va_list char *例如:
va_list var_arg;同样的可以将其转换成如下代码,本质是定义了一个 var_arg 指针:
char *var_arg;2.2 va_start
可以参考 C 库宏 – va_start() | 菜鸟教程,这是个宏函数,将上面的指针 va_arg 指向第一个可变参数。
// 指向可变参数
#define va_start(ap, param) (ap = (va_list)¶m + sizeof(param))从以上代码可以看到,取了第一个参数的地址,并计算出可变参数的其实地址。这就是为什么可变参数至少需要一个固定参数的原因,需用通过这个固定参数找到可变参数的真实地址。例如
va_start(var_arg, n_values);以上代码可转换为:
var_arg = (char *)&n_value + sizeof(n_value);2.3 va_arg
这个可以参考 C 库宏 – va_arg() | 菜鸟教程,它是获取可变参数的值,并将指针指向下一个参数的地址。简单说就是这个宏检索函数参数列表中类型为 type 的下一个参数。需要注意,这里一定是要指定类型的,不同的类型大小是不同的,指针必须跳过指定的字节数,才能到达下一个参数。
// ap 自增 sizeof(t),然后减去 sizeof(t),顺序获取参数的值
#define va_arg(ap, t) (*(t *)((ap = (ap + sizeof(t))) - sizeof(t)))ap 首先增加了 sizeof(t),然后又减了 sizeof(t)。第一次增加 ap 的值变了,第二次减 ap 的值没有变,是为了取当前参数的值。例如,
int value = va_arg(var_arg, int)以上代码可转换为:
var_arg = var_arg + sizeof(int)
int value = *(int *)(var_arg - sizeof(int))即:
┌─────────────────────────────────────────────────────────┐
│ 假设当前 ap 指向地址 0x08,要读取一个 int │
├─────────────────────────────────────────────────────────┤
│ 1. 计算类型大小:sizeof(int) = 4 │
│ 2. 先移动指针:ap += 4 (ap 从 0x08 变成 0x0C) │
│ 3. 然后回退读取:ap - 4 = 0x08 │
│ 4. 读取 4 字节作为 int 返回 │
│ │
│ 地址: 0x08 0x09 0x0A 0x0B 0x0C │
│ ┌──────┬──────┬──────┬──────┐ │
│ │ int 值 (4字节) │ ←── 读取这个 │
│ └──────┴──────┴──────┴──────┘ │
│ ↑ │
│ ap 移动到这里后,回退读取 │
└─────────────────────────────────────────────────────────┘2.4 va_end
可以参考 C 库宏 – va_end() | 菜鸟教程,这个宏是最后一步,即清理指针。它用于清理 va_list 变量,并使其不再指向任何有效的内存位置。它在可变参数函数的末尾使用,以结束可变参数的处理。
// 清理指针
#define va_end(ap) (ap = ((va_list)0))例如
va_end(var_arg);以上代码可以转换为:
var_arg = (char *)0;3. 另一种定义
网上还看到了下面这种定义,其实比上面的小节就是多了一个宏 _INTSIZEOF
typedef char* va_list;
#define _INTSIZEOF(n) ((sizeof(n)+sizeof(int)-1)&~(sizeof(int)-1))
#define va_start(ap,v) (ap=(va_list)&v+_INTSIZEOF(v))
#define va_arg(ap,t) (*(t*)((ap+=_INTSIZEOF(t))-_INTSIZEOF(t)))
#define va_end(ap) (ap=(va_list)0)_INTSIZEOF 这个宏是根据各种变量类型,取得他们的变量存在栈中的长度。实际使用中,每个传参存储的空间都必须是 int 类型长度的整数倍,即 4,8,12,16……例如 char, short, int, double:
char:
_INTSIZEOF(char) ==> ((sizeof(char)+sizeof(int)-1)&~(sizeof(int)-1))
这时算出的结果为((1+4-1)&~3),即4和非3进行与操作,非3执行后,该数据的低2位都为0,高位全为1,和4相与,结果为4
short:
_INTSIZEOF(short)结果为((2+4-1)&~3),5和非3相与,结果为4
int:
_INTSIZEOF(int)结果为((4+4-1)&~3),7和非3相与,结果为4
double:
_INTSIZEOF(double)结果为((8+4-1)&~3),11和非3相与,结果为84. vprintk()
要先再来看一下 vprintk 这个函数,函数太长,这里就先不管了,主要看 vscnprintf()→ vsnprintf()
int vsnprintf(char *buf, size_t size, const char *fmt, va_list args)
{
//...
switch (*fmt) {
case 'c':
if (!(flags & LEFT)) {
while (--field_width > 0) {
if (str < end)
*str = ' ';
++str;
}
}
c = (unsigned char) va_arg(args, int);
//...
case 's':
s = va_arg(args, char *);
//...
continue;
case 'p':
//...
}
//...
}
//...
/* the trailing null byte doesn't count towards the total */
return str-buf;
}从这里就可以看出,其实 printk 也是传了后面每个参数的类型的,类型就是在第一个格式字符串中的 %d、%c、%s 等。例如:
printk("%d %s %f", 42, "hello", 3.14);
│ │ │
│ │ └── va_arg(args, double)
│ └───────── va_arg(args, char*)
└────────────── va_arg(args, int)5. 一个示例
我们写一个通过可变参数做加法的实例:
// n_values 表示有几个参数,这里将参数全部写死为 int 类型
double average(int n_values, ...) {
va_list var_arg; // 定义一个 va_list 变量
int count;
float sum = 0;
va_start(var_arg, n_values); // 执行 var_arg =(va_list)&n_values + _INTSIZEOF(n_values),var_arg 指向参数 n_values 之后那个参数的地址,即 var_arg 指向第一个可变参数在堆栈的地址
for (count = 0; count < n_values; count++) {
sum += va_arg(var_arg, int); // (*(t *)((var_arg += _INTSIZEOF(int)) - _INTSIZEOF(int)) ) 取出当前 var_arg 指针所指的值,并使 var_arg 指向下一个参数
}
va_end(var_arg); // 清空 va_list var_arg
return sum / n_values;
}根据前面对宏的分析,这里我们可以转换为:
double average2(int n_values, ...) {
char *var_arg;
int count;
float sum = 0;
var_arg = (char*)&n_values + sizeof(n_values); // var_arg 指向第一个可变参数在堆栈的地址
for (count = 0; count < n_values; count++) {
var_arg = var_arg + sizeof(int);
int value = *(int *)(var_arg - sizeof(int));
sum += value;
}
var_arg = (char *)0;
return sum / n_values;
}参考资料:
【C/C++】C 语言可变参数实现原理 - 醉梦临川 - 博客园

