x86_64における関数のプロローグ・エピローグ
x86_64(x64,amd64)でのCにおける関数呼び出し、リターンの際のスタック、レジスタの使われ方について解説していきます。
環境
使うコード
今回の解説で使うコードは下のようなコードです。
int func2() { return 2; } int func1(int a) { int b = func2(); a = a + b; return a; } int main(void) { int b = func1(1); return 0; }
func2関数では2を返しています。 func1関数ではaを引数に取り、それにfunc2関数の結果を足して返します。 main関数では5を引数としてfunc関数を呼び出し、その返り値をbに代入しています。
アセンブラ上での解説
プロローグ
関数を実行するときの前処理をプロローグといい、その関数を実行するのに必要な情報をスタックに積んでいきます。 実際に上のコードをコンパイルしたものの逆アセンブル結果を見てみましょう。 objdumpコマンドで実行可能ファイルやオブジェクトファイルの逆アセンブル結果を見ることができます。 私はインテル記法で読みたいのでオプションには -M intel とつけています。また、.textセクションだけの結果を取り出しています。
$ gcc main.c -o main $ objdump -M intel -d main -j .text main: ファイル形式 elf64-x86-64 セクション .text の逆アセンブル: ... 000000000000061b <func2>: 61b: 55 push rbp 61c: 48 89 e5 mov rbp,rsp 61f: b8 02 00 00 00 mov eax,0x2 624: 5d pop rbp 625: c3 ret 0000000000000626 <func1>: 626: 55 push rbp 627: 48 89 e5 mov rbp,rsp 62a: 48 83 ec 18 sub rsp,0x18 62e: 89 7d ec mov DWORD PTR [rbp-0x14],edi 631: b8 00 00 00 00 mov eax,0x0 636: e8 e0 ff ff ff call 61b <func2> 63b: 89 45 fc mov DWORD PTR [rbp-0x4],eax 63e: 8b 45 fc mov eax,DWORD PTR [rbp-0x4] 641: 01 45 ec add DWORD PTR [rbp-0x14],eax 644: 8b 45 ec mov eax,DWORD PTR [rbp-0x14] 647: c9 leave 648: c3 ret 0000000000000649 <main>: 649: 55 push rbp 64a: 48 89 e5 mov rbp,rsp 64d: 48 83 ec 10 sub rsp,0x10 651: bf 01 00 00 00 mov edi,0x1 656: e8 cb ff ff ff call 626 <func1> 65b: 89 45 fc mov DWORD PTR [rbp-0x4],eax 65e: b8 00 00 00 00 mov eax,0x0 663: c9 leave 664: c3 ret 665: 66 2e 0f 1f 84 00 00 nop WORD PTR cs:[rax+rax*1+0x0] 66c: 00 00 00 66f: 90 nop ...
main関数内のcall命令で実際にfunc関数に飛んでいることがわかります。 call命令は、その時点でのRIPをスタックに退避(今の場合はcallの次の命令であるmov DWORD ...という命令)し、処理を引数で指定したアドレスに移します。
つまり、call命令は関数の引数など関係なく関数に飛んでいるのです。では、関数の引数はどのように関数に渡されているのでしょうか?
実は関数の引数はレジスタを通して渡されています。call命令の一つ前の命令では、EDIレジスタに5を格納しています。 これはfunc関数への引数へと与えている数字と同じなので引数はediレジスタ(rdiではないのはコンパイラが小さい値であると判断しているからです。事実大きな値を渡すとrdiが使われます。)を通して渡されているようです。
なぜraxやrbxでもなくrdiレジスタなのかというと、仕様でそう決まっているからです。詳しくは説明しませんが、参考資料に挙げたintelのマニュアルに書いてあるので興味があれば見てみてください。
さて引数が無事積まれたわけですが、まだまだやることは残っています。 関数の処理は、スタック上で行われるわけですが、このスタックが今のままだと呼び出し元の関数のものを使ってしまいます。これを防ぐためにプロローグでは、関数フレームというその関数専用のスタック領域を生成します。
上に関数スタック生成時におけるスタック状態の図を示します。図の見方についてですが、rsp(スタックポインタ)、rbp(ベースポインタ)はそれぞれ、現在のフレームの先頭、底を表します。 また、スタックは通常高アドレスから低アドレスに向かって伸びてくことに注意してください。
call命令を実行した後のスタックはa)のようになっています。main関数のフレームの底をrbpが指しており、call命令によってripの値がスタックに書き込まれたのでripがスタックトップにあります。
この状態からfunc関数のフレームを作成していくわけですが、ここで気をつけるのは関数は終わった後にはもとのスタックの状態に戻っている必要があるという点です。 フレームを指し示すのにrspとrbpを使っているので、func関数のフレームを作成するということはこれらのレジスタの値を書き換えるということになりますが、関数終了時にはmain関数のフレームを表すようにレジスタの値を復帰させなくてはいけないということです。
これを実現するためにスタックにrbpの値を退避させておきます。退避する値はmain関数のベースポインタ(図では赤い星印)です。 その後にfunc1関数フレームの底を設定しています。func1関数フレームの底はmain関数フレームの先頭と同じであるのでmov命令を使っています。
この一連の操作を終えると、スタックの状態はb)のようになります。
その後は、func1関数のフレームを伸長するためにsub命令でrspを小さくします。(スタックは低位に向かって伸びていくことに注意) これで関数のプロローグは終わりとなります。
ちなみにfunc2のプロローグを見てみると、sub命令でフレームを作っていないように見えます。これは、その後に呼ばれる関数がないのでフレームを伸ばす必要がないとコンパイラが判断したためでしょう。
エピローグ
関数が終了するときの処理をエピローグといいます。 まずやるべきは返り値のセットです。これもプロローグと同様に汎用レジスタ、具体的にはeaxレジスタ(これも値が大きくなればraxが選択されます)を用います。 実際にmov命令でeaxにebp-14(引数a)の値をコピーしているのが解ると思います。
次に関数フレームの復元です。プロローグのところでも説明したように、関数の実行が終わったら、スタックの状態は呼び出し前と同じにする必要があります。
この処理を実行しているのがleave命令とret命令です。以下にこの2つの命令の動作をまとめます。
命令 | 動作 |
---|---|
leave | rbpをrspにコピーし、rbpにスタックトップをポップする |
ret | スタックトップの値をripにセットし、その値をもとに次の処理に移る |
この2つの動作をまとめると下の図のようになります。
プロローグの図におけるc)がエピローグ前のスタックの招待です。まずはleave命令により、rbpをrspにコピーし、フレームを縮小(サイズは0)させます。これにより、スタックはa)のようになります。
その後にスタックトップ(退避しておいたrbp)をrbpにポップすることでrbpを退避前の状態に復元します。これによりスタックはb)のようになります。
最後にret命令を実行することでcall命令によって対比されていたripが復元され、スタックはc)のように呼び出し前に戻ります。
以上がエピローグの流れになります。
func2関数においてはプロローグ時にフレームが伸長していないのでleaveでなくpop rbpをしていますが、流れはfunc1と変わりません。
おまけ ~引数渡しに使われる汎用レジスタ~
プロローグの説明の際、引数はレジスタを使って渡されると書きましたが、実はこれには限度があります。 インテルのマニュアルによると、レジスタで渡せる引数は最大6つであることがわかります。()
では、7つ以上の引数を渡す関数ではどのような処理を行っているのでしょうか?
以下のfunc3関数を使って検証してみます。
int func3(int a, int b, int c, int d, int e, int f, int g) { a = a + 1; return a; } int main(void) { int b = func3(1, 2, 3, 4, 5, 6, 7); return 0; }
このコードを逆アセンブルした結果が以下になります。
00000000000005fa <func3>: 5fa: 55 push rbp 5fb: 48 89 e5 mov rbp,rsp 5fe: 89 7d fc mov DWORD PTR [rbp-0x4],edi 601: 89 75 f8 mov DWORD PTR [rbp-0x8],esi 604: 89 55 f4 mov DWORD PTR [rbp-0xc],edx 607: 89 4d f0 mov DWORD PTR [rbp-0x10],ecx 60a: 44 89 45 ec mov DWORD PTR [rbp-0x14],r8d 60e: 44 89 4d e8 mov DWORD PTR [rbp-0x18],r9d 612: 83 45 fc 01 add DWORD PTR [rbp-0x4],0x1 616: 8b 45 fc mov eax,DWORD PTR [rbp-0x4] 619: 5d pop rbp 61a: c3 ret 0000000000000649 <main>: 649: 55 push rbp 64a: 48 89 e5 mov rbp,rsp 64d: 48 83 ec 10 sub rsp,0x10 651: 6a 07 push 0x7 653: 41 b9 06 00 00 00 mov r9d,0x6 659: 41 b8 05 00 00 00 mov r8d,0x5 65f: b9 04 00 00 00 mov ecx,0x4 664: ba 03 00 00 00 mov edx,0x3 669: be 02 00 00 00 mov esi,0x2 66e: bf 01 00 00 00 mov edi,0x1 673: e8 82 ff ff ff call 5fa <func3> 678: 48 83 c4 08 add rsp,0x8 67c: 89 45 fc mov DWORD PTR [rbp-0x4],eax 67f: b8 00 00 00 00 mov eax,0x0 684: c9 leave 685: c3 ret 686: 66 2e 0f 1f 84 00 00 nop WORD PTR cs:[rax+rax*1+0x0] 68d: 00 00 00
引数は第7引数から順番に処理されていきます。第1〜第6引数はedi,esi,edx,ecx,r8d,r9dという汎用レジスタに格納されていきます。
問題の第7引数は、push命令でスタックに格納しているようです。
つまり、7つ目を超える引数はスタックにて扱われるようです。
参考資料
Intel® 64 and IA-32 Architectures Software Developer’s Manual