RISC-V软核在FPGA上的Boot流程与UART驱动开发
扫描二维码
随时随地手机看文章
在FPGA上构建RISC-V SoC时,从复位向量到串口打印“Hello World”的启动流程,是验证软核能否“自主呼吸”的关键。本文将基于常见的PicoRV32或VexRiscv软核,详解从硬件复位到软件驱动的完整链路,助你打通FPGA软核开发的“最后一公里”。
一、RISC-V SoC最小启动架构
一个能完成基础Boot流程的FPGA最小系统包含三个核心部件:
1. RISC-V CPU Core:如PicoRV32,运行在50-100MHz。
2. Boot ROM:FPGA片上BRAM实现,存放复位后执行的第一段代码(Bootloader)。
3. UART 16550兼容控制器:挂载在总线上的串口IP,用于调试输出。
地址空间规划(典型):
- 0x0000_0000:Boot ROM(64KB)
• 0x1000_0000:UART 寄存器(内存映射I/O)
• 0x8000_0000:主内存(外部SRAM/DDR,可选)
二、Boot流程:从复位到C语言环境
RISC-V软核上电后,PC指针会跳转到复位向量(通常为0x0000_0000),启动流程分为三个阶段:
1. 汇编启动(start.s):搭建栈空间
在Boot ROM的起始位置,必须有一段汇编代码负责最基础的初始化。这段代码通常命名为start.s或crt0.s。
.section .text.start
.global _start
_start:
# 1. 设置栈指针(SP),指向RAM顶端
la sp, _stack_top
# 2. 清零BSS段(清除未初始化全局变量)
la t0, _bss_start
la t1, _bss_end
bge t0, t1, clear_bss_done
clear_bss_loop:
sw zero, 0(t0)
addi t0, t0, 4
blt t0, t1, clear_bss_loop
clear_bss_done:
# 3. 跳转到C语言的main函数
call main
# 4. 若main返回,则进入死循环
j .
关键点:栈指针(SP)必须在调用C函数前正确设置,因为C函数依赖栈帧(Stack Frame)保存局部变量和返回地址。
2. 链接脚本(linker.ld):定义内存布局
链接脚本告诉编译器代码和数据应该放在哪里。对于Boot ROM,需要明确指定.text(代码段)从0x00000000开始。
MEMORY {
ROM (rx) : ORIGIN = 0x00000000, LENGTH = 64K
RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 256K
}
SECTIONS {
.text : {
*(.text.start) /* 确保_start在最前面 */
*(.text*)
} > ROM
.bss : {
_bss_start = .;
*(.bss*)
_bss_end = .;
} > RAM
_stack_top = ORIGIN(RAM) + LENGTH(RAM); /* 栈顶指向RAM末尾 */
}
3. C语言入口(main.c):驱动初始化
当栈和BSS段准备就绪后,程序跳转到C语言的main函数,此时可以安全地初始化外设。
// 定义UART寄存器基地址(内存映射)
#define UART_BASE 0x10000000
#define UART_TX_DATA (*(volatile unsigned int*)(UART_BASE + 0x00))
void uart_putchar(char c) {
while (/* 检查发送缓冲区是否满 */); // 轮询状态位
UART_TX_DATA = c;
}
int main() {
// 初始化UART(设置波特率等)
// uart_init(115200);
// 打印启动信息
const char* msg = "RISC-V Boot Success!\n";
for (int i = 0; msg[i] != '\0'; i++) {
uart_putchar(msg[i]);
}
return 0;
}
三、UART 16550驱动开发实战
在嵌入式RISC-V系统中,UART通常采用内存映射I/O(MMIO)方式访问,即直接读写特定内存地址来控制硬件。
1. 寄存器映射与初始化
假设UART IP核兼容16550标准,其关键寄存器偏移如下:
偏移 寄存器 作用
+0x00 TX Data / RX Data 收发数据寄存器
+0x04 Interrupt Enable 中断使能
+0x0C Line Control (LCR) 设置数据位、停止位
+0x14 Line Status (LSR) 状态查询(TX空、RX就绪)
波特率设置代码示例:
void uart_init(uint32_t baud_rate) {
uint32_t divisor = SYSTEM_CLK / (baud_rate * 16) - 1;
// 1. 设置LCR的DLAB位为1,允许访问波特率分频器
*(volatile uint32_t*)(UART_BASE + 0x0C) |= (1 << 7);
// 2. 写入分频值
*(volatile uint32_t*)(UART_BASE + 0x00) = divisor & 0xFF; // DLL
*(volatile uint32_t*)(UART_BASE + 0x04) = (divisor >> 8) & 0xFF; // DLM
// 3. 设置8N1格式,并清除DLAB
*(volatile uint32_t*)(UART_BASE + 0x0C) = 0x03; // 8位数据,无校验
}
2. 轮询式发送函数
在简单的Boot阶段,通常采用轮询(Polling)方式,避免复杂的中断处理。
void uart_putchar(char c) {
volatile uint32_t* lsr = (volatile uint32_t*)(UART_BASE + 0x14);
// 等待发送缓冲区为空(THR Empty)
while ((*lsr & (1 << 5)) == 0) {
// 空循环,等待硬件就绪
}
*(volatile uint32_t*)(UART_BASE + 0x00) = c;
}
void uart_puts(const char* str) {
while (*str) {
if (*str == '\n') {
uart_putchar('\r'); // 在终端中,换行前通常需要回车
}
uart_putchar(*str++);
}
}
四、FPGA工程集成与调试避坑
1. BRAM初始化:在Vivado或Quartus中,将编译生成的.bin或.hex文件作为Boot ROM(BRAM)的初始化文件。上电后,FPGA配置完成时,BRAM中即已存好程序。
2. 复位时序:确保FPGA的sys_rst_n信号在时钟稳定后延迟释放,防止CPU在时钟不稳定时启动。
3. 串口调试:如果UART无输出,首先检查:
◦ 波特率计算:SYSTEM_CLK是否与硬件PLL输出一致?
- 引脚约束:TX引脚是否被正确分配到FPGA的IO并设置为正确的电平标准(如LVCMOS33)?
- 终端配置:PC端串口助手是否设置为相同的波特率(如115200)、8N1格式?
五、结语
RISC-V软核在FPGA上的Boot流程,本质是“硬件地址映射”与“软件初始化顺序”的精确配合。掌握从汇编启动到C语言驱动的完整链条,意味着你不仅能让CPU跑起来,更能为其注入“五官”(UART)和“记忆”(RAM),为后续移植RTOS或运行复杂应用打下坚实基础。





