ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PICORV32软核源码精读:从状态机到总线握手与配置裁剪

PICORV32软核源码精读:从状态机到总线握手与配置裁剪 1. PICORV32到底是个什么水平值得逐行读的三个理由PICORV32这个项目我第一次认真读它代码的时候其实是带着一点怀疑的。一个CPU核核心实现就压在一个picorv32.v文件里加起来大概也就三百行左右的always块这能做成什么后来我把它综合到一块小规模FPGA上跑通了裸机串口打印、定时器中断、甚至用PCPI接口挂了一个自定义协处理器指令我确实服了。如果你在搜索PICORV32源代码分析多半已经知道它是个开源的RISC-V软核是Clifford Wolf的作品License本身也相对宽松。但我先给个更实在的定位PICORV32是一个刻意追求极小面积、极简实现的RV32I核心它可以裁剪到只保留整数指令也能通过配置打开RV32M乘除法、RV32C压缩指令、中断控制器、CSR寄存器甚至带一个名为PCPI的协处理器扩展接口。它跑不了Linux也不追求高主频但它是目前我见过的、最适合拿来一行一行读的CPU源代码。为什么值得逐行读第一它的代码量真的可控。市面上主流的开源软核像Ibex前身是Zero-RISCY动辄好几千行SystemVerilogVexRiscv虽然模块化做得很好但它用SpinalHDL写的你要先学一门新语言才能谈得上阅读源码。而PICORV32的整个处理器核心就集中在一个Verilog文件里状态机、数据通路、寄存器文件、乘法除法器、中断逻辑全都能在一个编辑器窗口里翻完。这对想通过源码搞懂CPU工作机制的人来说太友好了。第二它是一个真实可用的核。这不是教学玩具它有握手协议完整的内存接口、可以挂外部RAM/ROM、支持中断向量、支持CSR读写甚至有人把它做到MCU级别的SoC里跑小系统。用最小配置综合出来资源占用只有几百个LUT量级在现代FPGA里就是一个角落的事。也就是说你读完代码之后得到的是一颗真能在板子上跑程序的CPU而不是纸上谈兵的PPT架构。第三它的设计哲学和商业IP完全不同。商业CPU核的代码你通常拿不到拿到了也未必许可你去理解而PICORV32是把面积优先、逻辑优先做到极致的一个样本。读它的时候你能清清楚楚看到作者在每个周期里怎么省一个寄存器、怎么把一个加法器拆成两拍来降低关键路径。这些思考方式恰恰是很多科班教材不会教、但在真实工程里最值钱的东西。这篇文章我会沿着一条我从下载源码到跑通仿真再到挂上外设的实际路径来展开把整个源码分析过程里最关键的几个节点讲透。适合三类人读想在FPGA里塞一个软核做控制的硬件工程师想搞懂RISC-V处理器内部结构的在校学生以及准备基于PICORV32做自定义协处理器或者SoC集成的开发者。2. 先画地图再读源码picorv32.v的模块与接口划分2.1 整个CPU其实只有两个子模块第一次打开源码千万别急着从上往下读那样很容易被巨大的always块淹没。PICORV32整个顶层叫picorv32但往下翻你会发现它内部的实现主要分成两个子模块picorv32_alu和picorv32_cpu。可以这样理解picorv32_alu就是那个算数的零件它负责加法、减法、移位、与或异或这些运算而picorv32_cpu是总指挥它决定当前周期去取指令还是去执行指令把操作数送给ALU再把结果拿回来写进寄存器堆。顶层picorv32则把这两个零件组装起来并把所有的输入输出引脚时钟、复位、内存接口、中断、PCPI等暴露出去。我的建议是第一次读不要管顶层实例化的语法细节先去看picorv32_cpu里的主状态机。那是一个巨大的case (cpu_state)里面包含了取指、译码、执行、访存、写回等所有阶段。把这一段画成流程图整个CPU的骨架就出来了。2.2 顶层IO信号按功能分组源码的端口列表看起来很长但实际上一共就那么几组。我习惯把它按功能分开记时钟复位与状态相关clk、resetn全局时钟和低电平复位。注意PICORV32是同步复位设计resetn必须保持足够长的时间。trap当CPU执行到非法指令或错误的对齐访问时拉高一个周期是调试时非常重要的信号。内存访问接口mem_validCPU请求一次内存读或写。mem_addr访问地址。mem_wdata、mem_wstrb写数据和字节写使能4位对应4字节。mem_rdata读数据返回。mem_ready外部设备说数据给你了或者写完成。协处理器接口PCPIpcpi_valid、pcpi_insn、pcpi_rs1、pcpi_rs2、pcpi_wr、pcpi_rd、pcpi_wait。这组信号用来把一条自定义指令外包给外部逻辑去执行后面我会单独讲。中断接口irq_i、eoi、irq_mask这些信号用于外部中断输入和中断结束响应。irq_i在简单MCU场景里可以接定时器分频后的脉冲。把这些接口按功能分组之后再去读源码就会轻松很多。你不需要每个信号一上来就搞懂只要在遇到对应逻辑时知道它属于哪一条业务线即可。2.3 我推荐的一条读码路线如果你之前没有读软核源码的经验我特别建议按下面这个顺序走先看参数列表parameter和localparam了解这个版本支持哪些可配置项。找到cpu_state相关的状态定义把状态名抄下来。从STATE_IF开始读一路看取指、译码、执行、访存、写回。这一遍不追求看全只追求理解主流程。等主流程通了再回头看ALU怎么算、PCPI怎么握手、中断怎么响应。最后再回到顶层看我在什么条件下拉trap、怎么生成mem_valid。这样读下来快的话一个晚上能把主流程弄明白。慢一点也没关系这个过程本身就是最有价值的部分。3. 状态机与数据通路一条ADD指令在PICORV32里的完整旅程3.1 核心状态机拆解picorv32_cpu内部最核心的东西就是那个状态机。不同版本的源码里状态命名可能稍有差异但万变不离其宗核心状态大致是这样的状态主要职责典型动作取指IF发起取指访存把PC输出到mem_addr拉高mem_valid译码ID读指令、读出寄存器操作数从寄存器堆读取rs1、rs2执行EX送入ALU计算把操作数喂给ALU得到结果访存MEM处理load/store数据写入或读出内存写回WB把结果写回寄存器堆更新regs[rd]注意一点PICORV32并不是教科书里那种五级流水线它更像一个多周期状态机 部分前推逻辑的组合。很多状态下它会在同一个时钟周期里做两件事——比如在取指阶段同时把PC自增在执行阶段提前准备好下一个周期的写回信号。这种设计在低面积CPU里非常常见也是它能保持代码精简的根本原因。3.2 一条ADD指令的逐拍旅程以一条最普通的指令add a0, a1, a2为例我们看它到底是怎么在PICORV32里走完的。指令格式是add rd, rs1, rs2对应的机器码会放在指令存储器的某个地址上。第一拍状态机处于取指状态。CPU把当前的PC值放到mem_addr上同时拉高mem_valid告诉外部存储器我要读这个地址的指令。如果外部返回mem_ready这一拍结束之后指令数据就被锁存到内部的指令寄存器里PC也加4。第二拍状态机进入译码状态。CPU从指令字里拆出rs1、rs2、rd字段然后从寄存器堆里读出操作数。这个寄存器堆在源码里就是一个reg [31:0] regs [0:31]的二维数组零寄存器写入被屏蔽读的话组合逻辑直接输出对应位置的值。第三拍执行阶段。操作数被送进picorav32_alu根据当前的控制信号做加法得到一个32位结果。这个结果会放在内部总线上接下来可能被写入寄存器堆也可能被用作访存地址。第四拍写回阶段。如果这条指令需要写寄存器堆那在这一拍的时钟上升沿把结果写入regs[rd]更新完毕。问题是这些拍数并不是一成不变的。如果在这条ADD之后紧跟着一条需要读内存的LOAD指令PICORV32会多等一拍或者两拍因为在非流水结构里访存和写回之间需要让数据先稳定下来。如果你在波形里盯着cpu_state看会看到状态从IF跳到ID、再从ID跳到EX偶尔还会多出来几个专门用于等待内存响应的中间状态。3.3 那些多出来的周期才是精华初看PICORV32源码的人往往有个疑惑为什么一个指令要拆这么多拍为什么乘法要用那么多周期答案其实很简单一切都是为了省面积、省寄存器。举几个例子。在没有TWO_CYCLE_MULTIPLIER之类优化的时候乘法器可以做成纯组合逻辑一个周期出结果但面积大也可以做成移位累加的时序乘法器每个周期算一位32位乘法要跑三十多个周期。PICORV32的默认配置甚至允许你通过参数去选。如果你在源码里搜索移位有关的逻辑你会发现它甚至把右移做成一个每个周期移一位的状态序列——代价是慢但好处是硬件逻辑极省。这种用周期换面积的取舍恰恰是PICORV32和大型乱序CPU最本质的区别。读代码的时候我强烈建议你每遇到一个latency或者extra cycle相关的信号都停下来想想作者为什么要这么干。想通了你对整个CPU设计的理解就翻了一倍。4. 那些可以裁剪的宏配置每个参数背后对应源码的哪一块4.1 常用配置项一览PICORV32的核心裁剪能力来自于顶层参数的传递。在源码文件头部你会看到大量的parameter声明。下面是我常用的几个配置项和它们对应的代码区域参数名作用影响区域ENABLE_MUL打开乘法指令mulALU和乘法器生成块ENABLE_DIV打开除法/求余指令移位除法状态机COMPRESSED_ISA支持RVC压缩指令取指阶段的指令预解码ENABLE_IRQ支持中断中断状态机与irq_i处理ENABLE_CSR支持CSR指令读写专门的CSR数据通路TWO_STAGE_SHIFT移位拆成两拍缩短关键路径ALU内部TWO_CYCLE_ALUALU结果晚一拍输出执行阶段写回时序LATCHED_MEM_RDATA锁存内存读数据访存阶段与mem_rdata采样TWO_CYCLE_COMPARATOR比较器拆两拍分支判断逻辑这些参数大多数在源码里对应着generate if这种结构。比如你在顶层看到if (ENABLE_MUL) begin : gen_mul这样的块里面的逻辑只有在打开宏的时候才会被综合出来。4.2 从最小RV32I开始学我特别推荐的做法是先配置出一个最小的RV32I核心所有扩展全部关掉比如用类似这样的参数组合ENABLE_MUL 0 ENABLE_DIV 0 ENABLE_IRQ 0 COMPRESSED_ISA 0这样综合出来的核心只支持基础整数指令、load、store、分支跳转和一些系统指令。拿这个最小配置去读状态机最合适——因为你不用担心乘除法那堆中间状态干扰主线。等主线吃透了再一个一个打开其他配置观察每个配置给状态机新增了什么状态、给数据通路增加了哪些分支。这样做还有一个好处调试的时候问题容易收敛。如果你一上来就打开全部功能遇到波形异常时你很难分清是乘法器的问题、中断的问题还是主状态机的问题。4.3 实际裁剪时的面积与频率经验我自己在一个测试项目里对比过不同配置的资源占用。大致规律是基础RV32I的资源占用很小在常见的Lattice或Xilinx小器件上LUT消耗就是个零头打开乘法和除法之后面积会涨一块但还在可接受范围再打开中断、CSR、压缩指令集面积会进一步上升但最直接的压力往往是组合逻辑路径变长导致最高频率下降。如果你是第一次在FPGA里集成软核我的建议是别一开始就追求满配置。先用一个最小核把复位、取指、跑一个点灯程序跑通了再逐步加功能。这样做每次综合后如果出了时序问题你都能清楚地知道是谁造成的。5. 内存、总线与外设扩展mem握手协议的精髓5.1 valid/ready握手的时序真相PICORV32的访存接口是它最实用的部分之一。接口遵循一个非常简单的握手规则CPU在需要访问时把mem_valid拉高同时把地址放到mem_addr上。外部设备可以决定何时完成操作只要在完成那个周期把mem_ready拉高即可。读操作mem_ready拉高的那个周期CPU采样mem_rdata。写操作mem_wdata与mem_wstrb一直保持直到mem_ready拉高表示写入完成。这套协议最大的好处是外部设备不着急的话可以不立刻置高mem_readyCPU就会乖乖等在那里不往前走。如果你的外设慢比如接了SPI Flash或者I2C你可以用这个信号做流控。反过来如果你需要和另一个高速模块做数据交换你也完全可以做到上一拍发请求、下一拍收数据。读它的时候建议多想想AXI-Lite里的AWVALID/AWREADY、WVALID/WREADYWishbone里的STB/ACK它们本质都是同一类握手思想。PICORV32把这种思想简化到了一个极其容易看懂的程度作为总线协议入门材料比直接看AXI官方手册友好一百倍。5.2 免不了要处理的MCU地址映射问题PICORV32本身不定义地址映射它不关心你是把RAM放在0x00000000还是0x10000000。在设计SoC时你需要在这个接口后面自己做一个地址译码器低位给RAM高位给外设寄存器这就是一个最简单的memory map。实际使用时我习惯把地址空间大致切成这样0x00000000到0x0000FFFF指令数据RAM0x40000000到0x400000FFUART等外设寄存器0x80000000以上保留给特殊功能比如DMA或自定义加速器外设端的Verilog可以用一个极小的状态机来接PICORV32的写请求检测到mem_valid且mem_addr落在自己的地址范围且mem_wstrb非零时把对应寄存器的某个字节更新然后拉一个周期的mem_ready。读同理把寄存器值放到mem_rdata上并拉一个周期的mem_ready。注意mem_wstrb是4位的如果外设寄存器只支持32位整字访问那么你只需要保证程序里不出现非对齐或字节级访问的问题即可。但如果你要支持字节操作外部逻辑要自己按mem_addr[1:0]和mem_wstrb拆字段这一点很容易写错建议参照PICORV32的例子代码来写。5.3 一个实际外设的挂载例子我早期做过一个最简单的UART输出功能就挂在这个接口上。写发送字符时用状态机检测写请求把mem_wdata[7:0]锁存到发送缓冲然后以波特率时钟分帧送出去。发送忙时我把mem_ready保持为0这样CPU的写指令就会被卡住直到字符发送完。这个做法虽然牺牲了CPU利用率但代码极简单非常适合验证整套通路是否畅通。等我需要更高效的处理时再改成发送FIFO中断的结构外设里放一个8深度的FIFOCPU写数据进FIFO时立即拉高mem_ready返回FIFO空了之后拉高irq_i让CPU再补数据。这里我遇到的最大坑是FIFO空标志和irq_i的组合时序没处理好导致中断反复触发。后来我在中断服务程序里先读一个状态寄存器确认原因再决定是否发送问题就解决了。PCPI接口也是类似思路它让你在同一个指令流里插入完全自定义的硬件指令。你可以把一段算法做成一个组合逻辑块然后通过一条CPU指令触发结果再返回到寄存器堆。这个机制在加速某些固定算法时特别有用而且它的握手协议只有pcpi_valid、pcpi_rd、pcpi_wr等几个信号自己写一个扩展模块也不需要太多代码。6. 跑起来才有感觉仿真验证、波形观察与三个常见卡点6.1 用最小工程把它跑起来源码到手之后我先建议你把它跑进仿真里。PICORV32官方仓库带了不少仿真和示例但你也可以自己搭一个极简testbench。下面是我常用的方式iverilog -g2012 -o picorv32_tb.vvp \ picorv32.v \ testbench.v vvp picorv32_tb.vvptestbench里要做的事情很简单产生时钟先让resetn保持低电平若干周期再释放然后给CPU一块RAM让它从0x00000000开始取指执行。哪怕只放一条跳转到自身的死循环指令只要能观察到取指与访存握手就已经证明CPU的核心逻辑是通的。如果你没有现成的testbench可以用官方仓库里sim目录下的测试文件或者自己写一个简单的指令ROM。稍微花十分钟把仿真环境跑通比盯着代码干想效率高得多。6.2 波形里重点看什么跑起来之后用GTKWave打开VCD文件时我一般会抓下面几根信号cpu_state时刻知道CPU处于哪个阶段。mem_valid、mem_ready、mem_addr、mem_wdata、mem_wstrb验证总线握手。regs[0]到任意几个寄存器看指令有没有真正写入。trap如果这个信号拉高通常说明出问题了。看一条ADD指令的波形你会很直观地看到cpu_state的状态跳转以及mem_valid的几个脉冲。然后你再开一条LOAD指令来对比很快就会发现多出来的等待周期。用这个方法即便不去逐行读源码也能通过波形把CPU行为摸到七成。6.3 三个常见卡点及排查思路第一个卡点复位释放后CPU完全没有动作。这种情况十有八九是resetn没有保持足够长的有效时间或者外部RAM接口没有返回mem_ready导致取指状态一直卡在等待上。第二个卡点程序跑飞或者trap立即拉高。往往是内存地址映射没对上CPU第一条取指地址不是你的代码存放地址所以读回来一堆全零或者未知数据被当成非法指令触发了trap。第三个卡点无法正常写外设。排查时先看有没有把mem_valid和地址译码逻辑接对外设再看外设有没有在写请求到来时及时拉mem_ready。我遇到过好几次同事把mem_ready写成常高导致握手完全失效但程序从RAM取指又看起来正常的情况——因为RAM响应快握手少几个周期不影响功能但外设时序崩了以后就非常难排查。好的习惯是所有外设都遵循mem_ready在写数据被确认锁存后才拉高的原则不要偷懒。除了这三个卡点我还要特别提醒一句如果你使用LATCHED_MEM_RDATA配置读数据会在内部被锁存一拍外设无需一直保持mem_rdata但前提是你必须搞清楚自己的外设是同步输出还是异步输出。不同配置下挂同一块SRAM的表现会不太一样踩过一次坑之后你就会明白什么叫配置也要对应起来看。我自己后来每次做软核相关的项目都会先搭一个最小仿真环境把主状态机和内存握手波形的标准长相截图存下来。以后凡是说程序跑不动我先比对这个基准波形五分钟内就能判断问题出在复位、内存还是外设。这个习惯帮我省了无数个小时的调试时间。
返回列表