Open15

Cコンパイラ作成リベンジ

mocmoc

再挑戦

前回の挑戦でセルフホストまで到達できず未だに悔しいので再挑戦する。
なので目標は、セルフホストと独自の構文を追加することを設定する

環境構築

端末情報

  • 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
mocmoc

前回のチャレンジ

コンパイラは

  • トークナイザ

    • 純粋な文字列をTokenにする
    • 予約語なのか数値なのかなどを表現
  • パーサー

    • 構文木を生成する
    • 左から計算しなければならない演算子のことを「左結合」の演算子、右から計算しなければならない演算子のことを「右結合」の演算子といいます。Cでは、代入の=を除いて、ほとんどの演算子は左結合として定義されています。

  • コードジェネレータ

    • パーサーが生成した構文木から機械語を生成する

から構成されることは知っているので、その辺りからはじめる。

mocmoc

四則演算

文法の定義にはBNF(EBNF)を使う。

四則演算のBNF

expr    = mul ("+" mul | "-" mul)*
mul     = primary ("*" primary | "/" primary)*
primary = num | "(" expr ")"

四則演算のできる言語の作成までは一気にすっ飛ばした。

インタープリタとほぼ同じ。
ジェネレータ(機械語生成)の部分でx86でのスタックマシンについて詳しく書かれている。

mocmoc

単項プラス,単項マイナス

expr    = mul ("+" mul | "-" mul)*
mul     = unary ("*" unary | "/" unary)*
unary   = ("+" | "-")? primary
primary = num | "(" expr ")"

BNFをこう変更することで-5, +10, -( 1 + 2 * 3)のような表現ができる

比較演算子

今までに出てきた演算子を優先順位の低い順から高い順に書くと次のようになります。

  1. == !=
  2. < <= > >=
  3. + -
  4. * /
  5. 単項+ 単項-
  6. ()
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の機械語で表現する必要がある。

  1. RAX, RDIレジスタの値をcmp命令で比較する
  2. cmp演算の比較結果はフラグレジスタという特殊なレジスタに入れられる
  3. set◯◯命令でフラグレジスタから知りたい値をALレジスタに入れる。
  4. ALレジスタはRAXレジスタの下位8ビットのエイリアスであり、比較結果を使いたいのでRAXの上位56ビットをmovzb命令でゼロクリアする
mocmoc

変数の実装

  • それほど難しくない

  • 変数を表すトークンTK_IDENTの導入

    • 変数は小文字アルファベットからなるする
    • トークナイザは簡単で、アルファベットが続く限り、文字列を進めて、アルファベットで無くなるまで読めばいい
  • パーサが若干ややこしいが、変数として扱うには、変数を探せる必要がある

    • 変数を格納するリストLVar *localsを導入
    • find_lvar関数で変数を探せるようにする
      • 後のコードジェネレータの際、変数のスッタク上のオフセット値が必要になるため
  • コードジェネレータ

    • 結構複雑で、変数を扱うということは代入演算が必要なわけだが、a+1 = 5のような文法は排除したい
    • 変数の値の扱いは以下の2段階の処理からなる
      1. 変数のアドレスを計算する
      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;
"
mocmoc

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;
"
mocmoc

制御構文の追加

元記事

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
mocmoc

jump命令時のlabel_countに対応

  • .LendXXXとしていたlabelを.Lend001のようにして対応
  • グローバル変数としてlabel_countを定義し、適宜インクリメントして一意性を担保

実装例

mocmoc

ブロック

  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;
mocmoc

引数なし関数呼び出し

引数ありの関数呼び出し

コードジェネレータ

  • 関数呼び出し(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;
mocmoc

配列を実装する

  • intを4byteとして処理するような変更も含めた
    • メモリからの呼び出しと保存時に4byte(32bit)のレジスタを使うように変更
    • 関数の引数からの読み込みも今まで64bitレジスタを使っていたが、適切に32bitレジスタから読むように変更
  • 実装例
mocmoc

配列の添字を実装する

  • 本文には簡単と書いていたが、いろいろ実装する上で問題が起きた
  • まず、ポインタ演算をAST構築時(add内)にていじっていたわけだが、それより下の階層(primary)でa[1]のような表現をサポートする必要があり、これは本文に従うと*(a+1)としてASTを作るわけだが、そうすると、ここでまた、ポインタの演算が登場してしまう。対応は3通りくらいあったが、最終的には本家chibiccを参考にした.
    1. ポインタの演算時にASTを適切に変更するような処理を関数にまとめて現状のままいく
      • この場合はpointerの加減算はparser内で処理することになる
    2. nodeに型をつける段階(add_type)に同時に木を書き換える処理をする
    3. (本家) ポインタの加減算は「指し示す要素の大きさ倍して足す」というシンプルな処理なのでgeneratorで処理する
  • どれにするか迷ったが、1は再帰処理の中に組み込まれて煩雑になりそうだったので、却下。
  • 2か3で結局本家を参考に3を選んだが、tokenize -> construct AST -> add_type -> reshape AST -> generatorの形にするような形でも良かったかなと思う
  • 実装例
mocmoc

グローバル変数を実装する

文字型を実装する & 文字列リテラルを実装する