批处理系统
批处理系统 (Batch System) ,它可用来管理无需或仅需少量用户交互即可运行的程序,在资源允许的情况下它可以自动安排程序的执行,这被称为“批处理作业”。
特权机制
实现特权级机制的根本原因是应用程序运行的安全性不可充分信任。
确保操作系统的安全,对应用程序而言,需要限制的主要有两个方面:
- 应用程序不能访问任意的地址空间
- 应用程序不能执行某些可能破坏计算机系统的指令
为了实现这样的特权级机制,需要进行软硬件协同设计。一个比较简洁的方法就是,处理器设置两个不同安全等级的执行环境:用户态特权级的执行环境和内核态特权级的执行环境。且明确指出可能破坏计算机系统的内核态特权级指令子集,规定内核态特权级指令子集中的指令只能在内核态特权级的执行环境中执行。处理器在执行指令前会进行特权级安全检查,如果在用户态执行环境中执行这些内核态特权级指令,会产生异常。
为了让应用程序获得操作系统的函数服务,采用传统的函数调用方式(即通常的 call 和 ret 指令或指令组合)将会直接绕过硬件的特权级保护检查。所以可以设计新的机器指令:执行环境调用(Execution Environment Call,简称 ecall )和执行环境返回(Execution Environment Return,简称 eret )):
- ecall :具有用户态到内核态的执行环境切换能力的函数调用指令
- eret :具有内核态到用户态的执行环境切换能力的函数返回指令
RISC-V 特权级架构
RISC-V 架构中一共定义了 4 种特权级:
RISC-V 特权级
级别 | 编码 | 名称 |
0 | 00 | 用户/应用模式 (U, User/Application) |
1 | 01 | 监督模式 (S, Supervisor) |
2 | 10 | 虚拟监督模式 (H, Hypervisor) |
3 | 11 | 机器模式 (M, Machine) |
白色块表示一层执行环境,黑色块表示相邻两层执行环境之间的接口。这张图片给出了能够支持运行 Unix 这类复杂系统的软件栈。其中操作系统内核代码运行在 S 模式上;应用程序运行在 U 模式上。运行在 M 模式上的软件被称为 监督模式执行环境 (SEE, Supervisor Execution Environment),如在操作系统运行前负责加载操作系统的 Bootloader – RustSBI。站在运行在 S 模式上的软件视角来看,它的下面也需要一层执行环境支撑,因此被命名为 SEE,它需要在相比 S 模式更高的特权级下运行,一般情况下 SEE 在 M 模式上运行。
RISC-V的特权指令
与特权级无关的一般的指令和通用寄存器 x0 ~ x31 在任何特权级都可以执行。而每个特权级都对应一些特殊指令和 控制状态寄存器 (CSR, Control and Status Register) ,来控制该特权级的某些行为并描述其状态。当然特权指令不仅具有读写 CSR 的指令,还有其他功能的特权指令。
指令 | 含义 |
sret | 从 S 模式返回 U 模式:在 U 模式下执行会产生非法指令异常 |
wfi | 处理器在空闲时进入低功耗状态等待中断:在 U 模式下执行会产生非法指令异常 |
sfence.vma | 刷新 TLB 缓存:在 U 模式下执行会产生非法指令异常 |
访问 S 模式 CSR 的指令 | 通过访问 sepc/stvec/scause/sscartch/stval/sstatus/satp等CSR 来改变系统状态:在 U 模式下执行会产生非法指令异常 |
实现应用程序
应用程序的设计实现要点是:
- 应用程序的内存布局
- 应用程序发出的系统调用
应用程序设计
应用程序、用户库(包括入口函数、初始化函数、I/O 函数和系统调用接口等多个 rs 文件组成)放在项目根目录的 user 目录下,它和第一章的裸机应用不同之处主要在项目的目录文件结构和内存布局上:
- user/src/bin/*.rs :各个应用程序
- user/src/*.rs :用户库(包括入口函数、初始化函数、I/O 函数和系统调用接口等)
- user/src/linker.ld :应用程序的内存布局说明。
项目结构
我们看到 user/src 目录下面多出了一个 bin 目录。bin 里面有多个文件,目前里面至少有三个程序(一个文件是一个应用程序),分别是:
- hello_world :在屏幕上打印一行 Hello world from user mode program!
- store_fault :访问一个非法的物理地址,测试批处理系统是否会被该错误影响
- power :不断在计算操作和打印字符串操作之间进行特权级切换
批处理系统会按照文件名开头的数字编号从小到大的顺序加载并运行它
每个应用程序的实现都在对应的单个文件中。打开其中一个文件,会看到里面只有一个 main 函数和若干相关的函数所形成的整个应用程序逻辑。
在 lib.rs 中我们定义了用户库的入口点 _start :
#[no_mangle] #[link_section = ".text.entry"] pub extern "C" fn _start() -> ! { clear_bss(); exit(main()); panic!("unreachable after sys_exit!"); }
第 2 行使用 Rust 的宏将 _start 这段代码编译后的汇编代码中放在一个名为 .text.entry 的代码段中,方便我们在后续链接的时候调整它的位置使得它能够作为用户库的入口。
从第 4 行开始,进入用户库入口之后,手动清空需要零初始化的 .bss 段;然后调用 main 函数得到一个类型为 i32 的返回值,最后调用用户库提供的 exit 接口退出应用程序,并将 main 函数的返回值告知批处理系统。
我们还在 lib.rs 中看到了另一个 main :
#[linkage = "weak"] #[no_mangle] fn main() -> i32 { panic!("Cannot find main!"); }
第 1 行,我们使用 Rust 的宏将其函数符号 main 标志为弱链接。这样在最后链接的时候,虽然在 lib.rs 和 bin 目录下的某个应用程序都有 main 符号,但由于 lib.rs 中的 main 符号是弱链接,链接器会使用 bin 目录下的应用主逻辑作为 main 。这里我们主要是进行某种程度上的保护,如果在 bin 目录下找不到任何 main ,那么编译也能够通过,但会在运行时报错。
为了支持上述这些链接操作,我们需要在 lib.rs 的开头加入:
#![feature(linkage)]
内存布局
在 user/.cargo/config 中,设置链接时使用链接脚本 user/src/linker.ld 。在其中我们做的重要的事情是:
将程序的起始物理地址调整为 0x80400000 ,三个应用程序都会被加载到这个物理地址上运行;
将 _start 所在的 .text.entry 放在整个程序的开头,也就是说批处理系统只要在加载之后跳转到 0x80400000 就已经进入了 用户库的入口点,并会在初始化之后跳转到应用程序主逻辑;
提供了最终生成可执行文件的 .bss 段的起始和终止地址,方便 clear_bss 函数使用。
系统调用
在子模块 syscall 中,应用程序通过 ecall 调用批处理系统提供的接口,由于应用程序运行在用户态(即 U 模式), ecall 指令会触发 名为 Environment call from U-mode 的异常,并 Trap 进入 S 模式执行批处理系统针对这个异常特别提供的服务代码。由于这个接口处于 S 模式的批处理系统和 U 模式的应用程序之间,这个接口可以被称为 ABI 或者系统调用。现在我们不关心底层的批处理系统如何提供应用程序所需的功能,只是站在应用程序的角度去使用即可。
在本章中,应用程序和批处理系统之间按照 API 的结构,约定如下两个系统调用:
/// 功能:将内存中缓冲区中的数据写入文件。 /// 参数:`fd` 表示待写入文件的文件描述符; /// `buf` 表示内存中缓冲区的起始地址; /// `len` 表示内存中缓冲区的长度。 /// 返回值:返回成功写入的长度。 /// syscall ID:64 fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize; /// 功能:退出应用程序并将返回值告知批处理系统。 /// 参数:`exit_code` 表示应用程序的返回值。 /// 返回值:该系统调用不应该返回。 /// syscall ID:93 fn sys_exit(exit_code: usize) -> !;
我们知道系统调用实际上是汇编指令级的二进制接口,因此这里给出的只是使用 Rust 语言描述的 API 版本。在实际调用的时候,我们需要按照 RISC-V 调用规范(即ABI格式)在合适的寄存器中放置系统调用的参数,然后执行 ecall 指令触发 Trap。在 Trap 回到 U 模式的应用程序代码之后,会从 ecall 的下一条指令继续执行,同时我们能够按照调用规范在合适的寄存器中读取返回值。
在 RISC-V 调用规范中,和函数调用的 ABI 情形类似,约定寄存器 a0~a6 保存系统调用的参数, a0 保存系统调用的返回值。有些许不同的是寄存器 a7 用来传递 syscall ID,这是因为所有的 syscall 都是通过 ecall 指令触发的,除了各输入参数之外我们还额外需要一个寄存器来保存要请求哪个系统调用。由于这超出了 Rust 语言的表达能力,我们需要在代码中使用内嵌汇编来完成参数/返回值绑定和 ecall 指令的插入:
// user/src/syscall.rs use core::arch::asm; fn syscall(id: usize, args: [usize; 3]) -> isize { let mut ret: isize; unsafe { asm!( "ecall", inlateout("x10") args[0] => ret, in("x11") args[1], in("x12") args[2], in("x17") id ); } ret }
第 3 行,我们将所有的系统调用都封装成 syscall 函数,可以看到它支持传入 syscall ID 和 3 个参数。
syscall 中使用从第 5 行开始的 asm! 宏嵌入 ecall 指令来触发系统调用。
从 RISC-V 调用规范来看,就像函数有着输入参数和返回值一样, ecall 指令同样有着输入和输出寄存器: a0~a2 和 a7 作为输入寄存器分别表示系统调用参数和系统调用 ID ,而当系统调用返回后, a0 作为输出寄存器保存系统调用的返回值。在函数上下文中,输入参数数组 args 和变量 id 保存系统调用参数和系统调用 ID ,而变量 ret 保存系统调用返回值,它也是函数 syscall 的输出/返回值。这些输入/输出变量可以和 ecall 指令的输入/输出寄存器一一对应。如果完全由我们自己编写汇编代码,那么如何将变量绑定到寄存器则成了一个难题:比如,在 ecall 指令被执行之前,我们需要将寄存器 a7 的值设置为变量 id 的值,那么我们首先需要知道目前变量 id 的值保存在哪里,它可能在栈上也有可能在某个寄存器中。
有些时候不必将变量绑定到固定的寄存器,此时 asm! 宏可以自动完成寄存器分配。某些汇编代码段还会带来一些编译器无法预知的副作用,这种情况下需要在 asm! 中通过 options 告知编译器这些可能的副作用,这样可以帮助编译器在避免出错更加高效分配寄存器。事实上,
上面这一段汇编代码的含义和内容与 第一章中的 RustSBI 输出到屏幕的 SBI 调用汇编代码 涉及的汇编指令一样,但传递参数的寄存器的含义是不同的。
于是 sys_write 和 sys_exit 只需将 syscall 进行包装:
// user/src/syscall.rs const SYSCALL_WRITE: usize = 64; const SYSCALL_EXIT: usize = 93; pub fn sys_write(fd: usize, buffer: &[u8]) -> isize { syscall(SYSCALL_WRITE, [fd, buffer.as_ptr() as usize, buffer.len()]) } pub fn sys_exit(xstate: i32) -> isize { syscall(SYSCALL_EXIT, [xstate as usize, 0, 0]) }
注意 sys_write 使用一个 &[u8] 切片类型来描述缓冲区,这是一个 胖指针 (Fat Pointer),里面既包含缓冲区的起始地址,还 包含缓冲区的长度。我们可以分别通过 as_ptr 和 len 方法取出它们并独立地作为实际的系统调用参数。
我们将上述两个系统调用在用户库 user_lib 中进一步封装,从而更加接近在 Linux 等平台的实际系统调用接口:
// user/src/lib.rs use syscall::*; pub fn write(fd: usize, buf: &[u8]) -> isize { sys_write(fd, buf) } pub fn exit(exit_code: i32) -> isize { sys_exit(exit_code) }
我们把 console 子模块中 Stdout::write_str 改成基于 write 的实现,且传入的 fd 参数设置为 1,它代表标准输出, 也就是输出到屏幕。目前我们不需要考虑其他的 fd 选取情况。这样,应用程序的 println! 宏借助系统调用变得可用了。 参考下面的代码片段:
// user/src/console.rs const STDOUT: usize = 1; impl Write for Stdout { fn write_str(&mut self, s: &str) -> fmt::Result { write(STDOUT, s.as_bytes()); Ok(()) } }
exit 接口则在用户库中的 _start 内使用,当应用程序主逻辑 main 返回之后,使用它退出应用程序并将返回值告知 底层的批处理系统。
编译生成应用程序二进制码
这里简要介绍一下应用程序的自动构建。只需要在 user 目录下 make build 即可:
实现操作系统前执行应用程序
假定我们已经完成了编译并生成了 ELF 可执行文件格式的应用程序,我们就可以来试试。首先看看应用程序执行 RV64 的 S 模式特权指令 会出现什么情况,对应的应用程序可以在 user/src/bin 目录下找到。
// user/src/bin/03priv_inst.rs use core::arch::asm; #[no_mangle] fn main() -> i32 { println!("Try to execute privileged instruction in U Mode"); println!("Kernel should kill this application!"); unsafe { asm!("sret"); } 0 } // user/src/bin/04priv_csr.rs use riscv::register::sstatus::{self, SPP}; #[no_mangle] fn main() -> i32 { println!("Try to access privileged CSR in U Mode"); println!("Kernel should kill this application!"); unsafe { sstatus::set_spp(SPP::User); } 0 }
在上述代码中,两个应用都会打印提示信息,随后应用 03priv_inst 会尝试在用户态执行内核态的特权指令 sret ,而应用 04priv_csr 则会试图在用户态修改内核态 CSR sstatus 。
接下来,我们尝试在用户态模拟器 qemu-riscv64 执行这两个应用:
cd user make build cd target/riscv64gc-unknown-none-elf/release/ 确认待执行的应用为 ELF 格式 file 03priv_inst 03priv_inst: ELF 64-bit LSB executable, UCB RISC-V, version 1 (SYSV), statically linked, not stripped 执行特权指令出错 qemu-riscv64 ./03priv_inst Try to execute privileged instruction in U Mode Kernel should kill this application! Illegal instruction (core dumped) 执行访问特权级 CSR 的指令出错 qemu-riscv64 ./04priv_csr Try to access privileged CSR in U Mode Kernel should kill this application! Illegal instruction (core dumped)
看来RV64的特权级机制确实有用。那对于一般的用户态应用程序,在 qemu-riscv64 模拟器下能正确执行吗?
rcore 笔记 批处理系统 邓氏鱼(二)https://developer.aliyun.com/article/1391897