System V ABI
ABI は、Application Binary Interface の略です。
アプリケーションプログラムとシステムの間での、バイナリインターフェイスを意味します。
データ型のサイズや、関数呼び出しの規約、ELF などのフォーマットで使われる詳細などが定義されています。
この規則に沿うようにバイナリを出力すれば、異なる言語間でも、関数などを同じように扱うことができます。
アプリケーションプログラムとシステムの間での、バイナリインターフェイスを意味します。
データ型のサイズや、関数呼び出しの規約、ELF などのフォーマットで使われる詳細などが定義されています。
この規則に沿うようにバイナリを出力すれば、異なる言語間でも、関数などを同じように扱うことができます。
種類
ABI は、アーキテクチャや OS によって、いくつか種類があります。
Windows と System V では、引数や戻り値として使用されるレジスタなどが異なるので、注意してください。
ここでは、System V AMD64 ABI のみの説明を行います。
Windows x64 ABI | Windows x64 「windows abi」で検索してください。 |
---|---|
System V AMD64 ABI | Unix 系 x64 (Linux, MacOS X など) https://gitlab.com/x86-psABIs/x86-64-ABI |
Windows と System V では、引数や戻り値として使用されるレジスタなどが異なるので、注意してください。
ここでは、System V AMD64 ABI のみの説明を行います。
データ型のサイズ
C 言語のデータ型と、x64 上で扱われるデータサイズの一覧です。
long とポインタ型は、x86 では 32bit、x64 では 64bit になります。
ただし、Windows x64 では、long は 32bit になります。
SSE/AVX などの命令は除いて、アライメント (バイト境界) が揃っていないアドレス位置からのメモリアクセスは可能ですが、速度は落ちます。
C の型 | size | アライメント | AMD64 |
_Bool | 1 | 1 | boolean 0 で false、それ以外は true |
---|---|---|---|
char signed char | 1 | 1 | 符号付き 1byte |
unsigned char | 1 | 1 | 符号なし 1byte |
signed short | 2 | 2 | 符号付き 2byte |
unsigned short | 2 | 2 | 符号なし 2byte |
signed int enum | 4 | 4 | 符号付き 4byte |
unsigned int | 4 | 4 | 符号なし 4byte |
signed long (LP64) | 8 | 8 | 符号付き 8byte |
unsigned long (LP64) | 8 | 8 | 符号なし 8byte |
signed long (ILP32) | 4 | 4 | 符号付き 4byte |
unsigned long (ILP32) | 4 | 4 | 符号なし 4byte |
signed long long | 8 | 8 | 符号付き 8byte |
unsigned long long | 8 | 8 | 符号なし 8byte |
__int128 signed __int128 | 16 | 16 | 符号付き 16byte |
unsigned __int128 | 16 | 16 | 符号なし 16byte |
ポインタ | |||
any-type * (LP64) any-type (*)() (LP64) | 8 | 8 | 符号なし 8byte |
any-type * (ILP32) any-type (*)() (ILP32) | 4 | 4 | 符号なし 4byte |
浮動小数点数 | |||
_Float16 | 2 | 2 | 16bit (IEEE-754) |
float | 4 | 4 | 単精度 (IEEE-754) |
double | 8 | 8 | 倍精度 (IEEE-754) |
__float80 long double | 16 | 16 | 80-bit 拡張 (IEEE-754) |
__float128 long double | 16 | 16 | 128-bit 拡張 (IEEE-754) |
固定浮動小数点数 | |||
_Decimal32 | 4 | 4 | 32bit BID (IEEE-754R) |
_Decimal64 | 8 | 8 | 64bit BID (IEEE-754R) |
_Decimal128 | 16 | 16 | 128bit BID (IEEE-754R) |
パック | |||
__m64 | 8 | 8 | MMX, 3DNow! |
__m128 | 16 | 16 | SSE |
__m256 | 32 | 32 | AVX |
__m512 | 64 | 64 | AVX-512 |
long とポインタ型は、x86 では 32bit、x64 では 64bit になります。
ただし、Windows x64 では、long は 32bit になります。
SSE/AVX などの命令は除いて、アライメント (バイト境界) が揃っていないアドレス位置からのメモリアクセスは可能ですが、速度は落ちます。
構造体と共用体
構造体 (struct) と共用体 (union) については、先頭のメンバから順に、メモリの上から下に配置していく形になります。
また、各メンバは、それぞれの型ごとに、適切な位置にアライメントする必要があります。
例えば、int は4バイト境界、long は8バイト境界にする必要があります。
先頭のメンバの位置を 0 として、各メンバの位置が、そのデータ型に応じたアライメント数の、倍数の位置になるようにする必要があります。
位置がアライメントに合わない場合は、そのメンバの前に、余白のバイトが追加されます (内容は未定義)。
また、構造体/共用体の全体のサイズは、すべてのメンバの、最大のアライメント数の倍数である必要があり、サイズが合わない場合は、終端に余白が追加されます。
なお、構造体/共用体のデータを、実際にメモリ上に格納する時は、先頭位置のアドレスが、メンバ内の最大のアライメント数に合うように、メモリ位置を調整する必要があります。
各メンバのアライメントは問題ありませんが、全体サイズは 9 byte です。
最大のアライメントは 8 (long) であるため、全体サイズは 8 の倍数にする必要があります。
よって、終端に7バイトの余白を追加して、16 byte になります。
また、各メンバは、それぞれの型ごとに、適切な位置にアライメントする必要があります。
例えば、int は4バイト境界、long は8バイト境界にする必要があります。
先頭のメンバの位置を 0 として、各メンバの位置が、そのデータ型に応じたアライメント数の、倍数の位置になるようにする必要があります。
位置がアライメントに合わない場合は、そのメンバの前に、余白のバイトが追加されます (内容は未定義)。
また、構造体/共用体の全体のサイズは、すべてのメンバの、最大のアライメント数の倍数である必要があり、サイズが合わない場合は、終端に余白が追加されます。
なお、構造体/共用体のデータを、実際にメモリ上に格納する時は、先頭位置のアドレスが、メンバ内の最大のアライメント数に合うように、メモリ位置を調整する必要があります。
例1
typedef struct { char a; int b; short c; long d; } data; size: 24 byte --------------- 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 aa -- -- -- bb bb bb bb cc cc -- -- -- -- -- -- 16 17 18 19 20 21 22 23 dd dd dd dd dd dd dd dd --------------
- int b の位置は、4バイト境界にする必要があります。
char a の直後の位置は 1 のため、3バイトの余白が追加されて、4 の位置に配置されます。 - short c は、現在位置が 8 なので、そのまま続けて配置します。
- long d は、現在位置が 10 なので、8 の倍数にするため、16 の位置に配置します。
よって、6バイトの余白を追加します。
例2
typedef struct { long a; char b; //7byte 余白 } data; sizeof(data): 16 byte
各メンバのアライメントは問題ありませんが、全体サイズは 9 byte です。
最大のアライメントは 8 (long) であるため、全体サイズは 8 の倍数にする必要があります。
よって、終端に7バイトの余白を追加して、16 byte になります。
関数呼び出し
関数内で退避するレジスタ
RBX, RBP, R12〜R15 のレジスタの値は、関数の呼び出し元に属されているとみなすため、関数が戻る際に、これらのレジスタの値は、呼び出された直後と同じ値になっている必要があります。
そのため、関数内で上記のレジスタを使用して、値を変更する場合は、関数の開始時 (ローカル変数の確保後) に PUSH 命令で値を保存し、関数から戻る時に POP 命令で値を戻す必要があります。
ほかのレジスタについては、関数内で好きなように変更しても構いません。
そのため、関数内で上記のレジスタを使用して、値を変更する場合は、関数の開始時 (ローカル変数の確保後) に PUSH 命令で値を保存し、関数から戻る時に POP 命令で値を戻す必要があります。
ほかのレジスタについては、関数内で好きなように変更しても構いません。
RFLAGS
RFLAGS レジスタの方向フラグ (DF) は、関数を呼ぶ直前と、関数から戻る時は、常に 0 になっている必要があります。
つまり、関数が呼び出された時、DF は常に 0 になっていることが保証されます。
関数内で DF フラグを変更した場合は、0 に戻す必要があります。
つまり、関数が呼び出された時、DF は常に 0 になっていることが保証されます。
関数内で DF フラグを変更した場合は、0 に戻す必要があります。
MMX/x87
関数に入った時は、x87 モードであるとみなされます。
よって、関数が呼び出された時は、x87 レジスタが通常通り使用できる状態であることが必要になります。
具体的には、関数内で MMX レジスタを使用する場合、関数から戻るか、別の (規約に従う) 関数を呼び出す前に、EMMS 命令を実行して、x87 レジスタがすべて使用できる状態にする必要があります。
x87 コントロールワードレジスタの、丸めや例外マスクの設定は、関数内で変更される場合、呼び出し元の状態に戻すようにします。
よって、関数が呼び出された時は、x87 レジスタが通常通り使用できる状態であることが必要になります。
具体的には、関数内で MMX レジスタを使用する場合、関数から戻るか、別の (規約に従う) 関数を呼び出す前に、EMMS 命令を実行して、x87 レジスタがすべて使用できる状態にする必要があります。
x87 コントロールワードレジスタの、丸めや例外マスクの設定は、関数内で変更される場合、呼び出し元の状態に戻すようにします。
関数のスタックフレーム
スタックフレームは、以下のように形成されます。
--------- (ローカル変数領域) --------- RBP RBP (呼び出し元の RBP) --------- RBP + 8 RIP (戻る位置) --------- RBP + 16 ↑この位置を揃える 引数1,2... <余白> ---------
引数領域の境界合わせ
引数領域の先頭位置のアドレスは、16 byte 境界に揃える必要があります。
ただし、__m256 または __m512 の引数がスタック内で渡される場合は、32 byte または 64 byte 境界となります。
(スタックに渡される引数のうち、すべての型の最大アライメントが適用されます)
つまり、引数をスタックに格納した後、CALL 命令を呼び出す直前の時点で、RSP のアドレスは常に 16 の倍数になっている必要があります。
CALL 命令を呼び出す直前の RSP 値は、引数の先頭の値のアドレスとなります。
この位置を揃えるということは、各引数のアライメントを揃えるということです。
CALL 命令で関数が呼び出された直後は、RIP の値がスタックに入っているので、-8 byte。
そこから PUSH RBP を行うと、-8 byte で、RSP は再び 16 byte 境界になります。
その後、ローカル変数領域を割り当てた後は、RBX などの退避する必要があるレジスタを PUSH します。
また、関数内で、規約に従う関数を呼び出す場合は、CALL 命令直前の RSP を 16 byte 境界にしておく必要があるため、現在の RSP アドレスがどの境界にあるかを把握しておく必要があります。
ただし、__m256 または __m512 の引数がスタック内で渡される場合は、32 byte または 64 byte 境界となります。
(スタックに渡される引数のうち、すべての型の最大アライメントが適用されます)
つまり、引数をスタックに格納した後、CALL 命令を呼び出す直前の時点で、RSP のアドレスは常に 16 の倍数になっている必要があります。
CALL 命令を呼び出す直前の RSP 値は、引数の先頭の値のアドレスとなります。
この位置を揃えるということは、各引数のアライメントを揃えるということです。
CALL 命令で関数が呼び出された直後は、RIP の値がスタックに入っているので、-8 byte。
そこから PUSH RBP を行うと、-8 byte で、RSP は再び 16 byte 境界になります。
その後、ローカル変数領域を割り当てた後は、RBX などの退避する必要があるレジスタを PUSH します。
また、関数内で、規約に従う関数を呼び出す場合は、CALL 命令直前の RSP を 16 byte 境界にしておく必要があるため、現在の RSP アドレスがどの境界にあるかを把握しておく必要があります。
関数の引数
引数を渡す手順
関数に渡す引数の値は、第1引数から順に、値のタイプに応じて、レジスタまたはスタックに格納していきます。
PUSH 命令を使って、値をスタックに格納する場合は、スタックに格納する一番最後の引数から順に格納していきます。
(メモリ上では、先頭から順に並ぶ形になります)
複数の引数でタイプが混在している場合は、先頭から順に、それぞれのタイプごとに、値をレジスタに割り当てていきます。
割り当てられるレジスタがなくなった場合は、スタックに格納します。
PUSH 命令を使って、値をスタックに格納する場合は、スタックに格納する一番最後の引数から順に格納していきます。
(メモリ上では、先頭から順に並ぶ形になります)
- 全体を2つまでの数値として扱えない構造体データ (細かい分類あり)、または、アライメントされていないメンバが含まれる構造体などは、スタックに値を格納します。
- 値が整数またはポインタの場合、RDI, RSI, RDX, RCX, R8, R9 の順で、使用可能な次のレジスタに引数をセットします。
- 浮動小数点数 (float, double) の場合、XMM0〜XMM7 (128bit レジスタ) の順で、使用可能な次のレジスタに引数をセットします。
なお、x64 では、浮動小数点数は、SSE 命令を使って演算されます。 - レジスタに格納できない引数は、スタックに値を格納します (一つの値は 8 byte 単位)。
複数の引数でタイプが混在している場合は、先頭から順に、それぞれのタイプごとに、値をレジスタに割り当てていきます。
割り当てられるレジスタがなくなった場合は、スタックに格納します。
void func(int a, double b, short c, float d, int e[2]); RDI : a XMM0 : b RSI : c XMM1 : d RDX : e (ポインタ)
スタックへの格納の仕方
スタックに引数を格納する必要がある場合は、PUSH 命令を使わずに、MOV 命令で値をセットした方が、格納しやすくなります。
理由は、引数領域先頭のアライメントを 16 byte に合わせる必要があるのと、先頭の引数から順に格納するほうが扱いやすいからです。
まずは、引数を格納する前に、RSP から引数領域の全体サイズ分を減算しておきます。
この場合、減算後の RSP のアドレスが、16 byte (または必要なアライメント数) の境界に合うように、サイズを調整する必要があります。
これで、スタックに引数領域を確保することができ、アライメントに必要な余白も調整することができます。
その後、MOV 命令を使って、[RSP + N] の位置に、各引数の値を格納していきます (引数先頭のオフセットは 0 です)。
値のサイズが 64bit 以下でも、一つの値は常に 8 byte 単位になるので、注意してください。
MOV 命令で値を格納する場合は、64bit に符号拡張する必要はなく、任意のサイズで格納することができます。
引数を読み込む関数側では、引数の実際の定義サイズで値が扱われるため、64bit 中の余分なバイトの値を気にする必要はありません。
理由は、引数領域先頭のアライメントを 16 byte に合わせる必要があるのと、先頭の引数から順に格納するほうが扱いやすいからです。
まずは、引数を格納する前に、RSP から引数領域の全体サイズ分を減算しておきます。
この場合、減算後の RSP のアドレスが、16 byte (または必要なアライメント数) の境界に合うように、サイズを調整する必要があります。
これで、スタックに引数領域を確保することができ、アライメントに必要な余白も調整することができます。
その後、MOV 命令を使って、[RSP + N] の位置に、各引数の値を格納していきます (引数先頭のオフセットは 0 です)。
値のサイズが 64bit 以下でも、一つの値は常に 8 byte 単位になるので、注意してください。
MOV 命令で値を格納する場合は、64bit に符号拡張する必要はなく、任意のサイズで格納することができます。
引数を読み込む関数側では、引数の実際の定義サイズで値が扱われるため、64bit 中の余分なバイトの値を気にする必要はありません。
sub rsp, 16 ; 引数領域の確保 mov dword [rsp], 1 ; 第 N 引数 (8byte単位、32bit 数値) mov byte [rsp+8], 2 ; 第 N+1 引数 (8byte単位、8bit 数値) call func add rsp, 16 ; 引数領域を削除 -------- RSP (<- 境界合わせの対象) 01 00 00 00 ?? ?? ?? ?? -------- RSP+8 02 ?? ?? ?? ?? ?? ?? ?? -------- 境界合わせのためのパディング (必要なら) --------
構造体
引数に構造体の値を直接指定する場合は、少々ややこしくなります。
基本的に、構造体全体のデータを、メモリ上の 8 byte 単位で分割し、そこに含まれるメンバのタイプを、一つのタイプに分類して、結果として、一つの 8 byte 整数、または浮動小数点数として、通常の引数と同じように扱います。
構造体全体を、2つの 8 byte 値で扱うことが出来る場合、(使用可能なレジスタがあれば) レジスタに格納されます。
2つの 8 byte 値で扱えない場合は、構造体全体がスタックに格納されます。
構造体に関しては、色々と細かい分類がありますが、とりあえず簡単な例だけ説明しておきます。
メンバはすべて整数なので、構造体全体を一つの 64bit 整数値として扱い、RDI レジスタに値が格納されます。
引数を読み込む側では、一度 RDI の値をメモリに格納してから、各メンバの値を参照する形になります。
基本的に、構造体全体のデータを、メモリ上の 8 byte 単位で分割し、そこに含まれるメンバのタイプを、一つのタイプに分類して、結果として、一つの 8 byte 整数、または浮動小数点数として、通常の引数と同じように扱います。
構造体全体を、2つの 8 byte 値で扱うことが出来る場合、(使用可能なレジスタがあれば) レジスタに格納されます。
2つの 8 byte 値で扱えない場合は、構造体全体がスタックに格納されます。
構造体に関しては、色々と細かい分類がありますが、とりあえず簡単な例だけ説明しておきます。
例1
以下の場合、構造体のサイズは、余白を含めて 8 byte です。メンバはすべて整数なので、構造体全体を一つの 64bit 整数値として扱い、RDI レジスタに値が格納されます。
//sizeof(data) = 8 typedef struct { char a,b; //aa bb -- -- int c; } data; void func(data d) { //d -> RDI (0xCCCCCCCC_????BBAA) }
引数を読み込む側では、一度 RDI の値をメモリに格納してから、各メンバの値を参照する形になります。
例2
//sizeof(data) = 16 typedef struct { int a,b,c,d; } data; RDI = 0xBBBBBBBB_AAAAAAAA RSI = 0xDDDDDDDD_CCCCCCCC ------------ //sizeof(data) = 16 typedef struct { int a; //4byte余白 double b; } data; RDI = a XMM0 = b ------------ //sizeof(data) = 20 typedef struct { int a,b,c,d,e; } data; 2 つの 8 byte で扱えないため、全体がスタックに格納される
可変数引数
可変数引数を扱う関数の場合は、AL レジスタが隠し引数として使用され、引数で使用される XMM レジスタの数を指定します。
AL の値は、XMM レジスタの数と正確に一致する必要はありませんが、使用される XMM レジスタの数の上限であり、0〜8 の範囲内である必要があります。
※RAX の残りのビット部分は未定義。
全体の引数の数を正確に判断することはできないので、可変数引数を扱う関数の方で、引数の終端を判断する方法を決めておく必要があります。
(引数の値が 0 の場合に終了するなど)
AL の値は、XMM レジスタの数と正確に一致する必要はありませんが、使用される XMM レジスタの数の上限であり、0〜8 の範囲内である必要があります。
※RAX の残りのビット部分は未定義。
全体の引数の数を正確に判断することはできないので、可変数引数を扱う関数の方で、引数の終端を判断する方法を決めておく必要があります。
(引数の値が 0 の場合に終了するなど)
関数の戻り値
関数の戻り値は、以下のように、関数が戻った時の、特定のレジスタに格納されている値を使います。
レジスタが2つあるのは、128 bit 値を2つの 64 bit に分けて渡す場合や、戻り値が構造体の場合などに、2つの 8 byte 値として、値を返す時のためです。
この場合、引数に渡す時と同じように、構造体のデータを分類します。
通常の一つの戻り値であれば、RAX か XMM0 を使います。
なお、2つの 8 byte では扱えない、大きなデータを返す必要がある場合、呼び出し元は、戻り値用のスペースを確保し、そのアドレスを RDI に渡します。
これは第1引数として扱われますが、C 言語などでは、これを非表示の引数とします。
関数が戻る時、RAX には、呼び出し元によって、RDI に渡されたアドレスが返されます。
- 値が整数またはポインタの場合、RAX と RDX を使います。
- 浮動小数点数 (float, double など) の場合、XMM0 と XMM1 を使います。
レジスタが2つあるのは、128 bit 値を2つの 64 bit に分けて渡す場合や、戻り値が構造体の場合などに、2つの 8 byte 値として、値を返す時のためです。
この場合、引数に渡す時と同じように、構造体のデータを分類します。
通常の一つの戻り値であれば、RAX か XMM0 を使います。
なお、2つの 8 byte では扱えない、大きなデータを返す必要がある場合、呼び出し元は、戻り値用のスペースを確保し、そのアドレスを RDI に渡します。
これは第1引数として扱われますが、C 言語などでは、これを非表示の引数とします。
関数が戻る時、RAX には、呼び出し元によって、RDI に渡されたアドレスが返されます。