☰
FPGA上构建RISC-V软核:从SoC架构到上板调试实战解析
2026/10/6 13:55:42 网站建设 项目流程

去年带着这个作品去打比赛的时候,我们团队其实纠结了很久:做FPGA图像处理,还是做接口控制器,最后定了RISC-V软核这条路线。原因很直白——在FPGA上搭一个完整的RISC-V开发平台,要同时啃下指令集、SoC总线、外设驱动、交叉编译、上板调试这一整条链路,任何一个环节薄弱都会在答辩时露馅。而且RISC-V的开源特性意味着整个CPU核、总线、外设甚至工具链都能自己把控,这恰恰是FPGA开发者最舒服的姿势:硬件逻辑是我的,软件指令也是我的。最终作品拿到了一等奖,回头总结,赢在三点:方案选型没有贪大求全、SoC架构清晰可扩展、调试过程有完整的工具链支撑。

这篇文章按我当时做项目的顺序来梳理,从方案选型、SoC架构、关键模块设计,到上板调试和踩坑实录,尽量把能直接复现的命令、参数、RTL写法都留下来。对准备做FPGA+软核方向比赛、毕业设计,或者纯粹想从零理解RISC-V如何在FPGA上跑起来的读者,应该会有帮助。

1. 为什么在大赛里选FPGA+RISC-V这条路线

1.1 指令集选型背后的权衡

比赛组队第一天,我们列了三个候选方案:Xilinx自家MicroBlaze、ARM Cortex-M软核、RISC-V开源核。MicroBlaze确实成熟,Vivado里点几下就能生成,外设也有现成的IP,但问题在于它不开放,你想看内部实现、想改指令、想在答辩时讲清楚“CPU是怎么取指译码执行的”,基本没戏。ARM软核(比如Cortex-M1/M3的FPGA版本)授权和工具链同样受限,而且对比赛来说“重新造一个CPU”的完整过程才是评委最喜欢看到的东西。

RISC-V的优势恰好打在这几个点上:指令集本身是开放标准,不存在授权障碍;网络上能拿到从五级流水线到乱序执行的各种开源实现;GCC工具链、Newlib、甚至Linux都有对应的RISC-V分支。更重要的是,RISC-V的指令格式设计得很规整,比如R型、I型、S型指令的立即数和寄存器字段位置相对固定,译码逻辑写起来比MIPS还要顺手。对FPGA工程师来说,这种“指令集与硬件逻辑高度对应”的特性,让课堂里学的计算机组成原理能够直接落到RTL代码上,学习曲线反而比ARM平滑。

还有一个现实原因:从零写一个RISC-V RV32I核,在比赛的时间窗口内是可控的。我们用的核是自己按五级流水线写的,大概不到1500行SystemVerilog,加上总线桥和外设总共3000行上下。如果选题是ARM,大概率只能拿现成IP,最后答辩讲的是“我用了谁家的核”,而不是“我实现了什么”,对于一等奖这种目标来说,差距一下就出来了。

1.2 开发板与硬件资源预算

选FPGA型号时我们卡了很久。最终用的是Xilinx Artix-7系列的XC7A35T,逻辑单元约33K,DSP slice 90个,BRAM 1.8Mb。这个规模跑一个单核RV32I软核加UART、GPIO、定时器、中断控制器绰绰有余,实测资源占用大概在LUT 40%、FF 25%、BRAM 85%的水平。BRAM占用偏高是因为指令和数据RAM全放在片上BRAM里,后面会讲到如何省BRAM。

如果你的板子是国产FPGA,比如紫光同创、安路、高云,其实思路完全一样,只是工具链换成各自的IDE,IP核命名和原语调用方式有差异,RTL代码基本可以跨平台。需要特别注意的是软核频率——同一段代码,在Artix-7上能跑到80~100MHz,在小规模、慢速级的器件上可能只能跑到30~50MHz,所以串口波特率分频、定时器预分频这些参数要根据实际跑出来的时序余量去调,不能照搬。

1.3 作品的整体框架

整个平台分成四层:

  • 硬件层:RISC-V RV32I五级流水线CPU核、片上ROM/RAM、AXI4-Lite总线互连、UART、GPIO、定时器、中断控制器;
  • 固件层:启动代码(bootloader思路的简化版)、链接脚本、裸机驱动程序;
  • 工具链层:riscv-none-elf-gcc交叉编译器、Makefile构建脚本、串口烧录/通信脚本;
  • 应用层:LED流水灯、按键中断、串口回环、跑简单的Dhrystone基准程序。

答辩时我们把这个框架画成一张带地址映射的内存布局图,从复位向量0x00000000到外设寄存器0x40000000,每一步地址访问对应哪块硬件都清清楚楚。评委问“中断入口在哪”、“UART发送寄存器怎么访问”,直接指图回答,节奏完全可控。这条“CPU+总线+外设+软件”的完整度,是拿奖的核心加分项。

2. SoC架构与核心模块设计

2.1 总线互联与地址映射

我们没有直接用复杂的AXI4全协议,而是选了两级结构:CPU核内部是自定义的简单内存接口(类似SRAM接口),总线层用一个AXI4-Lite互连模块把CPU的访问转换成AXI4-Lite读/写事务,再按地址译码分发到各个外设。为什么不用完整AXI4?因为完整AXI4支持突发传输、乱序返回、多主多从等特性,对于一个单核裸机场景,这些特性用不上还会把验证复杂度拉高一个数量级。AXI4-Lite是精简版,每次传输一个数据,握手信号简单清晰,适合教学和比赛展示。

地址映射表如下(8位地址总线示例,实际用32位地址高4位译码):

地址段目标设备说明
0x00000000 – 0x00000FFF指令ROM(4KB)存放启动代码和只读数据
0x10000000 – 0x10000FFF数据RAM(4KB)RW数据、栈、堆
0x40000000 – 0x400000FFUART数据寄存器、状态寄存器、控制寄存器
0x40000100 – 0x400001FFGPIODIR、DATA、SET、CLR
0x40000200 – 0x400002FF定时器LOAD、COUNT、CTRL
0x40000300 – 0x400003FF中断控制器PENDING、ENABLE、THRESHOLD

总线互连的写数据通路设计里有一个容易忽略的坑:CPU写外设寄存器时,如果外设的写响应(BVALID)回来太慢,CPU会被卡死。我们统一要求所有从设备在收到写地址和写数据后一拍内返回写响应,也就是说外设寄存器写入没有等待状态,保证CPU侧不用插入stall。读通路则允许从设备插入等待周期(读数据有效前拉低RVALID),这样UART这类慢速外设在读取FIFO空标志时可以通过等待状态同步状态;平台整体跑起来不会有“CPU挂起”的假死现象。

2.2 存储系统:指令RAM与数据RAM

大多数开源软核默认把指令和数据统一编址到同一块RAM,这样省事,但比赛答辩时容易被问“哈佛结构和冯·诺依曼结构你选哪个,为什么”。我们选了经典的哈佛结构:指令走指令存储接口,数据走数据存储接口。好处是CPU在取指和访存并行,流水线效率高;代价是代码里如果需要“自修改代码”(比如加载程序到RAM再跳转执行),就得多一道把数据从数据RAM拷贝到指令RAM的流程。

RTL实现上,指令RAM用同步读、数据RAM也用同步读。这个细节很重要:如果数据RAM用异步读,组合逻辑路径会变得很长,时序收敛难度大增;而同步读只需要在流水线的访存级后插入一拍,代价极小。指令ROM部分我们在启动时把仿真时预置的二进制直接加载到BRAM初始化文件里,用$readmemh读入.hex文件,上板时这部分内容固化在BRAM初值中。

BRAM资源不够时,我们的省法是:把只读的启动代码(复位向量、初始化、main函数调用封装)放到“ROM”,把可读写的变量、栈放在“RAM”。如果RAM不够,就把栈挪到外部SRAM接口(比如板载SRAM芯片),前提是总线层增加一个SRAM控制器。比赛阶段为了稳,我们没有扩外部存储,而是用代码优化把两个段压缩到各4KB,完全够跑核心演示程序。真正的“跑Linux”不在单板裸机场景内,那是另一个量级的事。

2.3 外设模块:UART、GPIO、定时器与中断控制器

UART是每个软核平台必做的外设,而且既是通信工具又是调试工具。我们用标准的16倍过采样:接收端内部时钟是波特率的16倍,每个位周期采样16次,在位中心附近取数据。发送端就是简单的移位寄存器加波特率分频计数器。波特率分频值计算如下:

clk_freq = 50MHz baud = 115200 divider = clk_freq / baud - 1 = 434

实际操作中我强烈建议只把除数算好之后固化,不要在RTL里做成可配置的寄存器,因为软件侧改波特率寄存器一旦写错,整个通信直接卡死,排查起来非常痛苦。

GPIO我们做了带SET和CLR寄存器的类型,写入DATA寄存器需要读-改-写,而SET寄存器能让某一位直接置1而不影响其他位,这是嵌入式里很常规的设计思路。定时器做成了一个32位递减计数器,软件可以读当前计数值、设置初值、开/关中断。

中断控制器是最容易偷懒、但答辩时最能展示功底的部分。我们没有直接用现成的PLIC,而是写了一个极简中断控制器:支持16个外部中断源,每源有一个PENDING位和一个ENABLE位,任意使能且挂起的中断会拉高全局中断请求线;CPU侧通过csrrs/csrrc指令读写mstatus.MIE位来开/关中断。中断入口地址固定为0x20000000(作为一个只读跳转目标地址,实际上我们在启动代码里将这部分区域映射到ROM中的一段跳转指令)。虽然简陋,但覆盖了RISC-V中断机制的完整链路,评委问起来可以从CSR讲到异常委托再讲到向量表,逻辑闭环。

3. 关键设计细节与踩坑记录

3.1 串口接收模块的亚稳态与采样点选择

串口接收是所有外设里第一个动手写的。第一次上路,我直接用系统时钟对RX信号采样,收到起始位后每隔一个波特率周期采一次,结果上板乱码。问题出在跨时钟域和采样点上:外部RX信号与系统时钟是异步的,直接用上升沿打拍很容易采到亚稳态;而采样点选在位周期的开始位置,离跳变沿太近,一旦噪声导致跳变沿提前或延后,采到的就不是稳定电平。

正确做法分两步。第一,对RX信号做两级同步器,打两拍消除亚稳态;第二,用“起始位确认 + 中点采样”策略:检测到RX拉低后,在半个波特率周期处再确认一次仍然是低,才认为是真正的起始位;之后每个位周期在位的中央采样,因为位的中央离跳变沿最远,抗干扰能力最好。我们的UART接收模块里放了一个位计数器和一个采样点计数器,计数器计到波特率分频值得一半时采起始位,之后每满一个波特率周期采数据位。这个细节讲清楚了,评委基本不会再追问UART的可靠性问题。

3.2 外部中断与软件流程配合

中断是一个特别容易“硬件对了但软件不动”的模块。硬件侧的问题大多是边沿检测没做好——按键按下和释放都有机械抖动,同一段时间内可能产生多个高低跳变,如果没有去抖逻辑,中断触发好几次,逻辑就乱了。

我们在GPIO模块里给每个按键输入加了一个20ms左右窗口的去抖状态机(用定时器分频产生1ms tick,连续计数20个tick保持同一电平才确认按下),只有确认后的下降沿才置中断PENDING位。软件侧有个坑:中断服务程序里读完PENDING后,必须写1清除PENDING,而且清PENDING的动作必须在处理完该事件之后做,否则中断嵌套会混乱。RISC-V不像ARM有固定的中断向量表,我们的启动代码在0x20000000放了一条跳转到isr_handler的指令,中断服务程序里用csrrw a0, mepc, zero来获取异常返回地址,这个细节很多开源代码里没有解释清楚,但比赛时评委就是喜欢问这类“你是如何做特权态切换的”。

3.3 跨时钟域与复位设计

项目里其实只用了两个时钟域:系统时钟50MHz和异步外部信号(UART RX、按钮)。UART RX用两级同步器解决,按钮在去抖状态机里同步。真正让我长记性的是复位信号的处理方式——一开始直接把异步复位接到每个触发器的async复位端,结果综合后时序报告里复位网络的扇出巨大,占了大量布线资源。

后来改成“异步断言、同步释放”的复位同步器:外部复位信号进来后,先打两拍同步到系统时钟域,再用这个同步后的信号作为整个模块的异步复位。这样复位释放时刻与时钟沿对齐,避免复位释放时触发器处于亚稳态,同时复位网络的建立时间约束更容易满足。具体代码模式如下:

reg rst_sync1, rst_sync2; always @(posedge clk or posedge rst_n_ext) begin if (rst_n_ext) begin rst_sync1 <= 1'b0; rst_sync2 <= 1'b0; end else begin rst_sync1 <= 1'b1; rst_sync2 <= rst_sync1; end end assign rst_n = rst_sync2;

用这个模块替换所有触发器的复位端之后,时序报告中复位路径的WNS从负值转正,一眼可见的收益。

3.4 关于相控阵与图像处理的延伸思考

决赛答辩时评委顺口问了句“这个平台还能往哪些方向扩展”,我当时答了两个:其一是给平台加一个AXI-Stream接口,把DSP slice用起来,比如做FIR滤波或者相控阵波束赋形里的复数加权,RISC-V软核负责配置权值参数,数据通路由FPGA逻辑完成,这样软核和硬逻辑协同各干各的强项;其二是加一个简单的DMA控制器,把AD采集的数据批量搬到RAM,RISC-V只做后处理,就能支撑图像缩放(双线性插值)这类数据密集型算法。这两个扩展方向最终都被写进了作品文档,虽然没有实现在代码里,但向评委展示了“我明白FPGA软核的边界在哪里”——软核擅长控制流、不擅长数据流,这个认知甚至比代码本身更能拉分。

4. 从RTL到上板的完整流程

4.1 工具链与工程搭建

硬件工程用Vivado 2021.2(如果你用Quartus,流程同理)。工程结构我建议一定分层,比赛项目最忌讳把所有文件堆在一个目录里。我们用如下结构:

project/ ├── rtl/ │ ├── cpu/ // 取指、译码、执行、访存、写回 │ ├── soc/ // 总线互连、地址译码、复位同步 │ └── periph/ // uart、gpio、timer、plic ├── sim/ │ ├── tb_cpu.sv │ └── tb_soc.sv ├── sw/ │ ├── startup.S │ ├── main.c │ ├── link.ld │ └── Makefile └── fpga/ ├── constraints.xdc └── top.sv

RTL里CPU核心和SoC顶层分开编译,仿真时可以直接例化CPU核心独立跑指令,SoC仿真时再完整跑“load程序到RAM→执行→产生UART输出”的流程。这个分层设计让定位bug很直接:CPU仿真的失败大概率是指令执行问题,SoC仿真的失败大概率是总线或外设问题,不用两套逻辑混在一起猜。

软件那边用riscv-none-elf-gcc 10.1.0。安装很简单,从官方release页下载xPack或者SiFive的工具链解压就能用。Makefile里最关键的三个选项是-march=rv32i、-mabi=ilp32、-nostdlib——因为我们没有操作系统,不需要标准库的启动文件,只链接自己的startup.S和精简的newlib变种(甚至直接裸写寄存器操作,连串口printf都是自己封装)。

4.2 软件侧:启动代码与裸机程序

启动代码是软核上电后第一条指令所在。我们的startup.S做了三件事:

  • 设置栈指针(sp)为数据RAM的最高地址;
  • 把.data段从ROM拷贝到RAM(.data段初始值在ROM里,运行期在RAM);
  • 清零.bss段;
  • 跳转到main。

链接脚本里的内存布局要和硬件地址映射严格一致。脚本的关键部分:

MEMORY { ROM : ORIGIN = 0x00000000, LENGTH = 4K RAM : ORIGIN = 0x10000000, LENGTH = 4K }

如果链接脚本和硬件地址不一致,最常见的结果是程序取指正常、但访问变量时读写到错误地址,表现为“编译没问题,跑起来全是随机值”。我在调试时踩过一次,那次卡了几乎一个下午,最后是用Vivado里的ILA抓总线地址才发现RAM地址坐错了段。

裸机程序里我们写了串口回环、LED流水灯、按键中断三个演示用例,外加一个简化版Dhrystone计时函数,用来给出“平台能跑起来且性能可测”的硬指标。Dhrystone的移植相对无脑,只要在计时函数里读定时器寄存器,最后换算成DMIPS/MHz。记得当时这个平台跑Dhrystone大约0.9 DMIPS/MHz,虽然比商业核低,但作为纯RTL实现的五级流水线,已经足够拿出手。

4.3 综合、布局布线与资源利用率分析

综合后在Vivado里看资源报表,这是一个特别能体现“项目是否落地”的环节。当时的资源数据大致如下:

资源使用量占总资源比例
LUT1250038%
FF480012%
BRAM1680%
DSP00%

这个表格里BRAM 80%要引起警觉:如果后面还想加摄像头缓存或者大FIFO,BRAM会成为瓶颈。分析原因是指令ROM和数据RAM各用了4个BRAM(每个BRAM配置为真双端口2KB模式),再加上CPU内部的几个小FIFO,整体就上来了。如果提前规划,可以把指令ROM压缩到2KB、数据RAM压到2KB,BRAM占用降到50%以下。这个资源感知能力在答辩时非常加分——评委喜欢看到你不仅能让功能跑起来,还能解释资源花在哪里、怎么优化。

布局布线阶段的时序目标,我们定的约束是系统时钟50MHz,综合后WNS为0.2ns,PTS(布线后时序)WNS略降到0.05ns,整体可以稳定上板。如果WNS为负,不要急着改RTL,先看关键路径是组合逻辑链太长还是扇出太大。我们当时一条访存路径的组合逻辑链跨了总线、地址译码、RAM,降到35MHz才收敛,后来优化成在地址译码后插入一级流水线寄存器,把关键路径拆到两拍内,时序立刻好了。这也是“CPU跑多快取决于最长组合逻辑路径”这句老话的一个实战案例。

4.4 下板验证与作品演示

验证分三层来做。

第一层是软件仿真:用Vivado的Xsim跑整个SoC testbench,程序就是简单的LED翻转加串口发送,时钟设为50MHz,仿真跑完看UART发送帧是否和期望一致。第二层是上板点灯:只跑GPIO逻辑,确认CPU到GPIO地址通路正常,这一步如果卡住基本是地址译码或总线握手问题。第三层才是完整演示——上电,LED按流水灯模式跑,按按键触发中断,中断里UART输出一条事件记录,串口工具能看到字符串。这个演示顺序每步之间能明确分工定位,不会“一上电就全乱”却又无从下手。

演示时用到的一个比较讨巧的手法:在main里写一个延时循环,让LED每秒跳一步,同时通过UART每秒发送一次当前计数值。这样评委一眼看到“CPU真的在周期性地执行指令”,而不是静态亮灯。

5. 大赛中遇到的问题排查实录

5.1 仿真全对、上板全灰怎么办

这是软核最容易遇到的头号问题。仿真环境里一切波形完美,下载到FPGA上后LED完全不动,串口也不输出。排查思路按顺序走:

  • 先用ILA抓CPU的取指地址,确认PC是否在复位后跳到了0x00000000;
  • 如果PC不动,查复位释放是否正常——是不是外部复位按键一直按着,或者复位同步器有bug;
  • 如果PC在跑但LRAM数据不对,查ROM初始化$readmemh的路径是否写死成了仿真时的绝对路径,上板后BRAM初值是否成功加载;
  • 如果PC和RAM都正常但外设不响应,重点抓总线地址译码逻辑,看看读写的是不是映射表上实际存在的地址。

我们当时的“仿真对、上板灰”是ROM初始化文件路径问题:仿真时$readmemh用的是绝对路径所以能读到,但综合时该文件被打包时由于路径错误被忽略,BRAM初值全是0,CPU一条指令都没取到。记住:$readmemh的路径如果写绝对路径,上板和仿真行为可能不一致,一定要用相对路径或者在综合过程中把文件加入Vivado工程的文件列表。

5.2 串口乱码与波特率偏差

上板第一次串口通信实测时,输出是一堆乱码,大约每四个字符错一个。排查下来是波特率分频值算错。我们的系统时钟本来是50MHz,但为了方便仿真把时钟IP输出改成了12MHz,改回50MHz时忘记更新UART分频参数,导致发送端波特率成了115200×4,接收端自然也收不对。这个问题的典型特征是“仿真完美、上板乱码且乱得有规律”——每次通信的字符偏移量固定,如果乱码是固定几个字节重复,基本可以锁定是波特率分频问题,而不是接线问题。检查分频值、检查是否误用了仿真时降频后的时钟、检查发送和接收两端波特率是否统一,三步走完一般都能解决。

另外注意一点:如果UART发送数据时是先发LSB(最低位),那软件侧打印的字符与波形图就是反着的,初看容易误判成乱码。我们第一次抓波形时盯着发送端的TX线看了半天,最后发现是自己的驱动发送顺序搞反了,跟硬件体系无关。这个问题改成先发高位再发低位就解决了。

5.3 RAM溢出与栈越界

跑Dhrystone时出现过一次程序跑飞:打印中途突然停止,PC跳到了一个随机地址。查下来是RAM空间被.data、.bss和栈挤爆了,栈向下增长覆盖了堆区,写坏了一个变量,程序逻辑错乱。这个问题的排查思路是用定时器周期性打印栈指针SP的值,如果SP持续向低地址方向递减,逼近RAM起始地址,就知道栈溢出了。

解决方式是调整链接脚本:把栈放在RAM最顶端,堆放在栈下方,并且在startup.S里初始化SP时预留一段“哨兵”区域。加一个栈金丝雀(在栈底放一个特定值,定时检查是否被改写)是比赛中展示系统设计严谨性的加分项。虽然答辩时评委没有深挖,但我在文档里留了这个设计,事后回看确实帮我避免了好几次隐性bug。

5.4 调试三板斧:ILA、串口打印、LED

穷举所有复杂调试方法都不如这三招实用。

  • ILA(集成逻辑分析仪)抓内部信号波形,适合查总线时序和CPU状态机。代价是占BRAM和布线资源,跑大程序时要注意别把资源占满。
  • 串口打印是二维调试神器,在关键代码段插uart_send_str,基本能定位出程序卡在哪里。我们有一次查中断没反应,就是在中断服务程序入口加打印,结果发现入口就没跳到,问题立刻定位到中断协议层。
  • LED点灯是最笨但最可靠的方式,甚至可以直接用LED显示PC的高4位或者状态机当前状态。上板时如果ILA用不了、串口又乱码,LED至少还能告诉你“CPU在动还是没动”。

这套组合逻辑调试效率很高,推荐每个做FPGA软核项目的人都先搭好这三条路再开始大规模调试。

6. 如果想参赛,我建议你额外做这几件事

如果你也想做类似题目,除了上面说的技术细节,强烈建议在赛前演练中准备几类问题的回答:为什么不用商业IP核、如何保证软核的时序收敛、RISC-V相比ARM/MIPS在硬件实现上的优势、扩展外设的工作量评估。这些问题是评委的高频考点,提前写好一页纸的应答提纲,现场会从容很多。

答辩PPT的结构我也建议按“为什么做、怎么做、做到什么程度、还能做成什么”四段来组织,每个阶段配一张可运行的结果图。最忌讳的是贴“代码截图+原理图”的堆砌型PPT,评委看不到你的思考过程。我们当时用了一张“地址映射+数据通路”混合图,配一条“从按键按下到UART输出”的完整数据流,几分钟之内把整个平台的运转方式讲完了,效果比四十页代码讲解好太多。

最后想分享一个个人体会:FPGA上做RISC-V开发平台,难的不是某个模块,而是“硬件和软件同时正确”的交叉验证。你在RTL里定义一个寄存器,软件侧就要严格按地址和位域去访问;你在软件里写了死循环,硬件仿真反而会对比出异常。这种“两个世界共同工作”的区隔正是嵌入式开发的迷人之处——当你在串口助手里看到自己写的CPU跑起来打印出一行Hello World时,那种成就感是单纯写软件或单纯写硬件都无法替代的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询