进程内存布局

本页目录

进程地址空间是映射区域及权限的集合,并非一条固定的“代码、数据、堆、栈”连续分区。/proc/PID/maps 描述哪些地址当前有映射,smaps 则补充驻留与共享等统计。先区分虚拟地址、底层文件或匿名存储以及实际驻留页,才能正确解释内存使用。

64 位不意味着可用全部 64 位地址

指针宽度、CPU 实现的地址位数、内核页表配置和用户态地址空间范围是不同概念。x86-64 常见四级或五级页表配置,arm64 又有不同的虚拟地址宽度与页粒度组合。不能把“每个 Linux 进程都是 48 位或 57 位”写成跨架构事实。

地址空间中有未映射空洞、保留区域和受策略限制的范围。映射成功后也不一定已经为每一页分配独立物理内存;缺页、零页、文件缓存与写时复制会逐步决定实际页状态。

虚拟映射与物理驻留分开看:预留大,不等于已占满 RAM 映射 A:文件代码 r-xp,长度 16 KiB 映射 B:匿名数据 rw-p,长度 16 KiB 驻留 驻留 驻留 示例每格 4 KiB;灰色格未在本例中计入驻留,实际可有多种页状态 maps 说明地址与权限;smaps 的 RSS/PSS 等帮助解释物理占用和共享。

读懂 maps 的一行

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 按程序头 segment 加载,不能将每行 VMA 与一个 section 一一对应。一个 PT_LOAD 可因权限调整等原因拆成多行;.bss 尾部可与文件末页混合,再接匿名零填充映射。结合 readelf -lW 的 offset、filesz、memsz 解释。

private 不是始终独占物理页

MAP_PRIVATE 表示修改不会按共享映射方式写回给其他映射使用者。fork 后的私有匿名页通常先共享物理内容,写入时才复制;私有文件映射的未修改页也可共享文件缓存。

因此 malloc 用 mmap 得到的普通私有匿名区域,在 fork 后仍可能发生 COW。使用 mmap 不是“fork 友好所以没有 COW”的充分条件。真正共享修改的 MAP_SHARED 例子见 共享内存与信号量。

用父子进程区分私有修改与共享修改

下面先为两页分别建立 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。父子进程看到相同的虚拟指针值,却可以通过私有映射看到不同内容;因此仅比较地址数值,不能判断两个进程是否共享修改。

本例用 waitpid 确认子进程已完成写入,父进程没有在子进程写入期间并发轮询。实际共享内存队列还需要进程间同步,不能把“映射为 shared”当成自动完成发布、排序和通知。实验验证的是可见性语义,不能从这两个字节直接测出内核到底复制了多少物理页。

栈:主线程与 pthread 栈不同

主线程栈在 exec 时建立,常以 [stack] 标识,并可在符合条件的缺页时扩展,受资源限制、相邻映射和保护规则约束。它不是由 brk 分配,随意访问栈附近任意地址也不会都被内核自动接纳。

pthread 栈通常由线程库建立匿名映射,大小与 guard 策略可通过属性调整,不能简单照搬主栈的自动增长模型。默认大小依启动时限制、平台和线程库而变,不是固定 8 MiB。

普通无名字的匿名 VMA 可能属于线程栈、分配器 arena、JIT 或其他对象。现代 maps 不再普遍给每个线程栈显示 [stack:TID];不能看到一段 rw-p 就凭位置确认它一定是栈。

堆:malloc 对象不都在 [heap]

[heap] 通常对应 program break 管理的传统堆范围。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 进程,不是启动它的 shell;查看目标时应使用目标 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;
}

编译后找出三个地址各落在哪个区间。多次独立启动时基址可能变化,但具体地址差异不是语言语义保证。没有初始化并访问每一页的分配,也不适合作为“分配多少就立刻增加多少 RSS”的实验。

RSS、PSS 与虚拟大小

VmSize 描述虚拟地址范围规模,RSS 统计驻留页,共享页在多个进程的 RSS 中可能重复计算。PSS 按共享者分摊,有助于比较一组进程的驻留贡献。smaps_rollup 提供汇总,smaps 保留逐映射细节。

这些指标仍不等于分配器的在用字节,也不等于 cgroup 记账;页表、内核对象、共享缓存及统计时点会造成不同视角。映射与计数会随进程运行改变,跨多个 /proc 文件读取不自动形成同一原子快照。内核 /proc 文档

COW 发生后:两个进程的 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、仅两个共享者且全部驻留;忽略页表、内核及其他映射。

用页的账本解释 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] 内位于传统 program break 区域对应哪个分配对象、是否已 free
无名 rw-p 内私有可写匿名区域线程栈、arena 或其他用途
无映射此时没有该地址的普通 VMA地址来源、生命周期和映射变化

RSS 没变,物理页为什么增加了?

把同色同名方块理解为同一物理页的两条映射。每发生一次 COW,子进程仍映射四页,所以 RSS 不变;但其中一页不再与父进程分摊,PSS 从半页计费变成整页计费。这里统计的只是这四页,不能与真实进程全部 smaps 差值直接等同。

正在呈现知识画面
RSS 没变,物理页为什么增加了?

COW 使原先共享的物理页分裂为两个私有副本。两进程 RSS 可以不变,PSS 和独立物理页总量却增加。

从地址定位问题

先确认故障地址是否有映射,再看权限、所属对象、偏移与生命周期;“地址位于 rw-p 映射内”不能证明对象访问合法,可能仍是同页内越界或释放后使用。

理解布局能缩小定位范围,对象级错误仍需调试符号与 内存检测工具。随机化与执行权限的保护作用见 ASLR 与安全防护。

关键词:virtual memory, VMA, maps, smaps, RSS, PSS, stack, brk, mmap, COW, vDSO, ASLR