MIT--6-S081-操作系统工程笔记-全-
MIT 6.S081 操作系统工程笔记(全)


课程 P1:操作系统导论与示例 🖥️
在本节课中,我们将要学习操作系统的基本概念、目标以及其核心组成部分。我们将通过一个名为XV6的教学操作系统,了解操作系统如何为应用程序提供硬件抽象、资源复用和隔离等服务。课程将重点介绍内核的作用、系统调用的概念,并通过简单的代码示例展示应用程序如何与操作系统交互。
概述
操作系统是管理计算机硬件与软件资源的系统软件。它的核心目标包括抽象硬件、在多个应用程序间复用资源、提供隔离与安全,并支持高性能和广泛的应用程序。本节课将介绍这些目标,并概述操作系统的经典内部结构。
操作系统的主要目标 🎯
尽管存在多种不同的操作系统,但它们通常共享一套共同的核心目标。以下是操作系统需要完成的主要任务:
- 抽象硬件:操作系统在底层硬件(如CPU、内存)之上,为应用程序提供高级、便捷且可移植的接口,例如进程和文件系统。
- 复用硬件:操作系统允许多个应用程序同时运行,共享CPU、内存等硬件资源,而不会相互干扰。
- 提供隔离:操作系统确保同时运行的多个活动(进程)不会无意间相互干扰,即使某个程序存在缺陷。
- 控制共享:操作系统在允许进程间按需协作和共享(如读写文件)的同时,也能在用户不希望共享时(如保护个人文件)实施安全或访问控制。
- 实现高性能:操作系统必须确保其提供的服务不会妨碍应用程序充分利用硬件性能,甚至需要帮助应用程序获得良好性能。
- 支持广泛的应用:同一个操作系统(如Linux)需要能够支持从文本处理、游戏到数据库服务器、云计算等完全不同的任务。
操作系统的经典结构 🏗️
上一节我们介绍了操作系统的目标,本节中我们来看看操作系统内部是如何组织以实现这些目标的。一种经典且常见的组织方式如下图所示:

计算机系统可以划分为两个主要部分:
- 用户空间:这是各种应用程序(如文本编辑器、编译器、Shell)运行的世界。
- 内核:这是一个特殊的、始终运行的程序,作为计算机资源的守护者。内核维护管理进程、内存、硬件接口所需的数据结构,并提供核心服务。
内核提供的核心服务包括:
- 进程管理:管理每个正在运行的程序(称为进程),包括其内存和CPU时间。
- 内存管理:在多进程间复用、分割和分配内存。
- 文件系统:管理文件名、目录结构和文件内容在磁盘上的存储位置。
- 访问控制:决定进程是否有权访问特定资源(如文件、内存)。
在一个成熟的操作系统中,内核还包含许多其他服务,如进程间通信、网络协议栈(如TCP/IP)以及大量硬件设备的驱动程序。
系统调用:应用程序与内核的桥梁 🌉
我们了解了内核提供的服务,那么应用程序如何请求这些服务呢?这是通过系统调用实现的。系统调用看起来像是普通的函数调用,但实际上会跳转到内核中执行特定的代码。
以下是几个系统调用的示例:
- 打开文件:
fd = open("output.txt", O_WRONLY|O_CREATE);- 这个调用请求内核打开(或创建)名为“output.txt”的文件用于写入。
open是系统调用名,它返回一个称为文件描述符的整数句柄(fd),用于在后续操作中引用这个打开的文件。
- 这个调用请求内核打开(或创建)名为“output.txt”的文件用于写入。
- 写入文件:
write(fd, "hello\n", 6);- 这个调用请求内核将缓冲区中的6个字节(字符串“hello\n”)写入到文件描述符
fd所引用的文件中。
- 这个调用请求内核将缓冲区中的6个字节(字符串“hello\n”)写入到文件描述符
- 创建新进程:
pid = fork();- 这个调用创建当前进程的一个副本(称为子进程)。在父进程中,
fork()返回新创建子进程的ID;在子进程中,fork()返回0。这使得父进程和子进程可以根据返回值执行不同的代码路径。
- 这个调用创建当前进程的一个副本(称为子进程)。在父进程中,
系统调用是特殊的,因为当执行系统调用时,代码会从无特权的用户模式跳转到有特权的内核模式,使得内核代码能够直接访问和操作受保护的硬件资源。
课程结构与示例程序演示 📚
本课程将结合理论讲解和动手实践。我们将深入研究XV6这个小型教学操作系统的代码,并通过一系列实验来扩展和修改它。课程成绩主要基于实验完成情况。
现在,让我们回到技术内容,通过一些运行在XV6上的简单程序,具体看看系统调用是如何被使用的。
示例1:文件复制程序
以下是一个简单的程序,它从标准输入读取数据并写入标准输出。
// copy.c 简化示例
char buf[64];
int n;
while((n = read(0, buf, sizeof(buf))) > 0) {
write(1, buf, n);
}
read(0, buf, n):系统调用,从文件描述符0(标准输入)读取最多n字节数据到缓冲区buf。返回实际读取的字节数。write(1, buf, n):系统调用,将缓冲区buf中的n字节数据写入文件描述符1(标准输出)。- 当
read返回0时,表示到达输入末尾,循环结束。
示例2:创建新进程(fork)
下面的程序演示了如何使用 fork 系统调用创建新进程。
// fork.c 简化示例
int pid = fork();
if(pid == 0) {
printf("child\n");
} else {
printf("parent\n");
}
运行此程序,由于父进程和子进程同时运行,它们的输出“child”和“parent”可能会交织在一起,这直观地展示了并发执行。
示例3:执行新程序(exec)
fork 创建的是当前进程的副本。如果我们想运行一个全新的程序,需要使用 exec 系统调用。
// exec.c 简化示例
char *argv[] = { "echo", "this", "is", "echo", 0 };
exec("echo", argv);
printf("exec failed!\n");
exec 会用指定文件(如“echo”)中的指令和数据替换当前进程的内存,然后开始执行新程序的指令。如果 exec 成功,它不会返回(因为原进程的代码已被替换);只有失败时(如找不到文件)才会返回。
示例4:Shell的经典组合(fork + exec + wait)
Shell(命令行解释器)运行命令的典型模式是:fork 一个子进程,在子进程中调用 exec 来执行目标命令,而父进程则调用 wait 等待子进程结束。
// forkexecwait.c 简化示例
int pid = fork();
if(pid == 0) {
// 子进程
char *argv[] = { "echo", "hello", 0 };
exec("echo", argv);
printf("exec failed!\n");
exit(1);
} else {
// 父进程
wait(0); // 等待子进程结束
printf("child finished\n");
}
这种模式使得Shell在运行命令后,能重新获得控制权并等待用户输入下一条命令。
示例5:I/O重定向的实现
Shell支持像 echo hello > output.txt 这样的I/O重定向。这是通过在 fork 和 exec 之间,修改子进程的文件描述符来实现的。
// redirect.c 简化示例
int pid = fork();
if(pid == 0) {
// 子进程
close(1); // 关闭标准输出
open("output.txt", O_WRONLY|O_CREATE); // 打开文件,由于fd 1空闲,新文件会获得fd 1
char *argv[] = { "echo", "hello", 0 };
exec("echo", argv);
}
在子进程中,我们先关闭文件描述符1(标准输出),然后打开文件“output.txt”。根据规则,open 会返回当前最小的未使用文件描述符,即1。随后,当 exec 执行 echo 时,echo 程序照常向文件描述符1写入,但其输出就被重定向到了文件中,而 echo 程序本身对此一无所知。
总结


本节课中我们一起学习了操作系统的基本目标和经典结构。我们了解到内核是系统的核心,通过系统调用为应用程序提供服务。我们通过XV6操作系统的示例,探索了几个关键的系统调用:read/write 用于I/O,fork 用于创建进程,exec 用于执行新程序,wait 用于进程同步,以及如何组合它们来实现像Shell和I/O重定向这样的强大功能。这些接口虽然看起来简单(主要传递整数和指针),但其内部机制却非常精妙,能够有效地管理硬件资源并支持复杂的应用协作。在接下来的实验和课程中,我们将深入这些机制的内部实现。


课程11:线程切换 🧵
在本节课中,我们将学习操作系统中的核心概念——线程切换。我们将探讨为什么需要线程、线程是什么、以及xv6操作系统是如何实现线程切换的。通过理解保存和恢复线程状态、调度器的工作方式以及上下文切换的底层机制,你将能够掌握多任务处理的基本原理。
概述:为什么需要线程?🤔
人们希望计算机能够同时处理多项任务。这支持了时间共享,允许多个用户同时登录并运行进程。即使在单用户设备上,我们也期望它能运行多个不同的进程。线程简化了程序结构,有时是帮助程序员组合简单程序、降低复杂性的优雅方法。例如,在第一个实验的素数筛中,虽然没有直接使用线程,但使用了多个进程来构建软件。最后,线程允许程序的不同部分在不同核心上并行运行,从而可能提升性能。
什么是线程?🧶
线程是一种简化编程的抽象,用于处理多个任务。你可以将线程看作是一个单一的串行执行流。如果你编写一个按顺序做事的程序,那么该程序就是一个单一的控制线程。线程的状态很重要,因为我们需要保存并在以后恢复它。线程状态最重要的部分是它的程序计数器,它指示了执行的位置。此外,由于编译器生成代码的方式,每个线程通常都有自己的栈,用于记录函数调用,反映当前执行点。
线程系统的职责 🏗️
xv6的线程系统负责管理多个线程的交错执行。我们希望系统能够处理数十、数百甚至数千个线程,并让它们都取得进展。主要有两种策略来实现这种交错:
- 多CPU/多核:每个CPU核心运行自己的线程。每个线程自动拥有自己的程序计数器和寄存器。
- 线程切换:如果一个CPU需要运行多个线程,系统会在不同线程之间切换。它会保存当前线程的状态,切换到另一个线程执行一段时间,然后再切换回来。
xv6结合了这两种策略,在所有可用核心上运行线程,并且每个核心会在其管理的线程之间进行切换,因为通常线程数量远多于CPU核心数量。

线程与内存共享 🔗
不同的线程系统或实例的区别在于线程是否共享内存。
- 共享内存线程:多个线程在同一个地址空间内执行,可以看到彼此对内存的修改。xv6内核线程就是共享内存的例子,它们共享内核内存。
- 非共享内存线程:每个线程有自己的独立地址空间。xv6的用户进程就是如此,每个进程只有一个线程,且进程间不共享内存。
像Linux这样的系统允许单个用户进程内存在多个共享内存的线程,这需要更复杂的内核支持。
实现线程系统的挑战 ⚙️
要实现一个线程系统,我们需要解决几个高级挑战:
- 如何实现切换:如何实际实现线程间的切换,以实现交错执行。这个决策过程通常被称为调度。
- 如何保存和恢复状态:当停止一个线程并想在以后恢复时,需要决定保存哪些状态以及如何保存。
- 如何处理计算密集型线程:如果一个线程在进行长时间计算而不主动让出CPU,我们需要一种机制来自动中断它,以便其他线程可以运行。
抢占式调度与计时器中断 ⏰
处理计算密集型线程的关键机制是计时器中断。每个CPU核心都有一个硬件计时器,会周期性地产生中断(例如每10毫秒)。即使一个用户程序在运行无限循环,计时器中断也会将控制权从用户代码强制转移到内核中的中断处理程序。
- 抢占式调度:计时器中断“抢占”当前运行的线程,即使该线程不想主动放弃CPU。内核的中断处理程序会将CPU交还给调度器,让调度器决定运行哪个线程。
- 线程状态:我们需要区分线程的不同状态:
- RUNNING:线程正在某个CPU核心上执行。
- RUNNABLE:线程当前未在执行,但其状态已保存,并且希望尽快运行。
- SLEEPING:线程正在等待某些I/O事件,事件发生后才会变为RUNNABLE。
计时器中断所做的就是将当前RUNNING的线程转变为RUNNABLE状态。


xv6中的线程切换全景图 🖼️
在xv6中,线程切换不是直接从用户进程切换到另一个用户进程,而是通过内核间接完成的。以下是更完整的流程:
假设我们有两个进程:进程1(正在运行)和进程2(可运行但未运行),以及两个CPU核心。
- 计时器中断:CPU 0上的计时器中断迫使控制权从进程1的用户空间转移到内核。
- 保存用户状态:蹦床代码将进程1的用户寄存器保存到其陷阱帧中。
- 内核处理:执行内核的中断处理代码(
usertrap)。 - 调用 yield:内核决定让出CPU,调用
yield函数。 - 获取进程锁:
yield获取进程1的锁,防止其他核心的调度器在此过程中查看或运行该进程。 - 状态变更:将进程1的状态从 RUNNING 改为 RUNNABLE。
- 调用 sched:
yield调用sched函数。 - 调用 switch:
sched调用switch函数,这是核心步骤。switch保存当前内核线程(进程1的内核线程)的寄存器到进程1的context结构中。- 然后,它恢复CPU 0的调度器线程之前保存的寄存器(存储在
cpu结构中)。 - 这导致CPU开始执行调度器线程的代码,并从
switch返回到调度器函数(scheduler)中。
- 调度器运行:现在CPU 0运行的是调度器线程。它释放进程1的锁,并在进程表中查找下一个可运行的进程(例如进程2)。
- 再次切换:调度器找到进程2后,获取进程2的锁,将其状态设为 RUNNING,记录当前CPU正在运行进程2,然后再次调用
switch。- 这次,
switch保存调度器线程的寄存器到CPU的context中。 - 恢复进程2之前保存的内核线程寄存器(从其
context中)。 - 这导致CPU开始执行进程2的内核线程,并从进程2上次调用
switch的地方返回(可能是某个系统调用或中断处理代码中)。
- 这次,
- 返回用户空间:进程2的内核线程完成其工作后,通过恢复其陷阱帧中保存的用户寄存器,返回到进程2的用户空间继续执行。
关键点:
- 每个进程结构(
struct proc)中都有一个context结构,用于保存其内核线程的寄存器。 - 每个CPU结构(
struct cpu)中也有一个context结构,用于保存其调度器线程的寄存器。 - 线程切换的核心就是
switch函数,它通过保存和恢复寄存器来改变CPU的执行流。
代码剖析:switch 函数 💻
switch 函数是线程切换的核心,用汇编语言编写。它执行以下操作:
# 参数:a0 = 当前线程的 context 结构指针
# a1 = 目标线程的 context 结构指针
switch:
sd ra, 0(a0) # 保存返回地址
sd sp, 8(a0) # 保存栈指针
sd s0, 16(a0) # 保存被调用者保存寄存器 s0-s11
...
sd s11, 104(a0)
ld ra, 0(a1) # 恢复目标线程的返回地址
ld sp, 8(a1) # 恢复目标线程的栈指针
ld s0, 16(a1) # 恢复目标线程的寄存器 s0-s11
...
ld s11, 104(a1)
ret # 返回到目标线程的 ra 地址处
为什么只保存部分寄存器?
switch 被C代码调用。根据RISC-V调用约定,寄存器分为调用者保存(caller-saved)和被调用者保存(callee-saved)。编译器生成的C代码会负责在调用函数前保存调用者保存的寄存器(如临时寄存器)。因此,switch 只需要保存那些被调用者需要保存的寄存器(s0-s11、ra、sp),这些寄存器在函数调用后必须保持不变。
程序计数器(PC)呢?
不需要显式保存PC。因为当调用 switch 时,我们知道执行点就在 switch 函数内部。真正需要保存的是 ra(返回地址),它指示了从 switch 返回后应继续执行的位置。
锁的作用:保护切换过程 🔒
进程锁(p->lock)在切换过程中至关重要,它确保状态转换的原子性,防止竞态条件。
- 在让出CPU时(yield):获取锁后,进程状态被改为 RUNNABLE,寄存器被保存到
context。在完成这些步骤并实际调用switch切换到调度器之前,锁防止了其他核心的调度器看到这个处于“部分让出”状态的进程并错误地运行它。 - 在开始运行进程时(scheduler):调度器在找到一个新进程后,会先获取该进程的锁。然后设置状态为 RUNNING,调用
switch恢复其寄存器。在这个过程中,锁防止了计时器中断在该进程寄存器尚未完全恢复时就中断它,否则可能导致保存不完整的寄存器状态。
其他触发切换的场景 🔄
除了计时器中断,线程切换还可能由以下情况触发:
- 系统调用等待I/O:例如,一个进程执行
read系统调用等待磁盘数据或管道数据。如果数据未就绪,内核会将该进程置于 SLEEP 状态,并调用yield让出CPU。 - 进程退出:进程执行
exit系统调用时,也会最终调用yield让出CPU。
在这些情况下,切换的底层路径(经过 yield -> sched -> switch)与计时器中断是相似的。
总结 📚
本节课我们一起深入学习了线程切换机制。我们了解到:
- 线程是串行执行流,线程切换是实现多任务的核心。
- xv6通过计时器中断实现抢占式调度,强制收回CPU控制权。
- 线程切换是间接的,总是从用户态进入内核态,在内核线程间切换,再返回用户态。
- 切换的核心是
switch汇编函数,它通过保存和恢复一组被调用者保存的寄存器来实现执行流的跳转。 - 进程锁(
p->lock)保护了切换过程中关键状态的原子性变更。 - 上下文(
context)结构分别保存在进程结构(用户进程内核线程)和CPU结构(调度器线程)中,用于存储线程挂起时的寄存器状态。

理解这些概念是理解现代操作系统如何管理并发和执行多任务的基础。
📚 课程 P11:第12讲 - 问答环节 #2 (COW 实验) 🐄
在本节课中,我们将一起探讨 xv6 操作系统中“写时复制”(Copy-on-Write, COW)实验的实现细节、遇到的常见问题以及调试策略。我们将跟随讲师的思路,从实验的初始状态开始,一步步修改代码,处理页面错误,管理引用计数,并最终使测试通过。

🎯 概述
“写时复制”的目的是避免在 fork 操作时立即复制父进程的所有内存页面,从而降低开销。其核心思想是:父子进程最初共享所有物理页面,并将它们标记为只读。当任一进程尝试写入某个共享页面时,会触发页面错误(page fault),内核此时再为该进程分配一个新的物理页面并复制数据,然后修改页表映射,最后恢复页面的可写权限。
本教程将详细拆解实现 COW 的步骤,包括修改 fork 逻辑、处理页面错误、管理页面引用计数以及调整 copyout 系统调用。
🔧 第一步:修改 fork 以避免立即复制内存
上一节我们介绍了 COW 的基本概念。本节中,我们来看看如何修改 fork 系统调用的实现。
在原始的 xv6 中,fork 通过 uvmcopy 函数为子进程复制父进程的每一个内存页面。为了实现 COW,我们需要改变这个行为:子进程不应获得父进程页面的新副本,而应直接映射到相同的物理页面,并将这些页面标记为只读。
以下是修改 uvmcopy 函数的关键步骤:
- 删除分配新物理页面(
kalloc)和复制内存内容(memmove)的代码。 - 改为直接将父进程的页表条目(PTE)复制给子进程。
- 清除父进程和子进程页表条目中的
PTE_W(可写)标志位,以启用写保护。
// 在 uvmcopy 函数中,替换原有的分配和复制逻辑
// 删除以下代码:
// if((mem = kalloc()) == 0)
// goto err;
// memmove(mem, (char*)pa, PGSIZE);
// 改为直接映射父进程的物理页面:
if(mappages(new, i, PGSIZE, pa, flags) != 0){
goto err;
}
// 清除父进程和子进程 PTE 中的可写标志
*pte &= ~PTE_W;
// 设置一个标志(例如 PTE_COW)以标识这是一个 COW 页面(如果需要)
// flags 变量也需要相应调整,以便子进程映射时也使用只读权限
注意:我们需要同时修改父进程和子进程中对应页面的权限,确保双方都无法直接写入,从而在写入时触发页面错误。
⚠️ 第二步:处理写保护页面错误
当我们成功将共享页面设置为只读后,任何写入尝试都会导致页面错误。接下来,我们需要在内核中捕获并处理这种特定的错误。
在 xv6 中,页面错误会引发陷阱(trap),并在 usertrap() 函数中处理。我们需要在这里添加对 scause 寄存器值为 15(Store/AMO page fault)的检查。
以下是处理 COW 页面错误的核心步骤:
- 在
usertrap()中判断陷阱原因是否为存储页面错误。 - 调用一个专门的处理函数(例如
cowfault())。 - 在
cowfault()中:- 检查发生错误的虚拟地址是否合法(例如,在用户地址空间内,且具有有效的 PTE)。
- 检查该 PTE 是否确实指向一个 COW 页面(例如,通过检查
PTE_W为 0,并可能有一个自定义的PTE_COW标志)。 - 为该进程分配一个新的物理页面(
kalloc)。 - 将旧页面的数据复制到新页面(
memmove)。 - 更新当前进程的页表,将虚拟地址映射到新的物理页面,并设置正确的权限(
PTE_W置位,PTE_COW清除)。 - 减少旧物理页面的引用计数(下一节详述)。
- 如果处理成功,则返回并恢复用户进程执行;如果失败(例如内存不足),则终止该进程。
// 在 usertrap() 函数中添加
if(r_scause() == 15) { // Store/AMO page fault
uint64 va = r_stval(); // 获取出错的虚拟地址
if(cowfault(p->pagetable, va) < 0) {
p->killed = 1; // 处理失败,终止进程
}
}
📊 第三步:管理物理页面的引用计数
由于一个物理页面可能被多个进程共享(通过它们的页表映射),我们需要跟踪每个页面的引用数量。只有当最后一个引用者释放该页面时,才能真正将其归还给空闲链表。
我们需要实现一个全局的引用计数数组 ref_count[PHYSTOP / PGSIZE]。
以下是管理引用计数的关键操作:
- 初始化:在
kalloc()分配页面时,将其引用计数设为 1。 - 增加计数:在
fork中,当子进程共享一个页面时,增加该页面的引用计数(ref_count[PA2IDX(pa)]++)。 - 减少计数:在以下位置减少引用计数:
kfree():释放页面时,先减少计数。只有当计数降为 0 时,才执行实际的释放操作(将页面放回空闲链表)。cowfault():当为一个 COW 页面创建副本后,减少原页面的引用计数。proc_freepagetable()或uvmunmap():当进程退出或调用exec释放其所有内存映射时,需要遍历页表,减少每个映射到的物理页面的引用计数。
- 并发保护:对引用计数数组的修改必须使用锁(例如
kmem.lock)来保护,防止多核同时操作导致数据竞争。
// 示例:修改后的 kfree 函数
void
kfree(void *pa)
{
struct run *r;
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
acquire(&kmem.lock);
int idx = PA2IDX(pa);
if(ref_count[idx] <= 0) panic("kfree: ref count <= 0");
ref_count[idx]--; // 减少引用计数
int ref = ref_count[idx];
release(&kmem.lock);
if(ref > 0) {
return; // 还有其他引用,不实际释放
}
// 引用计数为 0,执行实际释放
memset(pa, 1, PGSIZE);
r = (struct run*)pa;
acquire(&kmem.lock);
r->next = kmem.freelist;
kmem.freelist = r;
release(&kmem.lock);
}
注意:在系统启动初期初始化空闲内存链表时(freerange),所有页面的引用计数应初始化为 0。
🔄 第四步:修改 copyout 系统调用
copyout 函数在内核态将数据复制到用户空间。它直接通过用户页表将虚拟地址转换为物理地址,然后进行内存拷贝。这个过程绕过了 MMU,因此不会触发我们设置的写保护页面错误。
为了解决这个问题,我们需要修改 copyout(以及类似的 copyin),让它在复制数据到用户空间时,也能检查目标页面是否是 COW 页面。如果是,则需要像处理页面错误一样,先执行复制操作。
修改思路如下:
- 在
copyout中,不是简单地将用户虚拟地址walk成物理地址,而是需要获取其 PTE。 - 检查该 PTE 是否指向一个受写保护的 COW 页面。
- 如果是,则调用与
cowfault类似的逻辑,确保该进程拥有一个可写的私有副本。 - 获取新的物理地址,然后进行数据复制。
// 在 copyout 循环内部修改地址转换部分
pte_t *pte;
pa0 = walkaddr(pagetable, va0); // 原来的方式
// 改为:
pa0 = walkaddr_cow(pagetable, va0, &pte); // 自定义函数,返回物理地址和 PTE
if(pa0 == 0 || is_cow_page(*pte)) { // 检查是否是 COW 页
if(cowfault(pagetable, va0) < 0) return -1;
pa0 = walkaddr(pagetable, va0); // 重新获取新的物理地址
}
// 然后使用 pa0 进行 memmove
🐛 第五步:调试策略与常见问题
在实现 COW 的过程中,你可能会遇到各种错误。以下是一些有效的调试策略:
- 小步前进:每次只实现一小部分功能(例如 5-10 行代码),然后立即编译、运行测试,确保它能工作,再继续下一步。
- 利用打印语句:在关键位置(如
kalloc,kfree,cowfault,uvmcopy)添加printf语句,输出引用计数、物理地址、虚拟地址等信息,帮助理解程序状态和定位问题。 - 理解测试输出:仔细阅读测试失败的信息。例如,
cowtest中的simple测试失败,很可能是因为fork后内存不足,这提示我们uvmcopy的修改可能未生效。 - 检查常见错误点:
- 非法指令错误:这通常意味着进程的指令页面被意外修改(例如,在
exec中错误地释放了仍被共享的页面)。检查引用计数的减少逻辑,确保不会过早释放仍在使用的页面。 - 内存耗尽:检查引用计数的增加逻辑是否遗漏。例如,在
fork共享页面时,是否忘记了增加计数? - 文件测试失败:如
filetest中读取失败,可能是因为copyout没有正确处理 COW 页面,导致数据写入到了其他进程共享的页面中。
- 非法指令错误:这通常意味着进程的指令页面被意外修改(例如,在
📝 总结
本节课中,我们一起学习了 xv6 操作系统“写时复制”(COW)功能的完整实现流程:
- 我们首先修改了
fork系统调用,让子进程共享父进程的物理页面,并将它们标记为只读,从而避免了初始的内存复制开销。 - 接着,我们修改了陷阱处理逻辑,以捕获因写入只读页面而引发的存储页面错误,并在错误处理程序中为进程分配和复制所需的私有页面。
- 为了管理共享页面的生命周期,我们引入了物理页面引用计数机制,并修改了
kalloc和kfree来维护这个计数。 - 最后,我们调整了
copyout系统调用,确保内核向用户空间复制数据时,也能正确处理 COW 页面。

实现 COW 是一个综合性的练习,它涉及进程管理、内存管理、中断处理和并发控制等多个操作系统核心概念。通过一步步地构建、测试和调试,我们不仅完成了实验功能,也加深了对这些核心机制协同工作的理解。

课程 P12:Lecture 13 - 睡眠与唤醒 🛌⏰
在本节课中,我们将要学习操作系统中的核心协调机制——睡眠与唤醒。我们将探讨为什么需要这种机制,如何实现它,以及如何避免一个常见但棘手的问题——“丢失的唤醒”。我们还会了解进程如何优雅地退出和被终止。
概述
上一节我们介绍了线程切换的机制和约束。本节中,我们来看看当线程需要等待特定事件(如I/O完成或数据可用)时,操作系统如何协调它们。睡眠与唤醒是解决此类协调问题的基本工具。
1. 线程切换的要点回顾
在深入睡眠与唤醒之前,有必要回顾一下线程切换的两个关键约束,它们对设计协调方案至关重要。

- 持有进程锁:在xv6中,当一个线程调用
switch切换到调度器线程时,它必须持有自身的进程锁(p->lock)。调度器线程会在切换完成后释放这把锁。这确保了在切换过程中,其他核心上的调度器不会误认为此线程可运行并尝试执行它,从而避免两个核心使用同一个线程栈导致的崩溃。 - 禁止持有其他自旋锁:线程在调用
switch时,除了自身的进程锁外,不允许持有任何其他自旋锁。这是为了防止死锁。例如,假设只有一个CPU核心,进程1持有锁A并调用switch让出CPU。调度器选择进程2运行,而进程2也试图获取锁A。由于锁A被进程1持有,进程2将在自旋等待中永远阻塞,无法让出CPU,而进程1也因无法被调度而无法释放锁A,系统就此死锁。即使在多核机器上,也可能构造出类似的死锁场景。
2. 为什么需要协调机制
锁非常适合保护共享数据,确保线程互斥访问。但在某些场景下,线程需要主动等待某个特定事件发生,而不仅仅是互斥访问。
以下是需要协调的例子:
- 管道读写:读进程在管道为空时需要等待写进程写入数据。
- 磁盘I/O:进程发起磁盘读请求后,需要等待磁盘操作完成(这可能需要毫秒级时间)。
- 等待子进程:父进程调用
wait系统调用,需要等待其任意子进程退出。
在这些情况下,线程需要一种机制来让出CPU,直到它所等待的事件发生。简单地“忙等待”(循环检查条件)会浪费CPU资源,尤其是在等待时间较长或不确定时。

3. 睡眠与唤醒接口

xv6(以及许多Unix系统)使用 sleep 和 wakeup 这一对原语来实现协调。
sleep(chan, lock): 使当前线程在通道chan上睡眠。chan是一个任意值(如地址),用于标识等待的事件。调用者必须持有保护睡眠条件的锁lock。wakeup(chan): 唤醒所有在通道chan上睡眠的线程。
其基本使用模式如下:
- 线程检查它需要等待的条件(例如,
uart发送完成标志是否为1)。 - 如果条件不满足,线程调用
sleep,并传入一个标识事件的chan和保护该条件的锁。 - 当事件发生时(例如,在中断处理程序中),另一段代码设置条件(例如,将完成标志置1),并调用
wakeup(chan)。 - 睡眠的线程被唤醒,重新检查条件,并继续执行。
4. “丢失的唤醒”问题
一个看似直接的 sleep 实现可能会引入一个严重问题:丢失的唤醒。
考虑以下有缺陷的流程:
- 线程A检查条件(如
done == 0),条件不满足,准备调用sleep。 - 在线程A即将调用
sleep但尚未调用的瞬间,中断发生。 - 中断处理程序将条件置为真(
done = 1),并调用wakeup。 - 由于此时线程A还未标记自己为睡眠状态,
wakeup找不到任何线程来唤醒。 - 中断返回,线程A继续执行,调用
sleep并进入睡眠。 - 现在,条件已满足,但唤醒已经发生过了。线程A可能永远无人唤醒。
问题的关键在于:释放保护条件的锁 和 将自身标记为睡眠状态 这两个操作必须是原子的,不能被打断。
5. xv6的解决方案
xv6的 sleep 函数通过其接口设计巧妙地解决了这个问题。关键在于 sleep 要求调用者传递保护条件的锁。
以下是 sleep 函数内部的关键步骤:
- 获取进程锁:
sleep首先获取当前进程的锁 (p->lock)。 - 释放条件锁:然后,
sleep释放调用者传入的条件锁 (lk)。 - 设置睡眠状态:接着,将进程状态设置为
SLEEPING,并记录睡眠通道chan。 - 调用
sched切换:最后,调用sched让出CPU。此时,进程仍持有自身的进程锁 (p->lock)。 - 被唤醒后:当
wakeup发现此进程并试图唤醒它时,必须获得该进程的锁 (p->lock)。由于进程在睡眠时仍持有此锁,wakeup会在此阻塞。 - 重新获取条件锁:当睡眠进程被调度重新执行时,在
sleep函数返回前,它会重新获取当初传入的条件锁 (lk)。
这个设计保证了从“检查条件”到“进入睡眠”的原子性:
- 在调用
sleep时,调用者持有条件锁,因此wakeup不可能在此时执行。 sleep在释放条件锁之前,已经获取了进程锁。因此,即使条件锁被释放,wakeup可以执行,但在获取目标进程锁时也会被阻塞,直到该进程完全进入睡眠状态。- 这样就关闭了“丢失的唤醒”可能发生的时间窗口。
6. 进程退出与终止
进程的生命周期结束涉及两个系统调用:exit(主动退出)和 kill(终止其他进程)。
6.1 进程退出 (exit)
进程调用 exit 时,并不能立即释放所有资源(如内核栈),因为它还在使用这些资源执行退出代码。因此,exit 的主要工作是:
- 关闭打开的文件。
- 将子进程过继给
init进程。 - 将自身状态设置为
ZOMBIE,并唤醒可能在wait的父进程。 - 调用
sched让出CPU,不再运行。
父进程的 wait 调用是资源释放的关键环节。wait 会查找处于 ZOMBIE 状态的子进程,并调用 freeproc 来最终释放该子进程的内核栈、页表等资源,将其状态置为 UNUSED,以便后续 fork 重用。因此,在Unix中,每个退出的进程都必须有一个对应的 wait 来“收尸”。
6.2 进程终止 (kill)
kill 系统调用并不会立即停止目标进程。因为强行停止一个正在内核中执行(可能持有锁或更新复杂数据结构)的进程是危险的。
kill 的实现相对温和:
- 它只是设置目标进程的一个
killed标志。 - 如果目标进程正在睡眠,则将其状态设为
RUNNABLE,使其能被调度。
目标进程会在安全的时机检查自己的 killed 标志,然后主动调用 exit。这些安全时机包括:
- 从系统调用返回用户空间之前。
- 在陷入内核时(如处理中断)。
- 在某些
sleep循环中醒来后(如管道读取),会检查killed标志。
对于某些不能中途退出的操作(如正在进行的文件系统磁盘操作),其 sleep 循环不会检查 killed 标志,以确保操作完整性。
总结

本节课中我们一起学习了操作系统中关键的线程协调机制。我们了解了为什么简单的忙等待不可行,以及 sleep 和 wakeup 如何提供一种让线程等待事件并让出CPU的方法。我们深入探讨了“丢失的唤醒”这一经典问题,并分析了xv6如何通过精巧的锁协议在 sleep 接口中解决它。最后,我们探讨了进程如何通过 exit 和 kill 结束其生命周期,理解了资源释放的协作过程以及安全终止的重要性。协调机制和锁一样,是编写正确并发代码的基础工具。
课程 P13:第14讲 - 文件系统 🗂️
在本节课中,我们将要学习文件系统的基础知识。文件系统是操作系统中最面向用户的组件之一,我们每天都在使用它。我们将深入探讨文件系统是如何实现的,包括其数据结构、磁盘布局以及性能优化等方面。
概述
文件系统为用户提供了持久化存储、友好的命名空间(如路径名)以及组织文件的能力。与之前学习的进程、内存等子系统不同,文件系统需要在计算机关闭后依然保留数据。我们将从XV6操作系统的简单文件系统入手,理解其核心概念和实现机制。
文件系统结构
上一节我们介绍了文件系统的基本概念,本节中我们来看看文件系统内部用于实现其API的核心数据结构。
最重要的结构是i-node。它代表一个独立于文件名的文件对象。进程内部通过一个整数(i-node号)来引用i-node,而不是路径名。
i-node必须维护一个链接计数,以跟踪指向该i-node的文件名数量。只有当链接计数和打开的文件描述符计数都为零时,文件才能被删除。
此外,由于读写系统调用中没有显式的偏移量参数,文件描述符必须隐式地维护一个当前偏移量。
因此,文件系统的核心数据结构是i-node,而文件描述符则是进程与文件系统交互的接口。
文件系统层次
由于文件系统相当复杂,通常将其组织成一系列层次来理解。这种分层有助于组织代码,尽管在具体实现中界限可能不那么严格。
以下是典型的文件系统层次:
- 磁盘:底层的存储设备,提供持久化能力。
- 缓冲区缓存/块缓存:位于磁盘之上,用于缓存内存中的数据,避免频繁访问慢速磁盘。
- 日志层:许多文件系统使用日志来保证崩溃安全性。
- i-node缓存:主要用于同步。在XV6中,i-node通常小于磁盘块,多个i-node被打包进一个磁盘块,i-node缓存提供了对这些独立i-node的同步访问。
- i-node层:实现文件读写等操作。
- 路径名与文件描述符操作:最上层,处理用户友好的路径名和文件描述符相关的系统调用。
几乎所有的文件系统都包含对应于这些不同层次的组件。
存储设备与磁盘布局
上一节我们了解了文件系统的逻辑层次,本节中我们来看看数据在物理磁盘上是如何组织和存储的。
存储设备(如SSD或机械硬盘)通过块号进行读写。文件系统的任务是将所有数据结构以一种能在重启后重建文件系统的方式布局在磁盘上。
XV6使用一种非常简单但典型的磁盘布局:
- 块0:通常不使用,或用于引导扇区。
- 块1:超级块,描述文件系统(如总块数、日志起始位置等)。
- 块2-32:日志区域。
- 块32-45:i-node区域。多个i-node被打包在一个磁盘块中。
- 块46:位图块,用于追踪数据块的空闲状态。
- 剩余块:数据块,用于存储文件内容或目录内容。
位图块、i-node块和日志块通常被称为元数据块,它们不存储实际数据,而是帮助文件系统管理数据。
给定一个i-node号,文件系统可以通过计算找到该i-node在磁盘上存储的确切字节位置。
i-node与文件内容组织
现在我们已经知道i-node存储在磁盘的什么位置,本节中我们来看看i-node内部如何组织文件内容。
在XV6中,磁盘上的i-node是一个64字节的数据结构,包含以下信息:

- 类型(文件、目录或空闲)
- 链接计数
- 文件大小(字节)
- 12个直接块号
- 1个间接块号
直接块号对应文件的前12个数据块。间接块号指向一个磁盘块,该块本身存储了256个块号。因此,XV6中最大文件大小为 (12 + 256) * 1024 字节,即268KB,这对于现代应用来说非常小。

要读取文件中的某个字节(例如偏移8000处的字节),文件系统会:
- 将字节偏移除以块大小(1024),得到块索引(7)。
- 检查该索引是否小于12。如果是,则使用i-node中对应的直接块号。
- 读取该数据块。
- 在块内,通过
字节偏移 % 1024计算具体位置(832),找到目标字节。
这种结构提供了实现读写系统调用所需的全部信息,以确定需要访问哪些磁盘块。
目录与路径名查找
文件系统的一个强大特性是层次化的命名空间。在类Unix系统(包括XV6)中,目录本质上是一种特殊类型的文件,其内容具有特定的结构。



在XV6中,目录由一系列目录项组成,每个条目固定为16字节:
- 前2字节:i-node号(
short类型)。 - 后14字节:文件名。
路径名查找(例如查找路径 /x/y)的过程如下:
- 从根目录开始(其i-node号固定为1)。
- 读取根目录i-node,获取其数据块。
- 遍历根目录的数据块,在目录项中查找名为
x的条目,并获取其i-node号(例如251)。 - 读取i-node 251,如果它是目录,则遍历其数据块,查找名为
y的条目,并获取其i-node号。 - 返回最终找到的i-node。
这种线性扫描的方式效率不高,实际的文件系统会使用更复杂的数据结构(如B树)来加速查找。
代码分析:文件创建与缓冲区缓存
为了更具体地理解这些概念,让我们通过XV6代码片段看看文件创建过程,并深入了解缓冲区缓存的工作原理。
当创建文件时,sys_open 会调用 create 函数,进而调用 ialloc 来分配一个i-node。ialloc 会遍历所有可能的i-node号,读取对应的磁盘块,检查i-node是否空闲,然后将其标记为“文件”类型并写回磁盘。
在这个过程中,会调用 bread 来读取磁盘块。bread 首先调用 bget 从缓冲区缓存中获取指定块号的缓冲区。
bget 的操作流程如下:
- 获取缓冲区缓存的自旋锁,遍历缓存链表,查找请求的块是否已在缓存中。
- 如果找到,增加该缓冲区的引用计数,释放缓存锁,然后尝试获取该缓冲区的睡眠锁。
- 如果未找到,则需要分配一个新缓冲区(可能涉及替换最近最少使用的缓冲区),并读取磁盘数据。
缓冲区缓存的关键不变量是:每个磁盘块在内存中只存在一个缓存副本。 这避免了数据不一致的问题。

睡眠锁 用于保护缓冲区内容。与自旋锁不同,持有睡眠锁时允许进程休眠、进行I/O操作,并且不会禁用中断,适用于可能长时间持有的锁。
当进程使用完缓冲区后,调用 brelse 释放睡眠锁,减少引用计数。如果引用计数降为零,则将该缓冲区移动到“最近使用”的位置,以便在需要替换时优先保留最近使用过的块(利用时间局部性原理)。

总结

本节课中我们一起学习了文件系统的基础知识。我们了解到文件系统是一个提供持久化存储和友好命名空间的复杂子系统。我们探讨了其核心数据结构i-node,它独立于文件名标识文件。我们分析了XV6简单的磁盘布局,包括超级块、日志区、i-node区、位图和数据区。我们还了解了文件内容如何通过直接和间接块号组织,以及目录如何作为特殊文件实现路径名查找。最后,我们深入研究了缓冲区缓存,它是提升文件系统性能的关键组件,通过缓存磁盘块、使用睡眠锁保证同步,并采用LRU策略管理缓存。下节课我们将重点关注文件系统的另一个关键方面:崩溃安全性。

课程 P14:第15讲 - 崩溃恢复 🛡️
在本节课中,我们将学习文件系统如何应对崩溃或断电等故障,确保数据的一致性和安全性。我们将重点探讨一个核心问题:多步骤文件系统操作(如创建或写入文件)期间发生故障,可能导致磁盘状态不一致。为了解决这个问题,我们将深入研究一种名为“日志记录”的技术,它源自数据库领域,现已被许多现代文件系统采用。我们将以XV6操作系统中的简单日志实现作为案例,理解其基本原理、优势以及面临的挑战。
文件系统崩溃风险 🔍
上一节我们介绍了文件系统的基本操作。本节中,我们来看看这些操作在面临崩溃时的风险。
文件系统操作(如创建文件、写入文件)通常涉及对磁盘的多次写入。如果系统在多次写入之间崩溃(例如断电或内核恐慌),磁盘可能会处于不一致的状态。所谓“不一致”,指的是违反了文件系统本应维持的不变性,例如:
- 一个数据块被分配给两个不同的文件。
- 一个inode被标记为已分配,但未出现在任何目录中。
这种不一致状态在重启后可能导致数据丢失、安全漏洞或文件系统无法正常工作。
为了更具体地说明,让我们在XV6文件系统的上下文中看一个例子。
示例:创建文件时的崩溃
假设在XV6中创建一个文件涉及以下步骤:
- 分配一个inode,并在磁盘上标记该inode为已使用(例如写入块33)。
- 更新目录内容,将新文件名和其inode号加入目录(例如写入块46)。
- 更新目录inode以反映大小变化(例如写入块32)。
场景A:在步骤1之后、步骤2之前崩溃。
- 重启后,磁盘上有一个inode被标记为已分配,但它不属于任何目录。这个inode被“泄露”了,无法被访问或释放。
场景B:调整顺序,先执行步骤2和3,再执行步骤1。
- 如果在更新目录之后、标记inode之前崩溃。
- 重启后,目录中包含一个指向“未分配”inode的条目。如果这个inode后来被分配给另一个文件,两个不同的文件将共享同一个inode,导致严重的安全问题。
这个例子表明,简单地调整操作顺序无法从根本上解决问题。核心风险在于,一组必须全部生效的写入,可能只有一部分被真正写入磁盘。
日志记录解决方案 📝
上一节我们看到了崩溃导致的不一致问题。本节中我们来看看日志记录如何作为一项有原则的解决方案。
日志记录的基本思想是将磁盘划分为两个区域:日志区和主文件系统区。文件系统更新时,不直接写入主区域,而是遵循一个“先写日志,再提交,最后应用”的三阶段过程。
日志记录工作流程
以下是日志记录的核心步骤:
- 日志写入阶段:将本次事务(即一个文件系统操作)需要修改的所有数据块,先写入磁盘的日志区域。
- 提交阶段:在确保所有数据块都已安全写入日志后,向日志区域写入一个特殊的提交记录(例如一个包含块数量的头块)。这个写入是原子的(整个扇区一起成功或失败)。
- 安装阶段:将日志中的数据块拷贝到它们在主文件系统中的最终位置。
- 清理阶段:将日志头中的提交记录清零,表示日志空间可复用。
关键规则:在写入提交记录之前,必须确保事务的所有数据块都已持久化在日志中。在提交记录写入之后,才能开始将数据块安装到主文件系统。
崩溃恢复流程
系统重启后,恢复过程非常简单:
- 读取日志头。
- 如果提交记录为0,则无事可做(事务未完成)。
- 如果提交记录非0(例如数字5),则说明有一个已完成提交但可能未完成安装的事务。恢复过程会重新执行安装阶段,将日志中的5个块再次拷贝到主文件系统。
- 清理日志。
这个方案保证了原子性:一个文件系统操作的所有效果要么全部生效,要么全部不生效。同时,它实现了快速恢复,无需扫描整个磁盘来检查一致性。


XV6中的日志实现 🖥️
上一节我们介绍了日志记录的通用原理。本节中,我们具体看看XV6是如何实现一个简单日志系统的。
XV6的日志结构非常简单:
- 磁盘上的日志:第一个块是日志头块,包含提交记录(事务中的块数)和一个块号数组,指向日志中后续的数据块。之后是连续存放的数据块副本。
- 内存中的日志:维护着日志头的内存副本,以及指向块缓存中对应数据块的指针。
事务的生命周期
在XV6中,每个文件系统调用(如create, write)都被包装成一个事务。
以下是事务处理的关键函数调用流程:
begin_op(); // 事务开始:检查日志空间和并发限制
// ... 文件系统操作代码,通过 log_write() 记录要修改的块 ...
end_op(); // 事务结束:执行提交和安装
log_write函数:它并不立即写入磁盘,而是:
- 将要修改的块号记录到内存的日志头数组中。
- 调用
bp->pin()固定该块在缓冲区缓存中,防止在事务提交前被换出并写回主文件系统位置(这违反了“先写日志”规则)。
end_op -> commit函数:这是事务的核心。
write_log():将内存中所有被log_write记录的块,从缓冲区缓存写入到磁盘的日志区域。write_head():将包含块数量的日志头写入磁盘。这是提交点。在此之后,事务就必须完成。install_trans():将日志区域中的数据块,拷贝到它们的主文件系统位置。write_head()(再次):将日志头中的计数清零,清理日志。
恢复的实现

XV6在启动时(main函数中)会调用recover_from_log()函数。
- 它读取磁盘上的日志头。
- 如果计数>0,则调用
install_trans()重放日志。 - 调用
clear_log()清理。


这种设计使得恢复过程快速且幂等(即使重复安装多次,结果也一样)。


挑战与折衷 ⚖️

上一节我们分析了XV6日志的基本运作。本节中我们来看看这种简单实现所面临的一些挑战和设计折衷。


即使是XV6的简单日志,也需要处理以下几个复杂问题:


1. 缓冲区缓存固定
问题:事务进行中,被修改的块缓存在内存中。如果缓冲区缓存满了,试图换出这些块会导致它们被写回主文件系统位置,从而破坏原子性。
解决方案:log_write()会调用bp->pin()增加块的引用计数,防止其被换出。在事务提交并安装后,再解除固定。
2. 日志大小限制

问题:XV6的日志只有固定大小(例如LOGSIZE个块)。一个文件系统操作写入的块数不能超过日志容量。
解决方案:
- XV6确保所有文件系统操作(如创建、链接)本身修改的块数很少,不会超过限制。
- 对于大文件写入(
write系统调用),XV6将其拆分为多个较小的事务。每个小事务独立保证原子性。虽然整个大写入不是原子的,但这符合UNIX语义,且能保证文件系统元数据(如分配新块)的原子性。

3. 并发操作与组提交
问题:多个进程可能同时执行文件系统调用。它们的总写入量可能超过日志容量。
解决方案:XV6在begin_op()中进行协调。
- 它跟踪当前并发的事务数以及已预留的日志块数。
- 如果新事务加入会导致超出日志容量,该进程会睡眠等待。
- 当一批并发事务都执行到
end_op()时,它们会被组提交——它们的所有修改被作为一个大事务一起提交到日志。这保证了操作的提交顺序,同时提高了吞吐量。

性能折衷

XV6日志的主要性能缺点是每个数据块被写了两次:一次到日志,一次到主文件系统。这带来了显著的写入放大。高性能文件系统(如Linux的ext4)使用更复杂的日志模式(如元数据日志、延迟日志等)来缓解这个问题,这将是后续课程的内容。


总结 📚

本节课中,我们一起学习了操作系统如何实现崩溃恢复。
我们首先认识到,多步骤的文件系统操作在遭遇崩溃时,可能导致磁盘状态不一致,引发数据错误或丢失。接着,我们深入探讨了日志记录这一核心解决方案。日志记录通过“先写日志,再提交,最后应用”的三阶段协议,保证了文件系统操作的原子性和快速恢复能力。

我们以XV6操作系统为例,剖析了一个简单日志系统的具体实现,包括事务的封装(begin_op/end_op)、修改的记录(log_write)、提交点的确立以及崩溃后的恢复流程。同时,我们也分析了这种简单实现面临的挑战:如需要固定缓存块、受限于日志大小、以及如何通过拆分大写入和组提交来管理并发操作。

最后,我们指出了XV6日志在性能上的主要代价——写入放大。这为我们理解现代高性能文件系统的日志优化设计奠定了基础。

课程16:文件系统性能与快速崩溃恢复 🚀
在本节课中,我们将学习日志记录(Journaling)这一关键技术,它如何使文件系统在崩溃后能快速恢复。我们将以Linux的EXT3文件系统为例,深入探讨其设计原理、性能优化技巧,并与简单的XV6日志系统进行对比。
日志记录回顾 📝
上一节我们介绍了日志记录的基本概念。本节中,我们来看看日志记录的核心机制。
文件系统可以看作一个存储在磁盘上的树形数据结构,包含目录、文件、inode和位图块等。我们将描述文件系统结构的块(如inode、目录块、位图)称为元数据,而存储文件实际内容的块称为文件内容块。
日志记录的核心思想是:在进行任何实际的磁盘更新之前,先将所有计划中的修改(元数据和/或数据)按顺序写入一个专门的日志区域。这个日志区域通常位于磁盘的起始部分。
在XV6中,日志的基本结构如下:
- 一个日志头块,记录本次事务(Transaction)中所有待修改的块号。
- 一系列数据块,即待修改块的实际内容。
- 在写入所有数据块后,系统会写入一个提交记录(在XV6中体现为更新日志头块),标志着本次事务已“提交”。
这个过程遵循 “提前写规则”(Write-Ahead Rule):必须在将任何修改应用到文件系统的主位置之前,先将所有修改完整地记录到日志中。只有这样,才能在崩溃后通过重放(Replay)日志来恢复一个完整的事务。
此外,还有一个 “释放规则”(Free Rule):日志中已提交事务占用的空间,必须在该事务的所有修改都被写回文件系统的主位置后,才能被释放和重用。
XV6日志的性能问题 ⏳
上一节我们回顾了日志的基本原理。本节中,我们来看看一个简单实现(如XV6)可能存在的性能瓶颈。
XV6的日志系统虽然正确,但性能较低,主要原因有三点:
- 同步操作(Synchronous):每个文件系统操作(如
write,create)必须等待其所有磁盘写入(包括写入日志、提交、写回主位置、清理日志)完成后才能返回。这导致系统调用延迟很高。 - 缺乏批处理(Batching):每个系统调用通常独立作为一个事务提交。固定开销(如寻道、写入日志头)无法被分摊。
- 缺乏并发(Concurrency):在提交一个事务时,新的系统调用必须等待。磁盘写入和应用程序计算无法重叠执行。
公式化地看,一个系统调用的耗时大致为:
T_xv6 ≈ (写入日志块时间 + 提交时间 + 写回主位置时间 + 清理时间)
由于机械硬盘每次写入约需10毫秒,XV6每秒只能处理很少的文件系统操作。
EXT3的性能优化策略 ⚡
上一节我们看到了简单日志系统的性能局限。本节中,我们来看看EXT3文件系统如何通过三种关键技术来大幅提升性能。
EXT3在EXT2文件系统的基础上增加了日志层,其核心优化在于:
1. 异步系统调用(Asynchronous System Calls)
系统调用在修改完内存中的缓存块后即可返回,无需等待数据落盘。这带来了快速响应和I/O并发(应用程序计算与磁盘操作重叠)。缺点是,系统调用返回并不保证数据在崩溃后依然存在,需要持久化保证的程序必须使用fsync()系统调用。
2. 批处理(Batching)
EXT3将一段时间内(默认约5秒)的多个系统调用打包成一个大型事务进行提交。
- 分摊固定开销:事务的固定成本(如寻道、写描述符块)被众多系统调用分摊。
- 写吸收(Write Absorption):对同一缓存块的多次修改在内存中合并,最终只需向日志写入一次该块的最终状态。
- 高效的磁盘调度:一次性向日志顺序写入大量块,效率远高于多次分散写入。即使后续写回主位置,磁盘调度器也能对大批量写操作进行优化排序。
3. 并发(Concurrency)
EXT3允许多个事务处于不同阶段,从而支持多种并发:
- 系统调用并发:多个系统调用可同时修改当前开放事务(Open Transaction)中的缓存块。
- 事务阶段并发:当一个事务正在提交到日志时,新的系统调用可以在下一个开放事务中继续执行。同时,更旧的事务可以在后台将其修改写回主位置。这些阶段可以并行进行。
相比之下,XV6在同一时间只能有一个活跃事务,且必须完全完成其所有阶段后才能开始下一个。
EXT3的事务提交流程 🔄
上一节我们了解了EXT3的三大性能策略。本节中,我们深入看看一个事务从开始到提交的详细步骤。
以下是EXT3提交一个事务(例如事务T1)所需的关键步骤:
- 阻止新系统调用:停止接受属于当前事务(T1)的新系统调用。这是为了防止后续事务(T2)的修改被T1看到,从而破坏原子性(后文会详述)。
- 等待未完成系统调用:等待所有已开始的、属于T1的系统调用完成。
- 开始新事务:此时,可以开启一个新事务(T2),被阻塞的系统调用可以继续在T2中执行。
- 写入日志:将T1中所有被修改的块写入日志区域,包括:
- 描述符块:包含本事务的序列号和所有被修改块的列表。
- 数据块:被修改块的内容。
- 提交块:写入一个特殊的提交块,标志本事务已提交。这是提交点(Commit Point)。在此之前崩溃,事务会完全丢弃;在此之后崩溃,事务保证能恢复。
- 后台写回:在后台,将T1日志中的块写回到它们在文件系统中的主位置。
- 释放日志空间:当T1的所有块都已写回主位置,并且所有更早的事务也都已释放后,就可以安全地释放T1在日志中占用的空间,以供后续事务使用。
这些步骤由内核中的后台线程(如kjournald)负责执行。
崩溃恢复机制 🛡️
上一节我们走完了事务的正常流程。本节中,我们来看看当崩溃发生时,EXT3如何利用日志进行恢复。
崩溃后,内存中的所有状态都会丢失。恢复软件(通常是e2fsck)的唯一依据是磁盘上日志区域的内容。恢复过程如下:
- 定位日志起点:读取日志的超级块,它记录了日志中最早有效事务的起始位置。
- 扫描日志:从起点开始,依次读取描述符块。根据描述符块中声明的块数,跳过相应数量的数据块,寻找提交块。
- 识别日志结尾:恢复软件持续扫描,直到遇到以下情况停止:
- 提交块之后的下一个块不是有效的描述符块(没有正确的魔数)。
- 或者,找到了描述符块,但其后对应位置没有有效的提交块。
- 重放事务:从日志开头到识别出的结尾,按顺序将所有数据块重写(Replay)到它们在文件系统中的主位置。
- 清理:恢复完成后,可以清除日志(或将超级块指向日志开头),然后正常启动系统。
关键细节:魔数(Magic Number)
为了区分描述符/提交块与普通数据块,EXT3在描述符块和提交块的开头使用一个特殊的魔数。同时,它确保任何写入日志的普通数据块都不会以这个魔数开头(如果会,则将其替换为0,并在描述符块中设置标志位)。这消除了扫描时的歧义,确保了恢复的可靠性。
数据块日志模式 📂
上一节我们专注于元数据的日志记录。本节中,我们来看看EXT3如何处理文件内容数据块,它提供了不同的日志模式供用户选择。
EXT3支持三种主要的日志模式,在创建文件系统时指定:
-
journal(日志数据模式):- 行为:元数据和文件内容数据都写入日志。这是最安全但最慢的模式,因为所有数据都要写两遍(先日志,后主位置)。
- 代码示例:
mkfs.ext3 -O journal_dev /dev/sda1
-
ordered(有序数据模式 - 默认模式):- 行为:只有元数据写入日志。文件内容数据直接写入主位置。但是,文件系统会保证先写完数据块,再提交包含相关元数据(如inode中新块指针)更新的事务。
- 优点:性能好,避免了数据块的双重写入。同时能防止崩溃后出现“旧数据暴露”问题(即新文件看到之前占用该块的其他文件的旧数据)。
- 代码示例:
mount -o data=ordered /dev/sda1 /mnt
-
writeback(回写模式):- 行为:只有元数据写入日志,对文件数据块的写入顺序没有强制约束。
- 优点:三种模式中性能最高。
- 缺点:可能导致崩溃后文件数据与元数据不一致,包括旧数据暴露问题。
关键要点与总结 🎯
本节课中,我们一起学习了文件系统日志记录的核心原理和高级实现。

核心要点总结:
- 日志的根本目的:通过“提前写规则”,将复杂的多步磁盘更新,转化为关于崩溃的原子操作(要么全做,要么全不做)。
- 性能三要素:EXT3通过异步系统调用、批处理和并发,克服了类似XV6等简单日志系统的性能瓶颈。
- 恢复的基础:崩溃恢复完全依赖于磁盘上格式良好的日志。恢复软件通过扫描和重放已提交的事务来恢复一致性。
- 灵活的数据处理:EXT3提供不同的日志模式(如默认的
ordered),在数据安全性和性能之间提供权衡,允许用户根据场景选择。 - 复杂性代价:更高的性能往往伴随着更复杂的实现,例如EXT3需要精心处理事务之间的依赖和顺序,以避免破坏原子性。

日志记录是一个极其成功的思想,它不仅应用于文件系统,也广泛应用于数据库、分布式系统等需要保证崩溃一致性的领域。理解EXT3的设计,是理解现代存储系统如何兼顾性能与可靠性的重要一步。

课程 P16:第17讲 - 应用程序的虚拟内存 🧠
在本节课中,我们将学习操作系统如何将虚拟内存的强大能力开放给用户应用程序。我们将探讨一篇经典论文提出的核心观点,了解现代操作系统(如Linux)如何通过系统调用实现这些功能,并通过具体的应用实例(如大表缓存和垃圾回收器)来理解其工作原理和优势。
概述
虚拟内存是操作系统的核心机制,传统上主要由内核使用。然而,用户应用程序同样可以从类似的机制中受益。本节课将介绍如何通过特定的系统调用,让应用程序能够更灵活地管理自己的内存,例如处理页面错误、动态调整内存区域的访问权限等。我们将从理论基础开始,逐步深入到具体实现和应用案例。
核心虚拟内存原语
上一节我们概述了用户程序使用虚拟内存的可能性。本节中,我们来看看实现这种能力所需的核心原语(Primitives)。论文指出,尽管应用程序需求多样,但它们都依赖于少量共同的虚拟内存原语。
以下是实现用户级虚拟内存功能所需的关键原语:
- 陷阱(Trap):允许在内核中发生的页面错误传播到用户空间。用户空间可以安装一个处理程序来响应这些错误。
- 代码描述:类似于
signal(SIGSEGV, handler)安装信号处理程序。
- 代码描述:类似于
- 降低可访问性(Reduce Accessibility):降低一个或多个内存页面的访问权限(例如,从可读写变为只读,或变为完全不可访问)。
- 公式描述:
mprotect(addr, len, PROT_READ)将区域设置为只读。
- 公式描述:
- 提高可访问性(Increase Accessibility):提升内存页面的访问权限(例如,从只读恢复为可读写)。
- 查询脏页(Dirty):找出哪些页面已被修改(变“脏”)。
- 双重映射(Map2):允许将同一物理内存区域以不同的访问权限映射到同一地址空间的多个虚拟地址范围。
在这些原语中,陷阱和保护(Protection)(即改变可访问性)是最核心的。现代UNIX系统(如Linux)通过一系列系统调用提供了这些功能的等价实现。
现代UNIX的系统调用实现
上一节我们介绍了理论上的原语。本节中我们来看看这些概念在现代操作系统中是如何具体实现的。Linux等系统提供了一组系统调用,几乎可以直接映射到上述原语。
以下是关键的系统调用及其作用:
mmap:将对象(如文件或匿名内存)映射到调用进程的地址空间。这是实现内存映射文件等功能的基础。- 代码描述:
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
- 代码描述:
mprotect:更改已映射内存区域的保护(访问权限)。- 代码描述:
int mprotect(void *addr, size_t len, int prot);
- 代码描述:
munmap:删除指定地址范围的映射。sigaction:允许应用程序为特定信号(如SIGSEGV段错误)安装处理程序。这实现了“陷阱”原语,使用户代码能响应页面错误。- 代码描述:
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
- 代码描述:
通过这些系统调用,应用程序获得了管理自身虚拟内存的强大工具。其中,mmap 和 sigaction 尤为重要。


操作系统内部支持机制
上一节我们看到了用户态可用的接口。本节中我们深入一步,看看操作系统内部是如何支持这些功能的。这主要涉及两个关键数据结构:VMA(虚拟内存区域)和用户级陷阱的处理流程。
虚拟内存区域(VMA)
操作系统内核使用VMA来跟踪进程地址空间中每个连续区域的信息。每个VMA记录了一段地址范围的起始、长度、权限以及背后映射的对象(例如一个文件)。当应用程序调用 mmap 时,内核就会创建一个新的VMA来记录这次映射。
用户级陷阱处理流程
- 应用程序访问了一个权限不足或未映射的页面,触发硬件页面错误。
- CPU陷入内核模式,内核保存现场并查询VMA等数据结构,确定错误原因。
- 如果该错误对应一个用户安装的
SIGSEGV处理程序,内核将控制权传递给该用户态处理函数。 - 用户处理函数执行(例如,调用
mprotect修改权限),然后返回。 - 内核恢复被中断的进程现场。如果错误原因已被处理(如权限已修复),指令将重新执行;否则可能再次陷入内核。
这个机制确保了用户程序能够安全、高效地响应内存访问事件。
应用实例一:大表缓存
上一节我们了解了内核的支持机制。本节中我们来看一个具体的应用实例,展示如何使用这些原语实现一个“大表缓存”。这个例子虽然简单,但能清晰体现用户级虚拟内存的威力。
场景:假设有一个计算非常耗时的函数 f(i)。我们希望预计算所有 i 对应的 f(i) 并存入一个大表,后续查询只需查表,避免重复计算。但此表可能极大,无法全部装入物理内存。
解决方案:
- 使用
mmap分配一块巨大的虚拟地址空间用于存放表,但不立即分配物理内存(类似懒惰分配)。 - 当程序首次访问表中某个条目时,会触发
SIGSEGV页面错误。 - 在错误处理程序中,分配一个物理页,计算该页所涵盖的所有条目的
f(i)值并填入,然后将该页映射回地址空间。 - 程序恢复执行,此次及后续对同一页内条目的访问都变为快速的查表操作。
- 如果物理内存不足,处理程序可以淘汰一些已计算的页(先调用
mprotect取消其访问权限),当这些页再次被访问时,会触发新的页面错误并重新计算填充。
优势:应用程序可以用很小的物理内存开销,表示一个巨大的虚拟表。访问模式决定了哪些部分真正留在内存中,实现了高效缓存。
应用实例二:垃圾回收器
上一节的例子展示了性能优化。本节中我们探讨一个更复杂的系统级应用:使用虚拟内存原语实现并发垃圾回收器(Garbage Collector, GC)。论文以“复制式回收器”为例。
传统复制式GC的问题:
- 开销大:应用程序每次访问指针,都需要插入检查代码,判断对象是否在待回收区域,并进行转发(Forwarding)。这增加了指令开销。
- 并发难:回收器与应用程序并发运行时,需要精细的同步来防止竞态条件。
基于VM的解决方案:
- 将堆内存划分为“From空间”和“To空间”。回收开始时,将存活对象从From空间复制到To空间。
- 关键技巧:将To空间中尚未扫描完成的区域,通过
mprotect设置为不可访问。 - 当应用程序试图访问一个位于“未扫描区域”的对象时,会触发页面错误。
- 在错误处理程序中,回收器扫描触发错误的这一页对象,将其内部指针指向的对象也复制到To空间,然后使用
mprotect将本页标记为可访问。 - 回收器自身为了扫描对象,需要使用
mmap将同一物理内存以可读写权限映射到另一个地址范围(即“Map2”原语的应用)。
优势:
- 低成本检查:指针检查由硬件MMU通过页面错误自动完成,省去了显式的条件判断指令。
- 隐式并发同步:应用程序无法访问“未扫描页”,回收器则可以安全地扫描它,硬件保护自然避免了并发冲突。
总结

本节课我们一起学习了用户应用程序如何利用虚拟内存原语来获得更强的能力和更好的性能。我们从一篇倡导此概念的经典论文出发,了解了现代操作系统(如Linux)通过 mmap、mprotect、sigaction 等系统调用对这些原语的具体实现。通过“大表缓存”和“垃圾回收器”两个深入案例,我们看到了这些技术如何解决实际编程中的难题,例如管理超出物理内存的数据结构、实现高效且并发的内存自动回收等。虚拟内存不仅是操作系统的核心,也是赋能应用程序开发者的重要工具。

课程 P17:第18讲 - 操作系统组织 🖥️
在本节课中,我们将要学习一种与传统的“单体式”内核不同的操作系统设计思路——微内核。我们将探讨微内核的基本思想、其背后的动机、核心工作机制,以及如何在一个微内核之上运行一个完整的Linux系统。
概述:为什么需要微内核?
上一节我们介绍了传统的“单体式”内核设计,如XV6和Linux。本节中我们来看看人们为何会探索微内核架构。
传统的单体式内核将所有功能(如文件系统、内存管理、设备驱动)都集成在一个大型、复杂的程序中。这种设计提供了强大的抽象,便于移植和资源共享,但也导致了内核庞大、复杂、难以验证,并且将许多设计决策强加给了应用程序。
微内核的提出,正是为了应对这些挑战。其核心思想是:内核应该尽可能小,只提供最基础的功能,如进程/线程抽象和进程间通信(IPC),而将其他所有服务(如文件系统、网络栈)作为用户空间的服务器进程来实现。
微内核的基本架构 🏗️
理解了传统内核的局限性后,本节我们来看看微内核的具体架构是什么样的。
微内核架构的核心是一个运行在最高特权级(如监督者模式)的极小内核。它只负责几件核心事务:
- 任务/线程管理:创建、调度和切换执行单元。
- 地址空间管理:管理虚拟内存映射。
- 进程间通信(IPC):提供高效的进程间消息传递机制。
所有其他操作系统服务,如文件系统(FS Server)、设备驱动(Disk Driver)、网络协议栈,都作为独立的用户空间进程运行。当应用程序(如文本编辑器vi)需要读取文件时,其流程如下:
vi通过IPC向FS Server发送请求。FS Server可能需要通过IPC向Disk Driver请求读取磁盘块。Disk Driver与硬件交互后,将数据通过IPC返回给FS Server。FS Server最终将文件数据通过IPC返回给vi。
这种设计的优势在于内核极小,潜在的安全漏洞更少,各服务模块化,易于替换和定制,单个服务崩溃不会导致整个系统宕机。
微内核的潜在优势与挑战 ⚖️
在了解了微内核的架构后,本节我们来分析一下人们期望从这种设计中获得哪些好处,以及需要面对哪些挑战。
以下是人们期望微内核能带来的优势:
- 简洁与安全:代码量小,更易于验证正确性,攻击面更小。
- 性能潜力:小内核更易于深度优化,且应用程序无需为不使用的功能付费。
- 灵活性与可定制性:将设计决策从内核移出,留给应用程序或用户级服务,例如可以实现自定义的页错误处理策略(如写时复制)。
- 模块化与健壮性:服务作为独立进程运行,故障隔离性好,易于升级替换。
- 多系统兼容:可以在同一个微内核上运行多个不同的操作系统“个性”(如同时运行Linux和Windows服务器)。
然而,实现这些优势也面临严峻挑战:
- 最小化API设计:如何定义一组尽可能少但又足够强大的系统调用?
- 用户级服务构建:需要构建一整套用户级的操作系统服务,这是一项巨大的工程。
- IPC性能瓶颈:所有服务交互都依赖IPC,其性能必须极高,否则将成为系统瓶颈。
- 集成优化丧失:在单体内核中,各子系统可以紧密协作、直接访问数据结构以优化性能。在微内核中,这种跨模块的深度优化变得困难。
L4 微内核实例解析 🔬
面对上述挑战,许多具体的微内核项目被创建出来。本节中我们以经典的 L4 微内核 为例,看看一个实际的微内核是如何工作的。
L4 是微内核研究中的一个代表性作品,其设计非常精简:
- 系统调用极少:仅有7个系统调用(对比:XV6有21个,Linux有数百个)。
- 核心抽象:只管理任务(类似进程,包含地址空间)、线程和IPC。
- 关键机制:
- IPC:是其生命线,经过高度优化。
- 映射(Map):线程可以通过特殊的IPC消息,请求内核将内存页面映射到自己或其他任务的地址空间中。
- 分页器(Pager):每个任务可以指定一个“分页器”任务。当该任务发生页错误时,内核会通过IPC通知其分页器,由这个用户级进程来处理错误(例如按需分配内存、加载页面),这为实现写时复制、内存映射文件等高级功能提供了极大的灵活性。
以下是L4中创建并启动一个新线程的简化流程:
- 调用
thread_create创建新任务和线程(此时地址空间为空)。 - 通过
IPC将包含代码/数据的内存页面映射到新任务的地址空间。 - 通过
IPC发送一个“启动”消息,指定新线程的初始程序计数器和栈指针。
高效的进程间通信(IPC)设计 🚀
由于IPC是微内核性能的关键,本节我们深入探讨L4是如何实现高效IPC的。
早期的微内核(如Mach)使用类似Unix管道的异步、带缓冲的IPC,速度很慢。L4采用了截然不同的优化策略,实现了同步、无缓冲的IPC:
核心优化点:
- 同步 rendezvous(汇合):发送者(
P1)的send和接收者(P2)的receive必须同时到达内核才会进行处理。如果P2已在receive中等待,P1的send可以立即将控制权“跳转”到P2的用户空间,省去了完整的上下文切换和调度开销。 - 零拷贝或直接拷贝:
- 小消息:直接通过寄存器传递,完全无需内存拷贝。
- 大消息:通过传递内存页面的映射权限来实现共享,而非拷贝数据。
- 复合系统调用:针对常见的“请求-响应”(RPC)模式,提供
call(发送请求并等待回复)和reply-and-wait(发送回复并等待下一个请求)这样的复合系统调用,减少内核穿越次数。
通过这些优化,L4的IPC性能比前代系统提升了20倍,使得基于IPC的微内核架构在性能上变得可行。
在微内核上运行Linux:L4Linux案例研究 🐧
一个纯粹的微内核无法直接运行现有的Linux应用程序。本节我们看看L4项目如何解决这个“兼容性”难题。

他们的解决方案非常直接:将完整的Linux内核作为一个用户级服务进程运行在L4之上,这个项目被称为 L4Linux。
架构图如下:
+-------------------------------------+
| Linux 应用 (vi, gcc, ...) | <- 作为独立的L4任务运行
+-------------------------------------+
| Linux 内核服务器 | <- 作为一个L4任务运行
+-------------------------------------+
| L4 微内核 |
+-------------------------------------+
| 硬件 |
+-------------------------------------+
工作原理:
- 系统调用拦截:Linux应用程序发出的系统调用(如
read,write),被一个小的库函数拦截,转换为发送给“Linux内核服务器”任务的IPC消息。 - 内核服务器处理:Linux服务器任务接收IPC请求,在其内部模拟对应的内核处理流程。当需要操作硬件或管理进程内存时,它通过L4的系统调用(如IPC、映射)来完成。
- 返回结果:处理完成后,Linux服务器通过IPC将结果返回给应用程序。
挑战与折衷:
- 单线程内核:当时的Linux是非对称多处理(SMP)不完善的版本,因此L4Linux选择让整个Linux内核服务器在单个L4线程中运行,内部使用自己的协程来模拟多线程。这简化了实现,但意味着Linux失去了对CPU调度的直接控制(由L4负责)。
- 地址空间难题:为了高效处理用户指针,他们尝试了复杂的“双地址空间”方案,但最终因性能问题放弃,转而采用了更简单但可能稍慢的方法。
性能评估与结论 📊
经过一系列精心的设计,L4Linux的性能表现如何?本节我们来看论文给出的关键数据。
论文通过两个基准测试来证明其性能竞争力:
- 简单系统调用(
getpid):- 原生Linux:1.7 微秒。
- L4Linux:4.0 微秒。
- 分析:L4Linux耗时约是原生的2.3倍,这主要源于额外的IPC开销。论文认为,考虑到需要两次用户/内核穿越(应用->L4->Linux服务器),这个开销已接近理论下限。
- 综合应用基准(AIM9):
- 这个测试模拟了真实的工作负载,包含文件操作、进程创建等多种系统调用。
- 结果:L4Linux的整体性能仅比原生Linux慢 3-5%。
核心结论:论文有力地反驳了“微内核天生低效”的观点。它证明,通过极致的优化(尤其是IPC),微内核架构可以实现与成熟单体内核相媲美的性能。因此,性能不应成为拒绝微内核的理由。
总结与影响 🌟
本节课中我们一起学习了操作系统组织的另一种范式——微内核。
我们从批评传统单体内核的复杂性出发,引入了微内核的核心思想:极小内核 + 用户级服务。我们以L4微内核为案例,详细剖析了其精简的设计、高度优化的IPC机制,以及如何在它之上通过L4Linux项目来兼容现有的Linux生态系统。最后的性能数据表明,精心设计的微内核在性能上是可以与单体内核一较高下的。
尽管微内核未能在通用桌面和服务器领域取代Linux,但其思想产生了深远影响:
- 嵌入式领域成功:L4及其后代(如seL4)在安全关键的嵌入式系统(如手机基带处理器)中广泛应用。
- 架构思想渗透:客户端-服务器模型、良好的IPC支持被融入macOS等系统。
- 虚拟化先驱:在微内核上运行完整操作系统的思路,可视为现代虚拟机监视器(VMM)的先声。
- 推动接口发展:对灵活性的追求促进了如内存映射(
mmap)、可加载内核模块等机制的发展。
微内核的研究是一次对操作系统“该做什么”和“该如何构建”的深刻反思,其追求简洁、安全、灵活的精神持续影响着操作系统的发展。

课程 P18:第19讲 - 虚拟机 🖥️
在本节课中,我们将要学习虚拟机的核心概念、实现原理以及现代硬件如何支持虚拟化。我们将从基础的“陷阱与仿真”方法开始,逐步深入到利用现代处理器硬件支持的虚拟化方案,并探讨一篇名为“沙丘”的研究论文如何利用这些硬件特性来实现新的功能。
什么是虚拟机?
虚拟机本质上是对一台计算机的精确模拟,其精确程度足以在其上运行一个完整的操作系统。例如,QEMU 就是一个虚拟机软件。
我们可以将虚拟机监视器视为一个取代了标准操作系统内核的软件层。它的工作是为一个或多个“客户”操作系统模拟出多台计算机的硬件环境。
- 客户空间:运行客户操作系统及其应用程序的区域。
- 主机空间:运行虚拟机监视器自身的区域。
客户操作系统内核(如 Linux 或 Windows)认为自己运行在真实的硬件上,并管理着其上的用户进程。虚拟机监视器的经典目标是提供如此逼真的硬件仿真,以至于未经修改的普通操作系统也能在其中正常运行,且无法察觉自己运行在虚拟环境中。
为何使用虚拟机?
以下是使用虚拟机的一些主要原因:

- 服务器整合:在一台物理服务器上运行多个低资源消耗的服务器实例(如 DNS 服务器、安全服务器),可以节省硬件成本和管理开销。
- 云计算:云服务提供商(如 AWS)可以动态地在物理硬件上分配和迁移虚拟机,实现资源的高效利用和灵活管理。
- 内核开发与调试:在虚拟机中运行和调试操作系统内核(如本课程使用 QEMU 运行 xv6)比在物理机上更方便、安全。
- 检查点与迁移:可以对整个运行中的虚拟机状态进行快照保存,之后恢复或克隆。还可以将运行中的虚拟机迁移到另一台物理主机,实现高可用性或维护。
- 强隔离与安全:虚拟机提供了比传统进程更严格的隔离,适合运行不受信任的代码,因为客户机很难突破其虚拟环境去访问主机或其他虚拟机的资源。
虚拟机技术有着悠久的历史,其思想在 20 世纪 60 年代就已提出。如今,许多操作系统领域的研究和创新也转移到了虚拟机监视器这一层。
如何构建虚拟机监视器?
上一节我们介绍了虚拟机的概念和用途,本节中我们来看看如何实现一个基本的虚拟机监视器。我们将以模拟 RISC-V 硬件来运行为 RISC-V 设计的操作系统(如 xv6)为例。
我们的目标是:客户软件完全无法察觉自己运行在虚拟机中,也无法逃逸出其虚拟环境。
陷阱与仿真策略
一种直观但低效的方法是纯软件解释执行每一条客户机指令。但更主流的策略是直接在真实 CPU 上运行客户机指令,同时通过“陷阱与仿真”来处理特权操作。
基本思路是:让客户机内核代码在宿主机的用户模式下运行。
- 当客户机执行普通非特权指令(如加法、访存)时,这些指令在硬件上全速执行。
- 当客户机尝试执行任何特权指令(如修改页表寄存器
satp、访问控制状态寄存器)时,由于它在宿主机的用户模式下,这会触发一个硬件陷阱,将控制权交还给虚拟机监视器。
虚拟机监视器会截获这个陷阱,查看是哪条指令引起的,然后在软件中模拟这条特权指令的效果。为此,虚拟机监视器需要为每个客户机维护一份虚拟硬件状态(例如虚拟的 stvec、sepc、scause 等寄存器)。
以下是虚拟机监视器需要处理的关键部分:
- 模式跟踪:虚拟机监视器需要记录客户机当前是处于“客户主管模式”还是“客户用户模式”,因为对同一特权指令(如用户模式下的非法指令)的处理方式可能不同。
- 系统调用与中断模拟:当客户机用户程序执行
ecall进行系统调用时,会陷入虚拟机监视器。监视器会更新虚拟状态(如将虚拟模式改为主管,虚拟scause设为系统调用),然后跳转到客户机操作系统的陷阱处理程序地址(由虚拟stvec指定)。
内存虚拟化:影子页表
内存隔离是虚拟机安全的关键。客户机操作系统认为自己设置并控制着页表,将虚拟地址映射到“客户物理地址”。但虚拟机监视器必须控制“客户物理地址”到真实“主机物理地址”的映射。
策略如下:
当客户机操作系统尝试通过 satp 寄存器加载一个新页表时,这会触发陷阱。虚拟机监视器不会直接使用客户机的页表,而是根据以下两者创建一个影子页表:
- 客户机提供的页表(客户虚拟地址 -> 客户物理地址)
- 虚拟机监视器维护的映射表(客户物理地址 -> 主机物理地址)
影子页表将客户虚拟地址直接映射到主机物理地址。然后,虚拟机监视器将这个影子页表设置到真实的 satp 寄存器中。这样,客户机只能访问虚拟机监视器分配给它的主机物理内存。
如果客户机直接修改其页表项(PTE),在 RISC-V 中,为了确保硬件能注意到修改,它必须执行 sfence.vma 指令。这是一条特权指令,会触发陷阱。虚拟机监视器借此机会重新扫描客户机页表的更改,并更新影子页表。
设备虚拟化
客户机操作系统期望能与各种硬件设备(如磁盘、网卡、控制台)交互。虚拟机监视器需要提供这些设备的仿真。
主要有三种策略:
- 完全仿真:虚拟机监视器软件模拟一个真实存在的设备(如 16550A UART)。客户机通过内存映射寄存器与设备“通信”,每次访问都会触发陷阱,由监视器模拟设备行为。这种方法简单,但性能较低,适用于低速设备。
- 准虚拟化:设计一套高效的、专为虚拟化环境设计的虚拟设备接口(如 VirtIO)。客户机操作系统需要安装特定的驱动程序,通过共享内存队列等机制与虚拟机监视器通信,大大减少了陷阱次数,性能更高。
- 设备直通:将物理设备(如支持 SR-IOV 的网卡)的一部分直接分配给客户机。客户机驱动程序可以直接与硬件对话,性能最高,但需要硬件支持且失去了设备共享的灵活性。
实现设备仿真是构建实用虚拟机监视器的主要工作之一。
现代硬件虚拟化支持
上一节我们探讨了基于“陷阱与仿真”的软件虚拟化方案,其性能瓶颈在于频繁的陷阱。本节中我们来看看现代处理器(如 Intel VT-x, AMD-V)提供的硬件虚拟化支持如何解决这个问题。
其核心动机是减少虚拟机环境下的陷阱开销,并简化虚拟机监视器的实现。
基本原理:根模式与非根模式
硬件虚拟化引入了新的 CPU 执行模式:
- 根模式:虚拟机监视器运行的模式,对硬件有完全控制权。
- 非根模式:客户机运行的模式。
关键改进在于,硬件为非根模式维护了完整且独立的一套“虚拟”控制寄存器副本。当 CPU 处于非根模式时:
- 客户机操作系统执行的大多数特权指令(如读写控制寄存器)会直接作用于这套虚拟寄存器,而不会触发陷阱。
- 硬件会自动检查这些操作,确保客户机无法越界。
虚拟机监视器通过一个名为 VMCS 的数据结构来配置每个虚拟机的初始状态。通过 VMLAUNCH/VMRESUME 指令切换到非根模式运行客户机。客户机可以通过 VMCALL 指令主动陷入根模式,而外部中断(如定时器中断)也会强制退出非根模式,让虚拟机监视器重新获得调度权。
扩展页表
内存虚拟化也得到硬件加速。传统的影子页表方案需要由软件维护和更新,开销大。硬件支持引入了扩展页表。
其工作原理是两级地址翻译:
- 客户机页表完成第一级翻译:客户虚拟地址 -> 客户物理地址。
- EPT 完成第二级翻译:客户物理地址 -> 主机物理地址。
EPT 由虚拟机监视器设置。客户机可以自由设置自己的页表,但其最终能访问的内存范围始终受 EPT 限制。这一切都由硬件内存管理单元自动完成,无需软件介入,效率极高。
案例研究:沙丘论文
上一节我们了解了现代硬件为高效虚拟化提供的支持。本节中我们来看看一篇名为“沙丘”的研究论文,它创造性地将这些为虚拟机设计的硬件特性,用于增强传统的进程抽象。
沙丘的核心思想是:让一个用户进程能够以“非根模式”运行,从而安全地使用部分硬件虚拟化功能。
沙丘的两种主要用途
沙丘作为一个可加载的 Linux 内核模块运行。它允许进程进入一个特殊的“沙丘模式”。
以下是沙丘提供的两种关键能力:
-
安全沙箱:一个受信任的应用程序(如 Web 浏览器)可以在沙丘模式下运行。它拥有自己的虚拟控制寄存器,可以设置自己的页表。然后,它可以加载一个不受信任的插件(如视频解码器),并让插件代码在进程内的“客户用户模式”下运行。
- 插件对内存的访问受到该进程页表的限制,无法随意访问浏览器的所有内存。
- 插件尝试进行系统调用时,会陷入进程内的“客户主管模式”代码(即浏览器的一部分),而不是真正的 Linux 内核,从而被浏览器完全控制。
-
高效垃圾回收:对于使用垃圾回收的语言(如 Java),追踪对象引用时,如果与用户线程并发运行,可能会遇到“对象被修改”的问题。垃圾回收器需要知道哪些内存页被修改了。
- 在沙丘模式下,进程可以拥有自己的页表,并能直接、快速地读取页表中的“脏位”。
- 垃圾回收器完成一轮追踪后,可以快速扫描脏页,只重新检查那些被修改过的页面中的对象,这比传统方法(如系统调用查询脏位)要高效得多。
沙丘的意义
沙丘展示了,原本为虚拟机设计的硬件特性,可以被重新用于构建更强大、更高效的进程级隔离和内存管理工具。它提供了一种不同于传统进程或容器的抽象,在某些场景下能带来显著的性能和安全优势。
总结
本节课中我们一起学习了虚拟机的完整图景。
我们首先定义了虚拟机,并探讨了其多种应用场景。接着,我们深入分析了实现虚拟机监视器的经典方法——陷阱与仿真,理解了如何通过软件截获和模拟特权指令、通过影子页表实现内存虚拟化,以及设备仿真的不同策略。
然后,我们看到了性能瓶颈如何驱动了硬件虚拟化支持(如 Intel VT-x)的发展,其通过引入根/非根模式和第二套硬件寄存器,以及扩展页表,极大地提升了虚拟化的效率。
最后,我们通过沙丘这篇论文,看到了如何跳出虚拟机的框架,将这些强大的硬件特性创新性地应用于增强普通进程的能力,例如实现高性能的安全沙箱和垃圾回收机制。
虚拟化技术是现代计算基础设施的基石,它深刻地改变了我们部署、管理和利用计算资源的方式。

课程 P19:第20讲 - 内核与高级语言 (HLL) 🧠💻
在本节课中,我们将探讨一个操作系统领域的核心问题:应该使用何种语言来编写内核?我们将通过分析一篇研究论文,来了解使用高级语言(如Go)编写内核的利弊、性能成本以及安全性考量。
概述 📋
内核是操作系统的核心,传统上多使用C语言编写,以获得对硬件的直接控制和极致性能。然而,C语言也带来了内存安全漏洞等风险。高级语言(HLL)提供了内存安全、自动内存管理等优势,但通常被认为会带来性能开销。本节课我们将深入分析一篇论文,该论文通过构建一个名为“Biscuit”的Go语言内核,并与Linux进行对比,旨在量化这种权衡。
背景与动机 🤔
上一节我们介绍了内核编程语言选择这一普遍问题。本节中,我们来看看这篇论文产生的具体背景和动机。
许多流行的内核,如Linux和Windows,都是用C语言编写的。C语言提供了对内存分配、硬件访问的完全控制,并且运行时极小。然而,编写安全的C代码非常困难,常见的漏洞包括缓冲区溢出、释放后使用等。根据CVE记录,仅2017年就有40个Linux内核漏洞可导致攻击者完全控制机器。
高级语言(如Java、Go、Python)的一个主要吸引力在于它们能提供内存安全,从而避免上述大部分漏洞。此外,它们还提供自动内存管理(垃圾回收)、良好的抽象和并发支持等好处。
那么,为什么不用高级语言编写所有内核呢?原因在于性能成本。高级语言通常需要边界检查、垃圾回收等操作,这会带来开销,有时被称为“高级语言税”。此外,内核编程还需要直接内存访问、手写汇编等能力,这可能与高级语言的特性不兼容。
这篇论文的目标就是衡量在内核中使用高级语言而非C语言的整体权衡,包括安全性、可编程性以及性能成本。
研究方法论 🔬
在了解了问题背景后,我们来看看论文是如何设计实验来回答这个问题的。
核心方法是构建一个名为Biscuit的新内核,它使用具有自动内存管理的高级语言(Go)编写,但遵循传统的单体内核架构。这样做的目的是为了与C语言内核(如Linux)进行公平的比较。
以下是实验设置的关键点:
- 对比对象:在左侧是使用Go编写的Biscuit内核;在右侧是作为对比基准的Linux内核。
- 功能子集:Biscuit实现了与Linux大致相同的系统调用子集和接口。
- 相同应用:在Biscuit和Linux上运行相同的应用程序(如Nginx网络服务器),生成相同的系统调用轨迹。
- 性能测量:通过比较两者执行必要操作的表现,来分析高级语言带来的差异。
选择Go语言的原因包括:它是静态编译语言,能生成高性能代码;其设计考虑了系统编程;并且它拥有垃圾回收器,这有助于我们评估自动内存管理的成本。
Biscuit 内核设计与挑战 ⚙️
现在,我们进入Biscuit内核本身,看看它的工作原理以及实现过程中遇到的一些预期之内和意料之外的挑战。
Biscuit采用了与XV6/Linux类似的经典内核模型,包含用户空间和内核空间。用户程序(用C编写)通过系统调用接口与内核交互。Biscuit内核本身则实现了进程、文件系统、网络栈、驱动程序等模块。
关键特性与实现细节
- 多核与并发:Biscuit是多核的,并利用Go的协程(goroutine)来实现内核线程。每个用户线程对应一个内核协程。
- 系统调用流程:与XV6类似,通过陷入指令进入内核,由对应的内核协程处理。
- 页表切换:在进入/退出内核时切换页表。由于使用未修改的Go运行时调度器,需要在软件中复制用户/内核空间的数据。
- 中断处理:设备中断处理程序几乎不做事,仅设置标志,由专门的Go协程来实际处理中断,以避免在中断上下文中分配内存或持有锁。
主要挑战:堆耗尽 (Heap Exhaustion)
最大的挑战之一是处理内核堆内存耗尽的情况。在内核中,像文件描述符、套接字等对象是动态分配的。当堆满时,新的分配请求会失败。
在C语言中,malloc可以返回NULL来表示失败,但Go语言的new操作不会失败。这迫使Biscuit需要一种不同的解决方案。
Biscuit的解决方案是预留(Reservation):
- 在每个系统调用开始时,先预留足够执行该系统调用所需的最大内存量。
- 如果内存不足,系统调用会等待(此时未持有任何锁),内核可以尝试回收内存(如刷新缓存)。
- 一旦预留成功,系统调用保证可以执行完毕,不会中途因内存不足而失败。
- 系统调用结束后,预留的内存被释放回池中。
关键问题在于如何计算一个系统调用所需的最大内存量。Biscuit利用了Go生态中的静态分析工具,通过分析调用图、对象大小、循环边界等,保守地估算出这个上界。这项工作需要人工介入和注释,但总体上是可行的。
评估:收益与成本 ⚖️
在了解了Biscuit如何工作之后,本节我们重点分析论文的评估结果,即使用高级语言到底带来了哪些好处,又付出了什么代价。
收益分析
首先,论文确认Biscuit确实使用了丰富的高级语言特性(如分配、垃圾回收、映射、闭包、接口等),其使用模式与大型Go项目相似。
在代码简化方面,高级语言带来了显著好处:
- 自动内存管理:简化了资源释放。例如,进程退出时,其地址空间等数据结构会被垃圾回收器自动清理。
- 内置数据结构:如映射(map),避免了手动实现哈希表的麻烦。
- 解决并发共享难题:当多个线程共享一个对象(如缓冲区)时,确定何时释放非常棘手。在C中可能需要复杂的引用计数或RCU机制。而垃圾回收器会自动追踪引用,当没有协程引用该对象时便会释放,对程序员完全透明。
在安全性方面,论文分析了Linux的CVE漏洞。在40个内存安全漏洞中,有29个在Go中会直接避免(如缓冲区溢出会引发panic而非漏洞),11个逻辑错误则无法避免。总体来看,高级语言消除了大部分内存安全漏洞。
成本分析(性能)
性能比较是核心。在运行Nginx等应用时,Biscuit的吞吐量比Linux慢大约10-15%。虽然并非苹果对苹果的完全公平比较,但差距并非数量级(如2倍、10倍)那么大。
为了进行更精确的比较,论文实现了一个完全相同的代码路径(管道乒乓测试)在C和Go中。结果显示,Go版本慢了约15%,主要开销来自于堆栈检查、安全指针检查等C中不存在的指令。
垃圾回收(GC)的开销是关注重点。在常规负载下,GC开销仅占执行时间的约3%。然而,GC开销与存活对象数量线性相关。实验表明,要维持GC开销在10%以下,需要空闲内存大约是存活内存的2倍(即总堆大小约为存活内存的3倍)。
垃圾回收导致的停顿时间:最大单次停顿约150微秒,单个HTTP请求可能经历的最大总停顿约582微秒。这对于尾延迟要求(通常在毫秒级)的应用来说,尚在可接受范围内。
结论与思考 💡
通过整篇论文的分析,我们现在可以回到最初的问题:应该使用什么语言编写内核?
论文并没有给出一个明确的“是或否”的答案,但提供了一些重要的思考维度:
- 教育与实践的差异:像XV6这样的教学操作系统使用C语言,是为了让学生透彻理解硬件与软件接口之间的每一层。而生产级内核的选择则需要权衡更多因素。
- 权衡取舍:
- 如果极致性能和最小内存占用是首要目标,C语言可能仍是首选。
- 如果安全性和开发效率更为重要,并且可以接受一定的性能开销(例如10-30%),那么高级语言是一个完全合理甚至更优的选择。
- 关键认知:高级语言并非内核编程的障碍。论文表明,用Go实现与Linux类似的高性能优化(如大页、无锁读取、目录缓存等)是可行的。主要的挑战在于如何与语言运行时(如垃圾回收器)协作,而非语言本身的能力限制。
最终,编程语言是一种工具。无论是C还是Go,都可以用来构建内核和用户程序。选择取决于项目的具体目标、约束条件和团队偏好。
总结 🎯

本节课我们一起学习了关于内核编程语言选择的深入探讨。我们通过一篇研究论文,了解了使用Go这样的高级语言编写内核(Biscuit)所带来的优势,如内存安全、代码简化,以及所需面对的性能成本挑战,特别是垃圾回收的开销和堆耗尽问题的创新解决方案。最重要的收获是,这是一个需要根据具体场景进行权衡的工程决策,没有放之四海而皆准的答案。


操作系统原理 P2:第3讲 - 操作系统组织与系统调用 🖥️🔧
在本节课中,我们将要学习操作系统的核心组织原则,特别是如何通过硬件支持实现应用程序与操作系统之间的强隔离。我们将探讨内核模式与用户模式、系统调用的工作原理,以及这些概念在RISC-V架构中的具体体现。
概述 📋
操作系统的主要设计目标之一是实现强隔离。这意味着一个应用程序中的错误或恶意行为不应影响其他应用程序或操作系统本身。为了实现这一点,现代操作系统依赖于硬件提供的两种关键机制:用户/内核模式和虚拟内存(页表)。本节课将首先探讨为什么隔离如此重要,然后深入讲解这两种机制如何工作,以及应用程序如何通过系统调用安全地请求内核服务。
隔离:核心设计目标 🛡️
上一节我们概述了课程目标,本节中我们来看看驱动操作系统组织的核心目标:隔离。
我们运行多个应用程序,例如Shell、echo或find。理想情况下,如果Shell中存在一个错误,它不应该影响其他应用程序。同样,应用程序中的错误也不应导致操作系统崩溃。这种强隔离对于系统的稳定性和安全性至关重要。
考虑一个没有操作系统的简单设计(或操作系统仅作为一个库)。应用程序将直接与硬件(CPU、内存、磁盘)交互。这种设计会带来严重的隔离问题:
- 协作式调度问题:如果只有一个CPU,应用程序需要“友好地”定期让出CPU以供其他程序运行(协作式调度)。但如果一个应用程序陷入无限循环,它将永远不会让出CPU,导致整个系统停滞。
- 内存隔离问题:所有应用程序的代码和数据都存放在物理内存中。如果没有边界,一个应用程序可以轻易地读写属于另一个应用程序的内存,导致错误传播和安全漏洞。
因此,操作系统的一个主要存在理由就是强制执行多路复用和强内存隔离。Unix系统调用接口(如fork、exec、文件操作)正是经过精心设计,以抽象硬件资源(CPU、内存、磁盘),使得操作系统能够在幕后实现这种隔离。
fork和进程抽象了CPU,使得操作系统可以在多个进程之间进行时间片轮转。exec和内存管理抽象了物理内存,应用程序无法直接访问特定的物理地址。- 文件抽象了磁盘块,操作系统控制数据在磁盘上的布局和访问权限。
内核模式与用户模式 🏰
为了实现强隔离,操作系统必须能够防御有缺陷或恶意的应用程序。这需要在应用程序和操作系统之间建立一道“坚固的墙”。硬件通过提供用户模式和内核模式来支持这一点。
处理器有两种操作模式:
- 内核模式:在此模式下,CPU可以执行所有指令,包括特权指令(例如直接操作硬件设备、修改页表寄存器的指令)。
- 用户模式:在此模式下,CPU只能执行非特权指令。如果尝试执行特权指令,处理器将产生一个异常(陷阱),并将控制权交还给操作系统。
CPU内部有一个特殊的寄存器位来标识当前模式。设置该位到内核模式的指令本身就是一个特权指令,这意味着用户程序无法自行切换到内核模式。这确保了用户程序始终被限制在“沙箱”中运行。
系统调用:通往内核的桥梁 🌉
既然用户程序无法直接执行特权操作,那么它们如何请求操作系统提供服务(如读写文件、创建进程)呢?答案是通过系统调用。
系统调用是用户程序主动进入内核模式的受控方式。其工作流程如下:
- 用户程序执行一个特殊的指令(在RISC-V中是
ecall),该指令会触发一个硬件陷阱。 - 硬件自动完成以下动作:
- 将处理器模式从用户模式切换到内核模式。
- 保存当前的程序计数器(PC)到SEPC寄存器。
- 将陷阱的原因保存到SCAUSE寄存器。
- 跳转到一个预设的内核地址(STVEC寄存器指向的陷阱向量)开始执行内核代码。
- 内核的陷阱处理代码开始运行。它检查陷阱原因(SCAUSE),发现是来自用户空间的系统调用。
- 内核根据用户程序传递的参数(通常通过寄存器,如RISC-V的a0-a6)来确定是哪个系统调用(如
read、write、fork),并执行相应的内核服务。 - 服务完成后,内核执行特权指令
sret。 - 硬件恢复现场:
- 将模式从内核模式切换回用户模式。
- 从SEPC寄存器恢复PC,程序从
ecall之后的下一条指令继续执行。
这个过程就像用户程序“敲门”(ecall),内核“开门”处理请求,然后“送客”(sret)并让用户程序继续。所有特权操作都在内核模式下安全完成。
RISC-V中的实现示例 💻
让我们在RISC-V和XV6的上下文中具体看看。以下是write系统调用在用户空间的简化汇编视图:
# 用户程序调用 write(1, “hello\n”, 6)
li a0, 1 # 文件描述符 (stdout)
la a1, hello_str # 缓冲区地址
li a2, 6 # 字节数
li a7, SYS_write # 将系统调用编号放入 a7
ecall # 执行系统调用,陷入内核
# ... ecall 返回后继续执行
当执行ecall后:
- 硬件切换到内核模式,跳转到
stvec指向的内核陷阱处理程序(如uservec或trampoline代码)。 - 内核保存用户寄存器状态,然后调用
usertrap()函数。 usertrap()检查scause,发现是系统调用,于是调用syscall()函数。syscall()根据a7中的编号(SYS_write)索引系统调用表,找到对应的内核函数sys_write并执行。sys_write在内核中完成实际的写操作。- 返回路径:
syscall()->usertrap()-> 恢复用户寄存器 -> 执行sret指令返回用户空间。
总结 🎯
本节课我们一起学习了操作系统组织的基石。



- 我们首先明确了强隔离是操作系统的核心设计目标,它防止应用程序相互干扰或破坏操作系统。
- 为了实现隔离,硬件提供了用户/内核模式。用户模式限制应用程序只能执行非特权指令,而内核模式则允许操作系统执行所有必要的特权操作。
- 应用程序通过系统调用接口安全地请求内核服务。执行
ecall指令会触发一个硬件陷阱,使CPU切换到内核模式并跳转到内核的陷阱处理代码。 - 我们以RISC-V架构和XV6操作系统为例,简要追踪了一个
write系统调用从用户空间到内核空间再返回的完整流程。







理解用户/内核模式的划分以及系统调用的过渡机制,是理解操作系统如何管理和保护资源的关键第一步。在接下来的课程中,我们将深入探讨进程调度、虚拟内存等如何利用这些机制来构建一个完整、安全的多任务系统。
课程21:网络基础与Livelock问题 🖧
在本节课中,我们将学习计算机网络的基本概念,包括数据包格式、网络协议栈的层次结构,以及网络系统中一个重要的性能问题——Livelock(活锁)。我们将从最底层的以太网协议开始,逐步向上探讨IP、UDP等协议,并分析一个典型网络软件栈的工作方式。最后,我们将深入研究一篇关于路由器性能的论文,理解Livelock现象的成因及其解决方案。
网络概述与连接方式 🌐
网络用于连接不同的主机。有两种主要的连接视图:一种是本地连接,主机通过如以太网交换机或Wi-Fi等媒介直接相连;另一种是通过路由器连接多个独立的局域网(LAN),构成更大的互联网。
- 局域网(LAN):规模有限,通常连接数十到数百台主机,所有主机可以直接看到彼此的广播数据包。
- 互联网:由许多独立的局域网通过路由器连接而成。路由器查看数据包中的地址信息,决定将其转发到哪个网络。
从协议角度看,本地通信由以太网协议负责,而远距离通信则在以太网之上分层,由IP(互联网协议) 处理。
以太网数据包格式 🔌
当同一局域网上的两台主机需要通信时,它们使用以太网协议。发送的数据单元称为以太网帧。
一个以太网帧包含一个头部和有效载荷。头部有三个关键字段:
- 目的地址:48位(6字节)数字,唯一标识目标主机的网络接口卡(NIC)。
- 源地址:48位数字,标识发送主机的NIC。
- 类型字段:16位,指示接收主机应如何处理该数据包的有效载荷(即,应使用哪个更高级别的协议)。
公式表示以太网头部:
| 目的地址 (6字节) | 源地址 (6字节) | 类型 (2字节) | 有效载荷 |
此外,硬件会在帧的开始和结束处添加特殊的位模式(前导码和帧尾),这些对软件不可见。
可以使用 tcpdump 工具查看实际的以太网数据包。


地址解析协议(ARP) 🗺️
在以太网层面,每台主机有一个48位的以太网地址。但在互联网上通信,需要使用32位的IP地址。IP地址的高位包含网络号信息,帮助路由器进行路由;低位则标识本地网络上的特定主机。
当一个IP数据包需要发送给同一局域网上的另一台主机时,发送方需要知道目标主机的以太网地址。ARP(地址解析协议) 就是用于动态地将IP地址映射到以太网地址的请求-响应协议。
ARP工作流程:
- 发送主机广播一个ARP请求包,询问“谁拥有这个IP地址?请用你的以太网地址回复”。
- 拥有该IP地址的主机收到请求后,会发送一个ARP响应包,包含其IP地址和以太网地址的映射关系。
ARP包被封装在以太网帧中,其以太网类型字段为 0x0806。
协议嵌套与数据包处理流程 📦
网络协议通常以嵌套的方式组织。例如,一个数据包可能包含:
- 一个以太网头部(用于本地传输)
- 一个IP头部(用于互联网路由)
- 一个UDP头部(用于将数据包递送给正确的应用程序)
- 最后是应用程序数据(如DNS查询)
发送时:应用程序数据被逐层封装,每层添加自己的头部。
接收时:数据包被逐层解析和剥离头部,每层根据头部信息决定将数据交给上一层的哪个协议或应用程序处理。
这种结构的关键在于每个头部都有一个字段(如以太网的类型字段、IP的协议字段)来指示下一个头部的类型。
IP数据包格式 🌍
IP协议负责在互联网上任何地方传递数据包。IP头部在从源主机到目标主机的整个路径中基本保持不变。
IP数据包的关键字段包括:
- 目的IP地址:32位,目标主机的IP地址。
- 源IP地址:32位,发送主机的IP地址。
- 协议字段:指示在IP头部之后是哪种协议的数据(如TCP=6,UDP=17)。
公式表示IP头部关键部分:
| ... 其他字段 ... | 源IP地址 (4字节) | 目的IP地址 (4字节) | ... 其他字段 ... | 协议 (1字节) |
路由器通过查看目的IP地址的高位(网络号)来决定数据包的下一跳。
用户数据报协议(UDP) 🚀
IP协议将数据包送达目标主机,但一台主机上运行着许多应用程序。UDP(用户数据报协议) 提供了一种简单的机制,将数据包递送给主机上的特定应用程序。
UDP头部的关键字段是两个端口号:
- 目的端口:指示数据包应该递送给哪个应用程序。
- 源端口:通常由发送方应用程序选择,以便接收方回复时使用。
应用程序通过套接字API声明它感兴趣接收哪个端口的数据包。操作系统会维护端口号与文件描述符(供应用程序读写)之间的映射关系。
UDP是一种“尽力而为”的协议,不提供丢失重传、顺序保证等复杂功能。
网络软件栈结构 🏗️
主机内的网络软件通常组织成一个分层的协议栈。一个典型的简化栈从上到下包括:
- 应用程序(如浏览器)
- 套接字层:管理文件描述符与端口号的映射,以及等待读取的数据包队列。
- 传输层(UDP/TCP):处理端口寻址。UDP较简单;TCP复杂,负责可靠传输。
- 网络层(IP):处理IP地址和路由。
- 网络接口驱动层:与物理网络接口卡(NIC)交互。
数据包向上流动时,各层解析并剥离头部;向下流动时,各层添加头部。整个栈共享一个数据包缓冲区分配器(如mbuf)。
数据包接收与中断处理 ⚡
网络接口卡(NIC)接收到数据包后,如何通知主机CPU?一种经典方式是使用中断。
传统中断流程:
- 数据包到达NIC。
- NIC触发硬件中断。
- CPU执行中断处理程序(驱动程序的一部分)。
- 中断处理程序从NIC内存中复制数据包到主机内存的数据包队列中。
- 中断处理程序返回。
- 另一个独立的网络处理线程(或软中断)从队列中取出数据包,进行IP处理、转发或递交给应用程序。
这种设计允许快速将数据包从有限的NIC缓冲区转移到更大的主机内存队列中,但也引入了并发实体:中断处理程序和网络处理线程。
现代NIC与DMA环 🔄
现代高性能NIC(如E1000)使用更高效的直接内存访问(DMA) 机制。
DMA环工作流程:
- 主机驱动程序初始化时,在主机内存中分配一组数据包缓冲区,并创建一个指向这些缓冲区的环状数组(DMA环)。
- 驱动程序将DMA环的地址告知NIC。
- 当数据包到达时,NIC直接将数据包内容通过DMA写入主机内存中环所指向的下一个缓冲区,然后更新环指针。
- 主机驱动程序通过检查环的状态,即可知道是否有新数据包到达,而无需逐字节复制。
这减少了CPU的参与,提升了效率。通常有独立的接收环(RX Ring) 和发送环(TX Ring)。
Livelock(活锁)问题与论文分析 📉
上一节我们介绍了网络栈的基本结构和数据流。本节中,我们来看一个在高负载下可能出现的严重性能问题——Livelock。
在一篇关于路由器性能的论文中,作者绘制了路由器的输入数据包速率与输出数据包速率的关系图。理想情况下,随着输入速率增加,输出速率应同步增加,直到达到系统处理能力的上限(如CPU或链路带宽限制),然后保持平稳。
然而,论文中的实际曲线在达到峰值后,输出速率反而急剧下降,甚至在输入速率极高时趋近于零。这就是Livelock现象。
Livelock成因分析:
- 每个输入数据包都会产生一个中断。
- 中断处理程序需要CPU时间从NIC复制数据包。
- 当输入速率超过系统处理能力时,大量时间被用于处理中断本身。
- 由于中断通常具有高优先级,这饿死了实际负责转发数据包的网络处理线程。
- 结果就是,CPU 100%忙于响应中断和复制即将被丢弃的数据包(因为队列已满),而没有时间执行真正的转发工作,导致有效吞吐量降为零。
解决方案核心思想:将中断驱动模式改为轮询驱动模式。
- 低负载时:使用中断,让CPU在无数据包时休眠。
- 高负载时:关闭NIC的中断。让网络处理线程主动轮询NIC,一次读取多个数据包进行处理。这样可以避免中断处理程序“窃取”本应用于转发任务的CPU时间,从而防止Livelock。
总结 📚
本节课中我们一起学习了计算机网络的基础知识。我们从最底层的以太网帧格式和ARP协议开始,理解了本地网络通信。接着,我们探讨了IP协议如何实现全球路由,以及UDP协议如何通过端口号将数据包递送给正确的应用程序。我们剖析了典型网络协议栈的分层结构,以及数据包在其中的流动过程。最后,我们深入研究了网络系统中的一个关键性能问题——Livelock,分析了其在高负载下由于中断处理与任务调度失衡而导致的性能崩溃,并了解了通过中断与轮询相结合的策略来避免此问题的核心思路。这些概念是理解现代操作系统网络子系统的基础。

课程 P21:第22讲 - Meltdown 攻击详解 🧨

在本节课中,我们将要学习一种名为“熔毁”(Meltdown)的现代处理器安全攻击。这种攻击利用了CPU内部的微架构特性,打破了操作系统内核与用户程序之间的隔离,使得用户程序能够读取内核内存中的敏感数据。我们将从基本概念入手,逐步剖析攻击的原理、依赖的技术以及相应的防御措施。
概述:什么是熔毁攻击?
熔毁攻击是一种利用现代CPU推测执行(Speculative Execution)和缓存(Cache)机制的安全漏洞。它允许一个运行在用户空间的恶意程序,绕过硬件和操作系统提供的内存保护,读取内核空间或其他受保护内存区域的数据。这篇论文于2018年初发表,揭示了长期以来被认为是坚不可摧的硬件隔离机制中存在的严重缺陷。
核心概念:隔离与微架构
上一节我们介绍了熔毁攻击的基本概念,本节中我们来看看攻击所依赖的两个核心硬件机制:推测执行和CPU缓存。理解这些概念是理解攻击如何工作的关键。
推测执行(Speculative Execution)
推测执行是CPU用于提升性能的一种优化技术。CPU会预测程序分支(如if语句)的走向,并提前执行预测路径上的指令,即使它尚未确定该路径是否正确。如果预测正确,则节省了等待时间;如果预测错误,CPU会“撤销”所有推测执行指令的影响,使程序状态恢复到预测前的样子。
以下是一个说明推测执行的简化代码示例:
// 假设 r0 中存储了一个地址
1. load r1, [valid] // 从内存加载 valid 变量的值
2. if r1 == 0 goto 7 // 如果 valid 为 0,跳转到第7行
3. load r2, [r0] // 从 r0 指向的地址加载数据
4. add r3, r2, 1 // 将加载的数据加1
5. store [result], r3 // 存储结果
6. ...
7. // 程序继续执行
在这个例子中,CPU可能在valid的值从内存加载完成之前(第1行),就开始推测性地执行第3行和第4行的指令。如果后来发现valid为0,CPU会取消第3、4行指令对寄存器r2和r3的修改。
CPU缓存(Cache)
CPU缓存是一种小型、高速的内存,用于存储最近访问过的数据,以减少访问主内存(RAM)的延迟。缓存通常分为多级(如L1、L2、L3)。当CPU需要数据时,首先在最快的L1缓存中查找。如果“命中”,则数据在几个周期内返回;如果“未命中”,则需要访问更慢的下一级缓存或主内存,耗时可能达到数百个周期。
缓存的行为在性能上是“可见”的,因为程序可以通过精确测量内存访问时间来推断特定数据是否在缓存中。这种特性被熔毁攻击所利用。
攻击原理:如何绕过内存保护?
上一节我们了解了推测执行和缓存,本节中我们来看看攻击者如何组合这些技术来窃取内核数据。
攻击的核心思想是:利用一次本应失败的、对内核地址的加载操作,在CPU推测执行阶段,让该操作影响缓存状态,然后通过测量缓存访问时间来推断出被加载的内核数据值。
以下是论文中攻击代码的简化版本:
// 攻击者用户空间代码
char buffer[256 * 4096]; // 用户缓冲区,大小足够大以确保不同索引位于不同的缓存行
unsigned long kernel_addr = 0xffffffff80000000; // 假设要窃取的内核地址
int bit_value;
// 1. 确保目标用户缓冲区不在缓存中
clflush(&buffer[0]);
clflush(&buffer[1 * 4096]);
// 2. 执行会引发页面错误的指令(访问内核地址)
// 同时安排一个长时间运行的指令,延迟错误的发生和指令的“退休”
asm volatile(
"mov (%1), %%rax \n" // 尝试从内核地址加载数据 -> 这将导致页面错误
"shl $12, %%rax \n" // 将数据的低位左移12位(乘以4096)
"mov (%0, %%rax, 1), %%rbx \n" // 以rax为索引访问用户缓冲区 -> 推测性执行
:
: "r" (buffer), "r" (kernel_addr)
: "rax", "rbx"
);
// 3. 攻击触发页面错误,进程可能崩溃或被异常处理程序捕获
// 4. 在错误处理程序中或重启后,通过“刷新+重载”技术检测缓存状态
time1 = rdtsc();
access1 = buffer[0];
time2 = rdtsc();
access2 = buffer[1 * 4096];
time3 = rdtsc();
// 比较访问时间,时间短的说明对应的缓冲区条目在缓存中
if ((time2 - time1) < (time3 - time2)) {
bit_value = 0; // buffer[0] 在缓存中,说明内核数据的低位是0
} else {
bit_value = 1; // buffer[1*4096] 在缓存中,说明内核数据的低位是1
}
攻击步骤分解:
- 刷新缓存:使用
clflush指令确保攻击者自己的缓冲区(buffer)不在CPU缓存中。 - 触发推测性非法访问:执行一条加载指令,目标是一个用户程序无权限访问的内核虚拟地址。在Intel CPU上,即使该加载操作因权限不足最终会失败(页面错误),在指令“退休”前,CPU的推测执行机制仍会将内核数据加载到一个临时寄存器中,并继续执行后续指令。
- 将数据值转换为缓存状态:后续的推测性指令使用这个临时寄存器中的值(即窃取的内核数据位)作为索引,去访问一个巨大的用户缓冲区。访问哪个缓冲区条目(如
buffer[0]或buffer[4096])取决于内核数据的值。这次访问会将对应的用户缓冲区条目加载到CPU缓存中。 - 处理错误:最初的非法规访问最终“退休”,CPU引发页面错误。攻击程序可以通过注册错误处理程序来捕获此错误并继续执行,或者通过其他方式(如多进程)在错误发生后恢复。
- 探测缓存:通过“刷新+重载”技术,精确测量访问
buffer[0]和buffer[4096]的时间。访问时间明显更短的那个条目,说明它在缓存中,从而反推出内核数据的值。
攻击依赖的条件与修复
上一节我们详细分析了攻击流程,本节中我们来看看攻击成功所需的条件以及业界如何修复这个漏洞。
攻击依赖的条件
- 内核映射存在于用户页表:攻击需要知道目标内核数据的虚拟地址,并且该地址在进程的页表中有映射(但用户权限位PTE_U被清除)。在论文发表时,许多操作系统(如Linux)为了系统调用性能,会将内核空间映射到每个用户进程的地址空间中。
- CPU的推测执行不提前检查权限:在推测执行阶段,Intel CPU不会立即检查内存加载操作的权限,而是等到指令退休时才检查。这使得内核数据能被临时加载。
- 能够精确测量时间:攻击需要高精度计时器(如
rdtsc指令)来区分缓存命中与未命中的微小时间差。 - 共享的CPU缓存:L1缓存通常基于虚拟地址,并且在系统调用不切换页表的情况下,可能同时包含用户和内核数据。
主要修复措施
以下是针对熔毁攻击的主要防御方案:
-
内核页表隔离(KPTI,原称KAISER):
- 原理:不再将内核空间的完整映射放入用户进程的页表中。当运行用户代码时,页表只包含用户空间的映射。进行系统调用时,通过类似蹦床页的机制切换到包含内核映射的独立页表。
- 效果:在用户态执行攻击代码时,内核虚拟地址在当前的页表中没有有效映射,甚至无法进行地址翻译,从而从根本上阻止了攻击。
- 代价:系统调用因需要切换页表和可能刷新TLB/缓存而变慢,但对大多数工作负载影响有限(约5%性能开销)。
-
CPU微码更新(硬件修复):
- 原理:修改CPU的微码,使得在推测执行阶段进行内存加载时,也进行权限检查。如果权限不足,则不返回数据,后续推测性指令也无法利用该数据。
- 效果:从硬件层面堵住漏洞。据信AMD CPU和一些新版Intel CPU已采用此方式。
- 代价:可能需要增加少量晶体管,但对最终用户透明。
总结与思考
本节课中我们一起学习了熔毁攻击。我们从操作系统的基本隔离机制出发,探讨了现代CPU的推测执行和缓存这两个关键的微架构优化。通过分析攻击代码,我们看到了攻击者如何巧妙地组合一次注定失败的内存访问、CPU的推测执行特性以及对缓存状态的侧信道探测,最终实现了对内核内存的窃取。
熔毁攻击及其同类攻击(如Spectre)深刻地提醒我们,计算机系统的安全是一个多层次、不断演化的挑战。长期以来被视为可信基石的硬件隔离机制,也可能因其复杂的性能优化实现而出现裂缝。这促使操作系统开发者、硬件制造商和安全研究人员必须更紧密地合作,在追求极致性能的同时,不断审视和加固整个系统的安全边界。
防御此类攻击不仅需要软件层面的补丁(如KPTI),也需要硬件设计的改进。同时,它也引发了关于在云计算、浏览器沙箱等高度共享环境中,如何保证强隔离性的更深层次思考。安全之路,道阻且长。

课程 P22:第 23 讲 - RCU 🧠
概述
在本节课中,我们将学习如何为多核硬件上的共享数据获取良好性能,特别是针对那些读取远多于写入的数据。我们将重点探讨 Linux 内核中一种非常成功的技术——RCU。我们将了解其工作原理、适用场景以及它如何通过避免读者端的锁和写操作来显著提升性能。
多核性能与共享数据
上一节我们介绍了多核性能的挑战。现代计算机拥有多个核心并行运行并共享内存,内核本身就是一个并行程序。为了获得高性能,我们需要确保内核的许多工作能在不同核心上尽可能并行执行。
如果系统上有许多进程在运行,并且它们都在进行系统调用,很多时候,不同进程的系统调用看起来是独立的,应该能够互不干扰地并行执行。然而,问题在于内核为了管理资源(如内存、CPU、磁盘缓存等),共享了许多数据结构。这意味着即使两个进程的系统调用互不相关,如果它们都使用了内核中的相同数据结构,我们也需要一种机制来确保它们能安全地并发使用这些数据。
传统锁的局限性
我们之前学习过一种保证正确性的机制——自旋锁。自旋锁虽然易于推理,但其作用是防止并行执行,从而会降低性能。自旋锁直接减少了并行性,这在追求高性能的场景下并不总是理想的。
因此,我们将聚焦于读取密集型数据的情况,即数据大部分时间被读取,相对较少被写入。我们将以一个简单的单向链表作为主要示例。
示例:受保护的链表
假设我们有一个全局头指针指向一个链表,每个链表元素包含一些数据(例如一个字符串)和一个指向下一个元素的指针。我们假设对这个链表的大多数操作是读取(例如扫描链表查找内容),但偶尔也会有写入操作(例如修改元素数据、插入或删除元素)。
在 XV6 这样的简单系统中,我们可能会用一个锁来保护整个链表。这意味着不仅写入者需要获取锁,读取者也需要获取锁,以防止在读取过程中链表被修改。这种方法的缺陷在于,即使没有写入者,每次读取操作也需要获取锁,这阻止了多个读取者并行执行,从而无法充分利用多核优势。
读写锁:一种改进方案
为了改进上述情况,一种思路是使用一种新类型的锁,它允许多个读取者,但只允许一个写入者。这就是读写锁。
以下是读写锁的接口:
- 读取者调用
r_lock()和r_unlock()。 - 写入者调用
w_lock()和w_unlock()。
其语义是:可以同时有多个读取者持有锁,或者恰好有一个写入者持有锁,但不能同时有读取者和写入者。这看起来是读取密集型数据结构的理想解决方案。
读写锁的实现与性能问题
然而,如果我们深入研究读写锁的实现细节,尤其是在数据被频繁读取时,会发现一些问题。Linux 内核中读写锁的一个简化实现使用了一个计数器 n:
n = 0:锁未被任何线程持有。n = -1:锁被一个写入者持有。n > 0:锁被n个读取者持有。
r_lock 函数的核心是一个循环,它使用 compare-and-swap 原子指令来安全地增加计数器。compare-and-swap 指令的语义如下:
// 伪代码:如果 *ptr 的值等于 expected,则将其设置为 new,并返回1;否则返回0。
int compare_and_swap(int *ptr, int expected, int new) {
if (*ptr == expected) {
*ptr = new;
return 1;
} else {
return 0;
}
}

尽管读写锁允许多个读取者,但其性能在多核系统上可能很差。原因在于,每个核心都有自己的缓存。当多个核心同时尝试获取读锁时,它们都需要读取并可能修改共享的锁变量 n。这会导致大量的缓存一致性通信(例如缓存行失效消息)。具体来说,如果有 N 个核心同时竞争读锁,总的消息开销或时间成本可能达到 O(N²) 级别。
关键问题在于:读写锁将原本可以是只读的、快速缓存访问的操作,转变成了涉及写入共享数据的昂贵操作。任何对可能被其他核心缓存的数据的写入,都需要核心间的通信,这在高并发读取时会成为性能瓶颈。
RCU 的核心思想
正是读写锁的性能问题催生了 RCU。RCU 的目标是允许读取者完全无锁且无写地访问数据。它通过让写入者遵循一些更复杂的规则,来换取读取者的极致性能。
我们将针对之前提到的“读者无锁读取时可能遇到的三种危险情况”来阐述 RCU 的解决方案。
思想一:通过副本更新(Read-Copy-Update)
问题:写入者直接修改链表元素的内容,导致读者可能看到部分更新的数据。
解决方案:禁止写入者就地修改数据。相反,写入者必须分配一个全新的元素副本,完全初始化这个新副本(包括数据和指针),然后通过一个原子的“提交写”操作,将前一个元素的 next 指针从指向旧元素改为指向新元素。
由于这个“提交写”是原子的(在常见硬件上,对齐的指针写入是原子的),读者在读取指针时,要么看到旧版本,要么看到完整初始化的新版本,绝不会看到处于中间状态的错误数据。
这个技术适用于可以通过单个原子写操作来“提交”更新的数据结构,如链表和树。
思想二:使用内存屏障
问题:编译器和处理器可能会对内存操作进行重排序。例如,写入者可能在实际初始化新元素内容之前,就执行了提交指针的写操作;或者读者可能在获取指针之前,就尝试读取指针指向的内容。
解决方案:写入者必须在提交写操作之前插入一个写内存屏障,确保所有对新元素的初始化写操作都在提交指针之前完成。
读取者必须在获取受保护指针之后、解引用该指针之前插入一个读内存屏障,确保获取指针的操作先于后续的读数据操作。
这保证了读者看到的指针所指向的数据是完整的。
思想三:延迟释放(垃圾回收思想)
问题:写入者提交了新元素后,旧元素需要被释放。但可能仍有读者在旧指针被替换前就获取了它,并正在访问旧元素。我们不能立即释放它。
解决方案:RCU 引入了一个宽限期的概念。规则如下:
- 读者规则:读者在 RCU 临界区(即持有指向 RCU 保护数据的指针时)不得发生上下文切换(即不能休眠)。
- 写入者规则:写入者在释放旧数据之前,必须等待一个宽限期,确保所有可能持有旧数据指针的核心都至少进行了一次上下文切换。
由于读者在临界区内不会休眠,那么在宽限期(所有核心都发生一次上下文切换)结束后,就可以确保没有任何读者仍持有旧数据的指针。此时,写入者安全地释放旧内存。
写入者可以通过调用 synchronize_rcu() 来同步等待宽限期结束,或者使用 call_rcu() 异步注册一个回调函数,在宽限期结束后执行释放操作。
RCU 使用示例
以下是使用 RCU 读取和更新链表的简化代码示例:
读取者代码
rcu_read_lock(); // 进入RCU读临界区,主要作用是禁止上下文切换
list_for_each_entry_rcu(e, &head, list) {
// rcu_dereference() 封装了读内存屏障
data = rcu_dereference(e->data);
// ... 使用 data ...
}
rcu_read_unlock(); // 退出RCU读临界区
读取者的开销极小:rcu_read_lock/unlock 几乎不做任何工作(主要是防止上下文切换),rcu_dereference 包含一个内存屏障。
写入者代码(替换第一个元素)
spin_lock(&list_lock); // 写入者之间仍需用锁互斥
old_e = head;
new_e = kmalloc(sizeof(*new_e)); // 分配新元素
new_e->data = new_data; // 初始化新元素
new_e->next = old_e->next;
// 写内存屏障 + 提交写
rcu_assign_pointer(head, new_e);
spin_unlock(&list_lock);
synchronize_rcu(); // 等待宽限期结束
kfree(old_e); // 安全释放旧元素
写入者的开销较大:需要锁、分配内存、内存屏障,以及可能耗时的 synchronize_rcu() 调用。
RCU 的优缺点与适用场景
优点
- 读者性能极高:几乎零开销,可真正并行。
- 特别适合读取占绝对主导、写入极少的工作负载。
缺点与限制
- 写入者开销大:写入操作更复杂、更慢。
- 读者限制:不能在 RCU 临界区内休眠或长时间持有指针。
- 数据结构限制:最适合能通过单次原子写操作更新的数据结构(如链表、树)。
- 可能读到旧数据:读者在宽限期前可能继续访问已被逻辑上“替换”的旧数据副本。
适用场景
RCU 在 Linux 内核中取得了巨大成功,广泛应用于许多读取密集型的数据结构,例如:
- 目录项缓存
- 文件系统对象缓存
- 网络路由表
- 模块引用计数
其核心魔力在于,它通过一种专门的垃圾收集机制(宽限期),完全消除了读者端的锁和写操作。
其他并发性能技术
RCU 主要解决读取密集型数据的问题。对于写入密集型数据,也有其他技术:
- 数据分片:将共享数据结构拆分为每个核心的私有部分(如每核心空闲链表、每核心统计计数器)。这样写入大多发生在本地,冲突减少。读取时需要聚合所有部分,因此将开销转移到了读取端。
- 序列锁:允许读者与写入者并发,但读者需要检查在读取期间数据是否被修改,如果被修改则重试。
选择哪种技术取决于具体的工作负载特征。
总结
本节课我们一起学习了 RCU 这一用于提升多核系统上读取密集型共享数据性能的关键技术。我们分析了传统锁和读写锁的局限性,深入探讨了 RCU 的三个核心思想:
- 读-复制-更新:通过创建副本来避免就地修改。
- 内存屏障:确保内存操作的顺序一致性。
- 延迟释放与宽限期:安全地回收不再被任何读者使用的旧数据。
RCU 的精妙之处在于,它通过让写入者承担更多责任,换取了读者端的极致性能,是一种非常成功的、以读者为中心的并发控制方案。理解 RCU 有助于我们设计出更能充分利用现代多核硬件能力的高性能系统。

课程 P23:Lecture 24 - 最终问答课 🎓
在本节课中,我们将回顾本学期的一些核心主题,并解答关于网络驱动程序和 mmap 系统调用的常见问题。我们将重点关注硬件与软件的交互、并发控制以及系统编程中的实用技巧。
概述 📋
本节课是本学期的最后一堂课,我们将以问答形式进行。主要讨论三个话题:网络驱动程序的解决方案、mmap 系统调用的原理,以及一些关于课程结构的常见问题。我们将从硬件结构开始,逐步深入到软件实现,确保初学者能够理解这些核心概念。
课程相关问题与后续学习路径 🛤️
在深入技术细节之前,我们先来解答一些关于课程本身的问题。许多同学询问了与操作系统相关的后续课程。
以下是MIT提供的一些相关系统课程:
- 6.828:操作系统工程课程,侧重于阅读论文和构建操作系统组件。
- 6.1810:本科生的操作系统课程,今年是第二年开设。
- 6.1750:计算机体系结构课程,侧重于硬件与系统的接口。
- 6.1910:计算结构课程,涉及硬件设计和编译器。
- 6.5840:分布式系统课程,将在春季学期开设。
- 6.1600:计算机系统安全原理课程,将在下一学年开设。
此外,如果你对系统研究感兴趣,可以关注最新的学术会议(如OSDI)和开源项目(如Linux内核)的动态。实践是学习系统知识的最佳方式,可以通过完成实验或自己动手构建项目来加深理解。
网络驱动程序详解 🔌
上一节我们介绍了课程相关的信息,本节中我们来看看网络驱动程序实验的核心挑战与解决方案。网络驱动的主要挑战在于理解硬件规格、处理并发以及进行有效的调试。
硬件结构 🖥️
理解驱动程序,首先要理解其交互的硬件。在我们的实验中,RISC-V处理器通过PCIe总线与E1000网卡相连。
- 内存映射I/O:网卡的控制寄存器被映射到处理器的物理内存地址空间。驱动程序通过普通的加载(
load)/存储(store)指令读写这些地址,从而控制硬件。 - 描述符环:网卡使用驻留在RAM中的“描述符环”来管理待发送(
TX)和待接收(RX)的数据包。这是一种生产-消费模型。- 发送环:驱动程序(生产者)将待发送数据包的描述符放入环中,并更新尾指针。网卡(消费者)从头指针处读取并发送数据包,然后更新头指针。
- 接收环:网卡(生产者)将收到的数据包描述符放入环中,并更新头指针。驱动程序(消费者)从尾指针处读取数据包,然后更新尾指针。
- DMA:网卡通过直接内存访问技术,直接在RAM和网络之间搬运数据包数据,无需CPU介入。
核心数据结构(描述符)示例:
// 发送描述符结构 (由硬件定义)
struct tx_desc {
uint64 addr; // 数据包在内存中的地址
uint16 length; // 数据包长度
uint8 cso;
uint8 cmd; // 命令字段 (如 EOP=1 表示包结束)
uint8 status; // 状态字段 (如 DD=1 表示描述符已处理完毕)
uint8 css;
uint16 special;
};
软件结构与并发控制 🔒
驱动程序代码分为“上半部分”和“下半部分”。
- 上半部分:在进程上下文(如系统调用)中运行,例如处理
send系统调用的e1000_transmit函数。 - 下半部分:在中断上下文(中断处理程序)中运行,例如响应数据包到达中断的
e1000_recv函数。
并发主要出现在两个场景:
- 多个CPU核心可能同时调用上半部分的发送函数。
- 中断处理程序(下半部分)可能在任何时候打断上半部分或用户态代码的执行。
关键问题:为什么接收函数通常不需要加锁?
因为中断处理程序在同一时刻只能在一个核心上运行,不会出现多个实例并发执行 e1000_recv 的情况。然而,如果接收处理程序需要调用网络栈,而网络栈又可能回调发送函数,且发送和接收共用同一把锁,则可能导致死锁。解决方案有:
- 接收函数完全不加锁(如果它与发送方无共享数据结构)。
- 为发送和接收使用不同的锁。
- 使用可重入锁。
代码实现要点 💻
以下是解决方案中的关键代码逻辑。

发送函数 (e1000_transmit) 的核心步骤:
- 获取发送锁,防止多个发送者冲突。
- 检查环中是否有空闲描述符(通过比较头尾指针)。
- 释放旧
mbuf,将新mbuf地址填入描述符。 - 设置描述符的命令字段(如
EOP和RS)。 - 使用内存屏障(
__sync_synchronize())确保描述符写入内存后,再更新网卡的尾指针寄存器,通知网卡有新包待发送。 - 释放锁。
接收函数 (e1000_recv) 的核心步骤:
- 读取网卡的尾指针,检查环中是否有新包到达(检查描述符的
DD状态位)。 - 使用
while循环处理所有已到达的数据包(而不仅是第一个)。这很重要,因为一次中断可能对应多个数据包的到达,需要全部处理以避免数据包滞留。 - 将数据包交付给上层网络栈(
net_rx)。 - 为描述符分配新的
mbuf缓冲区,以供网卡下次使用。 - 更新网卡的尾指针,告知网卡哪些描述符已被驱动程序回收。


mmap 系统调用 📄
上一节我们深入探讨了网络驱动,本节中我们来看看另一个有用的系统工具:mmap。它提供了另一种访问文件数据的方式。
传统的文件API(open, read, write, lseek)是流式的,适用于顺序访问。然而,当需要随机访问或修改文件内部复杂数据结构时,这种接口显得笨拙。
mmap 系统调用允许将文件的一部分或全部直接映射到进程的虚拟地址空间。之后,程序可以像访问普通内存一样读写文件内容,无需频繁调用read/write和lseek。

使用对比:
- 传统方式:打开文件 -> 读取数据到缓冲区 -> 修改缓冲区 -> 定位回文件开头(
lseek) -> 写回缓冲区。 mmap方式:打开文件 -> 映射文件到内存(mmap) -> 直接通过指针修改内存 -> 解除映射(munmap)。
这使得 mmap 特别适合数据库、编辑器等需要随机、细粒度访问文件内容的应用程序。
总结 🎯
本节课中我们一起学习了网络驱动程序实验的硬件原理、软件并发模型及关键实现细节,并了解了mmap系统调用如何简化对文件数据的随机访问。希望这些内容能帮助你巩固本学期的知识,并为后续的系统编程学习打下坚实的基础。



再次感谢大家本学期的积极参与和投入!


🧠 课程 P3:第4讲 - 页表
在本节课中,我们将要学习虚拟内存的核心机制——页表。我们将探讨地址空间的概念、RISC-V架构下的分页硬件如何工作,以及XV6操作系统是如何设置和使用页表的。通过理解这些内容,你将掌握实现内存隔离的关键技术。
🎯 概述:为什么需要虚拟内存?
虚拟内存的一个主要驱动力是实现隔离。我们希望用户应用程序(如shell、cat等)与操作系统内核之间,以及应用程序彼此之间,能够互不干扰地运行。具体到内存方面,我们需要实现内存隔离。
默认情况下,如果没有采取任何措施,我们就没有内存隔离。所有程序(内核和应用程序)的代码和数据都存储在同一个物理内存(DRAM)中。例如,如果cat程序错误地向地址1000写入数据,而这个地址恰好是shell程序内存的起始位置,那么就会破坏shell的运行,这显然是不可接受的。
我们的目标是让每个应用程序(包括内核)都拥有自己独立的地址空间。这样,当cat程序引用自己的地址1000时,它实际上是在引用自己地址空间内的1000,而不是shell地址空间内的1000。每个程序都在自己的地址空间中运行,无法直接访问其他程序的内存,从而实现了强隔离。
接下来的问题是如何在单一的物理内存上复用所有这些不同的地址空间。最常用且灵活的方法是使用页表。
⚙️ 分页硬件与地址转换
页表的实现依赖于硬件的支持,具体由处理器中的内存管理单元(MMU)负责。
以下是地址转换的基本流程:
- CPU执行指令(如加载或存储)时,使用的地址是虚拟地址。
- 这个虚拟地址被发送到MMU。
- MMU根据一个存储在内存中的映射表(即页表),将虚拟地址转换为物理地址。
- 转换得到的物理地址被用来访问实际的物理内存。
CPU视角下,所有启用了MMU后发出的地址都是虚拟地址。为了进行转换,MMU需要一个表(页表)。这个映射关系非常灵活:一侧是虚拟地址,另一侧是对应的物理地址。
页表本身也存储在物理内存中。CPU有一个特殊的寄存器(在RISC-V中称为SATP),它存储了当前活动页表根目录的物理地址。操作系统通过切换SATP寄存器的内容,来为不同的进程加载其对应的页表,从而实现地址空间的切换。由于修改SATP是特权指令,只有内核代码才能执行此操作,这保障了隔离性。
📄 页表的具体结构
如果为每一个可能的虚拟地址都设置一个映射条目,那么页表将变得异常巨大(在64位系统中是2^64个条目),这是不现实的。
因此,实际的实现采用了分页和分级页表的方式。
分页
硬件以页为单位进行地址转换。在RISC-V中,一个页的大小是4KB(4096字节)。虚拟地址被拆分为两部分:
- 页号:用于在页表中查找对应的物理页帧。
- 页内偏移:用于在找到的物理页帧内定位具体的字节。
公式:物理地址 = 物理页帧号 * 页大小 + 页内偏移
在RISC-V Sv39方案中,只使用64位虚拟地址中的低39位,因此虚拟地址空间最大约为512GB。物理地址则使用56位。
多级页表
为了更高效地管理稀疏的地址空间,RISC-V采用了三级页表结构。39位的虚拟地址(除去12位偏移后剩余的27位)被分为三个9位的索引:
- 第一级索引:在根页目录(由SATP指向)中查找,得到一个中间页目录的物理地址。
- 第二级索引:在中间页目录中查找,得到一个页表的物理地址。
- 第三级索引:在页表中查找,最终得到目标物理页帧的地址。
每一级页目录的大小都是一个页(4KB),每个条目(PTE)是8字节,因此一个页目录可以容纳512个条目。
这种分级结构的优势在于,如果地址空间的大部分区域未被使用,相应的中间页目录和页表就无需分配,节省了大量内存。
🏷️ 页表条目(PTE)与标志位
每个页表条目(PTE)是一个64位的字,其结构如下:
- 44位:物理页帧号。
- 10位:保留给未来扩展。
- 10位:标志位。
重要的标志位包括:
- V (Valid):该PTE是否有效。
- R (Read):是否允许读此页。
- W (Write):是否允许写此页。
- X (Execute):是否允许从此页执行指令。
- U (User):用户模式下的程序是否可以访问此页(与内核页区分)。
如果访问一个V=0的页,或者以不允许的方式(如写入一个W=0的页)访问,硬件会触发一个页错误异常。
🚀 加速转换:TLB
一次内存访问理论上需要三次内存读取(遍历三级页表),这看起来开销很大。为了解决这个问题,处理器内部有一个称为转址旁路缓冲器(TLB)的缓存。


TLB缓存了最近使用过的虚拟地址到物理地址的映射。当CPU需要转换一个虚拟地址时,它首先查找TLB。如果命中,则直接获得物理地址,无需访问内存中的页表;如果未命中,则进行完整的页表遍历,并将结果存入TLB。
当操作系统切换页表(例如进行上下文切换)时,旧的TLB条目可能失效。因此,内核必须通知硬件刷新TLB。在RISC-V中,这条指令是sfence.vma。
🖥️ XV6中的内核地址空间布局
上一节我们介绍了页表的基本硬件机制,本节中我们来看看XV6操作系统是如何具体设置和使用页表的。
XV6为内核设置了一个虚拟地址空间,其到物理地址的映射大部分是恒等映射,即虚拟地址和物理地址数值相同。这简化了初始设置。
以下是XV6内核地址空间的关键部分及其映射和权限:
- 内核文本(代码):映射为可读、可执行,但不可写(
R|X)。防止内核代码被意外修改。 - 内核数据:映射为可读、可写(
R|W)。存储内核的全局变量等。 - 设备内存:将物理内存中映射给I/O设备(如UART、磁盘控制器)的区域映射到内核地址空间的高端,以便内核通过加载/存储指令与设备通信。
- 内核栈:每个进程都有一个独立的内核栈。为了检测栈溢出错误,XV6在内核栈下方设置了一个守护页,该页的PTE无效(
V=0)。如果内核栈溢出,会立刻触发页错误,而不是破坏其他内存。 - 蹦床页面:映射在内核地址空间的最顶端,用于在用户态和内核态之间安全切换。



一个有趣的特点是,通过页表,同一个物理页可以被映射到多个虚拟地址。例如,内核栈所在的物理页,既被恒等映射到低地址区域,也被映射到高地址区域(带有守护页)。
🔍 深入代码:XV6的页表初始化
让我们通过XV6的代码来具体看看页表是如何建立的。
在main函数中,kvminit()函数负责设置内核的页表。它的主要步骤是:
- 分配根页目录:调用
kalloc()分配一个物理页作为根页目录,并清零。 - 映射各个区域:通过
kvmmap()函数,将内核需要访问的各个物理地址区域(如设备I/O、内核文本、内核数据、空闲内存等)逐一映射到内核的虚拟地址空间。 - 启用分页:在
kvminithart()函数中,将设置好的根页目录的物理地址写入当前CPU核心的SATP寄存器,并执行sfence.vma刷新TLB。从此以后,CPU发出的所有地址都将通过这个新页表进行转换。
kvmmap函数的核心是walk函数,它模拟了硬件MMU的页表遍历过程,根据虚拟地址找到或创建对应的PTE,然后由kvmmap设置该PTE的物理页帧号和标志位。
关键点:在写入SATP寄存器启用分页的瞬间,CPU对地址的解释方式发生了根本改变。之后执行的指令地址(程序计数器PC)也将作为虚拟地址进行转换。XV6能够平稳过渡,正是因为它预先建立了一个恒等映射的页表,使得启用分页后的第一条指令的虚拟地址能正确转换到其物理地址所在位置。



📝 总结
本节课中我们一起学习了虚拟内存和页表的核心知识。

我们首先了解了虚拟内存的目标是实现内存隔离,为每个进程提供独立的地址空间。然后,我们深入探讨了RISC-V架构下分页硬件的机制,包括如何通过多级页表将虚拟地址转换为物理地址,以及PTE中标志位的作用。我们还介绍了用于加速地址转换的TLB及其维护要点。

最后,我们分析了XV6操作系统内核地址空间的具体布局和初始化过程,看到了恒等映射、守护页等实际应用,并通过代码理解了页表从建立到启用的完整流程。



理解页表是掌握现代操作系统内存管理的基础,它不仅是实现隔离的基石,也为后续学习页错误处理、内存共享、写时复制等高级技巧提供了强大的支持。


课程 P4:第5讲 RISC-V 调用约定与栈帧 🧱
在本节课中,我们将要学习 RISC-V 处理器的汇编语言、调用约定以及栈帧的工作原理。这对于理解操作系统内核中的陷阱处理和调试至关重要。
概述 📋
处理器执行的是汇编语言,而不是我们直接编写的 C 语言。C 代码需要被编译成处理器能够理解的汇编指令。RISC-V 是一种精简指令集(RISC)架构,与 x86 等复杂指令集(CISC)架构不同,它的指令集更小、更简单。本节课我们将深入探讨 RISC-V 汇编、函数调用时寄存器的使用规则(调用约定)以及栈帧在内存中的布局。
从 C 语言到汇编 🔄


C 语言代码通过编译器转换为汇编语言,最终成为处理器可以执行的二进制机器码。这个过程对于所有编译型语言(如 C++)都是类似的。汇编语言的结构性远不如 C 语言,它由一行行简单的指令(如 add, sub)和标签组成。
示例:一个简单的 C 函数及其汇编
// C 代码:计算从 0 到 n 的累加和
int sum(int n) {
int acc = 0;
for (int i = 0; i < n; i++) {
acc += i;
}
return acc;
}
对应的 RISC-V 汇编核心逻辑可能类似于:
# 假设 n 的值在寄存器 a0 中,结果将放回 a0
mv t0, a0 # 将 n 的值移动到临时寄存器 t0
li a0, 0 # 将累加器 a0 清零
loop:
beqz t0, end # 如果 t0 为 0,跳转到结束
add a0, a0, t0 # 累加
addi t0, t0, -1 # t0 减 1
j loop # 跳回循环开始
end:
ret # 返回

在汇编文件中,.global 指令使得该函数可以被其他文件调用,.text 段则用于存放代码。
RISC-V 架构简介 ⚙️


RISC-V 是一种开源的精简指令集架构。与庞大的 x86-64 指令集相比,RISC-V 的核心指令集更小,文档也更简洁。RISC-V 指令集采用模块化设计,包含一个基础整数指令集(I),并可以选择性地添加其他扩展模块(如 F 用于单精度浮点)。

关键区别:
- RISC(如 RISC-V, ARM):指令数量少,每条指令执行的操作简单、周期短。
- CISC(如 x86-64):指令数量庞大,包含许多执行复杂操作的指令,强调向后兼容性。


尽管 x86-64 在个人电脑中占主导地位,但 RISC 架构在移动设备(如 ARM 处理器)和嵌入式领域无处不在。RISC-V 作为新兴的开源架构,正获得越来越多的关注和支持。
寄存器与调用约定 📊
寄存器是 CPU 上用于存储数据的高速存储单元。汇编指令主要在寄存器上进行操作。RISC-V 有 32 个通用寄存器(x0-x31),每个宽 64 位。
以下是部分重要寄存器及其在调用约定中的用途:




| 寄存器 | ABI 名称 | 描述 | 保存者 |
|---|---|---|---|
| x0 | zero | 硬连线为零 | - |
| x1 | ra | 返回地址 | 调用者 |
| x2 | sp | 栈指针 | 被调用者 |
| x3 | gp | 全局指针 | - |
| x4 | tp | 线程指针 | - |
| x5-x7 | t0-t2 | 临时寄存器 | 调用者 |
| x8-x9 | s0-s1 | 保存寄存器 | 被调用者 |
| x10-x17 | a0-a7 | 函数参数/返回值 | 调用者 |
| x18-x27 | s2-s11 | 保存寄存器 | 被调用者 |
| x28-x31 | t3-t6 | 临时寄存器 | 调用者 |
调用约定核心规则:
- 函数参数:前 8 个参数使用寄存器
a0-a7传递,更多参数则通过栈传递。 - 返回值:使用
a0和a1寄存器返回。 - 调用者保存(Caller-saved):如
ra,a0-a7,t0-t6。调用函数前,如果调用者希望这些寄存器的值在调用后保持不变,调用者需要自行保存它们。 - 被调用者保存(Callee-saved):如
sp,s0-s11。如果被调用的函数要使用这些寄存器,被调用者必须保证在返回前恢复其原始值。

理解“调用者保存”和“被调用者保存”对于编写和调试汇编代码至关重要,可以避免寄存器值被意外覆盖。
栈与栈帧 🥞
栈是一种后进先出的内存区域,用于支持函数调用。每次函数调用都会在栈上分配一个栈帧,用于存放局部变量、保存的寄存器以及调用信息。
栈帧典型布局(从高地址向低地址生长):
高地址
+-------------------+
| 调用者的栈帧 |
| ... |
+-------------------+
| 参数 n (可选) | <-- 当寄存器不够时
+-------------------+
| 返回地址 (ra) |
+-------------------+
| 旧的帧指针 (fp) |
+-------------------+
| 保存的寄存器 (s0..)|
+-------------------+
| 局部变量 |
+-------------------+
| ... | <-- 当前栈指针 (sp)
低地址
关键寄存器:
- 栈指针 (sp):总是指向当前栈帧的底部(最低地址)。
- 帧指针 (fp/s0):通常指向当前栈帧中保存旧
fp的位置,为访问栈帧内容提供稳定的基准点。

函数通常以序言开始,分配栈空间并保存 ra 和 fp;以尾声结束,恢复寄存器并释放栈空间。


示例:一个非叶子函数的汇编序言与尾声
double_and_sum:
# 序言
addi sp, sp, -16 # 在栈上分配16字节空间
sd ra, 8(sp) # 保存返回地址
sd fp, 0(sp) # 保存旧的帧指针
addi fp, sp, 16 # 设置新的帧指针
# 函数体...
call sum # 调用另一个函数
# 尾声
ld ra, 8(sp) # 恢复返回地址
ld fp, 0(sp) # 恢复旧的帧指针
addi sp, sp, 16 # 释放栈空间
ret # 返回
如果省略序言(如不保存 ra),函数返回时将跳转到错误的地址,导致程序崩溃或进入死循环。
使用 GDB 调试汇编 🐛


GDB 是强大的调试工具,可以单步执行汇编指令、检查寄存器和内存状态。
常用命令:
layout asm/layout src/layout reg:切换显示汇编、源代码或寄存器窗口。break *address/break function:在地址或函数处设置断点。stepi (si)/nexti (ni):单步执行一条汇编指令。info registers/i r:查看所有寄存器内容。x/10x address:以十六进制检查内存地址开始的内容。backtrace (bt)/frame n:查看调用堆栈和切换栈帧。watch variable:设置观察点,当变量改变时暂停。break ... if condition:设置条件断点。
通过 GDB,我们可以直观地观察函数调用时栈帧的变化、寄存器的保存与恢复,以及参数和返回值的传递过程。
结构体在内存中的布局 🧩
结构体在内存中是一块连续的区域,其字段按照声明的顺序依次存放。结构体通常通过引用(指针)传递给函数。
示例:检查结构体内容
struct Person {
int id;
int age;
};
在 GDB 中,如果 p 是一个指向 Person 的指针,可以使用 print *p 来查看其字段,或使用 x 命令检查其内存布局。

总结 🎯

本节课我们一起学习了 RISC-V 汇编的基础、调用约定以及栈帧的管理。我们了解到:
- C 代码如何被编译成处理器执行的 RISC-V 汇编指令。
- RISC-V 寄存器的用途和调用约定中“调用者保存”与“被调用者保存”的关键区别。
- 栈帧是如何在函数调用过程中被创建和销毁的,以及
sp和fp寄存器的作用。 - 如何使用 GDB 调试器来深入观察和分析汇编代码的执行过程、寄存器状态和内存布局。

掌握这些知识对于理解和完成后续关于陷阱(trap)和系统调用的实验至关重要。


P5:第6讲 - 隔离与系统调用进入/退出 🚀
在本节课中,我们将要学习用户代码与内核代码之间的转换机制,即系统调用、异常或中断发生时,如何从用户模式安全、高效地切换到内核模式,并在处理完毕后返回。这个过程对于操作系统的隔离性、安全性和性能至关重要。
概述
当用户程序(例如shell)需要执行特权操作(如写入控制台)时,它会发起一个系统调用。这触发了从用户空间到内核空间的转换。硬件状态(如寄存器、模式、页表)必须发生一系列精心设计的改变,以确保内核能安全执行,同时用户程序对此过程毫无感知。本节课我们将深入探讨这一转换过程的每一步。
硬件状态与转换需求
上一节我们介绍了转换的基本概念,本节中我们来看看转换发生时,具体的硬件状态是什么,以及我们需要改变什么。
系统调用发生时,CPU的所有状态都设置为运行用户代码。我们需要改变的状态包括:

- 用户寄存器:所有31个通用寄存器(包括堆栈指针
sp)都需要保存,以便内核处理后能透明地恢复用户程序。 - 程序计数器(PC):需要保存,以便知道从哪里恢复执行用户程序。
- 模式:需要从用户模式切换到主管模式,以便内核能使用特权指令和访问受保护资源。
- 页表指针(
satp):当前指向用户页表,它只包含用户程序所需的映射。内核代码需要切换到内核页表才能访问其数据和代码。 - 堆栈指针(
sp):需要指向内核中的某个堆栈,以便内核可以调用C函数。
高层目标与设计限制
在考虑如何实现上述状态转换时,有两个高层目标限制了我们的设计选项:
- 安全与隔离:陷阱机制不能信任用户空间的任何数据。用户寄存器可能包含恶意值,因此内核陷阱处理程序在查看这些寄存器前,必须先将它们保存起来。
- 对用户代码透明:陷阱处理完毕后,应能在用户代码毫无察觉的情况下恢复其执行。
此外,我们需要理解主管模式的特权具体是什么。在RISC-V中,主管模式主要允许:
- 读写控制寄存器(如
satp,stvec)。 - 使用页表项中未设置
PTE_U标志的页面。
主管模式代码同样受页表限制,只能访问当前页表中已映射的虚拟地址。
系统调用流程概览
以下是系统调用从发起到返回的完整流程预览,我们将通过GDB跟踪一个具体的write系统调用来详细分析。
- 用户空间:Shell调用
write库函数,该函数将系统调用号加载到寄存器a7,然后执行ecall指令。 - 陷阱入口(汇编):
ecall指令将模式改为主管,保存PC到sepc,并跳转到stvec寄存器指向的地址(即trampoline页开头)。执行的第一条内核指令是uservec(位于trampoline页的汇编代码)。 - 切换到C环境:
uservec保存用户寄存器,切换页表,设置内核栈,然后跳转到C函数usertrap。 - 系统调用处理(C代码):
usertrap判断陷阱原因为系统调用,调用syscall函数。syscall根据系统调用号查找并执行对应的内核函数(如sys_write)。 - 陷阱返回(C代码):系统调用执行完毕后,
usertrap调用usertrapret函数,为返回用户空间做准备(如设置stvec, 准备陷阱帧等)。 - 返回用户空间(汇编):
usertrapret最终跳转到trampoline页中的userret汇编函数。userret切换回用户页表,恢复用户寄存器,最后执行sret指令返回用户空间,在ecall指令后继续执行。
通过GDB深入分析转换过程
现在,让我们通过GDB实际跟踪一个write系统调用的完整过程,以理解上述每一步的细节。
1. 用户空间发起系统调用
我们在shell的write库函数中的ecall指令处设置断点。此时,我们处于用户模式,使用用户页表,地址空间很小。寄存器a0, a1, a2分别保存了文件描述符、缓冲区指针和字节数。
2. 执行ECALL进入内核
执行ecall指令后,硬件自动完成三件事:
- 将模式从用户改为主管。
- 将当前PC(
0xde6)保存到sepc寄存器。 - 将PC设置为
stvec寄存器的值(0x3ffffff000),即trampoline页的起始地址。
此时,我们开始在trampoline页中执行,处于主管模式,但仍在使用用户页表。所有通用寄存器仍保持用户程序设置的值。
3. TRAMPOLINE代码 (uservec)
trampoline页被映射到每个用户页表的固定高地址处,且其页表项未设置PTE_U标志,因此用户代码无法访问,保证了安全性。ecall后第一条指令是:
csrrw a0, sscratch, a0
这条指令交换了寄存器a0和sscratch的内容。此前,内核已将sscratch设置为指向当前进程的trapframe页的指针。交换后,a0持有了trapframe的地址,而用户原来的a0值被暂时保存在sscratch中。
trapframe是内核为每个进程预留的内存区域,用于保存和恢复状态。其结构(struct trapframe)中包含了保存所有用户寄存器的槽位。
接下来,uservec使用一系列的sd(存储双字)指令,将除a0外的所有用户寄存器保存到trapframe中相应的偏移位置。然后,它从trapframe中加载内核栈指针到sp,加载内核页表地址到satp寄存器,并执行页表切换。
关键点:为什么切换页表后不会崩溃?因为trampoline页在内核页表中同样被映射在相同的虚拟地址。因此,切换页表后,我们仍在执行相同的trampoline代码。
最后,uservec从trapframe中加载C处理函数usertrap的地址,并跳转到该C代码。
4. C陷阱处理 (usertrap)
现在我们进入了内核C代码环境,使用内核页表和内核栈。usertrap函数首先将stvec改为内核陷阱处理程序的地址(因为之后可能处理内核中的陷阱)。然后,它保存sepc(用户PC)到进程的trapframe中,因为后续可能发生进程切换。
接着,它检查陷阱原因(scause寄存器)。对于系统调用(值8),它需要:
- 将保存的PC(
sepc)加4,以便返回时执行ecall之后的下一条指令。 - 打开中断(因为在陷阱入口时中断被关闭)。
- 调用
syscall()函数。
syscall()函数从trapframe中读取用户设置的系统调用号(a7),并据此索引到一个函数指针表,调用相应的内核系统调用实现(如sys_write)。系统调用的参数也从trapframe中相应的寄存器槽位(a0, a1, a2...)读取。返回值被写入trapframe中的a0槽位。
5. 准备返回用户空间 (usertrapret)
系统调用处理完毕后,usertrap调用usertrapret为返回用户空间做准备。usertrapret负责:
- 关闭中断(因为接下来要修改
stvec, 若此时发生中断指向用户处理程序会导致问题)。 - 将
stvec重新设置为uservec的地址,以便下次用户陷阱时使用。 - 在
trapframe中写入下次陷阱所需的内核数据:内核页表地址、内核栈指针、usertrap函数地址等。 - 设置
sepc为之前保存的用户PC。 - 计算
trampoline页中userret函数的地址,并以trapframe和用户页表地址为参数,“调用”该汇编函数。
6. TRAMPOLINE代码返回 (userret)
控制权回到trampoline页中的userret汇编代码。此时仍在主管模式,使用内核页表。
- 首先,它将第二个参数(用户页表地址)加载到
satp寄存器,切换回用户页表。 - 然后,它将第一个参数(
trapframe指针)与sscratch交换。现在a0指向trapframe, 而sscratch保存了指向trapframe的指针(为下次陷阱做准备)。 - 接着,它从
trapframe中恢复所有用户寄存器的值。注意,此时trapframe中的a0槽位已经是系统调用的返回值。 - 最后,它执行
sret指令。该指令将模式改回用户,将PC设置为sepc的值,并重新开启中断。至此,CPU完全回到了用户空间,从ecall之后的下一条指令继续执行,并且寄存器a0中包含了系统调用的返回值。
设计思考与总结
本节课我们一起学习了xv6操作系统中用户空间与内核空间通过陷阱机制进行转换的完整过程。这个过程比普通的函数调用复杂得多,主要源于隔离性要求(内核不能信任用户数据)和对性能的追求。
我们可以思考一些替代设计:
- 硬件辅助:
ecall能否做得更多?例如自动保存寄存器或切换页表?RISC-V选择只做最小化工作,将灵活性留给软件,以支持不同操作系统的各种优化策略(如不切换页表、部分保存寄存器等)。 - 安全性:恶意程序能否滥用这些机制?整个设计,特别是通过
trampoline页和未设置PTE_U的陷阱帧,确保了用户代码无法干扰陷阱进入/退出路径上的关键数据。

xv6的实现展示了在RISC-V架构上实现安全、透明且相对高效的陷阱处理的一种方式。理解这一过程是理解操作系统如何实现隔离和保护的关键。


📚 课程 P6:Lecture 7 - 实验问答 (Beta版)
在本节课中,我们将回顾并解答关于“页表”实验的常见问题。我们将从实验的整体设置开始,逐步深入到各个部分的具体实现细节,包括内核页表的复制、用户地址空间的映射等核心概念。
🖼️ 实验设置与背景
上一节我们介绍了课程概述,本节中我们来看看实验的基本环境设置。
我们知道物理内存的一部分用于设备,它们位于地址 0x0 之上。QEMU 将内核的文本和数据加载到物理地址 0x80000000 之上。CPU 执行指令,其中涉及的程序计数器(PC)等寄存器包含虚拟地址。内存管理单元(MMU)负责将虚拟地址转换为物理地址。控制这个转换过程的是 satp 寄存器,它包含了当前运行进程的页表根地址。当处理器启动时,satp 没有值,因此虚拟地址直接作为物理地址使用。一旦 satp 加载了一个非零值,MMU 就会使用根页表进行地址翻译。
需要记住的关键点是,页表状态本身也存储在内存中。
📄 第一部分:打印初始进程的页表
上一节我们了解了地址翻译的基础,本节中我们来看看实验的第一部分:打印初始进程(init)的页表。
这部分要求你打印出初始化进程的页表,并参考图3-4进行解释。图3-4展示了用户地址空间的布局。
以下是用户地址空间的关键组成部分:
- 文本(Text):程序指令从地址0开始。
- 数据(Data):全局变量存储在此处。
- 保护页(Guard Page):一个未映射的页面,用于检测栈溢出。
- 栈(Stack):用户程序的栈,向下增长。
- 堆(Heap):用户程序通过
sbrk系统调用动态申请的内存区域。 - 蹦床页(Trampoline Page) 和 陷阱帧页(Trapframe Page):位于地址空间顶部的两个特殊页面,用于在用户态和内核态之间切换。
当我们从 init 进程打印页表时,可以观察到以下几点:
- 底部(低地址)的页面是空闲页面,其物理地址位于内核可分配的空闲内存区域内。
- 权限标志中设置了
R(读)、W(写)、X(执行)和U(用户)位,这意味着用户程序可以执行指令、写入内存。 - 第一页(保护页)只设置了
V(有效)位,但没有设置U位。因此,如果用户栈增长并触及此页,会导致页错误或陷入内核。 - 栈页具有所有权限(
R,W,X,U)。理论上可以禁用X位来防止在栈上执行代码。 - 顶部的条目(索引 511)对应蹦床页和陷阱帧页,它们设置了
X位(可执行)但没有U位,这意味着只有内核可以访问这些页面。 - 连续的虚拟地址所映射的物理页面不一定是连续的,这为内核分配内存提供了灵活性。
关于页表索引 255 与 511 的说明:我们常说蹦床页在地址空间“顶部”,这对应顶层页目录(Page Directory)的条目 511。但在实验中,我们看到的是条目 255。这是因为 RISC-V 的虚拟地址是 39 位,我们只使用了其中的 38 位。如果使用了第 39 位,则其高位必须全部为 1(符号扩展),为了简化处理,我们选择不使用它,因此最大虚拟地址对应的条目是 255 而非 511。
关于文本和数据在同一页的说明:在实际操作系统中,文本(代码)和数据通常位于不同的页面,以便设置更精细的权限(例如,数据页不可执行)。在本实验中,为了简单起见,我们将它们放在连续的页面中。

🧩 第二部分:为每个进程复制内核页表

上一节我们分析了初始进程的页表布局,本节中我们来看看实验的第二部分:为每个进程创建独立的内核页表副本。

这部分的任务是修改内核,使得每个进程都有自己的内核页表副本,而不是共享一个全局内核页表。


实现此任务主要有两种思路:
- 复制方法:为每个进程分配新的页表页,并将全局内核页表的内容复制过去。
- 共享方法:让进程的内核页表与全局内核页表共享那些不会被修改的条目(例如,映射内核代码和数据的较高地址条目),只独立创建和映射低地址部分(用户地址空间)。


两种方法都是可行的。本教程示例采用复制方法,但只复制了全局内核页表中需要独立管理的部分条目(即低地址部分),而较高地址的条目通过复制页表项(PTE)来共享,以避免分配过多的新页面。


关键实现步骤与注意事项:
- 在
proc结构体中添加字段来存储进程的内核页表指针。 - 实现一个函数(例如
kvminit_new)来创建进程的内核页表副本。在示例中,我们复制了全局内核页表的前 512 个条目中除 0 以外的条目(因为 0 条目区域将用于映射用户页表),并显式映射了必要的设备(如 UART、CLINT)。 - 在调度器(scheduler)切换进程时,需要将
satp寄存器切换到目标进程的内核页表。当切换回空闲进程或调度器本身时,需要切换回全局内核页表。这是为了确保当一个进程退出并被清理时,其内核页表没有被任何正在运行的进程使用。 - 在用户态陷入内核的路径上(例如
usertrap),需要确保内核使用当前进程的内核页表运行。 - 必须小心处理内核页表的释放,确保不会释放仍被其他页表共享的页面。
调试建议:内核编程调试环境苛刻。建议采用“小步快跑”的策略,每次只做一两个修改并测试,保留可工作的旧代码以便回滚。页表错误可能导致的内核崩溃往往发生在错误操作很久之后,因此耐心和系统性排查至关重要。


🔗 第三部分:将用户页表映射到内核页表
上一节我们探讨了如何为进程创建内核页表副本,本节中我们来看看第三部分:将用户地址空间映射到进程的内核页表中。


这部分的目标是修改内核的 copyin/copyout 等函数,使其能直接使用用户页表的映射在内核态解引用用户指针,而无需调用 walk 函数手动遍历页表进行地址转换。


这样做的好处:
- 简化内核代码:内核程序员无需关心用户数据的物理布局,硬件 MMU 会自动完成地址转换。
- 潜在的性能提升:对于需要复制大量数据的系统调用(如
read/write),避免了反复调用软件walk函数的开销。 - 更灵活的数据操作:如果内核只需要修改用户数据结构中的某个字段,可以直接通过映射后的地址进行读写,而无需完整地复制整个结构。
实现方法:
实现一个函数(例如 kvm_map_user),其核心逻辑是遍历用户页表,对于每一个有效的用户映射,将其页表项(PTE)复制到进程内核页表的对应位置。
复制 PTE 时的权限调整:
- U(用户)位:必须清除。在 RISC-V 的默认配置下,如果内核态访问设置了 U 位的页面,会引发页错误。这主要是一种内核调试辅助机制,用于捕获内核意外访问用户内存的错误。
- W(写)位和 X(执行)位:通常可以清除。因为在内核态执行
copyin/copyout时,只是读取或写入用户数据,不需要执行其中的代码。保守起见,可以禁用这些权限。 - R(读)位和 V(有效)位:保留。
在 fork 和 exec 中的集成:
- 在
fork中,创建子进程时,需要将子进程的用户页表映射到子进程的内核页表中。不能映射父进程的用户页表,因为当父进程退出并清理其页表后,子进程的内核页表中会留下无效的映射。 - 在
exec中,建立新的用户地址空间后,需要立即将其映射到当前进程的内核页表中。
关于 sbrk 和初始进程:sbrk 系统调用在增长或收缩用户堆时,也需要调用 kvm_map_user 来更新内核页表中的映射。初始进程(init)的创建也需要特殊处理,确保其第一个页面被映射到其内核页表中。
💎 总结与常见问题解答

本节课中,我们一起学习了“页表”实验的核心内容,涵盖了从打印页表到实现独立内核页表,再到映射用户空间的完整流程。


以下是针对一些常见问题的解答:
- 问:为什么修改后的
copyin不需要walk了?- 答:因为用户地址空间已经直接映射到了进程的内核页表里。当内核使用用户虚拟地址时,硬件 MMU 会像处理普通内核地址一样,自动使用当前
satp指向的页表(即进程的内核页表)进行翻译,无需软件干预。
- 答:因为用户地址空间已经直接映射到了进程的内核页表里。当内核使用用户虚拟地址时,硬件 MMU 会像处理普通内核地址一样,自动使用当前


-
问:如果用户程序比 CLINT 设备地址大怎么办?
- 答:实验假设用户程序不会大于 CLINT 的地址。在实际操作系统中,可以重新安排设备映射的位置(例如,映射到内核地址空间更高的未使用区域),从而为用户地址空间腾出更多空间。
-
问:内核栈为什么映射在地址空间高处?
- 答:因为栈是向下增长的。将其放在高处并在其下方放置一个未映射的“保护页”,可以在栈溢出时立即触发页错误,这比破坏其他内核数据结构要好。保护页不消耗物理内存,这是虚拟内存的优势之一。
-
问:有了独立的内核页表后,陷入/跳出内核时还需要切换页表吗?
- 答:是的,仍然需要。因为进程的内核页表只映射了当前进程的用户空间。当从内核返回用户态时,需要切换到该进程的用户页表。当从一个用户进程切换到另一个时,在调度器中需要切换
satp到目标进程的内核页表。
- 答:是的,仍然需要。因为进程的内核页表只映射了当前进程的用户空间。当从内核返回用户态时,需要切换到该进程的用户页表。当从一个用户进程切换到另一个时,在调度器中需要切换
-
问:管道(pipe)的实现会受此实验影响吗?
- 答:会的。管道涉及将数据从用户空间复制到内核缓冲区。本实验的修改简化了这部分复制代码,因为内核可以直接解引用用户指针,无需再调用
walk。
- 答:会的。管道涉及将数据从用户空间复制到内核缓冲区。本实验的修改简化了这部分复制代码,因为内核可以直接解引用用户指针,无需再调用

希望本次讲解能帮助你更好地理解虚拟内存和页表实验。这些概念和调试经验将对后续涉及虚拟内存的实验大有裨益。


课程 P7:第8讲 页面错误 🚨
在本节课中,我们将学习操作系统中的页面错误机制。页面错误为内核提供了动态修改页表映射的能力,是实现多种强大虚拟内存特性的基础。我们将探讨如何利用页面错误处理程序来实现惰性分配、写时复制和需求分页等功能。
概述
页面错误是虚拟内存系统的核心机制之一。当用户程序访问一个尚未映射到物理内存的虚拟地址时,硬件会触发一个页面错误异常,并将控制权转交给内核的页面错误处理程序。这为内核提供了一个绝佳的机会,可以动态地分配物理内存、调整页表权限或从磁盘加载数据,从而实现一系列优化和高级功能。
上一节我们介绍了虚拟内存的基本概念和页表的作用。本节中,我们来看看当页表映射缺失时,硬件和操作系统如何协同工作来处理页面错误。
页面错误的硬件机制
当页面错误发生时,RISC-V硬件会通过陷阱机制将控制权从用户空间转移到内核空间。与系统调用或设备中断类似,但硬件会提供一些额外的关键信息,帮助内核诊断和处理错误。
以下是页面错误发生时,硬件提供给内核的三类关键信息:

- 出错的虚拟地址:保存在
stval寄存器中。这是引发页面错误的指令试图访问的地址。 - 错误类型:保存在
scause寄存器中。RISC-V定义了三种页面错误类型:12: 指令页面错误(取指失败)13: 加载页面错误(读数据失败)15: 存储页面错误(写数据失败)
- 引发错误的指令地址:保存在
sepc寄存器中。内核在处理完页面错误后,需要返回到这里重新执行该指令。
有了这些信息,内核的页面错误处理程序就可以分析错误原因,采取相应措施(例如分配物理页、修改页表项),然后通过恢复 sepc 中的地址来让用户程序继续执行。
应用:惰性分配
惰性分配是一种优化策略。传统的 sbrk 系统调用会立即分配应用程序请求的物理内存。然而,应用程序常常会申请远超其实际需要的内存。惰性分配的核心思想是:直到应用程序真正使用某块内存时,才为其分配物理页。
实现思路
- 修改
sbrk系统调用:当应用程序请求更多堆内存时,内核仅更新进程的堆大小变量(p->sz),而不立即分配物理内存。 - 处理页面错误:当应用程序首次访问这块新“分配”但未映射的内存时,会触发页面错误。
- 在错误处理程序中,内核检查出错的虚拟地址是否在堆的合法范围内(即小于
p->sz)。- 如果是,则分配一个物理页,将其清零,并映射到出错的虚拟地址上,然后返回用户态重新执行指令。
- 如果否,则说明是非法访问,终止该进程。
代码示例

以下是在页面错误处理程序中实现惰性分配的核心逻辑伪代码:
void usertrap(void) {
// ... 其他陷阱处理 ...
if(r_scause() == 15) { // 存储页面错误
uint64 va = r_stval(); // 获取出错的虚拟地址
if(va < p->sz) { // 地址在堆范围内
// 分配物理页
char *mem = kalloc();
if(mem == 0) {
// 物理内存耗尽,杀死进程
p->killed = 1;
} else {
memset(mem, 0, PGSIZE);
// 将物理页映射到虚拟地址va
if(mappages(p->pagetable, PGROUNDDOWN(va), PGSIZE, (uint64)mem, PTE_W|PTE_X|PTE_R|PTE_U) != 0) {
kfree(mem);
p->killed = 1;
}
}
} else {
// 非法地址,杀死进程
p->killed = 1;
}
}
// ... 恢复执行 ...
}
注意:惰性分配会引入复杂性。例如,当进程退出时,内核需要释放其所有内存。对于惰性分配但从未使用的页面,其页表项是无效的,在释放时需要跳过这些页面,而不是触发恐慌。
应用:写时复制
写时复制是 fork 系统调用的一个关键优化。传统的 fork 会复制父进程的整个地址空间,这通常非常耗时且浪费内存,因为子进程往往很快会调用 exec 来替换掉自己的地址空间。
COW 的核心思想是:在 fork 时,让父子进程共享相同的物理页,但将这些页标记为只读。当任一进程试图写入这些共享页时,会触发页面错误,内核此时再为该进程复制一个私有的物理页副本。
实现思路
- 修改
fork:复制父进程的页表时,不分配新的物理页,而是让子进程的页表项指向父进程的物理页。同时,清除父子进程页表项中的PTE_W(写)标志位,并设置一个自定义位(例如PTE_COW)来标记这是一个 COW 页。 - 处理页面错误:当进程尝试写入一个只读的 COW 页时,触发页面错误。
- 在错误处理程序中,内核检查页表项是否设置了
PTE_COW位。- 如果是,则分配一个新的物理页,将旧页的内容复制到新页,然后将新页以可读写的权限映射回原虚拟地址。之后,减少旧物理页的引用计数。
- 如果否,则按其他错误处理(如惰性分配)。
- 引入引用计数:每个物理页都需要一个引用计数,记录有多少个页表项指向它。当引用计数降为0时,才能安全释放该物理页。
核心公式与概念
- COW 页表项权限:
PTE_R(读) +PTE_COW(自定义标志位) - 私有页表项权限:
PTE_R(读) +PTE_W(写) - 引用计数:
ref_count[pa / PGSIZE]++或--
应用:需求分页
需求分页将惰性分配的思想应用到了程序加载 (exec) 阶段。传统的 exec 会立即将程序的代码段(text)和数据段(data)从磁盘文件全部读入物理内存。需求分页则推迟了这一过程。
实现思路
- 修改
exec:不为程序的代码段和数据段分配物理内存和建立映射,仅在进程的地址空间结构中记录这些段对应的文件、偏移量和长度等信息。 - 处理页面错误:当程序开始执行第一条指令或访问数据时,会触发页面错误。
- 在错误处理程序中,内核根据出错的虚拟地址,查找其所属的段信息,然后从磁盘文件中读取相应的页面内容到新分配的物理页中,并建立映射,最后重新执行指令。
这样做的好处是:程序启动更快(减少了初始 I/O),并且节省内存(如果程序从未使用某些代码或数据,则对应的页永远不会被加载)。
高级话题:页面置换与内存映射文件
当物理内存不足时,内核需要选择一些页面“换出”到磁盘,以腾出空间。最常用的策略是最近最少使用。RISC-V 页表项中的 PTE_A(访问)位和 PTE_D(脏)位为实现 LRU 和高效换出提供了硬件支持。
内存映射文件 (mmap) 允许应用程序将一个文件(或一部分)直接映射到其地址空间。之后,对内存的读写操作就等同于对文件的读写。这同样可以通过页面错误机制惰性地实现:仅在访问时从磁盘加载文件页,并在页面被修改(脏)后写回磁盘。
总结

本节课我们一起学习了页面错误这一强大的机制。通过结合页表的静态映射和页面错误的动态处理,操作系统能够实现一系列精巧而高效的虚拟内存功能,包括惰性分配、写时复制、需求分页和内存映射文件。这些技术共同构成了现代操作系统高效管理内存的基石,在性能优化和资源利用方面发挥着至关重要的作用。


课程 P8:Lecture 9 - 中断 🚨

在本节课中,我们将要学习操作系统中的中断机制。中断是硬件设备通知CPU需要处理某些事件的一种方式,例如键盘输入或网络数据包的到达。我们将了解中断的来源、硬件与软件如何处理中断,并通过一个具体的案例——在控制台上打印字符——来深入理解整个过程。

操作系统内存使用情况概览
在深入讨论中断之前,我们先回顾一下操作系统如何使用内存。上周我们讨论了分页和寻找空闲内存。观察一台运行中的机器(例如通过 top 命令),你会发现大部分物理内存并非被应用程序占用,而是被缓冲区缓存(buffer cache)使用。操作系统会尽量利用物理内存,因此空闲内存通常很少。这意味着当应用程序或内核需要内存时,可能需要驱逐(evict)一些现有内容。
此外,每个进程的驻留内存(实际使用的物理内存)通常远小于其虚拟地址空间。这是虚拟内存技术(如按需分页和共享库)带来的好处。
中断的基本概念
上一节我们介绍了操作系统的内存管理,本节中我们来看看中断。中断的基本思路很简单:在某些情况下,硬件需要引起CPU的注意。例如,网卡收到数据包或键盘被按下时,会产生中断。CPU需要暂停当前工作,保存现场,运行中断处理程序,然后恢复之前的工作。
这种保存和恢复的机制,与系统调用(trap)和页面错误(page fault)非常相似。然而,中断有几个关键不同点:
- 异步性:中断的发生与当前CPU上运行的进程可能无关。系统调用发生在当前进程的上下文中,而中断处理程序则可能在完全不同的上下文中运行。
- 并发性:产生中断的设备与CPU是并行工作的。例如,网卡在独立处理网络流量,CPU在执行其他任务,当中断发生时,两者需要协调。这是我们讨论并发性的起点。
- 设备编程:每个外部设备(如网卡、UART串口)都有自己的编程手册,描述了其寄存器和操作方式。编程设备通常涉及对内存映射I/O地址进行读写。
本节课,我们将重点关注外部设备中断,并通过一个简单目标来理解其机制:理解如何在控制台上打印出 $ 这个字符。
中断的来源与硬件路径
中断从哪里来?本节课重点讨论外部中断,它们来自主板上的各种设备。在我们的RISC-V开发板上,有网卡、UART串口、SD卡等多种设备,它们都有线路连接到CPU。
具体到硬件细节,所有设备的中断线首先连接到一个叫做平台级中断控制器(PLIC)的组件。PLIC负责管理来自不同设备的中断请求,并将它们路由到合适的CPU核心。每个CPU核心都可以独立配置是否接受中断。


当设备产生中断时,PLIC会标记该中断为“待处理”(pending)。如果某个CPU核心启用了中断并且可以处理,PLIC就会将中断递交给它。该核心会“认领”(claim)这个中断,处理完毕后通知PLIC,PLIC随后可以传递其他中断。
软件层面:驱动程序结构
管理设备的代码被称为驱动程序。在像xv6这样的操作系统中,驱动程序代码位于内核内部。
典型的驱动程序结构分为两层:
- 顶层:提供与内核其他部分或用户进程交互的接口。例如,当
write系统调用向控制台写入时,会调用驱动程序的顶层代码。 - 底层(中断处理程序):当中断发生时自动调用的代码。它不在任何特定进程的上下文中运行,因此受到一些限制(例如,不能直接调用
copyout,因为当前页表可能不相关)。
这两层通过一个共享的数据缓冲区进行解耦,允许设备与CPU并行工作。驱动程序的代码量通常非常庞大,因为需要支持众多硬件设备。
设备编程:内存映射I/O
编程设备通常通过内存映射I/O来完成。设备控制器寄存器被映射到物理地址空间的特定位置。操作系统通过向这些地址执行普通的加载(load)和存储(store)指令来读写设备寄存器,从而控制设备。
例如,UART芯片的寄存器被映射到特定的物理地址。写入“传输保持寄存器”会发送一个字节;读取“接收寄存器”会获取一个字节。设备文档会详细说明每个寄存器的每位含义。
案例研究:打印“$”字符
现在,我们通过Shell打印$提示符的完整过程,来串联理解上述机制。
1. 初始化设置
- 内核启动时,在
start.c中配置CPU进入监管者模式(S-mode),并设置监管者状态寄存器(sstatus)以启用中断。 - 初始化UART驱动程序(
uartinit):配置UART芯片(如波特率),并启用其发送和接收中断。 - 配置PLIC(
plicinit):告知PLIC我们关心哪些设备(如UART)的中断。 - 每个CPU核心单独调用
plicinithart,表明自己愿意处理这些中断。 - 调度器运行前,通过设置
intr_on()函数启用CPU核心的中断使能位。至此,中断体系准备就绪。
2. Shell发起写入
- Shell进程执行
write(2, “$”, 1)系统调用。 - 内核中,
sys_write根据文件描述符找到控制台设备,调用其write函数(驱动程序的顶层)。 consolewrite将字符$放入UART的传输环形缓冲区,并调用uartstart()尝试启动发送。uartstart()检查设备是否空闲,如果空闲,则从缓冲区取出$,写入UART的传输保持寄存器。硬件UART芯片随后开始自动将该字节发送到串行线上。- 完成这些操作后,系统调用返回,Shell进程回到用户空间继续执行(例如,可能马上调用
read等待输入)。
3. 中断的发生与处理
- UART芯片完成一个字节的发送后,会拉高中断线。
- PLIC将中断路由到一个启用了中断的CPU核心。
- 该CPU核心的硬件响应:
- 清除中断使能位(防止嵌套中断)。
- 将当前程序计数器(PC)保存到监管者异常程序计数器(
sepc)。 - 跳转到预设的陷阱处理入口地址(
stvec),最终进入usertrap或kerneltrap。
- 在陷阱处理程序中,代码检查发现是外部中断,于是调用
plic_claim()获取中断号。 - 发现是UART中断号,调用
uartintr()(驱动程序底层)。 uartintr()检查中断原因。因为是发送完成中断,它调用uartstart()。此时,如果传输环形缓冲区中还有字符(例如Shell可能又写入了空格),uartstart()会将下一个字符写入UART寄存器,从而启动下一次发送。如果缓冲区已空,则什么也不做。- 中断处理完毕,恢复之前保存的现场,被中断的Shell进程(或内核代码)从原地继续执行。
4. 读取输入的处理
打印是发送,读取是接收,机制对称:
- Shell调用
read系统调用从控制台读取。 - 驱动程序的顶层
consoleread发现输入环形缓冲区为空,于是让Shell进程睡眠(sleep)。 - 当用户在键盘按下‘l’键,UART收到字节,产生接收中断。
- 中断处理程序
uartintr()从UART寄存器读取字符‘l’,并传递给consoleintr。 consoleintr将字符‘l’回显到控制台,并放入输入环形缓冲区。- 然后,它唤醒(wakeup) 正在睡眠等待输入的Shell进程。
- Shell被调度运行后,从缓冲区中取出字符‘l’并返回给用户程序。
关键概念与挑战
1. 生产者-消费者模型
驱动程序中的环形缓冲区是典型的生产者-消费者模型。
- 写操作(输出):Shell是生产者,向缓冲区填字符;UART中断处理程序是消费者,从缓冲区取字符发送。
- 读操作(输入):UART中断处理程序是生产者,将收到的字符放入缓冲区;Shell是消费者,从缓冲区取字符。
缓冲区解耦了生产者和消费者的速度,使它们能并发执行。
2. 并发控制
中断引入了真正的并发:
- 设备与CPU并行运行。
- 驱动程序的顶层(被系统调用触发)和底层(被中断触发)可能同时在不同CPU核心上运行,并访问共享的环形缓冲区。
- 内核代码本身也可能被中断打断。
为了保证数据正确性,必须使用锁(lock) 来对共享缓冲区的访问进行序列化。例如,在uartputc和uartintr中,都会先获取UART的锁,再操作缓冲区。sleep和wakeup机制用于在缓冲区满/空时进行等待和通知,它们也需要与锁小心配合。
3. 轮询与中断
对于极高速度的设备(如高性能网卡),中断处理开销(保存/恢复现场)可能变得不可接受。此时,驱动程序可能会采用轮询(polling) 模式:CPU主动反复检查设备寄存器,而不是等待中断。现代高性能驱动通常会动态地在中断和轮询模式间切换。
总结
本节课中我们一起学习了操作系统中的中断机制。我们了解了中断的异步和并发特性,以及它如何通过硬件(PLIC)和软件(驱动程序)协同处理。通过分析打印“$”字符的完整路径,我们看到了:
- 驱动程序如何通过内存映射I/O编程设备。
- 如何通过环形缓冲区和生产者-消费者模型来解耦设备与CPU。
- 中断处理程序如何响应硬件事件,并与驱动程序的顶层代码并发执行。
- 使用锁来保护共享数据结构,以及使用sleep/wakeup进行同步的必要性。

中断是操作系统管理硬件并实现并发的基石。理解它对于掌握后续关于锁、调度和进程通信的内容至关重要。


课程 P9:第10讲 - 多处理器与锁 🔒
在本节课中,我们将要学习多处理器系统中的锁。锁是协调多个处理器(或核心)访问共享数据结构的核心机制,对于保证操作系统的正确性至关重要。我们将探讨为什么需要锁、锁的基本概念、如何正确使用锁以及锁实现中的一些关键细节。
为什么需要锁?🤔
上一节我们介绍了多处理器系统的背景,本节中我们来看看为什么在这样的系统中锁是必不可少的。

我们的出发点是希望使用多个核心来提升性能。即使应用程序本身想在多个核心上运行,当它调用系统调用时,内核也必须能够处理这些来自不同进程的、可能并行发生的调用。这意味着,如果系统调用在不同的进程上并行运行,它们实际上可以访问许多共享的内核数据结构。
如果你有一个共享的数据结构,其中一个核心是写入者,另一个核心是读取者,我们就需要一种机制来协调这些更新,以保证数据的一致性。锁就是用来控制对共享数据结构的访问,以确保正确性的。

这有点令人沮丧,因为我们追求并行性以获得性能,但为了正确性又不得不引入锁,而锁本质上会序列化操作,从而限制性能。但这是我们必须面对的现实。




锁的基本概念与抽象 🧱


锁是一种抽象,它就像一个内核中的对象。在 xv6 中,有一个 struct lock 结构体来保存锁的状态。它有一个非常简单的应用程序接口(API)。
以下是锁 API 的核心调用:
acquire(struct lock *lk): 获取锁。release(struct lock *lk): 释放锁。
锁的基本规则是:在任何一个时间点,只有一个进程能够成功获得这把锁。任何其他试图同时获取这把锁的进程都必须等待。
在 acquire 和 release 调用之间的代码序列被称为临界区。临界区内的指令需要原子地(即不可分割地)执行,以避免多个执行流交错访问共享数据,这正是锁用来避免竞态条件的原因。
一个竞态条件的例子 ⚠️
让我们通过一个具体的例子来理解没有锁时会发生什么。在 xv6 的内存分配器中,有一个函数 kfree 负责释放页面并将其放回空闲链表。
以下是 kfree 函数中更新空闲链表的关键代码(简化):
// 假设 r 是要释放的页面
r->next = kmem.freelist; // 步骤1:更新 r 的 next 指针指向当前空闲链表头
kmem.freelist = r; // 步骤2:更新空闲链表头指向 r
如果没有锁的保护,考虑两个 CPU(CPU0 和 CPU1)几乎同时调用 kfree 释放不同的页面:
- CPU0 执行步骤1,将其页面
r0的next指向当前空闲链表头。 - 几乎同时,CPU1 执行步骤1,将其页面
r1的next也指向当前空闲链表头(此时链表头还未改变)。 - 接着,CPU0 执行步骤2,将空闲链表头更新为指向
r0。 - 然后,CPU1 执行步骤2,将空闲链表头更新为指向
r1。
最终结果是,r0 页面从空闲链表中丢失了,因为链表头最终指向了 r1,而 r1 的 next 指向的是旧的链表头,而不是 r0。这就是一个典型的“丢失更新”竞态条件。
使用锁可以将这两个步骤包装成一个原子操作,从而避免这种交错执行。
锁的粒度与性能权衡 ⚖️
既然锁这么重要,一个直接的想法可能是为每个共享数据结构自动关联一把锁。但这在实践中可能过于严格,甚至导致错误。
考虑一个 rename 系统调用,它将一个文件名从一个目录(D1)移动到另一个目录(D2)。如果采用自动锁策略,我们可能会:
- 锁住 D1,删除文件名。
- 锁住 D2,添加文件名。
问题在于,在步骤1和步骤2之间,文件看起来好像不存在了,即使重命名操作尚未完成。正确的做法是在操作开始时同时获取 D1 和 D2 的锁,然后执行删除和添加操作,最后再释放两把锁。这说明,有些操作需要同时持有多个锁,锁不能简单地与单个对象自动绑定。
锁的粒度选择是一个重要的设计决策:
- 粗粒度锁(如大内核锁):简单,但严重限制并行性。
- 细粒度锁:可以提高并行性,但设计复杂,容易引入死锁等问题。
一个实用的建议是:从较粗粒度的锁开始,通过测量锁的争用情况来判断是否需要以及如何重构为更细粒度的锁。
锁的挑战:死锁、顺序与模块化 🚧


不正确或不谨慎地使用锁会引入新的问题。
死锁:最简单的死锁是同一个进程试图两次获取同一把锁。更复杂的情况涉及多把锁。例如,进程 P1 按顺序获取锁 A、B,而进程 P2 按顺序获取锁 B、A。如果两者同时执行,就可能陷入互相等待的“死锁”状态。
解决方案:为所有锁定义一个全局的获取顺序。所有需要获取多把锁的代码都必须按照这个全局顺序来申请锁。在上面的例子中,如果规定总是先获取 A 再获取 B,那么 P2 也必须按此顺序进行,死锁就不会发生。
模块化挑战:锁的顺序要求可能破坏模块的抽象边界。如果一个模块 M1 调用模块 M2,而 M2 内部使用了锁,那么 M1 可能需要了解 M2 使用了哪些锁,以确保在需要时能遵循全局锁顺序。这使得系统设计更加复杂。
锁的实现:从软件到硬件 💻
如何实现一个正确的锁?让我们先看一个错误的实现(自旋锁):
// 错误的 acquire 实现
void acquire(struct lock *lk) {
for(;;) {
if(lk->locked == 0) { // 步骤A:检查锁是否空闲
lk->locked = 1; // 步骤B:获取锁
break;
}
}
}
这个实现的问题在于步骤 A 和步骤 B 不是原子操作。两个 CPU 可能同时看到 locked == 0,然后都执行 locked = 1,导致两者都认为自己获得了锁,违反了锁的互斥性。
为了解决这个问题,我们需要硬件的帮助。大多数处理器都提供原子指令,能在一条指令内完成“读-改-写”操作。在 RISC-V 中,这条指令是 amoswap(原子交换)。xv6 的 acquire 函数利用 C 标准库的 __sync_lock_test_and_set 函数,它最终会编译成这样的原子指令,确保检查和设置锁的状态是不可分割的。
锁实现中的关键细节:中断与内存序 🔩
在实现锁时,还需要处理两个关键问题:
1. 中断处理:
如果在一个 CPU 上,进程在持有锁的期间被中断,而中断处理程序也试图获取同一把锁,就会导致死锁(该 CPU 在等待自己释放锁)。因此,在 xv6 的 acquire 函数中,会在获取锁之前关闭中断,并在 release 释放锁之后再打开中断。
2. 内存排序:
编译器和处理器为了优化性能,可能会对指令进行重排序。在单线程执行中,这没有问题。但在并发环境中,这可能导致灾难性后果。例如,编译器或 CPU 可能将临界区内的某些内存访问移到 acquire 之前或 release 之后,从而破坏保护。
为了解决这个问题,需要在代码中设置内存屏障(或称为内存栅栏)。xv6 在 acquire 和 release 中使用了 __sync_synchronize() 函数,它告诉编译器和硬件:在此屏障之前的所有内存操作必须完成后,才能执行屏障之后的操作。这确保了临界区内的访问被正确地限制在锁的保护范围内。
案例研究:xv6 中的 UART 驱动锁 📠
让我们看一个 xv6 中使用锁的具体例子:UART(串口)驱动。UART 驱动使用一个传输缓冲区和一个锁(uart_tx_lock)。
锁在这里保护几个不变量:
- 缓冲区本身:写索引(
uart_tx_w)和读索引(uart_tx_r)之间的数据是待发送的字符。 - 硬件寄存器:确保只有一个执行流(可能是进程调用
printf,也可能是中断处理程序)在任何时候向 UART 的发送寄存器写入。
由于中断处理程序可能与进程并发执行(在不同的 CPU 上),它们也需要获取同一把锁。这展示了锁如何协调驱动程序的上半部分(进程调用)和下半部分(中断处理)。
总结 📝
本节课中我们一起学习了多处理器系统中的锁。
- 锁的核心作用是保证对共享数据结构访问的正确性,避免竞态条件。
- 锁的抽象通过
acquire和release操作定义临界区。 - 使用锁的挑战包括在性能(并行性)和正确性之间权衡、选择锁的粒度、避免死锁以及处理模块化问题。
- 锁的正确实现依赖于硬件的原子指令,并需要仔细处理中断和内存排序问题。


锁是并发编程中的基础工具,虽然会引入复杂性,但它是构建正确高效的多核操作系统不可或缺的组成部分。在后续的课程和实验中,我们将继续深入探索锁的使用与设计。


浙公网安备 33010602011771号