0%

终于完成了前三个阶段的练习。我这个系统方向的博士生,读了很多操作系统方面的论文,但是直到今天终于是脱离了书籍上的一知半解,可以称得上是“了解”操作系统的基本原理了。作为一个转专业学生,我从统计学起步,跨专业申请到了计算机方向的硕士,到现在在异国他乡博士在读。也算是凭着对计算机的热爱走到了现在。只是要在这个领域做出好的问题和好的研究,还是一件很困难的事情。写完最后一个hypervisor实验的感慨倒是适合放在此处: “仔细想想,实际上type-II hypervisor也几乎可以通过类似的机制实现。这一套并不局限于直接运行在硬件上的Type-I Hypervisor,只要硬件提供特权级的隔离的功能可以轻易实现。硬件支持真是个好东西。想到了当时读Xen的论文,通过Para Virtualization重量级实现hypervisor那种巨大的工程量,再到现在各个处理器指令集都支持虚拟化扩展,可以通过几百行代码实现一个简单虚拟机。也算是软件系统的需求促进的硬件发展的一个样例了。
时过境迁,前两年Timothy在OSDI疾呼研究者应该回归OS的本质做hardware-software co-design。现在新硬件层出不穷,硬件加速器,智能网卡,TEE,还有RDMA,CXL一堆新名词。好像“攻守之势异也”,变成了硬件推动OS进步了。面对着越来越多的外设和越来越专业化的使用场景,操作系统研究者的未来又在何方呢?”

祝自己将来能成为一名好的system researcher

rcore部分

Chapter 1 应用程序与基本执行环境

Ref: https://rcore-os.cn/rCore-Tutorial-Book-v3/chapter1/index.html
作为rCore教程的第一章,这章其实并未涉及到太多OS的概念。而是主要介绍了OS作为一个系统软件和应用层软件的区别:执行环境。并通过代码介绍了编写在OS执行环境中可以执行的代码。
我们在之前的学习中编写的用户态代码在执行时依赖于语言提供的运行时+标准库。运行时是一些预编译好的代码,会在编译时与我们自己编写的代码编译在一起,来实现一些诸如数组越界检查等功能。而标准库包含了对于很多系统调用的封装,比如说在使用print时,这个标准库函数在底层调用了操作系统提供的系统调用write 来把希望打印的内容输出到屏幕上。在运行时,操作系统也会帮我们完成准备和收尾工作,比如开辟虚拟内存,将程序代码载入到内存中,并在程序退出后执行清理工作等等等等等等。但是对于OS来说,它直接运行在硬件上,并没有封装好的系统调用,也没有其它软件为他进行准备工作了。因此一切都需要我们使用代码完成。

编译选项

首先就是在编译层面我们需要设置特别的编译选项。默认情况下Rust的编译选项默认标准库和对应的操作系统存在,因此生成代码时会用到其中的功能,这显然是不能接收的。因此我们需要把编译选项中只指定硬件架构RISCV 和基本的二进制文件格式elf 。

设置内存布局

由于没有fork 等系统调用,我们必须自己处理程序启动时的准备工作才能让程序运行起来。好在我们还可以使用rust_sbi 以及系统架构和上面的固件。RISCV固件在加电启动时会默认从内存0x1000 开始运行代码,并帮助我们跳转到rust_sbi 的启动流程中去。rust_sbi 规定系统软件的代码需要被放置在0x800002000 以保证正常执行。因此我们只需要在启动时把编译好的二进制文件load到0x80002000 中去即可。另一点需要注意的是rust的链接器(最终决定二进制内存布局)的默认设置并不符合QEMU中RISCV的要求。因此我们需要手动设置链接器的脚本以确保各个段被放置在合适的位置。从而保证load后能顺利运行

编写代码

由于这一章中仅仅是使用rust_sbi 完成输出Hello World,所以大部分内容属于调用SBI的细节无需赘述。需要注意的有几点,第一是在没有标准库后,代码默认的panic和exit都需要我们进行退出处理(甚至没有标准库的情况下它甚至无法指定默认入口为main 函数,我们需要给代码标注上![no_main] 宏然后在汇编中call main函数,并在链接脚本中指定开始执行的指令)。因此我们需要在代码中编写panic handler函数以处理出现panic的情况。另外就是作为系统软件,我们需要手动编写代码在启动时将.bss(全局变量)所在的内存段清零,防止后面出现奇怪的错误。当然,我们还需要在汇编代码中分配好栈的大小(由于现在并没有虚拟内存的支持因此只能分配固定大小的栈,我们没有很好的办法防止栈溢出,在完成虚拟内存章节后我们就完全避免这个问题了。)
当然我们还需要用宏将我们预编写的汇编语言包含在源代码中这样也生成的二进制中就也会包含我们写的汇编啦(汇编中包含函数入口,分配栈等内容)

ASM和链接器解析

下面是注释后的entry.asm和链接器脚本。asm生成了一些符号然后链接器会把他们按照链接脚本的方式计算出实际地址并翻译为二进制代码。

一个注释后的entry.asm

1
2
3
4
5
6
7
8
9
10
11
12
.section .text.entry       # 定义代码段,.text.entry是一个特殊的段名
.globl _start # 声明_start符号为全局可见
_start: # _start标签,程序入口点
la sp, boot_stack_top # 将boot_stack_top的地址加载到栈指针(sp)
call rust_main # 调用rust_main函数

.section .bss.stack # 定义未初始化数据段,用于栈
.globl boot_stack_lower_bound # 声明栈底界限为全局可见
boot_stack_lower_bound: # 栈底界限标签
.space 4096 * 16 # 分配16页内存(每页4KB)作为栈空间
.globl boot_stack_top # 声明栈顶为全局可见
boot_stack_top: # 栈顶标签
  • boot_stack_lower_bound: 定义了栈底部的标签(地址)
  • .space 4096 * 16 在这个标签之后预留了 64KB 的内存空间
  • boot_stack_top: 定义了栈顶部的标签(地址)
    boot_stack_top 后面什么都没有是因为它标记的是栈空间的结束位置。由于栈是向下增长的,实际的内存布局是这样的:
    1
    2
    3
    4
    5
    高地址 --> boot_stack_top         (栈指针初始位置)
    |
    | 64KB 的栈空间
    |
    低地址 --> boot_stack_lower_bound (栈底限制)

一个注释后的链接器脚本

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
OUTPUT_ARCH(riscv)         // 指定输出架构为RISC-V
ENTRY(_start) // 指定程序入口点为_start符号
BASE_ADDRESS = 0x80200000; // 定义基地址常量,这是RISC-V SBI通常加载内核的地址

SECTIONS
{
. = BASE_ADDRESS; // 将当前位置计数器设为基地址
skernel = .; // 定义skernel符号,表示内核起始地址

stext = .; // 定义stext符号,表示代码段起始地址
.text : { // 定义.text段(代码段)
*(.text.entry) // 首先放入所有.text.entry段(启动代码)
*(.text .text.*) // 然后放入所有其他.text段(普通代码)
}

. = ALIGN(4K); // 将当前位置按4KB对齐
etext = .; // 定义etext符号,表示代码段结束地址
srodata = .; // 定义srodata符号,表示只读数据段起始地址
.rodata : { // 定义.rodata段(只读数据段)
*(.rodata .rodata.*) // 放入所有.rodata段(常量等)
*(.srodata .srodata.*) // 放入所有.srodata段(小型只读数据)
}

. = ALIGN(4K); // 将当前位置按4KB对齐
erodata = .; // 定义erodata符号,表示只读数据段结束地址
sdata = .; // 定义sdata符号,表示数据段起始地址
.data : { // 定义.data段(已初始化数据段)
*(.data .data.*) // 放入所有.data段(初始化的变量)
*(.sdata .sdata.*) // 放入所有.sdata段(小型数据)
}

. = ALIGN(4K); // 将当前位置按4KB对齐
edata = .; // 定义edata符号,表示数据段结束地址
.bss : { // 定义.bss段(未初始化数据段)
*(.bss.stack) // 首先放入.bss.stack段(栈空间)
sbss = .; // 定义sbss符号,表示bss段主体起始地址
*(.bss .bss.*) // 放入所有.bss段(未初始化变量)
*(.sbss .sbss.*) // 放入所有.sbss段(小型bss数据)
}

. = ALIGN(4K); // 将当前位置按4KB对齐
ebss = .; // 定义ebss符号,表示bss段结束地址
ekernel = .; // 定义ekernel符号,表示整个内核结束地址

/DISCARD/ : { // 定义丢弃段,这些段不会出现在最终二进制文件中
*(.eh_frame) // 丢弃所有.eh_frame段(异常处理框架,通常不需要)
}
}

关于你提到的 "xx = ." 和 ". = xx" 语法:

xx = .

这种形式是在当前位置定义一个符号。
例如 stext = . 表示定义符号 stext 指向当前位置计数器的值。
这些符号通常用于在程序中获取段的边界地址。


. = xx

这种形式是将当前位置计数器设置为某个值。
例如 . = BASE_ADDRESS 将位置计数器设为基地址。
. = ALIGN(4K) 将位置计数器向上对齐到4KB的整数倍。



链接器脚本的工作方式是按顺序处理各个段,并且使用一个"位置计数器"(用 . 表示)来跟踪当前的地址。每定义一个段,这个计数器就会增加该段的大小。
通过这种方式,链接器能够:

计算每个段和符号的最终内存地址
调整代码中的引用,使它们指向正确的地址
生成一个内存布局明确的最终二进制文件

Chapter 2 批处理系统

Ref: https://rcore-os.cn/rCore-Tutorial-Book-v3/chapter2/index.html
rCore的第二章主要内容是如何实现系统调用。这一章涉及的具体“知识”非常少,大部分是工程细节。系统调用和函数调用非常相似,为了防止寄存器被嵌套函数执行时修改,都需要在调用前保存好上下文并在返回时恢复。与编译原理中哪些prolog和epilog完全一致。
唯二的区别在于,首先。操作系统需要保存所有的通用寄存器(因为不再有convention保护),以及重要的CSR(控制寄存器)。CSR是用户态程序无需关心的。这些寄存器包括保存了CPU当前特权级的sstatus ,trap返回后继续执行的地址sepc trap的原因以及附加信息scause/stval 等等。其次,处于安全考虑,操作系统需要维护自己的栈,而不能和用户态程序一样单纯继续向下开辟栈。因此我们可以利用RISCV中提供的额外寄存器sscratch 来保存内核栈的地址。这样当trap发生时,我们就可以从sscratch 中找到内核栈的地址并开始处理了。
需要注意的是,由于目前为止所有操作都是在物理地址上发生的。所以实际上应用程序有能力访问所有内存的并找到操作系统的内核栈,目前我们的操作系统并没有实现任何内核态和用户态的隔离。这一点可以通过x86架构提供的段实现,RISCV有没有类似功能尚不清楚。当然,这个问题会在我们实现虚拟内存后完全解决。
祝自己明天开始写第一个lab顺利!

Chapter 3 多道程序与分时多任务

Ref:https://rcore-os.cn/rCore-Tutorial-Book-v3/chapter3/index.html
rCore的第三章将操作系统的调度能力进一步拓展,在chapter 2中,我们的操作系统只能按照设定好的顺序逐个运行程序。而Chapter 3加入了抢占机制,使操作系统能够交替运行不同程序。极大的改善了程序的交互体验。
实现分时共享系统的核心是timer的加入。timer是一个来自硬件的中断发生器,会不断的向操作系统发出中断。每当中断来临时,操作系统就会接管节点,并决定是否要切换到另一个应用程序执行。切换的步骤与第二章中的系统调用是非常相似的。因为都是“打断”当前应用程序的执行并在稍后回复。因此需要保存应用运行的上下文。
在这一章中,应用程序有了task的抽象。并通过task数据结构被操作系统调度(突然理解了为什么linux中的进程管理块名叫task哈哈哈)
这一章的作业实现trace系统调用,还是比较简单的,希望之后一切顺利。容易错的点是题目实际要求的是分别统计每个应用的系统调用次数。因此需要简历一个二维数组。另外如果试图将数组放到更内层的TaskControlBlock 而不是TaskManager 会出现奇怪的内核崩溃bug。怀疑是因为TaskControlBlock 在内存中位置和长度都固定锁导致的.所以最终还是把二维数组放到了TaskManager 中

Chapter 4 地址空间

Ref:https://rcore-os.cn/rCore-Tutorial-Book-v3/chapter4/index.html
这一章中终于实现了虚拟内存。我们的程序可以拥有单独的地址空间而不是时刻在意自己具体的内存位置了。虚拟内存的实现是通过名为MemorySet 和MemoryMap 的数据结构实现的,这点和Linux中的虚拟内存管理非常一致。
虚拟内存管理通过硬件上的MMU自动实现虚拟地址到物理地址的翻译。只需要将页表的root page放置在特定的寄存器中就可以让硬件实现正确寻址。
加入虚拟内存让内核迎来了一次比较大的重构。因为我们之前最重要的管理机制:系统调用和程序切换锁保存的上下文会发生不同。这里涉及到很多tricky的问题,比如说开启虚拟内存后,在我进入内核的地址空间后,需要通过应用态的页表手动找到需要保存的上下文。还有由于地址空间不同,在陷入内核态之前需要通过一个特定的相同的跳板页来实现丝滑switch。这些设计上的细节是之前理论上学习操作系统时根本没有想过的。
关于这一节的实验没想到最困难的并不是mmap和unmap,而是port sys_trace函数。再试图往对应地址写入是,我把PPN转usize再加上offset结果发现一直不对。然后发现PPN居然并不是Physical Address但是后面用0 padding,它就单纯的是个物理页号。需要先左移PAGE_SIZE再加offset才是对应的physical address。

Chapter 5 进程

Ref: https://rcore-os.cn/rCore-Tutorial-Book-v3/chapter5/5exercise.html
这一节终于引入了进程的概念。它其实只是之前四章内容的更上一层抽象。进程的抽象让操作系统可以实现更加丰富的语义。比如更只能的进程调度,通过fork(), exec()运行新的程序。以及在这一章,我们终于有了shell!操作系统更像一个真正的操作系统了。
这一章具体的内容不多,大部分为有机的组合前四章的内容实现进程的概念。以及增加了进程管理的相关细节。比如如何启动第一个进程,进程退出后如何回收等等。
本章的实验倒是十分简单, spawn系统调用就是fork+exec拼一拼,只不过把复制memoryset的部分去掉。而stride调度器也只是在manager结构中统计一下步长和priority并根据它们调度。只要找到了对应的数据结构实现起来非常简单。

Chapter 6 文件系统

Ref: https://rcore-os.cn/rCore-Tutorial-Book-v3/chapter6/index.html
文件系统!终于可以把程序存在块设备上而不是呆在内存里了!因为在ECE566课上已经学过一遍了所以基本没有什么新知识。但是能看到具体的实现还是学到了很多细节。比如说具体如何和驱动程序沟通。但是由于disk的驱动程序已经做好了抽象所以实际上我们只需要实现对应的接口就可以了。
我在实验的第一版实现中犯了蠢在实现hardLink的时候给每个hardlink都创建了一个新的inode并且给inode增加一个新的type hardlink,然后修改read和write的逻辑。加一层indirection找到linkto的inode执行实际的读写
后面发现根本没必要啊!只需要在文件夹中的directory entry中将文件名link到对应的inode即可。另外一个奇怪的bug是如果在DiskNode加入太多的字段会触发奇怪的虚拟内存相关报错LoadPageError。而这个behavior在不同机器上跑的出现还不一样,非常随机。不知道为什么会这样,怀疑是加入太多字段会影响数据结构在内存中的排布?由于实在是太难debug了所以最终并没有发现根因、这个bug是所有rcore实验中困扰我最多的实验,干了一整个通宵,十分痛苦。
另一点是因为引入了stdout和stdin,在spawn创建新的进程的时候需要记得初始化stdout,否则会出现测试程序打印不出来内容的窘境。

Chapter 7 进程间通信

Ref: https://rcore-os.cn/rCore-Tutorial-Book-v3/chapter7/index.html
之前一直觉得linux语法很难理解,, 总觉得程序是一步一步执行的。 比如a|b 就是先运行a再把输出传给b 但看到有一些命令就晕了比如为什么有的命令可以cmd xx > yy << zz 难道先执行xx和yy? 看这一章惊觉shell命令是一起被处理而不是一步一步执行的,恍然大悟!
具体内容倒没有很多,我们只需要给不同的东西实现file trait就可以在进程中使用文件描述符等来管理它,再在运行时动态分配到stdout 特有的write函数中去打印到屏幕上。
io重定向也是类似的原理,通过一些操作读取第一个程序的stdout然后作为输入交给第二个程序,也就实现了重定向了。
进程间通信同理,我们给进程间通信的ringbuffer也实现read write等方法,在调用时实际写入一个ringbuffer而非实际文件即可。

Chapter 8 并发

这一节大部分内容真是学了无数遍了,OSTEP学了一遍,ECE650学了一遍,这次又学了一遍。锁,信号量,条件变量着实是滚瓜烂熟了。倒是线程管理的部分非常有趣。这一章把rcore变成了类似linux的方式,线程是基本的调度单位,进程仅仅维护fd_table, memoryset等进程共享资源。为了添加线程的支持,进程需要维护好每个线程的上下文和运行栈,线程的栈因为共享地址空间所以会被放置在特定的内存位置统一管理。在切换的时候与进程调度基本一致。
如果需要跨进程切换线程switch函数会负责切换地址空间,来保证线程的顺利运行。
这一章的lab是实现银行家算法。算是个蛮有意思的题目。但是感觉实现本身和OS的线程调度机制没有太大的关系,而且由于类似算法的时间复杂度实际操作系统中应该不会包含类似的算法。如果题目可以和OS本身更加相关就好了

arceos部分

Lab1 Print

第一个lab非常简单,只需要在println宏中加入riscv对应颜色的转义字符即可。注意要在打印后使用reset转义字符,不然后面所有的输出就都变颜色了。
下面是一些常用的转义字符

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
const ANSI_CODES: &[(&str, &str)] = &[
// 基本颜色
("BLACK", "\x1b[30m"),
("RED", "\x1b[31m"),
("GREEN", "\x1b[32m"),
("YELLOW", "\x1b[33m"),
("BLUE", "\x1b[34m"),
("MAGENTA", "\x1b[35m"),
("CYAN", "\x1b[36m"),
("WHITE", "\x1b[37m"),

// 粗体颜色
("BRIGHT_BLACK", "\x1b[90m"),
("BRIGHT_RED", "\x1b[91m"),
("BRIGHT_GREEN", "\x1b[92m"),
("BRIGHT_YELLOW", "\x1b[93m"),
("BRIGHT_BLUE", "\x1b[94m"),
("BRIGHT_MAGENTA", "\x1b[95m"),
("BRIGHT_CYAN", "\x1b[96m"),
("BRIGHT_WHITE", "\x1b[97m"),

// 背景色
("BG_BLACK", "\x1b[40m"),
("BG_RED", "\x1b[41m"),
// ... 其他背景色

// 样式
("BOLD", "\x1b[1m"),
("UNDERLINE", "\x1b[4m"),
("REVERSED", "\x1b[7m"),

// 控制
("RESET", "\x1b[0m"),
];

Lab 2 Hashmap

由于测例非常简单,所以偷了一下懒,用嵌套vector实现的hashmap,甚至没有实现resize(非常懒了!)
这一个lab的难度也不在写代码上,重点在于理解ArceOS的代码结构,然后把HashMap的代码糊上即可。
实验中遇到了两个小坑,

  1. 因为在std的lib.rs 中已经引用了alloc中的collections,因此我们的hashmap不能在collections里面,必须命名成别的,我命名成了axcollections。exercise中的用户态程序也要同步更改。
  2. 我的hashmap实现中用到了vector,因此需要用到globalallocator调用alloc动态分配内存。但是在第一个测试print_with_color编译选项中没有编译allocator。因此需要在axstd中我们的collections前面加上编译选项的宏,这样就编译第一个测试才不会报错。
    1
    2
    3

    #[cfg(feature = "alloc")]
    pub mod axcollections;
    目前对ArceOS有了一个比较初步的理解
  3. axlibc和axstd对应linux中的glibc和std。是用户态程序,会通过系统api(位于arceos/api )和os通信(而不是syscall?)
  4. OS的核心代码在modules中,包括了硬件抽象层HAL,内存管理MM,网络,进程管理等等都以模块化管理。这次作业比较相关的动态内存分配GlobalAllocator实现在了在axalloc中
    还有一个意外得到知识是哈希函数本身
    这次第一次用到Rust的Hash trait。 这个trait提供hash函数,接收一个实现了Hasher trait的state
    为什么需要一个带state的Hasher而不能直接Hash返回一组字节呢?
    因为希望灵活的处理哈希算法,并且比如你有多个字段或者字段内部有嵌套的字段它们都需要被被哈希,这个时候hash(hasher)可以帮助你抽象掉冗杂的细节,实现增量哈希。
    ps: 在哈希或密码学最忌讳灵机一动,我一开始想着把多个字段的哈希值直接异或也可以得到一个值呀,但这样的算法会面临严重的碰撞问题,比如(a,b,c)和(c,b,a)会得到相同的哈希值,会对哈希表的性能产生很大的影响。

Lab 3 Bump Allocator

这次的实验是实现一个Global Allocator。这是我一直非常好奇的一个部分。在rcore实验,rcore已经提供了一个实现好的buddy allocator。这次终于有机会自己实现了。
研读代码后发现,Global Allocator虽然名义上管理memory的allocation,但它实际上并不操作任何memory。它只是个“数字管理器”,记录哪些块被分出去,哪些块没有。实际上也没有任何措施来保证未分配的块不能被访问——这个功能是由Page Table通过页表项中合理的bit设定通过硬件保证的。
我在这里意外的发现Slab Allocator的实现和之前在CSAPP学习到的malloc实现惊人的相似。都是通过二的幂次管理多重链表来平衡分配内存的时间复杂度和额外空间占用。仔细想来也非常合理。global allocator和malloc实际上不涉及任何用户态和内核态的内容。它们本质上都是管理一段连续的数组,然后通过一定的算法尽量保证这个数组被高效利用。那么最终收敛到相似的设计就完全可以理解了。
本身的代码非常简单,略过不提。

Lab 4 File Rename

这次的实验是实现file rename操作。我照着ppt里的要求实现了结果发现ppt的作业和实际跑的test并不一样(尴尬。
由于myfilesystem的大部分实现是调用的现有库,无法更改。我的move实现本质上并没有操作文件系统,而是单纯的调用操作系统的API把一个文件内容读出来,然后创建并写入新文件。算是取巧过去了。
如果需要在文件系统层级实现rename,无需如此笨拙,只需更改directory node中对应的name,将name复制到new path 的directory node中即可。根本无需访问inode进行整个文件的读写

Lab 5 sys_mmap

这次实验是给教学用的arceos添加mmap操作。之前并没怎么使用过linux的mmap映射文件的功能。恰好借助这个机会补充一下。map文件到内存听上去很高大上,其实仔细想来只需要做三件事。

  1. 根据用户的参数申请一段连续内存
  2. 将文件内容读入内存并写到对应的内存上
  3. 当对应内存写入时,标记脏页并由内核线程写回磁盘(类似于文件写)
    在这次lab中,由于测例比较简单并没有实现3.
    1和2的实现方法需要找到陷入当前trap的进程的currentTask控制块,然后在它的地址空间中预定一块内存。然后调用fs的api读取文件,再将文件写入对应的内存中。更理想的实现可能为修改fs的接口,让文件读取可以直接读取到一段指定内存,这可以减少一次内存的复制操作。
    实现3的方法应该是借助MemoryArea的分类进行。比如文件申请的内存为FileBlockMemoryArea,有一个异步线程会定期被唤醒去把脏页写回block。所以对应到mmap当mmap需求的是map文件时,在map.alloc这个调用中需要指定类型为FileBlock。对于普通的操作则申请匿名内存,不需要异步线程进行检查

Lab6 simple_hv

这一个lab给我们展示了一个“最小hypervisor”的实现。类似于rcore Lab中的syscall实验。在rcore和arceos阶段研读过操作系统的代码后,实际上Hypervisor的运行方式并不难以想象。操作系统的目的是,安全,高效的同时运行多个程序。为了达成这个目的主要实现了抢占式调度,地址空间,以及特权级机制。Hypervisor同理,它要安全,高效的运行多个操作系统。因此也可以通过类似的方法,比如抢占式调度(或者干脆利用多核实现真并行调度,如教学代码所示),对于操作系统来说也需要“虚拟物理地址”,相当于一个vm级别的地址空间。另外,OS不再直接管理外设,也不能直接控制硬件,所以对于一些可能影响硬件的”危险操作“我们再进行一次特权级的隔离。让os通过与syscall类似的hypercall把这些操作的权利交给hypervisor定夺。
仔细想想,实际上type-II hypervisor也几乎可以通过类似的机制实现。这一套并不局限于直接运行在硬件上的Type-I Hypervisor,只要硬件提供特权级的隔离这一套可以轻易实现。硬件支持真是个好东西。想到了当时读Xen的论文,通过Para Virtualization重量级实现hypervisor那种巨大的工程量,再到现在各个处理器指令集都支持虚拟化扩展,可以通过几百行代码实现一个简单虚拟机。也算是软件系统的需求促进的硬件发展的一个样例了。
时过境迁,前两年Timothy在OSDI疾呼研究者应该回归OS的本质做hardware-software co-design。现在新硬件层出不穷,硬件加速器,智能网卡,TEE,还有RDMA,CXL一堆新名词。好像“攻守之势异也”,变成了硬件推动OS进步了。面对着越来越多的外设和越来越专业化的使用场景,操作系统研究者的未来又在何方呢?

总结

第二阶段的强度慢慢上来了,由于平时要上班和前期收不到短信验证码的问题,不能跟上每周的直播课,主要通过学习tutorial文档和课后看录频和课件的方式学习。本阶段对内存管理,进程管理和文件系统, 线程进行了系统的学习。特别是内存映射和文件系统章节,学习到很多。同时,随着代码量增大,学习的同时画UML图对理解代码和整个框架也很有帮助。

Rust补完计划

src:rust官网rust官文rust官仓crates.iorust-wiki卡狗圣经

​ Rust可看作一个在语法层面(编译时)具有严格检查和限制的C语言上位。且扩展了面向对象的便捷方法绑定。编译和运行方式类似于C/C++,可以rustc xxx.rs编译,./xxx运行。有约定的项目目录格式,可使用Cargo配置toml进行包管理、编译、运行、测试等等。包资源网站为CratesIO,见src↑。不支持运算符重载,支持多态。其中语句为:表达式+;,语句的值是()

​ 为了安全,几乎所有的方法/变量/属性都是私有的,除非使用pub进行显式公用声明。

​ 说到底,编程语言就是人类用来快速生成机器码以便于执行的模板引擎,有的语法层面(编译时/解释时)有强约束,有的仅仅是把特定字符串替换成另外的字符串或二进制,属于弱约束或者无约束。所有你在编程语言所看到的抽象,在机器码的层面本来就是一场幻月楼阁。比如你在编程语言层面,继承多态面向对象权限生命周期搞的花里胡哨的,但是在机器码看来,就仅仅是把PC变一下,或者某数据/指针变一下而已,你所关心的语法层面,语义特性,都是高层编译时/解释时的语法约束。这些约束让你写正确的高级语法的同时,最重要的是保证执行的结果符合预期。所以学底层的,一定要层层解耦,梳理层层抽象!

快速开始

安装:

1
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

推荐开发环境:

VSCode + rust-analyzer,VIM,RustRover

见面礼:

1
2
// main.rs
fn main() { println!("hello world!") }
1
rustc main.rs -o main && ./main

使用包管理器:

1
2
3
4
5
6
cargo new my_project_name	# 生成项目目录结构,包括`toml`、`lock`、`src`、`test`等等
cargo build # 编译后生成文件在`target/debug/项目名`下生成可执行文件,可选--release生成优化版本
cargo run # 仅run也会build
cargo clean # 清除target下编译后的文件
cargo check # 仅检查语法是否出错
# 安装新的包,在Cargo.toml的依赖下配置:`包名=版本`即可

包管理器代理:vim ~/.cargo/config.toml

1
2
3
4
5
[source.crates-io]
replace-with = 'ustc'

[source.ustc]
registry = "sparse+https://mirrors.ustc.edu.cn/crates.io-index/"

初次接触

语法语义一览表:

标识符:^[a-Z0-9_][a-Z0-9_$]* 其中,命名习惯为:CONST_VALStructNameImplNamemethod_nameval_name

注释符:// 单行注释/* 多行注释 *//// 文档注释,支持MD语法以及文档测试以及自动生成文档

运算符:+ - * / % += -= *= /= %= ! ~|& ^ [] ; , >> << == != < <= > >= && ||

变量声明:const CONST_VAL: i32 = 123; static STATIC_VAL: u32 = 321; let emm = 233; let mut var = 0;

类型别名:type word = u64

类型转换:var as type_name type_name::from(var)

分支:if 条件必须是布尔值 { ... } else { ... } match obj { case xx => { ... }, _ => { ... } }

循环:loop { ... } for i in 0..10 while n < 233break continue

支持循环标签来跳出指定的循环:tag1: loop { tag2: while true { break: tag1 } }

函数格式:fn add(a: i32, b: i32) -> i32 { a + b } 默认返回值是最后一条表达式的值,等同于:return a+b;

匿名函数:|a: i32, b: i32| -> i32 { a + b } 如果只有一条执行语句,可以省略大括号

类和对象:采用结构体struct(存数据)和特质trait(类似于抽象interface存方法)抽象,数据方法分离的思想

方法多态:方法和数据的关系是多对多,支持采用数据/特质签名来访问匿去的数据/方法:TraitA::fun1(&obj)

基本类型:i8 u8 i16 u16 … 有无符号+位数,str,bool,f64 … 同整型

类型变体:&i32 - 不可变引用,&mut - 可变引用,*const - 不可变裸指针,*mut - 可变裸指针

容器类型:[1, 2, 3] - Array - 定长同类型,(1, "heke", 1228) - Tuple - 定长不可变

数据容器:struct Person { age: u8; name: &str; } struct Bag (i32, i32, u8)

枚举类型:enum State { StateA, StateB, State233=233, ,PA(ch) ... } 详见特殊部分与模式匹配

其他容器:VecDequeQueueBTreeSetBTreeMap

导入管理:mod package_emm; mod mode_name { fn emm() { ... } } use mode_name::emm;

异步支持:asyncawaitstd::thread,以及异步的通道和异步智能指针

快速迁移(将采用Py作为对比语言):


1
2
def emm(a: int, b: int) -> float:
return float(a) + b
1
2
3
4
pub fn emm(a: i32, b: i32) -> f64 {
// 运算块的值是最后一句表达式的值,所以不显示写return也可以,语句的值是()表示空!
a as f64 + b // 或写为:`return a as f64 + b` 或 `return f64::from(a) + b;`
}

1
2
3
def emm(op) -> (int, str, int):
op
return 1, "heke", 1228
1
2
3
4
pub fn emm(op) -> (i32, &str, i32) {  // op会在编译时自动根据所调用时的类型生成对应类型标签的多态方法
op;
1, "heke".as_str(), 1228
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A:
def __init__(self, a: int, b: int, c: int):
self.a = a
self.b = b
self.c = c

def method233(self):
self.a += self.b
return None

if __name__ == '__main__':
obj = A(1,2,3)
obj.method233()
print(f"a: {obj.a}, b: {obj.b}")
print("c: ", obj.c)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct A {
pub a: i32,
pub b: i32,
pub c: i32,
}
impl A {
pub fn method233(&self) {
self.a += self.b;
return None;
}
}
fn main() {
let mut obj = A {a: 1, b: 2, c: 3};
obj.method233();
println!("{}", format!("a: {}, b: {}", obj.a, obj.b));
println!("c: {}", obj.c);
}

1
2
3
# method.py
def func():
pass
1
2
3
4
5
6
7
8
9
# __init__.py
from method import * # '0

from method import func # 语法多余,但是为了对比语法'1
from .method import func # 语法多余,但是为了对比语法'2
__all__ = ['func'] # 'export

func()
method.func()
1
2
3
4
// method.rs
pub fn func() {
;
}
1
2
3
4
5
6
7
8
9
10
11
// lib.rs / mod.rs
mod method; // '0
use method::*; // '0

pub use method::func; // pub use使得导入此包的也可以被别的包从此包导入'1 'export
pub use crate::method::func; // crate表示从包的根目录进行相对路径索引'2 'export

// 如果有main,则在main内:
func();
method::func();
crate::method::func();
Read more »

前导

本博客作为开源操作系统训练营2025S的1、2阶段学习记录,简单总结了在这两个阶段的学习和coding。留作纪念,也希望能够帮助到大家。

Read more »

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

第一阶段总结

第一次写rust,被所有权干烂了,不停和编译器搏斗+不停拷问AI终于磕磕绊绊写完了,以前习惯于C++的引用和指针乱飘了,写rust确实比C和C++安全,因为有编译器的所有权机制保障。

第二阶段总结

因为以前写过NJUPA(极其推荐),所以对上下文切换有一点概念。rcore-os的上下文切换采用了xv6类似的方式,在内核内部做控制流切换来切换执行的用户程序。对于操作系统中常用的锁,rcore采用了单核中的使用rust所有权机制来实现,上锁的时候将所有权转交给函数,尝试二次获取的时候就会因为所有权引发rust错误,避免了内核的死锁的情况。cargo的编译链接功能确实比GNU那一套方便,rust的属性语法支持也比C++要好很多。

地址空间和页表中极多的用来表示地址组成的结构体,充分体现了rust面向对象的特性和into的语法,增加了代码的可读性,以前用C系的写也就使用各种宏。rcore用了额外的BTreeMap来支持快速查询页表。作业题的实现也大多是调用现有的函数就差不多可以了。

到文件系统这里比较新鲜,因为以前没有怎么接触过,文件系统的实现层层嵌套,层层抽象,一个功能实现往往要修改多层,理解起来比较困难。

并发的作业是用Coffman提出的方法做死锁检测,开始的时候理解成了银行家算法,读作业提示的时候也没有读懂,卡了很长时间,在群u的提示下才通过。后来在上操作系统课的时候翻到书上才知道这是Coffman的死锁检测算法(书上的描述其实和作业里写得差不多)。

第三阶段

第三阶段换了个内核模式叫unikernel,不太习惯这样的组件化操作系统,还是习惯以前的传统宏内核结构。

print_with_color很简单,理解一下arceos提供的axstd模块,为内核应用提供了运行时环境。

support_hash_map在理解了rust可以自定义堆分配器后,唯一要解决的就是rust中如何得到一个hashable值的hash值,然后做简单的映射即可,测试不需要特别追求效率。

alt_alloc主要是实现一下需要的接口,了解如何向arceos添加自己的分配器,bump allocator可以说是最简单的分配器了。

ramfs_rename要把cargo里的包换成arceos下面的(不然文件系统的包永远是网上拉下来的),找到对traitVfsOpsVfsNodeOps的实现之后,搞清楚记录文件的数据结构,实现rename就可以了。

sys_map同样搞清楚task_ext提供的接口就可以直接调库了,第一次实现忘记输入地址是NULL的话要由内核自己找地址,遂发现了一个叫做find_free_area的妙妙工具。

simple_hv最简单的一次,同样的由操作系统代为执行一些指令的手法可以用来帮用户程序加载一些未对齐的数据(如果处理器不支持)。

第二阶段总结:操作系统学习的感悟与理解

经过这一阶段对操作系统核心内容的系统学习和实践,我对操作系统的本质、关键机制以及与 Rust 结合的优势有了更深刻的理解。以下是我的主要体会和总结:


一、操作系统的本质与核心任务

操作系统是管理硬件资源、为上层应用提供抽象和隔离的基础软件。它的核心任务包括:

  • 进程与线程管理:通过进程(资源分配单位)和线程(调度单位)实现多任务并发,保障系统的响应性和资源利用率。PCB(进程控制块)和 TCB(线程控制块)是操作系统调度和管理的核心数据结构。
  • 内存管理:通过虚拟内存、分页机制和权限控制,实现进程隔离、内存高效分配与回收。页表、TLB、懒分配、写时复制等机制极大提升了系统的安全性和性能。
  • 进程调度:采用多种调度算法(如时间片轮转、优先级、MLFQ等)实现公平与高效的 CPU 分配。上下文切换和调度策略直接影响系统吞吐和响应速度。
  • 系统调用接口:为用户程序提供受控访问硬件和内核资源的通道,实现用户态与内核态的安全切换。
  • 硬件抽象与性能优化:通过缓存、TLB、ASID 等机制优化访问速度,利用中断和异常机制实现高效的事件响应。

Read more »

之前对操作系统有一定理论基础,rcore 和 arceos 项目对我最大的挑战主要包括:

  1. risc-v 体系结构的知识,尤其是特权架构。这对理解 trap、context_switch、地址空间相关的代码极其重要。
  2. arceos 项目的组织构建。最底层是 axhal,抽象了硬件架构和运行平台,往上是各个 module 例如 axtask 等,再向上是 axapi 乃至 ulib。这种组件化的设计思想充分利用的 rust 语言的优势,极大方便构建。

unikernel 架构是没有特权级切换的,应用程序也运行在 s 态。刚开始没有仔细理解 ppt,给我造成了挺大的困扰。

hashmap 的实验我并没有自己手写代码,而是直接引入了 hashbrown 库。但手撕一下代码应该能更加锻炼能力。

此外,hypervisor 给我带来了挺大的困难,参考其他同学的经验我才得以通过。

Chapter 2

Introduction

Introduction

It corresponds to riscv:

  • Privilege for S(guaranteed by Supervisor Execution Environment of RustSBI)
  • User for U(constructed in current chapter as Application Execution Environment)

Reason:

  • Safety(Prevent app from accessing kernel)
  • Recoverable

Workflow:

  • Start application and user-mode context
  • Trap(Called by system level) to handle system
    • Goes wrong! Kill it!
    • Finish! Next!
  • Restore to user-mode context

riscv designs following CSR(Control and Status Register) to handle this:

CSR

CSR

Begin Trap:

  • sstatus: SPP seg to the current level of CPU.
  • sepc: next addr after Trap finished.
  • scause/stval: Trap cause and additional info.
  • stvec: storage of entry addr of Trap

stvec is a 64-bit CSR, with:

  • MODE(Direct/Vectored) [1:0](read from right to left): 2-bits
  • BASE [63:2]: 62-bits

finally, it will return by instruction sret which will change level and jump by sepc.

Construct Trap

Design:

  • General register will be shared by U-level and S-level.
  • Maintain a reasonable state of CSR.
  • Separate workflow of U-level and S-level by stack

Construct:

  • build KernelStack and UserStack for separation
  • in KernelStack, we store TrapContext in it, by asm and rust to control dispatch and handle, then store the code to stvec as the entry of Trap.
  • restore register for UserStack by push a new context refer to UserStack.

build stack and push context:

1
2
3
4
5
6
7
8
9
10
11
12
13
// stack struct ...

// buttom to top
fn get_sp(&self) -> usize {
self.data.as_ptr() as usize + KERNEL_STACK_SIZE
}
pub fn push_context(&self, cx: TrapContext) -> &'static mut TrapContext {
let cx_ptr = (self.get_sp() - core::mem::size_of::<TrapContext>()) as *mut TrapContext;
unsafe {
*cx_ptr = cx;
}
unsafe { cx_ptr.as_mut().unwrap() }
}
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/trap/context.rs

#[repr(C)]
pub struct TrapContext {
pub x: [usize; 32], // General register
pub sstatus: Sstatus,
pub sepc: usize,
}
/// set stack pointer to x_2 reg (sp)
pub fn set_sp(&mut self, sp: usize) {
self.x[2] = sp;
}
/// init app context
pub fn app_init_context(entry: usize, sp: usize) -> Self {
let mut sstatus = sstatus::read(); // CSR sstatus
sstatus.set_spp(SPP::User); //previous privilege mode: user mode
let mut cx = Self {
x: [0; 32],
sstatus,
sepc: entry, // entry point of app
};
cx.set_sp(sp); // app's user stack pointer
cx // return initial Trap Context of app
}

We will design __alltrap and __restore for operation by asm and part of rust:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.altmacro
.macro SAVE_GP n
sd x\n, \n*8(sp)
.endm
.macro LOAD_GP n
ld x\n, \n*8(sp)
.endm
.align_2
__alltraps:
...
# set input argument of trap_handler(cx: &mut TrapContext)
mv a0, sp # sp is point to TrapContext in kernel stack
call trap_handler # (&mut TrapContext)

--restore:
...

To handle Trap context, we will use riscv lib:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// os/Cargo.toml

[dependencies]
riscv = { git = "https://github.com/rcore-os/riscv", features = ["inline-asm"] }

// os/src/trap/mod.rs
global_asm!(include_str!("trap.S"));

pub fn init() {
extern "C" { fn __alltraps(); }
// write to stvec
unsafe {
stvec::write(__alltraps as usize, TrapMode::Direct);
}
}

#[no_mangle]
pub fn trap_handler(cx: &mut TrapContext) -> &mut TrapContext {
...
}

restore operation:

1
2
3
4
5
6
7
extern "C" { fn __restore(cx_addr: usize); }
unsafe {
__restore(KERNEL_STACK.push_context(
TrapContext::app_init_context(APP_BASE_ADDRESS, USER_STACK.get_sp())
// This context store the ptr to UserStack for restoration
) as *const _ as usize);
}

Construct User App

  • Link app binary to kernel with specify memory layout
  • Read the layout, use AppManager to maintain and store
  • Load app from memory layout, copy consecutively to APP_BASE_ADDRESS(Currently we have no ability to dynamically read address)
  • AppManager will run each app
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# os/src/link_app.S

.align 3
.section .data
.global _num_app # read from the ptr
_num_app:
.quad 5
.quad app_0_start
.quad app_1_start
.quad app_2_start
.quad app_3_start
.quad app_4_start
.quad app_4_end`

...

Design it!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// os/src/batch.rs

struct AppManager {
num_app: usize,
current_app: usize,
app_start: [usize; MAX_APP_NUM + 1],
}

// part of read in static init of AppManager
let num_app_ptr = _num_app as usize as *const usize;
let num_app = num_app_ptr.read_volatile();
let mut app_start: [usize; MAX_APP_NUM + 1] = [0; MAX_APP_NUM + 1];
let app_start_raw: &[usize] = core::slice::from_raw_parts(
num_app_ptr.add(1), num_app + 1
);
app_start[..=num_app].copy_from_slice(app_start_raw);

Load App:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// part of code of copying to kernel
asm!("fence.i");
// clear app area
core::slice::from_raw_parts_mut(
APP_BASE_ADDRESS as *mut u8,
APP_SIZE_LIMIT
).fill(0);
let app_src = core::slice::from_raw_parts(
self.app_start[app_id] as *const u8,
self.app_start[app_id + 1] - self.app_start[app_id]
);
let app_dst = core::slice::from_raw_parts_mut(
APP_BASE_ADDRESS as *mut u8,
app_src.len()
);
app_dst.copy_from_slice(app_src);

Run each app!

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
// os/src/batch.rs

pub fn run_next_app() -> ! {
let mut app_manager = APP_MANAGER.exclusive_access();
let current_app = app_manager.get_current_app();
unsafe {
app_manager.load_app(current_app);
}
app_manager.move_to_next_app();
drop(app_manager);
// before this we have to drop local variables related to resources manually
// and release the resources
extern "C" { fn __restore(cx_addr: usize); }
unsafe {
__restore(KERNEL_STACK.push_context(
TrapContext::app_init_context(APP_BASE_ADDRESS, USER_STACK.get_sp())
) as *const _ as usize);
}
panic!("Unreachable in batch::run_current_app!");
}

// main logic:
// os/src/main.rs

...above code
// load entry for trap
trap::init()
// load app
batch::run_next_app()

Chapter 1

Execution Environment

Execution Environment

The execution environment is defined by the Target Triplet, which specifies the platform, CPU architecture, and library required for the build. For example: x86_64-unknown-linux-gnu.

Components of the Target Triplet:

  • Platform: The specific operating system or runtime environment.
  • CPU Architecture: The underlying hardware architecture (e.g., x86_64, ARM).
  • Library: The standard library or runtime support required.

If the target platform contains no std or any support syscall, such platform called bare-metal, Rust contains a core lib independent of any platform support.

If we change .cargo/config s.t.:

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

it called cross compile because the running platform is different form execution platform.

No Std and No Main

The basic functionality provided by std and start semantic is panic_handler and main entry.

To toggle it off with:

1
2
#![no_std]
#![no_main]

RISCV

As for riscv, thing will be tough in here, we need to complete our own entry point, exit, and basic functionality like print/println.

First, we need to define linker and entry for stack allocation.

Linker:

1
2
3
4
5
6
7
# os/src/linker.ld
OUTPUT_ARCH(riscv)
ENTRY(_start) # entry point
BASE_ADDRESS = 0x80200000; # base addr for entry

SECTIONS
...

Stack Space:

1
2
3
4
5
6
7
8
9
10
11
12
13
# os/src/entry.asm
.section .text.entry
.globl _start
_start:
la sp, boot_stack_top
call rust_main # call rust_main function as entry

.section .bss.stack
.globl boot_stack_lower_bound
boot_stack_lower_bound:
.space 4096 * 16
.globl boot_stack_top
boot_stack_top:

For riscv, we need to call RustSBI(a underlying specification for rust in riscv).

After implement sbi_call, we could construct put_char:

1
2
3
4
5
6
7
8
9
const SBI_CONSOLE_PUTCHAR: usize = 1;

fn sbi_call(...) -> usize {
...
}

pub fn console_putchar(c:usize) {
sbi_call(SBI_CONSOLE_PUTCHAR,c,0,0)
}

With a formal interface for write:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Stdout;

impl Write for Stdout {
fn write_str(&mut self, s: &str) -> fmt::Result {
for c in s.chars() {
console_putchar(c as usize);
}
Ok(())
}
}

pub fn print(args: fmt::Arguments) {
Stdout.write_fmt(args).unwrap();
}

Now we construct basic functionality in println, you could also handle panic_handler and others…