进程跑起来时,这条字节数组切成五段;函数调用在栈上压栈帧。
一个进程的地址空间(Linux/x86-64 典型布局)从低地址到高地址分成几块:
高地址
┌──────────────────────┐
│ 栈 Stack │ ← 局部变量、函数调用帧;向低地址生长
│ ↓ 生长方向 │
│ (内存映射区 mmap) │ ← 动态库、大块 mmap
│ ↑ 生长方向 │
│ 堆 Heap │ ← malloc 分配;向高地址生长
├──────────────────────┤
│ BSS 段 │ 未初始化全局/静态变量,自动清零
├──────────────────────┤
│ Data 段 │ 已初始化全局/静态变量
├──────────────────────┤
│ .rodata 只读数据 │ 字符串字面量、const 常量
├──────────────────────┤
│ Text 段(代码) │ 机器指令,只读 + 可执行
└──────────────────────┘
低地址
| 段 | 存什么 | 可写 | 生命周期 |
|---|---|---|---|
| Text | 机器指令 | 只读可执行 | 程序全程 |
| .rodata | 字符串字面量、const | 只读 | 程序全程 |
| Data | 已初始化全局/静态变量 | 是 | 程序全程 |
| BSS | 未初始化全局/静态变量 | 是 | 程序全程 |
| 堆 Heap | malloc 来的内存 | 是 | 手动 free |
| 栈 Stack | 局部变量、调用帧 | 是 | 函数返回即失效 |
int g_inited = 5; // Data 段
int g_zero; // BSS 段(默认 0)
char *s = "hello"; // s 是局部指针,"hello" 在 .rodata
字面量在 .rodata(不能写);全局/局部生命周期不同;栈向下、堆向上长。
栈(stack) 是一段后进先出(LIFO)的内存,专门存函数调用过程中的临时数据。
rsp 指向的位置,在低地址端,随 push/pop 移动。push 一次,rsp 减小 8 字节。push rax ; rsp = rsp - 8;把 rax 存到 [rsp]
pop rax ; rax = [rsp];rsp = rsp + 8
术语:「栈顶」= top of stack = 活动端(rsp),物理地址在低地址端;栈「头朝下」长。
每调用一次函数,栈上就会压入一个「栈帧」,作为这次调用的专属工作区。一个栈帧通常包含(从高地址到低地址):
高地址
│ 参数(第 7 个起,前 6 个在寄存器) │ [rbp + 16...]
│ 返回地址(call 压入) │ [rbp + 8]
│ 调用者的 rbp(保存现场) │ ← rbp 指向这里
│ 局部变量 │ [rbp - 4] ...
│ (可能的保存寄存器 / 红区) │
│ ... 未用空间 ... │
│ │ ← rsp 指向这里(栈顶)
低地址
两个寄存器撑起整个机制:
rbp(基址/帧指针):指向当前栈帧的「底部」,整个函数内不变,作为访问局部变量、参数的锚点。rsp(栈指针):指向栈顶,随 push/pop、sub 而变。局部变量用 [rbp - offset] 访问,参数用 [rbp + offset] 访问——变量名在机器层就是「相对 rbp 的偏移」。
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, rbp 或 leave 一次性全丢弃——所以局部变量的生命周期 = 函数调用期间。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)。
栈的大小是有限的(Linux 默认主线程约 8MB,可用 ulimit -s 查)。两个原因会撑爆它:
int a[10000000]; 直接在栈上申请几十 MB。void rec(void) { rec(); } // 无限递归,几百万层后栈溢出 → SIGSEGV
栈溢出通常表现为段错误(SIGSEGV)。所以:大对象别放栈上,放堆(malloc);深递归要设终止条件或改迭代。
rsp/rbp 两个寄存器和 call/ret/push/pop 指令;「帧」是编译器按约定组织出来的。开了 -O2 编译器可能连 rbp 都省掉(用 rsp 直接寻址),这就是「帧指针省略」。PyFrameObject),但局部变量放在堆上的字典/数组里,靠引用计数回收,生命周期和 C 的栈帧完全不同。