简介:面向计算机组成原理、嵌入式系统与FPGA课程学习者,这份《硬件结构设计》综合实验报告以开源RISC-V核picoRV32为主线,说明如何在PYNQ-Z2开发板上搭建完整SoC并完成软硬件协同编程。报告共29页,依次覆盖实验要求、PYNQ框架与Jupyter Notebook使用、嵌入式系统设计流程、硬件及软件设计方案、模块设置与IP核包装、AXI总线连接、比特流生成、oDisk文件部署及RISC-V功能验证,并给出运行结果分析、常见问题处理思路和小组实验心得,能帮助读者理解从处理器核、存储器与I/O接口到交叉编译工具链调试的完整链路。资源包仅含1个docx文档,大小约3.01MB,目录清晰,便于课程实验对照、SoC入门实践和实验报告撰写参考。已有1582人学习。
1. 从一块 PYNQ-Z2 说起:picoRV32 搭 SoC 到底在搭什么
很多人对「硬件结构设计」综合实验的第一印象,是拿块板子跑个现成 demo,灯闪起来就交差。真上手才发现完全不是:你得基于开源的 RISC-V 核 picoRV32,从零拼出一个能跑程序的 SoC——复位向量放哪、BRAM 挂在哪、串口怎么映射、mem_ready 由谁拉高、中断线接不接,全得自己拍板。这块 PYNQ-Z2 好使的地方在于,板子上本来就跑着 Linux,Jupyter Notebook 里敲几行 Python 就能把 bitstream 灌进 PL,交叉编译出来的 RISC-V 二进制直接喂给核执行,省掉了最磨人的烧写和串口调试环节。适合谁?写过 C、看得懂 Verilog 语法、但没独立走完「写 RTL → 打 IP → 连线 → 上板 → 跑程序」整条链的人。你缺的往往不是知识点,而是这条链上一次完整的贯通。
2. 技术选型拆解:PYNQ 三层架构与 picoRV32 的接口账本
这一章先把两块拼图各自的边界划清楚。想明白 PYNQ 帮你兜了什么、picoRV32 又要求你交出什么,后面写 RTL 和调 Python 的时候才不至于两边互相甩锅。
2.1 硬件层、软件层、应用层:PYNQ 把 FPGA 藏在哪一层
PYNQ 的设计初衷就是用高层次的封装把 FPGA 的实现细节和上层应用解耦。它的架构是三层:最底下是 FPGA 设计为主的硬件层(PL 里的 bitstream),中间是 Linux 内核加 Python 的软件层,最上面是 Jupyter Notebook 组成的应用层。你从浏览器连过去,看到的是一个网页版 notebook,实际执行环境在板子的 ARM 核上,Python 进程通过驱动去读写 PL 的寄存器。
这个分层带来的直接好处是:硬件工程师交付一个 bitstream 加一个 hwh 描述文件,软件工程师不用懂 Verilog,靠 Python API 就能控制逻辑。反过来,对你做这个实验来说,意味着 picoRV32 对外只暴露成一组寄存器/内存映射,你在 Python 侧做的事情本质上就是「写内存、读内存」。
加载一个 overlay 就是实例化一个对象,构造过程里已经把 bitstream 烧进 PL 了:
from pynq import Overlay # 实例化即完成 PL 配置:内部会把 bit 文件下载到 FPGA ol = Overlay("riscv_soc.bit") # hwh(早期版本是 .tcl)会被自动解析,IP 的基地址、寄存器偏移都从这里来 print(list(ol.ip_dict.keys())) # 断言的地址映射,后面做内存读写用的就是这套偏移 print(ol.ip_dict["riscv_soc_axi_0"]["phys_addr"])Overlay的构造参数是 bitstream 文件名,相对路径默认在板子的 overlay 目录下找;ip_dict里每个条目包含phys_addr(物理基地址)、addr_range(地址范围)、registers(寄存器名到偏移的映射)。这里有个容易忽略的点:hwh 和 bit 必须是同一版 Vivado 同一次导出的一对,只拷 bit 不拷 hwh,Overlay就会在解析阶段直接抛异常。
2.2 picoRV32 的配置档位:RV32IMC 与三种资源取舍
picoRV32 是 RV32IMC 指令集的实现,但它的参数化做得很彻底——可以裁成 RV32E、RV32I、RV32IC、RV32IM、RV32IMC 中的任意一档,中断控制器、乘法器、除法器、桶形移位器、压缩指令都是独立开关。这直接决定了资源占用和频率,也是这个实验里第一个需要你自己做判断的地方。
报告里给的三个档位其实很好理解:small 档关掉计数器指令、关掉两周期 ALU 优化、不检测非对齐访问和非法指令,面积最小;regular 档就是默认配置;large 档把 PCPI、IRQ、MUL、DIV、BARREL_SHIFTER、COMPRESSED_ISA 全打开,功能最全但 LUT 吃得最多。我的建议是初版一律用 regular 起步,跑通了再按需要加减,别一上来就堆满,否则时序不收敛的时候你根本不知道是谁拖的。
一个典型的例化长这样:
picorv32 #( .ENABLE_COUNTERS (1), // 打开 rdcycle/rdinstret,测指令周期全靠它 .ENABLE_COUNTERS64 (1), // 64 位计数器 .ENABLE_MUL (1), // 乘法器,一条 MUL 约 40 周期 .ENABLE_DIV (1), // 除法器,一条 DIV 约 40 周期 .ENABLE_IRQ (1), // 内置中断控制器,接外部中断源 .ENABLE_PCPI (0), // 自定义协处理器接口,先关掉 .COMPRESSED_ISA (1), // 支持 RV32IC 压缩指令 .BARREL_SHIFTER (0), // 关掉桶形移位器,省 LUT,用迭代移位换面积 .PROGADDR_RESET (32'h0000_0000), // 复位向量 = BRAM 起始 .PROGADDR_IRQ (32'h0000_0010), // 中断入口,紧跟在复位向量后面 .CATCH_MISALIGN (1), // 非对齐访问触发异常,调试期别关 .CATCH_ILLINSN (1) // 非法指令触发异常,同上 ) cpu ( .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 (32'b0), .eoi () );PROGADDR_RESET和PROGADDR_IRQ这两个地址必须落在你 BRAM 的地址范围内,否则上电第一条指令就取了个空。CATCH_MISALIGN和CATCH_ILLINSN调试期一定要开——它们会把问题变成可见的异常跳转,而不是让你对着一个卡死的 PC 干瞪眼,这种时候就特别能体会到「黑匣子」和「有日志」的差别。
2.3 原生内存接口还是 AXI4-Lite:两种接法的取舍
picoRV32 有两个变体:picorv32提供简单的原生内存接口,picorv32_axi提供 AXI-4 Lite Master 接口。此外还有一个picorv32_axi_adapter单独的内核,用来桥接原生接口和 AXI4。
原生接口就是那七根线:mem_valid、mem_instr、mem_ready、mem_addr[31:0]、mem_wdata[31:0]、mem_wstrb[3:0]、mem_rdata[31:0]。协议极简,你自己写个地址译码加几块 BRAM 就能跑起来。AXI4-Lite 那套握手信号多得多,好处是能直接挂到 Zynq 的 PS 侧总线上,被 ARM 核访问。
| 对比项 | 原生 mem 接口(picorv32) | AXI4-Lite(picorv32_axi) |
|---|---|---|
| 信号数量 | 7 根,协议简单 | 五通道握手,信号多 |
| 上手成本 | 低,写个译码器就能用 | 需要理解 valid/ready 握手 |
| 与 PS 互通 | 需要自己写 adapter | 天然可挂到 AXI 互联 |
| 地址空间 | 你自定义,随便分 | 受 AXI 地址映射约束 |
| 适用场景 | 自建 SoC、教学实验 | 已有 AXI 系统的集成 |
这个实验中,picoRV32 本体在 PL 侧跑,程序和数据都在 BRAM 里,跟 PS 的交互只发生在「Python 把编译好的二进制写进 BRAM」和「Python 读回结果」这两步。所以原生接口足够,再配一个简化的 AXI4-Lite 从设备口给 PS 访问,比整个换picorv32_axi要省事得多。血泪经验是:两种接法别混着用一种又不彻底,我见过有人核用原生接口、外设用 AXI,中间手写桥接状态机,最后死在握手上。
2.4 Zynq-7000 的 PS 与 PL:启动顺序决定了你的设计边界
Zynq-7000 在单芯片里集成了 ARM Cortex-A9 双核(或单核)的处理系统 PS 和 28nm 的可编程逻辑 PL。PS 侧包含 APU(应用处理器单元,内含 MMU、32KB 指令缓存、32KB 数据缓存、FPU 与 NEON)、存储器接口(DDR2/3、DDR3L、LPDDR2、Q-SPI、NAND、SRAM/NOR)、IOP 外设(USB、千兆以太网、SD/SDIO、GPIO、UART、CAN、I2C、SPI)以及内部互联。
关键的架构约束在于启动顺序:PS 中的处理器永远先启动,PL 的配置由 CPU 上运行的软件来管理。所以整个流程天然是「Linux 起来 → Python 起来 → 加载 bitstream → 配置 PL → 释放 PL 复位 → 核开始取指」。PS 和 PL 还位于独立电源域,不用 PL 时可以关掉。
这个约束反过来给了你一条调试捷径:既然 PS 永远在,你就可以让 Python 侧随时暂停/复位 picoRV32、读写它的内存、改完再放行,等于自带一个最朴素的调试器。把resetn也接到一个 AXI 寄存器位上,调试体验会好很多——不用每次改程序都重烧 bitstream。
3. 目录与数据流:从 C 源码到 picoRV32 取出第一条指令
第二章把零件认全了,这一章串流程。从你敲下编译命令,到核真正取出第一条指令,中间有五六个转换环节,任何一环出错表现都是「不动」,所以必须把每一环的产物和校验点记住。
3.1 报告章节对应到工程目录
实验报告写的是「模块设置与 IP 核包装 → 与其他组件相连 → 生成文件并覆盖原文件 → 解压 → 放入指定文件夹 → 上板验证」,这套描述翻译成工程目录大概是这样:
riscv_soc/ ├── rtl/ │ ├── picorv32.v # 核本体,直接从开源仓库拉 │ ├── soc_top.v # SoC 顶层:核 + BRAM + 外设 + 地址译码 │ ├── mem_subsys.v # mem 接口的从设备,负责应答和分发 │ ├── uart_lite.v # 简易串口,映射到地址空间 │ └── axi_lite_slave.v # 给 PS 访问用的从口 ├── sw/ │ ├── start.S # 启动汇编,设栈指针、清 bss、跳 main │ ├── main.c # 用户程序 │ ├── link.ld # 链接脚本,决定内存布局 │ └── Makefile ├── vivado/ │ └── ip_repo/ # 打包好的 IP 目录,Vivado 从这里认 └── pynq/ ├── riscv_soc.bit # 比特流 ├── riscv_soc.hwh # 硬件描述,Overlay 解析用 └── run.ipynb # Jupyter 侧的加载与验证脚本这个划分的意义在于把「硬件产物」和「软件产物」隔开。ip_repo是 Vivado 打包 IP 后的落点,pynq目录是整个 overlay 的交付物。实际动手时最容易乱的就是这两处混在一起改,改完不知道哪个版本对应哪个 bit。
3.2 工具链三段式:elf → bin → hex
RISC-V 交叉编译产出的是 ELF,但 BRAM 初始化需要的是$readmemh能吃的十六进制文本,中间还可能要一个纯二进制用于 Python 直接写内存。三段式命令:
# 1) 编译链接成 ELF。-march 必须和 picoRV32 的配置严格一致 riscv32-unknown-elf-gcc -march=rv32imc -mabi=ilp32 \ -nostdlib -nostartfiles -T link.ld -O2 \ -o firmware.elf start.S main.c # 2) 转纯二进制,给 Python 侧直接往 BRAM 写 riscv32-unknown-elf-objcopy -O binary firmware.elf firmware.bin # 3) 转 hex 文本,给 Verilog 的 $readmemh 初始化 BRAM riscv32-unknown-elf-objcopy -O verilog firmware.elf firmware.hex-march=rv32imc里每个字母都对应核的一个开关:i是基础整数指令,m对应ENABLE_MUL/ENABLE_DIV,c对应COMPRESSED_ISA。你如果把核的ENABLE_MUL关掉了却还按rv32imc编译,链接能过、上板必挂,而且挂得毫无提示——这类问题最耗时间,后面避坑那章会专门说。
链接脚本决定内存布局,这是新手最容易糊弄过去、最后被反咬一口的地方:
OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { RAM (rwx) : ORIGIN = 0x00000000, LENGTH = 16K } SECTIONS { .text : { *(.text.init) /* 启动代码排最前,保证复位向量落在 _start */ *(.text*) } > RAM .rodata : { *(.rodata*) } > RAM .data : { *(.data*) } > RAM .bss : { *(.bss*) *(COMMON) } > RAM /* 栈从 BRAM 尾部往下长,留出 1K 空间 */ _stack_top = ORIGIN(RAM) + LENGTH(RAM); }ORIGIN必须等于PROGADDR_RESET,LENGTH必须等于你实际分配给 BRAM 的大小。.text.init单独拎出来放最前,是为了确保复位后第一条指令就是_start,而不是编译器随手排的某个函数。这个细节报告里不一定写,但少写这一行,程序大概率一上电就跑飞。
3.3 内存映射表:地址怎么分给 BRAM、外设、控制寄存器
SoC 的本质就是一张地址表。picoRV32 把mem_addr甩给你,你得在译码器里决定谁响应。我一般这样分:
| 起始地址 | 结束地址 | 大小 | 对应设备 | 说明 |
|---|---|---|---|---|
| 0x0000_0000 | 0x0000_3FFF | 16KB | BRAM | 指令与数据共用,复位向量在此 |
| 0x1000_0000 | 0x1000_000F | 16B | UART | 发送/接收/状态三个寄存器 |
| 0x1000_1000 | 0x1000_100F | 16B | GPIO | 板载 LED 与按键 |
| 0x1000_2000 | 0x1000_200F | 16B | TIMER | 定时器,用于测周期 |
| 0x2000_0000 | 0x2000_00FF | 256B | AXI 从口 | 供 PS 侧读写,含复位控制位 |
地址译码用高位比较就够了,不需要搞成完整的译码树:
// 高位比较式地址译码:谁命中谁拉 ready always @(*) begin sel_bram = (mem_addr[31:16] == 16'h0000); sel_uart = (mem_addr[31:8] == 24'h100000); sel_gpio = (mem_addr[31:8] == 24'h100010); sel_timer = (mem_addr[31:8] == 24'h100020); sel_axi = (mem_addr[31:24] == 8'h20); end选高位比较而不是全地址匹配,好处是每个外设占一段连续空间,以后加寄存器不用改译码逻辑。代价是地址空间有浪费,但在 32 位空间里这完全不算事。
3.4 上板后的完整加载链路
从 Python 侧看,一次完整的「跑一段程序」是这样的:
from pynq import Overlay, allocate import numpy as np ol = Overlay("riscv_soc.bit") # 1) 先按住复位,防止写内存时核已经在取指 ol.write(0x20000000, 0x0) # 2) 把编译好的 bin 按 4 字节一组搬进 BRAM with open("firmware.bin", "rb") as f: blob = f.read() # 补齐到 4 字节对齐,避免最后一次写越界 pad = (-len(blob)) % 4 blob += b"\x00" * pad buf = allocate(shape=(len(blob) // 4,), dtype=np.uint32) buf[:] = np.frombuffer(blob, dtype=np.uint32) for i, word in enumerate(buf): ol.write(0x00000000 + i * 4, int(word)) # 3) 清掉可能的残留状态,然后释放复位 ol.write(0x20000004, 0x1)allocate分配的是 PS 侧物理连续内存,mmap到用户空间后可以直接读写;逐字ol.write的缺点是慢,16KB 数据大概要几十毫秒,对调试够用。要提速就得给 AXI 从口加一个 DMA 或者批量写寄存器,但那属于二次开发的范畴了。
这段链路里每一步都要能单独验证:复位按住时读 0x0 地址应该能读回你写进去的第一个字,这是检查「写内存这条路通不通」最直接的办法。如果这步就不对,别往下走,先去查 AXI 从口的写时序。
4. 核心模块拆解:mem 握手时序、PCPI 与 IP 打包
前三章是铺垫,这一章开始拆真正的硬骨头。picoRV32 对外就两套接口:mem 和 PCPI。mem 管取指和数据访问,PCPI 管自定义指令扩展。把这俩吃透,整个 SoC 的可控部分你就都掌握了。
4.1 mem_valid / mem_ready 握手:一次取指周期到底长什么样
协议本身极简:核拉高mem_valid表示发起一次访问,同时给出mem_addr;从设备准备好数据后拉高mem_ready,核在mem_ready为高的那个周期采样mem_rdata。读传输时mem_wstrb为 0,mem_wdata无效;写传输时mem_wstrb的每一位对应一个字节的写使能。
重点在于时序约束:mem_ready可以和mem_valid在同一个周期变高(零等待),也可以绑死为常数 1(表示永远立即应答)。但如果你用了同步读的 BRAM,读数据会晚一拍出来,这时候就不能立刻拉mem_ready,得等数据对齐。
一个能同时干读写的从设备大致长这样:
module mem_subsys ( input wire clk, input wire resetn, // picoRV32 原生 mem 接口 input wire mem_valid, input wire mem_instr, output reg mem_ready, input wire [31:0] mem_addr, input wire [31:0] mem_wdata, input wire [3:0] mem_wstrb, output reg [31:0] mem_rdata, // 分支选择信号,来自地址译码 input wire sel_bram, input wire sel_uart, input wire sel_gpio ); reg [31:0] ram [0:4095]; // 16KB,按 32 位字组织 // BRAM 同步读:地址打一拍,数据下一拍出 reg [31:0] ram_rdata; always @(posedge clk) begin if (sel_bram && mem_valid && mem_wstrb == 4'b0000) ram_rdata <= ram[mem_addr[13:2]]; end always @(posedge clk) begin if (!resetn) begin mem_ready <= 1'b0; mem_rdata <= 32'b0; end else begin mem_ready <= 1'b0; if (mem_valid && !mem_ready) begin if (sel_bram) begin if (mem_wstrb == 4'b0000) begin // 读:等同步读数据出来再应答,这里延后一拍 mem_rdata <= ram_rdata; mem_ready <= 1'b1; end else begin // 写:按字节选通,四个分支分别处理 if (mem_wstrb[0]) ram[mem_addr[13:2]][ 7: 0] <= mem_wdata[ 7: 0]; if (mem_wstrb[1]) ram[mem_addr[13:2]][15: 8] <= mem_wdata[15: 8]; if (mem_wstrb[2]) ram[mem_addr[13:2]][23:16] <= mem_wdata[23:16]; if (mem_wstrb[3]) ram[mem_addr[13:2]][31:24] <= mem_wdata[31:24]; mem_ready <= 1'b1; // 写不需要等数据回来,当拍应答 end end else if (sel_uart || sel_gpio) begin mem_rdata <= 32'h0; // 外设另接译码,此处占位 mem_ready <= 1'b1; end end end end endmodule几个必须说清的点。第一,mem_wstrb == 4'b0000是判断读操作的方式,这是 picoRV32 的约定,别用mem_instr去区分读写,mem_instr只是标识这次取指是不是指令取指。第二,读路径上我让mem_ready延后一拍,是为了对齐同步读 BRAM 的一拍延迟;如果你把mem_rdata改成组合直通(异步读),mem_ready就可以当拍拉高,代价是 BRAM 推断会退化成分布式 RAM,面积和时序都变差。第三,写操作不需要等,mem_ready当拍拉高没问题,因为写入发生在时钟沿,核不关心写什么时候落地。
还有一个隐藏坑:mem_valid && !mem_ready这个条件保证了握手只发生一次。picoRV32 会在mem_ready拉高后撤销mem_valid,但如果你的从设备在同一个周期里既处理了这次访问又误判成新访问,就会出现重复读写。加这个门控是便宜的保险。
4.2 PCPI:不支持的指令怎么被外部核接管
PCPI(Pico 协处理器接口)是 picoRV32 留给自定义指令的口子。当核遇到一条自己不支持的指令、而 PCPI 特性被打开时,pcpi_valid拉高,pcpi_insn输出指令字本身,pcpi_rs1/pcpi_rs2给出两个源寄存器的值。外部协处理器解码、执行,完成时拉pcpi_ready;如果算出结果,可以把值放pcpi_rd并拉pcpi_wr,核会自己按指令里的rd字段写回寄存器。
有个硬性超时:如果 16 个时钟周期内没有pcpi_ready,核就会抛非法指令异常,跳到PROGADDR_IRQ。所以多周期指令必须在解码成功的当拍就拉高pcpi_wait,一直保持到pcpi_ready为止。
// 自定义指令示例:8 拍累加的向量点积 localparam IDLE = 2'd0, RUN = 2'd1, DONE = 2'd2; reg [1:0] state; reg [3:0] cnt; reg [31:0] acc, a_reg, b_reg; always @(posedge clk) begin if (!resetn) begin state <= IDLE; cnt <= 0; pcpi_ready <= 1'b0; pcpi_wait <= 1'b0; pcpi_wr <= 1'b0; end else begin case (state) IDLE: begin // custom-0 的 opcode 是 7'b0001011 if (pcpi_valid && pcpi_insn[6:0] == 7'b0001011) begin pcpi_wait <= 1'b1; // 当拍就拉住,防止 16 周期超时 a_reg <= pcpi_rs1; b_reg <= pcpi_rs2; acc <= 32'b0; cnt <= 4'd0; state <= RUN; end end RUN: begin acc <= acc + (a_reg + cnt) * (b_reg + cnt); // 示意运算 cnt <= cnt + 1'b1; if (cnt == 4'd7) begin pcpi_rd <= acc; pcpi_wr <= 1'b1; pcpi_ready <= 1'b1; pcpi_wait <= 1'b0; state <= DONE; end end DONE: begin pcpi_ready <= 1'b0; pcpi_wr <= 1'b0; state <= IDLE; end endcase end endpcpi_valid是核的输出,表示当前指令它不认识;pcpi_insn是完整 32 位指令字,你可以自己解 funct3/funct7 来区分多条自定义指令;pcpi_rs1/pcpi_rs2已经帮你把寄存器取出来了,省了你再访存。pcpi_wait的意义只有一个——告诉核「我正在算,别急着判我非法」,这个信号不拉,超过 16 拍必翻车。
C 侧用内联汇编写这条指令,得用.insn伪指令绕开编译器不认识的助记符:
// custom-0 自定义指令:rd = f(rs1, rs2) static inline uint32_t vec_dot(uint32_t a, uint32_t b) { uint32_t r; asm volatile (".insn r 0x0b, 0, 0, %0, %1, %2" : "=r"(r) : "r"(a), "r"(b)); return r; }.insn r后面跟的是 opcode、funct3、funct7、rd、rs1、rs2。0x0b就是 custom-0 的 opcode 值,这是 RISC-V 规范里留给自定义扩展的编码空间。这个技巧在给某个算法做硬件加速的时候特别省事,不需要改编译器后端。
4.3 Vivado 里把 picoRV32 包成 IP 并连线
RTL 跑通仿真之后,要把它变成一个能被 block design 引用的 IP。步骤是:Tools → Create and Package New IP → Package your current project,指定 IP 名称和ip_repo目录,然后在 IP 打包向导里做三件事——接口定义、地址分配、文件分组。
接口定义这块要把 mem 接口和 AXI 接口分别声明。原生 mem 接口不好自动推断,一般手动建一个 interface,把 7 根线塞进去;AXI4-Lite 从口能被自动识别。地址分配在 Address Editor 页面做,这里给的基地址最终会出现在 hwh 里,和前面那张内存映射表要对得上。
连线时有个结构性决定:picoRV32 的时钟和 PS 的FCLK_CLK0是同一个时钟,还是走独立时钟域?同一个时钟最省事,跨时钟域要加同步器,本实验没必要。所以直接把FCLK_CLK0接到soc_top的clk,复位用peripheral_aresetn,注意它是低有效,和 picoRV32 的resetn极性一致。
生成比特流之前一定要跑一次综合看资源报告。如果 LUT 使用率超过 70%,把BARREL_SHIFTER关掉、ENABLE_MUL/ENABLE_DIV按需关掉,基本能压下来。时序报告里看 WNS,负数就说明跑不到目标频率,先降频再查路径,别硬撑。
导出时勾选 Include bitstream,得到<name>.bit和<name>.hwh两个文件。这两个文件必须成对拷到板子上,前面说过原因。
4.4 结果回读:Python 侧怎么确认核真的跑完了
核跑完一段程序,最直接的验证是读回一块约定好的内存区域。让 C 程序在结束时往固定地址写一个魔数,Python 侧轮询这个地址:
import time MAGIC_ADDR = 0x00003FF0 # BRAM 尾部,避开栈 MAGIC_VAL = 0x5A5A1234 # 释放复位后轮询,最多等 2 秒 ol.write(0x20000004, 0x1) deadline = time.time() + 2.0 while time.time() < deadline: if ol.read(MAGIC_ADDR) == MAGIC_VAL: print("RISC-V 跑完了,魔数校验通过") break time.sleep(0.01) else: print("超时:核没有跑到结束标记,先查复位和 BRAM 初始化")C 侧对应写一句*(volatile uint32_t*)0x3FF0 = 0x5A5A1234;。这个「魔数 + 轮询」的模式看着土,但它把「程序跑飞了」和「程序跑完了但结果不对」两种情况明确区分开了,比看串口输出靠谱——串口本身也是要调通的,用没调通的东西去验证没调通的东西,容易陷入死循环。
5. 常见问题排查:六次翻车与定位手法
这个实验的难点不在写代码,在于出错时的表现高度同质化——绝大多数故障看起来都是「不动」。下面这几条是我和同学踩过的,按「现象 → 原因 → 解决」记,希望能帮你少走两趟。
5.1 上板六步流程与每步的校验点
先把标准流程和每一步的「通过标准」列清楚,出问题时才能定位到具体环节:
- 综合与实现,生成 bitstream,同时导出 hwh。通过标准:时序报告 WNS 为正,资源使用在预期范围内。
- 把
.bit、.hwh、firmware.bin一起拷到板子的同一目录。通过标准:文件名一致(除扩展名),ls -l能看到。 - Jupyter 里实例化
Overlay。通过标准:不抛异常,ip_dict里能看到你的 IP 条目。 - 按住复位,把 bin 写进 BRAM,再读回前几个字比对。通过标准:读回值和 bin 文件头一致。
- 释放复位,轮询结束魔数。通过标准:在超时之前读到魔数。
- 读回结果区,和软件仿真的预期值比对。通过标准:完全一致。
第 4 步的读回比对是很多人省掉的,但它恰恰是最有价值的一步——它把「写内存通路」和「核执行」两个问题解耦了。
5.2 六条具体踩坑记录
坑一:Overlay 加载报找不到 hwh。现象是 Python 抛异常,提示硬件描述文件缺失或解析失败。原因是导出 IP 时只拷了.bit没拷.hwh,或者两个文件来自不同版本的 Vivado。解决办法是用同一次导出、同一版工具生成的两个文件成对复制,并且保证基础文件名完全一致。
坑二:写完程序一释放复位,PC 卡在 0 不动,mem_valid一直是低。现象是核完全不发起取指。原因是复位信号极性搞反了——peripheral_aresetn是低有效,如果你的soc_top里又做了一次取反,核就一直处于复位态。解决办法是把复位路径上的所有反相器捋一遍,用 ILA 抓resetn的实际电平确认。
坑三:mem_valid有了,但mem_ready从不拉高,程序卡死在第一条指令。现象是握手只发生了一半。原因是地址译码漏了一种情况——比如mem_addr落到了没被任何sel_*命中的空洞区,从设备模块所有分支都不进,mem_ready永远为 0。解决办法是在mem_subsys里加一个默认分支,未命中时也拉mem_ready并返回 0,这样至少核能继续跑,你在异常处理里能捕获到。
坑四:串口输出全是乱码。现象是波特率明显不对。原因是分频系数按 50MHz 算的,但 PL 实际跑在 100MHz。解决办法是从ip_dict或者时钟 IP 的配置里确认FCLK_CLK0的真实频率,重新算分频值freq / baud。这个坑特别典型,改个时钟配置忘了同步改外设参数。
坑五:多条自定义指令连着执行时触发非法指令异常。现象是 PC 跳到PROGADDR_IRQ。原因是 PCPI 协处理器没在解码当拍拉pcpi_wait,8 拍的运算超过 16 周期上限。解决办法是把pcpi_wait的拉高时机从 RUN 状态提前到 IDLE 状态判断命中的那一拍,并且保证pcpi_ready拉高的同一拍撤销。
坑六:综合后 WNS 是负的,降频到 30MHz 才收敛。现象是时序不收敛。原因是 BRAM 用了异步读,mem_addr到mem_rdata是一条长组合路径,中间还穿了地址译码。解决办法是把 BRAM 换成同步读(读地址打一拍),mem_ready相应延后一拍;如果还不够,把地址译码的比较逻辑提前寄存。
5.3 定位手法:先缩小范围,再抓波形
遇到「不动」类问题时,我的固定顺序是:先读回 BRAM 确认程序真的写进去了;再抓resetn、mem_valid、mem_ready、mem_addr四根线看核有没有开始取指;最后才去怀疑程序逻辑。
ILA 的触发条件设成mem_valid && !mem_ready持续若干拍,能直接抓到握手卡死的位置。抓的时候把采样深度开大一点,因为 picoRV32 的取指很密集,深度不够只能看到尾巴。
软件侧还有一个便宜手段:读rdcycle。在 C 里asm volatile("rdcycle %0" : "=r"(c));,把开始和结束的周期数写进结果区,你就能算出这段程序实际跑了多少拍,和理论值一对比,差了数量级通常意味着某条指令掉进了异常处理或者陷入了重试。
6. 二次开发:加一个定时器外设与一条自定义指令
把基础版本跑通之后,最有价值的改造是「加一个自己的外设」和「加一条自己的指令」这两件事,它们分别对应 SoC 的两个扩展方向。
加定时器外设,改动集中在三处。地址译码里加一行sel_timer = (mem_addr[31:8] == 24'h100020);;mem_subsys里加一个分支,用mem_rdata返回当前计数值;外设本身就是一个自由运行的计数器,配一个比较寄存器做中断源。要把中断接进去,得在soc_top里把定时器的中断输出接到核的irq位上,并且在 C 侧写中断处理函数、在启动汇编里设置mtvec。这一步的验证方式是:让定时器每 N 个周期触发一次,处理函数里翻转一个 GPIO,用示波器或者 ILA 看翻转周期是否等于N × 时钟周期。
加自定义指令,改动集中在 PCPI 协处理器里。前面那个 8 拍累加的例子可以直接扩展成多指令:用pcpi_insn[14:12](funct3)区分不同运算,用pcpi_insn[31:25](funct7)区分变体。有几个工程上的取舍值得说一下:
| 改造点 | 保守做法 | 激进做法 | 我的建议 |
|---|---|---|---|
| 自定义指令功能 | 单条点积 | 一整套向量运算 | 先做一条,跑通再加 |
| 协处理器周期数 | 单周期组合输出 | 多周期流水 | 先做多周期,用pcpi_wait兜住 |
| 结果回写 | 只用pcpi_rd | 加旁路到内存 | 只用pcpi_rd,旁路容易乱序 |
| 验证方式 | C 里对比软硬件结果 | 只跑 benchmark | 一定要做前者 |
这里有一条我自己吃了亏才记住的规矩:任何一次改动,改完先跑一遍「魔数 + 结果区比对」的回归脚本,确认原有功能没被破坏,再去看新功能的输出。有一次我给 SoC 加 DMA,顺手调了 BRAM 的读延迟,结果新功能一切正常,老的乘法测试全错——因为mem_ready的时序变了但链接脚本里的栈顶没跟着动,栈溢出覆盖了 .data 段。从那以后我每次动地址映射或者存储器时序,都强制走一遍完整回归:读回 BRAM 比对、跑魔数、跑结果区、再看新功能。希望这些能帮你少熬两个晚上。
本文还有配套的精品资源,点击获取