ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PICORV32源码解析:一个Verilog文件实现RISC-V软核

PICORV32源码解析:一个Verilog文件实现RISC-V软核 手里要是有一块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乘除法扩展需要就置1PICORV32_ENABLE_IRQ是否支持中断与定时器需要就置1PICORV32_ENABLE_PCPI是否开放自定义协处理器接口想挂硬件加速器就置1PICORV32_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模块。一条普通加法指令的旅程是这样的IF状态从总线取出32位指令字存入指令寄存器下一拍进入ID状态将指令字段送给ALU并把寄存器堆里的操作数读出来再到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而是2PICORV32在所有跳转目标计算里都做了这种动态长度处理看到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最吸引人的设计之一就是PCPIPico 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响应。最简单的做法是用手写的分布式RAMalways (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会帮你做程序退出。编译器生成的启动文件通常会跳转到mainmain返回后又跳回一个无限循环确保pc不会跑到未定义区域。5.3 改源码定制你自己的核如果你想裁剪或者扩展PICORV32我建议遵循这样的顺序先原封不动跑通一个基础工程确认整体流程没问题再按需求打开PICORV32_ENABLE_MUL、PICORV32_ENABLE_IRQ、PICORV32_ENABLE_PCPI等参数每个参数改动后重新跑一遍简单测试程序确认软件侧还能正常编译运行最后再动核心状态机逻辑每次动一点用仿真波形验证。千万不要上来就大改状态机。这份代码的模块交织得很紧密牵一发而动全身不如靠参数裁剪和PCPI扩展来满足大部分需求。那种为了加一条自定义指令直接改pico_cpu状态转移的做法通常会把仿真波形调得面目全非。6. 读源码的推荐路线和常踩的坑6.1 推荐阅读顺序源码虽然只有一个文件但直接从头看到尾容易晕。我的建议是先看文件顶部的参数列表和宏定义搞清楚有哪些开关看pico_cpu模块的端口列表理解外部接口信号找到状态机定义和case (state)大段逻辑先不用深究每个细节把状态名捋一遍读pico_alu模块弄明白ALU输出和alu_q的区别读pico_mem模块理解内存总线握手和地址对齐逻辑最后回头看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加了两级流水线结果改了大半个月最后整体稳定性还不如原版配一个硬件乘法器。软核这种东西够用、稳、好改比单纯跑得快更值得追求。
返回列表