C++で実際にスタック領域が
どのように変化するのかを
覗いてみます。
その前に事前知識の準備です。
ご存知の通り、
スタックはFILO構造で、
動的メモリ確保用の領域です。
プログラムにおいて、
スタックに保存されるのは、
関数の変数などです。
関数が呼び出されたら、
変数などのデータは
スタックに動的確保されます。
このとき関数内で、
malloc関数などの処理によって、
メモリサイズが確定する変数などは、
該当処理がされるときに
ヒープ領域に確保されます。

※関数の命令コード自体は
コンパイルされたときに、
テキスト(コード)セグメントに
保存されます(後述します)。
なお関数の処理が終わるときは、
その関数に該当する領域が
スタックから破棄されます。
ここまでスタックに保存される
ものは「変数など」と濁してきました。
実際にスタックに入るものは
変数以外にも以下のものがあります。
- ローカル動的変数
- フレームポインタ
- 戻りアドレス(リターンアドレス)
- など
上記のように、
保存するデータ集合体を
スタックフレームと言います。
専門用語を使ってますが要は、
関数Aからmain関数に戻るための
復元に必要な情報と変数を
スタックに入れているだけです。
フレームポインタとは、
スタックに最後にpushされた関数の
スタックフレームのrbpの値を示します。
rbpとはベースポインタの意で後述します。
戻りアドレスには、
遷移した関数の処理から、
元に戻実行時点の
プログラムカウンタの値が入ります。
スタックの流れを理解すには、
レジスタを少し理解する
必要があります。
特に重要なレジスタを紹介します。
- rsp: スタックポインタ
- rbp: ベースポインタ
- rip: プログラムカウンタ
これは64bit (x64) の場合の呼び名なので、
32bit (x86) の場合は、esp/ebp/eipになります。
ここで勘違いを防ぐために、、、
rspなどはスタック(メモリ)内ではなく、
レジスタなので、CPU内部にあります。
スタックポインタは、
現在処理中の関数の保存した
スタックの先頭を示す
メモリアドレスを保存するレジスタ
ベースポインタは、
現在処理中の関数の保存した
スタックの末端を示す
メモリアドレスを保存するレジスタ
プログラムカウンタは、
次に実行する命令(処理)が保存された
メモリアドレスを示すレジスタです。
そのメモリアドレスは、
テキスト(コード)セグメントに存在します。

どの関数を処理しているかによって、
レジスタrspに入るメモリアドレスが
上の画像のように異なります。
例えば、関数Aが呼び出されると
スタックに関数Aのスタックフレームが
pushされるので、その先頭アドレスが
レジスタrspに入ります。
そして関数Aの処理が終わり、
main関数に処理に戻るときに、
関数Aのスタックフレームが
popされるので、main関数の
スタックフレームの先頭アドレスが
レジスタrspに入ります。
レジスタrbpに関しても
同様の考え方です。
レジスタrip(プログラムカウンタ)は、
後述します。
またスタックの特性として、
pushは、メモリアドレスが
低位アドレス(0x0000)の
方向に向かって行われます。
実際にC++で見てみましょう。
実際のメモリアドレスを見ながら、
スタックの変化を追っていきます。
サンプルソースはこちらです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
#include<stdio.h> #include<stdlib.h> #include<string.h> int check_passwd(char *passwd){ char buf[16]; int auth_flag = 0; strcpy(buf, passwd); if(strcmp(buf, "iloveyou")==0) auth_flag = 1; return auth_flag; } int main(int argc, char *argv[]){ if(argc<2){ printf("[-]How to use: %s <password>\n", argv[0]); exit(0); } if(check_passwd(argv[1])) printf("[+]Access Sucsess\n"); else printf("[-]Access Denied\n"); return 0; } |
コードはパスワードが
あっているか確認する
という内容です。
実行時にパスワードを入力します。
"iloveyou"が入力されったら成功
それ以外ないら、アクセス拒否
を標準出力します。
-gオプションを付与して
コンパイルします。
-gがないとデバッグが出来ません。
gdb(The GNU Debugger)を
-qオプションで起動します。
-qはなくてもOkです。
緑枠が実際にタイプする箇所です。

list 1をタイプすると、
ソースコードが出てきますので、
ブレイクポイントを指定します。
break 22 と break 13をタイプし、
22行目と13行目にブレイクします。
22行目:関数を呼び出す
13行目:main関数にリターン
ブレイクポイントを指定したら、
run iloveyouで実行させます。
すると先ほど指定した
22行目のブレイクポイントで止まります。
ここで、disassemble mainで、
main文を逆アセンブリしてみます。

アセンブラのコードがずらっと出てきます。
画面左に表記されているアドレスが、
テキストセグメントに該当します。
このように命令(処理)の内容は、
スタックではなく、
テキストセグメント内にあります。
赤枠で囲った箇所は、
main関数の最初の3処理です。
この3行でスタック領域に
main関数に該当する
スタックフレームを確保しています。
check_passwd関数を呼び出す直前に
ブレイクポイントを置いたので、
アセンブラコードでいうところの、
main+58で止まっています。
またこの処理のアドレスは、
0x00005555555551f8で、
ブレイクしています。
ここで上述してレジスタの中身を
確認してみましょう。

info registerで
全てのレジスタの中身を確認出来ます。
単にi rの2文字だけでも出来ます。
画像のようにレジスタ名を
指定することも可能です。
今、main+58の処理で
ブレイクしていますので、
rip(プログラムカウンタ)は
main+58を指すアドレス
0x00005555555551f8が入っています。
一方で、rspとrbpは、
main関数に関する内容が入った
スタック領域を指すレジスタです。
rsp:0x7fffffffe190
rbp:0x7fffffffe1a0
なので画像のように、
命令コードのアドレスとは、
かけ離れたアドレスを指します。
それでは次に、
check_passwd関数を呼び出してみます。
次のブレイクポイントには、
continueをタイプします。
単にcだけでも可能です。

先ほどと同様に、
レジスタの情報を出力します。
処理がすすんだので、
rip(プログラムカウンタ)が示す
メモリアドレスも変わります。
赤枠で囲った箇所、つまり
check_passwd関数が呼出されてすぐに、
pushなどの処理が行われます。
つまりcheck_passwd関数の
スタックフレームがスタック領域に
確保されることになります。
そこで、check_passwd関数の
スタックフレームの先頭アドレスを
示したrspの値を見てみると、
0x7fffffffe150になっています。
また末端アドレスを示す
rbpの値を見てみると、
0x7fffffffe180になっています。
このことは、関数が呼ばれると、
低位アドレスに向かって、
スタック領域が確保された
ということを意味しています。
スタックを実際に見てみましょう。

x/32xw $rspをタイプします。
"$"マークは、
スタックの先頭を示すrspの
メモリアドレスを参照します。
x/32xwは、
rspが指すメモリアドレスから
32ワード(4×8)分出力します。
これでスタック領域の中身が
明らかになりました。
(※リトルエンディアンです。)
赤枠で囲った箇所は、
main関数によってpushされて
スタックフレームになります。
そしてcheck_passwd関数の
スタックフレームは水色の枠です。
さらにcheck_passwd関数の
スタックフレームを見てみます。
最初はローカル変数が格納されており、
最後にピンク色と紫色で示した
アドレスが格納されています。
ピンク色0x7fffffffe190は、
フレームポインタに該当します。
つまり、main関数用の
スタックフレームのrbpになります。
check_passwd関数用の
スタックフレームの末端を示すrbpが
main関数用スタックフレーム先頭を示す
rspになります。
(実際は詰め物が間にありますが。)
そして紫色の枠にある
0x000055555555520bは、
check_passwd関数の処理が終わって、
main関数の処理の再開アドレスです。
最後に、
処理(プログラム)を完了させます。

cをタイプすると、
もうブレイクポイントがないため、
処理がプログラムの最後まで進みます。
ここでレジスタの各値を見てみます。
処理が終わったので、
プログラムカウンタも
スタック領域を指すレジスタにも
何も表示されていません。
以上、ソースサンプルを使って
実際にスタックの流れを追ってみました。
最後まで読んでいただきありがとうございました。
変数の宣言の仕方によって、
確保されるメモリ領域が違います。
もし今回の内容が難しかった場合は、
こちらを合わせて読むと分かりやすいです。