函数调用栈原理:栈帧结构、调用约定、setjmp/longjmp 基础
各位同学,今天我们来聊聊函数调用栈。这东西,说白了就是程序运行时的「后台」。你写的每个函数调用,背后都有一整套入栈出栈的规矩。我刚开始学嵌入式时,总觉得这玩意儿太底层,用不着深究。直到有一次调试一个诡异的栈溢出 bug,调了整整三天……嗯,从那以后,我再也不敢小看栈了。
一、栈帧结构:函数调用的「临时工位」
每次调用一个函数,系统都会在栈上给它分配一块内存区域,这就是栈帧(Stack Frame)。你可以把它想象成一个临时工位——函数干活时用,干完就撤。
一个典型的栈帧长这样:
高地址
+-----------------+
| 调用者局部变量 |
+-----------------+
| 返回地址 | ← 调用者压入
+-----------------+
| 旧帧基址 (EBP) | ← 被调用者压入
+-----------------+
| 被调用者局部变量 |
+-----------------+
| 被调用者临时数据 |
+-----------------+
低地址 (ESP 指向这里)
这里有两个关键寄存器:EBP(帧基址指针)和 ESP(栈顶指针)。EBP 像个锚点,固定住当前栈帧的底部;ESP 则随着 push/pop 不断移动。
核心要点:EBP 是「定海神针」,ESP 是「游标卡尺」。调试时看栈回溯,全靠 EBP 链。
我在项目中遇到过一个问题:某个 RTOS 任务栈设得太小,函数嵌套一深,ESP 直接怼到了栈底外面。结果程序跑着跑着就飞了,连异常处理都进不去。后来我养成了一个习惯——每个任务栈至少留 20% 的余量。
二、调用约定:谁负责打扫战场?
函数调用时,参数怎么传?谁清理栈?返回值放哪?这些规矩就是调用约定。不同的编译器、不同的平台,约定可能不一样。
常见的调用约定有这些:
| 约定名称 | 参数传递方式 | 栈清理者 | 典型平台 |
|---|---|---|---|
| cdecl | 从右向左压栈 | 调用者 | x86 C 默认 |
| stdcall | 从右向左压栈 | 被调用者 | Win32 API |
| fastcall | 前两个参数用寄存器 | 被调用者 | 部分编译器优化 |
| thiscall | this 指针用 ECX | 被调用者 | C++ 成员函数 |
为什么会有这些区别?说白了就是「谁擦屁股」的问题。cdecl 让调用者清理,好处是支持可变参数(比如 printf),因为只有调用者才知道自己传了多少个参数。stdcall 让被调用者清理,生成的代码更紧凑,但可变参数就玩不转了。
个人经验:写跨平台代码时,我建议显式声明调用约定。比如在 x86 上用 __attribute__((cdecl)) 或 __stdcall,避免编译器默认行为不同导致的链接错误。我曾经被这个坑过——Windows 的 DLL 导出函数默认是 stdcall,而我的调用方以为是 cdecl,结果栈直接乱掉。
三、setjmp/longjmp:非局部跳转的「任意门」
setjmp 和 longjmp 是 C 标准库提供的一对「任意门」。setjmp 记录当前执行环境(包括栈指针、程序计数器等),longjmp 则直接跳回那个记录点。这玩意儿比 goto 猛多了——goto 只能在函数内部跳,longjmp 可以跨函数跳。
基本用法:
#include <setjmp.h>
jmp_buf env;
void func() {
printf("进入 func\n");
longjmp(env, 42); // 跳回 setjmp 处,返回值为 42
printf("这行不会执行\n");
}
int main() {
int ret = setjmp(env);
if (ret == 0) {
printf("第一次执行,ret = %d\n", ret);
func();
} else {
printf("从 longjmp 跳回,ret = %d\n", ret);
}
return 0;
}
输出结果:
第一次执行,ret = 0
进入 func
从 longjmp 跳回,ret = 42
你想想看,这像不像协程的「挂起」和「恢复」?setjmp 保存现场,longjmp 恢复现场——这正是我们实现协程的核心机制。
重要警告:longjmp 跳回时,不会清理中间函数分配的局部变量和资源。如果你在 func 里 malloc 了一块内存,longjmp 之后那块内存就泄漏了。我曾经在项目中用 longjmp 做错误恢复,结果内存泄漏查了一下午……后来我改用「资源即释放」的模式,所有资源用之前先注册到 cleanup 链表里。
四、栈帧与 setjmp/longjmp 的关系
setjmp 到底保存了什么?说白了,它保存了三个关键信息:
- 栈指针(ESP):记录当前栈顶位置
- 帧基址(EBP):记录当前栈帧底部
- 程序计数器(EIP):记录下一条指令地址
longjmp 恢复时,直接把 ESP、EBP、EIP 还原到 setjmp 时的值。这意味着:所有在 setjmp 之后压入栈的数据,全部作废。栈指针直接退回到之前的「水位线」。
下面这张图展示了这个过程:
嗯,这里要注意:longjmp 跳回后,所有自动变量(局部变量)的值是未定义的,除非它们被声明为 volatile。编译器可能会把变量优化到寄存器里,longjmp 恢复寄存器时,那些优化后的变量就乱掉了。我建议:任何在 setjmp 之后修改、又在 longjmp 之后读取的变量,都加上 volatile。
五、从栈帧到协程:思路已经清晰了
你想想看,协程的核心是什么?挂起和恢复。挂起时保存当前执行上下文(栈、寄存器、PC),恢复时还原。这不就是 setjmp/longjmp 的升级版吗?
区别在于:
- setjmp/longjmp 只能在一个线程的栈上跳来跳去,不能跨栈
- 协程需要为每个协程分配独立的栈,切换时整个栈都要换
所以,下一层我们要解决的问题是:如何为每个协程分配独立的栈,并在切换时完整地保存和恢复上下文。这就要用到我们下一章要讲的 ucontext 或者汇编级别的上下文切换了。
避坑指南:我曾经在 ARM Cortex-M 上实现协程时,发现 setjmp/longjmp 只保存了通用寄存器,但没保存 FPU 寄存器。结果协程切换后浮点运算全乱套。后来我手动在切换函数里加了 FPU 寄存器的保存/恢复。所以,如果你用到了浮点运算,一定要检查你的 setjmp 实现是否包含了 FPU 上下文。
小结
这一章我们聊了三个东西:
- 栈帧结构:EBP 定基、ESP 游走,函数调用就是栈帧的入栈出栈
- 调用约定:cdecl、stdcall、fastcall,核心区别是谁清理栈
- setjmp/longjmp:非局部跳转,保存/恢复执行环境,是协程的雏形
这些东西看着基础,但它们是协程实现的基石。我当年做协程库时,第一个版本就是用 setjmp/longjmp 搭的,虽然功能有限,但让我彻底理解了「上下文切换」的本质。希望你们也能从这些基础中,看到协程的影子。
公众号:蓝海资料掘金营,微信deep3321