本篇位置 虚拟化内存里唯一一篇以 C 为主的。第 11 篇讲分配器内部怎么实现
运行环境 容器里的 Linux,gcc

一、为什么这一篇必须用 C

前面八篇都用 Python。这一篇换 C,理由很实在:

在 Python 里,你根本碰不到这些错误。 对象什么时候释放,由引用计数和垃圾回收决定;你拿不到裸指针,也没法释放两次。这是好事——但也意味着你看不见下面在发生什么

而下面在发生的事,会以另一种形式回到你身上:你的服务内存一直涨、某个请求偶尔返回别人的数据、程序在毫不相干的地方崩溃。这些症状的根,都在这一篇里。

先分清两组词

⚠️ 这里有一组极易混的概念,现在分清,后面几篇都要用:

谁管 什么时候发生
malloc / free glibc 的库函数,在用户态 每次你调用
brk / mmap 内核的系统调用 只在库函数手里的地不够用时

malloc 不是系统调用。 它是一个跑在你进程里的库,管着第 8 篇看到的那块 [heap]。地不够了它才去找内核批一块。所以:

  • malloc 通常不进内核,很快。
  • free 通常不把内存还给操作系统,只是还给这个库。所以 free 之后你的进程 RSS 常常一点不降。

打个比方

malloc 是向仓库管理员租一个格子。

  • 你说要多大,他找一个够用的格子,把钥匙给你。
  • 你用完了要退租free),否则这个格子永远算在你名下。
  • 管理员的账本——哪个格子被租了、多大——就贴在每个格子的旁边(这一点很重要,第四节会用到)。
  • 而管理员自己是从楼管那儿整层整层批下来的brk/mmap)。你退租,格子回到他手里,但他不会把整层楼还给楼管

⭐ 这就是为什么"我 free 了,为什么内存没降"——你退给的是管理员,不是楼管。

二、错法一:忘记退租

for (int i = 0; i < 20000; i++) {
    char *p = malloc(4096);
    memset(p, 1, 4096);            /* 必须真写,否则物理页都不会分配 */
}
  一开始              VmRSS:	    1164 kB
  漏掉 20000 次之后 VmRSS:	   81620 kB

从 1 MB 涨到 80 MB。 每一块都还租着,管理员不能重复出租。

⚠️ 注意代码里那句 memset如果不写这一行,RSS 几乎不涨。 因为 malloc 给你的只是一个地址(第 8 篇说的房号),物理内存要等你真的去碰它才分配。这是第 12 篇"按需分页"的预告,也是内存监控里的一个经典困惑:top 里的 VIRT 和 RES 差好几倍,原因就在这儿。

这个错法是最良性的一个——症状明显,工具(valgrind、ASan、堆快照)都能抓。后面几个都比它阴险。

三、错法二:退租之后接着用

char *p = malloc(64);
strcpy(p, "机密数据");
printf("  free 之前: %s\n", p);
free(p);
printf("  free 之后: %s        <-- 没崩,也没报错\n", p);
char *q = malloc(64);              /* 很可能拿到同一块地 */
strcpy(q, "别人的数据");
printf("  别人 malloc 之后再看 p: %s\n", p);
  free 之前: 机密数据
  free 之后: b??
        <-- 没崩,也没报错
  别人 malloc 之后再看 p: 别人的数据
  [退出状态 0]

三行输出,三件事:

  1. free 之后p 没有崩溃。那块内存还在你的地址空间里,权限没变——内核完全不知道你"退租"了,那是你和 glibc 之间的事
  2. 读到的内容已经变成垃圾了。free 会在这块地的开头写上它自己的链表指针。
  3. 最关键的一行:后来 q 拿到了同一块地。于是通过早就该作废的 p,你读到了 q 的数据

⭐ 把第 3 条翻译成生产环境的语言:一个请求处理完释放了缓冲区,下一个请求拿到同一块地写入了它的数据,而前一个请求的代码还拿着旧指针在读。 这就是"用户 A 偶尔看到用户 B 的数据"这类事故的一种典型成因。

回到仓库:你退了租,管理员把格子租给了别人,而你手上那把钥匙还能开。 你以为你在看自己的东西。

四、错法三和五:glibc 会当场翻脸

有两种错,glibc 检查得出来,而且不客气:

char *p = malloc(64);
free(p);
free(p);            /* 释放两次 */
  第一次 free 成功,马上第二次……
free(): double free detected in tcache 2
Aborted
  [退出状态 134]
char buf[16];       /* 栈上的数组 */
free(buf);          /* 释放一个不是 malloc 来的指针 */
double free or corruption (out)
Aborted
  [退出状态 134]

这两条错误信息是 glibc 自己打出来的,不是我写的。 退出码 134 = 128 + 6,6 是 SIGABRT

它凭什么发现?因为账本就贴在格子旁边——每块内存前面有一个 header 记着大小和状态标志。free 的时候它去读这个 header:已经标成空闲了 → 双重释放;这个地址前面根本没有一个合法的 header → 无效指针。

⭐ 请注意这两个检查是免费的——glibc 本来就要读那个 header 才能知道块多大。所以它们默认开着。下一个错法之所以抓不到,正是因为抓它不免费。

五、⭐ 错法四:最危险的那个,什么都没发生

char *p = malloc(16);
printf("  往一块 16 字节的地里写 64 字节……\n");
memset(p, 'A', 64);
free(p);
printf("  free 成功了?\n");
  往一块 16 字节的地里写 64 字节……
  free 成功了?
  [退出状态 0]

没有任何错误。写出界 48 字节,free 成功,退出码 0。

为什么不报错?因为没有人在看

  • 内核不看。 第 8 篇讲过,权限是按页(4096 字节)管的。你那 16 字节和后面 48 字节在同一页里,页的权限是可读可写——从 MMU 的角度看,这次访问完全合法
  • glibc 不看。 它只在 free 的时候读 header。你写坏的是后面那块的 header,而那块这次没被 free,所以没人去读它。

那 48 个 'A' 就那么躺在那儿了。炸弹在,引信没点。 等到某个毫不相干的时刻,别的代码去 free 后面那块,才会崩在那里——崩溃点和错误点隔了十万八千里。这就是内存 bug 最折磨人的地方。

检查不是做不到,是默认没开

把同一个程序用 AddressSanitizer 编一遍:

gcc -fsanitize=address -o bugs_asan bugs.c && ./bugs_asan 4
==12==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x502000000020
WRITE of size 64 at 0x502000000020 thread T0
    #0 in memset
    #1 in main (/lab/bugs_asan+0x172c)

精确到那一次 memset

ASan 的办法是在每块内存周围插入"毒区"(redzone),并给每 8 个字节维护一个影子字节记录可不可访问,每次读写前查一下。代价是内存翻两三倍、速度慢两倍左右。

⭐ 所以这不是"能不能检查"的问题,是要不要一直付这笔钱的问题。答案是:测试时付,生产时不付。 这门课后面还会几次遇到同样的形状——一个检查在技术上完全可行,但因为太贵而默认关着,于是它挡不住任何真实发生的事故。

六、代价与取舍

手动管内存换来了什么: 完全的控制权和可预测的性能。没有 GC 停顿,没有隐藏的分配,你知道每一块地什么时候来、什么时候走。写数据库、内核、实时系统的人需要这个。

它花了什么: 上面这五种错法,每一种都在真实世界里造成过重大事故。微软和 Chrome 团队各自统计过自家的严重安全漏洞,约七成源于内存安全问题

它放弃了什么: 这几十年的应对办法,正好是三条不同的路:

  • 加检查——ASan、valgrind、MALLOC_CHECK_。测试期有效,生产期太贵。
  • 加运行时——垃圾回收(Java、Go、Python)。代价是停顿和内存占用。
  • 加类型系统——Rust 的所有权。代价是学习曲线和表达上的限制,但它是唯一在编译期解决、运行时零开销的路

三条路都还活着,因为三种代价对不同场景来说轻重不同。

七、小结

  • ⚠️ malloc/free 是库函数,不是系统调用。 free 把内存还给 glibc,不还给内核——所以 free 之后 RSS 常常一点不降。
  • 忘记 free:实测 RSS 1 MB → 80 MB。⚠️ 但必须真的写入才涨——malloc 只给地址,物理内存等你碰它才分配。
  • 用完之后接着用:不崩、不报错,而且你会读到后来别人写进去的数据——“用户 A 看到用户 B 的数据"的一种典型成因。
  • 释放两次 / 释放非堆指针:glibc 当场 abort。它凭的是每块内存前面那个 header——这个检查免费,所以默认开着
  • 写出界:什么都不会发生。 内核不看(同一页内权限相同),glibc 也不看(那块这次没被 free)。炸弹埋下了,引信在别处点着。
  • 不是不能检查,是默认没开。 ASan 精确定位到那一行,代价是内存翻几倍、速度减半——测试时付,生产时不付。
  • 应对内存安全的三条路——加检查、加运行时、加类型系统——各有各的代价,所以三条都还活着。

思考题

  1. 第二节说不写 memset 的话 RSS 几乎不涨。那么一个程序 malloc 了 100 GB 但一个字节都不碰,会成功吗?(这台机器只有几十 GB 内存。)你觉得内核该不该让它成功?
  2. 第三节里 q 拿到了 p 刚释放的那块地。这是巧合吗?如果 glibc 改成"释放的块放到队尾,下次从队头取”,这个演示还成立吗?这样改能解决问题吗?
  3. 第五节说 ASan 太贵所以生产不开。但确实有一类生产环境愿意付这个钱。你能想到是哪一类吗?
  4. 仓库那个比方里,“账本贴在格子旁边"解释了为什么 double free 能被发现。那它也解释了另一件事:为什么写出界特别危险——你涂掉的不只是别人的东西。具体是什么?

延伸