Zigコンパイラのアーキテクチャ:コードベースを読むための地図
前提知識
- ›Zig言語の基本知識(comptime、@import、エラーユニオン、packed struct)
- ›コンパイラの基本概念に関する一般的な理解(字句解析、構文解析、IR、コード生成)
Zigコンパイラのアーキテクチャ:コードベースを読むための地図
Zigコンパイラは、単一のモノリポに収められたセルフホスト型・多段階コンパイラです。執筆時点で、src/ ディレクトリだけでZigのコードが30万行を超えており、x86_64バックエンドがさらに19万行を占めています。コードを1行も読む前に、「どのファイルが重要で、ファイル間がどうつながっていて、データがどこを流れるのか」という全体像を頭に入れておく必要があります。この記事はその地図を提供することを目的としています。
リポジトリのレイアウトを把握し、ソースからバイナリに至るIRチェーン全体を追い、3つの中心的なデータ構造を把握し、セルフホスティングを可能にするブートストラッププロセスを理解していきましょう。
リポジトリのレイアウトとディレクトリ構造
Zigのモノリポには、コンパイラ・標準ライブラリ・ビルドシステム・バンドルされたリンカがすべて1つのツリーに収められています。大まかな構成は次のとおりです。
| ディレクトリ | 役割 |
|---|---|
src/ |
コンパイラ本体 — Sema、コード生成、リンカ、CLI |
lib/std/ |
標準ライブラリ(コンパイラの フロントエンド を含む) |
lib/std/zig/ |
トークナイザ、パーサ、AstGen、ZIR — ツール間で共有 |
stage1/ |
ブートストラップ成果物: zig1.wasm、wasm2c.c、wasi.c |
build.zig |
コンパイラ向けビルドシステム定義 |
bootstrap.c |
zig1 → zig2 → zig3 をつなぐ純粋なCプログラム |
test/ |
コンパイラのテストスイート |
lib/compiler/ |
バンドルされたcompiler-rt、aro(Cパーサ)など |
ここで意外なのは、トークナイザ・パーサ・AstGenからなるコンパイラの フロントエンド が src/ ではなく lib/std/zig/ に置かれていることです。これは意図的な設計上の判断であり、第2回の記事で詳しく取り上げます。
graph TD
subgraph "Repository Root"
A["src/"] --> B["Compiler Core"]
C["lib/std/zig/"] --> D["Frontend (shared)"]
E["stage1/"] --> F["Bootstrap Artifacts"]
G["build.zig"] --> H["Build System"]
I["bootstrap.c"] --> J["C Bootstrap Chain"]
end
D -->|"used by"| B
D -->|"used by"| K["zig fmt, ZLS"]
ヒント: コードベースを読み進める際、コンパイラコード内の
@import("std")はlib/std/を参照することを覚えておきましょう。つまりstd.zig.Zirはlib/std/zig/Zir.zigに解決され、src/内のファイルではありません。
コンパイルパイプラインの全体像
すべての .zig ソースファイルは、機械語になるまでに一連の中間表現(IR)を経由します。パイプラインは6つのステージで構成されています。
flowchart LR
A["Source\nBytes"] --> B["Tokens"]
B --> C["AST"]
C --> D["ZIR"]
D --> E["AIR"]
E --> F["MIR"]
F --> G["Machine Code\n/ Binary"]
style A fill:#e8f5e9
style D fill:#fff3e0
style E fill:#e3f2fd
style G fill:#fce4ec
| ステージ | ファイル | 場所 |
|---|---|---|
| トークン化 | tokenizer.zig |
lib/std/zig/ |
| 構文解析 | Parse.zig |
lib/std/zig/ |
| AstGen(AST → ZIR) | AstGen.zig |
lib/std/zig/ |
| Sema(ZIR → AIR) | Sema.zig |
src/ |
| コード生成(AIR → MIR) | codegen.zig |
src/ |
| リンク(MIR → バイナリ) | link.zig |
src/ |
lib/std/zig/ と src/ の境界は、型なし 表現と 型あり 表現の境界でもあります。ZIRは最後の型なしIRであり、Semaがそれを完全な型情報を持つAIR(Analyzed IR)に変換します。この境界はまた、zig fmt やZLSといったツールが再利用できるコードと、コンパイラ専用コードの境界にもなっています。
エントリーポイント:main.zig とコマンドディスパッチ
コンパイラは src/main.zig#L166 の pub fn main() から始まります。ここでグローバルアロケータをセットアップし、ビルドモードに応じてデバッグアロケータ・libcアロケータ・SMPアロケータのいずれかを選択します。
アロケータのセットアップが終わると、処理は mainArgs() に移ります。ここでは大きな if-else チェーンによる文字列比較でコマンドをディスパッチします。
flowchart TD
M["main()"] --> MA["mainArgs()"]
MA -->|"build-exe"| BOT["buildOutputType()"]
MA -->|"build-lib"| BOT
MA -->|"build-obj"| BOT
MA -->|"test"| BOT
MA -->|"run"| BOT
MA -->|"cc / c++"| BOT
MA -->|"fmt"| FMT["fmt.zig"]
MA -->|"build"| CMD["cmdBuild()"]
MA -->|"fetch"| FETCH["cmdFetch()"]
コンパイルの主経路は buildOutputType() を通ります。この巨大な関数がCLIフラグを解析し、Compilation オブジェクトを構築し、その update() を呼び出します。build-exe・build-lib・build-obj・test・run、さらには cc/c++ の呼び出しまで、この1つの関数がすべてを担っています。
各コマンドの直前にある dev.check() の呼び出しに注目してください。これはコンパイル時のフィーチャーゲートであり、ブートストラップのセクションで詳しく説明します。たとえば 254行目 の dev.check(.build_exe_command) は、現在のビルド環境で build-exe コマンドが利用可能かどうかを確認します。
3つの中核構造体:Compilation、Zcu、InternPool
コンパイル全体のプロセスは、相互に関連する3つのデータ構造によって制御されています。コードベースのどの部分を読むにしても、この関係性を理解することが不可欠です。
flowchart TD
COMP["Compilation\n(top-level orchestrator)"]
ZCU["Zcu\n(Zig Compilation Unit)"]
IP["InternPool\n(types + values store)"]
COMP -->|"zcu: ?*Zcu"| ZCU
ZCU -->|"comp: *Compilation"| COMP
ZCU -->|"intern_pool"| IP
COMP -->|"config: Config"| CFG["Config"]
COMP -->|"bin_file: ?*link.File"| LNK["Linker Output"]
COMP -->|"work_queues"| WQ["Job Queues"]
Compilation(src/Compilation.zig)はトップレベルのオーケストレータです。設定・ジョブキュー・リンク出力・Cオブジェクトのコンパイルおよびスレッドインフラを管理します。すべての Compilation がZigコードを必要とするわけではありません。たとえば zig build-exe foo.o のようなケースもあるため、zcu: ?*Zcu はnullになり得ます。
Zcu(src/Zcu.zig)は Zig Compilation Unit です。Zigのソースコードをコンパイルする場合にのみ存在します。モジュールグラフ・ファイル追跡・エクスポート・InternPool を所有し、comp: *Compilation を通じて親の Compilation を参照します。
InternPool(src/InternPool.zig)はすべての型と値を一元管理するストアです。型も値もこの構造体への u32 インデックスとして表現されます。並行アクセスのためにシャーディングされており、スレッドごとの Local ストレージと共有の Shard 配列を持ちます。InternPoolはインクリメンタルコンパイルを支える依存関係追跡インフラも内包しています。
ヒント:
pt: Zcu.PerThreadを受け取る関数を見かけたら、それはInternPoolのシャード選択のためにスレッドIDを保持する、*Zcuのスレッドセーフなラッパーです。コンパイラ内で最も頻繁に登場するパラメータ型です。
dev.zig によるブートストラップとフィーチャーゲーティング
セルフホスト型コンパイラを初めてコンパイルするには、どうすればよいのでしょうか。Zigは3段階のブートストラップと巧妙なフィーチャーゲーティングの仕組みでこれを解決しています。
プロセスは bootstrap.c から始まります。これはチェーン全体を制御する純粋なCプログラムです。
sequenceDiagram
participant CC as System C Compiler
participant W2C as wasm2c
participant Z1 as zig1 (bootstrap)
participant Z2 as zig2 (core)
participant Z3 as zig3 (full)
CC->>W2C: Compile stage1/wasm2c.c
W2C->>Z1: Convert zig1.wasm → zig1.c
CC->>Z1: Compile zig1.c + wasi.c
Z1->>Z2: Build zig2.c (-ofmt=c, C backend only)
CC->>Z2: Compile zig2.c + compiler_rt.c
Z2->>Z3: Build full compiler (all backends)
ステージ1(zig1): stage1/ にある事前コンパイル済みの zig1.wasm を wasm2c でCに変換し、システムのCコンパイラでコンパイルします。この zig1 は bootstrap 環境で動作し、Cコードの出力(-ofmt=c)のみが可能です。
ステージ2(zig2): zig1 がコンパイラのソースを zig2.c にコンパイルします。bootstrap.cスクリプトが config.zig を生成し、そこに pub const dev = .core; が設定されることで core 環境が有効になります。システムのCコンパイラが zig2.c をネイティブバイナリにコンパイルします。
ステージ3(zig3): zig2 がすべてのバックエンドと機能を有効にした完全なコンパイラをビルドします。
この仕組みを支えるのが src/dev.zig です。bootstrap・core・full(および開発用のいくつかのバリアント)を持つ Env 列挙型を定義し、各バリアントが supports() 関数を通じてサポートする Feature を宣言します。
297行目 の check() 関数がこの仕組みの核心です。この関数は inline であり、機能がサポートされていない場合は noreturn を返します。つまり、コンパイラがコンパイル時にサブシステム全体をデッドコードとして除去するのです。bootstrap 環境がサポートする機能はわずか6つ(build-exe、build-obj、ast_gen、sema、c_backend、c_linker)なので、生成される zig1 バイナリは完全なコンパイラと比べて大幅に小さくなります。
現在の環境は 320行目 で次のように決定されます。
pub const env: Env = if (@hasDecl(build_options, "dev"))
@field(Env, @tagName(build_options.dev))
else if (@hasDecl(build_options, "only_c") and build_options.only_c)
.bootstrap
else ...
.full;
build.zig ではバージョン 0.16.0 が宣言され、src/dev.zig から DevEnv がインポートされ、環境がコンパイラのビルド全体に伝達されます。
次回の予告
全体像が掴めたところで、次はパイプラインの前半部分を詳しく見ていきます。第2回では、lib/std/zig/ に置かれたコンパイラフロントエンド — トークナイザ・パーサ・AstGenフェーズ — を掘り下げます。ソースバイトがどのようにZIRへと変換されるかを追いながら、Semaによる逐次処理を効率的にする平坦な命令配列データ構造についても解説します。