PICORV32源码解析:用Verilog读懂RISC-V软核的处理器设计
1. 为什么偏偏是PICORV32从一份不到两千行的Verilog谈起1.1 这个软核的分量开源RISC-V里的一块敲门砖先说结论PICORV32不是性能最强的RISC-V软核但它几乎是源码可读性最好的一个。由Clifford Wolf维护整个处理器的核心逻辑几乎集中在一个picorv32.v文件里总共不到两千行Verilog。对于一个指令集完整的处理器核来说这个代码量非常克制。我最初接触它是想在自己的一块FPGA开发板上跑一个最小的RISC-V环境。对比过Rocket太大了SCALA和Chisel写的对只想看RTL的人不够友好、Ibex需要SystemVerilog和UVM生态、VexRiscvScala写的生成出来的代码是黑盒。PICORV32是经典的Verilog风格全局信号可读状态机直接用iverilog加GTKWave就能跑起来特别适合想从零理解处理器数据通路的开发者。它的定位也很有意思不是给Linux用的而是给裸机、RTOS或者超小型嵌入式场景用的。支持RV32I指令集可选M扩展乘除法和C扩展压缩指令没有MMU没有缓存没有分支预测。用FPGA实现时逻辑单元占用通常只有几百到一两千时钟频率可以跑得比较宽裕。如果你要的是能跑、能读、能改的RISC-V它就是天花板级别的参考。1.2 源码结构总览一张地图搞定入口拿到仓库之后你会看到几个核心文件。我不建议一上来就点开picorv32.v硬读而是先把文件之间的关系理清楚文件作用阅读优先级picorv32.v核心处理器RTL全部逻辑所在必读picorv32.vh可配置宏定义参数开关集中地先读picorv32_axi.v将核心的简单握手封装成AXI4-Lite接口选读picorv32_wishbone.v封装成Wishbone总线接口选读picorv32_skid_buffer.vAXI封装中用于流水线气泡处理的缓冲模块选读picorv32_trace.v可选的行迹调试模块输出指令流选读picorv32.vh里定义了所有可配置参数比如PICORV32_REGS寄存器数量、PICORV32_ENABLE_MUL、PICORV32_ENABLE_COMPRESSED、PICORV32_ENABLE_IRQ等。你可以把这些参数理解为处理器的裁剪清单——通过改变宏定义同一份代码能生成不同面积和功能组合的软核。理解了这张清单再回去读picorv32.v就不会被一堆ifdef吓住。2. 从取指到写回状态机如何驱动一条指令走完生命周期2.1 主状态机的宏观流程PICORV32是一个典型的非流水线或极浅流水处理器指令生命周期被拆成清晰的阶段。核心状态寄存器在源码里叫cpu_state相关的状态常量包括localparam [3:0] LATCHED_OP 4d0; localparam [3:0] LATOHD 4d1; localparam [3:0] LATHE 4d2; localparam [3:0] EXECUTING 4d3; localparam [3:0] LATD 4d4; localparam [3:0] LATC 4d5; localparam [3:0] LATB 4d6; localparam [3:0] LATA 4d7;这些状态的名字LABEL部分对应的是等待什么比如LATOHD阶段负责解析操作数RS1、RS2的有效性LATHE阶段负责等待立即数或跳转信息就绪EXECUTING阶段真正把ALU结果算出来并写回寄存器。后面LATA到LATD并不是常规指令的必经路径更多是给乘除法、压缩指令译码以及异常处理这类需要额外周期的场景使用。核心流程可以简化成当前没有活动指令时把PC放到mem_addr上拉高mem_valid执行取指。外设或存储器回mem_ready后指令本身被锁存同时next_pc被更新为PC4非分支场景。组合逻辑译码立即产生decoded_*系列控制信号。状态机进入执行阶段完成ALU运算、访存或分支跳转。写回目标寄存器并回到取指状态。RISC-V的指令编码比较规整opcode、rd、rs1、rs2字段位置固定这让纯组合译码变得非常简单。PICORV32没有额外插入指令缓存取指和执行严格依赖mem_valid/mem_ready握手所以每条指令最少也要两个周期跳转或访存指令则更久。对于极简设计来说这种吞吐量完全可以接受。2.2 关键信号active_insn 和 trap 的作用读源码时你会反复碰到两个信号active_insn和trap。active_insn可以理解为当前有没有正在干活儿的状态。它本质上是一个很长的组合条件用来标明处理器正处于某条指令的执行流程中。为什么需要它因为PICORV32在取指和执行的重量级逻辑里大量使用条件赋值active_insn为假时所有执行相关的寄存器都不动这样代码就不用为每个分支单独写否则清空的逻辑。这在写状态机时是个很省事的技巧代价是你必须仔细追踪这个信号的所有置位和清零条件。trap是异常出口。当非法指令、非对齐访问等情形发生时处理器会拉高trap并把原因编码放进trap_cause。源码里还有对应的CATCH_ILLINSN、CATCH_MISALIGN参数。如果你在自己的SoC里挂了一个中断控制器通常会用trap信号驱动异常处理流程。值得注意的是PICORV32的trap不会自动跳转到某个异常向量它更像一个紧急事件上报通道实现细节需要你自己在SoC层面处理。2.3 实际时序在波形上看一条ADD指令我读这个模块时习惯先把仿真跑起来再对着波形看状态机。假设执行一条add指令第一拍mem_valid1mem_addr当前PC此时cpu_state在初始态。第二拍mem_ready回来mem_rdata上是指令。如果指令译码结果是一条普通ALU指令cpu_state先进入LATOHD把rs1和rs2对应的操作数准备好然后进入EXECUTINGALU算出结果目标寄存器写使能拉高。第三拍写回完成active_insn变低开始下一轮取指。对着GTKWave看的时候你只要盯住cpu_state的跳变和mem_valid/mem_ready的关系整条指令的经历会非常直观。这个以波形为索引读源码的方法比纯用眼睛扫代码快得多建议所有入门者都这么干一遍。3. 译码器背后的组合逻辑decoded_*信号如何还原RISC-V语义3.1 一套信号族打天下PICORV32的译码逻辑分布在多个always块里但命名规律非常一致。核心的就是一组decoded_*信号比如decoded_rd、decoded_rs1、decoded_rs2、decoded_imm、decoded_rd_sel等。它们全部由当前锁存的指令和active_insn组合决定。你可能会问为什么译码结果不用流水线寄存器锁存一下这个就得回到模型本身。PICORV32把指令锁存在ir寄存器里而译码结果是在每个周期内即时组合出来的。因为一个周期内只会执行一条指令译码不需要为后续指令预取信息所以用组合逻辑就够了。这大大减少了寄存器数量也是它能在FPGA上做到极低资源消耗的原因之一。但组合译码也有代价关键路径会变长。当你把主频拉高时从ir到ALU再到寄存器写回的组合延迟会决定最高频率。我在超频测试时遇到过这种情况加了PICORV32_REGS64寄存器堆多一组用于绕过转发后时序改善了一些这就是在面积和速度之间做取舍。3.2 立即数生成和操作数选择RISC-V的立即数有不同的编码布局I型、S型、B型、U型、J型。PICORV32里有一段组合逻辑专门负责把指令字段拼成32位立即数。比如if (active_insn) begin casez (active_insn[6:0]) 7b0010111: imm { active_insn[31:12], 12b0 }; // U-type lui 7b1101111: imm { {12{active_insn[31]}}, active_insn[19:12], active_insn[20], active_insn[30:21], 1b0 }; // J-type jal ... endcase end这段代码的妙处在于它把RISC-V规范中的立即数符号扩展、位拼接规则直接翻译成了Verilog的位选择没有做任何二次封装。你可能觉得这有点原始但恰恰因为直接你才能在半小时内确认某一种指令的立即数生成逻辑是否正确。操作数选择上PICORV32会把rs1对应的寄存器值放到reg_our[rs1]同时允许通过参数让源码在快速转发和直接等待之间切换。这部分我会在下一节专门展开因为它是理解处理器数据冒险的关键。3.3 分支条件和跳转目标分支指令BEQ、BNE、BLT等在译码时只需要判断两个操作数的大小或相等关系。PICORV32用组合逻辑算出branch_taken然后根据branch_taken把next_pc指向分支目标或PC4if (branch_taken) next_pc pc_plus_imm; else next_pc pc_plus_4;真正有趣的地方在于PICORV32在分支预测上做了个非常朴素的优化如果分支目标是确定的比如无条件跳转它会提前把取指地址切过去减少不必要的等待周期。像jalr这种间接跳转因为目标依赖寄存器值必须等到操作数准备好才能算出目标地址所以延迟会更明显。我在做总线仿真时发现JALR确实比JAL慢一到两个周期这个差别在源码里能直接对应到状态机的分支上。4. 寄存器堆的工程实现面积优先的架构决策4.1 你的寄存器堆不一定是32x32的SRAM很多初学者默认处理器寄存器堆就是一块SRAM两个读端口、一个写端口。PICORV32给了你更多选择它通过PICORV32_REGS参数控制PICORV32_REGS32标准32个32位寄存器分布到LUTRAM或FPGA的寄存器单元。PICORV32_REGS64实际使用64个物理寄存器其中一部分用于消除写后读冲突的转发压力。PICORV32_REGS4只有4个物理寄存器显著减少资源但性能会更差适合极小面积场景。源码中寄存器堆的主体大致是reg [31:0] reg_our [0:PICORV32_REGS-1];这里有几个工程细节值得注意。首先FPGA的分布式RAMLUTRAM通常支持一个写端口和两个读端口并且读写同周期时有读旧值和读新值两种模式。PICORV32会利用这种硬件特性尽量在时钟沿同步完成写回避免额外的旁路逻辑。其次编译器可能并不知道处理器的物理寄存器有64个而架构寄存器只有32个所以汇编代码该怎么生成还是怎么生成多出来的寄存器只在硬件层面做隐式转发。如果你的设计目标是最小面积选PICORV32_REGS4会明显降低LUT占用但每条指令都可能因为寄存器不足而被迫插入等待周期。我试过在ICE40上跑这个配置整体逻辑量可以压到非常低但实时性就别指望了跑串口收发之类的小任务倒还行。4.2 数据冒险不转发就暂停处理器里最常见的冒险是RAW写后读上一条指令要写寄存器下一条指令要读同一个寄存器。流水线处理器一般做转发PICORV32简化了这个问题——它的核心理念是让下一条指令在操作数真正可用的那个周期再进入执行状态。具体来说PICORV32会通过状态机的LATOHD阶段检查当前要读的rs1/rs2是否刚好等于正在写回的rd。如果匹配它不会马上执行而是多等一拍等写回真正落地再继续。这个行为在波形上看起来就是cpu_state停在LATOHD不动直到写使能撤销。这种设计让我想起初学CPU原理时老师讲的一句话没有转发就老老实实插入气泡。PICORV32不是没有能力做转发而是选择了让硬件更简单、面积更小。对于大多数裸机程序这个暂停周期完全可以接受。你如果真的很在意性能可以考虑PICORV32_REGS64——硬件会把架构寄存器的内容复制到多出的物理寄存器中用它来绕过一部分冒险阻塞。4.3 硬件和编译器的配合一个坑提醒一下PICORV32官方建议配合-marchrv32imc之类的GCC参数使用但当你修改了PICORV32_REGS之后编译器生成的汇编并不知道硬件有这个多寄存器转发机制。所以硬件层面的优化对软件是透明的软件无需感知。反过来如果你想自己加指令或改指令编码就必须同步改编译器。PICORV32社区常用的方式是直接基于RISC-V GNU工具链加patch这比手写汇编做实验要靠谱得多我改过一个自定义CSR指令最开始就是手写汇编跑通了后面才去改GCC的riscv-opcodes。5. M扩展与C扩展的裁剪智慧用最少的硬件换指令兼容5.1 乘除法的多周期实现RISC-V的M扩展包含乘法、除法、余数指令。PICORV32在默认配置下并没有实例化一个DSP硬核乘法器虽然有相关选项而是用移位加法的方式实现乘除法配合状态机多周期完成。你可以把这种实现理解为ALU面积小但乘法要跑很多拍。以32位乘法为例它会在LATD等扩展状态下按位累加部分积每周期移位一次最终在若干周期后得到结果。源码里你能看到明确的循环变量和移位逻辑这比直接调用乘法器更能反映设计者面积优先的思路。我在评估一个需要频繁乘法的DSP算法时发现性能短板很明显但换成用mul指令相对密集的代码后整个处理器的主频和面积依然可控。如果你的算法对乘法延迟敏感建议在SoC里单独挂一个硬件乘法器而不是让PICORV32自己做。5.2 压缩指令的合并译码RV32C扩展把常用指令压缩成16位编码好处是代码体积小、取指带宽省一半。PICORV32支持C扩展的方式也非常有特色它会把一条16位指令先等效成一条32位指令的语义再走统一的译码执行路径。从软件视角看PC的增长粒度变成216位指令或432位指令RISC-V规范里对此有明确描述。在硬件上你会在源码里看到处理ir时对是否压缩指令的判断如果当前指令是高16位有效那么取指地址推进2如果是一条32位指令则推进4。这里最容易被坑的地方是PC对齐和取指宽度当总线上出现半字对齐的取指操作时mem_addr的最低有效位需要做相应处理。我把这个功能打开后同样的固件代码体积大概缩小了30%~40%。代价是译码逻辑稍复杂了些但相对于省下的存储空间完全划算。如果你的FPGA存储资源紧张优先打开压缩指令扩展。5.3 中断支持与异常捕获PICORV32_ENABLE_IRQ参数会带来一组中断相关信号比如irq、eoi、irq_ack。这个机制比标准RISC-V的CLINT/PLIC要轻量得多它更像一个简单的外部信号触发跳转方案。当你把某个irq引脚拉高时处理器会在合适的边界响应跳到对应处理流程然后通过eoi来指示中断结束。异常捕获方面前面提到的trap信号会配合trap_cause上报原因。源码里有CATCH_ILLINSN、CATCH_MISALIGN参数分别用于捕获非法指令和非对齐访问。如果这两个参数为0遇到非法场景处理器不会暂停而是直接跳过这在某些嵌入式容错场景里也是一种实用策略。6. 内存接口与总线封装把一个裸核心变成SoC部件6.1 把valid/ready协议吃透PICORV32裸核的内存接口特别简单output mem_valid, output mem_instr, input mem_ready, output [31:0] mem_addr, output [31:0] mem_wdata, output [3:0] mem_wstrb, input [31:0] mem_rdata规则一句话就能讲完拉高mem_valid表示CPU发起访问地址和写数据随mem_addr、mem_wdata给出外设准备好之后拉高mem_ready一个周期完成握手mem_instr表示这次是取指访问mem_wstrb按字节控制写使能。读写的具体行为是读操作mem_valid1写使能全0外设把数据放到mem_rdata上握手后CPU锁存。写操作mem_valid1mem_wstrb非0握手时数据被锁存到外部存储器。这里有个细节PICORV32要求外设在mem_valid为高且mem_ready为高那一拍完成数据采样不支持额外的等待状态扩展。如果外设还需要更多周期就必须让mem_valid保持住等外设准备好再拉mem_ready。所以设计总线桥时最简单的办法是做一个握手转状态机的桥把mem_valid映射成总线事务开始信号mem_ready映射成事务完成信号。6.2 AXI和Wishbone封装的思路裸核接口可以直连你自研的存储器控制逻辑但要挂到标准总线上就需要封装。项目里提供了picorv32_axi和picorv32_wishbone两个现成的桥。AXI封装适配AXI4-Lite从端口核心的mem_valid/mem_ready被翻译成AW、W、B、AR、R通道的握手。由于AXI通道支持乱序返回桥内部还得处理多个未完成事务的顺序问题。Wishbone封装适配经典的Wishbone B4协议支持流水线操作模式。我在仿真时发现打开流水线模式能显著提高读吞吐量但需要外设也支持标准Wishbone握手。这两个桥本身也是很好的Verilog学习素材尤其是AXI桥里的状态机和FIFO处理几乎是把简单握手升级成标准总线的教科书案例。6.3 PicoSoC参考设计怎么用仓库里还有一个官方SoC示例叫PicoSoC。它把PICORV32、一块小RAM、一块SPI控制器、UART等外设集成在一起是快速搭建验证平台的模板。我第一次跑通PICORV32就是直接烧的PicoSoC工程用一块廉价的FPGA开发板就实现了简单的串口回显。官方还提供了一个叫picorv32_soc的顶层文件你可以参考它来理解时钟域、复位逻辑和外设地址映射。如果你想验证自己的总线封装建议先在这个SoC里替换存储控制器而不是直接对着裸核写驱动——毕竟裸核只有内存接口外设中断、寄存器读写都需要自己搭好才能跑软件。7. 读源码的方法论我是怎么快速吃透一个RTL项目的7.1 建一个能秒出波形的仿真环境读PICORV32源码我强烈建议先搭好iverilog GTKWave环境iverilog -o picorv32_sim picorv32.v testbench.v vvp picorv32_sim gtkwave dump.vcd写一个最简单的testbench拉高复位信号然后释放复位让CPU跑一段内存里预置的汇编指令。你不需要一开始就加复杂外设只需要一块RAM、一个时钟、一个复位就能看到完整的取指、执行、写回过程。等波形能正常显示了再打开源码一边看波形一边跳转代码效率极高。如果手头没有现成测试程序可以直接用make test跑项目自带的测试套件也可以自己写一小段汇编addi x1, x0, 5 addi x2, x0, 3 add x3, x1, x2 sw x3, 0(x1)把这段汇编编译成二进制并初始化到RAM里跑出来的波形足够你理解一整条数据通路。7.2 最容易踩的几个坑mem_wstrb和字节序。PICORV32是小端序写数据时mem_wstrb的每一位对应一个字节。如果你从外部总线直接读32位数据先确认自己桥里的字节交换没有写反。中断使能后的指令边界。打开ENABLE_IRQ后中断不是立即响应的必须等到当前指令完成。如果你的外设希望实时响应需要在SoC层做好缓冲。编译器的-march参数。PICORV32的实现并非完全支持RISC-V规范里的所有CSR和特权指令裸机程序里最好不要轻易使用MIDELEG之类的寄存器。官方为此还自带了一个跳板程序用来处理不同模式下的异常。PICORV32_REGS64不是免费午餐。它确实能减少一些冒险阻塞但寄存器堆占用会翻倍而且代码里某些组合逻辑路径也会变长不是所有场合都划算。7.3 如果你想改它从哪里下手最安全我最推荐的修改顺序是先改内存接口的时序把它适配到自己的存储器控制器再改动译码器加一条自定义指令最后才碰状态机核心。前者只需要改几个握手信号风险低中间者需要同步改编译器但逻辑相对独立后者一旦改错整个处理器的行为都会变建议在仿真充足的条件下进行。我自己曾经试着给PICORV32增加一个简单的硬件定时器其实不需要动CPU核心只要在SoC层挂一个外设、给它分配一段地址空间CPU用标准load/store访问即可。改完你会发现PICORV32的模块化程度比看起来要高核心保持稳定扩展完全在外设层完成。如果你也想彻底读懂一个RISC-V处理器源码我的建议很直接别只看代码先把状态机和波形对上再挑一条指令从头到尾走一遍。PICORV32是我见过最适合干这件事的作品没有之一。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →