実践・C コンパイラ演習

構造体の値渡し・値返し — 呼び出し規約を自分で決める

今日のゴール

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 の資料の「発展課題 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 ';')は変えない。この回で広げるのは 関数の境界だけである。

意味論

  1. 仮引数は自分専用の複製である。 呼ばれ側が p.x = 100; と書いても、 呼び出し元の構造体は変わらない。仮引数は入口を過ぎれば 他の局所変数とまったく同じ扱いになる。
  2. 戻り値も複製である。 呼ばれ側の局所変数を return したあとで その局所変数がどうなろうと(実際には関数が終わって消える)、 受け取った側の値は変わらない。
  3. 呼び出し式 f(...) の型は f の戻り値型である。 したがって make(1, 2).x と書ける(コマ12 までは呼び出し式の型は常に int だった)。
  4. 評価順序は従来どおり、実引数を左から右へ 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 つをこの順でやる。

  1. 返す式を codegen する(構造体の式なので a0 にはアドレスが載る)
  2. Step 3 で .sretp へ退避しておいた隠しポインタを a1 に読む
  3. copy_struct で a0 → a1 へ写す
  4. 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 シミュレータで実際に走らせる。 値渡し・値返しの正しさは「コピーが本当に起きたか」「元が無傷か」という メモリの性質であって、命令の並びを見比べるだけでは確かめきれないからである。 シミュレータ上で仮引数の複製を書き換えて、呼び出し元のバイト列が変わらないことを 直接見ている —— これが「値渡し」の定義そのものである。

発展課題

  1. 実 ABI に寄せる(隠しポインタを a0 へ): sret を末尾ではなく先頭に移す。 「仮引数 i ↔︎ 引数レジスタ ai」の対応表がどこで 1 つずれるかを数える。 ずれるのは呼ばれ側のプロローグと呼び出し側の引数配置の 2 か所だけのはずである
  2. 16 バイト以下をレジスタで渡す: 実 ABI に完全に寄せる。 「構造体の式はアドレスが a0 に載る」という、この回で 1 本にそろえた規則が どこで崩れるかを確かめる。崩れる場所の多さが、実 ABI の複雑さの正体である
  3. struct 値の入れ子フィールド: field_decl ::= scalar_type IDENT ';' を obj_type へ広げると struct Line { struct Point a; struct Point b; }; が書ける。 copy_struct はそのままで足りるか(足りる。なぜか説明する)
  4. コピーの省略: return p; の p が局所変数なら、 最初から隠しポインタの先を p の置き場にしてしまえばコピーが 0 回になる (実コンパイラの NRVO)。どの条件でこれが安全か
  5. L3 と並べる(L3 完了者向け): 可変長引数も struct 値も、 「引数レジスタ 8 本」という同じ資源を取り合っている。 両方を実装した関数(struct Point f(int n, ...))が書けるか考える
  6. Q1 と組み合わせる(Q1 完了者向け): sum(ptr) のように struct Point* を struct Point の仮引数へ渡すと、いまは黙って通ってしまう。 どこで弾けばよいか
補足

コラム: 規約は言語の外にある。 この回で決めた 2 つの取り決めは、language_spec.md のどこにも書けない。 「a0 に何を載せるか」は言語の意味ではなく、同じ言語の 2 つの翻訳結果が どう協調するかの取り決めだからである。だから規約は言語ごとではなく プラットフォームごとに決まる(RV64 の ABI、x86-64 の System V ABI…)。 そして一度公開された規約は、言語仕様よりも変えにくい —— 仕様は再コンパイルすれば直るが、規約を変えると すでに配布されたバイナリが全部壊れるからである。 「struct を値で渡す」というたった 1 行の C の機能の裏側に、 これだけの取り決めが積み上がっている。

この資料の原稿を見る