Linux 进程概念(下):程序地址空间与虚拟内存

一、程序地址空间:先看 C 语言那张图

学 C 的时候老师画过这样的布局,当时只是让我们「记住」:

text 复制代码
高地址  ┌──────────────────────┐
        │  命令行参数 / 环境变量 │
        ├──────────────────────┤
        │      栈 stack         │  ↓ 向低地址增长
        ├──────────────────────┤
        │          ↓            │
        │                       │
        │          ↑            │
        ├──────────────────────┤
        │      堆 heap          │  ↑ 向高地址增长
        ├──────────────────────┤
        │  BSS(未初始化全局)   │
        ├──────────────────────┤
        │  Data(已初始化全局)   │
        ├──────────────────────┤
        │  Text(代码 / 只读)   │
低地址  └──────────────────────┘

这张图有几个必须记住的特征:

  1. 代码段在最低地址,只读、可执行。
  2. BSS 和 Data 挨着。BSS 存未初始化的全局/静态变量(不占文件体积,运行时清零),Data 存已初始化的。
  3. 堆向高地址长,栈向低地址长,两者相向而行,中间留一大片空洞。
  4. 命令行参数和环境变量在最高处,紧贴栈顶。

先别急着背,跑段代码验证一下它是不是真的。


二、动手验证:地址分布到底是不是这样

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。

如果地址是物理地址,这在逻辑上是不可能的。你拿着一把钥匙开一个门,不可能看到两间不同的屋子。

由此可以推出三条结论:

  1. 变量内容不一样,所以父子进程操作的根本不是同一个变量。
  2. 但地址值一样,说明这个地址绝对不是物理地址。
  3. 这个地址叫虚拟地址。我们在 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 系列替换程序映像、进程终止与退出码。

如果这两篇帮你把进程这块补上了,点个赞再走。

相关推荐
无足鸟ICT1 小时前
【RHCA+】影响Linux系统性能的因素
linux·运维·服务器
silvamux_1 小时前
微头条爆款体平台适配实测:把交付要求变成可计算的指标
java·线性代数·算法·决策树·矩阵
论文复现现场1 小时前
DeepSeek V4.1 Flash 和 V4 Pro 有什么区别?API 价格、性能与迁移建议
java·服务器·人工智能·python·大模型api·deepseek·deepseek v4.1
沫璃染墨1 小时前
从零入门计算机网络系列(十):HTTP协议进阶——从资源路径解析到状态码与请求头详解
linux·运维·服务器·网络·c++·http
小则又沐风a2 小时前
深入了解五种IO模型与非阻塞IO
linux
Sayai2 小时前
Ubuntu 开发环境搭建避坑(一):网卡 EEE 致 60% 周期性丢包,Realtek RTL8125 网络黑洞排查与修复
linux·运维·网络·ubuntu·性能优化
墨染天姬2 小时前
【linux 网络1】数据怎么进网卡到内核?
linux·运维·网络
迪康coolmu2 小时前
金融客户数据合规落地:基于迪康端点安全一体化管理系统的四道防线实践
运维·服务器·数据库·经验分享·安全·网络安全·金融
崇子嵘2 小时前
上云方案硬件部分分析
linux