这一篇在干嘛?

本章是整本书的地基:先弄清 Linux 在 Unix 家族中的位置,再建立操作系统最核心的几组抽象——用户/内核两种模式、进程、文件、中断。后面第 2~20 章讲的每一个机制(内存寻址、进程、中断……)都能在这里找到”为什么需要它”的源头。

图 1:第 1 章题图(Understanding the Linux Kernel, 3rd Edition)

Linux 与其他类 Unix 内核 | 硬件依赖与内核版本 | 操作系统基本概念 | 内核架构:单体、微内核与模块 | Unix 文件系统概览 | 进程/内核模型与可重入内核 | 同步与临界区 | 信号、进程管理与内存管理

Linux 与其他类 Unix 内核

Linux 是类 Unix 操作系统大家族的一员,1991 年由 Linus Torvalds 为基于 Intel 80386 的 IBM 兼容 PC 编写。它不属于任何商业 Unix 的直系后代:商业 Unix 变体基本都源自 AT&T 的 SVR4 或伯克利的 4.4BSD,而 Linux 是参考 Maurice Bach 的《The Design of the Unix Operating System》(讲的是 SVR2 时代的基线)从零写出来的,同时博采各家之长。

严格说,Linux 是一个真正的 Unix 内核,但不是完整的 Unix 操作系统——它不带文件系统工具、窗口系统、编辑器、编译器这些外围程序。这些程序大多以 GPL 许可自由获取,所以日常使用的”Linux 发行版” = 内核 + 一大堆 GPL 软件。本书说的所有源码路径,默认都指 /usr/src/linux 下的内核源码树。

各个类 Unix 系统都遵循 POSIX、X/Open CAE 这类标准,但标准只规定应用程序编程接口(API),不约束内核内部实现。也就是说,学懂 Linux 内核的设计,再去看 Solaris、FreeBSD 也会觉得眼熟——核心设计思想是相通的。

Linux 2.6 与几种知名 Unix 内核的横向对比(这是理解 Linux 设计取舍的好材料):

特性Linux 2.6 的做法说明
单体内核(Monolithic kernel)整个内核是一个大程序,各逻辑组件集成在一起。Mac OS X 和 GNU Hurd 走的是基于 Mach 的微内核路线
动态加载模块支持得非常好,可按需自动装载/卸载商业 Unix 中只有 SVR4.2 和 Solaris 有类似能力
内核线程使用非常有限,只用于周期性执行少数内核函数不把内核线程当作基本执行上下文
多线程应用支持自定义的轻量级进程(LWP),通过非标准的 clone() 系统调用实现其他系统的 LWP 都基于内核线程,Linux 则把轻量级进程当作基本执行上下文
抢占式内核编译时开启 “Preemptible Kernel”选项即可全抢占的通用 Unix 很少,Solaris 和 Mach 3.0 算
多处理器支持支持 SMP,包括 NUMA 内存模型仅剩少数代码仍被”大内核锁”串行化
文件系统Ext2/Ext3/ReiserFS/JFS/XFS 等一应俱全靠面向对象的虚拟文件系统(VFS)层,移植外来文件系统比其他内核容易
STREAMS I/O 子系统SVR4 引入的 STREAMS 因性能开销被 Linus 拒绝

对比之后可以总结出 Linux 相对商业系统的几个优势:免费、完全可定制(编译选项 + GPL 源码)、能在低端硬件上运行(80386 + 4MB 内存也能当网络服务器)、以效率为第一目标、稳定、内核可以小到塞进一张 1.44MB 软盘、兼容大量文件系统和网络协议、社区响应快。这些不是空洞的宣传语,而是和内核设计决策直接相关的——比如”效率优先”就直接解释了为什么 Linux 不引入 STREAMS。

硬件依赖与内核版本

内核代码里有一部分天然和硬件绑定(比如操作页表、响应中断的汇编代码)。Linux 的做法是在 archinclude 目录下为每种硬件平台建一个子目录(2.6 时代有 23 个),把硬件相关代码隔离起来。常见的有:i386(80x86 PC)、x86_64(AMD64/EM64T)、ia64(安腾)、ppc(PowerPC)、s390(IBM 大型机)、sparc/sparc64、arm(嵌入式)、um(User Mode Linux,一种把内核跑在用户态的虚拟平台)等。

这种”硬件相关/硬件无关”的清晰划分,是我们后面读源码时判断”这段代码是不是每个平台都一样”的依据。本书以 80x86 平台为主线讲解。

版本号规则:2.5 之前用”三段式”——前两段标识版本,第三段是发布号,第二段为偶数表示稳定版(只修 bug、加驱动),奇数表示开发版(允许大改)。从 2.6 开始规则变了:第二个数字不再区分稳定/开发,大的改动直接进 2.6.x。这意味着 2.6.10 和 2.6.11 之间可能连核心算法都不一样,新发布的版本可能不稳定,所以又引入了第四位数字表示补丁版本(如 2.6.11.12)。

常见坑:拿错内核版本对不上书

本书讲的是 Linux 2.6.11。内核在同一个 2.6 系列内也可能有结构性改动,如果你在读更新版本的源码(比如现在的 5.x/6.x),发现某些函数或数据结构不见了,不要怀疑自己理解错了——去对照 2.6.11 的源码,或者查版本间的改动记录。

操作系统基本概念

一台计算机上有一组基础程序,其中最重要的是内核(kernel):开机时被装入 RAM,包含系统运转所需的关键过程。操作系统(实践中常直接当作内核的同义词)有两大目标:

  1. 与硬件交互,服务硬件平台上所有底层可编程部件;
  2. 为用户程序提供执行环境

MS-DOS 这类系统允许用户程序直接玩硬件;而类 Unix 系统把硬件的全部低层细节都藏起来——程序想用硬件资源,必须向内核发请求,由内核评估后代为执行。要做到这一点,硬件必须提供支持:CPU 至少要有两种执行状态——非特权模式跑用户程序、特权模式跑内核,Unix 分别称之为 用户态(User Mode)内核态(Kernel Mode)。没有这个硬件机制,任何用户程序都能绕过内核直接摸硬件,一切保护都是空谈。

多用户系统

多用户系统 = 能并发(concurrently)且独立(independently)地执行属于两个以上用户的应用。并发指多个应用同时活动、竞争 CPU/内存/磁盘等资源;独立指每个应用做事时不用管其他应用在干嘛。为此系统必须具备:

  • 认证机制(验证用户身份);
  • 防止有 bug 的用户程序拖垮其他应用的机制;
  • 防止恶意程序干扰、窥探其他用户的机制;
  • 记账机制(限制每个用户的资源配额)。

这些保护都依赖 CPU 特权模式的硬件保护,否则用户程序可以直接访问系统电路绕过所有限制。

用户与组

每个用户有唯一编号 UID,登录时凭用户名 + 密码认证。为了有选择地共享资料,用户可属于一个或多个,组有 GID;每个文件关联且只关联一个组。系统里有个特殊用户 root(超级用户):内核对它不施加常规保护机制,它几乎无所不能,管理员用 root 账户做系统维护。

进程

进程(process) 是操作系统最基础的抽象:一个”程序在执行中的实例”,或者说”运行中程序的执行上下文”。传统系统里一个进程在单一地址空间中执行一条指令序列;现代系统允许一个进程拥有多条执行流(多线程)。必须分清程序和进程:多个进程可以并发执行同一个程序,同一个进程也可以先后执行多个程序。

CPU 数量永远少于进程数量,所以由调度器(scheduler) 决定哪个进程能推进。多用户系统的进程必须是可抢占的:内核记录每个进程占用 CPU 的时长,周期性地激活调度器强制换人,而不是等进程自愿让出。

Unix 是”可抢占的多处理系统”,即使没人登录,也有大量系统进程在监视外设、监听终端等用户登录。类 Unix 系统采用进程/内核模型:每个进程都幻觉般地认为自己是机器上唯一的进程,独占地使用操作系统服务——这靠下面要讲的两种模式切换实现。

内核架构:单体、微内核与模块

大多数 Unix 内核是单体内核:每个内核层都集成进整个内核程序,在内核态代表当前进程运行。与之相对的微内核只让内核提供极少量功能(几个同步原语、一个简单调度器、进程间通信机制),其余系统层功能(内存分配器、设备驱动、系统调用处理)由跑在微内核之上的系统进程实现。

学术研究偏爱微内核,因为它有几条理论优点:强迫模块化、各层接口干净、易于移植(硬件相关代码都封在微内核里)、内存利用率可能更好。但它有个致命的实际问题——,因为层与层之间显式的消息传递有开销。

Linux 用模块(module)拿到了微内核的大部分好处,却不付性能代价。模块是一个目标文件,其代码可以在运行时链入内核或从内核解除链接,通常实现一个文件系统、一个设备驱动等上层功能。模块和微内核的外部层不同:它不作为独立进程运行,而是像静态链入的内核函数一样,在内核态代表当前进程执行。模块的好处:

  • 模块化:运行时插拔迫使程序员定义清晰的软件接口;
  • 平台无关:比如遵循 SCSI 标准的磁盘驱动模块在 PC 和 Alpha 上都能用;
  • 省内存:需要时链入、不需要时卸下,对小型嵌入式系统尤其有用;
  • 零性能损失:链入后的模块代码与静态内核代码等价,调用时不需消息传递。

Unix 文件系统概览

Unix 的设计以文件系统为中心,几个关键概念后面章节会反复用到。

文件、目录树与路径名

Unix 文件是字节序列构成的信息容器,内核不解释文件内容(记录、字段这些高层抽象由库实现,库最终还得靠系统调用)。文件组织成一棵树形命名空间:

图 2:目录树示例(原书图 1-1)

树上除叶子外的节点都是目录;根目录名为 /。文件名可以是除 / 和空字符 \0 之外的任意 ASCII 字符,多数文件系统限制长度不超过 255 字符。每个进程都有自己的当前工作目录;标识文件用路径名:以 / 开头的是绝对路径(从根开始),否则是相对路径(从当前目录开始)。. 表示当前目录,.. 表示父目录(当前目录就是根时两者重合)。

硬链接与软链接

目录中的一个文件名就是文件的一个硬链接(hard link)——同一个文件可以有多个链接,即多个文件名。ln p1 p2 为 p1 对应的文件创建一个新链接 p2。硬链接有两条限制:

  1. 不能给目录建硬链接——否则目录树可能变成带环的图,就无法按名字定位文件了;
  2. 只能在同一个文件系统内建链接——现代系统往往有多个磁盘/分区上的文件系统,用户未必感知物理划分。

软链接(符号链接)就是为了绕过这两条限制:它是一个短文件,内容是另一个文件的任意路径名,可以指向任何文件系统里的任何文件,甚至可以指向不存在的文件。ln -s p1 p2 创建指向 p1 的软链接 p2,之后对 p2 的引用会自动翻译成对 p1 的引用。

文件类型与 inode

Unix 文件有 7 种类型:普通文件、目录、符号链接、面向块的设备文件、面向字符的设备文件、管道与命名管道(FIFO)、套接字(socket)。前三种是文件系统的基本构件;设备文件与 I/O 设备及驱动关联,访问设备文件就等于直接作用于对应 I/O 设备;管道和套接字用于进程间通信。

Unix 明确区分文件内容关于文件的信息:文件本身只是字节序列,不含长度、EOF 之类的控制信息;文件系统处理文件所需的全部信息放在 inode 中,每个文件有自己的 inode。POSIX 规定 inode 至少包含:

  • 文件类型;
  • 硬链接数;
  • 文件长度(字节);
  • 设备 ID(文件所在设备的标识);
  • inode 编号(文件系统内标识文件);
  • 文件所有者的 UID、GID;
  • 几个时间戳(inode 状态改变时间、最后访问时间、最后修改时间);
  • 访问权限与文件模式。

访问权限与文件模式

文件的潜在用户分三类:所有者、同组用户(不含所有者)、其余用户。每类各有读/写/执行三种权限,共 9 个二进制标志。另有三个标志定义文件模式(对可执行文件而言):

  • suid:执行该文件的进程通常保留进程属主的 UID;若设置了 suid,进程改用文件属主的 UID(典型用途:让普通用户能执行需要特权的程序,如 passwd);
  • sgid:同理,进程改用文件的 GID;
  • sticky:请求内核在程序执行结束后仍把它留在内存里。

进程创建文件时,文件属主是创建者的 UID;GID 取创建进程的组 ID 或父目录的 GID,取决于父目录 sgid 标志的值。

文件处理系统调用

用户访问文件实际访问的是块设备上的数据,而用户态进程不能直接碰硬件,所以每次真正的文件操作都必须在内核态完成。几个核心系统调用的用法如下:

fd = open(path, flag, mode)

打开文件返回文件描述符(file descriptor),内核同时创建一个”打开文件对象”,内含:文件操作相关的数据结构(打开方式标志集、表示下次操作位置的文件指针 offset 等),以及进程可调用的内核函数指针集(可用哪些函数由 flag 决定)。要点:

  • 描述符代表”进程与打开文件的一次交互”,同一打开文件对象可被同一进程的多个描述符标识;
  • 多个进程可以并发打开同一个文件,此时每个进程拿到各自的描述符和各自的打开文件对象,文件系统不提供任何 I/O 操作同步——需要同步得靠 flock() 之类的系统调用。
newoffset = lseek(fd, offset, whence);   /* 显式移动文件指针 */
nread    = read(fd, buf, count);         /* 从当前指针位置读 count 字节到 buf */
res      = close(fd);                    /* 释放打开文件对象 */
res      = rename(oldpath, newpath);     /* 改名:作用于目录内容,不需要先打开文件 */
res      = unlink(pathname);             /* 链接数减 1,移除目录项 */

read() 返回实际读到的字节数 nread(遇到文件尾、空管道等可能读不满 count),文件指针随之前移 nread。rename 和 unlink 不作用于文件内容而作用于目录;只有当链接数降为 0 时文件才真正被删除

进程/内核模型与可重入内核

CPU 可以有不止两种执行状态(80x86 有 4 种),但所有标准 Unix 内核只使用内核态和用户态两种。程序通常在用户态运行,只在请求内核服务时才切换到内核态;内核满足请求后把程序送回用户态继续执行。切换靠每种 CPU 都提供的特殊指令完成。

内核本身不是进程,而是进程管理者。 需要内核服务的进程通过系统调用(system call)提请求:先准备好标识请求的参数,再执行硬件相关的 CPU 指令切入内核态,内核在该进程的执行上下文内完成任务后切回用户态,进程从系统调用之后那条指令继续跑。

除了用户进程,还有少数内核线程(kernel threads):运行在内核态、内核地址空间中,不与用户交互(不需要终端),通常开机时创建、关机时才结束。

内核态下执行的程序不一定都”属于”某个进程,内核例程可以被这样激活:

  • 进程发起系统调用;
  • CPU 检测到异常(exception,如非法指令、缺页)——内核代表引发异常的进程处理它;
  • 外设向 CPU 发出中断信号(I/O 完成等)——由中断处理程序处理,因为外设与 CPU 异步,中断出现的时间不可预测;
  • 内核线程被执行。

进程描述符

内核要管理进程,就得为每个进程保存一份进程描述符,记录进程的当前状态。内核暂停一个进程时,把若干处理器寄存器的内容存进描述符:程序计数器(PC)、栈指针(SP)、通用寄存器、浮点寄存器、含 CPU 状态信息的控制寄存器(处理器状态字)、跟踪进程所用 RAM 的内存管理寄存器。恢复执行时反过来用这些字段装载寄存器——因为存下的程序计数器指向最后一条指令的下一条,进程就恰好从停下的地方接着跑。

不在 CPU 上的进程都在等待某个事件。Unix 内核区分很多种等待状态,通常用进程描述符队列实现:每个(可能为空的)队列对应”等待某个特定事件的所有进程”。

可重入内核与内核控制路径

所有 Unix 内核都是可重入的(reentrant):多个进程可以同时在内核态执行。单处理器上当然每次只有一个进程在推进,但很多进程可以阻塞在内核态等 CPU 或等 I/O 完成。例如内核替进程 A 向磁盘发读请求后,让磁盘控制器去干活,自己继续跑别的进程;磁盘中断到来时再回头恢复 A。可重入内核即使当前进程处于内核态,也能被硬件中断挂起——这很重要,设备发出中断后在等 CPU 应答,内核应答得越快,设备控制器越能腾出手干别的活,吞吐量就高。

实现可重入的手段:要么把函数写成只改局部变量、不动全局数据(可重入函数),要么允许存在不可重入函数但用锁保证同一时刻只有一个进程执行它。

内核控制路径(kernel control path) = 内核为处理一次系统调用、异常或中断而执行的指令序列。最简单情况下一条控制路径从头顺跑到尾,但以下事件会让多条路径交错执行:

  • 系统调用路径发现请求无法立即满足,调用调度器选新进程 → 发生进程切换,两条路径分属两个不同进程
  • 内核控制路径执行中 CPU 检测到异常(如访问的页不在 RAM)→ 当前路径挂起,先跑异常处理(分配页、从盘读入),结束后恢复原路径,两条路径属于同一个进程
  • 中断开启时来了硬件中断 → 当前路径被打断,先处理中断,处理完恢复,两条路径在同一进程的执行上下文里跑,CPU 时间都记到它头上,但中断处理程序未必是在为这个进程服务;
  • 开启了内核抢占且出现更高优先级的可运行进程 → 当前路径被挂起,CPU 转去为高优先级进程执行控制路径。

图 3:用户态与内核态之间的转换(原书图 1-2)——进程 1 用户态发系统调用进内核态,服务完回用户态;时钟中断触发调度器完成进程切换;进程 2 用户态被硬件中断打断进内核态

图 4:内核控制路径的交错(原书图 1-3)——三种 CPU 状态:用户态跑进程(User)、跑异常/系统调用处理程序(Excp)、跑中断处理程序(Intr)

进程地址空间

每个进程运行在自己的私有地址空间:用户态访问私有的栈、数据、代码区;内核态访问内核数据与代码区,并使用另一个私有栈(内核栈)。因为内核可重入,多条控制路径各用各的私有内核栈。

地址空间看起来私有,但有些部分可以共享:多个用户同时用同一个编辑器时,程序代码只装一次、代码页大家共享(数据当然各是各的)——这是内核为省内存自动做的;进程也可以显式请求共享地址空间做进程间通信(System V 的”共享内存”);另外 Linux 支持 mmap() 系统调用,把文件或块设备的一部分映射进进程地址空间,作为常规读写之外的传输手段。

同步与临界区

可重入是有代价的:如果一条内核控制路径正在改某个内核数据结构时被挂起,其他控制路径在数据恢复一致之前绝不能碰它,否则信息会被破坏。书里给了一个经典例子:全局变量 V 记录某资源剩余数量(值为 1)。控制路径 A 读到 1,还没来得及减,B 被激活也读到 1,于是 B 把 V 减成 0 并占用资源;A 恢复执行后基于自己读到的旧值 1 也把 V 减一次并去用资源——结果 V = -1,两个路径共用同一份资源,后果可能是灾难性的。

当计算结果取决于两个以上进程的调度方式时,代码就是错的,这叫竞态条件(race condition)。 解决思路分几个层次:

  1. 原子操作:读和减用一条不可中断的指令完成,竞态自然消失。但很多操作做不到原子——比如从链表里摘一个元素至少要同时动两个指针。
  2. 临界区(critical region):任何一段”一个进程进入后,必须等它走完别的进程才能进”的代码。保护临界区有一套手段:
技术原理适用场景
禁止内核抢占内核态进程不可被任意挂起替换单处理器上,中断/异常处理程序不会更新的数据结构就安全了;多处理器不够用
禁止中断进临界区前关中断,出后开单处理器;临界区太大时会让硬件活动长时间冻结,且多处理器上只关本地 CPU 不够
信号量(semaphore)计数器 + 等待进程队列 + 两个原子方法 down()/up();down 后值小于 0 就把进程挂入队列并阻塞,up 后值 ≥ 0 就唤醒队列中的进程单/多处理器都适用;保护”会被长时间占用”的数据结构
自旋锁(spin lock)类似信号量但没有进程队列,拿不到锁就原地紧凑循环空转直到锁开多处理器上保护”很快就能更新完”的数据结构——挂起/唤醒进程的开销比等锁本身还大时不划算

信号量初始化为 1 就成了互斥访问的”门禁”。自旋锁在单处理器上是没用的:拿着锁的路径没机会继续跑并释放锁,试图加锁的路径空转到天荒地老,系统直接挂死。

常见坑:死锁

最简单的死锁:p1 拿着 a 等 b,p2 拿着 b 等 a,双方永远等下去。内核里锁的数量一多,想穷举所有交错路径证明不会死锁几乎不可能。Linux 的对策是按预定顺序申请锁——所有代码都遵守同一个加锁次序,环就成不了。

信号与进程间通信

信号(signal) 是 Unix 通知进程发生了系统事件的机制,每个事件对应一个信号编号(常用符号常量如 SIGTERM)。两类事件:

  • 异步通知:如用户按 Ctrl-C,终端向前台进程发 SIGINT;
  • 同步通知:如进程访问非法地址,内核给它发 SIGSEGV。

POSIX 定义了约 20 种信号,其中 2 种用户可自定义。进程对信号可以:忽略,或异步执行一个指定函数(信号处理程序);两者都没指定就执行默认动作。默认动作有 5 种:终止进程;core dump 后终止;忽略;挂起进程;若进程已停止则恢复执行。SIGKILL 和 SIGSTOP 不能被捕获也不能被忽略——这是给管理员留的”最后一手”。

System V 还引入了三种用户态进程间通信(合称 System V IPC):信号量(与上一节内核同步用的信号量类似,但供用户态进程使用)、消息队列(msgsnd()/msgrcv() 收发消息)、共享内存(最快的数据交换方式:shmget() 创建、shmat() 挂入地址空间、shmdt() 摘除)。IPC 资源和文件一样是持久的:必须由创建者、当前所有者或超级用户显式释放。POSIX 还定义了基于消息队列的 IPC,接口比 System V 的简单,基于文件。

进程管理

Unix 严格区分进程和它执行的程序。三个基本系统调用:

  • fork():创建新进程。调用者是父进程,新进程是子进程;进程描述符里有指向父进程和所有子进程的指针,所以一家子互相找得到。天真实现要把父进程的数据和代码整个复制一份给子进程,太慢——现代内核靠硬件分页单元用写时复制(Copy On Write):父子先共享同一批只读页,谁真的要写页了才在异常处理里复制那页。
  • exec() 类:加载新程序,进程拿着一个装了新程序的全新地址空间继续执行。
  • _exit():终止进程,内核释放其资源并向父进程发 SIGCHLD 信号(默认被忽略)。

父进程怎么知道子进程终止了?用 wait4():等某个子进程结束并取回其 PID 和资源使用数据。这里引出一个特殊进程状态——僵尸(zombie):已终止但父进程还没对它执行 wait4() 的进程会停留在僵尸态,描述符里只留下最少的记账信息。如果父进程不等子进程就先死了呢?比如 shell 允许后台启动命令后退出。解决办法是系统初始化时创建的特殊进程 init:任何进程终止时,内核把它的孤儿孩子统统过继给 init,init 例行公事地不断 wait4(),把孤儿僵尸全部收尸。

进程组与登录会话:为表达”作业”抽象,bash 执行 ls | sort | more 时会给这三个进程建一个进程组,shell 把它们当一个整体来操作。每个进程描述符里有进程组 ID;组长的 PID 恰好等于组 ID;新进程默认进父进程的组。登录会话包含在特定终端上开启会话的那个进程(通常是第一个 shell)的所有后代;会话内多个进程组同时活跃时只有一个在前台(有权访问终端),后台进程组试图访问终端会收到 SIGTTIN/SIGTTOU 信号,bg/fg 内部命令可以在前后台之间搬进程组。

信号、进程管理与内存管理

虚拟内存

虚拟内存是应用内存请求与硬件内存管理单元(MMU)之间的逻辑层,好处一条条列出来:

  • 多个进程可以并发执行;
  • 能跑内存需求超过物理内存的应用;
  • 进程可以执行只部分装入内存的程序;
  • 每个进程只能访问物理内存的一个子集(隔离);
  • 一个库或程序的一份内存映像可被多进程共享;
  • 程序可重定位(放在物理内存任何位置都能跑);
  • 程序员可以写与机器无关的代码,不用关心物理内存怎么组织。

核心概念是虚拟地址空间:进程可使用的内存引用集合与物理地址是两回事。CPU 里的硬件电路自动把虚拟地址翻译成物理地址:RAM 被切成页框(page frame,典型 4KB 或 8KB),配一套页表描述虚拟↔物理的对应关系。这套机制让分配变简单:申请一段连续虚拟地址,可以用物理上不连续的一组页框来满足。

RAM 的使用与碎片

所有 Unix 把 RAM 明确分成两块:几 MB 固定存放内核映像(内核代码 + 静态数据结构);其余交给虚拟内存系统,有三种用途——满足内核对缓冲区、描述符等动态数据结构的请求;满足进程对内存区和文件内存映射的请求;用缓存(cache)提升磁盘等缓冲设备的性能。RAM 有限,三类请求必须平衡;当可用内存跌到临界阈值,会调用页框回收算法腾内存——哪些页框最适合回收?没有简单的理论答案,只能靠精心调校的经验算法(第 17 章细讲)。

另一个大问题是内存碎片:理想情况是只要空闲页框够多、请求就该成功;但内核经常需要物理连续的内存区,于是明明还有足够内存、却因为没有一整块连续空间而申请失败。

内核内存分配器(KMA)

KMA 满足系统各处的内存区请求(内核子系统内部用 + 用户程序经系统调用扩地址空间)。一个好的 KMA 必须:(所有内核子系统包括中断处理程序都会调它,这是最关键的属性)、浪费内存少、尽量减少碎片、能与其他内存管理子系统借还页框。历史上提出的算法有资源映射分配器、2 的幂次空闲列表、McKusick-Karels 分配器、伙伴系统(buddy system)、Mach 的区分配器、Dynix 分配器、Solaris 的 Slab 分配器——Linux 采用伙伴系统之上叠加 Slab 分配器(第 8 章细讲)。

进程地址空间处理与按需分页

内核把进程的虚拟地址空间存成一组内存区(memory area)描述符的链表。进程经 exec() 类系统调用启动新程序时,内核分给它的地址空间包括这些内存区:程序可执行代码、已初始化数据、未初始化数据、初始程序栈(用户态栈)、所需共享库的代码与数据、堆(程序动态申请的内存)。所有现代 Unix 都采用按需分页(demand paging):进程启动时一页都不在内存里,访问到不存在的页时 MMU 产生异常,异常处理程序找到对应内存区、分配空闲页框、填入数据;malloc()/brk() 只更新堆区大小,真正的页框等进程真去访问那些虚拟地址触发异常时才分配。写时复制同理:新建进程时父子共享只读页,谁写谁触发异常、谁得新页。

缓存

物理内存的相当一部分用作磁盘等块设备的缓存——磁盘访问要几毫秒,与 RAM 访问时间相比是天文数字,磁盘常是系统性能瓶颈。最早的 Unix 就有一条策略:尽量推迟写盘。进程从盘上读过的数据即使已经没有进程在用,也继续留在 RAM 里,赌新进程会再次需要它(缓存命中就能不碰磁盘)。sync() 系统调用强制把所有”脏”缓冲区(内容与对应磁盘块不一致的)写回磁盘;为防数据丢失,所有系统都会周期性回写脏缓冲区。

设备驱动

内核通过**设备驱动(device driver)**与 I/O 设备打交道。驱动包含控制一个或多个设备(硬盘、键盘、鼠标、显示器、网卡、SCSI 总线上的设备……)的数据结构和函数,通过定义良好的接口与内核其余部分(甚至其他驱动)交互。好处:设备相关代码封装在特定模块里;厂商不需要内核源码、只要接口规范就能加新设备;内核以统一方式对待所有设备;驱动可以做成模块动态加载,不用重启系统。

图 5:设备驱动接口(原书图 1-4)——用户程序 P 通过常规文件类系统调用和 /dev 目录下的设备文件向内核发请求,设备文件是驱动接口的用户可见部分,每个设备文件对应一个驱动

早期 Unix 只直接管理字符终端;图形终端普及后,X Window System 这类应用作为标准进程直接访问显卡的 I/O 端口和 RAM 视频区。Linux 2.6 这类新内核为显卡帧缓冲提供了抽象,应用不用了解任何图形接口 I/O 细节也能访问。

通关标准:合上书能不看稿回答——为什么 Unix 必须有用户态/内核态两个执行模式?内核控制路径有哪四种交错情形?硬链接和软链接的限制各是什么?信号量、自旋锁各自适合什么场景、为什么?fork 为什么要用写时复制?答不全就回到对应小节再读一遍。