简介:面向FPGA嵌入式开发者的Zynq-7000 PS部分Ethernet驱动资源,聚焦TCP与UDP客户端实现。基于Vivado 18.2,含project05_ps_Ethernet工程与udpclient、tcpclient源码,覆盖AXI总线连接、LwIP套接字编程、硬件中断及DMA搬运等环节,适合PS端网络通信开发。压缩包rar格式,29.83MB,共2000个文件,以C/C++源码(h、c)、编译产物(o、elf、so)、makefile及Vivado工程配置(xpr、xci、xml、tcl)为主,并含ps7_init等硬件初始化文件。已有5805人学习;通过工程可掌握MAC/IP配置、丢包超时处理、软硬件协同设计等要点,降低Zynq网络应用开发门槛。
1. 当Zynq-7000的PS端Ethernet卡在"能link不能传":TCP/UDP客户端驱动怎么落地
很多人第一次用Zynq-7000做板卡通信时,会把大量精力放在PL侧的逻辑设计上,结果到了PS端Ethernet这一环,被TCP客户端连不上服务器、UDP发了十条不见一条回包这类问题卡住一两周。核心原因不是PHY坏了,而是驱动链路里某个状态没对上。这篇笔记把PS端Ethernet从GEM控制器、MDIO/PHY管理、DMA描述符到lwIP协议栈的TCP客户端和UDP客户端实现完整拆开,直接给可抄的初始化和收发代码,并把典型坑位标出来。适合正在做Zynq-7000板卡网络通信、第一次碰PS端驱动的开发者。
2. 驱动栈与协议栈选型:先分清GEM、PHY、lwIP三件事
2.1 数据通路:从MIO到DMA描述符
Zynq-7000的PS端网络收发不是一条"寄存器读一下就有数据"的简单路径。数据必须经过外部PHY、RGMII接口、PS端GEM控制器,再由DMA描述符环搬运到DDR内存,最后交给lwIP协议栈处理。这里每一段都有独立的状态机,任何一个环节没配对都会表现为"link灯亮,数据不通"。
GEM负责MAC层和DMA传输,PHY负责物理层信号编解码,MDIO管理接口用来读写PHY内部寄存器。MDIO引脚不是独立的物理引脚,它复用在MIO上,MIO引脚分配决定了MDIO访问路径。PHY地址则由板卡上的上下拉电阻决定,常见值是0x01或0x00。不同PHY对MDIO时钟有要求,多数PHY不允许超过2.5MHz,APB总线时钟到MDIO需要做分频,分频系数通常取26到29之间。DMA描述符在DDR中组成环形队列,GEM收到帧后根据描述符里的地址把数据写入内存并更新状态位;发送方向则反过来,驱动把数据放好,更新描述符,GEM自动读走。
注意:如果MDIO分频系数不对,PHY_ID寄存器读出来是全F或全0,这时别急着怀疑PHY芯片,先查MIO配置和分频参数。
接收路径里,驱动侧的"收到一帧"不是中断里直接读数据,而是轮询描述符状态。中断只是通知CPU"描述符环有更新",数据已经在DDR里了。理解这一点,后面调试DMA描述符数量不足导致丢包时,思路会清晰很多。
2.2 裸机BSP与lwIP的结合方式
Zynq-7000的PS端Ethernet在裸机环境下的标准做法是使用SDK生成的BSP,BSP里会集成lwIP协议栈和xemacps驱动。SDK生成BSP时如果勾选了lwIP,会自动把网卡驱动和协议栈绑定好,应用层不用关心网卡驱动内部细节。这里有一个关键选择:用Raw API还是Socket API。
Raw API是回调驱动的,lwIP把TCP状态机的事件映射成回调函数,应用层在回调里处理数据、错误、轮询,没有线程阻塞的概念,适合裸机和资源紧张的场景。Socket API提供类似BSD socket的接口,但它依赖操作系统提供的信号量和线程机制,通常在带FreeRTOS或RTOS的环境下使用。同一个BSP里两种API可以编译,但裸机工程建议直接用Raw API,减少一层封装。
回调驱动意味着整个协议栈的事件处理都在中断上下文或主循环里完成。如果ISR里处理时间过长,DMA描述符环可能被新帧占满,这是高负载丢包的常见根因。所以lwIP在裸机下通常配合一个大循环调用lwip_pkt_req或定时轮询,让协议栈有机会及时清理已接收的帧。
2.3 选型清单:TCP客户端和UDP客户端分别走哪条路
| 使用场景 | 协议栈方式 | 说明 |
|---|---|---|
| 裸机 + TCP 客户端,帧率不高 | Raw API | 内存占用小,回调直接处理连接和数据,最常用 |
| 裸机 + UDP 客户端,周期性上报 | Raw API | UDP没有连接状态,初始化更简单,回调里处理收包 |
| FreeRTOS + 多任务 | Socket API | 每个socket独立阻塞,代码直观,但多一层调度开销 |
| Linux 内核 | 内核网络栈 | 和裸机完全是两套体系,驱动模型不同,这里不展开 |
选型时先确认有没有RTOS。没有RTOS就死心用Raw API,不要试图在裸机里模拟socket的阻塞语义。我见过有人在裸机里自己实现消息队列去贴合Socket API,结果内存越吃越多,最后连TCP连接都建立不了。
3. TCP客户端实现:初始化、连接、收发与断线重连
3.1 初始化链路:BSP参数表和emacps驱动检查点
TCP客户端的基石是先把PS端网卡驱动初始化到位。BSP生成后,网卡驱动的设备ID、基地址、描述符数量都在xparameters.h里以宏的形式存在。实际代码里一般先查配置,再做初始化。
#include "xemacps.h" #include "xparameters.h" static XEmacPs EmacPsInstance; static int eth_ps_init(void) { XEmacPs_Config *cfg = XEmacPs_LookupConfig(XPAR_XEMACPS_0_DEVICE_ID); if (cfg == NULL) { return XST_FAILURE; } int status = XEmacPs_CfgInitialize(&EmacPsInstance, cfg, cfg->BaseAddress); if (status != XST_SUCCESS) { return status; } /* 请根据板卡原理图确认PHY地址,常见为0x01或0x00 */ XEmacPs_SetPhyAddress(&EmacPsInstance, 0x01); return XST_SUCCESS; }XEmacPs_LookupConfig从BSP的配置表里按设备ID取出网卡配置,CfgInitialize完成MAC控制器内部寄存器初始化。SetPhyAddress这一步很容易被忽略,它决定后续MDIO访问哪个PHY。如果PHY地址和实际硬件不对应,后面所有读PHY状态的操作都是空转。
初始化完成后建议读一次PHY的ID寄存器(寄存器0x02和0x03),如果能读到正确ID,说明MDIO通路没问题。这个检查点在调试时价值很大——以后遇到"link断断续续"的问题,第一步就去查PHY_ID寄存器,能读到就排除MDIO物理问题。
3.2 连接建立:tcp_new、tcp_connect与回调函数签名
TCP客户端在lwIP里的流程是:创建PCB(协议控制块),调用tcp_connect发起连接,连接成功后由connected回调接管后续事件。Raw API里几乎所有事件都靠回调,回调注册时机很关键——必须在connected回调里注册recv、sent、err、poll,不能在tcp_connect之前注册recv回调,否则连接还没建立,数据回调就绪状态是乱的。
static struct tcp_pcb *tcp_client_pcb; static ip_addr_t server_ip; static err_t tcp_connected_cb(void *arg, struct tcp_pcb *tcb, err_t err) { if (err == ERR_OK) { tcp_recv(tcb, tcp_recv_cb); tcp_sent(tcb, tcp_sent_cb); tcp_err(tcb, tcp_err_cb); tcp_poll(tcb, tcp_poll_cb, 2); } return ERR_OK; } static void tcp_client_start(u8_t *ip, u16_t port) { IP4_ADDR(&server_ip, ip[0], ip[1], ip[2], ip[3]); tcp_client_pcb = tcp_new(); if (tcp_client_pcb == NULL) { return; } tcp_connect(tcp_client_pcb, &server_ip, port, tcp_connected_cb); }IP4_ADDR把四个字节拼成lwIP内部的ip_addr_t结构。tcp_connect是异步操作,返回值只表示参数是否合法,真正的连接结果在connected回调的err参数里。tcp_poll的第二个参数是轮询间隔,单位是TCP粗粒度超时值(约500ms),设2表示每约1秒调用一次poll回调,常用于断线检测和重连触发。
3.3 数据收发与断线处理:缓冲管理和重连参数
连接建立后,发送数据走tcp_write加tcp_output组合。tcp_write只是把数据放入发送队列,tcp_output才真正触发驱动发包。tcp_write返回ERR_OK不代表数据已经到对端,只表示进入了协议栈队列。
static err_t tcp_sent_cb(void *arg, struct tcp_pcb *tcb, u16_t len) { /* len表示对端ACK确认的字节数,可作为业务层发送窗口 */ return ERR_OK; } static err_t tcp_recv_cb(void *arg, struct tcp_pcb *tcb, struct pbuf *p, err_t err) { if (p != NULL) { /* 从p->payload读取数据,注意pbuf可能是链式的 */ tcp_recved(tcb, p->tot_len); pbuf_free(p); } else if (err == ERR_OK) { /* 对端主动关闭连接 */ tcp_close(tcb); tcp_client_pcb = NULL; } return ERR_OK; }关于bc接收,有一个细节要处理对:tcp_recved必须在pbuf_free之前调用,因为tcp_recved会更新接收窗口,而pbuf释放是回收内存,顺序反了可能导致窗口更新滞后,触发对端降低发送速率。recv_cb的p为空表示对端FIN已到,要区分是关闭还是错误。
断线重连的坑在TIME_WAIT状态。TCP关闭后PCB不会立刻释放,如果直接在err回调里重新tcp_connect,可能因为旧PCB还在半关闭状态而失败。常见做法是先tcp_abort强制释放,再重新走tcp_client_start流程,而不是tcp_close。tcp_close走的是正常关闭流程,需要等对端ACK,周期太长。
static void tcp_err_cb(void *arg, err_t err) { if (tcp_client_pcb != NULL) { tcp_abort(tcp_client_pcb); tcp_client_pcb = NULL; } /* 建议延时2~3秒后重新调用 tcp_client_start */ }重连间隔太短会让服务器那边的连接表积累大量半开连接,一般同一台服务器建议最少延时2秒。可以在poll回调里维护一个状态机,把重连逻辑放进poll事件里统一处理,避免在err回调里直接做复杂操作。
4. UDP客户端实现:无连接但要有"边界感"
4.1 UDP初始化路径:和TCP客户端差在哪
UDP客户端初始化比TCP简单得多,不需要连接建立,不需要重连状态机。核心是把udp_pcb创建出来,绑定一个本地端口(可以绑定0表示由协议栈自动分配),然后注册接收回调。
static struct udp_pcb *udp_pcb; static void udp_recv_cb(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { if (p == NULL) { return; } /* p->payload指向数据起点,p->len是本pbuf的数据长度 */ /* 如果数据可能跨pbuf,要遍历整个pbuf链 */ pbuf_free(p); } static void udp_client_init(u16_t bind_port) { udp_pcb = udp_new(); if (udp_pcb == NULL) { return; } udp_bind(udp_pcb, IP_ADDR_ANY, bind_port); udp_recv(udp_pcb, udp_recv_cb, NULL); }udp_bind的端口参数很实用。绑定一个固定端口后,对端可以把该端口加进白名单做过滤,同时方便抓包分析。bind到0则是完全随机端口,每次启动都变,调试时抓包没法按端口过滤,建议开发阶段都绑定固定端口。
4.2 pbuf的分配、发送与释放:UDP最容易漏的地方
UDP发送需要自己分配pbuf,填数据,再调用udp_sendto发送,最后释放pbuf。pbuf有三种类型,PBUF_RAM是连续内存块,适合较大的数据包;PBUF_POOL是链式小缓冲池,分配快,适合小包高频发送。UDP客户端上报场景建议用PBUF_POOL,碎片化程度低。
void udp_client_send(u8_t *data, u16_t len, u8_t *ip, u16_t port) { struct pbuf *p = pbuf_alloc(PBUF_TRANSPORT, len, PBUF_POOL); if (p == NULL) { return; } memcpy(p->payload, data, len); ip_addr_t dst; IP4_ADDR(&dst, ip[0], ip[1], ip[2], ip[3]); err_t err = udp_sendto(udp_pcb, p, &dst, port); /* 无论发送成功与否,pbuf都必须释放 */ pbuf_free(p); }这里有一个容易被忽略的参数:PBUF_TRANSPORT是pbuf的header预留层数,表示除了数据本身,还要预留传输层和IP层头部空间。如果直接用PBUF_RAW,数据指针没有偏移,协议栈封装头部时会覆盖掉数据前一部分内容,属于"数据被吃了"的经典翻车点。
释放pbuf是UDP最关键的纪律。TCP的tcp_write不接管pbuf,而UDP的udp_sendto是异步队列,底层拷贝或引用情况依赖具体实现。安全做法是sendto返回后立即释放,不要在下一次发送时复用同一个pbuf。我曾经在测试中发现发送后会丢最后4字节,排查半天发现是复用了pbuf导致数据互相覆盖。
4.3 广播与端口绑定:UDP客户端的实用配置
UDP客户端也常用来做广播发现协议,比如设备上电后向子网广播自己的IP。在lwIP里把目的地址设为IP_ADDR_BROADCAST即可,但有一个前置条件:网卡的netif必须设置为广播使能,否则广播包会被丢弃。
裸机BSP里netif的flags默认可能没有开NETIF_FLAG_BROADCAST,要在网卡初始化后通过netif_set_flags补上。另外,子网掩码决定了广播地址范围,IP4_ADDR(&dst, 255, 255, 255, 255)是受限广播,只在本地子网有效。跨网段广播需要设置对应的子网定向广播地址,比如子网是192.168.1.0/24,广播地址就是192.168.1.255。
/* 网卡初始化完成后,确保广播权限打开 */ netif_set_flags(netif, NETIF_FLAG_BROADCAST);如果对端设备支持多播,UDP多播是另一个方向。lwIP的多播需要ip4_addr_set_multicast以及加入组播组操作,裸机环境下组播过滤在MAC层,PHY通常不会丢多播帧,但GEM的MAC地址过滤表要同步配置。多播的调试比较痛苦,抓包时容易看到"收到了但驱动没上报",这种时候先确认GEM的哈希表过滤是否包含对应组播组。
5. 避坑指南:Ethernet这潭水,踩过的五个坑
5.1 PHY地址与MDIO时序对不上:link up了但根本不可用
现象:网口指示灯常亮,PHY状态寄存器读到link up,但TCP/UDP数据完全不通,lwIP里netif->flags显示link true,抓包却看不到任何帧。
原因:板卡上PHY地址不是代码里设置的0x01,MDIO访问到了一个不存在的PHY地址,寄存器读取全部失败或超时返回全F。PHY状态是芯片上电自检后默认上报的,和MDIO通路无关,所以link灯照样亮。
解决:在初始化代码里强制读PHY_ID寄存器(寄存器0x02、0x03)并打印,确认读到的值匹配实际PHY型号。如果读到全F,把XEmacPs_SetPhyAddress的参数改成0x00到0x1f逐个尝试,每改一次读一次ID。这个动作应该做成一个循环检测函数,在工程里常驻,方便换板卡时不用改代码。
5.2 中断优先级没设好:UDP高负载就丢包
现象:UDP低频发送一切正常,每10ms发一包就开始丢包,且丢包率随包长增大而上升,TCP却基本不丢。
原因:GEM中断优先级低于其他外设中断,DMA描述符环在高速接收时被占满,新帧无空闲描述符被硬件丢弃。UDP没有TCP的重传机制,丢一帧就没了,所以症状比TCP明显。
解决:在中断控制器里把GEM中断优先级提到最高或次高,同时把DMA描述符数量从默认值加大。描述符数量在BSP的xparameters里可配置,比如默认128,按最大包长1514字节计算,128个描述符能覆盖约194KB数据,但高负载下还不够,一般加到512比较稳。修改描述符数量要同步调整DDR内存分配,描述符本身在内存里占空间。
5.3 内存池耗尽:TCP连接存活但数据发不出去
现象:TCP连接一直保持,对端不发数据时本地也发不出,tcp_write返回ERR_MEM,重启后能恢复一段时间,随后再次卡死。
原因:lwIP的pbuf_pool被耗尽。TCP发送数据被tcp_write放入队列后,如果对端不尽快ACK,发送队列里的pbuf越积越多,PBUF_POOL分配不到新块,tcp_write直接失败。裸机环境下没有自动回收机制,只能靠对端ACK。
解决:调整lwipopts.h里的PBUF_POOL_SIZE和MEM_SIZE,加大pbuf池容量;同时检查应用层有没有在发送后等待sent回调再发下一批,用sent回调做流控,而不是无脑循环tcp_write。如果对端是PC机上的TCP服务器,抓包确认ACK是否正常,常见情况是PC端接收缓冲区太小导致窗口关闭。
5.4 断开后重连不上:旧PCB没释放干净
现象:服务器主动断开或网线拔插后,本地tcp_err回调触发,重新调用tcp_client_start,服务端能看到SYN包但就是不建立连接,或者连接建立后立即RST。
原因:重新连接时旧的tcp_pcb没有完全释放。lwIP的TCP状态机里TIME_WAIT状态的PCB不会被tcp_close立即释放,新连接尝试复用相同四元组时和旧PCB状态冲突。
解决:err回调里必须用tcp_abort而不是tcp_close释放旧PCB,然后在重连前加一个延时状态。延时可以用tcp_poll回调维护计数器,延时2秒左右让旧连接完全退出TIME_WAIT。重连逻辑不要放在err回调里直接执行,把重连请求置一个标志位,在主循环里统一处理。
5.5 速度协商翻车:跑在100Mbps还以为是PHY坏了
现象:网线是千兆线,交换机端口是千兆,但PHY协商结果只有100Mbps。此时TCP/UDP收发都正常,但吞吐上不去,传大文件时明显慢。
原因:PHY的千兆协商需要RGMII的TX时钟稳定输出。Zynq-7000的GEM在千兆模式下的时钟机制和百兆不同,如果MIO引脚上的时钟约束没配好,PHY的1000BASE-T协商就失败,回退到100BASE-TX。这个坑在PCB上表现为时钟走线过长或没有等长处理。
解决:先读PHY的协商能力寄存器,确认PHY本身支持千兆。排除PHY问题后再查软件:确认GEM的速度配置是否通过XEmacPs_SetOperatingSpeed设置为千兆模式。时序问题属于软硬协同,最常见的软件侧原因是MDIO时钟频率过高导致PHY寄存器写失败,千兆协商没被正确触发。把MDIO分频系数适当调大,重新测试协商结果。
6. 进阶验证:从"能收发"到"知道性能边界"
6.1 用自写echo与抓包软件验证真实吞吐
TCP/UDP跑通只是第一步,要让这套驱动真正能在项目里承担数据通信,需要把性能边界测出来。我通常会在板卡上写一个echo任务:收到TCP数据就原样回发,收到UDP包也原样回发。然后在PC端用抓包工具同时看双向流量。
echo模式能同时验证收发通路和协议栈处理延迟。如果板卡到PC的ping延迟在1ms以下、UDP连续发包丢包率接近0,说明基础通路没问题。接着要测吞吐:PC向板卡持续发UDP包,包长1472字节(UDP最大有效载荷),每1000包统计一次接收计数,记录丢包率与发送间隔的关系。这个测试能直接暴露出描述符数量和中断处理时间是否匹配。
6.2 性能优化参数:内存、描述符、中断的微调顺序
优化顺序有讲究:先调描述符数量,再调lwIP内存池,最后动中断优先级。描述符数量决定硬件能暂存多少帧,内存池决定协议栈能缓存多少数据,中断优先级决定CPU响应速度。三者是流水线关系,前面不满足时调后面没用。
我常用的几个关键宏配置是:PBUF_POOL_SIZE设到32以上,PBUF_POOL_BUFSIZE保持默认1518,TCP_SND_BUF按最大发送窗口设到16KB以上,TCP_WND接收窗口同理。这些参数在lwipopts.h里调整后要重新生成BSP并编译整个工程,改动后必须重新跑一遍echo测试确认没有引入死锁或丢包。
经历了上面这些调试以后,我养成了一个习惯:每次拿到新板卡,第一件事是写一个驱动自检函数,依次检查PHY_ID寄存器、link状态、MDIO分频系数、DMA描述符统计寄存器,全部通过后再跑TCP/UDP业务代码。这套自检流程帮我省下了大量在业务代码里排查驱动问题的时间,也希望帮到你。
本文还有配套的精品资源,点击获取