简介:面向计算机组成原理、嵌入式系统课程的学习者,这份《硬件结构设计》综合实验报告围绕开源RISC-V核picoRV32搭建完整SoC展开,覆盖需求分析、体系结构设计、模块划分、IP核集成到系统联调的完整流程。报告以PYNQ-Z2开发板与RISC-V-On-PYNQ Overlay为平台,说明如何在Jupyter Notebook中完成C/C++与RISC-V汇编代码的编译、调试与验证,并将二进制文件放到picoRV32上运行。包内为1个docx文档,压缩包约3.01MB,含实验说明、软硬件设计方案、运行结果与分析、实验心得等章节,重点记录模块设置与IP核包装、AXI总线连接、比特流生成、oDisk解压、文件放置及云平台功能验证等环节。已有1582人学习。读者可对照复现一套可运行的RISC-V SoC实验,理解硬件描述语言与交叉编译工具链的配合,并借鉴排错思路与团队分工经验。
1. 从一份 29 页实验报告说起:picoRV32 在 PYNQ-Z2 上跑通 SoC 到底要几步
一份 29 页的《硬件结构设计》综合实验报告,核心任务只有一句话:用开源的 picoRV32 核,在一块 PYNQ-Z2 上搭出能跑自己写的 C 程序和汇编的 SoC。听着像课程作业,真动手才发现它把平时分开练的三件事拧成了一股绳——RISC-V 软核的参数裁剪、AXI 总线上的 IP 集成、以及 PYNQ 那套「Python 加载 bitstream、Jupyter 里敲代码」的软硬件协同流程。它适合手上有一块 Zynq 板子、写过一点 Verilog 但没从头拼过 SoC、又想弄明白「一行 C 怎么变成 PL 里的电平」的人。这份报告真正的价值不在结论多漂亮,而在于把从 Vivado 封装 IP 到板端 Jupyter 验证的整条路径都留了痕,照着走一遍能省掉大量试错。
2. 拆开 picoRV32 的配置位:从 RV32E 到 RV32IMC,以及一次内存握手的时序账
picoRV32 最容易被误解的一点,是把它当成一个固定形态的 CPU。它不是,它是一份用参数裁出来的 RTL:同一套源码可以综合成 RV32E、RV32I、RV32IC、RV32IM、RV32IMC 五种形态,面积和性能差出一大截。选型逻辑不是「越大越好」,而是「你的程序到底用不用得上这个功能」。这一章把参数表和两个最容易接错的接口拆开讲,接错了后面所有调试都是白费。
2.1 九个配置位怎么选:先算面积,再选指令集
报告里提到 picoRV32 可配置为 RV32E、RV32I、RV32IC、RV32IM 或 RV32IMC,并可选择内置中断控制器。这句话背后是一张取舍表,我按实际踩过的顺序整理成下面这样:
| 参数 | 默认 | 打开/关闭后的变化 | 什么时候该动它 |
|---|---|---|---|
ENABLE_REGS_16_31 | 1 | 关闭后退化为 RV32E,只剩 x0–x15 十六个通用寄存器 | 逻辑资源极度紧张,且程序是手写汇编、不依赖编译器寄存器分配 |
COMPRESSED_ISA | 0 | 支持 C 扩展的 16 位指令,代码体积通常降两三成 | 程序要放进小容量 BRAM,容量吃紧 |
ENABLE_MUL | 0 | 支持乘法,MUL约 40 周期,MULH约 72 周期 | 程序里有乘法,但乘法不在热路径上 |
ENABLE_FAST_MUL | 0 | 换成多周期流水乘法器,用面积换吞吐 | 乘法在循环体内高频执行 |
ENABLE_DIV | 0 | 支持除法和取余,单次约 40 周期 | 程序里有除法或取模 |
BARREL_SHIFTER | 0 | 移位从每比特一拍变成单周期完成 | 移位密集,比如解析数据包、做位域操作 |
ENABLE_PCPI | 0 | 打开协处理器接口,可外挂自定义指令 | 想把某个算法硬化成一条指令 |
TWO_CYCLE_ALU | 0 | ALU 拆成两级,fmax 更高、周期数略增 | 时序收敛困难,布线后建立时间差一点 |
ENABLE_IRQ | 0 | 内置中断控制器,支持外部、定时器、软件中断 | 需要响应外部事件,而不是纯轮询 |
真正决定成败的不是这张表,而是表外的三个地址参数。下面这段实例化代码来自我在类似场景里的写法,编译过、上过板:
picorv32 #( .ENABLE_COUNTERS (1), .ENABLE_COUNTERS64 (1), .COMPRESSED_ISA (1), // 对应 -march 里的 c .ENABLE_MUL (1), // 对应 -march 里的 m .ENABLE_DIV (1), .ENABLE_IRQ (1), .PROGADDR_RESET (32'h0000_0000), // 复位向量,必须等于程序 BRAM 的起始地址 .PROGADDR_IRQ (32'h0000_0010), // 异常/中断入口,和链接脚本里的 .text.irq 对齐 .STACKADDR (32'h0000_3FFC) // 栈顶,指向 BRAM 末尾,栈向下增长 ) 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) );逻辑说明:PROGADDR_RESET是复位后第一条取指的地址,picoRV32 不会自己去读什么向量表,它上电就从这里开始执行。所以这个值必须和链接脚本里的ORIGIN一模一样,差一个字节都会取到空白存储器里的无效指令。STACKADDR决定sp的初值,写大了越界写穿 BRAM,写小了把代码段当栈用。
参数说明:ENABLE_COUNTERS和ENABLE_COUNTERS64建议都开,rdcycle、rdtime这类指令做性能计数很方便,代价是几十个触发器。PROGADDR_IRQ只有在开了ENABLE_IRQ时才有意义,它和复位向量之间要留出足够空间放中断服务程序,我一般留 16 字节以上。
注意:
-march和 RTL 参数必须严格对齐。RTL 关了ENABLE_MUL,编译器还按rv32imc生成乘法指令,跑起来就是非法指令异常直接跳飞,现象是 PC 突然跳到一个你没写过的地址。
还有一个选型问题值得说清楚:核有两个变体,picorv32提供简单的原生内存接口,picorv32_axi提供 AXI4-Lite 主接口。前者适合自己拼小 SoC,所有外设挂一条本地总线,地址译码自己写;后者适合直接接进 Zynq 的互联矩阵。报告里的方案是前者——核通过原生接口直连 BRAM,PS 侧通过 AXI4-Lite 从设备把程序写进 BRAM,这样核的取指延迟可控,PS 也不用等总线上那套握手机制。如果你要做多核共享本地 RAM,还会用到picorv32_axi_adapter,它的作用就是把原生接口桥到 AXI4,让几个核之间用本地接口通信、对外用 AXI 通信。
2.2 mem_valid / mem_ready 握手:SoC 里最容易被忽视的一对信号
原生内存接口只有八根线,报告里列得清楚:mem_valid、mem_instr、mem_ready、mem_addr[31:0]、mem_wdata[31:0]、mem_wstrb[3:0]、mem_rdata[31:0]。读传输时mem_wstrb全零,mem_wdata无意义;写传输时mem_wstrb按字节使能,一位对应一个字节。核心在mem_ready为高的那个周期采样或提交数据,不要求外部插入等待周期,也就是说从设备可以在mem_valid拉高的同一拍就把mem_ready拉高,甚至把mem_ready常量绑成 1。
看起来很简单,但真正翻车的地方全在「读数据什么时候准备好」这一拍上。下面是我常用的一个直连 BRAM 从设备写法:
// 单周期响应的从设备:写立刻生效,读用组合读避免晚一拍 reg [31:0] ram [0:4095]; // 16 KB 程序/数据空间 always @(posedge clk) begin if (mem_valid && mem_ready && (mem_wstrb != 4'b0000)) 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]; end end // 读数据用组合逻辑,保证与 mem_ready 同拍有效 assign mem_rdata = ram[mem_addr[13:2]]; assign mem_ready = mem_valid; // 零等待应答 assign mem_instr = 1'b0; // 仅用于调试统计取指次数,可忽略逻辑说明:mem_addr[13:2]取的是字内偏移,因为 BRAM 按 32 位组织,地址线低两位不参与索引。mem_ready组合跟随mem_valid,意味着每一拍都能应答;这也要求mem_rdata必须是组合输出,否则核在应答那一拍读到的是上一拍的旧数据。这是新手最常见的一个错:用同步读的 BRAM 加组合mem_ready,仿真里因为 X 传播看不出来,上板读回来的永远是 0。
参数说明:ram深度 4096 对应 16 KB,如果你的程序超过这个大小,要么加大深度要么压缩代码。写字节使能的四个分支不能合并成整字写,否则mem_wstrb就白给了,非对齐访问和字节写入全废。
如果你的 BRAM 必须用同步读(例如大容量块 RAM),有两条路:一是打开LATCHED_MEM_RDATA让核自己锁存读数据,并把mem_ready延后一拍;二是自己写一个状态机,mem_valid拉高后先等一拍再拉mem_ready。后者更通用,但要注意mem_valid在等待期间不会撤销,你的状态机必须幂等。
2.3 PCPI 协处理器接口:外挂指令的十六周期生死线
PCPI 是 picoRV32 里最有意思也最少人用的部分。当核遇到一条自己不支持的指令、且ENABLE_PCPI打开时,pcpi_valid拉高,pcpi_insn输出完整指令字,pcpi_rs1、pcpi_rs2输出已经解码好的源寄存器值。外部协处理器可以自己解码、执行,完成后拉高pcpi_ready;如果想把结果写回寄存器堆,就同时把结果放到pcpi_rd并拉高pcpi_wr。
关键在于那个十六周期超时:核在十六个时钟周期内没等到pcpi_ready,就会抛非法指令异常,跳到PROGADDR_IRQ。需要多周期的协处理器必须在成功解码后立刻拉高pcpi_wait并一直保持,直到pcpi_ready为止,这样才不会误触发异常。
// PCPI 桩:不实现任何自定义指令,但输入必须绑定,不能悬空 assign pcpi_ready = 1'b0; assign pcpi_wr = 1'b0; assign pcpi_rd = 32'b0; assign pcpi_wait = 1'b0; // 如果真要实现一条指令,最小骨架大致是这样: // always @(posedge clk) begin // if (pcpi_valid && pcpi_insn[6:0] == 7'b0001011) begin // custom-0 编码空间 // pcpi_wait <= 1'b1; // 立刻占住超时 // if (cnt == 0) begin // result <= pcpi_rs1 + pcpi_rs2; // 例如做一条自定义加法 // pcpi_rd <= result; // pcpi_wr <= 1'b1; // pcpi_ready<= 1'b1; // pcpi_wait <= 1'b0; // end // end // end逻辑说明:不想用协处理器时,四个输入信号必须显式绑常量。悬空输入在综合时可能被优化成锁存器,仿真时是 X,上板后行为随机。真要实现时,pcpi_insn[6:0]用来区分自定义指令空间,RISC-V 留了几段custom-0到custom-3的编码给厂商扩展,自己玩就用其中一段。
参数说明:十六周期这个数字是硬编码在 RTL 里的,改不了。所以协处理器的解码路径必须足够短,从pcpi_valid到pcpi_wait拉高最好在一两拍内完成,别在解码逻辑里塞大位宽的除法器。这也是我一般建议的划分方式:慢的东西放多周期状态机,用pcpi_wait顶住;快的组合逻辑才放在解码那一拍。
3. PYNQ Overlay 落地:从 Vivado 封装 IP 到 Jupyter 里的第一行 MMIO 写
3.1 Overlay 类与 .hwh:Python 侧怎么摸到 PL 里的寄存器
PYNQ 的整套机制用一句话概括:把 FPGA 的 bitstream 当成一个可以动态加载的软件库。报告里那段Overlay("base.bit")的示例只是入口,真正让 Python 摸得到寄存器的是同目录下的.hwh文件——它是导出硬件时生成的地址映射描述,记录了每个 IP 的物理基地址和地址范围。没有它,ip_dict里只有名字没有地址,MMIO根本无从下手。
from pynq import Overlay, MMIO # 实例化时默认会下载 bitstream,并解析同名 .hwh ol = Overlay("riscv_soc.bit") print(ol.ip_dict.keys()) # 先看清楚有哪些 IP 被识别出来 info = ol.ip_dict["riscv_soc_0"] # 名字取自 BD 里的实例名 base = info["phys_addr"] print(hex(base), hex(info["addr_range"])) # 基地址和范围,务必和 Address Editor 对上 mmio = MMIO(base, 0x1000) # 映射 4 KB,覆盖所有寄存器与 BRAM 窗口 mmio.write(0x00, 0x1) # 控制寄存器 bit0:释放 CPU 复位 print(hex(mmio.read(0x04))) # 状态寄存器:读回 PC 采样值逻辑说明:Overlay的构造函数会在PYNQ的 overlay 搜索路径里找同名.bit和.hwh,找不到.hwh就直接报错。MMIO的第二个参数是映射长度,必须覆盖你访问到的最大偏移,越界访问会抛RuntimeError而不是静默返回零,这一点比裸写/dev/mem友好得多。
参数说明:phys_addr是 PS 视角下的物理地址,和 RISC-V 核视角的地址不是一回事。核里面的PROGADDR_RESET是本地地址,PS 写 BRAM 用的是 AXI 地址,两者之间隔着一层地址译码,这一点下一节细说。
3.2 IP 核包装与 AXI 连线:三个必须核对的地址检查点
报告里的流程分得很细:模块设置与 IP 核包装、把生成的 IP 与其他组件相连、生成文件并覆盖原文件、解压、放入指定文件夹、在云平台上验证。落到 Vivado 里其实是一串固定动作,我把其中会出事的检查点单独拎出来:
- 用 Create and Package New IP 把 picoRV32、程序 BRAM、控制寄存器组包成一个 AXI4-Lite 从设备。注意 AXI 接口的位宽选 32 位,与核的原生接口一致,省掉一层位宽转换。
- Package IP 的 Addressing and Memory 页里,给每个 AXI 接口分配地址范围。这一步的数值就是后面
.hwh里的addr_range,填错了 Python 侧写越界。 - Block Design 里用 Run Connection Automation 把 Zynq PS 的
M_AXI_GP0接到你的 IP 上,同时接FCLK_CLK0和peripheral_aresetn。 - Address Editor 里点 Assign All,然后逐个核对:不能和 DDR 区间重叠,不能落在 PS 内部外设区间,从设备之间不能互相覆盖。
- Validate Design 通过后 Generate Bitstream,再 Export Hardware,务必勾选 Include bitstream,否则
.hwh有了但.bit没有。
第 4 步最容易糊弄过去,也最容易在后面炸。我一般会跑一段 Tcl 把分配结果打出来存成文本,和 Python 侧的ip_dict逐行对:
# 在 Vivado Tcl Console 执行:列出所有从设备的基地址与范围 assign_bd_address foreach seg [get_bd_addr_segs -of_objects [get_bd_addr_spaces processing_system7_0/Data]] { set off [get_property OFFSET $seg] set range [get_property RANGE $seg] puts "[get_property NAME $seg] offset=$off range=$range" }逻辑说明:assign_bd_address会自动分配,但自动分配的結果可能随 BD 改动而漂移。把结果打印出来固定成一张对照表,后续换机器、换 Vivado 版本时可以直接比对。这个习惯救过我一次——重新生成 BD 之后某个从设备的基地址悄悄挪了 64 KB,Python 侧还在写旧地址,现象是「程序写进去了但核不跑」。
参数说明:M_AXI_GP0是 PS 的主端口,用来访问 PL 从设备;如果后面要做大数据量搬移(比如把几 MB 的镜像灌进去),可以改用S_AXI_HP0高性能端口,但那需要额外配置数据宽度和突发长度,小规模实验用 GP 口足够。
3.3 交叉编译到投递:一条命令生成能跑的 .bin
硬件通了只是半边,另外半边是让 RISC-V 真的有东西可跑。报告里说可以在 Jupyter Notebook 里编写 C/C++/RISC-V 汇编,编译后把二进制放到 picoRV32 上运行。工具链是标准的 RISC-V 交叉编译器,关键是那串编译选项必须和 RTL 参数严丝合缝。
# 编译:-march 必须与 RTL 参数一致,-mabi=ilp32 对应 RV32 的 32 位 ABI riscv32-unknown-elf-gcc \ -march=rv32imc -mabi=ilp32 \ -nostdlib -nostartfiles -T link.ld \ -O2 -o verify.elf verify.S # 转成裸二进制,objcopy 会按链接脚本里的地址摆放 riscv32-unknown-elf-objcopy -O binary verify.elf verify.bin # 反汇编前 40 行,核对起始地址和指令编码是否真的落在 0x00000000 riscv32-unknown-elf-objdump -d verify.elf | head -40逻辑说明:-nostdlib -nostartfiles是因为裸核上没有 C 运行时,_start要自己写;-T link.ld指定链接脚本,让代码段的起始地址等于PROGADDR_RESET。objcopy -O binary把 ELF 里的段按地址摊平成连续字节流,这一步之后地址信息就没了,所以摆放位置必须由 Python 侧按约定偏移写入。
参数说明:-march=rv32imc里的i、m、c分别对应基础整数、乘除、压缩指令,必须和 RTL 的ENABLE_MUL、ENABLE_DIV、COMPRESSED_ISA一一对应。我一般会多编一个rv32i版本作为对照,如果rv32i能跑而rv32imc不能跑,问题一定在乘除或压缩指令上,排查方向立刻收窄。
链接脚本大概长这样,重点是ORIGIN和LENGTH:
MEMORY { RAM (rwx) : ORIGIN = 0x00000000, LENGTH = 16K } SECTIONS { .text : { *(.text.start) *(.text*) } > RAM .rodata : { *(.rodata*) } > RAM .data : { *(.data*) } > RAM .bss : { *(.bss*) *(COMMON) } > RAM }投递这一步在 Python 侧完成,把二进制按字拆开写进程序 BRAM 窗口:
with open("verify.bin", "rb") as f: img = f.read() # 程序 BRAM 在 AXI 侧的偏移,必须与 Address Editor 里的分配一致 PROG_OFFSET = 0x100 for i in range(0, len(img), 4): word = int.from_bytes(img[i:i+4].ljust(4, b"\x00"), "little") mmio.write(PROG_OFFSET + i, word) mmio.write(0x00, 0x1) # 最后一步才释放复位,避免核读到写了一半的程序 print("loaded", len(img), "bytes")逻辑说明:ljust(4, b"\x00")处理最后不足四字节的尾巴,否则int.from_bytes会报错。指令与数据都是小端,"little"不能省。最关键的顺序是先写满程序区再释放复位,如果复位先放开,核会在程序还没写完的时候就开始取指,跑出来的是半截指令,现象非常随机。
参数说明:PROG_OFFSET是 PS 视角的偏移,不是 RISC-V 的0x00000000。这两者的关系是:AXI 从设备把偏移0x100起的窗口映射到内部 BRAM 的0x000,所以 PS 写0x100等于核取指地址0x000。这个映射关系一定要在自纸画一遍,否则调到最后都不知道自己写的是哪。
4. Zynq-7000 的 PS 与 PL 怎么分账:RISC-V 核为什么必须挂在可编程逻辑侧
4.1 APU 与 OCM:PS 侧跑 Linux,给 RISC-V 当宿主
报告里对 PS 侧架构的拆解挺扎实:APU 里是双核 ARM Cortex-A9,带 NEON 和浮点单元、MMU、32 KB 指令缓存和 32 KB 数据缓存,共享 512 KB 二级缓存;片上还有 256 KB 的 OCM 静态存储器。真正让这套方案成立的不是这些算力,而是 PYNQ 基于 Ubuntu 根文件系统这件事——能用包管理工具,意味着交叉编译工具链、Python 生态、文件传输工具全都可以直接装,不用自己移植。
RISC-V 核在这里的角色是「租客」:它没有自己的外设控制器、没有自己的存储控制器、没有自己的启动流程,所有的程序加载、时钟供给、复位控制都由 PS 侧的 Linux 代劳。这也是我建议初学者从这个组合入手的原因——你不需要先解决「怎么给一个裸核烧程序」这个前置难题,Python 写几行就能把镜像塞进去。
需要留意的是 OCM 那 256 KB。有些方案会把 RISC-V 的程序镜像先放到 OCM 里再搬,但 OCM 在地址空间里的位置和 DDR 是重叠映射的,地址计算容易搞混。我的习惯是直接用 DDR 存镜像,加载时再往 PL 的 BRAM 里写,链路清晰,出问题也好定位。
4.2 AXI 互联与地址空间:基地址定错,一切白搭
Zynq-7000 的地址空间划分里,PL 侧的从设备通常被分配到 0x4000_0000 往上的高位段,低段留给 DDR 和片上存储器。这个约定不是强制的,但违反了就容易和 PS 的默认映射打架。下面这张表是我每次做地址规划都会填一遍的:
| 区域 | 典型用途 | 检查要点 |
|---|---|---|
| DDR 低段 | Linux 运行、镜像暂存 | 不要把自己的从设备塞进来,会和内核抢内存 |
| 片上存储器 | 启动代码、小缓冲 | 双映射地址要分清用的是哪一个 |
| PL 从设备段 | AXI4-Lite 寄存器、BRAM 窗口 | 各从设备范围不能重叠,边界留 4 KB 对齐 |
| 保留区 | 厂商保留 | 不要碰 |
地址规划之外还有一个隐性成本:AXI4-Lite 是单次传输协议,一次最多搬一个字。往 16 KB 的 BRAM 里灌镜像就是四千多次 MMIO 写,在 Python 里循环写大概要几百毫秒到一秒。这个速度做实验够用,但如果要做大镜像加载,就得换成带突发的 AXI4 主端口,或者干脆让 PS 侧的 DMA 去搬。报告里的方案停留在 Lite 层面,属于教学取舍,实际工程里该换就换。
互联部分还有一条经验:AXI 从设备的aresetn一定要接 PS 侧的peripheral_aresetn,不要自己造一个上电复位。因为 PL 的配置是 PS 在启动之后才做的,自己造的上电复位时序和 bitstream 下载时序对不上,轻则核不启动,重则 AXI 总线挂死,Jupyter 内核直接卡住。
4.3 启动顺序与 bitstream 动态加载:为什么复位必须由软件释放
整套系统的启动链条是这样的:PS 先上电启动,加载 Linux,PYNQ 服务起来,Jupyter 可用;用户在 Notebook 里实例化 Overlay,bitstream 被写进 PL 的配置存储器,PL 逻辑开始工作。也就是说,PL 里那个 RISC-V 核在 bitstream 下载完成之前根本不存在,所有「上电复位」的假设都不成立。
这解释了一个很实际的现象:很多人把复位写成 PL 内部的计数器,上电后数若干拍自动释放,结果上板发现核有时候跑有时候不跑。原因是 PL 的配置时刻不确定,计数器可能在配置过程中就被触发了,等逻辑真正生效时复位早就释放完,核从一堆未初始化的 BRAM 里取指。正确做法是把复位做成一个软件可写的寄存器位,Python 侧先把镜像写完,再写这一位释放复位。这个顺序在上一节的投递代码里已经体现,mmio.write(0x00, 0x1)必须是最后一步。
重加载 Overlay 的时候同理,先写复位位把核停住,再去加载新的 bitstream。如果核还在跑、还在发 AXI 请求的时候把 PL 重新配置了,PS 侧的总线事务会悬空等待,表现就是 Notebook 里那一格永远不返回。
5. 避坑:picoRV32 + PYNQ 联调阶段的五处高频翻车
联调阶段的问题有个共同特征:现象都很像,原因却分散在硬件、工具链、地址映射三个层面。下面这五条是我在不同项目里反复遇到的,按「现象 → 原因 → 解决」写,方便对照排查。
5.1 时钟与复位:程序计数器纹丝不动的三种可能
现象:Python 侧所有写操作都返回成功,读状态寄存器里的 PC 采样值,一直是 0,怎么等都不变。
原因通常是三种之一。第一种,resetn极性搞反了,Verilog 里按低有效写,BD 里接的却是高有效的复位信号,核永远处于复位态。第二种,复位寄存器写进去了但没生效,因为控制寄存器的写使能条件和 AXI 握手没对上,写操作被静默丢弃。第三种,PL 的时钟压根没跑,FCLK_CLK0没有在 PS 侧使能,或者时钟约束里频率设成了 0。
解决:先别急着看核,先验证时钟。在 PL 里加一个自由运行的计数器,把高几位接到状态寄存器上,Python 侧连读两次,值在变说明时钟正常。然后单独测复位寄存器,写 0 读回 0、写 1 读回 1,确认写通路没问题。最后才去查核的resetn极性。这个顺序能把排查范围从「整个系统」缩到「一根线」。
5.2 存储器时序:mem_ready 常量 1 掩盖的读延迟
现象:仿真里跑得好好的,上板之后核取到的指令全是 0,PC 一路往上加,程序完全不按预期执行。
原因:用同步读的块 RAM 做程序存储器,却把mem_ready组合绑成了mem_valid。核在应答那一拍采样mem_rdata,而同步 RAM 的数据要下一拍才出来,于是永远读到上一拍的旧值,第一条指令就是错的。仿真里因为初始值常常是 X 或者恰好对上,看不出问题。
解决:两条路,打开LATCHED_MEM_RDATA让核自己多等一拍锁存,或者把mem_ready延后一拍并配一个简单的等待状态机。我更倾向后者,因为行为显式、时序好约束。验证方法是先不跑程序,用 Python 往 BRAM 里写一组已知图样(比如 0xDEADBEEF 递增),再原样读回来比对,读回不一致就说明存储器通路本身有问题,跟核没关系。
5.3 工具链与地址:编译参数、栈指针、重载 Overlay 三处连环坑
现象一:程序跑到乘法就跳飞,PC 出现在异常向量地址上。原因是编译器按rv32imc生成了mul指令,RTL 里ENABLE_MUL是 0,核把它当非法指令处理。解决办法是把两边对齐,或者在编译时降级到rv32i先验证基础通路。
现象二:程序跑几十条指令后数据开始错乱,全局变量被莫名其妙改写。原因是STACKADDR指向了 BRAM 范围之外,栈向下增长时踩到了别的区域;或者链接脚本里LENGTH写得比实际 BRAM 大,.bss段直接溢出去。解决办法是编译后跑一遍riscv32-unknown-elf-size verify.elf看各段大小,链接脚本里至少留 256 字节余量,STACKADDR取 BRAM 末尾往前 4 字节。
现象三:第二次加载 Overlay 时 Jupyter 内核无响应,只能重启。原因是核还在跑、还在发 AXI 事务,PL 被重新配置导致总线挂死。解决办法是加载新 bitstream 之前,先写复位寄存器把核停住,等一两毫秒再调Overlay()。这个习惯我后来固定成了模板代码,只要涉及重加载就一定先停核。
6. 把验证做扎实:八条汇编指令加上一段 Python 回读校验
调通「能跑」和确认「跑对」是两件事。我见过太多实验报告停在「串口打印了一行 hello」就收工,结果换一块板子、换一个 Vivado 版本立刻失效,因为从来没验证过数据通路的正确性。真正省时间的做法是写一个最小自检程序,让硬件自己把结果写到一个约定地址,Python 侧读回来比对。
先写八条指令的汇编,做完三件事:往数据区写一个特征值、往控制寄存器写完成标志、死循环停住:
# verify.S —— 最小自检:特征值写入 + 完成标志置位 .section .text.start .globl _start _start: li t0, 0x00000200 # 目标地址,落在数据 BRAM 区间 li t1, 0x5A5A5A5A # 特征值,选它有讲究:字节模式 0x5A 便于肉眼识别 sw t1, 0(t0) # 整字写入,验证 mem_wstrb 全 1 的通路 li t2, 0x00000001 sw t2, 0x0C(zero) # 写控制寄存器偏移 0x0C,作为"完成"标志 1: j 1b # 死循环,防止跑飞后行为不可预测逻辑说明:sw t1, 0(t0)触发一次四字节全使能的写,能同时验证地址译码、字节使能、BRAM 写端口三样东西。写完立刻写完成标志,Python 侧就可以用轮询等待,而不是盲等固定时间——后者在不同板子上延迟差异很大,容易误判。
参数说明:0x00000200这个数据地址要和链接脚本里的 RAM 范围错开,别和代码段重叠。0x0C是控制寄存器组里的一个只写偏移,硬件上就是一个带写使能的触发器,不占存储资源。
Python 侧的校验脚本:
import time mmio.write(0x00, 0x1) # 释放复位,核开始执行 for _ in range(200): # 最多等 2 秒,够核跑完几十条指令 if mmio.read(0x0C) & 0x1: # 轮询完成标志 break time.sleep(0.01) else: raise TimeoutError("核没有置完成标志,先查复位与时钟") got = mmio.read(0x200) # 读回特征值 assert got == 0x5A5A5A5A, f"数据通路异常,读回 {got:#010x}" print("自检通过")逻辑说明:for ... else这个结构在 Python 里表示「循环正常走完没有 break 才执行」,用来做超时判断比计数器更直观。assert里带上读回值,出错时不用再手动加打印。
参数说明:mmio.read(0x200)的偏移要按 AXI 窗口的映射换算,如果数据 BRAM 在 AXI 侧从 0x2000 开始,那这里就得写 0x2200。这个换算关系一定要在代码旁边写注释,否则过两周自己都看不懂。
自检通过之后,我还会加一个更细的技巧:把 PC 采样寄存器做成只读,Python 侧每隔 100 毫秒采一次,采五次。如果五次值完全一样,说明核卡在死循环或者异常处理里;把五次的值和objdump出来的反汇编逐行对照,基本能定位到具体哪条指令出的问题。这个手段比接逻辑分析仪快得多,也不需要额外的调试核。
从那以后我每次碰软核 SoC,不管多急,都强制走一遍「写完镜像 → 释放复位 → 轮询完成标志 → 读回特征值」这四步,任何一步不过就不往下走。硬件调试最大的成本从来不是写代码,是在一堆看起来都对的信号里找一个不对的。希望帮到你。
本文还有配套的精品资源,点击获取