Batch OS Details, rCore
第二章需要实现实现一个 邓氏鱼
操作系统, 它能够感知多个应用程序的存在, 并一个接一个地运行这些应用程序, 当一个应用程序执行完毕后, 会启动下一个应用程序, 直到所有的应用程序都执行完毕。
0. 资料汇总
- RISC-V
- RISC-V ELF psABI: Processor-specific application binary interface document.
- RISC-V Supervisor Binary Interface: Spec for SBI.
- RISC-V C API: RISC-V-specific predefined macros, function attributes and language extensions.
- RISC-V Assembly Programmer’s Manual: Document for pseudoinstructions and assembly directives.
- RISC-V Specifications:
- RISC-V ACLINT specification: ACLINT (Advanced Core Local Interruptor) specification defines a set of memory mapped devices which provide inter-processor interrupt and timer functionality for each HART of a multi-HART (or multi-processor) RISC-V platform.
- RISC-V Assembly Programmer’s Manual: Provide guidance to assembly programmers targeting the standard RISC-V assembly language.
- rCore
- rCore 第二章相关内容的实现可查看 Github Tag: [ch2-lab] [ch2-pro]
- rCore source code of labs for spring 2023: rCore-Tutorial-Guide-2023S Source Code
- rCore Concise Manual: rCore-Tutorial-Guide-2023S
- rCore Detail Book: rCore-Tutorial-Book-v3
1. 实现应用程序 - 代码解析与提要
-
根据 Rust 程序设计语言 中的描述, 创建一个允许其他语言调用 Rust 函数需要添加
extern
标识。 另外#[no_mangle]
能保证 Rust 函数名不会被编译器处理变得难以阅读与定位, 使其保留原有的名称方便被其他语言指定与链接。#[no_mangle] #[link_section = ".text.entry"] pub extern "C" fn _start() -> ! { clear_bss(); exit(main()); panic!("unreachable after sys_exit!"); }
-
Linux kernel system calls for all architectures 中可以查阅 RISCV-64 规约的系统函数调用的接口与调用的 ID 号。
-
Rust 版本指南 中提及了 macro 的变化, 在 Rust 2015 版本, 当我们要导入外部 crate 的宏, 必须要使用
#[macro_use]
属性, 最直接的例子是在user/src/lang_items.rs
中的println
宏的使用, 我们可以通过在user/src/lib.rs
在通过mod console;
导入 console crate 前使用#[macro_use]
就能在 user 被管理的各个 crate 中使用 console 提供的宏了(前提是宏需要用#[macro_export]
修饰)。
2. 实现批处理操作系统
2.1 build 构建脚本
Rust语言圣经(Rust Course) - 9.3.10 描述了 rust 中的构建脚本的作用, 一言以蔽之, 构建脚本会在项目被构建之前 Cargo 会编译构建脚本生成可执行文件并执行相应的任务, 其多用于 C 依赖库构建, 或指定依赖库, 以及进行平台的配置等预处理过程。 在 rCore 的项目中, os 项目工程的根目录下的 build.rs
主要完成的是链接功能性的二进制工具, 静态链接这些测试文件以在后续运行时动态调用。
值得一提的是, Rust 的构建脚本是通过 println!
宏与 Cargo 进行通信的, 通信内容是以 cargo:
开头的一系列字符串。 在我们撰写 chapter 2 的 build.rs
时, 使用 wrtieln!
宏写入信息需要注意使用 r#"..."#
取消转义, 以免导致不必要的麻烦。
2.2 批处理模块
讲到这个章节的时候对 Rust 的 Crate, Module, Package, use
这些细节产生了疑问, 之前初学 Rust 走马观花过了一遍书没有实际案例分析, 对相关概念了解不深, Rust Course 以及 Rust学习笔记 分别用理论以及具体案例讲解和分析了相关内容, 非常的清晰。 在 rCore 的构建中, 对功能模块进行分级归类我觉得很重要, 不能一股脑的使用新的模块导入方式, 既然 Rust 1.3.0 以后提供了新的选择, 就不妨做好取舍。
Rust语言圣经(Rust Course) - 2.12 包和模块
对Rust的模块系统的清晰解释 - Rust 学习笔记
3 实现特权级的切换
3.1 关于 UserStack 与 KernelStack
批处理操作系统被设计为运行在内核态特权级(RISC-V 的 S 模式)而应用程序被设计为运行在用户态特权级(RISC-V 的 U 模式), 在 AEE(Application Execution Environment) 中受到操作系统监管, 执行过程中需要切换特权级。 Context 即上下文需要在特权级切换前后保持不变, 内核态和用户态的上下文信息需要保存在不通的栈中以保证数据的隔离。 rCore 保存上下文信息的栈是这样设计的, get_sp
返回数组结尾地址, 即栈底位置(栈由上向下生长, 栈底的地址比栈顶高, 除非像当前这样栈为空栈底和栈顶处于同一个地址)。
#[repr(align(4096))]
struct UserStack {
data: [u8; USER_STACK_SIZE],
}
impl UserStack {
fn get_sp(&self) -> usize {
self.data.as_ptr() as usize + USER_STACK_SIZE
}
}
未初始化的全局变量和局部静态变量默认值都是 0, 一般来说会将这部分信息存放在 .bss 段并预留未定义的全局变量符号。 这里声明了一个全局静态变量并强制 4096 对齐。 通过 readelf -t os/target/riscv64gc-unknown-none-elf/release/os
读取编译出的 ELF 文件, .rodata
段被 4096 对齐了, 更改栈的大小也能确认 KernelStack
以及 UserStack
被放在了 .rodata
中。 初始化为 0 的全局变量和未初始化的全局变量的性质应当一致, 但这里出现了例外。 Rust 中的 static 变量实际声明了一段固定的内存空间且其中的内容不可变, 猜测 Rust 编译器倾向于将只读部分的预留内存保存在 .rodata
段。
The Rust Reference - 6.10 Static items
Non-mut static items that contain a type that is not interior mutable may be placed in read-only memory.
There are 20 section headers, starting at offset 0x187ca0:
Section Headers:
[Nr] Name
Type Address Offset Link
Size EntSize Info Align
Flags
[ 0]
NULL 0000000000000000 0000000000000000 0
0000000000000000 0000000000000000 0 0
[0000000000000000]:
[ 1] .text
PROGBITS 0000000080200000 0000000000001000 0
00000000000033ce 0000000000000000 0 4
[0000000000000006]: ALLOC, EXEC
[ 2] .rodata
PROGBITS 0000000080204000 0000000000005000 0
00000000000062d0 0000000000000000 0 4096
[0000000000000012]: ALLOC, MERGE
[ 3] .data
PROGBITS 000000008020b000 000000000000c000 0
0000000000007430 0000000000000000 0 8
[0000000000000003]: WRITE, ALLOC
[ 4] .bss
NOBITS 0000000080213000 0000000000013430 0
00000000000100c0 0000000000000000 0 8
[0000000000000003]: WRITE, ALLOC
最简单的办法就是将 USER_STACK
以及 KERNEL_STACK
这两个变量声明为 static mut
类型, 就能将这两块内存初始化为 .bss
段。
3.2 Non Stable Rust ABI
static mut KERNEL_STACK: KernelStack = KernelStack {
data: [0; KERNEL_STACK_SIZE],
};
static mut USER_STACK: UserStack = UserStack {
data: [0; USER_STACK_SIZE],
};
impl KernelStack {
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() }
}
}
在此处 issue - whfuyn 提出了两个问题, trap.S
的 __alltraps
段在最后调用了 trap_handler
以处理 trap
的内容, 但 Rust ABI 是 unstable 的, 我在这篇文章 “Rust does not have a stable ABI” - Federico’s Blog 中找到了相应的解释, 虽然对 ABI 的作用还是理解不是很深刻, 但也理解了为什么 src/context.rs
中的 TrapContext
会写成下面这种形式。
#[repr(C)]
pub struct TrapContext {
/// general regs[0..31]
pub x: [usize; 32],
/// CSR sstatus
pub sstatus: Sstatus,
/// CSR sepc
pub sepc: usize,
}
#[repr(C)]
的声明使得字段的顺序、 大小以及对齐方式与 C 中完全一致。 #[repr(C)]
结构体使得结构体的对齐量为 最大对齐量的字段, 字段的尺寸和偏移量由以下算法决定:
-
把当前偏移量设为从 0 字节开始。
-
对于结构体中的每个字段, 按其声明的先后顺序, 首先确定其尺寸和对齐量; 如果当前偏移量不是对其齐量的整倍数, 则向当前偏移量添加填充字节, 直至其对齐量的倍数1; 至此, 当前字段的偏移量就是当前偏移量; 下一步再根据当前字段的尺寸增加当前偏移量。
-
最后, 整个结构体的尺寸就是当前偏移量向上取整到结构体对齐量的最小整数倍数。
3.3 static mut 与 UnSafeCell
同样是 issue - whfuyn 提出的问题, 可以看到原来的 KernelStack
结构体实现了 fn get_sp
以及 pub fn push_context
两个函数, 但是这两个函数都使用 &self
共享不可变引用。 毫无疑问 self.data
访问了 KernelStack
的内部量, 并且在 push_context
中我们将指针的地址强制转换为了一个 可变裸指针, 裸指针的解引用是不安全的, 没有借用和声明周期的检查。
impl KernelStack {
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() }
}
}
The Rust Reference 对内部可变性的定义是这样的:
- interior mutability
- A type has interior mutability if its internal state can be changed through a shared reference to it.
显然这违反了共享引用(类似 &self
)所指向的值不变的要求。 UnSafeCell<T>
是唯一允许的方式达到内部可变性, Cell<T>
的内部实现也是通过 UnSafeCell<T>
达到的。 UnSafeCell<T>
使用了 #[lang = "unsafe_cell"]
宏以让编译器对其进行特殊处理, 使其不具备声明周期的 协/逆变 特性。 因而我们要实现内部可变性必须通过 UnSafeCell<T>
以避免未定义行为, 我们无法处理生命周期的问题。 UnSafeCell<T>
能够实现从不可变引用到 可变裸指针 的安全转换, 我们尽可能多的将安全检查问题交给编译器。
原来的 batch.rs
使用 UnSafeCell<T>
后的版本可查看 commit#8fbb8e0。
(soundness regression) static mut could be avoided. #117 - rust-lang-nursery/lazy-static.rs
Consider deprecation of UB-happy static mut #53639 - rust-lang/rust
关于 Rust 的 UnsafeCell、Cell 与 RefCell - 知乎
Rust语言圣经(Rust Course) - 13.6.3 Miri
Rust语言圣经(Rust Course) - 13.6.4 栈借用
Rust语言圣经(Rust Course) - 13.6.5 测试栈借用
Struct core::cell::UnsafeCell
The Rust Reference - 10.4. Interior mutability
3.4 关于 trap
在 trap_handler
中, 对于 Exception::UserEnvCall
异常, 有一个更新 sepc
寄存器信息的指令为 cx.sepc += 4
, 根据 issue 中的一条回答是这样解释的:
中断和异常的触发方式不同, 因此硬件设置的“默认执行的下一条指令的地址”也不同。 异常是由于一条指令的执行触发的, 此时硬件默认会将
sepc
仍然设置为这条指令的地址, 等内核处理完之后再执行一次, 期待这次指令能够正常执行; 而中断是在一条指令执行完毕之后, CPU 检测到了中断, 此时硬件会将sepc
设置为下一条指令的地址, 因为没有任何理由再执行一次刚刚执行完的指令。
4 课后练习
4.1 编程题
- 实现一个裸机应用程序 A, 能打印调用栈。
Stack Frame, rCore根据 第一章 - 为内核支持函数调用 描述的 RISC-V 的栈帧结构, 对第一章的编程题进行更改实现即可, 主要改动还是
asm
中的内容。 具体可以参考 stack_btrace.rs。 -
扩展内核, 实现新系统调用
get_taskinfo
, 能显示当前 task 的 id 和 task name; 实现一个裸机应用程序 B, 能访问get_taskinfo
系统调用。这部分真的折磨, 实在是想不到裸机情况下怎么获取 bin 文件的 prefix 名称, 考虑过添加 bin 文件 header 的方式但想想还是奇怪。 主要思路是考虑在
APP_MANAGER
中就把这部分信息填好, 能力有限不搞了。 -
扩展内核, 能够统计多个应用的执行过程中系统调用编号和访问此系统调用的次数。
这部分第一个想法就是用哈希表, 找了挺久发现一个 hashbrown 库是能用在 no_std 环境的。 我实现了
os/src/syscall/stats.rs
提供了两个函数分别是stats_update
(放在 trap_handler 的UserEnvCall
异常) 以及stats_clear_and_print
(放在run_next_app
之前)。 但是遇到一个问题, 但是当前的 OS 实现下是不能使用 heap 存储 static 变量的信息的, 需要之后再进行测试。error: no global memory allocator found but one is required; link to std or add `#[global_allocator]` to a static item that implements the GlobalAlloc trait
-
扩展内核, 能够统计每个应用执行后的完成时间。
原来想用 embedded-time 这个库替代标准库中的 time crate, 但是那个 Clock Trait 需要知悉硬件的信息才能实现就放弃了这个想法。 记得在第一章作业的 comment 有人提了一嘴有 time 相关的寄存器, 而且在 qemu 中这个 time 相关的时钟频率是 10 KHz。 所幸 riscv 库提供了这部分寄存器的读写, 只要在
run_next_app
函数的开始记录程序开始的时刻, 另外就是在异常退出或者 exit 函数调用中的run_next_app
函数前获取时间间隔即可。需要注意 u64 整数溢出以及可能出现的时刻点信息未获取为空的情况, 可以考虑使用
Option
。 具体实现可参考 comment#ce558ec -
扩展内核, 统计执行异常的程序的异常情况(主要是各种特权级涉及的异常), 能够打印异常程序的出错的地址和指令等信息。
编程题的设计好奇怪啊, 各种穿插, 直接仿照参考部分加上一些异常情况的处理, 具体实现可以参考 commit#d7f16c7。
4.2 实验练习
4.2.1 sys_write 安全检查
相关实现可以参考 ch2-lab, 主要想法就是检查当前传入的 buf
地址的合法性。
- 应用空间合法性
- app 经过
load_app
函数已经加载到了指定的内存空间中, 因而我们需要检查传入的buf
指针所处位置是否在 [APP_BASE_ADDRESS, APP_BASE_ADDRESS + APP_SIZE_LIMIT] 之间, 即检查应用空间的合法性。 - 用户栈空间合法性
- 需要注意的是, 栈是从上往下生长的, 因而获取栈指针之后, 我们需要检查
buf
可能在用户栈的范围是 [USER_STACK_PTR_ADDR - USER_STACK_SIZE, USER_STACK_PTR_ADDR]。
4.2.2 问答题
-
正确进入 U 态后,程序的特征还应有:使用 S 态特权指令,访问 S 态寄存器后会报错。请自行测试这些内容 (运行 Rust 三个 bad 测例 ) ,描述程序出错行为,注明你使用的 sbi 及其版本。
bad_address.rs
,bad_instruction.rs
,bad_register.rs
运行的结果如下:# SBI及其版本信息: RustSBI version 0.3.0-alpha.4, adapting to RISC-V SBI v1.0.0 [kernel] Loading app_5 [kernel] PageFault in application, kernel killed it. [kernel] Loading app_6 [kernel] IllegalInstruction in application, kernel killed it. [kernel] Loading app_7 [kernel] IllegalInstruction in application, kernel killed it.
bad_address.rs
出现PageFault
是因为非法访问了 0 地址空间并尝试对其进行值写入, 而程序的起始地址是0x80400000
。bad_instruction.rs
以及bad_register.rs
分别因为在 U Mode 调用了sret
S 特权级指令以及访问了sstatus
S 特权级寄存器而出错。
-
请结合用例理解 trap.S 中两个函数
__alltraps
和__restore
的作用,并回答如下几个问题:- L40: 刚进入
__restore
时,a0
代表了什么值。请指出__restore
的两种使用情景。这个问题我在 chapter2 issues 中有进行回答, 应当为 kernel 的
sp
。__restore
可以初始化内核栈以及用户栈指针地址, 进行用户态程序的加载, S Mode => U Mode;- 除此之外, 可以进行 trap 后的上下文恢复回到用户态。
- L46-L51: 这几行汇编代码特殊处理了哪些寄存器? 这些寄存器的的值对于进入用户态有何意义? 请分别解释。
ld t0, 32*8(sp) # 从内核栈中读取 sstatus 值 ld t1, 33*8(sp) # 从内核栈中读取 spec 值 ld t2, 2*8(sp) # 从内核栈中读取 sscratch 值 # 以下分别将读取的值写入对应的 CSR 特殊寄存器中 csrw sstatus, t0 csrw sepc, t1 csrw sscratch, t2
sstatus
表示程序状态, 其中的 SPP 指明了上一个状态时 U Mode, 可以帮助我们恢复到用户态。sepc
其实就是 S Mode 下的pc
, 可以指定用户态的程序的入口位置。sscratch
可以用来交换内核态与用户态的sp
寄存器的值。
- L53-L59: 为何跳过了
x2
和x4
?sp(x2)
是栈指针, 我们需要使用栈指针来定位缓存在内核栈和用户栈中的寄存器的位置;tp(x4)
是线程指针寄存器, 对于 uni-processor 而言用不到。 - L63:该指令之后,
sp
和sscratch
中的值分别有什么意义?sp
中存储的是用户栈栈顶的sp
,sscratch
中存储的是内核栈栈顶的sp
。 __restore
中发生状态切换在哪一条指令? 为何该指令执行之后会进入用户态?状态切换发生在
sret
指令。csrw sstatus, t0
中将特权级设置为 U,sstatus
的SPP
等字段给出 Trap 发生之前 CPU 处在哪个特权级(S/U)等信息, 可以从多个方面控制 S 特权级的 CPU 行为和执行状态。 在sret
执行后硬件会做这两件事:- CPU 会将当前的特权级按照
sstatus
的SPP
字段设置为 U; - CPU 会跳转到
sepc
寄存器指向的那条指令, 然后继续执行。
- CPU 会将当前的特权级按照
- L13:该指令之后,
sp
和sscratch
中的值分别有什么意义?与 L63 行为正好相反。
- 从 U 态进入 S 态是哪一条指令发生的?
进入 Trap 之前会有 syscall, 而 syscall 的第一句就是
ecall
, 调用完就切换状态了。
- L40: 刚进入
-
对于任何中断,
__alltraps
中都需要保存所有寄存器吗? 你有没有想到一些加速__alltraps
的方法? 简单描述你的想法。可以仅保存内核态中断服务程序所必须的状态, 包括CPU寄存器、 内核堆栈、 硬件中断等参数。 [参考]
5 调试记录
5.1 no_std 引入的错误
使用了 #![no_std]
之后 rust-analyzer 会注释一个 “can’t find crate for test
” 错误, 看着突兀的红色警告很是不爽。 rCore-Tutorial-v3 的 .vscode/setting.json
通过更改 rust-analyzer 的配置来尝试解决问题, 但是我在尝试 Google 搜索的所有结果后都没有解决问题。 但在 rust 社区我找到了一条帖子 [error[E0463]: can’t find crate for test
], 确实能消除这类报错并且不会对编译以及输出产生影响。 而我们所要做的更改也非常简单, 只需要在根文件 main.rs
中加入以下代码即可。
#![reexport_test_harness_main = "test_main"]
#![feature(custom_test_frameworks)]
#![test_runner(test_runner)]
pub fn test_runner(_test: &[&i32]) {
loop {}
}
5.2 在 no_std 环境下增加 test 用例
挖的坑还是要填的, 知其然知其所以然, 写 chapter2 的编程题的时候就在想可能得用到测试用例测试函数, 不然堆在主干代码中非常难管理。 所幸有人已经在如何实现 自定义框架测试 这块进行了研究了, 照着知乎这篇文章按流程分析, 我找到了 rust-osdev/bootimage 这个库, 它几乎能满足 qemu 创建 test 的所有需求但是目前仅支持 x86 架构, 这意味当前 rCore 使用 test 测试的构想落空了。
不枉我耗了一个晚上去找资料, 只能用朴素的办法了。
// src/main.rs
#![reexport_test_harness_main = "test_main"] // 创建新的 test 的入口函数
#![feature(custom_test_frameworks)] // 自定义 test 框架的属性声明
#![test_runner(test_runner)] // test 执行函数为 test_runner
#[no_mangle] // avoid compiler confusion
fn rust_main() {
...
#[cfg(test)]
test_main();
...
}
#[cfg(test)] // 保证该函数仅在 test 情形下生成
fn test_runner(tests: &[&dyn Fn()]) {
println!("Running {} tests", tests.len());
for test in tests {
test();
}
}
Writing an OS in Rust - Testing, Philipp Oppermann’s blog
使用Rust编写操作系统(四):内核测试 - 知乎