从零编写现代编译器 07 - 用参考解释器固定语言语义
第 06 篇完成了类型检查,1 + 2 能通过检查,true + 1 被拒绝。但类型检查只回答"这个程序有没有类型错误",不回答"这个程序执行后结果是什么"。编译器目前还没有任何部分说 1 + 2 的结果是 3。 本篇在 Typed HIR 上直接建一个参考解释器。它递归遍历节点,维护变量环境,按确定的规则求值,把结果打印出来。这个解释器将成为整个系列的语义基准:后续编译器生成本机代码后,任何测试用例只要和解释器的输出不一致,就说明降低、优化或代码生成出了错。 值的表示 解释器执行 HIR 节点后产生值。当前语言只有三种类型,值的表示对应地简单: 12345enum Val { I64(i64), Bool(bool), Unit,} I64 携带 64 位有符号整数,Bool 携带布尔值,Unit 对应没有有意义返回值的表达式(赋值、打印调用等)。后续加入数组和结构体时,这个枚举会扩展,但三种基础值从本篇开始就固定下来。 环境:栈帧与变量绑定 解释器需要知道每个变量当前的值。环境用一组栈帧表示,每个...
从零编写现代编译器 06 - 类型检查与 Typed HIR
第 05 篇完成了名称解析:每个变量引用都绑定到了确切的定义。但编译器仍然不知道 x + y 是在做整数加法还是布尔运算。这篇给 AST 中的每个表达式节点分配类型,产出一棵全新的中间表示——Typed HIR。类型检查不通过的程序将被拦在代码生成之前。 类型的表示 Sprout 当前只有三种值类型和一个内部哨兵: 123456enum Ty { I64, Bool, Unit, Error,} I64 对应 64 位有符号整数,Bool 对应布尔值,Unit 是没有有意义返回值时使用的类型。Error 不是语言中可写的类型,它只在类型检查发现错误时注入节点,防止一处错误沿表达式树向上引发大量连锁报告。如果操作数之一已经是 Error,类型检查器直接把结果也标为 Error,不再重复报错。 自底向上的类型检查 类型检查器遍历名称已解析的 AST,对每个表达式节点计算类型。过程是自底向上的:先确定叶子节点的类型,再根据操作符的规则推导父节点。 字面量。 整数字面量的类型是 I64,true 和 false 的类型是 Bool。 二元运算...
从零编写现代编译器 05 - 名字、作用域与可变变量
第 04 篇构建了 AST,表达式和语句有了树形结构。但 AST 中的名字只是字符串。两个不同作用域里都叫 x 的变量,在树上看起来完全一样——解析器不关心它们指向谁。如果直接把字符串传给后续阶段,类型检查器和代码生成器就必须各自重复"这个 x 到底是哪个 x"的查找逻辑。 本篇在 AST 之上增加一趟名称解析。遍历语法树,维护一个作用域栈,给每个声明分配唯一的定义 ID,把每个名字引用绑定到对应的定义。完成后,后续阶段只需要查 ID,不再处理裸字符串。 DefId 与符号表 每个变量声明得到一个编译期唯一编号 DefId。它是符号表的索引,整数类型,可以复制、比较、打印,不携带字符串。 123456789101112131415161718192021222324252627282930#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]pub struct DefId(pub u32);#[derive(Debug)]pub struct DefInfo { pub name: String...
从零编写现代编译器 04 - 表达式解析与错误恢复
第 03 篇给了编译器完整的词法分析器和源码位置。解析器已经能识别函数定义和 return 语句,但 return 后面那个表达式还只能接受单个整数字面量。return 1 + 2 * 3; 在当前解析器面前会直接报错——它不知道运算符是什么,更不知道 * 应该比 + 先结合。这一篇要解决这个问题:用 Pratt 解析实现一个完整的表达式解析器,正确处理优先级和结合性,并在遇到语法错误时恢复到可继续解析的状态。 优先级表 表达式里运算符的优先级决定了 AST 的形状。1 + 2 * 3 应该解析为 1 + (2 * 3) 而不是 (1 + 2) * 3。Sprout 语言的优先级从低到高: 优先级 运算符 结合性 说明 1 || 左结合 逻辑或,短路 2 && 左结合 逻辑与,短路 3 == != 左结合 相等比较 4 < > <= >= 左结合 大小比较 5 + - 左结合 加减 6 * / 左结合 乘除 7 一元 - ! 前缀 取负、逻辑非 所有二元运算符都是左结合。a + b + c 解析...
从零编写现代编译器 03 - 语句解析:let、赋值和分号
上一篇把源码拆成了 Token 序列。一行 let mut x: i64 = 0; 经过词法分析后变成 Let、Mut、Ident("x")、Colon、Ident("i64")、Assign、Int(0)、Semi 这样一串扁平记号。但 Token 序列不包含结构信息——编译器还不知道这是一条变量声明、一条赋值还是一条返回语句。这一篇的任务是把 Token 流解析成结构化的语句节点:let 声明、赋值、return 和块。 语句的文法 先用一组产生式把 Sprout 当前支持的语句写下来。这里只处理语句层面,表达式的优先级和结合性留给第 04 篇。 12345678910111213stmt → let_stmt | assign_stmt | return_stmt | expr_stmtlet_stmt → "let" ["mut"] IDENT ":" type "=" exp...
从零编写现代编译器 02 - 词法分析器:把文本切成 Token
上一篇里,编译器能处理的输入是硬编码的——它只认识 fn main() -> i64 { return 42; } 这一个程序,Token 序列直接写死在代码里。换一个数字就得改编译器源码。这不叫编译,这叫字符串替换。 这一篇要做的事情:给 Sprout 写一个词法分析器(lexer),让它读入任意源文本,逐字符扫描,输出一串带位置信息的 Token。后续的解析器将消费这个 Token 流,而不再关心原始字符。 词法分析器做什么 编译器前端的第一道工序是词法分析。它的输入是源文件的字节流,输出是 Token 序列。每个 Token 标记了一段源文本的语法角色:这是一个关键字,那是一个整数字面量,这里是左括号,那里是分号。 词法分析器不关心 Token 之间的组合是否合法。return return return 在词法层面完全没有问题——三个 Return Token。判断组合是否合法是解析器的工作。词法分析器只负责切割。 类比一下:词法分析像把一段中文拆成词语,每个词标上词性。"我吃了三碗饭"变成 [我/代词] [吃/动词] [了/助词] [三/数词...
从零编写现代编译器 01 - return 42:从源码到可执行文件
一个只返回 42 的函数,从源码文本变成本机可执行文件,中间到底经历了什么?这篇把整条路走一遍:手动分词、构造语法树、生成 LLVM IR、编译成目标文件、链接为二进制、运行并检查退出码。走完这条最短路径之后,后续每篇只需往流水线里加东西。 最小的程序 Sprout 语言的第一个合法程序只有一行有效逻辑: 123fn main() -> i64 { return 42;} fn 声明函数,main 是入口,-> i64 标注返回类型为 64 位有符号整数,函数体只做一件事——返回整数字面量 42。这个程序没有变量、没有条件、没有循环,但它足以驱动编译器的每一个阶段。 编译器要做的事情可以用一条流水线概括: 1源码 → 词法分析 → 语法分析 → 代码生成 → LLVM 工具链 → 可执行文件 下面逐段走完这条线。 源码到 Token:词法分析 词法分析器(lexer)的工作是把字符流切成有意义的最小单元——Token。对上面那段源码,手动分词的结果是: 1234567891011FN -- 关键字 fnIDENT(...
从零编写现代编译器 00 - 这门语言准备编译什么
多数编译器教程止步于计算器——解析 1 + 2 * 3,求值,结束。这个系列的终点不同:从一个空目录开始,逐步构建一个能检查类型错误、生成本机可执行文件、报告源码位置并接入编辑器的编译器。 目标语言暂名 Sprout,扩展名 .spr,编译器命令暂定 sproutc。实现语言是 Rust,主后端是 LLVM。进阶部分还会接入 Cranelift 即时编译(Just-In-Time compilation, JIT)、WebAssembly 组件和 MLIR 张量编译。 这篇导读划定语言范围、列出编译架构、给出语义边界草案并确认先修条件。代码从第 01 篇开始;本篇没有可运行的编译器。 本篇目标 读完这篇,需要能回答一个问题:给定一段 Sprout 源码 fn main() -> i64 { return 42; },区分源语言、实现语言、中间表示和机器码四个层次。 这个区分贯穿整个系列。源语言是读者设计的 Sprout,实现语言是 Rust,中间表示包括 AST、HIR、SSA 和 LLVM IR,机器码是目标架构的二进制指令。混淆任何两层都会在后续调试中制造困惑。 最终...
现代编译器写作计划:从 Rust 到 LLVM、Wasm 和 MLIR
这不是一篇“把一个表达式计算器写出来”的短教程,而是一套可以拆成连载的编译器写作计划。目标有三个:读者能跟着写,例子能跟着跑,技术选型不会很快过时。技术锚点按 2026-09-05 的官方文档来定:Rust 1.98.1 / Rust 2024 edition,LLVM 23.1.0,Wasmtime 45.0.0,WASI 0.2/0.3 preview,以及仍在活跃演进中的 MLIR。 这本书要写什么 这套教程的主线不是“如何写一个最小可运行编译器”,而是“如何写一条今天仍然成立的编译器路线”。前半部分讲前端:词法、语法、名字绑定、类型检查、错误恢复。中间部分讲 IR:AST、typed HIR、SSA、控制流图、数据流分析。后半部分讲后端:LLVM IR、Cranelift、WebAssembly component model、WASI,再加一章 MLIR 作为多层 IR 的扩展方向。 这条路线的好处很直接。读者先学到经典编译器骨架,再看到现代工具链怎么把这条骨架接到真实系统上。写完以后,代码可以落到原生二进制,也可以落到 Wasm component;同一套中端还能顺着...
分布式 ID 系统设计:从需求约束推导架构
数据库自增列足以支撑单库业务。系统拆成多个写入节点后,ID 生成突然变成一项基础设施能力:它要避免碰撞,不能拖慢写路径,还要经受扩容、时钟异常、数据库切换、配置漂移和跨地域网络分区。 这类系统最容易从算法名称开始讨论:UUID、Snowflake、数据库序列、号段。算法当然重要,但架构并不是从算法列表里挑出来的。它来自一组可验证的约束:唯一到什么范围,顺序要强到什么程度,故障时允许停多久,ID 可以暴露什么信息,以及团队能维护多少协调状态。 先定义 ID 的合同 “生成一个全局唯一且递增的 ID”看似明确,实际混合了几种不同语义。若不拆开,评审阶段达成的共识很可能只是每个人对同一句话的不同理解。 唯一性有作用域 唯一性至少要说明三个边界: 命名空间:全公司、单业务、单租户、单表,还是单个分片。 时间范围:进程存活期间、数据保留期,还是系统整个生命周期。 保证方式:确定性不重叠,还是碰撞概率低到工程上可接受。 数据库序列和合理分配的 Snowflake 节点空间可以给出确定性的不重叠保证。UUIDv4 依靠足够大的随机空间降低碰撞概率。两者都常被称为“全局唯一”,但证明路径并...



