提交 c382e75e 编写于 作者: chyyuu1972's avatar chyyuu1972

Merge branch 'main' of github.com:rcore-os/rCore-Tutorial-Book-v3 into main

......@@ -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!
......
......@@ -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!`` 宏的实现。
另外需要说明的是一点是:与上一章不同,应用的编号不再决定其被加载运行的先后顺序,而仅仅能够改变应用被加载到内存中的位置。
......
......@@ -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`` 开始依次为每个应用预留一段空间。
这样,我们就说明了多个应用是如何被构建和加载的。
......
......@@ -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:`函数调用与栈 <function-call-and-stack>` 。当时提到过,为了支持嵌套函数调用,不仅需要
硬件平台提供特殊的跳转指令,还需要保存和恢复 :ref:`函数调用上下文 <term-function-context>` 。注意在 *我们* 的定义中,函数调用包含在普通控制流(与异常控制流相对)之内
,且始终用一个固定的栈来保存执行的历史记录,因此函数调用并不涉及执行流的切换。但是我们依然可以将其看成调用者和被调用者两个执行过程的“切换”,
二者的协作体现在它们都遵循调用规范,分别保存一部分通用寄存器,这样的好处是编译器能够有足够的信息来尽可能减少需要保存的寄存器的数目。
虽然当时用了很大的篇幅来说明,但其实整个过程都是编译器负责完成的,我们只需设置好栈就行了。
- 第一章《RV64 裸机应用》中,我们介绍了 :ref:`函数调用与栈 <function-call-and-stack>` 。当时提到过,为了支持嵌套函数调用,不仅需要硬件平台提供特殊的跳转指令,还需要保存和恢复 :ref:`函数调用上下文 <term-function-context>` 。注意在 *我们* 的定义中,函数调用包含在普通控制流(与异常控制流相对)之内,且始终用一个固定的栈来保存执行的历史记录,因此函数调用并不涉及执行流的切换。但是我们依然可以将其看成调用者和被调用者两个执行过程的“切换”,二者的协作体现在它们都遵循调用规范,分别保存一部分通用寄存器,这样的好处是编译器能够有足够的信息来尽可能减少需要保存的寄存器的数目。虽然当时用了很大的篇幅来说明,但其实整个过程都是编译器负责完成的,我们只需设置好栈就行了。
- 第二章《批处理系统》中第一次涉及到了某种异常(Trap)控制流,即两条执行流的切换,需要保存和恢复 :ref:`系统调用(Trap)上下文 <term-trap-context>` 。当时,为了让内核能够 *完全掌控* 应用的执行,且不会被应用破坏整个系统,我们必须利用硬件
提供的特权级机制,让应用和内核运行在不同的特权级。应用运行在 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 上下文保存与恢复 <trap-context-save-restore>` 小节。
......@@ -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 调用规范 <term-calling-convention>` 可以知道它们分别通过寄存器 ``a0/a1`` 传入。
我们手写汇编代码来实现 ``__switch`` 。可以看到它的函数原型中的两个参数分别是当前 Trap 执行流和即将被切换到的 Trap 执行流的 ``task_cx_ptr2`` ,从 :ref:`RISC-V 调用规范 <term-calling-convention>` 可以知道它们分别通过寄存器 ``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:有一个内核态切换的例子。
......
......@@ -104,7 +104,7 @@ challenge: 支持多核。
- 如何更换页表?
- 单页表情况下,如何控制用户态无法访问内核页面?(tips:看看上一题最后一问)
- 单页表有何优势?(回答合理即可)
- 双页表实现下,何时需要更换页表?假设你写一个单页表操作系统,你会选择合适更换页表(回答合理即可)?
- 双页表实现下,何时需要更换页表?假设你写一个单页表操作系统,你会选择何时更换页表(回答合理即可)?
报告要求
--------------------------------------------------------
......
......@@ -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;
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册