0%

2025春夏季OS训练营学习笔记

终于完成了前三个阶段的练习。我这个系统方向的博士生,读了很多操作系统方面的论文,但是直到今天终于是脱离了书籍上的一知半解,可以称得上是“了解”操作系统的基本原理了。作为一个转专业学生,我从统计学起步,跨专业申请到了计算机方向的硕士,到现在在异国他乡博士在读。也算是凭着对计算机的热爱走到了现在。只是要在这个领域做出好的问题和好的研究,还是一件很困难的事情。写完最后一个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进步了。面对着越来越多的外设和越来越专业化的使用场景,操作系统研究者的未来又在何方呢?