LoginSignup
18
8

More than 5 years have passed since last update.

x86-64とARM64の可変長引数関数の呼び出し規約

Posted at

背景

呼び出し規約について

呼び出し規約(calling conventions)とは、何か処理(手続きや関数)を呼び出すときに、引数と戻り値をどうやってやりとりするかを定めるものである。呼ぶ側(caller)と呼ばれる側(callee)それぞれが、CPUのレジスタやスタックをどのように利用するかを規定する。基本的にはCPUとOSの組み合わせで定義されるが、言語やコンパイラでも異なることがある。以下ではC言語で説明するが、C言語専用の定義というわけではない。
iPhone 5sが出た時、ARM64の可変長引数関数(variadic functions)の呼び出し規約について調べた。(64bit環境のobjc_msgSendについて調べた)
2018年にWindowsのARM64のデバイスが買えるようになったので改めて調べてみた。

よくある呼び出し規約

典型的な呼び出し規約では、先頭の2~16個の引数をレジスタ、それ以降の引数をスタックで渡す。基本的にはレジスタ渡しのほうが速くて効率的だが、呼び出し規約やコンパイラは少し複雑になる。素朴なコンパイラだと全てスタックで渡したりする。
戻り値は基本はレジスタで返却されるが、構造体などレジスタに収まらないものは、スタック上の特別な領域に格納したりする。(どういう型のときにどうするかといったことを厳密に呼び出し規約で定める)

64bit x86 (AMD64, x86-64)

System V AMD64 ABI

System V AMD64 ABIというものがあるようで、Windows以外の多くのOS(Linux、BSD系、macOS)の呼び出し規約はこれ。
最初の6つの整数またはポインタの引数は汎用レジスタ(RDI, RSI, RDX, RCX, R8, R9)で渡される。最初の8つの浮動小数点数はベクタレジスタ(XMM0~XMM7)で渡される。汎用レジスタとベクタレジスタは独立して使われるので、下記の関数fではaがRDI、bがXMM0で渡される。

int f(int a, double b);

レジスタに収まらなかった引数はスタックで渡される。スタックには引数の後ろの方から積んでいく。スタックに積むとメモリアドレスは小さくなっていくので、メモリレイアウトとしては、引数の順番に並ぶことになる。
可変長引数の場合は少し特殊で、ベクタレジスタを使用した個数がALレジスタに渡される。
可変長引数のcalleeの例として、va_argを使った関数のアセンブリ出力を見てみたところ、まずALレジスタの個数だけベクタレジスタをスタックの専用領域に保存し、va_argで浮動小数点数を取り出す時にそこから取得する、ということをしていた。
va_argで取り出す型によって取り出す場所が違うということは、入れ替えできるのだろうか? ということでやってみると、

printf("%d %d %.2f %.2f\n", 1, 2, 3.0, 4.0);
printf("%d %d %.2f %.2f\n", 3.0, 4.0, 1, 2);

以下のように同じ結果になった。
※ ただし後者はコンパイラには順番が違うと怒られる。

1 2 3.00 4.00
1 2 3.00 4.00

なお、プロトタイプ宣言していない関数を呼び出す場合、その関数が可変長引数を取る関数かどうかは不明なので、ALレジスタがセットされる。
すなわち、きちんとプロトタイプ宣言しないと無駄が生じる可能性がある。

Microsoft x64 calling convention

WindowsはWindows用に呼び出し規約を定めている。UEFIでも使われているらしい。
最初の4つの引数がレジスタで渡せる場合、一つ目の引数がRCXまたはXMM0、二つ目の引数がRDXまたはXMM1、三つめがR8またはXMM2、四つ目がR9またはXMM3で渡される。
整数またはポインタなら汎用レジスタ(RCX, RDX, R8, R9)が、浮動小数点数ならベクタレジスタ(XMM0~XMM3)が使われる。すなわち次のような関数fの場合、引数aはRCXで、bはXMM1で渡される。

int f(int a, double b);

はみ出した分はスタックで渡される。スタックには引数の後ろの方から積んでいく。

関数が可変長引数を取るか、もしくはプロトタイプ宣言していない場合に、ベクタレジスタで渡される浮動小数点数は、汎用レジスタにもセットされる。すなわち

extern int f(double a, ...);
extern int g();
...
f(1.0, 2.0);
g(1.0, 2.0);

とすると、最初の引数がRCXとXMM0に、次の引数がRDXとXMM1の両方にセットされる。
一番目の例のように、関数が可変長引数のときは、固定引数の部分に浮動小数点数を含む場合であっても、このルールが適用される。
Windowsの場合においても、プロトタイプ宣言しないと無駄が生じる可能性がある。

64bit ARM

Procedure Call Standard for the ARM 64-bit Architecture (AAPCS64)

AAPCS64はARM(命令セットの設計元)が定めた標準的な呼び出し規約で、普通はこれを使うようである。
最初の8つの整数またはポインタの引数は汎用レジスタ(r0~r7)に格納される。(r0は一般名称で、アセンブリ言語では64bitでアクセスするときはx0、32bitでアクセスするときはw0と記述する)
最初の8つの浮動小数点数はベクタレジスタ(v0~v7)で渡される。(アセンブリ言語ではd0~d7)
この呼び出し規約では、関数が可変長引数を取るときと取らないときで、レジスタへの割り当ては変わらない。(関数が可変長引数を取らないとき、オプショナルな引数が0個の可変長引数関数と同じであると定められている)
また、プロトタイプ宣言を省略したときもレジスタの割り当てが変わらない。
System V AMD64 ABIと同様、汎用レジスタとベクタレジスタは独立して使われるので、引数を可変長引数で渡すときは、浮動小数点数を何番目に渡したのかはわからない。そのため、calleeではr0~r7とv0~v7を全てスタックに積み直し、va_argで取り出す型によってどこから取り出すかを決めるようである。

AWSにARM64のA1インスタンスができたので試してみたところ、x86-64と同じ結果になった。

printf("%d %d %.2f %.2f\n", 1, 2, 3.0, 4.0);
printf("%d %d %.2f %.2f\n", 3.0, 4.0, 1, 2);
...
1 2 3.00 4.00
1 2 3.00 4.00

ARM64 Function Calling Conventions

AppleはiOSのために独自の呼び出し規約を使っている。これは可変長引数の扱い以外は、だいたいAAPCS64と同じである。(一部簡略化されている)
関数が可変長引数を取るときは、オプショナルな引数は全てスタックに積まれる。一方、プロトタイプ宣言していない関数は、基本的にレジスタ渡しとなる。
この仕様の結果、objc_msgSendの呼び出しにキャストが必須になった。

Microsoft ARM64 ABI

ARM64用のWindowsでは独特の呼び出し規約を使う。可変長引数の扱い以外はだいたいAAPCS64と同じだが、関数が可変長引数を取るときは、最初の8つの引数は常に汎用レジスタになる。一方、プロトタイプ宣言していない関数の場合はベクタレジスタも使われる。

余談

x64 Windowsについて

x64 WindowsではCの呼び出し規約(cdecl)とパスカルの呼び出し規約(stdcall)は同じであるということを今回初めて知った。

ARM64とAArch64について

ARM64とAArch64という単語を見かけるが、どちらも、64bitモードで動作するARMという意味で、特定のOSや呼び出し規約は指さないようである。
LLVMに対してARMが先にAArch64というアーキテクチャ名でcontributeし、そのあとAppleがARM64というアーキテクチャ名でcontributeし、最終的にAArch64として統合された。(参考: http://d.hatena.ne.jp/embedded/20140427/p2)

参考資料

18
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
8