メモリレイアウト

Basis
最終更新: タグ: Memory, Systems

実行中のプログラムは OS から RAM のかたまりを渡される。そのかたまりは一様な塊ではなく、プログラムはそれをいくつかの異なる領域に分割する。各領域には、何が存在してどれだけ生き続けるかについて異なるルールがある。これらの領域を理解することが、Rust の所有権システムを恣意的ではなく論理的に感じさせるメンタルモデルになる — C、C++、Go、そして事実上あらゆるシステムプログラミング言語にも等しく当てはまる。

メモリは番号付きのバイトが並ぶ長い列

プログラムの動き方では、プログラムとは CPU が従う命令のリストだということを学んだ。OS がプログラムを起動するとき、それらの命令を RAM に読み込み、どこから実行を開始するかを CPU に伝える。

CPU から見ると、RAM はバイト(byte)が並ぶ長くフラットな列だ。各バイトは 0 から 255 の間の値を1つだけ保持できる小さな箱だ。RAM が有用なのは、すべてのバイトがアドレス(address)と呼ばれる固有の番号を持っているからだ。CPU は「アドレス 4,200 のバイトをくれ」と言えば、ナノ秒単位でそれを受け取れる。

番号付きのメールボックスが長く並んでいるイメージだ:

アドレス:  0     1     2     3     4     5     6     7    ...
         ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
値:      │  72 │  65 │ 108 │ 108 │ 111 │   0 │ ... │ ... │ ...
         └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘

現代の 64 ビットプログラムが見るアドレス空間は実際のマシンの RAM よりはるかに大きい — OS は仮想メモリ(virtual memory)と呼ばれる仕組みでそのギャップを埋める — しかし重要な抽象化は成り立つ。プログラムの視点では、メモリは巨大なバイトの配列であり、それぞれに固有の番号が付いている。

プログラムメモリの4つの領域

プログラムが起動すると、OS はそのメモリを4つの領域に設定する。それぞれ異なる役割を持つ:

高アドレス
┌──────────────────┐
│      Stack       │  ← 関数呼び出しのたびに下方向に伸びる
│        ↓         │
│                  │
│        ↑         │
│       Heap       │  ← メモリを要求するにつれ上方向に伸びる
├──────────────────┤
│       Data       │  グローバル変数および静的変数
├──────────────────┤
│       Code       │  コンパイル済みプログラム命令
└──────────────────┘
低アドレス

コード領域

コード領域(code region、テキストセグメントとも呼ぶ)は、プログラムのコンパイル済みマシン命令を保持する — CPU が読んで実行する実際のバイナリだ。起動時に一度読み込まれ、プログラムが動いている間は変わらない。

データ領域

データ領域(data region)はグローバル変数(global variable)を保持する — どの関数の外にも宣言され、プログラムの全ライフタイムにわたって存在する値だ。ほとんどのプログラムはこの領域を小さく保つ。面白いアクションはスタックとヒープで起きる。

スタックとヒープ

スタック(stack)とヒープ(heap)は、プログラマーとして最もよく考えることになる2つの領域だ。まったく逆の戦略でメモリを扱う。スタックは自動的だが制約がある。ヒープは柔軟だが手動管理が必要だ。それぞれを詳しく見ていこう。

スタック:関数呼び出しに従うメモリ

カフェテリアの皿のスタックを想像してほしい。皿は上に積まれ、上から取られる — 中から取り出すことはできない。最後に置かれた皿が最初に取られる。計算機科学者はこれを LIFOLast In, First Out:後入れ先出し)と呼ぶ。この仕組みを中心に構築された抽象データ構造自体もスタック(stack)と呼ばれる。

プログラムのコールスタックはまったく同じ仕組みで動くが、皿の代わりにスタックフレーム(stack frame)を扱う。スタックフレームとは、CPU が1つの関数呼び出しのために確保するメモリのブロックだ。その関数の中で宣言されたすべてのローカル変数と、関数が戻ったときに呼び出し元を再開するために CPU が必要とするいくつかの管理情報を保持する。フレームがどのようにプッシュ・ポップされ、関数呼び出しをまたいで接続されるかの詳細な仕組みは Calling Stack で扱う。ここではメモリレイアウトにとってどういう意味があるかだけに注目する。

ある関数が別の関数を呼び出すときに起きることを示す:

  1. 呼び出し元のフレームがすでにスタックにある。
  2. 呼び出し先が新しいフレームを先頭にプッシュ(push)する — そのローカル変数がそこに確保される。
  3. 呼び出し先は自分のフレーム内のローカル変数を読み書きしながら実行する。
  4. 呼び出し先が戻ると、そのフレームは瞬時にポップ(pop)される。そのメモリは次の関数呼び出しに即座に使える。

つまりローカル変数は厳密に一時的だ。関数が呼び出されたときに存在し始め、戻った瞬間に消える。ハードウェアがこれをすべて管理する — クリーンアップするコードを書く必要はない。

Zig で確認する

スタックの動きを示す小さな Zig プログラムを示す:

const std = @import("std");

fn multiply(a: i32, b: i32) i32 {
    const product = a * b; // 'product' は multiply のスタックフレームに存在する
    return product;        // multiply() が戻るとフレームは解放される
}

pub fn main() void {
    const x: i32 = 6;
    const y: i32 = 7;
    const result = multiply(x, y); // result は main のスタックフレームに存在する
    std.debug.print("{}\n", .{result});
}

multiply が実行中のとき、コールスタックは大まかにこのようになる:

  スタックの先頭(最後にプッシュされたもの)
  ┌──────────────────────────┐
  │  multiply のフレーム      │
  │    a:       i32 = 6      │
  │    b:       i32 = 7      │
  │    product: i32 = 42     │
  ├──────────────────────────┤
  │  main のフレーム          │
  │    x:      i32 = 6       │
  │    y:      i32 = 7       │
  │    result: i32 = ?       │  ← まだ埋まっていない
  └──────────────────────────┘
  スタックの底

multiply が戻った瞬間、そのフレーム全体が消える。main は返された 42 を受け取って result に格納し、処理を続ける — 今度は自分のフレームだけがスタックにある。

型は何バイト占めるか

スタック上のすべての変数は、型によって決まる固定のバイト数を占める。Zig では @sizeOf でコンパイル時にこれを調べられる:

const std = @import("std");

pub fn main() void {
    std.debug.print("bool: {} byte(s)\n", .{@sizeOf(bool)});
    std.debug.print("u8:   {} byte(s)\n", .{@sizeOf(u8)});
    std.debug.print("u32:  {} byte(s)\n", .{@sizeOf(u32)});
    std.debug.print("u64:  {} byte(s)\n", .{@sizeOf(u64)});
    std.debug.print("f32:  {} byte(s)\n", .{@sizeOf(f32)});
    std.debug.print("f64:  {} byte(s)\n", .{@sizeOf(f64)});
}

実行すると次のようになる:

bool: 1 byte(s)
u8:   1 byte(s)
u32:  4 byte(s)
u64:  8 byte(s)
f32:  4 byte(s)
f64:  8 byte(s)

これらの数字は型のビット幅を反映している。u32 は 32 ビット(4 バイト)の符号なし整数(unsigned integer)、u64 は 64 ビット(8 バイト)、といった具合だ。CPU はこれらのサイズを効率よく扱えるよう設計されている。

@sizeOf は構造体(struct)にも使える。構造体のサイズはフィールドのサイズの合計だ — ただしコンパイラが挿入するパディング(padding)が加わることがある(後で説明する):

const std = @import("std");

const Point = struct {
    x: f32,
    y: f32,
};

pub fn main() void {
    // f32 フィールドが2つ、各4バイト → 合計8バイト
    std.debug.print("Point: {} bytes\n", .{@sizeOf(Point)});
}

スタックの制約

スタックは非常に高速だが、2つの固い制約がある。

サイズの制限。 スタックは固定された、比較的小さなメモリ領域だ — 通常は数メガバイト。関数呼び出しを深くネストしすぎたり、ローカル変数として非常に大きな配列を宣言したりすると、スタックの空きがなくなる。これが悪名高いスタックオーバーフロー(stack overflow)エラーだ。スタックが限界を超えて、触れてはいけない領域に踏み込んでしまった状態だ。

ライフタイムの制限。 ローカル変数はその関数のフレームがスタックにある間だけ存在する。関数が戻ると、変数は消える。それを所有する関数が戻った後、ローカル変数を保持し続けることはできない。Rust のライフタイム(lifetime)システムはこのルールをコンパイル時に強制するが、そのルールの根本的な理由はスタックに関するこの物理的な事実にある。

ヒープ:自分で制御するメモリ

スタックの制約はほとんどのローカルな作業には問題ないが、以下のようなメモリが必要になることがある:

  • 関数呼び出しを越えて生き続ける — リストを構築してプログラムの他の部分に渡したい場合など。
  • 実行時に成長する — ユーザーがいくつの項目を追加するか、プログラムが動くまでわからない。
  • 単純にスタックに収まらない大きさ — 画像やビデオフレーム全体を保持するバッファは、典型的なスタックの限界を軽く超える。

これらすべてのために、ヒープに頼ることになる。ヒープとは、プログラムが実行中のどの時点でも借りられる大きな汎用メモリプールだ。

スタックとの重要な違い:ヒープは自動ではない。ヒープにメモリを確保すると、システムはそれらのバイトを使用中とマークしてあなたに渡す。終わったときは、明示的にシステムに伝えて解放済みとマークさせなければならない。何もヒープメモリを解放してくれない。

この自由には責任が伴う。ヒープメモリを手動で管理するほぼすべてのコードベースに2つの古典的な誤りが現れる:

  • メモリリーク(memory leak)— ヒープメモリを確保して解放しない。メモリは永遠に予約されたままで、プログラムまたはシステム全体が止まるまで使用可能な RAM をじわじわ使い果たす。
  • use-after-free — ヒープメモリのブロックを解放して、誤ってまたアクセスする。それらのバイトはすでに全く別のものに再利用されている可能性がある。結果として起きる動作は未定義で、しばしば壊滅的だ。

これら2つのバグのクラスは、現実世界のセキュリティ脆弱性とクラッシュの膨大な数の源だ。異なる言語はそれらを防ぐために異なる戦略を選んでいる:

言語ヒープ管理戦略
C手動: malloc / free を明示的に呼ぶ
Zig手動: アロケータ API を明示的に呼ぶ
C++RAII による手動管理。スマートポインタも使える
Rust所有権システムがコンパイル時に正しさを保証する
Go / Java / Pythonガベージコレクタが到達不能なメモリを自動的に回収する

このコースでは最初に Zig で手動ヒープ管理を練習する。手動で正しく書くために必要な規律を身をもって感じることで、Rust の自動的なアプローチがずっと満足感を伴うものになる — それが何をしてくれているかを正確に理解できるからだ。

今は概念だけ把握しておこう。ヒープは存在する。大きい。そして意図的な管理が必要だ。ヒープメモリを実際に確保・解放する仕組みは ヒープメモリの確保 で扱う。

アライメント:データがぴったり詰まらない理由

メモリ上のデータがどのように配置されるかを形作る概念がもう一つある。アライメント(alignment)だ。

CPU はメモリを固定サイズのチャンクで読み書きする — 通常は一度に 4 または 8 バイトだ。これを効率よく行うために、ハードウェアは値をその値のサイズの倍数のアドレスに格納することを要求する。4 バイトの u32 は 4 で割り切れるアドレス(0、4、8、12 …)から始まるべきだ。8 バイトの u64 は 8 で割り切れるアドレスから始まるべきだ。この要求を型のアライメントと呼ぶ。

Zig は @alignOf でアライメントを確認できる:

const std = @import("std");

pub fn main() void {
    std.debug.print("@alignOf(u8):  {}\n", .{@alignOf(u8)});
    std.debug.print("@alignOf(u16): {}\n", .{@alignOf(u16)});
    std.debug.print("@alignOf(u32): {}\n", .{@alignOf(u32)});
    std.debug.print("@alignOf(u64): {}\n", .{@alignOf(u64)});
}

出力:

@alignOf(u8):  1
@alignOf(u16): 2
@alignOf(u32): 4
@alignOf(u64): 8

構造体のパディング

コンパイラが構造体をメモリにレイアウトするとき、各フィールドを正しくアライメントするために、フィールド間に見えないパディング(padding)バイトを挿入する。そのため構造体のサイズはフィールドの合計よりも大きくなることが多い。

const std = @import("std");

const Inefficient = struct {
    a: u8,  // オフセット 0 に 1 バイト
            // b が 4 の倍数から始まるよう 3 バイトのパディング
    b: u32, // オフセット 4 に 4 バイト
    c: u8,  // オフセット 8 に 1 バイト
            // 全体を 4 の倍数にするため末尾に 3 バイトのパディング
};
// u8 + u32 + u8 = データは 6 バイトだが...

const Efficient = struct {
    b: u32, // オフセット 0 に 4 バイト
    a: u8,  // オフセット 4 に 1 バイト
    c: u8,  // オフセット 5 に 1 バイト
            // 末尾に 2 バイトのパディング
};

pub fn main() void {
    std.debug.print("Inefficient: {} bytes\n", .{@sizeOf(Inefficient)});
    std.debug.print("Efficient:   {} bytes\n", .{@sizeOf(Efficient)});
}

実行すると 128 が出る — フィールドを並び替えるだけで 4 バイトの違いだ。両方の構造体は同じデータを保持しているが、コンパイラの配置が違う。このような構造体がメモリ上に何百万個もあるとき、あるいは固定サイズのネットワークパケットにデータを詰めるときは、フィールドの並び順が重要になる。

この動作は Zig に限ったことではない。ネイティブコードにコンパイルするあらゆる言語 — C、C++、Rust — が同じハードウェア上で同じアライメントルールに従う。

まとめ

  • プログラムの視点では、メモリは長いバイトの配列で、各バイトは固有のアドレスで識別される。
  • 実行中のプログラムのメモリは4つの領域に分割される:
    • Code — コンパイル済みマシン命令。
    • Data — グローバル変数および静的変数。
    • Stack — ローカル変数。関数呼び出しの仕組みによって自動的に管理される。
    • Heap — 明示的に要求・解放する大きなメモリプール。
  • スタックは LIFO だ。関数呼び出しのたびにその関数のローカル変数を保持するスタックフレームがプッシュされ、戻るとフレームがポップされてその変数が瞬時に解放される。スタックは高速で自動的だが、サイズが限られていて(通常数メガバイト)、ライフタイムも限られている(ローカル変数は関数より長く生きられない)。
  • ヒープは柔軟で大きいが手動だ。終わったときにヒープメモリを明示的に解放しなければならない。忘れるとメモリリークになり、解放後にアクセスすると use-after-free バグになる。Rust の所有権システムは両方をコンパイル時に防ぐ。
  • すべての型はサイズ(占有バイト数、@sizeOf で確認できる)とアライメント(開始しなければならないアドレス境界、@alignOf で確認できる)を持つ。コンパイラはアライメントを満たすために構造体フィールド間にパディングを挿入する。そのため構造体の @sizeOf はフィールドの合計を超えることがある。
  • スタックとヒープは普遍的な概念だ。C、C++、Zig、Rust、Go、Java はすべて同じ基礎モデルで動いている。

次のステップ

メモリレイアウトを理解したら、自然につながるいくつかのトピックが待っている:

  • ヒープメモリの確保 — Zig でヒープメモリを要求・解放する方法と、間違えたときに何が起きるか。
  • Pointer — メモリアドレスを格納する値で、ヒープメモリを扱うための根本的なツールであり、関数間でデータを効率よく渡す手段でもある。
  • Calling Stack — スタック領域が実際に関数の呼び出しと戻りをどう動かすかをより詳しく見る。
  • Stack — プログラムメモリとしての役割から切り離した、抽象的な LIFO データ構造としてのスタック。