diff --git a/source/chapter1/3-2-mini-rt-baremetal.rst b/source/chapter1/3-2-mini-rt-baremetal.rst index 6bfd109aa76f83aa8b9634ace267c883c2abd595..66db107c3948922562b66463f6aab083efd0c3a2 100644 --- a/source/chapter1/3-2-mini-rt-baremetal.rst +++ b/source/chapter1/3-2-mini-rt-baremetal.rst @@ -89,18 +89,18 @@ 这需要从 CPU 加电后如何初始化,如何执行第一条指令开始讲起。对于我们采用的QEMU模拟器而言,它模拟了一台标准的RISC-V64计算机。我们启动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)`` 的值是 ``0x80020000`` 。 + - ``-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`` 处, 即RustSBI的第一条指令。RustSBI完成基本的硬件初始化后, - 会跳转操作系统的二进制代码 ``$(KERNEL_BIN)`` 所在内存位置 ``0x80020000`` ,执行操作系统的第一条指令。 + 会跳转操作系统的二进制代码 ``$(KERNEL_BIN)`` 所在内存位置 ``0x80200000`` ,执行操作系统的第一条指令。 这时我们的编写的操作系统才开始正式工作。 为啥在 ``0x80000000`` 放置 ``Bootloader`` ?因为这是QEMU的硬件模拟代码中设定好的 ``Bootloader`` 的起始地址。 - 为啥在 ``0x80020000`` 放置 ``os`` ?因为这是 ``Bootloader--RustSBI`` 的代码中设定好的 ``os`` 的起始地址。 + 为啥在 ``0x80200000`` 放置 ``os`` ?因为这是 ``Bootloader--RustSBI`` 的代码中设定好的 ``os`` 的起始地址。 .. note:: @@ -173,11 +173,11 @@ RustSBI提供的SBI服务的SBI调用的指令也是 ``ecall`` 。 $ 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=0x80020000 + $ 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分析,看到的入口地址不是 -RustSBIS约定的 ``0x80020000`` 。我们需要修改 ``os`` ELF执行程序的内存布局。 +RustSBIS约定的 ``0x80200000`` 。我们需要修改 ``os`` ELF执行程序的内存布局。 设置正确的程序内存布局 @@ -208,7 +208,7 @@ RustSBIS约定的 ``0x80020000`` 。我们需要修改 ``os`` ELF执行程序的 OUTPUT_ARCH(riscv) ENTRY(_start) - BASE_ADDRESS = 0x80020000; + BASE_ADDRESS = 0x80200000; SECTIONS { @@ -253,7 +253,7 @@ RustSBIS约定的 ``0x80020000`` 。我们需要修改 ``os`` ELF执行程序的 } 第 1 行我们设置了目标平台为 riscv ;第 2 行我们设置了整个程序的入口点为之前定义的全局符号 ``_start``; -第 3 行定义了一个常量 ``BASE_ADDRESS`` 为 ``0x80020000`` ,也就是我们之前提到的期望我们自己实现的初始化代码被放在的地址; +第 3 行定义了一个常量 ``BASE_ADDRESS`` 为 ``0x80200000`` ,也就是我们之前提到的期望我们自己实现的初始化代码被放在的地址; 从第 5 行开始体现了链接过程中对输入的目标文件的段的合并。其中 ``.`` 表示当前地址,也就是链接器会从它指向的位置开始往下放置从输入的目标文件 中收集来的段。我们可以对 ``.`` 进行赋值来调整接下来的段放在哪里,也可以创建一些全局符号赋值为 ``.`` 从而记录这一时刻的位置。我们还能够 @@ -276,29 +276,29 @@ RustSBIS约定的 ``0x80020000`` 。我们需要修改 ``os`` ELF执行程序的 为了说明当前实现的正确性,我们需要讨论这样一个问题: -1. 如何做到执行环境的初始化代码被放在内存上以 ``0x80020000`` 开头的区域上? +1. 如何做到执行环境的初始化代码被放在内存上以 ``0x80200000`` 开头的区域上? - 在链接脚本第 7 行,我们将当前地址设置为 ``BASE_ADDRESS`` 也即 ``0x80020000`` ,然后从这里开始往高地址放置各个段。第一个被放置的 + 在链接脚本第 7 行,我们将当前地址设置为 ``BASE_ADDRESS`` 也即 ``0x80200000`` ,然后从这里开始往高地址放置各个段。第一个被放置的 是 ``.text`` ,而里面第一个被放置的又是来自 ``entry.asm`` 中的段 ``.text.entry``,这个段恰恰是含有两条指令的执行环境初始化代码, - 它在所有段中最早被放置在我们期望的 ``0x80020000`` 处。 + 它在所有段中最早被放置在我们期望的 ``0x80200000`` 处。 这样一来,我们就将运行时重建完毕了。在 ``os`` 目录下 ``cargo build --release`` 或者直接 ``make build`` 就能够看到 最终生成的可执行文件 ``target/riscv64gc-unknown-none-elf/release/os`` 。 -通过分析,我们看到 ``0x80020000`` 处的代码是我们预期的 ``_start()`` 函数的内容。我们采用刚才的编译运行方式进行试验,发现还是同样的错误结果。 +通过分析,我们看到 ``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=0x80020000 -S -s + $ 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 *0x80020000 - (gdb) x /16i 0x80020000 + (gdb) break *0x80200000 + (gdb) x /16i 0x80200000 (gdb) si 结果发现刚执行一条指令,整个系统就飞了( ``pc`` 寄存器等已经变成为 ``0`` 了)。再一看, ``sp`` 寄存器是一个非常大的值 ``0xffffff...`` 。这就很清楚是 @@ -385,7 +385,7 @@ RustSBIS约定的 ``0x80020000`` 。我们需要修改 ``os`` ELF执行程序的 > -machine virt \ > -nographic \ > -bios ../bootloader/rustsbi-qemu.bin \ - > -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80020000 + > -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000 [rustsbi] Version 0.1.0 .______ __ __ _______.___________. _______..______ __ | _ \ | | | | / | | / || _ \ | | @@ -398,7 +398,7 @@ RustSBIS约定的 ``0x80020000`` 。我们需要修改 ``os`` ELF执行程序的 [rustsbi] misa: RV64ACDFIMSU [rustsbi] mideleg: 0x222 [rustsbi] medeleg: 0xb1ab - [rustsbi] Kernel entry: 0x80020000 + [rustsbi] Kernel entry: 0x80200000 # “优雅”地退出了。 @@ -471,7 +471,7 @@ RustSBIS约定的 ``0x80020000`` 。我们需要修改 ``os`` ELF执行程序的 $ 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=0x80020000 + $ 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 .______       __    __      _______.___________.  _______..______   __ @@ -485,7 +485,7 @@ RustSBIS约定的 ``0x80020000`` 。我们需要修改 ``os`` ELF执行程序的 [rustsbi] misa: RV64ACDFIMSU [rustsbi] mideleg: 0x222 [rustsbi] medeleg: 0xb1ab - [rustsbi] Kernel entry: 0x80020000 + [rustsbi] Kernel entry: 0x80200000 Hello, world! 可以看到,在裸机上输出了 ``Hello, world!`` ,而且qemu正常退出,表示RISC-V计算机也正常关机了。 @@ -522,7 +522,7 @@ RustSBIS约定的 ``0x80020000`` 。我们需要修改 ``os`` ELF执行程序的 -machine virt \ -nographic \ -bios ../bootloader/rustsbi-qemu.bin \ - -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80020000 + -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000 [rustsbi] Version 0.1.0 .______ __ __ _______.___________. _______..______ __ @@ -536,7 +536,7 @@ RustSBIS约定的 ``0x80020000`` 。我们需要修改 ``os`` ELF执行程序的 [rustsbi] misa: RV64ACDFIMSU [rustsbi] mideleg: 0x222 [rustsbi] medeleg: 0xb1ab - [rustsbi] Kernel entry: 0x80020000 + [rustsbi] Kernel entry: 0x80200000 Hello, world! Panicked at src/main.rs:95 It should shutdown! diff --git a/source/chapter3/0intro.rst b/source/chapter3/0intro.rst index 04e98f1068e4119834c6e921820015bdd803d9a3..c160a72e70f000cd41de58335108f6b10948c46c 100644 --- a/source/chapter3/0intro.rst +++ b/source/chapter3/0intro.rst @@ -16,11 +16,7 @@ - 通抢占机制支持程序被动放弃处理器,提高不同程序对处理器资源使用的公平性,也进一步提高了应用对I/O事件的响应效率 -上一章,我们实现了一个简单的批处理系统。首先,它能够自动按照顺序加载并运行序列中的每一个应用,当一个应用运行结束之后无需操作员的手动替换; -另一方面,在硬件提供的特权级机制的帮助下,运行在更高特权级的它不会受到有意或者无意出错的应用的影响,可以全方位监控运行在用户态特权级的应用的执行,一旦应用越过了 -硬件所设置特权级界限或主动申请获得操作系统的服务,就会触发 Trap 并进入到批处理系统中进行处理。无论原因是应用出错或是应用声明自己执行完毕,批处理系统都只需要加载序列中 -的下一个应用并进入执行。可以看到批处理系统的特性是:在内存中同一时间最多只需驻留一个应用。这是因为只有当一个应用出错或退出之后,批处理系统才会去将另一个应用加载到 -相同的一块内存区域。 +上一章,我们实现了一个简单的批处理系统。首先,它能够自动按照顺序加载并运行序列中的每一个应用,当一个应用运行结束之后无需操作员的手动替换;另一方面,在硬件提供的特权级机制的帮助下,运行在更高特权级的它不会受到有意或者无意出错的应用的影响,可以全方位监控运行在用户态特权级的应用的执行,一旦应用越过了硬件所设置特权级界限或主动申请获得操作系统的服务,就会触发 Trap 并进入到批处理系统中进行处理。无论原因是应用出错或是应用声明自己执行完毕,批处理系统都只需要加载序列中的下一个应用并进入执行。可以看到批处理系统的特性是:在内存中同一时间最多只需驻留一个应用。这是因为只有当一个应用出错或退出之后,批处理系统才会去将另一个应用加载到相同的一块内存区域。 而计算机硬件在快速发展,内存容量在逐渐增大,处理器的速度也在增加,外设IO性能方面的进展不大。这就使得以往内存只能放下一个程序的情况得到很大改善,但处理器的空闲程度加大了。于是科学家就开始考虑在内存中尽量同时驻留多个应用,这样处理器的利用率就会提高。但只有一个程序执行完毕后或主动放弃执行,处理器才能执行另外一个程序。这种运行方式称为 **多道程序** 。 @@ -63,8 +59,7 @@ .. _term-multiprogramming: .. _term-time-sharing-multitasking: -**多道程序** (Multiprogramming) 和 **分时多任务** (Time-Sharing Multitasking) 对于应用的要求是不同的,因此我们分别为它们 -编写了不同的应用,代码也被放在两个不同的分支上。对于它们更加深入的讲解请参考本章正文,我们在引言中仅给出运行代码的方法。 +**多道程序** (Multiprogramming) 和 **分时多任务** (Time-Sharing Multitasking) 对于应用的要求是不同的,因此我们分别为它们编写了不同的应用,代码也被放在两个不同的分支上。对于它们更加深入的讲解请参考本章正文,我们在引言中仅给出运行代码的方法。 获取多道程序的代码: @@ -217,8 +212,7 @@ [kernel] Panicked at src/task/mod.rs:98 All applications completed! [rustsbi] reset triggered! todo: shutdown all harts on k210; program halt. Type: 0, reason: 0 -输出结果看上去有一些混乱,原因是用户程序的每个 ``println!`` 往往会被拆分成多个 ``sys_write`` 系统调用提交给内核。有兴趣的同学可以参考 -``println!`` 宏的实现。 +输出结果看上去有一些混乱,原因是用户程序的每个 ``println!`` 往往会被拆分成多个 ``sys_write`` 系统调用提交给内核。有兴趣的同学可以参考 ``println!`` 宏的实现。 另外需要说明的是一点是:与上一章不同,应用的编号不再决定其被加载运行的先后顺序,而仅仅能够改变应用被加载到内存中的位置。 diff --git a/source/chapter3/1multi-loader.rst b/source/chapter3/1multi-loader.rst index c589aefe3dc127b4c091bfb2556dd477bb4aa1e9..b641d57f4576e5b94d1db5b433513d6475af25e6 100644 --- a/source/chapter3/1multi-loader.rst +++ b/source/chapter3/1multi-loader.rst @@ -14,14 +14,9 @@ 多道程序放置 ---------------------------- -与第二章相同,所有应用的 ELF 都经过 strip 丢掉所有 ELF header 和符号变为二进制镜像文件,随后以同样的格式通过 -``link_user.S`` 在编译的时候直接链接到内核的数据段中。不同的是,我们对相关模块进行了调整:在第二章中应用的加载和进度控制都交给 ``batch`` 子模块,而在第三章中我们将应用的加载这部分功能分离出来在 ``loader`` -子模块中实现,应用的执行和切换则交给 ``task`` 子模块。 +与第二章相同,所有应用的 ELF 都经过 strip 丢掉所有 ELF header 和符号变为二进制镜像文件,随后以同样的格式通过 ``link_user.S`` 在编译的时候直接链接到内核的数据段中。不同的是,我们对相关模块进行了调整:在第二章中应用的加载和进度控制都交给 ``batch`` 子模块,而在第三章中我们将应用的加载这部分功能分离出来在 ``loader`` 子模块中实现,应用的执行和切换则交给 ``task`` 子模块。 -注意,我们需要调整每个应用被构建时候使用的链接脚本 ``linker.ld`` 中的起始地址 ``BASE_ADDRESS`` 为它实际 -会被内核加载并运行的地址。也就是要做到:应用知道自己会被加载到某个地址运行,而内核也确实能做到将它加载到那个 -地址。这算是应用和内核在某种意义上达成的一种协议。之所以要有这么苛刻的条件,是因为应用和内核的能力都很弱,通用性很低。 -事实上,目前应用程序的编址方式是基于绝对位置的而并没做到与位置无关,内核也没有提供相应的重定位机制。 +注意,我们需要调整每个应用被构建时候使用的链接脚本 ``linker.ld`` 中的起始地址 ``BASE_ADDRESS`` 为它实际会被内核加载并运行的地址。也就是要做到:应用知道自己会被加载到某个地址运行,而内核也确实能做到将它加载到那个地址。这算是应用和内核在某种意义上达成的一种协议。之所以要有这么苛刻的条件,是因为应用和内核的能力都很弱,通用性很低。事实上,目前应用程序的编址方式是基于绝对位置的而并没做到与位置无关,内核也没有提供相应的重定位机制。 .. note:: @@ -73,8 +68,7 @@ 多道程序加载 ---------------------------- -应用的加载方式也和上一章的有所不同。上一章中讲解的加载方法是让所有应用都共享同一个固定的加载物理地址。也是因为这个原因,内存中同时最多只能驻留一个应用,当它运行完毕或者出错退出的时候由操作系统的 ``batch`` 子模块加载一个新的应用来替换掉它。本章中,所有的应用在内核初始化的时候就一并被加载到内存中。为了避免覆盖,它们自然需要被加载到不同的物理地址。这是通过 -调用 ``loader`` 子模块的 ``load_apps`` 函数实现的: +应用的加载方式也和上一章的有所不同。上一章中讲解的加载方法是让所有应用都共享同一个固定的加载物理地址。也是因为这个原因,内存中同时最多只能驻留一个应用,当它运行完毕或者出错退出的时候由操作系统的 ``batch`` 子模块加载一个新的应用来替换掉它。本章中,所有的应用在内核初始化的时候就一并被加载到内存中。为了避免覆盖,它们自然需要被加载到不同的物理地址。这是通过调用 ``loader`` 子模块的 ``load_apps`` 函数实现的: .. code-block:: rust :linenos: @@ -111,8 +105,7 @@ } } -可以看出,第 :math:`i` 个应用被加载到以物理地址 ``base_i`` 开头的一段物理内存上,而 ``base_i`` 的 -计算方式如下: +可以看出,第 :math:`i` 个应用被加载到以物理地址 ``base_i`` 开头的一段物理内存上,而 ``base_i`` 的计算方式如下: .. code-block:: rust :linenos: @@ -123,10 +116,7 @@ APP_BASE_ADDRESS + app_id * APP_SIZE_LIMIT } -我们可以在 ``config`` 子模块中找到这两个常数。从这一章开始, ``config`` 子模块用来存放内核中所有的常数。看到 -``APP_BASE_ADDRESS`` 被设置为 ``0x80400000`` ,而 ``APP_SIZE_LIMIT`` 和上一章一样被设置为 -``0x20000`` ,也就是每个应用二进制镜像的大小限制。因此,应用的内存布局就很明朗了——就是从 -``APP_BASE_ADDRESS`` 开始依次为每个应用预留一段空间。 +我们可以在 ``config`` 子模块中找到这两个常数。从这一章开始, ``config`` 子模块用来存放内核中所有的常数。看到 ``APP_BASE_ADDRESS`` 被设置为 ``0x80400000`` ,而 ``APP_SIZE_LIMIT`` 和上一章一样被设置为 ``0x20000`` ,也就是每个应用二进制镜像的大小限制。因此,应用的内存布局就很明朗了——就是从 ``APP_BASE_ADDRESS`` 开始依次为每个应用预留一段空间。 这样,我们就说明了多个应用是如何被构建和加载的。 diff --git a/source/chapter3/2task-switching.rst b/source/chapter3/2task-switching.rst index e24e0f2e4e7008e5a475cf81e6d28f600005df37..c5668c1930e8f6b8b612666816b88ac66c51b350 100644 --- a/source/chapter3/2task-switching.rst +++ b/source/chapter3/2task-switching.rst @@ -16,8 +16,7 @@ .. chyyuu:程序执行过程的图示。 -如果操作系统能够在某个应用程序处于等待阶段的时候,把处理器转给另外一个处于计算阶段的应用程序,那么只要转换的开销不大,那么处理器的执行效率就会大大提高。当然,这需要应用程序在运行途中能 -主动交出 CPU 的使用权,此时它处于等待阶段,等到操作系统让它再次执行后,那它就可以继续执行了。 +如果操作系统能够在某个应用程序处于等待阶段的时候,把处理器转给另外一个处于计算阶段的应用程序,那么只要转换的开销不大,那么处理器的执行效率就会大大提高。当然,这需要应用程序在运行途中能主动交出 CPU 的使用权,此时它处于等待阶段,等到操作系统让它再次执行后,那它就可以继续执行了。 .. _term-task: .. _term-task-switch: @@ -26,9 +25,7 @@ .. _term-task-context: -我们又看到了熟悉的“暂停-继续”组合。一旦一条执行流需要支持“暂停-继续”,就需要提供一种执行流切换的机制,而且需要保证执行流被切换出去之前和 -切换回来之后,它的状态,也就是在执行过程中同步变化的资源(如寄存器、栈等)需要保持不变,或者变化在它的预期之内。而不是所有的资源都需要被 -保存,事实上只有那些对于执行流接下来的进行仍然有用,且在它被切换出去的时候有被覆盖的风险的那些资源才有被保存的价值。这些物理资源被称为 **任务上下文 (Task Context)** 。 +我们又看到了熟悉的“暂停-继续”组合。一旦一条执行流需要支持“暂停-继续”,就需要提供一种执行流切换的机制,而且需要保证执行流被切换出去之前和切换回来之后,它的状态,也就是在执行过程中同步变化的资源(如寄存器、栈等)需要保持不变,或者变化在它的预期之内。而不是所有的资源都需要被保存,事实上只有那些对于执行流接下来的进行仍然有用,且在它被切换出去的时候有被覆盖的风险的那些资源才有被保存的价值。这些物理资源被称为 **任务上下文 (Task Context)** 。 这里,大家开始在具体的操作系统中接触到了一些抽象的概念,其实这些概念都是具体代码的结构和代码动态执行过程的文字表述而已。 @@ -36,21 +33,13 @@ 不同类型的上下文与切换 --------------------------------- -在执行流切换过程中,我们需要结合硬件机制和软件实现来保存和恢复任务上下文。任务的一次切换涉及到被换出和即将被换入的两条执行流(分属两个任务),通常它们都需要 -共同遵循某些约定来合作完成这一过程。在前两章,我们已经看到了两种上下文保存/恢复的实例。让我们再来回顾一下它们: +在执行流切换过程中,我们需要结合硬件机制和软件实现来保存和恢复任务上下文。任务的一次切换涉及到被换出和即将被换入的两条执行流(分属两个任务),通常它们都需要共同遵循某些约定来合作完成这一过程。在前两章,我们已经看到了两种上下文保存/恢复的实例。让我们再来回顾一下它们: -- 第一章《RV64 裸机应用》中,我们介绍了 :ref:`函数调用与栈 ` 。当时提到过,为了支持嵌套函数调用,不仅需要 - 硬件平台提供特殊的跳转指令,还需要保存和恢复 :ref:`函数调用上下文 ` 。注意在 *我们* 的定义中,函数调用包含在普通控制流(与异常控制流相对)之内 - ,且始终用一个固定的栈来保存执行的历史记录,因此函数调用并不涉及执行流的切换。但是我们依然可以将其看成调用者和被调用者两个执行过程的“切换”, - 二者的协作体现在它们都遵循调用规范,分别保存一部分通用寄存器,这样的好处是编译器能够有足够的信息来尽可能减少需要保存的寄存器的数目。 - 虽然当时用了很大的篇幅来说明,但其实整个过程都是编译器负责完成的,我们只需设置好栈就行了。 +- 第一章《RV64 裸机应用》中,我们介绍了 :ref:`函数调用与栈 ` 。当时提到过,为了支持嵌套函数调用,不仅需要硬件平台提供特殊的跳转指令,还需要保存和恢复 :ref:`函数调用上下文 ` 。注意在 *我们* 的定义中,函数调用包含在普通控制流(与异常控制流相对)之内,且始终用一个固定的栈来保存执行的历史记录,因此函数调用并不涉及执行流的切换。但是我们依然可以将其看成调用者和被调用者两个执行过程的“切换”,二者的协作体现在它们都遵循调用规范,分别保存一部分通用寄存器,这样的好处是编译器能够有足够的信息来尽可能减少需要保存的寄存器的数目。虽然当时用了很大的篇幅来说明,但其实整个过程都是编译器负责完成的,我们只需设置好栈就行了。 - 第二章《批处理系统》中第一次涉及到了某种异常(Trap)控制流,即两条执行流的切换,需要保存和恢复 :ref:`系统调用(Trap)上下文 ` 。当时,为了让内核能够 *完全掌控* 应用的执行,且不会被应用破坏整个系统,我们必须利用硬件 - 提供的特权级机制,让应用和内核运行在不同的特权级。应用运行在 U 特权级,它所被允许的操作进一步受限,处处被内核监督管理;而内核运行在 S - 特权级,有能力处理应用执行过程中提出的请求或遇到的状况。 + 提供的特权级机制,让应用和内核运行在不同的特权级。应用运行在 U 特权级,它所被允许的操作进一步受限,处处被内核监督管理;而内核运行在 S 特权级,有能力处理应用执行过程中提出的请求或遇到的状况。 - 应用程序与操作系统打交道的核心在于硬件提供的 Trap 机制,也就是在 U 特权级运行的应用执行流和在 S 特权级运行的 Trap 执行流(操作系统的陷入处理部分)之间的切换。 - Trap 执行流是在 Trap 触发的一瞬间生成的,它和原应用执行流有着很密切的联系,因为它唯一的目标就是处理 Trap 并恢复到原应用执行流。而且, - 由于 Trap 机制对于应用来说几乎是透明的,所以基本上都是 Trap 执行流在“负重前行”。Trap 执行流需要把 **Trap 上下文** 保存在自己的 + 应用程序与操作系统打交道的核心在于硬件提供的 Trap 机制,也就是在 U 特权级运行的应用执行流和在 S 特权级运行的 Trap 执行流(操作系统的陷入处理部分)之间的切换。Trap 执行流是在 Trap 触发的一瞬间生成的,它和原应用执行流有着很密切的联系,因为它唯一的目标就是处理 Trap 并恢复到原应用执行流。而且,由于 Trap 机制对于应用来说几乎是透明的,所以基本上都是 Trap 执行流在“负重前行”。Trap 执行流需要把 **Trap 上下文** 保存在自己的 内核栈上,里面包含几乎所有的通用寄存器,因为在 Trap 处理过程中它们都可能被用到。如果有需要的话,可以回看 :ref:`Trap 上下文保存与恢复 ` 小节。 @@ -63,22 +52,13 @@ - 与 Trap 切换不同,它的一部分是由编译器帮忙完成的; - 与 Trap 切换相同,它对应用是透明的。 -事实上,它是来自两个不同应用的 Trap 执行流之间的切换。当一个应用 Trap 到 S 模式的操作系统中进行进一步处理(即进入了操作系统的Trap执行流)的时候,其 Trap 执行流可以调用一个特殊的 -``__switch`` 函数。这个函数表面上就是一个普通的函数调用:在 ``__switch`` 返回之后,将继续从调用该函数的位置继续向下执行。 -但是其间却隐藏着复杂的执行流切换过程。具体来说,调用 ``__switch`` 之后直到它返回前的这段时间,原 Trap 执行流会先被暂停并被 -切换出去, CPU 转而运行另一个应用的 Trap 执行流。之后在时机合适的时候,原 Trap 执行流才会从某一条 Trap 执行流(很有可能不是 -它之前切换到的那一条)切换回来继续执行并最终返回。不过,从实现的角度讲, ``__switch`` 和一个普通的函数之间的差别仅仅是它会换栈。 +事实上,它是来自两个不同应用的 Trap 执行流之间的切换。当一个应用 Trap 到 S 模式的操作系统中进行进一步处理(即进入了操作系统的Trap执行流)的时候,其 Trap 执行流可以调用一个特殊的 ``__switch`` 函数。这个函数表面上就是一个普通的函数调用:在 ``__switch`` 返回之后,将继续从调用该函数的位置继续向下执行。但是其间却隐藏着复杂的执行流切换过程。具体来说,调用 ``__switch`` 之后直到它返回前的这段时间,原 Trap 执行流会先被暂停并被切换出去, CPU 转而运行另一个应用的 Trap 执行流。之后在时机合适的时候,原 Trap 执行流才会从某一条 Trap 执行流(很有可能不是它之前切换到的那一条)切换回来继续执行并最终返回。不过,从实现的角度讲, ``__switch`` 和一个普通的函数之间的差别仅仅是它会换栈。 .. image:: task_context.png -当 Trap 执行流准备调用 ``__switch`` 函数并进入暂停状态的时候,让我们考察一下它内核栈上的情况。如上图所示,在准备调用 -``__switch`` 函数之前,内核栈上从栈底到栈顶分别是保存了应用执行状态的 Trap 上下文以及内核在对 Trap 处理的过程中留下的 -调用栈信息。由于之后还要恢复回来执行,我们必须保存 CPU 当前的某些寄存器,我们称它们为 **任务上下文** (Task Context)。 -我们会在稍后介绍里面需要包含哪些寄存器。至于保存的位置,我们将任务上下文直接压入内核栈的栈顶,从这一点上来说它和函数调用一样。 +当 Trap 执行流准备调用 ``__switch`` 函数并进入暂停状态的时候,让我们考察一下它内核栈上的情况。如上图所示,在准备调用 ``__switch`` 函数之前,内核栈上从栈底到栈顶分别是保存了应用执行状态的 Trap 上下文以及内核在对 Trap 处理的过程中留下的调用栈信息。由于之后还要恢复回来执行,我们必须保存 CPU 当前的某些寄存器,我们称它们为 **任务上下文** (Task Context)。我们会在稍后介绍里面需要包含哪些寄存器。至于保存的位置,我们将任务上下文直接压入内核栈的栈顶,从这一点上来说它和函数调用一样。 -这样需要保存的信息就已经确实的保存在内核栈上了,而恢复的时候我们要从任务上下文的位置——也就是这一时刻内核栈栈顶的位置找到 -被保存的寄存器快照进行恢复,这个位置也需要被保存下来。对于每一条被暂停的 Trap 执行流,我们都用一个名为 ``task_cx_ptr`` -的变量来保存它栈顶的任务上下文的地址。利用 C 语言的引用来描述的话就是: +这样需要保存的信息就已经确实的保存在内核栈上了,而恢复的时候我们要从任务上下文的位置——也就是这一时刻内核栈栈顶的位置找到被保存的寄存器快照进行恢复,这个位置也需要被保存下来。对于每一条被暂停的 Trap 执行流,我们都用一个名为 ``task_cx_ptr`` 的变量来保存它栈顶的任务上下文的地址。利用 C 语言的引用来描述的话就是: .. code-block:: C @@ -96,17 +76,11 @@ .. image:: switch-2.png -Trap 执行流在调用 ``__switch`` 之前就需要明确知道即将切换到哪一条目前正处于暂停状态的 Trap 执行流,因此 ``__switch`` 有两个参数, -第一个参数代表它自己,第二个参数则代表即将切换到的那条 Trap 执行流。这里我们用上面提到过的 ``task_cx_ptr2`` 作为代表。在上图中我们 -假设某次 ``__switch`` 调用要从 Trap 执行流 A 切换到 B,一共可以分为五个阶段,在每个阶段中我们都给出了 A 和 B 内核栈上的内容。 - -- 阶段 [1]:在 Trap 执行流 A 调用 ``__switch`` 之前,A 的内核栈上只有 Trap 上下文和 Trap 处理的调用栈信息,而 B 是之前被切换 - 出去的,它的栈顶还有额外的一个任务上下文; -- 阶段 [2]:A 在自身的内核栈上分配一块任务上下文的空间在里面保存 CPU 当前的寄存器快照。随后,我们更新 A 的 ``task_cx_ptr``,只需 - 写入指向它的指针 ``task_cx_ptr2`` 指向的内存即可; -- 阶段 [3]:这一步极为关键。这里读取 B 的 ``task_cx_ptr`` 或者说 ``task_cx_ptr2`` 指向的那块内存获取到 B 的内核栈栈顶位置,并 - 复制给 ``sp`` 寄存器来换到 B 的内核栈。由于内核栈保存着它迄今为止的执行历史记录,可以说 **换栈也就实现了执行流的切换** 。 - 正是因为这一步, ``__switch`` 才能做到一个函数跨两条执行流执行。 +Trap 执行流在调用 ``__switch`` 之前就需要明确知道即将切换到哪一条目前正处于暂停状态的 Trap 执行流,因此 ``__switch`` 有两个参数,第一个参数代表它自己,第二个参数则代表即将切换到的那条 Trap 执行流。这里我们用上面提到过的 ``task_cx_ptr2`` 作为代表。在上图中我们假设某次 ``__switch`` 调用要从 Trap 执行流 A 切换到 B,一共可以分为五个阶段,在每个阶段中我们都给出了 A 和 B 内核栈上的内容。 + +- 阶段 [1]:在 Trap 执行流 A 调用 ``__switch`` 之前,A 的内核栈上只有 Trap 上下文和 Trap 处理的调用栈信息,而 B 是之前被切换出去的,它的栈顶还有额外的一个任务上下文; +- 阶段 [2]:A 在自身的内核栈上分配一块任务上下文的空间在里面保存 CPU 当前的寄存器快照。随后,我们更新 A 的 ``task_cx_ptr``,只需写入指向它的指针 ``task_cx_ptr2`` 指向的内存即可; +- 阶段 [3]:这一步极为关键。这里读取 B 的 ``task_cx_ptr`` 或者说 ``task_cx_ptr2`` 指向的那块内存获取到 B 的内核栈栈顶位置,并复制给 ``sp`` 寄存器来换到 B 的内核栈。由于内核栈保存着它迄今为止的执行历史记录,可以说 **换栈也就实现了执行流的切换** 。正是因为这一步, ``__switch`` 才能做到一个函数跨两条执行流执行。 - 阶段 [4]:CPU 从 B 的内核栈栈顶取出任务上下文并恢复寄存器状态,在这之后还要进行退栈操作。 - 阶段 [5]:对于 B 而言, ``__switch`` 函数返回,可以从调用 ``__switch`` 的位置继续向下执行。 @@ -156,11 +130,9 @@ Trap 执行流在调用 ``__switch`` 之前就需要明确知道即将切换到 addi sp, sp, 13*8 ret -我们手写汇编代码来实现 ``__switch`` 。可以看到它的函数原型中的两个参数分别是当前 Trap 执行流和即将被切换到的 Trap 执行流的 -``task_cx_ptr2`` ,从 :ref:`RISC-V 调用规范 ` 可以知道它们分别通过寄存器 ``a0/a1`` 传入。 +我们手写汇编代码来实现 ``__switch`` 。可以看到它的函数原型中的两个参数分别是当前 Trap 执行流和即将被切换到的 Trap 执行流的 ``task_cx_ptr2`` ,从 :ref:`RISC-V 调用规范 ` 可以知道它们分别通过寄存器 ``a0/a1`` 传入。 -阶段 [2] 体现在第 18~26 行。第 18 行在 A 的内核栈上预留任务上下文的空间,然后将当前的栈顶位置保存下来。接下来就是逐个对寄存器 -进行保存,从中我们也能够看出 ``TaskContext`` 里面究竟包含哪些寄存器: +阶段 [2] 体现在第 18~26 行。第 18 行在 A 的内核栈上预留任务上下文的空间,然后将当前的栈顶位置保存下来。接下来就是逐个对寄存器进行保存,从中我们也能够看出 ``TaskContext`` 里面究竟包含哪些寄存器: .. code-block:: rust :linenos: @@ -173,9 +145,7 @@ Trap 执行流在调用 ``__switch`` 之前就需要明确知道即将切换到 s: [usize; 12], } -这里面只保存了 ``ra`` 和被调用者保存的 ``s0~s11`` 。``ra`` 的保存很重要,它记录了 ``__switch`` 返回之后应该到哪里继续执行, -从而在切换回来并 ``ret`` 之后能到正确的位置。而保存调用者保存的寄存器是因为,调用者保存的寄存器可以由编译器帮我们自动保存。我们会将 -这段汇编代码中的全局符号 ``__switch`` 解释为一个 Rust 函数: +这里面只保存了 ``ra`` 和被调用者保存的 ``s0~s11`` 。``ra`` 的保存很重要,它记录了 ``__switch`` 返回之后应该到哪里继续执行,从而在切换回来并 ``ret`` 之后能到正确的位置。而保存调用者保存的寄存器是因为,调用者保存的寄存器可以由编译器帮我们自动保存。我们会将这段汇编代码中的全局符号 ``__switch`` 解释为一个 Rust 函数: .. code-block:: rust :linenos: @@ -191,14 +161,11 @@ Trap 执行流在调用 ``__switch`` 之前就需要明确知道即将切换到 ); } -我们会调用该函数来完成切换功能而不是直接跳转到符号 ``__switch`` 的地址。因此在调用前后 Rust 编译器会自动帮助我们插入保存/恢复 -调用者保存寄存器的汇编代码。 +我们会调用该函数来完成切换功能而不是直接跳转到符号 ``__switch`` 的地址。因此在调用前后 Rust 编译器会自动帮助我们插入保存/恢复调用者保存寄存器的汇编代码。 -仔细观察的话可以发现 ``TaskContext`` 很像一个普通函数栈帧中的内容。正如之前所说, ``__switch`` 的实现除了换栈之外几乎就是一个 -普通函数,也能在这里得到体现。尽管如此,二者的内涵却有着很大的不同。 +仔细观察的话可以发现 ``TaskContext`` 很像一个普通函数栈帧中的内容。正如之前所说, ``__switch`` 的实现除了换栈之外几乎就是一个普通函数,也能在这里得到体现。尽管如此,二者的内涵却有着很大的不同。 -剩下的汇编代码就比较简单了。读者可以自行对照注释看看图示中的后面几个阶段各是如何实现的。另外,后面会出现传给 ``__switch`` 的两个参数 -相同,也就是某个 Trap 执行流自己切换到自己的情形,请读者对照图示思考目前的实现能否对它进行正确处理。 +剩下的汇编代码就比较简单了。读者可以自行对照注释看看图示中的后面几个阶段各是如何实现的。另外,后面会出现传给 ``__switch`` 的两个参数相同,也就是某个 Trap 执行流自己切换到自己的情形,请读者对照图示思考目前的实现能否对它进行正确处理。 .. chyyuu:有一个内核态切换的例子。 diff --git a/source/chapter3/3multiprogramming.rst b/source/chapter3/3multiprogramming.rst index 0bc87419eee728c22d47b83a6495cee958fed791..8513739fee5b26570953906867f2b8d5a423a6f5 100644 --- a/source/chapter3/3multiprogramming.rst +++ b/source/chapter3/3multiprogramming.rst @@ -6,8 +6,7 @@ -------------------------- -上一节我们已经介绍了任务切换是如何实现的,最终我们将其封装为一个函数 ``__switch`` 。但是在实际使用的时候,我们需要知道何时调用该函数, -以及如何确定传入函数的两个参数——分别代表正待换出和即将被换入的两条 Trap 执行流。本节我们就来介绍任务切换的第一种实际应用场景:多道程序。 +上一节我们已经介绍了任务切换是如何实现的,最终我们将其封装为一个函数 ``__switch`` 。但是在实际使用的时候,我们需要知道何时调用该函数,以及如何确定传入函数的两个参数——分别代表正待换出和即将被换入的两条 Trap 执行流。本节我们就来介绍任务切换的第一种实际应用场景:多道程序。 本节的一个重点是展示进一步增强的操作系统管理能力的和对处理器资源的相对高效利用。为此,对 **任务** 的概念进行进一步扩展和延伸:形成了 @@ -20,44 +19,23 @@ 多道程序背景与 yield 系统调用 ------------------------------------------------------------------------- -还记得第二章中介绍的批处理系统的设计初衷吗?它是注意到 CPU 并没有一直在执行应用程序,在一个应用程序运行结束直到下一个应用程序开始运行 -的这段时间,可能需要操作员取出上一个程序的执行结果并手动进行程序卡片的替换,这段空档期对于宝贵的 CPU 计算资源是一种巨大的浪费。于是 -批处理系统横空出世,它可以自动连续完成应用的加载和运行,并将一些本不需要 CPU 完成的简单任务交给廉价的外围设备,从而让 CPU 能够更加 -专注于计算任务本身,大大提高了 CPU 的利用率。 +还记得第二章中介绍的批处理系统的设计初衷吗?它是注意到 CPU 并没有一直在执行应用程序,在一个应用程序运行结束直到下一个应用程序开始运行的这段时间,可能需要操作员取出上一个程序的执行结果并手动进行程序卡片的替换,这段空档期对于宝贵的 CPU 计算资源是一种巨大的浪费。于是批处理系统横空出世,它可以自动连续完成应用的加载和运行,并将一些本不需要 CPU 完成的简单任务交给廉价的外围设备,从而让 CPU 能够更加专注于计算任务本身,大大提高了 CPU 的利用率。 .. _term-input-output: -尽管 CPU 一直在跑应用了,但是其利用率仍有上升的空间。随着应用需求的不断复杂,有的时候会在内核的监督下访问一些外设,它们也是计算机系统 -的另一个非常重要的组成部分,即 **输入/输出** (I/O, Input/Output) 。CPU 会将请求和一些附加的参数写入外设,待外设处理完毕之后, -CPU 便可以从外设读到请求的处理结果。比如在从作为外部存储的磁盘上读取数据的时候,CPU 将要读取的扇区的编号以及读到的数据放到的物理地址 -传给磁盘,在磁盘对请求进行调度并完成数据拷贝之后,就能在物理内存中看到要读取的数据。 +尽管 CPU 一直在跑应用了,但是其利用率仍有上升的空间。随着应用需求的不断复杂,有的时候会在内核的监督下访问一些外设,它们也是计算机系统的另一个非常重要的组成部分,即 **输入/输出** (I/O, Input/Output) 。CPU 会将请求和一些附加的参数写入外设,待外设处理完毕之后, CPU 便可以从外设读到请求的处理结果。比如在从作为外部存储的磁盘上读取数据的时候,CPU 将要读取的扇区的编号以及读到的数据放到的物理地址传给磁盘,在磁盘对请求进行调度并完成数据拷贝之后,就能在物理内存中看到要读取的数据。 -在一个应用对外设发出了请求之后,它不能立即向下执行,而是要等待外设将请求处理完毕并拿到完整的处理结果之后才能继续。那么如何知道 -外设是否已经完成了请求呢?通常外设会提供一个可读的寄存器记录它目前的工作状态,于是 CPU 需要不断原地循环读取它直到它的结果显示设备已经 -将请求处理完毕了,才能向下执行。然而,外设的计算速度和 CPU 相比可能慢了几个数量级,这就导致 CPU 有大量时间浪费在等待外设这件事情上, -这段时间它几乎没有做任何事情,也在一定程度上造成了 CPU 的利用率不够理想。 +在一个应用对外设发出了请求之后,它不能立即向下执行,而是要等待外设将请求处理完毕并拿到完整的处理结果之后才能继续。那么如何知道外设是否已经完成了请求呢?通常外设会提供一个可读的寄存器记录它目前的工作状态,于是 CPU 需要不断原地循环读取它直到它的结果显示设备已经将请求处理完毕了,才能向下执行。然而,外设的计算速度和 CPU 相比可能慢了几个数量级,这就导致 CPU 有大量时间浪费在等待外设这件事情上,这段时间它几乎没有做任何事情,也在一定程度上造成了 CPU 的利用率不够理想。 -我们暂时考虑 CPU 只能 *单方面* 通过读取外设提供的寄存器来获取外设请求处理的状态。多道程序的思想在于:内核同时管理多个应用。 -如果外设处理的时间足够长,那我们可以先进行任务切换去执行其他应用,在某次切换回来之后,应用再次读取设备寄存器,发现请求已经处理完毕了, -那么就可以用拿到的完整的数据继续向下执行了。这样的话,只要同时存在的应用足够多,就能保证 CPU 不必浪费时间在等待外设上,而是几乎一直 -在进行计算。这种任务切换,是通过应用进行一个名为 ``sys_yield`` 的系统调用来实现的,这意味着它主动交出 CPU 的使用权给其他应用。 +我们暂时考虑 CPU 只能 *单方面* 通过读取外设提供的寄存器来获取外设请求处理的状态。多道程序的思想在于:内核同时管理多个应用。如果外设处理的时间足够长,那我们可以先进行任务切换去执行其他应用,在某次切换回来之后,应用再次读取设备寄存器,发现请求已经处理完毕了,那么就可以用拿到的完整的数据继续向下执行了。这样的话,只要同时存在的应用足够多,就能保证 CPU 不必浪费时间在等待外设上,而是几乎一直在进行计算。这种任务切换,是通过应用进行一个名为 ``sys_yield`` 的系统调用来实现的,这意味着它主动交出 CPU 的使用权给其他应用。 -这正是本节标题的后半部分“协作式”的含义。一个应用会持续运行下去,直到它主动调用 -``sys_yield`` 来交出 CPU 使用权。内核将很大的权力下放到应用,让所有的应用互相协作来最终达成最大化 CPU 利用率,充分 -利用计算资源这一终极目标。在计算机发展的早期,由于应用基本上都是一些简单的计算任务,且程序员都比较遵守规则,因此内核可以 -信赖应用,这样协作式的制度是没有问题的。 +这正是本节标题的后半部分“协作式”的含义。一个应用会持续运行下去,直到它主动调用 ``sys_yield`` 来交出 CPU 使用权。内核将很大的权力下放到应用,让所有的应用互相协作来最终达成最大化 CPU 利用率,充分利用计算资源这一终极目标。在计算机发展的早期,由于应用基本上都是一些简单的计算任务,且程序员都比较遵守规则,因此内核可以信赖应用,这样协作式的制度是没有问题的。 .. image:: multiprogramming.png -上图描述了一种多道程序执行的典型情况。其中横轴为时间线,纵轴为正在执行的实体。开始时,某个应用(蓝色)向外设提交了一个请求, -随即可以看到对应的外设(紫色)开始工作。但是它要工作相当长的一段时间,因此应用(蓝色)不会去等待它结束而是会调用 ``sys_yield`` -主动交出 CPU 使用权来切换到另一个应用(绿色)。另一个应用(绿色)在执行了一段时间之后调用了 ``sys_yield`` ,此时内核决定让应用(蓝色) -继续执行。它检查了一下外设的工作状态,发现请求尚未处理完,于是再次调用 ``sys_yield`` 。然后另一个应用(绿色)执行了一段时间之后 -``sys_yield`` 再次切换回这个应用(蓝色),这次的不同是它发现外设已经处理完请求了,于是它终于可以向下执行了。 +上图描述了一种多道程序执行的典型情况。其中横轴为时间线,纵轴为正在执行的实体。开始时,某个应用(蓝色)向外设提交了一个请求,随即可以看到对应的外设(紫色)开始工作。但是它要工作相当长的一段时间,因此应用(蓝色)不会去等待它结束而是会调用 ``sys_yield`` 主动交出 CPU 使用权来切换到另一个应用(绿色)。另一个应用(绿色)在执行了一段时间之后调用了 ``sys_yield`` ,此时内核决定让应用(蓝色)继续执行。它检查了一下外设的工作状态,发现请求尚未处理完,于是再次调用 ``sys_yield`` 。然后另一个应用(绿色)执行了一段时间之后 ``sys_yield`` 再次切换回这个应用(蓝色),这次的不同是它发现外设已经处理完请求了,于是它终于可以向下执行了。 -上面我们是通过“避免无谓的外设等待来提高 CPU 利用率”这一切入点来引入 ``sys_yield`` 。但其实调用 ``sys_yield`` 不一定与外设有关。 -随着内核功能的逐渐复杂,我们还会遇到很多其他类型的需要等待其完成才能继续向下执行的事件,我们都可以立即调用 ``sys_yield`` 来避免等待 -过程造成的浪费。 +上面我们是通过“避免无谓的外设等待来提高 CPU 利用率”这一切入点来引入 ``sys_yield`` 。但其实调用 ``sys_yield`` 不一定与外设有关。随着内核功能的逐渐复杂,我们还会遇到很多其他类型的需要等待其完成才能继续向下执行的事件,我们都可以立即调用 ``sys_yield`` 来避免等待过程造成的浪费。 .. note:: @@ -65,9 +43,7 @@ CPU 便可以从外设读到请求的处理结果。比如在从作为外部存 请读者思考一下, ``sys_yield`` 存在哪些缺点? - 当应用调用它主动交出 CPU 使用权之后,它下一次再被允许使用 CPU 的时间点与内核的调度策略与当前的总体应用执行情况有关,很有可能 - 远远迟于该应用等待的事件(如外设处理完请求)达成的时间点。这就会造成该应用的响应延迟不稳定,有可能极高。比如,设想一下,敲击键盘 - 之后隔了数分钟之后才能在屏幕上看到字符,这已经超出了人类所能忍受的范畴。 + 当应用调用它主动交出 CPU 使用权之后,它下一次再被允许使用 CPU 的时间点与内核的调度策略与当前的总体应用执行情况有关,很有可能远远迟于该应用等待的事件(如外设处理完请求)达成的时间点。这就会造成该应用的响应延迟不稳定,有可能极高。比如,设想一下,敲击键盘之后隔了数分钟之后才能在屏幕上看到字符,这已经超出了人类所能忍受的范畴。 但也请不要担心,我们后面会有更加优雅的解决方案。 @@ -134,8 +110,7 @@ CPU 便可以从外设读到请求的处理结果。比如在从作为外部存 .. _term-task-control-block: -仅仅有这个是不够的,内核还需要保存一个应用的更多信息,我们将它们都保存在一个名为 **任务控制块** -(Task Control Block) 的数据结构中: +仅仅有这个是不够的,内核还需要保存一个应用的更多信息,我们将它们都保存在一个名为 **任务控制块** (Task Control Block) 的数据结构中: .. code-block:: rust :linenos: @@ -153,9 +128,7 @@ CPU 便可以从外设读到请求的处理结果。比如在从作为外部存 } } -可以看到我们还在 ``task_cx_ptr`` 字段中维护了一个上一小节中提到的指向应用被切换出去的时候,它内核栈栈顶的任务上下文 -的指针。而在任务切换函数 ``__switch`` 中我们需要用这个 ``task_cx_ptr`` 的指针作为参数并代表这个应用,于是 -``TaskControlBlock`` 还提供了获取这个指针的指针 ``task_cx_ptr2`` 的方法 ``get_task_cx_ptr2`` 。 +可以看到我们还在 ``task_cx_ptr`` 字段中维护了一个上一小节中提到的指向应用被切换出去的时候,它内核栈栈顶的任务上下文的指针。而在任务切换函数 ``__switch`` 中我们需要用这个 ``task_cx_ptr`` 的指针作为参数并代表这个应用,于是 ``TaskControlBlock`` 还提供了获取这个指针的指针 ``task_cx_ptr2`` 的方法 ``get_task_cx_ptr2`` 。 任务控制块非常重要。在内核中,它就是应用的管理单位。在后面的章节我们还会不断向里面添加更多内容。 @@ -180,17 +153,11 @@ CPU 便可以从外设读到请求的处理结果。比如在从作为外部存 unsafe impl Sync for TaskManager {} -其中仍然使用到了变量与常量分离的编程风格:字段 ``num_app`` 仍然表示任务管理器管理的应用的数目,它在 -``TaskManager`` 初始化之后就不会发生变化;而包裹在 ``TaskManagerInner`` 内的任务控制块数组 -``tasks`` 以及表示 CPU 正在执行的应用编号 ``current_task`` 会在执行应用的过程中发生变化: 每个 -应用的运行状态都会发生变化,而 CPU 执行的应用也在不断切换。 +其中仍然使用到了变量与常量分离的编程风格:字段 ``num_app`` 仍然表示任务管理器管理的应用的数目,它在 ``TaskManager`` 初始化之后就不会发生变化;而包裹在 ``TaskManagerInner`` 内的任务控制块数组 ``tasks`` 以及表示 CPU 正在执行的应用编号 ``current_task`` 会在执行应用的过程中发生变化: 每个应用的运行状态都会发生变化,而 CPU 执行的应用也在不断切换。 -再次强调,这里的 ``current_task`` 与第二章批处理系统中的含义不同。在批处理系统中,它表示一个既定的应用序列中的 -执行进度,隐含着在该应用之前的都已经执行完毕,之后都没有执行;而在这里我们只能通过它知道 CPU 正在执行哪个应用, -而不能获得其他应用的任何信息。 +再次强调,这里的 ``current_task`` 与第二章批处理系统中的含义不同。在批处理系统中,它表示一个既定的应用序列中的执行进度,隐含着在该应用之前的都已经执行完毕,之后都没有执行;而在这里我们只能通过它知道 CPU 正在执行哪个应用,而不能获得其他应用的任何信息。 -我们在使用之前初始化 ``TaskManager`` 的全局实例 ``TASK_MANAGER`` -(为此也需要将 ``TaskManager`` 标记为 ``Sync``): +我们在使用之前初始化 ``TaskManager`` 的全局实例 ``TASK_MANAGER`` (为此也需要将 ``TaskManager`` 标记为 ``Sync``): .. code-block:: rust :linenos: @@ -256,8 +223,7 @@ CPU 便可以从外设读到请求的处理结果。比如在从作为外部存 panic!("Unreachable in sys_exit!"); } -它的含义是退出当前的应用并切换到下个应用。在调用它之前我们打印应用的退出信息并输出它的退出码。如果是应用出错也应该 -调用该接口,不过我们这里并没有实现,有兴趣的读者可以尝试。 +它的含义是退出当前的应用并切换到下个应用。在调用它之前我们打印应用的退出信息并输出它的退出码。如果是应用出错也应该调用该接口,不过我们这里并没有实现,有兴趣的读者可以尝试。 那么 ``suspend_current_and_run_next`` 和 ``exit_current_and_run_next`` 各是如何实现的呢? @@ -304,9 +270,7 @@ CPU 便可以从外设读到请求的处理结果。比如在从作为外部存 } } -以 ``mark_current_suspended`` 为例。它调用了全局任务管理器 ``TASK_MANAGER`` 的 ``mark_current_suspended`` -方法。其中,首先获得里层 ``TaskManagerInner`` 的可变引用,然后根据其中记录的当前正在执行的应用 ID 对应在任务控制块 -数组 ``tasks`` 中修改状态。 +以 ``mark_current_suspended`` 为例。它调用了全局任务管理器 ``TASK_MANAGER`` 的 ``mark_current_suspended`` 方法。其中,首先获得里层 ``TaskManagerInner`` 的可变引用,然后根据其中记录的当前正在执行的应用 ID 对应在任务控制块数组 ``tasks`` 中修改状态。 接下来看看 ``run_next_task`` 的实现: @@ -341,18 +305,9 @@ CPU 便可以从外设读到请求的处理结果。比如在从作为外部存 } } -``run_next_task`` 使用任务管理器的全局实例 ``TASK_MANAGER`` 的 ``run_next_task`` 方法。它会调用 -``find_next_task`` 方法尝试寻找一个运行状态为 ``Ready`` 的应用并返回其 ID 。注意到其返回的类型是 -``Option`` ,也就是说不一定能够找到,当所有的应用都退出并将自身状态修改为 ``Exited`` 就会出现这种情况, -此时 ``find_next_task`` 应该返回 ``None`` 。如果能够找到下一个可运行的应用的话,我们就可以分别拿到当前应用 -``current`` 和即将被切换到的应用 ``next`` 的 ``task_cx_ptr2`` ,然后调用 ``__switch`` 接口进行切换。 -如果找不到的话,说明所有的应用都运行完毕了,我们可以直接 panic 退出内核。 +``run_next_task`` 使用任务管理器的全局实例 ``TASK_MANAGER`` 的 ``run_next_task`` 方法。它会调用 ``find_next_task`` 方法尝试寻找一个运行状态为 ``Ready`` 的应用并返回其 ID 。注意到其返回的类型是 ``Option`` ,也就是说不一定能够找到,当所有的应用都退出并将自身状态修改为 ``Exited`` 就会出现这种情况,此时 ``find_next_task`` 应该返回 ``None`` 。如果能够找到下一个可运行的应用的话,我们就可以分别拿到当前应用 ``current`` 和即将被切换到的应用 ``next`` 的 ``task_cx_ptr2`` ,然后调用 ``__switch`` 接口进行切换。如果找不到的话,说明所有的应用都运行完毕了,我们可以直接 panic 退出内核。 -注意在实际切换之前我们需要手动 drop 掉我们获取到的 ``TaskManagerInner`` 的可变引用。因为一般情况下它是在 -函数退出之后才会被自动释放,从而 ``TASK_MANAGER`` 的 ``inner`` 字段得以回归到未被借用的状态,之后可以再 -借用。如果不手动 drop 的话,编译器会在 ``__switch`` 返回,也就是当前应用被切换回来的时候才 drop,这期间我们 -都不能修改 ``TaskManagerInner`` ,甚至不能读(因为之前是可变借用)。正因如此,我们需要在 ``__switch`` 前 -提早手动 drop 掉 ``inner`` 。 +注意在实际切换之前我们需要手动 drop 掉我们获取到的 ``TaskManagerInner`` 的可变引用。因为一般情况下它是在函数退出之后才会被自动释放,从而 ``TASK_MANAGER`` 的 ``inner`` 字段得以回归到未被借用的状态,之后可以再借用。如果不手动 drop 的话,编译器会在 ``__switch`` 返回,也就是当前应用被切换回来的时候才 drop,这期间我们都不能修改 ``TaskManagerInner`` ,甚至不能读(因为之前是可变借用)。正因如此,我们需要在 ``__switch`` 前提早手动 drop 掉 ``inner`` 。 于是 ``find_next_task`` 又是如何实现的呢? @@ -373,17 +328,13 @@ CPU 便可以从外设读到请求的处理结果。比如在从作为外部存 } } -``TaskManagerInner`` 的 ``tasks`` 是一个固定的任务控制块组成的表,长度为 ``num_app`` ,可以用下标 -``0~num_app-1`` 来访问得到每个应用的控制状态。我们的任务就是找到 ``current_task`` 后面第一个状态为 -``Ready`` 的应用。因此从 ``current_task + 1`` 开始循环一圈,需要首先对 ``num_app`` 取模得到实际的 -下标,然后检查它的运行状态。 +``TaskManagerInner`` 的 ``tasks`` 是一个固定的任务控制块组成的表,长度为 ``num_app`` ,可以用下标 ``0~num_app-1`` 来访问得到每个应用的控制状态。我们的任务就是找到 ``current_task`` 后面第一个状态为 ``Ready`` 的应用。因此从 ``current_task + 1`` 开始循环一圈,需要首先对 ``num_app`` 取模得到实际的下标,然后检查它的运行状态。 .. note:: **Rust 语法卡片:迭代器** - ``a..b`` 实际上表示左闭右开区间 :math:`[a,b)` ,在 Rust 中,它会被表示为类型 ``core::ops::Range`` , - 标准库中为它实现好了 ``Iterator`` trait,因此它也是一个迭代器。 + ``a..b`` 实际上表示左闭右开区间 :math:`[a,b)` ,在 Rust 中,它会被表示为类型 ``core::ops::Range`` ,标准库中为它实现好了 ``Iterator`` trait,因此它也是一个迭代器。 关于迭代器的使用方法如 ``map/find`` 等,请参考 Rust 官方文档。 @@ -394,12 +345,9 @@ CPU 便可以从外设读到请求的处理结果。比如在从作为外部存 第一次进入用户态 ------------------------------------------ -在应用真正跑起来之前,需要 CPU 第一次从内核态进入用户态。我们在第二章批处理系统中也介绍过实现方法,只需在内核栈上 -压入构造好的 Trap 上下文,然后 ``__restore`` 即可。本章的思路大致相同,但是有一些变化。 +在应用真正跑起来之前,需要 CPU 第一次从内核态进入用户态。我们在第二章批处理系统中也介绍过实现方法,只需在内核栈上压入构造好的 Trap 上下文,然后 ``__restore`` 即可。本章的思路大致相同,但是有一些变化。 -当一个应用即将被运行的时候,它会被 ``__switch`` 过来。如果它是之前被切换出去的话,那么此时它的内核栈上应该有 -Trap 上下文和任务上下文,切换机制可以正常工作。但是如果它是第一次被执行怎么办呢?这就需要它的内核栈上也有类似 -结构的内容。我们是在创建 ``TaskManager`` 的全局实例 ``TASK_MANAGER`` 的时候来进行这个初始化的。 +当一个应用即将被运行的时候,它会被 ``__switch`` 过来。如果它是之前被切换出去的话,那么此时它的内核栈上应该有 Trap 上下文和任务上下文,切换机制可以正常工作。但是如果它是第一次被执行怎么办呢?这就需要它的内核栈上也有类似结构的内容。我们是在创建 ``TaskManager`` 的全局实例 ``TASK_MANAGER`` 的时候来进行这个初始化的。 .. code-block:: rust @@ -438,8 +386,7 @@ Trap 上下文和任务上下文,切换机制可以正常工作。但是如果 } } -这里 ``KernelStack`` 的 ``push_context`` 方法先压入一个和之前相同的 Trap 上下文,再在它上面压入一个任务上下文, -然后返回任务上下文的地址。这个任务上下文是我们通过 ``TaskContext::goto_restore`` 构造的: +这里 ``KernelStack`` 的 ``push_context`` 方法先压入一个和之前相同的 Trap 上下文,再在它上面压入一个任务上下文,然后返回任务上下文的地址。这个任务上下文是我们通过 ``TaskContext::goto_restore`` 构造的: .. code-block:: rust @@ -455,12 +402,9 @@ Trap 上下文和任务上下文,切换机制可以正常工作。但是如果 } } -它只是将任务上下文的 ``ra`` 寄存器设置为 ``__restore`` 的入口地址。这样,在 ``__switch`` 从它上面恢复并返回 -之后就会直接跳转到 ``__restore`` ,此时栈顶是一个我们构造出来第一次进入用户态执行的 Trap 上下文,就和第二章的 -情况一样了。 +它只是将任务上下文的 ``ra`` 寄存器设置为 ``__restore`` 的入口地址。这样,在 ``__switch`` 从它上面恢复并返回之后就会直接跳转到 ``__restore`` ,此时栈顶是一个我们构造出来第一次进入用户态执行的 Trap 上下文,就和第二章的情况一样了。 -需要注意的是, ``__restore`` 的实现需要做出变化:它不再需要在开头 ``mv sp, a0`` 了。因为在 ``__switch`` 之后, -``sp`` 就已经正确指向了我们需要的 Trap 上下文地址。 +需要注意的是, ``__restore`` 的实现需要做出变化:它 **不再需要** 在开头 ``mv sp, a0`` 了。因为在 ``__switch`` 之后,``sp`` 就已经正确指向了我们需要的 Trap 上下文地址。 在 ``rust_main`` 中我们调用 ``task::run_first_task`` 来开始应用的执行: @@ -488,14 +432,9 @@ Trap 上下文和任务上下文,切换机制可以正常工作。但是如果 TASK_MANAGER.run_first_task(); } -这里我们取出即将最先执行的编号为 0 的应用的 ``task_cx_ptr2`` 并希望能够切换过去。注意 ``__switch`` 有两个参数 -分别表示当前应用和即将切换到的应用的 ``task_cx_ptr2`` ,其第一个参数存在的意义是记录当前应用的任务上下文被保存在 -哪里,也就是当前应用内核栈的栈顶,这样之后才能继续执行该应用。但在 ``run_first_task`` 的时候,我们并没有执行任何 -应用, ``__switch`` 前半部分的保存仅仅是在启动栈上保存了一些之后不会用到的数据,自然也无需记录启动栈栈顶的位置。 +这里我们取出即将最先执行的编号为 0 的应用的 ``task_cx_ptr2`` 并希望能够切换过去。注意 ``__switch`` 有两个参数分别表示当前应用和即将切换到的应用的 ``task_cx_ptr2`` ,其第一个参数存在的意义是记录当前应用的任务上下文被保存在哪里,也就是当前应用内核栈的栈顶,这样之后才能继续执行该应用。但在 ``run_first_task`` 的时候,我们并没有执行任何应用, ``__switch`` 前半部分的保存仅仅是在启动栈上保存了一些之后不会用到的数据,自然也无需记录启动栈栈顶的位置。 -因此,我们显式声明了一个 ``_unused`` 变量,并将它的地址作为第一个参数传给 ``__switch`` ,这样保存一些寄存器之后的 -启动栈栈顶的位置将会保存在此变量中。然而无论是此变量还是启动栈我们之后均不会涉及到,一旦应用开始运行,我们就开始在应用 -的用户栈和内核栈之间开始切换了。这里声明此变量的意义仅仅是为了避免覆盖到其他数据。 +因此,我们显式声明了一个 ``_unused`` 变量,并将它的地址作为第一个参数传给 ``__switch`` ,这样保存一些寄存器之后的启动栈栈顶的位置将会保存在此变量中。然而无论是此变量还是启动栈我们之后均不会涉及到,一旦应用开始运行,我们就开始在应用的用户栈和内核栈之间开始切换了。这里声明此变量的意义仅仅是为了避免覆盖到其他数据。 三叠纪“始初龙”协作式操作系统 diff --git a/source/chapter3/4time-sharing-system.rst b/source/chapter3/4time-sharing-system.rst index 519c15a04b32e4dbb9731870966535e62333cc17..edf2c6b74308cc456d24333fc50869233f2923bd 100644 --- a/source/chapter3/4time-sharing-system.rst +++ b/source/chapter3/4time-sharing-system.rst @@ -15,37 +15,23 @@ .. _term-throughput: -上一节我们介绍了多道程序,它是一种允许应用在等待外设时主动切换到其他应用来达到总体 CPU 利用率最高的设计。那个时候, -应用是不太注重自身的运行情况的,即使它 yield 交出 CPU 资源之后需要很久才能再拿到,使得它真正在 CPU 执行的相邻 -两时间段距离都很远,应用也是无所谓的。因为它们的目标是总体 CPU 利用率最高,可以换成一个等价的指标: **吞吐量** -(Throughput) 。大概可以理解为在某个时间点将一组应用放进去,要求在一段固定的时间之内执行完毕的应用最多,或者是 -总进度百分比最大。因此,所有的应用和编写应用的程序员都有这样的共识:只要 CPU 一直在做实际的工作就好。 +上一节我们介绍了多道程序,它是一种允许应用在等待外设时主动切换到其他应用来达到总体 CPU 利用率最高的设计。那个时候,应用是不太注重自身的运行情况的,即使它 yield 交出 CPU 资源之后需要很久才能再拿到,使得它真正在 CPU 执行的相邻两时间段距离都很远,应用也是无所谓的。因为它们的目标是总体 CPU 利用率最高,可以换成一个等价的指标: **吞吐量** (Throughput) 。大概可以理解为在某个时间点将一组应用放进去,要求在一段固定的时间之内执行完毕的应用最多,或者是总进度百分比最大。因此,所有的应用和编写应用的程序员都有这样的共识:只要 CPU 一直在做实际的工作就好。 .. _term-background-application: .. _term-interactive-application: .. _term-latency: -从现在的眼光来看,当时的应用更多是一种 **后台应用** (Background Application) ,在将它加入执行队列之后我们 -只需定期确认它的运行状态。而随着技术的发展,涌现了越来越多的 **交互式应用** (Interactive Application) , -它们要达成的一个重要目标就是提高用户操作的响应速度,这样才能优化应用的使用体验。对于这些应用而言,即使需要等待外设 -或某些事件,它们也不会倾向于主动 yield 交出 CPU 使用权,因为这样可能会带来无法接受的延迟。也就是说,应用之间相比 -合作更多的是互相竞争宝贵的硬件资源。 +从现在的眼光来看,当时的应用更多是一种 **后台应用** (Background Application) ,在将它加入执行队列之后我们只需定期确认它的运行状态。而随着技术的发展,涌现了越来越多的 **交互式应用** (Interactive Application) ,它们要达成的一个重要目标就是提高用户操作的响应速度,这样才能优化应用的使用体验。对于这些应用而言,即使需要等待外设或某些事件,它们也不会倾向于主动 yield 交出 CPU 使用权,因为这样可能会带来无法接受的延迟。也就是说,应用之间相比合作更多的是互相竞争宝贵的硬件资源。 .. _term-cooperative-scheduling: .. _term-preemptive-scheduling: -如果应用自己很少 yield ,内核就要开始收回之前下放的权力,由它自己对 CPU 资源进行集中管理并合理分配给各应用,这就是 -内核需要提供的任务调度能力。我们可以将多道程序的调度机制分类成 **协作式调度** (Cooperative Scheduling) ,因为 -它的特征是:只要一个应用不主动 yield 交出 CPU 使用权,它就会一直执行下去。与之相对, **抢占式调度** -(Preemptive Scheduling) 则是应用 *随时* 都有被内核切换出去的可能。 +如果应用自己很少 yield ,内核就要开始收回之前下放的权力,由它自己对 CPU 资源进行集中管理并合理分配给各应用,这就是内核需要提供的任务调度能力。我们可以将多道程序的调度机制分类成 **协作式调度** (Cooperative Scheduling) ,因为它的特征是:只要一个应用不主动 yield 交出 CPU 使用权,它就会一直执行下去。与之相对, **抢占式调度** (Preemptive Scheduling) 则是应用 *随时* 都有被内核切换出去的可能。 .. _term-time-slice: .. _term-fairness: -现代的任务调度算法基本都是抢占式的,它要求每个应用只能连续执行一段时间,然后内核就会将它强制性切换出去。一般将 **时间片** -(Time Slice) 作为应用连续执行时长的度量单位,每个时间片可能在毫秒量级。调度算法需要考虑:每次在换出之前给 -一个应用多少时间片去执行,以及要换入哪个应用。可以从性能和 **公平性** (Fairness) 两个维度来评价调度算法,后者 -要求多个应用分到的时间片占比不应差距过大。 +现代的任务调度算法基本都是抢占式的,它要求每个应用只能连续执行一段时间,然后内核就会将它强制性切换出去。一般将 **时间片** (Time Slice) 作为应用连续执行时长的度量单位,每个时间片可能在毫秒量级。调度算法需要考虑:每次在换出之前给一个应用多少时间片去执行,以及要换入哪个应用。可以从性能和 **公平性** (Fairness) 两个维度来评价调度算法,后者要求多个应用分到的时间片占比不应差距过大。 @@ -54,9 +40,7 @@ .. _term-round-robin: -简单起见,本书中我们使用 **时间片轮转算法** (RR, Round-Robin) 来对应用进行调度,只要对它进行少许拓展就能完全满足我们后续的需求。 -本章中我们仅需要最原始的 RR 算法,用文字描述的话就是维护一个任务队列,每次从队头取出一个应用执行一个时间片,然后把它 -丢到队尾,再继续从队头取出一个应用,以此类推直到所有的应用执行完毕。 +简单起见,本书中我们使用 **时间片轮转算法** (RR, Round-Robin) 来对应用进行调度,只要对它进行少许拓展就能完全满足我们续的需求。本章中我们仅需要最原始的 RR 算法,用文字描述的话就是维护一个任务队列,每次从队头取出一个应用执行一个时间片,然后把它丢到队尾,再继续从队头取出一个应用,以此类推直到所有的应用执行完毕。 本节的代码可以在 ``ch3`` 分支上找到。 @@ -72,9 +56,7 @@ RISC-V 架构中的中断 时间片轮转调度的核心机制就在于计时。操作系统的计时功能是依靠硬件提供的时钟中断来实现的。在介绍时钟中断之前,我们先简单介绍一下中断。 -**中断** (Interrupt) 和我们第二章中介绍的 用于系统调用的 **陷入** ``Trap`` 一样都是异常 ,但是它们被触发的原因确是不同的。对于某个处理器核而言, **陷入** 与发起 **陷入** 的指令执行是 **同步** (Synchronous) 的, **陷入** -被触发的原因一定能够追溯到某条指令的执行;而中断则 **异步** (Asynchronous) 于当前正在进行的指令,也就是说中断来自于哪个 -外设以及中断如何触发完全与处理器正在执行的当前指令无关。 +**中断** (Interrupt) 和我们第二章中介绍的 用于系统调用的 **陷入** ``Trap`` 一样都是异常 ,但是它们被触发的原因确是不同的。对于某个处理器核而言, **陷入** 与发起 **陷入** 的指令执行是 **同步** (Synchronous) 的, **陷入** 被触发的原因一定能够追溯到某条指令的执行;而中断则 **异步** (Asynchronous) 于当前正在进行的指令,也就是说中断来自于哪个外设以及中断如何触发完全与处理器正在执行的当前指令无关。 .. _term-parallel: @@ -83,14 +65,9 @@ RISC-V 架构中的中断 **从底层硬件的角度区分同步和异步** 从底层硬件的角度可能更容易理解这里所提到的同步和异步。以一个处理器传统的五级流水线设计而言,里面含有取指、译码、算术、 - 访存、寄存器等单元,都属于执行指令所需的硬件资源。那么假如某条指令的执行出现了问题,一定能被其中某个单元看到并反馈给流水线 - 控制单元,从而它会在执行预定的下一条指令之前先进入异常处理流程。也就是说,异常在这些单元内部即可被发现并解决。 + 访存、寄存器等单元,都属于执行指令所需的硬件资源。那么假如某条指令的执行出现了问题,一定能被其中某个单元看到并反馈给流水线控制单元,从而它会在执行预定的下一条指令之前先进入异常处理流程。也就是说,异常在这些单元内部即可被发现并解决。 - 而对于中断,可以想象为想发起中断的是一套完全 - 不同的电路(从时钟中断来看就是简单的计数和比较器),这套电路仅通过一根导线接入进来,当想要触发中断的时候则输入一个高电平 - 或正边沿,处理器会在每执行完一条指令之后检查一下这根线,看情况决定是继续执行接下来的指令还是进入中断处理流程。也就是说, - 大多数情况下,指令执行的相关硬件单元和可能发起中断的电路是完全独立 **并行** (Parallel) 运行的,它们中间只有一根导线相连 - ,除此之外指令执行的那些单元就完全不知道对方处于什么状态了。 + 而对于中断,可以想象为想发起中断的是一套完全不同的电路(从时钟中断来看就是简单的计数和比较器),这套电路仅通过一根导线接入进来,当想要触发中断的时候则输入一个高电平或正边沿,处理器会在每执行完一条指令之后检查一下这根线,看情况决定是继续执行接下来的指令还是进入中断处理流程。也就是说,大多数情况下,指令执行的相关硬件单元和可能发起中断的电路是完全独立 **并行** (Parallel) 运行的,它们中间只有一根导线相连,除此之外指令执行的那些单元就完全不知道对方处于什么状态了。 在不考虑指令集拓展的情况下,RISC-V 架构中定义了如下中断: @@ -131,49 +108,36 @@ RISC-V 的中断可以分成三类: - **时钟中断** (Timer Interrupt) - **外部中断** (External Interrupt) -另外,相比异常,中断和特权级之间的联系更为紧密,可以看到这三种中断每一个都有 M/S 特权级两个版本。中断的特权级可以 -决定该中断是否会被屏蔽,以及需要 Trap 到 CPU 的哪个特权级进行处理。 +另外,相比异常,中断和特权级之间的联系更为紧密,可以看到这三种中断每一个都有 M/S 特权级两个版本。中断的特权级可以决定该中断是否会被屏蔽,以及需要 Trap 到 CPU 的哪个特权级进行处理。 在判断中断是否会被屏蔽的时候,有以下规则: - 如果中断的特权级低于 CPU 当前的特权级,则该中断会被屏蔽,不会被处理; - 如果中断的特权级高于与 CPU 当前的特权级或相同,则需要通过相应的 CSR 判断该中断是否会被屏蔽。 -以内核所在的 S 特权级为例,中断屏蔽相应的 CSR 有 ``sstatus`` 和 ``sie`` 。``sstatus`` 的 ``sie`` 为 S 特权级 -的中断使能,能够同时控制三种中断,如果将其清零则会将它们全部屏蔽。即使 ``sstatus.sie`` 置 1 ,还要看 ``sie`` 这个 -CSR,它的三个字段 ``ssie/stie/seie`` 分别控制 S 特权级的软件中断、时钟中断和外部中断的中断使能。比如对于 S 态时钟 -中断来说,如果 CPU 不高于 S 特权级,需要 ``sstatus.sie`` 和 ``sie.stie`` 均为 1 该中断才不会被屏蔽;如果 CPU -当前特权级高于 S 特权级,则该中断一定会被屏蔽。 +以内核所在的 S 特权级为例,中断屏蔽相应的 CSR 有 ``sstatus`` 和 ``sie`` 。``sstatus`` 的 ``sie`` 为 S 特权级的中断使能,能够同时控制三种中断,如果将其清零则会将它们全部屏蔽。即使 ``sstatus.sie`` 置 1 ,还要看 ``sie`` 这个 CSR,它的三个字段 ``ssie/stie/seie`` 分别控制 S 特权级的软件中断、时钟中断和外部中断的中断使能。比如对于 S 态时钟中断来说,如果 CPU 不高于 S 特权级,需要 ``sstatus.sie`` 和 ``sie.stie`` 均为 1 该中断才不会被屏蔽;如果 CPU 当前特权级高于 S 特权级,则该中断一定会被屏蔽。 -如果中断没有被屏蔽,那么接下来就需要 Trap 进行处理,而具体 Trap 到哪个特权级与一些中断代理 CSR 的设置有关。默认情况 -下,所有的中断都需要 Trap 到 M 特权级处理。而设置这些代理 CSR 之后,就可以 Trap 到低特权级处理,但是 Trap 到的特权 -级不能低于中断的特权级。事实上所有的异常默认也都是 Trap 到 M 特权级处理的,它们也有一套对应的异常代理 CSR ,设置之后 -也可以 Trap 到低优先级来处理异常。 +如果中断没有被屏蔽,那么接下来就需要 Trap 进行处理,而具体 Trap 到哪个特权级与一些中断代理 CSR 的设置有关。默认情况下,所有的中断都需要 Trap 到 M 特权级处理。而设置这些代理 CSR 之后,就可以 Trap 到低特权级处理,但是 Trap 到的特权级不能低于中断的特权级。事实上所有的异常默认也都是 Trap 到 M 特权级处理的,它们也有一套对应的异常代理 CSR ,设置之后也可以 Trap 到低优先级来处理异常。 我们会在 :doc:`/appendix-c/index` 中再深入介绍中断/异常代理。在正文中我们只需要了解: - 包括系统调用(即来自 U 特权级的环境调用)在内的所有异常都会 Trap 到 S 特权级处理; - 只需考虑 S 特权级的时钟/软件/外部中断,且它们都会被 Trap 到 S 特权级处理。 -默认情况下,当 Trap 进入某个特权级之后,在 Trap 处理的过程中同特权级的中断都会被屏蔽。这里我们还需要对第二章介绍的 -Trap 发生时的硬件机制做一下补充,同样以 Trap 到 S 特权级为例: +默认情况下,当 Trap 进入某个特权级之后,在 Trap 处理的过程中同特权级的中断都会被屏蔽。这里我们还需要对第二章介绍的 Trap 发生时的硬件机制做一下补充,同样以 Trap 到 S 特权级为例: -- 当 Trap 发生时,``sstatus.sie`` 会被保存在 ``sstatus.spie`` 字段中,同时 ``sstatus.sie`` 置零,这也就在 - Trap 处理的过程中屏蔽了所有 S 特权级的中断; +- 当 Trap 发生时,``sstatus.sie`` 会被保存在 ``sstatus.spie`` 字段中,同时 ``sstatus.sie`` 置零,这也就在 Trap 处理的过程中屏蔽了所有 S 特权级的中断; - 当 Trap 处理完毕 ``sret`` 的时候, ``sstatus.sie`` 会恢复到 ``sstatus.spie`` 内的值。 .. _term-nested-interrupt: -也就是说,如果不去手动设置 ``sstatus`` CSR ,在只考虑 S 特权级中断的情况下,是不会出现 **嵌套中断** -(Nested Interrupt) 的。嵌套中断是指在处理一个中断的过程中再一次触发了中断从而通过 Trap 来处理。由于默认情况下 -一旦进入 Trap 硬件就自动禁用所有同特权级中断,自然也就不会再次触发中断导致嵌套中断了。 +也就是说,如果不去手动设置 ``sstatus`` CSR ,在只考虑 S 特权级中断的情况下,是不会出现 **嵌套中断** (Nested Interrupt) 的。嵌套中断是指在处理一个中断的过程中再一次触发了中断从而通过 Trap 来处理。由于默认情况下一旦进入 Trap 硬件就自动禁用所有同特权级中断,自然也就不会再次触发中断导致嵌套中断了。 .. note:: **嵌套中断与嵌套 Trap** - 嵌套中断可以分为两部分:在处理一个中断的过程中又被同特权级/高特权级中断所打断。默认情况下硬件会避免前一部分, - 也可以通过手动设置来允许前一部分的存在;而从上面介绍的规则可以知道,后一部分则是无论如何设置都不可避免的。 + 嵌套中断可以分为两部分:在处理一个中断的过程中又被同特权级/高特权级中断所打断。默认情况下硬件会避免前一部分,也可以通过手动设置来允许前一部分的存在;而从上面介绍的规则可以知道,后一部分则是无论如何设置都不可避免的。 嵌套 Trap 则是指处理一个 Trap 过程中又再次发生 Trap ,嵌套中断算是嵌套 Trap 的一部分。 @@ -181,22 +145,17 @@ Trap 发生时的硬件机制做一下补充,同样以 Trap 到 S 特权级为 **RISC-V 架构的 U 特权级中断** - 目前,RISC-V 用户态中断作为代号 N 的一个指令集拓展而存在。有兴趣的读者可以阅读最新版的 RISC-V 特权级架构 - 规范一探究竟。 + 目前,RISC-V 用户态中断作为代号 N 的一个指令集拓展而存在。有兴趣的读者可以阅读最新版的 RISC-V 特权级架构规范一探究竟。 时钟中断与计时器 ------------------------------------------------------------------ -由于需要一种计时机制,RISC-V 架构要求处理器要有一个内置时钟,其频率一般低于 CPU 主频。此外,还有一个计数器统计处理器 -自上电以来经过了多少个内置时钟的时钟周期。在 RV64 架构上,该计数器保存在一个 64 位的 CSR ``mtime`` 中,我们无需担 -心它的溢出问题,在内核运行全程可以认为它是一直递增的。 +由于需要一种计时机制,RISC-V 架构要求处理器要有一个内置时钟,其频率一般低于 CPU 主频。此外,还有一个计数器统计处理器自上电以来经过了多少个内置时钟的时钟周期。在 RV64 架构上,该计数器保存在一个 64 位的 CSR ``mtime`` 中,我们无需担心它的溢出问题,在内核运行全程可以认为它是一直递增的。 -另外一个 64 位的 CSR ``mtimecmp`` 的作用是:一旦计数器 ``mtime`` 的值超过了 ``mtimecmp``,就会触发一次时钟中断。 -这使得我们可以方便的通过设置 ``mtimecmp`` 的值来决定下一次时钟中断何时触发。 +另外一个 64 位的 CSR ``mtimecmp`` 的作用是:一旦计数器 ``mtime`` 的值超过了 ``mtimecmp``,就会触发一次时钟中断。这使得我们可以方便的通过设置 ``mtimecmp`` 的值来决定下一次时钟中断何时触发。 -可惜的是,它们都是 M 特权级的 CSR ,而我们的内核处在 S 特权级,是不被硬件允许直接访问它们的。好在运行在 M 特权级的 SEE -已经预留了相应的接口,我们可以调用它们来间接实现计时器的控制: +可惜的是,它们都是 M 特权级的 CSR ,而我们的内核处在 S 特权级,是不被硬件允许直接访问它们的。好在运行在 M 特权级的 SEE 已经预留了相应的接口,我们可以调用它们来间接实现计时器的控制: .. code-block:: rust @@ -230,14 +189,10 @@ Trap 发生时的硬件机制做一下补充,同样以 Trap 到 S 特权级为 set_timer(get_time() + CLOCK_FREQ / TICKS_PER_SEC); } -- 代码片段第 5 行, ``sbi`` 子模块有一个 ``set_timer`` 调用,是一个由 SEE 提供的标准 SBI 接口函数, - 它可以用来设置 ``mtimecmp`` 的值。 -- 代码片段第 14 行, ``timer`` 子模块的 ``set_next_trigger`` 函数对 ``set_timer`` 进行了封装,它首先读取 - 当前 ``mtime`` 的值,然后计算出 10ms 之内计数器的增量,再将 ``mtimecmp`` 设置为二者的和。这样,10ms 之后 - 一个 S 特权级时钟中断就会被触发。 +- 代码片段第 5 行, ``sbi`` 子模块有一个 ``set_timer`` 调用,是一个由 SEE 提供的标准 SBI 接口函数,它可以用来设置 ``mtimecmp`` 的值。 +- 代码片段第 14 行, ``timer`` 子模块的 ``set_next_trigger`` 函数对 ``set_timer`` 进行了封装,它首先读取当前 ``mtime`` 的值,然后计算出 10ms 之内计数器的增量,再将 ``mtimecmp`` 设置为二者的和。这样,10ms 之后一个 S 特权级时钟中断就会被触发。 - 至于增量的计算方式, ``CLOCK_FREQ`` 是一个预先获取到的各平台不同的时钟频率,单位为赫兹,也就是一秒钟之内计数器 - 的增量。它可以在 ``config`` 子模块中找到。10ms 的话只需除以常数 ``TICKS_PER_SEC`` 也就是 100 即可。 + 至于增量的计算方式, ``CLOCK_FREQ`` 是一个预先获取到的各平台不同的时钟频率,单位为赫兹,也就是一秒钟之内计数器的增量。它可以在 ``config`` 子模块中找到。10ms 的话只需除以常数 ``TICKS_PER_SEC`` 也就是 100 即可。 后面可能还有一些计时的操作,比如统计一个应用的运行时长的需求,我们再设计一个函数: @@ -251,8 +206,7 @@ Trap 发生时的硬件机制做一下补充,同样以 Trap 到 S 特权级为 time::read() / (CLOCK_FREQ / MSEC_PER_SEC) } -``timer`` 子模块的 ``get_time_ms`` 可以以毫秒为单位返回当前计数器的值,这让我们终于能对时间有一个具体概念了。 -实现原理就不再赘述。 +``timer`` 子模块的 ``get_time_ms`` 可以以毫秒为单位返回当前计数器的值,这让我们终于能对时间有一个具体概念了。实现原理就不再赘述。 我们也新增一个系统调用方便应用获取当前的时间,以毫秒为单位: @@ -283,8 +237,7 @@ Trap 发生时的硬件机制做一下补充,同样以 Trap 到 S 特权级为 } } -我们只需在 ``trap_handler`` 函数下新增一个分支,当发现触发了一个 S 特权级时钟中断的时候,首先重新设置一个 10ms -的计时器,然后调用上一小节提到的 ``suspend_current_and_run_next`` 函数暂停当前应用并切换到下一个。 +我们只需在 ``trap_handler`` 函数下新增一个分支,当发现触发了一个 S 特权级时钟中断的时候,首先重新设置一个 10ms 的计时器,然后调用上一小节提到的 ``suspend_current_and_run_next`` 函数暂停当前应用并切换到下一个。 为了避免 S 特权级时钟中断被屏蔽,我们需要在执行第一个应用之前进行一些初始化设置: @@ -317,14 +270,9 @@ Trap 发生时的硬件机制做一下补充,同样以 Trap 到 S 特权级为 - 第 9 行设置了 ``sie.stie`` 使得 S 特权级时钟中断不会被屏蔽; - 第 10 行则是设置第一个 10ms 的计时器。 -这样,当一个应用运行了 10ms 之后,一个 S 特权级时钟中断就会被触发。由于应用运行在 U 特权级,且 ``sie`` 寄存器被正确 -设置,该中断不会被屏蔽,而是 Trap 到 S 特权级内的我们的 ``trap_handler`` 里面进行处理,并顺利切换到下一个应用。这 -便是我们所期望的抢占式调度机制。从应用运行的结果也可以看出,三个 ``power`` 系列应用并没有进行 yield ,而是由内核负责 -公平分配它们执行的时间片。 +这样,当一个应用运行了 10ms 之后,一个 S 特权级时钟中断就会被触发。由于应用运行在 U 特权级,且 ``sie`` 寄存器被正确设置,该中断不会被屏蔽,而是 Trap 到 S 特权级内的我们的 ``trap_handler`` 里面进行处理,并顺利切换到下一个应用。这便是我们所期望的抢占式调度机制。从应用运行的结果也可以看出,三个 ``power`` 系列应用并没有进行 yield ,而是由内核负责公平分配它们执行的时间片。 -目前在等待某些事件的时候仍然需要 yield ,其中一个原因是为了节约 CPU 计算资源,另一个原因是当事件依赖于其他的应用的时候 -,由于只有一个 CPU ,当前应用的等待可能永远不会结束。这种情况下需要先将它切换出去,使得其他的应用到达它所期待的状态并 -满足事件的生成条件,再切换回来。 +目前在等待某些事件的时候仍然需要 yield ,其中一个原因是为了节约 CPU 计算资源,另一个原因是当事件依赖于其他的应用的时候,由于只有一个 CPU ,当前应用的等待可能永远不会结束。这种情况下需要先将它切换出去,使得其他的应用到达它所期待的状态并满足事件的生成条件,再切换回来。 .. _term-busy-loop: @@ -345,9 +293,7 @@ Trap 发生时的硬件机制做一下补充,同样以 Trap 到 S 特权级为 0 } -它的功能是等待 3000ms 然后退出。可以看出,我们会在循环里面 ``yield_`` 来主动交出 CPU 而不是无意义的忙等。尽管 -我们不这样做,已有的抢占式调度还是会在它循环 10ms 之后切换到其他应用,但是这样能让内核给其他应用分配更多的 CPU -资源并让它们更早运行结束。 +它的功能是等待 3000ms 然后退出。可以看出,我们会在循环里面 ``yield_`` 来主动交出 CPU 而不是无意义的忙等。尽管我们不这样做,已有的抢占式调度还是会在它循环 10ms 之后切换到其他应用,但是这样能让内核给其他应用分配更多的 CPU 资源并让它们更早运行结束。 三叠纪“腔骨龙”抢占式操作系统 --------------------------------- diff --git a/source/chapter4/7exercise.rst b/source/chapter4/7exercise.rst index 10754fae9fdfad87001796b5de14a907a17d38ac..6659d1e316148c7cf7dc2070689bd39e9af9b452 100644 --- a/source/chapter4/7exercise.rst +++ b/source/chapter4/7exercise.rst @@ -104,7 +104,7 @@ challenge: 支持多核。 - 如何更换页表? - 单页表情况下,如何控制用户态无法访问内核页面?(tips:看看上一题最后一问) - 单页表有何优势?(回答合理即可) - - 双页表实现下,何时需要更换页表?假设你写一个单页表操作系统,你会选择合适更换页表(回答合理即可)? + - 双页表实现下,何时需要更换页表?假设你写一个单页表操作系统,你会选择何时更换页表(回答合理即可)? 报告要求 -------------------------------------------------------- diff --git a/source/chapter5/1process.rst b/source/chapter5/1process.rst index 03204433ee304748323134e69bcfea6ea4a1c3c7..28c3f06c8bc92cdd1f96d6536d49fc16ae0238a5 100644 --- a/source/chapter5/1process.rst +++ b/source/chapter5/1process.rst @@ -95,7 +95,7 @@ waitpid 系统调用 /// 功能:当前进程等待一个子进程变为僵尸进程,回收其全部资源并收集其返回值。 /// 参数:pid 表示要等待的子进程的进程 ID,如果为 -1 的话表示等待任意一个子进程; /// exit_code 表示保存子进程返回值的地址,如果这个地址为 0 的话表示不必保存。 - /// 返回值:如果要等待的子进程不存在则返回 -1;否则如果要等待的子进程均为结束则返回 -2; + /// 返回值:如果要等待的子进程不存在则返回 -1;否则如果要等待的子进程均未结束则返回 -2; /// 否则返回结束的子进程的进程 ID。 /// syscall ID:260 pub fn sys_waitpid(pid: isize, exit_code: *mut i32) -> isize;