实现批处理系统

批处理系统

管理无需或仅需少量用户交互即可运行的程序,在资源允许的情况下它可以自动安排程序的执行。

核心思想为:将多个程序打包到一起输入计算机。而当一个程序运行结束后,计算机会 自动 加载下一个程序到内存并开始执行。

特权级机制

  • 保护 计算机系统不受有意或无意出错的程序破坏的机制被称为 特权级 (Privilege) 机制。

    • 应用程序不能执行某些可能破坏计算机系统的指令
  • 它让应用程序运行在用户态,而操作系统运行在内核态,且实现用户态和内核态的隔离。

    • 处理器设置两个不同安全等级的执行环境:用户态特权级的执行环境内核态特权级的执行环境。且明确指出可能破坏计算机系统的内核态特权级指令子集,规定内核态特权级指令子集中的指令只能在内核态特权级的执行环境中执行。处理器在执行指令前会进行特权级安全检查,如果在用户态执行环境中执行这些内核态特权级指令,会产生异常。
  • 软硬件协同工作。

模拟客户端应用程序

创建应用端库

1
2
3
4
5
# tuos/

$ cargo new user -lib
Creating library `user` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

修改库名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# tuos/user/Cargo.toml

[package]
name = "user_lib"
version = "0.1.0"
authors = ["Kay <cn.kay.wang@gmail.com>"]
edition = "2018" # 这里要改不然后续执行会报错

[dependencies]
riscv = { git = "https://github.com/rcore-os/riscv", features = ["inline-asm"] }

[profile.release]
opt-level = 0 # 因qemu9.0.0兼容性文件所以要设置


lib.rs 中我们定义了用户库的入口点 _start

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# tuos/user/lib.rs

// 将属性宏从指定库中导入到当前作用域中
use user_lib::println;

// 不修改符号名
#[no_mangle]
// 指定.text.entry段入口
#[link_section = ".text.entry"]
pub extern "C" fn _start() -> ! {
clear_bss();
exit(main());
panic!("unreachable after sys_exit!");
}

我们还在 lib.rs 中看到了另一个 main

1
2
3
4
5
6
7
8
# tuos/user/lib.rs

// 标为弱链接,虽然名叫main但不会作为主函数
#[linkage = "weak"]
#[no_mangle]
fn main() -> i32 {
panic!("Cannot find main!");
}

为了支持上述这些链接操作,我们需要在 lib.rs 的开头加入

1
2
3
# tuos/user/lib.rs

#![feature(linkage)]

设置内存布局

让操作系统能够把应用加载到内存地址,然后顺利启动并运行应用程序。

  • user/.cargo/config 中,设置链接时使用链接脚本 user/src/linker.ld

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # user/.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
    33
    # user/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
2
3
4
5
6
7
8
9
10
11
12
13
/// 功能:将内存中缓冲区中的数据写入文件。
/// 参数:`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不直接支持寄存器操作,为此我们需要在代码中使用内嵌汇编来完成参数/返回值绑定和 ecall 指令的插入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// user/src/syscall.rs

use core::arch::asm;
fn syscall(id: usize, args: [usize; 3]) -> isize {
let mut ret: isize;
unsafe {
// asm! 宏可以将汇编代码嵌入到局部的函数上下文中。
// 相比 global_asm!, asm! 宏可以获取上下文中的变量信息并允许嵌入的汇编代码对这些变量进行操作
asm!(
"ecall",
inlateout("x10") args[0] => ret,
in("x11") args[1],
in("x12") args[2],
in("x17") id
);
}
ret
}

将所有的系统调用都封装成 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~a2a7 作为输入寄存器分别表示系统调用参数和系统调用 ID,当系统调用返回后, a0 作为输出寄存器保存系统调用的返回值。

于是 sys_writesys_exit 只需将 syscall 进行包装:

1
2
3
4
5
6
7
8
9
10
11
12
// 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])
}

将上述两个系统调用在用户库 user_lib 中进一步封装,从而更加接近在 Linux 等平台的实际系统调用接口:

1
2
3
4
5
6
// 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! 宏借助系统调用变得可用了。

1
2
3
4
5
6
7
8
9
10
11
12
// user/src/console.rs

const STDOUT: usize = 1;

struct Stdout;

impl Write for Stdout {
fn write_str(&mut self, s: &str) -> fmt::Result {
write(STDOUT, s.as_bytes());
Ok(())
}
}

exit 接口则在用户库中的 _start 内使用,当应用程序主逻辑 main 返回之后,使用它退出应用程序并将返回值告知 底层的批处理系统。

模拟应用程序执行

尝试在用户态模拟器 qemu-riscv64 执行这两个应用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ 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, RVC, double-float ABI, 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)

user/Cargo.toml下要设置

[package]

edition = “2018”

[profile.release]

5 1 0opt-level = 0

不然几个实例客户端程序将可能会出错

实现批处理系统

  • 操作系统自身运行在内核态,支持应用程序在用户态运行,且能够完成应用程序发出的系统调用。
  • 能够一个接着一个地自动运行不同的运行程序

在具体实现其批处理执行应用程序功能之前,本节我们首先实现该应用加载机制,也即:在操作系统和应用程序需要被放置到同一个可执行文件的前提下,设计一种尽量简洁的应用放置和加载方式,使得操作系统容易找到应用被放置到的位置,从而在批处理操作系统和应用程序之间建立起联系的纽带。具体而言,应用放置采用“静态绑定”的方式,而操作系统加载应用则采用“动态加载”的方式:

  • 静态绑定:通过一定的编程技巧,把多个应用程序代码和批处理操作系统代码“绑定”在一起。
  • 动态加载:基于静态编码留下的“绑定”信息,操作系统可以找到每个应用程序文件二进制代码的起始地址和长度,并能加载到内存中运行。

将应用程序链接到内核

利用build.rs脚本文件生成汇编代码将应用程序链接到内核

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// os/build.rs

use std::fs::{read_dir, File};
use std::io::{Result, Write};

fn main() {
println!("cargo:rerun-if-changed=../user/src/");
println!("cargo:rerun-if-changed={}", TARGET_PATH);
insert_app_data().unwrap();
}

static TARGET_PATH: &str = "../user/target/riscv64gc-unknown-none-elf/release/";

fn insert_app_data() -> Result<()> {
let mut f = File::create("src/link_app.S").unwrap();
let mut apps: Vec<_> = read_dir("../user/src/bin")
.unwrap()
.into_iter()
.map(|dir_entry| {
let mut name_with_ext = dir_entry.unwrap().file_name().into_string().unwrap();
name_with_ext.drain(name_with_ext.find('.').unwrap()..name_with_ext.len());
name_with_ext
})
.collect();
apps.sort();

writeln!(
f,
r#"
.align 3
.section .data
.global _num_app
_num_app:
.quad {}"#,
apps.len()
)?;

for i in 0..apps.len() {
writeln!(f, r#" .quad app_{}_start"#, i)?;
}
writeln!(f, r#" .quad app_{}_end"#, apps.len() - 1)?;

for (idx, app) in apps.iter().enumerate() {
println!("app_{}: {}", idx, app);
writeln!(
f,
r#"
.section .data
.global app_{0}_start
.global app_{0}_end
app_{0}_start:
.incbin "{2}{1}.bin"
app_{0}_end:"#,
idx, app, TARGET_PATH
)?;
}
Ok(())
}

build.rs脚本文件将生成以下类似文件

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# os/src/link_app.S

.align 3
.section .data
.global _num_app
_num_app:
.quad 5
.quad app_0_start
.quad app_1_start
.quad app_2_start
.quad app_3_start
.quad app_4_start
.quad app_4_end

.section .data
.global app_0_start
.global app_0_end
app_0_start:
.incbin "../user/target/riscv64gc-unknown-none-elf/release/00hello_world.bin"
app_0_end:

.section .data
.global app_1_start
.global app_1_end
app_1_start:
.incbin "../user/target/riscv64gc-unknown-none-elf/release/01store_fault.bin"
app_1_end:

.section .data
.global app_2_start
.global app_2_end
app_2_start:
.incbin "../user/target/riscv64gc-unknown-none-elf/release/02power.bin"
app_2_end:

.section .data
.global app_3_start
.global app_3_end
app_3_start:
.incbin "../user/target/riscv64gc-unknown-none-elf/release/03priv_inst.bin"
app_3_end:

.section .data
.global app_4_start
.global app_4_end
app_4_start:
.incbin "../user/target/riscv64gc-unknown-none-elf/release/04priv_csr.bin"
app_4_end:

将汇编代码链接到内核

1
2
3
# os/src/main.rs

global_asm!(include_str!("link_app.S"));

找到并加载应用程序二进制码

创建一个应用管理器AppManager用来找到并加载应用程序二进制码

1
2
3
4
5
6
7
// os/src/batch.rs

struct AppManager {
num_app: usize,
current_app: usize,
app_start: [usize; MAX_APP_NUM + 1],
}

其中:

  • current_app 字段表示当前执行的是第几个应用,它是一个可修改的变量,会在系统运行期间发生变化。

我们希望将 AppManager 实例化为一个全局变量,使得任何函数都可以直接访问,最简单就是将它设置为static mut,但static mut则是 unsafe 的,而我们要在编程中尽量避免使用 unsafe ,这样才能让编译器负责更多的安全性检查。因此,我们需要使用RefCell,即内部可变性。但要给static类型的变量设置RefCell则必须要实现Sync tarit。为此我们在RefCell上封装一层叫做UPSafeCell

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// os/src/sync/up.rs

pub struct UPSafeCell<T> {
/// inner data
inner: RefCell<T>,
}

unsafe impl<T> Sync for UPSafeCell<T> {}

impl<T> UPSafeCell<T> {
/// User is responsible to guarantee that inner struct is only used in
/// uniprocessor.
pub unsafe fn new(value: T) -> Self {
Self { inner: RefCell::new(value) }
}
/// Panic if the data has been borrowed.
pub fn exclusive_access(&self) -> RefMut<'_, T> {
self.inner.borrow_mut()
}
}

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
2
3
4
# os/Cargo.toml

[dependencies]
lazy_static = { version = "1.4.0", features = ["spin_no_std"] }

使用 lazy_static! 宏进行初始化工作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// os/src/batch.rs

lazy_static! {
static ref APP_MANAGER: UPSafeCell<AppManager> = unsafe { UPSafeCell::new({
extern "C" { fn _num_app(); }
let num_app_ptr = _num_app as usize as *const usize;
let num_app = num_app_ptr.read_volatile();
let mut app_start: [usize; MAX_APP_NUM + 1] = [0; MAX_APP_NUM + 1];
let app_start_raw: &[usize] = core::slice::from_raw_parts(
num_app_ptr.add(1), num_app + 1
);
app_start[..=num_app].copy_from_slice(app_start_raw);
AppManager {
num_app,
current_app: 0,
app_start,
}
})};
}

实现跨特权级的系统接口调用

执行系统调用前后能够准备和恢复用户态执行应用程序的上下文

支持多个应用程序轮流启动运行

二者之间的特权级切换

答疑