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

相关推荐
一个天蝎座 白勺 程序猿2 小时前
AI入门踩坑实录:我换了3种语言才敢说,Python真的是入门唯一选择吗?
开发语言·人工智能·python·ai
Hui_AI7202 小时前
保险条款NLP解析与知识图谱搭建:让AI准确理解保险产品的技术方案
开发语言·人工智能·python·算法·自然语言处理·开源·开源软件
杜子不疼.2 小时前
用 Python 搭建本地 AI 问答系统:避开 90% 新手都会踩的环境坑
开发语言·人工智能·python
执于代码2 小时前
python 常见的框架
开发语言·python
AI老李2 小时前
【Python】6 种方法轻松将 Python 脚本打包成 EXE 应用
开发语言·python
大G的笔记本2 小时前
redis常用场景-java示例
java·开发语言·redis
xieliyu.2 小时前
Java手搓数据结构:从零模拟实现顺序表增删改查
java·开发语言·数据结构·学习·顺序表
没有羊的王K2 小时前
机器学习指标解析:AUC与KS值
开发语言·python
千江明月2 小时前
Ollama安装的详细步骤以及Python调用Qwen
开发语言·python·ollama·qwen模型