Cの可変長引数とABIの奇妙な関係


printf に関する以下のツイートが流行っていました。

上のツイートでは割とあっさり説明されていますが、amd64 SysV ABIでこの現象が起こる理由にはもっと深遠なものがあると思うので、可変長引数とからめて説明してみたいと思います。

前提条件として「ABI」「可変長引数」「non-prototyped関数」の知識が必要なのでそこから説明します

ABIとは

ABI (Application Binary Interface) とは、機械語レベルでのインターフェースのことです。

機械語そのもののルールはISA (Instruction Set Architecture) によって規定されていますが、たとえばC言語の「関数呼び出し」などの概念を機械語でどのように表現するかについては規定していません。そのルールを定めたのがABIです。したがって、ISAとABIはおよそ1対多の関係にあります。たとえば、およそ以下のような対応関係があります。

  • x86-64 (ISA)
    • amd64 (ABI)
    • x64 (ABI)
    • x32 (ABI)
  • PowerPC64 (ISA)
    • PowerPC64 V1 (ABI)
    • PowerPC64 V2 (ABI)

「レジスタの用途」「スタックの使い方」「関数呼び出しのルール」「メモリ空間の割り付け」「C言語の型のサイズ・アラインメントと表現」「システムコールの呼び出し方」「OSから起動時に渡ってくる情報のフォーマット」「OS側のローダーの仕様」「バイナリの形式」「OS側の動的リンカーの仕様」「共有ライブラリの形式」「静的リンカーの仕様」「リロケーションの形式」「C++シンボルマングリングの規則」などがABIに含まれることがあります。

Linuxの場合、プラットフォームによらない規定はSystem V ABIのBase Documentという形で提供されていて、プラットフォーム依存の部分をそれぞれのドキュメントで規定する形になっています。

Cの可変長引数

Cの可変長引数は printf で使われていることもあってきわめて身近です。

printf("%d %f\n", 3, 1.2);

ではこの printf はどのように実装されているでしょうか。実はたいていの場合 vfprintf という関数に移譲しているだけのシンプルな関数として実現されます。たとえばmusl libcのprintfは以下のようになっています:

int printf(const char *restrict fmt, ...)
{
    int ret;
    va_list ap;
    va_start(ap, fmt);
    ret = vfprintf(stdout, fmt, ap);
    va_end(ap);
    return ret;
}

これは stdarg.h という標準ライブラリに定義されているマクロを使って、可変長引数 (...) を va_list ap という単一の値にパックしています。ただし、パックといってもメモリをコピーしているのではなく、スタックへのポインタを作成しているだけです。

この va_list という型は va_arg(ap, type) というマクロによって順番に取り出せるようになっています。逆に、それ以外の取り出し方法は提供されていません。とりわけ以下の性質は重要です。

  • 可変長引数または va_list 自体には、型情報はエンコードされていない。
  • 可変長引数または va_list 自体には、引数の個数に関する情報はエンコードされていない。
  • 可変長引数を va_list に変換する処理は、型や引数の個数に依存しない処理である必要がある。

printf 系関数の場合、先頭の fmt 引数にこれらの情報が入っているのでそれが使われています。また、 exec 系関数の場合、型は char* で固定、引数の個数は最後を NULL 終端することで渡しています。

non-prototyped関数

歴史的な事情から、C言語(Not C++)では以下のような関数宣言をすることができます。

int f();

これは引数不明という意味で、引数0個という意味ではありません。 (かわりに、引数0個の場合は int f(void); と書きます。)

可変長引数の単純な実現方法

関数呼び出しの最もシンプルな規約は以下のようなものです。

  • スタックはメモリの上のほうから始まり、メモリの下のほうへ伸長する。
  • f(x, y, z) のような関数を呼ぶときは、z, y, xの順にスタックに積む。 (メモリ上では x, y, zの順に並ぶ)
  • 関数終了後に、呼び出し元がスタックを掃除する。

こうすると固定長引数関数と可変長引数関数を区別する必要はなく、 va_list も単にスタック上へのポインタを算出するだけで取得できます。

この規約に比較的近いのがx86 (32bit) のcdecl呼び出し規約です。スタックの掃除方法は若干異なりますが、引数とスタックの対応関係は上記のものとほぼ同じため、可変長引数について悩むことはあまりありません。

In addition to registers, each function has a frame on the run-time stack. This
stack grows downward from high addresses. Figure 3-15 shows the stack frame
organization.

-- System V Application Binary Interface Intel386™ Architecture Processor Supplement, Fourth Edition, page 36 (retrieved from http://www.sco.com/developers/devspecs/abi386-4.pdf)

レジスタ渡し

いっぽう、効率のことを考えると、なんでもスタック経由で渡すより、できるだけレジスタを使って引数を渡したいと考えることになります。x86 (32bit) ではfastcallなどの呼び出し規約が使われることがあります。「最初の4引数は所定のレジスタを経由して渡し、それよりも沢山必要なときはスタックを経由して渡す」というような形が基本形です。

しかしC言語の標準呼び出し規約として採用するには可変長引数のことを考える必要があります。

「固定長のときはレジスタ渡し、可変長のときはスタック渡しでいいんじゃない?」と思うかもしれませんが、ここでnon-prototyped関数が問題になります。以下のプログラムを考えます。

main.c
int f(); // ヘッダにこれが記載されているとしてもよい

int main(int argc, char *argv[]) {
    f("%d %f\n", 3, 1.2);
    return 0;
}

C言語では各 *.c (翻訳単位) は別々にコンパイルされ、お互いにお互いのことは知りません。つまり、上記 main.c からは f がどのように定義されているかはわからないわけです。

この f は「単に文字列とintとdoubleを受け取る関数」かもしれないし、「printfと同様の関数」かもしれません。ということは、どちらでも動くようにコンパイルできる必要があります

f.c
int f(const char *s, int n, double f) { /* ... */ }
// または
int f(const char *fmt, ...) { /* ... */ }

とはいえ、現代のまともなC言語プログラムであればnon-prototyped関数宣言は使いません。なので、「その気になれば、どちらとも取れるような呼び出しもできる」という形でインターフェースを作ることになります。たとえば……

  • 固定長引数のときは、最初の4個はレジスタ渡し
  • 固定長引数のときは、5個目以降は「4個分のスペースを空けた上で」スタックに入れて渡す
  • 可変長引数のときは、全てスタックに入れて渡す

と定義すればうまくいきます。というのも、可変長か固定長かわからないときは「レジスタにもスタックにも入れて渡す」という手段が取れるからです。

実際のABIでは、可変長引数でもレジスタ渡しはしつつ、呼び出された側でスタックに書き戻して va_list を構成していることが多い(?)ようです。

浮動小数点数

浮動小数点数と整数では演算器が異なるため、効率化のためにレジスタを分ける(汎用レジスタと浮動小数点数レジスタ)のが一般的です。関数呼び出しの際も、適切なレジスタに入れて渡すのがよいことになります。

たとえばamd64 System V ABIでは以下のように定義されています。

Passing Once arguments are classified, the registers get assigned (in left-to-right order) for passing as follows:

  1. If the class is MEMORY, pass the argument on the stack.
  2. If the class is INTEGER, the next available register of the sequence %rdi, %rsi, %rdx, %rcx, %r8 and %r9 is used.
  3. If the class is SSE, the next available vector register is used, the registers are taken in the order from %xmm0 to %xmm7.
  4. If the class is SSEUP, the eightbyte is passed in the next available eightbyte chunk of the last used vector register.
  5. If the class is X87, X87UP or COMPLEX_X87, it is passed in memory.

-- AMD64 ABI Draft 0.99.7, page 20 (retrieved from https://www.uclibc.org/docs/psABI-x86_64.pdf)

PowerPC64 V2では次のように定義されています。

For the OpenPOWER Architecture, it is more efficient to pass arguments to functions in registers rather than through memory. For more information about passing parameters through memory, see Parameter Save Area on page 52. For the OpenPOWER ABI, the following parameters can be passed in registers:

  • Up to eight arguments can be passed in general-purpose registers r3 - r10.
  • Up to thirteen qualified floating-point arguments can be passed in floating-point registers f1 - f13 or up to twelve in vector registers v2 - v13.
  • Up to thirteen single-precision or double-precision decimal floating-point arguments can be passed in floating-point registers f1 - f13.
  • Up to six quad-precision decimal floating-point arguments can be passed in even-odd floating-point register pairs f2 - f13.
  • Up to 12 qualified vector arguments can be passed in v2 - v13.

-- Power Architecture
64-Bit ELF V2 ABI Specification Version 1.1, page https://members.openpowerfoundation.org/document/dl/576

さて、せっかく汎用レジスタと浮動小数点数レジスタが両方あるので、できるなら両方のレジスタを可能な限り使い切りたいと考えるのは自然なことです。つまり、次のようにしたいわけですね。

  • 整数引数(ポインタなども含む)であって、汎用レジスタがまだ余っていたら、一番若い汎用レジスタを使う。
  • 浮動小数点数引数であって、浮動小数点数レジスタがまだ余っていたら、一番若い浮動小数点数レジスタを使う。
  • 余ってなかったらスタックに積む。

ところが、この仕様にすると、可変長引数関数の場合の処理が大変になります。上のような仕様にしておいて va_list をきちんと構築できるようにするには、整数レジスタ + 浮動小数点数レジスタ分の空間を呼び出し側で空けておいて、呼び出された側で va_list を構築する必要がある場合はレジスタをすべてその空間にコピーしなければいけません。引数用の浮動小数点数レジスタは多くなりがちなので、可変長引数関数のパフォーマンス低下が懸念されます。

これについてamd64とPowerPC64 V2では別の回避策を取っています。

amd64の場合

上記の方式は維持しつつ、可変長引数関数(かもしれない関数)を呼ぶ場合は使用した浮動小数点レジスタの個数を暗黙の引数として渡すことにしています。

For calls that may call functions that use varargs or stdargs (prototype-less
calls or calls to functions containing ellipsis (...) in the declaration) %al is used as hidden argument to specify the number of vector registers used. The contents of %al do not need to match exactly the number of registers, but must be an upper bound on the number of vector registers used and is in the range 0-8 inclusive.

-- AMD64 ABI Draft 0.99.7, page 20 (retrieved from https://www.uclibc.org/docs/psABI-x86_64.pdf)

これにより、 %al (%rax の下位8bit) で指定された個数以上の浮動小数点レジスタコピーは行わなくてよくなるという寸法です。

PowerPC64 V2の場合

浮動小数点数引数でも、可変長引数として渡すときは汎用レジスタに入れて渡すように定められています。そのため、浮動小数点数引数には浮動小数点数レジスタと汎用レジスタの両方が割り付けられます。関数が固定長か可変長か不明な場合は、呼び出し側で両方のレジスタに入れておき、固定長か可変長かわかっている場合には、使わないほうのレジスタは空けておくという寸法です。

まとめ

C言語の仕様を満足しつつ、現代のCPUで最大限のパフォーマンスを出すために、ABIレベルでかなり深遠な工夫がなされているということが伝わったでしょうか。また、これでnon-prototyped関数がよくないものだという認識が広まったら嬉しいなと思います。

これは以前筆者が大学でアセンブリを書く演習授業のティーチングアシスタントをしたときに授業準備の一環で調べた内容に基づいています。大まかに間違ったことは言っていないと思いますが、狭い意味での専門家ではなく、また全ての著名なアーキテクチャについて調査したわけではないので怪しい主張もあるかもしれません。何か直すべき点があればコメント等で指摘していただけるとありがたいです。