最近在折腾PYNQ刚跑通第一个AXI_GPIO实验趁着热乎劲把整个过程整理出来。PYNQ这个东西简单说就是Xilinx的一款开源开发框架让你可以用Python直接操作FPGA上的外设和逻辑省去了写一堆C代码或者复杂的硬件描述语言的麻烦。对于想玩FPGA但又不想被底层细节劝退的朋友PYNQ绝对是个好入口对于已经在搞Zynq开发的人来说PYNQ也能大幅缩短调试周期。这篇博文主要围绕“AXI_GPIO”这个最基础的实验展开。我会从环境准备、硬件搭建、Python控制、原理解析到问题排查完整走一遍流程。内容偏实操适合刚入手PYNQ的初学者也适合想快速评估PYNQ开发体验的老手。1. 先说结论AXI_GPIO到底解决什么问题1.1 从一个最简单的需求说起假设你手头有一块Zynq开发板板上带了几个LED和按键你希望用代码控制它们。传统思路是在FPGA里写Verilog/VHDL逻辑把LED和按键的引脚绑定到芯片的IO上然后综合、实现、生成比特流再把比特流下载到板子里。整个过程对新手来说光是把开发环境跑起来、把工具链的坑填完就可能花掉半天时间。而PYNQ的思路是直接在板子上跑一个轻量级的Linux系统系统里预装了Python和Jupyter Notebook交互环境。你只需要在浏览器里新建一个Notebook写几行Python代码就能通过AXI总线访问FPGA里的外设——包括GPIO、UART、PWM、I2C之类。也就是说PL可编程逻辑里的硬件资源被封装成了Python可以“看见”的内存地址读写这些地址就等于操作了对应的硬件引脚。这就是AXI_GPIO的核心作用。1.2 为什么选择AXI GPIO而不是直接用PS的MIOZynq芯片本身有PS处理系统部分也自带一些MIO引脚可以直接当GPIO用。但MIO的引脚数量有限而且很多被外设复用比如DDR、SD卡、以太网留给用户的可自由配置引脚很少。更重要的是PS自带的GPIO只能访问PS侧固定的引脚无法扩展到PL逻辑里。AXI_GPIO是把GPIO控制逻辑放在PL里通过AXI总线与PS的处理器对接。这样一来你可以把任意数量的GPIO核挂到AXI总线上每个核又可以配置多路通道每条通道支持最多32个GPIO引脚。这个灵活性就大得多了而且不影响PS侧的资源占用。所以在需要大量、灵活GPIO的场景下AXI_GPIO是首选方案。1.3 整个实验的最终效果我这个“初体验”实验最终实现了两件事一是用Python让板载LED按指定规律闪烁二是读取板载按键的状态并在Notebook里打印出来。如果按键按下LED的状态也跟着翻转——一个最简单的“按键控制灯”交互逻辑就这样在几分钟内跑通了。整个过程不需要写一行Verilog也不需要额外的下载器全靠板上的Linux环境和Jupyter Notebook。2. 准备阶段硬件、SD卡镜像与开发环境2.1 硬件清单一块支持PYNQ的开发板这里用的是某基于Zynq的板卡型号不展开过程通用一张16GB以上的Micro SD卡建议用Class 10或更高读写速度的卡镜像文件好几GB写入速度太慢会非常折磨人一根网线用于连接板子和路由器或电脑一根Micro USB转串口线调试用视需要一个5V/2A左右的电源适配器务必先确认板子的供电要求注意不同板卡的镜像文件不通用。比如针对某款Zynq开发板的镜像不能直接刷到另一块板子上因为板载资源、DDR型号、外设配置都不一样。去PYNQ官方仓库下载镜像时先确认是不是匹配你的板卡。2.2 烧录SD卡镜像PYNQ官方提供了预编译好的镜像里面已经装好了Linux系统、Python环境、Jupyter Notebook以及PYNQ相关库。不需要自己从头交叉编译这是PYNQ对新手最友好的地方之一。烧录工具我推荐用RufusWindows环境或者balenaEtcher跨平台流程都一样标准把SD卡插入读卡器接上电脑打开烧录工具选择下载好的PYNQ镜像文件一般是.img或.rar后缀rar的话先解压出.img选择SD卡对应的盘符开始烧录烧录完成后安全弹出SD卡有一点要特别注意烧录会把SD卡上所有数据清空操作前先确认没有重要文件。2.3 启动板卡并访问Jupyter Notebook把烧录好的SD卡插入开发板连接网线然后上电。板子启动后通常需要一两分钟完成Linux系统的初始化。这里有个小坑如果板子是通过路由器DHCP获取IP地址那么你需要进路由器的管理页面找到这台新接入设备的IP。如果嫌麻烦也可以在电脑上安装一个串口终端工具比如MobaXterm、PuTTY通过USB转串口连接板子登录后执行ip addr命令直接查看IP。拿到IP之后浏览器访问http://开发板IP:9090就能看到Jupyter Notebook的界面了。默认密码一般是xilinx。登录进去之后左侧文件列表里会自带很多示例Notebook覆盖了GPIO、PWM、音频、视频、神经网络加速等多个类别。我们这次主要看GPIO相关的内容但不用完全按照官方示例来完全可以自己写。3. 硬件侧搭建创建一个AXI_GPIO核3.1 打开Vivado并创建工程虽然PYNQ的默认镜像自带了一些示例Overlay但如果你想用自己定义的硬件逻辑比如想控制板子上某个特定引脚还是需要走一遍完整的硬件设计流程。我用的版本是Vivado 2020.1和PYNQ镜像自带的Linux内核版本配合没有问题。高版本的Vivado也能用但可能需要额外打补丁新手建议直接用官方文档推荐的版本。创建一个新的Block Design工程新建项目选择板卡型号不同板卡选对应的Board Part在IP Integrator里创建一个Block Design添加Zynq UltraScale MPSoC或者Zynq-7000处理系统IP取决于你的板卡运行Block Automation让工具自动连接PS侧的基本接口比如DDR、UART、GPIO等3.2 添加AXI_GPIO IP在Block Design里右键添加IP搜索“AXI GPIO”拖动到画布上。然后需要配置它的参数GPIO Width控制通道1的引脚数量比如设为2表示这个GPIO核有2个输出引脚GPIO2 Width如果勾选了第二个通道GPIO2可以设置第二个通道的引脚数量Direction默认是双向可以改为Output或InputAll Outputs、All Inputs如果你确定只需要输出或输入勾选后可以在写HDL代码时减少不必要的逻辑AI也有辅助生成如果板子上的LED是低电平点亮那逻辑上要注意初始值最好设为1否则上电瞬间LED会闪一下初始化前管脚默认电平不确定。这个细节我在第5节会再展开。3.3 地址分配与连接在Block Design里AXI GPIO核需要与PS的AXI从机接口相连。通常在PS端会有一个名为axi_gpio_0的从机接口把它连接到AXI GPIO的S_AXI端口上。然后还需要一个AXI互联模块AXI Interconnect用来把PS的AXI总线和多个从设备连接起来。Vivado的Connection Automation可以自动完成这些连接。连接完成后需要手动分配地址。双击AXI GPIO核在“Address Editor”选项卡里给它分配一个基地址Base Address。这个地址很关键后面Python代码里操作寄存器都是基于这个地址来的。不同板卡的默认地址布局不同常见的是基地址在0xA000_0000附近。然后依次右键Block Design选择“Generate Output Products”再选择“Create HDL Wrapper”综合、实现、生成比特流生成比特流之后会得到三个关键文件.bit比特流文件、.tcl描述Block Design的TCL脚本、.hwh硬件描述文件。PYNQ加载Overlay时需要这三个文件。3.4 导出到开发板把这三个文件放到开发板上任意一个你能访问的目录比如新建一个/home/xilinx/pynq/axi_gpio_test/或者直接放到Jupyter文件管理页面的某个工作目录下。PYNQ的Overlay加载逻辑是通过指定的.bit文件生成一个Overlay对象然后工具会自动解析同名的.tcl和.hwh文件把里面声明的各个核的基地址信息导入到ip_dict里。所以你加载代码里需要用到的文件名和放在板子上的文件名要完全一致。4. Python侧实现用pynq库驱动AXI_GPIO4.1 加载Overlay一切就绪后在Jupyter Notebook里新建一个Python3代码单元格先写这段from pynq import Overlay # 注意文件名要和板子上的实际文件名完全一致 overlay Overlay(axi_gpio_test.bit) # 查看Overlay里包含哪些IP print(overlay.ip_dict.keys())运行之后如果输出里包含类似/axi_gpio_0这样的条目说明Overlay加载成功而且AXI GPIO核已注册。此时比特流已经被写入PL部分硬件逻辑已经开始运行了。常见现象是加载时提示找不到对应的.hwh文件。这时候检查一下三个文件是否都拷贝齐了以及文件名是否一致。Overlay类内部其实是通过.hwh文件解析地址没有它即使.bit能加载也无法用overlay.axi_gpio_0这样的方式访问。4.2 直接访问地址空间手动读写先演示一种偏底层的写法虽然现在直接用pynq的GPIO封装更方便但理解这种写法能帮你搞清楚PYNQ到底在干什么。根据第3节设计的AXI GPIO它的基地址假设是0xA000_0000。AXI GPIO IP内部有4个寄存器32位控制16个引脚具体定义是偏移地址寄存器名作用0x00GPIO_DATA数据和输出控制0x04GPIO_TRI每个bit对应一个引脚的方向控制0x08GPIO2_DATA第二个通道的数据寄存器0x0CGPIO2_TRI第二个通道的方向寄存器对于GPIO_DATA寄存器如果对应的TRI位为0则该引脚被配置为输出此时DATA寄存器对应位控制引脚高低电平如果对应的TRI位为1则该引脚被配置为输入此时DATA寄存器对应位读到的是引脚电平对于GPIO_TRI寄存器值为0表示Output值为1表示Input高阻态所以在数据读写之前必须先把GPIO_TRI设置为正确的方向。下面的代码示范了如何用pynq底层操作方式直接通过内存映射读写物理地址import os import mmap # 计算寄存器地址用生成文件时分配的基地址和偏移 base_addr 0xA0000000 GPIO_DATA base_addr 0x00 GPIO_TRI base_addr 0x04 # 打开 /dev/mem 并映射物理地址到用户空间 with open(/dev/mem, rb) as f: mm mmap.mmap(f.fileno(), length4096, offsetbase_addr) # 把 bit0-1 设置为输出低两位写0其他bit保持1输入 # 先读当前值然后修改低两位 # 注意mmap默认会缓存需要先用munmap/msync刷新 def write_reg(addr, value): # 这里简化处理实际用 ctypes 或者 struct 封装偏移 pass这种写法的缺点是代码不够直观还要处理字节序和内存对齐。所以更推荐直接用pynq内置的GPIO类这也是我们实验的主要写法。4.3 用pynq GPIO类控制LED在Overlay已经加载的情况下可以直接通过overlay对象拿到对应的GPIO实例前提是IP的名字能够从.hwh文件里解析出来。from pynq.lib import GPIO # 获取AXI GPIO核实例 axi_gpio overlay.axi_gpio_0 # 读取基地址 print(Base Address:, hex(axi_gpio.base_addr)) # 直接把bit0-1设为输出 axi_gpio.write(0x04, 0xFFFFFFFC) # GPIO_TRI: 低两位0其他位1 # 点亮LED1bit0写1 axi_gpio.write(0x00, 0x00000001)这段代码里write(0x04, 0xFFFFFFFC)就是写GPIO_TRI寄存器把bit0和bit1设为输出模式0其他引脚保持输入模式1。write(0x00, 0x00000001)则是写GPIO_DATA寄存器让bit0输出高电平也就是点亮第1个LED。如果你的AXI GPIO核配置的是全输出All Outputs那GPIO_TRI寄存器实际上会被硬件忽略那么写不写方向寄存器无所谓data寄存器直接控制引脚。不过为了通用我还是建议保持手动设置方向。4.4 LED频谱闪灯示例掌握了单灯控制之后写一个循环闪烁就非常自然了import time # 配置方向寄存器16个引脚全部输出 axi_gpio.write(0x04, 0x0000FFFF) # 循环让bit0到bit3依次点亮、熄灭 for i in range(5): axi_gpio.write(0x00, 0x0001) # 只有bit0亮 time.sleep(0.2) axi_gpio.write(0x00, 0x0002) # 只有bit1亮 time.sleep(0.2) axi_gpio.write(0x00, 0x0004) # 只有bit2亮 time.sleep(0.2) axi_gpio.write(0x00, 0x0008) # 只有bit3亮 time.sleep(0.2) # 全部熄灭 axi_gpio.write(0x00, 0x0000)这里有个小技巧如果LED是低电平点亮开发板常见那写1反而会熄灭。这种情况你就把逻辑反过来数据位写0表示亮写1表示灭。具体看板子上的电路设计不放心就用万用表量一下引脚电平。4.5 读取按键GPIO2通道AXI GPIO的第二个通道GPIO2经常被用来接输入比如按键。假设板上的两个按键接到GPIO2的bit0和bit1那么配置和读取的代码如下# GPIO2_TRI 寄存器偏移 0x0C # 设置bit0-1为输入值为1 axi_gpio.write(0x0C, 0x00000003) # 读取 GPIO2_DATA 寄存器偏移 0x08 data axi_gpio.read(0x08) # 掩码提取对应按键状态 btn0 (data 0) 0x1 btn1 (data 1) 0x1 print(fbtn0{btn0}, btn1{btn1})按键按下去时电平是什么状态取决于原理图。一般是按下接地松开接高电平也有反过来的。所以读出来的值你需要在实际板子上按一下看变化然后确定逻辑。4.6 把按键和LED结合起来做成一个Python脚本用GPIO2读到按键状态然后根据状态改变GPIO1的LED输出这样一个最简单的交互式程序设计如下def init_gpio(): # channel 1: 16个LED输出 axi_gpio.write(0x04, 0x0000FFFF) # channel 2: 2个按键输入 axi_gpio.write(0x0C, 0x00000003) def get_btn(): data axi_gpio.read(0x08) return ((data 1) 0x1, data 0x1) def set_leds(pattern): # pattern 是16位整数每一位控制一个LED axi_gpio.write(0x00, pattern) init_gpio() for _ in range(10): btn0, btn1 get_btn() if btn0 1: set_leds(0xFFFF) # 全部点亮 elif btn1 1: set_leds(0x5555) # 亮奇数位 else: set_leds(0xAAAA) # 亮偶数位 time.sleep(0.1)这段代码已经具备了实际项目的气象读取输入状态根据状态决策再驱动输出设备。如果再加上中断或者高频轮询就能应对更多真实场景。5. 原理解析Python为什么能“看见”FPGA的引脚5.1 地址映射的本质AXI-Lite总线AXI-GPIO核内部原理并不复杂。它内部本质上是GPIO控制器挂在一组AXI4-Lite总线上。Zynq的PS部分通过存储器映射的方式访问这段地址空间也就是说当PS端CPU执行对某个物理地址的读写指令时AXI总线协议转换器会把这次读写转换成对AXI-Lite从设备的写操作或读操作。AXI-Lite是一种轻量级的AXI协议适合低吞吐量的寄存器读写正好适合GPIO这种外设。每次读写操作只传输一个字的数据32位不需要突发传输因此非常简洁。用人类的话说你往某个内存地址写一个数总线上会响起一声“我要写这个地址数据是XXX”的信号AXI GPIO核听到之后就把对应引脚的电平拉高或拉低。Python端的读写最终是通过/dev/mem设备文件完成的。Linux把物理内存映射到了用户空间PYNQ则在Python库的层面包装了这些读写让你不用关心底层是用了mmap还是ioremap反正最后表现就是一个write()函数调用。5.2 PYNQ Overlay机制Overlay这个词在PYNQ里指的是“把FPGA的比特流文件当成一个可编程的硬件库来使用”。加载Overlay(xxx.bit)的时候PYNQ实际上做了三件事下载比特流把FPGA的配置数据写入PL让逻辑电路开始工作解析硬件描述读取.hwh文件里的信息知道这个设计里有哪些IP核、它们的基地址是多少构建Python对象在overlay.ip_dict里维护这些IP的名字和地址映射关系同时自动把每个支持PYNQ封装的IP转换成对应的Python类实例所以当你使用overlay.axi_gpio_0时你得到的其实是一个已经绑定好地址的对象。你不需要手动去计算偏移就可以直接调用.write()和.read()。5.3 地址分配与偏移的对应关系虽然前面提到可以用用overlay.axi_gpio_0简化操作但如果你想完全搞懂实验里每一步在做什么最好还是看懂地址对应关系。AXI GPIO核的寄存器偏移是固定的偏移寄存器说明0x00GPIO_DATA通道1的数据寄存器读操作返回引脚电平写操作设置输出0x04GPIO_TRI通道1的三态控制寄存器对应位写0表示输出写1表示输入0x08GPIO2_DATA通道2的数据寄存器0x0CGPIO2_TRI通道2的三态控制寄存器而基地址则是在Vivado里分配的不同设计的分配不同。拿到基地址后实际寄存器地址就是“基地址 偏移”。比如基地址是0xA000_0000那么GPIO_DATA就是0xA000_0000GPIO_TRI就是0xA000_0004。5.4 为什么寄存器值会反直觉我刚开始实验时有个困惑把GPIO_TRI的0x04寄存器某些位写0反而让引脚变成了输出写1反而变成了输入。这不直观但仔细想一下是有道理的。三态控制位在这里的含义是“是否为高阻态”写1代表“引脚不驱动外部电路”也就是输入写0代表“引脚由输出驱动电路控制”也就是输出。所以寄存器名是TRITristate而不是DIRDirection。理解这个之后配置方向就再也不会错了。6. 常见问题与避坑指南6.1 Overlay加载失败这是PYNQ新手遇到的第一个高频问题。表现是执行Overlay(xxx.bit)时报错找不到文件或者IP。原因基本是以下三种文件名不一致大小写、扩展名、路径任何一处不对都会导致问题在Jupyter里可以用!ls命令确认文件目录缺少.hwh文件PYNQ依赖.hwh解析IP信息没有它无法访问ip_dict比特流和硬件描述不匹配修改过Block Design后重新生成比特流忘记更新对应的.hwh和.tcl文件我自己的经验是每次在Vivado里改完Block Design导出时三个文件要一起拷贝不要只拷.bit。而且最好把上一次的.tcl也一起保留这样恢复工程时少走弯路。6.2 写寄存器没反应如果你确定Overlay加载成功了地址也写对了但写GPIO_DATA引脚没反应按优先级排查检查GPIO_TRI寄存器还没有被设置为输出。如果保持默认值通常是全1引脚都是输入状态写DATA寄存器当然不会让外部引脚变化检查AXI GPIO核的配置里有没有勾选“All Outputs”。如果没有勾选芯片初始时所有引脚都是高阻态需要先写TRI检查硬件连接。LED是否确实接到了对应的PL引脚有些板子的LED是通过EMIO或者专用GPIO口连接的不一定能被AXI GPIO控制检查电平极性。低电平点亮和高电平点亮的控制逻辑正好相反6.3 读取按键不稳定按键在机械接触过程中会产生抖动单次读取很容易读到中间状态。解决方法是软件去抖检测到电平变化后延迟10到20毫秒再读一次两次结果一致才确认有效。下面是一个简单的去抖逻辑def read_debounced(gpio, mask): # 第一次读 state1 (gpio.read(0x08) mask) ! 0 time.sleep(0.02) # 第二次读 state2 (gpio.read(0x08) mask) ! 0 return state1 and state2 # 只有两次都是按下状态才返回True如果你的项目需要更多按键或者要求响应更快那就要用到中断在Linux下配好中断控制器之后可以用Python的asyncio或者驱动级阻塞读来实现但那已经不是“初体验”的范畴了。6.4 Jupyter内核崩溃或重启在Notebook里反复加载Overlay、写寄存器有时Python内核会无响应。这往往是因为内存问题——每次Overlay()调用都会加载新的比特流并且分配用户空间内存如果循环加载很多次旧的对象没有及时释放。最简单的规避方法在一个Notebook里尽量不要反复创建和销毁Overlay对象最好像这样# 前面已经创建 if overlay not in globals(): overlay Overlay(axi_gpio_test.bit)如果内核已经崩溃就只能重启内核然后在新的Notebook会话里重新执行代码。6.5 不同版本PYNQ镜像的兼容性PYNQ官方对Python库和Linux内核的版本很敏感。我最早用的PYNQ 2.5镜像配Vivado 2020.1没问题但换了高版本的Vivado生成的比特流有时会提示“比特流与目标器件不匹配”。解决方法是优先匹配官方文档中标注的Vivado版本实在要用新版本就要重新生成设计并确保所有约束文件都匹配当前器件。6.6 一些小提醒不要直接用电源按钮瞬间断电尽量在系统里执行sudo shutdown now再断电SD卡文件系统容易损坏板子发热要重视跑PYNQ环境时温度上升很快如果长期运行建议加个小散热片有些开发板默认不会给PL部分供电需要先在Vivado的Block Design里打开PS端的PL供电选项否则即使把比特流下载进去PLIO也可能没有电7. 一点实操心得这次AXI_GPIO实验给了我很多收获。最直观的一点是PYNQ把FPGA的开发门槛降低到了“写几行Python就能驱动硬件”的程度但它没有掩盖掉底层原理的复杂性。我建议新手在做这个实验时不要只停留在“跑通它”这个层面。把寄存器地址、偏移、方向配置这几个概念都弄懂再去看pynq库的源码你会发现原来那些write和read调用的背后其实是Linux的mmap和pread/pwrite操作。理解了这一层以后不管是用MicroBlaze软核、RISC-V SoC还是其他平台变换一下总线和地址映射的知识都能复用。还有一个体会是实验的调试思路。用PYNQ做硬件调试比传统方式方便太多——不需要每个问题都重新编译工程直接在Notebook里改Python脚本就行。比如我想验证LED闪烁频率对视觉的影响直接用time.sleep改参数就完事了这在传统FPGA开发流程里是不可想象的。后续如果想继续深入可以尝试给AXI GPIO加一个中断控制器实现按键的中断方式读取或者把GPIO作为软核处理器的外设跑一个简单的片上系统甚至可以把这次实验封装成一个Python类以后在其他项目里直接导入复用。老规矩遇到问题可以在评论区留言看到都会回复。下一步我准备记录一个关于PYNQ和PWM驱动的实验如果对这类内容感兴趣可以留意后续更新。