从零编写现代编译器 21 - 差分、属性与模糊测试
手工测试用例覆盖的是写测试的人想得到的错误。想不到的错误藏在组合空间的角落里,等着在生产环境被触发。前面二十篇积累了解释器、编译器、优化 pass 和源码映射,手工正反例加起来几百条,仍然不能保证它们之间没有分歧。 本篇用随机生成的 Sprout 程序同时喂给解释器和编译后的本机二进制,比较两边的输出。任何不一致都意味着 bug。找到之后自动缩减失败样本,留下最小复现,纳入回归语料。 差分测试:两条路径,一个判定 差分测试的核心逻辑只有一句话:同一个程序经过两条独立的执行路径,结果必须一致。 123456789随机生成合法 Sprout 程序 P │ ├─ 解释器(第 07 篇)执行 P → 退出值 A, stdout A, 错误类别 A │ └─ 编译器 → 本机二进制 → 执行 → 退出值 B, stdout B, 错误类别 B │ 比较 (A, B) 一致 → 通过 不一致 → 记录 P,进入缩减流程 比较的不是错误消息的字面文本。解释器报 division by zero at line 5,编译后的运行时报 runtime error: div...
从零编写现代编译器 20 - 让错误回到源程序
运行时错误目前只能告诉你"除零错误",然后进程就没了。调试器停在一个机器地址上,对照的是 LLVM IR 甚至汇编,读者必须自己倒推这条指令对应源文件的哪一行。编译器花了十几篇功夫把 Sprout 源码翻译成机器码,却在出错的时候把来路全丢了。这一篇补上这条回路:从词法器的 Span 开始,让位置信息穿透 AST、HIR、SSA、LLVM IR,最终写进目标文件的 DWARF 段,使调试器和运行时错误处理器都能把机器地址映射回 sum.spr:5:12。 位置信息的传递链 词法器扫描源文件时,每个 Token 已经携带了 Span——文件 ID、起始字节偏移、结束字节偏移。解析器把 Token 组装成 AST 节点,每个节点继承或合并子节点的 Span。名称解析和类型检查产生 Typed HIR,Span 仍然挂在每个表达式和语句上。 从 HIR 降低到 CFG/SSA 时,每条 SSA 指令也要保留原始 Span。一条 add %1, %2 -> %3 可能来自 sum = sum + i,它的 Span 指向源文件中 + 运算符的位置。这个信息在 ...
从零编写现代编译器 19 - 多文件模块、ABI 与链接
前 18 篇的所有程序都住在一个文件里。泛型函数、结构体、枚举、引用计数——全部挤在同一份 .spr 源码中。程序再大一点,这种组织方式就不可维护了。 本篇把编译单元从单文件扩展到多文件:每个 .spr 文件是一个模块,模块之间通过 import 声明交换类型签名,编译器分别产出目标文件,最终由系统链接器合并成一个可执行程序。 模块就是文件 Sprout 采用最直接的模块方案:一个 .spr 文件就是一个模块,模块名从文件路径导出。math.spr 的模块名是 math,util/sort.spr 的模块名是 util_sort。不存在与文件脱离的命名空间声明。 在模块顶部用 import 引入依赖: 123456import math;fn main() -> i64 { print_i64(math.gcd(48, 18)); return 0;} import math 使 math 模块中所有标记为 pub 的函数名在当前模块可见。被导入模块的私有函数不可访问——编译器在名称解析阶段就会报错,不需要等到链接。 可见性:默认私有 函数...
从零编写现代编译器 18 - 泛型如何变成具体代码
第 17 篇给 Sprout 加上了结构体和枚举。字段类型在定义时写死:一个存放 i64 对的结构体和一个存放 bool 对的结构体需要分别声明,尽管它们的布局逻辑完全一样。泛型允许把类型当作参数,写一次定义,给不同类型复用。本篇实现显式类型参数和单态化(monomorphization)——编译器在编译期为每组具体类型生成一份专用代码,运行时不存在泛型。 泛型函数与泛型结构体 Sprout 的泛型语法把类型参数放在尖括号里。函数签名可以带一个或多个类型参数: 123fn identity<T>(x: T) -> T { return x;} 调用时用 turbofish 语法显式指定类型: 12let a: i64 = identity::<i64>(42);let b: bool = identity::<bool>(true); 结构体同理: 1234struct Pair<A, B> { first: A, second: B,} 使用时写出全部类型参数:Pa...
从零编写现代编译器 17 - 结构体、枚举与模式匹配
数组解决了同类元素的连续存储,但真实程序需要把不同类型的数据捆绑在一起——坐标由 x 和 y 两个整数组成,几何图形可能是圆也可能是矩形。本篇为 Sprout 加入结构体和枚举两种复合类型,再用模式匹配安全地拆开枚举值。类型检查器同时获得字段访问验证和穷尽性检查能力。 结构体:命名字段的值类型 声明一个 Point: 1struct Point { x: i64, y: i64 } 结构体是值类型。把 Point 赋给另一个变量,得到一份完整的副本。字段按声明顺序排列在内存中: 1234偏移 0 8 16 ┌──────┬──────┐ │ x │ y │ Point:16 字节 └──────┴──────┘ 每个字段的偏移等于前面所有字段大小之和。i64 宽度 8 字节,Point 总大小 16 字节。如果以后引入更小的类型,偏移计算还需要加入对齐填充——按字段自然对齐向上取整,结构体末尾可能出现尾部填充以满足整体对齐。本篇只涉及 i64 和 bool 字段,暂时不会碰到填充。 构造和访问: 12...
从零编写现代编译器 16 - 字符串和引用计数
第 15 篇的数组使用了 malloc 和 free 的直接配对:一个变量持有一块堆内存,作用域结束时释放。字符串让事情变得更复杂。两个变量可以指向同一份字符串——把 a 赋给 b 时,复制整块内存太浪费,让两者共享同一份数据更合理。但共享引入了新问题:a 退出作用域时,b 可能还活着。直接释放会导致悬垂指针;不释放又会泄漏。引用计数解决这个矛盾,而且不需要垃圾回收器。 堆上的字符串表示 Sprout 字符串存放在堆上,布局固定为三个部分: 123┌──────────────┬──────────────┬──────────────────────┐│ refcount: i64│ byte_len: i64│ bytes: u8 ... │└──────────────┴──────────────┴──────────────────────┘ refcount 记录有多少个变量指向这块内存。byte_len 是字节数组的长度。bytes 存放 UTF-8 编码的内容。 Sprout 按字节长度计量字符串,不提供按字符索引的操作。"Hello, ...
从零编写现代编译器 15 - 数据布局与受检数组
到第 14 篇为止,Sprout 程序中每个值都能装进一个寄存器:i64 是 64 位整数,bool 是 1 位逻辑值。函数参数和返回值通过寄存器传递,局部变量在 SSA 里就是虚拟寄存器。这套方案处理标量足够了,但程序需要处理一组数据时——排序、统计、批量输入——单个寄存器放不下。 本篇引入数组,它是 Sprout 的第一个堆分配类型。数组有可变长度,住在堆上,索引时必须检查边界。围绕它展开的问题不止语法:分配多大的内存、用什么布局存放长度和元素、怎样在运行时拦截越界、大小计算本身会不会溢出。 数组的内存布局 数组变量本身是一个指针,指向堆上分配的连续内存块。这块内存的头部存放元素个数,紧随其后是元素本身。Sprout 目前只有 i64 元素类型,每个元素 8 字节,长度字段也用 i64 存储。 1234567堆块起始地址 p┌──────────┬──────────┬──────────┬───┬──────────┐│ length │ elem[0] │ elem[1] │ … │ elem[n-1]││ (i64) │ (i64) │ (i64...
从零编写现代编译器 14 - 接入 LLVM 优化与后端
第 10–13 篇从零构建了 CFG、SSA、常量传播和无用代码删除。这些 pass 足够说明数据流分析的核心思路,但它们只覆盖了产品级优化器的极小一角。LLVM 经过二十余年的迭代,包含指令组合、循环展开、向量化、寄存器分配、指令选择等数百个 pass。本篇把自制 SSA 翻译成 LLVM IR 文本,接入这条管线,让 Sprout 程序获得真正的优化与本机代码生成。 从自制 SSA 到 LLVM IR 翻译的核心映射并不复杂。自制 SSA 里的每个值对应 LLVM IR 中一个 % 命名的虚拟寄存器;基本块参数翻译成 LLVM 的 phi 节点;Sprout 的 i64 直接映射为 LLVM 的 i64,bool 映射为 i1;函数签名翻译成 define 声明。 以 sum_to 为例,--emit=llvm 输出: 123456789101112131415161718define i64 @sum_to(i64 %n) {entry: br label %looploop: %sum = phi i64 [0, %entry], [%sum.next, ...
从零编写现代编译器 13 - 常量传播与无用代码删除
第 12 篇完成了 SSA 构造。每个值只有一个定义点,优化器可以沿定义链追踪数据流。程序已经具备了被分析的结构基础,但结构本身不减少指令数量。本篇实现第一批真正的优化:常量传播和无用代码删除,把 SSA 从"可分析的中间表示"变成"更精简的中间表示"。 常量折叠:编译期完成的算术 最直接的优化是常量折叠(constant folding)。如果一条算术指令的两个操作数都是编译期已知的常量,编译器直接计算结果,用一个常量值替换这条指令。 优化前的 SSA 片段: 12345v1 = const 3v2 = const 5v3 = add v1, v2v4 = mul v3, v3return v4 v1 和 v2 都是常量,add 的结果在编译期可以确定为 8。替换后 v3 变成 const 8,接着 mul v3, v3 的两个操作数也是常量,折叠为 64。三条算术指令最终变成一个常量返回。 SSA 的单定义性质让替换可靠——v1 在整个函数里只有一个定义点,所有使用 v1 的地方看到的值都是 3,不存在"某条路径上 v1 被...
从零编写现代编译器 12 - SSA 构造与 phi 节点
第 09 篇用 alloca/load/store 把可变变量映射到栈内存。这种做法能让代码生成跑起来,但优化器看到的是一堆内存操作,无法直接判断哪些 load 读的是同一个值。常量传播要穿透内存别名分析,复杂度远超必要。 SSA(Static Single Assignment)要求每个变量恰好被定义一次。当同一个变量在不同路径上获得不同值时,合流处放一个 phi 节点完成合并。优化器只需沿定义-使用链就能追踪值的流动。本篇从第 10–11 篇的 CFG 和支配关系出发,构造 SSA 并替换掉第 09 篇的临时内存形式。 数据结构变化:从变量到值 第 10 篇的 Block 只存储无类型的指令列表和终结符,块参数用 (VarId, Ty) 表示——还是以变量为中心的视角: 1234567// 第 10 篇的 Blockpub struct Block { pub id: BlockId, pub params: Vec<(VarId, Ty)>, pub body: Vec<Inst>, pub terminator:...




