スタックの仕組み

AsmWalker ガイド — メモリとスタックポインタの動きを理解する

1. スタックとは何か

スタック(stack)は「後入れ先出し(LIFO: Last In, First Out)」のデータ構造です。 本を積み上げる場面を想像してください。一番上に置いた本を最初に取り出せます。

積む(PUSH): A → B → C と順に積む
取り出す(POP): C → B → A の順に取り出せる

プログラムの実行中は、この LIFO 構造を使ってローカル変数・戻り先アドレス・保存レジスタなどを 一時的に保持します。

AsmWalker での確認
右側の「スタック」パネルがスタックメモリをリアルタイムで表示します。 ステップを進めながら値が積まれていく様子と、SP が上下する様子を確認してください。

2. スタックはメモリのどこにあるか

プログラムが使うメモリは大まかに次のような領域に分かれています。

高アドレス ↑
┌─────────────────────────┐
│  スタック(stack)        │  ← 関数のローカル変数・戻りアドレスなど
│   ↓(使うほど下に伸びる)  │
│                          │
│  ヒープ(heap)           │  ← malloc など動的確保
│   ↑(使うほど上に伸びる)  │
│                          │
│  データ領域(.data)      │  ← グローバル変数・static 変数
│  コード領域(.text)      │  ← 命令列
└─────────────────────────┘
低アドレス ↓

スタックは高アドレスから低アドレス方向に伸びるという点が重要です。 値を積むたびにアドレスが小さくなっていきます。

注意
「スタックが下に伸びる」という言い方は、メモリアドレスが小さくなる方向に伸びることを指します。 画面の上下(スタックの図では高アドレスを上に描くことが多い)と混同しないように注意してください。
AsmWalker での確認
スタックパネルは高アドレスが上に表示されます。 PUSH 命令を実行するたびに新しいセルが下に追加されていくことを確認してください。 これがアドレスの減少方向に積まれている証拠です。

3. PUSH と POP の正体

PUSH / POP は見た目はシンプルですが、内部では SP の移動 + メモリの読み書き の 2ステップが起きています。

ARM Cortex-M

; 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

x86-64

; 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 の増減量が異なります。

AsmWalker での確認
命令詳細パネルに PUSH 命令の説明が表示されます。 rsp ← rsp - 8; [rsp] ← rbp のように、SP 移動とメモリ書き込みの 2ステップがひとつの命令として実行される様子を確認できます。

4. スタックポインタ(SP / RSP)

スタックポインタは「現在スタックのどこまで使っているか」を示すレジスタです。

アーキテクチャレジスタ名ビット幅役割
ARM Cortex-MSP(= r13)32bitスタックの現在位置
x86-64RSP64bitスタックの現在位置

SP は常に「最後に積んだ値のアドレス」を指しています(Full Descending スタック)。 値を積む前に SP を下げ、その位置に書き込むのがお作法です。

スタックの状態変化を追う

以下は ARM での PUSH/POP のメモリ状態の例です。

0x20008000
(未使用)
0x20007ffc
r4 = 0x12345678
0x20007ff8
← SP(push r3 後)
0x20007ff4
(未使用)
AsmWalker での確認
特殊レジスタパネルの「SP」欄でスタックポインタの値を確認できます。 PUSH を実行するたびに値が小さくなり(アドレスが下がり)、 POP を実行すると値が大きくなる(アドレスが上がる)ことを確認してください。

5. 関数がスタックを使う理由

関数がスタックを必要とする主な場面は 3 つです。

  1. ローカル変数の保存 — 関数内で宣言した変数を一時的に置く場所
  2. 戻り先アドレスの保存 — 関数から戻るときの行き先(x86: call が自動で積む)
  3. レジスタの退避(callee-save) — 呼び出し先が使ったレジスタを壊さないよう、元の値をスタックに退避する

これらを合わせたメモリ領域ひとまとまりを「スタックフレーム(stack frame)」と呼びます。 関数が呼び出されるたびに新しいフレームが積まれ、関数から戻るときに破棄されます。

main → FuncA → FuncB と呼ばれたときのスタック

3段階のネスト呼び出しが行われると、スタックには下の図のように 3 つのフレームが積み上がります。 一番最初に呼ばれた main のフレームが高アドレス側にあり、 現在実行中の FuncB のフレームが最も低アドレス側にあります。

高アドレス(初期 SP)▲
main のスタックフレーム
保存 LR(戻り先アドレス)
int x = 10 [fp_main - 4]  ← 通常のメモリ読み書き
int y = 20 [fp_main - 8]
↕ FP_main(フレームポインタ) FP_main →
FuncA のスタックフレーム
保存 FP_main (呼び出し元のフレームポインタを退避)
保存 LR(main への戻り先)
int a = 3 [fp_A - 4]  ← 通常のメモリ読み書き
int b = 5 [fp_A - 8]
↕ FP_A(フレームポインタ) FP_A →
FuncB のスタックフレーム(現在実行中)
保存 FP_A (呼び出し元のフレームポインタを退避)
保存 LR(FuncA への戻り先)
int p = 7 [fp_B - 4]  ← 通常のメモリ読み書き
↕ FP_B(フレームポインタ) FP_B →
▼ SP(スタックポインタ。現在 FuncB のフレーム末尾を指している)
低アドレス ▼
「スタック = push/pop しかできない」は誤解です
スタックという名前からデータ構造の LIFO を連想し、 「スタックメモリへのアクセスは push / pop しか許されない」と思い込む初学者は少なくありません。

実際には 2 つのルールが別々に存在します:
ローカル変数は [fp - 4][fp - 8] のように フレームポインタからのオフセット指定で自由に読み書きできます。 push / pop を使うのは「フレームの確保・解放」と「レジスタの退避・復元」の場面だけです。
AsmWalker での確認
スタックパネルの左端のカラーバーがフレームの帰属を示します。 main のフレーム(紫)の上に子関数のフレーム(緑)が積まれる様子を確認してください。 FrameViz(フレーム色分け図)でも視覚的に確認できます。

6. ローカル変数の確保と解放

ローカル変数用の領域は SP をずらすだけで確保できます。 malloc のようなシステムコールは不要で、非常に高速です。

ARM — ローカル変数の確保

push {fp, lr}       ; FP, LR を保存
add  fp, sp, #4     ; フレームポインタ設定
sub  sp, sp, #8     ; ローカル変数 8 バイト確保

; ... 関数の処理 ...

sub  sp, fp, #4     ; SP を元に戻す(解放)
pop  {fp, pc}       ; FP 復元、PC = 戻り先

x86-64 — ローカル変数の確保

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 に配置
ARM -mthumb / 最適化時の注意
ARM GCC に -mthumb-O1 以上を指定すると、フレームポインタ(r7 / fp)を 省略してスタックポインタ(SP)からの相対アドレスでローカル変数にアクセスする場合があります。 そのため GCC 出力の [sp, #N] のような書き方を見かけることがあります。
AsmWalker での確認
スタックパネルのセルにマウスを乗せると値とアドレスが表示されます。 mov DWORD PTR [rbp-4], 3 を実行した後に rbp-4 のアドレスのセルに 3 が書き込まれていることを確認してください。

7. スタックオーバーフロー

スタック領域には上限があります。深い再帰や巨大なローカル変数でスタックが限界を超えると、 スタックオーバーフロー(stack overflow)が発生してプログラムがクラッシュします。

// 再帰が深すぎる例(すぐにスタックオーバーフロー)
void infinite(void) {
    int buf[1024];    // 毎回 4KB をスタックに確保
    infinite();       // 戻らずに再帰 → SP が際限なく下がる
}

ARM Cortex-M などの組み込み環境では数 KB しかスタックがないことも多く、 深い再帰や大きな配列のローカル変数には注意が必要です。

AsmWalker での注意
AsmWalker は最大 200〜500 ステップでトレースを打ち切ります。 無限再帰は「最大ステップ数を超えました」エラーになります。 本物のスタックオーバーフローとは現象が異なりますが、無限ループの検出として機能します。

8. まとめ

概念内容
スタックの方向高アドレス → 低アドレスへ伸びる(SP が下がる方向)
PUSHSP -= サイズ → [SP] に値を書き込む
POP[SP] から値を読み出す → SP += サイズ
スタックフレーム1関数が使うスタックの範囲。関数開始で確保、終了で解放
「解放」の意味SP を元の位置に戻すだけ。メモリはクリアされない
ローカル変数FP(またはSP)± オフセットで読み書き
スタックオーバーフローSP が領域限界を超えてクラッシュ