
1. 为什么读了那么多CPU书还是该回到PICORV32源码里翻一遍先说个很多人都有过的经历学RISC-V的时候看文档、看指令集手册甚至看某大师写的《计算机组成与设计》都觉得懂了但真正拿到一个能跑的处理器源码时对着几千行Verilog直接傻眼。PICORV32就是这种看起来简单、真读起来又绕的典型。它是一个由Clifford Wolf用纯Verilog写出来的极简RISC-V核整个核心代码基本就集中在一个文件里支持RV32IMC指令集没有MMU没有缓存面积小到可以在任何一块入门级FPGA上跑起来。很多SoC集成、嵌入式教学、开源硬件项目里都能看到它的身影。我最初接触PICORV32是因为一个项目需要在FPGA里塞一个能跑简单RTOS的小CPU评估了一圈软核最后锁定了它。真正把源码从头到尾读完之后我对面积优化这四个字有了全新的理解——它几乎把所有能省的都省了但又能保证指令集兼容性可以跑Zephyr、FreeRTOS这类轻量级系统。这篇分析就是想把我在读源码过程中梳理出的执行流程、关键模块、握手协议和配置裁剪逻辑讲清楚。适合看这篇文章的人分三类一是想读第一个CPU源码但不知道怎么下手的学生二是准备在FPGA项目里集成PICORV32的工程师三是只想搞明白一个极简处理器到底能简到什么程度的爱好者。不夸张地说把PICORV32的源码读透比看十遍教材都管用因为它把复杂的东西摊开在了你面前。1.1 拿到源码后的第一反应别从头读到尾很多人犯的第一个错误就是打开picorv32.v从第一行开始往下读。这个文件虽然不算大核心代码三千行左右但它的写法非常FPGA工程师风格一大片信号声明一大片组合逻辑一大片时序逻辑中间夹杂着大量generate和localparam配置分支。从头读到尾的后果是读到最后已经忘了前面在干什么。我的建议是反过来读先通读顶层模块的端口声明知道这个CPU对外长什么样然后找到核心状态机的几个关键寄存器最后再回去看组合逻辑里那些译码和ALU代码。这一篇分析也会按照这个顺序展开。1.2 三个文件各干各的活源码仓库里通常会有几个文件核心就三份picorv32.vCPU核心所有处理器逻辑都在这里。picorv32_axi.v把核心的简单握手接口翻译成AXI4-Lite事务的封装层。picorv32_wb.v类似的Wishbone总线封装。我第一次看的时候以为AXI版本会复杂到不行结果打开发现它只是把核心暴露出来的mem_valid、mem_ready、mem_addr这些信号按AXI通道的时序要求做了转接。这从侧面说明了一个重要事实PICORV32核心对存储器的要求低到令人发指任何能用一组握手信号描述的存储器它都能接。2. 先从顶层模块的引脚看设计者的性格PICORV32这个核的设计哲学从端口声明就能看出一大半。它没有把引脚做成AXI或者Wishbone本身而是自定义了一套极简的存储器握手协议。这个选择非常重要因为它决定了整个核的时序行为和后续所有封装层的存在意义。2.1 一组握手信号和一组可选信号核心的存储器接口信号大致如下表所列它们不是AXI那种多通道并行事务只有读和写共用的一个地址通道加一个数据通道信号方向信号名作用输出mem_valid当前周期发出的访存请求有效输入mem_ready外部存储器回应请求已被接收/完成输出mem_addr[31:0]指令或数据访问地址输出mem_wdata[31:0]写数据输出mem_wstrb[3:0]写字节使能输入mem_rdata[31:0]读返回数据这种设计是在告诉集成者别跟我扯复杂的总线你只要保证在mem_valid拉高的时候盯着地址然后该给数据给数据、该握手握手就行。这种接口看起来原始但反而是PICORV32能被各种平台快速移植的关键。很多FPGA教程里拿它当示例核原因就在于它足够裸。除了存储器接口还有一组中断相关引脚irq和调试相关引脚比如ebreak行为相关的控制不过它们都是可配置的不开启对应参数时这些信号会被综合工具优化掉不占资源。2.2 组合逻辑与时序逻辑的边界拿到端口之后下一步是找核心状态机。PICORV32没有经典的五级流水线也没有独立的取指/译码/执行级寄存器。它的执行模型更接近一个微码状态机每条指令被拆成若干微周期靠一组内部状态寄存器一步步推进。最核心的几个状态寄存器是reg_pc当前程序计数器。reg_next_pc下一条指令地址通常等于reg_pc 4但分支、跳转时会被改写。reg_insn当前正在执行的指令原始编码。reg_opcode这个寄存器很特别它既存放译码后的指令类型又充当状态机的状态编码。我第一次读源码时一直在找一个叫state或者fsm_state的寄存器结果没找到后来才发现设计者直接把译码结果当状态用。一条指令在多个周期内需要干不同的事比如乘法要迭代几十轮这时reg_opcode就带着当前指令类型循环等待直到迭代完成。这是一种非常激进的面积优化——它省掉了传统FSM里那组状态编码到行为的映射逻辑。这种设计带来的连锁反应是每条指令的周期数不是固定的。简单指令可能两三个周期就执行完乘法则要几十上百个周期。对于追求实时性和确定性的场景这一点必须心里有数。3. 一条指令的完整旅程从取指到写回理解了PICORV32的执行模型之后就可以顺着一条指令的生命周期去读源码了。这里我用一条最普通的加法指令add x1, x2, x3来追踪它虽然没有访存也没有跳转但足以把取指、译码、执行、写回四个阶段全部走一遍。3.1 取指怎么排队PICORV32的取指并不是每个周期都发起一次。在空闲状态下它会拉起mem_valid把reg_pc放到mem_addr上然后等待mem_ready。注意这里有个细节由于存储器接口是握手式的即使接一个零等待RAM一次取指也至少要两个周期——第一个周期发请求第二个周期接收数据。外部存储器如果慢mem_ready不拉高CPU就一直等。这个至少两个周期的设定在源码注释里被反复强调。设计者宁可牺牲性能也要保证接口的简洁和跨平台一致性。我在FPGA上实测过接片上BRAM时每条简单指令大约需要三到四个时钟周期如果跑在50MHz实际大概能到十几MIPS的水平做控制类任务完全够用做重度计算就吃力了。3.2 译码其实是一张巨大的查找表指令取回来之后存放在reg_insn里。接下来是一大块组合逻辑它在同一时刻计算出这条指令需要的几乎所有控制信号寄存器读地址、立即数、ALU操作类型、写回目标寄存器、下一个PC来源等。PICORV32的译码逻辑没有用case嵌套得很深而是大量使用独立的信号赋值语句逐位解析比如先看opcode低7位区分是哪一大类指令LUI、AUIPC、OP、LOAD、STORE、BRANCH、JAL、JALR、SYSTEM等。再看funct3和funct7进一步区分具体的算术/逻辑操作。立即数扩展逻辑单独做I型、S型、B型、U型、J型各有各的拼接方式。这里我建议读源码时重点看立即数扩展那段。RISC-V的立即数扩展是所有指令集里做得最规整的之一各类型只是把指令字段重新排列到高位然后做符号扩展。PICORV32里这部分代码几乎可以当教科书看。3.3 写回寄存器堆只有一个写口执行阶段完成后结果会送到寄存器堆。PICORV32的寄存器堆设计非常朴素只有一个写口写reg_rd_valid有效时把数据写入reg_rd指定的寄存器。读口则有两条路径分别对应源寄存器reg_rs1和reg_rs2。由于指令在同一个周期里能同时读两个源寄存器并且ALU计算是组合逻辑完成的所以绝大多数算术逻辑指令在一个数据周期内就能算完。真正让它无法做到每个周期都执行一条指令的原因还是取指握手至少两个周期以及某些指令比如乘除法需要多周期迭代。这里还有一个很有意思的细节由于指令串行执行不存在流水线冒险问题所以PICORV32里完全不需要转发网络也不需要插入气泡。这对面积优化来说是巨大的胜利——省掉了一大堆比较器和多路选择器。4. 寄存器堆与ALU极简主义的极致很多第一次读PICORV32源码的人都会有一个困惑寄存器堆怎么不是一块RAM这恰恰是这个核在设计理念上和主流教科书CPU最大的分歧点。4.1 寄存器堆是触发器阵列而不是RAM主流处理器为了追求频率通常把寄存器堆做成多端口RAM用同步读或者组合读实现。但PICORV32的32个通用寄存器直接用触发器阵列实现每个寄存器是一个32位的reg变量分布在always块里。为什么可以这么干因为PICORV32运行的时钟频率本来就不高在入门级FPGA上通常50-100MHz而且对寄存器堆的访问是一个周期读、一个周期写的简单模式用触发器阵列完全能满足时序要求。相比之下只要综合工具肯做优化几十个触发器阵列的布线开销并不比小RAM大而且省掉了RAM的地址译码延迟。这个设计几乎是为FPGA量身定做的。如果换成ASIC16个写读端口的寄存器堆用触发器做会让面积膨胀但在FPGA的LUTFF架构里这种实现反而更直接。4.2 ALU的用与不用PICORV32的ALU并没有做成一个独立模块而是内联在核心逻辑里。它支持的运算包括加、减、与、或、异或、左移、右移、逻辑右移、比较SLT/SLTU。实现上算术运算用一个组合减法/加法器逻辑运算直接用Verilog的位运算符。比较特殊的是移位操作。PICORV32支持两种移位实现由TWO_STAGE_SHIFT参数控制。默认情况下移位操作分多个周期完成每个周期移一位或几位用来节省面积如果打开BARREL_SHIFTER参数则用一个组合逻辑桶形移位器一个周期完成移位。这个取舍很典型面积换速度或者速度换面积源码里给了你选择权。乘除法扩展M扩展更是把面积优化做到了极致。PICORV32没有用硬件乘法器而是用移位加算法迭代完成乘法每个周期检查乘数的一位决定是否把被乘数加进部分积。32位乘法需要最多32轮迭代所以一条mul指令可能消耗几十个周期。同样除法也是用恢复余数法逐位算。这种实现方式在FPGA上资源占用极低代价就是指令延迟大得出奇。如果项目对乘法延迟敏感建议在集成时评估一下是否用硬件乘法器替换。4.3 状态编码的复用技巧再回到reg_opcode这个寄存器。它同时扮演两个角色既记录当前指令的类型也充当状态机的状态。例如执行乘法时reg_opcode保持在乘法对应的状态内部计数器reg_count从32递减到0每减一次就完成一次迭代移位计到0时乘法结束reg_opcode被清回默认值进入下一轮取指。这种做法的精妙之处在于乘法结束后控制逻辑不需要根据状态判断该恢复什么只需要把reg_opcode改回空闲状态即可。整个状态机只有忙碌中和空闲两个大状态中间细节全部靠计数器和指令编码自己驱动。代码量少逻辑层数浅综合出的电路面积小。5. 存储接口背后的设计哲学握手协议再读一遍PICORV32的存储器接口是整个源码里最值得反复读的部分因为它不仅定义了CPU和存储器的交互方式还决定了系统集成时几乎所有的坑。5.1 mem_valid/mem_ready 为什么这么重要这个握手协议本质上很简单mem_valid是CPU发出的本次访问有效信号mem_ready是存储器的我收到并且已经完成访问信号。完成一次读或写必须同时满足两个条件。但几个细节容易踩坑访问一旦发起mem_valid拉高CPU会一直等待mem_ready期间不会再发起新的访问。所以外部存储器绝对不能在mem_valid有效期间去做其他事。mem_valid不会因为mem_ready没来就自动撤销它必须保持稳定。如果存储器是无等待的比如BRAM仍然至少需要两个周期完成一次访问因为第一个周期mem_ready还没准备好。我实际在FPGA上接BRAM的时候第一次写存储器控制器就犯了错误看到mem_valid拉高就立刻返回数据结果忽略了第二个周期的握手。这个协议的真正意图是让存储器自己决定什么时候可以完成访问CPU不做任何时序假设。5.2 地址对齐与字节序的细节PICORV32默认小端模式。对于lb、lh、lbu、lhu这类部分字节访问它会在mem_rdata返回后用组合逻辑根据低地址位选择字节或半字然后做符号扩展或零扩展。接外部存储器的时候有个常见问题BRAM的读数据在地址变化后需要一两个周期才稳定而PICORV32在mem_ready有效的同一个周期里就采样mem_rdata和mem_wdata。我自己做BRAM控制器时一开始给地址后又等了一个周期才拉mem_ready结果发现CPU总是读到旧数据。后来在mem_ready产生逻辑里加了一拍延迟数据就对了。这个问题很像读后写的边界问题调试时一定要看波形说话。5.3 总线封装层到底在翻译什么picorv32_axi.v和picorv32_wb.v把核心的握手协议翻译成AXI4-Lite和Wishbone事务。以AXI封装为例它的工作就是CPU发起mem_valid时把地址和写数据打包成AW、W通道事务同时拉起AR通道读地址。AXI返回的B响应和R数据通过mem_ready反馈给核心。这种封装的价值在于你不需要改CPU核心代码就能把它挂到已有的AXI互联矩阵上。我见过很多开源SoC项目这样做PICORV32作为主设备通过AXI封装接到总线矩阵上外挂UART、GPIO、SPI控制器。如果非要挑毛病那就是AXI封装会引入额外的延迟周期导致性能进一步下降但在非性能敏感场景完全无所谓。6. 中断、调试与那些看起来没用的引脚很多人在评估PICORV32时容易忽略中断支持觉得一个极简核不可能有像样的中断机制。但源码读下来会发现它在面积受限的前提下做了相当务实的中断设计。6.1 中断入口固定入口而不是向量表PICORV32支持多个中断源输入irq端口每个中断源对应一个可配置的入口地址默认情况下入口地址是0x00000010。当中断发生时它会强制把PC跳到对应入口同时把当前的PC保存到内部寄存器中并在执行mret时恢复。这里有个非常值得注意的设计它没有实现完整的CSR控制状态寄存器集合只实现了与中断处理直接相关的少数几个。也就是说如果你习惯了用mtvec、mepc、mstatus这些标准CSR编程在PICORV32上可能会遇到这个CSR不存在的编译错误。但反过来如果只是跑一个简单的RTOS tick定时器中断它的机制完全够用。6.2 ebreak、trace与调试PICORV32把ebreak指令处理成了调试模式的进入指令。开启ENABLE_IRQ_EBREAK后执行ebreak会触发一个调试异常这时你可以通过外部逻辑读取内部寄存器状态。它在源码里暴露了调试读寄存器接口允许外部工具在调试模式下读取reg_pc、reg_rs1这些内部值。我实际调试时更喜欢用另一个办法打开ENABLE_TRACE参数让CPU把执行过的指令地址通过trace端口输出然后在仿真波形里观察程序流。这个方法在跑裸机程序时非常有效能快速定位PC跑飞的位置。7. 把源码头到尾读一遍后的经验总结7.1 阅读顺序建议如果你准备自己读一遍PICORV32源码我根据走过的弯路给出一个建议路径先读参数定义ENABLE_MUL、ENABLE_DIV、ENABLE_IRQ、PROGBUF_SIZE、TWO_STAGE_SHIFT这些参数决定了你看到的代码分支。建议第一次全部关闭只读RV32I核心路径。再读端口声明理解mem_*接口和irq接口。找到执行主循环搜索reg_opcode的赋值逻辑追踪它的所有分支。补读译码逻辑从reg_insn到控制信号的映射关系。最后读总线封装这个可以放到有集成需求时再看。这个顺序最大的好处是每一步都建立在上一步的基础上不会被细节淹没。7.2 实际项目里最好用的配置组合根据自己的项目经验我总结出两个比较典型的配置方案场景推荐参数设置说明面积最小、跑简单控制关闭M、关闭IRQ、关闭BARREL_SHIFTER资源占用极低适合当状态机替代品跑RTOS、需要定时器中断开启M、开启IRQ_TIMER、开启BARREL_SHIFTER性能均衡能跑Zephyr等轻量级系统有一点要特别提醒开启M扩展之后乘法指令的延迟可能高达几十个周期如果代码里频繁做乘法整体性能会非常难看。开启ENABLE_FAST_MUL参数可以在一定程度上改善乘法性能代价是占用更多DSP资源。这是典型的用DSP换速度FPGA上DSP数量够用时很划算。7.3 总体评价与适用边界PICORV32是一个设计目标极其明确的处理器核用最少的资源实现一个可用的RISC-V兼容处理器。它的源码就像一本面积优化实践手册每个设计决策都在告诉你如何在FPGA上省资源。但它不适合追求高性能的场景也不适合需要完整特权级和MMU的操作系统场景。我个人的体会是如果你要学CPU架构与其读那些动辄上百万行的工业级处理器不如先把PICORV32这三千行代码吃透。它能让你在最短时间内建立起指令是怎么变成电路行为的完整认知。而如果你要拿它做产品那重点就要放在配置裁剪和总线封装上把有限的资源留给真正重要的外设和逻辑。最后分享一个小技巧读这类代码时旁边放一份RISC-V指令集手册遇到reg_insn里某个字段的取值随时翻指令编码格式对照效率会高出很多。