1. PICORV32到底是个什么水平:值得逐行读的三个理由
PICORV32这个项目,我第一次认真读它代码的时候,其实是带着一点怀疑的。一个CPU核,核心实现就压在一个picorv32.v文件里,加起来大概也就三百行左右的always块,这能做成什么?后来我把它综合到一块小规模FPGA上,跑通了裸机串口打印、定时器中断、甚至用PCPI接口挂了一个自定义协处理器指令,我确实服了。
如果你在搜索"PICORV32源代码分析",多半已经知道它是个开源的RISC-V软核,是Clifford Wolf的作品,License本身也相对宽松。但我先给个更实在的定位:PICORV32是一个刻意追求"极小面积、极简实现"的RV32I核心,它可以裁剪到只保留整数指令,也能通过配置打开RV32M乘除法、RV32C压缩指令、中断控制器、CSR寄存器,甚至带一个名为PCPI的协处理器扩展接口。它跑不了Linux,也不追求高主频,但它是目前我见过的、最适合拿来一行一行读的CPU源代码。
为什么值得逐行读?第一,它的代码量真的可控。市面上主流的开源软核,像Ibex(前身是Zero-RISCY)动辄好几千行SystemVerilog,VexRiscv虽然模块化做得很好,但它用SpinalHDL写的,你要先学一门新语言才能谈得上阅读源码。而PICORV32的整个处理器核心就集中在一个Verilog文件里,状态机、数据通路、寄存器文件、乘法除法器、中断逻辑全都能在一个编辑器窗口里翻完。这对"想通过源码搞懂CPU工作机制"的人来说太友好了。
第二,它是一个"真实可用的核"。这不是教学玩具,它有握手协议完整的内存接口、可以挂外部RAM/ROM、支持中断向量、支持CSR读写,甚至有人把它做到MCU级别的SoC里跑小系统。用最小配置综合出来,资源占用只有几百个LUT量级,在现代FPGA里就是一个角落的事。也就是说,你读完代码之后得到的是一颗真能在板子上跑程序的CPU,而不是纸上谈兵的PPT架构。
第三,它的设计哲学和商业IP完全不同。商业CPU核的代码你通常拿不到,拿到了也未必许可你去理解;而PICORV32是把"面积优先、逻辑优先"做到极致的一个样本。读它的时候你能清清楚楚看到作者在每个周期里怎么省一个寄存器、怎么把一个加法器拆成两拍来降低关键路径。这些思考方式,恰恰是很多科班教材不会教、但在真实工程里最值钱的东西。
这篇文章我会沿着一条我从"下载源码"到"跑通仿真"再到"挂上外设"的实际路径来展开,把整个源码分析过程里最关键的几个节点讲透。适合三类人读:想在FPGA里塞一个软核做控制的硬件工程师,想搞懂RISC-V处理器内部结构的在校学生,以及准备基于PICORV32做自定义协处理器或者SoC集成的开发者。
2. 先画地图再读源码:picorv32.v的模块与接口划分
2.1 整个CPU其实只有两个子模块
第一次打开源码千万别急着从上往下读,那样很容易被巨大的always块淹没。PICORV32整个顶层叫picorv32,但往下翻你会发现它内部的实现主要分成两个子模块:picorv32_alu和picorv32_cpu。
可以这样理解:picorv32_alu就是那个"算数的零件",它负责加法、减法、移位、与或异或这些运算;而picorv32_cpu是"总指挥",它决定当前周期去取指令还是去执行指令,把操作数送给ALU,再把结果拿回来写进寄存器堆。顶层picorv32则把这两个零件组装起来,并把所有的输入输出引脚(时钟、复位、内存接口、中断、PCPI等)暴露出去。
我的建议是,第一次读不要管顶层实例化的语法细节,先去看picorv32_cpu里的主状态机。那是一个巨大的case (cpu_state),里面包含了取指、译码、执行、访存、写回等所有阶段。把这一段画成流程图,整个CPU的骨架就出来了。
2.2 顶层IO信号按功能分组
源码的端口列表看起来很长,但实际上一共就那么几组。我习惯把它按功能分开记:
时钟复位与状态相关:
clk、resetn:全局时钟和低电平复位。注意PICORV32是同步复位设计,resetn必须保持足够长的时间。trap:当CPU执行到非法指令或错误的对齐访问时拉高一个周期,是调试时非常重要的信号。
内存访问接口:
mem_valid:CPU请求一次内存读或写。mem_addr:访问地址。mem_wdata、mem_wstrb:写数据和字节写使能(4位,对应4字节)。mem_rdata:读数据返回。mem_ready:外部设备说"数据给你了"或者"写完成"。
协处理器接口(PCPI):
pcpi_valid、pcpi_insn、pcpi_rs1、pcpi_rs2、pcpi_wr、pcpi_rd、pcpi_wait。这组信号用来把一条自定义指令"外包"给外部逻辑去执行,后面我会单独讲。
中断接口:
irq_i、eoi、irq_mask这些信号,用于外部中断输入和中断结束响应。irq_i在简单MCU场景里可以接定时器分频后的脉冲。
把这些接口按功能分组之后,再去读源码就会轻松很多。你不需要每个信号一上来就搞懂,只要在遇到对应逻辑时知道它属于哪一条业务线即可。
2.3 我推荐的一条读码路线
如果你之前没有读软核源码的经验,我特别建议按下面这个顺序走:
- 先看参数列表
parameter和localparam,了解这个版本支持哪些可配置项。 - 找到
cpu_state相关的状态定义,把状态名抄下来。 - 从
STATE_IF开始读,一路看取指、译码、执行、访存、写回。这一遍不追求看全,只追求理解主流程。 - 等主流程通了,再回头看ALU怎么算、PCPI怎么握手、中断怎么响应。
- 最后再回到顶层,看我在什么条件下拉
trap、怎么生成mem_valid。
这样读下来,快的话一个晚上能把主流程弄明白。慢一点也没关系,这个过程本身就是最有价值的部分。
3. 状态机与数据通路:一条ADD指令在PICORV32里的完整旅程
3.1 核心状态机拆解
picorv32_cpu内部最核心的东西就是那个状态机。不同版本的源码里状态命名可能稍有差异,但万变不离其宗,核心状态大致是这样的:
| 状态 | 主要职责 | 典型动作 |
|---|---|---|
| 取指(IF) | 发起取指访存 | 把PC输出到mem_addr,拉高mem_valid |
| 译码(ID) | 读指令、读出寄存器操作数 | 从寄存器堆读取rs1、rs2 |
| 执行(EX) | 送入ALU计算 | 把操作数喂给ALU,得到结果 |
| 访存(MEM) | 处理load/store | 数据写入或读出内存 |
| 写回(WB) | 把结果写回寄存器堆 | 更新regs[rd] |
注意一点:PICORV32并不是教科书里那种五级流水线,它更像一个"多周期状态机 + 部分前推逻辑"的组合。很多状态下它会在同一个时钟周期里做两件事——比如在取指阶段同时把PC自增,在执行阶段提前准备好下一个周期的写回信号。这种设计在低面积CPU里非常常见,也是它能保持代码精简的根本原因。
3.2 一条ADD指令的逐拍旅程
以一条最普通的指令add a0, a1, a2为例,我们看它到底是怎么在PICORV32里走完的。指令格式是add rd, rs1, rs2,对应的机器码会放在指令存储器的某个地址上。
第一拍,状态机处于取指状态。CPU把当前的PC值放到mem_addr上,同时拉高mem_valid,告诉外部存储器"我要读这个地址的指令"。如果外部返回mem_ready,这一拍结束之后指令数据就被锁存到内部的指令寄存器里,PC也加4。
第二拍,状态机进入译码状态。CPU从指令字里拆出rs1、rs2、rd字段,然后从寄存器堆里读出操作数。这个寄存器堆在源码里就是一个reg [31:0] regs [0:31]的二维数组,零寄存器写入被屏蔽,读的话组合逻辑直接输出对应位置的值。
第三拍,执行阶段。操作数被送进picorav32_alu,根据当前的控制信号做加法,得到一个32位结果。这个结果会放在内部总线上,接下来可能被写入寄存器堆,也可能被用作访存地址。
第四拍,写回阶段。如果这条指令需要写寄存器堆,那在这一拍的时钟上升沿把结果写入regs[rd],更新完毕。
问题是:这些拍数并不是一成不变的。如果在这条ADD之后紧跟着一条需要读内存的LOAD指令,PICORV32会多等一拍或者两拍,因为在非流水结构里,访存和写回之间需要让数据先稳定下来。如果你在波形里盯着cpu_state看,会看到状态从IF跳到ID、再从ID跳到EX,偶尔还会多出来几个专门用于等待内存响应的中间状态。
3.3 那些"多出来的周期"才是精华
初看PICORV32源码的人往往有个疑惑:为什么一个指令要拆这么多拍?为什么乘法要用那么多周期?答案其实很简单:一切都是为了省面积、省寄存器。
举几个例子。在没有TWO_CYCLE_MULTIPLIER之类优化的时候,乘法器可以做成纯组合逻辑,一个周期出结果,但面积大;也可以做成移位累加的时序乘法器,每个周期算一位,32位乘法要跑三十多个周期。PICORV32的默认配置甚至允许你通过参数去选。如果你在源码里搜索移位有关的逻辑,你会发现它甚至把右移做成一个"每个周期移一位"的状态序列——代价是慢,但好处是硬件逻辑极省。
这种"用周期换面积"的取舍,恰恰是PICORV32和大型乱序CPU最本质的区别。读代码的时候我强烈建议你每遇到一个latency或者extra cycle相关的信号,都停下来想想作者为什么要这么干。想通了,你对整个CPU设计的理解就翻了一倍。
4. 那些可以裁剪的宏配置:每个参数背后对应源码的哪一块
4.1 常用配置项一览
PICORV32的核心裁剪能力来自于顶层参数的传递。在源码文件头部你会看到大量的parameter声明。下面是我常用的几个配置项和它们对应的代码区域:
| 参数名 | 作用 | 影响区域 |
|---|---|---|
ENABLE_MUL | 打开乘法指令mul | ALU和乘法器生成块 |
ENABLE_DIV | 打开除法/求余指令 | 移位除法状态机 |
COMPRESSED_ISA | 支持RVC压缩指令 | 取指阶段的指令预解码 |
ENABLE_IRQ | 支持中断 | 中断状态机与irq_i处理 |
ENABLE_CSR | 支持CSR指令读写 | 专门的CSR数据通路 |
TWO_STAGE_SHIFT | 移位拆成两拍,缩短关键路径 | ALU内部 |
TWO_CYCLE_ALU | ALU结果晚一拍输出 | 执行阶段写回时序 |
LATCHED_MEM_RDATA | 锁存内存读数据 | 访存阶段与mem_rdata采样 |
TWO_CYCLE_COMPARATOR | 比较器拆两拍 | 分支判断逻辑 |
这些参数大多数在源码里对应着generate if这种结构。比如你在顶层看到if (ENABLE_MUL) begin : gen_mul这样的块,里面的逻辑只有在打开宏的时候才会被综合出来。
4.2 从"最小RV32I"开始学
我特别推荐的做法是:先配置出一个最小的RV32I核心,所有扩展全部关掉,比如用类似这样的参数组合:
ENABLE_MUL = 0 ENABLE_DIV = 0 ENABLE_IRQ = 0 COMPRESSED_ISA = 0这样综合出来的核心只支持基础整数指令、load、store、分支跳转和一些系统指令。拿这个最小配置去读状态机最合适——因为你不用担心乘除法那堆中间状态干扰主线。等主线吃透了,再一个一个打开其他配置,观察每个配置给状态机新增了什么状态、给数据通路增加了哪些分支。
这样做还有一个好处:调试的时候问题容易收敛。如果你一上来就打开全部功能,遇到波形异常时你很难分清是乘法器的问题、中断的问题还是主状态机的问题。
4.3 实际裁剪时的面积与频率经验
我自己在一个测试项目里对比过不同配置的资源占用。大致规律是:基础RV32I的资源占用很小,在常见的Lattice或Xilinx小器件上LUT消耗就是个零头;打开乘法和除法之后,面积会涨一块,但还在可接受范围;再打开中断、CSR、压缩指令集,面积会进一步上升,但最直接的压力往往是组合逻辑路径变长,导致最高频率下降。
如果你是第一次在FPGA里集成软核,我的建议是别一开始就追求满配置。先用一个最小核把复位、取指、跑一个点灯程序跑通了,再逐步加功能。这样做,每次综合后如果出了时序问题,你都能清楚地知道是谁造成的。
5. 内存、总线与外设扩展:mem握手协议的精髓
5.1 valid/ready握手的时序真相
PICORV32的访存接口是它最实用的部分之一。接口遵循一个非常简单的握手规则:
- CPU在需要访问时,把
mem_valid拉高,同时把地址放到mem_addr上。 - 外部设备可以决定何时完成操作,只要在完成那个周期把
mem_ready拉高即可。 - 读操作:
mem_ready拉高的那个周期,CPU采样mem_rdata。 - 写操作:
mem_wdata与mem_wstrb一直保持,直到mem_ready拉高表示写入完成。
这套协议最大的好处是:外部设备不着急的话可以不立刻置高mem_ready,CPU就会乖乖等在那里不往前走。如果你的外设慢,比如接了SPI Flash或者I2C,你可以用这个信号做流控。反过来如果你需要和另一个高速模块做数据交换,你也完全可以做到上一拍发请求、下一拍收数据。
读它的时候建议多想想AXI-Lite里的AWVALID/AWREADY、WVALID/WREADY,Wishbone里的STB/ACK,它们本质都是同一类握手思想。PICORV32把这种思想简化到了一个极其容易看懂的程度,作为总线协议入门材料比直接看AXI官方手册友好一百倍。
5.2 免不了要处理的"MCU地址映射"问题
PICORV32本身不定义地址映射,它不关心你是把RAM放在0x00000000还是0x10000000。在设计SoC时,你需要在这个接口后面自己做一个地址译码器:低位给RAM,高位给外设寄存器,这就是一个最简单的memory map。
实际使用时我习惯把地址空间大致切成这样:
0x00000000到0x0000FFFF:指令+数据RAM0x40000000到0x400000FF:UART等外设寄存器0x80000000以上:保留给特殊功能,比如DMA或自定义加速器
外设端的Verilog可以用一个极小的状态机来接PICORV32的写请求:检测到mem_valid且mem_addr落在自己的地址范围且mem_wstrb非零时,把对应寄存器的某个字节更新,然后拉一个周期的mem_ready。读同理,把寄存器值放到mem_rdata上并拉一个周期的mem_ready。
注意:mem_wstrb是4位的,如果外设寄存器只支持32位整字访问,那么你只需要保证程序里不出现非对齐或字节级访问的问题即可。但如果你要支持字节操作,外部逻辑要自己按mem_addr[1:0]和mem_wstrb拆字段,这一点很容易写错,建议参照PICORV32的例子代码来写。
5.3 一个实际外设的挂载例子
我早期做过一个最简单的UART输出功能,就挂在这个接口上。写发送字符时,用状态机检测写请求,把mem_wdata[7:0]锁存到发送缓冲,然后以波特率时钟分帧送出去。发送忙时,我把mem_ready保持为0,这样CPU的写指令就会被卡住直到字符发送完。这个做法虽然牺牲了CPU利用率,但代码极简单,非常适合验证整套通路是否畅通。
等我需要更高效的处理时,再改成"发送FIFO+中断"的结构:外设里放一个8深度的FIFO,CPU写数据进FIFO时立即拉高mem_ready返回;FIFO空了之后拉高irq_i让CPU再补数据。这里我遇到的最大坑是:FIFO空标志和irq_i的组合时序没处理好,导致中断反复触发。后来我在中断服务程序里先读一个状态寄存器确认原因再决定是否发送,问题就解决了。
PCPI接口也是类似思路,它让你在同一个指令流里插入完全自定义的硬件指令。你可以把一段算法做成一个组合逻辑块,然后通过一条CPU指令触发,结果再返回到寄存器堆。这个机制在加速某些固定算法时特别有用,而且它的握手协议只有pcpi_valid、pcpi_rd、pcpi_wr等几个信号,自己写一个扩展模块也不需要太多代码。
6. 跑起来才有感觉:仿真验证、波形观察与三个常见卡点
6.1 用最小工程把它跑起来
源码到手之后,我先建议你把它跑进仿真里。PICORV32官方仓库带了不少仿真和示例,但你也可以自己搭一个极简testbench。下面是我常用的方式:
iverilog -g2012 -o picorv32_tb.vvp \ picorv32.v \ testbench.v vvp picorv32_tb.vvptestbench里要做的事情很简单:产生时钟,先让resetn保持低电平若干周期再释放,然后给CPU一块RAM,让它从0x00000000开始取指执行。哪怕只放一条跳转到自身的死循环指令,只要能观察到取指与访存握手,就已经证明CPU的核心逻辑是通的。
如果你没有现成的testbench,可以用官方仓库里sim目录下的测试文件,或者自己写一个简单的指令ROM。稍微花十分钟把仿真环境跑通,比盯着代码干想效率高得多。
6.2 波形里重点看什么
跑起来之后,用GTKWave打开VCD文件时,我一般会抓下面几根信号:
cpu_state:时刻知道CPU处于哪个阶段。mem_valid、mem_ready、mem_addr、mem_wdata、mem_wstrb:验证总线握手。regs[0]到任意几个寄存器:看指令有没有真正写入。trap:如果这个信号拉高,通常说明出问题了。
看一条ADD指令的波形,你会很直观地看到cpu_state的状态跳转,以及mem_valid的几个脉冲。然后你再开一条LOAD指令来对比,很快就会发现多出来的等待周期。用这个方法,即便不去逐行读源码,也能通过波形把CPU行为摸到七成。
6.3 三个常见卡点及排查思路
第一个卡点:复位释放后CPU完全没有动作。这种情况十有八九是resetn没有保持足够长的有效时间,或者外部RAM接口没有返回mem_ready,导致取指状态一直卡在等待上。
第二个卡点:程序跑飞或者trap立即拉高。往往是内存地址映射没对上,CPU第一条取指地址不是你的代码存放地址,所以读回来一堆全零或者未知数据,被当成非法指令触发了trap。
第三个卡点:无法正常写外设。排查时先看有没有把mem_valid和地址译码逻辑接对外设,再看外设有没有在写请求到来时及时拉mem_ready。我遇到过好几次同事把mem_ready写成常高,导致握手完全失效,但程序从RAM取指又"看起来正常"的情况——因为RAM响应快,握手少几个周期不影响功能,但外设时序崩了以后就非常难排查。好的习惯是:所有外设都遵循mem_ready在写数据被确认锁存后才拉高的原则,不要偷懒。
除了这三个卡点,我还要特别提醒一句:如果你使用LATCHED_MEM_RDATA配置,读数据会在内部被锁存一拍,外设无需一直保持mem_rdata,但前提是你必须搞清楚自己的外设是同步输出还是异步输出。不同配置下挂同一块SRAM的表现会不太一样,踩过一次坑之后你就会明白什么叫"配置也要对应起来看"。
我自己后来每次做软核相关的项目,都会先搭一个最小仿真环境,把主状态机和内存握手波形的"标准长相"截图存下来。以后凡是说"程序跑不动",我先比对这个基准波形,五分钟内就能判断问题出在复位、内存还是外设。这个习惯帮我省了无数个小时的调试时间。