可変長引数の定義 — printf の側に立つ
今日のゴール
int sum(int n, ...) のような可変長引数の関数を、自分で定義できるようにする。
引数レジスタをフレームに退避し、添字で読み出す仕組みを作る。
この回の位置づけ
| 項目 | 内容 |
|---|---|
| 必須の前提 | コマ10(printf の呼び出し)まで。完了条件のうち golden.py は完成した final/mycc.py を土台に fixed17 を回すので、やり切るにはコマ15 も要る |
| 推奨の前提 | — |
| 改変しない | mycc.py、scaffold/(parser.py を含む)、ラッパー langcc.py(配布済み・完成品) |
| 編集する | variadic.py |
| 完了条件 | check.py の全 Step が PASS になり、golden.py が可変長関数のテストの通過と fixed17 全通を報告する |
| コマ数 | 1 |
| 仕様との関係 | 仕様の外側。language_spec.md は可変長引数関数の定義を「外部プロトタイプのみ許可」として外しており、定義を書くと構文解析エラーになる。呼び出し規約に合わせた意味をこの回で決めて足す |
| 備考 | 言語機能発展シリーズ。R2(自前 printf)が printf1(fmt, iarg, sarg) という不格好な形を強いられた、その理由を解消する回でもある |
仕様は「可変長引数関数の定義」を非対応と決めて外している
(「除外機能の一覧」の欄は「外部プロトタイプのみ許可」)。
つまり仕様が意図的に落とした機能を、自分で足す回である
(L2 の複合代入、L1 の p - q と同じ性格の、言語設計そのものへの追加)。
呼ぶのはできるが、定義はできない
コマ10 から printf("x=%d\n", 42) を呼んできた。
呼び出し側は簡単である。引数を a0, a1, … に並べて call するだけで、
個数が可変であることを気にする必要がない。
難しいのは呼ばれる側である。
int sum(int n, ...) {
// 引数がいくつ来たのか? どこにあるのか?
}いまのコンパイラでこれを書くと、構文解析の時点で止まる。
構文解析エラー: [line 5] 可変長 '...' はプロトタイプ宣言でのみ使えます
... を読めないのではない。printf のような外部関数のプロトタイプでは
... を書けるのだから、字句も構文も ... 自体は知っている。
スキャフォールド のパーサは、... の付いた宣言が ; で終わらず本体 { に続いたときだけ、
わざわざこのエラーを出している。
これは実装のサボりではなく、仕様どおりの拒否である。
言語仕様の対応表は「可変長引数関数の定義」の欄を
「外部プロトタイプのみ許可」としている。
printf を呼ぶために ... の宣言は要るが、... の定義は
register save area という呼び出し規約の知識を丸ごと要求するので、
Core プロファイルはそこで線を引いた。
この回でやるのは、その線を自分の手で引き直すことである。
だから今回の追加は、コード生成だけでは済まない。 構文の受理範囲を広げるところから始まる。
構文 — 定義側の ... を通す(ラッパー担当)
| 段階 | 追加すること | 担当 |
|---|---|---|
| 字句 | なし(... は3文字トークンとしてもう読める) |
— |
| 構文 | 定義側の ... を受理し、その関数名を覚える |
langcc.py |
| コード生成 | save area の確保・退避・__arg(i) の読み出し |
Step 1・2・3 |
構文の広げ方は、L2 の複合代入と同じ手を使う。
スキャフォールド の Parser を継承した複製クラスを作り、_parse_func だけを
差し替えたものを sys.modules["parser"] に置く。
あとから読み込まれる mycc.py は from parser import parse で、
何も知らないままこの複製を掴む。
workbook/scaffold/parser.py のファイル自体は1文字も変わらない。
差分は本当に1か所しかない。
# 関数定義
if variadic:
_parse_error("可変長 '...' はプロトタイプ宣言でのみ使えます", self.cur.line) # ← 本家
body = self.parse_func_body()複製側はこの _parse_error を消し、代わりに関数名を VARIADIC_FUNCS に記録して
そのまま本体を読む。プロトタイプ(; で終わる側)の扱いは本家のままにしておく
——printf の宣言は、この回の前から正しく通っていたのだから触る理由がない。
「仕様を広げる」とは、拒否を1つ消すことである。 言語に機能を足すと聞くと、新しい構文規則を書き足す作業を想像しやすい。 しかし実際には、こうしてエラーを出していた行を外すだけで済むことが多い。 仕様の境界は文法の中に散らばった「ここは駄目」の集合として書かれていて、 非対応の機能ほど、その一文だけで表現されている。 逆に言えば、パーサのエラーメッセージを読むと、 その言語がどこに線を引いたのかが分かる。
問題1: レジスタはすぐ消える
関数に入った直後、引数は a0〜a7 に入っている。
しかし、これらはすぐ上書きされる。
sum:
addi sp, sp, -32
...
li a0, 0 ← この時点で第1引数は消えたコマ6 で「引数はプロローグでスタックに保存する」と学んだが、
それは名前のついた引数だけだった。名前のない引数(... の分)は
保存されないまま捨てられている。
そこで、関数の先頭で a0〜a7 を8本まとめてフレームに書き出す。
この領域を register save area と呼ぶ。
sd a0, -24(s0) ← save area の先頭
sd a1, -32(s0)
sd a2, -40(s0)
...
sd a7, -80(s0)「まだ何もしていない」タイミングでしか正しい値が読めないので、 本体の最初の命令として行う必要がある。
問題2: 添字で読めなければ意味がない
保存できても、__arg(3) のように実行時に決まる添字で読めなければ、
ループで引数を回せない。
そこで save area を連続した領域として確保する。
alloc_local を8回続けて呼ぶと、8バイトずつ下がる連続領域になる。
s0 - 24 a0 の値 ← 先頭(base)
s0 - 32 a1 の値
s0 - 40 a2 の値
...
s0 - 80 a7 の値
すると i 番目の引数のアドレスは、掛け算と引き算で求まる。
アドレス = base - i * 8
コマ9 のポインタ添字 p[i](*(p + i) の略記で、内部では
base + i * サイズ を計算している)と同じ計算で、
向きが下向きなだけである。
__arg(i) — 組み込み関数という手
本物の C は va_start / va_arg / va_end というマクロで これを行う。
しかし Core プロファイルには関数形式マクロがないので、この回では
コンパイラが特別扱いする組み込み関数 __arg(i) を用意する。
int __arg(int i); // 宣言だけ書く。実体は要らない
int sum(int n, ...) {
int total;
int i;
total = 0;
for (i = 1; i <= n; i = i + 1) {
total = total + __arg(i); // i 番目の引数
}
return total;
}__arg(i) は関数呼び出しの形をしているが、call は生成されない。
コード生成器が「この名前の呼び出しを見たら、save area からの読み出しに置き換える」
という特別扱いをする。
添字はすべての引数を 0 から数える。
sum(3, 10, 20, 30) なら __arg(0) が n(= 3)、
__arg(1) 〜 __arg(3) が可変長の分になる。
組み込み関数(intrinsic)という考え方。
「関数の顔をしているが、コンパイラが直接コードに置き換えるもの」を
組み込み関数(intrinsic)という。
__builtin_va_arg(gcc)や __builtin_popcount などが実物である。
ライブラリでは書けない処理(レジスタを直接触る、特殊命令を出す)を
言語の枠内で書けるようにするための逃げ道であり、実務でもよく使われる。
呼び出し側は何も変えなくてよい
面白いことに、呼び出し側のコード生成は1行も変えない。
sum(3, 10, 20, 30) は、コマ6 で作った通常の呼び出し
(引数を a0〜a3 に置いて call)のままで正しく動く。
可変長引数が「呼ぶ側は普通、呼ばれる側だけ特別」という設計になっているのは、 まさにこのためである。
実装
| Step | 実装対象 | 内容 |
|---|---|---|
| 1 | reserve_save_area |
8個のスロットを連続して確保する |
| 2 | gen_save_registers |
本体の先頭で a0〜a7 を書き出す |
| 3 | is_arg_builtin / gen_arg_access |
__arg(i) を読み出しに変換する |
構文側(定義側の ... を受理し、その関数名を覚えておく)はラッパーが担当する。
Parser を継承した複製で _parse_func を差し替えているので、
... は捨てられずに VARIADIC_FUNCS に残る。
Step 1〜3 はその集合を見て、可変長の関数だけに save area を作る。
python3 langcc.py tests/sum.c | grep "sd a7" # save area ができているか
python3 langcc.py tests/sum.c | grep -c "call __arg" # 0 のはず(呼び出しは生成されない)テスト
python3 check.py # Step ごとの単体テスト(未実装は SKIP)
python3 golden.py # 可変長関数のテスト + fixed17fixed17 を最後に回すのは、
可変長でない関数のフレームまで大きくしていないかを見るためである。
| テスト | 確認内容 |
|---|---|
sum.c |
引数の個数を変えて合計する(0個の場合も) |
max.c |
ループで全引数を走査して最大値を取る |
named.c |
名前つき引数も __arg で読める / 通常の関数が壊れていない |
発展課題
- R2 の
printfを作り直す:printf1(fmt, iarg, sarg)をやめ、 本物と同じint my_printf(char *fmt, ...)にする。 書式指定子を読むたびに__arg(i)のiを進める - 9個以上の引数: いまは
a0〜a7の8個しか保存していない。 9個目以降は呼び出し側がスタックに積む規約なので、 save area の「続き」としてそこも読めるようにする va_list相当:__arg(i)は「何番目か」を呼ぶ側が管理する。 代わりに「次の引数を返す」形(va_arg相当)にするには、 どこに状態を持てばよいか- 型の問題: いまは全部8バイトとして読んでいる。
char *を渡したときはたまたま動くが、doubleがあったらどうなるか (RV64 の浮動小数点は別のレジスタfa0〜fa7を使う) - 無駄を減らす: 可変長でない関数では save area を作っていないことを、 生成コードで確認する。作ってしまうと全関数のフレームが64バイト増える
コラム: なぜ可変長引数は「危険」と言われるのか。
printf("%d %d", 1) と書いても、コンパイラは止めてくれない
(引数が足りないので、2つ目にはゴミが読まれる)。
呼ばれる側は「いくつ来たか」を知る方法がなく、
書式文字列や番兵(NULL 終端)など別の手段で教えてもらうしかないからである。
いま作った __arg(i) も、n を超えた i を渡せば save area の外を読む。
C++ の可変長テンプレートや Rust のマクロが、
型と個数を静的に検査する方向へ進化したのは、この弱点が理由である。