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

update append-a/b, ch0/1

上级 ebd7309b
......@@ -5,6 +5,40 @@
: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
......@@ -4,4 +4,358 @@
.. toctree::
:hidden:
:maxdepth: 4
\ No newline at end of file
分析可执行文件
------------------------
对于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
......@@ -8,7 +8,14 @@
站在一万米的代码空间维度看
----------------------------------
现在的操作系统是一个复杂的系统软件,比如Linux操作系统达到了千万行的C源代码量级。在学习操作系统的初期,我们没有必要去分析了解这样一个规模的软件。但这样的软件也是有其特有的一些特征。如果我们站在一万米的高空来看操作系统,可以发现操作系统这个软件干的事主要有两件:一是向下管理计算机硬件和各种外设,而是向上给应用软件提供各种服务帮助。这样描述还太简单了一些,我们可对其进一步描述:操作系统是一个可以管理CPU、内存和各种外设,并管理和服务应用软件的软件。为了完成这些工作,操作系统需要知道如何与硬件打交道,如何更好地面向应用软件做好服务,这就有一系列操作系统相关的理论,抽象,设计等来支持如何做和做得好的需求。
现在的通用操作系统是一个复杂的系统软件,比如Linux操作系统达到了千万行的C源代码量级。在学习操作系统的初期,我们没有必要去分析了解这样大规模的软件。但这样的软件也是有其特有的一些特征。首先,它称为系统软件,简单理解它就是在一个计算机系统范围类使用的软件,管的是整个计算机系统。如果这样来看,一个编辑软件,如Vi Emacs就不能算了。
而在计算机中安装的Rust标准库(类似的有Libc库等)可以算是一个。
如果我们站在一万米的高空来看操作系统,可以发现操作系统这个软件干的事主要有两件:一是向下管理计算机硬件和各种外设,而是向上给应用软件提供各种服务帮助。这样描述还太简单了一些,我们可对其进一步描述:操作系统是一个可以管理CPU、内存和各种外设,并管理和服务应用软件的软件。为了完成这些工作,操作系统需要知道如何与硬件打交道,如何更好地面向应用软件做好服务,这就有一系列操作系统相关的理论,抽象,设计等来支持如何做和做得好的需求。
按照这个特征来看看,其实我们要学习甚至设计实现的一个面向简单计算机系统的软件,也可称为是一个操作系统。比如在这个简单的计算机系统中,只运行了一个应用软件,那专门服务于这个应用软件的系统也可称为是一种比较专用的操作系统。
站在计算机发展的百年时间尺度看
......@@ -27,7 +34,7 @@
.. note::
可以在 :ref:`本书第一章 <link-chapter1>` 看到初级的操作系统其实就是一个给应用提供各种服务(比如输出字符串)的库,方便了单一应用程序的开发与运行。
可以在 :ref:`本书第一章 <link-chapter1>` 看到初级的“三叶虫”操作系统其实就是一个给应用提供各种服务(比如输出字符串)的库,方便了单一应用程序的开发与运行。
恐龙时代
~~~~~~~~~~~~~~
......
......@@ -16,18 +16,18 @@
**为何要写这本操作系统书**
现在国内外已有一系列优秀的操作系统教材,例如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硬件细节,影响操作系统实验的效果。还有部分教材也基本以 Linux/Unix 等实际操作系统为主,难以让学生在一个学期内掌握其中的核心设计。
现在国内外已有一系列优秀的操作系统教材,例如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硬件细节,影响操作系统实验的效果。还有部分教材也基本以 Linux/Unix 等实际操作系统为主,难以让学生在一个学期内掌握其中的核心设计。
对于在校的学生和已经参加工作的工程师而言,能否以较小的时间和精力比较全面地了解操作系统呢?陆游老夫子说过“纸上得来终觉浅,绝知此事要躬行”,也许在了解基本的操作系统概念和原理基础上,通过实际动手来一步一步分析、设计和实现一个操作系统,会发现操作系统原来如此,概念原理和实际实现之间有紧密的联系和巨大的差异。
对于在校的学生和已经参加工作的工程师而言,能否以较少的时间和精力比较全面地了解操作系统呢?陆游老夫子说过“纸上得来终觉浅,绝知此事要躬行”,也许在了解基本的操作系统概念和原理基础上,通过实际动手来一步一步分析、设计和实现“小”操作系统,会发现操作系统原来如此,概念原理和实际实现之间有紧密的联系和一定的差异。
也许学生有疑问,在本科期间自己能通过设计实现一个操作系统吗?这一点其实已经是一个实际存在的现实情况了。MIT教授 Frans Kaashoek等师生设计实现了基于UNIX v6的xv6教学操作系统用于每年的本科操作系统课的实验中,而且在2019年,他们进一步改进了xv6,让其运行在RISC-V CPU上。RISC-V CPU同样来源于高校,是Berkeley教授David Patterson等师生设计实现的第五代RISC CPU(现在简称RISC-V),用于计算机组成原理和计算机体系机构课程的教学和科研中。
这些都给了我们很大的启发:对一个计算机专业的本科生而言,设计实现一个操作系统(包括CPU)有挑战但可行! 所以本书的目标是以简洁的RISC-V CPU为底层硬件基础,根据上层应用从小到大的需求,逐步讲解如何设计并实现满足这些需求的“从小到大”的多个操作系统。并在实现操作系统的过程中,逐步解析操作系统各种概念与原理的知识点,对应的做到有“理”可循和有“码”可查,最终让读者了解和掌握操作系统的原理、设计与实现
这些都给了我们很好的启发:对一个计算机专业的本科生而言,设计实现一个操作系统(包括CPU)有挑战但可行! 所以本书的目标是以简洁的RISC-V CPU为底层硬件基础,根据上层应用从小到大的需求,逐步讲解如何设计并实现满足这些需求的“从小到大”的多个“小”操作系统。并在设计实现操作系统的过程中,逐步解析操作系统各种概念与原理的知识点,对应的做到有“理”可循和有“码”可查,最终让读者通过主动的操作系统设计与实现来深入地掌握操作系统的概念与原理
在具体撰写过程中,第零章是对操作系统的一个概述,让读者对操作系统的历史、定义、特征等概念上有一个大致的了解。后面的每个章节体现了操作系统的一个微缩的历史发展过程,即从对应用由简到繁的支持的角度出发,每章会讲解如何设计一个可运行应用的操作系统,满足应用的阶段性需求。从而读者可以通过对应配套的操作系统设计实验,了解如何从一个微不足道的小操作系统,根据应用需求,添加或增强操作系统功能,逐步形成一个类似UNIX的相对完善的操作系统。每一步都小到足以让人感觉到易于掌控,而在每一步结束时,你都有一个可以工作的操作系统。另外,通过足够详尽的测试程序 ,可以随时验证读者实现的操作系统在每次更新后是否正常工作。由于实验的代码规模和实现复杂度在一个逐步递增的可控范围内,读者可以结合对应于操作系统设计实验的进一步的原理讲解,来建立操作系统概念原理和实际实现的对应关系,从而能够通过操作系统实验的实践过程来加强对理论概念的理解,通过理论概念来进一步指导操作系统实验的实现与改进。
在具体撰写过程中,第零章是对操作系统的一个概述,让读者对操作系统的历史、定义、特征等概念上有一个大致的了解。后面的每个章节体现了操作系统的一个微缩的历史发展过程,即从对应用由简到繁的支持的角度出发,每章会讲解如何设计一个可运行应用的操作系统,满足应用的阶段性需求。从而读者可以通过对应配套的操作系统设计实验,了解如何从一个微不足道的“小”操作系统,根据应用需求,添加或增强操作系统功能,逐步形成一个类似UNIX的相对完善的“小”操作系统。每一步都小到足以让人感觉到易于掌控,而在每一步结束时,你都有一个可以工作的“小”操作系统。另外,通过足够详尽的测试程序 ,可以随时验证读者实现的操作系统在每次更新后是否正常工作。由于实验的代码规模和实现复杂度在一个逐步递增的可控范围内,读者可以结合对应于操作系统设计实验的进一步的原理讲解,来建立操作系统概念原理和实际实现的对应关系,从而能够通过操作系统实验的实践过程来加强对理论概念的理解,通过理论概念来进一步指导操作系统实验的实现与改进。
在你开始阅读与实践本书讲解的内容之前,你需要决定用什么编程语言来完成操作系统实验。你可以用任何你喜欢的编程语言来实现操作系统。我们推荐的编程语言是Rust
在你开始阅读与实践本书讲解的内容之前,你需要决定用什么编程语言来完成操作系统实验。你可以用任何你喜欢的编程语言和你喜欢的CPU上来实现操作系统。我们推荐的编程语言是Rust,我们推荐的CPU是RISC-V
.. note::
......@@ -35,8 +35,12 @@
- 没错,C语言就是为写UNIX而诞生的。Dennis Ritchie和KenThompson没有期望设计一种新语言能帮助高效简洁地开发复杂的应用业务逻辑,只是希望用一种简洁的方式抽象出计算机的行为,便于编写控制计算机硬件的操作系统,最终的结果就是C语言。
- C语言的指针的天使与魔鬼,且C语言缺少有效的并发支持,导致内存和并发漏洞成为当前操作系统的噩梦。
- 从某种角度上看,Rust的目标是解决C的短板,取代C。所以用Rust写OS具有很好的开发和运行的体验。
- 从某种角度上看,新出现的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
......@@ -5,6 +5,16 @@
:hidden:
:maxdepth: 5
本节导读
-------------------------------
本节介绍了一个应用程序执行所需要的执行环境的特征。在某种程度上看,执行环境的软件主体就可称为是一种操作系统。
并进一步说明了不管执行环境是简单还是复杂,设计实现上是否容易,它都体现了操作系统的存在--给应用需求提供服务。
同时,操作系统向下与硬件紧密相关,
向上可通过系统级的函数库间接给应用提供服务,甚至可把系统函数库作为操作系统的一部分,来直接给应用提供服务。
执行应用程序
-------------------------------
......@@ -82,13 +92,15 @@
从之前的 ``cargo run`` 的输出可以看出之前构建的可执行文件是在 target/debug 目录下的 os 。
在 Ubuntu 系统上,可以通过 ``strace`` 工具来运行一个程序并输出程序运行过程当中向内核请求的所有的系统调用及其返回值。
我们只需输入 ``strace target/debug/os`` 即可看到一长串的系统调用。
我们只需输入 ``strace target/debug/os`` 即可看到一长串的各种系统调用。
其中,真正容易看出与 ``Hello, world!`` 相关的只有一个系统调用:
其中,容易看出与 ``Hello, world!`` 应用实际执行相关的只有两个系统调用:
.. code-block::
# 输出字符串
write(1, "Hello, world!\n", 14) = 14
# 程序退出执行
exit_group(0)
其参数的具体含义我们暂且不在这里进行解释。
......@@ -109,19 +121,48 @@
**多层执行环境都是必需的吗?**
除了最上层的应用程序和最下层的硬件平台必须存在之外,作为中间层的函数库和内核并不是必须存在的:它们都是对下层资源进行了 **抽象** (Abstraction),
并为上层提供了一套执行环境。抽象的优点在于它让上层以较小的代价获得所需的功能,并同时可以提供一些保护。但抽象同时也是一种限制,会丧失一些
除了最上层的应用程序和最下层的硬件平台必须存在之外,作为中间层的函数库和操作系统内核并不是必须存在的:
它们都是对下层资源进行了 **抽象** (Abstraction),
并为上层提供了一套执行环境(也可理解为一些服务功能)。抽象的优点在于它让上层以较小的代价获得所需的功能,并同时可以提供一些保护。
但抽象同时也是一种限制,会丧失一些
应有的灵活性。比如,当你在考虑在项目中应该使用哪个函数库的时候,就常常需要这方面的权衡:过多的抽象和过少的抽象自然都是不合适的。
理解应用的需求也很重要。一个能合理满足应用需求的操作系统设计是操作系统设计者需要深入考虑的问题。
这也是一种权衡,过多的服务功能和过少的服务功能自然都是不合适的。
实际上,我们通过应用程序的特征来判断它需要什么程度的抽象
实际上,我们通过应用程序的特征和需求来判断操作系统需要什么程度的抽象和功能
- 如果函数库和内核都不存在,那么我们就是在手写汇编代码,这种方式具有最高的灵活性,抽象能力则最低,基本等同于硬件。我们通常用这种方式来
实现一些架构相关且仅通过编程语言无法描述的小模块或者代码片段。
- 如果仅存在函数库而不存在内核,意味着我们不需要内核提供的抽象。在嵌入式场景就常常会出现这种情况。嵌入式设备虽然也包含 CPU、内存和 I/O
设备,但是它上面通常只会同时运行一个或几个功能非常简单的小应用程序,其定位就是那种功能单一的场景,比如人脸识别打卡系统等。我们常用的
操作系统如 Windows/Linux/macOS 等的抽象都支持同时运行很多应用程序,在嵌入式场景是过抽象的。因此,常见的解决方案是仅使用函数库构建
操作系统如 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 (无效的参数)
......
平台与目标三元组
---------------------------------------
......
......@@ -5,7 +5,15 @@
:hidden:
:maxdepth: 5
本节我们尝试移除之前的 ``Hello world!`` 程序对于标准库的依赖,使得它能够编译到裸机平台 RV64GC 上。
本节导读
-------------------------------
为了很好地理解一个简单应用所需的服务如何体现,本节将尝试开始构造一个小的执行环境,可建立在Linux之上,也可直接建立在裸机之上,
我们称为“三叶虫”操作系统。
作为第一步,本节将尝试移除之前的 ``Hello world!`` 程序对于Rust std标准库的依赖,使得它能够编译到裸机平台 RV64GC 或Linux上。
移除 println! 宏
----------------------------------
我们首先在 ``os`` 目录下新建 ``.cargo`` 目录,并在这个目录下创建 ``config`` 文件,并在里面输入如下内容:
......@@ -23,8 +31,7 @@
当然,这只是使得我们之后在 ``cargo build`` 的时候不必再加上 ``--target`` 参数的一个小 trick。如果我们现在 ``cargo build`` ,还是会和
上一小节一样出现找不到标准库 std 的错误。于是我们开始着手移除标准库。当然,这会产生一些副作用。
移除 println! 宏
----------------------------------
我们在 ``main.rs`` 的开头加上一行 ``#![no_std]`` 来告诉 Rust 编译器不使用 Rust 标准库 std 转而使用核心库 core。编译器报出如下错误:
......@@ -131,6 +138,43 @@
``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 平台上移除标准库依赖**
......
重建用户态执行环境
=================================
.. 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:: rust
$ 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() {
再次编译执行,可以看到正确的字符串输出,且程序也能正确结束!
.. code-block:: rust
$ qemu-riscv64 target/riscv64gc-unknown-none-elf/debug/os
Hello, world!
\ No newline at end of file
重建裸机上的最小化运行时执行环境
=================================
.. toctree::
:hidden:
:maxdepth: 5
本节导读
-------------------------------
本节开始我们将着手自己来实现裸机上的最小执行环境,即我们的“三叶虫”操作系统,并能在裸机上运行 ``Hello, world!`` 程序。
有了上一节实现的用户态的最小执行环境,我们可以稍加改造,就可以完成裸机上的最小执行环境了。
在这一小节,我们介绍如何进行 **执行环境初始化** 。我们在上一小节提到过,一个应用程序的运行离不开下面多层执行环境栈的支撑。
以 ``Hello, world!`` 程序为例,在目前广泛使用的操作系统上,它就至少需要经历以下层层递进的初始化过程:
- 启动OS:硬件启动后,会有一段代码(一般统称为bootloader)对硬件进行初始化,让包括内核在内的系统软件得以运行;
- OS准备好应用程序执行的环境:要运行该应用程序的时候,内核分配相应资源,将程序代码和数据载入内存,并赋予 CPU 使用权,由此应用程序可以运行;
- 应用程序开始执行:程序员编写的代码是应用程序的一部分,它需要标准库/核心库进行一些初始化工作后才能运行。
不过我们的目标是实现在裸机上执行的应用。由于目标平台 ``riscv64gc-unknown-none-elf`` 没有任何操作系统支持,我们只能禁用标准库并移除默认的 main 函数
入口。但是最终我们还是要将 main 函数恢复回来并且输出 ``Hello, world!`` 的。因此,我们需要知道具体需要做哪些初始化工作才能支持
应用程序在裸机上的运行。
而这又需要明确两点:首先是系统在做这些初始化工作之前处于什么状态,在做完初始化工作也就是即将执行 main 函数之前又处于什么状态。比较二者
即可得出答案。
.. _term-bootloader:
.. note::
**QEMU模拟CPU加电的执行过程是啥?**
CPU加电后的执行细节与具体硬件相关,我们这里以QEMU模拟器为具体例子简单介绍一下。
这需要从 CPU 加电后如何初始化,如何执行第一条指令开始讲起。对于我们采用的QEMU模拟器而言,它模拟了一台标准的RISC-V64计算机。我们启动QEMU时,可设置一些参数,在RISC-V64计算机启动执行前,先在其模拟的内存中放置好BootLoader程序和操作系统的二进制代码。这可以通过查看 ``os/Makefile`` 文件中包含 ``qemu-system-riscv64`` 的相关内容来了解。
- ``-bios $(BOOTLOADER)`` 这个参数意味着硬件内存中的固定位置 ``0x80000000`` 处放置了一个BootLoader程序--RustSBI(戳 :doc:`../appendix-c/index` 可以进一步了解RustSBI。)。
- ``-device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA)`` 这个参数表示硬件内存中的特定位置 ``$(KERNEL_ENTRY_PA)`` 放置了操作系统的二进制代码 ``$(KERNEL_BIN)`` 。 ``$(KERNEL_ENTRY_PA)`` 的值是 ``0x80020000`` 。
当我们执行包含上次参数的qemu-system-riscv64软件,就意味给这台虚拟的RISC-V64计算机加电了。此时,CPU的其它通用寄存器清零,
而PC寄存器会指向 ``0x1000`` 的位置。
这个 ``0x1000`` 位置上是CPU加电后执行的第一条指令(固化在硬件中的一小段引导代码),它会很快跳转到 ``0x80000000`` 处,
即RustSBI的第一条指令。RustSBI完成基本的硬件初始化后,
会跳转操作系统的二进制代码 ``$(KERNEL_BIN)`` 所在内存位置 ``0x80020000`` ,执行操作系统的第一条指令。
这时我们的编写的操作系统才开始正式工作。
为啥在 ``0x80000000`` 放置 ``Bootloader`` ?因为这是QEMU的硬件模拟代码中设定好的 ``Bootloader`` 的起始地址。
为啥在 ``0x80020000`` 放置 ``os`` ?因为这是 ``Bootloader--RustSBI`` 的代码中设定好的 ``os`` 的起始地址。
.. note::
**操作系统与SBI之间是啥关系?**
SBI是RISC-V的一种底层规范,操作系统内核与实现SBI规范的RustSBI的关系有点象应用与操作系统内核的关系,后者向前者提供一定的服务。只是SBI提供的服务很少,
能帮助操作系统内核完成的功能有限,但这些功能很底层,很重要,比如关机,显示字符串等。通过操作系统内核也能直接实现,但比较繁琐,如果RustSBI提供了服务,
那么操作系统内核直接调用就好了。
实现第一版的“三叶虫”操作系统
----------------------------
这一版的基本要求是让“三叶虫”操作系统能够正常关机,这是需要调用SBI提供的关机功能 ``SBI_SHUTDOWN`` ,这与上一节的 ``SYSCALL_EXIT`` 类似,
只是在具体参数上有所不同。修改后的代码如下:
.. code-block:: rust
//bootloader/rustsbi-qemu.bin 直接添加的二进制代码
//.cargo/config 添加内容
[build]
target = "riscv64gc-unknown-none-elf"
// os/src/main.rs
const SYSCALL_EXIT: usize = 93;
pub fn shutdown() -> ! {
syscall(SBI_SHUTDOWN, [0, 0, 0]);
panic!("It should shutdown!");
}
#[no_mangle]
extern "C" fn _start() {
shutdown();
}
也许有同学比较迷惑,应用程序访问操作系统提供的系统调用的指令是 ``ecall`` ,操作系统访问RustSBI提供的SBI服务的SBI调用的指令也是 ``ecall`` 。
这其实是没有问题的,虽然指令一样,但它们所在的特权级和特权级转换是不一样的。简单地说,应用程序位于最弱的用户特权级(User Mode),操作系统位于
很强大的内核特权级(Supervisor Mode),RustSBI位于完全掌控机器的机器特权级(Machine Mode),通过 ``ecall`` 指令,可以完成从弱的特权级
到强的特权级的转换。具体细节,可以看下一章的进一步描述。在这里,知道这么多就足够了。
下面是编译执行,结果如下:
.. code-block:: console
#编译生成ELF格式的执行文件
$ cargo build --release
Compiling os v0.1.0 (/media/chyyuu/ca8c7ba6-51b7-41fc-8430-e29e31e5328f/thecode/rust/os_kernel_lab/os)
Finished release [optimized] target(s) in 0.15s
#把ELF执行文件转成bianary文件
$ rust-objcopy --binary-architecture=riscv64 target/riscv64gc-unknown-none-elf/release/os --strip-all -O binary target/riscv64gc-unknown-none-elf/release/os.bin
#加载运行
$ qemu-system-riscv64 -machine virt -nographic -bios ../bootloader/rustsbi-qemu.bin -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80020000
# 无法退出,风扇狂转,感觉碰到死循环
这样的结果是我们不期望的。问题在哪?仔细查看和思考,操作系统的入口地址不对!对 ``os`` ELF执行程序,通过rust-readobj分析,看到的入口地址不是
RustSBIS约定的 ``0x80020000`` 。我们需要修改 ``os`` ELF执行程序的地址内存布局。
实现第二版的“三叶虫”操作系统
----------------------------
.. _term-linker-script:
我们可以通过 **链接脚本** (Linker Script) 调整链接器的行为,使得最终生成的可执行文件的内存布局符合我们的预期。
我们修改 Cargo 的配置文件来使用我们自己的链接脚本 ``os/src/linker.ld`` 而非使用默认的内存布局:
.. code-block::
:linenos:
:emphasize-lines: 5,6,7,8
// os/.cargo/config
[build]
target = "riscv64gc-unknown-none-elf"
[target.riscv64gc-unknown-none-elf]
rustflags = [
"-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
]
具体的链接脚本 ``os/src/linker.ld`` 如下:
.. code-block::
:linenos:
OUTPUT_ARCH(riscv)
ENTRY(_start)
BASE_ADDRESS = 0x80020000;
SECTIONS
{
. = BASE_ADDRESS;
skernel = .;
stext = .;
.text : {
*(.text.entry)
*(.text .text.*)
}
. = ALIGN(4K);
etext = .;
srodata = .;
.rodata : {
*(.rodata .rodata.*)
}
. = ALIGN(4K);
erodata = .;
sdata = .;
.data : {
*(.data .data.*)
}
. = ALIGN(4K);
edata = .;
.bss : {
*(.bss.stack)
sbss = .;
*(.bss .bss.*)
}
. = ALIGN(4K);
ebss = .;
ekernel = .;
/DISCARD/ : {
*(.eh_frame)
}
}
第 1 行我们设置了目标平台为 riscv ;第 2 行我们设置了整个程序的入口点为之前定义的全局符号 ``_start``;
第 3 行定义了一个常量 ``BASE_ADDRESS`` 为 ``0x80020000`` ,也就是我们之前提到的期望我们自己实现的初始化代码被放在的地址;
从第 5 行开始体现了链接过程中对输入的目标文件的段的合并。其中 ``.`` 表示当前地址,也就是链接器会从它指向的位置开始往下放置从输入的目标文件
中收集来的段。我们可以对 ``.`` 进行赋值来调整接下来的段放在哪里,也可以创建一些全局符号赋值为 ``.`` 从而记录这一时刻的位置。我们还能够
看到这样的格式:
.. code-block::
.rodata : {
*(.rodata)
}
冒号前面表示最终生成的可执行文件的一个段的名字,花括号内按照放置顺序描述将所有输入目标文件的哪些段放在这个段中,每一行格式为
``<ObjectFile>(SectionName)``,表示目标文件 ``ObjectFile`` 的名为 ``SectionName`` 的段需要被放进去。我们也可以
使用通配符来书写 ``<ObjectFile>`` 和 ``<SectionName>`` 分别表示可能的输入目标文件和段名。因此,最终的合并结果是,在最终可执行文件
中各个常见的段 ``.text, .rodata .data, .bss`` 从低地址到高地址按顺序放置,每个段里面都包括了所有输入目标文件的同名段,
且每个段都有两个全局符号给出了它的开始和结束地址(比如 ``.text`` 段的开始和结束地址分别是 ``stext`` 和 ``etext`` )。
为了说明当前实现的正确性,我们需要讨论这样一个问题:
1. 如何做到执行环境的初始化代码被放在内存上以 ``0x80020000`` 开头的区域上?
在链接脚本第 7 行,我们将当前地址设置为 ``BASE_ADDRESS`` 也即 ``0x80020000`` ,然后从这里开始往高地址放置各个段。第一个被放置的
是 ``.text`` ,而里面第一个被放置的又是来自 ``entry.asm`` 中的段 ``.text.entry``,这个段恰恰是含有两条指令的执行环境初始化代码,
它在所有段中最早被放置在我们期望的 ``0x80020000`` 处。
这样一来,我们就将运行时重建完毕了。在 ``os`` 目录下 ``cargo build --release`` 或者直接 ``make build`` 就能够看到
最终生成的可执行文件 ``target/riscv64gc-unknown-none-elf/release/os`` 。
通过分析,我们看到 ``0x80020000`` 处的代码是我们预期的 ``_start()`` 函数的内容。我们采用刚才的编译运行方式进行试验,发现还是同样的错误结果。
问题出在哪里?这时需要用上 ``debug`` 大法了。
.. code-block:: console
#在一个终端执行如下命令:
$ qemu-system-riscv64 -machine virt -nographic -bios ../bootloader/rustsbi-qemu.bin -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80020000 -S -s
#在另外一个终端执行如下命令:
$ rust-gdb target/riscv64gc-unknown-none-elf/release/os
(gdb) target remote :1234
(gdb) break *0x80020000
(gdb) x /16i 0x80020000
(gdb) si
结果发现刚执行一条指令,整个系统就飞了( ``pc`` 寄存器等已经变成为 ``0`` 了)。再一看, ``sp`` 寄存器是一个非常大的值 ``0xffffff...`` 。这就很清楚是
**栈 stack**出现了问题。我们没有设置好**栈 stack**! 好吧,我们需要考虑如何合理设置**栈 stack**。
实现第三版的“三叶虫”操作系统
----------------------------
为了说明如何实现正确的栈,我们需要讨论这样一个问题:
1. 应用函数调用所需的栈放在哪里?
需要有一段代码来分配并栈空间,并把 ``sp`` 寄存器指向栈空间的起始位置(注意:栈空间是从上向下 ``push`` 数据的)。
所以,我们要写一小段汇编代码 ``entry.asm`` 来帮助建立好栈空间。
从链接脚本第 32 行开始,我们可以看出 ``entry.asm`` 中分配的栈空间对应的段 ``.bss.stack`` 被放入到可执行文件中的
``.bss`` 段中的低地址中。在后面虽然有一个通配符 ``.bss.*`` ,但是由于链接脚本的优先匹配规则它并不会被匹配到后面去。
这里需要注意的是地址区间 :math:`[\text{sbss},\text{ebss})` 并不包括栈空间,其原因后面再进行说明。
我们自己编写运行时初始化的代码:
.. code-block:: asm
:linenos:
# os/src/entry.asm
.section .text.entry
.globl _start
_start:
la sp, boot_stack_top
call rust_main
.section .bss.stack
.globl boot_stack
boot_stack:
.space 4096 * 16
.globl boot_stack_top
boot_stack_top:
在这段汇编代码中,我们从第 8 行开始预留了一块大小为 4096 * 16 字节也就是 :math:`64\text{KiB}` 的空间用作接下来要运行的程序的栈空间,
这块栈空间的栈顶地址被全局符号 ``boot_stack_top`` 标识,栈底则被全局符号 ``boot_stack`` 标识。同时,这块栈空间单独作为一个名为
``.bss.stack`` 的段,之后我们会通过链接脚本来安排它的位置。
从第 2 行开始,我们通过汇编代码实现执行环境的初始化,它其实只有两条指令:第一条指令将 sp 设置为我们预留的栈空间的栈顶位置,于是之后在函数
调用的时候,栈就可以从这里开始向低地址增长了。简单起见,我们目前暂时不考虑 sp 越过了栈底 ``boot_stack`` ,也就是栈溢出的情形,虽然这有
可能导致严重的错误。第二条指令则是通过伪指令 ``call`` 函数调用 ``rust_main`` ,这里的 ``rust_main`` 是一个我们稍后自己编写的应用
入口。因此初始化任务非常简单:正如上面所说的一样,只需要设置栈指针 sp,随后跳转到应用入口即可。这两条指令单独作为一个名为
``.text.entry`` 的段,且全局符号 ``_start`` 给出了段内第一条指令的地址。
接着,我们在 ``main.rs`` 中嵌入这些汇编代码并声明应用入口 ``rust_main`` :
.. code-block:: rust
:linenos:
:emphasize-lines: 4,8,10,11,12,13
// os/src/main.rs
#![no_std]
#![no_main]
#![feature(global_asm)]
mod lang_items;
global_asm!(include_str!("entry.asm"));
#[no_mangle]
pub fn rust_main() -> ! {
loop {}
}
背景高亮指出了 ``main.rs`` 中新增的代码。
第 4 行中,我们手动设置 ``global_asm`` 特性来支持在 Rust 代码中嵌入全局汇编代码。第 8 行,我们首先通过
``include_str!`` 宏将同目录下的汇编代码 ``entry.asm`` 转化为字符串并通过 ``global_asm!`` 宏嵌入到代码中。
从第 10 行开始,
我们声明了应用的入口点 ``rust_main`` ,这里需要注意的是需要通过宏将 ``rust_main`` 标记为 ``#[no_mangle]`` 以避免编译器对它的
名字进行混淆,不然的话在链接的时候, ``entry.asm`` 将找不到 ``main.rs`` 提供的外部符号 ``rust_main`` 从而导致链接失败。
这样一来,我们就将“三叶虫”操作系统编写完毕了。再次使用上节中的编译,生成和运行操作,我们看到QEMU模拟的RISC-V 64计算机**优雅**地推出了!
.. code-block:: console
$ qemu-system-riscv64 \
> -machine virt \
> -nographic \
> -bios ../bootloader/rustsbi-qemu.bin \
> -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80020000
[rustsbi] Version 0.1.0
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Platform: QEMU
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: 0x222
[rustsbi] medeleg: 0xb1ab
[rustsbi] Kernel entry: 0x80020000
$ #“优雅”地退出了。
我们可以松一口气了。接下来,我们要让“三叶虫”操作系统要实现“Hello, world”输出!
手动清空 .bss 段
----------------------------------
由于 ``.bss`` 段需要在程序正式开始运行之前被固定初始化为零,因此在 ELF 文件中,为了节省磁盘空间,只会记录 ``.bss`` 段的位置而并不是
有一块长度相等的全为零的数据。在内核将可执行文件加载到内存的时候,它需要负责将 ``.bss`` 所分配到的内存区域全部清零。而我们这里需要在
应用程序 ``rust_main`` 中,在访问任何 ``.bss`` 段的全局数据之前手动将其清零。
.. code-block:: rust
:linenos:
// os/src/main.rs
fn clear_bss() {
extern "C" {
fn sbss();
fn ebss();
}
(sbss as usize..ebss as usize).for_each(|a| {
unsafe { (a as *mut u8).write_volatile(0) }
});
}
在程序内自己进行清零的时候,我们就不用去解析 ELF(此时也没有 ELF 可供解析)了,而是通过链接脚本 ``linker.ld`` 中给出的全局符号
``sbss`` 和 ``ebss`` 来确定 ``.bss`` 段的位置。
\ No newline at end of file
手动加载、运行应用程序
==================================
.. toctree::
:hidden:
:maxdepth: 5
在上一节中我们自己实现了一套运行时来代替标准库,并完整的构建了最终的可执行文件。但是它现在只是放在磁盘上的一个文件,若想将它运行起来的话,
就需要将它加载到内存中,在大多数情况下这是操作系统的任务。
让我们先来看看最终可执行文件的格式:
.. code-block:: console
$ file os/target/riscv64gc-unknown-none-elf/release/os
os/target/riscv64gc-unknown-none-elf/release/os: ELF 64-bit LSB executable,
UCB RISC-V, version 1 (SYSV), statically linked, not stripped
.. _term-elf:
.. _term-metadata:
从中可以看出可执行文件的格式为 **可执行和链接格式** (Executable and Linkable Format, ELF),硬件平台是 RV64 。在 ELF 文件中,
除了程序必要的代码、数据段(它们本身都只是一些二进制的数据)之外,还有一些 **元数据** (Metadata) 描述这些段在地址空间中的位置和在
文件中的位置以及一些权限控制信息,这些元数据只能放在代码、数据段的外面。
我们可以通过二进制工具 ``readelf`` 来看看 ELF 文件中究竟包含什么内容,输入命令:
.. code-block:: console
$ riscv64-unknown-elf-readelf os/target/riscv64gc-unknown-none-elf/release/os -a
首先可以看到一个 ELF header,它位于 ELF 文件的开头:
.. code-block:: objdump
:linenos:
:emphasize-lines: 2,11,12,13,17,19
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: RISC-V
Version: 0x1
Entry point address: 0x80020000
Start of program headers: 64 (bytes into file)
Start of section headers: 9016 (bytes into file)
Flags: 0x1, RVC, soft-float ABI
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 3
Size of section headers: 64 (bytes)
Number of section headers: 8
Section header string table index: 6
.. _term-magic:
- 第 2 行是一个称之为 **魔数** (Magic) 独特的常数,存放在 ELF header 的一个固定位置。当加载器将 ELF 文件加载到内存之前,通常会查看
该位置的值是否正确,来快速确认被加载的文件是不是一个 ELF 。
- 第 11 行给出了可执行文件的入口点为 ``0x80020000`` ,这正是我们上一节所做的事情。
- 从 12/13/17/19 行中,我们可以知道除了 ELF header 之外,还有另外两种不同的 header,分别称为 program header 和 section header,
它们都有多个。ELF header 中给出了三种 header 的大小、在文件中的位置以及数目。
一共有 3 个不同的 program header,它们从文件的 64 字节开始,每个 56 字节:
.. code-block:: objdump
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000001000 0x0000000080020000 0x0000000080020000
0x000000000000001a 0x000000000000001a R E 0x1000
LOAD 0x0000000000002000 0x0000000080021000 0x0000000080021000
0x0000000000000000 0x0000000000010000 RW 0x1000
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x0
每个 program header 指向一个在加载的时候可以连续加载的区域。
一共有 8 个不同的 section header,它们从文件的 9016 字节开始,每个 64 字节:
.. code-block:: objdump
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000080020000 00001000
000000000000001a 0000000000000000 AX 0 0 2
[ 2] .bss NOBITS 0000000080021000 00002000
0000000000010000 0000000000000000 WA 0 0 1
[ 3] .riscv.attributes RISCV_ATTRIBUTE 0000000000000000 00002000
000000000000006a 0000000000000000 0 0 1
[ 4] .comment PROGBITS 0000000000000000 0000206a
0000000000000013 0000000000000001 MS 0 0 1
[ 5] .symtab SYMTAB 0000000000000000 00002080
00000000000001c8 0000000000000018 7 4 8
[ 6] .shstrtab STRTAB 0000000000000000 00002248
0000000000000041 0000000000000000 0 0 1
[ 7] .strtab STRTAB 0000000000000000 00002289
00000000000000ab 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
p (processor specific)
There are no section groups in this file.
每个 section header 则描述一个段的元数据。
其中,我们看到了代码段 ``.text`` 被放在可执行文件的 4096 字节处,大小 0x1a=26 字节,需要被加载到地址 ``0x80020000``。
它们分别由元数据的字段 Offset、 Size 和 Address 给出。同理,我们自己预留的应用程序函数调用栈在 ``.bss`` 段中,大小为 :math:`64\text{KiB}`
,需要被加载到地址 ``0x80021000`` 处。我们没有看到 ``.data/.rodata`` 等段,因为目前的 ``rust_main`` 里面没有任何东西。
我们还能够看到 ``.symtab`` 段中给出的符号表:
.. code-block::
Symbol table '.symtab' contains 19 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS os.78wp4f2l-cgu.0
2: 0000000000000000 0 FILE LOCAL DEFAULT ABS os.78wp4f2l-cgu.1
3: 0000000080020000 0 NOTYPE LOCAL DEFAULT 1 .Lpcrel_hi0
4: 0000000080020000 0 NOTYPE GLOBAL DEFAULT 1 _start
5: 0000000080021000 0 NOTYPE GLOBAL DEFAULT 2 boot_stack
6: 0000000080031000 0 NOTYPE GLOBAL DEFAULT 2 boot_stack_top
7: 0000000080020010 10 FUNC GLOBAL DEFAULT 1 rust_main
8: 0000000080020000 0 NOTYPE GLOBAL DEFAULT ABS BASE_ADDRESS
9: 0000000080020000 0 NOTYPE GLOBAL DEFAULT 1 skernel
10: 0000000080020000 0 NOTYPE GLOBAL DEFAULT 1 stext
11: 0000000080021000 0 NOTYPE GLOBAL DEFAULT 1 etext
12: 0000000080021000 0 NOTYPE GLOBAL DEFAULT 1 srodata
13: 0000000080021000 0 NOTYPE GLOBAL DEFAULT 1 erodata
14: 0000000080021000 0 NOTYPE GLOBAL DEFAULT 1 sdata
15: 0000000080021000 0 NOTYPE GLOBAL DEFAULT 1 edata
16: 0000000080031000 0 NOTYPE GLOBAL DEFAULT 2 sbss
17: 0000000080031000 0 NOTYPE GLOBAL DEFAULT 2 ebss
18: 0000000080031000 0 NOTYPE GLOBAL DEFAULT 2 ekernel
里面包括了栈顶、栈底、rust_main 的地址以及我们在 ``linker.ld`` 中定义的各个段开始和结束地址。
因此,从 ELF header 中可以看出,ELF 中的内容按顺序应该是:
- ELF header
- 若干个 program header
- 程序各个段的实际数据
- 若干的 section header
当将程序加载到内存的时候,对于每个 program header 所指向的区域,我们需要将对应的数据从文件复制到内存中。这就需要解析 ELF 的元数据
才能知道数据在文件中的位置以及即将被加载到内存中的位置。但目前,我们不需要从 ELF 中解析元数据就知道程序的内存布局
(这个内存布局是我们按照需求自己指定的),我们可以手动完成加载任务。
具体的做法是利用 ``rust-objcopy`` 工具删除掉 ELF 文件中的
所有 header 只保留各个段的实际数据得到一个没有任何符号的纯二进制镜像文件,由于缺少了必要的元数据,我们的二进制工具也没有办法
对它完成解析了。而后,我们直接将这个二进制镜像文件手动载入到内存中合适位置即可。在这里,我们知道在镜像文件中,仍然是代码段 ``.text``
作为起始,因此我们要将这个代码段载入到 ``0x80020000`` 才能和上一级 bootloader 对接上。因此,我们只要把整个镜像文件手动载入到
内存的地址 ``0x80020000`` 处即可。在不同的硬件平台上,手动加载的方式是不同的。
qemu 平台
-------------------------
首先我们还原一下可执行文件和二进制镜像的生成流程:
.. 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 的参数。
- ``-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。
手动清空 .bss 段
----------------------------------
由于 ``.bss`` 段需要在程序正式开始运行之前被固定初始化为零,因此在 ELF 文件中,为了节省磁盘空间,只会记录 ``.bss`` 段的位置而并不是
有一块长度相等的全为零的数据。在内核将可执行文件加载到内存的时候,它需要负责将 ``.bss`` 所分配到的内存区域全部清零。而我们这里需要在
应用程序 ``rust_main`` 中,在访问任何 ``.bss`` 段的全局数据之前手动将其清零。
.. code-block:: rust
:linenos:
// os/src/main.rs
fn clear_bss() {
extern "C" {
fn sbss();
fn ebss();
}
(sbss as usize..ebss as usize).for_each(|a| {
unsafe { (a as *mut u8).write_volatile(0) }
});
}
在程序内自己进行清零的时候,我们就不用去解析 ELF(此时也没有 ELF 可供解析)了,而是通过链接脚本 ``linker.ld`` 中给出的全局符号
``sbss`` 和 ``ebss`` 来确定 ``.bss`` 段的位置。
.. 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 的规则来使用它们便可借助
编译器在编译期就解决很多潜在的内存不安全问题。
重建最小化运行时
=================================
理解应用程序和执行环境
==================================
.. toctree::
:hidden:
:maxdepth: 5
本节开始我们将着手自己来实现之前被我们移除的 ``Hello, world!`` 程序中的功能。在这一小节,我们介绍如何进行 **执行环境初始化** 。
我们在上一小节提到过,一个应用程序的运行离不开下面多层执行环境栈的支撑。以 ``Hello, world!`` 程序为例,在目前广泛使用的操作系统上,
它就至少需要经历以下层层递进的初始化过程:
- 启动OS:硬件启动后,会有一段代码(一般统称为bootloader)对硬件进行初始化,让包括内核在内的系统软件得以运行;
- OS准备好应用程序执行的环境:要运行该应用程序的时候,内核分配相应资源,将程序代码和数据载入内存,并赋予 CPU 使用权,由此应用程序可以运行;
- 应用程序开始执行:程序员编写的代码是应用程序的一部分,它需要标准库/核心库进行一些初始化工作后才能运行。
不过我们的目标是实现在裸机上执行的应用。
在上一小节中,由于目标平台 ``riscv64gc-unknown-none-elf`` 没有任何操作系统支持,我们只能禁用标准库并移除默认的 main 函数
入口。但是最终我们还是要将 main 函数恢复回来并且输出 ``Hello, world!`` 的。因此,我们需要知道具体需要做哪些初始化工作才能支持
应用程序在裸机上的运行。
而这又需要明确两点:首先是系统在做这些初始化工作之前处于什么状态,在做完初始化工作也就是即将执行 main 函数之前又处于什么状态。比较二者
即可得出答案。
.. _term-physical-address:
.. _term-physical-memory:
本节导读
-------------------------------
.. note::
在前面几节,我们进行了大量的实验。接下来是要消化总结和归纳理解的时候了。
本节主要会进一步归纳总结执行程序和执行环境相关的基础知识:
**物理内存与物理地址**
- 物理内存与物理地址
- 函数调用与栈
- 调用规范
- 程序内存布局
- 执行环境
物理内存是计算机体系结构中一个重要的组成部分。在存储方面,CPU 唯一能够直接访问的只有物理内存中的数据,它可以通过访存指令来达到这一目的。
从 CPU 的视角看来,可以将物理内存看成一个大字节数组,而物理地址则对应于一个能够用来访问数组中某个元素的下标。与我们日常编程习惯不同的
是,该下标通常不以 0 开头,而通常以 ``0x80000000`` 开头。总结一下的话就是, CPU 可以通过物理地址来 *逐字节* 访问物理内存中保存的
数据。
如果读者已经了解,可直接跳过,进入下一节。
值得一提的是,当 CPU 以多个字节(比如 2/4/8 或更多)为单位访问物理内存(事实上并不局限于物理内存)中的数据时,就有可能会引入端序和
地址对齐的问题。由于这并不是重点,我们在这里不展开说明。
.. _term-bootloader:
.. _term-physical-address:
.. _term-physical-memory:
在该目标平台上,物理内存以物理地址 ``0x80000000`` 开头的部分放置着 **引导加载程序** (Bootloader) 的代码。它的任务是对硬件进行一些
初始化工作,并跳转到一个固定的物理地址 ``0x80020000`` 。在本书正文中我们无需关心它的实现,而是当成一个黑盒使用即可,它的预编译版本
可执行文件放在项目根目录的 ``bootloader`` 目录下。在这之后,控制权就会被移交到我们手中。因此,我们需要保证我们负责的初始化的代码
出现在物理内存以物理地址 ``0x80020000`` 开头的地方。在我们的初始化任务完成之后,自然需要跳转到 main 函数进行执行里面的代码,这也是
初始化任务的一个重要部分。
但实际上不止如此,我们还需要考虑栈的设置。
物理内存与物理地址
----------------------------
物理内存是计算机体系结构中一个重要的组成部分。在存储方面,CPU 唯一能够直接访问的只有物理内存中的数据,它可以通过访存指令来达到这一目的。
从 CPU 的视角看来,可以将物理内存看成一个大字节数组,而物理地址则对应于一个能够用来访问数组中某个元素的下标。与我们日常编程习惯不同的
是,该下标通常不以 0 开头,而通常以 ``0x80000000`` 开头。总结一下的话就是, CPU 可以通过物理地址来 *逐字节* 访问物理内存中保存的
数据。
.. note::
**CPU 加电后在做啥?**
值得一提的是,当 CPU 以多个字节(比如 2/4/8 或更多)为单位访问物理内存(事实上并不局限于物理内存)中的数据时,就有可能会引入端序和
地址对齐的问题。由于这并不是重点,我们在这里不展开说明。
CPU加电后的执行细节与具体硬件相关,我们这里以QEMU模拟器为具体例子简单介绍一下。
这需要从 CPU 加电后如何初始化,如何执行第一条指令开始讲起。对于我们采用的QEMU模拟器而言,它模拟了一台标准的RISC-V64计算机。我们启动QEMU时,可设置一些参数,在RISC-V64计算机启动执行前,先在其模拟的内存中放置好BootLoader程序和操作系统的二进制代码。这可以通过查看 ``os/Makefile`` 文件中包含 ``qemu-system-riscv64`` 的相关内容来了解。
- ``-bios $(BOOTLOADER)`` 这个参数意味着硬件内存中的特定位置 ``0x80000000`` 处放置了一个BootLoader程序--RustSBI(戳 :doc:`../appendix-c/index` 可以进一步了解RustSBI。)。
- ``-device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA)`` 这个参数表示硬件内存中的特定位置 ``$(KERNEL_ENTRY_PA)`` 放置了操作系统的二进制代码 ``$(KERNEL_BIN)`` 。 ``$(KERNEL_ENTRY_PA)`` 的值是 ``0x80020000`` 。
当我们执行包含上次参数的qemu-system-riscv64软件,就意味给这台虚拟的RISC-V64计算机加电了。此时,CPU的PC寄存器会指向 ``0x1000`` 的位置,这个位置上是CPU加电后执行的第一条指令,它会很快跳转到 ``0x80000000`` 处,即RustSBI的第一条指令。RustSBI完成基本的硬件初始化后,会跳转操作系统的二进制代码 ``$(KERNEL_BIN)`` 所在内存位置 ``0x80020000`` ,执行操作系统的第一条指令。这时我们的编写的操作系统才开始正式工作。
.. _function-call-and-stack:
......@@ -178,6 +157,11 @@ ra 保存的物理地址,由于 x0 是一个恒为 0 的寄存器,在 rd 中
.. _term-calling-convention:
调用规范
----------------
**调用规范** (Calling Convention) 约定在某个指令集架构上,某种编程语言的函数调用如何实现。它包括了以下内容:
1. 函数的输入参数和返回值如何传递;
......@@ -330,172 +314,6 @@ RISC-V 架构中,栈是从高地址到低地址增长的。在一个函数中
都有一个符号表,里面记录着它需要从其他文件中寻找的外部符号和能够提供给其他文件的符号,通常是一些函数和全局变量等。在链接的时候汇编器会将
外部符号替换为实际的地址。
.. _term-linker-script:
我们可以通过 **链接脚本** (Linker Script) 调整链接器的行为,使得最终生成的可执行文件的内存布局符合我们的预期。
实现
----------
我们自己编写运行时初始化的代码:
.. code-block:: asm
:linenos:
# os/src/entry.asm
.section .text.entry
.globl _start
_start:
la sp, boot_stack_top
call rust_main
.section .bss.stack
.globl boot_stack
boot_stack:
.space 4096 * 16
.globl boot_stack_top
boot_stack_top:
在这段汇编代码中,我们从第 8 行开始预留了一块大小为 4096 * 16 字节也就是 :math:`64\text{KiB}` 的空间用作接下来要运行的程序的栈空间,
这块栈空间的栈顶地址被全局符号 ``boot_stack_top`` 标识,栈底则被全局符号 ``boot_stack`` 标识。同时,这块栈空间单独作为一个名为
``.bss.stack`` 的段,之后我们会通过链接脚本来安排它的位置。
从第 2 行开始,我们通过汇编代码实现执行环境的初始化,它其实只有两条指令:第一条指令将 sp 设置为我们预留的栈空间的栈顶位置,于是之后在函数
调用的时候,栈就可以从这里开始向低地址增长了。简单起见,我们目前暂时不考虑 sp 越过了栈底 ``boot_stack`` ,也就是栈溢出的情形,虽然这有
可能导致严重的错误。第二条指令则是通过伪指令 ``call`` 函数调用 ``rust_main`` ,这里的 ``rust_main`` 是一个我们稍后自己编写的应用
入口。因此初始化任务非常简单:正如上面所说的一样,只需要设置栈指针 sp,随后跳转到应用入口即可。这两条指令单独作为一个名为
``.text.entry`` 的段,且全局符号 ``_start`` 给出了段内第一条指令的地址。
接着,我们在 ``main.rs`` 中嵌入这些汇编代码并声明应用入口 ``rust_main`` :
.. code-block:: rust
:linenos:
:emphasize-lines: 4,8,10,11,12,13
// os/src/main.rs
#![no_std]
#![no_main]
#![feature(global_asm)]
mod lang_items;
global_asm!(include_str!("entry.asm"));
#[no_mangle]
pub fn rust_main() -> ! {
loop {}
}
背景高亮指出了 ``main.rs`` 中新增的代码。
第 4 行中,我们手动设置 ``global_asm`` 特性来支持在 Rust 代码中嵌入全局汇编代码。第 8 行,我们首先通过
``include_str!`` 宏将同目录下的汇编代码 ``entry.asm`` 转化为字符串并通过 ``global_asm!`` 宏嵌入到代码中。
从第 10 行开始,
我们声明了应用的入口点 ``rust_main`` ,这里需要注意的是需要通过宏将 ``rust_main`` 标记为 ``#[no_mangle]`` 以避免编译器对它的
名字进行混淆,不然的话在链接的时候, ``entry.asm`` 将找不到 ``main.rs`` 提供的外部符号 ``rust_main`` 从而导致链接失败。
我们修改 Cargo 的配置文件来使用我们自己的链接脚本 ``os/src/linker.ld`` 而非使用默认的内存布局:
.. code-block::
:linenos:
:emphasize-lines: 5,6,7,8
// os/.cargo/config
[build]
target = "riscv64gc-unknown-none-elf"
[target.riscv64gc-unknown-none-elf]
rustflags = [
"-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
]
具体的链接脚本 ``os/src/linker.ld`` 如下:
.. code-block::
:linenos:
OUTPUT_ARCH(riscv)
ENTRY(_start)
BASE_ADDRESS = 0x80020000;
SECTIONS
{
. = BASE_ADDRESS;
skernel = .;
stext = .;
.text : {
*(.text.entry)
*(.text .text.*)
}
. = ALIGN(4K);
etext = .;
srodata = .;
.rodata : {
*(.rodata .rodata.*)
}
. = ALIGN(4K);
erodata = .;
sdata = .;
.data : {
*(.data .data.*)
}
. = ALIGN(4K);
edata = .;
.bss : {
*(.bss.stack)
sbss = .;
*(.bss .bss.*)
}
. = ALIGN(4K);
ebss = .;
ekernel = .;
/DISCARD/ : {
*(.eh_frame)
}
}
第 1 行我们设置了目标平台为 riscv ;第 2 行我们设置了整个程序的入口点为之前定义的全局符号 ``_start``;
第 3 行定义了一个常量 ``BASE_ADDRESS`` 为 ``0x80020000`` ,也就是我们之前提到的期望我们自己实现的初始化代码被放在的地址;
从第 5 行开始体现了链接过程中对输入的目标文件的段的合并。其中 ``.`` 表示当前地址,也就是链接器会从它指向的位置开始往下放置从输入的目标文件
中收集来的段。我们可以对 ``.`` 进行赋值来调整接下来的段放在哪里,也可以创建一些全局符号赋值为 ``.`` 从而记录这一时刻的位置。我们还能够
看到这样的格式:
.. code-block::
.rodata : {
*(.rodata)
}
冒号前面表示最终生成的可执行文件的一个段的名字,花括号内按照放置顺序描述将所有输入目标文件的哪些段放在这个段中,每一行格式为
``<ObjectFile>(SectionName)``,表示目标文件 ``ObjectFile`` 的名为 ``SectionName`` 的段需要被放进去。我们也可以
使用通配符来书写 ``<ObjectFile>`` 和 ``<SectionName>`` 分别表示可能的输入目标文件和段名。因此,最终的合并结果是,在最终可执行文件
中各个常见的段 ``.text, .rodata .data, .bss`` 从低地址到高地址按顺序放置,每个段里面都包括了所有输入目标文件的同名段,
且每个段都有两个全局符号给出了它的开始和结束地址(比如 ``.text`` 段的开始和结束地址分别是 ``stext`` 和 ``etext`` )。
为了说明当前实现的正确性,我们需要讨论这样两个问题:
1. 如何做到执行环境的初始化代码被放在内存上以 ``0x80020000`` 开头的区域上?
在链接脚本第 7 行,我们将当前地址设置为 ``BASE_ADDRESS`` 也即 ``0x80020000`` ,然后从这里开始往高地址放置各个段。第一个被放置的
是 ``.text`` ,而里面第一个被放置的又是来自 ``entry.asm`` 中的段 ``.text.entry``,这个段恰恰是含有两条指令的执行环境初始化代码,
它在所有段中最早被放置在我们期望的 ``0x80020000`` 处。
2. 应用函数调用所需的栈放在哪里?
从链接脚本第 32 行开始,我们可以看出 ``entry.asm`` 中分配的栈空间对应的段 ``.bss.stack`` 被放入到可执行文件中的
``.bss`` 段中的低地址中。在后面虽然有一个通配符 ``.bss.*`` ,但是由于链接脚本的优先匹配规则它并不会被匹配到后面去。
这里需要注意的是地址区间 :math:`[\text{sbss},\text{ebss})` 并不包括栈空间,其原因后面再进行说明。
这样一来,我们就将运行时重建完毕了。在 ``os`` 目录下 ``cargo build --release`` 或者直接 ``make build`` 就能够看到
最终生成的可执行文件 ``target/riscv64gc-unknown-none-elf/release/os`` 。
.. note::
......@@ -505,4 +323,3 @@ RISC-V 架构中,栈是从高地址到低地址增长的。在一个函数中
- `Notes from Cornell CS3410 2019Spring <http://www.cs.cornell.edu/courses/cs3410/2019sp/schedule/slides/10-calling-notes-bw.pdf>`_
- `Lecture from Berkeley CS61C 2018Spring <https://inst.eecs.berkeley.edu/~cs61c/sp18/lec/06/lec06.pdf>`_
- `Lecture from MIT 6.828 2020 <https://pdos.csail.mit.edu/6.828/2020/lec/l-riscv-slides.pdf>`_
......@@ -5,7 +5,10 @@
:hidden:
:maxdepth: 5
这一小节我们来自己实现 ``println!`` 的功能。 我们这里只是给出一些函数之间的调用关系,而不在这里进行一些实现细节上的展开。有兴趣的读者
这一小节我们要让我们的执行环节来自己实现 ``println!`` 的功能。按照从难到
我们这里只是给出一些函数之间的调用关系,而不在这里进行一些实现细节上的展开。有兴趣的读者
可以自行参考代码提供的注释。
在屏幕上打印一个字符是最基础的功能,它已经由 bootloader (也就是放在 ``bootloader`` 目录下的预编译版本)提供,具体的调用方法可以参考
......
......@@ -9,18 +9,29 @@
1app-ee-platform
2remove-std
3minimal-rt
4load-manually
5sbi-print
3-1-mini-rt-usrland
3-2-mini-rt-baremetal
4understand-prog
5syscall-sbi-print
6practice
本章导读
------------------
大多数程序员的第一行代码都从 ``Hello, world!`` 开始,当我们满怀着好奇心在编辑器内键入仅仅数个字节,再经过几行命令编译(靠的是编译器)、运行(靠的是操作系统),终于在黑洞洞的终端窗口中看到期望中的结果的时候,一扇通往编程世界的大门已经打开。在本章第一节 :doc:`1app-ee-platform` 中,可以看到用Rust语言编写的非常简单的“Hello, world”应用程序。
不过我们能够隐约意识到编程工作能够如此方便简洁并不是理所当然的,实际上有着多层硬件和软件工具和支撑环境隐藏在它背后,才让我们不必付出那么多努力就能够创造出功能强大的应用程序。
不过我们能够隐约意识到编程工作能够如此方便简洁并不是理所当然的,实际上有着多层硬件和软件工具和支撑环境隐藏在它背后,才让我们不必付出那么多努力就能够创造出功能强大的应用程序。生成应用程序二进制执行代码所依赖的是以**编译器**为主的开发环境;运行应用程序执行码所依赖的是以**操作系统**为主的执行环境。
本章主要是设计和实现建立在裸机上的执行环境,从中对应用程序和它所依赖的执行环境有一个全面和深入的理解。
本章我们的目标仍然只是输出 ``Hello, world!`` ,但这一次,我们将离开舒适区,基于一个几乎空无一物的平台从零开始搭建我们自己的高楼大厦,
而不是仅仅通过一行语句就完成任务。所以,在接下来的内容中,我们将描述如何让 ``Hello, world!`` 应用程序逐步脱离对编译器、运行时和操作系统的依赖,最终在裸机上运行。这时,我们也可把这个能在裸机上运行的``Hello, world!`` 应用程序称为一种支持输出字符串的非常初级的“三叶虫”操作系统,它其实就是一个给应用提供各种服务(比如输出字符串)的库,方便了单一应用程序在裸机上的开发与运行。
实践体验
------------------
本章设计实现了一个支持显示字符串应用的简单操作系统--“三叶虫”操作系统。
获取本章代码:
.. code-block:: console
......@@ -29,14 +40,14 @@
$ cd rCore-Tutorial-v3
$ git checkout ch1
在 qemu 模拟器上运行本章代码:
在 qemu 模拟器上运行本章代码,看看一个小应用程序是如何在QEMU模拟的计算机上运行的
.. code-block:: console
$ cd os
$ make run
将 Maix 系列开发版连接到 PC,并在上面运行本章代码:
将 Maix 系列开发版连接到 PC,并在上面运行本章代码,看看一个小应用程序是如何在真实计算机上运行的
.. code-block:: console
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册