跳转到内容
新建笔记

调用栈:栈帧、调用约定与对象生命周期

调用栈记录尚未结束的函数调用所需的执行状态。按通常的调用与返回顺序,最后进入的调用先返回,因此它具有后进先出的特点;抽象数据结构本身见栈。

设 main() 调用 parse(),parse() 再调用 read_token()。当最内层函数正在执行时,可以从调试器观察到这样的逻辑调用关系:

当前执行 → read_token()
parse() 等待其返回
main() 等待其返回

返回时先继续 parse(),再继续 main()。异常展开、协程挂起和编译器优化会改变简单模型的表现,分析具体程序时要同时看语言规则和目标平台的调用约定。

一个调用对应的栈上状态通常称为栈帧。它可能包含:

内容为什么需要是否一定在栈上
需要保留的寄存器值返回后恢复调用者状态按调用约定和实际使用决定
局部对象及临时数据寄存器不足、需要地址或其他布局要求不一定;可能只在寄存器中或被优化掉
部分函数参数参数数量或类型超出寄存器传递规则不一定;许多 ABI 优先用寄存器
返回位置返回调用点后的下一条指令可能由栈保存,也可能先保存在链接寄存器
对齐空间和调用者预留区满足平台 ABI随平台而变

“有局部变量”不能直接推出“产生栈访问”。 C/C++ 的自动存储期说明对象何时存在,并不指定它必须占用某个物理栈槽。局部 static 对象具有静态存储期,也不随本次调用返回而销毁。

Windows x64 的前四个普通整数或指针参数通常通过 RCX、RDX、R8、R9 传递,调用者还需预留规定的栈空间。Arm AAPCS32 通常使用 r0 到 r3 传递前几个字参数,并使用 lr 记录返回位置。两种规则都说明“参数和返回地址全部先压栈”不是通用结论。Microsoft x64 调用约定、Arm AAPCS32

用汇编检验一个局部变量

跳转到“用汇编检验一个局部变量”

保存为 stack_demo.c:

int plus_one(int x)
{
int result = x + 1;
return result;
}

以 Cortex-M3 为编译目标,生成汇编文件:

终端窗口
arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -O2 -S stack_demo.c -o stack_demo.s

在本页使用的 GCC 13.3.1 中,函数核心为:

adds r0, r0, #1
bx lr

参数和结果都通过 r0 传递,result 没有独立的栈槽;返回使用 lr。这个例子验证的是该编译目标和选项下的产物,不能据此预测其他函数、优化级别或 ABI 的全部布局。把 -O2 改成 -O0 再比较,就能观察调试友好的代码为何常保存更多状态。

生命周期比地址更重要

跳转到“生命周期比地址更重要”
/* 错误示意:不要执行或依赖这段函数的返回值。 */
int *bad_result(void)
{
int value = 42;
return &value;
}

自动对象 value 的生命周期在函数返回时结束。调用者使用这个地址访问原对象没有合法依据;“调试时数值似乎还在”不延长对象的生命周期。

可以根据用途改成返回值、让调用者提供缓冲区,或用明确所有权的动态分配。不要仅为绕开生命周期问题随意改成 static:共享状态、线程安全和再次调用覆盖结果也需要考虑。动态分配的配对释放见运行时堆与动态内存。

每个线程通常有自己的调用栈,其大小受运行环境或线程配置约束。以下情况会提高栈耗尽的风险:

  • 递归层数没有上界,或每层保留较大的状态。
  • 在调用路径上放置大型自动数组。
  • 回调、异常处理和中断嵌套使实际最深调用链超过估计。

栈空间需求不能只用“函数个数 × 一个固定栈帧大小”推算。可结合编译器的栈使用报告、链接结果和运行时测量分析;间接调用与递归仍需补充边界。也不要假设所有平台都向低地址增长,或尾调用一定会消除递归栈帧。

  1. 先核对二进制与调试符号是否对应。
  2. 明确编译目标、优化级别及内联情况。
  3. 找到第一个与业务异常有关的调用帧,检查参数和对象生命周期。
  4. 遇到缺失变量或不完整回溯时,继续检查优化、栈破坏和展开信息,而不是把每个源代码变量都映射成固定内存地址。

本页的 plus_one 已实际交叉编译并核对指令;未在 Cortex-M3 硬件上运行。错误生命周期示例只用于解释,未执行。