应用程序与基本执行环境
创建一个bin项目
1 | mkdir tuos |
移除执行环境对操作系统的依赖
1.添加目标平台
- 我们要构建一个可以在裸机上运行的 RISC-V架构的操作系统,那么就需要添加对目标平台的支持:
1 | tuos/os |
这条命令有几个作用:
识别目标平台:
riscv64gc
表示 RISC-V 64位通用计算(General-Purpose Computing)架构( CPU 架构)。unknown
表示 Rust 不了解这个目标的操作系统或供应商信息(CPU 厂商)。none
表示没有操作系统,这通常用于裸机编程(操作系统)。elf
表示没有标准的运行时库(没有任何系统调用的封装支持),但可以生成 ELF 格式的执行程序(运行时库)。
安装必要组件:如果
rustup
有为riscv64gc-unknown-none-elf
准备的预编译组件,它会尝试下载并安装这些组件。更新Rust工具链配置:在 Rust 配置中添加对新目标的支持,这样当你使用
cargo build --target riscv64gc-unknown-none-elf
命令时,Cargo 就会知道如何为riscv64gc-unknown-none-elf
目标构建你的项目。
可以使用如下命令查看当前Rust工具链支持的所有目标平台:
1 | rustup target list |
2.在 tuos
目录下新建 .cargo
目录,并在这个目录下创建 config
文件,并在里面输入如下内容:
1 | # tuos/os/.cargo/config |
这会对于 Cargo 工具在 os 目录下的行为进行调整:现在默认会使用 riscv64gc 作为目标平台而不是原先的默认 x86_64-unknown-linux-gnu。
2.移除标准库依赖
我们希望它能够被编译到 RV64GC 裸机平台上,为此我们需要移除它对于 Rust std标准库的依赖,因为 Rust std标准库自己就需要操作系统内核的支持。
1 | # tuos/os/src/main.rs |
3.添加错误处理机制
Rust编译器在编译程序时,从安全性考虑,需要有 panic!
宏的具体实现。
1 | # os/src/lang_items.rs |
在把 panic_handler
配置在单独的文件 tuos/src/lang_items.rs
后,需要在os/src/main.rs文件中添加以下内容才能正常编译整个软件:
1 | # os/src/main.rs |
4.移除标准main函数
应用程序在执行之前进行一些初始化工作,然后才跳转到应用程序的入口点(也就是跳转到我们编写的 main
函数)开始执行。 start
语义项代表了标准库 std 在执行应用程序之前需要进行的一些初始化工作。由于我们禁用了标准库,编译器也就找不到这项功能的实现了。最简单的解决方案就是压根不让编译器使用这项功能。我们在 main.rs
的开头加入设置 #![no_main]
告诉编译器我们没有一般意义上的 main
函数,并将原来的 main
函数删除。在失去了 main
函数的情况下,编译器也就不需要完成所谓的初始化工作了。
1 | # os/src/main.rs |
至此,我们成功移除了标准库的依赖,并完成了构建裸机平台上操作系统的第一步工作–通过编译器检查并生成执行码。
1 | cargo build |
验证Qemu模拟器与执行环境是否正确可用
下面我们将实践在 Qemu 上执行内核的第一条指令,用以检验环境是否正确可用。
编写内核第一条指令
1 | os/src/entry.asm |
一般情况下,所有的代码都被放到一个名为 .text
的代码段中,这里我们将其命名为 .text.entry
从而区别于其他 .text
的目的在于我们想要确保该段被放置在相比任何其他代码段更低的地址上。这样,作为内核的入口点,这段指令才能被最先执行。
常见的 x86、RISC-V 等架构采用的是小端序。
将汇编代码嵌入main.rs
:
1 | # os/src/main.rs |
关于
main
函数和global_asm!
的执行顺序,重要的是理解它们的作用和上下文:
global_asm!: 这个宏用于在编译时嵌入汇编代码。这些汇编代码通常用于设置中断向量表、初始化硬件或其他低级设置。这些代码在二进制文件的开始或特定段中,并且在程序开始执行时就已经存在。它们并不是在运行时执行的代码,而是作为二进制文件的一部分存在。
main 函数: 在 Rust 程序中,
main
函数是程序的入口点。当程序开始执行时,它首先会跳转到main
函数。在main
函数之前,任何全局的初始化代码(包括通过global_asm!
嵌入的汇编代码)都应该已经完成。
调整内核的内存布局
由于链接器默认的内存布局并不能符合我们的要求,为了实现与 Qemu 正确对接,我们可以通过 链接脚本 (Linker Script) 调整链接器的行为,使得最终生成的可执行文件的内存布局符合Qemu的预期,即内核第一条指令的地址应该位于 0x80200000 。我们修改 Cargo 的配置文件来使用我们自己的链接脚本 os/src/linker.ld
而非使用默认的内存布局:
1 | os/.cargo/config |
- “-Clink-arg=-Tsrc/linker.ld” :告诉 Rust 编译器在链接阶段使用
src/linker.ld
作为链接器脚本- “-Cforce-frame-pointers=yes” : 强制编译器在生成的代码中包含帧指针。
链接脚本 os/src/linker.ld
如下:
1 | OUTPUT_ARCH(riscv) # 设置了目标平台为 riscv; |
第 1 行我们设置了目标平台为 riscv ;第 2 行我们设置了整个程序的入口点为之前定义的全局符号 _start
;
第 3 行定义了一个常量 BASE_ADDRESS
为 0x80200000
,也就是我们之前提到内核的初始化代码被放置的地址;
从第 5 行开始体现了链接过程中对输入的目标文件的段的合并。其中 .
表示当前地址,也就是链接器会从它指向的位置开始往下放置从输入的目标文件中收集来的段。我们可以对 .
进行赋值来调整接下来的段放在哪里,也可以创建一些全局符号赋值为 .
从而记录这一时刻的位置。我们还能够看到这样的格式:
1 | .rodata : { |
冒号前面表示最终生成的可执行文件的一个段的名字,花括号内按照放置顺序描述将所有输入目标文件的哪些段放在这个段中,每一行格式为 <ObjectFile>(SectionName)
,表示目标文件 ObjectFile
的名为 SectionName
的段需要被放进去。我们也可以使用通配符来书写 <ObjectFile>
和 <SectionName>
分别表示可能的输入目标文件和段名。因此,最终的合并结果是,在最终可执行文件中各个常见的段 .text, .rodata .data, .bss
从低地址到高地址按顺序放置,每个段里面都包括了所有输入目标文件的同名段,且每个段都有两个全局符号给出了它的开始和结束地址(比如 .text
段的开始和结束地址分别是 stext
和 etext
)。
第 12 行我们将包含内核第一条指令的 .text.entry
段放在最终的 .text
段的最开头,同时注意到在最终内存布局中代码段 .text
又是先于任何其他段的。因为所有的段都从 BASE_ADDRESS
也即 0x80200000
开始放置,这就能够保证内核的第一条指令正好放在 0x80200000
从而能够正确对接到 Qemu 上。
生成内核可执行文件,切换到 os
目录下进行以下操作:
1 | 以 release 模式生成了内核可执行文件 |
手动加载内核可执行文件
使用如下命令可以丢弃内核可执行文件中的元数据得到内核镜像:
1 | rust-objcopy --strip-all target/riscv64gc-unknown-none-elf/release/os -O binary target/riscv64gc-unknown-none-elf/release/os.bin |
cargo build --release
后得到的内核可执行文件完全符合我们对于内存布局的要求,但是我们不能将其直接提交给 Qemu ,因为它除了实际会被用到的代码和数据段之外还有一些多余的元数据,这些元数据无法被 Qemu 在加载文件时利用,且会使代码和数据段被加载到错误的位置。所以我们需要将其元数据丢弃
基于GDB验证启动流程
在 os
目录下通过以下命令启动 Qemu 并加载 RustSBI 和内核镜像:
1 | qemu-system-riscv64 \ |
-s
可以使 Qemu 监听本地 TCP 端口 1234 等待 GDB 客户端连接,而 -S
可以使 Qemu 在收到 GDB 的请求后再开始运行。因此,Qemu 暂时没有任何输出。注意,如果不想通过 GDB 对于 Qemu 进行调试而是直接运行 Qemu 的话,则要删掉最后一行的 -s -S
。
打开另一个终端,启动一个 GDB 客户端连接到 Qemu :
1 | riscv64-elf-gdb \ |
可以看到,正如我们在上一节提到的那样,Qemu 启动后 PC 被初始化为 0x1000
。我们可以检查一下 Qemu 的启动固件的内容:
1 | (gdb) x/10i $pc |
这里 x/10i $pc
的含义是从当前 PC 值的位置开始,在内存中反汇编 10 条指令。不过可以看到 Qemu 的固件仅包含 5 条指令,从 0x1018
开始都是数据,当数据为 0 的时候则会被反汇编为 unimp
指令。 0x101a
处的数据 0x8000
是能够跳转到 0x80000000
进入启动下一阶段的关键。总之,在执行位于 0x1014
的指令之前,寄存器 t0
的值恰好为 0x80000000
,随后通过 jr t0
便可以跳转到该地址。我们可以通过单步调试来复盘这个过程:
1 | (gdb) si |
其中, si
可以让 Qemu 每次向下执行一条指令,之后屏幕会打印出待执行的下一条指令的地址。 p/x $t0
以 16 进制打印寄存器 t0
的值,注意当我们要打印寄存器的时候需要在寄存器的名字前面加上 $
。可以看到,当位于 0x1010
的指令执行完毕后,下一条待执行的指令位于 RustSBI 的入口,也即 0x80000000
,这意味着我们即将把控制权转交给 RustSBI 。
1 | (gdb) x/10i $pc |
我们可以用同样的方式反汇编 RustSBI 最初的几条指令并单步调试。不过由于 RustSBI 超出了本书的范围,我们这里并不打算进行深入。接下来我们检查控制权能否被移交给我们的内核:
1 | (gdb) b *0x80200000 |
我们在内核的入口点,也即地址 0x80200000
处打一个断点。需要注意,当需要在一个特定的地址打断点时,需要在地址前面加上 *
。接下来通过 c
命令(Continue 的缩写)让 Qemu 向下运行直到遇到一个断点。可以看到,我们成功停在了 0x80200000
处。随后,可以检查内核第一条指令是否被正确执行:
1 | (gdb) x/5i $pc |
可以看到我们在 entry.asm
中编写的第一条指令可以在 0x80200000
处找到。这里 ra
是寄存器 x1
的别名, p/d $x1
可以以十进制打印寄存器 x1
的值,它的结果正确。最后,作为下一节的铺垫,我们可以检查此时栈指针 sp
的值,可以发现它目前是 0 。下一节我们将设置好栈空间,使得内核代码可以正常进行函数调用,随后将控制权转交给 Rust 代码。
设置栈空间以及内核初始化
分配栈空间
我们在 entry.asm
中分配启动栈空间,并在控制权被转交给 Rust 入口之前将栈指针 sp
设置为栈顶的位置。
1 | os/src/entry.asm |
综上所述,这段代码定义了程序的入口点
_start
,并设置了一个栈空间,栈的顶部是boot_stack_top
,底部是boot_stack_lower_bound
,总共 64KB。程序从_start
开始执行,首先设置栈指针,然后调用 Rust 编写的main
函数。
编写入口函数
1 | # os/src/main.rs |
对 .bss
段的清零
1 | // os/src/main.rs |
在函数
clear_bss
中,我们会尝试从其他地方找到全局符号sbss
和ebss
,它们由链接脚本linker.ld
给出,并分别指出需要被清零的.bss
段的起始和终止地址。接下来我们只需遍历该地址区间并逐字节进行清零即可。
使用RustSBI 实现字符打印功能
RustSBI 通过实现 SBI 标准,使得操作系统能够利用RISC-V处理器的指令集系统来执行各种操作。
调用 SBI 服务
在 Cargo.toml
中引入 sbi_rt 依赖来调用 SBI 服务的接口:
1 | # os/Cargo.toml |
我们将内核与 RustSBI 通信的相关功能实现在子模块 sbi
中,因此我们需要在 main.rs
中加入 mod sbi
将该子模块加入我们的项目。在 os/src/sbi.rs
中,我们直接调用 sbi_rt 提供的接口来将输出字符:
1 | # os/src/main.rs |
实现关机功能
实现关机功能:
1 | os/src/sbi.rs |
格式化输出
1 | # os/src/main.rs |
测试功能:
1 | 生成内核镜像 |
程序内存布局与编译流程
程序内存布局
在我们将源代码编译为可执行文件之后,它就会变成一个看似充满了杂乱无章的字节的一个文件。但我们知道这些字节至少可以分成代码和数据两部分,在程序运行起来的时候它们的功能并不相同:代码部分由一条条可以被 CPU 解码并执行的指令组成,而数据部分只是被 CPU 视作可读写的内存空间。事实上我们还可以根据其功能进一步把两个部分划分为更小的单位: 段 (Section) 。不同的段会被编译器放置在内存不同的位置上,这构成了程序的 内存布局 (Memory Layout)。一种典型的程序相对内存布局如下所示:
在上图中可以看到,代码部分只有代码段 .text
一个段,存放程序的所有汇编代码。而数据部分则还可以继续细化:
- 已初始化数据段保存程序中那些已初始化的全局数据,分为
.rodata
和.data
两部分。前者存放只读的全局数据,通常是一些常数或者是 常量字符串等;而后者存放可修改的全局数据。 - 未初始化数据段
.bss
保存程序中那些未初始化的全局数据,通常由程序的加载者代为进行零初始化,即将这块区域逐字节清零; - 堆 (heap)区域用来存放程序运行时动态分配的数据,如 C/C++ 中的 malloc/new 分配到的数据本体就放在堆区域,它向高地址增长;
- 栈 (stack)区域不仅用作函数调用上下文的保存与恢复,每个函数作用域内的局部变量也被编译器放在它的栈帧内,它向低地址增长。
局部变量与全局变量
在一个函数的视角中,它能够访问的变量包括以下几种:
- 函数的输入参数和局部变量:保存在一些寄存器或是该函数的栈帧里面,如果是在栈帧里面的话是基于当前栈指针加上一个偏移量来访问的;
- 全局变量:保存在数据段
.data
和.bss
中,某些情况下 gp(x3) 寄存器保存两个数据段中间的一个位置,于是全局变量是基于 gp 加上一个偏移量来访问的。- 堆上的动态变量:本体被保存在堆上,大小在运行时才能确定。而我们只能 直接 访问栈上或者全局数据段中的 编译期确定大小 的变量。因此我们需要通过一个运行时分配内存得到的一个指向堆上数据的指针来访问它,指针的位宽确实在编译期就能够确定。该指针即可以作为局部变量放在栈帧里面,也可以作为全局变量放在全局数据段中。
编译流程
从源代码得到可执行文件的编译流程可被细化为多个阶段(虽然输入一条命令便可将它们全部完成):
编译器 (Compiler) 将每个源文件从某门高级编程语言转化为汇编语言,注意此时源文件仍然是一个 ASCII 或其他编码的文本文件;
汇编器 (Assembler) 将上一步的每个源文件中的文本格式的指令转化为机器码,得到一个二进制的 目标文件 (Object File);
链接器 (Linker) 将上一步得到的所有目标文件以及一些可能的外部目标文件链接在一起形成一个完整的可执行文件。
汇编器输出的每个目标文件都有一个独立的程序内存布局,它描述了目标文件内各段所在的位置。而链接器所做的事情是将所有输入的目标文件整合成一个整体的内存布局。在此期间链接器主要完成两件事情:
- 第一件事情是将来自不同目标文件的段在目标内存布局中重新排布。如下图所示,在链接过程中,分别来自于目标文件
1.o
和2.o
段被按照段的功能进行分类,相同功能的段被排在一起放在拼装后的目标文件output.o
中。注意到,目标文件1.o
和2.o
的内存布局是存在冲突的,同一个地址在不同的内存布局中存放不同的内容。而在合并后的内存布局中,这些冲突被消除。
第二件事情是将符号替换为具体地址。这里的符号指什么呢?我们知道,在我们进行模块化编程的时候,每个模块都会提供一些向其他模块公开的全局变量、函数等供其他模块访问,也会访问其他模块向它公开的内容。要访问一个变量或者调用一个函数,在源代码级别我们只需知道它们的名字即可,这些名字被我们称为符号。取决于符号来自于模块内部还是其他模块,我们还可以进一步将符号分成内部符号和外部符号。然而,在机器码级别(也即在目标文件或可执行文件中)我们并不是通过符号来找到索引我们想要访问的变量或函数,而是直接通过变量或函数的地址。例如,如果想调用一个函数,那么在指令的机器码中我们可以找到函数入口的绝对地址或者相对于当前 PC 的相对地址。
那么,符号何时被替换为具体地址呢?因为符号对应的变量或函数都是放在某个段里面的固定位置(如全局变量往往放在
.bss
或者.data
段中,而函数则放在.text
段中),所以我们需要等待符号所在的段确定了它们在内存布局中的位置之后才能知道它们确切的地址。当一个模块被转化为目标文件之后,它的内部符号就已经在目标文件中被转化为具体的地址了,因为目标文件给出了模块的内存布局,也就意味着模块内的各个段的位置已经被确定了。然而,此时模块所用到的外部符号的地址无法确定。我们需要将这些外部符号记录下来,放在目标文件一个名为符号表(Symbol table)的区域内。由于后续可能还需要重定位,内部符号也同样需要被记录在符号表中。外部符号需要等到链接的时候才能被转化为具体地址。假设模块 1 用到了模块 2 提供的内容,当两个模块的目标文件链接到一起的时候,它们的内存布局会被合并,也就意味着两个模块的各个段的位置均被确定下来。此时,模块 1 用到的来自模块 2 的外部符号可以被转化为具体地址。同时我们还需要注意:两个模块的段在合并后的内存布局中被重新排布,其最终的位置有可能和它们在模块自身的局部内存布局中的位置相比已经发生了变化。因此,每个模块的内部符号的地址也有可能会发生变化,我们也需要进行修正。上面的过程被称为重定位(Relocation),这个过程形象一些来说很像拼图:由于模块 1 用到了模块 2 的内容,因此二者分别相当于一块凹进和凸出一部分的拼图,正因如此我们可以将它们无缝地拼接到一起。
上面我们简单介绍了程序内存布局和编译流程特别是链接过程的相关知识。那么如何得到一个能够在 Qemu 上成功运行的内核镜像呢?首先我们需要通过链接脚本调整内核可执行文件的内存布局,使得内核被执行的第一条指令位于地址 0x80200000
处,同时代码段所在的地址应低于其他段。这是因为 Qemu 物理内存中低于 0x80200000
的区域并未分配给内核,而是主要由 RustSBI 使用。其次,我们需要将内核可执行文件中的元数据丢掉得到内核镜像,此内核镜像仅包含实际会用到的代码和数据。这则是因为 Qemu 的加载功能过于简单直接,它直接将输入的文件逐字节拷贝到物理内存中,因此也可以说这一步是我们在帮助 Qemu 手动将可执行文件加载到物理内存中。下一节我们将成功生成内核镜像并在 Qemu 上验证控制权被转移到内核。