今回はgdbを使ってプロセスが持っているスタック領域のメモリを覗いてみようと思います。
これまでスタックという言葉は知っていて、「あぁ、あれだよね、あの質問サイトのやつ。」ぐらいのへっぽこ知識しかありませんでしたが、先日読了した「自作エミュレータで学ぶx86アーキテクチャ」の中で分かりやすく説明されてたので実際に自分の手でスタックが積まれていく様子などを確認してみようと思います。
●そもそもスタック領域って?
・私もうまく説明できませんが、プロセスが持つメモリ領域は「スタック、ヒープ、グローバル、定数、コード」などの領域が分かれているという前提がまずあります。そのうえで、malloc()などで動的にヒープ領域に確保されるメモリと違って、関数内のローカル変数などで一時的に利用されるメモリ領域ぐらいの認識でいます。(詳細はご自身でお調べください。。。)また、ヒープ領域と違ってプログラマがメモリの解放を行う必要がなくメモリリークなどの心配がない感じですかね。この辺のスタック破棄の動作回りも後ほど確認します。
(学校では教えてくれないこと)ヒープとスタック
●実験内容
1, C言語のサンプルソースを作成してgdbでスタックが積まれる様子を覗いてみる
– サンプルソースではスタック領域にローカル変数として確保された変数の初期値を表示しています。
– main()関数からtest_func()関数を3回実行して、実行するたびにスタックに確保された値を覗いていきます。
– 予想:
– 1回目: test_func()の初回呼び出し時に確保されたローカル変数は初期化されておらずゴミデータが見えるはず
– 2回目: test_func()の2回目の呼び出し時は、初回呼び出し時にセットした値が残っているのが確認できるはず。関数が完了しスタック領域を破棄するときは、使用したメモリ領域を初期化せずにスタックポインタ(rsp)とベースポインタ(rbp)の移動のみで完結するため。
– 3回目: 2回目と同じ
サンプルソースと実行結果
### スタック動作確認用のサンプルソース
hiro@hiro-ubuntu [15時40分48秒] [~]
-> % cat stack-check.c
#include <stdio.h>
void test_func(int val){
int i; // こいつがスタック領域に確保される
printf("=====================\n");
printf("before func:%d\n", i);
i = val;
printf("after func:%d\n", i);
}
int main(void){
test_func(4);
test_func(8);
test_func(12);
return 0;
}
### ビルドと実行結果の例
hiro@hiro-ubuntu [15時40分50秒] [~]
-> % gcc -o stack-check stack-check.c
hiro@hiro-ubuntu [15時43分00秒] [~]
-> % ./stack-check
=====================
before func:22080 // これが初回のゴミデータ(※この値は実行のたびに変わる)
after func:4
=====================
before func:4 // これが、初回実行時に入れたデータゴミとして残ってる
after func:8
=====================
before func:8
after func:12
サンプルソースのアセンブリを出力
gdbでスタックの様子を確認する際に、サンプルソースのアセンブリがあったほうが動作を追いやすいので逆アセンブルした結果の一部を抜粋しておきます。### 逆アセンブル
hiro@hiro-ubuntu [16時02分47秒] [~]
-> % ndisasm -b 64 stack-check
### main()関数とtest_func()関数の抜粋
// こっからtest_func()関数
0000116A 55 push rbp
0000116B 4889E5 mov rbp,rsp
0000116E 4883EC20 sub rsp,byte +0x20 // test_func()用のスタック領域を確保
00001172 897DEC mov [rbp-0x14],edi // main()から渡された引数をスタックに確保した変数に代入
00001175 488D3D880E0000 lea rdi,[rel 0x2004]
0000117C E8AFFEFFFF call 0x1030
00001181 8B45FC mov eax,[rbp-0x4]
00001184 89C6 mov esi,eax
00001186 488D3D8D0E0000 lea rdi,[rel 0x201a]
0000118D B800000000 mov eax,0x0
00001192 E8A9FEFFFF call 0x1040
00001197 8B45EC mov eax,[rbp-0x14]
0000119A 8945FC mov [rbp-0x4],eax
0000119D 8B45FC mov eax,[rbp-0x4]
000011A0 89C6 mov esi,eax
000011A2 488D3D810E0000 lea rdi,[rel 0x202a]
000011A9 B800000000 mov eax,0x0
000011AE E88DFEFFFF call 0x1040
000011B3 90 nop
000011B4 C9 leave
000011B5 C3 ret
// こっからmain()関数
000011B6 55 push rbp
000011B7 4889E5 mov rbp,rsp
000011BA BF04000000 mov edi,0x4 // 引数4をediレジスタに代入
000011BF E8A6FFFFFF call 0x116a // 初回test_func()呼び出し
000011C4 BF08000000 mov edi,0x8 // 引数8をediレジスタに代入
000011C9 E89CFFFFFF call 0x116a // 2回目test_func()呼び出し
000011CE BF0C000000 mov edi,0xc // 引数12をediレジスタに代入
000011D3 E892FFFFFF call 0x116a // 3回目test_func()呼び出し
000011D8 B800000000 mov eax,0x0
000011DD 5D pop rbp
000011DE C3 ret
gdbを使ってスタックを覗く
それでは実際にgdbを使ってスタック領域を覗いていきたいと思います。(ももいろテクノロジー)gdbの使い方のメモ
hiro@hiro-ubuntu [16時17分55秒] [~]
-> % gdb ./stack-check
(gdb) break main
(gdb) run
Starting program: /home/hiro/stack-check
Breakpoint 1, 0x00005555555551ba in main ()
main()関数に入った直後のレジスタの値を確認しておこうと思います。
(gdb) info registers rbp rsp edi eax
rbp 0x7fffffffe240 0x7fffffffe240
rsp 0x7fffffffe240 0x7fffffffe240
edi 0x1 1
eax 0x555551b6 1431654838
スタックポインタが「0x7fffffffe240」を指していることがわかります。それではステップ実行していこうと思います。実行している間にスタックポインタが移動して、test_func()用のローカル変数が確保され行くはずです。
(gdb) si
0x000055555555516a in test_func ()
(gdb) info registers rbp rsp edi eax
rbp 0x7fffffffe240 0x7fffffffe240
rsp 0x7fffffffe238 0x7fffffffe238
edi 0x4 4
eax 0x555551b6 1431654838
siを複数回実行すると、test_func()の中にやってきました。スタックポインタの値も「0x7fffffffe238」になって変化しています。ここでちょっとした疑問ですが、「スタックが積まれるならメモリの番地は増えてるはずでは?」というものがあります。
実際はスタックは下方向に伸びていくので、スタック領域が増加すると、メモリ番地はマイナスされます。
なので前述のアセンブリにあるように、スタック領域を確保するときにはsub命令を使って、現在のスタックポインタから減算しています。
「sub rsp,byte +0x20」
何回かsiを実行していくとrspの値が-0x20されます。ここでtest_func()内でローカル変数に使う領域がスタックが積まれたので、初回実行時のゴミデータを覗いてみましょう。
実際に覗くメモリのアドレスは「rbp-0x14」になります。理由としては、前述したtest_func()アセンブリの中で「mov [rbp-0x14],edi」という処理で、ediに格納されているmain()関数からの引数を代入している処理がありからです。つまり、C言語の「i = val;」に対応する処理になり、代入先がローカル変数のアドレスということになります。
0x0000555555555172 in test_func ()
(gdb) info registers rbp rsp edi edx
rbp 0x7fffffffe230 0x7fffffffe230
rsp 0x7fffffffe210 0x7fffffffe210
edi 0x4 4
edx 0xffffe348 -7352
(gdb) x/1xw $rbp-0x14
0x7fffffffe21c: 0x00005555
※edxの値が変わってるのは動作確認の際に何回かgdbを実行しなおしてるからですので気にしないでください。
「0x7fffffffe21c: 0x00005555」という値が取得できました。これは10進数では「21845」なので、初回実行時はこのゴミデータを表示して「before func:21845」という出力がされるはずです。
それではプログラムの実行を進めて出力を確認してみます。
(gdb) n
Single stepping until exit from function test_func,
which has no line number information.
=====================
before func:21845
after func:4
想定通り、スタックに確保されたアドレスにあったゴミデータを初回実行時は表示しました。続いて2回目以降のこのアドレスの値を確認していきます。2回目実行時も、main()関数から連続して同一関数の呼び出しを行っているので、初回と同じ位置にスタックが積まれていきます。
想定では、初回実行時に「i = val;」してるので、「4」が入っているはずです。
この動作は、関数終了後にスタックを破棄する際にrbpとrspの移動のみでメモリの値を初期化しているわけではないという動作確認にもなります。
0x0000555555555172 in test_func ()
(gdb) info registers rbp rsp edi edx
rbp 0x7fffffffe230 0x7fffffffe230
rsp 0x7fffffffe210 0x7fffffffe210
edi 0x8 8
edx 0x0 0
(gdb) x/1xw $rbp-0x14
0x7fffffffe21c: 0x00000004
想定通り「4」が入っていますね。この後処理を進めると、2回目の引数の「8」で上書きされるはずです。
### 2回目のtest_func()終了後の出力
(gdb) n
Single stepping until exit from function test_func,
which has no line number information.
=====================
before func:4
after func:8
### すでに破棄されたスタック領域を覗いてみる
### 先ほどのebpのぽインタ「0x7fffffffe230」を用いる
(gdb) x/1xw 0x7fffffffe230-0x14
0x7fffffffe21c: 0x00000008
想定通り「8」が入っていますね。3回目も同様の動作となるので動作確認は割愛します。
以上で今回の実験を終了としたいと思います。
私が勘違いしてるか箇所等あるかもなので疑問に思った点などはぜひご自身でも試してみると面白いかと思います。
今回はgdbを用いてプログラムのメモリ領域を確認してみました。ちゃんと使えると便利なツールなのでちょくちょく使う機会を増やしていこうかなーと思うところでした。(そうゆう機会はなかなかないけど。)
それではまた。