rui314/9ccを写経する-その1

8ccという有名なCコンパイラがあるが、これを書いたrui314さんが新たに9ccというリポジトリを上げていた。

https://note.mu/ruiu/n/n00ebc977fd60 を読むに、これは8ccをさらにわかりやすく、Cコンパイラ自作の教材として作っているものに違いないと勝手に判断し、写経してみることにした。 写経なので、書いているコードは同じ。自分が理解していった記録をここに残す。

僕のスペックは、普段はRubyでWebアプリを書くお仕事をしている。 Cコンパイラは一切書いたことはなく、Brainf*ckぐらいは書いたことがあるレベルだ。

ではスタート。

https://github.com/rui314/9cc/commit/56e94442ae8844688d5390851e5b29ba0c946e2f

9cc.c

main関数でprintfしているだけのコードのようだが、いきなりアセンブラっぽいコードを発見。 アセンブラは一切書いたことがないのでこれが初アセンブラだ。

.intel_syntax noprefixググるアセンブラのお作法としての記法を宣言するコードのようだ。アセンブラには複数の記法があるのだろう。

.global main
main:

なんとなくだけど関数宣言みたいなものかな?Cで言うmain関数宣言みたいなものだろう。

  mov rax, %d
  ret

うーんこの辺は全然わからん。多分変数raxに数字を入れてreturn的な感じか?

Makefile

9cc: 9cc.c

なんとたったこれだけで cc 9cc.c -o 9cc taskを書いたことになるようだ。 *.c taskがdefaultで用意されてるのかな?深くはわからない。

test.sh

テストコードっぽい。

  ./9cc "$input" > tmp.s

これから作るCコンパイラ9ccに引数をあたえて標準出力を書き出す。 いまのところ単にアセンブラをprintfするコードだったので、tmp.sにはアセンブラのコードが書き出されるのだろう。

  gcc -static -o tmp tmp.s

これはmacでは実行不可能だった。-staticgcc特有のオプションっぽい? clangでも同じことができなくはないだろうけど、コードの読み替えなどはしたくなかったのでDockerでlinux環境を用意した。

Dockerfile

FROM ubuntu:18.10

RUN apt-get update
RUN apt-get install -y make gcc lld

docker-compose.yml

version: "3"
services:
  9cc:
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - ./:/9cc
    working_dir: /9cc

これで写経したコードが

docker-compose run 9cc

で動くようになった。docker便利。

  ./tmp
  actual="$?"

おそらく出力されたアセンブラを元に、仮の実行バイナリtmpを作って実行しているんだろう。 $?はプロセスの終了ステータスだった。あのアセンブラは、終了ステータスをセットするものだったようだ。 この終了ステータスが入力と同じならOKとしている。

初っ端から初アセンブラを書いた。新言語習得である。

https://github.com/rui314/9cc/commit/916237396164ae25557b62bf0d1ca9d9cf8c2070

いきなり足し算引き算の登場。どうやって実装するんだろう。おそらくアセンブラコードを拡張していくはずだ。

9cc.c

まず最初に数字を読み込むが、strtolは初めて知った。 第一引数にポインタを渡したら勝手に文字列を数字として読み取ってくれるっぽい。 第二引数にポインタのポインタを渡すと、読み取り終わり位置までポインタが指す位置を進めておいてくれるようだ。便利。 テストコードから察するに、これで20のような2桁の場合でも読み込めるようだ。 次は1文字ずつ読み込んで、+だったらaddアセンブラ命令、-だったらsubアセンブラ命令を書き出すようだ。 たぶんこれで足し算引き算になるんだろう。

test.sh

写経通りやると動いた。 試しに123456のような数字を書いてみると、どうやら結果が255を超えることができない。 多分終了ステータスの最大値だと思われるが、このコンパイラは現状255が限界のようだ。

https://github.com/rui314/9cc/commit/42adde9b5e0ed09c0d76bb84da38d136be8390c1

おそらく空白を入れることができるようにする変更。これまでは空白文字を入れるとSEGVしていた。 ついでにtokenizeということで、空白を除去したプログラム構文を作り出すんだろう。本格的になってきた。

9cc.c

enum {
  TK_NUM = 256, // Number literal
  TK_EOF,       // End marker
};

tokenの種類を定義しているようだけど、何故256から始まるんだろう?0からじゃだめなのかな?とりあえずこのまま進める。

typedef struct {
  int ty;      // Token type
  int val;     // Number literal
  char *input; // Token string (for error reporting)
} Token;

tokenの構造体のようだ。tokenのty(種類)とval(値)とinput(入力文字)。valがint型なので、おそらく数字の足し引きしかできないだろう。これでいいんだ感満載である。

Token tokens[100];

これは正直ちょっと笑ってしまった。 あまりに単純で「これでいいんだw」というズコーな感じと、単純なところから始めることへの徹底されたこだわりまで感じる。 極限まで研ぎ澄まされた職人芸という感じだ。

void tokenize(char *p)

これも結局は1文字ずつ読み込んでif文で分岐しているだけ。 「これでいいんだ」と何回思わせるんだ。職人芸だ。

tyに直接'+''-'を入れている。 そうか、tyに直接文字列が入ることを想定すると、どの文字ともぶつからないようにするために TK_NUM = 256 となっていたんだな。

main関数ではこのtokenを使って、やはりif文で分岐してアセンブラを書き出す。

空白を飛ばすだけにしてはやや仰々しいが、これぐらいはいいでしょと飛ばしているんだろう。

test.sh

無事、空白を無視することができるようになった。