(実験)メモリのスタック領域を覗く

投稿者: | 2020年12月30日
皆さんこんにちは。

今回は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を用いてプログラムのメモリ領域を確認してみました。ちゃんと使えると便利なツールなのでちょくちょく使う機会を増やしていこうかなーと思うところでした。(そうゆう機会はなかなかないけど。)

それではまた。