実践・C コンパイラ演習

可変長引数の定義 — 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    # 可変長関数のテスト + fixed17

fixed17 を最後に回すのは、 可変長でない関数のフレームまで大きくしていないかを見るためである。

テスト 確認内容
sum.c 引数の個数を変えて合計する(0個の場合も)
max.c ループで全引数を走査して最大値を取る
named.c 名前つき引数も __arg で読める / 通常の関数が壊れていない

発展課題

  1. R2 の printf を作り直す: printf1(fmt, iarg, sarg) をやめ、 本物と同じ int my_printf(char *fmt, ...) にする。 書式指定子を読むたびに __arg(i) の i を進める
  2. 9個以上の引数: いまは a0〜a7 の8個しか保存していない。 9個目以降は呼び出し側がスタックに積む規約なので、 save area の「続き」としてそこも読めるようにする
  3. va_list 相当: __arg(i) は「何番目か」を呼ぶ側が管理する。 代わりに「次の引数を返す」形(va_arg 相当)にするには、 どこに状態を持てばよいか
  4. 型の問題: いまは全部8バイトとして読んでいる。 char * を渡したときはたまたま動くが、double があったらどうなるか (RV64 の浮動小数点は別のレジスタ fa0〜fa7 を使う)
  5. 無駄を減らす: 可変長でない関数では save area を作っていないことを、 生成コードで確認する。作ってしまうと全関数のフレームが64バイト増える
補足

コラム: なぜ可変長引数は「危険」と言われるのか。 printf("%d %d", 1) と書いても、コンパイラは止めてくれない (引数が足りないので、2つ目にはゴミが読まれる)。 呼ばれる側は「いくつ来たか」を知る方法がなく、 書式文字列や番兵(NULL 終端)など別の手段で教えてもらうしかないからである。 いま作った __arg(i) も、n を超えた i を渡せば save area の外を読む。 C++ の可変長テンプレートや Rust のマクロが、 型と個数を静的に検査する方向へ進化したのは、この弱点が理由である。

この資料の原稿を見る