尧图精选

PICORV32 RISC-V软核源码解析:从状态机到总线集成

🕒 发布时间:2026/10/1 16:35:24 📁 来源:尧图网络
做FPGA的时候想给片上系统塞一个RISC-V核网上搜了一圈不是带着庞大的总线矩阵就是配置项多到劝退。后来看到PICORV32一个Verilog文件搞定名字又带着PICORV这种草根气息当时就想这种软核大概率能读懂。于是花了两个晚上把picorv32.v从头到尾过了一遍期间做了不少笔记这篇文章就是我从源码层面分析PICORV32的记录希望给准备用这个核、或者打算研究软核实现的朋友一些能直接上手的东西。我不会逐行贴源码因为不同版本细节会有出入更合适的方式是按模块逻辑讲清楚它为什么要这么写同时标注关键信号名和参数名你看源码时能对应上就行。如果你手里也有这份Verilog跟着文章过一遍应该比对着代码干瞪眼快很多。1. 为什么翻PICORV32的源码一颗看得懂的RISC-V软核1.1 它的定位为面积和可读性而生PICORV32由Clifford Wolf开发项目本身叫PicoSoc的一部分后来经常单独拿出来用。它的设计目标非常明确在FPGA上放一个能跑标准RISC-V工具链编译出来的程序的小核代价是最小的逻辑资源。很多人会拿它和VexRiscv、IBEX这类软核比较。VexRiscv用Scala写配置灵活到眼花适合做高性能流水线IBEX是Chisel写的工程化程度高但你要啃的是Chisel生成的Verilog阅读体验不谈了。PICORV32的优势就两条纯Verilog单文件无外部依赖代码风格直白状态机可读性极强适合教学、定制和快速集成。你不需要装Chisel工具链不需要处理一大堆生成文件一个源文件扔进你的FPGA工程就能用。从指令集角度它实现的是RV32I基础整数指令集通过参数可以追加RV32M乘除法扩展部分配置下也能支持压缩指令扩展。没有MMU没有缓存没有标准的中断控制器但这些缺失恰恰是它足够简单的原因也让它非常适合跑裸机程序、实时任务和小型嵌入式Linux的前期评估带MMU的项目别指望它。1.2 分析源码前的基本功三个概念速览想读源码不迷路先确认三个概念RISC-V的32位整数指令集RV32I。包含的是最基本的运算、访存、分支、系统指令固定32位指令宽度32个通用寄存器x0至x31其中x0硬连线为0。你需要认得出常见指令格式R型寄存器运算、I型立即数运算/加载、S型存储、B型分支、U型LUI/AUIPC、J型JAL。软核与硬核的区别。硬核是芯片里物理存在的CPU软核是写在HDL里、在FPGA上综合出来的CPU。软核的频率、资源由FPGA工艺决定PICORV32在中等规模的Artix-7上跑到80-100MHz并不稀奇但同工艺下的硬核CPU频率一定是更高的。CSR寄存器与中断。RISC-V把控制状态寄存器CSR单独划了一块地址空间机器模式下的mstatus、mtvec、mepc、mcause这些寄存器承担异常和中断的核心逻辑。PICORV32对CSR的支持是选择性实现这一点后面第5章会详细说。这些概念不需要你熟到能背手册但读代码时遇到就不至于卡壳。1.3 源码文件的组织方式你拿到的PICORV32源码通常就是一个picorv32.v里面定义了三个主要模块picorv32、picorv32_axi和picorv32_axi内部用的包装逻辑。其中picorv32是核心其余两个是以它为基础的封装变体。模块内部并没有细分成fetch.v、decode.v这种多文件结构而是用一个超大always块配合状态寄存器cpu_state来驱动整个CPU的行为。这和许多教学级CPU的做法一致指令从取指到执行是在一个状态机里串行流转的没有硬件流水线诶这里反而更容易读懂。2. 顶层模块与端口先看门牌号再进门2.1 参数表这软核就是一堆开关组合打开picorv32模块的声明前面一大串parameter就是软核的功能开关。我挑几个关键参数给你过一遍参数名作用我常用的配置STACKBOOT复位时x2sp寄存器的初值0x00010000方便上电直接跑C程序ENABLE_COMPRESSED开启16位压缩指令扩展0/1按需开了能显著省程序空间ENABLE_MULDIV开启RV32M乘除法扩展1编译器会生成mul/div指令ENABLE_IRQ开启中断输入与响应逻辑1ENABLE_IRQ_TIMER内置周期定时器中断1ENABLE_COUNTERS支持cycle/instret等计数器CSR1分析程序性能很有用ENABLE_REGS_16_31是否实现x16-x31寄存器默认1关掉可省资源但有些指令不可用BARREL_SHIFTER用桶形移位器替代循环移位0追求低面积保持默认TWO_STAGE_LOAD是否将load结果延迟一拍再写入寄存器配合特定存储时序开启LATCH_MEM_RDATA锁存内存读数据看外设时序要求看到这些参数你就明白了PICORV32同一份RTL可以根据需求裁剪出不同配置的CPU。但注意参数改动不是无脑开的。例如ENABLE_REGS_16_31关闭后编译器就不能使用x16-x31中的任意寄存器工具链的CPU型号要对应选rv32e或加-marchrv32i限制。改参数前先去查一下软件侧是否配套。2.2 端口信号CPU与外界的所有交互看端口列表时我建议你按功能分组记忆时钟与复位clk、resetn低电平复位没什么好说的。内存访问主接口mem_valid、mem_instr、mem_addr、mem_wdata、mem_wstrb、mem_rdata、mem_ready。这套是核心的访存通道CPU通过它去取指令、读数据、写数据。mem_instr用来告诉总线当前访问的是指令还是数据很多简单总线的仲裁逻辑就靠它区分。前瞻接口mem_la_read、mem_la_write、mem_la_addr、mem_la_wdata、mem_la_wstrb。look-ahead信号会提前一拍给出下一次访存的信息方便外接缓存或支持pipelined时序的存储控制器。中断与陷阱irq32位输入、trap输出。trap输出高电平表示CPU正处于中断处理状态可以用它做调试指示或系统状态检测。调试接口部分配置dbg_*系列如dbg_insn_addr、dbg_insn_data用于Trace调试内部会记录上一条执行的指令。其中最容易忽略的是mem_wstrb它是字节写使能4位分别对应32位数据总线上的4个字节。任何软件层写入最终都要落到wstrb上否则一个32位写操作会把相邻字节也冲掉。我遇到过不少人第一次写外设时没拉这个信号导致只写低字节却把整个字都改了排查半天。2.3 两个顶层变体原生总线与AXI源码里还有个picorv32_axi模块它是picorv32内核外面包了一层AXI4-Lite主机接口。内部实例化一个picorv32把原生mem_*信号转换成AXI的aw、w、b、ar、r五通道握手。如果你所在的SoC已经统一用AXI互联直接用这个封装能省很多事。但要注意这个AXI封装并没有做任何事情去优化性能它只是时序转换。核心还是一次访问一个word总线上看不出任何burst或outstanding特性。所以不要指望picorv32_axi能跑满AXI的带宽它的价值是省得你自己写桥而不是更快的访存。3. 核心控制逻辑一条指令在全状态机里的旅程3.1 预取缓冲为什么它不叫流水线却胜似流水线PICORV32内部没有传统意义上的流水线寄存器组但它有一个关键优化——指令预取缓冲对应内部信号reg_prev_insn和reg_prev_insn_addr。过程是这样的CPU在执行某条指令的同时如果访存接口空闲它会尝试从reg_pc指示的地址去取下一条指令放到reg_prev_insn里。等到当前指令执行完如果下一条指令正好就在预取缓冲中CPU就不用再等待一次存储器访问直接把缓冲中的指令送入译码就能继续执行。对于没有分支的连续指令流这个机制可以让有效CPI接近1而不用每条指令都等一遍完整的取指延迟。同时源码里还有一个reg_previnsn_addr用来记录预取指令的地址。当分支发生时预取缓冲会被判定失效CPU重新从新的PC地址取指。这里我总结一下它在分支场景的处理如果分支目标地址恰好等于reg_prev_insn_addr那分支后可以直接用预取缓冲里的指令省一个周期否则就打水漂重新取。这种碰运气式的优化虽然简单但在无分支的小型循环里有效。预取机制也解释了为什么PICORV32的内存接口看起来总是多拍操作——它在时序上有一个细微的前瞻性动作这一点在对接外部RAM时会影响你对等待周期的判断。3.2 一个大状态机走完一条指令看picorv32核心部分你会发现它再用一个大always (posedge clk)块完成主要逻辑。状态机里最核心的寄存器是cpu_state它的取值大致覆盖了如下阶段指令读取等待指令译码寄存器读取与操作数准备ALU运算访存请求发出写回寄存器分支条件判断下一状态计算PICORV32没有把每条指令都映射到固定周期而是根据条件判断决定状态如何在状态机内流转。比如一条add指令可能先在译码期把rs1、rs2的值读到reg_op1和reg_op2然后ALU算完下个周期写回寄存器。而一条lw指令在ALU阶段算出有效地址后还要进入访存等待状态等mem_ready握手再下一步才写回。这种设计的好处是逻辑集中哪里出问题一眼就能发现坏处是它天然无法像流水线CPU那样多条指令并行执行。但换句话说这也是PICORV32代码量能压到很短的原因。3.3 ALU与移位器小而够用的算逻单元ALU部分源码里非常直白输入是reg_op1和reg_op2输出是reg_out操作类型由译码后的alu_op信号控制。常见的加减、与或异或、比较大小都在里面比较大小还会区分有符号和无符号对应reg_op1、reg_op2的扩展方式。值得一讲的是移位器。RISC-V的移位指令里sll/srl/sra需要在0到31之间左移或右移这是典型的桶形移位器资源消耗点。PICORV32没有使用大面积的桶形移位器除非你打开BARREL_SHIFTER参数默认用基于状态机的循环移位方式实现。具体办法是把移位拆成多次移位步骤每次移位寄存器靠循环移位来逼近最终结果。代价是若干条移位指令的执行周期会变长但面积省下来了一大块。这个取舍非常典型如果你在FPGA上要跑大量移位密集型算法建议打开BARREL_SHIFTER如果只是嵌入式控制流默认就够。乘除法扩展的实现也类似。ENABLE_MULDIV开启后源码里会多出一套基于移位加法的状态机乘法按radix-2逐位累加除法按恢复余数法逐步求商。所以每次mul或div都需要几十个周期才能完成这在软件里要心里有数。好处是整个乘法器不占用DSP硬核纯逻辑实现对FPGA布局友好。4. 存储接口与总线时序软核与外界的握手协议4.1 mem接口的握手valid-ready模型PICORV32原生访存接口是一个简单的valid-ready握手模型。CPU要访问内存时拉高mem_valid同时在mem_addr上给出地址外设或RAM控制器准备好之后回复mem_ready为高一个访问事务就完成了。组合上还有mem_instr信号用来区分指令请求还是数据请求。很多桥接逻辑会用到它来做指令缓存和数据缓存的区分处理。顺序上记住两个原则读事务CPU拉mem_valid与地址等待mem_ready高后数据在mem_rdata上被采样。写事务CPU拉mem_valid、mem_addr、mem_wdata和mem_wstrb等待mem_ready高后事务完成。如果你想把PICORV32接到SRAM控制器或片上RAM直接把RAM的读数据接到mem_rdata把写数据使能等信号映射到mem_wstrb再根据RAM的响应速度生成mem_ready即可。对于ReadLatency较大的存储器比如带延迟的伪双口RAM你需要用mem_ready配合等待周期或者开启TWO_STAGE_LOAD、LATCH_MEM_RDATA这类参数来调整数据采样时机。4.2 LA信号为什么地址比valid早一拍在原生接口里还有一组以mem_la_开头的信号LA就是look-ahead。它会比mem_valid提前一个周期把下一次访问的地址和写数据准备好由mem_la_read和mem_la_write指示方向mem_la_addr给出地址mem_la_wdata给出写数据mem_la_wstrb给出写字节使能。这有什么用假设你外接的是一个需要两拍才能返回数据的SRAM控制器单纯看mem_valid来启动访问会浪费一拍。而LA信号带来的地址提前量可以让你在下一拍mem_valid出现之前就把地址打入RAM的地址寄存器甚至在外部做一个简单的流水线缓存。有些开发者会用它实现0等待的片上RAM访问。看源码时你会发现reg_la_addr等信号参与了这个过程的记录和后续的写数据选择要去理解它如何把LA阶段与正式握手阶段关联起来。4.3 自己动手桥到Wishbone或AXI除了直接用picorv32_axi如果你手头是一个Wishbone总线系统常见做法是写一个小转换逻辑将mem_valid翻译为Wishbone的cyc和stb将mem_wstrb翻译为Wishbone的sel将mem_ready翻译为Wishbone的ack将mem_instr接到总线周期类型标志核心就是握手信号的平移。别被总线协议吓到PICORV32的手握模型比AHB和AXI简单太多只要保持请求-应答的一一映射就没有问题。还有一个细节因为mem_valid在指令取指、数据读写时都会拉高你的总线桥里如果带忙闲标志需要时刻准备处理连续请求。PICORV32可能上一条load刚结束下一条指令就立刻来一个取指请求没有任何突发间隙桥的处理必须尽量无缓冲、无等待地跟进。5. 中断、陷阱与CSRRISC-V标准中的非标准角落5.1 不要再把trap当错误信号初看PICORV32端口表时我差点把trap当成错误警报来用仔细读源码才发现它是进入异常/中断处理状态的指示。当系统触发中断后该引脚会拉高表示CPU正在执行trap处理流程而不是CPU发生了故障。具体流程上当irq输入引脚上有未被屏蔽的中断并且全局中断使能时CPU会在当前指令执行到安全边界后保存当前PC到mepc相关的内部寄存器设置cause然后跳转到mtvec指定的处理入口。这些寄存器同样不是你想改就能改的PICORV32用了一套自定义CSR映射来暴露这些控制位你需要配合软件框架来完成保存与恢复上下文的工作。5.2 PICORV32的CSR访问方式一半标准一半私货RISC-V的CSR指令是csrrw、csrrs、csrrc这些系统指令。PICORV32源码里实现了一套CSR读写逻辑但它并没有把标准RISC-V特权规范里所有CSR都实现只实现了与自身能力相关的那部分并加入了自己的补充寄存器。以IRQ为例你会在源码里看到用于访问中断控制寄存器的特殊地址例如需要配置ENABLE_IRQ_QREGS参数来提供额外的快速中断寄存器组某些代码里也会出现qreg_前缀的内部信号这部分就是Clifford Wolf自己设计的中断快速上下文切换机制用来在中断响应时避免通用寄存器的保存和恢复开销。使用中要特别留意PICORV32并不是一个完整的RISC-V特权架构实现。你不能指望跑Linux那样依赖标准异常处理的复杂系统它更多是为裸机或简单RTOS设计的。如果你想跑嵌入式Linux必须选带MMU的核PICORV32不是这个方向的答案。5.3 与标准工具链的兼容边界在编译软件时如果只用RV32I标准riscv-gcc生成的代码在PICORV32上基本能跑前提是你正确设置了链接脚本和启动代码。中断向量表、启动时的栈指针STACKBOOT参数、以及CSR初始化代码都需要和这个软核的CSR实现匹配。我建议直接复用PicoSoc示例的startup文件不要自己从头写因为PICORV32的中断使能方式跟标准RISC-V的mstatus.MIE开关流程不完全一样。6. 看完源码后移植、集成和踩坑清单6.1 一个最小SoC怎么拼起来简单描述一下我用PICORV32搭最小SoC的步骤你照着做一个最小系统不是难事实例化picorv32配置参数连出mem_*接口。把mem_*接到一块片上RAM控制器。RAM分两块地址区间比如指令区0x00000000起始数据区0x00010000起始区分的标志就是mem_instr。准备UART或其他外设挂在总线上用地址译码器选中。外设读写和RAM读写共用mem_*接口mem_ready由译码器根据地址范围返回。接入irq中断源比如UART接收中断、定时器中断。用riscv-gcc编译裸机程序链接地址设定为RAM起始地址启动代码里初始化栈、清bss、跳main。实测下来这个系统在入门级FPGA上占用逻辑资源非常少几百个LUT再加一个Block RAM就能跑起来做教学或原型验证非常灵活。6.2 面积与性能的取舍账本再看PICORV32的设计思路会得出一个很有意思的结论它把很多性能包袱都甩给了编译器或外部电路。分支开销大无分支预测每次分支都清空预取缓冲重新取指。编译器应尽量使用条件传送指令或逻辑运算代替短分支。乘除法慢状态机迭代完成代码里尽量用移位、加法和查表替代乘法实在不行再调用mul。访存性能依赖外部RAM等待周期用LA信号可以缓解但根本上还是单发单收。与之相对的是面积极其可控不依赖DSP、不依赖专用RAM硬核映射到Lattice iCE40这类小器件上也毫无压力。6.3 读这份源码的意外收获最后说几个从PICORV32源码里学到的通用经验这些思路用在其他Verilog项目上同样有价值参数化恰到好处。它没有把所有东西都做成参数而是在最影响交付场景的点上留开关比如是否实现乘除法、是否实现x16-x31寄存器、是否做桶形移位器。这些参数背后对应的是真实的面积与性能权衡而不是为了灵活而堆开关。状态机边界清晰。一条指令从取指到执行的状态跳转每一步的触发条件都写得很明确。它的reg_op1、reg_op2、reg_out这些信号名简单到即使不看注释也能猜出用途。组合逻辑与时序逻辑的界限。访存握手信号大量是时序输出阅读时反复对照mem_valid与mem_la_*的组合关系能帮你建立对时序边界的直觉。如果你在做一个需要接入总线的自定义外设这份源码里的valid-ready逻辑、写字节使能处理以及look-ahead地址设计都是可以直接借鉴的参考模板。对了还有一个随手记录的技巧如果使用PICORV32跑循环密集型代码建议打开ENABLE_COUNTERS参数这样可以在软件里读取cycle计数器精确测量循环耗时比外部逻辑分析仪抓引脚靠谱得多。这个参数本身不占多少资源但排查性能瓶颈时非常重要。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →