セキュリティ・キャンプ2025参加記――自作Cコンパイラで自作Cコンパイラをコンパイル

2025-10-03

はじめまして。k_kiriです。 私はこれまでブログというものを書いたことがなかったのですが、 せっかくセキュリティ・キャンプ2025全国大会という イベントに参加させていただき、とても面白い経験ができたので誰かの参考になれば良いなという思いで参加記を公開しておきます。


私は、2025年8月に開催されたセキュリティ・キャンプのCコンパイラゼミに参加し、Cコンパイラkccを作りました。 キャンプ期間中に、このCコンパイラでコンパイラ自身をコンパイルするセルフホストを達成することができました。

この記事では、セキュリティ・キャンプに参加してCコンパイラを作った過程を振り返りたいと思います。

応募

セキュリティ・キャンプの存在は様々な人のブログやSNSを通して知っていたのですが、今まで応募したことはありませんでした。 参加したいと思いながらも応募課題の難易度に圧倒されたり予定が合わなかったりしているうちにいつの間にか年齢制限により 応募できるチャンスが残り僅かなことに今年になって気が付きました。

今年こそは応募しなければと思い立ち、Cコンパイラを書きたかったので迷わずにCコンパイラゼミにエントリーしました。 例年開講されているOS自作ゼミの名前の響きにも憧れていたのですが、残念ながら今年は開講されなかったようです。 応募課題は締切の2週間くらい前から少しずつ解き始めました。 提出したのは確か締切当日だった気がします。 かといってギリギリだったかというと、(締切駆動で生きている私にとっては珍しく)そうではなく数日は内容の見直しや推敲をしていました。

そして応募課題を提出してから2週間後に参加が認められました。 その数日後にオンラインで初回のミーティングがあり、GitHubにリポジトリを作成したその日からセキュリティ・キャンプが始まりました。

このとき、自作Cコンパイラでセルフホストすることを目標に定めました。

事前学習期間

セキュリティ・キャンプが開催される5日間(開発に使えるのは実質3日間)でCコンパイラを作るのは難しいので、 キャンプ前の事前学習期間からコンパイラを書き始めます。今年は1か月強ありました。

Cコンパイラゼミの事前学習期間では、 Rui Ueyamaさんの低レイヤを知りたい人のためのCコンパイラ作成入門(通称CompilerBook)と、 教科書として頂いた『新・標準プログラマーズライブラリC言語 ポインタ完全制覇』、 そして、COMPILER EXPLORER1を参考にインクリメンタルに実装を進めました。

また、定期的にミーティング(作業通話の会)が開かれ、 講師のhsjoihsさんに見守られながらコーディングを進めました。 hsjoihsさんには、実装やC言語の仕様に関して困ったときや進め方など様々な場面で相談に乗ってもらいました。 会話はhsjoihsさんによって手動で文字起こしされ記録されました。2 これは後で見返すことができてとてもありがたかったです。

記録を残しておくことは後で見返すときに便利なので、 私も開発ログに進捗や実装についてメモを残しながら開発を進めました。 この開発ログをもとにこの記事を書いています。

事前学習期間に実装した機能は大体次の通りです。

1週目

  • 基本的な数値演算
  • int型のローカル変数
  • 式文
  • 関数呼び出し
  • {}ブロック
  • if/while/for文

式の構文解析には、演算子の優先順位をもとに再帰的に構文木を組み立てるPratt Parsingという手法を採用しました。 基本的には演算子の結合順を定義するだけで新しい演算子をパースできるようになるのが良い点です。

1週目には、次のような再帰関数でフィボナッチ数を求めるプログラムをコンパイルできました。 この時点ではまだ型の概念はなく、変数も関数の返り値もすべてint型です。

fib(n) {
    if (n <= 1) return n;
    else return fib(n - 1) + fib(n - 2);
}
main(){ return fib(10); } // => 55

また、関数呼び出しの際にスタックポインタrspを16の倍数にアラインするようにABI3で定められており、調整が必要です。 本来はコンパイラ側でスタックの使用状況を数えて調整するのが良いと思いますが、 手を抜いて実行時に動的に揃えるようにしました。 関数に渡す引数の数はレジスタに入れて渡す6つまでにしか対応していません。 こちらも後回しにしたのですが、キャンプが終わるまで結局このままでした。

2週目

  • 複合代入演算子
  • ポインタ演算
  • sizeof演算子
  • int型/ポインタ型

この週にはキャンプ事務局から教科書『ポインタ完全制覇』が届きました。 この本を眺めつつ、ポインタ関連の機能を追加しました。

ポインタの機能を追加したことで、型について少し考える必要が出てきました。 このときは、型をどのように持つのか(型情報を持ったノードの構造体を新しく作る?既存の構造体に型情報を持たせる?)や、 どの段階で型付けを行うのか(構文解析しながら?構文解析のあと?)などの実装方針が立っていなかったので、 試行錯誤しながら実装を進めました。

最終的には、既存の抽象構文木(AST)のノードを表すNode構造体に型の情報を持つメンバを追加しました。 また、これまでは字句解析→構文解析→コード生成というように処理していましたが、 構文解析の後にASTへ型を付けるパスを追加しました。 型付けの処理は、コード生成と同様にASTを再帰的に辿ってゆくという実装を行いました。

確かこの週のゼミのミーティングで、汎整数拡張というC言語の仕様を教えてもらいました。 汎整数拡張とは、C言語でint型以下の大きさを持つ整数型は評価されるときに自動的にint型になるというものです。 例えば、以下はchar型のサイズを求めてその値を返すプログラムであり、 当たり前ですが1が返ってきます。4

int main(){ char a; return sizeof a; }

しかし、

int main(){ char a; return sizeof +a; }

このようにsizeof +aに変更の上、gccでコンパイルして実行すると、私の環境では4が返ってきました。5 これは単項演算子+を適用するときに、汎整数拡張が行われているからです。

規格書6にも、

The result of the unary + operator is the value of its (promoted) operand

と、汎整数拡張が行われることが明記してありました。 はじめは汎整数拡張という仕様を奇妙に感じましたが、CPUの気持ちになって考えてみると 計算するときは8bitのchar型の値であろうが、32bit/64bitのレジスタに乗せることになります。 それを踏まえて改めて考えると、結構自然な仕様に思えます。

3週目

  • 配列
  • グローバル変数
  • char型
  • リファクタリング

この週は、最初に配列を実装しました。 配列は変数の確保するメモリサイズを大きくするだけなので、意外と簡単に追加できました。 よく聞くC言語豆知識ですが、配列にアクセスをするときのarr[i]*(arr + i)の糖衣構文です。 ここで、配列arrはその先頭要素へのポインタに読み替えられています。 配列だと思っていたものが、式の中ではポインタになってしまうのです。 配列アクセスに関しても、ポインタ演算をすでに実装していたのですんなりと追加できました。

グローバル変数はローカル変数とは似て非なるものであると、コンパイラを作って初めて体感しました。 何が異なるのかというと、メモリ上の位置です。それによって、アクセスの方法も異なります。 このときは初期化子をサポートしていなかったのでグローバル変数の初期値は0しかありえず、 .zero Nのようなアセンブリを出力して、0で初期化されたN byteの領域を確保するようにしました。

他にchar型を追加しました。 それに合わせて今までは8byteにしていたint型を4byteに変更しました。 x86_64ではレジスタの下位ビットに別名がついており、 その名前を使うことでレジスタからメモリに望んだビット数(8,16,32,64ビット)をストアできます。 ロード時は、mov rax, qword ptr [rax]のように大きさを指定します。

4週目

  • 文字列
  • 関数の返り値
  • !! 型 !!

この週は、型の構文解析に苦しめられました。 次の2つを正しく区別できるように実装するのは大変でした。 プログラムがうまく動いていないときに、状態を把握しながら再帰関数を追ってデバッグするのに苦労しました。

  • int *hoge[10]hogeintへのポインタの配列(sizeof hogeは80)
  • int (*hoge)[10]hogeintの配列へのポインタ(sizeof hogeは8)

実装にあたって、CompilerBook ネストしている型の読み方の章と 9ccの実装を参考にしました。 また、変数名をint (((hoge)))=0;のように括弧で囲ってもC言語の文法上正しいのですが、kccでは正しく解釈できるようにしました。

文字列リテラルもサポートしました。 ここは結構手を抜いた実装を行っており、エスケープシーケンスには対応していません。 したがって、例えば\nが含まれているとき、sizeof <文字列>を正しく求めることができません。 しかし、文字列をアセンブラに横流しすると、アセンブラが正しく解釈してくれるのでセルフホストをするためという観点では無くても困りませんでした。

5週目

  • 配列の初期化子
  • グローバル変数の初期化子
  • 3項演算子
  • カンマ演算子
  • 論理和・論理積
  • for文の中で変数宣言

試験期間だったのであまり時間の取れない日もありましたが、足りない演算子を追加するなど少しでも進捗を生むことを心掛けました。

2022年のCコンパイラゼミのログにあった回転するドーナツを表示するプログラムをコンパイルしようと思い、 カンマ演算子・論理演算などの足りなかった機能を追加しました。 そして、コンパイルすることができました! donut

小さなテストケースを通すことを繰り返しているうちにいつの間にか複雑なプログラムをコンパイルできるようになっているのは驚きです。 これが言語処理系作りの醍醐味かもしれません。

Cコンパイラゼミ講義概要の次の一節を実感できました。

実装・テスト・デバッグの繰り返しを続けるうちにどんどんコンパイルできるコードが増えていきます。あっという間に書いている本人よりもコンパイラの方が賢くなっていって驚くことでしょう。

直前

  • 構造体

キャンプの直前に、構造体を追加しました。 Cコンパイラを作る前は構造体をどのようにコンパイルすればよいのかはっきりイメージができていませんでした。 しかし、ここまでコンパイラを実装してきたことで、 このときには既に何となく実装するべき処理が大体イメージできるようになっていました。

COMPILER EXPLORERで答え合わせをして、アラインメントの原則をhsjoihsさんに教わったあと、実装に取り掛かりました。

k_kiri「アラインメントが十分理解できていないというか」
hsjoihs「アラインメントの基本原則は2つで、『アラインメントが最大のメンバのアラインメントが採用される』『型のポインタのアドレスは、常にアラインメントの倍数』です」 (2025/08/10 Discordログより)

構造体は、メンバごとのオフセットとサイズ、アラインメントを計算するだけです。 オフセットを保存しておくという点でローカル変数とよく似ており、追加するのに思っていたほど時間はかからなかったと思います。 また、アクセスのために.->演算子を追加しました。

キャンプ期間

構造体をコンパイルできるようになったところで、キャンプ期間に突入しました。 以降も同様に進捗を書いていきます。

0日目

  • リファクタリング

自宅から東京までの移動に時間がかかるため前泊が認められ、一足先に会場のLINK FORESTに到着しました。 東京の鉄道路線に詳しくないため、乗り換えに苦労しました。 他の前泊をする参加者と夕食をとり、名刺交換7をしました。

次の日の昼の開講式まで特にやることはなかったので、Cコンパイラのリファクタリングをしていました。

link_forest

1日目

  • 文字定数
  • void型
  • 関数の返り値

午前中はコンパイラを少し書いて、午後から待ちに待ったセキュリティ・キャンプが始まりました。 開講式や共通講義、参加者同士の交流イベントがありました。 ゼミの同期生がデバッグ用ゴム製アヒルを配っていたので、1匹もらいました。(ありがとう!)

dack

2日目(開発コース1日目)

  • 2kmccのコンパイル
  • continue, break
  • do while

開発コースの初日は、2kmcc8をコンパイルしようとするところから始まりました。 コンパイルのために必要な機能は実装できており、2kmccを一通りアセンブリに変換することはできました。 しかし、kccでコンパイルした2kmccでプログラムを正しくコンパイルできないという問題が発生しました。 つまり、kccの変換過程に何らかの誤りがあるということです。

kccでコンパイルした2kmccの出力を確かめると、文字列が正しく出力されない場合があるということがわかりました。 例えば、以下のような入力を与えた時、main関数のmainがmとしか出力されませんでした。

 % ./2kmcc 'int main() {return 0;}'
.intel_syntax noprefix
  .text
  .section .rodata
  .text
.text
.globl m
m:
  push rbp
  mov rbp, rsp
  sub rsp, 0
  mov rax, 1
  mov rax, 0
  mov rsp, rbp
  pop rbp
  ret
  mov rax, 42
  mov rsp, rbp
  pop rbp
  ret

コンパイルしたプログラムが動かないという問題のデバッグは、 普段行っているCで書かれたプログラム自体のデバッグとは性質が異なるので問題の原因を突き止めるのに苦労しました。

はじめ私はkccが誤ってコンパイルしてしまいそうな最小ケースで思い当たるものを片っ端から試し、誤りを探すという方向で進めました。 つまりコンパイラのテストケースで十分に試せていなかったケースを考えることで問題を見つけようとしました。 いくつかテストケースを追加すると、正しく動かない次のケースを発見しました。

int main() {
    int **tests = calloc(2, sizeof(int*));
    tests[1] = calloc(3, sizeof(int));
    tests[1][1] = 4;
    return tests[1][1] + 5;
}

そこで、このコードをコンパイルしたときに正しく動かない原因を探りました。 たまたま通りかかった講師のuchanさんにアドバイスをいただきながらアセンブリを追っていくことで 64bitのアドレスをメモリに保存したい箇所でmov [rdi], eaxと出力されている(32bit分しか保存されない)というコンパイラのミスを発見しました。 そのため、コンパイラの型付けの処理が誤っていると考え該当箇所のコードを読むと代入演算子の型付けに誤りを見つけました。 そのバグを修正することでこのテストコードを通すことができました。

しかし、このテストケースを通すことができてもkccでコンパイルした2kmccが正しく動作しないという問題は解決しませんでした。

デバッグに行き詰ってしまい、hsjoihsさんに助けを求めたところ、 「バグが再現する最小の入力を求めるとよい」という趣旨のアドバイスをもらいました。

このとき発生していたのは、自作コンパイラが入力された2kmccのソースコードのどこかをミスコンパイルしてしまうという問題です。 ただ、約1500行の2kmccのどこをミスコンパイルしているのかを人力で確認するのは手間がかかりすぎます。 そこで、バグが再現する状況を維持しながら2kmccのソースコードを削ってゆくことで、問題のエッセンスを抽出しようということです。

アドバイス通りに、先のバグが発生するのを確認しながら2kmccを削っていきました。 2kmccを機械的に64行まで削ったときに、char型へのポインタ同士の減算が行われていることに気が付きました。

int length = &str[i] - start;

C言語ではポインタ同士の減算をすることができ、ポインタ間のデータの要素数を求めることができます。 しかし、このときのkccは(手を抜いて実装を行っていたので)int型へのポインタの減算にしか対応していませんでした。

ここがバグの原因であることは確実だったので、修正すると2kmccをコンパイルしテストを通すことができました。

2kmccをコンパイルした後は、セルフホストのために足りない機能を追加しました。

3日目(開発コース2日目)

  • switch, case
  • union
  • enum
  • typedef

この日はコンパイルできる構文をひたすら追加していました。 不完全型と、無名構造体や無名共用体の取り扱いに苦労しました。

隣のゼミから聞こえてくる助けを求める声や、hsjoihsさんの歌うASCIIコード暗記ソングを聞きながら開発を進めました。

4日目(開発コース3日目)

  • 簡易プリプロセッサ

とうとう開発コースの最終日になってしまいました。 この日は、簡易的なプリプロセッサを実装するところから始めました。 まず、午前中は#define#includeを追加しました。 ここでは時間短縮のために厳密な実装はせず、最低限動くものを作りました。

また、hsjoihsさんの助言を受けセルフホストのために#ifdef __STDC__という文のみプリプロセッサでサポートできるよう実装しました。 これはgccと自作コンパイラの両方で同じソースコードをコンパイルできるようにするためです。

キャンプ中にセルフホストを達成したかったので、プリプロセッサを実装した後は、 コンパイルできる構文・機能を追加するのではなくコンパイラ自身が使う機能を削減する方向にシフトしました。 例えば、構造体の代入をサポートしていないので、構造体の代入が行われている箇所をmemsetに置き換えました。 そして、この日のうちに自作コンパイラのプログラムファイル6つのうち5つを自作コンパイラ自身でコンパイルすることに成功しましたが、 セルフホストは達成できませんでした。

この時発生していた不具合は、2世代目の自作コンパイラが構造体をコンパイルするときにセグメンテーション違反を起こすというバグでした。 2世代目のコンパイラのバグはどこに原因があるかを特定するのが難しくデバッグが難航しましたが、 parser.cのみをgccでコンパイルしたときはこのバグが発生しなくなったため、 parserのソースコードをうまくコンパイルできていないということまでは特定できていました。

5日目

さらに問題箇所を特定するためにparserのソースコードをgccでコンパイルする分と、自作コンパイラでコンパイルする分に切り分けて、 自作コンパイラでコンパイルする分を徐々に増やしつつ不具合が発生するかを確かめることで問題が発生している箇所を突き止めました。 結果的に、3項演算子の型付けが誤っていたことがこのバグの原因だったことが判明しました。

バグの原因が分かったのは5日目の写真撮影と閉講式の間の休憩時間で、すぐにこのバグが発生しないようにプログラムを書き換えました。 すると、すべてのファイルを自作コンパイラでコンパイルできるようになりました。 そして、1世代目のコンパイラが出力するコードと2世代目のコンパイラが出力するコードが同一であることを確認でき、セルフホストを達成することができました。

この時のソースコードは、seccamp_selfhostブランチにあります。

おわりに

キャンプ中に自作コンパイラをセルフホストするという目標をギリギリ達成することができました。 セルフホストができると聞くと完成度の高いものだと思われるかもしれませんが、 セルフホスト達成時のkccの実態は全くそんなことはなく、最低限の構文しかサポートしていない不完全なものです。 にもかかわらず、セルフホストができたことは作っている私からしても不思議な体験でした。

開発中は数多くのバグに悩まされましたが、とりわけ型付けのミス(扱うデータのbit数の取り違え)によるバグが多かったという印象があります。 この類のバグは不可解な挙動をする9ことが多く、原因を突き止めるのがとても難しかったです。 しかし、講師の方にサポートをしていただきながらデバッグをする過程でデバッグ力を鍛えることができました。

反省点

  • デバッグに役立つため、コンパイラのエラー出力はもっと時間をかけて作り込むべきだった
  • テストコードをCで書き直して高速化しておけばよかった
  • キャンプ中のセルフホスト達成を優先し、機能を削った点は少し悔しい
  • 手を抜いて未実装の部分を忘れ、正しく動かないと勘違いして悩んでいる時間はもったいなかった

コンパイラデバッグのコツ

  • デバッガで動作を観測する
  • 出力するアセンブリコードにコメントを付ける
  • 問題を特定するために、入力を小さくする

最後に、この記事ではあまり触れられませんでしたが、 セキュリティ・キャンプには開発コース以外にも共通講義や参加者同士の交流、LT会など多彩なプログラムがあり、 とても楽しい時間を過ごすことができました。 関係者の皆さん本当にありがとうございました。

ゼミの同期生の記事


  1. アセンブリはこれを見ながら学びました

  2. 2022年度のCコンパイラゼミ会話ログが公開されています。

  3. System V ABI

  4. sizeof(char)は規格により1であると定められています

  5. 記事を書きながらkccで試したところ、両者とも1が返ってきてしまいました。構文解析時点で単項+を無視しているからです。これはまずい

  6. C23規格書のドラフト

  7. 名刺は作って持っていったほうが絶対いいです

  8. hsjoihsさん作の小さなCコンパイラ

  9. ゼミ同期生の椎名さんの半分だけ動くドーナツバグなど

../