Kotaro7750's diary

低レイヤを中心とした技術ブログ、たまに日記

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のマニュアルに書いてあるので興味があれば見てみてください。

さて引数が無事積まれたわけですが、まだまだやることは残っています。 関数の処理は、スタック上で行われるわけですが、このスタックが今のままだと呼び出し元の関数のものを使ってしまいます。これを防ぐためにプロローグでは、関数フレームというその関数専用のスタック領域を生成します。

f:id:Kotaro7750:20191114124929j:plain
プロローグの図

上に関数スタック生成時におけるスタック状態の図を示します。図の見方についてですが、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つの動作をまとめると下の図のようになります。

f:id:Kotaro7750:20191114124929j:plain
エピローグの図

プロローグの図における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