[RISCV] 用 Rust 写一个 RISC-V BootROM:从 QEMU 到真实硬件(2)

目标平台: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 时配置

CTRLR0BAUDR 等寄存器前必须先把 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,无第三方依赖。

相关推荐
Rust研习社8 小时前
添加依赖库时的 features 是什么?优雅实现编译期条件编译与模块化开发
开发语言·后端·rust
Rust研习社9 小时前
Rust 条件变量(Condvar)详解:线程同步的高效方式
后端·rust·编程语言
Rust研习社9 小时前
Rust Channel 详解:线程间安全通信的利器
后端·rust·编程语言
Source.Liu13 小时前
【A11】身份证号无损压缩到48位的Rust实现
rust
嵌入式小企鹅16 小时前
算力价值重估、AI编程模型齐开源、RISC-V融资15亿
人工智能·学习·ai·程序员·risc-v·前沿科技·太空算力
Rust研习社1 天前
Once、OnceCell、OnceLock:Rust 一次性初始化终极指南
后端·rust·编程语言
Rust研习社1 天前
从入门到实践:Rust 异步编程完全指南
开发语言·后端·rust
Rust研习社1 天前
Rust Pin 解析:核心原理与异步编程实践
开发语言·后端·rust
圆山猫1 天前
[Linux] 用 Buildroot 为 RISC-V QEMU 构建最小根文件系统
linux·运维·risc-v