☰
拆解PICORV32:RISC-V软核的FPGA实现与源码分析
2026/10/1 20:43:42 网站建设 项目流程

既然点进来了,我猜你多半跟我一样,碰过FPGA,也翻过几行RISC-V的指令手册。PICORV32这套源码我前前后后读了三遍,每次都有新收获。作为YosysHQ团队维护的开源RISC-V软核,它的整个工程就一个核心的Verilog文件,压缩下来大概七八千行,跟动辄几十万行的商用处理器IP完全两个画风。用它做入门级的CPU微架构分析,或者直接拿它当FPGA SoC的CPU核,都特别顺手。这篇文章我会带你从头到尾拆这份源码,讲清它的状态机、寄存器堆、ALU、总线握手、中断和PCPI接口,再把测试仿真流程、常见坑一起端出来。

先说结论:PICORV32不是一个追求极致性能的处理器,它更像一份“能看懂、能改装、能在FPGA上跑起来”的处理器参考实现。它默认支持RV32I指令集,可选M扩展(乘除法)、C扩展(压缩指令),甚至还能开E扩展(嵌入式,只留16个寄存器)。对大多数软核应用来说,基本不用改代码就能直接用。正因为它小,寄存器级的行为全部摊在几个大的always块里,只要你会看波形、会用iverilog,把一条指令从取指到写回的过程追出来并不难。这份源码最值钱的地方,就在于它把所有CPU该有的部件都压缩到了最小可读的规模。

1. 为什么值得花时间逐行读PICORV32

1.1 它是软核里少有的“单文件可读”设计

用“软核”这个词,大家一般会想到Xilinx的MicroBlaze、Altera的Nios II,或者RISC-V阵营里功能更全的VexRiscv、Rocket。但它们要么是商业闭源,要么涉及Chisel/Scala生成Verilog,源码本身对Verilog工程师并不友好。PICORV32不同,它从上到下都在一个picorv32.v文件里,模块端口、参数列表、内部信号,全都能在同一个文件里翻到。这意味着你不需要在几十个文件之间跳来跳去理依赖关系,一台电脑、一个编辑器、一个仿真器,就能把整颗CPU从内到外拆开看。

它解决的实际问题也很朴素:在FPGA上做嵌入式系统、跑裸机程序、接自定义外设,或者只是想低成本验证一段RISC-V程序的行为。很多开源项目拿它当核心,包括一些网络设备、存储控制器和教学实验板卡,因为它不挑工具链、不依赖特定FPGA厂商的原语,综合出来面积也很小。对一个初学者来说,拿它做CPU微架构的第一份源码分析,比直接看ARM Cortex-M的RTL现实得多。

1.2 面积、频率和周期的取舍逻辑

我第一次看这份代码时,最强烈的感觉是:作者Cliff Wolf在“面积”和“时序”之间做了非常刻意的取舍。PICORV32的基本配置下,在入门级FPGA上可能只占几百个LUT,这个面积放到今天很多片子里连1%都不到。作为代价,它执行每条指令的周期数并不好看,没有分支预测,加载指令往往要停顿,乘法如果不开ENABLE_FAST_MUL更是完全靠软件模拟。但这恰恰是关键:很多FPGA上的软核应用根本不缺LUT,缺的是稳定的时序和可控的行为。面积最小、频率最高、周期数多一点,对嵌入式控制场景反而更合适。

读源码时你还会发现,作者没有为了展示技巧而堆代码,所有复杂度都集中在几个多路选择器和有限状态机上。它内部没有复杂的乱序执行、没有旁路网络的全互联、没有专门的cache控制器,数据通路相当直白。这几乎是教科书级别的“指令流水线最简形式”,非常适合用来建立“处理器到底怎么一步步工作”的直觉。

2. 源码架构入口:参数体系与顶层约定

2.1 拿到的工程里到底有哪些东西

如果你去GitHub拉下YosysHQ/picorv32,根目录下最核心的就是picorv32.v。除此之外还有picorv32.h,这是给C语言裸机程序用的头文件,里面定义了自定义CSR的地址、快速中断入口的宏等等。scripts目录里有makehex.py,作用是把编译器生成的ELF文件转成Verilog可以直接加载的hex初始化文件。testbench目录提供仿真环境,包括经典的sim.v、用于生成指令流和检查结果的C测试程序。整个仓库没有大型IDE工程,所有东西都是为了让你“拿起来就在命令行里跑”而设计的。

这种结构暗示了分析顺序:先读picorv32.v里的参数,理解它能配置出哪些不同形态的CPU;再读模块端口,看清外部接口;然后顺着时钟沿找内部状态机;最后再回头结合testbench,观察具体指令在波形上的表现。如果一上来就扎进always块逐行抠,很容易迷失在信号海洋里。

2.2 参数列表决定了CPU的性格

模块开头那一长串parameter就是PICORV32的配置菜单。我在项目里最常用的几个:

parameter [31:0] ENABLE_MUL = 0; parameter [31:0] ENABLE_FAST_MUL = 0; parameter [31:0] ENABLE_DIV = 0; parameter [31:0] ENABLE_IRQ = 0; parameter [31:0] ENABLE_PCPI = 0; parameter [31:0] CATCH_MISALIGN = 0; parameter [31:0] CATCH_ILLINSN = 0; parameter [31:0] LATCHED_MEM = 0;

不开ENABLE_MUL时,M扩展的乘除指令会变成非法指令陷阱,需要软件模拟。开了ENABLE_MUL但不开ENABLE_FAST_MUL,乘法会通过一个多周期移位加法器完成,面积小、周期多;开了ENABLE_FAST_MUL,则用DSP块或组合乘法器,速度快但资源开销直线上升。ENABLE_IRQ控制整个中断子系统的生成,不开的话模块端口里那堆irq、eoi信号都只是摆设。LATCHED_MEM决定内存接口是默认的“两周期锁存型”还是另一种适合片内SRAM的“直接握手型”,这一点后面我会专门展开。

配置参数的意义在于:你不必移植一整个完整RV32IMC核。比如你的应用只需要整数指令和串口输出,那就只开ENABLE_MUL和必要的计数器,把中断、PCPI全部关掉。这样综合面积更小,时序也更干净。读源码时我建议先把参数全设成默认值,跑通基础仿真后再一项项打开,观察代码里哪些generate或条件分支被激活,这样能抓住每个功能块的实际边界。

2.3 从端口信号反推整体框图

顶层端口除了clk和resetn,内部核心是两组接口:一组是内存接口,mem_valid、mem_ready、mem_addr、mem_wdata、mem_wstrb、mem_rdata、mem_instr;另一组是协处理器接口,pcpi_valid、pcpi_insn、pcpi_rs1、pcpi_rs2、pcpi_wr、pcpi_rd、pcpi_wait等。如果你把这两组接口当成CPU肚子里的“血管”,整个结构就清晰了:取指和访存都通过内存接口完成,自定义指令则通过PCPI送到外部协处理器。

这里要特别提醒一点:PICORV32没有独立的指令总线和数据总线,取指、读数据、写数据全走同一套mem_*信号,靠mem_instr来区分当前访问是不是取指。这种单总线结构简化了片上互连,但也意味着取指和数据访问必然互相阻塞。在分析代码时,凡是出现mem_instr的判定语句,都要格外小心,因为它直接决定指令流会不会因为数据访问而暂停。

3. 微架构核心:状态机、寄存器堆与ALU

3.1 主状态机是怎样驱动一条指令的

PICORV32内部没有庞大的流水线寄存器堆,取而代之的是一个主状态机和几个latched_*信号。核心思想是把指令的执行拆成取指、译码、执行几个阶段,但每个阶段不一定严格占一拍。代码里最醒目的状态机信号一般会出现在always @(posedge clk)块内,根据当前状态和mem_ready、mem_valid等握手信号决定下一步跳转。

你会在波形里看到这样一个典型的取指过程:CPU把当前PC送到mem_addr,拉高mem_valid和mem_instr,然后停在这个状态等mem_ready。外部存储器一旦返回mem_ready,CPU把读到的指令锁存进内部指令寄存器,译码开始。这个机制看起来简单,但它是整个CPU能稳定工作的基石。读源码时,可以把每条指令的触发条件分成三类:纯寄存器指令(如add)、访存指令(如lw、sw)、跳转指令(如jal、beq),分别看状态机在每类指令下的跳转路径,这样比对着指令手册逐条核对快得多。

作者在注释里还提到一种叫“prefetch”的优化方向,当条件允许时会提前向内存发起下一次取指。这部分代码在默认配置下可能不激活,但它能帮你理解为什么某些情况下指令执行看起来比预期快。如果要在PICORV32基础上做性能调优,prefetch逻辑就是最值得关注的一个扩展点。

3.2 寄存器堆建在触发器上而不是RAM上

在查看regs相关代码时,你会发现PICORV32的寄存器堆并不是用reg [31:0] mem [0:31]这种经典RAM写法,而更偏向一组离散的寄存器定义配合多路选择器输出。这样设计的代价是面积偏大,但好处是在FPGA上不依赖厂商RAM原语,时序更可控。

关于x0寄存器,RISC-V规范要求它永远读出0,写入被丢弃。PICORV32在读取和写入路径上都对x0做了特殊处理,源码里会把目标寄存器等于0的写操作直接旁路掉。分析时需要注意寄存器堆的写地址来源:普通指令写rd,而跳转链接指令会把返回地址写到x1或rd指定的寄存器,这些地址信号在同一个always块里会被多路复用,稍不留神就会看岔。

3.3 ALU实现里的“代码复用”智慧

PICORV32的ALU不是独立的module,而是融合进主状态机周围的组合逻辑里,通过alu_op、alu_out之类的内部信号把加法器、移位器、比较器组织起来。由于是软核,ALU的实现思路是在最小面积和最高频率之间找平衡,所以你在源码里不会看到复杂的高速加法器结构,更多是直接的组合函数调用。

比较常见的代码模式是为每条指令生成一个“执行结果候选值”,然后在写入寄存器之前通过多路选择器选中真正的结果。比如算数运算走加法器路径,逻辑运算走独立路径,加载指令则把内存读出的数据直接接到写回通道。读这种代码时我的习惯是:先找文件最下面的连续赋值语句,再回到clocked块里看结果在什么条件下被锁存。只要你把“哪个信号是输入,哪个信号是输出”标清楚,整个数据通路就会从一团乱麻变成一张清楚的结构图。

当然,如果你点名要看乘法器的实现,那么ENABLE_FAST_MUL参数打开时,综合工具会把乘法映射到FPGA的DSP单元;关闭时则利用移位相加逻辑,源码里一般会有一个显式的多周期乘法状态。这个状态在波形上会表现为一个循环,持续若干周期后产生最终结果。分析时可以用一个开着乘法、不开快速乘法综合后的仿真结果,直观感受“多周期指令”的含义。

4. 内存、中断与PCPI:接口如何交织

4.1 内存握手协议到底谁等谁

PICORV32的内存接口是同步握手,跟ARM AXI、Wishbone都不一样,网上也叫“Native接口”。核心就是CPU拉高mem_valid表示我要访问,外部存储器拉高mem_ready表示我已经准备好。读操作时,CPU在检测到mem_ready的下一拍取走mem_rdata;写操作时,CPU需要把mem_wdata和mem_wstrb同时准备好。

这里最常见的误解是:mem_valid和mem_ready是不是必须在一个周期内重合?答案是否定的。mem_valid可以持续拉高多个周期,直到外部存储器给出mem_ready为止。因此外部设备可以用一个简单的有限状态机来控制响应速度,也可以直接用组合逻辑把mem_ready接到高电平,表示“永远随时响应”。对使用FPGA Block RAM的场景,很多人会直接把mem_ready做成读地址有效后的延迟一拍信号;对使用总线桥的场景,则要等AXI或Wishbone那边的返回。分析源码时,我建议把mem_valid理解成“CPU请求有效”,把mem_ready理解成“外设请求完成”,这两个信号互相握手,CPU才会推进状态。

4.2 LATCHED_MEM到底改变了什么

LATCHED_MEM参数为1时,内存接口会有一部分“锁存”行为。普通模式下,CPU在mem_valid为高且mem_ready为高的同一拍完成数据传输。锁存模式下,CPU会把地址先锁进内部寄存器,然后再发起读写,整个过程可能需要额外的周期,但对应到FPGA内部的Block RAM时序更友好。代码里表现为多了latched_addr、latched_wdata之类信号,以及一组独立的“la”端口,比如mem_la_read、mem_la_write。

我在一个实际项目里遇到过一种情况:外部SRAM的读延迟是固定的两拍,直接连mem_ready总是来不及。后来我放弃了在Always块里手工做延迟匹配,直接打开LATCHED_MEM,把锁存接口接到一个简单的读延迟状态机上,时序一下子就干净了。这件事给我的启发是:接口的握手延迟,不是CPU端单方面能解决的,你需要读源码看清楚CPU给你预留了哪些握手模式,再根据外设的时序特性选择最简单的那一种。

4.3 中断子系统的源码阅读顺序

PICORV32的中断实现比它的基本指令路径复杂一个量级。它支持通过irq[31:0]输入外部中断,支持定时器中断,甚至支持一种“快速中断”模式。源码里围绕中断引入了一批CSR相关的内部逻辑,包括mstatus、mie、mepc这种机器模式寄存器。通读中断代码的关键路径是:先找irq信号什么时候被锁存,再找异常/中断统一入口是如何把PC切到异常向量的。不要把每条CSR指令都抠细,先把“中断来了,PC跳去哪、返回地址存哪、状态怎么恢复”这条主线找到,其他细节都只是CSR读写逻辑的延伸。

还有一点让我印象特别深:PICORV32的中断使能是作为内存映射或CSR控制存在的,用csr指令可以读写这些寄存器。如果你之前只用过ARM CM0那种硬件自动压栈的中断模型,看到这个会有点不习惯——它没有自动压栈,进入中断后需要软件自己保存现场。源码里对应位置会有一套保存mepc、mstatus的机制,但不负责把通用寄存器全部推入栈。这意味着中断服务程序要自己管理通用寄存器的保存恢复,否则中断里一用临时寄存器,现场就被破坏了。这一点在写裸机中断程序时极其容易踩坑。

4.4 PCPI:让自定义指令成为CPU的一部分

PCPI接口是PICORV32让我最兴奋的设计。你可以把某些自定义指令的译码和执行完全外包给外部Verilog模块,CPU在做完基础译码后,把pcpi_valid拉高,并把指令、rs1、rs2的值送给协处理器。协处理器算完后把结果放到pcpi_rd,再拉高pcpi_wr,CPU就会把结果写进目标寄存器。整个过程对软件透明,汇编代码里可以直接写一条自定义指令。

源码里PCPI部分会包含一个对无PCPI外设接入时的默认处理:如果没有任何外部模块拉高pcpi_wr,CPU可能会自动判定为非法指令,或者一直等待。这个等待行为很容易成为隐藏问题,所以如果你确定使用了PCPI,一定要在仿真里验证外部协处理器的握手信号是否完备。我在第一次调PCPI时,就是忘了给协处理器加时钟,导致pcpi_valid拉高后CPU一直停在那里等,看起来像是死机,实际只是没人应答。

5. 实操:从源码到仿真,跑通一条指令

5.1 工具链与测试环境的准备

要真正“玩”这份源码,至少要准备三样东西:一个支持RISC-V的交叉编译器,比如riscv64-unknown-elf-gcc;一个Verilog仿真器,最常用的是iverilog,更看重性能的用verilator;还有一个波形查看工具,如GTKWave。仓库里自带了很多测试用例,从简单的add、sub指令到完整的Dhrystone都有。第一次跑建议直接用官方testbench,先把CPU基础功能跑通,再用自己的程序替换测试镜像。

官方仿真流程大概是这样:先用交叉编译器把C测试程序编译成ELF,再用makehex.py把ELF转成hex文件,最后让testbench里的readmemh把hex加载进内存模型。这个过程虽然多了一步转换,但好处是内存初始化完全由软件控制,仿真环境不依赖任何操作系统或BIOS。

5.2 最容易碰到的三个坑

我在这套源码上踩过的坑,第一个是复位后PC的初值问题。PICORV32没有在RTL里定义固定的复位PC地址,复位后PC往往从上电时的寄存器初值开始,也就是说它取决于外部如何初始化内存和寄存器,而非像ARM那样固定跳0x00000000。如果你发现仿真一开始CPU行为不对,先检查testbench里PC初值到底被设置成了什么。

第二个坑是内存模型的mem_valid和mem_ready长时间握手。如果外部自己在组合逻辑里把mem_ready拉高,但地址还没稳定,CPU可能提前采到错误数据。调试时最好的办法是打开VCD波形,把mem_valid、mem_ready、mem_addr、mem_rdata四根线一起看,一旦出现握手成功但数据错误的场景,马上能定位是谁的时序没对齐。

第三个坑是中断相关测试程序里寄存器保存不完全。官方的某些中断测试例子默认编译器不会在中断服务程序里乱用寄存器,但你换成自己的程序后,如果中断里调用了函数,必须确保保存恢复现场。源码分析只能帮你确认硬件跳转逻辑没问题,能不能跑对,最终还得靠编译器的调用约定和你的汇编代码配合。

5.3 用波形反推内部状态机的办法

拿到一份陌生CPU源码,我最常用的分析路径是先跑一条最简单的指令,比如addi a0, zero, 1,然后看波形上PC的变化、mem_valid的拉高、mem_rdata的返回,再观察内部寄存器写使能什么时候被置上。这一步能快速确认CPU从取指到执行写回的基本周期数。再跑一条加载指令lw a0, 0(a1),观察数据访存和取指之间的停顿,你就能从波形上看出加载延迟到底占了几拍。

当需要更深层分析时,我会在源码里临时加信号,比如把cpu_state编成可读字符串输出,或在关键握手处加$display打印。这种临时改动不影响综合,只影响仿真,能大大加快阅读速度。最终你会发现,其实PICORV32的状态机拢共就那么多状态,绝大多数时间CPU都在“等握手”和“执行普通运算”两种状态里切换。

6. 源码改造经验与调试技巧

6.1 如何把PICORV32“调”成自己的软核

源码分析到最后,多数人不会只停留在“看懂”,而是想改成适合自己的形态。最常见的改动是内存接口,比如把Native接口封装成AXI-Lite或Wishbone主设备;其次是增加自定义CSR,实现软件可读的版本号、状态寄存器;还有就是在PCPI上挂一个硬件加速器,让CPU直接执行某种自定义运算指令。

改源码时我强烈建议先拉分支、保持原版可跑,再逐步做小改动。每次改完立刻仿真验证,不要一次性改完再查。因为PICORV32里大量逻辑是互相关联的,改一个握手条件可能牵动取指状态跳转,不动仿真回归很难发现隐藏问题。官方testbench虽然简单,但覆盖了大部分基础指令和中断路径,足够做日常回归。

6.2 几处容易改错的地方

如果你要动参数默认值,记得先看清楚模块内部有没有相关的generate条件块。比如ENABLE_IRQ打开后,中断相关信号和CSR逻辑才会被真正综合出来,否则很多寄存器只是悬空。把参数从0改成1很简单,但代码里与参数相关的条件分支可能分散在多个always块里,任何一处没照顾到都会出现“信号存在但逻辑不生效”的现象。

还有地址总线宽度的问题。PICORV32的mem_addr是32位,但实际使用的FPGA内存可能只有几十KB,地址映射需要在总线桥上做高地址位裁剪,而不是在CPU内部改。你如果直接把mem_addr连接到Block RAM的窄地址端口,综合工具可能会报位宽不匹配,逻辑上也会导致地址环绕访问,这是很容易被忽略的边界情况。

6.3 根据个人经验的一条建议

最后分享一个我的读码习惯:第一遍读代码时不要打开仿真波形,而是在纸上先把数据通路画出来,标出“哪里是取指”、“哪里是译码”、“哪里写回寄存器”。第二遍打开波形,针对几条代表性指令验证自己的画是不是对。第三遍再去抠细节,比如乘法的周期数、中断返回的时序、PCPI握手的边沿。三遍下来,这份源码的骨干基本就在你脑子里了,之后不管是用它做实验板CPU,还是移植到自己的SoC总线上,都会顺手很多。

如果你手头正好有FPGA开发板,我建议直接把PICORV32烧进去,写一个LED闪烁程序,再从串口回传一个整数运算结果。这种“软核真的能跑程序”的感受,比单纯读源码带来的理解要深得多。哪怕是同一份代码,第二次读也总会发现新的细节,这就是开源软核最有魅力的地方。

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

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

立即咨询