構造体の代入 — レジスタに載らないものを運ぶ
今日のゴール
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 # 構造体代入のテスト + fixed17golden.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 の「コピー後に元を変えても影響しない」が本質的である。
参照ではなく複製であることを、そこで確かめている。
発展課題
- 引数の値渡し・戻り値の値返し:
int area(struct Point p)とstruct Point make(int x, int y)を書けるようにする。 どちらも「誰がコピーするのか」「戻り値の置き場を誰が用意するのか」という、 言語仕様には書かれていない取り決め —— 呼び出し規約 —— を決める話になる。 ここは独立した回になった。詳しくは 発展課題 L5「構造体の値渡し・値返し」 を見ること (この回で書くgen_struct_copyが、そのまま L5 のコピー部品の土台になる)。 考えるとっかかりだけ挙げておく: 実 RV64 の規約では 16バイト以下の構造体は レジスタ2本に分けて渡し、それより大きければ呼び出し側がコピーを作って アドレスを渡す。戻り値は呼び出し側が領域を用意して、そのアドレスを 隠し引数として渡す(sret と呼ばれる)。 なぜ「戻り値を a0 に載せる」では足りないのか、を先に自分で考えてみるとよい - 大きな構造体: 1KB の構造体を代入すると、命令が何百行も並ぶ。
サイズが一定を超えたら
memcpyを呼ぶように変える - グローバル構造体変数:
struct Point origin;をグローバルに置けるようにする (.bssのサイズ計算は_struct_defsから引ける) - 構造体の比較: C では
p == qは書けない。なぜ代入はできて比較はできないのか
コラム: 「値」の大きさが違うということ。
int の代入も構造体の代入も、C の文法上は同じ = である。
しかし機械語のレベルでは、片方は1命令、もう片方はコピーのループになる。
言語が同じに見せているものが、実装では違う——これはコンパイラを書いて
初めて実感できることの1つである。
値になる大きさをどこまで許すかは言語設計の選択で、
この言語は構造体を「値になる」側に置いた結果、
コンパイラがコピーを生成する義務を負っている。