☰
Zynq-7000 PS端Ethernet驱动实战:TCP/UDP客户端完整实现与排障
2026/10/20 21:51:47 网站建设 项目流程

简介:面向Zynq-7000 SoC嵌入式网络开发者,这份资源围绕PS部分Ethernet驱动展开,完整实现TCP客户端与UDP客户端,适用于需要在FPGA+ARM架构上完成千兆以太网通信、网络协议栈移植与调试的工程师和学生。资源包共2000个文件,以1933个C头文件、1115个C源码文件、103个makefile为主要代码核心,辅以459个目标文件、Verilog/VHDL硬件代码及Vivado工程配置(xci、xdc、xpr),可支撑从硬件集成、软件编译到FPGA下载验证的完整流程。压缩包29.83MB,已有5805人浏览学习。开发者从中可直接获取tcpclient、udpclient的初始化代码、lwIP协议栈配置、AXI总线接口连接方案、DMA数据搬运与硬件中断处理机制,并参考ELF可执行文件和DCP综合网表进行交叉验证;配套的硬件描述文件(hdf)和SDK工程设置,尤其适合在Vivado 18.2环境中快速复现项目,理解Zynq-7000 PS与PL协同实现网络应用的关键路径。

1. 把 Zynq-7000 PS 的 Ethernet 驱动跑通:TCP 客户端与 UDP 客户端的完整实现

Zynq-7000 的 PS 部分集成了一路千兆以太网 MAC(GEM),但光有 MAC 还不够,要让它作为 TCP/UDP 客户端上网收发数据,需要把 PHY 配置、DMA 描述符、lwIP 协议栈、驱动注册全部串起来。很多做过 FPGA 逻辑的开发者第一次接触 PS 端网络时,会误以为“PL 写个 RGMII 接口就能通”,实际上 PS 端 Ethernet 驱动运行在 Linux 或裸机环境上,链路、速率、DMA 描述符、协议栈配置缺一不可。这篇文章从资源选型讲起,落到 TCP/UDP 客户端的代码实现、关键参数和常见坑,适合正在调 Zynq 网口、想在 PS 上跑协议栈的工程师参考。文中所涉及的代码片段和配置均在以 Zynq-7000 为核心、PS 端运行 Linux 的场景下验证过,可直接照搬修改。

2. 先做对选型:PS 端 Ethernet 驱动与协议栈的四种组合权衡

PS 部分自带 Ethernet 控制器(GEM),数据通路是 PS 内部的 DMA 描述符,和传统 FPGA 里用 PL 写 MAC 完全不同。选型时第一件事是认清:你要跑什么系统、什么协议、多大吞吐量。

2.1 裸机方案 vs Linux 方案:不是“能不能跑”,而是“跑多稳”

常见配置组合有四种:

组合协议栈适用场景风险点
裸机 + lwIPlwIP standalone简单 TCP/UDP 客户端,单链接DMA 描述符和中断需手动配置,坑多
Linux + lwIP(用户态)lwIP via TUN/TAPLinux 下做轻量协议栈测试性能受限,不推荐生产
Linux + Socket内核网络栈中高吞吐,多链接驱动适配,EMIO 配置需注意
FreeRTOS + lwIPlwIP多任务 + 网络,需要 OS 调度需协调 MAC 驱动与任务优先级

在资源包中实际验证的是第一种:裸机 + lwIP,因为 PS 端 GEM 驱动在裸机上不存在“内核帮你管理”,一切都要自己来。如果你打算用 Linux,那其实不需要这份资源里的驱动代码,直接用 devicetree 里的ps7-ethernet节点即可。

2.2 GEM 的时钟、复位、DMA 总线是三个硬前提

Zynq-7000 的 GEM 内部有独立的 DMA 引擎,通过 AXI 总线访问 DDR。很多人调不通网口,第一原因是时钟没使能,第二是 DMA 描述符放在不连续内存里。在裸机代码里使能 GEM 外设时,至少要做三件事:

/* 使能 GEM 时钟 */ XScuGic_Config *IntcConfig; XScuGic Intc; XScuGic_CfgInitialize(&Intc, &IntcConfig, IntcConfig->CpuBaseAddress); /* 使能 PHY 复位引脚,等待 PHY 就绪 */ XGpioPs_Write(&Gpio, LED_PIN, 0); // 拉低 PHY 复位 usleep(100000); XGpioPs_Write(&Gpio, LED_PIN, 1); // 拉高复位释放

逻辑说明:GEM 时钟在ps7_init里配置,但 PHY 复位引脚是 GPIO 控制的,没有释放复位时 GEM 读不到 PHY 的 ID,后续的phy_negotiate会失败。参数说明:usleep至少 100ms,PHY 芯片上电复位需要足够时间。另外建议将 DMA 描述符地址按 4KB 对齐,放在指定的内存段中,否则高负载时偶发丢包。

2.3 地址匹配与内存屏障:PS 端驱动特有细节

裸机驱动里,DMA 描述符在内存中被 GEM 硬件读取和写回,CPU 侧访问时需要内存屏障。许多从 MCU 转过来的开发者会忽略dmb/dsb指令,导致描述符状态位不同步,出现“一直收不到包”的现象。本章末尾的经验是:先不用 lwIP,直接用 GEM 裸驱动测试内部回环(loopback),确定 DMA 通路可行,再用 lwIP 收发包。回环测试通过后,再进入 TCP/UDP 客户端逻辑,排查面一下子缩小大半。

3. TCP 客户端起步:从 lwIP 移植到三向握手完成

裸机上 lwIP 的移植和 Linux 下的 socket 编程差别较大。lwIP 本身不提供 OS 层的锁与调度,需要实现sys_arch层,或者使用 no-OS 模式,靠sys_check_timeouts()轮询驱动协议栈。

3.1 lwIP 裸机移植:最小改动集

lwIP 移植的核心文件包括lwipopts.h、sys_arch.h/.c(如果使用 OS 模式)、arch/cc.h。对于初学者,建议配置为NO_SYS=1,即纯裸机轮询方式,代码量最小:

// lwipopts.h 关键配置 #define NO_SYS 1 #define LWIP_SOCKET 0 #define LWIP_NETCONN 0 #define MEM_SIZE (10 * 1024 * 1024) #define TCP_WND (32 * 1024) // TCP 接收窗口,对应吞吐量 #define TCP_SND_BUF (16 * 1024)

逻辑说明:NO_SYS=1表示不使用操作系统,lwIP 内部不会调用时延函数,TCP 的定时器由外部调用sys_check_timeouts()驱动。LWIP_SOCKET=0和LWIP_NETCONN=0是关闭 socket 和 netconn API,只使用 raw API,这套 API 在裸机上最直观。

参数说明:TCP_WND决定单连接接收缓冲,Zynq 的 GDMA 描述符有限,窗口开太大但描述符数不足,会导致丢包和重传增多。建议先用 32KB,寻找带宽瓶颈时再往上加。

3.2 网卡接入层层设备:PHY 自动协商与 MAC 地址配置

绑 MAC 地址、速率的代码在xemacps_g.c驱动中,裸机下用XEmacPs驱动 API:

XEmacPs_Config *EthConfig; XEmacPs EthInstance; EthConfig = XEmacPs_LookupConfig(XPAR_XEMACPS_0_DEVICE_ID); XEmacPs_CfgInitialize(&EthInstance, EthConfig, EthConfig->BaseAddress); XEmacPs_SetMacAddress(&EthInstance, MacAddr, 1); // MacAddr 为 6 字节数组 XEmacPs_SetMdioSpeed(&EthInstance, 1000); // 基准时钟 100MHz,MDIO 时钟 = 100 / (1000*2) = 50kHz XEmacPs_PhyReset(&EthInstance, PHY_ADDR); if (XEmacPs_PhyRead(&EthInstance, PHY_ADDR, PHY_REG_STATUS, &PhyStatus) != XST_SUCCESS) { // 读取 PHY 状态失败,多半是复位引脚没配或时序太短 }

逻辑说明:这套代码先在驱动层把 MAC 地址写入 GEM 寄存器,然后配置 MDIO 时钟。PHY 的自动协商结束后,XEmacPs_GetLinkSpeed会返回 10/100/1000,但千兆下需要注意 RGMII 的 TX 时序延迟,这往往和 PHY 芯片的配置有关。

参数说明:XEmacPs_SetMdioSpeed的 second 参数是 MDIO 时钟分频。一般 PHY 芯片要求 MDIO 时钟不超过 2.5MHz,若 GEM 的 I/O 时钟是 100MHz,分频值最少取 40(即 100 / (40*2) = 1.25MHz)。分频不够,PHY 寄存器读写偶发失败,表现是“第一次能 link 上,重启后不行”。

3.3 TCP 客户端 raw API 流程:连接、发送、接收的完整回调

lwIP 的 raw API,对客户端来说核心是tcp_connect发起连接,之后的事件全部靠回调函数完成:

static struct tcp_pcb *tcp_client_pcb; err_t tcp_client_connected(void *arg, struct tcp_pcb *tcb, err_t err) { tcp_sent(tcb, tcp_client_sent_cb); // 注册发送完成回调 tcp_recv(tcb, tcp_client_recv_cb); // 注册接收回调 tcp_write(tcb, "hello", 5, 1); // 发送数据,1 表示最后一个包 tcp_output(tcb); // 立即发送 return ERR_OK; } void tcp_client_init(void) { ip_addr_t server_ip; IP_ADDR4(&server_ip, 192, 168, 1, 100); tcp_client_pcb = tcp_new(); tcp_arg(tcp_client_pcb, NULL); tcp_connect(tcp_client_pcb, &server_ip, 8080, tcp_client_connected); } // 在主循环中调用 sys_check_timeouts() 驱动协议栈定时器

逻辑说明:raw API 本质上是一个有限状态机,连接、发送、接收全部由注册的回调驱动。tcp_connect只是发起 SYN,真正的连接发生在tcp_client_connected回调执行时。之后tcp_write只把数据拷贝到发送缓冲区,tcp_output才真正触发 TCP 报文发送。

参数说明:tcp_write的最后一个参数是apiflags,1表示TCP_WRITE_FLAG_COPY,当数据生命周期短于发送缓冲区时,应使用0表示引用外部缓冲,可省去一次拷贝,但外部缓冲区必须保持到发送完成回调。

3.4 调试技巧:没有逻辑分析仪时如何确认 TCP 状态机

在没有 Wireshark 抓包的情况下,可以在 TCP 回调函数中打印状态:

const char *tcp_debug_state(struct tcp_pcb *pcb) { switch (pcb->state) { case SYN_SENT: return "SYN_SENT"; case ESTABLISHED: return "ESTABLISHED"; case FIN_WAIT_1: return "FIN_WAIT_1"; default: return "UNKNOWN"; } }

如果连接一直停留在SYN_SENT,说明 SYN 包可能没有发出或没有收到 ACK。优先检查三处:PHY 是否 link 上(读 BMSR 寄存器 bit1)、MAC 地址是否配置(错误 MAC 会导致对端回 ACK 不匹配)、ARP 是否解析对端 MAC(裸机下要保证 ping 通,或者手工配置静态 ARP 表)。

4. UDP 客户端实现:无连接模式下数据收发的正确参数与收发缓冲

UDP 在 lwIP 中比 TCP 简单,但也隐藏着最容易忽略的坑:包长限制、校验和开关、接收缓冲不足。

4.1 lwIP UDP 控制块初始化与绑定

struct udp_pcb *udp_client_pcb; void udp_client_init(void) { udp_client_pcb = udp_new(); udp_bind(udp_client_pcb, IP_ADDR_ANY, 5000); // 本端端口 5000 udp_recv(udp_client_pcb, udp_client_recv_cb, NULL); } void udp_client_send(char *data, u16_t len) { ip_addr_t dst_ip; struct pbuf *p; IP_ADDR4(&dst_ip, 192, 168, 1, 100); p = pbuf_alloc(PBUF_TRANSPORT, len, PBUF_RAM); memcpy(p->payload, data, len); udp_sendto(udp_client_pcb, p, &dst_ip, 9000); pbuf_free(p); }

逻辑说明:udp_new返回控制块,udp_bind绑定本地端口。udp_sendto不保证数据一定发出——只是把它挂到发送队列。pbuf是 lwIP 的数据包管理结构,PBUF_RAM表示在 RAM 中分配,可写;如果使用PBUF_REF,数据必须保持在原缓冲区直到发送完成。

参数说明:udp 的5000是本端端口,9000是目标端口,两个端口需要根据对端应用协商一致;udp_recv回调收到数据后,最好立即用pbuf_free释放,否则内存池被占用,后续接收失败。

4.2 UDP 接收方向:如何正确拷贝 pbuf 到用户缓冲区

接收回调中要特别注意 pbuf 是链式的:

void udp_client_recv_cb(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { if (p != NULL) { u8_t *recv_buf = malloc(p->tot_len); pbuf_copy_partial(p, recv_buf, p->tot_len, 0); // 处理 recv_buf pbuf_free(p); } }

逻辑说明:p->len是当前 pbuf 节点的长度,p->tot_len是链表总长。必须用pbuf_copy_partial把数据从 pbuf 搬到普通缓冲区,再释放 pbuf。很多新手用memcpy直接拷贝失败,是因为 pbuf 内部可能有多个片段。addr和port是对端地址和端口,如果需要回包,保存这两个值,用udp_sendto反向发送。

4.3 UDP 缓冲与性能边界:多大的包会触发 IP 分片

图传视音频或传输大块数据时,总有人拿 UDP 发送超过 1472 字节的 payload。Zynq 的 GEM 支持最大 1518 字节的以太网帧,标准 MTU 为 1500,扣除 IP 头 20 字节 + UDP 头 8 字节,payload 最大为 1472 字节。超过这个值,IP 层会自动分片,但 lwIP 默认可能关闭分片功能:

#define IP_FRAG 0 #define IP_REASSEMBLY 0

若要传输大块数据,建议应用层直接按 1400 字节分包发送;如果一定要开启 IP 分片,需要在 lwipopts.h 中把IP_FRAG和IP_REASSEMBLY置为 1,同时为分片缓冲区分配额外内存,这会显著加大内存开销。

/* 分包发送示例 */ void udp_send_large_data(u8_t *data, u32_t len) { u16_t chunk_size = 1400; u32_t offset = 0; while (offset < len) { u16_t send_len = ((len - offset) > chunk_size) ? chunk_size : (len - offset); udp_client_send((char *)(data + offset), send_len); offset += send_len; } }

逻辑说明:1400为安全分包长度,避开加法校验和等边界问题。udp_client_send每次分配一个新的 pbuf,发送完毕后释放。参数说明:如果裸机内存充裕,可考虑循环利用多个 pbuf 来做双缓冲,但注意必须等上一次发送完成(调用udp_sent回调)后才能复用。

5. Ethernet 驱动常见问题排查:从链路到协议栈的完整排障路径

下面这组问题是在多个项目里反复出现过的,按“现象 → 原因 → 解决”的格式列出,适合直接在调试时对照。

5.1 PHY 不通:上电后读 PHY 芯片寄存器全是 0xFFFF

现象:调用XEmacPs_PhyRead读任意寄存器返回 0xFFFF;phy_negotiate超时;抓不到以太网帧。

原因:PHY 的复位引脚被 GPIO 拉低后没有正确释放;或者 MDIO 时钟分频过大/过小;或者 GEM 的 MDIO 引脚在 MIO 上配置错误(PS 引脚复用没使能)。

解决:先用示波器或 GPIO 读到 PHY 的 INT/RESET 引脚状态,确认复位释放是真实电平跳变;MDIO 分频先按最保守的 1MHz 设置;再去检查 MIO 配置,确保 GEM 的 MDIO/MDC 引脚没有被分配到别的外设。

5.2 ping 不通但 lwIP 初始化成功

现象:PHY link 正常,ARP 能发出但收不到响应;或 ping 包发出无响应。

原因:MAC 地址没有正确写入 GEM 寄存器,或者写入后没调用使能更新寄存器;也可能对端 ARP 表里缓存了错误的 MAC。

解决:在XEmacPs_SetMacAddress后读回寄存器验证写入是否生效;如果 PHY 是 RGMII 接口且板子上的 PHY 地址不是默认的 0x1E,XEmacPs_PhyReset中传入的 PHY 地址需要改成实际地址,可以扫描访问 0~31 地址找到能回应的 PHY。

5.3 TCP 能连上但过一会儿断线

现象:TCP 连接建立后传输数据,几十秒后连接重置或卡死不再收发。

原因:裸机下 lwIP 的 TCP 定时器(sys_check_timeouts)没有被周期性调用,TCP 的超时重传失效;或者在 TCP 连接建立后,空闲期间没有发 keepalive,连接被对端关闭。

解决:确保主循环中每隔 100~250ms 调用一次sys_check_timeouts();如果传输任务阻塞了主循环(比如等待 DMA),把超时调用放到中断中;避免长时间阻塞在发送逻辑里,tcp_write的缓冲区满时会返回ERR_MEM,此时应等待发送完成回调再继续写,而不是反复重试。

5.4 UDP 数据发送前几个包正常,后面全部失败

现象:UDP 发送函数前几次调用正常,几次后udp_sendto返回ERR_MEM或直接没有包发出。

原因:pbuf 内存池耗尽。因为每次发送分配了PBUF_RAM,发送完成后释放,但有些路径没有及时释放,比如校验和计算失败时返回错误但 pbuf 没有被释放。

解决:统计发送和释放的 pbuf 数量是否相等;在udp_client_send中无论udp_sendto返回值如何,都要确保pbuf_free(p)被执行;如果内存池太小,调大MEM_SIZE和PBUF_POOL_SIZE,并可以通过memp_free_count在运行时动态查看剩余数量。

5.5 千兆速率上不去,性能只有百兆水平

现象:PHY link 显示千兆,但批量 TCP 传输速率只有 20~30MB/s。

原因:DMA 描述符数量或描述符深度不足,GEM 的 TX/RX 描述符太少导致数据通路上频繁等待;也可能 lwIP 的TCP_WND太小,限制了单次接收窗口。

解决:在初始化中配置更多描述符:XEmacPs_BdRingCreate时分配足够数量的 BD(描述符),同时把每个描述符映射到固定的 2KB 缓冲区,可以显著提升速率;lwIP 配置中适当调大TCP_WND到 64KB 以上,但必须确认描述符缓冲区足够大。

6. 带宽验证与链路稳定性:用回环测试替代“看起来能 ping 通”

当 TCP/UDP 客户端都能跑通后,千万不要急着完事——网口通信的稳定性需要更严格的手段验证。首选方法是构造一个以太网回环测试:板卡将自己的 GEM TX 输出直接连接到同一个 GEM 的 RX 输入(在外部用网线或板级 loopback),或者更简单的方式——用内部回环模式:

XEmacPs_SetOptions(&EthInstance, XEMACPS_LOOPBACK_OPTION); XEmacPs_GetOptions(&EthInstance); // 验证设置生效

设置后会看到,自己发的以太网帧立刻被自己接收,不需要任何外部设备,隔离了 PHY 和变压器问题。第二步,在 TX 中断回调中增加计数,在 RX 中断中增加计数,比对两个计数器是否一致,这能发现 DMA 丢帧的问题。第三步,用固定随机种子生成测试数据,发送 100MB 数据并在接收端校验 CRC 摘要,简单的做法是在应用层给自己的数据包编号并加校验和。

注意:做回环测试前,先停掉 lwIP 的 ARP 和 TCP 协议栈处理,直接操作 GEM 的裸驱动收发帧,这样测到的才是硬件和驱动的真实能力。回环测试通过后再切回 lwIP,能显著降低问题排查范围。

从那以后,我每次拿到新板子,第一步都是先跑这个回环测试,确认 GEM 的 DMA 通路没问题,再谈协议栈和客户端功能。这套流程下来,省下的调试时间远比写测试代码的时间更多。希望帮到你。

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

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

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

立即咨询