前章で、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が後退して”なかったこと”になる。
スタックオーバーフローの正体
スタックには上限がある(通常数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;
}
malloc(size):ヒープからsizeバイトを確保し、その先頭アドレスを返すfree(ptr):確保した領域を返却する
スタックとヒープを厨房で例えるなら:
- スタック=調理中の作業台。今の作業に使い、次の工程に移れば自動で片付く。速いが狭い
- ヒープ=大型倉庫。好きなだけ広い場所を借りられるが、借りたら自分で返さないと、倉庫は借りっぱなしで埋まっていく
メモリリークの正体
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章で扱う)。
まとめ — 「おまじない」の消し方
- メモリには地図がある:テキスト(コード)・静的領域・ヒープ・スタックが同じ空間に同居する
- スタック=自動・高速・LIFO:関数呼び出しを管理。SPの増減だけで確保/解放するから速い
- ローカル変数が消える理由:SPが巻き戻り、フレームが”なかったこと”になるから
- ヒープ=自由・可変・要責任:mallocで確保し、freeで返す。忘れるとメモリリーク
「なぜローカル変数を関数の外に返すと危ないのか」「なぜ動的確保にはfreeが要るのか」——その物理的な理由が見えた。
次章では、このスタックとヒープを結ぶ糸——ポインタの正体に迫る。「難しい」と恐れられるポインタが、実はただの「住所」に過ぎないことを解体していく。
「メモリ管理には二つの流儀しかない。自分で管理して間違えるか、誰か(GC)に任せて遅くなるか。エンジニアは、その選択の意味を知っている者のことだ。」