関数のプロローグについて

関数のプロローグとは、関数を呼び出して実行し、呼び出し元に戻る際に必要となる情報(局所変数のメモリ、rbp、戻りアドレスなど)をスタックに積み上げる処理のことを指す

関数のプロローグを実際に確認するため、以下のようなプログラムを考えてみる。あくまで、関数のプロローグを見るための適当なプログラムである。

#include <stdio.h>

void  func(void)
{
	int x = 100;
	char str[] = "Hello, World!";
}

int main(void)
{
	func();
	return 0;
}

これをコンパイルしつつデバッグ情報を付与した後に、GDBデバッガを起動させる。

gcc -o test test.c -g
gdb -q ./test

GDBデバッガに入ったら、まずは個人的に分かりやすいと思っているintel記法へと設定。

(gdb) set disassembly-flavor intel

いよいよ本題。ソースコードをアセンブリとして表示してみる。

(gdb) disass main
Dump of assembler code for function main:
   0x000055555555519d <+0>:     endbr64
   0x00005555555551a1 <+4>:     push   rbp
   0x00005555555551a2 <+5>:     mov    rbp,rsp
   0x00005555555551a5 <+8>:     call   0x555555555149 <func>
   0x00005555555551aa <+13>:    mov    eax,0x0
   0x00005555555551af <+18>:    pop    rbp
   0x00005555555551b0 <+19>:    ret
End of assembler dump.

(gdb) disass func
   0x0000555555555149 <+0>:     endbr64
   0x000055555555514d <+4>:     push   rbp
   0x000055555555514e <+5>:     mov    rbp,rsp
   0x0000555555555151 <+8>:     sub    rsp,0x20
   0x0000555555555155 <+12>:    mov    rax,QWORD PTR fs:0x28
   0x000055555555515e <+21>:    mov    QWORD PTR [rbp-0x8],rax
   0x0000555555555162 <+25>:    xor    eax,eax
   0x0000555555555164 <+27>:    mov    DWORD PTR [rbp-0x1c],0x64
   0x000055555555516b <+34>:    movabs rax,0x57202c6f6c6c6548
   0x0000555555555175 <+44>:    mov    QWORD PTR [rbp-0x16],rax
   0x0000555555555179 <+48>:    mov    DWORD PTR [rbp-0xe],0x646c726f
   0x0000555555555180 <+55>:    mov    WORD PTR [rbp-0xa],0x21
   0x0000555555555186 <+61>:    nop
   0x0000555555555187 <+62>:    mov    rax,QWORD PTR [rbp-0x8]
   0x000055555555518b <+66>:    sub    rax,QWORD PTR fs:0x28
   0x0000555555555194 <+75>:    je     0x55555555519b <func+82>
   0x0000555555555196 <+77>:    call   0x555555555050 <__stack_chk_fail@plt>
   0x000055555555519b <+82>:    leave
   0x000055555555519c <+83>:    ret
End of assembler dump.

特に、func関数のアセンブリの中にある

   0x000055555555514d <+4>:     push   rbp
   0x000055555555514e <+5>:     mov    rbp,rsp
   0x0000555555555151 <+8>:     sub    rsp,0x20

この部分こそが関数のプロローグと呼ばれる処理である。

なにをしているのか?

最初に述べたように、関数のプロローグは、関数の実行と呼び出し元に戻るための情報をスタックに積み上げる。関数のプロローグを理解するためには、rspレジスタとrbpレジスタの理解から始めよう。rspとrbpは適当な話、スタックに積まれた一つのフレーム(関数実行時に必要となる情報の集まり)の上下のアドレスを保持している。つまりrspとrbpが保持しているアドレスの間に局所変数などか格納されているということ。

func関数を呼び出す前と、呼び出されてfuncの処理が行われているときのrspとrbpが持つアドレスを見てみよう。まずはbreakコマンドにより、7行目(func関数内)と11行目(func関数呼び出し前)にブレイクポイントを設定し、runコマンドで最初のブレイクポイント、func関数呼び出し前の11行目で処理を停止させてみる。

(gdb) break 7
(gdb) break 11
(gdb) run

次にfunc関数を呼び出す前のrspとrbpが保持するアドレスについて見ていく。以下のコマンドで、レジスタが保持している値を確認できる。

(gdb) i r $rsp $rbp
rsp            0x7fffffffe060      0x7fffffffe060
rbp            0x7fffffffe060      0x7fffffffe060

先程、rspとrbpが保持しているアドレスの間に局所変数などか格納されていると書いたが、main関数内では局所変数を宣言していないため、スタック上には領域が確保されていないのです。つまりrspとrbpの値は同じアドレスを指しているということになる。

funcを呼び出す前のスタックの状態が確認ができたら、次はfunc実行中のの状態を確認してみる。contコマンドで、次のブレイクポイントである7行目(func関数内)へと処理を進め、先ほどと同じinfo registerコマンドを使用して、レジスタを確認。

(gdb) cont
(gdb) i r $rsp $rbp
rsp            0x7fffffffe030      0x7fffffffe030
rbp            0x7fffffffe050      0x7fffffffe050

アドレスが先程よりも小さくなっていることが分かると思う。これはmain関数のスタックフレームが存在している0x7fffffffe060の上にfunc関数のスタックフレーム(関数実行に必要な情報)が積み上げられたためである。またrspとrbpには0x20の差がありますが、この差の中に局所変数などが格納されている。

もう少し詳しく

今度は命令を追って、具体的に値が変更される流れを見ていこう。今のスタックのイメージは先程のrsp・rbpのアドレスを参考に、このような状態からスタートする。 stack1.jpg

まずはmain関数内にあるcall命令から。

   0x00005555555551a5 <+8>:     call   0x555555555149 <func>

call命令は戻りアドレスをスタックへpush(積み上げ)し、ripを呼び出し先の関数の先頭アドレスに変更する。 また、戻りアドレスとはmain関数内のcall命令の次の命令のアドレスを意味する。

つまり以下の命令のアドレスが、戻りアドレスとして0x7fffffffe060の上にpushされるということになる。

   0x00005555555551aa <+13>:    mov    eax,0x0

そしてpushを行った場合、rspは減算される。 つまりrspは、保持している0x7fffffffe060から0x8が減算されて0x7fffffffe058という値を格納する。 stack2.jpg

また、ripに格納される呼び出し先の関数(func)の先頭アドレスはこれ。

   0x0000555555555149 <+0>:     endbr64

ripとは次に実行する命令のアドレスを保持するレジスタのことで、プロセッサはこれを使って次の命令の場所を把握して実行する。call命令を使用することで、呼び出し先の関数の命令が格納されているアドレスへとripを書き換え、関数の実行が実現する。

では次の命令、つまり関数のプロローグ最初の命令を見ていこう。

   0x000055555555514d <+4>:     push   rbp

これは、rbpをスタックへとpushする命令。main関数へ制御を返す際にrbpの値を復元するために後々用いられる。0x7fffffffe058の上にrbpの現在の値がpushされるため、rspのアドレスは先ほどと同じように0x8だけ小さくなるので0x7fffffffe050となる。 stack3.jpg

次の命令はrbpへrspのアドレスをmov命令によってコピーする。これによって作成するfunc関数のスタックフレーム、そのボトムをrbpが表すようになる。

   0x000055555555514e <+5>:     mov    rbp,rsp

stack4.jpg

これで、関数から呼び出し元へと帰る際の準備が完了。あとは局所変数などを格納するためにrspの値を減算して領域を確保するだけ。

   0x0000555555555151 <+8>:     sub    rsp,0x20

sub命令はrspから0x20を引き算するよう命令している。 stack5.jpg

これにて関数プロローグを全て追うことができた。

確認

本当に一連の動作が行われているのかを確認していこう。examineコマンドを使用してrspのアドレスからmain関数のフレームである0x7fffffffe060のあたりまでを確認。

(gdb) x/8xg $rsp
0x7fffffffe030: 0x0000006400000002      0x2c6f6c6c6548fbff
0x7fffffffe040: 0x0021646c726f5720      0xfea8e158512f7600
0x7fffffffe050: 0x00007fffffffe060      0x00005555555551aa
0x7fffffffe060: 0x0000000000000001      0x00007ffff7db8d90

下から2行目には右に戻りアドレス、左にrbpが格納されていることが確認できる。 また、上から1行目の4バイトまでを数えると0x00000064が格納されており、これは10進数の100であることもわかる。さらには上から2行目の右側と、3行目の左側の値をASCIIコード表と比べてみると

0x 2c 6f 6c 6c 65 48 fb ff
   ,  o  l  l  e  H

0x 00 21 64 6c 72 6f 57 20
   \0 !  d  l  r  o  W  Space

上記のように、Hello, World! が確認できる。