コード生成とリンク: AIR からバイナリへ
前提知識
- ›本シリーズの第1〜3回
- ›マシンコードと命令エンコーディングの基礎知識
- ›実行可能ファイル形式 (ELF、Mach-O、PE/COFF) への理解
コード生成とリンク: AIR からバイナリへ
Sema が関数の型付き AIR を生成し終えると、コンパイラのバックエンドが処理を引き継ぎます。パイプラインのこの後半部分 — コード生成とリンク — では、抽象的な操作を具体的なマシン命令へと変換し、それらを実行可能なバイナリとして組み立てます。Zig コンパイラは複数の codegen バックエンドと複数のリンカー実装をサポートしており、いずれもジェネリックなディスパッチ層を通じて制御されています。
この記事では、AIR が codegen ディスパッチャを経由してバックエンド固有の MIR へと変換され、マシンコードとして出力されてリンクされるまでの流れを追っていきましょう。
Codegen ディスパッチャとバックエンドの選択
codegen のエントリーポイントは 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"),
// ...
};
}
これは comptime ディスパッチです。backend パラメータはコンパイル時に確定するため、importBackend は具体的なモジュールに解決されます。さらに、32行目 の devFeatureForBackend による dev.zig のフィーチャーゲーティングと組み合わせることで、不要なバックエンドは完全にデッドコードとして除去されます。
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"]
2フェーズのコード生成: MIR と出力
コード生成は、それぞれ独立した関数を持つ2つのフェーズに分かれています。この分割により、「何の命令を生成するか」という関心と「バイトを出力ファイルのどこに配置するか」という関心が明確に分離されます。
フェーズ 1: 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 { ... }
フェーズ 2: 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 ユニオンが、この2つのフェーズをつなぐ橋渡し役を担っています。
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,
// ...
};
このユニオンにより、テンプレートや型消去を使わずに、バックエンド固有のデータをジェネリックな codegen/リンカーインターフェース越しに受け渡せます。アクティブなフィールドは使用中のバックエンドから確定するため、誤ったフィールドへのアクセスは型システムによって論理エラーとして検出されます。
ヒント: C バックエンドは特別な存在です。その「MIR」は実質的に C の AST であり、
emitFunctionは呼び出されません。代わりにlink.Cが C バックエンドの MIR を直接解釈し、.cファイルとして出力します。
バックエンドの詳細: x86_64、C、LLVM
最も重要な3つのバックエンドは、それぞれ異なる役割を担っています。
| バックエンド | 場所 | 規模 | 役割 |
|---|---|---|---|
| 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 の直接エンコーディングを行います。190K行という規模は、コンパイラ内の単一コンポーネントとして最大であり、Sema をも上回ります。
C バックエンドはマシンコードではなく C ソースコードを出力します。これによりブートストラップチェーンが成立しています。zig1 は C バックエンドを使って zig2.c を生成し、それをシステムの C コンパイラがコンパイルします。その重要な役割にもかかわらず、わずか約8K行という驚くほどコンパクトな実装です。
LLVM バックエンドは直接マシンコードを生成しません。代わりに、LLVM の C API を通じて AIR を LLVM IR に変換し、最適化とネイティブコード生成を LLVM に委ねます。最大限の最適化を求めるユーザー向けのプロダクションパスです。
また、299行目 には generateSymbol 関数もあります。これは関数以外の宣言 (グローバル変数や定数) を処理します。generateFunction とは異なり、MIR を経由せず、値のバイト表現を直接書き出します。
リンカーの File 抽象化
リンカー層の起点は 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 の実装が2つあることに気づいた方もいるでしょう。elf (旧実装) と elf2 (新しい書き直し版) です。fromObjectFormat 関数は use_new_linker フラグに基づいてどちらを使うか選択します。移行期間中に新旧両実装を並存させるこのパターンは、Zig プロジェクトではよく見られます。
リンカーは、コンパイルのワークキューに積まれた link_nav と link_type ジョブを通じて codegen から作業を受け取ります。Nav の値が完全に解決されると link_nav ジョブがキューに追加され、対応するリンカー実装が呼び出されて宣言が出力バイナリに書き出されます。
セルフホスト ELF リンカーの詳細
src/link/Elf.zig の ELF リンカーは、セルフホストリンカーの中で最も複雑な実装です。構造体には base: link.File が埋め込まれ、ELF 固有の状態が追加されています。
base: link.File,
zig_object: ?*ZigObject,
rpath_table: std.StringArrayHashMapUnmanaged(void),
image_base: u64,
// ... many z_* flags for linker options
files: std.MultiArrayList(File.Entry) = .{},
sections: std.MultiArrayList(Section) = .{},
リンカーは Atom (Elf/Atom.zig で定義) を管理します。Atom はコードやデータの最小リロケータブル単位で、すべての関数とグローバル変数がそれぞれ 1 つの 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
ジョブシステムにおける Codegen とリンク
第1回で触れたように、コンパイルはステージ分けされたジョブキューで動作します。Codegen とリンクの処理は src/Compilation.zig 内のこのシステムを通じて調整されます。
processOneJob 関数は codegen_func ジョブを次の手順で処理します。
- AIR 内のすべての型が完全に解決済みであることを確認する
- 関数の
SharedMirを作成する - バックエンドがスレッドをサポートしていれば codegen 用ワーカースレッドを起動し、そうでなければ現在のスレッドで codegen を実行する
- マシンコードを出力バイナリに書き込む
link_funcタスクをディスパッチする
ジョブ優先度システムでは、codegen_func と resolve_type_fully がステージ 0 (最高優先度) に位置し、analyze_mod や link_nav などは ステージ 1 となります。これにより、Sema が他の関数の解析を続けている間も、codegen スレッドが常に稼働し続けることが保証されます。
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()メソッドは、codegen を Sema とは別のスレッドで実行できるかどうかを判断します。一部のバックエンドにはスレッドに関するバグがあり (Zcu.Feature.separate_threadに記載)、その場合はシングルスレッド実行にフォールバックします。
次回予告
ここまでで、ソースのバイトからバイナリ出力までの完全な流れを追うことができました。最終回では、視点を引き上げてオーケストレーション層を見ていきます。Compilation.update() がサイクル全体をどう駆動するか、3つのキャッシュモードの仕組み、InternPool の依存関係追跡による細粒度インクリメンタル再コンパイルの実現方法、そしてマルチステージブートストラップの全体像を解説します。