PicoRV32源码深度解析:面积优化型RISC-V软核的设计与实践
拿到PICORV32的源码时如果是一路看着经典教材里那种模块分明、状态机规整的RISC-V教学核过来的第一反应多半是懵的上万行Verilog几乎全塞在单个picoRV32.v文件里到处是嵌套三元运算符连个像样的状态机都看不见。可它偏偏是过去十年里被集成次数最多的开源RISC-V软核之一作者是Clifford Wolf——Yosys和nextpnr的作者在FPGA工具链圈子里几乎是无人不晓的名字。这颗核的设计目标非常直接用最小的FPGA面积跑尽量完整的RISC-V软件。于是大量实际项目里研究人员拿它做SoC最小启动核、嵌入式裸机控制、低功耗测试平台甚至软核教学实验因为它的体积小到在入门级FPGA上也挤得下行为又能兼容常见工具链编译出的程序。这篇分析不会把每一行代码搬出来念而是从我实际读源码、跑仿真、二次集成、调性能的经历出发把PICORV32的源代码组织、执行通路、内存与中断机制、PCPI扩展接口和踩坑点按一条可复现的路径拆开。适合想拿它做软核集成、想理解面积优化型CPU真正取舍以及在FPGA上折腾RISC-V的开发者。1. 源码怎么摆分布形态、参数开关与第一次通读方法1.1 单文件内核背后是C风格的硬件思维PicoRV32的主模块只有picoRV32.v官方仓库里还有picoRV32.h提供仿真辅助scripts目录下是一些内存初始化脚本firmware、examples里分别放了测试程序和最小SoC示例。很多人第一次打开picoRV32.v会吓一跳它不像通常的CPU RTL那样分alu.v、regfile.v、ctrl.v而是把所有执行逻辑写在一个巨大always块里用大量?:表达式按指令优先级层层展开。这个写法跟C语言更像跟Verilog的常规可综合风格反而有距离但这是作者有意为之的让综合工具拿到的是一个很扁平的逻辑网表而不是一堆模块级黑盒这样在面积优化模式下工具可以把译码和执行逻辑尽量吃进同一个LUT6/LUT4结构里。实测下来同样功能如果用规整状态机拆成十个小模块在iCE40和Artix-7上面积往往会多出15%到30%时序收敛也更麻烦。1.2 参数就是这份源码的配置表主模块module picoRV32 #(...)前面挂了一大串parameter理解这份源码的第一步不是逐行读ALU而是把这些开关查清楚。我用得最频繁的几个列在下面参数默认值作用STACKADDR32h00000000启动时写入sp的初始值软件栈底PROGADDR_RESET32h00000000复位后第一条指令地址PROGADDR_IRQ32h00000010普通陷阱/中断入口地址PROGADDR_IRQ_FAST32h00000010快速中断入口地址ENABLE_MUL / ENABLE_DIV1 / 1是否启用M扩展乘除法ENABLE_FAST_MUL0用更快但更贵的乘法器ENABLE_IRQ / ENABLE_IRQ_FAST / ENABLE_IRQ_TIMER1中断相关子功能ENABLE_PCPI1是否暴露协处理器接口CACHE_MEM / CACHE_ADDR_WIDTH / CACHE_SIZE0 / 9 / 4内置小指令缓存的开关与容量ENABLE_COUNTERS1是否实现cycle/instret计数器ENABLE_REGS_16_321是否扩展32个影子寄存器ENABLE_REGS_DUALPORT1寄存器文件是否为双口影响面积与性能LATTICE_ICE400针对iCE40 LUT结构的综合提示这些参数影响的是generate和条件赋值里的分支而不是每个参数都有独立的模块边界。读源码时先把参数和宏绑定关系捋一遍后面看执行逻辑会轻松很多。1.3 端口分组先分清三类总线顶层端口除了时钟和复位可以分成三组理解Simple Memory Interfacemem_valid / mem_ready / mem_addr / mem_wdata / mem_wstrb / mem_rdata / mem_instr这是核唯一的访存通道取指和数据访问共用。PCPI协处理器接口pcpi_valid / pcpi_insn / pcpi_rs1 / pcpi_rs2 / pcpi_wr / pcpi_wrdata / pcpi_wait / pcpi_ready。调试与中断接口trap / irq / eoi / trace_valid / trace_data。我第一次看mem_instr这个信号时容易忽略它告诉外部总线当前访问是指令还是数据。很多SoC集成者拿它做指令ROM和RAM的分区选择或者统计指令命中率。理解这些端口后再进入always块就能把一个大的逻辑块映射到取指握手阶段、译码执行阶段和写回句柄三类状态而不是被一大团代码吓住。2. 面积优化型CPU的刻意简单2.1 为什么不用经典五级流水线经典RISC-V有IF/ID/EX/MEM/WB五级流水线每一级都在打拍寄存器堆每周期要写一读二。PicoRV32故意不走这条路线。它更像一个取指单周期执行的两段式结构取指通过内存接口握手拿到指令后在一个周期内完成译码、读寄存器、ALU运算和PC更新涉及访存的指令再额外插入等待周期。代价是IPC不高很多人实测大约0.5到0.8 DMIPS/MHz跟ARM Cortex-M这类商业核差距明显好处是指令通路里的流水寄存器少组合逻辑可以相对自由地跨级被综合工具优化控制逻辑也几乎没有冒险检测、转发、气泡这些复杂内容。对FPGA软核来说面积就是硬约束硅片上的简单换来的是更低的LUT占用和明显好收敛的时序这笔账在很多项目里非常划算。2.2 内部核心寄存器与伪状态机源码里真正持有状态的核心寄存器不多我刚开始追踪时就盯这几个reg_pc当前程序计数器执行分支时更新。reg_insn当前译码执行的指令。reg_op1 / reg_op2ALU的两个源操作数已经完成立即数扩展和寄存器文件读取。reg_op3访存和特殊指令用的第三个操作数比如store的数据。reg_out写回寄存器堆的数据。reg_valid、reg_mem_valid这类控制位用来区分当前周期是取指控制还是执行控制。这些寄存器在同一个always块里根据reg_valid和内存握手信号组合跳转。作者没有写一个显式的state[2:0]状态枚举而是用一组状态位互相配合这种做法在面积优化型RTL里很常见但对读者不友好。我建议先画一张只有四个节点的状态草图取指等待、取指完成、执行中、访存等待然后再回来看代码基本可以对上。2.3 寄存器堆的实现与双口配置寄存器堆在源码里直接体现为reg [31:0] mem [0:31]数组用always (posedge clk)写入读则是组合逻辑数组索引。ENABLE_REGS_DUALPORT打开时会生成两套读端口这样某些ALU指令可以在一个周期内同时读两个源寄存器关闭后面积更小但部分指令会多花周期或借助额外组合逻辑。ENABLE_REGS_16_32则是在标准32个寄存器之外又扩展了32个影子寄存器主要给快速中断保存上下文用这也是PicoRV32脱离标准RISC-V CSR体系、自己设计中断机制的一部分。注意ENABLE_REGS_16_32不是RV32E而是快速中断的影子寄存器组。不要把它等同于标准RISC-V的任意特权模式。2.4 为什么没有独立ICache/DCache标准CPU通常会做哈佛结构、指令Cache、数据CachePicoRV32都没有内存接口就一套。它只在CACHE_MEM参数打开时在核心内部为取指路径加了一块很小的缓存用CACHE_ADDR_WIDTH指定缓存行数CACHE_SIZE指定总容量。这个缓存的实现思路非常简单只缓存最近访问过的指令块缓存命中时就不用再走外部握手取指。因为数据访问在软核SoC里频率相对低很多场景下这一小个指令缓存就足够把循环体跑顺。没有满哈佛结构的原因还是那个词面积。FPGA里一个标准的4路组相联ICache动辄上千LUT比PicoRV32整个核心还大完全违背设计初衷。3. 指令执行通路拆解从取指到写回3.1 操作码字段与局部参数源码开头定义了一组localparam比如localparam [6:0] OPCODE_LUI 7b0110111; localparam [6:0] OPCODE_AUIPC 7b0010111; localparam [6:0] OPCODE_JAL 7b1101111; localparam [6:0] OPCODE_JALR 7b1100111; localparam [6:0] OPCODE_BRANCH 7b1100011; localparam [6:0] OPCODE_LOAD 7b0000011; localparam [6:0] OPCODE_STORE 7b0100011; localparam [6:0] OPCODE_OP_IMM 7b0010011; localparam [6:0] OPCODE_OP 7b0110011; localparam [6:0] OPCODE_SYSTEM 7b1110011; localparam [6:0] OPCODE_FENCE 7b0001111; localparam [6:0] OPCODE_OP_IMM_32 7b0011011; localparam [6:0] OPCODE_OP_32 7b0111011;这些就是RISC-V手册里的标准操作码唯一的特别之处是OPCODE_SYSTEM不只是mret/ecall还兼任了PicoRV32自定义CSR的空间。指令字段拆分也很直白wire [6:0] instruction_opcode reg_insn[6:0]; wire [4:0] instruction_rd reg_insn[11:7]; wire [2:0] instruction_funct3 reg_insn[14:12]; wire [4:0] instruction_rs1 reg_insn[19:15]; wire [4:0] instruction_rs2 reg_insn[24:20]; wire [6:0] instruction_funct7 reg_insn[31:25];虽然是面条代码但这些字段抽取做得非常直接每个字段的用途和RISC-V spec一一对应这也是PicoRV32能稳定通过RISC-V工具链测试的基础。3.2 译码执行的意大利面条块核心执行逻辑可以概括为一个以reg_valid开头的条件树always (posedge clk) begin if (reset) // 初始化 reg_pc、控制位 else if (reg_valid) begin // 根据 reg_insn 的 opcode 和 funct3/funct7 决定行为 end end在reg_valid块内部译码不是用一个大case(instruction_opcode)而是用类似if (instruction_opcode OPCODE_OP_IMM) begin // 立即数算术指令 end else if (instruction_opcode OPCODE_OP) begin // 寄存器算术指令 end else if (instruction_opcode OPCODE_BRANCH) begin // 条件分支指令 end的优先级链。这样写的好处是综合工具在做互斥判断时能直接从LUT的进位链结构里受益坏处是人看代码时要自己维护一个优先级表。我读的时候通常拿RISC-V指令手册放旁边遇到一条指令就按opcode、funct3、funct7三层过滤很快能跳到具体分支。3.3 ALU与比较器的拼接PicoRV32的ALU并没有一个叫alu的独立模块它把加法、减法、移位、逻辑运算全部展开在同一个always块里。比如加法就是reg_out reg_op1 reg_op2减法用reg_out reg_op1 - reg_op2比较结果则通过符号判断生成1位结果再扩展到32位。对于I型指令立即数扩展在reg_op2赋值早期完成// 示意addi 的立即数扩展与运算 reg_op2 {{21{instruction[31]}}, instruction[30:20]}; reg_out reg_op1 reg_op2;这也解释了为什么第一次读会迷茫一条指令的取值、扩展、运算可能分布在几个不同的赋值块里要顺着reg_op1/reg_op2 - reg_out - 写回寄存器这条线追才能看清全貌。等把两三类指令追完后面的指令基本都是同一个套路。M扩展乘法除法在默认配置下通过内部的PCPI接口实例化一个乘除法单元而不是在ALU里直接写*和/。因为FPGA的DSP块和LUT乘法器布局差异很大这样做既能在需要时替换成外部更快的乘除法协处理器也能精确控制面积。提醒默认的ENABLE_MUL乘法器是移位相加式的慢速乘法器ENABLE_FAST_MUL才是直接调综合工具的乘法器。想要性能别只开ENABLE_MUL。3.4 访存指令与握手时序Load/Store是理解执行通路的关键也是最容易出问题的部分。执行到访存指令时核心会先暂停新取指把地址推上内存接口mem_valid 1; mem_addr reg_op1 立即数; mem_wdata reg_op2; mem_wstrb 根据 funct3 生成的字节使能; mem_instr 0;然后等mem_ready。握手完成的定义是mem_valid mem_ready。此时如果是load数据从mem_rdata进入reg_out和寄存器堆如果是store传输完成继续取指。PicoRV32要求访问必须对齐非对齐load/store会触发异常这个行为在源码里是通过检查低地址位后跳陷阱逻辑实现的。SoC集成时要特别注意mem_ready的时序不能在mem_valid拉低之前释放数据否则核心读到的是不确定数据。3.5 分支、跳转与PC的更新策略Branch/JAL/JALR不走访存握手核心在译码周期内计算出目标地址并更新reg_pc同时让取指路径重新指向新地址。这里没有分支预测、没有延迟槽一旦跳转刚刚取进预取缓冲的指令作废。所以循环里的分支会有固定惩罚周期这也是IPC不高的来源之一。实际跑dhrystone这类循环密集测试时可以明显看到C扩展RVC压缩指令因为减小了取指次数、提升了预取命中对性能的帮助比单纯减少代码体积更明显。4. 中断、陷阱和自定义机制4.1 内存映射中断控制器标准RISC-V有CLINT/PLIC/CSRPicoRV32没有走这条路而是把中断控制做成了一组特殊地址CPU访问这些地址时走内部逻辑而不是外部总线localparam [31:0] REG_IRQ_MASK 32h 02000000; localparam [31:0] REG_IRQ_PENDING 32h 02000004; localparam [31:0] REG_IRQ_EOI 32h 02000008; localparam [31:0] REG_IRQ_TIMER 32h 0200000c; localparam [31:0] REG_IRQ_QREGS 32h 02000010;外部中断irq输入在指令边界被采入reg_irq_pending是否响应由MASK_IRQ参数决定。CPU跳转到PROGADDR_IRQ地址前会把返回地址存入固定位置软件在中断服务程序里读取该位置即可恢复现场。这个做法的最大好处是IP核不需要实现庞大的CSR译码和特权级状态机面积省下一大块坏处是它与标准RISC-V中断软件模型不兼容不能用常见的riscv_plic驱动所有中断代码都要按PicoRV32自己的API写。4.2 快速中断与影子寄存器快速中断ENABLE_IRQ_FAST是PicoRV32最亮眼的设计之一。普通中断进入时要把ra、sp、状态位逐个保存开销很大快速中断则利用一组影子寄存器保存上下文中断服务程序可以直接复用那组寄存器而不触碰主寄存器堆从而把中断延迟缩短到几个周期。REGS_16_32参数在这里就是影子寄存器组的总开关。还有ENABLE_IRQ_TIMER提供的内部定时器可以在不依赖外部外设的情况下生成周期中断非常方便做分时调度实验。实际项目中用到快速中断时要管住一个脾气别在快速中断里调用复杂函数因为影子寄存器只有一组任何嵌套都会破坏上下文。官方示例里快速中断服务程序都是手工汇编写的短小函数这个限制在源码注释里写得很清楚但很多人第一次移植RTOS时容易踩。4.3 ECALL与陷阱信号ENABLE_ECALL开关决定ecall是否产生异常。默认开启执行ecall时会进入陷阱向量并把PC切换到PROGADDR_IRQ。trap输出信号会拉高表示CPU进入了异常状态SoC可以把trap接给调试LED或总线主控用于识别程序崩溃。很多初学者以为trap是普通中断其实它只表示需要软件处理的异常事件比如非法指令、非对齐访问、ecall。我在调试裸机程序时会把trap接成计数器的触发信号崩溃多少次一目了然比看串口日志还直观。5. PCPI给自定义指令留的后门5.1 一套简单但完整的软核协处理器握手PCPI是Pico Co-Processor Interface的缩写本质是一个可综合的协处理器握手协议。核心执行到无法解码的自定义指令时不会立刻报非法指令而是把这条指令和源操作数往外推pcpi_valid 1; pcpi_insn reg_insn; pcpi_rs1 reg_op1; pcpi_rs2 reg_op2;外部协处理器如果认识这条指令就通过pcpi_wr和pcpi_wrdata把结果写回通过pcpi_ready通知执行完成如果不认识就一直不拉pcpi_ready核心等一段时间后进入非法指令陷阱。还有一种情况是协处理器可以复用一个周期以上的流水线此时拉pcpi_wait让核心等待。整个协议只有四个状态比AXI-Lite简单得多非常适合在FPGA里手写加速器。5.2 官方乘除法协处理器是最好教材源码自带一个picorv32_pcpi_mul模块负责M扩展的乘除法内部又分成了乘法和除法子模块。把ENABLE_MUL打开时主核通过内部实例化连上这个PCPI模块把这个扩展关掉空出的PCPI接口就能接自己的野逻辑。我第一份自定义PCPI做的是一个硬件CRC32加速器只花了一个晚上就接上了原因就是PCPI的握手信号和主核执行流水天然对齐不用专门做读写同步。5.3 什么时候该用PCPI而不是直接改主核如果你只是想加一两条自定义指令PCPI是比较优雅的路径不用动picoRV32.v那团逻辑。但如果你的自定义指令需要读特殊寄存器、需要多周期内部状态或者想支持中断那可能要直接改主核或者考虑换VexRiscv这类带插件总线的核。PCPI适合的是计算密集、输入输出清晰、不频繁打断控制流的加速器比如FFT蝶形、CRC、AES轮函数、神经网络的乘加。6. 实操经验读源码、跑仿真、集成与避坑6.1 从仿真环境开始而不是从头硬读我建议的顺序是这样先把examples里的最小SoC跑起来用iverilog或者Verilator加载picoRV32.v和测试固件打开GTKWave看波形。先找mem_valid、mem_instr、reg_pc三个信号跟着它们走十几条指令你自然就明白了取指和执行是怎么交织的。然后改一下测试程序加一条jal、一条lw再看一次波形分支和访存的多周期行为也就清楚了。这一步做完再回去读picoRV32.v效率翻倍。命令行跑仿真的典型流程# 用工具链生成 RISC-V 固件 riscv32-unknown-elf-gcc -marchrv32imc -mabiilp32 -Os -nostdlib -T link.ld -o firmware.elf firmware.c riscv32-unknown-elf-objcopy -O verilog firmware.elf firmware.hex # 用 iverilog 仿真 iverilog -o test_tb.vvp picoRV32.v testbench.v vvp test_tb.vvp6.2 我会记得的三个经典坑第一个是mem_ready死锁。自建内存模型时如果mem_valid拉高后mem_ready一直不拉高核心就永远卡在访存等待里。解决办法先看波形确认mem_valid是否维持在1然后检查内存模型是否在同一个always块里组合产生mem_ready不要用独立的时钟打拍生成。第二个是复位和初始化。PicoRV32没有通用异步复位所有状态都在resetn有效时初始化。如果外部SoC把resetn接到一个上电后立刻无效的电源管理信号上而STACKADDR又没配好程序第一行sp就是个随机值裸机代码一压栈就崩。务必把STACKADDR设为实际SRAM顶端地址PROGADDR_RESET指向固件入口。第三个是ENABLE_COUNTERS对fence/mret之类的隐式影响。计数器占用了一定数量的内部寄存器地址空间如果软件没有启用这些计数器部分系统指令的执行路径会和开了计数器的配置不同换配置后二进制行为可能有变化。我遇到过一次在A配置下正常、B配置下启动失败的诡异现象最后就是被计数器相关译码分支坑的。6.3 按需裁剪参数的面积参考我在iCE40UP5K和Artix-7上都综合过给大家一个经验范围最小RV32I无中断无乘除约500到750个LUT默认开RV32IMC和中断约1000到1300个LUT加上快速中断、PCPI、计数器会再增加两三百LUT。这已经包含了寄存器堆。所以如果你的目标是跑Linux那类系统软件别选它目标是裸机控制、解释器、简单RTOS它的面积优势非常明显。6.4 二次开发的小建议如果真要改主核建议先复制一份picoRV32.v改个模块名保留原核做对照仿真。每次改完跑一组自写的定向用例加减法、访存对齐、分支边界、中断嵌套、自定义PCPI最好都用Verilator编译成C测试做回归。PicoRV32没有自带复杂测试套件但它对riscv-tests的兼容性做得相当好先把标准测试跑过再改自己的能省大量排查时间。我个人的体会是PicoRV32这份源码的教学价值恰恰藏在它反教科书的写法里。读过它之后再看其他带完整流水线、分支预测、多级Cache的RISC-V核心你会多一个衡量维度哪些复杂度是性能必需哪些只是惯性使然。如果你正在做FPGA软核或者想理解面积优化型CPU与其在网上看别人的总结不如把picoRV32.v打开用一次波形仿真验证一条store指令的完整生命周期那种哦原来握手是这样配合的的感觉是任何文章都替代不了的。后面我还打算继续写一篇关于在PicoRV32上移植最小RTOS和自定义PCPI加速器的实操记录到时候再把定时器中断和影子寄存器的坑一起补上。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →