DIVE

Part 2: アセンブリ・実行モデル

第 5 章

メモリの二つの国 — スタックとヒープ

前章で、Cのコードが機械語になりCPUで実行されることを見た。では、その実行の最中、int x = 5; の 5 や、関数の引数、malloc で確保した領域は——メモリのどこに、どう置かれているのか。

この章では、プログラムのメモリ空間に存在する「二つの国」、スタックとヒープを訪ねる。この2つを理解すると、多くの謎(スタックオーバーフロー、メモリリーク、なぜローカル変数は関数を抜けると消えるのか)が氷解する。


プロセスのメモリ地図

プログラムが実行されるとき、OSはそのプロセスに連続したメモリ空間を割り当てる。その地図はおおよそこうなっている。

高アドレス
┌─────────────────────┐
│      スタック        │  ↓ 下に伸びる(関数呼び出しで成長)
│         │           │
│         ▼           │
│                     │
│    (空き領域)      │
│                     │
│         ▲           │
│         │           │
│      ヒープ          │  ↑ 上に伸びる(malloc で成長)
├─────────────────────┤
│   静的領域 (BSS/Data) │  グローバル変数・静的変数
├─────────────────────┤
│   テキスト領域        │  機械語コードそのもの
└─────────────────────┘
低アドレス

面白いのは、スタックとヒープが向かい合って伸びること。限られた空き領域を、両側から使っていく。スタックは高アドレスから下へ、ヒープは低アドレスから上へ。両者が衝突すればメモリ不足だ。


スタック — 規律正しい積み重ね

スタック(Stack)は、その名の通り「積み重ね」の構造だ。皿を積むように、後に置いたものを先に取る(LIFO: Last In, First Out)。

スタックの主な用途は関数呼び出しの管理だ。関数を呼ぶたびに「スタックフレーム」という箱が積まれ、関数を抜けると取り除かれる。1つのフレームには、その関数のローカル変数・引数・戻り先アドレスが入る。

int add(int a, int b) {
    int result = a + b;   // ローカル変数 result はスタックに置かれる
    return result;
}

int main(void) {
    int x = add(3, 4);    // add を呼ぶ → スタックに add のフレームが積まれる
    return 0;             // add から戻る → フレームが取り除かれる
}

呼び出しの様子を図にすると:

main呼び出し中:          add呼び出し中:            addから戻った後:
┌──────────┐            ┌──────────┐              ┌──────────┐
│ main     │            │ add      │ ← 積まれる    │ main     │
│  x       │            │  a=3,b=4 │              │  x=7     │ ← resultが返る
└──────────┘            │  result  │              └──────────┘
                        ├──────────┤
                        │ main     │
                        │  x       │
                        └──────────┘

なぜスタックは速く、自動なのか

スタックの確保・解放は、スタックポインタ(SP)というレジスタの値を増減させるだけだ。第3章のレジスタを思い出してほしい。SPを下げれば領域確保、戻せば解放。たった1命令で済む。

領域確保:SP←SP−(必要バイト数)\text{領域確保:}\quad SP \leftarrow SP - (\text{必要バイト数})

だからスタックは桁違いに速く、かつ関数を抜ければ自動で解放される。ローカル変数が「関数を抜けると消える」のは、フレームごとSPが巻き戻され、その領域が無効になるからだ。消えるのではなく、SPが後退して”なかったこと”になる。

スタックオーバーフローの正体

スタックには上限がある(通常数MB)。関数呼び出しが深くなりすぎると、スタックが伸びきって限界を超える。これがスタックオーバーフローだ。典型例は、終わらない再帰。

def infinite(n):
    return infinite(n + 1)   # 戻らずに呼び続ける → フレームが積まれ続ける

infinite(0)   # RecursionError: maximum recursion depth exceeded

呼び出すたびにフレームが積まれ、解放されないまま上限に達する。あの有名なQ&Aサイトの名前の由来でもある。


ヒープ — 自由と引き換えの責任

スタックは高速で自動だが、制約がある:サイズが固定で、関数を抜けると消える。「実行中にサイズが決まるデータ」「関数を越えて生き残るデータ」には使えない。

そこでヒープ(Heap)の出番だ。ヒープは必要なときに必要なだけ、明示的に確保する自由な領域。その代わり、解放も自分の責任になる。

#include <stdlib.h>

int main(void) {
    // 実行時に決まるサイズの配列をヒープに確保
    int n = 100;
    int *arr = malloc(n * sizeof(int));   // ヒープから確保

    if (arr == NULL) return 1;            // 確保失敗のチェック

    for (int i = 0; i < n; i++) arr[i] = i * i;

    free(arr);                            // ← 使い終わったら必ず解放する
    return 0;
}

スタックとヒープを厨房で例えるなら:

メモリリークの正体

free を忘れると、その領域は「使われていないのに、確保されたまま」になる。プログラムが動き続ける限り、こうした返し忘れが積もると、いずれメモリが枯渇する。これがメモリリークだ。

void leak(void) {
    int *p = malloc(1000);
    // free(p) を忘れる! → 関数を抜けると p (アドレス) は消えるが、
    // ヒープ上の1000バイトは確保されたまま、誰も解放できなくなる
}

ここが重要だ。ローカル変数 p(スタック上のポインタ)は関数を抜けると消える。だが p が指していたヒープ上の実体は残る。アドレスを失った領域は、二度と解放できない迷子になる。この「スタックのポインタとヒープの実体」の関係こそ、次章「ポインタの真実」の核心だ。


スタック vs ヒープ 比較

観点スタックヒープ
確保/解放自動(SPの増減)手動(malloc / free)
速度非常に速い比較的遅い(管理が要る)
サイズ小さい(数MB)・固定大きい・可変
寿命関数を抜けると消えるfree するまで生きる
主な用途ローカル変数・関数呼び出し実行時サイズ・長寿命のデータ
失敗の形スタックオーバーフローメモリリーク・解放漏れ

言語による違いも面白い。CやRustは「どちらを使うか」を強く意識させる。一方、Pythonやジャバスクリプトはほとんどのオブジェクトをヒープに置き、解放をGC(ガベージコレクタ)が自動化している。「メモリ管理を意識しなくていい」言語は、この面倒をランタイムが肩代わりしているだけで、下では同じことが起きている(GCの裏側は第12章で扱う)。


まとめ — 「おまじない」の消し方

  1. メモリには地図がある:テキスト(コード)・静的領域・ヒープ・スタックが同じ空間に同居する
  2. スタック=自動・高速・LIFO:関数呼び出しを管理。SPの増減だけで確保/解放するから速い
  3. ローカル変数が消える理由:SPが巻き戻り、フレームが”なかったこと”になるから
  4. ヒープ=自由・可変・要責任:mallocで確保し、freeで返す。忘れるとメモリリーク

「なぜローカル変数を関数の外に返すと危ないのか」「なぜ動的確保にはfreeが要るのか」——その物理的な理由が見えた。

次章では、このスタックとヒープを結ぶ糸——ポインタの正体に迫る。「難しい」と恐れられるポインタが、実はただの「住所」に過ぎないことを解体していく。


「メモリ管理には二つの流儀しかない。自分で管理して間違えるか、誰か(GC)に任せて遅くなるか。エンジニアは、その選択の意味を知っている者のことだ。」