老师,您可以写一份操作ps端gpio的经典例程嘛,比如这个gpio用来给到pl端,与pl端进行数据交互,可以传输数据,控制信号啥的;并每行代码分享注释,具体到底层是如何运行,为什么需要这样写,调用的这些函数每个函数的接口表示的含义,这些函数的功能,这些函数最后解析到底层是对应什么,谢谢啦
同学,坐!看到你要开始写 PS 端控制 GPIO 与 PL 端交互 的经典例程,老师真为你感到高兴。你现在不仅是在写代码,而是在试图理解"上帝是怎么设计这整个世界的"。
在 Zynq 里,GPIO 分为两种:
-
PS 端自带的 GPIO:直接控制芯片引脚(MIO)。
-
PL 端的 GPIO IP(AXI GPIO) :这就是我们今天要用的。它挂在 FPGA 内部,PS 通过 AXI 总线去控制它,它再去控制 PL 端的引脚。这就构成了 "软件控制硬件" 的最经典闭环。
🎯 第一步:硬件搭建(Vivado 里的伏笔)
在写 C 代码前,你必须先在 Vivado 里搭好硬件。这决定了你代码里的地址。
-
添加 Zynq7 Processing System。
-
添加一个 AXI GPIO IP 核(名字设为
axi_gpio_0)。 -
双击 AXI GPIO,把
GPIO Width设为 32(因为我们要传 32 位数据),如果是双向交互,勾选Enable Dual Channel。 -
用
Run Connection Automation把 Zynq 和 AXI GPIO 连起来。 -
在
Address Editor里,Vivado 会给它分配一个基地址(假设为0x4120_0000)。 -
导出
.xsa文件,在 Vitis 里新建工程。
🚀 第二步:代码实战与逐行拆解
c
#include "xgpio.h" // AXI GPIO 的底层驱动头文件
#include "xparameters.h" // 包含所有硬件地址的“字典”
#include "xil_printf.h"
// =========================================================
// 宏定义:从字典中提取硬件信息
// =========================================================
// XPAR_AXI_GPIO_0_DEVICE_ID 是系统给这个 IP 分配的编号(通常是 0)
#define GPIO_DEVICE_ID XPAR_AXI_GPIO_0_DEVICE_ID
// 声明一个 GPIO 驱动实例
XGpio Gpio;
int main()
{
int Status;
u32 send_data = 0xA5A5A5A5; // 我们要发给 PL 端的数据(随便写的)
u32 recv_data = 0;
xil_printf("--- PS to PL GPIO Test Start ---\r\n");
// 【1】初始化 GPIO 驱动
// XGpio_Initialize:让驱动找到硬件,绑定基地址。
// 内部机制:这个函数会去查系统字典(xparameters),找到 axi_gpio_0 的物理地址(比如 0x41200000),
// 然后把这个地址告诉我们的 Gpio 实例。这就相当于给 CPU 配了把去这个外设的钥匙。
Status = XGpio_Initialize(&Gpio, GPIO_DEVICE_ID);
if (Status != XST_SUCCESS) {
xil_printf("GPIO Initialization Failed!\n\r");
return XST_FAILURE;
}
// 【2】设置通道方向
// XGpio_SetDataDirection:配置是输入还是输出。
// 第一个参数:GPIO 实例。
// 第二个参数:通道 1(1 代表 Channel 1)。
// 第三个参数:0x00 代表全设为输出(0是输出,1是输入)。
// 底层机制:这行代码会通过 AXI 总线,往 GPIO IP 的“方向寄存器”里写 0。
// 这相当于告诉 PL 端:“这几根线我要用作输出,你别抢!”
XGpio_SetDataDirection(&Gpio, 1, 0x00);
// 【3】PS 发送数据给 PL
// XGpio_DiscreteWrite:往 GPIO 写数据。
// 底层机制:这行代码底层其实是一句 Xil_Out32(基地址 + 数据偏移量, send_data)。
// 数据顺着 AXI 总线狂奔,写入 GPIO IP 的数据寄存器,PL 端的引脚电平瞬间变化。
XGpio_DiscreteWrite(&Gpio, 1, send_data);
xil_printf("PS Sent to PL: 0x%08x\r\n", send_data);
// (在实际工程中,这里通常会加一点延时,或者等 PL 端发一个中断过来)
// 【4】PS 读取 PL 传来的数据
// 假设通道 2 是输入(如果在 Vivado 里配置了双通道)
// XGpio_SetDataDirection(&Gpio, 2, 0xFF); // 设置通道 2 全为输入
// XGpio_DiscreteRead:读取 PL 端传来的信号。
// 底层机制:底层是一句 Xil_In32(基地址 + 数据偏移量)。
// CPU 顺着 AXI 总线去“拿”数据。
// recv_data = XGpio_DiscreteRead(&Gpio, 2);
// xil_printf("PS Received from PL: 0x%08x\r\n", recv_data);
// 【5】反转数据再次发送(做回环测试)
send_data = ~send_data;
XGpio_DiscreteWrite(&Gpio, 1, send_data);
xil_printf("PS Sent reversed data: 0x%08x\r\n", send_data);
return 0;
}
🎯 第三步:扒开底层的黑盒(这些函数到底干了啥?)
同学,你可能会问:这些 XGpio_xxx 函数看起来很高级,它们到底是什么?其实,它们全都是一层层包装好的"马甲"。
咱们以 XGpio_DiscreteWrite 为例,扒开它的源码看看(路径通常在 xgpio.c):
c
void XGpio_DiscreteWrite(XGpio *InstancePtr, unsigned Channel, u32 Data) {
// 找到要写的寄存器的绝对地址
UINTPTR RegBase = InstancePtr->BaseAddress + XGPIO_DATA_OFFSET + (Channel - 1) * XGPIO_CHAN_OFFSET;
// 使用最底层的内存映射函数,直接把数据砸进去
XGpio_WriteReg(RegBase, 0, Data);
}
你再往下挖,XGpio_WriteReg 最终会变成什么?
在 xil_io.h 里,它变成了这句极其硬核的 C 语言代码:
c
*(volatile u32 *)RegBase = Data;
大白话翻译 :"把 RegBase 这个内存地址,强制转换成一个 32 位的指针,然后把 Data 赋值给这个地址。"
这就是软件控制硬件的终极真相! 所有的驱动库(HAL、BSP),只不过是在帮你算地址而已。只要你知道地址,你完全可以在 main.c 里直接写 *(volatile u32 *)0x41200000 = 0xA5A5A5A5;,连库都不用调!
🎯 第四步:PS 与 PL 的数据交互灵魂
上面的代码实现了 单向 传输。在真实的工业应用中,PS 和 PL 的数据交互有几个核心机制:
-
轮询(死等) :PS 读完数据后,在
while(1)里不断看 GPIO 的某个位(比如"数据有效"引脚)有没有拉高。- 缺点:极度浪费 CPU。
-
中断(最优解) :PL 端准备好数据后,拉高 GPIO 的一个引脚,这个引脚连到了 Zynq 的
pl_ps_irq,触发 CPU 中断。CPU 在中断里调用XGpio_DiscreteRead读取数据。- 优势:CPU 在平时可以去干别的事,数据到了自动处理。
-
AXI DMA(大批量数据) :如果是几百兆的数据,千万别用 GPIO 传。要用 AXI DMA 走
High Performance接口。
💡 老师的总结与忠告
同学,你发现了吗?你看懂了内存映射,GPIO 就再也藏不住秘密了。
为什么还需要写这么复杂的库函数?
因为你写的不仅是你自己的代码,而是整个团队的代码。用库函数,你的同事一看就懂;直接写地址,几个月后你自己都不知道 0x41200000 是什么鬼。这就是咱们之前讲的**"分层解耦"**。
实战中的坑 :
你在 Vivado 里可能配了 64 位的 GPIO,但 Zynq 是 32 位的 ARM,所以一次只能读写 32 位。分两次传就行。
去把这段代码敲进 Vitis,用万用表或者逻辑分析仪量一下 PL 端的引脚。当你看到自己用 C 代码轻轻敲下一行,真实世界的电平随之跳变时,那就是你成为 Zynq 高手的美妙瞬间!老师等你实验成功的捷报!
老师,来一个ps与pl,通过AXI GPIO 双向交互的经典例程吧,并每行代码分享注释,具体到底层是如何运行,为什么需要这样写,调用的这些函数每个函数的接口表示的含义,这些函数的功能,这些函数最后解析到底层是对应什么,谢谢啦
同学,坐!你终于问到了"双向交互",这绝对是 Zynq 开发中最有成就感的一步。之前的单向点灯只是"我说你听",而双向交互是**"握手交谈"**,这意味着 PS 和 PL 真正开始协同工作了。
为了保证你能跑通,老师今天不仅给你写上层应用代码,还要先告诉你 Vivado 里该怎么配,因为硬件决定了软件的"门牌号"。
🎯 第一步:Vivado 硬件配置(极其关键)
-
添加 AXI GPIO IP:在 Block Design 中双击它。
-
开启双通道 :勾选
Enable Dual Channel。 -
配置位宽 :把 Channel 1 和 Channel 2 的
GPIO Width都设为 8(我们做 8 位数据交互实验,简单直观)。 -
连线 :让
S_AXI连上 Zynq 的M_AXI_GP0。 -
分配地址 :在 Address Editor 里,Vivado 会分配一个基地址(假设是
0x4120_0000)。 -
(脑补操作):把 Channel 1 的引脚连接到 PL 端的 LED 或自定义逻辑的输入;把 Channel 2 的引脚连接到 PL 端的开关或自定义逻辑的输出。
🚀 第二步:Vitis C 代码实战与逐行拆解
c
#include "xgpio.h" // AXI GPIO 的底层驱动头文件
#include "xparameters.h" // 硬件地址字典(包含 DEVICE_ID 和 BASEADDR)
#include "xil_printf.h"
// =========================================================
// 宏定义与变量声明
// =========================================================
#define GPIO_DEVICE_ID XPAR_AXI_GPIO_0_DEVICE_ID
XGpio Gpio; // 实例化一个 GPIO 驱动对象
int main()
{
int Status;
u8 send_data = 0xAA; // 我们要发给 PL 端的 8 位数据 (10101010)
u8 recv_data = 0x00;
xil_printf("--- PS & PL Dual-Channel GPIO Test Start ---\r\n");
// 【1】初始化 GPIO 驱动 (找门牌号)
// 功能:将软件对象与底层硬件绑定。
// 底层操作:查字典拿到基地址(0x41200000),把地址存入 Gpio 结构体中。
Status = XGpio_Initialize(&Gpio, GPIO_DEVICE_ID);
if (Status != XST_SUCCESS) {
xil_printf("GPIO Initialization Failed!\r\n");
return XST_FAILURE;
}
// 【2】配置通道方向 (极其重要的双向设定)
// XGpio_SetDataDirection(实例, 通道, 方向值)
// 参数含义:方向值为 1 代表输入,0 代表输出。
// 通道 1 设为全输出 (0x00):PS 发数据给 PL
// 底层操作:向基地址+0x04 的寄存器写入 0x00。
XGpio_SetDataDirection(&Gpio, 1, 0x00);
// 通道 2 设为全输入 (0xFF):PS 从 PL 读数据
// 底层操作:向基地址+0x08 的寄存器写入 0xFF。
XGpio_SetDataDirection(&Gpio, 2, 0xFF);
// 【3】数据发送 (PS -> PL)
// XGpio_DiscreteWrite(实例, 通道, 数据)
// 功能:把 8 位数据写到通道 1 的输出引脚上。
// 底层解析:*(volatile u32 *)(0x41200000 + 0x00) = 0xAA;
// 硬件动作:AXI 总线把数据搬到 AXI GPIO IP,IP 立刻将引脚拉高/拉低。
XGpio_DiscreteWrite(&Gpio, 1, send_data);
xil_printf("PS sent to PL Channel 1: 0x%02x\r\n", send_data);
// (假装延迟一下,模拟 PL 端处理时间)
for(volatile int i = 0; i < 1000000; i++);
// 【4】数据接收 (PL -> PS)
// XGpio_DiscreteRead(实例, 通道)
// 功能:读取通道 2 当前引脚上的电平状态。
// 底层解析:recv_data = *(volatile u32 *)(0x41200000 + 0x08);
// 硬件动作:CPU 顺着总线去读 GPIO IP 的数据寄存器,拿到外部传入的值。
recv_data = XGpio_DiscreteRead(&Gpio, 2);
xil_printf("PS received from PL Channel 2: 0x%02x\r\n", recv_data);
// 【5】简单验证逻辑
if (recv_data == 0x55) { // 假设 PL 端设定了只要收到 0xAA,就回传 0x55
xil_printf("Bidirectional communication SUCCESS!\r\n");
} else {
xil_printf("Data mismatch.\r\n");
}
return 0;
}
🎯 第三步:扒开底层的黑盒(底层是怎么跑的?)
同学,你之前问过:"为什么要这样写?" 其实,这些函数全都是为了掩盖一个极其暴力的底层真相。
1. XGpio_Initialize 底层到底干了啥?
它其实就是个"指针赋值":
c
// 源码大概长这样
Gpio->BaseAddress = XPAR_AXI_GPIO_0_BASEADDR; // 比如 0x41200000
然后它会让 AXI GPIO 硬件复位一下。从这以后,你调用的所有函数,其实都在操作 0x41200000 这个地址附近的数据。
2. 为什么双通道在底层能分开?
你看你的这段代码 XGpio_DiscreteWrite(&Gpio, 1, send_data); 和读通道 2。
在底层的芯片手册里,AXI GPIO 这个 IP 内部有四个核心寄存器:
-
0x00:通道 1 数据寄存器 (GPIO_DATA) -
0x04:通道 1 方向寄存器 (GPIO_TRI) -
0x08:通道 2 数据寄存器 (GPIO2_DATA) -
0x0C:通道 2 方向寄存器 (GPIO2_TRI)
当你写 XGpio_SetDataDirection(&Gpio, 1, 0x00) 时,底层实际上是在往 0x41200004 这个地址写入 0x00。
当你写 XGpio_DiscreteRead(&Gpio, 2) 时,底层是在读 0x41200008 这个地址的当前值。
3. 硬件是怎么配合的?
-
对于输出(通道 1) :你往
0x00寄存器写 1,AXI GPIO IP 内部的三态门打开,对应引脚输出 3.3V 高电平;写 0 就输出 0V 低电平。 -
对于输入(通道 2) :PL 端的外设(或者外部按键)拉低了引脚,这个物理电信号传到了 AXI GPIO IP 的接收端。当你调用
Read去读0x08寄存器时,读到的那一位就是 0。
💡 老师的实战避坑指南(小白必看)
-
位宽不一致会出事 :如果你的 Vivado 里配置的是 32 位宽,但你代码里只传了 8 位(比如
0xAA),这没关系,高位补 0 而已。但如果你从 PL 端输入超过 32 位的信号,AHB/AXI 数据线就装不下了,会被截断。这就是为什么位宽配置要匹配的原因。 -
方向设反了是致命伤:如果通道 1 是输出,你硬把它配成输入,而你又在驱动它输出,硬件内部会发生总线冲突。严重时甚至可能烧毁芯片引脚(虽然 Zynq 内部有三态保护,但会读不到正确的值)。
-
不要用 GPIO 传大规模数据:我们这次做的是 8 位控制信号。如果你要传几百兆的图片,千万不要用这种办法。你要去用 AXI DMA,那才是干大工程的。
同学,你可以把这套代码跑起来,如果你有逻辑分析仪,去抓一下 PL 端的引脚波形,你会看到 0xAA 变成了真实的 01010101 的波形。这就是你亲手打通的软硬边界!去试试吧,有报错随时找老师!