简单理解函数调用:栈帧(Stack Frame)与内存布局简单
文章目录
前言
先说明:不同 CPU、编译器、优化级别、操作系统 ABI 的细节不同。现代 64 位系统里,参数通常优先走寄存器,不一定真的全部压栈。但“调用栈 / 栈帧”的整体模型仍然非常重要。
1、 程序内存的大致布局
高地址
┌──────────────────────┐
│ 栈 Stack │ ← 函数调用、局部变量、返回地址等
│ ↓ │ 通常向低地址增长
├──────────────────────┤
│ │
│ 空闲区 │
│ │
├──────────────────────┤
│ ↑ │
│ 堆 Heap │ 动态分配,如 malloc / new
├──────────────────────┤
│ 全局变量 / 静态变量区 │
├──────────────────────┤
│ 代码区 / 只读数据区 │
└──────────────────────┘
低地址
这里最重要的是:
- 栈:管理函数调用过程中的临时数据。
- 堆:动态申请的对象,生命周期通常不由函数返回直接决定。
- 代码区:保存函数机器指令。
- 全局/静态区:全局变量、静态变量等。
2、 什么是栈、栈帧
栈是一种“后进先出”的内存结构。可以把它想象成一摞盘子:最后放进去的盘子,最先拿出来。
函数调用时,当前函数使用一块栈空间,这一块空间称为:栈帧,Stack Frame。
一个函数的栈帧严格包含:
| 内容 | 说明 |
|---|---|
| 返回地址 | call 指令压入,ret 指令弹出。逻辑上属于被调用者栈帧的边界标记。 |
| 保存的帧指针(旧 EBP/FP) | 指向上一个函数的栈帧基址,形成帧指针链(Frame Pointer Chain)。 |
| 保存的寄存器 | 被调用者承诺不破坏的寄存器(callee-saved),用之前先保存。 |
| 局部变量 | 函数体内定义的变量。 |
| 编译器临时变量 | 表达式求值、结构体复制等产生的匿名临时空间。 |
| 对齐填充 | 满足 ABI 栈对齐要求(如 16 字节)的占位字节。 |
不属于当前函数栈帧,但紧密相关的内容:
| 内容 | 归属 | 说明 |
|---|---|---|
| 函数参数 | 调用者栈帧 | 调用者分配和清理,但被调用者可通过 EBP + 正偏移 访问。 |
| 更上层的栈帧 | 调用链 | 通过帧指针链可以回溯所有调用者的栈帧。 |
每调用一个函数,通常都会产生一个新的栈帧。例如:
main()
└── funcA()
└── funcB()
栈中可能是:
+------------------+
| main 栈帧 |
+------------------+
| funcA 栈帧 |
+------------------+
| funcB 栈帧 | ← 当前正在执行
+------------------+
当 funcB() 返回时:
+------------------+
| main 栈帧 |
+------------------+
| funcA 栈帧 | ← 当前恢复执行
+------------------+**
3、x86 两个关键寄存器:SP 和 BP/FP
可以把 SP 和 BP/FP 理解成两个“指针”:
- SP(Stack Pointer,栈指针):始终指向当前栈顶,栈一有进出它就会变。
- BP/FP(Base Pointer / Frame Pointer,栈帧指针):指向当前函数栈帧的一个固定参考位置,通常在函数执行期间不怎么变。
BP/FP 是当前函数栈帧的固定参考点,用来稳定定位局部变量、旧 BP 和返回地址,简单来说可以理解为:SP 就像一摞盘子最上面的那个位置,放东西、拿东西时它会一直移动;而 BP/FP 就像在当前这一层盘子旁边贴的一个固定标签,专门用来标记“这个函数自己的空间从哪里开始”。因为函数执行时可能不断压入临时数据,SP 的位置会变,如果只靠它就不容易找到局部变量;有了固定的 BP/FP,函数就能一直按固定距离找到自己的变量。
函数调用时 SP 和 BP 的变化:
| 步骤 | 操作 | 操作功能说明 | SP(ESP)变化 | BP/FP(EBP)变化 |
|---|---|---|---|---|
| 调用前 | push args |
将函数需要的参数压入栈中,供被调用函数使用。 | 减小(压入参数) | 不变 |
| 调用 | call func |
调用目标函数:先把返回地址压入栈,再跳转到函数代码处执行。 | 减小(压入返回地址) | 不变 |
| 序言 | push ebp |
保存调用者原来的 EBP,避免当前函数修改后丢失。 |
减小(保存旧 EBP) | 不变 |
| 序言 | mov ebp, esp |
建立当前函数的栈帧基准点,让 EBP 指向当前函数栈帧。 |
不变 | 设为当前 ESP |
| 序言 | sub esp, N |
为当前函数的局部变量、临时数据等预留 N 字节栈空间。 |
减小(分配局部变量) | 不变 |
| 执行 | ... |
执行函数主体代码,例如计算、判断、访问局部变量或调用其他函数。 | 可能浮动 | 通常固定不变 |
| 返回 | mov esp, ebp |
回收当前函数分配的局部变量空间,使栈顶回到栈帧基准位置。 | 增大到 EBP 处 | 不变 |
| 返回 | pop ebp |
从栈中恢复调用者原来的 EBP,恢复调用者的栈帧环境。 |
增大(弹出旧 EBP) | 恢复为调用者的值 |
| 返回 | ret |
从栈中取出返回地址,并跳转回调用函数的位置继续执行。 | 增大(弹出返回地址) | 不变 |
| 调用后 | add esp, X |
清理调用前压入栈中的参数,释放参数占用的栈空间。 | 增大(清理参数) | 不变 |
注意名称区别:
| 通用名称 | x86 32 位名称 | x86 64 位名称 | 含义 |
|---|---|---|---|
| SP(Stack Pointer) | ESP | RSP | 栈顶指针,指向当前栈顶 |
| BP / FP(Base Pointer / Frame Pointer) | EBP | RBP | 栈帧基准指针,指向当前函数栈帧的固定参考位置 |
4、x86 函数调用流程演示
前面讲了栈帧和寄存器,现在用一个完整的例子把整个调用过程串起来。假设有这样一个 C 函数:
int add(int a, int b) {
int sum = a + b;
return sum;
}
int main() {
int result = add(3, 4);
return 0;
}
在 32 位 x86 环境下,main() 调用 add(3, 4) 时,栈的变化大致如下:
// 调用前:main 的栈帧
+------------------+
| main 局部变量 |
+------------------+
| 参数 b = 4 | ← push 4
| 参数 a = 3 | ← push 3
+------------------+
| 返回地址 | ← call add 压入
+------------------+
| 旧 EBP | ← push ebp
+------------------+
| sum 局部变量 | ← sub esp, N 分配
+------------------+ ← 此时 ESP 指向这里,EBP 指向旧 EBP 位置
对应到寄存器表格中的每一步:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 调用前 | push 4 / push 3 |
参数从右往左压栈 |
| 调用 | call add |
压入返回地址,跳转到 add |
| 序言 | push ebp / mov ebp, esp |
保存旧 EBP,建立新栈帧 |
| 序言 | sub esp, N |
为局部变量 sum 分配空间 |
| 执行 | mov eax, [ebp+8] / add eax, [ebp+12] |
取参数 a、b 并相加 |
| 返回 | mov esp, ebp / pop ebp |
撤销栈帧,恢复旧 EBP |
| 返回 | ret |
弹出返回地址,回到 main |
| 调用后 | add esp, 8 |
清理两个参数 |
注意:参数 a 和 b 在 add 的栈帧中位于 EBP 的上方(高地址),通过 [ebp+8] 和 [ebp+12] 访问。这就是为什么 EBP 被称 “帧指针” ——它提供了一个固定的参考点,无论 ESP 如何浮动,参数和局部变量的相对位置都是确定的。
5、STM32函数调用与栈变化
前面用 x86 视角讲了函数调用。但当把这套模型套到 STM32 上时,会发现对不上——因为 STM32 用的是 ARM Cortex-M 内核,采用 AAPCS(ARM 架构过程调用标准),它和 x86 的调用约定有本质区别。
5.1 STM32 和x86的核心差异:没有 call、没有 push ebp
STM32 常见的内核包括 Cortex-M0、M0+、M3、M4、M7、M33 等,它们都属于 ARM Cortex-M 系列。
| 对比项 | x86 | ARM |
|---|---|---|
| 代表厂商 / 产品 | Intel、AMD CPU(电脑 CPU) | 高通骁龙、苹果 M 系列、瑞芯微、STM32、ESP32 等 |
| 常见设备 | 台式机、笔记本、服务器、Windows 电脑 | 手机、平板、树莓派、单片机、苹果 Mac、嵌入式设备、路由器 |
| 指令集特点 | 常被归类为复杂指令集计算机(CISC);性能强、功耗相对较高 | 常被归类为精简指令集计算机(RISC);低功耗是主要优势 |
| 软件兼容性 | x86 程序通常不能直接运行在 ARM CPU 上 | ARM 程序通常也不能直接运行在 x86 CPU 上,需要重新编译或通过模拟/转译 |
| 厂商模式 | Intel、AMD 等厂商自行设计和生产 x86 CPU | ARM 公司主要授权架构或内核,各厂商可基于授权设计芯片 |
| 常见例子 | Intel i5、i7、i9;AMD Ryzen(锐龙) | 骁龙 8 Gen 系列、Apple M1/M2/M3、STM32 单片机 |
x86 和 ARM 都是 CPU 指令集架构,不是操作系统。它们规定了 CPU 可执行的指令、寄存器形式以及程序运行方式。
STM32和x86最关键的几个差异:
| 对比项 | x86(32位) | STM32(Cortex-M / AAPCS) |
|---|---|---|
| 调用指令 | call(自动把返回地址压栈) |
BL / BLX(返回地址存进 LR 寄存器,不压栈) |
| 返回地址 | 保存在栈上 | 首先保存在 LR(R14)寄存器里 |
| 参数传递 | 优先压栈 | 优先用寄存器 R0~R3 传前 4 个参数,多余的才压栈 |
| 返回值 | EAX | R0(64位用 R0:R1) |
| 帧指针 | EBP 几乎必用 | FP 可选,Cortex-M 常用 R7 作 FP,且优化后经常省略 |
| 栈操作指令 | push / pop |
PUSH / POP(本质是 STMDB/LDMIA,可一次压/弹多个寄存器) |
| 栈指针 | ESP/RSP | SP(R13) |
| 返回指令 | ret |
BX LR 或 POP {..., PC} |
一句话总结差异:x86 靠栈传参数和返回地址,STM32 靠寄存器传参数和返回地址,能不用栈就不用栈——这是为了性能,因为 MCU 主频低、访存慢。
💡 建议:想亲眼看到这些差异,可以在 Keil / STM32CubeIDE 里对着
add函数打断点,打开 Disassembly 窗口 和 Register 窗口,单步执行时观察 SP、LR、R0~R3、R7 的变化,会比看图更直观。
5.2 Cortex-M 的关键寄存器
STM32 的 Cortex-M 内核中,函数调用和栈相关的寄存器主要有:
| 寄存器 | 别名 | 作用 | 保存方式 |
|---|---|---|---|
| R0~R3 | — | 用于传递前 4 个函数参数;返回值通常放在 R0;也可作为临时寄存器 | 调用者保存(Caller-saved) |
| R4~R11 | — | 通用寄存器,常用于保存局部变量 | 被调用者保存(Callee-saved) |
| R7 | FP | 常被 GCC 用作帧指针(Frame Pointer),指向当前栈帧基址,但不是硬件强制规定 | 被调用者保存 |
| R11 | FP(可选) | 编译器也可能使用 R11 作为栈帧指针 | 被调用者保存 |
| R12 | — | 临时寄存器,常用于编译器生成的临时计算 | 调用者保存 |
| R13 | SP | 栈指针,始终指向当前栈顶;Cortex-M 中可对应 MSP(主栈指针)或 PSP(进程栈指针) | 硬件自动管理 |
| R14 | LR | 链接寄存器,执行 BL 指令调用函数时,自动保存返回地址 |
硬件自动管理 |
| R15 | PC | 程序计数器,指向当前或下一条将要执行的指令 | 硬件自动管理 |
其中最关键的是:
SP:栈顶指针,压栈时向低地址移动,出栈时向高地址移动。LR:函数调用返回地址通常保存在这里。R0~R3:前四个参数一般直接放在寄存器里,不需要压栈。R0:普通整数返回值通常放在这里。
5.3 STM32函数调用流程演示
先看同样的 C 代码在 STM32 上会怎么执行:
int add(int a, int b) {
int sum = a + b;
return sum;
}
int main() {
int result = add(3, 4);
return 0;
}
在 STM32 上(未优化)大致的汇编如下:
main:
MOVS r1, #4 ; 第 2 个参数 b 放入 R1
MOVS r0, #3 ; 第 1 个参数 a 放入 R0
BL add ; 调用 add,返回地址写入 LR;BL 本身不自动压栈
; add 返回后,结果位于 R0 中
add:
PUSH {r7, lr} ; 保存调用者的 R7,以及当前函数的返回地址 LR
ADD r7, sp, #0 ; R7 作为帧指针,固定指向当前栈帧基准位置
SUB sp, sp, #12 ; 分配 12 字节:a、b、sum 三个 int 局部空间
STR r0, [r7, #-4] ; 将参数 a 保存到局部变量区
STR r1, [r7, #-8] ; 将参数 b 保存到局部变量区
LDR r2, [r7, #-4] ; r2 = a
LDR r3, [r7, #-8] ; r3 = b
ADD r3, r3, r2 ; r3 = a + b
STR r3, [r7, #-12] ; sum = a + b
LDR r0, [r7, #-12] ; 将 sum 放入 R0,作为函数返回值
MOV sp, r7 ; 回收局部变量占用的栈空间
POP {r7, pc} ; 恢复调用者的 R7;将保存的 LR 弹入 PC,返回 main
关键点对照
- 传参不压栈:
add(3,4)的两个参数直接放进 R0、R1,栈没动。 - 返回地址进 LR:
BL add把返回地址写进 LR 寄存器,而不是压栈。 - 序言
PUSH {r7, lr}:因为add内部又要调用别的函数(会覆盖 LR),或需要用 R7 做帧指针,所以在序言里把 LR 和旧 R7 一起压栈保存。如果add是叶子函数(不再调用任何函数),编译器连 LR 都不压栈,直接BX LR返回,栈完全不动! - 返回用
POP {r7, pc}:一次性把栈上保存的旧 R7 弹回 R7,把返回地址弹到 PC,直接跳回去,等价于 x86 的pop ebp+ret合并成一步。
TM32 上的栈变化图(调用 add 期间)
高地址
+------------------+
| main 的局部变量 |
+------------------+ ← 调用 add 前 SP 在这
| 旧 R7 | ← PUSH {r7, lr}
+------------------+
| LR(返回地址) | ← PUSH {r7, lr}
+------------------+
| add 局部变量 | ← SUB sp, #12
| (sum 等) |
+------------------+ ← 此时 SP 指向这里,R7 指向栈帧基址
低地址
对比原文的 x86 图,最大区别:
- 没有单独 push 的“参数 b=4 / 参数 a=3”那两行——它们在寄存器里。
- 返回地址不是 call 自动压的——是序言里手动
PUSH的(且只有非叶子函数才压)。
5.5 STM32 的“压栈/出栈”还有一个特殊场景:中断
x86 讲的都是普通函数调用。STM32 有一个非常特殊的机制——硬件自动压栈(异常入栈):
当中断/异常发生时,Cortex-M 内核硬件自动把 8 个寄存器压入栈:
| 寄存器 | 名称 | 简要作用 | 异常发生时保存的内容 |
|---|---|---|---|
| xPSR | 程序状态寄存器 | 保存处理器当前状态,例如条件标志位、中断状态和当前执行状态 | 异常发生前的程序状态 |
| PC(R15) | 程序计数器 | 保存当前正在执行或下一条将要执行的指令地址 | 异常返回后继续执行的位置 |
| LR(R14) | 链接寄存器 | 普通函数调用时通常保存函数返回地址 | 异常发生前 LR 中的原始值 |
| R12 | 临时寄存器 | 供编译器或程序临时计算使用 | 异常发生前 R12 中的数据 |
| R3 | 通用寄存器 | 可用于传参、计算和保存临时变量 | 异常发生前 R3 中的数据 |
| R2 | 通用寄存器 | 可用于传参、计算和保存临时变量 | 异常发生前 R2 中的数据 |
| R1 | 通用寄存器 | 常用于传递第 2 个函数参数,也可保存临时数据 | 异常发生前 R1 中的数据 |
| R0 | 通用寄存器 | 常用于传递第 1 个函数参数和保存函数返回值 | 异常发生前 R0 中的数据 |
这称为 “自动保存的栈帧(stacked frame)”。中断返回时(执行特殊值 EXC_RETURN 的 BX LR)硬件又自动弹出这 8 个寄存器。这是 STM32 独有的、程序员看不见的“自动压栈/出栈”,x86 完全没有对应概念(x86 中断压栈规则完全不同)。
如果开了 FPU(如 STM32F4/F7),还会额外自动压 S0~S15 + FPSCR,栈帧更大。
5.6 MSP 和 PSP:STM32 有两个栈指针
x86 中通常可以简单理解为只有一个主要栈指针;而 Cortex-M 中有两个栈指针:
| 栈指针 | 全称 | 常见用途 |
|---|---|---|
| MSP | Main Stack Pointer,主栈指针 | 复位后默认使用;异常和中断处理程序必须使用 MSP |
| PSP | Process Stack Pointer,进程栈指针 | 可供线程模式下的任务使用,常见于 RTOS |
在没有 RTOS 的普通裸机 STM32 工程中:
- 程序复位后通常使用 MSP;
- main() 和普通函数一般也都使用 MSP;
- 因此大多数初学阶段可以把 SP 理解为 MSP。
在 FreeRTOS、RTX 等 RTOS 中,常见情况是:
- 任务代码:通常使用 PSP;
- 中断服务程序:使用 MSP;
- 每个任务通常有自己的任务栈。
6. 总结
回顾全文,从程序内存布局出发,认识了栈、堆、代码区、全局/静态区各自的分工;接着理解了栈和栈帧的概念,以及 SP、BP/FP 两个关键寄存器的作用。在 x86 架构下,通过 add(3, 4) 的完整示例,看到了参数压栈、call 压返回地址、序言建立栈帧、执行、返回撤销栈帧并清理参数的完整流程。
随后切换到 STM32(ARM Cortex-M 架构),发现调用模型发生了本质变化:x86 是"栈优先",STM32 是"寄存器优先"。前 4 个参数直接走 R0~R3 寄存器,返回地址存进 LR 而不是压栈,叶子函数甚至可以完全不动栈。同时,STM32 还有两个独特机制:中断时硬件自动压入 8 个寄存器的异常栈帧,以及 MSP/PSP 双栈指针在 RTOS 中的分工。
虽然 x86 和 ARM 在实现载体上不同——一个靠栈、一个靠"寄存器 + 栈"的组合——但"栈帧 / 调用栈"的整体逻辑模型是通用的。理解了这套模型,无论是排查段错误、分析递归、阅读反汇编,还是调试嵌入式中断,都能做到心中有数。
无论选择哪条路,肯定都会对没走的那条路念念不忘吧。所以这世上没有毫不后悔的选择,而且生活也是没有正确答案的,只要坚信选择的道路就是正确答案,并把它变成正确答案就可以了。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)