Skip to content

LV070-可变参数

还记得 printf 吧,它为什么可以传很多的参数进去?这节来了解一下。

一、函数栈帧

1. 概述

1.1 是什么?

在程序运行过程中,函数栈帧(Function Stack Frame) 是栈内存中为单个函数调用分配的一块独立内存区域。主要作用如下:

  • ① 用于管理函数的参数、局部变量、返回地址、寄存器状态等信息
  • ② 它是函数调用和返回的基础,确保了函数之间的独立执行和数据隔离。

1.2 c 程序内存分布

img

1.3 栈的概念

我们可以吧栈看做于一支乒乓球桶,我们放入乒乓球通过桶口放入,我们拿出乒乓球通过桶口拿出,底下的乒乓球先被放进去,顶上的乒乓球后被放进去,顶上的乒乓球先被拿出,底下的乒乓球后被拿出。栈的特点就是 后进先出( LIFO ),它是一种特殊的线性存储结构。

其中放入乒乓球被称为 压栈(push) ,拿出乒乓球被称为 出栈(pop)

imgimg

维护栈的指针(两个寄存器):

  • ①EBP(栈底指针):固定指向当前栈帧的 “基地址”,用于定位栈帧内的参数、变量(偏移量固定)。

  • ②ESP(栈顶指针):始终指向栈的 “顶部”,随压栈(push)/ 弹栈(pop)动态移动。

1.4 CPU 寄存器 AX

AX 寄存器常用于累加器,广泛用于不同类型的运算,如加减乘除。在汇编中子程序中,子程序的返回值也会存放在这个 AX 寄存器中,这个子程序其实就是函数。

1.5 测试代码

c
#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:初始状态

如下图所示:

img

main 函数执行中,未调用 add 前, 变量 a 先压入栈中,变量 b 后压入栈中,此时 esp 栈顶指针指向下一个即将压入栈中元素的位置,ebp 维护的是 main 函数栈帧中的基底位置。

2.2 阶段 2:调用 add 前

main 准备参数,压栈,C 语言函数参数默认 从右向左 压栈(先压最右边的参数 y,再压左边的 x),然后压入 “返回地址”(add 执行完后,main 要回到的代码位置)。

img
2.2.1 压入 add 的第二个参数

如下图所示:

  • 执行 push 3(将 b 的值压栈)
  • ESP 向下移动 4 字节(int 占 4 字节),指向新栈顶
img
2.2.2 压入 add 的第一个参数

如下图所示:

  • 执行 push 2(将 a 的值压栈)
  • ESP 再向下移动 4 字节
img
2.2.3 压入 "返回地址"

如下图所示:

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

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 字节。
img
2.3.2 设置 add 的 EBP

如图所示:

  • 将当前 ESP 的值赋给 EBP,此时 EBP 成为 add 栈帧的 “基地址”;
  • 此时 EBP 固定指向 add 栈帧的 “基地址”,后续 add 访问参数 / 变量都通过 EBP 的偏移量(如 EBP+8 是 x,EBP+12 是 y)
img
2.3.3 分配 add 的局部变量空间
  • 此时 esp 向低地址处一定的字节大小,同时 add 开辟一定的空间
  • add 有局部变量 int z,需要在栈中至少预留 4 字节空间;
img

2.4 阶段 4:执行 add 函数逻辑

如图所示:计算 z = x + y 核心逻辑:

(1)取 x:访问 EBP+8,值为 2;

(2)取 y:访问 EBP+12,值为 3;

(3)计算 2+3=5,将结果存入 z 的地址

img

2.5 阶段 5:add 函数返回

如图所示:销毁 add 的栈帧,回到 main。

2.5.1 传递返回值

z = 5 存入 EAX,通过将函数中的返回值存入到 CPU 中的 EAX 寄存器中,然后将其带回主函数。

2.5.2 释放 add 的局部变量空间

将 ESP 拉回 EBP 的位置,相当于 “回收” 局部变量 z 的空间。 简单来说,add 函数所开辟的空间被操作系统回收,用户不在拥有访问权限,后续会被其他在栈上开辟的空间所覆盖。

img
2.5.3 恢复 main 的 EBP

如图所示:

img

弹出旧 的 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 字节);
img

2.6 阶段 6:main 接收返回值,继续执行

如图所示:

  • main 从 EAX 寄存器中取出返回值 5,赋值给局部变量 cint c = 5);
  • 后续执行 printf("c=%d", c),输出 c=5,程序结束。
img

3. 小结

函数栈帧的核心要点:

  • ① 创建栈帧:调用者压参数 → 压返回地址 → 被调用者压旧 EBP→ 设新 EBP→ 分配局部变量。

  • ② 使用栈帧:通过 EBP 的固定偏移访问参数(EBP+偏移)和局部变量(EBP-偏移)。

  • ③ 销毁栈帧:释放局部变量 → 恢复旧 EBP→ 弹出返回地址 → 清理参数 → 回到调用者。

二、参数可变的原理

1. printk 的定义

看一个老版本的吧,新版本的嵌套太多层,很难追,printk.c - kernel/printk.c - Linux source code v2.6.22.6,老版本虽然老,但是原理都是一样的。

c
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 * 是最合适的。

c
#define va_list char *

例如:

c
va_list var_arg;

同样的可以将其转换成如下代码,本质是定义了一个 var_arg 指针:

c
char *var_arg;

2.2 va_start

可以参考 C 库宏 – va_start() | 菜鸟教程,这是个宏函数,将上面的指针 va_arg 指向第一个可变参数。

c
// 指向可变参数
#define va_start(ap, param) (ap = (va_list)&param + sizeof(param))

从以上代码可以看到,取了第一个参数的地址,并计算出可变参数的其实地址。这就是为什么可变参数至少需要一个固定参数的原因,需用通过这个固定参数找到可变参数的真实地址。例如

c
va_start(var_arg, n_values);

以上代码可转换为:

c
var_arg = (char *)&n_value + sizeof(n_value);

2.3 va_arg

这个可以参考 C 库宏 – va_arg() | 菜鸟教程,它是获取可变参数的值,并将指针指向下一个参数的地址。简单说就是这个宏检索函数参数列表中类型为 type 的下一个参数。需要注意,这里一定是要指定类型的,不同的类型大小是不同的,指针必须跳过指定的字节数,才能到达下一个参数。

c
// ap 自增 sizeof(t),然后减去 sizeof(t),顺序获取参数的值
#define va_arg(ap, t) (*(t *)((ap = (ap + sizeof(t))) - sizeof(t)))

ap 首先增加了 sizeof(t),然后又减了 sizeof(t)。第一次增加 ap 的值变了,第二次减 ap 的值没有变,是为了取当前参数的值。例如,

c
int value = va_arg(var_arg, int)

以上代码可转换为:

c
var_arg = var_arg + sizeof(int)
int value = *(int *)(var_arg - sizeof(int))

即:

text
┌─────────────────────────────────────────────────────────┐
│  假设当前 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 变量,并使其不再指向任何有效的内存位置。它在可变参数函数的末尾使用,以结束可变参数的处理。

c
// 清理指针
#define va_end(ap) (ap = ((va_list)0))

例如

c
va_end(var_arg);

以上代码可以转换为:

c
var_arg = (char *)0;

3. 另一种定义

网上还看到了下面这种定义,其实比上面的小节就是多了一个宏 _INTSIZEOF

c
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:

text
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相与,结果为8

4. vprintk()

要先再来看一下 vprintk 这个函数,函数太长,这里就先不管了,主要看 vscnprintf()vsnprintf()

c
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 等。例如:

text
printk("%d %s %f", 42, "hello", 3.14);
            │    │      │
            │    │      └── va_arg(args, double)
            │    └───────── va_arg(args, char*)
            └────────────── va_arg(args, int)

5. 一个示例

我们写一个通过可变参数做加法的实例:

c
// 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;
}

根据前面对宏的分析,这里我们可以转换为:

c
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 语言可变参数实现原理 - 醉梦临川 - 博客园

C stdarg.h:可变参数 va_list、va_arg 等宏的使用及原理简介 - kafm - 博客园

C 标准库 – | 菜鸟教程

C 语言可变参数及其原理 - 知乎

嵌入式 Linux——printk:printk 打印机制分析_vprintk-CSDN 博客