从零编写现代编译器 16 - 字符串和引用计数
第 15 篇的数组使用了 malloc 和 free 的直接配对:一个变量持有一块堆内存,作用域结束时释放。字符串让事情变得更复杂。两个变量可以指向同一份字符串——把 a 赋给 b 时,复制整块内存太浪费,让两者共享同一份数据更合理。但共享引入了新问题:a 退出作用域时,b 可能还活着。直接释放会导致悬垂指针;不释放又会泄漏。引用计数解决这个矛盾,而且不需要垃圾回收器。
堆上的字符串表示
Sprout 字符串存放在堆上,布局固定为三个部分:
1 | |
refcount 记录有多少个变量指向这块内存。byte_len 是字节数组的长度。bytes 存放 UTF-8 编码的内容。
Sprout 按字节长度计量字符串,不提供按字符索引的操作。"Hello, 世界!" 占 14 个字节——“Hello, " 共 7 个 ASCII 字节(含逗号后的空格),“世"和"界"各占三字节,”!” 一字节。语言层面暴露的 len() 返回 14 而不是 10。这是一个明确的设计选择:把字节索引冒充字符索引会在多字节字符的边界产生切割错误,而真正的 Unicode 字符计数涉及规范化和字形簇,不属于本阶段的目标。
分配一个字符串字面量时,编译器生成的代码调用 malloc(16 + byte_len),在前 8 字节写入 1(初始引用计数),接下来 8 字节写入长度,随后拷贝字面量内容。返回给变量的指针指向这整个块的起始地址。
retain 和 release
两个操作构成引用计数的全部机制:
- retain:将
refcount加一。发生在赋值、参数传递等"多了一个持有者"的场合。 - release:将
refcount减一。如果减到零,调用free释放整块内存。发生在变量离开作用域、被覆盖赋值等"少了一个持有者"的场合。
翻译到 LLVM IR 时,retain 是一条 load 加 add 1 加 store;release 是 load、sub 1、store,然后检查结果是否为零,为零则跳入 free 分支。
这套机制同样适用于第 15 篇引入的数组。数组的堆块头部可以增加一个 refcount 字段,赋值时 retain,离开作用域时 release,逻辑与字符串完全一致。本篇以字符串为例展开,但 retain/release 的插入规则和作用域清理策略对所有堆分配类型通用。
赋值与别名
考虑这段程序:
1 | |
let b = a 不分配新内存。b 获得和 a 相同的指针,编译器在赋值处插入 retain。作用域结束时,先 release b(refcount 降到 1),再 release a(refcount 降到 0,执行 free)。
覆盖赋值需要正确处理引用计数:
1 | |
编译器在赋值语句处生成三步:(1) 对新值调用 retain,(2) 对旧值调用 release,(3) 将新值写入目标。这个顺序确保即使新旧值是同一个对象(自赋值 s = s),引用计数也不会意外归零——retain 先把计数加到 2,release 再降回 1,对象始终存活。这是 Swift ARC 和 Objective-C ARC 采用的标准协议。
作用域清理
每个作用域结束时,编译器必须释放该作用域内所有活跃的字符串变量。这包括三种退出路径:
- 块末尾正常退出:按声明逆序依次 release。
- 显式 return:release 当前作用域和所有外层作用域中的活跃字符串,然后返回。
- 条件分支后合流:分支内声明的字符串在分支结束时释放,外层的不受影响。
提前返回是最容易出错的场景。假设:
1 | |
return 0 处的清理代码需要释放 b(当前 if 作用域)和 a(函数作用域)。编译器遍历从当前作用域到函数顶层的所有嵌套层,收集每一层中活跃的字符串变量,逐一插入 release 调用。
这些释放指令由编译器在 IR 生成阶段自动插入,程序员不写任何析构代码。每个变量在每条退出路径上恰好被 release 一次——不多也不少。多了是 double-free,少了是泄漏。
防止 double-free
编译器怎样保证每条路径恰好一次 release?关键在于:release 点的生成绑定在 CFG 的结构上,而不是运行时的条件上。
每个基本块的终结指令(ret、br)之前,编译器检查该块退出时哪些字符串变量仍然活跃。活跃性由 SSA 的数据流分析确定——如果一个变量的值在某个退出点之后不再被使用,它就需要在这里 release。
对于 if-else 两个分支各自 return 的情况,两条路径各自生成自己的清理序列,互不干扰。不存在"两条路径都 release 同一个变量"的情况,因为控制流不会同时走两条路。
具体到 LLVM IR,每个 return 前的清理块大致如下:
1 | |
泄漏测试
验收方式直截了当:包装 malloc 和 free,分别递增全局计数器。程序结束时,两个计数器必须相等。
1 | |
这个程序涉及别名(b = a)、覆盖赋值(c = b)和提前返回(if 内的 return)。运行后检查 malloc 次数等于 free 次数,且程序不崩溃。如果任何路径漏掉了 release,计数器会不平衡;如果多释放了一次,程序在 free 时就会崩溃或被内存检测工具捕获。
练习与资料
追踪以下程序中每个字符串对象的引用计数变化,标注每一步是 retain 还是 release,写出每条退出路径上的 free 时机:
1 | |
提示:r = b 处需要先 retain b(“beta”),再 release r 的旧值(“alpha”),最后将 b 的指针写入 r。两条 return 路径各自需要释放不同的引用集合。
Sprout 禁止递归堆类型和闭包,因此引用关系构成有向无环图,引用计数不会出现循环导致的永久泄漏。闭包和递归对象的内存管理需要追踪式垃圾回收,不在本系列主线范围内。
Apple 的 Objective-C ARC 文档描述了编译器自动插入 retain/release 的工业实践;Swift 的 ARC 章节解释了强引用和引用环的关系。本篇的实现远比它们简单,但核心思路——编译器而非程序员负责插入内存操作——是一致的。
上一篇:15 - 数据布局与受检数组。下一篇:17 - 结构体、枚举与模式匹配。
