DIVE

Part 3: カーネルと仮想化

第 8 章

仮想メモリ — カーネルが見せる「あなただけの世界」

前章の最後で、私はある謎を残した。複数のプログラムがみな「アドレス 0x1000」を使っているのに、なぜ衝突しないのか。

第6章でポインタを学んだとき、変数 x は 0x1000 番地に住んでいた。だが、別のプログラムを同時に動かせば、そちらの変数もまた 0x1000 を使いうる。同じ物理メモリの同じ番地を、二つのプログラムが取り合ったら破綻するはずだ。

だが破綻しない。なぜか。答えは——あなたが見ている「アドレス」は、そもそも本物ではないからだ。


あなたのアドレスは「嘘」だった — 歴史の必然

初期のコンピュータでは、プログラムが使うアドレスは物理メモリの番地そのものだった。これを物理アドレスという。だが、これには三重の問題があった。

  1. 衝突問題: 2つのプログラムが同じ物理番地を使えば、互いを破壊する
  2. 容量問題: 物理メモリ(例: 8GB)より大きなデータは扱えない
  3. 断片化問題: 空きメモリが飛び飛びになると、大きな連続領域を確保できない

人類の解決策は鮮やかだった。「アプリが見るアドレス」と「物理メモリの番地」を切り離す。各プログラムには、あたかも自分だけが広大なメモリ空間を独り占めしているかのような幻想を見せる。この幻のアドレスを仮想アドレス、幻の空間を仮想アドレス空間と呼ぶ。

64ビットマシンなら、各プログラムは理論上 2642^{64} バイト——約1600万テラバイト——の「自分だけの世界」を持てる。実際の物理メモリが8GBしかなくても、だ。

仮想アドレス空間の理論最大=264 バイト≈1.8×1019 バイト\text{仮想アドレス空間の理論最大} = 2^{64}\ \text{バイト} \approx 1.8 \times 10^{19}\ \text{バイト}

厨房のアナロジー — 番号札と実際の棚

飲食店の巨大なクロークを想像してほしい。客(プログラム)は預けた荷物に 「1番」「2番」という番号札(仮想アドレス) を受け取る。

だが、その番号札は実際の棚の位置とは無関係だ。客Aの「1番」の荷物は奥の棚に、客Bの「1番」は手前の棚に、係員(カーネル + MMU)がバラバラに収納している。客同士は同じ「1番」という札を持っているのに、絶対に取り違えない。番号札から実際の棚を対応づける台帳を、係員が持っているからだ。

私が働いていた店でも、テーブル番号(客が見る番号)と、厨房の調理順(実際の処理)は別管理だった。客席の「5番テーブル」がどの席かは、フロア係の頭の中の対応表で決まる。仮想メモリのページテーブルは、まさにこの「対応表」だ。

プログラムA が見る世界        対応表(ページテーブル)      物理メモリ(現実)
┌───────────────┐                                  ┌───────────────┐
│ 仮想 0x1000    │ ──────▶ [0x1 → 物理 0x8F] ─────▶ │ 物理 0x8F000   │
├───────────────┤                                  ├───────────────┤
│ 仮想 0x2000    │ ──────▶ [0x2 → 物理 0x3A] ─────▶ │ 物理 0x3A000   │
└───────────────┘                                  ├───────────────┤
プログラムB が見る世界                                │ 物理 0x12000   │
┌───────────────┐                                  ├───────────────┤
│ 仮想 0x1000    │ ──────▶ [0x1 → 物理 0x12] ─────▶ │ ...           │
└───────────────┘        (Bは別の対応表を持つ)       └───────────────┘

同じ仮想 0x1000 でも、対応表が違うから別の物理番地に着地する

ページング — メモリを「区画」で管理する

仮想アドレスと物理アドレスを1バイト単位で対応づけるのは非現実的だ(対応表が巨大になりすぎる)。そこで、メモリを固定サイズの区画に区切って管理する。この区画をページと呼ぶ。典型的なサイズは 4KB(4096バイト) だ。

仮想アドレスは、上位ビットのページ番号と、下位ビットのオフセット(ページ内の位置)に分解される。4KBページなら下位12ビットがオフセットだ(212=40962^{12} = 4096)。

仮想アドレス=(ページ番号×4096)+オフセット\text{仮想アドレス} = (\text{ページ番号} \times 4096) + \text{オフセット}

翻訳の手順はこうだ:

  1. 仮想アドレスからページ番号を取り出す
  2. ページテーブルを引いて、対応するフレーム番号を得る
  3. フレーム番号 × 4096 にオフセットを足して、物理アドレスを得る
仮想アドレス 0x00002ABC (4KBページ)
┌────────────────────┬──────────────┐
│  ページ番号 = 0x2   │ オフセット=0xABC │
└─────────┬──────────┴──────────────┘
          │ ページテーブルを引く
          ▼
      フレーム = 0x3A
          │
          ▼
物理アドレス = 0x3A * 0x1000 + 0xABC = 0x0003AABC
              └─ フレーム ─┘  └オフセットはそのまま┘

オフセットは翻訳で変化しない点に注目してほしい。ページ内の相対位置は、仮想でも物理でも同じだからだ。


MMU と TLB — ハードウェアが翻訳を担う

この翻訳を毎回ソフトウェア(カーネル)が行っていたら遅すぎる。メモリアクセスは1命令ごとに発生するからだ。そこで、翻訳専用のハードウェア——MMU(Memory Management Unit)——がCPUに内蔵されている。プログラムがメモリを触るたび、MMUが自動でページテーブルを引き、仮想→物理の翻訳を行う。

だが、ページテーブル自体もメモリ上にある。翻訳のためにメモリを読み、そのために翻訳し……では本末転倒だ。しかも64ビットでは、ページテーブルは巨大になるため多段(4段など)に分割される。1回の翻訳に複数回のメモリアクセスが要る。

そこで登場するのが TLB(Translation Lookaside Buffer) だ。TLBは「最近使った翻訳結果」を保存する超高速なキャッシュで、MMUの中にある。

メモリアクセスの流れ:
  仮想アドレス
     │
     ▼
  ┌───────┐  ヒット(数サイクル)  物理アドレス確定 ✓
  │  TLB  │ ───────────────▶
  └───┬───┘
      │ ミス
      ▼
  ページテーブルを辿る(多段: 遅い)→ 結果をTLBに保存 → 物理アドレス確定

TLBヒット率が性能を左右する。データがメモリ上で局所的にまとまっているほどTLBは効く。第17章で行列演算を扱うとき、「メモリアクセスの順序」が性能を激変させるのを見るが、その根っこにはこのTLBとキャッシュの局所性がある。


ページフォルトとスワップ — 物理より大きな幻想

仮想メモリの真骨頂は、物理メモリより大きな空間を扱えることだ。どうやって? カラクリはこうだ。

すべての仮想ページが、常に物理フレームに割り当てられているわけではない。使われていないページはディスクに退避しておき、実際にアクセスされた瞬間に物理メモリへ運び込む。この「今この仮想ページは物理メモリにいません」という状態でアクセスが起きると、MMUがページフォルトという例外を発生させる。

アプリが仮想ページXにアクセス
     │
     ▼
  TLB/ページテーブルに物理対応が無い(Present=0)
     │
     ▼
  ★ ページフォルト(CPU例外)→ カーネルモードへ!
     │
     ▼
  カーネルがディスクから該当ページを物理フレームに読み込む
  (空きが無ければ、使っていないページを追い出す = スワップ)
     │
     ▼
  ページテーブルを更新し、アプリの命令を「やり直す」

ページフォルトは前章のシステムコールと同じく、ユーザーモードからカーネルモードへの遷移だ(違いは、アプリが明示的に頼んだのではなく、ハードウェアが自動で発生させる点)。この仕組みのおかげで、8GBの物理メモリで16GBのデータを扱える。ただし、ディスクは桁違いに遅いため、ページフォルトが多発するとシステムは激遅になる。これがスラッシングだ。

mmap で「幻想」を体感する

ファイルを、あたかもメモリ上の配列であるかのように扱える mmap を見てみよう。巨大なファイルでも、実際に触ったページだけが物理メモリへ運ばれる。

#include <sys/mman.h>
#include <fcntl.h>
#include <stdio.h>

int main(void) {
    int fd = open("huge.bin", O_RDONLY);
    /* ファイル全体を仮想アドレス空間に「地図として貼る」だけ。
       この時点では物理メモリはほぼ消費しない */
    char *data = mmap(NULL, 1L << 30 /* 1GB */,
                      PROT_READ, MAP_PRIVATE, fd, 0);

    /* 実際に触った瞬間、その 4KB ページだけがページフォルト経由で運ばれる */
    printf("先頭バイト: %d\n", data[0]);          /* ここで1ページだけロード */
    printf("500MB地点: %d\n", data[500L << 20]);  /* ここで別の1ページだけロード */

    munmap(data, 1L << 30);
    close(fd);
    return 0;
}

1GBを「貼った」のに、実際に消費する物理メモリは触った数ページ分だけ。これが仮想メモリの魔法だ。


セグメンテーション違反の正体

第6章で見た「セグメンテーション違反(segfault)」を、今なら正確に説明できる。

int *p = NULL; *p = 5; が落ちるのは、仮想アドレス 0(NULL)に対応する物理フレームが割り当てられていないからだ。カーネルは、アドレス0付近を意図的に「未マップ領域」にしている。そこへアクセスすると、ページフォルトが起きるが、カーネルは「これは正当なページではない」と判断し、プロセスに SIGSEGV シグナルを送って強制終了させる。

つまりsegfaultは、仮想メモリの保護機構が正しく働いた証なのだ。もし仮想メモリがなければ、NULLへの書き込みは物理番地0を破壊し、システム全体を巻き込んでいただろう。あなたのプログラムだけが死んで、OSも他のプログラムも無事——それは仮想メモリという壁のおかげだ。


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

  1. アプリが見るアドレスは仮想:物理番地とは切り離された「幻のアドレス」を各プログラムが独占する
  2. ページテーブルが対応表:仮想ページ→物理フレームの対応を管理し、同じ仮想番地でも別の物理番地に着地させる
  3. MMUとTLBが高速翻訳:ハードウェアが自動翻訳し、TLBが最近の結果をキャッシュする
  4. ページフォルトで物理を超える:使わないページはディスクへ退避し、必要時に運ぶ。だから物理より大きく扱える
  5. segfaultは保護の証:未マップの仮想アドレスへのアクセスをカーネルが検知し、そのプロセスだけを止める

「なぜプログラムは互いのメモリを壊さないのか」「なぜ物理メモリより大きなデータを扱えるのか」——その答えは、この仮想化の幻想装置にあった。

ここまでで、カーネルは「メモリの世界」をプログラムごとに分離できるようになった。だが分離できるのはメモリだけだろうか。プロセスID、ネットワーク、ファイルシステム——あらゆる資源を丸ごと分離できたら? 次章では、その発想が生んだ現代の主役、コンテナ(namespaces・cgroups)の正体へ踏み込む。


「最も優れた幻想とは、それが幻想だと気づかせないものだ。あなたのプログラムは、自分がマシンを独り占めしていると信じたまま、一生を終える。」