PICORV32源码精读:从取指到写回的RISC-V软核实现剖析
如果你问我,想在今天搞明白一个CPU到底是怎么从取指到写回一步步把程序跑起来的,应该先读哪份源代码,我会毫不犹豫地说:把PICORV32打开,逐行读一遍。原因很简单——它小、完整、没有任何工业级处理器常见的花活,你看到的每一个寄存器、每一个状态、每一段组合逻辑,背后都有一个能讲清楚的道理。更重要的是,PICORV32不是一个玩具,它在真实项目里被大量用作嵌入式软核、自定义指令集实验平台、以及教学芯片设计的样例。
这篇文章是我自己把PICORV32源码从顶层到细节啃完一圈之后的笔记,不是单纯翻译代码注释,而是把它的架构思路、状态机流转、译码逻辑、参数化裁剪套路,以及集成到FPGA时容易踩的坑都串起来讲。适合刚接触RISC-V和软核的人作为第一篇源码精读,也适合已经在用PICORV32但还没完全吃透内部逻辑的开发者对照着查漏补缺。
1. 为什么PICORV32值得逐行读:小核不小,五脏俱全
1.1 一个“小处理器”到底能干什么
PICORV32是Clifford Wolf在2015年前后发布的开源RISC-V软核,完整实现了RV32IMC指令集,也就是32位基础整数指令,加上可选的乘除法扩展,再加上可选的压缩指令扩展。它没有Cache、没有TLB、没有乱序执行、没有多级流水线,整个核心只有一个文件picorv32.v,却能跑C编译器生成的普通RISC-V程序,能接中断,能和外部存储器通过简单的握手协议通信。
我第一次看到它的综合结果时有点意外:在主流FPGA上,一个带乘除法、带压缩指令、带中断控制器的配置,通常只用掉一千个左右的LUT和几百个触发器,主频可以跑到几十甚至上百兆赫兹。这意味着它塞进任何一颗低端FPGA都能轻松运行,非常适合做SoC里的控制核心、协议转换桥、或者教学用的可修改处理器。
它的设计哲学很明确——用重复的简单代替复杂。指令不并行、不前瞻、不猜测,每条指令老老实实花多个时钟周期完成,换来的是时序简单、面积极小、代码量少到可以用一个周末读完。
1.2 和市面上其它软核相比,它输在性能,赢在理解成本
很多人在选型时会把PICORV32和VexRiscv、Rocket、SERV放在一起比较。它们的定位其实是完全不同的物种,硬比性能没有意义。
| 软核 | 架构风格 | 代码规模 | 适用场景 |
|---|---|---|---|
| PICORV32 | 无流水线,多周期状态机 | 单文件数千行Verilog | 教学、小面积嵌入式、自定义指令实验 |
| VexRiscv | 5级流水线,用SpinalHDL编写 | 较大,工程化程度高 | 对性能有要求的中等嵌入式SoC |
| Rocket | 有序5级流水,面向SoC集成 | 非常庞大 | 需要运行Linux等复杂系统的应用 |
| SERV | 位串行处理器,逐个bit执行指令 | 面积极致小 | 极低功耗、极低性能的场景 |
我见过不少项目上来就想用VexRiscv,结果被流水线冒险、AXI接口、总线矩阵搞得焦头烂额;也见过用Rocket跑Linux,结果发现大部分资源都在服务操作系统而应用逻辑只占很小比重。如果你的需求是一个“可靠的、能跑裸机C代码的、方便改指令集的32位核”,PICORV32往往是成本最低的选项。
从学习角度来说,PICORV32的价值更高。CPU设计中最难理解的概念——取指、译码、执行、访存、写回、分支延迟、中断响应、乘除法迭代——在PICORV32里全部以最朴素的方式呈现出来。读懂了它,再去看那些带流水线、带分支预测的核,就很容易把复杂概念映射回已知状态机上。
1.3 源码的藏身之处与文件组织
PICORV32的官方仓库结构非常干净,核心的源代码全部集中在picorv32.v这一个文件里。文件里定义了主模块picorv32,还封装了带AXI接口的picorv32_axi模块。除了核心模块之外,仓库里通常会带上一个名为picosoc的示例SoC,演示了如何用PICORV32搭配SPI Flash、UART、GPIO组成一个能跑完整程序的单片机;还有一套testbench和测试程序,用来做回归验证。
这种“一个文件就是一颗CPU”的组织方式在实际工程里特别讨喜。集成的时候不需要在一堆工程文件里翻找依赖关系,直接把picorv32.v拷贝进工程,配置好参数就可以开始干活。修改时也只触碰一个文件,出问题方便回溯。当然,代价是它的代码结构非常密,初读时会觉得到处都有`define、generate、条件编译,不舒服但值得习惯——因为这是很多优秀开源硬件项目的通用表达方式。
2. picorv32.v的骨架:寄存器、数据通路和“单块式”设计
2.1 核心信号的“名册”
要读源码,先要认识信号。PICORV32里没有复杂的分层总线,数据和控制全部通过一组寄存器在时钟边沿处推进。下面是我读代码时整理的核心寄存器清单,了解它们各自干什么,后面看状态机会顺畅很多。
| 信号名 | 作用 |
|---|---|
reg_pc | 程序计数器,保存当前取指地址 |
reg_op1、reg_op2 | ALU的两个源操作数 |
reg_opcode | 当前正在执行的指令的特征码 |
reg_rdata | 从存储器读回的数据暂存 |
reg_rs1、reg_rs2、reg_rd | 指令中的寄存器索引 |
reg_imm | 译码生成的立即数 |
reg_mem_addr | 访存地址 |
reg_mem_wdata、reg_mem_wstrb | 写存储器的数据和字节使能 |
reg_shifter | 移位、乘法、除法迭代中的一个核心工作寄存器 |
reg_count | 乘除法以及部分多周期操作的递减计数器 |
latched_branch、latched_store、latched_load | 分支、写存储、读存储的锁存标志 |
这套寄存器几乎构成了CPU的全部内部状态。你会发现这里没有单独的指令寄存器IR,也没有流水线寄存器组,因为无流水线状态机只需要在正确的时间点把数据锁存下来就够了。
2.2 数据通路:一条指令如何流过这颗芯
PICORV32的执行过程是一条清晰的链路:取指、译码、读寄存器、执行、访存、写回。但是因为没有任何流水线重叠,整个过程是在同一个大状态机的控制下顺序完成的。
以一条简单的add指令为例,它大概会经历这样几个阶段:
- CPU先把
reg_pc放到存储器接口上,请求取指,等待mem_ready返回; - 取回指令后,译码逻辑识别出opcode、funct3、funct7、寄存器编号、立即数字段;
- 从寄存器文件读出源操作数,放入
reg_op1和reg_op2; - ALU根据解码结果完成加法,结果准备写回
reg_rd; - 在写回周期更新寄存器文件和
reg_pc指向下一条指令。
整个过程中,那个“大状态机”并不像教科书里那种STATE_FETCH / STATE_EXECUTE / STATE_WRITE_BACK的显式状态编码,而是用若干latched_*信号表达“现在正处于哪个子阶段”。这是PICORV32代码不容易一眼看懂的地方,但也是它代码紧凑的原因。
2.3 集中式always块:可综合风格的最高效选择
PICORV32的核心逻辑几乎全部写在一个带时钟和复位的同步always块中。这种写法在学术风格的Verilog代码里不多见,因为多数教材会教你拆成always @(*)和always @(posedge clk)两个部分,用显式状态机分开控制和数据。
集中式写法的优点很明显:所有寄存器在同一时刻统一更新,天然避免了多进程写法中常见的信号竞争和赋值顺序问题;综合器把它看作一个大FSM加数据通路,优化起来反而更主动。代价是代码不直观,初读者容易迷失在一个几百行的always块里分不清哪个分支是取指、哪个分支是写回。
我的读法建议是:先别着急扣细节,把latched_*信号当成状态变量来标记进度,再看reg_pc在哪些条件下变化,理解“当前处在哪个环节”比理解每一行的组合逻辑要重要得多。
3. 状态机:无流水线的CPU如何稳定走完每条指令
3.1 取指循环:指令是怎么被“喂”进来的
PICORV32的取指逻辑非常直白。CPU把reg_pc送到存储器地址线上,同时把mem_valid拉高,表示这是一次有效的读请求。外部存储器或总线在准备好数据后,把数据放在mem_rdata上并拉高mem_ready。CPU在时钟边沿看到mem_ready=1之后,才把mem_rdata锁存为当前指令,然后进入译码阶段。
整个过程中最关键的理解点是:取指至少需要两个时钟周期,一个周期发出请求,一个周期收回数据。如果存储器很慢,CPU就一直停在“等待取指返回”的位置,不做任何其他事。这也是无流水线处理器的典型特征——时间开销全部串行化,简单但慢。
3.2 分支与跳转:为什么要花掉额外周期
分支指令在PICORV32里占据的执行路径相当有代表性。当译码发现一条beq、bne这类分支指令时,CPU先正常读取源操作数,在ALU里比较两个数是否相等或不等,然后在同一个周期里根据比较结果决定“是否锁存分支”。
这里的核心机制是latched_branch。如果分支条件成立,CPU会把目标地址锁存起来,随后的取指阶段直接从锁存的目标地址开始取指;如果条件不成立,则像普通指令一样继续顺序执行。由于这个决策发生在execute阶段,分支指令要额外占用周期来等待操作数、完成比较、再进入下一轮取指,所以一条分支指令的总开销大约在三个周期以上。
用打比方的方式理解它:无流水线CPU处理分支,就像你走到岔路口停下来看地图再选择方向,而带预测的流水线CPU则像导游提前带着队伍往一个方向走,万一走错再折返。PICORV32选择了最稳妥也最慢的那种。
3.3 存储和加载:mem_valid/mem_ready握手的意义
访存指令是PICORV32里交互最丰富的一类。先说读:lw指令译码后,CPU把计算出的地址放到reg_mem_addr,在总线接口上呈现mem_valid=1和读取地址,同样等待mem_ready。数据返回后先暂存在reg_rdata里,再按字/半字/字节的宽度对齐,最后写回目标寄存器。
写比读稍微复杂一点。sw指令需要同时输出地址、写数据和字节使能。PICORV32的写数据是reg_mem_wdata,字节使能reg_mem_wstrb用来指示当前写哪几个字节。之所以在写路径上再加一个锁存状态,是为了保证在mem_ready为低的时候写数据和地址一直稳定,不会因为后续指令的推进而改变。
整个握手协议其实只遵循一个朴素原则:主设备发出请求,从设备准备好后应答,主设备再继续。别小看这个协议,PICORV32能轻松挂接SRAM、SPI Flash、片上外设,甚至AXI总线,靠的就是这个统一的mem_valid / mem_ready接口。
3.4 乘除法指令:迭代实现的“笨办法”
M扩展指令是PICORV32里性能差距最大的一类。如果不启用硬件乘法器参数,乘法、除法、取模全部用迭代算法完成,一条32位乘法或除法指令需要消耗三十多个时钟周期。这对于控制类程序来说通常可以接受,但如果你在循环里做大量乘法运算,性能会明显拖后腿。
迭代实现的思路其实很经典:乘法用移位加,除法用移位减,每周期处理一个bit。代码中的reg_shifter在这里充当被乘数、商、余数的工作寄存器,reg_count充当递减计数器。从阅读角度来说,追踪reg_count从高到低递减的过程,就等于看到了迭代循环的推进轨迹。
PICORV32提供的PICORV32_ENABLE_MUL_HARDWAREMUL参数可以替换乘法实现为组合逻辑乘法器,让乘法在几个周期内完成;PICORV32_ENABLE_MUL_FASTDIV则优化除法器的移位步进。需要注意的是,这些优化以增加组合逻辑深度为代价,如果你追求高频时序,可能需要评估权衡。
4. 译码器与立即数:理解RISC-V指令集的一个窗口
4.1 RISC-V指令分类在PICORV32中的落地
RISC-V指令集在编码上有一个很友好的特性:指令类型清晰、opcode字段固定,译码器不需要像x86那样逐字节猜边界。PICORV32正是充分榨干了这种友好性。
在代码里,你会在localparam定义中看到大量CPU_OPCODE_*常量,比如CPU_OPCODE_LOAD、CPU_OPCODE_STORE、CPU_OPCODE_BRANCH、CPU_OPCODE_JALR、CPU_OPCODE_OP_IMM、CPU_OPCODE_OP等等。译码器的核心就是一个基于reg_opcode的case语句,按照RISC-V的opcode把指令粗分成几大类,再根据funct3/funct7细化为具体操作。
这种“两层译码”结构的优势在于复用性极高。同一条加法执行通路,既服务于add指令,也服务于lw、sw、jalr的地址计算,因为它们的核心操作都是“加法”。PICORV32把这种通用ALU操作集中在一个模块里,整个译码逻辑永远不会因为新增一条需要简单算术支持的指令而大改。
4.2 立即数的生成:不同格式如何统一成32位
RVC压缩指令、标准指令、跳转指令、访存指令里的立即数格式各不相同。RISC-V的优势在于这些立即数本质上都是从固定的bit位置抽取若干位然后做符号扩展,所以PICORV32在译码阶段会一次性组合出一个统一的32位立即数reg_imm,后续执行单元不再关心指令类型。
阅读这部分代码时,我建议按这样的顺序理解:
- 先看标准R型、I型、S型、B型、U型、J型六种格式的bit布局;
- 再对照代码中如何用几个slice和
|运算拼出中间值; - 最后看符号扩展怎么把12位、20位立即数补充到32位。
一旦理清这个映射,你会觉得RISC-V指令集编码是真心为译码器省面积。
4.3 自定义指令区:PICORV32最有意思的扩展点
PICORV32在标准RV32I之外留出了自定义指令空间,也就是custom0、custom1、custom2这组opcode区。这一组指令空间让开发者可以在不修改工具链的情况下注入专用指令,配合PCO协处理器接口甚至可以加速特定运算。
举个例子,如果你希望CPU带一条硬件计算min的指令,理论上可以在自定义指令区定义一种编码,让它在译码阶段被识别为一次ALU操作,执行阶段直接输出(op1 < op2) ? op1 : op2。这种改法不需要改动RISC-V工具链,因为程序里可以用汇编伪指令或者内联汇编发出对应的原始编码。
此外PICORV32还有一套PICO专用指令,比如SETQ和GETQ。它们的作用是向一个小型队列寄存器写入或读取数据,再配合协处理器接口传递到外部硬件。很多基于PICORV32做DSP或通信加速的项目,正是用这套机制把CPU和硬件加速器之间的握手开销降到了最低。
5. 参数化配置:一个核适配多种场景
5.1 关键参数的底层影响
PICORV32的灵活性在很大程度上来自顶部的一组参数。修改这些参数会直接改变综合出来的硬件规模和行为,读代码时一定要顺带理解它们的开关在哪里生效。
| 参数 | 作用 | 使用建议 |
|---|---|---|
RISCV_ISA_M_EXTENSION | 启用乘除法指令 | 需要乘法时打开,面积略增 |
RISCV_ISA_C_EXTENSION | 启用压缩指令 | 降低代码体积,增加译码复杂度 |
RISCV_ISA_E_BASE | 嵌入式模式,只保留16个寄存器 | 追求极致面积时用 |
PICORV32_CATCH_MISALIGN | 响应非对齐访存 | 调试时打开,生产环境可按需关闭 |
PICORV32_CATCH_ILLINSTR | 响应非法指令 | 裸机调试强烈建议打开 |
PICORV32_ENABLE_MUL_HARDWAREMUL | 用硬件乘法器替代迭代乘法 | 性能优先时打开 |
PICORV32_ENABLE_MUL_FASTDIV | 优化除法速度 | 除法频繁时打开 |
PICORV32_ENABLE_IRQ | 使能中断控制器 | 需要实时响应时打开 |
PICORV32_ENABLE_IRQ_QREGS | 中断队列寄存器扩展 | 需要嵌套中断时打开 |
PICORV32_MEM_EXTRA_STATEBITS | 扩展访存状态位 | 总线延迟较长时使用 |
这些参数在源码中大多通过generate或条件编译来影响最终生成的结构。比如乘除法扩展打开时,才会生成reg_count和迭代逻辑;关闭时,这部分电路整体被优化掉,面积差异非常明显。
5.2 中断:无特权模式下如何实现快速响应
PICORV32的中断控制器不走RISC-V标准的机器模式中断机制,而是一个轻量级的自定义实现。它没有CSR寄存器组,不区分特权等级,甚至不需要复杂的mtvec配置,而是把传入的一组IRQ请求映射到固定处理入口,并用一个简单的中断屏蔽寄存器控制是否响应。
中断响应时,CPU会记录当前PC值作为返回地址,然后跳转到中断服务程序入口。PICORV32_ENABLE_IRQ_QREGS打开后,控制器维护一个小型队列,可以同时记录多个待处理的返回地址,这让它具备了一定的中断嵌套能力。
对于小系统来说,这套机制已经足够实用。我在集成过时传感器轮询、网络协议栈这类实时任务时,都靠它完成快速上下文切换,而不用引入整套RTOS。如果你需要标准的中断控制器行为,PICORV32的这套轻量方案反而不合适,建议外挂标准中断控制器,或者换用配置了完整CSR的核。
5.3 面积与性能的权衡:什么时候该忍住不换核
综合之后,PICORV32的面积数字相当漂亮。基础配置在主流7系列FPGA上大约只需要几百个LUT和几百个触发器;启用压缩指令、乘除法、中断控制器之后,也基本维持在一千多个LUT的水平。这直接意味着,时序收敛很容易,资源别的地方随便用。
但它的性能上限就摆在那里。因为每条指令平均要花三到五个周期,主频即使能跑到100MHz,实际吞吐也比不上一颗简单的三级流水线软核。
我的建议是:评估项目时先算清楚每条热路径指令的需求,如果应用主要在做状态控制、寄存器读写、低速通信协议,PICORV32绰绰有余;如果涉及大量乘加运算、数据搬运,或者打算跑Linux这种重量级应用,就果断换VexRiscv甚至Rocket,不要在PICORV32上强行优化。
6. 跑起来和改起来:PICORV32的实际落地经验
6.1 在仿真环境里跑通一条程序
官方仓库提供了完整的testbench环境。简单来说,你需要准备一个RISC-V工具链(建议用带-march=rv32imc支持的GCC),把C代码或汇编编译成elf或hex格式,加载到testbench的存储器模型里,然后跑仿真。
仿真有个很有用的点:testbench会逐条记录指令执行状态,任何异常PC、非法指令、总线挂死都会立刻暴露。我调试自定义指令时,几乎全靠testbench在波形里观察reg_op1、reg_op2和写回数据的变化,不需要在FPGA上反复烧录。
6.2 FPGA集成时的四个经典坑
把PICORV32接进真实SoC时,我踩过一些值得记录的坑。
第一是mem_ready时序。如果把PICORV32直接接片上RAM,RAM的读延迟和mem_ready的时序必须严格对好,否则偶尔会出现取指数据错位。用Block RAM时,建议在RAM输出侧打一拍,保证mem_rdata稳定后再被锁存。
第二是复位信号。PICORV32支持同步复位,但复位释放时机如果和时钟边沿太近,会偶发PC不定状态。稳妥做法是使用外部复位芯片的干净复位信号,尽量不要用逻辑产生的毛刺复位。
第三是中断的粘性问题。外部IRQ如果是一个持续时间较长的高电平脉冲,而CPU尚未开启中断使能,这个脉冲可能在使能打开之后被误判为新中断,导致进入错误的中断上下文。处理方式是在中断源侧做边沿检测,或者确保中断信号宽度符合预期。
第四是启动地址。上电后PC从固定地址进入,这个地址通常需要放一条跳转指令到真正的初始化代码。如果从SPI Flash启动,还要在Flash里准备好对应的启动头。很多时候仿真能跑、上板不行,问题就出在启动引导阶段。
6.3 亲手加一条自定义指令的完整路径
为了演示PICORV32的扩展能力,我拿一个具体例子说明:给CPU增加一条自定义指令,作用是计算两个寄存器中较小值并写回目标寄存器。
实现的路径大致如下:
- 在
custom0指令区选取一个未占用的编码空间,确定funct7和funct3的组合; - 在译码case中新增分支,让这条指令像普通R型指令一样读取两个源寄存器并生成写回目标;
- 在ALU执行逻辑中增加一个
min判断分支,输出比较结果; - 在testbench里构造一组测试数据,验证结果正确性。
整个过程不需要改动工具链,因为我们可以直接在汇编中用.word或者内联汇编发出自定义编码。但这里要提醒一句:自定义指令会让代码彻底绑定PICORV32,换其它RISC-V核就跑不了。所以做之前先想清楚,是将其作为底层硬件抽象,还是仅仅为了验证某个加速思路。
我在一个电机控制项目里就加过类似的自定义指令,用来在单周期内完成位置误差钳位,省去好几条比较和分支指令。效果很明显,但也因此把控制代码锁死在了这颗核上。后来硬软件解耦时花了不少功夫把指令替换回标准函数。这种权衡,做之前一定要有数。
另一个实际经验是:读PICORV32源码不要只盯着picorv32.v单文件,把picosoc示例SoC一起看了,才能理解它和外部存储、外设、中断源怎么配合。源码里有些信号在纯核仿真里根本用不到,只有放在SoC上下文里才明白为什么作者要多留那些状态位。
如果你把这个核读透了,我建议下一个目标可以去看VexRiscv的流水线实现,重点对比“无流水线状态机”和“流水线冒险处理”的差别——这时候你会发现,因为PICORV32已经把指令生命周期讲得足够清楚,再看流水线就很容易理解哪里需要前递、哪里需要停顿、哪里需要冲刷。我当年就是沿着这条路线,从PICORV32起步,一步步啃下了更多复杂处理器源码,后来设计自己的小核心时,脑子里最早浮现的框架仍然是PICORV32这套简单可靠的状态机逻辑。