☰
Zynq-7000 PS端LWIP以太网驱动:TCP/UDP双客户端源码详解
2026/10/20 21:56:00 网站建设 项目流程

简介:一份聚焦Zynq-7000 PS端网络通信开发的完整工程资料包,面向FPGA嵌入式开发者,解决在Vivado 18.2环境下配置千兆以太网、实现TCP与UDP客户端的问题。内容涵盖硬件IP配置、AXI总线连接、lwIP或Socket软件栈调用、中断处理与收发逻辑,并包含完整的Vivado工程、SDK源码及编译中间文件。包内共2000个文件,以C源码、头文件、目标文件、makefile为主,同时含VHDL/Verilog硬件描述、XDC约束、HDF硬件定义及readme说明,便于对照工程结构学习软硬件协同流程。压缩包整体29.83MB,已有5805人学习下载。通过该资源可快速搭建Zynq-7000的Ethernet驱动框架,理解TCP连接管理和UDP无连接通信的差异,并可直接参考UDP/TCP客户端实现进行二次开发或移植。

1. 项目定位:Zynq-7000 PS 端 Ethernet 驱动,一份 TCP/UDP 双客户端源码

接手一块 Zynq-7000 板卡,FPGA 侧的逻辑已经跑通,上位机却要通过以太网给 PS 下发指令、回收遥测数据——这时候才发现,PS 端的 Ethernet 驱动还是一片空白。如果你也卡在“网口波形正常但数据不通”的尴尬阶段,这份基于 LWIP 协议栈的 TCP 客户端、UDP 客户端源码就是你要找的落地参考。它跑在裸机上,不依赖操作系统,通过 SDK 提供的 BSP 接口直接操作 GEM 控制器,把链路层到传输层的完整通路打通。适合两类人:一是要在 Zynq-7000 上快速实现以太网通信的产品开发者,二是刚接触 PS 端驱动、想弄明白 socket 与底层 DMA 之间关系的嵌入式初学者。

2. 技术栈与工程结构:GEM 控制器 + LWIP,SDK 工程怎么组织

2.1 硬件链路:从 GEM 控制器到 PHY 芯片

Zynq-7000 的 PS 端集成了最多两个千兆以太网 MAC 控制器,在 Xilinx 文档里叫 GEM(Gigabit Ethernet MAC)。它负责 MAC 层的帧收发、地址过滤、流控,而物理层的编码、电平转换交给外接 PHY 芯片完成。GEM 与 PHY 之间通过 RGMII 接口相连,控制引脚则走 MIO 或 EMIO。常见的板卡设计会用 MIO 16-27 做 MDIO 管理接口和 RGMII 数据线,这些引脚在 Vivado 里配置完成后,会固化为 PS 端专属外设,不占用 PL 逻辑资源。

驱动要真正工作,第一步是搞清楚 PHY 芯片的地址和复位时序。比如某块板子上用的 PHY 芯片地址是 0x1E,而 SDK 生成的默认 xparameters.h 里可能写的是 0x1F,这就需要在初始化时手动覆盖。PHY 的复位引脚一般接在 MIO 或 GPIO 上,上电后必须保证至少 10ms 的低电平复位脉冲,否则 PHY 不完成自协商,GEM 读到的链路状态永远是 down。这个细节直接在驱动初始化代码里处理,而不是指望上电自动复位,省掉很多排查时间。

2.2 SDK 工程目录:从 BSP 到 LWIP 的绑定

拿到这份源码,工程结构并不复杂。核心是 SDK 环境下生成的 BSP 工程,BSP 里已经包含以太网外设的底层驱动,也就是 emacps 系列文件,它们实现了对 GEM 寄存器的读写、DMA 描述符的初始化、中断处理。LWIP 协议栈以静态库的形式被链接进应用工程,应用层代码通过 lwip 提供的 socket API 或者 raw API 发起通信。这份驱动里采用的是 socket API,代码可读性好,便于移植到其它嵌入式平台。

工程目录主要分四块:一是src/下存放应用主程序和 TCP/UDP 客户端实现,通常看到main.c、tcp_client.c、udp_client.c;二是lwip/相关目录放协议栈配置,重点是lwipopts.h和arch/下的移植文件,配置项决定内存池大小、超时时间、是否支持 DHCP;三是platform/里是板级初始化,包括platform_setup_interrupts、init_platform之类的函数;四是 BSP 生成的xparameters.h,里面保存了 GEM 基地址和中断 ID。改驱动时,最常动的就是lwipopts.h和应用程序里的初始化顺序,不是底层寄存器。

2.3 关键配置参数表

配置项位置典型值说明
GEM 基地址xparameters.hXPAR_XEMACPS_0_BASEADDR由 Vivado 地址映射自动生成
PHY 地址xemacps_g.c / main.c0x1E 或实际情况必须与板卡原理图一致
工作 IPtcp_client.c192.168.1.10静态 IP 时手动指定
端口tcp_client.c5001与上位机约定一致
LWIP 内存池lwipopts.hMEM_SIZE 1024*1024影响并发连接数与吞吐量
ARP 超时lwipopts.hARP_TMR_INTERVAL 500ms过期缓存会导致发送失败

表格里这几项是排查网络问题时最常调整的参数。比如 ARP 缓存超时设置过短,客户端每隔十几秒就会发一次 ARP 请求,延迟明显;设置过长又会导致对端换了 IP 后仍然发给旧地址。一般保持默认 500ms 轮询即可,不用刻意修改。

3. 核心模块拆解:TCP 客户端与 UDP 客户端的完整收发链路

3.1 TCP 客户端:从创建 socket 到连接服务器的生命周期

TCP 客户端在主程序中按照“创建套接字 → 绑定本地端口 → 连接远端 → 收发数据 → 关闭”的顺序执行。这里的实现并没有用 select 模型做异步 IO,而是采用阻塞式 socket,配合 LWIP 内部的时间片轮询完成协议栈处理。下面这段代码是 TCP 客户端初始化的核心流程。

int tcp_client_init(struct tcp_client_config *cfg) { struct sockaddr_in local_addr, remote_addr; int sock, ret; /* 创建 TCP socket,地址族 AF_INET,流式套接字 */ sock = socket(AF_INET, SOCK_STREAM, 0); if (sock < 0) { printf("TCP socket create failed\r\n"); return -1; } /* 绑定本地端口,通常绑定端口 0 由系统自动分配 */ memset(&local_addr, 0, sizeof(local_addr)); local_addr.sin_family = AF_INET; local_addr.sin_port = htons(cfg->local_port); /* 0 表示随机端口 */ local_addr.sin_addr.s_addr = INADDR_ANY; ret = bind(sock, (struct sockaddr *)&local_addr, sizeof(local_addr)); if (ret != 0) { printf("TCP bind failed\r\n"); close(sock); return -1; } /* 连接远端服务器 */ memset(&remote_addr, 0, sizeof(remote_addr)); remote_addr.sin_family = AF_INET; remote_addr.sin_port = htons(cfg->remote_port); inet_aton(cfg->remote_ip, &remote_addr.sin_addr); ret = connect(sock, (struct sockaddr *)&remote_addr, sizeof(remote_addr)); if (ret != 0) { printf("TCP connect failed: %d\r\n", ret); close(sock); return -1; } return sock; }

cfg结构体里存了远端 IP、远端端口和本地端口,这个结构体的好处是把网络参数集中管理,二次开发时改成运行时配置非常方便。inet_aton把点分十进制的 IP 字符串转成网络字节序的整数,注意在 LWIP 环境里这个函数是支持完备的。connect失败时不仅要关闭 socket,还要检查两次调用之间是否间隔了足够的协议栈处理时间,否则上一次超时状态没有处理干净,立刻重连大概率还是失败。

收发数据相对直接:send()发送缓冲区内容,recv()接收服务端下发数据。阻塞模式下recv会一直等到有数据才返回,如果业务上不允许长时间卡住,需要配合setsockopt的SO_RCVTIMEO设置超时。驱动源码里接收线程就是一个独立的 while 循环,把接收到的数据通过一个回调函数抛给业务层处理,与发送路径完全解耦。

3.2 UDP 客户端:无连接模型的报式收发

UDP 部分的实现比 TCP 简单,不需要 connect 过程,socket 创建后可以直接 sendto 发送,recvfrom 接收。代码结构如下。

int udp_client_init(char *remote_ip, unsigned short remote_port) { struct sockaddr_in remote_addr; int sock; sock = socket(AF_INET, SOCK_DGRAM, 0); if (sock < 0) { printf("UDP socket create failed\r\n"); return -1; } memset(&remote_addr, 0, sizeof(remote_addr)); remote_addr.sin_family = AF_INET; remote_addr.sin_port = htons(remote_port); inet_aton(remote_ip, &remote_addr.sin_addr); /* UDP 客户端可以直接依靠 sendto 隐式选择路由 */ if (sendto(sock, "hello", 5, 0, (struct sockaddr *)&remote_addr, sizeof(remote_addr)) < 0) { printf("UDP sendto failed\r\n"); close(sock); return -1; } return sock; }

UDP 不需要 connect,但这个驱动里会在初始化时发一条确认报文,用来验证链路通断,也算是一种变相的“握手”。需要注意sendto每次都要携带目标地址参数,与 TCP 那种绑定连接的写法有明显区别。如果接收端回包里的源地址不是我们最初发送的目标地址,recvfrom会返回一个新的地址结构,使用前要拷贝出来,避免下次发送时用错地址。

3.3 让协议栈活起来的周期任务

很多人在裸机上跑 LWIP 容易出现“初始化正常但就是不收发数据”的问题,根本原因是没有周期性调用协议栈的处理函数。LWIP 是一个事件驱动的协议栈,TCP 的超时重传、ARP 缓存老化、TCP 定时器都依赖周期触发。在裸机环境下,通常用 SDK 的定时器中断来实现。

/* 定时器中断服务函数,每隔 250ms 触发一次 */ void lwip_timer_tick(void *param) { sys_check_timeouts(); /* 处理 LWIP 内部超时事件 */ } /* 在主循环中调用,保证协议栈活跃 */ int main(void) { init_platform(); lwip_init(); netif_add(&netif, ...); while (1) { /* 调用 lwip 的包处理函数 */ xemacpsif_poll(&netif); usleep(1000); } }

sys_check_timeouts()是裸机移植 LWIP 的关键入口,它内部会遍历所有注册的定时器,处理 TCP 的重发队列、ARP 的过期表项。xemacpsif_poll负责从 GEM 的 RX 描述符里取出收到的包,交给 LWIP 协议栈处理。这两行代码缺一不可,少了任何一个,驱动马上变现“假死”。中断模式下,DMA 中断服务函数内部也会触发接收回调,主循环里的轮询只是兜底。

4. 运行与验证:从建工程到上位机联调的完整步骤

4.1 创建 SDK 工程并挂载 LWIP 库

拿到源码后,运行环境建议是 Vitis 或老版本 SDK,先新建一个 Application Project。创建过程中选择硬件平台文件,也就是 Vivado 导出的.hdf或.xsa,这个文件里包含了 GEM 外设的配置信息。创建完后,BSP 会默认生成 emacps 驱动,你需要进入 BSP 设置面板,勾选lwip库并选择lwip141或更高版本,确认后 SDK 会自动把协议栈加进 BSP 库中。

接下来把源码文件拷贝到应用工程的 src 目录。关键一步是检查lwipopts.h里NO_SYS宏是否定义为 1,裸机环境下必须开启这个宏,告诉 LWIP 不依赖操作系统。同时确认SYS_LIGHTWEIGHT_PROT为 0,避免无操作系统时出现接口桩错误。工程里默认的堆栈大小需要调大,TCP 收发缓冲区加上 LWIP 运行时内存,建议把stack size提到 0x4000 以上,否则运行一段时间后会出现硬错误。

编译之前,打开xparameters.h确认 GEM 基地址与中断号。需要说明的是,Vivado 生成的 PS 配置里如果勾选了“Enable Gigabit Ethernet”,SDK 会自动识别并生成对应驱动,这一步通常不需要手动修改。但如果板卡对外接口是百兆 PHY,RGMII 实际跑在百兆速率,GEM 需要配置为 Reduced 模式,这个由硬件设计时固定,软件侧注意别强制开启千兆双速自适应。

4.2 编译下载与网络助手联调

编译通过后,用 JTAG 下载到板卡,打开串口终端,波特率一般设为 115200。复位后串口会打印网口初始化日志,包括 PHY 地址检测结果、连接速率、客户端的本地 IP 和端口。如果日志停留在“Waiting for PHY...”,说明 PHY 自协商没完成,优先检查 PHY 地址和复位时序。

上位机用网络调试助手验证收发。PC 网卡先设置到同一网段,比如板卡是 192.168.1.10/24,PC 就设置成 192.168.1.100/24。TCP 模式下,PC 端启动 TCP Server 监听 5001 端口,板卡上电后主动连接,连接成功后,PC 发送数据,板卡串口回显;板卡业务代码定时向 PC 发送数据,助手窗口能看到周期报文。UDP 模式下,助手打开 UDP 绑定本地端口,板卡初始化成功后直接向该端口发报文即可。

联调过程中如果遇到 PC 收不到数据,先查串口日志里的网口速率匹配信息,再抓包看有没有 ARP 请求发出。抓包最直接能看到链路层的真实状态——比如 PC 发 TCP SYN 后,板卡没有回应 ACK,问题多半出在 LWIP 初始化的 PHY 参数上;如果板卡发出了 SYN,但 PC 没回 ACK,则是两侧网络参数不匹配,检查 IP 和端口。

4.3 验证清单

验证驱动是否合格,按下面的清单逐项打勾:

验证项方法通过标准
PHY 链路插上网线观察串口日志打印 Link up 且速率正确
ARP 通信PC ping 板卡 IP延时稳定,无丢包
TCP 上行板卡发、PC 收数据内容与长度一致
TCP 下行PC 发、板卡收串口回显完整
UDP 收发助手互相发送无丢包,时延正常
持续稳定性运行 12 小时无死机、无内存增长

稳定性这一项最容易漏。嵌入式网络驱动的很多坑是在运行几小时后才暴露的,比如内存池耗尽、定时器溢出。建议验证阶段跑一个 12 小时压力测试,板卡端每秒向 PC 发一条心跳,PC 端随机下发控制指令,观察串口是否有报错。这一步能提前暴露第 5 章中提到的那些隐藏问题。

5. 避坑记录:PHY 复位、ARP 缓存、DMA 对齐几个老大难

5.1 现象:TCP 三次握手永远停在 SYN_SENT

刚把驱动跑起来时,板卡端串口显示正在连接 PC 的上位机,但 PC 的网络调试助手始终收不到连接请求。抓包看到 PC 根本没有收到板卡发出的 SYN 包。原因排查后发现是 PHY 复位不彻底。GEM 初始化先于 PHY 复位完成时,MDIO 接口与 PHY 无法通信,导致 GEM 侧不知道链路已就绪,也不会发送任何帧。解决方式是在初始化函数里显式延时等待 PHY 复位完成。

/* 复位 PHY:GPIO 拉低 20ms 再拉高 */ XGpio_DiscreteWrite(&gpio, 1, 0); usleep(20000); XGpio_DiscreteWrite(&gpio, 1, 1); usleep(20000);

这里的 20ms 是经验值,规格书上一般要求最长 10ms 低电平,留一倍余量保证可靠。复位后调用 PHY 检测函数读取 ID 寄存器,确认读到的是 0x2000 开头之类的芯片 ID,才算复位完成。从那以后我每次初始化都会在日志里打印 PHY ID,一旦出现全 F 的返回值就直接报错,不再往下执行,省得把问题留到联调阶段才暴露。

5.2 现象:UDP 能发不能收,串口收不到任何下行数据

UDP 发送给 PC 一切正常,但 PC 回发的数据板卡收不到。排查思路先排除 IP 地址问题,板卡能发出数据说明链路、IP、ARP 都正常,问题大概率出在本地端口绑定上。初始化代码如果绑定了固定的本地端口,而 PC 端回包的目标端口与这个绑定不一致,内核协议栈会直接丢弃报文。

解决方法是检查 UDP 初始化时bind的端口号,保证与 PC 端调试助手的“目标端口”配置一致。另一种常见情况是 DSP 或传感器数据采集用的接收线程和 UDP 接收共用一套缓冲,缓冲被业务代码读走后,协议栈还没拷贝完。裸机上 CPU 单线程处理时,注意接收回调里不要做耗时操作,否则 LWIP 的 PBUF 池耗尽后,新报文进不了协议栈。

5.3 现象:运行几分钟后网络假死,抓包仍能看到板卡在发 ARP

这类问题最折磨人。ARP 请求还在发,说明 MAC 层和 DMA 还在工作,但 TCP 和 UDP 业务突然都停了。逐步定位后发现是内存池碎片化导致 PBUF 分配失败。LWIP 的 PBUF 池在长时间高强度收发后会进入一种状态:总内存足够,但每个空闲块都很碎,无法满足一个大报文的连续分配需求。

通常解决方法是调大lwipopts.h里的PBUF_POOL_SIZE和MEM_SIZE,并开启内存统计宏LWIP_STATS,在串口日志里观察 pbuf 分配失败次数。如果失败次数持续增长,说明业务层有阻塞点导致协议栈无法及时释放缓冲区,需要优化接收线程,而不是单纯加内存。我踩过最典型的一次就是接收回调里调用了一个耗时 200ms 的存储函数,直接把协议栈卡死,把耗时操作丢到独立线程后就正常了。

5.4 现象:同一网段 ping 通,跨网段不通

本地调试没问题,一接入局域网就直接哑火。检查路由器和 PC 的配置没问题,板卡侧设置的默认网关却是 0.0.0.0。LWIP 的netif结构里默认网关需要在初始化时手动设置,否则访问非本网段地址时,协议栈不知道该把包发给谁。

IP4_ADDR(&netmask, 255, 255, 255, 0); IP4_ADDR(&gw, 192, 168, 1, 1); /* 与局域网网关一致 */ netif_add(&netif, &ipaddr, &netmask, &gw, NULL, ethernetif_init, tcpip_input); netif_set_default(&netif);

网段联通后,还要注意 LWIP 的 ARP 缓存行为。跨网段时目标 MAC 从网关的 ARP 表里获取,如果网关设备老化快,板卡又没有及时刷新,就会出现间歇性不通。此时检查ARP_TMR_INTERVAL和ARP_MAXAGE的配置,把 ARP 超时时间调整为 5 分钟左右比较稳妥。

6. 二次开发:把客户端改成配置化与协议汇聚

6.1 将远端 IP 与端口改为运行时配置

原始驱动里,IP 和端口是硬编码的宏定义,每次改地址都要重新编译。实际项目里,往往希望给产品保留一个串口命令接口,运行中动态修改连接目标。改造思路是把tcp_client_config结构体里的成员设为 volatile,通过串口中断更新,更新后主动关闭旧连接并重连。注意connect失败后要先close再重新创建 socket,不能对着同一个 fd 反复调用 connect。

6.2 TCP 与 UDP 共用业务层

一个常见的需求是“TCP 收下位机指令,UDP 上传感测数据”。两份源码可以缩成一个协议分发层:定义统一的消息结构体app_msg_t,包含数据类型、长度、方向三个字段,TCP 和 UDP 的接收线程都向同一个队列投递消息,主循环按类型分发处理。这样新增通信接口时只加一个文件,不需要改动业务逻辑。

6.3 性能调优参数

需要提高吞吐量时,优先调整lwipopts.h中的TCP_SND_BUF和TCP_WND,这两个值决定发送窗口和接收窗口大小,建议同时调大。UDP 要降低丢包率,关键是把PBUF_POOL_BUFSIZE和PBUF_POOL_SIZE一起放大,保证底层 DMA 能连续接收多个报文而不被覆盖。调参后记得重新跑 12 小时的稳定性测试,内存池调整会直接影响内存占用,BSP 里堆的大小要同步增加。

回顾整份源码,最有价值的一点是它把裸机环境下 LWIP 的若干关键细节全部展示出来了:PHY 的复位时机、协议栈的周期调用、内存池配置对稳定性的影响。这些经验是我在一次项目中反复翻车才悟出来的。从那以后,我每次做以太网驱动都强制先跑完四步:查 PHY 手册、打印 PHY ID、确认 netif 网关、压测 12 小时,再凛然进入业务联调。希望这份驱动的拆解过程可以帮你少走几段弯路。

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

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

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

立即咨询