第二章:RISC-V处理器内核启动流程:Bootloader加载、PC指针初始化、异常向量表设置

大家好,我是你们的FPGA与RISC-V调试课讲师。今天我们来聊聊RISC-V内核上电后,到底是怎么“活过来”的。

很多同学拿到一个RISC-V核,烧进FPGA,发现它不动。或者跑飞了。或者卡在某个地址不动。这时候你就要问自己三个问题:PC从哪开始?Bootloader在哪?异常来了往哪跳?

这三个问题,就是本章要讲的核心。说白了,就是RISC-V的“开机流程”。

2.1 上电那一刻:PC指针从哪来?

RISC-V规范里写得明明白白:复位后,PC(程序计数器)指向0x8000_0000。嗯,这是标准做法。但实际项目中,我见过不少定制化的设计,比如把复位地址改成0x0000_0000或者0x2000_0000。

为什么会这样?因为有些SoC把Boot ROM放在低地址,有些放在高地址。我个人习惯,在FPGA原型验证阶段,直接把复位地址映射到片上BRAM的起始位置,这样调试起来最方便。

关键点:PC复位值 = 处理器取第一条指令的地址。这个值必须在硬件设计时确定,通常通过参数化配置。

举个例子,如果你用Sifive的U54内核,复位PC是0x8000_0000。但如果你自己写一个RISC-V核,完全可以把复位PC设成0x0000_0000。只要你的Bootloader放在那里就行。

2.2 Bootloader:谁把程序搬进内存?

PC有了,但内存里是空的啊。这时候就需要Bootloader登场了。

Bootloader说白了就是一个“搬运工”。它的任务很简单:把用户程序从非易失存储器(比如Flash、SD卡)搬到RAM里,然后跳过去执行

我在项目中遇到过一种情况:FPGA上电后,RISC-V核直接从Flash里取指令。但Flash速度太慢,导致CPU每取一条指令都要等几十个周期。后来我加了一个简单的Bootloader,先把代码搬到BRAM里,再跳转,性能直接翻倍。

Bootloader的典型流程如下:

  1. 初始化硬件:关中断、设栈指针、初始化UART(方便打印调试信息)
  2. 搬运代码:从Flash/SD卡读取用户程序到DDR或BRAM
  3. 跳转执行:修改mepc或直接跳转到用户程序入口地址

小技巧:调试阶段,我经常把Bootloader和用户程序一起编译,直接烧进BRAM。这样省去搬运步骤,上电就能跑。等验证通过再改成真正的Flash启动。

2.3 异常向量表:出事了你往哪跑?

程序跑起来了,但万一遇到非法指令、访问越界、外部中断呢?这时候就需要异常向量表来“指路”。

RISC-V的异常向量表有两种模式:

  • Direct模式:所有异常都跳转到同一个地址(mtvec基址)
  • Vectored模式:每种异常跳转到不同的偏移地址(mtvec基址 + 4 * 异常编号)

我个人习惯用Vectored模式,因为调试时一眼就能看出是哪种异常。比如看到PC停在0x8000_000C,就知道是非法指令异常(异常号3)。

设置异常向量表,其实就是写一个寄存器:mtvec(Machine Trap Vector)。

// 设置异常向量表基址为0x8000_0100,Vectored模式
// mtvec 寄存器格式:[31:2] 基址, [1:0] 模式(00=Direct, 01=Vectored)
uint32_t mtvec_val = (0x80000100 & 0xFFFFFFFC) | 0x01;
__asm__ volatile("csrw mtvec, %0" : : "r"(mtvec_val));

注意:我曾经犯过一个低级错误——忘了设置mtvec。结果异常来了,CPU直接跳转到0x0000_0000,而那里是空的,导致死循环。排查了半天才发现是mtvec没初始化。

2.4 实战:一个最小启动流程示例

下面我给一个最简单的RISC-V启动代码,包含Bootloader和异常向量表设置。这段代码我经常在FPGA调试时用。

// 最小启动代码 (startup.S)
.section .text.start
.globl _start

_start:
    // 1. 设置栈指针
    la sp, _stack_top

    // 2. 设置异常向量表 (Vectored模式)
    la t0, _trap_handler
    csrw mtvec, t0
    csrwi mtvec, 0x01  // 设置模式位

    // 3. 搬运代码 (从Flash到RAM)
    la a0, _flash_start
    la a1, _ram_start
    la a2, _ram_size
1:
    lw t0, 0(a0)
    sw t0, 0(a1)
    addi a0, a0, 4
    addi a1, a1, 4
    addi a2, a2, -4
    bnez a2, 1b

    // 4. 跳转到主程序
    la t0, main
    jr t0

// 异常处理函数
_trap_handler:
    // 保存上下文
    csrr t0, mcause
    // 根据mcause判断异常类型
    // 这里简单处理:打印异常号并死循环
    // ... (实际项目要更复杂)
    j _trap_handler

这段代码虽然简单,但包含了启动的三个核心要素:PC初始化、Bootloader搬运、异常向量表设置。你可以在任何RISC-V核上跑起来。

2.5 知识体系总览

为了让大家更直观地理解,我画了一张流程图:

RISC-V处理器内核启动流程 上电复位 PC = 复位地址 Bootloader搬运 设置mtvec 跳转 main() 关键寄存器 • PC: 复位后指向固定地址 • mtvec: 异常向量表基址 • mcause: 异常原因 • mepc: 异常返回地址 常见问题 1. PC跑飞 → 检查复位地址 2. 不执行Bootloader → 检查搬运地址 3. 异常死机 → 检查mtvec设置 调试建议 • 用ILA抓取PC总线 • 在Bootloader中加UART打印 • 先用仿真跑通再上板

2.6 避坑指南

最后,我分享几个实际项目中踩过的坑:

  • 复位地址和Bootloader地址不匹配:PC指向0x8000_0000,但你的Bootloader放在0x0000_0000。CPU取的第一条指令就是错的。解决办法:要么改复位地址,要么改链接脚本。
  • 栈指针没初始化:Bootloader里调用了函数,但SP还是0。一压栈就写到了非法地址。我建议在_start的第一条指令就设SP。
  • 异常向量表对齐问题:mtvec要求基址4字节对齐(Vectored模式)或128字节对齐(Direct模式)。不对齐的话,异常来了跳错地方。

总结一下:RISC-V启动流程就三步——PC指向正确地址、Bootloader把代码搬到位、异常向量表设好。这三步走对了,你的处理器就能“活”过来。走错了,嗯,那就老老实实抓波形吧。

好了,这一章就到这里。下一章我们聊聊怎么用JTAG调试器单步跟踪启动流程,到时候我会展示一些实用的调试技巧。


专注资料整理