AsmWalker ガイド — メモリとスタックポインタの動きを理解する
スタック(stack)は「後入れ先出し(LIFO: Last In, First Out)」のデータ構造です。 本を積み上げる場面を想像してください。一番上に置いた本を最初に取り出せます。
積む(PUSH): A → B → C と順に積む
取り出す(POP): C → B → A の順に取り出せる
プログラムの実行中は、この LIFO 構造を使ってローカル変数・戻り先アドレス・保存レジスタなどを 一時的に保持します。
プログラムが使うメモリは大まかに次のような領域に分かれています。
高アドレス ↑
┌─────────────────────────┐
│ スタック(stack) │ ← 関数のローカル変数・戻りアドレスなど
│ ↓(使うほど下に伸びる) │
│ │
│ ヒープ(heap) │ ← malloc など動的確保
│ ↑(使うほど上に伸びる) │
│ │
│ データ領域(.data) │ ← グローバル変数・static 変数
│ コード領域(.text) │ ← 命令列
└─────────────────────────┘
低アドレス ↓
スタックは高アドレスから低アドレス方向に伸びるという点が重要です。 値を積むたびにアドレスが小さくなっていきます。
PUSH / POP は見た目はシンプルですが、内部では SP の移動 + メモリの読み書き の 2ステップが起きています。
; PUSH {r4} の展開:
sub sp, sp, #4 ; SP -= 4
str r4, [sp] ; [SP] ← r4
; POP {r4} の展開:
ldr r4, [sp] ; r4 ← [SP]
add sp, sp, #4 ; SP += 4
; push rbp の展開:
sub rsp, 8 ; RSP -= 8
mov [rsp], rbp ; [RSP] ← rbp
; pop rbp の展開:
mov rbp, [rsp] ; rbp ← [RSP]
add rsp, 8 ; RSP += 8
ARM では 1 レジスタ = 4 バイト(32bit)、x86-64 では 1 レジスタ = 8 バイト(64bit)なので、 SP の増減量が異なります。
rsp ← rsp - 8; [rsp] ← rbp のように、SP 移動とメモリ書き込みの
2ステップがひとつの命令として実行される様子を確認できます。
スタックポインタは「現在スタックのどこまで使っているか」を示すレジスタです。
| アーキテクチャ | レジスタ名 | ビット幅 | 役割 |
|---|---|---|---|
| ARM Cortex-M | SP(= r13) | 32bit | スタックの現在位置 |
| x86-64 | RSP | 64bit | スタックの現在位置 |
SP は常に「最後に積んだ値のアドレス」を指しています(Full Descending スタック)。 値を積む前に SP を下げ、その位置に書き込むのがお作法です。
以下は ARM での PUSH/POP のメモリ状態の例です。
関数がスタックを必要とする主な場面は 3 つです。
call が自動で積む)これらを合わせたメモリ領域ひとまとまりを「スタックフレーム(stack frame)」と呼びます。 関数が呼び出されるたびに新しいフレームが積まれ、関数から戻るときに破棄されます。
3段階のネスト呼び出しが行われると、スタックには下の図のように 3 つのフレームが積み上がります。 一番最初に呼ばれた main のフレームが高アドレス側にあり、 現在実行中の FuncB のフレームが最も低アドレス側にあります。
[fp - 4]、[fp - 8] のように
フレームポインタからのオフセット指定で自由に読み書きできます。
push / pop を使うのは「フレームの確保・解放」と「レジスタの退避・復元」の場面だけです。
ローカル変数用の領域は SP をずらすだけで確保できます。 malloc のようなシステムコールは不要で、非常に高速です。
push {fp, lr} ; FP, LR を保存
add fp, sp, #4 ; フレームポインタ設定
sub sp, sp, #8 ; ローカル変数 8 バイト確保
; ... 関数の処理 ...
sub sp, fp, #4 ; SP を元に戻す(解放)
pop {fp, pc} ; FP 復元、PC = 戻り先
push rbp ; RBP を保存
mov rbp, rsp ; フレームポインタ設定
sub rsp, 16 ; ローカル変数 16 バイト確保
; ... 関数の処理 ...
leave ; mov rsp, rbp; pop rbp
ret ; 戻り先へジャンプ
スタックの解放はSP を元の位置に戻すだけです。メモリ上の値はクリアされません。
確保: sub rsp, 16 ; RSP を 16 下げる → その領域が「使用中」
解放: add rsp, 16 ; RSP を 16 上げる → その領域が「空き」扱いになる
; ※ メモリ上の値はそのまま残っている
return で返してはいけません。
関数が終わると SP が戻り、その領域は「空き」になります。
次の関数呼び出しで同じアドレスに別のデータが書き込まれ、
返したアドレスの中身は予期しない値に化けます(未定義動作)。
int* bad() {
int x = 42;
return &x; // 危険: 関数終了後はゴミ値になる
}
確保した領域にある変数は、フレームポインタ(FP / RBP)からのオフセットで参照するのが基本です。
; int x = 3; をスタックに保存する例(x86-64)
mov DWORD PTR [rbp-4], 3 ; x は rbp - 4 に配置
; int y = 5; をスタックに保存する例
mov DWORD PTR [rbp-8], 5 ; y は rbp - 8 に配置
-mthumb / 最適化時の注意-mthumb や -O1 以上を指定すると、フレームポインタ(r7 / fp)を
省略してスタックポインタ(SP)からの相対アドレスでローカル変数にアクセスする場合があります。
そのため GCC 出力の [sp, #N] のような書き方を見かけることがあります。
mov DWORD PTR [rbp-4], 3 を実行した後に
rbp-4 のアドレスのセルに 3 が書き込まれていることを確認してください。
スタック領域には上限があります。深い再帰や巨大なローカル変数でスタックが限界を超えると、 スタックオーバーフロー(stack overflow)が発生してプログラムがクラッシュします。
// 再帰が深すぎる例(すぐにスタックオーバーフロー)
void infinite(void) {
int buf[1024]; // 毎回 4KB をスタックに確保
infinite(); // 戻らずに再帰 → SP が際限なく下がる
}
ARM Cortex-M などの組み込み環境では数 KB しかスタックがないことも多く、 深い再帰や大きな配列のローカル変数には注意が必要です。
| 概念 | 内容 |
|---|---|
| スタックの方向 | 高アドレス → 低アドレスへ伸びる(SP が下がる方向) |
| PUSH | SP -= サイズ → [SP] に値を書き込む |
| POP | [SP] から値を読み出す → SP += サイズ |
| スタックフレーム | 1関数が使うスタックの範囲。関数開始で確保、終了で解放 |
| 「解放」の意味 | SP を元の位置に戻すだけ。メモリはクリアされない |
| ローカル変数 | FP(またはSP)± オフセットで読み書き |
| スタックオーバーフロー | SP が領域限界を超えてクラッシュ |