1. 项目缘起与整体设计思路1.1 为什么要在Zynq上折腾EMIO和中断手里有一块Zynq-7z035的官方开发板PL端有几个按键和LEDPS端跑着裸机程序或者Linux。最直接的做法是在PL里写个逻辑按键直接控制LEDPS完全不参与。但这样做有个问题PS端根本不知道按键被按下了没法做更复杂的响应比如记录按键次数、触发某个任务、切换系统状态等等。所以我们需要把PL的按键信号传到PS端让PS来决定怎么处理。Zynq的PS和PL之间有两种GPIO扩展方式MIO和EMIO。MIO是PS端直接引出的54个引脚直接连到PS的GPIO控制器上不经过PL。EMIO则是通过PL扩展出来的GPIOPS端通过GPIO控制器的Bank2和Bank3来访问信号实际走的是PL的引脚。用EMIO的好处是灵活——你想接多少个按键、多少个LED都行只要PL资源够用不像MIO那样引脚数量固定且很多已经被外设占用了。那为什么用中断模式而不是轮询轮询就是在PS端写个while循环不停读GPIO状态简单是简单但CPU一直在空转功耗高、效率低而且如果程序还有其他任务要跑轮询会严重拖慢响应速度。中断模式则是按键按下时PL端的GPIO信号变化触发中断PS端的GIC通用中断控制器收到中断后跳转到中断服务函数处理。CPU平时该干嘛干嘛按键来了才响应效率高得多。对于Zynq这种PS和PL协同工作的场景中断模式是更专业、更实用的选择。这个项目的核心目标就是PL端的按键通过EMIO扩展到PS端PS端配置GPIO中断在中断服务函数中读取按键状态并控制PL端的LED。整个链路涉及PL端的引脚约束、PS端的GPIO配置、中断控制器配置、中断服务函数编写等环节是一个典型的Zynq软硬件协同开发案例。1.2 整体架构与数据流先把这个项目的整体架构理清楚。PL端有两个按键假设叫KEY0和KEY1和一个LEDLED0。按键和LED的物理引脚都在PL端但控制逻辑在PS端。具体的数据流是这样的按键按下→ PL引脚电平变化 → EMIO信号传到PS的GPIO控制器Bank2 → GPIO控制器检测到中断触发条件 → 向GIC发送中断请求 → GIC根据配置将中断分发到CPU → CPU跳转到中断服务函数 → 在ISF中读取GPIO状态、判断哪个按键被按下 → 控制LED对应的GPIO输出 → LED亮灭变化。这个链路里PL端其实不需要写任何逻辑代码只需要在Block Design里把GPIO IP核配置好把按键和LED的引脚约束到物理引脚上就行。真正的工作量在PS端的软件配置上。注意EMIO的GPIO编号是从54开始的MIO占用了0-53Bank2对应EMIO的54-85Bank3对应86-117。这个编号在写代码时非常关键搞错了就找不到对应的引脚。1.3 方案选型的几个关键决策在动手之前有几个决策点需要想清楚第一个决策用GPIO中断还是用PL端自定义IP产生中断用GPIO中断是最简单的方式PS端的GPIO控制器自带中断功能只需要配置触发条件上升沿、下降沿、双边沿、高电平、低电平就行。如果用PL端自定义IP产生中断需要自己写中断控制逻辑通过IRQ_F2P接口连到PS复杂度高很多。对于按键这种简单场景GPIO中断完全够用。第二个决策按键消抖在PL端做还是PS端做机械按键按下时会有抖动一般持续5-20ms。如果在PL端做消抖需要写额外的逻辑如果在PS端做可以在中断服务函数里加延时或者用定时器。我个人的做法是在PL端加一个简单的消抖逻辑这样PS端收到的中断就是干净的不用在中断里做延时中断里做延时是大忌。第三个决策中断触发方式选边沿还是电平按键按下是电平变化用边沿触发更合适。上升沿对应按键释放下降沿对应按键按下。如果只关心按下事件配置下降沿触发就行。双边沿触发则按下和释放都会产生中断适合需要检测完整按键周期的场景。2. 硬件环境搭建与PL端配置2.1 Vivado工程创建与Block Design搭建打开Vivado创建一个新工程器件选择对应的Zynq-7z035型号。工程创建好之后第一步是创建Block Design。在Block Design里添加Zynq Processing System IP核然后双击配置。在PS的配置界面里需要重点关注几个地方Peripheral I/O Pins这里可以看到MIO的分配情况。我们不需要在这里配置EMIOEMIO是在GPIO部分配置的。MIO Configuration确认需要用到的外设比如UART、SD卡等已经使能这些会占用MIO引脚。GPIO Configuration在EMIO选项卡里使能GPIO EMIO并设置需要的GPIO宽度。我们这里需要至少3个EMIO GPIO2个按键1个LED所以设置宽度为3或者更大。配置完成后Zynq PS的GPIO接口会多出一个EMIO GPIO端口。接下来需要把这个端口引出到PL端的引脚上。2.2 EMIO GPIO的引脚约束在Block Design里把Zynq PS的GPIO_0端口引出Make External然后创建一个约束文件XDC把EMIO的引脚绑定到开发板上的物理引脚。这里有个关键点EMIO的引脚编号和物理引脚的对应关系。在Vivado中EMIO GPIO的引脚名称格式是gpio_0_tri_io[0]、gpio_0_tri_io[1]等对应EMIO的第0位、第1位。在PS端软件里这些引脚对应的GPIO编号是54054、54155以此类推。约束文件的写法大概是这样的# 按键0对应EMIO bit0 set_property PACKAGE_PIN [具体引脚] [get_ports gpio_0_tri_io[0]] set_property IOSTANDARD LVCMOS33 [get_ports gpio_0_tri_io[0]] # 按键1对应EMIO bit1 set_property PACKAGE_PIN [具体引脚] [get_ports gpio_0_tri_io[1]] set_property IOSTANDARD LVCMOS33 [get_ports gpio_0_tri_io[1]] # LED对应EMIO bit2 set_property PACKAGE_PIN [具体引脚] [get_ports gpio_0_tri_io[2]] set_property IOSTANDARD LVCMOS33 [get_ports gpio_0_tri_io[2]]具体的引脚编号需要根据开发板的原理图来确定。官方开发板一般都有详细的引脚分配表找到按键和LED对应的PL引脚即可。实操心得EMIO的引脚在PL端是双向的tri-state如果只做输入或只做输出可以在约束里加上set_property PULLUP true或者set_property PULLDOWN true来设置默认电平避免悬空导致误触发。2.3 PL端按键消抖逻辑的实现虽然PS端也能做消抖但在PL端做更干净。消抖逻辑很简单用一个计数器当按键信号稳定一段时间比如10ms后才认为按键状态有效。假设PL端有一个50MHz的时钟10ms对应500000个时钟周期。用一个20位的计数器2^201048576够用了当按键输入和当前稳定状态不一致时计数器开始计数计数到500000时更新稳定状态如果中途按键输入又变回去了计数器清零。这个逻辑用Verilog写大概是这样module debounce ( input clk, input rst_n, input key_in, output reg key_out ); reg [19:0] cnt; reg key_in_r; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 0; key_out 1b1; key_in_r 1b1; end else begin key_in_r key_in; if (key_in_r ! key_out) begin if (cnt 20d500000) begin key_out key_in_r; cnt 0; end else begin cnt cnt 1; end end else begin cnt 0; end end end endmodule这个模块的输出key_out就是消抖后的按键信号可以直接连到EMIO的输入上。注意按键一般默认是高电平按下是低电平所以消抖后的信号也是按下时为低。2.4 Block Design的连线与综合把消抖模块的输入连到PL端的按键引脚输出连到Zynq PS的EMIO GPIO输入。LED则直接连到EMIO GPIO的输出。在Block Design里可能需要添加一些IP核来做信号连接比如用xlslice来提取EMIO的某一位或者用xlconcat来合并信号。连线完成后生成顶层HDL然后运行综合、实现、生成比特流。导出硬件时记得包含比特流文件这样在SDK或Vitis里才能正确初始化PL端。注意如果PL端逻辑比较简单也可以不用Block Design直接在顶层HDL里实例化Zynq PS和消抖模块手动连线。但用Block Design更直观适合初学者。3. PS端软件配置与中断实现3.1 硬件平台导出与工程创建比特流生成后导出硬件平台Export Hardware包含比特流。然后在Vitis或SDK里创建一个新的应用工程选择刚刚导出的硬件平台。创建工程时模板可以选择Empty Application或者Hello World然后自己添加GPIO和中断相关的代码。需要包含的头文件主要有xgpiops.hPS端GPIO操作xscugic.h中断控制器操作xparameters.h硬件参数定义xil_exception.h异常处理这些头文件在Vitis的BSP里都有不需要额外安装。3.2 GPIO初始化与中断配置PS端GPIO的初始化分几步第一步查找GPIO设备并初始化。用XGpioPs_LookupConfig找到GPIO的配置信息然后用XGpioPs_CfgInitialize初始化GPIO实例。第二步设置GPIO方向。按键对应的EMIO引脚54和55设置为输入LED对应的EMIO引脚56设置为输出。用XGpioPs_SetDirectionPin函数。第三步使能输出。对于LED引脚需要调用XGpioPs_SetOutputEnablePin使能输出否则即使设置了方向为输出引脚也不会有输出。第四步配置中断。这是关键步骤。首先用XGpioPs_SetIntrTypePin设置中断触发类型按键按下是下降沿所以设置为XGPIOPS_IRQ_TYPE_EDGE_FALLING。然后调用XGpioPs_IntrEnablePin使能对应引脚的中断。第五步配置GIC。用XScuGic_LookupConfig和XScuGic_CfgInitialize初始化中断控制器然后用XScuGic_Connect把GPIO的中断服务函数注册到GIC上最后用XScuGic_Enable使能GIC中的GPIO中断。第六步使能处理器中断。调用Xil_ExceptionInit、Xil_ExceptionRegisterHandler、Xil_ExceptionEnable来使能处理器的中断响应。这几步的顺序不能乱尤其是GIC的初始化和中断注册必须在GPIO中断使能之前完成否则中断来了没人处理。3.3 中断服务函数的编写要点中断服务函数ISF是整个项目的核心。当按键按下时GPIO中断触发CPU跳转到ISF执行。ISF里需要做几件事第一确认中断来源。用XGpioPs_IntrGetStatusPin读取中断状态判断是哪个按键触发了中断。虽然我们只使能了两个按键的中断但保险起见还是要判断一下。第二清除中断标志。用XGpioPs_IntrClearPin清除对应引脚的中断标志。这一步非常重要如果不清楚中断会一直触发程序就卡在ISF里出不来了。第三处理按键事件。根据按键编号控制LED的亮灭。比如KEY0按下时LED翻转KEY1按下时LED常亮或常灭。第四返回。ISF执行完毕后返回CPU继续执行主程序。ISF的代码大概长这样void GpioHandler(void *CallbackRef) { XGpioPs *GpioPtr (XGpioPs *)CallbackRef; u32 IntrStatus; // 读取中断状态 IntrStatus XGpioPs_IntrGetStatusPin(GpioPtr, KEY0_PIN); if (IntrStatus) { // 清除中断标志 XGpioPs_IntrClearPin(GpioPtr, KEY0_PIN); // 翻转LED led_state !led_state; XGpioPs_WritePin(GpioPtr, LED_PIN, led_state); } IntrStatus XGpioPs_IntrGetStatusPin(GpioPtr, KEY1_PIN); if (IntrStatus) { XGpioPs_IntrClearPin(GpioPtr, KEY1_PIN); // 另一个按键的处理 XGpioPs_WritePin(GpioPtr, LED_PIN, 1); } }实操心得ISF里尽量不要做耗时操作比如打印调试信息、延时等。如果确实需要可以用一个全局变量标记事件在主循环里处理。中断里做太多事会导致响应变慢甚至丢失后续中断。3.4 主程序流程与调试输出主程序main函数的流程比较简单初始化GPIO、初始化GIC、配置中断、使能中断然后进入一个无限循环。循环里可以什么都不做也可以做一些低优先级的任务比如每隔一段时间打印一次系统状态。调试时可以在ISF里翻转一个额外的GPIO引脚用示波器或者逻辑分析仪观察中断响应时间。也可以在ISF里对一个全局计数器加一主循环里定期打印这个计数器的值看看按键按下时计数是否增加。如果程序跑起来后按键没反应首先检查中断是否被正确触发。可以在ISF入口处加一个打印语句虽然不推荐在ISF里打印但调试阶段可以临时用一下看看中断有没有进来。如果中断没进来检查GIC配置和GPIO中断使能是否正确。4. 常见问题排查与实战经验4.1 中断不触发的几种典型原因在实际调试中中断不触发是最常见的问题。根据我的经验原因主要有以下几类第一类GPIO方向配置错误。按键引脚必须配置为输入如果误配置为输出中断永远不会触发。检查XGpioPs_SetDirectionPin的第二个参数0表示输入1表示输出。第二类中断类型配置错误。按键按下是下降沿如果配置成上升沿那只有松开按键时才触发中断。检查XGpioPs_SetIntrTypePin的参数是否正确。第三类GIC配置遗漏。GIC的初始化、中断注册、使能三步缺一不可。特别是XScuGic_Enable很多人容易忘记调用导致GIC不转发中断。第四类处理器中断未使能。Xil_ExceptionEnable必须调用否则CPU不响应任何中断。第五类中断标志未清除。如果ISF里没有清除中断标志第一次中断触发后中断状态一直保持后续中断无法触发。表现就是按键第一次有反应后面就没反应了。第六类EMIO引脚约束错误。如果约束文件里的引脚编号写错了按键信号根本没连到EMIO上自然不会有中断。检查XDC文件和原理图确认引脚对应关系。4.2 按键抖动导致多次中断的处理即使PL端加了消抖逻辑有时候还是会出现一次按键触发多次中断的情况。原因可能是消抖时间不够或者中断触发方式配置成了双边沿。如果消抖时间不够可以增大消抖计数器的阈值。比如从10ms增加到20ms。但消抖时间太长会影响按键响应速度一般10-20ms是比较合适的范围。如果中断触发方式配置成了双边沿那按下和释放都会触发中断。如果只需要检测按下事件改成下降沿触发即可。还有一种情况是按键信号在消抖后仍然有毛刺这可能是硬件电路的问题。可以在按键两端并联一个0.1uF的电容硬件消抖。4.3 中断响应时间优化中断响应时间是指从按键按下到ISF执行的时间。这个时间受多个因素影响PL端消抖延迟消抖计数器需要计数到阈值才更新输出这段时间是固定的。10ms消抖对应10ms延迟。EMIO信号传输延迟EMIO信号从PL传到PS的GPIO控制器这个延迟很小一般在纳秒级。GIC中断分发延迟GIC收到中断请求后分发到CPU这个延迟也很小。CPU响应延迟CPU当前指令执行完毕后才会响应中断如果正在执行长指令或者中断被屏蔽延迟会增加。如果要优化响应时间可以减少消抖时间但会增加误触发风险或者提高CPU主频。不过对于按键这种应用几十毫秒的响应时间完全够用不需要过度优化。4.4 常见问题速查表问题现象可能原因排查方法解决方案按键无反应GPIO方向配置错误检查XGpioPs_SetDirectionPin参数按键引脚配置为输入按键无反应中断未使能检查XGpioPs_IntrEnablePin是否调用使能对应引脚中断按键无反应GIC未使能检查XScuGic_Enable是否调用调用GIC使能函数按键无反应处理器中断未使能检查Xil_ExceptionEnable是否调用调用处理器中断使能第一次有反应后续无反应中断标志未清除检查ISF中是否调用XGpioPs_IntrClearPin在ISF中清除中断标志一次按键多次触发消抖不足或双边沿触发检查消抖时间和中断类型增加消抖时间或改为下降沿触发LED不亮输出未使能检查XGpioPs_SetOutputEnablePin是否调用使能LED引脚输出LED不亮引脚约束错误检查XDC文件修正引脚约束提示调试中断问题时建议先用轮询方式验证GPIO读写是否正常确认硬件连接无误后再切换到中断模式。这样可以排除硬件问题专注于中断配置的调试。4.5 几个容易被忽略的细节细节一EMIO的GPIO编号。在PS端代码里EMIO的GPIO编号是从54开始的。很多人习惯从0开始编号结果找不到对应的引脚。记住MIO占用0-53EMIO从54开始。细节二Bank的区分。EMIO的54-85属于Bank286-117属于Bank3。在配置中断时需要根据引脚编号确定属于哪个Bank然后配置对应的中断寄存器。细节三中断服务函数的注册。XScuGic_Connect的第二个参数是中断IDGPIO中断的ID在xparameters.h里有定义一般是XPAR_XGPIOPS_0_INTR。不要手动写数字用宏定义更安全。细节四全局变量的使用。ISF和主程序之间共享的变量需要声明为volatile防止编译器优化导致变量值不同步。细节五比特流的加载。如果程序跑起来后PL端没反应检查比特流是否已经加载。在Vitis里可以通过配置Run Configuration来在程序运行前自动加载比特流。5. 项目扩展与进阶玩法5.1 从单按键到多按键矩阵如果按键数量比较多比如需要16个按键用EMIO直接扩展需要16个GPIO占用PL引脚太多。这时候可以用矩阵键盘的方式4行4列共8个引脚就能支持16个按键。矩阵键盘的扫描逻辑可以放在PL端用Verilog实现行列扫描检测到按键后通过EMIO输出按键编号。PS端只需要读取编号即可不需要关心扫描过程。这样PS端的代码更简单PL端的逻辑也不复杂。5.2 结合定时器实现长按和双击检测简单的按键中断只能检测按下事件如果要实现长按、双击等高级功能需要结合定时器。思路是按键按下时启动定时器如果在定时器超时前按键释放则判定为短按如果定时器超时后按键仍按下则判定为长按。双击检测则需要记录两次按键的时间间隔如果间隔小于某个阈值比如300ms则判定为双击。这些逻辑可以放在PS端的主循环里处理ISF只负责记录按键事件和时间戳。5.3 在Linux下使用GPIO中断如果PS端跑的是Linux系统GPIO中断的使用方式会有所不同。Linux下可以通过sysfs或者gpiod接口来操作GPIO中断则通过poll或者epoll来监听。具体做法是在设备树里配置EMIO GPIO然后在用户空间用gpiod库或者直接操作/sys/class/gpio来监听中断。Linux下的中断处理更复杂但功能也更强大可以结合多线程、信号等机制实现更灵活的控制。5.4 中断与DMA结合的数据采集如果PL端有高速数据采集需求比如ADC采样可以用中断触发DMA传输。PL端采集到一定数量的数据后产生中断PS端在ISF里启动DMA把数据搬到内存然后处理。这种方式可以大大提高数据吞吐量适合高速数据采集场景。Zynq的DMA控制器DMA_SG支持中断触发配置好描述符后PL端中断一来DMA自动搬运数据PS端只需要处理搬运完成的中断即可。这个玩法比较进阶需要对Zynq的DMA架构有深入了解。5.5 实际项目中的经验教训我在实际项目中踩过几个坑分享出来供大家参考第一个坑中断优先级配置。如果系统里有多个中断源需要合理配置中断优先级。GPIO中断的优先级一般设置为中等太高会影响其他关键中断太低会导致按键响应慢。在GIC配置时可以通过XScuGic_SetPriorityTriggerType来设置优先级。第二个坑ISF中的打印语句。调试阶段在ISF里加打印语句很方便但正式版本一定要删掉。打印语句会显著增加ISF执行时间导致后续中断丢失。如果确实需要调试信息可以用一个环形缓冲区记录事件在主循环里输出。第三个坑EMIO引脚的电平标准。不同开发板的PL端IO电平标准可能不同有的是3.3V有的是1.8V。约束文件里的IOSTANDARD必须和硬件匹配否则可能损坏引脚或者信号不稳定。第四个坑比特流和软件的版本匹配。Vivado和Vitis的版本必须一致否则导出的硬件平台可能无法正确识别。比如Vivado 2020.2导出的平台用Vitis 2021.1打开可能会报错。第五个坑中断嵌套。默认情况下Zynq的中断是不嵌套的即ISF执行期间不会响应新的中断。如果ISF执行时间较长可能会导致其他中断丢失。如果需要中断嵌套需要在GIC配置里使能嵌套中断并合理设置优先级。这个项目虽然看起来简单但涉及的知识点很多PL端逻辑设计、引脚约束、PS端GPIO配置、中断控制器配置、ISF编写、调试技巧等。把这一套流程走通对Zynq的软硬件协同开发会有比较深入的理解。后续可以在此基础上扩展更多功能比如多按键处理、长按检测、Linux下的中断处理等逐步提升项目的复杂度。