函数调用栈机制总结

本文档总结了函数调用过程中栈的核心机制,适用于大多数编译型语言(C/C++、Go、Rust等)。

一、栈的本质

1.1 什么是栈

栈是两个概念的结合:

层面定义
数据结构LIFO(后进先出)结构
内存区域进程内存中一块连续空间,从高地址向低地址增长
进程内存布局:

高地址 (0x7fffffffffff)
│─────────────────────────│
│      栈区        │ ← 函数调用使用
│  向下增长 ↓             │
│  SP指针在这里           │
│─────────────────────────│
│      堆区        │ ← 动态分配使用
│  向上增长 ↑             │
│─────────────────────────│
│      数据区      │ ← 全局变量
│─────────────────────────│
│      代码区      │ ← 函数代码
│─────────────────────────│
低地址 (0x00000000)

1.2 入栈与出栈的本质

操作本质动作形象比喻
入栈SP向下移动 + 写入数据翻开新页写内容
出栈SP向上移动翻回上一页(数据还在)

关键认知:出栈不删除数据,只是移动SP指针,下次入栈自然覆盖。


二、函数调用完整流程

2.1 调用流程序列图

sequenceDiagram
    participant C as Caller<br>调用者
    participant S as 栈内存
    participant L as Callee<br>被调用者
    participant R as CPU寄存器

    Note over S: 栈从高地址向低地址增长

    rect rgb(200, 230, 200)
        Note over C,S: 入栈阶段
        C->>S: ① 参数入栈 PUSH 参数值
        Note right of S: SP向下移动<br>数据写入栈
        C->>S: ② 返回地址入栈<br>CALL指令自动执行
        Note right of S: SP继续向下<br>返回地址在参数之上
    end

    C->>R: CALL指令跳转<br>PC = callee入口

    rect rgb(230, 230, 200)
        Note over L,S: Callee初始化
        L->>S: ③ BP入栈 PUSH BP
        Note right of S: 保存caller栈帧基址
        L->>S: ④ 局部变量分配 SUB $N, SP
        Note right of S: 分配局部变量空间
    end

    rect rgb(200, 200, 230)
        Note over L: 函数执行
        L->>L: ⑤ 执行函数体
        Note right of L: 计算返回值<br>写入预留位置
    end

    rect rgb(230, 200, 200)
        Note over L,S: 出栈阶段
        L->>S: ⑥ 局部变量释放 ADD $N, SP
        Note right of S: SP向上移动
        L->>S: ⑦ BP出栈 POP BP
        Note right of S: 恢复caller栈帧
        L->>R: ⑧ RET指令<br>返回地址写入PC
        Note right of R: SP向上移动<br>PC跳到返回地址
    end

    rect rgb(200, 200, 200)
        Note over C,S: Caller清理
        C->>S: ⑨ 参数出栈 ADD $N, SP
        Note right of S: SP回到原位置
    end

2.2 栈帧结构

两种栈帧定义(关键区分):

定义范围包含内容分配方式
狭义栈帧Callee私有保存的BP + 局部变量SUB SP, $N 整块分配
广义栈帧整个调用上下文参数区 + 连接区 + 狭义栈帧混合(PUSH + SUB)
flowchart TB
    subgraph CallerContext["广义栈帧 = 整个调用上下文"]
        direction TB

        subgraph CallerFrame["① Caller狭义栈帧(整块分配)"]
            CLV["Caller局部变量<br/>SUB SP, $N 分配"]
            RV["返回值空位(预留)"]
        end

        subgraph CallConvention["② 调用约定区(PUSH分配 - Caller负责)"]
            PA["参数区<br/>Caller PUSH 入栈"]
            RA["返回地址<br/>CALL指令自动压入"]
        end

        subgraph CalleeFrame["③ Callee狭义栈帧(整块分配)"]
            SB["保存的BP<br/>Callee PUSH BP"]
            LLV["Callee局部变量<br/>SUB SP, $N 分配"]
        end
    end

    CLV --> RV
    RV --> PA
    PA --> RA
    RA --> SB
    SB --> LLV

    style CallerFrame fill:#e3f2fd
    style CallConvention fill:#c8e6c9
    style CalleeFrame fill:#ffe0b2
    style SB fill:#fff9c4

    Note1["Callee的BP指向这里<br/>(狭义栈帧起点)"] -.-> SB
    Note2["SP指向这里<br/>(栈顶,不断变化)"] -.-> LLV

关键理解

  1. Caller/Callee 的相对性(重要!):

    • Caller 和 Callee 是相对于一次特定函数调用的角色
    • 一个函数既是上一级调用的 Callee,又是下一级调用的 Caller
    • 示例:A() → B() → C(),B 是 A→B 的 Callee,同时也是 B→C 的 Caller
    • 广义栈帧也是相对于一次特定调用定义的,不是全局固定的
  2. 参数区不属于 Callee 的狭义栈帧,而是 Caller 用 PUSH 放在公共区域

    • Callee 通过正偏移 [BP+16], [BP+24] 访问参数(“向上看” Caller 的空间)
  3. PUSH 与 SUB 的区别

    • PUSH:逐个压入,LIFO,适合 Caller 放参数
    • SUB SP:整块分配,随机访问,适合 Callee 管理局部变量

三、入栈责任分配

3.1 责任分配表

动作负责者执行方式时间点
参数入栈Caller主动执行PUSH/MOVCALL前
返回地址入栈CALL指令硬件自动CALL时
BP入栈Callee主动执行PUSH函数prologue
局部变量分配Callee主动执行SUB SP函数prologue

3.2 入栈顺序

flowchart BT
    P4["④ 局部变量<br/>Callee分配"]
    P3["③ BP<br/>Callee入栈"]
    P2["② 返回地址<br/>CALL自动压入"]
    P1["① 参数<br/>Caller入栈"]

    P1 --> P2 --> P3 --> P4

    style P4 fill:#ffe0b2
    style P3 fill:#fff9c4
    style P2 fill:#ffcdd2
    style P1 fill:#c8e6c9

    Note["栈顶在④位置(局部变量)<br/>返回地址在②位置(不是栈顶)"]

    P4 -.-> Note

四、出栈责任分配

4.1 责任分配表

动作负责者执行方式时间点
局部变量释放Callee主动执行ADD SP函数epilogue
BP出栈Callee主动执行POP BP函数epilogue
返回地址写入PCRET指令硬件自动RET时
参数出栈Caller主动执行ADD SP返回后

4.2 出栈顺序

出栈顺序(与入栈完全相反):

④ 局部变量释放(栈顶)  ← 先出栈
③ BP出栈                ← 其次
② 返回地址弹出(RET)   ← 再次
① 参数清理              ← 最后

遵守LIFO:最后入栈的最先出栈

五、参数传递机制

5.1 参数传递方式

方式适用场景实现机制
寄存器传递参数少、值小参数存入AX/BX/CX等寄存器
栈传递参数多、值大参数PUSH入栈

5.2 参数入栈流程

flowchart TD
    A["Caller计算参数值"] --> B{"参数传递方式?"}
    B -->|寄存器| C["MOV 参数值, AX"]
    B -->|栈传递| D["PUSH 参数值<br/>SP向下移动"]
    C --> E["执行CALL指令<br/>自动压入返回地址"]
    D --> E
    E --> F["Callee从寄存器或栈取参数"]
    F --> G["执行函数体"]
    G --> H["Caller清理参数"]

六、返回值传递机制

6.1 返回值传递方式

方式适用场景实现机制
AX寄存器单返回值、小值Callee写入AX,Caller读取AX
栈传递多返回值、大值Caller预留空间,Callee写入

6.2 返回值栈传递的关键机制

关键认知:返回值空间是Caller预留的固定位置,Caller将此空间的地址作为隐藏的第一个参数传递给Callee

System V AMD64 ABI规范定义:
"If the type has class MEMORY, then the caller provides space for the 
return value and passes the address of this storage in %rdi as if it 
were the first argument to the function."

这实际上是一个隐藏参数传递机制,而非简单的”预留固定位置”。

flowchart BT
    C1["Caller局部变量"]
    RV["返回值空位(预留)"]
    PA["参数"]
    RA["返回地址"]
    BP["BP"]
    C2["Callee局部变量"]

    C1 --> RV --> PA --> RA --> BP --> C2

    style RV fill:#bbdefb
    style C2 fill:#ffe0b2

    Note["隐藏参数机制:<br/>1. Caller预留返回值空间<br/>2. Caller将空间地址放入rdi<br/>3. rdi作为隐藏的第一个参数<br/>4. Callee通过rdi写入返回值"]

    RV -.-> Note

6.3 栈传递返回值流程

关键点:Caller将返回值空间地址作为隐藏参数(通过rdi)传递给Callee。

flowchart TD
    subgraph Caller
        A1["预留返回值空间<br/>SUBQ $N, SP"] --> A0["将返回值空间地址<br/>放入rdi(隐藏参数)"]
        A0 --> A2["压入其他参数"]
        A2 --> A3["执行CALL"]
        A6["从栈读取返回值<br/>MOVQ 偏移, result"] --> A7["清理返回值空间和参数"]
    end

    subgraph Callee
        A4["prologue初始化栈帧"] --> B1["执行函数体"]
        B1 --> B2["计算返回值"]
        B2 --> B3["通过rdi写入返回值<br/>MOVQ result, (rdi)<br/>不是PUSH,不移动SP"]
        B3 --> B4["epilogue清理栈帧"]
        B4 --> B5["执行RET<br/>rax = rdi(返回地址)"]
    end

    A3 --> A4
    A5["返回后"] --> A6

    A3 -.-> A5
    B5 -.-> A5

    style B3 fill:#ffe0b2
    style A0 fill:#fff9c4

七、CALL与RET的对称关系

7.1 指令对比

flowchart LR
    subgraph CALL["CALL指令"]
        C1["① PUSH 返回地址"]
        C2["② JMP 函数入口"]
    end

    subgraph RET["RET指令"]
        R1["① POP返回地址到PC"]
        R2["② JMP返回地址"]
    end

    CALL -.-> RET

    style CALL fill:#c8e6c9
    style RET fill:#ffcdd2

    Note1["Caller主动执行<br/>硬件自动压返回地址"]
    Note2["Callee主动执行指令<br/>硬件自动弹返回地址"]

    CALL -.-> Note1
    RET -.-> Note2

7.2 对称机制

对比项CALLRET
执行者Caller主动调用Callee主动执行
返回地址处理自动压入栈自动弹出到PC
SP操作向下移动(压栈)向上移动(弹栈)
PC跳转跳到callee入口跳到返回地址

八、完整调用示例

8.1 代码示例

// 示例代码(Go/C通用概念)
func a() {
    x := 10
    b(10)      // a调用b
    y := 20
}
 
func b(param int) int {
    z := param + 5
    return z
}

8.2 完整栈变化时序图

stateDiagram-v2
    [*] --> S1: a开始执行

    S1: 栈状态1<br/>a局部变量 x=10
    S2: 栈状态2<br/>参数入栈
    S3: 栈状态3<br/>CALL后
    S4: 栈状态4<br/>b栈帧建立
    S5: 栈状态5<br/>返回值写入
    S6: 栈状态6<br/>局部变量释放
    S7: 栈状态7<br/>BP出栈
    S8: 栈状态8<br/>RET后
    S9: 栈状态9<br/>清理后

    S1 --> S2: caller入栈参数
    S2 --> S3: CALL压入返回地址
    S3 --> S4: callee入栈BP+局部变量
    S4 --> S5: b执行,写返回值
    S5 --> S6: ADD SP释放局部变量
    S6 --> S7: POP BP
    S7 --> S8: RET弹出返回地址
    S8 --> S9: caller清理参数

    note right of S5
        返回值写入固定位置
        不是入栈,不动SP
    end note

    note right of S7
        栈顶是返回地址
        RET从栈顶取值跳转
    end note

8.3 栈内存详细变化图

flowchart BT
    subgraph S1["状态1: a执行中"]
        A1["a局部变量 x=10"]
    end

    subgraph S2["状态2: 参数入栈"]
        A2_1["a局部变量 x=10"]
        A2_2["参数 10"]
    end

    subgraph S3["状态3: CALL后"]
        A3_1["a局部变量 x=10"]
        A3_2["参数 10"]
        A3_3["返回地址"]
    end

    subgraph S4["状态4: b栈帧建立"]
        A4_1["a局部变量 x=10"]
        A4_2["参数 10"]
        A4_3["返回地址"]
        A4_4["BP(保存的)"]
        A4_5["局部变量 z"]
    end

    subgraph S5["状态5: 返回值写入"]
        A5_1["a局部变量 x=10"]
        A5_2["返回值空位 15"]
        A5_3["参数 10"]
        A5_4["返回地址"]
        A5_5["BP"]
        A5_6["局部变量 z=15"]
    end

    subgraph S8["状态8: RET后"]
        A8_1["a局部变量 x=10"]
        A8_2["返回值 15"]
        A8_3["参数 10"]
    end

    S1 --> S2 --> S3 --> S4 --> S5 --> S8

    style A5_2 fill:#bbdefb
    style A4_5 fill:#ffe0b2
    style A5_6 fill:#ffe0b2

    Note["← 栈顶(SP)位置"]
    A4_5 -.-> Note
    A5_6 -.-> Note

九、核心概念总结

9.1 栈的本质

概念说明
内存区域 + 使用规则
栈帧函数占用的栈空间
SP栈指针,指向栈顶
BP基址指针,标记栈帧边界
入栈SP向下移 + 写数据
出栈SP向上移(数据不删)

9.2 函数调用本质

本质说明
控制流转移PC跳转(CALL→callee,RET→caller)
数据流传递参数和返回值传递(寄存器或栈)
上下文保存返回地址和栈帧基址保存

9.3 栈布局约定

flowchart BT
    subgraph Stack["标准栈帧布局(从高地址到低地址)"]
        direction BT

        subgraph GFrame["广义栈帧(整个调用上下文)"]
            direction BT

            L1["Caller局部变量<br/>(Caller狭义栈帧 - SUB分配)"]
            L2["返回值空位(预留)"]
            L3["参数<br/>(Caller PUSH - 不属于Callee狭义栈帧)"]
            L4["返回地址<br/>(CALL自动)"]
            L5["BP(保存的)<br/>(Callee PUSH - 狭义栈帧起点)"]
            L6["Callee局部变量<br/>(Callee狭义栈帧 - SUB分配)"]
            L7["[空]"]
        end
    end

    L1 --> L2 --> L3 --> L4 --> L5 --> L6 --> L7

    style L1 fill:#e3f2fd
    style L3 fill:#c8e6c9
    style L4 fill:#ffcdd2
    style L5 fill:#fff9c4
    style L6 fill:#ffe0b2

    BP_Note["BP指向这里<br/>(Callee狭义栈帧基址)"]
    SP_Note["SP指向这里<br/>(栈顶)"]

    L1 -.-> BP_Note
    L7 -.-> SP_Note

布局说明

  • PUSH 区(参数、返回地址):Caller/CALL指令管理,LIFO访问
  • SUB 区(局部变量):Callee整块分配,随机访问
  • 边界:保存的BP是Callee狭义栈帧的起点
  • 相对性:上图是相对于一次特定调用(如 A→B)的静态视角。在实际运行中,B 既是 A→B 的 Callee,又可能是 B→C 的 Caller。广义栈帧是针对单次调用定义的完整上下文。

9.4 编译器角色

任务编译器做什么
分析函数调用确定参数数量、类型、大小
生成入栈指令在Caller代码生成参数入栈
生成栈帧管理在Callee生成prologue/epilogue
确定返回值方式根据返回值选择寄存器或栈传递
生成返回值处理Caller预留/读取,Callee写入

十、常见误区澄清

flowchart LR
    subgraph Misconceptions["常见误区"]
        M1["出栈删除数据"]
        M2["返回地址是栈顶"]
        M3["b知道返回地址"]
        M4["写返回值要入栈"]
        M5["局部变量在栈顶"]
    end

    subgraph Truth["正确理解"]
        T1["出栈只移动SP<br/>数据还在内存"]
        T2["b入栈BP和局部变量后<br/>返回地址在栈中间"]
        T3["b不需要知道<br/>RET取栈顶值跳转"]
        T4["返回值空间是预留位置<br/>MOV写入,不是PUSH"]
        T5["局部变量在栈帧内部<br/>BP之下"]
    end

    M1 --> T1
    M2 --> T2
    M3 --> T3
    M4 --> T4
    M5 --> T5

    style Misconceptions fill:#ffcdd2
    style Truth fill:#c8e6c9
误区正确理解
出栈删除数据出栈只移动SP,数据还在内存,下次入栈自然覆盖
返回地址是栈顶b入栈BP和局部变量后,返回地址在栈中间位置
b知道返回地址b不需要知道,RET只是取栈顶值跳转
写返回值要入栈返回值空间地址作为隐藏参数传递,Callee通过该地址写入,不是PUSH
局部变量在栈顶局部变量在b栈帧内部,BP之下,不是栈顶

附录:汇编指令对照表

操作典型汇编指令说明
参数入栈PUSH 参数 / MOV 参数, (SP)Caller执行
调用函数CALL 函数名Caller执行,自动压返回地址
保存BPPUSH BPCallee prologue
分配局部变量SUB $N, SPCallee prologue
释放局部变量ADD $N, SPCallee epilogue
恢复BPPOP BPCallee epilogue
返回RETCallee执行,自动弹返回地址到PC
清理参数ADD $N, SP / POPCaller返回后执行
预留返回值空间SUB $N, SPCaller执行(大结构体返回)
传递返回值地址LEAQ 返回值空间, RDICaller执行,作为隐藏参数
写返回值(栈)MOV result, (RDI)Callee执行,通过隐藏参数写入
返回地址(栈返回)MOV RDI, RAXCallee返回时,rax包含返回值地址
读返回值(寄存器)MOV AX, resultCaller返回后执行
读返回值(栈)MOV 偏移(SP), resultCaller返回后执行

参考资料

  • System V AMD64 ABI Specification (refspecs.linuxbase.org)
  • Go Internal ABI Specification (go.dev/src/cmd/compile/abi-internal)
  • Computer Systems: A Programmer’s Perspective (CSAPP)
  • x86/ARM Calling Conventions

附录二:Go与C的ABI差异对比

Go使用独特的ABI(ABIInternal),与C的System V AMD64 ABI有显著差异:

特性C (System V AMD64)Go (ABIInternal)
参数传递寄存器rdi, rsi, rdx, rcx, r8, r9(6个)AX, BX, CX, DI, SI, R8-R11(9个)
返回值寄存器rax, rdx(最多2个)AX, BX, CX等(更多)
Callee-save寄存器rbx, rbp, r12-r15需保存没有callee-save寄存器
栈帧布局返回地址 → BP → 局部变量返回地址 → BP → 局部变量 → 出栈参数
栈溢出检查无(依赖操作系统)每个函数都有栈增长检查
栈对齐16字节对齐8字节对齐(amd64)
返回大结构体隐藏参数(rdi传地址)Caller预留空间,Callee直接写入Caller栈帧

关键差异说明

  1. 没有callee-save寄存器:Go函数可以覆盖任何寄存器,这简化了GC和编译器,但增加了调用开销
  2. 栈增长检查:每个Go函数入口都会检查栈空间,不足时调用runtime.morestack
  3. 参数溢出参数区:Go中超过寄存器数量的参数放在Caller栈帧的”outgoing arguments”区域
flowchart LR
    subgraph C_ABI["C栈帧布局"]
        C1["返回地址"] --> C2["BP"] --> C3["局部变量"]
    end
    
    subgraph Go_ABI["Go栈帧布局"]
        G1["返回地址"] --> G2["BP"] --> G3["局部变量"] --> G4["outgoing args"]
    end
    
    style G4 fill:#fff9c4

附录三:内容验证来源

本文档核心内容已通过以下方式验证:

汇编验证

  • GCC生成的x86-64汇编代码分析
  • Go编译器生成的汇编代码分析
  • 实际函数调用栈帧结构验证

官方规范

  • System V AMD64 ABI规范明确定义隐藏参数机制
  • Go官方ABI文档说明寄存器传递规则

学术来源

  • University of Virginia x86 Assembly Guide
  • CMU Lecture Notes on Calling Conventions
  • Harvard CS61 Assembly Guide

社区讨论

  • Stack Overflow关于栈增长方向的讨论
  • Hacker News关于ABI参数传递的分析