実践・C コンパイラ演習

構造体の代入 — レジスタに載らないものを運ぶ

今日のゴール

q = p; のように、構造体をまるごとコピーできるようにする。 ポインタ・フィールドを持つ構造体(Line の中の Point *)やポインタ経由 (*dst = *src)も動くようにする。

この回の位置づけ

項目 内容
必須の前提 コマ12(構造体)まで。完了条件のうち golden.py は完成した final/mycc.py を土台に fixed17 を回すので、やり切るにはコマ15 も要る
推奨の前提 —
改変しない mycc.py、scaffold/、ラッパー semcc.py(配布済み・完成品)
編集する structcopy.py
完了条件 check.py の全 Step が PASS になり、golden.py が構造体代入のテストの通過と fixed17 全通を報告する
コマ数 1
仕様との関係 仕様の内側(language_spec.md の対応表がこの構文に付ける「仕様外」区分は、標準トラックの実装対象外という別軸の区分)。language_spec.md は「struct 変数は宣言できるが、struct 値の代入・実引数・戻り値は不可」と定めているのに、実装は q = p; をエラーにせず受理して誤った答えを返す。判定基準どおりの S 系である
備考 意味論発展シリーズの第3回。引数・戻り値の値渡しはこの回では扱わない(発展課題 L5 で実装する)

なぜ S 系なのか

language_spec.md は struct について、こう定めている。

struct 変数は宣言できるが、struct 値の代入・実引数・戻り値は不可 (. によるフィールドアクセスと &s は可)。struct の受け渡しはポインタで行う。

つまり q = p; は、仕様が受け付けないと決めた形である。 ところがいまの実装はこれをエラーにせず、黙って受理して誤った答えを返す (何が起きるかは次節で見る)。仕様が禁じた形を実装が素通しさせている—— これは「無い機能を足す」話ではなく、仕様と実装のずれである。

S 系と L 系は、その回で作る振る舞いの「正解」がどこに書いてあるかで分かれる。 仕様が意味を決めている構文の、仕様と実装のずれを埋めるのが S 系。 仕様が対象外と決めた機能に、意味を自分で決めて足すのが L 系である (発展課題の索引の「S と L の判定基準」節)。 この回は仕様の該当行を引用でき、そこから出発するので S 系にあたる。 ずれの解消の向きは「受け付けない」ではなく「正しくコピーする」側へ倒す ——仕様のこの行は、いずれ改訂の対象になる。

この回は 2026-08-01 に L1_struct から S3_struct へ改名した。 以前は判定が S 系なのに ID が L1 のままという食い違いがあり、それを解消した。 ラッパーも L ファミリの langcc.py から S ファミリの semcc.py に変わっている。

題材としては、コマ12 の資料が「構造体のネスト、構造体代入、グローバル構造体変数は 扱わない」と明示的に先送りした宿題の回収でもある。

注意

引数の値渡し(f(p) で構造体を渡す)と戻り値の値渡しは、この回では扱わない。 どちらも RV64 の呼び出し規約に踏み込む必要があり(16バイト以下はレジスタ2本、 それより大きければ呼び出し側が領域を用意して隠し引数で渡す)、 分量が2倍以上になるためである。 この回ではいままで通り、関数へはポインタで渡す(f(&p))。 値渡し・値返しそのものは、この回を前提とする 発展課題 L5 が実装する (呼び出し規約を自分で決めるところから始める、L 系列で最も重い回である)。

いまはどうなっているか

struct Point p; struct Point q;
p.x = 3; p.y = 4;
q = p;
return q.x + q.y;     // 7 のはず

いまのコンパイラは、これをエラーにせず、間違った答えを返す。

ポインタ・フィールド経由のメンバアクセス(l.a->y)は、実は既に正しく動く。 l.a はただのポインタなので、-> は「ポインタの指す先の y を読む」という、 コマ11までと同じ処理でしかない。 壊れているのは代入(m = l で l.a・l.b という2本のポインタをコピーする部分)だけである。

なぜ壊れるのか — レジスタに載らない

いままでの代入は、こういう形だった。

左辺のアドレスを求める → 退避
右辺の値を a0 に載せる
a0 を左辺のアドレスへ書き込む

「右辺の値を a0 に載せる」——ここが構造体では成り立たない。 Point は8バイト、Line は16バイト、もっと大きい構造体もある。 レジスタ1本に載らないものは、この方式では運べない。

いまの実装は、構造体でも同じ道を通って ld / sd で8バイトだけ写す。 Point(ちょうど8バイト)なら偶然動きそうに見えるが、 型サイズの計算がずれて lw(4バイト)になり、y が写らない。 結果として q.y はゴミになる。

どう直すか — アドレスからアドレスへ

構造体の代入は、値の移動ではなくメモリのコピーである。

左辺の「アドレス」を求める   → a1
右辺の「アドレス」を求める   → a0     ← 値ではない
a0 から a1 へ、size バイト写す

右辺に codegen() ではなく codegen_lval() を使うのが要点である。 コマ7 で学んだ「rvalue と lvalue」の区別が、ここで効いてくる。 構造体の代入では、右辺も lvalue として扱う。

コピーは大きい単位から行う。

  ld a2, 0(a0)      ;; 8バイトずつ
  sd a2, 0(a1)
  ld a2, 8(a0)
  sd a2, 8(a1)
  lw a2, 16(a0)     ;; 残りが4バイト以上なら4バイト
  sw a2, 16(a1)
  lb a2, 20(a0)     ;; 最後の端数は1バイト
  sb a2, 20(a1)
補足

これは memcpy である。 いま書いたループは、libc の memcpy(dst, src, n) がやっていることそのものである。 実際のコンパイラも、構造体の代入を memcpy の呼び出しに変換することが多い (サイズが小さいときは、いまのように命令を並べて展開する)。 「構造体の代入」という言語機能の正体は、メモリのコピーである。

代入式の値は何か

C では代入も式なので、a = (b = c) のように値を持つ。 構造体の代入の場合、その値は「代入された構造体」である。

レジスタには載らないので、この回では左辺のアドレスを a0 に残しておく。 p = q = r; のような連鎖でも、次の代入が codegen_lval でアドレスを求め直すので 辻褄が合う。

実装

Step 実装対象 内容
1 is_struct_assign 左辺の型が構造体かを判定する
2 gen_struct_copy 両辺のアドレスを用意して size バイト写す
python3 semcc.py tests/assign.c | grep -A6 "ld a2"
python3 semcc.py tests/nested.c | grep -c "sd a2"    # 16バイト = 2回

テスト

python3 check.py     # 判定と生成命令列を確認(未実装は SKIP)
python3 golden.py    # 構造体代入のテスト + fixed17

golden.py の後半で回す fixed17 が、 構造体のコピーを足したせいで既存の代入を壊していないことの安全網になる。

テスト 確認内容
assign.c q = p でコピーされ、その後 p を変えても q が変わらない
nested.c l.a = &p(ポインタ・フィールドへの代入)と m = l
(ポインタ2本=16バイトのコピー。コピー後 m.a と l.a は同じ Point を指す)
via_ptr.c *dst = *src(ポインタ経由)

assign.c の「コピー後に元を変えても影響しない」が本質的である。 参照ではなく複製であることを、そこで確かめている。

発展課題

  1. 引数の値渡し・戻り値の値返し: int area(struct Point p) と struct Point make(int x, int y) を書けるようにする。 どちらも「誰がコピーするのか」「戻り値の置き場を誰が用意するのか」という、 言語仕様には書かれていない取り決め —— 呼び出し規約 —— を決める話になる。 ここは独立した回になった。詳しくは 発展課題 L5「構造体の値渡し・値返し」 を見ること (この回で書く gen_struct_copy が、そのまま L5 のコピー部品の土台になる)。 考えるとっかかりだけ挙げておく: 実 RV64 の規約では 16バイト以下の構造体は レジスタ2本に分けて渡し、それより大きければ呼び出し側がコピーを作って アドレスを渡す。戻り値は呼び出し側が領域を用意して、そのアドレスを 隠し引数として渡す(sret と呼ばれる)。 なぜ「戻り値を a0 に載せる」では足りないのか、を先に自分で考えてみるとよい
  2. 大きな構造体: 1KB の構造体を代入すると、命令が何百行も並ぶ。 サイズが一定を超えたら memcpy を呼ぶように変える
  3. グローバル構造体変数: struct Point origin; をグローバルに置けるようにする (.bss のサイズ計算は _struct_defs から引ける)
  4. 構造体の比較: C では p == q は書けない。なぜ代入はできて比較はできないのか
補足

コラム: 「値」の大きさが違うということ。 int の代入も構造体の代入も、C の文法上は同じ = である。 しかし機械語のレベルでは、片方は1命令、もう片方はコピーのループになる。 言語が同じに見せているものが、実装では違う——これはコンパイラを書いて 初めて実感できることの1つである。 値になる大きさをどこまで許すかは言語設計の選択で、 この言語は構造体を「値になる」側に置いた結果、 コンパイラがコピーを生成する義務を負っている。

この資料の原稿を見る