函数调用栈原理:栈帧结构、调用约定、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 之后压入栈的数据,全部作废。栈指针直接退回到之前的「水位线」。

下面这张图展示了这个过程:

setjmp/longjmp 栈帧变化示意图 setjmp 时 main 局部变量 返回地址 (main→func) 旧 EBP (main 帧基址) func 局部变量 ESP (setjmp 记录点) longjmp 后 main 局部变量 返回地址 (main→func) 旧 EBP (main 帧基址) func 局部变量 (已废弃) ESP (恢复到此) longjmp 恢复栈指针 注意:longjmp 后,func 的栈帧被「截断」,其局部变量不再有效 ⚠️ 关键限制 longjmp 只能跳转到 尚未返回 的 setjmp 点 如果 setjmp 所在的函数已经返回,行为是未定义的!

嗯,这里要注意:longjmp 跳回后,所有自动变量(局部变量)的值是未定义的,除非它们被声明为 volatile。编译器可能会把变量优化到寄存器里,longjmp 恢复寄存器时,那些优化后的变量就乱掉了。我建议:任何在 setjmp 之后修改、又在 longjmp 之后读取的变量,都加上 volatile

五、从栈帧到协程:思路已经清晰了

你想想看,协程的核心是什么?挂起和恢复。挂起时保存当前执行上下文(栈、寄存器、PC),恢复时还原。这不就是 setjmp/longjmp 的升级版吗?

区别在于:

  • setjmp/longjmp 只能在一个线程的栈上跳来跳去,不能跨栈
  • 协程需要为每个协程分配独立的栈,切换时整个栈都要换

所以,下一层我们要解决的问题是:如何为每个协程分配独立的栈,并在切换时完整地保存和恢复上下文。这就要用到我们下一章要讲的 ucontext 或者汇编级别的上下文切换了。

避坑指南:我曾经在 ARM Cortex-M 上实现协程时,发现 setjmp/longjmp 只保存了通用寄存器,但没保存 FPU 寄存器。结果协程切换后浮点运算全乱套。后来我手动在切换函数里加了 FPU 寄存器的保存/恢复。所以,如果你用到了浮点运算,一定要检查你的 setjmp 实现是否包含了 FPU 上下文。

小结

这一章我们聊了三个东西:

  1. 栈帧结构:EBP 定基、ESP 游走,函数调用就是栈帧的入栈出栈
  2. 调用约定:cdecl、stdcall、fastcall,核心区别是谁清理栈
  3. setjmp/longjmp:非局部跳转,保存/恢复执行环境,是协程的雏形

这些东西看着基础,但它们是协程实现的基石。我当年做协程库时,第一个版本就是用 setjmp/longjmp 搭的,虽然功能有限,但让我彻底理解了「上下文切换」的本质。希望你们也能从这些基础中,看到协程的影子。


公众号:蓝海资料掘金营,微信deep3321