ZYNQ 7020 lwIP裸机TCP服务器移植与实现要点
2026/9/12 11:59:06 网站建设 项目流程

简介:ZYNQ 7020 lwIP实现TCP Server驱动的SDK工程包,面向嵌入式开发者和FPGA学习者,解决在Xilinx ZYNQ-7020 SoC平台上使用SDK与lwIP协议栈构建稳定TCP服务端驱动的实际需求。ZYNQ 7020集成ARM Cortex-A9双核处理器,该工程正适合软硬件协同设计的网络通信场景。包内共1042个文件,以419个.h头文件和224个.c源码文件为主体,涵盖驱动源码、外设初始化、lwIP协议栈集成及网络应用逻辑;另含92个.o目标文件、HDF硬件定义文件、makefile构建脚本、TCL工程脚本、xpr工程文件及README等,可导入Xilinx SDK直接查看或重新编译,目录结构也便于定位驱动代码与构建脚本。压缩包整体仅11.74MB,便于传输保存,目前已有486人学习下载。工程覆盖从硬件平台导入、驱动初始化、网络接口配置到监听端口并处理客户端请求的完整链路,代码经测试可直接编译运行,并涉及中断服务例程、同步机制与错误处理等关键环节。对于希望复用ZYNQ网络外设驱动、快速搭建TCP通信原型的开发者,这是份具参考价值的工程范例,也可作为学习lwIP移植与ZYNQ嵌入式网络开发的良好起点。

1. 没跑通过 ZYNQ 的 TCP 服务器,别急着怀疑板子

做 ZYNQ 7020 嵌入式网络开发的人,十有八九是在把 PS 侧跑起来、想用网口跟 PC 通信时卡在 lwIP 上。这个标题里的 tcp_server 看起来是个“例程”,实际上它同时踩中了三个深坑:ZYNQ 的 PS 网口默认根本不在地址空间中、lwIP 裸机移植模型在 SDK 里要自己勾选库、以及 TCP 服务器并非串口那样开了就能用——你得把 DMA 描述符、中断和内存池都配到同一个节拍上。本文会先讲清楚 ZYNQ 7020 上 lwIP 裸机移植的硬件基础,然后沿着 Vivado 工程到 SDK 代码的完整链路,给出一个能直接上板验证的 TCP 服务器实现路径,重点落在三处:PS 端 GEM 控制器的地址映射、lwIP 在 standalone BSP 中的使能方式、以及服务器循环里每个 API 的阻塞语义。面向想用 PS + lwIP 做设备联网、数据采集或远程控制的嵌入式工程师,也包括那些在网络协议栈边缘试探、被connect失败折磨的 FPGA 开发者。

2. lwIP 裸机移植模型:为什么选它而不是 Linux 网络栈

2.1 在 ZYNQ 7020 上处理 TCP/IP,第一反应不该是 Linux

ZYNQ 7020 的 PS 侧是双核 Cortex-A9,片上内存和 DDR 资源都有限。跑 Linux 加网络栈确实强大,但启动时间、镜像体积、驱动维护成本,对很多设备端场景都是负担。SDK 裸机环境下,lwIP 的优势在于它是 event-driven 的事件驱动协议栈,配合 standalone BSP 里自带的链路层驱动,能做到很小的内存占用就完成 TCP 通信。注意,lwIP 本身只负责 IP / TCP / UDP 层的协议处理,网卡的底层驱动需要由 BSP 提供。SDK 在创建 BSP 时勾选了 lwIP 库之后,会自动把xemacpsif_dma.c这套基于 AXI DMA 的驱动包含进来。裸机模型的核心是它不用操作系统来调度,而是用一个tcpip_thread类似的主循环配合中断完成收发包,所以开发者要面对的编程模型是回调 + 轮询,而不是阻塞等待。

2.2 地址映射:PS 网口不是你想开就能开的

ZYNQ 7020 的千兆以太网控制器(GEM)是挂在 PS 内部的高性能端口上的,不是 PL 里的 AXI 外设。GEM0 和 GEM1 的寄存器基地址分别位于0xE000B0000xE000C000。但 SDK 的 lwIP 驱动不会自动探测这些地址,它依赖的是 BSP 生成的xparameters.h里的参数。很多时候 TCP 服务器没反应,不是代码逻辑错,而是你建的 Vivado 工程里根本没有使能 GEM 外设,或者没有正确连接 MIO 引脚到外部 PHY。常见做法是:在 IP integrator 里双击ZYNQ7 Processing System,在 Peripheral I/O Pins 里勾选ENET 0(或 ENET 1),再把 MIO 配置成 GMII 或 RGMII 模式。如果这个配置漏了,xparameters.h里就不会有XPAR_XEMACPS_0_IS_GEM相关的宏,SDK 那边编译虽然能过,但底层驱动拿不到有效基地址,运行时直接挂死。

/* xparameters.h 中与 GEM 相关的宏(SDK 自动生成) */ #define XPAR_XEMACPS_0_IS_GEM 1 #define XPAR_XEMACPS_0_GEM_BASEADDR 0xE000B000 #define XPAR_XEMACPS_0_MIO_OFFSET 54 #define XPAR_XEMACPS_0_PHY_TYPE 1

GEM 基地址不是绕不开的话题。裸机移植模型里,lwIP 的初始化流程会调用xemacpsif_init,这个函数会读取上述宏来配置 DMA、MAC 地址和 MDIO 接口。你在 SDK 里看到netif_add失败,八成是baseaddr这个参数拿到的是 0,也就是 BSP 没有感知到这个外设。解决办法不是去改代码,而是回头把 Vivado 工程改对,重新生成硬件描述文件。

2.3 lwIP 的内存池:配置不对,TCP 收发直接就丢

PS 侧裸机环境是没有 MMU 帮你做地址转换的,lwIP 使用的内存池是固定的静态数组。lwipopts.h里有一组关键配置,默认值对于 ZYNQ 7020 来说往往不够。以 C 代码的方式做 TCP 接收,数据到达时会触发中断,驱动程序将数据放入 PBUF 结构里,然后通知协议栈处理。如果MEM_SIZE定义太小,或者PBUF_POOL_SIZE太小,唯一的征兆就是 TCP 连接建立成功但数据传一会儿就断了。SDK 的 lwIP 库默认给PBUF_POOL_SIZE是 4 到 8 个,这在千兆网卡的突发流量下几乎必挂。一般我会调到 32 以上,同时把TCP_MSS设为 1460,确保 TCP 分段能直接放满一个以太网 MTU。

lwipopts.h里,除了池大小之外,还有几个和裸机中断强相关的宏。比如NO_SYS必须为 1,因为你没有 RTOS,不能用信号量那套;SYS_LIGHTWEIGHT_PROT必须为 0,否则会在临界区处理上出问题。这些参数决定了 lwIP 是运行在“raw API”模式还是“sequential API”模式。ZYNQ 7020 SDK 默认支持的是 raw API,即你不用创建线程,而是注册回调函数,在中断上下文里完成协议栈的调用。很多从 Linux 网络编程转过来的开发者会在这里迷路,习惯性地想用 socket 那套阻塞写法,实际上在裸机上你要处理的是tcp_accepttcp_recv这几个回调函数。

在开始写服务器逻辑之前,先把 GEM 和 lwIP 的关系厘清,看起来是 SDK 在帮你做事情,实际只是提供了一套封装好的适配层,底层仍然是你在控制硬件中断。

3. Vivado 工程搭建和网络外设配置:一个按键失误导致整个网口进不了系统

3.1 使用 IP integrator 配置 ZYNQ PS,使能千兆以太网控制器

打开 Vivado,创建 RTL 工程后,第一步是在 IP integrator 里添加ZYNQ7 Processing System。这个过程没有任何神秘感,双击这个 IP,进入配置界面后,最重要的操作发生在Peripheral I/O Pins选项卡中。要保证以太网能用,你需要找到ENET 0(若板载 PHY 连接到 GEM0)或ENET 1,勾选它。同时,在MIO Configuration里观察:ENET 0的 MIO 引脚号会根据你的板子走线自动分配,比如常见的是 MIO 16 到 MIO 27 用于 RGMII 接口的信号线,包括RXD[3:0]TXD[3:0]RX_CTLTX_CTLRXCTXC。一切确认完毕,再检查时钟配置。ZYNQ 7020 的 PS 侧 CPU_6x4x 时钟域里,需要确保ENET的时钟源被定义为IO PLLARM PLL,频率会根据 PHY 的工作模式决定:RGMII 需要 125MHz,GMII 需要 2.5MHz 到 25MHz。

# Vivado Tcl 配置示例:使能 GEM0,并强制设定 MIO 到 RGMII 模式 set_property -dict [list \ CONFIG.PCW_ENET0_EN {1} \ CONFIG.PCW_ENET0_PERIPHERAL_EN {1} \ CONFIG.PCW_ENET0_GRP_MDIO_EN {1} \ CONFIG.PCW_ENET0_GRP_MDIO_IO {MIO 52 53} \ CONFIG.PCW_ENET0_PERIPHERAL_IO {MIO 16 27} \ CONFIG.PCW_ENET0_ENET0_IO {MIO 16 27} \ CONFIG.PCW_ENET0_PERIPHERAL_CLKSRC {IO PLL} \ CONFIG.PCW_ENET0_PERIPHERAL_DIV {0x8} \ ] [get_bd_cells processing_system7_0]

每次创建新工程时,用 IP integrator 自带的预设,比如ZedBoardZC702这些模板,可以省掉大半的引脚配置工作。但如果是自己画的板子,上面这段 Tcl 设置得手动处理,尤其是PCW_ENET0_ENET0_IO这个选项,直接定义了 GEM0 的引脚走向。很多人直接在图形界面上勾选却忘了点OK确认,导致最后生成的比特流里根本没有以太网的引脚约束,SDK 里xparameters.h也自然缺失对应宏。

3.2 DDR 配置与地址映射:lwIP 的内存大头

ZYNQ 7020 的裸机工程虽小,但 lwIP 的缓冲池、描述符以及代码本身都需要放进 DDR3 或 DDR2 里运行。Vivado 里的 DDR 配置是在DDR Configuration选项卡里完成的,你需要根据板载内存颗粒的型号选择Memory Part,比如 MT41K256M16 HA-125 是常见的 DDR3 颗粒。如果这部分配置不对,SDK 下载程序后,lwIP 初始化时访问内存会宕机,因为控制器拿到的地址是错的。DDR 总容量和地址映射直接决定了lscript.ld里各段的基址。在裸机工程里,通常ps7_ddr_0的基址是0x00100000,如果不小心把 lwIP 的堆放在0x00000000这个地址段,即 OCM 里,那空间完全不够用。

/* lscript.ld 中内存区域划分示例 */ MEMORY { ps7_ddr_0 : ORIGIN = 0x00100000, LENGTH = 0x1FF00000 ps7_ram_0 : ORIGIN = 0x00000000, LENGTH = 0x00030000 ps7_ram_1 : ORIGIN = 0xFFFF0000, LENGTH = 0x0000FE00 }

建议把 lwIP 的堆放在 DDR 里,而非 OCM 的ps7_ram_0里。原因很直接:OCM 只有 256KB,而 lwIP 在做大数据收发时,PBUF 池可以被设计到 100KB 以上。DDR 配置完毕后,在 SDK 中打开lscript.ld,确认.heap.stack被分配到ps7_ddr_0段中,这样 lwIP 动态分配的内存才不至于越界。

3.3 UART 和中断:调试 tcp_server 的两条命脉

无论你用哪个 PHY 芯片,ZYNQ 的 GEM 中断默认连接到 GIC(通用中断控制器)的 54 号中断(对应 GEM0)。没有中断,网卡驱动根本无法知道数据帧到达。在 Vivado 里确认 ZYNQ PS 的 IRQ 输出正确连接到pl_ps_irq0或者irq_0,这是给 PL 的中断输入,要确保它被引出。注意,SDK 的 BSP 在生成 lwIP 库时,会自动处理中断连接,它通过XScuGic驱动把 GEM 的中断注册到 CPU。

UART 则是另一条命脉。在裸机 tcp_server 调试过程中,你能看到的唯一输出通道就是串口(通常是 MIO 14 和 MIO 15)。lwip_errnetif_set_up这些函数的返回值,如果不用xil_printf打印出来,你会完全在黑盒子里摸索。在Peripheral I/O Pins里勾选UART1,并且把PCW_UART1_BAUD_RATE设置为 115200,硬件配置部分才算完整。

在实际项目中,我习惯先把 UART 调通、打印一个简单的 “hello” 再继续。这个动作能排除 Vivado 阶段 80% 的问题——有时候硬件没问题,只是 MicroBlaze 或 PS 时钟配置错误,串口就有输出。把 UART 和 ENET 都使能后,生成比特流,导出硬件到 SDK,进入软件设计。

4. SDK 驱动封装与 lwIP 应用代码:从 BSP 配置到 TCP 服务器跑通

4.1 在 BSP 设置中勾选 lwIP 库,并调整以下三个关键参数

打开 SDK,新建一个 Application Project,在 Board Support Package 界面里点击Manage BSP或直接在选择列表里勾选lwipxilnet相关库。BSP 生成后,在lwip141lwip211(版本号取决于你的 SDK 版本)这个库的配置里,检查lwipopts.h是否被自动包含。关键参数之一是NO_SYS,默认是 1,代表裸机模式,这个值对了传输才能正常;如果 SDK 版本不同,出现了NO_SYS为 0 的情况,你需要手动把它改回 1。

另一个核心参数是内存池大小。打开lwipopts.h,你会看到类似下面的宏定义。建议把MEM_SIZE设置为(1024 * 1500)也就是 1.5MB 左右,虽然这个值听起来很大,但 ZYNQ 的 DDR 完全承受得住。同时PBUF_POOL_SIZE增加到 32,否则在 TCP 窗口较大的时候,接收方向的内存会被耗尽。

/* lwipopts.h 关键配置片段 */ #define NO_SYS 1 #define MEM_STATIC 1 #define MEM_SIZE (1024 * 1024 * 2) #define MEMP_NUM_PBUF 32 #define PBUF_POOL_SIZE 32 #define TCP_MSS 1460 #define TCP_WND (4 * TCP_MSS) #define TCP_SND_BUF (4 * TCP_MSS) #define SYS_LIGHTWEIGHT_PROT 0

如果打算跑高吞吐量,把这个TCP_SND_BUF再往上调整到 8 倍 MSS。但要注意,发送缓冲区越大,重传时的内存占用越高,这对裸机环境下的稳定性是考验。我一般用TCP_SND_BUF (8 * TCP_MSS)配合 TCP_WND 4 倍 MSS,可以在不拖垮 CPU 的情况下跑到近百兆吞吐。

4.2 从 lwIP 的 echo 服务器示例出发,改造出自己的 TCP 服务器结构

SDK 自带的 lwIP 示例库中有一个echo_server(或tcp_perf_server),直接从那里开始改是最快的方法。它已经帮你做好了platform_initnetif_addtcpip_thread这些底层初始化。你需要做的是在echo_server的基础上,把回调函数的逻辑改写成你自己的协议处理。

核心结构通常是这样的:先调用tcp_new()创建一个 TCP PCB(协议控制块),然后调用tcp_bind()将它绑定到端口 5001 之类的端口上。接着tcp_listen()把这个 PCB 变成监听模式,最后注册tcp_accept回调。当有客户端连接时,lwIP 调用这个回调,在回调内部再注册tcp_recv回调来接收数据。如果你见过 Linux 网络编程的acceptrecv函数,这里的 raw API 本质上是把它们拆散到回调函数里了。

/* 基于 lwIP raw API 的 TCP 服务器初始化 */ static struct tcp_pcb *server_pcb; void tcp_server_init(void) { err_t err; server_pcb = tcp_new(); // 分配 TCP 控制块 if (server_pcb == NULL) { xil_printf("tcp_new failed\r\n"); return; } err = tcp_bind(server_pcb, IP_ADDR_ANY, 5001); // 绑定所有本地地址和端口 if (err != ERR_OK) { xil_printf("tcp_bind failed: %d\r\n", err); return; } server_pcb = tcp_listen(server_pcb); // 进入监听状态 tcp_accept(server_pcb, tcp_server_accept); // 注册 accept 回调 xil_printf("TCP server listening on port 5001\r\n"); }

代码里tcp_bind返回的错误最常见是ERR_USE,意思是端口被占用。排查方法是检查是否在同一个 BSP 里跑起了两个服务器实例,或者是上一次运行时没有正确关闭 PCB,导致状态没被清理。由于裸机环境没有进程退出概念,重复调用tcp_server_init可能会造成控制块泄漏,需要先释放旧的 PCB。

4.3 数据接收与发送:理解tcp_recv回调在中断上下文里的坑

TCP 连接建立后,数据到达会触发 lwIP 内部的tcp_recv回调函数。这个回调是在 lwIP 的主循环中被调用的,但它可能运行在中断保护的上下文里。在这个回调里,你要立刻处理掉pbuf中的数据,然后调用tcp_recved()函数告知协议栈数据已经被应用层消费,否则 TCP 的接收窗口会被填满,客户端发送会停止。这是新手最容易忽略的关键点。

/* TCP 接收回调 */ err_t tcp_server_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, err_t err) { if (p == NULL) { /* 对端关闭连接,释放 PCB */ tcp_close(pcb); return ERR_OK; } /* 这里处理 p->payload 中的数据 */ tcp_recved(pcb, p->len); // 告知协议栈数据已取走 tcp_write(pcb, p->payload, p->len, 1); // 回显数据 pbuf_free(p); // 释放 pbuf 缓冲 return ERR_OK; }

注意tcp_write并不保证数据立即发送,它只是把数据拷贝到发送缓冲队列中,真正发送动作发生在这个回调返回之后。如果回调里调用tcp_write的返回值为ERR_MEM,说明发送缓冲区不够,你需要等待tcp_sent回调触发后再继续写入。处理大文件传输时,不能只在tcp_recv里塞数据,而要在tcp_sent回调里实现流量控制,否则用裸机 lwIP 传大文件的体验会非常差。

pbuf_free的调用时机也比想象的更微妙:tcp_recv给你的这个p,在使用完毕之后必须释放。如果只调用了tcp_recved而忘了释放缓冲,只需要几次收发内存就泄漏了。我的习惯是先用pbuf_copy_partial把数据提取到自己的应用层 buffer,然后再统一释放,这样可以避免在回调里处理耗时的业务逻辑。

4.4 设置静态 IP 和 MAC 地址,避免产生环回干扰和冲突

默认的 SDK 示例代码会从xparameters.h里读取 MAC 地址高位,但如果你不显式设置,MAC 地址可能全是 0。连接到路由器或交换机时,全 0 的 MAC 地址可能导致报文被丢弃。使用netif->hwaddr[0]显式赋值是比较可靠的做法。同时 IP 地址默认示例是 192.168.1.10,如果你的 PC 不在这个网段,TCP 客户端根本找不到服务器。

/* netif 的 IP 和 MAC 设置片段 */ #define SERVER_IP_ADDR "192.168.1.10" #define SERVER_NETMASK "255.255.255.0" #define SERVER_GW "192.168.1.1" struct netif server_netif; ip4_addr_t ipaddr, netmask, gw; IP4_ADDR(&ipaddr, 192, 168, 1, 10); IP4_ADDR(&netmask, 255, 255, 255, 0); IP4_ADDR(&gw, 192, 168, 1, 1); netif_add(&server_netif, &ipaddr, &netmask, &gw, NULL, xemacpsif_init, tcpip_input); netif_set_default(&server_netif); netif_set_up(&server_netif);

程序启动后,串口打印的 IP 地址要跟实际网卡设置一致才能通。如果在调试时发现能 ping 通但 TCP 连接不上,请检查防火墙或路由器 AP 隔离,这不是 ZYNQ 的问题。

5. 验证与踩坑:QEMU 仿真、SecureCRT 监听和吞吐监控三板斧

5.1 用 QEMU 在没有网线的地方验证 TCP 连通性

ZYNQ SDK 内置的 QEMU 可以模拟 ARM Cortex-A9 处理器和外设,其中网络部分是通过 tap 设备连接到宿主机上的。使用 QEMU 验证 lwIP 的时间成本很低,不需要上板,不用插网线。在 SDK 的Run配置里选择Single Application Debug,然后在Target Setup里勾选Use QEMU。注意 QEMU 模式下的 GEM 驱动和实际板卡略有差异,它使用的虚拟 PHY 是xemacps仿真模型,所以 MAC 时序和实际 PHY 不同。但对于测试 TCP connect 和简单回显逻辑,QEMU 已经够用。

qemu-system-arm -machine xilinx-zynq-a9 -m 1G \ -serial mon:stdio -net nic -net tap,ifname=tap0,script=no,downscript=no \ -kernel tcp_server.elf

QEMU 启动后,在宿主机上直接用 netcat 连接即可验证 TCP 端口是否工作正常。

nc -vz 192.168.1.10 5001

5.2 三个必查参数:lwIP: 0x1TCP_PCB状态和pbuf泄漏

在串口终端连接上开发板之后,用xil_printf打印 lwIP 协议栈内部的统计信息是最直接的验证手段。常见的做法是周期性地打印tcpActivePCBstcp_tw_pcbs的数量,来确认连接没有被残留 TIME-WAIT 状态的 PCB 占满。ZYNQ 7020 裸机上没有操作系统帮你回收 TCP 连接资源,如果客户端频繁断开重连,经过几天运行就会发现tcp_new失败。解决方案是把MEMP_NUM_TCP_PCB从默认的4调整为16或更高。

QEMU 里跑通不算完,真正的边缘用例在硬件上:比如当 PHY 芯片的 link 状态变化时(拔网线再插回),lwIP 驱动需要在netif里处理链路中断。GEM 驱动会周期性地读 PHY 寄存器来感知链路状态,如果你的代码里没有写xemacps_phy检测逻辑,则会出现插拔网线后 TCP 连接虽然存在但完全无法收发数的情况。

5.3 用 ethtool 和 tcpdump 双端验证吞吐与重传

前面几步都通过后,如果想要确认服务器达到了可用性能,可以用 PC 端的tcpdumpethtool做一个快速的双端验证。先确认板子的网口速率实为千兆,而不是降级到了百兆。在 PC 上执行:

sudo ethtool eth0

输出里的Speed: 1000Mb/s与实际 PHY 配置一致才算正常。此时用iperf测试吞吐是一个业内通用做法,但 SDK 示例不一定自带 iperf 服务器。你可以写一个简单的 TCP 服务器循环接收数据并丢弃,在 PC 上运行客户端即可得到初步吞吐。

# PC 端 Python 脚本,持续向 ZYNQ 发送 1KB 数据包 import socket s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect(("192.168.1.10", 5001)) payload = b"x" * 1024 for i in range(100000): s.send(payload) s.close()

然后在 PC 上的抓包结果里查看是否有大量 TCP Retransmission。若存在大量重传,说明接收方的内存池或者 DMA 描述符数量不足,报文的ps7_netif处理赶不上入口速率。把TXRX的描述符数量由默认的1632提高一倍,是成本最低的解决方式。注意描述符数量的调整在两个位置:一是xemacpsif_dma.cRX BDTX BD的数组大小,二是xemacps_hw.h中的XEMACPS_RX_BD_CNT宏,两处必须同时改,否则驱动访问越界。

关于tcp_poll定时器的使用也值得多写一笔。lwIP 的 raw API 中,如果长时间不调用tcp_recvtcp_write,某些 PCB 可能处于半开状态而不被感知。在服务器中注册一个tcp_poll回调,周期最长为 5 秒,用于检测 PCB 是否已失效,是防止裸机长时间运行连接表耗尽的好习惯。

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

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

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

立即咨询