2application.rst.txt 12.3 KB
Newer Older
Y
Yifan Wu 已提交
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 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144
实现应用程序
===========================

.. toctree::
   :hidden:
   :maxdepth: 5

本节我们来实现被批处理系统逐个加载并运行的应用程序,它们是在认为自己会在 U 模式运行的前提下而设计、编写的,但实际上它们完全可能在其他特权级
运行。事实上,保证应用程序的代码在 U 模式运行是我们接下来将实现的批处理系统的任务。

应用程序的实现放在项目根目录的 ``user`` 目录下,它和第一章的嵌入式应用不同之处在于以下几点。

项目结构
------------------

我们看到 ``user/src`` 目录下面多出了一个 ``bin`` 目录。``bin`` 里面有多个文件,每个文件都是一个用户程序,目前里面有三个程序,分别是:

- ``00hello_world``:在屏幕上打印一行 ``Hello, world!``;
- ``01store_fault``:访问一个非法的物理地址,测试批处理系统是否会被该错误影响;
- ``02power``:一个略微复杂的、行为不断在计算和打印字符串间切换的程序。

批处理系统会按照文件名开头的编号从小到大的顺序加载并运行它们。

打开其中任意一个文件,会看到里面只有一个 ``main`` 函数,因此这很像是我们日常利用高级语言编程,只需要在单个文件中给出主逻辑的实现即可。

我们还能够看到代码中尝试引入了外部库:

.. code-block:: rust

    #[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();
        syscall::sys_exit(main());
        panic!("unreachable after sys_exit!");
    }

第 2 行使用 Rust 的宏将 ``_start`` 这段代码编译后的汇编代码中放在一个名为 ``.text.entry`` 的代码段中,方便我们在后续链接的时候
调整它的位置使得它能够作为用户库的入口。

而从第 4 行开始我们能够看到进入用户库入口之后,首先和第一章一样手动清空需要被零初始化 ``.bss`` 段(很遗憾到目前为止底层的批处理系统还
没有这个能力,所以我们只能在用户库中完成),然后是调用 ``main`` 函数得到一个类型为 ``i32`` 的返回值,最后是使用接下来会提到的系统调用
退出应用程序并将这个返回值告知批处理系统。

我们还在 ``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)]

内存布局
-------------------

在 ``user/.cargo/config`` 中,我们和第一章一样设置链接时使用链接脚本 ``user/src/linker.ld`` 。在其中我们做的重要的事情是:

- 将程序的起始物理地址调整为 ``0x80040000`` ,三个应用程序都会被加载到这个物理地址上运行;
- 将 ``_start`` 所在的 ``.text.entry`` 放在整个程序的开头,也就是说批处理系统只要在加载之后跳转到 ``0x80040000`` 就已经进入了
  用户库的入口点,并会在初始化之后跳转到应用程序主逻辑;
- 提供了最终生成可执行文件的 ``.bss`` 段的起始和终止地址,方便 ``clear_bss`` 函数使用。

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

系统调用
---------------------

在子模块 ``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:

    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
    }

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

第 4 行开始,我们使用 Rust 提供的 ``llvm_asm!`` 宏在代码中内嵌汇编,在本行也给出了具体要插入的汇编指令,也就是 ``ecall``,但这并不是
全部,后面我们还需要进行一些相关设置。这个宏在 Rust 中还不稳定,因此我们需要在 ``lib.rs`` 开头加入 ``#![feature(llvm_asm)]`` 。
Y
Yifan Wu 已提交
145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174
此外,编译器无法判定插入汇编代码这个行为的安全性,所以我们需要将其包裹在 unsafe 块中自己来对它负责。

Rust 中的 ``llvm_asm!`` 宏的完整格式如下:

.. code-block:: rust

   llvm_asm!(assembly template
      : output operands
      : input operands
      : clobbers
      : options
   );

下面逐行进行说明。

第 5 行指定输出操作数。这里由于我们的系统调用返回值只有一个 ``isize`` ,根据调用规范它会被保存在 ``a0`` 寄存器中。在双引号内,我们
可以对于使用的操作数进行限制,由于是输出部分,限制的开头必须是一个 ``=`` 。我们可以在限制内使用一对花括号再加上一个寄存器的名字告诉
编译器汇编的输出结果会保存在这个寄存器中。我们将声明出来用来保存系统调用返回值的变量 ``ret`` 包在一对普通括号里面放在操作数限制的
后面,这样可以把变量和寄存器建立联系。于是,在系统调用返回之后我们就能在变量 ``ret`` 中看到返回值了。注意,变量 ``ret`` 必须为可变
绑定,否则无法通过编译,这也说明在 unsafe 块内编译器还是会进行力所能及的安全检查。

第 6 行指定输入操作数。由于是输入部分,限制的开头不用加上 ``=`` 。同时在限制中设置使用寄存器 ``a0~a2`` 来保存系统调用的参数,以及
寄存器 ``a7`` 保存 syscall ID ,而它们分别 ``syscall`` 的参数变量 ``args`` 和 ``id`` 绑定。

第 7 行用于告知编译器插入的汇编代码会造成的一些影响以防止编译器在不知情的情况下误优化。常用的使用方法是告知编译器某个寄存器在执行嵌入
的汇编代码中的过程中会发生变化。我们这里则是告诉编译器在执行嵌入汇编代码中的时候会修改内存。这能给编译器提供更多信息。

第 8 行用于告知编译器将我们在程序中给出的嵌入汇编代码保持原样放到最终构建的可执行文件中。如果不这样做的话,编译器可能会把它和其他代码
一视同仁并放在一起进行一些我们期望之外的优化。为了保证语义的正确性,一些比较关键的汇编代码需要加上该选项。

Y
Yifan Wu 已提交
175 176 177
第一章中的输出到屏幕的操作也同样是使用内联汇编调用 SEE 提供的 SBI 接口来实现的。有兴趣的读者可以回顾第一章的 ``console.rs`` 和 
``sbi.rs`` 。

Y
Yifan Wu 已提交
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218
.. 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:

    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`` 方法取出它们并独立的作为实际的系统调用参数。

我们把 ``console`` 子模块中 ``Stdout::write_str`` 改成基于 ``sys_write`` 的实现,且传入的 ``fd`` 参数设置为 1,它代表标准输出,
也就是输出到屏幕。目前我们不需要考虑其他的 ``fd`` 选取情况。这样,应用程序的 ``println!`` 宏借助系统调用变得可用了。

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

自动构建
-----------------------

这里简要介绍一下应用程序的自动构建。只需要在 ``user`` 目录下 ``make build`` 即可:

1. 对于 ``src/bin`` 下的每个应用程序,在 ``target/riscv64gc-unknown-none-elf/release`` 目录下生成一个同名的 ELF 可执行文件;
2. 使用 objcopy 二进制工具将上一步中生成的 ELF 文件删除所有 ELF header 和符号得到 ``.bin`` 后缀的纯二进制镜像文件。它们将被链接
   进内核并由内核在合适的时机加载到内存。