実践・C コンパイラ演習

C 移植・セルフホスト — Python 版を C で書き直す

このトピックのゴール

Python 版コンパイラを C 言語で丸ごと書き直し、最終的に自分自身をコンパイルできる状態 (セルフホスト)を目指す。 これまでの発展課題は1トピック=1回で完結したが、この回だけは性質が違う。 「これが通れば完了」という単一の判定は無く、 自分のペースで進められるところまで進める自主課題として扱う。

この回の位置づけ

項目 内容
必須の前提 コマ15(完成した final/mycc.py)
推奨の前提 F1〜F4(フロントエンドの移植がかなり楽になる)
改変しない mycc.py、scaffold/(読解の対象であって、書き換えない)
編集する 自分で用意する src/(構成は自由。雛形は配らない)
完了条件 自動採点は無い。参考達成条件(必達目標ではない)は、mycc_stage1 の生成成功、final/tests(fixed17)の全通、前処理(#include / #define)の自前実装が動くこと
コマ数 -
備考 移植・セルフホスト発展シリーズ。他のトピックと違って1回では終わらず、check.py / golden.py も無い。資料が示すのは大まかな方針だけである

難易度は発展課題の中で最も高く、最も範囲が広い。 この資料は手順の詳細やコード例ではなく、大まかな方針だけを示す。 実装そのものは、コマ1〜15 で作った Python 版と、その中で読んできた scaffold/ が最大のヒントになる。

基本方針: アルゴリズムを再発明しない

移植は「もう一度コンパイラを設計する」ことではなく、 すでに動いている Python 版のデータ構造を C に写す作業である。

Python C
class / dataclass struct
dict 連結リスト(線形探索)
list.append 末尾連結
None NULL
isinstance(node, AddNode) node->kind == ND_ADD

コンパイラの論理(どの順で何をたどるか)は Python 版で完成させたものをそのまま流用する。 ここで新しいアルゴリズムを考え出そうとすると、範囲が発散して終わらなくなる。

大まかな進め方

  1. データ構造の移植: struct Node・struct Type・シンボルテーブル・emit() をまず C に写す
  2. コード生成の移植: 算術式・変数・制御構文 → 関数・ポインタ → 配列・構造体・グローバル変数、 の順に Python 版のコード生成ロジックを C に写していく
  3. フロントエンド: scaffold/lexer.py / parser.py を読解して移植する。 発展課題 F 系列(F1〜F4)に先に取り組んでおくと、このフロントエンド移植がかなり楽になる
  4. セルフホストへの挑戦: #include / #define の前処理を自分で実装し、 Stage 0(gcc で自分の C 版をビルド)→ Stage 1(Stage 0 でできた自分のコンパイラで、 もう一度自分自身をビルド)という段階を踏んで確認する

ファイル構成の注意: このコンパイラの駆動は「ソース1本を受け取る」形なので、 複数の .c に分けた場合もルートとなる1つの .c が他の .c を #include する 単一の翻訳単位として構成する。共有する struct 定義・定数・プロトタイプは 1つのヘッダに集約し、ルートから1回だけ取り込む。これは C の複数ファイル構成一般の注意点でもある (同じ struct タグを複数の翻訳単位でそれぞれ定義する形は、本仕様が扱いを定めていない)。

最初の目標は、算術式・変数・制御構文までを C 版で出力できるようにすることに置くとよい。 一気に全部を移植しようとせず、Python 版のコマ順(コマ2〜15 の積み上げ)をそのまま 移植の順番として使える。

C 実装の規約

項目 規約
言語 Core プロファイル自身で書く(セルフホスト可能な範囲に収める)
命名規則 型 snake_case / 関数 snake_case / 定数 UPPER_SNAKE
メモリ管理 malloc のみ使用。free は不要(コンパイラはプロセス終了時に一括解放)
エラー処理 fprintf(stderr, ...) + exit(1) で即終了
コメント // 一行コメントのみ

命名規約について: 本仕様は予約語を12語だけに絞っているため(language_spec.md の例外 E2)、 C の他のキーワード(typedef や switch など)を識別子として使うことができる。 しかし Stage 0 は gcc でビルドするので、その名前は通常の C コンパイラでもビルドできる 必要がある。C のキーワードを自分のソースの識別子に使わないこと。

RV64 の呼び出し規約とアラインメント規則は rv64_reference.md を参照。

セルフホストの検証

雛形は用意しない。以下の段階を自分のビルドスクリプトで踏めばよい。

このコンパイラは -o のような出力先オプションを持たず、 ソース1本を受け取ってアセンブリを標準出力へ出す(test_runner.py が期待する駆動と同じ)。 Stage 1 は RV64 の実行可能ファイルなので、ホスト環境では直接実行できず qemu-riscv64 を経由する。

# Stage 0: gcc で自分の C 版をビルドする(ルートの .c を1本渡す)
gcc -o mycc_stage0 src/mycc.c

# Stage 1: Stage 0 に自分自身をコンパイルさせ、アセンブルして RV64 実行可能ファイルにする
./mycc_stage0 src/mycc.c > stage1.s
riscv64-linux-gnu-gcc -x assembler -static stage1.s -o mycc_stage1

# 不動点の確認: Stage 1 にも同じソースをコンパイルさせ、Stage 0 の出力と突き合わせる
qemu-riscv64 ./mycc_stage1 src/mycc.c > stage2.s
cmp stage1.s stage2.s && echo "fixpoint OK"

test_runner.py に Stage 1 を渡すときは、qemu-riscv64 ./mycc_stage1 "$@" を実行するだけの 短いシェルスクリプトを用意し、それを --compiler に指定する (test_runner.py はコンパイラを直接実行するため、RV64 バイナリをそのままは渡せない)。

python3 scaffold/test_runner.py --compiler ./run_stage1.sh --tests final/tests

なぜ段階を踏むか: セルフホストは「動いた気がする」状態になりやすい。 どの段階の成果物でテストを通したのかを自分で明示的に確認する必要がある。

本質的な判定は不動点比較である: 「Stage 1 が生成できた」「テストが通った」だけでは、 自分自身を正しく翻訳できている証拠にならない。ブートストラップするコンパイラの検証では、 Stage 0 と Stage 1 がそれぞれ同じソース(コンパイラ自身)に対して生成するアセンブリが バイト単位で完全に一致すること(不動点)を確認するのが本筋である。 これが成り立てば、Stage 1 が出力するコンパイラは Stage 1 自身と同じものになり、 以降いくら段を重ねても変化しない。なお不動点の確認は出力が決定的であること (実行のたびに同じアセンブリが出ること)を前提にするので、 ラベル名やメモリアドレスに依存した出力を混ぜないよう気をつける。

参考達成条件(必達目標ではない): mycc_stage1 の生成成功 + final/tests(fixed17)全通 + 前処理(#include / #define)の自前実装が動くこと。 さらに不動点比較(Stage 0 と Stage 1 の出力アセンブリのバイト一致)まで確認できれば、 セルフホストが正しく達成できたことの証拠になる。

この資料の原稿を見る