前言
刚开学就被学长推荐做rCore, 一开始是参加了校内工作室举办的光点计划Ⅱ,做到后面还剩下些时间就来参加了这个训练营,确实学到了很多东西,感觉这几个月是技术提升最快的一段时间。
🦀 第一阶段 - Rust
由于我之前有一点 C++ 基础,这一阶段的入门没有那么困难。Rust 中相当多的概念是与 C++ 互通的,如 RAII, move等等。在这一阶段了解到了 Rust 很多优秀的方面,比如:
- borrow checker (带来了更好的安全性,但是也提升了学习曲线)
- macro (过程宏很强大)
- cargo (太方便了,包管理比 C++ 完善多了)
- rustdoc (标准化了第三方库的文档管理,不管是写文档还是看文档都舒服了很多)
相关资料
学习中确实遇到了很多困惑,以下是一些资料的整理
All
Move
Rust 的 Move by default
有点像带GC的语言,与 C++ 有较大区别。
这几篇虽然主要是讲 C++ 的,但是其语义与 Rust 的 Move 很类似,文章中也有与 Rust 的对比
Slice
Fn/FnMut/FnOnce Trait
Rust 的闭包涉及到了一些所有权的转移问题,所以有一些特别的 Trait 需要注意,初学时有些迷糊。
- Closures: Anonymous Functions that Capture Their Environment
- 三种 Fn 特征
- Rust 中的闭包:function-like types and their traits
- 绕弯大王Rust里的Fn/FnMut/FnOnce
Misc
这几篇涉及一些对个别 Rust 语法的分析,其中最后一篇是我写的
- Rust 中的 Magic function params
- Does Rust have a way similar to prvalue in C++?
- Don't use boxed trait objects
- Rust数组长度中使用泛型参数
😋 第二阶段 - rCore
实验环境配置
本文实验环境为 Manjaro Linux x86_64 6.9.12-3-MANJARO
Qemu 7.0.0
wget https://download.qemu.org/qemu-7.0.0.tar.xz
tar xvJf qemu-7.0.0.tar.xz
cd qemu-7.0.0
./configure --target-list=riscv64-softmmu,riscv64-linux-user
make -j$(nproc)
qemu 7.0.0 编译的时候可能会报错,解决方案如下
修改 ebpf/ebpf_rss.c
中的 bpf_program__set_socket_filter
为 bpf_program__set_type(rss_bpf_ctx->progs.tun_rss_steering_prog, BPF_PROG_TYPE_SOCKET_FILTER);
GDB
首先是确认各项目(如 os
, user
和 easy-fs
) 的 Cargo.toml
中包含如下配置:
[profile.release]
debug = true
这一步如果没做好会导致后面GDB调试的时候断点不生效,十分重要。
关于 GDB 的配置也可以参考以下资料:
主要内容
RISC-V 相关资料
寄存器组 | 保存者 | 功能 |
---|---|---|
a0~a7 (x10~x17) | 调用者保存 | 用来传递输入参数。其中的 a0 和 a1 还用来保存返回值。 |
t0~t6 (x5~x7,x28~x31) | 调用者保存 | 作为临时寄存器使用,在被调函数中可以随意使用无需保存。 |
s0~s11 (x8~x9,x18~x27) | 被调用者保存 | 作为临时寄存器使用,被调函数保存后才能在被调函数中使用。 |
- zero (x0) 恒为零,函数调用不会对它产生影响
- ra (x1) 被调用者保存。被调用者函数可能也会调用函数,在调用之前就需要修改 ra 使得这次调用能正确返回。因此,每个函数都需要在开头保存 ra 到自己的栈帧中,并在结尾使用 ret 返回之前将其恢复。栈帧是当前执行函数用于存储局部变量和函数返回信息的内存结构。
- sp (x2) 是被调用者保存的。这个是之后就会提到的栈指针(Stack Pointer)寄存器,它指向下一个将要被存储的栈顶位置。
- fp (s0),它既可作为s0临时寄存器,也可作为栈帧指针(Frame Pointer)寄存器,表示当前栈帧的起始位置,是一个被调用者保存寄存器。fp 指向的栈帧起始位置 和 sp 指向的栈帧的当前栈顶位置形成了所对应函数栈帧的空间范围。
特权级的切换
特权级机制实现了用户态和内核态的隔离,因此这里的代码涉及到汇编与 Rust 代码的交互,硬件与操作系统的交互,与我以前接触的代码差别很大,虽然代码量不大,但难以理解。正确认识ecall
, sret
以及CSR
相关的原子指令是理解这块内容的关键。
CSR 名 | 该 CSR 与 Trap 相关的功能 |
---|---|
sstatus | SPP 等字段给出 Trap 发生之前 CPU 处在哪个特权级(S/U)等信息 |
sepc | 当 Trap 是一个异常的时候,记录 Trap 发生之前执行的最后一条指令的地址 |
scause | 描述 Trap 的原因 |
stval | 给出 Trap 附加信息 |
stvec | 控制 Trap 处理代码的入口地址 |
- csrr rd, csr (把控制状态寄存器 csr 的值写入 x[rd])
- csrrw rd, csr, rs (记控制状态寄存器 csr 中的值为 t。把寄存器 x[rs]的值写入 csr,再把 t 写入 x[rd]。)
任务切换
任务切换不涉及特权级切换。任务切换同样对应用是透明的,因此也需要保存相关的寄存器。理解__switch
的四个阶段是关键。
- 阶段 [1]:在 Trap 控制流 A 调用
__switch
之前,A 的内核栈上只有 Trap 上下文和 Trap 处理函数的调用栈信息,而 B 是之前被切换出去的; - 阶段 [2]:A 在 A 任务上下文空间在里面保存 CPU 当前的寄存器快照;
- 阶段 [3]:这一步极为关键,读取
next_task_cx_ptr
指向的 B 任务上下文,根据 B 任务上下文保存的内容来恢复ra
寄存器、s0~s11
寄存器以及sp
寄存器。只有这一步做完后,__switch
才能做到一个函数跨两条控制流执行,即 通过换栈也就实现了控制流的切换 。 - 阶段 [4]:上一步寄存器恢复完成后,可以看到通过恢复
sp
寄存器换到了任务 B 的内核栈上,进而实现了控制流的切换。这就是为什么__switch
能做到一个函数跨两条控制流执行。此后,当 CPU 执行ret
汇编伪指令完成__switch
函数返回后,任务 B 可以从调用__switch
的位置继续向下执行。
地址空间
第四章我感觉是最难的一章,突然出现了大量的新概念,代码量也激增。理解相关映射方式,跳板,地址空间的布局等是理解地址空间切换的关键。
另外第四章的lab可以实现一个辅助函数copy_to_app
,用来从内核地址空间复制数据到应用地址空间,这样本章和后面的lab都会方便很多。
进程
这一章与前面的任务切换有些类似,理解前面的内容对这一章有很大帮助。其中进程的调度算法比较复杂,也直接影响操作系统的性能。
文件系统
这一章代码量比较大,新概念也很多,感觉难度仅次于第四章。但是好在这里的代码几乎不涉及汇编等与 Rust 的交互,代码逻辑上与平时的编程较为相似,相对来说更容易理解一些。把握easy-fs
的五个层次是关键:
- 磁盘块设备接口层
- 块缓存层
- 磁盘数据结构层
- 磁盘块管理器层
- 索引节点层
另外最近校内有一个工作室的招新题涉及到了这一块,于是我用Rust实现了一个简单的虚拟文件系统。功能很简陋,很多特性都没有支持,但是做完后感觉对文件系统这一块的理解更加深入了。
进程间通信
这一章较前面简单一些,主要涉及管道、信号之类的方法,同时在练习中也了解了邮箱这种方式。这部分内容感觉与 Rust 中的mpsc有些相似。
并发
这部分主要是锁,信号量与条件变量的实现。用户态锁的实现比较有意思,特别是 Peterson 算法比较绕。
本章lab的死锁检测比较难,看题目有些摸不着头脑。
首先要辨清 Available
, Allocation
和 Need
分别对应着什么。
- 可利用资源向量 Available :含有 m 个元素的一维数组,每个元素代表可利用的某一类资源的数目,其初值是该类资源的全部可用数目,其值随该类资源的分配和回收而动态地改变。 Available[j] = k,表示第 j 类资源的可用数量为 k。
- 分配矩阵 Allocation:n * m 矩阵,表示每类资源已分配给每个线程的资源数。 Allocation[i,j] = g,则表示线程 i 当前己分得第 j 类资源的数量为 g。
- 需求矩阵 Need:n * m 的矩阵,表示每个线程还需要的各类资源数量。 Need[i,j] = d,则表示线程 i 还需要第 j 类资源的数量为 d 。
要注意的是这里的资源就是 mutex/semaphore, 第 j 类资源就是 id 为 j 的 mutex/semaphore。
相关资料
🤔 第三阶段 - 组件化操作系统
内核模式
其中 Unikernel 的应用与内核处于同一特权级,且共享同一地址空间,最终编译形成一个Image,一体运行。
而其他的内核大多应用与内核隔离特权级运行,具有独立的地址空间,最终是不同的Image,独立运行。
PFlash
Qemu的PFlash模拟闪存磁盘,启动时自动从文件加载内容到固定的MMIO区域,而且对读操作不需要驱动,可以直接访问。
TLSF (Two-Level Segregated Fit)
- First Level: 每一位对应一个范围的内存块,示例中分别对应24 ~ 231。1表示空闲。图中两个1。
- Second Level: 有几位就表示几等分。例如, 26表示64~127,然后进行4等分就是64~79, 80~95, 96~107, 108~127,每一位对应一个范围,同样1表示空闲。
Buddy
分配时寻找匹配alloc需要(order)的最小块。如果order大于目标,则二分切割,直至相等,每级剩余的部分挂到对应的Order List
释放时查看是否有邻居空闲块,有则尽可能向高Oder合并,直至无法合并,挂到OrderList。
Slab
分配时从 block 空闲链表中弹出一个 block。
依靠 Buddy 分配器提供内存分配支持,初始时以及 block 不足时,从 BuddyAllocator 申请,分割 block 后加入 block 空闲链表。
Slab 分配器分配内存以字节为单位,基于 Buddy 分配器的大内存进一步细分成小内存分配。换句话说,Slab 分配器仍然从 Buddy 分配器中申请内存,之后自己对申请来的内存细分管理。
基于 Unikernel 的最小化宏内核
需要的增量工作:
- 用户地址空间的创建和区域映射
- 在异常中断响应的基础上增加系统调用
- 复用 Unikernel 原来的调度机制,针对宏内核扩展 Task 属性
- 在内核与用户两个特权级之间的切换机制
兼容 Linux 应用
在应用和内核交互界面上实现兼容。
兼容界面包含三类:
1) syscall
2) procfs & sysfs等伪文件系统
3) 应用、编译器和libc对地址空间的假定,涉及某些参数定义或某些特殊地址的引用
应用的用户栈初始化
Linux应用基于glibc/musl-libc等库编译,libc在调用应用的main之前,检查用户栈上的参数等内容。
而应用启动之后,也可能会调用这些参数。内核需要在切换到首应用前,为应用准备栈上内容。
Hypervisor
Hypervisor与模拟器Emulator的区别:
根据1974年,Popek和Goldberg对虚拟机的定义, 虚拟机可以看作是物理机的一种高效隔离的复制,蕴含三层含义:同质、高效和资源受控。同质要求ISA的同构,高效要求虚拟化消耗可忽略,资源受控要求中间层对物理资源的完全控制。Hypervisor必须符合上述要求,而模拟器更侧重的是仿真效果,对性能效率通常没有硬性要求。其根本区别是虚拟运行环境和支撑它的物理运行环境的体系结构即ISA是否一致。
两种类型的虚拟化:
Riscv64在特权级模式的H扩展
特权级从三个扩展到五个,新增了与Host平行的Guest域
- 原来的S增强了对虚拟化支持的特性后,称它为HS。
- M/HS/U形成Host域,用来运行I型Hypervisor或者II型的HostOS,三个特权级的作用不变。
- VS/VU形成Guest域,用来运行GuestOS,这两个特权级分别对应内核态和用户态。
- HS是关键,作为联通真实世界和虚拟世界的通道。体系结构设计了双向变迁机制。
H扩展后,S模式发送明显变化:原有s[xxx]寄存器组作用不变,新增hs[xxx]和vs[xxx]
hs[xxx]寄存器组的作用:面向Guest进行路径控制,例如异常/中断委托等
vs[xxx]寄存器组的作用:直接操纵Guest域中的VS,为其准备或设置状态
Guest与Host的地址空间关系
Guest是指虚拟机所在的执行环境;Host指Hypervisor所处的执行环境。
Hypervisor负责基于HPA面向Guest映射GPA,基本寄存器是hgatp;
Guest认为看到的GPA是“实际”的物理空间,它基于satp映射内部的GVA虚拟空间。
虚拟机的时间中断
物理环境或者qemu模拟器中,时钟中断触发时,能够正常通过stvec寄存器找到异常中断向量表,然后
进入事先注册的响应函数。但是在虚拟机环境下,宿主环境下的原始路径失效了。有两种解决方案:
- 启用 RISC-V AIA 机制,把特定的中断委托到虚拟机 Guest 环境下。要求平台支持,且比较复杂。
- 通过中断注入的方式来实现。如下例
需要实现两部分的内容:
- 响应虚拟机发出的SBI-Call功能调用SetTimer
- 响应宿主机时钟中断导致的VM退出,注入到虚拟机内部
曹也是用上了rust了是吧
mb曹神
xm操作系统