☰
Zynq PS+PL协同控制流水灯:AXI总线与地址映射详解
2026/10/6 8:48:26 网站建设 项目流程

流水灯这个demo,几乎每个玩过单片机的人都在51上写过,逻辑简单到不值一提。可一旦放到ZedBoard上、放到Zynq的PS+PL架构里,事情就变了味:明明还是那几个LED,却要先折腾Vivado建工程、Block Design连线、AXI总线、地址映射,最后还要用C代码控制。很多人就是在这里卡住,觉得"这破灯怎么这么难"。

其实难的不是流水灯,而是Zynq的思维方式。上一篇我写了纯PL侧用Verilog实现流水灯,那个思路和传统FPGA一模一样——写计数器、写移位寄存器、约束引脚,完事。这一篇升级到PS+PL协同,核心工作从"怎么写逻辑"变成了"怎么让ARM核通过AXI总线去控制PL侧的寄存器"。这个思维拐过来之后,Zynq开发的大门才算真正打开,后面你做VDMA、做DDR读写、做Linux驱动,底层全是这套东西。

这篇我尽量把从Vivado建工程、Block Design连线、地址映射,到SDK/Vitis里写C代码、上板跑通的完整链路一次讲透,顺便把这段时间踩过的几个坑也一并列出来。

1. 先弄清这次和"纯PL流水灯"到底差在哪

1.1 传统FPGA思维下的流水灯

纯PL方案里,流水灯是这么做的:写一个分频计数器,计数到一定阈值产生一个使能信号,然后在这个使能下让寄存器左移或右移,移位结果直接送到LED引脚。整个过程没有任何"软件"参与,时序是硬件逻辑天然保证的,输出也是一拍一拍沿着寄存器链走。

这个方案的问题在于:一旦你想改变流水速度、想切换流水花样,就得改RTL代码、重新综合、重新生成比特流、重新下载。改一个延时参数就要全流程重走一遍,这在开发阶段非常折磨人。

1.2 换成PS + PL之后,难度其实跑到了"接口"上

PS + PL方案下,LED仍然接在PL侧(也就是FPGA的引脚上),但控制权交给了ARM处理器。ARM通过AXI总线去读写PL里一个叫AXI GPIO的外设IP,这个IP的输出再驱动LED。也就是说,LED的点亮、熄灭、移位节奏,全部由C代码在ARM上执行决定,PL只是提供了一个"手脚"。

这样一来,改流水灯花样的代价从一个完整FPGA工程周期,缩短成一次代码重新编译。你甚至可以在程序运行时根据串口输入动态切换流水方向,这在纯PL方案里基本不可能。

这个方案的难度不在逻辑本身,而在接口。你要理解AXI总线是什么、地址映射怎么分配、外设IP的寄存器长什么样、C代码里的API又是怎么和寄存器一一对应的。这几件事搞明白之后,PS和PL的配合就通了。

1.3 这套工程对后续Zynq开发的意义

说句实话,流水灯本身真没什么价值,有价值的是它把Zynq最典型的开发路径完整走了一遍:

  • 用Vivado Block Design搭PL侧硬件系统
  • 通过AXI总线把PS和PL外设连接起来
  • 导出硬件到Vitis/SDK
  • 用C语言操作AXI外设寄存器控制PL资源

后面你无论是挂一个自定义IP、接一个OV5640摄像头,还是做以太网传输,前面的几步几乎一模一样。所以这个demo值得认真做,做通一次,后面的路就顺了。

2. AXI GPIO和地址映射:PS凭什么"遥控"PL

2.1 M_AXI_GP0与0x4000_0000:一条所有外设共用的马路

PS侧有一个ARM Cortex-A9双核处理器,它想和PL侧通信,靠的是PS里的一组AXI主接口,其中M_AXI_GP0是通用目的接口,32位数据宽度,映射到物理地址0x4000_0000到0x7FFF_FFFF这个1GB的窗口。

打个比方,这个地址空间就像城市里的一条主干道,PL里每个外设IP都在路边抢占了一个门牌号(基地址)。CPU访问内存一样去访问这个地址,AXI总线协议就会把读写请求翻译成对具体外设寄存器的操作。

在Block Design里,AXI GPIO的基地址通常会被自动分配为0x4000_0000,占用4KB空间(0x4000_0000到0x4000_0FFF)。这个分配不是随便选的,而是Vivado的地址编辑器在PS的GP0地址窗口内自动找了一块空地方。一个外设占多大空间由它的寄存器数量决定,AXI GPIO属于轻量级外设,4KB绰绰有余。

你在软件代码里可以用xparameters.h里自动生成的宏XPAR_AXI_GPIO_0_BASEADDR拿到这个基地址,而不是硬编码。硬编码的问题在于:如果你在Block Design里手动调整了地址,代码就废了,而且这种bug非常难查。

2.2 AXI GPIO的寄存器到底长什么样

AXI GPIO是Xilinx提供的一个简单外设IP,它内部本质就是一组寄存器,直接映射到物理引脚。最常用的两个寄存器是:

  • 0x00偏移:GPIO_DATA,数据寄存器。你往这个寄存器写值,这个值就会出现在GPIO输出引脚上。
  • 0x04偏移:GPIO_TRI,方向寄存器。每一位对应一个引脚,写1表示该引脚为输入,写0表示输出。

这里有个关键点:GPIO_TRI复位默认值是0xFFFFFFFF,也就是所有引脚默认都是输入态。如果你在Block Design里没有勾选"All Outputs",那么上电后LED引脚是悬空输入状态,你往GPIO_DATA写再多值,引脚上也不会有输出。这就是很多新手"灯死活不亮"的根本原因之一。

方向寄存器对应每个引脚单独控制,所以你可以把一部分引脚配置为输出驱动LED,另一部分配置为输入接按键,同一个AXI GPIO IP就能完成多种功能。但注意我们通常为了简单,在IP配置里直接勾选All Outputs,这样GPIO_TRI复位值就是0,方向寄存器都不用管了。

2.3 Block Design里的连接关系

硬件连接逻辑如下:PS的M_AXI_GP0连接到AXI Interconnect,AXI Interconnect再连接到AXI GPIO的S_AXI端口。AXI Interconnect是总线交换矩阵,它负责地址路由,也就是根据访问地址判断这笔访问应该转发给哪个从设备。

时钟方面,PS的FCLK_CLK0默认输出100MHz,通过Block Automation自动连接到AXI Interconnect和AXI GPIO的S_AXI_ACLK。这个时钟就是PL侧外设的同步时钟,PS端CPU访问AXI总线时,和这个时钟是异步域关系,AXI协议本身保证了跨时钟域的正确性。

AXI GPIO的GPIO引脚则需要手动Make External,变成一个顶层端口,然后在XDC约束文件里映射到ZedBoard板卡上的LED引脚。如果用的是Vivado自带的板级文件,你可以直接在Board标签页里拖拽LED器件,系统会自动生成引脚约束,省去手动查XDC的步骤。

3. 裸机驱动代码拆解:主循环那几行才是灵魂

3.1 初始化:三步走

在SDK/Vitis的裸机环境下,控制AXI GPIO只需要三个步骤:初始化外设驱动实例、设置GPIO方向、开始读写数据。

#include "xgpio.h" #include "xparameters.h" #include "sleep.h" #include "xil_printf.h" #define GPIO_DEVICE_ID XPAR_AXI_GPIO_0_DEVICE_ID #define LED_CHANNEL 1 #define LED_MASK 0x0F static XGpio led_gpio; int main(void) { u32 led_state = 0x01; int direction = 1; XGpio_Initialize(&led_gpio, GPIO_DEVICE_ID); XGpio_SetDataDirection(&led_gpio, LED_CHANNEL, 0x00); xil_printf("PS+PL LED demo started...\r\n"); while (1) { XGpio_DiscreteWrite(&led_gpio, LED_CHANNEL, led_state); if (direction) { led_state <<= 1; if (led_state == 0x10) { led_state = 0x08; direction = 0; } } else { led_state >>= 1; if (led_state == 0x00) { led_state = 0x02; direction = 1; } } usleep(200000); } return 0; }

XGpio_Initialize负责把驱动实例和硬件基地址绑定;XGpio_SetDataDirection的第二个参数是通道号,AXI GPIO支持两个独立通道,只用通道1的话传1就行,第三个参数0x00表示所有位设为输出,对应GPIO_TRI寄存器全部写0。

3.2 流水灯状态的过渡逻辑

这个流水灯的移位逻辑我用了最直观的写法:变量led_state表示当前哪个LED亮,direction表示移位方向。在循环体内先写当前状态到硬件,再计算下一个状态,然后延时200ms。

从0x01左移到0x08后,如果继续左移会变成0x10,超出4位范围,所以代码里检测到这个值后,把状态重置为0x08,并把direction改为0,开始反向移动;反向移动到0x01后继续右移会变成0x00,同样需要拦截,重置为0x02并改回正方向。

这是流水灯最经典的"边界回环"问题。你可以用if判断处理,也可以用环形移位、查表法,甚至用状态机。在板级调试时,我建议用这种显式的边界判断,思路清晰,出问题也好定位。

3.3 延时与优化器陷阱

裸机环境下没有操作系统,usleep这个函数由BSP提供,底层是基于定时器的,精度足够。但你如果图省事自己写延时循环,有一个特别坑的点:如果延时变量没加volatile修饰,编译器开-O2优化后可能直接把整个空循环优化掉,导致流水灯闪得飞快,肉眼根本看不清。

// 反面教材 void delay(void) { unsigned int i; for (i = 0; i < 1000000; i++); }

这段代码在某些编译优化等级下会被优化器判定为"循环体无副作用",直接删除。所以要么用BSP自带的usleep,要么把i加上volatile修饰。再说一次:裸机环境下,能用官方BSP的函数就不要自己造轮子,不是为了省事,是为了避开这些看不见的坑。

4. 从Vivado建工程到板子跑通的完整链路

4.1 建工程与Block Design

打开Vivado,创建新工程,RTL Project类型,勾选"Do not specify sources at this time",这样Vivado会帮你建一个空的源文件目录。器件型号选择ZedBoard对应的xc7z020clg484-1,这一步千万别选错,Zynq的PS硬核是固定集成在硅片上的,型号不对后面综合会直接报错。

工程建好后,左侧Flow Navigator里点击Create Block Design,名字随便取,比如system。在Diagram窗口的空白处右键,选择Add IP,搜索"zynq7 processing system"并添加。

添加之后,PS核是以一个灰色大框形式出现的,此时还没有任何配置,需要先运行Block Automation。Vivado上方会弹出绿色提示条,点击Run Block Automation。在弹出的选项里,ZedBoard的板级预设会自动帮PS配置好DDR、UART、QSPI、SD等外设,这些参数每一个都决定了PS能不能正常启动,新手不建议手动改动,直接保留默认即可。

4.2 重点:让Vivado自动完成AXI互联

接着再添加一个AXI GPIO IP。双击打开配置界面,GPIO Width设为4,选择All Outputs,Channel 1的GPIO就配置完成了。

关掉配置窗口之后,再添加一个AXI Interconnect吗?不用。你只需要手动把AXI GPIO的S_AXI端口连接到PS的M_AXI_GP0端口,在Diagram里把两个端口用鼠标拖到一起,系统会弹出一个连线确认框,让你选择自动创建AXI Interconnect和时钟/复位连接。选择自动创建,Vivado会自动帮你插一个AXI Interconnect,并把FCLK_CLK0、复位信号一并连好。

这里我强烈建议新手依赖Vivado的自动连线功能,不要自己手动去接时钟和复位。AXI Interconnect的时钟连接、复位极性、跨时钟域处理这些细节,手动接非常容易出问题,而且报错信息对新手极不友好。自动连线生出来的结构可能不够优雅,但一定是能工作的。

4.3 地址分配与HDL Wrapper

连线完成后打开Address Editor标签页,可以看到AXI GPIO已经被自动分配了基地址,一般就是0x4000_0000。如果没分配,可以右键选择Assign Address,让Vivado自动分配。

AXI GPIO的GPIO引脚目前还是挂在Block Design内部的gpio_rtl虚拟端口上。右键选择Make External,它会变成顶层的一个输入输出端口。这一步做完,Block Design的硬件部分就算完成了。

接下来在Sources面板里右击block design源文件,选择Create HDL Wrapper,选Let Vivado manage wrapper。Vivado会自动生成一个顶层Verilog文件,把Block Design例化进去,同时暴露gpio_rtl端口。同样的,GPIO的引脚约束在这个顶层里还没有分配,需要打开工程自带的XDC约束文件,把gpio_rtl_0_tri_o[3:0]这个端口映射到ZedBoard的LED物理引脚。

ZedBoard的官方XDC文件里LED引脚的约束清晰可查,如果你用的是支持板级文件的Vivado版本,这一步也可以直接在Board标签页拖拽完成,Vivado会自动生成约束。

4.4 导出硬件,在Vitis里写应用

综合、实现、生成比特流的过程比较常规。点Generate Bitstream之后,Vivado会自动跑完完整流程。如果中途报时序错误,大概率是Block Automation配置里某个时钟没配对,这个后面展开说。

比特流生成成功后,执行File > Export Hardware,勾选Include bitstream,导出一个xsa文件。从Vivado 2020.1开始,Xilinx把SDK改名成了Vitis,所以直接用Tools > Launch Vitis IDE的方式打开开发环境。

在Vitis里,先创建一个Platform Project,选择你刚导出的xsa文件。Platform工程编译完成后,再新建Application Project,选择Empty Application模板,然后创建main.c文件,写入上一节那段流水灯代码。注意这里要包含xparameters.h和xgpio.h,BSP在Platform工程里自动帮你关联好了。

4.5 上板运行与串口验证

连接ZedBoard的电源、JTAG下载线和USB转串口线。串口终端设置波特率115200,8N1。

在Vitis里先运行一下Program FPGA,把比特流下载到PL侧。这一步很关键,因为裸机程序里Store指令访问的是PL侧的AXI GPIO地址,如果PL侧没有加载比特流,这个地址上没有任何外设,ARM访问就会挂死,程序跑不起来。

然后右键Application工程,选择Run As > Launch on Hardware(Single Application Debug)。程序开始运行后,ZedBoard上的4个LED应该开始来回流水,同时串口终端会打印出启动信息。如果看到这个现象,恭喜,PS+PL的通路已经完整打通了。

5. 我踩过的几个坑:从识别不到板子到灯死活不亮

5.1 Vivado识别不到ZedBoard,驱动背锅

这个问题几乎每个新手都会遇到,插上USB线之后,Vivado的Hardware Manager里一片空白,或者显示No JTAG cable found。

ZedBoard的USB-JTAG接口用的是赛灵思的Platform Cable USB方案,需要单独安装驱动。驱动在Vivado安装目录下的data/xicom/cable_drivers文件夹里,装完还要注意Windows系统的数字签名策略,如果是Win10及以上,先把驱动签名强制禁用再装,或者通过"高级启动"里的"禁用驱动程序强制签名"模式来加载未签名驱动。这个坑一般窗口期在换电脑、重装系统之后,装一次能管很久。

还有一种情况是JTAG链上有其他设备冲突,或者USB线用了只有充电功能的线,导致数据不通。交换USB口、换根公头线,很多时候能解决。

5.2 生成比特流时的"设计为空"和时序告警

生成比特流过程中,如果报"Synth 8-3331: design is empty",基本可以判断是HDL Wrapper没有设置成顶层,Block Design没有被例化进去。在Sources面板里右键wrapper文件,选择Set as Top,重新综合即可。

时序告警方面,Zynq的PS + PL系统最常见的是setup timing violation,尤其是当你用了较高的FCLK_CLK0频率时。默认100MHz一般不会出问题。如果报了,先检查Block Automation是否完整执行过,时钟和复位是不是真的都连上了。多数情况下,自动生成的连接不会有时序问题,反而手动改过的连接容易出这种错。

5.3 Program FPGA失败

Program FPGA失败,报错信息会包含类似"Can not download bitstream"、JTAG communication error等。

先检查电源是否正常供电,再看JTAG是否在Hardware Manager里识别到设备。还有一种比较隐蔽的情况:因为ZedBoard支持多种启动模式,板上的跳线帽(JP1和JP2)如果被设置在QSPI或SD卡启动模式,FPGA配置会被绕过。裸机调试时,建议把跳线设到JTAG模式,否则PL侧的下载路径可能受阻。这个细节上板时超容易忽略,建议先养成看启动模式的习惯。

5.4 灯不亮:方向寄存器与高低有效

代码编译、下载都成功,程序也跑在Running状态,可LED就是全灭或全亮,没有任何流水效果。

先检查GPIO方向寄存器。如果Block Design里没勾选All Outputs,默认方向是输入,输出驱动不生效。用XGpio_SetDataDirection(&gpio, LED_CHANNEL, 0x00)可以显式设成输出,这个一定要在写数据之前调用。

再检查高低有效。不同板卡的LED驱动极性不同,有的LED阴极接FPGA引脚,写1是灭、写0是亮,有的是阳极接引脚,写1亮、写0灭。ZedBoard这4个LED是高有效,写1点亮,但推荐你在代码里加一个初始化状态测试:先把0x0F写入GPIO_DATA,再写0x00,看LED是否跟着亮灭。这样能快速排除极性问题,不至于在排错时怀疑人生。

写数据的方式也有讲究。XGpio_DiscreteWrite是直接把值写到GPIO_DATA寄存器,适合我们这种整组LED输出场景。XGpio_DiscreteSet和XGpio_DiscreteClear则是按位设置和清除,适合单独控制某一位而不影响其他位,可以理解为一个直接覆盖、一个读改写。在并发场景下,后者存在读改写竞态的风险,但裸机流水灯完全用不到,知道有区别即可。

5.5 printf不输出但程序在跑

程序运行正常,LED流水也在跑,可串口终端啥都不显示。

这个问题的根源在于裸机BSP里printf的重定向需要依赖一个串口驱动,并且BSP配置里的stdin和stdout必须指向UART1(ZedBoard上PS侧的调试串口)。在Platform工程的BSP设置里检查一下stdout是否为uart1。还有一种可能是串口终端应用的问题,波特率、数据位、停止位、校验位任何一个不对,printf输出就会变成乱码或干脆不显示。我一般是先在代码里加一句xil_printf启动信息,如果这句都不显示,就优先排查串口连接和终端参数,排除法效率最高。

6. 跑通只是个开始:三个值得做的扩展方向

6.1 把点灯逻辑挪到PL自定义IP里

这个demo里,流水逻辑在PS侧跑,PL侧的AXI GPIO只是个被动输出脚。下一步你可以试试在PL里写一个自定义IP,内部用Verilog实现流水逻辑和寄存器配置接口,PS通过AXI总线往IP里的寄存器写控制字。比如你写一个寄存器,低4位控制流水速度,第4位控制流水方向,一次写操作就能同时改变多个行为参数。

这样做的好处是让你亲自体验一次AXI Slave接口的握手逻辑,理解AXI协议里的读地址通道、写数据通道、响应信号这些概念。有了这个概念,后面看VDMA、看AXI Stream接口就不懵了。做法不是太难:在Vivado里Tools > Create and Package New IP,选择AXI4 Peripheral模板,Vivado会帮你生成带完整AXI接口的骨架,你只需要往里面填逻辑。

6.2 挂到Linux下用GPIO子系统点灯

如果哪天你准备把Zynq跑成带Linux系统的嵌入式平台,现在这套LED硬件可以无缝迁移。把xsa文件导出给PetaLinux,在内核配置里打开Xilinx GPIO驱动,启动后在用户空间里就能通过/sys/class/gpio目录下的接口直接操作这几个LED。

具体来说,内核设备树里会给AXI GPIO分配一个gpio-controller节点,里面有reg属性指定了基地址,加上gpio-cells描述引脚数量和编号。启动后写一个echo到/sys/class/gpio/export文件,把引脚导出来,再echo out到direction属性,最后echo 1到value属性,LED就会点亮。整个过程没有写任何驱动代码,全靠内核里现成的gpio子系统,这对理解设备树、理解Linux下外设驱动的分层模型都极有帮助。

6.3 用中断替代轮询

目前的流水灯是纯轮询模式,CPU全程在跑循环。将来如果你接了按键、接了传感器,总不能一直轮询等待事件到来,这时候中断就必不可少了。扩展方向可以是:把按键接到AXI GPIO的某个输入通道,开启AXI GPIO的中断输出,再连接到PS的PL-PS中断端口IRQ_F2P。PS端用XScuGic中断控制器驱动,注册一个按键中断处理函数。

这个扩展做完,你对中断控制器、中断线编号、中断服务程序的注册流程就有整体认识了。它的复杂度和这个流水灯不在一个量级,但一步步做下来,收获同样很大。实际调试时,建议先用一个按键触发中断,中断服务程序里改变LED状态,这样验证链路最短,容易定位问题。


如果你是从51或STM32转过来学Zynq的,这套PS+PL流水灯值得认真做完。它不复杂,但串联了Zynq开发里最核心的那条链路:硬件工程搭建、AXI总线互联、地址映射、裸机驱动、上板调试。等这条链路在你手里跑通一次,再回头看那些复杂的Zynq项目,你会发现它们的骨架和你现在做的这个流水灯,并没有本质区别。

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

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

立即咨询