折腾了两周,终于让PYNQ 4.0在这套“snickerdoodle black + piSmasher”的组合上稳定跑起来了。这块板子用的是Zynq-7020,双核Cortex-A9,FPGA逻辑资源做中低复杂度设计够用,但最大的问题是官方没有放出对应的PYNQ镜像,piSmasher扩展板上的外设驱动也没有现成方案,只能从零开始移植。整个过程走下来,相当于把PYNQ的 boot 链路、设备树、overlay 机制和外设驱动全捋了一遍。
这篇文章就把我的完整操作记录写出来:镜像怎么选、SD卡怎么分区、U-Boot 参数怎么设、设备树怎么改、piSmasher 的外设怎么映射,以及我踩过的各种坑。如果你手头也是第三方 Zynq 板卡,想跑 PYNQ 又找不到现成镜像,这篇文章可以直接当操作手册参考。
1. 项目概述:一个需要自己动手移植的软硬件组合
1.1 PYNQ 框架到底是什么,能解决什么问题
PYNQ 4.0 不是单纯的操作系统,它是一整套基于 Linux 的 FPGA 开发环境。它的核心思路是把 Zynq SoC 的 FPGA 部分封装成 Python 可以直接调用的“overlay”,软件工程师不用写 Verilog 或 VHDL,就能用 Python 操作 FPGA 里的 IP 核,实现硬件加速或者外设控制。
在官方支持的开发板上,PYNQ 有现成镜像,烧到 SD 卡开机就是 Jupyter Notebook 界面,非常省心。但 snickerdoodle black 这类第三方板卡没有官方镜像,我只能自己完成两件事:第一,把 PYNQ 的 rootfs 适配到这块板卡的硬件上;第二,保证 FPGA 部分的 overlay 机制在 Linux 里能正常加载。
选择 4.0 而不是更老的版本,是因为 4.0 的内核和 Python 包更新,Overlay 类会自动解析 hwh 文件里的寄存器映射信息,加载 PL 端的 IP 核比以前方便很多。但新版本也有代价——内核模块匹配更严格,移植时如果 rootfs 里的内核模块和实际内核版本对不上,启动过程会直接报错。这块我当时花了很长时间才搞清楚。
1.2 snickerdoodle black 和 piSmasher 的硬件构成
snickerdoodle black 的核心是 Zynq-7020,集成双核 ARM Cortex-A9 处理器和 Artix-7 架构的 FPGA 可编程逻辑。板载内存配置不算大,所以我在后续部署 rootfs 时尽量精简软件包,避免内存不足导致 OOM。板子引出了大量 MIO(多功能 IO)引脚,这些引脚同时服务于 PS 端外设和扩展板接口。
piSmasher 扩展板是这个项目的主角之一。它把 Zynq 的 MIO 和 EMIO 信号引到各种实用接口上,包含 I2C、SPI、UART、PWM、ADC 和一些传感器插座。理论上,把 piSmasher 插到 snickerdoodle black 上,就能扩展出很多物联网原型开发需要的外设接口。但实际麻烦在于,piSmasher 上没有独立的中继芯片,所有外设信号直接连到 Zynq 的引脚上,这意味着引脚复用(pin mux)必须由设备树精确配置,否则外设即使物理连接正确,Linux 端也完全感知不到。
1.3 移植工作的难点分布
我把整个项目的工作量分为三块,各占大约三分之一。第一块是基础镜像移植,包括 rootfs 解压、U-Boot 编译和启动参数调整;第二块是设备树编写,重点是 piSmasher 引脚的 MIO 复用配置;第三块是 overlay 设计与外设验证,主要是在 FPGA 里搭 AXI 接口的 IP 核,然后通过 Python 控制它们。
实际做下来,最难的不是 Linux 启动,而是搞清楚哪些引脚被板载 LED 占用,哪些被 piSmasher 外设使用,哪些必须留给 PL 端 AXI 总线接口。在没有任何完整文档的情况下,我是靠原理图结合实测,一点点试出来的。后面我会把最终能用的设备树配置片段放出来,尽量减少你重复踩坑的时间。
2. 构建带 PYNQ 的启动镜像:工具链与 boot 系统
2.1 版本匹配:PYNQ、Vivado 与 U-Boot 不能乱配
移植 PYNQ 第一步不是下载镜像,而是确认版本的匹配关系。PYNQ 4.0 的官方预编译 rootfs,对应的是 2021 年左右发布的硬件设计工具链版本。这意味着我在本机生成 bitstream 和 hwh 文件时,最好使用同一时期的工具版本,否则导出的硬件描述文件可能与 PYNQ 的 overlay 解析逻辑不兼容。
版本匹配这种事,看起来是小问题,实际很容易让人崩溃。我一开始用了新版本工具链去生成 overlay 文件,结果 PYNQ 加载时报了一堆莫名其妙的错误,后来换回与 PYNQ 4.0 同期发布的工具版本,同样的代码一次通过。所以这里我强烈建议:不要追求工具链版本最新,而是要跟官方 PYNQ 仓库保持一致。
镜像方面,官方网站下载的是 rootfs 压缩包,比如 pynq_rootfs.tar.gz,不是整卡镜像。这一点和官方开发板用户习惯的“写入一张卡就能开机”不同,需要自己完成分区和解压步骤。很多人在这一步就卡住了,其实操作本身不复杂,只是流程不太一样。
2.2 SD 卡分区与 rootfs 解压的完整操作
Zynq 平台从 SD 卡启动,至少需要两个分区。第一个是 FAT32 格式的 boot 分区,存放 BOOT.BIN、image.ub、设备树 dtb 和 boot.scr;第二个是 ext4 格式的 rootfs 分区,存放完整的 Linux 根文件系统。我的 SD 卡是 16GB,分区方案如下:
分区1: 512MB,FAT32,boot 分区 分区2: 剩余全部,ext4,rootfs 分区在 Linux 主机上操作时,我先把 SD 卡插入读卡器,用 fdisk 删除旧分区并重新创建两个主分区,然后用 mkfs.vfat 和 mkfs.ext4 分别格式化。rootfs 的解压操作看起来简单,但有一个非常关键的细节:必须以 root 权限解压。我第一次忽略了这一点,解压完成后所有设备节点的权限都不对,结果启动过程卡在 udev 设备创建阶段,等了很久都进不了登录界面。后来重新用 sudo 解压,启动时间从三分钟缩短到十几秒,差别非常大。
之后把编译好的 BOOT.BIN、image.ub 和对应的 dtb 复制到 boot 分区,SD 卡就准备好了。整个过程的逻辑顺序是:BootROM 从 SD 卡读取 BOOT.BIN 到片上内存,FSBL 初始化 DDR 和时钟,然后跳转到 U-Boot,U-Boot 再读取 image.ub 和设备树启动 Linux 内核。
2.3 编译 U-Boot 与启动脚本配置
snickerdoodle black 没有现成的 U-Boot 板级配置,最省力的办法是找一个同为 Zynq-7020 的参考板配置,在此基础上修改串口、DDR 和启动地址。DDR 参数是最容易出问题的,我一开始手上的 BOOT.BIN 是从别处找来的,上电之后 U-Boot 反复重启,连菜单都进不去。换用厂商预编译的 FSBL 后,DDR 初始化正常,问题才消失。
U-Boot 环境变量里最重要的一个是 bootargs,它告诉内核根文件系统在哪里。我使用的配置如下:
setenv bootargs 'console=ttyPS0,115200 root=/dev/mmcblk0p2 rw rootwait rootfstype=ext4'另外要注意内核镜像和设备树的加载地址不能重叠。比如把 image.ub 加载到 0x8000,设备树放到 0x200000,中间留足空间。如果设备树加载地址太低,内核启动到一半会把设备树覆盖,系统就会卡在“starting kernel”之后没有任何输出。这个问题排查起来特别容易误判,我一开始还以为内核镜像损坏,后来才意识到是地址冲突。
我把这些命令写进了 boot.scr,用 mkimage 工具生成 U-Boot 能识别的脚本格式。这样每次上电就自动完成环境变量设置和启动流程,不用每次手动敲命令。
3. 设备树设计与 piSmasher 引脚映射
3.1 设备树从哪来:借鉴相近板卡配置
设备树(DTB)是 Linux 用来描述硬件拓扑的文件,所有外设的地址、中断、引脚复用信息都在里面。PYNQ 的 rootfs 里已经带了很多官方板卡的设备树,但没有 snickerdoodle black 对应的。自己从头写一份完整的 dts 不现实,因为里面要描述的内容太多,很容易漏掉某个关键节点。
我采用的方法是:从官方 PYNQ 仓库里找一个同为 Zynq-7020 板卡的设备树源文件作为起点,然后只修改差异部分,主要就是 DDR 大小、以太网配置和 MIO 的 pin mux。设备树源文件通常包含一个通用的 zynq-7020.dtsi,里面描述所有 PS 端控制器,比如 UART、I2C、SPI、GPIO,板级 dts 文件负责把实际引出的外设和具体引脚对应起来。
dts 文件改完后,用 dtc 工具编译成 dtb,复制到 boot 分区。这里建议把编译命令固定在一个脚本里,因为后续调整 pin mux 的频率很高,手动敲命令容易出错。我就是因为偷懒没写脚本,结果每次改设备树都要重新回忆命令,浪费了不少时间。
3.2 MIO 复用与 pinctrl 配置的关键点
Zynq 的 MIO 引脚高度复用,同一个物理引脚可以配置成 UART、SPI、GPIO 或其它功能。piSmasher 扩展板把大量 MIO 信号引到接口上,如果设备树里的 pinctrl 配置不对,外设即便物理连着也工作不了。
我遇到的最典型的问题,是板载 LED 和 piSmasher 上某个 UART 功能使用了同一个 MIO 引脚。默认状态下这个引脚被配置成 GPIO,UART 的信号根本进不来,时通时断。解决方法是在设备树中为 UART 控制器指定 pinctrl-0 节点,把对应 MIO 的 mux 配置切换到 UART 功能。
&pinctrl0 { pinctrl_uart1_default: uart1-default { mux { groups = "uart1_9_grp"; function = "uart1"; }; }; }; &uart1 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&pinctrl_uart1_default>; };其中 groups 的名称和编号必须参考芯片手册,不同板卡的命名不完全一样。如果配错了,启动日志里会出现 pin configuration failed 之类的提示,或者外设工作不稳定。这里分享一个实用的排查技巧:不要一次性把所有外设都配置好,先用 UART 和 GPIO 跑起最小系统,确认基础功能正常后,再逐个增加 I2C、SPI 外设。同时改好几处 pinctrl,出问题后根本不知道是哪一处引起的。
3.3 PL 端 overlay 的地址规划
PYNQ 的 overlay 机制核心是运行时把 bitstream 加载到 FPGA,然后通过 hwh 文件建立 Python 和硬件 IP 之间的映射。我在设计自己的 overlay 时,必须保证 PL 端 AXI 地址不与 PS 端内存和控制器的地址空间冲突。
我的设计里,AXI GPIO 分配在 0x40000000,AXI I2C 分配在 0x40010000,这个范围属于标准的 PL 地址区,不会与 DDR 起始地址 0x00000000 冲突,也不会覆盖 PS 端外设地址区域。如果地址设置重叠,overlay 加载时可能不会立即报错,但后续 Python 访问寄存器时就会触发总线错误,甚至整个系统卡死。我调试时遇到过类似现象,一开始以为是 Python 代码写错了,后来查地址才发现是设计里的地址重叠问题。
另外,PYNQ 4.0 的 Overlay 类会自动比对 hwh 文件里的地址信息和设备树,如果两者不一致会抛出异常。所以每次修改 Block Design 后,硬件描述文件和 bitstream 要同时更新,名字保持一致,避免加载出旧配置。
4. 实操:从第一次上电到跑通 piSmasher
4.1 搭建串口调试台,观察完整启动日志
拿到板卡后,第一步不是插网线,而是先搞定串口。snickerdoodle black 上有调试串口,我使用 USB 转串口模块连接板卡对应的 TX、RX 和 GND 引脚,参数设置为 115200 波特率,8 个数据位,1 个停止位,无校验。打开串口终端后再给板卡上电,屏幕上会依次出现 BootROM、U-Boot、Linux 内核和登录提示符。
串口是排查一切问题的基础工具。如果终端完全没有输出,问题大概率在 BOOT.BIN 或者串口引脚连接上;如果输出到一半死掉,重点检查 U-Boot 的 DDR 参数;如果内核解压完但不出 shell,基本可以确定是 rootfs 挂载失败。有一个经验:每次修改设备树或启动参数后,都要盯着完整启动日志看,不要只盯最后几行。很多问题在日志中段已经露出苗头,等它表现在外设上时,排查成本已经高了很多。
4.2 配置网络并确认 Jupyter 环境
PYNQ 默认将开发板配置成一个 Jupyter 服务器,但第三方板卡没有预设 IP,需要手动设置。我给板卡配置了静态 IP,避免每次重启后地址变化导致 SSH 和浏览器连接不稳定。PYNQ rootfs 的网络配置文件位置取决于系统使用的是哪个网络管理器,我用的版本是 /etc/dhcpcd.conf,直接在文件里指定静态地址、掩码和网关。
配置完成后重启网络服务,然后在电脑浏览器访问板卡的 Jupyter 端口。新建一个 Notebook,输入:
from pynq import Overlay如果导入成功,说明 Python 环境和 pynq 包都没问题。这一步如果报错,先检查 rootfs 是否完整,再确认 Python 版本是否匹配。PYNQ 4.0 的 rootfs 里已经预装好了所有依赖,正常情况下不需要手动安装。另外,我遇到过防火墙拦截端口的情况,在板卡上放行对应端口后,浏览器才能正常访问,这个细节很容易被忽略。
4.3 用最小 overlay 验证 GPIO 控制
在接触 piSmasher 前,我先用最简单的设计验证整条 overlay 链路。我在硬件设计工具里创建了一个 Block Design,添加 Zynq PS 核和一个 AXI GPIO IP,GPIO 输出接到板上的一个 LED。综合实现后导出 bit 和 hwh 两个文件,上传到板卡同一目录。
在 Notebook 里执行:
ol = Overlay("design_1.bit") gpio_ip = ol.axi_gpio_0 gpio_ip.write(0xffff, 0)我看到 LED 点亮的那一刻,基本确定了:bitstream 加载成功、AXI 地址映射正确、Python 到寄存器的通路正常。这一步是整个项目的地基,后面 piSmasher 上所有外设控制,无论传感器还是电机,本质上都是在这个流程上增加不同的 IP 核和对应的 Python 封装。如果这一步都没跑通,就别急着碰复杂外设,先排查工具链版本和文件匹配问题。
这里有一个容易踩的坑:hwh 文件里 IP 名称和 Python 属性名对不上。比如 Block Design 里叫 axi_gpio_0,但生成 hwh 后实际名字可能是 axi_gpio_1,直接按自己记忆中的名字写代码就会报属性错误。解决办法是在 Vivado 里查看 Block Design 的 IP 实际名称,或者加载 overlay 后先用 dir(ol) 查看所有可用属性。
4.4 piSmasher 传感器读取:I2C 设备树与 Python 双重验证
piSmasher 板载的传感器挂在 I2C 总线上。我在设备树中确认 I2C 控制器已经配置好对应的 MIO,并在板级 dts 中添加传感器设备节点。然后先在 Linux shell 里扫描总线,确认传感器地址:
i2cdetect -y 1扫描结果中会显示类似 0x48 这样的地址,说明传感器已经在总线上响应。此时可以先用 i2cget 手动读取寄存器验证数据是否合理,例如:
i2cget -y 1 0x48 0x00这一步通过后,再进入 Python 环境操作。我一开始直接走 Python 的 smbus 库,但后来考虑到后续要在 FPGA 里做数据处理,更倾向于把 I2C 控制器放在 PL 端,也就是在 overlay 里加一个 AXI I2C IP,让 FPGA 直接管理总线,Python 通过读写寄存器获取数据。两条路都能用,区别是前者依赖 Linux 内核驱动,后者依赖 FPGA 逻辑。为了后续扩展方便,我最终选用了 AXI I2C 方案。
不过这里必须提醒一句:不要同时在系统 I2C 和 AXI I2C 上挂同一条物理总线,否则两个控制器会互相干扰,地址访问冲突,数据时对时错。我就因为两边同时开着,折腾了一个晚上才定位到问题。选一边,用到最后。
5. 踩坑实录与故障排查思路
5.1 上电反复重启:先查 DDR 参数
第一次上电时,U-Boot 打印完 DDR 信息就反复重启,没有任何明确的错误信息。这种问题几乎可以锁定在 DDR 参数配置上。Zynq 的 DDR 控制器初始化在 FSBL 阶段完成,如果 BOOT.BIN 里的 DDR 速度等级、寻址参数不对,表现就是无限重启。
解决办法是直接使用厂商预编译的 BOOT.BIN 作为启动固件,或者从板卡自带 Linux 系统的 U-Boot 中提取正确的 DDR 配置。从零开始调 DDR 参数非常痛苦,而且示波器都用不上,只能反复改配置测试。我的经验是,能用现成的就不要自己造。确认 BOOT.BIN 没问题后,再考虑 U-Boot 本身的地址映射错误。
5.2 内核解压后无输出:检查 fdt 加载地址
启动日志停在“starting kernel”之后,屏幕再无动静。最开始我以为内核镜像损坏,重新解压 rootfs 好几次,问题依旧。后来在 U-Boot 命令行手动查看加载地址才发现,设备树加载地址 0x8000 与内核镜像加载地址重合,内核在启动过程中覆盖了设备树,导致系统无法获取平台硬件信息。
调整方法很简单,把 fdt 加载地址改到 0x200000,确保与内核镜像地址保持足够间隔,同时确保 boot.scr 里设置的地址与实际加载地址一致。这类问题没有通用解决公式,需要在 U-Boot 阶段用 printenv 检查所有环境变量,逐步排除。
5.3 rootfs 权限导致启动卡在 udev
启动时卡在类似“udevd waiting for events”的界面,等了很久都无法进入登录提示符。这个问题几乎可以确定是 rootfs 解压时没有使用 root 权限,导致 /dev 设备节点的权限属性不对。udev 需要这些设备节点正常工作,权限错误时无法创建设备文件,启动流程就停在那里。
解决方式非常粗暴有效:重新解压 rootfs,所有文件操作加 sudo。解压完成后检查一下 /dev 目录的权限,如果发现大量文件属主不对,就是权限问题。这个问题同样会导致系统启动极其缓慢,即使最终能进入系统,整体性能也会受影响。我重新解压后启动速度提升了十几秒,可见权限问题的影响有多大。
5.4 Overlay 加载报错:检查 hwh 和 bit 是否配套
PYNQ 加载 bitstream 时会读取同目录的 hwh 文件来建立寄存器映射。如果 hwh 和 bit 不匹配,Python 会抛出 RuntimeError,提示找不到某些 IP。我遇到这个问题的原因是:在硬件设计工具里修改了 Block Design 后重新综合,但导出硬件时没有使用最新的 bit 文件,导致加载的是旧配置,描述文件却是新配置。
正确的做法是每次修改设计后,重新生成整套硬件导出文件,把 bit 和 hwh 一起上传,文件名保持一致。我后来把这条规则写进了自己的操作流程,再没出过同类问题。这类错误不会影响系统稳定性,但会浪费大量调试时间,而且报错信息常常让人误以为是 Python 代码的问题。
5.5 piSmasher 外设不稳定的排查顺序
piSmasher 板卡传感器读取时通时断,一开始怀疑接触不良,重新插拔好几次都没用。后来排查到 MIO 引脚的电气特性配置,发现是引脚驱动强度不足,信号边沿不够陡峭,I2C 时序不稳定导致数据偶尔出错。在设备树 pinctrl 节点中调整 slew rate 和驱动强度后,问题彻底消失。
Zynq 的 MIO 配置不仅仅是功能选择,电气特性同样关键。如果遇到外设偶尔掉线、数据偶发错误,第一反应不应该是硬件接触问题,先检查 pinctrl 配置。另外我还遇到过 GPIO 完全无反应的情况,排查发现对应的 MIO 被 PS 端 DDR 地址校验功能占用,这种引脚在普通 GPIO 模式下永远不会工作。这类问题只能对照原理图排查,没有捷径。
我在实际项目中的习惯是:拿到一块板卡,先跑原厂 Linux 系统,用 sysfs 逐个点亮可用 GPIO,测一遍 UART 和 I2C,把能用的引脚记录下来,然后再动手移植 PYNQ。很多人会跳过这一步直接开始改镜像,结果启动后外设不工作,根本分不清是软件问题还是硬件限制。先摸清板卡底细,再谈移植,会顺畅得多。