
1. 项目缘起与整体设计思路1.1 为什么要在 PYNQ 上折腾 EMIO 中断先把这个标题拆开看PYNQ、中断、GPIO、EMIO。四个词里PYNQ 是平台GPIO 是外设EMIO 是 GPIO 的扩展通道中断是核心机制。合在一起就是一件很具体的事——在 PYNQ 平台上通过 EMIO 把 PL 侧的 GPIO 信号引到 PS 侧并让 PS 能对引脚电平变化产生中断响应。这件事听起来不复杂但真正动过手的人都知道坑不少。PYNQ 官方例程大多围绕 MIO 上的按键和 LED 展开EMIO 相关的完整中断示例相对零散很多细节需要自己拼。我第一次做这个测试的时候光是搞清楚“EMIO 到底该配成输入还是输出、中断该挂在 GIC 的哪个通道上”就花了大半天。所以这篇内容的目标很明确把 PYNQ 上用 EMIO 做 GPIO 中断测试的完整链路讲透。从硬件平台选型、Vivado 里的 IP 配置、管脚约束到 PYNQ 侧 Python 代码怎么写、中断怎么注册、回调函数怎么处理再到实际调试中会遇到的各种异常和排查方法全部覆盖。适合已经对 PYNQ 有基本了解、想深入掌握 PL-PS 中断交互的开发者也适合刚接触嵌入式异构平台、想找一个完整案例上手的新手。1.2 整体方案选型与架构拆解在 PYNQ 上做 GPIO 中断有几条路可以走。第一条是纯 PS 侧方案直接用 MIO 上的 GPIO 做中断不涉及 PL配置简单但灵活性差引脚数量有限。第二条是 PS 通过 AXI GPIO IP 控制 PL 侧引脚中断由 AXI GPIO 产生再通过 PL 到 PS 的中断线传给 PS。第三条就是本文要讲的 EMIO 方案——把 PS 的 GPIO 控制器通过 EMIO 接口扩展到 PL 侧引脚上中断仍然由 PS 的 GPIO 控制器管理。为什么选 EMIO 而不是 AXI GPIO核心原因在于中断路径的简洁性。AXI GPIO 产生的中断需要经过 PL 的中断控制器汇聚再通过 IRQ 线送入 PS 的 GIC链路更长配置更复杂。而 EMIO 本质上是 PS GPIO 控制器的引脚扩展中断处理逻辑和 MIO 上的 GPIO 完全一致软件层面只需要操作同一个 GPIO 控制器代码更统一调试也更直观。整个方案的架构可以这样理解PL 侧的物理引脚通过 EMIO 接口映射到 PS 的 GPIO 控制器上GPIO 控制器检测到电平变化后产生中断信号这个信号通过 GIC通用中断控制器上报给 ARM 核Linux 内核的中断子系统接管后最终由 PYNQ 的 Python 层通过注册的回调函数来响应。这里有一个关键点需要提前说明EMIO 的 GPIO 编号是从 MIO 之后开始排的。以常见的 Zynq-7000 系列为例MIO 有 54 个引脚编号 0-53EMIO 从 54 开始编号。如果你用的是 Zynq UltraScale 系列MIO 数量不同EMIO 的起始编号也会变化。这个编号在写代码时非常关键搞错了就找不到对应的 GPIO。注意不同 PYNQ 版本对 GPIO 编号的处理方式可能有差异建议在写代码前先用gpioinfo命令确认系统中实际的 GPIO 编号分配。1.3 硬件与软件环境准备硬件方面我用的是一块常见的 Zynq-7000 系列开发板PL 侧引出了一个按键和一个 LED 用于测试。按键接在 EMIO 扩展的 GPIO 上LED 用来指示中断是否被正确触发。如果你手头没有物理按键也可以用杜邦线手动短接引脚来模拟电平变化效果一样。软件方面需要以下环境Vivado 用于搭建硬件工程生成 bitstream 和 hwh 文件PYNQ 镜像已经烧录到 SD 卡上系统能正常启动Jupyter Notebook 能通过浏览器访问串口终端或 SSH 连接可用方便查看内核日志Vivado 版本和 PYNQ 版本的匹配很重要。PYNQ 2.7 通常对应 Vivado 2020.2PYNQ 3.0 对应 Vivado 2022.2。版本不匹配可能导致 hwh 文件解析失败overlay 加载报错。我建议在动手之前先确认自己的 PYNQ 版本然后选择对应的 Vivado 版本省得后面返工。2. Vivado 工程配置与 EMIO 管脚约束2.1 Block Design 搭建与 Zynq 配置打开 Vivado新建工程选择对应的芯片型号。然后创建 Block Design添加 Zynq Processing System IP。双击进入配置界面这里有几个关键设置需要调整。在Peripheral I/O Pins页面找到 GPIO 部分。默认情况下GPIO MIO 是使能的但 EMIO 是关闭的。你需要勾选 GPIO EMIO并设置需要的 EMIO 宽度。比如你要用 1 个 EMIO 引脚做中断输入就设成 1如果要同时控制 LED 和读取按键就设成 2 或更多。在Interrupts页面确保PL-PS Interrupt Ports中的IRQ_F2P是使能的。虽然 EMIO GPIO 的中断走的是 PS 内部 GPIO 控制器的路径不直接经过 IRQ_F2P但有些设计里会混用提前使能没坏处。不过要说明的是纯 EMIO GPIO 中断其实不需要 IRQ_F2P因为中断信号是在 PS 内部产生的不经过 PL 的中断汇聚。配置完成后Zynq IP 上会多出一个GPIO_0接口这就是 EMIO 的出口。把这个接口引出后面需要连接到顶层端口或者约束到物理引脚。2.2 EMIO 管脚约束与物理映射EMIO 接口引出后需要把它映射到 PL 侧的物理引脚上。这一步通过 XDC 约束文件完成。假设我们把 EMIO 的第 0 位映射到 PL 侧的一个按键引脚上约束写法大致如下# 假设 EMIO 第 0 位对应按键连接到 PL 的某个引脚 set_property PACKAGE_PIN [具体引脚编号] [get_ports {gpio_0_tri_io[0]}] set_property IOSTANDARD LVCMOS33 [get_ports {gpio_0_tri_io[0]}]这里有个容易踩的坑EMIO 引出的端口名通常是gpio_0_tri_io它是一个三态接口。如果你只做输入理论上可以只约束输入方向但 Vivado 默认会把它当成双向口处理。为了简化可以在 Zynq 配置里把不需要的方向关掉或者在约束里明确指定方向。另一个坑是引脚编号。不同开发板的物理引脚编号完全不同你必须查自己板子的原理图或引脚分配表。我曾经因为抄了别人的约束文件引脚号对不上结果按键按下去毫无反应查了半天才发现是引脚映射错了。实操心得约束文件写完后一定要在 Vivado 里跑一次 Implementation然后打开 I/O Planning 视图确认引脚分配是否正确。如果有冲突或未分配的端口Vivado 会给出警告别忽略这些警告。2.3 Bitstream 生成与 Overlay 文件准备约束完成后直接生成 bitstream。这个过程可能需要几分钟到十几分钟取决于工程复杂度。生成完毕后在 Vivado 的导出菜单里选择Export Hardware勾选Include bitstream导出格式选择Fixed或者XSA取决于你的 PYNQ 版本。PYNQ 2.x 通常需要的是.hwh文件和.bit文件PYNQ 3.x 则直接使用.xsa文件。导出后把文件传到 PYNQ 板上。我一般会在 Jupyter 里新建一个工作目录把 bitstream 和 hwh 文件放在同一个文件夹下方便后续加载。加载 overlay 的代码很简单from pynq import Overlay ol Overlay(your_design.bit)如果加载成功不会有报错。如果报错常见原因包括bitstream 和 hwh 文件不匹配、文件路径不对、PYNQ 版本与 Vivado 版本不兼容。这时候先检查文件是否完整再确认版本匹配情况。3. PYNQ 侧 GPIO 中断的 Python 实现3.1 GPIO 控制器的定位与引脚编号计算Overlay 加载成功后下一步是找到 GPIO 控制器。在 PYNQ 的 Python 环境里可以通过pynq.gpio模块来操作 GPIO。但要注意PYNQ 的 GPIO 类主要针对 MIO 和 EMIO 的统一编号你需要知道 EMIO 引脚对应的全局编号。前面提到过Zynq-7000 的 MIO 有 54 个引脚编号 0 到 53。EMIO 从 54 开始。所以如果你在 Vivado 里配置了 1 个 EMIO 引脚它的全局编号就是 54。如果配置了多个依次递增。在 PYNQ 里获取 GPIO 对象的代码大致如下from pynq import GPIO # EMIO 第 0 位全局编号 54 emio_pin GPIO(54, in)这里的in表示方向为输入。如果是输出就写out。创建 GPIO 对象时PYNQ 会自动处理底层的 sysfs 或 libgpiod 接口你不需要手动去操作/sys/class/gpio。但这里有一个隐藏的坑PYNQ 的 GPIO 类在不同版本里实现方式不同。早期版本用 sysfs新版本可能用 libgpiod。如果你发现GPIO(54, in)报错提示找不到引脚很可能是编号不对或者 GPIO 控制器没有正确暴露出来。这时候可以进系统终端用gpioinfo或ls /sys/class/gpio来确认实际的 GPIO 分配情况。3.2 中断注册与回调函数编写GPIO 对象创建好后就可以注册中断了。PYNQ 的 GPIO 类提供了wait_for_interrupt方法但更常用的方式是注册回调函数。不过要注意PYNQ 原生的 GPIO 类对中断的支持在不同版本里差异较大有些版本需要手动通过asyncio或者线程来轮询。我实测下来比较稳定的做法是使用gpiod库或者直接操作 sysfs 的edge和value文件。但为了保持 PYNQ 风格的简洁性这里给出一个基于 PYNQ GPIO 类的中断处理框架import asyncio from pynq import GPIO emio_pin GPIO(54, in) async def wait_for_button(): while True: # 等待中断事件 await emio_pin.wait_for_interrupt() print(中断触发按键被按下) # 这里可以添加去抖动逻辑 await asyncio.sleep(0.05) # 在 Jupyter 中运行 loop asyncio.get_event_loop() loop.create_task(wait_for_button())这段代码的核心逻辑是创建一个异步任务不断等待 GPIO 中断事件。当按键按下时wait_for_interrupt会返回然后打印信息。asyncio.sleep(0.05)是用来做简单去抖动的因为机械按键在按下和释放时会产生多次电平跳变如果不处理一次按键可能触发多次中断。注意在 Jupyter Notebook 里直接跑asyncio事件循环有时会遇到问题因为 Jupyter 本身已经在运行一个事件循环。这时候可以用nest_asyncio库来打补丁或者改用线程的方式来处理中断。3.3 中断触发方式的配置细节GPIO 中断的触发方式有几种上升沿、下降沿、双边沿、高电平、低电平。对于按键输入通常用下降沿或双边沿。下降沿表示按键按下时触发双边沿则表示按下和释放都触发。在 PYNQ 里配置触发方式可以通过 GPIO 对象的属性来设置emio_pin.edge falling # 下降沿触发或者emio_pin.edge both # 双边沿触发这里有一个非常重要的细节触发方式必须在注册中断之前设置好。如果你先注册了中断再改触发方式有些实现里不会生效或者会导致中断行为异常。我踩过这个坑当时调了半天以为代码逻辑有问题后来发现是设置顺序反了。另外EMIO 引脚的电平标准也要注意。PL 侧的 IO 标准通常是 LVCMOS33 或 LVCMOS18取决于你的板子。如果电平不匹配比如按键电路是 5V 而 FPGA 引脚是 3.3V可能会损坏引脚或者读不到正确的电平。这种情况下需要加电平转换电路。4. 实操过程中的典型问题与排查方法4.1 中断不触发的常见原因排查中断不触发是最常见的问题可能的原因有很多。我整理了一个排查顺序按这个顺序走基本能定位到问题。排查步骤检查内容可能问题解决方法1硬件连接按键未接好、引脚接错用万用表测通断确认引脚映射2电平标准IO 标准不匹配检查 XDC 约束和板子原理图3GPIO 编号EMIO 起始编号算错用 gpioinfo 确认实际编号4触发方式edge 设置错误改为 both 测试5中断注册回调未正确注册检查代码执行顺序6内核驱动GPIO 驱动未加载查看 dmesg 日志第一步永远是查硬件。我遇到过好几次代码翻来覆去改最后发现是杜邦线松了。所以先确认物理连接没问题再去看软件。第二步查电平标准。如果你的按键电路是上拉设计按下时引脚拉低那么应该配置为下降沿触发。如果是下拉设计按下时拉高就配置为上升沿。搞反了就不会触发。第三步查 GPIO 编号。这个前面强调过了不同平台 MIO 数量不同EMIO 起始编号也不同。用gpioinfo命令可以列出系统中所有 GPIO 控制器的引脚分配找到对应的 EMIO 引脚编号。第四步查触发方式。有时候双边沿触发比单边沿更可靠因为你不确定按键电路的具体行为。先用 both 测试确认中断能触发后再改成具体的边沿。第五步查中断注册。确保wait_for_interrupt或者回调函数在 GPIO 对象创建和 edge 设置之后才执行。顺序错了中断可能注册不上。第六步查内核日志。用dmesg | grep gpio看看有没有 GPIO 相关的错误或警告。有时候驱动加载失败或者引脚被其他驱动占用内核会给出提示。4.2 中断抖动与重复触发处理机械按键的抖动是一个经典问题。按下一次电平可能会跳变好几次导致中断被触发多次。如果不处理你的程序可能会把一次按键当成多次按键。解决方法有两种硬件去抖和软件去抖。硬件去抖是在按键两端并联一个电容通常 0.1uF 左右可以滤掉高频抖动。软件去抖是在中断回调里加延时等电平稳定后再读取。软件去抖的代码示例import asyncio from pynq import GPIO emio_pin GPIO(54, in) emio_pin.edge falling async def debounced_handler(): while True: await emio_pin.wait_for_interrupt() await asyncio.sleep(0.05) # 延时 50ms 去抖 if emio_pin.read() 0: # 确认电平仍然为低 print(有效按键触发) loop asyncio.get_event_loop() loop.create_task(debounced_handler())这里的逻辑是中断触发后先等 50ms然后再次读取引脚电平。如果电平仍然是触发状态才认为是有效按键。如果电平已经恢复说明是抖动忽略这次中断。50ms 是一个经验值大多数机械按键的抖动时间在 10ms 到 20ms 之间50ms 足够覆盖。如果你觉得响应太慢可以降到 20ms但要注意可能会漏掉快速按键。4.3 PYNQ 版本差异与兼容性处理PYNQ 2.x 和 3.x 在 GPIO 和中断处理上有一些差异。PYNQ 2.x 基于 Python 3.6 或 3.7asyncio 的用法和 3.8 略有不同。PYNQ 3.x 升级了底层库GPIO 的实现可能从 sysfs 切换到了 libgpiod。如果你在 PYNQ 3.x 上跑 PYNQ 2.x 的代码可能会遇到AttributeError或者FileNotFoundError。这时候需要检查 PYNQ 的版本然后调整代码。查看 PYNQ 版本的命令import pynq print(pynq.__version__)如果是 3.x建议用gpiod库来操作 GPIO而不是 PYNQ 原生的 GPIO 类。gpiod的用法更底层但兼容性更好import gpiod chip gpiod.Chip(gpiochip0) line chip.get_line(54) line.request(consumertest, typegpiod.LINE_REQ_EV_FALLING_EDGE) while True: if line.event_wait(sec1): event line.event_read() print(中断触发:, event)这段代码用gpiod直接操作 GPIO 线路注册下降沿事件然后循环等待事件。这种方式在 PYNQ 3.x 上更稳定不依赖 PYNQ 的封装层。实操心得如果你不确定该用哪种方式先在终端里用gpiodetect和gpioinfo看看系统里有哪些 GPIO 控制器和线路然后再决定用 PYNQ 原生类还是 gpiod。这两个命令在大多数 PYNQ 镜像里都预装了。4.4 中断响应延迟的优化思路中断响应延迟是指从中断触发到回调函数执行的时间。在 PYNQ 上这个延迟通常在毫秒级受多个因素影响Linux 内核的中断处理机制、Python 解释器的调度、asyncio 事件循环的负载等。如果你需要更低的延迟可以考虑以下几个方向。第一减少 Python 层的处理逻辑把中断处理尽量放在内核驱动或者 PL 侧完成。第二提高中断线程的优先级用chrt命令把 Python 进程设为实时优先级。第三关闭不必要的系统服务减少 CPU 负载。不过对于大多数测试和教学场景毫秒级的延迟完全够用。如果你做的是高速信号采集或者精密控制那可能需要考虑用裸机程序或者 RTOS 来处理中断而不是在 Linux 用户态用 Python。5. 完整测试流程与代码复现5.1 从零开始的完整操作步骤把前面的内容串起来完整的操作流程如下。第一步在 Vivado 里创建工程配置 Zynq PS使能 GPIO EMIO设置宽度为 1 或 2。生成 bitstream导出硬件文件。第二步把 bitstream 和 hwh 文件传到 PYNQ 板上的同一个目录。第三步在 Jupyter 里新建 Notebook加载 overlayfrom pynq import Overlay ol Overlay(emio_interrupt.bit)第四步确认 GPIO 编号。在终端里运行gpioinfo找到 EMIO 对应的 GPIO 线路编号。第五步创建 GPIO 对象设置触发方式注册中断处理import asyncio from pynq import GPIO emio_pin GPIO(54, in) emio_pin.edge falling async def handler(): count 0 while True: await emio_pin.wait_for_interrupt() await asyncio.sleep(0.05) if emio_pin.read() 0: count 1 print(f第 {count} 次按键触发) loop asyncio.get_event_loop() loop.create_task(handler())第六步按下按键观察输出。如果能看到打印信息说明中断链路打通了。第七步如果没反应按照前面的排查表逐步检查。5.2 测试结果验证与日志分析测试的时候除了看 Python 端的打印输出还要关注内核日志。在终端里运行dmesg -w | grep -i gpio这个命令会实时显示 GPIO 相关的内核消息。当你按下按键时如果中断被正确触发内核可能会打印中断号或者 GPIO 状态变化的信息。如果没有任何输出说明中断信号没有到达内核问题可能在硬件或者 GIC 配置上。另一个有用的命令是查看中断统计cat /proc/interrupts这个文件列出了系统中所有的中断号和触发次数。找到 GPIO 相关的中断号观察计数是否在按键时增加。如果计数不变说明中断没有被触发如果计数增加但 Python 端没反应说明中断到了内核但没传到用户态。我实测下来EMIO GPIO 中断在/proc/interrupts里通常会显示为一个特定的中断号触发次数会随着按键增加。这个验证方法非常直接推荐每次调试时都看一眼。5.3 代码封装与复用建议如果你要多次使用这个功能建议把 GPIO 中断处理封装成一个类方便复用import asyncio from pynq import GPIO class EmioButton: def __init__(self, pin_number, edgefalling, debounce0.05): self.pin GPIO(pin_number, in) self.pin.edge edge self.debounce debounce self.callback None def on_press(self, func): self.callback func async def _run(self): while True: await self.pin.wait_for_interrupt() await asyncio.sleep(self.debounce) if self.pin.read() 0 and self.callback: self.callback() def start(self): loop asyncio.get_event_loop() loop.create_task(self._run())用的时候这样写button EmioButton(54) button.on_press(lambda: print(按键触发)) button.start()这样封装之后换一个引脚只需要改编号换一个触发方式只需要改参数代码复用性大大提高。6. 几个容易被忽略的细节与经验总结6.1 EMIO 与 MIO 中断的差异对比虽然 EMIO 和 MIO 在软件层面共用同一个 GPIO 控制器但实际使用中还是有一些差异。MIO 的引脚是 PS 直接控制的不需要经过 PL所以配置更简单延迟也更低。EMIO 的引脚要经过 PL 的 IO 缓冲器信号路径更长延迟略高但灵活性更强可以映射到任意 PL 引脚。在中断行为上两者基本一致都是通过 GIC 上报。但 EMIO 的中断可能会受到 PL 侧逻辑的影响比如如果 PL 侧有其他的逻辑也在驱动同一个引脚可能会产生冲突。所以用 EMIO 做中断输入时确保 PL 侧没有其他逻辑占用该引脚。另外EMIO 的引脚在 PS 侧看来和 MIO 没有区别编号连续。但在 PL 侧它们是普通的 IO 引脚需要约束和电平标准设置。这个双重身份是 EMIO 的特点也是容易混淆的地方。6.2 中断上下文中的操作禁忌在中断回调函数里有些事情是不能做的。比如不能执行耗时操作不能调用可能阻塞的函数不能分配大量内存。因为中断上下文是在内核态执行的用户态的回调函数虽然不在严格的中断上下文里但仍然应该尽量轻量。我见过有人在回调里做文件读写、网络请求、甚至数据库操作结果导致系统卡死或者中断丢失。正确的做法是回调里只做最简单的标记或者入队操作把耗时的处理放到单独的线程或者异步任务里去做。在 PYNQ 的 Python 环境里由于 GIL 的存在回调函数的执行会阻塞其他 Python 线程。所以如果你的回调里有time.sleep或者复杂的计算整个 Python 进程都会卡住。用asyncio.sleep而不是time.sleep可以让出控制权避免阻塞。6.3 长期运行稳定性注意事项如果你要让这个中断测试长期运行比如做压力测试或者产品原型有几个稳定性问题需要注意。第一中断风暴。如果按键抖动严重或者引脚受到干扰可能会产生大量中断导致 CPU 占用率飙升。解决方法是在硬件上加滤波电容在软件上加去抖逻辑并且限制单位时间内的中断处理次数。第二内存泄漏。Python 的异步任务如果不断创建而不回收可能会导致内存泄漏。确保你的while True循环里没有不断累积的对象回调函数里没有全局列表不断追加。第三GPIO 资源释放。程序退出时应该释放 GPIO 对象避免下次运行时引脚被占用。PYNQ 的 GPIO 类通常会在对象销毁时自动释放但如果你用的是 gpiod需要手动调用line.release()。第四看门狗。如果系统长时间运行建议启用看门狗防止程序卡死导致系统无响应。PYNQ 的 Linux 系统通常自带看门狗驱动可以通过/dev/watchdog来操作。6.4 从测试到产品的扩展思路这个 EMIO 中断测试虽然简单但它是很多实际应用的基础。比如工业控制里的限位开关检测、安防系统里的门磁传感器、消费电子里的按键唤醒底层原理都是一样的。如果你要把这个测试扩展成实际产品需要考虑几个方向。第一多路中断的管理如何区分不同引脚的中断如何做优先级处理。第二中断与业务逻辑的解耦用消息队列或者事件总线来传递中断事件。第三异常情况的处理比如引脚短路、信号丢失、系统重启后的状态恢复。我个人在实际操作中的体会是EMIO 中断的调试过程比写代码本身更花时间。硬件连接、引脚映射、触发方式、去抖处理每一个环节都可能出问题。但只要按照排查顺序一步步来最终都能定位到原因。最怕的是东改一下西改一下没有系统性的排查思路那样只会越调越乱。最后再分享一个小技巧在调试阶段可以在 PL 侧加一个计数器或者 LED 指示用来直观地看到中断信号是否到达了 PL。比如每次 EMIO 引脚电平变化时让 PL 侧的 LED 翻转一次。这样你就能区分问题是在 PL 侧还是在 PS 侧大大缩小排查范围。这个方法我用了很多次非常有效。