高级 #assembly#stack

栈帧与函数调用约定

函数调用时,参数、返回地址、局部变量都存放在哪里?栈帧(Stack Frame)是管理函数调用的核心机制,而调用约定(Calling Convention)则是函数间"约法三章"的规则

“用完就还”的内存区域

你写了一个函数 A,它调用函数 B,B 又调用了 C……所有函数都正确返回了,数据一点没乱。这是怎么做到的?

关键在于一个特殊的内存区域——栈(Stack)

类比:食堂的餐盘架

学校食堂里,餐盘架上的弹簧会把盘子托起来:

  • 放上一个盘子(push),弹簧压下一点
  • 取走最上面的盘子(pop),弹簧弹起一点
  • 最后放上去的盘子总是最先被取走

这和函数调用一模一样:

  • 调用函数时,数据被压入
  • 函数返回时,数据被弹出
  • 后调用的函数先返回(LIFO:Last In, First Out)

栈指针(Stack Pointer)

CPU 用一个专门的寄存器来管理栈——SP(Stack Pointer,栈指针),它始终指向栈顶(当前可读写的位置):

     高地址
     ┌───────────┐
     │  旧的数据  │
     ├───────────┤
     │    ...    │
     ├───────────┤  ← SP 指向这里
     │  栈顶元素  │      (当前栈顶)
     ├───────────┤
     │  空 闲 区  │
     └───────────┘
     低地址

     栈的生长方向:从高地址向低地址(向下生长)

💡 大多数架构(x86、ARM 等)的栈是向下生长的——push 让 SP 减小,pop 让 SP 增大。这看似反直觉,但只是设计惯例。

PUSH 和 POP

PUSH:压入栈

PUSH R1    ; 把 R1 的值压到栈顶

PUSH 干了三件事:

  1. SP = SP - 4(或者 8,取决于架构的字长)
  2. 在 SP 指向的新位置写入数据
  3. 原来的值不受影响
PUSH R1 之前:               PUSH R1 之后:
高地址                      高地址
┌──────────┐                ┌──────────┐
│  ...     │                │  ...     │
├──────────┤                ├──────────┤
│          │ ← SP           │  R1 的值  │ ← SP(减了 4)
├──────────┤                ├──────────┤
│ 空闲区域 │                │ 空闲区域  │
└──────────┘                └──────────┘
低地址                      低地址

POP:弹出栈

POP R2     ; 把栈顶的值取出来,放到 R2 中

POP 也干了三件事:

  1. 读取 SP 指向的值
  2. 存入 R2
  3. SP = SP + 4

🔑 POP 不会真的”擦除”内存中的数据——它只是移动 SP。原来的值还在那里,但被认为是”垃圾”,下次 PUSH 会直接覆盖它。

CALL 和 RET

CALL:调用函数

CALL 指令做了两件事:

  1. 把返回地址压入栈(当前指令的下一条地址)
  2. 跳转到目标函数
_main:          ; 主程序
    MOV R1, #42
    CALL _func  ; ① 压入返回地址  ② 跳转到 _func
    
_next:          ; 这是返回地址 ← 函数执行完会回到这里
    ADD R1, #1

_func:          ; 函数开始
    ; 函数体...
    RET         ; 从栈顶弹出返回地址并跳回去
CALL 之前:                CALL 之后(刚进入 _func):
                          ┌──────────────┐
                          │  返回地址     │ ← SP
                          │(_next 的地址)│
                          ├──────────────┤
                          │  ...         │

RET:返回

RET(Return)指令:

  1. 从栈顶弹出地址
  2. 跳转到那个地址
_func:
    ; 函数体...
    RET      ; pop 返回地址 → 跳回 _next

嵌套调用

_main:
    CALL _A   ; 压入 _main_ret1,跳到 _A
_main_ret1:
    ; ...

_A:
    ; 做点事...
    CALL _B   ; 压入 _A_ret,跳到 _B
_A_ret:
    RET       ; 弹出 _A_ret → 跳回 _A_ret

_B:
    ; 做点事...
    RET       ; 弹出 _main_ret1 → 跳回 _main_ret1
调用过程中的栈变化(A 调用 B):
主程序 → 调用 A → A → 调用 B → B → 返回 → A → 返回 → 主程序

栈状态:
                                                    ┌─────────┐
                                              ┌─────┤ B 返回地址│
                                        ┌─────┤ A 返回地址│
                                  ┌─────┤ A 返回地址│
                                  │ A 返回地址│
                                  │ _main返回地址│
时间 ──────────────────────────────────────────────────────→

💡 这就是栈的威力——无论嵌套多深,只要每个函数正确 RET,控制流就会原路返回,不会迷路。

栈帧(Stack Frame)

每个函数在被调用时,会在栈上分配一块属于自己的区域——栈帧(Stack Frame/Activation Record)

一个典型的栈帧包含:

    高地址
    ┌──────────────────┐
    │   调用者的栈帧     │
    ├──────────────────┤
    │   参数 N          │  ← 如果函数有多个参数,从右向左压入
    │   参数 2          │
    │   参数 1          │
    ├──────────────────┤
    │   返回地址         │  ← CALL 指令压入的
    ├──────────────────┤
    │   旧 BP(基址指针) │  ← 保存调用者的 BP
    ├──────────────────┤
    │   局部变量区域      │
    │   局部变量 1       │
    │   局部变量 2       │
    ├──────────────────┤  ← SP(当前栈顶)
    │   空闲区域         │
    └──────────────────┘
    低地址

基址指针(BP / Base Pointer)

BP 是一个专门用来”定位”栈帧的寄存器。它就像一个标记线——不管栈顶怎么变(push/pop 导致 SP 变化),BP 始终指向当前函数的栈帧底部:

_func:
    ; 函数序言(Prologue)
    PUSH BP        ; 保存老的 BP
    MOV  BP, SP    ; BP = SP,固定当前栈帧的底部
    SUB  SP, #16   ; 为局部变量腾出 16 字节空间
    
    ; 现在可以通过 BP 来访问参数和局部变量了
    ; [BP + 8]  = 第一个参数
    ; [BP + 12] = 第二个参数
    ; [BP - 4]  = 第一个局部变量
    ; [BP - 8]  = 第二个局部变量
    
    ; 函数体...
    MOV [BP - 4], #10      ; 局部变量 1 = 10
    MOV [BP - 8], #20      ; 局部变量 2 = 20
    LOAD R1, [BP + 8]      ; 读取第一个参数
    ADD R1, [BP - 4]       ; 加上局部变量
    
    ; 函数尾声(Epilogue)
    MOV  SP, BP    ; 恢复 SP(释放局部变量空间)
    POP  BP        ; 恢复老的 BP
    RET            ; 返回

🔑 函数序言(Prologue)函数尾声(Epilogue) 是每个函数的”标准包装”——序言建立栈帧,尾声销毁栈帧。

为什么需要栈帧?

画一个实际例子,看看栈帧如何让嵌套函数井井有条:

int add(int a, int b) {
    int result = a + b;
    return result;
}

int main() {
    int x = 3;
    int y = 5;
    int z = add(x, y);
    return z;
}

对应的汇编执行过程(简化 x86):

_main:
    ; 序言
    PUSH BP
    MOV  BP, SP
    SUB  SP, #12             ; 为 3 个局部变量(x, y, z)腾空间
    
    ; int x = 3;
    MOV [BP - 4], #3         ; x = 3
    
    ; int y = 5;
    MOV [BP - 8], #5         ; y = 5
    
    ; 准备参数并调用 add(x, y)
    PUSH [BP - 4]            ; push x
    PUSH [BP - 8]            ; push y
    CALL _add
    
    ; 调用返回后,清理压入的参数
    ADD  SP, #8              ; 弹出两个参数(8 字节)
    MOV  [BP - 12], R0       ; z = 返回值
    
    ; 尾声
    MOV  SP, BP
    POP  BP
    RET
add 被调用时的栈状态:

    高地址
    ┌──────────────────────┐
    │  main 的局部变量区域   │
    │  [BP_main - 4]  = x  │
    │  [BP_main - 8]  = y  │
    │  [BP_main - 12] = z  │
    ├──────────────────────┤
    │  参数: x              │  ← 被 main 压入
    ├──────────────────────┤
    │  参数: y              │  ← 被 main 压入
    ├──────────────────────┤
    │  返回地址 (回 main)    │  ← CALL 指令压入
    ├──────────────────────┤
    │  旧 BP(main 的 BP)  │  ← add 的序言压入
    ├──────────────────────┤  ← BP_add(add 的基址)
    │  add 的局部变量       │
    │  result              │
    ├──────────────────────┤  ← SP_add
    │  空闲区域             │
    └──────────────────────┘
    低地址

不用 BP 行不行?

可以。但有了 BP,你就不需要关心 SP 来回变化了:

  • 参数永远在 [BP + 偏移](正偏移,朝高地址)
  • 局部变量永远在 [BP - 偏移](负偏移,朝低地址)

即使你在函数中 PUSH 了 10 次(SP 变了 10 次),BP 仍然指着原来的位置,参数和局部变量的访问不受影响。

调用约定的核心规则

调用约定(Calling Convention)是什么?

不同的语言、编译器、操作系统之间,函数调用的”游戏规则”可能不一样。调用约定就是一套关于如何调用函数的书面规则——参数放哪?返回值放哪?谁来清理栈?

常见的调用约定

约定参数传递栈清理方优点典型环境
cdecl栈(从右向左压入)调用者清理支持可变参数C 语言(x86)
stdcall栈(从右向左压入)被调用者清理代码体积小Windows API
fastcall前 2 个参数用寄存器,其余用栈被调用者清理速度快编译器优化
System V AMD64前 6 个参数用寄存器(RDI, RSI, …)调用者清理速度快Linux x86-64

cdecl(C Declaration)

; cdecl 调用 add(3, 5)
; 参数从右向左压入栈
; 调用者负责清理栈

    PUSH #5         ; 先压入右边的参数(y=5)
    PUSH #3         ; 再压入左边的参数(x=3)
    CALL _add       ; 调用 add
    
    ADD SP, #8      ; ⚠️ 调用者清理栈(弹出 2 个参数 × 4 字节)

fastcall(寄存器传参)

; fastcall 调用 add(3, 5)
; 前 2 个参数用 ECX、EDX 传递

    MOV ECX, #3     ; 第一个参数 → ECX
    MOV EDX, #5     ; 第二个参数 → EDX
    CALL _add       ; 调用 add
    ; 不需要清理栈——参数没有压入栈!

💡 为什么 fastcall 更快? — 寄存器比内存快几十倍,而且不需要压栈出栈的指令。但寄存器数量有限,参数一多还是得上栈。

谁来保存寄存器?

调用者调用一个函数前,可能正在使用一些寄存器。被调用者也想用这些寄存器——冲突了!解决方案是约定一组规则

调用者保存(Caller-Saved):               被调用者保存(Callee-Saved):
调用者自己保护现场                        被调用者保证不破坏现场

例:                                      例:
_main:                                    _func:
    MOV R1, #100    ; ← 调用者正在用       PUSH R4    ; 保存
    CALL _func      ; 不敢保证 R1 还在       PUSH R5    ; 保存
    ; 可能需要重新加载 R1                    ; 使用 R4, R5...
    ; 或者调用前自己 PUSH R1 ~               POP R5     ; 恢复
    ; 调用结束后 POP R1                     POP R4     ; 恢复
                                           RET
规则含义典型寄存器
调用者保存如果你在乎某个寄存器的值,调用前自己压栈保护R0-R3(临时寄存器)
被调用者保存被调用者必须保证这些寄存器的值在返回后不变R4-R11(通用寄存器)

类比:图书馆占座

  • 调用者保存 = 你离开座位去书架找书,回来发现位子可能被别人占了——所以走之前把值钱东西收好(PUSH)
  • 被调用者保存 = 你找别人帮忙看座位,对方说”你放心走,位子我一定帮你守住”——但举手之劳,对方需要自己搬个凳子坐在你位子上

自己动手:手写一个栈帧

让我们用汇编手动构建一个完整的函数调用。假设要实现:

int multiply_add(int a, int b, int c) {
    return a * b + c;
}

按照 cdecl 约定:

_multiply_add:
    ; 序言
    PUSH BP
    MOV  BP, SP        ; BP = 栈帧底部
    
    ; [BP + 8]  = a(返回地址占 4 字节 + 旧 BP 占 4 字节 = 8 字节偏移)
    ; [BP + 12] = b
    ; [BP + 16] = c
    
    ; 函数体
    LOAD R0, [BP + 8]  ; R0 = a
    LOAD R1, [BP + 12] ; R1 = b
    MUL  R0, R1        ; R0 = a * b
    LOAD R1, [BP + 16] ; R1 = c
    ADD  R0, R1        ; R0 = a * b + c
    
    ; 尾声
    MOV  SP, BP
    POP  BP
    RET                ; 返回值在 R0 中

_main:
    ; 调用 multiply_add(3, 4, 5) = 3 * 4 + 5 = 17
    PUSH #5            ; c
    PUSH #4            ; b
    PUSH #3            ; a
    CALL _multiply_add
    ADD  SP, #12       ; 清理 3 个参数
    
    ; R0 = 17(返回值)

常见错误

错误 1:序言尾声不匹配

_func:
    PUSH BP
    MOV BP, SP
    ; ...函数体...
    POP BP
    RET                ; ✅ 正确

_bad_func:
    PUSH BP
    MOV BP, SP
    ; ...函数体...
    RET                ; ❌ 忘了 POP BP!栈帧泄漏!

⚠️ 每个 PUSH 必须对应一个 POP。否则栈会失衡,函数返回时可能跳到错误地址。

错误 2:栈清理错误(cdecl 下)

    PUSH #3
    PUSH #5
    CALL _add
    ; ❌ 忘了 ADD SP, #8 清理参数
    
    ; 下一次 PUSH 就会在错误的位置操作

错误 3:访问超出栈帧

_func:
    PUSH BP
    MOV BP, SP
    SUB SP, #8        ; 为局部变量分配 8 字节
    MOV [BP + 100], #1 ; ❌ 试图在栈帧外写数据——可能覆盖别人的数据!

小结

栈帧是计算机科学中最重要的基础机制之一——它是函数调用能够正常嵌套、递归、返回的基石:

概念作用
栈(Stack)后进先出的内存区域,向下生长
SP(栈指针)指向当前栈顶
PUSH / POP往栈上放/取数据
CALL / RET调用和返回函数
BP(基址指针)固定标记栈帧位置,便于访问参数和局部变量
栈帧(Stack Frame)每个函数在栈上分配的工作区
调用约定函数间关于参数、返回、寄存器保存的”合同”

为什么这很重要? 没有栈,函数就无法嵌套调用——整个程序只能平铺展开。有了栈,函数可以任意嵌套、递归,而每个函数的局部数据彼此隔离,不会互相干扰。

接下来,你将深入学习参数传递的具体细节——哪些参数放寄存器,哪些放栈上,以及不同的调用约定如何影响程序行为:参数传递(寄存器 vs 栈)