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.v:CPU核心,所有处理器逻辑都在这里。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量身定做的。如果换成ASIC,16个写读端口的寄存器堆用触发器做会让面积膨胀,但在FPGA的LUT+FF架构里,这种实现反而更直接。
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里某个字段的取值,随时翻指令编码格式对照,效率会高出很多。