从零编写操作系统 00 - 从启动扇区到窗口:这套操作系统准备实现什么
这个系列准备从一个 512 字节启动扇区开始,逐步做出一个能运行用户程序、读取文件、响应键盘鼠标、显示窗口终端的教学操作系统。 路线故意选得朴素:x86 32 位、BIOS、C11、少量 NASM 汇编、QEMU 模拟 PC。它不是现代操作系统的完整复刻,也不追求真实机器兼容。它的目标是把“操作系统到底接管了什么”拆成一组可以运行、可以坏掉、可以诊断的实验。 最终目标是一条连续演示:从自制磁盘镜像启动,进入自制内核;打开一个终端窗口;运行 cat README.TXT;再运行 cat README.TXT | wc;最后运行一个 DEMO.EXE,它并发启动两个计数程序和一个故意越界访问的程序。越界程序应该被终止,两个计数程序继续前进,Shell 还能继续接收下一条命令。 这篇导读只给路线、边界和先修自测。首批配套项目提供工具链样例;自制内核尚未出现。后续每篇会在同一份代码上增加一个可观察能力,并给出构建命令、运行日志和故障实验。 最终系统长什么样 先用一张目标图定住边界。图里的“目标”表示后续实现完成后的结构,不是当前已经跑通的截图。 12345678910111213141...
从零编写现代编译器 30 - 张量方言与 Lowering 到 LLVM
上一篇为 Sprout 定义了自己的 MLIR 方言——带形状检查的张量操作停留在高层表示里,没有进入真正的机器码。这一篇把它降下去:从张量方言出发,经过 linalg、scf、memref,一路到 LLVM IR,最后链接成本机可执行文件。这条路径正是 MLIR 最初被设计出来要走的路——它诞生于 TensorFlow 和 IREE 项目,目的就是让张量运算能在多层抽象之间有序地降低。 张量类型:值语义的多维数组 MLIR 里的 tensor<4x4xi64> 表示一个 4 行 4 列、元素类型为 i64 的多维数组。它和 memref<4x4xi64> 的区别在语义层面:tensor 是值语义(value semantics),一次操作产生一个新 tensor,原来的不变;memref 是引用语义(reference semantics),指向一块具体内存,修改直接生效。 这个区别不是风格偏好,而是编译优化的前提。值语义让编译器可以自由重排、合并、拆分张量操作而不用担心别名问题。等到优化做完,再通过 bufferization 统一转成 memref...
从零编写现代编译器 29 - MLIR 入门:定义 Sprout 方言
到第 14 篇为止,Sprout 的编译管线把 Typed HIR 降低到 LLVM IR,再交给 LLVM 的 pass pipeline 做优化和代码生成。这条路径能用,但有一个结构性的缺陷:LLVM IR 太低了。循环变成了跳转和 phi,数组访问变成了 getelementptr,函数调用的高层语义全部摊平成了调用约定。一旦进入 LLVM IR,编译器就很难再回答"这段代码原来是一个模式匹配"或者"这个数组创建应该做边界检查"这类高层问题。 MLIR(Multi-Level Intermediate Representation)正是为了解决这个问题而设计的。它允许在同一个框架内定义多个抽象层级的 IR——称为方言(dialect),每个方言保留特定层级的语义信息,再逐层降低到最终的机器表示。这一篇我们为 Sprout 定义一个自己的 MLIR 方言,并搭建从 Sprout 方言到 LLVM IR 的降低管线。 MLIR 的核心结构 MLIR 的设计围绕几个递归嵌套的概念展开。 操作(Operation) 是最基本的单元。每个操作...
从零编写现代编译器 28 - 用 WIT 封装组件接口
第 27 篇把 Sprout 的标量子集编译成了一个独立的 .wasm 核心模块。模块能在 Wasmtime 里跑起来,但它的边界只有导出函数的数字签名——调用者必须自己知道哪个参数是长度、哪个是指针偏移、返回值代表什么。换句话说,核心模块没有类型化的接口描述,就像 C 的 .o 文件没有头文件。 WIT(WebAssembly Interface Types)正是给 Wasm 组件补上的这层头文件。它用一种独立的接口定义语言描述组件的导入和导出:函数签名、参数类型、返回类型、记录、枚举、列表。两个组件通过 WIT 约定的接口互相调用,运行时在边界处自动完成类型验证和数据格式转换。组件之间不共享线性内存,也不需要知道对方的内部布局。 这篇要做的事情:给 Sprout 的统计函数写一份 WIT 接口定义,用工具把核心模块封装成组件,再把它和一个提供文件读取能力的宿主组件接到一起。 WIT 的基本结构 第 27 篇使用 wasm32-wasi(WASI preview 1)编译核心模块。本篇切换到 wasm32-wasip2(WASI preview 2),因为组件模型是 WASI...
从零编写现代编译器 27 - 编译到 WebAssembly
前 26 篇编译出来的都是本机二进制。它在 Linux x86-64 上跑得好好的,拿到 ARM macOS 就得重新编译,拿到浏览器里更是无从执行。WebAssembly 提供了另一条路:编译一次,生成一个 .wasm 模块,任何带 Wasm 运行时的平台都能直接运行。这篇给 sproutc 加一个 wasm32-wasi 目标,让同一份 Sprout 源码产出可移植的 Wasm 模块。 WebAssembly 是什么 WebAssembly(缩写 Wasm)是一种二进制指令格式,设计目标是安全、快速、可移植。它不绑定任何特定操作系统或处理器架构。一个 .wasm 文件是一个模块,包含函数定义、线性内存、类型签名和导入导出声明。 几个关键特征: 栈机器。Wasm 的执行模型是栈机器,不是寄存器机器。每条指令从栈上消费操作数,把结果压回栈。i64.add 弹出两个 i64,压入一个 i64。这和 x86 的寄存器模型不同,但 LLVM 后端会处理这层转换。 类型化函数。每个函数都有精确的参数和返回值类型签名。调用时参数数目和类型必须匹配。这在模块加载阶段就能验证,不需要等到运行...
从零编写现代编译器 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...




