前言

  先说明:不同 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 清理两个参数

  注意:参数 ab 在 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 LRPOP {..., 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

关键点对照

  1. 传参不压栈:add(3,4) 的两个参数直接放进 R0、R1,栈没动。
  2. 返回地址进 LR:BL add 把返回地址写进 LR 寄存器,而不是压栈。
  3. 序言 PUSH {r7, lr}:因为 add 内部又要调用别的函数(会覆盖 LR),或需要用 R7 做帧指针,所以在序言里把 LR 和旧 R7 一起压栈保存。如果 add 是叶子函数(不再调用任何函数),编译器连 LR 都不压栈,直接 BX LR 返回,栈完全不动!
  4. 返回用 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_RETURNBX 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 在实现载体上不同——一个靠栈、一个靠"寄存器 + 栈"的组合——但"栈帧 / 调用栈"的整体逻辑模型是通用的。理解了这套模型,无论是排查段错误、分析递归、阅读反汇编,还是调试嵌入式中断,都能做到心中有数。


  无论选择哪条路,肯定都会对没走的那条路念念不忘吧。所以这世上没有毫不后悔的选择,而且生活也是没有正确答案的,只要坚信选择的道路就是正确答案,并把它变成正确答案就可以了。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐