From 614b71c7a270d455a4050138f868e23b1cc17dc5 Mon Sep 17 00:00:00 2001 From: Yu Chen Date: Sun, 17 Jul 2022 20:55:35 +0800 Subject: [PATCH] remove all warnings --- source/chapter1/2remove-std.rst | 1 + .../3first-instruction-in-kernel1.rst | 6 + source/chapter1/5support-func-call.rst | 2 + source/chapter1/8answer.rst | 3 + source/chapter1/old3-1-mini-rt-usrland.rst | 310 ---------- source/chapter1/old3-2-mini-rt-baremetal.rst | 583 ------------------ source/chapter1/old4understand-prog.rst | 380 ------------ source/chapter2/0intro.rst | 2 +- source/chapter2/1rv-privilege.rst | 1 - source/chapter3/2task-switching.rst | 2 +- source/chapter4/7more-as.rst | 2 +- source/chapter4/9answer.rst | 32 +- source/chapter4/index.rst | 2 + source/chapter5/2core-data-structures.rst | 4 +- source/chapter5/index.rst | 2 + source/chapter6/3using-easy-fs-in-kernel.rst | 3 +- source/chapter6/index.rst | 2 + source/chapter7/4signal.rst | 6 +- source/chapter7/index.rst | 2 + source/chapter8/0intro.rst | 32 +- source/chapter8/1thread-kernel.rst | 4 +- source/chapter8/1thread.rst | 10 +- source/chapter8/2lock.rst | 50 +- source/chapter8/5concurrency-problem.rst | 18 +- source/chapter8/index.rst | 2 + source/chapter9/index.rst | 2 + source/log.rst | 2 +- source/terminology.rst | 57 +- 28 files changed, 133 insertions(+), 1389 deletions(-) delete mode 100644 source/chapter1/old3-1-mini-rt-usrland.rst delete mode 100644 source/chapter1/old3-2-mini-rt-baremetal.rst delete mode 100644 source/chapter1/old4understand-prog.rst diff --git a/source/chapter1/2remove-std.rst b/source/chapter1/2remove-std.rst index dfe0332d..71684d49 100644 --- a/source/chapter1/2remove-std.rst +++ b/source/chapter1/2remove-std.rst @@ -209,6 +209,7 @@ 对于上面这个被移除标准库的应用程序,通过了编译器的检查和编译,形成了二进制代码。但这个二进制代码是怎样的,它能否被正常执行呢?为了分析这些程序,首先需要安装 cargo-binutils 工具集: .. code-block:: console + $ cargo install cargo-binutils $ rustup component add llvm-tools-preview diff --git a/source/chapter1/3first-instruction-in-kernel1.rst b/source/chapter1/3first-instruction-in-kernel1.rst index ab251f7f..b1b3642c 100644 --- a/source/chapter1/3first-instruction-in-kernel1.rst +++ b/source/chapter1/3first-instruction-in-kernel1.rst @@ -15,6 +15,8 @@ 当编写应用程序的时候,大多数情况下我们只需调用库函数即可在操作系统的支持下实现各项功能,而无需关心操作系统如何调度管理各类软硬件资源。操作系统提供了一些监控工具(如 Windows 上的任务管理器或 Linux 上的 ``ps`` 工具),这些工具可以帮助我们统计 CPU、内存、硬盘、网络等资源的占用情况,从而让我们大致上了解这些资源的使用情况,并帮助我们更好地开发或部署应用程序。然而,在实际编写操作系统的时候,我们就必须直面这些硬件资源,将它们管理起来并为应用程序提供高效易用的抽象。为此,我们必须增进对于这些硬件的了解。 +.. _term-physical-address: + 计算机主要由处理器(Processor,也即中央处理器,CPU,Central Processing Unit),物理内存和 I/O 外设三部分组成。在前八章我们主要用到 CPU 和物理内存。处理器的主要功能是从物理内存中读取指令、译码并执行,在此过程中还要与物理内存和 I/O 外设打交道。物理内存则是计算机体系结构中一个重要的组成部分。在存储方面,CPU 唯一能够直接访问的只有物理内存中的数据,它可以通过访存指令来达到这一目的。从 CPU 的视角看来,可以将物理内存看成一个大字节数组,而物理地址则对应于一个能够用来访问数组中某个元素的下标。与我们日常编程习惯不同的是,该下标通常不以 0 开头,而通常以一个常数,如 ``0x80000000`` 开头。简言之,CPU 可以通过物理地址来寻址,并 **逐字节** 地访问物理内存中保存的数据。 值得一提的是,当 CPU 以多个字节(比如 2/4/8 或更多)为单位访问物理内存(事实上并不局限于物理内存,也包括I/O外设的数据空间)中的数据时,就有可能会引入端序(也称字节顺序)和内存地址对齐的问题。由于这并不是重点,我们在这里不展开说明,如读者有兴趣可以参考下面的补充说明。 @@ -54,6 +56,8 @@ 其中: +.. _term-bootloader: + - ``-machine virt`` 表示将模拟的 64 位 RISC-V 计算机设置为名为 ``virt`` 的虚拟计算机。我们知道,即使同属同一种指令集架构,也会有很多种不同的计算机配置,比如 CPU 的生产厂商和型号不同,支持的 I/O 外设种类也不同。关于 ``virt`` 平台的更多信息可以参考 [#virt_platform]_ 。Qemu 还支持模拟其他 RISC-V 计算机,其中包括由 SiFive 公司生产的著名的 HiFive Unleashed 开发板。 - ``-nographic`` 表示模拟器不需要提供图形界面,而只需要对外输出字符流。 - 通过 ``-bios`` 可以设置 Qemu 模拟器开机时用来初始化的引导加载程序(bootloader),这里我们使用预编译好的 ``rustsbi-qemu.bin`` ,它需要被放在与 ``os`` 同级的 ``bootloader`` 目录下,该目录可以从每一章的代码分支中获得。 @@ -62,6 +66,8 @@ Qemu 启动流程 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. _term-physical-memory: + ``virt`` 平台上,物理内存的起始物理地址为 ``0x80000000`` ,物理内存的默认大小为 128MiB ,它可以通过 ``-m`` 选项进行配置。在本书中,我们只会用到最低的 8MiB 物理内存,对应的物理地址区间为 ``[0x80000000,0x80800000)`` 。如果使用上面给出的命令启动 Qemu ,那么在 Qemu 开始执行任何指令之前,首先两个文件将被加载到 Qemu 的物理内存中:即作为 bootloader 的 ``rustsbi-qemu.bin`` 被加载到物理内存以物理地址 ``0x80000000`` 开头的区域上,同时内核镜像 ``os.bin`` 被加载到以物理地址 ``0x80200000`` 开头的区域上。 为什么加载到这两个位置呢?这与 Qemu 模拟计算机加电启动后的运行流程有关。一般来说,计算机加电之后的启动流程可以分成若干个阶段,每个阶段均由一层软件负责,每一层软件的功能是进行它应当承担的初始化工作,并在此之后跳转到下一层软件的入口地址,也就是将计算机的控制权移交给了下一层软件。Qemu 模拟的启动流程则可以分为三个阶段:第一个阶段由固化在 Qemu 内的一小段汇编程序负责;第二个阶段由 bootloader 负责;第三个阶段则由内核镜像负责。 diff --git a/source/chapter1/5support-func-call.rst b/source/chapter1/5support-func-call.rst index a2283516..47f2829e 100644 --- a/source/chapter1/5support-func-call.rst +++ b/source/chapter1/5support-func-call.rst @@ -23,6 +23,8 @@ - 调用者函数和被调用者函数如何合作保证调用子函数前后寄存器内容保持不变?调用者保存和被调用者保存寄存器的保存与恢复各自由谁负责?它们暂时被保存在什么位置?它们于何时被保存和恢复(如函数的开场白/退场白)? - 在 RISC-V 架构上,调用者保存和被调用者保存寄存器如何划分(特别地,思考 sp 和 ra 是调用者还是被调用者保存寄存器?为什么?)?如何使用寄存器传递函数调用的参数和返回值? +.. _term-function-call-and-stack: + 函数调用与栈 ---------------------------- diff --git a/source/chapter1/8answer.rst b/source/chapter1/8answer.rst index 8ef35de1..82394f59 100644 --- a/source/chapter1/8answer.rst +++ b/source/chapter1/8answer.rst @@ -184,3 +184,6 @@ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 1. 请学习 gdb 调试工具的使用(这对后续调试很重要),并通过 gdb 简单跟踪从机器加电到跳转到 0x80200000 的简单过程。只需要描述重要的跳转即可,只需要描述在 qemu 上的情况。 + + +.. [#qemu_bootrom] https://github.com/qemu/qemu/blob/0ebf76aae58324b8f7bf6af798696687f5f4c2a9/hw/riscv/boot.c#L300 \ No newline at end of file diff --git a/source/chapter1/old3-1-mini-rt-usrland.rst b/source/chapter1/old3-1-mini-rt-usrland.rst deleted file mode 100644 index a92bd8bb..00000000 --- a/source/chapter1/old3-1-mini-rt-usrland.rst +++ /dev/null @@ -1,310 +0,0 @@ -.. _term-print-userminienv: - -构建用户态执行环境 -================================= - -.. toctree:: - :hidden: - :maxdepth: 5 - -本节导读 -------------------------------- - -本节的主要目标是通过设计实现应用程序最小执行环境来理解一个应用程序要正常执行所需要的一些非常基本的支持。 -在这里,我们先设计实现一个用户态的最小执行环境(就是上节提到的用户态LibOS)以支持最简单的用户态 ``Hello, world!`` 程序,再改进这个最小执行环境,让它能在内核态运行(即可以在裸机上运行的嵌入式OS),支持裸机应用程序。这样设计实现的原因是, -它能帮助我们理解这两个不同的执行环境在支持同样一个应用程序时的的相同和不同之处,这将加深对执行环境的理解,并对后续写自己的OS和运行在OS上的应用程序都有帮助。 -所以,本节将先建立一个用户态的最小执行环境,即 **恐龙虾** 操作系统 [#shrimp]_ 。 - -本节开始我们将着手自己来实现之前被我们移除的 ``Hello, world!`` 程序中用户态执行环境的功能。 -在这一小节,我们首先介绍如何进行 **执行环境初始化** 。 - - - -用户态最小化执行环境 ----------------------------- - -执行环境初始化 -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -在上一节,我们构造的二进制程序是一个空程序,其原因是 Rust 编译器找不到执行环境的入口函数,于是就没有生产后续的代码。所以,我们首先要把入口函数 -找到。通过查找资料,发现Rust编译器要找的入口函数是 ``_start()`` ,于是我们可以在 ``main.rs`` 中添加如下内容: - - -.. code-block:: rust - - // os/src/main.rs - #[no_mangle] - extern "C" fn _start() { - loop{}; - } - - -对上述代码重新编译,再用 ``rust-readobj`` 和 ``rust-objdump`` 等分析工具分析,可以看到: - - -.. code-block:: console - - $ cargo build - Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os) - Finished dev [unoptimized + debuginfo] target(s) in 0.06s - - [文件格式] - $ file target/riscv64gc-unknown-none-elf/debug/os - target/riscv64gc-unknown-none-elf/debug/os: ELF 64-bit LSB executable, UCB RISC-V, ...... - - [文件头信息] - $ rust-readobj -h target/riscv64gc-unknown-none-elf/debug/os - File: target/riscv64gc-unknown-none-elf/debug/os - Format: elf64-littleriscv - Arch: riscv64 - AddressSize: 64bit - ...... - Type: Executable (0x2) - Machine: EM_RISCV (0xF3) - Version: 1 - Entry: 0x11120 - ...... - } - - [反汇编导出汇编程序] - $ rust-objdump -S target/riscv64gc-unknown-none-elf/debug/os - target/riscv64gc-unknown-none-elf/debug/os: file format elf64-littleriscv - - Disassembly of section .text: - - 0000000000011120 <_start>: - ; loop {} - 11120: 09 a0 j 2 <_start+0x2> - 11122: 01 a0 j 0 <_start+0x2> - - -通过 ``file`` 工具对二进制程序 ``os`` 的分析可以看到它依然是一个合法的 RV64 执行程序,但通过 ``rust-readobj`` 工具进一步分析,发现它的入口地址 Entry 是 ``0x11120`` ,这好像是一个合法的地址。再通过 ``rust-objdump`` 工具把它反汇编,可以看到编译器生成的汇编代码! - - -仔细读读这两条指令,发现就是一个死循环的汇编代码,且其第一条指令的地址与入口地址 Entry 的值一致,这说明编译器生成的目标执行程序已经是一个合理的程序了。如果我们用 ``qemu-riscv64 target/riscv64gc-unknown-none-elf/debug/os`` 执行这个程序,没有输出,也不会退出,好像程序就是在执行死循环的效果。 - - -程序正常退出 -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -我们能让程序正常退出吗?我们把 ``_start()`` 函数中的循环语句注释掉,重新编译并分析,看到其汇编代码是: - - -.. code-block:: console - - $ rust-objdump -S target/riscv64gc-unknown-none-elf/debug/os - - target/riscv64gc-unknown-none-elf/debug/os: file format elf64-littleriscv - - - Disassembly of section .text: - - 0000000000011120 <_start>: - ; } - 11120: 82 80 ret - -看起来是有内容(具有 ``ret`` 函数返回汇编指令)且合法的执行程序。但如果我们执行它,就发现有问题了: - -.. code-block:: console - - $ qemu-riscv64 target/riscv64gc-unknown-none-elf/debug/os - 段错误 (核心已转储) - -*段错误 (核心已转储)* 是常见的一种应用程序出错,而我们这个非常简单的应用程序导致了 Linux 环境模拟程序 ``qemu-riscv64`` 崩溃了!为什么会这样? - -.. _term-qemu-riscv64: - -.. note:: - - QEMU有两种运行模式: ``User mode`` 模式,即用户态模拟,如 ``qemu-riscv64`` 程序,能够模拟不同处理器的用户态指令的执行,并可以直接解析ELF可执行文件,加载运行那些为不同处理器编译的用户级Linux应用程序(ELF可执行文件);在翻译并执行不同应用程序中的不同处理器的指令时,如果碰到是系统调用相关的汇编指令,它会把不同处理器(如RISC-V)的Linux系统调用转换为本机处理器(如x86-64)上的Linux系统调用,这样就可以让本机Linux完成系统调用,并返回结果(再转换成RISC-V能识别的数据)给这些应用。 ``System mode`` 模式,即系统态模式,如 ``qemu-system-riscv64`` 程序,能够模拟一个完整的基于不同CPU的硬件系统,包括处理器、内存及其他外部设备,支持运行完整的操作系统。 - -回顾一下最开始的输出 ``Hello, world!`` 的简单应用程序,其入口函数名字是 ``main`` ,编译时用的是标准库 std 。它可以正常执行。再仔细想想,当一个应用程序出错的时候,最上层为操作系统的执行环境会把它给杀死。但如果一个应用的入口函数正常返回,执行环境应该优雅地让它退出才对。没错!目前的执行环境还缺了一个退出机制。 - -先了解一下,操作系统会提供一个退出的系统调用服务接口,如果应用程序调用这个接口,那这个程序就会在操作系统的帮助和管理下退出了。这里先给出代码: - -.. _term-llvm-syscall: - -.. code-block:: rust - - // os/src/main.rs - #![feature(asm)] - - const SYSCALL_EXIT: usize = 93; - - fn syscall(id: usize, args: [usize; 3]) -> isize { - let mut ret: isize; - unsafe { - asm!("ecall" - : "={x10}" (ret) - : "{x10}" (args[0]), "{x11}" (args[1]), "{x12}" (args[2]), "{x17}" (id) - : "memory" - : "volatile" - ); - } - ret - } - - pub fn sys_exit(xstate: i32) -> isize { - syscall(SYSCALL_EXIT, [xstate as usize, 0, 0]) - } - - #[no_mangle] - extern "C" fn _start() { - sys_exit(9); - } - -``main.rs`` 增加的内容不多,但还是有点与一般的应用程序有所不同,因为它引入了汇编和系统调用。如果你看不懂上面内容的细节,没关系,在第二章的第二节 :doc:`/chapter2/2application` 会有详细的介绍。这里只需知道 ``_start`` 函数调用了一个 ``sys_exit`` 函数,来向操作系统发出一个退出服务的系统调用请求,并传递给OS的退出码为 ``9`` 。 - -我们编译执行以下修改后的程序: - -.. code-block:: console - - $ cargo build --target riscv64gc-unknown-none-elf - Compiling os v0.1.0 (/media/chyyuu/ca8c7ba6-51b7-41fc-8430-e29e31e5328f/thecode/rust/os_kernel_lab/os) - Finished dev [unoptimized + debuginfo] target(s) in 0.26s - - [$?表示执行程序的退出码,它会被告知 OS] - $ qemu-riscv64 target/riscv64gc-unknown-none-elf/debug/os; echo $? - 9 - -可以看到,返回的结果确实是 ``9`` 。这样,我们在没有任何显示功能的情况下,勉强完成了一个简陋的用户态最小化执行环境。 - -上面实现的最小化执行环境貌似能够在 Linux 操作系统上支持只调用一个 ``SYSCALL_EXIT`` 系统调用服务的程序,但这也说明了 -在操作系统的支持下,实现一个基本的用户态执行环境还是比较容易的。其中的原因是,操作系统帮助用户态执行环境完成了程序加载、程序退出、资源分配、资源回收等各种琐事。如果没有操作系统,那么实现一个支持在裸机上运行应用程序的执行环境,就要考虑更多的事情了,或者干脆简化一切可以不必干的事情(比如对于单个应用,不需要调度功能等)。 -能在裸机上运行的执行环境,其实就是之前提到的“三叶虫”操作系统。 - - -有显示支持的用户态执行环境 ----------------------------- - -没有显示功能,终究觉得缺了点啥。在没有通常开发应用程序时常用的动态调试工具的情况下,其实能显示字符串,就已经能够满足绝大多数情况下的操作系统调试需求了。 - -Rust 的 core 库内建了以一系列帮助实现显示字符的基本 Trait 、数据结构、函数和宏等,我们可以对其中的关键部分进行扩展,就可以实现定制的 ``println!`` 功能。 - - -实现输出字符串的相关函数 -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -首先封装一下对 ``SYSCALL_WRITE`` 系统调用。这个是 Linux 操作系统内核提供的系统调用,其 ``ID`` 就是 ``SYSCALL_WRITE``。 - -.. code-block:: rust - - const SYSCALL_WRITE: usize = 64; - - pub fn sys_write(fd: usize, buffer: &[u8]) -> isize { - syscall(SYSCALL_WRITE, [fd, buffer.as_ptr() as usize, buffer.len()]) - } - -然后实现基于 ``Write`` Trait 的数据结构,并完成 ``Write`` Trait 所需要的 ``write_str`` 函数,并用 ``print`` 函数进行包装。 - - -.. code-block:: rust - - struct Stdout; - - impl Write for Stdout { - fn write_str(&mut self, s: &str) -> fmt::Result { - sys_write(1, s.as_bytes()); - Ok(()) - } - } - - pub fn print(args: fmt::Arguments) { - Stdout.write_fmt(args).unwrap(); - } - -最后,实现基于 ``print`` 函数,实现Rust语言 **格式化宏** ( `formatting macros `_ )。 - - -.. code-block:: rust - - #[macro_export] - macro_rules! print { - ($fmt: literal $(, $($arg: tt)+)?) => { - $crate::console::print(format_args!($fmt $(, $($arg)+)?)); - } - } - - #[macro_export] - macro_rules! println { - ($fmt: literal $(, $($arg: tt)+)?) => { - print(format_args!(concat!($fmt, "\n") $(, $($arg)+)?)); - } - } - -上面的代码没有读懂?没关系,你只要了解到应用程序发出的宏调用 ``println!`` 就是通过上面的实现,一步一步地调用,最终通过操作系统提供的 ``SYSCALL_WRITE`` 系统调用服务,帮助我们完成了字符串显示输出。这就完成了有显示支持的用户态执行环境。 - -接下来,我们调整一下应用程序,让它发出显示字符串和退出的请求: - -.. code-block:: rust - - #[no_mangle] - extern "C" fn _start() { - println!("Hello, world!"); - sys_exit(9); - } - -整体工作完成!当然,我们实现的很简陋,用户态执行环境和应用程序都放在一个文件里面,以后会通过软件工程的相关知识,进行软件重构,让代码更清晰和模块化。 - -现在,我们编译并执行一下,可以看到正确的字符串输出,且程序也能正确结束! - - -.. code-block:: console - - $ cargo build --target riscv64gc-unknown-none-elf - Compiling os v0.1.0 (/media/chyyuu/ca8c7ba6-51b7-41fc-8430-e29e31e5328f/thecode/rust/os_kernel_lab/os) - Finished dev [unoptimized + debuginfo] target(s) in 0.61s - - $ qemu-riscv64 target/riscv64gc-unknown-none-elf/debug/os; echo $? - Hello, world! - 9 - - -.. 下面出错的情况是会在采用 linker.ld,加入了 .cargo/config -.. 的内容后会出错: -.. .. [build] -.. .. target = "riscv64gc-unknown-none-elf" -.. .. [target.riscv64gc-unknown-none-elf] -.. .. rustflags = [ -.. .. "-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes" -.. .. ] - -.. 重新定义了栈和地址空间布局后才会出错 - -.. 段错误 (核心已转储) - -.. 系统崩溃了!借助以往的操作系统内核编程经验和与下一节调试kernel的成果经验,我们直接定位为是 **栈** (Stack) 没有设置的问题。我们需要添加建立栈的代码逻辑。 - -.. .. code-block:: asm - -.. # entry.asm - -.. .section .text.entry -.. .globl _start -.. _start: -.. la sp, boot_stack_top -.. call rust_main - -.. .section .bss.stack -.. .globl boot_stack -.. boot_stack: -.. .space 4096 * 16 -.. .globl boot_stack_top -.. boot_stack_top: - -.. 然后把汇编代码嵌入到 ``main.rs`` 中,并进行微调。 - -.. .. code-block:: rust - -.. #![feature(global_asm)] - -.. global_asm!(include_str!("entry.asm")); - -.. #[no_mangle] -.. #[link_section=".text.entry"] -.. extern "C" fn rust_main() { - -.. 再次编译执行,可以看到正确的字符串输出,且程序也能正确结束! - - -.. [#shrimp] 恐龙虾是一类有三只眼的小型甲壳生物,最早出现在三亿年前的古生代石炭纪,在经历了三次地球世纪大灭绝之后,至今仍广泛地分布于世界各地。 \ No newline at end of file diff --git a/source/chapter1/old3-2-mini-rt-baremetal.rst b/source/chapter1/old3-2-mini-rt-baremetal.rst deleted file mode 100644 index 2f32598b..00000000 --- a/source/chapter1/old3-2-mini-rt-baremetal.rst +++ /dev/null @@ -1,583 +0,0 @@ -.. _term-print-kernelminienv: - -构建裸机执行环境 -================================= - -.. toctree:: - :hidden: - :maxdepth: 5 - -本节导读 -------------------------------- - -本节开始我们将着手自己来实现裸机上的最小执行环境,即我们的“三叶虫”操作系统,并能在裸机上运行 ``Hello, world!`` 程序。 -有了上一节实现的用户态的最小执行环境,我们可以稍加改造,就可以完成裸机上的最小执行环境了。与上节不同,“恐龙虾”操作系统运行在用户态,通过下层的操作系统来帮助完成内存布局、初始化、显示字符串和关闭。由于我们选择的Rust编译器的目标平台 ``riscv64gc-unknown-none-elf`` 是假设没有任何操作系统支持的,所以在上节编写的程序中,需要禁用需要操作系统支持的标准库,并移除默认的 main 函数 -入口。最终我们通过实现 ``_start`` 函数来代替了传统的 main 函数,并通过汇编实现的系统调用接口,来输出 ``Hello, world!`` 。 - - -我们在上一小节提到过,一个应用程序的运行离不开下面多层执行环境栈的支撑。而“恐龙虾”操作系统本质上是一个没有std标准库支持的特殊的用户态应用程序。 -在目前广泛使用的操作系统上,它就至少需要经历以下层层递进的初始化过程: - -- 启动OS:硬件启动后,会有一段代码(一般统称为bootloader)对硬件进行初始化,让包括内核在内的系统软件得以运行; -- OS准备好应用程序执行的环境:要运行该应用程序的时候,内核分配相应资源,将程序代码和数据载入内存,并赋予 CPU 使用权,由此应用程序可以运行; -- 应用程序开始执行:程序员编写的代码是应用程序的一部分,它需要标准库/核心库进行一些初始化工作后才能运行。 - - -而“三叶虫”操作系统的下面是没有操作系统了,它需要关注地方主要是: - -- 地址空间:物理内存的 DRAM 位置(放应用程序的地方)和应用程序的内存布局(如何在 DRAM 中放置应用程序的各个部分) -- 启动执行:应用程序的初始化(起始的指令位置,对 ``栈 stack`` 和 ``bss`` 的初始化) -- I/O接口:SBI 的字符输出接口(执行环境提供的输出字符服务,可以被应用程序使用) - - -了解硬件组成和裸机启动过程 ----------------------------- - -在这一小节,我们介绍如何进行裸机层面的 **执行环境初始化** , 即我们需要知道具体需要做哪些初始化工作才能支持 -应用程序在裸机上的运行。而这又需要明确三点: - -- 首先,应用程序的裸机硬件系统是啥样子的? -- 其次,计算机系统在做这些初始化工作之前处于什么状态? -- 最后,在做完初始化工作也就是即将执行 main 入口函数之前,计算机系统又处于什么状态? - - -硬件组成 -^^^^^^^^^^^^^^^^^^^^^^ - -我们采用的是QEMU软件 ``qemu-system-riscv64`` 来模拟一台RISC-V 64计算机,具体的硬件规格是: - - I/O外设:16550A UART,virtio-net/block/console/gpu等和设备树 - - 硬件特权级:priv v1.10, user v2.2 - - 中断控制器:可参数化的CLINT(核心本地中断器)、可配置的PLIC(平台级中断控制器) - - 可配置的RAM内存 - - 可配置的多核 RV64GC M/S/U mode CPU - -这里列出的硬件功能很多还用不上,不过在后面的章节中会逐步用到上面的硬件功能,以支持更加强大的操作系统能力。 - -在QEMU模拟的硬件中,物理内存和外设都是通过对内存读写的方式来进行访问,下面列出了QEMU模拟的物理内存空间。 - -.. code-block:: c - - // qemu/hw/riscv/virt.c - static const struct MemmapEntry { - hwaddr base; - hwaddr size; - } virt_memmap[] = { - [VIRT_DEBUG] = { 0x0, 0x100 }, - [VIRT_MROM] = { 0x1000, 0xf000 }, - [VIRT_TEST] = { 0x100000, 0x1000 }, - [VIRT_RTC] = { 0x101000, 0x1000 }, - [VIRT_CLINT] = { 0x2000000, 0x10000 }, - [VIRT_PCIE_PIO] = { 0x3000000, 0x10000 }, - [VIRT_PLIC] = { 0xc000000, VIRT_PLIC_SIZE(VIRT_CPUS_MAX * 2) }, - [VIRT_UART0] = { 0x10000000, 0x100 }, - [VIRT_VIRTIO] = { 0x10001000, 0x1000 }, - [VIRT_FLASH] = { 0x20000000, 0x4000000 }, - [VIRT_PCIE_ECAM] = { 0x30000000, 0x10000000 }, - [VIRT_PCIE_MMIO] = { 0x40000000, 0x40000000 }, - [VIRT_DRAM] = { 0x80000000, 0x0 }, - }; - - -在上面的代码中,需要注意的两个比较重要的内容是: - - VIRT_DRAM:这是计算机的物理内存,DRAM的内存起始地址是 ``0x80000000`` ,缺省大小为128MB。在本书中一般限制为8MB。 - - VIRT_UART0:这是串口的控制寄存器区域,串口相关的寄存器起始地址是 ``0x10000000`` ,范围是 ``0x100`` ,我们通过访问这段特殊的区域来实现字符输入输出的管理与控制。 - -.. _term-bootloader: - - -裸机启动过程 -^^^^^^^^^^^^^^^^^^ - -.. note:: - - **QEMU 模拟 CPU 加电的执行过程** - - CPU加电后的执行细节与具体硬件相关,我们这里以QEMU模拟器为具体例子简单介绍一下。 - - 这需要从 CPU 加电后如何初始化,如何执行第一条指令开始讲起。对于我们采用的QEMU模拟器而言,它模拟了一台标准的RISC-V64计算机。我们启动QEMU时,通过设置参数,可控制QEMU模拟的RISC-V64计算机在其模拟的内存中的固定位置放置BootLoader程序和操作系统的二进制代码。这样,QEMU模拟的RISC-V64计算机在启动后,从这个固定位置开始执行就可以让BootLoader程序开始执行了。 - - 这可以通过查看 ``os/Makefile`` 文件中包含 ``qemu-system-riscv64`` 的相关内容来了解。 - - - ``-bios $(BOOTLOADER)`` 这个参数意味着硬件内存中的固定位置 ``0x80000000`` 处放置了一个BootLoader程序--RustSBI(戳 :doc:`../appendix-c/index` 可以进一步了解RustSBI。)。 - - ``-device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA)`` 这个参数表示硬件内存中的特定位置 ``$(KERNEL_ENTRY_PA)`` 放置了操作系统的二进制代码 ``$(KERNEL_BIN)`` 。 ``$(KERNEL_ENTRY_PA)`` 的值是 ``0x80200000`` 。 - - 当我们执行包含上次启动参数的qemu-system-riscv64软件,就意味给这台虚拟的RISC-V64计算机加电了。此时,CPU的其它通用寄存器清零, - 而PC寄存器会指向 ``0x1000`` 的位置。 - 这个 ``0x1000`` 位置上是CPU加电后执行的第一条指令(固化在硬件中的一小段引导代码),它会很快跳转到 ``0x80000000`` 处, - 即BootLoader程序--RustSBI的第一条指令。RustSBI完成基本的硬件初始化后, - 会跳转操作系统的二进制代码 ``$(KERNEL_BIN)`` 所在内存位置 ``0x80200000`` ,执行操作系统的第一条指令。 - 这时我们的编写的操作系统才开始正式工作。 - - 为啥在 ``0x80000000`` 放置 ``Bootloader`` ?因为这是QEMU的硬件模拟代码中设定好的 ``Bootloader`` 的起始地址。 - - 为啥在 ``0x80200000`` 放置 ``os`` ?因为这是Bootloader-- ``RustSBI`` 的代码中设定好的 ``os`` 的起始地址。 - - -.. note:: - - **操作系统与SBI之间是啥关系?** - - ``SBI`` 的全称是 ``RISC- V Supervisor Binary Interface`` , 是RISC-V的一种底层规范,操作系统内核与BI规范的一种具体实现RustSBI的关系有点象应用与操作系统内核的关系,后者向前者提供一定的服务。只是SBI提供的服务很少, - 能帮助操作系统内核完成的功能有限,但这些功能很底层,很重要,比如关机,显示字符串等。通过操作系统内核也能直接实现,但比较繁琐,如果RustSBI提供了服务, - 那么操作系统内核直接调用就好了。 - - -.. chyyuu **FIXME: 提供一下分析展示** ??? - - -实现关机功能 ----------------------------- - -如果在裸机上的应用程序执行完毕并通知操作系统后,那么操作系统就没事干了,实现正常关机是一个合理的选择。所以我们要让“三叶虫”操作系统能够正常关机,这是需要调用SBI提供的关机功能 ``SBI_SHUTDOWN`` ,这与上一节的 ``SYSCALL_EXIT`` 类似, -只是在具体参数上有所不同。在上一节完成的没有显示功能的用户态最小化执行环境基础上,修改的代码如下: - -.. _term-llvm-sbicall: - -.. code-block:: rust - - // bootloader/rustsbi-qemu.bin 直接添加的SBI规范实现的二进制代码,给操作系统提供基本支持服务 - - // os/src/sbi.rs - fn sbi_call(which: usize, arg0: usize, arg1: usize, arg2: usize) -> usize { - let mut ret; - unsafe { - asm!("ecall" - : "={x10}" (ret) - : "{x10}" (arg0), "{x11}" (arg1), "{x12}" (arg2), "{x17}" (which) - ... - - // os/src/main.rs - const SBI_SHUTDOWN: usize = 8; - - pub fn shutdown() -> ! { - sbi_call(SBI_SHUTDOWN, 0, 0, 0); - panic!("It should shutdown!"); - } - - #[no_mangle] - extern "C" fn _start() { - shutdown(); - } - - - - -也许有同学比较迷惑,应用程序访问操作系统提供的系统调用的指令是 ``ecall`` ,操作系统访问 -RustSBI提供的SBI服务的SBI调用的指令也是 ``ecall`` 。 -这其实是没有问题的,虽然指令一样,但它们所在的特权级和特权级转换是不一样的。简单地说,应用程序位于最弱的用户特权级(User Mode),操作系统位于 -较强的内核特权级(Supervisor Mode),RustSBI位于最强的机器特权级(Machine Mode),完全掌控机器。软件通过 ``ecall`` 指令,可以完成从弱的特权级 -到强的特权级的一层转换: - -``App in User Mode`` -- ``ecall`` --> ``OS in Supervisor Mode`` -- ``ecall`` --> ``Bootloader in Machine Mode`` - - -可以看下一章的进一步描述来了解具体细节。在这里,只要知道,如果“三叶虫”操作系统正确地向RustSBI发出了停机的SBI服务请求, -那么RustSBI能够通知QEMU模拟的RISC-V计算机停机(即 ``qemu-system-riscv64`` 软件能正常退出),就行了。 - -下面是编译执行,结果如下: - - -.. code-block:: console - - # 编译生成ELF格式的执行文件 - $ cargo build --release - Compiling os v0.1.0 (/media/chyyuu/ca8c7ba6-51b7-41fc-8430-e29e31e5328f/thecode/rust/os_kernel_lab/os) - Finished release [optimized] target(s) in 0.15s - # 把ELF执行文件转成bianary文件 - $ rust-objcopy --binary-architecture=riscv64 target/riscv64gc-unknown-none-elf/release/os --strip-all -O binary target/riscv64gc-unknown-none-elf/release/os.bin - - #加载运行 - $ qemu-system-riscv64 -machine virt -nographic -bios ../bootloader/rustsbi-qemu.bin -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000 - # 无法退出,风扇狂转,感觉碰到死循环 - -这样的结果是我们不期望的。问题在哪?仔细查看和思考,操作系统的入口地址不对!对 ``os`` 这个面向裸机环境的ELF执行程序,通过 ``rust-readobj`` 分析,看到的入口地址不是 -RustSBI约定的 ``0x80200000`` 。我们需要修改 ``os`` 这个ELF执行程序的内存布局。 - - -设置正确的程序内存布局 ----------------------------- - -.. _term-linker-script: - -我们可以通过 **链接脚本** (Linker Script) 调整链接器的行为,使得最终生成的可执行文件的内存布局符合我们的预期。 -我们修改 Cargo 的配置文件来使用我们自己的链接脚本 ``os/src/linker.ld`` 而非使用默认的内存布局: - -.. code-block:: - :linenos: - :emphasize-lines: 5,6,7,8 - - // os/.cargo/config - [build] - target = "riscv64gc-unknown-none-elf" - - [target.riscv64gc-unknown-none-elf] - rustflags = [ - "-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes" - ] - -具体的链接脚本 ``os/src/linker.ld`` 如下: - -.. code-block:: - :linenos: - - OUTPUT_ARCH(riscv) - ENTRY(_start) - BASE_ADDRESS = 0x80200000; - - SECTIONS - { - . = BASE_ADDRESS; - skernel = .; - - stext = .; - .text : { - *(.text.entry) - *(.text .text.*) - } - - . = ALIGN(4K); - etext = .; - srodata = .; - .rodata : { - *(.rodata .rodata.*) - } - - . = ALIGN(4K); - erodata = .; - sdata = .; - .data : { - *(.data .data.*) - } - - . = ALIGN(4K); - edata = .; - .bss : { - *(.bss.stack) - sbss = .; - *(.bss .bss.*) - } - - . = ALIGN(4K); - ebss = .; - ekernel = .; - - /DISCARD/ : { - *(.eh_frame) - } - } - -第 1 行我们设置了目标平台为 riscv ;第 2 行我们设置了整个程序的入口点为之前定义的全局符号 ``_start``; -第 3 行定义了一个常量 ``BASE_ADDRESS`` 为 ``0x80200000`` ,也就是我们之前提到的初始化代码被放置的地址; - -从第 5 行开始体现了链接过程中对输入的目标文件的段的合并。其中 ``.`` 表示当前地址,也就是链接器会从它指向的位置开始往下放置从输入的目标文件 -中收集来的段。我们可以对 ``.`` 进行赋值来调整接下来的段放在哪里,也可以创建一些全局符号赋值为 ``.`` 从而记录这一时刻的位置。我们还能够 -看到这样的格式: - -.. code-block:: - - .rodata : { - *(.rodata) - } - -冒号前面表示最终生成的可执行文件的一个段的名字,花括号内按照放置顺序描述将所有输入目标文件的哪些段放在这个段中,每一行格式为 -``(SectionName)``,表示目标文件 ``ObjectFile`` 的名为 ``SectionName`` 的段需要被放进去。我们也可以 -使用通配符来书写 ```` 和 ```` 分别表示可能的输入目标文件和段名。因此,最终的合并结果是,在最终可执行文件 -中各个常见的段 ``.text, .rodata .data, .bss`` 从低地址到高地址按顺序放置,每个段里面都包括了所有输入目标文件的同名段, -且每个段都有两个全局符号给出了它的开始和结束地址(比如 ``.text`` 段的开始和结束地址分别是 ``stext`` 和 ``etext`` )。 - - - - -为了说明当前实现的正确性,我们需要讨论这样一个问题: - -1. 如何把执行环境的初始化代码放到 ``0x80200000`` 起始的内存区域上? - - 在链接脚本第 7 行,我们将当前地址设置为 ``BASE_ADDRESS`` 也即 ``0x80200000`` ,然后从这里开始往高地址放置各个段。第一个被放置的 - 是 ``.text`` ,而里面第一个被放置的又是来自 ``entry.asm`` 中的段 ``.text.entry``,这个段恰恰是含有两条指令的执行环境初始化代码, - 它在所有段中最早被放置在我们期望的 ``0x80200000`` 处。 - - -这样一来,我们就将面向裸机硬件环境的运行时重建完毕了。在 ``os`` 目录下 ``cargo build --release`` 或者直接 ``make build`` 就能够看到 -最终生成的可执行文件 ``target/riscv64gc-unknown-none-elf/release/os`` 。 -通过分析,我们看到 ``0x80200000`` 处的代码是我们预期的 ``_start()`` 函数的内容。我们采用刚才的编译运行方式进行试验,发现还是同样的错误结果。 -问题出在哪里?这时需要用上 ``debug`` 大法了。 - - -.. code-block:: console - - # 在一个终端执行如下命令: - $ qemu-system-riscv64 -machine virt -nographic -bios ../bootloader/rustsbi-qemu.bin -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000 -S -s - - # 在另外一个终端执行如下命令: - $ rust-gdb target/riscv64gc-unknown-none-elf/release/os - (gdb) target remote :1234 - (gdb) break *0x80200000 - (gdb) x /16i 0x80200000 - (gdb) si - -结果发现刚执行一条指令,整个系统就飞了( ``pc`` 寄存器等已经变成为 ``0`` 了)。再一看, ``sp`` 寄存器是一个非常大的值 ``0xffffff...`` 。这就很清楚是 -**栈 stack** 出现了问题。我们没有设置好 **栈 stack** ! 好吧,我们需要考虑如何合理设置 **栈 stack** 。 - - -正确配置栈空间布局 ----------------------------- - -为了说明如何实现正确的栈,我们需要讨论这样一个问题:应用函数调用所需的栈放在哪里? - - 需要有一段代码来分配并栈空间,并把 ``sp`` 寄存器指向栈空间的起始位置(注意:栈空间是从上向下 ``push`` 数据的)。 - 所以,我们要写一小段汇编代码 ``entry.asm`` 来帮助建立好栈空间。 - 从链接脚本第 32 行开始,我们可以看出 ``entry.asm`` 中分配的栈空间对应的段 ``.bss.stack`` 被放入到可执行文件中的 - ``.bss`` 段中的低地址中。在后面虽然有一个通配符 ``.bss.*`` ,但是由于链接脚本的优先匹配规则它并不会被匹配到后面去。 - 这里需要注意的是地址区间 :math:`[\text{sbss},\text{ebss})` 并不包括栈空间,其原因后面再进行说明。 - -我们自己编写运行时初始化的代码: - -.. code-block:: asm - :linenos: - - # os/src/entry.asm - .section .text.entry - .globl _start - _start: - la sp, boot_stack_top - call rust_main - - .section .bss.stack - .globl boot_stack - boot_stack: - .space 4096 * 16 - .globl boot_stack_top - boot_stack_top: - -在这段汇编代码中,我们从第 8 行开始预留了一块大小为 4096 * 16 字节也就是 :math:`64\text{KiB}` 的空间用作接下来要运行的程序的栈空间, -这块栈空间的栈顶地址被全局符号 ``boot_stack_top`` 标识,栈底则被全局符号 ``boot_stack`` 标识。同时,这块栈空间单独作为一个名为 -``.bss.stack`` 的段,之后我们会通过链接脚本来安排它的位置。 - -从第 2 行开始,我们通过汇编代码实现执行环境的初始化,它其实只有两条指令:第一条指令将 sp 设置为我们预留的栈空间的栈顶位置,于是之后在函数 -调用的时候,栈就可以从这里开始向低地址增长了。简单起见,我们目前暂时不考虑 sp 越过了栈底 ``boot_stack`` ,也就是栈溢出的情形,虽然这有 -可能导致严重的错误。第二条指令则是通过汇编伪指令 ``call`` 来调用C函数 ``rust_main`` ,这里的 ``rust_main`` 是一个我们稍后自己编写的应用 -入口。因此初始化任务非常简单:正如上面所说的一样,只需要设置栈指针 sp,随后跳转到应用入口即可。这两条指令单独作为一个名为 -``.text.entry`` 的段,且全局符号 ``_start`` 给出了段内第一条指令的地址。 - -接着,我们在 ``main.rs`` 中嵌入这些汇编代码并声明应用入口 ``rust_main`` : - -.. code-block:: rust - :linenos: - :emphasize-lines: 4,8,10,11,12,13 - - // os/src/main.rs - #![no_std] - #![no_main] - #![feature(global_asm)] - - mod lang_items; - - global_asm!(include_str!("entry.asm")); - - #[no_mangle] - pub fn rust_main() -> ! { - loop {} - } - -背景高亮指出了 ``main.rs`` 中新增的代码。 - -第 4 行中,我们手动设置 ``global_asm`` 特性来支持在 Rust 代码中嵌入全局汇编代码。第 8 行,我们首先通过 -``include_str!`` 宏将同目录下的汇编代码 ``entry.asm`` 转化为字符串并通过 ``global_asm!`` 宏嵌入到代码中。 - -从第 10 行开始, -我们声明了应用的入口点 ``rust_main`` ,这里需要注意的是需要通过宏将 ``rust_main`` 标记为 ``#[no_mangle]`` 以避免编译器对它的 -名字进行混淆,不然的话在链接的时候, ``entry.asm`` 将找不到 ``main.rs`` 提供的外部符号 ``rust_main`` 从而导致链接失败。 - - -这样一来,我们就将初始的“三叶虫”操作系统编写完毕了。再次使用上节中的编译,生成和运行操作,我们看到QEMU模拟的RISC-V 64计算机 **优雅** 地退出了! - - - -.. code-block:: console - - $ qemu-system-riscv64 \ - > -machine virt \ - > -nographic \ - > -bios ../bootloader/rustsbi-qemu.bin \ - > -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000 - [rustsbi] Version 0.1.0 - .______ __ __ _______.___________. _______..______ __ - | _ \ | | | | / | | / || _ \ | | - | |_) | | | | | | (----`---| |----`| (----`| |_) || | - | / | | | | \ \ | | \ \ | _ < | | - | |\ \----.| `--' |.----) | | | .----) | | |_) || | - | _| `._____| \______/ |_______/ |__| |_______/ |______/ |__| - - [rustsbi] Platform: QEMU - [rustsbi] misa: RV64ACDFIMSU - [rustsbi] mideleg: 0x222 - [rustsbi] medeleg: 0xb1ab - [rustsbi] Kernel entry: 0x80200000 - # “优雅”地退出了。 - - - - - -清空 .bss 段 ----------------------------------- - -与内存相关的部分太容易出错了。所以,我们再仔细检查代码后,发现在嵌入式系统中常见的 **清零 .bss段** 的工作并没有完成。 - -.. note:: - - 由于一般应用程序的 ``.bss`` 段在程序正式开始运行之前会被执环境(系统库或操作系统内核)固定初始化为零,因此在 ELF 文件中,为了节省磁盘空间,只会记录 ``.bss`` 段的位置,且应用程序的假定在它执行前,其 ``.bss段`` 的数据内容都已是 ``全0`` 。 - 如果这块区域不是全零,且执行环境也没提前清零,那么会与应用的假定矛盾,导致程序出错。对于在裸机上执行的应用程序,其执行环境(就是QEMU模拟硬件+“三叶虫”操作系统内核)要负责将 ``.bss`` 所分配到的内存区域全部清零。 - -落实到我们正在实现的“三叶虫”操作系统内核,我们需要提供清零的 ``clear_bss()`` 函数。此函数属于执行环境,并在执行环境调用 -应用程序的 ``rust_main`` 主函数前,把 ``.bss`` 段的全局数据清零。 - -.. code-block:: rust - :linenos: - - // os/src/main.rs - fn clear_bss() { - extern "C" { - fn sbss(); - fn ebss(); - } - (sbss as usize..ebss as usize).for_each(|a| { - unsafe { (a as *mut u8).write_volatile(0) } - }); - } - -在程序内自己进行清零的时候,我们就不用去解析 ELF执行文件格式(此时也没有 ELF 执行文件可供解析)来定位 ``.bss`` 段的位置,而是通过链接脚本 ``linker.ld`` 中给出的全局符号 -``sbss`` 和 ``ebss`` 来确定 ``.bss`` 段的位置。 - - - -我们可以松一口气了。接下来,我们要让“三叶虫”操作系统实现“Hello, world”输出! - - -添加裸机环境中的打印相关函数 ----------------------------------------- - - - -与上一节为输出字符实现的代码片段相比,裸机应用的执行环境支持字符输出的代码改动会很小。这里的原因是,二者的区别仅仅是系统调用的参数格式和SBI调用的参数格式的不同。 -下面的代码是在基于上节有打印能力的用户态执行环境的基础上做的变动。 - -.. code-block:: rust - - const SBI_CONSOLE_PUTCHAR: usize = 1; - - pub fn console_putchar(c: usize) { - syscall(SBI_CONSOLE_PUTCHAR, [c, 0, 0]); - } - - impl Write for Stdout { - fn write_str(&mut self, s: &str) -> fmt::Result { - //sys_write(STDOUT, s.as_bytes()); - for c in s.chars() { - console_putchar(c as usize); - } - Ok(()) - } - } - - -可以看到主要就只是把之前的操作系统系统调用改为了SBI调用。然后我们再编译运行试试, - -.. code-block:: console - - $ cargo build - $ rust-objcopy --binary-architecture=riscv64 target/riscv64gc-unknown-none-elf/debug/os --strip-all -O binary target/riscv64gc-unknown-none-elf/debug/os.bin - $ qemu-system-riscv64 -machine virt -nographic -bios ../bootloader/rustsbi-qemu.bin -device loader,file=target/riscv64gc-unknown-none-elf/debug/os.bin,addr=0x80200000 - - [rustsbi] Version 0.1.0 - .______       __    __      _______.___________.  _______..______   __ - |   _  \     |  |  |  |    /       |           | /       ||   _  \ |  | - |  |_)  |    |  |  |  |   |   (----`---|  |----`|   (----`|  |_)  ||  | - |      /     |  |  |  |    \   \       |  |      \   \    |   _  < |  | - |  |\  \----.|  `--'  |.----)   |      |  |  .----)   |   |  |_)  ||  | - | _| `._____| \______/ |_______/       |__|  |_______/    |______/ |__| - - [rustsbi] Platform: QEMU - [rustsbi] misa: RV64ACDFIMSU - [rustsbi] mideleg: 0x222 - [rustsbi] medeleg: 0xb1ab - [rustsbi] Kernel entry: 0x80200000 - Hello, world! - -可以看到,在裸机上输出了 ``Hello, world!`` ,而且qemu正常退出,表示RISC-V计算机也正常关机了。 - - - -添加处理异常错误的功能 ----------------------------------- - -有bug的应用程序会有各种运行时的异常或错误,我们需要能够感知这些运行时的异常或错误,才能有效地修复应用程序的bug。当裸机执行环境支持显示输出能力后,我们就可以结合Rust语言和Rust的core库联合提供的异常处理功能,来让裸机支持环境具有可显示输出的异常处理能力。 - -接下来,我们就要实现这样的功能,来提高“三叶虫”操作系统处理异常的能力,即给异常处理函数 ``panic`` 增加显示字符串能力,帮助我们理解出现异常时的代码执行情况。主要修改内容如下: - -.. code-block:: rust - - // os/src/main.rs - #![feature(panic_info_message)] - - #[panic_handler] - fn panic(info: &PanicInfo) -> ! { - if let Some(location) = info.location() { - println!("Panicked at {}:{} {}", location.file(), location.line(), info.message().unwrap()); - } else { - println!("Panicked: {}", info.message().unwrap()); - } - shutdown() - } - -我们尝试从传入的 ``PanicInfo`` 中解析 panic 发生的文件和行数。如果解析成功的话,就和 panic 的报错信息一起打印出来。我们需要在 -``main.rs`` 开头加上 ``#![feature(panic_info_message)]`` 才能通过 ``PanicInfo::message`` 获取报错信息。 - -但我们在 ``main.rs`` 的 ``rust_main`` 函数中调用 ``panic!("It should shutdown!");`` 宏时,整个模拟执行的结果是: - -.. code-block:: console - - $ cargo build --release - $ rust-objcopy --binary-architecture=riscv64 target/riscv64gc-unknown-none-elf/release/os \ - --strip-all -O binary target/riscv64gc-unknown-none-elf/release/os.bin - $ qemu-system-riscv64 \ - -machine virt \ - -nographic \ - -bios ../bootloader/rustsbi-qemu.bin \ - -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000 - - [rustsbi] Version 0.1.0 - .______ __ __ _______.___________. _______..______ __ - | _ \ | | | | / | | / || _ \ | | - | |_) | | | | | | (----`---| |----`| (----`| |_) || | - | / | | | | \ \ | | \ \ | _ < | | - | |\ \----.| `--' |.----) | | | .----) | | |_) || | - | _| `._____| \______/ |_______/ |__| |_______/ |______/ |__| - - [rustsbi] Platform: QEMU - [rustsbi] misa: RV64ACDFIMSU - [rustsbi] mideleg: 0x222 - [rustsbi] medeleg: 0xb1ab - [rustsbi] Kernel entry: 0x80200000 - Hello, world! - Panicked at src/main.rs:95 It should shutdown! - -可以看到产生panic的地点在 ``main.rs`` 的第95行,与源码中的实际位置一致!到这里,我们基本上算是完成了第一章的实验内容, -除了实现支持应用程序在Linux操作系统上显示字符串的“恐龙虾”操作系统,还实现了支持应用程序在裸机上显示字符串的“三叶虫”操作系统。但也能看出,这些操作系统很脆弱,只能支持一个简单的易用,在本质上 -是一个提供方便服务接口的库,只是一个是运行在用户态的库,一个是运行在裸机上的库。 -在下一章,我们将进入“敏迷龙” [#minmi]_ 操作系统的设计与实现,来进化操作系统,并提升它的能力。 - - -.. note:: - - **Rust 小知识:错误处理** - - Rust 中常利用 ``Option`` 和 ``Result`` 进行方便的错误处理。它们都属于枚举结构: - - - ``Option`` 既可以有值 ``Option::Some`` ,也有可能没有值 ``Option::None``; - - ``Result`` 既可以保存某个操作的返回值 ``Result::Ok`` ,也可以表明操作过程中出现了错误 ``Result::Err`` 。 - - 我们可以使用 ``Option/Result`` 来保存一个不能确定存在/不存在或是成功/失败的值。之后可以通过匹配 ``if let`` 或是在能够确定 - 的场合直接通过 ``unwrap`` 将里面的值取出。详细的内容可以参考 Rust 官方文档。 - -.. [#minmi] 敏迷龙是第一种发现于南半球的小型甲龙亚目恐龙,生存于早白垩纪,约1亿1900万年前到1亿1300万年前。 \ No newline at end of file diff --git a/source/chapter1/old4understand-prog.rst b/source/chapter1/old4understand-prog.rst deleted file mode 100644 index 83c231e9..00000000 --- a/source/chapter1/old4understand-prog.rst +++ /dev/null @@ -1,380 +0,0 @@ -理解应用程序和执行环境 -================================== - -.. toctree:: - :hidden: - :maxdepth: 5 - - - -本节导读 -------------------------------- - -在前面几节,我们进行了大量的实验。接下来是要消化总结和归纳理解的时候了。 -本节主要会进一步归纳总结执行程序和执行环境相关的基础知识: - - - 物理内存与物理地址 - - 函数调用与栈 - - 调用规范 - - 程序内存布局 - - 执行环境 - -这些知识与编译原理和组成原理有直接的关系,而且同学理解操作系统的执行过程,会涉及到这些知识。如果同学们已经了解上述基础知识,可直接跳过本节,进入下一节学习。 - -.. _term-physical-address: -.. _term-physical-memory: - - -物理内存与物理地址 ----------------------------- -物理内存是计算机体系结构中一个重要的组成部分。在存储方面,CPU 唯一能够直接访问的只有物理内存中的数据,它可以通过访存指令来达到这一目的。 -从 CPU 的视角看来,可以将物理内存看成一个大字节数组,而物理地址则对应于一个能够用来访问数组中某个元素的下标。与我们日常编程习惯不同的 -是,该下标通常不以 0 开头,而通常以一个常数,如 ``0x80000000`` 开头。简言之, CPU 可以通过物理地址来寻址,并 **逐字节** 地访问物理内存中保存的 -数据。 - -值得一提的是,当 CPU 以多个字节(比如 2/4/8 或更多)为单位访问物理内存(事实上并不局限于物理内存,也包括I/O外设的数据空间)中的数据时,就有可能会引入端序(也称字节顺序)和 -内存地址对齐的问题。由于这并不是重点,我们在这里不展开说明。 - - -.. note:: - - **端序或尾序(英语:Endianness)** - - 端序或尾序(英语:Endianness),又称字节顺序。在计算机科学领域中,指电脑内存中或在数字通信链路中,多字节组成的字(Word)的字节(Byte)的排列顺序。字节的排列方式有两个通用规则。例如,将一个多位数的低位放在较小的地址处,高位放在较大的地址处,则称小端序(little-endian);反之则称大端序(big-endian)。常见的x86、RISC-V等处理器采用的是小端序。 - -.. note:: - - **内存地址对齐** - - 内存地址对齐是内存中的数据排列,以及CPU访问内存数据的方式,包含了基本数据对齐和结构体数据对齐的两部分。CPU在内存中读写数据是按字节块进行操作,理论上任意类型的变量访问可以从内存的任何地址开始,但在计算机系统中,CPU访问内存是通过数据总线(决定了每次读取的数据位数)和地址总线(决定了寻址范围)来进行的,基于计算机的物理组成和性能需求,CPU一般会要求访问内存数据的首地址的值为4 bit或者8 bit的整数倍。 - - 基本类型数据对齐是指数据在内存中的偏移地址必须为一个字的整数倍,这种存储数据的方式,可以提升系统在读取数据时的性能。结构体数据对齐,是指在结构体中的上一个数据域结束和下一个数据域开始的地方填充一些无用的字节,以保证每个数据域(假定是基本类型数据)都能够对齐(即按基本类型数据对齐)。 - - 对于RISC-V处理器而言,load/store指令进行数据访存时,数据在内存中的地址应该对齐。如果访存32位数据,内存地址应当按32位(4字节)对齐,。如果数据的地址没有对齐,执行访存操作将产生异常。这也是在学习内核编程中经常碰到的一种bug。 - - -.. _function-call-and-stack: - -函数调用与栈 ----------------------------- - -从汇编指令的级别看待一段程序的执行,假如 CPU 依次执行的指令的物理地址序列为 :math:`\{a_n\}`,那么这个序列会符合怎样的模式呢? - -.. _term-control-flow: - -其中最简单的无疑就是 CPU 一条条连续向下执行指令,也即满足递推公式 :math:`a_{n+1}=a_n+L`,这里我们假设该平台的指令是定长的且均为 -:math:`L` 字节(常见情况为 2/4 字节)。但是执行序列并不总是符合这种模式,当位于物理地址 :math:`a_n` 的指令是一条跳转指令的时候, -该模式就有可能被破坏。跳转指令对应于我们在程序中构造的 **控制流** (Control Flow) 的多种不同结构,比如分支结构(如 if/switch 语句) -和循环结构(如 for/while 语句)。用来实现上述两种结构的跳转指令,只需实现跳转功能,也就是将 pc 寄存器设置到一个指定的地址即可。 - -.. _term-function-call: - -另一种控制流结构则显得更为复杂: **函数调用** (Function Call)。我们大概清楚调用函数整个过程中代码执行的顺序,如果是从源代码级的 -视角来看,我们会去执行被调用函数的代码,等到它返回之后,我们会回到调用函数对应语句的下一行继续执行。那么我们如何用汇编指令来实现 -这一过程?首先在调用的时候,需要有一条指令跳转到被调用函数的位置,这个看起来和其他控制结构没什么不同;但是在被调用函数返回的时候,我们 -却需要返回那条跳转过来的指令的下一条继续执行。这次用来返回的跳转究竟跳转到何处,在对应的函数调用发生之前是不知道的。比如,我们在两个不同的 -地方调用同一个函数,显然函数返回之后会回到不同的地址。这是一个很大的不同:其他控制流都只需要跳转到一个 *编译期固定下来* 的地址,而函数调用 -的返回跳转是跳转到一个 *运行时确定* (确切地说是在函数调用发生的时候)的地址。 - - -.. image:: function-call.png - :align: center - :name: function-call - - -对此,指令集必须给用于函数调用的跳转指令一些额外的能力,而不只是单纯的跳转。在 RISC-V 架构上,有两条指令即符合这样的特征: - -.. list-table:: RISC-V 函数调用跳转指令 - :widths: 20 30 - :header-rows: 1 - :align: center - - * - 指令 - - 指令功能 - * - :math:`\text{jal}\ \text{rd},\ \text{imm}[20:1]` - - :math:`\text{rd}\leftarrow\text{pc}+4` - - :math:`\text{pc}\leftarrow\text{pc}+\text{imm}` - * - :math:`\text{jalr}\ \text{rd},\ (\text{imm}[11:0])\text{rs}` - - :math:`\text{rd}\leftarrow\text{pc}+4` - - :math:`\text{pc}\leftarrow\text{rs}+\text{imm}` - -.. _term-source-register: -.. _term-immediate: -.. _term-destination-register: - -.. note:: - - **RISC-V 指令各部分含义** - - 在大多数只与通用寄存器打交道的指令中, rs 表示 **源寄存器** (Source Register), imm 表示 **立即数** (Immediate), - 是一个常数,二者构成了指令的输入部分;而 rd 表示 **目标寄存器** (Destination Register),它是指令的输出部分。rs 和 rd - 可以在 32 个通用寄存器 x0~x31 中选取。但是这三个部分都不是必须的,某些指令只有一种输入类型,另一些指令则没有输出部分。 - - -.. _term-pseudo-instruction: - -从中可以看出,这两条指令在设置 pc 寄存器完成跳转功能之前,还将当前跳转指令的下一条指令地址保存在 rd 寄存器中,即 :math:`\text{rd}\leftarrow\text{pc}+4` 这条指令的含义。 -(这里假设所有指令的长度均为 4 字节) -在 RISC-V 架构中, -通常使用 ``ra`` 寄存器(即 ``x1`` 寄存器)作为其中的 ``rd`` 对应的具体寄存器,因此在函数返回的时候,只需跳转回 ``ra`` 所保存的地址即可。事实上在函数返回的时候我们常常使用一条 -**伪指令** (Pseudo Instruction) 跳转回调用之前的位置: ``ret`` 。它会被汇编器翻译为 ``jalr x0, 0(x1)``,含义为跳转到寄存器 -``ra`` 保存的物理地址,由于 ``x0`` 是一个恒为 ``0`` 的寄存器,在 ``rd`` 中保存这一步被省略。 - -总结一下,在进行函数调用的时候,我们通过 ``jalr`` 指令 -保存返回地址并实现跳转;而在函数即将返回的时候,则通过 ``ret`` 伪指令回到跳转之前的下一条指令继续执行。这样,RISC-V的这两条指令就实现了函数调用流程的核心机制。 - -由于我们是在 ``ra`` 寄存器中保存返回地址的,我们要保证它在函数执行的全程不发生变化,不然在 ``ret`` 之后就会跳转到错误的位置。事实上编译器 -除了函数调用的相关指令之外确实基本上不使用 ``ra`` 寄存器。也就是说,如果在函数中没有调用其他函数,那 ``ra`` 的值不会变化,函数调用流程 -能够正常工作。但遗憾的是,在实际编写代码的时候我们常常会遇到函数 **多层嵌套调用** 的情形。我们很容易想象,如果函数不支持嵌套调用,那么编程将会 -变得多么复杂。如果我们试图在一个函数 :math:`f` 中调用一个子函数,在跳转到子函数 :math:`g` 的同时,ra 会被覆盖成这条跳转指令的 -下一条的地址,而 ra 之前所保存的函数 :math:`f` 的返回地址将会 `永久丢失` 。 - -.. _term-function-context: -.. _term-activation-record: - -因此,若想正确实现嵌套函数调用的控制流,我们必须通过某种方式保证:在一个函数调用子函数的前后,``ra`` 寄存器的值不能发生变化。但实际上, -这并不仅仅局限于 ``ra`` 一个寄存器,而是作用于所有的通用寄存器。这是因为,编译器是独立编译每个函数的,因此一个函数并不能知道它所调用的 -子函数修改了哪些寄存器。而站在一个函数的视角,在调用子函数的过程中某些寄存器的值被覆盖的确会对它接下来的执行产生影响。因此这是必要的。 -我们将由于函数调用,在控制流转移前后需要保持不变的寄存器集合称之为 **函数调用上下文** (Function Call Context) 。 - -.. _term-save-restore: - -由于每个 CPU 只有一套寄存器,我们若想在子函数调用前后保持函数调用上下文不变,就需要物理内存的帮助。确切的说,在调用子函数之前,我们需要在 -物理内存中的一个区域 **保存** (Save) 函数调用上下文中的寄存器;而在函数执行完毕后,我们会从内存中同样的区域读取并 **恢复** (Restore) 函数调用上下文 -中的寄存器。实际上,这一工作是由子函数的调用者和被调用者(也就是子函数自身)合作完成。函数调用上下文中的寄存器被分为如下两类: - -.. _term-callee-saved: -.. _term-caller-saved: - -- **被调用者保存(Callee-Saved) 寄存器** :被调用的函数可能会覆盖这些寄存器,需要被调用的函数来保存的寄存器,即由被调用的函数来保证在调用前后,这些寄存器保持不变; -- **调用者保存(Caller-Saved) 寄存器** :被调用的函数可能会覆盖这些寄存器,需要发起调用的函数来保存的寄存器,即由发起调用的函数来保证在调用前后,这些寄存器保持不变。 - -从名字中可以看出,函数调用上下文由调用者和被调用者分别保存,其具体过程分别如下: - -- 调用函数:首先保存不希望在函数调用过程中发生变化的 **调用者保存寄存器** ,然后通过 jal/jalr 指令调用子函数,返回之后恢复这些寄存器。 -- 被调用函数:在被调用函数的起始,先保存函数执行过程中被用到的 **被调用者保存寄存器** ,然后执行函数,最后在函数退出之前恢复这些寄存器。 - -.. _term-prologue: -.. _term-epilogue: - -我们发现无论是调用函数还是被调用函数,都会因调用行为而需要两段匹配的保存和恢复寄存器的汇编代码,可以分别将其称为 **开场** (Prologue) 和 -**结尾** (Epilogue),它们会由编译器帮我们自动插入,来完成相关寄存器的保存与恢复。一个函数既有可能作为调用者调用其他函数,也有可能作为被调用者被其他函数调用。 - - -.. chyyuu - - 对于这个函数而言,如果在执行的时候需要修改被调用者保存寄存器,而必须在函数开头的开场和结尾处进行保存;对于调用者保存寄存器则可以没有任何顾虑的随便使用,因为它在约定中本就不需要承担保证调用者保存寄存器保持不变的义务。 - -.. note:: - - **寄存器保存与编译器优化** - - 这里值得说明的是,调用者和被调用者实际上只需分别按需保存调用者保存寄存器和被调用者保存寄存器的一个子集。对于调用函数而言,在调用子函数的时候,即使子函数修改了调用者保存寄存器,编译器在调用函数中插入的代码会恢复这些寄存器;而对于被调用函数而言,在其执行过程中没有 - 使用到的被调用者保存寄存器也无需保存。编译器在进行后端代码生成时,知道在这两个场景中分别有哪些值得保存的寄存器。 - 从这一角度也可以理解为何要将函数调用上下文分成两类:可以让编译器尽可能早地优化掉一些无用的寄存器保存与恢复操作,提高程序的执行性能。 - -.. chyyuu 最好有个例子说明 - -.. _term-calling-convention: - - -调用规范 ----------------- - - -**调用规范** (Calling Convention) 约定在某个指令集架构上,某种编程语言的函数调用如何实现。它包括了以下内容: - -1. 函数的输入参数和返回值如何传递; -2. 函数调用上下文中调用者/被调用者保存寄存器的划分; -3. 其他的在函数调用流程中对于寄存器的使用方法。 - -调用规范是对于一种确定的编程语言来说的,因为一般意义上的函数调用只会在编程语言的内部进行。当一种语言想要调用用另一门编程语言编写的函数 -接口时,编译器就需要同时清楚两门语言的调用规范,并对寄存器的使用做出调整。 - -.. note:: - - **RISC-V 架构上的 C 语言调用规范** - - RISC-V 架构上的 C 语言调用规范可以在 `这里 `_ 找到。 - 它对通用寄存器的使用做出了如下约定: - - .. list-table:: RISC-V 寄存器功能分类 - :widths: 20 20 40 - :align: center - :header-rows: 1 - - * - 寄存器组 - - 保存者 - - 功能 - * - a0~a7( ``x10~x17`` ) - - 调用者保存 - - 用来传递输入参数。其中的 a0 和 a1 还用来保存返回值。 - * - t0~t6( ``x5~x7,x28~x31`` ) - - 调用者保存 - - 作为临时寄存器使用,在被调函数中可以随意使用无需保存。 - * - s0~s11( ``x8~x9,x18~x27`` ) - - 被调用者保存 - - 作为临时寄存器使用,被调函数保存后才能在被调函数中使用。 - - 剩下的 5 个通用寄存器情况如下: - - - zero( ``x0`` ) 之前提到过,它恒为零,函数调用不会对它产生影响; - - ra( ``x1`` ) 是调用者保存的,不过它并不会在每次调用子函数的时候都保存一次,而是在函数的开头和结尾保存/恢复即可。虽然 ``ra`` 看上去和其它被调用者保存寄存器保存的位置一样,但是它确实是调用者保存的。 - - sp( ``x2`` ) 是被调用者保存的。这个是之后就会提到的栈指针 (Stack Pointer) 寄存器。 - - fp( ``s0`` ),它既可作为s0临时寄存器,也可作为栈帧指针(Frame Pointer)寄存器,表示当前栈帧的起始位置,是一个被调用者保存寄存器。 - - gp( ``x3`` ) 和 tp( ``x4`` ) 在一个程序运行期间都不会变化,因此不必放在函数调用上下文中。它们的用途在后面的章节会提到。 - - 更加详细的内容可以参考 Cornell 大学的 `课件内容 `_ 。 - -.. _term-stack: -.. _term-stack-pointer: -.. _term-stack-frame: - -之前我们讨论了函数调用上下文的保存/恢复时机以及寄存器的选择,但我们并没有详细说明这些寄存器保存在哪里,只是用“内存中的一块区域”草草带过。实际上, -它更确切的名字是 **栈** (Stack) 。 ``sp`` 寄存器常用来保存 **栈指针** (Stack Pointer),它指向内存中栈顶地址。在 -RISC-V 架构中,栈是从高地址向低地址增长的。在一个函数中,作为起始的开场代码负责分配一块新的栈空间,即将 ``sp`` -的值减小相应的字节数即可,于是物理地址区间 :math:`[\text{新sp},\text{旧sp})` 对应的物理内存的一部分便可以被这个函数用来进行函数调用上下文的保存/恢复 -,这块物理内存被称为这个函数的 **栈帧** (Stackframe)。同理,函数中的结尾代码负责将开场代码分配的栈帧回收,这也仅仅需要 -将 ``sp`` 的值增加相同的字节数回到分配之前的状态。这也可以解释为什么 ``sp`` 是一个被调用者保存寄存器。 - - -.. note:: - - **栈帧 stack frame** - - 我们知道程序在执行函数调用时,调用函数和被调用函数使用的是同一个栈。在通常的情况下,我们并不需要区分调用函数函数和被调用函数分别使用了栈的哪个部分。但是,当我们需要在执行过程中对函数调用进行调试或backtrace的时候,这一信息就很重要了。简单的说,栈帧(stack frame)就是一个函数所使用的栈的一部分区域,所有函数的的栈帧串起来就组成了一个完整的栈。一般而言,当前执行函数的栈帧的两个边界分别由栈指针 (Stack Pointer)寄存器和栈帧指针(frame pointer)寄存器来限定。 - - - -.. figure:: CallStack.png - :align: center - - 函数调用与栈帧:如图所示,我们能够看到在程序依次调用 a、调用 b、调用 c、c 返回、b 返回整个过程中栈帧的分配/回收以及 ``sp`` 寄存器的变化。 - 图中标有 a/b/c 的块分别代表函数 a/b/c 的栈帧。 - -.. _term-lifo: - -.. note:: - - **数据结构中的栈与实现函数调用所需要的栈** - - 从数据结构的角度来看,栈是一个 **后入先出** (Last In First Out, LIFO) 的线性表,支持向栈顶压入一个元素以及从栈顶弹出一个元素 - 两种操作,分别被称为 push 和 pop。从它提供的接口来看,它只支持访问栈顶附近的元素。因此在实现的时候需要维护一个指向栈顶 - 的指针来表示栈当前的状态。 - - 我们这里的栈与数据结构中的栈原理相同,在很多方面可以一一对应。栈指针 ``sp`` 可以对应到指向栈顶的指针,对于栈帧的分配/回收可以分别 - 对应到 ``push`` / ``pop`` 操作。如果将我们的栈看成一个内存分配器,它之所以可以这么简单,是因为它回收的内存一定是 *最近一次分配* 的内存, - 从而只需要类似 ``push`` / ``pop`` 的两种操作即可。 - -在合适的编译选项设置之下,一个函数的栈帧内容可能如下图所示: - -.. figure:: StackFrame.png - :align: center - - 函数栈帧中的内容 - -它的开头和结尾分别在 sp(x2) 和 fp(s0) 所指向的地址。按照地址从高到低分别有以下内容,它们都是通过 ``sp`` 加上一个偏移量来访问的: - -- ``ra`` 寄存器保存其返回之后的跳转地址,是一个调用者保存寄存器; -- 父亲栈帧的结束地址 ``fp`` ,是一个被调用者保存寄存器; -- 其他被调用者保存寄存器 ``s1`` ~ ``s11`` ; -- 函数所使用到的局部变量。 - -因此,栈上多个 ``fp`` 信息实际上保存了一条完整的函数调用链,通过适当的方式我们可以实现对函数调用关系的跟踪。 - -至此,我们基本上说明了函数调用是如何基于栈来实现的。不过我们可以暂时先忽略掉这些细节,因为我们现在只是需要在初始化阶段完成栈的设置,也就是 -设置好栈指针 ```sp`` 寄存器,编译器会帮我们自动完成后面的函数调用相关机制的代码生成。麻烦的是, ``sp`` 的值也不能随便设置,至少我们需要保证它指向合法的物理内存, -而且不能与程序的其他代码、数据段相交,因为在函数调用的过程中,栈区域里面的内容会被修改。如何保证这一点呢?此外,之前我们还提到我们编写的 -初始化代码必须放在物理地址 ``0x80020000`` 开头的内存上,这又如何做到呢?事实上,这两点都需要我们接下来讲到的程序内存布局的知识。 - -程序内存布局 ----------------------------- - -.. _term-section: -.. _term-memory-layout: - -在我们将源代码编译为可执行文件之后,它就会变成一个看似充满了杂乱无章的字节的一个文件。但我们知道这些字节至少可以分成代码和数据两部分,在 -程序运行起来的时候它们的功能并不相同:代码部分由一条条可以被 CPU 解码并执行的指令组成,而数据部分只是被 CPU 视作可读写的内存空间。事实上 -我们还可以根据其功能进一步把两个部分划分为更小的单位: **段** (Section) 。不同的段会被编译器放置在内存不同的位置上,这构成了程序的 -**内存布局** (Memory Layout)。一种典型的程序相对内存布局如下所示: - -.. figure:: MemoryLayout.png - :align: center - - 一种典型的程序相对内存布局 - -在上图中可以看到,代码部分只有代码段 ``.text`` 一个段,存放程序的所有汇编代码。而数据部分则还可以继续细化: - -.. _term-heap: - -- 已初始化数据段保存程序中那些已初始化的全局数据,分为 ``.rodata`` 和 ``.data`` 两部分。前者存放只读的全局数据,通常是一些常数或者是 - 常量字符串等;而后者存放可修改的全局数据。 -- 未初始化数据段 ``.bss`` 保存程序中那些未初始化的全局数据,通常由程序的加载者代为进行零初始化,即将这块区域逐字节清零; -- **堆** (heap)区域用来存放程序运行时动态分配的数据,如 C/C++ 中的 malloc/new 分配到的数据本体就放在堆区域,它向高地址增长; -- **栈** (stack)区域不仅用作函数调用上下文的保存与恢复,每个函数作用域内的局部变量也被编译器放在它的栈帧内,它向低地址增长。 - -.. note:: - - **局部变量与全局变量** - - 在一个函数的视角中,它能够访问的变量包括以下几种: - - - 函数的输入参数和局部变量:保存在一些寄存器或是该函数的栈帧里面,如果是在栈帧里面的话是基于当前 sp 加上一个偏移量来访问的; - - 全局变量:保存在数据段 ``.data`` 和 ``.bss`` 中,某些情况下 gp(x3) 寄存器保存两个数据段中间的一个位置,于是全局变量是基于 - gp 加上一个偏移量来访问的。 - - 堆上的动态变量:本体被保存在堆上,大小在运行时才能确定。而我们只能 *直接* 访问栈上或者全局数据段中的 **编译期确定大小** 的变量。 - 因此我们需要通过一个运行时分配内存得到的一个指向堆上数据的指针来访问它,指针的位宽确实在编译期就能够确定。该指针即可以作为局部变量 - 放在栈帧里面,也可以作为全局变量放在全局数据段中。 - -我们可以将常说的编译流程细化为多个阶段(虽然输入一条命令便可将它们全部完成): - -.. _term-compiler: -.. _term-assembler: -.. _term-linker: -.. _term-object-file: - -1. **编译器** (Compiler) 将每个源文件从某门高级编程语言转化为汇编语言,注意此时源文件仍然是一个 ASCII 或其他编码的文本文件; -2. **汇编器** (Assembler) 将上一步的每个源文件中的文本格式的指令转化为机器码,得到一个二进制的 **目标文件** (Object File); -3. **链接器** (Linker) 将上一步得到的所有目标文件以及一些可能的外部目标文件链接在一起形成一个完整的可执行文件。 - -每个目标文件都有着自己局部的内存布局,里面含有若干个段。在链接的时候,链接器会将这些内存布局合并起来形成一个整体的内存布局。此外,每个目标文件 -都有一个符号表,里面记录着它需要从其他文件中寻找的外部符号和能够提供给其他文件的符号,通常是一些函数和全局变量等。在链接的时候汇编器会将 -外部符号替换为实际的地址。 - - -.. note:: - - 本节内容部分参考自: - - - `RISC-V C 语言调用规范 `_ - - `Notes from Cornell CS3410 2019Spring `_ - - `Lecture from Berkeley CS61C 2018Spring `_ - - `Lecture from MIT 6.828 2020 `_ - - -当同学读完本节后,建议再回头看看前面几节中涉及到的执行程序,通过各种工具静态和动态地分析一下这些函数在函数调用、内存布局、执行环境上,是否与本节讲的内容是一致的,从而能对应用程序的执行环境与操作系统的关系有更加全面的理解。 - - - -.. chyyuu embedded system history - - https://www.guru99.com/embedded-systems-tutorial.html#4 - - History of Embedded system - Here, are important milestones from the history of embedded system: - - In 1960, embdded system was first used for developing Apollo Guidance System by Charles Stark Draper at MIT. - In 1965, Autonetics, developed the D-17B, the computer used in the Minuteman missile guidance system. - In 1968, the first embedded system for a vehicle was released. - Texas Instruments developed the first microcontroller in 1971. - In 1987, the first embedded OS, VxWorks, was released by Wind River. - Microsoft’s Windows embedded CE in 1996. - By the late 1990s, the first embedded Linux system appeared. - The embedded market reach $140 billion in 2013. - Analysts are projecting an Embedded market larger than $40 billion by 2030. - - Apollo 制导计算机:历史 https://history-computer.com/apollo-guidance-computer/ https://www.universetoday.com/142897/the-story-of-the-apollo-guidance-computer-part-1/ https://www.universetoday.com /143102/the-story-of-the-apollo-guidance-computer-part-2/ https://en.wikipedia.org/wiki/Apollo_Guidance_Computer (讲解了软件部分,J. Halcombe Laning设计了一个简单的实时操作系统,) https://github.com/chrislgarry/Apollo-11 - - 1955 MIT's Tape Director operating system made for UNIVAC 1103[2][3] https://web.archive.org/web/20150923211552/http://www.csail.mit.edu/timeline/timeline.php/timeline.php?query=event&id=3 \ No newline at end of file diff --git a/source/chapter2/0intro.rst b/source/chapter2/0intro.rst index 88499e40..660b9a41 100644 --- a/source/chapter2/0intro.rst +++ b/source/chapter2/0intro.rst @@ -230,7 +230,7 @@ 相比于上一章的两个简单操作系统,本章的操作系统有两个最大的不同之处,一个是操作系统自身运行在内核态,且支持应用程序在用户态运行,且能完成应用程序发出的系统调用;另一个是能够一个接一个地自动运行不同的应用程序。所以,我们需要对操作系统和应用程序进行修改,也需要对应用程序的编译生成过程进行修改。 -首先改进应用程序,让它能够在用户态执行,并能发出系统调用。这其实就是上一章中 :ref:`构建用户态执行环境 ` 小节介绍内容的进一步改进。具体而言,编写多个应用小程序,修改编译应用所需的 ``linker.ld`` 文件来 :ref:`调整程序的内存布局 ` ,让操作系统能够把应用加载到指定内存地址,然后顺利启动并运行应用程序。 +首先改进应用程序,让它能够在用户态执行,并能发出系统调用。具体而言,编写多个应用小程序,修改编译应用所需的 ``linker.ld`` 文件来 :ref:`调整程序的内存布局 ` ,让操作系统能够把应用加载到指定内存地址,然后顺利启动并运行应用程序。 在应用程序的运行过程中,操作系统要支持应用程序的输出功能,并还能支持应用程序退出。这需要实现跨特权级的系统调用接口,以及 ``sys_write`` 和 ``sys_exit`` 等具体的系统调用功能。 在具体设计实现上,涉及到内联汇编的编写,以及应用与操作系统内核之间系统调用的参数传递的约定。为了让应用程序在还没实现 ``邓氏鱼`` 操作系统之前就能在Linux for RISC-V 64 上进行运行测试,我们采用了Linux on RISC-V64 的系统调用参数约定。具体实现可参看 :ref:`系统调用 ` 小节中的内容。 这样写完应用小例子后,就可以通过 ``qemu-riscv64`` 模拟器进行测试了。 diff --git a/source/chapter2/1rv-privilege.rst b/source/chapter2/1rv-privilege.rst index f1f995f9..64de6e62 100644 --- a/source/chapter2/1rv-privilege.rst +++ b/source/chapter2/1rv-privilege.rst @@ -96,7 +96,6 @@ RISC-V 架构中一共定义了 4 种特权级: 在特权级相关机制方面,本书正文中我们重点关心 RISC-V 的 S/U 特权级, M 特权级的机制细节则是作为可选内容在 :doc:`/appendix-c/index` 中讲解,有兴趣的同学可以参考。 -.. _term-ecf: .. _term-trap: 执行环境的另一种功能是对上层软件的执行进行监控管理。监控管理可以理解为,当上层软件执行的时候出现了一些异常或特殊情况,导致需要用到执行环境中提供的功能,因此需要暂停上层软件的执行,转而运行执行环境的代码。由于上层软件和执行环境被设计为运行在不同的特权级,这个过程也往往(而 **不一定** )伴随着 CPU 的 **特权级切换** 。当执行环境的代码运行结束后,我们需要回到上层软件暂停的位置继续执行。在 RISC-V 架构中,这种与常规控制流(顺序、循环、分支、函数调用)不同的 **异常控制流** (ECF, Exception Control Flow) 被称为 **异常(Exception)** ,是 RISC-V 语境下的 Trap 种类之一。 diff --git a/source/chapter3/2task-switching.rst b/source/chapter3/2task-switching.rst index a8085e1f..779a997b 100644 --- a/source/chapter3/2task-switching.rst +++ b/source/chapter3/2task-switching.rst @@ -40,7 +40,7 @@ 在控制流切换过程中,我们需要结合硬件机制和软件实现来保存和恢复任务上下文。任务的一次切换涉及到被换出和即将被换入的两条控制流(分属两个应用的不同任务),通常它们都需要共同遵循某些约定来合作完成这一过程。在前两章,我们已经看到了两种上下文保存/恢复的实例。让我们再来回顾一下它们: -- 第一章“RV64 裸机应用”中,我们介绍了 :ref:`函数调用与栈 ` 。当时提到过,为了支持嵌套函数调用,不仅需要硬件平台提供特殊的跳转指令,还需要保存和恢复 :ref:`函数调用上下文 ` 。注意在上述定义中,函数调用包含在普通控制流(与异常控制流相对)之内,且始终用一个固定的栈来保存执行的历史记录,因此函数调用并不涉及控制流的特权级切换。但是我们依然可以将其看成调用者和被调用者两个执行过程的“切换”,二者的协作体现在它们都遵循调用规范,分别保存一部分通用寄存器,这样的好处是编译器能够有足够的信息来尽可能减少需要保存的寄存器的数目。虽然当时用了很大的篇幅来说明,但其实整个过程都是编译器负责完成的,我们只需设置好栈就行了。 +- 第一章“应用程序与基本执行环境”中,我们介绍了 :ref:`函数调用与栈 ` 。当时提到过,为了支持嵌套函数调用,不仅需要硬件平台提供特殊的跳转指令,还需要保存和恢复 :ref:`函数调用上下文 ` 。注意在上述定义中,函数调用包含在普通控制流(与异常控制流相对)之内,且始终用一个固定的栈来保存执行的历史记录,因此函数调用并不涉及控制流的特权级切换。但是我们依然可以将其看成调用者和被调用者两个执行过程的“切换”,二者的协作体现在它们都遵循调用规范,分别保存一部分通用寄存器,这样的好处是编译器能够有足够的信息来尽可能减少需要保存的寄存器的数目。虽然当时用了很大的篇幅来说明,但其实整个过程都是编译器负责完成的,我们只需设置好栈就行了。 - 第二章“批处理系统”中第一次涉及到了某种异常(Trap)控制流,即两条控制流的特权级切换,需要保存和恢复 :ref:`系统调用(Trap)上下文 ` 。当时,为了让内核能够 *完全掌控* 应用的执行,且不会被应用破坏整个系统,我们必须利用硬件提供的特权级机制,让应用和内核运行在不同的特权级。应用运行在 U 特权级,它所被允许的操作进一步受限,处处被内核监督管理;而内核运行在 S 特权级,有能力处理应用执行过程中提出的请求或遇到的状况。 应用程序与操作系统打交道的核心在于硬件提供的 Trap 机制,也就是在 U 特权级运行的应用控制流和在 S 特权级运行的 Trap 控制流(操作系统的陷入处理部分)之间的切换。Trap 控制流是在 Trap 触发的一瞬间生成的,它和原应用控制流有着很密切的联系,因为它几乎唯一的目标就是处理 Trap 并恢复到原应用控制流。而且,由于 Trap 机制对于应用来说几乎是透明的,所以基本上都是 Trap 控制流在“负重前行”。Trap 控制流需要把 Trap 上下文(即几乎所有的通用寄存器)保存在自己的内核栈上,因为在 Trap 处理过程中所有的通用寄存器都可能被用到。可以回看 :ref:`Trap 上下文保存与恢复 ` 小节。 diff --git a/source/chapter4/7more-as.rst b/source/chapter4/7more-as.rst index edab66af..b58018b6 100644 --- a/source/chapter4/7more-as.rst +++ b/source/chapter4/7more-as.rst @@ -271,7 +271,7 @@ Clock置换策略 理解工作集置换策略的前提是先理解工作集的定义。一个任务当前正在使用的页面集合称为它的工作集(working set,也称驻留集合)。如果整个工作集都被装入到了内存中,那么任务在运行到下一运行阶段之前,一般不会产生很多缺页中断。若内存太小而无法容纳下整个工作集,那么任务在后续运行过程中可能会产生大量的缺页中断,导致执行变慢。 -注意,上述工作集的概念和对缺页中断的推断,其实是建立在程序执行具有局部性这个基础上的,也是一种根据历史来推测未来的启发式方法。为了基于工作集的特征来设计置策略,我们需要量化工作集的概念。在t时刻,任务最近n次内存访问均发生在m个页面集合上,那么这个页面集合就是任务在t时刻最近k次内存访问下的工作集,用 w(k,t) 来表示。工作集中页面数量用 |w(k,t)| 表示。 随着任务的执行,工作集中的页面会发生变化,其数量也会发生变化。如果一个任务占用的页面数与其工作集大小相等或超过工作集,则该任务可在一段时间内不会发生缺页异常。如果其在内存的页面数小于工作集,则发生缺页中断的频率将增加。 +注意,上述工作集的概念和对缺页中断的推断,其实是建立在程序执行具有局部性这个基础上的,也是一种根据历史来推测未来的启发式方法。为了基于工作集的特征来设计置策略,我们需要量化工作集的概念。在t时刻,任务最近n次内存访问均发生在m个页面集合上,那么这个页面集合就是任务在t时刻最近k次内存访问下的工作集,用 ``w(k,t)`` 来表示。工作集中页面数量用 ``|w(k,t)|`` 表示。 随着任务的执行,工作集中的页面会发生变化,其数量也会发生变化。如果一个任务占用的页面数与其工作集大小相等或超过工作集,则该任务可在一段时间内不会发生缺页异常。如果其在内存的页面数小于工作集,则发生缺页中断的频率将增加。 所以,工作集置换策略的目标就是动态调整工作集的内容和大小,一个任务占用的页面数接近其工作集大小,减少缺页异常次数。 diff --git a/source/chapter4/9answer.rst b/source/chapter4/9answer.rst index dbe48d70..858ff430 100644 --- a/source/chapter4/9answer.rst +++ b/source/chapter4/9answer.rst @@ -83,38 +83,38 @@ 15. `**` 缺页指的是进程访问页面时页面不在页表中或在页表中无效的现象,此时 MMU 将会返回一个中断,告知操作系统:该进程内存访问出了问题。然后操作系统可选择填补页表并重新执行异常指令或者杀死进程。操作系统基于缺页异常进行优化的两个常见策略中,其一是 Lazy 策略,也就是直到内存页面被访问才实际进行页表操作。比如,一个程序被执行时,进程的代码段理论上需要从磁盘加载到内存。但是 操作系统并不会马上这样做,而是会保存 .text 段在磁盘的位置信息,在这些代码第一次被执行时才完成从磁盘的加载操作。 另一个常见策略是 swap 页置换策略,也就是内存页面可能被换到磁盘上了,导致对应页面失效,操作系统在任务访问到该页产生异常时,再把数据从磁盘加载到内存。 - - 哪些异常可能是缺页导致的?发生缺页时,描述与缺页相关的CSR寄存器的值及其含义。 + 1. 哪些异常可能是缺页导致的?发生缺页时,描述与缺页相关的CSR寄存器的值及其含义。 - - 答案:`mcause`寄存器中会保存发生中断异常的原因,其中`Exception Code`为`12`时发生指令缺页异常,为`15`时发生`store/AMO`缺页异常,为`13`时发生`load`缺页异常。 - - CSR寄存器: - - - `scause`: 中断/异常发生时,`CSR`寄存器`scause`中会记录其信息,`Interrupt`位记录是中断还是异常,`Exception Code`记录中断/异常的种类。 - - `sstatus`: 记录处理器当前状态,其中`SPP`段记录当前特权等级。 - - `stvec`: 记录处理`trap`的入口地址,现有两种模式`Direct`和`Vectored`。 - - `sscratch`: 其中的值是指向hart相关的S态上下文的指针,比如内核栈的指针 - - `sepc`: `trap`发生时会将当前指令的下一条指令地址写入其中,用于`trap`处理完成后返回。 - - `stval`: `trap`发生进入S态时会将异常信息写入,用于帮助处理`trap`,其中会保存导致缺页异常的虚拟地址 + - 答案: `mcause` 寄存器中会保存发生中断异常的原因,其中 `Exception Code` 为 `12` 时发生指令缺页异常,为 `15` 时发生 `store/AMO` 缺页异常,为 `13` 时发生 `load` 缺页异常。 + + CSR寄存器: + + - `scause`: 中断/异常发生时, `CSR` 寄存器 `scause` 中会记录其信息, `Interrupt` 位记录是中断还是异常, `Exception Code` 记录中断/异常的种类。 + - `sstatus`: 记录处理器当前状态,其中 `SPP` 段记录当前特权等级。 + - `stvec`: 记录处理 `trap` 的入口地址,现有两种模式 `Direct` 和 `Vectored` 。 + - `sscratch`: 其中的值是指向hart相关的S态上下文的指针,比如内核栈的指针。 + - `sepc`: `trap` 发生时会将当前指令的下一条指令地址写入其中,用于 `trap` 处理完成后返回。 + - `stval`: `trap` 发生进入S态时会将异常信息写入,用于帮助处理 `trap` ,其中会保存导致缺页异常的虚拟地址。 - - Lazy 策略有哪些好处?请描述大致如何实现Lazy策略? + 2. Lazy 策略有哪些好处?请描述大致如何实现Lazy策略? - 答案:Lazy策略一定不会比直接加载策略慢,并且可能会提升性能,因为可能会有些页面被加载后并没有进行访问就被释放或替代了,这样可以避免很多无用的加载。分配内存时暂时不进行分配,只是将记录下来,访问缺页时会触发缺页异常,在`trap handler`中处理相应的异常,在此时将内存加载或分配即可。 - - swap 页置换策略有哪些好处?此时页面失效如何表现在页表项(PTE)上?请描述大致如何实现swap策略? + 3. swap 页置换策略有哪些好处?此时页面失效如何表现在页表项(PTE)上?请描述大致如何实现swap策略? - 答案:可以为用户程序提供比实际物理内存更大的内存空间。页面失效会将标志位`V`置为`0`。将置换出的物理页面保存在磁盘中,在之后访问再次触发缺页异常时将该页面写入内存。 16. `**` 为了防范侧信道攻击,本章的操作系统使用了双页表。但是传统的操作系统设计一般采用单页表,也就是说,任务和操作系统内核共用同一张页表,只不过内核对应的地址只允许在内核态访问。(备注:这里的单/双的说法仅为自创的通俗说法,并无这个名词概念,详情见 `KPTI `_ ) - - 单页表情况下,如何控制用户态无法访问内核页面? + 1. 单页表情况下,如何控制用户态无法访问内核页面? - 答案:将内核页面的 pte 的`U`标志位设置为0。 - - 相对于双页表,单页表有何优势? + 2. 相对于双页表,单页表有何优势? - 答案:在内核和用户态之间转换时不需要更换页表,也就不需要跳板,可以像之前一样直接切换上下文。 - - 请描述:在单页表和双页表模式下,分别在哪个时机,如何切换页表? + 3. 请描述:在单页表和双页表模式下,分别在哪个时机,如何切换页表? - 答案:双页表实现下用户程序和内核转换时、用户程序转换时都需要更换页表,而对于单页表操作系统,不同用户线程切换时需要更换页表。 diff --git a/source/chapter4/index.rst b/source/chapter4/index.rst index ece4aeb1..8b2a0776 100644 --- a/source/chapter4/index.rst +++ b/source/chapter4/index.rst @@ -1,3 +1,5 @@ +.. _link-chapter4: + 第四章:地址空间 ============================================== diff --git a/source/chapter5/2core-data-structures.rst b/source/chapter5/2core-data-structures.rst index 1d57d75f..8c20dd35 100644 --- a/source/chapter5/2core-data-structures.rst +++ b/source/chapter5/2core-data-structures.rst @@ -134,7 +134,7 @@ ------------------------------------------------------------------------ 进程标识符 -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 同一时间存在的所有进程都有一个唯一的进程标识符,它们是互不相同的整数,这样才能表示表示进程的唯一性。这里我们使用 RAII 的思想,将其抽象为一个 ``PidHandle`` 类型,当它的生命周期结束后对应的整数会被编译器自动回收: @@ -209,7 +209,7 @@ } 内核栈 -~~~~~~~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 在前面的章节中我们介绍过 :ref:`内核地址空间布局 ` ,当时我们将每个应用的内核栈按照应用编号从小到大的顺序将它们作为逻辑段从高地址到低地址放在内核地址空间中,且两两之间保留一个守护页面使得我们能够尽可能早的发现内核栈溢出问题。从本章开始,我们将应用编号替换为进程标识符。我们可以在内核栈 ``KernelStack`` 中保存着它所属进程的 PID : diff --git a/source/chapter5/index.rst b/source/chapter5/index.rst index e773cbe9..d7ef874a 100644 --- a/source/chapter5/index.rst +++ b/source/chapter5/index.rst @@ -1,3 +1,5 @@ +.. _link-chapter5: + 第五章:进程 ============================================== diff --git a/source/chapter6/3using-easy-fs-in-kernel.rst b/source/chapter6/3using-easy-fs-in-kernel.rst index 7e9ef437..14d09d4b 100644 --- a/source/chapter6/3using-easy-fs-in-kernel.rst +++ b/source/chapter6/3using-easy-fs-in-kernel.rst @@ -416,7 +416,8 @@ K210 真实硬件平台 应用访问文件的内核机制实现 ----------------------------------------------- -应用程序在访问文件之前,首先需要完成对文件系统的初始化和加载。这可以通过操作系统来完成,也可以让应用程序发出文件系统相关的系统调用(如 ``mount``等)来完成。我们这里的选择是让操作系统直接完成。 + +应用程序在访问文件之前,首先需要完成对文件系统的初始化和加载。这可以通过操作系统来完成,也可以让应用程序发出文件系统相关的系统调用(如 ``mount`` 等)来完成。我们这里的选择是让操作系统直接完成。 应用程序如果要基于文件进行I/O访问,大致就会涉及如下一些系统调用: diff --git a/source/chapter6/index.rst b/source/chapter6/index.rst index b8738d8e..a8d3e91b 100644 --- a/source/chapter6/index.rst +++ b/source/chapter6/index.rst @@ -1,3 +1,5 @@ +.. _link-chapter6: + 第六章:文件系统 ============================================== diff --git a/source/chapter7/4signal.rst b/source/chapter7/4signal.rst index 3733907f..e1a463c4 100644 --- a/source/chapter7/4signal.rst +++ b/source/chapter7/4signal.rst @@ -86,12 +86,10 @@ // 设置要阻止的信号 // mask:信号掩码 sys_sigprocmask(mask: u32) -> isize - ``` - ```rust + // 清除堆栈帧,从信号处理例程返回 sys_sigreturn() -> isize - ``` - ```rust + // 将某信号发送给某进程 // pid:进程pid // signal:信号的整数码 diff --git a/source/chapter7/index.rst b/source/chapter7/index.rst index 68299630..8f08a00f 100644 --- a/source/chapter7/index.rst +++ b/source/chapter7/index.rst @@ -1,3 +1,5 @@ +.. _link-chapter7: + 第七章:进程间通信与 I/O 重定向 ============================================== diff --git a/source/chapter8/0intro.rst b/source/chapter8/0intro.rst index 7a596ef0..cff987bb 100644 --- a/source/chapter8/0intro.rst +++ b/source/chapter8/0intro.rst @@ -63,15 +63,15 @@ .. note:: - **线程与进程的区别** + **线程与进程的区别** + + 下面的比较是以线程为调度对象的操作系统作为分析对象: - 下面的比较是以线程为调度对象的操作系统作为分析对象: - - * 进程间相互独立(即资源隔离),同一进程的各线程间共享进程的资源(即资源共享); - * 子进程和父进程有不同的地址空间和资源,而多个线程(没有父子关系)则共享同一所属进程的地址空间和资源; - * 每个线程有其自己的执行上下文(线程ID、程序计数器、寄存器集合和执行栈),而进程的执行上下文包括其管理的线程执行上下文和地址空间(故同一进程的线程上下文切换比进程上下文切换要快); - * 线程是一个可调度/分派/执行的实体(线程有就绪、阻塞和运行三种基本执行状态),进程不是可调度/分派/执行的的实体,而是线程的资源容器; - * 进程间通信需要通过IPC机制(如管道等), 属于同一进程的线程间可以共享“即直接读写”进程的数据,但需要同步互斥机制的辅助,以保证数据的一致性。 + * 进程间相互独立(即资源隔离),同一进程的各线程间共享进程的资源(即资源共享); + * 子进程和父进程有不同的地址空间和资源,而多个线程(没有父子关系)则共享同一所属进程的地址空间和资源; + * 每个线程有其自己的执行上下文(线程ID、程序计数器、寄存器集合和执行栈),而进程的执行上下文包括其管理的线程执行上下文和地址空间(故同一进程的线程上下文切换比进程上下文切换要快); + * 线程是一个可调度/分派/执行的实体(线程有就绪、阻塞和运行三种基本执行状态),进程不是可调度/分派/执行的的实体,而是线程的资源容器; + * 进程间通信需要通过IPC机制(如管道等), 属于同一进程的线程间可以共享“即直接读写”进程的数据,但需要同步互斥机制的辅助,以保证数据的一致性。 同步互斥 @@ -105,7 +105,8 @@ println!("NUM = {:?}", NUM); } -如果线程的个数为 ``n`` ,那么最后主进程会显示的数应该是多少呢? 也许同学觉得应该也是 ``n`` ,但现实并不是这样。为了了解事实真相,我们首先必须了解Rust编译器对 ``num = num + 1; `` 这一行源代码生成的汇编代码序列。 + +如果线程的个数为 ``n`` ,那么最后主进程会显示的数应该是多少呢? 也许同学觉得应该也是 ``n`` ,但现实并不是这样。为了了解事实真相,我们首先必须了解Rust编译器对 ``num = num + 1;`` 这一行源代码生成的汇编代码序列。 .. code-block:: asm :linenos: @@ -136,27 +137,18 @@ **并发相关术语**   - - - 共享资源(shared resource):不同的线程/进程都能访问的变量或数据结构。 - + - 共享资源(shared resource):不同的线程/进程都能访问的变量或数据结构。 - 临界区(critical section):访问共享资源的一段代码。 - - 竞态条件(race condition):多个线程/进程都进入临界区时,都试图更新共享的数据结构,导致产生了不期望的结果。 - - 不确定性(indeterminate): 多个线程/进程在执行过程中出现了竞态条件,导致执行结果取决于哪些线程在何时运行,即执行结果不确定,而开发者期望得到的是确定的结果。 - - 互斥(mutual exclusion):一种操作原语,能保证只有一个线程进入临界区,从而避免出现竞态,并产生确定的执行结果。 - - 原子性(atomic):一系列操作要么全部完成,要么一个都没执行,不会看到中间状态。在数据库领域,具有原子性的一系列操作称为事务(transaction)。 - - 同步(synchronization):多个并发执行的进程/线程在一些关键点上需要互相等待,这种相互制约的等待称为进程/线程同步。 - - 死锁(dead lock):一个线程/进程集合里面的每个线程/进程都在等待只能由这个集合中的其他一个线程/进程(包括他自身)才能引发的事件,这种情况就是死锁。 - - 饥饿(hungry):指一个可运行的线程/进程尽管能继续执行,但由于操作系统的调度而被无限期地忽视,导致不能执行的情况。 -在后续的章节中,会大量使用上述术语,如果现在还不够理解,没关系,随着后续的一步一步的分析和实验,相信大家能够掌握上述术语的实际含义。 +在后续的章节中,会大量使用上述术语,如果现在还不够理解,没关系,随着后续的一步一步的分析和实验,相信大家能够掌握上述术语的实际含义。 实践体验 diff --git a/source/chapter8/1thread-kernel.rst b/source/chapter8/1thread-kernel.rst index 92306b53..5cc27233 100644 --- a/source/chapter8/1thread-kernel.rst +++ b/source/chapter8/1thread-kernel.rst @@ -150,7 +150,7 @@ } -waittid 等待一个线程标识符的值为tid 的线程结束。在具体实现方面,我们看到当 sys_waittid 返回值为 -2 ,即要等待的线程存在但它却尚未退出的时候,主线程调用 yield_ 主动交出 CPU 使用权,待下次 CPU 使用权被内核交还给它的时候再次调用 sys_waittid 查看要等待的线程是否退出。这样做是为了减小 CPU 资源的浪费。这种方法是为了尽可能简化内核的实现。 +waittid 等待一个线程标识符的值为tid 的线程结束。在具体实现方面,我们看到当 sys_waittid 返回值为 -2 ,即要等待的线程存在但它却尚未退出的时候,主线程调用 ``yield_`` 主动交出 CPU 使用权,待下次 CPU 使用权被内核交还给它的时候再次调用 sys_waittid 查看要等待的线程是否退出。这样做是为了减小 CPU 资源的浪费。这种方法是为了尽可能简化内核的实现。 多线程应用程序 -- threads @@ -212,6 +212,7 @@ waittid 等待一个线程标识符的值为tid 的线程结束。在具体实 - 任务管理器 TaskManager :管理线程集合的核心数据结构。 - 处理器管理结构 Processor :用于线程调度,维护线程的处理器状态。 + 线程控制块 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -363,6 +364,7 @@ waittid 等待一个线程标识符的值为tid 的线程结束。在具体实 new_task_tid as isize } + 上述代码主要完成了如下事务: - 第4-5行,找到当前正在执行的线程 ``task`` 和此线程所属的进程 ``process`` 。 diff --git a/source/chapter8/1thread.rst b/source/chapter8/1thread.rst index 96e83e10..68fb247f 100644 --- a/source/chapter8/1thread.rst +++ b/source/chapter8/1thread.rst @@ -151,9 +151,11 @@ 线程管理运行时负责整个应用中的线程管理。当然,它也需要完成自身的初始化工作。这里主要包括两个函数: - `Runtime::new()` 主要有三个步骤: + - 初始化应用主线程控制块(其TID为 `0` ),并设置其状态为 `Running` 状态; - 初始化 `tasks` 线程控制块向量,加入应用主线程控制块和空闲线程控制块,为后续的线程创建做好准备; - 包含 `tasks` 线程控制块向量和 `current` 当前线程id(初始值为0, 表示当前正在运行的线程是应用主线程),来建立 `Runtime` 变量; + - `Runtime::init()` ,把线程管理运行时的 `Runtime` 自身的地址指针赋值给全局可变变量 `RUNTIME` .. code-block:: rust @@ -214,9 +216,9 @@ - 第4~12行,在线程向量中查找一个状态为 `Available` 的空闲线程控制块; - 第14~20行,初始化该空闲线程的线程控制块; - - `x1`寄存器:老的返回地址 -- `guard`函数地址 - - `nx1`寄存器:新的返回地址 -- 输入参数 `f` 函数地址 - - `x2` 寄存器:新的栈地址 -- available.stack+size + - `x1` 寄存器:老的返回地址 -- `guard` 函数地址 + - `nx1` 寄存器:新的返回地址 -- 输入参数 `f` 函数地址 + - `x2` 寄存器:新的栈地址 -- available.stack+size .. code-block:: rust @@ -355,7 +357,7 @@ } -这里需要注意两个细节。第一个是寄存器集合的保存数量。在保存通用寄存器集合时,并没有保存所有的通用寄存器,其原因是根据RISC-V的函数调用约定,有一部分寄存器是有调用函数 `Caller` 来保存的,所以就不需要被调用函数 `switch` 来保存了。第二个是当前指令指针(PC)的切换。在具体切换过程中,是基于函数返回地址来进行切换的。即首先把 `switch` 的函数返回地址 `ra` (即 `x1` )寄存器保存在 `TaskContext` 中,在此函数的倒数第二步,恢复切换后要执行线程的函数返回地址,即 `ra` 寄存器到 `t0` 寄存器,然后调用 `jr t0 ` 即完成了函数的返回。 +这里需要注意两个细节。第一个是寄存器集合的保存数量。在保存通用寄存器集合时,并没有保存所有的通用寄存器,其原因是根据RISC-V的函数调用约定,有一部分寄存器是有调用函数 `Caller` 来保存的,所以就不需要被调用函数 `switch` 来保存了。第二个是当前指令指针(PC)的切换。在具体切换过程中,是基于函数返回地址来进行切换的。即首先把 `switch` 的函数返回地址 `ra` (即 `x1` )寄存器保存在 `TaskContext` 中,在此函数的倒数第二步,恢复切换后要执行线程的函数返回地址,即 `ra` 寄存器到 `t0` 寄存器,然后调用 `jr t0` 即完成了函数的返回。 开始执行 diff --git a/source/chapter8/2lock.rst b/source/chapter8/2lock.rst index 1f5d8307..b05ce49e 100644 --- a/source/chapter8/2lock.rst +++ b/source/chapter8/2lock.rst @@ -280,7 +280,7 @@ CAS原子指令和TAS原子指令 :linenos: fn TestAndSet(old_ptr: &mut i32, new:i32) -> i32 { - let old :i32 = *old_ptr; // 取得 old_ptr 指向内存单元的旧值 old + let old :i32 = *old_ptr; // 取得 old_ptr 指向内存单元的旧值 old *old_ptr = new; // 把新值 new 存入到 old_ptr 指向的内存单元中 old // 返回旧值 old } @@ -503,32 +503,34 @@ mutex系统调用的实现 .. code-block:: Rust :linenos: - :emphasize-lines: 14,30 + :emphasize-lines: 17,20 // os/src/syscall/sync.rs - pub fn sys_mutex_create(blocking: bool) -> isize { - let process = current_process(); - let mut process_inner = process.inner_exclusive_access(); - if let Some(id) = process_inner - .mutex_list - .iter() - .enumerate() - .find(|(_, item)| item.is_none()) - .map(|(id, _)| id) { - process_inner.mutex_list[id] = if !blocking { - Some(Arc::new(MutexSpin::new())) - } else { - Some(Arc::new(MutexBlocking::new())) - }; - id as isize - } else { - process_inner.mutex_list.push(Some(Arc::new(MutexSpin::new()))); - process_inner.mutex_list.len() as isize - 1 - } - } + pub fn sys_mutex_create(blocking: bool) -> isize { + let process = current_process(); + let mutex: Option> = if !blocking { + Some(Arc::new(MutexSpin::new())) + } else { + Some(Arc::new(MutexBlocking::new())) + }; + let mut process_inner = process.inner_exclusive_access(); + if let Some(id) = process_inner + .mutex_list + .iter() + .enumerate() + .find(|(_, item)| item.is_none()) + .map(|(id, _)| id) + { + process_inner.mutex_list[id] = mutex; + id as isize + } else { + process_inner.mutex_list.push(mutex); + process_inner.mutex_list.len() as isize - 1 + } + } -- 第14行,如果向量中有空的元素,就在这个空元素的位置创建一个可睡眠的互斥锁; -- 第30行,如果向量满了,就在向量中添加新的可睡眠的互斥锁; +- 第17行,如果向量中有空的元素,就在这个空元素的位置创建一个可睡眠的互斥锁; +- 第20行,如果向量满了,就在向量中添加新的可睡眠的互斥锁; 有了互斥锁,接下来就是实现 ``Mutex`` trait的内核函数:对应 ``SYSCALL_MUTEX_LOCK`` 系统调用的 ``sys_mutex_lock`` 。操作系统主要工作是,在锁已被其他线程获取的情况下,把当前线程放到等待队列中,并调度一个新线程执行。主要代码如下: diff --git a/source/chapter8/5concurrency-problem.rst b/source/chapter8/5concurrency-problem.rst index 5268a227..7e17f806 100644 --- a/source/chapter8/5concurrency-problem.rst +++ b/source/chapter8/5concurrency-problem.rst @@ -18,16 +18,16 @@ :emphasize-lines: 4,10 static mut A: usize = 0; - ... + //... other code unsafe fn thr1() -> ! { if (A == 0) { - println!("thr1: A is Zero --> {}", A); + println!("thr1: A is Zero --> {}", A); } - ... + //... other code } unsafe fn thr2() -> ! { A = A+1; - println!("thr2: A is One --> {}", A); + println!("thr2: A is One --> {}", A); } A是共享变量。粗略地看,可以估计执行流程为:第一个线程thr1检查A的值,如果为0,则显示“"thr1: A is Zero --> 0”;第二个线程thr2设置A的值为2,并显示"thr2: A is One --> 1”。但如果线程thr1执行完第4行代码,准备执行第5行代码前发生了线程切换,开始执行线程th2;当线程thr2完成第10行后,操作系统有切换回线程thr1继续执行,那么线程thr1就会输出“thr1: A is Zero --> 1” 这样的奇怪结果。 @@ -38,19 +38,19 @@ A是共享变量。粗略地看,可以估计执行流程为:第一个线程t :linenos: static mut A: usize = 0; - ... + //... other code unsafe fn thr1() -> ! { mutex.lock(); if (A == 0) { - println!("thr1: A is Zero --> {}", A); + println!("thr1: A is Zero --> {}", A); } mutex.unlock(); - ... + //... other code } unsafe fn thr2() -> ! { mutex.lock(); A = A+1; - println!("thr2: A is One --> {}", A); + println!("thr2: A is One --> {}", A); mutex.unlock(); } @@ -242,7 +242,7 @@ Need[i,j] = Max[i,j] - allocation[i, j] :linenos: Finish[i] == false; - Need[i,j] ≤ Work[j]; + Need[i,j] <= Work[j]; 若找到,执行步骤3,否则,执行步骤4。 diff --git a/source/chapter8/index.rst b/source/chapter8/index.rst index 015dba15..76483e5a 100644 --- a/source/chapter8/index.rst +++ b/source/chapter8/index.rst @@ -1,3 +1,5 @@ +.. _link-chapter8: + 第八章:并发 ============================================== diff --git a/source/chapter9/index.rst b/source/chapter9/index.rst index f4150796..f15bae9a 100644 --- a/source/chapter9/index.rst +++ b/source/chapter9/index.rst @@ -1,3 +1,5 @@ +.. _link-chapter9: + 第九章:I/O设备管理 ============================================== diff --git a/source/log.rst b/source/log.rst index e0052b9b..832c423d 100644 --- a/source/log.rst +++ b/source/log.rst @@ -41,7 +41,7 @@ 2021-03-05 ------------------------------- -- 第三章练习中增加了对于 ``sys_gettime`` 语义在教程和测例中差异的相关说明, :ref:`详情 ` 。 +- 第三章练习中增加了对于 ``sys_gettime`` 语义在教程和测例中差异的相关说明。 - 修正了第四章练习中 mmap 系统调用语义中的一处错误。 diff --git a/source/terminology.rst b/source/terminology.rst index addbe304..459b743d 100644 --- a/source/terminology.rst +++ b/source/terminology.rst @@ -42,85 +42,85 @@ - :ref:`移除标准库依赖 ` * - 物理地址 - Physical Address - - :ref:`重建最小化运行时 ` + - :ref:`内核第一条指令(原理篇) ` * - 物理内存 - Physical Memory - - :ref:`重建最小化运行时 ` + - :ref:`内核第一条指令(原理篇) ` * - 引导加载程序 - Bootloader - - :ref:`重建最小化运行时 ` + - :ref:`内核第一条指令(原理篇) ` * - 控制流 - Control Flow - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 函数调用 - Function Call - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 源寄存器 - Source Register - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 立即数 - Immediate - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 目标寄存器 - Destination Register - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 伪指令 - Pseudo Instruction - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 上下文 - Context - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 活动记录 - Activation Record - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用` * - 保存/恢复 - Save/Restore - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 被调用者保存 - Callee-Saved - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 调用者保存 - Caller-Saved - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 开场白 - Prologue - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 收场白 - Epilogue - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 调用规范 - Calling Convention - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 栈/栈指针/栈帧 - Stack/Stack Pointer/Stackframe - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 后入先出 - LIFO, Last In First Out - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 段 - Section - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 内存布局 - Memory Layout - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 堆 - Heap - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 编译器 - Compiler - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 汇编器 - Assembler - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 链接器 - Linker - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 目标文件 - Object File - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 链接脚本 - Linker Script - - :ref:`重建最小化运行时 ` + - :ref:`为内核支持函数调用 ` * - 可执行和链接格式 - ELF, Executable and Linkable Format - :ref:`手动加载、运行应用程序 ` @@ -190,9 +190,6 @@ * - 数据缓存 - d-cache, Data Cache - :ref:`实现批处理系统 ` - * - 执行流 - - Execution of Thread - - :ref:`处理 Trap ` * - 原子指令 - Atomic Instruction - :ref:`处理 Trap `