☰
PICORV32源码解析:一个Verilog文件实现RISC-V软核
2026/10/1 15:01:55 网站建设 项目流程

手里要是有一块FPGA开发板,多半绕不开PICORV32这个名字。它可能是目前最适合拿来“研究CPU怎么工作”的开源RISC-V软核,整颗处理器核心就封装在一个Verilog文件里,扔进工程就能用。没有流水线、没有分支预测、没有缓存,一切为了省逻辑资源,代价是性能确实慢,但换来的是代码极其容易读懂。这篇文章就一个字一个字拆开这份源码,看它到底怎么把自己塞进一个文件里,还能完整跑起来RISC-V指令集。

这份源码适合三类人:想搞明白“一条指令从取指到写回到底发生了什么”的初学者;在FPGA上做小规模Soc控制核心的嵌入式开发者;以及打算在RISC-V核上挂自定义协处理器的折腾型玩家。我说的“折腾”,是指PICORV32预留了PCPI接口、自定义寄存器读写等扩展点,稍微改改就能变成自己专属的处理器,这也是它能在一众软核里活了这么多年还不被人忘记的原因。

1. PICORV32是什么:一个文件装下整个RISC-V处理器

1.1 设计理念:把面积省到极致

PICORV32出自Clifford Wolf之手,这位老哥做开源FPGA工具链很有名,写这个软核的思路也跟他做工具链一样,追求的不是频率多高、性能多强,而是在最小FPGA资源占用下实现完整可用的RISC-V处理器。

于是它把计算机体系结构教科书里的那些经典设计全砍了:不要多级流水线,一条指令的执行就是一拍一拍串行推进的状态机;不要Cache,取指令直接走外部总线;不要分支预测,跳转就老老实实等流水线冲刷。为什么这么干?因为每一层复杂度,在Verilog里都是额外的逻辑门和LUT,对FPGA这种资源有限的设备来说,省到极致意味着同样的芯片可以塞下更多东西。

反过来看,PICORV32把面积优先做到了什么程度?整个处理器核心大概几千个LUT级别,在小型FPGA上能轻松跑起来,主频即便不算高,对控制类任务也完全够用。而且源码只有一份picorv32.v,没有繁杂的工程结构,你要是想研究一个CPU核内部是怎么组织起来的,这套代码比绝大多数商业IP都友好。

1.2 源码结构:三个模块的分工

打开picorv32.v,核心代码不是一堆哗啦啦的文件,而是三个模块打天下:

模块职责核心要点
pico_alu算术逻辑运算纯组合逻辑,处理加、减、移位、比较等运算
pico_mem内存读写控制负责外部总线的握手与时序,内部有对齐和字节选择逻辑
pico_cpu顶层控制与指令执行状态机、寄存器堆、译码逻辑、扩展接口全在这里

这三个模块不是想象中那种“CPU主模块例化ALU和内存模块”的树形结构,而是互相交织的三块逻辑。尤其pico_cpu和pico_mem之间有一堆跨模块信号,光看层次图看不明白,必须跟着信号走一遍时序才能理顺。

参数设计上,源码顶部有一大堆PICORV32_开头的宏和localparam,这是整个核的“可裁制开关”。我挑几个重要的列在下面:

参数/宏作用常用设置
PICORV32_ENABLE_MUL是否支持RV32M乘除法扩展需要就置1
PICORV32_ENABLE_IRQ是否支持中断与定时器需要就置1
PICORV32_ENABLE_PCPI是否开放自定义协处理器接口想挂硬件加速器就置1
PICORV32_ENABLE_EXTRA_CSR允许访问自定义系统寄存器一般按需开启
PICORV32_ENABLE_COMPRESSED是否支持RVC压缩指令能省程序体积

注意:关闭功能不只是省寄存器,也会改变指令译码路径。比如关掉PICORV32_ENABLE_MUL后,遇到乘除指令会直接进trap而不是执行错误结果,这是设计上明确的行为,别把它当成bug。

2. 核心控制逻辑:没有流水线的CPU怎么让指令转起来

2.1 状态机:只有几个状态,却完成了全部工作

PICORV32控制核心是一个典型的状态机,源码注释前就可以看到一排localparam,大致是这些状态:取指(IF)、译码准备(ID)、执行(EX)、内存读(LD)、内存写(ST)、分支处理(BRANCH)以及异常(EBREAK之类的)。没有独立的总线状态,因为访存已经拆进了pico_mem模块。

一条普通加法指令的旅程是这样的:

  1. IF状态,从总线取出32位指令字,存入指令寄存器;
  2. 下一拍进入ID状态,将指令字段送给ALU,并把寄存器堆里的操作数读出来;
  3. 再到EX状态,ALU运算结果落回寄存器堆或写入目标寄存器。

如果指令是lw,执行状态之后还会多出LD状态,等外部总线把数据返回;如果是sw,则在EX后进入ST状态完成写数据。要是跳转指令,还要单独处理BRANCH状态,这个后面细说。

所以PICORV32的效率就是“一条指令至少三四个周期”,乘法除法这种复杂指令更是几十个周期起步,慢,但逻辑极其简单。这个思路非常适合FPGA入门,因为只要理解了“状态机推进=CPU在干活”,整颗核的运作方式就抓到了一大半。

2.2 指令预取:它也有点“小聪明”

PICORV32不是完全没有优化。它在执行当前指令的时候,会尝试把下一条指令先从内存里取出来放到预取寄存器里。这个设计叫指令预取,虽然跟现代处理器的多级流水线预取没法比,但对串行状态机来说能省掉不少总线等待时间。

这里有个关键信号:prefetch_instr和clear_prefetch。前者表示正在预取下一条指令,后者在遇到跳转等控制流变化时,把预取结果打掉。因为预取的下一条指令不一定是跳转真正的目标地址,如果不打掉,乱用旧指令会执行出莫名其妙的结果。

预取还带来了一个实际影响:程序里一条跳转指令要付出额外代价。跳转发生的时候,预取队列作废,状态机必须回到取指状态重新启动预取,这比顺序执行的指令多花周期。

2.3 tiny_rv32 和 fast_rv32:内置的两种配置

在源码里能找到两套配置文件,常被称为picorv32_tiny和picorv32_fast。它们是通过不同的参数组合得到的:

配置特点适用场景
tiny关掉所有扩展,只留基础RV32I,逻辑量最小资源紧张的主控任务
fast打开部分扩展,调整关键路径,提升运行频率对性能有要求的嵌入式应用

其实这两套配置的核心区别不仅仅是开关扩展功能,还包括实现方式。例如源码内部有些逻辑在fast配置下会用并行实现,在tiny配置下用串行实现。读这份源码时如果发现同一个功能有两套写法,先看看是不是被ifdef或localparam区分开的,基本就是这两个配置在起作用。

我自己的经验:除非你确定不需要乘除法,否则大多数项目我还是会打开PICORV32_ENABLE_MUL,因为软件层面上做乘法代价很高,而硬件上这个功能不算太贵,性价比挺划算。

3. 指令执行细节:运算、寄存器堆、访存和跳转

3.1 pico_alu:组合逻辑把运算一次搞定

pico_alu模块是纯组合逻辑,它负责把两个32位操作数按照操作码算出一个结果。RISC-V指令里R型指令的操作本质就是“把寄存器rs1和rs2送给ALU,按funct3和funct7决定做什么运算”。PICORV32的做法非常直接:用case语句枚举所有ALU操作,每算一步都是纯组合逻辑,一个周期就能出结果。

这个模块里可以看到加、减、与、或、异或、逻辑左移、逻辑右移、算术右移、无符号小于比较、有符号小于比较等运算。还有个细节,pico_alu除了输出正常的结果,还会额外输出一个alu_q信号。这个信号跟乘除法扩展有关,它内部保存了移位加法/减法的中间状态,为多周期乘除法做准备。

为什么要把乘除运算的移位状态放在ALU里而不是CPU状态机里?因为PICORV32想把乘除当成“ALU内部的多周期操作”,在状态机层面不用增加一堆额外的状态。等alu_q经过指定轮数运算完成,再通知主状态机拿结果。

3.2 寄存器堆:两个读口一个写口

RISC-V架构要求有x0到x31共32个通用寄存器,PICORV32用x_reg数组存它们。这个寄存器堆提供了两个异步读口和一个同步写口,分别对应指令里的rs1、rs2和rd字段。为什么两个读口?因为大多数指令执行时,ALU需要同时读两个操作数。

读寄存器是纯组合逻辑,给定寄存器号立刻出数据;写寄存器一定要等时钟上升沿,而且有个细节:x0寄存器永远不写。这虽然只是架构规范里一句话,但在Verilog实现里必须单独处理,否则会污染掉永远为0的硬约束。源码里写回时大概率会做x_reg[rd] <= ...,但对rd == 0的情况有专门的屏蔽条件。

PICORV32还有一个很少人注意的隐藏福利:自定义寄存器组qregs。这是一组跟通用寄存器堆类似的寄存器,但它的访问指令是自定义操作码,专门设计用来高效实现“寄存器堆即存储”的用法。在FPGA上,这种寄存器数组会自然映射成分布式RAM或块RAM,对需要大数据缓存的场景很有用。

3.3 访存指令:握手信号里的字节使能

PICORV32面向外部世界的接口是一组非常精简的内存总线信号:

信号方向含义
mem_valid输出当前访问有效
mem_addr输出访问地址
mem_wdata输出写数据
mem_wstrb输出字节写使能
mem_rdata输入读数据
mem_ready输入外部设备准备完成

lw指令发起到mem_valid拉高,地址落在mem_addr上。这时外部内存控制器需要工作在mem_ready拉高时完成应答。PICORV32设计成访问没有插入额外等待也不影响正确性,但很多情况下内存不可能一个周期读完,所以mem_ready用来协调速度。

注意一个坑:mem_wstrb是4位,分别对应32位数据中的4个字节。写sb(存放单字节)时只拉高对应字节的写使能,写sw时4位全拉高。如果你直接拿这个总线对接一个只支持整字读写的简单RAM,很容易出现高字节被错误覆盖的问题。正确做法是外部内存控制器看着mem_wstrb做字节合并。

3.4 跳转和分支:BRANCH状态的独门处理

RISC-V里jal和jalr是无条件跳转,beq、bne这类是有条件分支。在PICORV32里它们都有共同的命运:进入BRANCH状态。为什么不能满足于普通执行状态?因为跳转涉及几个动作:计算目标地址、判断分支条件、打掉预取的旧指令、把目标地址塞进程序计数器。

具体到jal,目标地址就是当前指令地址加立即数偏移;jalr则是寄存器值加立即数并对齐。源码里会有专门的逻辑算这两个结果,并且比较rs1和rs2来决定beq等分支是否成立。对不成立的分支,程序计数器正常加4,继续预取;对成立的分支,必须把预取作废。这个“作废”操作,就是clear_prefetch信号的主要职责。

读这部分源码时有个容易绕晕的地方:程序计数器pc的更新不是固定加4,而是等于“当前指令地址 + 单条指令长度”。如果支持RVC压缩指令,指令长度可能不是4而是2,PICORV32在所有跳转目标计算里都做了这种动态长度处理,看到pc相关逻辑时千万别默认它一定是4递增。

4. 不是只会跑基础指令:扩展功能怎么塞进小体积核

4.1 RV32M扩展:多周期乘除法如何实现

RISC-V的M扩展包含mul、mulh、mulhu、div、divu、rem、remu这些指令。PICORV32没有照搬硬件乘法器阵列,而是用了一个更省资源的多周期算法:移位加法和移位减法。

乘法做法是经典的“部分积移位相加”,每周期处理一位或几位,最终结果先放在alu_q通道里。除法同理,通过移位减法逐位求出商和余数。正因为这样,乘除法指令在PICORV32上需要几十个周期才能完成,速度确实不快,但换来的是极低的资源消耗。如果你的应用有大量乘法运算,别在这颗软核上跑纯软件算法,要么打开硬件乘法扩展,要么直接用PCPI挂外部乘法器。

源码里还有一个值得注意的点:mul和mulh共用一部分移位电路,因为它们本质都是32位乘32位,只是取结果的低32位还是高32位。这种资源复用在面积受限的软核里非常典型,也提醒你:alu_out输出的是当前运算主通道结果,alu_q则保存中间过程量,二者用途完全不同。

4.2 RVC压缩指令:16位指令怎么混进32位状态机

PICORV32打开压缩指令支持后,可以从16位指令里缩减程序体积。很多RVC指令其实都能映射到某条32位指令,比如压缩的c.add和标准add功能一致只是寄存器编号更少。

源码处理方式很直接:在取指后先判断当前指令是不是16位,如果是,在译码阶段把它展开成内部统一的“准32位指令形式”。这样主状态机不需要为压缩指令单独开一套流水逻辑,而是把它跟标准指令共用同一条执行路径。代价是压缩指令的译码逻辑稍微复杂一点,因为要做指令映射。

实际使用中,打开RVC后程序体积能减小不少,对FPGA里块RAM紧张的场合很管用。但要注意:跳转目标对齐问题。RVC指令可能只有2字节,jal偏移计算和pc更新都必须考虑2的增量,不支持自动对齐的调试器在这种核上访问指令流时容易出错。

4.3 PCPI接口:把自定义硬件加速器挂上去

PICORV32最吸引人的设计之一就是PCPI(Pico Co-Processor Interface)。它本质上是一组约定好的握手信号,让你把自定义指令扔给外部协处理器执行。典型用法是接硬件乘法器、硬件CRC计算器,甚至是一个对外的控制总线。

PCPI接口的关键信号如下:

信号方向含义
pcpi_valid输出当前指令是自定义指令,需要协处理器处理
pcpi_insn输出完整的指令字
pcpi_rs1输出第一个源操作数
pcpi_rs2输出第二个源操作数
pcpi_wr输入协处理器请求写回
pcpi_rd输入协处理器写回的数据
pcpi_wait输入协处理器还需要等待

对接时,主核发出pcpi_valid,协处理器在内部运算完成后将pcpi_wait拉低并置pcpi_wr,主核就看到有效写回结果。如果协处理器一直不响应,主核会一直等,所以协处理器逻辑里必须有确定性的完成条件,别因为组合环或者漏状态导致死等。

我建议第一次接触PCPI的人,先用它做一个最简单的“从rs1读数加1写回”的实验。做完这个,你就能理解自定义指令软硬件接口的整个握手流程,后面再加复杂功能就有底了。

4.4 中断与定时器:软核也要有打断能力

PICORV32把中断支持做得也很精简。它有irq输入管脚,可以在外部事件到来时触发内核进入中断处理流程。PICORV32_ENABLE_IRQ打开后,还会提供定时器功能,可以配置周期触发中断,这对做操作系统调度或者轮询节拍很有价值。

原理上,PICORV32在每条指令边界检查中断请求。如果存在待处理中断,并且当前指令不是跳转执行中间态,就暂停当前主程序,跳转到固定入口地址执行中断服务程序。中断返回后,它会恢复现场继续原程序。

源码里涉及一堆与中断相关的寄存器,用于保存中断状态、中断地址、中断原因等。这部分逻辑在状态机里是额外的一小块,不好画在一张图里,但行为和Cortex-M这类硬核的简单版本中断机制很像。读代码时不用全懂,只要知道:irq输入的高电平,会在合适时候强制改变PC方向。

5. 在FPGA上跑起来:集成、编译、定制三板斧

5.1 内存总线的正确接法

把PICORV32接进FPGA工程,最基本的是接一块BRAM。这个BRAM要能在一个周期内给出mem_ready响应。最简单的做法是用手写的分布式RAM:

always @(posedge clk) begin if (mem_valid && mem_ready) begin if (mem_wstrb[0]) ram[mem_addr[15:2]][7:0] <= mem_wdata[7:0]; // 其他三个字节同理 end end

注意这里用mem_addr[15:2],是因为RAM按32位字寻址,而总线地址是字节地址。如果直接用mem_addr作为数组索引,会超出RAM实际容量。这个细节查问题能查一整晚。

5.2 为PICORV32编译程序

软件侧,PICORV32跑的是标准RISC-V RV32IMC指令集。你用riscv32-unknown-elf-gcc编译程序时,链接脚本要把起始地址设成软核复位后访问的地址,一般就是0x00000000。最简单的一段启动代码就是放一个跳转指令到main,然后让main结束后死循环。

int main() { volatile unsigned int *led = (unsigned int *)0x10000000; *led = 0x55; return 0; }

把led地址指到外部总线地址上,写操作就会通过PICORV32的mem_wstrb和mem_wdata送到Vivado或Quartus里的LED外设逻辑。烧进FPGA后,灯亮不亮、内存读写对不对,很快就能验证。

而启动代码末尾不要用return 0了之,因为软核环境不像Linux会帮你做程序退出。编译器生成的启动文件通常会跳转到main,main返回后又跳回一个无限循环,确保pc不会跑到未定义区域。

5.3 改源码定制你自己的核

如果你想裁剪或者扩展PICORV32,我建议遵循这样的顺序:

  1. 先原封不动跑通一个基础工程,确认整体流程没问题;
  2. 再按需求打开PICORV32_ENABLE_MUL、PICORV32_ENABLE_IRQ、PICORV32_ENABLE_PCPI等参数;
  3. 每个参数改动后重新跑一遍简单测试程序,确认软件侧还能正常编译运行;
  4. 最后再动核心状态机逻辑,每次动一点,用仿真波形验证。

千万不要上来就大改状态机。这份代码的模块交织得很紧密,牵一发而动全身,不如靠参数裁剪和PCPI扩展来满足大部分需求。那种为了加一条自定义指令直接改pico_cpu状态转移的做法,通常会把仿真波形调得面目全非。

6. 读源码的推荐路线和常踩的坑

6.1 推荐阅读顺序

源码虽然只有一个文件,但直接从头看到尾容易晕。我的建议是:

  1. 先看文件顶部的参数列表和宏定义,搞清楚有哪些开关;
  2. 看pico_cpu模块的端口列表,理解外部接口信号;
  3. 找到状态机定义和case (state)大段逻辑,先不用深究每个细节,把状态名捋一遍;
  4. 读pico_alu模块,弄明白ALU输出和alu_q的区别;
  5. 读pico_mem模块,理解内存总线握手和地址对齐逻辑;
  6. 最后回头看PCPI、IRQ等扩展接口,因为这时候你对主状态机已经有整体认识了。

按这个路线走,大概一个下午能理清骨架;想彻底把每条指令的周期都数明白,建议自己拉波形仿真。

6.2 常见问题速查

现象可能原因解决思路
复位后程序不跑复位信号没正确释放,或起始地址不匹配检查复位时序和链接脚本启动地址
mem_valid一直为高外部设备没拉mem_ready确保RAM控制器能及时响应握手
字节写数据错乱忽略mem_wstrb信号按字节使能分别写入RAM各字节
PCPI指令不响应自定义指令编码冲突,协处理器没匹配上检查pcpi_insn里的funct3/funct7
程序烧进去跑飞中断入口和设备地址配置不对核对中断向量和链接脚本内存映射
时序不收敛组合逻辑链太长优先关掉不必要的扩展,限制软件里的乘除法

6.3 一些实践心得

最后分享几条我实际调试中的真经验。

第一,仿真比上板重要得多。PICORV32源码本身比较严谨,但如果改了它,一定要用Testbench把一条加法、一条访存、一条跳转、一条自定义指令都过一遍,再烧上板。空跑仿真波形只需几分钟,上板查错可能要几小时。

第二,不要小看pcpi_wait的处理。很多协处理器初版都忘了在计算未完成时拉高pcpi_wait,导致主核以为结果已有效,读了半成品数据。规范的做法是:协处理器默认pcpi_wait为高,计算完成后才拉低,同时置pcpi_wr。

第三,关键是先把基础跑通再谈优化。这颗核的性能上限不高,如果追求性能,它的价值不在速度,而在于可控、可改、可扩展。我见过有人为了性能给PICORV32加了两级流水线,结果改了大半个月,最后整体稳定性还不如原版配一个硬件乘法器。软核这种东西,够用、稳、好改,比单纯跑得快更值得追求。

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

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

立即咨询