実践・C コンパイラ演習

発展課題

通常回(コマ1〜15)を終えた人向けの発展課題を置く。ここが発展教材の入口である。

いずれも選択制で、1トピック = 1ディレクトリになっている。 advanced/<回ID>_<内容>/ の形で、README.md・スケルトン・check.py(および多くは golden.py)が そのディレクトリの中に閉じている(P1 のみ例外: 自動採点なしの方針資料)。資料は サイトの発展課題 にある。

回 ID の頭文字がカテゴリを表す。

頭文字 カテゴリ 内容
F フロントエンド 字句解析・構文解析を自分で作り、スキャフォールドを置き換える
B 最適化入門 測定基盤なしで始められる軽量な最適化(着手はコマ6 から)
O 最適化 測定 → 解析 → 変換を積み上げる本格版(前提はコマ15)
R ランタイム libc なしで動かす、printf / malloc の自作
S 意味論(仕様の内側) 仕様が意味を決めている構文について、仕様と実際の振る舞いのずれを埋める
L 言語機能(仕様の外側) 仕様が対象外と決めた機能に、意味を自分で決めて足す
Q 品質 型検査でエラーを出す
P 移植・セルフホスト Python 版を C へ移植し、自分自身をコンパイルする(最難・長期)

全トピック一覧

回 ディレクトリ 内容 コマ 必須の前提 資料
F0 F0_cyk/ 字句解析と構文解析とは何か(CYK 法で数式を解く) 1 コマ1 サイト
F1 F1_lexer/ 字句解析: scaffold/lexer.py 読解 + Core lexer 自作 1 F0 サイト
F2 F2_parser_expr/ 再帰下降①: 式のパーサ(EBNF の階層 = 関数の階層) 1 F1 サイト
F3 F3_parser_stmt/ 再帰下降②: 文・制御構文(dangling else) 1 F2 サイト
F4 F4_parser_decl/ 再帰下降③: 宣言・型・struct・関数(ブラックボックスの完全置き換え) 1 F3 サイト
B1 B1_fold_peephole/ 最適化入門: 定数畳み込み + ピープホール 1 コマ6(着手)+ コマ15(golden.py) サイト
B2 B2_regalloc/ 式の途中結果を t レジスタへ(スタックマシンを卒業する) 1 コマ6(着手)+ コマ15(golden.py) サイト
B3 B3_tailcall/ 末尾呼び出し最適化(再帰をループに変える) 1 コマ6(着手)+ コマ15(golden.py) サイト
O1 O1_measure/ 最適化の測り方(静的/動的命令数、ベンチマーク集) 1 コマ15 サイト
O2 O2_cfg/ 基本ブロックとフローグラフ 1 コマ15, O1 サイト
O3 O3_isel/ 命令選択(複数命令を1命令に畳む) 2 コマ15, O1 サイト
O4 O4_regalloc/ 局所変数を callee-saved レジスタへ 2 コマ15, O1 サイト
O5 O5_liveness/ 生存変数解析 1 O1, O2, O4 サイト
O6 O6_copyprop/ コピー伝播と死コード除去 2 コマ15, O1, O2, O4, O5 サイト
O7 O7_layout/ ブロック整列とループ回転 1 コマ15, O1, O2 サイト
R1 R1_nolibc/ libc なしで動かす(システムコール直接発行) 1 コマ10(着手)+ コマ15(check.py の Step 2) サイト
R2 R2_printf/ 自前 printf(整数→10進文字列の変換) 1 R1 + コマ15(check.py) サイト
R3 R3_malloc/ 自前 malloc(バンプ割り当て → フリーリスト) 1 コマ12 + コマ15(check.py) サイト
S1 S1_shortcircuit/ 短絡評価(&& / \|\| の意味論を、仕様がCとわざと違えている点からCへ寄せる) 1 コマ12 + コマ15(golden.py) サイト
S2 S2_int32/ int の演算が32bitで折り返さない 1 コマ12 + コマ15(golden.py) サイト
S3 S3_struct/ 構造体の代入(ポインタ・フィールド持ち構造体、ポインタ経由の代入。値渡しは L5) 1 コマ12 + コマ15(golden.py) サイト
L1 L1_ptrdiff/ ポインタ同士の引き算が要素数にならない 1 コマ12 + コマ15(golden.py) サイト
L2 L2_compound_assign/ 複合代入(x += 3、p -= 1。左辺は1回だけ評価) 1 コマ13 + コマ15(golden.py) サイト
L3 L3_variadic/ 可変長引数の「定義」(int sum(int n, ...)) 1 コマ10 + コマ15(golden.py) サイト
L4 L4_sizeof_expr/ sizeof 単項式(sizeof x。オペランドは評価しない) 1 コマ12 + コマ15(golden.py) サイト
L5 L5_struct_byval/ 構造体の値渡し・値返し(呼び出し規約を自分で決める。L 系列で最も重い) 1 コマ12 + S3 + コマ15(golden.py) サイト
Q1 Q1_typecheck/ 型検査パス(実行前に誤りをまとめて報告する) 1 コマ12 サイト
P1 P1_selfhost/ C 移植・セルフホスト(方針のみ。複数回に渡る自主課題、自動採点なし) - コマ15 サイト

どこから始めるか

やりたいこと おすすめ
コンパイラの入口(字句・構文解析)を理解したい F0(コンパイラ本編と独立。前提はコマ1 まで)
コマ15 を待たずに最適化を試したい B1(測定基盤なしで着手できる。golden.py まで通すにはコマ15 が要る)
生成したコードを本気で速くしたい O1(まず測る物差しを作る)
仕様がCとわざと違えている点をCへ寄せたい S1(&& が短絡していないことの確認から)
OS もライブラリも無い世界を見たい R1
仕様の外側の機能を自分で足してみたい L4(L 系列で最も軽い。字句の変更なし、コード生成は1命令)
言語仕様に書かれない取り決め(呼び出し規約)を自分で決めたい L5(L 系列で最も重い。軽い入口ではないので、S3 を終えてから)

共通のルール

fixed17 とは

workbook/final/tests/ に置いてある17本のテストのこと。 コマ1〜15 で作った機能を通しで確認する参考テストで、標準トラック完成の目安として コマ15(通常回15)で導入する。 内訳は docs/testing.md と docs/code_example.md の一覧にある。

発展課題では、これを安全網として使う。 どのトピックも既存の機能を壊さずに何かを足す/直すので、 「fixed17 が全通したままか」が「意味を壊していないか」の判定になる。

# workbook/ から。--compiler にそのトピックのラッパーを渡す
python3 scaffold/test_runner.py --compiler advanced/L1_ptrdiff/langcc.py --tests final/tests

check.py と golden.py

各トピックのテストは、目的の違う2本に分かれている。

ファイル 役割 対象
check.py そのトピックで書いたコードの合否判定。Step ごとに関数を直接呼び、期待値と比べる。未実装の Step は SKIP になり、全 Step が PASS になることがそのトピックの到達点 自分が書いたファイルだけ
golden.py 既存資産に対する回帰と効果の測定。すでに手元にある資産(fixed17、講義の全テスト入力、scaffold の Lexer / Parser、O1_measure/bench/)に対して通しで走らせ、壊していないことを確かめる。O 系列ではあわせて命令数の削減も測る 教材全体

先に check.py を全 PASS にしてから golden.py を回す、という順で使う。

golden.py が無いトピックもある。F0(コンパイラ本編と独立)、 R1・R2・R3(mycc.py の出力を変えないので回帰の対象が無い)、 P1(自動採点なし)の5本がそれで、check.py だけで完結する。

共有ツール

ファイル 用途 使うトピック
optcc.py 最適化パスを合成して走らせるコンパイララッパー O 系列
count_insns.py 生成アセンブリの命令数を数える B 系列
basecc.py 土台の final/mycc.py が動くか確かめる健全性チェック B, O1, S, L 系列

ラッパーの名前

mycc.py を書き換えないトピックでは、ラッパーが mycc.py を読み込んで 必要な差し替えを施す。名前はファミリごとに決まっている。

ファミリ ラッパー 置き場所
B foldcc.py(B1) / regcc.py(B2) / tccc.py(B3) 各トピックのディレクトリ
O optcc.py advanced/(O 系列で共有。--passes でパスを選ぶ)
S semcc.py 各トピックのディレクトリ
L langcc.py 各トピックのディレクトリ
Q checkcc.py Q1_typecheck/

F・R・P はラッパーを使わない(F は scaffold/ の部品を自作で置き換え、 R は生成物のリンク方法を変え、P は移植そのものが課題であるため)。

環境変数の名前

土台のコンパイラと差し込むファイルの置き場所は、環境変数で差し替えられる。 名前は例外なく次の規則に従う。

接頭辞 = ラッパーのファイル名から .py を取って大文字にしたもの。 langcc.py なら LANGCC_、optcc.py なら OPTCC_。

接尾辞 意味 持っているラッパー
_COMPILER 土台にする mycc.py 全部
_PASSES 差し込むファイル(fold.py など)のあるディレクトリ B1, B2, B3, S, L, Q
_PASSES 適用するパス名の並び(optcc.py だけ意味が違う) O
_REGALLOC 1 なら --regalloc と同じ O
_ANSWERS 実装を別ディレクトリから読む(教員用) O

ラッパーを持たない R 系列だけは例外で、回 ID を接頭辞にする (R1_COMPILER / R2_COMPILER / R3_COMPILER)。

注意: 2026-08-01 に、この規則へそろえる形でいくつかの名前を改名した。 環境変数名(optcc.py・foldcc.py・L3 のラッパー)とファイル名(L3 の varcc.py)が対象である。 あわせて、ファミリを移した2本(S3_ptrdiff/ → L1_ptrdiff/、L1_struct/ → S3_struct/)の ラッパーも semcc.py と langcc.py が入れ替わり、環境変数の接頭辞も入れ替わった。 旧名はエラーにならず黙って無視されるので、以前の名前を書いた手元のスクリプトやメモが あれば読み替えること。

workbook/advanced/ から実行する。

python3 ../final/mycc.py foo.c | python3 count_insns.py

F: フロントエンド

スキャフォールドでブラックボックスとして使ってきた Lexer / Parser を、自分の手で理解し、作り、置き換える。

B: 最適化入門

生成したコードを「速くする」トピックのうち、測定基盤を必要としないもの。 着手はコマ6(関数呼び出し)までで足りるので、コマ15 を待たずに始められる。 ただし完了条件のうち golden.py は fixed17(=コマ15 の完成した final/mycc.py)を土台にするので、 やり切るにはコマ15 が要る。コマ15 前は check.py を全 PASS にするところまで進める。

各回は独立している(好きな順に取り組んでよい)。 効果は count_insns.py による静的命令数で確認する。 「実行された命令が何回減ったか」は静的命令数では分からない。 それを測る物差しは O1 が作る。

O 系列との関係

B は O の前哨である。 題材が一部重なるので、対応を先に示しておく。

B の題材 O での対応 関係
B1 の remove_jump_to_next O7 の Step 3 同名・同一の最適化。片方の実装を持ち込んでよい
B1 の fuse_push_const_pop O3(命令選択) 動機は同じ(push/pop の無駄)だが手口が違う
B1 の定数畳み込み 対応なし O 系列は AST を触らない
B2(式の途中結果 → t0〜t6) O4(局所変数 → s1〜s11) 別の無駄を消すので効果はほとんど重ならない
B3(末尾呼び出し) 対応なし B3 だけの題材

どちらを先にやるか。 コマ15 がまだなら B から。 終わっているなら、O1(測る物差し)→ O3・O4 と進んだほうが、 「減った」の意味がはっきりする。

O: 最適化

生成したコードを速くする技法を、測定 → 解析 → 変換の順に積み上げる。 全部で10コマだが、各回は独立して取り組める。O1 と O2 を先に済ませておけば、 O3・O4・O7 はどれから始めてもよい。

依存関係

O1(測定)             ─→ O2(フローグラフ), O3(命令選択), O4(レジスタ割り当て)
O2(フローグラフ)     ─→ O5(生存解析), O7(ブロック整列)
O4(レジスタ割り当て) ─→ O5(生存解析)
O5(生存解析)         ─→ O6(コピー伝播 / 死コード除去)

各行は「左が、右の直接の前提である」ことを表す(矢印の向きは「前提 → そのトピック」)。 O3・O6・O7 はここから先が無いので、左側には現れない。

図にあるのは直接の前提だけで、上の一覧表の「必須の前提」は間接的なものも含めて書いてある。 たとえば O6 の O1, O2, O4 は O5 を経由した間接の前提であり、O7 の O1 は O2 を経由する。

2つの差し込み方

種別 何をするか 回
パス 出来上がったアセンブリを行単位で書き換える O3, O6, O7
コード生成パッチ アセンブリになる前のコード生成器を差し替える O4

O4 だけコード生成器を触るのは、「どの変数の & が取られているか」が AST を見ないと分からないためである。

1ファイルだけ試すときは workbook/advanced/ から実行する。

python3 optcc.py --passes isel file.c
python3 optcc.py --regalloc --passes isel,copyprop,dce,layout file.c
python3 optcc.py --passes '' file.c        # 最適化なし(基準値)

final/tests(fixed17)全体に通したいときは workbook/ から scaffold/test_runner.py --compiler advanced/optcc.py --tests final/tests を使う (../docs/testing.md 参照)。同じ optcc.py だが、 cwd が違うのでパスの書き方が変わる。

--passes の順番を変えると効果が変わる(O6 で扱う phase ordering)。

命令数の基準は O1 に1つだけ置いてある

O 系列の資料はどれも「前 → 後」の表を出すが、その基準(素 / +isel / +regalloc / +copyprop,dce / +layout の5構成)は O1 の資料の「全構成の基準表」に一括で載せてある。 各回の表はそこからの差分である。数字が食い違って見えたら、まず基準表と突き合わせる。

全部かけるとどうなるか

基準表の両端、つまり最適化なしと --regalloc --passes isel,copyprop,dce,layout の比較。

ベンチマーク   静的:前   後    削減    動的:前      後      削減
bubble           340   233   31.5%    24023   15952   33.6%
fib_rec           77    70    9.1%    73993   68073    8.0%
loop_sum          70    43   38.6%     7840    4030   48.6%
matmul           453   308   32.0%    27958   18563   33.6%
strops           243   173   28.8%    77108   50135   35.0%
合計            1183   827   30.1%   210922  156753   25.7%

loop_sum は実行される命令が半分以下になる。 fib_rec の伸びが小さいのは、ループが無く、 局所変数をレジスタに置いても得をしない形だからである(O4 コマ2 で扱う)。

R: ランタイム

gcc とライブラリに任せていた部分を、自分の手で開ける。

R2 は R1 の syscall.s を土台にする。R3 は独立している(libc を使ってよい)。

S と L の判定基準 — 仕様の内側か、外側か

S と L は難易度でも題材でも分かれていない。分かれ目は その回で作る振る舞いの「正解」がどこに書いてあるかである。

二段の問い ファミリ その回でやること
仕様がその構文に何かを定めており、いまの実装がそれに違反している(仕様の内側) S 仕様が定めた姿と実際の振る舞いのずれを埋める。正解は仕様の該当行に書いてある
仕様がその構文に何も定めていない(仕様の外側) L 意味を自分で決めて足す。正解はこれから決めるもので、資料の「追加する仕様」節が定義する

判定は二段の問いで行う。①仕様はその構文について何か言っているか。②言っているとして、 いまの実装はそれに違反しているか。「禁止」も仕様が言っていることに数える (S3 は仕様が「不可」と定めた形を実装が受理しており、違反である)。 逆に L1 でポインタ差がバイト差になるのは仕様違反ではない——仕様がポインタ同士の減算に 何も決めていないだけである。

S のずれは向きが2通りある。実装を仕様へ合わせる回(S2)と、 仕様の側を ISO C へ寄せる回(S1・S3。S1 は仕様が例外 E3 として差分を自ら明記し、 S3 は仕様の禁止行がいずれ改訂される想定である)である。 どちらも仕様の該当行から出発する点で共通で、L のように意味を新しく決めることはしない。

この判定結果は、各トピックの位置づけ表の 仕様との関係 欄に書いてある。

2本の ID を振り直した(食い違いは解消済み)

以前は、判定基準と ID が食い違う回が2本あった。2026-08-01 に実際に移した。

旧ID・旧ディレクトリ 新ID・新ディレクトリ 判定の理由
S3_ptrdiff/(ポインタ差) L1_ptrdiff/ 仕様はポインタ同士の減算を「ない」と明示的に除外している。「要素いくつ分か」を返すという意味は、この回で自分で決めて足すものである
L1_struct/(構造体の代入) S3_struct/ 仕様は「struct 値の代入・実引数・戻り値は不可」と定めているのに、実装は q = p; を黙って受理して誤った答えを返す。仕様と実装のずれが題材である(解消の向きは仕様の側の改訂である)

2本は番号を入れ替えた形になっている。S3 も L1 も番号としては残っているが、 指す中身が入れ替わっているので、以前のメモやブックマークをそのまま読むと取り違える。

これで S1〜S3・L1〜L3 の6本すべてが、判定基準どおりの位置にある。

S: 意味論

仕様が意味を決めている構文について、いまの実装とのずれを自分で見つけて直す。 どれも「テストが通ってしまうので気づきにくい」性質を持つ。

各回は独立している。いずれも mycc.py を書き換えず、 ラッパーで生成を差し替える方式で、fixed17 が通り続けることが安全網になる。

L: 言語機能

Core プロファイルで「扱わない」と決めた機能を、意味を自分で決めてあとから足す。 どれも mycc.py と scaffold/ を書き換えず、ラッパーで差し込む。

各回は独立している。fixed17 が通り続けることが、 既存の機能を壊していないことの保証になる。

Q: 品質

「正しく動く」を作り込む。コンパイラ本体には手を入れず、独立したパスとして追加する。 「正しいプログラムを1つも拒まない」ことを、教材のテスト入力全体で確認する。


P: 移植・セルフホスト

Python 版コンパイラを C へ移植し、最終的に自分自身をコンパイルする(セルフホスト) 長期プロジェクト。他のトピックと違い1回では終わらず、自動採点もない。 大まかな進め方だけを P1_selfhost/README.md に示してある。

このセクションのページ

この資料の原稿を見る