Introduction
本文记录了笔者完成 rCore Tutorial Book 第一章的过程,包括配置 rust 环境,编译普通的 hello world rust程序,以及实现内核第一条指令、最终借助 rustSBI 打印出 hello world 并关机的过程,大致经过如下
- 配置普通的 rust 环境,编译 hello world
- 配置 riscv64gc 的 rust 裸机目标平台,进行交叉编译出裸机 risc-v 程序
- 编写链接脚本,使得编译出的 risc-v ELF 文件的内存布局满足第一条指令在
0x80200000处 - 裁切 ELF ,去掉 headers 只留下 section 部分,生成
.bin镜像文件,并将其载入到 Qemu 中 - 借助 rustSBI 和 qemu 进行运行,用 GDB 检查 PC 是否如预期一般跳到了
0x80200000并执行内核的第一条指令 - 修改
entry.asm分配一块栈空间,使得 rust 函数调用能够正常进行,并在rust程序中初始化.bss区域 - 使用 RustSBI 提供的服务完成打印
hello world并关机
相关链接如下
Rust The Programming Language 环境配置说明
注:不要使用 THU 课程仓库中的 RustSBI 镜像,如果使用的 Qemu 版本较高(>=7.2.0),设备结点名发生更改会造成 RustSBI 无法正常运行,详见:
git clone https://github.com/rcore-os/rCore-Tutorial-v3.git
环境配置
在 macOS 上配置 rust 环境
输入以下指令安装 rustup
$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
在 macOS 上,如果没有 C 编译环境,需要输入下面的指令进行安装
$ xcode-select --install
sodium@nas-MacBook-Air-13 os % rustc --version --verbose
rustc 1.98.1 (48a229cea 2026-09-01)
binary: rustc
commit-hash: 48a229ceaefd4985c50990b14116b6d856af0985
commit-date: 2026-09-01
host: aarch64-apple-darwin
release: 1.98.1
LLVM version: 22.1.8
sodium@nas-MacBook-Air-13 os % rustup target list --installed
aarch64-apple-darwin
后续会需要交叉编译,所以需要安装 riscv64gc-unknown-none-elf 编译器,让编译出来的程序能够运行在 riscv64gc 指令集的裸机(unknown 对应 CPU 厂家、none 对应裸机)上,elf 是可执行文件的格式
sodium@nas-MacBook-Air-13 os % rustup target add riscv64gc-unknown-none-elf
info: downloading component rust-std
rust-std-riscv64gc-unknown-none-elf installed 11.93 MiB
sodium@nas-MacBook-Air-13 os % rustup target list --installed
aarch64-apple-darwin
riscv64gc-unknown-none-elf
编译一条能在裸机上运行的 rust 程序
一个普通的 hello world
运行下面的指令能够新建一个 rust 项目,名称为 os , --bin 表示创建一个可执行程序项目而不是函数库项目
cargo new os --bin
sodium@nas-MacBook-Air-13 rust-learn % tree os
os
├── Cargo.toml
└── src
└── main.rs
2 directories, 2 files
其中 Cargo.toml 内容如下,包含了初始化的项目信息和依赖
[package]
name = "os"
version = "0.1.0"
edition = "2024"
[dependencies]
而 main.rs 则是自动生成的一个 hello world 打印程序
fn main() {
println!("Hello, world!");
}
运行这个项目可以看到打印信息
sodium@nas-MacBook-Air-13 os % cargo run
Compiling os v0.1.0 (/Users/sodium/Developer/rust/rust-learn/os)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.84s
Running `target/debug/os`
Hello, world!
从上面的信息可以看到可执行程序路径是 target/debug/os
sodium@nas-MacBook-Air-13 os % ./target/debug/os
Hello, world!
尝试检查程序使用了什么系统调用
sodium@nas-MacBook-Air-13 os % sudo dtruss ./target/debug/os
Password:
dtrace: system integrity protection is on, some features will not be available
SYSCALL(args) = return
Hello, world!
由于 SIP 的原因,这里看不到结果,所以改用 Linux
ubuntu@VM-0-6-ubuntu:~/workspace/rust-learning/os$ strace target/debug/os
execve("target/debug/os", ["target/debug/os"], 0x7ffe577e1190 /* 27 vars */) = 0
brk(NULL) = 0x604337e74000
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7917212a0000
...(一大串系统调用信息)
write(1, "Hello, world!\n", 14Hello, world!
) = 14
exit_group(0) = ?
+++ exited with 0 +++
虽然很长,但是在 main 里面直接相关的只有
write(1, "Hello, world!\n", 14Hello, world!)= 14
exit_group(0)= ?
+++ exited with 0 +++
前面的一大串都是启动流程,涉及标准库中的函数。
更换目标平台至 riscv64gc-unknown-none-elf
riscv64gc-unknown-none-elf 含义可以拆解如下
riscv64gc
↓
RISC-V 64 + G/C 扩展
unknown
↓
没有特定CPU厂商
none
↓
没有操作系统
elf
↓
输出 ELF 格式
对比原来的 aarch64-apple-darwin
aarch64 apple darwin
│ │ │
│ │ └── macOS
│ └────────── Apple
└──────────────────── ARM64
类似的还有 x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc, x86_64-apple-darwin等,详见附录
目标平台换成 riscv64gc-unknown-none-elf 之后,原本的标准库找不到了,因为裸机平台上的软件没有传统操作系统支持。
sodium@nas-MacBook-Air-13 os % cargo run --target riscv64gc-unknown-none-elf
Compiling os v0.1.0 (/Users/sodium/Developer/rust/rust-learn/os)
error[E0463]: can't find crate for `std`
|
= note: the `riscv64gc-unknown-none-elf` target may not support the standard library
= note: `std` is required by `os` because it does not declare `#![no_std]`
error: cannot resolve a prelude import
error: cannot find macro `println` in this scope
--> src/main.rs:2:5
|
2 | println!("Hello, world!");
| ^^^^^^^
error: `#[panic_handler]` function required, but not found
For more information about this error, try `rustc --explain E0463`.
error: could not compile `os` (bin "os") due to 4 previous errors
编译器报错信息如下
- 没有声明
#![no_std],认为项目需要std,而目标平台可能并不支持标准库(standard library) std错误导致 Rust 默认提供的包含了各种常用类型/功能的 prelude 都没办法正常建立。- 因为没有
std提供的标准输出环境,println缺少宏定义 - 编译一个
no_std风格的裸机程序,那么你必须自己提供但没有声明#[panic_handler]
移除标准库依赖,使用对 Rust 语言标准库 –std 裁剪过后的 Rust 语言核心库 core
为了让 rust 程序能够在逻辑上运行起来,必须要消除上面的报错信息,要移除标准库依赖
先把目标平台写到 config 里面,之后不需要手动加上 --target riscv64gc-unknown-none-elf
ubuntu@VM-0-6-ubuntu:~/workspace/rust-learning/os$ mkdir .cargo
ubuntu@VM-0-6-ubuntu:~/workspace/rust-learning/os$ vim .cargo/config
ubuntu@VM-0-6-ubuntu:~/workspace/rust-learning/os$ cat .cargo/config
# os/.cargo/config
[build]
target = "riscv64gc-unknown-none-elf"
开发平台(x86_64)与目标平台(riscv64gc)不同,称之为 交叉编译
为了消除先前的报错信息,需要修改 main.rs
- 添加
#![no_std],放弃标准库,改用核心库 - 注释掉
println宏 - 新建文件
os/src/lang_items.rs,实现#[panic_handler]指向的 panic 函数 - 添加
#![no_main],让这个 crate 不使用 Rust 默认的main程序入口机制。
修改完后代码如下
// os/src/main.rs
#![no_main]
#![no_std]
mod lang_items; // 类似于 #include
fn main() {
// println!("Hello, world!");
}
// os/src/lang_items.rs
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! { // 死循环,永不返回
loop {}
}
os/
├── src/
│ ├── main.rs
│ └── lang_items.rs
修改之后,就可以通过编译了
sodium@nas-MacBook-Air-13 os % cargo build
warning: `/Users/sodium/Developer/rust/rust-learn/os/.cargo/config` is deprecated in favor of `config.toml`
|
= help: if you need to support cargo 1.38 or earlier, you can symlink `config` to `config.toml`
Compiling os v0.1.0 (/Users/sodium/Developer/rust/rust-learn/os)
warning: function `main` is never used
--> src/main.rs:4:4
|
4 | fn main() {
| ^^^^
|
= note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default
warning: linker stderr: rust-lld: cannot find entry symbol _start; not setting start address
|
= note: `#[warn(linker_messages)]` on by default
warning: `os` (bin "os") generated 2 warnings
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.50s
这里的警告信息主要是两点
main未使用_start程序入口找不到
分析程序
首先需要安装 cargo-binutils 工具集
cargo install cargo-binutils
rustup component add llvm-tools-preview
常见工具如下
file <file>分析文件格式rust-readobj -h <file>分析文件头信息rust-objdump -S <file>反汇编导出汇编程序
file
sodium@nas-MacBook-Air-13 os % file target/riscv64gc-unknown-none-elf/debug/os
target/riscv64gc-unknown-none-elf/debug/os: ELF 64-bit LSB executable, UCB RISC-V, RVC, double-float ABI, version 1 (SYSV), statically linked, with debug_info, not stripped
- ELF 64bit: Executable and Linkable Format
- 这是 Linux / Unix 世界非常常见的可执行文件格式。64-bit 说明是 64 位 ELF。
- LSB: Least Significant Byte
- 小端序(Little Endian)。RISC-V 64 Linux/裸机环境通常使用小端
- UCB RISC-V: LLVM/file 对 RISC-V ELF 的描述。
- RVC: 表示启用了 RISC-V Compressed Instructions
- double-float ABI: 使用 double-precision floating-point ABI
- version 1 (SYSV): SYSV 是 System V ELF ABI 相关的标识
- statically linked: 静态链接
- libc.so, libgcc.so, 动态链接器需要运行时再加载,这里没有这些东西
- with debug_info: ELF 中保留了调试信息
- not stripped: 没有执行 strip,符号/调试信息还保留着
rust-readobj -h 查看 ELF header
sodium@nas-MacBook-Air-13 os % 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
LoadName: <Not found>
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: Executable (0x2)
Machine: EM_RISCV (0xF3)
Version: 1
Entry: 0x0
ProgramHeaderOffset: 0x40
SectionHeaderOffset: 0xED8
Flags [ (0x5)
EF_RISCV_FLOAT_ABI_DOUBLE (0x4)
EF_RISCV_RVC (0x1)
]
HeaderSize: 64
ProgramHeaderEntrySize: 56
ProgramHeaderCount: 4
SectionHeaderEntrySize: 64
SectionHeaderCount: 12
StringTableSectionIndex: 10
}
其中 Entry: 0x0 表示入口地址不存在,对应了先前的警告 rust-lld: cannot find entry symbol _start; not setting start address
所以实际上这个 ELF 并不能真正启动
rust-objdump -S 查看反汇编文件
sodium@nas-MacBook-Air-13 os % rust-objdump -S target/riscv64gc-unknown-none-elf/debug/os
target/riscv64gc-unknown-none-elf/debug/os: file format elf64-littleriscv
没有反汇编代码,因为程序没有入口点,而且程序也没有任何有意义的工作
让 QEMU 执行内核的第一条指令
启动 QEMU
rCore Tutorial 使用软件 qemu-system-riscv64 来模拟一台 64 位 RISC-V 架构的计算机,它包含CPU 、物理内存以及若干 I/O 外设。从 os/Makefile 中可以看到启动命令
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的虚拟计算机,其硬件配置详见 Virt-nographic表示不需要图形界面bios用于设置 Qemu 模拟器开机时用来初始化的引导加载程序-device下面有三个选项loader用于在 Qemu 模拟器开机之前将一个宿主机上的文件载入到 Qemu 的物理内存的指定位置中file设置待载入文件的路径,os.bin被称为 内核镜像,由可执行文件处理而来addr设置将文件载入到的 Qemu 物理内存上的物理地址
启动流程可以分为三个部分
PC=0x1000: 在必要的文件载入内存之后,Qemu 会将 PC 初始化为0x1000,执行若干指令(QEMU 内置启动代码)后跳转至PC=0x80000000: 该跳转地址不可更改,一般存放的是bootloader的位置。这里存放了RustSBI,会对计算机进行一些初始化操作,然后跳转至- 和
xv6不同,M-Mode 部分不归 rCore 处理,而是使用了RustSBI
- 和
PC=0x80200000: RustSBI 将下一阶段的入口地址预先约定为固定的 0x80200000,这里存放了内核镜像,到这里计算机的控制权已经被移交给内核
真实计算机的加电启动流程也是类似的三部分
- 加电后 CPU 的 PC 寄存器被设置为计算机内部只读存储器(ROM,Read-only Memory)的物理地址,随后 CPU 开始运行 ROM 内的软件(Firmware)。
- 这部分代码会完成一部分 CPU 初始化工作,并将
bootloader的代码、数据等从硬盘加载到内存中,最后跳转地址,控制权交给 bootloader
- 这部分代码会完成一部分 CPU 初始化工作,并将
- bootloader 同样完成一些 CPU 的初始化工作,将操作系统镜像从硬盘加载到物理内存中,最后跳转到适当地址将控制权转移给操作系统。
- 控制权被转移给操作系统。
程序内存布局
一种典型的程序布局如下
- Code Memory
.text: 存放程序的所有汇编代码
- Data Memory
.rodata: 存放只读的全局数据,通常是一些常数或者是 常量字符串等.data: 存放可修改的全局数据.bss: 保存程序中那些未初始化的全局数据,通常由程序的加载者代为进行零初始化,即将这块区域逐字节清零- heap: 程序运行时动态分配的数据
- stack: 用于函数调用上下文的保存与恢复,存放每个函数作用域内的局部变量,对应寄存器
sp
| 区域 | 典型内容 | 汇编访问的主要途径 | 典型形式 |
|---|---|---|---|
.text | 指令 | pc | PC → 指令 |
.rodata | 常量、字符串 | pc / a寄存器 / gp 等 | auipc + offset |
.data | 已初始化全局变量 | gp / pc 相对地址 | lw ..., offset(gp) |
.bss | 未初始化全局变量 | gp / pc 相对地址 | sw ..., offset(gp) |
| heap | malloc/Box 等动态对象 | 指针寄存器 | lw ..., 0(a0) |
| stack | 局部变量、保存的寄存器 | sp / fp(s0) | lw ..., offset(sp) |
编译流程
从源代码得到可执行文件的编译流程可以划分为三个部分
- 源代码到汇编:由编译器负责
- 汇编到机器码/目标文件:由汇编器负责
- 目标文件和外部目标文件到可执行文件:链接器负责
- 链接器需要将不同目标文件的段重新排布,
.text放到一块,.data放到一块等等,避免地址冲突 - 此时内存布局已确定,可以将符号替换为具体地址
- 链接器需要将不同目标文件的段重新排布,
编写第一条指令
# os/src/entry.asm
.section .text.entry
.globl _start
_start:
li x1, 100
_start是入口地址的标志,地址为li x1, 100的地址.globl _start表明_start是全局符号,可以被其他目标文件使用.section .text.entry表明我们希望将第 2 行后面的内容全部放到一个名为.text.entry的段中.text.entry从而区别于其他.text的目的在于我们想要确保该段被放置在相比任何其他代码段 更低 的地址上。- 这样,作为内核的入口点,这段指令才能被最先执行。
将这段汇编嵌入到 main.rs 中:include_str! 宏将同目录下的汇编代码 entry.asm 转化为字符串并通过 global_asm! 宏嵌入到代码中。
// os/src/main.rs
#![no_std]
#![no_main]
mod lang_items;
use core::arch::global_asm;
global_asm!(include_str!("entry.asm"));
调整内存布局
通过链接脚本 (Linker Script) 可以调整链接器的行为,使得最终生成的可执行文件的内存布局符合 Qemu 的预期,即 内核第一条指令的地址应该位于 0x80200000 。
修改 Cargo 的配置文件 os/.cargo/config 来使用我们自己的链接脚本 os/src/linker.ld 而非使用默认的内存布局
// 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 内容如下
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)
}
}
OUTPUT_ARCH(riscv)设置目标平台为riscvENTRY(_start)设置程序入口为_startBASE_ADDRESS = 0x80200000定义常量BASE_ADDRESS为0x80200000- 这里
.相当于链接器的“当前地址游标”。会随着 Section 的添加而向上增长。 stext表示.text开始,etext表示.text结束,其他同理。*(.text .text.*)表示将输入目标文件中的所有.text和.text.xxx文件拿过来。其他句子同理。. = ALIGN(4K)表示将当前地址向上对齐到 4KB
注意到 .bss 部分不太一样,按照 rCore 这里的设计,.bss.stack 不会被那段 BSS 清零代码清掉。
.bss 的开始
↓
┌───────────────────┐
│ .bss.stack │
│ 内核栈 │
└───────────────────┘
↑
sbss
│
┌───────────────────┐
│ 普通未初始化数据 │
│ .bss │
│ .bss.xxx │
│ .sbss.xxx │
└───────────────────┘
↓
ebss
进行编译并查看文件格式
sodium@nas-MacBook-Air-13 os % cargo build --release
warning: `/Users/sodium/Developer/rust/rust-learn/os/.cargo/config` is deprecated in favor of `config.toml`
|
= help: if you need to support cargo 1.38 or earlier, you can symlink `config` to `config.toml`
Compiling os v0.1.0 (/Users/sodium/Developer/rust/rust-learn/os)
warning: function `main` is never used
--> src/main.rs:6:4
|
6 | fn main() {
| ^^^^
|
= note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default
warning: `os` (bin "os") generated 1 warning
Finished `release` profile [optimized] target(s) in 0.39s
sodium@nas-MacBook-Air-13 os % 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
对比前面的会发现,少了个 with debug_info,因为编译标签是 --release
sodium@nas-MacBook-Air-13 os % file target/riscv64gc-unknown-none-elf/debug/os
target/riscv64gc-unknown-none-elf/debug/os: ELF 64-bit LSB executable, UCB RISC-V, RVC, double-float ABI, version 1 (SYSV), statically linked, with debug_info, not stripped
去除元数据,生成镜像
ELF 的元数据无法被 Qemu 在加载文件时利用,且会使代码和数据段被加载到错误的位置。所以需要留下真正需要的 Section 部分
Rust 源码
↓
编译
↓
ELF
├── Header ← 工具/加载器信息
├── Program Headers ← 加载信息
├── .text ← 真正代码
├── .rodata ← 真正数据
├── .data ← 真正数据
├── .bss ← 需要的内存,但通常不占文件空间
├── Symbol Table ← 符号
└── Debug Info ← 调试
↓
objcopy -O binary
↓
os.bin
├── .text
├── .rodata
└── .data
↓
QEMU
↓
0x80200000
使用如下命令可以丢弃内核可执行文件中的元数据得到内核镜像:
rust-objcopy --strip-all target/riscv64gc-unknown-none-elf/release/os -O binary target/riscv64gc-unknown-none-elf/release/os.bin
使用 stat 工具来比较内核可执行文件和内核镜像的大小:
sodium@nas-MacBook-Air-13 os % stat target/riscv64gc-unknown-none-elf/release/os
16777232 21953905 -rwxr-xr-x 1 sodium staff 0 5488 "Oct 5 14:26:05 2026" "Oct 5 14:26:05 2026" "Oct 5 14:26:05 2026" "Oct 5 14:26:05 2026" 4096 16 0 target/riscv64gc-unknown-none-elf/release/os
sodium@nas-MacBook-Air-13 os % stat target/riscv64gc-unknown-none-elf/release/os.bin
16777232 21956785 -rwxr-xr-x 1 sodium staff 0 4 "Oct 5 14:38:20 2026" "Oct 5 14:38:20 2026" "Oct 5 14:38:20 2026" "Oct 5 14:38:20 2026" 4096 8 0 target/riscv64gc-unknown-none-elf/release/os.bin
sodium@nas-MacBook-Air-13 os %
内核镜像的大小仅有 4 字节,这是因为它里面仅包含我们在 entry.asm 中编写的一条指令(li x1, 100)。一般情况下 RISC-V 架构的一条指令位宽即为 4 字节。
注:Qemu 7.0.0 后不需要手动进行裁切,将 Qemu 的参数替换为 -device loader,file=path/to/os 即可
使用 GDB 查看程序运行情况
sodium@nas-MacBook-Air-13 os % 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
将仓库的 rustsbi-qemu.bin 复制过来,然后执行上面的命令启动 qemu,其中 -s 可以使 Qemu 监听本地 TCP 端口 1234 等待 GDB 客户端连接,而 -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'
在 macOS 上这里使用了 riscv64-elf-gdb 进行调试
sodium@nas-MacBook-Air-13 os % riscv64-elf-gdb \
-ex 'file target/riscv64gc-unknown-none-elf/release/os' \
-ex 'set arch riscv:rv64' \
-ex 'target remote localhost:1234'
具体调试过程如下
(gdb) info registers
ra 0x0 0x0
sp 0x0 0x0
gp 0x0 0x0
tp 0x0 0x0
t0 0x0 0
t1 0x0 0
t2 0x0 0
fp 0x0 0x0
s1 0x0 0
a0 0x0 0
a1 0x0 0
a2 0x0 0
a3 0x0 0
a4 0x0 0
a5 0x0 0
a6 0x0 0
a7 0x0 0
s2 0x0 0
s3 0x0 0
s4 0x0 0
s5 0x0 0
s6 0x0 0
s7 0x0 0
s8 0x0 0
s9 0x0 0
s10 0x0 0
s11 0x0 0
t3 0x0 0
t4 0x0 0
t5 0x0 0
t6 0x0 0
pc 0x1000 0x1000
(gdb) x/12i $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: .insn 2, 0x8000
0x101c: unimp
0x101e: unimp
0x1020: unimp
0x1022: .insn 2, 0x87e0
(gdb) si
0x0000000000001004 in ?? ()
(gdb) si
0x0000000000001008 in ?? ()
(gdb) si
0x000000000000100c in ?? ()
(gdb) si
0x0000000000001010 in ?? ()
(gdb) p/x $t0
$1 = 0x1000
(gdb) si
0x0000000000001014 in ?? ()
(gdb) x/gx 0x1018
0x1018: 0x0000000080000000
(gdb) p/x $t0
$2 = 0x80000000
(gdb) si
0x0000000080000000 in ?? ()
(gdb) x/10i $pc
=> 0x80000000: csrw mie,zero
0x80000004: auipc sp,0xa
0x80000008: addi sp,sp,-2044
0x8000000c: lui t0,0x4
0x8000000e: csrr t1,mhartid
0x80000012: addi t1,t1,1
0x80000014: add sp,sp,t0
0x80000016: addi t1,t1,-1
0x80000018: bnez t1,0x80000014
0x8000001c: auipc ra,0x2
(gdb) b *0x8020000
Breakpoint 1 at 0x8020000
(gdb) c
Continuing.
上面的调试流程揭示了一部分启动流程,其中 x/10i $pc 的含义是从当前 PC 值的位置开始,在内存中反汇编 10 条指令。不过可以看到 Qemu 的固件仅包含 5 条指令,从 0x1014 开始都是数据,当数据为 0 的时候则会被反汇编为 unimp 指令。
- 初始时
t0=0x1000,a2=0x1028,a0=mhartid,a1=0,随后t0=0x80000000 - PC 跳转至
0x80000000执行 rustsbi 部分 - 执行完成后,正常情况下应该跳到
0x80200000执行第一条内核代码。
由于 rustsbi 不兼容新版本的 Qemu,这里发生了死循环。正常情况下应该输出
(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
不过GDB显示代码已经载入到内存中了
(gdb) x/10i 0x80200000
0x80200000 <stext>: li ra,100
0x80200004: unimp
0x80200006: unimp
0x80200008: unimp
0x8020000a: unimp
0x8020000c: unimp
0x8020000e: unimp
0x80200010: unimp
0x80200012: unimp
0x80200014: unimp
为了研究卡死原因,本人通过 GDB 进行了分析,详见附录
为内核支持函数调用
在前面的步骤中,本文已经实现内核的第一条指令,但是却是通过汇编的形式来执行的,如果能使用函数调用,从而进入 rust 编写的内核函数入口,控制权就可以移交给 rust 代码。
原理:函数调用与栈
Risc-v 进行跳转的指令主要有两条
jal rd, imm[20:1]: rd = pc+4, pc = pc+immjalr rd (imm[11:0])rs: rd = pc+4, pc = rs+imm
返回时,ret 伪指令内容如下
jalr x0, 0(x1)或者jalr x0, 0(ra),也就是 pc = ra
通常来说,调用 jal(r) ra, ... 来跳转至对应函数入口,然后 ret 时返回到下一条指令。
不过这里会有一个问题,当发生多层嵌套调用时,ra 会被覆盖,或者 ra 在此期间发生了修改,导致无法返回。
所以需要约定寄存器保存的问题,调用规范如下
| 寄存器组 | 保存者 | 功能 |
|---|---|---|
a0a7( x10 | 调用者保存 | 用来传递输入参数。其中的 a0 和 a1 还用来保存返回值。 |
t0t6( x5 | 调用者保存 | 作为临时寄存器使用,在被调函数中可以随意使用无需保存。 |
s0s11( x8 | 被调用者保存 | 作为临时寄存器使用,被调函数保存后才能在被调函数中使用。 |
x0: 恒为零,无影响x1(ra): 被调用者保存,在函数开头压入栈,在ret前恢复x2(sp): 被调用者保存s0(fp): 可作为s0或者 frame pointer 寄存器,被调用者保存,指向当前栈帧起始地址,相比之下sp指向栈顶x3(gp),x4(tp): 一般不会变化,这里不用保存
在函数调用期间,大致流程如下,如果没有优化的话
- 调用者将
a0a7,t0t6压入栈中,fp指向栈起始(高地址),sp指向栈顶(低地址) - 被调用者入栈(修改
sp),用于保存ra,fp, ,以及保存其他寄存器,更新fp,然后执行其他部分 - 返回时,恢复
ra,fp, 退栈,相当于恢复sp
# 开场
# 为当前函数分配 64 字节的栈帧
addi sp, sp, -64
# 将 ra 和 fp 压栈保存
sd ra, 56(sp)
sd s0, 48(sp)
# 更新 fp 为当前函数栈帧顶端地址
addi s0, sp, 64
# 函数执行
# 中间如果再调用了其他函数会修改 ra
# 结尾
# 恢复 ra 和 fp
ld ra, 56(sp)
ld s0, 48(sp)
# 退栈
addi sp, sp, 64
# 返回,使用 ret 指令或其他等价的实现方式
ret
初始化栈空间
因此,在实现函数调用之前,需要把栈指针(sp)初始化
# os/src/entry.asm
.section .text.entry
.globl _start
_start:
la sp, boot_stack_top
call rust_main
.section .bss.stack
.globl boot_stack_lower_bound
boot_stack_lower_bound:
.space 4096 * 16
.globl boot_stack_top
boot_stack_top:
这段汇编预留了一块 4096*16Bytes 的栈空间,其中 boot_stack_top 指向栈的最高位置,而 boot_stack_lower_bound 指向栈的最低位置边界。最后这块位置被放到了 .bss.stack 的段中。
.bss : {
*(.bss.stack) <= 不需要清零
sbss = .;
*(.bss .bss.*) <= 需要清零
*(.sbss .sbss.*)
}
ebss = .;
使用启动栈
call rust_main 之后,控制权交给 rust
// os/src/main.rs
#[unsafe(no_mangle)]
pub fn rust_main() -> ! {
loop{}
}
pub类似于 C++ 的public- 外部模块和内部模块都可以访问,而
mod只能内部模块访问 - 链接时,确保汇编器可以看到
- 外部模块和内部模块都可以访问,而
#[unsafe(no_mangle)]避免符号修饰(symbol mangling),从而让call rut_main找不到rust_main- 默认情况下rust会进行符号修饰,将函数名编码
除此之外,rust 代码还需要完成 .bss 段清零的工作
.bss 段一般放置需要被初始化为零的数据。然而栈并不需要在使用前被初始化为零,因为在函数调用的时候我们会插入栈帧覆盖已有的数据。全局符号 sbss 和 ebss 分别指向 .bss 段除 .bss.stack 以外的起始和终止地址,在使用这部分数据之前需要将它们初始化为零,
| 段 | 存放内容 | 文件中是否占空间 |
|---|---|---|
.text | 代码 | 占 |
.rodata | 只读常量 | 占 |
.data | 已初始化的全局/静态变量 | 占 |
.bss | 未初始化或全零变量 | 不占(只记录大小) |
栈空间不放在 .data 段是因为启动栈不是程序初始化数据,不应该占用 ELF 文件空间。而之所以不放在 sbss 和 ebss 之间,是因为栈空间不需要初始化为零。
// os/src/main.rs
macro_rules! linker_symbol_addr { // 把链接器符号转换成地址
($symbol:path) => {
($symbol as *const ()).addr() // 符号转换成裸指针(sbss as *const()),然后得到地址(.addr())
};
}
#[unsafe(no_mangle)]
pub fn rust_main() -> ! {
clear_bss();
loop {}
}
fn clear_bss() {
unsafe extern "C" { // 将 sbss, ebss 声明成 extern "C" 函数符号
safe fn sbss();
safe fn ebss();
}
(linker_symbol_addr!(sbss)..linker_symbol_addr!(ebss)).for_each(|a| {
unsafe { (a as *mut u8).write_volatile(0) } // 对于每个地址,转换成 *mut u8 并写入0
});
}
- 宏语法
macro_rules! linker_symbol_addr是 macro_rules macro,声明式宏,本质是一个代码替换工具- 比如
linker_symbol_addr!(sbss)会展开为(sbss as *const()).addr() ($symbol:path)表示接受一个 Rust 路径形式的东西上面的例子中$symbol = sbsspath可以匹配很多类型
- 比如
- 迭代器语法如下
(a..b).for_each(|a| {
...
});
相当于
for(uintptr_t a=bss_start;
a<bss_end;
a++)
{
*(uint8_t*)a=0;
}
- 闭包语法,比如
let f = |x| {
x + 1
};
println!("{}", f(10));
输出 11
借助 SBI 服务完成输出和关机
附录
一些目标平台
输入 rustup target list 可以查看 rust 支持的目标平台
sodium@nas-MacBook-Air-13 rCore-Tutorial-Code % rustup target list
aarch64-apple-darwin (installed)
aarch64-apple-ios
aarch64-apple-ios-sim
aarch64-linux-android
aarch64-pc-windows-gnullvm
aarch64-pc-windows-msvc
aarch64-unknown-fuchsia
aarch64-unknown-linux-gnu
aarch64-unknown-linux-musl
aarch64-unknown-linux-ohos
aarch64-unknown-none
aarch64-unknown-none-softfloat
aarch64-unknown-uefi
arm-linux-androideabi
arm-unknown-linux-gnueabi
arm-unknown-linux-gnueabihf
arm-unknown-linux-musleabi
arm-unknown-linux-musleabihf
armebv7r-none-eabi
armebv7r-none-eabihf
armv5te-unknown-linux-gnueabi
armv5te-unknown-linux-musleabi
armv7-linux-androideabi
armv7-unknown-linux-gnueabi
armv7-unknown-linux-gnueabihf
armv7-unknown-linux-musleabi
armv7-unknown-linux-musleabihf
armv7-unknown-linux-ohos
armv7a-none-eabi
armv7r-none-eabi
armv7r-none-eabihf
i586-pc-windows-msvc
i586-unknown-linux-gnu
i586-unknown-linux-musl
i686-linux-android
i686-pc-windows-gnu
i686-pc-windows-gnullvm
i686-pc-windows-msvc
i686-unknown-freebsd
i686-unknown-linux-gnu
i686-unknown-linux-musl
i686-unknown-uefi
loongarch64-unknown-linux-gnu
loongarch64-unknown-none
loongarch64-unknown-none-softfloat
nvptx64-nvidia-cuda
powerpc-unknown-linux-gnu
powerpc64-unknown-linux-gnu
powerpc64le-unknown-linux-gnu
riscv32i-unknown-none-elf
riscv32im-unknown-none-elf
riscv32imac-unknown-none-elf
riscv32imafc-unknown-none-elf
riscv32imc-unknown-none-elf
riscv64gc-unknown-linux-gnu
riscv64gc-unknown-none-elf (installed)
riscv64imac-unknown-none-elf
s390x-unknown-linux-gnu
sparc64-unknown-linux-gnu
sparcv9-sun-solaris
thumbv6m-none-eabi
thumbv7em-none-eabi
thumbv7em-none-eabihf
thumbv7m-none-eabi
thumbv7neon-linux-androideabi
thumbv7neon-unknown-linux-gnueabihf
thumbv8m.base-none-eabi
thumbv8m.main-none-eabi
thumbv8m.main-none-eabihf
wasm32-unknown-emscripten
wasm32-unknown-unknown
wasm32-wasi
wasm32-wasip1
wasm32-wasip1-threads
x86_64-apple-darwin
x86_64-apple-ios
x86_64-fortanix-unknown-sgx
x86_64-linux-android
x86_64-pc-solaris
x86_64-pc-windows-gnu
x86_64-pc-windows-gnullvm
x86_64-pc-windows-msvc
x86_64-unknown-freebsd
x86_64-unknown-fuchsia
x86_64-unknown-illumos
x86_64-unknown-linux-gnu
x86_64-unknown-linux-gnux32
x86_64-unknown-linux-musl
x86_64-unknown-linux-ohos
x86_64-unknown-netbsd
x86_64-unknown-none
x86_64-unknown-redox
x86_64-unknown-uefi
其中比较有代表性的可以列成下面的表格
| Target | 架构 | Vendor | OS / 环境 | 典型设备/用途 |
|---|---|---|---|---|
x86_64-unknown-linux-gnu | x86-64 | unknown | Linux | 绝大多数 Linux PC / 服务器 |
x86_64-pc-windows-msvc | x86-64 | PC | Windows | Windows PC,MSVC 工具链 |
x86_64-pc-windows-gnu | x86-64 | PC | Windows | Windows + GNU 工具链 |
aarch64-apple-darwin | ARM64 | Apple | macOS | M芯片 Mac |
aarch64-unknown-linux-gnu | ARM64 | unknown | Linux | ARM Linux 服务器、开发板 |
aarch64-apple-ios | ARM64 | Apple | iOS | iPhone/iPad 真机 |
aarch64-apple-ios-sim | ARM64 | Apple | iOS Simulator | Apple Silicon Mac 上的 iOS 模拟器 |
aarch64-apple-visionos | ARM64 | Apple | visionOS | Apple Vision Pro |
aarch64-apple-watchos | ARM64 | Apple | watchOS | Apple Watch |
riscv64gc-unknown-linux-gnu | RISC-V 64GC | unknown | Linux | RISC-V Linux 设备/服务器 |
riscv64gc-unknown-none-elf | RISC-V 64GC | unknown | 无 OS | rCore、裸机 RISC-V |
riscv32imac-unknown-none-elf | RISC-V 32 | unknown | 无 OS | RISC-V 32 位嵌入式 |
x86_64-unknown-none | x86-64 | unknown | 无 OS | x86 裸机 |
aarch64-unknown-none | ARM64 | unknown | 无 OS | ARM64 裸机 |
powerpc64le-unknown-linux-gnu | PowerPC 64 LE | unknown | Linux | PowerPC 服务器/历史平台 |
s390x-unknown-linux-gnu | IBM Z | unknown | Linux | IBM 大型机 |
loongarch64-unknown-linux-gnu | LoongArch 64 | unknown | Linux | 龙芯平台 |
wasm32-unknown-unknown | WebAssembly 32 | unknown | 无特定 OS | 浏览器 / Wasm 运行时 |
wasm32-wasip2 | WebAssembly 32 | — | WASI | WebAssembly 系统接口环境 |
nvptx64-nvidia-cuda | NVIDIA PTX 64 | NVIDIA | CUDA | GPU kernel / CUDA |
| Target | 架构 | Android 设备/用途 |
|---|---|---|
aarch64-linux-android | ARM64 | 现代 Android 手机的主流架构 |
armv7-linux-androideabi | ARM 32-bit | 老 Android 手机 |
arm-linux-androideabi | ARM 32-bit | 更老的 Android ARM 平台 |
x86_64-linux-android | x86-64 | Android x86_64 模拟器、少数设备 |
i686-linux-android | x86 32-bit | 老 Android x86 |
RustSBI卡死原因分析
尝试 GDB 排查
GDB 调试过程如下
考虑让程序运行一段时间之后查看 PC 地址
Temporary breakpoint 3, 0x0000000080001f34 in ?? ()
(gdb) continue
Continuing.
^C
Program received signal SIGINT, Interrupt.
0x0000000000000000 in ?? ()
(gdb) x/10i $pc
=> 0x0: ❌️ Cannot access memory at address 0x0
(gdb) info registers
ra 0x80003864 0x80003864
sp 0x8000d618 0x8000d618
gp 0x0 0x0
tp 0x0 0x0
t0 0x8 8
t1 0x16 22
t2 0x1680 5760
fp 0x80029930 0x80029930
s1 0x80007658 2147513944
a0 0x80029938 2147653944
a1 0x800074c0 2147513536
a2 0x800074da 2147513562
a3 0x5 5
a4 0x0 0
a5 0x5b 91
a6 0x20 32
a7 0x7f 127
s2 0xa 10
s3 0x8000d7a0 2147538848
s4 0x230 560
s5 0x1 1
s6 0x80005126 2147504422
s7 0x0 0
s8 0x0 0
s9 0x0 0
s10 0x0 0
s11 0x0 0
t3 0x30 48
t4 0x3000 12288
t5 0x20 32
t6 0xf0 240
pc 0x0 0x0
(gdb) info registers mstatus mepc mcause mtval
mstatus 0xa00001800 SD:0 VM:00 MXR:0 PUM:0 MPRV:0 XS:0 FS:0 MPP:3 HPP:0 SPP:0 MPIE:0 HPIE:0 SPIE:0 UPIE:0 MIE:0 HIE:0 SIE:0 UIE:0
mepc 0x0 0
mcause 0x1 1
mtval 0x0 0
| mcause | 含义 |
|---|---|
| 0 | Instruction address misaligned |
| 1 | Instruction access fault |
| 2 | Illegal instruction |
| 3 | Breakpoint |
| 5 | Load access fault |
| 7 | Store access fault |
| 8 | Environment call from U-mode |
推测运行过程中发生了什么错误,导致了 pc 跳转到了 0
尝试反汇编二进制机器码
sodium@nas-MacBook-Air-13 bootloader % riscv64-unknown-elf-objdump \
-D \
-b binary \
-m riscv:rv64 \
rustsbi-qemu.bin > rustsbi-qemu.asm
sodium@nas-MacBook-Air-13 bootloader % head -n 20 rustsbi-qemu.asm
rustsbi-qemu.bin: file format binary
Disassembly of section .data:
0000000000000000 <.data>:
0: 30401073 csrw mie,zero
4: 0000a117 auipc sp,0xa
8: 80410113 addi sp,sp,-2044 # 0x9808
c: 6291 lui t0,0x4
e: f1402373 csrr t1,mhartid
12: 0305 addi t1,t1,1
14: 9116 add sp,sp,t0
16: 137d addi t1,t1,-1
18: fe031ee3 bnez t1,0x14
1c: 00002097 auipc ra,0x2
20: ea8080e7 jalr -344(ra) # 0x1ec4
24: 00002097 auipc ra,0x2
28: 2f8080e7 jalr 760(ra) # 0x231c
sodium@nas-MacBook-Air-13 bootloader % grep mret rustsbi-qemu.asm
2d20: 30200073 mret
2e18: 30200073 mret
2e3a: 30200073 mret
这里反汇编应该整体加 +0x80000000 才是内存中实际地址
尝试设置断点在 0x0 处
(gdb) b *0x0
Breakpoint 3 at 0x0
(gdb) c
Continuing.
Breakpoint 3, 0x0000000000000000 in ?? ()
(gdb) info registers
ra 0x80003864 0x80003864
sp 0x8000d618 0x8000d618
gp 0x0 0x0
tp 0x0 0x0
t0 0x8 8
t1 0x16 22
t2 0x1680 5760
fp 0x80029930 0x80029930
s1 0x80007658 2147513944
a0 0x80029938 2147653944
a1 0x800074c0 2147513536
a2 0x800074da 2147513562
a3 0x5 5
a4 0x0 0
a5 0x5b 91
a6 0x20 32
a7 0x7f 127
s2 0xa 10
s3 0x8000d7a0 2147538848
s4 0x230 560
s5 0x1 1
s6 0x80005126 2147504422
s7 0x0 0
s8 0x0 0
s9 0x0 0
s10 0x0 0
s11 0x0 0
t3 0x30 48
t4 0x3000 12288
t5 0x20 32
t6 0xf0 240
pc 0x0 0x0
(gdb) info registers mepc mcause mtval mtvec
mepc 0x80003f6a 2147499882
mcause 0x5 5
mtval 0x5 5
mtvec 0x0 0
(gdb)
根据寄存器地址并且对照表格发现是执行指令 0x80003f6a 期间访问地址 0x5 时发生的错误
| mcause | 含义 |
|---|---|
| 0 | Instruction address misaligned |
| 1 | Instruction access fault |
| 2 | Illegal instruction |
| 3 | Breakpoint |
| 5 | Load access fault |
| 7 | Store access fault |
| 8 | Environment call from U-mode |
3f5c: ee89 bnez a3,0x3f76
3f5e: 0100000f fence w,unknown
3f62: bfc5 j 0x3f52
3f64: 0100000f fence w,unknown
3f68: 7514 ld a3,40(a0)
3f6a: 0006c683 lbu a3,0(a3) <== 造成异常的指令
3f6e: 0206f693 andi a3,a3,32
3f72: daed beqz a3,0x3f64
3f74: bf45 j 0x3f24
3f76: 01070023 sb a6,0(a4)
3f7a: 7514 ld a3,40(a0)
3f7c: 0006c683 lbu a3,0(a3)
3f80: 0206f693 andi a3,a3,32
3f84: 47a1 li a5,8
3f86: fed9 bnez a3,0x3f24
3f88: 0100000f fence w,unknown
3f8c: 7514 ld a3,40(a0)
3f8e: 0006c683 lbu a3,0(a3)
翻译成 C 类似于
while (1) {
uint8_t status = *(volatile uint8_t *)(*(uint64_t *)(a0+40));
if (status & 0x20)
break;
fence();
}
*(volatile uint8_t *)a4 = a6;
lbu 访问的地址是 0(a3) 也就是5,而这个来源是 40(a0) 读出来的数,查看寄存器发现 a0=0x80029938,也就是错误来源是地址
0x80029960
检查这片内存区域
(gdb) x/20gx 0x80029938
0x80029938: 0x0000000000000000 0x0000000000000001
0x80029948: 0x0000000000000002 0x0000000000000003
0x80029958: 0x0000000000000004 0x0000000000000005 <== 0x05
0x80029968: 0x0000000000000002 0x0000000087e00000
0x80029978: 0x0000000087e013a4 0x0000000000000011
0x80029988: 0x69762d7663736972 0x6d65712c6f697472
0x80029998: 0x0000000000000075 0x0000000000000000
0x800299a8: 0x0000000000000000 0x0000000000000000
0x800299b8: 0x0000000000000000 0x0000000000000000
0x800299c8: 0x0000000000000000 0x0000000000000000
0x80029988:
0x69762d7663736972
0x6d65712c6f697472
72697363762d7669 ===> riscv-vi
7274696f2c71656d ===> rtio,qem
也就是 riscv-virtio,qem
检查 0x8000000 时这片区域的数据发现全是0
Temporary breakpoint 1, 0x0000000080000000 in ?? ()
(gdb) info registers a0 a1
a0 0x0 0
a1 0x87e00000 2279604224
(gdb) x/40gx 0x80029938
0x80029938: 0x0000000000000000 0x0000000000000000
0x80029948: 0x0000000000000000 0x0000000000000000
0x80029958: 0x0000000000000000 0x0000000000000000
0x80029968: 0x0000000000000000 0x0000000000000000
0x80029978: 0x0000000000000000 0x0000000000000000
0x80029988: 0x0000000000000000 0x0000000000000000
0x80029998: 0x0000000000000000 0x0000000000000000
0x800299a8: 0x0000000000000000 0x0000000000000000
0x800299b8: 0x0000000000000000 0x0000000000000000
0x800299c8: 0x0000000000000000 0x0000000000000000
0x800299d8: 0x0000000000000000 0x0000000000000000
0x800299e8: 0x0000000000000000 0x0000000000000000
0x800299f8: 0x0000000000000000 0x0000000000000000
0x80029a08: 0x0000000000000000 0x0000000000000000
0x80029a18: 0x0000000000000000 0x0000000000000000
0x80029a28: 0x0000000000000000 0x0000000000000000
0x80029a38: 0x0000000000000000 0x0000000000000000
0x80029a48: 0x0000000000000000 0x0000000000000000
0x80029a58: 0x0000000000000000 0x0000000000000000
0x80029a68: 0x0000000000000000 0x0000000000000000
(gdb)
判断可能是运行期间发生了什么导致数据被错误覆盖。接下来检查运行过程是什么修改了这片内存
(gdb) watch *0x80029960
Hardware watchpoint 2: *0x80029960
(gdb) continue
Continuing.
Hardware watchpoint 2: *0x80029960
Old value = 0
New value = 5
0x0000000080006a28 in ?? ()
(gdb) x/10i $pc-16
0x80006a18: bnez a2,0x80006a3a
0x80006a1a: j 0x80006a4c
0x80006a1c: blez t0,0x80006a30
0x80006a20: mv a1,a7
0x80006a22: ld a2,0(a1)
0x80006a24: sd a2,0(t2)
=> 0x80006a28: addi t2,t2,8
0x80006a2a: addi a1,a1,8
0x80006a2c: bltu t2,a3,0x80006a22
0x80006a30: add a1,a7,t0
(gdb) info registers t2 a1 a2 a3 a7 t0
t2 0x80029960 2147653984
a1 0x8000d630 2147538480
a2 0x5 5
a3 0x80029968 2147653992
a7 0x8000d608 2147538440
t0 0x30 48
翻译成C大致如下
// a7 = 0x8000d608
// a3 = 0x80029968
a1 = a7;
while (t2 < a3) {
*(uint64_t *)t2 = *(uint64_t *)a1;
t2 += 8;
a1 += 8;
}
这实际上是一个复制内存的操作,将 a7 指向的内存复制到 t2 指向的位置,直到 t2==a3,复制完成后变成
0x80029938: 0x0000000000000000
0x80029940: 0x0000000000000001
0x80029948: 0x0000000000000002
0x80029950: 0x0000000000000003
0x80029958: 0x0000000000000004
0x80029960: 0x0000000000000005
0x80029968: 0x0000000000000002
0x80029970: 0x0000000087e00000
...
(gdb) p/x $ra
$1 = 0x80000422
这段复制操作的调用者是 0x80000422,整理一下目前进展
0x80000422 处的指令发生跳转,最后执行了下面的复制操作
↓
复制地址 0x8000d608~0x8000d630 的数据到 0x80029938~0x80029960 (0,1,2,3,4,5)
↓
地址 0x80029960 在 PC=80006a28 发生复制操作,数值从初始时的 0 变成 5
↓
a3 从地址 0x80029960 读出数值,该数值为5
↓
PC=0x80003f6a: lbu a3,0(a3) <== 造成异常的指令
↓
a3=0x5, 访问0x5地址,发生错误,PC跳转至0,卡死
DeepSeek 排查结果
排查工作交给 DeepSeek后,答复如下:
不是「数据被意外覆盖」,而是 RustSBI 压根没在设备树里找到串口,于是串口基址停留在默认值 0;打印横幅时它要读串口的状态寄存器(偏移 5),算出来地址 = 0 + 5 = 5,去读物理地址 5(一个不存在的地方),触发 load access fault。
完整链条(对照源码)
直接原因是 QEMU 7.2 改了串口节点名 。 设备树里串口节点从 uart@10000000 改名为 serial@10000000(你 dump 出来的 DTB 正是 serial@10000000)。
旧 RustSBI 靠「节点名前缀」找串口,只认 uart:
// rustsbi-qemu/src/device_tree.rs (2022-10 的版本)
const UART: &str = "uart"; // 只有 "uart",没有 "serial"
// 走到 soc 子节点时:
if name.starts_with(UART) || name.starts_with(TEST) || ... { StepInto }
// 读 reg 属性时:
if node.starts_with(UART) { ans.uart = reg.next().unwrap(); }
节点现在叫 serial@...,starts_with("uart") 为 false → 整个串口节点被跳过,ans.uart 保持默认值 0..0。
于是 Ns16550a::new(board_info.uart.start) = new(0),串口基址 = 0。
打印横幅时调用 putchar → send()。16550 发送前要先读 LSR(Line Status Register,偏移 5)判断发送 FIFO 空没空:
lbu a3, 0(a3) # 读 base+5 = 0+5 = 5 ← 你 GDB 里 mepc=0x80003f6a 那条
andi a3, a3, 32 # 测 bit5(THRE 位) ← 你 GDB 里 0x80003f6e 那条
物理地址 5 什么都没有(DRAM 从 0x80000000 起,你工作版横幅也打印了 Platform Memory: 0x80000000..0x88000000)→ mcause=5(load access fault)→ mtvec=0 → PC 跳 0 卡死。
关于你看到的「0,1,2,3,4,5 污染」
这里要稍微纠正一下你 GDB 的解读:a3 = 5 不是「基址被写成了 5」,而是 LSR 的地址 = base(0) + 偏移 5。真正发生的是:因为节点没匹配到,设备描述结构体从头到尾没有被正确填充,基址是 0 而不是 5。你观察到的「一段 0,1,2,3,4,5 被 memcpy 进结构体」,本质就是「该填 0x10000000 的地方一直停在默认/未初始化状态」的下游现象。
为什么课程环境没事、你这里出事
课程仓库 LearningOS/rCore-Tutorial-Code 的 Dockerfile 钉的是 QEMU 7.0.0——那时串口节点还叫 uart@…,旧 SBI 能匹配到。你用的是 QEMU 11.0.3(≥ 7.2),节点已改名叫 serial@…,旧 SBI 就匹配不到了。
修复(源码里明确能看到)
2023-01-20 的提交 1699e34 “fix: 识别 qemu7.2 设备树里的 serial 节点” 加了一行:
const SERIAL: &str = "serial";
// 改成同时匹配两种名字:
if node.starts_with(UART) || node.starts_with(SERIAL) { ... }
官方 rustsbi 0.3.1 二进制包含了这个修复,所以你换上之后就正常了。
完整的因果链如下
QEMU 启动
|
| 生成 DTB
|
v
serial@10000000 {
compatible = "ns16550a";
reg = <0x10000000 0x100>;
}
|
| RustSBI 旧版解析 DTB
| 只识别 uart@
|
v
没有找到 UART
|
v
board_info.uart.start 保持默认值 0
|
v
Ns16550a::new(0)
|
v
RustSBI 启动打印 banner
|
v
UART send()
|
v
读取 LSR:
lbu a3, 0(a3)
a3 = uart_base + 5
= 0 + 5
= 0x5
|
v
访问物理地址 0x5
|
v
load access fault
mcause = 5
mepc = 0x80003f6a
mtval = 5
|
v
mtvec = 0
异常处理地址不存在
|
v
PC = 0
serial@...,starts_with("uart") 为 false 时,ans.uart 保持默认值0,所以串口基址为0,为什么 false 了还能接着用呢
好问题,这正是这个 bug 的本质——不是「false 之后还能接着用」,而是这整条链路上从头到尾都没有「检查是否找到串口」这一步。false 的后果不是报错,而是「跳过更新」,让那个字段安静地保持默认值,然后被后面盲目地用掉。
拆成代码看就很清楚了:
0..0 只是「还没找到」的初始值
let mut ans = BoardInfo {
...
uart: 0..0, // ← 一进来就默认 0,这是个「未找到」的占位
...
};
它本来是个「占位符」:找到串口就覆盖它,没找到就一直是 0。
starts_with("uart") == false 的后果是 StepOver,不是报错
} else if current == Str::from(SOC) {
if name.starts_with(UART) || name.starts_with(TEST) || name.starts_with(CLINT) {
StepInto // 只有名字匹配才走进去读 reg
} else {
StepOver // 不匹配 → 整棵串口子树直接跳过
}
}
serial@10000000 既不 starts_with uart,也不 starts_with test/clint,所以走 StepOver。关键点:这个 StepOver 只是「不进入这个节点」,它没有触发任何「失败」。于是后面那句真正给 ans.uart 赋值的代码——
DtbObj::Property(Property::Reg(mut reg)) => {
if node.starts_with(UART) {
ans.uart = reg.next().unwrap(); // ← 因为上面 StepOver 了,这一行根本不会执行到
}
...
}
——压根没机会运行。
parse() 返回时不做任何校验
// 走完整个设备树后,直接返回,没人问一句「uart 到底找到没有」
ans
没有 assert!、没有 unwrap、没有「如果 uart 还是 0 就 panic」。它把「未找到」和「找到了但基址恰好是 0」这两种情况混为一谈了。
main.rs 拿到结果后也盲目地直接用
let board_info = BOARD_INFO.call_once(|| device_tree::parse(opaque));
SERIAL.call_once(|| unsafe { Ns16550rt) }); // start = 0
Ns16550a::new(0) 内部也只是 MmioSeri验,存个空指针就完事。之后打印时读到0 + 5 = 5 才崩。
所以本质是
这是一类很典型的 「静默默认值传播」bug:
- 设计上假设「串口节点一定存在、且一定叫 uart@…」,所以觉得没必要检查;
- 0..0 被当「未找到」的哨兵值,但没 替换过;
- 结果「没找到」这种异常情况,和「正常找到了」走的是同一条路径,只是带着一个错误的 0 值继续往下跑,直到真正访问硬件时才
如果当时代码写成像下面这样,问题就会
let board_info = device_tree::parse(
assert!(board_info.uart.start != 0, "UART not found in device tree");
(修复提交 1699e34 的解法则是更根本的——直接把 serial 也加入匹配,让串口能被正确找到,0 就不会再出现了。)
相关文件:rustsbi-qemu/src/device_tree.rs,源代码如下
use core::{
fmt::{Display, Formatter, Result},
ops::Range,
};
/// 从设备树采集的板信息。
pub(crate) struct BoardInfo {
pub dtb: Range<usize>,
pub model: StringInline<128>,
pub smp: usize,
pub mem: Range<usize>,
pub uart: Range<usize>,
pub test: Range<usize>,
pub clint: Range<usize>,
}
/// 在栈上存储有限长度字符串。
pub(crate) struct StringInline<const N: usize>(usize, [u8; N]);
impl<const N: usize> Display for StringInline<N> {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
write!(f, "{}", unsafe {
core::str::from_utf8_unchecked(&self.1[..self.0])
})
}
}
/// 解析设备树。
pub(crate) fn parse(opaque: usize) -> BoardInfo {
use dtb_walker::{Dtb, DtbObj, HeaderError as E, Property, Str, WalkOperation::*};
const CPUS: &str = "cpus";
const MEMORY: &str = "memory";
const SOC: &str = "soc";
const UART: &str = "uart";
const SERIAL: &str = "serial";
const TEST: &str = "test";
const CLINT: &str = "clint";
let mut ans = BoardInfo {
dtb: opaque..opaque,
model: StringInline(0, [0u8; 128]),
smp: 0,
mem: 0..0,
uart: 0..0,
test: 0..0,
clint: 0..0,
};
let dtb = unsafe {
Dtb::from_raw_parts_filtered(opaque as _, |e| {
matches!(e, E::Misaligned(4) | E::LastCompVersion(_))
})
}
.unwrap();
ans.dtb.end += dtb.total_size();
dtb.walk(|ctx, obj| match obj {
DtbObj::SubNode { name } => {
let current = ctx.name();
if ctx.is_root() {
if name == Str::from(CPUS) || name == Str::from(SOC) || name.starts_with(MEMORY) {
StepInto
} else {
StepOver
}
} else if current == Str::from(SOC) {
if name.starts_with(UART)
|| name.starts_with(SERIAL)
|| name.starts_with(TEST)
|| name.starts_with(CLINT)
{
StepInto
} else {
StepOver
}
} else {
if current == Str::from(CPUS) && name.starts_with("cpu@") {
ans.smp += 1;
}
StepOver
}
}
DtbObj::Property(Property::Model(model)) if ctx.is_root() => {
ans.model.0 = model.as_bytes().len();
ans.model.1[..ans.model.0].copy_from_slice(model.as_bytes());
StepOver
}
DtbObj::Property(Property::Reg(mut reg)) => {
let node = ctx.name();
if node.starts_with(UART) || node.starts_with(SERIAL) {
ans.uart = reg.next().unwrap();
StepOut
} else if node.starts_with(TEST) {
ans.test = reg.next().unwrap();
StepOut
} else if node.starts_with(CLINT) {
ans.clint = reg.next().unwrap();
StepOut
} else if node.starts_with(MEMORY) {
ans.mem = reg.next().unwrap();
StepOut
} else {
StepOver
}
}
DtbObj::Property(_) => StepOver,
});
ans
}