[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、初始化内存、设置时钟------在硬件上,这些都要你自己动手。理解这一点,也就理解了为什么固件工程师总说"在板子上跑通才算数"。

相关推荐
郝学胜-神的一滴10 分钟前
C++ Templates 07:编译报错和调试手段与实战技巧
开发语言·c++·后端·软件工程·visual studio
害人终害己19 分钟前
Redisson 连接 Redis 失败 报错解决方案
java·开发语言
jimy129 分钟前
c++的静态类型和动态类型(二)
开发语言·c++
吠品29 分钟前
箭头函数为什么不能 new
开发语言·javascript·原型模式
谢亮_vipxieliang37 分钟前
PHP 8 新特性:构造器属性提升与实战
开发语言·后端·php
鬼手点金1 小时前
opencode-crawl4ai使用建议
java·开发语言·人工智能·python·机器学习
谢亮_vipxieliang1 小时前
Go select 多路复用:从语法到实战的完整指南
开发语言·后端·golang
2601_9628857211 小时前
如何用 Python 自动识别股票的支撑位与压力位?
开发语言·python
爱吃奥利奥_wen11 小时前
面向对象三板斧:封装、继承、多态,到底在“装“什么、“承“什么、“变“什么?
开发语言·经验分享·c#
我可能是个假开发11 小时前
FTP Unicode 文件名导致 `550` 的问题分析与解决方案
java·开发语言·spring