01:介绍与概述 🖥️

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/c26a98cb8df21276baa3ae6daeace192_0.png

在本节课中,我们将要学习 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(): 获取内核运行时间。

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/c26a98cb8df21276baa3ae6daeace192_2.png

用户程序

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

  • cat

  • echo

  • grep

  • kill

  • ln

  • ls

  • mkdir

  • rm

  • wc

局限性说明

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

  • 用户 ID 和登录验证。

  • 文件的读写执行保护位。

  • mount 命令,因此只有一个文件系统。

  • 虚拟地址空间换出到磁盘的功能。

  • 网络支持和进程间通信同步机制。

  • 大量的设备驱动程序。

  • 丰富的应用程序。

总结

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/c26a98cb8df21276baa3ae6daeace192_4.png

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

02:通用特性 🖥️

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/b2b4b866a1ec574fac361a59d6118487_0.png

在本节课中,我们将学习 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:启动流程与代码组织 🚀

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/a59b82e821df9896a77153c2b0f833d6_0.png

在本节课中,我们将学习 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]))

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

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/a59b82e821df9896a77153c2b0f833d6_2.png

总结 📝

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

  1. xv6 代码结构清晰,分为内核与用户空间。

  2. 系统从 entry.S 开始,在多核上并行启动,核心0负责全局初始化,其他核心等待同步。

  3. main 函数是内核初始化的中心,它按顺序初始化各个子系统,并最终启动所有核心的调度器。

  4. 基础头文件 types.hparam.hdefs.h 定义了系统的基本数据类型、常量参数和函数接口,是理解内核代码的基础。

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

04:自旋锁 🔒

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/8896ea6bdf0be38fd30126c199553dba_0.png

在本节课中,我们将学习 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)的作用。

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/8896ea6bdf0be38fd30126c199553dba_2.png

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

05:内存管理

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/883ff0ff844ebe5b6257bf3a26873344_1.png

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

概述

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

核心数据结构与初始化

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

  • 空闲链表:一个指向空闲内存页链表的指针 freelist

  • 链表节点:每个空闲页本身作为一个节点,其开头存储一个指向下一个空闲页的指针(结构体 run)。

  • 自旋锁:一个名为 kmem.lock 的自旋锁,用于保护对空闲链表的并发访问。

  • 内存边界:变量 end 由链接器设置,指向内核代码和数据段之后的首个可用内存地址。

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

内存分配:kalloc 函数

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

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/883ff0ff844ebe5b6257bf3a26873344_3.png

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,看看空闲链表最初是如何建立的。

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/883ff0ff844ebe5b6257bf3a26873344_5.png

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

总结

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

06:用户态系统调用

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/0abed134b4860e5d27189819d79b47c5_0.png

在本节课中,我们将学习用户态程序如何发起系统调用。我们将通过分析一个简单的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,具体观察了系统调用在代码中的使用方式。

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/0abed134b4860e5d27189819d79b47c5_2.png

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

07:RISC-V 架构 🏗️

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/8809b2b6b49ce7388f780f5b258de901_0.png

在本节课中,我们将学习 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)。

    • 引发错误的指令(如非法指令、对齐错误、页错误等)。

  • 中断:异步发生,源自当前指令之外。例如:

    • 定时器中断。

    • 设备中断(如串行通信设备、磁盘)。

  • 软件中断:一种特殊的中断。当定时器中断发生时,运行在机器模式的处理程序需要通知内核(监管者模式代码),它会引发一个软件中断,然后由内核的软件中断处理程序来处理定时器中断的相关事务。

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/8809b2b6b49ce7388f780f5b258de901_2.png

核心编号与物理内存保护

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

  • 核心编号mhartid CSR 包含核心编号,它是硬连线的,无法修改。内核启动时会立即将此值移动到 tp 寄存器,并在内核中保持不变。用户代码可以修改 tp,但每当内核从用户代码重新获得控制权时,会首先恢复自己的寄存器(包括 tp),因此 tp 在内核中永远不会改变。

  • 物理内存保护:RISC-V 提供了物理内存保护系统,用于限制运行在监管者或用户模式的代码对物理内存的访问。其本意是支持安全启动和虚拟机监控程序代码。在 xv6 中,启动时在机器模式下会将其配置为允许完全访问所有物理内存,之后不再更改。


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

08:RISC-V 页表

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/f39da02346c55ba4763f6ae664a7a7cd_0.png

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/f39da02346c55ba4763f6ae664a7a7cd_0.png

在本节课中,我们将要学习地址转换,并描述 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 内存管理的基础。

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/f39da02346c55ba4763f6ae664a7a7cd_2.png

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/f39da02346c55ba4763f6ae664a7a7cd_2.png

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

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/bef91684c29e5c6ec5a990daeb948ba1_0.png

在本节课中,我们将学习 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,将设备中断和软件中断委托给监管者模式。

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

总结 🎯

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/bef91684c29e5c6ec5a990daeb948ba1_2.png

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

10:上下文切换 🔄

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/f5d6a2940eff4f47bd307f796193ef7d_0.png

在本节课中,我们将学习 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),过程会更复杂。用户模式执行时发生陷阱,进入内核。此时,从某种意义上说,它仍是同一个进程的内核线程。调度器线程是另一个独立的线程(图中标为棕色)。如果我们决定要调度另一个线程,就会进行第二次上下文切换:从当前进程的内核线程切换到调度器线程。调度器选择另一个进程后,再切换到那个进程的内核线程。

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/f5d6a2940eff4f47bd307f796193ef7d_2.png

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

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

切换函数:swtch ⚙️

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

  • 当调度器选择要运行哪个进程时,会在函数 scheduler 中调用 swtch 来执行上下文切换。

  • 当一个进程的内核线程想要让出 CPU 时,会在函数 sched 中调用 swtch 来切换到调度器。

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

总结 🎯

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

  1. 时间片轮转的基本流程,涉及陷阱进入内核和 sret 指令返回用户空间。

  2. 从用户线程和调度器线程的不同视角看待执行流。

  3. 多核系统中,进程状态在共享内存中保存和恢复,并通过锁进行保护。

  4. 进程包含用户线程和内核线程,与独立的调度器线程之间的切换。

  5. 上下文切换的核心是由汇编函数 swtch 实现的,它负责保存和恢复寄存器状态。

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

11:内存布局 🗺️

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/97df3c59ba22851e315a64c659573c53_0.png

在本节课中,我们将学习 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 指令。

地址空间挑战与解决方案

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/97df3c59ba22851e315a64c659573c53_2.png

问题在于,保存用户寄存器和加载内核寄存器的代码,是在用户的地址空间中运行的。因此,这些指令及其引用的内存位置必须在用户的虚拟地址空间中。同样,在下面恢复 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:链接内核 🧩

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/9d924c13dc7436ff5882e550a8f85dfe_0.png

在本节课中,我们将要学习如何将多个目标文件链接成一个可执行的内核映像文件。我们将重点分析链接器脚本文件 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 = .;:同样是在定义符号,分别标记代码段尾和整个数据区尾。


https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/9d924c13dc7436ff5882e550a8f85dfe_2.png

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

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

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/6feeb9aa16f9013c104ca27c22fe10cf_0.png

在本节课中,我们将学习 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 处理程序负责重置下一次中断时间,并通过触发一个监管者软件中断的方式,将控制权交还给内核进行真正的调度处理。

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/6feeb9aa16f9013c104ca27c22fe10cf_2.png

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

14:陷阱处理 🖥️

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/8e30442a4939663ca60bdd9d5d14968e_0.png

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

概述

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

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

陷阱处理路线图

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

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/8e30442a4939663ca60bdd9d5d14968e_0.png

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

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 这些函数的实现细节。

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/8e30442a4939663ca60bdd9d5d14968e_2.png

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/8e30442a4939663ca60bdd9d5d14968e_2.png

15:蹦床与陷阱帧 🚀

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/d69e1e306a03a0b1c09a6e8148ba9320_0.png

概述

在本节课中,我们将学习 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 的下一条指令)。

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

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/d69e1e306a03a0b1c09a6e8148ba9320_2.png

总结

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

核心要点

  • 蹦床页面:一段位于固定物理地址的汇编代码,因其在所有地址空间均有映射,成为用户态和内核态之间安全切换的“跳板”。

  • 陷阱帧:每个进程独有的数据结构,是保存和恢复用户执行上下文的中转站。

  • 状态保存与恢复:通过精心设计的汇编代码(uservec/userret)和 C 函数(usertrap/usertrapret)的配合,实现了用户态和内核态执行环境的隔离与透明切换。

  • 控制流:硬件中断触发跳转到 uservec -> 保存上下文并调用 usertrap -> 处理具体陷阱 -> usertrapret 准备返回环境 -> userret 恢复上下文并执行 sret 返回。

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/d69e1e306a03a0b1c09a6e8148ba9320_4.png

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

16:调度与上下文切换 🧵

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/ca78db1d69132c768c7c9fff1fbe774a_0.png

在本节课中,我们将学习 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 指令返回用户空间之间发生的一切。

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/ca78db1d69132c768c7c9fff1fbe774a_0.png

当用户代码执行时发生陷阱(例如定时器中断),内核会调用 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

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/ca78db1d69132c768c7c9fff1fbe774a_2.png

接着,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() 函数详解 🧠

https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/hhp3-xv6-krn/img/517c35e4232493292bb1b5f3bd171b7f_0.png

在本节课中,我们将要学习 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() 设置好通道和状态之前看到这个进程,从而实现了操作的原子性。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐