上一章建立了「内存是条街」的图景,这一章回答街上的户籍问题:一个程序跑起来,它的变量、代码、调用关系分别住在哪。核心素材是两个:进程地址空间的分段地图(五段),和函数调用在栈上留下的脚印(栈帧)。这一章也是数据结构课第 12 章「内存视角」的指针课版本,以及计组课第 10 章汇编内容的落地:看懂它,gdb 里的 backtrace、递归爆栈、悬空指针,全都从「背过的名词」变成「亲眼见过现场」。
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 局部变量是「全局变量换了件马甲」,不在栈上,生命周期全程;栈和堆相向生长,中间的大空隙就是它们各自的扩张空间。
栈是一段后进先出的内存,专门服务函数调用。x86-64 上它向低地址生长:push 一次,rsp 减 8。两个寄存器主持大局:
「栈」在数据结构课(第 3 章)是抽象类型,在硬件上就两个寄存器加两条指令,push rax 等价于 rsp -= 8; [rsp] = rax。抽象和实现的距离,从来没这么近过。
每次函数调用,栈上多一帧,长这样:
高地址
│ 调用者的栈帧
│ 参数(第 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.3 的第 ⑤⑥⑦ 步:
int *bad(void) {
int x = 42;
return &x; // 返回的地址 = 本帧的 [rbp-4]
}
int *p = bad(); // bad 返回后,栈顶已弹回,那块内存"退房"了
printf("%d\n", *p); // 读垃圾;若中间又调了别的函数,那里已被覆盖
指针不知道自己过期了(它只是个门牌号),房子换住人了它还去敲门。这也是「返回堆地址是安全的」的原因:堆不随函数返回消失(第 9 章),只要有人负责 free。
栈大小有限(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 消失,堆块还在(这就是泄漏:没人记得它)。bt 会直接指出栈帧链,比盯源码猜快得多;第 14 章的排查工具链会系统地过一遍。bt 看栈帧链,info frame 看当前帧的 rbp/rsp,和 2.3 的图对号。mov rsp, rbp 一次性作废整个帧,和「析构必被调用」之间的保证关系。