算计算机计算机底层笔记2026.09.10

从一行 C 代码看内存:栈、堆与虚拟地址

一个指针、两个变量、三张内存地图。把「程序运行时到底发生了什么」拆成可以看见的层次。

6 MIN READ1709 字

先看一段再普通不过的 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 会做几件事:

  1. 把返回地址压入栈;
  2. 跳转到 add 的代码;
  3. add 建立自己的栈帧(stack frame),保存旧的基址指针,为局部变量 sum 留出空间;
  4. 执行加法,把结果放进返回值寄存器(x86-64 上是 eax / rax);
  5. 销毁栈帧,弹出返回地址,跳回 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 级页表),理论可寻址 2472^{47} 字节,也就是 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.

    还没有留言。做第一个在这里留下脚印的人吧。

    登录后会把留言记在你的名字下;不注册也可以直接留言。
    支持换行 · 最多 2000 字0 / 2000