picoRV32 RISC-V软核在PYNQ-Z2上的SoC搭建与上板调试
简介:面向计算机组成原理与嵌入式系统课程的学习者,这份综合实验报告以开源RISC-V核picoRV32为核心,讲解如何在PYNQ-Z2开发板上搭建完整的SoC并完成软硬件协同验证,适合正在做硬件结构设计课程设计、需要参考完整设计流程与报告写作范式的本科生。压缩包内为1个docx文档,约3.01MB,共29页,含实验说明与要求、PYNQ框架介绍、硬件与软件设计方案、模块设置与IP核包装、IP互联与比特流生成、oDisk文件部署及RISC-V功能验证等章节,并附运行结果分析与小组实验心得。已有1582人学习。读者可据此掌握嵌入式系统设计流程、AXI总线互联、交叉编译工具链使用与Jupyter Notebook调试方法,也可借鉴报告结构与图表组织方式。
1. 第一次把 picoRV32 塞进 PYNQ-Z2:这颗 RISC-V 软核到底能干什么
实验室里最尴尬的一刻,是 Vivado 的综合进度条走到 100%、板子上的灯也亮了,然后你不知道下一步该敲什么。这份 29 页的《硬件结构设计》综合实验报告,接的正是这个断点:把开源 RISC-V 核 picoRV32 塞进 PYNQ-Z2 的可编程逻辑里,配好存储器与外设,搭出一个真能跑 C 代码的 SoC,再把编译、加载、验证整套动作收进 Jupyter Notebook 的一个单元格。
它适合两类人:学过数字电路、想第一次把软核真正跑起来的新手;以及在 FPGA 上做 RISC-V 加速器原型、又不想从零写 SoC 的熟手。报告是文档,价值在于那条从 Vivado 工程到板子上电、再到串口输出的完整路径——踩过的坑都写在里面。
2. 选型拆解:picoRV32、PYNQ、Zynq-7000 各自管哪一段
这套方案能成立,关键不在某一颗芯片多强,而在于三层的职责切得干净:PL 里跑处理器和总线,PS 里跑 Linux 和 Python,浏览器里写代码。选型一旦错位,后面每走一步都得回头补课。先看内核本身的可配置项,这决定了你搭出来的 SoC 是省面积还是拼性能。
2.1 picoRV32 的配置项与三档资源取舍
picoRV32 实现的是 RV32IMC 指令集,但它是编译期裁剪的:ENABLE_MUL、ENABLE_DIV、BARREL_SHIFTER、COMPRESSED_ISA、ENABLE_PCPI、ENABLE_IRQ这些宏决定最终综合出什么。原文给的实测数据很直观——ENABLE_MUL打开后一条 MUL 要 40 个周期,DIV 同样约 40 周期。这不是性能缺陷,是拿周期换 LUT 的典型做法,对辅助处理器场景完全够用。
| 参数 | 取值 | 对资源/时序的影响 |
|---|---|---|
| ENABLE_MUL | 0 / 1 | 打开才有 RV32M 乘法,约 40 周期完成 |
| ENABLE_DIV | 0 / 1 | 除法器,约 40 周期 |
| BARREL_SHIFTER | 0 / 1 | 单周期桶形移位,省掉则移位退化成多周期 |
| COMPRESSED_ISA | 0 / 1 | 支持 RV32C,代码密度明显提升 |
| ENABLE_PCPI | 0 / 1 | 打开协处理器接口,能挂自定义指令 |
| TWO_CYCLE_ALU | 0 / 1 | ALU 拆两拍,时序更好、面积更小 |
| TWO_PORT_REGFILE | 0 / 1 | 双端口寄存器堆,性能优先;单端口更小 |
原文把配置分成三档:small 关掉计数器类指令模块、不做两周期变化、mem_rdata外接锁存、也不处理非对齐访问和非法指令;regular 是默认配置;large 则把 PCPI、IRQ、MUL、DIV、BARREL_SHIFTER、COMPRESSED_ISA 全开。
我的建议是第一次上板别贪,先用 regular 档跑通再逐个加。TWO_CYCLE_ALU 这个选项容易被忽略,但它对时序收敛影响很大——原文提到picorv32_axi打开 TWO_CYCLE_ALU 后,在 Artix-7T、Kintex-7T、Virtex-7T 以及 UltraScale 系列上做过布局布线验证,用二分搜索找满足时序的最短时钟周期。换句话说,如果你的设计在 100MHz 附近反复报时序违例,先别怀疑约束写错,把这个开关打开试试。
另有一个必须提前定的选择:用picorv32还是picorv32_axi。前者是简单的本机存储器接口,地址、数据、写掩码直接暴露,适合自建 RAM/ROM/外设的小系统;后者提供 AXI4-Lite 主口,能直接挂进 Zynq 的互联矩阵。两者之间还有个picorv32_axi_adapter,把原生接口桥到 AXI4,适合系统里既有本地 RAM 又有 AXI 外设的混合结构。
2.2 PYNQ 的三层结构:把 FPGA 细节藏在 Python 后面
PYNQ 的设计初衷就是把底层 FPGA 实现细节和上层应用解耦。它的架构分三层:以 FPGA 设计为主的硬件层、Linux 内核加 Python 的软件层、以 Jupyter Notebook 为主的应用层。对做 SoC 的人来说,这个分层最大的好处是——你不需要在板子上装串口终端、不需要每次改代码都重新烧 bit,浏览器打开 notebook 就能干活。
核心 API 就一个Overlay类。它接收 bitstream 文件名,实例化时默认完成两件事:把 bit 流下载进 PL,以及解析配套的 hwh 硬件描述文件,把 IP 的寄存器地址、中断号读进 Python 字典。
from pynq import Overlay # 实例化即下载 bitstream,并解析同目录下的 hwh overlay_design = Overlay("base.bit") # ip_dict 是后续一切寄存器操作的地图:名字 -> 物理地址/参数 for name, info in overlay_design.ip_dict.items(): print(name, hex(info["phys_addr"]), info["type"])这里的参数含义值得说清:Overlay("base.bit")的字符串既可以是绝对路径,也可以是 PYNQ 搜索路径下的相对名;实例化动作本身带下载副作用,重复实例化同一个文件不会重复配置 PL。ip_dict里每一项的phys_addr来自 hwh,这就是mmio读写能对上地址的唯一依据——hwh 和 bit 一旦版本对不上,你拿到的地址就是错的,而且不报错。
PYNQ 基于 Ubuntu 发行版构建,能直接用 apt 拿软件包,代价是镜像体积和启动时间,换来的是不用自己移植交叉编译器和各种库。对这套 RISC-V 实验来说,这一点直接决定了工具链能不能装在板子上。
2.3 Zynq-7000 的 PS/PL 分工与启动顺序
Zynq-7000 在单颗芯片里集成了双核或单核 Cortex-A9 的处理系统(PS)和 28nm 可编程逻辑(PL)。PS 侧可以拆成四块:应用处理器单元 APU、存储器接口、输入输出外设 IOP、内部互联。APU 里除了 A9 核,还有 FPU/NEON、MMU、32KB 指令缓存、32KB 数据缓存、512KB L2 缓存与控制器、256KB 片上 SRAM、8 通道 DMA、GIC 中断控制器等。
对搭 SoC 的人来说,只需要记住一条硬规则:PS 中的处理器永远先启动,PL 的配置由 CPU 上运行的软件管理。这解释了为什么 PYNQ 能用一行 Python 下载 bit 流——本质是 Linux 驱动往 FPGA 配置端口写数据。也解释了为什么你的 picoRV32 复位后必须等 PS 侧把程序放进 RAM 才能开始跑,上电顺序不是可选项。
存储器接口分两侧:一侧接 SRAM/NOR、ONFI 1.0 NAND、四通道 SPI Flash 这类非易失存储器;另一侧接 DDR2/3、DDR3L、LPDDR2 这类易失存储器。IOP 提供 2 路 USB、2 路千兆以太网、2 路 SDIO、GPIO、2 路 UART、2 路 CAN、2 路 I2C、2 路 SPI。互联矩阵把处理器、存储接口、外设连起来。
这套结构的实际意义是:picoRV32 不需要自己实现串口。它把结果写进一块共享内存或者一个 AXI 外设寄存器,PS 侧的 Python 读出来打印就行。很多第一次做这个实验的人非要在 PL 里手写一个 UART 发送模块,结果波特率算错、时序对不上,白白耗掉两天——用 PS 的现成外设,才是这套平台该有的用法。
3. 工程结构与文件流转:从 Vivado 工程到板子上的 overlay 目录
文档类资源最容易让人迷失的地方,是章节顺序和实际操作顺序对不上。29 页报告的目录看着是线性的,实际动手时你在 Vivado、终端、浏览器之间来回跳。把文件按阶段归位,比记住章节号有用得多。
3.1 报告章节与实际工程阶段的映射
把目录拆开看,每一节都对应一个可交付物:
| 报告章节 | 实际产出 | 落地位置 |
|---|---|---|
| 1.1 实验要求 | 需求边界:RV32IMC、SoC、软件可跑 | 无 |
| 1.2 所用框架 | PYNQ / picoRV32 / Zynq-7000 选型结论 | 无 |
| 1.3 嵌入式系统设计流程 | 需求→架构→模块划分→IP 集成→联调 | 无 |
| 二 硬件及软件设计方案 | Verilog 源码、Block Design、约束文件 | Vivado 工程目录 |
| 3.1 模块设置与 IP 核包装 | 自定义 IP 目录 | ip_repo/ |
| 3.2 IP 与其他组件相连 | Block Design(.bd) | 工程内 |
| 3.3 生成文件并覆盖原文件 | bit + hwh | overlay 目录 |
| 3.4 oDisk 解压文件 | 板端文件系统里的 overlay 包 | 板子存储 |
| 3.5 放入指定文件夹 | PYNQ 可搜索到的路径 | 运行时目录 |
| 3.6 在 PYNQ-Z2 上验证 | Jupyter 单元格输出 | 浏览器 |
这张表真正的作用是排查:任何一步失败,先确认对应产物在不在、时间戳新不新。3.3 的「覆盖原文件」是最容易出问题的一步——很多人只覆盖了 bit,忘了 hwh,结果后面所有寄存器访问全错位。
3.2 bit / hwh / tcl 三件套与 overlay 的落位
一次 Vivado 综合实现导出的是三个东西:.bit是比特流,描述 PL 里连了什么;.hwh是硬件描述,描述地址映射和 IP 参数;早期版本用.tcl代替 hwh。PYNQ 的Overlay类在实例化时会把 hwh 解析成 Python 对象树,ip_dict、hierarchy_dict、interrupt_pins全都来自这里。
落位策略上,常见做法是给每个 overlay 单独建一个目录,bit 和 hwh 同名放一起,然后在 notebook 里用绝对路径加载。这样做的理由是 PYNQ 的默认搜索路径有多处(包内的 overlays 目录、当前工作目录等),同名文件冲突时很难判断到底加载了哪个。我在自己的板子上固定用/home/xilinx/overlay/<项目名>/这一层,路径写死,避免玄学。
还有一点:hwh 里记录的地址是在 Block Design 里分配的。如果你改了 BD 里的地址映射但没重新导出,或者导出时勾选项漏了,Python 侧读到的phys_addr就是旧值。表现是寄存器能读能写、但值不生效,非常像硬件 bug,实际是文件版本问题。
3.3 交叉编译产物与 hex 转换
软件侧的准备分三步:编译 ELF、抽二进制、转十六进制文本。第三步是为了让 Verilog 里的$readmemh能直接初始化 RAM,省掉在 PL 里做 Flash 控制器和加载逻辑。
# 目标架构必须和 picoRV32 的编译期配置一致: # 开了 COMPRESSED_ISA 才能用 rv32imc,否则退回 rv32im riscv32-unknown-elf-gcc \ -march=rv32imc -mabi=ilp32 \ -nostdlib -nostartfiles \ -T link.ld -O2 \ -o demo.elf start.S main.c # 去掉 ELF 头和调试段,只留纯二进制镜像 riscv32-unknown-elf-objcopy -O binary demo.elf demo.bin # 每 4 字节一行、8 个十六进制字符,喂给 $readmemh hexdump -v -e '1/4 "%08x\n"' demo.bin > demo.hex参数逐个说:-march=rv32imc决定指令集子集,和 picoRV32 的COMPRESSED_ISA、ENABLE_MUL必须对齐,否则编译器会生成硬件不认识的指令,运行到那条就触发非法指令异常;-mabi=ilp32是 RV32 的标准调用约定,用错会链接失败;-nostdlib -nostartfiles表示不链接 libc 和默认启动代码,裸机环境必须自己写start.S设置栈指针和清零.bss;-T link.ld指定链接脚本,-O2是常规优化。
hexdump那一行的-e '1/4 "%08x\n"'意思是每次读 1 个单位、每个单位 4 字节、按 8 位小写十六进制加换行输出。这里有个隐含前提:RAM 初始化时按 32 位字宽读取。如果你在 Verilog 里声明的是reg [7:0] ram[...],这个 hex 文件读进去就会错位,症状是第一条指令就跑飞。
链接脚本里最关键的是起始地址。picoRV32 复位后从mem_addr = 0开始取指,所以.text段一般从 0x00000000 起;如果你的 SoC 把 RAM 映射在 0x80000000 这类偏移上,那链接脚本和硬件地址译码必须同时改,改一个不改另一个,必翻车。
4. 核心模块拆解:存储器接口、AXI 包装与 PCPI 协处理器
到这里硬件骨架已经有了轮廓,接下来是三个真正决定"能不能跑"的模块:处理器怎么访问存储器、怎么接进 Zynq 的总线体系、以及怎么用协处理器接口扩展指令。这三块拆明白了,改任何一处都不会牵一发动全身。
4.1 原生 mem 接口的读写时序
picoRV32 的原生存储器接口只有七个信号,极简但对时序有明确要求:
| 信号 | 方向 | 位宽 | 说明 |
|---|---|---|---|
| mem_valid | 核 → 从机 | 1 | 请求有效 |
| mem_instr | 核 → 从机 | 1 | 本次访问是取指 |
| mem_ready | 从机 → 核 | 1 | 应答 |
| mem_addr | 核 → 从机 | 32 | 字节地址 |
| mem_wdata | 核 → 从机 | 32 | 写数据 |
| mem_wstrb | 核 → 从机 | 4 | 字节写掩码,读传输时为 0 |
| mem_rdata | 从机 → 核 | 32 | 读数据 |
读传输的规则是:mem_wstrb为 0,mem_wdata无效,从机在mem_ready拉高的那个周期把数据放到mem_rdata上。写入同理,从机通过拉高mem_ready确认。关键是——协议不要求外部等待周期,mem_ready可以和mem_valid同一拍拉高,也可以直接绑常量 1 实现即时应答。
// 零等待 SRAM 从机:对接 picoRV32 原生 mem 接口 module mem_slave #( parameter MEM_WORDS = 16384 // 64KB,按 32 位字寻址 )( input clk, input resetn, // 处理器侧 input mem_valid, input [31:0] mem_addr, input [31:0] mem_wdata, input [ 3:0] mem_wstrb, output reg mem_ready, output reg [31:0] mem_rdata, // 预装程序口:由 PS 侧或 testbench 写入 input prog_we, input [13:0] prog_addr, input [31:0] prog_wdata ); reg [31:0] ram [0:MEM_WORDS-1]; wire [13:0] idx = mem_addr[15:2]; // 字对齐,丢弃低 2 位 always @(posedge clk) begin if (prog_we) ram[prog_addr] <= prog_wdata; // 加载程序镜像 else if (mem_valid && mem_ready && (mem_wstrb != 4'b0000)) ram[idx] <= mem_wdata; // 简化:整字写 end always @(*) begin mem_ready = mem_valid; // 当拍应答 mem_rdata = ram[idx]; // 组合读出 end endmodule这段代码的逻辑:写通道优先响应prog_we,让外部在复位期间把 hex 内容灌进 RAM;正常运行时,只有mem_valid和mem_ready同时为高、且mem_wstrb非零才写,最后这条判断是区分读和写的唯一依据。读通道用组合逻辑输出,mem_ready直接等于mem_valid,构成零等待。
参数上,MEM_WORDS = 16384对应 64KB,mem_addr[15:2]取 14 位正好覆盖,改容量时这两个数必须同步改,否则地址会回绕——症状是程序跑到某处突然跳回开头。另外这个实现把mem_wstrb简化成了整字写,真实场景里要按字节掩码处理,否则uint8_t类型的全局变量会写坏相邻字节。
组合读的写法综合时会被推断成 LUTRAM 或分布式 RAM,容量一大就吃 LUT。工程里更常见的做法是用 BRAM 加一拍读延迟,把mem_ready延后一拍给到处理器,代价是多一个周期但省下大量逻辑资源。
4.2 AXI4-Lite 包装:让软核挂进 Zynq 的互联矩阵
原生接口适合自建小系统,但一旦要访问 DDR、挂在 PS 侧的主端口后面、或者用现成的 AXI BRAM 控制器,就得换成picorv32_axi。它对外暴露 AXI4-Lite 主口,核内部的取指和数据访问都转成 AXI 事务。
// picorv32_axi 顶层例化:AXI4-Lite 主口直接进 Zynq 互联 picorv32_axi #( .ENABLE_MUL (1), // 打开 RV32M 乘法 .ENABLE_DIV (1), // 打开除法 .ENABLE_PCPI (1), // 预留协处理器接口 .ENABLE_IRQ (0), // 本设计不用中断 .COMPRESSED_ISA (1), // 与 -march=rv32imc 对齐 .TWO_CYCLE_ALU (1), // 折中时序与面积 .BARREL_SHIFTER (0), // 首版不开,先保时序 .PROGADDR_RESET (32'h0000_0000), .PROGADDR_IRQ (32'h0000_0010) ) cpu ( .clk (clk), .resetn (resetn), // AXI4-Lite 主口 .m_axi_awvalid (m_axi_awvalid), .m_axi_awready (m_axi_awready), .m_axi_awaddr (m_axi_awaddr), .m_axi_wvalid (m_axi_wvalid), .m_axi_wready (m_axi_wready), .m_axi_wdata (m_axi_wdata), .m_axi_wstrb (m_axi_wstrb), .m_axi_bvalid (m_axi_bvalid), .m_axi_bready (m_axi_bready), .m_axi_arvalid (m_axi_arvalid), .m_axi_arready (m_axi_arready), .m_axi_araddr (m_axi_araddr), .m_axi_rvalid (m_axi_rvalid), .m_axi_rready (m_axi_rready), .m_axi_rdata (m_axi_rdata), // 中断与跟踪 .irq (irq), .trap (trap) );关键参数说明:PROGADDR_RESET是复位后第一条指令的地址,这个值必须和链接脚本的.text起始地址完全一致,错一个字节都会跑飞;PROGADDR_IRQ只在ENABLE_IRQ打开时有用,不用中断时给个占位值即可。ENABLE_MUL、COMPRESSED_ISA要跟-march对齐,前面已经强调过。
trap信号很值得单独接出来。它在非法指令、非对齐访问、中断未处理等情况下拉高。第一次做这个实验时我把它悬空了,程序跑飞了三天没找到原因,接出来打到一个 LED 或 GPIO 上,五分钟就定位了。
还有一种混合结构:外部用picorv32内核加本地 RAM,通过picorv32_axi_adapter把原生接口桥成 AXI 主口,再挂到 Zynq 互联上。这样做的好处是核访问本地 RAM 走快速路径,访问外设时才绕到 AXI,原文对这个 adapter 的定位描述得很清楚——用来创建包含一个或多个 PicoRV32 内核以及本地 RAM、ROM 和内存映射外设的定制系统。
4.3 PCPI:把自定义指令挂进核里
PCPI 是这套设计里最容易被跳过、也是二次开发价值最高的一部分。它允许外部内核实现非分支指令:遇到核不支持的指令且 PCPI 功能被激活时,pcpi_valid拉高,指令字从pcpi_insn输出,rs1、rs2字段被解码后从pcpi_rs1、pcpi_rs2给出。
// 自定义协处理器:一条 custom-0 指令实现 (rs1 + rs2) << 1 module pcpi_shift_add ( input clk, input resetn, input pcpi_valid, input [31:0] pcpi_insn, input [31:0] pcpi_rs1, input [31:0] pcpi_rs2, output reg pcpi_wr, output reg [31:0] pcpi_rd, output reg pcpi_wait, output reg pcpi_ready ); localparam [6:0] OPCODE_CUSTOM0 = 7'b0001011; // RISC-V 保留的 custom-0 wire hit = pcpi_valid && (pcpi_insn[6:0] == OPCODE_CUSTOM0); always @(posedge clk) begin if (!resetn) begin pcpi_wr <= 1'b0; pcpi_ready <= 1'b0; pcpi_wait <= 1'b0; pcpi_rd <= 32'b0; end else begin pcpi_wr <= hit; // 有结果回写 pcpi_rd <= (pcpi_rs1 + pcpi_rs2) << 1; pcpi_ready <= hit; // 单周期完成 pcpi_wait <= 1'b0; // 不等长指令无需 wait end end endmodule逻辑说明:pcpi_valid是核发来的请求,hit判断操作码是否属于本协处理器认领的范围。单周期完成的指令只需要在下一拍同时拉高pcpi_ready和pcpi_wr,核会把pcpi_rd写回rd字段对应的寄存器。
最关键的约束是那个 16 周期超时:外部 PCPI 内核如果 16 个时钟周期内没确认,核直接触发非法指令异常。要多个周期才能算完的指令,必须在解码成功后立刻拉高pcpi_wait并一直保持,直到pcpi_ready拉高,否则异常照样会来。这条规则是新手最容易踩的坑——明明自己的模块逻辑正确,就是跑不通,原因是流水线太长超过了窗口期。
4.4 Vivado 里打包 IP 与 Block Design 连线
硬件写完之后要能进 Block Design,就得包成 IP。手工填 IP 描述文件太慢,用 Tcl 一条命令搞定:
# 把 picoRV32 + 存储 + 外设的 wrapper 包成可复用 IP ipx::package_project -root_dir ./ip_repo \ -vendor user.org -library user -taxonomy /UserIP \ -import_files set core [ipx::current_core] # 把 AXI 主口和时钟、复位识别成标准接口,BD 里才能自动连线 ipx::infer_bus_interfaces xilinx.com:interface:aximm_rtl:1.0 $core ipx::save_core $core参数含义:-root_dir指定输出目录,BD 里要通过set_property ip_repo_paths把它加进搜索路径;-vendor和-library是 IP 的命名空间,随便填但要和引用时一致;-import_files表示把源文件复制进 IP 目录,避免工程挪位置后断链。ipx::infer_bus_interfaces这一步不能省,它让 BD 认出 AXI 接口,否则连线时得手工一根一根接,几十根信号漏一根就等着调试。
连线阶段的操作顺序建议固定:先连时钟和复位,再连 AXI 主口到互联,最后接中断和自定义引脚。每连完一组就跑一次 Validate Design,错误提示比综合阶段清晰得多。
生成产物时要勾上导出硬件描述,这一步产出的 bit 和 hwh 就是第 3 章要放到 overlay 目录的那两个文件。解压上板、放进指定文件夹、在 Jupyter 里实例化验证,这套流程走完一遍,SoC 就算立起来了。
5. 避坑与排查:五个上板前没人提醒你的坑
综合通过不等于能跑,能加载不等于跑对。这套流程里失败点分布得很散,从 IP 打包一直延伸到 bin 文件的字节序。下面五条都是"现象看起来像 A,实际原因是 B"的类型,每条给出定位路径。
5.1 综合、打包与握手阶段的三个坑
坑一:overlay 加载成功,但寄存器地址全错。
现象是Overlay(...)不报错,ip_dict也能打印出来,但往里写数据读不回来,或者读到的值来自另一个 IP。原因几乎都是 bit 和 hwh 不是同一次综合导出的——只覆盖了.bit忘了.hwh,或者从不同版本的工程目录各拷了一个。解决方式很机械:确认两个文件的修改时间在同一分钟内,不确定就整目录删掉重新导出。我现在的习惯是 overlay 目录按日期打标签,riscv_soc_20210715/,切换时整体替换,避免混用。
坑二:时序不收敛,跑起来随机死机。
现象是功能跑一会儿停一会儿,重启又能跑,抓波形看不出规律,属于典型的玄学。原因可能是时钟约束缺失、跨时钟域没做同步、或者 ALU 组合路径太长。原文提到picorv32_axi打开TWO_CYCLE_ALU就是为了改善时序,这条线索值得优先试。解决路径:先把TWO_CYCLE_ALU和BARREL_SHIFTER调成省时序的组合,跑一遍实现看 WNS;还有违例就降频验证,把时钟从 100MHz 降到 50MHz,如果问题消失基本可以确认是时序而非逻辑。约束文件里别忘了给 picoRV32 的时钟和 PS 侧时钟分别建create_clock。
坑三:核卡死,PC 停在 0,mem_ready恒为 0。
现象是程序完全不执行,trap不拉高,没有任何输出。原因是存储器从机在某种访问模式下没给应答——常见的是只处理了写、忘了读,或者地址译码出现空洞,访问落到没接从机的区域,mem_ready永远等不到。解决方式是把mem_valid、mem_ready、mem_addr一起接到 ILA 上,看第一个未应答的地址落在哪。另一个隐蔽原因是mem_ready用了组合逻辑但依赖了时序信号,形成组合环,综合给个 warning 就过去了,实际跑起来死锁。
5.2 上板运行阶段的坑与排查顺序
坑四:程序第一条指令就异常,trap立刻拉高。
现象是加载完 hex,一放开复位就进 trap。原因通常是链接地址和硬件地址对不上:链接脚本把.text放在 0x80000000,而PROGADDR_RESET是 0x0,或者 RAM 的地址译码只覆盖了低 16KB,程序一超出就访问到空洞。解决方式是把PROGADDR_RESET、链接脚本的起始地址、RAM 从机的地址范围三者列出来对齐,任何一个不一致都要改。另外一个可能是指令集不匹配——编译用了rv32imc,硬件没开COMPRESSED_ISA,遇到压缩指令直接非法。
坑五:hex 读进 RAM 全是 x 或错位。
现象是仿真里 RAM 内容大面积为 x,或者指令看起来像乱码。原因是$readmemh的位宽和 hex 文件的行格式不匹配。前面那条hexdump -e '1/4 "%08x\n"'输出的是每行 8 个字符对应 32 位,如果 RAM 声明成reg [7:0],就会一个字节一行地对不上。解决方式统一为 32 位字宽声明,或者改用objcopy -O verilog直接生成带地址标记的格式,省掉手动转换这一步。
排查顺序上,我的建议是从后往前:先确认文件版本(bit/hwh 是否同批),再确认加载是否成功(ip_dict内容对不对),再确认程序镜像(起始地址、指令集),最后才是波形级调试。反过来的话,很容易在波形里泡两天,最后发现是拷错了文件。
6. 二次开发:把"能跑"改成"能加速"的改造路径
跑通之后最有价值的动作,是把 PCPI 从占位模块变成真正的加速器。选一段 C 里的热点循环,比如定长数组的累加移位,先用-O2编译跑一遍,记下周期数(在start.S里读mcycle前后相减,或者用trap之外的 GPIO 翻转配合逻辑分析仪测),再把这段运算搬到 Verilog 里。
// 用内联汇编发一条 custom-0 指令,交给 PCPI 协处理器执行 // .insn r <opcode>, <funct3>, <funct7>, <rd>, <rs1>, <rs2> #define PCPI_SHADD(a, b) ({ \ unsigned int __r; \ asm volatile (".insn r 0x0b, 0, 0, %0, %1, %2" \ : "=r"(__r) : "r"(a), "r"(b)); \ __r; }) unsigned int acc = 0; for (int i = 0; i < N; i++) { acc = PCPI_SHADD(acc, input[i]); // 原本是 acc = (acc + input[i]) << 1 }0x0b是 custom-0 操作码,与 4.3 节 Verilog 里的OPCODE_CUSTOM0必须一致;funct3、funct7全填 0,正好留给协处理器内部再细分操作。改完之后重新编译、重新生成 hex、重新灌进 RAM,用同样的方法测周期数。这里的血泪经验是:一定要先测基线再改硬件,否则你无法判断加速比是真实收益还是测量误差。我吃过一次亏,改完发现快了 30%,后来发现是-O2把整个循环优化掉了,改之前根本没实测。
验证环节建议做两级。第一级是功能等价:把 PCPI 结果和纯 C 实现的结果逐元素比对,跑一百组随机输入,不一致就打出第一个分歧点。第二级是资源与时序:打开BARREL_SHIFTER之类的开关后重新看 LUT 占用和 WNS,加速器面积别把主核的时序拖垮。这张对照表可以按你自己的场景填:
| 改造项 | 预期收益 | 主要代价 | 验证方法 |
|---|---|---|---|
| PCPI 自定义指令 | 热点循环周期数下降 | LUT 占用上升 | mcycle 前后差值 |
| 换 picorv32_axi | 能挂 DDR 与 DMA | 多一层 AXI 延迟 | 大数据量吞吐对比 |
| 开 BARREL_SHIFTER | 移位指令单周期 | 时序压力变大 | 综合后 WNS |
| 存储器换 BRAM 加流水 | 主频上限提高 | 多一拍读延迟 | 频率扫描找临界点 |
再往前一步,可以把加速器做成多实例挂在 AXI 上,由 PS 侧 Python 通过mmio配参数、picoRV32 负责调度。这套结构的好处是软硬件边界清晰:算法迭代改 Verilog 重下 bit,控制逻辑改 Python 重跑单元格,两边互不影响。
从那以后我每次动 PCPI,都强制先把基线周期数打出来贴在 notebook 第一格,再动一行 Verilog。没有基线就没有加速,这个习惯帮我省掉了不少自我感动的时刻。希望帮到你。
本文还有配套的精品资源,点击获取