0%

2025年春夏开源操作系统训练营前三阶段总结-moot

前言

本人是大三的计科专业学生,上学期学习了操作系统,颇有兴趣,这学期听说了 opencamp os 训练营,就来深入学习一下。本文是我在完成前三阶段任务后的阶段性总结,主要回顾这一个月的学习历程,并对知识进行整理与查漏补缺。

  • rust
    本人在参加训练营之前在 rust 语言上是 0 基础。我花了一周的时间学习了基本语法和特性,并通过了 Rust 编程的阶段。虽然如此,但我对 rust 的掌握还不是很深刻。
    在做 rCore 和 ArceOS 的过程中也会遇到很多语言上的困难,只能走一步看一步,一边完成 os 的编程任务,一边补充对 rust 的理解。
  • rCore
    在上学期学习操作系统期间已经了解到有 rCore 这个练习项目,并粗略的看过 《rCore-Tutorial-Book 第三版》的前两章节,只是有了一个大概的了解。这个学期参加了训练营,希望通过学习 rCore 提升工程能力与对 os 的理解。大概花了两周多的时间完成了 rCore-Camp-2025S,期间在内存管理的部分花费了比较多的精力,重点是对多级页表和内核空间与用户空间的理解。在完成了 rCore-Camp-2025S 后,虽不能说对 rCore 了如指掌,但我已掌握了 os 设计的基本思想,当然仍有很多细节上的问题我没有搞清楚的,未来还需要下功夫。
  • ArceOS
    这一阶段着重于组件化操作系统的设计与实现,从 Unikernel 模型逐步过渡到宏内核,再进一步到 Hypervisor 架构,核心在于基于组件构造内核。

rust

经过一段时间对 Rust 编程语言的学习,我对它的设计理念、语法特性和工程实践有了更深的理解。Rust 是一门强调安全性、并发性和性能的系统级语言,它提供了现代化语言的表达能力,同时避免了传统系统编程语言(如 C/C++)中常见的内存错误。Rust 不好学,但很值得。它强制你显式处理所有权、生命周期、错误、并发,这在一开始确实挺“痛苦”,但长远来看,它让你写出的是不会在上线后半夜爆炸的代码。我现在写其他语言时也会下意识想:“这里会不会有数据竞争?有没有提前释放?”这也说明 Rust 的设计理念对我有了影响。

核心知识点

所有权与生命周期

Rust 的突出亮点是 所有权(Ownership),它配合 借用(Borrowing) 和 生命周期(Lifetimes) 构建了一套静态分析机制,编译器可以在编译期验证:

  • 谁拥有一段数据,什么时候释放,不需要手动 free;
  • 可变借用和不可变借用不能同时存在,防止数据竞争;
  • 生命周期 ‘a 标注虽然有点难啃,但一旦理解,能让接口更稳健。

    模式匹配和枚举

    Rust 的 match 语法真的很香,不管是处理状态机、分支逻辑,还是错误处理都很顺手。
    特别是配合 Option 和 Result 类型,可以优雅地替代 null 和异常。处理错误不再是随便 try 一把,而是显式地告诉你:这个函数可能失败,你要明确处理。

    借用检查器

    Rust 的借用规则和“编译时检查”是它安全模型的核心:要么拥有所有权,要么借用,但不能两者兼得。比如:
  • 同一时间只能有一个可变引用,或多个不可变引用;
  • 不能用悬垂指针,因为你根本构造不出来;
  • 编译器层面就会排除很多危险的操作;
    虽然一开始你可能会遇到很多 borrow checker 报错,但久了就会发现,它其实是在教你写出更清晰的数据流。

    零成本抽象

    Rust 的抽象设计很克制:
  • 它有泛型、有 trait、有迭代器、甚至还有 async;
  • 但这一切都不会牺牲运行时性能(编译器把该内联的都内联了);
  • 写出来的代码既优雅又快,不像 C++ 那样容易写成模板地狱。

    并发编程

    在并发方面,Rust 的 Send 和 Sync trait 保证了数据在多线程中使用时不会出错。
  • 想跨线程传东西?得先确保类型是 Send 的;
  • 多线程共享引用?你得保证它是 Sync 的;
  • Arc<Mutex> 是经典组合,我也用它实现了线程安全的计数器和资源池。
    这不是“语法糖”或者“运行时报错”,而是真正的 编译期内存安全,是 Rust 拿手的东西。

rCore

第一章:应用程序与基本执行环境

本章描述了如何从零构建一个裸机执行环境,我们希望将程序移植到 RICV 目标平台 riscv64gc-unknown-none-elf 上运行,为了以裸机平台为目标编译程序,我们要将对标准库 std 的引用换成核心库 core。为此,我们需要移除标准库依赖,并要给 rust 编译器编译器提供入口函数 _start()。需要注意的是,在构建用户态最小化执行环境,需要考虑 QEMU 的两种运行模式:

  • User mode 即用户态模拟,如 qemu-riscv64 程序, 能够模拟不同处理器的用户态指令的执行,并可以直接解析 ELF 可执行文件, 加载运行那些为不同处理器编译的用户级 Linux 应用程序。
  • System mode 模式,即系统态模式,如 qemu-system-riscv64 程序, 能够模拟一个完整的基于不同 CPU 的硬件系统,包括处理器、内存及其他外部设备,支持运行完整的操作系统。
    我们需要在 _start 中使用操作系统提供的 exit 系统调正确的退出程序。
    在完成裸机上的最小执行环境的基础上,我们希望更进一步,即设置正确的程序内存布局。可以通过链接脚本调整链接器的行为,使得最终生成的可执行文件的内存布局符合我们的预期。在 linker.ld 中定义了几个关键的全局变量和数据段,每个段都有两个全局变量给出其起始和结束地址。
  1. BASE_ADDRESS = 0x80200000常量,RustSBI 期望的 OS 起始地址
  2. text程序代码,包括入口 _start 和其他函数体
  3. rodata只读数据段
  4. data数据段,已初始化的全局变量,运行时会从只读区域复制初始化值
  5. bss未初始化的全局变量,在启动时由引导代码清零
  • 接下来需要正确配置栈空间布局,我们在entry.asm中定义了初始的操作系统的栈空间,完成了定义_start的行为,分配栈空间,用boot_stack_top标识栈顶地址的工作。最后我们需要将entry.asm嵌入main.rs中。

第二章:批处理系统

本章着重于实现一个简单的批处理系统,可以理解为将多个程序打包到一起输入计算机;当一个程序运行结束后,计算机会 自动 执行下一个程序。为了保证操作系统不会被用户程序破坏,我们需要引入特权级机制, 实现用户态和内核态的隔离。由于我们没有引入地址空间的概念,当前的用户栈和内核栈处于一个地址空间,仍然存在安全问题。

系统调用

应用程序运行在用户态(即 U 模式), ecall 指令会触发名为 Environment call from U-mode 的异常, 并 Trap 进入 S 模式,执行异常处理,这个过程涉及到了特权级的切换,关键在于对寄存器的保存和恢复工作。

实现 RISC-V 特权级的切换

几个重要的寄存器:
| CSR 名 | 该 CSR 与 Trap 相关的功能 |
| ——- | ——————————————————————– |
| sstatus | SPP 等字段给出 Trap 发生之前 CPU 处在哪个特权级(S/U)等信息 |
| sepc | 当 Trap 是一个异常的时候,记录 Trap 发生之前执行的最后一条指令的地址 |
| scause | 描述 Trap 的原因 |
| stval | 给出 Trap 附加信息 |
| stvec | 控制 Trap 处理代码的入口地址 |

在 Trap 触发时, CPU 会切换到 S 特权级并跳转到 stvec 所指示的位置。 在进入 S 特权级的 Trap 处理之前,必须保存原控制流的寄存器状态,这一般通过栈来完成。为此我们建立了区别于用户栈的内核栈。Trap 的上下文保存与恢复,通过 trap.S 完成。Trap 处理的总体流程如下:首先通过 __alltraps 将 Trap 上下文保存在内核栈上,然后跳转到使用 Rust 编写的 trap_handler 函数 完成 Trap 分发及处理。当 trap_handler 返回之后,使用 __restore 从保存在内核栈上的 Trap 上下文恢复寄存器。最后通过一条 sret 指令回到应用程序执行。

第三章:多道程序与分时多任务

本章着重于实现分时多任务系统,能并发地执行多个用户程序,并调度这些程序。这需要我们完成几个核心功能:

任务切换

与上一章描述的 Trap 控制流切换不同,任务切换并不涉及特权级的变化,而是在同一特权级(S 态)内核中,在多个应用 Trap 控制流之间进行的切换。
当某个应用陷入(Trap)S 态内核处理某项操作时,它会进入内核的 Trap 控制流,执行如系统调用、异常处理等任务。在内核中,Trap 控制流可以调用一个特殊函数 __switch 来实现任务的切换。

  • 调用 __switch 时,当前的 Trap 控制流(我们称为 A)将保存自己的上下文(如寄存器、栈指针等),暂停执行。
  • 随后,CPU 开始执行另一个应用的 Trap 控制流(称为 B)的上下文,从而实现任务切换。
  • 在之后的某个时刻,另一个 Trap 控制流(称为 C)可能会再次调用__switch,切换回之前的 A。
  • __switch 返回后,控制流会从它被调用的位置继续执行下去,仿佛“从未中断”。

管理多道程序

为了方便管理多个用户进程,我们引入了 任务控制块(Task Control Block,简称 TCB)数据结构。每个用户进程都对应一个任务控制块,用于保存其运行状态和上下文信息。

任务状态(TaskStatus)

我们使用 TaskStatus 枚举来表示任务当前的状态。常见的任务状态包括:

  • UnInit:未初始化。任务尚未准备好运行,通常刚被创建或尚未加载完成。
  • Ready:就绪态。任务已经准备好,可以被调度器选中运行。
  • Running:运行态。任务当前正在 CPU 上执行。
  • Exited:退出态。任务已经执行完毕,资源等待回收。
任务控制块(TaskControlBlock)

任务状态与任务上下文(即一组用于任务切换时保存的寄存器信息)被封装在 TaskControlBlock 中。它是调度器管理进程的核心数据结构:

1
2
3
4
5
/// 任务控制块
pub struct TaskControlBlock {
pub task_status: TaskStatus, // 当前任务状态
pub task_cx: TaskContext, // 任务上下文,用于任务切换
}
任务管理器

内核需要一个 全局的任务管理器 来统一管理所有用户任务的生命周期、状态以及调度信息。我们通过 TaskManager 结构体来实现该功能。

1
2
3
4
5
6
7
8
9
10
11
/// 全局任务管理器
pub struct TaskManager {
num_app: usize, // 当前系统中任务的数量
inner: UPSafeCell<TaskManagerInner>, // 使用 UPSafeCell 实现内部可变性与安全并发访问
}

/// 内部实际保存任务信息的结构体
struct TaskManagerInner {
tasks: [TaskControlBlock; MAX_APP_NUM], // 所有用户任务的控制块数组
current_task: usize, // 当前正在运行的任务编号
}

分时多任务系统

现代的任务调度算法基本都是抢占式的,它要求每个应用只能连续执行一段时间,然后内核就会将它强制性切换出去。 一般将 时间片 (Time Slice) 作为应用连续执行时长的度量单位,每个时间片可能在毫秒量级。 简单起见,我们使用 时间片轮转算法 (RR, Round-Robin) 来对应用进行调度。RISC-V 要求处理器维护时钟计数器 mtime,还有另外一个 CSR mtimecmp 。 一旦计数器 mtime 的值超过了 mtimecmp,就会触发一次时钟中断。

Chapter3 练习

我的完成

本章我实现了一个自定义的系统调用 sys_trace,用于提供用户态程序对运行时内部状态的基本追踪与监控能力。该系统调用支持以下三类功能:

  1. 读取操作trace_request = 0):从当前任务的 id 地址处读取一个字节的无符号整数。
  2. 写入操作trace_request = 1):将 data 的最低字节写入当前任务的 id 地址处。
  3. 查询调用次数trace_request = 2):返回当前任务对系统调用编号为 id 的累计调用次数(包含本次查询)。

为了完成对系统调用次数的查询,我在 TaskControlBlock 添加了一下条目,用于记录当前任务每一个系统调用的调用次数。并在 trap_handler 中对其进行更新和维护。

1
pub syscall_counts: [usize; MAX_SYSCALL_NUM]

第四章:地址空间

本章中内核将实现虚拟内存机制,这注定是一趟艰难的旅程。

SV39 多级页表机制

内存控制相关的 CSR 寄存器

通过修改 S 特权级的 satp CSR 来启用分页模式,SV39 分页机制被启用,所有 S/U 特权级的访存被视为一个 39 位的虚拟地址,MMU 会将其转换成 56 位的物理地址。

地址格式与组成

在 RISC-V 的分页机制中,我们采用三级页表结构来管理虚拟内存,并将地址划分为固定大小的页面进行映射。为了简化设计,我们将页面大小固定为 4KB(即 $2^{12}$ 字节)。这样,地址的低 12 位用于表示页内偏移(page offset),高位则用于标识页面编号。

  • 虚拟地址结构(Sv39 模式)
    RISC-V 的 Sv39 虚拟地址采用 39 位宽度,其结构如下:

    1
    2
    3
    | 38..30 | 29..21 | 20..12 | 11..0  |
    | ------ | ------ | ------ | ------ |
    | VPN[2] | VPN[1] | VPN[0] | Offset |
    • VPN[2..0](Virtual Page Number):27 位,标识虚拟页号,分三级用于页表索引。
    • Offset:12 位,页内偏移,用于定位页内具体字节。
  • 物理地址结构
    物理地址在 RISC-V 中通常为 56 位有效地址,但我们实际使用其中的高 44 位表示物理页号:

    1
    2
    3
    | 55..12 | 11..0  |
    | ------ | ------ |
    | PPN | Offset |
    • PPN(Physical Page Number):44 位,标识物理页号。
    • Offset:12 位,页内偏移,与虚拟地址偏移一致。
Sv39 分页模式下的页表项结构(PTE)

SV39 分页模式下的页表项:

1
2
3
| 63..54   | 53..28 | 27..19 | 18..10 | 9..8 | 7   | 6   | 5   | 4   | 3   | 2   | 1   | 0   |
| -------- | ------ | ------ | ------ | ---- | --- | --- | --- | --- | --- | --- | --- | --- |
| Reserved | PPN[2] | PPN[1] | PPN[0] | RSW | D | A | G | U | X | W | R | V |

标志位含义如下:

  • V (Valid):
    仅当 V 位为 1 时,页表项才是合法的,地址翻译才会使用该项。否则,访问该页表项会触发异常。
  • R/W/X (Readable / Writable / Executable):
    控制该页是否可读、可写或可执行。如果 R/W/X 都为 0,且 V = 1,该页表项表示页表中间项(即指向下一级页表)。
  • U (User):
    控制是否允许用户态(U 特权级)访问该页。为 1 表示允许用户态访问;为 0 表示仅内核态可访问。
  • G (Global):
    表示该页是否是全局页。全局页在所有地址空间中都有效,不会因上下文切换而从 TLB 中清除。RustSBI 中一般不使用该位,可忽略。
  • A (Accessed):
    访问位。当页被 CPU 访问(无论是读/写/取指)时,该位自动置为 1。操作系统可定期清零,用于页面置换算法(如 LRU)。
  • D (Dirty):
    修改位。当该页被写入时,CPU 自动将此位设为 1。操作系统可据此判断该页是否需要回写到磁盘或交换区。
  • RSW (Reserved for Software):
    软件保留位,RISC-V 不定义其含义,通常由操作系统自定义使用(如记录权限信息、辅助状态等)。

用户地址空间与内核地址空间的切换

由于引入了用户地址空间与内核地址空间,并实现了二者的隔离,回忆上一章,当一个应用 Trap 到内核时,sscratch 已经指向了该应用内核栈的栈顶,我们只需要交换 spsscratch 即可完成从用户空间到内核空间的切换。
但在本章引入了用户地址空间与内核地址空间的隔离后,__alltraps 保存 Trap 上下文时,必须通过修改 satp 寄存器从应用地址空间切换到内核地址空间。此时,地址映射关系发生了改变,如果不做特殊处理,CPU 在切换页表后会因地址无法访问或权限不足而中断,导致指令执行失败。
为了解决这一问题,我们引入了跳板(trampoline)机制。跳板是一段特殊的小型代码区,我们将内核地址空间和用户地址空间的高 4KB 的虚拟空间都映射到这段跳板代码所在的物理地址,也就是说,无论当前使用的是内核地址空间还是应用地址空间,跳板的虚拟页始终处于同一个固定地址。

跳板(trampoline)的作用和内容
  • 跳板存储的主要内容是

    • 一小段汇编代码,用于完成地址空间切换的“桥梁”工作。
    • 这段代码执行时,CPU 已切换到新的 satp 所指定的页表,但跳板的地址映射在两个地址空间中保持一致,保证跳板代码本身可以被连续执行,不会因切换页表而中断。
  • 跳板完成的核心工作包括

    • 从用户地址空间切换到内核地址空间
      在 Trap 发生时,跳板负责执行切换 satp 寄存器,完成页表切换,确保 CPU 使用内核页表进行地址转换。
    • 保存和恢复 Trap 上下文
      跳板代码调用内核函数前,会将用户态的寄存器状态、程序计数器等 Trap 上下文信息保存到内存中的 TrapContext 结构体中。
    • 切换栈指针
      跳板切换 spsscratch,确保后续内核代码使用内核栈执行,避免用户栈和内核栈混淆。
    • 返回用户态时的逆向操作
      跳板负责恢复用户态 Trap 上下文,切换回用户地址空间的 satp,然后从用户态继续执行。

Chapter4 练习

重写 sys_get_time

对于原来的sys_get_time,我们需要将时间保存在一个地址中,ts: *mut TimeVal。由于本章引入的虚拟地址空间与物理地址空间的映射,我们首先需要将其映射到物理地址后再进行写入,这里调用 translated_byte_buffer即可完成。

重写 sys_trace

对于系统调用的统计,直接迁移即可,对于指定地址的读和写,先将虚拟地址映射到物理地址再进行操作即可。

完成系统调用 sys_mmap

对于给定的起始虚拟地址和长度,我们可以计算出虚拟地址范围,得出虚拟页号的范围,并将 port 通过自己写的函数 parse_prot_to_flags 转换成 MapPermission 类型的 flag,自此完成了准备工作。接下来我们先获取当前任务的页表 token,这样我们就可以拿到当前任务的页表,先通过 translate(vpn) 判断申请的页表是否已经被占用,然后我们就可以通过当前的 TaskManagerInner 读取当前任务的 MemorySet,最后通过 memory_set.insert_framed_area(start_vaddr, end_vaddr, flags) 完成映射工作。

完成系统调用 sys_unmmap

思路与 sys_mmap类似,我们只需要获取当前任务的符号表,再通过page_table.unmap(vpn)即可完成工作。


第五章:进程及进程管理

重要系统调用

fork 系统调用

fork 系统调用用于创建一个新的进程。它会将当前进程(父进程)的完整地址空间、用户上下文、打开的文件描述符等信息复制一份,创建一个子进程。操作系统内核中常用的做法是写时复制(Copy-On-Write, COW):只有当父子进程中的某一方修改内存页时,才真正复制该页,以减少 fork 带来的性能消耗。

exec 系统调用

exec 系统调用用于替换当前进程的地址空间为新程序,执行新的程序代码。

  • 它会加载一个新的可执行文件(二进制格式,如 ELF):
    • 替换原有用户代码段和数据段;
    • 初始化新的用户栈;
    • 设置新的入口地址;
  • 执行成功后,当前进程的原有代码不再执行,新程序开始运行。
  • 不会创建新的进程(仍是原 PID)。
waitpid 系统调用

waitpid 用于让父进程等待子进程退出(回收子进程资源),防止出现“僵尸进程(zombie)”。

进程管理的核心数据结构

为了更好的抽象和管理进程,我们引入了一些新的数据结构

  • 进程标识符 PidHandle
  • 任务控制块 TaskControlBlock
  • 任务管理器 TaskManager
  • 处理器管理结构 Processor
    对于 pid 的分配,我们有一个PidAllocator,其记录了当前未被分配的最小 pid,记为 current,和一个可回收的 pid 队列。当需要分配时先从回收队列里面 pop,若队列为空则分配 current。回收时先检验其有效性,即在回收队列中查找,若合法则加入回收队列。

Chapter5 练习

迁移工作

迁移上一章的 sys_get_time sys_mmap sys_munmap 以适应新的进程结构。由于结构变化不到,因此迁移不难。

spawn 的实现

在基于TaskControlBlocknew()的基础上,将新进程加入父进程的孩子 Vec 即可。

sys_set_priority 和 stride 调度算法

为了实现这一算法,我在TaskControlBlockInner中加入了三个条目:

1
2
3
4
5
6
7
8
9
/// Priority of the process (the number of "tickets" it holds).
/// Larger value means higher scheduling frequency.
pub prio: isize,
/// The stride value, inversely proportional to prio: stride = BIG_STRIDE / prio
/// The smaller the stride, the more frequently the process gets scheduled.
pub stride: usize,
/// Pass value represents the virtual time when the task is scheduled.
/// Each time the task runs, its pass increases by stride.
pub pass: usize,

进程初始 stride 设置为 0,初始优先级设置为 16。

1
pub const BIG_STRIDE: usize = 16_777_216; // 2^24

在任务调度的时候,ready 队列中挑选 stride 最小的作为下一个任务,并加上对应的 pass。

1
2
3
4
5
6
7
8
9
let mut min_index = 0;
let mut min_stride = self.ready_queue[0].inner_exclusive_access().stride; // 提前封装函数获取 stride
for (i, task) in self.ready_queue.iter().enumerate() {
let stride = task.inner_exclusive_access().stride;
if stride < min_stride {
min_stride = stride;
min_index = i;
}
}

第六章:文件系统与I/O重定向

本章实现了文件系统 easy-fs 从内核中分离出来,分成两个不同的 crate:

  • easy-fs 是简易文件系统的本体;
  • easy-fs-fuse 是能在开发环境(如 Ubuntu)中运行的应用程序,用于将应用打包为 easy-fs 格式的文件系统镜像,也可以用来对 easy-fs 进行测试。

文件描述符与文件描述符表

本章为每一个进程控制块中加入文件描述符表的相应字段:

1
pub fd_table: Vec<Option<Arc<dyn File + Send + Sync>>>
  • Vec 的动态长度特性使得我们无需设置一个固定的文件描述符数量上限;
  • Option 使得我们可以区分一个文件描述符当前是否空闲,当它是 None 的时候是空闲的,而 Some 则代表它已被占用;
  • Arc 首先提供了共享引用能力。可能会有多个进程共享同一个文件对它进行读写。此外被它包裹的内容会被放到内核堆而不是栈上,于是它便不需要在编译期有着确定的大小;
  • dyn 关键字表明 Arc 里面的类型实现了 File/Send/Sync 三个 Trait ,但是编译期无法知道它具体是哪个类型(可能是任何实现了 File Trait 的类型如 Stdin/Stdout ,故而它所占的空间大小自然也无法确定),需要等到运行时才能知道它的具体类型。

Chapter6 练习

sys_linkat 与 sys_unlinkat 的实现

为了实现这两个系统调用,我们需要为DiskInode添加一个记录链接数的变量 nlink:u32,同时为File这个 trait 加入了 fn get_inode_id(&self) -> usize

1
2
3
4
5
6
7
8
pub struct DiskInode {
pub size: u32,
pub direct: [u32; INODE_DIRECT_COUNT],
pub indirect1: u32,
pub indirect2: u32,
pub nlink: u32,
type_: DiskInodeType,
}

核心做法是在文件系统中,通过调用 open_file 获取目标文件对应的 OSInode。我在 OSInode 类型下新增了两个接口,分别用于创建和删除目录项(链接):

1
2
3
4
5
6
7
8
/// create new link
pub fn create_new_link(&self, name: &str, old_inode_id: usize) -> Option<Arc<Inode>> {
ROOT_INODE.create_new_link(name, old_inode_id)
}
/// delete link
pub fn delete_link(&self, name: &str) -> Option<Arc<Inode>> {
ROOT_INODE.delete_link(name)
}

核心做法是先做合法判断,即判断当前文件系统是否存在以name命名的文件,在合法的情况下,对于create_new_link,只需要新建一个DirEntry,在根目录下写入即可:

1
2
3
4
5
6
7
// write dirent
let dirent = DirEntry::new(name, old_inode_id as u32);
root_inode.write_at(
file_count * DIRENT_SZ,
dirent.as_bytes(),
&self.block_device,
);

对于delete_link,思路类似,我们只需要找到目标DirEntry并将其清空即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// find dirent
let mut dirent = DirEntry::empty();
for i in 0..file_count {
root_inode.read_at(i * DIRENT_SZ, dirent.as_bytes_mut(), &self.block_device);
if dirent.name() == name && dirent.inode_id() == inode_id {
let empty = DirEntry::empty();
root_inode.write_at(
i * DIRENT_SZ,
empty.as_bytes(),
&self.block_device,
);
found = true;
break;
}
}

fstat 的实现

File这个 trait 加入了 fn stat(&self) -> StatStat内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pub struct Stat {
/// ID of device containing file
pub dev: u64,
/// inode number(文件唯一标识)
pub ino: u64,
/// 文件类型与权限信息
pub mode: StatMode,
/// 硬链接计数
pub nlink: u32,
/// 填充字段(与 Unix/Linux 接口保持兼容)
pad: [u64; 7],
}
/// whether a directory or a file
pub struct StatMode: u32 {
/// null
const NULL = 0;
/// directory
const DIR = 0o040000;
/// ordinary regular file
const FILE = 0o100000;
}

具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fn stat(&self) -> Stat {
let inner = self.inner.exclusive_access();
let mode = if inner.inode.is_dir() {
StatMode::DIR
} else if inner.inode.is_file() {
StatMode::FILE
} else {
panic!("Unknown inode type");
};
let inode_id = inner.inode.get_inode_id();
let nlink = inner.inode.get_nlinks();
let stat = Stat {
dev: 0,
ino: inode_id as u64,
mode,
nlink: nlink as u32,
pad: [0; 7],
};
debug!("[Stat]-dev is {}, ino is {}, mode is {:?}, nlink is {}.", stat.dev, stat.ino, stat.mode, stat.nlink);
stat
}

第七章:进程间通信

这一章我们在已有的文件系统基础上,引入了管道机制,实现了进程之间的数据传递能力,同时也让命令行程序可以使用标准输入输出进行通信,包括常见的 I/O 重定向操作。

管道

管道本质上是一个环形缓冲区,pipe_fd[0] 保存了管道读端的文件描述符,pipe_fd[1] 保存了管道写端的文件描述符,它们都实现了统一的 File trait,可以直接放进进程的 fd_table 中使用。通过 sys_pipe() 系统调用,用户程序可以一次性获取读写两端的文件描述符,用于父子进程或兄弟进程间通信。

文件描述符重定向

在应用中除非明确指出了数据要从指定的文件输入或者输出到指定的文件,否则数据默认都是输入自进程文件描述表位置 0 处的标准输入,并输出到进程文件描述符表位置 1 处的标准输出。
为了实现重定向,我们引入了一个新的系统调用sys_dup,将进程中一个已经打开的文件复制一份并分配到一个新的文件描述符中。

第八章:并发

这一章的重点是引入并发控制机制,主要实现了互斥锁(mutex)信号量(semaphore),让多个线程或进程可以安全地访问共享资源。
我们首先实现了一个简单的自旋锁,作为内核中最基础的锁机制,用来保护临界区。随后基于它实现了阻塞型的 mutex 和 semaphore,允许线程在资源不可用时挂起,等资源就绪再唤醒继续执行。
这些同步原语被封装成了系统调用,用户程序可以通过标准接口使用它们,管理多个线程间的资源访问。
此外,我们还引入了银行家算法来进行死锁检测。通过对每个线程的资源分配、剩余需求和系统可用资源进行建模,判断当前资源申请是否会将系统带入不安全状态,从而避免死锁的发生。
这章的核心是:在并发场景下,资源访问必须是受控的,安全性和公平性需要通过机制保障。

Chapter8 练习

目前我们在内核中实现的 Mutex 和 Semaphore 等同步原语,只通过系统调用操作资源,并不会分析线程或进程之间的资源依赖关系。这将导致用户程序在错误使用同步原语的情况下发生 死锁,而内核无法检测或恢复。为了解决这一问题,我们引入 银行家算法(Banker’s Algorithm),用于在资源分配前检测是否可能导致系统进入不安全状态。为了实现银行家算法,我在 sync 下加入了 banker 模块。核心结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
pub struct Banker {
/// 可用资源数,key 是资源 id(如信号量 id),value 是数量
available: BTreeMap<usize, isize>,

/// 当前各个进程已经占有的资源数量
/// key: pid, value: {res_id -> 数量}
allocation: BTreeMap<usize, BTreeMap<usize, isize>>,

/// 当前各个进程**最多还可能需要**的资源数量
/// key: pid, value: {res_id -> 数量}
need: BTreeMap<usize, BTreeMap<usize, isize>>,
}

核心接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/// 创建新的 Banker 实例
pub fn new() -> Self

/// 注册一个资源种类及其总量(首次使用)
pub fn register_resource(&mut self, id: usize, total: usize)

/// 注册一个线程/任务的资源记录(首次使用)
pub fn register_thread(&mut self, tid: usize)

/// 增加某线程对某资源的最大需求(可多次调用以叠加)
pub fn increase_need(&mut self, tid: usize, id: usize, need: usize)

/// 检查当前系统是否处于安全状态(核心死锁检测)
pub fn is_safe(&self) -> bool

/// 请求资源,若安全则修改 available/need/allocation,返回 true;否则返回 false(拒绝)
pub fn try_allocate(&mut self, tid: usize, id: usize, amount: usize) -> bool

/// 某资源释放时增加 available 数量
pub fn increase_available(&mut self, id: usize, increase: usize)

在每个进程初始化时,同时也会初始化一个 banker。

1
2
3
4
5
6
7
8
pub struct ProcessControlBlock {
/// immutable
pub pid: PidHandle,
/// mutable
inner: UPSafeCell<ProcessControlBlockInner>,
/// banker
pub banker: UPSafeCell<Banker>,
}

ArceOS

这个实验的主要目的在于基于组件构造内核,即所有内核实例都可以基于组合组件的方式,从简单到复杂逐级迭代的进行构建。所有内核模式都可以看作以 Unikernel 模式为基础,朝向特定方向的组件化扩展。 Unikernel 模式内核的特点在于应用与内核:

  • 处于同一特权级 - 即内核态。
  • 共享同一地址空间 - 相互可见。
  • 编译形成一个 Image 而后一体运行,Unikernel 既是应用又是内核,是二者合体。
  • 二者之间无隔离无切换,简单高效,安全性低。

axstd 层面

可修改 axstd/io 下的__print_impl函数,为参数加上与颜色相关的前缀和后缀,即可输出对应的颜色,由于printprintln是调用__print_impl,因此这一处的改动这两个函数。
为了方便对颜色的修改,借助了 axlog 下的with_color。一下是最终实现:

1
2
3
4
5
6
7
8
9
10
11
12
pub fn __print_impl(args: core::fmt::Arguments) {
use core::fmt::Write;
use arceos_api::stdio::{ax_console_write_bytes, ax_console_write_fmt};
if cfg!(feature = "smp") {
// synchronize using the lock in axlog, to avoid interleaving
// with kernel logs
ax_console_write_fmt(with_color!(ColorCode::Yellow, "{}", args)).ok();
} else {
let mut out = stdout().lock();
out.write_fmt(with_color!(ColorCode::Red, "{}", args)).ok();
}
}

axhal 层面

核心在于 axhal/src/lib.rs 下的pub mod consolewrite_bytes,这个函数是将字节输出到终端,因此我们也可以像在 axstd 层面的做法一样,为字节序列加上前后缀即可。为此我们可以做一些有趣的事情,比如在输出字节流时,为每一个字节分配不同的颜色。效果如下:

具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
pub fn write_bytes(bytes: &[u8]) {
use axlog::ColorCode;
const RAINBOW: [u8; 7] = [
ColorCode::Red as u8,
ColorCode::Yellow as u8,
ColorCode::Green as u8,
ColorCode::Cyan as u8,
ColorCode::Blue as u8,
ColorCode::Magenta as u8,
ColorCode::BrightWhite as u8,
];

for (i, &b) in bytes.iter().enumerate() {
let color = RAINBOW[i % RAINBOW.len()];
putchar(b'\x1b');
putchar(b'[');
putchar(b'0' + (color / 10));
putchar(b'0' + (color % 10));
putchar(b'm');

putchar(b);
}

putchar(b'\x1b');
putchar(b'[');
putchar(b'0');
putchar(b'm');
}

support_hashmap

为了在本地(无标准库环境或操作系统内核环境)实现一个高效且安全的哈希映射(HashMap),我在 ulib/axstd/collection 目录下新建了一个自定义的 HashMap 模块,主要设计如下:

1
2
3
4
5
pub struct HashMap<K, V> {
buckets: Vec<Option<Vec<(K, V)>>>,
size: usize,
secret: u128,
}

设计说明:

  • buckets
    使用一个动态数组 Vec 作为桶的容器。每个桶是一个 Option,为空时表示该桶当前没有元素,否则存储一个键值对的向量 Vec<(K, V)>。
  • size
    记录当前哈希表中存储的键值对数量,方便做扩容和性能优化的判断。
  • secret
    使用一个 u128 类型的秘密随机数作为哈希种子,防止哈希碰撞攻击,提升安全性。这个值会在哈希函数中参与计算,确保同样的键在不同实例中的哈希值不同。在 HashMap 初始化时随机生成。用到了axhal 中的一个随机数产生函数 random()。

insert 策略

  • 对于一个需要插入的(k, v),先通过调用哈希函数计算出应该插入的buckets的索引 idx:
    1
    let idx = self.hash(&k) % self.buckets.len();
  • 冲突处理:如果目标桶 buckets[idx] 已存在(即 Some(bucket)),说明发生了哈希冲突。此时把多个冲突的键值对保存在该桶的 Vec<(K, V)> 中。在已存在的桶中,查找是否有相同的键。如果找到了,说明是更新操作(覆盖旧值),将原有的值替换为新值 v,然后直接返回。如果桶中没有相同的键,就将 (k, v) 添加到该桶向量中(追加)。如果该桶原本为空(即 None),则新建一个 Vec 并插入键值对,再用 Some 包装。插入成功后,则增加 self.size。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    match &mut self.buckets[idx] {
    Some(bucket) => {
    for &mut (ref existing_key, ref mut existing_value) in bucket.iter_mut() {
    if existing_key == &k {
    *existing_value = v;
    return;
    }
    }
    bucket.push((k, v));
    }
    None => {
    self.buckets[idx] = Some(vec![(k, v)]);
    }
    }

hash 策略

我构建了一个struct SimpleHasher(u128),并为其实现了core::hash::Hasher的部分功能:

1
2
3
4
5
6
7
8
9
10
11
12
struct SimpleHasher(u128);
impl core::hash::Hasher for SimpleHasher {
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.0 = self.0.wrapping_mul(31).wrapping_add(b as u128);
}
}

fn finish(&self) -> u64 {
(self.0 >> 64) as u64 ^ (self.0 as u64)
}
}

hash 的过程如下:

1
2
3
4
5
6
7
fn hash(&self, k: &K) -> usize {
use core::hash::{Hash, Hasher};

let mut hasher = SimpleHasher(self.secret);
k.hash(&mut hasher);
hasher.finish() as usize
}
  • self.secret 是一个随机的 u128 值,作为哈希初始种子。
  • k 是任意类型,只要它实现了 core::hash::Hash。
  • 调用 k.hash(&mut hasher) 的时候,Rust 会自动将 k 的值按照其类型的 Hash 实现转换为字节序列,并依次传递给 hasher.write(&[u8])。这正是我们为 SimpleHasher 实现的 write 方法所接收的数据。在 write 方法中,我们使用一种简单的乘加哈希策略,对每个字节 b 执行:
    $$
    state = (state \times 31 + b) \bmod 2^{128}
    $$
  • 最后的hasher.finish() as usize是将高 64 位和低 64 位异或,用于输出一个 u64 结果作为哈希值。
    $$
    hash = (state \gg 64) \oplus (state & 0xFFFFFFFFFFFFFFFF)
    $$

bump 内存分配算法

bump 内存分配算法的核心思想是在一段可用的空间内,一端是 Byte 分配器,另一端是 Page 分配器,二者相互无关。

1
2
3
[ bytes-used | avail-area | pages-used ]
| | --> <-- | |
start b_pos p_pos end

简化操作后,我们只需要维护一下这个核心结构即可:

1
2
3
4
5
6
pub struct EarlyAllocator<const PAGE_SIZE: usize> {
start: usize,
end: usize,
b_pos: usize,
p_pos: usize,
}

为 RamFS 支持 rename 操作

rename在用户态函数有两个参数:srcdst,含义就是将 src 重命名到 dst。为了实现这个操作,需要在 axfs_ramfs/src/dir.rs 的VfsNodeOps for DirNode下的加入rename。执行过程是从用户态的rename到底层的RootDirectory下的rename。我们需要对RootDirectory下的rename进行修改,核心思想是通过src_pathdst_path找到各自mountRamFileSystem,然后再在各自的 fs 中找到目标文件,将src_path的文件移除,添加到dst_path即可。具体实现如下,由于用了两层循环,时间复杂度在
$
O(n^2)
$

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
fn rename(&self, src_path: &str, dst_path: &str) -> VfsResult {
self.lookup_mounted_fs(src_path, |src_fs, src_rest| {
self.lookup_mounted_fs(dst_path, |dst_fs, dst_rest| {
if src_rest.is_empty() || dst_rest.is_empty() {
return ax_err!(InvalidInput);
}
let src_rest_pro = src_rest.trim_start_matches('/');
let dst_rest_pro = dst_rest.trim_start_matches('/');
if Arc::ptr_eq(&src_fs, &dst_fs) {
// 同一文件系统内 rename
return src_fs.root_dir().rename(src_rest, dst_rest);
}

let src_root_node = src_fs.root_dir();
let dst_root_node = dst_fs.root_dir();
let src_dir_node = src_root_node
.as_any()
.downcast_ref::<DirNode>()
.ok_or(VfsError::NotADirectory)?;
let dst_dir_node = dst_root_node
.as_any()
.downcast_ref::<DirNode>()
.ok_or(VfsError::NotADirectory)?;
// 检查目标是否已存在
if dst_fs.root_dir().lookup(dst_rest).is_ok() {
return ax_err!(AlreadyExists);
}
let src_node = src_dir_node
.get_children_write()
.remove(src_rest_pro)
.ok_or(VfsError::NotFound)?;
dst_dir_node
.get_children_write()
.insert(dst_rest_pro.to_string(), src_node);
Ok(())
})
})
}

处理缺页异常

实现handle_page_fault响应函数,我们可以先获取当前任务的地址空间,调用addr_space.handle_page_fault()即可完成这个函数。

实现 mmap 系统调用

mmap 系统调用用于将用户空间的一段虚拟地址映射到物理页框,或与文件建立内存映射关系。其实现的核心思路如下:

  1. 获取当前任务的地址空间
    通过当前进程对象获取其地址空间管理器,用于后续虚拟内存操作。
  2. 解析参数并对齐长度
    对传入的 prot 权限标志和 flags 映射标志进行解析,并将 length 向上按页大小对齐。
  3. 处理 MAP_FIXED 和地址分配逻辑
    若设置了 MAP_FIXED,则强制从指定的 addr 开始映射,必须先解除该范围内原有的映射。若未指定 MAP_FIXED,则在用户地址空间范围内搜索一段空闲区域进行映射。
  4. 建立匿名或文件映射
    若设置了 MAP_ANONYMOUS,则仅分配物理页并建立映射,页面内容默认为零。若未设置 MAP_ANONYMOUS,则视为文件映射,需要从对应文件描述符中读取数据填入映射页。
  5. 返回映射起始地址
    返回用户空间可见的映射虚拟地址,供用户访问该映射区域。

实现最简单的 Hypervisor ,响应 VM_EXIT 常见情况