了解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
-machine virt
表示将模拟的 64 位 RISC-V 计算机设置为名为virt
的虚拟计算机。我们知道,即使同属同一种指令集架构,也会有很多种不同的计算机配置,比如 CPU 的生产厂商和型号不同,支持的 I/O 外设种类也不同。Qemu 还支持模拟其他 RISC-V 计算机,其中包括由 SiFive 公司生产的著名的 HiFive Unleashed 开发板。-nographic
表示模拟器不需要提供图形界面,而只需要对外输出字符流。- 通过
-bios
可以设置 Qemu 模拟器开机时用来初始化的引导加载程序(bootloader),这里我们使用预编译好的rustsbi-qemu.bin
,它需要被放在与os
同级的bootloader
目录下,该目录可以从每一章的代码分支中获得。 - 通过虚拟设备
-device
中的loader
属性可以在 Qemu 模拟器开机之前将一个宿主机上的文件载入到 Qemu 的物理内存的指定位置中,file
和addr
属性分别可以设置待载入文件的路径以及将文件载入到的 Qemu 物理内存上的物理地址。这里我们载入的os.bin
被称为 内核镜像 ,它会被载入到 Qemu 模拟器内存的0x80200000
地址处。 那么内核镜像os.bin
是怎么来的呢?上一节中我们移除标准库依赖后会得到一个内核可执行文件os
,将其进一步处理就能得到os.bin
,具体处理流程我们会在后面深入讨论。
QEMU启动流程
^6e433b
这里主要参考官方文档,只提到一点非常有意义的,
在Qemu模拟的 virt
硬件平台上,物理内存的起始物理地址为 0x80000000
,物理内存的默认大小为 128MiB ,它可以通过 -m
选项进行配置。如果使用默认配置的 128MiB 物理内存则对应的物理地址区间为 [0x80000000,0x88000000)
。如果使用上面给出的命令启动 Qemu ,那么在 Qemu 开始执行任何指令之前,首先把两个文件加载到 Qemu 的物理内存中:即作把作为 bootloader 的 rustsbi-qemu.bin
加载到物理内存以物理地址 0x80000000
开头的区域上,同时把内核镜像 os.bin
加载到以物理地址 0x80200000
开头的区域上。
真实计算机的启动流程
这里考虑到关于STM32的知识你是非常熟悉的,所以可以直接拿出来说.
- 第一阶段:加电后 CPU 的 PC 寄存器被设置为计算机内部只读存储器(ROM,Read-only Memory)的物理地址,随后 CPU 开始运行 ROM 内的软件。我们一般将该软件称为固件(Firmware),它的功能是对 CPU 进行一些初始化操作,将后续阶段的 bootloader 的代码、数据从硬盘载入到物理内存,最后跳转到适当的地址将计算机控制权转移给 bootloader 。它大致对应于 Qemu 启动的第一阶段,即在物理地址
0x1000
处放置的若干条指令。可以看到 Qemu 上的固件非常简单,因为它并不需要负责将 bootloader 从硬盘加载到物理内存中,这个任务此前已经由 Qemu 自身完成了。 - 第二阶段:bootloader 同样完成一些 CPU 的初始化工作,将操作系统镜像从硬盘加载到物理内存中,最后跳转到适当地址将控制权转移给操作系统。可以看到一般情况下 bootloader 需要完成一些数据加载工作,这也就是它名字中 loader 的来源。它对应于 Qemu 启动的第二阶段。在 Qemu 中,我们使用的 RustSBI 功能较弱,它并没有能力完成加载的工作,内核镜像实际上是和 bootloader 一起在 Qemu 启动之前加载到物理内存中的。
- 第三阶段:控制权被转移给操作系统。由于篇幅所限后面我们就不再赘述了。
程序内存布局和编译流程
这里也主要参考官方文档,考虑自己曾经学过的51单片机的知识,想一下当时51的RAM区的存储,直接恍然大悟.
主要关注编译器的三个部分的各自的作用.
编写第一条内核指令
asm
汇编文件
在src
下创建entry.asm
.
# os/src/entry.asm
.section .text.entry
.globl _start
_start:
li x1, 100
^932692
主要作用是:将寄存器 x1
赋值为 100
.
更具体的作用要看官方文档:
实际的指令位于第 5 行,也即 li x1, 100
。 li
是 Load Immediate 的缩写,也即将一个立即数加载到某个寄存器,因此这条指令可以看做将寄存器 x1
赋值为 100
。第 4 行我们声明了一个符号 _start
,该符号指向紧跟在符号后面的内容——也就是位于第 5 行的指令,因此符号 _start
的地址即为第 5 行的指令所在的地址。第 3 行我们告知编译器 _start
是一个全局符号,因此可以被其他目标文件使用。第 2 行表明我们希望将第 2 行后面的内容全部放到一个名为 .text.entry
的段中。一般情况下,所有的代码都被放到一个名为 .text
的代码段中,这里我们将其命名为 .text.entry
从而区别于其他 .text
的目的在于我们想要确保该段被放置在相比任何其他代码段更低的地址上。这样,作为内核的入口点,这段指令才能被最先执行。
在main.rs
中插入这个命令
当前main.rs
的内容为:
// os/src/main.rs
#![no_std]
#![no_main]
mod lang_items;
use core::arch::global_asm;
global_asm!(include_str!("entry.asm"));
这里同样引用文档中比较重要的一句:
第 8 行,我们通过 include_str!
宏将同目录下的汇编代码 entry.asm
转化为字符串并通过 global_asm!
宏嵌入到代码中。
调整内核的内存布局
由于QEMU要求第一条内核指令在0x80200000
,默认的链接器无法完成任务.因此需要:
- 设置链接器行为,要求使用自己的链接脚本
- 编辑自己的链接脚本
链接器设置
调整.cargo/config
的内容:
# os/.cargo/config
[build]
target = "riscv64gc-unknown-none-elf"
[target.riscv64gc-unknown-none-elf]
rustflags = [
"-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
]
detail
[target.riscv64gc-unknown-none-elf]
: 这是一个条件编译配置块,意味着只有当为目标riscv64gc-unknown-none-elf
构建时,这里面的设置才会生效。这个目标与RISC-V 64位架构的嵌入式或裸机系统相关,其中gc
代表通用客户机配置,unknown
表明没有指定操作系统,而none-elf
表示目标文件格式为ELF,且不依赖于任何操作系统环境。rustflags = [ ... ]
: 在这个配置块内,rustflags
是一个数组,包含了传递给Rust编译器(rustc
)的额外命令行参数。这些参数可以用来控制编译过程中的各种行为,包括链接器选项、代码生成选项等。-Clink-arg=-Tsrc/linker.ld
:-C
: 是一个标志,告诉Rust编译器接下来的参数是“代码生成”(codegen) 相关的选项。link-arg
: 是一个codegen选项,用于向链接器传递额外的参数。-Tsrc/linker.ld
: 这个参数指定了一个自定义的链接脚本路径(src/linker.ld
)。链接脚本定义了如何将编译和汇编后产生的目标文件组合成最终的可执行文件或库,包括内存布局、段的排列顺序等。对于嵌入式开发尤为重要,因为它允许精细控制程序在内存中的布局,特别是对于没有操作系统的环境,需要手动管理内存映射。
-Cforce-frame-pointers=yes
:-Cforce-frame-pointers
: 是另一个codegen选项,用于强制编译器在生成的代码中包含帧指针(frame pointers)。yes
: 表示启用此功能。帧指针可以帮助调试,因为它提供了函数调用栈的直接访问路径,使得在调试时可以更容易地跟踪局部变量和函数调用序列。在某些优化级别下,编译器可能会省略帧指针以减少代码大小或提高运行速度,但对某些调试场景不太友好。
链接脚本
在src
文件夹下创建linker.ld
,内容为:
OUTPUT_ARCH(riscv)
ENTRY(_start)
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)
}
}
detail
详细解析看官方文档
生成内核可执行文件
cargo build --release
用file
观察这个文件
file target/riscv64gc-unknown-none-elf/release/os
detail
这里特别要注意,这个os
文件生成于riscv64gc-unknown-none-elf
文件夹下.
手动加载内核可执行文件
为什么要这么做,官方文档讲的很清楚了.为了能让自己清楚,我按照自己的理解复述,但是更加严肃的学习应该依仗官方文档.
![[Pasted image 20240621215458.png]]
编译出来的os
文件中,包括Metadata0
,sections
,Metadata1
三部分,而实际上QEMU在执行时只需要代码段,而且会寻找图中为浅蓝色的属于sections
的第一部分,即内核的第一句.因此需要去掉这些元数据.
丢弃内核可执行文件中的元数据
rust-objcopy --strip-all target/riscv64gc-unknown-none-elf/release/os -O binary target/riscv64gc-unknown-none-elf/release/os.bin
detail
- rust-objcopy: 这是Rust工具链中用于对象文件操作的一个工具,类似于GNU的
objcopy
。它能够对编译后的目标文件进行多种操作,比如修改格式、剥离符号等。 - --strip-all: 这是一个选项,指示
rust-objcopy
从目标文件中移除所有符号表和调试信息。这包括了函数名、变量名、行号信息等,使得生成的二进制文件更小,且不适合于调试,但非常适合用于生产环境部署,因为它可以减少文件大小并防止敏感信息泄露。 - target/riscv64gc-unknown-none-elf/release/os: 这是输入文件的路径。该文件是由Rust编译器使用
release
配置为RISC-V 64位架构、无操作系统环境生成的目标文件。os
是该二进制文件的名称,通常是一个可执行文件。 - -O binary: 这个选项指定了输出文件的格式应为纯二进制格式(raw binary format),没有额外的头部或格式信息,只包含可执行代码和数据。这对于需要直接加载到特定地址执行的嵌入式系统特别有用。
- target/riscv64gc-unknown-none-elf/release/os.bin: 这是输出文件的路径及名称。通过该命令,原始的
os
文件被处理并转换为一个剥离了所有调试信息的纯二进制文件os.bin
,适合直接在目标硬件上运行。
用stat
指令比较文件大小
stat ./os
stat ./os.bin
detail
File: ./os
Size: 5352 Blocks: 16 IO Block: 4096 regular file
Device: 803h/2051d Inode: 788618 Links: 2
Access: (0775/-rwxrwxr-x) Uid: ( 1000/winddevil) Gid: ( 1000/winddevil)
Access: 2024-06-21 22:04:42.208542504 +0800
Modify: 2024-06-21 01:52:46.835022696 +0800
Change: 2024-06-21 01:52:46.851022785 +0800
Birth: 2024-06-21 01:52:46.835022696 +0800
File: ./os.bin
Size: 4 Blocks: 8 IO Block: 4096 regular file
Device: 803h/2051d Inode: 786457 Links: 1
Access: (0775/-rwxrwxr-x) Uid: ( 1000/winddevil) Gid: ( 1000/winddevil)
Access: 2024-06-21 22:04:42.208542504 +0800
Modify: 2024-06-21 22:04:42.212542293 +0800
Change: 2024-06-21 22:04:42.216542080 +0800
Birth: 2024-06-21 22:04:42.208542504 +0800
新版QEMU支持保存元数据
官方文档:
经过我们的实验,至少在 Qemu 7.0.0 版本后,我们可以直接将内核可执行文件 os
提交给 Qemu 而不必进行任何元数据的裁剪工作,这种情况下我们的内核也能正常运行。其具体做法为:将 Qemu 的参数替换为 -device loader,file=path/to/os
。但是,我们仍推荐大家了解并在代码框架和文档中保留这一流程,原因在于这种做法更加通用,对环境和工具的依赖程度更低。
基于 GDB 验证启动流程
配置RustSBI
在我们从GitHub上边,pull下来的rCore-Tutorial-v3
里寻找到地址为rCore-Tutorial-v3/bootloader/rustsbi-qemu.bin
的文件,将其放置到我们实验需要的工程os
的上级目录的名为bootloadeer
的目录里边,使得其相对于os
的相对地址为../bootloader/rustsbi-qemu.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 \
-s -S
detail
qemu-system-riscv64
: 这是QEMU的RISC-V 64位系统模拟器的可执行文件,用于模拟RISC-V架构的64位系统。-machine virt
: 指定使用“virt”机器类型,这是QEMU为RISC-V提供的一个通用的、简化的虚拟机模型,适合进行操作系统开发和测试。-nographic
: 表示不使用图形界面输出,所有的输出(包括串口输出)都会被重定向到控制台(标准输出/错误)。-bios ../bootloader/rustsbi-qemu.bin
: 指定使用的BIOS映像文件为rustsbi-qemu.bin
,通常这是一个RustSBI(RISC-V Supervisor Binary Interface)实现的二进制文件,用于初始化系统并引导后续的启动流程。-device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000
: 加载用户自定义的操作系统内核映像。file
后面跟的是内核映像的路径,这里是target/riscv64gc-unknown-none-elf/release/os.bin
,表示编译好的操作系统内核。addr=0x80200000
指定了该内核在虚拟内存中的加载地址,这是RISC-V体系结构中常见的内核加载地址之一。-s -S
: 这两个选项一起使用是为了GDB调试准备的。-s
启用QEMU的GDB调试支持,它会在启动时打开一个GDB远程调试端口,默认是1234。-S
表示启动QEMU后暂停执行,等待外部调试器连接。这对于想要在操作系统启动初期介入调试非常有用。
打开另一个终端GDB监听
riscv64-unknown-elf-gdb \
-ex 'file target/riscv64gc-unknown-none-elf/release/os' \
-ex 'set arch riscv:rv64' \
-ex 'target remote localhost:1234'
detail
riscv64-unknown-elf-gdb
: 这是指定的GDB调试器的可执行文件,它是专为RISC-V 64位架构的ELF格式文件设计的交叉调试器。-ex 'file target/riscv64gc-unknown-none-elf/release/os'
:-ex
参数用于在GDB启动时执行一条GDB命令。这里使用它来指定要调试的可执行文件路径,即你的RISC-V操作系统内核映像。注意命令中的os
应该是os.elf
或完整的文件名,根据你的实际编译输出来确定。-ex 'set arch riscv:rv64'
: 这条命令设置了GDB的架构为RISC-V 64位(rv64)。这对于确保GDB能够正确解析和显示RISC-V 64位架构的寄存器、指令等信息非常重要。-ex 'target remote localhost:1234'
: 这条命令告诉GDB连接到远程调试目标,这里是本地主机(localhost)的1234端口。这个端口正是前面通过qemu-system-riscv64
命令中-s
选项开启的GDB调试服务器监听的端口。通过这个命令,GDB可以与QEMU模拟器中的RISC-V处理器实例建立连接,从而允许你调试运行在QEMU中的操作系统代码。
log
显示Qemu 启动后 PC 被初始化为 0x1000
.
0x0000000000001000 in ?? ()
检查Qemu 的启动固件的内容
在GDB监听端输入:
x/10i $pc
这里 x/10i $pc
的含义是从当前 PC 值的位置开始,在内存中反汇编 10 条指令.
log
(gdb) x/10i $pc
=> 0x1000: auipc t0,0x0
0x1004: addi a2,t0,40
0x1008: csrr a0,mhartid
0x100c: ld a1,32(t0)
0x1010: ld t0,24(t0)
0x1014: jr t0
0x1018: unimp
0x101a: 0x8000
0x101c: unimp
0x101e: unimp
可以看到 Qemu 的固件仅包含 5 条指令,从 0x1014
开始都是数据,当数据为 0 的时候则会被反汇编为 unimp
指令。 0x101a
处的数据 0x8000
是能够跳转到 0x80000000
进入启动下一阶段的关键。 ^b2fc42
有兴趣的读者可以自行探究位于 0x1000
和 0x100c
两条指令的含义。总之,在执行位于 0x1010
的指令之前,寄存器 t0
的值恰好为 0x80000000
,随后通过 jr t0
便可以跳转到该地址. ^e44d27
单步调试
si
可以让 Qemu 每次向下执行一条指令,之后屏幕会打印出待执行的下一条指令的地址.
si
log
调试到下一条指令的地址为0x1010
的时候停下.
(gdb) si
0x0000000000001004 in ?? ()
(gdb) si
0x0000000000001008 in ?? ()
(gdb) si
0x000000000000100c in ?? ()
(gdb) si
0x0000000000001010 in ?? ()
以 16 进制打印寄存器 t0
的值
p/x $t0
以 16 进制打印寄存器 t0
的值,注意当我们要打印寄存器的时候需要在寄存器的名字前面加上 $
.
p/x $t0
log
可以看到,,当位于 0x1010
的指令执行完毕后,下一条待执行的指令位于 RustSBI 的入口,也即 0x80000000
,这意味着我们即将把控制权转交给 RustSBI.
1 = 0x80000000
检查控制权能否被移交给内核
打一个断点
我们在内核的入口点,也即地址 0x80200000
处打一个断点。
b *0x80200000
log
Breakpoint 1 at 0x80200000
继续运行直到断点
c
log
Continuing.
反汇编5条指令
x/5i $pc
log
可以看到我们在 entry.asm
中编写的第一条指令可以在 0x80200000
处找到。这里 ra
是寄存器 x1
的别名.
这里注意这条指令都是和我们上边的内容相对应的[[08 内核第一条指令#^932692|entry.asm 内容]].
(gdb) x/5i $pc
=> 0x80200000: li ra,100
0x80200004: unimp
0x80200006: unimp
0x80200008: unimp
0x8020000a: unimp
运行这条指令
si
检查指令运行效果
以十进制打印x1
寄存器
p/d $x1
log
可以知道这条指令执行成功了.
这里注意这条指令都是和我们上边的内容相对应的[[08 内核第一条指令#^932692|entry.asm 内容]].
2 = 100
查看栈指针的值
p/x $sp
log
我们可以检查此时栈指针 sp
的值,可以发现它目前是 0.
其实就是还没有用到栈来做函数的回调和并发等功能.
3 = 0x0
标签:bin,os,08,elf,第一条,指令,内核,Qemu
From: https://www.cnblogs.com/chenhan-winddevil/p/18292657