Cコンパイラ作りはじめました

こんにちは。自分のブログを書くのはものすごく久しぶりのid:rskyです。何年知的便秘だったんだって感じですね。

表題のとおりこれからCコンパイラを作っていこうかと思います。同僚のDQNEOさんが<8cc.go>というコンパイラを作られていて、それに触発されて「Rustでやってみよう」ということで、<低レイヤを知りたい人のためのCコンパイラ作成入門>をCでなくRustでやっていきます。 Rust自体も初めてなので、それも含めてどんなものになっていくのか、自分でも楽しみです。

なお、今後このシリーズでは<低レイヤを知りたい人のためのCコンパイラ作成入門>を二重鉤括弧つきの『入門』と表記します。

開発環境

自分の開発マシンはMacですが、macOSはターゲットとせずLinuxで動くものを作っていきます。『入門』にも

macOSはLinuxとアセンブリのソースレベルでかなり互換性がありますが、完全互換ではありません。この本の内容に従ってmacOS対応のCコンパイラを作成するのは不可能ではないものの、実際に試してみると、細かな点でいろいろな非互換性に悩まされることになるでしょう。Cコンパイラ作成のテクニックと、macOSとLinuxの差異を同時に学ぶというのは、あまりお勧めできることではありません。何かがうまく動かない場合、どちらの理解が間違っているのかよくわからなくなってしまうからです。したがって現時点では、本書ではmacOSは対象外とします。macOSではDockerなどを使ってLinux環境を用意するようにしてください。

とあるように、コンパイラ作成の本質に注力するためです。 予断ですがサブでWindowsも使っており、WSL2とWindows Terminalの出来如何ではいよいよメイン開発環境をWindowsにしてもいいかなと思っています。

コラム: macOSでの差異

Linuxとシンプルかつ最大の違いといえば、macOSでは公開シンボルにアンダースコアのprefixが必要なことでしょうか。main関数はアセンブリでは_mainという名前で宣言しないといけません。

また、これは教えていただいたことですが、レジスタにも違いがあるらしいです。このあたりの知識は今の自分に全く欠けていることもあり、素直にLinuxのみをターゲットにすることにしました。将来的にはクロスコンパイルにもチャレンジしてみたいですね。

Repository

既に作成済みの9cc.rustをcloneして作業します。

$ git clone git@github.com:rsky/9cc.rust.git
$ cd 9cc.rust

Dockerfile

Ubuntu 18.04 LTSベースのイメージを作ります。

FROM ubuntu:18.04

# Install build tools
RUN apt-get update && apt-get install -y gcc make git curl binutils libc6-dev

# Install Rust
RUN curl https://sh.rustup.rs -o rustup-init.sh && chmod +x rustup-init.sh && ./rustup-init.sh -y

WORKDIR /9cc.rust

COPY . /9cc.rust

Getting Started

このシリーズで作るコンパイラのバイナリ名は r9cc とします。それではさっそくプロジェクトを作成しましょう。今まさに読み始めたばかりの<プログラミングRust>に従ってパッケージを作成します。

$ cargo new --bin r9cc
     Created binary (application) `r9cc` project
$ tree r9cc
r9cc
├── Cargo.toml
└── src
    └── main.rs

1 directory, 2 files

しかし既に9cc.rustプロジェクト内で作業しているのでこれではちょっと都合が悪い、ということでリネームしました。

$ mv -v r9cc/* .
r9cc/Cargo.toml -> ./Cargo.toml
r9cc/src -> ./src
$ rmdir r9cc

Hello, Rust!

まずは引数も取らず、固定のアセンブリコードを吐くだけのプログラムを書いてみます。僕自身初のRustコードです。

src/main.rs

fn main() {
    println!(".intel_syntax noprefix");
    println!(".global main");
    println!("main:");
    println!("  mov rax, 1");
    println!("  ret");
}

これをビルドします。※ここから先はコンテナ上での作業ですが、そこら辺の説明はざっくり省いています。

$ cargo run
   Compiling r9cc v0.1.0 (/9cc.rust)
    Finished dev [unoptimized + debuginfo] target(s) in 5.60s
     Running `target/debug/r9cc`
.intel_syntax noprefix
.global main
main:
  mov rax, 1
  ret

target/debug/r9cc にバイナリが作られたようなので、これを実行、生成物をアセンブルして実行します。

$ ./target/debug/r9cc > tmp.s
$ cat tmp.s
.intel_syntax noprefix
.global main
main:
  mov rax, 1
  ret
$ gcc -o tmp tmp.s
$ ./tmp
$ echo $?
1

OKですね。引き続き引数として取った数値を返すようにします。

Take Arguments

コマンドライン引数の処理は<プログラミングRust>の第2章序盤に書かれており、そのまま使えました。

use std::io::Write;
use std::str::FromStr;

fn main() {
    let mut numbers = Vec::new();

    for arg in std::env::args().skip(1) {
        numbers.push(u8::from_str(&arg).expect("error parsing argument"))
    }

    if numbers.len() != 1 {
        writeln!(std::io::stderr(), "Invalid number of arguments.").unwrap();
        std::process::exit(1);
    }

    println!(".intel_syntax noprefix");
    println!(".global main");
    println!("main:");
    println!("  mov rax, {}", numbers[0]);
    println!("  ret");
}

ビルドして実行してみます。引数のパースエラーも処理できていてありがたいですね。

$ cargo run
   Compiling r9cc v0.1.0 (/9cc.rust)
    Finished dev [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/r9cc`
Invalid number of arguments.

$ ./target/debug/r9cc
Invalid number of arguments.

$ ./target/debug/r9cc 42
.intel_syntax noprefix
.global main
main:
  mov rax, 42
  ret

$ ./target/debug/r9cc 65535
thread 'main' panicked at 'error parsing argument: ParseIntError { kind: Overflow }', src/libcore/result.rs:997:5
note: Run with `RUST_BACKTRACE=1` environment variable to display a backtrace.

$ ./target/debug/r9cc foo
thread 'main' panicked at 'error parsing argument: ParseIntError { kind: InvalidDigit }', src/libcore/result.rs:997:5
note: Run with `RUST_BACKTRACE=1` environment variable to display a backtrace.

というわけで、引数を指定して出力したコードをアセンブル、実行します。

$ ./target/debug/r9cc 42 > tmp.s
$ cat tmp.s
.intel_syntax noprefix
.global main
main:
  mov rax, 42
  ret
$ gcc -o tmp tmp.s
$ ./tmp
$ echo $?
42

OKですね。これでようやく『入門』のステップ1の途中、「コンパイラ本体の作成」まで完了しました。 次回は「ユニットテストの作成」です。