深入理解 Linux 进程地址空间:从虚拟内存、页表映射到 fork 写时拷贝与 VMA 管理
- 前言
- [1. 进程地址空间:先建立正确的内存模型](#1. 进程地址空间:先建立正确的内存模型)
-
- [1.1 一个进程拥有一套虚拟地址视图](#1.1 一个进程拥有一套虚拟地址视图)
- [1.2 经典地址空间布局与每个区域的职责](#1.2 经典地址空间布局与每个区域的职责)
- [1.3 为什么要在物理内存前增加虚拟地址层](#1.3 为什么要在物理内存前增加虚拟地址层)
- [2. 一次变量访问究竟经历了什么](#2. 一次变量访问究竟经历了什么)
-
- [2.1 从虚拟地址到物理页的完整路径](#2.1 从虚拟地址到物理页的完整路径)
- [2.2 按需调页、文件后备页与匿名页](#2.2 按需调页、文件后备页与匿名页)
- [3. fork 为什么快:相同地址、共享页面与写时拷贝](#3. fork 为什么快:相同地址、共享页面与写时拷贝)
-
- [3.1 父子进程为什么能打印相同地址却得到不同值](#3.1 父子进程为什么能打印相同地址却得到不同值)
- [3.2 独立与共享的边界,以及"进程挂起"时内存去哪了](#3.2 独立与共享的边界,以及“进程挂起”时内存去哪了)
- [4. 内核如何组织地址空间:task_struct、mm_struct 与 VMA](#4. 内核如何组织地址空间:task_struct、mm_struct 与 VMA)
-
- [4.1 三层结构各自负责什么](#4.1 三层结构各自负责什么)
- [4.2 为什么按"区间"管理,以及 VMA 如何变化](#4.2 为什么按“区间”管理,以及 VMA 如何变化)
- [5. 从 exec 到退出:代码和数据如何真正进入内存](#5. 从 exec 到退出:代码和数据如何真正进入内存)
-
- [5.1 先建立映射,再在访问时装入页面](#5.1 先建立映射,再在访问时装入页面)
- [5.2 malloc、free、退出与物理内存回收](#5.2 malloc、free、退出与物理内存回收)
- [6. 实战观察:用 Linux 工具验证地址空间](#6. 实战观察:用 Linux 工具验证地址空间)
-
- [6.1 编写一个可观察的地址布局程序](#6.1 编写一个可观察的地址布局程序)
- [6.2 查看 maps、ELF 段与物理占用](#6.2 查看 maps、ELF 段与物理占用)
- 总结
前言
在 C/C++ 程序里打印一个指针,我们会看到类似 0x7ffd... 的地址;查看一个 32 位进程的内存布局,又经常能看到"用户空间 3GiB、内核空间 1GiB"的示意图。最容易产生的误解是:这个地址就是内存条上的物理位置,4GiB 地址空间也意味着进程已经占用了 4GiB 物理内存。
事实恰好相反。用户程序通常看到的是一套由操作系统和硬件共同维护的虚拟地址空间 。CPU 发出的虚拟地址要经过 MMU、TLB 和页表翻译,才能落到物理内存中的某个页框;如果目标页尚未装入,访问还会触发缺页异常,由内核补齐映射后再重试。也正因为中间多了这一层抽象,操作系统才能在有限的物理内存上实现进程隔离、权限保护、按需调页、共享内存以及 fork 的写时拷贝。
这套机制看似概念多,主线其实很清楚:
- 进程看见什么:从低地址到高地址的一片虚拟地址空间。
- 内核如何描述 :
task_struct关联mm_struct,再由多个 VMA 描述不同虚拟区间。 - 硬件如何访问:页表把虚拟页映射到物理页,TLB 缓存近期翻译结果。
- 数据何时进入内存:创建映射不等于立刻分配物理页,许多页面在首次访问时才装入。
进程地址空间不是一整块真实物理内存,而是进程可使用的虚拟地址范围,以及这段范围对应的映射规则、访问权限和后备存储信息。
1. 进程地址空间:先建立正确的内存模型
1.1 一个进程拥有一套虚拟地址视图
现代操作系统通常按字节寻址 。对 32 位程序而言,一个指针最多能表达 2^32 种地址,因此理论虚拟地址范围是 0x00000000 到 0xffffffff,总计 2^32 字节,也就是 4GiB。这个结论只描述地址编号的数量,不代表进程启动时真的分配了 4GiB 物理内存。
进程执行时,CPU 产生的是虚拟地址。内核为进程维护页表,页表记录"某个虚拟页应该映射到哪个物理页,以及允许怎样访问"。因此,两个进程都可以访问数值相同的虚拟地址 0x111111,但它们的页表完全可以把该地址翻译到不同物理页。对进程来说,这就像每个人都拿着一份从 0 开始编号的私人地图;地图上的门牌号可以相同,实际对应的房间却不同。
所谓进程的独立性,至少包含两层含义:
- 内核管理数据独立:每个进程有自己的进程描述信息,例如 PID、调度状态、打开文件表引用和地址空间指针。
- 虚拟地址映射独立 :普通进程通常拥有独立的
mm_struct和页表,修改自己的私有数据不会直接改掉另一个进程的数据。
这里还要补一个重要边界:同一进程中的多个线程共享地址空间 ,它们通常共享同一个 mm_struct,所以线程之间可以直接读写同一全局变量、堆对象和映射区;线程真正私有的主要是寄存器上下文、内核栈和用户栈等执行现场。进程之间也不是绝对不能共享,mmap 的共享映射、System V 共享内存等机制都能显式建立共同映射。
| 对比维度 | 虚拟地址 | 物理地址 |
|---|---|---|
| 谁直接使用 | 程序指针、CPU 访存指令 | 内存控制器和内核内存管理 |
| 是否进程私有 | 同一数值可在不同进程中重复出现 | 物理页是全局资源,由内核统一分配 |
| 是否连续 | VMA 内看起来连续 | 对应物理页可以离散分布 |
| 是否一定有内存 | 不一定,可能尚未映射或尚未装入 | 表示实际页框或设备地址 |
| 如何建立关系 | 通过页表、PTE 和 MMU 翻译 | 作为页表映射的目标 |
1.2 经典地址空间布局与每个区域的职责
下面是经典 32 位 Linux 进程地址空间的教学模型。图中的 3GiB/1GiB 只是常见划分,不能当成所有系统的固定比例;64 位系统的用户地址空间更大,而且受硬件有效地址位数、内核配置和 ASLR 影响,实际地址也不会像图中一样整齐固定。

从低地址到高地址,各区域的典型用途如下。
- 代码段(text) 保存机器指令,通常是文件后备映射,常见权限为
r-x。只读常量可能位于单独的rodata区域,权限通常为r--。 - 已初始化数据段(data) 保存有明确非零初值的全局变量和静态变量,例如
int g_value = 100;。初始内容来自可执行文件。 - BSS 段 保存未显式初始化或初始化为零的全局变量、静态变量,例如
static int count;。可执行文件不需要为一大片零逐字节存储内容,只需记录区域大小,装载时按零初始化即可。 - 堆(heap) 用于动态分配。传统上
brk推高堆顶,小块malloc常从用户态分配器管理的堆块中取得;较大分配或特定实现也会直接使用匿名mmap。堆在示意图中通常向高地址增长。 - 共享库与 mmap 映射区 容纳动态链接库、文件映射、匿名映射和线程栈等内容。它不是只能放"共享"数据,私有的
MAP_PRIVATE映射同样可以出现在这里。 - 栈(stack) 保存函数调用现场、局部变量和返回地址等内容,经典示意图中向低地址扩展。每个线程有自己的用户栈,但这些栈仍处在同一进程地址空间内。
- 命令行参数与环境变量 在程序启动时由内核布置到初始用户栈附近,
main(int argc, char *argv[], char *envp[])最终从这里取得参数。 - 内核空间 在经典 32 位布局中位于高地址。用户态不能随意访问带有内核权限的页;进入系统调用或异常处理时,CPU 会切换到内核态执行受控代码。
堆向上、栈向下只是为了说明它们可以动态扩展,两者之间不会因此天然形成一整块已分配内存 。中间大片地址可以没有任何 VMA,也可以被 mmap 放入新的映射。ASLR 还会随机化栈、堆、共享库和 PIE 可执行文件的基址,提高利用固定地址攻击的难度。
1.3 为什么要在物理内存前增加虚拟地址层
如果程序直接操作全局物理地址,系统会立刻遇到三个难题:不同进程容易写坏彼此数据,程序必须关心自己被装到内存条的什么位置,物理内存碎片还会让大块连续空间越来越难找。虚拟内存把这些问题拆开处理。
第一,把物理内存变得有序。程序看到连续的虚拟区间,内核却可以把它映射到离散物理页。只要每个虚拟页都有正确映射,程序无需知道页面在内存条上的真实位置。
第二,实现访问控制和进程隔离。页表项不仅保存页框号,还能标记用户/内核、只读/可写、可执行/不可执行等权限。地址翻译本身就顺便完成了权限检查。
第三,让进程管理与内存管理适度解耦 。创建进程时可以先建立 task_struct、mm_struct、VMA 和页表骨架,不必立刻把全部代码、数据复制进物理内存;需要页面时再按需加载。
第四,支持灵活共享。不同进程的页表可以故意指向同一物理页,用于共享库、共享内存和文件页缓存;也可以先共享,写入时再拆分,这正是写时拷贝。
| 目标 | 没有虚拟内存时的困难 | 虚拟内存的解决方式 |
|---|---|---|
| 连续地址 | 必须寻找连续物理块 | 连续虚拟页可映射到离散物理页 |
| 进程隔离 | 任意程序可能覆盖别人的数据 | 每个进程使用独立页表和权限 |
| 大程序运行 | 程序必须整体装入内存 | 只加载当前真正访问的页面 |
| 共享数据 | 需要显式搬运和同步副本 | 多套页表映射同一物理页 |
| 快速创建子进程 | 必须复制整个地址空间 | fork 配合 COW 延迟复制 |
2. 一次变量访问究竟经历了什么
2.1 从虚拟地址到物理页的完整路径
假设程序读取全局变量 g_value。编译器和链接器先让指令以某种方式引用它的虚拟地址;CPU 执行访存指令时,再把这个虚拟地址交给 MMU。常见页面大小为 4KiB,此时虚拟地址的低 12 位是页内偏移,其余高位构成虚拟页号。大页机制会使用不同的划分方式。

访问流程可以拆成下面几步:
- CPU 生成虚拟地址,MMU 用虚拟页号查询 TLB。TLB 是地址翻译缓存,命中时不必再次遍历多级页表。
- TLB 未命中时,硬件或架构相关代码从当前进程的页表根开始查找 PTE。多级页表避免为整个巨大地址空间预先创建一张扁平大表。
- PTE 若有效且权限允许,MMU 取出物理页框号,与原虚拟地址中的页内偏移拼接,得到最终物理地址。
- PTE 不存在、
Present=0或权限不满足时,CPU 触发页故障异常,控制权进入内核。 - 内核先查 VMA,判断该地址是否落在合法区间、读写执行方式是否允许。合法但页面尚未装入时,内核分配匿名零页,或从可执行文件、共享库、普通文件、交换区读取页面。
- 内核更新页表并刷新相关 TLB 状态,然后返回用户态,重新执行刚才失败的那条指令 。如果地址根本不合法或权限冲突无法修复,内核通常向进程发送
SIGSEGV。
缺页异常不一定是错误。 合法映射的页面第一次被访问时发生缺页,是按需调页的正常组成部分;只有无法由内核修复的非法地址或权限访问,才会演变成用户看到的段错误。
页表项通常还会保存或参与维护多种状态。具体位布局取决于 CPU 架构,但核心语义相近。
| PTE 信息 | 作用 | 典型结果 |
|---|---|---|
| Present/Valid | 页面是否已有有效物理映射 | 为 0 时可能触发按需调页 |
| Read/Write | 是否允许写入 | 向只读页写入会触发保护异常 |
| User/Supervisor | 用户态能否访问 | 用户程序不能直接访问内核页 |
| Executable/NX | 是否允许取指执行 | 堆、栈通常不可执行 |
| Accessed | 页面近期是否被访问 | 可辅助页面回收决策 |
| Dirty | 页面是否被写过 | 文件页回写或换出时需要关注 |
在进程切换时,内核还需要让 CPU 使用下一个进程的页表根。现代处理器可能借助 PCID/ASID 一类标识减少 TLB 全量失效,但概念上仍然是:同一个虚拟地址,因为当前页表不同,可以得到不同翻译结果。
2.2 按需调页、文件后备页与匿名页
把可执行文件装载进程并不等于把整个文件一次性读进物理内存。Linux 通常先依据 ELF 程序头建立 VMA,让代码段和已初始化数据段具有文件后备关系。CPU 第一次取某个代码页的指令时,如果 PTE 尚未建立有效映射,就发生缺页;内核再通过页缓存取得对应文件页,更新页表后继续执行。
匿名内存没有普通文件作为后备来源,典型例子是 BSS、堆和栈。程序申请 1GiB 匿名虚拟空间时,系统往往只先保留地址范围和必要元数据。只有真正写到其中某个页面,才需要为那个页面准备物理页。这就是为什么虚拟内存大小 VSZ 很大,不等于常驻物理内存 RSS 同样大。
缺页通常还能按是否需要真实存储 I/O 粗略区分:
| 缺页类型 | 是否通常需要磁盘 I/O | 常见场景 |
|---|---|---|
| Minor fault(次缺页) | 不需要 | 页已在页缓存、COW 复制、首次匿名页分配 |
| Major fault(主缺页) | 需要 | 页面必须从可执行文件、映射文件或交换区读取 |
| Protection fault(保护异常) | 不一定 | 写只读页、执行 NX 页;COW 可被内核合法修复 |
物理内存紧张时,内核可以回收干净文件页,因为以后还能从文件重新读取;匿名脏页若要被回收,通常需要交换空间或其他内存管理策略承接内容。于是虚拟地址、物理页与磁盘后备存储形成了分层关系:上层程序只持有稳定的虚拟地址,页面当前在 RAM、页缓存还是交换区,由内核动态调度。
3. fork 为什么快:相同地址、共享页面与写时拷贝
3.1 父子进程为什么能打印相同地址却得到不同值
fork 创建子进程时,子进程会获得父进程当时的地址空间视图。如果真的把父进程每个物理页立刻复制一遍,创建大型进程的成本会非常高,而且许多子进程马上调用 execve 替换整个地址空间,那些复制工作将完全浪费。
Linux 因此采用 Copy-on-Write,写时拷贝 。fork 初期,父子页表可以同时指向同一批物理页,并把本来可写的私有映射暂时改成只读/COW 状态。只读访问继续共享;任一进程第一次写入时,CPU 触发写保护缺页,内核确认这是合法的 COW 映射,复制目标物理页,再让写入方的 PTE 指向新页并恢复可写权限。

下面的程序能直观看到"虚拟地址相同,变量内容独立":
c
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int g_value = 100;
int main(void)
{
printf("fork 前: pid=%d, &g_value=%p, g_value=%d\n",
getpid(), (void *)&g_value, g_value);
fflush(stdout);
pid_t id = fork();
if (id < 0) {
perror("fork");
return 1;
}
if (id == 0) {
g_value++;
printf("子进程: pid=%d, &g_value=%p, g_value=%d\n",
getpid(), (void *)&g_value, g_value);
return 0;
}
waitpid(id, NULL, 0);
printf("父进程: pid=%d, &g_value=%p, g_value=%d\n",
getpid(), (void *)&g_value, g_value);
return 0;
}
编译并运行:
bash
moss@VM-0-4-ubuntu:~/process-space$ gcc -O0 -g cow_demo.c -o cow_demo
moss@VM-0-4-ubuntu:~/process-space$ ./cow_demo
fork 前: pid=3210, &g_value=0x55b2aaf3c048, g_value=100
子进程: pid=3211, &g_value=0x55b2aaf3c048, g_value=101
父进程: pid=3210, &g_value=0x55b2aaf3c048, g_value=100
gcc 用于编译 C 程序;-O0 关闭优化,便于观察源码行为;-g 写入调试信息;-o cow_demo 指定输出文件名。输出中的具体地址会受 PIE 和 ASLR 影响,但父子进程打印出的地址数值通常相同。子进程写入后得到 101,父进程仍为 100,说明二者已经映射到不同物理页。
fflush(stdout) 也不是多余的:如果标准输出缓冲区在 fork 前尚未刷新,父子进程会各自继承一份用户态缓冲区,随后可能把同一段缓冲内容输出两次。
3.2 独立与共享的边界,以及"进程挂起"时内存去哪了
写时拷贝并不表示父子进程永远共享所有页面。不同映射的语义如下:
| 内存类型 | fork 后初始状态 |
一方写入后的结果 |
|---|---|---|
| 私有匿名页 | 通常 COW 共享 | 写入方获得私有副本 |
MAP_PRIVATE 文件映射 |
通常共享文件页并采用私有 COW | 修改只在进程私有视图中生效 |
MAP_SHARED 映射 |
继续映射共同对象 | 修改可被其他映射者观察到 |
| 只读代码页 | 可长期共享 | 正常情况下不允许写 |
| 同一进程的线程 | 共享同一地址空间 | 写入直接对其他线程可见 |
"进程挂起"在日常表达中可能指几种不同状态。进程因等待事件进入可中断睡眠 S、等待 I/O 进入不可中断睡眠 D,或者收到 SIGSTOP 进入停止状态 T 时,通常只是暂时不在 CPU 上运行,它的 mm_struct、VMA 和虚拟地址仍然存在。物理页却不一定全部常驻:干净文件页可被回收,匿名页可能被换出,稍后再次访问再通过缺页恢复。
因此,"没占用 CPU"与"没有地址空间"是两回事。即使进程看起来卡住,VSZ 仍可很大;RSS 会随页面回收变化。只有进程退出、被成功终止,或者 execve 用新映像替换旧地址空间时,旧的内存映射才会按引用关系释放。D 状态进程还可能因为内核正在等待不可中断 I/O,暂时无法立刻响应终止信号,这也不是页表失效造成的。
4. 内核如何组织地址空间:task_struct、mm_struct 与 VMA
4.1 三层结构各自负责什么
如果为地址空间中的每个字节都创建一个管理对象,元数据会比真正的数据还夸张。内核采用"先描述整体,再按连续区间管理"的方式:task_struct 表示任务,mm_struct 表示一整套用户地址空间,VMA 则描述其中一段连续且属性一致的区间。

三类结构的职责可以这样理解:
| 内核对象 | 管理粒度 | 典型内容 |
|---|---|---|
task_struct |
一个任务/线程 | PID、状态、调度信息、打开文件引用、mm 指针 |
mm_struct |
一套用户地址空间 | 页表根、代码/数据/堆/栈边界、参数与环境边界、VMA 索引、统计信息 |
VMA(概念上由 vm_area_struct 表示) |
一段连续虚拟区间 | vm_start、vm_end、访问权限、映射标志、文件来源、相关操作 |
经典内核资料中常见的 mm_struct 字段具有以下含义。实际字段、类型和索引结构会随内核版本变化,因此不能把某一版结构体当成稳定用户态 ABI。
pgd一类字段指向页表根,是虚拟地址翻译的起点。start_code/end_code、start_data/end_data记录代码和数据的边界。start_brk/brk记录传统堆区的起点和当前堆顶,start_stack记录初始栈相关位置。arg_start/arg_end与env_start/env_end描述命令行参数和环境变量的范围。total_vm、locked_vm、shared_vm、exec_vm、stack_vm等字段用于不同维度的页面统计。- VMA 索引让内核能快速查找"某个虚拟地址属于哪个区间"。许多教材和旧内核图会画成
mm->mmap加vm_next链表;阅读具体内核源码时,应以该版本真实的 VMA 索引实现为准。
一个用户进程通常有自己的 mm_struct,同进程线程则共享它。纯内核线程没有普通用户地址空间,mm 可以为空;这进一步说明 task_struct 与 mm_struct 不是"永远一一对应"的死规则,而是由进程/线程模型决定的关联。
4.2 为什么按"区间"管理,以及 VMA 如何变化
可以把地址空间想成一把 100cm 的刻度尺。桌面 A 占用 [0, 50),桌面 B 占用 [50, 100),系统只需记录两个起止区间,而不必为 100 个刻度分别建对象。半开区间 [start, end) 还能让相邻边界自然衔接:前一段的 end 正好等于后一段的 start,不会重复包含边界字节。
VMA 也是如此。只要一段虚拟地址连续、权限相同、映射来源和相关属性一致,就可以由一个 VMA 描述。如果对其中 [20, 30) 子区间调用 mprotect 修改权限,原来的 [0, 50) 可能被拆成 [0, 20)、[20, 30)、[30, 50) 三段;相邻 VMA 后来若重新具备完全相同的属性,也可能再次合并。
常见操作对 VMA 的影响如下:
| 操作 | 主要用途 | 对地址空间的典型影响 |
|---|---|---|
mmap |
建立文件或匿名映射 | 创建新 VMA,必要时调整周围区间 |
munmap |
解除一段映射 | 删除、截断或拆分 VMA,并清理相关 PTE |
mprotect |
修改访问权限 | 更新权限,可能把一个 VMA 拆成多段 |
brk |
调整传统堆顶 | 扩大或缩小堆相关虚拟区间 |
| 栈自动扩展 | 满足合法的栈增长 | 在限制和安全检查通过后扩展栈 VMA |
VMA 解决"这个地址区间应该是什么"的问题,页表解决"这个虚拟页当前实际映射到哪里"的问题。二者不能互相替代:只有 VMA 没有 PTE,CPU 仍无法直接访问物理页;只有 PTE 没有合法 VMA,缺页和权限管理也失去了完整的高层语义。
权限保护可以用字符串常量说明:
c
char *str = "helloworld";
str[0] = 'H'; /* 未定义行为,常见结果是 SIGSEGV */
字符串字面量通常位于只读映射区。str 本身是一个可修改的指针变量,但它指向的字符区域没有写权限。执行写指令时,MMU 发现 PTE 权限不符并触发保护异常;内核再检查 VMA,确认这不是可修复的 COW 写入,最终通常发送 SIGSEGV。正确写法是创建可写数组:
c
char str[] = "helloworld";
str[0] = 'H';
这段数组位于可写存储区。这个例子也说明:指针数值只是地址,能不能读、能不能写、能不能执行,还取决于 VMA 和页表权限。
5. 从 exec 到退出:代码和数据如何真正进入内存
5.1 先建立映射,再在访问时装入页面
Shell 启动外部程序时,常见路径是先 fork 得到子进程,再由子进程调用 execve。fork 创建新的任务关系和 COW 地址空间;execve 不会创建一个新 PID,而是用目标程序映像替换调用线程所在进程的旧用户地址空间。

execve 的核心流程可以概括为:
- 内核读取 ELF 头和程序头,检查格式、架构、权限及装载信息。
- 为新程序准备
mm_struct,依据各个PT_LOAD段建立代码、只读数据、可写数据等 VMA。 - 创建用户栈,把
argc、argv、envp和辅助向量等启动信息放入合适位置。 - 动态链接程序还会映射解释器和共享库,由动态链接器完成重定位等工作。
- 大多数代码页和数据页不必立刻读入。CPU 第一次取指或访问数据时触发缺页,文件后备页从页缓存/文件取得,BSS、堆和栈等匿名页通常按零初始化。
- 页面映射完成后重试指令,程序感觉不到中间曾经暂停处理缺页。
这回答了一个常见的先后问题:创建进程管理结构与装入全部代码数据不是一个不可分割的动作。 内核可以先有 task_struct、mm_struct、VMA 和稀疏页表,再随着执行逐页补齐物理内存。对于典型 fork + exec 路径,fork 先以 COW 方式得到地址空间,execve 随后丢弃旧映像并建立新映像;对于内核线程,甚至可以没有普通用户地址空间。
5.2 malloc、free、退出与物理内存回收
用户调用 malloc 时,分配器先在自己的空闲块中查找;不足时再通过 brk 或 mmap 向内核申请更多虚拟地址范围。申请成功只表示虚拟区间可用,不保证所有页面已经有独占物理页。第一次写入页面时,匿名缺页通常才会真正分配并零填充物理页。
free 也不等于每次都立刻把物理页还给内核。为了提高后续分配效率,用户态分配器可能保留小块内存;较大的 mmap 分配可能通过 munmap 直接解除,堆顶附近的连续空闲区也可能通过调整 brk 归还。观察内存问题时,必须区分以下指标:
| 指标 | 含义 | 常见误区 |
|---|---|---|
| VSZ/VIRT | 进程虚拟地址空间总量 | 大不代表全在 RAM 中 |
| RSS/RES | 当前常驻物理内存 | 包含共享页时会重复计数 |
| PSS | 共享页按共享者数量折算后的占用 | 比 RSS 更适合估算进程实际分摊 |
| 匿名内存 | 堆、栈、匿名映射等 | 可被交换,不一定始终驻留 |
| 文件映射 | 代码、共享库、mmap 文件 | 干净页通常可直接回收后重读 |
进程正常退出或被终止后,内核撤销其用户地址空间,释放页表和 VMA,对物理页、文件页和共享对象递减引用计数。只有没有其他映射者继续引用的资源才会真正释放。execve 则在保留 PID 等进程身份的同时,用新 mm 替换旧映像,旧地址空间也按相同引用规则清理。
6. 实战观察:用 Linux 工具验证地址空间
6.1 编写一个可观察的地址布局程序
先准备一个小程序,同时创建代码、已初始化数据、BSS、堆和栈对象:
c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int g_init = 100;
int g_uninit;
static const char g_readonly[] = "hello";
int main(void)
{
int stack_value = 1;
int *heap_value = malloc(sizeof(*heap_value));
if (heap_value == NULL) {
perror("malloc");
return 1;
}
*heap_value = 2;
printf("pid = %d\n", getpid());
printf("code = %p\n", (void *)main);
printf("rodata = %p\n", (void *)g_readonly);
printf("data = %p\n", (void *)&g_init);
printf("bss = %p\n", (void *)&g_uninit);
printf("heap = %p\n", (void *)heap_value);
printf("stack = %p\n", (void *)&stack_value);
fflush(stdout);
sleep(300);
free(heap_value);
return 0;
}
编译并在后台运行:
bash
moss@VM-0-4-ubuntu:~/process-space$ gcc -O0 -g address_demo.c -o address_demo
moss@VM-0-4-ubuntu:~/process-space$ ./address_demo &
[1] 4208
moss@VM-0-4-ubuntu:~/process-space$ PID=$!
./address_demo & 中的 & 让程序在后台运行;Shell 变量 $! 保存最近一个后台任务的 PID,因此 PID=$! 便于后续查询。程序用 sleep(300) 暂停 300 秒,让我们有时间查看它的映射。具体地址每次运行可能改变,这是 ASLR 的正常表现。
6.2 查看 maps、ELF 段与物理占用
先查看进程的 VMA:
bash
moss@VM-0-4-ubuntu:~/process-space$ cat /proc/$PID/maps
55c7c2f74000-55c7c2f75000 r--p 00000000 08:01 123456 /home/moss/process-space/address_demo
55c7c2f75000-55c7c2f76000 r-xp 00001000 08:01 123456 /home/moss/process-space/address_demo
55c7d71ec000-55c7d720d000 rw-p 00000000 00:00 0 [heap]
7ffd6a8b5000-7ffd6a8d6000 rw-p 00000000 00:00 0 [stack]
cat 输出文件内容;/proc/$PID/maps 是目标进程的映射表。每行依次包含地址范围、权限、文件偏移、设备号、inode 和映射来源。权限中的 r、w、x 分别表示可读、可写、可执行,p 表示私有映射,s 表示共享映射。输出已做精简,真实进程还会包含动态链接器、libc、vDSO 等区域。
再查看 ELF 程序头:
bash
moss@VM-0-4-ubuntu:~/process-space$ readelf -l ./address_demo
readelf 用来读取 ELF 元数据;-l 表示显示 program headers。重点观察 LOAD 段及其 R、W、E 标志,它们解释了为什么 /proc/$PID/maps 中会形成只读、可执行和可写的不同 VMA。ELF 的 section 面向链接组织,program segment 才是装载器建立运行时映射的主要依据,二者不要混为一谈。
查看进程内存统计:
bash
moss@VM-0-4-ubuntu:~/process-space$ pmap -x $PID
moss@VM-0-4-ubuntu:~/process-space$ cat /proc/$PID/smaps_rollup
moss@VM-0-4-ubuntu:~/process-space$ ps -o pid,stat,vsz,rss,cmd -p $PID
命令与参数含义如下:
| 命令 | 参数含义 | 重点观察 |
|---|---|---|
pmap -x $PID |
-x 显示扩展映射统计 |
每段虚拟大小、RSS、脏页 |
cat /proc/$PID/smaps_rollup |
汇总 smaps 的内存指标 |
RSS、PSS、匿名页、交换量 |
ps -o ... -p $PID |
-o 自定义列,-p 指定 PID |
STAT、VSZ、RSS 的差别 |
查询页面大小和栈限制:
bash
moss@VM-0-4-ubuntu:~/process-space$ getconf PAGE_SIZE
4096
moss@VM-0-4-ubuntu:~/process-space$ ulimit -s
8192
getconf PAGE_SIZE 查询系统基础页大小,示例输出 4096 字节;系统也可能支持透明大页或显式 HugeTLB,不能因此断言所有映射都只用 4KiB 页。ulimit -s 查询当前 Shell 为新进程设置的栈大小软限制,单位通常为 KiB,示例 8192 表示 8MiB。它是上限策略,不代表进程启动时已经常驻 8MiB 栈内存。
实验结束后终止后台程序并回收任务:
bash
moss@VM-0-4-ubuntu:~/process-space$ kill $PID
moss@VM-0-4-ubuntu:~/process-space$ wait $PID
kill $PID 默认发送 SIGTERM,请求进程正常终止;wait $PID 让当前 Shell 回收该子进程的退出状态,避免留下僵尸任务。如果进程仍在执行清理逻辑,SIGTERM 不保证瞬间退出,不应一上来就使用强制信号。
总结
Linux 进程地址空间的关键,不是背诵一张"代码、数据、堆、栈"的固定布局图,而是理解三层关系:程序使用虚拟地址,内核用 mm_struct 和 VMA 描述哪些区间合法、具有什么属性,硬件再依靠 MMU、TLB 与页表把虚拟页翻译到物理页。VMA 管区间语义,PTE 管页面当前落点,二者共同完成隔离、权限和按需调页。
同一虚拟地址可以在不同进程中映射到不同物理页,也可以被有意映射到同一物理页。fork 正是先共享私有页面,再在首次写入时通过 COW 拆分,从而减少创建时间和内存消耗。execve 则先依据 ELF 建立新地址空间,代码和数据页在真正访问时按需装入。理解这条主线后,段错误、VSZ 与 RSS 差异、共享库、malloc、进程睡眠和内存回收等现象,就都能放进同一套模型中解释。