Read OSS

代码生成与链接:从 AIR 到二进制

高级

前置知识

  • 本系列第 1 至 3 篇文章
  • 对机器码和指令编码有基本了解
  • 熟悉可执行文件格式(ELF、Mach-O 或 PE/COFF)

代码生成与链接:从 AIR 到二进制

Sema 为函数生成类型化的 AIR 之后,编译器的后端就接手了。流水线的后半段——代码生成与链接——负责将抽象操作转换为具体的机器指令,并将它们组装成可执行的二进制文件。Zig 编译器支持多个代码生成后端和多套链接器实现,统一由一个通用分发层进行调度。

本文将逐步追踪 AIR 如何流经代码生成分发器、降级为后端专属的 MIR、输出为机器码,最终被链接为产物文件。

代码生成分发器与后端选择

代码生成的入口位于 src/codegen.zig,通过 importBackend() 将请求路由到对应的后端:

fn importBackend(comptime backend: std.builtin.CompilerBackend) type {
    return switch (backend) {
        .stage2_aarch64 => aarch64,
        .stage2_c => @import("codegen/c.zig"),
        .stage2_llvm => @import("codegen/llvm.zig"),
        .stage2_riscv64 => @import("codegen/riscv64/CodeGen.zig"),
        .stage2_sparc64 => @import("codegen/sparc64/CodeGen.zig"),
        .stage2_spirv => @import("codegen/spirv/CodeGen.zig"),
        .stage2_wasm => @import("codegen/wasm/CodeGen.zig"),
        .stage2_x86, .stage2_x86_64 => @import("codegen/x86_64/CodeGen.zig"),
        // ...
    };
}

这是编译期分发——backend 参数在编译时已知,因此 importBackend 会直接解析为具体模块。结合 dev.zig 中的特性门控(通过 第 32 行devFeatureForBackend),不需要的后端会被彻底死代码消除。

flowchart TD
    AIR["AIR"] --> DISP["codegen.zig dispatcher"]
    DISP -->|"x86_64"| X86["x86_64/CodeGen.zig\n~190K lines"]
    DISP -->|"aarch64"| ARM["aarch64/\nCodeGen.zig"]
    DISP -->|"C"| CBE["c.zig\n~8K lines"]
    DISP -->|"LLVM"| LLVM["llvm.zig\n~13K lines"]
    DISP -->|"wasm"| WASM["wasm/CodeGen.zig"]
    DISP -->|"riscv64"| RV["riscv64/CodeGen.zig"]
    DISP -->|"spirv"| SPIRV["spirv/CodeGen.zig"]

两阶段代码生成:MIR 与输出

代码生成分为两个阶段,各自对应独立的函数。这种划分将生成什么指令如何将字节写入输出文件的正确位置解耦开来。

第一阶段:generateFunction第 141 行)将 AIR 降级为后端专属的 MIR(Machine IR)。它接收一个 Air,产出一个 AnyMir

pub fn generateFunction(
    lf: *link.File, pt: Zcu.PerThread,
    src_loc: Zcu.LazySrcLoc, func_index: InternPool.Index,
    air: *const Air, liveness: *const ?Air.Liveness,
) CodeGenError!AnyMir { ... }

第二阶段:emitFunction第 176 行)将 MIR 转换为原始机器码字节。它由链接器调用,并可能查询链接器状态(例如用于解析重定位目标):

pub fn emitFunction(
    lf: *link.File, pt: Zcu.PerThread,
    src_loc: Zcu.LazySrcLoc, func_index: InternPool.Index,
    atom_index: u32, any_mir: *const AnyMir,
    w: *std.Io.Writer, debug_output: link.File.DebugInfoOutput,
) ...
flowchart LR
    AIR["AIR\n(typed)"] -->|"generateFunction"| MIR["MIR\n(backend-specific)"]
    MIR -->|"emitFunction"| MC["Machine Code\n(bytes)"]
    MC -->|"linker writes"| BIN["Output Binary"]

    style AIR fill:#e3f2fd
    style MIR fill:#fff3e0
    style MC fill:#fce4ec

第 100 行定义的 AnyMir 联合体是两个阶段之间的桥梁:

pub const AnyMir = union {
    aarch64: @import("codegen/aarch64/Mir.zig"),
    riscv64: @import("codegen/riscv64/Mir.zig"),
    x86_64: @import("codegen/x86_64/Mir.zig"),
    wasm: @import("codegen/wasm/Mir.zig"),
    c: @import("codegen/c.zig").Mir,
    // ...
};

这个联合体让通用的代码生成/链接器接口得以传递后端专属数据,无需模板或类型擦除。当前活跃字段由所使用的后端决定,访问错误字段属于逻辑错误,类型系统会直接捕获。

提示: C 后端比较特殊——它的"MIR"本质上是一棵 C AST,且 emitFunction 永远不会被调用。取而代之的是,link.C 直接理解 C 后端的 MIR,并将其渲染为 .c 文件。

后端深度解析:x86_64、C 与 LLVM

三个最重要的后端各自承担不同的职责:

后端 位置 规模 职责
x86_64 src/codegen/x86_64/CodeGen.zig ~190K 行 最成熟的自托管后端
C src/codegen/c.zig ~8K 行 引导链所必需(输出 .c 文件)
LLVM src/codegen/llvm.zig ~13K 行 对接外部 LLVM 的生产路径

x86_64 后端是旗舰级自托管后端,负责寄存器分配、指令选择以及直接的 x86_64 编码。它拥有约 19 万行代码,是编译器中体积最大的单一组件,甚至超过了 Sema。

C 后端输出的是 C 源代码而非机器码,这正是引导链得以成立的基础:zig1 使用 C 后端生成 zig2.c,再由系统 C 编译器负责编译。尽管承担着如此关键的职责,它的体积却只有约 8000 行,相当精简。

LLVM 后端不直接生成机器码,而是通过 LLVM 的 C API 将 AIR 转换为 LLVM IR,然后交由 LLVM 完成优化和本地代码生成。对于追求极致优化的用户,这是首选的生产路径。

此外,第 299 行还有一个 generateSymbol 函数,用于处理非函数声明(全局变量、常量等)。与 generateFunction 不同,它不经过 MIR,而是直接写入值的字节表示。

链接器文件抽象层

链接器层从 src/link.zig#L380 中定义的 File 结构体开始:

pub const File = struct {
    tag: Tag,
    comp: *Compilation,
    emit: Path,
    file: ?fs.File,
    // ...
};

Tag 枚举标识具体的链接器实现:

pub const Tag = enum {
    coff2, elf, elf2, macho, c, wasm, spirv, plan9, lld,

    pub fn Type(comptime tag: Tag) type {
        return switch (tag) {
            .coff2 => Coff2, .elf => Elf, .elf2 => Elf2,
            .macho => MachO, .c => C, .wasm => Wasm,
            .spirv => SpirV, .lld => Lld, .plan9 => unreachable,
        };
    }
};
classDiagram
    class File {
        +tag: Tag
        +comp: *Compilation
        +emit: Path
        +prelink()
    }
    class Elf {
        +base: File
        +zig_object: ?*ZigObject
        +sections: MultiArrayList
        +files: MultiArrayList
    }
    class MachO {
        +base: File
    }
    class C {
        +base: File
    }
    class Wasm {
        +base: File
    }
    File <|-- Elf
    File <|-- MachO
    File <|-- C
    File <|-- Wasm

可以注意到这里有两套 ELF 实现:elf(原版)和 elf2(新版重写)。fromObjectFormat 函数会根据 use_new_linker 标志在两者之间切换。在过渡期同时维护新旧两套实现,这在 Zig 项目中是常见的做法。

链接器通过编译工作队列中的 link_navlink_type 任务接收来自代码生成的工作。当一个 Nav 的值完全解析后,系统会将一个 link_nav 任务加入队列,进而调用对应的链接器实现,将该声明写入输出二进制文件。

自托管 ELF 链接器深度解析

ELF 链接器位于 src/link/Elf.zig,是最复杂的自托管链接器实现。其结构体内嵌 base: link.File,并附加了 ELF 专属状态:

base: link.File,
zig_object: ?*ZigObject,
rpath_table: std.StringArrayHashMapUnmanaged(void),
image_base: u64,
// ... 许多 z_* 链接器选项标志
files: std.MultiArrayList(File.Entry) = .{},
sections: std.MultiArrayList(Section) = .{},

链接器管理的基本单位是 Atom(定义于 Elf/Atom.zig)——可重定位代码或数据的最小单元。每个函数和全局变量都会成为一个 Atom。ZigObject 代表 ELF 文件中由 Zig 生成的对象,而 SharedObject 条目则代表动态链接库。

在增量链接模式下,ELF 链接器可以就地修补单个 atom,无需重新链接整个二进制文件。某个函数发生变更时,只有对应的 atom 会被重新输出。这得益于初次链接时会预留填充空间,供后续更新填充使用。

flowchart TD
    subgraph "ELF Linker"
        ZO["ZigObject\n(Zig code atoms)"]
        OBJ["Object Files\n(C objects, etc.)"]
        SO["Shared Objects\n(.so files)"]
        SECT["Sections\n(.text, .data, .rodata)"]
        SHDR["Section Headers"]
        OUT["ELF Binary"]
    end
    ZO --> SECT
    OBJ --> SECT
    SO --> SECT
    SECT --> SHDR
    SHDR --> OUT

代码生成与链接在任务系统中的运作

如第 1 篇文章所述,编译过程使用分阶段的任务队列。代码生成与链接通过 src/Compilation.zig 中的该系统进行协调。

processOneJob 函数处理 codegen_func 任务时会依次执行以下步骤:

  1. 检查 AIR 中所有类型是否已完全解析
  2. 为函数创建 SharedMir
  3. 若后端支持,则为代码生成派生一个工作线程;否则在当前线程上执行
  4. 分发 link_func 任务,将机器码写入输出二进制文件

任务优先级系统将 codegen_funcresolve_type_fully 设为 第 0 阶段(最高优先级),其他任务如 analyze_modlink_nav 则为第 1 阶段。这样可以确保代码生成线程保持忙碌,同时 Sema 继续分析其他函数:

sequenceDiagram
    participant WQ as Work Queue
    participant T1 as Thread 1 (Sema)
    participant T2 as Thread 2 (Codegen)
    participant LNK as Linker

    T1->>WQ: Queue codegen_func (stage 0)
    T1->>T1: Continue analyzing next function
    T2->>WQ: Dequeue codegen_func
    T2->>T2: generateFunction (AIR → MIR)
    T2->>LNK: Dispatch link_func task
    LNK->>LNK: emitFunction (MIR → bytes)

提示: separateCodegenThreadOk() 方法决定代码生成是否可以在独立于 Sema 的线程上运行。由于部分后端存在线程安全问题(记录于 Zcu.Feature.separate_thread),编译器在必要时会回退到单线程执行。

下一步

至此,我们已完整追踪了从源码字节到二进制输出的全部路径。在最后一篇文章中,我们将把视角拉远,审视整个编排层:Compilation.update() 如何驱动完整的编译周期、三种缓存模式如何运作、InternPool 的依赖追踪如何实现细粒度增量重编译,以及多阶段引导链如何将一切串联在一起。