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

上一章建立了「内存是条街」的图景,这一章回答街上的户籍问题:一个程序跑起来,它的变量、代码、调用关系分别住在哪。核心素材是两个:进程地址空间的分段地图(五段),和函数调用在栈上留下的脚印(栈帧)。这一章也是数据结构课第 12 章「内存视角」的指针课版本,以及计组课第 10 章汇编内容的落地:看懂它,gdb 里的 backtrace、递归爆栈、悬空指针,全都从「背过的名词」变成「亲眼见过现场」。

2.1 五段地图:先看全景

Linux x86-64 上一个进程的地址空间,从低到高:

高地址
┌────────────────────────┐
│  栈 Stack        ↓向下长 │  ← 局部变量、调用帧
│     (空隙)              │
│  内存映射区 mmap  ↑向上长 │  ← 动态库、大块分配
│     (空隙)              │
│  堆 Heap         ↑向上长 │  ← malloc 来的
├────────────────────────┤
│  BSS 段        自动清零   │  ← 未初始化全局变量
│  Data 段        可写     │  ← 已初始化全局变量
│  .rodata       只读     │  ← 字符串字面量
│  Text 段    只读+可执行  │  ← 机器指令
└────────────────────────┘
低地址

每段用一个变量验证归属,这段代码值得跑一遍并对照 cat /proc/self/maps(操作系统课第 10 章解剖过这文件):

int g_inited = 5;          // Data:已初始化全局
int g_zero;                // BSS:未初始化,加载时自动清零
const char *lit = "hi";    // lit 本身在栈,"hi" 两个字节的字面量在 .rodata

int main(void) {
    static int s = 1;      // static 局部变量在 Data 段(不在栈!)
    int local = 2;         // 栈
    int *p = malloc(8);    // p 在栈,p 指向的 8 字节在堆
}

几个反直觉的点专门标出来:字符串字面量不可写(写 lit[0] = 'H' 段错误,只读段的权限位在 MMU 那里把守,计组第 8 章);static 局部变量是「全局变量换了件马甲」,不在栈上,生命周期全程;栈和堆相向生长,中间的大空隙就是它们各自的扩张空间。

2.2 栈:倒着长的函数工作区

栈是一段后进先出的内存,专门服务函数调用。x86-64 上它向低地址生长:push 一次,rsp 减 8。两个寄存器主持大局:

  • rsp(栈指针):指着栈顶(最低的活动地址),随 push/pop 变动。
  • rbp(帧指针):指着当前栈帧的底,函数全程不动,是访问本帧变量的锚点。

「栈」在数据结构课(第 3 章)是抽象类型,在硬件上就两个寄存器加两条指令,push rax 等价于 rsp -= 8; [rsp] = rax。抽象和实现的距离,从来没这么近过。

2.3 栈帧:每次调用的脚印

每次函数调用,栈上多一帧,长这样:

高地址
│  调用者的栈帧
│  参数(第 7 个起;前 6 个走寄存器)   [rbp+16...]
│  返回地址(call 自动压入)          [rbp+8]
│  调用者的 rbp                    ← 当前 rbp 指这
│  局部变量                         [rbp-4] 等
│  ...                             ← rsp 在这(栈顶)
低地址

把一次完整调用拆开看,add(3, 4) 的汇编(System V ABI,计组第 10 章讲过的约定):

mov  edi, 3        ; 第 1 参数进 edi
mov  esi, 4        ; 第 2 参数进 esi
call add           ; ① 压返回地址,跳走
; ---- add 内部(序言) ----
push rbp           ; ② 存调用者的帧底
mov  rbp, rsp      ; ③ 立自己的锚点
sub  rsp, 16       ; ④ 给局部变量留 16 字节
mov  eax, edi      ; 干活,返回值放 eax
; ---- 尾声 ----
mov  rsp, rbp      ; ⑤ 栈顶退回帧底,局部变量一次性作废
pop  rbp           ; ⑥ 还给调用者帧底
ret                ; ⑦ 弹返回地址,跳回去

三个结论从这段汇编里直接读出来,不用背:局部变量的生命周期等于 sub rsp 到 mov rsp, rbp 之间的时间(第 5 章悬空指针的伏笔);参数传寄存器是约定不是魔法(所以第 7 个参数必须压栈);栈帧不是硬件结构,是编译器按 ABI 约定组织的(-O2 优化常常干脆不用 rbp,「帧指针省略」)。

2.4 悬空指针:一次 rsp 的移动

「不能返回局部变量的地址」,机器级真相就是 2.3 的第 ⑤⑥⑦ 步:

int *bad(void) {
    int x = 42;
    return &x;      // 返回的地址 = 本帧的 [rbp-4]
}
int *p = bad();     // bad 返回后,栈顶已弹回,那块内存"退房"了
printf("%d\n", *p); // 读垃圾;若中间又调了别的函数,那里已被覆盖

指针不知道自己过期了(它只是个门牌号),房子换住人了它还去敲门。这也是「返回堆地址是安全的」的原因:堆不随函数返回消失(第 9 章),只要有人负责 free。

2.5 爆栈:8MB 的红线

栈大小有限(Linux 主线程默认 8MB,ulimit -s 可查),两条路撑爆它:

void rec(void) { rec(); }            // ① 无限递归:每层一帧,压到底
int main(void) { int a[10000000]; }  // ② 40MB 局部数组:一帧就超

爆栈的表现是 SIGSEGV,但报错位置往往不在真凶附近(栈警卫页被踩到的那一刻才崩)。递归深度可以用 gdb 的 bt 数(数帧数乘以帧大小和 8MB 对账,数据结构课第 9 章的练习做过);防御手段两选一:大对象上堆,深递归改迭代或显式栈。

常见错误

  • 混淆「指针在栈」和「指针指向的内存」。int *p = malloc(8) 里 p 本身是栈上 8 字节,指向的堆块才是数据;函数返回后 p 消失,堆块还在(这就是泄漏:没人记得它)。
  • 以为 BSS 的自动清零是「默认行为」。全局/静态的清零由加载器完成,堆上 malloc 的内存不保证清零(要零初始化用 calloc),两类「没赋值」的语义完全不同。
  • 在调试器外读 core dump 不看线程栈。段错误的 core 文件里 bt 会直接指出栈帧链,比盯源码猜快得多;第 14 章的排查工具链会系统地过一遍。

练习

  1. 动手:跑 2.1 的归属验证代码,打印各变量地址并按大小排序,和五段地图对号;再加一行修改字符串字面量的代码,记录段错误的真实报错。
  2. 动手:用 gdb 调试一个三层嵌套调用,在最小层 bt 看栈帧链,info frame 看当前帧的 rbp/rsp,和 2.3 的图对号。
  3. 手算:递归函数每层栈帧约 48 字节(3 个保存寄存器 + 局部),8MB 栈能撑多少层?如果每层再加一个 1024 字节的局部数组呢?
  4. 思考:C++ 的 RAII(栈对象退出作用域自动析构)本质是利用了栈帧的什么性质?想想第 ⑤ 步 mov rsp, rbp 一次性作废整个帧,和「析构必被调用」之间的保证关系。