32ビット・システムにおける関数呼び出し時のスタックの動きについて。
こちらの記事でも書いた内容を改めてまとめ直した。
まずは関連用語の覚書
スタック
関数の引数、関数内部のローカル変数や戻りアドレスが格納される。LIFO (Last In, First Out 後入れ先出し)。
スタック領域はメモリの上位アドレスから下位アドレスに向かって確保されていく。スタック領域はプログラム実行時にコンパイラやOSによって自動的に割り当てられる。
ヒープ
プログラムの実行中に動的に確保されるメモリ領域。例えばmallocによって動的にメモリ領域を確保する場合は、ヒープ領域上に確保される。ヒープ領域はメモリの下位アドレスから上位アドレスに向かって確保されていく。
EIP
次に実行する命令文のアドレスを格納している。命令文が実行されるたび、EIPの値は加算される (どのくらい加算されるかは実行された命令文のサイズによる)
ESP
スタック・ポインタ。スタック領域のトップに積まれたデータのメモリー・アドレスを格納する。スタックはメモリの上位アドレスから下位アドレスに向かって積み上がっていくので、ESPは最下位のアドレスに格納されたデータを指す。
EBP
ベース・ポインタ。データの格納領域の基点のメモリー・アドレスを格納する。通常、関数の最初において、EBPにはESPの値が設定される。これは関数の引数とローカル変数を追跡するためである。ローカル変数へのアクセスはEBPからオフセットを引くことによって行われる。関数の引数へのアクセスはEBPにオフセットを加算することによって行われる。
※ただし、gccなどのGNUコンパイラは関数の引数・変数へのアクセスにEBPではなく、ESPを用いる。
以下のサンプル・プログラムを例にスタックの動きを追ってみる。
#include <stdio.h>
int sum(int a, int b)
{
int result;
result = a + b;
printf("%d\n", result);
return result;
}
int main(void)
{
sum(5, 10);
return 0;
}
上記のソースコードをコンパイルする。
gcc stack-layout.c -o stack-layout.out -fno-pie -fno-stack-protector -m32
-fno-pie: PIE形式でコンパイルしないためのオプション
-fno-stack-protector: スタックの保護機能を無効化するためのオプション
-m32: 32ビットのバイナリ形式でコンパイルすためのオプション
コンパイルしたプログラムをobjdumpコマンドで逆アセンブルする。
objdump -d -M intel stack-layout.out
main関数の逆アセンブル: sum関数を引数付きで呼び出す。(引数は5と10)
08048446 <main>:
8048446: 55 push ebp
8048447: 89 e5 mov ebp,esp
8048449: 83 e4 f0 and esp,0xfffffff0
804844c: 83 ec 10 sub esp,0x10
804844f: c7 44 24 04 0a 00 00 mov DWORD PTR [esp+0x4],0xa
8048456: 00
8048457: c7 04 24 05 00 00 00 mov DWORD PTR [esp],0x5
804845e: e8 ba ff ff ff call 804841d <sum>
8048463: b8 00 00 00 00 mov eax,0x0
8048468: c9 leave
8048469: c3 ret
804846a: 66 90 xchg ax,ax
804846c: 66 90 xchg ax,ax
804846e: 66 90 xchg ax,ax
sum関数の逆アセンブル: 2つの整数の合計を求める
0804841d <sum>:
804841d: 55 push ebp
804841e: 89 e5 mov ebp,esp
8048420: 83 ec 28 sub esp,0x28
8048423: 8b 45 0c mov eax,DWORD PTR [ebp+0xc]
8048426: 8b 55 08 mov edx,DWORD PTR [ebp+0x8]
8048429: 01 d0 add eax,edx
804842b: 89 45 f4 mov DWORD PTR [ebp-0xc],eax
804842e: 8b 45 f4 mov eax,DWORD PTR [ebp-0xc]
8048431: 89 44 24 04 mov DWORD PTR [esp+0x4],eax
8048435: c7 04 24 00 85 04 08 mov DWORD PTR [esp],0x8048500
804843c: e8 af fe ff ff call 80482f0 <printf@plt>
8048441: 8b 45 f4 mov eax,DWORD PTR [ebp-0xc]
8048444: c9 leave
8048445: c3 ret
コンパイルしたプログラムをデバッグしてスタックの内容を確認してみる。
main関数内でsum関数を呼び出している箇所(アドレス 0x804845e
)にブレークポイントをセットし、ステップイン実行する。
gdb ./stack-layout.out
(gdb) b *0x804845e
Breakpoint 1 at 0x804845e
(gdb) r
Starting program: /home/sansforensics/Desktop/stack-layout.out
Breakpoint 1, 0x0804845e in main ()
(gdb) si
0x0804841d in sum ()
スタックの最上位にsum関数が実行されたあとの次の命令のアドレス0x08048463
が格納されている。
(gdb) x/x $esp
0xffffd0ac: 0x08048463
その下にはsum関数の第一引数 5
が格納されている。
(gdb) x/d $esp+0x4
0xffffd0b0: 5
さらに、その下にはsum関数の第2引数 10
が格納されている。
(gdb) x/d $esp+0x8
0xffffd0b4: 10
まとめると、引数が2つスタックに積まれ、さらにsum関数が実行されたあとの次の命令のアドレスがリターンアドレスとしてスタックに積まれる。この操作はcallが実行されると自動的に行われる。
sum関数に処理が移る。 まず、push ebp
命令によってmain関数のEBPレジスタの値(0xffffd0c8
)がスタックに積まれる。これにより、sum関数の実行が完了したあとにmain関数に戻れるようになる。
そして、mov ebp, esp
命令によりEBPとESPの指すアドレス位置が等しくなる。
push ebp
の直前
Breakpoint 1, 0x0804845e in main ()
(gdb) si
0x0804841d in sum ()
(gdb) p $ebp
$6 = (void *) 0xffffd0c8
(gdb) x/x $esp
0xffffd0ac: 0x08048463
push ebp
の実行後。EBPの値(0xffffd0c8
)がESPの最上位に格納された。
0x0804841e in sum ()
(gdb) p $ebp
$8 = (void *) 0xffffd0c8
(gdb) x/x $esp
0xffffd0a8: 0xffffd0c8
(gdb) p $esp
$9 = (void *) 0xffffd0a8
mov ebp,esp
の実行後。EBPとESPの指すアドレス位置が等しくなった。
0x08048420 in sum ()
(gdb) p $ebp
$12 = (void *) 0xffffd0a8
(gdb) p $esp
$13 = (void *) 0xffffd0a8
(gdb)
sub esp, 0x28
命令によってESPレジスタの指す位置を下位のアドレスに移動させる。EBPレジスタの指す位置とESPレジスタの指す位置の間にできた領域はローカル変数の格納に利用される。この場合は0x28、つまり10進数で40バイトの領域がローカル変数の領域として割り当てられる。
2つの引数を加算して結果をprintfによって出力した後、leave命令を呼び出す。
leave命令によってESPの指す位置がEBPの指す位置と同じ位置に戻され、保存していたmain関数のEBPレジスタの値(0xffffd0c8
)が復元される。
Breakpoint 1, 0x08048444 in sum ()
(gdb) si
0x08048445 in sum ()
(gdb) p $ebp
$3 = (void *) 0xffffd0c8
(gdb)
leave命令はmov esp, ebp; pop ebp
と等しい動作をする。
leave命令完了後は、ESPはリターンアドレス0x08048463
を指している。
(gdb) si
0x08048445 in sum ()
(gdb) x/x $esp
0xffffd0ac: 0x08048463
(gdb)
最後にret命令を呼び出す。ret命令はスタックのトップから値をpopし、そこへjmpする (pop eip
と等しい)。今回の場合スタック上位に積まれているのはsum関数実行後のリターンアドレス0x08048463
である。 retによってsum関数からmain関数のアドレス0x08048463
に処理が移っていることが下記から読み取れる。
0x08048445 in sum ()
(gdb) x/x $esp
0xffffd0ac: 0x08048463
(gdb) si
0x08048463 in main ()
(gdb)