从零编写现代编译器 25 - 从空目录构建并运行完整应用
二十四篇下来,编译器从只接受 return 42 长到了能处理泛型容器、多文件模块和编辑器协议。每篇都在前一篇基础上增加一种能力,但从来没有人从一个空目录开始,把所有东西拉下来跑一遍。本篇做的就是这件事:克隆源码,构建编译器,编译一个用到系列全部特性的 Sprout 应用,运行它,然后用四种输入检验结果。
最终应用:整数文本统计
目标程序 programs/stats/ 由三个 .spr 文件组成。io.spr 负责从文件读取文本并按行解析为整数数组;sort.spr 实现泛型插入排序;main.spr 调用前两者,输出统计值。
1 | |
这段程序覆盖了系列积累的全部语言特性:用户定义函数(第 08 篇)、条件与循环(第 09 篇)、受检数组(第 15 篇)、字符串与引用计数(第 16 篇)、结构体(第 17 篇)、泛型与单态化(第 18 篇)、多文件模块与链接(第 19 篇)。输入格式错误时,运行时通过源码位置报告诊断,对应第 20 篇的源码映射机制。
从空目录构建
以下命令在一台干净的 Linux x86-64 机器上执行。环境要求:Rust 1.98.1(rust-toolchain.toml 锁定)、LLVM 20.1(opt、llc、clang 三者版本一致)、系统链接器。
1 | |
构建完成后,target/debug/sproutc 就是编译器。--locked 保证依赖与提交时的 Cargo.lock 完全一致,不会因为本地缓存拉到不同版本。
环境记录:
| 项目 | 版本 |
|---|---|
| 宿主系统 | Ubuntu 24.04, x86-64 |
| Rust | 1.98.1 (edition 2024) |
| LLVM | 20.1.4 |
| 目标三元组 | x86_64-unknown-linux-gnu |
| 链接器 | GNU ld 2.42 |
四次测试运行
准备输入文件 numbers.txt,内容为十个整数,每行一个:
1 | |
运行一:正常输入。
1 | |
1 | |
排序、遍历、统计计算正确。mean 为整数除法向零截断的结果,416 / 10 = 41,符合第 07 篇确定的语义。
运行二:格式错误输入。 将 numbers.txt 第三行改为 hello:
1 | |
错误消息指向 io.spr 中解析函数的具体行列,而不是一个裸的段错误。这依赖第 20 篇建立的跨层源码映射。
运行三:空输入。 传入一个空文件:
1 | |
空数组不触发除零——compute 函数在 count == 0 时直接返回全零 Stats,不执行除法。
运行四:数组越界。 不传文件参数,args[1] 触发数组边界检查:
1 | |
受检数组(第 15 篇)在运行时捕获越界,错误位置精确到源文件的表达式。
五组可观察能力
系列写作计划要求交付五组能力,全部在本篇验收。
能力一:check 给出诊断。 故意在 main.spr 中把 i64 参数传给 String 形参:
1 | |
1 | |
文件名、字符范围、错误码齐全。修正后 check 返回零退出码,build 正常生成可执行文件。
能力二:--emit 追踪翻译过程。 对同一个加法表达式导出全部中间形式:
1 | |
输出依次是 Token 序列、AST 节点、Typed HIR、自制 SSA 指令和最终 LLVM IR。同一个 sum + nums[i] 从词法单元变成 %7 = add i64 %5, %6,中间经过名称解析、类型检查、phi 插入和常量传播,每一步都有对应的文本表示。
能力三:O0 与 O2 行为一致。
1 | |
diff 无输出。两个优化级别对相同输入产生相同结果。运行时错误同样保留源码位置——O2 的越界报告与 O0 指向同一行列。
能力四:编辑器集成。 启动 LSP 服务器,在 VS Code 中打开 programs/stats/:
- 诊断:删除一个导入语句,编辑器立即标红未定义名字,悬浮窗显示错误码。
- 悬停类型:光标放在
compute调用上,提示fn(Array<i64>) -> Stats。 - 跳转定义:在
insertion_sort上按 F12,跳转到sort.spr中的函数签名。
修改 sort.spr 的函数签名后,main.spr 中的调用方诊断自动刷新。
能力五:测试基础设施。 回归测试套件覆盖第 01 至 24 篇积累的正例和反例:
1 | |
1 | |
347 个案例涵盖词法、解析、类型检查、优化、代码生成和运行时行为。每个曾经触发 bug 的输入都保留在 tests/regression/ 中(第 21 篇建立的回归语料)。新提交必须通过全部案例,CI 在每次推送后自动执行。测试基础设施本身也经过第 21 篇描述的错误注入验证——故意破坏一个优化 pass,确认测试能检出。
源码快照
本篇对应的 Git 提交标记为 compiler-step-25。从该提交导出的 tarball 经过独立解压、构建和上述四次测试,结果一致。能力五中列出的 347 个回归案例全部通过。
系列回顾
二十五篇走过了一条完整的路径:从手写词法器到 Pratt 解析,从符号表到类型检查,从参考解释器到自制 SSA,从 LLVM 后端到引用计数,从源码映射到语言服务器。
刻意没有做的事情同样值得列出。主线排除了闭包(需要捕获环境和逃逸分析)、trait 系统(需要虚表或字典传递)、借用检查(需要生命周期推断)、追踪式 GC(需要根集扫描和停顿策略)、宏(需要卫生展开和语法扩展点)。每一项排除都是一个可以独立展开的方向——闭包从第 17 篇的枚举环境开始,trait 从第 18 篇的单态化约束开始,GC 从第 16 篇的引用计数替换开始。
这些排除不是遗憾,而是边界。一个教学编译器需要在可完成的范围内把每个环节讲清楚,而不是把所有特性都塞进去然后每个都只讲一半。
后续
第 26–30 篇是进阶实作:Cranelift JIT、WebAssembly 核心模块、WIT 组件接口、MLIR 张量 dialect、张量降低到 CPU。它们复用主线的前端和 SSA,分别接入不同的后端,验证同一份语义在多种执行环境下的表现。
上一篇:24 - 接入语言服务器协议。下一篇:26 - 用 Cranelift 实现 JIT 编译。
