Introduction

本文记录了笔者完成 rCore Tutorial Book 第一章的过程,包括配置 rust 环境,编译普通的 hello world rust程序,以及实现内核第一条指令、最终借助 rustSBI 打印出 hello world 并关机的过程,大致经过如下

  1. 配置普通的 rust 环境,编译 hello world
  2. 配置 riscv64gc 的 rust 裸机目标平台,进行交叉编译出裸机 risc-v 程序
  3. 编写链接脚本,使得编译出的 risc-v ELF 文件的内存布局满足第一条指令在 0x80200000 处
  4. 裁切 ELF ,去掉 headers 只留下 section 部分,生成 .bin 镜像文件,并将其载入到 Qemu 中
  5. 借助 rustSBI 和 qemu 进行运行,用 GDB 检查 PC 是否如预期一般跳到了 0x80200000 并执行内核的第一条指令
  6. 修改 entry.asm 分配一块栈空间,使得 rust 函数调用能够正常进行,并在 rust 程序中初始化 .bss 区域
  7. 使用 RustSBI 提供的服务完成打印 hello world 并关机

相关链接如下

Rust The Programming Language 环境配置说明

rCore Tutorial Book

rCore 官方仓库

建议使用的 RustSBI 镜像

rCore THU 课程仓库

注:不要使用 THU 课程仓库中的 RustSBI 镜像,如果使用的 Qemu 版本较高(>=7.2.0),设备结点名发生更改会造成 RustSBI 无法正常运行,详见:

RustSBI卡死原因

git clone https://github.com/rcore-os/rCore-Tutorial-v3.git

RustSBI 官方仓库

环境配置

在 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

编译器报错信息如下

  1. 没有声明 #![no_std] ,认为项目需要 std,而目标平台可能并不支持标准库(standard library)
  2. std 错误导致 Rust 默认提供的包含了各种常用类型/功能的 prelude 都没办法正常建立。
  3. 因为没有 std 提供的标准输出环境,println 缺少宏定义
  4. 编译一个 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

  1. 添加 #![no_std] ,放弃标准库,改用核心库
  2. 注释掉 println 宏
  3. 新建文件 os/src/lang_items.rs,实现 #[panic_handler] 指向的 panic 函数
  4. 添加 #![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

这里的警告信息主要是两点

  1. main 未使用
  2. _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 物理内存上的物理地址

启动流程可以分为三个部分

  1. PC=0x1000: 在必要的文件载入内存之后,Qemu 会将 PC 初始化为 0x1000,执行若干指令(QEMU 内置启动代码)后跳转至
  2. PC=0x80000000: 该跳转地址不可更改,一般存放的是 bootloader 的位置。这里存放了 RustSBI,会对计算机进行一些初始化操作,然后跳转至
    • 和 xv6 不同,M-Mode 部分不归 rCore 处理,而是使用了 RustSBI
  3. PC=0x80200000: RustSBI 将下一阶段的入口地址预先约定为固定的 0x80200000,这里存放了内核镜像,到这里计算机的控制权已经被移交给内核

真实计算机的加电启动流程也是类似的三部分

  1. 加电后 CPU 的 PC 寄存器被设置为计算机内部只读存储器(ROM,Read-only Memory)的物理地址,随后 CPU 开始运行 ROM 内的软件(Firmware)。
    • 这部分代码会完成一部分 CPU 初始化工作,并将 bootloader 的代码、数据等从硬盘加载到内存中,最后跳转地址,控制权交给 bootloader
  2. bootloader 同样完成一些 CPU 的初始化工作,将操作系统镜像从硬盘加载到物理内存中,最后跳转到适当地址将控制权转移给操作系统。
  3. 控制权被转移给操作系统。

程序内存布局

一种典型的程序布局如下

  1. Code Memory
    • .text: 存放程序的所有汇编代码
  2. Data Memory
    • .rodata: 存放只读的全局数据,通常是一些常数或者是 常量字符串等
    • .data: 存放可修改的全局数据
    • .bss: 保存程序中那些未初始化的全局数据,通常由程序的加载者代为进行零初始化,即将这块区域逐字节清零
    • heap: 程序运行时动态分配的数据
    • stack: 用于函数调用上下文的保存与恢复,存放每个函数作用域内的局部变量,对应寄存器 sp
区域典型内容汇编访问的主要途径典型形式
.text指令pcPC → 指令
.rodata常量、字符串pc / a寄存器 / gp 等auipc + offset
.data已初始化全局变量gp / pc 相对地址lw ..., offset(gp)
.bss未初始化全局变量gp / pc 相对地址sw ..., offset(gp)
heapmalloc/Box 等动态对象指针寄存器lw ..., 0(a0)
stack局部变量、保存的寄存器sp / fp(s0)lw ..., offset(sp)

编译流程

从源代码得到可执行文件的编译流程可以划分为三个部分

  1. 源代码到汇编:由编译器负责
  2. 汇编到机器码/目标文件:由汇编器负责
  3. 目标文件和外部目标文件到可执行文件:链接器负责
    • 链接器需要将不同目标文件的段重新排布,.text 放到一块,.data 放到一块等等,避免地址冲突
    • 此时内存布局已确定,可以将符号替换为具体地址

编写第一条指令

 # os/src/entry.asm
     .section .text.entry
     .globl _start
 _start:
     li x1, 100
  1. _start 是入口地址的标志,地址为 li x1, 100 的地址
  2. .globl _start 表明 _start 是全局符号,可以被其他目标文件使用
  3. .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) 设置目标平台为 riscv
  • ENTRY(_start) 设置程序入口为 _start
  • BASE_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 指令。

  1. 初始时 t0=0x1000, a2=0x1028, a0=mhartid, a1=0,随后 t0=0x80000000
  2. PC 跳转至 0x80000000 执行 rustsbi 部分
  3. 执行完成后,正常情况下应该跳到 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 进行了分析,详见附录

RustSBI卡死原因

为内核支持函数调用

在前面的步骤中,本文已经实现内核的第一条指令,但是却是通过汇编的形式来执行的,如果能使用函数调用,从而进入 rust 编写的内核函数入口,控制权就可以移交给 rust 代码。

原理:函数调用与栈

Risc-v 进行跳转的指令主要有两条

  • jal rd, imm[20:1]: rd = pc+4, pc = pc+imm
  • jalr 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( x10x17 )调用者保存用来传递输入参数。其中的 a0 和 a1 还用来保存返回值。
t0t6( x5x7,x28~x31 )调用者保存作为临时寄存器使用,在被调函数中可以随意使用无需保存。
s0s11( x8x9,x18~x27 )被调用者保存作为临时寄存器使用,被调函数保存后才能在被调函数中使用。
  • x0: 恒为零,无影响
  • x1(ra): 被调用者保存,在函数开头压入栈,在 ret 前恢复
  • x2(sp): 被调用者保存
  • s0(fp): 可作为 s0 或者 frame pointer 寄存器,被调用者保存,指向当前栈帧起始地址,相比之下 sp 指向栈顶
  • x3(gp), x4 (tp): 一般不会变化,这里不用保存

在函数调用期间,大致流程如下,如果没有优化的话

  1. 调用者将 a0a7, t0t6 压入栈中,fp 指向栈起始(高地址),sp 指向栈顶(低地址)
  2. 被调用者入栈(修改 sp),用于保存 ra, fp, ,以及保存其他寄存器,更新 fp ,然后执行其他部分
  3. 返回时,恢复 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 = sbss
    • path 可以匹配很多类型
  • 迭代器语法如下
(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架构VendorOS / 环境典型设备/用途
x86_64-unknown-linux-gnux86-64unknownLinux绝大多数 Linux PC / 服务器
x86_64-pc-windows-msvcx86-64PCWindowsWindows PC,MSVC 工具链
x86_64-pc-windows-gnux86-64PCWindowsWindows + GNU 工具链
aarch64-apple-darwinARM64ApplemacOSM芯片 Mac
aarch64-unknown-linux-gnuARM64unknownLinuxARM Linux 服务器、开发板
aarch64-apple-iosARM64AppleiOSiPhone/iPad 真机
aarch64-apple-ios-simARM64AppleiOS SimulatorApple Silicon Mac 上的 iOS 模拟器
aarch64-apple-visionosARM64ApplevisionOSApple Vision Pro
aarch64-apple-watchosARM64ApplewatchOSApple Watch
riscv64gc-unknown-linux-gnuRISC-V 64GCunknownLinuxRISC-V Linux 设备/服务器
riscv64gc-unknown-none-elfRISC-V 64GCunknown无 OSrCore、裸机 RISC-V
riscv32imac-unknown-none-elfRISC-V 32unknown无 OSRISC-V 32 位嵌入式
x86_64-unknown-nonex86-64unknown无 OSx86 裸机
aarch64-unknown-noneARM64unknown无 OSARM64 裸机
powerpc64le-unknown-linux-gnuPowerPC 64 LEunknownLinuxPowerPC 服务器/历史平台
s390x-unknown-linux-gnuIBM ZunknownLinuxIBM 大型机
loongarch64-unknown-linux-gnuLoongArch 64unknownLinux龙芯平台
wasm32-unknown-unknownWebAssembly 32unknown无特定 OS浏览器 / Wasm 运行时
wasm32-wasip2WebAssembly 32—WASIWebAssembly 系统接口环境
nvptx64-nvidia-cudaNVIDIA PTX 64NVIDIACUDAGPU kernel / CUDA
Target架构Android 设备/用途
aarch64-linux-androidARM64现代 Android 手机的主流架构
armv7-linux-androideabiARM 32-bit老 Android 手机
arm-linux-androideabiARM 32-bit更老的 Android ARM 平台
x86_64-linux-androidx86-64Android x86_64 模拟器、少数设备
i686-linux-androidx86 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含义
0Instruction address misaligned
1Instruction access fault
2Illegal instruction
3Breakpoint
5Load access fault
7Store access fault
8Environment 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含义
0Instruction address misaligned
1Instruction access fault
2Illegal instruction
3Breakpoint
5Load access fault
7Store access fault
8Environment 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仓库

相关文件: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
}