HHP3-xv6-内核笔记-全-

HHP3 xv6 内核笔记(全)

01:介绍与概述 🖥️

在本节课中,我们将要学习 XV6 操作系统内核的基本介绍。XV6 是一个用于教学目的的、简短而精悍的类 Unix 操作系统。它由麻省理工学院开发,也被其他机构使用。这个操作系统主要供学生在操作系统课程中使用。

内核版本与运行环境

XV6 内核有两个实现版本:一个用于 X86 架构,另一个用于 RISC-V 架构。在本系列视频中,我们将讨论 RISC-V 版本。该版本使用 64 位处理器。无论使用哪个版本,您很可能需要通过 QEMU 等模拟器以模拟方式运行它,因为您不太可能拥有一台备用的 RISC-V 处理器计算机。它是一个旨在运行在裸机上的多核操作系统,而 QEMU 能够模拟多核系统。

代码规模与语言

XV6 内核代码非常简短,总共只有大约 6000 行。其中大部分代码使用 C 编程语言编写,大约有 300 行是汇编语言。代码结构简单、编写良好且清晰,是学习优秀编码技巧的绝佳范例。

学习目标与前提

本系列视频将对几乎所有代码进行逐行讲解,以帮助您理解其工作原理。我们不会假设您具备 RISC-V 指令集架构的知识,但会假设您有一些汇编语言编码的基础。我们将详细讲解汇编指令。如果您正在学习操作系统课程,本系列将是一个很好的补充。

核心特性

上一节我们介绍了 XV6 的基本背景,本节中我们来看看它的核心功能特性。

XV6 内核具备以下主要特性:

  • 进程:进程运行在各自的虚拟地址空间中,每个地址空间都有对应的页表支持。
  • 文件系统:支持类 Unix 的文件和目录层次结构。
  • 管道:支持将数据从一个程序管道传输到另一个程序。
  • 多任务处理:通过定时中断实现时间片轮转,使多个进程并行运行。

系统调用

XV6 实现了 21 个系统调用。虽然与拥有约 300 到 500 个系统调用的生产级 Unix 系统相比数量不多,但这足以展示 Unix 的核心思想。

以下是 XV6 中提供的一些系统调用列表:

  • fork(): 创建新进程。
  • wait(): 等待子进程终止。
  • exit(): 终止进程。
  • pipe(): 创建管道。
  • open(), close(), read(), write(): 用于文件操作。
  • kill(): 终止进程。
  • exec(): 加载并执行文件。
  • mkdir(), link(), unlink(): 用于目录和链接操作。
  • fstat(): 获取文件信息。
  • chdir(): 改变当前工作目录。
  • dup(): 复制文件描述符。
  • getpid(): 获取当前进程 ID。
  • sbrk(): 增长堆内存。
  • sleep(): 使进程休眠。
  • uptime(): 获取内核运行时间。

用户程序

XV6 附带了一系列用户程序,用以展示操作系统的能力。操作系统可以运行一个简单的 shell 程序。其他常见的 Unix 程序包括:

  • cat
  • echo
  • grep
  • kill
  • ln
  • ls
  • mkdir
  • rm
  • wc

局限性说明

尽管 XV6 可以被视为一个真正的 Unix 系统,但它缺失了许多复杂功能。例如,像 Linux 这样的真实操作系统内核代码量可能是其 100 倍。XV6 缺少的功能包括:

  • 用户 ID 和登录验证。
  • 文件的读写执行保护位。
  • mount 命令,因此只有一个文件系统。
  • 虚拟地址空间换出到磁盘的功能。
  • 网络支持和进程间通信同步机制。
  • 大量的设备驱动程序。
  • 丰富的应用程序。

总结

本节课中我们一起学习了 XV6 操作系统内核的概述。我们了解了它的开发背景、代码规模、核心特性、提供的系统调用和用户程序,以及它与完整操作系统相比的局限性。在接下来的视频中,我们将开始深入详细地分析代码。

02:通用特性 🖥️

在本节课中,我们将学习 XV6 内核的一些通用特性。XV6 是一个教学用的操作系统内核,设计运行在共享内存的多处理器系统上。我们将了解其硬件抽象、内存管理、调度策略以及用户地址空间等核心概念。

硬件抽象与配置 💾

上一节我们介绍了 XV6 的基本定位,本节中我们来看看它运行的硬件环境。

XV6 设计运行在共享内存的多处理器系统上。这意味着系统拥有多个核心(CPU),但所有核心共享同一块主内存。在代码和描述中,术语 CPU、核心(core)和硬件线程(hart)含义相同,均指能执行单个控制线程的硬件处理器。

主内存大小为 128 MB,这个值在代码中通过 #define 硬编码固定。真实的操作系统内核会在启动时探测可用内存并动态配置,但 XV6 简化了这一过程。

XV6 系统支持几种设备:

  • UART(通用异步收发器):处理串行通信,用于打印输出和读取键盘输入。
  • 磁盘驱动器:在模拟环境中由一个主机文件模拟。
  • 定时器中断:每个核心拥有自己独立的定时器中断。

此外,系统还模拟了平台级中断控制器(PLIC)和核心本地中断控制器(CLINT),用于管理设备中断的派发。

内存管理 🧠

了解了硬件基础后,我们进入内存管理部分。XV6 的内存管理方案力求简洁。

物理内存被划分为页,页大小固定为 4 KB(通过 #define 定义)。内核通过一个空闲页链表(free list)来管理内存。当内核需要内存时,它从链表头部分配一个页;当页不再需要时,则将其归还到链表头部。这是一个非常基础的内存分配方案。

XV6 没有类似 malloc 的动态内存分配器,也不支持分配可变大小的内存块。所有内核内存分配都以页为单位。

虚拟地址空间通过页表管理。XV6 使用三级页表结构。每个进程拥有自己的页表,此外还有一个内核页表,它映射所有物理内存,并被所有核心共享。

页表硬件支持对数据页设置权限标记:

  • R(可读)W(可写)X(可执行)
  • U(用户模式可访问)
  • V(有效)

这些标记决定了页是否可读、可写、可执行,以及当处理器(核心)运行在用户模式时能否访问该页。

进程调度 ⏱️

内存管理决定了数据的存放,而调度器则决定了执行的顺序。XV6 的调度器设计简单。

它是一个基本的轮转调度器。每个进程被分配一个固定的时间片(在 XV6 中为 100 万时钟周期)。所有核心共享一个单一的就绪队列(ready queue)。这个队列实际上是一个进程数组。

以下是调度过程:

  1. 当一个核心准备运行进程时,它线性扫描就绪队列数组,寻找状态为“可运行”的进程。
  2. 找到后,在该核心上给予该进程一个时间片。
  3. 时间片结束后,核心将该进程重新标记为可运行并放回数组中,然后继续扫描下一个进程。

由于多个核心独立扫描同一个共享数组,一个进程可能在核心 A 上刚结束时间片,紧接着就被核心 B 选中运行。因此,它并不是严格的轮转调度(即一个进程需等待其他所有可运行进程都执行一次),而是每个核心内部近似轮转,但整体上更简单。

启动、同步与限制 🔒

进程调度依赖于内核的稳定运行,而内核启动和并发控制是稳定的基础。

启动序列非常基础。模拟器会直接将内核可执行文件加载到模拟的物理内存固定位置。XV6 不支持引导加载程序、主引导记录或 BIOS。

XV6 使用几种技术进行并发控制:

  • 自旋锁:通过 acquirerelease 函数操作。锁用一个内存字表示,0 表示空闲,1 表示被持有。acquire 在循环中等待该字变为 0 后将其置 1;release 则将其置 0。
  • 睡眠与唤醒sleep 函数使调用进程进入阻塞(睡眠)状态,不再被调度。wakeup 函数用于唤醒一个或多个睡眠中的进程,将其状态改回可运行。
  • 中断禁用:每个核心可以单独禁用中断,以防止被本核心的定时器或 I/O 中断打断。但这不影响其他核心,因此其他核心上的线程仍可能同时修改内存。

XV6 有许多通过 #define 定义的固定限制,例如最大进程数、就绪队列数组大小、同时打开的最大文件数等。内核倾向于使用数组而非链表,并通过线性搜索来操作这些数组。

用户地址空间 👤

最后,我们看看用户程序视角下的虚拟地址空间。这对于理解应用程序如何运行至关重要。

用户虚拟地址空间从 0 开始向上增长。内核以页为单位分配此空间。

以下是地址空间的布局:

  • 代码与数据:通过 exec 系统调用,内核从文件系统读取 ELF 格式的可执行文件,分配若干页,并载入代码和数据。这些页被标记为可读、可写、可执行。
  • :分配一个单独的 4 KB 页。XV6 的栈大小固定,无法增长。若用户程序尝试超越此栈空间,将导致进程终止。
  • 守护页:位于栈页之下,被标记为用户模式不可访问。用户代码访问此页会立即触发异常并终止进程,从而防止栈溢出。
  • :位于栈之上,以页为单位向上增长。用户程序可以通过系统调用请求内核分配更多页来扩大堆,用于实现自己的 malloc 等内存管理。
  • 蹦床页与陷阱帧页:位于地址空间顶部(高地址处)。两者均标记为用户模式不可访问。
    • 蹦床页:包含可执行代码,在发生异常或中断时执行。所有进程共享同一物理蹦床页。
    • 陷阱帧页:可读可写,用于在发生陷阱时保存用户进程的寄存器状态。每个进程拥有自己独立的物理陷阱帧页。

C 程序员熟悉的 main 函数参数 argcargv 由内核在创建用户栈时设置并压栈。XV6 不支持环境变量。

关于地址空间大小,XV6 基于 RISC-V 的 SV39 方案,使用 38 位虚拟地址(而非完整的 39 位),因此最大虚拟地址空间为 256 GB。

总结 📝

本节课中我们一起学习了 XV6 内核的通用特性。我们了解到 XV6 是一个为多核共享内存系统设计的教学内核,具有简化的固定内存配置、基础的页式内存管理与空闲链表分配器、基于共享数组的多核轮转调度、以及用于并发控制的自旋锁和睡眠/唤醒机制。最后,我们详细剖析了用户地址空间的布局,包括代码、栈、堆以及内核用于处理陷阱的专用区域。这些设计体现了 XV6 追求简洁、清晰以服务于教学的核心目标。

03:启动流程与代码组织 🚀

在本节课中,我们将学习 xv6 操作系统的启动流程,并了解其源代码的组织结构。我们将从代码文件概览开始,接着深入分析系统启动时各核心的执行路径,最后浏览几个基础的头文件。

代码文件组织 📁

xv6 系统的源代码组织非常清晰。主要包含两个目录:kerneluser

以下是 kernel 目录中的文件:

  • C 语言源文件:包含内核的主要功能代码。
  • 头文件:定义数据结构、常量和函数原型。
  • 汇编语言文件
    • entry.S:系统启动入口。
    • kernelvec.S:内核中断与异常处理。
    • switch.S:进程上下文切换。
    • trampoline.S:用户态与内核态之间的跳板。
    • initcode.S:第一个用户进程的初始化代码。
  • 链接器脚本文件:用于指导内核镜像的链接过程。

user 目录中,存放着用户空间的程序代码,包括初始进程 initshell 以及其他工具程序如 catecho 等。

此外,项目根目录下还包含构建整个系统的 MakefileREADME 和许可证文件。

多核启动流程 🔄

xv6 运行在多核处理器上。系统启动时,所有核心会同时开始执行,它们共享相同的内存空间和初始代码。

所有核心的起始执行点都在汇编文件 entry.S 中。这段简短的代码负责为执行 C 语言程序做好准备。它会初始化两个关键寄存器:

  1. 栈指针寄存器 (sp):为每个核心设置独立的栈空间,防止重叠。
  2. 线程指针寄存器 (tp):将其设置为当前核心的编号(0, 1, 2...),这样内核代码在任何时候都能通过 cpuid() 函数查询自己运行在哪个核心上。

完成这些设置后,控制权会转移到 C 语言函数 start()(位于 start.c)。这里涉及处理器模式的切换。RISC-V 处理器有机器模式 (Machine Mode)、监管者模式 (Supervisor Mode) 和用户模式 (User Mode)。start.c 中的代码在完成少量机器模式下的簿记工作后,会将核心切换到监管者模式,此后内核的大部分代码都运行在此模式下。

主函数分析 🧠

上一节我们了解了系统的启动入口,本节中我们来看看内核的“大脑”——main 函数。其代码位于 main.c 中,内容如下:

// main.c 核心逻辑
void main() {
    if(cpuid() == 0) {
        // 核心0执行的初始化代码
        consoleinit();
        printfinit();
        kinit();         // 物理内存分配器
        kvminit();       // 创建内核页表
        kvminithart();   // 打开分页
        procinit();      // 进程表
        trapinit();      // 陷阱向量
        trapinithart();  // 安装内核陷阱向量
        plicinit();      // 设置中断控制器
        plicinithart();  // 为当前核心启用中断
        binit();         // 缓冲区缓存
        iinit();         // inode 表
        fileinit();      // 文件表
        virtio_disk_init(); // 模拟磁盘
        userinit();      // 第一个用户进程
        __sync_synchronize(); // 内存屏障
        started = 1;     // 通知其他核心
    } else {
        // 其他核心执行的代码
        while(started == 0)
            ; // 等待核心0完成初始化
        printf("hart %d starting\n", cpuid());
        __sync_synchronize();
        kvminithart();    // 打开分页
        trapinithart();   // 安装内核陷阱向量
        plicinithart();   // 为当前核心启用中断
    }
    scheduler(); // 所有核心最终调用调度器
}

所有核心会并行执行 main 函数。它们通过 cpuid() 函数(读取 tp 寄存器)来区分自己的角色:

  • 核心0:负责全局初始化工作,如初始化控制台、内存、页表、进程、中断和设备驱动等。最后,它创建第一个用户进程,并通过将共享变量 started 设置为 1 来通知其他核心。
  • 其他核心:在 started 变为 1 之前,它们会在一个紧凑的循环中等待。收到信号后,它们打印启动信息,并完成自身核心相关的初始化(如启用分页和中断)。

__sync_synchronize() 是一个内存屏障指令,它告诉编译器不要为了优化而重排其前后的代码执行顺序,确保初始化操作的完整性和同步变量的正确可见性。

当所有核心完成初始化后,它们都会调用 scheduler() 函数,开始寻找并执行用户进程。

基础头文件速览 📄

最后,我们来快速浏览几个基础的头文件,它们定义了系统的基本参数和类型。

1. types.h:类型定义
此文件使用 typedef 为常用数据类型定义了简洁的别名,确保在不同平台上的一致性。

typedef unsigned int   uint;
typedef unsigned short ushort;
typedef unsigned char  uchar;
typedef unsigned char  uint8;
typedef unsigned short uint16;
typedef unsigned int   uint32;
typedef unsigned long  uint64; // 用于地址和指针

2. param.h:系统参数
此文件硬编码了内核的许多常量参数,例如:

#define NPROC        64  // 最大进程数
#define NCPU          8  // 核心数
#define NOFILE       16  // 每个进程最大打开文件数
#define NFILE       100  // 系统最大打开文件数
#define MAXARG       32  // 系统调用最大参数数量
#define MAXPATH     128  // 文件路径最大长度

3. defs.h:函数原型声明
这个文件很长,其主要作用是为分散在各个 .c 文件中的函数提供全局声明(原型),以便它们可以相互调用。它相当于内核的“接口目录”。

4. 有用的宏
defs.h 的末尾,定义了一个计算数组元素个数的宏:

#define NELEM(x) (sizeof(x)/sizeof((x)[0]))

这个宏通过计算数组总大小与单个元素大小的比值,来得到数组的元素数量。

总结 📝

本节课中我们一起学习了 xv6 内核的启动流程与代码组织。我们了解到:

  1. xv6 代码结构清晰,分为内核与用户空间。
  2. 系统从 entry.S 开始,在多核上并行启动,核心0负责全局初始化,其他核心等待同步。
  3. main 函数是内核初始化的中心,它按顺序初始化各个子系统,并最终启动所有核心的调度器。
  4. 基础头文件 types.hparam.hdefs.h 定义了系统的基本数据类型、常量参数和函数接口,是理解内核代码的基础。

掌握这些启动和组织结构的知识,为我们后续深入分析 xv6 内核的各个模块(如内存管理、进程调度、文件系统等)奠定了坚实的基础。

04:自旋锁 🔒

在本节课中,我们将学习 XV6 操作系统内核中自旋锁的实现原理。自旋锁是一种基础的同步原语,用于在多核或多线程环境中保护共享数据,确保同一时间只有一个执行单元能进入临界区。我们将从基本概念开始,逐步深入到 XV6 的具体实现细节,包括如何避免死锁和处理中断。

自旋锁的基本概念

自旋锁的核心是一个表示其状态的单字变量。这个变量通常只有两个值:

  • 0:表示锁是空闲的未锁定的已释放的
  • 1:表示锁是被持有的已获取的已锁定的

在 XV6 中,自旋锁的结构体定义如下:

struct spinlock {
    int locked;       // 锁的状态,0 表示空闲,1 表示被持有
    char *name;       // 用于调试的锁名称
    struct cpu *cpu;  // 指向当前持有锁的 CPU 结构体
};

其中,namecpu 字段主要用于调试目的。每个 CPU 核心都有一个对应的 cpu 结构体,cpu 字段指向当前持有该锁的 CPU 核心的结构体。

关键操作函数

自旋锁(以及大多数锁)的关键操作函数是 acquire(获取)和 release(释放)。此外,还有初始化锁的 init 函数和用于检查当前核心是否持有锁的 holding 函数。

一个简单的(但有问题的)获取尝试

获取锁的基本思路是:检查锁是否空闲(值为 0),如果是,则将其设置为 1(持有)。如果锁已被持有,则循环等待(即“自旋”)。一个初步的实现可能如下:

while (lock->locked == 1) // 检查锁是否被持有
    ; // 自旋等待
lock->locked = 1; // 获取锁

然而,这段代码在多核并发环境下存在严重问题。两个线程可能同时检查到锁是空闲的(值为 0),然后同时将其设置为 1,导致双方都认为自己持有了锁,破坏了锁的互斥性。

原子操作:AMO Swap

为了解决上述并发问题,我们需要一个原子操作。RISC-V 架构提供了 AMO swap(原子内存交换)指令。这条指令能不可分割地完成两件事:

  1. 将一个值写入内存的某个字。
  2. 同时,取出该内存字在写入之前的值。

其工作流程可以表示为:

old_value = atomic_swap(&lock->locked, 1);

这个操作保证了在“读取旧值”和“写入新值”之间,不会有其他任何指令(无论是来自当前核心还是其他核心)插入执行。

正确的获取与释放实现

利用 AMO swap,我们可以实现正确的锁获取逻辑:

  1. 使用原子交换指令,尝试将锁的值设置为 1,并获取其旧值。
  2. 如果旧值为 0,说明锁之前是空闲的,我们成功获取了锁。
  3. 如果旧值为 1,说明锁已被其他执行单元持有,我们需要回到步骤 1 继续循环尝试(自旋)。

释放锁的操作则相对简单:只需将锁的值原子地设置为 0 即可。虽然简单的内存存储操作通常是原子的,但 XV6 为了严谨,同样使用了原子指令。

XV6 中的自旋锁实现

上一节我们介绍了自旋锁的基本原理和原子操作的必要性。本节中,我们来看看 XV6 内核中自旋锁的具体实现代码。

初始化锁

以下是初始化自旋锁的代码(来自 spinlock.c):

void initlock(struct spinlock *lk, char *name) {
    lk->name = name;        // 设置锁的调试名称
    lk->locked = 0;         // 初始状态为未锁定
    lk->cpu = 0;            // 初始时没有 CPU 持有锁
}

初始化时,锁被设置为未锁定状态,且没有持有者。

获取锁

以下是 acquire 函数的核心部分:

void acquire(struct spinlock *lk) {
    push_off(); // 禁用中断,避免死锁
    // 检查是否已经持有该锁(防止重复获取)
    if(holding(lk))
        panic("acquire");

    // 自旋等待,直到成功获取锁
    while(__sync_lock_test_and_set(&lk->locked, 1) != 0)
        ;

    // 告诉编译器和 CPU:临界区内存访问必须严格在锁获取之后进行
    __sync_synchronize();

    // 记录当前持有锁的 CPU
    lk->cpu = mycpu();
}

代码解析:

  • __sync_lock_test_and_set():这是一个编译器内置函数,会生成 AMO swap 指令。它尝试将 lk->locked 设置为 1 并返回旧值。循环会持续直到旧值为 0。
  • __sync_synchronize():这是一个内存屏障指令。它告诉编译器和处理器,不要将临界区内的加载/存储操作重排到锁获取操作之前,确保临界区的访问受到保护。
  • push_off():这是一个关键调用,用于禁用中断。我们稍后会详细解释其原因。
  • holding(lk):检查当前 CPU 是否已经持有此锁,如果是则报错(防止递归获取导致死锁)。

释放锁

以下是 release 函数:

void release(struct spinlock *lk) {
    // 检查当前 CPU 是否确实持有此锁
    if(!holding(lk))
        panic("release");

    lk->cpu = 0; // 清除持有者记录

    // 内存屏障:确保临界区所有操作在释放锁前完成
    __sync_synchronize();

    // 释放锁(原子操作)
    __sync_lock_release(&lk->locked);

    pop_off(); // 恢复中断状态
}

代码解析:

  • __sync_lock_release():原子地将锁的值设置为 0。
  • pop_off():与 push_off() 配对,用于恢复中断状态

检查锁持有状态

holding 函数用于检查当前 CPU 是否持有指定的锁:

int holding(struct spinlock *lk) {
    int r;
    // 锁被持有(值为1)且持有者记录是当前 CPU
    r = (lk->locked && lk->cpu == mycpu());
    return r;
}

自旋锁的使用模式与中断处理

我们已经看到了自旋锁的代码实现。自旋锁的设计决定了它不应该被长时间持有,否则其他等待锁的核心会持续空转,浪费 CPU 资源。它通常用于保护非常短小的临界区。

典型使用模式

自旋锁的典型使用模式是保护对共享数据的访问:

acquire(&lock);   // 进入临界区前获取锁
// ... 访问或修改共享数据 ... // 临界区
release(&lock);   // 离开临界区后释放锁

临界区内的代码一次只能由一个执行单元执行。

示例:键盘输入缓冲区

假设一个键盘中断处理程序将字符写入一个共享缓冲区,而另一个线程从中读取字符。这个缓冲区就需要用自旋锁保护。

  • 中断处理程序acquire(&lock) -> 写入字符 -> release(&lock)
  • 读取线程acquire(&lock) -> 读取字符 -> release(&lock)

中断与死锁问题

现在,我们来解答之前留下的悬念:为什么在 acquire 中需要调用 push_off 来禁用中断?

考虑以下可能引发死锁的场景:

  1. 线程 T 在某个 CPU 上运行,并获取了锁 L。
  2. 就在此时,该 CPU 上发生了一个硬件中断(例如键盘输入)。
  3. CPU 开始执行中断处理程序。
  4. 该中断处理程序的第一行代码也试图获取同一个锁 L
  5. 结果:中断处理程序自旋等待线程 T 释放锁 L,但线程 T 只有在中断处理程序执行完毕返回后才能继续运行并释放锁。双方互相等待,形成死锁。

为了避免这种由同一 CPU 上中断导致的死锁,XV6 的策略是:在获取自旋锁时,禁用当前 CPU 的中断。这样,持有锁的线程就不会被同一 CPU 上的中断处理程序打断,从而避免了上述死锁场景。这在 release 锁时再恢复中断。

嵌套锁与中断状态管理

但问题又来了:如果中断在调用 acquire 之前就已经被禁用了呢?(例如,在中断处理程序内部)。或者,代码需要连续获取多个锁?我们不应该在释放第一个锁时就冒然重新开启中断。

XV6 的解决方案是使用一个每 CPU 的计数器 intena(位于 cpu 结构体中)来管理中断状态的嵌套。

  • push_off()
    • 保存当前中断状态(启用/禁用)。
    • 如果这是第一次进入(计数器从 0 变为 1),则保存旧的中断启用状态。
    • 禁用中断。
    • 增加嵌套计数器。
  • pop_off()
    • 减少嵌套计数器。
    • 如果计数器回到 0,并且之前保存的状态是“中断启用”,则重新启用中断。

这种机制确保了中断状态能够被正确、嵌套地保存和恢复。

总结

本节课中我们一起学习了 XV6 内核中自旋锁的完整实现。我们从自旋锁的基本概念出发,理解了为什么需要原子操作(AMO swap)来保证正确的并发获取。然后,我们详细分析了 XV6 中 initlockacquirereleaseholding 函数的代码,并了解了内存屏障(__sync_synchronize)的作用。

最后,我们探讨了自旋锁使用中最关键的问题之一:中断与死锁。通过分析一个典型死锁场景,我们明白了在获取锁时禁用中断(push_off)的必要性,以及 XV6 如何通过每 CPU 的嵌套计数器来优雅地管理中断状态的保存与恢复(pop_off)。自旋锁是构建操作系统更高级同步机制的基础,理解其原理和实现细节至关重要。

05:内存管理

在本节课中,我们将学习 XV6 内核如何管理物理内存。这是一个相对简单直接的系统,核心是维护一个空闲内存页的链表。

概述

XV6 内核的内存管理以 4KB 的块(也称为“页”)为单位进行。所有空闲内存页被维护在一个链表中。我们有两个核心函数:kalloc 用于从链表中分配一个空闲页,kfree 用于将一个页释放回链表。接下来,我们将详细探讨其实现。

核心数据结构与初始化

所有内存管理相关的代码位于文件 kalloc.c 中。以下是关键的数据结构:

  • 空闲链表:一个指向空闲内存页链表的指针 freelist
  • 链表节点:每个空闲页本身作为一个节点,其开头存储一个指向下一个空闲页的指针(结构体 run)。
  • 自旋锁:一个名为 kmem.lock 的自旋锁,用于保护对空闲链表的并发访问。
  • 内存边界:变量 end 由链接器设置,指向内核代码和数据段之后的首个可用内存地址。

初始化函数 kinit 会初始化自旋锁,然后调用 freerange 函数,将 end 到物理内存顶端(例如 128MB)之间的所有内存页释放到空闲链表中。

内存分配:kalloc 函数

上一节我们介绍了内存管理的基础结构,本节中我们来看看如何分配内存。

kalloc 函数负责从空闲链表中分配一个 4KB 页。其工作流程如下:

  1. 获取保护空闲链表的自旋锁。
  2. 从链表头部取出第一个空闲页。
  3. 将链表头指针指向取出的页的下一个页。
  4. 释放自旋锁。
  5. 在返回页指针前,用特定值(如 0x05)填充整个页,目的是暴露潜在的程序错误(如使用已释放的内存)。

以下是 kalloc 的核心代码逻辑示意:

acquire(&kmem.lock);
r = kmem.freelist;
if(r)
    kmem.freelist = r->next;
release(&kmem.lock);
if(r)
    memset((char*)r, 5, PGSIZE); // PGSIZE = 4096
return (void*)r;

内存释放:kfree 函数

分配内存的反向操作是释放内存。kfree 函数接收一个指向要释放页的指针,并将其添加回空闲链表。

以下是 kfree 的执行步骤:

  1. 进行错误检查:确保地址是页对齐的,并且位于有效的物理内存范围内。
  2. 用另一个特定值(如 0x01)填充整个页,目的同样是触发错误。
  3. 获取自旋锁。
  4. 将要释放的页插入到空闲链表的头部。
  5. 释放自旋锁。

以下是 kfree 的核心代码逻辑示意:

// 错误检查与填充
acquire(&kmem.lock);
r->next = kmem.freelist;
kmem.freelist = r;
release(&kmem.lock);

初始化过程详解

现在,让我们回到初始化过程 freerange,看看空闲链表最初是如何建立的。

freerange 函数接收一个起始地址和结束地址,并将其间的所有内存页释放。它首先将地址向上舍入到页边界,然后循环遍历每一页,通过调用 kfree 函数将每一页添加到空闲链表中。这有效地将所有可用内存初始化为空闲状态。

总结

本节课中我们一起学习了 XV6 内核简单而有效的物理内存管理机制。其核心是维护一个由 4KB 页组成的空闲链表,并通过 kallockfree 两个函数进行分配与释放。使用自旋锁保护链表的并发访问,并在分配和释放时填充数据以帮助调试。这个模型为操作系统其他部分的内存需求提供了基础服务。

06:用户态系统调用

在本节课中,我们将学习用户态程序如何发起系统调用。我们将通过分析一个简单的C程序和一个特殊的汇编程序来理解系统调用的机制,包括参数传递、返回值以及从用户态切换到内核态的过程。

概述

系统调用是用户程序请求操作系统内核服务的主要方式。在XV6中,每个系统调用都有一个唯一的编号,用户程序通过特定的汇编指令序列来触发系统调用。本节我们将通过分析 kill 命令和第一个用户程序 initcode.S 的代码,来揭示这一过程。

从C程序看系统调用

首先,我们来看一个名为 kill 的C程序。这个程序你可能很熟悉,它调用了库函数(如 fprintfatoi)以及系统调用(如 exitkill)。

以下是 kill 程序的关键部分:

#include "user.h"
...
exit(1);
...
kill(pid);

它包含了头文件 user.h,并向系统调用 exit 传递了参数 1。系统调用可以返回值,但在这个例子中没有体现。

user.h 头文件

user.h 头文件至关重要,它包含了所有库函数和系统调用的函数原型。XV6支持21个系统调用,每个都在此文件中列出。

例如,exitkill 的系统调用原型如下:

int exit(int) __attribute__((noreturn));
int kill(int);

__attribute__((noreturn)) 是编译器指令,告知编译器该函数永不返回,以便进行优化。该文件也包含了如 printfstrlenatoi 等库函数的原型。

系统调用的汇编实现

每个系统调用在用户侧都有一个对应的、非常简短的汇编函数。这些函数被收集在由Perl脚本 usys.pl 自动生成的 usys.S 文件中。

以下是 usys.plopen 系统调用生成的汇编代码示例:

.global open
open:
    li a7, SYS_open
    ecall
    ret

这段代码做了三件事:

  1. li a7, SYS_open:将系统调用编号(例如 open 的编号是15)加载到寄存器 a7 中。
  2. ecall:执行环境调用指令,从用户态切换到内核态。
  3. ret:从系统调用返回。

系统调用的参数通过寄存器 a0a1a2 等传递。返回值通过寄存器 a0 返回。因此,这个简短的包装函数不会修改 a0 等参数寄存器,它们由调用者在调用前设置,并由内核在返回时填充结果。

第一个用户程序:initcode.S

现在,我们来看一个特殊的用户程序 initcode.S。这是内核启动后执行的第一个用户态程序。

它的主要功能是调用 exec 系统调用来启动 /init 程序。其逻辑用伪代码表示如下:

char *argv[] = { "/init", 0 };
exec("/init", argv);
exit(0); // 如果exec意外返回,则调用exit

exec 系统调用接收两个参数:一个指向文件路径字符串("/init")的指针,和一个指向参数数组的指针。

以下是 initcode.S 的关键汇编代码:

#include "syscall.h"
...
    la a0, init
    la a1, argv
    li a7, SYS_exec
    ecall
...
    li a7, SYS_exit
    ecall
  1. la a0, init:将字符串 "/init" 的地址加载到参数寄存器 a0
  2. la a1, argv:将参数数组 argv 的地址加载到参数寄存器 a1
  3. li a7, SYS_exec:将 exec 的系统调用编号加载到 a7
  4. ecall:发起系统调用。

如果 exec 调用成功,它将用 /init 程序替换当前进程镜像,不会返回。如果失败,代码会继续执行 exit 系统调用。exit 调用理论上也不应返回,如果返回,程序会陷入无限循环。

总结

本节课我们一起学习了XV6中用户态发起系统调用的完整流程:

  1. 用户程序通过包含 user.h 头文件来获取系统调用原型。
  2. 每个系统调用在用户侧对应一个由脚本生成的简短汇编函数(如 usys.S 中的函数)。
  3. 该汇编函数将系统调用编号存入 a7 寄存器,通过 ecall 指令陷入内核,然后返回。
  4. 系统调用的参数通过 a0a1 等寄存器传递,返回值通过 a0 寄存器传回。
  5. 我们通过分析 kill 程序和第一个用户程序 initcode.S,具体观察了系统调用在代码中的使用方式。

理解这套机制是理解操作系统如何为用户程序提供服务的基础。

07:RISC-V 架构 🏗️

在本节课中,我们将学习 RISC-V 处理器架构的基础知识。了解这些知识对于理解 xv6 内核的工作原理至关重要。我们将从寄存器、处理器模式、控制状态寄存器以及异常和中断等核心概念开始。

寄存器

RISC-V 指令集架构拥有 32 个通用寄存器和一个程序计数器,它们都是 64 位的。以下是这些寄存器的简要介绍:

  • x0:该寄存器被硬连线为 0,因此在进程间进行上下文切换时无需保存。
  • ra:返回地址寄存器。RISC-V 使用一种巧妙的函数调用和返回系统。调用发生时,返回地址保存在此寄存器中,而不是压入栈。返回指令则简单地将此寄存器的值复制回程序计数器。
  • sp:栈指针,栈向下增长。
  • tp:线程指针。在 xv6 内核中,它包含核心编号,即硬件线程 ID。
  • gp:全局指针,由编译器使用,用于高效访问全局和共享变量。
  • a0-a7:用于向函数传递参数。a0 也用于存放函数返回值。
  • t0-t6:临时寄存器,可在函数内自由使用。
  • s0-s11:被调用者保存寄存器。调用者假定被调用的函数不会修改这些寄存器。因此,如果函数需要使用它们,必须在使用前保存(通常压入栈),并在返回前恢复。

这 31 个寄存器和程序计数器构成了用户模式线程的完整状态。用户代码无法访问状态寄存器,因此在用户模式下状态寄存器是不可见的。

在每次上下文切换时(即结束一个进程的时间片并开始另一个进程的时间片),内核需要保存前一个线程的状态,并在下一个时间片开始前加载下一个进程的寄存器状态。

处理器模式

在任何时刻,RISC-V 处理器都处于以下三种模式之一:

  • 机器模式:最高权限模式。核心启动或复位后进入此模式。在 xv6 内核中,机器模式使用不多,主要用于启动初始化和处理定时器中断。
  • 监管者模式:所有内核代码在此模式下运行。特权指令只能在此模式和机器模式下执行。
  • 用户模式:所有用户应用程序代码在此模式下运行。如果用户程序尝试执行特权操作,将引发陷阱,内核将终止该进程。

每个核心都有自己的寄存器集,并且在任何时刻都只运行于一种模式。

控制与状态寄存器

除了通用寄存器,还有一系列控制与状态寄存器。RISC-V 架构最多可容纳 4096 个此类寄存器,但为理解 xv6 内核,我们只关注其中 19 个。

有三个重要的特权指令用于操作 CSR:

  • 读取 CSRcsrr a0, sstatus (将 sstatus CSR 的值读入 a0 寄存器)
  • 写入 CSRcsrw sstatus, a0 (将 a0 寄存器的值写入 sstatus CSR)
  • 交换 CSRcsrrw a0, mscratch, a0 (原子性地将 mscratch CSR 的值读入 a0,同时将 a0 的旧值写入 mscratch CSR)

以下是一些关键的 CSR:

  • mhartid:包含核心编号。
  • sstatus:状态寄存器。
  • stvec:陷阱向量,即陷阱发生时将被调用的处理程序的地址。
  • sepc:异常程序计数器,保存发生陷阱时的程序计数器值。
  • scause:保存陷阱的原因。
  • stval:可能保存与陷阱相关的附加信息。
  • satp:页表指针,用于地址转换。
  • 一系列用于选择性启用和查询中断(在机器模式和监管者模式)的寄存器。
  • 用于将异常和中断从机器模式委托到监管者模式的寄存器。
  • 物理内存保护相关的寄存器。

异常与中断

异常和中断都属于更广义的“陷阱”。陷阱处理程序用于处理异常或中断。

  • 异常:同步发生,由当前执行的指令引起。例如:
    • 系统调用指令(在 RISC-V 中名为 ecall)。
    • 引发错误的指令(如非法指令、对齐错误、页错误等)。
  • 中断:异步发生,源自当前指令之外。例如:
    • 定时器中断。
    • 设备中断(如串行通信设备、磁盘)。
  • 软件中断:一种特殊的中断。当定时器中断发生时,运行在机器模式的处理程序需要通知内核(监管者模式代码),它会引发一个软件中断,然后由内核的软件中断处理程序来处理定时器中断的相关事务。

核心编号与物理内存保护

上一节我们介绍了 CSR 的概念,本节中我们来看看两个具体的寄存器示例。

  • 核心编号mhartid CSR 包含核心编号,它是硬连线的,无法修改。内核启动时会立即将此值移动到 tp 寄存器,并在内核中保持不变。用户代码可以修改 tp,但每当内核从用户代码重新获得控制权时,会首先恢复自己的寄存器(包括 tp),因此 tp 在内核中永远不会改变。
  • 物理内存保护:RISC-V 提供了物理内存保护系统,用于限制运行在监管者或用户模式的代码对物理内存的访问。其本意是支持安全启动和虚拟机监控程序代码。在 xv6 中,启动时在机器模式下会将其配置为允许完全访问所有物理内存,之后不再更改。

本节课中我们一起学习了 RISC-V 架构的基础知识,包括其寄存器组织、三种处理器模式、关键的控制与状态寄存器,以及异常和中断的处理机制。这些是理解 xv6 内核如何管理硬件资源和执行流程的基石。在下一节课中,我们将深入探讨状态寄存器和页表。

08:RISC-V 页表

在本节课中,我们将要学习地址转换,并描述 RISC-V 处理器中使用的页表架构,至少是理解 xv6 内核所需了解的部分。

概述

上一节我们介绍了操作系统的基本概念,本节中我们来看看 RISC-V 处理器如何进行虚拟地址到物理地址的转换。我们将重点了解控制地址转换的 SATP 寄存器、RISC-V 采用的 SV39 三级页表结构,以及虚拟地址的构成和转换过程。

SATP 寄存器

我们需要了解的核心寄存器是 SATP,即地址转换指针监管者寄存器。它是一个控制和状态寄存器,其值被设置为指向当前正在使用的页表。

页表保存在主内存中。在任何时刻,只有一个页表处于活动状态,即由 SATP 寄存器指向的那个。

在 RISC-V 核心中,虚拟地址转换在初始化完成后总是开启的。初始化阶段,SATP 被设置为零,此时不发生地址转换。但在初始化阶段,我们会将 SATP 设置为指向我们想要使用的页表。

当处理器运行在监管者模式和用户模式时,地址转换总是开启的;在机器模式下则不开启。

页表类型

系统中有多种页表。

  • 内核页表:所有处理器核心共享同一个内核页表。它提供了一个几乎是一对一的映射,将整个物理内存映射到内核的虚拟地址空间,使得内核代码无需进行复杂的地址计算即可访问内存中的任何位置。此外,一些内存映射的 I/O 设备也会被直接映射到内核页表中。
  • 用户进程页表:除了内核页表,每个用户模式进程都有自己独立的页表。

SV39 页表方案

在 RISC-V 架构中,页表实现有多种选项,称为 SV32、SV39、SV48。SV32 是两级页表方案,SV39 是三级页表架构,SV48 是四级页表架构。SV32 适用于 32 位处理器。我们只关心 64 位处理器,而 xv6 使用的是 SV39 方案。因此,处理器实现了 SV39 方案,我们拥有三级页表。

地址转换与 TLB

从概念上讲,每次对主内存的加载、存储或取指操作都会遍历页表,以找到要使用的地址转换。遍历页表会涉及多次内存访问,这将导致极低的效率。

因此,出于性能考虑,实际的处理器都包含一种称为翻译后备缓冲器的组件。这些是核心内部的寄存器,对程序员来说通常是透明或不可见的。它们的基本作用是作为最近使用的页表条目的缓存。

作为内核程序员,我们唯一需要知道的是:每当我们更改页表时,即每当我们更新 SATP 寄存器时,我们需要以某种方式刷新这个缓存,清空所有的 TLB 条目。

为此,RISC-V 指令集中有一条名为 sfence.vma 的指令。在内核中,有一个名为 sfence_vma() 的函数,其作用就是执行这条指令。

SV39 虚拟地址结构

在 SV39 方案中,一个虚拟地址是 39 位

这个地址可以划分为一个偏移量。所有页面的长度都是 4 KB,由于 2^12 = 4096,我们恰好需要 12 位来定位页面内的字节。

剩余的 27 位被分成三个字段,分别用于访问页表的 L2、L1 和 L0 级。高于这些的位将被忽略。

当我们访问页表时,会得到一个页表条目

该条目包含多个控制位,用于决定是否允许访问:

  • R:可读。
  • W:可写。
  • X:可执行。
  • U:指示页面是否可在用户模式下访问,还是仅在监管者模式下访问。
  • V:有效位,指示此页表条目是否有效。

还有一些其他位,但我们不关心。最后,条目中包含物理页号

地址转换硬件将获取虚拟地址,使用这些索引查找正确的条目并检索页表条目,然后它将取 44 位的物理页号和 12 位的偏移量,将它们组合起来。

物理地址 = (物理页号 << 12) | 偏移量

这样就形成了 56 位的物理地址。通过这种方案,我们可以支持高达 2^56 字节的物理主内存。

页表结构详解

我们再来仔细看看页表本身。记住我们有三个各 9 位的字段。

页表结构如下所示。SATP 寄存器指向一棵树。

树中的每个节点都是主内存中的一个 4 KB 页面,这棵树有三层。在叶子层,我们拥有实际的数据页。因此,所有这些节点和叶子都是 4 KB 的页面。

页表中每一层内部节点都包含 512 个条目。每个条目是 64 位(8 字节),8 字节 * 512 = 4096 字节,正好填满一个 4KB 页面。每个条目都是一个页表条目,其中包含一些控制位和一个指向下一级节点的指针。

其工作方式是:硬件首先查看虚拟地址的第一个 9 位字段(L2索引),并将其作为索引访问根节点(因为 2^9 = 512,9 位正好可以索引 512 个条目)。得到一个指向下一级(L1)页表的指针。接着,使用第二个 9 位字段(L1索引)作为索引访问这个 L1 页表,得到指向 L0 级页表的指针。最后,使用第三个 9 位字段(L0索引)作为索引访问 L0 页表,得到最终的页表条目。这个条目将用于检查对数据页的访问权限,并用于构建最终的物理地址。

总结

本节课中我们一起学习了 RISC-V 的地址转换机制。我们了解了 SATP 寄存器的作用,认识了内核页表和用户进程页表的区别。我们重点剖析了 xv6 采用的 SV39 三级页表方案,包括 39 位虚拟地址的构成(27位索引 + 12位偏移)、页表条目的权限位,以及从虚拟地址到 56 位物理地址的转换过程。最后,我们还提到了 TLB 的存在及其刷新指令 sfence.vma。理解这些内容是后续学习 xv6 内存管理的基础。

09:RISC-V 异常与中断处理 🖥️

在本节课中,我们将学习 RISC-V 架构中与异常和中断处理相关的核心硬件机制。我们将重点关注状态寄存器以及硬件如何处理“陷阱”(trap),这是对异常和中断的统称。

概述 📋

RISC-V 架构通过一套控制与状态寄存器(CSR)来管理处理器状态和陷阱处理。理解这些机制是理解操作系统内核如何响应系统调用、硬件中断和程序错误的基础。本节将详细介绍陷阱的分类、硬件处理流程以及 xv6 内核如何配置这些硬件设施。

陷阱的类型与基本概念

上一节我们介绍了课程背景,本节中我们来看看陷阱的具体类型。在 RISC-V 中,陷阱主要分为两类:异常中断

  • 异常:由当前执行的指令流直接引发。例如:
    • 系统调用(通过 ecall 指令触发)。
    • 程序错误,如非法指令、对齐错误等。
  • 中断:由处理器外部的异步事件引发,与当前执行的指令无关。例如来自定时器或外部设备的信号。

无论处理器当前处于用户模式还是监管者模式,当陷阱发生时,硬件都会跳转到特定的处理程序代码开始执行,并且该处理程序运行在监管者模式下。

监管者模式下的状态寄存器

现在,让我们深入了解监管者模式下的状态寄存器。xv6 内核几乎完全运行在监管者模式下,因此我们主要关注 sstatus 寄存器。虽然实际情况更复杂,但理解 xv6 内核只需关注其中三个关键位:

  1. SIE:此位控制中断是否被启用
  2. SPIE:当陷阱发生时,硬件会将之前的 SIE 值保存在此位中,以便在处理完成后恢复。
  3. SPP:此位用于保存陷阱发生前处理器所处的模式(用户模式或监管者模式)。

内核有时会通过临时禁用中断(将 SIE 设为 0)来保护关键代码段,防止被其他中断打扰。需要注意的是,xv6 是多核操作系统,禁用中断只影响当前核心,其他核心可能同时修改内存,因此还需要锁机制来处理更复杂的并发情况。

硬件陷阱处理流程

当陷阱(异常或中断)发生时,硬件会遵循一套固定的流程。以下是硬件自动执行的操作序列:

  1. 判断与等待:首先,硬件检查 SIE 位。
    • 如果是中断SIE=0(中断被禁用),则该中断会保持挂起状态,直到内核重新启用中断时才会被处理。
    • 如果是异常,无论 SIE 为何值,都会被立即处理
  2. 保存现场:一旦决定处理陷阱,硬件会完成当前指令,然后执行以下保存操作:
    • 将当前程序计数器(PC)保存到 sepc 寄存器。
    • 将陷阱原因(一个编号)保存到 scause 寄存器。
    • 有时会将附加信息(如出错的虚拟地址)保存到 stval 寄存器。
  3. 切换状态:硬件接着更新处理器状态:
    • 将之前的模式(用户/监管者)保存到 sstatus.SPP
    • 将之前的中断启用位(SIE)保存到 sstatus.SPIE
    • 禁用中断(将 SIE 设为 0)。
    • 将处理器模式切换到监管者模式
  4. 跳转执行:最后,硬件将程序计数器(PC)设置为 stvec 寄存器中保存的地址。stvec 寄存器指向陷阱处理程序的第一条指令,从而跳转到内核的陷阱处理代码(在 xv6 中是 usertrapkerneltrap)。

处理程序执行完毕后,内核会使用 sret 指令返回。该指令会:

  • sstatus.SPIE 恢复中断启用位(SIE)。
  • sstatus.SPP 恢复之前的处理器模式。
  • 将程序计数器设置为 sepc 中的地址,从而返回到被中断的代码继续执行。

机器模式下的陷阱处理

除了监管者模式,RISC-V 还有一个权限更高的机器模式。xv6 内核有一小部分代码在此运行。机器模式下的陷阱处理逻辑与监管者模式类似,但主要处理一种特定情况:定时器中断

由于某些原因,定时器中断无法被“委托”给监管者模式处理,必须在机器模式中处理。为此,xv6 采用了一种变通方法:

  1. 当定时器中断发生时,硬件会跳转到机器模式的陷阱处理程序(在 xv6 中是 timervec 函数)。
  2. timervec 函数会设置一个软件中断位,从而“制造”一个在监管者级别待处理的软件中断。
  3. 然后,timervec 执行 mret 指令返回被中断的代码。
  4. 随后,如果监管者模式的中断是启用的(SIE=1),这个“制造”的软件中断就会立即触发,从而让监管者模式的内核代码获得控制权,进行实际的调度等操作;如果 SIE=0,则软件中断会挂起,等待内核启用中断。

中断的启用与委托

RISC-V 提供了精细的中断控制机制。除了全局启用位(sstatus.SIEmstatus.MIE),还有用于选择性启用特定中断源的寄存器:

  • sie:监管者中断启用寄存器。可以分别控制外部设备中断、软件中断和定时器中断是否被启用。
  • sip:监管者中断挂起寄存器。当有中断发生时,对应的位会被置 1。内核也可以通过写此寄存器来“模拟”一个中断(正如 timervec 对软件中断位所做的那样)。

更重要的是,RISC-V 允许将大多数陷阱从机器模式委托给监管者模式处理,这简化了内核设计。xv6 在启动时(start 函数,运行在机器模式)会配置两个委托寄存器:

  • medeleg:异常委托寄存器。xv6 将其所有位设为 1,意味着将所有异常(如系统调用、页错误)都委托给监管者模式处理。
  • mideleg:中断委托寄存器。xv6 同样将其所有位(除无法委托的定时器中断相关位)设为 1,将设备中断和软件中断委托给监管者模式。

因此,对于绝大多数陷阱,硬件会跳过机器模式,直接触发监管者模式的陷阱处理流程。只有定时器中断需要经过机器模式处理程序的中转。

总结 🎯

本节课我们一起学习了 RISC-V 架构中陷阱处理的硬件机制。我们明确了异常与中断的区别,剖析了监管者模式状态寄存器 sstatus 的关键位,并详细跟踪了硬件从陷阱发生到跳转至处理程序的完整流程。我们还了解了更高权限的机器模式如何处理特殊的定时器中断,以及 xv6 如何通过配置委托寄存器,将大部分陷阱处理工作集中在监管者模式的内核代码中。这些硬件机制是操作系统实现系统调用、进程调度和硬件交互的基石。

10:上下文切换 🔄

在本节课中,我们将学习 xv6 内核中的上下文切换机制。我们将探讨陷阱(trap)和系统返回(sret)指令如何被用于实现时间片轮转,以及系统如何从一个线程切换到另一个线程。

概述 📋

操作系统通过时间片轮转的方式在多个线程之间共享 CPU 资源。上下文切换是实现这一功能的核心机制,它涉及保存当前线程的状态,并恢复下一个要运行线程的状态。这个过程主要通过陷阱进入内核,再由内核调度器决定切换到哪个线程。

时间片与执行流程 ⏱️

上一节我们介绍了操作系统的基本概念,本节中我们来看看线程的执行流程。时间沿着页面垂直向下流动。一个用户线程持续执行指令,直到某个时刻发生陷阱(trap),随后系统切换到内核模式执行内核指令。

  • 用户线程指令在用户模式下执行。
  • 任何在内核模式下执行的指令都属于内核的一部分。

从用户模式切换到内核模式是陷阱的结果。这可能是由设备请求关注的中断,也可能是用户线程自身通过系统调用发起的请求,或者是用户线程的程序异常(某种错误)导致的。

内核执行完毕后,准备返回用户线程时,会执行 sret(系统返回)指令,使系统回到用户模式。如果陷阱是中断,用户线程将不会察觉到陷阱的发生,它只是继续执行指令。

在陷阱发生时,用户线程的所有寄存器都会被保存。在系统返回时,这些寄存器将被恢复。因此,线程可以从它中断的地方继续执行。

随着时间的推移,用户线程会经历一系列时间片。如果陷阱是定时器中断的结果,那么就标志着一个时间片的结束。内核会去处理其他线程,但最终会决定再次给予该用户线程一个时间片并返回。

调度器的视角 👁️

以下是调度器视角下的线程切换流程:

  • 左侧红色部分代表调度器线程。
  • 线程 X 和线程 Y 交替执行。

从线程 X 的视角看,它执行一段时间后发生陷阱,然后在某个稍后的时间点,系统返回发生,它继续执行,如此循环。当内核活动时,它可能选择运行线程 Y 的一个时间片。因此,系统返回到线程 Y,线程 Y 执行直到它发生陷阱。

在这张图中,一个有趣的现象是:陷阱之后跟着返回,有点像调用之后跟着返回。对于系统调用,用户线程可以想象成它在调用内核,而内核最终会返回。但在调度场景下则不同,返回和陷阱的顺序是“颠倒”的——一个陷阱发生后,并不会立即返回,而是会先执行一些其他操作(如切换到另一个线程)。

从调度器的角度看,它通过 sret 指令进入线程 X 并开始其时间片,而该时间片以一条陷阱指令结束。

陷阱发生时 🔍

现在,让我们更仔细地看看陷阱发生时的情况。当陷阱发生时,内核开始执行,最终进行系统返回。这个过程相当复杂,但基本流程是:用户代码执行 -> 发生陷阱 -> 内核处理 -> 系统返回 -> 用户代码恢复。

在陷阱发生的最初阶段,寄存器和程序计数器会被保存。在底部的系统返回之前,用户寄存器被恢复,然后执行 sret 指令。内核处理过程包含一个大的条件判断:可能是设备请求服务,需要运行特定设备的处理程序;可能是用户代码请求系统调用;也可能是定时器中断或程序异常。但至少在这几种情况下,最终都会回到用户代码。

多核系统上的上下文切换 🖥️🖥️

接下来,我们讨论多核系统上的情况。之前的图示展示的是单核系统。在多核系统中,情况类似,但涉及多个核心。

  • 蓝色代表一个核心上的调度器。
  • 红色代表另一个核心。
  • 进程 X, Y, Z, W 在不同核心上获得时间片。

从进程 Z 的视角看,它在一个核心上获得一个时间片,然后等待一段时间,接着在另一个核心上获得另一个时间片。任何进程的时间片都可以在任何核心上发生,在 xv6 中这很大程度上是随机的。

在这个陷阱发生时(进程 Z 从核心 0 切换出来),进程 Z 在该核心上的所有状态(即其通用寄存器和程序计数器)会被保存。在稍后的时间点,红色核心决定给 Z 一个时间片,于是它执行另一个上下文切换到进程 Z,并将进程 Z 所需的状态从内存加载到核心 1 的寄存器中。

进程 Z 的状态被保存在所有核心共享的内存中。在这个上下文切换时,会访问该共享内存,将状态加载回核心的寄存器。存储进程 Z 状态的共享内存是临界区,需要用锁来保护。在保存状态的上下文切换期间,锁会被持有,直到完成后才释放。这可以防止红色核心过早地尝试启动进程 Z 的时间片。只有在红色核心能够获取锁之后,它才能开始加载进程 Z 的寄存器并执行上下文切换。

内核线程与调度器线程 🧵

有时,上下文切换的图示会有所不同。时间水平向右流动。我们看到从用户模式到内核模式发生陷阱,然后在系统返回时,发生从内核模式回到用户模式的上下文切换。

如果是简单操作(例如处理设备中断或某些可以立即处理的系统调用),我们可能只有一次陷阱和一次返回,直接回到被中断的用户模式,这非常高效。

但在其他情况下(例如需要让出 CPU),过程会更复杂。用户模式执行时发生陷阱,进入内核。此时,从某种意义上说,它仍是同一个进程的内核线程。调度器线程是另一个独立的线程(图中标为棕色)。如果我们决定要调度另一个线程,就会进行第二次上下文切换:从当前进程的内核线程切换到调度器线程。调度器选择另一个进程后,再切换到那个进程的内核线程。

每个进程都有一个用户部分(在用户模式执行)和一个内核部分(在内核模式执行),它们同属于一个线程。调度器线程则是不同的线程。这里涉及从进程(内核线程)到调度器线程的上下文切换,以及再次切换回来。

从用户模式切换到内核模式时,需要保存所有通用寄存器和程序计数器。而当我们在内核中切换到调度器线程时,由于已经在内核中,可以做一些假设,不需要保存全部寄存器,因此略有不同。

切换函数:swtch ⚙️

这个上下文切换(到调度器)和那个上下文切换(从调度器到新进程)都是由一个名为 swtch(即 switch)的汇编语言函数处理的。

  • 当调度器选择要运行哪个进程时,会在函数 scheduler 中调用 swtch 来执行上下文切换。
  • 当一个进程的内核线程想要让出 CPU 时,会在函数 sched 中调用 swtch 来切换到调度器。

swtch 函数的基本功能是保存一个线程的寄存器,并加载下一个线程的寄存器。

总结 🎯

本节课中我们一起学习了 xv6 内核的上下文切换机制。我们了解了:

  1. 时间片轮转的基本流程,涉及陷阱进入内核和 sret 指令返回用户空间。
  2. 从用户线程和调度器线程的不同视角看待执行流。
  3. 多核系统中,进程状态在共享内存中保存和恢复,并通过锁进行保护。
  4. 进程包含用户线程和内核线程,与独立的调度器线程之间的切换。
  5. 上下文切换的核心是由汇编函数 swtch 实现的,它负责保存和恢复寄存器状态。

上下文切换、陷阱和系统返回是构建多任务操作系统的基石。在后续课程中,我们将再次深入探讨更详细的“路线图”。

11:内存布局 🗺️

在本节课中,我们将学习 xv6 操作系统的内存布局。我们将探讨物理内存的组织方式、内核的虚拟地址空间、内核页表,并解释为何需要蹦床页和陷阱帧页。

概述

本视频是 Xv6 操作系统内核系列的一部分。我们将通过分析 memlayout.h 文件,了解内存的整体组织、内核的虚拟地址空间与页表,并深入理解蹦床页和陷阱帧页的必要性。

用户线程与陷阱处理

让我们从一个执行指令的用户线程开始。在某个时刻,会发生一个陷阱,我们开始在内核中执行指令。之后,内核代码会执行 sret(系统返回)指令,返回到用户代码。

用户代码在用户模式下执行,内核代码在监督者模式下执行。RISC-V 称之为监督者模式,有时我也使用内核模式这个术语,两者含义相同。

陷阱与返回的细节

现在,让我们更仔细地看看陷阱发生和 sret 指令执行时发生了什么。

  • 当我们在用户模式下执行时,发生了一个陷阱。
  • 接着是上下文切换,我们开始执行内核代码,运行在 RISC-V 所谓的监督者模式或内核模式下。
  • 之后,执行 sret 指令,我们返回到用户虚拟地址空间中的用户模式下执行用户代码。

以下是陷阱指令在硬件中的处理流程,以及下方的 sret 指令:

  1. 陷阱处理:由硬件处理。核心将切换到内核模式(监督者模式),它会:

    • 禁用中断。
    • 将程序计数器保存到一个名为 sepc 的控制状态寄存器中。
    • 从另一个名为 stvec 的 CSR 加载程序计数器。该寄存器包含用户态陷阱处理例程第一条指令的地址,因此这实际上是一次跳转到该例程。
  2. 用户态陷阱例程:用汇编语言编写。它首先保存用户寄存器。通用寄存器和程序计数器对用户程序至关重要,必须立即保存,以便后续恢复。由于不使用通用寄存器几乎无法做任何事,这必须是首先要做的事情。

    • 然后,加载几个关键的内核寄存器:需要加载内核栈指针寄存器,以及包含核心编号的 tp 寄存器。
    • 最后,切换到内核的地址空间:加载另一个名为 satp 的控制状态寄存器,其中包含内核页表的地址,从而将所有执行切换到不同的虚拟地址空间。
    • 最终,跳转到用 C 语言编写的 usertrap 函数。
  3. 返回用户代码:当我们准备返回用户代码时,会调用 usertrapret 例程。这个 usertrapret 函数会调用一个名为 userret 的例程。

    • userret 用汇编语言编写,它会:
      • 恢复 satp 寄存器,将我们切换回用户的虚拟地址空间。
      • 在直接执行 sret 指令之前,恢复用户寄存器。
    • 最后,sret 指令将模式切换到用户模式,并从 sepc 寄存器中恢复程序计数器,最后启用中断。此后,我们在用户模式下执行。

需要注意的是,虽然我称它们为“函数”,但并不完全准确。函数被调用后会返回,而这些例程并非如此。uservec 以跳转结束。usertrap 被编码为 C 函数,但永远不会从中返回。它调用其他函数,最终调用 usertrapret,而 usertrapret 会调用 userretuserret 永远不会返回到 usertrapret。从这个意义上说,它并不是真正的函数,而只是一段代码块。同样,userret 也不会有正常的返回,相反,它执行的是 sret 指令。

地址空间挑战与解决方案

问题在于,保存用户寄存器和加载内核寄存器的代码,是在用户的地址空间中运行的。因此,这些指令及其引用的内存位置必须在用户的虚拟地址空间中。同样,在下面恢复 satp 之后,我们也在用户的地址空间中运行。因此,指令必须在该地址空间中,并且我们从中获取旧寄存器内容的内存位置也需要在用户的地址空间中。

用户虚拟地址空间

我们之前展示过这张图。用户的虚拟地址空间包含程序的代码和数据,以及一个栈页,可能还有一些堆页。在最顶部,我们有一个称为“蹦床页”的东西,还有一个“陷阱帧页”。这些页面存在于每个虚拟地址空间中,并且位于完全相同的位置,即最顶部的两个页面。

下面的内容因用户程序而异,例如栈页的确切位置可能不同。但这两个页面始终位于相同的位置。用户的页表会将这两个页面标记为在用户模式下不可访问。因此,如果用户代码尝试访问它们,将会出错,所以它们对用户代码是“隐形”的。蹦床页包含代码,被标记为可读和可执行;陷阱帧页包含数据,被标记为可读和可写。

多进程与内核视图

以下是另一张展示情况的图片。所有用户进程(最多 64 个)的虚拟地址空间,每个都有一个陷阱帧页和一个蹦床页(蓝色为蹦床,红色为陷阱帧)。内核只有一个虚拟地址空间,所有内核代码都使用相同的地址空间,所有核心共享这一个虚拟地址空间。它包含许多内容,但特别要指出的是,它在地址空间的最顶部也包含蹦床页。

在进一步讨论之前,让我们看看蹦床页和陷阱帧页。

  • 蹦床页:只有一个蹦床页。它包含代码,特别是包含 uservecuserret 这两个汇编语言例程,不包含其他内容。它被映射到所有虚拟地址空间(64 个用户进程各一个,加上内核地址空间)的完全相同地址,即最顶页。如前所述,它被标记为可读和可执行,但在用户模式下运行时无法访问。
  • 陷阱帧页:每个进程都有自己的陷阱帧页,因此每个页面都不同。最多有 64 个活跃进程,每个虚拟地址空间都有自己的陷阱帧页。它将包含数据,特别是保存用户寄存器的区域。标记为可读、可写,在用户模式下不可访问。

在下图中,我展示了所有 64 个用户进程虚拟地址空间的顶部。最顶页是蹦床页,次顶页是陷阱帧页。这里的思路是,页表将所有蹦床页映射到同一个物理地址。而蓝色的陷阱帧页则各自映射到不同的物理页,因此它们不共享。共享代码是可以的,因为代码不变且可以无问题地共享,但每个用户进程都需要自己的区域来保存寄存器。

物理内存与内核虚拟地址空间

现在让我们看看这张图。右侧是物理内存,左侧是内核的虚拟地址空间。

物理内存布局

让我们从物理内存开始。它从 0 开始,一直延伸到巨大的 256 EB。其中绝大部分是未使用和未分配的。Xv6 旨在运行的计算机将拥有 128 MB 的物理主内存,位于这个特定地址区域(恰好是 2 GB 边界)。在此之下,是为内存映射设备预留的空间。我们看到:

  • 串行通信设备占用一个页。
  • 磁盘设备占用一个页。
  • 平台级中断控制器占用 4 MB。
  • 还有核心本地中断控制器。
    最下面是引导 ROM。在内核执行后,引导过程结束,因此内核完全不会访问它。

内核虚拟地址空间

在左侧,我们看到内核的虚拟地址空间。首先要注意的是,所有物理内存都被直接映射到虚拟地址空间中。这意味着内核可以提供一个地址,并且不需要真正区分它是虚拟地址还是物理内存地址。即使启用了虚拟寻址,相同的数字也可以用于物理位置,并且会指向同一个地方。同样,下面的所有设备也被直接映射。因此,要访问虚拟磁盘、串行通信设备和平台级中断控制器,内核可以直接使用物理地址,并且由于它们是直接映射的,不会有问题。

核心本地中断控制器仅在机器模式下访问。请记住,在机器模式下,没有虚拟寻址,页表不活动,我们只使用物理地址。核心本地中断控制器仅在机器模式下访问,因此实际上不需要将其映射到虚拟地址空间中。

虚拟地址空间顶部

现在,我们想讨论的是虚拟地址空间顶部(256 GB - 1 页)的情况。我们有蹦床页。然后,有一些称为内核栈页的页面,每个用户进程一个,共有 64 个。所有这些页面(蹦床页和栈页)都由一个保护页隔开。该保护页未被映射,不可读、不可写、不可执行,因此任何访问尝试都会导致错误。这只是为了捕获来自内核栈区域的任何栈溢出。

在物理主内存的内核部分,我们将看到内核代码、只读数据,然后是内核的读写数据(即内核中使用的变量)。在此之上,是物理内存的其余部分,将用于页分配器。我们有两个函数 kallockfree。这个区域最初被划分为页,所有这些页都保存在一个空闲列表中。每次我们调用 kalloc 函数,它都会从该空闲列表中分配一页空闲内存。因此,这些页中的一页将被分配给某个用途。当我们用完该页后,可以调用 kfree 例程将其返回到这里的空闲区域,放回空闲列表,供下次需要页时使用。因此,大部分物理内存将被 kallockfree 使用的这个页区域占用。

内存映射全景

现在,让我们看看这张图,它试图以略有不同的方式展示情况。同样,物理内存在某个地方,虚拟地址空间在另一边。我们有 64 个用户进程,这里只展示了其中三个,但每个用户进程都有一个虚拟地址空间。内核只有一个虚拟地址空间,因此所有核心共享这一个内核虚拟地址空间和这一个内核页表。

我们在所有虚拟地址空间的顶部(蓝色)和每个用户模式虚拟地址空间的顶部(红色)看到蹦床页和陷阱帧页。这些页面被映射到物理内存中的某个地方。

  • 蹦床页:被映射到代码中。因此,它实际上是内核文本区域的一部分。内核代码位于物理内存的这个区域,所以蹦床页将被映射到文本区域的某个地方。
  • 陷阱帧页:在内核启动时分配,它们将被分配在空闲区域的某个地方,即 kallockfree 使用的页面所在的区域。因此,我将它们显示为映射到该区域的某个地方。当然,所有物理内存都是直接映射的,因此内核如果需要可以直接访问它们,但每个用户进程的页表都会将这些陷阱帧页映射到通过调用 kalloc 获得的页面之一。
  • 内核栈页:对于 64 个进程中的每一个,我们都需要一个内核栈。这是因为当我们首次进入内核模式时,陷阱发生并开始执行。我们保存用户寄存器并加载内核寄存器。每个进程都需要一个单独的栈。显然,两个独立的线程不能共享同一个栈,它们每个都需要独立的栈。因此,与 64 个用户进程相关联的 64 个线程中的每一个,都将拥有自己的栈。当用户模式代码切换到内核模式执行时,它将需要访问其内核栈。因此,我们在这里为 64 个用户进程中的每一个准备了一个栈页。在启动时,通过调用 kalloc 从空闲池中分配了 64 个页,每次调用 kalloc 时,该页被映射到上方这些区域之一。图中还显示了分隔这些页面的保护页。

代码分析:memlayout.h

现在,我们准备实际查看 memlayout.h 的代码。这里只有两页,我从第一页开始。有一些注释,我们从串行设备的地址开始。就是这个地址,你在这里看到它,它只是用一个 #define 常量定义。然后我们有磁盘设备的页,显示在这里。这是我们定义的地址。

这是核心本地中断器,它被映射到某个特定地址,显示在这里。它在内核模式下执行时不使用,但在机器模式下使用,所以我们在这里给它一个地址。这将用于定时器和定时器中断。定时器中断由在机器模式下运行的代码处理,产生这些中断的设备在这里访问。

这些内存映射设备具有所谓的“寄存器”(有时是硬件寄存器,不要与核心中的通用寄存器混淆)。这就是这里的情况。有一个名为 mtime 的寄存器。它位于这个起始位置加上某个常量处。该硬件寄存器包含自启动以来的周期数。因此,内核代码可以从该地址读取,有效地访问此设备并获取当前值。该设备不断更新存储在此内存位置的值,当我们需要当前时间时可以直接读取。

这里的 mtimecmp 是一个函数。回想一下,对于 C 预处理器,这里是否有空格很重要。如果有空格,那么这只是被替换的内容;如果没有空格,那么这是一个参数。这里定义了一个函数。因此,给定一个特定的核心编号,我们将有一个表达式来计算其中一个寄存器的地址。有八个核心,因此有八个硬件寄存器。它们位于哪里?起始地址加上某个值,每个寄存器是 8 字节。因此,这里的表达式计算这些硬件寄存器之一的地址。该寄存器将由内核加载,它将告诉何时产生下一个中断。因此,内核写入此位置,当时间到达时,该设备将自动生成一个中断。

对于平台级中断控制器,我们有类似的情况。我们有一个起始地址,然后有一些不同的函数。你可以看到我们有一些不同的硬件寄存器,它们被定义为核心编号的函数。hartid 只是核心编号 0 到 7。这些表达式计算该设备内的各种地址。

第二页内容

转到 memlayout.h 的第二页。我们定义了内核加载的位置,如前所述,这个数字是 2 GB,即内核基址。我们还有物理主内存的顶部,起始位置之后 128 MB 的主内存是物理内存顶部 PHYSTOP,给出了这里的地址。

常量 TRAMPOLINE 给出了蹦床页的地址,即最大虚拟地址 256 GB 减去一页。所以就是这个点。最后,我们有栈页的地址,这是一个函数。给定一个进程编号(0 到 63),我们在这里计算一个地址。你可以计算这里的代数,但基本上我们以两页为单位进行计算,因为存在保护页。所以每两页我们有一个栈页的地址。最后,我们有一个常量 TRAPFRAME,它只是陷阱帧页的地址。所以它就是蹦床页地址减去 4096,也就是这里的这个值。这就是这个定义的含义。

总结

本节课中,我们一起学习了 xv6 操作系统的内存布局。我们探讨了物理内存的组织、内核虚拟地址空间的直接映射特性、以及蹦床页和陷阱帧页在用户态与内核态切换中的关键作用。通过分析 memlayout.h 文件,我们了解了各种内存区域和设备寄存器的地址定义,为后续深入理解内核机制奠定了基础。

12:链接内核 🧩

在本节课中,我们将要学习如何将多个目标文件链接成一个可执行的内核映像文件。我们将重点分析链接器脚本文件 kernel.ld,了解它如何指导链接器将代码和数据放置在内存的特定位置。


内存布局概览

上一节我们介绍了内核代码和数据的编译过程。本节中,我们来看看链接器如何决定它们在内存中的最终位置。

链接器并不实际将内容加载到内存,而是计算出所有内容在内存中的地址。对于大多数用户级C程序,链接器的默认设置就足够了。但在构建操作系统内核时,我们需要更精确地控制内存布局,这就需要使用链接器脚本。

链接器会读取所有目标文件(.o文件)中的各个段(section),并将它们合并,生成一个包含所有待加载数据的可执行文件。随后,模拟器(如QEMU)会读取这个可执行文件,并按照链接器指定的地址将内容加载到内存中。

理解目标文件中的段

编译或汇编源代码会生成目标文件。每个目标文件包含多个段,每个段包含一组需要在内存中连续存放的数据。编译器或汇编器并不知道这些段最终会被放在内存的哪个位置。

以下是几种常见的段类型:

  • .text:包含可执行的机器代码。
  • .data:包含已初始化的全局变量和静态变量,程序可以读写它们。
  • .rodata:包含只读数据,在运行时不会被修改。
  • .bss:包含未初始化的全局变量和静态变量。这个段在目标文件中不占用实际空间,但在加载到内存时,其内容会被初始化为零。

链接过程详解

链接器需要决定如何将这些来自不同文件的段放置在内存中。在XV6中,内核代码和数据被放置在2GB(0x80000000)的内存边界开始处。

链接器按照命令行中目标文件的顺序,将同类型的段合并在一起:

  1. 首先放置所有文件的 .text 段。
  2. 然后放置一个特殊的 trampsec(蹦床)段。
  3. 接着放置所有文件的 .rodata 段。
  4. 再放置所有文件的 .data 段。
  5. 最后放置所有文件的 .bss 段。

链接器的一个关键作用是解析符号地址。例如,当代码中引用一个变量的地址时,这个地址在编译和汇编时是未知的。只有在链接时,链接器知道了所有段和符号的最终位置,才能回填这些地址值。

此外,内核开始运行后,会建立页表来管理内存,并将 .text 段标记为可执行,而将其他数据段标记为可读写

链接器还会定义几个重要的全局符号,供内核代码使用:

  • etext:指向代码段(.text)的结束位置,也就是数据段的开始。
  • end:指向整个内核数据(包括 .bss)的结束位置。
  • _trampoline:指向蹦床代码段在内存中的起始地址。

分析链接器脚本 kernel.ld

现在,让我们深入看看指导链接器工作的脚本文件 kernel.ld。这个文件使用一种链接器能理解的特定语言编写。

以下是脚本的核心内容解析:

/* 指定输出文件架构为RISC-V,入口点为 `_entry` 符号 */
OUTPUT_ARCH("riscv")
ENTRY(_entry)

/* 定义输出文件的各个段 */
SECTIONS
{
    /* 内核从0x80000000地址开始加载 */
    . = 0x80000000;

    /* 1. 创建 .text 段 */
    .text : {
        /* 收集所有输入文件的 .text 段和名为 .text.* 的段 */
        *(.text .text.*)
        /* 对齐到下一个页边界(4KB) */
        . = ALIGN(0x1000);
        /* 定义符号 _trampoline,其值为当前地址 */
        _trampoline = .;
        /* 放入特殊的蹦床代码段 */
        *(.trampsec)
        /* 再次对齐到页边界 */
        . = ALIGN(0x1000);
        /* 断言:蹦床代码大小不能超过一页 */
        ASSERT(. - _trampoline == 0x1000, "trampoline larger than one page");
        /* 定义符号 etext,标记代码段结束 */
        etext = .;
    }

    /* 2. 创建只读数据段 (.rodata) */
    .rodata : {
        /* 按16字节对齐 */
        . = ALIGN(16);
        /* 收集所有只读数据段 */
        *(.rodata .rodata.*)
    }

    /* 3. 创建可读写数据段 (.data) */
    .data : {
        . = ALIGN(16);
        *(.data .data.*)
    }

    /* 4. 创建 .bss 段 */
    .bss : {
        *(.bss .bss.*)
        /* 定义符号 end,标记所有内核数据的结束 */
        end = .;
    }
}

脚本关键点说明:

  • *(.text .text.*):通配符 * 表示从所有输入文件中收集匹配的段。这确保了 entry.o(内核入口)的代码被放在最前面。
  • ALIGN(0x1000):将当前位置计数器对齐到4KB的页边界。这对于内存分页管理至关重要。
  • _trampoline = .;:这是一个赋值语句,它创建一个符号 _trampoline,并将其值设置为当前地址(.)。
  • ASSERT:这是一个断言检查,确保蹦床代码的大小恰好为一页,否则链接过程会报错。
  • etext = .;end = .;:同样是在定义符号,分别标记代码段尾和整个数据区尾。

本节课中我们一起学习了XV6内核的链接过程。我们了解了目标文件中的不同段(.text, .data, .rodata, .bss),并详细分析了链接器脚本 kernel.ld 如何指挥链接器将这些段有序地放置在内存的特定地址,同时解析符号地址并定义关键的内存边界符号。理解链接过程对于掌握操作系统内核的启动和内存布局至关重要。

13:内核启动流程与时钟中断 🚀

在本节课中,我们将学习 xv6 操作系统的内核启动流程,并了解时钟中断是如何初始化和处理的。我们将重点分析 entry.Sstart.c 这两个文件中的代码。这些代码主要在机器模式下执行,并在最后切换到监管者模式,跳转到 main 函数。

启动代码概览

上一节我们介绍了课程目标,本节中我们来看看启动过程涉及的两个核心文件。

  • entry.S:包含一小段汇编代码,用于初始化栈指针并跳转到 start 函数。
  • start.c:包含 starttimerinit 两个函数,负责设置机器模式下的各种寄存器,最终通过 mret 指令进入监管者模式的 main 函数。

栈空间分配

在多核系统中,每个核心都需要自己独立的栈空间。以下是 start.c 中为每个核心分配栈空间的代码。

__attribute__ ((aligned (16))) char stack0[4096 * NCPU];
  • NCPU 是一个常量,设置为 8,表示系统最多支持 8 个核心。
  • 为每个核心分配一个 4096 字节(4KB)的页面作为栈。
  • __attribute__ ((aligned (16))) 确保这个数组在 16 字节边界上对齐。

汇编入口点:entry.S

现在,让我们进入汇编代码部分,看看系统是如何开始执行的。

.section .text
.globl _entry
_entry:
    la sp, stack0
    li a0, 1024*4
    csrr a1, mhartid
    addi a1, a1, 1
    mul a0, a0, a1
    add sp, sp, a0
    call start
spin:
    j spin
  • .section .text 指示将后续代码放入可执行的文本段。
  • .globl _entry_entry 标签声明为全局符号,以便链接器识别。
  • la sp, stack0stack0 数组的地址加载到栈指针寄存器 sp
  • 接下来的指令根据当前核心的 ID (mhartid) 计算该核心栈的顶部地址,并设置 sp
  • call start 跳转到 start.c 中的 start 函数。
  • 如果 start 函数意外返回,代码将进入 spin 处的无限循环。

机器模式初始化:start 函数

entry.S 设置好栈之后,控制权交给了 start 函数。这个函数在机器模式下执行,为进入监管者模式做准备。

void start() {
    // 设置 mstatus 寄存器,准备在 mret 后进入监管者模式
    unsigned long x = r_mstatus();
    x &= ~MSTATUS_MPP_MASK;
    x |= MSTATUS_MPP_S;
    w_mstatus(x);

    // 设置 mret 后要跳转的地址为 main 函数
    w_mepc((uint64)main);

    // 禁用分页(SATP 寄存器置零)
    w_satp(0);

    // 将中断和异常委托给监管者模式处理
    w_medeleg(0xffff);
    w_mideleg(0xffff);

    // 在监管者模式下启用外部设备、软件和时钟中断(虽然时钟中断委托无效)
    w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE);

    // 配置物理内存保护(PMP),允许监管者模式访问所有物理内存
    w_pmpaddr0(0x3fffffffffffffull);
    w_pmpcfg0(0xf);

    // 初始化时钟中断
    timerinit();

    // 将核心 ID 写入 tp 寄存器,供 mycpu() 等函数使用
    w_tp(r_mhartid());

    // 执行 mret,切换到监管者模式并跳转到 main 函数
    asm volatile("mret");
}

以下是 start 函数的关键步骤说明:

  1. 设置机器状态 (mstatus):清除之前的特权模式位,并设置为从监管者模式“返回”。
  2. 设置异常程序计数器 (mepc):指向 main 函数,这样 mret 指令就会跳转到那里。
  3. 禁用转换 (satp):确保在进入 main 时未启用分页。
  4. 委托中断和异常:通过 medelegmideleg 寄存器,将大多数中断和异常的处理委托给监管者模式。注意:时钟中断 (MTI) 无法被委托
  5. 启用监管者模式中断:在 sie 寄存器中启用相应位,允许监管者模式接收中断。
  6. 配置物理内存保护 (PMP):简化设置,使监管者模式能访问所有物理内存。
  7. 初始化时钟:调用 timerinit() 函数。
  8. 保存核心 ID:将核心 ID 写入 tp 寄存器,这是一个经常用于存储核心特定信息的寄存器。
  9. 切换模式:执行 mret 指令。这会根据之前设置的 mstatusmepc,将特权级切换到监管者模式,并开始执行 main 函数。

时钟中断初始化:timerinit 函数

时钟中断需要特殊处理,因为它必须在机器模式下处理。timerinit 函数负责其初始化。

首先,我们需要了解一个核心数据结构 timer_scratch,它为每个核心存储了处理时钟中断所需的信息。

// 每个核心有 5 个 64 位字的存储区域
uint64 timer_scratch[NCPU][5];

这个数组的每个元素(对应一个核心)布局如下:

  • 偏移 0-15:用于临时保存寄存器 a1-a3
  • 偏移 24:存储该核心的 mtimecmp 寄存器地址。
  • 偏移 32:存储时钟中断间隔(1000000 个周期)。

以下是 timerinit 函数的代码:

void timerinit() {
    int id = r_mhartid(); // 获取当前核心 ID
    // 设置第一次时钟中断:当前时间 + 1000000 周期
    *(uint64*)CLINT_MTIMECMP(id) = *(uint64*)CLINT_MTIME + interval;

    // 获取当前核心的 timer_scratch 区域指针
    uint64 *scratch = &timer_scratch[id][0];
    // 保存 mtimecmp 寄存器地址和间隔值
    scratch[3] = CLINT_MTIMECMP(id);
    scratch[4] = interval;
    // 将区域地址写入核心的 mscratch 寄存器
    w_mscratch((uint64)scratch);

    // 设置机器模式陷阱向量地址为 timervec
    w_mtvec((uint64)timervec);

    // 启用机器模式中断
    w_mstatus(r_mstatus() | MSTATUS_MIE);
    // 特别启用机器模式下的时钟中断 (MTIE)
    w_mie(r_mie() | MIE_MTIE);
}

函数步骤如下:

  1. 设置首次中断:向当前核心的 mtimecmp 寄存器写入一个未来的时间点(当前时间 + 1000000 周期)。
  2. 准备暂存区:将 mtimecmp 的地址和中断间隔存储到该核心的 timer_scratch 区域中。
  3. 设置 mscratch:将该区域的地址存入 mscratch 寄存器,以便中断处理程序快速找到它。
  4. 设置陷阱向量:将机器模式陷阱向量地址 (mtvec) 设置为 timervec。当时钟中断发生时,硬件会自动跳转到此处。
  5. 启用中断:在机器状态 (mstatus) 中全局启用中断,并在机器中断启用 (mie) 寄存器中特别启用时钟中断。

时钟中断处理程序:timervec

当时钟中断发生时,CPU 在机器模式下跳转到 timervec(位于 kernelvec.S 中)。这个处理程序的主要职责是安排下一次中断,并触发一个监管者模式的软件中断,让内核进行真正的调度处理。

.globl timervec
.align 4
timervec:
    csrrw a0, mscratch, a0
    sd a1, 0(a0)
    sd a2, 8(a0)
    sd a3, 16(a0)

    ld a1, 24(a0)
    ld a2, 32(a0)
    ld a3, 0(a1)
    add a3, a3, a2
    sd a3, 0(a1)

    li a1, 2
    csrw sip, a1

    ld a3, 16(a0)
    ld a2, 8(a0)
    ld a1, 0(a0)
    csrrw a0, mscratch, a0
    mret

处理流程如下:

  1. 保存上下文:利用 mscratch 指向的暂存区,快速保存 a0-a3 寄存器。
  2. 安排下次中断:从暂存区加载 mtimecmp 地址和间隔值,计算新的中断时间并写回寄存器。
  3. 触发软件中断:向监管者中断待处理寄存器 sip 写入值 2(对应监管者软件中断位 SSIP)。这会在监管者模式中产生一个待处理的中断。
  4. 恢复上下文并返回:恢复所有保存的寄存器,然后执行 mret 返回被中断的代码。
  5. 内核处理:当监管者模式代码(内核)下次启用中断时,会处理这个软件中断。内核的中断处理程序会将其解释为一次时钟中断,并执行进程调度等操作。

总结

本节课中我们一起学习了 xv6 内核的启动流程和时钟中断机制。

  1. 系统从汇编入口 _entry 开始,为每个核心设置栈指针。
  2. start 函数在机器模式下初始化硬件状态,将大多数中断委托给监管者模式,并最终通过 mret 指令跳转到监管者模式的 main 函数。
  3. 由于时钟中断无法委托,timerinit 函数在机器模式下对其进行特殊设置:初始化第一次中断时间,并准备好中断处理程序 timervec 所需的数据。
  4. 当时钟中断发生时,机器模式下的 timervec 处理程序负责重置下一次中断时间,并通过触发一个监管者软件中断的方式,将控制权交还给内核进行真正的调度处理。

这个设计巧妙地将必须在机器模式下处理的硬件定时器中断,与内核在监管者模式下进行的调度逻辑分离开来。

14:陷阱处理 🖥️

在本节课中,我们将学习 xv6 内核如何处理从用户模式到内核模式的陷阱(trap)。我们将详细探讨陷阱处理的全过程,包括硬件行为、关键数据结构以及内核代码的执行路径。通过本节课,你将理解一次系统调用或中断是如何被捕获、处理并最终返回用户空间的。

概述

当用户程序执行时,可能会因为系统调用、设备中断或程序错误而触发一个陷阱。硬件会接管控制权,保存关键状态,并跳转到预设的内核处理代码。内核随后会判断陷阱原因,执行相应的处理程序(如设备中断处理或系统调用处理),最后恢复用户程序的状态并返回。整个过程涉及用户态与内核态的切换、寄存器的保存与恢复,以及多个关键数据结构的协作。

上一节我们介绍了进程和内存管理的基本概念,本节中我们来看看当发生陷阱时,操作系统内核具体是如何响应的。

陷阱处理路线图

下图展示了从发生陷阱到执行 sret 指令返回用户模式的完整流程:

让我们沿着这条路线图,逐步分析每个阶段发生了什么。

1. 陷阱的起因与硬件响应

陷阱可能由两种原因引起:

  • 异步中断:例如定时器中断或I/O设备中断。
  • 同步异常:例如用户程序执行 ecall 指令发起系统调用,或程序本身出错(如除零错误)。

当陷阱发生时,硬件会自动执行以下操作:

  1. 禁用中断
  2. 切换到监管者模式(Supervisor Mode)
  3. 将当前程序计数器(PC) 保存到 sepc 寄存器。
  4. 将陷阱的原因保存到 scause 寄存器。
  5. stvec 寄存器中保存的地址加载到 PC 中,从而跳转到陷阱处理程序。stvec 是一个控制状态寄存器,它存储了所有类型陷阱的处理代码入口地址。

2. 跳转到蹦床页面

硬件将PC设置为 stvec 中的地址,这个地址指向蹦床页面(trampoline page) 中的汇编代码。蹦床页面是一个特殊的页面,它被映射到所有地址空间(包括用户地址空间和内核地址空间)的相同虚拟地址上。

蹦床页面的代码负责初步保存用户态的状态。具体来说,它将所有通用寄存器和程序计数器保存到陷阱帧(trapframe) 中。每个进程都有自己独立的陷阱帧,它们被映射到用户地址空间的倒数第二个页面。

3. 准备执行内核代码

在保存用户状态后,需要为执行内核代码做准备:

  • 每个线程都需要自己的内核栈,因此需要初始化栈指针寄存器(sp)。
  • 初始化 tp 寄存器,其中包含当前核心的编号。
  • satp 寄存器(页表寄存器)设置为指向内核页表,从而将虚拟地址空间切换到内核空间。
  • 完成上述设置后,跳转到名为 usertrap 的 C 函数。

4. usertrap:C语言陷阱处理

usertrap 函数是陷阱处理的核心。它首先执行一个 switch 语句,根据 scause 寄存器的值判断陷阱的具体原因。

但在判断原因之前,它先做了一件事:更新 stvec 寄存器。之前 stvec 指向用户态的陷阱处理入口(uservec),但如果陷阱发生在内核模式,我们需要不同的处理方式。因此,这里将 stvec 更新为内核陷阱处理程序的地址。

以下是 usertrap 根据不同原因的分支处理逻辑:

  • 程序异常:如果是程序错误(如非法指令),则打印错误信息并调用 exit 函数终止该进程。
  • 设备中断:调用 devintr 函数处理设备中断。
    • 处理完成后,检查进程的 killed 标志。如果该标志被设置(表示其他部分要求此进程终止),则调用 exit
  • 定时器中断:表示当前进程的时间片用完。
    • 检查 killed 标志,若被设置则调用 exit
    • 否则,调用 yield 函数。yield 会触发调度器,让其他就绪进程运行。当前进程会暂时让出CPU,等待下一次被调度。
  • 系统调用:这是用户程序主动发起的陷阱。
    • 首先启用中断,允许在处理系统调用时被更高优先级的设备中断打断。
    • 然后处理该系统调用。
    • 处理完成后,同样检查 killed 标志,若被设置则调用 exit

无论以上哪种情况处理完毕,最后都会调用 usertrapret 函数,开始返回用户模式的流程。

5. usertrapret:返回用户模式的准备

usertrapret 函数负责为执行 sret 指令返回用户模式做准备:

  1. 禁用中断(如果之前被启用的话)。
  2. stvec 重新设置为指向 uservec,为下一次用户态陷阱做好准备。
  3. 将当前的内核栈指针和核心编号保存到陷阱帧中。这是因为当再次发生陷阱时,蹦床代码需要这些信息来重新初始化内核线程状态。
  4. 从陷阱帧中恢复之前保存的用户程序计数器(sepc)。
  5. 最后,跳转到位于蹦床页面中的 userret 汇编代码。

6. userret 与 sret:最终返回

userret 汇编代码完成最后的收尾工作:

  1. 此时我们仍在蹦床页面中,该页面在所有地址空间中都有映射。因此,我们可以安全地将 satp 寄存器从内核页表切换到当前用户的页表
  2. 恢复所有之前保存在陷阱帧中的用户寄存器
  3. 在状态寄存器(sstatus)中设置两个关键位:
    • 告诉 sret 指令,返回后的特权级为用户模式
    • 将“先前中断启用标志”设置为启用,这样当 sret 执行后,中断在用户模式将重新被启用。
  4. 执行 sret 指令。硬件会根据 sstatussepc 寄存器,恢复用户模式的执行:重新启用中断、切换到用户特权级,并从之前保存的PC地址继续执行用户程序。

关键数据结构

理解了处理流程后,我们来看看支撑这一流程的几个核心数据结构。

陷阱帧(trapframe)

当在用户模式执行时,控制状态寄存器 sscratch 会指向当前进程的陷阱帧。陷阱帧是一个物理页,用于保存和恢复用户态上下文。

以下是陷阱帧包含的主要字段(以偏移量形式定义):

// 内核页表指针 (satp)
// 内核栈指针 (sp)
// 保存的用户程序计数器 (epc)
// 核心编号 (hartid)
// 31个通用寄存器 (x1 到 x31)
  • sscratch 寄存器指向陷阱帧,便于快速保存寄存器。
  • 陷阱帧保存了用户模式线程的全部状态:31个通用寄存器(x0恒为0,无需保存)和程序计数器(PC)。
  • 同时,它也预先存储了进入内核所需的信息:内核页表指针、内核栈指针、usertrap 函数地址以及核心编号。

每CPU结构体(struct cpu)

系统支持多核,每个CPU核心都有一个对应的 struct cpu 数据,存储在全局数组 cpus[8] 中。

该结构体包含以下关键字段:

struct cpu {
    struct proc *proc;          // 当前在该核心上运行的进程,为空则表示运行调度器线程
    struct context context;     // 调度器线程的上下文保存区
    int noff;                   // push_off 的嵌套深度
    int intena;                 // 在最外层 push_off 之前,中断是否启用
};
  • proc:指向当前正在该CPU上运行的进程的 proc 结构体。如果CPU正在运行调度器线程(而非某个进程),此值为空。
  • context:当从进程的内核线程切换到调度器线程时,用于保存调度器线程的寄存器上下文。
  • noffintena:用于管理中断禁用/启用的嵌套层数。push_offpop_off 操作会修改它们,确保中断状态能被正确保存和恢复。

进程结构体(struct proc)

每个进程都由一个 struct proc 来描述,系统最多有64个这样的结构体。

以下是该结构体的核心字段:

enum procstate { UNUSED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };

struct proc {
    struct spinlock lock;
    enum procstate state;        // 进程状态
    void *chan;                  // 若在睡眠,则指向等待的“通道”
    int killed;                  // 是否已被标记为终止
    int xstate;                  // 退出状态码
    int pid;                     // 进程ID

    struct proc *parent;         // 父进程
    uint64 sz;                   // 用户虚拟内存大小
    pagetable_t pagetable;       // 用户页表
    struct trapframe *trapframe; // 指向陷阱帧物理页的指针
    struct context context;      // 进程内核线程的上下文保存区

    int ofile[NOFILE];           // 打开的文件描述符数组
    struct inode *cwd;           // 当前工作目录
    char name[16];               // 进程名称
};

需要持有锁 (lock) 才能访问的字段:

  • state:进程状态,包括:
    • UNUSED:结构体空闲。
    • RUNNABLE:就绪,等待被调度。
    • RUNNING:正在某个CPU上运行。
    • SLEEPING:睡眠(等待某事件,如I/O)。
    • ZOMBIE:已终止,但其退出状态尚未被父进程读取,资源未完全释放。
  • chan:当进程状态为 SLEEPING 时,此字段标识它正在等待什么(例如一个锁的地址)。
  • killed:布尔标志,指示是否有其他部分(如kill系统调用)要求此进程终止。
  • xstate:进程调用 exit 时设置的退出状态码,供父进程的 wait 读取。
  • pid:进程的唯一ID。
  • parent:指向父进程 proc 结构体的指针。

无需持有锁即可访问的“私有”字段(通常仅由进程自身修改):

  • pagetable:指向用户地址空间页表的指针。
  • trapframe:指向该进程独有的陷阱帧物理页的指针。
  • context:当在进程的内核线程和调度器线程之间切换时,用于保存进程内核线程的寄存器上下文。它与 struct cpu 中的 context 配对使用。
  • sz:用户地址空间的大小。
  • 其他资源信息:如打开文件表 ofile、当前目录 cwd 和进程名 name

上下文结构体(struct context)

struct context 用于保存线程(可能是进程的内核线程,也可能是调度器线程)的寄存器上下文,以便在切换时能恢复执行。它只保存被调用者保存的寄存器(callee-saved registers)。

struct context {
    uint64 ra; // 返回地址寄存器
    uint64 sp; // 栈指针寄存器
    // 被调用者保存的寄存器 (s0-s11)
    uint64 s0;
    uint64 s1;
    // ... s2 到 s11
};
  • ra:返回地址(相当于PC),指示切换回来后应从何处继续执行。
  • sp:栈指针。
  • s0-s11:RISC-V 中需要由被调用者保存的12个寄存器。调用者保存的寄存器(caller-saved)无需在此保存,因为它们可以通过函数调用约定来保护。

总结

本节课我们一起深入学习了 xv6 内核的陷阱处理机制。我们沿着“陷阱发生 -> 硬件响应 -> 蹦床页面保存上下文 -> 内核C函数 (usertrap) 分发处理 -> 准备返回 (usertrapret) -> 恢复上下文并返回 (userret/sret)”这条主线,剖析了每个步骤的职责。

我们还详细介绍了支撑这一流程的三个关键数据结构:

  1. 陷阱帧 (trapframe):作为用户态与内核态之间上下文切换的“中转站”。
  2. 每CPU结构体 (struct cpu):维护与每个处理器核心相关的状态,特别是当前运行的进程和调度器上下文。
  3. 进程结构体 (struct proc):描述一个进程的所有信息,包括状态、资源、内存和上下文。

理解这些流程和数据结构,是理解操作系统如何管理进程、处理中断和系统调用的基础。在下一节课中,我们将通过代码走读,具体分析 uservecusertrapusertrapretuserret 这些函数的实现细节。

15:蹦床与陷阱帧 🚀

概述

在本节课中,我们将学习 XV6 操作系统内核如何处理从用户模式到内核模式的切换,以及如何安全地返回。这个过程的核心是“蹦床”(Trampoline)页面和“陷阱帧”(Trapframe)数据结构。我们将详细分析 trampoline.S 文件中的汇编代码(uservecuserret)以及 trap.c 文件中的 C 函数(usertrapusertrapret),理解它们如何协同工作以完成一次完整的陷阱处理。

路线图与核心流程

首先,我们来看一下从用户代码陷入到返回的完整流程,这就像一个路线图。

  1. 用户代码执行:用户程序正在运行。
  2. 发生陷阱:发生系统调用、中断或异常。
  3. 执行 uservec 汇编代码:硬件自动跳转到此处,开始保存用户态上下文。
  4. 进入 usertrap C 函数:在核心态处理具体的陷阱类型。
  5. 调用 usertrapret C 函数:准备返回用户态的环境。
  6. 执行 userret 汇编代码:恢复用户态上下文,并最终执行 sret 指令,返回到用户程序。

当陷阱发生时,硬件会自动完成几件事:禁用中断、切换到监管者模式(Supervisor Mode)、将当前程序计数器(PC)保存到 sepc 寄存器,并假设 stvec 寄存器包含了陷阱处理程序的地址,然后跳转到该地址。在 XV6 中,这个地址就是 uservec 的起始处。

蹦床页面与 uservec

蹦床页面是一个特殊的物理页,它被映射到所有地址空间(用户和内核)的最高虚拟页。这使得无论当前是哪个页表生效,都能执行其中的代码。trampoline.S 文件主要包含两个例程:uservecuserret

uservec:保存上下文并进入内核

uservec 是陷阱发生后首先执行的代码。它的首要任务是在使用任何通用寄存器之前,保存完整的用户态执行上下文。

在用户代码执行时,控制状态寄存器 sscratch 保存了一个指向当前进程陷阱帧(Trapframe)的指针。陷阱帧是内核中用于保存用户寄存器状态的一个数据结构。

以下是 uservec 的关键步骤:

  1. 交换 a0sscratch

    csrrw a0, sscratch, a0
    

    执行后,a0 寄存器持有指向陷阱帧的指针,而 sscratch 则保存了用户原来的 a0 值。现在我们有了一个可用的指针(a0)来存储其他寄存器。

  2. 保存通用寄存器
    使用 a0 作为基址,将除了 a0 之外的所有通用寄存器保存到陷阱帧中对应的偏移位置。

  3. 恢复 a0
    sscratch 中将用户原来的 a0 值读到一个临时寄存器(如 t0),然后将其存入陷阱帧中为 a0 预留的位置。至此,所有用户寄存器都已保存。

  4. 加载内核栈和核心ID
    从陷阱帧中加载内核栈指针到 sp 寄存器。每个进程都有一个独立的内核栈页。同时,加载核心ID(hartid)到线程指针寄存器 tp,以便内核代码知道当前在哪个CPU核心上运行。

  5. 切换到内核地址空间
    从陷阱帧中加载内核页表的地址到 satp 寄存器,并执行 sfence.vma 指令同步。此后,CPU开始使用内核的虚拟地址空间。

  6. 跳转到C陷阱处理函数
    从陷阱帧中加载 usertrap 函数的地址,然后跳转到该地址执行。注意,这里使用 jr(跳转寄存器)而不是 jalr(跳转并链接),因为 usertrap 不会返回到这里。

陷阱帧结构

陷阱帧是连接用户态和内核态的关键数据结构。在 uservec 中,我们将用户状态保存于此;在 userret 中,我们又从这里恢复状态。其布局大致如下:

  • 用户寄存器保存区:用于保存 x0x31 所有通用寄存器的值。
  • 内核态准备区:包含几个内核执行所需的信息:
    • kernel_satp:内核页表的地址。
    • kernel_sp:该进程内核栈的栈顶指针。
    • kernel_trapusertrap 函数的地址。
    • kernel_hartid:当前CPU核心的ID。

usertrap:内核中的陷阱分发与处理

上一节我们看到了如何通过 uservec 进入内核。现在,我们来看看在内核中如何处理这个陷阱。usertrap 是一个用 C 编写的函数,它接收控制权并决定如何处理陷阱。

以下是 usertrap 函数的主要逻辑:

  1. 确认来源:检查 sstatus 寄存器中的先前特权模式位,确保陷阱确实来自用户模式。如果不是,则说明出现了严重错误。

  2. 重定向内核陷阱:将 stvec 设置为内核陷阱处理程序(kernelvec)的地址。这样,如果在内核执行期间发生中断,将由另一个处理程序接管。

  3. 保存用户程序计数器:将 sepc(即陷阱发生时用户的 PC)保存到陷阱帧中。这是 uservec 中未保存的最后一项用户状态。

  4. 根据原因处理陷阱:读取 scause 寄存器以确定陷阱原因。

    • 系统调用(scause == 8
      • 检查进程是否被标记为“已杀死”(killed),如果是,则退出。
      • 将保存的 sepc 加 4(因为 RISC-V 的 ecall 指令是 4 字节),这样返回时会执行下一条指令。
      • 重新启用中断,允许设备中断在处理系统调用时发生。
      • 调用 syscall() 函数处理具体的系统调用。
    • 设备中断
      • 调用 devintr() 函数判断是哪个设备。
      • 如果是时钟中断,则调用 yield() 函数,可能让出 CPU 给其他进程。
      • 如果是其他设备(如磁盘、控制台),则进行相应处理。
    • 其他原因(错误)
      • 打印错误信息(进程ID、出错的用户PC、附加信息 stval)。
      • 将进程标记为“已杀死”。
  5. 检查进程状态:在处理完陷阱后,再次检查进程的 killed 标志。如果被设置,则调用 exit() 终止进程。

  6. 准备返回:最后,调用 usertrapret() 函数来准备返回用户空间。

usertrapret:返回用户空间的准备

usertrapret 函数负责在真正执行返回用户空间的汇编代码之前,在内核态完成所有必要的设置。

以下是它的主要工作:

  1. 禁用中断:在操作控制状态寄存器时,必须确保不会被中断打断。

  2. 重置用户陷阱向量:将 stvec 重新设置为 uservec 的地址,这样下次用户态发生陷阱时,才能正确跳转。

  3. 设置下一次陷阱所需信息:更新当前进程的陷阱帧,为下一次陷入内核做好准备:

    • kernel_satp:设置为内核页表地址。
    • kernel_sp:设置为该进程内核栈的栈顶。
    • kernel_trap:设置为 usertrap 函数的地址。
    • kernel_hartid:设置为当前CPU核心的ID(从 tp 寄存器读取)。
  4. 配置返回状态

    • 设置 sstatus 寄存器:确保 SPP 位为 0(表示先前模式是用户模式),SPIE 位为 1(表示返回用户态后启用中断)。
    • 将陷阱帧中保存的用户程序计数器(PC)写回 sepc 寄存器。
  5. 计算并跳转到 userret

    • 计算出 userret 在蹦床页面中的实际地址(TRAMPOLINE + (userret - trampoline))。
    • 将这个地址作为一个函数指针调用,并传入两个参数:用户页表的地址和陷阱帧的地址。这实际上是一个“永不返回”的函数调用,它将控制权移交给了汇编代码 userret

userret:恢复上下文并返回用户态

现在,我们回到了汇编世界。userret 接收来自 usertrapret 的两个参数,并完成返回用户模式的最后一步。

以下是 userret 的关键步骤:

  1. 切换到用户页表:将传入的用户页表地址(在 a1 中)写入 satp 寄存器,并执行 sfence.vma。此时,CPU 切换到了用户的虚拟地址空间。但由于蹦床页面在所有地址空间都有映射,代码执行不会中断。

  2. 恢复用户寄存器

    • 首先,将陷阱帧中保存的用户 a0 值加载到 t0,然后存入 sscratch。此时 sscratch 保存了用户的 a0,而 a0 仍指向陷阱帧。
    • 然后,使用 a0 作为基址,从陷阱帧中恢复所有通用寄存器(除了即将要处理的 a0)。
  3. 最终交换:执行 csrrw a0, sscratch, a0。这条指令完成后:

    • a0 寄存器恢复了用户原本的值。
    • sscratch 寄存器则重新指向了当前进程的陷阱帧,为处理下一次陷阱做好了准备。
  4. 执行 sret 返回

    • sret 指令会根据 sstatus 的设置,将特权级切换回用户模式,并重新启用中断。
    • 同时,它将 sepc 寄存器的值加载到程序计数器(PC),从而跳转回用户代码发生陷阱时的位置(对于系统调用,是 ecall 的下一条指令)。

至此,一次完整的陷阱处理流程结束,用户程序从它被中断的地方继续执行。

总结

本节课我们一起深入学习了 XV6 操作系统的陷阱处理机制。我们追踪了从用户态陷入内核(uservec),到内核分发处理(usertrap),再到准备返回(usertrapret),最后恢复现场并返回用户态(userret)的完整路径。

核心要点

  • 蹦床页面:一段位于固定物理地址的汇编代码,因其在所有地址空间均有映射,成为用户态和内核态之间安全切换的“跳板”。
  • 陷阱帧:每个进程独有的数据结构,是保存和恢复用户执行上下文的中转站。
  • 状态保存与恢复:通过精心设计的汇编代码(uservec/userret)和 C 函数(usertrap/usertrapret)的配合,实现了用户态和内核态执行环境的隔离与透明切换。
  • 控制流:硬件中断触发跳转到 uservec -> 保存上下文并调用 usertrap -> 处理具体陷阱 -> usertrapret 准备返回环境 -> userret 恢复上下文并执行 sret 返回。

理解这一机制是理解操作系统如何管理系统调用、中断和异常,并实现进程隔离和保护的基础。

16:调度与上下文切换 🧵

在本节课中,我们将学习 xv6 内核中进程调度的核心机制,特别是 yieldschedscheduler 这三个关键函数,以及辅助函数 cpuidmycpumyproc 和汇编文件 swtch.S 中的上下文切换逻辑。我们将从简单的辅助函数开始,逐步深入到复杂的调度过程。

辅助函数简介

在深入调度核心之前,我们先了解几个简单的辅助函数,它们为调度提供了必要的信息。

cpuid 函数

cpuid 函数用于获取当前正在执行代码的 CPU 核心编号。它直接返回 tp 寄存器的值。由于中断可能随时发生,为了确保返回的值不会在返回瞬间就过时,调用此函数时必须禁用中断

int cpuid() {
    return r_tp(); // 读取 tp 寄存器
}

mycpu 函数

mycpu 函数获取当前核心的编号,然后在 CPU 结构体数组中查找对应的结构体。系统中每个核心都有一个对应的 struct cpu,此函数返回指向描述当前核心数据的结构体的指针。

struct cpu* mycpu(void) {
    int id = cpuid();
    return &cpus[id];
}

myproc 函数

myproc 函数通过调用 mycpu() 获取当前 CPU 结构体,然后返回该结构体中指向当前执行进程的 proc 指针。如果当前没有执行任何进程,这个指针可能为 null。为了线程安全,此函数内部会调用 push_off() 来禁用中断。

struct proc* myproc(void) {
    push_off(); // 禁用中断
    struct cpu *c = mycpu();
    struct proc *p = c->proc;
    pop_off();  // 恢复中断状态
    return p;
}

调度流程概览

上一节我们介绍了获取进程和CPU信息的辅助函数。本节中,我们来看看调度的整体路径。下图展示了从发生陷阱(trap)到执行 sret 指令返回用户空间之间发生的一切。

当用户代码执行时发生陷阱(例如定时器中断),内核会调用 yield 函数。在 yield 返回后,内核会恢复用户状态并继续执行。在整个陷阱处理路径中,包括调用 yield 时,中断始终是禁用的

深入 yield 和 sched 函数

现在,让我们聚焦于 yieldsched 函数在调用 swtch 之前的具体操作。

我们正在运行某个进程 P 的内核线程。yield 函数的主要工作是调用 sched。但在调用之前,它会:

  1. 获取指向当前进程 P 的 proc 结构体的指针。
  2. 获取保护进程状态等字段的进程锁。
  3. 将进程 P 的状态从 RUNNING 改为 RUNNABLE,表示它放弃CPU,等待下一次时间片。

然后,yield 调用 schedsched 函数会进行一系列检查:

  • 确保持有进程 P 的锁。
  • 确保中断是禁用的(intr_get() == false)。
  • 确保进程状态不再是 RUNNING

接着,sched 保存当前 CPU 结构体中 intena 字段的值(该字段记录了进入内核时中断是否启用),然后调用 swtch 进行上下文切换。

神秘的上下文切换:swtch

swtch 函数是调度中最有趣的部分,它用汇编语言编写,负责保存旧线程的寄存器并加载新线程的寄存器。

swtch 接收两个参数:指向旧上下文(struct context *old)和新上下文(struct context *new)的指针。

  • 旧上下文:用于保存即将被换出线程的寄存器状态。
  • 新上下文:用于加载即将被换入线程的寄存器状态。

在 RISC-V 调用约定中,a0a1 寄存器分别用于传递第一个和第二个参数。swtch 保存所有被调用者保存寄存器(s0-s11)、返回地址寄存器 ra 和栈指针寄存器 sp。调用者保存寄存器(a0-a7, t0-t6)则无需保存,因为调用者假定它们可能被覆盖。

# void swtch(struct context *old, struct context *new);
swtch:
        sd ra, 0(a0)    # 保存返回地址
        sd sp, 8(a0)    # 保存栈指针
        sd s0, 16(a0)   # 保存被调用者保存寄存器 s0
        sd s1, 24(a0)
        ... # 保存 s2-s11
        ld ra, 0(a1)    # 加载新线程的返回地址
        ld sp, 8(a1)    # 加载新线程的栈指针
        ld s0, 16(a1)   # 加载新线程的寄存器 s0
        ld s1, 24(a1)
        ... # 加载 s2-s11
        ret             # 返回到新线程的 `ra` 所指向的地址

关键点在于,swtch 通过加载新线程的 ra,使得 ret 指令不是返回到原来的调用者,而是跳转到新线程上次被切换出去时保存的地址。这就实现了从一个线程到另一个线程的跳跃。

调度器线程:scheduler

swtch 从进程线程切换到调度器线程后,调度器开始工作。scheduler 函数在每个 CPU 核心上作为一个独立的线程运行,它包含一个无限循环。

以下是调度器的主要工作流程:

  1. 获取当前 CPU 核心的结构体指针。
  2. 进入一个无限循环,在每次循环中遍历进程表(proc 数组)。
  3. 对于每个进程,先获取其锁,然后检查其状态是否为 RUNNABLE
  4. 如果找到一个可运行进程:
    • 将其状态改为 RUNNING
    • 将当前 CPU 的 proc 字段指向该进程。
    • 调用 swtch(&c->context, &p->context),切换到该进程执行。
  5. 当该进程的时间片用完(例如通过定时器中断调用 yield),会再次调用 swtch 切换回调度器线程。
  6. 调度器线程从 swtch 返回后,将当前 CPU 的 proc 字段设为 null,并释放该进程的锁。
  7. 如果遍历完整个进程表都没有找到 RUNNABLE 的进程,调度器会在循环间隙短暂启用中断,这允许设备中断发生并可能唤醒某个睡眠的进程,从而避免死锁。

锁的配对与跨核心释放

在调度过程中,锁的获取和释放以一种特殊方式配对。观察 yieldscheduler

  • yield 中,获取进程 P 的锁,然后调用 sched 并最终切换到调度器。
  • scheduler 中,当选择进程 P 运行时,也会获取进程 P 的锁,切换进去运行。
  • 进程 P 在 yield 中释放锁,而调度器在切换回之后释放锁。

这意味着,锁可能在一个 CPU 核心上被获取,而在另一个 CPU 核心上被释放。例如,核心 A 的调度器选择了进程 P 并获取其锁,然后切换到 P 执行。当 P 的时间片用完,它可能在核心 B 上执行 yield 并释放锁。

中断状态管理

中断状态在整个调度过程中被精心管理:

  • 从陷阱进入 yield 时,中断是禁用的。
  • sched 中调用 swtch 切换到调度器时,中断保持禁用。
  • 调度器线程在执行时,中断也是禁用的。
  • 只有当调度器在循环中找不到可运行进程时,才会短暂启用中断,以处理可能唤醒进程的设备中断。
  • 当调度器切换到一个用户进程时,在最终通过 sret 指令返回用户空间前,中断会被重新启用。

总结

本节课中我们一起学习了 xv6 内核调度机制的核心。我们从获取 CPU 和进程信息的辅助函数开始,然后剖析了进程主动放弃 CPU 的 yieldsched 函数。我们深入了解了用汇编编写的 swtch 函数如何通过保存和恢复寄存器上下文来实现线程间的切换。最后,我们分析了调度器 scheduler 如何循环选择可运行进程,并管理锁与中断状态,从而在多个进程间公平地分配 CPU 时间片。这个过程涉及精妙的锁配对和跨核心同步,是操作系统并发管理的核心体现。

17:sleep() 与 wakeup() 函数详解 🧠

在本节课中,我们将要学习 xv6 操作系统中用于进程同步的两个核心函数:sleep()wakeup()。我们将探讨它们的工作原理、典型的使用模式,以及如何通过“通道”机制来协调进程间的等待与唤醒。


概述

sleep()wakeup() 是 xv6 中实现进程间同步的基础机制。一个进程可以调用 sleep() 使自己进入休眠状态,直到另一个进程调用 wakeup() 将其唤醒。为了精确地唤醒特定的进程,xv6 引入了“通道”的概念。


通道机制

通道是一个简单的数字标识符。当进程调用 sleep() 时,它会指定一个通道号。当其他进程调用 wakeup() 并传入相同的通道号时,所有在该通道上休眠的进程都会被唤醒。通道号本身没有特殊含义,它只是一个用于匹配的标识。

在 xv6 中,每个进程的 proc 结构体中都有一个字段来存储它正在休眠的通道号。wakeup() 函数会遍历所有进程,唤醒那些状态为“休眠”且通道号匹配的进程。


典型使用模式

以下是 sleep()wakeup() 的典型使用模式。其核心思想是:检查一个条件,如果条件不满足,则进入休眠等待;当条件可能被其他进程改变后,重新检查。

while (condition_is_false) {
    sleep(channel, lock);
}
// 条件满足后,处理共享数据...

然而,这个模式存在一个潜在问题:在检查条件之后、调用 sleep() 之前,条件可能已经变为真,并且对应的 wakeup() 调用可能已经发生,从而导致当前进程错过唤醒信号,永远休眠下去。


解决方案:结合锁使用

为了解决上述问题,并遵守“不能在持有自旋锁时休眠”的原则,xv6 采用了以下模式:

  1. 获取保护共享数据的锁。
  2. 在持有锁的情况下检查条件。
  3. 如果条件不满足,调用 sleep(channel, &lock)sleep() 函数内部会原子性地释放传入的锁,并将进程状态改为休眠。
  4. 进程被唤醒后,在 sleep() 函数返回前,它会重新获取之前释放的锁。
  5. 循环回到步骤 2,再次检查条件。

这样确保了从检查条件到进入休眠的整个过程是原子的,不会错过任何发生在期间的 wakeup() 调用。


代码实例分析:sleep 系统调用

让我们以 xv6 中处理 sleep 系统调用的函数为例,看看上述模式的具体实现。

// 伪代码示意
uint64 sys_sleep(void) {
    int n;
    argint(0, &n); // 获取休眠时长参数(单位:滴答)
    acquire(&tickslock); // 获取保护全局变量 ticks 的锁
    uint64 start_ticks = ticks; // 记录开始时间
    while (ticks - start_ticks < n) { // 条件:是否已休眠足够时长?
        if (myproc()->killed) { // 检查进程是否被终止
            release(&tickslock);
            return -1;
        }
        sleep(&ticks, &tickslock); // 条件不满足,进入休眠
    }
    release(&tickslock); // 条件满足,释放锁并返回
    return 0;
}

在这个例子中:

  • 共享数据:全局变量 ticks(系统滴答计数)。
  • 保护锁tickslock
  • 条件:当前 ticks 与开始时间的差值是否达到参数 n
  • 通道:使用了共享变量 ticks 的地址作为通道号。

sleep() 函数实现

上一节我们看到了 sleep() 如何被使用,本节中我们来看看它的内部实现。sleep() 的核心职责是原子性地释放调用者持有的锁,并将进程状态设置为休眠。

// 睡眠函数伪代码
void sleep(void *chan, struct spinlock *lk) {
    struct proc *p = myproc(); // 获取当前进程
    acquire(&p->lock); // 获取进程自身的锁
    release(lk); // 释放调用者传入的锁(例如 tickslock)
    p->chan = chan; // 设置休眠通道
    p->state = SLEEPING; // 修改进程状态为休眠
    sched(); // 让出 CPU,触发调度
    // 当进程在此处被唤醒并重新调度执行时...
    p->chan = 0; // 清空通道字段
    release(&p->lock); // 释放进程锁
    acquire(lk); // 重新获取调用者传入的锁
}

关键点在于获取进程锁 (p->lock) 和释放调用者锁 (lk) 的顺序。这个顺序保证了 wakeup() 无法在 sleep() 设置好通道和状态之前看到这个进程,从而实现了操作的原子性。


wakeup() 函数实现

接下来,我们看看与 sleep() 配对的 wakeup() 函数是如何工作的。它的逻辑相对直接:遍历所有进程,唤醒在指定通道上休眠的进程。

// 唤醒函数伪代码
void wakeup(void *chan) {
    struct proc *p;
    for(p = proc; p < &proc[NPROC]; p++) { // 遍历进程表
        acquire(&p->lock); // 获取该进程的锁
        if(p->state == SLEEPING && p->chan == chan) { // 状态为休眠且通道匹配
            p->state = RUNNABLE; // 将状态改为可运行
        }
        release(&p->lock); // 释放进程锁
    }
}

wakeup() 在修改任何进程状态前,都必须先获取该进程的锁。这与 sleep() 中获取进程锁的操作共同构成了同步的基石,确保了不会出现竞态条件。


原子性保证

现在,让我们总结一下 sleep()wakeup() 如何协同工作以保证原子性。关键在于进程锁 (p->lock) 的互斥作用。

考虑 sleep() 中释放调用者锁 (lk) 和设置休眠状态的操作:

  1. sleep() 先获取进程锁 p->lock
  2. 然后释放调用者锁 lk
  3. 接着设置 p->chanp->state

对于 wakeup()

  • 它必须获取进程锁 p->lock 后才能检查 p->statep->chan
  • 因此,对于同一个进程,sleep() 中的步骤 1-3 和 wakeup() 中的检查是互斥的。
  • wakeup() 要么在 sleep() 设置好状态之前看到进程(此时进程还未休眠),要么在之后看到(此时进程已正确设置休眠信息)。它不可能看到中间的不一致状态。

这就保证了“检查条件”和“进入休眠”这两个操作作为一个整体是原子的,不会错过任何发生在期间的唤醒信号。


总结

本节课中我们一起学习了 xv6 操作系统的 sleep()wakeup() 同步原语。我们了解了:

  1. 通道 作为进程休眠和唤醒的匹配标识。
  2. 结合 自旋锁 使用的标准模式,以安全地检查条件并进入休眠,避免错过唤醒。
  3. sleep() 函数的内部实现,特别是它如何原子性地 释放调用者锁设置休眠状态
  4. wakeup() 函数如何遍历进程表并 唤醒匹配通道 的进程。
  5. 通过 进程锁 实现的 原子性保证 机制,这是 sleep()wakeup() 正确协作的核心。

理解这些机制是掌握操作系统内核中进程同步与通信的基础。

18:uart.c 与 console.c 详解 🖥️

在本节课中,我们将学习 xv6 操作系统中负责串行输入输出的两个核心文件:uart.cconsole.c。我们将了解它们如何协同工作,管理来自键盘的输入和发送到显示器的输出,包括缓冲、中断处理和字符回显等关键机制。

系统概述

上一节我们介绍了 xv6 内核的总体结构,本节中我们来看看具体的硬件交互模块。xv6 通过模拟的 16550A 芯片与终端进行通信。内核通过向特定内存映射地址写入字节来向显示器发送字符,并通过从特定地址读取字节来从键盘获取字符。

硬件内部可能包含一些 FIFO 缓冲区以使通信更顺畅,但内核可以忽略这些。软件层面,内核维护了两个主要缓冲区:

  • 输出队列:称为 UART 发送缓冲区。
  • 输入队列:称为控制台缓冲区。

每个队列都由自己的锁保护。内核通过三个主要函数与外界交互:

  • printf:内核用于打印错误信息,这些信息被视为关键,会绕过输出缓冲区立即显示。
  • consolewrite:用户模式程序用于向输出写入数据。
  • consoleread:用户模式程序用于从输入读取数据。

输出处理流程

现在,让我们深入了解输出是如何被缓冲和发送的。输出缓冲区是一个环形缓冲区,称为 UART 发送缓冲区。

以下是其工作原理的关键点:

  • 缓冲区有固定大小(实际为32字节)。
  • 使用读索引(r)和写索引(w)来管理。
  • r == w 时,缓冲区为空。
  • w - r == 缓冲区大小 时,缓冲区为满。
  • 索引值会持续递增,并通过取模运算(% 缓冲区大小)来实现环形访问。

由于多个进程和核心可能同时访问此缓冲区,因此它由一个名为 uart_tx_lock 的锁保护。

输入处理流程

接下来,我们转向输入缓冲区。输入缓冲区(控制台缓冲区)比输出缓冲区更复杂,因为它需要处理行编辑(如退格和整行删除)。

输入缓冲区使用三个索引:

  • r:读取索引,指向下一个待读取给用户程序的字符。
  • w:写入索引,指向下一个待写入的新行起始位置(或文件结束符)。
  • e:编辑索引,指向当前正在输入的行尾。

其工作流程如下:

  • 用户键入字符时,字符被添加到 e 指向的位置,然后 e 递增。
  • 按下退格键时,e 递减,并回显删除操作。
  • 按下 Ctrl+U 时,e 回退到上一行末尾(或 w 的位置),以删除整行。
  • 当用户输入换行符或 Ctrl+D(文件结束符)时,w 被设置为 e 的位置,标志着一行输入完成,并唤醒等待输入的 consoleread 函数。

硬件寄存器映射

为了与硬件通信,内核需要读写特定的内存映射寄存器。UART 设备被映射到物理内存地址空间中的固定地址(UART0)。

以下是关键寄存器及其偏移量:

  • 偏移量 0写操作时是发送保持寄存器(THR),用于输出字节。读操作时是接收保持寄存器(RHR),用于输入字节。
  • 偏移量 5:线路状态寄存器(LSR)。包含两个重要位:
    • 一位指示接收保持寄存器中是否有数据可读。
    • 另一位指示发送保持寄存器是否就绪,可以接收下一个要发送的字节。
  • 其他寄存器用于设置波特率、启用中断和 FIFO 等。

uart.c 文件函数解析

在了解了基本概念后,我们深入代码。uart.c 文件包含以下核心函数:

1. uartputc_sync
此函数用于尽可能快地将字符发送到硬件输出,绕过输出缓冲区。它被 printf 用于打印错误信息,也用于回显输入字符。

void uartputc_sync(int c) {
    // 等待发送寄存器就绪
    while((ReadReg(LSR) & LSR_TX_IDLE) == 0);
    // 写入字符到发送寄存器
    WriteReg(THR, c);
}

2. uartputc
此函数由 consolewrite 调用,用于将字符添加到输出缓冲区。如果缓冲区满,调用者会睡眠等待。

void uartputc(int c) {
    acquire(&uart_tx_lock);
    // 如果缓冲区满,则睡眠
    while(uart_tx_w == uart_tx_r + UART_TX_BUF_SIZE) {
        sleep(&uart_tx_r, &uart_tx_lock);
    }
    // 将字符放入缓冲区并更新写索引
    uart_tx_buf[uart_tx_w % UART_TX_BUF_SIZE] = c;
    uart_tx_w += 1;
    // 尝试启动发送
    uartstart();
    release(&uart_tx_lock);
}

3. uartstart
此函数检查输出缓冲区是否有数据,以及硬件是否就绪。如果条件满足,则从缓冲区取出一个字符发送给硬件,并唤醒可能正在等待缓冲区空间变空的 uartputc 函数。

4. uartgetc
此函数从硬件读取一个输入字节(如果就绪)。如果没有数据,则立即返回 -1,不会等待。

5. uartintr
这是 UART 设备的中断处理函数。当硬件有输入字符到达或准备好接收下一个输出字符时,会触发中断并调用此函数。它的职责是:

  • 读取所有可用的输入字符,并为每个字符调用 consoleintr
  • 调用 uartstart 来发送输出缓冲区中的下一个字符。

console.c 文件函数解析

现在,我们来看控制台相关的函数,它们主要管理输入缓冲区。

1. consolewrite
此函数被系统调用实现使用,用于将用户空间的数据写入输出。它循环调用 uartputc 将每个字符放入输出缓冲区。

2. consoleread
此函数被系统调用实现使用,用于从输入缓冲区读取数据到用户空间。它等待直到有一整行数据可用(即 w > r),然后将字符复制到用户缓冲区,直到遇到换行符或文件结束符。

3. consoleintr
此函数由 uartintr 为每个输入的字符调用。它负责处理字符并将其添加到输入缓冲区,同时处理特殊字符:

  • 退格/删除:将编辑索引 e 回退,并回显删除操作。
  • Ctrl+U:将 e 回退到行首或上一个换行符,删除整行。
  • 换行符/Ctrl+D:将写入索引 w 更新到 e,标志一行输入完成,并唤醒所有等待输入的 consoleread 进程。
  • 普通字符:回显字符,并将其存储到 e 指向的缓冲区位置。

4. consoleinit
初始化函数,设置控制台锁并调用 uartinit 来初始化 UART 硬件。

总结

本节课中我们一起学习了 xv6 操作系统中串行输入输出子系统的详细工作原理。我们分析了 uart.cconsole.c 两个关键文件,了解了它们如何通过环形缓冲区管理输入输出,如何处理硬件中断,以及如何实现字符回显和行编辑功能。核心在于通过锁保护共享缓冲区,并通过睡眠/唤醒机制协调生产者(输入/写入者)和消费者(输出/读取者)的速度。这套机制是理解操作系统设备驱动和系统调用接口的基础。

19:虚拟内存辅助函数概述 🧠

在本节课中,我们将学习 xv6 内核中用于虚拟内存管理的一系列辅助函数。这些函数定义在 vm.c 文件中,用于操作页表结构。它们相对独立且不涉及锁或休眠操作。为了便于理解,我们将分两部分介绍:本节是函数概述,下一节将详细分析代码。

首先,让我们回顾一下页表的结构。

页表结构回顾 🌳

在 xv6 中,页表是一个三级树形结构。我们可以将根节点称为根页,所有构成页表的页都称为索引页。在最底层,索引页指向数据页

一个虚拟地址的格式如下:

| 一级索引 | 二级索引 | 三级索引 | 页内偏移 |

我们使用地址的高位字段(一级、二级、三级索引)在页表中逐级向下查找,最后的“页内偏移”用于在数据页内部定位。

页表项(PTE)的格式包含一个页对齐的指针(用于指向下一级页表或数据页)和几个标志位:

  • 有效位 (V):表示该页表项是否有效。
  • 读/写/执行位 (R/W/X):定义页面的访问权限。
  • 用户模式位 (U):表示该页面是否可在用户模式下访问。

请注意R/W/X/U 这些权限位仅在最后一级(指向数据页)的页表项中有效。在上层的索引页中,只有 V 位有意义,其他位应为零。

上一节我们回顾了页表的基本结构,本节中我们来看看操作这些结构的具体函数。

核心辅助函数详解 🔧

以下是 vm.c 中定义的主要虚拟内存辅助函数及其功能。

1. 页表遍历函数 walk

walk 函数接收一个页表(即指向根页的指针)和一个虚拟地址。它的作用是遍历页表树,并返回指向最终数据页页表项(PTE)的地址

pte_t *walk(pagetable_t pagetable, uint64 va, int alloc);
  • 参数 alloc:如果路径上的中间索引页不存在,此参数决定是否创建它们(分配物理页并设置)。
  • 返回值:成功则返回指向目标 PTE 的指针;如果无法分配所需页面(且 alloc 为 0),则返回空指针 0

2. 建立映射函数 mappages

mappages 函数用于向页表中添加一个或多个映射。它接收一个页表、一个起始虚拟地址、一个起始物理地址、要映射的页面数量以及权限标志。

int mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm);

  • 功能:将一段连续的虚拟页面映射到一段连续的物理页面。
  • 返回值:成功返回 0;如果映射过程中出现错误(如页面已映射),则返回 -1。这是 xv6 内核函数常见的错误处理模式。

3. 内核页表相关函数

内核只有一个页表,所有 CPU 核心共享它。创建内核页表涉及以下函数:

  • kvmmap:此函数与 mappages 功能类似,但用于构建内核页表。由于内核页表在初始化时必须成功建立,如果 mappages 返回错误,kvmmap 会直接调用 panic 使内核崩溃。
  • kvminit:这是初始化内核页表的主函数。它调用 kvmmap 来:
    1. 建立直接映射,将全部物理内存映射到内核虚拟地址空间。
    2. 映射所有内存映射的 I/O 设备。
    3. trampoline 页(蹦床页)建立映射。该物理页在虚拟地址空间中有两个映射:一个在其实际物理位置,另一个在虚拟地址空间的最高页。
    4. 调用 proc_mapstacks 为每个进程分配内核栈页。
  • kvminithart:每个 CPU 核心在初始化时调用此函数。它将全局变量 kernel_pagetable(由 kvminit 设置)的地址写入 satp 寄存器。这步操作实际上为该核心开启了分页机制

4. 地址翻译函数 walkaddr

walkaddr 函数用于将用户空间的虚拟地址转换为物理地址。

uint64 walkaddr(pagetable_t pagetable, uint64 va);
  • 功能:调用 walk 查找虚拟地址对应的页表项,然后结合页内偏移计算出物理地址。
  • 要求:目标页面必须标记为有效 (V) 且用户可访问 (U)。
  • 返回值:成功返回物理地址;如果地址无效或权限不足,则返回 0。由于虚拟地址 0 被映射到物理地址 0,没有其他虚拟地址会翻译成物理地址 0,因此返回 0 是安全的错误指示。

5. 用户地址空间创建与修改

  • uvmcreate:创建一个空的用户虚拟地址空间。它分配一个物理页作为页表的根页,并将其内容清零。
  • uvminit:创建第一个用户地址空间(init 进程)。它分配一个页,映射到虚拟地址 0,并标记为可读、可写、可执行且用户可访问 (R|W|X|U)。然后,它将内核中存储的 initcode 字节数组(一段汇编程序)复制到这个页面。这段代码会执行 exec(“/init”) 系统调用。
  • uvmalloc:为用户地址空间增加页面(例如扩展堆)。
    uint64 uvmalloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz);
    
    • 功能:分配新的物理页,并使用 mappages 将其映射到用户地址空间中 oldsznewsz 的区域。新页面权限为 R|W|X|U
    • 返回值:成功返回新的地址空间大小 (newsz);失败则释放所有已分配的资源并返回 0
  • uvmdealloc:为用户地址空间减少页面(例如收缩堆)。它内部调用 uvmunmap 来解除映射并释放物理页。
    uint64 uvmdealloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz);
    
    • 注意oldsznewsz 不需要页面对齐。如果 newsz > oldsz,该函数不执行任何操作。

6. 解除映射与空间释放

  • uvmunmap:从页表中移除指定虚拟地址范围内的映射。
    void uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free);
    
    • 参数 do_free:一个布尔值,指示是否在解除映射后调用 kfree 释放对应的数据页
    • 操作:遍历指定范围内的每个页面,将其页表项的有效位 (V) 清零,使其无效。
  • uvmfree:释放整个用户地址空间。
    void uvmfree(pagetable_t pagetable, uint64 sz);
    
    • 功能:首先调用 uvmunmap(…, 1) 释放所有用户数据页(代码、数据、堆、栈)。然后调用 freewalk 释放页表本身的所有索引页
    • 注意:用户地址空间高位的 trampoline 页(所有进程共享)和 trapframe 页(每个进程独立预分配)不会被此函数释放。
  • freewalk:递归地释放页表树中的所有索引页,但不释放数据页。
    void freewalk(pagetable_t pagetable);
    
    • 递归深度:由于 xv6 页表只有三级,递归深度有限,不会导致内核栈溢出。

7. 地址空间复制 uvmcopy

fork 系统调用中,需要复制父进程的整个地址空间给子进程。uvmcopy 负责这项工作。

int uvmcopy(pagetable_t old, pagetable_t new, uint64 sz);
  • 功能:将旧页表 (old) 中 [0, sz) 范围内的所有数据页逐页复制到新分配的物理页,并将这些新页以相同的权限映射到新页表 (new) 中。
  • 实现:对每个需要复制的页面,调用 walk 查找 PTE,kalloc 分配新物理页,memmove 复制数据,mappages 建立新映射。
  • 错误处理:如果中途失败(如内存不足),它会回滚所有操作:释放已分配的新页并解除已建立的新映射。

8. 用户空间访问函数

内核经常需要读写用户进程地址空间中的数据(例如,系统调用参数)。由于用户数据可能跨多个不连续的物理页,需要特殊函数来处理。

  • copyin:从用户空间复制数据到内核缓冲区。
    int copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len);
    
    • 功能:将用户页表 pagetable 中,起始于虚拟地址 srcvalen 字节数据,复制到内核地址 dst。它逐页处理,确保在内核中获得连续的数据。
  • copyout:从内核缓冲区复制数据到用户空间。
    int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len);
    
    • 功能:将内核地址 src 处的 len 字节数据,复制到用户页表 pagetable 中起始于虚拟地址 dstva 的位置。
  • copyinstrcopyin 的变体,专门用于复制以空字符 (\0) 结尾的字符串。
    int copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max);
    
    • 功能:从用户空间复制字符串到 dst,最多复制 max 个字节。如果遇到空终止符则停止。
    • 返回值:成功返回 0;如果达到 max 仍未遇到空终止符,则返回 -1

9. 其他工具函数

  • uvmclear:用于将用户地址空间中的栈保护页标记为用户不可访问。它通过清除对应页表项中的 U 位来实现。
    void uvmclear(pagetable_t pagetable, uint64 va);
    

总结 📚

本节课我们一起学习了 xv6 内核虚拟内存管理的一系列核心辅助函数。我们了解了如何遍历页表 (walk)、建立和解除映射 (mappages, uvmunmap)、管理用户地址空间的创建、扩展、收缩和复制 (uvmcreate, uvmalloc, uvmdealloc, uvmcopy),以及如何在用户空间和内核空间之间安全地复制数据 (copyin/out)。这些函数是 xv6 实现内存隔离、进程创建和系统调用的基础。在下一节中,我们将深入代码,详细查看这些函数的具体实现。

20:VM 函数代码详解 🧠

在本节课中,我们将深入学习 xv6 内核中虚拟内存(VM)相关的辅助函数。这些函数位于 vm.c 文件中,负责管理页表、地址映射、内存分配与释放等核心操作。我们将通过代码走查的方式,逐一解析每个函数的工作原理和实现细节。


函数 walk:遍历页表 🚶‍♂️

上一节我们概述了 VM 函数的功能,本节中我们来看看 walk 函数的具体实现。该函数接收一个指向页表树根节点的指针、一个虚拟地址和一个布尔值 alloc。它的作用是遍历页表树,并返回指向目标虚拟地址对应的页表项(PTE)的指针。如果遍历过程中发现中间层页表不存在,且 alloc 为真,则会分配并初始化这些页表。

以下是 walk 函数的核心逻辑:

pte_t *walk(pagetable_t pagetable, uint64 va, int alloc) {
    if(va >= MAXVA)
        panic("walk");
    for(int level = 2; level > 0; level--) {
        pte_t *pte = &pagetable[PX(level, va)];
        if(*pte & PTE_V) {
            pagetable = (pagetable_t)PTE2PA(*pte);
        } else {
            if(!alloc || (pagetable = (pde_t*)kalloc()) == 0)
                return 0;
            memset(pagetable, 0, PGSIZE);
            *pte = PA2PTE(pagetable) | PTE_V;
        }
    }
    return &pagetable[PX(0, va)];
}

代码执行步骤如下:

  1. 检查虚拟地址:确保虚拟地址 va 不超过最大允许值 MAXVA
  2. 循环遍历层级:从第 2 级(根页表)开始,向下遍历到第 1 级。
    • 使用宏 PX(level, va) 从虚拟地址中提取当前层级的索引位。
    • 通过索引在当前页表中找到对应的页表项(PTE)。
  3. 处理页表项
    • 如果 PTE 的有效位(PTE_V)已设置,则通过 PTE2PA 宏将 PTE 转换为物理地址,并更新 pagetable 指针,指向下一级页表。
    • 如果 PTE 无效:
      • alloc 为假,则返回 0(表示失败)。
      • alloc 为真,则调用 kalloc 分配一个新的物理页作为下一级页表,用 memset 清零,并通过 PA2PTE 宏将其物理地址转换为 PTE 格式,设置有效位后,存入当前 PTE。
  4. 返回最终 PTE:循环结束后,pagetable 指向第 0 级页表。使用 PX(0, va) 提取最终索引,返回指向目标页表项的指针。

函数 mappages:建立地址映射 🗺️

理解了如何查找页表项后,我们来看看如何建立虚拟地址到物理地址的映射。mappages 函数用于在页表中创建一系列连续的页表项,将一段虚拟地址空间映射到一段物理地址空间。

该函数接收页表根指针、起始虚拟地址 va、映射大小 size、起始物理地址 pa 以及权限位 perm。它会为 [va, va+size) 范围内的每个虚拟页,在页表中创建对应的页表项,使其指向 [pa, pa+size) 范围内对应的物理页,并设置相同的权限。

以下是 mappages 函数的关键部分:

int mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm) {
    uint64 a, last;
    pte_t *pte;
    a = PGROUNDDOWN(va);
    last = PGROUNDDOWN(va + size - 1);
    for(;;) {
        if((pte = walk(pagetable, a, 1)) == 0)
            return -1;
        if(*pte & PTE_V)
            panic("remap");
        *pte = PA2PTE(pa) | perm | PTE_V;
        if(a == last)
            break;
        a += PGSIZE;
        pa += PGSIZE;
    }
    return 0;
}

执行流程如下:

  1. 地址对齐:使用 PGROUNDDOWN 确保起始虚拟地址 a 和结束地址 last 是页对齐的。
  2. 逐页映射:循环处理每一页。
    • 调用 walk 函数查找或创建当前虚拟地址 a 对应的页表项 ptealloc 参数为 1,允许分配中间页表。
    • 检查找到的 PTE 是否已经有效(PTE_V),如果是,说明发生了重映射,触发 panic
    • 使用 PA2PTE 将物理地址 pa 转换为 PTE 格式,与权限位 perm 和有效位 PTE_V 进行或操作,然后存入页表项 *pte
    • 如果当前页是最后一页(a == last),则结束循环。
    • 否则,虚拟地址 a 和物理地址 pa 都增加一个页的大小(PGSIZE),处理下一页。
  3. 返回结果:成功映射所有页后返回 0。

函数 kvmmake:构建内核页表 🏗️

现在,我们来看内核如何构建自己的页表。kvmmake 函数负责创建并初始化内核的页表,它通过调用 kvmmap(一个包装了 mappages 的函数)来建立各种映射。

内核页表需要建立以下几种映射:

  1. 直接映射:将大部分物理内存(包括内核代码、数据)以相同的虚拟地址进行映射,方便内核访问。
  2. 设备映射:将 UART、磁盘、PLIC 等 I/O 设备的物理地址映射到特定的虚拟地址。
  3. 跳板页映射:将 trampoline 代码页映射到虚拟地址空间的最高页。
  4. 进程内核栈映射:为每个进程分配一个独立的内核栈页,并映射到内核地址空间。

以下是 kvmmake 函数的核心映射调用:

pagetable_t kvmmake(void) {
    pagetable_t kpgtbl = (pagetable_t) kalloc();
    memset(kpgtbl, 0, PGSIZE);
    // 映射 UART 设备
    kvmmap(kpgtbl, UART0, UART0, PGSIZE, PTE_R | PTE_W);
    // 映射磁盘设备
    kvmmap(kpgtbl, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
    // 映射 PLIC
    kvmmap(kpgtbl, PLIC, PLIC, 0x400000, PTE_R | PTE_W);
    // 映射内核代码段 (只读、可执行)
    kvmmap(kpgtbl, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
    // 映射内核数据段及剩余物理内存 (可读、可写)
    kvmmap(kpgtbl, (uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);
    // 映射跳板页 (只读、可执行)
    kvmmap(kpgtbl, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
    // 为每个进程映射内核栈
    proc_mapstacks(kpgtbl);
    return kpgtbl;
}

函数 proc_mapstacks 会为每个进程分配一个物理页作为内核栈,并使用 kvmmap 将其映射到内核虚拟地址空间中一个特定的、受保护的位置(通常每个栈之间有一个“保护页”以防止溢出)。


函数 uvmallocuvmdealloc:管理用户地址空间大小 📏

用户进程的堆空间需要能够动态增长和收缩。uvmallocuvmdealloc 函数分别用于扩展和缩小用户虚拟地址空间。

uvmalloc:当进程需要更多内存时(例如通过 sbrk 系统调用),此函数被调用。

  • 输入:用户页表 pagetable,旧大小 oldsz,新大小 newsz
  • 逻辑
    1. oldsz 向上舍入到页边界。
    2. 循环分配新的物理页,并调用 mappages 将其映射到从舍入后地址开始的虚拟地址空间。
    3. 新页的权限设置为可读、可写、用户可访问(PTE_R | PTE_W | PTE_U)。
    4. 如果任何一步失败(如 kalloc 失败),则调用 uvmdealloc 回滚所有已分配的页,并返回 0。
    5. 成功则返回 newsz

uvmdealloc:当进程释放内存或 uvmalloc 需要回滚时,此函数被调用。

  • 输入:用户页表 pagetable,旧大小 oldsz,新大小 newsz
  • 逻辑
    1. 检查 newsz 是否小于 oldsz,如果不是,则不进行收缩。
    2. 计算需要释放的页数:(PGROUNDUP(oldsz) - PGROUNDUP(newsz)) / PGSIZE
    3. 调用 uvmunmap 函数,从虚拟地址 PGROUNDUP(newsz) 开始,取消映射指定数量的页,并释放对应的物理页(如果 do_free 参数为真)。
    4. 返回 newsz

函数 uvmcopy:复制用户地址空间 (用于 fork) 👯‍♂️

fork 系统调用需要复制父进程的整个用户地址空间给子进程。uvmcopy 函数实现了这个功能。

  • 输入:父进程页表 old,子进程页表 new,父进程地址空间大小 sz
  • 逻辑
    1. 遍历父进程地址空间的每一页(从 0 到 sz)。
    2. 对于每一页:
      • 使用 walk 找到父进程页表中对应的 PTE。
      • 获取该 PTE 指向的物理页地址和权限标志。
      • 为子进程分配一个新的物理页(kalloc)。
      • 将父进程物理页的内容复制到子进程的新物理页中(memmove)。
      • 调用 mappages 将子进程的新物理页映射到子进程页表中相同的虚拟地址,并设置相同的权限。
    3. 如果任何一步失败(如分配失败),则跳转到错误处理标签,调用 uvmunmap 释放子进程中已成功映射的所有页及其物理内存,并返回 -1。
    4. 成功复制所有页后返回 0。

函数 copyin / copyout / copyinstr:内核与用户空间的数据拷贝 🔄

内核经常需要从用户空间读取数据(如系统调用参数),或向用户空间写入数据(如系统调用结果)。由于用户空间可能跨页,且需要检查地址有效性,xv6 提供了专门的拷贝函数。

copyin:从用户虚拟地址空间拷贝数据到内核缓冲区。

  • 逻辑:循环处理可能跨页的数据。每次迭代:
    1. 使用 walkaddr(内部调用 walk)将用户虚拟地址转换为物理地址。此函数会检查地址有效性和用户权限。
    2. 计算当前页内可拷贝的字节数。
    3. 使用 memmove 从转换得到的物理地址拷贝数据到内核目标地址。
    4. 更新指针和剩余长度。

copyout:从内核缓冲区拷贝数据到用户虚拟地址空间。

  • 逻辑:与 copyin 几乎对称,只是方向相反。

copyinstr:从用户空间拷贝一个以空字符结尾的字符串到内核缓冲区,并防止缓冲区溢出。

  • 逻辑:与 copyin 类似,但逐字节拷贝,并在遇到空字符 \0 或达到最大长度 max 时停止。如果成功拷贝到空字符,返回 0;如果达到最大长度仍未遇到空字符,返回 -1。

其他重要函数概览

  • uvmcreate:创建一个空的用户页表(仅分配根页表并清零)。
  • uvmfree:释放整个用户地址空间。它先调用 uvmunmap 释放所有用户页(数据页和页表项),然后调用 freewalk 递归释放页表树本身的所有层级页表。
  • freewalk:递归地释放页表树。它遍历一个页表,对所有有效的、指向下一级页表(非叶子节点)的 PTE,递归调用自身释放下级页表,最后释放当前页表页。
  • walkaddr:将用户虚拟地址转换为物理地址。它调用 walkalloc=0),并检查 PTE 的有效性和用户权限位。

总结 🎯

本节课中我们一起深入学习了 xv6 操作系统中虚拟内存管理的核心函数。我们从遍历页表的 walk 函数开始,了解了如何查找和创建页表项。接着,我们分析了建立地址映射的 mappages,以及构建内核页表的 kvmmake。然后,我们探讨了管理用户地址空间动态大小的 uvmallocuvmdealloc,以及用于 fork 系统调用的 uvmcopy 函数。最后,我们学习了在内核与用户空间之间安全拷贝数据的 copyincopyoutcopyinstr 函数。这些函数共同构成了 xv6 内存管理子系统的基础,确保了进程隔离、内存分配和高效的系统调用数据传递。

21:进程创建

本视频是 XB6 操作系统内核系列的一部分。在本视频中,我们将讨论进程,特别是用于表示进程的数据结构。我们将介绍进程结构数组如何初始化,以及如何为初始的第一个进程设置数据。这些内容来自文件 proc.c,该文件还包含许多其他内容。除了我将要讨论的这些材料外,它还包含调度器、yield 等代码,以及 sleepwakeup 函数的代码。这些内容已在之前的视频中介绍过。该文件还包含 forkwaitkill 等函数的代码,我将在未来的视频中介绍它们。

那么,让我们从文件本身开始,我想从这两个字段开始。有一个初始化为 1 的进程 ID 计数器,还有一个名为 pid_lock 的自旋锁。首先,我们可以看看这个函数 allocpid。每当我们需要一个新的进程 ID 时,就可以调用这个函数。它相当直接,返回新的进程 ID。为了访问和更新全局变量 nextpid,我们需要先获取锁。因此,这里先获取锁,然后将当前值读入一个局部变量,递增全局变量,最后释放锁。这是 acquirerelease 的典型用法。

好了,现在我们可以进入我想介绍的第一个函数 procinit。在此之前,让我提醒你本视频相关的结构。我们有一个 proc 结构数组。这里我展示了一个,总共有 64 个,每个进程一个。请记住,它们有一个自旋锁和一些字段,比如状态(是运行中、可运行等)、通道(如果正在睡眠)、killed(进程是否已被杀死)以及其他一些东西。我们还有一个指向虚拟内存中栈所在区域的指针。我稍后会讲到这个。地址空间的大小,即该进程堆顶的断点、指向页表的指针、指向陷阱帧的指针、用于保存的上下文以及其他一些字段,如 name 字段。我现在不打算详细讨论这些,但我想谈谈这个函数 procinit,它初始化进程数组。这个函数在内核初始化期间仅由 core0 调用一次,它初始化了我们刚才看到的进程 ID 锁,还初始化了另一个名为 wait_lock 的锁。然后它遍历这个进程数组。它遍历数组,并为每个元素初始化自旋锁。同时,它还初始化这个 kstack。在这一点上,我将讨论内核的虚拟地址空间是什么样的。请记住,内核虚拟地址空间,像所有地址空间一样,会将蹦床页面映射到最高页。然后它有一系列的保护页和栈页。因此,对于 64 个进程中的每一个,都有一个页面,当该进程在内核模式下运行时,将使用该栈页面的虚拟地址空间。在 procinit 函数中,我们正在确定虚拟地址。这是一个预处理器宏函数,给定一个 0 到 63 之间的数字,它确定该栈页面的虚拟地址,并简单地将其保存在这个 proc 结构的 kstack 字段中。这是 proc 结构,这是 kstack 字段。它只是保存虚拟地址。我添加了这条虚线来表示这是一个虚拟地址。与此处的链接相反,页表指向物理内存中的某个物理地址,陷阱帧指针也指向陷阱帧的实际物理地址。当然,陷阱帧将被映射到虚拟地址空间的第二高页,但这里的这个点指向的是从 kalloc 返回的实际物理页,我们稍后会看到这一点。

我已经提到了这个文件中的另外几个函数,但我只是回顾一下。cpuid 通过返回 tp 寄存器的值来返回当前执行此函数的核心编号。mycpu 使用当前核心编号并返回指向 cpu 结构的指针。这是 cpu 结构数组。这里我展示了一个。最多有 8 个核心,这是一个固定常量。每个核心都有一个 cpu 结构。因此,我们返回指向当前核心的那个结构的指针。最后,我们有 myproc 例程,它获取当前 cpu 结构的指针,然后跟随 proc 字段获取指向表示当前正在执行的进程的结构的指针。每个 cpu 结构都有一个 proc 字段,指向一个 proc 结构。每个核心在任何时刻,要么正在执行调度器代码,要么正在执行某个进程。如果它正在执行一个进程,那么这个指针是有效的,它指向一个 proc 结构。如果 CPU 正在执行调度器代码,那么这个指针将是 null。所以,这些函数相当直接。

接下来,让我们看看 procdump。我认为 procdump 很简单。基本上,它用于调试。它只是遍历进程数组并打印出信息。它不接收参数,也不返回值。它做什么呢?正如我所说,它是一个 for 循环,遍历这里所有 64 个 proc 结构的整个数组。如果该结构是未使用的,换句话说,如果这里的 stateUNUSED,那么我们就不打印任何内容。我们继续并重复循环体。否则,我们在我们这里的小数组中查找状态。我们有一个小数组,将常量 UNUSEDSLEEPINGRUNNABLERUNNINGZOMBIE 映射为短字符串。因此,我们在这里获取名为 state 的字符串。我们在这里只是检查以确保它是一个有效的数字。否则,我们使用那个。然后我们打印一行。这将为进程打印一行,包含进程 ID、当前状态和名称。name 字段是一个固定长度的字段,我们可以在其中存储一个短名称。在这里,我们打印名称。这相当直接。这就是 procdump

现在,我们将进入 allocproc 函数。让我们看看这个函数做什么。首先,它不接收参数。当我们需要一个 proc 结构来创建一个新进程时,我们调用 allocproc。它将找到一个并初始化它,然后返回一个指向它的指针。在进程表中查找一个未使用的 proc。如果找到,初始化它并返回,同时持有锁。如果有问题,它返回 null。这是代码将要执行的操作的概述。它首先搜索一个未使用的 proc 结构。然后它调用 kalloc 为该进程分配一个陷阱帧页面。接着,它创建一个新的页表,为蹦床页面添加映射(当然,蹦床页面由所有虚拟地址空间共享),并为刚刚分配的陷阱帧页面添加映射。然后它设置上下文,或者我应该说它初始化上下文,为该进程的第一个时间片做准备。回想一下,在 proc 结构中,我们有这个上下文区域,这是寄存器保存区,所以我们在那里做一些初始化。创建的地址空间,我们在这里创建了一个页表,但还没有放入任何内容。所以还没有代码或数据。allocproc 函数不负责这个,但稍后会添加。如果有任何问题,这个函数必须撤销一切,通过调用 kfree 将所有分配的页面返回到空闲池,然后返回 null

让我们看看这里的代码。我们正在寻找一个状态为 UNUSEDproc 结构。我们在这个 for 循环中遍历整个 64 个元素的数组。对于每个元素,我们获取锁。如果它是未使用的,那么我们就找到了。然后跳转到这里的标签。但如果它正在使用,那么我们释放锁并继续寻找。如果我们找不到任何东西,就返回 null。假设我们找到了。那很好。在这里,我们填写进程 ID。每个进程将获得一个新的标识符。当这个变量溢出时会发生什么?在 xv6 中我们不担心这种事情,因为它在我们有生之年永远不会发生。我们将状态设置为 USED。这是我们可以拥有的状态之一。请记住,USED 状态没有列在这里。无论如何,它进入被使用的状态。稍后,调度器将负责在某个未来时间使其变为可运行状态。然后我们分配陷阱帧页面。这里我们调用 kalloc。如果出现任何问题,我们将释放锁并返回 null。我们还将调用这个 freeproc 函数。我接下来会介绍这个函数,但基本上,任何时候出现问题都会调用它。我们在这里也看到它被调用。它基本上将使 proc 结构再次变为未使用状态,并将所有字段清零,以便可以回收。

现在,我们创建页表。这里我们调用 proc_pagetable,我稍后会介绍,但那是要创建页表并添加几个映射。如果一切正常,我们就继续;否则,我们调用 freeproc 来撤销我们所做的,释放锁并返回 null,就像我们在这里做的那样。allocproc 在两个地方被调用。一个是 userinit,用于创建第一个进程(init 进程);另一个地方是 fork 函数,用于 fork 系统调用。无论如何,在进程创建之后,它将被调度运行。所以我们需要为此设置一些东西。请记住,在 proc 结构中,我们有寄存器的保存区。这将是进程开始运行时将使用的寄存器。当然,当它在内核模式下被调度时,它将开始运行。所以,我们需要有返回地址和栈指针用于它的初始调度。我们设置上下文。首先,我们清除所有寄存器。memset 只是将寄存器保存区中的所有寄存器写为零。然后我们将 ra 寄存器初始化为指向这个 forkret 函数。我稍后会看一下这个。我们将 sp 寄存器初始化为指向栈页面。请记住,每个进程在虚拟内存中都有一个页面。例如,进程 2 在虚拟内存中的这个地址有这个页面,栈是向下增长的。所以我们希望将栈指针初始化为指向这个页面的顶部,以便它可以从这个区域开始向下增长。因此,我们初始化栈指针。当这个进程被调度时,也就是当它获得第一个时间片时,它将开始执行这里给出的 ra 地址处的代码,并带有一个栈。

在我们调用 allocproc 之后,我们要做的基本上是填充代码和数据。我们还需要释放锁。请记住,我们在这里获取了锁,我们需要释放锁。然后这个线程可以继续做它想做的任何事情。但在某个时刻,调度器将选择这个进程,它将获取该进程的锁,调度它,然后从上下文加载寄存器,其中包括返回地址。因此,这个进程将开始执行。每个进程,包括 init 进程,都将通过执行 forkret 函数开始。这是 forkret 函数。我们可以看到它做什么。当我们第一次被调度时,我们持有进程锁。所以我们必须释放那个锁,除了从陷阱返回之外,我们没有什么可做的。我们实际上并没有发生陷阱,但我们从陷阱返回开始。在 fork 的情况下,我们确实发生了陷阱。在初始进程的情况下,我们将伪造一个陷阱。但无论如何,我们在这里进行陷阱返回。forkret 中的其他内容,你可以看到一个全局变量 first,或者我应该说一个静态变量,它被初始化为 true,所以这只会执行一次。任何进程第一次被调度时,它将执行这段代码,然后将 first 设置为 false。它说文件系统初始化必须在常规进程的上下文中运行,因为它调用了 sleep,因此不能从 main 函数运行。这就是这里发生的事情。当我们即将调度初始进程时,我们会看到 first 是 1,我们将调用这个 fsinit 函数,并在我们执行 usertrapret 返回到初始进程的第一条指令之前处理它。

接下来,让我们看看 freeproc。如果出现任何问题,我们将调用 freeproc。当我们完成一个进程时,我们也会调用 freeproc。那么它会做什么呢?它接收一个指向我们要放弃的进程的指针。它查看陷阱帧指针。如果它不是 null,那么它指向某个东西,所以将其返回到空闲池。这在这里发生。将陷阱帧设置为 null。然后它查看页表指针。它查看这里指向页表的指针,它需要基本上销毁那个页表。我们将讨论 proc_pagetableproc_freepagetable。但基本上,它将调用 proc_freepagetable 来撤销那个页表并将所有内容返回到空闲池。然后它清零一些剩余的字段。这并非严格必要,但它做了所有这些。在 name 字段中设置为 null 等等。但最重要的是,它将状态设置为 UNUSED。在这一点上,这个进程结构是未使用的。

回到 allocproc 函数,我们找到了一个 proc 结构,并设置了陷阱帧。然后我们调用 proc_pagetable 来创建一个空的用户页表。现在让我们看看 proc_pagetable。它为给定的进程创建一个用户页表。它接收一个指向 proc 结构的指针。它将设置页表并返回一个指向该页表的指针,正如我们在 allocproc 中看到的,它将被存储在 pagetable 字段中。那么它做什么呢?我们已经介绍过 uvmcreate 来创建一个空的页表。所以那只是创建用户页表。如果有任何问题,我们返回 0。接下来,我们为蹦床页面创建一个映射。蹦床地址是虚拟地址空间中的最高页,长度为一页。我们将其映射到蹦床页面。这是内核代码区域中的页面,标记为可读和可执行。我们已经讨论过 mappages。所以这为蹦床页面向页表添加了一个映射。如果出现问题,那么我们释放已创建的页表并返回。接下来,我们将陷阱帧映射到第二高页。我们再次调用 mappages。陷阱帧地址是第二高页,同样长度为一页。那个页面在哪里?我们之前通过调用 kalloc 分配了页面,并将 trapframe 设置为指向物理内存中的物理页面,这就是我们在这里使用的,trapframe,我们使其可读和可写。如果出现任何问题,我们取消映射蹦床页面,然后再次释放页表并返回 0。

那么相反的操作,proc_freepagetable 呢?我们已经在关于虚拟内存函数的视频中讨论过这些函数。我们基本上移除蹦床页面的映射,而不释放数据页面本身。我们移除陷阱帧页面的映射,而不释放陷阱帧页面本身。然后我们调用 uvmfree 来完全销毁页表,将所有数据页面返回到空闲池,并将所有索引页面返回到空闲池。我们在哪里调用这个?在 freeproc 中。这是我们的函数 freeproc。你看,我们已经在这里释放了陷阱帧页面,所以这就是为什么我们在这里不要求释放它,因为我们已经释放了它。我们在这里调用 proc_freepagetable 并将 pagetable 设置为 null。这就是我们使用 proc_freepagetable 的地方。

现在,让我们谈谈这个函数 userinituserinit 被调用来设置第一个用户进程。让我们逐步查看代码。它不太长。它做的第一件事是调用 allocproc 来找到一个 proc 结构并进行设置。现在我们有了一个指向 proc 结构的指针。这被称为 initproc。我们将保存一个指向它的指针。接下来,我们将分配一个单独的页面,并复制初始进程的代码。这里我们调用 uvminit,我们之前讨论过,但它接收一个指向页表的指针。allocproc 应该已经设置了这个页表,但还没有填充任何代码或数据。uvminit 被传递了一些字节的指针和字节数。那是什么?这是 initcode。这是这个数组,它包含字节。我想它们数出了 52 个字节。这里我们将把这 52 个字节复制到这个页表指向的虚拟地址空间的第 0 页。我们还将设置 size 字段。这是 proc 结构中的一个字段,它告诉虚拟地址空间从 0 到断点的大小,这里我们只有一个页面,所以不是很大。

为第一次从内核返回到用户做准备。这是陷阱帧。epc 是我们保存程序计数器的地方。当正在运行的用户进程中发生陷阱时,我们保存程序计数器,我们把它保存在这里,我们也保存所有寄存器。我们保存所有通用寄存器,包括栈指针。当我们准备返回到用户进程时,我们在恢复所有寄存器后,返回到这个程序计数器处执行。我们在这里做的是将程序计数器设置为 0。因此,对于这个进程,并且仅对于这个进程,我们将从 0 开始执行它。对于所有其他进程,当它们第一次开始执行时,它们是由于 fork 构造而创建的,它们将在 fork 系统调用之后直接开始执行。所以我们不需要为其他进程这样做。但对于第一个进程,init 进程,我们将其程序计数器设置为 0。我们还将它的栈指针设置为页面大小。请记住,初始进程将恰好有一个页面,其中将包含代码和栈。通过将 sp 设置为页面大小,我们将其设置为该初始页面的顶部。因此,希望初始进程不会增长它的栈太多。事实上,它根本不会增长。但我们无论如何都设置了它。最后,我们复制到 proc 结构的 name 字段中。这是 proc 结构,我们这里有这个 name 字段。它是一个固定数量的字节,一个小的字节数。我们正在复制这些字符。safestrcpy 是一个将字符串从一个地方复制到另一个地方的函数,它不会溢出目标区域。因此,我们通过这里的 sizeof 参数传入要复制的最大字符数。这样我们就不会意外地溢出这个数组。我们还有几个其他字段,proc 结构中的当前工作目录被初始化为指向这个斜杠。这就是那个。最后,我们设置状态。将状态设置为 RUNNABLE 并释放锁。请记住,allocproc 返回时锁是设置的。现在我们已经分配了一个 proc 结构,初始化了它的页表和所有内容,一切都准备好了。我们将其状态设置为 RUNNABLE,我们就完成了。运行这个的线程可以忽略它。所以我们只需释放锁。然后调度器在寻找要运行的东西时会找到这个进程,它会看到它是可运行的,就会调度它,初始代码就会开始执行。当然,它做的事情不多,只是查看这里的代码。这是代码。当然,你无法阅读它,因为它是 RISC-V 处理器的机器代码。无论如何,它做的是调用 exec 系统调用,传递一个字符串 /init,这就是它所做的一切。我们基本上获取这个代码。这是 UNIX 命令 init。基本上我们对其进行 objdump 并将其放入一个文件,然后复制到这里。

就是这样。我将在未来的视频中介绍这个文件 proc.c 的其余部分。

22:系统调用剖析 🧠

在本节课中,我们将详细剖析 XV6 操作系统中一个系统调用从用户态发起,到内核处理,再返回用户态的完整过程。我们将以 sbrk 系统调用为例,逐步跟踪其执行路径,理解其中涉及的代码文件、函数和关键机制。


概述

系统调用是用户程序请求操作系统内核服务的接口。当用户程序执行 ecall 指令时,会触发一个从用户态到内核态的“陷阱”。内核接管后,根据特定的系统调用号执行相应服务,处理完毕后,再通过 sret 指令返回用户态。整个过程涉及用户态存根、陷阱处理、参数传递和内核服务函数等多个环节。


用户态入口:user.h 与存根函数

任何想要发起系统调用的用户模式程序都需要包含 user.h 头文件。该文件为每个系统调用提供了函数原型。

例如,sbrk 系统调用的原型如下:

int sbrk(int n);

它接收一个参数 n,表示堆内存需要增长(正数)或缩小(负数)的字节数。返回值为调整前堆的大小。

那么,当用户程序调用 sbrk() 时,实际执行的是哪个函数呢?答案是位于 usys.S 文件中的一段简短的汇编语言存根函数。


汇编存根:usys.S

usys.S 是一个汇编语言文件,为 21 个系统调用中的每一个都提供了一个存根函数。这些函数由 Perl 脚本自动生成。

每个存根函数的核心逻辑相同,以 sbrk 为例:

.global sbrk
sbrk:
 li a7, SYS_sbrk
 ecall
 ret

这段代码执行以下操作:

  1. li a7, SYS_sbrk:将系统调用号(对于 sbrk 是 12)加载到寄存器 a7 中。这是内核识别用户请求哪个系统调用的关键。
  2. ecall:执行环境调用指令,触发从用户态到内核态的陷阱。
  3. ret:从函数返回。此时,内核已将返回值放入寄存器 a0

在调用存根函数时,系统调用的参数已按约定存放在寄存器 a0a5 中。ecall 指令不会改变这些寄存器的值,因此内核可以读取它们来获取参数。同样,内核会将返回值写入 a0 寄存器,供用户程序在 ecall 返回后使用。


陷阱处理流程:从 ecallsret

ecall 指令执行后,处理器会陷入内核态,并开始执行一系列预设的陷阱处理代码。整个流程可以概括为:

  1. 陷入内核ecall 指令将模式切换为内核模式,禁用中断,保存程序计数器(PC),并跳转到 uservec 函数。
  2. 保存上下文uservec 位于“蹦床”页面,它保存用户寄存器,加载内核寄存器,并切换到内核的虚拟地址空间。
  3. 陷阱原因分发:随后跳转到 C 函数 usertrapusertrap 会检查陷阱原因(通过 scause 寄存器)。对于系统调用,scause 的值为 8。
  4. 执行系统调用usertrap 识别出是系统调用后,会调用 syscall 函数。
  5. 恢复与返回syscall 返回后,usertrap 调用 usertrapret,后者再调用汇编函数 userretuserret 负责恢复用户寄存器,切换回用户地址空间,最后执行 sret 指令。sret 指令将模式切换回用户模式,并从之前保存的 PC(此时已指向存根函数中的 ret 指令)处恢复执行。

上一节我们介绍了用户态如何发起调用以及整体的陷阱流程,本节中我们来看看内核是如何分发和执行具体的系统调用的。


内核分发:syscall 函数

usertrap 函数确定陷阱原因为系统调用后,会调用 syscall 函数(位于 syscall.c)。这是系统调用的核心分发器。

以下是 syscall 函数的关键步骤:

  1. 获取系统调用号:从当前进程的陷阱帧(trapframe)中读取用户之前存入 a7 寄存器的系统调用号。
    int num = p->trapframe->a7;
    
  2. 查询并调用处理函数:使用该系统调用号作为索引,查询一个名为 syscalls 的函数指针数组。该数组在文件开头初始化,将每个系统调用号映射到对应的内核处理函数(例如,sys_sbrk)。
    // syscalls 数组示例
    static uint64 (*syscalls[])(void) = {
        [SYS_fork]    sys_fork,
        [SYS_exit]    sys_exit,
        // ...
        [SYS_sbrk]    sys_sbrk, // 索引 12
        // ...
    };
    // 调用对应的系统调用函数
    p->trapframe->a0 = syscalls[num]();
    
  3. 错误检查与返回:在调用前,会检查系统调用号是否有效(在数组范围内且对应的函数指针非空)。如果无效,则将返回值 a0 设置为 -1 并打印错误信息。如果有效,则执行对应的函数,并将其返回值存入陷阱帧的 a0 寄存器,这将成为返回用户态后的返回值。

参数传递与辅助函数

内核的系统调用处理函数(如 sys_sbrk)如何获取用户传递的参数呢?它们依赖一组辅助函数。

以下是主要的参数获取辅助函数:

  • argraw(int n):直接从陷阱帧中返回第 n 个参数寄存器(a0-a5)的值。
  • argint(int n, int *ip):调用 argraw 获取第 n 个参数,并将其存储到指针 ip 指向的整数中。
  • argaddr(int n, uint64 *ip):与 argint 类似,但用于获取 64 位地址参数。
  • argstr(int n, char *buf, int max):获取第 n 个参数(一个指向字符串的指针),并从用户地址空间将该字符串安全地拷贝到内核缓冲区 buf 中,最多拷贝 max 个字符。

这些函数的核心是安全地从用户虚拟地址空间读取数据,使用了如 copyincopyinstr 等虚拟内存辅助函数,并会进行边界检查,以防止用户程序传递非法指针导致内核崩溃或安全漏洞。


具体执行:sys_sbrkgrowproc

现在,我们来看 sbrk 系统调用的具体实现函数 sys_sbrk(位于 sysproc.c)。

uint64 sys_sbrk(void)
{
  int addr;
  int n;
  // 获取用户传递的参数 n
  if(argint(0, &n) < 0)
    return -1;
  // 获取当前堆的大小(即进程的 sz 字段)
  addr = myproc()->sz;
  // 调用 growproc 来调整内存大小
  if(growproc(n) < 0)
    return -1;
  // 返回调整前的堆大小
  return addr;
}

其工作流程如下:

  1. 使用 argint(0, &n) 从用户陷阱帧的 a0 寄存器获取要调整的字节数 n
  2. 记录当前进程地址空间的大小(myproc()->sz),作为返回值。
  3. 调用 growproc(n) 函数来实际调整内存。
  4. 如果 growproc 成功,返回旧的地址空间大小;如果失败(如内存不足),返回 -1。

真正的内存调整工作由 growproc 函数(位于 proc.c)完成:

int growproc(int n)
{
  struct proc *p = myproc();
  uint64 sz = p->sz;
  if(n > 0){
    // 扩大内存
    if((sz = uvmalloc(p->pagetable, sz, sz + n)) == 0) {
      return -1;
    }
  } else if(n < 0){
    // 缩小内存
    sz = uvmdealloc(p->pagetable, sz, sz + n);
  }
  p->sz = sz;
  return 0;
}
  • 如果 n > 0,则调用 uvmalloc 分配新的物理页并映射到用户地址空间。
  • 如果 n < 0,则调用 uvmdealloc 取消映射并释放物理页。
  • 最后更新进程结构体中的 sz 字段。

总结

本节课中我们一起学习了 XV6 系统调用的完整解剖过程:

  1. 用户侧发起:用户程序调用 user.h 中声明的函数,实际执行的是 usys.S 中的汇编存根。存根将系统调用号存入 a7,参数存入 a0-a5,然后执行 ecall
  2. 陷入内核ecall 触发陷阱,硬件切换到内核态,跳转到 uservec 保存上下文,再进入 usertrap
  3. 内核分发usertrap 根据 scause 识别系统调用,调用 syscall 函数。syscall 根据 a7 中的号码,从 syscalls 数组中找到对应的处理函数(如 sys_sbrk)并执行。
  4. 参数与执行:处理函数通过 argint 等辅助函数安全获取用户参数,并完成具体功能(如 growproc 调整内存)。
  5. 返回用户:返回值被置入 a0。控制流经由 usertrapretuserret 返回,最终执行 sret 指令,恢复用户态执行。

这个过程清晰地展示了用户态与内核态之间受控的边界跨越、参数的标准化传递以及内核服务的结构化分发,是理解操作系统如何为用户程序提供支持的关键。

23:Fork 系统调用 🧬

在本节课中,我们将要学习 XV6 操作系统中 fork 系统调用的工作原理。fork 是进程创建的唯一方式(除了初始进程的创建)。我们将详细探讨父进程如何克隆自身以创建子进程,以及内核在背后执行了哪些关键步骤。

概述

fork 系统调用允许一个进程(父进程)创建另一个几乎完全相同的进程(子进程)。调用成功后,两个进程都会从系统调用返回,但返回值不同,这使得它们能够区分自己是父进程还是子进程,并执行不同的代码路径。

进程创建的核心步骤

当父进程调用 fork 时,内核会执行一系列操作来创建子进程。以下是这些核心步骤的分解。

1. 分配进程结构

首先,内核调用 allocproc 函数。该函数在进程表中寻找一个空闲的 proc 结构体并占用它,开始初始化过程。

  • 分配新进程ID (PID):为新进程分配一个唯一的标识符。
  • 创建空地址空间:为新进程初始化一个空的虚拟地址空间。
  • 映射核心页面:为 trampolinetrapframe 页面建立映射,这是进程陷入内核和返回用户空间所必需的。

2. 复制虚拟地址空间

接下来,内核需要复制父进程的虚拟地址空间。这是通过 uvmcopy 函数完成的。

  • 复制数据页:父进程的每一个数据页都会被复制,并添加到子进程的虚拟地址空间中。
  • 复制权限:子进程中每个数据页的权限设置与父进程中对应页面的权限完全相同。

3. 初始化进程控制块

然后,内核初始化子进程 proc 结构体中的其他关键字段。

  • 复制地址空间大小 (sz):这个字段表示虚拟地址空间的大小(以字节为单位),它等同于堆顶的 break 地址。该值从父进程复制而来。
  • 复制陷阱帧 (trapframe):父进程进行系统调用时,其所有用户态寄存器(包括程序计数器 PC)的值都保存在其陷阱帧中。通过复制陷阱帧,子进程在被调度运行时,其所有寄存器将拥有与父进程调用 fork 时完全相同的值,并从 ecall 指令之后的位置开始执行。
  • 修改返回值寄存器 (a0):在子进程的陷阱帧中,将寄存器 a0 的值设置为 0。这样,当子进程从 fork 系统调用返回时,看到的返回值是 0。而在父进程中,a0 保持不变,返回的是子进程的 PID。
  • 复制进程名:子进程获得与父进程相同的名称。
  • 复制文件描述符表:父进程所有已打开文件的文件描述符都会被复制到子进程中。这意味着在父进程中打开的任何文件,在子进程中同样处于打开状态。
  • 复制当前工作目录:子进程继承父进程的当前工作目录。
  • 设置父进程指针:在子进程的 proc 结构体中,parent 指针被设置为指向父进程的 proc 结构体。这是子进程与父进程在进程父子关系层次结构中的主要区别。
  • 设置为可运行状态:最后,将子进程的状态设置为 RUNNABLE。至此,子进程创建完成,一旦调度器有机会,就会调度它运行。

如果上述任何步骤失败(例如内存不足),fork 系统调用会在父进程中返回 -1

代码解析

上一节我们介绍了 fork 的理论步骤,本节中我们来看看在 XV6 的 proc.c 文件中,fork 函数是如何具体实现的。

系统调用处理函数 sys_fork 会直接调用 fork 函数并返回其结果。fork 函数的主要逻辑如下:

  1. 获取父进程指针:首先获取当前(父)进程的 proc 结构体指针。
  2. 调用 allocproc:尝试分配一个新的进程结构体。如果失败,则返回 -1
  3. 复制地址空间:调用 uvmcopy 复制父进程的所有数据页到子进程。如果复制失败,则释放刚分配的进程结构体并返回 -1
  4. 复制关键数据
    • 复制地址空间大小 (sz)。
    • 复制陷阱帧 (trapframe),这包括了所有用户态寄存器。
    • 在子进程的陷阱帧中,将 a0 寄存器清零。
  5. 复制资源
    • 遍历父进程的打开文件表,为每个打开的文件复制文件描述符到子进程。
    • 复制当前工作目录的引用。
    • 复制进程名称。
  6. 处理父子关系与锁
    • 获取子进程的 PID 并保存到一个局部变量中(原因后述)。
    • 为了修改子进程的 parent 指针,需要获取一个全局的 wait_lock这里有一个关键操作:在获取 wait_lock 之前,必须先释放子进程 proc 结构体上的锁,这是为了避免死锁。
    • 获取 wait_lock,设置子进程的 parent 指针指向父进程,然后释放 wait_lock
  7. 完成创建:将子进程状态设为 RUNNABLE,释放其 proc 结构体锁,最后返回之前保存的子进程 PID。

关于死锁的说明

为什么需要先释放子进程锁再获取 wait_lock?考虑一个典型的死锁场景:进程 A 持有锁 L1 并试图获取锁 L2,同时进程 B 持有锁 L2 并试图获取锁 L1,双方都无法继续执行。

fork 中,我们持有子进程的锁,然后需要获取 wait_lock。而在 exit(进程退出)和 wakeup(唤醒父进程)函数中,代码路径是先持有 wait_lock,然后尝试获取特定子进程的锁。如果 fork 不释放子进程锁就直接获取 wait_lock,就可能与正在执行 exit 的进程形成上述的循环等待,导致死锁。因此,释放锁再获取的顺序至关重要。

另外,在释放锁之前将 PID 保存到局部变量,是因为一旦锁被释放,这个 proc 结构体可能立即被调度、退出并被新的 fork 重用,从而分配新的 PID。如果之后再去读取 pid 字段,可能会得到错误的(新的)PID。

子进程的首次执行

子进程创建后处于 RUNNABLE 状态,但它如何开始执行呢?这与普通的进程调度返回略有不同。

当调度器最终选择子进程运行时,它会调用 swtch 进行上下文切换。swtch 会从子进程的 context 中加载寄存器并返回。关键在于,allocproc 函数在初始化子进程时,将其 context 中的返回地址寄存器 ra 设置为了一个特殊函数 forkret 的地址,同时设置了内核栈指针 sp

因此,当 swtch “返回”时,它实际上跳转到了 forkret 函数。forkret 函数只做两件事:

  1. 释放当前进程(即子进程)的 proc 结构体锁。
  2. 调用 usertrapret

调用 usertrapret 后,流程就与一次普通的中断或系统调用返回完全一致了:它从子进程的陷阱帧中恢复所有用户态寄存器(其中 a0 已被设为 0),然后通过 sret 指令返回到用户空间。由于陷阱帧是父进程的副本,子进程将从父进程调用 forkecall 指令之后的那条指令开始执行,就像它自己刚刚完成了 fork 调用一样。

总结

本节课中我们一起学习了 XV6 的 fork 系统调用。我们了解到:

  • fork 通过克隆父进程来创建子进程,是进程创建的核心机制。
  • 内核需要执行分配结构体、复制地址空间、复制文件描述符、设置父子关系等一系列复杂步骤。
  • 代码实现中需要精心处理锁的顺序,以避免死锁。
  • 子进程通过一个特殊的 forkret 路径完成初始化,并最终返回到用户空间,使其看起来像是从 fork 调用中返回。

理解 fork 是理解进程模型和后续如 execwaitexit 等系统调用的重要基础。

24:Exit、Wait、Kill 系统调用 🖥️

概述

在本节课中,我们将要学习 xv6 操作系统中用于进程终止和管理的三个核心系统调用:exitwaitkill。我们将探讨进程如何终止、父进程如何回收子进程资源,以及如何强制终止一个进程。理解这些机制是理解操作系统进程生命周期管理的基础。

Exit 系统调用

上一节我们介绍了进程的基本概念,本节中我们来看看进程如何结束自己的生命。exit 系统调用用于终止一个进程。

  • 功能exit 终止调用它的进程。
  • 参数:一个整数状态码。在 Unix/Linux 和 xv6 中,状态码 0 通常表示成功,非零值表示错误。
  • 核心行为
    1. 进程停止执行指令。
    2. 如果存在正在等待(wait)的父进程,则唤醒父进程,并将状态码传递给父进程。
    3. 进程自身变为“僵尸”(Zombie)状态。僵尸进程已停止运行,但其进程结构体(proc)仍被保留,用于存放退出状态,等待父进程回收。
    4. 如果父进程没有在等待,则终止的进程必须保持僵尸状态,直到父进程调用 wait
    5. 如果父进程先于子进程退出,子进程会被“重新指定父进程”(reparent)给初始进程 initinit 进程会负责回收这些孤儿进程。

以下是 exit 系统调用在 xv6 内核中的主要代码逻辑框架:

void exit(int status) {
    // 关闭进程打开的文件
    for(int fd = 0; fd < NOFILE; fd++) {
        if(proc->ofile[fd]) {
            // 关闭文件描述符
        }
    }
    // 进程状态变为 ZOMBIE
    proc->state = ZOMBIE;
    // 保存退出状态
    proc->xstate = status;
    // 如果有子进程,将它们重新指定给 init 进程
    reparent(proc);
    // 唤醒可能正在等待的父进程
    wakeup(proc->parent);
    // 跳转到调度器,此进程永远不会再被调度
    sched();
}

Wait 系统调用

了解了进程如何终止后,我们来看看父进程如何得知子进程的结束并清理资源。wait 系统调用用于父进程等待子进程终止。

  • 功能wait 使父进程等待任意一个子进程结束,并获取其退出状态。
  • 参数:一个用于存放子进程退出状态码的内存地址指针。
  • 返回值:返回终止子进程的进程 ID(PID)。如果没有子进程,则返回 -1。
  • 核心行为
    1. 父进程遍历进程表,寻找状态为 ZOMBIE 的子进程。
    2. 如果找到僵尸子进程,则获取其退出状态码,复制到参数指定的用户空间地址,然后释放该子进程的 proc 结构体,最后返回该子进程的 PID。
    3. 如果没有僵尸子进程,但存在活着的子进程,则父进程调用 sleep 函数进入睡眠状态,等待子进程退出。
    4. 当子进程调用 exit 时,会调用 wakeup 唤醒正在睡眠的父进程。父进程被唤醒后,再次执行步骤1。

以下是 wait 系统调用在 xv6 内核中的主要逻辑:

int wait(uint64 addr) {
    // 循环等待子进程退出
    for(;;) {
        // 遍历所有进程,寻找当前进程的子进程
        for(np = proc; np < &proc[NPROC]; np++) {
            if(np->parent == proc) { // 找到子进程
                acquire(&np->lock);
                if(np->state == ZOMBIE) { // 子进程已终止
                    // 复制退出状态到用户空间 addr
                    copyout(..., addr, np->xstate, ...);
                    // 释放子进程资源
                    freeproc(np);
                    release(&np->lock);
                    return np->pid;
                }
                release(&np->lock);
            }
        }
        // 没有找到可回收的僵尸子进程,但有子进程存活,则睡眠
        sleep(proc, &wait_lock);
    }
}

Kill 系统调用

最后,我们学习如何从外部强制终止一个进程。kill 系统调用用于向指定进程发送“终止”信号。

  • 功能kill 请求终止一个具有指定 PID 的进程。
  • 参数:目标进程的 PID。
  • 返回值:成功返回 0,如果 PID 不存在则返回 -1。
  • 核心行为
    1. 根据 PID 找到目标进程的 proc 结构体。
    2. 将该进程的 killed 标志设置为 1(真)。
    3. 如果目标进程正在睡眠(SLEEPING 状态),则将其状态改为可运行(RUNNABLE),并唤醒它。这是为了确保被 kill 的进程能尽快执行到检查 killed 标志的代码路径。
    4. kill 系统调用本身并不立即停止目标进程。目标进程会在下次有机会执行内核代码时(例如,从系统调用返回用户空间前)检查自己的 killed 标志。如果发现被置位,则会调用 exit 来终止自己。

以下是 kill 系统调用在 xv6 内核中的主要逻辑:

int kill(int pid) {
    // 遍历进程表
    for(p = proc; p < &proc[NPROC]; p++) {
        acquire(&p->lock);
        if(p->pid == pid) { // 找到目标进程
            p->killed = 1; // 设置终止标志
            if(p->state == SLEEPING) {
                // 如果进程在睡眠,唤醒它以便其能检查 killed 标志
                p->state = RUNNABLE;
            }
            release(&p->lock);
            return 0;
        }
        release(&p->lock);
    }
    return -1; // 未找到指定 PID 的进程
}

总结

本节课中我们一起学习了 xv6 操作系统中三个关键的进程管理系统调用。

  1. exit:进程主动终止,保存退出状态,变为僵尸进程,并通知或等待父进程。
  2. wait:父进程等待并回收僵尸子进程的资源,获取其退出状态。这是防止僵尸进程残留的必要操作。
  3. kill:向另一个进程发送终止请求。它通过设置标志位并可能唤醒目标进程来实现,实际的终止动作由目标进程在检查到标志后调用 exit 来完成。

这三个系统调用协同工作,构成了 xv6 进程从创建、运行到终止和清理的完整生命周期管理机制。理解它们对于掌握操作系统的进程模型至关重要。

25:休眠锁(Sleeplocks)

概述

在本节课中,我们将学习 xv6 操作系统内核中的休眠锁(Sleep Locks)。我们将了解休眠锁与自旋锁(Spin Locks)的区别,并详细解析休眠锁的数据结构、初始化、获取、释放以及调试功能。休眠锁允许进程在持有锁时进入睡眠状态,适用于需要长时间持有锁的场景。

休眠锁与自旋锁的对比

上一节我们介绍了自旋锁,它要求锁的持有时间必须非常短,并且在持有期间不允许进程进入睡眠状态。本节中我们来看看休眠锁,它解决了自旋锁在长时间持有锁时的局限性。

自旋锁有两个主要函数:acquirerelease。获取自旋锁的函数会在一个紧密循环中等待锁被释放,这适用于多核系统,因为锁通常很快就会被释放。然而,如果需要在获取锁和释放锁之间等待很长时间,或者需要在此期间调用 sleep 函数让进程挂起,自旋锁就不适用了,因为它会占用 CPU 核心进行无意义的循环等待。

休眠锁则允许进程在持有锁时进入睡眠状态。在 xv6 的实现中,休眠锁也有对应的获取和释放函数,分别是 acquiresleepreleasesleep

休眠锁的数据结构

以下是休眠锁的核心数据结构,它包含四个字段:

struct sleeplock {
    uint locked;       // 锁状态:1 表示被持有,0 表示空闲
    struct spinlock lk; // 保护本结构体的自旋锁
    char *name;        // 锁的名称,用于调试
    int pid;           // 持有锁的进程 ID
};
  • locked 是一个布尔值,表示锁是否被持有。
  • lk 是一个自旋锁,用于保护 lockedpid 字段的并发访问。
  • name 是锁的名称,仅用于调试。
  • pid 记录当前持有锁的进程 ID。

休眠锁的函数实现

以下是休眠锁相关函数的实现细节。

初始化函数 initsleeplock

此函数用于初始化一个休眠锁结构体。

void initsleeplock(struct sleeplock *lk, char *name) {
    initlock(&lk->lk, "sleep lock");
    lk->name = name;
    lk->locked = 0;
    lk->pid = 0;
}

它初始化了保护锁的自旋锁 lk,设置了锁的名称 name,并将锁状态 locked 和持有者进程 ID pid 清零,表示锁处于未持有状态。

获取锁函数 acquiresleep

此函数用于获取一个休眠锁。

void acquiresleep(struct sleeplock *lk) {
    acquire(&lk->lk); // 获取保护锁的自旋锁
    while (lk->locked) { // 如果休眠锁已被持有
        sleep(lk, &lk->lk); // 进入睡眠,等待锁被释放
    }
    lk->locked = 1; // 标记锁为已持有
    lk->pid = myproc()->pid; // 记录当前进程 ID
    release(&lk->lk); // 释放保护锁的自旋锁
}
  1. 首先获取保护休眠锁结构的自旋锁 lk->lk
  2. 检查 locked 字段。如果锁已被其他进程持有(locked == 1),则调用 sleep 函数让当前进程进入睡眠状态。sleep 函数会原子性地释放自旋锁 lk->lk 并使进程睡眠,以避免错过唤醒信号。
  3. 当进程被唤醒并重新获得自旋锁后,再次检查 locked 字段。如果锁已空闲,则跳出循环。
  4. locked 设置为 1,并记录当前进程的 ID 到 pid 字段。
  5. 最后释放保护用的自旋锁 lk->lk

释放锁函数 releasesleep

此函数用于释放一个休眠锁。

void releasesleep(struct sleeplock *lk) {
    acquire(&lk->lk); // 获取保护锁的自旋锁
    lk->locked = 0; // 标记锁为空闲
    lk->pid = 0; // 清除持有者进程 ID
    wakeup(lk); // 唤醒所有等待此锁的进程
    release(&lk->lk); // 释放保护锁的自旋锁
}
  1. 获取保护休眠锁结构的自旋锁 lk->lk
  2. locked 字段设置为 0,表示锁已空闲。
  3. pid 字段清零。
  4. 调用 wakeup(lk) 唤醒所有正在等待此休眠锁(通过相同的通道地址 lk 标识)的进程。
  5. 释放保护用的自旋锁 lk->lk

调试函数 holdingsleep

此函数用于检查当前进程是否持有指定的休眠锁,主要用于错误检测。

int holdingsleep(struct sleeplock *lk) {
    int r;
    acquire(&lk->lk); // 获取保护锁的自旋锁
    r = lk->locked && (lk->pid == myproc()->pid);
    release(&lk->lk); // 释放保护锁的自旋锁
    return r;
}
  1. 获取保护休眠锁结构的自旋锁 lk->lk
  2. 检查条件:锁被持有(locked == 1)且持有者进程 ID 等于当前进程的 ID。
  3. 释放保护用的自旋锁 lk->lk
  4. 返回检查结果。如果返回假(0),内核可能会触发 panic 以指示错误。

总结

本节课中我们一起学习了 xv6 操作系统内核中的休眠锁。我们了解了休眠锁与自旋锁的关键区别:休眠锁允许进程在持有锁时进入睡眠状态,适用于需要长时间操作的临界区。我们详细分析了休眠锁的数据结构,它包含一个用于内部保护的自旋锁。我们还逐步解析了休眠锁的初始化、获取、释放以及用于调试的检查函数的工作原理。掌握休眠锁是理解 xv6 中涉及长时间等待或 I/O 操作的同步机制的重要一步。

26:内核模式下的陷阱处理

概述

在本节课中,我们将要学习当 xv6 操作系统内核在内核模式下执行时,如果发生陷阱(例如设备中断或时钟中断),系统是如何处理的。我们将分析相关的汇编代码和 C 语言函数,理解寄存器保存、中断处理以及进程调度的具体流程。

内核模式陷阱处理流程回顾

上一节我们介绍了用户模式下的陷阱处理流程。本节中我们来看看内核模式下的情况。当内核代码执行时发生陷阱,硬件会跳转到 kernelvec 汇编代码处,而不是 uservec

内核模式下的陷阱处理与用户模式有一个关键区别:寄存器被保存在当前内核栈上,而不是进程的固定陷阱帧中。这是因为内核在执行时已经拥有一个有效的栈。

内核陷阱入口:kernelvec

首先,我们查看 kernelvec.S 中的汇编代码,这是内核模式陷阱发生后首先执行的地方。

.globl kernelvec
.align 4
kernelvec:
    # 在内核栈上分配256字节空间
    addi sp, sp, -256
    # 保存所有通用寄存器(除了始终为0的x0寄存器)
    sd ra, 0(sp)
    sd sp, 8(sp)
    sd gp, 16(sp)
    # ... 保存其他寄存器 t0-t6, s0-s11, a0-a7
    # 调用C语言陷阱处理函数
    call kerneltrap
    # 恢复所有通用寄存器
    ld ra, 0(sp)
    ld sp, 8(sp)
    ld gp, 16(sp)
    # ... 恢复其他寄存器
    # 释放栈空间并返回
    addi sp, sp, 256
    sret

这段代码首先在内核栈上预留空间,然后保存所有通用寄存器的状态。接着,它调用C函数 kerneltrap 进行具体的陷阱处理。处理完毕后,恢复寄存器状态,并通过 sret 指令返回到被中断的内核代码继续执行。

注意tp(线程指针)寄存器在此过程中不会被恢复。因为陷阱(如时钟中断)可能导致进程被重新调度到不同的CPU核心上执行,而 tp 寄存器用于标识当前运行的核心,应由调度器在恢复进程时正确设置。

核心陷阱处理函数:kerneltrap

kerneltrap 函数(位于 trap.c 中)是内核模式陷阱处理的核心。它负责诊断陷阱原因并分发给相应的处理程序。

以下是该函数的关键步骤:

  1. 保存关键状态寄存器:首先保存发生陷阱时的程序计数器(sepc)、状态寄存器(sstatus)和原因寄存器(scause)的值。这些信息对于返回和诊断至关重要。
  2. 完整性检查:确认陷阱发生时确实处于内核模式(通过检查 sstatus 中的特权模式位),并确认中断当时是禁用的。
  3. 调用设备中断处理:调用 devintr() 函数来判断陷阱的具体类型。
  4. 根据返回值处理
    • 如果返回 2,表示是时钟中断,则调用 yield() 函数让出CPU。
    • 如果返回 1,表示是设备中断(如UART或磁盘),devintr() 内部已调用相应设备的中断处理程序。
    • 如果返回 0,表示是未知原因,则打印错误信息并触发内核恐慌(panic)。
  5. 恢复与返回:最后,恢复之前保存的 sepcsstatus 寄存器值,然后返回到 kernelvec 汇编代码,由后者完成最终的寄存器恢复和 sret 返回。

一个重要的检查:在因时钟中断调用 yield() 之前,代码会检查当前进程的状态是否为 RUNNING。这是因为中断也可能发生在调度器代码本身(而非某个进程)执行时。例如,当调度器循环寻找可运行进程并临时打开中断时,就可能被中断。此时没有进程处于 RUNNING 状态,不应调用 yield()

设备中断分发函数:devintr

devintr 函数负责解析 scause 寄存器,以确定具体的中断来源。

其逻辑如下:

  1. 读取 scause 寄存器。
  2. 判断中断类型:
    • 如果是外部中断,则来源于平台级中断控制器(PLIC),通常是UART或磁盘设备。函数会查询PLIC是哪个设备触发的,然后调用对应的中断处理程序(uartintrvirtio_disk_intr),最后告知PLIC中断已处理,并返回值 1
    • 如果是软件中断,在xv6中,这由机器模式代码在收到时钟中断后模拟产生,因此代表时钟中断。函数会清除中断等待位,如果是核心0,还会更新全局时钟滴答数(ticks)。最后返回值 2
    • 其他情况返回值 0

时钟滴答数 ticks 由一个自旋锁 tickslock 保护,对其进行累加操作时需要先获取锁。

总结

本节课中我们一起学习了 xv6 内核模式下的陷阱处理机制。关键点在于:

  1. 内核陷阱使用当前内核栈来保存上下文。
  2. 处理入口是 kernelvec 汇编代码,它保存/恢复寄存器并调用 kerneltrap
  3. kerneltrap 函数进行状态保存、原因诊断,并分发给设备中断处理或调度器(yield)。
  4. devintr 函数具体区分设备中断和时钟中断。
  5. 处理过程需要仔细管理中断的禁用与启用状态,并考虑调度器上下文等特殊情况。

理解内核模式下的陷阱处理,对于掌握操作系统的并发控制、中断响应和进程调度至关重要。

27:平台级中断控制器 (PLIC) 🎯

在本节课中,我们将要学习平台级中断控制器(Platform Level Interrupt Controller,简称 PLIC)的工作原理。PLIC 是 RISC-V 处理器生态系统中一个关键的硬件电路,负责管理和路由来自各种输入/输出(I/O)设备的中断信号到处理器核心。理解 PLIC 是理解操作系统如何处理硬件中断的基础。

概述:中断路由问题

当 I/O 设备需要中断一个处理器核心时会发生什么?这是一个核心问题。设想我们有一些 I/O 设备,如磁盘、键盘、UART 或网卡,以及多个处理器核心。当设备需要关注时(例如,用户按下键盘或磁盘完成读写操作),它会发送一个中断信号。这个信号需要被路由到某个核心,触发该核心上的陷阱(trap),从而运行相应的中断处理程序。中断处理程序会直接与设备通信,处理请求,然后恢复被中断的工作。PLIC 就是负责这个路由过程的硬件。

PLIC 的基本架构与工作流程

上一节我们介绍了中断路由的基本问题,本节中我们来看看 PLIC 是如何具体解决这个问题的。

PLIC 与 I/O 设备、处理器核心以及主内存通过某种互连结构(如总线)连接。设备通过内存映射 I/O 寄存器与核心通信,而 PLIC 本身也是一组内存映射寄存器,供核心进行配置和交互。

中断信号通过专门的线路(电线)从设备传送到 PLIC。信号进入 PLIC 后,首先经过一个称为“网关”(gateway)的组件。网关可以理解为一个比特位,当中断到达时,该位被置为 1,表示有一个中断正在等待处理(pending)。此时,PLIC 会根据一个“使能矩阵”(enable matrix)来决定通知哪些核心。

以下是 PLIC 处理单个中断的基本步骤:

  1. 中断发生:设备发送中断信号,PLIC 网关中对应的“源中断挂起位”(source interrupt pending bit)被置为 1。
  2. 通知核心:PLIC 查询使能矩阵,向所有为该设备使能的核心发送外部中断信号。
  3. 核心响应:如果目标核心的中断是启用的,则会立即发生陷阱,并跳转到陷阱处理程序。
  4. 声明中断:陷阱处理程序的第一件事是向 PLIC “声明”(claim)这个中断。这是通过读取 PLIC 中一个特定的内存映射寄存器来完成的。PLIC 会返回触发中断的设备 ID,并(通常)清除对应的源中断挂起位。
  5. 处理中断:获得设备 ID 的核心会运行对应的设备驱动程序代码,直接与设备交互。
  6. 完成中断:中断处理完毕后,处理程序通过向同一个声明寄存器写入设备 ID 来通知 PLIC 中断处理已完成。
  7. 后续处理:PLIC 收到完成通知后,会再次检查该设备的源中断挂起位。如果该位仍为 1(表示设备又发出了中断请求),则 PLIC 会再次发起中断流程。

中断信号类型:电平敏感与边沿触发

在深入细节之前,我们需要了解设备发送给 PLIC 的两种信号类型,这决定了 PLIC 如何检测中断请求。

  • 电平敏感(Level-sensitive):PLIC 持续监测信号线的电平。当信号线为高电平(1)时,PLIC 就认为有中断请求。中断处理完成后,PLIC 会再次检查,如果线仍然是高电平,则会触发下一次中断。
  • 边沿触发(Edge-triggered):PLIC 监测信号从低电平到高电平的跳变(上升沿)。每次上升沿代表一次中断请求。网关有两种实现方式:
    • 单比特位:上升沿将位置 1,直到中断被处理完成才清零。在此期间的新上升沿被忽略。
    • 计数器:每个上升沿使计数器加 1,每次中断被声明时计数器减 1。处理完成后,如果计数器大于 0,则立即触发下一次中断。

PLIC 的详细机制与配置

现在我们已经了解了 PLIC 的基本流程和信号类型,本节我们来探讨一些更复杂的机制和配置细节。

首先,每个能产生中断的设备都被分配一个唯一的 ID(1 到 1023),ID 0 表示“无设备”。其次,现代处理器核心可能支持多个硬件线程(HART),并且 RISC-V 核心可以在不同的特权模式(如机器模式、监管者模式)下运行。PLIC 规范将每个“目标”(target)定义为(HART, 模式)的组合,最多支持 15872 个目标。

PLIC 引入了优先级阈值的概念来实现中断仲裁:

  • 设备优先级:每个设备被赋予一个优先级数字,数字越大优先级越高。快速设备(如磁盘)通常设置高优先级,慢速设备(如键盘)设置低优先级。
  • 核心阈值:每个目标(核心/模式)有一个优先级阈值。
  • 仲裁规则:一个设备的中断只会发送给一个核心,当且仅当该设备的优先级高于该核心的当前阈值。PLIC 会选择优先级最高的待处理中断,并将其发送给阈值低于该中断优先级且使能了该设备的所有核心中,ID 最小的那个核心。

处理器核心通过读写 PLIC 的内存映射 I/O 寄存器来控制它。这些寄存器占据了物理地址空间中的一大段区域(例如 64 MB),主要包括:

  • 优先级寄存器:为每个设备(ID 1-1023)设置优先级。
  • 阈值寄存器:为每个目标设置优先级阈值。
  • 使能寄存器:一个大的位矩阵,配置每个设备可以向哪些目标发送中断。
  • 挂起寄存器:核心可以读取这些位来查询哪些设备有中断正在挂起。
  • 声明/完成寄存器(每个目标一个):这是最重要的寄存器。读取操作会“声明”一个中断并返回设备 ID;写入一个设备 ID 则“完成”该中断的处理。

在 xv6 与 QEMU 中的具体实现

理论部分已经介绍完毕,本节我们来看看 PLIC 在 xv6 操作系统和 QEMU 模拟器中的具体实现。

在 xv6 运行的 QEMU 环境中,PLIC 是虚拟模拟的。xv6 支持 8 个核心(NCPU),并且只使用监管者模式(supervisor mode)来处理中断,因此总共有 8 个目标。QEMU 模拟了两个主要 I/O 设备:

  • 虚拟 I/O 磁盘(VirtIO disk):设备 ID 1
  • UART(串口):设备 ID 10

xv6 的 PLIC 驱动代码在 plic.c 文件中,非常简洁。它主要包含四个函数:

以下是相关函数的简要说明:

  1. plicinit(): 由核心 0 在启动时调用一次,用于设置两个设备的优先级。

    // 设置磁盘(ID 1)和 UART(ID 10)的优先级为 1
    *(uint32*)(PLIC + UART0_IRQ*4) = 1;
    *(uint32*)(PLIC + VIRTIO0_IRQ*4) = 1;
    
  2. plicinithart(): 每个核心在启动时调用,用于配置本核心的 PLIC。

    int hart = cpuid();
    // 1. 设置使能位:允许磁盘(ID 1)和UART(ID 10)中断本核心
    uint32 enabled = (1 << UART0_IRQ) | (1 << VIRTIO0_IRQ);
    *(uint32*)PLIC_SENABLE(hart) = enabled;
    // 2. 设置本核心的优先级阈值为 0,允许所有优先级的中断
    *(uint32*)PLIC_SPRIORITY(hart) = 0;
    
  3. plic_claim(): 当核心进入设备中断处理程序时调用,用于向 PLIC 声明并获取中断的设备 ID。

    int hart = cpuid();
    int irq = *(uint32*)PLIC_SCLAIM(hart); // 读取声明寄存器
    return irq; // 返回设备ID,若为0则表示本核心未获得中断
    
  4. plic_complete(): 设备中断处理完毕后调用,通知 PLIC 中断已完成。

    int hart = cpuid();
    *(uint32*)PLIC_SCLAIM(hart) = irq; // 向声明寄存器写入设备ID
    

当中断发生时,devintr() 函数会调用 plic_claim()。如果返回非零的设备 ID(1 或 10),则调用相应的设备处理程序(如 uartintr()virtio_disk_intr()),处理完毕后再调用 plic_complete()。如果 plic_claim() 返回 0,说明其他核心已经处理了该中断,则本核心直接返回。

总结

本节课中我们一起学习了平台级中断控制器(PLIC)的核心概念。我们首先了解了 PLIC 如何解决多设备到多核心的中断路由问题。接着,我们剖析了其工作流程:从中断发生、核心通知、声明中断、处理中断到最终完成中断。我们还区分了电平敏感和边沿触发两种中断信号类型。然后,我们深入探讨了优先级、阈值、内存映射寄存器等详细机制。最后,我们通过分析 xv6 内核中简短的 plic.c 代码,看到了 PLIC 在实践中的初始化、声明和完成操作是如何实现的。PLIC 是操作系统与硬件中断交互的关键枢纽,理解它对于掌握操作系统的底层工作原理至关重要。

28:磁盘缓冲区缓存 🗃️

在本节课中,我们将学习 Unix 文件系统的底层实现,特别是磁盘系统和缓冲区缓存机制。我们将从磁盘的基本概念开始,逐步深入到 xv6 内核中管理磁盘块缓存的代码实现。

概述

在本节中,我们将首先了解磁盘如何以“块”为单位进行数据读写,以及操作系统如何通过“缓冲区缓存”来管理这些磁盘块,以提高性能并协调多个进程对同一数据的访问。我们将重点分析 xv6 中的 buf.hbio.c 文件。


磁盘与数据块

上一节我们介绍了课程的整体目标,本节中我们来看看数据存储的基础——磁盘。

磁盘与主内存交换数据的单位不是字节或字,而是一个更大的单位,称为“块”。块是固定大小的字节块。在 xv6 中,块大小由常量 BSIZE 定义为 1024 字节。其他系统(如 Linux)可能使用不同的块大小,但在任何操作系统中,块大小都是固定的。

磁盘可以被视为一系列按编号排列的块,编号从 0 开始,直到某个最大值。其中,编号为 1 的第二个块是特殊的,它包含称为“超级块”的信息。超级块是固定的,包含多个参数,其中一个参数是磁盘的大小或块数,文件系统通过读取它来了解可用空间。

磁盘实际读写字节的另一个单位是“扇区”。块和扇区有时被混用,但它们是不同的概念。通常,扇区大小比块小。例如,在 Unix 中,块大小可能是 4096 字节,而某个磁盘模型的扇区大小可能是 512 字节。通过让操作系统以块为单位工作,我们可以忽略不同磁盘驱动器的扇区大小差异。

每当内核需要读写磁盘时,它都会读写整个块,这将导致设备驱动程序读写多个扇区。例如,如果磁盘的扇区大小为 512 字节,那么每次内核读写 4096 字节时,将导致 8 个扇区的读写操作。

在旋转式磁盘设备上,存在多种延迟,例如移动磁头到其他磁道,或等待磁盘旋转使目标扇区位于读写头下方。通过将扇区组合成块,我们可以确保在读取时,大部分扇区是连续读取的,这显著提高了性能。当然,这也有代价:文件的最后一个块可能只被部分填充,最小文件大小将是块大小(如 4096 字节),可能导致一些空间浪费。


磁盘驱动接口

上一节我们了解了磁盘块的概念,本节中我们来看看 xv6 如何与磁盘交互。

xv6 通过 QEMU 模拟器运行,该模拟器与一个 VirtIO 磁盘设备接口。VirtIO 旨在标准化设备驱动程序与实际硬件之间的接口。xv6 的磁盘驱动程序提供了一个用于读写操作的函数。

这个函数名为 virtio_disk_rw,它可以执行读或写操作,具体由第二个参数决定。第一个参数是指向缓冲区(buf 结构体实例)的指针。该缓冲区包含足够的空间来存储一个块的数据(在 xv6 中是 1024 字节),以及我们想要读写的块号。

在 xv6 系统中,此函数不返回任何错误报告。如果发生错误(如读取失败),该函数内部会处理(例如重读),直到最终获取数据。在模拟器中,磁盘由主机系统上的文件模拟,因此可能不会发生实际错误。

此函数可能会休眠。例如,如果我们想读取一个缓冲区,该函数会启动读取操作,然后进入睡眠状态。当操作完成时,磁盘会引发一个陷阱,磁盘的中断处理程序将被激活,并唤醒睡眠的函数。此时,调用读/写操作的进程将被重新唤醒并返回。

此外,该函数不会重新排序操作,它会严格按照函数被调用的顺序执行操作,这对于原子事务很重要。


缓冲区缓存结构

了解了磁盘接口后,现在我们聚焦于核心的缓存机制——缓冲区缓存。

buf 结构体被用作磁盘块的缓存。在内核启动时,会预分配固定数量的缓冲区,由常量 NBUF 控制。在 xv6 中,恰好有 30 个缓冲区,每个缓冲区都有足够的空间容纳一个数据块(1024 字节)。每个缓冲区还包含其缓存数据对应的磁盘块号,以及其他用于同步的字段。

缓冲区可以是空闲的或正在使用的。我们有一个空闲缓冲区列表,不在列表中的缓冲区正在被使用。

以下是缓冲区的组织方式,它们被组织成一个双向循环链表(也称为环形链表):

  • 有一个特殊的头节点(head),它不包含任何数据,仅用于其 nextprev 指针。
  • 缓存中的缓冲区(例如 30 个)按“最近最少使用”到“最近最多使用”的顺序组织。我们可以通过头节点的 next 指针找到最近最多使用的缓冲区,通过 prev 指针找到最近最少使用的缓冲区。
  • 使用环形链表可以轻松地移除元素,例如,如果一个元素变为最近最多使用,我们可以将其移到列表前端。

让我们详细查看缓冲区的内容:

  • next, prev: 指向链表中其他 buf 结构的指针。
  • refcnt: 引用计数。如果为 0,表示该缓冲区当前未被使用,可以回收重用。如果大于 0,则缓冲区正在使用中。
  • dev: 设备号。在 xv6 中只有一个磁盘设备,所以它基本上是常量 1。
  • blockno: 指示此缓冲区中存储的数据对应磁盘上的哪个块。
  • data: 存储整个数据块的空间(1024 字节)。
  • valid: 标志位,指示 data 字段是否包含来自磁盘的有效数据。如果不包含,当我们需要使用该数据时,必须从磁盘读入。
  • disk: 字段,仅在磁盘驱动程序内部使用(virtio_disk_rw 函数),用于指示磁盘操作是否正在进行。
  • lock: 睡眠锁,用于保护数据以及 validdisk 标志位。

缓冲区通过一个名为 bcache 的结构体进行分配,它包含三个字段:

  • lock: 一个自旋锁。
  • head: 一个 buf 结构体,作为链表的头节点。
  • buf: 一个 buf 结构体数组(在 xv6 中是 30 个元素),这些是实际的缓冲区。

我们不直接访问这个数组,而是通过头节点的指针来访问。该数组仅在初始化时用于分配缓冲区并构建初始的循环链表。自旋锁用于保护整个链表,特别是 nextprevrefcntdevblockno 字段。每次我们想要分配或释放缓冲区时,都需要获取这个自旋锁。


缓存初始化与核心函数

在了解了缓冲区缓存的结构后,本节我们来看看它是如何初始化和运作的。

首先,我们查看 buf.h 文件,它包含了 buf 结构体的定义,其字段与我们之前描述的一致。

接下来,我们查看 bio.c 文件,其中定义了 bcache 结构体。文件顶部的注释说明了缓冲区缓存的用途和接口:

  • 缓冲区缓存是 buf 结构体的链表,用于缓存磁盘块内容。
  • 在内存中缓存磁盘块可以减少磁盘读取次数,并为多个进程使用的块提供同步点。
  • 接口:
    • 要获取特定磁盘块的缓冲区,应调用 bread 函数。
    • 更改缓冲区中的数据后,可以调用 bwrite 将其写回磁盘。
    • 使用完缓冲区后,应调用 brelse 释放它,之后不应再使用该缓冲区。
    • 一次只能有一个进程使用一个缓冲区,因此需要注意不要过长时间持有缓冲区。

初始化函数 binit 在内核启动时被调用。它初始化 bcache 锁,并创建缓冲区的链表。它首先创建一个空链表,其中头节点的 prevnext 指针都指向自身。然后遍历 buf 数组,初始化每个缓冲区的睡眠锁,并将其添加到链表中。refcntdevblockno 字段被隐式初始化为 0。


缓冲区读写与获取

现在,让我们深入核心的缓冲区操作函数。

bread 函数返回一个已上锁的缓冲区,其中包含指定磁盘块的内容。它会搜索缓存,如果找到已缓存该磁盘块的缓冲区,则直接返回。否则,它会分配一个新缓冲区,从磁盘读取数据到该块,然后返回。在返回前,它会获取该缓冲区的睡眠锁,并增加其引用计数。

bread 接收设备号和块号作为参数。它首先调用 bgetbget 会搜索循环缓冲区链表,看是否已有该块的缓存副本。如果有,则返回指向该缓冲区的指针。如果没有,bget 会返回一个指向新分配的(引用计数为 0 的)缓冲区的指针。无论哪种情况,它都会增加引用计数并获取锁。然后,bread 检查 valid 标志。如果是从缓存中找到的现有副本,则 valid 为真。如果是新分配的缓冲区,则 valid 为假,此时需要调用 virtio_disk_rw 从磁盘读取数据,然后将 valid 标志设为 1,最后返回缓冲区指针。

bget 函数首先遍历缓冲区缓存,寻找是否已有包含所需数据的块。如果找到,则返回指向该缓冲区的指针。如果没找到,则分配一个空闲缓冲区。无论哪种情况,它都返回一个指向缓冲区(已上锁且引用计数已增加)的指针。

bget 中,我们首先获取 bcache 锁以保护链表。第一个循环正向遍历链表(跟随 next 指针),寻找设备号和块号都匹配的缓冲区。如果找到,则增加其引用计数,释放 bcache 锁,然后获取该缓冲区自身的睡眠锁,最后返回指针。

如果遍历链表后没有找到匹配项,则需要获取一个当前未使用的缓冲区。此时,我们反向遍历链表(跟随 prev 指针),寻找引用计数为 0 的缓冲区。如果找到,我们将其 devblockno 设置为目标值,将其 valid 标志设为 0(因为它可能包含其他磁盘块的数据),将其引用计数从 0 增加到 1,释放 bcache 锁,然后获取该缓冲区的睡眠锁并返回。如果找不到任何空闲缓冲区,则会触发错误(在预分配足够缓冲区的情况下,这不应发生)。

关于 LRU 列表的维护:xv6 采用的方式是,每次使用完一个缓冲区(即释放时),将其移动到列表的尾部。因此,在 bget 中我们看不到缓冲区位置的改变,这将在 brelse 函数中完成。


缓冲区写回与释放

获取和读取缓冲区后,我们还需要知道如何将修改写回磁盘并释放缓冲区。

bwrite 函数接收一个指向缓冲区的指针,并将该缓冲区中块的内容写回磁盘上的对应位置。每个要写入磁盘的缓冲区必须首先通过调用 bread 函数获取。因此,此时缓冲区已设置好 devblockno 字段,并包含数据块中的 1024 字节数据。此外,bread 函数返回的缓冲区总是处于上锁状态。bwrite 会检查是否持有该缓冲区的锁,然后调用 virtio_disk_rw 函数并传入参数 1 来执行写操作。该操作将缓冲区中的块写回磁盘,并在写操作完成后返回。

使用缓冲区的典型流程是:首先调用 bread 将数据从磁盘读入缓冲区。此时缓冲区已上锁,并包含数据。然后,我们可以修改该块中的部分或全部字节,接着调用 bwrite 将修改后的块写回磁盘。我们还可以继续修改缓冲区中的字节并再次调用 bwrite,因为 bwrite 函数本身不会释放缓冲区。

当我们准备释放缓冲区时,调用 brelse 函数。由于该缓冲区是通过调用 bread 获取的,我们应该持有它的锁。brelse 首先检查是否确实持有该缓冲区的睡眠锁,然后释放该锁。接着,它减少引用计数,并检查是否变为 0。

每次访问引用计数或 prev/next 字段时,都需要持有 bcache 锁。因此,在 brelse 中,我们先获取 bcache 锁,然后减少引用计数。如果引用计数变为 0,表示该缓冲区空闲,不再被任何人使用。它仍然包含该特定块的数据,devblockno 字段以及 valid 标志仍然准确。未来的 bread 调用可能会重用这个缓冲区,也可能需要它来缓存不同的块而丢弃现有数据。

如果引用计数变为 0,该缓冲区就成为最近最少使用的缓冲区。随后的代码会修改该缓冲区以及头节点的 nextprev 字段,基本上是将该缓冲区从当前位置解除链接,然后移动到循环链表的尾部(即最近最少使用的一端)。


其他辅助函数

最后,我们简要介绍两个辅助函数。

bpinbunpin 函数用于“固定”或“取消固定”缓冲区。固定缓冲区就是增加其引用计数,取消固定则是减少其引用计数。当我们想确保某个缓冲区不会被过早释放时,可以调用 bpin 函数来增加其引用计数。当我们用完该缓冲区,并希望允许其他人释放它时,可以调用 bunpin 来减少引用计数。为了修改引用计数,必须持有 bcache 锁,因此在这两个函数中我们都先获取再释放该锁。


总结

本节课中,我们一起学习了 xv6 操作系统中磁盘缓冲区缓存的实现。我们从磁盘块的基本概念出发,了解了 VirtIO 磁盘驱动接口,深入探讨了缓冲区缓存的数据结构(bufbcache)及其组织方式(双向循环链表)。我们分析了核心函数 breadbgetbwritebrelse 的工作原理,它们共同实现了磁盘块的缓存、读取、修改、写回和释放,并维护了 LRU 替换策略。此外,我们还了解了用于缓冲区管理的 bpinbunpin 辅助函数。这套机制有效减少了磁盘 I/O,并协调了多进程对磁盘数据的访问。

29:磁盘日志文件系统 🗂️

在本节课中,我们将要学习 xv6 操作系统内核中一个关键的机制:磁盘日志文件系统。这个系统用于确保文件系统在发生崩溃时,其数据结构能保持一致性。我们将深入分析 log.c 文件中的代码,理解其工作原理。

概述

文件系统的更新通常涉及对多个磁盘块的写入。如果在写入过程中系统发生崩溃,可能导致文件系统处于不一致的状态(例如,数据损坏或丢失)。日志文件系统通过将多个写入操作组合成一个“事务”来解决这个问题。一个事务要么全部完成(提交),要么在崩溃时完全不生效,从而保证了“全有或全无”的原子性。

核心问题与动机

上一节我们介绍了文件系统一致性的重要性,本节中我们来看看一个具体的例子。

考虑一个需要交换两个节点顺序的单向链表。这需要更新三个指针。如果系统在更新过程中崩溃,链表可能处于损坏状态。在文件系统中,每个“更新”对应一个完整磁盘块的写入,一个复杂操作(如增加文件大小)可能涉及写入多个块。如果崩溃发生在部分写入之后,文件系统就会不一致。

例如,增加文件大小需要两个步骤:

  1. 从空闲块池中分配一个块。
  2. 将这个块添加到目标文件的索引结构中。

如果先执行步骤1后崩溃,会导致一个块既不在空闲池中,也不在任何文件中(块丢失)。如果先执行步骤2后崩溃,会导致一个块同时属于文件又属于空闲池(块重复)。这两种情况都是灾难性的。

事务与日志机制

为了解决上述问题,xv6 引入了事务机制。多个磁盘写入操作被分组到一个事务中。事务的边界由 begin_op()end_op() 函数标记。

  • begin_op(): 标记事务开始。
  • end_op(): 标记事务结束。只有当所有进行中的事务都调用 end_op() 后,系统才会真正提交事务,将所有累积的写入一次性执行。

在事务内部,写入操作通过 log_write() 记录,而不是立即写入磁盘。这些被修改的块(称为“脏块”)被暂存在内存的缓冲区缓存中,并被“固定”,以防止被移出缓存。读取操作 bread() 会优先从缓冲区缓存中获取数据,如果数据不在缓存中,再从磁盘读取,这确保了事务内部能看到自己写入的最新数据。

磁盘布局与内存结构

xv6 的磁盘布局如下所示:

| 引导块 | 超级块 | 日志区 | 主数据区 |
| (块0) | (块1)  |        |          |

日志区本身由一个日志头块和固定数量(例如30个)的日志数据块组成。日志头块在内存中对应一个 struct logheader 结构体,其核心字段是:

  • int n: 当前日志中已使用的数据块数量。
  • int block[LOGSIZE]: 一个数组,记录每个日志数据块实际对应主数据区中的哪个块号。

整个日志系统在内存中由一个 struct log 全局变量管理,它包含了日志的元数据(如起始位置、大小)、用于同步的自旋锁、记录进行中事务数量的计数器等。

关键操作流程

以下是事务处理中关键步骤的分解:

1. 开始事务 (begin_op)

begin_op() 在每次文件系统调用开始时被调用。

  • 检查是否有其他线程正在提交事务,如果有则等待。
  • 检查日志空间是否足够容纳本次事务可能的最大写入量(由 MAXOPBLOCKS 定义,例如10个块)。如果空间不足,则等待。
  • 通过增加 log.outstanding 计数器来记录一个新的进行中事务。

2. 记录写入 (log_write)

当需要修改一个块时,调用 log_write(buf)

  • 该函数不立即写盘,而是将目标块的块号记录到内存中的日志头数组 log.lh.block[] 中。
  • 如果该块号已存在于日志中(即同一事务内多次修改同一块),则复用该条目,不增加计数 n
  • 否则,在数组末尾新增条目,并递增 n
  • 固定对应的缓冲区 (bpin(buf)),增加其引用计数,确保在事务提交前它不会被重用或驱逐。

3. 结束事务 (end_op)

end_op() 在每次文件系统调用结束时被调用。

  • 递减 log.outstanding 计数器。
  • 如果计数器减为 0,意味着这是最后一个进行中的事务,此时可以启动提交过程。它会设置 log.committing 标志,并调用 commit() 函数。
  • 如果计数器不为 0,则直接返回,等待其他事务结束。

4. 提交事务 (commit)

提交过程分为两个关键阶段,确保即使在提交中途崩溃也能恢复一致性。

阶段一:将脏数据写入日志区

  • 调用 write_log():遍历内存日志头数组,将每个脏块(log.lh.block[i] 指定的块)从缓冲区缓存复制到磁盘上对应的日志数据块中。
  • 调用 write_head():将更新后的内存日志头(包含数组 block[] 和计数 n)写入磁盘的日志头块这一步是真正的提交点。在此之后,即使系统崩溃,恢复流程也能知道有一个完整的事务等待完成。

阶段二:将数据写回主数据区

  • 调用 install_trans(recovering=0):再次遍历日志头数组,这次是将日志数据块中的内容,写回到它们真正的归宿——主数据区对应的块中。
  • 清理:将内存日志头中的计数 n 置为 0,并再次调用 write_head() 将清空的日志头写回磁盘。这标志着整个事务完成,日志空间被释放。

5. 崩溃恢复 (recover_from_log)

系统启动时,在初始化日志系统 (initlog) 的过程中会调用 recover_from_log()

  • 调用 read_head() 从磁盘读取日志头。
  • 如果日志头中的计数 n > 0,说明上次系统关闭前有一个已提交(阶段一完成)但未完全应用(阶段二未完成)的事务。
  • 调用 install_trans(recovering=1),将日志中的数据块重新应用到主数据区。由于恢复时缓冲区未被固定,因此无需调用 bunpin()
  • 恢复完成后,将 n 置 0 并写回磁盘,清空日志。

这种设计保证了:只要阶段一完成,事务就是持久的;阶段二可以安全地重复执行(幂等性)。

并发与资源管理

系统需要处理多个并发事务:

  • 提交互斥begin_op() 会检查 log.committing 标志,确保同一时间只有一个提交在进行。
  • 日志空间预留begin_op() 会预留足够的日志空间(基于 MAXOPBLOCKS),防止事务因空间不足而无法完成,这避免了死锁。
  • 防止饿死:当最后一个事务调用 end_op() 并启动提交后,它会唤醒所有可能在 begin_op() 中等待日志空间或提交完成的线程。

总结

本节课中我们一起学习了 xv6 磁盘日志文件系统的工作原理。其核心思想是通过事务将多个磁盘写入操作捆绑,并借助预写日志技术来保证原子性。关键步骤包括:在事务中延迟写入并记录日志,在提交时先确保所有修改持久化到日志区,再将修改应用到主数据区。这种机制确保了即使在系统崩溃的情况下,文件系统也能恢复到一致的状态。代码通过精巧的缓冲区管理、日志空间预留和并发控制,实现了这一复杂但至关重要的功能。

30:文件系统

概述

在本节课中,我们将学习 xv6 文件系统在磁盘上的组织方式。我们将了解磁盘布局、文件与目录的表示方法,以及用于创建文件系统的工具。核心概念包括inode超级块目录结构

磁盘布局与访问

上一节我们介绍了日志系统,本节我们来看看文件系统数据在磁盘上的具体组织。

磁盘被划分为一系列连续的块。xv6 内核提供了函数来访问这些块。以下是访问磁盘块的核心代码:

struct buf *bp = bread(dev, blockno); // 读取块到内存缓冲区
... // 操作缓冲区数据
log_write(bp); // 将修改写入日志(事务的一部分)
brelse(bp); // 释放缓冲区

对磁盘块的修改被包裹在事务中,以确保原子性(要么全部写入,要么全部不写入)。

begin_op(); // 开始事务
... // 多次调用 log_write
end_op(); // 结束事务,确保所有写入原子提交

文件系统结构

一个文件系统包含一棵目录树和位于这些目录中的文件,它们都位于同一个设备(如磁盘)上。

  • 目录 组织成树形结构(无环图)。
  • 文件 通过路径名引用,文件本身没有名字。
  • 文件类型:在 xv6 中,文件有三种类型:目录普通文件设备文件

以下是文件系统类型的对比:

  • 硬链接:允许多个目录条目指向同一个文件(inode)。xv6 支持。
  • 符号链接(软链接):文件内容是一个路径名,内核会间接访问目标文件。xv6 不支持

Inode:文件的标识符

用户程序通过路径名访问文件,但内核内部使用一个称为 inode 号 的小整数来唯一标识文件。

每个文件都关联一组属性,存储在磁盘上的一个固定大小的结构体中,称为 inode。内核会将正在使用的 inode 缓存在内存中。

以下是磁盘上 inode 结构体的定义(简化):

struct dinode {
    short type;           // 文件类型(目录、文件、设备)
    short major;          // 主设备号(仅设备文件有效)
    short minor;          // 次设备号(仅设备文件有效)
    short nlink;          // 指向此文件的硬链接数
    uint size;            // 文件大小(字节)
    uint addrs[NDIRECT+1]; // 数据块地址数组
};

inode 号隐含在磁盘 inode 数组的索引中。类型为 0 表示该 inode 空闲。

详细的磁盘组织

现在,让我们更详细地了解 xv6 的磁盘布局。磁盘块按顺序组织,包含以下区域:

  1. 引导块:用于系统启动,内核不读写。
  2. 超级块:包含文件系统的元数据(如大小、inode 数量、各区域起始位置)。内核只读不写。
  3. 日志区:用于事务日志。
  4. inode 区:存储所有 inode 结构体的数组。
  5. 位图区:每个数据块对应一个位,表示该块是空闲(0)还是已用(1)。
  6. 数据块区:存储文件和目录的实际内容。

超级块在启动时通过 readsb() 函数读入内存,其结构如下:

struct superblock {
    uint magic;        // 魔数,标识文件系统类型
    uint size;         // 文件系统总块数
    uint nblocks;      // 数据块数量
    uint ninodes;      // inode 数量
    uint nlog;         // 日志块数量
    uint logstart;     // 日志起始块号
    uint inodestart;   // inode 区起始块号
    uint bmapstart;    // 位图区起始块号
};

目录的表示

目录在 xv6 中是一种特殊类型的文件。其内容是一个线性数组,每个条目将文件名映射到 inode 号。

以下是目录条目的结构:

struct dirent {
    ushort inum;      // inode 号
    char name[DIRSIZ]; // 文件名(最多14字符)
};

查找文件时,内核需要线性扫描目录数组。现代系统使用更复杂的数据结构来支持更长的文件名和更快的查找。

创建文件系统:mkfs

mkfs 是一个独立的 C 程序,用于从头创建一个 xv6 文件系统镜像。

它的作用是:

  1. 创建一个名为 fs.img 的磁盘镜像文件。
  2. 初始化镜像:写入超级块、初始化 inode 数组和位图。
  3. 创建初始的目录树结构(如根目录 /)。
  4. 将编译好的用户程序(如 init, sh)作为文件写入镜像中。

这样,当 xv6 内核启动时,就能看到一个已初始化完毕、包含可用程序的完整文件系统。

总结

本节课我们一起学习了 xv6 文件系统的磁盘表示。

  • 我们了解了文件系统在磁盘上的布局,包括超级块、inode 区、位图区和数据区。
  • 我们学习了 inode 是文件的唯一标识,存储了文件的元数据和数据块指针。
  • 我们知道了目录是一种将文件名映射到 inode 号的特殊文件。
  • 最后,我们了解了 mkfs 工具如何创建初始的文件系统镜像。
    理解这些磁盘上的数据结构是理解文件系统代码如何工作的基础。

31:Inodes 详解 🗂️

在本节课中,我们将学习 XV6 操作系统中 Inode 的核心概念。我们将了解 Inode 在磁盘上的表示方式、在内存中的缓存机制,以及相关的数据结构和关键函数。通过本节内容,你将掌握文件系统如何通过 Inode 来组织和管理文件。


磁盘布局概览 📊

上一节我们介绍了文件系统的基本概念,本节中我们来看看 XV6 磁盘的具体布局。下图展示了磁盘的示意图,其中每个小方块代表一个磁盘块。

磁盘布局包含以下几个关键区域:

  • 日志区:用于事务日志记录。
  • Inode 数组区:存储所有 Inode 结构。
  • 位图区:标记数据块的使用情况。
  • 数据块区:存储常规文件和目录的实际数据。

位图中的每一位对应一个数据块,1 表示已使用,0 表示空闲。当需要为文件或目录分配新块时,内核会在此位图中查找空闲块。


Inode 在磁盘上的结构 💾

让我们更深入地观察 Inode 数组区。该区域从超级块中 inodestart 字段指定的块号开始。这个数组由多个 inode 结构紧密排列组成。

每个磁盘上的 inode 结构包含以下字段:

  • type:文件类型(常规文件、目录或设备)。
  • majorminor:设备的主、次设备号(仅对设备文件有效)。
  • nlink:指向此文件的硬链接数量。
  • size:文件大小(字节数,对设备文件无效)。
  • addrs[]:一个包含 12 个直接块指针的数组。

如果文件大小超过 12 个块,系统将使用一个间接块addrs[12] 指向一个磁盘块,该块本身不存储数据,而是存储 256 个指向其他数据块的指针。

因此,XV6 中单个文件的最大尺寸由以下公式决定:
最大文件大小 = (直接指针数 + 间接块指针数) * 块大小
最大文件大小 = (12 + 256) * 1024 字节

与支持双重、三重间接块的现代系统相比,XV6 的文件大小限制较为严格。


Inode 在内存中的缓存 🧠

为了高效操作,内核会将正在使用的 Inode 读入内存进行缓存。内存中有一个名为 itable 的结构,它包含一个自旋锁和一个大小为 50 的 inode 结构数组。

以下是内存中缓存的 inode 结构(定义在 file.h 中)包含的字段:

struct inode {
    uint dev;           // 设备号
    uint inum;          // Inode 编号
    int ref;            // 引用计数
    struct sleeplock lock; // 睡眠锁
    int valid;          // 数据是否已从磁盘读入的标志位
    short type;         // 文件类型
    short major;        // 主设备号
    short minor;        // 次设备号
    short nlink;        // 硬链接数
    uint size;          // 文件大小
    uint addrs[NDIRECT+1]; // 数据块地址数组
};
  • devinum 共同唯一标识一个文件。
  • ref 表示当前有多少个指针(或线程)正在使用此缓存 Inode。ref 为 0 表示该缓存槽位空闲。
  • lock 是一个睡眠锁,用于保护 valid 标志位及以下的所有字段(type, size, addrs 等)。
  • valid 标志位指示该 Inode 的元数据(如 type, size)是否已从磁盘读入。若为 0,则在需要访问时必须先从磁盘读取。

itable 中的自旋锁保护的是整个缓存数组的分配状态,即 devinumref 字段。


设备切换表 ⚙️

XV6 通过一个名为 devsw(设备切换表)的数组来管理不同设备的驱动函数。该数组有 NDEV(默认为 10)个元素。

每个元素是一个包含 readwrite 函数指针的结构体:

struct devsw {
    int (*read)(int, uint64, int);
    int (*write)(int, uint64, int);
};
extern struct devsw devsw[NDEV];

例如,控制台设备(主设备号 1)就使用此表中的特定函数进行读写操作。readwrite 函数的参数包括目标/源地址、字节数以及一个标识地址空间(内核物理地址或用户虚拟地址)的标志位。


关键头文件解析 📄

以下是 fs.hfile.h 中与 Inode 相关的核心定义:

fs.h 中:

  • ROOTINO:根目录的 Inode 编号,固定为 1。
  • BSIZE:磁盘块大小,固定为 1024 字节。
  • NDIRECT:直接指针数量,值为 12。
  • NINDIRECT:一个间接块能容纳的指针数,值为 BSIZE / sizeof(uint) = 256。
  • MAXFILE:最大文件块数,值为 NDIRECT + NINDIRECT
  • struct dinode:磁盘上 Inode 的结构体定义,与内存版本对应,但不包含缓存管理字段。
  • IPB:每块磁盘能容纳的 Inode 数量,计算公式为 BSIZE / sizeof(struct dinode)
  • IBLOCK 宏:根据 Inode 编号计算其所在的磁盘块号。
  • BPB:每个位图块包含的比特数,值为 BSIZE * 8
  • struct dirent:目录项结构,包含一个 Inode 编号 (inum) 和一个最多 14 字符的文件名。

file.h 中:

  • struct inode:如前所述,内存中缓存的 Inode 结构。
  • struct devsw:如前所述,设备切换表项结构。

文件系统函数概览 🔧

fs.c 文件中包含了约 25 个管理文件系统的函数。在深入代码之前,我们先对这些函数进行简要介绍:

初始化与块管理:

  • iinit(): 初始化内存中的 Inode 缓存表 (itable)。
  • fsinit(int dev): 初始化文件系统,读取超级块。
  • bzero(int dev, int bno): 将指定块清零。
  • balloc(uint dev): 分配一个空闲数据块,更新位图并返回块号。
  • bfree(int dev, uint b): 释放一个数据块,更新位图。

Inode 生命周期管理:

  • ialloc(uint dev, short type): 在磁盘上分配一个新的 Inode(类型为 type),将其读入内存缓存,并返回指针。它会设置磁盘 Inode 的 type 字段。
  • iget(uint dev, uint inum): 根据设备号和 Inode 编号,在内存缓存中获取对应的 Inode 结构。如果尚未缓存,则分配一个缓存槽位。增加其引用计数 (ref),但对其加锁,也保证数据 (valid) 已从磁盘读入。
  • ilock(struct inode *ip): 对 Inode 加睡眠锁。如果 valid 为 0,则从磁盘读取其元数据。
  • iunlock(struct inode *ip): 对 Inode 解锁。
  • iupdate(struct inode *ip): 将内存中已修改的 Inode 元数据写回磁盘。
  • idup(struct inode *ip): 增加 Inode 的引用计数。
  • iput(struct inode *ip): 减少 Inode 的引用计数。如果引用计数和硬链接数 (nlink) 都降为 0,则调用 itrunc 释放文件占用的所有数据块,并将磁盘 Inode 的 type 置 0 以标记为空闲。
  • itrunc(struct inode *ip): 将文件截断为长度 0,释放其所有数据块和间接块。

数据访问与路径解析:

  • bmap(struct inode *ip, uint bn): 将文件内的逻辑块号 bn 转换为磁盘上的物理块号。如果需要,会分配新的数据块。
  • readi(struct inode *ip, int user_dst, uint64 dst, uint off, uint n): 从文件的偏移 off 处读取 n 字节到内存地址 dst
  • writei(struct inode *ip, int user_src, uint64 src, uint off, uint n): 从内存地址 src 写入 n 字节到文件的偏移 off 处。
  • dirlookup(struct inode *dp, char *name, uint *poff): 在目录 dp 中查找文件名 name。如果找到,则通过 iget 获取其 Inode,并可在 poff 中返回目录项偏移量。
  • dirlink(struct inode *dp, char *name, uint inum): 在目录 dp 中创建一个新的目录项,将 name 链接到 Inode 编号 inum
  • skipelem(char *path, char *name): 解析路径名,提取第一个组成部分到 name,并返回指向路径剩余部分的指针。
  • namex(char *path, int nameiparent, char *name): 路径名解析的核心函数,根据 nameiparent 标志决定是解析到最后一级(返回目标 Inode)还是倒数第二级(返回父目录 Inode 并保存最后一级名称)。
  • namei(char *path)nameiparent(char *path, char *name): 对外接口,封装了对 namex 的调用,分别用于获取目标 Inode 和父目录 Inode。

重要提示:以上所有涉及磁盘读写的函数(如 readi, writei, iupdate)本身并不包含事务(transaction)的开始 (begin_op) 和结束 (end_op)。它们被设计为在由更高层函数发起的事务上下文中被调用,通过日志系统来保证操作的原子性。


总结 📝

本节课中我们一起学习了 XV6 操作系统中 Inode 的核心机制。我们了解了 Inode 在磁盘上的物理布局与数据结构,以及在内核中如何通过缓存表 (itable) 进行高效管理。我们还浏览了 fs.hfile.h 中的关键定义,并对 fs.c 中负责 Inode 分配、释放、数据读写和路径解析的主要函数有了整体认识。理解这些基础组件是掌握 XV6 文件系统工作原理的关键。在下一节中,我们将深入这些函数的源代码,探究其具体实现细节。

32:fs.c 文件系统代码详解(第一部分)

在本节课中,我们将学习 xv6 文件系统核心代码文件 fs.c 中的一系列函数。我们将从初始化函数开始,逐步深入到文件创建、数据块管理以及索引节点(inode)缓存的操作。本教程将详细解释每个函数的逻辑,确保初学者能够理解其工作原理。

概述

fs.c 文件包含了 xv6 文件系统的核心实现。由于函数数量众多,我们将其分为两部分进行讲解。本视频(第一部分)将涵盖初始化、数据块分配与释放、索引节点缓存管理以及文件截断等核心功能。在下一部分中,我们将继续讲解文件读写、路径名解析等高级功能。

初始化函数

上一节我们介绍了文件系统的整体结构,本节中我们来看看系统启动时如何初始化文件系统组件。

文件系统初始化 (fsinit)

系统启动后,第一个用户进程会调用 fsinit 函数。该函数负责读取磁盘上的超级块(superblock)并初始化日志系统。

void fsinit(int dev) {
    readsb(dev, &sb);
    if(sb.magic != FSMAGIC)
        panic("invalid file system");
    initlog(dev, &sb);
}

代码解释

  1. readsb 函数从指定设备读取超级块到内存中的 sb 结构体。
  2. 检查超级块的魔数(magic)以确保文件系统类型正确。
  3. 调用 initlog 初始化日志系统,此后系统可以开始事务操作(如 begin_op, end_op)。

缓冲区缓存初始化 (binit)

main 函数中,会调用 binit 来初始化与缓冲区缓存相关的数据。

索引节点表初始化 (iinit)

索引节点缓存是一个包含 50 个 inode 结构的数组,由一个自旋锁保护整个数组,每个结构还有自己的睡眠锁。

void iinit() {
    initlock(&itable.lock, "itable");
    for(i = 0; i < NINODE; i++) {
        initsleeplock(&itable.inode[i].lock, "inode");
    }
}

代码解释

  1. 初始化保护整个索引节点表(itable)的自旋锁。
  2. 遍历数组,初始化每个 inode 结构体的睡眠锁。
  3. 每个结构的引用计数(ref)默认初始化为 0,表示该条目未被使用。

数据块管理

文件系统需要管理磁盘上的数据块,包括分配空闲块和释放已使用的块。

分配数据块 (balloc)

每当创建或扩展一个普通文件时,都需要在磁盘上找到一个未使用的数据块,将其清零,然后分配给文件。balloc 函数负责此操作。

static uint balloc(uint dev) {
    for(b = 0; b < sb.size; b += BPB) {
        bp = bread(dev, BBLOCK(b, sb));
        for(bi = 0; bi < BPB && b + bi < sb.size; bi++) {
            m = 1 << (bi % 8);
            if((bp->data[bi/8] & m) == 0) {
                bp->data[bi/8] |= m;
                log_write(bp);
                brelse(bp);
                bzero(dev, b + bi);
                return b + bi;
            }
        }
        brelse(bp);
    }
    panic("balloc: out of blocks");
}

算法流程

  1. 外层循环遍历位图的所有块(b 以每块位数 BPB 递增)。
  2. 对于每个位图块,调用 bread 读入缓冲区。
  3. 内层循环遍历该块中的所有位(bi)。
  4. 计算字节内位的位置并创建掩码 m
  5. 如果该位为 0(表示空闲),则将其置 1,通过 log_write 写回磁盘,然后释放缓冲区。
  6. 调用 bzero 将新分配的数据块清零,最后返回块号。
  7. 如果搜索完所有位图都未找到空闲块,则系统崩溃。

释放数据块 (bfree)

当文件被删除或缩小时,需要将其占用的数据块归还给空闲池。bfree 函数负责此操作。

void bfree(int dev, uint b) {
    bp = bread(dev, BBLOCK(b, sb));
    bi = b % BPB;
    m = 1 << (bi % 8);
    if((bp->data[bi/8] & m) == 0)
        panic("freeing free block");
    bp->data[bi/8] &= ~m;
    log_write(bp);
    brelse(bp);
}

算法流程

  1. 根据块号 b 计算其对应的位图块,并读入缓冲区。
  2. 计算块在位图块内的位偏移 bi
  3. 创建掩码 m 以定位特定位。
  4. 检查该位是否已为 0(即已空闲),若是则报错。
  5. 使用 & ~m 操作将该位清零。
  6. 通过 log_write 将修改后的位图块写回磁盘,然后释放缓冲区。

索引节点缓存操作

文件系统通过索引节点缓存来高效管理正在使用的文件。主要操作包括获取、锁定、释放缓存条目。

获取索引节点 (iget)

当需要访问一个已存在的文件时,调用 iget。它在缓存中查找指定设备号和索引节点号的 inode。如果找到,则增加其引用计数并返回指针;如果未找到,则分配一个缓存条目。

struct inode* iget(uint dev, uint inum) {
    acquire(&itable.lock);
    // 1. 查找缓存中是否已存在
    for(ip = &itable.inode[0]; ip < &itable.inode[NINODE]; ip++){
        if(ip->ref > 0 && ip->dev == dev && ip->inum == inum){
            ip->ref++;
            release(&itable.lock);
            return ip;
        }
    }
    // 2. 查找一个空闲条目(ref == 0)并分配
    for(ip = &itable.inode[0]; ip < &itable.inode[NINODE]; ip++){
        if(ip->ref == 0) {
            ip->dev = dev;
            ip->inum = inum;
            ip->ref = 1;
            ip->valid = 0; // 标记数据尚未从磁盘读入
            release(&itable.lock);
            return ip;
        }
    }
    panic("iget: no inodes");
}

关键点

  • iget 不会从磁盘读取 inode 数据,也不会对其加锁。
  • 它只管理缓存条目的分配和引用计数。
  • 新分配的条目 valid 字段为 0,表示其磁盘数据尚未加载。

锁定并读取索引节点 (ilock)

在对 inode 进行修改或确保其数据已就绪前,需要调用 ilock。它会获取该 inode 的睡眠锁,如果 valid 为 0,则从磁盘读取数据。

void ilock(struct inode *ip) {
    if(ip == 0 || ip->ref < 1)
        panic("ilock");
    acquiresleep(&ip->lock);
    if(ip->valid == 0) {
        bp = bread(ip->dev, IBLOCK(ip->inum, sb));
        dip = (struct dinode*)bp->data + ip->inum % IPB;
        ip->type = dip->type;
        ip->major = dip->major;
        ip->minor = dip->minor;
        ip->nlink = dip->nlink;
        ip->size = dip->size;
        memmove(ip->addrs, dip->addrs, sizeof(ip->addrs));
        brelse(bp);
        ip->valid = 1;
        if(ip->type == 0)
            panic("ilock: no type");
    }
}

释放索引节点引用 (iput)

当一个进程完成对文件的操作后,需要调用 iput 来减少缓存 inode 的引用计数。如果引用计数降为 0,并且文件的硬链接数也为 0,则删除该文件(释放其所有数据块)。

void iput(struct inode *ip) {
    acquire(&itable.lock);
    if(ip->ref == 1 && ip->valid && ip->nlink == 0) {
        // 这是最后一个引用,且文件已无硬链接,可以删除
        acquiresleep(&ip->lock);
        release(&itable.lock);
        itrunc(ip);        // 截断文件,释放所有数据块
        ip->type = 0;      // 标记 inode 类型为未使用
        iupdate(ip);       // 将 type=0 写回磁盘
        ip->valid = 0;     // 使缓存失效
        releasesleep(&ip->lock);
        acquire(&itable.lock);
    }
    ip->ref--; // 减少引用计数
    release(&itable.lock);
}

关键逻辑

  1. 检查是否是该 inode 的最后一个引用(ref == 1)且其硬链接数已为 0(nlink == 0)。
  2. 如果是,则获取该 inode 的睡眠锁,然后释放全局表锁(避免长时间持有)。
  3. 调用 itrunc 释放文件所有数据块。
  4. inode 类型置 0 并写回磁盘,标记为未使用。
  5. 将缓存条目的 valid 置 0,防止后续 ialloc 错误重用旧缓存。
  6. 最后减少引用计数。如果引用计数不为 0,文件仍保留在缓存中供其他进程使用。

解锁并释放 (iunlockput)

这是一个常用组合操作:先解锁 inode 的睡眠锁,然后调用 iput 释放引用。

void iunlockput(struct inode *ip) {
    iunlock(ip);
    iput(ip);
}

文件创建与截断

分配索引节点 (ialloc)

当需要创建新文件(或目录)时,调用 ialloc。它在磁盘上查找一个类型为 0(未使用)的 inode,将其标记为已分配(设置类型),并返回其缓存版本。

struct inode* ialloc(uint dev, short type) {
    for(inum = 1; inum < sb.ninodes; inum++) {
        bp = bread(dev, IBLOCK(inum, sb));
        dip = (struct dinode*)bp->data + inum % IPB;
        if(dip->type == 0) { // 找到空闲 inode
            memset(dip, 0, sizeof(*dip));
            dip->type = type;
            log_write(bp); // 将分配信息写入日志
            brelse(bp);
            return iget(dev, inum); // 获取缓存版本
        }
        brelse(bp);
    }
    panic("ialloc: no inodes");
}

流程

  1. 从 1 开始遍历所有可能的 inode 号(0 号无效)。
  2. 读取每个 inode 所在的磁盘块。
  3. 检查其 type 字段,若为 0 则表示空闲。
  4. 将该 inode 在磁盘上的所有字段清零,然后设置其 type
  5. 通过 log_write 将修改写回磁盘。
  6. 调用 iget 获取该 inode 的缓存版本并返回。注意,此时缓存中的 valid 为 0,后续 ilock 会读取数据。

更新索引节点到磁盘 (iupdate)

当内存中缓存的 inode 信息被修改后,必须调用 iupdate 将其写回磁盘。调用者必须持有该 inode 的睡眠锁。

void iupdate(struct inode *ip) {
    bp = bread(ip->dev, IBLOCK(ip->inum, sb));
    dip = (struct dinode*)bp->data + ip->inum % IPB;
    dip->type = ip->type;
    dip->major = ip->major;
    dip->minor = ip->minor;
    dip->nlink = ip->nlink;
    dip->size = ip->size;
    memmove(dip->addrs, ip->addrs, sizeof(ip->addrs));
    log_write(bp);
    brelse(bp);
}

截断文件 (itrunc)

此函数将文件大小截断为 0,释放其所有数据块(包括直接块和间接块)。它被 iput 在删除文件时调用。

void itrunc(struct inode *ip) {
    // 释放直接块
    for(i = 0; i < NDIRECT; i++) {
        if(ip->addrs[i]) {
            bfree(ip->dev, ip->addrs[i]);
            ip->addrs[i] = 0;
        }
    }
    // 释放间接块
    if(ip->addrs[NDIRECT]) {
        bp = bread(ip->dev, ip->addrs[NDIRECT]);
        a = (uint*)bp->data;
        for(j = 0; j < NINDIRECT; j++) {
            if(a[j])
                bfree(ip->dev, a[j]);
        }
        brelse(bp);
        bfree(ip->dev, ip->addrs[NDIRECT]); // 释放间接块本身
        ip->addrs[NDIRECT] = 0;
    }
    ip->size = 0;
    iupdate(ip); // 更新 inode 信息到磁盘
}

获取文件状态 (stati)

这是一个简单的辅助函数,将 inode 中的基本信息复制到 stat 结构体中,用于 stat 系统调用。调用者需持有 inode 锁。

void stati(struct inode *ip, struct stat *st) {
    st->dev = ip->dev;
    st->ino = ip->inum;
    st->type = ip->type;
    st->nlink = ip->nlink;
    st->size = ip->size;
}

总结

本节课中我们一起学习了 xv6 文件系统 fs.c 文件前半部分的核心函数。我们详细探讨了:

  1. 初始化流程:系统启动时如何读取超级块并初始化日志和索引节点缓存。
  2. 数据块管理:如何使用位图来分配 (balloc) 和释放 (bfree) 磁盘数据块。
  3. 索引节点缓存:通过 igetilockiunlockiput 这一套机制来高效、安全地管理正在使用的文件元数据。
  4. 文件生命周期操作:如何创建新文件 (ialloc),如何将修改写回磁盘 (iupdate),以及如何删除文件 (itrunciput 中调用)。

这些函数共同构成了文件系统的基础设施,为上层文件操作(如打开、读写、查找)提供了支持。在下一部分中,我们将继续学习文件读写、目录操作以及路径名解析等更高级的功能。

33:fs.c 第二部分 🧩

在本节课中,我们将继续深入分析 xv6 内核文件 fs.c 中的函数。上一节我们介绍了 bmap 函数,它负责将文件内的逻辑块号映射到磁盘上的物理块号。本节中,我们将完成对 fs.c 中剩余核心函数的讲解,包括读写文件、目录操作以及路径名解析,最终理解 namei 函数如何根据路径名查找文件。


bmap 函数继续

bmap 函数是文件系统数据访问的基础。它接收一个指向文件 inode 的指针和一个文件内的逻辑块号,返回该逻辑块在磁盘上的实际块号。如果该块尚未分配,bmap 会分配一个新块并将其清零。

以下是其核心逻辑的简化描述:

  1. 处理直接块:如果请求的逻辑块号 < 12,则直接从 inode 的 addrs[] 数组中获取对应的磁盘块号。如果该条目为 0(未分配),则调用 balloc 分配新块。
  2. 处理间接块:如果逻辑块号 >= 12,则需要通过一级间接块来寻址。
    • 首先计算在间接块数组中的索引:bn -= NDIRECT
    • 检查索引是否超出范围(>= NINDIRECT),若是则触发 panic
    • 读取间接块。如果 inode 中指向间接块的指针为 0,则先分配并清零一个间接块。
    • 从间接块数组的相应索引处获取目标磁盘块号。若为 0,则分配新块。
    • 如果修改了间接块(例如分配了新块),必须调用 log_write 将其写回日志。

关键点bmap 可能会修改 inode(例如分配新块后更新指针数组)。调用 bmap 的函数有责任在适当时候调用 iupdate 将修改后的 inode 写回磁盘。


文件数据读写:readiwritei

理解了块映射后,我们来看如何实际读写文件数据。

readi 函数:从文件读取数据

readi 函数负责从文件(由 inode ip 描述)的指定偏移量 offset 处,读取 n 字节数据到内存地址 dst

以下是其工作流程:

  1. 参数检查
    • 如果 offset 超出文件大小,返回 0。
    • 如果请求读取的字节数 n < 0,返回 0。
    • 如果 offset + n 超出了文件大小,则将 n 调整为仅读取到文件末尾的字节数。
  2. 循环传输:读取操作可能跨越文件的多个块。函数进入循环,每次处理一个数据块内的连续字节。
  3. 单次块操作
    • 根据当前 offset 计算其所在的文件逻辑块号。
    • 调用 bmap 将该逻辑块号转换为磁盘物理块号。
    • 调用 bread 将该磁盘块读入缓冲区。
    • 计算本次循环要传输的字节数 m:不能超过当前块内剩余字节,也不能超过剩余需读取的总字节数。
    • 调用 either_copyout 将数据从缓冲区复制到用户空间或内核空间(由参数 user_dst 决定)。
    • 如果复制失败,释放缓冲区并返回 -1。
    • 成功则释放缓冲区,更新 offsetdst 和已传输字节数 tot
  4. 返回:循环结束后,返回成功传输的总字节数 tot

关于 inode 更新:在 xv6 中,readi 不会导致文件扩展(因为没有 lseek 系统调用允许在文件末尾之后定位)。因此,readi 调用 bmap 时只会访问已分配的块,不会修改 inode,所以它不需要调用 iupdate。这是一个与某些类 Unix 系统不同的设计选择。

writei 函数:向文件写入数据

writei 函数与 readi 对称,负责将内存中 src 地址处的 n 字节数据,写入文件 ip 的指定偏移量 offset 处。

以下是其工作流程:

  1. 参数检查
    • 如果 offset 超出文件大小,或 n < 0,返回 -1。
    • 如果 offset + n 超出文件最大限制(MAXFILE * BSIZE),返回 -1。
  2. 循环传输:写入操作同样可能跨越多个块。循环结构与 readi 类似。
  3. 单次块操作
    • 计算当前偏移量所在的逻辑块号,调用 bmap 获取(或分配)磁盘块。
    • 调用 bread 读入目标块(如果块是新建的,内容全为零)。
    • 计算本次写入字节数 m
    • 调用 either_copyin 将数据从用户/内核空间复制到缓冲区。
    • 调用 log_write 将修改后的缓冲区标记为待写入磁盘(日志系统)。
    • 释放缓冲区。如果复制失败,则中断并返回错误。
  4. 更新文件大小:如果写入操作使 offset + n 超过了当前文件大小,则更新 inode 的 size 字段。
  5. 写回 inode:由于 bmap 可能在写入过程中分配了新块,从而修改了 inode 的地址数组,因此必须调用 iupdate 将 inode 的更改写回磁盘。
  6. 返回:返回成功写入的总字节数。

目录操作

文件系统通过目录来组织文件。xv6 的目录本质上是一种特殊类型的文件,其内容是一系列固定大小的目录项。

目录项结构

每个目录项 (struct dirent) 大小为 16 字节:

  • inum (2字节):文件对应的 inode 编号。为 0 表示该条目空闲。
  • name (14字节):文件名(以空字符结尾,若正好 14 字符则无终止符)。

dirlookup 函数:在目录中查找文件

此函数在目录 dp 中查找名为 name 的条目。

以下是其查找步骤:

  1. 验证目录:确认 dp 指向的确实是一个目录(T_DIR 类型)。
  2. 遍历目录项:从偏移量 0 开始,以 16 字节为步进遍历目录文件。
  3. 读取条目:每次循环调用 readi 读取 16 字节到一个 dirent 结构 de 中。
  4. 检查条目
    • 如果 de.inum == 0,跳过此空闲条目。
    • 否则,比较 de.name 与目标 name。使用 namecmp 函数进行比较(最多比较 14 字符)。
  5. 找到处理:如果名称匹配,则:
    • 记录找到的 inode 编号。
    • 如果调用者提供了 poff 参数(用于存储偏移量),则保存当前条目在目录中的偏移。
    • 调用 iget 根据设备号和 inode 编号获取并返回缓存中的 inode 指针。
  6. 未找到:遍历完整个目录仍未找到,则返回 0。

此函数在目录 dp 中添加一个名为 name、指向 inode inum 的新目录项。

以下是其添加步骤:

  1. 检查重名:首先调用 dirlookup 检查 name 是否已存在。如果存在,则返回 -1(失败)。
  2. 寻找空闲槽位:遍历目录,寻找一个 inum 为 0 的空闲目录条目。
  3. 写入新条目
    • name 复制到目录项结构的 name 字段(最多 14 字符)。
    • inum 写入 inum 字段。
    • 调用 writei 将这个 16 字节的目录项结构写入到找到的空闲槽位(或文件末尾)。writei 会自动处理文件扩展。
  4. 返回结果:成功返回 0,失败返回 -1。

路径名解析

用户程序通过路径名(如 /usr/bin/ls)访问文件。内核需要解析这些路径,找到对应的 inode。

skipelem 函数:解析路径组件

这是一个纯字符串处理函数,用于从路径中提取下一个组件(文件名或目录名)。

  • 输入:路径字符串指针 path,用于存储组件名的 14 字节缓冲区 name
  • 操作
    1. 跳过开头的所有 /
    2. 如果已到字符串末尾,返回 0。
    3. 找到下一个 / 或字符串结尾,确定当前组件的长度。
    4. 将组件名复制到 name 缓冲区(如果长度 >= DIRSIZ(14),则只复制前 14 字节;否则复制后添加空字符)。
    5. 将路径指针 path 前进到下一个组件之前(跳过可能的 /)。
  • 输出:返回更新后的 path 指针(指向下一个待解析部分)。

namex / namei / nameiparent 函数:路径名查找的核心

这是路径解析的最终环节。nameinameiparent 都调用 namex 完成实际工作。

  • namei(path): 返回路径 path 最终指向文件的 inode。
  • nameiparent(path, name): 返回路径 path 的父目录的 inode,同时将最终组件名复制到 name 缓冲区。

以下是 namex 的核心逻辑:

  1. 确定起点
    • 如果路径以 / 开头,则从根目录(设备 ROOTDEV,inode 编号 1)开始查找。
    • 否则,从当前进程的当前工作目录(proc->cwd)开始查找。
    • 通过 iget 获取起点目录的 inode 指针 ip,并增加其引用计数。
  2. 循环解析组件
    • 调用 skipelem 从路径中提取下一个组件到 name,并更新 path 指针。
    • 如果 name 为空(已是最后一个组件),根据 nameiparent 标志决定返回当前 ip(父目录)还是进行错误处理,然后跳出循环。
    • 确保 ip 指向的是一个目录(类型 T_DIR)。
    • ip 指向的目录加锁,然后调用 dirlookup(ip, name, 0) 在该目录中查找名为 name 的条目。
    • 如果查找失败,解锁 ip,减少其引用计数,返回 0(失败)。
    • 如果查找成功,dirlookup 返回子文件/目录的 inode 指针 next
    • 对当前目录 ip 解锁并减少其引用计数(我们已经不再需要它)。
    • ip 指向 next,准备以此作为下一轮循环的查找起点。
  3. 返回结果
    • 当循环结束(路径解析完毕),ip 就指向了目标文件或目录。函数返回 ip。注意,返回的 inode 已被 iget 引用,但未被加锁,调用者通常需要立即对其加锁。

nameiparent 的特殊处理:当 nameiparent 标志为真时,namex 会在解析到路径的倒数第二个组件时提前停止。此时,ip 指向父目录,而最后一个组件名已保存在调用者提供的 name 缓冲区中。这对于创建或删除文件等需要操作父目录的场景非常有用。


总结 🎯

本节课中我们一起学习了 xv6 文件系统 fs.c 模块的后半部分核心功能:

  1. 数据块映射bmap 函数实现了文件逻辑块到磁盘物理块的映射,支持直接和间接寻址,并负责按需分配新块。
  2. 文件数据读写
    • readi:将数据从文件读入内存。在 xv6 的设计下,它不会触发文件扩展。
    • writei:将数据从内存写入文件。可能触发文件扩展和块分配,因此必须负责更新 inode 到磁盘。
  3. 目录管理
    • 目录是包含固定格式条目(dirent)的特殊文件。
    • dirlookup:在目录中按名称查找条目,返回对应文件的 inode。
    • dirlink:在目录中创建新的目录项。
  4. 路径名解析
    • skipelem:辅助函数,用于从路径字符串中逐步提取组件。
    • namex (由 nameinameiparent 调用):路径解析的核心引擎。它从根目录或当前目录开始,逐级查找每个路径组件,最终定位到目标文件或其父目录,并返回对应的 inode。

通过这些函数的协作,xv6 实现了完整的文件树遍历、文件访问和目录管理功能,为上层系统调用(如 openreadwritelinkunlink)提供了坚实的基础。

34:管道 (Pipes) 🚀

在本节课中,我们将学习 xv6 操作系统内核中管道的实现。管道是一种进程间通信机制,允许一个进程向另一个进程发送数据流。我们将深入探讨管道的数据结构、工作原理以及相关的核心函数。


概述

管道在 Unix 系统中类似于文件,你可以向它写入数据,也可以从中读取数据。但与存储在磁盘上的文件不同,管道完全存在于内存中。一个进程(写入者)可以向管道写入数据,另一个进程(读取者)可以从管道读取数据。数据存储在操作系统内核内存的一个有限大小的缓冲区中。如果写入者写入过多数据,内核会将其挂起,直到缓冲区不再满。同样,如果读取者试图读取数据但缓冲区为空,内核会将其挂起,直到写入者向管道写入数据。


管道数据结构

每个打开的管道在内核中都会分配并使用一个 pipe 结构体。这个结构体包含了管理管道所需的所有信息。

struct pipe {
  struct spinlock lock; // 保护结构体其余字段的自旋锁
  char data[PIPESIZE];  // 数据缓冲区,大小为 PIPESIZE (512 字节)
  uint nread;           // 已读取的字节数索引
  uint nwrite;          // 已写入的字节数索引
  int readopen;         // 读端是否打开的标志
  int writeopen;        // 写端是否打开的标志
};

上一节我们介绍了管道的概念,本节中我们来看看其核心数据结构。pipe 结构体包含一个自旋锁,用于保护缓冲区、读写索引以及表示管道两端是否打开的标志位。


缓冲区与索引机制

管道的缓冲区是一个环形缓冲区。nread 索引指示下一个要从缓冲区中读取的字节位置,而 nwrite 索引指示下一个要写入缓冲区的字节位置。

缓冲区被视为一个无限的字节序列,nreadnwrite 索引可以远远超过 PIPESIZE (512)。我们只在访问缓冲区数据时才进行取模运算 (% PIPESIZE)。这种方法避免了空缓冲区和满缓冲区状态混淆的问题,是一种更优的实现方式。

nread 等于 nwrite 时,缓冲区为空。当 nwrite 等于 nread + PIPESIZE 时,缓冲区为满。


管道与文件描述符

每个进程由一个 proc 结构体表示,其中包含一个打开文件的数组 (ofile)。用户代码通过文件描述符(一个小整数,如 0, 1, 2)来引用文件,内核使用这个数字作为 ofile 数组的索引。数组中的每个条目指向一个 file 对象。

file 对象有一个类型字段。对于管道,其类型为 FD_PIPE。它还有一个指针,指向具体的管道对象 (pipe)。此外,file 对象还有 readablewritable 标志位。

对于一个管道,有两个 file 对象:一个用于读端(readable 为真,writable 为假),另一个用于写端(writable 为真,readable 为假)。这两个 file 对象都指向同一个 pipe 结构体。

file 对象有一个引用计数,记录有多少个进程打开了它。而 pipe 结构体中的 readopenwriteopen 标志位则分别指示读端和写端的 file 对象是否存在。当这两个标志位都为假时,表示没有 file 对象指向该管道,此时可以释放其占用的内存页。


管道的创建与初始化

当进程调用 pipe 系统调用时,内核会分配一个管道。以下是 pipealloc 函数的主要步骤:

  1. 分配文件对象:调用 filealloc 两次,分别分配读端和写端的 file 对象。
  2. 分配内存页:调用 kalloc 分配一个物理内存页,用于存放 pipe 结构体。
  3. 初始化管道:初始化 pipe 结构体的自旋锁、读写索引和打开标志位。
  4. 初始化文件对象:设置两个 file 对象的类型、读写标志,并让它们指向新创建的 pipe 结构体。
  5. 错误处理:如果任何一步失败,则清理已分配的资源并返回错误。

这个函数最终通过指针参数返回两个 file 对象的指针给调用者。


从管道读取数据

piperead 函数负责从管道读取数据。它接收一个指向 pipe 结构体的指针、一个用户空间地址和一个要读取的最大字节数。

以下是该函数的核心逻辑:

  1. 获取锁:首先获取保护管道结构的自旋锁。
  2. 检查缓冲区:在循环中,检查管道是否为空 (nread == nwrite)。
    • 如果为空且写端仍打开 (writeopen 为真),则调用 sleep 等待写入者。进程将在 &p->nread 这个“通道”上休眠。
    • 如果为空且写端已关闭,则直接返回 0,表示没有数据可读。
  3. 复制数据:如果缓冲区有数据,则逐个字节地从缓冲区 (p->data[nread % PIPESIZE]) 复制到用户空间地址 (addr + i)。
  4. 唤醒写入者:每读取一个字节后,递增 nread。读取完成后,如果有写入者因为缓冲区满而在等待 (sleep&p->nwrite),则调用 wakeup 唤醒它们。
  5. 释放锁并返回:释放自旋锁,返回实际读取的字节数。

向管道写入数据

pipewrite 函数负责向管道写入数据。它接收一个指向 pipe 结构体的指针、一个用户空间地址和要写入的字节数。

以下是该函数的核心逻辑:

  1. 获取锁:首先获取保护管道结构的自旋锁。
  2. 检查读端:在循环开始前,检查读端是否打开。如果已关闭,则返回 -1。
  3. 写入循环:尝试写入所有 n 个字节。
    • 检查缓冲区满:如果缓冲区满 (nwrite == nread + PIPESIZE),则先唤醒可能正在等待的读取者 (wakeup(&p->nread)),然后自己在 &p->nwrite 通道上 sleep,等待缓冲区有空间。
    • 复制数据:从用户空间地址 (addr + i) 复制一个字节到管道缓冲区 (p->data[nwrite % PIPESIZE])。
    • 更新索引:递增 nwrite 和已写入字节计数 i
  4. 唤醒读取者:写入完成后,如果有读取者因为缓冲区空而在等待 (sleep&p->nread),则调用 wakeup 唤醒它们。
  5. 释放锁并返回:释放自旋锁,返回实际写入的字节数(正常情况下应等于 n)。

关闭管道

当进程关闭一个管道文件描述符时,最终会调用 pipeclose 函数。它接收一个指向 pipe 结构体的指针和一个标志位,指示是关闭读端还是写端。

以下是该函数的核心逻辑:

  1. 获取锁:获取管道自旋锁。
  2. 更新标志位
    • 如果关闭写端,将 p->writeopen 设为 0,并唤醒所有在 &p->nread 上等待的读取者(它们将发现写端已关闭并返回 0)。
    • 如果关闭读端,将 p->readopen 设为 0,并唤醒所有在 &p->nwrite 上等待的写入者(它们将发现读端已关闭并返回 -1)。
  3. 判断是否释放管道:检查 p->readopenp->writeopen 是否都为 0。如果是,则表示管道的两端都已关闭,没有 file 对象再引用它。此时可以释放管道结构体所占用的整个内存页 (kfree(p))。
  4. 释放锁:最后释放自旋锁。


总结

本节课中我们一起学习了 xv6 内核中管道的完整实现。我们了解了管道如何通过一个环形的内存缓冲区在进程间传递数据,以及其相关的 pipe 数据结构。我们详细分析了管道的创建 (pipealloc)、读取 (piperead)、写入 (pipewrite) 和关闭 (pipeclose) 这四个核心函数的运作机制,包括它们如何使用自旋锁进行同步、如何在缓冲区空或满时让进程睡眠与唤醒。管道是 Unix 系统进程间通信的基石之一,其简洁而有效的设计思想值得我们深入体会。

35:文件描述符与打开的文件 📂

在本节课中,我们将学习 XV6 内核中文件描述符和打开文件的核心概念。我们将探讨文件描述符如何作为进程与文件、管道和设备之间的桥梁,并深入了解内核中用于管理这些资源的 file 结构体。通过分析相关代码,我们将理解文件如何被打开、共享和关闭。

概述

“文件”一词有多种含义,容易造成混淆。它可能指磁盘上的常规文件、目录(也是一种文件)或设备文件。在 C 语言编程中,用户态程序使用 FILE 结构体,并通过库函数(如 fopenfread)操作文件。然而,这些库函数最终会调用 openreadwriteclose 等系统调用,通过一个称为“文件描述符”的小整数与内核通信。

在 XV6 内核中,有一个名为 file 的核心数据结构,它代表了内核视角下的一个“打开的文件”。本讲将详细解析这个结构体及其管理机制。

核心数据结构:struct file

内核使用 struct file 来表示一个打开的文件。它定义在 file.h 中,主要包含以下字段:

struct file {
  enum { FD_NONE, FD_PIPE, FD_INODE, FD_DEVICE } type;
  int ref; // 引用计数
  char readable; // 可读标志
  char writable; // 可写标志
  struct pipe *pipe; // 指向管道(如果类型是 FD_PIPE)
  struct inode *ip; // 指向索引节点(如果类型是 FD_INODE 或 FD_DEVICE)
  uint off; // 文件偏移量(用于常规文件/目录)
  short major; // 主设备号(用于设备文件)
};
  • type: 表示文件类型:未使用(FD_NONE)、管道(FD_PIPE)、索引节点文件(FD_INODE,指常规文件或目录)或设备(FD_DEVICE)。
  • ref: 引用计数,记录有多少个指针指向这个 file 结构体。当 ref 为 0 时,该结构体空闲可用。
  • readable / writable: 标志位,指示此打开实例是只读、只写还是可读写。
  • pipe: 如果类型是 FD_PIPE,则指向对应的 struct pipe
  • ip: 如果类型是 FD_INODEFD_DEVICE,则指向对应的 struct inode(索引节点)。
  • off: 文件当前的读写偏移量(仅对 FD_INODE 类型有效)。
  • major: 主设备号(仅对 FD_DEVICE 类型有效)。

文件描述符与进程的关系

每个进程都有一个 struct proc 结构体,其中包含一个 struct file* 类型的数组 ofile

struct proc {
  // ... 其他字段
  struct file *ofile[NOFILE]; // 打开文件表
  // ... 其他字段
};

常量 NOFILE 定义了每个进程最多能同时打开的文件数量(在 XV6 中通常是 16)。这个数组的索引就是文件描述符(一个小的整数)。

当进程进行 open 系统调用时,内核会找到一个空闲的 file 结构体进行初始化,并将其指针放入 ofile 数组中第一个空闲的位置,然后返回该位置的索引作为文件描述符。随后的 readwriteclose 等系统调用都需要使用这个文件描述符,内核通过它找到对应的 file 结构体。

在 Unix 传统中,文件描述符 0、1、2 通常预留给标准输入(stdin)、标准输出(stdout)和标准错误(stderr)。

文件结构体的共享与偏移量

file 结构体是内核资源,可以被多个进程共享。例如:

  1. 当一个进程调用 fork() 创建子进程时,子进程会复制父进程的整个 ofile 数组。这意味着子进程获得了指向相同 file 结构体的指针,内核会增加这些 file 结构体的引用计数 (ref++)。
  2. 两个独立的进程通过 open 系统调用打开同一个文件,会获得两个独立的 file 结构体,但它们指向同一个 inode

偏移量 (off) 是每个 file 结构体独立的。这意味着,即使两个进程共享同一个文件(通过同一个 inode),如果它们拥有各自的 file 结构体,那么它们的读写位置 (off) 是独立的。但如果它们共享同一个 file 结构体(例如通过 fork),那么它们就共享同一个偏移量。

内核如何管理 file 结构体

内核在启动时,会静态分配一个固定大小的 file 结构体数组(在 XV6 中是 100 个),由一个全局变量 ftable 管理:

struct {
  struct spinlock lock;
  struct file file[NFILE];
} ftable;
  • lock: 一个自旋锁,用于保护整个 ftable,主要是保护每个 file 结构体中 ref 字段的并发修改。
  • file[NFILE]: file 结构体数组。

管理这个池的核心函数在 file.c 中:

  1. filealloc(): 分配一个空闲的 file 结构体。它遍历 ftable.file 数组,找到第一个 ref == 0 的项,将其 ref 设为 1,并返回指针。此过程受 ftable.lock 保护。
  2. filedup(struct file *f): 增加一个 file 结构体的引用计数 (ref++)。当 fork 复制文件描述符时调用。
  3. fileclose(struct file *f): 关闭文件。它将引用计数减一 (ref--)。如果 ref 变为 0,则意味着这个结构体不再被任何进程引用,需要被释放。释放过程包括:
    • 如果类型是 FD_PIPE,则调用 pipeclose() 处理管道。
    • 如果类型是 FD_INODEFD_DEVICE,则调用 iput() 减少对应 inode 的引用,必要时释放 inode 或删除磁盘文件。

示例:filestat 系统调用实现

让我们通过 fstat 系统调用的实现,来看如何从文件描述符获取文件信息。用户调用 fstat(fd, &st)

  1. 系统调用入口 sys_fstat() (在 sysfile.c 中) 被触发。
  2. 它调用 argfd(0, &fd, &f) 来获取用户传入的文件描述符 fd,并找到对应的 struct file* f
  3. 接着调用 filestat(f, &st)
  4. filestat 函数(在 file.c 中):
    • 检查 f->type,如果是 FD_INODEFD_DEVICE,则可以通过 f->ip 访问 inode
    • 获取 inode 的锁(一个睡眠锁)。
    • 调用 stati() 函数,将 inode 中的信息(如设备号、inode 号、文件类型、链接数、大小等)填充到一个内核态的 stat 结构体中。
    • 释放 inode 锁。
    • 使用 copyout() 将内核态的 stat 结构体数据复制到用户空间指针 &st 所指向的位置。
  5. 如果一切顺利,返回 0,否则返回 -1。

锁的职责

  • ftable.lock: 保护 file.ref 字段的原子性增减。
  • inode.lock: 保护 inode 本身的内容以及 file.off 字段。因为多个进程可能通过不同的 file 结构体操作同一个 inode,所以对偏移量的读写需要同步。
  • file 结构体的其他字段(如 type, readable, writable, pipe, ip, major)在分配初始化后就不会改变,因此不需要额外的锁保护。

总结

本节课我们一起学习了 XV6 内核中文件描述符和打开文件的核心机制。我们明确了 struct file 是内核内部表示一个“打开上下文”的关键数据结构,它通过引用计数 (ref) 管理生命周期,并通过类型字段区分管道、普通文件和设备。文件描述符是进程 ofile 数组的索引,是用户程序与内核 file 对象交互的句柄。我们还了解了 fork 如何共享文件描述符,以及 fstat 系统调用的实现路径。理解这些概念是掌握操作系统文件系统与 I/O 管理的基础。在下一讲中,我们将继续分析 filereadfilewrite 函数的实现。

36:文件相关系统调用 - 第一部分

概述

在本节课中,我们将学习 xv6 内核中处理文件相关系统调用的代码。我们将重点关注 sysfile.c 文件中的函数,并涉及 file.c 中的 filereadfilewrite 函数。由于代码量较大,本教程分为两部分。第一部分将涵盖 readwritecloselinkunlinkfstatchdirdup 系统调用。


系统调用机制简介

在深入具体系统调用之前,我们先了解 xv6 中系统调用的通用机制。用户态代码通过将参数放入寄存器 a0a1a2 等,并将系统调用编号放入寄存器 a7 来发起系统调用。随后执行 RISC-V 的 ecall 指令,将控制权交给内核。

内核保存用户寄存器后,检查 a7 中的值以确定是哪个系统调用,并调用对应的 sys_* 函数(例如 sys_read)。这些 sys_* 函数使用 argintargaddrargfd 等辅助函数从保存的寄存器中提取参数。


readwrite 系统调用

readwrite 系统调用各接受三个参数:文件描述符、数据缓冲区的虚拟地址以及要读取/写入的字节数。

以下是 sys_read 函数的基本流程:

  1. 使用 argfd 获取文件描述符并验证其有效性,同时获取对应的 file 结构体指针。
  2. 使用 argaddr 获取用户缓冲区的虚拟地址。
  3. 使用 argint 获取要读取的字节数。
  4. 调用 fileread 函数执行实际的读取操作。
  5. 返回读取的字节数或错误代码(-1)。

sys_write 函数的流程与 sys_read 几乎完全相同,只是最后调用的是 filewrite 函数。

fileread 函数详解

fileread 函数根据文件类型决定如何读取数据:

  • 管道:调用 piperead 函数。
  • 设备:根据设备主编号,在设备开关表 devsw 中找到对应的 read 函数并调用它。
  • 索引节点(文件或目录):调用 readi 函数。在读取前后需要锁定和解锁索引节点。如果读取成功,还需要更新文件结构体中的偏移量。
  • 其他类型:触发内核恐慌(panic)。

filewrite 函数详解

filewrite 函数与 fileread 类似,但针对写入操作:

  • 管道:调用 pipewrite 函数。
  • 设备:根据设备主编号,在设备开关表 devsw 中找到对应的 write 函数并调用它。
  • 索引节点(文件或目录):写入操作必须包含在事务中,以确保崩溃一致性。由于事务大小有限制,大的写入操作需要被分割成多个块(chunk)进行。每个块在一个独立的事务中,通过 writei 函数写入,并更新文件偏移量。

close 系统调用

close 系统调用接受一个文件描述符作为参数,用于关闭一个已打开的文件。

sys_close 函数的工作流程如下:

  1. 使用 argfd 获取文件描述符和对应的 file 结构体指针。
  2. 在进程的打开文件表 ofile 中将该文件描述符对应的条目置为 null
  3. 调用 fileclose 函数。
    • fileclose 会减少该 file 结构体的引用计数。
    • 如果引用计数降为0,则根据文件类型(管道或索引节点)进一步释放相关资源(如减少索引节点的引用计数或释放管道结构体)。

dup 系统调用

dup 系统调用复制一个已有的文件描述符,返回一个新的文件描述符,两者指向同一个打开的文件。

sys_dup 函数的工作流程如下:

  1. 使用 argfd 获取要复制的原始文件描述符对应的 file 结构体指针。
  2. 调用 fdalloc 函数在当前进程的打开文件表 ofile 中寻找一个空闲槽位。
  3. 如果找到空闲槽位,fdalloc 会将传入的 file 指针存入该槽位,并返回新的文件描述符编号。
  4. sys_dup 接着调用 filedup 来增加该 file 结构体的引用计数。
  5. 最后,返回新的文件描述符。

注意:Unix 约定文件描述符 0、1、2 分别代表标准输入、标准输出和标准错误。dup 会分配 ofile 数组中第一个可用的槽位,shell 等程序依赖这一行为。


fstat 系统调用

fstat 系统调用用于获取一个已打开文件的状态信息。

sys_fstat 函数的工作流程如下:

  1. 使用 argfd 获取文件描述符对应的 file 结构体指针。
  2. 使用 argaddr 获取用户空间缓冲区的虚拟地址,状态信息将被复制到这里。
  3. 调用 filestat 函数,该函数从文件对应的索引节点中提取信息(如类型、设备号、链接数、大小等),并复制到用户缓冲区。

chdir 系统调用

chdir 系统调用用于改变进程的当前工作目录。

sys_chdir 函数的工作流程如下:

  1. 使用 argstr 获取用户空间传入的路径名字符串。
  2. 调用 namei 函数,根据路径名查找对应的索引节点。如果成功,namei 会增加该索引节点的引用计数。
  3. 检查获取到的索引节点类型是否为目录(T_DIR)。如果不是,则释放资源并返回错误。
  4. 如果类型正确,则:
    • 对进程原有的当前工作目录索引节点执行 iput,减少其引用计数。
    • 将进程的当前工作目录指针指向新的目录索引节点。
  5. 返回成功(0)。

link 系统调用创建指向同一个索引节点(文件)的新路径名,即创建硬链接。

sys_link 函数的工作流程如下:

  1. 使用 argstr 获取两个路径名字符串:old(现有文件)和 new(新链接名)。
  2. 开始一个事务,因为涉及磁盘修改。
  3. 调用 namei 根据 old 路径找到目标文件的索引节点(ip),并增加其引用计数。
  4. 检查 ip 的类型,确保它不是目录(目录不能创建硬链接,以维持目录树结构)。
  5. 增加 ip 的链接计数(nlink),并调用 iupdate 准备写回磁盘。
  6. 调用 nameiparent 解析 new 路径,获取其父目录的索引节点(dp)和最终的文件名(name)。
  7. 检查新链接是否与目标文件在同一设备上(硬链接不能跨文件系统)。
  8. 调用 dirlink 函数,在父目录 dp 中添加一个目录项,其索引节点号指向 ip
  9. 如果以上任何步骤失败,都会跳转到错误处理标签,回滚操作(如减少之前增加的链接计数)。
  10. 成功后,释放相关锁和索引节点引用,结束事务,返回 0。

unlink 系统调用删除一个目录项,即移除一个指向文件的硬链接。如果这是最后一个硬链接,文件将被删除。

sys_unlink 函数的工作流程如下:

  1. 使用 argstr 获取要删除的路径名字符串。
  2. 开始一个事务。
  3. 调用 nameiparent 获取目标文件父目录的索引节点(dp)和文件名(name)。
  4. 检查文件名不是 “.” 或 “..”。
  5. 调用 dirlookup 在目录 dp 中查找文件名 name,获取其对应的索引节点(ip)以及该目录项在目录文件中的偏移量(off)。
  6. 检查 ip 的链接数。如果 ip 是目录,则必须为空(仅包含 “.” 和 “..”)才能被删除。
  7. 将目录 dp 中偏移量 off 处的目录项清零(主要是将索引节点号设为0),标记为未使用。调用 writei 写回磁盘。
  8. 减少目标文件索引节点 ip 的链接计数(nlink)。如果 ip 是目录,还需要减少父目录 dp 的链接计数(因为 “..” 指向 dp)。调用 iupdate 更新这两个索引节点。
  9. 释放所有锁和索引节点引用,结束事务,返回 0。

关于锁顺序的说明:在多锁场景中,锁的获取顺序至关重要,错误的顺序可能导致死锁。在 unlink 代码中,通常先锁父目录(dp),再锁目标文件(ip),解锁时则按相反顺序进行。


总结

本节课我们一起学习了 xv6 内核中多个文件相关系统调用的实现,包括 readwriteclosedupfstatchdirlinkunlink。我们看到了这些调用如何通过 sys_* 函数入口,提取参数,并根据不同的文件类型(管道、设备、普通文件/目录)分派到具体的处理函数。关键点包括:文件描述符与 file 结构体的管理、索引节点操作、事务在写操作中的使用,以及维护文件系统一致性(如链接计数)的细节。在下一部分,我们将继续学习 openmkdirmknodpipe 等系统调用。

37:文件相关系统调用 - 第二部分

在本节课中,我们将继续学习 xv6 内核中与文件相关的系统调用。我们将重点分析 openmknodmkdirpipe 这几个系统调用的实现。我们将了解它们如何创建和操作文件系统中的不同对象,并理解文件描述符、inodefile 结构体之间的关系。

上一节我们介绍了一些基础的函数,本节中我们来看看几个更复杂的系统调用实现。

create 函数

create 函数用于在文件系统的某个目录下创建一个新文件。它接收一个类型码,指示要创建的文件类型:目录文件、普通文件或设备文件。

以下是 create 函数的核心逻辑:

  1. 解析路径:调用 nameiparent 解析路径名,获取父目录的 inode 和要创建的文件名。
  2. 检查文件是否存在:在父目录中查找该文件名。
    • 如果文件已存在:
      • 若调用来自 open 系统调用,且已存在的文件是普通文件或设备文件,则直接返回该文件的 inode
      • 若调用来自 mknodmkdir,则返回错误。
    • 如果文件不存在,则继续创建。
  3. 分配 inode:调用 ialloc 分配一个新的 inode,并设置其类型。
  4. 初始化文件
    • 设置硬链接数为 1。
    • 如果是设备文件,设置主设备号和次设备号。
    • 如果是目录文件,需要增加父目录的硬链接数(因为 .. 指向父目录),并在新目录中添加 ... 条目。
  5. 链接到目录:调用 dirlink 将新文件名及其 inode 号添加到父目录中。
  6. 清理并返回:解锁父目录 inode,减少其引用计数,然后返回指向新创建且已锁定的 inode 的指针。

mkdir 系统调用

mkdir 系统调用用于创建一个新目录。它的实现相对简单,主要封装了 create 函数。

以下是 mkdir 的实现步骤:

  1. 从用户空间获取路径名参数。
  2. 开始一个事务(用于磁盘操作)。
  3. 调用 create 函数,指定类型为 T_DIR(目录)。
  4. 如果创建成功,create 会返回一个已锁定的新目录 inode
  5. 解锁该 inode 并减少其引用计数。
  6. 结束事务,成功返回 0,失败返回 -1。

mknod 系统调用

mknod 系统调用用于创建设备文件。其逻辑与 mkdir 非常相似。

以下是 mknod 的实现步骤:

  1. 从用户空间获取三个参数:路径名、主设备号、次设备号。
  2. 开始一个事务。
  3. 调用 create 函数,指定类型为 T_DEV(设备),并传入主次设备号。
  4. 如果创建成功,处理并返回新 inode
  5. 结束事务,成功返回 0,失败返回 -1。

open 系统调用

open 系统调用是打开(或创建)文件的核心接口。它接收一个路径名和一个标志字 flags,返回一个文件描述符。

xv6 中 flags 的定义如下:

  • O_RDONLY: 只读打开。
  • O_WRONLY: 只写打开。
  • O_RDWR: 读写打开。
  • O_CREATE: 如果文件不存在则创建。
  • O_TRUNC: 如果文件存在,将其截断为长度 0。

以下是 sys_open 函数的实现流程:

  1. 获取参数:获取路径名和 flags
  2. 开始事务:因为可能涉及创建或修改文件。
  3. 打开或创建文件
    • 如果 flags 包含 O_CREATE,则调用 create 尝试创建/打开文件。
    • 否则,调用 namei 直接按路径名查找打开文件。
    • 两种方式成功都会返回一个 inode 指针(引用计数已增加且处于锁定状态)。
  4. 权限与类型检查
    • 如果打开的是目录,则只允许以只读模式(flags == 0)打开。
    • 如果打开的是设备文件,检查主设备号是否有效。
  5. 分配文件结构体:调用 filealloc 分配一个 struct file
  6. 分配文件描述符:调用 fdalloc 在当前进程的打开文件表中找到一个空闲槽位,使其指向刚分配的 struct file
  7. 初始化 struct file
    • 设置 type 字段:设备文件设为 FD_DEVICE,目录或普通文件设为 FD_INODE
    • 设置 ip 字段指向获取到的 inode
    • 根据 flags 设置 readablewritable 标志。
    • 对于 FD_INODE 类型,初始化文件偏移 off 为 0。
    • 如果 flags 包含 O_TRUNC 且文件是普通文件,则调用 itrunc 截断文件。
  8. 清理并返回:解锁 inode(但保持其引用计数,因为 struct file 现在持有引用),返回分配到的文件描述符。

pipe 系统调用

pipe 系统调用创建一个管道,用于进程间通信。它接收一个指向长度为 2 的整数数组的指针,并将读端和写端的文件描述符填入该数组。

以下是管道创建后的数据结构关系图:

进程A的 proc 结构体
+-------------------+
| ofile[] 数组      |
| ...               |
| [2] -> *file (读) |---> struct file { type=FD_PIPE, readable=1, writable=0, pipe=*pipe }
| ...               |
| [5] -> *file (写) |---> struct file { type=FD_PIPE, readable=0, writable=1, pipe=*pipe }
+-------------------+
                                     |
                                     v
                              struct pipe {
                                char data[PIPESIZE];
                                int nread;
                                int nwrite;
                                int readopen;
                                int writeopen;
                              }

以下是 sys_pipe 函数的实现步骤:

  1. 获取参数:获取用户空间数组的地址。
  2. 分配管道:调用 pipealloc 分配一个 struct pipe 以及两个 struct file(分别代表读端和写端),并初始化它们与管道的关系。
  3. 分配文件描述符:两次调用 fdalloc,为读端和写端的 struct file 在进程打开文件表中分配槽位,获得两个文件描述符 fd0fd1
  4. 复制到用户空间:使用 copyoutfd0fd1 这两个整数值写入用户传入的数组地址。
  5. 错误处理:如果任何一步失败,需要关闭已分配的文件描述符(这会自动释放管道资源),并返回 -1。
  6. 成功返回:返回 0。

本节课中我们一起学习了 xv6 中 openmknodmkdirpipe 这几个关键文件系统调用的实现细节。我们看到了它们如何利用底层的 inode 操作、事务机制以及文件描述符表来为用户程序提供文件访问的抽象。exec 系统调用更为复杂,我们将在下一节课中专门讲解。

38:Exec 系统调用

在本节课中,我们将要学习 xv6 操作系统中 exec 系统调用的工作原理。exec 是进程加载和执行新程序的核心机制,它综合运用了文件系统、内存管理和进程管理的知识。我们将从可执行文件的格式讲起,逐步深入到内核中 exec 的具体实现。

可执行文件格式:ELF

为了理解 exec 系统调用,我们首先需要了解可执行文件在磁盘上是如何组织的。在 Unix 世界中,可执行文件通常采用 ELF 格式。

ELF 文件以一个固定格式的 文件头 开始,它指明了文件中其他部分的位置。文件头之后是一个 程序头表 数组,每个程序头结构相同。程序头表之后是若干个 ,实际要加载到内存的代码和数据就位于这些段中。每个段恰好由一个程序头指向,程序头包含了该段在文件中的位置信息。段之后可能还有其他部分,如节头表,但本节课中我们不会涉及。

ELF 文件头

让我们更详细地看一下文件头。它包含多个字段:

  • 魔数:必须是固定的四个字节 0x7f, ‘E‘, ‘L‘, ‘F‘。内核通过检查这个魔数来确认文件是否为 ELF 格式。
  • 字长:指示这是 32 位还是 64 位文件。
  • 字节序:指示数据是小端序还是大端序。
  • 机器类型:指示代码的目标处理器架构(如 x86, RISC-V)。
  • 类型:指示文件类型(如可执行文件、可重定位文件)。
  • 程序头表偏移和数量:指示程序头表在文件中的起始位置和包含的程序头数量。
  • 入口点:包含程序开始执行的地址。

在 xv6 中,我们主要关注魔数、入口点以及程序头表偏移和数量,其他字段会被忽略。

程序头

程序头描述了如何将文件中的段加载到内存。其结构包含以下关键字段:

  • 类型:我们只关心类型为 1可加载段
  • 标志位:指示该段在加载到内存后应被标记为 可执行可写可读
  • 偏移:段数据在 ELF 文件中的起始位置。
  • 虚拟地址:段应被加载到的虚拟内存地址。
  • 文件大小:段在文件中的数据量。
  • 内存大小:段在虚拟内存中应占用的空间。它可以大于文件大小,多出的部分由内核用零填充。

在 xv6 中,我们假设所有段都从页边界开始对齐,并忽略物理地址和对齐字段。

上一节我们介绍了可执行文件的 ELF 格式,本节中我们来看看 xv6 内核如何通过 exec 系统调用来加载并执行这样的文件。

系统调用入口:sys_exec

exec 系统调用接收两个参数:

  1. path:指向要加载的可执行文件路径名的指针。
  2. argv:指向字符串指针数组的指针,这些字符串是传递给新程序的参数。数组以一个空指针结尾。

这些参数都位于调用进程的虚拟地址空间中。在 xv6 中,对应的内核函数是 sys_exec(位于 sysfile.c)。它的主要任务是从用户空间安全地获取这些参数,然后调用真正的 exec 函数完成工作。

以下是 sys_exec 函数的主要步骤:

  1. 复制路径名:使用 argstr 函数将用户空间的路径名字符串复制到内核的局部变量 path 中。
  2. 获取参数数组地址:将用户空间 argv 数组的地址保存到局部变量 uargv 中。
  3. 准备参数存储:初始化一个内核空间的指针数组 argv(最大容量为 MAXARG,即32个),所有元素先置为空。
  4. 循环复制每个参数
    • 从用户空间 uargv 数组中获取第 i 个字符串的地址。
    • 如果地址为空,说明参数列表结束,跳出循环。
    • 否则,为这个字符串在内核中分配一个完整的内存页。
    • 使用 fetchstr 函数将用户空间的字符串(包括终止的空字符)复制到这个新分配的页中。
    • 将指向这个内核页的指针保存在 argv[i] 中。
  5. 调用核心函数:将所有参数准备完毕后,调用 exec 函数,传入 pathargv
  6. 错误处理:如果 exec 执行失败返回,或者在准备参数过程中出错,函数会跳转到 bad 标签,释放所有已分配的内核页,并向用户进程返回 -1

现在,我们已经了解了如何从用户空间获取参数。接下来,我们将进入 exec 函数,看看它如何利用这些信息创建一个全新的进程映像。

核心加载逻辑:exec 函数

exec 函数(位于 exec.c)是加载新程序的真正核心。它接收文件路径名和参数数组指针,如果成功,将开始在新的虚拟地址空间中执行新程序,并永不返回;如果失败,则返回 -1

初始化和文件检查

  1. 开始事务并打开文件:调用 begin_op 开始一个文件系统事务。然后使用 namei 根据路径名找到文件对应的 inode。如果文件不存在或打开失败,则结束事务并返回错误。
  2. 锁定 inode 并读取 ELF 头:锁定该 inode,然后从文件偏移 0 处读取 ELF 文件头到内核变量 elf 中。
  3. 验证 ELF 魔数:检查 elf.magic 是否等于 ELF_MAGIC。如果不匹配,则跳转到错误处理。

创建新地址空间并加载段

  1. 创建新页表:调用 proc_pagetable 为当前进程创建一个新的页表。这个新页表已经包含了位于地址空间顶部的蹦床页和陷阱帧页。如果创建失败,则跳转到错误处理。
  2. 遍历程序头表:这是一个循环,遍历 ELF 文件中的所有程序头。
    • 使用 readi 从文件中读取一个程序头到变量 ph 中。
    • 检查 ph.type 是否为 PT_LOAD(即可加载段)。如果不是则跳过。
    • 进行一系列安全检查:确保 ph.memsz >= ph.filesz;确保虚拟地址 ph.vaddr 是页对齐的;确保 ph.vaddr + ph.memsz 不会发生整数溢出。
  3. 分配虚拟内存:对于每个可加载段,调用 uvmalloc 来扩展新的虚拟地址空间,使其足以容纳该段(大小为 ph.vaddr + ph.memsz)。uvmalloc 会根据程序头中的标志位(可执行、可写)设置页表项的权限。
    • flags2perm(ph.flags) 这个辅助函数将程序头中的标志位转换为页表项权限位。
  4. 加载段数据:调用 loadseg 函数。该函数负责将文件中从偏移 ph.off 开始的 ph.filesz 字节数据,加载到虚拟地址 ph.vaddr 处。它通过循环,每次读取一页数据,并使用 readi 将其写入到已分配的物理页中。

设置用户栈和参数

所有程序段加载完毕后,就完成了对可执行文件本身的操作。接下来需要为用户进程设置栈,并将参数压栈。

  1. 分配用户栈:将当前虚拟地址空间大小 sz 向上舍入到页边界。然后调用 uvmalloc 再分配两个页:一个作为 用户栈,一个作为其下方的 保护页(不可访问,用于检测栈溢出)。栈页被标记为可读、可写。
  2. 设置保护页:调用 uvmclear 将保护页标记为用户不可访问。
  3. 计算初始栈指针:栈是向下生长的。初始栈指针 sp 指向栈页的顶部(即高地址)。stackbase 用于后续检查栈溢出。
  4. 将参数压栈
    • 遍历内核中的 argv 数组。
    • 对于每个参数字符串,计算其长度(包括空字符),将栈指针 sp 向下移动相应距离,并确保 16 字节对齐。
    • 检查栈溢出(sp < stackbase)。
    • 将字符串从内核页复制到栈上的新位置。
    • 将字符串在栈中的地址记录到一个临时数组 ustack 中。
    • 在所有字符串之后,在 ustack 数组中压入一个空指针。
  5. 将参数指针数组压栈
    • 计算 ustack 数组(包含所有字符串指针和结尾的空指针)的总大小。
    • 将栈指针 sp 向下移动该大小,并确保 16 字节对齐。
    • 将整个 ustack 数组复制到栈上 sp 所指的位置。
    • 此时,栈顶 sp 指向的就是新程序的 argv 数组。参数数量 argc 就是字符串的个数。

切换地址空间并返回

  1. 保存程序名:从路径名中提取出最后的文件名(去掉目录部分),并将其复制到进程结构的 name 字段中,用于调试。
  2. 提交更改:此时所有步骤成功,可以提交更改。
    • 释放旧地址空间:保存旧页表指针和大小,然后调用 proc_freepagetable 释放旧地址空间的所有物理页和页表。
    • 切换页表:将进程的页表指针替换为新创建的页表,并更新进程的虚拟地址空间大小。
    • 设置返回上下文:修改进程的陷阱帧:
      • 将程序计数器 epc 设置为 ELF 文件头中的入口地址 elf.entry
      • 将栈指针 sp 设置为我们计算好的新栈顶。
      • 将参数数量 argc 存入寄存器 a0
      • argv 数组的地址(即栈顶 sp)存入寄存器 a1
  3. 返回用户态exec 函数返回 0(实际上,调用者 sys_exec 会将其作为 argc 返回,但成功时内核不会返回到原来的用户代码)。当内核从陷阱返回时,CPU 将使用新的页表、新的程序计数器和新的栈指针,开始执行新程序的代码。新程序会收到正确的 argcargv 参数。

错误处理

在上述任何步骤中,如果发生错误(如文件不存在、内存不足、格式错误、栈溢出等),代码都会跳转到 bad 标签。在 bad 中,内核会:

  • 如果已创建新页表,则释放它及其所有物理页。
  • 如果文件 inode 已打开,则解锁并释放它。
  • 结束文件系统事务。
  • 返回 -1 给上层调用者(sys_exec),后者会负责清理为参数分配的内核页。

总结

本节课中我们一起学习了 xv6 操作系统中 exec 系统调用的完整流程。我们首先了解了 ELF 可执行文件的基本格式,包括文件头和程序头。然后,我们深入内核代码,看到 sys_exec 如何安全地从用户空间获取参数。最后,我们详细分析了 exec 函数的每一步:它如何验证文件、创建新的虚拟地址空间、将程序的各个段加载到内存、精心设置用户栈并布置参数,最终通过切换页表和陷阱帧,让进程“脱胎换骨”,开始执行全新的程序。exec 是进程管理的关键,它使得进程复用成为可能,是 Unix “一切皆文件”和进程模型的重要体现。

posted @ 2026-03-29 09:15  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报