实现应用程序

注解

拓展阅读:RISC-V 特权级机制

应用程序设计

注意

用户库看起来很复杂,它预留了直到 ch7 内核才能实现的系统调用接口,console 模块还实现了输出缓存区。它们不是为本章准备的,你只需关注本节提到的部分即可。

应用程序、用户库(包括入口函数、初始化函数、I/O函数和系统调用接口等多个rs文件组成)放在项目根目录的 user 目录下:

  • user/src/bin/*.rs:各个应用程序

  • user/src/*.rs:用户库(包括入口函数、初始化函数、I/O函数和系统调用接口等)

  • user/src/linker.ld:应用程序的内存布局说明

项目结构

user/src/bin 里面有多个文件,其中三个是:

  • hello_world:在屏幕上打印一行 Hello, world!

  • bad_address:访问一个非法的物理地址,测试批处理系统是否会被该错误影响

  • power:不断在计算操作和打印字符串操作之间切换

批处理系统会按照文件名顺序加载并运行它们。

每个应用程序的实现都在对应的单个文件中。打开 hello_world.rs,能看到一个 main 函数,还有外部库引用:

#[macro_use]
extern crate user_lib;

这个外部库其实就是 user 目录下的 lib.rs 以及它引用的若干子模块。 在 user/Cargo.toml 中我们对于库的名字进行了设置: name =  "user_lib" 。 它作为 bin 目录下的源程序所依赖的用户库,等价于其他编程语言提供的标准库。

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

1#[no_mangle]
2#[link_section = ".text.entry"]
3pub extern "C" fn _start() -> ! {
4    clear_bss();
5    exit(main());
6}

第 2 行使用 link_section 宏将 _start 函数编译后的汇编代码放在名为 .text.entry 的代码段中, 方便用户库链接脚本将它作为用户程序的入口。

而从第 4 行开始,我们手动清零 .bss 段,然后调用 main 函数得到一个类型为 i32 的返回值, 最后,调用用户库提供的 exit 接口退出,并将返回值告知批处理系统。

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

1#![feature(linkage)]    // 启用弱链接特性
2
3#[linkage = "weak"]
4#[no_mangle]
5fn main() -> i32 {
6    panic!("Cannot find main!");
7}

我们使用 Rust 宏将其标志为弱链接。这样在最后链接的时候, 虽然 lib.rsbin 目录下的某个应用程序中都有 main 符号, 但由于 lib.rs 中的 main 符号是弱链接, 链接器会使用 bin 目录下的函数作为 main 。 如果在 bin 目录下找不到任何 main ,那么编译也能通过,但会在运行时报错。

内存布局

我们使用链接脚本 user/src/linker.ld 规定用户程序的内存布局:

  • 将程序的起始物理地址调整为 0x80400000 ,三个应用程序都会被加载到这个物理地址上运行;

  • _start 所在的 .text.entry 放在整个程序的开头 0x80400000; 批处理系统在加载应用后,跳转到 0x80400000,就进入了用户库的 _start 函数;

  • 提供了最终生成可执行文件的 .bss 段的起始和终止地址,方便 clear_bss 函数使用。

其余的部分和第一章基本相同。

系统调用

在子模块 syscall 中我们来通过 ecall 调用批处理系统提供的接口, 由于应用程序运行在用户态(即 U 模式), ecall 指令会触发名为 Environment call from U-mode 的异常, 并 Trap 进入 S 模式执行批处理系统针对这个异常特别提供的服务程序。 这个接口被称为 ABI 或者系统调用。 现在我们不关心 S 态的批处理系统如何提供应用程序所需的功能,只考虑如何使用它。

在本章中,应用程序和批处理系统约定如下两个系统调用:

第二章新增系统调用
/// 功能:将内存中缓冲区中的数据写入文件。
/// 参数:`fd` 表示待写入文件的文件描述符;
///      `buf` 表示内存中缓冲区的起始地址;
///      `len` 表示内存中缓冲区的长度。
/// 返回值:返回成功写入的长度。
/// syscall ID:64
fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize;

/// 功能:退出应用程序并将返回值告知批处理系统。
/// 参数:`xstate` 表示应用程序的返回值。
/// 返回值:该系统调用不应该返回。
/// syscall ID:93
fn sys_exit(xstate: usize) -> !;

实际调用时,我们要按照 RISC-V 调用规范,在合适的寄存器中放置参数, 然后执行 ecall 指令触发 Trap。当 Trap 结束,回到 U 模式后, 用户程序会从 ecall 的下一条指令继续执行,同时在合适的寄存器中读取返回值。

注解

RISC-V 寄存器编号从 0~31 ,表示为 x0~x31 。 其中: - x10~x17 : 对应 a0~a7 - x1 :对应 ra

约定寄存器 a0~a6 保存系统调用的参数, a0 保存系统调用的返回值, 寄存器 a7 用来传递 syscall ID。 这超出了 Rust 语言的表达能力,我们需要内嵌汇编来完成参数/返回值绑定和 ecall 指令的插入:

 1// user/src/syscall.rs
 2
 3fn syscall(id: usize, args: [usize; 3]) -> isize {
 4   let mut ret: isize;
 5   unsafe {
 6       core::arch::asm!(
 7           "ecall",
 8           inlateout("x10") args[0] => ret,
 9           in("x11") args[1],
10           in("x12") args[2],
11           in("x17") id
12       );
13   }
14   ret
15}

第 3 行,我们将所有的系统调用都封装成 syscall 函数,可以看到它支持传入 syscall ID 和 3 个参数。

第 6 行开始,我们使用 Rust 提供的 asm! 宏在代码中内嵌汇编。 Rust 编译器无法判定汇编代码的安全性,所以我们需要将其包裹在 unsafe 块中。

简而言之,这条汇编代码的执行结果是以寄存器 a0~a2 来保存系统调用的参数,以及寄存器 a7 保存 syscall ID, 返回值通过寄存器 a0 传递给局部变量 ret

这段汇编代码与第一章中出现过的内嵌汇编很像,读者可以查看 os/src/sbi.rs

注解

可以查看 Inline assembly 了解 asm 宏。

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

 1// user/src/syscall.rs
 2
 3const SYSCALL_WRITE: usize = 64;
 4const SYSCALL_EXIT: usize = 93;
 5
 6pub fn sys_write(fd: usize, buffer: &[u8]) -> isize {
 7    syscall(SYSCALL_WRITE, [fd, buffer.as_ptr() as usize, buffer.len()])
 8}
 9
10pub fn sys_exit(xstate: i32) -> isize {
11    syscall(SYSCALL_EXIT, [xstate as usize, 0, 0])
12}

我们将上述两个系统调用在用户库 user_lib 中进一步封装,像标准库一样:

1// user/src/lib.rs
2use syscall::*;
3
4pub fn write(fd: usize, buf: &[u8]) -> isize { sys_write(fd, buf) }
5pub fn exit(exit_code: i32) -> isize { sys_exit(exit_code) }

console 子模块中,借助 write,我们为应用程序实现了 println! 宏。 传入到 writefd 参数设置为 1,代表标准输出 STDOUT,暂时不用考虑其他的 fd 选取情况。

编译生成应用程序二进制码

简要介绍一下应用程序的构建,在 user 目录下 make build

  1. 对于 src/bin 下的每个应用程序, 在 target/riscv64gc-unknown-none-elf/release 目录下生成一个同名的 ELF 可执行文件;

  2. 使用 objcopy 二进制工具删除所有 ELF header 和符号,得到 .bin 后缀的纯二进制镜像文件。 它们将被链接进内核,并由内核在合适的时机加载到内存。