每一块芯片上电的第一件事,是从一个固定地址取出第一条指令。那段在黑暗中率先醒来的代码,就是 BootROM。

本文用 Rust 从零实现一个 RISC-V BootROM,先在 QEMU virt 机器上跑通,再讨论移植到 VisionFive 2(JH7110)这样的真实硅片上需要注意什么。

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


一、环境准备

# Rust 裸机目标
rustup target add riscv64imac-unknown-none-elf

# RISC-V 交叉工具链(用于检查反汇编)
sudo apt install gcc-riscv64-linux-gnu

# QEMU
sudo apt install qemu-system-riscv64

验证:

qemu-system-riscv64 --version
riscv64-linux-gnu-objdump --version

二、项目结构

rom/
├── Cargo.toml
├── linker.ld           ← 内存布局(当前为 QEMU 配置)
├── .cargo/
│   └── config.toml     ← 默认 target
└── src/
    ├── main.rs         ← Rust 入口
    ├── start.s         ← 汇编入口(上电第一条指令)
    ├── uart.rs         ← 16550 UART 驱动
    └── clint.rs        ← 多核同步

三、基础配置

.cargo/config.toml

[build]
target = "riscv64imac-unknown-none-elf"

[target.riscv64imac-unknown-none-elf]
rustflags = [
    "-C", "link-arg=-Tlinker.ld",
    "-C", "link-arg=--nmagic",
]

Cargo.toml

[package]
name = "bootrom"
version = "0.1.0"
edition = "2021"

[profile.release]
opt-level = "s"      # 优化体积而非速度
lto = true
panic = "abort"      # 裸机环境不能 unwind
codegen-units = 1

四、Linker Script:内存布局的关键

Linker Script 告诉链接器:代码和数据应该放在哪里。

QEMU virt 配置

QEMU virt 机器没有独立的 ROM 和 SRAM,使用 -bios none 时 CPU 直接从 DRAM 0x80000000 开始取指:

ENTRY(_start)

MEMORY {
    RAM (rwx): ORIGIN = 0x80000000, LENGTH = 128M
}

SECTIONS {
    . = ORIGIN(RAM);

    .text : {
        KEEP(*(.text.start))   /* _start 必须在最前 */
        *(.text .text.*)
    } > RAM

    .rodata : ALIGN(4) { *(.rodata .rodata.*) } > RAM

    .data : ALIGN(4) {
        _data_start = .;
        *(.data .data.*)
        _data_end = .;
    } > RAM

    .bss (NOLOAD) : ALIGN(4) {
        _bss_start = .;
        *(.bss .bss.*)
        *(COMMON)
        _bss_end = .;
    } > RAM

    _stack_top = _bss_end + 0x10000;  /* 栈:BSS 之后 64KB */

    /DISCARD/ : { *(.eh_frame) *(.debug_*) }
}

VF2 / JH7110 硬件配置

真实硬件有分离的地址空间:mask ROM 是只读的,可读写数据必须放在 SRAM:

MEMORY {
    ROM (rx) : ORIGIN = 0x2A000000, LENGTH = 64K   /* mask ROM */
    RAM (rwx): ORIGIN = 0x08000000, LENGTH = 256K  /* L2 LIM SRAM */
}

SECTIONS {
    .text   : { KEEP(*(.text.start)) *(.text .text.*) } > ROM
    .rodata : { *(.rodata .rodata.*) } > ROM

    /* .data 的加载地址(LMA)在 ROM,运行地址(VMA)在 RAM */
    _data_load = LOADADDR(.rodata) + SIZEOF(.rodata);
    .data : AT(_data_load) {
        _data_start = .;
        *(.data .data.*)
        _data_end = .;
    } > RAM

    .bss (NOLOAD) : {
        _bss_start = .;
        *(.bss .bss.*)
        *(COMMON)
        _bss_end = .;
    } > RAM

    _stack_top = _bss_end + 0x8000;
}

关键概念:LMA vs VMA

  • VMA(Virtual Memory Address):程序运行时访问的地址
  • LMA(Load Memory Address):数据实际存储的地址

硬件上 .data 段被烧写在 ROM(LMA),但 C/Rust 代码访问全局变量时用的是 RAM 地址(VMA)。两者不一致,必须在启动代码里手动复制。


五、汇编入口 start.s

这是 CPU 上电后执行的第一段代码,必须用汇编完成 Rust 运行环境的最低要求:

QEMU 版本

.section .text.start
.global _start
.extern _stack_top

_start:
    csrw mie, zero          # 关闭所有中断

    la   sp, _stack_top     # 设置栈指针

    # 清零 BSS 段(未初始化全局变量默认为 0)
    la   t0, _bss_start
    la   t1, _bss_end
1:  bgeu t0, t1, 2f
    sd   zero, 0(t0)
    addi t0, t0, 8
    j    1b
2:
    # QEMU 的 ELF 加载器已把各段放到正确地址,无需复制 .data
    call bootrom_main

_halt:
    wfi
    j _halt

VF2 硬件版本(额外增加 .data 复制)

    # 在跳转 Rust 之前,从 ROM 复制 .data 到 RAM
    la   t0, _data_start    # RAM 目标地址(VMA)
    la   t1, _data_end
    la   t2, _data_load     # ROM 源地址(LMA)
3:  bgeu t0, t1, 4f
    ld   t3, 0(t2)
    sd   t3, 0(t0)
    addi t0, t0, 8
    addi t2, t2, 8
    j    3b
4:
    call bootrom_main

为什么 QEMU 不需要复制 .data

QEMU 作为 ELF 加载器,会解析 ELF 的 Program Header,把每个段直接写到对应的 VMA。所有数据已经在正确位置。真实硬件上没有这样的加载器,烧写进 ROM 的只是原始二进制,LMA 就是存放位置,VMA 的 RAM 地址需要代码自己去填充。


六、UART 驱动

JH7110 和 QEMU virt 都使用 16550 兼容 UART,基地址相同(0x10000000),但时钟频率不同,导致波特率分频值不同:

divisor = f c l k 16 × baudrate \text{divisor} = \frac{f_{clk}}{16 \times \text{baudrate}} divisor=16×baudratefclk

平台时钟115200 bps 分频值
QEMU virt3.6864 MHz2
VF2 JH711024 MHz13
// UART0 基地址(两个平台相同)
const UART0_BASE: usize = 0x1000_0000;

pub fn init() {
    write(IER, 0x00);   // 关闭中断
    write(LCR, 0x80);   // DLAB=1,准备设置波特率

    // QEMU virt: 改为 13 即可用于 VF2
    write(DLL, 2);      // 分频值低字节
    write(DLM, 0);      // 分频值高字节

    write(LCR, 0x03);   // 8N1,DLAB=0
    write(FCR, 0x07);   // 使能并清空 FIFO
}

七、Rust 主函数

#![no_std]
#![no_main]

mod uart;
mod clint;

use core::panic::PanicInfo;

core::arch::global_asm!(include_str!("start.s"));

#[no_mangle]
pub extern "C" fn bootrom_main(hartid: usize, dtb_addr: usize) -> ! {
    // 多核:只让 hart 0 执行初始化
    if hartid != 0 {
        clint::wait_for_ipi();
    }

    uart::init();
    uart::puts("[BootROM] starting...\r\n");
    uart::puts("[BootROM] Hart ID: ");
    uart::put_hex(hartid);
    uart::puts("\r\n[BootROM] DTB @ ");
    uart::put_hex(dtb_addr);
    uart::puts("\r\n");

    loop { core::hint::spin_loop(); }
}

#[panic_handler]
fn panic(_: &PanicInfo) -> ! {
    uart::puts("[PANIC]\r\n");
    loop { core::hint::spin_loop(); }
}

八、在 QEMU 上运行

cargo build --release

qemu-system-riscv64 \
    -machine virt \
    -nographic \
    -bios none \
    -kernel target/riscv64imac-unknown-none-elf/release/bootrom

输出:

[BootROM] starting...
[BootROM] Hart ID: 0x0
[BootROM] DTB @ 0x87e00000

退出:Ctrl+A 然后 X


九、移植到 VF2 真实硬件的注意事项

9.1 地址空间完全不同

资源QEMU virtVF2 JH7110
代码入口0x800000000x2A000000
SRAM不存在0x08000000(L2 LIM,256KB)
UART00x100000000x10000000
CLINT0x020000000x02000000
DRAM0x800000000x40000000

9.2 必须手动复制 .data

如第五节所述,真实硬件上 .data 的 LMA(ROM)和 VMA(RAM)不同,汇编启动代码必须完成复制,否则所有初始化的全局变量值都是错的。

9.3 烧写格式是裸二进制,不是 ELF

QEMU 可以直接加载 ELF 文件,真实硬件的 ROM 控制器只认裸二进制:

# 生成裸二进制
riscv64-linux-gnu-objcopy -O binary \
    target/riscv64imac-unknown-none-elf/release/bootrom \
    bootrom.bin

# 确认起始字节是合法指令(不是 ELF magic 0x7F454C46)
xxd bootrom.bin | head -2

9.4 时钟初始化

QEMU 的时钟是模拟的,永远"正确"。真实硬件上电后时钟可能处于低速默认状态(JH7110 默认用内部 RC 振荡器),访问外设前通常需要先初始化 PLL,否则 UART 波特率、DDR 时序都会出错。

9.5 DDR 初始化

QEMU 的内存是模拟的,直接可用。真实硬件的 DDR SDRAM 需要初始化控制器(PHY 训练、时序配置),这是 BootROM 最复杂的部分之一,VF2 的 DDR 初始化代码有数千行。

9.6 多核启动

QEMU 默认只启动 hart 0,其他 hart 处于等待状态。VF2 有 5 个 hart(1 个 S7 监控核 + 4 个 U74 应用核),上电后所有 hart 同时从 mask ROM 入口开始执行,必须在汇编入口处用 hart ID 区分,非 boot hart 立即进入低功耗等待。

9.7 安全启动

真实产品的 BootROM 通常需要验证下一级固件的签名(RSA/ECDSA),防止启动未经授权的代码。QEMU 环境无需此步骤。


十、QEMU vs 硬件改动对照表

改动项QEMU virtVF2 JH7110
linker.ld 入口地址0x800000000x2A000000
linker.ld 内存分区单一 RAMROM + SRAM 分离
start.s .data 复制不需要必须
UART 分频值 (DLL)213
烧写格式ELF(QEMU 直接加载)裸 binary(objcopy -O binary
时钟初始化不需要需要初始化 PLL
DDR 初始化不需要需要(数千行)
多核处理hart 0 自动启动所有 hart 同时起,需手动分流

结语

从 QEMU 到真实硅片,本质上是从"有人替你准备好一切"到"你就是那个准备好一切的人"的转变。

QEMU 帮你加载 ELF、初始化内存、设置时钟——在硬件上,这些都要你自己动手。理解这一点,也就理解了为什么固件工程师总说"在板子上跑通才算数"。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐