一卷在储藏室里沉睡了半个世纪的九轨磁带,意外掀起了系统编程界对 C 语言内存管理祖源的重新审视。个人技术博客 gardenweb 于标注日期 2026 年 10 月 29 日刊发了一篇源码考古文章,作者翻阅近期恢复的 Unix V4 源码时,在 Dennis Ritchie 的目录下发现了 malloc(mp, size) 与 mfree(mp, size, aa),并惊叹这套显式传入区域且带大小释放的接口,远比如今 C 标准库里只靠裸指针释放的 malloc()/free() 先进。作者敏锐地嗅到了现代内存安全设计的味道,却看错了这段远古代码的真实身份——Dennis Ritchie 写下的根本不是存放 C 结构体的内存池,而是一本连物理内存与磁盘扇区都能用同一套百行逻辑调度的带外资源账本。

储藏室里的 1974 时间胶囊与两段远古代码

这批引发热议的远古代码,来自一次轰动计算机史学界的实物抢救。Unix V4 于 1973 年 11 月在贝尔实验室问世,是历史上首个内核主体由早期 C 语言重写的里程碑版本,但其源码长年被学界视为已经佚失。直到 2025 年 7 月 28 日,Aleks Maricq 在犹他大学 Flux 研究组储藏室整理已故教授 Jay Lepreau 的遗留文献时,意外翻出了一卷标有 V4 字样的磁带。

沉睡半世纪的九轨磁带与 1974 年致歉信重见天日
沉睡半世纪的九轨磁带与 1974 年致歉信重见天日

Ken Thompson 曾于 1974 年 5 月 31 日致信犹他大学的 Martin E. Newell,为文档缺货导致的交付延迟致歉,这封信印证了该磁带的来历。Rob Ricci 于 2025 年 11 月 6 日在 Mastodon 宣布了磁带重现的消息,并经 Rob Pike 转发至 TUHS(Unix 遗产协会)邮件列表;同年 12 月 19 日,Al Kossow 在计算机历史博物馆使用改装磁带机与 readtape 软件完成了读取,以 SIMH 格式保存下原始捕获数据。

犹他大学 Unix V4 磁带从写下到重见天日 1973.11 贝尔实验室 发布 Unix V4 1974.06 寄出犹他磁带 演化快照定格 2025.07.28 储藏室清理 发现遗留磁带 2025.11.06 Mastodon 披露 震动系统学界 2025.12.19 博物馆读出源码 补齐 C 内核断代

研究者 Diomidis Spinellis 对恢复出的完整系统转储(含源码、编译产物及内核)比对后证实,这卷磁带实为 1974 年 6 月前后的分发快照,与现存的 Unix V5 快照之间存在实质性差异;目前 TUHS 在线展示的 V4 代码树,则是将该磁带内容与 Dennis Ritchie 另行提供的 usr/man 归档包合并后的结果。

正是在这棵代码树里,博文作者挖出了两段截然不同的早期内存管理代码。一段藏在 C 编译器优化器 /usr/c/c20.c:814(由于 TUHS 对应节点近期多次响应超时,该文件细节目前仅据博文摘录)中:函数 alloc(an) 只是简单把指针往前推,空间不够就调用 sbrk(2000) 扩容,若返回 -1 则向标准错误输出 14 字节的 Out of space\n 并直接 exit(1),全程没有任何释放操作。另一段则是 /usr/sys/dmr/malloc.c,里头定义了接收 struct map *mp 参数的 malloc(mp, size) 与 mfree(mp, size, aa),配合 /usr/sys/systm.h:2 里的 coremap 和 swapmap 使用。

源码勘误:coremap 不是你想的内存池

看到 malloc(mp, size) 显式接收一个映射区指针,还在释放时要求传入 size,很容易像博文作者一样写出 p = malloc(coremap, sizeof foo) 这样的伪代码。作者据此认定 coremap 和 swapmap 是两块扁平内存区域(Arena),并兴奋地推演道,如果沿用这种接口,宿主程序只要直接丢弃整个 map,就能一键回收第三方库分配的所有内存,还能天然隔离堆损坏漏洞。

内核用同一套带外账本调度主存块与磁盘扇区(示意图)
内核用同一套带外账本调度主存块与磁盘扇区(示意图)

但这其实是一场跨越半个世纪的误会。只要翻开 V4 内核里真正调用这两个函数的 /usr/sys/ken/text.c 和 /usr/sys/ken/main.c,就会发现内核从未拿它分配过任何字节级的 C 语言结构体。

丢弃 coremap 不会释放半点内存,只会当场烧毁内核的空闲账本。

在 V4/usr/sys/dmr/malloc.c 中,Dennis Ritchie 实现的是一个按地址升序排列的首次适应(first-fit)资源映射表分配器。coremap 与 swapmap 数组里装的根本不是待分配的内存字节,而是记录「哪段区间还空着」的带外(out-of-band)记账条目,以大小为 0 的条目作为结尾(当时的头文件里确实存在未将 CMAPSIZ 与 SMAPSIZ 折半的小瑕疵)。正如 John Lions 在经典的 Unix V6 注释书中所解释的,这两张表管理的是两类硬件资源:以 32 字(64 字节)为单位的主存,以及以 256 字(512 字节)为单位的磁盘交换区。

在 V4/usr/sys/ken/text.c 中,内核调用 malloc(swapmap, (rp->p_size+7)/8) 来申请 512 字节的磁盘扇区块,调用 mfree(coremap, os, rp->p_addr) 来归还 64 字节的主存单元(在使用时通过 coreaddr<<6 左移转换为字节地址)。malloc 返回的不是 C 指针,而是整数块号或扇区偏移量;失败时返回的是整数 0 而非空指针。更有意思的是,在 V4/usr/sys/ken/main.c 的系统启动阶段,初始状态下的映射表是空的,内核正是通过反向调用 mfree(),把探测到的可用物理内存和磁盘交换区一段段「释放」进账本里,才完成了开机冷启动。


同名不同命的双轨演化,为何击中今日痛点

既然内核里的 dmr/malloc.c 是管理抽象整数区间的资源账本,它自然也就不是今天用户态 malloc() 被「劣化」前的失落前身。早期 Unix 的内存管理从一开始就是三条支线并行演化的。

隐藏块头易受溢出波及,带外记账实现物理隔离(剖面示意)
隐藏块头易受溢出波及,带外记账实现物理隔离(剖面示意)
早期 Unix 内存管理的三条平行演化支线 编译器级:线性分配器(V4 c20.c / V6 diff1.c) sbrk(2000) 单向推进 · 失败直接 exit(1) · 随进程退出整块销毁,对应今日 Arena 范式 内核级:带外资源映射表(V4 dmr/malloc.c 至 V6) 显式传入 coremap / swapmap 与释放大小 · 账本与资源分离 · 统一调度 64B 主存与 512B 磁盘块 用户态:带内块头通用堆(1974.03 V6 alloc.3 至 V7 libc) 指针前内嵌块大小 · 免传上下文与长度 · 零摩擦跨库组合,但埋下越界篡改与碎片隐患

早在 1974 年 3 月 1 日编纂的 Unix V6 用户手册(alloc.3)中,用户态就已经拥有了基于 sbrk 扩容且无需传入释放大小的 alloc(bytes) 与 free(ptr);到了 1979 年 1 月发布的 V7 中,它演进为标准库里带忙碌位块头的循环首次适应 malloc(bytes)/free(ptr),K&R C 圣经第 8.7 节的示例也随之从第一版的 alloc/free 改写为第二版的 malloc/free。

为什么当年的贝尔实验室大牛们在用户态放弃了传上下文和传大小?在 PDP 小型机极其捉襟见肘的内存里,用户态程序需要频繁创建生命周期交错的小对象。如果强制要求调用方为每个小对象记住字节数,或者预先划分固定长度的外部映射表,不仅编程负担沉重,一旦碎片过多还会直接撑爆映射表数组。把块大小偷偷藏在返回指针紧前方的隐藏头部(in-band metadata)里,只需交出一个裸指针就能随处释放,用微小的头部开销换来了各个独立C语言库之间的零摩擦组合。

分配器范式早期 Unix 代表实现元数据位置与释放接口现代系统工程回响
线性区域分配V4 c20.c alloc(an)无单块元数据,进程结束或重置水位线回滚编译器前端、游戏帧内 Arena 分配器
带外资源账本V4 dmr/malloc.c独立 map 数组记账,mfree(mp, size, aa)内核虚拟地址、GPU 显存与页框子分配器
带内块头全局堆V6 alloc.3 / V7 malloc紧贴数据块头内嵌大小,free(ptr) 单指针释放传统 POSIX libc 堆,正被现代安全设计改造
  • 结论.原博文作者虽然误读了 coremap 的物理含义,却凭直觉撞见了当代底层软件最重要的接口反思——把元数据从数据流里剥离出去,把上下文和大小交还给调用方。

半个世纪后,当年为省事而塞进数据块前方的带内块头,成了缓冲区溢出攻击最爱篡改的靶子;而在释放时省掉的那个 size 参数,迫使现代分配器不得不在释放路径上额外做一次指针解引用和哈希查找,拖累了 CPU 缓存命中率。正如 Bjarne Stroustrup 后来在回忆录中感叹的那样,当 C++ 引入借自 Simula 的 new 操作符并底层依赖 C 的 malloc() 后,真实系统程序一度要把 50% 甚至更多的运行时间耗在 malloc() 上。

对于今天正在编写底层基础设施的系统程序员、编译器作者与新式系统语言设计者而言,这卷 1974 年的磁带提供了一份极简主义的清醒剂:

  • 写编译器、解析器等按阶段运行的工具时,大可效仿 c20.c 里的 alloc(an) 或 V6 diff1.c 里直接回滚指针的做法,用绑死生命周期的线性 Arena 彻底省掉逐个 free 的开销。
  • 在设计跨子系统的资源调度、显存管理或高安全组件时,dmr/malloc.c 展示了带外记账 + 显式传入区域 + 带大小释放(如同 C++14 重新补回的 sized deallocation 与现代系统语言显式传递 Allocator 句柄)的威力——账本与数据物理隔离,哪怕数据区被越界写穿,分配器本身的控制结构也毫发无损。

至于计算机史与操作系统研究者,这卷 1974 年 6 月快照的考古才刚刚开篇。随着 Spinellis 等学者对 V4 至 V5、V6 演进差异的逐行比对,以及对早期多趟 C 编译器工具链的继续挖掘,半个世纪前那群工程师在硬件枷锁下做出的精妙权衡,还将继续为今天的软件工程提供镜鉴。