26M の @ikura-hamu です。Go のコンパイラを Go で書いて、セルフホストが動いたので記事を書きます。
正確には、「Go の(プログラムの一部を処理できる)(狭義)コンパイラ」を書いたことになります。
リポジトリはこちらです。
https://github.com/ikura-hamu/ooo
作ったコンパイラについて
コンパイラの名前は「 ooo 」(小文字の o が3つ)です。Toy Go Compiler から取ったということになっていますが、それは後付けで、科学大のキャンパスがある大岡山(おおおかやま)から取っています。なので読み方は「おおお」です。
ooo は、Go のプログラムを入力に取り、x86_64 のアセンブリを出力するプログラムです。コンパイラと呼ばれるプログラムは、アセンブリの出力のみを行う狭義のコンパイラと、アセンブル、リンクなども行って実行ファイルを出力する広義のコンパイラがありますが、ooo は前者にあたります。

ooo はセルフホストを行うことができます。コンパイラのセルフホストとは、コンパイラ自身のソースコードをコンパイルすることです。すなわち、ooo の Go プログラムを ooo に渡すと、ooo のアセンブリプログラムが得られます。


通常の Go コンパイラによってできた ooo が第 1 世代(ooo1)、ooo1 によってコンパイルされた ooo が第 2 世代(ooo2)、ooo2 によってコンパイルされた ooo が第 3 世代(ooo3)です。ooo2 が ooo 自身をコンパイルでき、さらに ooo2 と ooo3 の出力が一致すれば、少なくとも ooo 自身については自己コンパイルが安定していることを確認できます。今回はこれを目標としました。
Go には様々な機能が仕様で定められていますが、今回の目標はセルフホストだったため、セルフホストに必要なほぼ最低限機能のみに絞って実装をしています。たとえば、goroutine や switch 文、map、GC、メソッド、interface などは実装していません。そのため、機能としてはほとんど C と変わりません。
中身の技術的な話
コンパイルしたい Go のプログラムのファイル名を半角スペースで区切って標準入力から渡すと、Intel 形式の x86_64 アセンブリを標準出力から吐き出します。普通のコンパイラはコマンドライン引数からファイル名などを指定しますが、コマンドライン引数を取得する部分の実装を省略するために標準入力から取得しています。
ooo は大まかには、Go のソースコードを読み、構文解析・型に関する処理・エスケープ解析などを行い、最後に x86_64 アセンブリを出力する、という流れで動きます。
メモリ管理
Go には Garbage Collection (GC) による動的なヒープのメモリ管理がありますが、ooo にはありません。セルフホストをするのに必要なメモリはそれほど多くなく、コンパイラはすぐに実行終了するプログラムだからです。そのため、一度確保した領域を解放しない単純な allocator でも、セルフホストには十分でした。
ヒープのメモリは起動時にある程度まとまった量を brk システムコールで確保し、必要になったらそこから取得するようになっています。一旦取得したメモリは解放しないため、最初に確保したメモリを使い切ったらメッセージを出して終了するようになっています。
libc 非依存
ooo は libc に依存していません。メモリ管理や I/O は自分でシステムコールを呼ぶことで実装しています。アセンブリから syscall 命令を使うことでシステムコールが使えるので、アセンブリプログラムを温かみのある手書きで書いて使っています。
例えばファイルパスを指定してファイルディスクリプタを取得する open 関数はこのようになります。
# func open(path string, flags int) int
.global _open
_open:
# return values
# [rbp+40] for fd
push rbp
mov rbp, rsp
mov rax, 2 # syscall open
mov rdi, [rbp+16] # file path
mov rsi, [rbp+32] # flags
syscall
mov [rbp+40], rax # file descriptor
mov rsp, rbp
pop rbp
ret
libc 非依存にした理由は、ooo の関数呼び出しのルールと、C / libc 側の関数呼び出しのルールを合わせるのが大変だと思ったからです。ooo では実装を簡単にするために、通常の C のコンパイラでの関数呼び出し時のルールを破っており、たとえば引数はレジスタではなく全てスタック領域に積まれています。ooo の関数と libc の関数によってルールを使い分けるのはコストが高いと考え、自力実装を行いました。
幸いシステムコールに関する知識は情報工学系の「システムプログラミング」という科目で少し学んでいたので、比較的楽に実装できました。
エスケープ解析
Go のコンパイラといっても C のコンパイラと構文が違うだけでだいたい同じようにできるんだろうと思っていましたが、1 つ大きな違いとしてエスケープ解析がありました。
以下の C と Go のプログラムは非常に似ていますが、異なる結果になります。
C
int* f1() {
int a = 1;
return &a;
}
void f2() {
int a = 2;
printf("%d\n", a);
}
int main() {
int* a = f1();
f2();
printf("%d\n", *a);
}
Go
package main
import "fmt"
func f1() *int {
var a int = 1
return &a
}
func f2() {
var a int = 2
fmt.Printf("%d\n", a)
}
func main() {
var a *int = f1()
f2()
fmt.Printf("%d\n", *a)
}
C の方はローカル変数のポインタをスコープ外で参照しているため未定義動作になります。手元の gcc では segmentation fault になりました。これはローカル変数のポインタがスコープの外に出る、今回であれば返り値としてローカル変数のポインタを指定しているためです。
一方、Go の方は、main 関数での fmt.Printf では、意図した通りの 1 が出力されます。これは Go のコンパイラがエスケープ解析と呼ばれる処理を行っているためです。エスケープ解析では、スコープの外に渡されるローカル変数のポインタを探し、必要であればそれをスタックではなくヒープに置きます。
ooo の実装ではエスケープ解析を自分で書く必要がありました。具体的には、ローカル変数のうち、そのポインタが返り値になったりグローバル変数に代入されたりしているものを探して、それらの実体をヒープに置くようにしました。
ストリング命令
コンパイラを書いていると、メモリ上のある連続した領域の値を、他の場所にコピーしたり、他の場所と比較したりする必要が出てきます。例えば文字列の比較や結合、slice の append 関数などです。はじめはこれをループを書いて 1 バイト、もしくは 8 バイトずつ処理していました。
この処理を書くのは特にループ回数が実行時にならないと分からない場合に大変なのですが、実装終盤にストリング命令に出会ったことで効率が大きく上がりました。ストリング命令は、連続したメモリ上の領域に関する処理を少ない命令数で記述できるものです。以下のように使います。
pop rsi # コピー元のアドレス
pop rdi # コピー先のアドレス
pop rcx # 何回コピーするか
rep movsb # ecx レジスタの値が 0 になるまで 1 バイトずつコピーして ecx-- をする
コメント部分はとてもざっくりしているので、ちゃんと使う場合は他の資料を当たってください。この命令の素晴らしい部分は、扱う領域の大きさをレジスタに指定することで動的に変えることができる点です。この例は 1 バイトごとのコピーでしたが、8 バイトごとにしたり、値の比較にしたりもできます。
このストリング命令の威力はすさまじく、原因がよくわからないバグが、コピーの処理をストリング命令に置き換えただけで直った、ということが2、3回ありました。ありがとう、ストリング命令。
実装したもの・しなかったもの
できるだけ少ない手数で実装したかったので、ooo に持たせる機能は非常に少なくしました。例えば ooo がサポートしている型は、プリミティブ型は int, string, bool の 3 つのみ、複合型も struct, array, slice の 3 つのみです。
機能を絞って実装量を少なくする方法はうまく行ったと思います。しかし、セルフホストするためには ooo 自身も対応する機能のみで記述する必要があるので、記述の楽さと実装の量のトレードオフがあります。例えば ooo は byte 型に対応していません。byte 型はその名の通り 1 バイトであるのに対し、他の型は全て 8 の倍数バイトであり、個々の処理を分けるのが大変だと思ったからです。そのため、文字列から 1 文字を取得する際は str[0] ではなく str[0:1] のように長さ 1 の string として書く必要があります。この程度の手間は許容できるものです。一方、初めは && と || も実装しない予定でしたが、記述があまりに面倒になることに気づいて実装しました。
制作過程について
作ろうと思った理由
本能です。
と書くのが traP の伝統のようです。
ooo を作ろうと思った理由は 2 つあります。
1 つ目は、いきなり自作 Go コンパイラを持ってきた人がいたら面白いと思ったからです。想像してほしいんですが、大学の昼休みに友達に「Go コンパイラ作ったんだよね」って言われてセルフホストしてるコンパイラ見せられたら面白くないですか?
2 つ目は、大学の授業で C コンパイラを作ったのが楽しかったからです。科学大(当時は東工大でしたが、)の情報工学系には「コンパイラ構成」という授業があります。この授業は、C のサブセットをコンパイルするコンパイラを C で書くのが目標です。この授業を系外から履修したのですが、自分が作ったコンパイラでプログラムを動かせるのがとても楽しく、自分でもやりたいと思い、自作コンパイラに取り組むことにしました。
制作期間
実装を始めたのは 2025 年 1 月 2 日です。最終的にセルフホストを達成したのは 2026 年 4 月 29 日なので、1 年と 4 か月ほどかかったことになります。この期間 ooo だけをやっていたわけではなく、時間があるときにちょこちょこ進めている感じでした。

作ろうと思った理由 1 つ目にある通りいきなり完成物を出したかったので、さもコンパイラには興味ありませんよみたいな感じで振舞っており、人にコンパイラを作っていることは言っていませんでした。しかし、想像以上に時間がかかってしまったので、自分が設けた制約のせいで苦しかったです。
LLM について
自分が ooo の実装をしている間にLLM がとんでもない速度で進化し、コーディングエージェントなるものが登場してきました。
ooo の実装においてはコーディングエージェントは全く使いませんでした。理由は自分の力試しとしてどこまでいけるか試したかったからです。結果的に最後まで実装できたので良かったです。
今年(2026 年)の 2 月に、Claude が C のコンパイラを 14 日間で実装したことが話題になりました。
https://www.anthropic.com/engineering/building-c-compiler

この時期は、ooo が完成したら「AI が 14 日間で C コンパイラを作った一方、僕は 14 か月で Go のコンパイラを作った」みたいな記事タイトルにしたらウケるかなと思っていましたが、2 月中に完成させることができなかったので没になりました。
僕が実装を始めたときは、これ完成させたらめっちゃちやほやされると思ってたんですが、LLM のせいでそうではなくなる気がして残念です。
終わり方
第 1 世代のコンパイラが初めて第 2 世代の ooo のアセンブリを出力したのは、2026 年の 3 月 30 日でした。
https://github.com/ikura-hamu/ooo/commit/4b45ca75fd2027b972a5fb5f2c06cab6013ea9b8
ここからはこのアセンブリを実行して第 3 世代のコンパイラを出力できるようにならなくてはいけません。そのためにはアセンブリで書かれたプログラムのデバッグが必要になります。Go で書かれたプログラムのデバッグに比べて遥かに難易度が高いため、ここからさらに 1 年くらいかかるのを覚悟しました。
しかし、セルフホストの達成の瞬間はあっけなく来ました。数個バグをつぶすとちょっとプログラムを出力するようになり、1 個の小さいバグを直すといきなり最後まで完走して第 3 世代のアセンブリを出力しました。まさかと思って第 2 世代と第 3 世代のアセンブリを比較すると、完全に一致して驚きました。
https://github.com/ikura-hamu/ooo/commit/dd798a6a5e604022258afb93752301a63e1fd4a5
参考にした資料
多くの資料のおかげでセルフホストを達成できました。
低レイヤを知りたい人のためのCコンパイラ作成入門
Rui Ueyama さんが書かれている、C コンパイラを作るための資料です。執筆中らしいですが、コンパイラづくりを始めるための情報が集まっており、とても助けられました。
Linuxで学ぶx86-64アセンブリ言語
東京科学大学 情報工学系の権藤克彦先生が書かれた、アセンブリ言語に関する資料です。先述のコンパイラ構成の授業も担当されている先生で、授業内でも資料として使われていました。アセンブリ言語の命令や gdb(デバッガ)の使い方などを調べる際にとても頼りになりました。
Goコンパイラをゼロから作って147日でセルフホストを達成した と Goコンパイラを自作して93日でセルフホストを達成した(2回目)
DQNEO さんが書かれた、Go コンパイラ実装の記録です。Go でコンパイラを作るか迷っているときにこの記事を見つけて、作ることを決めました。設計の方針などを参考にしました。
感想
Go の仕様を知れてよかった
実装にあたって、Go の仕様書を必要に応じて読みました。今までもGo Spec読みたいなと思っていたのですが、時間やモチベが無くて読めていませんでした。今回コンパイラを実装するのに必要なタスクとして仕様書を読むというものが出てきたので読むことができました。
仕様を読むと今まで知らなかったことも知れて面白かったです。たとえば組み込みのprintやprintln関数は、Go のコンパイラは標準エラー出力に文字列を出力するようビルドしますが、仕様ではどこに出力すべきか書かれていません。そのため、ooo では標準出力を使うようにしました。
アセンブリはつらかった
それはそうなんですが、アセンブリを読んで書いてデバッグするのは大変でした。ただ、自分で書いたアセンブリを読むのは別にそれほど苦でもなくて、どちらかというとコンパイラが吐き出したアセンブリを理解するのが大変でした。
それでも、終盤になると複数行のアセンブリを 1 つの意味のまとまりとして捉えるなどしてだいぶ読めるようになりました。
コンパイラ書くのおもしろかった
自分で書いたコンパイラが動いて実行できるアセンブリを吐き出すという現象そのものも面白かったし、プログラムを解釈して抽象化し、どうしたらやりたい動作を表現できるか考えるのも面白かったです。
もうしばらくやりたくない
きつかったというより長かったのが大変でした。なにかやりたいことを見つけても、「ooo が終わってからかな」と思ってしまっていたので、終わってほっとしてます。今は Rust とか Zig とか関数型言語とか、または OS とかの勉強をしたいです。
みんなやった方がいいですよ
面白かったので、みんなやった方がいいですよ。Go の仕様に詳しくなりたい人とか、低レイヤーを勉強したい人はおすすめです。
また、自作コンパイラといえば C コンパイラという雰囲気が強いと思いますが、Go に限らず C 以外のコンパイラを書く人がもっといてもいいと思います。セルフホストにはそれほど高度な機能を実装する必要はありませんし、何より自分が好きな言語でやった方がモチベーションが上がると思います。
おわり
とりあえず終わってほっとしています。次コンパイラを作るときは、アセンブリ以外を出力するものにしたいです。
ヘッダー画像用ライセンス表示
The Go gopher was designed by Renée French.

