一、程序地址空间:先看 C 语言那张图
学 C 的时候老师画过这样的布局,当时只是让我们「记住」:
text
高地址 ┌──────────────────────┐
│ 命令行参数 / 环境变量 │
├──────────────────────┤
│ 栈 stack │ ↓ 向低地址增长
├──────────────────────┤
│ ↓ │
│ │
│ ↑ │
├──────────────────────┤
│ 堆 heap │ ↑ 向高地址增长
├──────────────────────┤
│ BSS(未初始化全局) │
├──────────────────────┤
│ Data(已初始化全局) │
├──────────────────────┤
│ Text(代码 / 只读) │
低地址 └──────────────────────┘
这张图有几个必须记住的特征:
- 代码段在最低地址,只读、可执行。
- BSS 和 Data 挨着。BSS 存未初始化的全局/静态变量(不占文件体积,运行时清零),Data 存已初始化的。
- 堆向高地址长,栈向低地址长,两者相向而行,中间留一大片空洞。
- 命令行参数和环境变量在最高处,紧贴栈顶。
先别急着背,跑段代码验证一下它是不是真的。
二、动手验证:地址分布到底是不是这样
c
#include <stdio.h>
#include <stdlib.h>
int g_unval; /* BSS:未初始化全局 */
int g_val = 100; /* Data:已初始化全局 */
int main(int argc, char *argv[], char *env[])
{
const char *str = "helloworld"; /* 只读字符串 → .rodata */
static int test = 10; /* 静态变量属于 Data */
char *heap_mem = (char*)malloc(10);
printf("code addr: %p\n", main);
printf("init global addr: %p\n", &g_val);
printf("uninit global addr: %p\n", &g_unval);
printf("test static addr: %p\n", &test);
printf("heap addr: %p\n", heap_mem);
printf("stack addr: %p\n", &heap_mem); /* 指针变量本身在栈上 */
printf("read only string addr: %p\n", str);
printf("argv[0]: %p\n", argv[0]);
printf("env[0]: %p\n", env[0]);
return 0;
}
编译运行(32 位平台上地址形态最典型):
text
code addr: 0x40055d
init global addr: 0x601034
uninit global addr: 0x601040
test static addr: 0x601038
heap addr: 0x1791010
stack addr: 0x7ffd0f9a4368
read only string addr: 0x400800
argv[0]: 0x7ffd0f9a4811
env[0]: 0x7ffd0f9a4819
对照着看,每一个规律都对上了:
| 观察 | 说明 |
|---|---|
0x40055d(代码)< 0x601034(数据)< 0x1791010(堆)< 0x7ffd...(栈) |
整体布局和那张图一致 |
str 在 0x400800,和代码段挨着 |
只读字符串属于 .rodata,和代码同段 |
g_val、g_unval、test 都在 0x6010xx |
三者都在 Data 区 |
argv[0] 和 env[0] 在 0x7ffd...48xx |
在最高处,紧贴栈顶上方 |
一个细节 :
stack addr打印的是&heap_mem,指针变量本身在栈上;heap addr打印的是heap_mem的值,堆上的地址。两个概念别混。
布局验证完了。但这里藏着一个陷阱------上面所有打印出来的地址,全都是假的。
三、虚拟地址:一个地址,两个值
3.1 那个「毁三观」的实验
c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
pid_t id = fork();
if (id < 0) {
perror("fork");
return 0;
} else if (id == 0) { /* 子进程先跑,先改 */
g_val = 100;
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
} else { /* 父进程睡一会再读 */
sleep(3);
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
sleep(1);
return 0;
}
输出:
text
child[3046]: 100 : 0x80497e8
parent[3045]: 0 : 0x80497e8
同一个地址 0x80497e8,子进程读出 100,父进程读出 0。
如果地址是物理地址,这在逻辑上是不可能的。你拿着一把钥匙开一个门,不可能看到两间不同的屋子。
由此可以推出三条结论:
- 变量内容不一样,所以父子进程操作的根本不是同一个变量。
- 但地址值一样,说明这个地址绝对不是物理地址。
- 这个地址叫虚拟地址。我们在 C/C++ 里看到的所有地址,全都是虚拟地址;物理地址用户一概看不到,由操作系统统一管理。
所以第一章那个说法------「程序的地址空间」------是不准确的。准确的说法是「进程地址空间」 ,每个进程有自己独立的一份。
3.2 那为什么两个进程的虚拟地址会「看起来一样」
因为 fork 的时候,子进程的地址空间是照着父进程的模板复制的:代码段起始地址一样、数据段位置一样、栈初值一样。模板一样,同一个变量在各自地址空间里的偏移自然一样。
所以「地址相同」本质上是「模板相同」;「值不同」,是因为它们指向了不同的物理页。
页表是「翻译官」,它把「看起来一样」的虚拟地址,翻译到「实际不一样」的物理位置。
3.3 一个必须先回答的问题:页表是一直存在的吗
看到这里很容易产生一个误解:既然有页表,那页表一定从头到尾都在,每个地址都有一项。
不是。 页表是按需建立的:
c
char *p = malloc(1024 * 1024 * 1024); /* 申请 1GB */
/* 此刻:页表里一个 PTE 都没有。物理内存占用 0 */
p[0] = 1; /* 第一次访问第一个页 */
/* 此刻:内核分配一个 4KB 物理页,为它建第一个 PTE */
p[4096] = 1; /* 访问第二个页 */
/* 此刻:再建第二个 PTE */
这个过程叫延迟分配(lazy allocation) 。内核只在 VMA 里记一笔「这块地址是允许访问的」,真正建立映射要等到你第一次碰它。
这个设计的意义 :malloc(1GB) 变成了一次「记账」而不是「搬家」,耗时从毫秒级降到微秒级。程序申请内存后往往只用一小部分,这个设计省下了大量没人用的物理内存。
问题的另一面:「分配成功」不等于「能用」 。1GB 申请成功,但如果物理内存 + swap 真的不够,你不会在 malloc 那里得到 NULL,而是会在访问内存的那一刻被 OOM Killer 杀掉。第八章 8.3 节验证的就是这件事。
四、怎么理解虚拟地址空间:两个比喻
4.1 大富翁:理解「凭空多出来的 4G」
操作系统像印钞票的庄家,可以给每个进程都发一张「你有 4G 内存」的地产证,哪怕机器只有 8G 物理内存、同时跑着 100 个进程。只要大家不同时用满,账面就兜得住。谁真的要用,内核才真的分配------这就是上一节说的延迟分配。
4.2 38 线:理解「区域划分」
划分地址空间的各个区域,只是在结构体里记了几个数字 (start_code、end_code、start_brk、brk......),物理内存里什么也没做。区域划分是纯逻辑的 ,这一点是理解后面 mm_struct 的钥匙。
五、mm_struct:把整个地址空间描述出来
5.1 先看 task_struct 里的两个 mm 指针
c
struct task_struct
{
/*...*/
struct mm_struct *mm;
struct mm_struct *active_mm;
/*...*/
};
mm:普通用户进程指向自己的地址空间;内核线程的mm是NULL。active_mm:内核线程专用,指向「借用的」那个地址空间。
内核线程永远在内核态跑,不访问用户空间,所以不需要 mm;但内核代码本身也要映射,所以借一个刚被换下去的进程的 mm 用就行。
5.2 结构体全貌
mm_struct(内存描述符)定义在 mm_types.h 里:
c
struct mm_struct
{
/*...*/
struct vm_area_struct *mmap; /* 指向虚拟区间(VMA)链表 */
struct rb_root mm_rb; /* 红黑树根节点 */
unsigned long task_size; /* 该进程虚拟地址空间的大小 */
/* 代码段、数据段、堆、栈、参数段、环境段的起止地址 */
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
/*...*/
};
一个进程只有一个 mm_struct。 这个结构体就是「进程地址空间」在代码里的全部代表。
5.3 为什么每个段都要一对 start / end
start_code/end_code、start_data/end_data、arg_start/arg_end......每一段都用「起点 + 终点」两个数字表示,而不是「基址 + 长度」。
这就是 38 线比喻的代码化:用两个端点描述一个区间,增删一个区域只需要改数字 ,不需要移动任何数据。mm_struct 只有几百字节,却描述了整个 4G 空间。
5.4 顺带回收上篇的一个伏笔
第二章打印的 argv[0] 和 env[0] 都在 0x7ffd...48xx,紧贴栈顶上方。mm_struct 里正好有 arg_start/arg_end 和 env_start/env_end 四个字段记录这两块区域的边界。
环境变量之所以能被子进程继承,就是因为它存在地址空间的这个固定位置,fork 复制地址空间时顺手就复制过去了。
六、vm_area_struct:区域怎么划分和组织
6.1 为什么不能只用一个 mm_struct
一个进程的地址空间不是一个整块,而是被切成很多性质完全不同 的块:代码段、数据段、堆、共享库、栈......中间还夹着大片未映射的空洞------那里没有任何 VMA,页表里也没有对应项,访问它立刻段错误。
mm_struct 里那几个 start/end 不够用了,需要一个结构体来描述每一块:
c
struct vm_area_struct /* 简称 VMA */
{
unsigned long vm_start; /* 这块虚拟内存的起点 */
unsigned long vm_end; /* 终点(不含) */
unsigned long vm_flags; /* 标志:VM_READ / VM_WRITE / VM_EXEC / VM_SHARED */
struct file *vm_file; /* 数据来源:映射的文件(匿名则为 NULL) */
const struct vm_operations_struct *vm_ops; /* 缺页时调谁 */
struct vm_area_struct *vm_next, *vm_prev; /* 链表指针 */
struct rb_node vm_rb; /* 红黑树节点 */
struct mm_struct *vm_mm; /* 属于哪个 mm_struct */
/*...*/
};
一个 VMA = 一段「同质」的连续虚拟地址。
6.2 「同质」是哪三条
只要满足这三条,就能合成一个 VMA;有一条不同,就得拆开:
① 权限相同:代码段只读可执行,数据段可读写。页表权限位按页设置,权限不同的地方必须用 VMA 边界划开。
② 数据来源相同 :代码段来自可执行文件,共享库来自 .so,栈和堆是匿名内存。来源不同,缺页时的处理方式完全不同。
③ 增长行为一致:栈向低地址长,堆向高地址长,中间留一大片空洞,用一个区域表达不了。
这三条一记住,/proc/pid/maps 里为什么有几十上百行就很好解释了。
6.3 组织:链表 + 红黑树,两套并存
c
struct mm_struct {
struct vm_area_struct *mmap; /* ① 链表头 */
struct rb_root mm_rb; /* ② 红黑树根 */
};
一个进程有多个 VMA,两种组织方式同时用:
- 缺页时要回答「出错的地址属于哪个 VMA」------最频繁的操作,走红黑树,O(log n)。
/proc/pid/maps要按地址顺序列出所有 VMA------链表天然有序,遍历简单。
所以内核两个都维护,按场景各取所长,每次增删 VMA 两个容器同步更新。
6.4 侵入式容器:一个结构体,两个容器
vm_next/vm_prev 和 vm_rb 是直接嵌在结构体里的成员 ,同一个结构体同时挂在链表和红黑树里,互不干扰,这叫侵入式容器 。好处是不需要为每个容器额外分配节点;代价是从 vm_rb 反推结构体首地址得靠 container_of(成员地址减去偏移量)。
这是 Linux 内核数据结构的统一范式,看懂一次,后面全是重复。
6.5 vm_ops:这个 VMA 出问题了找谁
c
const struct vm_operations_struct *vm_ops;
这是个函数指针表,其中 fault 最关键:当进程访问一个「在 VMA 里、但页表里还没建映射」的地址时,内核调它去现场造一个页------从文件读,或者给个零页。
匿名映射(堆、栈)用 anon_vm_ops,文件映射用 file_vm_ops。同一套缺页流程,靠不同的 vm_ops 分流出完全不同的行为------典型的「用函数指针做多态」。
七、页表:虚拟地址怎么落到物理地址
前面反复提到「页表」和「翻译」,这一节把翻译的机制讲透。
7.1 为什么按「页」而不是按「字节」映射
如果每个字节都记一条映射,4G 空间就是 40 亿条,根本存不下。所以内存管理的基本单位是页:虚拟地址空间和物理内存都被切成 4KB 一块,映射的单位是「页」,不是「字节」。
为什么是 4KB? 页太小则页表项太多、换出 I/O 次数多;页太大则内部碎片严重、单次换出延迟高。4KB 恰好是「页表本身能放进一页」的尺寸。现代系统也支持大页(2MB / 1GB),用于减少 TLB 压力和页表层级。
7.2 32 位下为什么是 10 + 10 + 12
32 位虚拟地址一共 32 位,页大小 4KB = 2¹²,所以:
text
31 22 21 12 11 0
┌─────────────┬──────────────┬──────────────┐
│ 页目录索引 │ 页表索引 │ 页内偏移 │
│ (10 位) │ (10 位) │ (12 位) │
└─────────────┴──────────────┴──────────────┘
│ │ │
▼ ▼ ▼
1024 项 1024 项 4096 字节
页目录 ──────► 页表 ──────► 物理页帧内的字节
10 + 10 + 12 = 32,刚好。翻译分两步走:
text
① 取虚拟地址高 10 位 → 在页目录里找到对应的「页表」
② 取中间 10 位 → 在这个页表里找到对应的「物理页帧号」
③ 取低 12 位 → 在物理页帧内偏移,定位到具体字节
为什么偏偏是 10 位? 因为 10 位正好索引 1024 项,而 1024 × 4 字节 = 4096 字节 = 正好一页。
于是页目录占一页,每个页表也占一页。分配和换出都以页为单位,管理起来最自然。 64 位下换成 9 位索引 + 512 项 × 8 字节 = 4KB,同样一页,同样的道理。
7.3 多级页表到底省了什么
如果只用一级页表:2²⁰ 个页 × 4 字节 = 4MB,每个进程一张,100 个进程就是 400MB,光页表就吃掉一半内存。
拆成两级后总量看起来没变 (1024 个页表 × 4KB 还是 4MB),但关键在于按需创建 :一个只用了 8MB 内存的进程,只需要 2 个二级页表 + 1 个页目录 = 12KB,而不是 4MB。
这就是多级页表的核心价值:稀疏映射只花稀疏的代价。
一句话记住:多级页表省的不是「总容量」,是「那些你根本没用到的地址所占的表项」。二级页表为空,整个页表对象都不存在。
64 位下更极端:48 位有效地址切成 9 + 9 + 9 + 9 + 12,四级页表(PGD → PUD → PMD → PTE)。
7.4 PTE 里到底存了什么
页表项(PTE)不是简单存一个物理地址,它是一个带权限和状态标志的 32 位数:
text
31 12 11 10 9 8 7 6 5 4 3 2 1 0
┌───────────────────────┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│ 物理页帧号 (20位) │ │G │PS│D │A │CD│WT│U │RW│P │ │
└───────────────────────┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
全局 大页 脏 访问 用户 可写 存在
| 位 | 名字 | 含义 |
|---|---|---|
| 0 | P (Present) | 存在位 。0 = 这个页不在内存里 → 触发缺页 |
| 1 | R/W | 可写位。0 只读,1 可读写 |
| 2 | U/S | 用户位。0 只有内核能访问,1 用户态也能访问 |
| 5 | A (Accessed) | 被访问过。给 LRU 回收算法用 |
| 6 | D (Dirty) | 被写过。换出时必须写回磁盘 |
| 7 | PS | 大页标志(2MB/4MB) |
| 8 | G | 全局位。进程切换时不刷这个 TLB 项 |
| 12~31 | PFN | 物理页帧号 |
四个位要重点记:
P 位 = 缺页的开关。 内核要「假装」一个地址可用但实际没分配,做法就是把 PTE 的 P 位清 0------CPU 一访问就触发缺页,内核在处理函数里现场造页。延迟分配、写时拷贝、swap 换出,全靠这一个位实现。 这是整个虚拟内存设计里最优雅的一处。
A 位和 D 位 = 回收的依据。 内核回收内存时要判断「哪些页最近没动过」(A=0 的优先回收)、「哪些页改了要写回」(D=1 的才需要 I/O)。这两位由硬件自动维护。
U/S 位 = 用户态和内核态的隔离墙。 32 位下,0~3G 的 PTE 的 U 位是 1,3G~4G 的 U 位是 0。你写代码访问 0xC0000000 以上,CPU 一查 PTE 发现 U=0 而当前在用户态,直接触发异常。
这就是为什么用户态永远碰不到内核数据 ------不是「约定不能碰」,是硬件在地址翻译时直接拦住了。任何软件技巧(指针运算、类型强转)都绕不过去。
7.5 TLB:页表的缓存
查一次地址要走两级页表,等于多访问两次内存,程序会慢到不可接受。所以 CPU 里有个 TLB(Translation Lookaside Buffer) ,缓存最近的「虚拟页 → 物理页帧」映射,命中率通常 95% 以上------因为程序访存有极强的局部性。
这也解释了进程切换为什么贵 :换进程就换页表,旧进程的 TLB 项全废,新进程一上来每次访存都要重新走页表。这也是「线程切换比进程切换便宜」的根本原因------同进程的线程共享页表,TLB 不用大改。
八、缺页中断:延迟分配的兑现时刻
8.1 缺页不等于出错
初学者看到「缺页异常」以为程序出 bug 了,其实恰好相反 :Linux 下每一次 malloc 的内存,第一次被访问时都会触发一次缺页。这是正常流程。
走到缺页处理的原因分好几类:
| 情况 | 触发原因 | 处理 |
|---|---|---|
| 匿名页首次访问 | malloc 后第一次写 |
分配一个物理页,清零,建映射 |
| 文件页首次访问 | mmap 文件 / 加载程序 |
从文件读入对应页 |
| 写时拷贝 | 写一个共享的只读页 | 复制一份新页,改权限 |
| swap 换入 | 访问被换出到磁盘的页 | 从 swap 读回内存 |
| 栈自动增长 | 访问栈下方的「红区」 | 扩展栈 VMA |
| 真的非法 | 地址不在任何 VMA 里 | SIGSEGV |
| 权限不符 | 写只读页 | SIGSEGV |
关键区别:前五种是「合法且可修复的」,后两种是「真的错了」。 缺页处理函数本质上就是在做这个分类。
8.2 处理流程
text
① 进程访问虚拟地址 addr
│
▼
② MMU 查 TLB → 未命中 → 走页表 → PTE 的 P 位 = 0
│
▼
③ CPU 触发 #PF 异常,addr 存入 CR2,错误码记录原因
(是读还是写?用户态还是内核态?页不存在还是权限不够?)
│
▼
④ 内核 do_page_fault(regs, error_code)
│
├─► 内核态访问非法地址?→ 内核 Oops
│
▼
⑤ find_vma(mm, addr):这个地址属于哪个 VMA?
│
├─► 找不到 VMA ──────────────► 发送 SIGSEGV,进程死
│
▼
⑥ 检查权限:VMA 的 vm_flags 允许这次访问吗?
│
├─► 不允许(如写只读段)───► 发送 SIGSEGV,进程死
│
▼
⑦ handle_mm_fault():调用 VMA 的 vm_ops->fault
│
├─ 匿名页:从伙伴系统要一个页,清零
├─ 文件页:从 page cache 找,没有就发起读盘
├─ COW:复制一份新页,改 PTE 指向新页并置可写
└─ swap:从交换分区读回
│
▼
⑧ 填写 PTE:填物理页帧号,P 位置 1,设置 R/W/U 权限
│
▼
⑨ 返回用户态,CPU 重新执行那条触发缺页的指令
(这次 PTE 已就绪,正常执行完)
三个容易忽略的细节:
① 那条指令是「重新执行」的,不是「继续执行下一条」。 CPU 触发缺页时,出错指令的现场被完整保存,内核处理完返回后从同一条指令 的同一个位置重来。对程序来说完全无感知。
② 缺页可能在内核态触发。 copy_from_user 拷贝用户数据时,用户页可能刚好没映射,也会触发缺页。这种「内核态触发的用户页缺页」是合法的。
③ 缺页处理的成本差三个数量级。 分配一个零页可能几微秒;从磁盘读一个页可能几毫秒。这就是「第一次访问一大块内存特别慢」的原因------你要等几千次缺页。
8.3 实验:亲眼看缺页次数
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main()
{
size_t sz = 100 * 1024 * 1024; /* 100MB */
char *p = malloc(sz);
printf("malloc 完成\n");
getchar(); /* 停住,去看 /proc/pid/stat */
memset(p, 1, sz); /* 逐页触碰 */
printf("memset 完成\n");
getchar();
return 0;
}
看 /proc/<pid>/stat 的第 10 个字段(min_flt,次缺页数):
bash
$ pid=$(pgrep a.out)
$ awk '{print "min_flt:", $10}' /proc/$pid/stat
min_flt: 109 # malloc 之后:只有程序本身加载的缺页
# (按回车,让程序 memset 完)
$ awk '{print "min_flt:", $10}' /proc/$pid/stat
min_flt: 25709 # 多了 25600 次
25600 = 100 × 1024 × 1024 / 4096,一个页对应一次缺页,精确吻合。
而 malloc 那一刻,min_flt 几乎没涨------因为那时候物理内存一个字节都没分配。这就是「延迟分配」最直接的证据。
九、写时拷贝:fork 到底复制了什么
9.1 如果老老实实复制
fork 创建子进程,如果真把父进程的内存全复制一遍:一个占 1GB 的进程 fork 一次就要拷 1GB,耗时几百毫秒。
而实际用途里,子进程往往是马上 exec 换成别的程序------这 1GB 白拷了。
所以内核用 COW(Copy-on-Write,写时拷贝)。
9.2 三个步骤
text
第一步:复制页表,共享物理页
父页表 ──┐
├──► 物理页 A(原本可写,现在标记为【只读】)
子页表 ──┘
注:物理页的引用计数 +1
第二步:某一方尝试写
子进程执行 *p = 100
│
▼
MMU 查 PTE:R/W = 0(只读)→ 写操作违规
│
▼
触发缺页,#PF 错误码里带着「写 + 权限不足」
第三步:内核判断这是 COW 页,复制一份
物理页 A(refcount=1,恢复可写)◄── 父页表
物理页 B(新分配,复制内容,可写)◄── 子页表
之后父子各写各的,互不影响。
关键点:fork 复制的是页表,不是物理内存。 物理页一个字节都没动,只是把 PTE 的 R/W 位置成了 0,引用计数加了 1。
9.3 内核怎么区分「COW 只读」和「真的只读」
这是个很妙的地方。代码段的只读和 COW 的只读,在 PTE 层面看起来一模一样(R/W 都是 0)。内核怎么知道该复制还是该报错?
判断逻辑大致是:
text
缺页发生了,是写操作,权限不够。
问:这个页的物理页引用计数 > 1 吗?
├─ 是 → 有别人也在用这个页 → 这是 COW,复制一份
│ (还要检查 VMA 的 vm_flags 里有没有 VM_MAYWRITE,
│ 表示「这个区域原则上允许写,只是暂时被禁」)
│
└─ 否 → 只有我自己在用,却写不了 → 真的在写只读段
→ SIGSEGV
引用计数(_refcount)是分水岭,VM_MAYWRITE 标志是第二道判据。
| 代码段(真只读) | 数据段(COW 只读) | |
|---|---|---|
| PTE 的 R/W | 0 | 0 |
vm_flags 有 VM_MAYWRITE 吗 |
没有 | 有 |
| 物理页 refcount | 可能 > 1 | > 1 |
| 写操作结果 | SIGSEGV |
复制页,成功写入 |
这也解释了 COW 为什么必须先把页设成只读 :只读是为了制造一次缺页,从而获得「介入检查」的机会。真正的写权限判断是内核在缺页处理里做的,不是硬件直接放行。
9.4 一个反直觉的事实
fork 之后,父进程的内存页也变成只读了。
也就是说,父进程第一次写自己的变量,也会触发一次缺页 。所以 fork 的成本不只落在子进程身上------父进程后续的写入也有一笔「解 COW」的开销。
9.5 亲眼看 COW
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main()
{
size_t sz = 100 * 1024 * 1024;
char *p = malloc(sz);
memset(p, 1, sz); /* 先全部实际分配 */
printf("parent ready, pid=%d\n", getpid());
getchar();
pid_t id = fork();
if (id == 0) {
printf("child pid=%d\n", getpid());
getchar(); /* 停住:此时父子共享 100MB */
memset(p, 2, sz); /* 子进程全写一遍 → 触发 COW */
printf("child 写完\n");
getchar();
_exit(0);
}
getchar();
return 0;
}
用 pmap -x <pid> 看 RSS:
| 时刻 | 父进程 RSS | 子进程 RSS | 系统实际物理占用 |
|---|---|---|---|
| fork 后、都没写 | 100MB | 100MB | 约 100MB(共享) |
| 子进程全写之后 | 100MB | 100MB | 约 200MB(各自私有) |
关键在第一行 :两个进程各报 100MB,加起来 200MB,但系统实际只用了 100MB。因为 RSS 统计的是「映射到我的地址空间的页」,不区分独占还是共享。
这就是为什么用 RSS 相加估算内存占用会高估 。要看真实占用得用 PSS (Proportional Set Size,共享页按进程数均摊),在 /proc/pid/smaps 里:
bash
$ grep -E "^(Rss|Pss|Shared_Clean|Private_Dirty)" /proc/$(pgrep a.out)/smaps
工程上的启示 :容器里跑的服务如果「物理内存莫名翻倍」,八成是某处
fork了却马上要改数据。典型场景:Python 的multiprocessing、PHP-FPM 的 worker、Redis 的BGSAVE。Redis 官方文档专门讨论过「fork 导致内存翻倍」这个问题。
十、为什么需要虚拟地址空间
10.1 反过来说:直接操作物理内存会怎样
课件讲了早期计算机的做法:程序全部装入内存,直接跑在物理地址上。128M 内存,跑 A(10M)和 B(110M),那就前 10M 给 A,剩下 118M 里划 110M 给 B。
看起来没什么问题,实际上有三个致命毛病:
问题一:安全风险
每个进程都能访问任意内存,任何一个进程都能读写系统相关的内存区域。如果这是个木马病毒,它就能随意改内存,让设备直接瘫痪。
用虚拟地址后:进程只能访问自己页表里映射过的地址。你写个野指针 *(int*)0x12345 = 1,如果这个地址在页表里没有映射,CPU 直接触发缺页,内核一看「非法访问」,一个 SIGSEGV 把你干掉。你连别的进程的物理页在哪都不知道,更别说改了。
问题二:地址不确定
编译好的程序存在硬盘上,运行时搬到内存。如果直接用物理地址,每次运行的实际地址都不一样 ------第一次执行时内存全空,搬到 0x00000000;第二次已经有 10 个进程在跑,地址就不一定了。程序里的绝对地址全都要重定位。
用虚拟地址后:每个进程的地址空间布局都是固定的 ,代码永远从 0x400000 开始,栈永远在 0x7fff... 附近。编译器可以放心生成绝对地址,链接器可以放心填符号表。物理上搬到哪里,页表说了算,程序完全无感知。
问题三:效率低下
物理内存不够时,要把不常用的进程拷到磁盘的交换分区。如果按物理地址管理,一个进程就是一个整体内存块,必须整个拷走。一次拷几百 MB,慢得要命。
用虚拟地址 + 分页后:内存管理的基本单位是「页」(4K),不是「进程」 。内存紧张了,挑几个最不常用的页换出去就行。这就是「按页换出」比「按进程换出」高效的原因。
10.2 三个额外红利
课件最后一段列的这三条,是整章的收束,也是最能体现设计思想的地方。
红利一:保护了物理内存中的所有合法数据
地址空间和页表是 OS 创建并维护的。凡是想使用地址空间和页表进行映射,也一定要在 OS 的监管之下来进行访问。
页表是内核的数据结构,用户态程序改不了。所有访问都要经过页表翻译,而 PTE 里的权限位由内核设置。这是一道硬件级别的强制检查------CPU 在翻译地址时检查权限,不合规直接触发异常,用户态代码无法绕过。
它保护的不只是用户进程,也包括内核自己的数据。
红利二:解耦进程管理和内存管理
因为有地址空间和页表的映射,物理内存中可以对未来的数据进行任意位置的加载!物理内存的分配和进程的管理就可以做到没有关系。
这是最重要的一条。进程管理模块不需要知道物理内存长什么样,内存管理模块也不需要知道进程在干什么。 进程只管在自己的虚拟地址空间里写代码,内存模块只管在物理内存里找空闲页帧填页表。
这个解耦的直接后果就是延迟分配:
因为有地址空间的存在,所以我们在 C/C++ 里
new、malloc空间的时候,其实是在地址空间上申请的,物理内存甚至可以一个字节都不给你。而当你真正进行对物理地址空间访问的时候,才执行内存的相关管理算法,帮你申请内存,构建页表映射关系。这由操作系统自动完成,用户包括进程完全 0 感知!
这段话说的就是本篇 3.3 节和 8.3 节反复验证的那件事:malloc 只是记账,不是搬家。
红利三:进程视角的内存分布是有序的
因为页表的映射的存在,程序在物理内存中理论上就可以任意位置加载。它可以将地址空间上的虚拟地址和物理地址进行映射。
物理内存是碎片的、无序的------这一页在 0x1000,下一页可能在 0x9000。但在进程眼里,通过页表映射,它看到的永远是一段连续、整齐的地址空间。
进程看到的是整理过的房间,实际住在哪个物理地址是内核的事。 这就是抽象的价值。
十一、下半篇小结
text
① 描述:mm_struct 用一串 start/end,把 4G 地址空间切成若干区间
② 划分:VMA 说清「这一段是什么、能不能写、数据从哪来」
③ 组织:链表管遍历,红黑树管查找,同一个结构体挂在两个容器里
④ 翻译:页表把虚拟页映射到物理页帧,PTE 的 P 位决定「这个页在不在」
⑤ 补丁:缺页中断负责在 P 位为 0 时现场造页
⑥ 偷懒:写时拷贝让 fork 只复制页表,不复制内存
六件事合起来,才有「每个进程都以为自己独占 4G」这个幻觉。
一句话:
虚拟内存 = 一套描述(mm_struct + VMA)+ 一套翻译(页表)+ 两个补丁(缺页、COW)。
十二、复习清单
布局与虚拟地址
- 代码段、数据段、BSS、堆、栈、参数/环境,从低到高排一遍?
- 为什么堆向上长、栈向下长?中间那大片空洞是什么?
- 「同一个地址两个值」这个现象,怎么解释?
- 为什么两个进程的虚拟地址能「看起来一样」?
- 页表是一开始就全建好的吗?不是的话,什么时候建?
mm_struct 与 VMA
- 内核线程的
mm为什么是NULL?active_mm又解决什么? - 为什么每个段存「start + end」,而不是「基址 + 长度」?
- 一个 VMA 必须满足哪三条「同质」条件?
- 为什么 VMA 同时挂在链表和红黑树里?各解决什么问题?
-
container_of的原理?为什么不直接存结构体指针? -
vm_ops是干什么的?
页表
- 32 位下为什么是 10 + 10 + 12?为什么每一级恰好 1024 项?
- 多级页表到底省了什么?(总量没变,省在哪一步)
- PTE 的 P 位、A 位、D 位、U/S 位,各被谁使用?
- 用户态为什么绝对访问不到内核地址?约定还是硬件拦截?
缺页
- 缺页有哪几种合法情况?哪两种是「真的错了」?
- 处理完缺页,CPU 从哪条指令继续?为什么是重来而不是下一条?
- 为什么第一次访问一大块内存特别慢?
- 内核叫「缺页」,用户态看到「段错误」,两者什么关系?
-
min_flt为什么能精确对上「内存大小 / 4K」?
写时拷贝
- COW 为什么要把页设成只读?
- 内核怎么区分「COW 只读」和「代码段只读」?
-
fork之后父进程的内存会受影响吗? - 为什么 RSS 相加会高估内存占用?该看什么指标?
为什么要虚拟地址空间
- 直接操作物理内存的三个问题是什么?
- 「解耦进程管理和内存管理」具体解耦了什么?
- 虚拟地址空间保护了哪些东西?
上篇:《Linux 进程概念(上):从冯诺依曼到进程调度》------冯诺依曼体系、操作系统与系统调用、PCB 与 fork、七种进程状态、僵尸与孤儿、优先级、上下文切换与 O(1) 调度、环境变量。
下一篇 :《进程控制》------wait/waitpid 回收僵尸、exec 系列替换程序映像、进程终止与退出码。
如果这两篇帮你把进程这块补上了,点个赞再走。