« Xbyakで始めるx86(IA-32)入門(2-2) | メイン | x86カルトクイズ »

x86入門(3) 関数の呼び出し規約

Cからasm,逆にasmからCを呼び出すために必要な手続きを呼び出し規約といいます.たとえば前回puts()を使う際に,文字列のポインタをpushしましたが,そういう手続きのことです.
x86での規約は大きく分けて3種類あり,細かい部分についてはコンパイラ依存であることも多く,注意が必要です.しかし,ほぼすべてのコンパイラで共通である規約は簡単で覚えやすく,しかも移植性が高くなります.計算処理などOSに依存しにくい部分ではその規約のみに従って記述すれば,Windows/Linux/Macで同一のソースとすることができメンテナンスもしやすくなります.

まずは基本の規約(cdecl)をしっかりと使いこなし,必要になってから他のその他の規約を学べばよいでしょう.詳細は呼出規約(Wikipedia)などを参考にしてください.拙文でも多少触れています.

汎用性の高い規約

Cとasmとの間で呼び出しあう関数は次の規約に従ってください.
  • C++での関数宣言にはextern "C"をつける.
  • 引数の型はvoid, int(32bit),またはポインタのみとする.構造体,浮動小数は扱わない.
  • 返り値はvoid, int,またはポインタのみとする.
このルールさえ守って記述すれば大抵のコンパイラやOSで問題なく動作させることができます.

たとえば

double func(double a, double b);

とするよりは

void func(double *out, const double *a, const double *b);

とすれば移植性が高くなるということです(もちろんasmとのやりとりが必要な部分のみです).原理的に後者の方がやや遅くなる可能性がありますが,高速化を目的とした処理では通常,関数内に大きなループが存在するため,この二つの差が問題になるケースはまずありません.逆に,問題になるぐらいならその前後の処理も含めてasmの対象したほうがよい可能性が高いです.

呼び出す側の規約

次に上記規約を満たす関数(func)があったときに,funcをasmから呼び出す手続きについて述べます.asmでは次の規約に従って関数を呼び出してください.
  • funcの引数を右側からpushする.
  • call funcする(Xbyakではcall(int(func));).
  • 引数の数 x 4だけespを大きくする.
  • 関数の返り値はeaxに入っている.void型の場合はeaxの値は不定.
たとえば
int func0(int a, char *b, double *c);

の場合は

push(ポインタcの値);
push(ポインタbの値);
push(aの値);
call(int(func0));
add(esp, 3 * 4); /* 引数が3個なので3 * 4 */

とします.引数がなければpushとaddは省略できます.たとえば

void func();

の場合は

call(int(func));

となります.

呼び出される側の規約

Cから呼び出される関数は次の規約に従ってください.
  • 返り値はeaxに代入する(返り値がvoidならeaxの値は不定).
  • 汎用レジスタのうちebx, esi, edi, ebp, espの値は関数を抜けるときに呼び出されたときの状態に戻す.ecx, edxの値を保存する必要はない.
  • ret();で関数から戻る.
このとき,関数の引数は「esp + 左から引数が何番目にあるか(1オリジン) * 4」のアドレスに格納されています.

関数の引数についてもう少し詳しく説明します.例としてfunc0()を考えましょう.

func0の呼び出し手続きに従ってfunc0が呼ばれたときのスタックの状況を考えます.c, b, aと順にpushされたのでスタックには大きい方から小さい方へc, b, aと並んでいるはずです.ということは

esp + 8 : c
esp + 4 : b
esp + 0 : a

なのでしょうか.実はちょっと違いまして,callを実行したとき,こっそりと実行していた命令の次の命令の先頭アドレスがスタックに格納されています.つまり正解は

esp + 12: c
esp + 8 : b
esp + 4 : a
esp + 0 : 次の命令のアドレス

となります.デバッガで確認してみましょう.

extern "C" void func(int a, int b, int c)
{
}
 
int main()
{
    func(1, 2, 3);
}

をfuncのところでステップ実行してみます.下記はcallを実行する直前でのレジスタとスタックの状況です.スタック領域に1, 2, 3が格納されていることを確認してください.

<asm>
00401B97   push        3
00401B99   push        2
00401B9B   push        1
00401B9D   call        @ILT+1400(func) (0040157d)
00401BA2   add         esp,0Ch
 
<レジスタ>
 EAX = CCCCCCCC EBX = 7FFDF000 ECX = 00000000 EDX = 00371538
 ESI = 00000000 EDI = 0012FF70
 EIP = 00401B9D ESP = 0012FADC EBP = 0012FF80 EFL = 00000212
 
<スタック>
0012FADC  01 00 00 00 02 00 00 00 03 00 00 00 00 00 00 00
0012FAEC  00 00 00 00 00 F0 FD 7F CC CC CC CC CC CC CC CC
0012FAFC  CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC

ここでcallを実行します.

<レジスタ>
 EAX = CCCCCCCC EBX = 7FFDF000 ECX = 00000000 EDX = 00371538
 ESI = 00000000 EDI = 0012FF70
 EIP = 00401B30 ESP = 0012FAD8 EBP = 0012FF80 EFL = 00000212
 
<スタック>
0012FAD8  A2 1B 40 00 01 00 00 00 02 00 00 00 03 00 00 00
0012FAE8  00 00 00 00 00 00 00 00 00 F0 FD 7F CC CC CC CC
0012FAF8  CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC

espの値が4減ってスタック領域にcallの次の命令のアドレス0x00401ba2が格納されたことがわかります.ret()でfunc()を抜けるときに,この値を参照して帰るべき場所を決定します.そのとき自動的にespも4増えています.

今回は関数の呼び出し規約について説明しました.納得するまで何度もデバッガで確認するのがよいと思います.

コメントを投稿

(いままで、ここでコメントしたことがないときは、コメントを表示する前にこのブログのオーナーの承認が必要になることがあります。承認されるまではコメントは表示されません。そのときはしばらく待ってください。)