第 9 章

函数调用的秘密

函数调用的秘密

每次你写 result = some_function(a, b),背后的 CPU 要完成一个精密的"书签操作":保存当前位置,跳到新位置,执行,然后精确地跳回原位,一分不差。

这件事我们认为理所当然,但它涉及了 CPU 架构中最关键的机制之一:调用栈(Call Stack)

Level 1:建立直觉

函数调用需要解决什么问题

假设你正在看一本书,第 50 页有一个注解说"详见第 200 页"。你翻到第 200 页,看完之后想回到第 50 页的那个位置继续读。

你会怎么做?夹一个书签在第 50 页,然后翻到 200 页,看完后翻回书签处。

函数调用也是这个逻辑:

但这里有个复杂性:如果第 200 页的注解又说"详见第 350 页",你就需要两个书签(一个在 50 页,一个在 200 页)。多层函数调用就是这种情况——就是专门用来存这些书签的地方,后进先出(LIFO)。

什么是栈

栈是内存的一段区域,遵循**后进先出(LIFO)**原则:

调用 A → 调用 B → 调用 C → C返回 → B返回 → A返回

调用时入栈:           返回时出栈:
  ┌─────┐                ┌─────┐
  │  C  │  ← 栈顶 RSP    │     │
  ├─────┤                ├─────┐
  │  B  │                │  B  │  ← 栈顶 RSP(C已出栈)
  ├─────┤                ├─────┤
  │  A  │                │  A  │
  └─────┘                └─────┘

栈从高地址向低地址增长(这是 x86 的约定)。RSP(Stack Pointer)寄存器永远指向当前栈顶。

一次函数调用发生了什么

来看一个具体例子:

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

int main() {
    int x = 10, y = 20;
    int result = add(x, y);  // ← 函数调用在这里
    return result;
}

在 x86-64 上(System V ABI),执行步骤:

调用 add 之前

  1. 参数放入寄存器:rdi = 10(第1个参数),rsi = 20(第2个参数)

执行 call add

  1. 把下一条指令的地址(返回地址)压入栈:push rip_next
  2. RSP -= 8(栈指针下移)
  3. 跳转到 add 函数的入口

在 add 函数内部

  1. (通常)保存调用者保存的寄存器(如 RBP)
  2. 执行函数体:add rdi, rsi(计算结果放 RAX)
  3. 恢复寄存器

执行 ret

  1. 从栈顶读出返回地址
  2. RSP += 8(栈指针上移,"弹出"返回地址)
  3. 跳转到返回地址(回到 main)

回到 main

  1. result = rax(读取返回值)

整个过程,只用了 2 条额外指令(callret),开销极低。

栈帧:函数的私人空间

每个函数在栈上有自己的栈帧(Stack Frame)——一块专属空间,存放:

栈的结构(调用 main → add → deep_func 时):

高地址
┌──────────────────┐ ← 程序启动时的初始 RSP
│  操作系统的帧    │
├──────────────────┤
│  main 的局部变量  │
│  main 保存的寄存器│
│  main 的返回地址  │ ← main 的栈帧基址(RBP 在这里)
├──────────────────┤
│  add 的局部变量   │
│  add 保存的寄存器 │
│  add 的返回地址   │
├──────────────────┤
│ deep_func 的局部变量│
│     ...          │
└──────────────────┘ ← 当前 RSP(栈顶)
低地址

RBP(Base Pointer)是"帧指针",指向当前函数栈帧的基址,方便用固定偏移量访问局部变量。

优化编译(-O2)通常省去 RBP 帧指针,直接用 RSP + 偏移量,节省 2 条指令(push rbp/pop rbp)。

Level 2:原理剖析

汇编级别的函数调用

来看完整的汇编代码:

int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}

编译后(x86-64,-O0,不优化方便观察):

factorial:
    push rbp              ; 保存帧指针
    mov  rbp, rsp         ; 建立新的帧指针
    sub  rsp, 16          ; 分配局部变量空间(n在这里)
    mov  [rbp-4], edi     ; 保存参数 n 到栈上

    cmp  dword [rbp-4], 1 ; if (n <= 1)
    jg   .else
    mov  eax, 1           ; return 1
    jmp  .done

.else:
    mov  eax, [rbp-4]     ; n
    sub  eax, 1           ; n - 1
    mov  edi, eax         ; 参数: n-1
    call factorial         ; 递归调用
    imul eax, [rbp-4]     ; return n * factorial(n-1)

.done:
    add  rsp, 16          ; 释放局部变量空间
    pop  rbp              ; 恢复帧指针
    ret                   ; 返回

注意:每次递归调用都会消耗一定量的栈空间(用于保存 rbp、局部变量等)。factorial(10000) 会消耗约 160,000 字节的栈空间。

栈溢出:递归的死亡陷阱

默认栈大小:

如果递归太深,栈空间耗尽,发生栈溢出(Stack Overflow)——进程崩溃,臭名昭著的 SIGSEGV 信号。

// 危险!无限递归 → 栈溢出
void infinite() {
    infinite();  // 每次调用消耗约 32-64 字节栈空间
    // 约 128,000 次递归后,8MB 栈耗尽
}

防止方法:尾调用优化(Tail Call Optimization,TCO)

如果函数的最后一件事就是递归调用自己(尾调用),编译器可以把它优化成一个跳转而不是函数调用——不需要建新栈帧,直接重用当前帧:

// 普通递归(O(n) 栈空间)
int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);  // 不是尾调用!还有乘法
}

// 尾递归(O(1) 栈空间,如果编译器支持 TCO)
int factorial_tail(int n, int acc) {
    if (n <= 1) return acc;
    return factorial_tail(n - 1, n * acc);  // 尾调用!
}

// 调用:factorial_tail(10, 1)

GCC 在 -O2 及以上会对尾递归进行优化。Haskell、Erlang、Scheme 等语言保证尾调用优化。Python 和 Java 没有 TCO,使用深度递归时要小心。

闭包与上值:函数的"背包"

很多高级语言支持闭包(Closure)——一个函数"携带"着它创建时所在作用域的变量:

def make_adder(n):
    def adder(x):
        return x + n   # n 来自外层作用域!
    return adder

add5 = make_adder(5)
add10 = make_adder(10)
print(add5(3))   # 8
print(add10(3))  # 13

adder 函数"捕获"了变量 n。但 make_adder 返回后,它的栈帧就销毁了——n 去哪了?

在 C 里,这是危险的(返回局部变量的指针会引用已销毁的栈帧,称为悬垂指针)。

在支持闭包的语言里,编译器或运行时会把被捕获的变量从栈上移到**堆(Heap)**上——这叫"变量逃逸到堆"。闭包对象在堆上保存一个对这些"逃逸变量"的引用。

这也是为什么 Python 的闭包、JavaScript 的 arrow function 不会导致悬垂指针——运行时自动管理了这些捕获变量的生命周期。

协程和有栈/无栈协程

普通函数调用是"深度优先"的:必须等子函数返回才能继续。

**协程(Coroutine)**允许函数在执行中途"暂停",把控制权交还给调用者,之后可以"恢复":

def generator():
    yield 1
    yield 2
    yield 3

gen = generator()
print(next(gen))  # 1,函数在 yield 1 暂停
print(next(gen))  # 2,函数从上次暂停处恢复

实现协程有两种方式:

有栈协程(Stackful Coroutine): 每个协程有自己独立的栈,切换时保存/恢复整个栈。

无栈协程(Stackless Coroutine): 不维护独立栈,编译器把协程状态(局部变量等)存在堆上的一个"帧对象"里。

# Python 无栈协程(asyncio)
async def fetch_data(url):
    # await 是暂停点,不消耗调用栈
    data = await http_get(url)  # 暂停,让事件循环处理其他协程
    return process(data)

Go 的 goroutine 之所以能开几十万个,是因为有栈协程的初始栈只有 2KB,而不是线程的 8MB。

Level 3 · 规范怎么定义的(资深)

调用约定与 ABI 的形式化规范

函数调用的行为由 ABI(Application Binary Interface) 严格定义。最广泛使用的是 System V AMD64 ABI(用于 Linux、macOS、FreeBSD)和 Microsoft x64 ABI(用于 Windows)。这两份规范精确定义了:参数如何传递(哪些寄存器、溢出到栈的顺序)、返回值放在哪里、栈帧的布局、异常处理表的格式、以及 red zone(System V 允许函数使用 RSP 以下 128 字节而无需调整栈指针,Windows ABI 不允许)。

DWARF(Debugging With Attributed Record Formats)标准定义了调试信息的格式,包括 CFI(Call Frame Information)——描述如何从任意指令地址回溯(unwind)栈帧的表格。每个函数的 CFI 记录了在该函数执行的每个点,如何恢复 callee-saved 寄存器和上一帧的栈指针。这是 gdb 的 backtrace、C++ 异常处理(exception unwinding)和 Linux perf 的栈采样能工作的基础。DWARF 目前的版本是 DWARF 5(2017 年发布)。

Itanium C++ ABI(虽然以 Itanium 命名,但被 GCC 和 Clang 在所有平台上采用)还定义了 C++ 特有的调用约定:虚函数表(vtable)的布局、RTTI(运行时类型信息)的结构、名字修饰(name mangling)规则等。这些规范确保了不同编译器编译的 C++ 代码可以互相调用。

Level 4 · 边界与陷阱(所有人)

陷阱 1:栈溢出攻击——经典而致命

函数的栈帧中,局部数组和返回地址相邻存放。如果程序没有检查数组越界(如使用 gets() 或不安全的 strcpy()),攻击者可以通过溢出局部数组来覆盖返回地址,将控制流劫持到恶意代码。这就是缓冲区溢出攻击——1988 年的 Morris 蠕虫就利用了这一手法。现代防护措施包括:Stack Canary(在返回地址前插入随机值,函数返回时检查是否被篡改)、ASLR(地址空间随机化)和 NX/DEP(栈内存不可执行)。但即使有这些防护,ROP 攻击仍可通过拼接已有代码片段绕过 NX 保护。

陷阱 2:栈大小有限,深递归必然崩溃

Linux 默认的线程栈大小是 8MB(可通过 ulimit -s 查看)。如果递归深度太大,栈帧累积超过 8MB,程序会触发 SIGSEGV(段错误)。这在处理深层嵌套数据结构(如深度 10 万层的 JSON/XML)时很容易发生。Python 的默认递归限制只有 1000 层(sys.getrecursionlimit()),就是为了在栈溢出前给出友好错误。解决方案是将递归改写为迭代(用显式栈数据结构模拟调用栈),或使用支持尾调用优化(TCO)的语言(如 Scheme、Erlang)——但注意 C/C++ 标准不保证 TCO,只是部分编译器在 -O2 时会实现。

陷阱 3:闭包捕获的变量可能已经失效

在 C/C++ 中,如果 lambda 或函数指针捕获了局部变量的引用(或指针),当外层函数返回后,这些变量所在的栈帧已被回收,访问它们是未定义行为——俗称"悬挂引用"(dangling reference)。Go 和 Rust 通过不同方式解决:Go 的逃逸分析会自动将被闭包捕获的变量从栈"提升"到堆上;Rust 的借用检查器在编译期拒绝这种不安全的闭包。C++ 程序员如果在 std::async 或线程中捕获局部变量的引用,经常会在数月后才在生产环境中遇到随机崩溃——因为栈帧被新的函数调用覆盖,旧数据变成了随机值。

本章评分
4.7  / 5  (38 评分)

💬 留言讨论