ZYNQ PL+PS+网口高速数据传输链路详解
2026/9/9 20:57:01 网站建设 项目流程

简介:面向正在学习FPGA与嵌入式网络开发的工程师和学生,内容围绕ZYNQ平台中可编程逻辑(PL)与处理器系统(PS)之间的通信,以及通过以太网接口采用TCP协议向上位机传输数据的完整实现方案展开。从ZYNQ的整体架构入手,介绍了AXI总线不同标准在简单读写、存储映射和高速数据流场景中的选择与设计,并详细说明了PL中以太网MAC接口的搭建、与PS端轻量级网络协议栈(LWIP)配合使用时的DMA数据交换方式,以及TCP连接管理与用户程序编写的要点。同时针对工程中容易遇到的实时性、带宽和功耗问题,给出了合理划分任务、降低延迟、添加硬件加速模块等优化思路,并补充了逻辑仿真、硬件在环测试和网络抓包分析等方法,便于开发者逐步验证从硬件逻辑到上层应用的整条通信链路。资源包约56.6MB,未提供具体文件明细,但整套内容对从架构设计到工程落地的关键路径有系统梳理。目前已有超过一万人学习下载,可直接用于指导个人项目或课程设计。 做ZYNQ开发的人多半都经历过这么一段:PL端费了半天劲把数据采回来了,结果怎么送给上位机成了新的麻烦。用串口吧,速率不够,数据一多就丢;用USB芯片吧,又多一颗料、多一堆驱动问题。后来把目光放在ZYNQ自带的PS端以太网口上,才发现这条路才是正解——PL负责采集和预处理,PS跑Linux系统,直接走TCP/IP协议栈把数据推到上位机,整个链路干净又高效。

这篇文章我就把自己实际跑通的方案完整拆一遍:PL和PS之间怎么通信、DMA怎么配、PS端的TCP服务器怎么写、以及最容易被坑的缓存一致性和粘包问题。适合已经会用Vivado建工程、但对PS端Linux开发还不太熟的工程师。我会把关键步骤、代码片段和调试思路都放出来,照着做基本能跑通。

1. 为什么非要用“PL+PS+网口”这条链路

很多初学者会问一个问题:既然ZYNQ的PS端本身有ARM核,为什么还要PL端参与?直接写个Linux程序跑TCP不就完了?

道理很简单:PL端存在的意义是搞定ARM核搞不定的东西。比如高速ADC的采样数据、Camera Sensor的MIPI信号、自定义的编码协议、需要纳秒级时序控制的逻辑——这些要么速率太高,要么时序要求太严,用ARM的GPIO去翻转根本不现实。PL先把这些数据接住,做一些预处理(比如滤波、拼接、降采样),然后交给PS去传输。

那为什么TCP这件“杂活”要放在PS端,而不是用PL的MAC核做?

我见过有人尝试在纯FPGA里实现TCP/IP协议栈,比如用LWIP的纯硬件版本,或者OpenTCP之类的IP核。不是说不行,但代价很高:每加一个功能都要重新综合、布局布线,调一个bug的迭代周期按小时算。而PS端跑Linux的话,TCP/IP协议栈是内核现成的,你只需要写一个socket程序就完事。ZYNQ的PS端有ARM Cortex-A9双核,跑个TCP服务毫无压力,哪怕千兆网口线速转发,只要不是极端性能要求,Linux协议栈完全扛得住。

所以这条链路的本质是:PL做“手”,PS做“脑”,网口做“嘴”。各干各擅长的事,开发效率成倍提升。

2. 硬件通路搭建:AXI接口选型与DMA的取舍

PL和PS之间怎么交换数据,这是整条链路的地基。ZYNQ里PS端给PL端开放了好几组AXI接口,主要分两类:AXI GP口和AXI HP口。选错了,后面的性能会被卡死。

2.1 AXI GP和AXI HP到底差在哪

AXI GP口(General Purpose)有两组:M_AXI_GP0和M_AXI_GP1,数据位宽32位,理论带宽大约600MB/s。听着还行对吧?但GP口没有经过DDR控制器,延迟高、带宽受限于AXI协议本身的效率,实际跑数据流传输的时候很容易成为瓶颈。它适合干什么?适合做寄存器级别的控制操作,比如PS往PL写一个启动命令字、读一个状态位。

AXI HP口(High Performance)是真正的数据通路。它有四个口,数据位宽64位,直接连接到PS端的DDR控制器,意味着PL端可以通过HP口直接读写DDR内存,带宽能达到数GB/s级别。大数据量的图像、采样数据搬运,一定要走HP口。

我做这个项目时,DMA的MM2S(内存映射到流)和S2MM(流到内存映射)通道就是挂在HP口上的。别把它挂在GP口上,血泪教训——之前在GP口上试过,DMA突发传输一大包数据时,直接把GP口的带宽吃满,CPU这边连中断响应都变慢了。

2.2 要不要上DMA

如果你要传的数据量不大,比如几百字节的控制指令、状态反馈,那完全不用DMA。PS直接通过AXI总线读写PL端寄存器就行,简单粗暴可靠。PL端设计一组寄存器,PS用mmap或者/dev/mem直接访问物理地址,来回来去读写即可。

但一旦涉及批量数据——比如我这里是ADC采集的连续波形数据,一秒钟几百KB甚至几MB——就必须上DMA。用DMA的核心逻辑是:PL把数据通过S2MM通道直接写进DDR的指定缓冲区,写完一个缓冲区发出一次中断,PS端的驱动收到中断后通知应用层去读这包数据。全程CPU不参与数据搬运,把ARM核从“苦力活”里解放出来。

DMA配置里有几个关键的:

  • 数据位宽:和HP口一致,选64位
  • Burst长度(突发长度):一般选16或者32,选太大容易占总线太久,选太小又降低效率
  • 地址对齐:DMA传输要求缓冲区地址对齐到32字节,这个后面调试时特别容易踩坑

2.3 PL到PS的中断路径

DMA每次搬运完成怎么通知PS?靠中断。ZYNQ里PL到PS的中断通路是IRQ_F2P,一共能映射8个中断源。在Vivado的Block Design里,把DMA的中断输出接到一个Concat IP核,然后连到PS的IRQ_F2P端口。设备树里配置中断号的时候要注意,IRQ_F2P对应的是SPI中断号61到68(8个),具体用哪个要看你接的Concat第几个输入。

我在设备树里是这样配的:

axi_dma_0: dma@40400000 { compatible = "xlnx,axi-dma-1.00.a"; reg = <0x40400000 0x10000>; interrupts = <0 29 4>; /* 这里29对应IRQ_F2P第1个 */ interrupt-parent = <&intc>; dma-channels = <1>; };

这个中断号的换算逻辑是:GPIO的SPI中断号68从1开始数,61对应第一个IRQ_F2P。29这个值是设备树里SPI编号的表示方式,实际等于61-32。这个换算关系每次都要翻手册,建议直接记下来。

3. PL端工程搭建:从Block Design到自定义IP核

硬件通路选定了,接下来就是在Vivado里把工程搭起来。

3.1 Block Design的关键连线

新建一个ZYNQ处理系统,然后在Diagram里做这么几件事:

  • 打开PS的配置界面,在Peripheral IO里使能UART1(调试打印用)、使能网口(这里选的是千兆网口,RGMII接口)、使能SDIO(启动Linux用)
  • 把HP接口的S_AXI_HP0使能,DMA就是通过这个口访问DDR
  • 添加AXI DMA IP核,配置成读写都使能(Enable Read Channel和Enable Write Channel都勾上)
  • 添加AXI SmartConnect,把DMA的M_AXI接口连到SmartConnect,再连到PS的HP口
  • DMA的S_AXI_LITE接口(配置寄存器)连到PS的M_AXI_GP0,这样PS的CPU可以配置DMA
  • DMA的中断输出连Concat,Concat输出连IRQ_F2P

这里有个容易忽略的点:DMA的S_AXI_LITE和M_AXI接的总线频率可能不一样,Vivado会自动插入CDC(跨时钟域)处理,但你要确认SmartConnect里每个接口的时钟都正确连接了,不然上板子以后寄存器读写就是乱的。

3.2 自定义IP核的寄存器设计

DMA负责搬运数据,PL端还得有逻辑产生数据、响应控制。我一般会写一个自定义IP核,用Vivado的Create and Package New IP功能生成一个带AXI-Lite从接口的模板。这个IP核的寄存器我通常这样规划:

偏移地址寄存器名方向功能说明
0x00CTRL_REG启动/停止采集,bit0=1启动
0x04STATUS_REGbit0=数据就绪,bit1=FIFO满
0x08DATA_LEN当前数据包长度
0x0CSAMPLE_RATE采样率分频系数

这个自定义IP核的内部逻辑不复杂,但有两个点要注意:

第一,跨时钟域处理。PL端采样逻辑的工作时钟和AXI总线的时钟不一定同源。我习惯在IP核内部加一个异步FIFO,采样数据先写进FIFO,DMA读空FIFO。FIFO的深度要根据数据速率和DMA响应时间算,我一般留至少2-4倍余量,不然突发流量进来FIFO直接溢出丢数据。

第二,数据拼接对齐。如果你的ADC是12位或者14位的,而DMA数据位宽是64位(8字节),那每一拍DMA读取对应FIFO输出的8个字节。你得在PL端把采样数据补齐成整字节,不然PS端解析数据包时位对齐是个噩梦。我通常的做法是把多通道数据打包成32位一个字,两个通道拼一路,保证DMA侧看到的数据是规整的。

3.3 上板前必做的仿真

在PL端的逻辑正式投入使用前,一定要做行为仿真。这一步省不得。主要仿真两个场景:

  • 自定义IP核的寄存器读写是否正常,AXI-Lite时序对不对
  • FIFO读写吞吐是否匹配,给FIFO灌入满速数据,观察fifo_prog_full信号触发后,读侧是否来得及把数据排空

我踩过最大的坑就是仿真看波形的时钟沿选错了,导致FIFO读写时序明明反了但仿真没发现,上板以后数据全是乱的,排查了整整一天才发现是IP核内部FIFO读时钟极性不对。仿真时可以故意把读写使能信号用#10延时错开,看逻辑还能不能正确握手。

4. PS端Linux环境:驱动准备与TCP服务端设计

硬件工程生成比特流之后,导出硬件描述文件(XSA),接下来的重头戏是PS端的软件。

4.1 PetaLinux工程的快速搭建

我用的是PetaLinux,版本和Vivado保持同一个年份版本,比如Vivado 2023.1就配PetaLinux 2023.1,版本不一致会出一堆诡异报错。流程大致是:

petalinux-create --type project --template zynq --name tcp_server petalinux-config --get-hw-description=./xsa

进入配置界面后,重点检查两处:

  • Image Packaging Configuration -> Root filesystem type,选EXT4或者INITRAMFS。调试阶段建议用INITRAMFS,打包快,改应用代码后重新打包也快;正式部署再换EXT4挂SD卡
  • 确认以太网驱动已经编进内核,ZYNQ的macb驱动默认是开着的

设备树这一层,把DMA节点、中断号配好。如果直接用PetaLinux生成的设备树,DMA节点可能存在,但中断号可能不是你实际接线对应的那个,务必手工核对。

4.2 Linux下访问DMA缓冲区的思路

应用层没法直接操作物理地址,所以访问DMA缓冲区有几种方式:

  • UIO框架:把DMA的寄存器空间和缓冲区物理地址暴露给用户态,应用层通过mmap访问,省去写内核驱动的麻烦,适合调试阶段
  • 内核字符设备驱动:正规做法,在驱动里申请DMA缓冲区(用dma_alloc_coherent),实现read/write和mmap接口,把数据从DMA缓冲区拷贝到用户空间
  • 直接/dev/mem:最粗暴,只建议快速验证硬件通路是否正常时用

我调试这个项目时先走的UIO,确认DMA搬运正常、中断能触发,再写正式的字符设备驱动。分两步走,隔离问题域,调试效率最高。

用UIO时有一个关键点:DMA缓冲区使用的物理地址要在设备树里通过reg属性指定,这样应用层才知道mmap哪个物理地址段。比如我在设备树里给DMA保留了一块从0x3F000000开始的1MB内存:

reserved-memory { #address-cells = <1>; #size-cells = <1>; ranges; dma_buf@3f000000 { compatible = "shared-dma-pool"; reg = <0x3f000000 0x100000>; no-map; }; };

应用层mmap这块物理地址后,DMA从PL端搬过来的数据就落在mmap得到的虚拟地址上,直接解析就行。

4.3 TCP服务器端的两个关键设计

TCP服务器用标准的Linux socket就行,我倾向于用select模型。为什么要用select而不是简单单线程阻塞收数据?因为我们需要同时监听两件事:客户端连接请求和数据到达。用select可以一个线程搞定,省去多线程的同步麻烦。

核心片段大概是这样:

int sock_fd, client_fd; struct sockaddr_in server_addr; fd_set read_fds; int max_fd; sock_fd = socket(AF_INET, SOCK_STREAM, 0); server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = htonl(INADDR_ANY); server_addr.sin_port = htons(5000); bind(sock_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)); listen(sock_fd, 5); while (1) { FD_ZERO(&read_fds); FD_SET(sock_fd, &read_fds); FD_SET(dma_fd, &read_fds); // dma_fd是对DMA设备文件的描述符 max_fd = sock_fd > dma_fd ? sock_fd : dma_fd; ret = select(max_fd + 1, &read_fds, NULL, NULL, NULL); if (FD_ISSET(sock_fd, &read_fds)) { client_fd = accept(sock_fd, NULL, NULL); } if (FD_ISSET(dma_fd, &read_fds)) { len = read(dma_fd, buf, BUF_SIZE); send(client_fd, buf, len, 0); } }

第二个关键设计:TCP_NODELAY选项。默认情况下TCP启用Nagle算法,发送端会把小数据包合并成大数据包再发出去,这对交互式应用是好事,但对我们这种连续数据流传输是灾难——数据会被延迟几毫秒到几十毫秒才发出去,上位机看到的实时性就差了很多。在send之前设置:

int flag = 1; setsockopt(client_fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));

实测下来,开启TCP_NODELAY后,网络延迟能降一个量级以上。

4.4 协议格式怎么定

TCP是流式传输协议,它只保证字节顺序,不保证消息边界。也就是说,你在应用层send了一个1MB的数据包,上位机recv的时候可能分了好几次才收完,也可能一次性收了你两次send的数据。这就叫“粘包/半包问题”。

解决办法就是在应用层自定义数据帧协议。我自己用的是非常经典的帧格式:

字段长度说明
帧头2字节固定0xAA55
数据长度4字节小端格式,表示载荷字节数
载荷数据N字节原始数据
CRC324字节对载荷的校验值

上位机收到数据后,先找帧头,再读长度字段,按长度收满整包后校验CRC。这样可以100%保证数据完整性。别嫌加校验麻烦,网络传输的位翻转在实验室环境不常见,但一旦你的设备到了工厂现场,电磁环境复杂,没有CRC你根本定位不了数据的偶发错误。

5. 调试链路全记录:五个必查的故障点

整个项目从搭建到跑通,我大概花了三周,其中真正写代码的时间只占三分之一,剩下全在调试。把印象最深的几个故障点整理出来,你们遇到类似现象可以直接按顺序排查。

5.1 现象:DMA中断一直不触发

先确认PL端DMA配置寄存器有没有写进去。在Linux里用devmem直接读DMA的MM2S_DMACR寄存器,确认cyclic模式或者DMA模式设置正确。如果寄存器读写本身就有问题,优先检查AXI SmartConnect的时钟和复位,很多都是时钟没给对导致总线无法握手。

如果寄存器没错,再看中断号映射。我第一次做的时候设备树里中断号配的就是别人的工程抄来的,结果IRQ_F2P实际接的是第二个输入,中断号差了1,中断怎么都不触发。查这种问题最快的办法是:PL端用ILA抓一下Concat输出到IRQ_F2P的线有没有脉冲,再用cat /proc/interrupts看有没有对应的中断计数。两层一夹,问题就能定位到具体在哪一侧。

5.2 现象:数据能搬,但上位机收到的全是错位数据

这是缓存一致性问题,或者DMA缓冲区地址对齐问题。如果用的是devmem那块物理内存,而内核中有别的驱动也把这个区域当普通内存用,DDR缓存和DMA之间的数据一致性就会出问题。解决的办法就是用前面说过的reserved-memory把这块内存预留出来,不要让内核把它分配给其他用途。

另一个对齐问题:AXI DMA要求缓冲区物理地址32字节对齐。你如果用kmalloc默认分配的内存,对齐可能只有8字节或者16字节,DMA就会出现不可预期的数据错位。用dma_alloc_coherent申请内存能保证对齐和一致性,这也是我建议走内核驱动而不用/dev/mem的原因。

5.3 现象:UDP通、TCP不通

我在项目后期想把传输协议换成TCP时,碰到过一个很怪的现象:UDP数据能正常收到,TCP就是连不上。查了一圈发现,问题出在ZYNQ的千兆网口PHY芯片上——某些PHY的RGMII时序配置在上电后需要软件延时等待,Linux驱动加载得太快,PHY还没完成自协商,导致链路状态没起来。

解决办法是在设备树里给macb节点增加local-mac-address属性和phy-mode = "rgmii-id",让驱动等待PHY稳定。或者干脆在PetaLinux里把PHY的复位GPIO延时加到100ms以上。纯软件层面,可以用mii-tool或者ethtool查看链路状态确认。

5.4 现象:DMA传完一包后第二包就死掉

这通常是DMA的tail descriptor没正确更新导致的。DMA传输靠描述符链表驱动,如果驱动侧在中断处理里正确地更新了tail descriptor指针,DMA就会停下来等新的描述符。我在驱动里处理时,每次中断完成后都要重新写一次tail descriptor地址,并且在写之前先保证DMA停在idle状态,不然会出现驱动和DMA状态不一致。

一个更隐蔽的坑是,DMA的S2MM通道在完成一次传输后,如果有残留的包没有及时清空FIFO,下一包数据会被覆盖或丢失。PL端要设计一个“一包数据结束后自动重置FIFO读指针”的逻辑,或者在驱动里每次中断后先读一次状态寄存器,确认DMA的buffer length真的归零再启动下一次传输。

5.5 现象:上位机显示数据速度远低于实际采样率

网络传输瓶颈一般不在TCP协议栈,而在应用层到网络协议的拷贝次数。如果你在驱动里把数据从DMA缓冲区拷贝到用户态buffer,再在应用层新建一个buffer调用send,数据就经历两次拷贝,加上TCP协议栈还要再拷贝一次,三次下来CPU占用率直接拉满。

优化方式是使用sendfile或者splice系统调用,把数据从设备文件直接发送到socket,减少用户态拷贝。实测这个优化让吞吐量提升了接近一倍。如果还不够,就在驱动里用更大的DMA缓冲区,争取一次中断发送更多数据,减少中断处理的次数。

6. 性能极限与扩展思路:这块板子还能榨出多少

跑通一条链路只是开始。干这行的人都知道,真正考验功力的是把性能榨到极限。

按我目前这套配置,PL端DMA通过HP口往DDR写数据,实测能跑到大约1.2GB/s的带宽,但这是DDR侧的极限。TCP协议栈这边就要现实多了:千兆网口的理论线速是125MB/s,刨去TCP/IP头、帧间隔、ACK回包,实际应用层的吞吐量能做到90-100MB/s已经相当好看。CPU占用率在双核A9下大约占了一核左右,另一核还能跑点轻量的控制逻辑。

如果你要传的是视频数据,带宽要求翻倍,那就得上VDMA(Video DMA)配合帧缓冲管理。PL端把视频帧写入DDR的帧缓冲,PS端通过VDMA的帧完成中断来调度,把帧数据通过TCP推给上位机。这套方案的CPU占用率更低,因为VDMA可以配置帧中断,应用层只需要处理整帧的事件,不需要关心像素级别的数据搬运。

另一个值得尝试的方向是改用零拷贝传输。具体来说,PL端要发送的帧直接在DDR里拼好,应用层通过环形缓冲区管理帧索引,不等DMA中断把数据搬完,只要帧完整写入就通知socket发送。这里的核心是“以帧为单位的流控”,用双缓冲甚至三缓冲把发送流程流水线化,能把吞吐再往上提一档。

7. 一点个人体会

整个项目做下来,最大的感触是:ZYNQ这种异构SoC的设计,难点从来不在某一端的逻辑有多复杂,而在于PL和PS之间的“握手”是否高效可靠。很多人一上来就急着写代码,结果底板没调通就堆应用,出了问题都不知道哪一环是源头。

我自己踩过的坑里,最不值得的是设备树中断号配错那次——花了一整天,最后就是数字差1。从那以后我给自己定了个规矩:每次从XSA生成PetaLinux工程后,第一件事就是打开设备树dtsi文件,把中断号、寄存器地址、DMA通道编号全部手工核对一遍,十分钟的事,能省掉两天的痛苦。

最后再分享一个小技巧:调试DMA中断和TCP传输链路时,在上位机用Wireshark抓包,同时板上用devmem实时导出DMA的状态寄存器,两边时间戳对着看,哪一侧延迟大、哪一侧丢包,一目了然。这种“两头夹逼”的调试方式,比闷头改代码高效得多。

本文还有配套的精品资源,点击获取

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

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

立即咨询