セキュリティ・キャンプで Rust 製 RISC-V ターゲットの C コンパイラを開発した
セキュリティ・キャンプ 全国大会 2025 の L4 Cコンパイラゼミに参加しました。Cコンパイラゼミは一ヶ月ほどの事前学習期間と5日間の合宿 (内3日の開発期間) で小さなCコンパイラを作るという野心的なゼミです。
私は Rust で RISC-V をターゲットにした gakicc という C コンパイラを開発しました。Rust で開発する都合上、セルフホストはできないので、講師の hsjoihs さんの作った 1.5k 行程でセルフホストされている 2kmcc を自作のコンパイラでコンパイルし、生成されたバイナリを用いてもう一度 2kmcc をコンパイル、さらに生成されたバイナリを用いてもう一度 2kmcc をコンパイルし、2回目と3回目に生成されたアセンブリが一致することを目標としました。最終日の帰宅までになんとか目標が達成できて良かったです。
gakicc では、 低レイヤを知りたい人のためのCコンパイラ作成入門(compilerbook) や chibicc のコミットをかなり逐次で追いながら移植に近い形で実装しています。もちろん理想的には実装は見ずに進めた方が良いとは思いますが、期間も短かったのでまず今回は一度走り切って全体像を掴めたらいいなという気持ちで取り組みました。
難しかったところ
「逐次で移植するだけなら詰まるところなんてないのでは?」と思われそうなところではありますが、ちゃんとした(?)コンパイラを書くのが初めてだったこともあり想像以上にいろんなところで詰まりました。その内いくつかを紹介します。
突然 nullable だったことを知らされる構造体のメンバ
(これはコンパイラを書く上での話というよりは C 言語で書かれたプログラムを Rust に移植する際の話なのですが) 参考にした chibicc は C 言語で実装されていて、ポインタ型は常に nullable なので、以下のように一つの構造体に kind と省略可能なポインタ型のメンバを持つことで、値をつことのできる enum (Rust の直和型のような) のように使っている箇所が多数存在します。
enum Kind {
A,
B,
C,
};
struct Variants {
enum Kind kind;
int *a_int;
char *b_str;
};
この時に、kind が A や B であれば a_int や b_str は常に存在すると思って以下のように Option にしなかったら、後から突然 null で使われ始めて改修が必要になるということが何度かありました。
enum Variants {
A(i32),
B(String),
C,
}
また、 chibicc は(コンパクトに書くためだと思うので仕方ないのですが)かなりレキサ、パーサ、意味解析、コード生成のモジュール間が密結合なので各時点での表現をどうするかも悩ましかったです。
RISC-V ターゲット
参考にしていた chibicc では x86_64 をコンパイルターゲットにしているため、レジスタの下位 8 bit, 32 bit などにアクセスするための擬似レジスタが存在し、そのための専用のルックアップテーブルなどを作成していました。一方、 RISC-V では load 命令が 64 bit なら ld (load double-word), 32 bit なら lw (load word), 8 bit なら lb (load byte) のようにかなり規則性があり、面倒なルックアップテーブルなしに比較的シンプルに実装 できました。
作り始めた当初はスタックマシンでないことなどから x86_64 ターゲットよりも RISC-V ターゲットの方が難しくなるのでは、と予想していたのですが、 x86_64 に親しみがない私にとっては(少なくとも 2kmcc のコンパイルまでなら)むしろ簡単に作れたような気がします。ただ、現状では push / pop を擬似的に行なってスタックマシンのように扱っているので、ちゃんとレジスタ割り付けとかをするとなると当然もう少し難しくなると思います。
半分だけ動くドーナツ
C コンパイラゼミでは例年、 donut.c という、ドーナツが回転する簡単な C 言語のプログラムを実行できるようにすることが一つの目標になっています。
元の donut.c では float が使われていますが、gakicc では float に対応していないので、講師の hsjoihs さんが作成した int のみで動作するバージョン をベースに、さらに三項演算子などを省いた 最小限の donut.c を用意しました。
コンパイルすると、以下のように上半分だけが表示されるという不自然な状態になってしまいました。
しばらくかかって、hsjoihs さんによって gakicc では int が 4 byte ではなく 8 byte になっていたことが問題だと発覚しました。というのも、この donut.c は memset でピクセル数分の擬似フレームバッファのようなものを初期化して usleep させながら出力することでアニメーションさせています。しかし、 memset の値が固定サイズだったために int が、期待される 4 byte の倍である 8 byte あった gakicc では memset で本来のピクセル数の半分しか初期化できていなかった、ということでした。memset で sizeof を利用することで解決しました。
再帰を含む struct の定義
以下のような構造体をパースする際に、一度 foo, bar まではパースして、 s にたどり着いた時点で、その時パースで来ていたメンバだけを持った struct の型をメンバの型として使用しておき、s を含めた全てのメンバのパースが完了した時に最初に使用した不完全な struct の型を完全なもので置き換える必要がありました。
struct S {
int foo;
int bar;
struct S *s;
}
この時に、今までは型の表現として単なる構造体を素直に利用していたため、後から一括で置き換えることが難しく、全ての型の表現を Rc<RefCell<CType<'a>>>
で置き換えました。特に、パターンマッチを多用していた型推論の箇所の置き換えに苦労しました。これがRustを採用したことによって期間中遭遇した唯一の問題だったと思います。
文字エスケープ
C 言語には \n
などの様々な文字のエスケープ機能が存在します。\n
, \t
, \r
などは Rust にも存在しますが、\a
, \b
, \v
, \f
などがあることは初めて知りました。そういった文字エスケープは何も処理せずにコード生成でそのまま再出力してしまっても動くのですが、そうすると sizeof 演算子でうまく文字数が取得できません。
そのため、一度レキサでそれらのエスケープ表現を実際の文字に変換し、再度コード生成でエスケープすることにしました。先ほど例に挙げたような一文字のエスケープだけであれば比較的シンプルなのですが、\x<n桁の16進数>
や、 \<1~3桁の8進数>
も対応するとなると少し複雑になります。最終的には 90 行ほどの escape / unescape 関数 を作成しました。
このエスケープ部分由来のバグとして途中、gakicc でコンパイルした 2kmcc によってコンパイルされた 2kmcc の出力するアセンブリが以下のように文字化けしてしまうことがありました。(after が本来期待される出力)
mov rax, 0
mov rdi, rax
pop rax
- cmp eax, edisi�x�x
+ cmp eax, edi
setne al
movzb rax, al
cmp rax, 0
このバグは gakicc で入力の中の "edi\0esi\0edx\0ecx\0r8d\0r9d"
が "edi\x0esi\x0edx\x0ecx\x0r8d\x0r9d"
になってしまい、それによってコンパイルされた 2kmcc で edisi�x�x
などが生成されてしまうというものでした。これは初期の escape / unescape 関数では \<1~3桁の8進数>
や \x<n桁の16進数>
という表現があった際に、一律に \x<n桁の16進数>
に変換していたことが原因でした。先ほどの文字列で言うと、 \0
が \x0
に変換され、それによって後続の文字列の一部が 16 進数表現の一部として解釈され、 \x0e
や \x0ed
, \x0ec
といった想定されていない文字が出現していました。最終的に、全てを 8 進数側に寄せて \000
のように表現することで対処しました。
アセンブリの差分を送ったら hsjoihs さんがデバッグしてくれている様子
まとめ
compilerbook / chibicc を参考に、RISC-V ターゲットの C コンパイラを Rust で作成しました。compilerbook にはこれまでにも何度か挑戦して失敗していたので、集中して一ヶ月で取り組める機会は貴重でした。また、言語やコンパイルターゲットは違っても、一緒に C コンパイラを作っている人がいたり、困ったら「困ったな〜」と呟くだけで講師やチューターの方が助けに来てくれたりするキャンプはとても良い環境でした。
目標だった 2kmcc のコンパイルまで辿り着いてみると、案外 compilerbook がカバーする範囲は小さく、逐次での移植みたいな形でも一度最後までやってみるのは大事だったと思いました。gakicc は今の時点では 2kmcc を(少し不正しつつ)ギリギリコンパイルできるレベルでかなり機能が少ないので、今後拡充するか、今の段階で定数畳み込みやレジスタ割り付けができると楽しいのかな〜と思ったりしています。
最後に今回作成した gakicc のリポジトリを載せておきます。最初は &&
や ||
が短絡評価になっていないなど、ひどい手抜きの様子を見ることができると思います。スターしてもらえると喜びます。
Discussion