应用程序运行环境与平台支持 ================================================ .. toctree:: :hidden: :maxdepth: 3 作为一切的开始,让我们使用 Cargo 工具来创建一个 Rust 项目。它看上去没有任何特别之处: .. code-block:: console $ cargo new os --bin 我们加上了 ``--bin`` 选项来告诉 Cargo 我们创建一个可执行项目而不是库项目。此时,项目的文件结构如下: .. code-block:: console $ tree os os ├── Cargo.toml └── src └── main.rs 1 directory, 2 files 其中 ``Cargo.toml`` 中保存着项目的配置,包括作者的信息、联系方式以及库依赖等等。显而易见源代码保存在 ``src`` 目录下,目前为止只有 ``main.rs`` 一个文件,让我们看一下里面的内容: .. code-block:: rust :linenos: :caption: 最简单的 Rust 应用 fn main() { println!("Hello, world!"); } 利用 Cargo 工具即可一条命令实现构建并运行项目: .. code-block:: console $ cargo run Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os) Finished dev [unoptimized + debuginfo] target(s) in 1.15s Running `target/debug/os` Hello, world! 如我们预想的一样,我们在屏幕上看到了一行 ``Hello, world!`` 。但是,需要注意到我们所享受到的编程的方便并不是理所当然的,背后有着从硬件 到软件的多种机制的支持。 应用程序运行环境 ------------------------------- 如下图所示,应用程序的运行需要下面一套运行环境栈的支持: .. _app-software-stack: .. figure:: app-software-stack.png 应用程序运行环境栈 图中的白色块自上而下(越往下则越靠近底层,下层作为上层的执行环境支持上层代码的运行)表示各级运行环境,黑色块则表示相邻两层运行环境之间 的接口。 我们的应用位于最上层,它可以通过调用编程语言提供的标准库或者其他三方库对外提供的功能强大的函数接口,使得仅需少量的源代码就能完成复杂的 功能。但是这些库的功能不仅限于此,事实上它们属于应用程序的 **执行环境** (Execution Environment),在我们通常不会注意到的地方,它 们还会在执行应用之前完成一些初始化工作,并在应用程序执行的时候对它进行监控。我们在打印 ``Hello, world!`` 时使用的 ``println!`` 宏正是由 Rust 标准库 std 提供的。 从内核/操作系统的角度看来,它上面的一切都属于用户态,而它自身属于内核态。无论用户态应用如何编写,是手写汇编代码,还是基于某种编程语言利用 其标准库或三方库,某些功能总要直接或间接的通过内核/操作系统提供的 **系统调用** (System Call) 来实现。因此系统调用充当了用户和内核之间 的边界。内核作为用户态的运行环境,它不仅要提供系统调用接口,还需要对用户态应用的执行进行监控和管理。 .. note:: ``Hello, world!`` 用到了哪些系统调用? 从之前的 ``cargo run`` 的输出可以看出之前构建的可执行文件是在 target/debug 目录下的 os 。 在 Ubuntu 系统上,可以通过 ``strace`` 工具来运行一个程序并输出程序运行过程当中向内核请求的所有的系统调用及其返回值。 我们只需输入 ``strace target/debug/os`` 即可看到一长串的系统调用。 其中,真正容易看出与 ``Hello, world!`` 相关的只有一个系统调用: .. code-block:: write(1, "Hello, world!\n", 14) = 14 其参数的具体含义我们暂且不在这里进行解释。 其余的系统调用基本上分别用于函数库和内核两层执行环境的初始化工作和对于上层的运行期监控和管理。之后,随着应用场景的复杂化,我们 需要更强的抽象能力,也会实现这里面的一些系统调用。 从硬件的角度来看,它上面的一切都属于软件。硬件可以分为三种: 处理器 (Processor) ——它更常见的名字是中央处理单元 (CPU, Central Processing Unit), 内存 (Memory) 还有 I/O 设备。其中处理器无疑是其中最复杂同时也最关键的一个。它与软件约定一套 **指令集体系结构** (ISA, Instruction Set Architecture), 使得软件可以通过 ISA 中提供的汇编指令来访问各种硬件资源。软件当然也需要知道处理器会如何执行这些指令:最简单的话就是一条一条执行位于内存 中的指令。当然,实际的情况远比这个要复杂得多,为了适应现代应用程序的场景,处理器还需要提供很多额外的机制,而不仅仅是让数据在 CPU 寄存器、内存和 I/O 设备 三者之间流动。 .. note:: 多层执行环境都是必需的吗? 除了最上层的应用程序和最下层的硬件平台必须存在之外,作为中间层的函数库和内核并不是必须存在的:它们都是对下层资源进行了 **抽象** (Abstraction), 并为上层提供了一套运行环境。抽象的优点在于它让上层以较小的代价获得所需的功能,并同时可以提供一些保护。但抽象同时也是一种限制,会丧失一些 应有的灵活性。比如,当你在考虑在项目中应该使用哪个函数库的时候,就常常需要这方面的权衡:过多的抽象和过少的抽象自然都是不合适的。 实际上,我们通过应用程序的特征来判断它需要什么程度的抽象。 - 如果函数库和内核都不存在,那么我们就是在手写汇编代码,这种方式具有最高的灵活性,抽象能力则最低,基本等同于硬件。我们通常用这种方式来 实现一些架构相关且仅通过编程语言无法描述的小模块或者代码片段。 - 如果仅存在函数库而不存在内核,意味着我们不需要内核提供的抽象。在嵌入式场景就常常会出现这种情况。嵌入式设备虽然也包含 CPU、内存和 I/O 设备,但是它上面通常只会同时运行一个或几个功能非常简单的小应用程序,其定位就是那种功能单一的场景,比如人脸识别打卡系统等。我们常用的 操作系统如 Windows/Linux/macOS 等的抽象都支持同时运行很多应用程序,在嵌入式场景是过抽象的。因此,常见的解决方案是仅使用函数库构建 单独的应用程序或是用专为应用场景特别裁减过的轻量级内核管理少数应用程序。 平台与目标三元组 --------------------------------------- 对于一份用某种编程语言实现的源代码而言,编译器在将其通过编译、链接得到目标文件的时候需要知道程序要在哪个 **平台** (Platform) 上运行。 从上面给出的 :ref:`应用程序运行环境栈 ` 可以看出: - 如果用户态基于的内核不同,会导致系统调用接口不同或者语义不一致; - 如果底层硬件不同,对于硬件资源的访问方式会有差异。特别是 ISA 不同的话,对上提供的指令集和寄存器都不同。 它们都会导致最终生成的目标文件有很大不同。需要指出的是,某些编译器支持同一份源代码无需修改就可编译到多个不同的目标平台并在上面运行。这种 情况下,源代码是 **跨平台** 的。而另一些编译器则已经预设好了一个固定的目标平台。 我们可以通过 **目标三元组** (Target Triple) 来描述一个目标平台。它一般包括 CPU 架构、CPU 厂商和操作系统,它们确实都会控制目标文件的生成。 比如,我们可以尝试看一下之前的 ``Hello, world!`` 的目标平台是什么。这可以通过打印编译器 rustc 的默认配置信息: .. code-block:: console $ rustc --version --verbose rustc 1.48.0-nightly (73dc675b9 2020-09-06) binary: rustc commit-hash: 73dc675b9437c2a51a975a9f58cc66f05463c351 commit-date: 2020-09-06 host: x86_64-unknown-linux-gnu release: 1.48.0-nightly LLVM version: 11.0 从其中的 host 一项可以看出默认的目标平台是 ``x86_64-unknown-linux-gnu``,其中 CPU 架构是 x86_64,CPU 厂商是 unknown,操作系统是 linux-gnu。 这种无论编译器还是其目标文件都在我们当前所处的平台运行是一种最简单也最普遍的情况。但是很快我们就将遇到另外一种情况。 讲了这么多,终于该介绍我们的主线任务了。我们希望能够在另一个平台上运行 ``Hello, world!``,而与之前的默认平台不同的地方在于,我们将 CPU 架构从 x86_64 换成 RISC-V。 .. note:: 为何基于 RISC-V 架构而非 x86 系列架构? x86 架构为了在升级换代的同时保持对基于旧版架构应用程序/内核的兼容性,存在大量的历史包袱,也就是一些对于目前的应用场景没有任何意义,但又必须 花大量时间正确设置才能正常使用 CPU 的奇怪设定。为了建立并维护架构的应用生态,这确实是必不可少的,但站在教学的角度几乎完全是在浪费时间。而 新生的 RISC-V 架构十分简洁,架构文档需要阅读的核心部分不足百页,且这些功能已经足以用来构造一个具有相当抽象能力的内核了。 可以看一下目前 Rust 编译器支持哪些基于 RISC-V 的平台: .. code-block:: console $ rustc --print target-list | grep riscv riscv32i-unknown-none-elf riscv32imac-unknown-none-elf riscv32imc-unknown-none-elf riscv64gc-unknown-linux-gnu riscv64gc-unknown-none-elf riscv64imac-unknown-none-elf 这里我们选择的是 ``riscv64gc-unknown-none-elf``,目标三元组中的操作系统是 none-elf,表明没有任何系统调用支持。这里我们之所以不选择有 linux-gnu 系统调用支持的版本 ``riscv64gc-unknown-linux-gnu``,是因为我们只是想跑一个 ``Hello, world!``,没有必要使用操作系统所提供的 那么高级的抽象。而且我们很清楚后续我们要开发的是一个内核,如果仅仅基于已有操作系统提供的系统调用的话,它自身的抽象能力会受到很大限制。所以它必须 直面底层硬件来解锁更大的抽象能力上限。 .. note:: RISC-V 指令集拓展