ch2 nearly completed.
This commit is contained in:
parent
f025b59c54
commit
b2e9e9a200
|
@ -18,7 +18,7 @@
|
|||
user文件夹以及测例简介
|
||||
---------------------------------------
|
||||
|
||||
大家马上就会发现我们目前的代码之中并没有这样一个文件夹。实际上user文件夹是我们用于存放测例的文件夹。大家可以通过clone如下仓库的得到:
|
||||
应用程序的实现放在项目根目录的 ``user`` 目录下,大家马上就会发现我们目前的代码之中并没有这样一个文件夹。实际上user文件夹是我们用于存放测例的文件夹。大家可以通过clone如下仓库的得到:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
|
@ -48,359 +48,51 @@ user的库是如何调用到os的系统调用的呢?在user/lib/arch/riscv下
|
|||
|
||||
那么现在我们还面临一个理解上的问题:就是测例文件在调用ecall的时候的细节:程序是如何完成特权级切换的?在ecall完毕回到U态的时候,程序又是如何恢复调用ecall之前的执行流并继续执行的呢?这里其实和汇编课程对于异常的处理是一样的,下面我们来复习一下。
|
||||
|
||||
应用程序设计
|
||||
应用程序的ecall处理流程
|
||||
-----------------------------
|
||||
|
||||
应用程序的实现放在项目根目录的 ``user`` 目录下,它和第一章的裸机应用不同之处在于以下几点。
|
||||
ecall作为异常的一种,操作系统和CPU对它的处理方式其实和其他各种异常没什么区别。U态进行ecall调用具体的异常编号是8-Environment call from U-mode.RISCV处理异常需要引入几个特殊的寄存器——CSR寄存器。这些寄存器会记录异常和中断处理流程所需要或保存的各种信息。在上一章中我们看见的mideleg,medeleg寄存器就是CSR寄存器。
|
||||
|
||||
项目结构
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
几个比较关键的CSR寄存器如下:
|
||||
- scause: 它用于记录异常和中断的原因。它的最高位为1是中断,否则是异常。其低位决定具体的种类。
|
||||
- sepc:处理完毕中断异常之后需要返回的PC值。
|
||||
- stval: 产生异常的指令的地址。
|
||||
- stvec:处理异常的函数的起始地址。
|
||||
- sstatus:记录一些比较重要的状态,比如是否允许中断异常嵌套。
|
||||
|
||||
我们看到 ``user/src`` 目录下面多出了一个 ``bin`` 目录。``bin`` 里面有多个文件,每个文件都是一个用户程序,目前里面有三个程序,分别是:
|
||||
需要注意的是这些寄存器是S态的CSR寄存器。M态还有一套自己的CSR寄存器mcause,mtvec...
|
||||
|
||||
- ``00hello_world``:在屏幕上打印一行 ``Hello, world!``;
|
||||
- ``01store_fault``:访问一个非法的物理地址,测试批处理系统是否会被该错误影响;
|
||||
- ``02power``:一个略微复杂的、行为不断在计算和打印字符串间切换的程序。
|
||||
所以当U态执行ecall指令的时候就产生了异常。此时CPU会处理各个CSR寄存器,之后跳转至stvec所指向的地址,也就是我们的异常处理函数。我们的os的这个函数的具体位置是在trap_init函数之中就指定了——是uservec函数。这个函数位于trampoline.S之中,是由汇编语言编写的。在uservec之中,os保存了U态执行流的各个寄存器的值。这些值的位置其实已经由trap.h中的trapframe结构体规定好了:
|
||||
|
||||
批处理系统会按照文件名开头的编号从小到大的顺序加载并运行它们。
|
||||
.. code-block:: c
|
||||
|
||||
打开其中任意一个文件,会看到里面只有一个 ``main`` 函数,因此这很像是我们日常利用高级语言编程,只需要在单个文件中给出主逻辑的实现即可。
|
||||
// os/trap.h
|
||||
struct trapframe {
|
||||
/* 0 */ uint64 kernel_satp; // kernel page table
|
||||
/* 8 */ uint64 kernel_sp; // top of process's kernel stack
|
||||
/* 16 */ uint64 kernel_trap; // usertrap entry
|
||||
/* 24 */ uint64 epc; // saved user program counter
|
||||
/* 32 */ uint64 kernel_hartid; // saved kernel tp, unused in our project
|
||||
/* 40 */ uint64 ra;
|
||||
/* 48 */ uint64 sp;
|
||||
/* ... */ ....
|
||||
/* 272 */ uint64 t5;
|
||||
/* 280 */ uint64 t6;
|
||||
};
|
||||
|
||||
我们还能够看到代码中尝试引入了外部库:
|
||||
然后就跳转到了我们早先设定在 trapframe->kernel_trap 中的地址,也就是 trap.c 之中的 usertrap 函数。这个函数在main的初始化之中已经调用了。
|
||||
|
||||
.. code-block:: rust
|
||||
.. code-block:: c
|
||||
|
||||
#[macro_use]
|
||||
extern crate user_lib;
|
||||
|
||||
这个外部库其实就是 ``user`` 目录下的 ``lib.rs`` 以及它引用的若干子模块中。至于这个外部库为何叫 ``user_lib`` 而不叫 ``lib.rs``
|
||||
所在的目录的名字 ``user`` ,是因为在 ``user/Cargo.toml`` 中我们对于库的名字进行了设置: ``name = "user_lib"`` 。它作为
|
||||
``bin`` 目录下的源程序所依赖的用户库,等价于其他编程语言提供的标准库。
|
||||
|
||||
在 ``lib.rs`` 中我们定义了用户库的入口点 ``_start`` :
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
#[no_mangle]
|
||||
#[link_section = ".text.entry"]
|
||||
pub extern "C" fn _start() -> ! {
|
||||
clear_bss();
|
||||
exit(main());
|
||||
panic!("unreachable after sys_exit!");
|
||||
// os/trap.c
|
||||
// set up to take exceptions and traps while in the kernel.
|
||||
void trapinit(void)
|
||||
{
|
||||
w_stvec((uint64)uservec & ~0x3); // 写 stvec, 最后两位表明跳转模式,该实验始终为 0
|
||||
}
|
||||
|
||||
第 2 行使用 Rust 的宏将 ``_start`` 这段代码编译后的汇编代码中放在一个名为 ``.text.entry`` 的代码段中,方便我们在后续链接的时候
|
||||
调整它的位置使得它能够作为用户库的入口。
|
||||
该函数完成异常中断处理与返回,包括执行我们写好的syscall。
|
||||
|
||||
而从第 4 行开始我们能够看到进入用户库入口之后,首先和第一章一样手动清空需要被零初始化 ``.bss`` 段(很遗憾到目前为止底层的批处理系统还
|
||||
没有这个能力,所以我们只能在用户库中完成),然后是调用 ``main`` 函数得到一个类型为 ``i32`` 的返回值。
|
||||
从S态返回U态是由 usertrapret 函数实现的。这里设置了返回地址sepc,并调用另外一个 userret 汇编函数来恢复 trapframe 结构体之中的保存的U态执行流数据。最后执行sret指令,从S态回到U态,并将PC移动到sepc指定的位置。
|
||||
|
||||
第 5 行我们调用后面会提到的用户库提供的 ``exit`` 接口退出应用程序并将这个返回值告知批处理系统。
|
||||
|
||||
我们还在 ``lib.rs`` 中看到了另一个 ``main`` :
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
#[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`` 的开头加入:
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
#![feature(linkage)]
|
||||
|
||||
|
||||
.. _term-app-mem-layout:
|
||||
|
||||
内存布局
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
在 ``user/.cargo/config`` 中,我们和第一章一样设置链接时使用链接脚本 ``user/src/linker.ld`` 。在其中我们做的重要的事情是:
|
||||
|
||||
- 将程序的起始物理地址调整为 ``0x80400000`` ,三个应用程序都会被加载到这个物理地址上运行;
|
||||
- 将 ``_start`` 所在的 ``.text.entry`` 放在整个程序的开头,也就是说批处理系统只要在加载之后跳转到 ``0x80400000`` 就已经进入了
|
||||
用户库的入口点,并会在初始化之后跳转到应用程序主逻辑;
|
||||
- 提供了最终生成可执行文件的 ``.bss`` 段的起始和终止地址,方便 ``clear_bss`` 函数使用。
|
||||
|
||||
其余的部分和第一章基本相同。
|
||||
|
||||
.. _term-call-syscall:
|
||||
|
||||
系统调用
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
在子模块 ``syscall`` 中我们作为应用程序来通过 ``ecall`` 调用批处理系统提供的接口,由于应用程序运行在 U 模式, ``ecall`` 指令会触发
|
||||
名为 ``Environment call from U-mode`` 的异常,并 Trap 进入 S 模式执行批处理系统针对这个异常特别提供的服务代码。由于这个接口处于
|
||||
S 模式的批处理系统和 U 模式的应用程序之间,从上一节我们可以知道,这个接口可以被称为 ABI 或者系统调用。现在我们不关心底层的批处理系统如何
|
||||
提供应用程序所需的功能,只是站在应用程序的角度去使用即可。
|
||||
|
||||
在本章中,应用程序和批处理系统之间约定如下两个系统调用:
|
||||
|
||||
.. code-block:: rust
|
||||
:caption: 第二章新增系统调用
|
||||
|
||||
/// 功能:将内存中缓冲区中的数据写入文件。
|
||||
/// 参数:`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) -> !;
|
||||
|
||||
我们知道系统调用实际上是汇编指令级的二进制接口,因此这里给出的只是使用 Rust 语言描述的版本。在实际调用的时候,我们需要按照 RISC-V 调用
|
||||
规范在合适的寄存器中放置系统调用的参数,然后执行 ``ecall`` 指令触发 Trap。在 Trap 回到 U 模式的应用程序代码之后,会从 ``ecall`` 的
|
||||
下一条指令继续执行,同时我们能够按照调用规范在合适的寄存器中读取返回值。
|
||||
|
||||
在 RISC-V 调用规范中,和函数调用的情形类似,约定寄存器 ``a0~a6`` 保存系统调用的参数, ``a0~a1`` 保存系统调用的返回值。有些许不同的是
|
||||
寄存器 ``a7`` 用来传递 syscall ID,这是因为所有的 syscall 都是通过 ``ecall`` 指令触发的,除了各输入参数之外我们还额外需要一个寄存器
|
||||
来保存要请求哪个系统调用。由于这超出了 Rust 语言的表达能力,我们需要在代码中使用内嵌汇编来完成参数/返回值绑定和 ``ecall`` 指令的插入:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// user/src/syscall.rs
|
||||
|
||||
fn syscall(id: usize, args: [usize; 3]) -> isize {
|
||||
let mut ret: isize;
|
||||
unsafe {
|
||||
llvm_asm!("ecall"
|
||||
: "={x10}" (ret)
|
||||
: "{x10}" (args[0]), "{x11}" (args[1]), "{x12}" (args[2]), "{x17}" (id)
|
||||
: "memory"
|
||||
: "volatile"
|
||||
);
|
||||
}
|
||||
ret
|
||||
}
|
||||
|
||||
第 3 行,我们将所有的系统调用都封装成 ``syscall`` 函数,可以看到它支持传入 syscall ID 和 3 个参数。
|
||||
|
||||
第 6 行开始,我们使用 Rust 提供的 ``llvm_asm!`` 宏在代码中内嵌汇编,在本行也给出了具体要插入的汇编指令,也就是 ``ecall``,但这并不是
|
||||
全部,后面我们还需要进行一些相关设置。这个宏在 Rust 中还不稳定,因此我们需要在 ``lib.rs`` 开头加入 ``#![feature(llvm_asm)]`` 。
|
||||
此外,编译器无法判定插入汇编代码这个行为的安全性,所以我们需要将其包裹在 unsafe 块中自己来对它负责。
|
||||
|
||||
Rust 中的 ``llvm_asm!`` 宏的完整格式如下:
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
llvm_asm!(assembly template
|
||||
: output operands
|
||||
: input operands
|
||||
: clobbers
|
||||
: options
|
||||
);
|
||||
|
||||
下面逐行进行说明。
|
||||
|
||||
第 7 行指定输出操作数。这里由于我们的系统调用返回值只有一个 ``isize`` ,根据调用规范它会被保存在 ``a0`` 寄存器中。在双引号内,我们
|
||||
可以对于使用的操作数进行限制,由于是输出部分,限制的开头必须是一个 ``=`` 。我们可以在限制内使用一对花括号再加上一个寄存器的名字告诉
|
||||
编译器汇编的输出结果会保存在这个寄存器中。我们将声明出来用来保存系统调用返回值的变量 ``ret`` 包在一对普通括号里面放在操作数限制的
|
||||
后面,这样可以把变量和寄存器建立联系。于是,在系统调用返回之后我们就能在变量 ``ret`` 中看到返回值了。注意,变量 ``ret`` 必须为可变
|
||||
绑定,否则无法通过编译,这也说明在 unsafe 块内编译器还是会进行力所能及的安全检查。
|
||||
|
||||
第 8 行指定输入操作数。由于是输入部分,限制的开头不用加上 ``=`` 。同时在限制中设置使用寄存器 ``a0~a2`` 来保存系统调用的参数,以及
|
||||
寄存器 ``a7`` 保存 syscall ID ,而它们分别 ``syscall`` 的参数变量 ``args`` 和 ``id`` 绑定。
|
||||
|
||||
第 9 行用于告知编译器插入的汇编代码会造成的一些影响以防止编译器在不知情的情况下误优化。常用的使用方法是告知编译器某个寄存器在执行嵌入
|
||||
的汇编代码中的过程中会发生变化。我们这里则是告诉编译器:程序在执行嵌入汇编代码中指令的时候会修改内存。这能给编译器提供更多信息以生成正确的代码。
|
||||
|
||||
第 10 行用于告知编译器将我们在程序中给出的嵌入汇编代码保持原样放到最终构建的可执行文件中。如果不这样做的话,编译器可能会把它和其他代码
|
||||
一视同仁并放在一起进行一些我们期望之外的优化。为了保证语义的正确性,一些比较关键的汇编代码需要加上该选项。
|
||||
|
||||
上面这一段汇编代码的含义和内容与第一章中的 :ref:`第一章中U-Mode应用程序中的系统调用汇编代码 <term-llvm-syscall>` 的是一致的。与 :ref:`第一章中的RustSBI输出到屏幕的SBI调用汇编代码 <term-llvm-sbicall>` 涉及的汇编指令一样,但传递参数的寄存器的含义是不同的。有兴趣的读者可以回顾第一章的 ``console.rs`` 和 ``sbi.rs`` 。
|
||||
|
||||
.. note::
|
||||
|
||||
**Rust 语法卡片:内联汇编**
|
||||
|
||||
我们这里使用的 ``llvm_asm!`` 宏是将 Rust 底层 IR LLVM 中提供的内联汇编包装成的,更多信息可以参考 `llvm_asm 文档 <https://doc.rust-lang.org/unstable-book/library-features/llvm-asm.html>`_ 。
|
||||
|
||||
在未来的 Rust 版本推荐使用功能更加强大且方便易用的 ``asm!`` 宏,但是目前还未稳定,可以查看 `inline-asm RFC <https://doc.rust-lang.org/beta/unstable-book/library-features/asm.html>`_ 了解最新进展。
|
||||
|
||||
于是 ``sys_write`` 和 ``sys_exit`` 只需将 ``syscall`` 进行包装:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// 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])
|
||||
}
|
||||
|
||||
.. _term-fat-pointer:
|
||||
|
||||
注意 ``sys_write`` 使用一个 ``&[u8]`` 切片类型来描述缓冲区,这是一个 **胖指针** (Fat Pointer),里面既包含缓冲区的起始地址,还
|
||||
包含缓冲区的长度。我们可以分别通过 ``as_ptr`` 和 ``len`` 方法取出它们并独立的作为实际的系统调用参数。
|
||||
|
||||
我们将上述两个系统调用在用户库 ``user_lib`` 中进一步封装,从而更加接近在 Linux 等平台的实际体验:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// 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!`` 宏借助系统调用变得可用了。
|
||||
参考下面的代码片段:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// 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`` 即可:
|
||||
|
||||
1. 对于 ``src/bin`` 下的每个应用程序,在 ``target/riscv64gc-unknown-none-elf/release`` 目录下生成一个同名的 ELF 可执行文件;
|
||||
2. 使用 objcopy 二进制工具将上一步中生成的 ELF 文件删除所有 ELF header 和符号得到 ``.bin`` 后缀的纯二进制镜像文件。它们将被链接
|
||||
进内核并由内核在合适的时机加载到内存。
|
||||
|
||||
实现操作系统前执行应用程序
|
||||
-----------------------------------
|
||||
|
||||
我们还没有实现操作系统,能提前执行或测试应用程序吗?可以! 这是因为我们除了一个能模拟一台RISC-V 64 计算机的全系统模拟器 ``qemu-system-riscv64`` 外,还有一个 :ref:`直接支持运行RISC-V64 用户程序的半系统模拟器qemu-riscv64 <term-qemu-riscv64>` 。
|
||||
|
||||
.. note::
|
||||
|
||||
如果想让用户态应用程序在Linux和在我们自己写的OS上执行效果一样,需要做到二者的系统调用的接口是一样的(包括系统调用编号,参数约定的具体的寄存器和栈等)。
|
||||
|
||||
|
||||
.. _term-csr-instr-app:
|
||||
|
||||
假定我们已经完成了编译并生成了ELF 可执行文件格式的应用程序,我们就可以来试试。首先看看应用程序执行 :ref:`RV64的S模式特权指令 <term-csr-instr>` 会出现什么情况。
|
||||
|
||||
.. note::
|
||||
|
||||
下载编译特权指令的应用需要获取
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ git clone -b v4-illegal-priv-code-csr-in-u-mode-app https://github.com/chyyuu/os_kernel_lab.git
|
||||
$ cd os_kernel_lab/user
|
||||
$ make build
|
||||
|
||||
我们先看看代码:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// usr/src/bin/03priv_intr.rs
|
||||
...
|
||||
println!("Hello, world!");
|
||||
unsafe {
|
||||
llvm_asm!("sret"
|
||||
: : : :
|
||||
);
|
||||
}
|
||||
...
|
||||
|
||||
在上述代码中,在显示 ``Hello, world`` 字符串后,会执行 ``sret`` 特权指令。
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// usr/src/bin/04priv_intr.rs
|
||||
...
|
||||
println!("Hello, world!");
|
||||
let mut sstatus = sstatus::read();
|
||||
sstatus.set_spp(SPP::User);
|
||||
...
|
||||
|
||||
在上述代码中,在显示 ``Hello, world`` 字符串后,会读写 ``sstatus`` 特权CSR。
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ cd user
|
||||
$ cd target/riscv64gc-unknown-none-elf/release/
|
||||
$ ls
|
||||
00hello_world 01store_fault 02power
|
||||
03priv_intr 04priv_csr
|
||||
...
|
||||
# 上面的文件就是ELF格式的应用程序
|
||||
$ qemu-riscv64 ./03priv_intr
|
||||
Hello, world!
|
||||
非法指令 (核心已转储)
|
||||
# 执行特权指令出错
|
||||
$ qemu-riscv64 ./04priv_csr
|
||||
Hello, world!
|
||||
非法指令 (核心已转储)
|
||||
# 执行访问特权级CSR的指令出错
|
||||
|
||||
看来RV64的特权级机制确实有用。那对于一般的应用程序,在 ``qemu-riscv64`` 模拟器下能正确执行吗?
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ cd user
|
||||
$ cd target/riscv64gc-unknown-none-elf/release/
|
||||
$ ls
|
||||
00hello_world 01store_fault 02power
|
||||
03priv_intr 04priv_csr
|
||||
...
|
||||
# 上面的文件就是ELF格式的应用程序
|
||||
$ qemu-riscv64 ./00hello_world
|
||||
Hello, world!
|
||||
# 正确显示了字符串
|
||||
$ qemu-riscv64 01store_fault
|
||||
qemu-riscv64 01store_fault
|
||||
Into Test store_fault, we will insert an invalid store operation...
|
||||
Kernel should kill this application!
|
||||
段错误 (核心已转储)
|
||||
# 故意访问了一个非法地址,导致应用和qemu-riscv64被Linux内核杀死
|
||||
$ qemu-riscv64 02power
|
||||
3^10000=5079
|
||||
3^20000=8202
|
||||
3^30000=8824
|
||||
3^40000=5750
|
||||
3^50000=3824
|
||||
3^60000=8516
|
||||
3^70000=2510
|
||||
3^80000=9379
|
||||
3^90000=2621
|
||||
3^100000=2749
|
||||
Test power OK!
|
||||
# 正确地完成了计算
|
||||
|
||||
三个应用都能够执行并顺利结束!是由于得到了本机操作系统Linux的支持。我们期望我们在下一节开始实现的泥盆纪“邓式鱼”操作系统也能够正确上面的应用程序。
|
||||
这个过程中还有许多细节,大家将在课后习题中慢慢品味。
|
|
@ -1,7 +1,7 @@
|
|||
|
||||
.. _term-batchos:
|
||||
|
||||
实现批处理操作系统
|
||||
实现批处理操作系统的细节
|
||||
==============================
|
||||
|
||||
.. toctree::
|
||||
|
@ -11,212 +11,122 @@
|
|||
本节导读
|
||||
-------------------------------
|
||||
|
||||
目前本章设计的批处理操作系统--泥盆纪“邓式鱼”操作系统,还没有文件/文件系统的机制与设计实现,所以还缺少一种类似文件系统那样的松耦合灵活放置应用程序和加载执行应用程序的机制。这就需要设计一种简洁的程序放置和加载方式,能够在批处理操作系统与应用程序之间建立联系的纽带。这主要包括两个方面:
|
||||
前面一节中我们明白了os是如何执行应用程序的。但是os是如何”找到“这些应用程序并允许它们的呢?在引言之中我们简要介绍了这是由link_app.S以及kernel_app.ld完成的。实际上,能够在批处理操作系统与应用程序之间建立联系的纽带。这主要包括两个方面:
|
||||
|
||||
- 静态编码:通过一定的编程技巧,把应用程序代码和批处理操作系统代码“绑定”在一起。
|
||||
- 动态加载:基于静态编码留下的“绑定”信息,操作系统可以找到应用程序文件二进制代码的起始地址和长度,并能加载到内存中运行。
|
||||
|
||||
这里与硬件相关且比较困难的地方是如何让在内核态的批处理操作系统启动应用程序,且能让应用程序在用户态正常执行。本节会讲大致过程,而具体细节将放到下一节具体讲解。
|
||||
这里与硬件相关且比较困难的地方是如何让在内核态的批处理操作系统启动应用程序,且能让应用程序在用户态正常执行。
|
||||
|
||||
将应用程序链接到内核
|
||||
--------------------------------------------
|
||||
|
||||
在本章中,我们把应用程序的二进制镜像文件作为内核的数据段链接到内核里面,因此内核需要知道内含的应用程序的数量和它们的位置,这样才能够在运行时
|
||||
对它们进行管理并能够加载到物理内存。
|
||||
makefile更新
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
在 ``os/src/main.rs`` 中能够找到这样一行:
|
||||
我们首先看一看本章的makefile改变了什么::
|
||||
link_app.o: link_app.S
|
||||
link_app.S: pack.py
|
||||
@$(PY) pack.py
|
||||
kernel_app.ld: kernelld.py
|
||||
@$(PY) kernelld.py
|
||||
|
||||
.. code-block:: rust
|
||||
kernel: $(OBJS) kernel_app.ld link_app.S
|
||||
$(LD) $(LDFLAGS) -T kernel_app.ld -o kernel $(OBJS)
|
||||
$(OBJDUMP) -S kernel > kernel.asm
|
||||
$(OBJDUMP) -t kernel | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > kernel.sym
|
||||
|
||||
global_asm!(include_str!("link_app.S"));
|
||||
可以看到makefile执行了两个python脚本生成了我们提到的link_app.S和kernel_app.ld。这里选择python只是因为比较好写生成的代码,我们的os和python没有任何关系。link_app.S的大致内容如下::
|
||||
|
||||
这里我们引入了一段汇编代码 ``link_app.S`` ,它一开始并不存在,而是在构建的时候自动生成的。当我们使用 ``make run`` 让系统成功运行起来
|
||||
之后,我们可以先来看一看里面的内容:
|
||||
|
||||
.. code-block:: asm
|
||||
:linenos:
|
||||
|
||||
# os/src/link_app.S
|
||||
|
||||
.align 3
|
||||
.align 4
|
||||
.section .data
|
||||
.global _num_app
|
||||
_num_app:
|
||||
.quad 3
|
||||
.global _app_num
|
||||
_app_num:
|
||||
.quad 2
|
||||
.quad app_0_start
|
||||
.quad app_1_start
|
||||
.quad app_2_start
|
||||
.quad app_2_end
|
||||
|
||||
.section .data
|
||||
.quad app_1_end
|
||||
|
||||
.global _app_names
|
||||
_app_names:
|
||||
.string "hello.bin"
|
||||
.string "matrix.bin"
|
||||
|
||||
.section .data.app0
|
||||
.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
|
||||
.incbin "../user/target/hello.bin" (文件名是不是要改一下)
|
||||
|
||||
.section .data.app1
|
||||
.global app_1_start
|
||||
.global app_1_end
|
||||
app_1_start:
|
||||
.incbin "../user/target/riscv64gc-unknown-none-elf/release/01store_fault.bin"
|
||||
.incbin "../user/target/matrix.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:
|
||||
|
||||
可以看到第 13 行开始的三个数据段分别插入了三个应用程序的二进制镜像,并且各自有一对全局符号 ``app_*_start, app_*_end`` 指示它们的
|
||||
开始和结束位置。而第 3 行开始的另一个数据段相当于一个 64 位整数数组。数组中的第一个元素表示应用程序的数量,后面则按照顺序放置每个应用
|
||||
程序的起始地址,最后一个元素放置最后一个应用程序的结束位置。这样每个应用程序的位置都能从该数组中相邻两个元素中得知。这个数组所在的位置
|
||||
同样也由全局符号 ``_num_app`` 所指示。
|
||||
pack.py会遍历../user/target/,并将该目录下的目标用户程序*.bin包含入 link_app.S中,同时给每一个bin文件记录其地址和名称信息。最后,我们在 Makefile 中会将内核与 link_app.S 一同编译并链接。这样,我们在内核中就可以通过 extern 指令访问到用户程序的所有信息,如其文件名等。
|
||||
|
||||
这个文件是在 ``cargo build`` 的时候,由脚本 ``os/build.rs`` 控制生成的。有兴趣的读者可以参考其代码。
|
||||
由于 riscv 要求程序指令必须是对齐的,我们对内核链接脚本也作出修改,保证用户程序链接时的指令对齐,这些内容见 kernel/kernelld.py。这个脚本也会遍历../user/target/,并对每一个bin文件分配对齐的空间。最终修改后的kernel_app.ld脚本中多了如下对齐要求::
|
||||
|
||||
找到并加载应用程序二进制码
|
||||
-----------------------------------------------
|
||||
.data : {
|
||||
*(.data)
|
||||
. = ALIGN(0x1000);
|
||||
*(.data.app0)
|
||||
. = ALIGN(0x1000);
|
||||
*(.data.app1)
|
||||
. = ALIGN(0x1000);
|
||||
*(.data.app2)
|
||||
. = ALIGN(0x1000);
|
||||
*(.data.app3)
|
||||
. = ALIGN(0x1000);
|
||||
*(.data.app4)
|
||||
|
||||
能够找到并加载应用程序二进制码的应用管理器 ``AppManager`` 是“邓式鱼”操作系统的核心组件。我们在 ``os`` 的 ``batch`` 子模块中实现一个应用管理器,它的主要功能是:
|
||||
|
||||
- 保存应用数量和各自的位置信息,以及当前执行到第几个应用了。
|
||||
- 根据应用程序位置信息,初始化好应用所需内存空间,并加载应用执行。
|
||||
|
||||
应用管理器 ``AppManager`` 结构体定义
|
||||
如下:
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
struct AppManager {
|
||||
inner: RefCell<AppManagerInner>,
|
||||
}
|
||||
struct AppManagerInner {
|
||||
num_app: usize,
|
||||
current_app: usize,
|
||||
app_start: [usize; MAX_APP_NUM + 1],
|
||||
}
|
||||
unsafe impl Sync for AppManager {}
|
||||
|
||||
这里我们可以看出,上面提到的应用管理器需要保存和维护的信息都在 ``AppManagerInner`` 里面,而结构体 ``AppManager`` 里面只是保存了
|
||||
一个指向 ``AppManagerInner`` 的 ``RefCell`` 智能指针。这样设计的原因在于:我们希望将 ``AppManager`` 实例化为一个全局变量使得
|
||||
任何函数都可以直接访问,但是里面的 ``current_app`` 字段表示当前执行到了第几个应用,它会在系统运行期间发生变化。因此在声明全局变量
|
||||
的时候一种自然的方法是利用 ``static mut``。但是在 Rust 中,任何对于 ``static mut`` 变量的访问都是 unsafe 的,而我们要尽可能
|
||||
减少 unsafe 的使用来更多的让编译器负责安全性检查。
|
||||
|
||||
此外,为了让 ``AppManager`` 能被直接全局实例化,我们需要将其标记为 ``Sync`` 。
|
||||
|
||||
.. note::
|
||||
|
||||
**为什么对于 static mut 的访问是 unsafe 的**
|
||||
|
||||
**为什么要将 AppManager 标记为 Sync**
|
||||
|
||||
可以参考附录A:Rust 快速入门的并发章节。
|
||||
|
||||
.. _term-interior-mutability:
|
||||
|
||||
于是,我们利用 ``RefCell`` 来提供 **内部可变性** (Interior Mutability),
|
||||
所谓的内部可变性就是指在我们只能拿到 ``AppManager`` 的不可变借用,意味着同样也只能
|
||||
拿到 ``AppManagerInner`` 的不可变借用的情况下依然可以修改 ``AppManagerInner`` 里面的字段。
|
||||
使用 ``RefCell::borrow/RefCell::borrow_mut`` 分别可以拿到 ``RefCell`` 里面内容的不可变借用/可变借用,
|
||||
``RefCell`` 会在运行时维护当前它管理的对象的已有借用状态,并在访问对象时进行借用检查。于是 ``RefCell::borrow_mut`` 就是我们实现内部可变性的关键。
|
||||
|
||||
我们这样初始化 ``AppManager`` 的全局实例:
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
lazy_static! {
|
||||
static ref APP_MANAGER: AppManager = AppManager {
|
||||
inner: RefCell::new({
|
||||
extern "C" { fn _num_app(); }
|
||||
let num_app_ptr = _num_app as usize as *const usize;
|
||||
let num_app = unsafe { num_app_ptr.read_volatile() };
|
||||
let mut app_start: [usize; MAX_APP_NUM + 1] = [0; MAX_APP_NUM + 1];
|
||||
let app_start_raw: &[usize] = unsafe {
|
||||
core::slice::from_raw_parts(num_app_ptr.add(1), num_app + 1)
|
||||
};
|
||||
app_start[..=num_app].copy_from_slice(app_start_raw);
|
||||
AppManagerInner {
|
||||
num_app,
|
||||
current_app: 0,
|
||||
app_start,
|
||||
}
|
||||
}),
|
||||
};
|
||||
*(.data.*)
|
||||
}
|
||||
|
||||
初始化的逻辑很简单,就是找到 ``link_app.S`` 中提供的符号 ``_num_app`` ,并从这里开始解析出应用数量以及各个应用的开头地址。注意其中对于切片类型的使用能够很大程度上简化编程。
|
||||
编译出的kernel已经包含了bin文件的信息。熟悉汇编的同学可以去看看生成的kernel.asm(kernel整体的汇编代码)来加深理解。
|
||||
|
||||
这里我们使用了外部库 ``lazy_static`` 提供的 ``lazy_static!`` 宏。要引入这个外部库,我们需要加入依赖:
|
||||
内核的relocation
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. code-block:: toml
|
||||
内核中通过访问 link_app.S 中定义的 _app_num、app_0_start 等符号来获得用户程序位置.
|
||||
|
||||
# os/Cargo.toml
|
||||
.. code-block:: c
|
||||
|
||||
[dependencies]
|
||||
lazy_static = { version = "1.4.0", features = ["spin_no_std"] }
|
||||
// os/batch.c
|
||||
extern char _app_num[]; // 在link_app.S之中已经定义
|
||||
void batchinit() {
|
||||
app_info_ptr = (uint64*) _app_num;
|
||||
app_num = *app_info_ptr;
|
||||
app_info_ptr++;
|
||||
// from now on:
|
||||
// app_n_start = app_info_ptr[n]
|
||||
// app_n_end = app_info_ptr[n+1]
|
||||
}
|
||||
|
||||
``lazy_static!`` 宏提供了全局变量的运行时初始化功能。一般情况下,全局变量必须在编译期设置一个初始值,但是有些全局变量依赖于运行期间
|
||||
才能得到的数据作为初始值。这导致这些全局变量需要在运行时发生变化,也即重新设置初始值之后才能使用。如果我们手动实现的话有诸多不便之处,
|
||||
比如需要把这种全局变量声明为 ``static mut`` 并衍生出很多 unsafe code。这种情况下我们可以使用 ``lazy_static!`` 宏来帮助我们解决
|
||||
这个问题。这里我们借助 ``lazy_static!`` 声明了一个 ``AppManager`` 结构的名为 ``APP_MANAGER`` 的全局实例,且只有在它第一次被使用到
|
||||
的时候才会进行实际的初始化工作。
|
||||
然而我们并不能直接跳转到 app_n_start 直接运行,因为用户程序在编译的时候,会假定程序处在虚存的特定位置,而由于我们还没有虚存机制,因此我们在运行之前还需要将用户程序加载到规定的物理内存位置。为此我们规定了用户的链接脚本,并在内核完成程序的 "搬运"::
|
||||
|
||||
因此,借助 Rust 核心库提供的 ``RefCell`` 和外部库 ``lazy_static!``,我们就能在避免 ``static mut`` 声明的情况下以更加优雅的Rust风格使用全局变量。
|
||||
# user/lib/arch/riscv/user.ld
|
||||
SECTIONS {
|
||||
. = 0x80400000; # 规定了内存加载位置
|
||||
|
||||
``AppManagerInner`` 的方法中, ``print_app_info/get_current_app/move_to_next_app`` 都相当简单直接,需要说明的是 ``load_app``:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
unsafe fn load_app(&self, app_id: usize) {
|
||||
if app_id >= self.num_app {
|
||||
panic!("All applications completed!");
|
||||
.startup : {
|
||||
*crt.S.o(.text) # 确保程序入口在程序开头
|
||||
}
|
||||
println!("[kernel] Loading app_{}", app_id);
|
||||
// clear icache
|
||||
llvm_asm!("fence.i" :::: "volatile");
|
||||
// clear app area
|
||||
(APP_BASE_ADDRESS..APP_BASE_ADDRESS + APP_SIZE_LIMIT).for_each(|addr| {
|
||||
(addr as *mut u8).write_volatile(0);
|
||||
});
|
||||
let app_src = core::slice::from_raw_parts(
|
||||
self.app_start[app_id] as *const u8,
|
||||
self.app_start[app_id + 1] - self.app_start[app_id]
|
||||
);
|
||||
let app_dst = core::slice::from_raw_parts_mut(
|
||||
APP_BASE_ADDRESS as *mut u8,
|
||||
app_src.len()
|
||||
);
|
||||
app_dst.copy_from_slice(app_src);
|
||||
|
||||
.text : { *(.text) }
|
||||
.data : { *(.data .rodata) }
|
||||
|
||||
/DISCARD/ : { *(.eh_*) }
|
||||
}
|
||||
|
||||
这个方法负责将参数 ``app_id`` 对应的应用程序的二进制镜像加载到物理内存以 ``0x80400000`` 开头的位置,这个位置是批处理操作系统和应用程序
|
||||
之间约定的常数地址,回忆上一小节中,我们也调整应用程序的内存布局以同一个地址开头。第 8 行开始,我们首先将一块内存清空,然后找到待加载应用
|
||||
二进制镜像的位置,并将它复制到正确的位置。它本质上是把数据从一块内存复制到另一块内存,从批处理操作系统的角度来看是将它数据段的一部分复制到了它
|
||||
程序之外未知的地方。在这一点上也体现了冯诺依曼计算机的 ``代码即数据`` 的特征。
|
||||
这样之后,我们就可以在读取指定内存位置的bin文件来执行它们了。下面是os内核读取link_app.S的info并把它们搬运到0x80400000开始位置的具体过程。
|
||||
.. code-block:: c
|
||||
|
||||
.. _term-dcache:
|
||||
.. _term-icache:
|
||||
|
||||
注意第 7 行我们插入了一条奇怪的汇编指令 ``fence.i`` ,它是用来清理 i-cache 的。我们知道缓存是存储层级结构中提高访存速度的很重要一环。
|
||||
而 CPU 对物理内存所做的缓存又分成 **数据缓存** (d-cache) 和 **指令缓存** (i-cache) 两部分,分别在 CPU 访存和取指的时候使用。在取指
|
||||
的时候,对于一个指令地址, CPU 会先去 i-cache 里面看一下它是否在某个已缓存的缓存行内,如果在的话它就会直接从高速缓存中拿到指令而不是通过
|
||||
总线和内存通信。通常情况下, CPU 会认为程序的代码段不会发生变化,因此 i-cache 是一种只读缓存。但在这里,我们会修改会被 CPU 取指的内存
|
||||
区域,这会使得 i-cache 中含有与内存中不一致的内容。因此我们这里必须使用 ``fence.i`` 指令手动清空 i-cache ,让里面所有的内容全部失效,
|
||||
才能够保证正确性。
|
||||
|
||||
.. warning::
|
||||
|
||||
**模拟器与真机的不同之处**
|
||||
|
||||
至少在 Qemu 模拟器的默认配置下,各类缓存如 i-cache/d-cache/TLB 都处于机制不完全甚至完全不存在的状态。目前在 Qemu 平台上,即使我们
|
||||
不加上刷新 i-cache 的指令,大概率也是能够正常运行的。但在 K210 真机上就会看到错误。
|
||||
|
||||
``batch`` 子模块对外暴露出如下接口:
|
||||
|
||||
- ``init`` :调用 ``print_app_info`` 的时候第一次用到了全局变量 ``APP_MANAGER`` ,它也是在这个时候完成初始化;
|
||||
- ``run_next_app`` :批处理操作系统的核心操作,即加载并运行下一个应用程序。当批处理操作系统完成初始化或者一个应用程序运行结束或出错之后会调用
|
||||
该函数。我们下节再介绍其具体实现。
|
||||
// os/batch.c
|
||||
const uint64 BASE_ADDRESS = 0x80400000, MAX_APP_SIZE = 0x20000;
|
||||
int load_app(uint64* info) {
|
||||
uint64 start = info[0], end = info[1], length = end - start;
|
||||
memset((void*)BASE_ADDRESS, 0, MAX_APP_SIZE);
|
||||
memmove((void*)BASE_ADDRESS, (void*)start, length);
|
||||
return length;
|
||||
}
|
Loading…
Reference in New Issue