批处理系统
管理无需或仅需少量用户交互即可运行的程序,在资源允许的情况下它可以自动安排程序的执行。
核心思想为:将多个程序打包到一起输入计算机。而当一个程序运行结束后,计算机会 自动 加载下一个程序到内存并开始执行。
特权级机制
保护 计算机系统不受有意或无意出错的程序破坏的机制被称为 特权级 (Privilege) 机制。
- 应用程序不能执行某些可能破坏计算机系统的指令
它让应用程序运行在用户态,而操作系统运行在内核态,且实现用户态和内核态的隔离。
- 处理器设置两个不同安全等级的执行环境:用户态特权级的执行环境和内核态特权级的执行环境。且明确指出可能破坏计算机系统的内核态特权级指令子集,规定内核态特权级指令子集中的指令只能在内核态特权级的执行环境中执行。处理器在执行指令前会进行特权级安全检查,如果在用户态执行环境中执行这些内核态特权级指令,会产生异常。
软硬件协同工作。
模拟客户端应用程序
创建应用端库
1 | tuos/ |
修改库名
1 | # tuos/user/Cargo.toml |
在 lib.rs
中我们定义了用户库的入口点 _start
1 | # tuos/user/lib.rs |
我们还在 lib.rs
中看到了另一个 main
1 | # tuos/user/lib.rs |
为了支持上述这些链接操作,我们需要在 lib.rs
的开头加入
1 | # tuos/user/lib.rs |
设置内存布局
让操作系统能够把应用加载到内存地址,然后顺利启动并运行应用程序。
在
user/.cargo/config
中,设置链接时使用链接脚本user/src/linker.ld
1
2
3
4
5
6
7
8
9user/.cargo/config
[build]
target = "riscv64gc-unknown-none-elf"
[target.riscv64gc-unknown-none-elf]
rustflags = [
"-Clink-args=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
]设置内存布局
- 在
user/src/linker.ld
中设置程序起始地址为0x80400000
; - 将
_start
所在的.text.entry
放在整个程序的开头,使得系统只要跳转到0x80400000
就已经进入了 用户库的入口点,并会在初始化之后跳转到应用程序主逻辑; - 提供了最终生成可执行文件的
.bss
段的起始和终止地址,方便clear_bss
函数使用;
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
33user/src/linker.ld
OUTPUT_ARCH(riscv)
ENTRY(_start)
BASE_ADDRESS = 0x80400000;
SECTIONS
{
. = BASE_ADDRESS;
.text : {
*(.text.entry)
*(.text .text.*)
}
.rodata : {
*(.rodata .rodata.*)
*(.srodata .srodata.*)
}
.data : {
*(.data .data.*)
*(.sdata .sdata.*)
}
.bss : {
start_bss = .;
*(.bss .bss.*)
*(.sbss .sbss.*)
end_bss = .;
}
/DISCARD/ : {
*(.eh_frame)
*(.debug*)
}
}- 在
调用系统ABI
使用RISC-V 提供的机器指令:执行环境调用指令(Execution Environment Call,简称 ecall
)和一类执行环境返回(Execution Environment Return,简称 eret
)指令。其中:
ecall
具有用户态到内核态的执行环境切换能力的函数调用指令;sret
:具有内核态到用户态的执行环境切换能力的函数返回指令。
这里约定如下两个系统调用:
1 | /// 功能:将内存中缓冲区中的数据写入文件。 |
由于rust不直接支持寄存器操作,为此我们需要在代码中使用内嵌汇编来完成参数/返回值绑定和 ecall
指令的插入:
1 | // user/src/syscall.rs |
将所有的系统调用都封装成 syscall
函数。
在 RISC-V 调用规范中,和函数调用的 ABI 情形类似,约定寄存器
a0~a6
保存系统调用的参数,a0
保存系统调用的返回值。有些许不同的是寄存器a7
用来传递 syscall ID,这是因为所有的 syscall 都是通过ecall
指令触发的,除了各输入参数之外我们还额外需要一个寄存器来保存要请求哪个系统调用。RISC-V 寄存器编号和别名
RISC-V 寄存器编号从
0~31
,表示为x0~x31
。 其中:
x10~x17
: 对应a0~a7
x1
:对应ra
在
ecall
指令中a0~a2
和a7
作为输入寄存器分别表示系统调用参数和系统调用 ID,当系统调用返回后,a0
作为输出寄存器保存系统调用的返回值。
于是 sys_write
和 sys_exit
只需将 syscall
进行包装:
1 | // user/src/syscall.rs |
将上述两个系统调用在用户库 user_lib
中进一步封装,从而更加接近在 Linux 等平台的实际系统调用接口:
1 | // user/src/lib.rs |
将 console
子模块中 Stdout::write_str
改成基于 write
的实现,且传入的 fd
参数设置为 1,它代表标准输出, 也就是输出到屏幕。目前我们不需要考虑其他的 fd
选取情况。这样,应用程序的 println!
宏借助系统调用变得可用了。
1 | // user/src/console.rs |
exit
接口则在用户库中的 _start
内使用,当应用程序主逻辑 main
返回之后,使用它退出应用程序并将返回值告知 底层的批处理系统。
模拟应用程序执行
尝试在用户态模拟器 qemu-riscv64
执行这两个应用:
1 | cd user |
在
user/Cargo.toml
下要设置[package]
edition = “2018”
[profile.release]
5 1 0opt-level = 0
不然几个实例客户端程序将可能会出错
实现批处理系统
- 操作系统自身运行在内核态,支持应用程序在用户态运行,且能够完成应用程序发出的系统调用。
- 能够一个接着一个地自动运行不同的运行程序
在具体实现其批处理执行应用程序功能之前,本节我们首先实现该应用加载机制,也即:在操作系统和应用程序需要被放置到同一个可执行文件的前提下,设计一种尽量简洁的应用放置和加载方式,使得操作系统容易找到应用被放置到的位置,从而在批处理操作系统和应用程序之间建立起联系的纽带。具体而言,应用放置采用“静态绑定”的方式,而操作系统加载应用则采用“动态加载”的方式:
- 静态绑定:通过一定的编程技巧,把多个应用程序代码和批处理操作系统代码“绑定”在一起。
- 动态加载:基于静态编码留下的“绑定”信息,操作系统可以找到每个应用程序文件二进制代码的起始地址和长度,并能加载到内存中运行。
将应用程序链接到内核
利用build.rs
脚本文件生成汇编代码将应用程序链接到内核
1 | // os/build.rs |
build.rs
脚本文件将生成以下类似文件
1 | # os/src/link_app.S |
将汇编代码链接到内核
1 | # os/src/main.rs |
找到并加载应用程序二进制码
创建一个应用管理器AppManager
用来找到并加载应用程序二进制码
1 | // os/src/batch.rs |
其中:
current_app
字段表示当前执行的是第几个应用,它是一个可修改的变量,会在系统运行期间发生变化。
我们希望将 AppManager
实例化为一个全局变量,使得任何函数都可以直接访问,最简单就是将它设置为static mut
,但static mut
则是 unsafe 的,而我们要在编程中尽量避免使用 unsafe ,这样才能让编译器负责更多的安全性检查。因此,我们需要使用RefCell
,即内部可变性。但要给static
类型的变量设置RefCell
则必须要实现Sync
tarit。为此我们在RefCell
上封装一层叫做UPSafeCell
。
1 | // os/src/sync/up.rs |
UPSafeCell
对于 RefCell
简单进行封装,它和 RefCell
一样提供内部可变性和运行时借用检查,只是更加严格:调用 exclusive_access
可以得到它包裹的数据的独占访问权。因此当我们要访问数据时(无论读还是写),需要首先调用 exclusive_access
获得数据的可变借用标记,通过它可以完成数据的读写,在操作完成之后我们需要销毁这个标记,此后才能开始对该数据的下一次访问。相比 RefCell
它不再允许多个读操作同时存在。
这段代码里面出现了两个 unsafe
:
- 首先
new
被声明为一个unsafe
函数,是因为我们希望使用者在创建一个UPSafeCell
的时候保证在访问UPSafeCell
内包裹的数据的时候始终不违背上述模式:即访问之前调用exclusive_access
,访问之后销毁借用标记再进行下一次访问。这只能依靠使用者自己来保证,但我们提供了一个保底措施:当使用者违背了上述模式,比如访问之后忘记销毁就开启下一次访问时,程序会 panic 并退出。unsafe
关键字本身无法提供上述保障,这个关键字主要是给用户一个警告和提示。这个保证在单核的情况下可以由借用检查器给到。 - 另一方面,我们将
UPSafeCell
标记为Sync
使得它可以作为一个全局变量。这是 unsafe 行为,因为编译器无法确定我们的UPSafeCell
能否安全的在多线程间共享。而我们能够向编译器做出保证,第一个原因是目前我们内核仅运行在单核上,因此无需在意任何多核引发的数据竞争/同步问题;第二个原因则是它基于RefCell
提供了运行时借用检查功能,从而满足了 Rust 对于借用的基本约束进而保证了内存安全。
初始化AppManager
的全局实例APP_MANAGER
这里我们使用了外部库 lazy_static
提供的 lazy_static!
宏。
添加依赖
1 | # os/Cargo.toml |
使用 lazy_static!
宏进行初始化工作
1 | // os/src/batch.rs |