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

每一块芯片上电的第一件事,是从一个固定地址取出第一条指令。那段在黑暗中率先醒来的代码,就是 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、初始化内存、设置时钟------在硬件上,这些都要你自己动手。理解这一点,也就理解了为什么固件工程师总说"在板子上跑通才算数"。

相关推荐
jerryinwuhan6 小时前
基于各城市站点流量的复合功能比较
开发语言·php
迈巴赫车主7 小时前
Java基础:list、set、map一遍过
java·开发语言
南 阳8 小时前
Python从入门到精通day66
开发语言·python
红尘散仙9 小时前
一套 Rust 核心,跑通 Tauri + React Native
react native·react.js·rust
feasibility.9 小时前
反爬十层妖塔:现代爬虫攻防的立体战争
爬虫·python·科技·scrapy·rust·go·硬件
十八旬9 小时前
快速安装ClaudeCode完整指南
开发语言·windows·python·claude
前进的李工9 小时前
EXPLAIN输出格式全解析:JSON、TREE与可视化
开发语言·数据库·mysql·性能优化·explain
Byron Loong10 小时前
【c++】为什么有了dll和.h,还需要包含lib
java·开发语言·c++
独隅10 小时前
CodeX + Visual Studio Code 联动的全面指南
开发语言·php
坚果派·白晓明10 小时前
【鸿蒙PC三方库移植适配框架解读系列】第一篇:Lycium C/C++ 三方库适配 — 概述与环境配置
c语言·开发语言·c++·harmonyos·开源鸿蒙·三方库·c/c++三方库