☰
ZYNQ-7000 GPIO实战:MIO、EMIO与AXI GPIO配置全解
2026/10/15 15:54:29 网站建设 项目流程

ZYNQ-7000的GPIO使用,是很多刚从普通单片机转到这颗异构SoC上的工程师碰到的第一道坎。ZYNQ的GPIO并不像STM32那样打开时钟、配个模式就能点灯,它内部还分成了MIO、EMIO、AXI GPIO这三种形态,牵涉到PS端寄存器、PL端引脚约束、AXI总线地址映射和中断控制器。我最早做两灯一按键的测试工程时,在Vivado里配了一下午,结果上板灯就是不亮,最后查出来是MIO引脚被其他外设悄悄占用了。这篇文章会从一个实际需求拆解出发,完整走一遍硬件配置到SDK裸机驱动的流程,把方向寄存器、输出使能、EMIO引脚编号、AXI GPIO中断这些关键点都过一遍。适合刚开始用ZYNQ、准备在PS端写裸机程序的开发者参考,少走点弯路。

1. 先从MIO、EMIO、AXI GPIO三种形态说起

1.1 三种GPIO到底有什么不一样

ZYNQ-7000这颗芯片本质上是“双核ARM Cortex-A9处理器 + 可编程逻辑”的组合,处理器这边叫PS端,可编程逻辑那边叫PL端。GPIO在这种架构下被拆成了三种完全不同的使用方式,很多人一开始就是被这个搞晕的。

MIO是PS端自带的物理引脚,一共54根,编号从MIO0到MIO53,直接连到ZYNQ芯片的外部引脚上,CPU通过GPIO寄存器就能直接读写。这54根引脚不是全部留给GPIO用的,很多外设比如UART、SD、USB、SPI、I2C都可以复用这些引脚。MIO的优势是软件访问路径最短,点个灯、读个按键、做低速控制非常合适,缺点就是数量固定、复用关系复杂。

EMIO全称是扩展GPIO,它本质上是PS端GPIO控制器的一部分,引脚编号跟着MIO往后排,但在芯片内部并不直接引出物理引脚,而是把64路信号送到PL端。你需要把这个信号在PL端再接出来,连到PL的IO引脚上,或者接到PL内部的逻辑上。EMIO解决了PS端GPIO数量不够、又需要和PL逻辑做交互的问题。

AXI GPIO是完全跑在PL端的一个通用IP核,挂在PS端和PL端之间的AXI总线上。它不占用MIO,也不占用EMIO,而是在FPGA逻辑里例化出一个GPIO控制器,通过AXI总线访问。AXI GPIO的好处是宽度可以自己配,1到32位随你选,双通道还能配双向,而且自带中断功能,特别适合PL侧量大、需要并行控制的IO场景。

1.2 一个GPIO引脚背后管着哪些寄存器

不管是MIO还是EMIO,最终操作的都是PS端GPIO控制器里那一堆寄存器。裸机驱动里Xilinx提供的API帮你封装了一层,但如果你不知道底层原理,一旦出问题根本无从下手。

一个GPIO引脚在PS端GPIO外设里有三个最关键的位:方向位决定这个引脚是输入还是输出,输出使能位决定输出缓冲是否打开,数据位就是实际读写的数据。很多人点灯不亮,就是栽在“方向设置了输出,但输出使能没开”这种组合错误上。除此之外还有上下拉控制寄存器、中断使能寄存器、中断状态寄存器,每个MIO引脚都有对应的位。

MIO引脚还分了4个Bank,每个Bank有独立供电电压域。Bank0管MIO0到MIO7,Bank1管MIO8到MIO27,Bank2管MIO28到MIO51,Bank3管MIO52、MIO53,同时EMIO的64路信号也挂在Bank3的逻辑上。不同Bank的电平标准由硬件设计决定,有的板子MIO部分接3.3V,有的接1.8V,软件配置的时候并不能改电压,只能看板子原理图。

AXI GPIO IP内部也有类似寄存器结构,包含数据寄存器DR、方向寄存器DDR,通道2还有对应的GPIO2_DR、GPIO2_DDR,以及中断全局使能、中断使能、中断状态等寄存器。SDK里的XGpio驱动封装了这些操作,但中断处理流程还是得自己理清楚。

2. Vivado里把ZYNQ GPIO配明白的实操要点

2.1 MIO和外设复用怎么取舍

在Vivado里新建工程,加入ZYNQ7 Processing System这个IP后,双击打开PS配置界面。左侧列着UART、SPI、I2C、SD、USB这些外设,右侧是MIO Configuration页签,里面会列出54个MIO引脚当前的复用状态。配置的基本原则是:你想把某个MIO引脚当普通GPIO用,就必须保证它没被其他外设勾选占用。

我实际项目里遇到的情况是这样的:板子上UART接在MIO10和MIO11,这两个引脚在Peripheral I/O里勾选了UART0,对应MIO表格里就变成灰色;我想把MIO48、MIO49留出来接两个LED,结果调试串口默认又勾选了UART1,位置正好是MIO48和MIO49。我当时没注意直接导出硬件,SDK里代码怎么写都点不亮灯,查了半天才反应过来引脚被UART1占着。如果你不确认哪个MIO被占用了,直接在MIO Configuration表格里把不用的外设全部取消勾选,只保留调试串口和真正要用到的外设,是最稳妥的做法。

每个外设占用MIO的具体位置在表格里都能看到,比如SD0默认占MIO40到MIO45,USB0占MIO28到MIO39,SPI0占MIO13到MIO16。建议配置完成后把哪个MIO被谁占用列个清单,输出到Excel或者截个图,后面写代码对照着看效率高很多。

2.2 EMIO怎么接到PL里

EMIO的配置位置在PS配置界面的MIO Configuration里,把EMIO GPIO是占用的,对应MIO表格里就变成灰色道:在MIO Configuration页签找到GPIO部分,设置EMIO GPIO的宽度,可以从0配到64。配置完成后Block Design里ZYNQ PS核上会出现一个GPIO_0的端口组,信号宽度就是你设的位数。这个信号在PL端不会自动连到任何物理引脚,需要你手动把它接出去。

简单的做法是直接在Block Design里把GPIO_0做成外部端口,然后创建HDL顶层wrapper,Vivado会自动生成一个顶层模块把GPIO_0作为模块端口拉出来。这时候你还得在XDC约束文件里给这个端口指定PL引脚,例如set_property PACKAGE_PIN U14 [get_ports {gpio_0_tri_io[0]}],再设置IO标准。否则综合实现能过,上板却测不到任何电平变化,因为信号在PL端悬空着。

如果你想把EMIO信号接到PL内部逻辑而不是物理引脚,就不用在XDC里约束,直接在顶层模块里实例化自己的逻辑,把GPIO_0相关的信号连进去就行。实际中EMIO最常见的用途就是“PS端跨到PL端做一个简单的握手信号”,比如PS写一个寄存器值,PL通过这个值判断执行不同逻辑分支。

2.3 AXI GPIO IP怎么配才不埋雷

AXI GPIO这个IP在Block Design里可以直接搜出来,双击配置时重点关注三块:通道数量、数据宽度、方向。单通道模式适合纯粹的输入或输出,双通道模式适合同时需要输入和输出的场景,比如一个通道驱动电机控制信号,另一个通道读取编码器反馈。每个通道的数据宽度可以分别设置,1到32位。

配置完成后要点Run Block Automation,它会自动帮你把AXI GPIO挂到PS的M_AXI_GP0总线上,并且分配一段地址空间,默认通常是4KB。这个地址一定要记下来,SDK生成的BSP会用xparameters.h里的宏把这个基地址暴露出来,后面XGpio_Initialize就靠它。

如果要用中断,记得把AXI GPIO的interrupt端口连到PS的PL中断输入上,通常是IRQ_F2P[0]或直接连System中断。硬件连接做完后综合、实现、生成比特流,然后File -> Export Hardware勾选包含bitstream,再Launch SDK。

这里有一个经验:如果PL侧只需要简单的低速控制,比如几个LED、几个按键、几个继电器,优先用MIO或EMIO,别为这点事引入AXI GPIO。AXI GPIO适合PL侧信号数量特别多、或者需要配合数据流做并行控制的场景。同样的功能,MIO的代码只有十几行,AXI GPIO要做BSP初始化、地址映射、中断注册,复杂程度完全不一样。

3. 裸机驱动实操:方向、输出、输入与中断的完整代码

3.1 MIO点灯和按键读取的完整流程

在SDK里新建Hello World工程,先会调用XGpioPs驱动。使用PS端GPIO的套路比较固定,先用XGpioPs_LookupConfig找到外设配置,再XGpioPs_CfgInitialize做初始化,然后就是设置方向、输出使能、读写数据。

一个容易搞错的地方是:XGpioPs_SetDirection的第二个参数是Bank号,不是引脚号。比如你操作MIO0,传的是XGPIOPS_BANK0,数据值里边第0位对应MIO0,第1位对应MIO1。写整个Bank的数据用XGpioPs_Write,也是传Bank号再传数值,并不是直接操作某个引脚。很多人把单片机思维带过来,直接传引脚号,编译不会报错但行为完全不对。

我做的两灯一按键测试工程,MIO0接LED,MIO1接按键,代码是这样写的:

#include "xgpiops.h" #include "xparameters.h" #define BANK0 XGPIOPS_BANK0 #define LED_MASK 0x01 #define KEY_MASK 0x02 static XGpioPs Gpio; int GpioInit(void) { XGpioPs_Config *Cfg; Cfg = XGpioPs_LookupConfig(XPAR_XGPIOPS_0_DEVICE_ID); if (Cfg == NULL) { return -1; } XGpioPs_CfgInitialize(&Gpio, Cfg, Cfg->BaseAddr); /* MIO0输出,MIO1输入,方向按位设置:1输出 0输入 */ XGpioPs_SetDirection(&Gpio, BANK0, LED_MASK); /* MIO0打开输出使能,MIO1保持关闭 */ XGpioPs_SetOutputEnable(&Gpio, BANK0, LED_MASK); /* MIO1输入,内部上拉使能,按键低有效 */ XGpioPs_SetPullupEnable(&Gpio, BANK0, KEY_MASK); return 0; } void LedToggle(void) { u32 BankVal; BankVal = XGpioPs_Read(&Gpio, BANK0); XGpioPs_Write(&Gpio, BANK0, BankVal ^ LED_MASK); } int GetKey(void) { u32 BankVal = XGpioPs_Read(&Gpio, BANK0); return (BankVal & KEY_MASK) ? 1 : 0; }

有几个细节值得展开。SetDirection和SetOutputEnable是两回事,方向决定输入输出,输出使能决定输出缓冲是否真正打开。如果把方向设成输出但输出使能没打开,寄存器里的数据位写1也没用,引脚还是高阻状态。反过来,如果方向设成输入而输出使能打开,外部电路电平会被输出模式影响,可能把外部信号拉死。

按键读取还要注意抖动。上面代码只是简单读了一下电平,实际项目里建议加20到50毫秒的软件消抖,或者用定时器做周期扫描,在电平连续两次一致时才判定有效。MIO按键如果按下时接地,低电平表示按下,记得开内部上拉,否则引脚悬空,读到的值是随机跳变的。

3.2 EMIO引脚号到底从哪开始算

EMIO虽然是PS端GPIO控制器的一部分,但在XGpioPs驱动的Bank模型里,EMIO挂在Bank3下面。Bank3的位0和位1对应MIO52、MIO53,从位2开始才对应EMIO的64路信号。也就是说,EMIO0对应的操作位是Bank3的bit2,EMIO1对应bit3,以此类推。

代码里操作EMIO0并接到PL引脚的过程:

#define BANK3 XGPIOPS_BANK3 #define EMIO0_BIT (1 << 2) XGpioPs_SetDirection(&Gpio, BANK3, EMIO0_BIT); XGpioPs_SetOutputEnable(&Gpio, BANK3, EMIO0_BIT); XGpioPs_Write(&Gpio, BANK3, EMIO0_BIT);

如果你直接用XGpioPs_SetDirection(&Gpio, BANK3, 0x01),设置的是MIO52而不是EMIO0,这两个引脚大多数人根本没用过,信号电平变化完全看不到,很容易被绕进去。

EMIO的整个流程比MIO多两道工序:Vivado里要把EMIO GPIO宽度配置好,并且把GPIO_0端口拉到顶层或PL逻辑中;SDK里操作芯片内部的Bank3寄存器。上板测试前,先用万用表确认PL端那个物理引脚的bank电压和IO标准是一致的。PL引脚不能接到芯片供电电压不同的地方,否则出现电平不匹配,信号永远是错的。

3.3 AXI GPIO与中断组合的正确打开方式

AXI GPIO在SDK里用的是XGpio驱动,和XGpioPs完全是两个库。AXI GPIO操作按通道分,通道1对应GPIO,通道2对应GPIO2。设置方向用XGpio_SetDataDirection,注意这里传的Mask中0表示输出、1表示输入,和PS端XGpioPs是反的,我第一次用的时候在这上面栽过跟头。

初始化代码:

#include "xgpio.h" #include "xscugic.h" #include "xparameters.h" #define AXI_GPIO_DEVICE_ID XPAR_AXI_GPIO_0_DEVICE_ID #define CH1_OUTPUT_MASK 0x00 #define CH2_INPUT_MASK 0x01 static XGpio AxiGpio; static XScuGic Intc; void GpioIntrHandler(void *CallbackRef) { XGpio *GpioPtr = (XGpio *)CallbackRef; u32 IntStatus; /* 读出中断状态并清除,AXI GPIO中断状态位写1清除 */ IntStatus = XGpio_InterruptGetStatus(GpioPtr); XGpio_InterruptClear(GpioPtr, IntStatus); if (IntStatus & 0x01) { /* 处理通道2的输入变化 */ } /* 重新打开全局中断 */ XGpio_InterruptGlobalEnable(GpioPtr, 1); } int AxiGpioInit(void) { int Status; XScuGic_Config *IntcCfg; Status = XGpio_Initialize(&AxiGpio, AXI_GPIO_DEVICE_ID); if (Status != XST_SUCCESS) { return -1; } /* 通道1全部输出,通道2 bit0输入 */ XGpio_SetDataDirection(&AxiGpio, 1, CH1_OUTPUT_MASK); XGpio_SetDataDirection(&AxiGpio, 2, CH2_INPUT_MASK); /* 打开AXI GPIO的通道中断使能和全局中断 */ XGpio_InterruptEnable(&AxiGpio, 1); XGpio_InterruptGlobalEnable(&AxiGpio, 1); /* 初始化GIC中断控制器 */ IntcCfg = XScuGic_LookupConfig(XPAR_SCUGIC_SINGLE_DEVICE_ID); Status = XScuGic_CfgInitialize(&Intc, IntcCfg, IntcCfg->CpuId); if (Status != XST_SUCCESS) { return -1; } Xil_ExceptionInit(); Xil_ExceptionRegisterHandler(XIL_EXCEPTION_ID_INT, (Xil_ExceptionHandler)XScuGic_InterruptHandler, &Intc); Xil_ExceptionEnable(); Status = XScuGic_Connect(&Intc, XPAR_AXI_GPIO_0_IP2INTC_IRPT_INTR, (Xil_ExceptionHandler)GpioIntrHandler, &AxiGpio); if (Status != XST_SUCCESS) { return -1; } XScuGic_Enable(&Intc, XPAR_AXI_GPIO_0_IP2INTC_IRPT_INTR); return 0; }

中断处理函数里最关键的一步是读取状态并清除。AXI GPIO的中断状态寄存器是写1清除的,不清除的话中断标志一直挂着,处理函数退出后会立刻再次进入,表现为系统卡死在中断里。读完状态再清、处理完逻辑再打开全局中断,这个顺序别乱。

为什么推荐用AXI GPIO做输入中断而不是轮询?轮询读GPIO数据寄存器确实简单,但PL侧如果有多个信号同时变化,轮询可能丢事件,而且CPU一直被占着干等。中断方式让CPU在事件发生前休眠,事件来了由GIC唤醒处理,系统响应更快也更省资源。

4. 常见问题与排查技巧:一表看懂

4.1 引脚怎么都不输出,先查这三个寄存器

点灯不亮是最常见的现象,排查路径很固定。先用SDK的debug看寄存器实测值,方向寄存器、输出使能寄存器、数据寄存器三个逐一确认。方向位没有置1、输出使能没有置1、数据位没有写对任何一个出了问题,输出都不会有电平变化。

我用XGpioPs_Read把整个Bank的值读出来在调试串口打印过很多次,最典型的错误是配置了输出方向却没开输出使能,数据寄存器写1了但引脚始终是高阻。代码层面加一行XGpioPs_SetOutputEnable就解决,但很多人根本不知道有这个API。

硬件侧也要看:MIO引脚的bank电压是否没供电、开发板上LED是不是还有限流电阻、LED接的是高电点亮还是低电点亮。我遇到过把LED负极接MIO、正极接3.3V的情况,代码里写1反而灭,写0才亮,这时候别改代码,把逻辑反一下就行。

4.2 MIO引脚被外设占用了,代码一点问题都没用

代码看着完全正常,方向、输出使能、数据都对了,但输出还是没有反应,大概率是MIO被外设复用了。ZYNQ-7000的很多外设默认使能,SD0占MIO40到MIO45,UART1占MIO48和MIO49,USB0占MIO28到MIO39。如果SDK里BSP配置了相关外设,或者Vivado导出时这些外设是开着的,对应MIO就被硬件改成了外设功能,GPIO控制器根本驱动不到引脚。

排查顺序是:先回Vivado看MIO Configuration表格里这个引脚有没有被勾选外设,然后把不用的外设全部取消,重新生成硬件导到SDK。再看SDK生成的xparameters.h,里面每个外设的DEVICE_ID宏都会暴露出来,对着检查哪些外设还在。最后还要注意boot mode相关的引脚,比如MIO2到MIO6的跳线可能被启动模式占用,不能随便当GPIO用。

4.3 EMIO在PL侧测不到电平,信号哪去了

EMIO测不到电平,先问自己三个问题:PS配置里EMIO宽度设了吗?Block Design里GPIO_0信号连出来了吗?XDC约束绑到具体引脚了吗?这三个环节缺一个,EMIO信号都不会出现在物理引脚上。

有一个细节:EMIO信号在PL侧如果连到内部逻辑,不经过IO Bank,那么也不可能在物理引脚上测到。只有做了XDC引脚约束,信号才会真正输出到PL的IO引脚。检查XDC时把PACKAGE_PIN和IO标准对着板子原理图核对一遍,IO标准要匹配PL引脚的bank电压,否则电平完全不对。

如果信号连接到PL内部逻辑但逻辑里没有用到,综合后这个信号可能被优化掉,仿真和上板都看不到。可以加一条(* KEEP = "TRUE" *)属性在信号声明前,防止综合器把悬空信号优化掉。

4.4 中断进得去出不来,系统像卡住了一样

AXI GPIO中断进得去出不来,绝大多数是中断状态没清干净。中断状态寄存器write-1-to-clear,不写清就永远挂着,处理函数一返回又触发下一次中断,看起来就是程序死循环。处理函数要一进来就把状态读出来、立刻清除,再做业务逻辑,最后才重新开全局中断。

另外一个容易忽略的问题是在XScuGic_Connect之后必须先Enable中断,然后在AXI GPIO侧也要使能通道中断和全局中断,两侧任何一边没使能都不会触发。调试的时候我在XScuGic_Enable之前先调了XGpio_InterruptGlobalEnable,结果中断一直不来,顺序反过来就好了。

PL侧中断信号本身要是脉冲型的,电平型的可能会导致GIC状态异常。AXI GPIO IP默认的中断输出是电平有效还是边沿有效,和IP配置有关,通常在Vivado里保持默认即可,但如果你改过中断类型配置,回头再排查一遍没坏处。

4.5 快速排查速查表

现象可能原因检查点
MIO输出无电平变化方向或输出使能未配置读对应Bank寄存器,确认方向位和OEN位
MIO输出电平异常bank电压域不匹配或外部电路问题看原理图确认这个MIO的供电电压
代码没问题但GPIO被外设占用MIO复用冲突Vivado检查MIO Configuration,SDK检查xparameters
EMIO物理引脚无信号未约束XDC或GPIO_0未连接检查XDC的PACKAGE_PIN和IO标准
EMIO内部逻辑看不到信号被综合优化加KEEP属性,检查综合log
AXI GPIO写数据不生效地址或方向问题核对XPAR_AXI_GPIO_0_BASEADDR,XGpio_SetDataDirection掩码方向
中断反复触发中断状态未清除处理函数先读ISR并写1清除
中断不触发双侧使能没打开检查XScuGic_Enable和XGpio_InterruptEnable

5. 踩坑总结与Linux下的GPIO补充

5.1 Linux下ZYNQ GPIO怎么操作

如果板子跑的是Linux而不是裸机,GPIO操作路径和裸机完全不同,但根源还是那三种GPIO形态。Linux里MIO和EMIO被放在同一个gpiochip控制器下,AXI GPIO会注册成另一个gpiochip。用cat /sys/kernel/debug/gpio可以查看到当前系统里所有gpiochip的编号范围,每个chip对应哪个GPIO外设。

写应用层的时候通过libgpiod工具操作比较省心,gpiodetect列出所有chip,gpioinfo查看每个chip的引脚信息,gpioget和gpioset读写电平。不推荐再用老式的echo到/sys/class/gpio的方式,新内核里这类接口慢慢废弃了,而且编号映射上容易踩坑。

Linux下的重点跟裸机不一样,裸机要操心方向和输出使能寄存器,Linux下要操心设备树。PS端GPIO在设备树里的节点通常叫gpio@e000a000,里面有gpio-controller属性和ngpios属性,ngpios的数值包括MIO和EMIO。如果内核里注册的GPIO数量和硬件配置不匹配,多半是设备树里的ngpios设错了。AXI GPIO也有自己的设备树节点,compatible通常是xlnx,xps-gpio-1.00.a,reg属性对应Vivado分配的总线地址。

5.2 几个实际项目里的建议

基于我实际做过的几个ZYNQ工程,有几点建议非常实用。第一,工程开始之前就把MIO分配表做出来,用Excel把54个MIO从头到尾列出来,把UART、SD、启动模式这些固定占用标出来,剩下空着的才是GPIO候选引脚。第二,PL侧引脚约束尽量在项目初期就定下来,XDC里漏一个PACKAGE_PIN,整板跑起来才发现某个信号悬空,返工成本极高。第三,AXI GPIO的地址分配要留成固定值,别每次重新生成硬件都让工具自动分配,否则SDK里好几个宏跟着变,维护起来头大。

ZYNQ的GPIO使用本质上是“需求先进来,再决定走哪条路”的问题。低速、数量少、PS端自己控制的走MIO;需要跨进PL、做逻辑交互、信号量有限的走EMIO;量大、要双向、要中断的走AXI GPIO。把这三个方向想清楚了,Vivado配置和SDK代码基本就是照葫芦画瓢的事。

我个人踩过最深的坑,还是那句老话“先查硬件复用,再查软件寄存器”。很多时候代码没毛病,就是MIO被某个不起眼的外设占着。所以你拿到一块新板子,第一件事不是写代码,而是把Vivado的MIO配置表格导出来,对着板子原理图一格格核对,把每个引脚的归属弄清楚。这步做好了,后面的GPIO开发就是水到渠成的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询