1. 1. 现在是11.1早上10:50,我刚看完youtube上的一个讲解os的直播视频,感觉讲的还是非常清晰的,有必要梳理一下先前离散的知识点,形成一个较为完整的知识体系。我大致用笔记了下一些关键信息,但还是决定让ai帮我生成一份完整的视频总结,以下是ai总结的视频内容:
  2. 2. 操作系统到底是怎么工作的:进程、线程与虚拟内存
    1. 2.1. 0. 文档信息与阅读导航
      1. 2.1.1. 视频时间轴(便于回看原片)
    2. 2.2. 1. 为什么需要「进程」这个抽象
      1. 2.2.1. 1.1 先看裸硬件:极简计算机模型
      2. 2.2.2. 1.2 两个推动抽象诞生的现实问题
      3. 2.2.3. 1.3 解决办法:时间分片 + 上下文切换
      4. 2.2.4. 1.4 如果没有抽象,会发生什么?
    3. 2.3. 2. 进程:给你「独占一整台机器」的幻觉
      1. 2.3.1. 2.1 定义
      2. 2.3.2. 2.2 这个抽象的价值
    4. 2.4. 3. 进程是怎么被创建出来的:fork 与 exec
      1. 2.4.1. 3.1 fork:克隆自己
      2. 2.4.2. 3.2 exec / execve:换掉自己正在跑的程序
      3. 2.4.3. 3.3 标准启动模式:fork + exec
      4. 2.4.4. 3.4 一切的源头:init 进程
    5. 2.5. 4. 进程的解剖:它到底「包含」了什么
      1. 2.5.1. 4.1 用户地址空间内的布局
      2. 2.5.2. 4.2 内核上下文
      3. 2.5.3. 4.3 文件描述符(file descriptor)是什么
    6. 2.6. 5. 进程 vs 线程
      1. 2.6.1. 5.1 核心区别:共享地址空间,独享栈
      2. 2.6.2. 5.2 对比表
      3. 2.6.3. 5.3 两种架构风格
    7. 2.7. 6. 进程的生命周期与调度
    8. 2.8. 7. 虚拟内存:让每个进程都以为自己拥有全部内存
      1. 2.8.1. 7.1 要解决的问题
      2. 2.8.2. 7.2 中间人:MMU
      3. 2.8.3. 7.3 分页(paging):翻译的基本单位
      4. 2.8.4. 7.4 地址怎么拆:页号 + 页内偏移
      5. 2.8.5. 7.5 页表项里存了什么
      6. 2.8.6. 7.6 一次完整的地址翻译(逐步走一遍)
      7. 2.8.7. 7.7 当 R = 0:缺页(page fault)流程
      8. 2.8.8. 7.8 为什么要多级页表
      9. 2.8.9. 7.9 虚拟内存最终解决的三个问题
    9. 2.9. 8. 核心概念速查表
    10. 2.10. 9. 常见误解澄清
    11. 2.11. 10. 延伸学习(视频中提到的资料)
    12. 2.12. 11. 自测题
    13. 2.13. 12. 一页速记

学一会儿操作系统

现在是11.1早上10:50,我刚看完youtube上的一个讲解os的直播视频,感觉讲的还是非常清晰的,有必要梳理一下先前离散的知识点,形成一个较为完整的知识体系。我大致用笔记了下一些关键信息,但还是决定让ai帮我生成一份完整的视频总结,以下是ai总结的视频内容:

原视频链接:https://www.youtube.com/watch?v=0y_2mZqP3cc&t=2679s

操作系统到底是怎么工作的:进程、线程与虚拟内存

本文是对 YouTube 视频 《How the OS actually works》(作者 Ben Dicken)的完整知识梳理文档。
视频本身是一场 80 分钟的直播式讲解,中间夹杂了大量观众问答和闲聊,本文已剔除闲聊、保留并重组全部技术内容,按「为什么 → 是什么 → 怎么实现」的逻辑重新组织,并补充了少量必要的背景说明(凡属补充内容均以 补充 标注)。


0. 文档信息与阅读导航

项目 内容
视频标题 How the OS actually works(操作系统实际上是如何工作的)
作者 Ben Dicken(软件工程 / 数据库 / 分布式系统方向的内容创作者,前高校讲师,曾讲授操作系统课程)
形式 直播讲解(Live Stream)+ 白板绘图
时长 约 80 分钟(4815 秒)
依据教材 Systems Performance(《系统性能》)— Brendan Gregg,800+ 页,作者只挑选部分章节讲解
核心技术主题 硬件模型 / 进程抽象 / fork 与 exec / 进程地址空间 / 文件描述符 / 进程与线程 / 进程状态与调度 / 虚拟内存与分页
文档整理时间 2026-11-01

视频时间轴(便于回看原片)

时间 内容
00:00–13:30 开场闲聊、书籍介绍、延伸阅读(Cursor 的 agent swarm 重构 SQLite 博客)
13:30–22:30 为什么要进程抽象:硬件模型、单道执行的历史、I/O 等待
22:30–31:30 时间分片与上下文切换;不做抽象会带来的冲突与安全问题
31:30–39:00 如何创建进程:fork / clone、exec / execve、init 进程
39:00–47:50 进程的解剖:用户地址空间 + 内核上下文;栈与堆;文件描述符
49:00–52:00 进程 vs 线程;多进程架构 vs 多线程架构
52:00–53:30 进程生命周期与四种状态
55:50–79:30 虚拟内存:MMU、分页、页表项、地址翻译全流程、多级页表
79:30–结尾 收尾

1. 为什么需要「进程」这个抽象

1.1 先看裸硬件:极简计算机模型

作者用一个极简模型开始讲解(19:00 起):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌──────────────────────────────┐
│ CPU │
│ ┌────────┐ ┌────────┐ │
│ │ Core 0 │ │ Core 1 │ ... │ ← 现代单芯片可有 16 / 32 核
│ └────────┘ └────────┘ │
└───────────┬──────────────────┘
│ 取指令 / 读写数据
▼
┌──────────────────────────────┐
│ RAM │ ← 存放「工作集」数据
└───────────┬──────────────────┘
│ 溢出 / 持久化
▼
┌──────────────────────────────┐
│ Disk │
└──────────────────────────────┘

要点:

  1. CPU 是指令真正执行的地方,每个核独立执行指令流。
  2. RAM 与 CPU 交互极其频繁,因为这里是工作数据的存放地。
  3. 磁盘 容量大但慢得多,RAM 的内容会溢出/持久化到磁盘。

这个模型里只有「一个程序从头跑到尾」的世界:早期计算机确实如此——一台昂贵的大机器,喂进去一个程序(甚至用打孔卡片),跑完就结束,同一时刻只运行一个程序。

1.2 两个推动抽象诞生的现实问题

问题一:I/O 太慢,CPU 在空等。
程序在 CPU 上执行得非常快,但一旦要去读/写磁盘、发网络请求,就要等很久。如果只有一个程序,这段时间 CPU 就是闲着的。我们希望「A 程序等 I/O 的时候,CPU 去执行 B 程序」。

问题二:人想同时干很多事。
一边算数学、一边放音乐、一边管理直播、一边开聊天窗口——一台机器要同时承载多个程序。

1.3 解决办法:时间分片 + 上下文切换

1
2
3
4
5
4 个核:Core0→聊天   Core1→音乐   Core2→数据库   Core3→终端   (真并行)

1 个核:时间分片(time sharing)
[聊天 1~10ms][音乐 1~10ms][数据库 1~10ms][终端 1~10ms][聊天 ...]
↑ 每次切换前,把该进程的全部状态保存下来,再装入下一个进程的状态
  • 单核上「同时运行多个程序」的本质是把 CPU 时间切成小片轮流分配,即分时(time sharing)。
  • 切换的核心动作叫上下文切换(context switch):保存当前进程状态 → 装入下一个进程状态 → 继续执行。
  • 谁决定「哪个程序何时运行、运行多久」?——调度器(scheduler)。这是操作系统里一个极其复杂、有大量策略的子系统(视频中只点到为止)。

1.4 如果没有抽象,会发生什么?

假设你作为工程师要写一个自制视频编辑器,而系统没有进程抽象,那么你的代码必须自己操心:

  • 数据库程序占了哪段 RAM?音乐程序占了哪段?我的编辑器只能用哪一小块?
  • 我的程序该用磁盘的哪些区域?会不会写到别人的数据上?
  • 更糟的是安全问题:如果聊天程序被恶意篡改,它可以去读视频编辑器占用的内存,甚至读取别人的密钥。——因为没有任何边界。

结论:如果没有统一抽象,每个程序都要自己解决「资源互不冲突」的问题,写任意程序的通用操作系统就不可能成立。


2. 进程:给你「独占一整台机器」的幻觉

2.1 定义

进程(Process)= 一个正在运行的程序的自包含实例(a self-contained instance of a running program)。

这是 Unix(macOS、Linux/Ubuntu)与 Windows 上基本一致的抽象。创建一个进程时,需要把一个程序加载进去,然后由操作系统维护这套抽象,让该进程能够「假装自己是这台机器上唯一在跑的东西」:

资源 进程看到的「幻觉」 实际上由 OS 负责的事
CPU 「整个 CPU 都是我的」 调度、时间片分配、优先级
内存 「全部 RAM 都是我的」,甚至「我有近乎无限的内存」 虚拟内存、页表映射、换页
磁盘 「文件系统随我用」 文件系统、权限控制、缓存

关键点:应用工程师不需要做这些决策。操作系统通过虚拟内存、CPU 调度、优先级、文件系统等机制,替所有进程协调冲突、公平共享资源。

2.2 这个抽象的价值

正因为大家都建立在「进程」这个稳定原语之上,我们才能:

  • 写应用时不必关心物理内存布局;
  • 让 AI/编译器/框架有一个行为可预期的基座(作者强调:AI 写代码同样受益于「可靠原语」,进程、数据库接口都是这种原语);
  • 在需要时换一套抽象(例如为实时性优化的专用 OS),但那属于另一个权衡方向。

顺带一提:Linux 内核代码量超过一百万行,要同时支持多种 CPU 架构、处理各种边界情况并保证安全,这正是「自己造一个 OS」远没有听起来那么轻松的原因。


3. 进程是怎么被创建出来的:fork 与 exec

上一节讲了「进程是什么」,本节讲「进程从哪来」。这需要回到系统调用(system call)——应用与内核之间的 API,是应用进入内核态去操作磁盘、网卡等资源的方式。

3.1 fork:克隆自己

1
2
3
4
5
┌──────────────┐        fork()        ┌──────────────┐
│ 进程 A │ ───────────────▶ │ 进程 A' │
│ (video edit)│ │ (完全相同副本)│
└──────────────┘ └──────────────┘
仅 PID 不同

fork() 的行为(视频中直接读了 man fork):

  • 创建一个新进程,新进程(子进程)是调用进程(父进程)的精确副本;
  • 差别只有几处:
    • 子进程有唯一的 PID;
    • 子进程有不同的父进程(PPID 指向调用者);
    • 子进程有自己的一份文件描述符副本;
    • 子进程的资源使用统计(resource utilizations)清零。

也就是说,如果视频编辑器进程调用 fork(),系统上就真的出现了两个一模一样的视频编辑器进程(除 PID 外)。

关于命名:fork 是 C 库函数名;在 Linux 内核里,进程和线程统一用 clone() 这类系统调用创建(视频中作者也提到这点)。

补充:现代内核为了避免「真的复制一整份内存」的巨大开销,通常配合 写时复制(Copy-on-Write, COW)——父子进程先共享同一批物理页,只有某一方写入时才真正复制那一页。

3.2 exec / execve:换掉自己正在跑的程序

1
2
3
4
5
6
7
进程 A'(fork 出来的副本)
│
│ execve("/bin/vim", argv, envp)
▼
进程 A' 的地址空间被重置:
text 段被新程序的代码替换 → 从新程序入口开始执行
(PID 不变,还是同一个进程)

execve() 的行为(视频中读了 man execve):

  • 把调用进程本身转换成「一个新程序」;
  • 新程序由路径指向的可执行文件构建,这个文件要么是可执行目标文件,要么是交给解释器处理的数据文件;
  • 可执行目标文件由「标识头 + 表示初始程序的若干数据页」组成(即编译产物,如 GCC 输出的 ELF)。

注意:exec 不创建新进程,它替换的是当前进程正在运行的程序镜像(代码/数据/堆栈被重置)。

3.3 标准启动模式:fork + exec

1
2
3
1. 父进程 fork()   ──▶ 得到一个子进程(父进程的副本)
2. 子进程 execve() ──▶ 子进程把自己替换成目标程序,开始执行
3. 父进程 wait() ──▶ 等待子进程结束并回收

这是 shell 执行一条命令的标准套路:先复制一个自己,再让复制品变身成目标程序。这样做的好处是父进程(shell)自己的状态完全不受影响。

3.4 一切的源头:init 进程

系统启动时,硬件上电、固件(firmware)执行完之后,会启动一个初始进程(Unix 传统上叫 init,现代 Linux 多为 systemd,PID 为 1)。

  • 内核会维护一张「当前所有活跃进程」的列表,负责给它们分配 CPU 时间、管理内存;
  • 其余几乎所有进程——无论内核进程还是用户进程——都是从这个初始进程 fork 出来的。

这形成了一棵进程树:init(1) → ... → 你的终端 → shell → 你敲下的每个命令。


4. 进程的解剖:它到底「包含」了什么

这是全片结构最清晰的一张图(39:00 起,作者取自 Brendan Gregg 的书)。一个进程由两大块组成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌───────────────────────── 进程 ──────────────────────────┐
│ │
│ 【用户地址空间 User Address Space】 ← 程序跑在这里 │
│ ┌────────────────────────────────────┐ 低地址 │
│ │ 可执行代码 executable (text) │ │
│ │ 只读数据 / 常量 data、bss │ │
│ │ 动态库 libraries │ │
│ ├────────────────────────────────────┤ │
│ │ ↓ 向下增长 │ │
│ │ 堆 Heap(malloc/new) │ │
│ │ │ │
│ │ 栈 Stack(↑ 向上增长) │ │
│ └────────────────────────────────────┘ 高地址 │
│ │
├──────────────────────────────────────────────────────────┤
│ 【内核上下文 Kernel Context】 ← 陷入内核时使用 │
│ · 内核栈(kernel stack):内核自己的一串函数调用 │
│ · 元数据:PID、UID、CPU 时间… │
│ · 文件描述符表(file descriptors) │
└──────────────────────────────────────────────────────────┘

4.1 用户地址空间内的布局

区域 内容 特点
text(程序文本) 真正被执行的指令:add / mov / jump 等汇编指令 大小固定,通常只读
data / bss 常量、已初始化/未初始化全局数据 大小固定
libraries 动态链接进来的库 加载时映射
stack(栈) 函数调用帧:局部变量、返回地址、当前执行位置 随函数调用/返回增长或收缩;深度递归可达上千帧
heap(堆) 与某次函数调用无关的通用内存:数组、图、哈希表、对象等 单向增长,但会产生碎片(分配与释放交错)

栈 vs 堆的一句话区分:

  • 栈回答「我现在执行到哪、这一层层函数调用的状态是什么」——是执行记录;
  • 堆回答「我需要一块不属于某次调用的通用内存,用来放要传递、共享的数据」——是数据存放地。

两者在地址空间里通常相向增长(栈向下、堆向上),中间的空隙留给彼此扩张。

4.2 内核上下文

当进程需要做 I/O(读磁盘、发网络请求等)时会切换到内核态执行,此时用的是另一套栈——内核栈,因为内核会执行完全不同的函数调用序列。

内核还会为该进程保存:

  • 元数据:进程 ID(PID)、用户 ID(UID)、累计 CPU 时间等;
  • 文件描述符表:这个进程当前打开了哪些文件。

4.3 文件描述符(file descriptor)是什么

观众提问「能解释一下文件描述符吗」,作者的解答可以整理为:

  1. 在 Unix 中打开文件要调用 open()(C 里常用 fopen() 作为其封装);
  2. open() 的语义:按路径打开文件,返回一个文件描述符给调用进程;
  3. 文件描述符就是这个进程「打开了这个文件」的标识符/句柄;
  4. 内核必须知道每个进程当前打开着哪些文件,原因有二:
    • 读写请求要能定位到具体文件;
    • 权限控制:文件系统有完善的读/写/执行权限模型,内核要结合「谁(UID)」和「打开了什么」来判断是否允许操作。

补充:Unix 中 0/1/2 固定为 stdin/stdout/stderr;文件描述符本质是进程级「打开文件表」的索引,fork 后子进程会继承一份副本,因此重定向、管道才能在进程间传递。


5. 进程 vs 线程

5.1 核心区别:共享地址空间,独享栈

1
2
3
4
5
6
7
8
     单线程进程                      多线程进程
┌──────────────────┐ ┌──────────────────────────────┐
│ 用户栈 ×1 │ │ 用户栈 ×N(每个线程一个) │
│ 内核栈 ×1 │ │ 内核栈 ×N(每个线程一个) │
│ ───────────── │ │ ──────────────────────── │
│ 共享:text / 堆 │ │ 共享:text / 堆 / 全局数据 / │
│ / 全局数据 │ │ 文件描述符 / 地址空间 │
└──────────────────┘ └──────────────────────────────┘

一句话概括:线程是进程的子部件——它共享同一个虚拟地址空间和同一个堆,但拥有自己独立的程序栈(用户栈 + 内核栈各一套),因此可以停在不同执行位置。

5.2 对比表

维度 进程(Process) 线程(Thread)
隔离性 完全隔离(独立地址空间) 共享地址空间、堆、全局数据、文件描述符
独立拥有的东西 地址空间、堆、栈、页表、FD 表 仅程序栈(用户栈 + 内核栈)、寄存器上下文
切换开销 较大(要切换地址空间/页表) 较小(同地址空间内切换)
通信方式 需要 IPC:消息传递 或 共享内存 直接读写共享内存(因此需要同步机制)
崩溃影响 一个进程崩溃通常不影响其他进程 一个线程崩溃可能拖垮整个进程
协调成本 低耦合,但通信成本高 耦合紧,需要锁/原子操作等协调

5.3 两种架构风格

  • 多进程架构(multi-process):一个程序主动 fork 出多个进程,用消息传递或共享内存通信,从而利用多核并行。优点是隔离性好、健壮(类似浏览器的多进程模型)。
  • 多线程架构(multi-threaded):单进程内创建 2 / 5 / 10 / 100 个线程,每个线程有独立执行栈、可被调度到不同 CPU 核上真正并行。优点是轻量、共享数据方便。

作者的立场值得记下:有观众说「进程是一个糟糕的抽象」,他强烈反对——正因为有了进程这个抽象,应用层才不必处理物理内存冲突与隔离问题。

补充:在 Linux 内核视角里,进程与线程都表示为 task(task_struct),区别只在于是否与其他 task 共享地址空间等资源(clone() 的参数决定共享哪些)。所谓「轻量级进程」即指此。


6. 进程的生命周期与调度

系统可能同时要跑上百个进程,但只有 4 个或 8 个核,物理上不可能同时执行。调度器除了选「谁上 CPU」,还要维护每个进程处于哪个状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
               ┌─────────────────────────────────────┐
│ │
▼ │
┌────────────┐ 被调度器选中 ┌────────────┐
新建 ──▶│ Ready │─────────────────▶│ Running │
│ 就绪/等待 │◀─────────────────│ 运行中 │
└────────────┘ 时间片用完/被抢占 └─────┬──────┘
▲ │
│ I/O 完成 / 事件到达 │ 发起 I/O、等网络、
│ │ 等锁、等信号
┌──────┴─────┐ │
│ Waiting │◀─────────────────────────┘
│ 阻塞/等待中 │
└────────────┘

执行结束 ──▶ ┌────────────┐ 由 OS 回收 ──▶ 彻底消失
│ Zombie/终止 │
└────────────┘
状态 含义 何时进入 何时离开
Ready(就绪) 万事俱备,只等 CPU 时间;没被任何东西阻塞 新建、I/O 完成、被抢占 被调度上 CPU
Running(运行) 正在 CPU 上执行指令 调度器选中 时间片用完、被更高优先级抢占、发起 I/O
Waiting / Blocked(等待) 因等待 I/O、网络、锁等而让出 CPU 发起阻塞操作 事件完成,回到 Ready
Zombie / Terminated(终止/僵尸) 已执行完毕,等待父进程或 OS 清理 进程退出 被回收后彻底消失

要点:

  • 「等待」状态是提高 CPU 利用率的关键——进程等 I/O 时把 CPU 让给别人;
  • 只有在 Running 状态的进程数 ≤ 核数时才是真并行,其余都是分时复用;
  • 调度策略(如 MLFQ 多级反馈队列)决定 Ready 队列里的谁先上、给多少时间片。视频中观众提到 OSTEP 书里的 MLFQ,作者表示没读过那本书但认可其价值。

7. 虚拟内存:让每个进程都以为自己拥有全部内存

这是全片技术密度最高的部分(55:50–79:30)。

7.1 要解决的问题

进程抽象承诺「你拥有全部 RAM,甚至近乎无限的内存」。但物理 RAM 只有那么多,还被几十上百个进程共享。如何同时兑现「独占幻觉」和「物理共享」?——虚拟内存(Virtual Memory)。

7.2 中间人:MMU

1
2
3
4
5
6
7
   CPU                       MMU                        RAM
┌────────┐ 虚拟地址 ┌──────────────────┐ 物理地址 ┌────────┐
│ 执行指令│──────────▶ │ 地址翻译 │──────────▶│ 物理内存│
│ "把 10 │ 0x12345 │ (硬件 + 内核软件) │ 0x83145 │ │
│ 存到 │ │ 查页表 / 页映射 │ │ │
│ 0x12345"│◀────────── │ │◀────────── │ │
└────────┘ 数据返回 └──────────────────┘ └────────┘
  1. CPU 执行的每条访存指令用的都是虚拟地址(如 0x12345);
  2. 这个地址不会直接送到 RAM,而是先交给 MMU(Memory Management Unit,内存管理单元);
  3. MMU 查表把虚拟地址翻译成物理地址(可能是完全不同的 0x83145),再去 RAM 取数据;
  4. MMU 由硬件 + 操作系统内核软件共同构成(具体划分依架构/OS 而异)。

作者用的类比很贴切:MMU 之于内存,就像数据库代理(proxy)之于数据库——应用不直连数据库,而是连 proxy,由 proxy 决定实际路由到哪个后端。MMU 就是 CPU 与物理内存之间的「代理」,负责把虚拟地址平滑地翻译成物理地址。

7.3 分页(paging):翻译的基本单位

  • 内存不是按字节映射的,而是按**页(page)**映射;
  • 常见页大小:4 KB ~ 16 KB;部分系统支持大页(如 2 MB);
  • 页是「装入内存 / 换出到磁盘」的基本单位;
  • 单条指令可能只请求 1 字节或 8 字节,但翻译只发生在页粒度上。

7.4 地址怎么拆:页号 + 页内偏移

1
2
3
4
5
6
7
虚拟地址 0x2C8 (视频采用的极简示例:地址只有 3 位十六进制)
┌──────┬────────┐
│ 2 │ C8 │
└──┬───┴───┬────┘
│ │
│ └─▶ 页内偏移 offset = 0xC8(在这页里的第几个字节)
└─────────▶ 页号 / 页表索引 index = 2(去页表第 2 项找映射)

真实系统中(x86-64 常用 48 位地址)则是高位做多级页表索引、低位做偏移。

7.5 页表项里存了什么

视频用一张极简「页映射表(page map)」来讲,每一项至少包含:

字段 含义 作用
R(Resident,驻留位) 该页当前是否真的在物理内存中 若为 0,需要先从磁盘调入(缺页)
D(Dirty,脏位) 自上次从磁盘载入后,该页内容是否被修改过 回收该页前若 D=1,必须先写回磁盘;D=0 则可直接丢弃/复用
物理页框号 / 偏移 该虚拟页实际映射到物理内存的哪个位置 完成翻译

D 位与 R 位的意义都来自「swap / 换页」机制:当 RAM 不够装下所有进程的内存时,OS 会把一些页推到磁盘上做后备存储,腾出物理内存给别人用。等需要时再换回来。所以必须记录「这页脏了没有」和「这页现在在不在内存里」。

7.6 一次完整的地址翻译(逐步走一遍)

以「把地址 0x2C8 的内容 load 到寄存器 R1」为例:

  1. CPU 执行 load 指令,产生虚拟地址 0x2C8;
  2. 拆成 页表索引 = 2,偏移 = 0xC8;
  3. 去当前进程的页映射表查第 2 项;
  4. 读该项的 R 位:
    • R = 1(已在内存) → 进入第 5 步;
    • R = 0(不在内存) → 触发缺页异常,进入下面 7.7 的流程;
  5. 读该项记录的物理页框号(示例中假设是 9);
  6. 定位到物理内存第 9 块页的起始处,再加上偏移 0xC8;
  7. 从该精确位置取出所需的 4 / 8 字节,送回 CPU 完成这条指令。

7.7 当 R = 0:缺页(page fault)流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CPU 请求 0x2C8
│
├─ 查页表:R = 0(页不在物理内存)
│
├─ 陷入内核:向内核请求「这页在磁盘的哪里」
│
├─ 发起磁盘 I/O 把该页读入内存
│ └─ 同时:把当前进程置为 Waiting/睡眠状态
│ 让其他进程先用 CPU ← CPU 不被浪费
│
├─ I/O 完成 → 触发 trap(陷入)
│
├─ 更新页表:R 置 1,填入物理页框号
│
└─ 回到原指令继续执行,取到数据返回

对 CPU 来说,这次访问墙钟时间(wall clock)变长了,但等待期间系统做了别的活——这就是虚拟内存与调度协同带来的收益。

7.8 为什么要多级页表

  • 现代 x86-64 虽然指令集是 64 位,实际使用的地址通常是 48 位;
  • 48 位地址空间意味着页表条目数量极其庞大(数亿级),不可能给每个进程都维护一张平铺大表;
  • 关键观察:真实程序对地址空间的使用极其稀疏——99.9% 的情况下只用到了零散的小块,绝大部分虚拟地址从未被访问;
  • 解法:多级 / 树状页表。先把地址空间切成很大的块,块内再分块……只为实际用到的部分分配下级页表,从而大幅压缩存储。

补充:为加速翻译,CPU 内还有 TLB(Translation Lookaside Buffer) 缓存最近的地址翻译结果,避免每次都走多级页表(视频未展开)。

7.9 虚拟内存最终解决的三个问题

  1. 隔离:程序读写的「地址」只在自己的映射里有效,无法踩到别的进程的物理内存;
  2. 安全:在 OS 层、乃至硬件层保证你不能读取别人的数据;
  3. 无冲突共享:OS 为每个进程维护一份页映射,并确保不会把同一块物理内存同时分配给两个进程。

于是程序可以随意执行「load 地址 0x1352 到寄存器」「把 R4 的内容存到某地址」这类指令,完全不去想物理布局——而这一切又完全不影响其他进程。


8. 核心概念速查表

概念 一句话定义 关键词
进程 Process 一个正在运行的程序的自包含实例,拥有独占机器的幻觉 隔离、PID、地址空间
线程 Thread 进程内的执行单元,共享地址空间与堆,独享栈 轻量、并行、共享
上下文切换 保存当前执行流状态、装入另一个执行流状态 分时、开销
调度器 Scheduler 决定哪个进程何时上 CPU、运行多久 时间片、优先级、MLFQ
fork / clone 克隆出一个几乎完全相同的进程 新 PID、FD 副本
exec / execve 用新程序替换当前进程的映像 不新建进程
init(PID 1) 系统第一个进程,几乎所有进程的祖先 进程树
用户地址空间 text / data / 库 / 堆 / 栈 程序运行地
内核上下文 内核栈 + 元数据 + 文件描述符表 内核态、陷入
文件描述符 FD 进程打开文件的标识符,由 open() 返回 权限、读写
堆 Heap 与函数调用无关的通用动态内存 碎片、单向增长
栈 Stack 函数调用帧记录 递归、增长/收缩
虚拟内存 让每个进程以为独占全部内存的抽象 MMU、页表
MMU 虚拟地址 → 物理地址的翻译单元(硬件+软件) 代理、映射
页 Page 映射与换入换出的基本单位(4–16 KB,大页 2 MB) 页号 + 偏移
页表项 PTE 记录 R 位、D 位、物理页框号 驻留、脏页
缺页 Page Fault 访问的页不在内存,需从磁盘调入 阻塞、trap
多级页表 树状结构,只为实际使用的地址段分配 稀疏、48 位地址

9. 常见误解澄清

  1. 「进程是糟糕的抽象」 —— 作者明确反对。没有进程抽象,每个应用都要自己处理内存冲突与隔离,通用 OS 无法成立。
  2. 「exec 会创建新进程」 —— 不会。exec 是替换当前进程的程序映像;创建新进程靠 fork/clone。
  3. 「多线程一定比多进程快」 —— 不一定。线程切换更轻、通信更方便,但共享带来同步成本,且一个线程崩溃可能拖垮整个进程。
  4. 「虚拟内存就是硬盘当内存用」 —— 只是其中一部分(swap)。虚拟内存的核心是地址翻译与隔离,让每个进程拥有独立、连续、可稀疏使用的地址空间。
  5. 「CPU 直接访问物理地址」 —— 不。程序发出的都是虚拟地址,必须经 MMU/页表翻译;每次访问前还要检查 R 位。
  6. 「一个核只能跑一个进程、不能同时跑多个」 —— 单核可以通过时间分片在多个进程间快速切换,产生「同时」的观感。

10. 延伸学习(视频中提到的资料)

资料 说明
Systems Performance — Brendan Gregg 本片依据的教材,800+ 页,含 CPU / 内存 / 文件系统等章节;作者建议按需选读,不必从头读到尾
MIT 的操作系统课程虚拟内存讲解视频 作者推荐:比书里更具体地讲了「虚拟页号 + 偏移 → 页表 → 物理地址」的机制(片中示范的 0x2C8 例子即来自该视频)
OSTEP(Operating Systems: Three Easy Pieces) 观众推荐的经典 OS 教材,其中 MLFQ 调度章节评价很高;作者尚未细读
Cursor 博客《Agent swarms in the new model economics》 片中提到的延伸阅读:用「任务树分解 + 多模型协作」的 agent 集群按 SQLite 规格重写 SQLite,并把该 swarm 类比为编译器(planner 把意图逐层 lowering 成可执行任务;区别是编译器每一步保义,而 swarm 每一步都是概率性的)
mmap 观众提问;作者指出它是「把文件内容映射进内存」的重要机制,本片未展开
fork bomb / 双 fork(double fork)/ 会话与进程组 观众提问,片中未展开;双 fork 常用于让守护进程脱离终端会话
作者本人的博客 关于 processes vs threads 的文章发布在 PlanetScale 博客,个人站 benjdd.com

11. 自测题

  1. 如果一台机器只有 1 个核,为什么还能「同时」运行浏览器和音乐播放器?请描述其机制。
  2. fork() 创建的子进程与父进程有哪些具体差异?为什么这些差异是必要的?
  3. 为什么「启动一个程序」需要 fork 和 exec 两步,而不是只要一步?
  4. 画出进程地址空间,标出 text、data、堆、栈,并说明堆和栈的差异与增长方向。
  5. 文件描述符是什么?内核为什么必须为每个进程维护一张 FD 表?(提示:权限)
  6. 线程与进程最本质的区别是什么?由此带来哪些性能与工程上的权衡?
  7. 列出进程的四种状态及各自转换条件。
  8. 虚拟地址 0x2C8 在「页表索引=高位、偏移=低位」的模型中如何拆分?若对应页表项 R=0,接下来会发生什么?
  9. 页表项中的 D 位和 R 位分别解决什么问题?如果 D 位始终为 0、永不置位,会有什么后果?
  10. 为什么 48 位地址空间必须用多级页表而不能用单级平铺页表?

12. 一页速记

  1. 硬件只有 CPU、RAM、磁盘;多程序并发靠分时 + 上下文切换。
  2. 进程是「让你以为自己独占整台机器」的自包含运行实例,隔离与安全都靠它。
  3. 创建进程 = fork 克隆自己 + exec 变身新程序;系统一切进程都从 PID 1 派生。
  4. 一个进程 = 用户地址空间(text/data/库/堆/栈)+ 内核上下文(内核栈/PID/UID/FD 表)。
  5. 栈记执行轨迹,堆放通用数据;FD 是进程打开文件的句柄,受权限约束。
  6. 线程共享地址空间与堆、独享栈,故更轻量,但需同步、隔离性弱。
  7. 进程状态:Ready → Running → Waiting →(Zombie),由调度器在核间分配。
  8. 虚拟内存用 MMU + 分页 + 页表(R/D 位、多级树)把虚拟地址翻译成物理地址,实现「独占幻觉 + 物理共享 + 安全隔离」。