☰
拆解PICORV32:用Verilog读懂RISC-V处理器核心
2026/9/28 5:29:01 网站建设 项目流程

1. 为什么值得啃PICORV32这份源代码

PICORV32的源代码是少数几份我愿意通读的CPU实现之一。它不是性能最强的RISC-V核,却是结构最容易进入大脑的。完整源码的核心部分几乎都集中在picorv32.v这一个Verilog文件里,大约一两千行,支持RV32I基础指令、M扩展乘除法、C压缩指令和部分Zicsr CSR,还带中断与调试接口。你不需要在几十个模块之间来回跳转,就能把取指、译码、执行、访存、写回的全过程在脑子里串起来。对初次接触CPU设计的人来说,这份代码是难得的完整教材;对已经做过SoC集成的工程师来说,它又是随时可以改造成自定义处理器骨架的素材。

我最早读这份代码,是想在FPGA上做一个能跑RTOS的最小RISC-V系统。市面上常见的选择有几类:像Rocket那样乱序多发射的核太重,像SERV那样追求极端精简的串行核又太慢,而Ibex虽然设计漂亮,但流水线和复杂译码对新手并不友好。PICORV32恰好落在中间:它用多周期状态机实现,不带分支预测,没有流水线冒险,却能跑到几十兆赫兹,面积也小。更重要的是,它的作者是Yosys开源综合工具的维护者Clifford Wolf,工程习惯极好,代码风格克制、信号命名清晰,测试和工具链配套完整。这份代码不是给人“看”的,是拿来“拆”的。

1.1 它到底做了什么、没做什么

PICORV32是一颗32位单发射多周期RISC-V核,只实现机器模式(Machine Mode),没有MMU,没有Cache,没有指令预取缓冲之外的复杂部件。指令集方面完整覆盖了RV32I的整数指令、RV32M的乘除扩展、RV32C的压缩指令,以及CSR指令和基础机器模式CSR寄存器。这意味着它可以跑newlib、FreeRTOS、Zephyr这类生态,但不适合直接跑Linux这类依赖MMU的重量级系统。

它没做的事情同样关键。因为没有流水线,所以不存在数据前递、乱序完成、分支预测这类让初学者头痛的问题。多周期结构意味着每条指令都要花几个时钟周期完成,但换来的是数据通路极度清晰:取指、译码、执行、访存按照一个主状态机的节拍依次推进。你在读代码时不需要同时跟踪五条指令的背影,只需要关心“当前这条指令正处于哪个阶段”。

我见过不少人把“代码量小”理解成“功能简陋”,这是对PICORV32最大的误解。它的精妙之处在于用最少的硬件资源覆盖了RV32IMC的全部用户态指令,同时保留了可裁剪的参数化选项:寄存器堆数量可以从32个裁到16个,移位器可以选单周期或两周期,乘除法可以内建也可以在外部协处理器实现。你改一个parameter,代码结构不变,但综合出来的面积和时序会明显变化。这种把“功能开关”做进RTL的设计思路,本身就是一份值得反复品读的教材。

1.2 读这份代码之前,建议你先准备三件事

第一,RISC-V指令格式要熟。PICORV32源码里看不到一个独立的“译码器模块”,译码逻辑是散落在主状态机周边的一堆组合逻辑。这些组合逻辑大量使用current_insn[6:0]、current_insn[14:12]这类字段来区分指令类型。如果你不熟悉opcode、funct3、funct7、RD、RS1、RS2这些字段的位置,读起来会非常吃力。建议先把《RISC-V Unprivileged Spec》里的指令编码表过一遍,不用背,但看到7'b0010011要能立刻反应出是OP-IMM类。

第二,Verilog的可综合写法要过关。这份代码大量使用generate块、genvar循环、带(* keep *)这类综合属性的声明,对寄存器的赋值也习惯写成“默认值加例外覆盖”的结构。如果你平时只写仿真testbench,对always @(posedge clk)里整块if/else的赋值方式不熟悉,建议补一下可综合RTL的基本范式,否则容易在高密度always块里迷路。

第三,也是最重要的,要有“数据通路优先”的阅读策略。我见过很多朋友打开这份代码从头行到尾行地刷,结果在中间状态机部分就放弃了。正确方法是先不看代码,只看端口和参数,画出CPU与外部总线的连接关系;然后看主状态机,搞清楚一条指令从取指到写回的节拍;最后再回到各个执行细节。我下面几节的内容,就是按这条路径展开的。

2. 源码地图:一个v文件的自信与代价

PICORV32的仓库里其实不止一个文件,除了核心的picorv32.v,还有picorv32_axi.v、picorv32_axi_adapter.v、picorv32_pcpi_mul.v、picorv32_pcpi_div.v等配套模块。但核心执行逻辑确实全部收在picorv32.v里。这种“核心单文件”的安排在学习阶段是大优势:你不需要在IDE里来回跳转,一个文件从头往尾读,就是在沿数据通路走一遍。代价是单个文件里信号多、状态密集,需要你建立起“先看接口,再看状态机,最后看执行单元”的阅读顺序。

2.1 从端口和参数看懂设计边界

读picorv32.v的第一步是看module声明顶部的parameter列表。这一串参数就是整颗CPU的行为边界。我把它们分成几组,方便对照:

参数组代表参数作用
指令集裁剪ENABLE_MUL、ENABLE_DIV、ENABLE_PCPI是否支持乘除指令、是否开放协处理器接口
指令行为CATCH_MISALIGN、CATCH_ILLINSN非对齐访问、非法指令是否触发trap
时序选项TWO_STAGE_SHIFT、LATCHED_MEM_RDATA移位是否分两拍、访存读数据是否锁存一拍
寄存器堆ENABLE_REGS_16_32、ENABLE_REGS_DUALPORT寄存器数量与读端口数量
中断选项ENABLE_IRQ、ENABLE_IRQ_TIMER、ENABLE_IRQ_EXT是否支持中断、定时器/外部中断
调试接口ENABLE_DEBUG是否挂出调试读写端口
计数器ENABLE_COUNTERS、ENABLE_COUNTERS32、ENABLE_COUNTERS64cycle/instret计数器的存在与位宽

这些参数不是摆设。ENABLE_MUL和ENABLE_DIV开与关,直接影响综合面积和最高频率;ENABLE_REGS_16_32设为0后寄存器堆只剩x0到x15,面积能省一块,代价是ABI要求的t0-t6等临时寄存器没了,只能跑为特殊裁剪编译的软件。我实际做SoC时,如果目标只是跑裸机点灯,会把中断、计数器和调试接口全关掉,让核心瘦到只剩CPU与外设总线握手。

端口方面,最值得关心的是mem_*这组Native Memory Interface:mem_valid、mem_addr、mem_wdata、mem_wstrb、mem_rdata、mem_ready。这是CPU与外界通信的唯一数据通道,取指、load、store全走它。其次是irq[31:0]和eoi这组中断信号,以及trace_valid和trace_data这组指令流输出。端口数量不多,但每个端口背后都牵着一大块逻辑。

2.2 代码不是平铺的:先认识这些“关节”

虽然核心只有一个文件,代码却不是按章节平铺的。你会发现一大片一大片的generate块、若干个超大的always块,以及大量以latched_、current_、next_为前缀的寄存器信号。这是PICORV32的一个显著风格:它把“指令从取指到执行”的生命周期,用一组latch信号在不同阶段之间传递状态。

举个例子,代码里会有latched_insn保存刚取到的32位指令,latched_rd保存当前指令的目标寄存器号,latched_alu、latched_branch、latched_store分别标记当前指令是否要用ALU、要跳转、要访存。这些信号在译码阶段被置位,在执行阶段被消费。你看到这些latch前缀的信号时,可以把它理解为“指令执行到哪一步的胶囊”。

真正的主状态机通常集中在一两个大的always块里,另外还有很多组合逻辑块负责生成下一拍的控制信号。阅读技巧是把“状态寄存器的变化”和“组合逻辑对不同指令的响应”分开看:先找状态变量,再看每个状态里哪些latch信号被赋值,最后看这些latch信号如何驱动ALU、寄存器堆和存储总线。

2.3 顺手把配套模块也读了

picorv32.v只定义了CPU核心,实战集成时还要用到仓库里的配套模块。picorv32_axi_adapter.v把Native Memory Interface翻译成AXI总线协议,方便你直接挂到AXI互联网络上;picorv32_pcpi_mul.v和picorv32_pcpi_div.v是乘除法协处理器的参考实现。我强烈建议把这三份代码连着读。

PCPI(PicoCoProcesor Interface)是这份源码里很有特色的设计:CPU识别到未实现的指令码时,不是直接报非法指令,而是通过pcpi_valid、pcpi_insn等信号把指令“外包”给外部协处理器,外部模块计算完成后拉pcpi_ready并把结果写回。这个机制让PICORV32的指令集有了极高的可扩展性,你甚至可以在外部挂一个独立的AI加速器、CRC计算器或者硬件排序单元,CPU只需要认识它的操作码。默认仓库里的乘除模块就是通过这套接口实现的,所以你会看到picorv32.v里乘除相关的逻辑并不多,真正的算术电路在协处理器文件里。这种“主核保持简单,复杂功能走扩展口”的架构思想,比背下一段代码值钱得多。

3. 主状态机:取指、译码、执行如何串成一条线

PICORV32的性能不像流水线CPU那么惊艳,但它的主状态机设计得非常巧妙。整颗芯片可以理解成一个“尽量让取指和执行重叠”的多周期状态机。你不需要把所有RTL细节都记下来,只需要抓住四个关键点:存储接口的握手、指令的锁存、latch信号的传递、以及PC的更新逻辑。

3.1 mem接口:CPU和世界的唯一通道

pc就是当前取指地址。CPU要取指时,将mem_valid拉高,同时在mem_addr上给出pc,然后等待mem_ready。在mem_ready有效的那一拍,mem_rdata上的数据就是指令。这里没有Cache和预取队列,所以每条指令都走这组握手,load和store也共用这组握手。

LATCHED_MEM_RDATA参数在这里发挥作用。如果设为0,要求外部存储器在mem_ready有效时必须同时把数据放到mem_rdata总线上,读取数据不需要额外的锁存拍;如果设为1,CPU允许mem_rdata延后一拍稳定,内部会多一级寄存器把数据打一拍。这个选项在挂接不同时序的存储介质时非常关键。我最初在AXI互联上接入SRAM控制器时,因为时序余量不足,一度取指错乱,最后把LATCHED_MEM_RDATA打开、调整了adapter的ready时序才解决。

还有一个隐蔽的技巧:PICORV32的取指并不完全串行。CPU在译码当前指令的同一拍,常常已经准备好了下一个取指地址;等到执行阶段结束后,如果指令是分支且跳转条件成立,再把新的pc替换过去。换句话说,它用状态机的重叠操作掩盖了一部分取指延迟,这让它比教科书里那种“取指一整个周期、执行一整个周期”的简单多周期设计要高效不少。

3.2 latch系列信号:指令在管道里如何传递

当你看到代码里密密麻麻的latched_*信号时,记住一条经验:它们是CPU内部数据通路的“胶囊”。每个节拍,主状态机根据当前指令类型,决定接下来要把哪些控制信息装进胶囊。

一条典型RI指令的生命周期大概是这样的:

  1. 取指完成:latched_insn锁存指令,同时next_pc更新到pc+4或压缩指令的pc+2。
  2. 译码组合逻辑根据latched_insn的opcode、funct3、funct7,生成latched_alu、latched_store、latched_branch、latched_load等标志。
  3. 执行阶段读取latched_insn中的RS1、RS2字段,从寄存器堆读出操作数,送入ALU;如果需要访存,则发起mem_valid请求。
  4. 写回阶段,ALU结果或存储读回数据被写入latched_rd指定的寄存器。

你会在代码里看到非常多的“默认不使能、只在特定指令下使能”的写法。比如默认情况下latched_store = 0,只有遇到store类指令时才会被置1。这种“清零+条件置位”的RTL风格贯穿全篇,虽然看起来啰嗦,但综合效果稳定,而且调试时逐个信号看非常明晰。

3.3 分支、跳转和trap是如何改变PC的

PC更新是状态机里最关键的一环。大多数指令的next_pc就是当前pc加指令长度,但分支、跳转、trap和中断要独立处理。

分支指令的执行分为两步:先由ALU计算比较结果,然后根据比较结果决定是保持pc+4还是跳转到目标地址。由于没有流水线,分支不需要延迟槽,也不担心预测错误,比较结果一出来就能把jump_addr写到pc寄存器。这种“慢”但“确定性极高”的特点,对实时性要求严格的裸机系统反而是优点。

trap和中断路径更特殊。CATCH_ILLINSN、CATCH_MISALIGN这类参数开启时,CPU如果遇到无法译码的指令或非对齐访存,会把自己切入异常状态:保存当前pc到mepc,然后把pc改为mtvec指向的异常入口。中断请求irq也会在指令边界被采样,优先于普通指令进入mtvec。这里面的mepc、mtvec都是CSR寄存器,在代码里体现为独立的一组寄存器逻辑。你不需要马上弄懂CSR的每个字段,但要清楚它们的作用:一个是“出事的地址”,一个是“事故处理程序的入口”。

4. 数据通路的最小实现:寄存器堆与ALU

很多CPU教材会把数据通路画成一大张电路图,而在PICORV32源码里,数据通路就是几个连续的逻辑块:寄存器堆、ALU、访存接口和写回选择器。拆开来看,每块都不难。

4.1 寄存器堆:能裁到16个,也能做到双口读

寄存器堆在源码里是一段典型的generate生成逻辑。默认情况下它提供32个32位寄存器,x0恒为0。ENABLE_REGS_16_32参数可以把寄存器数裁成16个;ENABLE_REGS_DUALPORT则决定寄存器堆是否支持两个读端口。

许多初学者会问:为什么寄存器堆还要分单口双口?因为一条普通ALU指令通常需要同时读RS1和RS2两个操作数。如果寄存器堆只有一个读口,CPU就得分两拍读操作数,指令周期明显变长。ENABLE_REGS_DUALPORT=1意味着寄存器堆内有两套读地址端口,可以在一拍内同时输出rs1和rs2的值,这是性能选项。代价是面积增加。

另一个值得学习的细节是x0恒为0的实现。RISC-V规定x0就是一个常数0,写入不生效。PICORV32的做法很简单:写使能逻辑里判断目标寄存器是不是x0,如果是就不让写。读操作则通过组合逻辑把x0的读结果固定为0。这看起来是小事,但体现了一个工程原则:与其在软件层面保证“不写x0”,不如在硬件上直接把这个行为变成规格的一部分。

4.2 ALU:用一分组合逻辑覆盖全部整数指令

PICORV32的ALU不是独立模块,它就是在主代码里的一个大组合块。输入是rs1、rs2或立即数,以及当前指令的控制信号,输出是alu_out。它需要支持的运算包括加减、与或异或、逻辑左移右移、算术右移、setlt/setltu比较等。

立即数的处理是一个重点。RISC-V有I型、S型、B型、U型、J型等多种立即数格式,PICORV32里的decode逻辑会把这些分散的立即数字段统一拼接成32位有符号数。我读这部分代码时最大的收获是:CPU译码器的本质就是“把指令里位置各异的字段,映射成规整的控制信号和数据”,这一点不管在什么处理器里都成立。

移位运算的实现很有意思。默认支持TWO_STAGE_SHIFT参数,把32位的桶形移位拆成两拍完成。第一拍粗移,第二拍精移,用两级较浅的移位器完成原本需要32路多路选择器的电路。这个折中牺牲了一两个周期的性能,换取了显著的LUT节省。如果你的FPGA时序紧张,这个参数是优先调整项。

setlt这类比较指令的实现方式也值得留意。它不需要单独的比较器,而是复用ALU里的减法器:用rs1减去rs2,再根据结果的符号位和溢出标志得出小于关系。这种“一条硬件逻辑支撑多条指令”的设计,是RTL工程师应该刻在脑子里的本能。

4.3 乘除法器:为什么要独立成协处理器

乘除指令在PICORV32里不是ALU的一部分,而是通过PCPI接口外部实现的。内部ENABLE_PCPI开启后,CPU遇到乘法或除法指令时,会把整个指令通过PCPI总线发给协处理器模块,协处理器忙完后把结果写回。

仓库里默认提供picorv32_pcpi_mul.v和picorv32_pcpi_div.v两个参考实现。乘法采用移位相加,除法采用长除法,都是周期较多但面积很小的设计。如果打开ENABLE_FAST_MUL,则会在内部生成快速乘法器,适合有DSP单元的目标平台。

这种解耦架构有不少好处。第一,乘除法不是每条程序都会用的高频指令,把它们放在协处理器里,意味着你可以在不使用乘除的项目里只保留PCPI接口或干脆关掉,主ALU的电路和时序完全不受影响。第二,你可以自定义自己的协处理器,例如把某个算法提速,而不改动主核执行路径。第三,代码层面的模块边界清楚,验证测试也容易划分。我在给PICORV32加自定义PCPI单元时,几乎没改主核代码,只新增了一个外部模块加一条操作码映射,半天就通了。

5. 中断、CSR与调试接口:隐藏的另一半

如果你只关注指令执行路径,PICORV32的代码可能只读了一半。中断、CSR和调试接口这半部分,在实际SoC集成时同样绕不开。它们不像ALU那样天天用,但一旦出问题,排查起来非常痛苦。

5.1 机器模式CSR的最小集合

PICORV32支持CSR指令,并实现了机器模式下的一组基础CSR寄存器:mstatus、misa、mepc、mtvec、mscratch、mcycle、minstret等。读cycle和instret这两个计数器,是评估程序性能最直接的手段。代码里通过ENABLE_COUNTERS系列参数控制这两个计数器的存在与位宽,有32位和64位两种选项。

这些寄存器的实现相对规整:每个CSR在代码中对应一组寄存器逻辑,CSR指令通过读写地址索引到对应的寄存器。如果你想给PICORV32扩展自己的CSR,比如加一个保存硬件配置ID的寄存器,按同样的模式加一组读写逻辑就行。我当时为了给SoC加一个“芯片版本号”寄存器,就是这么做的,整个改动不超过二十行。

5.2 IRQ输入端与eoi:中断是如何响应的

PICORV32的中断路径是很多项目的痛点。ENABLE_IRQ=1后,irq[31:0]作为外部中断输入,eoi作为中断结束通知输出。CPU在指令边界检查中断请求,如果pending则保存当前pc到mepc,然后跳到mtvec指定的中断服务程序。处理完成后,软件执行mret指令回到原上下文。

我踩过的坑是中断源的清除时序。PICORV32本身不负责确认外部中断源,软件需要自己判断是哪个irq触发,并在处理完后用eoi通知外部控制器。如果你的外设中断是电平触发,必须保证中断服务程序里先清外设中断标志再发eoi,否则会因为中断信号仍然有效导致反复进中断。这个问题的根因不是CPU有bug,而是电平中断与简单CPU中断控制器之间的经典配合问题。

5.3 调试接口与trace输出:没有JTAG也能看内心

ENABLE_DEBUG=1时,PICORV32会挂出一组调试端口:debug_valid、debug_addr、debug_wdata、debug_rdata。通过这组端口,外部主机可以在CPU运行时读写内部寄存器,甚至访问内存空间。这在仿真调试中极其好用:testbench里可以直接注入debug请求,把某个寄存器的值读出来比对,而不需要打断CPU。

trace_valid和trace_data是另一个宝藏信号。CPU执行每条指令时,会在trace_data上输出一条包含指令执行PC和指令编码的跟踪信息。我会把它接入逻辑分析仪或者ILA,实时观看程序跑到了哪条指令、是否跳转异常。裸机调试时,这比一遍遍加打印语句高效得多。很多RISC-V调试工具也可以基于trace重建执行流,对定位死循环、跳飞问题帮助极大。

6. 基于源码的分析:实际扩展和踩坑记录

最后这部分,我想分享一些把这颗核集成到真实项目中遇到的问题。源码分析的价值不只在于读懂,更在于改得动、跑得稳。

6.1 接AXI总线的第一个坑:ready时序

PICORV32的Native Memory Interface是很简单的请求/应答式总线:CPU发出mem_valid后一直等mem_ready。仓库里的picorv32_axi_adapter.v负责把它转成AXI协议。初次使用时,我犯了一个典型错误:没有关注LATCHED_MEM_RDATA参数与AXI读数据通道的时序匹配。

AXI总线上读数据的valid可以比接受请求晚好几拍,而PICORV32的Native接口如果不锁存读数据,要求mem_rdata要在mem_ready同一拍有效。两者之间必须靠adapter内的寄存器做缓冲和重定时。如果你在集成时发现CPU取指偶尔错乱、程序随机跳飞,先检查读数据路径上是否有足够的流水寄存,并把LATCHED_MEM_RDATA打开。这个教训排查了我整整一个晚上,最后根因就是一个小到不起眼的握手时序。

6.2 跑RTOS时的参数裁剪建议

我在这颗核上跑过裸机轮询、也跑过轻量RTOS。跑RTOS时,中断、CSR计数器和系统定时器是必须的,所以ENABLE_IRQ、ENABLE_COUNTERS不能关。ENABLE_MUL和ENABLE_DIV建议打开,因为RTOS里的线程切换、软件延时经常会用到除法,软件模拟除法的代价远比硬件大。CATCH_ILLINSN建议保留,跑RTOS时一条非法指令如果静默处理,死因极难查。

内存映射方面,最简单的SoC设计是取指和数据共用同一条总线,核心代码和堆栈都放在同一块SRAM里。启动代码需要自己写,把_start放到复位向量地址0x0,设置栈指针,然后跳进C程序。不要指望PICORV32自带Boot ROM,它只是一个裸核,启动流程完全由你决定。

6.3 综合与面积:这些参数直接影响时序

在FPGA上综合PICORV32时,我习惯用Yosys快速验证,再用Vivado或Quartus做实际布局布线。如果Fmax上不去,优先检查两条关键路径:移位器路径和乘法器路径。TWO_STAGE_SHIFT已经是默认打开,能降就降;ENABLE_FAST_MUL会引入DSP单元,如果目标FPGA缺乏DSP或者频率要求高,反而可能成为瓶颈。

面积方面,一套适合教学的最小配置大约是:关掉调试、关掉除法、关掉64位计数器、只保留RV32I+压缩指令。这个配置在入门级FPGA上综合出来只有几千个LUT,跑在50MHz附近的压力很小。而生产级配置则要按外设需求逐一打开参数,宁可多花资源也要让调试手段齐备。

我在把这些参数反复调度之后,最大的体会是:PICORV32源码分析最好的结果,不是把代码背下来,而是搞清楚每一个parameter背后对应的硬件取舍。下次换到其他软核或者自研CPU时,这些取舍判断完全可以直接复用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询