Open15
Cコンパイラ作成リベンジ
再挑戦
前回の挑戦でセルフホストまで到達できず未だに悔しいので再挑戦する。
なので目標は、セルフホストと独自の構文を追加することを設定する
環境構築
端末情報
- MAcBook Pro
- Apple M1
- 8GB RAM
Dockerを使う
- コンテナの作成
docker container run -it --volume $HOME/workspace/9cc:/9cc --name --platform linux/amd64 9cc ubuntu:22.04 bash
- コンテナの起動&アタッチ
docker container start 9cc
docker container attach 9cc (or, docker container exec -it 9cc bash)
- コンテナの停止
docker container stop 9cc
前回のチャレンジ
コンパイラは
-
トークナイザ
- 純粋な文字列をTokenにする
- 予約語なのか数値なのかなどを表現
-
パーサー
- 構文木を生成する
-
左から計算しなければならない演算子のことを「左結合」の演算子、右から計算しなければならない演算子のことを「右結合」の演算子といいます。Cでは、代入の=を除いて、ほとんどの演算子は左結合として定義されています。
-
コードジェネレータ
- パーサーが生成した構文木から機械語を生成する
から構成されることは知っているので、その辺りからはじめる。
四則演算
文法の定義にはBNF(EBNF)を使う。
四則演算のBNF
expr = mul ("+" mul | "-" mul)*
mul = primary ("*" primary | "/" primary)*
primary = num | "(" expr ")"
四則演算のできる言語の作成までは一気にすっ飛ばした。
インタープリタとほぼ同じ。
ジェネレータ(機械語生成)の部分でx86でのスタックマシンについて詳しく書かれている。
単項プラス,単項マイナス
expr = mul ("+" mul | "-" mul)*
mul = unary ("*" unary | "/" unary)*
unary = ("+" | "-")? primary
primary = num | "(" expr ")"
BNFをこう変更することで-5
, +10
, -( 1 + 2 * 3)
のような表現ができる
比較演算子
今までに出てきた演算子を優先順位の低い順から高い順に書くと次のようになります。
- == !=
- < <= > >=
- + -
- * /
- 単項+ 単項-
- ()
expr = equality
equality = relational ("==" relational | "!=" relational)*
relational = add ("<" add | "<=" add | ">" add | ">=" add)*
add = mul ("+" mul | "-" mul)*
mul = unary ("*" unary | "/" unary)*
unary = ("+" | "-")? primary
primary = num | "(" expr ")"
本文では上のようになっているが、unaryに関しては
unary = ("+" | "-")? unary | primary
でないと、テストケースの- - +10
の場合が失敗するはず。
この部分でのコードジェネレータに関して、
比較演算をx86-64の機械語で表現する必要がある。
- RAX, RDIレジスタの値をcmp命令で比較する
- cmp演算の比較結果はフラグレジスタという特殊なレジスタに入れられる
- set◯◯命令でフラグレジスタから知りたい値をALレジスタに入れる。
- ALレジスタはRAXレジスタの下位8ビットのエイリアスであり、比較結果を使いたいのでRAXの上位56ビットをmovzb命令でゼロクリアする
変数の実装
-
それほど難しくない
-
変数を表すトークンTK_IDENTの導入
- 変数は小文字アルファベットからなるする
- トークナイザは簡単で、アルファベットが続く限り、文字列を進めて、アルファベットで無くなるまで読めばいい
-
パーサが若干ややこしいが、変数として扱うには、変数を探せる必要がある
- 変数を格納するリスト
LVar *locals
を導入 - find_lvar関数で変数を探せるようにする
- 後のコードジェネレータの際、変数のスッタク上のオフセット値が必要になるため
- 変数を格納するリスト
-
コードジェネレータ
- 結構複雑で、変数を扱うということは代入演算が必要なわけだが、
a+1 = 5
のような文法は排除したい - 変数の値の扱いは以下の2段階の処理からなる
- 変数のアドレスを計算する
- アドレスから値をロードする
- 以上を踏まえると
- 代入時
- 左辺は変数処理1を行う
- 右辺は今まで通り
- これで、左辺の変数のアドレスと右辺の値がスタックに乗っているはずなので、値をストアする処理をする
- 変数の参照時
- 変数処理1, 2を行う
- 代入時
- 結構複雑で、変数を扱うということは代入演算が必要なわけだが、
case ND_LVAR:
gen_lval(node); // 変数処理1
// 変数処理2
printf(" pop rax\n");
printf(" mov rax, [rax]\n");
printf(" push rax\n");
return;
case ND_ASSIGN:
gen_lval(node->lhs); // 変数処理1
gen(node->rhs); // 右辺値を計算
// アドレスに値をストア
printf(" pop rdi\n");
printf(" pop rax\n");
printf(" mov [rax], rdi\n");
printf(" push rdi\n");
return;
こんなテストケースを追加した。
assert 3 'a=3;'
assert 3 'a=3; a;'
assert 7 'a=3; z=5; c=7;'
assert 8 'a=3; z=5; a+z;'
assert 8 'a=3; z=5; b=a+z;'
assert 5 "foo = 5;"
assert 8 "
bar = 8;
bar;
"
assert 10 "
foo = 4;
bar = 6;
foo + bar;
"
return文
-
トークナイザ
- TK_RETRUNを追加
- starts_with(p, "return")で良さそうだが、"returnx"をreturnとして解釈して欲しくないので、starts_with(p, "return") && is_alnum(p[6])でreturnを解釈する
-
パーサ
- 文法の修正
stmt = expr ";"
| "return" expr ";"
* ND_RETURNの追加
- コードジェネレータ
- そのまま生成
case ND_RETURN:
gen(node->lhs);
return;
- テストケースを追加
assert 4 "return 4;"
assert 5 "foo = 5; foo;"
assert 8 "
bar = 8;
return bar;
"
assert 10 "
bar = 8;
return bar + 2;
"
assert 10 "
foo = 4;
bar = 6;
return foo + bar;
"
制御構文の追加
if ... else、while、forといった制御構造を言語に追加します
実装例
BNFは以下のようになります
program = stmt*
stmt = expr ";"
| "if" "(" expr ")" stmt ("else" stmt)?
| "while" "(" expr ")" stmt
| "for" "(" expr? ";" expr? ";" expr? ")" stmt
| ...
...
トークナイザ
- TK_IF, TK_ELSE, TK_WHILE, TK_FORを生成 & トークナイズ
if (starts_with(p, "while") && !is_alnum(p[5])) {
cur = new_token(TK_WHILE, cur, p, 5);
p += 5;
continue;
}
パーサー
- if elseのとき
- if (A) B else C で最大3つ格納する要素があることに注意してパーサーを修正
- Nodeにif else用の変数を追加しても良いが、以下のように対応してもOK
- node->lhs : A
- node->rhs->lhs : B
- node->rhs->rhs : C
- 実装例
if (consume_if()) {
Node *node = new_node(ND_IF, NULL, NULL);
expect("(");
node->lhs = expr();
expect(")");
node->rhs = stmt();
// if (A) B
// node->lhs = A, node->rhs = B
// if (A) B else C
// node->lhs = A, node->rhs->lhs = B, node->rhs->rhs = C
if (consume_else()) {
Node *else_node = new_node(ND_ELSE, NULL, NULL);
else_node->lhs = node->rhs;
else_node->rhs = stmt();
node->rhs = else_node;
}
return node;
}
- for のとき
- for(A;B;C) Dで最大4つの要素が必要
- if elseのときと同様に以下のようにすればOK
- node->lhs->lhs : A
- node->lhs->rhs : B
- node->rhs->lhs : C
- node->rhs->rhs : D
- 実装例
if (consume_for()) {
// for (A; B; C) D
Node *for_node_left = new_node(ND_FOR_LEFT, NULL, NULL);
Node *for_node_right = new_node(ND_FOR_RIGHT, NULL, NULL);
expect("(");
if (!consume(";")) {
for_node_left->lhs = expr(); // A
expect(";");
}
if (!consume(";")) {
for_node_left->rhs = expr(); // B
expect(";");
}
if (!consume(")")) {
for_node_right->lhs = expr(); // C
expect(")");
}
for_node_right->rhs = stmt(); // D
Node *node = new_node(ND_FOR, for_node_left, for_node_right);
return node;
}
- whileのとき
- while(A) Bなので、nodeのrhsとlhsにAとBを追加すればOK
- 特別なことはない
コードジェネレータ
- 本文に詳しく書いているので、その通りに実装してOK
jump命令時のlabel_countに対応
- .LendXXXとしていたlabelを.Lend001のようにして対応
- グローバル変数としてlabel_countを定義し、適宜インクリメントして一意性を担保
実装例
ブロック
-
実装例
-
BNFの更新
stmt = expr ";"
| "{" stmt* "}"
トークナイザ
- 更新の必要はない
パーサー
- NodeKindにND_BLOCKの追加
- "{"を読み、"}"を読むまでstmtを読み、追加し続ける
- 実装例
if (consume("{")) {
Node *node = new_node(ND_BLOCK, NULL, NULL);
int stmt_count = 0;
while (!consume("}")) {
node->block = realloc(node->block, sizeof(Node *) * (stmt_count + 1));
node->block[stmt_count] = stmt();
stmt_count += 1;
}
node->block_count = stmt_count;
return node;
}
コードジェネレータ
- パーサーで追加したnode->blockを前から順番に生成
- gen()の後にスタックに値が1つ残っているので、popを忘れない!
- 実装例
case ND_BLOCK:
for (int i = 0; i < node->block_count; i++) {
gen(node->block[i]);
printf(" pop rax\n");
}
return;
引数なし関数呼び出し
引数ありの関数呼び出し
-
実装例
-
引数は関数呼び出しの際にレジスタに移されるが、レジスタの使い方には決まりがある
- 参考:https://th0x4c.github.io/blog/2013/04/10/gdb-calling-convention/
- RDI, RSI, RDX, RCX, R8, R9の順番
- 6つ以上はきっとスタックに積まれるだろうが、本文では一旦気にしなくてよいらしい
コードジェネレータ
- 関数呼び出し(call命令)前に、引数をレジスタに積む
case ND_FUNCCALL:
for (int i = 0; i < node->arg_count; i++) {
gen(node->args[i]);
}
for (int i = node->arg_count - 1; i >= 0; i--) {
printf(" pop %s\n", argreg[i]);
}
printf(" call %s\n", node->funcname);
printf(" push rax\n");
return;
単項&と単項*
"暗黙の変数定義を廃止して、intというキーワードを導入する" & "ポインタ型を導入する" & "ポインタの加算と減算を実装する"
sizeof演算子
配列を実装する
- intを4byteとして処理するような変更も含めた
- メモリからの呼び出しと保存時に4byte(32bit)のレジスタを使うように変更
- 関数の引数からの読み込みも今まで64bitレジスタを使っていたが、適切に32bitレジスタから読むように変更
- 実装例
配列の添字を実装する
- 本文には簡単と書いていたが、いろいろ実装する上で問題が起きた
- まず、ポインタ演算をAST構築時(add内)にていじっていたわけだが、それより下の階層(primary)でa[1]のような表現をサポートする必要があり、これは本文に従うと*(a+1)としてASTを作るわけだが、そうすると、ここでまた、ポインタの演算が登場してしまう。対応は3通りくらいあったが、最終的には本家chibiccを参考にした.
- ポインタの演算時にASTを適切に変更するような処理を関数にまとめて現状のままいく
- この場合はpointerの加減算はparser内で処理することになる
- nodeに型をつける段階(add_type)に同時に木を書き換える処理をする
- (本家) ポインタの加減算は「指し示す要素の大きさ倍して足す」というシンプルな処理なのでgeneratorで処理する
- ポインタの演算時にASTを適切に変更するような処理を関数にまとめて現状のままいく
- どれにするか迷ったが、1は再帰処理の中に組み込まれて煩雑になりそうだったので、却下。
- 2か3で結局本家を参考に3を選んだが、tokenize -> construct AST -> add_type -> reshape AST -> generatorの形にするような形でも良かったかなと思う
- 実装例