
简介资源包围绕ZYNQ进阶之路中自定义AXI-FULL IP实现PS与PL双向高速通讯的完整示例面向ZYNQ SoC开发者、嵌入式学习者和硬件加速设计人员。内容覆盖AXI-FULL接口定义、DMA控制器设计、Vivado工程搭建、软硬件联动调试等关键环节可参考构建高吞吐数据传输链路。压缩包共1261个文件总大小48.39MB主要包含C/H源码、V/Verilog与VHD硬件描述、XDC约束、TCL脚本、bit/elf可执行文件及工程配置文件便于按模块研读和复用。其中C/H源码体现驱动与应用设计硬件描述文件定义IP核逻辑约束与脚本简化项目重建bit/elf文件可直接部署验证此外还包含仿真数据与日志文件便于结果验证。已有523人浏览学习资源依Vivado工程目录组织附文档与仿真数据适合希望掌握AXI协议、乒乓缓冲与DMA传输的进阶学习者。对照源码与工程配置可加深对ZYNQ架构及自定义IP开发流程的理解。1. 从轮询到突发自定义AXI-FULL IP解决的是ZYNQ的真正高速通讯问题在ZYNQ开发中很多刚接触PS和PL通讯的人第一反应是写AXI-Lite寄存器把数据一个一个塞进寄存器。塞到几百KB时就会发现CPU占用率高得让人头疼传输速度却只有几MB/s。标题里的自定义AXI-FULL IP就是为了解决“高带宽、大块数据、双向搬运”这个真实需求。Vivado里面称它为AXI4 Full接口适合图像采集回传、AD/DA数据缓冲、PL侧算法与PS侧共享内存等场景。它的本质是让PS像访问内存一样去读写PL里的BRAM或DDR一次总线事务可以连续传几十上百个数据。这篇文章不是介绍AXI协议的理论书而是把“自定义AXI-FULL IP实现PS和PL双向高速通讯”这个工程方案拆开怎么选型、怎么建IP、怎么出驱动、哪里有坑。新手能照步骤跑通一个最小系统熟手可以跳过操作直接看避坑部分。2. 动手前先把AXI选型和带宽预算做对2.1 AXI-Lite、AXI-FULL、AXI-Stream到底怎么选先给一个最直接的工程经验控制寄存器用AXI-Lite大块数据交换用AXI-FULL纯数据流用AXI-Stream。很多人把Stream和Full混在一起其实它们差别很大。AXI-Stream不带地址数据从一个主设备流向从设备适合视频流、FFT流这类连续数据AXI-FULL带地址支持突发写和突发读主设备可以随机访问从设备地址空间里的任意位置。接口是否带地址是否支持突发典型场景谁发起AXI-Lite是否寄存器读写PS配置PLPS主PL从AXI-FULL是是BRAM/DDR大块搬运DMAPS主或PL主AXI-Stream否是连续流视频、DSP流数据PL内部/DMA为主AXI-FULL的完整通信包含五个通道写地址AW、写数据W、写响应B、读地址AR、读数据R。每个通道都有一对VALID/READY握手信号。这个协议本身不复杂但状态机的坑多比如WLAST没有在最后一个数据拉高或者BVALID和BRREADY握手错位都会导致总线卡死。所以在自定义IP之前先明确你的数据通路是哪一种。标题里明确写了AXI-FULL那就意味着你要做的是“PS或PL作为主设备突发读写一大段连续地址”而不是在一个寄存器里慢慢倒数据。2.2 带宽预算先算再选数据位宽和时钟很多示例工程默认32位数据总线理由是ZYNQ的GP口就是32位。但做“高速通讯”建议先按公式算一下再定理论吞吐 数据宽度 × AXI时钟 × 通道利用率例如32位总线跑100MHz理论每秒传输400MB。但AXI协议里有地址阶段、反压和握手等待实际能达到250~300MB/s已经算不错。同样100MHz下64位总线理论800MB/s实测通常能到500~600MB/s这也是ZYNQ PS的HP口默认支持64位的原因。真正动手前先回答三个问题一次传输的数据量是多少几百字节还是几MB是持续流还是突发性传输采集卡一般是大包突发图像处理是连续流。PL侧挂的是BRAM还是DDR3关于第三点现在有很多项目做“zynq ps读写pl外挂ddr”。这种情况下PS去访问的不是IP内部的BRAM而是MIG控制器管理的DDR地址空间AXI-FULL IP只是一个通路。这时瓶颈往往不在AXI协议而在DDR的刷新效率、Page命中率和MIG配置。我的经验是带宽预算要按实测值的70%来规划否则后面接算法逻辑时会发现时序根本跑不满。2.3 双向通讯最常见的共享内存式拓扑很多人看到“双向高速通讯”就想设计两条独立通路一条上行一条下行。工程上最稳妥的做法是共享内存自定义一个带AXI-FULL从接口的IP内部放一块双端口BRAM或一组BRAM。PS通过HP口往BRAM写数据PL算法从BRAM另一端口读数据PL算完把结果写回同一块BRAM再通过中断通知PS来读。这种结构有三个明显好处AXI-FULL从接口逻辑集中在BRAM不需要自己实现复杂的Master状态机。数据块大小可以按需配置从几KB到几十MB都可以。中断只负责“通知”真正的数据全走内存通路不会一个数据打断CPU一次。对应的自定义IP通常包含两部分AXI-FULL从接口接PSBRAM的B口留给用户逻辑。PL侧算法通过B口访问同一片存储区。这里有个仲裁问题如果PL算法和PS同时访问BRAM双端口BRAM本身支持两边同时操作但如果两边写同一个地址数据就会冲突。常见做法是让双方按帧交替使用缓冲或加一个简单的轮询仲裁。一开始不需要把仲裁做复杂先让通信跑起来最重要。3. 创建自定义AXI-FULL IP从GUI流程到底层BRAM3.1 用Create and Package IP把骨架生成出来在Vivado里创建自定义AXI-FULL IP最常用的入口是Tools - Create and Package IP - Next - Create AXI4 Peripheral。这个过程会生成一个带完整AXI从机接口的工程模板省去自己手写总线接口的大半工作量。也可以用Tcl命令先建一个普通工程create_project zynq_axi_full_demo ./proj -part xc7z020clg400-2 set_property target_language Verilog [current_project]参数说明工程名可以随意改但-part一定要改成你实际板卡上的FPGA型号。xc7z020是ZYNQ开发板里最常见的器件如果是7010需要改成xc7z010clg400-1否则后续综合会报器件不匹配。向导里的三个关键参数Number of Slaves选1一个从接口就够用。Interface Type选Full不能选Lite。这是标题里AXI-FULL的来源。Data Width有带宽压力选64普通验证选32。Address Width决定内部可寻址空间16表示64KB地址空间。生成后的模板里会有一个slave_full_v1_0_S00_AXI.v文件默认是一组寄存器逻辑只支持单拍读写不满足大块传输需求。下一步要把它的后端逻辑改成BRAM。3.2 用AXI BRAM Controller省掉手写状态机手写AXI-FULL状态机是非常容易翻车的事。尤其是指定WLAST、BVALID和RLAST的顺序稍不注意PS端发起一次长burst就会一直卡在等待响应。我的建议是自定义IP顶层保留AXI-FULL接口内部实例化Xilinx的AXI BRAM Controller把协议处理交给官方IP自定义逻辑放在BRAM的另外一侧。关键Verilog实例化代码如下axi_bram_ctrl #( .DATA_WIDTH(C_S00_AXI_DATA_WIDTH), .ADDR_WIDTH(C_S00_AXI_ADDR_WIDTH - 2) ) u_bram_ctrl ( .S_AXI_ACLK (s00_axi_aclk), .S_AXI_ARESETN (s00_axi_aresetn), .S_AXI_AWADDR (s00_axi_awaddr[C_S00_AXI_ADDR_WIDTH-1:0]), .S_AXI_AWVALID (s00_axi_awvalid), .S_AXI_AWREADY (s00_axi_awready), .S_AXI_WDATA (s00_axi_wdata), .S_AXI_WSTRB (s00_axi_wstrb), .S_AXI_WVALID (s00_axi_wvalid), .S_AXI_WREADY (s00_axi_wready), .S_AXI_BVALID (s00_axi_bvalid), .S_AXI_BREADY (s00_axi_bready), .S_AXI_ARADDR (s00_axi_araddr[C_S00_AXI_ADDR_WIDTH-1:0]), .S_AXI_ARVALID (s00_axi_arvalid), .S_AXI_ARREADY (s00_axi_arready), .S_AXI_RDATA (s00_axi_rdata), .S_AXI_RVALID (s00_axi_rvalid), .S_AXI_RREADY (s00_axi_rready) );逻辑说明这段代码把模板暴露出来的S00_AXI信号原样透传给AXI BRAM Controller。BRAM Controller会自己处理INCR、WRAP这些突发类型不需要再关心具体的last时序。只需要把BRAM侧的端口接一个Block Memory Generator并把Block Memory Generator配置成双端口A/BA口给AXIB口留给用户逻辑。参数说明ADDR_WIDTH用C_S00_AXI_ADDR_WIDTH - 2原因是AXI地址是字节地址而BRAM按字寻址。32位总线时低2位不参与字选如果你把数据宽度改成了64这里要减3。这个参数不匹配是新手最容易忽略的细节错了之后读写数据会偏移但仿真又不会立刻暴露。用AXI BRAM Controller而不是自己写状态机还有一个额外好处Xilinx对这个IP做了S_AXI_HP口适配PS侧的AXI时钟和PL侧BRAM时钟能做到异步减少时钟域问题。3.3 在Block Design里挂HP口并分配地址回到工程主界面添加ZYNQ7 Processing System在PS配置里同时使能GP口和HP口。GP口用于AXI-Lite寄存器访问HP口用于大块数据搬运。如果不使能HP口后面的带宽提升无从谈起。连接关系自定义IP的S00_AXI接到PS的S_AXI_HP0自定义IP的S00_AXI_ACLK接到PS的FCLK_CLK0复位信号接到proc_sys_reset外设的peripheral_aresetn。中断信号如果要做连接到PS的PL-PS Interrupt端口。打开Address Editor给自定义IP分配一个地址范围。比如BRAM是32KB就分配32KB基地址用0x40010000。这里有个常见现象如果自动分配的地址出现在0x43C00000附近说明IP被识别成了AXI-Lite外设。我们需要检查IP Interface Type是不是Full或者手工把地址段改到PS的S_AXI_HP0空间下。最后生成Output Products导出硬件包括bitstream启动Vitis。导出后在Vitis的xparameters.h里能看到自定义IP的基地址和中断号这是我们下一章写程序要用的关键参数。4. PS端驱动与自检把AXI-FULL BRAM当普通内存用4.1 用mmap映射PL侧物理地址在Linux/PetaLinux下访问自定义IP的BRAM最简单的方式是打开/dev/mem并mmap。这样不需要写内核驱动可以直接用指针读写BRAM地址。#include stdio.h #include fcntl.h #include sys/mman.h #include unistd.h #define BRAM_BASE 0x40010000UL #define BRAM_SIZE 0x8000UL int main(void) { int fd open(/dev/mem, O_RDWR | O_SYNC); if (fd 0) { perror(open /dev/mem); return -1; } unsigned long *va mmap(NULL, BRAM_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, BRAM_BASE); if (va MAP_FAILED) { perror(mmap); return -1; } volatile unsigned long *bram va; printf(map ok, first value 0x%08lx\n, bram[0]); return 0; }参数说明BRAM_BASE必须和Vivado Address Editor里分配的基地址一致BRAM_SIZE建议只映射实际BRAM大小不要多映射。O_SYNC避免写入停在page cache里MAP_SHARED让同一物理地址的修改对其他映射可见。加volatile是为了防止编译器把后续循环里的读写优化掉。如果是裸机或Vitis standalone环境不需要/dev/mem这种方式直接用Xil_In32/Out32或直接指针访问物理地址操作更简单。但Linux下用mmap是通用做法。4.2 读写自检与数据格式约定跑通总线最扎实的办法是写一个自检循环往BRAM里写一串带规律的数据再读回来比对。#define TEST_WORDS 4096 volatile unsigned long *bram va; for (unsigned long i 0; i TEST_WORDS; i) { bram[i] 0xA0000000UL | (i 0x0FFFFFFFUL); } __sync_synchronize(); int errors 0; for (unsigned long i 0; i TEST_WORDS; i) { if (bram[i] ! (0xA0000000UL | (i 0x0FFFFFFFUL))) { errors; } } printf(AXI-FULL BRAM test: %d errors / %lu words\n, errors, TEST_WORDS);逻辑说明写入数值里带上了地址索引所以读回时能精确判断是数据错位了还是数据被覆盖。__sync_synchronize()是关键它是一道内存屏障防止CPU把写操作重排到读操作之后。这里有个工程习惯先把TEST_WORDS设成1个word通过后再设16、256、4096。如果小地址能过大地址出错通常是mmap范围越过了IP实际地址空间。如果前几个word能过后面全是0大概率是BRAM深度不够或AXI地址高位没有正确参与译码。4.3 双向通讯握手中断加状态寄存器PS写完数据后PL怎么知道有两种常见方案。第一种是轮询在BRAM末尾保留一个word作为命令字PL算法不断读这个word第二种是中断PS在写完数据后写一个控制寄存器IP检测到上升沿后通知PL算法开始处理。考虑到双向高速通讯我倾向于中断加状态寄存器。Vitis下的中断初始化代码大致是#include xscugic.h static XScuGic Gic; int SetupInterrupt(XScuGic *GicPtr) { XScuGic_Config *Cfg XScuGic_LookupConfig(XPAR_PS7_SCUGIC_0_DEVICE_ID); if (Cfg NULL) return -1; int Status XScuGic_CfgInitialize(GicPtr, Cfg, Cfg-CpuBaseAddress); if (Status ! XST_SUCCESS) return -1; Status XScuGic_Connect(GicPtr, XPAR_FABRIC_AXI_FULL_IP_0_IRQ_INTR, (Xil_InterruptHandler)PlIrqHandler, GicPtr); if (Status ! XST_SUCCESS) return -1; XScuGic_Enable(GicPtr, XPAR_FABRIC_AXI_FULL_IP_0_IRQ_INTR); Xil_ExceptionInit(); Xil_ExceptionRegisterHandler(XIL_EXCEPTION_ID_IRQ_INT, (Xil_ExceptionHandler)XScuGic_InterruptHandler, GicPtr); Xil_ExceptionEnable(); return 0; }参数说明XPAR_FABRIC_AXI_FULL_IP_0_IRQ_INTR是Vitis根据你的Block Design自动生成的宏如果不叫这个名字去xparameters.h里搜INTR即可。中断服务函数里不要做耗时的数据搬移应该先把PL写的状态寄存器和结果地址读出来然后清掉IP里的中断标志位最后用信号量唤醒应用层任务。PL侧中断信号建议用“电平保持”而不是“单周期脉冲”。这看起来是小事但能避免第五章里说的丢中断问题。5. 避坑自定义AXI-FULL IP调试里常见的5个坎5.1 读回全F多半是挂错口现象PS端mmap成功但读BRAM前面一堆地址全是0xFFFFFFFF写进去再读还是全F。原因自定义IP的S00_AXI被接到了PS的M_AXI_GP0口而不是S_AXI_HP0。GP口虽然也能走AXI协议但它数据位宽和缓冲深度都有限而且如果你的IP被默认分配到了GP口的外设地址空间PS要真正访问PL里的BRAM时总线地址解析会出问题。解决回到Block Design把自定义IP的S_AXI接口改到PS的S_AXI_HP0或HP1。分配地址时确认Address Editor里这一段的master接口是S_AXI_HP0而不是M_AXI_GP0。重新生成bitstream后先跑单word测试再跑burst测试。5.2 大包连续读写会卡死先看burst边界现象小包8字节、64字节都没问题搬运4KB以上时程序卡在某一次写循环Linux报External abort或者直接看门狗复位。原因AXI-FULL的burst事务不允许跨4KB地址边界也不能超过从端支持的突发长度。如果IP内部手写了状态机AWLEN处理不对最后一个beat没有正确产生WLAST主设备会一直等写响应。如果用Xilinx AXI BRAM Controller最常见原因是BRAM深度小于总线地址范围PS写到了BRAM空间外从端回了SLVERR。解决先确认IP的最大burst长度设成16或者32。PS侧测试时可以每次只搬一小段比如512字节然后多次循环。更关键的是映射范围要和BRAM实际深度一致BRAM只有16KB就映射16KB别贪心映射64KB。如果确实需要大容量直接上DDR别用BRAM熬。5.3 同一个IP换Vivado版本就乱IP纯净度不足现象自定义IP在Vivado 2022.2里综合没问题放到2023.1里打开Block DesignS00_AXI端口要么少了几个信号要么连不上总线。原因这是典型的IP纯净度问题。Vivado打包IP时接口信息写在component.xml里。很多人改RTL时直接手动加了信号或者在顶层改动了端口名却忘了在IP Packager界面里同步更新Ports and Interfaces导致RTL实际端口和XML描述不一致。换版本时Vivado重新解析XML自然就乱套了。解决每次改完RTL不要只保存文件回到Edit in IP Packager的Ports and Interfaces页把新增端口归到正确的AXI interface下确认每个信号都有interface归属。打包前跑一次Report IP Status确保没有红色warning。这个习惯能把一批莫名其妙的跨版本问题直接消灭。5.4 中断丢事件边沿脉冲不可靠现象PL算法处理完用单周期脉冲拉高中断信号通知PS。实测下来PS中断服务程序大概每十次会漏掉两三次。原因ZYNQ的PL到PS中断控制器对脉冲的采样不一定可靠尤其是PL侧时钟比较慢、CPU侧频率很高时单周期脉冲很容易落在CPU时钟采样的盲区里。这类问题在FPGA调试里非常隐蔽看起来像是中断配置错了实际是信号形式选错。解决在自定义IP里加一个中断状态寄存器。PL算法完成时先把结果数量写到BRAM或寄存器再拉高状态寄存器的bit中断输出保持高电平。PS中断处理程序读到状态后向清除寄存器写1才会拉低中断。这样把“事件”转换成了“电平”中断一次都不会丢。这个设计我后来在好几个项目里都当成标准模板用了。5.5 读回来的数据是旧的注意Cache一致性现象PS先往BRAM写数据PL算法修改了同一块BRAMPS第二次读回来的仍然是之前写入的旧数据。把程序重启一遍再运行数据就正常了。原因PS访问HP口设备地址时Cortex-A9的L1/L2 Cache可能把这整片地址看成可缓存区域。PL侧修改BRAM是发生在FPGA内部的不会主动去失效PS的Cache所以CPU读回来的是Cache里的旧内容。解决在Linux下使用mmap映射PL地址时不能保证非Cache属性。最稳妥的手段是写一个内核驱动用ioremap_nocache把物理地址映射成不可缓存。裸机环境里每次读BRAM前调用Xil_DCacheInvalidateRange。如果是PetaLinux做产品建议直接把数据通路改成DMAPS和PL的首选交互内存放在PS侧DDR里PL用AXI DMA或VDMA把数据搬过去PS只读DDRCache一致性问题整体绕开。这个思路在“zynq ps读写pl外挂ddr”的场景里同样适用。6. 最后一步用System ILA验证AXI-FULL事务再压一压带宽自检代码跑通以后不要着急接应用逻辑。我建议先在Block Design里给自定义IP的S00_AXI加一个System ILA把AWVALID、WVALID、BVALID、ARVALID、RVALID这五个关键握手信号抓出来触发条件设为AWVALID AWREADY采集深度设4096。重新生成比特流后跑一次PS端大包自检。你会看到写通道、写响应通道、读通道的时序关系WLAST是否在每个burst最后拉高总线是否出现了反压等待。这一步能筛掉绝大多数总线适配问题。性能调优有三个优先级比较高的位置数据位宽从32改成64AXI时钟尽可能让PS的FCLK_CLK0跑在100MHz以上在自定义IP的AXI接口通路里加一个AXI Register Slice。Register Slice看起来是多余延迟但能打断过长的组合逻辑路径对布局布线反而友好吞吐会更稳定。调优后可以统计一下实际吞吐time ./bram_bench 4096 1000程序内部按顺序读写BRAM并统计总字节数最终算出一个读加写的平均速度。在我调过的ZYNQ-7020工程里32位数据总线100MHz时钟实测大约260MB/s改成64位以后能到500MB/s左右。再往上推瓶颈就不是AXI总线而是BRAM端口数量了这时候想把PL侧的数据缓冲从BRAM换到DDR3。我现在的新习惯是任何PS和PL总线工程都要先写一个最小自检函数再用ILA录一遍总线跑通小包、跑通大包、最后才接算法。这套流程让我少熬了很多夜也希望帮到你。本文还有配套的精品资源点击获取