操作系统基础深度解析

从内核态/用户态出发,把进程、虚拟内存、文件系统、I/O 栈、系统调用与调度器一次讲透。每一节都给出可在终端验证的命令和量级数字。

打开一个终端,敲下 cat hello.txt。在你按下回车的那一瞬间,发生了至少七层下钻:bash 解析命令 -> fork+execve 启动 cat 进程 -> 进程的内存被分配虚拟地址空间 -> cat 调用 read() 触发 syscall -> CPU 切到内核态 -> VFS 转发到 ext4 -> 块层给 NVMe 排好请求 -> 硬件通过 DMA 把字节送回 -> 中断把控制权交还给 cat -> 字节经过页缓存复制到用户缓冲区 -> 屏幕上出现内容。

整个过程大概 100 微秒,你看到的只是最后那一帧。这篇文章的目标,是把这条路径上的每一层都拆开,让你在遇到性能问题、奇怪的权限问题、或者「为什么我的程序卡在 D 状态」时,能直接定位到是哪一层出了事。

读完你能做到

  • 准确说出 monolithic 与 microkernel 的真实差异,以及为什么 Linux 至今没有走 microkernel 路线。
  • 看到一个 ps 输出里的 D/S/R/Z,知道每个状态对应进程生命周期的哪一格、要等什么事件才能离开。
  • 理解一次「进程访问一个变量」背后 MMU/TLB/页表/page fault/swap 的完整链路,并能算出每一级的数量级开销。
  • 看明白 ls -li 输出里 inode、nlink、blocks 三列分别在说什么,能解释 rm 后空间没释放的原因。
  • 把一次 read() 系统调用从 glibc 一路追到 NVMe 队列,并能定位「为什么我的 IO 慢」是在哪一层。
  • 比较 FCFS / SJF / RR / CFS 四种调度策略的优劣,理解 Linux 为什么选 CFS 而不是 RR。

前置

  • 至少写过一些 C 或者用过 Python 调过 OS 接口(os.reados.fork 之类)。
  • 用过 Linux,知道 toppsstrace/proc 大致是干嘛的。
  • 不要求看过《操作系统:三只小猪》,但看过会更顺。

1. 操作系统到底在做什么

操作系统(Operating System,OS)这个名字其实有点误导人——它听上去像一个「系统」,实际上是一组解决多人共用一台机器这件事的代码集合。如果一台机器上永远只跑一个程序、永远只有一个用户、硬件永远不会出错,OS 大部分代码都可以删掉。OS 之所以复杂,是因为现实里:

  • CPU 只有几十个核,但要跑成百上千个进程 -> 需要调度器
  • 物理内存只有几十 GB,但每个进程都希望「我独占整台机器的地址空间」 -> 需要虚拟内存
  • 硬件型号上千种,但应用程序不想知道 NVMe 和 SATA 的区别 -> 需要驱动 + VFS 抽象
  • 多个进程要共享文件、内存、socket,但不能互相破坏 -> 需要权限 + 隔离

把这四件事记住,下面所有章节都能挂在它们底下。我们会按「最贴近 CPU 的部分先讲」的顺序展开:内核架构 -> 进程 -> 内存 -> 文件 -> I/O -> 系统调用 -> 调度。

2. 内核架构:单内核 vs 微内核

Monolithic vs Microkernel — what runs in kernel mode

「内核态」和「用户态」是 CPU 提供的硬件特性(x86 上是 ring 0 和 ring 3)。在内核态可以执行特权指令、直接访问任意物理内存、控制中断。用户态不行——你的浏览器、数据库、bash,全部跑在 ring 3,想访问硬件只能通过内核

这一刀切下去之后,内核里到底应该塞多少东西?这就是单内核(monolithic)和微内核(microkernel)的核心分歧。

2.1 单内核:什么都塞进去

Linux、FreeBSD、Windows NT(实际上是 hybrid)都属于这一派。调度器、内存管理、文件系统、TCP/IP 栈、设备驱动全部住在内核地址空间里,互相直接调函数,不需要走任何隔离机制。

  • 优点:快。 文件系统模块要给网络栈传数据,就是一次普通的函数调用,没有任何边界开销。
  • 缺点:脆弱。 任何一个驱动崩溃 = 整个内核 panic。Linux 内核里 70% 的代码是驱动,新硬件就是新风险。
  • 缓解办法:模块化。 Linux 把驱动做成可加载模块(*.ko),运行时 insmod/rmmod。但模块本身仍然在内核地址空间里,bug 一样能搞死内核——只是降低了编译和发布耦合。

2.2 微内核:只留必需的

Mach、L4、seL4、QNX 走的是另一条路。内核里只留进程间通信(IPC)、调度、最低限度的内存管理,文件系统、网络栈、驱动全都做成普通用户态进程

  • 优点:隔离。 网卡驱动崩溃 = 一个用户进程崩溃,重启它即可,内核不动。seL4 甚至做了形式化证明:在它的规约下,内核保证不会崩溃。
  • 缺点:慢。 一次「打开文件」原本是一个函数调用,现在变成「应用 -> 内核 IPC -> 文件服务进程 -> 内核 IPC -> 块设备服务进程」,每一跳都是一次上下文切换。
  • 现实使用: QNX 在汽车、医疗、工业控制里跑了几十年;macOS / iOS 的 XNU 是 Mach 微内核 + BSD 单内核拼出来的「混合内核」;纯微内核在通用桌面 / 服务器上仍然是少数派。

2.3 为什么 Linux 没换成微内核

1990 年 Tanenbaum 和 Linus 的著名论战里,Tanenbaum 说「Linux 用单内核已经过时了」。三十多年过去,Linux 仍然是单内核。原因不浪漫:

  • 性能数字说话。 微内核的 IPC 开销(即使在 L4 这种极致优化的实现上)也比函数调用大一个数量级。
  • 生态惯性。 全世界几十万个 Linux 内核驱动重写一遍是不可能的。
  • 「足够安全」的中间路线。 内核地址空间随机化(KASLR)、模块签名、eBPF 把不可信代码沙箱化、用户态文件系统(FUSE)等手段,让单内核也能在大部分场景下做到「驱动出 bug 不直接 panic」。

记住这张图后,每次看到内核 oops 或 BSOD,你就知道:那是单内核的代价。每次看到一个驱动崩溃却没拖垮系统(比如手机 wifi 模块自己重启),那很可能是微内核思想在某个层面被借用了。

3. 进程与状态机

进程是 OS 给「正在运行的程序」记账的单位。每个进程都有自己的:地址空间、文件描述符表、信号处理表、CPU 上下文(寄存器)、调度状态。当 OS 决定让一个进程下 CPU、让另一个进程上 CPU 的时候,就是把前者的寄存器存到内存里、把后者的寄存器从内存里恢复——这叫上下文切换,每次大概 1~5 微秒。

3.1 五状态生命周期

Five-state process lifecycle

任何时刻,每个进程都恰好处于这五个状态之一:

状态Linux ps 列里的字母含义离开的事件
NEW(短暂)正在创建(forkexecve 之间)内核完成进程结构,转 READY
READYR (Runnable)万事俱备,等 CPU调度器选中 -> RUNNING
RUNNINGR (实际占着 CPU)正在执行用户代码或内核代码时间片用完 / 调用阻塞 syscall / 退出
BLOCKEDS (可中断) / D (不可中断)在等某个事件(I/O、锁、子进程)事件到达 -> READY
TERMINATEDZ (Zombie)跑完了但 PCB 还在等父进程 wait()父进程读走退出码 -> 完全消失

值得专门记住两件事:

  1. READY 和 RUNNING 在 ps 里都是 R Linux 把「能跑」和「正在跑」合并显示,因为时间分辨率太短,区分意义不大。
  2. D 状态杀不掉。 kill -9 都没用。这是因为进程正在内核里等一个不能取消的硬件操作(比如 NFS 读写卡死)。看到大量 D 进程,第一个怀疑对象是底层存储 / 网络。

3.2 上下文切换的真实开销

1
2
3
4
# 用 vmstat 看切换频率(cs 列,单位是次/秒)
vmstat 1
# 用 perf 看一次切换的具体周期
perf stat -e context-switches,task-clock sleep 1

一次切换的直接成本约 1~5 微秒(保存/恢复寄存器、切页表、更新调度统计),间接成本是 缓存污染——新进程的工作集要重新加载到 L1/L2 cache,可能再多花几微秒到几十微秒。所以线程数远多于核心数几乎一定会变慢,不是因为 CPU 算不过来,而是因为切换在烧时间。

3.3 进程 vs 线程 vs 协程

这三个词每天都在被混用。一句话区分:

  • 进程:独立地址空间。隔离最好,创建最贵(fork 要复制页表)。
  • 线程:同一地址空间。共享内存最方便,但要自己处理同步。Linux 上线程其实就是「共享更多东西的进程」,由 clone() 系统调用控制共享什么。
  • 协程:用户态调度。OS 完全看不到,没有 syscall 开销,但需要程序自己交出 CPU(await)。一台机器上可以轻松跑几十万个协程。
维度进程线程协程
调度者OS 内核OS 内核用户态运行时
切换成本~5 us~3 us~100 ns
内存隔离无(同进程内)
适合场景多服务、安全边界计算密集 + 共享内存高并发 I/O

4. 虚拟内存:每个进程都以为自己独占机器

Virtual memory + paging

直接让进程访问物理地址有三个无解的问题:

  1. 冲突。 两个程序都想用 0x1000,谁让谁?
  2. 隔离。 进程 A 有没有办法读到进程 B 的密码?
  3. 超额。 32 个进程每个想用 4 GB,机器只有 16 GB,怎么办?

虚拟内存把所有问题一起解决:每个进程拿到一份自己的虚拟地址空间(64 位下名义上 256 TB),由 MMU 硬件 + 内核维护的页表翻译成物理地址。

4.1 翻译流程

地址按页(page,通常 4 KiB)切分。一个虚拟地址 = [VPN | offset],VPN 经过页表映射成 PFN(physical frame number),物理地址 = [PFN | offset]

  • TLB(Translation Lookaside Buffer): MMU 上的小缓存,通常几百到几千项。命中是 1 个 cycle,没命中要走页表(4 级页表 = 4 次内存访问 ~= 100 cycles)。
  • 多级页表: 64 位下如果用单级页表,光页表本身就要占数 PB。Linux 现在用 5 级页表(PML5),按需展开,只为真正使用的虚拟地址范围分配页表项。
  • 页表项里的标志位: R/W/X(权限)、Present(是否在物理内存里)、Dirty(写过没)、Accessed(最近访问过没)、User/Supervisor。这些位是所有内存安全和性能优化的基础——COW、mmap、swap、KSM 全都靠它们。

4.2 page fault 路径

如果 PTE 的 Present 位是 0,CPU 直接抛 page fault 异常,跳到内核处理。三种典型情况:

  1. Minor fault(按需分配): 进程 malloc 了 1 GB 但还没真正写过——内核此刻才真给它分配物理页,并把 PTE 写好。
  2. Major fault(从 swap 调入): 那一页之前被换到了磁盘上的 swap 空间,要把它读回内存。这是慢动作,10 ms 量级。
  3. Segfault(非法访问): 那一页根本不属于这个进程的合法地址范围 -> 发 SIGSEGV,进程多半挂掉。
1
2
3
4
# 看进程的 page fault 数
ps -o min_flt,maj_flt,cmd -p <pid>
# 看全机的 page fault 速率
sar -B 1

maj_flt 持续增长 = 在频繁换页,性能会塌方。这是「机器在 swap」的硬指标,比看 free 列直观得多。

4.3 一些每天都用、但你可能没意识到是虚拟内存在干活的功能

  • fork() 的 Copy-on-Write: fork 不真的复制 4 GB,而是把父子的所有页都标记为只读 + 共享,谁先写谁触发 fault,到时再复制那一页。
  • mmap() 把文件映射成内存: 你以为在读内存,其实 OS 在按需读磁盘。/usr/bin/python 这种共享库,所有 Python 进程都映射到同一份物理页上。
  • OOM killer: 当物理 + swap 也不够用时,内核挑一个「分数最高」的进程杀掉。/proc/<pid>/oom_score 决定谁倒霉。
  • 大页(huge pages): 把页大小从 4 KiB 改到 2 MiB 或 1 GiB,TLB 覆盖范围放大 512 倍。数据库(PostgreSQL、Redis)经常配。

数量级要记住:

事件时间相对慢多少
L1 cache 命中1 ns1x
TLB 命中 + L1 命中~1 ns1x
TLB miss + 页表遍历~30 ns30x
RAM 访问~100 ns100x
page fault(minor,无 I/O)~1 us1,000x
page fault(major,从 SSD swap)~80 us80,000x
page fault(major,从 HDD swap)~10 ms10,000,000x

「慢」是有量级的。性能优化的第一步永远是判断你在跟哪个量级打交道。

5. 文件系统:名字、元数据、数据是三件不同的事

Inode-based file system

很多人以为「文件 = 一段字节 + 一个名字」。这是用户视角。在内核里它分成三层:

  1. 目录项(dirent): 名字 -> inode 号的映射。目录就是「特殊的文件」,里面装着一堆 dirent。
  2. inode: 文件的元数据 + 指向数据块的指针。没有名字。
  3. 数据块(block): 真正存字节的地方,通常 4 KiB 一块。

把这三层分开,是 Unix 文件系统的设计精髓。它直接解释了一堆原本看着诡异的现象:

5.1 为什么 mv 在同一个分区里是瞬时的?

mv a.txt b.txt:只是把目录里 a.txt -> ino 42 这条 dirent 改成 b.txt -> ino 42。inode 没动,数据块更没动。零字节复制。跨分区就不行了,因为另一个文件系统的 inode 表是独立的,必须真复制。

5.2 为什么 rm 不一定真删数据?

rm 做的是 (1) 删 dirent (2) nlink--。当 nlink 减到 0 没有任何进程还打开着这个文件时,inode 才真正被释放,数据块才被标记为 free。

1
2
# 一个文件被进程打开着,rm 之后空间不会立刻释放
lsof | grep deleted  # 找出来这种「僵尸文件」

这是一个常见的「磁盘满了但找不到大文件」的根因:日志被 logrotate 删了,但应用还开着 fd 在写。重启那个应用 = 释放空间。

5.3 硬链接 vs 软链接

  • 硬链接 (ln a b): 在同一个 inode 上挂多个名字。nlink 加一。删任一个名字其他都还在。不能跨分区(inode 号在分区内才唯一)、不能链接目录(避免成环)。
  • 软链接 (ln -s a b): 一个独立的 inode,里面只存目标的路径字符串。可以跨分区、可以指向不存在的目标(dangling link)、被指目标删了它就坏了。
1
2
ls -li  # 第二列是 nlink。普通文件 1,目录至少 2(自己 + . 引用)
stat foo

5.4 块指针:为什么 inode 大小固定,但能存超大文件?

经典 Unix inode 里有 12 个直接块指针(直接指数据块)+ 1 个一级间接 + 1 个二级间接 + 1 个三级间接。块大小 4 KiB 时:

  • 直接:12 * 4 KiB = 48 KiB
  • 一级间接:(4 KiB / 4 B) * 4 KiB = 4 MiB
  • 二级间接:~4 GiB
  • 三级间接:~4 TiB

绝大多数文件 < 48 KiB,所以小文件直接命中、零额外读。大文件付出 1~3 次额外 I/O 就能定位到任意 4 KiB。这是「为常见情况优化」最经典的例子之一。

现代文件系统(ext4、xfs、btrfs、ZFS)改用 extent(连续区间)替代「指针指向单块」,进一步减少元数据开销,但「inode 是元数据 + 块定位结构」的核心不变。

5.5 VFS:把所有「文件状的东西」都叫文件

Linux 在文件系统之上加了一层 VFS(Virtual File System),让不同的文件系统、网络挂载、伪文件系统都暴露同一套 open/read/write/seek/close 接口。这就是为什么你可以:

  • cat /proc/cpuinfo 读 CPU 信息(procfs,纯虚构的文件)
  • cat /sys/class/net/eth0/address 读 MAC 地址(sysfs)
  • echo mem > /sys/power/state 让机器睡眠
  • 用 FUSE 把 SSH 远端目录挂载成本地路径

「一切皆文件」不是哲学口号,是 VFS 把它实现出来的工程结果。

6. I/O 子系统:一次 read() 下钻七层

The I/O stack

read(fd, buf, 4096) 看起来是一行代码,背后是一个七层下钻 + 一次中断回调。我们按层走一遍:

  1. 应用层。 调 glibc 的 read() 包装函数。
  2. 系统调用接口。 包装函数把寄存器准备好,执行 syscall 指令陷入内核。CPU 切到 ring 0,跳到 entry_SYSCALL_64 入口。
  3. VFS。 内核根据 fd 找到 struct file *,按它指向的 file_operations.read 派发到具体文件系统。
  4. 具体文件系统(ext4)。 把文件偏移翻译成块号(走 inode 的 extent 树)。
  5. 页缓存(page cache)。 先看这块是不是已经在内存里。命中就直接 copy_to_user(buf) 返回,整个流程在几微秒内结束。这是 99% 的热数据走的路径。
  6. 块层。 没命中,构造一个 BIO(块 I/O 描述符),交给块层调度。块层会合并相邻 BIO(电梯算法 / mq-deadline / kyber),减少机械硬盘的寻道。
  7. 设备驱动。 NVMe 驱动把 BIO 翻译成 NVMe 命令,提交到硬件队列(submission queue)。
  8. 硬件。 SSD 控制器执行命令,通过 DMA 把数据直接写到内核分配好的物理页里(不走 CPU),完成后触发中断通知 CPU。

中断处理把数据复制到用户 buf,唤醒之前 sleep 的进程。控制权回到 read() 调用者。

6.1 为什么这么多层

每一层都对应一个抽象:

抽象的是
系统调用接口用户态/内核态权限边界
VFS不同文件系统的 API 差异
文件系统字节流 -> 磁盘块的映射
页缓存慢设备 + 快内存的速度差
块层重排请求以优化设备
驱动不同硬件的命令集差异

少了哪一层,应用程序都要自己解决那个抽象。这就是为什么直接绕过 OS 写一个数据库引擎并不会比走 OS 快——OS 的这些优化都是几十年沉淀下来的。

6.2 同步、异步、io_uring

经典 read()阻塞同步:调用者一直挂着等数据。这对一个进程一次只做一件 I/O 的程序很合适,但对要同时管 1 万个连接的服务器来说就挂掉了——每个连接一个线程?10000 个线程的上下文切换会把 CPU 跑冒烟。

历代解法:

  • select/poll/epoll 一个线程同时盯多个 fd,谁就绪了通知。只能告诉你「现在可以发 read 了」,read 本身仍然是同步。
  • aio Linux 早年的真正异步 I/O,但只支持 direct I/O,bug 很多,没真正流行起来。
  • io_uring(Linux 5.1+): 现代答案。用户态和内核共享两个环形队列(提交 + 完成),单次 syscall 可以提交几十个 I/O,内核完成后写回完成队列,用户态自己来取。系统调用次数指数级下降,是高性能 I/O 库(QUIC 服务器、新一代数据库)的首选。

6.3 「慢 IO」的诊断顺序

1
2
3
4
5
6
7
8
# 1. 应用层视角
strace -e trace=read,write -T -p <pid>   # -T 显示每个 syscall 用了多少时间
# 2. 块层视角
iostat -x 1                              # await 列:单次 IO 平均耗时
# 3. 设备层视角
iostat -x 1 | awk '/nvme|sda/ {print}'   # %util、svctm
# 4. 页缓存命中率
cat /proc/meminfo | grep -E 'Cached|Buffers'

从应用追到设备,定位「慢」是哪一层带来的。常见误区:看到 CPU 不忙就以为系统闲着——其实可能整台机器都在等 I/O,vmstatwa 列才是真相。

7. 系统调用:用户态唯一合法的求救信号

The system call boundary

用户态程序想做任何「特权」操作(读文件、发包、申请新内存、创建进程),都必须通过系统调用。这是操作系统能保证安全的根本机制——CPU 在 ring 3 时根本执行不了某些指令,唯一切到 ring 0 的方式就是 syscall / int 0x80 / sysenter 这类陷阱指令

7.1 一次 syscall 的步骤

  1. C 库包装。 应用代码里的 read(fd, buf, n) 是 glibc 的普通函数。
  2. 寄存器准备。 glibc 把 syscall 号放进 rax(Linux x86_64 约定),参数放进 rdirsirdxr10r8r9
  3. syscall 指令。 CPU 把当前 RIP/RSP/EFLAGS 保存到 MSR,切换到 ring 0,跳到内核早就注册好的入口 entry_SYSCALL_64
  4. 派发。 内核以 rax 为下标查 sys_call_table,跳到对应的内核函数(如 sys_read)。
  5. 执行。 走 VFS、文件系统、块层 ……(见上一章)。
  6. 返回。 结果写到 rax,执行 sysret 切回 ring 3。

7.2 syscall 的真实开销

一次「空 syscall」(什么都不做的 getpid())在现代 CPU 上大约 100 ns。看上去不多,但相对来说:

  • 普通函数调用: ~1 ns。syscall 比函数调用慢 100 倍。
  • 缓存影响: mode 切换会污染 L1/L2,下面几百个指令的 IPC 会下降。
  • Spectre/Meltdown 缓解(KPTI): 切换时要刷 TLB,开销又被推高 30~50%。

这就是为什么所有高性能 I/O 库都在批量化 syscall:sendmmsg 一次发多个包、io_uring 一次提交多个 I/O、vmsplice 直接搬页表。本质都是「摊薄那 100 ns」。

7.3 用 strace 看 syscall

1
2
3
4
5
6
# 看一个程序总共调了哪些 syscall、各多少次、各多慢
strace -c ls /tmp
# 实时跟一个运行中的进程
strace -p <pid> -e trace=network
# 跟踪 fork 出来的子进程
strace -f ./run.sh

写性能问题的诊断里,strace -c 通常 30 秒内告诉你瓶颈在哪——如果 read 占 80%,问题在 I/O;futex 占 80%,是锁争用;epoll_wait 占 80%,那大概率是健康的(在等事件)。

8. 调度器:四种策略,同一组任务

Four schedulers, same workload

调度器决定「下一刻 CPU 让谁跑」。用同样的三个任务(P1 burst=8、P2 burst=4、P3 burst=2,依次到达)跑一遍四种经典策略,对比直观:

8.1 FCFS — 先来先服务

谁先来谁先跑,跑完才让位。实现一行代码。问题叫护航效应(convoy effect):一个 CPU-bound 长任务卡在前面,后面所有短任务都饿着。上图里 P3 等到 t=12 才跑,平均等待时间 5.67。

适用场景:批处理后台、单任务系统。绝不用于交互式系统。

8.2 SJF — 最短作业优先

每次从就绪队列里挑剩余 burst 最短的。理论上平均等待时间最优。问题有两个:

  1. 谁知道未来 burst 多长? 不知道。只能用历史平均估计(Linux 早年的 nice + 老化算法)。
  2. 饥饿。 长任务可能永远等不到——只要短任务源源不断进来。

8.3 Round Robin — 时间片轮转

每个进程跑固定时长(quantum,通常 10~100 ms),到点就强制换下一个。没有饥饿、响应均匀——这就是早期分时操作系统能让二十个人共用一台机器的原因。

调参的核心是 quantum:

  • 太小(< 1 ms):上下文切换占比过高,吞吐塌方。
  • 太大(> 1 s):交互响应卡顿,长任务又把 CPU 霸占了,退化成 FCFS。

上图 quantum=2 时平均等待 4.33。

8.4 CFS — Linux 现役

Linux 从 2.6.23 开始用 CFS(Completely Fair Scheduler)。核心思想:给每个进程记一个 vruntime(虚拟运行时间),永远调度 vruntime 最小的那个。vruntime 按 nice 值加权——nice 高的进程 vruntime 涨得快,于是被调度的频率低。

实现细节:

  • 用红黑树按 vruntime 排序,pick 最小是 O(log n)。
  • 没有固定 quantum,而是「目标时延 / 进程数」动态分配。8 个 runnable 进程、目标 6 ms -> 每人跑 0.75 ms。
  • I/O-bound 进程因为大部分时间在 sleep,vruntime 涨得慢,自动获得高优先级——这正是「交互式优先」的天然实现,不需要专门分类。

上图 CFS 的等待时间最低(4.0)且最均匀。短任务 P3 1 单位等待就完成了。

2024 年 Linux 6.6 引入了 EEVDF(Earliest Eligible Virtual Deadline First),是 CFS 的改进版,更适合现代 NUMA + 大量小任务的工作负载。思想一脉相承,工程细节不同。

8.5 实时调度

CFS 是「公平调度」,对实时任务(音视频、机器人控制、汽车)不够。Linux 还有:

  • SCHED_FIFO 严格优先级,最高优先级的实时任务一上来就霸占 CPU 直到自己 sleep / 让出。
  • SCHED_RR 同优先级用 RR 切换。
  • SCHED_DEADLINE(3.14+): 给任务声明 (runtime, deadline, period),内核保证在 deadline 前给到 runtime。基于 EDF 算法。
1
2
3
4
# 看一个进程的调度类
chrt -p <pid>
# 用 SCHED_FIFO 优先级 50 跑一个程序
chrt -f 50 ./mybin

9. 把 7 张图串成一次完整的 cat hello.txt

回到开头那个 cat hello.txt,用学过的所有零件复述一遍:

  1. bash 解析命令 -> 调用 fork():内核给新进程分配 PCB、复制虚拟地址空间(COW,所以瞬时完成)。父子都从 fork 返回。
  2. 子进程调 execve("/bin/cat", ...):清掉自己当前的虚拟地址空间,重新 mmap /bin/cat 的代码段、加载动态链接器、启动 main()。这一步发生了 N 次 minor page fault——cat 的代码页是按需分配的。
  3. cat 调用 open("hello.txt") -> syscall(用户态切内核态,约 100 ns) -> VFS -> ext4 -> 找到 dirent 「hello.txt -> ino 1234」 -> 读 inode 1234 进内存 -> 返回 fd。
  4. cat 调用 read(fd, buf, 4096) -> syscall -> VFS -> ext4:把文件偏移 0 翻译成 LBA 19528 -> 查页缓存:未命中 -> 构造 BIO -> 块层调度 -> NVMe 驱动 -> 硬件 DMA 把 4096 字节搬到内核页。
  5. 中断回调 -> 唤醒 cat(从 BLOCKED 切回 READY) -> 调度器(CFS)选中它 -> 上下文切换 -> cat 在内核里执行 copy_to_user(buf, page, 4096) -> 返回用户态。
  6. cat 调用 write(1, buf, n) -> syscall -> VFS -> /dev/pts/0(伪终端) -> tty 子系统 -> 终端模拟器 -> 屏幕。
  7. cat 调用 _exit(0) -> syscall -> 内核回收资源、把退出码塞 PCB、转 ZOMBIE -> bash wait() 收尸。

整个过程涉及:4 次主要 syscall、若干次 page fault、一次 NVMe 完整 I/O、若干次上下文切换、CFS 几次调度决策。每一步都对应了一张图。 这就是「理解操作系统」的真正含义——不是背概念,而是看一个具体动作,能说出底层每一步在干什么。

10. 接下来读什么

按「越来越底」的顺序:

  • 入门: Operating Systems: Three Easy Pieces(OSTEP,免费在线,强烈推荐)。
  • Linux 内核细节: Understanding the Linux Kernel(旧但经典);新一点用 Linux Kernel Development (Robert Love)。
  • 性能视角: Brendan Gregg 的 Systems Performance 和他的网站,是当代性能工程的圣经。
  • 微内核 / 形式化: seL4 的论文 seL4: Formal Verification of an OS Kernel
  • 想动手: xv6(MIT 6.S081 的教学 OS),代码 1 万行就能跑起来一个真正的多进程内核。
  • Linux 子系统专题: 内核源码树里 Documentation/ 是被严重低估的资料库;/proc/sys 是活的文档。

「操作系统」这个领域的好处是它的概念五十年没大变。你今天学的进程模型、虚拟内存、文件系统抽象,回到 1985 年的 BSD 同样适用,到 2050 年大概率还在。投入回报极高的一笔学习。

Liked this piece?

Follow on GitHub for the next one — usually one a week.

GitHub