从零编写操作系统 02 - 第一个启动扇区:让 BIOS 执行自己的指令
第 01 篇得到了一块全零磁盘。QEMU 能运行固件,GDB 能控制 CPU,但 BIOS 最终只显示 No bootable device.。磁盘里没有可执行的引导入口,工具链生成的 ELF 文件也没有被加载。 本篇把一段 16 位机器码写进磁盘第一个扇区。BIOS 读取它,程序准备自己的段寄存器和栈,输出固定标识,再停在可检查的循环中。改坏末尾签名后,同一份机器码将不再得到执行机会。 从第一个扇区交出执行权 在本系列固定的 QEMU PC 和 SeaBIOS 环境里,固件尝试从 IDE 硬盘启动时,会读取第一个逻辑扇区,把内容放到物理地址 0x7c00。通过有效性检查后,执行流转移到这块内存。 磁盘上的扇区编号、文件偏移和内存地址是三个不同的量: 123456789镜像文件偏移 0 … 511:第一个 512 字节扇区 │ BIOS 读取 ▼客体内存0x7c00 … 0x7dff:刚读入的同一组字节 │ CPU 取指 ▼设置段与栈 → 输出标识 → 停止 0x7c00 不是 NASM 决定的磁盘位置。固件先决定加载位置,汇编代...
从零编写操作系统 06 - 输出与故障现场:建立串口、控制台和 panic
第 05 篇已经完成从保护模式到 kernel_main 的交接,栈、参数、已初始化数据和 BSS 都有检查。内核还需要把检查结果输出成可保存的日志,并在无法继续时留下调用位置。 第 06 篇增加 COM1 串口、VGA 文本控制台和 panic。正常镜像会演示换行、滚屏与格式化;独立故障镜像触发断言,同时输出文件名、行号和表达式,随后循环执行 hlt。这些功能仍运行在单核、未开启 IRQ、没有分页的早期内核里。 输出模块在自检之前初始化 启动器仍按第 05 篇的契约装载内核。stage2 完成 E820、A20 和保护模式切换,把内核复制到 0x100000;入口汇编清 BSS、建立栈,再把 BootInfo 指针传给 C。新增的控制台没有改变这条装载路径。 kernel_main 的第一步是 console_init(),之后才检查 BootInfo、data、BSS 和最小运行时函数。这个顺序让自检失败能输出具体原因。若先验证 BSS,输出模块却依赖尚未初始化的状态,坏 BSS 实验就可能连错误信息也无法显示。 因此,console_init() 显式把 row、colu...
从零编写操作系统 01 - 准备工具链:源代码怎样变成磁盘里的字节
在普通应用项目里,编译成功后通常可以直接运行可执行文件。自制操作系统还缺少一段工作:谁把文件里的指令放进内存,谁准备栈,CPU 又从哪里开始执行? 本篇先把这些问题拆开验证。第一条实验生成 ELF32/i386 文件,检查源代码、符号、链接地址和机器指令的对应关系。第二条实验把一块全零磁盘接到 QEMU,观察固件启动失败,再用 GDB 停住并单步执行 CPU。 两条实验还没有连接起来。sample.elf 不会被写进空磁盘,也不会由 BIOS 加载。本篇完成后得到的是可用的编译与调试环境;第 02 篇才开始写 BIOS 能执行的启动扇区。 宿主、目标与固件 本次实验的宿主链路是 ARM macOS 上的 Podman Linux 虚拟机,容器内运行 Debian 12 aarch64。交叉编译器本身是一段 ARM Linux 程序,它生成的指令却属于 32 位 x86。QEMU 再通过 TCG 动态翻译运行这个 x86 客体,不要求宿主 CPU 支持直接执行 x86 指令,机制见 QEMU TCG 文档。 12345678ARM macOS └─ Podman Linux V...
从零编写操作系统 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() -&...
