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》里的指令编码表过一遍不用背但看到7b0010011要能立刻反应出是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 Interfacemem_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是乘除法协处理器的参考实现。我强烈建议把这三份代码连着读。PCPIPicoCoProcesor 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总线上读取数据不需要额外的锁存拍如果设为1CPU允许mem_rdata延后一拍稳定内部会多一级寄存器把数据打一拍。这个选项在挂接不同时序的存储介质时非常关键。我最初在AXI互联上接入SRAM控制器时因为时序余量不足一度取指错乱最后把LATCHED_MEM_RDATA打开、调整了adapter的ready时序才解决。还有一个隐蔽的技巧PICORV32的取指并不完全串行。CPU在译码当前指令的同一拍常常已经准备好了下一个取指地址等到执行阶段结束后如果指令是分支且跳转条件成立再把新的pc替换过去。换句话说它用状态机的重叠操作掩盖了一部分取指延迟这让它比教科书里那种“取指一整个周期、执行一整个周期”的简单多周期设计要高效不少。3.2 latch系列信号指令在管道里如何传递当你看到代码里密密麻麻的latched_*信号时记住一条经验它们是CPU内部数据通路的“胶囊”。每个节拍主状态机根据当前指令类型决定接下来要把哪些控制信息装进胶囊。一条典型RI指令的生命周期大概是这样的取指完成latched_insn锁存指令同时next_pc更新到pc4或压缩指令的pc2。译码组合逻辑根据latched_insn的opcode、funct3、funct7生成latched_alu、latched_store、latched_branch、latched_load等标志。执行阶段读取latched_insn中的RS1、RS2字段从寄存器堆读出操作数送入ALU如果需要访存则发起mem_valid请求。写回阶段ALU结果或存储读回数据被写入latched_rd指定的寄存器。你会在代码里看到非常多的“默认不使能、只在特定指令下使能”的写法。比如默认情况下latched_store 0只有遇到store类指令时才会被置1。这种“清零条件置位”的RTL风格贯穿全篇虽然看起来啰嗦但综合效果稳定而且调试时逐个信号看非常明晰。3.3 分支、跳转和trap是如何改变PC的PC更新是状态机里最关键的一环。大多数指令的next_pc就是当前pc加指令长度但分支、跳转、trap和中断要独立处理。分支指令的执行分为两步先由ALU计算比较结果然后根据比较结果决定是保持pc4还是跳转到目标地址。由于没有流水线分支不需要延迟槽也不担心预测错误比较结果一出来就能把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_DUALPORT1意味着寄存器堆内有两套读地址端口可以在一拍内同时输出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_IRQ1后irq[31:0]作为外部中断输入eoi作为中断结束通知输出。CPU在指令边界检查中断请求如果pending则保存当前pc到mepc然后跳到mtvec指定的中断服务程序。处理完成后软件执行mret指令回到原上下文。我踩过的坑是中断源的清除时序。PICORV32本身不负责确认外部中断源软件需要自己判断是哪个irq触发并在处理完后用eoi通知外部控制器。如果你的外设中断是电平触发必须保证中断服务程序里先清外设中断标志再发eoi否则会因为中断信号仍然有效导致反复进中断。这个问题的根因不是CPU有bug而是电平中断与简单CPU中断控制器之间的经典配合问题。5.3 调试接口与trace输出没有JTAG也能看内心ENABLE_DEBUG1时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时这些取舍判断完全可以直接复用。