第二章: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的典型流程如下:
- 初始化硬件:关中断、设栈指针、初始化UART(方便打印调试信息)
- 搬运代码:从Flash/SD卡读取用户程序到DDR或BRAM
- 跳转执行:修改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 知识体系总览
为了让大家更直观地理解,我画了一张流程图:
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调试器单步跟踪启动流程,到时候我会展示一些实用的调试技巧。