news 2026/10/1 1:04:45

PICORV32软核源码解析:从Verilog到RISC-V处理器设计入门

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PICORV32软核源码解析:从Verilog到RISC-V处理器设计入门

PICORV32是一颗用Verilog写出来的RISC-V软核,整个CPU核心基本就集中在picorv32.v一个文件里,源码规模两千行上下。别小看这两千行,里面塞下了一条完整的RV32I指令集,还能按需打开乘法、除法、压缩指令、中断、自定义协处理器接口。对刚接触处理器设计的人来说,它是极佳的入门教材;对要在FPGA里快速塞一颗小CPU做控制、协议解析、状态机复杂逻辑的人来说,它又是非常趁手的工具。这篇文章就围绕PICORV32源代码,从架构设计、内部模块、关键机制到仿真运行、常见调试问题,完整拆一遍。

读代码之前先明确一点,PICORV32的设计目标从来不是“高性能”,而是“小而全”。它宁可多花几个周期执行一条复杂指令,也要把逻辑面积压到极致。理解了这一点,后面所有的代码细节就都顺了。

1. 为什么选PICORV32:设计思路与软核选型拆解

1.1 从“源代码分析”的角度看PICORV32的定位

PICORV32的作者是Clifford Wolf,也就是开源FPGA工具链Yosys和NextPNR的核心开发者。他做这个软核的初衷很明确:给开源FPGA生态提供一个干净、可综合、授权宽松的RISC-V参考实现。所以整个工程刻意保持简洁,源码入口就是picorv32.v,没有复杂的构建系统,没有依赖庞大的IP库,拿来就能综合,加上一个testbench就能仿真。

这个定位对“源代码分析”特别友好。很多商用CPU软核动辄几十万行Verilog,包含流水线冒险控制、多级Cache、MMU、总线矩阵,初学者根本无从下手。PICORV32把CPU最核心的骨架露在外面:取指、译码、执行、访存、写回全部可见,更像是一张“处理器的解剖图”,而不是一座“复杂的摩天大楼”。

还有一个很关键的点:PICORV32用Verilog-2001写成,没有SystemVerilog的高级语法,不需要专门的仿真器特性支持。Icarus Verilog能跑,Verilator能跑,Yosys能综合,Vivado、Quartus、Gowin、Efinix这些FPGA工具链也都能直接吃下。对做开源硬件、跨平台开发的人来说,这种“低门槛”比性能更有价值。

1.2 与主流RISC-V软核对比:面积、主频、扩展能力

我实际对比过几类常见的RISC-V软核,各自的性格差异非常大。PICORV32适合的场景和VexRiscv、Neorv32、SweRV这些是完全不同的,选型前先看清楚差异,能省很多返工。

软核流水线深度典型逻辑面积总线接口适合场景学习成本
PICORV32无流水线(准单周期)约1500~2500 LUT自定义简易总线 / AXI4-Lite小规模控制、协议栈、自定义指令实验低
VexRiscv5级流水线约3000+ LUTAXI4 / Wishbone追求性能的SoC中高
Neorv322级流水线约2500~4000 LUTWishbone / AXI4功能丰富的小SoC中
SweRV9级流水线数万门起步AXI4中高性能嵌入式高

从表格能看出,PICORV32最大的卖点是面积和复杂度。它不需要专用的SoC总线互连,内存接口就是一个简单的握手总线,接BRAM、接SRAM、接自定义外设都极其随意。把mem_valid、mem_ready当成最朴素的读写握手信号来理解即可。

“无流水线”听起来落后,但实际在嵌入式场景里完全够用。比如在FPGA里跑一个Modbus协议解析器、做电机控制状态机、做传感器数据采集,这类任务对算力要求不高,几十MHz的主频绰绰有余。反而省下来几百个LUT可以留给业务逻辑。

总结一句:如果你的项目需要“写完就跑、逻辑简单、面积敏感、方便加自定义指令”,PICORV32是首选。如果你的目标是学习“流水线冒险、乱序执行、分支预测”,那它就不是合适的教材,请去看VexRiscv或SweRV。

2. 源码级架构解剖:一个单文件里的完整CPU

2.1 顶层模块和内部逻辑块

打开picorv32.v,第一层看到的是module picorv32,它把所有功能参数和外部接口集中在一起。这里注意一个细节:整个CPU实际上被拆成了pico_cpu、pico_execute、pico_mem、pico_muldiv等子模块,但都被打包在同一个文件里。这种“单文件多模块”的组织方式方便分发,也方便在综合时做全层次优化。

核心子模块的分工如下:

  • pico_cpu:主状态机,负责取指、译码、分支跳转、异常处理、寄存器写回控制。
  • pico_execute:ALU执行单元,负责算术逻辑运算、移位、比较、CSR读写。
  • pico_mem:访存控制逻辑,负责load/store、字节使能、存储器握手机制。
  • pico_muldiv:乘除法运算单元,支持有符号、无符号乘法和除法。
  • pico_axi/pico_axil:AXI封装逻辑,把内部简易总线转换成AXI4或AXI4-Lite。

整个模块之间通过内部信号交互。最重要的几个接口信号组是:

  • 取指/访存总线:mem_valid、mem_instr、mem_ready、mem_addr、mem_wdata、mem_wstrb、mem_rdata。
  • 外部控制:clk、resetn、trap。
  • 外部中断:irq、eoi。
  • PCPI协处理器接口:pcpi_valid、pcpi_insn、pcpi_rs1、pcpi_rs2、pcpi_rd、pcpi_ready、pcpi_wait。

理解模块划分,最好带着问题去读代码:一条指令从内存取出来之后,哪个信号在什么时候被拉开,哪个模块在什么周期算完结果,最后又是如何写回寄存器。把这三个时间点找到,CPU的基本运行脉络就抓住了。

2.2 指令执行框架与状态机

PICORV32虽然叫“无流水线”,但并不是一条指令严格在一个时钟周期内完成。它采用了一种“多周期状态机”的方式,只是没有为不同指令建立并行流水级。

从源代码里能看到,CPU内部有一个核心状态变量cpu_state,配合一组decoder阶段信号来推进指令流程。大致流程是:

  1. 取指阶段:CPU拉高mem_valid,同时拉高mem_instr,表示当前访问是取指。外部存储器返回数据后,mem_ready拉高,指令被锁存进内部寄存器。
  2. 译码阶段:解析指令的opcode、funct3、funct7,以及rd、rs1、rs2字段。这里会区分是R型、I型、S型、B型还是U型/J型指令。
  3. 执行阶段:把操作数送入pico_execute,算出结果。对于简单ALU指令,这个阶段可能就是一个周期;对于乘除法,会进入pico_muldiv,需要多个周期才能完成。
  4. 访存阶段:如果是load/store指令,pico_mem会把地址和数据拿到总线上,等mem_ready握手完成。
  5. 写回阶段:把执行结果写回寄存器堆的rd。

和常规流水线CPU最大的不同是,PICORV32不需要冒险检测和转发逻辑。因为下一条指令的取指要等当前指令完全退休之后才开始,数据竞争在时间上被天然消除了。代价是CPI(周期每指令)比较高,但对面积极度敏感的设计,这个交换非常划算。

代码里还有一个值得关注的概念:reg_retired。这个信号在每条指令写回完成后拉高一个周期,相当于“这条指令已经退休”的标记。调试波形时,数一下reg_retired的脉冲个数,就能知道CPU执行了多少条指令。

2.3 可选扩展背后的条件编译逻辑

PICORV32的灵活性很大程度上来自一堆parameter开关。这些参数在实例化时传入,决定生成什么样的硬件。看源代码时,频繁出现generate if (...)的块,就是这些参数在起作用。

常用参数整理如下:

参数名作用典型取值
ENABLE_MUL支持RV32IM的乘法指令0/1
ENABLE_DIV支持除法指令0/1
COMPRESSED_ISA支持RV32C压缩指令0/1
ENABLE_IRQ支持外部中断0/1
ENABLE_IRQ_QREGS使用快速中断寄存器组0/1
ENABLE_TIMER支持内部定时器中断0/1
ENABLE_PCPI启用协处理器接口0/1
ENABLE_COUNTERS支持mcycle、minstret等计数器0/1
ENABLE_TRACE输出指令跟踪信号0/1
TWO_STAGE_SHIFT移位操作分两周期完成0/1
BARREL_SHIFTER使用桶形移位器,面积换速度0/1

有一点必须提醒:参数不是盲目全开的。所有扩展都打开后,面积可能翻一倍以上。设计时要先想清楚最终需要什么指令集,能不开的尽量不开。TWO_STAGE_SHIFT默认是1,代表移位指令用两个周期完成,能减少关键路径;BARREL_SHIFTER则是相反的选择,牺牲面积换来移位操作单周期完成。这两者互斥,只能选一个。

3. 核心机制实现细节:从Verilog反推CPU原理

3.1 寄存器堆与写回策略

寄存器堆是CPU最基础的结构。PICORV32内部用一个二维内存数组实现32个32位寄存器,regs[0]固定为0,任何写入都会被忽略。这里有个设计取舍:默认情况下寄存器堆只开一个写端口、一个读端口,所以同一周期内只能读取一个操作数,意味着某些需要双操作数的指令在译码阶段就要分时取数。

如果追求微小的性能提升,可以打开ENABLE_REGS_DUALPORT,给寄存器堆增加第二个读端口。代价是寄存器堆面积上涨。对于大多数嵌入式应用,单端口完全够用,因为CPU本来就是顺序执行,多出的那一个周期对最终体验几乎没有影响。

写回策略也是典型的“集中写回”。ALU计算完的结果、load返回的数据、跳转指令产生的PC值,都会在指令退休前统一送到regs[rd]。这样做的好处是逻辑简单,不需要为不同来源数据设计不同的写回通路。坏处是如果有某条指令要同时写寄存器和访问内存,就必须把两个操作拆到不同周期,这就是为什么PICORV32的load指令通常需要多个周期才能完成。

3.2 访存接口与等待机制

PICORV32的访存总线非常直白,核心就一组信号:

  • mem_valid:CPU发起访问的有效标志。
  • mem_ready:外部设备应答标志,拉高说明访问完成。
  • mem_addr:32位地址。
  • mem_wdata:写数据。
  • mem_wstrb:4位写字节使能,对应32位数据中的4个字节。
  • mem_rdata:读数据。
  • mem_instr:高电平表示当前是取指访问,低电平表示是load/store。

这套握手机制在仿真里非常好用。任何外部设备只需要在接收到mem_valid后,把mem_ready拉高一个周期,CPU就会继续前进。如果外部设备很慢,比如操作UART或者Flash,可以一直把mem_ready拉低,CPU就会停在当前状态等待。这种“可插入等待周期”的设计让CPU和外设之间的时序解耦变得极其简单。

我在实际工程里常把这个接口直接接到Block RAM上。标准做法是:地址作为RAM地址,读数据直接回给mem_rdata,写数据通过mem_wstrb控制RAM的写使能,mem_ready直接接一个固定高电平。对于片上RAM来说,一个周期就能完成访问,所以等待周期可以完全省去。

3.3 乘除法、中断、PCPI等特色机制

乘除法单元是PICORV32里比较值得单独讲的部分。pico_muldiv模块内部实现了一个可以执行乘法、除法、取模的运算单元。乘法默认是“慢速乘法”,按位累加,需要多个周期完成;如果打开ENABLE_FAST_MUL,会换用更快的乘法器结构。除法则更慢,大致是按位试商的算法,每条除法指令可能要几十个周期。

中断机制也是这个软核的一个亮点。PICORV32通过irq输入向量接收外部中断请求,同时用eoi输出中断结束信号。内部还有可屏蔽的中断寄存器,软件可以通过CSR指令读写。打开ENABLE_IRQ_QREGS后,中断响应时会自动切换一组独立的快速寄存器,避免中断服务程序中保存和恢复上下文的开销。这个功能对小RTOS非常友好。

PCPI(Pico Co-Processor Interface)是我个人认为最有想象力的接口。它允许外部挂接自定义指令协处理器,标准RV32I之外可以定义自己的指令编码,CPU译码时如果发现指令无法识别,会把它转发到PCPI接口上,由外部协处理器决定是否处理。这个机制在做硬件加速时特别有用,比如自定义一条“CRC32计算”指令,把计算逻辑放进FPGA逻辑里,软件只需要一条指令就能拿到结果。

4. 实操入门:搭建仿真环境到跑通RISC-V程序

4.1 工具链与工程准备

读源码不如跑源码。准备仿真环境之前,先把工具装齐。最基础的组合是Icarus Verilog加RISC-V GCC工具链。

我推荐直接使用预编译的RISC-V工具链,比如Sifive或Bootlin提供的riscv32-unknown-elf预编译包,避免自己编译工具链浪费一整天时间。安装完成后,用以下命令验证工具链可用:

riscv32-unknown-elf-gcc --version

接着拉取PICORV32仓库:

git clone https://github.com/cliffordwolf/picorv32.git cd picorv32

仓库里已经带了完整的testbench和firmware测试代码。官方Makefile写得很好,直接执行:

make -f Makefile iverilog

这条命令会自动编译所有固件测试,并用Icarus Verilog跑仿真。如果一切顺利,终端会显示所有测试通过。这是验证编译环境是否正常最快的方式。

4.2 从C代码到仿真波形

为了真正看到CPU内部信号,建议自己写一个最简单的固件,然后用GTKWave观察波形。整个过程分四步:

第一步,写C代码。比如:

int main(void) { volatile int *gpio = (volatile int *)0x40000000; *gpio = 0x55; while (1) { } }

第二步,编译链接成Verilog能加载的格式。PICORV32的testbench支持直接加载一个firmware数组,所以要用特定方式把二进制转成Verilog内存初始化格式。仓库里的firmware/目录已经提供现成链接脚本,可以直接参考:

riscv32-unknown-elf-gcc -c -march=rv32im -mabi=ilp32 -nostdlib -fno-builtin -nostartfiles -T firmware/link.ld firmware/start.S test.c -o test.o riscv32-unknown-elf-objcopy -O verilog -I binary test.bin test.hex

第三步,把生成的hex数据放进testbench。手动改testbench.v太麻烦,PICORV32官方提供了一个自动化脚本来处理这类工作。实际项目中我习惯写一个小Makefile:

firmware.hex: test.c riscv32-unknown-elf-gcc -c -march=rv32im -mabi=ilp32 -nostdlib \ -fno-builtin -nostartfiles -T firmware/link.ld \ firmware/start.S test.c -o test.o riscv32-unknown-elf-ld -T firmware/link.ld -o test.elf test.o riscv32-unknown-elf-objcopy -O binary -j .text -j .data test.elf test.bin riscv32-unknown-elf-objcopy -O verilog -I binary test.bin firmware.hex

第四步,用iverilog仿真并导出VCD波形:

iverilog -o test.vvp picorv32.v testbench.v firmware.hex vvp test.vvp -vcd gtkwave dump.vcd

波形里重点观察几个信号:mem_valid能看出CPU何时发起访存;reg_retired能看出指令退休的节奏;mem_addr能确认程序计数器走到哪。看到这些信号像预期一样跳动,说明你已经跑通了整个编译仿真链路。

4.3 把PICORV32挂到自己的FPGA工程里

仿真跑通之后,下一步通常是挂到真实FPGA工程里。实例化PICORV32非常直观:

wire mem_valid; wire mem_ready; wire [31:0] mem_addr; wire [31:0] mem_rdata; wire [31:0] mem_wdata; wire [3:0] mem_wstrb; wire [31:0] irq; wire [31:0] eoi; picorv32 #( .ENABLE_MUL(1), .ENABLE_DIV(0), .ENABLE_IRQ(1), .COMPRESSED_ISA(1) ) cpu_inst ( .clk (clk), .resetn (resetn), .mem_valid (mem_valid), .mem_instr (mem_instr), .mem_ready (mem_ready), .mem_addr (mem_addr), .mem_wdata (mem_wdata), .mem_wstrb (mem_wstrb), .mem_rdata (mem_rdata), .irq (irq), .eoi (eoi) );

接着用一个简单的地址译码把总线拆成“程序存储器”和“外设区”两部分。地址高几位作为片选信号,程序区放在Block RAM里,外设区映射到自定义寄存器。有几点经验值得记下来:

  • 不要把程序ROM直接放在组合逻辑里,FPGA上必须用Block RAM或分布式RAM,否则时序没法收敛。
  • mem_wstrb是字节写使能,外设接口处理时要逐字节判断,不能只看整个数据是否有效。
  • 如果不同时使用所有GPIO,irq输入必须按位拉低,悬空会导致不确定性。

5. 常见问题与调试经验实录

5.1 编译与链接问题

跑PICORV32最常见的坑集中在固件编译环节。第一个是在链接脚本里没有正确设置栈指针,导致程序一调用函数就跳飞。官方link.ld已经把所有内存段分配清楚,新手不要轻易改动,除非你清楚自己在做什么。第二个是编译C代码时忘记加-march=rv32im,默认可能生成RV64指令,PICORV32根本解不出来。第三个是中断服务函数必须用__attribute__((interrupt))修饰,否则编译器生成的函数序言和RISC-V中断约定不匹配。

我遇到过最典型的卡壳现象:CPU一直trap,且reg_retired不动。排查步骤是先确认指令是不是在C代码编译阶段就错了,把反汇编打开对比:

riscv32-unknown-elf-objdump -d test.elf

如果反汇编里看到RV64I特有的指令,比如addiw,立刻检查编译参数。如果反汇编正常,再用trap信号确认是否进入了异常处理。PICORV32的trap输出在高电平时表示CPU陷入异常,配合波形看mem_addr停在哪个地址,基本就能定位问题。

5.2 仿真与综合类问题

仿真阶段常见的现象是CPU“卡死”。基本都出在总线握手上:mem_valid已经拉高,但mem_ready始终不给。例如你用一个简单的组合逻辑设备,但设备侧响应条件里忘记加上mem_valid判断,就会导致写数据永远无法完成。

另外要注意mem_instr这个信号。PICORV32取指和load/store共用一套总线,外部存储器必须区分当前访问是指令还是数据。如果你把指令存储器和数据存储器共用一块RAM,那没问题;如果分成两个物理器件,就要用mem_instr做选择。忘了这件事,程序会神秘“跑飞”。

综合上的坑主要是乘法器资源。默认慢速乘法用移位加实现,不占DSP;打开ENABLE_FAST_MUL后,综合器可能推断出DSP乘法器,在低端FPGA上会导致DSP资源紧张。同样,BARREL_SHIFTER会显著增加LUT消耗。做资源评估时,建议分别综合一版“全关”和“全开”,对比面积再决定最终参数。

5.3 自查清单与避坑经验

调试PICORV32时,我总结了一个简洁的自查顺序,按这个顺序排查,大部分问题半小时内能定位:

症状可能原因检查方法
CPU一上电就trap启动代码异常、PC初始化错误看复位后第一条指令的地址
程序卡死在某个循环内存握手不释放、外设没有应答看mem_valid和mem_ready是否长时间不变化
中断不响应irq未拉高、中断屏蔽寄存器被置位查波形里irq输入,再查CSR中断使能
乘法结果错误ENABLE_MUL没开,或funct7处理异常换用加法指令对比,再用objdump查乘法指令编码
写寄存器不生效rd_decode错误、写回时机不对看reg_retired对应的指令地址

另外强烈推荐在调试时打开ENABLE_TRACE。它会输出trace_valid和trace_data两个信号,其中trace_data携带当前退休指令的二进制编码。配合一个小脚本解析,可以实时看到CPU执行到哪条指令。这个功能资源开销很小,但调试体验提升巨大。

还有一条经验:不要轻易改PICORV32内部的时序逻辑。它整颗CPU的时序耦合非常紧密,任何“小改动”都可能破坏状态机的平衡。如果你只是想要一个特定功能,优先通过参数和外部接口实现,不要动内部Verilog代码。源码内部的逻辑已经经过原作者和社区多年验证,改动比你想象的风险更大。

结尾

读PICORV32源码这圈子走下来,我最大的感受是:一颗CPU最核心的逻辑,真的可以做到如此精炼。它不像商业IP那样藏着掖着,所有状态机、所有握手协议、所有优化技巧都摊开在源码里,一行一行注释得明明白白。

我建议你也亲手做一遍“从源码到仿真”的完整流程。不要只满足于看文档,把Icarus跑起来,把GTKWave的波形打开,盯着mem_valid和reg_retired的变化去理解CPU的呼吸节奏。这比读十篇分析文章都有用。等你能不看注释讲清楚每一条指令是怎么从内存走到寄存器堆的,恭喜你,处理器的门就算真正踏进来了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 1:04:26

工业气体泄漏检测数据集:双模态实例分割+多级语义标注

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 1:04:02

井盖缺陷检测数据集:2890张实拍图+VOC/YOLO双格式+5类细粒度标注

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 1:03:34

Switch原生运行Wine兼容层:无需刷系统跑PC游戏实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 1:01:56

Jenkins密码重置实战:通过config.xml恢复管理员访问

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 1:01:39

常微分方程与差分方程:从欧拉法到RK4的数值求解实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华