Hotdry.

Article

用Zig实现C编译器:手写词法分析、语法解析与目标代码生成

基于Zig语言实现完整的C编译器,聚焦词法分析器设计、递归下降语法解析器构建与x86目标代码生成,提供工程化参数与监控要点。

2026-04-23compilers

当我们谈论编译器实现时,Zig 语言因其独特的元编程能力、零成本抽象和对硬件的直接控制而成为理想的实现载体。与 Rust 的复杂生命周期和借用检查不同,Zig 采用更直接的内存管理模型,使编译器前端与后端的实现更加透明可控。本文将系统阐述如何利用 Zig 构建一个功能完备的 C 编译器,涵盖词法分析、语法解析与目标代码生成三大核心阶段。

词法分析:Scanner 结构设计与 Token 定义

词法分析是编译过程的第一道门槛,其任务是将原始源代码字符流转换为离散的 Token 序列。在 Zig 中实现词法分析器,我们需要构建一个 Scanner 结构体来维护扫描状态。核心状态包括:当前扫描起始位置 start、当前推进位置 current、当前行号 line 以及已识别 Token 的存储列表 tokens。Zig 的切片机制使其可以高效地从源代码字符串中提取词素,而无需额外的内存拷贝。

pub const Scanner = struct {
    start: usize,
    current: usize,
    line: usize,
    tokens: std.ArrayList(Token),
    alloc: std.mem.Allocator,
    source: []const u8,
    keyword_map: std.StringHashMap(TokenType),
};

Token 的定义采用 Zig 强大的标签联合特性,能够在同一数据结构中表示不同类型的字面量值。整型、浮点型和字符串字面量可以通过 Literal 联合的变体进行区分,而标识符和关键字则通过 TokenType 枚举进行分类。关键字识别通过 StringHashMap 实现,将字符串形式的保留字映射到对应的 TokenType 枚举值,这一设计使得语法分析阶段能够快速判断标识符是否为保留关键字。

词素提取采用最大 munch 原则,即始终选择能够组成的最长合法词素。这一原则对于处理标识符与关键字的歧义至关重要。例如,当源代码中出现 "forloop" 时,词法分析器应将其识别为单个标识符而非关键字 "for" 与标识符 "loop" 的组合。实现上,我们在识别完标识符后查询 keyword_map,若未命中则将其标记为普通 IDENTIFIER。

语法解析:递归下降 Parser 与 AST 构建

完成词法分析后,我们得到的是线性的 Token 序列。语法解析阶段的任务是将这些 Token 重组为层次化的抽象语法树(AST)。递归下降_parser 是实现 C 语言子集最直观的方法,其核心思想是为每种语法结构编写专门的解析函数,函数之间通过相互调用形成递归关系。

对于 C 语言的基本表达式,我们可以构建如下递归下降解析器:表达式解析函数首先尝试解析加法表达式,加法表达式解析函数则尝试解析乘法表达式,乘法表达式解析函数进一步解析一元表达式,一元表达式解析函数解析基本表达式。这种从低优先级到高优先级的设计确保了表达式的运算优先级被正确保留。

pub fn parseExpression(self: *Parser) !*AstNode {
    return try self.parseAddition();
}

pub fn parseAddition(self: *Parser) !*AstNode {
    var left = try self.parseMultiplication();
    while (self.check(.PLUS) or self.check(.MINUS)) {
        const operator = self.advance();
        const right = try self.parseMultiplication();
        left = try self.createBinaryNode(operator, left, right);
    }
    return left;
}

AST 节点的表示同样利用 Zig 的标签联合,每个节点类型对应联合中的一个变体。表达式语句、声明语句、控制流语句等都有其对应的节点结构。值得注意的是,Zig 的 comptime 特性允许我们 在编译期进行大量的类型检查和代码验证,这为编译器的可靠性提供了额外的保障。

目标代码生成:x86 架构的机器码发射

从 AST 到可执行文件的最后一步是目标代码生成。对于 x86-64 架构,我们可以选择生成汇编代码后调用外部汇编器,也可以直接发射机器码。直接发射机器码虽然实现复杂度较高,但能够实现真正的自包含编译过程,无需依赖外部工具链。

x86-64 指令编码的核心在于 ModR/M 字节和 SIB 字节的构造。每条指令由操作码前缀、操作码、ModR/M、SIB、位移和立即数组成。以最常用的 MOV 指令为例,其基本操作码为 0x89(寄存器到寄存器)或 0x8B(寄存器到寄存器,反向),具体的寄存器编码通过 ModR/M 字节中的 reg 和 r/m 字段指定。RAX 到 R15 寄存器的编码分别为 0 到 15。

const x86 = @import("x86-zig");
const machine64 = x86.Machine.init(.x64);
const instr = try machine64.build2(.MOV, 
    x86.Operand.register(.RAX), 
    x86.Operand.immediate(42)
);
const code = instr.asSlice(); // 输出: 48 B8 2A 00 00 00 00 00 00 00

对于完整的 C 函数代码生成,我们需要处理函数调用约定、栈帧分配和寄存器分配。System V AMD64 ABI 要求函数参数依次使用 RDI、RSI、RDX、RCX、R8、R9 传递,超出部分通过栈传递返回值通过 RAX 返回。函数序言和尾声分别负责栈帧的建立与销毁:push rbp 保存调用者栈帧指针,mov rbp, rsp 分配新栈帧,sub rsp, N 分配局部变量空间,函数返回前执行 leave 指令恢复栈帧。

工程实践参数与监控要点

在实现完整的 C 编译器时,以下工程参数值得特别关注。词法分析阶段,单个 Token 的识别应在 O (1) 时间内完成,整体时间复杂度为 O (n),其中 n 为源代码长度。对于大型源文件,建议采用分块读取策略,每次读取 4KB 至 64KB 数据,避免一次性加载整个文件导致内存压力。Scanner 的缓冲区大小建议设置为 8192 字节,既能保证缓存友好性,又能控制内存占用。

语法解析阶段,AST 节点的内存分配是性能关键路径。建议使用内存池(Memory Pool)策略,预先分配大块内存并在其中切分出节点结构,避免频繁的系统调用。典型的内存池大小为 1MB 至 8MB,具体取决于待编译代码的复杂度。解析错误恢复采用同步标记法,在检测到语法错误后跳过 Tokens 直到遇到同步点(如分号或右花括号),以继续报告后续错误。

代码生成阶段,指令选择应优先考虑寄存器直接操作,避免不必要的内存访问。函数内联阈值可设为 20 至 50 条指令,超出此阈值的函数不进行内联优化。寄存器分配采用线性扫描算法,活跃区间计算从代码生成阶段开始,分配过程的时间复杂度为 O (REGNUM × INSNUM)。生成的机器码应进行基本的有效性验证,包括检查指令长度是否合法、操作数是否在有效范围内。

编译过程的监控指标包括:每秒编译行数(LCPS)、峰值内存占用、错误报告数量与位置分布。对于增量编译场景,还应记录依赖文件的修改时间戳,仅重新编译受影响的翻译单元。调试信息生成时,DWARF 格式的行号表条目应与源代码行号一一对应,便于后续的符号调试和堆栈追溯。

通过上述设计与实现,我们利用 Zig 语言构建了一个完整的 C 编译器原型。Zig 的 comptime 求值能力使得许多编译器检查可以在编译期完成,而其对硬件的直接访问能力则确保了目标代码生成的高效性。这一实现不仅深化了对编译原理的理解,也为探索更高级的编译器优化技术奠定了坚实基础。

参考资料:Writing a Lexer in Zig - Noah Solomon Blog;x86-zig library for assembling x86 in Zig

compilers