0%

之前也曾做过os的实验,但是当时所用脚手架代码太多,掩盖了许多底层细节——尤其是内存空间相关的。所以决定做一下rcore,复习os相关知识。

可能因为有相关基础,rcore前四个lab都没怎么费力,只有最后一个lab理解上花了点功夫。究其原因,是因为当时学银行家算法时就没有理解他是怎么运作的。带着先入为主的错误的观点来看实验要求,就感觉百思不得其解了。
我最初以为银行家算法是静态对一个程序进行分析以判断该程序是否会产生死锁的——但实际上银行家算法也可以用于“动态申请🔒时,如果判断可能死锁,就返回错误值”的情况。(实际上对于图灵完备的语言,静态分析反而没办法判断是否会产生死锁)

此外从 rcore 中学到的东西有:

  1. 文件系统的多层设计
  2. os如何分配和填写页表
  3. 具体如何实现线程和进程

写到这里,第二阶段就要告一段落了。rCore的文档真的写的很棒,作为一名非科班的CS爱好者,终于弥补了自己本科时期的遗憾,系统的学完了操作系统,也对git项目开发和rust有所入门。

学完之后最大的感觉是补全了自己的计算机体系结构,之前一直在学一些语言层面的语法,思考代码主要是从逻辑层面,现在能思考一些比较深刻的东西,debug能力也有所增强。

lab1算是对操作系统的基本入门,了解了用户态程序是如何一步步进入到内核态代码进行处理的。前三章读完之后,感觉分时多任务和上个月学的rtos的工作原理有点像,属于同一类系统。也对如何从0开始构建一个裸机应用,后面想试试用rust编写stm32,应该也会很方便。

lab2加深了我对虚拟地址的一些理解,之前了解过可执行文件的地址空间,当时还在想一个应用有4G的地址空间,真的能存下这么多吗?这一章对页表机制讲得很深刻,解答了我之前所遇到的一些困惑。

lab3的编写,让我对进程管理的一些api及实现有了全新的认识,之前的我只是一个pthread的调包工程师,现在对进程的实现和相关流程有了更为本质的理解。

lab4实现的文件系统,来回看了好几遍,真的挺复杂的。最终完成lab,很有成就感,学习了rust的Trait实现,后面的项目可以参考这个架构。

lab5的各种同步机制,算是对Rust同步机制的巩固,在项目中多次用到了多线程,想想挺可笑的,这些没有锁导致的问题,自己在初学阶段真的都有犯过,后面才有一步步接触到了这些同步机制。

看完整个RCore教程,完成了第二阶段的实验,才发现自己浪费了太多时间在一些开发技术上面了。学完操作系统明白了,这些技术本质上都是相通的,无非是各个语言实现的不同,以后要花重点在这些基础上,做一些更偏底层的学习。

最后,感谢训练营,感谢清华大学能提供这次机会学习操作系统,学到了很多,希望下一个阶段自己能坚持走到最后。

一、前言

在过去两周,我学习了rCore操作系统。通过阅读实验指导书,我跟着操作系统的发展历程,学习了批处理系统、多道程序与分时多任务、虚拟地址空间、进程管理、文件系统、进程通信和并发等核心概念,并对rCore的实现有了更深入的认识。以下是我对这两周学习过程的总结。

二、学习内容

  1. 搭建执行环境:

    学习了平台与目标三元组,理解了应用程序执行环境。

  2. 批处理系统:

    学习了批处理系统的基本原理,包括作业调度、作业执行过程。

  3. 多道程序与分时多任务:

    掌握了多道程序设计的基本概念,以及如何实现分时多任务调度。

  4. 虚拟地址空间:

    理解了虚拟内存的概念,包括页表、地址映射。

  5. 进程管理:

    学习了进程的管理、调度,更加深入的理解了fork+exec。

  6. 文件系统:

    掌握了文件系统的基本结构,包括目录、文件。

  7. 进程通信:

    学习了父子进程之间的通信机制——管道。

  8. 并发:

    学习了线程管理机制的设计与实现,理解了同步互斥的实现,包括锁、信号量、条件变量。

三、学习心得

Rust语言的安全性和性能在rCore开发中得到了充分体现,经过阅读系统的源码以及作业的编写,对rust语言在内存安全和并发控制方面的优势有了更深的理解,第一阶段学习rust的很多疑问也得到了解答。

通过学习rCore,我对操作系统的原理有了更深入的理解,虽然考研的时候较为系统的学习了操作系统的知识,但是基本上还是停留在理论知识方面。这次rCore学习之旅,我获取PCB对进程进行操作、实现课本上学习过的系统调用、深入汇编代码理解什么是 '陷入' ,让我对操作系统的设计理念、计算机的体系结构有了具象化的认识。

在学习过程中,我也遇到了许多挑战,包括环境搭建遇到报错、Rust基础不够牢固导致代码编写举步维艰等,但通过不断解决这些问题,我的编程能力和问题解决能力得到了显著提升。

两周的rCore学习之旅让我受益匪浅。通过学习rCore,我对操作系统的设计和实现有了更深刻的认识,同时也提升了我的编程技能。我相信,这些知识和经验将对我未来的学习和职业发展产生积极影响。

一、前言

在过去两周,我学习了Rust编程语言的相关知识。通读了一遍《Rust程序设计语言》书籍并完成了训练营的Rustlings练习。经过两周的学习,我对Rust有了初步的认识和掌握。以下是我对这两周学习过程的总结。

二、学习内容

  1. Rust基础知识
  • Rust编程语言的基本语法,包括变量、数据类型、运算符、控制流等。
  • Rust的所有权系统,包括所有权、借用、生命周期等概念。
  1. Rustlings练习
  • 通过完成一系列练习,巩固对Rust基础知识的理解和应用。
  • 练习涵盖了Rust的所有权、借用、生命周期、错误处理、宏、模式匹配等方面的内容。

三、学习心得

非常感谢训练营的老师和助教帮助,让我能够在两周的时间快速入门rust这门优秀的语言。印象最深的还是最后的算法部份,尤其是前两道关于链表的题目,其实之前一直是使用c++做算法题,对链表也较为熟悉了,但是由于对rust的特性不熟悉以及对链表没有深刻理解,让我有一种有力使不出的感觉,后面通过阅读题目的框架,以及对书本知识的巩固,终于是对rust中的链表有了初步的认识,写完链表的题目后,后续的题目也很快完成了。rust语言的特性让我对编程和计算机有了更深的理解,尽管现在还是写得磕磕绊绊,和编译器打架,但是相信通过不断学习和时间,将来我也能够编写出优秀的rust代码。

碎碎念

拖了好久最后还是写完了,中间碰到些奇怪的问题和bug,卡了好一大气,最后发现问题在什么地方的时候真的血压升高,一整个气烂了。但是最后还是解决了,感觉自己的能力有所提升吧。

最后真的永远也习惯不了写各种报告(捂脸

学习总结

前三个lab

前三个lab的内容大致上还是比较简单,感谢各位老师和助教提供的框架,写的时候可以更注重于OS的机制以及riscv平台本身,不用关注太多的细节。报告里的思考题也挺有帮助的。

前四个lab要求需要整合前面lab实现的功能,在写这几个lab的过程当中,一些部分的实现也在一直调整,变得更加合理以及方便使用。也发现自己一些问题,有时候还是要先想清楚把代码读好再动手,不然会浪费很多时间。

第四个lab

在这个lab上花的时间最长。代码写好以后调试始终是不对。最后发现spawn跑过去之后整个文件系统一整个挂掉了,整个easy-fs乱成了一锅粥。调试了好久好久也搞不清楚什么原因,最后还是换了种实现spawn的方法,先过了测试。猜测可能是在exec的时候不小心写了什么地方?玄学问题以后有时间的时候再研究研究吧。如果真的是把fs的内容给覆写掉的了话,我也真厉害能写出这么恶心的代码。毕竟表面上看完全是不可能的

第五个lab

这个lab是要我们实现一个死锁检测的算法。这个算法之前在课上有学过,这下也算是见到本体了。觉得自己有时候对于并发编程的了解还是不够深入,可能如果加上一些实现并发的代码和lab会更好一些?希望能在后面的课程中学到更多的知识。

心得体会

rust

算是开始接触rust有一段时间了,和编译器斗争越来越熟练。感觉rust是一门很有趣的语言,有时候会出现写其他语言的时候没有的一些奇怪的问题,但是解决了之后能对一些东西有更好的了解,平时会更注意一些。

比如说rust的所有权系统,有时候会觉得很麻烦,但是这个系统也能帮助我们避免一些潜在的bug,也能让我们更好的理解内存和生命周期的管理。这些内容其实在写C/C++的时候也会遇到,但是表现的形式可能更加狂野一点。rust这样更加严格的检查让我们在写代码的时候,设计整个结构的时候更小心,考虑到更多的细节。之前在写C++的时候,发现C++11加入的std::movestd::unique_ptr等等,也是为了表达一些所有权等相关的语义,但是没有强制化的要求。rust的所有权系统更加严格,也更加直观。

希望以后能够继续学习rust。

riscv

riscv和x86比起来简直就是天堂(

riscv指令集更简单,更容易理解。在设计的时候就避免了很多x86遗留的历史设计问题,实现OS的时候更加方便。在学习riscv的过程中,也对计算机体系结构有了更深的了解。觉得riscv是一个很有潜力的平台,希望以后应用更普遍一些吧。

rCore

Chapter 1

目标三元组

目标三元组 (Target Triplet) 描述了目标平台的 CPU 指令集、操作系统类型和标准运行时库。

  • x86_64-unknown-linux-gnu:CPU 架构是 x86_64,CPU 厂商是 unknown,操作系统是 linux,运行时库是 gnu libc。
  • riscv64gc-unknown-none-elf:CPU 架构是 riscv64gc,厂商是 unknown,无操作系统, elf 表示没有标准的运行时库。没有任何系统调用的封装支持,但可以生成 ELF 格式的执行程序。

通过cargo配置文件指定目标平台。

1
2
3
# os/.cargo/config
[build]
target = "riscv64gc-unknown-none-elf"

移除std依赖

RUST默认使用了标准库std,std提供了许多高级功能,如文件系统访问、线程、网络等,这些功能依赖于操作系统的支持。因此,std只能在支持这些功能的平台上使用。对于不支持操作系统功能的平台(如裸机编程或嵌入式系统),需要显式指定使用core。core是std的一个子集,不依赖于操作系统,提供了Rust语言的核心功能。

  • #![no_std]:告诉 Rust 编译器不使用 Rust 标准库 std。
  • #![no_main]:告诉编译器我们没有一般意义上的 main 函数, 并将原来的 main 函数删除。这样编译器也就不需要考虑初始化工作了。

panic_handler

Rust编译器在编译程序时,处于安全性考虑,要求有 panic! 宏的具体实现。

#[panic_handler] 是一种编译指导属性,用于标记核心库core中的 panic! 宏要对接的函数(该函数实现对致命错误的具体处理)。该编译指导属性所标记的函数需要具有 fn(&PanicInfo) -> ! 函数签名,函数可通过 PanicInfo 数据结构获取致命错误的相关信息。

1
2
3
4
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}

结合汇编代码

在裸机编程或嵌入式系统开发中,通常需要定义一个自定义的入口点函数。在Rust中,这个入口点函数通常被命名为_start。

在 fn 关键字之前增加 extern 关键字可以用来导入外部符号。
#[no_mangle] 注解来告诉 Rust 编译器不要 mangle 此函数的名称。Mangling 发生于当编译器将我们指定的函数名修改为不同的名称时,这会增加用于其他编译过程的额外信息,不过会使其名称更难以阅读。每一个编程语言的编译器都会以稍微不同的方式 mangle 函数名,所以为了使 Rust 函数能在汇编代码中使用,必须禁用 Rust 编译器的 name mangling。

Qemu启动流程

  1. 将必要文件载入Qemu模拟的物理内存后,PC被初始化为0x1000。然后执行一段指令后跳转到0x80000000。
  2. 运行位于0x80000000的bootloader(这里使用的是rustsbi-qemu.bin),RustSBI将下一阶段的入口地址设置为0x80200000。
  3. 运行0x80200000开始的程序。

调整内核的内存布局

链接器默认的内存布局并不能符合我们的要求,为了实现与 Qemu 正确对接,我们可以通过链接脚本(Linker Script)调整链接器的行为。

首先通过cargo配置文件指定链接脚本:

1
2
3
4
5
6
7
// os/.cargo/config
...
[target.riscv64gc-unknown-none-elf]
rustflags = [
"-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
# 强制打开 fp 选项,避免 fp 相关指令被编译器优化掉
]

在链接脚本中指定起始地址为0x80200000,:

1
2
3
4
5
6
7
8
OUTPUT_ARCH(riscv)
ENTRY(_start)
BASE_ADDRESS = 0x80200000;
SECTIONS
{
. = BASE_ADDRESS;
...
}

elf文件中除了实际会被用到的代码和数据段之外还有一些多余的元数据,这些元数据无法被 Qemu 在加载文件时利用,且会使代码和数据段被加载到错误的位置。需要通过rust-objcopy --strip-all丢弃这些元数据。

函数调用栈

Chapter 2

配置环境一章给了 GDB 预编译二进制的下载链接,但版本不支持Python脚本。为了提高调试效率,需要自己编译新版本的 GDB。

查看汇编代码发现在rust_main函数中找不到对trap::init函数的调用,初始化CSR stvec的代码被直接内联汇编到了rust_main中。(相比与C语言,要将RUST源码与对应的汇编代码对应起来的难度明显更大,也可能是因为目前对RUST的机制还不太熟悉)

#[link_section = ".text.entry"]宏将编译后的汇编代码放在名为 .text.entry 的代码段中, 供链接脚本使用。

#![feature(linkage)]启用弱链接特性

#[linkage = "weak"]在有多个同名符号时优先使用其他符号

将应用程序链接到内核

在本章中,通过global_asm!(include_str!("link_app.S"));将应用程序的二进制镜像文件作为内核的数据段链接到内核里面。

UPSafeCell

有一些变量(例如本章中的APP_MANAGER)需要作为全局变量使用,但有需要在运行时修改部分成员变量。我们为这些变量将RefCell封装成了UPSafeCell,并为UPSafeCell添加了Sync标记,从而允许我们在单核上安全使用可变全局变量。

1
2
3
4
5
// os/src/sync/up.rs
pub struct UPSafeCell<T> {
inner: RefCell<T>,
}
unsafe impl<T> Sync for UPSafeCell<T> {}

lazy_static!

1
2
3
4
5
6
// os/src/batch.rs
lazy_static! {
static ref APP_MANAGER: UPSafeCell<AppManager> = unsafe { UPSafeCell::new({
...
})};
}

手动实现全局变量初始化有诸多不便之处,比如需要把这种全局变量声明为 static mut 并衍生出很多 unsafe 代码。在这里借助lazy_static!宏来简化代码。

刷新指令缓存(i-cache)

OS 在加载完应用代码后必须使用取指屏障指令 fence.i ,它的功能是保证 在它之后的取指过程必须能够看到在它之前的所有对于取指内存区域的修改 ,这样才能保证 CPU 访问的应用代码是最新的而不是 i-cache 中过时的内容。

特权级切换

RISC-V 提供了新的机器指令:执行环境调用指令(Execution Environment Call,简称 ecall )和一类执行环境返回(Execution Environment Return,简称 eret )指令。其中:

  • ecall 具有用户态到内核态的执行环境切换能力的函数调用指令;
  • sret :具有内核态到用户态(从Superivisor模式返回)的执行环境切换能力的函数返回指令。

操作系统需要负责特权级切换时的上下文保存和恢复。

相关的控制状态寄存器:

  • sstatus:SPP等字段给出切换前的特权级等信息
  • sepc:当Trap是异常时,记录Trap发生前的最后一条指令地址
  • scause:Trap原因
  • stval:Tral附加信息
  • stvec:Trap处理代码的入口地址

当 CPU 执行完一条指令并准备从用户特权级 陷入( Trap )到 S 特权级的时候,硬件会自动完成如下这些事情:

  • sstatus 的 SPP 字段会被修改为 CPU 当前的特权级(U/S)。
  • sepc 会被修改为Trap发生前的最后一条指令地址
  • scause/stval 分别会被修改成这次 Trap 的原因以及相关的附加信息。
  • CPU 会跳转到 stvec 所设置的 Trap 处理入口地址,并将当前特权级设置为 S ,然后从Trap 处理入口地址处开始执行。

Trap上下文

与函数调用类似,在处理Trap之前,需要在某些地方(由操作系统决定)保存必要的寄存器,并在Trap处理结束后恢复这些寄存器。

Trap发生时需要保存的内容:

  • 32个通用寄存器的值(部分寄存器如x0可以不作保存,预留空间主要是为了方便后续编码)
  • sstatus
  • sepc
    scause/stval 的情况是:它总是在 Trap 处理的第一时间就被使用或者是在其他地方保存下来了,因此它没有被修改并造成不良影响的风险。

Trap处理流程

  1. 通过__alltraps保存Trap上下文到内核栈上
  2. 跳转到trap_handler处理Trap
  3. 通过__restore恢复上下文
  4. sret返回

思考:sscratch 是何时被设置为内核栈顶的?
在第一次执行restore时,通过a0传入的参数实际是(KERNEL_STACK.get_sp() - core::mem::size_of::<TrapContext>()) as *mut TrapContext;,并将a0的值赋给了sp。在restore返回之前,先将sp+34*8即TrapContext的大小,然后交换sp和sscratch,从而设置为内核栈顶。

KERNEL_STACK栈顶的TrapContext中,x[1](ra)存储了第一个程序的入口地址,x[2](sp)存储了用户栈顶地址,在__restore执行过程中分别被加载到ra和sp寄存器中,因此当sret返回时就会进入用户态执行第一个程序。

Chapter 3

把应用程序的一次执行过程(也是一段控制流)称为一个 任务 ,把应用执行过程中的一个时间片段上的执行片段或空闲片段称为 “ 计算任务片 ” 或“ 空闲任务片 ” 。当应用程序的所有任务片都完成后,应用程序的一次任务也就完成了。从一个程序的任务切换到另外一个程序的任务称为 任务切换

Task切换与Trap切换主要有以下异同:

  • 与 Trap 切换不同,它不涉及特权级切换;
  • 与 Trap 切换不同,它的一部分是由编译器完成的(可以看作编译时插入额外的汇编代码);
  • 与 Trap 切换相同,它对应用是透明的。

Task上下文

Task切换时需要手动保存的内容:

  • s0~s11
  • ra
  • sp

Task处理流程

  1. 保存当前Task上下文
  2. 加载下一个Task上下文
  3. ret返回

每个任务的TaskContext中,保存的ra初始值都是__restore,因此在第一次通过__switch切换到该任务时,都会跳转到__restore,然后从该任务对应的初始TrapContext中读取程序入口(载入spec寄存器),从而开始运行。

相比于Chapter 2,这里的__restore中不需要mv sp, a0,因为在__switch之后,CPU从TaskContext中读取对应内核栈的栈顶地址,并加载到了sp寄存器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// os/src/task/mod.rs
impl TaskManager {
fn run_next_task(&self) {
if let Some(next) = self.find_next_task() {
let mut inner = self.inner.exclusive_access();
let current = inner.current_task;
inner.tasks[next].task_status = TaskStatus::Running;
inner.current_task = next;
let current_task_cx_ptr = &mut inner.tasks[current].task_cx as *mut TaskContext;
let next_task_cx_ptr = &inner.tasks[next].task_cx as *const TaskContext;
drop(inner);
// before this, we should drop local variables that must be dropped manually
unsafe {
__switch(
current_task_cx_ptr,
next_task_cx_ptr,
);
}
// go back to user mode
} else {
panic!("All applications completed!");
}
}
}

在run_netx_task中,需要手动在__switch前drop获取的TaskManagerInner实例(实际上是通过RefCell的borrow_mut方法获取的TASK_MANAGER.inner的可变借用)。

因为编译器帮我们自动插入的drop会位于作用域的尾部,而在此之前我们就通过__switch切换到了其他任务的执行流中,而在处理其他任务时也可能需要获取TASK_MANAGER.inner的可变借用,因此需要提前调用drop。

中断

当中断产生并进入某个特权级之后,在中断处理的过程中同特权级的中断都会被屏蔽。

Chapter 4

资源获取即初始化 (RAII, Resource Acquisition Is Initialization,指将一个使用前必须获取的资源的生命周期绑定到一个变量上,变量释放时,对应的资源也一并释放。)

alloc 库需要提供给它一个 全局的动态内存分配器 来管理堆空间,从而使得与堆相关的智能指针或容器数据结构可以正常工作。动态内存分配器需要实现它提供的 GlobalAlloc Trait,这个 Trait 有两个必须实现的抽象接口:

1
2
3
// alloc::alloc::GlobalAlloc
pub unsafe fn alloc(&self, layout: Layout) -> *mut u8;
pub unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout);

然后只需将我们的动态内存分配器类型实例化为一个全局变量,并使用 #[global_allocator] 语义项标记即可。由于该分配器的实现比较复杂,我们这里直接使用一个已有的伙伴分配器(buddy_system_allocator)实现。#[alloc_error_handler]标识内存分配失败时的处理函数。

分页内存管理

  • 可用的物理内存被分为若干个(物理)页帧(Frame),用物理页号 (PPN, Physical Page Number)标识
  • 每个应用的地址空间可以被分成若干个(虚拟) 页面 (Page) ,用 虚拟页号 (VPN, Virtual Page Number) 标识
  • 根据VPN可以在页表(Page Table)中查询到页表项 (PTE, Page Table Entry),satp寄存器记录了当前所用的一级页表的物理页
  • PTE记录了对应的PPN和相关的控制信息
  • 手动修改PTE后,需要使用 sfence.vma 指令刷新整个 TLB。注:可以在 sfence.vma 指令后面加上一个虚拟地址,这样 sfence.vma 只会刷新TLB中关于这个虚拟地址的单个映射项。

物理页帧管理

栈式物理页帧管理策略

1
2
3
4
5
6
// os/src/mm/frame_allocator.rs
pub struct StackFrameAllocator {
current: usize, //空闲内存的起始物理页号
end: usize, //空闲内存的结束物理页号
recycled: Vec<usize>,
}

物理页帧的生命周期绑定的一个FrameTracker实例上

1
2
3
4
5
6
7
8
9
10
// os/src/mm/frame_allocator.rs
pub struct FrameTracker {
pub ppn: PhysPageNum,
}

impl Drop for FrameTracker {
fn drop(&mut self) {
frame_dealloc(self.ppn);
}
}

这些 FrameTracker 的生命周期进一步绑定到 PageTable 下面。当 PageTable 生命周期结束后,向量 frames 里面的那些 FrameTracker 也会被回收,也就意味着存放多级页表节点的那些物理页帧被回收了。

1
2
3
4
5
// os/src/mm/page_table.rs
pub struct PageTable {
root_ppn: PhysPageNum,
frames: Vec<FrameTracker>,
}

PageTable提供了from_token 方法可以临时创建一个专用来手动查页表的 PageTable ,它仅有一个从传入的 satp token 中得到的多级页表根节点的物理页号,它的 frames 字段为空,也即不实际控制任何资源。from_token创建的PageTable不能用来修改地址映射

地址空间

PageTable 下挂着所有多级页表的节点所在的物理页帧,而每个 MapArea 下则挂着对应逻辑段中的数据所在的物理页帧,这两部分合在一起构成了一个地址空间所需的所有物理页帧。这同样是一种 RAII 风格,当一个地址空间 MemorySet 生命周期结束后,这些物理页帧都会被回收。

Frame->FrameTracker->PageTable/MapArea->MemorySet

应用地址空间:

跳板(Trapmpoline)和TrapContext位于用户栈栈顶,但是并不包含U标志位,只在地址空间切换时才会用到。因为有了分支机制,在Trap上下文保存和恢复时需要同时完成换栈和换页表

在内核代码和应用代码看来,trap.S中的汇编代码都放在最高虚拟页面中(也就是Trapmpoline中),因此进入trap处理流程时CPU指令能够连续执行。

Chapter 5

fork

实现fork时,需要复制父进程的当前状态创建新的TaskControlBlock,主要是复制进程空间、栈和寄存器数据。

exec

实现exec时,直接在当前进程的TaskControlBlock上进行修改。对于系统调用 sys_exec 来说,一旦调用它之后, trap_handler 原来上下文中的 cx 失效了——因为它是用来访问之前地址空间中 Trap 上下文被保存在的那个物理页帧的,而现在它已经被回收掉了。

spawn

实现spawn时,与fork类似的是创建了新的TaskControlBlock,但没有复制父进程的当前状态,而是类似exec从elf文件中读取信息来创建地址空间。

Chapter 6

块与扇区

扇区 (Sector) 是块设备随机读写的数据单位,通常每个扇区为 512 字节。而块是文件系统存储文件时的数据单位,每个块的大小等同于一个或多个扇区。Linux的Ext4文件系统的单个块大小默认为 4096 字节。在 easy-fs 实现中一个块和一个扇区同为 512 字节。

块缓存

  • 位于内存中的缓冲区
  • 内存中同时只能驻留有限个磁盘块的映射
  • 需要写回磁盘才能真正修改持久性存储的数据

easy-fs磁盘布局

  • 超级块 (Super Block)在内存中对应数据结构SuperBlock,SuperBlock与磁盘上的数据结构一致,没有额外的元数据。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // easy-fs/src/layout.rs
    #[repr(C)]
    pub struct SuperBlock {
    magic: u32,
    // 文件系统的总块数,包括非数据块
    pub total_blocks: u32,
    // 索引节点位图区域占据的块数
    pub inode_bitmap_blocks: u32,
    pub inode_area_blocks: u32,
    pub data_bitmap_blocks: u32,
    pub data_area_blocks: u32,
    }
  • 索引节点位图和数据块位图在内存中对应数据结构Bitmap,但Bitmap本是只存储了相关的元数据,即位图的起始块编号以及占据的块数。实际磁盘上的数据载入到内存中的BitmapBlock实例中。

    1
    2
    3
    4
    5
    6
    // easy-fs/src/bitmap.rs
    pub struct Bitmap {
    start_block_id: usize,
    blocks: usize,
    }
    type BitmapBlock = [u64; 64];
  • 索引节点在内存中对应数据结构DiskInode,SuperBlock与磁盘上的数据结构一致,没有额外的元数据。索引节点本身存储的是对应文件/目录的元数据,即大小、数据块索引、类型。DiskInode大小为128字节。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // easy-fs/src/layout.rs
    #[repr(C)]
    pub struct DiskInode {
    pub size: u32,
    pub direct: [u32; INODE_DIRECT_COUNT],
    pub indirect1: u32,
    pub indirect2: u32,
    type_: DiskInodeType,
    }
  • 数据块在内存中对应DataBlock,DataBlock与磁盘上的数据结构一致,没有额外的元数据。数据块中可能是文件的数据,也可能是目录项DirEntry。

    1
    2
    3
    4
    5
    6
    7
    // easy-fs/src/layout.rs
    type DataBlock = [u8; BLOCK_SZ];
    #[repr(C)]
    pub struct DirEntry {
    name: [u8; NAME_LENGTH_LIMIT + 1],
    inode_number: u32,
    }

    Inode 是放在内存中的记录文件索引节点信息的数据结构。block_id 和 block_offset 记录该 Inode 对应的 DiskInode 保存在磁盘上的具体位置方便我们后续对它进行访问。

1
2
3
4
5
6
7
8
// easy-fs/src/vfs.rs
pub struct Inode {
block_id: usize,
// 偏移量单位是字节
block_offset: usize,
fs: Arc<Mutex<EasyFileSystem>>,
block_device: Arc<dyn BlockDevice>,
}

文件描述符

文件描述符表 (File Descriptor Table) ,其中的每个 文件描述符 (File Descriptor) 代表了一个特定读写属性的I/O资源。

为简化操作系统设计实现,可以让每个进程都带有一个线性的 文件描述符表 ,记录该进程请求内核打开并读写的那些文件集合。而 文件描述符 (File Descriptor) 则是一个非负整数,表示文件描述符表中一个打开的 文件描述符 所处的位置(可理解为数组下标)。

Chapter 8

Appendix

RISC-V Notes

寄存器

RISC-V 的寄存器集由 32 个通用寄存器和一些特殊寄存器组成。

通用寄存器

寄存器 名称 用途
x0 zero 常数 0
x1 ra 返回地址寄存器
x2 sp 栈指针
x3 gp 全局指针
x4 tp 线程指针
x5 t0 临时寄存器 0
x6 t1 临时寄存器 1
x7 t2 临时寄存器 2
x8 s0/fp 保存寄存器 0 / 帧指针
x9 s1 保存寄存器 1
x10 a0 函数参数 / 返回值 0
x11 a1 函数参数 / 返回值 1
x12 a2 函数参数 2
x13 a3 函数参数 3
x14 a4 函数参数 4
x15 a5 函数参数 5
x16 a6 函数参数 6
x17 a7 函数参数 7
x18 s2 保存寄存器 2
x19 s3 保存寄存器 3
x20 s4 保存寄存器 4
x21 s5 保存寄存器 5
x22 s6 保存寄存器 6
x23 s7 保存寄存器 7
x24 s8 保存寄存器 8
x25 s9 保存寄存器 9
x26 s10 保存寄存器 10
x27 s11 保存寄存器 11
x28 t3 临时寄存器 3
x29 t4 临时寄存器 4
x30 t5 临时寄存器 5
x31 t6 临时寄存器 6

特殊寄存器

  • PC(程序计数器):保存当前执行指令的地址。
  • CSR(控制和状态寄存器):用于存储控制和状态信息,如异常处理、性能计数等。

寄存器分类

  • 零寄存器(x0):始终为零。
  • 返回地址寄存器(ra):用于保存函数返回地址。
  • 栈指针寄存器(sp):指向当前栈顶。
  • 全局指针寄存器(gp):指向全局变量区域。
  • 线程指针寄存器(tp):指向线程本地存储。
  • 临时寄存器(t0-t6):用于临时数据存储,不需要在函数调用间保存。
  • 保存寄存器(s0-s11):需要在函数调用间保存的数据。
  • 函数参数寄存器(a0-a7):用于传递函数参数和返回值。

C语言调用规范

  • a0a7( x10x17 )调用者保存,用来传递输入参数。其中的 a0 和 a1 还用来保存返回值。
  • t0t6( x5x7,x28~x31 )调用者保存,作为临时寄存器使用,在被调函数中可以随意使用无需保存。
  • s0s11( x8x9,x18~x27 )被调用者保存,作为临时寄存器使用,被调函数保存后才能在被调函数中使用。
  • ra( x1 ) 是被调用者保存的。
  • sp( x2 ) 是被调用者保存的。这个是之后就会提到的栈指针 (Stack Pointer) 寄存器,它指向下一个将要被存储的栈顶位置。
  • fp( s0 ),它既可作为s0临时寄存器,也可作为栈帧指针(Frame Pointer)寄存器,表示当前栈帧的起始位置,是一个被调用者保存寄存器。fp 指向的栈帧起始位置 和 sp 指向的栈帧的当前栈顶位置形成了所对应函数栈帧的空间范围。
  • gp( x3 ) 和 tp( x4 ) 在一个程序运行期间都不会变化,因此不必放在函数调用上下文中。

指令

  • Load Immediate: li x16, 0
  • Load Address: la rd, symbol
  • Store Word: sw value, dst_addr
  • Wait For Interrupt: wfi
  • Add Upper Immediate to PC: auipc rd, imm
  • Jump And Link Register: jalr rd, (imm[11:0])rs

Rust Notes

胖指针(Fat Pointer)

sys_write 使用了一个 &[u8] 切片类型来描述缓冲区,这是一个 胖指针 (Fat Pointer),里面既包含缓冲区的起始地址,还包含缓冲区的长度。可以分别通过 as_ptrlen 方法取出它们并独立地作为实际的系统调用参数。

#[derive]

通过 #[derive(...)] 可以让编译器为自定义类型提供一些 Trait 的默认实现。

  • 实现了 Clone Trait 之后就可以调用 clone 函数完成拷贝;
  • 实现了 PartialEq Trait 之后就可以使用 == 运算符比较该类型的两个实例。
  • Copy 是一个标记 Trait,决定该类型在按值传参/赋值的时候采用移动语义还是复制语义。

很早之前在翻一个朋友的 GitHub 的时候偶然看见了 rCore-Tutorial-V3 这个项目,今年 9 月便趁着空闲开始学习。因为 rCore-Tutorial-V3 中有不少的测试用例缺失,所以我便试着在 LearningOS 的仓库中找以前训练营的测试用例,然后偶然发现今年的训练营已经开营了,便毫不犹豫参加了。

因为很早就已经学过 Rust 并且运用在项目当中了,第一阶段算是对 Rust 的复习了,因此花了大改四个小时就完成了。

前三个实验总体来说还是比较容易的,只有在 Lab3 中需要为进程TCB实现 core::cmp::Ord 遇到了一点麻烦,通过查阅官方文档解决了。在第四个实验的时候就感觉有些不太理解了,因此我查阅了一些操作系统的教材和教学视频才能理解。而第五个实验中的算法,一开始我误认为是银行家算法,然后发现程序没有也无法提供最大需求资源 Max,因此稍稍转变思路成功完成了实验。

经过第二阶段的学习我学习了操作系统的基本概念,并且动手实践编写了一些操作系统功能,我觉得受益良多。

第一阶段总结

第一阶段主要目标是熟悉Rust的语法,对我来说做rustlings没有太大的困难,因为Rust也是我自己开发小工具的常用语言之一。

比较有趣的是,仓库中加了一部分算法的题目,而且给出的框架是用unsafe的方法实现的,这让我对Rustunsafe下的使用有了更深的了解。
但是这一部分使用到的unsafe大多是如何去获得一个对象的裸指针或者引用,而到了第二阶段正式开始rCore之后在项目中用的更多的是如何获取对象的切片引用,或者是硬转成*mut T/*const T
似乎在第一阶段学到的unsafe相关的东西并没有在第二阶段用到很多。

但无论如何,完成了rustlings之后,对平时没有用到的一些Rust边角知识更加清晰了,在第二阶段也能更加顺利,不会被语言困住。

在导师的介绍下,了解到开源操作系统训练营这样一个平台。在那之后一直在关注,直到这次秋冬季训练营的开营,我毫不犹豫地选择了报名参与,并与导师一同号召学校的本科生、研究生积极参与。最后,有超过 50 名同学进行了报名,同时通过一阶段的同学有 20 名之多,晋级率大大超过平均水平。

说回到这次训练营的第二阶段,也就是 rCore 实验的部分。由于前段时间正好做了一下 MIT 的操作系统内核课程 MIT6.s081,并且过了一遍知名操作系统入门教材——《Operating Systems Three Easy Pieces》,因此我在本次实验的理论部分准备还算比较充分,在这方面没有花多少时间,而是主要投身于理解 rCore 的代码架构设计和与此相关的 Rust 语言的一些特性(我在此之前并没有接触过 Rust)。不得不感叹,Rust 语言虽然在写一些数据结构与算法题时非常痛苦,但在用于这种工程项目,尤其是系统软件时,却显得十分优雅,个人认为代码可读性上相比用 C 语言编写的 xv6 来说更高。

rCore 一共有 5 个实验,基本都是实现系统调用,但是涉及的操作系统模块各不相同,涵盖虚拟内存、进程管理、文件系统和并发控制部分,还算比较全面,题目难度比较适中,给操作系统内核编程入门提供了一个很好的途径。

尽管 rCore 实验的设计已经很好了,但也仍存在一些不足,最突出的莫过于 测试用例的不全面。很多样例都只检测一个非常表层的信息,而不对其所完成的具体功能进行测试。例如 sys_set_priority 只对返回值进行检测。另一个我的切身体会是,我在完成所有实验后,尝试撰写题解并进行复盘,便把之前写的一些已经“OK”的代码仔细读了一遍,发现从中找出了不少错误,而这些都成功“逃离”了测试样例的“法眼”。当然,编写全面细致的测试具备一定的技术难度与繁琐性,但我还是希望未来能够尽可能的对其进行完善。

最后,虽然 rCore 的学习暂时结束了,但操作系统学习的道路还远未到达终点,也不可能到达终点,期待三、四阶段与 ArceOS 的故事。

第二阶段总结

这是我第一次接触完整的操作系统实验,一路走下来可谓是收获颇多。苦于学校教学质量之差,我到处寻找教学资源,例如csdiy,但是从来没有坚持完成过较难的课程。这次rCore也算是弥补了一些遗憾吧。

  1. 学习RISC-V的特权级机制
  2. Rust风格编程
  3. 学会阅读文档,lab提示,以及参考源码,面向源码和测例编程(x)
  4. gdb调试技巧和配置
  5. 认识到和大佬们的差距
  6. 受大家的鼓舞,希望能坚持在sys的路上一直走下去