从零编写现代编译器 26 - 用 Cranelift 实现 JIT 编译
前 25 篇的代码生成路径是固定的:Sprout SSA → LLVM 文本 IR → opt 优化 → llc 编译成机器码 → 链接成可执行文件。整个流程产出的是一个本地二进制文件,运行前必须经过完整的编译和链接。LLVM 的优化器能生成很快的机器码,但编译本身不快——一个千行级的 Sprout 程序走完 LLVM 流水线可能需要数百毫秒。对于发布构建这完全可以接受,但如果想做交互式开发——输入一段代码、立即看到结果——几百毫秒的延迟就太明显了。 这篇引入第二个代码生成后端:Cranelift。它是 Bytecode Alliance 开发的代码生成库,设计目标是编译速度优先、生成质量够用。用它替代 LLVM 之后,编译到可执行的函数指针只需要几毫秒,代价是生成的机器码比 LLVM O2 慢大约两倍。 Cranelift 的架构 Cranelift 不是一个完整的编译器框架,而是一个函数级别的代码生成库。它的输入是自己的中间表示 CLIF(Cranelift IR),输出是特定目标架构的机器码。没有链接器,没有目标文件——调用 API 传入 CLIF,拿回一段可执行的机器码...
从零编写现代编译器 25 - 从空目录构建并运行完整应用
二十四篇下来,编译器从只接受 return 42 长到了能处理泛型容器、多文件模块和编辑器协议。每篇都在前一篇基础上增加一种能力,但从来没有人从一个空目录开始,把所有东西拉下来跑一遍。本篇做的就是这件事:克隆源码,构建编译器,编译一个用到系列全部特性的 Sprout 应用,运行它,然后用四种输入检验结果。 最终应用:整数文本统计 目标程序 programs/stats/ 由三个 .spr 文件组成。io.spr 负责从文件读取文本并按行解析为整数数组;sort.spr 实现泛型插入排序;main.spr 调用前两者,输出统计值。 123456789101112131415161718192021222324// main.sprimport io;import sort;struct Stats { min: i64, max: i64, mean: i64, count: i64,}fn compute(nums: Array<i64>) -> Stats { ... }fn main() -&...
从零编写现代编译器 24 - 接入语言服务器协议
第 23 篇让语义分析变成了增量查询:修改一个函数体,只有依赖它的调用方才重新检查。但这些能力只能通过命令行使用。写代码时,每次保存文件再切到终端看诊断,效率太低。 本篇把增量分析接入 Language Server Protocol(LSP)。完成后,编辑器里能看到类型错误的波浪下划线,鼠标悬停显示函数签名,Ctrl-click 跳转到定义处。编译器从后台工具变成了交互式开发环境的一部分。 JSON-RPC 与协议骨架 LSP 在编辑器(客户端)和语言服务器之间定义了一套基于 JSON-RPC 2.0 的消息协议。传输层通常是 stdio:编辑器启动服务器进程,通过 stdin/stdout 交换消息,每条消息前面带一个 Content-Length 头。TCP 同样可用,但 stdio 不需要端口管理,是多数编辑器插件的默认选择。 客户端发送请求,服务器返回结果。也有从服务器主动推送的通知。协议交互的最小流程: 123456789101112131415客户端 服务器 │ initialize request ──→...
从零编写现代编译器 23 - 不完整代码与增量分析
前 22 篇的编译器每次运行都从头分析整个程序。命令行工具可以承受这个代价——几千行的 Sprout 程序,全量分析也就几十毫秒。但编辑器不行。用户敲下一个字符,期望诊断在百毫秒内刷新。如果每次击键都触发完整的词法、语法、名称解析和类型检查,延迟会随着项目规模线性增长。 本篇引入两项改造:让解析器在遇到语法错误时继续工作而不中断,以及让语义分析只重新计算受改动影响的部分。 容错解析:语法树必须覆盖全部源码 之前的解析器遇到无法归约的 token 时报错并停止。这对批量编译够用,但在编辑器场景里,用户正在输入的那一行几乎永远不合法。如果解析器在第一个错误处放弃,后面所有函数都拿不到语法树,诊断和跳转全部失效。 改造策略:遇到错误时,把当前无法解析的 token 序列包进一个 Error 节点,挂到 AST 的对应位置,然后跳到下一个同步点继续解析。同步点的选取沿用第 04 篇的方案——分号、右花括号、fn 关键字。 12345fn foo() -> i64 { let x: i64 = 1 +; // 缺右操作数 return x;}f...
从零编写现代编译器 22 - 测量编译与运行性能
编译器能正确地把 Sprout 程序翻译成本机代码了。但"能用"和"够快"是两件事。本篇把编译器和它生成的程序分开测量:前者是开发体验问题——改一行代码要等多久;后者是产出质量问题——生成的二进制跑得有多快。两件事的测量方法、影响因素和优化方向完全不同。 分阶段计时 编译器内部有明确的阶段边界:词法分析、语法解析、名称解析与类型检查、SSA 构造、自制优化、LLVM IR 生成、LLVM 后端处理。在每个阶段入口和出口取一次墙钟时间,差值就是该阶段的耗时。 1234567891011121314use std::time::Instant;let t0 = Instant::now();let tokens = lex(&source);let t1 = Instant::now();let ast = parse(&tokens);let t2 = Instant::now();let hir = typecheck(&ast);let t3 = Instant::now();// ... SSA, optimi...
从零编写现代编译器 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...
