rcore 笔记 批处理系统 邓氏鱼(二)https://developer.aliyun.com/article/1391897
Trap 上下文的保存与恢复
首先是具体实现 Trap 上下文保存和恢复的汇编代码。
在批处理操作系统初始化的时候,我们需要修改 stvec 寄存器来指向正确的 Trap 处理入口点。
// os/src/trap/mod.rs global_asm!(include_str!("trap.S")); pub fn init() { extern "C" { fn __alltraps(); } unsafe { stvec::write(__alltraps as usize, TrapMode::Direct); } }
这里我们引入了一个外部符号 __alltraps ,并将 stvec 设置为 Direct 模式指向它的地址。我们在 os/src/trap/trap.S 中实现 Trap 上下文保存/恢复的汇编代码,分别用外部符号 __alltraps 和 __restore 标记为函数,并通过 global_asm! 宏将 trap.S 这段汇编代码插入进来。
Trap 处理的总体流程如下:首先通过 __alltraps 将 Trap 上下文保存在内核栈上,然后跳转到使用 Rust 编写的 trap_handler 函数完成 Trap 分发及处理。当 trap_handler 返回之后,使用 __restore 从保存在内核栈上的 Trap 上下文恢复寄存器。最后通过一条 sret 指令回到应用程序执行。
首先是保存 Trap 上下文的 __alltraps 的实现:
# os/src/trap/trap.S .macro SAVE_GP n sd x\n, \n*8(sp) .endm .align 2 __alltraps: csrrw sp, sscratch, sp # now sp->kernel stack, sscratch->user stack # allocate a TrapContext on kernel stack addi sp, sp, -34*8 # save general-purpose registers sd x1, 1*8(sp) # skip sp(x2), we will save it later sd x3, 3*8(sp) # skip tp(x4), application does not use it # save x5~x31 .set n, 5 .rept 27 SAVE_GP %n .set n, n+1 .endr # we can use t0/t1/t2 freely, because they were saved on kernel stack csrr t0, sstatus csrr t1, sepc sd t0, 32*8(sp) sd t1, 33*8(sp) # read user stack from sscratch and save it on the kernel stack csrr t2, sscratch sd t2, 2*8(sp) # set input argument of trap_handler(cx: &mut TrapContext) mv a0, sp call trap_handler
- 第 7 行我们使用 .align 将 __alltraps 的地址 4 字节对齐,这是 RISC-V 特权级规范的要求;
- 第 9 行的 csrrw 原型是可以将 CSR 当前的值读到通用寄存器中,然后将通用寄存器 的值写入该 CSR 。因此这里起到的是交换 sscratch 和 sp 的效果。在这一行之前 sp 指向用户栈,sscratch 指向内核栈,现在 sp 指向内核栈, sscratch 指向用户栈。
- 第 12 行,我们准备在内核栈上保存 Trap 上下文,于是预先分配 字节的栈帧,这里改动的是 sp ,说明确实是在内核栈上。
- 第 13~24 行,保存 Trap 上下文的通用寄存器 x0~x31,跳过 x0 和 tp(x4),原因之前已经说明。我们在这里也不保存 sp(x2),因为我们要基于它来找到每个寄存器应该被保存到的正确的位置。实际上,在栈帧分配之后,我们可用于保存 Trap 上下文的地址区间为 ,按照 TrapContext 结构体的内存布局,基于内核栈的位置(sp所指地址)来从低地址到高地址分别按顺序放置 x0~x31这些通用寄存器,最后是 sstatus 和 sepc 。因此通用寄存器 xn 应该被保存在地址区间。为了简化代码,x5~x31 这 27 个通用寄存器我们通过类似循环的 .rept 每次使用 SAVE_GP 宏来保存,其实质是相同的。注意我们需要在 trap.S 开头加上 .altmacro 才能正常使用 .rept 命令。
- 第 25~28 行,我们将 CSR sstatus 和 sepc 的值分别读到寄存器 t0 和 t1 中然后保存到内核栈对应的位置上。指令的功能就是将 CSR 的值读到寄存器中。这里我们不用担心 t0 和 t1 被覆盖,因为它们刚刚已经被保存了。
- 第 30~31 行专门处理 sp 的问题。首先将 sscratch 的值读到寄存器 t2 并保存到内核栈上,注意: sscratch 的值是进入 Trap 之前的 sp 的值,指向用户栈。而现在的 sp 则指向内核栈。
- 第 33 行令,让寄存器 a0 指向内核栈的栈指针也就是我们刚刚保存的 Trap 上下文的地址,这是由于我们接下来要调用 trap_handler 进行 Trap 处理,它的第一个参数 cx 由调用规范要从 a0 中获取。而 Trap 处理函数 trap_handler 需要 Trap 上下文的原因在于:它需要知道其中某些寄存器的值,比如在系统调用的时候应用程序传过来的 syscall ID 和对应参数。我们不能直接使用这些寄存器现在的值,因为它们可能已经被修改了,因此要去内核栈上找已经被保存下来的值。
注解
RISC-V 中读写 CSR 的指令是一类能不会被打断地完成多个读写操作的指令。这种不会被打断地完成多个操作的指令被称为 原子指令 (Atomic Instruction)。这里的 原子 的含义是“不可分割的最小个体”,也就是说指令的多个操作要么都不完成,要么全部完成,而不会处于某种中间状态。
另外,RISC-V 架构中常规的数据处理和访存类指令只能操作通用寄存器而不能操作 CSR 。因此,当想要对 CSR 进行操作时,需要先使用读取 CSR 的指令将 CSR 读到一个通用寄存器中,而后操作该通用寄存器,最后再使用写入 CSR 的指令将该通用寄存器的值写入到 CSR 中。
当 trap_handler 返回之后会从调用 trap_handler 的下一条指令开始执行,也就是从栈上的 Trap 上下文恢复的 __restore :
# os/src/trap/trap.S .macro LOAD_GP n ld x\n, \n*8(sp) .endm __restore: # case1: start running app by __restore # case2: back to U after handling trap mv sp, a0 # now sp->kernel stack(after allocated), sscratch->user stack # restore sstatus/sepc ld t0, 32*8(sp) ld t1, 33*8(sp) ld t2, 2*8(sp) csrw sstatus, t0 csrw sepc, t1 csrw sscratch, t2 # restore general-purpuse registers except sp/tp ld x1, 1*8(sp) ld x3, 3*8(sp) .set n, 5 .rept 27 LOAD_GP %n .set n, n+1 .endr # release TrapContext on kernel stack addi sp, sp, 34*8 # now sp->kernel stack, sscratch->user stack csrrw sp, sscratch, sp sret
- 第 10 行比较奇怪我们暂且不管,假设它从未发生,那么 sp 仍然指向内核栈的栈顶。
- 第 13~26 行负责从内核栈顶的 Trap 上下文恢复通用寄存器和 CSR 。注意我们要先恢复 CSR 再恢复通用寄存器,这样我们使用的三个临时寄存器才能被正确恢复。
- 在第 28 行之前,sp 指向保存了 Trap 上下文之后的内核栈栈顶, sscratch 指向用户栈栈顶。我们在第 28 行在内核栈上回收 Trap 上下文所占用的内存,回归进入 Trap 之前的内核栈栈顶。第 30 行,再次交换 sscratch 和 sp,现在 sp 重新指向用户栈栈顶,sscratch 也依然保存进入 Trap 之前的状态并指向内核栈栈顶。
- 在应用程序控制流状态被还原之后,第 31 行我们使用 sret 指令回到 U 特权级继续运行应用程序控制流。
Trap 分发与处理
Trap 在使用 Rust 实现的 trap_handler 函数中完成分发和处理:
// os/src/trap/mod.rs #[no_mangle] pub fn trap_handler(cx: &mut TrapContext) -> &mut TrapContext { let scause = scause::read(); let stval = stval::read(); match scause.cause() { Trap::Exception(Exception::UserEnvCall) => { cx.sepc += 4; cx.x[10] = syscall(cx.x[17], [cx.x[10], cx.x[11], cx.x[12]]) as usize; } Trap::Exception(Exception::StoreFault) | Trap::Exception(Exception::StorePageFault) => { println!("[kernel] PageFault in application, kernel killed it."); run_next_app(); } Trap::Exception(Exception::IllegalInstruction) => { println!("[kernel] IllegalInstruction in application, kernel killed it."); run_next_app(); } _ => { panic!("Unsupported trap {:?}, stval = {:#x}!", scause.cause(), stval); } } cx }
- 第 4 行声明返回值为 &mut TrapContext 并在第 25 行实际将传入的Trap 上下文 cx 原样返回,因此在 __restore 的时候 a0 寄存器在调用 trap_handler 前后并没有发生变化,仍然指向分配 Trap 上下文之后的内核栈栈顶,和此时 sp 的值相同,这里的 并不会有问题;
- 第 7 行根据 scause 寄存器所保存的 Trap 的原因进行分发处理。这里我们无需手动操作这些 CSR ,而是使用 Rust 的 riscv 库来更加方便的做这些事情。要引入 riscv 库,我们需要:
# os/Cargo.toml [dependencies] riscv = { git = "https://github.com/rcore-os/riscv", features = ["inline-asm"] }
- 第 8~11 行,发现触发 Trap 的原因是来自 U 特权级的 Environment Call,也就是系统调用。这里我们首先修改保存在内核栈上的 Trap 上下文里面 sepc,让其增加 4。这是因为我们知道这是一个由 ecall 指令触发的系统调用,在进入 Trap 的时候,硬件会将 sepc 设置为这条 ecall 指令所在的地址(因为它是进入 Trap 之前最后一条执行的指令)。而在 Trap 返回之后,我们希望应用程序控制流从 ecall 的下一条指令开始执行。因此我们只需修改 Trap 上下文里面的 sepc,让它增加 ecall 指令的码长,也即 4 字节。这样在 __restore 的时候 sepc 在恢复之后就会指向 ecall 的下一条指令,并在 sret 之后从那里开始执行。
用来保存系统调用返回值的 a0 寄存器也会同样发生变化。我们从 Trap 上下文取出作为 syscall ID 的 a7 和系统调用的三个参数 a0~a2 传给 syscall 函数并获取返回值。 syscall 函数是在 syscall 子模块中实现的。 这段代码是处理正常系统调用的控制逻辑。
- 第 12~20 行,分别处理应用程序出现访存错误和非法指令错误的情形。此时需要打印错误信息并调用 run_next_app 直接切换并运行下一个应用程序。
- 第 21 行开始,当遇到目前还不支持的 Trap 类型的时候,“邓式鱼” 批处理操作系统整个 panic 报错退出。
实现系统调用功能
对于系统调用而言, syscall 函数并不会实际处理系统调用,而只是根据 syscall ID 分发到具体的处理函数:
// os/src/syscall/mod.rs pub fn syscall(syscall_id: usize, args: [usize; 3]) -> isize { match syscall_id { SYSCALL_WRITE => sys_write(args[0], args[1] as *const u8, args[2]), SYSCALL_EXIT => sys_exit(args[0] as i32), _ => panic!("Unsupported syscall_id: {}", syscall_id), } }
这里我们会将传进来的参数 args 转化成能够被具体的系统调用处理函数接受的类型。它们的实现都非常简单:
// os/src/syscall/fs.rs const FD_STDOUT: usize = 1; pub fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize { match fd { FD_STDOUT => { let slice = unsafe { core::slice::from_raw_parts(buf, len) }; let str = core::str::from_utf8(slice).unwrap(); print!("{}", str); len as isize }, _ => { panic!("Unsupported fd in sys_write!"); } } } // os/src/syscall/process.rs pub fn sys_exit(xstate: i32) -> ! { println!("[kernel] Application exited with code {}", xstate); run_next_app() }
- sys_write 我们将传入的位于应用程序内的缓冲区的开始地址和长度转化为一个字符串 &str ,然后使用批处理操作系统已经实现的 print! 宏打印出来。注意这里我们并没有检查传入参数的安全性,即使会在出错严重的时候 panic,还是会存在安全隐患。这里我们出于实现方便暂且不做修补。
- sys_exit 打印退出的应用程序的返回值并同样调用 run_next_app 切换到下一个应用程序。
执行应用程序
当批处理操作系统初始化完成,或者是某个应用程序运行结束或出错的时候,我们要调用 run_next_app 函数切换到下一个应用程序。此时 CPU 运行在 S 特权级,而它希望能够切换到 U 特权级。在 RISC-V 架构中,唯一一种能够使得 CPU 特权级下降的方法就是执行 Trap 返回的特权指令,如 sret 、mret 等。事实上,在从操作系统内核返回到运行应用程序之前,要完成如下这些工作:
- 构造应用程序开始执行所需的 Trap 上下文;
- 通过 __restore 函数,从刚构造的 Trap 上下文中,恢复应用程序执行的部分寄存器;
- 设置 sepc CSR的内容为应用程序入口点 0x80400000;
- 切换 scratch 和 sp 寄存器,设置 sp 指向应用程序用户栈;
- 执行 sret 从 S 特权级切换到 U 特权级。
它们可以通过复用 __restore 的代码来更容易的实现上述工作。我们只需要在内核栈上压入一个为启动应用程序而特殊构造的 Trap 上下文,再通过 __restore 函数,就能让这些寄存器到达启动应用程序所需要的上下文状态。
// os/src/trap/context.rs impl TrapContext { pub fn set_sp(&mut self, sp: usize) { self.x[2] = sp; } pub fn app_init_context(entry: usize, sp: usize) -> Self { let mut sstatus = sstatus::read(); sstatus.set_spp(SPP::User); let mut cx = Self { x: [0; 32], sstatus, sepc: entry, }; cx.set_sp(sp); cx } }
为 TrapContext 实现 app_init_context 方法,修改其中的 sepc 寄存器为应用程序入口点 entry, sp 寄存器为我们设定的一个栈指针,并将 sstatus 寄存器的 SPP 字段设置为 User 。
在 run_next_app 函数中我们能够看到:
// 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!"); }
在高亮行所做的事情是在内核栈上压入一个 Trap 上下文,其 sepc 是应用程序入口地址 0x80400000 ,其 sp 寄存器指向用户栈,其 sstatus 的 SPP 字段被设置为 User 。push_context 的返回值是内核栈压入 Trap 上下文之后的栈顶,它会被作为 __restore 的参数(回看 __restore 代码 ,这时我们可以理解为何 __restore 函数的起始部分会完成 ),这使得在 __restore 函数中 sp 仍然可以指向内核栈的栈顶。这之后,就和执行一次普通的 __restore 函数调用一样了。