PICORV32软核源码详解:FPGA上的RISC-V状态机与预取机制
前阵子接手一个FPGA小控制器的活儿需要在资源紧张的芯片里塞一个能跑C代码的CPU软核。对比了一圈最后选了PICORV32。用起来确实省心但真正让我踏踏实实花掉一整个周末的是把picorv32.v从头到尾过了一遍。因为它不是那种标准的教科书式五级流水线而是一个用状态机驱动的、带指令预取缓存的精简RISC-V核。搞懂这套思路你才能安全地改代码、加外设、调时序。这篇就是我基于源码逐行分析后的完整笔记适合正在用或用打算用PICORV32做FPGA软核的开发者也适合想通过一个真实软核理解RISC-V底层执行机制的硬件爱好者。1. 从顶层端口看设计意图PICORV32到底长什么样拿到一份源代码别急着钻进always块里。先把顶层模块的端口和参数读明白这决定了你对整个架构的第一印象。PICORV32的顶层长这样我做了精简变量名和原仓库保持一致module picorv32 #( parameter integer STACKADDR 32h 0x0000_0000, parameter integer PROGADDR_RESET 32h 0x0000_0000, parameter integer PROGADDR_IRQ 32h 0x0000_0010, parameter ENABLE_IRQ 1, parameter ENABLE_IRQ_TIMER 1, parameter ENABLE_IRQ_QREGS 1, parameter ENABLE_TWO_STAGE_ALU 1, parameter ENABLE_PCPI 1, parameter ENABLE_MUL 1, parameter ENABLE_DIV 1, parameter ENABLE_FAST_MUL 0, parameter ENABLE_FAST_DIV 0, parameter ENABLE_CSR 1, parameter ENABLE_COUNTERS 1, parameter LATCHED_MEM_RDATA 0 )( input clk, input resetn, input trap, input [31:0] irq, output [31:0] eoi, output mem_valid, output mem_instr, input mem_ready, output [31:0] mem_addr, output [31:0] mem_wdata, output [3:0] mem_wstrb, input [31:0] mem_rdata, output pcpi_valid, output [31:0] pcpi_insn, output [31:0] pcpi_rs1, output [31:0] pcpi_rs2, input pcpi_wr, input [31:0] pcpi_wr_data, input pcpi_wait, input pcpi_ready );看到这个端口列表第一反应是这个核没有走传统读写指令都严格通过一套Avalon/AXI总线简单封装的路子。它给外部提供了mem_valid/mem_addr/mem_rdata/mem_ready这一组非常简单的SRAM式接口同时把时序因素考虑进去了——多了个mem_instr标志方便你区分当前访问是指令取指还是数据读写。这种接口在教育核和轻量FPGA集成里非常友好不需要去理解复杂的总线协议也不需要SDK去管理burst传输。另一个值得注意的点是pcpi_*这组信号。PICORV32把扩展能力做成了一组独立的协处理器握手接口。默认情况下乘除法扩展ENABLE_MUL/ENABLE_DIV就是通过PCPI接口挂上去的并不是在ALU里硬编码一套乘法器。后面我会单独展开这块这是理解PICORV32可扩展性的钥匙。顶层参数里STACKADDR和PROGADDR_RESET决定软核上电后从哪里取第一条指令PROGADDR_IRQ是中断服务入口地址。默认值都是0多数FPGA设计里你会让PROGADDR_RESET指向你的RAM起始地址比如32h0000_0000。ENABLE_IRQ支持多路外部中断配合ENABLE_IRQ_TIMER和ENABLE_IRQ_QREGS还能做定时中断和快速保存中断现场。ENABLE_TWO_STAGE_ALU默认开启这是PICORV32性能提升的一个关键选项后面讲双发射时会详细说。表格式对比一下两个常见变体配置资源占用约说明标准版所有扩展全开约 1200-1500 LUTiCE40级别性能好支持M扩展、中断、CSRTiny版picorv32_tiny约 800 LUT面积最小无两级ALU不支持PCPI中断仅保留基本IRQ设计上把很多功能都做成编译期参数好处是你不用的逻辑压根不会综合进去。比如你不需要中断ENABLE_IRQ0相关的irq_pending寄存器、CSR逻辑会被工具裁掉面积进一步下降。理解这点很重要因为PICORV32的性能和面积本来就靠这套参数化方案在低端FPGA上立足你要按需取舍。2. 指令预取、状态机与执行单元核心源码是怎么串起来的看PICORV32源码你会发现它没有大规模的流水线寄存器组取而代之的是一个以cpu_state为核心的大状态机外加一组预取寄存器。这是它最“反直觉”的地方也是它面积小的根本原因。2.1 指令预取的“弹药库”思路传统处理器每个周期都要去取指令然后送到译码器。PICORV32则引入了一个小型的look-ahead预取机制。代码里有类似下面这种结构的关键寄存器reg [31:0] mem_la_rdata; reg [31:0] mem_la_wdata; reg [31:0] mem_la_addr; reg mem_la_ready; reg la_instr;每次当前指令还没执行完主状态机就可以先把下一条或者连续几条指令的地址发到mem_addr上并把返回的mem_rdata存到预留缓冲里。这样当上一条指令结束进入取指状态时指令已经等在手里了省掉一次访存等待。源码里专门处理了预取失效的情况。遇到jal、jalr、ret这类跳转指令时必须把预取队列里已读出来的但还没执行的旧指令作废因为PC已经变了。这也是为什么你在代码里能看到大量的flush控制信号——凡是改变PC的路径都会把look-ahead那组寄存器清零重来。顺便说一句LATCHED_MEM_RDATA这个参数很有意思。置1时外部返回的读数据会被打一拍降低时序压力适合跑更高频率的FPGA。代价是预取机制要额外处理数据晚回来一拍的情况。默认是0新手别乱改。2.2 译码与执行共用状态机的精巧之处PICORV32的译码核心其实不是一个独立的组合译码器而是把译码和执行动作融进了cpu_state的状态分支里。大体伪代码如下localparam FETCH 7d0, DECODER 7d1, REG_OP 7d2, LREG 7d3, SB 7d4, ... always (posedge clk) begin if (!resetn) begin cpu_state FETCH; end else begin case (cpu_state) FETCH: begin // 从预取缓冲里取指令到 ir // 根据 ir[6:0] 是 OP 还是 LOAD/STORE 进入不同分支 end REG_OP: begin // 执行寄存器类型运算 // 处理 rd 写回 end ... endcase end end这个状态机里有一组稳定的状态用于处理几乎所有指令。比如REG_OP负责加法、减法、逻辑运算LREG负责load访存SB负责store。有意思的是PICORV32把很多指令动作拆成了几个状态串行完成而不是用一条长的组合逻辑链一次算完。这种风格在FPGA上特别友好——你可以跑更高的时钟频率同时代码阅读起来也很清晰。源码中指令寄存器ir被保留出来供译码和回写使用。它的位域划分严格遵循RISC-V规范ir[6:0]是opcodeir[14:12]是funct3ir[31:25]是funct7。状态机会根据这几个字段组合决定下一步// 一个真实的片段体现如何用 opcode 区分指令 wire [6:0] opcode ir[6:0]; wire [2:0] funct3 ir[14:12]; always (*) begin is_lui (opcode 7b0110111); is_alu (opcode 7b0110011) || (opcode 7b0010011); is_jal (opcode 7b1101111); is_load (opcode 7b0000011); is_store (opcode 7b0100011); ... end2.3 ALU是怎么算的移位与比较ALU部分也体现了“为FPGA面积做妥协”的智慧。加法、减法、按位与或异或这些是纯组合逻辑一个周期就能出结果。比较操作setlt/setltu处理的是符号和零扩展之间的关系代码里用的是减法结果配合符号位判定这也是RISC-V软核常见的做法核心思路是diff a - b; result diff[31]之类的判断。移位运算没有选择用桶形移位器而是一个循环执行的设计。默认情况下sll/srl/sra会通过一个移位计数器循环移位每次移一位。这意味着移位指令比普通加法慢但占用的LUT少。如果你的设计对移位性能有要求可以在综合工具里把ENABLE_FAST_SHIFTS相关逻辑打开实际代码中体现在是否让移位一次完成。作为使用者你要清楚默认配置下移位是“慢指令”不要用软核去跑大量位操作密集型算法。2.4 两级ALUPICORV32的隐藏加速器ENABLE_TWO_STAGE_ALU1时PICORV32会把连续的、不相关的寄存器运算指令做一定的重叠执行。比如连续两条加法指令如果第一条的rd不是第二条的rs1/rs2那么第二条可以提前进入执行阶段。这就是那个“Two-Stage”的含义。它不是一个完整的多发射处理器因为访存指令、跳转指令都无法重叠。但对最常见的纯算术代码段两级ALU能让CPI从大约4降到接近3。我个人实测体验开这个参数能让CoreMark分数提升大概15%到20%面积增加非常有限。所以在资源允许的情况下强烈建议保持默认开启。3. 中断、CSR、PCPI与乘除法一块一块拆开看3.1 中断机制简化但不失实用PICORV32的中断设计非常精简适合嵌入式裸机。它支持最多irq[31:0]输入但不会给你完整的中断控制器。所有中断共用同一个入口地址也就是PROGADDR_IRQ。源码中的中断处理逻辑大致是reg [31:0] irq_pending; always (posedge clk) begin if (ENABLE_IRQ) begin irq_pending irq_pending | irq; if (cpu_state FETCH irq_pending) begin // 进入中断状态清除pending跳到IRQ入口 end end end值得注意的是默认情况下中断并不会自动保存和恢复现场Processor会跳到PROGADDR_IRQ让软件自己管理现场。ENABLE_IRQ_QREGS开启后它可以帮你快速保存几个关键寄存器减少中断延迟和软件负担。而ENABLE_IRQ_TIMER会内置一个可加载的timer寄存器通过CSR指令访问。如果你用裸机写RTOS风格代码这两个参数非常有价值能省掉不少汇编现场保护代码。中断结束标志eoi是给外部中断控制器反馈用的表示CPU已经接受了这次中断。这个信号在源码里会在进入中断入口前拉高一个周期配合irq_pending的清零逻辑能避免中断被重复触发。3.2 CSR指令支持只做了必需的最小集RISC-V的CSR指令集非常庞大PICORV32没有实现完整CSR而是只实现了csrrw/csrrs/csrrc这几个基本读写指令用以操作它内部的几个状态寄存器。源码里这些寄存器大致对应irq_pending、eoi、timer等。这是很务实的选择因为完整CSR协议状态机对一个小软核来说太奢侈了。如果你在编译固件时用了#include stdio.h之外比较复杂的库这些库可能用到用户态CSR比如cycle计数器。PICORV32提供了ENABLE_COUNTERS可以支持rdcycle/rdtime/rdinstret这类读取指令。但如果程序里出现了它不支持的CSR地址会触发非法的指令行为你得在软件里避免。这也是移植代码时最常见的坑。3.3 PCPI协处理器接口扩展的万能插座PCPI是PICORV32最有意思的机制。它的作用相当于一个可裁剪的指令扩展插槽。CPU在执行某条指令时如果识别到pcpi_valid有效就把这条指令和源操作数通过pcpi_insn/pcpi_rs1/pcpi_rs2发给外部协处理器然后等待pcpi_ready或pcpi_wr应答。看源码里的协议部分always (*) begin case (opcode) // 用户自定义 opcodes 会走 pcpi default: pcpi_valid ...; endcase end默认仓库的picorv32_pcpi_mul.v和picorv32_pcpi_div.v就是两个典型的PCPI模块。pcpi_mul实现了一个32x32迭代乘法器pcpi_div实现迭代除法。它们不是组合逻辑一次算完而是占用多个周期逐步迭代。所以你看源码时如果看到ENABLE_MUL1不要以为这是硬件乘法器。它只是把一个软件乘法指令翻译成了一系列硬件微操作由PCPI模块执行。因此乘法指令的延迟大约在几十个周期上下具体取决于乘法器配置ENABLE_FAST_MUL可加速。你完全可以替换掉默认的乘除法PCPI模块挂上自己设计的加速器。比如做FFT需要乘加你可以自定义一条MACC指令在PCPI里实现真正的并行乘法加法器然后回写结果。这种可扩展性让PICORV32不只是教学核也能承担一定的DSP加速任务。3.4 调试辅助与trap处理源码里还内置了一些调试接口比如trap输入和输出。外部系统可以通过trap信号触发处理器的异常状态一条ebreak指令也会进入类似的处理流程。默认trap输出可以用于错误检测。对于调试裸机程序我建议在顶层把trap连到一个LED程序跑飞时能直观看到。4. 访存路径与启动流程软核如何控制外部RAMPICORV32的访存接口是分立信号不是AXI也不是Wishbone这在一众软核里算小众的但原理最直接。理解它的时序你才能决定怎么接你自己的RAM或者外设。4.1 读时序取指和数据读一次合法的读写事务需要mem_valid拉高然后等待mem_ready拉高。外部RAM或内存控制器在这个期间必须准备好返回数据。取指时会有mem_instr1数据访问时mem_instr0。作用在于你可以在外部做指令Cache或者指令缓冲区分也可用于跟踪执行流。如果你的RAM是单端口SRAM或者块RAM这个信号也可以忽略。但要注意不同步的块RAM在数据返回时有等待周期PICORV32的预取机制在很大程度上能掩盖指令读取的等待但对数据读取的等待比较敏感——lw必须等数据回来才能继续。4.2 写时序字节掩码的巧妙处理写操作通过mem_wstrb按字节控制。写一个32位字mem_wstrb4b1111写一个字节就是对应的0001、0010等。外部RAM需要根据掩码做对应字节的写入。这种设计比AXI还要简单因为连位宽转换都省了。常见的picorv32_ram.v封装里就是直接把mem_wstrb应用到RAM的写使能上。如果你要把PICORV32接到AXI总线上仓库里有picorv32_axi.v做桥接但底层行为不变它只是把这组合适的mem接口转换成了AXI-Lite事务。4.3 启动流程与复位向量PICORV32复位后PC被设为PROGADDR_RESET然后开始从那个地址取指。上电后第一件事一般是跳到STACKADDR初始化栈指针接着调用main。所以你在固件链接脚本里要把.text段放到PROGADDR_RESET。如果RAM起始地址和PROGADDR_RESET不一致程序跑飞是必然的。一个实操经验把PROGADDR_RESET设置为你的RAM起始地址同时RAM里烧录固件偏移也设为0这样最省心。如果用picorv32_ram.v的封装它还会在你给定初始值后自动把RAM里的前几个位置初始化成跳转指令方便对接bootloader。5. 工具链、综合约束与实测数据5.1 编译与生成固件PICORV32的标准工具链是riscv32-unknown-elf-gcc新版更常见是riscv-none-elf。我一般这样编译riscv32-unknown-elf-gcc -marchrv32im -mabiilp32 -Os -ffreestanding -nostdlib \ -Tlink.ld start.S main.c -o firmware.elf riscv32-unknown-elf-objcopy -O binary firmware.elf firmware.bin链接脚本里要指定PROGADDR_RESET对应的地址并保证entry从这里开始。然后用脚本把firmware.bin转成Verilog可加载的$readmemh格式或者直接生成.hex烧进RAM初始化文件。我推荐先在仿真里跑通。仓库自带的测试环境用的iverilog个人也推荐verilator做更快的仿真。仿真的时候如果你想看清PICORV32内部状态可以直接把picorv32.v内部的cpu_state和ir引出来到测试平台或者加$display打印。这个办法比用波形工具还直观。5.2 综合频率与面积参考从我个人在多个FPGA上的综合结果来看FPGA配置LUT/LEFmax约Lattice iCE40 UP5K标准版~1200 LUT70-80 MHzXilinx Artix-7标准版~1500 LUT含接口逻辑100-120 MHzCyclone IVTiny版~900 LE80 MHz当然Fmax和你的内存接口时序关系极大。如果把LATCHED_MEM_RDATA打开、加上合理的流水寄存还能再往上提。5.3 常见综合陷阱一个常见问题是把resetn信号处理不好导致预取逻辑和状态机不同步。PICORV32内部大量使用的是同步复位外部复位信号必须保证足够的有效时间。另一个问题是如果mem_ready长期拉低状态机会一直卡在访存状态看起来像死机。接外部慢速外设时尤其要注意它不会自动插入等待周期要么外设及时响应mem_ready要么由总线桥自己产生足够的等待状态。6. 常见问题与调试实战速查记录几个我在移植和调试PICORV32时踩过的真实问题都非常有代表性。问题1跳转指令后程序跑飞反复复位才能恢复。原因几乎都是预取缓冲未正确失效。检查是否把mem_la相关的预取信号在jal/jalr路径上清掉。如果你修改过源码不要只关心PC更新还要看预取使能信号是否被废除。问题2中断来了但CPU没反应。先确认ENABLE_IRQ1再确认外部irq信号确实被拉高且宽度够。PICORV32的中断识别是电平式的至少要保持高电平直到CPU进入中断入口。还要检查PROGADDR_IRQ地址是否正确以及在那段地址处是否真的放有中断服务程序。很多人会忘记在链接脚本里为中断入口保留空间。问题3访问外设地址时外部总线一直不返回mem_readyCPU卡死。在总线桥里实现一个超时机制几拍之后强制返回mem_ready。或者确保每个外设的地址空间都有相应的waitstate计数器。这是所有简单软核共有的问题PICORV32也不会替你兜底。问题4固件编译后烧进去RAM先读到一堆0代码不动。大概率是PROGADDR_RESET和你RAM初始化的位置对不上。检查$readmemh的起始地址以及固件bin文件的偏移。另一个可能是工具链的_start文件没有正确设置栈指针sp一进C代码就跑飞。问题5想用浮点指令却发现编译不过或者运行到浮点运算时卡死。PICORV32没有F扩展。它只支持RV32IMC加上可选的C压缩指令。编译器要用-marchrv32im或rv32imc不能写rv32imafc。浮点要么软浮点模拟要么通过PCPI挂一个浮点协处理器自己实现。7. 继续扩展的方向我自己的项目里最后还在PICORV32的基础上做了两件事一件是用PCPI扩展实现了一个简单的硬件加速CRC32模块把原本几十个周期的循环计算压缩到几个周期另一件是给mem接口加了一个轻量的地址过滤把一部分地址空间直接映射到外部SRAM一部分映射到寄存器组。整个过程得益于PICORV32源码的清晰和模块化——所有需要动手的地方其实都集中在那几个状态分支和接口信号上。如果你也想深入了解建议从官方仓库下载源码后先用iverilog跑通自带的testbench再根据这篇分析把cpu_state的转移路径对照着看一遍。相信我把状态机挨个走一遍的收获比看十遍架构图都实在。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →