目标平台:StarFive VisionFive 2(JH7110,RISC-V RV64GC)
工具链:
riscv64imac-unknown-none-elf,Rust 2021 Edition完整代码:本文所有代码均来自实际可编译的项目,两种模式(QEMU / 硬件)均通过验证
[RISCV] 用 Rust 写一个 RISC-V BootROM:从 QEMU 到真实硬件(1)
为什么用 Rust 写 BootROM?
BootROM 运行在芯片上电后的第一个机器周期。它没有操作系统、没有标准库、没有运行时,只有寄存器和内存映射 I/O。这恰好是 Rust 最擅长的舞台:
- 零成本抽象 :
#[inline(always)]、read_volatile、内联汇编------和 C 一样贴近硬件,没有隐藏的运行时开销 - 没有未定义行为:裸机代码里的一个野指针就能让整块板子变砖,Rust 的所有权模型把大量错误推进了编译期
#[cfg(feature)]:一套代码,QEMU 验证 + 真实硬件烧录,切换只需一个编译参数
项目结构
rom/
├── Cargo.toml # features: hw
├── linker.ld # 双 MEMORY 块(注释切换 QEMU/硬件)
├── build.sh # 构建 + QEMU 启动脚本
└── src/
├── start.s # 汇编入口:_start
├── main.rs # bootrom_main():主流程
├── uart.rs # 16550 UART 驱动
├── clint.rs # CLINT:hart 同步、定时器
├── spi.rs # DW_apb_ssi SPI 控制器(仅 hw)
└── sd.rs # SD 卡 SPI 模式驱动(仅 hw)
整体启动流程:
上电(Reset Vector)
└─ _start(start.s)
├─ hart != 0 → WFI 等待
├─ 关闭 M 模式中断
├─ 设置栈指针(_stack_top)
├─ 清零 BSS
├─ 复制 .data:ROM LMA → SRAM VMA(硬件模式)
└─ call bootrom_main()
├─ UART 初始化 + banner
├─ 打印 hartid / DTB 地址 / misa CSR
├─ [hw] SPI 初始化 → SD 卡检测
├─ [hw] 从 SD 卡加载 payload(SPL/U-Boot)
└─ [hw] 跳转到 payload 入口 / [qemu] WFI 挂起
第一步:no_std 项目骨架
toml
# Cargo.toml
[package]
name = "bootrom"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "bootrom"
path = "src/main.rs"
[features]
hw = [] # 启用真实硬件路径
[profile.release]
opt-level = "s" # 优化体积
lto = true # 链接时优化,消除死代码
panic = "abort" # 不生成 unwind 表
codegen-units = 1
strip = "symbols"
toml
# .cargo/config.toml
[build]
target = "riscv64imac-unknown-none-elf"
[target.riscv64imac-unknown-none-elf]
rustflags = [
"-C", "link-arg=-Tlinker.ld",
"-C", "link-arg=--nmagic", # 关闭页对齐,减小二进制体积
]
main.rs 的入口声明:
rust
#![no_std]
#![no_main]
mod clint;
mod uart;
#[cfg(feature = "hw")]
mod spi;
#[cfg(feature = "hw")]
mod sd;
use core::panic::PanicInfo;
// 把 start.s 编译进来,链接器会把 _start 放到最前面
core::arch::global_asm!(include_str!("start.s"));
第二步:链接脚本------双模式内存布局
QEMU virt 机器和 JH7110 真实硬件的内存映射差异很大:
| 区域 | QEMU virt | JH7110 硬件 |
|---|---|---|
| 代码段起始 | 0x8000_0000(DRAM) | 0x2A00_0000(片上掩膜 ROM) |
| 数据段 | 同一片 DRAM | 0x0800_0000(片上 SRAM) |
| CPU 复位后 PC | 0x8000_0000 | 0x2A00_0000 |
在 linker.ld 里用注释块实现切换,只需改动一处:
ld
/* [A] QEMU virt(默认)*/
MEMORY {
RAM (rwx): ORIGIN = 0x80000000, LENGTH = 128M
}
/* [B] JH7110 硬件(注释掉 [A],取消注释 [B])
MEMORY {
ROM (rx): ORIGIN = 0x2A000000, LENGTH = 64K
SRAM (rwx): ORIGIN = 0x08000000, LENGTH = 256K
}
*/
硬件模式下 .data 段有两个地址:LMA (Load Memory Address,存放在 ROM 里)和 VMA (Virtual Memory Address,运行时在 SRAM 里),链接器用 AT > 表达:
ld
/* 硬件模式 SECTIONS 片段 */
.data : ALIGN(8) {
_data_lma = LOADADDR(.data); /* ROM 中的源地址 */
_data_start = .; /* SRAM 中的目标地址 */
*(.data .data.*)
_data_end = .;
} > SRAM AT > ROM
QEMU 模式下 ELF 加载器会直接把各段放到正确地址,_data_lma == _data_start,复制循环立即退出,零开销。
第三步:汇编入口(start.s)
这是 CPU 上电后执行的第一条指令,要做的事情很多,而且完全没有 C 运行时的辅助:
asm
.section .text.start, "ax"
.global _start
_start:
# ① 多核处理:非 hart0 直接去等待
bnez a0, .Lwait_for_ipi
# ② 关闭 M 模式所有中断(防止初始化期间被打断)
csrw mie, zero
csrw mip, zero
csrw mstatus, zero
# ③ 设置栈指针(由 linker script 定义 _stack_top)
la sp, _stack_top
# ④ 清零 BSS(Rust 全局变量依赖此步骤)
la t0, _bss_start
la t1, _bss_end
.Lbss_loop:
bgeu t0, t1, .Lbss_done
sd zero, 0(t0)
addi t0, t0, 8
j .Lbss_loop
.Lbss_done:
# ⑤ 复制 .data 段(ROM LMA → SRAM VMA)
# QEMU:_data_lma == _data_start,循环立即退出
# 硬件:需要手动搬运
la t0, _data_lma
la t1, _data_start
la t2, _data_end
.Ldata_copy:
bgeu t1, t2, .Ldata_done
ld t3, 0(t0)
sd t3, 0(t1)
addi t0, t0, 8
addi t1, t1, 8
j .Ldata_copy
.Ldata_done:
# ⑥ 跳入 Rust(a0=hartid, a1=dtb_addr 由前级固件传入,保持不变)
call bootrom_main
.Lwait_for_ipi:
csrw mie, zero
wfi
j .Lwait_for_ipi
几个关键细节:
为什么要先处理非 hart0? RISC-V 多核板子上电时,所有 hart 同时从复位向量开始执行。如果让 hart1/2/3 也走完初始化流程,它们会竞争栈、重复清零 BSS,造成数据损坏。让它们在 WFI 里省电等待是标准做法。
为什么用 sd 而非 sd zero? sd 是 64 位存储,每次清零 8 字节,比 sb 快 8 倍。BSS 由链接器保证 8 字节对齐(ALIGN(8)),所以这是安全的。
为什么 .data 复制能在 QEMU 和硬件上共用? QEMU 的 ELF 加载器会把 .data 段放到 VMA 地址(_data_start),同时 LOADADDR(.data) 等于 _data_start,于是 bgeu t1, t2 在第一次判断时就成立,整个循环零次执行。硬件上 ROM 和 SRAM 地址不同,循环正常工作。
第四步:UART 驱动------一个参数的差别
JH7110 和 QEMU virt 都使用 16550 兼容 UART,物理地址也相同(0x1000_0000),唯一的差别是时钟频率:
| 平台 | 时钟 | 波特率除数(115200) |
|---|---|---|
| QEMU virt | 3.6864 MHz | 3_686_400 / (16 × 115200) = 2 |
| JH7110 硬件 | 24 MHz | 24_000_000 / (16 × 115200) = 13 |
用 #[cfg(feature)] 在编译期选择:
rust
#[cfg(feature = "hw")]
const BAUD_DIVISOR: u16 = 13;
#[cfg(not(feature = "hw"))]
const BAUD_DIVISOR: u16 = 2;
初始化时操作 LCR.DLAB 位来访问波特率寄存器:
rust
pub fn init() {
write8(IER, 0x00); // 禁用 UART 中断
write8(LCR, 0x80); // DLAB=1,打开分频器访问窗口
write8(DLL, (BAUD_DIVISOR & 0xFF) as u8);
write8(DLM, (BAUD_DIVISOR >> 8) as u8);
write8(LCR, 0x03); // 8N1,DLAB=0
write8(FCR, 0xC7); // 使能 FIFO,清空 TX/RX
}
输出函数避免了格式化字符串(format! 需要 allocator),改为手动的整数转换:
rust
pub fn put_hex(val: usize) {
puts("0x");
if val == 0 { putc(b'0'); return; }
let mut buf = [0u8; 16];
let mut i = 16usize;
let mut v = val;
while v > 0 {
i -= 1;
let nibble = (v & 0xF) as u8;
buf[i] = if nibble < 10 { b'0' + nibble } else { b'a' + nibble - 10 };
v >>= 4;
}
for &c in &buf[i..] { putc(c); }
}
第五步:CLINT------多核同步与定时器
CLINT(Core Local Interruptor)是 RISC-V 标准的 per-hart 中断和定时器控制器。JH7110 的布局与 SiFive CLINT 兼容,QEMU virt 也模拟了相同的接口:
0x0200_0000 + 4 × hartid → MSIP(机器模式软件中断寄存器,写 1 触发 IPI)
0x0200_4000 + 8 × hartid → MTIMECMP(定时器比较值)
0x0200_BFF8 → MTIME(64 位全局计数器,JH7110 @ 4 MHz)
在 wait_for_ipi 里用 wfi 而非纯自旋,当 hart 没有收到中断时会暂停流水线,显著降低功耗:
rust
pub fn wait_for_ipi(hartid: usize) -> ! {
clear_ipi(hartid);
loop {
unsafe { core::arch::asm!("wfi", options(nomem, nostack)); }
let msip = unsafe { msip_ptr(hartid).read_volatile() };
if msip != 0 {
clear_ipi(hartid);
// 生产代码:从共享内存读取入口地址并跳转
}
}
}
第六步:SPI 控制器(JH7110 硬件专属)
JH7110 的 SPI0(0x1302_0000)使用 Synopsys DesignWare SSI(DW_apb_ssi)核。这个控制器有一个反直觉的特性:配置寄存器只有在控制器被禁用时才可写,所以初始化流程是:
rust
pub fn init() {
write32(SSIENR, 0); // 先禁用
// CTRLR0:Motorola SPI Mode 0,8 位帧
// [3:0] DFS=7(8-bit),[5:4] FRF=0(Motorola),[7:6] MODE=0(CPOL=CPHA=0)
write32(CTRLR0, 0x0007);
// 时钟:100 MHz / 4 = 25 MHz(SD 卡初始化后可提速到 50 MHz)
write32(BAUDR, 4);
write32(TXFTLR, 0);
write32(RXFTLR, 0);
write32(SER, 0); // 不选中任何从机
write32(SSIENR, 1); // 重新使能
}
收发一字节用全双工 transfer:发一字节进 TX FIFO,等 RX FIFO 有数据,读出来:
rust
pub fn transfer(byte: u8) -> u8 {
wait_tx_ready(); // 等 TX FIFO 有空位
write32(DR0, byte as u32);
wait_rx_data(); // 等 RX FIFO 有数据
(read32(DR0) & 0xFF) as u8
}
第七步:SD 卡 SPI 模式初始化
SD 卡有两种工作模式:原生 SD 模式(4 线)和 SPI 模式(单线,速度慢但逻辑简单)。BootROM 用 SPI 模式,代价是最高 25 MB/s 而非 50 MB/s,对于加载几百 KB 的 SPL 完全够用。
初始化握手流程
上电后等待 ≥1ms
│
├─ CS 高,发 80+ 个 CLK 脉冲(让卡完成内部初始化)
│
├─ CMD0(GO_IDLE_STATE)→ 进入 SPI 模式
│ 期望响应:R1 = 0x01(IDLE 状态)
│
├─ CMD8(SEND_IF_COND,arg=0x1AA)
│ ├─ 响应 0x01 + echo 0x1AA → SDv2 或 SDHC
│ └─ 响应 0x05(非法命令)→ SDv1
│
├─ ACMD41(SD_SEND_OP_COND)循环等待
│ SDv2/SDHC:arg |= 0x4000_0000(HCS bit,声明支持高容量)
│ 响应变为 0x00 → 初始化完成
│
├─ CMD58(READ_OCR)→ 读 OCR[30](CCS bit)
│ CCS=1 → SDHC/SDXC(块地址)
│ CCS=0 → SDv2(字节地址)
│
└─ CMD16(SET_BLOCKLEN,arg=512)→ 固定块大小(SDHC 可选)
用 Rust 的 Result 处理每一步可能的失败:
rust
impl SdCard {
pub fn init() -> Result<Self, &'static str> {
// 上电延时:80 CLK
spi::cs_high();
for _ in 0..10 { spi::send(0xFF); }
// CMD0:进入 SPI 模式
let mut retry = 0usize;
loop {
let r = send_cmd(CMD0, 0);
spi::cs_high(); spi::recv();
if r == R1_IDLE { break; }
retry += 1;
if retry > 200 { return Err("SD CMD0 timeout"); }
}
// ... CMD8、ACMD41、CMD58、CMD16
}
}
多块读取(CMD18)
加载 256 KB payload 需要读 512 个扇区,用 CMD18(READ_MULTIPLE_BLOCK)而非 512 次 CMD17,避免每次都要重新发送命令和等待数据令牌:
rust
pub fn load(&self, start_lba: u32, dst: &mut [u8]) -> Result<usize, &'static str> {
let nblocks = dst.len() / 512;
let addr = if self.card_type == CardType::SDHC { start_lba }
else { start_lba * 512 }; // SDv1/v2 用字节地址
let r = send_cmd(CMD18, addr);
if r != 0x00 { spi::cs_high(); return Err("SD CMD18 failed"); }
for i in 0..nblocks {
// 等待数据起始令牌 0xFE
let mut token = 0u8;
for _ in 0..65535usize {
token = spi::recv();
if token == 0xFE { break; }
}
// 读 512 字节 + 2 字节 CRC(忽略)
for b in dst[i*512..(i+1)*512].iter_mut() { *b = spi::recv(); }
spi::recv(); spi::recv();
}
// CMD12:停止多块读
spi::recv();
send_cmd(12, 0);
wait_not_busy(1024);
spi::cs_high();
Ok(nblocks * 512)
}
第八步:主流程与跳转
bootrom_main 把所有组件串联起来。用 #[cfg(feature = "hw")] 条件编译让同一份代码在两种模式下行为不同:
rust
#[no_mangle]
pub extern "C" fn bootrom_main(hartid: usize, dtb_addr: usize) -> ! {
uart::init();
uart::puts(" VisionFive 2 BootROM (Rust)\r\n");
if cfg!(feature = "hw") {
uart::puts(" Mode : JH7110 Hardware\r\n");
} else {
uart::puts(" Mode : QEMU virt\r\n");
}
// 打印 misa CSR,验证 ISA 扩展
let misa = read_csr_misa();
uart::puts("[boot] misa: 0x");
uart::put_hex(misa);
print_misa_extensions(misa); // 输出 "IMAC" 等
#[cfg(feature = "hw")]
{
spi::init();
match sd::SdCard::init() {
Ok(card) => {
let dst = unsafe {
core::slice::from_raw_parts_mut(
0x0800_0000 as *mut u8, 256 * 1024
)
};
card.load(2048, dst).expect("SD load failed");
uart::flush(); // 等 UART FIFO 发完再跳转
jump_to(0x0800_0000, hartid, dtb_addr);
}
Err(e) => { uart::puts(e); halt(); }
}
}
#[cfg(not(feature = "hw"))]
halt();
}
跳转时需要保持 a0(hartid)和 a1(dtb_addr)不变,因为下一级 payload(SPL/U-Boot)也遵循同样的 RISC-V 固件调用约定:
rust
fn jump_to(addr: usize, hartid: usize, dtb_addr: usize) -> ! {
unsafe {
core::arch::asm!(
"jr {addr}",
addr = in(reg) addr,
in("a0") hartid,
in("a1") dtb_addr,
options(noreturn)
);
}
}
第九步:在 QEMU 上验证
bash
# 安装工具链(如果还没有)
rustup target add riscv64imac-unknown-none-elf
cargo install cargo-binutils
rustup component add llvm-tools-preview
# 构建 + 启动 QEMU
./build.sh run
build.sh 的 QEMU 命令:
bash
qemu-system-riscv64 \
-machine virt \
-cpu rv64 \
-smp 4 \
-m 128M \
-bios none \ # 不加载 OpenSBI,PC 直接跳到 0x80000000
-kernel target/.../bootrom \ # QEMU 解析 ELF,正确放置各段
-nographic \
-serial mon:stdio
正常输出:
========================================
VisionFive 2 BootROM (Rust)
Target: riscv64imac-unknown-none-elf
Mode : QEMU virt
========================================
[boot] Hart ID : 0
[boot] DTB addr : 0x87e00000
[boot] misa : 0x8000000000141101 (IMAC)
[boot] QEMU mode: next stage not loaded
[boot] Halting. To load a payload, build with --features hw
按 Ctrl-A X 退出 QEMU。
第十步:烧录到真实硬件
切换到硬件模式
① 修改 linker.ld :注释掉 [A] MEMORY 块,取消注释 [B]:
ld
/* [A] 已注释掉
MEMORY { RAM (rwx): ORIGIN = 0x80000000, LENGTH = 128M }
*/
MEMORY {
ROM (rx): ORIGIN = 0x2A000000, LENGTH = 64K
SRAM (rwx): ORIGIN = 0x08000000, LENGTH = 256K
}
同样操作 SECTIONS 中的 [A]/[B] 块。
② 构建硬件版本:
bash
./build.sh hw
# 或
cargo build --release --features hw
rust-objcopy -O binary target/riscv64imac-unknown-none-elf/release/bootrom bootrom.bin
③ 写入 SD 卡:
VisionFive 2 的 SPL 存放在 SD 卡第一个分区起始 LBA 2048(1 MiB 偏移处),替换 /dev/sdX 为实际设备:
bash
sudo dd if=bootrom.bin of=/dev/sdX bs=512 seek=2048 conv=fsync
sync
警告 :
dd直接写原始扇区,会覆盖该位置的原有数据。确认设备路径正确后再操作。
④ 串口连接:
VisionFive 2 的调试串口在 40-pin GPIO 头的第 8/10 脚(UART TX/RX),波特率 115200 8N1。用 minicom 或 picocom 连接:
bash
picocom -b 115200 /dev/ttyUSB0
QEMU 到硬件的差异清单
| 项目 | QEMU virt | JH7110 硬件 |
|---|---|---|
| 复位向量 | 0x8000_0000 | 0x2A00_0000 |
| UART 时钟 | 3.6864 MHz(除数=2) | 24 MHz(除数=13) |
.data 复制 |
不需要(ELF 加载器处理) | 需要(ROM→SRAM) |
| SD 卡驱动 | 不可用(virt 无 DW_apb_ssi) | DW_apb_ssi @ 0x1302_0000 |
| CLINT 地址 | 0x0200_0000(兼容) | 0x0200_0000(相同) |
几个值得记录的坑
1. 链接器脚本不支持嵌套注释
GNU ld 和 LLVM lld 都不支持 /* 外层 /* 内层 */ */ 嵌套注释。如果在被注释掉的 [B] 硬件 MEMORY 块里也写了 /* 片上 ROM */ 之类的注释,链接器会报错:
rust-lld: error: linker.ld:28: unknown directive: SRAM
解决:被注释掉的块内部改用行注释(链接器脚本也支持 //)。
2. 跳转前必须 flush UART
uart::flush() 等待 LSR.TEMT(Transmitter Empty)置位,确保 shift register 也清空,而不只是 FIFO 空(THRE)。否则最后几个字节会在跳转到 payload 后才输出,混入 payload 的输出流。
3. DW_apb_ssi 只能在 SSIENR=0 时配置
写 CTRLR0、BAUDR 等寄存器前必须先把 SSIENR 写 0。直接配置会被硬件忽略,没有任何错误提示,症状是 SPI 时钟速率不对或帧格式错误。
4. SD 卡 CMD0 需要正确 CRC
SPI 模式下只有 CMD0 和 CMD8 检查 CRC,其他命令可以发 0xFF 哑元 CRC。但 CMD0 的 CRC 必须是 0x95,否则卡不会响应 R1_IDLE,初始化就卡死在那里。
5. mip 不一定可写
csrw mip, zero 在某些实现(包括部分 QEMU 版本)上会触发非法指令异常,因为 mip 的某些位是只读的(反映外部中断状态)。如果遇到这个问题,去掉这行;mie=0 已经足够阻止中断响应。
总结
用 Rust 写 BootROM 的核心收益不是语法糖,而是编译期的确定性:
#[cfg(feature = "hw")]让 QEMU 验证路径和硬件路径在同一份代码里并存,不需要维护两套read_volatile/write_volatile明确表达"这次访问有副作用",不会被优化器重排或消除-> !的发散函数类型让编译器在bootrom_main里的halt()没有覆盖所有路径时直接报错,而不是在硬件上静默执行垃圾指令
整个项目(含注释)约 700 行,两种模式均通过编译,QEMU 端输出正常。硬件端需要根据实际 JH7110 SPI 时序微调 BAUDR 和 SD 初始化重试次数,但结构无需改动。
bash
# 最终的两条构建命令
cargo build --release # QEMU 验证
cargo build --release --features hw # 硬件烧录
代码位于 src/ 目录,所有文件均可独立阅读,每个驱动只依赖 core crate,无第三方依赖。