実践・C コンパイラ演習

int は32ビット — 桁あふれを正しく折り返す

今日のゴール

int の演算を 32bit 命令(addw など)で行い、 桁あふれの振る舞いを「ホスト gcc と同じ 32bit 折り返し」に揃える。

この回の位置づけ

項目 内容
必須の前提 コマ12(malloc)まで。折り返しの考え方そのものは型サイズまでで足りるが、安全網の tests/pointer_ok.c が struct と malloc を使う。完了条件のうち golden.py は完成した final/mycc.py を土台に fixed17 を回すので、やり切るにはコマ15 も要る
推奨の前提 —
改変しない mycc.py、scaffold/、ラッパー semcc.py(配布済み・完成品)
編集する narrow.py
完了条件 check.py の全 Step が PASS になり、golden.py が折り返しのテストの通過と fixed17 全通を報告する
コマ数 1
仕様との関係 仕様の内側。language_spec.md は int を「4 バイト符号付き整数(32 ビット 2 の補数)」と定めているのに、実装は演算を 64bit 命令で行っている。実装を仕様へ合わせる
備考 意味論発展シリーズの第2回。S1・S3 とは独立している。3本の中では最も気づきにくいずれである

いまのコンパイラは何をしているか

コマ8 で「int は4バイト」と決め、lw / sw で読み書きするようにした。 しかし計算のほうは、ずっと64bit命令のままである。

  add a0, a1, a0      # 64bit で足している

RV64 には、同じ演算の32bit版がある。

64bit 版 32bit 版 32bit 版の意味
add addw 下位32bitで足し、結果を符号拡張する
sub / mul / div / rem subw / mulw / divw / remw 同上

w は word(RV64 では32bit)の意味である。 int の計算には、本来こちらを使わなければならない。

何が違うのか

int x;
x = 2147483647;   // int の最大値
x = x + 1;

この言語の仕様(language_spec.md)は、符号付き整数のオーバーフローを 未定義動作(UB)と明記している。規格上は「何が起きてもよい」ということだが、 それでは実装の方針が立たない。そこでこの回では、 「ホスト gcc(および RISC-V の実際の挙動)と同じく、32bitで折り返す」 という具体的な振る舞いを選んで実装する(UB の中で最もよく採用される選択であり、 -fwrapv 相当の挙動と考えてよい)。この方針のもとでは、x は 最小値 -2147483647 - 1 に折り返す。

いまの実装は64bitで計算するので、2147483648 という int に存在しないはずの値がそのまま残る。

int y;
y = 100000 * 100000;   // 折り返す方針では: 1410065408
                        // いまの実装: 10000000000
重要

このずれも、S1 と同じくテストが通ってしまう種類のものである。 講義のテストは小さい値しか使わないので、64bitでも32bitでも同じ答えになる。 大きな値を扱った瞬間に、初めて食い違う。

「小さい入力でテストする」のは正しい習慣だが、 境界値を試さないと見つからないバグがあることも同時に示している。

どう直すか — 命令を書き換える

コード生成のあちこちに散らばった add / sub / mul … を すべて書き直すのは大変である。そこで、出力される命令行を通り道で書き換える。

codegen が emit("  add a0, a1, a0") を呼ぶ
    ↓
narrow(line, ty) が「型が int なら addw に」書き換える
    ↓
実際に出力される

narrow は純粋な文字列変換なので、テストが書きやすい。 ラッパー semcc.py が emit を包んでこれを呼ぶ。

難しいのは「どこを書き換えないか」

素朴に「add を全部 addw にする」と、コンパイラは壊れる。

命令 用途 32bit にすると
add a0, a1, a0 x + y(int の足し算) 正しい
add a0, a1, a0 p + i(ポインタ演算) アドレスが壊れる
addi a0, s0, -24 変数のアドレス計算 アドレスが壊れる
addi sp, sp, -16 スタックの確保 sp が壊れる

アドレスは64bitである。同じ add でも、 「何を計算しているか」で扱いが変わる。

そこで2つの仕掛けを使う。

  1. addi は対象外にする — アドレス計算専用と割り切る (このコンパイラは int の加算に addi を使わない)
  2. 型コンテキストを見る — いま生成している式の型が int / char のときだけ書き換える

2つ目はラッパーが用意する。codegen を包んで、 「いま生成中のノードの型」をスタックで持ち回り、emit のときに参照する。

補足

入れ子のアドレス計算に注意。 int *p; に対する s = s + p[i] を考える。外側の + は int だが、 その中の p[i](*(p + i) の略記)はアドレス計算(base + i*4)である。 外側の型を内側にも適用してしまうと、アドレスが32bitに切り詰められて落ちる。

ラッパーは、算術ノード以外では型を None にして積み直すことでこれを防いでいる。 実際に作る途中でこの罠を踏み、f11_ptr_arith.c が SIGSEGV で落ちた ——「型は式ごとに違う」ことを、身をもって確かめられる箇所である。

実装

Step 実装対象 内容
1 narrow(line, ty) 型が int 系のときだけ、対象命令に w を付ける

書くのは20行ほどの文字列処理である。難しいのはコードではなく、 何を対象にして何を対象にしないかの判断である。

python3 semcc.py tests/overflow.c | grep -E "addw|subw|mulw"
python3 semcc.py tests/pointer_ok.c | grep -c addiw   # 0 でなければ危険

テスト

python3 check.py     # narrow の入出力を直接確認(未実装は SKIP)
python3 golden.py    # 折り返しのテスト + fixed17
テスト 確認内容
overflow.c 最大値 + 1 が最小値に折り返す
mul_wrap.c 100000 * 100000 が 32bit で折り返す
wrap_sub.c 引き算で INT_MIN をまたいで折り返す
pointer_ok.c malloc 領域・ポインタ・構造体が壊れていない(narrow しすぎの検出)

pointer_ok.c が安全網である。fixed17 と合わせて、 「直しすぎていないこと」を保証する。

発展課題

  1. unsigned を考える: unsigned int があれば divu / remu が要る。 Core プロファイルに unsigned がないのはなぜか、あると何が増えるか
  2. long との違い: C の long は64bitである。 仕様に long を足すとしたら、narrow はどう変わるか
  3. char の扱い: char は1バイトだが、計算は int に格上げされる(整数拡張)。 いまの実装は char も32bitで計算しているが、これで正しいか
  4. 比較は大丈夫か: slt は64bit比較だが、32bitで符号拡張された値同士なら 正しく比較できる。なぜそう言えるか説明する
  5. B1 と組み合わせる: 定数畳み込み(B1)の to_i64 は64bitで丸めている。 int の式なら32bitで丸めるべきではないか。両者の整合を取る
補足

コラム: なぜ RV64 に32bit命令があるのか。 RISC-V は64bit CPU だが、C の int が32bitであることを前提に、 32bit演算を1命令でできるようにしてある(addw など)。 これがないと、足すたびに「下位32bitを取り出して符号拡張する」処理が余分に要る。 言語の都合が命令セットの設計に影響している例である。 x86-64 も同じ理由で、32bit レジスタ名(eax)での演算が用意されている。

この資料の原稿を見る