実践・C コンパイラ演習

テストとデバッグ

scaffold/test_runner.py の使い方とテストケースの形式、 そして落ちたときに原因を絞り込む手順。


基本

workbook/ から実行する。

# 各回のテスト(その回の mycc.py と tests/ を使う)
python3 scaffold/test_runner.py sessions/02_arithmetic_codegen
python3 scaffold/test_runner.py sessions/05_loops

# 最終統合版(引数を省略すると final/mycc.py + final/tests/)
python3 scaffold/test_runner.py

出力例:

[PASS] sessions/05_loops/tests/for_count.c
[FAIL: exit code expected=55 got=0] sessions/05_loops/tests/while_sum.c

=============================
  PASS: 7  FAIL: 1  SKIP: 0
=============================

SKIP になるのは、コマ14 の math_util.c / stat_lib.c のように 単体では実行せず他のテストと一緒にコンパイルする補助ソースだけである。 補助ソースかどうかは、同じディレクトリのどれかの .files から参照されているかで判定する。

それ以外の .c に .ans が無いときは SKIP ではなく FAIL になる。 期待値の置き忘れを「実行されなかったので成功」と読み違えないための扱いである。 同じ理由で、テストがあるのに PASS が 0 件のときも終了コードは 0 にならない。

一方、発展課題の check.py が出す SKIP は未実装を意味する。 そちらは SKIP が残っている間は完了条件を満たさず、終了コードも 0 にならない。

コンパイラとテストを個別に指定する

python3 scaffold/test_runner.py --compiler final/mycc.py --tests final/tests

発展課題ではラッパー経由で走らせることが多い。

# 最適化パスを差し込んだコンパイラで fixed17 を通す
python3 scaffold/test_runner.py --compiler advanced/optcc.py --tests final/tests

テストケースの形式

入力 C ファイルと期待結果ファイルの組で構成する。

foo.c       # 入力ソース
foo.ans     # 期待する exit code
foo.stdout  # 期待する標準出力(必要な場合のみ)
foo.stderr  # 期待する標準エラー出力(必要な場合のみ)
foo.files   # 一緒にコンパイルする追加ソース(複数ファイル構成の場合のみ)

例:

sessions/05_loops/tests/while_sum.c
sessions/05_loops/tests/while_sum.ans
sessions/10_strings_data_section/tests/printf_hello.stdout
final/tests/f01_arith.c
final/tests/f01_arith.ans

.ans は終了コードで結果を確認するため、値は 0〜255 に収める。 テストケース側で未定義動作(UB)に踏み込まないようにする。

.stdout / .stderr は、置いたときだけ突き合わせる。 fdopen(2, "w") で書いた出力のように、標準出力に出ないものを確認したいときは .stderr を置く (例: sessions/11_expr_walk_libc/tests/file_stream.stderr)。

.files(複数ファイルのテスト)

foo.files があると、foo.c に加えてそこに列挙したファイルもコンパイル対象に含める。 1行に1ファイル名で、パスは foo.c と同じディレクトリからの相対で書く。

sessions/14_preprocess_multifile/tests/multifile_global.files:
stat_lib.c

stat_lib.c 自身には対応する .ans が無い。単体では実行せず他のテストと 一緒にコンパイルされる補助ソースだからである。テストランナーはこの .files の 記述から stat_lib.c を補助ソースと判定し、直接拾ったときは SKIP にする (前述のとおり正常な挙動)。逆に言えば、どの .files からも参照されていない .c に .ans を置き忘れると FAIL になる。

final/tests(fixed17)

主要な機能を組み合わせて確認する17本の総合テスト。 論理演算・char・複数ファイル・ストリーム操作は含まれないため、 標準トラックの実装完成には fixed17 と、最終コンパイラでのコマ2〜14の全テスト通過が必要である。

発展課題でも、変更を入れたあと毎回 fixed17 を通し、変更が関わる機能の過去回テストも確認する。

最終コンパイラで過去回の全テストを確認する

workbook/ から、次を実行する。

for tests in sessions/0[2-9]_*/tests sessions/1[0-4]_*/tests; do
  python3 scaffold/test_runner.py --compiler final/mycc.py --tests "$tests" || exit 1
done

すべての実行で FAIL: 0 になればよい。コマ14の補助ソースの SKIP は正常である。 ループは失敗した回で終了するので、修正後にもう一度全体を実行する。 sessions/NN_xxx だけを渡すとその回のコンパイラが選ばれるため、 統合後の回帰確認では --compiler final/mycc.py を省略しない。 コマ1はインタープリター用の別のチェッカーを使うので、このループに含めない。

総合テストと過去回のテストは、正しい入力の実行結果を確認する。 不正な入力をすべて拒否できることの証明にはならない。 診断の保証範囲は language_spec.md の「標準トラックの診断範囲」を参照。

手で1本だけ確認する

テストランナーを使わず、生成アセンブリを目で見たいときの手順。

python3 sessions/02_arithmetic_codegen/mycc.py test.c > out.s
riscv64-linux-gnu-gcc -x assembler -static out.s -o out
qemu-riscv64 ./out; echo $?

パイプでつなぐこともできる。

python3 final/mycc.py test.c \
  | riscv64-linux-gnu-gcc -x assembler -static - -o out
qemu-riscv64 ./out; echo $?

落ちたときの調べ方は、後半の「症状から当たりをつける」を参照。

AST を確認する

コンパイル結果ではなく、Parser が返した AST を見たいとき。

python3 scaffold/parse_viewer.py sessions/01_interpreter/tests/add_mul.c
python3 scaffold/parse_viewer.py sessions/02_arithmetic_codegen/tests/add.c --tokens
python3 scaffold/parse_viewer.py sessions/02_arithmetic_codegen/tests/add.c --format tree
python3 scaffold/parse_viewer.py sessions/02_arithmetic_codegen/tests/add.c --format dot > ast.dot

発展課題のテスト

各トピックのディレクトリから実行する。

python3 check.py     # ステップごとの確認
python3 golden.py    # 存在し、README で指定されている場合

トピックによって専用のコマンドがあるので、対象の README.md を確認すること。


デバッグの基本手順

動かないときは、複雑な入力をそのまま追わず、小さい入力に戻す。

  1. 失敗したテストを単体で確認する
  2. 生成アセンブリをファイルに保存して読む
  3. qemu の終了コードを確認する
  4. 関数呼び出しで壊れる場合は ra、s0、スタックアラインメントを見る
  5. ポインタで壊れる場合は codegen() と codegen_lval() の区別を見る
python3 sessions/02_arithmetic_codegen/mycc.py sessions/02_arithmetic_codegen/tests/add.c > out.s
riscv64-linux-gnu-gcc -x assembler -static out.s -o out
qemu-riscv64 ./out; echo $?

症状から当たりをつける

症状 よくある原因 見るところ
FAIL: compile — NotImplementedError: … その回の TODO が残っている メッセージが指す関数を実装する。全文が要るなら python3 sessions/NN_xxx/mycc.py <入力.c> を直接実行する
FAIL: assemble — … アセンブルまたはリンクの失敗 生成結果を保存し(python3 … > out.s)、riscv64-linux-gnu-gcc -x assembler -static out.s -o out を実行してエラー全文を見る
undefined reference to .LC…(未定義の文字列ラベル) 文字列収集の走査漏れ collect_strings_expr の分岐とハンドラを見る。三項演算子や前置 ++/-- の内側も走査し、参照したラベルを .data に出しているか
Illegal instruction / Bus error スタックアラインメント違反(16バイト境界) align_to(n, 16) を通しているか。call 直前の sp も対象で、そのとき積んでいる一時値が奇数個ならずれている(rv64_reference.md)
終了コードが 0 になる return の値が a0 に残っていない 最後の codegen() のあとで a0 を上書きしていないか
期待値と 256 ずれる 終了コードは 0〜255 テスト側の期待値を見直す
関数から戻ると壊れる ra / s0 の退避漏れ、フレームサイズの計算違い プロローグとエピローグが対称か
再帰の途中で壊れる 引数の退避漏れ(a0–a7 は caller-saved) 再帰呼び出しの前後で引数を保存しているか
ポインタ経由の代入が効かない codegen() と codegen_lval() の混同 *p = v; の左辺は codegen_lval()
配列の添字がずれる ポインタ演算で要素サイズを掛けていない size_of_ty_str() / elem_ty_str() を通しているか
未定義変数で黙って動く シンボルテーブルの探索順序 _locals → _globals の順に引いているか
AttributeError: … has no attribute '_continue_stack' / '_align_to' / 'align_to' 2026-08-01 より前に取得したファイルと後のファイルが混ざっている 回ごとのスケルトンは前の回を継承するので、一部だけ差し替えると壊れる。取得時期の違うファイルを混ぜず、まとめて取り直す(移行手順は担当教員から受け取る)
最適化をかけたのに命令数が変わらない(発展課題) 環境変数が旧名のままで黙って無視されている 発展課題の「環境変数の名前」の規則と突き合わせる
AST の構造が分からない(優先順位・結合の向き) 目視では判断しづらい 学習ツールの構造表示 にソースを貼って構文木と S 式を確認する
生成されたアセンブリの動きを目で追いたい 静的に読むだけでは1命令ごとのレジスタ・スタックの変化が分からない 学習ツールの実行表示 にアセンブリを貼って1命令ずつ実行する

アセンブリを読むときの順序

  1. .globl とラベルが期待どおり出ているか
  2. プロローグでフレームサイズが正しいか
  3. 問題の式だけを追う(値が a0 に残るか、アドレスが a0 に残るか)
  4. エピローグがプロローグと対称か

gdb でステップ実行する

終了コードだけでは原因が分からないときに使う。

# qemu をデバッグサーバとして起動
qemu-riscv64 -g 1234 ./out

# 別のターミナルで gdb を繋ぐ
gdb-multiarch ./out
(gdb) target remote :1234
(gdb) break main
(gdb) stepi
(gdb) info registers a0 sp s0 ra

Illegal instruction の発生箇所を特定するときは、止まった時点の sp が 16 の倍数になっているかを確認する。

この資料の原稿を見る