プロセスのメモリレイアウト

このページの目次

プロセスのアドレス空間は、マッピング領域と権限の集合体であり、固定された「コード、データ、ヒープ、スタック」という連続したパーティションではありません。/proc/PID/maps は現在どのアドレスにマッピングされているかを示し、smaps は共有や実ページ(resident pages)などの統計情報を補足します。メモリ使用状況を正しく解釈するには、仮想アドレス、その背後にあるファイルまたは匿名ストレージ、そして実際にメモリにロードされているページを区別する必要があります。

64 ビットだからといって、64 ビットアドレス全体が使用できるわけではない

ポインタの幅、CPU が実装するアドレスのビット数、カーネルのページテーブル設定、およびユーザー空間のアドレス空間範囲は、異なる概念です。x86-64 では一般的な 4 段または 5 段のページテーブル設定があり、arm64 では異なる仮想アドレスの幅とページ粒度の組み合わせがあります。「すべての Linux プロセスが 48 ビットまたは 57 ビットである」という記述は、アーキテクチャに依存するため、普遍的な事実として記述すべきではありません。

アドレス空間には、未マッピングの空洞、予約領域、およびポリシーによって制限された範囲が含まれます。マッピングが成功しても、すべてのページに個別の物理メモリが割り当てられているわけではありません。ページフォールト、ゼロページ、ファイルキャッシュ、およびコピー・オン・ライト(COW)によって、実際のページの状態が段階的に決定されます。

仮想マッピングと物理的な実ページは別々に見る:予約量が大きいからといって、RAM が埋まっているわけではない マッピング A:ファイルコード r-xp、長さ 16 KiB マッピング B:匿名データ rw-p、長さ 16 KiB 実ページ 実ページ 実ページ 例:各マスは 4 KiB。灰色のマスはこの例では実ページとしてカウントされていないが、実際には様々なページ状態が存在しうる maps はアドレスと権限を説明し、smaps の RSS/PSS などは物理的な占有量と共有性を解釈するのに役立つ。

maps の1行を読み解く

70000000-70004000 r-xp 00002000 08:01 12345 /opt/demo

これは人為的に作成されたフォーマットの例です。アドレス範囲は半開区間であり、長さは 0x4000 です。マッピングはファイルオフセット 0x2000 から始まります。r/w/x は読み書き実行権限を、p は private(非共有)、s は shared(共有)を示します。デバイス番号と inode は背後にあるオブジェクトを確認するのに役立ちます。パスに deleted が付いている場合があり、名前自体が安定した識別子であるとは限りません。

もしアドレス 0x70000120 にアクセスした場合、このファイルサポート領域における対応するオフセットは 0x2000 + 0x120 = 0x2120 となります。ただし、.bss などのようにファイルのバイト列に対応しない部分は、一律に読み取り可能なディスクオフセットとして扱うことはできません。proc_pid_maps(5)

ELF はプログラムヘッダのセグメント単位でロードされるため、各行の VMA(仮想メモリ領域)をセクションと1対1で対応付けることはできません。1つの PT_LOAD は、権限の変更などの理由で複数の行に分割されることがあります。.bss の末尾はファイルの最終ページと混在し、その後に匿名のゼロ埋めマッピングが続くことがあります。readelf -lW の offset、filesz、memsz を組み合わせて解釈してください。

private が常に物理ページを独占するわけではない

MAP_PRIVATE は、変更が共有マッピングのように他のマッピング利用者へ書き戻されないことを意味します。fork 後のプライベートな匿名ページは、通常は物理的な内容を共有しており、書き込み時にのみコピーされます。プライベートなファイルマッピングにおいて、変更されていないページはファイルキャッシュと共有されることもあります。

したがって、malloc が mmap を使用して得た通常のプライベートな匿名領域は、fork 後も COW が発生する可能性があります。mmap を使用することは、「fork に優しいので COW が発生しない」という十分な条件ではありません。実際に共有変更を行う MAP_SHARED の例は、共有メモリとセマフォ を参照してください。

親子プロセスを使ってプライベートな変更と共有変更を区別する

以下では、まず2つのページそれぞれに MAP_PRIVATE と MAP_SHARED の匿名マッピングを確立し、同じ初期値 7 を書き込んだ後、fork を実行します。子プロセスは両方のページに 9 を書き込みます。親プロセスは子プロセスの終了を待ってから読み取り、プライベートページは 7 のまま、共有ページは 9 になっています。fork のメモリ関係

#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    long page_size = sysconf(_SC_PAGESIZE);
    if (page_size <= 0) return 1;
    size_t size = (size_t)page_size;
    unsigned char *private_page = mmap(NULL, size, PROT_READ | PROT_WRITE,
                                     MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (private_page == MAP_FAILED) return 1;
    unsigned char *shared_page = mmap(NULL, size, PROT_READ | PROT_WRITE,
                                    MAP_SHARED | MAP_ANONYMOUS, -1, 0);
    if (shared_page == MAP_FAILED) { munmap(private_page, size); return 1; }
    private_page[0] = shared_page[0] = 7;
    pid_t child = fork();
    if (child == 0) {
        private_page[0] = 9;
        shared_page[0] = 9;
        _exit(private_page[0] == 9 && shared_page[0] == 9 ? 0 : 1);
    }
    int failed = child == -1;
    if (!failed) {
        int status;
        pid_t waited;
        do { waited = waitpid(child, &status, 0); }
        while (waited == -1 && errno == EINTR);
        failed = waited != child || !WIFEXITED(status) || WEXITSTATUS(status) != 0;
        if (!failed) {
            printf("parent: private=%u shared=%u\n",
                   (unsigned)private_page[0], (unsigned)shared_page[0]);
            failed = private_page[0] != 7 || shared_page[0] != 9;
        }
    }
    if (munmap(private_page, size) == -1) failed = 1;
    if (munmap(shared_page, size) == -1) failed = 1;
    return failed;
}

これを cow-demo.c として保存し、cc -Wall -Wextra cow-demo.c -o cow-demo でコンパイルして実行します。期待される出力は parent: private=7 shared=9 です。親子プロセスは同じ仮想ポインタ値を見ていますが、プライベートマッピングを通じて異なる内容を見ることができます。したがって、アドレス値だけを比較しても、2つのプロセスが変更を共有しているかどうかを判断することはできません。

この例では waitpid を使用して子プロセスの書き込み完了を確認しており、親プロセスは子プロセスの書き込み期間中に並行してポーリングしていません。実際の共有メモリキューでは、プロセス間の同期が必要であり、「マッピングを shared にする」だけで公開、順序付け、通知が自動的に行われるわけではありません。この実験で検証されているのは可視性のセマンティクスであり、この2つのバイトから直接、カーネルが実際に何ページの物理ページをコピーしたかを測定することはできません。

スタック:メインスレッドと pthread スタックは異なる

メインスレッドのスタックは exec 時に作成され、通常 [stack] として識別されます。条件付きのページフォールト時に拡張可能ですが、リソース制限、隣接するマッピング、および保護規則によって制約されます。これは brk によって割り当てられるものではなく、スタック近くの任意のアドレスを自由にアクセスしても、すべてがカーネルによって自動的に受け入れられるわけではありません。

pthread スタックは通常、スレッドライブラリによって匿名マッピングが作成されます。サイズとガードポリシーは属性によって調整可能であり、メインスタックの自動成長モデルを単純に適用することはできません。デフォルトのサイズは、起動時の制限、プラットフォーム、およびスレッドライブラリによって異なり、固定の 8 MiB ではありません。

名前なしの通常の匿名 VMA は、スレッドスタック、アロケータの arena、JIT、またはその他のオブジェクトに属している可能性があります。現代の maps では、すべてのスレッドスタックに [stack:TID] が表示されることは一般的ではありません。rw-p が表示されたからといって、その位置だけでそれがスタックであると断定することはできません。

ヒープ:malloc のオブジェクトがすべて [heap] 内にあるわけではない

[heap] は通常、プログラムブレークによって管理される従来のヒープ範囲に対応します。malloc は、arena や個別の大きなブロックの割り当てのために、他の匿名マッピングも使用するため、この行を統計しても、すべての動的割り当て量を把握することはできません。

小さな割り当ては、スレッドキャッシュまたは arena の既存の空きブロックから取得される可能性があり、呼び出すたびに brk を使用するわけではありません。独立した mmap を使用するかどうかは、割り当てサイズ、現在の空きブロック、閾値、実行履歴、および特定のアロケータの影響を受け、固定の 128 KiB で一律に判断することはできません。malloc の内部

vDSO、vvar および歴史的な vsyscall

vDSO は、ユーザー空間に提供される小さな ELF コードの断片であり、対応するアーキテクチャとカーネル上で特定のコールを高速化できます。関連する読み取り専用データのマッピングは通常、vvar として表示されます。これらは特殊なカーネルによって提供されるマッピングであり、通常の malloc による匿名ページとして扱うべきではありません。

関数の集合、シンボルバージョン、およびシステムコールへのフォールバックの有無は、実装によって決定されます。clock_gettime が一般的に vDSO を経由する場合でも、すべてのクロックやすべてのカーネルで syscall が実行されないとは限りません。補助ベクトルなどの規約を通じて位置を特定し、アドレスをハードコーディングしないでください。vdso(7)

x86-64 の歴史的な vsyscall は固定のアドレス範囲を使用しており、カーネルによってシミュレートまたは無効化される可能性があり、具体的な動作は設定によって異なります。これはランダム化可能な vDSO とは異なるインターフェースのセットであり、arm64 もこの固定アドレスをそのまま採用していません。

実際のレイアウトを観察する

以下のプログラムは、いくつかのオブジェクトのアドレスを出力し、自身の maps を読み取ります。cat /proc/self/maps における self は cat プロセスを指しており、それを起動したシェルではありません。対象を確認する際は、対象の PID を使用するか、ここでは対象内部で読み取るようにしています。

#include <stdio.h>
#include <stdlib.h>

static int global_value = 7;

int main(void) {
    int local_value = 3;
    void *allocated = malloc(4096);
    if (allocated == NULL) return 1;
    printf("global=%p stack=%p allocated=%p\n",
           (void *)&global_value, (void *)&local_value, allocated);
    FILE *maps = fopen("/proc/self/maps", "r");
    if (maps == NULL) { free(allocated); return 1; }
    char line[1024];
    int failed = 0;
    while (fgets(line, sizeof line, maps) != NULL) {
        if (fputs(line, stdout) == EOF) { failed = 1; break; }
    }
    if (ferror(maps)) failed = 1;
    if (fclose(maps) == EOF) failed = 1;
    free(allocated);
    if (fflush(stdout) == EOF) failed = 1;
    return failed;
}

コンパイル後、3つのアドレスがそれぞれどの区間に属するかを確認します。独立して複数回起動すると、ベースアドレスが変化する場合がありますが、具体的なアドレスの差異は言語のセマンティクスによって保証されるものではありません。初期化せず、かつすべてのページにアクセスしていない割り当ては、「割り当てた分だけ即座に RSS が増加する」という実験には適していません。

RSS、PSS および仮想サイズ

VmSize は仮想アドレス範囲の規模を示し、RSS は実ページをカウントします。共有ページは複数のプロセスの RSS で重複してカウントされる可能性があります。PSS は共有者ごとに按分されるため、一連のプロセスの実ページへの寄与を比較するのに役立ちます。smaps_rollup は集計を提供し、smaps はマッピングごとの詳細を保持します。

これらの指標は、アロケータの使用済みバイト数や、cgroup の会計処理と等しくありません。ページテーブル、カーネルオブジェクト、共有キャッシュ、および統計のタイミングによって、異なる視点が生じます。マッピングとカウントはプロセスの実行に伴って変化するため、複数の /proc ファイルから読み取っても、自動的に同一の原子スナップショットが形成されるわけではありません。カーネル /proc ドキュメント

COW 発生後:2つのプロセスの RSS は不変のまま、合計 PSS は増加する 書き込み前:4つの実ページを共有 親/子 RSS はそれぞれ 16 KiB、PSS はそれぞれ 8 KiB 子プロセスがそのうち1ページをコピーした後 親/子 RSS は依然としてそれぞれ 16 KiB、PSS はそれぞれ 10 KiB 物理ページ:4 → 5;両者の RSS 合計:32 → 32 KiB PSS 合計:16 → 20 KiB。このモデルにおける実際の共有ページ総量により近い ページサイズを 4 KiB、共有者が2つのみですべて実ページであると仮定。ページテーブル、カーネル、およびその他のマッピングは無視。

PSS を別の malloc サイズとして扱うのではなく、ページの勘定として説明する

親子プロセスが 4 つの 4 KiB の実ページを共有し、他の共有者がいないと仮定します。各プロセスの RSS は 4 ページ全体を計算し、PSS はページごとに半分を計算します。子プロセスがそのうち 1 ページで COW を発生させると、3つの共有ページが残され、親と子それぞれに 1 つのプライベートページが加わります。smaps の指標

from fractions import Fraction
page = 4096
before_rss_each = 4 * page
before_pss_each = 4 * Fraction(page, 2)
after_rss_each = (3 + 1) * page
after_pss_each = 3 * Fraction(page, 2) + page
assert before_rss_each == after_rss_each == 16384
assert before_pss_each == 8192
assert after_pss_each == 10240
assert 2 * before_pss_each == 4 * page
assert 2 * after_pss_each == 5 * page

このモデルは、コピー発生後に RSS だけを見ても変化がわからない理由を説明しています。実際の smaps では、共有者の変化、ページのリサイクル、巨大ページ、および統計の精度などの要因にも直面します。ここでの 2 KiB の変化を、cow-demo を実行するたびにプロセス全体で必ず発生する差値として扱うべきではありません。

出力されたアドレスから VMA を逆引きするには、区間の包含関係を検証する

前述のプログラムにおける global、stack、allocated のアドレスは、maps の開始・終了アドレスと個別に比較できます。start ≤ address < end を使用します。もしあるアドレスがちょうど end に等しい場合、それはすでにその行に属していません。パス名はマッピングのソースを説明する補助にはなりますが、この判断に代わるものではなく、オブジェクトがまだ存続していることを証明するものではありません。

アドレスの位置確認済みの情報補足が必要な情報
ファイルマッピング内所属するファイル領域と権限ELF セグメント、シンボル、およびプライベートな変更の有無
[heap] 内従来のプログラムブレーク領域内にあるどの割り当てオブジェクトに対応するか、すでに free されているか
名前なし rw-p 内プライベートな書き込み可能な匿名領域スレッドスタック、arena、またはその他の用途
マッピングなし現在、そのアドレスの通常の VMA はないアドレスのソース、ライフサイクル、およびマッピングの変化

RSS は変わっていないのに、なぜ物理ページが増えたのか?

同じ色で同じ名前のブロックを、同一の物理ページへの2つのマッピングとして理解します。COW が発生するたびに、子プロセスは依然として4ページにマッピングされているため、RSS は変わりません。しかし、そのうち1ページは親プロセスと共有されなくなり、PSS は半ページの課金から全ページの課金に変更されます。ここで統計されているのはこれら4ページのみであり、実際のプロセスの全 smaps の差値と直接同一視することはできません。

図を準備しています
RSS は変わっていないのに、なぜ物理ページが増えたのか?

COW により、以前は共有されていた物理ページが2つのプライベートなコピーに分裂する。2つのプロセスの RSS は不変のままでも、PSS と独立した物理ページの合計は増加する。

アドレスから問題を特定する

まず、障害が発生したアドレスにマッピングがあるかを確認し、次に権限、所属オブジェクト、オフセット、およびライフサイクルを確認する。「アドレスが rw-p マッピング内にある」からといって、オブジェクトへのアクセスが合法であるとは限りません。同じページ内での境界外アクセスや、解放後の使用(use-after-free)である可能性があります。

レイアウトを理解することで特定範囲を絞り込めますが、オブジェクトレベルのエラーにはデバッグシンボルとメモリ検査ツール が依然として必要です。ランダム化と実行権限による保護の効果については、ASLR とセキュリティ強化 を参照してください。

キーワード:virtual memory, VMA, maps, smaps, RSS, PSS, stack, brk, mmap, COW, vDSO, ASLR