構造体の値渡し・値返し — 呼び出し規約を自分で決める
今日のゴール
int sum(struct Point p) のように構造体を値で受け取り、
struct Point make(int x, int y) のように構造体を値で返せるようにする。
S3 の q = p; は「レジスタに載らないものを、同じ関数の中で運ぶ」話だった。
この回はそれを関数の境界をまたいで運ぶ。境界をまたいだ瞬間、
言語仕様のどこにも書かれていない取り決めが必要になる ——
どちらがコピーするのか。戻り値の置き場を誰が用意するのか。
これが呼び出し規約であり、この回で自分で決めるものである。
この回の位置づけ
| 項目 | 内容 |
|---|---|
| 必須の前提 | コマ12(構造体)まで、および S3(構造体の代入)。完了条件のうち golden.py は完成した final/mycc.py を土台に fixed17 を回すので、やり切るにはコマ15 も要る |
| 推奨の前提 | — |
| 改変しない | mycc.py、scaffold/(lexer.py・parser.py を含む)、ラッパー langcc.py(配布済み・完成品) |
| 編集する | byval.py |
| 完了条件 | check.py の全 Step が PASS になり、golden.py が値渡し・値返しのテストの通過と fixed17 全通を報告する |
| コマ数 | 1 |
| 仕様との関係 | 仕様の外側。language_spec.md は param ::= scalar_type IDENT・ret_type ::= scalar_type \| 'void' と定めて struct 値の実引数・戻り値を構文レベルで禁じている。追加する構文と意味論は下記の「L5 が追加する仕様」節が定義する |
| 備考 | 言語機能発展シリーズ。L 系列で最も重い回で、L3(可変長引数の定義側)と並ぶ。コード生成が呼び出し規約そのものに踏み込む |
この回は L 系列で最も重い。
字句は 1 文字も変わらないが、構文(param・ret_type の拡張)・
型の伝播(呼び出し式の型が int でなくなる)・コード生成(呼び出し側の一時領域、
呼ばれ側のフレーム複製、戻り値の隠しポインタ)の 3 段すべてに手が入る。
L3 の可変長引数の定義側と同じ重量級だと思ってよい。
S3 を終えてから来ること —— コピーの部品はそこで書いたものを土台にする。
なぜ L 系なのか
language_spec.md は struct 値の実引数・戻り値を、意味論ではなく構文で禁じている。
param ::= scalar_type IDENT /* struct 値は書けない */
ret_type ::= scalar_type | 'void' /* struct 値は書けない */
scalar_type の 'struct' IDENT stars は stars を必須にしているので、
struct Point *p は書けても struct Point p は仮引数に書けない。
仕様はこれを「この構成により次の制約が構文レベルで決まる」と明記している ——
つまり意図的な設計であって、実装の取りこぼしではない。
だからこの回は「仕様と実装のずれを埋める」(S 系)ではなく、 「仕様が対象外と決めた機能に、意味を自分で決めて足す」(L 系)にあたる (発展課題の索引の「S と L の判定基準」節)。 しかも足す意味の中身が呼び出し規約なので、L 系の中でも特殊である —— ふつう L 系で決めるのは構文と意味論だが、この回は 「生成コードがどう協調するか」という、言語の外側の約束まで決めることになる。
S3 との関係
S3 は q = p; を実装した。あの回のコピーの本体
(ld/sd・lw/sw・lb/sb でバイト単位に写す部分)は、この回でもそのまま使う。
違うのは、コピーの両端が
- S3: 同じ関数の中の 2 つの左辺値
- L5: 呼び出し側のフレームと呼ばれ側のフレーム
という点だけである。S3 の資料の「発展課題 1・2」に設計だけ置いてあったのが、この回である。
L5 が追加する仕様
構文
language_spec.md の EBNF を 2 か所だけ広げる。
param ::= obj_type IDENT /* L5: scalar_type → obj_type */
ret_type ::= scalar_type | 'void' | 'struct' IDENT /* L5: 最後の1本を追加 */
obj_type は「変数宣言に書ける型」で、すでに struct Point(* なし)を含んでいる。
つまり param は var_decl と同じ型の書き方になる ——
仮引数は局所変数と同じものが書ける、という自然な形にそろう。
ret_type のほうは obj_type にしてしまうと void 単独が書けなくなるので、
'struct' IDENT の 1 本だけを足す。
void 単独の仮引数が書けないこと、struct 値のフィールドが書けないこと
(field_decl ::= scalar_type IDENT ';')は変えない。この回で広げるのは
関数の境界だけである。
意味論
- 仮引数は自分専用の複製である。 呼ばれ側が
p.x = 100;と書いても、 呼び出し元の構造体は変わらない。仮引数は入口を過ぎれば 他の局所変数とまったく同じ扱いになる。 - 戻り値も複製である。 呼ばれ側の局所変数を
returnしたあとで その局所変数がどうなろうと(実際には関数が終わって消える)、 受け取った側の値は変わらない。 - 呼び出し式
f(...)の型はfの戻り値型である。 したがってmake(1, 2).xと書ける(コマ12 までは呼び出し式の型は常にintだった)。 - 評価順序は従来どおり、実引数を左から右へ 1 回ずつである。 struct 値の実引数も 1 回しか評価されない。
L5 を実装しても language_spec.md は変わらない —— 変わるのは
あなたのコンパイラが受理する言語のほうである。
呼び出し規約 —— この回の中心
決めることは 2 つしかない。
| 決めること | 選択肢 |
|---|---|
| struct 値の実引数を、誰がコピーするか | 呼び出し側 / 呼ばれ側 / 両方 / どちらもしない |
| struct 値の戻り値を、どこへ置くか | レジスタに詰める / 呼び出し側が用意した領域へ |
実引数 —— アドレスを渡し、呼ばれ側だけがコピーする
この教材では呼び出し側はコピーしない。渡す式のアドレスを、
引数スロット 1 個にそのまま載せて渡す(&p と同じ機構の流用)。
呼ばれ側が入口で、そのアドレスの中身を自分のフレームへ写す。
呼び出し側: sum(a) → a0 = &a (コピー 0 回)
呼ばれ側: 入口で &a の中身を自分のフレームへ写す (コピー 1 回)
コピーは全体で 1 回である。「呼び出し側がコピーして、その複製のアドレスを渡す」 という素朴な案だと、呼ばれ側から見て「仮引数は自分専用の領域か?」が 呼び出し側の実装に依存してしまう。呼ばれ側だけがコピーすると、 呼ばれ側の中だけを見て意味論が保証できる。
アドレスを取ってよいのか。
この言語で struct 値になれる式は、変数・*p・x.f / p->f・関数呼び出しの結果の
4 つだけである(struct の演算子は存在しない)。前の 3 つはもともと左辺値でアドレスを持ち、
4 つ目もこの回の設計では「呼び出し側フレーム内の一時領域」にあるのでアドレスを持つ。
だから「アドレスを取る」で困る場面が無い。ISO C の一時オブジェクトのような
やっかいな話が出てこないのは、この言語が小さいおかげである。
戻り値 —— 隠しポインタを末尾の追加引数で渡す
戻り値はレジスタ 1 本には載らない。そこで呼び出し側が受け取る領域を自分のフレームに用意し、
そのアドレスを追加の引数として渡す。呼ばれ側は return 式; で
a0 に値を積む代わりに、そのポインタの指す先へ写す。
呼び出し側: q = make(5, 6)
a0 = 5, a1 = 6, a2 = &(自分のフレーム内の一時領域)
call make
→ 一時領域に戻り値が入っている
呼ばれ側: return p; → a2 で渡されたポインタの先へ p を写す
この隠しポインタ(sret と呼ばれる)を、実 ABI は a0 = 先頭に置く。 この教材では末尾に置く。理由は単純で、 「仮引数 i ↔︎ 引数レジスタ ai」という既存の対応表を 1 文字も変えずに済むからである。
一時領域は呼び出し 1 か所につき 1 個、呼び出し側のフレームの中に確保する
(ラッパーが .sret0・.sret1… という名前で用意してある)。
グローバルな共有バッファを 1 個だけ置く実装は、再帰すると
内側の return が外側の領域を踏み潰して壊れる。tests/recurse.c がそこを直接突く。
実 ABI との非互換 —— 隠さずに書いておく
gcc が RV64 で使う本物の呼び出し規約とは、次の 2 点で非互換である。
| 実 RV64 ABI | この教材 | |
|---|---|---|
| struct 値の実引数 | 16 バイト以下ならレジスタ 2 本に詰めて渡す。それを超えるとコピーを作ってアドレスを渡す | 常にアドレスを 1 本で渡し、呼ばれ側がコピーする |
| struct 値の戻り値の隠しポインタ | a0(先頭) | 末尾の追加引数 |
これは手抜きではなく、境界を跨がないから許される単純化である。
lib.h が宣言する標準ライブラリ関数のうち、struct 値を引数または戻り値に取るものは
1 つも無い(struct FILE * はすべてポインタである)。
つまり struct 値の受け渡しは mycc がコンパイルした関数どうしでしか起こらず、
その両側は常に同じコンパイラが同じ規約で吐く。規約が外の世界と食い違っても、
食い違いが観測される場所が存在しない。
逆に言えば、lib.h に struct 値を返す C の関数が 1 つでも入った瞬間、
この単純化は破綻する。「ABI は実装の詳細ではなく契約である」というのは、そういう意味である。
なぜ実 ABI は 16 バイト以下をレジスタに詰めるのか。
struct { int x; int y; } のような小さな構造体は非常に多く、
これをいちいちメモリ経由にすると呼び出しのたびに store と load が挟まる。
2 本のレジスタで渡せれば、メモリに触らずに済む。
速さのために規約が複雑になっている典型例で、
「規約を決める」という仕事が性能設計そのものであることが分かる。
この教材は速さを捨てて単純さを取っている。
引数の上限が 8 個 → 7 個になる
引数レジスタは a0〜a7 の 8 本しかない(この教材は引数をスタック経由で渡さない)。 struct 値を返す関数は隠しポインタで 1 本使うので、ユーザが書ける引数は 7 個までになる。 8 個目を書くと、ラッパーがこう止める。
RuntimeError: 引数が多すぎます(9 個 > 8 個): 'f' — struct 値を返す関数は
隠しポインタで 1 個使うので、ユーザ引数は 7 個まで
tests/args7.c がちょうど上限の 7 個を使っている。
struct 値を返さない関数は従来どおり 8 個まで書ける。
いまのコンパイラに食わせるとどうなるか
tests/byval.c と tests/retval.c を標準トラックのコンパイラに渡すと、
どちらも構文解析で止まる。
構文解析エラー: [line 4] struct 値はここでは使えません(ポインタにする)
構文解析エラー: [line 4] struct 値の戻り値は使えません(ポインタにする)
前者は parse_scalar_type が、後者は parse_program の戻り値型の判定が出している。
どちらも「うっかり」ではなく、仕様どおりに明示的に弾いている。
| 段階 | 追加すること | 担当 |
|---|---|---|
| 字句 | なし | — |
| 構文 | param を obj_type に、ret_type に 'struct' IDENT を足す |
langcc.py |
| 型 | 呼び出し式の型を戻り値型にする(従来は常に int) |
langcc.py |
| フレーム | 隠しポインタの置き場と、戻り値の一時領域を確保する | langcc.py |
| コード生成 | 値渡し・値返しの 5 つの部品 | Step 1〜5 |
値渡し —— 単一コピーの実装
呼び出し側(Step 2)
やることは分岐 1 つである。実引数の型を見て、どちらを呼ぶかを決める。
| 実引数の型 | 生成 | a0 に載るもの |
|---|---|---|
struct Point(struct 値) |
cg.codegen_lval(arg) |
その構造体のアドレス |
それ以外(int・char・ポインタ) |
cg.codegen(arg) |
従来どおり値 |
struct Point *ptr はポインタなので下の段に落ちる
(cg.struct_size('struct Point*') は None を返す)。
「値そのものがアドレス」と「値がレジスタに載らないのでアドレスで代用する」は
生成コードが似ていても意味がまったく違う。ここを取り違えないこと。
呼ばれ側(Step 3)
土台の gen_func は、関数の入口で仮引数レジスタをフレームへ書き出している。
sd a0, -24(s0) ;; 仮引数 p のスロットstruct 値の仮引数では、このスロットに入っているのは 呼び出し側の構造体のアドレスである。そこで、それを読んでから 同じスロットへ中身を写し直す。
ld a0, -24(s0) ;; 呼び出し側が渡したアドレス
addi a1, s0, -24 ;; 自分のフレーム内のスロット
ld a2, 0(a0) ;; ← copy_struct(8 バイト)
sd a2, 0(a1)写し終えるとスロットは本物の構造体になっている。
同じ場所を上書きしているのに壊れないのは、アドレスを先にレジスタへ読んであるからである。
スロットの大きさも足りる —— alloc_local はスロットを 8 バイト境界に切り上げるので、
5 バイトの構造体でもポインタを置く 8 バイトは必ずある。
隠しポインタの退避は、コピーより先に。
copy_struct は a0・a1・a2 を作業に使う。
struct 値を返す関数では隠しポインタも a レジスタに載ってやってくるので、
先にコピーを始めるとそれを踏み潰すことがある。
gen_param_prologue の先頭で sd a{n}, {off}(s0) すること。
check.py の Step 3 はこの順序を直接見ている(順序を逆にしても
ソースによっては動いてしまうので、単体テストで捕まえないと気づきにくい)。
これで tests/byval.c の意味論 —— 呼ばれ側が p.x = 100; と書いても
呼び出し元の a.x は 3 のまま —— が成立する。
コピーはプロローグの 1 回だけで、以降 p はただの局所変数である。
値返し —— 隠しポインタの実装
呼び出し側(Step 4)
ユーザ引数を左から順に積み、最後に戻り先のアドレスを積んでから呼ぶ。
q = make(5, 6); の生成コードはこうなる。
li a0, 5 ;; 第1引数
...push...
li a0, 6 ;; 第2引数
...push...
addi a0, s0, -24 ;; .sret0 = 戻り先。隠しポインタは【末尾】に積む
...push...
ld a0, 16(sp) ;; 積んだものを a0, a1, a2 へ配る
ld a1, 8(sp)
ld a2, 0(sp) ;; ← a2 が隠しポインタ
call make
addi a0, s0, -24 ;; この式の値は .sret0 のアドレス。もう一度載せ直す引数の配置(ld a0, … から call まで)はラッパーの cg.emit_call() がやる。
Step 4 が決めるのは「何を、どの順で積むか」だけである。
最後の addi が要る理由に注意したい。呼び出しから戻ると a0 は壊れている。
一方この式の「値」は構造体、すなわちその一時領域のアドレスなので、
もう一度 a0 に載せ直す必要がある。これがあるおかげで
sum(make(3, 4)) // 戻り値をそのまま実引数に
make(1, 2).x // 戻り値のメンバを直接読む
q = make(5, 6); // 戻り値を代入がどれも「構造体の式はアドレスが a0 に載る」という 1 つの規則で扱える。
呼ばれ側(Step 5)
return 式; は、a0 に値を積む代わりに次の 4 つをこの順でやる。
- 返す式を
codegenする(構造体の式なので a0 にはアドレスが載る) - Step 3 で
.sretpへ退避しておいた隠しポインタを a1 に読む copy_structで a0 → a1 へ写すmv a0, a1—— 戻り値としても、その領域のアドレスを残しておく
隠しポインタを読むのが 1 の後である点が肝心である。
return step(r);(tests/recurse.c)のように返す式自体が関数呼び出しだと、
先に a1 へ読んでおいても呼び出しで壊れてしまう。
「レジスタは呼び出しをまたいで生き残らない、フレームの中は生き残る」という、
コマ7 以来ずっと使ってきた原則がここでも効く。
make の生成コードを実際に見ると、末尾はこうなっている。
addi a0, s0, -48 ;; 返す局所変数 p のアドレス
ld a1, -40(s0) ;; .sretp から隠しポインタを読む
ld a2, 0(a0) ;; copy_struct
sd a2, 0(a1)
mv a0, a1
j .L1実装
| Step | 実装対象 | 内容 |
|---|---|---|
| 1 | copy_struct |
a0 の指す size バイトを a1 の指す先へ写す(S3 の再掲) |
| 2 | gen_arg |
呼び出し側: struct 値の実引数はアドレスを渡す |
| 3 | gen_param_prologue |
呼ばれ側: 入口でフレーム内へ複製する |
| 4 | gen_sret_call |
呼び出し側: 戻り先を確保して隠しポインタを末尾に渡す |
| 5 | gen_struct_return |
呼ばれ側: return で隠しポインタの先へ写す |
Step 1〜3 まで書けば値渡しが、Step 4〜5 まで書けば値返しが動く。
tests/byval.c は Step 3 の時点で通る。区切りがはっきりしているので、
一度に全部を相手にせず、まず値渡しを通してから値返しへ進むとよい。
copy_struct は S3 で書いたものと同じロジックだが、
呼び出し規約が「a0 = 元、a1 = 先、a2 だけ壊す」に切り出してある。
この 1 つを、値渡し・値返し・(ラッパーが持っている)struct 代入の 3 か所から使う。
python3 langcc.py tests/byval.c | grep -A8 "^sum:" # 呼ばれ側の複製が見える
python3 langcc.py tests/retval.c | grep "ld a1, -40" # 隠しポインタを読む行テスト
python3 check.py # Step ごとの単体テスト(未実装は SKIP)
python3 golden.py # 値渡し・値返しのテスト + fixed17| テスト | 確認内容 |
|---|---|
byval.c |
呼ばれ側が仮引数を書き換えても呼び出し元が変わらない。*q も値渡しできる |
retval.c |
戻り値を代入で受け取る。make(1, 2).x でメンバを直接読む。同じ変数へ2回返す |
inout.c |
値で受け取り値で返す。12 バイト(8+4)のコピー |
recurse.c |
再帰。隠しポインタが呼び出しごとに別の領域を指す(共有バッファ実装だと壊れる) |
nested.c |
戻り値をそのまま実引数に渡す。add(...).x |
args7.c |
引数上限 7 個(隠しポインタを足してちょうど 8 本) |
check.py は他の回と違い、出た命令列を小さな RV64 シミュレータで実際に走らせる。
値渡し・値返しの正しさは「コピーが本当に起きたか」「元が無傷か」という
メモリの性質であって、命令の並びを見比べるだけでは確かめきれないからである。
シミュレータ上で仮引数の複製を書き換えて、呼び出し元のバイト列が変わらないことを
直接見ている —— これが「値渡し」の定義そのものである。
発展課題
- 実 ABI に寄せる(隠しポインタを a0 へ): sret を末尾ではなく先頭に移す。 「仮引数 i ↔︎ 引数レジスタ ai」の対応表がどこで 1 つずれるかを数える。 ずれるのは呼ばれ側のプロローグと呼び出し側の引数配置の 2 か所だけのはずである
- 16 バイト以下をレジスタで渡す: 実 ABI に完全に寄せる。 「構造体の式はアドレスが a0 に載る」という、この回で 1 本にそろえた規則が どこで崩れるかを確かめる。崩れる場所の多さが、実 ABI の複雑さの正体である
- struct 値の入れ子フィールド:
field_decl ::= scalar_type IDENT ';'をobj_typeへ広げるとstruct Line { struct Point a; struct Point b; };が書ける。copy_structはそのままで足りるか(足りる。なぜか説明する) - コピーの省略:
return p;のpが局所変数なら、 最初から隠しポインタの先をpの置き場にしてしまえばコピーが 0 回になる (実コンパイラの NRVO)。どの条件でこれが安全か - L3 と並べる(L3 完了者向け): 可変長引数も struct 値も、
「引数レジスタ 8 本」という同じ資源を取り合っている。
両方を実装した関数(
struct Point f(int n, ...))が書けるか考える - Q1 と組み合わせる(Q1 完了者向け):
sum(ptr)のようにstruct Point*をstruct Pointの仮引数へ渡すと、いまは黙って通ってしまう。 どこで弾けばよいか
コラム: 規約は言語の外にある。
この回で決めた 2 つの取り決めは、language_spec.md のどこにも書けない。
「a0 に何を載せるか」は言語の意味ではなく、同じ言語の 2 つの翻訳結果が
どう協調するかの取り決めだからである。だから規約は言語ごとではなく
プラットフォームごとに決まる(RV64 の ABI、x86-64 の System V ABI…)。
そして一度公開された規約は、言語仕様よりも変えにくい ——
仕様は再コンパイルすれば直るが、規約を変えると
すでに配布されたバイナリが全部壊れるからである。
「struct を値で渡す」というたった 1 行の C の機能の裏側に、
これだけの取り決めが積み上がっている。