从零编写现代编译器 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...
从零编写现代编译器 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...
