每一块芯片上电的第一件事,是从一个固定地址取出第一条指令。那段在黑暗中率先醒来的代码,就是 BootROM。
本文用 Rust 从零实现一个 RISC-V BootROM,先在 QEMU virt 机器上跑通,再讨论移植到 VisionFive 2(JH7110)这样的真实硅片上需要注意什么。
一、环境准备
bash
# Rust 裸机目标
rustup target add riscv64imac-unknown-none-elf
# RISC-V 交叉工具链(用于检查反汇编)
sudo apt install gcc-riscv64-linux-gnu
# QEMU
sudo apt install qemu-system-riscv64
验证:
bash
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
toml
[build]
target = "riscv64imac-unknown-none-elf"
[target.riscv64imac-unknown-none-elf]
rustflags = [
"-C", "link-arg=-Tlinker.ld",
"-C", "link-arg=--nmagic",
]
Cargo.toml
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 开始取指:
ld
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:
ld
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 版本
asm
.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 复制)
asm
# 在跳转 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=fclk16×baudrate\text{divisor} = \frac{f_{clk}}{16 \times \text{baudrate}}divisor=16×baudratefclk
| 平台 | 时钟 | 115200 bps 分频值 |
|---|---|---|
| QEMU virt | 3.6864 MHz | 2 |
| VF2 JH7110 | 24 MHz | 13 |
rust
// 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 主函数
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 上运行
bash
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 virt | VF2 JH7110 |
|---|---|---|
| 代码入口 | 0x80000000 |
0x2A000000 |
| SRAM | 不存在 | 0x08000000(L2 LIM,256KB) |
| UART0 | 0x10000000 |
0x10000000 ✓ |
| CLINT | 0x02000000 |
0x02000000 ✓ |
| DRAM | 0x80000000 |
0x40000000 |
9.2 必须手动复制 .data 段
如第五节所述,真实硬件上 .data 的 LMA(ROM)和 VMA(RAM)不同,汇编启动代码必须完成复制,否则所有初始化的全局变量值都是错的。
9.3 烧写格式是裸二进制,不是 ELF
QEMU 可以直接加载 ELF 文件,真实硬件的 ROM 控制器只认裸二进制:
bash
# 生成裸二进制
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 virt | VF2 JH7110 |
|---|---|---|
| linker.ld 入口地址 | 0x80000000 |
0x2A000000 |
| linker.ld 内存分区 | 单一 RAM | ROM + SRAM 分离 |
| start.s .data 复制 | 不需要 | 必须 |
| UART 分频值 (DLL) | 2 |
13 |
| 烧写格式 | ELF(QEMU 直接加载) | 裸 binary(objcopy -O binary) |
| 时钟初始化 | 不需要 | 需要初始化 PLL |
| DDR 初始化 | 不需要 | 需要(数千行) |
| 多核处理 | hart 0 自动启动 | 所有 hart 同时起,需手动分流 |
结语
从 QEMU 到真实硅片,本质上是从"有人替你准备好一切"到"你就是那个准备好一切的人"的转变。
QEMU 帮你加载 ELF、初始化内存、设置时钟------在硬件上,这些都要你自己动手。理解这一点,也就理解了为什么固件工程师总说"在板子上跑通才算数"。