操作系统设计
操作系统与内核的关系
操作系统(Operating System)是一个更宽泛的概念,通常包含两类内容:
- 内核(kernel):运行在硬件之上、拥有最高特权的一段软件,负责管理 CPU、内存、磁盘、外设等资源,并向上提供受控的服务入口(系统调用接口)。
- 用户态程序与系统软件:例如 shell、工具程序、库、守护进程等,它们运行在受限的用户态,依赖内核提供的机制完成工作。
因此,“OS”在口语中常被用来指代“内核”,但严格区分时,内核是操作系统的核心部分,负责最关键的资源管理与隔离。
从整体视角理解系统分层
可以把计算机系统理解为由下到上的层次结构:
- 硬件层:CPU、RAM、磁盘、外设与总线等。
- 内核层:提供抽象、调度、内存管理、文件系统、驱动等功能。
- 系统调用接口层:用户程序进入内核的唯一受控入口(如
open、read、fork等)。 - 应用层:shell、echo 等普通程序。
系统调用接口是关键边界:它既是功能入口,也是安全与隔离边界。
操作系统设计目标中的核心:隔离
“隔离”(isolation)是内核存在的根本动机之一。隔离的目标是:让不同程序在同一台机器上同时运行时,互不破坏、互不越权、互不干扰到不可接受的程度。
隔离主要包含两类问题。
资源隔离
资源隔离关注“量”的问题,即某个程序不应无限制占用公共资源,例如:
- 某个程序占用过多内存导致其他程序无法运行。
- 某个程序长期占用 CPU,使其他程序得不到执行机会。
- 某个程序写满磁盘导致系统无法正常工作。
这类问题的关键是:硬件资源是共享的,必须有一个仲裁者强制执行公平与上限。
内存隔离
内存隔离关注“边界”的问题,即某个程序不应读写另一个程序的内存,也不应读写内核的内存。原因包括:
- 程序缺陷(bug)可能把错误数据写到不属于自己的地址。
- 恶意程序可能故意扫描、篡改其他程序数据,窃取机密或破坏运行。
隔离必须假设最坏情况:用户程序并非“无心犯错”,而可能“主动攻击”。
系统调用接口为何能够帮助隔离
Unix 风格的系统调用接口不是随意堆砌的函数集合,而是一组经过设计的“受控能力集合”。它通过抽象硬件资源来限制用户程序能做什么、以什么方式做,从而把隔离变成可执行的规则。
进程与 fork 抽象 CPU 核心
- 物理层面:CPU 只有有限的核心数量。
- 抽象层面:内核向上提供“进程”,让系统看起来可以同时运行许多程序。
内核通过调度(scheduler)在多个进程间切换,使得:
- 核心在多个进程间透明复用。
- 进程无法自行“霸占”核心(需要内核强制抢占或轮转)。
- 进程数量可以多于核心数量。
这里的第一性原理是:CPU 时间必须由一个更高权限的实体分配与回收,否则资源隔离无法成立。
地址空间与 exec/sbrk 抽象 RAM
- 物理层面:RAM 是一块共享的物理存储。
- 抽象层面:每个进程拥有“自己的内存”,称为地址空间(address space),通过虚拟地址(VA)访问。
内核决定:
- 进程的虚拟地址映射到哪些物理地址(PA)。
- 进程是否允许访问某段地址(权限控制)。
- 进程增长内存(如
sbrk)时是否允许分配更多页。
第一性原理是:若程序可以随意读写任意物理内存,则隔离不可能。因此必须引入“虚拟化的地址视图”和硬件强制的访问检查。
文件抽象磁盘块
- 物理层面:磁盘是块设备,读写的是扇区/块。
- 抽象层面:文件系统提供文件与目录。
内核确保:
- 不同文件的块分配不互相冲突。
- 权限(读/写/执行)与访问控制得到执行。
- 多进程并发访问时有一致性与互斥机制。
第一性原理是:共享存储必须由一个仲裁层维护命名、权限与一致性,否则任何进程都可能破坏其他进程的数据结构。
管道抽象受控共享
隔离并不意味着完全不共享。系统调用接口提供“受控共享”通道,例如 pipe:
- 允许进程间传递数据。
- 共享以“内核对象”为媒介,受内核规则约束。
- 避免任意共享内存导致的越界与破坏。
隔离与安全模型:内核必须假设用户态是恶意的
一个实用且传统的操作系统安全假设是:
- 用户态代码不可信:可能恶意构造输入、调用序列与竞态条件,试图突破边界。
- 内核代码被视为可信:属于可信计算基(Trusted Computing Base, TCB)。内核一旦被绕过或被利用,系统整体安全性即崩溃。
因此,系统调用不仅是功能入口,更是攻击入口。设计与实现必须具备“安全思维”:
- 每一个来自用户态的参数都必须被校验。
- 任何指针、长度、权限、边界条件都必须防御性处理。
- 内核 bug 可能直接变成权限提升或信息泄露漏洞。
内核如何抵御用户态:硬件机制与接口设计缺一不可
防御体系有两大支柱:
- 硬件层面的隔离机制:硬件强制执行边界。
- 系统调用接口与实现的正确性:软件层面控制能力与验证输入。
用户态与监督态的特权分离
现代 CPU 提供不同的执行模式,用于限制指令能力。
-
监督态(supervisor mode,也常称 kernel mode):允许执行特权指令,例如:
- 访问设备寄存器与 I/O。
- 修改页表与地址转换相关寄存器。
- 配置中断与特权控制寄存器。
-
用户态(user mode):禁止执行特权指令。若试图执行,将触发异常,进入内核处理流程。
在 RISC-V 中通常讨论 U 模式与 S 模式(还存在 M 模式,但在该教学路径中通常不作为常规内核执行模式重点)。
第一性原理是:若用户态能执行与内核相同的特权指令,则不存在“内核”意义上的边界;隔离只能由硬件强制,否则只是约定。
虚拟内存与页表的硬件强制访问控制
CPU 的内存管理单元(MMU)通过页表实现虚拟地址到物理地址的映射:
- 页表定义:哪些虚拟页映射到哪些物理页。
- 页表项包含权限:可读、可写、可执行、是否允许用户态访问等。
内核通过配置页表实现隔离:
- 每个进程有自己的页表,从而拥有自己的地址空间。
- 用户态页表不映射(或禁止访问)内核地址区域。
- 页表只能在监督态被修改,因此用户态无法自行扩大权限或映射内核内存。
第一性原理是:隔离必须由不可绕过的访问检查实现。MMU 在每次内存访问时执行检查,使隔离成为“硬件事实”,而非“软件约束”。
系统调用的本质:受控的模式切换与受控的入口
用户态需要访问特权资源(磁盘、控制台、创建进程等),但又不允许直接执行特权指令。解决方案是:提供一种受控的“陷入”(trap)机制。
在 RISC-V 上,用户态发起系统调用通常使用 ecall 指令,其效果可以概括为:
- 触发一个异常/陷入事件。
- CPU 切换到监督态。
- 跳转到内核预先设置好的入口地址(陷入向量)。
- 内核根据系统调用号与参数执行对应服务。
- 执行结束后通过受控的返回指令回到用户态并返回结果。
关键点在于:
- 用户态不能指定任意内核函数地址,只能通过固定入口进入。
- 内核掌握控制权,可以验证参数与权限。
- 模式切换由硬件完成,用户态不能伪造“已经在监督态”。
在没有硬件特权与虚拟内存时是否能隔离
理论上可以通过强类型语言与受控运行时实现隔离(例如研究系统 Singularity 采用语言与类型系统作为隔离基础)。其核心思想是:
- 通过语言规则保证内存安全与边界安全。
- 禁止不受控的指针算术与越界访问。
但代价是:
- 用户程序必须使用被批准的语言/编译器/运行时体系。
- 难以兼容传统生态与任意二进制代码。
因此,硬件提供的特权模式与虚拟内存仍是最普遍、最通用的隔离方案。
内核结构设计:宏内核与微内核
宏内核(monolithic kernel)
宏内核将大多数操作系统功能放在内核态实现:文件系统、进程管理、内存管理、驱动等都在一个地址空间内运行。xv6、Linux 均属于此类。
优点:
- 子系统之间协作成本低,调用开销小。
- 更容易共享缓存与数据结构(例如文件系统缓存与页缓存的协同)。
缺点:
- 内核内部缺乏隔离:驱动或某个子系统的 bug 可能破坏整个内核。
- 交互复杂,错误更难局部化,安全面更大。
微内核(microkernel)
微内核将尽可能多的功能移到用户态进程(服务器)中,内核只保留最小机制(进程、地址空间、IPC 等)。
目标:
- 缩小 TCB,降低内核 bug 的系统性破坏范围。
- 将驱动与服务的故障隔离在用户态,提升鲁棒性。
代价:
- IPC 与跨地址空间交互更频繁,性能与复杂性需要精心工程化权衡。
xv6 作为研究对象的意义与运行环境
xv6 是为教学设计的类 Unix 内核,规模小、结构清晰,但包含关键概念:系统调用、进程、虚拟内存、文件系统、调度、中断等。
运行平台与板级环境
xv6 运行在 RISC-V 架构上,并假设一种简单的板级设备组合(与 SiFive HiFive Unleashed 类似的环境):
- 多核 RISC-V 处理器(典型为四核)
- 内存(例如 128MB)
- UART 串口控制台
- 类磁盘存储设备
- 以太网等
教学与实验通常使用 QEMU 仿真“virt”机器:
- QEMU 在软件中模拟 CPU 与外设。
- 加载内核二进制到模拟内存。
- 提供一个模拟磁盘镜像作为文件系统载体。
仿真的第一性原理:以软件解释执行硬件语义
仿真的本质是:用一个普通程序维护“硬件状态”,并循环执行:
- 读取下一条指令
- 解码指令语义(如
ld、add等) - 更新寄存器、内存与设备状态
从抽象角度看,仿真器相当于把“硬件状态机”用软件实现,从而无需真实硬件也能运行同样的内核代码。
xv6 的源码结构与构建产物
典型目录划分:
-
kernel/:内核源代码(例如文件系统、内存管理、系统调用分发等)。 -
user/:用户态程序(shell、init、echo 等),它们通过系统调用请求内核服务。 -
构建过程生成:
- 内核可执行镜像(如
kernel/kernel) - 反汇编输出(如
kernel/kernel.asm) - 文件系统镜像(如
fs.img)
- 内核可执行镜像(如
构建链路(概念层面):
- 编译:各
.c编译为.o - 链接:链接为内核镜像
- 打包:生成带文件系统内容的磁盘镜像供 QEMU 使用
xv6 启动流程与第一个用户进程
在 QEMU 启动后,控制权进入内核入口,随后内核完成初始化并创建首个用户进程:
main():初始化内核子系统与硬件相关设施。userinit():创建一个初始进程,并设置其从特定入口开始执行。- 初始进程执行
exec("/init"),加载用户态的init程序。 init设置控制台文件描述符(标准输入输出通常为 0 与 1),并启动 shell。
这里体现了一个关键设计:用户态世界并非与内核同时“天然存在”,而是由内核创建、装载、并授予初始资源(如控制台)的结果。
xv6 系统调用在用户态的形态:编号而非直接调用
在 xv6 中,用户程序调用 open()、exec() 等,并不是直接跳转到某个内核函数地址,而是走一条受控路径:
- 用户态封装代码(如
user/usys.S)将系统调用号放入约定寄存器,并执行ecall。 - 内核陷入入口捕获
ecall,读取系统调用号。 - 内核在
kernel/syscall.c等位置进行分发,将编号映射到具体的sys_*函数。 sys_*函数从用户态地址空间安全地取参数,并调用更深层的内核实现。
这一机制的关键意义是:
- 系统调用接口是“能力列表”:用户态只能请求被允许的那组编号对应的服务。
- 内核拥有最终解释权:编号与参数只是请求,内核可以拒绝、限制或返回错误。
以 exec 为例理解系统调用为何必须防御性实现
exec 是复杂系统调用的典型:它把当前进程的地址空间替换为一个新程序映像。
高层步骤可概括为:
- 从参数得到可执行文件路径与参数向量(argv)。
- 从文件系统读取该文件内容。
- 解析可执行文件格式(通常为 ELF),得到代码段、数据段、入口地址等信息。
- 为新地址空间分配并映射内存页。
- 把代码与数据装入新地址空间。
- 创建用户栈,把 argv 等参数按约定写入栈。
- 设置寄存器,使得返回到用户态后从新程序入口开始执行。
防御性检查之所以密集,是因为威胁模型明确:用户态可能提供恶意输入,例如:
- 伪造 ELF 头部与段表,使加载器把数据写到非法地址。
- 构造超大长度、溢出边界或整数溢出,诱导内核错误分配或越界。
- 传入指向内核地址或无效地址的指针,试图诱导内核读写不该读写的内存。
因此,复杂系统调用的第一性原则是:
- 任何来自用户态的数据都不应被直接信任。
- 所有涉及地址、长度、权限、格式的字段都必须逐层校验。
- 内核对用户态指针的解引用必须通过“安全拷贝/安全访问”路径完成,而不是直接当作内核指针使用。
关键总结:隔离不是“功能”,而是“约束体系”
从第一性原理视角,隔离之所以需要内核与硬件协作,是因为:
- 资源与地址空间天然共享,必须有更高权限的仲裁者。
- 单靠约定与库无法阻挡恶意代码,必须由硬件强制执行边界。
- 系统调用接口把“可做之事”收敛为有限集合,并提供可验证的输入与可控的共享方式。
- 内核是可信计算基,其正确性与防御性决定系统整体安全与可靠性上限。
操作系统组织与微内核
操作系统与内核的第一性原理
操作系统要解决的根问题可以拆成三类:
- 资源抽象:把硬件资源(CPU、内存、磁盘、网卡、显示设备等)抽象成可被程序稳定使用的概念,降低编程复杂度并提升可移植性。
- 资源复用与管理:在多个程序并发运行时,实现公平、效率与隔离,解决“谁在何时占用多少资源”的协调问题。
- 安全与可靠性边界:防止一个程序破坏另一个程序或破坏系统整体;在故障发生时尽量把影响限制在局部并支持恢复。
“内核”是操作系统中拥有最高硬件特权、能够直接控制关键资源的部分。由于特权极高,内核中的错误往往代价巨大:轻则系统崩溃,重则安全漏洞。因此,“内核到底应当包含哪些功能”属于操作系统设计的核心分歧点。
抽象与系统调用的基本概念
-
抽象:对外暴露的概念与规则。例如“文件”“目录”“进程”“管道”“套接字”等。
-
系统调用:程序从普通权限进入内核特权执行路径的入口,是抽象的具体接口。例如
open、read、fork、exec、pipe等。 -
机制与策略:
- 机制回答“能做什么、如何做”:例如页表映射、上下文切换、消息传递。
- 策略回答“应该怎么选择”:例如页面置换算法、调度优先级策略。 操作系统设计常见目标是:内核尽量提供通用机制,把更上层的策略留给可替换的组件;但这与性能、兼容性、工程复杂度会发生张力。
传统路线:大抽象与单体内核
大抽象的含义与价值
以 UNIX 风格为代表的传统系统倾向于在内核中提供较“完整”的高层抽象,例如:
- 文件系统:文件名、目录层次、权限、链接、缓存等
- 进程:创建、终止、等待、信号等
- 虚拟内存:地址空间、保护、映射、缺页处理等
- 设备与网络:驱动、网络协议栈、套接字等
大抽象的直接效果:
- 对程序员:便利与可移植性 程序无需面对原始磁盘扇区、设备寄存器与中断细节。
- 对系统资源管理:共享与配额更易统一实现 例如磁盘空间分配与文件缓存由内核统一控制,能跨进程协调。
- 对安全:更容易在统一抽象上做访问控制 例如文件权限模型、用户与组、能力边界等。
单体内核的含义与优势
单体内核指:内核内部包含多个子系统(文件系统、内存管理、进程管理、驱动、网络等),这些子系统同处特权态、同一地址空间,通过普通函数调用协作,边界较少。
优势主要体现在:
- 协作成本低:子系统之间调用就是函数调用,数据结构共享直接,易做深度集成
例如
exec需要同时协调进程、地址空间、文件描述符等,单体内核实现自然。 - 性能潜力高:减少跨边界切换与数据复制,缓存命中更友好。
单体内核与大抽象的代价
-
复杂度增大带来缺陷风险:代码量大、路径长、状态多,错误更难彻底消除。
-
内核内部缺少“自我隔离”:几乎所有内核代码同等特权运行,驱动或某子系统的漏洞可能危及全局。
-
抽象可能过度一般化:统一抽象会强迫应用接受某些设计决策 例如:
- 等待进程的语义被固定为“等待子进程”之类,难以满足一些更一般的同步需求。
- 修改别的进程地址空间等能力被严格封装,某些系统软件可能希望更灵活控制。
- 数据库可能希望按 B-Tree 等特定布局组织磁盘数据,内核通用文件系统抽象可能造成额外开销或限制。
性能上的典型直觉例子是:即使只是通过 UNIX 管道传送一个字节,内核可能涉及缓冲、锁、睡眠与唤醒、调度等一系列路径,导致“看起来简单的操作”实际执行大量通用机制。
微内核路线:最小内核与用户态服务
核心目标与基本思想
微内核的主张是:把内核缩到尽可能小,只保留必须在特权态完成的少数机制,把绝大多数传统内核功能移动到用户态“服务进程”中实现。
常见的微内核内核功能集合:
- 地址空间与页表映射等最底层内存机制
- 线程与上下文切换
- 进程间通信(IPC)
- 受控的硬件访问与中断递送
而传统内核中的大量功能被移出内核:
- 文件系统
- 进程创建接口如
fork、程序装载如exec - 管道、套接字等高级 IPC 抽象
- 设备驱动与网络协议栈
- 许多安全策略与资源管理策略
这些移出的功能在微内核体系中以两种形式出现:
- 库:链接到应用中,直接用微内核提供的最小接口构建抽象
- 服务器进程:独立的用户态系统服务,通过 IPC 为应用提供功能(如文件服务器、网络服务器、驱动服务器)
预期收益
微内核阵营希望得到的好处主要包括:
- 更小带来更少缺陷与更高安全性:内核攻击面缩小,关键代码更可审计。
- 可形式化验证:内核小到一定程度,能进行严格验证(典型例子是 seL4 的路线)。
- 更强的模块化与可替换性:服务在用户态,边界清晰,更新、替换、定制更容易。
- 更强的鲁棒性:服务崩溃理论上可重启,不必拖垮整个系统;驱动常被认为是缺陷高发区,移出内核能降低系统性风险。
- 减少强制性的设计决策:内核只提供机制,策略与抽象由上层服务决定,允许不同工作负载采用不同抽象。
设计挑战:最小内核是否足够
微内核并不是“把东西移出去”就自动成立,核心难点集中在以下问题。
“最小”到底多小,且是否唯一
“最小有用内核”并无唯一标准:
- 若只追求形式化可验证,可能希望更极简。
- 若追求高性能与良好兼容,可能需要加入更多机制(例如更复杂的 IPC、内存共享支持)。 因此,存在多个“局部最小点”,取决于目标场景。
安全语义与策略位置
传统系统常在内核中直接理解“用户、组、权限、文件对象”等语义并做强制控制。微内核若不理解这些高层概念,安全机制需要重新设计,常见路线包括:
- 内核只提供低层隔离与通信控制,把高层安全模型放到用户态服务中;
- 用能力(capability)或授权令牌等机制表达“谁能对什么做什么”,避免内核必须理解复杂对象语义;
- 但无论采取哪种方式,都必须回答:内核至少要知道什么,才能防止越权的内存映射、设备访问与 IPC。
资源共享与协调
磁盘与网络等资源天生共享。若内核不提供文件/套接字等高级抽象,则共享与协调转移到服务层,需要解决:
- 多个应用如何一致地访问同一存储对象
- 缓存一致性与并发控制如何实现
- 服务崩溃恢复时的状态一致性如何保证
性能与集成度
把功能移到用户态后,原先的“内核内函数调用”可能变成“跨地址空间 IPC”。若 IPC 代价高,系统性能会大幅下降。微内核历史上最著名的争议点就是“IPC 过慢导致整体过慢”。
资源分配质量
内核越“无知”,越难做全局最优资源分配。调度、内存回收、IO 调度等决策需要信息与策略。微内核通常要在“保持内核小”与“做出足够好的资源决策”之间平衡,或者把更多策略上移到用户态调度/管理服务,但这又引入额外协调成本。
工程与生态:为何需要务实路线
面向通用计算(工作站、服务器)时,主要障碍不是“能否实现”,而是:
- 兼容性要求:大量现有应用依赖 UNIX 或 Windows 风格接口。若从零在微内核上重建完整兼容层,工程成本巨大。
- 迁移成本与采用路径:缺少“立即可用”的应用生态,很难说服用户与厂商迁移。
因此出现了务实方案:在微内核之上运行现有的大型内核作为用户态进程,先解决兼容,再逐步替换服务。
L4:以最小化为导向的微内核代表
L4内核提供的基本抽象
L4 系列以“极少系统调用与极小代码规模”为设计导向,其核心抽象通常包括:
- 任务(task)/地址空间:定义一组虚拟地址到物理内存的映射关系与保护边界。
- 页映射(page mappings):显式控制某些页在地址空间之间的映射与共享。
- 线程(thread):执行实体,绑定到某个地址空间中运行。
- 进程间通信(IPC):线程之间的消息传递机制,通常以线程标识作为通信端点。
- 缺页处理的外部化(pager 概念):内核把缺页事件通过 IPC 上报给某个用户态“分页器”,由其决定如何为目标地址空间建立映射。
- 设备访问与中断递送:以受控方式允许访问硬件,并把中断以 IPC 的形式递送给特定线程。
L4内核刻意不提供的内容
与 Linux 这类传统内核相比,L4 内核不直接提供:
- 文件系统与目录/权限语义
fork、exec等传统进程语义- 管道、套接字等高级 IPC 抽象
- 大部分设备驱动与网络协议栈
这些功能若需要,必须由用户态库或服务器进程基于 L4 提供的最小机制搭建。
以 exec 为例理解“把传统内核服务外置”的含义
在传统 UNIX 中,exec 是系统调用:内核读取可执行文件、建立新地址空间映射、设置初始栈与入口点、切换执行。
在 L4 式微内核中,可将这一过程拆为“最小机制 + 用户态服务”:
- 任务创建:创建新地址空间但初始为空(没有映射)
- 线程启动:令新线程从某入口地址开始执行
- 缺页触发:因访问未映射地址产生缺页
- 缺页上报:内核把缺页信息通过 IPC 发送给分页器(可能是父任务或专门的加载器)
- 文件读取:分页器向文件系统服务器请求读取可执行文件数据
- 建立映射:分页器请求内核把相应页面映射进新任务地址空间
- 继续执行:新任务在“按需分页(demand paging)”驱动下逐步获得代码与数据页面
这一思路的关键是:内核负责“缺页事件递送与页映射机制”,而“从哪里拿页面、以何种策略装载页面”由用户态服务决定。
IPC 性能为何是微内核成败的关键
把系统服务移到用户态后,许多原本的内核内调用变成 IPC。例如一次系统调用可能变成:
- 应用线程向“Linux 服务器”或“文件服务器”发请求消息
- 服务处理后回发应答消息
如果一次请求-应答需要多次陷入内核、发生多次上下文切换、并进行多次数据拷贝,则开销会迅速累积并吞噬性能。
早期“慢 IPC”常见形态:异步缓冲队列
一种直观但昂贵的设计是模仿管道:
send:把消息拷贝到内核队列后返回recv:从内核队列拷贝到用户缓冲区;没有消息则睡眠等待
若采用请求-应答(RPC)模式,需要分别发送请求与接收应答,导致:
- 更多系统调用次数(多次用户态/内核态穿越)
- 更多消息拷贝次数(用户到内核、内核到用户)
- 更多缓存扰动与调度干预
L4的“快 IPC”思想:为常见模式优化
L4 的关键经验是:IPC 的主要用途常是 RPC,因此接口与实现应直接面向“请求-应答”常态,减少冗余步骤。典型优化点包括:
-
合并系统调用:提供
call与sendrecv这类接口- 客户端
call:发送请求并等待应答 - 服务端
sendrecv:回复一个请求并等待下一个请求 目标是减少用户态与内核态穿越次数。
- 客户端
-
同步通信:发送方等待接收方就绪 常见情形是服务端已在等待请求,内核可直接完成对接,减少排队与调度开销。
-
去内核缓冲(unbuffered):已知双方缓冲区地址,可直接完成拷贝,无需先拷贝到内核队列再拷贝出去。
-
小消息走寄存器:避免内存访问与拷贝。
-
大消息以页映射传递:通过映射共享页面实现“零拷贝”式的数据传递。
-
内核代码布局优化:缩小关键路径指令与数据的缓存足迹。
这些共同指向一个原则:既然微内核架构把大量功能变成 IPC,那么 IPC 必须被当作“核心硬件路径”来极致优化,否则架构整体无法成立。
在微内核上构建完整系统的常用方法
多服务器重建传统服务
一种方法是为文件系统、网络、驱动等分别建立用户态服务器,通过 IPC 提供接口。这更“纯粹”,但:
- 需要重新设计接口与语义
- 需要解决服务器间协作、状态一致性与安全策略组合
- 需要解决大量现有应用的兼容问题
运行现有内核作为用户态服务器
务实方法是把一个成熟的单体内核(如 Linux)当作用户态进程运行在微内核之上,提供 UNIX 兼容接口。这样:
- 既保留微内核的隔离与最小化优势(部分程度上)
- 又能快速获得现有应用生态
- 代价是架构不再“纯粹”,并引入额外 IPC 与间接层
L4/Linux 即属于此类。
L4与 Linux 结合:L4 Linux 的基本结构
结构概览
- L4 内核位于最底层,提供地址空间、线程、IPC、映射与中断递送等最小机制。
- Linux 内核以“用户态进程/服务器”的方式运行(通常称 Linux server)。
- 每个 Linux 进程对应一个 L4 任务(地址空间)。
- Linux 进程发起系统调用时,通过 IPC 把请求发送给 Linux server,由其执行 Linux 系统调用实现,再通过 IPC 返回结果。
“用户态 Linux 内核”意味着什么
Linux 内核本质上也是程序。要让其在用户态运行,需要把原本直接操作硬件/特权资源的部分改为:
- 调用 L4 提供的系统调用来完成等价操作,或
- 与特定服务进程通过 IPC 交互来完成等价操作
而 Linux 的高层逻辑(文件系统、网络协议、驱动框架等)可以尽量保持不变,从而降低移植成本。
内存管理的一项关键设计:Linux server 映射全部用户内存
为高效处理系统调用中的用户指针(例如 read(fd, buf, n) 的 buf),L4/Linux 采取的常见设计是:
- Linux server 负责分配并管理用户进程的内存页面
- 这些页面同时也映射进 Linux server 的地址空间
这样 Linux server 在处理系统调用时能直接解引用用户指针,避免把大量数据作为 IPC 消息搬运,从而使系统调用 IPC 请求可以只携带“地址与长度”,而不是携带大块数据。
以 fork 为例理解 L4 Linux 的工作流
传统 Linux 的 fork 涉及复制或写时复制(COW)语义、页表设置、子进程创建等,通常深度依赖内核特权。
在 L4/Linux 中可以概括为:
- 进程发起
fork,其 C 库把该调用转为向 Linux server 的 IPC 请求 - Linux server 为子进程准备资源(页面、页表逻辑等),并请求 L4 创建新的任务(即子进程地址空间)
- Linux server 通过向新任务发送特定 IPC 或设置初始寄存器状态,使其从正确的指令地址与栈指针开始运行
- 子进程运行中发生缺页时,L4 把缺页事件递送给外部分页器逻辑(在此体系下通常由 Linux server 承担),由其逐页建立映射并恢复执行
该流程体现出:微内核提供“任务、线程、映射、缺页递送”的机制;Linux server 在用户态实现传统 Linux 语义并驱动这些机制。
性能评估应当回答的问题与方法
研究型系统论文的评价通常分两层:
- 微基准(microbenchmark):测量最核心原语的开销,例如一次系统调用/IPC 的成本。用于回答“关键路径是否过慢”。
- 整机基准(macrobenchmark / whole-system benchmark):用接近真实负载的程序组合测量整体吞吐与延迟。用于回答“关键开销是否会在真实工作负载中放大成灾难”。
对于 L4/Linux 类系统,读者最关心的是:
- IPC 是否足够快
- 系统调用经由 IPC 转发是否带来不可接受的额外开销
- 在混合负载下,总体效率与原生 Linux 相比差距多大
- 与早期微内核方案(如 Mach/Linux)相比是否显著改进
如何理解“系统调用开销增加约两倍”的影响
将一次原生 Linux 系统调用变成“向 Linux server 发送请求 IPC + 接收应答 IPC”,直观上至少多一次额外的内核路径与切换,因此出现“系统调用成本增加约两倍”是可预期现象。
其影响大小取决于工作负载结构:
- 系统调用密集且每次工作量很小:例如频繁
getpid、read极小数据块、极多短操作,开销更敏感。 - 计算或 IO 主导:若单次系统调用背后执行大量计算或等待磁盘/网络,额外几微秒的系统调用开销可能被掩盖。
- 缓存与批处理:现实系统中大量操作经由缓存命中、合并写、延迟分配等机制摊薄调用次数,因而整体影响可能小于微基准直觉。
因此,单看微基准并不足以下结论,必须结合整机基准看“总体效率曲线”的斜率与趋势。
结论性图景:微内核在现实中的位置
综合以上路线,可以得到较稳定的工程判断:
- 微内核在嵌入式与安全关键场景更常见:系统服务可控、应用集合相对封闭、对隔离与可验证性需求高。
- 通用计算领域微内核未大规模取代 Linux/Windows 的核心原因通常是生态与迁移路径,而非“能否工作”。
- 许多微内核研究成果被传统系统吸收:更强的虚拟内存机制、用户态线程库与运行时、模块化与可扩展思路、高性能 IPC 技术、驱动隔离与用户态服务化的局部实践等。
从第一性原理角度看,传统单体内核与微内核并非“谁绝对更好”,而是围绕以下权衡做不同选择:
- 接口抽象的高度:便利与统一管理对灵活性的约束
- 边界的位置:性能与集成度对安全与鲁棒性的影响
- 工程可行性与生态成本:兼容性、迁移路径与维护复杂度
术语表
- 内核:以最高特权运行,控制关键资源与隔离边界的系统核心。
- 单体内核:多数 OS 服务在内核态同一地址空间实现,模块间直接函数调用。
- 微内核:内核只保留最小机制,绝大多数服务移至用户态,通过 IPC 组合成系统。
- 地址空间/任务:虚拟地址到物理内存映射及其保护域。
- 线程:CPU 调度的执行单元,运行于某地址空间内。
- IPC:进程/线程间通信机制;在微内核体系中是系统组装的基础原语。
- 缺页(page fault):访问未映射或不允许访问的虚拟地址触发的异常。
- 分页器(pager):接收缺页事件并决定如何建立映射的实体(可在用户态)。
- 按需分页(demand paging):只有在访问时才装载/映射页面的策略。
- 零拷贝:通过共享映射等方式避免在内核/用户之间重复拷贝数据。
页表
虚拟内存与页表的核心动机
现代操作系统首先面临的根本问题是隔离。在没有虚拟内存的系统中,所有用户程序与内核共享同一份物理内存地址空间。一旦某个用户程序存在缺陷,例如向随机地址写入数据,就可能直接破坏内核或其他进程的状态,系统稳定性与安全性完全无法保证。
虚拟内存的目标可以归结为三点:
- 地址空间隔离:每个进程只能访问属于自身的内存区域
- 受控共享:在必要时(例如内核与用户切换),允许精确、受限的共享
- 资源复用:将多个逻辑地址空间高效地复用到同一份物理内存之上
虚拟内存并不是增加了物理内存,而是通过地址翻译引入了一层间接性,使软件对内存的视图不再直接等同于硬件提供的物理地址。
地址翻译的第一性原理
处理器执行的所有加载与存储指令,都只能使用虚拟地址。虚拟地址到物理地址的转换由硬件中的 MMU(Memory Management Unit) 完成,其基本数据来源是页表。
抽象流程如下:
CPU 指令 → 虚拟地址 → MMU → 物理地址 → RAM
页表的本质是一种映射关系:
虚拟页号 → 物理页号 + 权限信息
如果一个虚拟地址在页表中没有对应映射,或权限不满足访问类型,硬件会触发页异常(page fault),控制权被转移到内核。
分页而非逐字节映射
如果为每一个虚拟地址都建立独立映射,页表规模将无法接受。因此系统以**页(page)**为最小管理单位。
在 RISC-V 中:
- 页大小为 4 KB
- 页内偏移占 12 位
- 虚拟地址的高位用于标识页
这意味着页表只需要为“页”建立映射,而非为每一个字节建立映射。
RISC-V Sv39 地址模型
RISC-V 的 xv6 使用 Sv39 分页模式,其核心约束如下:
- 虚拟地址有效位宽为 39 位
- 地址空间上限为 512 GB
- 页表索引位共 27 位
- 分三级,每级 9 位
这种设计并非偶然,而是与硬件与内存效率密切相关。
多级页表的结构与动机
如果直接使用一个线性数组作为页表,需要 (2^{27}) 个条目,每个条目 8 字节,单个进程就需要约 1 GB 的页表内存,这在实践中完全不可接受。
RISC-V 采用三级页表(树形结构),只为实际使用的地址范围分配页表节点,从而显著节省内存。
核心思想是:
- 虚拟地址被切分为多段索引
- 每一段索引用于在一层页目录中查找
- 未使用的子树不分配内存
九位索引的工程意义
每一级页表使用 9 位索引,原因在于:
- (2^9 = 512) 个页表项
- 每个页表项 8 字节
- 单个页表页大小为 (512 × 8 = 4096) 字节
即:一个页目录恰好占用一个物理页。 这一点使得页表的分配、回收、管理完全与分页机制对齐,是高度工程化的设计选择。
页表项的组成与语义
在 Sv39 中,每个页表项(PTE)为 64 位,其中关键字段包括:
-
PPN(Physical Page Number) 指向物理页的高位地址
-
权限标志位
- V:是否有效
- R:可读
- W:可写
- X:可执行
- U:用户态可访问
物理地址的低 12 位始终来自虚拟地址的页内偏移,这一点由硬件自动完成。
页异常的语义与处理
当出现以下情况之一时,会产生页异常:
- PTE 的 V 位未设置
- 写操作但 W 位未设置
- 执行操作但 X 位未设置
- 用户态访问 U 位未设置的页面
异常并不等价于错误。它只是一次受控的状态转移。
在 xv6 中,页异常主要分为两类:
- 合法但尚未完成的映射 例如延迟分配的堆内存
- 非法访问 通常是用户程序缺陷,内核终止进程并打印诊断信息
TLB 与性能问题
三级页表意味着一次地址翻译可能需要多次内存访问。为避免性能灾难,处理器引入 TLB(Translation Lookaside Buffer):
- 缓存最近使用的虚拟页到物理页映射
- 命中时无需访问页表
- 切换页表后必须失效旧缓存
xv6 采取保守策略,在用户态与内核态切换时刷新整个 TLB。
内核页表的设计原则
内核同样运行在虚拟地址之上,并拥有自己的页表,其特点包括:
-
直接映射(direct mapping) 虚拟地址等于物理地址,简化内核代码
-
严格权限划分
- 内核代码不可写
- 内核数据不可执行
-
高地址保留特殊用途 包括 trampoline 与内核栈
内核页表在系统启动早期构建,并在所有进程间共享。
用户地址空间的布局逻辑
每个进程拥有独立的页表,但虚拟地址布局高度一致:
- 用户地址从零开始,便于编译器与链接器
- 地址空间连续,但物理内存可不连续
- 预留大量空间用于堆与栈增长
- trampoline 页面在内核与用户页表中映射到同一虚拟地址
这种设计在抽象一致性与实现效率之间取得了平衡。
页表操作的核心实现思想
xv6 中的页表操作遵循一个基本原则:
软件必须精确模拟硬件的地址翻译过程。
因此:
walk函数按层级下降,定位最终 PTE- 若中间页目录不存在,则动态分配
- 映射的建立与修改本质上是对内存中页表结构的写操作
- 页表根节点的物理地址被写入
satp寄存器
需要强调的是:
satp 中保存的是物理地址,而非虚拟地址。
总结性的抽象视角
虚拟内存不是一项局部优化,而是操作系统最核心的抽象之一:
- 它定义了进程与内存的关系
- 它塑造了内核与用户的边界
- 它将硬件限制转化为软件可控的资源
理解页表的结构、权限与异常语义,是理解进程隔离、系统调用、安全机制以及后续高级内存技巧的必要前提。
系统调用出入口
问题的第一性原理
操作系统内核的核心职责之一是“受控地共享硬件资源”。共享的前提是隔离:用户程序必须无法随意读取或修改内核数据与设备寄存器,也不能随意获得更高特权;与此同时,内核又必须在必要时刻接管控制权,例如:
- 用户程序请求受保护的操作(系统调用,如
write()、fork())。 - CPU 侦测到异常(如页故障、非法指令)。
- 设备触发中断(如定时器、磁盘完成、串口收到字符)。
因此需要一个硬件与软件协作的“陷入机制(trap)”:让控制流从用户态进入内核态。该机制必须同时满足:
- 隔离性:任何时刻都不能在更高特权级执行不受信任的用户指令。
- 透明性:返回用户态后,用户程序的寄存器与执行位置应当像“被暂停后继续运行”一样一致;这对异步设备中断尤其关键。
- 性能:陷入与返回是高频路径,保存/恢复状态、切换页表等都应尽量高效。
执行上下文与“需要切换的资源”
用户程序在 CPU 上运行时,占用一组关键资源(可视为“机器状态”):
- 通用寄存器:RISC-V 有 32 个整数寄存器(含
sp栈指针、ra返回地址、参数寄存器a0-a7等)。 - 程序计数器:当前指令地址(RISC-V 通过
pc表示;陷入时保存到sepc)。 - 特权级:用户态(U-mode)或监督者态/内核态(S-mode)。
- 地址空间根:页表基址寄存器
satp决定当前虚拟地址到物理地址的映射。 - 中断开关等状态:如
sstatus中与中断和返回相关的位。
当发生系统调用(如 write())时,进入内核至少需要完成下列动作,才可安全执行内核 C 代码:
- 切换到监督者态(内核态)。
- 保存用户态的全部通用寄存器与用户态
pc,以便透明恢复。 - 切换到内核页表(内核地址空间)。
- 切换到内核栈(避免覆盖用户栈、并满足 C 调用约定)。
- 跳转到内核的陷阱处理 C 代码入口(如
usertrap())。
此外还需保证:进入路径本身不能被用户代码干扰,例如不能出现“在监督者态继续执行用户页中的下一条指令”的窗口。
RISC-V 陷阱相关寄存器与指令语义
关键寄存器
-
satp:页表根指针与地址转换模式。 -
stvec:陷阱向量基址。陷入监督者态后,CPU 将pc设置为该地址(或其派生地址)。 -
sepc:保存陷入前的pc(返回地址)。 -
scause:陷阱原因编码(系统调用、异常、中断等)。 -
sscratch:监督者态临时寄存器,常用于陷阱入口阶段的“临时存储”。 -
sstatus:包含:SIE:监督者态中断使能位。SPIE:保存陷入前的SIE。SPP:保存陷入前的特权级(指示sret返回到 U 还是 S)。
关键指令
-
ecall(在用户态执行):- 切换到监督者态(S-mode)。
- 将当前
pc保存到sepc。 - 将
SIE复制到SPIE,并清零SIE(等价于暂时屏蔽进一步中断,避免嵌套陷阱破坏尚未保存的状态)。 - 将
pc跳转到stvec指向的位置。
-
sret(在监督者态执行):- 将
sepc写回pc,作为返回地址。 - 根据
sstatus.SPP切换到先前特权级(常见为返回用户态)。 - 将
SPIE复制回SIE,恢复中断使能状态。
- 将
这些语义体现一个关键设计:硬件只做最少的、通用的状态转换,把其余工作交给操作系统,以便操作系统实现不同权衡(更快的系统调用、更少的保存、更少的页表切换等)。
统一入口的总体结构
在 xv6(RISC-V 版本)中,用户态陷入与返回大体可概括为以下链路:
- 用户态调用
write()→ 进入ecall - CPU 跳到
stvec指向的入口(位于trampoline.S的uservec) uservec完成最关键的保存、切换与跳转 → 进入 C 函数usertrap()usertrap()根据scause判断类型,系统调用则进入syscall()并分发到sys_write()等- 准备返回:
usertrapret()设置返回所需寄存器与陷阱入口环境 - 跳回
trampoline.S的userret,恢复寄存器、切回用户页表 - 执行
sret回到用户态继续执行write()调用点之后
这一结构将极早期的、必须极度谨慎的阶段放在汇编中完成(避免 C 编译器改写寄存器/栈),再尽快切换到内核常规 C 环境。
用户态系统调用从发起到返回的逐阶段解释
阶段一:用户态发起系统调用
用户程序调用 write(fd, buf, n) 时,符合 RISC-V 调用约定:
- 参数放入寄存器:
a0=fd,a1=buf,a2=n。 - 系统调用号放入
a7(例如SYS_write=16)。 - 执行
ecall。
此刻仍在用户页表与用户栈之下运行;用户地址空间只映射用户代码/数据/栈等,以及两段特殊高地址页:trapframe 与 trampoline(原因见后文)。
阶段二:ecall 触发陷入,硬件最小动作
ecall 发生后:
- 特权级从 U 切到 S。
sepc保存陷入前的pc(也即返回用户态继续执行的位置)。pc变为stvec(陷阱向量入口)。SIE被清零,避免在尚未保存用户寄存器时发生嵌套中断。
重要的是:satp 不会自动改变,仍然是用户页表。这一点直接带来后续设计约束:即便处于监督者态,取指与访存仍受当前页表限制;若当前页表不映射内核代码/数据,则不能直接执行普通内核 C 代码。
阶段三:trampoline 页与 uservec 的必要性
由于 ecall 不切页表,陷入后的第一段代码必须满足:
- 在“用户页表”下可取指执行;
- 但不能让用户态代码可执行或可篡改这段代码;
- 并且最好不占用用户低地址空间,以免破坏用户地址空间布局。
xv6 的做法是引入一页“蹦床”代码页 trampoline:
- 映射在虚拟地址空间的最高页。
- 在用户页表与内核页表中都映射到同一虚拟地址(便于切换页表时不中断执行)。
- 页表项不设置
PTE_U,因此用户态无法取指/访问该页;只有监督者态可执行。
stvec 指向 trampoline.S 中的 uservec,因此 ecall 后立即开始执行 uservec,控制权完全落入内核预先放置的代码,而不会继续执行用户指令。
阶段四:保存用户寄存器的存放位置与 trapframe
为了透明恢复,所有用户通用寄存器都必须保存。难点在于:
- 当前仍使用用户页表,无法随意写入内核内存。
- 32 个通用寄存器都装着用户值,原则上都要保留,不能随意拿一个寄存器当“临时指针”而丢失其值。
xv6 用两部分机制解决“保存到哪里、用什么指针”的问题:
- 在每个进程的用户页表中额外映射一个“陷阱帧”页
trapframe,固定虚拟地址,例如0x3fffffe000。该页用于存放保存的寄存器、内核栈指针、内核页表指针、陷阱处理入口等。每个进程的trapframe指向不同物理页,因此进程间互不干扰。 - 使用 RISC-V 的
sscratch寄存器作为陷阱入口阶段的临时存储。用户态无权访问sscratch,因此其内容不会被用户破坏,无需为“透明恢复”保存用户态的sscratch值。
典型序列为:
- 先把某个通用寄存器(如
a0)暂存到sscratch; - 再把
TRAPFRAME固定地址装入a0作为指针; - 然后按偏移把 32 个用户寄存器逐一存入
trapframe。
这样既保留了 a0 的用户值(在 sscratch),又得到一个可用指针指向保存区。
阶段五:切换到内核执行环境
完成寄存器保存后,仍需进入“可运行内核 C 代码”的环境:
- 内核栈:C 代码会使用栈、并遵循调用约定。必须把
sp切到本进程的内核栈顶。xv6 事先把该指针写入trapframe的固定槽位,uservec从trapframe取出并写入sp。 - 当前硬件线程标识:xv6 常用
tp保存 hart(硬件线程)ID,用于快速定位当前 CPU 的struct proc*等;uservec会设置tp。 - 内核页表:需要访问内核代码与数据,必须把
satp切换为内核页表。内核页表的地址同样预先放在trapframe中,uservec取出后写入satp,并执行sfence.vma清理 TLB,使新页表生效。
此处存在一个表面矛盾:切换 satp 时正在执行指令,若新页表不映射当前指令地址将立即崩溃。xv6 避免该问题的关键是:trampoline 页在用户页表与内核页表中都映射在同一虚拟地址,因此在 uservec 中切换 satp 后仍可继续在同一地址取指执行。
最后,uservec 从 trapframe 取出内核 C 入口地址(如 usertrap())并跳转,至此进入常规内核代码。
阶段六:内核 C 入口 usertrap() 的责任边界
usertrap() 是“来自用户态的一切陷阱”的统一 C 入口:系统调用、异常、设备中断都会到这里。其第一项工作是读取 scause 判断原因,例如:
scause=8表示来自 U 模式的环境调用(系统调用)。
对系统调用而言,usertrap() 通常会:
- 从当前进程结构中找到
trapframe(例如p->trapframe)。 - 取出系统调用号:
p->trapframe->a7。 - 通过系统调用表分发到对应实现(如
sys_write())。 - 将返回值写入
p->trapframe->a0,以符合用户态调用约定(返回值在a0)。
此处“必须从 trapframe 取 a7”而不能直接读当前 CPU 寄存器的原因在于:进入内核后寄存器已经被内核代码使用与改写,尤其在 C 环境里编译器会把寄存器当作临时变量分配;只有 trapframe 中保存的是“陷入瞬间用户态寄存器”的稳定快照。
阶段七:返回准备 usertrapret() 的本质
返回用户态不是简单执行 sret 即可。sret 只依据少数寄存器(sepc、sstatus、当前 satp 等)动作,因此必须先准备好“下一次进入内核”和“本次返回用户”的全部状态。主要包括两类准备:
-
为下一次陷入预设入口环境 因为返回用户态后未来随时可能再次
ecall或被中断打断,所以需要设置:stvec指回trampoline中的uservec(确保下一次陷入仍走正确入口)。- 在
trapframe中写入:内核页表指针、内核栈指针、陷阱 C 入口地址、hartid 等,供下一次uservec使用。
-
为本次
sret返回设置硬件要求字段sepc应当是用户态继续执行的pc(通常来自先前保存并可能被调整,例如系统调用需要sepc指向ecall后的下一条)。sstatus.SPP需要设置为返回用户态。- 中断相关位需要符合语义:返回用户态后是否允许中断。
还需要切回用户页表。但在内核 C 代码中直接切到用户页表往往不可行,因为用户页表不映射内核 C 代码所在地址,切换会导致立刻无法取指。解决方式与进入时对称:跳转到 trampoline 中的 userret,因为 trampoline 同时映射在两套页表里,可在切换过程中保持可执行。
阶段八:userret 切页表、恢复寄存器、执行 sret
userret 位于 trampoline,它执行一系列严格顺序的步骤:
-
将
satp写为用户页表,并sfence.vma使之生效。 -
从
trapframe逐个恢复 32 个用户通用寄存器。 -
通常将
a0(返回值寄存器)最后恢复,以免在恢复过程中失去访问trapframe的指针寄存器或破坏返回值。 -
执行
sret:pc ← sepc,回到用户态继续执行;- 切换特权级回用户态;
- 恢复中断使能(
SIE ← SPIE)。
至此,用户态的 write() 调用点继续向下执行,并在 a0 中看到内核设置的返回值(例如返回写入的字节数)。
隔离与正确性为何要求如此复杂
用户态不能干预“进入后的第一条指令”
若陷入后仍可能执行用户页中的指令,等价于用户程序在监督者态运行,将直接破坏隔离。RISC-V 的 ecall 通过强制 pc=stvec 实现“立即进入内核指定入口”。用户程序即使能执行 ecall,也无法决定跳到哪里,因为 stvec 只能由内核设置。
为何硬件不直接帮忙保存寄存器、切页表、换栈
硬件若在 ecall 内部直接做“全量保存与切换”,会固定系统软件的策略,牺牲灵活性与性能空间。例如:
- 某些系统可采用单一页表映射用户与内核,无需切
satp。 - 某些快速系统调用路径可能只需保存少量寄存器或无栈处理。
- 某些异常处理可能希望在当前地址空间就地处理。
因此硬件只提供最小、通用的机制:切特权级、记录返回点、跳到向量入口、管理中断使能,剩余由操作系统实现。
为何需要 trapframe 映射进用户页表
进入内核的最早阶段仍受用户页表约束,必须有一块“在用户页表下可写、但仅监督者态可访问”的保存区。trapframe 满足:
- 固定虚拟地址,入口汇编可立即定位。
- 页表项不设
PTE_U,用户态不可访问,避免泄露与篡改。 - 每进程独立物理页,避免进程间干扰。
- 既用于保存用户寄存器,也用于携带“下一次陷入所需的内核元数据”(内核栈指针、内核页表指针、C 入口等),减少早期阶段对内核全局数据的依赖。
常见关键疑问的统一解答
设备中断若发生在 trampoline 代码执行期间会怎样
进入 uservec 后,ecall 已经把 SIE 清零,相当于暂时屏蔽进一步中断,避免在寄存器尚未保存完全时发生嵌套陷阱导致状态混乱。等到保存完成并进入内核可控环境后,内核可以在合适时机再开启中断。对异步中断而言,这一策略把中断“延后”到安全点处理,从而保证透明性与一致性。
为什么系统调用分发必须使用 trapframe 中的寄存器值
陷入内核后,通用寄存器立即成为内核执行资源,会被汇编入口与 C 编译器任意使用与覆盖。只有 trapframe 中保存的快照才代表“陷入瞬间用户态寄存器”的真实值,因此系统调用号、参数与用户 pc 都必须从 trapframe 读取与写回。
性能与简化空间的第一性原理讨论
潜在攻击面
进入机制的攻击面集中在两类地方:
- 向量入口的不可篡改性:
stvec必须仅由内核设置;trampoline与trapframe必须对用户态不可访问(无PTE_U)。 - 返回时的状态完整性:
sepc、sstatus、用户页表指针等必须由内核验证与设置,防止返回到非法地址或以错误特权级返回。
可能的简化方向
从结构上看复杂性主要来自三件事:保存/恢复寄存器、切页表、切栈。潜在简化思路包括:
- 合并地址空间:用单一页表同时映射用户与内核(仍需权限位隔离),可减少
satp切换与 TLB 刷新开销,但需更谨慎的权限管理与更复杂的漏洞面分析。 - 减少保存寄存器数量:若 ABI 与内核约定允许,仅保存必要寄存器可提升性能,但会降低透明性或增加编译器/调用约束复杂度。
- 硬件加速陷阱保存:由硬件提供更快的寄存器批量保存/恢复指令或专用栈切换机制,但会减少操作系统可选策略。
- 专门的快速系统调用路径:区分系统调用与异步中断/异常,给系统调用提供更短路径(例如不必处理全部通用情形),但会增加内核实现复杂度。
可能的加速方向
性能瓶颈常见于:
sfence.vma引发的 TLB 失效与重填成本;- 寄存器保存/恢复的指令数量;
- 频繁陷入导致的流水线与缓存扰动。
因此加速通常围绕减少页表切换、减少保存/恢复工作量、缩短入口汇编路径、避免不必要的内核态分发层级展开。
关键结论汇总
- 系统调用、异常、设备中断通过同一陷阱机制进入内核,核心目标是隔离、透明与性能。
ecall只做最小动作:切特权级、保存返回点、跳到stvec、管理中断使能;不切页表、不换栈、不保存通用寄存器。- xv6 通过
trampoline与trapframe两个“高地址、非用户可达”的映射页,解决了“在仍使用用户页表的最早阶段如何安全保存状态、如何安全切换到内核执行环境”的难题。 - 进入路径以汇编完成关键保存与切换,随后尽快进入 C 代码处理;返回路径对称地在
trampoline中切回用户页表、恢复寄存器、执行sret。 - 复杂性来源于在强隔离约束下仍要做到完全透明恢复,并把陷阱路径做成高性能热路径。
系统调用插桩
背景与定位
系统调用插桩(system call interposition)是一类“在进程与内核之间设置检查点”的技术:当某个进程试图进入内核执行系统调用时,插桩层会截获该调用,依据安全策略决定允许、拒绝,或记录审计信息。其核心目标是在“不完全信任应用代码”的前提下,将潜在破坏限制在可控范围内。
“不完全信任”包含两类情形:
- 缺陷驱动:程序存在漏洞、边界条件错误或未定义行为,导致越权访问或破坏数据。
- 对抗驱动:程序或其输入被攻击者操纵,以恶意方式调用系统资源。
典型动机来自“查看不可信内容”的场景:例如浏览器调用外部解释器/渲染器来打开 PostScript 或 PDF。此类格式可能包含可执行逻辑(例如脚本),因此攻击者只需诱导用户打开构造好的文件,就可能在用户权限下执行任意操作:读取敏感文件、篡改配置、发起网络连接、生成子进程等。系统调用插桩旨在把这种风险从“可任意破坏用户账户下的一切”缩小到“仅能在允许的资源集合内活动”。
目标与威胁模型
安全目标可表述为“最小化损害”:
- 限制文件系统影响面:允许访问某些文件/目录,但禁止触及其他文件;即使允许写入,也只在被允许的路径范围内。
- 限制进程影响面:禁止向其他不相关进程发送信号、附加调试、注入代码或破坏其状态。
- 限制网络影响面:禁止创建网络套接字或连接外部网络,或仅允许访问指定地址/端口。
- 允许自损但禁止外损:被隔离进程可以破坏自身内存、崩溃或自我损毁,但不应突破边界去影响外部关键资源。
威胁模型通常假设:
- 被限制进程在用户态完全不可信,可能刻意构造系统调用参数、竞态条件与异常行为。
- 内核可信,但插桩机制必须尽量避免引入新的内核漏洞。
- 策略引擎(policy engine)可以是可信组件,但若在用户态实现,需要考虑其与内核状态的一致性与竞态。
选择系统调用作为边界的第一性原理
资源保护的根本边界
操作系统对关键资源(文件、进程、网络、设备、内存映射等)的管理最终都通过内核提供的接口完成。在主流系统中,这个接口就是系统调用。若希望限制“对真实资源的最终影响”,最可靠的拦截点就在系统调用入口处。
与应用内部状态解耦
应用内部可能被攻击者控制:栈、堆、全局变量、库函数数据结构均可被篡改。基于库级别(例如拦截 printf、malloc、write 的用户态封装)进行限制无法形成强隔离,因为应用可以绕过库、伪造调用约定或直接执行系统调用指令。系统调用接口本身是为隔离设计的:用户态无法直接修改内核对象,只能通过受控入口请求服务。
无需修改应用
重写应用以消除漏洞往往不可行:成本高、覆盖不全、旧软件难维护。系统调用插桩的工程优势在于“对既有二进制有效”,在不改变应用的情况下实施策略。
插桩方式概览
用户态监控:ptrace 类方案
Unix 传统做法是利用 ptrace 调试接口让监控进程观察被监控进程的系统调用并做决策。其缺点通常包括:开销较大、对多线程复杂、与内核状态同步困难、存在竞态窗口、功能受限等。
内核态过滤:seccomp/BPF 与集成框架
现代系统倾向于在内核内执行过滤逻辑(例如 Linux seccomp + BPF),以降低上下文切换与数据复制成本,并减少竞态窗口。进一步的框架(例如强绑定内核对象的 MAC/LSM 机制)可以利用内核已有的安全钩子(hook)来获得更丰富的语义信息。
用户可配置的高层工具
如 Linux AppArmor、Firejail、macOS Seatbelt 等,通常在内核机制之上提供策略语言、配置文件与默认模板,使普通用户或管理员能以较小成本启用沙箱。
Janus 的设计范式
Janus 代表一种经典结构:内核内的拦截桩 + 用户态策略引擎。
- 内核模块负责截获系统调用,并对“敏感调用”做转发或上报;对性能关键且默认安全的调用可直接放行以降低开销。
- 用户态策略引擎根据策略决定允许、拒绝或记录。用户态实现易开发、易更新、崩溃影响小。
- 两者之间需要一个专门的通信接口,用于传递系统调用号、参数(或其副本)、调用上下文等信息。
这种分层的根本矛盾在于:做决策所需的语义信息大量存在于内核,而策略引擎若在用户态,则必须“理解并复刻”内核的语义与状态。这正是“陷阱与坑(Traps and Pitfalls)”一类论文聚焦的问题。
难点的第一性原理解释
系统调用插桩的困难可抽象为一句话:系统调用的参数在形式上是低层的(指针、整数、字符串、文件描述符),但策略判断需要高层语义(对象、路径解析结果、权限、引用关系、时间一致性)。
因此出现四类根源性问题:
- 语义缺失:参数本身不包含足够信息,必须结合内核状态解释。
- 状态复制:用户态策略引擎若要解释参数,必须复制/推导内核状态,容易与真实状态偏离。
- 接口复杂:系统调用集合巨大,存在非常规路径、隐式副作用与跨接口交互。
- 时间一致性:决策与执行之间可能被并发变化打断,产生竞态(TOCTTOU)。
以下逐项展开。
语义缺失与上下文依赖
指针参数的含义取决于调用者地址空间
系统调用经常接收用户指针,例如 read(fd, buf, n) 的 buf。从插桩层视角,指针只是一个数值(如 0x1234),无法单独判断其“好坏”。真正的问题包括:
- 指针是否可读/可写、是否跨页、是否指向映射区域。
- 该内存内容在决策过程中可能改变(多线程写入)。
- 若策略要检查内容(例如路径字符串),必须在某个时间点复制内容。
路径名的含义取决于当前工作目录与目录结构
open("a/b/c") 的路径字符串并不是一个稳定对象,其解析结果依赖:
- 当前工作目录(cwd)。
a、a/b各层目录是否是符号链接。- 并发重命名、替换、挂载点变化。
- 最终对象的真实 inode 与权限。
因此“字符串等于某个值”并不等价于“访问某个特定文件对象”。
文件描述符只是表索引
fd=5 的安全含义取决于该进程的文件描述符表中第 5 项当前指向何物:普通文件、目录、管道、套接字、设备等。更复杂的是:
dup、dup2、fcntl可复制/重定向描述符。close可释放,随后open可能复用同一数字。- 其他机制可在进程间传递描述符(见后述)。
若策略只按“fd 数字”决策,必然失真;必须按“fd 当前绑定的内核对象”决策。
状态复制与内核语义复刻的陷阱
为了做正确判断,插桩系统常尝试在用户态维护一个“内核状态影子”,例如:
- 跟踪每次
open/close/dup,维护 fd→对象映射。 - 跟踪
chdir,维护 cwd。 - 自己实现路径解析:处理
.、..、符号链接、挂载点等。
困难在于:内核语义包含大量边界条件与历史包袱。任何遗漏都会导致影子状态与真实状态分离。一旦分离,策略引擎可能:
- 错误放行(绕过安全限制)。
- 错误拒绝(破坏正常功能)。
- 在不同系统版本/文件系统实现上表现不一致。
这种问题往往不是性能数字能直接揭示的,而是通过“意想不到的交互”暴露出来,因此属于“经验教训型”论文擅长总结的内容。
宽接口与非显式交互
系统调用接口极宽,且存在许多非直观路径让进程获得影响力:
崩溃与核心转储
进程崩溃后系统可能生成 core dump 文件。即使策略严格控制 open,核心转储的生成也可能造成文件写入,表现为“没有显式 open 也写了文件”。若策略没有覆盖这条路径,隔离就不完整;若简单禁用 core dump,则可能影响调试与程序预期行为。
文件描述符传递
在 Unix 域套接字中可以通过辅助数据(ancillary data)传递文件描述符。效果是:进程可以不经过自身的 open,直接获得一个已经打开的、可能高权限的描述符。如果策略仅在 open 上拦截,就可能被绕过。要正确控制,必须同时控制:
- 套接字创建与连接。
sendmsg/recvmsg的辅助数据。- 传入 fd 的对象类型与权限。
辅助进程与外部组件
应用可能调用 helper 进程完成任务(例如打印、预览、解码)。若沙箱只覆盖主进程,helper 可能成为策略缺口;若都覆盖,又会引入复杂的进程族管理、继承与权限传播问题。
TOCTTOU:检查与使用的时间竞争
TOCTTOU(Time Of Check To Time Of Use)是系统调用插桩中最核心的安全陷阱之一:策略引擎检查的是某一时刻的状态,但系统调用真正执行时状态可能已变化。
典型来源包括:
符号链接竞态
策略检查路径 "safe/file" 位于允许目录下,但在检查后到执行前,攻击者把 safe 或中间组件替换为指向敏感位置的符号链接,从而让最终打开的对象变成不允许文件。
目录重命名与遍历竞态
在路径解析过程中,目录项可能被并发重命名或替换。若策略在用户态重新解析路径,可能与内核解析到的对象不同。
当前工作目录变化
多线程程序中,某线程 chdir 改变 cwd,另一个线程发起相对路径 open。策略若按旧 cwd 判断,可能放行不应放行的访问。
内存参数变化
系统调用参数位于用户内存中,策略读取路径字符串时看到的是 A,但内核稍后读取时看到的是 B(另一线程修改了字符串)。若不复制用户内存并锁定一致性,就会出现“检查 A,执行 B”的漏洞。
强制子集化系统调用语义的困难
许多沙箱策略试图定义“允许的系统调用子集”或“允许的语义子集”,例如:
- 禁止创建符号链接。
- 禁止重命名符号链接。
- 禁止通过符号链接访问(如使用
O_NOFOLLOW)。
问题在于:现有 API 并不总能表达需要的约束。
O_NOFOLLOW只影响最终路径分量,对中间分量的符号链接无能为力。- 若想禁止解析过程中的任何符号链接,需要更强的系统调用语义支持(例如更细粒度的解析控制)。
- 这解释了为何后来出现
openat2之类更现代的接口,提供诸如“拒绝符号链接解析”的明确选项:它把原本难以在插桩层可靠实现的语义,内建为内核原语。
从第一性原理看,这是“正确性所需信息必须由内核提供”的例子:当安全约束依赖内核解析过程的每一步时,用户态复刻不可避免地脆弱。
阻断系统调用带来的功能与语义偏移
沙箱拒绝某些系统调用并不总是“只减少能力”。程序可能依赖这些调用来维持安全或正确性,因此阻断可能导致反直觉后果:
- 程序试图主动降低权限(drop privileges)失败,反而长期以高权限运行。
- 程序试图关闭继承的敏感 fd 失败,导致描述符泄露到子进程。
- 程序依赖特定错误码走安全分支,但沙箱返回不同错误码或延迟,触发不安全回退逻辑。
因此,策略不仅要考虑“禁止什么”,还要考虑“被禁止后程序会怎样补偿”。在实践中,沙箱常需要“允许某些看似危险但用于自我约束的调用”,或提供“受控替代行为”。
与其他体系的对比:为何 Unix 既有优势也有负担
Unix 系统调用相比某些极简内核接口(例如以消息发送/接收为主的微内核接口)具有一个现实优势:很多资源操作在语义上可被分解与拦截。
- 路径名操作(
open)与对已打开对象的操作(read/write)分离:可以在open时做严格检查,随后允许对该 fd 的读写,而不必每次读写都解析路径。 - 可以禁止创建网络套接字,但允许对普通文件 fd 的读写。
但 Unix 同时背负“历史累积的宽接口”与“复杂交互”,导致前述陷阱大量存在。这是 Janus 一类工作的背景:它们试图在不修改内核的情况下用插桩获得灵活隔离,但被系统语义复杂性反复反噬。
在 xv6 中哪些问题会出现
以教学系统 xv6 为例(以常见版本的简化语义为前提),以下判断是基于其典型特性给出的结构性分析:
- 路径解析与 cwd 依赖:存在。
open、chdir、相对路径解析都会引入上下文依赖与竞态潜力。 - 指针参数与内存一致性:存在。xv6 也从用户地址空间拷贝参数,若插桩层在“拷贝前后”处理不当,同样会出现“检查与使用不一致”。
- 影子状态分离风险:存在但规模较小。xv6 系统调用集合较小、文件系统语义相对简单,复刻成本更低,但依然可能因边界条件遗漏而分离。
- 文件描述符传递:通常不存在(取决于具体实验扩展)。若没有 Unix 域套接字及其 ancillary data 机制,则该绕过路径不成立。
- core dump 等复杂副作用:通常不存在或被大幅简化,因此相关陷阱较弱。
- 多线程与高度并发:标准 xv6 进程模型相对简单;若缺少真正的用户级多线程,则部分竞态来源减弱,但进程并发仍可造成路径/目录的竞态。
结论是:xv6 会呈现“系统调用插桩的基本难题”,但许多“工业级坑”来自更丰富的接口与并发环境,在 xv6 中可能不完全显现。
规避陷阱的工程原则
倾向内核内强制,而非用户态旁路检查
在内核内进行过滤可以:
- 减少上下文切换与性能开销。
- 更接近真实内核状态,避免影子状态分离。
- 更容易把检查嵌入到“即将使用资源”的代码路径,缩小 TOCTTOU 窗口。
对用户内存参数进行一次性拷贝并以拷贝为准
对路径字符串、结构体参数等,在进入策略判断前将用户内存复制到内核缓冲区,随后:
- 策略判断基于该副本。
- 内核执行也基于该副本或与该副本一致的解析结果。
目的在于消除“检查 A,执行 B”的内存竞态。
与内核路径解析/权限检查合流
不要在外部“重复实现路径解析”。更可靠的方法是:
- 让内核完成路径解析并获得稳定对象引用(例如 inode/文件对象)。
- 策略对“已解析的对象”进行判定(按对象标识,而非按字符串)。
- 执行阶段使用同一对象引用,避免中间被替换。
这本质上是把策略判断从“基于名称”提升到“基于对象”,从而弱化符号链接与重命名竞态。
允许必要的“自我约束调用”,并精确定义失败语义
阻断系统调用时需要考虑:
- 返回何种错误码最能保持程序安全分支。
- 是否应在拒绝时关闭相关资源、清理状态,避免残留能力。
- 是否需要对调用进行“降级执行”(例如允许只读打开而拒绝写入)。
在可行处修改或约束被沙箱程序的行为
在某些应用场景,可以通过改造应用来减少系统调用语义复杂度:
- 将路径访问展开为逐步、可检查的操作序列。
- 避免依赖隐式行为(如自动生成文件、隐式继承 fd)。
- 将高风险功能拆分为更小的、权限更低的进程。
这不是“修复所有应用”,而是“针对关键应用做配套工程”,以换取更可证明的隔离边界。
实践价值总结
系统调用插桩在现实中广泛用于:
- 进程隔离(sandboxing)
- 安全审计与入侵检测(logging/auditing/IDS)
- 运行时强制策略(policy enforcement)
- 在不可信输入驱动的程序周围构建“可部署的安全外壳”
其最大优势是对现有应用透明、对恶意行为可强制约束;其最大挑战是语义与时间一致性:系统调用参数不足以独立表达安全语义,而任何“在内核外复刻内核语义”的尝试都容易陷入边界条件与竞态陷阱。
页面错误
背景与目标
虚拟内存机制的核心价值是隔离:每个进程看到一个独立的地址空间,进程之间与进程和内核之间的内存相互隔离。隔离并不是虚拟内存唯一的用途。虚拟内存还提供了一层间接性:应用程序发出的虚拟地址访问,并不直接等同于物理内存访问;内核通过页表决定虚拟地址应当映射到哪里、以什么权限映射、以及在访问失败时采取什么动作。 正是这层间接性,使得内核能够在保证隔离的前提下实现一系列“按需”“共享”“延迟”“透明”的能力。
本讲内容的主线是:页故障触发陷入,内核不必崩溃,而是可以在陷入处理期间修改页表,然后重启导致故障的指令。这一模式把“异常”变成了“机制入口”,从而实现按需分配、零页、写时复制、按需装入、换页、内存映射文件等功能。
虚拟内存的多重视角
隔离视角
- 每个进程拥有独立的虚拟地址空间。
- 进程只能访问被其页表映射并允许的地址;越界或权限不符会触发异常。
- 内核通过控制页表权限位,保证用户态无法直接读写内核内存或其他进程内存。
间接性视角
“虚拟地址 → 页表翻译 → 物理地址”这一间接层带来两个关键能力:
- 改变映射位置:同一虚拟地址可以映射到不同的物理页;不同进程的相同虚拟地址可以映射到不同物理页;不同虚拟地址也可以映射到同一物理页(共享)。
- 改变访问语义:通过权限位控制读/写/执行;通过故意制造“不可访问”来触发页故障,再由内核补全映射或升级权限。
在 xv6 中已经出现过一些利用间接性的例子(共享 trampoline 页、guard page、upid、super pages 等)。页故障机制把这类技巧扩展到更一般的“按需”场景。
页故障与“修改页表后重启指令”的基本机制
页故障属于陷入的一种
在 RISC-V 上,页故障是异常(exception)的一类。异常会导致从当前执行流“受控地”转移到内核的陷入处理代码(与系统调用类似,差别在于触发原因是硬件检测到的违规访问,而非显式的 ecall 指令)。
xv6 默认在页故障时会 panic,但这只是教学操作系统的简化策略。通用操作系统通常会:
- 判断故障是否可修复(例如:尚未分配的合法地址、按需加载的文件页、写时复制页等)。
- 若可修复:分配或装入页面、更新页表项、设置合适权限。
- 返回用户态时重启造成故障的那条指令,使其在新映射下重新执行并成功完成。
这一流程要求陷入返回路径能够恢复用户态寄存器上下文,并确保程序计数器(PC)仍指向故障指令(而不是下一条指令)。在 xv6 中,陷入返回逻辑会依据 trapframe(例如 tf->epc)完成这一点。
页故障处理需要的三类信息
为了在页故障时做出正确决策,内核通常需要:
- 故障虚拟地址:RISC-V 的
stval寄存器在页故障时会记录导致故障的虚拟地址。 - 故障类型:
scause指示异常原因。与分页相关的主要包括:取指页故障、加载页故障、存储/原子写页故障。故障类型决定处理策略(例如写时复制只在写故障触发)。 - 发生位置与特权级:用户态的指令地址可从 trapframe 的
epc得到;处于用户态还是内核态可由陷入路径区分(usertrap / kerneltrap)。这对策略至关重要:用户态非法访问通常应终止进程;内核态页故障往往意味着内核 bug,通常不可恢复。
延迟按需分配的第一性原理
传统 sbrk 的问题本质
sbrk 通过增大进程堆的上界来“申请更多内存”。在朴素实现中,sbrk 会:
- 调整进程地址空间大小(例如 p->sz)。
- 立即为新增区间分配物理页并建立映射。
这一策略的问题不在“功能错误”,而在资源效率与时延控制:
- 应用可能申请大块内存以覆盖最坏情况,但实际只使用其中一小部分。
- 立即分配导致物理内存占用提前发生,可能引发更早的内存压力。
- 分配的成本集中在
sbrk时刻,产生明显的长尾延迟;当系统内存紧张时,可能需要等待回收/置换等操作。
按需分配的核心思想
现代操作系统倾向于将“声明需要的地址范围”与“真正占用物理内存”解耦:
-
sbrk仅扩大“合法虚拟地址范围”(更新 p->sz),但不分配物理页,也不建立映射。 -
程序第一次触碰这些尚未映射的地址时,硬件触发页故障。
-
内核在页故障处理函数中判断该地址属于合法扩展区间后:
- 分配物理页;
- 将其映射到故障虚拟页;
- 设置权限;
- 返回用户态并重启故障指令。
这样带来的性质变化是:
- 未被使用的虚拟地址范围不会消耗物理内存。
- 分配成本被摊到每个“第一次访问页面”的时刻,避免一次性开销。
- 在高内存压力下,仍可能产生延迟,但延迟与实际访问页面数量相关而非与申请量相关。
xv6 示例:sbrklazy 的工作链路
应用行为层
示例程序会用 sbrklazy() 一次性扩大地址空间以容纳最坏输入长度的缓冲区 buf,但程序可能只读写其中少量页面。结果是:
- 虚拟地址范围变大。
- 物理页并未全部分配。
- 实际触碰到的页面数量决定最终分配数量。
首次访问导致页故障
当程序第一次读取 buf[0] 时,如果该页尚未映射,将触发加载页故障:
stval指向buf[0]所在的虚拟地址;scause指示这是 load page fault;epc指向触发访问的指令地址。
vmfault 的典型逻辑
一个“可修复”的延迟分配页故障处理函数通常做:
- 检查故障地址是否在进程合法范围内(例如:小于 p->sz 且不落入非法区域)。
- 页表遍历以确认相应 PTE 是否不存在或标记为未映射状态。
- 分配一个物理页并清零(或采用更高级策略,如零页共享)。
- 调用类似
mappages()更新该虚拟页的 PTE,设置用户可访问和读写权限。 - 返回,让陷入返回路径重启指令。
关键复杂点:copyin/copyout
即使用户态代码尚未访问 buf,内核也可能通过系统调用需要访问用户缓冲区。例如 read/write 系统调用会把数据从内核拷入用户空间(copyout)或从用户空间拷入内核(copyin)。此时会出现“用户缓冲区虚拟地址已声明,但尚未映射物理页”的情况:
- copyin/copyout 不能直接向不存在的物理页写入。
- 解决方式是:copyin/copyout 在访问用户页前检查映射是否存在;若不存在,主动触发与用户页故障同样的修复逻辑(调用 vmfault 或等价函数)来建立映射。
- 一旦映射建立,内核可通过自身页表对物理内存的一对一映射访问对应物理地址,完成拷贝。
这一机制对应用是透明的:应用无需知道内部发生了页故障与按需分配。
零页与零填充按需的第一性原理
零初始化为何普遍
许多场景要求进程获得“内容全为零”的新页面:
- 未初始化的全局变量段(通常称为 BSS)语义要求初值为零。
- 新分配的堆页通常需要保证对用户态不可泄露旧数据,因此也必须清零。
- 许多应用显式依赖零值(例如大数组)。
朴素做法是每次分配新页都 memset(page, 0, PGSIZE)。这保证正确性,但在大量分配时产生可观的写入开销。
共享零页的核心思想
构造一张“全为零”的物理页(zero page),并让多个虚拟页初始时都映射到它,但映射权限设置为只读(不允许写):
-
读:读取到的永远是 0,满足语义。
-
写:第一次写入会触发存储页故障,因为 PTE 不含写权限。
-
在写故障处理中:
- 分配一个新的物理页;
- 将零页内容复制到新页(由于全零,可用清零替代复制,取决于实现);
- 将故障虚拟页重新映射到新页,并赋予写权限;
- 返回重启写指令,写入发生在新页上。
这相当于把“每页都清零”的成本变为“在真正发生写入时才付出成本”。对只读或只读后不写的页面,完全避免了额外分配与清零。
示例 vmfault0 的含义
该示例展示了将 vmfault 扩展为:
- 首次 fault 时先把虚拟页映射到共享零页,并去掉写权限。
- 当后续发生写入,才在写故障路径中分配并映射可写新页。
由此可以出现同一地址在不同访问类型下导致不同次数页故障的现象:读故障与写故障分别触发不同处理路径。这也揭示了一个设计点:处理函数应结合 scause(读/写/取指)判断“此次访问意图”,从而在第一次故障就做更合适的决策。
写时复制 fork 的第一性原理
fork 朴素实现的浪费来源
传统 fork 语义:子进程获得父进程地址空间的拷贝。朴素实现直接复制父进程所有用户页:
- 成本与进程内存大小成正比。
- 但现实中 fork 之后常常立刻 exec,旧地址空间很快被替换,复制的工作变成浪费。
写时复制的核心机制
写时复制(Copy-on-Write,COW)把“复制整份内存”延迟到“真正发生写入”:
-
fork 时不复制物理页,而是让父子页表都指向同一组物理页(共享)。
-
同时将这些共享页面的 PTE 写权限清除,改为只读,并设置一个额外标记位表示该页为 COW(可使用 RISC-V PTE 中为软件保留的位,例如 RSW 位)。
-
当父或子尝试写入共享页面:
- 触发存储页故障;
- 内核检测到 PTE 的 COW 标记;
- 分配新物理页,将原页内容复制到新页;
- 更新当前进程的 PTE 指向新页并恢复写权限;
- 共享计数减少;若某物理页不再被任何进程引用,则可回收。
物理页引用计数的必要性
由于多个进程可共享同一物理页,必须有机制判断何时可以释放该物理页:
- 维护 per-page 引用计数。
- fork 时对共享页引用计数加一。
- 某进程因写故障获得私有副本或因退出释放映射时,引用计数减一。
- 引用计数归零时才释放物理页。
写时复制在概念上简单,但在真实系统里涉及多核并发、TLB 一致性、与文件映射/共享内存交互等复杂问题,因此实现细节可能非常困难。
按需装入与“需求分页”的第一性原理
exec 一次性装入的代价
朴素 exec 会把可执行文件中需要的段一次性读入内存并建立映射:
- 若文件位于慢速存储(磁盘、网络文件系统),启动延迟显著。
- 程序可能不会执行到全部代码路径,也不会触及所有数据页,提前装入造成浪费。
需求分页的基本策略
需求分页把“从文件读取页面”延迟到“页面第一次被访问”:
-
在 exec 阶段不把段内容全部读入;
-
预先建立页表项,但标记为“按需”(可能表现为 PTE 无效,或有效但无权限,或者使用软件结构记录映射尚未就绪);
-
记录足够的元数据:某虚拟页对应文件的哪个偏移、长度、权限等;
-
当触发页故障:
- 根据故障地址查找元数据;
- 分配物理页;
- 从文件中读取对应页内容;
- 建立/更新 PTE 并设置权限;
- 返回重启指令。
VMA 的作用
真实操作系统中,按需装入通常依赖“虚拟内存区域”(VMA)结构保存区间级别的元数据:
- 虚拟地址范围;
- 映射来源(匿名内存或文件);
- 文件描述符或 inode 引用与文件偏移;
- 权限与共享属性;
- 可能的页对齐与长度信息。
没有这类元数据,就无法在页故障时知道应该从哪里把内容补齐。
物理内存不足时仍支持更大虚拟内存
问题本质:工作集与容量不匹配
应用可能声明或使用的虚拟内存规模超过物理内存容量。系统能否运行取决于“活跃使用的页面集合”(工作集)能否大致容纳在物理内存中:
- 如果工作集能容纳:系统通过页面置换维持运行。
- 如果工作集无法容纳:频繁换入换出导致抖动(thrashing),性能急剧下降。
换页机制的核心路径
当需要为某进程分配物理页而内存已满,内核需要选择某些物理页作为牺牲者,将其内容写回磁盘(swap 或文件后备),然后释放该页帧:
- 页出(page-out):将不常用页写回后备存储,并使对应虚拟页的 PTE 失效或标记为“在磁盘上”。
- 页入(page-in):进程再次访问该虚拟页时触发页故障,内核从磁盘读取回物理内存并恢复映射。
脏页与访问位的意义
硬件通常在 PTE 中维护辅助位:
- Dirty(D)位:页面自从被载入/清理后是否被写过。若未被修改,页出时可以不写回(例如文件映射的只读页或未改动的匿名页)。
- Access(A)位:页面是否被访问过。内核可利用 A 位近似实现 LRU 或其变种,选择“最近最少使用”的页面作为牺牲者。
页面替换策略是操作系统研究的重要主题。现实系统往往还提供接口让应用影响决策,例如 madvise(提供访问模式提示)与 mlock(锁定页面避免被换出)。
内存映射文件的第一性原理
目标:用内存访问语义访问文件
内存映射文件(mmap)的核心是让文件内容出现在进程虚拟地址空间中,使得:
- 读取文件内容可以用普通 load 指令完成;
- 修改文件内容可以用 store 指令完成;
- 不必通过 read/write 系统调用在用户缓冲区与内核之间显式拷贝;
- 可以随机访问文件的任意部分,而不需要维护显式文件偏移或多次 lseek。
典型接口形如:
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
与页故障的结合方式
mmap 一般不会立刻把整个文件范围读入内存,而是:
-
建立一个 VMA,记录该虚拟区间映射到文件的哪一段;
-
对应 PTE 初始状态通常不可用(或无权限),以便首次访问触发页故障;
-
页故障时按需把文件对应页读入并映射;
-
内存压力下可选择把不常用的文件页回收:
- 对未修改页:直接丢弃,未来再访问再从文件重新读入;
- 对已修改页:需要写回文件(取决于映射类型与 flags,例如共享映射与私有映射语义不同)。
因此,mmap 实质上是“文件作为后备存储的需求分页”,把文件系统与虚拟内存子系统联结起来。
统一视角下的总结
虚拟内存通过页表提供隔离,同时通过“间接性”提供可编程的映射与权限控制。页故障把一次非法访问变成一次进入内核的受控入口。只要在页故障处理期间完成页表更新,并在返回时重启故障指令,就能把许多看似复杂的功能归约为同一个模式:
- 延迟按需分配:首次访问才分配并映射。
- 共享零页:读共享、写触发复制。
- 写时复制 fork:fork 共享、写触发复制并引用计数回收。
- 需求分页:首次访问才从文件装入。
- 置换与交换:物理内存不足时把不活跃页暂存到磁盘,访问时再恢复。
- 内存映射文件:把文件页作为可按需装入和可回收的虚拟页来源。
xv6 的实现足够简化,便于直接观察 stval/scause/epc 与页表更新对执行流的影响;但所依赖的第一性原理与工业级系统一致,差别主要在工程复杂度、并发一致性、性能策略与安全边界的细节处理上。
透明超级页
经典分页的目标与基本抽象
分页机制的核心目标是把“进程可见的虚拟内存”与“机器实际拥有的物理内存”解耦,从而同时满足两类需求:
- 地址空间抽象:每个进程拥有连续、私有、受保护的虚拟地址空间,进程无需关心物理内存布局与其他进程的存在。
- 容量扩展:当进程的“可用虚拟内存”大于“物理内存容量”时,仍可正确运行;物理内存不足时以磁盘作为后备存储(backing store)。
- 按需供给:进程可以先“声明/预留”大量虚拟内存,内核在真正被访问时才分配物理页与进行初始化,从而实现快速启动与更好的内存复用。
分页实现依赖两层关键对象:
- 页表:将虚拟页号映射到物理页号,并携带权限、存在位、访问位、脏位等元数据。
- 页异常(page fault)处理路径:当访问的虚拟页未映射或权限不满足时陷入内核,由内核决定如何“把该虚拟页变成可用状态”。
惰性分配与按需映射
现代系统普遍采用比教学系统更“惰性”的策略:
-
进程启动时,页表可能几乎为空或只覆盖少量必需区域。
-
进程调用内存分配接口(如堆增长、匿名映射)时,虚拟地址空间的范围会扩大,但页表条目往往并不立即建立。
-
当进程首次访问某个尚未建立映射的虚拟页时:
- 触发页异常;
- 内核分配一个物理页;
- 对物理页进行初始化(清零,或从可执行文件/映射文件读入相应内容);
- 在页表中建立映射并设置权限;
- 返回用户态,重试指令,访问成功。
惰性分配的动机在第一性原理层面主要是:
- 启动快:不必为从未访问的页面支付分配与初始化成本。
- 承诺与兑现分离:允许进程声明远大于物理内存的潜在需求,只有“被触达”的部分才真正占用物理内存。
- 更好的全局资源管理:在多进程场景中,物理内存分配由内核统一仲裁,避免应用层过早占用资源导致其他工作负载受损。
分页到磁盘的工作方式
分页到磁盘的本质是:把物理内存当作缓存,把磁盘当作后备存储。当物理内存不足以容纳所有“活跃页”时,内核把一部分页暂存到磁盘,并在需要时再读回。
基本流程与关键术语
-
换出(eviction / page-out / replacement):选择一个当前驻留在物理内存中的页,将其内容保存到磁盘(若需要),并撤销其在页表中的有效映射,使该物理页可被重新分配。
-
换入(page-in):当进程访问一个不在内存中的页时,从磁盘把该页读入某个物理页并重新建立映射。
-
后备存储位置记录:被换出的虚拟页必须记录其在磁盘中的位置(交换区槽位或文件偏移),以便之后换入。
-
脏页与净页
- 脏页(dirty):内存内容与磁盘上的副本不一致,需要写回磁盘才能安全回收该物理页。
- 净页(clean):磁盘上已有一致副本(例如来自只读可执行文件映射,或此前已写回),回收时通常无需写盘。
示例推演的抽象要点
在“物理内存页数少于进程使用页数”的极端示例中,流程体现出几个普遍规律:
-
首次读取某虚拟页:如果该页来自可执行文件或映射文件,内核从文件读入;如果是匿名页(如堆/栈),内核通常清零。
-
当没有空闲物理页可分配时:
- 内核选择一个候选物理页作为牺牲者;
- 若其对应的虚拟页为脏页,则写出到交换区;
- 相关页表项被置为无效(invalid / not present),并记录交换区位置;
- 该物理页被重新用于当前缺页的虚拟页。
-
之后若访问被换出的虚拟页:
- 再次触发缺页;
- 内核可能先驱逐另一个物理页;
- 把目标页从交换区读回并建立映射。
这一机制使得系统能够在物理内存不足时保持正确性,但性能高度依赖于驱逐策略与访问局部性。
驱逐策略与局部性、工作集、抖动
为什么驱逐策略至关重要
磁盘访问延迟相比内存大几个数量级。一次缺页导致的磁盘读写可能以毫秒计,等价于大量 CPU 周期的停顿。若驱逐策略频繁选中“马上会再次访问”的页,将导致连续缺页与频繁磁盘 I/O,性能急剧下降。
局部性与工作集
- 局部性:程序在某一阶段倾向于反复访问相对小的一组页面。
- 工作集:一段时间窗口内实际被访问的页集合。
- 分页到磁盘能否“运转良好”的关键条件通常是:工作集规模不超过可用物理内存,并且工作集变化相对缓慢。
抖动(thrashing)
当工作集过大或系统并发进程过多,物理内存无法容纳活跃页:
- 内核刚换入的页很快又被换出;
- 进程执行被缺页频繁打断;
- 大量时间消耗在磁盘 I/O 上;
- 系统表现为“极慢但 CPU 不一定满载”,整体吞吐量显著下降。
经典近似策略:最近最少使用
设计动机
理想的驱逐策略是“驱逐下一次访问距离最远的页”,但内核无法预知未来,因此采用可实现的启发式。LRU 的核心假设是:最近被访问的页更可能在不久的将来再次被访问。
典型实现要点
-
内核维护某种形式的页面链表或队列结构来近似 LRU 次序。
-
借助页表硬件提供的 访问位(Accessed):
- 内核周期性扫描物理页;
- 观察每页关联映射的访问位;
- 若访问位为真,则把该页“提升”为更活跃;
- 同时清除访问位,为下一轮采样做准备。
-
当需要回收物理页时,从“最不活跃端”挑选牺牲页。
反向映射需求
一个物理页可能被多个虚拟地址映射(写时复制、共享内存、文件映射等)。驱逐物理页时必须:
- 找到所有引用它的页表项并置为无效;
- 因而需要 反向映射:从物理页追踪到相关页表与虚拟地址列表。
净页优先
为了降低缺页处理路径的写盘成本,驱逐时通常更偏好净页:
- 净页回收可直接复用物理页(必要时清零),无需同步写回;
- 脏页需要写回,增加延迟并占用磁盘带宽。
更精细的多队列设计思想
讲义中提到的一种典型思路是把页分成多个队列(例如 Active、Inactive、Cache):
- Active:近期活跃页。
- Inactive:近期不活跃但可能再用页。
- Cache:不活跃且为净页的缓存页,可快速回收。
在内存压力增大时:
- 将 Active 尾部逐步降级到 Inactive;
- 对 Inactive 中的脏页安排写回;
- 将 Inactive 尾部的净页移动到 Cache;
- 缺页需要新物理页时优先从 Cache 分配,保证缺页路径更快。
其第一性原理是:把“热数据”与“可回收的冷净页”显式隔离,缩短常见路径的尾延迟,并用后台机制把“脏页变净”以提高可回收性。
页大小的根本权衡
页大小由硬件体系结构决定或强烈影响,内核需要在既定约束下做取舍。单一页大小同时影响:
- 页表占用的内存量;
- 缺页与换入换出的 I/O 粒度;
- 访问位与脏位的统计精度;
- 内部碎片(页内未使用空间);
- 以及与 TLB 的交互性能。
大页的优势
- 页表更小:覆盖相同虚拟空间需要更少页表条目与更少页表页。
- 内核元数据更少:空闲链表、反向映射、区域描述等的数量降低。
- 缺页次数更少:若访问具有空间局部性,一个大页覆盖更多数据。
小页的优势
- 内部碎片更小:部分使用的页面浪费更少。
- 统计与保护粒度更细:访问位与脏位更精确,有利于只保留真正热的部分。
- I/O 粒度更小:换入换出时的磁盘读写更少,缺页等待时间可能更短。
因此,同时支持基础页与超级页并能动态选择是自然的设计方向,但复杂性显著增加。
TLB 与“覆盖范围”问题
TLB 的角色
每次取指、加载、存储都需要虚拟地址到物理地址的转换。为了接近每周期一条内存访问的速度,处理器用 TLB 缓存近期地址转换结果:
- TLB 项数通常较少,因为其必须非常快。
- TLB 未命中时需要 页表遍历(多级页表多次内存访问),会引入显著延迟。
覆盖范围(reach)
TLB 能覆盖的内存大小约为:
- TLB 项数 × 页大小
因此:
- 基础页很小会导致覆盖范围有限;
- 程序的活跃地址空间稍大就会频繁 TLB 未命中;
- TLB 未命中的代价可能高到足以主导运行时间。
超级页提升覆盖范围
当页大小增大:
- 同样数量的 TLB 项可以覆盖更大的连续虚拟空间;
- TLB 未命中率降低;
- CPU 花在页表遍历上的时间下降。
但其代价是潜在的内存浪费与更粗粒度的访问/脏统计,这正是超级页机制面临的核心张力。
论文主题:透明超级页的系统支持
论文关注的目标是:由内核自动选择基础页或超级页,在不需要应用显式参与的情况下提升性能,主要受益路径是减少 TLB 未命中。
透明性的含义包括:
- 应用仍按普通虚拟内存接口申请与访问;
- 内核根据观察到的访问行为与内存压力做决策;
- 行为可随程序阶段变化而调整,而不是一次性静态选择。
本质上,给某段虚拟地址范围分配超级页是一种预测:
- 预测该范围会被较充分、较密集地访问;
- 若预测错误,浪费的物理内存可能很大,并可能放大系统的内存压力。
核心设计问题之一:何时分配或提升为超级页
常见候选时机的本质差别在于“证据强度”与“收益延迟”的权衡。
在内存申请时直接使用超级页
例如在堆增长或映射请求发生时就直接分配超级页:
- 优点:尽早获得 TLB 受益。
- 缺点:申请大小并不等价于未来访问模式,容易造成大量未使用空间的浪费。
在某个范围首次缺页时就使用超级页
这是较激进的策略:
- 优点:已经观察到至少会使用其中一部分,且能立即获得大页收益。
- 缺点:仍可能只使用很小一部分,浪费接近整个超级页。
在范围内所有基础页都实际缺页后再提升
这是更保守的策略,也是讲义中强调的论文做法:
- 优点:提升发生时已确认该范围被充分触达,浪费风险低。
- 缺点:在“逐页触达”的期间,无法获得超级页带来的 TLB 优势;收益出现较晚。
第一性原理上,这一问题等价于:在“性能收益”与“内存浪费风险”之间选择不同的证据阈值。
核心设计问题之二:如何获得满足对齐与连续性的物理内存
超级页通常要求物理内存满足:
- 连续;
- 按超级页大小对齐;
- 且整段可用。
与基础页的“可离散分配”不同,这在长期运行系统中会遭遇 物理内存碎片化:空闲页被散落在已分配页之间,难以自然形成大块连续空闲区。
常见路线包括:
启动时预留若干大块区域
- 简化实现,保证有连续块。
- 难点在于预留数量:过多会影响基础页灵活性;过少会限制超级页收益。
在线整理与紧凑(compaction)
- 通过搬移物理页,把分散空闲页“挤压”成连续空闲区;
- 搬移后需修补相关页表映射;
- 缺点是会消耗 CPU 时间,并可能在关键路径引入延迟抖动。
论文的“保留但不立刻占用”思想
讲义给出的论文方案关键点是:
- 在某个超级页覆盖范围内第一次缺页时,只为该页分配并映射一个基础页,立即满足访问;
- 同时从“冷净页缓存”中预留一整段潜在可用的物理页集合,用于未来把该范围逐步填满;
- 预留页暂不清零、不重映射,因而仍保留其缓存内容,原有映射若再次需要仍可通过常规机制恢复;
- 随着更多缺页发生,再逐个把预留页真正分配给该范围并初始化;
- 当该范围内所有基础页都被触达后,再统一提升为超级页映射。
这一安排的根本好处可以从三个维度理解:
- 关键路径最短化:第一次缺页只做一次基础页分配与映射,避免一次性分配整块超级页而引入长延迟。
- 对连续性的“延迟兑现”:通过预留机制尽早锁定未来可能需要的连续资源,降低后续因碎片化导致无法组成超级页的概率。
- 对缓存价值的尊重:预留页不立刻清零与重用,使其仍作为文件缓存的候选,系统在行为变化时仍保有回旋余地,避免过早破坏缓存命中率。
核心设计问题之三:何时以及如何降级超级页
超级页一旦建立,系统仍可能需要撤销或拆分(demotion),典型原因包括:
内存压力上升时降低浪费
当系统需要更精细地回收内存:
- 超级页的粒度过大,可能包含大量冷数据;
- 拆分为基础页可以只驱逐真正冷的部分,降低不必要的 I/O 与内存占用。
访问位与脏位粒度过粗
超级页的访问位与脏位通常以超级页粒度记录:
- 只要其中任意小部分被访问,整个超级页可能都显示为“被访问”,不利于替换算法判断冷热。
- 只要其中少量被写入,整个超级页可能都被视为“脏”,导致换出代价显著增加。
因此可能采用的策略包括:
- 通过保护机制捕获首次写入,在写入发生时拆分以获得基础页级别的脏位追踪;
- 在需要更精准的冷热信息时拆分以恢复基础页级别访问位采样。
第一性原理上,降级机制是为了在“TLB 覆盖收益”与“统计精度、回收精度、写回成本”之间动态切换。
性能收益的典型形态与代价边界
从讲义概括的结果出发,可以抽象出常见现象:
-
在内存充裕、程序确实密集访问大范围连续内存时,减少 TLB 未命中可带来可观收益,常见量级为个位到十余个百分点,个别工作负载更高。
-
但透明超级页的风险在于:
- 工作负载并非总是密集使用整段超级页;
- 内存浪费会抬高系统内存压力,反而导致更多页回收、更多写回、甚至触发抖动;
- 因而在某些场景下,收益可能被代价抵消,甚至出现负收益。
这解释了为何真实系统中的透明巨大页机制长期演进:其核心不是“是否支持大页”,而是“如何在正确的时间、正确的地点、以可接受的成本做对”。
关键概念的统一视角
可以用一个统一框架贯穿全部内容:
-
分页到磁盘解决的是“容量与正确性”:让虚拟内存可以超过物理内存,并通过替换维持运行。
-
替换算法与多队列结构解决的是“在磁盘极慢时如何减少缺页”:通过局部性假设尽量把工作集留在内存。
-
TLB 与超级页解决的是“在内存很快但地址转换仍可能成为瓶颈时如何减少转换开销”:用更大的页提升 TLB 覆盖范围。
-
透明超级页把“页大小选择”变成一个在线决策问题:在每个时刻根据访问模式与内存压力平衡两类代价:
- TLB 未命中与页表遍历的 CPU 代价;
- 内存浪费、回收精度下降与写回放大的系统代价。
面向实现与理解的检查清单
为避免理解盲点,可将系统实现分解为一组必须闭环的机制:
- 缺页处理:匿名页清零、文件页读入、权限与异常类型区分。
- 后备存储管理:交换区槽位分配与回收、位置记录、写回一致性。
- 脏页与净页判定:何时置脏、何时写回、何时可直接丢弃。
- 反向映射:从物理页定位所有映射并进行失效与更新。
- 替换与回收:冷热判定、队列组织、后台写回与内存压力反馈。
- 超级页分配:对齐与连续性、碎片化治理、预留与兑现策略。
- 提升与降级:提升触发条件、降级触发条件、粒度与元数据一致性。
- 性能观测:TLB 未命中、缺页率、写回量、碎片度、回收延迟等指标的关联分析。
结语:为何该主题在现代仍重要
即使单机内存容量显著增长,分页与超级页仍然长期重要,原因并不在于“单个程序是否超过内存”,而在于:
- 多进程与文件缓存竞争使内存成为共享稀缺资源;
- 数据中心规模下,内存成本与能耗仍是关键约束;
- 工作负载对尾延迟与吞吐的敏感度不断提高;
- 地址转换与缓存层次结构使得“CPU 并不总是被计算限制”,TLB 未命中等微架构事件足以成为瓶颈。
因此,“在正确性、内存效率、I/O 成本、TLB 效率之间进行动态权衡”的能力,是现代操作系统内存子系统的核心竞争力之一。
设备驱动
设备驱动与中断的目标与问题域
现代 CPU 并不能独立完成“计算以外”的任务。存储、网络通信、显示、键盘输入等都依赖外设。操作系统之所以需要设备驱动(device driver),本质原因是:
- 外设是独立工作的硬件实体:它们有自己的状态机、缓冲、时钟与速度约束,不随 CPU 指令节奏同步。
- 外设接口通常“刚性且复杂”:寄存器语义严格、时序要求明确、边界条件多,错误处理繁琐。
- 外设与 CPU 并发运行:外设状态随时间变化,可能在任何时刻需要 CPU 立刻处理(例如网络包到达),这就引入了中断与并发控制问题。
在生产级内核中,驱动代码往往占据很大比例,原因并非“驱动本身更高深”,而是因为它必须覆盖大量硬件、多版本差异、复杂边界条件与高并发场景。
用户层虚拟内存
主题概览
“用户态虚拟内存原语”的核心观点是:虚拟内存不仅是内核用来隔离进程、实现按需分页、提供文件映射等能力的工具;如果操作系统向用户程序暴露更细粒度、可控的虚拟内存机制,那么用户程序也能用它来构建一些传统上很难高效实现的功能,例如并发垃圾回收、并发检查点、分布式共享内存、应用级压缩换页、持久化存储等。
讲义以 Appel 与 Li 的论文为引子,讨论两件事:
- 操作系统内核如何“创造性”地使用虚拟内存。
- 如果把某些虚拟内存控制能力下放到用户态,应用可以如何“借力”,减少编译器插桩、减少显式检查、实现更强的运行时机制。
同时,讲义将一九九一年论文提出的抽象原语,与现代类 Unix 系统实际提供的接口(mmap、mprotect、sigaction 等)对照,讨论“哪些已可实现,哪些仍缺口明显”。
虚拟内存的第一性原理
地址与映射
虚拟内存提供一种“地址重命名”机制:
- 程序使用的是虚拟地址(Virtual Address, VA)。
- 真实内存硬件访问的是物理地址(Physical Address, PA)。
- 硬件与内核共同维护从 VA 到 PA 的映射关系,通常由页表(Page Table)表达,并由地址转换缓存(Translation Lookaside Buffer, TLB)加速。
这种设计带来三类基础能力:
- 隔离:不同进程的 VA 可以相同,但映射到不同 PA,互不干扰。
- 保护:每个页可附带权限位(读、写、执行、不可访问等),硬件对违规访问直接触发异常。
- 稀疏与按需:VA 空间可以很大,只有访问到某页时才需要真正分配物理页或从外部对象(文件、交换区、远端)填充。
页与权限触发的陷入
虚拟内存通常以“页”为粒度管理(例如常见 4KiB,也可更大)。当 CPU 执行一条指令访问某 VA 时:
- 若该 VA 未映射、或权限不满足(例如写只读页、读不可访问页),硬件触发页故障(page fault)异常。
- 异常将控制权转交内核:保存现场、定位故障地址与原因、决定后续动作(建立映射、加载数据、终止进程、或向进程发送信号等)。
页故障因此成为一个“可编排的控制点”:通过刻意设置映射与权限,可以把“某类访问”转换成一次可拦截的事件。
论文提出的用户态虚拟内存原语
论文抽象出一组“对用户程序有用”的 VM 原语。讲义列出如下(名称为讲义符号):
- Trap:允许在用户态处理页故障陷入(本质是“页故障上行到用户态的回调”)。
- Prot(单页):降低某一页的可访问性(例如从可写降为只读、或降为不可访问)。
- Prot(多页):对一段页范围批量降低可访问性。
- Unprot:提高某一页的可访问性(解除保护,使其可继续访问)。
- Dirty:返回自上次调用以来被写脏的页列表(“脏页追踪”的显式接口)。
- Map(双映射):将同一物理页映射到两个不同虚拟地址,并允许它们具有不同权限级别。
这些原语的共同点是:让应用能像内核一样,把“访问内存”变成一种可观测、可控的事件流,从而把一些传统上需要编译器/运行时深度配合的机制,转化为“基于缺页与权限”的机制。
教学对照:xv6 与现代类 Unix 系统
xv6 的情况
讲义指出:xv6 基本不支持上述用户态 VM 原语。可以将论文视为“一个好的 OS 应该向用户态开放哪些 VM 能力”的论证,也可以把它当作检验 OS 机制完备性的清单。
现代类 Unix 已有的对应能力
现代 Unix(包括 Linux、BSD、macOS 等)虽然未必以论文同名原语呈现,但已提供一套足以组合出大部分能力的系统调用与信号机制:
-
mmap:将文件或匿名内存映射到进程地址空间。
- 文件映射可实现“把文件当内存读写”,并由内核负责按需载入与回写。
- 匿名映射可作为堆的替代(很多场景比 sbrk 更灵活)。
-
mprotect:修改某段映射的权限;例如设为只读或完全不可访问。访问不可访问页会触发页故障。
-
munmap:撤销映射,制造“访问必故障”的空洞。
-
sigaction:为信号设置处理函数。对内存保护违规(例如 SIGSEGV)可在用户态接收到通知,并可根据故障地址做自定义处理。
此外 Linux 还有一系列更细的 VM 接口(madvise、mincore、mremap、msync、mlock、mbind、shmat 等),用于提供更强的性能提示、驻留查询、映射调整、同步策略、NUMA 策略等。
与论文原语的一一对应关系
可以按“可组合性”理解映射关系:
- Trap:由“页故障 → 内核 → 发送 SIGSEGV → 用户 handler”实现。
- Prot(单页/多页):由 mprotect 对单页/范围实现。
- Unprot:仍由 mprotect 提升权限实现。
- Dirty:多数 Unix 并未直接提供“返回脏页列表”的标准接口;存在替代方案,但语义与成本不同(后文说明)。
- Map(双映射不同权限):并非直接单一调用提供;可用共享内存对象(如 shm_open 后 mmap 两次)等方式绕行,但不一定能达到论文设想的“同一物理页双虚拟地址且不同权限”的简洁语义。
内核如何实现“用户态处理页故障”的上行
这一段是理解整篇讲义的关键:用户态 handler 并不是“直接接管硬件异常”,而是内核将异常转译为信号,把控制流“上行”给用户态。
地址空间的组织:VMA 与页表
现代内核通常把一个进程地址空间分成两层信息:
- VMA(Virtual Memory Area):描述一段连续虚拟地址范围的元数据:起止地址、权限、背后对象(文件、匿名内存、共享内存等)、以及缺页时的处理规则。
- 页表:描述“具体某个虚拟页是否已映射到物理页、物理页是哪一页、权限位是什么”等。
缺页处理时:
- 内核先依据故障地址查到所属 VMA。
- 再依据 VMA 的规则决定:需要从文件读入?需要分配匿名页并清零?访问违规要终止?还是向用户发信号?
从页故障到 sigaction handler 的控制流
典型流程可概括为:
-
CPU 触发页故障,保存用户态现场并进入内核。
-
内核检查故障原因与 VMA 决策:
- 若是合法的按需分页:内核直接补齐映射并返回用户态继续执行。
- 若是权限/映射违规:内核生成信号(如 SIGSEGV),准备将其投递给该进程/线程。
-
信号投递时,内核会安排在用户栈(或专用信号栈)上构造一段“调用用户 handler 的栈帧”,并修改返回路径,使得从内核返回用户态后,先进入用户 handler。
-
用户 handler 获得故障地址与原因(SA_SIGINFO 可提供更丰富信息),可以:
- 自行 mmap/mprotect/munmap 调整映射;
- 或记录信息后终止;
- 或实现“按需填充”策略。
-
handler 返回后,内核恢复现场,再次返回到触发故障的指令处,继续执行或重启该指令。
一个关键实践约束是:handler 如果希望程序继续运行,通常需要对触发故障的页进行 Unprot(提升权限)或建立映射,否则会反复故障,形成死循环。
示例机制:用极少物理页“伪装”超大表
讲义提到一个演示:构造一个看似巨大的 sqrt 查表数组 sqrts[n]。
目标效果:
- 从应用视角:像访问一个巨大数组,查询
sqrts[n]就能得到sqrt(n)。 - 从物理内存视角:并不真正分配/常驻整个表,只需要极少页(演示中甚至可做到单页常驻)。
实现思路(抽象层面):
-
预留一大片虚拟地址区间作为“表”的地址范围(mmap 一段很大 VA)。
-
初始把这些页设为不可访问(mprotect PROT_NONE),或根本不映射。
-
当访问
sqrts[n]触发 SIGSEGV:- handler 根据故障地址计算该元素所在页、对应的 n 范围;
- 临时把某个物理页映射到该 VA 页(或把该 VA 页解保护并填充内容);
- 在该页内生成对应区间的 sqrt 值;
- 然后 Unprot 允许访问继续。
-
如需复用“单页承载任意区间”,还需要在访问不同区间时回收/复写这一个物理页,并重新保护旧页,迫使再次访问时重新填充。
这种设计本质上是把“缓存与按需计算策略”交给用户程序:页故障成为“缓存未命中”的信号,VMA/页权限成为“缓存有效性”的硬件级标记。
经典用例:并发与增量垃圾回收
这一部分解释“为何用户态 VM 原语在语言运行时领域很诱人”。
垃圾回收的基本矛盾
垃圾回收(GC)要解决的问题是:自动回收不再可达的对象,避免显式 free 带来的悬空指针、重复释放、并发安全等问题。
难点在于:
- 程序线程(mutator)在不断读写堆对象;
- 回收线程(collector)需要遍历对象图判断可达性;
- 并发执行会产生竞态:程序线程可能在 collector 扫描时修改指针或对象内容。
传统并发 GC 常需要编译器/运行时在每次读写上插桩(例如写屏障、读屏障),以便在并发下维持不变式。这会带来“每次访问都有额外开销”的问题。
Baker 实时复制算法的核心结构
讲义用 Baker 的实时(real-time)复制收集为例说明:
- 堆划分为 from-space 与 to-space。
- 回收开始:根集合(栈、寄存器、全局根)指向的对象被复制到 to-space,并在 from-space 原对象处留下转发指针(forwarding pointer),指向新副本。
- to-space 再细分为“已扫描”“未扫描”“新分配”等区域;扫描推进意味着不断把未扫描对象引用的对象复制进来。
- 当扫描完成:from-space 全部成为可用空间,完成一次回收。
其优点:
- 分配可非常便宜(指针递增),无需复杂空闲链表。
- 复制带来天然压缩,减少碎片。
- 可增量推进:每次分配或某些访问时推进一点扫描工作,从而降低停顿时间。
其成本与并发难点:
- 程序每次解引用指针时,若该指针仍指向 from-space,需要检查并可能触发复制与更新。这意味着每次访问都要“测试与分支”,成本高。
- 并发下,collector 与 mutator 可能同时发现同一对象需要复制,容易产生“双份副本”等竞态,需要同步策略,复杂且昂贵。
利用虚拟内存避免“每次访问都检查”
论文与讲义提出的“漂亮技巧”是:用页保护把“细粒度的读屏障”变成“粗粒度的页屏障”。
做法的抽象描述:
-
把 to-space 中“未扫描”的部分设为不可访问(或对相关页做保护)。
-
当 mutator 访问到未扫描页中的对象时,会触发页故障:
- handler 或 collector 扫描该页中的对象引用,并完成必要的复制/更新;
- 然后对该页 Unprot,使其恢复可访问。
-
这样,每页最多触发一次故障;故障的成本被摊到一整页内的所有访问上。
-
并发性更容易:collector 线程可以后台扫描页并 Unprot;mutator 线程遇到故障时也可协助扫描。需要同步的主要是“哪一页正在被哪个线程扫描”,而不是对每次对象访问插桩。
从第一性原理看,这等价于把“屏障触发条件”从“每次指针读写”提升到“首次触及某页”这一更稀疏的事件,从而减少常态开销。
现代系统调用是否足够
讲义指出:
- 除了 Map(双映射不同权限)之外,现代 Unix 基本可通过 mmap/mprotect/sigaction 组合出所需能力。
- Map 的缺口可通过共享内存对象(shm_open + 两次 mmap)等绕行,但语义不如论文原语直接。
- 性能层面,关键在“陷入与信号处理的成本”是否可接受:一次故障的开销可能比扫描一页还大;但如果用页粒度把大量访问合并为一次故障,整体仍可能有优势。
- 讲义用不同年代机器的测量对比说明:陷入成本在不同系统上差异很大,现代系统可做到微秒级,但仍是“昂贵事件”,适合被摊销。
脏页追踪原语的缺口与常见替代
论文的 Dirty 原语希望直接得到“自上次调用以来被写过的页集合”。这对检查点、迁移、增量同步等很重要。
现代通用 Unix 上,精确且低成本的“用户态可直接查询的脏页列表”并不普遍,原因在于:
- 脏页状态通常是内核内部的页缓存/页表/回写机制的一部分;
- 暴露精确脏页集合会引入额外的元数据维护、同步与安全隔离问题。
常见替代思路包括(概念层面):
- 写保护捕获写入:对一段内存设为只读;发生写入时触发故障,handler 记录该页为“脏”,然后解除保护允许后续写入。这样可以获得“被写过的页集合”,代价是每页第一次写会付出一次故障成本。
- 内核接口近似查询:某些系统提供驻留/回写状态的查询接口,但语义与“自上次调用以来的写集合”并不等价,且常需额外配合。
讲义倾向强调:Dirty 原语在一九九一年被认为很关键,但在通用 OS API 中并未以同等抽象直接出现,应用通常以“写保护 + 故障记录”的方式在用户态构建等价功能。
现实高价值用例:进程与容器迁移
讲义指出,用户态 VM 技巧在现代基础设施中有一个非常实用的影子:虚拟机/容器/进程的迁移(migration)与检查点恢复(checkpoint/restore)。
基本问题:迁移为何慢
若把进程 P 从机器 M₁ 迁到 M₂,最直观方案是:
- 在 M₁ 停止 P;
- 把 P 的所有状态(寄存器、文件描述符、页表、以及整个内存映像)都拷贝到 M₂;
- 在 M₂ 重启 P。
瓶颈在于:内存映像可能非常大,完整拷贝会产生长时间停顿。
以页故障实现“按需拉取”与“重叠传输”
用户态 VM 思路提供一种将停顿变短的结构化方法:
-
在 M₁ 停止 P,做检查点,但暂不搬完整内存;
-
在 M₂ 启动 P 的检查点恢复版本,但把其内存映像区域先 unmap 或设为不可访问;
-
后台线程逐步从 M₁ 拉取内存页到 M₂;
-
当 P 在 M₂ 运行过程中访问到尚未拉取的页时触发页故障:
- handler 立即从 M₁ 拉取该页并建立映射;
- 访问继续。
这样做的收益是:内存传输与程序运行重叠,停顿时间可显著降低。所需的关键能力正是 Trap 与对多页范围的保护/解除保护。
变体:仍在源机运行时记录修改页
另一种变体是:
-
在 M₁ 仍让 P 运行,同时把内存页复制到 M₂;
-
为了保证一致性,需要追踪“复制之后又被修改的页”:
- 对页做写保护;
- 写入触发故障,记录该页为“需要再发送”,然后解除保护继续运行;
-
切换到 M₂ 时,只需补齐这些“复制后被修改”的页集合。
这正是 Dirty 原语想要直接提供的能力;在现实系统中通常用“写保护 + 故障记录”实现。
对“用户态 VM 原语”的总体评价
从第一性原理出发,可以用三条结论概括讲义的态度:
- 页故障是强大的控制点:它能把“访问内存”变成“可拦截事件”,而事件携带精确的地址信息;这是构建用户态运行时机制的基础。
- 权限位是低成本的状态编码:通过 mprotect/munmap,应用可用硬件权限位表达“是否可访问、是否已处理、是否需要扫描、是否需要拉取”等状态,避免在每次访问上显式检查。
- 性能取决于摊销与频率:一次陷入很贵,但若能做到“每页最多一次”,并把大量常态访问从“每次检查”变成“零开销直接访问”,整体可能更快,尤其在多核并发下更有价值。
同时也要看到限制:
- API 语义层面,Dirty 与 Map(双映射不同权限)在通用 Unix 中并不总能直接得到同等表达力,只能绕行或近似。
- 工程层面,信号处理的复杂性、可重入性、与线程/栈的交互、以及与 JIT/运行时的兼容性,都会显著提高实现难度。
关键术语小结
- 虚拟地址/物理地址:程序看到的是虚拟地址,硬件最终访问物理地址。
- 页表/TLB:页表存映射关系,TLB 缓存映射加速转换。
- 页故障:访问未映射或权限不足的页触发异常,进入内核处理。
- VMA:内核对一段连续虚拟地址范围的元数据描述,决定缺页策略。
- mmap/mprotect/munmap:分别对应建立映射、改权限、撤销映射。
- sigaction + SIGSEGV:把“保护违规/非法访问”的事件上行到用户态 handler。
- 屏障思想:用 VM 的“首次触页故障”替代“每次访问插桩检查”,通过页粒度摊销成本。
设备在哪里以及它们如何连接到 CPU
可以用一个抽象图理解系统结构:
- CPU(执行指令、处理中断)
- 内存(RAM)
- 总线(bus,用于连接并寻址设备)
- 外设(磁盘、网卡、串口 UART 等)
关键点在于:CPU 通过“地址空间”与设备交互。设备并非只能通过特殊指令访问;在常见架构中,设备暴露为一组寄存器,被映射到物理地址空间。
设备编程的基本机制:内存映射输入输出
基本定义
内存映射输入输出(memory-mapped I/O,MMIO):把设备的控制/状态寄存器放进物理地址空间中,使得 CPU 用普通的 load/store 指令即可读写这些寄存器。
第一性原理视角下,MMIO 做了两件事:
- 复用统一的寻址机制:CPU 看到的是“某个物理地址”,具体是 RAM 还是设备寄存器,由硬件互连/总线仲裁决定。
- 以寄存器读写表达控制语义:写某地址可能触发设备开始传输;读某地址可能返回设备状态或数据。
设计权归属
“哪些地址对应哪些设备寄存器”由平台设计者决定(在教学环境中通常由 QEMU 的平台模型决定)。这解释了为什么同样的 CPU 架构在不同平台上,设备地址可能完全不同。
示例设备:UART 的概念模型
UART(Universal Asynchronous Receiver Transmitter)是一种串行通信硬件。你可以把它理解为一个将“并行字节流”与“串行线信号”互相转换的硬件模块。教学环境里常见的 16550 UART 芯片被 QEMU 模拟,用作控制台输入输出。
数据通路与缓冲
一个典型 UART 包含:
- 接收线路(rx wire)与发送线路(tx wire)
- 接收移位寄存器(receive shift register):把串行比特拼成字节
- 发送移位寄存器(transmit shift register):把字节拆成串行比特
- 接收 FIFO 与发送 FIFO:对字节做队列缓冲(例如十六字节深度)
这里的 FIFO 非常关键:它是硬件侧的“生产者/消费者缓冲区”,用来缓解两种速度不匹配:
- 外设线速与 CPU 处理速度不匹配
- 中断响应/调度延迟导致 CPU 不可能在每个字节到达时立刻处理
没有 FIFO 时,CPU 若稍晚响应,中间到达的字节就会丢失;有 FIFO 后,设备可以先积累一批数据等待驱动读取,从而显著降低丢包概率与中断频率。
UART 的寄存器与驱动如何使用它们
寄存器的本质
从驱动角度,UART 是“几组寄存器 + 一些状态位”。这些寄存器位于某段物理地址(例如 UART0 基址),偏移量对应不同寄存器。
典型寄存器语义包括:
- 接收/发送保持寄存器:读取得到接收字节,写入提交待发送字节
- 中断使能寄存器:控制是否允许接收、发送相关事件触发中断
- 线路状态寄存器:报告接收 FIFO 是否有数据、发送 FIFO 是否有空位等
驱动的读写方式
驱动代码本质就是:
- 把寄存器地址当作指针
- 用 volatile 语义的 load/store 进行访问(避免编译器优化破坏时序与可见性)
- 按数据手册定义解释每一位状态的含义
例如“读接收寄存器”可以在抽象上等价于:
- 读取某个 MMIO 地址处的一个字节
这在软件层看起来像“读内存”,在硬件层实际触发的是“从设备寄存器取值”。
设备不就绪:为什么驱动必须处理等待
设备交互有一个不可回避的事实:设备并不总是处于可读/可写状态。
- 读操作:接收 FIFO 为空时,没有数据可读
- 写操作:发送 FIFO 满时,没有空间可写
因此驱动必须围绕“就绪条件”工作,常见就绪信号由状态寄存器提供,例如:
- Data Ready:接收侧有数据
- Transmitter Empty 或 tx FIFO not full:发送侧可写
等待策略:忙等与中断的取舍
忙等的定义与适用条件
忙等(busy loop)是指 CPU 反复读取状态寄存器,直到条件满足:
- 优点:实现简单;无上下文切换;延迟低
- 缺点:浪费 CPU;对“可能长期不就绪”的设备极不友好
忙等仅适用于“几乎可以确定很快就会就绪”的场景。控制台输入显然不满足:用户可能长期不按键,此时忙等会把 CPU 白白烧掉。
中断的动机
中断解决的核心问题是:让设备在“状态变化需要关注”时主动通知 CPU,而不是 CPU 盲目轮询。
在 UART 场景中,常见中断触发条件包括:
- 接收 FIFO 从空变为非空
- 发送 FIFO 从满变为非满(意味着可以继续发送更多字节)
中断并不是“把数据送到 CPU”,而是一个“事件提示”:状态可能变了,请驱动来检查。
内核如何看到中断:从设备到陷阱处理路径
中断与陷阱的统一机制
在 RISC-V 中,中断通常复用陷阱(trap)机制:与系统调用、异常类似,CPU 在硬件层面把控制流转移到预设入口,并记录原因。
关键点:
- 中断可能发生在任意两条指令之间(在允许中断的前提下)
- 中断发生时,需要保存现场(寄存器等),以便处理完返回
PLIC 的角色
在常见 RISC-V 平台里,PLIC(Platform-Level Interrupt Controller)负责:
- 汇聚多个设备的中断源
- 决定把中断投递到哪个 CPU 核
- 提供“领取中断号”和“完成中断”的寄存器接口
可以用一条逻辑链理解:
- 设备发起中断信号
- PLIC 记录并仲裁
- CPU 进入陷阱入口
- 内核查询中断来源并分发到具体驱动处理函数
驱动侧常见模式是:
- 读取 scause 判断是否为外部设备中断(高位常用于区分中断与异常)
- 调用 plic_claim 获取 IRQ 编号(例如 UART 的 IRQ)
- 根据 IRQ 调用对应设备的中断处理例程
- 调用 plic_complete 告诉 PLIC 该 IRQ 已处理完毕
中断只是提示:为什么仍要读状态寄存器
务必把这条原则当作驱动的基本定律:
- 中断通常只是提示“状态可能改变了”
- 真实状态只能以设备状态寄存器为准
原因包括:
- 一个中断可能对应多个待处理条件(例如既有接收也有发送可继续)
- 中断可能合并、延迟或在你处理时状态又发生变化
- 有些设备会在一次中断中积累多条数据,需要循环读取直到状态清空
因此 UART 中断处理通常会:
- 读线路状态寄存器
- 若接收就绪则从接收寄存器取字节,重复直到 FIFO 为空
- 若发送可继续则从软件缓冲取更多字节写入发送寄存器,重复直到 FIFO 满
使能中断的双重条件:设备侧与 CPU 侧
中断能否发生,通常同时受两类开关控制:
- 设备侧开关:例如 UART 的中断使能寄存器,决定接收/发送事件是否产生中断
- CPU 侧开关:例如 RISC-V 的全局中断使能位(如 SSTATUS 中的相应位),决定 CPU 是否接受中断进入陷阱
这解释了为何内核初始化时既要配置 UART,也要在合适时机打开 CPU 的中断响应;同时在某些临界区又必须暂时关闭中断,避免破坏内核不变式。
控制台读输入的协作模型:系统调用与中断的配合
控制台输入是理解“并发协作”的典型样例。你可以用“生产者/消费者缓冲”来统一解释:
- 生产者:UART 中断处理路径(把接收字节放入软件缓冲)
- 消费者:进程在 read 系统调用中从软件缓冲取走字节
中间媒介是内核中的控制台缓冲区(例如 cons.buf 以及读写指针 r、w)。其语义是典型环形队列:
- 中断到来时写入 w 并推进
- read 路径读取 r 并推进
- 缓冲为空时,read 路径睡眠
- 中断写入后唤醒睡眠的读者
这里的 sleep/wakeup 不是“忙等”,而是把 CPU 让给其他任务,并在条件满足时由内核调度唤醒,从而实现高效等待。
陷阱入口与内核栈:中断发生时到底用哪个栈
当中断发生时,CPU 必须有一个可用栈来保存现场并执行内核代码。关键依赖是:
- 如果中断发生在用户态,通常通过 trampoline 进入内核,并切换到当前进程的内核栈
- 如果中断发生在内核态,必须保证当前正在使用的栈是有效的(例如当前进程内核栈,或调度器专用栈)
因此内核通常有“内核态陷阱入口”(例如 kernelvec 一类的入口汇编),负责:
- 保存寄存器到当前栈
- 构造调用约定
- 跳转到 C 语言的统一陷阱处理函数(例如 kerneltrap)
- 处理完毕后恢复寄存器并返回
这也是为什么“内核里允许中断”会引入复杂性:你必须确保在允许中断的任何时刻,栈指针、页表等关键上下文都是一致且可用的。
设备驱动的通用结构:上半部与下半部
把驱动拆成两部分是理解并发与正确性的最常用框架:
下半部
- 由进程上下文触发(系统调用路径,例如 read/write)
- 负责发起 I/O 或消费/提交数据
- 可能需要等待条件(例如等待输入到达、等待输出完成)
- 典型动作:检查软件缓冲,空则 sleep;有则拷贝给用户
上半部
- 由中断触发(不在某个特定进程上下文中)
- 负责与硬件寄存器交互:取输入、推进输出
- 通过共享缓冲与 sleep/wakeup 与下半部“松耦合”协作
- 不能假设它打断的是哪个进程,甚至可能运行在另一个 CPU 核上
上半部与下半部的交互原则是“保持克制”:
- 不直接依赖对方的控制流
- 通过缓冲、状态位、唤醒机制传递信息
- 并发访问共享数据时,必须有同步策略(至少关中断或加锁;多核时更依赖锁)
多设备同时中断:分发、领取与完成
当多个设备几乎同时发出中断:
- PLIC 负责仲裁并分发到各 CPU 核
- 哪个核先调用 claim,就“领取”某个 IRQ 的处理权
- 每个设备在任一时刻通常只允许一个“未完成的中断”处于活跃状态
- 处理完后必须 complete,PLIC 才会认为该 IRQ 已清结,可以再次投递
这套协议的第一性原理是:避免重复处理与丢失处理,并为多核并行提供明确的所有权划分。
中断与并发的核心风险:任意指令间打断导致的不一致
只要中断被允许,它就可能发生在任意两条指令之间。考虑一个看似无害的操作:
- 共享变量 n 自增
在机器指令层,它往往拆成:
- 从内存加载 n 到寄存器
- 寄存器加一
- 写回内存
如果在“加载之后、加一之前”发生中断,而中断处理程序也对 n 做同样的自增,就会出现丢失更新:两次自增最终只体现一次。
解决思路分层看待:
- 单核场景:短临界区可通过临时关闭中断避免被同核中断打断
- 多核场景:另一个核仍可并发修改共享变量,因此仅关中断不够,必须引入锁等跨核同步机制
关闭中断期间,中断去了哪里
当 CPU 侧全局中断使能关闭时:
- 设备仍可向 PLIC 发起中断
- PLIC 与 CPU 会把它记录为 pending(待处理)
- 一旦重新打开中断,CPU 会在合适的时机接收并进入处理路径
因此,关闭中断并不等于“丢中断”,而是“延迟交付”。这也是内核敢于在短临界区关中断的前提:只要时间足够短,就不会造成不可接受的响应延迟。
缓冲与批处理:生产与消费解耦的通用原则
驱动里极其常见的一条工程原则是:把生产与消费解耦,通过缓冲匹配速度。
输入方向:
- 数据可能在读者未等待时到达
- 数据可能到达速度高于读者处理速度
- 缓冲可以吸收突发流量,减少丢失,并允许批量处理
输出方向:
- 设备可能很慢,若进程每写一个字节就同步等待,会极大降低吞吐并浪费 CPU
- 缓冲输出可以让进程尽快返回,后台逐步把数据送入设备
- 对高速设备,批量写入也更高效(减少寄存器交互与中断频率)
你已经在两层看见了这个原则:
- 硬件层:UART 内部 FIFO
- 内核层:控制台软件环形缓冲区
后续在管道、网络等机制中会反复出现同类结构。
中断开销与高频事件:为什么有时要改用轮询
中断不是免费机制。一次中断需要:
- CPU 进入陷阱
- 保存寄存器与关键状态 -(可能)切换页表与栈相关上下文
- 判断来源与分发到具体设备
- 处理完成后恢复现场并返回
现代处理器流水线更深、寄存器更多、缓存与 TLB 状态更复杂,使得“仅仅为了通知而付出的固定成本”可能达到微秒量级。对低频事件这完全可接受;对网络包这类高频事件就可能不成立。
因此出现了另一种事件通知策略:轮询(polling)。
轮询的核心思想是:
-
关闭该设备的中断(或降低中断频率)
-
在固定时机检查设备是否有积累事件
- 例如在调度器循环中
- 或在定时器中断中顺带检查
-
每次检查时批量处理“自上次以来积累的所有事件”
当事件率很高时,轮询往往比中断更高效,因为它减少了陷阱进入次数,把工作集中批处理,显著降低“通知成本占比”。更进一步,实际系统常会根据测得的事件率在“中断模式”和“轮询模式”之间切换。
DMA:把数据搬运从 CPU 旁路出去
UART 驱动示例里,CPU 是一字节一字节从 MMIO 寄存器读写。这种方式对高速设备并不合适:
- MMIO 常不可缓存,访问代价高
- 粒度太小,CPU 开销大
- 总线交互频繁,效率低
高速设备通常使用 DMA(direct memory access):
- 设备硬件直接把一大块数据写入 RAM(或从 RAM 读出)
- CPU 只需要设置 DMA 描述符/缓冲区指针
- DMA 完成后设备再发中断通知
- CPU 随后在普通 RAM 中处理数据(缓存友好、吞吐高)
这体现了“现代中断演化”的方向:硬件更智能、每次中断携带更多工作成果,从而减少中断次数与固定开销占比。
现代驱动体系的延伸方向
在真实系统中,围绕中断与驱动还有持续演进的主题:
- 更快的陷阱路径(针对系统调用、中断、缺页等高频路径做极致优化)
- 更强的多核并行处理(把不同设备或不同队列分散到不同 CPU)
- 用户态驱动(在安全与隔离允许时绕开内核路径,减少内核态开销与复杂性)