应用程序与基本执行环境

应用程序与基本执行环境

创建一个bin项目

1
2
3
4
5
$ mkdir tuos
$ cd tuos
$ cargo new os --bin
Creating binary (application) `os` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

移除执行环境对操作系统的依赖

1.添加目标平台

  1. 我们要构建一个可以在裸机上运行的 RISC-V架构的操作系统,那么就需要添加对目标平台的支持:
1
2
3
4
5
6
7
8
9
10
11
# tuos/os

$ rustup target add riscv64gc-unknown-none-elf
$ rustc --version --verbose
rustc 1.79.0-nightly (385fa9d84 2024-04-04)
binary: rustc
commit-hash: 385fa9d845dd326c6bbfd58c22244215e431948a
commit-date: 2024-04-04
host: x86_64-unknown-linux-gnu
release: 1.79.0-nightly
LLVM version: 18.1.2

这条命令有几个作用:

  1. 识别目标平台:

    • riscv64gc 表示 RISC-V 64位通用计算(General-Purpose Computing)架构( CPU 架构)。
    • unknown 表示 Rust 不了解这个目标的操作系统或供应商信息(CPU 厂商)。
    • none 表示没有操作系统,这通常用于裸机编程(操作系统)。
    • elf 表示没有标准的运行时库(没有任何系统调用的封装支持),但可以生成 ELF 格式的执行程序(运行时库)。
  2. 安装必要组件:如果 rustup 有为 riscv64gc-unknown-none-elf 准备的预编译组件,它会尝试下载并安装这些组件。

  3. 更新Rust工具链配置:在 Rust 配置中添加对新目标的支持,这样当你使用 cargo build --target riscv64gc-unknown-none-elf 命令时,Cargo 就会知道如何为 riscv64gc-unknown-none-elf 目标构建你的项目。

可以使用如下命令查看当前Rust工具链支持的所有目标平台:

1
$ rustup target list

2.在 tuos 目录下新建 .cargo 目录,并在这个目录下创建 config 文件,并在里面输入如下内容:

1
2
3
4
#  tuos/os/.cargo/config

[build]
target = "riscv64gc-unknown-none-elf"

这会对于 Cargo 工具在 os 目录下的行为进行调整:现在默认会使用 riscv64gc 作为目标平台而不是原先的默认 x86_64-unknown-linux-gnu。

2.移除标准库依赖

我们希望它能够被编译到 RV64GC 裸机平台上,为此我们需要移除它对于 Rust std标准库的依赖,因为 Rust std标准库自己就需要操作系统内核的支持。

1
2
3
4
# tuos/os/src/main.rs
// 告诉 Rust 编译器不使用 Rust 标准库 std 转而使用核心库 core(core库不需要操作系统的支持);
#![no_std]
// 注释掉println!宏,它是由标准库 std 提供的且会使用到一个名为 write 的系统调用;

3.添加错误处理机制

Rust编译器在编译程序时,从安全性考虑,需要有 panic! 宏的具体实现。

1
2
3
4
5
6
7
8
# os/src/lang_items.rs

use core::panic::PanicInfo;
// 指定一个函数作为程序的 panic 处理程序。
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}

在把 panic_handler 配置在单独的文件 tuos/src/lang_items.rs 后,需要在os/src/main.rs文件中添加以下内容才能正常编译整个软件:

1
2
3
4
5
6
7
# os/src/main.rs

// 移除标准库(std)依赖
#![no_std]

// 导入外部模块,内容为对panic!的具体实现
mod lang_items;

4.移除标准main函数

应用程序在执行之前进行一些初始化工作,然后才跳转到应用程序的入口点(也就是跳转到我们编写的 main 函数)开始执行。 start 语义项代表了标准库 std 在执行应用程序之前需要进行的一些初始化工作。由于我们禁用了标准库,编译器也就找不到这项功能的实现了。最简单的解决方案就是压根不让编译器使用这项功能。我们在 main.rs 的开头加入设置 #![no_main] 告诉编译器我们没有一般意义上的 main 函数,并将原来的 main 函数删除。在失去了 main 函数的情况下,编译器也就不需要完成所谓的初始化工作了。

1
2
3
4
5
6
7
8
9
# os/src/main.rs

// 移除标准库(std)依赖
#![no_std]
// 禁用标准main函数
#![no_main]
// 导入外部模块,内容为对panic!的具体实现
mod lang_items;
// 删掉 自带的main函数

至此,我们成功移除了标准库的依赖,并完成了构建裸机平台上操作系统的第一步工作–通过编译器检查并生成执行码。

1
2
3
4
5
$ cargo build
warning: `/home/kay/workspace/tuos/os/.cargo/config` is deprecated in favor of `config.toml`
note: if you need to support cargo 1.38 or earlier, you can symlink `config` to `config.toml`
Compiling os v0.1.0 (/home/kay/workspace/tuos/os)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.06s

验证Qemu模拟器与执行环境是否正确可用

下面我们将实践在 Qemu 上执行内核的第一条指令,用以检验环境是否正确可用。

编写内核第一条指令

1
2
3
4
5
# os/src/entry.asm
.section .text.entry # .section: 定义或切换到一个特定的代码段 .text.enery代码段
.globl _start # 告知编译器 _start 是一个全局符号,可以被其他目标文件使用
_start: # 全局符号,指向下面的(li x1, 100)
li x1, 100 # 立即数 100 被加载到了寄存器 x1 中

一般情况下,所有的代码都被放到一个名为 .text 的代码段中,这里我们将其命名为 .text.entry 从而区别于其他 .text 的目的在于我们想要确保该段被放置在相比任何其他代码段更低的地址上。这样,作为内核的入口点,这段指令才能被最先执行。

常见的 x86、RISC-V 等架构采用的是小端序。

将汇编代码嵌入main.rs

1
2
3
4
5
6
7
8
9
10
11
# os/src/main.rs
// 移除标准库
#![no_std]
// 移除main函数
#![no_main]
// 载入模块
mod lang_items;
// 引用模块
use core::arch::global_asm;
// global_asm!宏:嵌入全局汇编代码;include_str!宏:文件的内容作为一个字符串嵌入到程序中;
global_asm!(include_str!("entry.asm"));

关于 main 函数和 global_asm! 的执行顺序,重要的是理解它们的作用和上下文:

  • global_asm!: 这个宏用于在编译时嵌入汇编代码。这些汇编代码通常用于设置中断向量表、初始化硬件或其他低级设置。这些代码在二进制文件的开始或特定段中,并且在程序开始执行时就已经存在。它们并不是在运行时执行的代码,而是作为二进制文件的一部分存在

  • main 函数: 在 Rust 程序中,main 函数是程序的入口点。当程序开始执行时,它首先会跳转到 main 函数。main 函数之前,任何全局的初始化代码(包括通过 global_asm! 嵌入的汇编代码)都应该已经完成

调整内核的内存布局

由于链接器默认的内存布局并不能符合我们的要求,为了实现与 Qemu 正确对接,我们可以通过 链接脚本 (Linker Script) 调整链接器的行为,使得最终生成的可执行文件的内存布局符合Qemu的预期,即内核第一条指令的地址应该位于 0x80200000 。我们修改 Cargo 的配置文件来使用我们自己的链接脚本 os/src/linker.ld 而非使用默认的内存布局:

1
2
3
4
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"
]
  • “-Clink-arg=-Tsrc/linker.ld” :告诉 Rust 编译器在链接阶段使用 src/linker.ld 作为链接器脚本
  • “-Cforce-frame-pointers=yes” : 强制编译器在生成的代码中包含帧指针。

链接脚本 os/src/linker.ld 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
OUTPUT_ARCH(riscv)	# 设置了目标平台为 riscv;
ENTRY(_start) # 设置了整个程序的入口点为之前定义的全局符号 _start;
BASE_ADDRESS = 0x80200000; # 定义了一个常量 BASE_ADDRESS 为 0x80200000 ,内核的初始化时的地址;

SECTIONS
{
. = BASE_ADDRESS;
skernel = .;

stext = .;
.text : {
*(.text.entry)
*(.text .text.*)
}

. = ALIGN(4K);
etext = .;
srodata = .;
.rodata : {
*(.rodata .rodata.*)
*(.srodata .srodata.*)
}

. = ALIGN(4K);
erodata = .;
sdata = .;
.data : {
*(.data .data.*)
*(.sdata .sdata.*)
}

. = ALIGN(4K);
edata = .;
.bss : {
*(.bss.stack)
sbss = .;
*(.bss .bss.*)
*(.sbss .sbss.*)
}

. = ALIGN(4K);
ebss = .;
ekernel = .;

/DISCARD/ : {
*(.eh_frame)
}
}

第 1 行我们设置了目标平台为 riscv ;第 2 行我们设置了整个程序的入口点为之前定义的全局符号 _start

第 3 行定义了一个常量 BASE_ADDRESS0x80200000 ,也就是我们之前提到内核的初始化代码被放置的地址;

从第 5 行开始体现了链接过程中对输入的目标文件的段的合并。其中 . 表示当前地址,也就是链接器会从它指向的位置开始往下放置从输入的目标文件中收集来的段。我们可以对 . 进行赋值来调整接下来的段放在哪里,也可以创建一些全局符号赋值为 . 从而记录这一时刻的位置。我们还能够看到这样的格式:

1
2
3
.rodata : {
*(.rodata)
}

冒号前面表示最终生成的可执行文件的一个段的名字,花括号内按照放置顺序描述将所有输入目标文件的哪些段放在这个段中,每一行格式为 <ObjectFile>(SectionName),表示目标文件 ObjectFile 的名为 SectionName 的段需要被放进去。我们也可以使用通配符来书写 <ObjectFile><SectionName> 分别表示可能的输入目标文件和段名。因此,最终的合并结果是,在最终可执行文件中各个常见的段 .text, .rodata .data, .bss 从低地址到高地址按顺序放置,每个段里面都包括了所有输入目标文件的同名段,且每个段都有两个全局符号给出了它的开始和结束地址(比如 .text 段的开始和结束地址分别是 stextetext )。

第 12 行我们将包含内核第一条指令的 .text.entry 段放在最终的 .text 段的最开头,同时注意到在最终内存布局中代码段 .text 又是先于任何其他段的。因为所有的段都从 BASE_ADDRESS 也即 0x80200000 开始放置,这就能够保证内核的第一条指令正好放在 0x80200000 从而能够正确对接到 Qemu 上。

生成内核可执行文件,切换到 os 目录下进行以下操作:

1
2
3
4
5
6
7
8
9
10
11
# 以 release 模式生成了内核可执行文件

$ cargo build --release
warning: `/home/kay/workspace/tuos/os/.cargo/config` is deprecated in favor of `config.toml`
note: if you need to support cargo 1.38 or earlier, you can symlink `config` to `config.toml`
Compiling os v0.1.0 (/home/kay/workspace/tuos/os)
Finished `release` profile [optimized] target(s) in 0.06s

# 通过 file 工具查看它的属性
$ file target/riscv64gc-unknown-none-elf/release/os
target/riscv64gc-unknown-none-elf/release/os: ELF 64-bit LSB executable, UCB RISC-V, RVC, double-float ABI, version 1 (SYSV), statically linked, not stripped

手动加载内核可执行文件

使用如下命令可以丢弃内核可执行文件中的元数据得到内核镜像:

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
2
3
4
5
6
$ qemu-system-riscv64 \
-machine virt \
-nographic \
-bios ../bootloader/rustsbi-qemu.bin \
-device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000 \
-s -S

-s 可以使 Qemu 监听本地 TCP 端口 1234 等待 GDB 客户端连接,而 -S 可以使 Qemu 在收到 GDB 的请求后再开始运行。因此,Qemu 暂时没有任何输出。注意,如果不想通过 GDB 对于 Qemu 进行调试而是直接运行 Qemu 的话,则要删掉最后一行的 -s -S

打开另一个终端,启动一个 GDB 客户端连接到 Qemu :

1
2
3
4
5
6
$ riscv64-elf-gdb \
-ex 'file target/riscv64gc-unknown-none-elf/release/os' \
-ex 'set arch riscv:rv64' \
-ex 'target remote localhost:1234'
[GDB output]
0x0000000000001000 in ?? ()

可以看到,正如我们在上一节提到的那样,Qemu 启动后 PC 被初始化为 0x1000 。我们可以检查一下 Qemu 的启动固件的内容:

1
2
3
4
5
6
7
8
9
10
11
$ (gdb) x/10i $pc
=> 0x1000: auipc t0,0x0
0x1004: add a2,t0,40
0x1008: csrr a0,mhartid
0x100c: ld a1,32(t0)
0x1010: ld t0,24(t0)
0x1014: jr t0
0x1018: unimp
0x101a: .2byte 0x8000
0x101c: unimp
0x101e: unimp

这里 x/10i $pc 的含义是从当前 PC 值的位置开始,在内存中反汇编 10 条指令。不过可以看到 Qemu 的固件仅包含 5 条指令,从 0x1018 开始都是数据,当数据为 0 的时候则会被反汇编为 unimp 指令。 0x101a 处的数据 0x8000 是能够跳转到 0x80000000 进入启动下一阶段的关键。总之,在执行位于 0x1014 的指令之前,寄存器 t0 的值恰好为 0x80000000 ,随后通过 jr t0 便可以跳转到该地址。我们可以通过单步调试来复盘这个过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ (gdb) si
0x0000000000001004 in ?? ()
$ (gdb) si
0x0000000000001008 in ?? ()
$ (gdb) si
0x000000000000100c in ?? ()
$ (gdb) si
0x0000000000001010 in ?? ()
$ (gdb) si
0x0000000000001010 in ?? ()
$ (gdb) p/x $t0
$1 = 0x80000000
$ (gdb) si
0x0000000080000000 in ?? ()

其中, si 可以让 Qemu 每次向下执行一条指令,之后屏幕会打印出待执行的下一条指令的地址。 p/x $t0 以 16 进制打印寄存器 t0 的值,注意当我们要打印寄存器的时候需要在寄存器的名字前面加上 $ 。可以看到,当位于 0x1010 的指令执行完毕后,下一条待执行的指令位于 RustSBI 的入口,也即 0x80000000 ,这意味着我们即将把控制权转交给 RustSBI 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(gdb) x/10i $pc
=> 0x80000000: auipc sp,0x29
0x80000004: add sp,sp,-200
0x80000008: lui t0,0x4
0x8000000a: add t1,a0,1
0x8000000e: add sp,sp,t0
0x80000010: add t1,t1,-1
0x80000012: bnez t1,0x8000000e
0x80000016: j 0x80015e0c
0x8000001a: unimp
0x8000001c: add sp,sp,-32
$ (gdb) si
0x0000000080000004 in ?? ()
$ (gdb) si
0x0000000080000008 in ?? ()
$ (gdb) si
0x000000008000000a in ?? ()
$ (gdb) si
0x000000008000000e in ?? ()

我们可以用同样的方式反汇编 RustSBI 最初的几条指令并单步调试。不过由于 RustSBI 超出了本书的范围,我们这里并不打算进行深入。接下来我们检查控制权能否被移交给我们的内核:

1
2
3
4
5
6
$ (gdb) b *0x80200000
Breakpoint 1 at 0x80200000
$ (gdb) c
Continuing.

Breakpoint 1, 0x0000000080200000 in ?? ()

我们在内核的入口点,也即地址 0x80200000 处打一个断点。需要注意,当需要在一个特定的地址打断点时,需要在地址前面加上 * 。接下来通过 c 命令(Continue 的缩写)让 Qemu 向下运行直到遇到一个断点。可以看到,我们成功停在了 0x80200000 处。随后,可以检查内核第一条指令是否被正确执行:

1
2
3
4
5
6
7
8
9
10
11
12
(gdb) x/5i $pc
=> 0x80200000: li ra,100
0x80200004: unimp
0x80200006: unimp
0x80200008: unimp
0x8020000a: unimp
$ (gdb) si
0x0000000080200004 in ?? ()
$ (gdb) p/d $x1
$2 = 100
$ (gdb) p/x $sp
$3 = 0x0

可以看到我们在 entry.asm 中编写的第一条指令可以在 0x80200000 处找到。这里 ra 是寄存器 x1 的别名, p/d $x1 可以以十进制打印寄存器 x1 的值,它的结果正确。最后,作为下一节的铺垫,我们可以检查此时栈指针 sp 的值,可以发现它目前是 0 。下一节我们将设置好栈空间,使得内核代码可以正常进行函数调用,随后将控制权转交给 Rust 代码。

设置栈空间以及内核初始化

分配栈空间

我们在 entry.asm 中分配启动栈空间,并在控制权被转交给 Rust 入口之前将栈指针 sp 设置为栈顶的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# os/src/entry.asm

.section .text.entry # 定义一个段
.globl _start # 定义一个全局符号
_start: # 程序入口
la sp, boot_stack_top # 立即将 boot_stack_top 的地址加载到栈指针 sp 中
call main # 调用一个名为main的子程序

.section .bss.stack
.globl boot_stack_lower_bound
boot_stack_lower_bound:
.space 4096 * 16 # 为数据段分配一定数量的空间。
.globl boot_stack_top
boot_stack_top: # 表示为栈顶 ,上文_start程序入口加载的位置

综上所述,这段代码定义了程序的入口点 _start,并设置了一个栈空间,栈的顶部是 boot_stack_top,底部是 boot_stack_lower_bound,总共 64KB。程序从 _start 开始执行,首先设置栈指针,然后调用 Rust 编写的 main 函数。

编写入口函数

1
2
3
4
5
6
7
8
# os/src/main.rs

// 不要对该函数名进行修饰,以便其他语言就可以通过该原始名称来链接和调用 Rust 函数或访问 Rust 变量。
// 这里面的main名字要与汇编代码中指定的入口子函数名称一致
#[no_mangle]
pub fn main() -> ! {
loop {}
}

.bss 段的清零

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// os/src/main.rs

#[no_mangle]
pub fn rust_main() -> ! {
clear_bss();
loop {}
}

// 对.bss段清零
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) }
});
}

在函数 clear_bss 中,我们会尝试从其他地方找到全局符号 sbssebss ,它们由链接脚本 linker.ld 给出,并分别指出需要被清零的 .bss 段的起始和终止地址。接下来我们只需遍历该地址区间并逐字节进行清零即可。

使用RustSBI 实现字符打印功能

RustSBI 通过实现 SBI 标准,使得操作系统能够利用RISC-V处理器的指令集系统来执行各种操作。

调用 SBI 服务

Cargo.toml 中引入 sbi_rt 依赖来调用 SBI 服务的接口:

1
2
3
4
5
6
7
# os/Cargo.toml

[package]
edition = "2018" # 因为兼容问题Qemu9.0.0在进行后续学习时候或出错,故将版次改为 2018

[dependencies]
sbi-rt = { version = "0.0.2", features = ["legacy"] }

我们将内核与 RustSBI 通信的相关功能实现在子模块 sbi 中,因此我们需要在 main.rs 中加入 mod sbi 将该子模块加入我们的项目。在 os/src/sbi.rs 中,我们直接调用 sbi_rt 提供的接口来将输出字符:

1
2
3
4
5
6
7
8
9
# os/src/main.rs
mod sbi;

# os/src/sbi.rs
pub fn console_putchar(c: usize) {
// #[allow(deprecated)]属性来禁止编译器发出警告,对于某些已经弃用的方法。
#[allow(deprecated)]
sbi_rt::legacy::console_putchar(c);
}

实现关机功能

实现关机功能:

1
2
3
4
5
6
7
8
9
10
# os/src/sbi.rs
pub fn shutdown(failure: bool) -> ! {
use sbi_rt::{system_reset, NoReason, Shutdown, SystemFailure};
if !failure {
system_reset(Shutdown, NoReason);
} else {
system_reset(Shutdown, SystemFailure);
}
unreachable!()
}

格式化输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# os/src/main.rs

#[macro_use]
mod console;

// os/src/console.rs
use crate::sbi::console_putchar;
use core::fmt::{self, Write};

struct Stdout;

impl Write for Stdout {
fn write_str(&mut self, s: &str) -> fmt::Result {
for c in s.chars() {
console_putchar(c as usize);
}
Ok(())
}
}

pub fn print(args: fmt::Arguments) {
Stdout.write_fmt(args).unwrap();
}

#[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)+)?) => {
$crate::console::print(format_args!(concat!($fmt, "\n") $(, $($arg)+)?));
}
}

测试功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 生成内核镜像
$ rust-objcopy --strip-all target/riscv64gc-unknown-none-elf/release/os -O binary target/riscv64gc-unknown-none-elf/release/os.bin

# 启动Qemu模拟器
$ qemu-system-riscv64 \
-machine virt \
-nographic \
-bios ../bootloader/rustsbi-qemu.bin \
-device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000

# 运行结果如图:
[rustsbi] RustSBI version 0.4.0-alpha.1, adapting to RISC-V SBI v2.0.0
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Implementation : RustSBI-QEMU Version 0.2.0-alpha.3
[rustsbi] Platform Name : riscv-virtio,qemu
[rustsbi] Platform SMP : 1
[rustsbi] Platform Memory : 0x80000000..0x88000000
[rustsbi] Boot HART : 0
[rustsbi] Device Tree Region : 0x87e00000..0x87e01290
[rustsbi] Firmware Address : 0x80000000
[rustsbi] Supervisor Address : 0x80200000
[rustsbi] pmp01: 0x00000000..0x80000000 (-wr)
[rustsbi] pmp02: 0x80000000..0x80200000 (---)
[rustsbi] pmp03: 0x80200000..0x88000000 (xwr)
[rustsbi] pmp04: 0x88000000..0x00000000 (-wr)
Hello, world!
Panicked at src/main.rs:23 Shutdown machine!

程序内存布局与编译流程

程序内存布局

在我们将源代码编译为可执行文件之后,它就会变成一个看似充满了杂乱无章的字节的一个文件。但我们知道这些字节至少可以分成代码和数据两部分,在程序运行起来的时候它们的功能并不相同:代码部分由一条条可以被 CPU 解码并执行的指令组成,而数据部分只是被 CPU 视作可读写的内存空间。事实上我们还可以根据其功能进一步把两个部分划分为更小的单位: (Section) 。不同的段会被编译器放置在内存不同的位置上,这构成了程序的 内存布局 (Memory Layout)。一种典型的程序相对内存布局如下所示:

MemoryLayout

在上图中可以看到,代码部分只有代码段 .text 一个段,存放程序的所有汇编代码。而数据部分则还可以继续细化:

  • 已初始化数据段保存程序中那些已初始化的全局数据,分为 .rodata.data 两部分。前者存放只读的全局数据,通常是一些常数或者是 常量字符串等;而后者存放可修改的全局数据。
  • 未初始化数据段 .bss 保存程序中那些未初始化的全局数据,通常由程序的加载者代为进行零初始化,即将这块区域逐字节清零;
  • (heap)区域用来存放程序运行时动态分配的数据,如 C/C++ 中的 malloc/new 分配到的数据本体就放在堆区域,它向高地址增长;
  • (stack)区域不仅用作函数调用上下文的保存与恢复,每个函数作用域内的局部变量也被编译器放在它的栈帧内,它向低地址增长。

局部变量与全局变量

在一个函数的视角中,它能够访问的变量包括以下几种:

  • 函数的输入参数和局部变量:保存在一些寄存器或是该函数的栈帧里面,如果是在栈帧里面的话是基于当前栈指针加上一个偏移量来访问的;
  • 全局变量:保存在数据段 .data.bss 中,某些情况下 gp(x3) 寄存器保存两个数据段中间的一个位置,于是全局变量是基于 gp 加上一个偏移量来访问的。
  • 堆上的动态变量:本体被保存在堆上,大小在运行时才能确定。而我们只能 直接 访问栈上或者全局数据段中的 编译期确定大小 的变量。因此我们需要通过一个运行时分配内存得到的一个指向堆上数据的指针来访问它,指针的位宽确实在编译期就能够确定。该指针即可以作为局部变量放在栈帧里面,也可以作为全局变量放在全局数据段中。

编译流程

从源代码得到可执行文件的编译流程可被细化为多个阶段(虽然输入一条命令便可将它们全部完成):

  • 编译器 (Compiler) 将每个源文件从某门高级编程语言转化为汇编语言,注意此时源文件仍然是一个 ASCII 或其他编码的文本文件;

  • 汇编器 (Assembler) 将上一步的每个源文件中的文本格式的指令转化为机器码,得到一个二进制的 目标文件 (Object File);

  • 链接器 (Linker) 将上一步得到的所有目标文件以及一些可能的外部目标文件链接在一起形成一个完整的可执行文件。

汇编器输出的每个目标文件都有一个独立的程序内存布局,它描述了目标文件内各段所在的位置。而链接器所做的事情是将所有输入的目标文件整合成一个整体的内存布局。在此期间链接器主要完成两件事情:

  • 第一件事情是将来自不同目标文件的段在目标内存布局中重新排布。如下图所示,在链接过程中,分别来自于目标文件 1.o2.o 段被按照段的功能进行分类,相同功能的段被排在一起放在拼装后的目标文件 output.o 中。注意到,目标文件 1.o2.o 的内存布局是存在冲突的,同一个地址在不同的内存布局中存放不同的内容。而在合并后的内存布局中,这些冲突被消除。

MemoryLayout-Linker

  • 第二件事情是将符号替换为具体地址。这里的符号指什么呢?我们知道,在我们进行模块化编程的时候,每个模块都会提供一些向其他模块公开的全局变量、函数等供其他模块访问,也会访问其他模块向它公开的内容。要访问一个变量或者调用一个函数,在源代码级别我们只需知道它们的名字即可,这些名字被我们称为符号。取决于符号来自于模块内部还是其他模块,我们还可以进一步将符号分成内部符号和外部符号。然而,在机器码级别(也即在目标文件或可执行文件中)我们并不是通过符号来找到索引我们想要访问的变量或函数,而是直接通过变量或函数的地址。例如,如果想调用一个函数,那么在指令的机器码中我们可以找到函数入口的绝对地址或者相对于当前 PC 的相对地址。

    那么,符号何时被替换为具体地址呢?因为符号对应的变量或函数都是放在某个段里面的固定位置(如全局变量往往放在 .bss 或者 .data 段中,而函数则放在 .text 段中),所以我们需要等待符号所在的段确定了它们在内存布局中的位置之后才能知道它们确切的地址。当一个模块被转化为目标文件之后,它的内部符号就已经在目标文件中被转化为具体的地址了,因为目标文件给出了模块的内存布局,也就意味着模块内的各个段的位置已经被确定了。然而,此时模块所用到的外部符号的地址无法确定。我们需要将这些外部符号记录下来,放在目标文件一个名为符号表(Symbol table)的区域内。由于后续可能还需要重定位,内部符号也同样需要被记录在符号表中。

    外部符号需要等到链接的时候才能被转化为具体地址。假设模块 1 用到了模块 2 提供的内容,当两个模块的目标文件链接到一起的时候,它们的内存布局会被合并,也就意味着两个模块的各个段的位置均被确定下来。此时,模块 1 用到的来自模块 2 的外部符号可以被转化为具体地址。同时我们还需要注意:两个模块的段在合并后的内存布局中被重新排布,其最终的位置有可能和它们在模块自身的局部内存布局中的位置相比已经发生了变化。因此,每个模块的内部符号的地址也有可能会发生变化,我们也需要进行修正。上面的过程被称为重定位(Relocation),这个过程形象一些来说很像拼图:由于模块 1 用到了模块 2 的内容,因此二者分别相当于一块凹进和凸出一部分的拼图,正因如此我们可以将它们无缝地拼接到一起。

上面我们简单介绍了程序内存布局和编译流程特别是链接过程的相关知识。那么如何得到一个能够在 Qemu 上成功运行的内核镜像呢?首先我们需要通过链接脚本调整内核可执行文件的内存布局,使得内核被执行的第一条指令位于地址 0x80200000 处,同时代码段所在的地址应低于其他段。这是因为 Qemu 物理内存中低于 0x80200000 的区域并未分配给内核,而是主要由 RustSBI 使用。其次,我们需要将内核可执行文件中的元数据丢掉得到内核镜像,此内核镜像仅包含实际会用到的代码和数据。这则是因为 Qemu 的加载功能过于简单直接,它直接将输入的文件逐字节拷贝到物理内存中,因此也可以说这一步是我们在帮助 Qemu 手动将可执行文件加载到物理内存中。下一节我们将成功生成内核镜像并在 Qemu 上验证控制权被转移到内核。