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

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

......@@ -20,6 +20,11 @@ help:
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
deploy:
@make clean
@make html
@rm -rf docs
@cp -r build/html docs
@touch docs/.nojekyll
@git add -A
@git commit -m "Deploy"
@git push
# Sphinx build info version 1
# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
config: 6e307cd3ba0daae86ac8cbb85a0d88aa
tags: 645f666f9bcd5a90fca523b33c5a78b7
附录 A:Rust 快速入门
=============================
.. toctree::
:hidden:
:maxdepth: 4
.. note::
**Rust 语法卡片:外部符号引用**
extern "C" 可以引用一个外部的 C 函数接口(这意味着调用它的时候要遵从目标平台的 C 语言调用规范)。但我们这里只是引用位置标志
并将其转成 usize 获取它的地址。由此可以知道 ``.bss`` 段两端的地址。
**Rust 语法卡片:迭代器与闭包**
代码第 7 行用到了 Rust 的迭代器与闭包的语法,它们在很多情况下能够提高开发效率。如读者感兴趣的话也可以将其改写为等价的 for
循环实现。
.. _term-raw-pointer:
.. _term-dereference:
.. warning::
**Rust 语法卡片:Unsafe**
代码第 8 行,我们将 ``.bss`` 段内的一个地址转化为一个 **裸指针** (Raw Pointer),并将它指向的值修改为 0。这在 C 语言中是
一种司空见惯的操作,但在 Rust 中我们需要将他包裹在 unsafe 块中。这是因为,Rust 认为对于裸指针的 **解引用** (Dereference)
是一种 unsafe 行为。
相比 C 语言,Rust 进行了更多的语义约束来保证安全性(内存安全/类型安全/并发安全),这在编译期和运行期都有所体现。但在某些时候,
尤其是与底层硬件打交道的时候,在 Rust 的语义约束之内没法满足我们的需求,这个时候我们就需要将超出了 Rust 语义约束的行为包裹
在 unsafe 块中,告知编译器不需要对它进行完整的约束检查,而是由程序员自己负责保证它的安全性。当代码不能正常运行的时候,我们往往也是
最先去检查 unsafe 块中的代码,因为它没有受到编译器的保护,出错的概率更大。
C 语言中的指针相当于 Rust 中的裸指针,它无所不能但又太过于灵活,程序员对其不谨慎的使用常常会引起很多内存不安全问题,最常见的如
悬垂指针和多次回收的问题,Rust 编译器没法确认程序员对它的使用是否安全,因此将其划到 unsafe Rust 的领域。在 safe Rust 中,我们
有引用 ``&/&mut`` 以及各种功能各异的智能指针 ``Box<T>/RefCell<T>/Rc<T>`` 可以使用,只要按照 Rust 的规则来使用它们便可借助
编译器在编译期就解决很多潜在的内存不安全问题。
- `Stanford 新开的一门很值得学习的 Rust 入门课程 <https://reberhardt.com/cs110l/spring-2020/>`_
- `一份简单的 Rust 入门介绍 <https://zhuanlan.zhihu.com/p/298648575>`_
- `《RustOS Guide》中的 Rust 介绍部分 <https://simonkorl.gitbook.io/r-z-rustos-guide/dai-ma-zhi-qian/ex1>`_
\ No newline at end of file
附录 B:常见工具的使用方法
========================================
.. toctree::
:hidden:
:maxdepth: 4
分析可执行文件
------------------------
对于Rust编译器生成的执行程序,可通过各种有效工具进行分析。如果掌握了对这些工具的使用,那么在后续的开发工作中,对碰到的各种奇怪问题就进行灵活处理和解决了。
我们以Rust编译生成的一个简单的“Hello, world”应用执行程序为分析对象,看看如何进行分析。
让我们先来通过 ``file`` 工具看看最终生成的可执行文件的格式:
.. code-block:: console
$ cargo new os
$ cd os; cargo build
Compiling os v0.1.0 (/tmp/os)
Finished dev [unoptimized + debuginfo] target(s) in 0.26s
$ file target/debug/os
target/debug/os: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, ......
$
.. _term-elf:
.. _term-metadata:
从中可以看出可执行文件的格式为 **可执行和链接格式** (Executable and Linkable Format, ELF),硬件平台是 x86-64。在 ELF 文件中,
除了程序必要的代码、数据段(它们本身都只是一些二进制的数据)之外,还有一些 **元数据** (Metadata) 描述这些段在地址空间中的位置和在
文件中的位置以及一些权限控制信息,这些元数据只能放在代码、数据段的外面。
rust-readobj
^^^^^^^^^^^^^^^^^^^^^^^
我们可以通过二进制工具 ``rust-readobj`` 来看看 ELF 文件中究竟包含什么内容,输入命令:
.. code-block:: console
$ rust-readobj -all target/debug/os
首先可以看到一个 ELF header,它位于 ELF 文件的开头:
.. code-block:: objdump
:linenos:
:emphasize-lines: 8,19,20,21,24,25,26,27
File: target/debug/os
Format: elf64-x86-64
Arch: x86_64
AddressSize: 64bit
LoadName:
ElfHeader {
Ident {
Magic: (7F 45 4C 46)
Class: 64-bit (0x2)
DataEncoding: LittleEndian (0x1)
FileVersion: 1
OS/ABI: SystemV (0x0)
ABIVersion: 0
Unused: (00 00 00 00 00 00 00)
}
Type: SharedObject (0x3)
Machine: EM_X86_64 (0x3E)
Version: 1
Entry: 0x5070
ProgramHeaderOffset: 0x40
SectionHeaderOffset: 0x32D8D0
Flags [ (0x0)
]
HeaderSize: 64
ProgramHeaderEntrySize: 56
ProgramHeaderCount: 12
SectionHeaderEntrySize: 64
SectionHeaderCount: 42
StringTableSectionIndex: 41
}
......
.. _term-magic:
- 第 8 行是一个称之为 **魔数** (Magic) 独特的常数,存放在 ELF header 的一个固定位置。当加载器将 ELF 文件加载到内存之前,通常会查看
该位置的值是否正确,来快速确认被加载的文件是不是一个 ELF 。
- 第 19 行给出了可执行文件的入口点为 ``0x5070`` 。
- 从 20-21 行中,我们可以知道除了 ELF header 之外,还有另外两种不同的 header,分别称为 program header 和 section header,
它们都有多个。ELF header 中给出了其他两种header 的大小、在文件中的位置以及数目。
- 从 24-27 行中,可以看到有 12 个不同的 program header,它们从文件的 0x40 字节偏移处开始,每个 56 字节;
有64个section header,它们从文件的 0x2D8D0 字节偏移处开始,每个 64 字节;
有多个不同的 section header,下面是个具体的例子:
.. code-block:: objdump
......
Section {
Index: 14
Name: .text (157)
Type: SHT_PROGBITS (0x1)
Flags [ (0x6)
SHF_ALLOC (0x2)
SHF_EXECINSTR (0x4)
]
Address: 0x5070
Offset: 0x5070
Size: 208067
Link: 0
Info: 0
AddressAlignment: 16
EntrySize: 0
}
每个 section header 则描述一个段的元数据。
其中,我们看到了代码段 ``.text`` 需要被加载到地址 ``0x5070`` ,大小 208067 字节,。
它们分别由元数据的字段 Offset、 Size 和 Address 给出。。
我们还能够看到程序中的符号表:
.. code-block::
Symbol {
Name: _start (37994)
Value: 0x5070
Size: 47
Binding: Global (0x1)
Type: Function (0x2)
Other: 0
Section: .text (0xE)
}
Symbol {
Name: main (38021)
Value: 0x51A0
Size: 47
Binding: Global (0x1)
Type: Function (0x2)
Other: 0
Section: .text (0xE)
}
里面包括了我们写的 ``main`` 函数的地址以及用户态执行环境的起始地址 ``_start`` 函数的地址。
因此,从 ELF header 中可以看出,ELF 中的内容按顺序应该是:
- ELF header
- 若干个 program header
- 程序各个段的实际数据
- 若干的 section header
rust-objdump
^^^^^^^^^^^^^^^^^^^^^^^
如果想了解正常的ELF文件的具体指令内容,可以通过 ``rust-objdump`` 工具反汇编ELF文件得到:
.. code-block:: console
$ rust-objdump -all target/debug/os
具体结果如下:
.. code-block:: objdump
505b: e9 c0 ff ff ff jmp 0x5020 <.plt>
Disassembly of section .plt.got:
0000000000005060 <.plt.got>:
5060: ff 25 5a 3f 04 00 jmpq *278362(%rip) # 48fc0 <_GLOBAL_OFFSET_TABLE_+0x628>
5066: 66 90 nop
Disassembly of section .text:
0000000000005070 <_start>:
5070: f3 0f 1e fa endbr64
5074: 31 ed xorl %ebp, %ebp
5076: 49 89 d1 movq %rdx, %r9
5079: 5e popq %rsi
507a: 48 89 e2 movq %rsp, %rdx
507d: 48 83 e4 f0 andq $-16, %rsp
5081: 50 pushq %rax
5082: 54 pushq %rsp
5083: 4c 8d 05 86 2c 03 00 leaq 208006(%rip), %r8 # 37d10 <__libc_csu_fini>
508a: 48 8d 0d 0f 2c 03 00 leaq 207887(%rip), %rcx # 37ca0 <__libc_csu_init>
5091: 48 8d 3d 08 01 00 00 leaq 264(%rip), %rdi # 51a0 <main>
5098: ff 15 d2 3b 04 00 callq *277458(%rip) # 48c70 <_GLOBAL_OFFSET_TABLE_+0x2d8>
......
00000000000051a0 <main>:
51a0: 48 83 ec 18 subq $24, %rsp
51a4: 8a 05 db 7a 03 00 movb 228059(%rip), %al # 3cc85 <__rustc_debug_gdb_scripts_section__>
51aa: 48 63 cf movslq %edi, %rcx
51ad: 48 8d 3d ac ff ff ff leaq -84(%rip), %rdi # 5160 <_ZN2os4main17h717a6a6e05a70248E>
51b4: 48 89 74 24 10 movq %rsi, 16(%rsp)
51b9: 48 89 ce movq %rcx, %rsi
51bc: 48 8b 54 24 10 movq 16(%rsp), %rdx
51c1: 88 44 24 0f movb %al, 15(%rsp)
51c5: e8 f6 00 00 00 callq 0x52c0 <_ZN3std2rt10lang_start17hc258028f546a93a1E>
51ca: 48 83 c4 18 addq $24, %rsp
51ce: c3 retq
51cf: 90 nop
......
从上面的反汇编结果,我们可以看到用户态执行环境的入口函数 ``_start`` 以及应用程序的主函数 ``main`` 的地址和具体汇编代码内容。
rust-objcopy
^^^^^^^^^^^^^^^^^^^^^^^
当前的ELF执行程序有许多与执行无直接关系的信息(如调试信息等),可以通过 ``rust-objcopy`` 工具来清除。
.. code-block:: console
$ rust-objcopy --strip-all target/debug/os target/debug/os.bin
$ ls -l target/debug/os*
-rwxrwxr-x 2 chyyuu chyyuu 3334992 1月 19 22:26 target/debug/os
-rwxrwxr-x 1 chyyuu chyyuu 297200 1月 19 22:59 target/debug/os.bin
$ ./target/debug/os.bin
Hello, world!
可以看到,经过处理的ELF文件 ``os.bin`` 在文件长度上大大减少了,但也能正常执行。
另外,当将程序加载到内存的时候,对于每个 program header 所指向的区域,我们需要将对应的数据从文件复制到内存中。这就需要解析 ELF 的元数据
才能知道数据在文件中的位置以及即将被加载到内存中的位置。但如果我们不需要从 ELF 中解析元数据就知道程序的内存布局
(这个内存布局是我们按照需求自己指定的),我们可以手动完成加载任务。
具体的做法是利用 ``rust-objcopy`` 工具删除掉 ELF 文件中的
所有 header 只保留各个段的实际数据得到一个没有任何符号的纯二进制镜像文件:
.. code-block:: console
$ rust-objcopy --strip-all target/debug/os -O binary target/debug/os.bin
这样就生成了一个没有任何符号的纯二进制镜像文件。由于缺少了必要的元数据,我们的 ``file`` 工具也没有办法
对它完成解析了。而后,我们可直接将这个二进制镜像文件手动载入到内存中合适位置即可。
qemu 平台上可执行文件和二进制镜像的生成流程
----------------------------------------------
make & Makefile
^^^^^^^^^^^^^^^^^^^^^^^
首先我们还原一下可执行文件和二进制镜像的生成流程:
.. code-block:: makefile
# os/Makefile
TARGET := riscv64gc-unknown-none-elf
MODE := release
KERNEL_ELF := target/$(TARGET)/$(MODE)/os
KERNEL_BIN := $(KERNEL_ELF).bin
$(KERNEL_BIN): kernel
@$(OBJCOPY) $(KERNEL_ELF) --strip-all -O binary $@
kernel:
@cargo build --release
这里可以看出 ``KERNEL_ELF`` 保存最终可执行文件 ``os`` 的路径,而 ``KERNEL_BIN`` 保存只保留各个段数据的二进制镜像文件 ``os.bin``
的路径。目标 ``kernel`` 直接通过 ``cargo build`` 以 release 模式最终可执行文件,目标 ``KERNEL_BIN`` 依赖于目标 ``kernel``,将
可执行文件通过 ``rust-objcopy`` 工具加上适当的配置移除所有的 header 和符号得到二进制镜像。
我们可以通过 ``make run`` 直接在 qemu 上运行我们的应用程序,qemu 是一个虚拟机,它完整的模拟了一整套硬件平台,就像是一台真正的计算机
一样,我们来看运行 qemu 的具体命令:
.. code-block:: makefile
:linenos:
:emphasize-lines: 11,12,13,14,15
KERNEL_ENTRY_PA := 0x80020000
BOARD ?= qemu
SBI ?= rustsbi
BOOTLOADER := ../bootloader/$(SBI)-$(BOARD).bin
run: run-inner
run-inner: build
ifeq ($(BOARD),qemu)
@qemu-system-riscv64 \
-machine virt \
-nographic \
-bios $(BOOTLOADER) \
-device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA)
else
@cp $(BOOTLOADER) $(BOOTLOADER).copy
@dd if=$(KERNEL_BIN) of=$(BOOTLOADER).copy bs=128K seek=1
@mv $(BOOTLOADER).copy $(KERNEL_BIN)
@sudo chmod 777 $(K210-SERIALPORT)
python3 $(K210-BURNER) -p $(K210-SERIALPORT) -b 1500000 $(KERNEL_BIN)
miniterm --eol LF --dtr 0 --rts 0 --filter direct $(K210-SERIALPORT) 115200
endif
qemu
^^^^^^^^^^^^^^^^^^^^^^^
注意其中高亮部分给出了传给 qemu 的参数。
- ``-machine`` 告诉 qemu 使用预设的硬件配置。在整个项目中我们将一直沿用该配置。
- ``-bios`` 告诉 qemu 使用我们放在 ``bootloader`` 目录下的预编译版本作为 bootloader。
- ``-device`` 则告诉 qemu 将二进制镜像加载到内存指定的位置。
可以先输入 Ctrl+A ,再输入 X 来退出 qemu 终端。
.. warning::
**FIXME: 使用 GDB 跟踪 qemu 的运行状态**
k210 平台上可执行文件和二进制镜像的生成流程
-------------------------------------------------------
对于 k210 平台来说,只需要将 maix 系列开发板通过数据线连接到 PC,然后 ``make run BOARD=k210`` 即可。从 Makefile 中来看:
.. code-block:: makefile
:linenos:
:emphasize-lines: 13,16,17
K210-SERIALPORT = /dev/ttyUSB0
K210-BURNER = ../tools/kflash.py
run-inner: build
ifeq ($(BOARD),qemu)
@qemu-system-riscv64 \
-machine virt \
-nographic \
-bios $(BOOTLOADER) \
-device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA)
else
@cp $(BOOTLOADER) $(BOOTLOADER).copy
@dd if=$(KERNEL_BIN) of=$(BOOTLOADER).copy bs=128K seek=1
@mv $(BOOTLOADER).copy $(KERNEL_BIN)
@sudo chmod 777 $(K210-SERIALPORT)
python3 $(K210-BURNER) -p $(K210-SERIALPORT) -b 1500000 $(KERNEL_BIN)
miniterm --eol LF --dtr 0 --rts 0 --filter direct $(K210-SERIALPORT) 115200
endif
在构建目标 ``run-inner`` 的时候,根据平台 ``BOARD`` 的不同,启动运行的指令也不同。当我们传入命令行参数 ``BOARD=k210`` 时,就会进入下面
的分支。
- 第 13 行我们使用 ``dd`` 工具将 bootloader 和二进制镜像拼接到一起,这是因为 k210 平台的写入工具每次只支持写入一个文件,所以我们只能
将二者合并到一起一并写入 k210 的内存上。这样的参数设置可以保证 bootloader 在合并后文件的开头,而二进制镜像在文件偏移量 0x20000 的
位置处。有兴趣的读者可以输入命令 ``man dd`` 查看关于工具 ``dd`` 的更多信息。
- 第 16 行我们使用烧写工具 ``K210-BURNER`` 将合并后的镜像烧写到 k210 开发板的内存的 ``0x80000000`` 地址上。
参数 ``K210-SERIALPORT`` 表示当前 OS 识别到的 k210 开发板的串口设备名。在 Ubuntu 平台上一般为 ``/dev/ttyUSB0``。
- 第 17 行我们打开串口终端和 k210 开发板进行通信,可以通过键盘向 k210 开发板发送字符并在屏幕上看到 k210 开发板的字符输出。
可以输入 Ctrl+] 退出 miniterm。
\ No newline at end of file
附录 C:深入机器模式:RustSBI
=================================================
.. toctree::
:hidden:
:maxdepth: 4
RISC-V指令集的SBI标准规定了类Unix操作系统之下的运行环境规范。这个规范拥有多种实现,RustSBI是它的一种实现。
RISC-V架构中,存在着定义于操作系统之下的运行环境。这个运行环境不仅将引导启动RISC-V下的操作系统, 还将常驻后台,为操作系统提供一系列二进制接口,以便其获取和操作硬件信息。 RISC-V给出了此类环境和二进制接口的规范,称为“操作系统二进制接口”,即“SBI”。
SBI的实现是在M模式下运行的特定于平台的固件,它将管理S、U等特权上的程序或通用的操作系统。
RustSBI项目发起于鹏城实验室的“rCore代码之夏-2020”活动,它是完全由Rust语言开发的SBI实现。 现在它能够在支持的RISC-V设备上运行rCore教程和其它操作系统内核。
RustSBI项目的目标是,制作一个从固件启动的最小Rust语言SBI实现,为可能的复杂实现提供参考和支持。 RustSBI也可以作为一个库使用,帮助更多的SBI开发者适配自己的平台,以支持更多处理器核和片上系统。
当前项目实现源码:https://github.com/luojia65/rustsbi
\ No newline at end of file
RISCV汇编相关
=========================
- `RISC-V Assembly Programmer's Manual <https://github.com/riscv/riscv-asm-manual/blob/master/riscv-asm.md>`_
- `RISC-V Low-level Test Suits <https://github.com/riscv/riscv-tests>`_
- `CoreMark®-PRO comprehensive, advanced processor benchmark <https://github.com/RISCVERS/coremark-pro>`_
- `riscv-tests的使用 <https://stackoverflow.com/questions/39321554/how-do-i-use-the-riscv-tests-suite>`_
\ No newline at end of file
附录 D:RISC-V相关信息
=================================================
.. toctree::
:hidden:
:maxdepth: 4
1asm
\ No newline at end of file
为何要写这本操作系统书
==================================================
现在国内外已有一系列优秀的操作系统教材,例如William Stallings的《Operating Systems Internals and Design Principles》,Avi Silberschatz、Peter Baer Galvin 和 Greg Gagne 的《Operating System Concepts》,Remzi H. Arpaci-Dusseau 和 Andrea C. Arpaci-Dusseau 的《Operating Systems: Three Easy Pieces》等。然而,从我们从2000年以来的教学实践来看,某些经典教材对操作系统的概念和原理很重视,但还有如下一些问题有待改进:
- 原理与实践脱节:缺乏在操作系统的概念/原理与操作系统的设计/实现之间建立联系的桥梁,导致学生发现操作系统实现相关的实验与操作系统的概念相比,有较大的鸿沟。
- 缺少历史发展的脉络:操作系统的概念和原理是从实际操作系统设计与实现过程中,从无到有逐步演进而产生的,有其发展的历史渊源和规律。但目前的大部分教材只提及当前主流操作系统的概念和原理,有“凭空出现”的感觉,学生并不知道这些内容出现的前因后果。
- 忽视硬件细节或用复杂硬件:很多教材忽视或抽象硬件细节,是的操作系统概念难以落地。部分教材把 x86 作为的操作系统实验的硬件参考平台,缺乏对当前快速发展的RISC-V等体系结构的实验支持,使得学生在操作系统实验中可能需要花较大代价了解相对繁杂的x86硬件细节,影响操作系统实验的效果。
这些问题增加了学生学习和掌握操作系统的难度。我们想通过尝试解决上面三个问题,来缓解学生学习操作系统的压力,提升他们的兴趣,让他们能够在一个学期内比较好地掌握操作系统。为应对“原理与实践脱节”的问题,我们强调了实践先行,实践引领原理的理念。MIT教授 Frans Kaashoek等师生设计实现了基于UNIX v6的xv6教学操作系统用于每年的本科操作系统课的实验中,并在课程讲解中把原理和实验结合起来,在国际上得到了广泛的认可。这些都给了我们很好的启发,经过十多年的实践,对一个计算机专业的本科生而言,设计实现一个操作系统(包括CPU)有挑战但可行,前提是实际操作系统要小巧并能体现操作系统的核心思想。这样就能够让学生加深对操作系统原理和概念的理解,能让操作系统原理和概念落地。
为应对“缺少历史发展的脉络”的问题,我们重新设计操作系统实验和教学内容,按照操作系统的历史发展过程来建立多个相对独立的小实验,每个实验体现了操作系统的一个微缩的历史,并从中归纳总结出操作系统相关的概念与原理,并在教学中引导学生理解这些概念和原理是如何一步一步演进的。
为应对“忽视硬件细节或用复杂硬件”的问题,我们在硬件(x86, ARM, MIPS, RISC-V等)和编程语言(C, C++, Go, Rust等)选择方面进行了多年尝试。在2017年引入了RISC-V CPU作为操作系统实验的硬件环境,在2018年引入Rust编程语言作为开发操作系统的编程语言,使得学生以相对较小的开发和调试代价能够用Rust语言编写运行在RISC-V上的操作系统。而且方便和简化了让操作系统的概念和原理形象化,可视化的过程。学生可以吧操作系统的概念和原理直接对应到程序代码、硬件规范和操作系统的实际执行中,加强了学生对操作系统内涵的实际体验和感受。
所以本书的目标是以简洁的RISC-V CPU为底层硬件基础,根据上层应用从小到大的需求,按OS发展的历史脉络,逐步讲解如何设计并实现满足这些需求的“从小到大”的多个“小”操作系统。并在设计实现操作系统的过程中,逐步解析操作系统各种概念与原理的知识点,对应的做到有“理”可循和有“码”可查,最终让读者通过主动的操作系统设计与实现来深入地掌握操作系统的概念与原理。
在具体撰写过程中,第零章是对操作系统的一个概述,让读者对操作系统的历史、定义、特征等概念上有一个大致的了解。后面的每个章节体现了操作系统的一个微缩的历史发展过程,即从对应用由简到繁的支持的角度出发,每章会讲解如何设计一个可运行应用的操作系统,满足应用的阶段性需求。从而读者可以通过对应配套的操作系统设计实验,了解如何从一个微不足道的“小”操作系统,根据应用需求,添加或增强操作系统功能,逐步形成一个类似UNIX的相对完善的“小”操作系统。每一步都小到足以让人感觉到易于掌控,而在每一步结束时,你都有一个可以工作的“小”操作系统。另外,通过足够详尽的测试程序 ,可以随时验证读者实现的操作系统在每次更新后是否正常工作。由于实验的代码规模和实现复杂度在一个逐步递增的可控范围内,读者可以结合对应于操作系统设计实验的进一步的原理讲解,来建立操作系统概念原理和实际实现的对应关系,从而能够通过操作系统实验的实践过程来加强对理论概念的理解,通过理论概念来进一步指导操作系统实验的实现与改进。
在你开始阅读与实践本书讲解的内容之前,你需要决定用什么编程语言来完成操作系统实验。你可以用任何你喜欢的编程语言和你喜欢的CPU上来实现操作系统。我们推荐的编程语言是Rust,我们推荐的CPU是RISC-V。
..
chyyuu:有一个比较大的ascii图,画出我们做出的各种OSes。
.. note::
**目前常见的操作系统内核都是基于C语言的,为何要推荐Rust语言?**
- 没错,C语言就是为写UNIX而诞生的。Dennis Ritchie和KenThompson没有期望设计一种新语言能帮助高效简洁地开发复杂的应用业务逻辑,只是希望用一种简洁的方式抽象出计算机的行为,便于编写控制计算机硬件的操作系统,最终的结果就是C语言。
- C语言的指针的天使与魔鬼,且C语言缺少有效的并发支持,导致内存和并发漏洞成为当前操作系统的噩梦。
- Rust语言具有与C一样的硬件控制能力,且大大强化了安全编程。从某种角度上看,新出现的Rust语言的核心目标是解决C的短板,取代C。所以用Rust写OS具有很好的开发和运行的体验。
- 用Rust写OS的代价仅仅是学会用Rust编程。
**目前常见的CPU是x86和ARM,为何要推荐RISC-V?**
- 没错,最常见的的CPU是x86和ARM,他们已广泛应用在服务器,台式机,移动终端和很多嵌入式系统中。它们需要支持非常多的软件系统和应用需求,导致它们越来越复杂。
- x86的向过去兼容的策略确保了它的江湖地位,但导致其丢不掉很多已经比较过时的硬件设计,让操作系统疲于适配这些硬件特征。
- x86和ARM都很成功,这主要是在商业上,其广泛使用是的其CPU硬件逻辑越来越复杂,且不够开放,不能改变,不是开源的,提高了操作系统开发者的学习难度。
- 从某种角度上看,新出现的RISC-V的核心目标是灵活适应未来的AIoT场景,保证基本功能,提供可配置的扩展功能。其开源特征使得学生都可以方便地设计一个RISC-V CPU。
- 写面向RISC-V的OS的代价仅仅是你了解RISC-V的Supevisor特权模式,知道OS在Supevisor特权模式下的控制能力。
\ No newline at end of file
什么是操作系统
================================================
.. toctree::
:hidden:
:maxdepth: 5
站在一万米的代码空间维度看
----------------------------------
现在的通用操作系统是一个复杂的系统软件,比如Linux操作系统达到了千万行的C源代码量级。在学习操作系统的初期,我们没有必要去分析了解这样大规模的软件。但这样的软件也是有其特有的一些特征。首先,它称为系统软件,简单理解它就是在一个计算机系统范围类使用的软件,管的是整个计算机系统。如果这样来看,一个编辑软件,如Vi Emacs就不能算了。
而在计算机中安装的Rust标准库(类似的有Libc库等)可以算是一个。
如果我们站在一万米的高空来看 :ref:`操作系统 <computer-hw-sw>` ,可以发现操作系统这个软件干的事主要有两件:一是向下管理计算机硬件和各种外设,而是向上给应用软件提供各种服务帮助。我们可对其进一步描述:操作系统是一个可以管理CPU、内存和各种外设,并管理和服务应用软件的软件。这样的描述也是大多数操作系统教材上对操作系统的一个定义,比较高大上。为了完成这些工作,操作系统需要知道如何与硬件打交道,如何更好地面向应用软件做好服务,这就有一系列操作系统相关的理论,抽象,设计等来支持如何做和做得好的需求。
.. image:: computer-hw-sw.png
:align: center
:scale: 50 %
:name: computer-hw-sw
如果看看我们的身边,Android应用运行在ARM处理器上Android操作系统的执行环境中,微软的Office应用运行在x86-64处理器上Windows操作系统的执行环境中,Web Server应用运行在x86-64处理器上Linux操作系统的执行环境中, Web app应用运行在x86-64或ARM处理器上Chrome OS
操作系统的执行环境中。而在一些嵌入式操作系统环境中,所谓的嵌入式操作系统就是以运行时库的形式与应用程序紧密结合在一起,形成一个可在嵌入式硬件上的单一执行软件。所以,在不同的应用场景下,其实操作系统的边界也是不同的,我们可以把运行时库、图形界面支持库等这些可支持不同应用的系统软件(System Software)也可看成是操作系统的一部分。
站在应用程序的角度来看,我们可以发现常见的应用程序其实是运行在由硬件,操作系统,运行时库,图形界面支持库等所包起来的一个 :ref:`执行环境(Execution Environment) <exec-env>` 中,应用程序只需根据与系统软件约定好的应用程序二进制接口(Application Bianry Interface, ABI),来请求执行环境提供的各种服务或功能,从而完成应用程序自己的功能。基于这样的观察,我们可以把操作系统再简单一点地定义为:**应用程序的软件执行环境**。从这个角度出发,操作系统可以包括运行时库、图形界面支持库等系统软件,也能适应在操作系统发展的不同历史时期对操作系统的概括性描述和定义。
.. image:: EE.png
:align: center
:name: exec-env
站在计算机发展的百年时间尺度看
----------------------------------
虽然电子计算机的出现距今才仅仅七十年左右,但计算机技术和操作系统已经发生了巨大的变化。从计算机发展的短暂的历史角度看,操作系统也是从无到有地逐步发展起来的。操作系统主要完成对硬件控制和对应用程序的服务所必需的功能,操作系统的历史与计算机的发展史密不可分。操作系统的内涵和功能随着历史的发展也在一直变化,改进中,在今天,在二十一世纪初期的大众眼中,操作系统就是他们的手机/终端上的软件系统,包括各种应用程序集合。在用户的眼里,如果一个操作系统没有图形界面、网络浏览器,那它就称为一个操作系统了。
其实,操作系统的内涵和外延随着历史的发展,也一直在变化,并没有类似于“1+1=2”这样的明确定义。参考生物的进化史,我们也给操作系统的进化历史做一个简单的概述,从中可以看到操作系统在各个时间段上包含什么,具有什么样的特征。但无论操作系统的内在实现和具体目标如何变化,其管理计算机硬件,给应用提供服务的功能需求没有变化。
寒武纪生物大爆发时代
~~~~~~~~~~~~~~~~~~~~~~
电子计算机在1946年最开始出现的时候是没有操作系统(Operating System)的,只有操作员(Operator)。启动,扳开关,装卡片/纸带等比较辛苦的工作都是计算机操作员或者用户自己完成。操作员/用户带着记录有程序和数据的卡片\(punch card\)或打孔纸带去操作机器。装好卡片/纸带后,启动卡片/纸带阅读器,让计算机把程序和数据读入计算机机的内存中后,计算机就开始工作,并把结果也输出到卡片/纸带或显示屏上,最后程序停止。
由于人的操作效率太低,计算机的机时宝贵,所以就引入监控程序(Monitor)辅助完成输入,输出,加载,运行程序等工作,这是现代操作系统的起源,类似寒武纪生物大爆发中的“三叶虫”。一般情况下,计算机每次只能执行一个任务,CPU大部分时间都在等待人的缓慢操作。这个初级的“辅助操作”过程一直持续到20世纪50年代。
.. note::
可以在 :ref:`本书第一章 <link-chapter1>` 看到初级的“三叶虫”操作系统其实就是一个给应用提供各种服务(比如输出字符串)的库,方便了单一应用程序的开发与运行。
泥盆纪鱼类时代和二叠纪两栖动物时代
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
在20世纪50年代~60年代,计算机发展到大型机阶段,而所对应的早期操作系统非常多样化,专用化,生产商生产出针对各自硬件的专用操作系统,大部分用汇编语言编写,这导致操作系统的进化比较缓慢,但进化再持续,从“手工操作”进化到了“批处理”阶段和“多道程序”阶段。在1964年,IBM公司开发了面向System/360系列机器的统一可兼容的操作系统——OS/360。OS/360是一种批处理操作系统。为了能充分地利用计算机系统,应尽量使该系统连续运行,减少空闲时间,所以批处理操作系统把一批作业(古老的术语,可理解为现在的程序)以脱机方式输入到磁带上,并使这批作业能一个接一个地连续处理:1)将磁带上的一个作业装入内存;2)并把运行控制权交给该作业;3)当该作业处理完成后,把控制权交还给操作系统;4)重复1-3的步骤。
批处理操作系统分为单道批处理系统和多道批处理系统。单道批处理操作系统只能管理内存中的一个(道)作业,无法充分利用计算机系统中的所有资源,致使系统整体性能较差。多道批处理操作系统能管理内存中的多个(道)作业,可比较充分地利用计算机系统中的所有资源,提升系统整体性能。
多道批处理操作系统为此采用了多道程序设计技术,就是指允许同时把多个程序放入内存,并允许它们交替在CPU中运行,它们共享系统中的各种硬、软件资源。当一道程序因I/O请求而暂停运行时,CPU便立即转去运行另一道程序。
虽然批处理操作系统提高了系统的执行效率,但其缺点是人机交互性差。如果程序员的代码出现错误,必须重新编码,上传内存,再执行。这需要花费以小时和天为单位的时间开销,使得程序员修改和调试程序很不方便。
.. note::
可以在 :ref:`本书第二章 <link-chapter2>` 看到批处理操作系统的设计实现,以及支持一个一个地执行应用程序的运行过程。而在 `本书第三章 <https://rcore-os.github.io/rCore-Tutorial-Book-v3/chapter3/index.html>`_ 的前三节可以看到支持协作式多道程序的操作系统的设计实现,以及支持应用程序主动放弃CPU以提高系统整体执行效率的过程。和第三章后三节的支持抢占式多任务的操作系统。
侏罗纪与白垩纪的爬行动物时代
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
20世纪60年代末,提高人机交互方式的分时操作系统越来越展露头角。分时是指多个用户和多个程序以很小的时间间隔来共享使用同一台计算机上的CPU和其他硬件/软件资源。1964年由贝尔实验室、麻省理工学院及美国通用电气公司所共同参与研发目标远大的MULTICS\(MULTiplexed Information and Computing System\)操作系统,MULTICS是一套安装在大型主机上多人多任务的操作系统。 MULTICS以兼容分时系统(CTSS)做基础,建置在美国通用电力公司的大型机GE-645,目标是连接1000部终端机,支持300的用户同时上线。因MULTICS项目的工作进度过于缓慢,1969年AT&T的 Bell 实验室从MULTICS 研发中撤出。但贝尔实验室的两位软件工程师 Thompson 与 Ritchie借鉴了一些重要的Multics理念,以C语言为基础,发展出UNIX操作操作系统。UNIX操作系统的早期版本是完全免费的,可以轻易获得并随意修改,所以它得到了广泛的接受。后来,它成为开发小型机操作系统的起点。由于早期的广泛应用,它已经成为的分时操作系统的典范。
.. note::
可以在 :ref:`本书第三章 <link-chapter3>` 的第四节可以看到分时操作系统的设计实现,以及操作系统可强制让应用程序被动放弃CPU以提高系统整体执行效率的过程。并且UNIX还有虚存、文件、进程等当前操作系统的关键特性,这些内容也在本书的第四章~第七章中有详细的设计描述。
古近纪哺乳动物时代
~~~~~~~~~~~~~~~~~~~~~~~
20世纪70年代,微型处理器的发展使计算机的应用普及至中小企及个人爱好者,推动了个人计算机\(Personal Computer\)的发展,也进一步推动了面向个人使用的操作系统的出现。其代表是由微软公司中在20世纪80年代为个人计算机开发的DOS/Windows操作系统,其特点是简单易用,特别是基于Windows操作系统的GUI界面,极大地简化了一般用户使用计算机的难度,使得计算机得到了快速的普及。这里需要注意的是,第一个带GUI界面的个人计算机原型起源于伟大却又让人扼腕叹息的施乐帕洛阿图研究中心PARC(Palo Alto Research Center),PARC研发出的带有图标、弹出式菜单和重叠窗口的GUI(Graphical User Interface),可利用鼠标的点击动作来进行操控,这是当今我们所使用的GUI系统的基础。支持便捷的图形交互界面也成为从20世纪70年代~21世纪初的操作系统的主要特征之一。
.. note::
目前支持GUI交互接口的操作系统设计实现在本书中还没有对应的章节。但其操作系统的内核其实与分时操作系统的设计实现思路基本是一致的。如果在本书设计的简单分时操作系统的基础上,添加一个图形外设的驱动和一个简单的GUI窗口系统,也许是一个有趣的实验内容。
第四纪智人时代
~~~~~~~~~~~~~~~~~~~~~
21世纪以来,Internt和移动互联网的迅猛发展,使得在服务器领域和个人终端的应用与需求大增。iOS和Android操作系统是21世纪个人终端操作系统的代表,Linux在巨型机到数据中心服务器操作系统中占据了统治地位。以Android系统为例,Android操作系统是一个包括Linux操作系统内核、基于Java的中间件、用户界面和关键应用软件的移动设备软件栈集合。这里介绍一下广泛用在服务器领域和个人终端中的操作系统内核--Linux操作系统内核。1991年8 月,芬兰学生 Linus Torvalds\(林纳斯·托瓦兹\)在 comp.os.minix 新闻组贴上了以下这段话: 
"你好,所有使用 minix 的人 -我正在为 386 ( 486 ) AT 做一个免费的操作系统 ( 只是为了爱好 )...″
而他所说的"爱好″成为了大家都知道的 Linux操作系统内核。这个时代的操作系统的特征是联网,发挥网络的吞吐量和低延迟是这个时代的网络操作系统追求的目标。 
.. note::
目前支持联网的操作系统设计实现在本书中还没有对应的章节。但其操作系统的内核其实与分时操作系统的设计实现思路基本是一致的。如果在本书设计的简单分时操作系统的基础上,添加一个网卡外设的驱动和一个简单的网络协议栈,也许是另一个有趣的实验内容。
二十一世纪神人时代
~~~~~~~~~~~~~~~~~~~~~~~~~
当前,大数据、人工智能、机器学习、高速移动互联网络、AR/VR对操作系统等系统软件带来了新的挑战。如何有效支持和利用这些技术是未来操作系统的方向。
在2020年,我们看到了华为逐步推出的鸿蒙系统;小米也推出了物联网软件平台小米Vela;阿里推出了AliOS Thing;腾讯推出了Tencent OS;苹果公司接连推出A14、M1等基于ARM的CPU,逐步开始淘汰X86CPU;微软推出Windows 10 IoT,Google推出Fuchsia OS,也都在做着各种云、边、端的技术调整和创新。
大家好像都意识到,不仅仅是人工智能和机器学习,下一个具有分布式特征的操作系统的新突破即将到来,并试图通过这种具有分布式特征的操作系统带来的连贯用户体验,打通从数据中心、服务器、桌面、移动端、边缘设备等的整个AI+IoT(Internet of Things)的生态。也许这个时代的未来操作系统与之前的操作系统相比,其最大的不同是跳出了单个设备节点,而是通过高速的无线网络从多种维度来管理多个设备,形成分布式操作系统。
.. note::
目前支持AIoT的操作系统设计实现在本书中还没有对应的章节,不过我们的同学也设计了
`zCore操作系统 <https://github.com/rcore-os/zCore>`_ ,
欢迎看完本书的同学能够尝试参与或独立设计面向未来的操作系统。
.. note::
本节内容部分参考了尤瓦尔·赫拉利所著的“人类简史”、“未来简史” 。
\ No newline at end of file
操作系统的接口
================================================
.. toctree::
:hidden:
:maxdepth: 5
读者可站在使用操作系统的角度来看操作系统,这样会比较容易对操作系统有个初步的进一步了解。操作系统内核是一个需要提供各种服务的软件,其服务对象是应用程序,而用户(这里可以理解为一般使用计算机的人)是通过应用程序的服务间接获得操作系统的服务的),所以操作系统内核藏在一般用户看不到的地方。但应用程序需要访问操作系统,获得操作系统的服务,这就需要通过操作系统的接口才能完成。操作系统的接口的形式就是上一节提到的应用程序二进制接口(Application Binary Interface, ABI)。但操作系统不是简单的一个函数库的编程接口(Application Programming Interface, API),它的接口需要考虑安全因素,使得应用软件不能直接读写操作系统内部函数的地址地址空间,为此,操作系统设计了一个安全可靠的接口,我们称为系统调用接口(System Call Interface),应用程序可以通过系统调用接口请求获得操作系统的服务,但不能直接调用操作系统的函数和全局变量;操作系统提供完服务后,返回应用程序继续执行。
.. note::
**API与ABI的区别**
应用程序二进制接口(Application Binary Interface,ABI)是不同二进制代码片段的连接纽带。ABI定义了二进制机器代码级别的规则。主要包括基本数据类型,通用寄存器的使用,参数的传递规则,以及堆栈的使用等等。ABI是用来约束链接器(Linker)和汇编器(Assembler)的。基于不同高级语言编写的应程序、库和操作系统,如果遵循同样的ABI定义,那么它们就能正确链接和执行。
应用程序编程接口(Application Programming Interface,API)是不同源代码片段的连接纽带。API定义了一个源码(如C语言)级函数的参数,参数的类型,函数的返回值等。 因此API是用来约束编译器(Compiler)的:一个API是给编译器的一些指令,它规定了源代码可以做以及不可以做哪些事。API与编程语言相关,如LibC是基于C语言编写的标准库,那么基于C的应用程序就可以通过编译器建立与LibC的联系,并能在运行中正确访问LibC中的函数。
对于实际操作系统而言,具有大量的服务接口,比如目前Linux有三百个系统调用接口。下面列出了一些相对比较重要的操作系统接口:
* 进程(即程序运行过程)管理:复制创建进程--fork、退出进程--exit、执行进程--exec ...
* 同步互斥的并发控制:信号量--semaphore、管程--monitor、条件变量--condition variable ...
* 进程间通信:管道--pipe、信号--signal、事件--event ...
* 虚存管理:内存空间映射--mmap、改变数据段地址空间大小--sbrk、共享内存--shared mem ...
* 文件I/O操作:读--read、写--write、打开--open、关闭--close ...
* 外设I/O操作:外设包括键盘、显示器、串口、磁盘、时钟 ...,但接口是直接采用了文件I/O操作的系统调用接口
.. note::
上述表述在某种程度上说明了操作系统对计算机硬件重要组成的抽象和虚拟化,是的应用程序只需基于对简单的抽象概念的访问来到达对计算机系统资源的使用:
* 文件(File)是外设的一种抽象和虚拟化。特别对于存储外设而言,文件是持久存储的抽象。
* 地址空间(Address Space)是对内存的抽象和虚拟化。
* 进程(Process)是对计算机资源的抽象和虚拟化。而其中最核心的部分是对CPU的抽象与虚拟化。
.. image:: run-app.png
:align: center
:name: run-app
有了这些接口,简单的应用程序就不用考虑底层硬件细节,可以在操作系统的服务支持和管理下简洁地完成其应用功能了。在现阶段,也许大家对进程、文件、地址空间等抽象概念还不了解,在接下来的章节会对这些概念有进一步的介绍。
操作系统抽象
================================================
.. toctree::
:hidden:
:maxdepth: 5
..
chyyuu:我觉得需要给出执行环境(EE),Task,...,上下文(函数,trap,task,进程...),执行流等的描述。
并且有一个图,展示这些概念的关系。这些概念能够有链接,指向进一步实际定义或使用的地方。
接下来读者可站在操作系统实现的角度来看操作系统。操作系统为了能够更好地管理计算机系统并对应用程序提供便捷的服务,在计算机和操作系统的技术研究和发展的过程中,形成了一系列的核心概念,奠定了操作系统内核设计与实现的基础。
.. note::
在本书中,下面的抽象表示将以有形的数据结构和实际的执行行在后续各章实现的操作系统内核中进行展示。
执行环境(Execution Environment)
----------------------------------------
执行环境是一个内涵很丰富且有一定变化的一个术语,它主要负责给在其上执行的软件提供相应的功能与资源,并可在计算机系统中形成多层次的执行环境。对于现在直接运行在裸机硬件(baremetal)上的操作系统,其执行环境也是 **计算机的硬件** 。
在寒武纪时期的计算机系统中,还没有操作系统,所以对于直接运行在裸机硬件(baremetal)上的应用程序而言,其执行环境就是 **计算机的硬件** 。
随着计算机技术的发展,应用程序下面形成了一层比较通用的函数库,这使得
应用程序不需要直接访问硬件了,它所需要的功能(比如显示字符串)和资源(比如一块内存)都可以通过函数库的函数来帮助完成。在第二个阶段,应用程序的执行环境就变成了 **计算机硬件+函数库** ,而这时 **函数库** 的执行环境就是 **计算机的硬件** 。
.. image:: basic-EE.png
:align: center
:name: basic-ee
再进一步,操作系统取代了函数库来访问硬件,函数库通过访问操作系统的系统服务来进一步给应用程序
提供丰富的功能和资源。在第三个阶段,应用程序的执行环境就变成了 **函数库+操作系统+计算机硬件** 。
在后面又出现了基于java语言的应用程序,在函数库和操作系统之间,多了一层 *java虚拟机* ,此时java应用
程序的执行环境就变成了 **函数库+java虚拟机+操作系统+计算机硬件** 。在云计算时代,在传统操作系统与
计算机硬件之间多了一层Hypervisor/VMM,此时应用程序的执行环境变成了 **函数库+java虚拟机+操作系统+Hypervisor/VMM+计算机硬件** 。另外,执行环境的不同层次通过 **Program Interface** 或
**Binary Interface** 进行交互,而且彼此之间也有一定的交集,并不一定具有严格区分的界面。
.. image:: complex-EE.png
:align: center
:name: complex-ee
对于应用程序的执行环境而言,其具体的内容是多变的,但应用程序只能看到执行环境提供给它的直接的接口(API or ABI),这使得应用程序所能得到的直接服务取决于执行环境提供给它的直接接口。当然执行环境
中的内在功能(如对于应用程序的资源调度与管理等)也会对应用程序的执行效率,可靠性等提供间接的支持。所以,操作系统是属于或等于应用程序执行环境的软件部分,其形态可以是一个库,也可以是一个虚拟机等,或者它们的某种组合形式。
基于上面的介绍,我们可以给应用程序的执行环境一个基本的定义:执行环境是一个概念,一种机制,用来完成应用程序在运行时的数据与资源管理、应用程序的生存期等方面的处理,它定义了应用程序有权访问的其他数据或资源,并决定了应用程序的行为限制范围。
异常控制流(exceptional control flow)
--------------------------------------
首先,处理器的控制流(flow of control 或 control flow)是指处理器中程序计数器的控制转移序列。最简单的一种控制流是一个“平滑的”序列,其中每个要执行的指令地址在内存中都是相邻的。如果前一条指令和后一条指令位于两个完全不同的位置,即不同的 **执行环境**,比如,前一条指令还在应用程序的代码段中,后一条指令就跑到操作系统的代码段中去了,这就是一种控制流的“突变”,即控制流脱离了其所在的 **执行环境** 。
简单地说,异常控制流是处理器在执行过程中的突变,即通过硬件和操作系统的协同工作来响应处理器状态中的特殊变化。比如当应用程序正在执行时,产生了时钟外设中断,导致操作系统打断当前应用程序的执行,转而进入 **操作系统** 所在的执行环境去处理时钟外设中断。处理完毕后,再回到应用程序的 **执行环境** 中被打断的地方继续执行。在操作系统中,需要处理三类异常控制流:外设中断(Device Interrupt)、陷入(Trap)和异常(Exception,也称Fault Interrupt)。外设中断由外部设备引起的外部I/O事件如时钟中断、控制台中断等。外设中断是异步产生的,与处理器的执行无关。
.. image:: interrupt.png
:align: center
:name: interrupt
异常是在处理器执行指令期间检测到不正常的或非法的内部事件(如除零错、地址访问越界)。
.. image:: exception.png
:align: center
:name: exception
陷入是在程序中使用请求操作系统服务的系统调用而引发的有意事件。
.. image:: syscall.png
:align: center
:name: syscall
在后面的叙述中,如果没有特别指出,我们将用简称中断、陷入、异常来区分这三种异常控制流。
进程(Process)
----------------------------------
进程的一个经典定义是一个正在运行的程序的实例。在计算机系统中,我们可以“同时”运行多个程序,这个“同时”,其实是操作系统给用户造成的一个“幻觉”。在操作系统上运行一个程序时,我们会得到一个“幻觉”,就好像我们执行的一个程序是整个计算机系统中当前运行的唯一的程序,且像独占地使用处理器、内存和外设。而且程序中的代码和数据好像是系统内存中唯一的对象。这些假象都是通过进程的概念提供给我们的。
.. image:: prog-illusion.png
:align: center
:name: prog-illusion
计算机系统中运行的每个程序都是运行在某个进程的上下文(context)中。这里的上下文是指程序在运行中的状态。运行的状态包括:内存中的代码和数据,栈、堆、当前执行的指令位置(程序计数器的内容)、
当前执行时刻的各个通用寄存器中的值,各种正在访问的资源描的集合。进程上下文如下图所示:
.. image:: context-of-process.png
:align: center
:name: context-of-process
大家知道,处理器是计算机系统中的硬件资源。为了提高处理器的利用率,操作系统需要让处理器足够忙,即让不同的程序轮流占用处理器来运行。如果一个程序因某个事件而不能运行下去时,就通过进程上下文切换把处理器占用权转交给另一个可运行程序。进程上下文切换如下图所示:
.. image:: context-switch.png
:align: center
:name: context-switch
基于上面的介绍,我们可以给进程一个更加准确的定义:一个进程是一个具有一定独立功能的程序在一个数据集合上的一次动态执行过程。操作系统中的进程管理需要协调多道程序之间的关系,解决对处理器分配调度策略、分配实施和回收等问题,从而使得处理器资源得到最充分的利用。
地址空间(Address Space)
----------------------------------
地址空间是对物理内存的虚拟化和抽象,也称虚存(Virtual Memory)。它就是操作系统通过处理器中的MMU硬件的支持而给应用程序和用户提供一个大的(超过计算机中的内存条容量)、一致的(连续的地址空间)、私有的(其他应用程序无法破坏)的存储空间。这需要操作系统将内存和硬盘结合起来管理,为用户提供一个容量比实际内存大得多的虚拟存储器,并且需要操作系统为应用程序分配内存空间,使用户存放在内存中的程序和数据彼此隔离、互不侵扰。操作系统中的虚存管理与处理器的MMU密切相关,在启动虚存机制后,软件通过CPU访问的每个虚拟地址都需要通过CPU中的MMU转换为一个物理地址来进行访问。下面是虚拟的地址空间与物理内存和物理磁盘映射的图示:
.. image:: address-space.png
:align: center
:name: address-space
文件(File)
----------------------------------
文件主要用于对持久存储的抽象,并进一步扩展到为外设的抽象。具体而言,文件可理解为存放在持久存储介
质(比如硬盘、光盘、U盘等)上,方便应用程序和用户读写的数据。以磁盘为代表的持久存储介质的数据访问单位是一个扇区或一个块,而在内存中的数据访问单位是一个字节或一个字。这就需要操作系统通过 **文件** 来屏蔽磁盘与内存差异,尽量以内存的读写方式来处理持久存储的数据。当处理器需要访问文件中的数据时,可通过操作系统把它们装入内存。文件管理的任务是有效地支持文件的存储、
检索和修改等操作。
下面是文件对磁盘的抽象映射图示:
.. image:: file-disk.png
:align: center
:name: file-disk
从一个更高和更广泛的层次上看,各种外设虽然差异很大,但也有基本的读写操作,可以通过文件来进行统一的抽象,并在操作系统内部实现中来隐藏对外设的具体访问过程,从而让用户可以以统一的文件操作来访问各种外设。这样就可以把文件看出是对外设的一种统一抽象,应用程序通过基本的读写操作来完成对外设的访问。
操作系统的特征
================================================
.. toctree::
:hidden:
:maxdepth: 5
基于操作系统的四个抽象,我们可以看出,从总体上看,操作系统具有五个方面的特征:虚拟性(Virtualization)、
并发性(Concurrency)、异步性、共享性和持久性(Persistency)。在虚拟性方面,可以从操作系统对内存,CPU的抽象和处理上
有更好的理解;对于并发性和共享性方面,可以从操作系统支持多个应用程序“同时”运行的情况来理解;
对于异步性,可以从操作系统调度,中断处理对应用程序执行造成的影响等几个放马来理解;
对于持久性方面,可以从操作系统中的文件系统支持把数据方便地从磁盘等存储介质上存入和取出来理解。
虚拟性
----------------------------------
内存虚拟化
~~~~~~~~~~~~~~
首先来看看内存的虚拟化。
程序员在写应用程序的时候,不用考虑其程序的起始内存地址要放到计算机内存的具体某个位置,
而是用字符串符号定义了各种变量和函数,直接在代码中便捷地使用这些符号就行了。
这是由于操作系统建立了一个 **地址固定** ,**空间巨大** 的虚拟内存给应用程序来运行,这是 **空间虚拟化** 。
这里的每个符号在运行时是要对应到具体的内存地址的。这些内存地址的具体数值是什么?程序员不用关心。为什么?
因为编译器会自动帮我们吧这些符号翻译成地址,形成可执行程序。程序使用的内存是否占得太大了?
在一般情况下,程序员也不用关心。
.. note::
还记得虚拟地址(逻辑地址)的描述吗?
但编译器\(compiler,比如gcc\)和链接器(linker,比如ld)也不知道程序每个符号对应的地址应该放在未来程序运行时的哪个物理内存地址中。所以,编译器的一个简单处理办法就是,设定一个固定地址(比如 0x10000)作为起始地址,开始存放代码,代码之后是数据,所有变量和函数的符号都在这个起始地址之后的某个固定偏移位置。假定程序每次运行都是位于一个不会变化的起始地址。
这里的变量指的是全局变量,其地址在编译链接后会确定不变。但局部变量是放在堆栈中的,会随着堆栈大小的动态变化而变化。
这里编译器产生的地址就是虚拟地址。
这里,编译器和链接器图省事,找了一个适合它们的解决办法。当程序要运行的时候,这个符号到机器物理内存的映射必须要解决了,这自然就推到了操作系统身上。操作系统会把编译器和链接器生成的执行代码和数据放到物理内存中的空闲区域中,并建立虚拟地址到物理地址的映射关系。由于物理内存中的空闲区域是动态变化的,这也导致虚拟地址到物理地址的映射关系是动态变化的,需要操作系统来维护好可变的映射关系,确保编译器“固定起始地址”的假设成立。只有操作系统维护好了这个映射关系,才能让程序员只需写一些易于人理解的字符串符号来代表一个内存空间地址,且编译器只需确定一个固定地址作为程序的起始地址就可以生成一个不用考虑将来这个程序要在哪里运行的问题,从而实现了 **空间虚拟化** 。
应用程序在运行时不用考虑当前物理内存是否够用。如果应用程序需要一定空间的内存,但由于在某些情况下,物理内存的空闲空间可能不多了,这时操作系统通过把物理内存中最近没使用的空间(不是空闲的,只是最近用得少)换出(就是“挪地”)到硬盘上暂时缓存起来,这样空闲空间就大了,就可以满足应用程序的运行时内存需求了,从而实现了 **空间大小虚拟化** 。
CPU虚拟化
~~~~~~~~~~~~~~
再来看CPU虚拟化。不同的应用程序可以在内存中并发运行,相同的应用程序也可有多个拷贝在内存中并发运行。而每个程序都“认为”自己完全独占了CPU在运行,这是”时间虚拟化“。这其实也是操作系统给了运行的应用程序一个虚拟幻象。其实是操作系统把时间分成小段,每个应用程序占用其中一小段时间片运行,用完这一时间片后,操作系统会切换到另外一个应用程序,让它运行。由于时间片很短,操作系统的切换开销也很小,人眼基本上是看不出的,反而感觉到多个程序各自在独立”并行“执行,从而实现了 **时间虚拟化** 。
.. note::
并行(Parallel)是指两个或者多个事件在同一时刻发生;而并发(Concurrent)是指两个或多个事件在同一时间间隔内发生。
对于单CPU的计算机而言,各个”同时“运行的程序其实是串行分时复用一个CPU,任一个时刻点上只有一个程序在CPU上运行。
这些虚拟性的特征给应用程序的开发和执行提供了非常方便的环境,但也给操作系统的设计与实现提出了很多挑战。
并发性
----------------------------------
操作系统为了能够让CPU充分地忙起来并充分利用各种资源,就需要给很多任务给它去完成。这些任务是分时完成的,有操作系统来完成各个应用在运行时的任务切换。并发性虽然能有效改善系统资源的利用率,但并发性也带来了对共享资源的争夺问题,即同步互斥问题;执行时间的不确定性问题,即并发程序在执行中是走走停停,断续推进的。并发性对操作系统的设计也带来了很多挑战,一不小心就会出现程序执行结果不确定,程序死锁等很难调试和重现的问题。
异步性
----------------------------------
在这里,异步是指由于操作系统的调度和中断等,会不时地暂停或打断当前正在运行的程序,使得程序的整个运行过程走走停停。在应用程序运行的表现上,特别它的执行完成时间是不可预测的。但需要注意,只要应用程序的输入是一致的,那么它的输出结果应该是符合预期的。
共享性
----------------------------------
共享是指多个应用并发运行时,宏观上体现出它们可同时访问同一个资源,即这个资源可被共享。但其实在微观上,操作系统在硬件等的支持下要确保应用程序互斥或交替访问这个共享的资源。比如两个应用同时写访问同一个内存单元,从宏观的应用层面上看,二者都能正确地读出同一个内存单元的内容。而在微观上,操作系统会调度应用程序的先后执行顺序,在数据总线上任何一个时刻,只有一个应用去访问存储单元。
持久性
----------------------------------
操作系统提供了文件系统来从可持久保存的存储介质(硬盘,SSD等,以后以硬盘来代表)中取数据和代码到内存中,并可以把内存中的数据写回到硬盘上。硬盘在这里是外设,具有持久性,以文件系统的形式呈现给应用程序。
.. note::
文件系统也可看成是操作系统对存储外设(如硬盘、SSD等)的虚拟化。
这种持久性的特征进一步带来了共享属性,即在文件系统中的文件可以被多个运行的程序所访问,从而给应用程序之间实现数据共享提供了方便。即使掉电,存储外设上的数据还不会丢失,可以在下一次机器加电后提供给运行的程序使用。持久性对操作系统的执行效率提出了挑战,如何让数据在高速的内存和慢速的硬盘间高效流动是需要操作系统考虑的问题。
实验环境配置
============
.. toctree::
:hidden:
:maxdepth: 4
本节我们将完成环境配置并成功运行 rCore-Tutorial-v3 。整个流程分为下面几个部分:
- 系统环境配置
- Rust 开发环境配置
- Qemu 模拟器安装
- 其他工具安装
- 运行 rCore-Tutorial-v3
系统环境配置
-------------------------------
目前实验仅支持 Ubuntu18.04 + 操作系统。对于 Windows10 和 macOS 上的用户,可以使用 VMware 或
VirtualBox 安装一台 Ubuntu18.04 虚拟机并在上面进行实验。
特别的,Windows10 的用户可以通过系统内置的 WSL2 虚拟机(请不要使用 WSL1)来安装 Ubuntu 18.04 / 20.04 。
步骤如下:
- 升级 Windows 10 到最新版(Windows 10 版本 18917 或以后的内部版本)。注意,如果
不是 Windows 10 专业版,可能需要手动更新,在微软官网上下载。升级之后,
可以在 PowerShell 中输入 ``winver`` 命令来查看内部版本号。
- 「Windows 设置 > 更新和安全 > Windows 预览体验计划」处选择加入 “Dev 开发者模式”。
- 以管理员身份打开 PowerShell 终端并输入以下命令:
.. code-block::
# 启用 Windows 功能:“适用于 Linux 的 Windows 子系统”
>> dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
# 启用 Windows 功能:“已安装的虚拟机平台”
>> dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
# <Distro> 改为对应从微软应用商店安装的 Linux 版本名,比如:`wsl --set-version Ubuntu 2`
# 如果你没有提前从微软应用商店安装任何 Linux 版本,请跳过此步骤
>> wsl --set-version <Distro> 2
# 设置默认为 WSL 2,如果 Windows 版本不够,这条命令会出错
>> wsl --set-default-version 2
- `下载 Linux 内核安装包 <https://docs.microsoft.com/zh-cn/windows/wsl/install-win10#step-4---download-the-linux-kernel-update-package>`_
- 在微软商店(Microsoft Store)中搜索并安装 Ubuntu18.04 / 20.04。
如果你打算使用 VMware 安装虚拟机的话,我们已经配置好了一个能直接运行 rCore-Tutorial-v3 的
Ubuntu18.04 镜像,它是一个 ``vmdk`` 格式的虚拟磁盘文件,只需要在 VMware 中新建一台虚拟机,
在设置虚拟磁盘的时候选择它即可。`百度网盘链接 <https://pan.baidu.com/s/1JzKjWivy9GZKK8rc3WMJ0g>`_ (提取码 x5mf )
或者 `清华云盘链接 <https://cloud.tsinghua.edu.cn/d/a9b7b0a1b4724c3f9c66/>`_ 。
已经创建好用户 oslab ,密码为一个空格。它已经安装了中文输入法和 Markdown 编辑器 Typora 还有作为 Rust 集成开发环境的
Visual Studio Code,能够更容易完成实验并撰写实验报告。
.. note::
**Docker 开发环境**
感谢 dinghao188 配置好的 Docker 开发环境:
.. code-block::
docker pull dinghao188/rcore-tutorial
你也可以在 Windows10 或 macOS 原生系统或者其他 Linux 发行版上进行实验,基本上不会出现太大的问题。不过由于
时间问题我们只在 Ubuntu18.04 上进行了测试,后面的配置也都是基于它的。如果遇到了问题的话,请在本节的讨论区
中留言,我们会尽量帮助解决。
Rust 开发环境配置
-------------------------------------------
首先安装 Rust 版本管理器 rustup 和 Rust 包管理器 cargo,这里我们用官方的安装脚本来安装:
.. code-block:: bash
curl https://sh.rustup.rs -sSf | sh
如果通过官方的脚本下载失败了,可以在浏览器的地址栏中输入 `<https://sh.rustup.rs>`_ 来下载脚本,在本地运行即可。
如果官方的脚本在运行时出现了网络速度较慢的问题,可选地可以通过修改 rustup 的镜像地址
(修改为中国科学技术大学的镜像服务器)来加速:
.. code-block:: bash
export RUSTUP_DIST_SERVER=https://mirrors.ustc.edu.cn/rust-static
export RUSTUP_UPDATE_ROOT=https://mirrors.ustc.edu.cn/rust-static/rustup
curl https://sh.rustup.rs -sSf | sh
或者使用tuna源来加速 `参见 rustup 帮助 <https://mirrors.tuna.tsinghua.edu.cn/help/rustup/>`_:
.. code-block:: bash
export RUSTUP_DIST_SERVER=https://mirrors.tuna.edu.cn/rustup
export RUSTUP_UPDATE_ROOT=https://mirrors.tuna.edu.cn/rustup/rustup
curl https://sh.rustup.rs -sSf | sh
或者也可以通过在运行前设置命令行中的科学上网代理来实现:
.. code-block:: bash
# e.g. Shadowsocks 代理,请根据自身配置灵活调整下面的链接
export https_proxy=http://127.0.0.1:1080
export http_proxy=http://127.0.0.1:1080
export ftp_proxy=http://127.0.0.1:1080
安装完成后,我们可以重新打开一个终端来让之前设置的环境变量生效。我们也可以手动将环境变量设置应用到当前终端,
只需要输入以下命令:
.. code-block:: bash
source $HOME/.cargo/env
接下来,我们可以确认一下我们正确安装了 Rust 工具链:
.. code-block:: bash
rustc --version
可以看到当前安装的工具链的版本。
.. code-block:: bash
rustc 1.46.0-nightly (7750c3d46 2020-06-26)
我们最好把软件包管理器 cargo 所用的软件包镜像地址 crates.io 也换成中国科学技术大学的镜像服务器来加速三方库的下载。
我们打开(如果没有就新建) ``~/.cargo/config`` 文件,并把内容修改为:
.. code-block:: toml
[source.crates-io]
registry = "https://github.com/rust-lang/crates.io-index"
replace-with = 'ustc'
[source.ustc]
registry = "git://mirrors.ustc.edu.cn/crates.io-index"
同样,也可以使用tuna源 `参见 crates.io 帮助 <https://mirrors.tuna.tsinghua.edu.cn/help/crates.io-index.git/>`_:
.. code-block:: toml
[source.crates-io]
replace-with = 'tuna'
[source.tuna]
registry = "https://mirrors.tuna.tsinghua.edu.cn/git/crates.io-index.git"
接下来安装一些Rust相关的软件包
.. code-block:: bash
rustup target add riscv64gc-unknown-none-elf
cargo install cargo-binutils
rustup component add llvm-tools-preview
rustup component add rust-src
.. note::
rCore-Tutorial 仓库中的 ``Makefile`` 包含了这些工具的安装,如果你使用 ``make run`` 也可以不手动安装。
至于 Rust 开发环境,推荐 JetBrains Clion + Rust插件 或者 Visual Studio Code 搭配 rust-analyzer 和 RISC-V Support 插件。
.. note::
* JetBrains Clion是付费商业软件,但对于学生和教师,只要在 JetBrains 网站注册账号,可以享受一定期限(半年左右)的免费使用的福利。
* Visual Studio Code 是开源软件,不用付费就可使用。
* 当然,采用 VIM,Emacs 等传统的编辑器也是没有问题的。
Qemu 模拟器安装
----------------------------------------
我们需要使用 Qemu 5.0.0 版本进行实验,而很多 Linux 发行版的软件包管理器默认软件源中的 Qemu 版本过低,因此
我们需要从源码手动编译安装 Qemu 模拟器。
首先我们安装依赖包,获取 Qemu 源代码并手动编译:
.. code-block:: bash
# 安装编译所需的依赖包
sudo apt install autoconf automake autotools-dev curl libmpc-dev libmpfr-dev libgmp-dev \
gawk build-essential bison flex texinfo gperf libtool patchutils bc \
zlib1g-dev libexpat-dev pkg-config libglib2.0-dev libpixman-1-dev git tmux python3
# 下载源码包
# 如果下载速度过慢可以使用我们提供的百度网盘链接:https://pan.baidu.com/s/1z-iWIPjxjxbdFS2Qf-NKxQ
# 提取码 8woe
wget https://download.qemu.org/qemu-5.0.0.tar.xz
# 解压
tar xvJf qemu-5.0.0.tar.xz
# 编译安装并配置 RISC-V 支持
cd qemu-5.0.0
./configure --target-list=riscv64-softmmu,riscv64-linux-user
make -j$(nproc)
.. note::
注意,上面的依赖包可能并不完全,比如在 Ubuntu 18.04 上:
- 出现 ``ERROR: pkg-config binary 'pkg-config' not found`` 时,可以安装 ``pkg-config`` 包;
- 出现 ``ERROR: glib-2.48 gthread-2.0 is required to compile QEMU`` 时,可以安装
``libglib2.0-dev`` 包;
- 出现 ``ERROR: pixman >= 0.21.8 not present`` 时,可以安装 ``libpixman-1-dev`` 包。
另外一些 Linux 发行版编译 Qemu 的依赖包可以从 `这里 <https://risc-v-getting-started-guide.readthedocs.io/en/latest/linux-qemu.html#prerequisites>`_
找到。
之后我们可以在同目录下 ``sudo make install`` 将 Qemu 安装到 ``/usr/local/bin`` 目录下,但这样经常会引起
冲突。个人来说更习惯的做法是,编辑 ``~/.bashrc`` 文件(如果使用的是默认的 ``bash`` 终端),在文件的末尾加入
几行:
.. code-block:: bash
# 请注意,qemu-5.0.0 的父目录可以随着你的实际安装位置灵活调整
export PATH=$PATH:/home/shinbokuow/Downloads/built/qemu-5.0.0
export PATH=$PATH:/home/shinbokuow/Downloads/built/qemu-5.0.0/riscv64-softmmu
export PATH=$PATH:/home/shinbokuow/Downloads/built/qemu-5.0.0/riscv64-linux-user
随后即可在当前终端 ``source ~/.bashrc`` 更新系统路径,或者直接重启一个新的终端。
此时我们可以确认 Qemu 的版本:
.. code-block:: bash
qemu-system-riscv64 --version
qemu-riscv64 --version
其他工具安装
------------------------------
为了能在 K210 真机上运行 Tutorial,我们还需要安装基于 Python 的串口通信库和简易的串口终端。
.. code-block:: bash
pip3 install pyserial
sudo apt install python-serial
下面这些工具链并不一定会被用到,可以等到真正依赖的时候再来安装。
运行 rCore-Tutorial-v3
------------------------------------------------------------
如果是在 Qemu 平台上运行,只需在 ``os`` 目录下 ``make run`` 即可。在内核加载完毕之后,可以看到目前可以用的
应用程序。 ``usertests`` 打包了其中的很大一部分,所以我们可以运行它,只需输入在终端中输入它的名字即可。
.. image:: qemu-final.gif
之后,可以先按下 ``Ctrl+A`` ,再按下 ``X`` 来退出 Qemu。
如果是在 K210 平台上运行则略显复杂。
首先,我们需要将 MicroSD 插入 PC 来将文件系统镜像拷贝上去。
.. image:: prepare-sd.gif
.. warning::
在 ``os/Makefile`` 中我们默认设置 MicroSD 在当前操作系统中可以用设备 ``SDCARD=/dev/sdb`` 访问。你可以使用 ``df -hT`` 命令来确认在你的环境中 MicroSD 是哪个设备,
并在 ``make sdcard`` 之前对 ``os/Makefile`` 的 ``SDCARD`` 配置做出适当的修改。不然,这有可能导致 **设备 /dev/sdb 上数据丢失**!
随后,我们将 MicroSD 插入 K210 开发板,将 K210 开发板连接到 PC ,然后进入 ``os`` 目录 ``make run BOARD=k210``
在 K210 开发板上跑 Tutorial 。
.. image:: k210-final.gif
之后,可以按下 ``Ctrl+]`` 来退出串口终端。
到这里,恭喜你完成了实验环境的配置,可以开始阅读教程的正文部分了!
K210 开发板相关问题
=====================================================
我们采用的真实硬件平台 Kendryte K210 在设计的时候基于 RISC-V 特权级架构 1.9.1 版本,它发布于 2016 年,目前已经不被
主流工具链所支持了。麻烦的是, 1.9.1 版本和后续版本确实有很多不同。为此,RustSBI 做了很多兼容性工作,使得基于新版规范
的软件几乎可以被不加修改的运行在 Kendryte K210 上。在这里我们先简单介绍一些开发板相关的问题。
K210 相关 Demo 和文档
--------------------------------------------
- `K210 datasheet <https://cdn.hackaday.io/files/1654127076987008/kendryte_datasheet_20181011163248_en.pdf>`_
- `K210 官方 SDK <https://github.com/kendryte/kendryte-standalone-sdk>`_
- `K210 官方 SDK 文档 <https://canaan-creative.com/wp-content/uploads/2020/03/kendryte_standalone_programming_guide_20190311144158_en.pdf>`_
- `K210 官方 SDK Demo <https://github.com/kendryte/kendryte-standalone-demo>`_
- `K210 Demo in Rust <https://github.com/laanwj/k210-sdk-stuff>`_
K210 相关工具
--------------------------------------------
JTAG 调试
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- `一篇 Blog <https://blog.sipeed.com/p/727.html>`_
- `Sipeed 工程师提供的详细配置文档 <https://github.com/wyfcyx/osnotes/blob/master/book/sipeed_rv_debugger_k210.pdf>`_
- `MaixDock OpenOCD 调试配置 <https://github.com/wyfcyx/osnotes/blob/master/book/openocd_ftdi.cfg>`_
烧写
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- `kflash.py <https://github.com/sipeed/kflash.py>`_
- `kflash_gui <https://github.com/sipeed/kflash_gui>`_
K210 可用内存大小
--------------------------------------------
K210 的内存是由 CPU 和 KPU 共享使用的,如果想要 CPU 能够使用全部的 :math:`8\text{MiB}` 需要满足三个条件:
- KPU 不能处于工作状态;
- PLL1 必须被使能;
- PLL1 的 clock gate 必须处于打开状态。
否则, CPU 仅能够使用 :math:`6\text{MiB}` 内存。
我们进行如下操作即可让 CPU 使用全部 :math:`8\text{MiB}` 内存(基于官方 SDK):
.. code-block:: c
sysctl_pll_enable(SYSCTL_PLL1);
syscyl_clock_enable(SYSCTL_CLOCK_PLL1);
K210 的频率
--------------------------------------------
默认情况下,K210 的 CPU 频率为 403000000 ,约 :math:`400\text{MHz}` 。而计数器 ``mtime`` CSR 增长的频率为
CPU 频率的 1/62 ,约 :math:`6.5\text{MHz}` 。
K210 的 MMU 支持
--------------------------------------------
K210 有完善的 SV39 多级页表机制,然而它是基于 1.9.1 版本特权级架构的,和我们目前使用的有一些不同。不过在 RustSBI
的帮助下,本项目中完全看不出 Qemu 和 K210 两个平台在这方面的区别。详情请参考
`RustSBI 的设计与实现 <https://github.com/luojia65/DailySchedule/blob/master/2020-slides/RustSBI%E7%9A%84%E8%AE%BE%E8%AE%A1%E4%B8%8E%E5%AE%9E%E7%8E%B0.pdf>`_
的 P11 。
K210 的外部中断支持
--------------------------------------------
K210 的 S 特权级外部中断不存在(被硬件置为零),因此任何软件/硬件代理均无法工作。为此,RustSBI 专门提供了一个新的 SBI
call ,让 S 模式软件可以编写 S 特权级外部中断的 handler 并注册到 RustSBI 中,在中断触发的时候由 RustSBI 调用该
handler 处理中断。详情请参考 `RustSBI 的设计与实现 <https://github.com/luojia65/DailySchedule/blob/master/2020-slides/RustSBI%E7%9A%84%E8%AE%BE%E8%AE%A1%E4%B8%8E%E5%AE%9E%E7%8E%B0.pdf>`_
的 P12 。
\ No newline at end of file
.. _link-chapter0:
第零章:操作系统概述
==============================================
.. toctree::
:maxdepth: 4
0intro
1what-is-os
2os-interface
3os-hw-abstract
4os-features
5setup-devel-env
6hardware
引言
=====================
本章导读
--------------------------
..
这是注释:我觉得需要给出执行环境(EE),Task,...等的描述。
并且有一个图,展示这些概念的关系。
大多数程序员的第一行代码都从 ``Hello, world!`` 开始,当我们满怀着好奇心在编辑器内键入仅仅数个字节,再经过几行命令编译(靠的是编译器)、运行(靠的是操作系统),终于在黑洞洞的终端窗口中看到期望中的结果的时候,一扇通往编程世界的大门已经打开。在本章第一节 :doc:`1app-ee-platform` 中,可以看到用Rust语言编写的非常简单的“Hello, world”应用程序。
不过我们能够隐约意识到编程工作能够如此方便简洁并不是理所当然的,实际上有着多层硬件和软件工具和支撑环境隐藏在它背后,才让我们不必付出那么多努力就能够创造出功能强大的应用程序。生成应用程序二进制执行代码所依赖的是以 **编译器** 为主的开发环境;运行应用程序执行码所依赖的是以 **操作系统** 为主的执行环境。
本章主要是设计和实现建立在裸机上的执行环境,从中对应用程序和它所依赖的执行环境有一个全面和深入的理解。
本章我们的目标仍然只是输出 ``Hello, world!`` ,但这一次,我们将离开舒适区,基于一个几乎空无一物的平台从零开始搭建我们自己的高楼大厦,
而不是仅仅通过一行语句就完成任务。所以,在接下来的内容中,我们将描述如何让 ``Hello, world!`` 应用程序逐步脱离对编译器、运行时和操作系统的现有复杂依赖,最终以最小的依赖需求能在裸机上运行。这时,我们也可把这个能在裸机上运行的 ``Hello, world!`` 应用程序称为一种支持输出字符串的非常初级的寒武纪“三叶虫”操作系统,它其实就是一个给应用提供各种服务(比如输出字符串)的库,方便了单一应用程序在裸机上的开发与运行。输出字符串功能好比是三叶虫的眼睛,有了它,我们就有了最基本的调试功能,即通过在代码中的不同位置插入特定内容的输出语句来实现对程序运行的调试。
.. note::
在操作系统发展历史上,在1956年就诞生了操作系统GM-NAA I/O,并且被实际投入使用,它的一个主要任务就是"自动加载运行一个接一个的程序"。
实践体验
---------------------------
本章设计实现了一个支持显示字符串应用的简单操作系统--“三叶虫”操作系统。
获取本章代码:
.. code-block:: console
$ git clone https://github.com/rcore-os/rCore-Tutorial-v3.git
$ cd rCore-Tutorial-v3
$ git checkout ch1
在 qemu 模拟器上运行本章代码,看看一个小应用程序是如何在QEMU模拟的计算机上运行的:
.. code-block:: console
$ cd os
$ make run
将 Maix 系列开发版连接到 PC,并在上面运行本章代码,看看一个小应用程序是如何在真实计算机上运行的:
.. code-block:: console
$ cd os
$ make run BOARD=k210
.. warning::
**FIXME: 提供 wsl/macOS 等更多平台支持**
如果顺利的话,以 qemu 平台为例,将输出:
.. code-block::
[rustsbi] Version 0.1.0
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Platform: QEMU
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: 0x222
[rustsbi] medeleg: 0xb109
[rustsbi] Kernel entry: 0x80020000
Hello, world!
.text [0x80020000, 0x80022000)
.rodata [0x80022000, 0x80023000)
.data [0x80023000, 0x80023000)
boot_stack [0x80023000, 0x80033000)
.bss [0x80033000, 0x80033000)
Panicked at src/main.rs:46 Shutdown machine!
除了 ``Hello, world!`` 之外还有一些额外的信息,最后关机。
.. note::
RustSBI是啥?
戳 :doc:`../appendix-c/index` 可以进一步了解RustSBI。
应用程序执行环境与平台支持
================================================
.. toctree::
:hidden:
:maxdepth: 5
本节导读
-------------------------------
本节介绍了如何设计实现一个提供显示字符服务的用户态执行环境和裸机执行环境,以支持一个应用程序显示字符串。显示字符服务的裸机执行环境和用户态执行环境向下直接或间接与硬件关联,向上可通过函数库给应用提供**显示字符** 的服务。这也说明了不管执行环境是简单还是复杂,设计实现上是否容易,它都体现了不同操作系统的共性特征--给应用需求提供服务。在某种程度上看,执行环境的软件主体就可称为是一种操作系统。
执行应用程序
-------------------------------
我们先在Linux上开发并运行一个简单的“Hello, world”应用程序,看看一个简单应用程序从开发到执行的全过程。作为一切的开始,让我们使用 Cargo 工具来创建一个 Rust 项目。它看上去没有任何特别之处:
.. code-block:: console
$ cargo new os --bin
我们加上了 ``--bin`` 选项来告诉 Cargo 我们创建一个可执行项目而不是库项目。此时,项目的文件结构如下:
.. code-block:: console
$ tree os
os
├── Cargo.toml
└── src
└── main.rs
1 directory, 2 files
其中 ``Cargo.toml`` 中保存着项目的配置,包括作者的信息、联系方式以及库依赖等等。显而易见源代码保存在 ``src`` 目录下,目前为止只有 ``main.rs``
一个文件,让我们看一下里面的内容:
.. code-block:: rust
:linenos:
:caption: 最简单的 Rust 应用
fn main() {
println!("Hello, world!");
}
进入 os 项目根目录下,利用 Cargo 工具即可一条命令实现构建并运行项目:
.. code-block:: console
$ cargo run
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
Finished dev [unoptimized + debuginfo] target(s) in 1.15s
Running `target/debug/os`
Hello, world!
如我们预想的一样,我们在屏幕上看到了一行 ``Hello, world!`` 。但是,需要注意到我们所享受到的编程和执行程序的方便性并不是理所当然的,背后有着从硬件
到软件的多种机制的支持。特别是对于应用程序的运行,是需要有一个强大的执行环境来帮助。接下来,我们就要看看有操作系统加持的强大的执行环境。
应用程序执行环境
-------------------------------
如下图所示,现在通用操作系统(如Linux等)上的应用程序运行需要下面一套多层次的执行环境栈的支持:
.. _app-software-stack:
.. figure:: app-software-stack.png
:align: center
应用程序执行环境栈:图中的白色块自上而下(越往下则越靠近底层,下层作为上层的执行环境支持上层代码的运行)表示各级执行环境,
黑色块则表示相邻两层执行环境之间的接口。
.. _term-execution-environment:
我们的应用位于最上层,它可以通过调用编程语言提供的标准库或者其他三方库对外提供的功能强大的函数接口,使得仅需少量的源代码就能完成复杂的
功能。但是这些库的功能不仅限于此,事实上它们属于应用程序的 **执行环境** (Execution Environment),在我们通常不会注意到的地方,它
们还会在执行应用之前完成一些初始化工作,并在应用程序执行的时候对它进行监控。我们在打印 ``Hello, world!`` 时使用的 ``println!``
宏正是由 Rust 标准库 std 和GNU Libc库等提供的。
.. _term-system-call:
从内核/操作系统的角度看来,它上面的一切都属于用户态,而它自身属于内核态。无论用户态应用如何编写,是手写汇编代码,还是基于某种编程语言利用
其标准库或三方库,某些功能总要直接或间接的通过内核/操作系统提供的 **系统调用** (System Call) 来实现。因此系统调用充当了用户和内核之间
的边界。内核作为用户态的执行环境,它不仅要提供系统调用接口,还需要对用户态应用的执行进行监控和管理。
.. note::
**Hello, world! 用到了哪些系统调用?**
从之前的 ``cargo run`` 的输出可以看出之前构建的可执行文件是在 target/debug 目录下的 os 。
在 Ubuntu 系统上,可以通过 ``strace`` 工具来运行一个程序并输出程序运行过程当中向内核请求的所有的系统调用及其返回值。
我们只需输入 ``strace target/debug/os`` 即可看到一长串的各种系统调用。
其中,容易看出与 ``Hello, world!`` 应用实际执行相关的只有两个系统调用:
.. code-block::
# 输出字符串
write(1, "Hello, world!\n", 14) = 14
# 程序退出执行
exit_group(0)
其参数的具体含义我们暂且不在这里进行解释。
其余的系统调用基本上分别用于函数库和内核两层执行环境的初始化工作和对于上层的运行期监控和管理。之后,随着应用场景的复杂化,我们
需要更强的抽象能力,也会实现这里面的一些系统调用。
.. _term-isa:
从硬件的角度来看,它上面的一切都属于软件。硬件可以分为三种: 处理器 (Processor) ——它更常见的名字是中央处理单元 (CPU, Central Processing Unit),
内存 (Memory) 还有 I/O 设备。其中处理器无疑是其中最复杂同时也最关键的一个。它与软件约定一套 **指令集体系结构** (ISA, Instruction Set Architecture),
使得软件可以通过 ISA 中提供的汇编指令来访问各种硬件资源。软件当然也需要知道处理器会如何执行这些指令:最简单的话就是一条一条执行位于内存
中的指令。当然,实际的情况远比这个要复杂得多,为了适应现代应用程序的场景,处理器还需要提供很多额外的机制,而不仅仅是让数据在 CPU 寄存器、内存和 I/O 设备
三者之间流动。
.. _term-abstraction:
.. note::
**多层执行环境都是必需的吗?**
除了最上层的应用程序和最下层的硬件平台必须存在之外,作为中间层的函数库和操作系统内核并不是必须存在的:
它们都是对下层资源进行了 **抽象** (Abstraction),
并为上层提供了一套执行环境(也可理解为一些服务功能)。抽象的优点在于它让上层以较小的代价获得所需的功能,并同时可以提供一些保护。
但抽象同时也是一种限制,会丧失一些
应有的灵活性。比如,当你在考虑在项目中应该使用哪个函数库的时候,就常常需要这方面的权衡:过多的抽象和过少的抽象自然都是不合适的。
理解应用的需求也很重要。一个能合理满足应用需求的操作系统设计是操作系统设计者需要深入考虑的问题。
这也是一种权衡,过多的服务功能和过少的服务功能自然都是不合适的。
实际上,我们通过应用程序的特征和需求来判断操作系统需要什么程度的抽象和功能。
- 如果函数库和内核都不存在,那么我们就是在手写汇编代码,这种方式具有最高的灵活性,抽象能力则最低,基本等同于硬件。我们通常用这种方式来
实现一些架构相关且仅通过编程语言无法描述的小模块或者代码片段。
- 如果仅存在函数库而不存在内核,意味着我们不需要内核提供的抽象。在嵌入式场景就常常会出现这种情况。嵌入式设备虽然也包含 CPU、内存和 I/O
设备,但是它上面通常只会同时运行一个或几个功能非常简单的小应用程序,其定位就是那种功能单一的场景,比如人脸识别打卡系统等。我们常用的
操作系统如 Windows/Linux/macOS 等的抽象都支持同时运行很多应用程序,在嵌入式场景是过抽象或功能太多,用力过猛的。因此,常见的解决方案是仅使用函数库构建
单独的应用程序或是用专为应用场景特别裁减过的轻量级内核管理少数应用程序。
.. note::
**“用力过猛”的现代操作系统**
对于如下更简单的小应用程序,我们可以看到“用力过猛”的现代操作系统提供的执行环境支持:
.. code-block:: rust
//ch1/donothing.rs
fn main() {
//do nothing
}
它只是想显示一下几乎感知不到的存在感。在编译后再运行,可以看到的情况是:
.. code-block:: console
$ rustc donothing.rs
$ ./donothing
$ #啥也没有
$ strace ./donothing
# 多达93行的输出,表明donothing向Linux操作系统内核发出了93次各种各样的系统调用
execve("./donothing", ["./donothing"], 0x7ffe02c9ca10 /* 67 vars */) = 0
brk(NULL) = 0x563ba0532000
arch_prctl(0x3001 /* ARCH_??? */, 0x7fff2da54360) = -1 EINVAL (无效的参数)
......
平台与目标三元组
---------------------------------------
.. _term-platform:
对于一份用某种编程语言实现的应用程序源代码而言,编译器在将其通过编译、链接得到目标文件的时候需要知道程序要在哪个 **平台** (Platform) 上运行。这里 **平台** 主要是指CPU类型、操作系统类型和标准运行时库的组合。
从上面给出的 :ref:`应用程序执行环境栈 <app-software-stack>` 可以看出:
- 如果用户态基于的内核不同,会导致系统调用接口不同或者语义不一致;
- 如果底层硬件不同,对于硬件资源的访问方式会有差异。特别是 ISA 不同的话,对上提供的指令集和寄存器都不同。
它们都会导致最终生成的目标文件有很大不同。需要指出的是,某些编译器支持同一份源代码无需修改就可编译到多个不同的目标平台并在上面运行。这种
情况下,源代码是 **跨平台** 的。而另一些编译器则已经预设好了一个固定的目标平台。
.. _term-target-triplet:
我们可以通过 **目标三元组** (Target Triplet) 来描述一个目标平台。它一般包括 CPU 架构、CPU 厂商、操作系统和运行时库,它们确实都会控制目标文件的生成。
比如,我们可以尝试看一下之前的 ``Hello, world!`` 的目标平台是什么。这可以通过打印编译器 rustc 的默认配置信息:
.. code-block:: console
$ rustc --version --verbose
rustc 1.51.0-nightly (d1aed50ab 2021-01-26)
binary: rustc
commit-hash: d1aed50ab81df3140977c610c5a7d00f36dc519f
commit-date: 2021-01-26
host: x86_64-unknown-linux-gnu
release: 1.51.0-nightly
LLVM version: 11.0.1
从其中的 host 一项可以看出默认的目标平台是 ``x86_64-unknown-linux-gnu``,其中 CPU 架构是 x86_64,CPU 厂商是 unknown,操作系统是 linux,运行时库是gnu libc(封装了Linux系统调用,并提供POSIX接口为主的函数库)。
这种无论编译器还是其目标文件都在我们当前所处的平台运行是一种最简单也最普遍的情况。但是很快我们就将遇到另外一种情况。
讲了这么多,终于该介绍我们的主线任务了。我们希望能够在另一个硬件平台上运行 ``Hello, world!``,而与之前的默认平台不同的地方在于,我们将 CPU 架构从
x86_64 换成 RISC-V。
.. note::
**为何基于 RISC-V 架构而非 x86 系列架构?**
x86 架构为了在升级换代的同时保持对基于旧版架构应用程序/内核的兼容性,存在大量的历史包袱,也就是一些对于目前的应用场景没有任何意义,但又必须
花大量时间正确设置才能正常使用 CPU 的奇怪设定。为了建立并维护架构的应用生态,这确实是必不可少的,但站在教学的角度几乎完全是在浪费时间。而
新生的 RISC-V 架构十分简洁,架构文档需要阅读的核心部分不足百页,且这些功能已经足以用来构造一个具有相当抽象能力的内核了。
可以看一下目前 Rust 编译器支持哪些基于 RISC-V 的平台:
.. code-block:: console
$ rustc --print target-list | grep riscv
riscv32gc-unknown-linux-gnu
riscv32i-unknown-none-elf
riscv32imac-unknown-none-elf
riscv32imc-unknown-none-elf
riscv64gc-unknown-linux-gnu
riscv64gc-unknown-none-elf
riscv64imac-unknown-none-elf
这里我们选择的是 ``riscv64gc-unknown-none-elf``,目标三元组中的CPU 架构是 riscv64gc,厂商是 unknown,操作系统是 none,elf表示没有标准的运行时库(表明没有任何系统调用的封装支持),但可以生成ELF格式的执行程序。这里我们之所以不选择有
linux-gnu 系统调用支持的版本 ``riscv64gc-unknown-linux-gnu``,是因为我们只是想跑一个 ``Hello, world!``,没有必要使用现在通用操作系统所提供的
那么高级的抽象和多余的操作系统服务。而且我们很清楚后续我们要开发的是一个操作系统内核,它必须直面底层物理硬件(bare-metal)来提供更大的操作系统服务功能,已有操作系统(如Linux)提供的系统调用服务对这个内核而言是多余的。
.. note::
**RISC-V 指令集拓展**
由于基于 RISC-V 架构的处理器可能用于嵌入式场景或是通用计算场景,因此指令集规范将指令集划分为最基本的 RV32/64I 以及若干标准指令集拓展。
每款处理器只需按照其实际应用场景按需实现指令集拓展即可。
- RV32/64I:每款处理器都必须实现的基本整数指令集。在 RV32I 中,每个通用寄存器的位宽为 32 位;在 RV64I 中则为 64 位。它可以用来模拟
绝大多数标准指令集拓展中的指令,除了比较特殊的 A 拓展,因为它需要特别的硬件支持。
- M 拓展:提供整数乘除法相关指令。
- A 拓展:提供原子指令和一些相关的内存同步机制,这个后面会展开。
- F/D 拓展:提供单/双精度浮点数运算支持。
- C 拓展:提供压缩指令拓展。
G 拓展是基本整数指令集 I 再加上标准指令集拓展 MAFD 的总称,因此 riscv64gc 也就等同于 riscv64imafdc。我们剩下的内容都基于该处理器
架构完成。除此之外 RISC-V 架构还有很多标准指令集拓展,有一些还在持续更新中尚未稳定,有兴趣的读者可以浏览最新版的 RISC-V 指令集规范。
Rust 标准库与核心库
----------------------------------
我们尝试一下将当前的 ``Hello, world!`` 程序的目标平台换成 riscv64gc-unknown-none-elf 看看会发生什么事情:
.. code-block:: console
$ cargo run --target riscv64gc-unknown-none-elf
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
error[E0463]: can't find crate for `std`
|
= note: the `riscv64gc-unknown-none-elf` target may not be installed
.. _term-bare-metal:
在之前的开发环境配置中,我们已经在 rustup 工具链中安装了这个目标平台支持,因此并不是该目标平台未安装的问题。这个问题只是单纯的表示在这个目标平台上找不到
Rust 标准库 std。我们之前曾经提到过,编程语言的标准库或三方库的某些功能会直接或间接的用到操作系统提供的系统调用。但目前我们所选的目标平台不存在
任何操作系统支持,于是 Rust 并没有为这个目标平台支持完整的标准库 std。类似这样的平台通常被我们称为 **裸机平台** (bare-metal)。
.. note::
**Rust语言标准库**
Rust语言标准库是让Rust语言开发的软件具备可移植性的基础,类似于C语言的LibC标准库。它是一组最小的、经过实战检验的共享抽象,适用于更广泛的Rust生态系统开发。它提供了核心类型,如Vec和Option、类库定义的语言原语操作、标准宏、I/O和多线程等。默认情况下,所有Rust crate都可以使用std来支持Rust应用程序的开发。但Rust语言标准库的一个限制是,它需要有操作系统的支持。所以,如果你要实现的软件是运行在裸机上的操作系统,就不能直接用Rust语言标准库了。
幸运的是,Rust 有一个对 std 裁剪过后的核心库 core,这个库是不需要任何操作系统支持的,相对的它的功能也比较受限,但是也包含了 Rust 语言
相当一部分的核心机制,可以满足我们的大部分需求。Rust 语言是一种面向系统(包括操作系统)开发的语言,所以在 Rust 语言生态中,有很多三方库也不依赖标准库 std 而仅仅依赖核心库 core。对它们的使用可以很大程度上减轻我们的编程负担。它们是我们能够在裸机平台挣扎求生的最主要倚仗,也是大部分运行在没有操作系统支持的Rust嵌入式软件的必备。
于是,我们知道在裸机平台上我们要将对于标准库 std 的引用换成核心库 core。但是做起来其实还要有一些琐碎的事情需要解决。
\ No newline at end of file
移除标准库依赖
==========================
.. toctree::
:hidden:
:maxdepth: 5
本节导读
-------------------------------
为了很好地理解一个简单应用所需的服务如何体现,本节将尝试开始构造一个小的执行环境,可建立在Linux之上,也可直接建立在裸机之上,
我们称为“三叶虫”操作系统。
作为第一步,本节将尝试移除之前的 ``Hello world!`` 程序对于Rust std标准库的依赖,使得它能够编译到裸机平台 RV64GC 或Linux上。
移除 println! 宏
----------------------------------
我们首先在 ``os`` 目录下新建 ``.cargo`` 目录,并在这个目录下创建 ``config`` 文件,并在里面输入如下内容:
.. code-block:: toml
# os/.cargo/config
[build]
target = "riscv64gc-unknown-none-elf"
.. _term-cross-compile:
这会对于 Cargo 工具在 os 目录下的行为进行调整:现在默认会使用 riscv64gc 作为目标平台而不是原先的默认 x86_64-unknown-linux-gnu。
事实上,这是一种编译器运行所在的平台与编译器生成可执行文件的目标平台不同(分别是后者和前者)的情况。这是一种 **交叉编译** (Cross Compile)。
..
chyyuu:解释一下交叉编译???
当然,这只是使得我们之后在 ``cargo build`` 的时候不必再加上 ``--target`` 参数的一个小 trick。如果我们现在 ``cargo build`` ,还是会和
上一小节一样出现找不到标准库 std 的错误。于是我们开始着手移除标准库。当然,这会产生一些副作用。
我们在 ``main.rs`` 的开头加上一行 ``#![no_std]`` 来告诉 Rust 编译器不使用 Rust 标准库 std 转而使用核心库 core。编译器报出如下错误:
.. error::
.. code-block:: console
$ cargo build
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
error: cannot find macro `println` in this scope
--> src/main.rs:4:5
|
4 | println!("Hello, world!");
| ^^^^^^^
我们之前提到过, println! 宏是由标准库 std 提供的,且会使用到一个名为 write 的系统调用。现在我们的代码功能还不足以自己实现一个 println! 宏。由于
使用了系统调用也不能在核心库 core 中找到它,所以我们目前先通过将它注释掉来绕过它。
提供语义项 panic_handler
----------------------------------------------------
.. error::
.. code-block:: console
$ cargo build
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
error: `#[panic_handler]` function required, but not found
在使用 Rust 编写应用程序的时候,我们常常在遇到了一些无法恢复的致命错误导致程序无法继续向下运行的时候手动或自动调用 panic! 宏来并打印出错的
位置让我们能够意识到它的存在,并进行一些后续处理。panic! 宏最典型的应用场景包括断言宏 assert! 失败或者对 ``Option::None/Result::Err``
进行 ``unwrap`` 操作。
在标准库 std 中提供了 panic 的处理函数 ``#[panic_handler]``,其大致功能是打印出错位置和原因并杀死当前应用。可惜的是在核心库 core 中并没有提供,
因此我们需要自己实现 panic 处理函数。
.. note::
**Rust 语法卡片:语义项 lang_items**
Rust 编译器内部的某些功能的实现并不是硬编码在语言内部的,而是以一种可插入的形式在库中提供。库只需要通过某种方式告诉编译器它的某个方法实现了
编译器内部的哪些功能,编译器就会采用库提供的方法来实现它内部对应的功能。通常只需要在库的方法前面加上一个标记即可。
我们开一个新的子模块 ``lang_items.rs`` 保存这些语义项,在里面提供 panic 处理函数的实现并通过标记通知编译器采用我们的实现:
.. code-block:: rust
// os/src/lang_items.rs
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
注意,panic 处理函数的函数签名需要一个 ``PanicInfo`` 的不可变借用作为输入参数,它在核心库中得以保留,这也是我们第一次与核心库打交道。之后我们
会从 ``PanicInfo`` 解析出错位置并打印出来,然后杀死应用程序。但目前我们什么都不做只是在原地 ``loop`` 。
移除 main 函数
-----------------------------
.. error::
.. code-block::
$ cargo build
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
error: requires `start` lang_item
编译器提醒我们缺少一个名为 ``start`` 的语义项。我们回忆一下,之前提到语言标准库和三方库作为应用程序的执行环境,需要负责在执行应用程序之前进行
一些初始化工作,然后才跳转到应用程序的入口点(也就是跳转到我们编写的 ``main`` 函数)开始执行。事实上 ``start`` 语义项正代表着标准库 std 在
执行应用程序之前需要进行的一些初始化工作。由于我们禁用了标准库,编译器也就找不到这项功能的实现了。
最简单的解决方案就是压根不让编译器使用这项功能。我们在 ``main.rs`` 的开头加入设置 ``#![no_main]`` 告诉编译器我们没有一般意义上的 ``main`` 函数,
并将原来的 ``main`` 函数删除。在失去了 ``main`` 函数的情况下,编译器也就不需要完成所谓的初始化工作了。
至此,我们成功移除了标准库的依赖并完成裸机平台上的构建。
.. 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
目前的代码如下:
.. code-block:: rust
// os/src/main.rs
#![no_std]
#![no_main]
mod lang_items;
// os/src/lang_items.rs
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
本小节我们固然脱离了标准库,通过了编译器的检验,但也是伤筋动骨,将原有的很多功能弱化甚至直接删除,看起来距离在 RV64GC 平台上打印
``Hello world!`` 相去甚远了(我们甚至连 println! 和 ``main`` 函数都删除了)。不要着急,接下来我们会以自己的方式来重塑这些
基本功能,并最终完成我们的目标。
分析被移除标准库的程序
-----------------------------
对于上面这个被移除标准库的应用程序,通过了编译器的检查和编译,形成了二进制代码。但这个二进制代码能执行吗?生成的代码结构是啥?我们可以通过一些工具来分析一下。
.. code-block:: console
# 文件格式
$ 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: 0x0
......
}
# 反汇编导出汇编程序
$ rust-objdump -S target/riscv64gc-unknown-none-elf/debug/os
target/riscv64gc-unknown-none-elf/debug/os: file format elf64-littleriscv
通过 ``file`` 工具对二进制程序 ``os`` 的分析可以看到它好像是一个合法的RISC-V 64执行程序,但通过 ``rust-readobj`` 工具进一步分析,
发现它的入口地址 ``Entry`` 是 ``0`` ,这就比较奇怪了,地址从0执行,好像不对。再通过 ``rust-objdump`` 工具把它反汇编,可以看到没有
生成汇编代码。所以,我们可以断定,这个二进制程序虽然合法,但它是一个空程序。这不是我们希望的,我们希望有具体内容的执行程序。为什么会这样呢?
原因是我们缺少了编译器需要找到的入口函数 ``_start`` 。
在下面几节,我们将建立有支持显示字符串的最小执行环境。
.. note::
**在 x86_64 平台上移除标准库依赖**
有兴趣的同学可以将目标平台换回之前默认的 ``x86_64-unknown-linux-gnu`` 并重复本小节所做的事情,比较两个平台从 ISA 到操作系统
的差异。可以参考 `BlogOS 的相关内容 <https://os.phil-opp.com/freestanding-rust-binary/>`_ 。
.. note::
本节内容部分参考自 `BlogOS 的相关章节 <https://os.phil-opp.com/freestanding-rust-binary/>`_ 。
\ No newline at end of file
构建用户态执行环境
=================================
.. toctree::
:hidden:
:maxdepth: 5
本节导读
-------------------------------
本节开始我们将着手自己来实现之前被我们移除的 ``Hello, world!`` 程序中执行环境的功能。
在这一小节,我们介绍如何进行 **执行环境初始化** 。
在这里,我们先设计实现一个最小执行环境以支持最简单的用户态 ``Hello, world!`` 程序,再改进这个最小执行环境,支持对裸机应用程序。这样设计实现的原因是,
它能帮助我们理解这两个不同的执行环境在支持同样一个应用程序时的的相同和不同之处,这将加深对执行环境的理解,并对后续写自己的OS和运行在OS上的应用程序都有帮助。
所以,本节将先建立一个用户态的最小执行环境,即 **恐龙虾** 操作系统。
用户态最小化执行环境
----------------------------
在上一节,我们构造的二进制程序是一个空程序,其原因是Rust编译器找不到执行环境的入口函数,于是就没有生产后续的代码。所以,我们首先要把入口函数
找到。通过查找资料,发现Rust编译器要找的入口函数是 ``_start()`` ,于是我们可以在 ``main.rs`` 中添加如下内容:
.. code-block:: rust
// os/src/main.rs
#[no_mangle]
extern "C" fn _start() {
loop{};
}
对上述代码重新编译,再用分析工具分析,可以看到:
.. 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`` 的分析可以看到它依然是一个合法的RISC-V 64执行程序,但通过 ``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
段错误 (核心已转储)
`` 段错误 (核心已转储)`` 是常见的一种应用程序出错,而我们这个非常简单的应用程序导致了用户态硬件模拟程序 ``qemu-riscv64`` 崩溃了!为什么会这样?
回顾一下我们曾经写过的简单应用程序,好像入口函数名字是 ``main`` 函数, 编译时用的是标准的std库。执行起来没啥问题。再仔细想想,当一个应程序如果出错,
有操作系统顶着的执行环境会把它给杀死。但如果一个应用的入口函数正常返回,执行环境应该优雅地让它退出才对。没错!目前的执行环境还缺了一个退出机制。
先了解一下,操作系统会提供一个退出的系统调用服务接口,但应用程序调用这个接口,那这个程序就退出了。这里先给出代码:
.. code-block:: rust
// os/src/main.rs
#![feature(llvm_asm)]
const SYSCALL_EXIT: usize = 93;
fn syscall(id: usize, args: [usize; 3]) -> isize {
let mut ret: isize;
unsafe {
llvm_asm!("ecall"
: "={x10}" (ret)
: "{x10}" (args[0]), "{x11}" (args[1]), "{x12}" (args[2]), "{x17}" (id)
: "memory"
: "volatile"
);
}
ret
}
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(STDOUT, s.as_bytes());
Ok(())
}
}
pub fn print(args: fmt::Arguments) {
Stdout.write_fmt(args).unwrap();
}
最后,实现基于 ``print`` 函数,实现Rust语言 **格式化宏** ( `formatting macros <https://doc.rust-lang.org/std/fmt/#related-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
段错误 (核心已转储)
系统崩溃了!借助以往的操作系统内核编程经验和与下一节调试kernel的成果经验,我们直接定位为是 **栈 stack** 没有设置的问题。我们需要添加建立栈的代码逻辑。
.. code-block:: 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() {
再次编译执行,可以看到正确的字符串输出,且程序也能正确结束!
.. _link-chapter1:
第一章:RV64 裸机应用
==============================================
.. toctree::
:maxdepth: 4
0intro
1app-ee-platform
2remove-std
3-1-mini-rt-usrland
3-2-mini-rt-baremetal
4understand-prog
引言
================================
本章导读
---------------------------------
..
chyyuu:有一个ascii图,画出我们做的OS。
上一章,我们在 RV64 裸机平台上成功运行起来了 ``Hello, world!`` 。看起来这个过程非常顺利,只需要一条命令就能全部完成。但实际上,在那个
计算机刚刚诞生的年代,很多事情并不像我们想象的那么简单。 当时,程序被记录在打孔的卡片上,使用汇编语言甚至机器语言来编写。而稀缺且昂贵的
计算机由专业的管理员负责操作,就和我们在上一章所做的事情一样,他们手动将卡片输入计算机,等待程序运行结束或者终止程序的运行。最后,他们从
计算机的输出端——也就是打印机中取出程序的输出并交给正在休息室等待的程序提交者。
实际上,这样做是一种对于珍贵的计算资源的浪费。因为当时的计算机和今天的个人计算机不同,它的体积极其庞大,能够占满一整个空调房间,象巨大的史前生物。
管理员在房间的各个地方跑来跑去、或是等待打印机的输出的这些时间段,计算机都并没有在工作。于是,人们希望计算机能够不间断的工作且专注于计算任务本身。
.. _term-batch-system:
**批处理系统** (Batch System) 应运而生。它的核心思想是:将多个程序打包到一起输入计算机。而当一个程序运行结束后,计算机会 *自动* 加载
下一个程序到内存并开始执行。这便是最早的真正意义上的操作系统。
.. _term-privilege:
程序总是难免出现错误。但人们希望一个程序的错误不要影响到操作系统本身,它只需要终止出错的程序,转而运行执行序列中的下一个程序即可。如果后面的
程序都无法运行就太糟糕了。这种 *保护* 操作系统不受有意或无意出错的程序破坏的机制被称为 **特权级** (Privilege) 机制,它实现了用户态和
内核态的隔离,需要软件和硬件的共同努力。
本章主要是设计和实现建立支持**批处理系统**的泥盆纪“邓式鱼”操作系统,从而对可支持运行一批应用程序的执行环境有一个全面和深入的理解。
本章我们的目标让泥盆纪“邓式鱼”操作系统能够感知多个应用程序的存在,并一个接一个地运行这些应用程序,当一个应用程序执行完毕后,会启动下一个应用程序,
直到所有的应用程序都执行完毕。
.. image:: deng-fish.png
:align: center
:name: fish-os
实践体验
---------------------------
本章我们的批处理系统将连续运行三个应用程序,放在 ``user/src/bin`` 目录下。
获取本章代码:
.. code-block:: console
$ git clone https://github.com/rcore-os/rCore-Tutorial-v3.git
$ cd rCore-Tutorial-v3
$ git checkout ch2
在 qemu 模拟器上运行本章代码:
.. code-block:: console
$ cd os
$ make run
将 Maix 系列开发版连接到 PC,并在上面运行本章代码:
.. code-block:: console
$ cd os
$ make run BOARD=k210
如果顺利的话,我们可以看到批处理系统自动加载并运行所有的程序并且正确在程序出错的情况下保护了自身:
.. code-block::
[rustsbi] Version 0.1.0
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Platform: QEMU
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: 0x222
[rustsbi] medeleg: 0xb1ab
[rustsbi] Kernel entry: 0x80020000
[kernel] Hello, world!
[kernel] num_app = 3
[kernel] app_0 [0x8002b028, 0x8002c328)
[kernel] app_1 [0x8002c328, 0x8002d6c0)
[kernel] app_2 [0x8002d6c0, 0x8002eb98)
[kernel] Loading app_0
Hello, world!
[kernel] Application exited with code 0
[kernel] Loading app_1
Into Test store_fault, we will insert an invalid store operation...
Kernel should kill this application!
[kernel] PageFault in application, core dumped.
[kernel] Loading app_2
3^10000=5079
3^20000=8202
3^30000=8824
3^40000=5750
3^50000=3824
3^60000=8516
3^70000=2510
3^80000=9379
3^90000=2621
3^100000=2749
Test power OK!
[kernel] Application exited with code 0
[kernel] Panicked at src/batch.rs:61 All applications completed!
\ No newline at end of file
特权级机制
=====================================
.. toctree::
:hidden:
:maxdepth: 5
本节导读
-------------------------------
为了保护我们的批处理操作系统不受到出错应用程序的影响并全程稳定工作,单凭软件实现是很难做到的,而是需要 CPU 提供一种特权级隔离机制,使CPU在执行应用程序和操作系统内核的指令时处于不同的特权级。本节主要介绍了特权级机制的软硬件设计思路,以及RISC-V的特权级架构。
特权级的软硬件协同设计
------------------------------------------
实现特权级机制的根本原因是应用程序运行的安全性不可充分信任。在上一节里,操作系统和应用紧密连接在一起,形成一个应用程序来执行。随着应用需求的增加,操作系统也越来越大,会以库的形式存在;同时应用自身也会越来越复杂。由于操作系统会给多个应用提供服务,所以它可能的错误会比较快地被发现,但应用自身的错误可能就不会很快发现。由于二者通过编译器形成一个应用程序来执行,即使是应用本身的问题,也会导致操作系统受到连累,从而可能导致整个计算机系统都不可用了。
所以,计算机专家就想到一个方法,能否让相对安全可靠的操作系统不受到应用程序的破坏,运行在一个安全的执行环境中,而让应用程序运行在一个无法破坏操作系统的执行环境中?
为确保操作系统的安全,对应用程序而言,需要限制的主要有两个方面:
- 应用程序不能访问任意的地址空间(这个在第四章会进一步讲解,本章不会讲解)
- 应用程序不能执行某些可能破会计算机系统的指令(本章的重点)
假设有了这样的限制,我们还需要确保应用程序能够得到操作系统的服务,即应用程序和操作系统还需要有交互的手段。使得低特权级软件都只能做高特权级软件允许它做的,且低特权级软件的超出其能力的要求必须寻求高特权级软件的帮助。在这里的高特权级软件就是低特权级软件的软件执行环境。
为了完成这样的特权级需求,需要进行软硬件协同设计。一个比较简洁的方法就是,处理器设置两个不同安全等级的执行环境:用户态特权级的执行环境和内核态特权级的执行环境。且明确指出可能破会计算机系统的内核态特权级指令子集,规定内核态特权级指令子集中的指令只能在内核态特权级的执行环境中执行,如果在用户态特权级的执行环境中执行这些指令,会产生异常。处理器在执行不同特权级的执行环境下的指令前进行特权级安全检查。
为了让应用程序获得操作系统的函数服务,采用传统的函数调用方式(即通常的 ``call`` 和 ``ret`` 指令或指令组合)将会直接绕过硬件的特权级保护检查。所以要设计新的指令:执行环境调用(Execution Environment Call,简称ecall)和执行环境返回(Execution Environment Return,简称eret)):
- ecall:具有用户态到内核态的执行环境切换能力的函数调用指令
- eret:具有用户态到内核态的执行环境切换能力的函数返回指令
但硬件具有了这样的机制后,还需要操作系统的配合才能最终完成对操作系统自己的保护。首先,操作系统需要提供相应的控制流,能在执行 ``eret`` 前准备和恢复用户态执行应用程序的上下文。其次,在应用程序调用 ``ecall`` 指令后,能够保存用户态执行应用程序的上下文,便于后续的恢复;且还要坚持应用程序发出的服务请求是安全的。
.. note::
在实际的CPU,如x86、RISC-V等,设计了多达4种特权级。对于一般的操作系统而言,其实只要两种特权级就够了。
RISC-V 特权级架构
------------------------------------------
RISC-V 架构中一共定义了 4 种特权级:
.. list-table:: RISC-V 特权级
:widths: 30 30 60
:header-rows: 1
:align: center
* - 级别
- 编码
- 名称
* - 0
- 00
- 用户/应用模式 (U, User/Application)
* - 1
- 01
- 监督模式 (S, Supervisor)
* - 2
- 10
- H, Hypervisor
* - 3
- 11
- 机器模式 (M, Machine)
其中,级别的数值越小,特权级越高,掌控硬件的能力越强。从表中可以看出, M 模式处在最高的特权级,而 U 模式处于最低的特权级。
之前我们给出过支持应用程序运行的一套 :ref:`执行环境栈 <app-software-stack>` ,现在我们站在特权级架构的角度去重新看待它:
.. image:: PrivilegeStack.png
:align: center
:name: PrivilegeStack
.. _term-see:
和之前一样,白色块表示一层执行环境,黑色块表示相邻两层执行环境之间的接口。这张图片给出了能够支持运行 Unix 这类复杂系统的软件栈。其中
内核代码运行在 S 模式上;应用程序运行在 U 模式上。运行在 M 模式上的软件被称为 **监督模式执行环境** (SEE, Supervisor Execution Environment)
,这是站在运行在 S 模式上的软件的视角来看,它的下面也需要一层执行环境支撑,因此被命名为 SEE,它需要在相比 S 模式更高的特权级下运行,
一般情况下在 M 模式上运行。
.. note::
**按需实现 RISC-V 特权级**
RISC-V 架构中,只有 M 模式是必须实现的,剩下的特权级则可以根据跑在 CPU 上应用的实际需求进行调整:
- 简单的嵌入式应用只需要实现 M 模式;
- 带有一定保护能力的嵌入式系统需要实现 M/U 模式;
- 复杂的多任务系统则需要实现 M/S/U 模式。
- 到目前为止,(Hypervisor, H)模式的特权规范还没完全制定好。所以本书不会涉及。
之前我们提到过,执行环境的其中一种功能是在执行它支持的上层软件之前进行一些初始化工作。我们之前提到的引导加载程序会在加电后对整个系统进行
初始化,它实际上是 SEE 功能的一部分,也就是说在 RISC-V 架构上引导加载程序一般运行在 M 模式上。此外,编程语言的标准库也会在执行程序员
编写的逻辑之前进行一些初始化工作,但是在这张图中我们并没有将其展开,而是统一归类到 U 模式软件,也就是应用程序中。
.. _term-ecf:
.. _term-trap:
执行环境的另一种功能是对上层软件的执行进行监控管理。监控管理可以理解为,当上层软件执行的时候出现了一些情况导致需要用到执行环境中提供的功能,
因此需要暂停上层软件的执行,转而运行执行环境的代码。由于上层软件和执行环境被设计为运行在不同的特权级,这个过程也往往(而 **不一定** )
伴随着 CPU 的 **特权级切换** 。当执行环境的代码运行结束后,我们需要回到上层软件暂停的位置继续执行。在 RISC-V 架构中,这种与常规控制流
(顺序、循环、分支、函数调用)不同的 **异常控制流** (ECF, Exception Control Flow) 被称为 **陷入** (Trap) 。
.. _term-exception:
触发 Trap 的原因总体上可以分为两种: 中断和 **异常** (Exception) 。本章我们只会用到异常,因此暂且略过中断。异常
就是指上层软件需要执行环境功能的原因确切的与上层软件的 **某一条指令的执行** 相关。下表中我们给出了 RISC-V 特权级定义的一些异常:
.. list-table:: RISC-V 异常一览表
:align: center
:header-rows: 1
:widths: 30 30 60
* - Interrupt
- Exception Code
- Description
* - 0
- 0
- Instruction address misaligned
* - 0
- 1
- Instruction access fault
* - 0
- 2
- Illegal instruction
* - 0
- 3
- Breakpoint
* - 0
- 4
- Load address misaligned
* - 0
- 5
- Load access fault
* - 0
- 6
- Store/AMO address misaligned
* - 0
- 7
- Store/AMO access fault
* - 0
- 8
- Environment call from U-mode
* - 0
- 9
- Environment call from S-mode
* - 0
- 11
- Environment call from M-mode
* - 0
- 12
- Instruction page fault
* - 0
- 13
- Load page fault
* - 0
- 14
- Store/AMO page fault
.. _term-environment-call:
其中断点(Breakpoint) 和 **执行环境调用** (Environment call) 两个异常(为了与其他非有意为之的异常区分,会把这种有意为之的指令称为 ``陷入`` 或
``trap`` )是通过在上层软件中执行一条特定的指令触发的:当执行 ``ebreak``
这条指令的之后就会触发断点陷入;而执行 ``ecall`` 这条指令的时候则会随着 CPU 当前所处特权级而触发不同的 ``陷入`` 情况。从表中可以看出,当 CPU 分别
处于 M/S/U 三种特权级时执行 ``ecall`` 这条指令会触发三种陷入。
.. _term-sbi:
.. _term-abi:
在这里我们需要说明一下执行环境调用 ``ecall`` ,这是一种很特殊的会产生 ``陷入`` 的指令, :ref:`上图 <PrivilegeStack>` 中相邻两特权级软件之间的接口正是基于这种陷入
机制实现的。M 模式软件 SEE 和 S 模式的内核之间的接口被称为 **监督模式二进制接口** (Supervisor Binary Interface, SBI),而内核和
U 模式的应用程序之间的接口被称为 **应用程序二进制接口** (Application Binary Interface, ABI),当然它有一个更加通俗的名字—— **系统调用**
(syscall, System Call) 。而之所以叫做二进制接口,是因为它和在同一种编程语言内部调用接口不同,是汇编指令级的一种接口。事实上 M/S/U
三个特权级的软件可能分别由不同的编程语言实现,即使是用同一种编程语言实现的,其调用也并不是普通的函数调用执行流,而是陷入,在该过程中有可能
切换 CPU 特权级。因此只有将接口下降到汇编指令级才能够满足其通用性和灵活性。
可以看到,在这样的架构之下,每层特权级的软件都只能做高特权级软件允许它做的、且不会产生什么撼动高特权级软件的事情,一旦低特权级软件的要求超出了其能力范围,
就必须寻求高特权级软件的帮助。因此,在一条执行流中我们经常能够看到特权级切换。如下图所示:
.. image:: EnvironmentCallFlow.png
:align: center
:name: environment-call-flow
.. _term-csr:
其他的异常则一般是在执行某一条指令的时候发生了错误(非有意为之),需要将控制转交给高特权级软件:当错误可恢复的时候,则处理错误并重新回到上层软件的执行;
否则,一般会将上层软件杀死以避免破坏执行环境。非法指令错误就可以在某种程度上实现特权级保护机制。通用寄存器 ``x0~x31`` 在任何特权级
都可以任意访问,而每个特权级都对应一些特殊的 **控制状态寄存器** (CSR, Control and Status Register) 来控制该特权级的某些行为并描述
其状态, 它们必须通过特殊的特权指令才能够访问。当然特权指令不只有读写 CSR 这一种用途,还有其他功能的特权指令。当我们在低特权级使用高特权级
的特权指令时就会触发非法指令异常,于是位于高特权级的执行环境能够得知上层软件出现了该错误,这个错误一般是不可恢复的,此时一般它会将上层的低特权级软件终止。
第一章只是一个简单的裸机应用,它全程运行在 S 模式下。而在后续的章节中,我们会涉及到 M/S/U 三种特权级:其中我们的内核运行在 S 模式下
(在本章表现为一个简单的批处理系统),应用程序运行在 U 特权级下,而第一章提到的预编译的 bootloader -- ``RustSBI`` 实际上是运行在 M 模式下的 SEE。
整个系统就由这三层运行在不同特权级下的不同软件组成。在特权级相关机制方面,本书正文中我们重点关心 S/U 特权级, M 特权级的机制细节则
是作为可选内容在 :doc:`/appendix-c/index` 中讲解,有兴趣的读者可以参考。
..
随着特权级的逐渐降低,硬件的能力受到限制,
从每一个特权级看来,比它特权级更低的部分都可以看成是它的应用。(这个好像没啥用?)
M 模式是每个 RISC-V CPU 都需要实现的模式,而剩下的模式都是可选的。常见的模式组合:普通嵌入式应用只需要在 M 模式上运行;追求安全的
嵌入式应用需要在 M/U 模式上运行;像 Unix 这样比较复杂的系统这需要 M/S/U 三种模式。
RISC-V 特权级规范中给出了一些特权寄存器和特权指令...
重要的是保护,也就是特权级的切换。当 CPU 处于低特权级的时候,如果发生了错误或者一些需要处理的情况,CPU 会切换到高特权级进行处理。这个
就是所谓的 Trap 机制。
RISC-V 架构规范分为两部分: `RISC-V 无特权级规范 <https://github.com/riscv/riscv-isa-manual/releases/download/Ratified-IMAFDQC/riscv-spec-20191213.pdf>`_
和 `RISC-V 特权级规范 <https://github.com/riscv/riscv-isa-manual/releases/download/Ratified-IMFDQC-and-Priv-v1.11/riscv-privileged-20190608.pdf>`_ 。
RISC-V 无特权级规范中给出的指令和寄存器无论在 CPU 处于哪个特权级下都可以使用。
实现应用程序
===========================
.. toctree::
:hidden:
:maxdepth: 5
本节导读
-------------------------------
本节主要讲解如何设计实现被批处理系统逐个加载并运行的应用程序。它们是假定在 U 特权级模式运行的前提下而设计、编写的(实际上它们完全可能在其他特权级运行)。保证应用程序的代码在 U 模式运行是我们接下来将实现的批处理系统的任务。其涉及的设计实现要点是:
- 应用程序的内存布局
- 应用程序发出的系统调用
从某种程度上讲,这里设计的应用程序与第一章中的最小用户态执行环境有很多相同的地方。即设计一个应用程序,能够在用户态通过操作系统提供的服务完成自身的功能。
应用程序设计
-----------------------------
应用程序的实现放在项目根目录的 ``user`` 目录下,它和第一章的裸机应用不同之处在于以下几点。
项目结构
^^^^^^^^^^^^^^^^^^^^^^
我们看到 ``user/src`` 目录下面多出了一个 ``bin`` 目录。``bin`` 里面有多个文件,每个文件都是一个用户程序,目前里面有三个程序,分别是:
- ``00hello_world``:在屏幕上打印一行 ``Hello, world!``;
- ``01store_fault``:访问一个非法的物理地址,测试批处理系统是否会被该错误影响;
- ``02power``:一个略微复杂的、行为不断在计算和打印字符串间切换的程序。
批处理系统会按照文件名开头的编号从小到大的顺序加载并运行它们。
打开其中任意一个文件,会看到里面只有一个 ``main`` 函数,因此这很像是我们日常利用高级语言编程,只需要在单个文件中给出主逻辑的实现即可。
我们还能够看到代码中尝试引入了外部库:
.. code-block:: rust
#[macro_use]
extern crate user_lib;
这个外部库其实就是 ``user`` 目录下的 ``lib.rs`` 以及它引用的若干子模块中。至于这个外部库为何叫 ``user_lib`` 而不叫 ``lib.rs``
所在的目录的名字 ``user`` ,是因为在 ``user/Cargo.toml`` 中我们对于库的名字进行了设置: ``name = "user_lib"`` 。它作为
``bin`` 目录下的源程序所依赖的用户库,等价于其他编程语言提供的标准库。
在 ``lib.rs`` 中我们定义了用户库的入口点 ``_start`` :
.. code-block:: rust
:linenos:
#[no_mangle]
#[link_section = ".text.entry"]
pub extern "C" fn _start() -> ! {
clear_bss();
exit(main());
panic!("unreachable after sys_exit!");
}
第 2 行使用 Rust 的宏将 ``_start`` 这段代码编译后的汇编代码中放在一个名为 ``.text.entry`` 的代码段中,方便我们在后续链接的时候
调整它的位置使得它能够作为用户库的入口。
而从第 4 行开始我们能够看到进入用户库入口之后,首先和第一章一样手动清空需要被零初始化 ``.bss`` 段(很遗憾到目前为止底层的批处理系统还
没有这个能力,所以我们只能在用户库中完成),然后是调用 ``main`` 函数得到一个类型为 ``i32`` 的返回值。
第 5 行我们调用后面会提到的用户库提供的 ``exit`` 接口退出应用程序并将这个返回值告知批处理系统。
我们还在 ``lib.rs`` 中看到了另一个 ``main`` :
.. code-block:: rust
:linenos:
#[linkage = "weak"]
#[no_mangle]
fn main() -> i32 {
panic!("Cannot find main!");
}
第 1 行,我们使用 Rust 的宏将其函数符号 ``main`` 标志为弱链接。这样在最后链接的时候,虽然在 ``lib.rs`` 和 ``bin`` 目录下的某个
应用程序都有 ``main`` 符号,但由于 ``lib.rs`` 中的 ``main`` 符号是弱链接,链接器会使用 ``bin`` 目录下的应用主逻辑作为 ``main`` 。
这里我们主要是进行某种程度上的保护,如果在 ``bin`` 目录下找不到任何 ``main`` ,那么编译也能够通过,并会在运行时报错。
为了上述这些链接操作,我们需要在 ``lib.rs`` 的开头加入:
.. code-block:: rust
#![feature(linkage)]
内存布局
^^^^^^^^^^^^^^^^^^^^^^
在 ``user/.cargo/config`` 中,我们和第一章一样设置链接时使用链接脚本 ``user/src/linker.ld`` 。在其中我们做的重要的事情是:
- 将程序的起始物理地址调整为 ``0x80040000`` ,三个应用程序都会被加载到这个物理地址上运行;
- 将 ``_start`` 所在的 ``.text.entry`` 放在整个程序的开头,也就是说批处理系统只要在加载之后跳转到 ``0x80040000`` 就已经进入了
用户库的入口点,并会在初始化之后跳转到应用程序主逻辑;
- 提供了最终生成可执行文件的 ``.bss`` 段的起始和终止地址,方便 ``clear_bss`` 函数使用。
其余的部分和第一章基本相同。
系统调用
^^^^^^^^^^^^^^^^^^^^^^
在子模块 ``syscall`` 中我们作为应用程序来通过 ``ecall`` 调用批处理系统提供的接口,由于应用程序运行在 U 模式, ``ecall`` 指令会触发
名为 ``Environment call from U-mode`` 的异常,并 Trap 进入 S 模式执行批处理系统针对这个异常特别提供的服务代码。由于这个接口处于
S 模式的批处理系统和 U 模式的应用程序之间,从上一节我们可以知道,这个接口可以被称为 ABI 或者系统调用。现在我们不关心底层的批处理系统如何
提供应用程序所需的功能,只是站在应用程序的角度去使用即可。
在本章中,应用程序和批处理系统之间约定如下两个系统调用:
.. code-block:: rust
:caption: 第二章新增系统调用
/// 功能:将内存中缓冲区中的数据写入文件。
/// 参数:`fd` 表示待写入文件的文件描述符;
/// `buf` 表示内存中缓冲区的起始地址;
/// `len` 表示内存中缓冲区的长度。
/// 返回值:返回成功写入的长度。
/// syscall ID:64
fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize;
/// 功能:退出应用程序并将返回值告知批处理系统。
/// 参数:`xstate` 表示应用程序的返回值。
/// 返回值:该系统调用不应该返回。
/// syscall ID:93
fn sys_exit(xstate: usize) -> !;
我们知道系统调用实际上是汇编指令级的二进制接口,因此这里给出的只是使用 Rust 语言描述的版本。在实际调用的时候,我们需要按照 RISC-V 调用
规范在合适的寄存器中放置系统调用的参数,然后执行 ``ecall`` 指令触发 Trap。在 Trap 回到 U 模式的应用程序代码之后,会从 ``ecall`` 的
下一条指令继续执行,同时我们能够按照调用规范在合适的寄存器中读取返回值。
在 RISC-V 调用规范中,和函数调用的情形类似,约定寄存器 ``a0~a6`` 保存系统调用的参数, ``a0~a1`` 保存系统调用的返回值。有些许不同的是
寄存器 ``a7`` 用来传递 syscall ID,这是因为所有的 syscall 都是通过 ``ecall`` 指令触发的,除了各输入参数之外我们还额外需要一个寄存器
来保存要请求哪个系统调用。由于这超出了 Rust 语言的表达能力,我们需要在代码中使用内嵌汇编来完成参数/返回值绑定和 ``ecall`` 指令的插入:
.. code-block:: rust
:linenos:
// user/src/syscall.rs
fn syscall(id: usize, args: [usize; 3]) -> isize {
let mut ret: isize;
unsafe {
llvm_asm!("ecall"
: "={x10}" (ret)
: "{x10}" (args[0]), "{x11}" (args[1]), "{x12}" (args[2]), "{x17}" (id)
: "memory"
: "volatile"
);
}
ret
}
第 3 行,我们将所有的系统调用都封装成 ``syscall`` 函数,可以看到它支持传入 syscall ID 和 3 个参数。
第 6 行开始,我们使用 Rust 提供的 ``llvm_asm!`` 宏在代码中内嵌汇编,在本行也给出了具体要插入的汇编指令,也就是 ``ecall``,但这并不是
全部,后面我们还需要进行一些相关设置。这个宏在 Rust 中还不稳定,因此我们需要在 ``lib.rs`` 开头加入 ``#![feature(llvm_asm)]`` 。
此外,编译器无法判定插入汇编代码这个行为的安全性,所以我们需要将其包裹在 unsafe 块中自己来对它负责。
Rust 中的 ``llvm_asm!`` 宏的完整格式如下:
.. code-block:: rust
llvm_asm!(assembly template
: output operands
: input operands
: clobbers
: options
);
下面逐行进行说明。
第 7 行指定输出操作数。这里由于我们的系统调用返回值只有一个 ``isize`` ,根据调用规范它会被保存在 ``a0`` 寄存器中。在双引号内,我们
可以对于使用的操作数进行限制,由于是输出部分,限制的开头必须是一个 ``=`` 。我们可以在限制内使用一对花括号再加上一个寄存器的名字告诉
编译器汇编的输出结果会保存在这个寄存器中。我们将声明出来用来保存系统调用返回值的变量 ``ret`` 包在一对普通括号里面放在操作数限制的
后面,这样可以把变量和寄存器建立联系。于是,在系统调用返回之后我们就能在变量 ``ret`` 中看到返回值了。注意,变量 ``ret`` 必须为可变
绑定,否则无法通过编译,这也说明在 unsafe 块内编译器还是会进行力所能及的安全检查。
第 8 行指定输入操作数。由于是输入部分,限制的开头不用加上 ``=`` 。同时在限制中设置使用寄存器 ``a0~a2`` 来保存系统调用的参数,以及
寄存器 ``a7`` 保存 syscall ID ,而它们分别 ``syscall`` 的参数变量 ``args`` 和 ``id`` 绑定。
第 9 行用于告知编译器插入的汇编代码会造成的一些影响以防止编译器在不知情的情况下误优化。常用的使用方法是告知编译器某个寄存器在执行嵌入
的汇编代码中的过程中会发生变化。我们这里则是告诉编译器在执行嵌入汇编代码中的时候会修改内存。这能给编译器提供更多信息。
第 10 行用于告知编译器将我们在程序中给出的嵌入汇编代码保持原样放到最终构建的可执行文件中。如果不这样做的话,编译器可能会把它和其他代码
一视同仁并放在一起进行一些我们期望之外的优化。为了保证语义的正确性,一些比较关键的汇编代码需要加上该选项。
第一章中的输出到屏幕的操作也同样是使用内联汇编调用 SEE 提供的 SBI 接口来实现的。有兴趣的读者可以回顾第一章的 ``console.rs`` 和
``sbi.rs`` 。
.. note::
**Rust 语法卡片:内联汇编**
我们这里使用的 ``llvm_asm!`` 宏是将 Rust 底层 IR LLVM 中提供的内联汇编包装成的,更多信息可以参考 `llvm_asm 文档 <https://doc.rust-lang.org/unstable-book/library-features/llvm-asm.html>`_ 。
在未来的 Rust 版本推荐使用功能更加强大且方便易用的 ``asm!`` 宏,但是目前还未稳定,可以查看 `inline-asm RFC <https://doc.rust-lang.org/beta/unstable-book/library-features/asm.html>`_ 了解最新进展。
于是 ``sys_write`` 和 ``sys_exit`` 只需将 ``syscall`` 进行包装:
.. code-block:: rust
:linenos:
// user/src/syscall.rs
const SYSCALL_WRITE: usize = 64;
const SYSCALL_EXIT: usize = 93;
pub fn sys_write(fd: usize, buffer: &[u8]) -> isize {
syscall(SYSCALL_WRITE, [fd, buffer.as_ptr() as usize, buffer.len()])
}
pub fn sys_exit(xstate: i32) -> isize {
syscall(SYSCALL_EXIT, [xstate as usize, 0, 0])
}
.. _term-fat-pointer:
注意 ``sys_write`` 使用一个 ``&[u8]`` 切片类型来描述缓冲区,这是一个 **胖指针** (Fat Pointer),里面既包含缓冲区的起始地址,还
包含缓冲区的长度。我们可以分别通过 ``as_ptr`` 和 ``len`` 方法取出它们并独立的作为实际的系统调用参数。
我们将上述两个系统调用在用户库 ``user_lib`` 中进一步封装,从而更加接近在 Linux 等平台的实际体验:
.. code-block:: rust
:linenos:
// user/src/lib.rs
use syscall::*;
pub fn write(fd: usize, buf: &[u8]) -> isize { sys_write(fd, buf) }
pub fn exit(exit_code: i32) -> isize { sys_exit(exit_code) }
我们把 ``console`` 子模块中 ``Stdout::write_str`` 改成基于 ``write`` 的实现,且传入的 ``fd`` 参数设置为 1,它代表标准输出,
也就是输出到屏幕。目前我们不需要考虑其他的 ``fd`` 选取情况。这样,应用程序的 ``println!`` 宏借助系统调用变得可用了。
参考下面的代码片段:
.. code-block:: rust
:linenos:
// user/src/console.rs
const STDOUT: usize = 1;
impl Write for Stdout {
fn write_str(&mut self, s: &str) -> fmt::Result {
write(STDOUT, s.as_bytes());
Ok(())
}
}
``exit`` 接口则在用户库中的 ``_start`` 内使用,当应用程序主逻辑 ``main`` 返回之后,使用它退出应用程序并将返回值告知
底层的批处理系统。
编译生成应用程序二进制码
-------------------------------
这里简要介绍一下应用程序的自动构建。只需要在 ``user`` 目录下 ``make build`` 即可:
1. 对于 ``src/bin`` 下的每个应用程序,在 ``target/riscv64gc-unknown-none-elf/release`` 目录下生成一个同名的 ELF 可执行文件;
2. 使用 objcopy 二进制工具将上一步中生成的 ELF 文件删除所有 ELF header 和符号得到 ``.bin`` 后缀的纯二进制镜像文件。它们将被链接
进内核并由内核在合适的时机加载到内存。
\ No newline at end of file
实现批处理操作系统
==============================
.. toctree::
:hidden:
:maxdepth: 5
本节导读
-------------------------------
目前本章设计的批处理操作系统--泥盆纪“邓式鱼”操作系统,还没有文件/文件系统的机制与设计实现,所以还缺少一种类似文件系统那样的松耦合灵活放置应用程序和加载执行应用程序的机制。这就需要设计一种简洁的程序放置和加载方式,能够在批处理操作系统与应用程序之间建立联系的纽带。这主要包括两个方面:
- 静态编码:通过一定的编程技巧,把应用程序代码和批处理操作系统代码“绑定”在一起。
- 动态加载:基于静态编码留下的“绑定”信息,操作系统可以找到应用程序文件二进制代码的起始地址和长度,并能加载到内存中运行。
这里与硬件相关且比较困难的地方是如何让在内核态的批处理操作系统启动应用程序,且能让应用程序在用户态正常执行。本节会讲大致过程,而具体细节将放到下一节具体讲解。
将应用程序链接到内核
--------------------------------------------
在本章中,我们把应用程序的二进制镜像文件作为内核的数据段链接到内核里面,因此内核需要知道内含的应用程序的数量和它们的位置,这样才能够在运行时
对它们进行管理并能够加载到物理内存。
在 ``os/src/main.rs`` 中能够找到这样一行:
.. code-block:: rust
global_asm!(include_str!("link_app.S"));
这里我们引入了一段汇编代码 ``link_app.S`` ,它一开始并不存在,而是在构建的时候自动生成的。当我们使用 ``make run`` 让系统成功运行起来
之后,我们可以先来看一看里面的内容:
.. code-block:: asm
:linenos:
# os/src/link_app.S
.align 4
.section .data
.global _num_app
_num_app:
.quad 3
.quad app_0_start
.quad app_1_start
.quad app_2_start
.quad app_2_end
.section .data
.global app_0_start
.global app_0_end
app_0_start:
.incbin "../user/target/riscv64gc-unknown-none-elf/release/00hello_world.bin"
app_0_end:
.section .data
.global app_1_start
.global app_1_end
app_1_start:
.incbin "../user/target/riscv64gc-unknown-none-elf/release/01store_fault.bin"
app_1_end:
.section .data
.global app_2_start
.global app_2_end
app_2_start:
.incbin "../user/target/riscv64gc-unknown-none-elf/release/02power.bin"
app_2_end:
可以看到第 13 行开始的三个数据段分别插入了三个应用程序的二进制镜像,并且各自有一对全局符号 ``app_*_start, app_*_end`` 指示它们的
开始和结束位置。而第 3 行开始的另一个数据段相当于一个 64 位整数数组。数组中的第一个元素表示应用程序的数量,后面则按照顺序放置每个应用
程序的起始地址,最后一个元素放置最后一个应用程序的结束位置。这样每个应用程序的位置都能从该数组中相邻两个元素中得知。这个数组所在的位置
同样也由全局符号 ``_num_app`` 所指示。
这个文件是在 ``cargo build`` 的时候,由脚本 ``os/build.rs`` 控制生成的。有兴趣的读者可以参考其代码。
能够找到并加载应用程序二进制码的应用管理器
-----------------------------------------------
能够找到并加载应用程序二进制码的应用管理器 ``AppManager`` 是“邓式鱼”操作系统的核心组件。我们在 ``os`` 的 ``batch`` 子模块中实现一个应用管理器,它的主要功能是:
- 保存应用数量和各自的位置信息,以及当前执行到第几个应用了。
- 根据应用程序位置信息,初始化好应用所需内存空间,并加载应用执行。
应用管理器 ``AppManager`` 结构体定义
如下:
.. code-block:: rust
struct AppManager {
inner: RefCell<AppManagerInner>,
}
struct AppManagerInner {
num_app: usize,
current_app: usize,
app_start: [usize; MAX_APP_NUM + 1],
}
unsafe impl Sync for AppManager {}
这里我们可以看出,上面提到的应用管理器需要保存和维护的信息都在 ``AppManagerInner`` 里面,而结构体 ``AppManager`` 里面只是保存了
一个指向 ``AppManagerInner`` 的 ``RefCell`` 智能指针。这样设计的原因在于:我们希望将 ``AppManager`` 实例化为一个全局变量使得
任何函数都可以直接访问,但是里面的 ``current_app`` 字段表示当前执行到了第几个应用,它会在系统运行期间发生变化。因此在声明全局变量
的时候一种自然的方法是利用 ``static mut``。但是在 Rust 中,任何对于 ``static mut`` 变量的访问都是 unsafe 的,而我们要尽可能
减少 unsafe 的使用来更多的让编译器负责安全性检查。
此外,为了让 ``AppManager`` 能被直接全局实例化,我们需要将其标记为 ``Sync`` 。
.. note::
**为什么对于 static mut 的访问是 unsafe 的**
**为什么要将 AppManager 标记为 Sync**
可以参考附录A:Rust 快速入门的并发章节。
.. _term-interior-mutability:
于是,我们利用 ``RefCell`` 来提供 **内部可变性** (Interior Mutability),
所谓的内部可变性就是指在我们只能拿到 ``AppManager`` 的不可变借用,意味着同样也只能
拿到 ``AppManagerInner`` 的不可变借用的情况下依然可以修改 ``AppManagerInner`` 里面的字段。
使用 ``RefCell::borrow/RefCell::borrow_mut`` 分别可以拿到 ``RefCell`` 里面内容的不可变借用/可变借用,
``RefCell`` 内部会运行时维护当前已有的借用状态并进行借用检查。于是 ``RefCell::borrow_mut`` 就是我们实现内部可变性的关键。
我们这样初始化 ``AppManager`` 的全局实例:
.. code-block:: rust
lazy_static! {
static ref APP_MANAGER: AppManager = AppManager {
inner: RefCell::new({
extern "C" { fn _num_app(); }
let num_app_ptr = _num_app as usize as *const usize;
let num_app = unsafe { num_app_ptr.read_volatile() };
let mut app_start: [usize; MAX_APP_NUM + 1] = [0; MAX_APP_NUM + 1];
let app_start_raw: &[usize] = unsafe {
core::slice::from_raw_parts(num_app_ptr.add(1), num_app + 1)
};
app_start[..=num_app].copy_from_slice(app_start_raw);
AppManagerInner {
num_app,
current_app: 0,
app_start,
}
}),
};
}
这里我们使用了外部库 ``lazy_static`` 提供的 ``lazy_static!`` 宏。要引入这个外部库,我们需要加入依赖:
.. code-block:: toml
# os/Cargo.toml
[dependencies]
lazy_static = { version = "1.4.0", features = ["spin_no_std"] }
``lazy_static!`` 宏提供了全局变量的运行时初始化功能。一般情况下,全局变量必须在编译期设置一个初始值,但是有些全局变量依赖于运行期间
才能得到的数据作为初始值。这导致这些全局变量需要在运行时发生变化,也即重新设置初始值之后才能使用。如果我们手动实现的话有诸多不便之处,
比如又需要把这种全局变量声明为 ``static mut`` 并衍生出很多 unsafe 。这种情况下我们可以使用 ``lazy_static!`` 宏来帮助我们解决
这个问题。这里我们借助 ``lazy_static!`` 声明了一个名为 ``APP_MANAGER`` 的 ``AppManager`` 全局实例,且只有在它第一次被使用到
的时候才会实际进行初始化工作。
初始化的逻辑很简单,就是找到 ``link_app.S`` 中提供的符号 ``_num_app`` ,并从这里开始解析出应用数量以及各个应用的开头地址。注意其中
对于切片类型的使用能够很大程度上简化编程。
因此,借助 Rust 核心库提供的 ``RefCell`` 和外部库 ``lazy_static!``,我们就能在避免 ``static mut`` 声明的情况下以更加 Rust 的
方式使用全局变量。
``AppManagerInner`` 的方法中, ``print_app_info/get_current_app/move_to_next_app`` 都相当简单直接,需要说明的是 ``load_app``:
.. code-block:: rust
:linenos:
unsafe fn load_app(&self, app_id: usize) {
if app_id >= self.num_app {
panic!("All applications completed!");
}
println!("[kernel] Loading app_{}", app_id);
// clear icache
llvm_asm!("fence.i" :::: "volatile");
// clear app area
(APP_BASE_ADDRESS..APP_BASE_ADDRESS + APP_SIZE_LIMIT).for_each(|addr| {
(addr as *mut u8).write_volatile(0);
});
let app_src = core::slice::from_raw_parts(
self.app_start[app_id] as *const u8,
self.app_start[app_id + 1] - self.app_start[app_id]
);
let app_dst = core::slice::from_raw_parts_mut(
APP_BASE_ADDRESS as *mut u8,
app_src.len()
);
app_dst.copy_from_slice(app_src);
}
这个方法负责将参数 ``app_id`` 对应的应用程序的二进制镜像加载到物理内存以 ``0x80040000`` 开头的位置,这个位置是批处理操作系统和应用程序
之间约定的常数,回忆上一小节中,我们也调整应用程序的内存布局以同一个地址开头。第 8 行开始,我们首先将一块内存清空,然后找到待加载应用
二进制镜像的位置,并将它复制到正确的位置。它本质上是数据从一块内存复制到另一块内存,从批处理操作系统的角度来看是将它数据段的一部分复制到了它
程序之外未知的地方。
.. _term-dcache:
.. _term-icache:
注意第 7 行我们插入了一条奇怪的汇编指令 ``fence.i`` ,它是用来清理 i-cache 的。我们知道缓存是存储层级结构中提高访存速度的很重要一环。
而 CPU 对物理内存所做的缓存又分成 **数据缓存** (d-cache) 和 **指令缓存** (i-cache) 两部分,分别在 CPU 访存和取指的时候使用。在取指
的时候,对于一个指令地址, CPU 会先去 i-cache 里面看一下它是否在某个已缓存的缓存行内,如果在的话它就会直接从高速缓存中拿到指令而不是通过
总线和内存通信。通常情况下, CPU 会认为程序的代码段不会发生变化,因此 i-cache 是一种只读缓存。但在这里,我们会修改会被 CPU 取指的内存
区域,这会使得 i-cache 中含有与内存中不一致的内容。因此我们这里必须使用 ``fence.i`` 指令手动清空 i-cache ,让里面所有的内容全部失效,
才能够保证正确性。
.. warning::
**模拟器与真机的不同之处**
至少在 Qemu 模拟器的默认配置下,各类缓存如 i-cache/d-cache/TLB 都处于机制不完全甚至完全不存在的状态。目前在 Qemu 平台上,即使我们
不加上刷新 i-cache 的指令,大概率也是能够正常运行的。但在 K210 真机上就会看到错误。
``batch`` 子模块对外暴露出如下接口:
- ``init`` :调用 ``print_app_info`` 的时候第一次用到了全局变量 ``APP_MANAGER`` ,它也是在这个时候完成初始化;
- ``run_next_app`` :批处理操作系统的核心操作,即加载并运行下一个应用程序。当批处理操作系统完成初始化或者一个应用程序运行结束或出错之后会调用
该函数。我们下节再介绍其具体实现。
\ No newline at end of file
此差异已折叠。
.. _link-chapter2:
第二章:批处理系统
==============================================
.. toctree::
:maxdepth: 4
0intro
1rv-privilege
2application
3batch-system
4trap-handling
引言
========================================
本章导读
--------------------------
..
chyyuu:有一个ascii图,画出我们做的OS。
上一章,我们实现了一个简单的批处理系统。首先,它能够自动按照顺序加载并运行序列中的每一个应用,当一个应用运行结束之后无需操作员的手动替换;
另一方面,在硬件提供的特权级机制的帮助下,运行在更高特权级的它不会受到有意或者无意出错的应用的影响,可以全方位监控应用的执行,一旦应用越过了
硬件所设置的界限,就会触发 Trap 并进入到批处理系统中进行处理。无论原因是应用出错或是应用声明自己执行完毕,批处理系统都只需要加载序列中
的下一个应用并进入执行。可以看到批处理系统的特性是:在内存中同一时间最多只需驻留一个应用。这是因为只有当一个应用出错或退出之后,批处理系统才会去将另一个应用加载到
相同的一块内存区域。
而计算机在发展,内存容量在逐渐增大,CPU的速度也在增加,IO方面的进展不大。这就使得以往内存只能放下一个程序的情况得到很大改善,CPU的空闲程度加大。于是科学家就开始考虑在内存中尽量同时驻留多个应用,这样CPU的利用率就会提高。但只有一个程序执行完毕后,才能执行另外一个程序。这种方式称之为支持**多道程序**的操作系统。
当CPU进一步发展后,它与IO的速度差距也进一步拉大。这时计算机科学家发现,如果一个应用由于IO操作让CPU空闲下来,其他需要CPU资源进行计算的应用还是没法使用空闲的CPU资源。于是就想到,让应用在执行IO操作时,可以主动释放CPU,让其他应用继续执行。这就是支持协作式**多道程序**的操作系统。
但计算机科学家很快发现,编写应用程序的科学家(或程序员)来自不同的领域,他们不一定有友好互助的意识,也不了解其他程序的情况,很难有站在提高整个系统利用率上的大局观。这导致其在编写程序时,很难主要到要在合适位置执行放弃CPU的操作,让其他程序执行。在他们的脑海里,整个计算机就应该是为他们自己的应用准备的,不用考虑其他程序的运行。所以,站在系统的层面,还是需要有一种办法能强制打断应用程序的执行,来提高整个系统的效率,让在这个系统中执行的程序占用计算机资源的情况相对公平一些。于是就想到了,通过时钟中断来强制打断一个程序的执行,且可以通过一个程序占用CPU的执行时间来评估程序对资源
的消耗。通过平衡各个程序的执行时间,达到一定程度的系统公平。这就是支持抢占式**分时多任务**的操作系统。
分时多任务系统
本章所介绍的多道程序和分时多任务系统则是在内存中同一时间可以驻留多个应用。所有的应用都是在系统启动的时候分别加载到
内存的不同区域中。由于目前我们只有一个 CPU,则同一时间最多只有一个应用在执行,剩下的应用则处于就绪状态,需要内核将 CPU 分配给它们才能
开始执行。
本章主要是设计和实现建立支持**多道程序**的二叠纪“锯齿螈”操作系统,从而对可支持运行一批应用程序的执行环境有一个全面和深入的理解。
本章我们的目标让泥盆纪“邓式鱼”操作系统能够感知多个应用程序的存在,并一个接一个地运行这些应用程序,当一个应用程序执行完毕后,会启动下一个应用程序,
直到所有的应用程序都执行完毕。
因此,我们能够看到多个应用在一个 CPU 上交替执行的现象。
.. note::
读者也许会有疑问:由于只有一个 CPU,即使这样做,同一时间最多还是只能运行一个应用,还浪费了更多的内存来把所有
的应用都加载进来。那么这样做有什么意义呢?
读者可以带着这个问题继续看下去。后面我们会介绍这样做到底能够解决什么问题。
实践体验
-------------------------------------
.. _term-multiprogramming:
.. _term-time-sharing-multitasking:
**多道程序** (Multiprogramming) 和 **分时多任务系统** (Time-Sharing Multitasking) 对于应用的要求是不同的,因此我们分别为它们
编写了不同的应用,代码也被放在两个不同的分支上。对于它们更加深入的讲解请参考本章正文,我们在引言中仅给出运行代码的方法。
获取多道程序的代码:
.. code-block:: console
$ git clone https://github.com/rcore-os/rCore-Tutorial-v3.git
$ cd rCore-Tutorial-v3
$ git checkout ch3-coop
获取分时多任务系统的代码:
.. code-block:: console
$ git clone https://github.com/rcore-os/rCore-Tutorial-v3.git
$ cd rCore-Tutorial-v3
$ git checkout ch3
在 qemu 模拟器上运行本章代码:
.. code-block:: console
$ cd os
$ make run
将 Maix 系列开发版连接到 PC,并在上面运行本章代码:
.. code-block:: console
$ cd os
$ make run BOARD=k210
多道程序的应用分别会输出一个不同的字母矩阵。当他们交替执行的时候,以 k210 平台为例,我们将看到字母行的交错输出:
.. code-block::
[rustsbi] Version 0.1.0
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Platform: K210
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: 0x222
[rustsbi] medeleg: 0x1ab
[rustsbi] Kernel entry: 0x80020000
[kernel] Hello, world!
AAAAAAAAAA [1/5]
BBBBBBBBBB [1/2]
CCCCCCCCCC [1/3]
AAAAAAAAAA [2/5]
BBBBBBBBBB [2/2]
CCCCCCCCCC [2/3]
AAAAAAAAAA [3/5]
Test write_b OK!
[kernel] Application exited with code 0
CCCCCCCCCC [3/3]
AAAAAAAAAA [4/5]
Test write_c OK!
[kernel] Application exited with code 0
AAAAAAAAAA [5/5]
Test write_a OK!
[kernel] Application exited with code 0
[kernel] Panicked at src/task/mod.rs:97 All applications completed!
[rustsbi] reset triggered! todo: shutdown all harts on k210; program halt
分时多任务系统应用分为两种。编号为 00/01/02 的应用分别会计算质数 3/5/7 的幂次对一个大质数取模的余数,并会将结果阶段性输出。编号为 03 的
应用则会等待三秒钟之后再退出。以 k210 平台为例,我们将会看到 00/01/02 三个应用分段完成它们的计算任务,而应用 03 由于等待时间过长总是
最后一个结束执行。
.. code-block::
[rustsbi] Version 0.1.0
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Platform: K210
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: 0x222
[rustsbi] medeleg: 0x1ab
[rustsbi] Kernel entry: 0x80020000
[kernel] Hello, world!
power_3 [10000/200000]
power_3 [20000/200000]
power_3 [30000/200000power_5 [10000/140000]
power_5 [20000/140000]
power_5 [30000/140000power_7 [10000/160000]
power_7 [20000/160000]
power_7 [30000/160000]
]
power_3 [40000/200000]
power_3 [50000/200000]
power_3 [60000/200000]
power_5 [40000/140000]
power_5 [50000/140000]
power_5 [60000/140000power_7 [40000/160000]
power_7 [50000/160000]
power_7 [60000/160000]
]
power_3 [70000/200000]
power_3 [80000/200000]
power_3 [90000/200000]
power_5 [70000/140000]
power_5 [80000/140000]
power_5 [90000/140000power_7 [70000/160000]
power_7 [80000/160000]
power_7 [90000/160000]
]
power_3 [100000/200000]
power_3 [110000/200000]
power_3 [120000/]
power_5 [100000/140000]
power_5 [110000/140000]
power_5 [120000/power_7 [100000/160000]
power_7 [110000/160000]
power_7 [120000/160000200000]
power_3 [130000/200000]
power_3 [140000/200000]
power_3 [150000140000]
power_5 [130000/140000]
power_5 [140000/140000]
5^140000 = 386471875]
power_7 [130000/160000]
power_7 [140000/160000]
power_7 [150000/160000/200000]
power_3 [160000/200000]
power_3 [170000/200000]
power_3 [
Test power_5 OK!
[kernel] Application exited with code 0
]
power_7 [160000/160000]
7180000/200000]
power_3 [190000/200000]
power_3 [200000/200000]
3^200000 = 871008973^160000 = 667897727
Test power_7 OK!
[kernel] Application exited with code 0
Test power_3 OK!
[kernel] Application exited with code 0
Test sleep OK!
[kernel] Application exited with code 0
[kernel] Panicked at src/task/mod.rs:97 All applications completed!
[rustsbi] reset triggered! todo: shutdown all harts on k210; program halt
输出结果看上去有一些混乱,原因是用户程序的每个 ``println!`` 往往会被拆分成多个 ``sys_write`` 系统调用提交给内核。有兴趣的同学可以参考
``println!`` 宏的实现。
另外需要说明的是一点是:与上一章不同,应用的编号不再决定其被加载运行的先后顺序,而仅仅能够改变应用被加载到内存中的位置。
多任务加载器
=====================================
在本章的引言中我们提到每个应用都需要按照它的编号被分别加载到内存中不同的位置。本节我们就来介绍它是如何实现的。
与第二章相同,所有应用的 ELF 都经过 strip 丢掉所有 ELF header 和符号变为二进制镜像文件,随后以同样的格式通过
``link_user.S`` 在编译的时候直接链接到内核的数据段中。不同的是,我们对相关模块进行了调整:在第二章中
应用的加载和进度控制都交给 ``batch`` 子模块,而在第三章中我们将应用的加载这部分功能分离出来在 ``loader``
子模块中实现,应用的执行和切换则交给 ``task`` 子模块。
应用的加载方式也和上一章不同。上一章的时候所有应用都被加载到一个固定的物理地址,也是因为这个原因,内存中同时
最多只能驻留一个应用,当它运行完毕或者出错退出的时候由 ``batch`` 子模块加载一个新的应用来替换掉它。本章中,
所有的应用在内核初始化的时候就一并被加载到内存中。为了避免覆盖,它们自然需要被加载到不同的物理地址。这是通过
调用 ``loader`` 子模块的 ``load_apps`` 函数实现的:
.. code-block:: rust
:linenos:
// os/src/loader.rs
pub fn load_apps() {
extern "C" { fn _num_app(); }
let num_app_ptr = _num_app as usize as *const usize;
let num_app = get_num_app();
let app_start = unsafe {
core::slice::from_raw_parts(num_app_ptr.add(1), num_app + 1)
};
// clear i-cache first
unsafe { llvm_asm!("fence.i" :::: "volatile"); }
// load apps
for i in 0..num_app {
let base_i = get_base_i(i);
// clear region
(base_i..base_i + APP_SIZE_LIMIT).for_each(|addr| unsafe {
(addr as *mut u8).write_volatile(0)
});
// load app from data section to memory
let src = unsafe {
core::slice::from_raw_parts(
app_start[i] as *const u8,
app_start[i + 1] - app_start[i]
)
};
let dst = unsafe {
core::slice::from_raw_parts_mut(base_i as *mut u8, src.len())
};
dst.copy_from_slice(src);
}
}
可以看出,第 :math:`i` 个应用被加载到以物理地址 ``base_i`` 开头的一段物理内存上,而 ``base_i`` 的
计算方式如下:
.. code-block:: rust
:linenos:
// os/src/loader.rs
fn get_base_i(app_id: usize) -> usize {
APP_BASE_ADDRESS + app_id * APP_SIZE_LIMIT
}
我们可以在 ``config`` 子模块中找到这两个常数。从这一章开始, ``config`` 子模块用来存放内核中所有的常数。看到
``APP_BASE_ADDRESS`` 被设置为 ``0x80100000`` ,而 ``APP_SIZE_LIMIT`` 和上一章一样被设置为
``0x20000`` ,也就是每个应用二进制镜像的大小限制。因此,应用的内存布局就很明朗了——就是从
``APP_BASE_ADDRESS`` 开始依次为每个应用预留一段空间。
注意,我们需要调整每个应用被构建时候使用的链接脚本 ``linker.ld`` 中的起始地址 ``BASE_ADDRESS`` 为它实际
会被内核加载并运行的地址。也就是要做到:应用知道自己会被加载到某个地址运行,而内核也确实能做到将它加载到那个
地址。这算是应用和内核在某种意义上达成的一种协议。之所以要有这么苛刻的条件,是因为应用和内核的能力都很弱,泛用性很低。
事实上,目前我们的应用是绝对位置而并不是位置无关的,内核也没有提供相应的重定位机制。
.. note::
可以在 `这里 <https://nju-projectn.github.io/ics-pa-gitbook/ics2020/4.2.html>`_ 找到更多有关
位置无关和重定位的说明。
由于每个应用被加载到的位置都不同,也就导致它们 ``linker.ld`` 中的 ``BASE_ADDRESS`` 都是不同的。实际上,
我们写了一个脚本 ``build.py`` 而不是直接 ``cargo build`` 构建应用:
.. code-block:: python
:linenos:
# user/build.py
import os
base_address = 0x80100000
step = 0x20000
linker = 'src/linker.ld'
app_id = 0
apps = os.listdir('src/bin')
apps.sort()
for app in apps:
app = app[:app.find('.')]
lines = []
lines_before = []
with open(linker, 'r') as f:
for line in f.readlines():
lines_before.append(line)
line = line.replace(hex(base_address), hex(base_address+step*app_id))
lines.append(line)
with open(linker, 'w+') as f:
f.writelines(lines)
os.system('cargo build --bin %s --release' % app)
print('[build.py] application %s start with address %s' %(app, hex(base_address+step*app_id)))
with open(linker, 'w+') as f:
f.writelines(lines_before)
app_id = app_id + 1
它的思路很简单,在遍历 ``app`` 的大循环里面只做了这样几件事情:
- 第 16~22 行,找到 ``src/linker.ld`` 中的 ``BASE_ADDRESS = 0x80100000;`` 这一行,并将后面的地址
替换为和当前应用对应的一个地址;
- 第 23 行,使用 ``cargo build`` 构建当前的应用,注意我们可以使用 ``--bin`` 参数来只构建某一个应用;
- 第 25~26 行,将 ``src/linker.ld`` 还原。
这样,我们就说明了多个应用是如何被构建和加载的。
任务切换
================================
本节我们来介绍本章的核心机制——任务切换。在上一章批处理系统中,一个应用会独占 CPU 直到它出错或主动退出。而本章中,一个应用在运行途中便会
主动/被动交出 CPU 的使用权,此时它只能暂停执行,等到内核重新给它分配 CPU 资源之后才能恢复并继续执行。
我们又看到了熟悉的“暂停-继续”组合。一旦一条执行流需要支持“暂停-继续”,就需要提供一种执行流切换的机制,而且需要保证执行流被切换出去之前和
切换回来之后,它的状态,也就是在执行过程中同步变化的资源(如寄存器、栈等)需要保持不变,或者变化在它的预期之内。而不是所有的资源都需要被
保存,事实上只有那些对于执行流接下来的进行仍然有用,且在它被切换出去的时候有被覆盖的风险的那些资源才有被保存的价值。这些资源被称为上下文。
在执行流切换过程中,我们需要结合硬件机制和软件实现来保存和恢复上下文。一次执行流的切换涉及到被换出和即将被换入的两条执行流,通常它们都需要
共同遵循某些约定来合作完成这一过程。在前两章,我们已经看到了两种上下文保存/恢复的实例。让我们再来回顾一下它们:
- 第一章《RV64 裸机应用》中,我们介绍了 :ref:`函数调用与栈 <function-call-and-stack>` 。当时提到过,为了支持嵌套函数调用,不仅需要
硬件平台提供特殊的跳转指令,还需要保存和恢复 **函数调用上下文** 。注意在 *我们* 的定义中,函数调用包含在普通控制流(与异常控制流相对)之内
,且始终用一个固定的栈来保存执行的历史记录,因此函数调用并不涉及执行流的切换。但是我们依然可以将其看成调用者和被调用者两个过程的“切换”,
二者的协作体现在它们都遵循调用规范,分别保存一部分通用寄存器,这样的好处是编译器能够有足够的信息来尽可能减少需要保存的寄存器的数目。
虽然当时用了很大的篇幅来说明,但其实整个过程都是编译器负责完成的,我们只需设置好栈就行了。
- 第二章《批处理系统》中第一次涉及到了某种异常控制流,即两条执行流的切换。当时,为了让内核能够 *完全掌控* 应用的执行,我们必须利用硬件
提供的特权级机制,让应用和内核运行在不同的特权级。应用运行在 U 特权级,它所被允许的操作进一步受限,处处被内核监督管理;而内核运行在 S
特权级,有能力处理应用执行过程中提出的请求或遇到的状况。
二者打交道的核心在于硬件提供的 Trap 机制,也就是在 U 特权级运行的应用执行流和在 S 特权级运行的 Trap 执行流之间的切换。
Trap 执行流是在 Trap 触发的一瞬间生成的,它和原应用执行流有着很密切的联系,因为它唯一的目标就是处理 Trap 并恢复到原应用执行流。而且,
由于 Trap 机制对于应用来说几乎是透明的,所以基本上都是 Trap 执行流在“负重前行”。Trap 执行流需要把 **Trap 上下文** 保存在自己的
内核栈上,里面包含几乎所有的通用寄存器,因为在 Trap 处理过程中它们都可能被用到。如果有需要的话,可以回看
:ref:`Trap 上下文保存与恢复 <trap-context-save-restore>` 小节。
本节的任务切换是第二章的 Trap 之后的另一种异常控制流,都描述两条执行流之间的切换,如果将它和 Trap 进行比较:
- 与 Trap 不同,它不涉及特权级切换;
- 与 Trap 不同,它的一部分是由编译器帮忙完成的;
- 与 Trap 相同,它对应用是透明的。
事实上,它是来自两个不同应用的 Trap 执行流之间的切换。当一个应用 Trap 到 S 进行处理的时候,其 Trap 执行流可以调用一个特殊的
``__switch`` 函数。这个函数表面上就是一个普通的函数调用:在 ``__switch`` 返回之后,将继续从调用该函数的位置继续向下执行。
但是其间却隐藏着复杂的执行流切换过程。具体来说,调用 ``__switch`` 之后直到它返回前的这段时间,原 Trap 执行流会先被暂停并被
切换出去, CPU 转而运行另一个应用的 Trap 执行流。之后在时机合适的时候,原 Trap 执行流才会从某一条 Trap 执行流(很有可能不是
它之前切换到的那一条)切换回来继续执行并最终返回。不过,从实现的角度讲, ``__switch`` 和一个普通的函数之间的差别仅仅是它会换栈。
.. image:: task_context.png
.. _term-task-context:
当 Trap 执行流准备调用 ``__switch`` 函数并进入暂停状态的时候,让我们考察一下它内核栈上的情况。如上图所示,在准备调用
``__switch`` 函数之前,内核栈上从栈底到栈顶分别是保存了应用执行状态的 Trap 上下文以及内核在对 Trap 处理的过程中留下的
调用栈信息。由于之后还要恢复回来执行,我们必须保存 CPU 当前的某些寄存器,我们称它们为 **任务上下文** (Task Context)。
我们会在稍后介绍里面需要包含哪些寄存器。至于保存的位置,我们将任务上下文直接压入内核栈的栈顶,从这一点上来说它和函数调用一样。
这样需要保存的信息就已经确实的保存在内核栈上了,而恢复的时候我们要从任务上下文的位置——也就是这一时刻内核栈栈顶的位置找到
被保存的寄存器快照进行恢复,这个位置也需要被保存下来。对于每一条被暂停的 Trap 执行流,我们都用一个名为 ``task_cx_ptr``
的变量来保存它栈顶的任务上下文的地址。利用 C 语言的引用来描述的话就是:
.. code-block:: C
TaskContext *task_cx_ptr = &task_cx;
由于我们要用 ``task_cx_ptr`` 这个变量来进行保存,自然也要对它进行修改。于是我们还需要指向它的指针 ``task_cx_ptr2`` :
.. code-block:: C
TaskContext **task_cx_ptr2 = &task_cx_ptr;
接下来我们同样从栈上内容的角度来看 ``__switch`` 的整体流程:
.. image:: switch-1.png
.. 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`` 才能做到一个函数跨两条执行流执行。
- 阶段 [4]:CPU 从 B 的内核栈栈顶取出任务上下文并恢复寄存器状态,在这之后还要进行退栈操作。
- 阶段 [5]:对于 B 而言, ``__switch`` 函数返回,可以从调用 ``__switch`` 的位置继续向下执行。
从结果来看,我们看到 A 和 B 的状态发生了互换, A 在保存任务上下文之后进入暂停状态,而 B 则恢复过来在 CPU 上执行。
下面我们给出 ``__switch`` 的实现:
.. code-block:: riscv
:linenos:
# os/src/task/switch.S
.altmacro
.macro SAVE_SN n
sd s\n, (\n+1)*8(sp)
.endm
.macro LOAD_SN n
ld s\n, (\n+1)*8(sp)
.endm
.section .text
.globl __switch
__switch:
# __switch(
# current_task_cx_ptr2: &*const TaskContext,
# next_task_cx_ptr2: &*const TaskContext
# )
# push TaskContext to current sp and save its address to where a0 points to
addi sp, sp, -13*8
sd sp, 0(a0)
# fill TaskContext with ra & s0-s11
sd ra, 0(sp)
.set n, 0
.rept 12
SAVE_SN %n
.set n, n + 1
.endr
# ready for loading TaskContext a1 points to
ld sp, 0(a1)
# load registers in the TaskContext
ld ra, 0(sp)
.set n, 0
.rept 12
LOAD_SN %n
.set n, n + 1
.endr
# pop TaskContext
addi sp, sp, 13*8
ret
我们手写汇编代码来实现 ``__switch`` 。可以看到它的函数原型中的两个参数分别是当前 Trap 执行流和即将被切换到的 Trap 执行流的
``task_cx_ptr2`` ,从 :ref:`RISC-V 调用规范 <term-calling-convention>` 可以知道它们分别通过寄存器 ``a0/a1`` 传入。
阶段 [2] 体现在第 18~26 行。第 18 行在 A 的内核栈上预留任务上下文的空间,然后将当前的栈顶位置保存下来。接下来就是逐个对寄存器
进行保存,从中我们也能够看出 ``TaskContext`` 里面究竟包含哪些寄存器:
.. code-block:: rust
:linenos:
// os/src/task/context.rs
#[repr(C)]
pub struct TaskContext {
ra: usize,
s: [usize; 12],
}
这里面只保存了 ``ra`` 和被调用者保存的 ``s0~s11`` 。``ra`` 的保存很重要,它记录了 ``__switch`` 返回之后应该到哪里继续执行,
从而在切换回来并 ``ret`` 之后能到正确的位置。而保存调用者保存的寄存器是因为,调用者保存的寄存器可以由编译器帮我们自动保存。我们会将
这段汇编代码中的全局符号 ``__switch`` 解释为一个 Rust 函数:
.. code-block:: rust
:linenos:
// os/src/task/switch.rs
global_asm!(include_str!("switch.S"));
extern "C" {
pub fn __switch(
current_task_cx_ptr2: *const usize,
next_task_cx_ptr2: *const usize
);
}
我们会调用该函数来完成切换功能而不是直接跳转到符号 ``__switch`` 的地址。因此在调用前后 Rust 编译器会自动帮助我们插入保存/恢复
调用者保存寄存器的汇编代码。
仔细观察的话可以发现 ``TaskContext`` 很像一个普通函数栈帧中的内容。正如之前所说, ``__switch`` 的实现除了换栈之外几乎就是一个
普通函数,也能在这里得到体现。尽管如此,二者的内涵却有着很大的不同。
剩下的汇编代码就比较简单了。读者可以自行对照注释看看图示中的后面几个阶段各是如何实现的。另外,后面会出现传给 ``__switch`` 的两个参数
相同,也就是某个 Trap 执行流自己切换到自己的情形,请读者对照图示思考目前的实现能否对它进行正确处理。
\ No newline at end of file
.. _link-chapter3:
第三章:多道程序与分时多任务
==============================================
.. toctree::
:maxdepth: 4
0intro
1multi-loader
2task-switching
3multiprogramming
4time-sharing-system
此差异已折叠。
此差异已折叠。
第四章:地址空间
==============================================
.. toctree::
:maxdepth: 4
0intro
1address-space
2rust-dynamic-allocation
3sv39-implementation-1
4sv39-implementation-2
5kernel-app-spaces
6multitasking-based-on-as
第五章:进程及重要系统调用(施工)
==============================================
.. toctree::
:hidden:
:maxdepth: 4
MULTICS操作系统是侏罗纪的“霸王龙”操作系统。
UNIX操作系统是小巧聪明的“伤齿龙”操作系统。
\ No newline at end of file
第六章:文件描述符与进程间通信
==============================================
.. toctree::
:hidden:
:maxdepth: 4
有团队协作能力的“迅猛龙”操作系统。
\ No newline at end of file
第七章:数据持久化存储
==============================================
.. toctree::
:hidden:
:maxdepth: 4
最晚灭绝的“霸王龙”操作系统
\ No newline at end of file
第八章:阻塞(暂定)
==============================================
.. toctree::
:hidden:
:maxdepth: 4
\ No newline at end of file
此差异已折叠。
此差异已折叠。
修改和构建本项目
====================================
.. toctree::
:hidden:
:maxdepth: 4
1. 参考 `这里 <https://www.sphinx-doc.org/en/master/usage/installation.html>`_ 安装 Sphinx。
2. ``pip install sphinx_rtd_theme`` 安装 Read The Docs 主题。
3. ``pip install jieba`` 安装中文分词。
4. ``pip install sphinx-comments`` 安装 Sphinx 讨论区插件。
5. :doc:`/rest-example` 是 ReST 的一些基本语法,也可以参考已完成的文档。
6. 修改之后,在项目根目录下 ``make clean && make html`` 即可在 ``build/html/index.html`` 查看本地构建的主页。请注意在修改
章节目录结构之后需要 ``make clean`` 一下,不然可能无法正常更新。
7. 确认修改无误之后,在项目根目录下 ``make deploy`` 然后即可 ``git add -A && git commit -m && git push`` 上传到远程仓库。
如果出现冲突的话,请删除掉 ``docs`` 目录再进行 merge。
此差异已折叠。
此差异已折叠。
.fa:before{-webkit-font-smoothing:antialiased}.clearfix{*zoom:1}.clearfix:after,.clearfix:before{display:table;content:""}.clearfix:after{clear:both}@font-face{font-family:FontAwesome;font-style:normal;font-weight:400;src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713?#iefix) format("embedded-opentype"),url(fonts/fontawesome-webfont.woff2?af7ae505a9eed503f8b8e6982036873e) format("woff2"),url(fonts/fontawesome-webfont.woff?fee66e712a8a08eef5805a46892932ad) format("woff"),url(fonts/fontawesome-webfont.ttf?b06871f281fee6b241d60582ae9369b9) format("truetype"),url(fonts/fontawesome-webfont.svg?912ec66d7572ff821749319396470bde#FontAwesome) format("svg")}.fa:before{font-family:FontAwesome;font-style:normal;font-weight:400;line-height:1}.fa:before,a .fa{text-decoration:inherit}.fa:before,a .fa,li .fa{display:inline-block}li .fa-large:before{width:1.875em}ul.fas{list-style-type:none;margin-left:2em;text-indent:-.8em}ul.fas li .fa{width:.8em}ul.fas li .fa-large:before{vertical-align:baseline}.fa-book:before,.icon-book:before{content:"\f02d"}.fa-caret-down:before,.icon-caret-down:before{content:"\f0d7"}.fa-caret-up:before,.icon-caret-up:before{content:"\f0d8"}.fa-caret-left:before,.icon-caret-left:before{content:"\f0d9"}.fa-caret-right:before,.icon-caret-right:before{content:"\f0da"}.rst-versions{position:fixed;bottom:0;left:0;width:300px;color:#fcfcfc;background:#1f1d1d;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;z-index:400}.rst-versions a{color:#2980b9;text-decoration:none}.rst-versions .rst-badge-small{display:none}.rst-versions .rst-current-version{padding:12px;background-color:#272525;display:block;text-align:right;font-size:90%;cursor:pointer;color:#27ae60}.rst-versions .rst-current-version:after{clear:both;content:"";display:block}.rst-versions .rst-current-version .fa{color:#fcfcfc}.rst-versions .rst-current-version .fa-book,.rst-versions .rst-current-version .icon-book{float:left}.rst-versions .rst-current-version.rst-out-of-date{background-color:#e74c3c;color:#fff}.rst-versions .rst-current-version.rst-active-old-version{background-color:#f1c40f;color:#000}.rst-versions.shift-up{height:auto;max-height:100%;overflow-y:scroll}.rst-versions.shift-up .rst-other-versions{display:block}.rst-versions .rst-other-versions{font-size:90%;padding:12px;color:grey;display:none}.rst-versions .rst-other-versions hr{display:block;height:1px;border:0;margin:20px 0;padding:0;border-top:1px solid #413d3d}.rst-versions .rst-other-versions dd{display:inline-block;margin:0}.rst-versions .rst-other-versions dd a{display:inline-block;padding:6px;color:#fcfcfc}.rst-versions.rst-badge{width:auto;bottom:20px;right:20px;left:auto;border:none;max-width:300px;max-height:90%}.rst-versions.rst-badge .fa-book,.rst-versions.rst-badge .icon-book{float:none;line-height:30px}.rst-versions.rst-badge.shift-up .rst-current-version{text-align:right}.rst-versions.rst-badge.shift-up .rst-current-version .fa-book,.rst-versions.rst-badge.shift-up .rst-current-version .icon-book{float:left}.rst-versions.rst-badge>.rst-current-version{width:auto;height:30px;line-height:30px;padding:0 6px;display:block;text-align:center}@media screen and (max-width:768px){.rst-versions{width:85%;display:none}.rst-versions.shift{display:block}}
\ No newline at end of file
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册