从一行 C 代码看内存:栈、堆与虚拟地址
一个指针、两个变量、三张内存地图。把「程序运行时到底发生了什么」拆成可以看见的层次。
先看一段再普通不过的 C 代码:
#include <stdio.h>
#include <stdlib.h>
int global_count = 42;
int add(int a, int b) {
int sum = a + b;
return sum;
}
int main(void) {
int x = 7;
int *heap = malloc(sizeof(int) * 4);
heap[0] = add(x, global_count);
printf("sum = %d, address = %p\n", heap[0], (void *)heap);
free(heap);
return 0;
}
这几行里,变量分别住在哪里?heap 指针本身在哪里?printf 打印出来的地址是真实的物理内存吗?
答案藏在「虚拟内存」这四个字里。
四个区域,四种寿命
在 Linux / x86-64 上,一个进程的地址空间大致可以画成这样(从低地址到高地址):
┌──────────────────────────────┐ 高地址
│ 内核空间(用户不可访问) │ 0xffff...
├──────────────────────────────┤
│ 栈 stack │ ↓ 向低地址增长
│ 局部变量、返回地址、寄存器保存 │
├──────────────────────────────┤
│ mmap 区域 │ 共享库、大块 malloc
├──────────────────────────────┤
│ 堆 heap │ ↑ 向高地址增长
│ malloc / free 管理的内存 │
├──────────────────────────────┤
│ BSS(未初始化全局变量) │
├──────────────────────────────┤
│ Data(已初始化全局变量) │
├──────────────────────────────┤
│ 代码段 text(只读) │
└──────────────────────────────┘ 低地址
对照上面的代码:
global_count位于 Data 段,初始值是 42,所以它会被写进可执行文件。x和sum位于栈,函数返回后自动消失。heap这个指针变量本身在栈上;它指向的 4 个int在堆上。add的机器码位于代码段,通常被标记为只读、可执行。
栈:一次函数调用就是一次压栈
当 main 调用 add 时,CPU 会做几件事:
- 把返回地址压入栈;
- 跳转到
add的代码; add建立自己的栈帧(stack frame),保存旧的基址指针,为局部变量sum留出空间;- 执行加法,把结果放进返回值寄存器(x86-64 上是
eax/rax); - 销毁栈帧,弹出返回地址,跳回
main。
栈帧通常长这样:
高地址
┌────────────────────┐
│ 调用者的栈帧 │
├────────────────────┤
│ 返回地址 │ ← call 指令压入
├────────────────────┤
│ 保存的 rbp │ ← 被调用者保存
├────────────────────┤
│ 局部变量 sum │
└────────────────────┘
低地址
栈的分配和释放只是移动一个寄存器(rsp),所以极快。但它也很小:Linux 上默认通常是 8 MB,递归太深就会栈溢出。
堆:malloc 并不等于「向系统要内存」
malloc 是 C 标准库的函数,不是系统调用。
第一次调用 malloc 时,glibc 通常会通过 brk 或 mmap 向内核申请一大块虚拟地址,然后由分配器自己切成小块,维护空闲链表。后续的小额分配大多只是在这块「已经拿到的地」里划格子。
这也是为什么:
malloc(16)不一定刚好拿到 16 字节,分配器需要额外的元数据和对齐;- 多次
malloc的地址通常连续,但不保证; free之后内存不一定立刻还给操作系统,可能留在分配器的空闲池里复用。
当请求很大时(glibc 默认的 mmap 阈值是 128 KiB,可通过 mallopt 调整),分配器会直接调用 mmap,单独映射一块区域。这样 free 时可以立即 munmap 归还。
虚拟地址:每个进程都以为自己拥有整台机器
printf 打印出来的 %p 是虚拟地址,不是物理内存地址。
在 x86-64 上,用户空间通常使用 47 位地址(4 级页表),理论可寻址 字节,也就是 128 TiB;如果开启 5 级页表,可以扩展到 57 位。内核把虚拟地址切成页(通常 4 KiB),通过页表映射到物理页框。
虚拟地址 0x00007f8a... ──▶ 页表 ──▶ 物理页框
│
├─ 页目录项(PML4)
├─ 页目录指针表(PDPT)
├─ 页目录(PD)
└─ 页表(PT)
每次内存访问都查四层页表会慢得无法接受,所以 CPU 内部有一个 TLB(Translation Lookaside Buffer)缓存最近的地址翻译。TLB 命中时,翻译几乎不花时间;未命中时,硬件会进行 page walk。
虚拟内存带来的好处远不止「地址看起来连续」:
- 隔离:进程 A 无法直接读写进程 B 的物理内存;
- 按需分配:
malloc拿到的页在第一次写入前可能根本没有物理页框,触发缺页异常时才真正分配; - 共享:多个进程可以映射同一份只读的 libc 代码页;
- swap:不常用的页可以被换到磁盘。
看一眼真实的内存地图
在 Linux 上,可以读取 /proc/<pid>/maps 观察当前进程的地址空间:
cat /proc/self/maps
输出大致如下(不同系统和 ASLR 会导致地址每次变化):
55d4c0a1f000-55d4c0a20000 r--p 00000000 08:01 131074 /usr/bin/cat
55d4c0a20000-55d4c0a21000 r-xp 00001000 08:01 131074 /usr/bin/cat
7f2b9c400000-7f2b9c428000 r--p 00000000 08:01 131074 /usr/lib/libc.so.6
7f2b9c4a0000-7f2b9c4a2000 rw-p 00000000 00:00 0 [heap]
7ffd5c1f8000-7ffd5c21a000 rw-p 00000000 00:00 0 [stack]
每行的结构是:
起始地址-结束地址 权限 偏移 设备 inode 路径
权限的四个字母分别表示读、写、执行、共享(p 为私有,s 为共享)。代码段通常是 r-xp,堆和栈是 rw-p,只读数据是 r--p。
地址每次都在变:ASLR
如果你多次运行同一个程序,会发现 heap 和栈的地址不同。这是 ASLR(Address Space Layout Randomization)在起作用。
它把栈、堆、共享库的基址随机化,让攻击者难以硬编码跳转地址。代价是调试时地址不固定,但可以用 setarch -R 关闭随机化,或者用 gdb 直接查看符号。
ASLR 不是万能的,也不能替代其他安全机制。但它是「让攻击者多猜几次」的典型工程思路。
三个常见误解
「栈比堆快,所以变量都应该放栈上。」 栈的分配确实快,但栈空间有限,而且大对象在栈上会导致栈溢出。更重要的是,很多性能差异来自缓存局部性,而不是栈或堆本身。
「free 之后内存就没了。」 free 只是把这块内存归还给分配器。指针仍然指向原来的地址,继续访问是未定义行为,可能读到旧值,也可能触发段错误。
「虚拟地址是假的,所以没有用。」 虚拟地址是进程唯一能看见的地址。CPU 的 MMU 负责把它翻译成物理地址。对程序而言,它就是真实存在的空间。
内存是一张会呼吸的地图
写 C 的时候,我们总说「要管理内存」。但真正需要管理的,其实是对生命周期的理解:
- 栈上的东西随作用域生灭;
- 堆上的东西要你亲手结束;
- 全局变量活到进程终止;
- 而虚拟内存给每个进程发了一张看似无限、实际被精心裁剪的地图。
下一次打印一个指针地址时,不妨多想一秒:它属于哪一段?谁在为它分配物理页?它会在什么时候消失?
指针不是地址。指针是关于地址的承诺。
MARGINALIA
留言0
路过的话,留下一枚脚印吧。
BE KIND, BLOOM SOFTLY.
还没有留言。做第一个在这里留下脚印的人吧。