第 2 章 · 程序的内存布局与栈帧

进程跑起来时,这条字节数组切成五段;函数调用在栈上压栈帧。

2.1 进程地址空间:五大段

一个进程的地址空间(Linux/x86-64 典型布局)从低地址到高地址分成几块:

高地址
┌──────────────────────┐
│      栈  Stack        │  ← 局部变量、函数调用帧;向低地址生长
│         ↓ 生长方向      │
│  (内存映射区 mmap)    │  ← 动态库、大块 mmap
│         ↑ 生长方向      │
│      堆  Heap          │  ← malloc 分配;向高地址生长
├──────────────────────┤
│  BSS 段               │  未初始化全局/静态变量,自动清零
├──────────────────────┤
│  Data 段              │  已初始化全局/静态变量
├──────────────────────┤
│  .rodata 只读数据      │  字符串字面量、const 常量
├──────────────────────┤
│  Text 段(代码)        │  机器指令,只读 + 可执行
└──────────────────────┘
低地址
存什么可写生命周期
Text机器指令只读可执行程序全程
.rodata字符串字面量、const只读程序全程
Data已初始化全局/静态变量程序全程
BSS未初始化全局/静态变量程序全程
堆 Heapmalloc 来的内存手动 free
栈 Stack局部变量、调用帧函数返回即失效
int   g_inited   = 5;     // Data 段
int   g_zero;             // BSS 段(默认 0)
char *s = "hello";        // s 是局部指针,"hello" 在 .rodata

字面量在 .rodata(不能写);全局/局部生命周期不同;栈向下、堆向上长。

2.2 栈:栈顶、栈底与生长方向

栈(stack) 是一段后进先出(LIFO)的内存,专门存函数调用过程中的临时数据。

  • 栈底(stack bottom):栈的起始位置,在高地址端,固定不动。
  • 栈顶(stack top):栈指针 rsp 指向的位置,在低地址端,随 push/pop 移动。
  • 生长方向:x86-64 上栈向低地址生长——每 push 一次,rsp 减小 8 字节。
push rax      ; rsp = rsp - 8;把 rax 存到 [rsp]
pop  rax      ; rax = [rsp];rsp = rsp + 8

术语:「栈顶」= top of stack = 活动端(rsp),物理地址在低地址端;栈「头朝下」长。

2.3 栈帧(stack frame)

每调用一次函数,栈上就会压入一个「栈帧」,作为这次调用的专属工作区。一个栈帧通常包含(从高地址到低地址):

高地址
│  参数(第 7 个起,前 6 个在寄存器)  │  [rbp + 16...]
│  返回地址(call 压入)              │  [rbp + 8]
│  调用者的 rbp(保存现场)           │  ← rbp 指向这里
│  局部变量                          │  [rbp - 4] ...
│  (可能的保存寄存器 / 红区)        │
│  ... 未用空间 ...                   │
│                                    │  ← rsp 指向这里(栈顶)
低地址

两个寄存器撑起整个机制:

  • rbp(基址/帧指针):指向当前栈帧的「底部」,整个函数内不变,作为访问局部变量、参数的锚点
  • rsp(栈指针):指向栈顶,随 push/pop、sub 而变。

局部变量用 [rbp - offset] 访问,参数用 [rbp + offset] 访问——变量名在机器层就是「相对 rbp 的偏移」

2.4 函数调用的机器级全过程

int add(int a, int b) { return a + b; }
int main(void) { return add(3, 4); }

System V AMD64 ABI 下,一次调用走这几步:

; ── 调用方(main)──
mov edi, 3        ; 第 1 个参数进 edi
mov esi, 4        ; 第 2 个参数进 esi
call add          ; ① 把返回地址压栈(rsp-=8),跳转到 add

; ── 被调方 add 的序言(prologue)──
push rbp          ; ② 保存调用者的 rbp
mov  rbp, rsp     ; ③ 以当前栈顶建立自己的帧底
sub  rsp, 16      ; ④ 下移栈顶,为局部变量留 16 字节

; ── 函数体:用 [rbp-…] 取局部、[rbp+…] 取参数 ──
mov  eax, edi     ; 参数就在寄存器里
add  eax, esi     ; 3 + 4 = 7(返回值放 eax)

; ── 尾声(epilogue)──
mov  rsp, rbp     ; ⑤ 栈顶回到帧底,等于丢弃所有局部变量
pop  rbp          ; ⑥ 恢复调用者的 rbp
ret               ; ⑦ 弹出返回地址(rsp+=8),跳回 main

要点:

  • call 只做一件事:把下一条指令的地址(返回地址)压栈,然后跳转ret 是它的逆操作——弹出返回地址,跳回去
  • 局部变量是靠 sub rsp, N 一次性留出来的,函数返回时 mov rsp, rbpleave 一次性全丢弃——所以局部变量的生命周期 = 函数调用期间。

2.5 为什么「返回局部变量地址」会悬空

int *bad(void) {
    int x = 42;   // x 存在栈帧里,即 [rbp-4]
    return &x;    // 返回这个地址
}
int *p = bad();   // bad 返回后,它的栈帧已被 rsp 弹回
printf("%d\n", *p);   // [rbp-4] 那块内存已经被「归还」给栈

bad 返回时 rsp 已回调用前位置,x 所在内存不再属于任何变量;后续任何 push/调用都可能覆盖它,*p 读到垃圾甚至崩。

对比之下,堆上的内存不会随函数返回自动消失(第 9 章),所以返回堆地址是安全的(只要有人负责 free)。

2.6 栈溢出(stack overflow)

栈的大小是有限的(Linux 默认主线程约 8MB,可用 ulimit -s 查)。两个原因会撑爆它:

  1. 递归过深(没有/太晚退出)——每层递归都压一个新栈帧。
  2. 局部大数组——int a[10000000]; 直接在栈上申请几十 MB。
void rec(void) { rec(); }   // 无限递归,几百万层后栈溢出 → SIGSEGV

栈溢出通常表现为段错误(SIGSEGV)。所以:大对象别放栈上,放堆(malloc);深递归要设终止条件或改迭代。

2.7 与汇编 / 其他语言对照

  • 汇编:栈帧不是硬件的概念,是编译器的约定(ABI)。CPU 只认识 rsp/rbp 两个寄存器和 call/ret/push/pop 指令;「帧」是编译器按约定组织出来的。开了 -O2 编译器可能连 rbp 都省掉(用 rsp 直接寻址),这就是「帧指针省略」。
  • Java:栈帧同样存在(JVM 每个方法一个栈帧,含操作数栈、局部变量表),但所有对象都在堆上,局部变量表里只放引用和基本类型——所以没有「返回局部对象地址」问题,对象由 GC 兜底。
  • C++:与 C 一致,但 RAII 利用栈帧「退出必析构」的特性自动清理资源(对象在栈上,出作用域自动调用析构函数)。
  • Python:函数调用走 CPython 的调用栈(PyFrameObject),但局部变量放在堆上的字典/数组里,靠引用计数回收,生命周期和 C 的栈帧完全不同。