ZYNQ 7100裸跑LWIP从TCP切换到UDP通信实战指南
发布时间:2026/10/15 3:45:03 锦皓数字建站

1. 从TCP到UDP为什么要在7100裸跑环境里换协议栈做ZYNQ裸跑bare-metal开发的朋友大概率都经历过这样一个阶段LWIP协议栈跑通了ping通了TCP回环测试也过了心里正美滋滋结果一到实际业务场景就发现——TCP那套三次握手、重传、拥塞控制的机制在裸机环境下反而成了拖累。我这次要聊的就是把7100平台上裸跑的LWIP从TCP模式切换到UDP通信方式的完整过程以及背后那些不看文档根本想不到的细节。先交代一下背景。ZYNQ 7100是Xilinx Zynq-7000系列里比较经典的一款SoCPS端双核Cortex-A9加上PL端FPGA逻辑做嵌入式网络通信非常合适。裸跑意味着没有Linux、没有操作系统调度所有事情都得自己管——包括网卡初始化、内存分配、协议栈轮询。LWIP作为轻量级IP协议栈天然适合这种场景但它的默认配置和示例代码大多围绕TCP展开UDP虽然更简单反而资料零散。那为什么非要换成UDP核心原因有三个。第一实时性要求。TCP的确认重传机制在丢包时会引入不可控的延迟抖动而UDP只管发延迟稳定可预测。第二资源开销。TCP需要维护连接状态、滑动窗口、重传队列每个连接都要占内存裸跑环境下RAM本来就紧张。第三通信模型匹配。很多工业采集、传感器上报场景本身就是发完即忘的单向数据流用TCP反而是杀鸡用牛刀。但这里有个误区要提前说清楚UDP不是简化版TCP它是完全不同的通信范式。你不能指望它帮你保证可靠交付该自己做的校验、序号、重传逻辑一个都跑不掉。我见过不少项目从TCP切到UDP之后数据丢了都不知道最后排查半天发现是应用层没做任何保护。所以这篇文章不只是讲怎么改更要讲改完之后要注意什么。提示裸跑LWIP的UDP改造难点不在协议栈本身而在内存池配置、回调注册时机、以及和PL端数据交互的同步问题。这三点没处理好跑起来也是时通时断。下面我按照实际改造的顺序从环境确认、协议栈配置、代码改造、调试验证到性能优化一步步拆开讲。每个环节都会说明为什么这么做以及我踩过的坑。2. 改造前的环境盘点与LWIP配置基线确认2.1 7100裸跑工程的现状梳理动手改代码之前先得把现有工程摸清楚。我拿到的是一个已经跑通TCP echo的裸跑工程基于Xilinx SDK现在叫Vitis创建PS端配置了GEM0以太网控制器PHY用的是常见的RTL8211系列时钟25MHz。LWIP版本是Xilinx适配过的1.4.1或者2.x分支具体版本不同API差异不小这点必须先确认。确认版本的方法很简单在lwipopts.h或者lwip源码的init.c里找LWIP_VERSION宏。1.4.1和2.x最大的区别在于2.x引入了netif的output回调签名变化、pbuf管理更严格、以及sys_thread相关接口的调整。裸跑环境下虽然不用线程但sys_arch那套模拟层还是要适配的。我当时的工程结构大致是这样main.c里初始化平台、初始化LWIP、然后进一个while循环调用xemacif_input处理接收。TCP部分用的是tcp_new、tcp_bind、tcp_listen、tcp_accept这一套。要改成UDP这些全都要换掉但底层网卡驱动、netif注册、sys_check_timeouts这些是共用的不用动。这里有个经验先把TCP相关代码用宏隔离出来别直接删。因为改造过程中你很可能需要回退对比而且TCP的某些初始化逻辑比如netif的hwaddr设置、ip_addr配置UDP也要用。我一般会加一个#define USE_UDP 1的开关把两套逻辑都留着调试时随时切换。2.2 lwipopts.h里必须检查的几个关键项裸跑LWIP的配置文件lwipopts.h是改造的核心战场。TCP和UDP对配置的要求差别很大下面这几项必须逐一确认。配置项TCP场景典型值UDP场景建议值说明LWIP_UDP0或11必须打开否则UDP API全是空宏MEMP_NUM_UDP_PCB44~8UDP控制块数量每个socket占一个MEMP_NUM_PBUF1616~32pbuf数量UDP发送频繁时要加PBUF_POOL_SIZE1616~32pbuf池大小接收突发数据要够MEM_SIZE16001600~4096堆大小UDP不需要太大但别太小LWIP_SO_RCVBUF01需要设置接收缓冲区时打开UDP_TTL255255默认TTL一般不用改LWIP_NETIF_TX_SINGLE_PBUF01发送单pbuf优化UDP常用重点说几个容易踩坑的。MEMP_NUM_UDP_PCB如果设成1那你只能创建一个UDP socket想同时收发多个端口就抓瞎了。我一般设4留够余量。PBUF_POOL_SIZE在UDP场景下要比TCP更关注因为UDP没有流控对端可能一口气发一堆包过来池子小了直接丢包而且丢得悄无声息。还有一个隐藏项LWIP_NETIF_TX_SINGLE_PBUF。这个选项打开后发送时如果pbuf链只有一个节点就直接用不拷贝。UDP发送小包比如几十字节的传感器数据时这个优化能省一次内存拷贝对裸跑环境很有意义。但要注意打开它之后你的发送缓冲区生命周期管理要更小心别发完就释放了还在被DMA读。注意改完lwipopts.h一定要全量重新编译LWIP很多配置是通过宏在编译期决定结构体大小的增量编译可能出诡异问题。我吃过这个亏改了一个宏没重编跑起来内存越界查了一整天。2.3 网卡驱动与netif注册的共用部分这部分TCP和UDP完全一样但我要强调一个容易被忽略的点netif的mtu和flags设置。UDP发送大包时如果MTU设成1500而你的pbuf链没处理好分片底层驱动可能直接报错。裸跑环境下我建议把应用层单包控制在1472字节以内1500减去IP头20和UDP头8避免IP分片。分片在裸跑LWIP里支持得并不好能避则避。netif注册的代码大概长这样我贴出来方便对照struct netif server_netif; ip_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, xemacif_init, ethernet_input); netif_set_default(server_netif); netif_set_up(server_netif);xemacif_init是Xilinx提供的网卡初始化ethernet_input是LWIP的以太网输入函数。这两行不用改。但netif_set_up之后记得在while循环里调用xemacif_input(server_netif)否则收不到包。这个函数内部会处理DMA接收描述符把数据喂给LWIP。3. UDP通信改造的核心代码拆解3.1 创建UDP控制块与绑定端口TCP时代我们用tcp_new创建控制块UDP对应的是udp_new。这个函数从MEMP_NUM_UDP_PCB池里分配一个PCB返回指针。如果返回NULL说明池子满了或者LWIP_UDP没打开。struct udp_pcb *upcb; err_t err; upcb udp_new(); if (upcb NULL) { xil_printf(udp_new failed\r\n); return; } err udp_bind(upcb, IP_ADDR_ANY, 8080); if (err ! ERR_OK) { xil_printf(udp_bind failed: %d\r\n, err); udp_remove(upcb); return; }udp_bind的第二个参数是本地IP用IP_ADDR_ANY表示绑定所有网卡地址。第三个是端口我习惯用8080实际项目按需改。绑定失败常见原因有两个端口被占用裸跑环境一般不会或者PCB状态不对。udp_bind内部会检查pcb-local_port是否已经是绑定状态重复绑定会返回ERR_USE。这里有个细节UDP的bind和TCP的bind语义不同。TCP bind之后还要listenUDP bind完就能收发了。所以UDP的代码流程更短但也意味着没有连接建立这个状态可以依赖你得自己维护对端信息。3.2 接收回调的注册与数据提取UDP接收靠回调。注册回调用udp_recvudp_recv(upcb, udp_receive_callback, NULL);第三个参数是自定义的arg会原样传给回调。我一般把一些上下文指针放这里比如统计结构体。回调函数原型是void udp_receive_callback(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port)p是收到的数据pbuf链addr和port是发送方地址端口。关键点回调里必须负责释放p否则内存泄漏跑一会儿就崩。释放用pbuf_free(p)。提取数据时要注意pbuf可能是链式的。如果数据超过一个pbuf的容量通常PBUF_POOL_BUFSIZE默认几百字节就会分成多个节点。稳妥的做法是用pbuf_copy_partialvoid udp_receive_callback(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { u8_t buffer[256]; u16_t len; if (p NULL) return; len p-tot_len; if (len sizeof(buffer)) len sizeof(buffer); pbuf_copy_partial(p, buffer, len, 0); // 处理buffer里的数据 process_data(buffer, len); pbuf_free(p); }pbuf_copy_partial的第四个参数是偏移量0表示从头拷。这个函数会自动处理链式pbuf比手动遍历p-next安全得多。我早期图省事直接读p-payload结果遇到大包只读到第一段数据截断排查了好久。提示回调是在xemacif_input的调用上下文里执行的也就是你的主循环里。所以回调里千万别做耗时操作否则会阻塞整个协议栈轮询。需要复杂处理的把数据拷出来放到队列主循环里再处理。3.3 发送路径udp_sendto与pbuf分配发送比接收直观。核心API是udp_sendtoerr_t udp_send_data(struct udp_pcb *pcb, u8_t *data, u16_t len, ip_addr_t *dest_ip, u16_t dest_port) { struct pbuf *p; err_t err; p pbuf_alloc(PBUF_TRANSPORT, len, PBUF_RAM); if (p NULL) { return ERR_MEM; } pbuf_take(p, data, len); err udp_sendto(pcb, p, dest_ip, dest_port); pbuf_free(p); return err; }pbuf_alloc的第二个参数是长度第三个是类型。PBUF_RAM表示从堆里分配连续内存适合发送。接收方向LWIP一般用PBUF_POOL从池里分配速度快但可能链式。pbuf_take把用户数据拷进pbuf。注意这个函数在LWIP 2.x里叫pbuf_take1.4.1里可能叫pbuf_take也有但行为略有差异老版本要确认一下。发送完成后pbuf_free。这里有个坑udp_sendto返回ERR_OK只表示数据交给了网卡驱动不代表对端收到了。UDP没有确认机制发出去就不管了。所以如果你需要知道发送是否成功得自己在应用层做ACK。3.4 从TCP代码迁移时的常见编译错误直接删TCP代码加UDP代码编译大概率报一堆错。我整理了几个高频问题错误信息原因解决undefined reference to tcp_newlwipopts.h里LWIP_TCP被关要么打开TCP要么彻底删TCP调用udp_newimplicit declarationLWIP_UDP没开设LWIP_UDP 1pbuf_takeundefinedLWIP版本差异查版本1.4.1用pbuf_take需确认struct udp_pcbincomplete头文件没包含#include lwip/udp.hERR_USEundefinederr.h没包含#include lwip/err.h最隐蔽的是第一个。Xilinx的BSP有时候默认把LWIP_TCP设成0来省空间你代码里还留着TCP调用链接就炸。所以改造时要么全删干净要么把LWIP_TCP打开。我建议全删省内存。4. 裸跑环境下的调试链路与验证方法4.1 用ping和网络调试助手做基础连通性验证代码编译通过只是第一步能不能通还得实测。我的验证顺序是这样的第一步ping测试。UDP改造不影响ICMP所以ping应该照常通。如果ping不通说明netif或者网卡驱动有问题跟UDP无关先解决底层。ping通说明IP层、ARP、网卡都正常。第二步PC端发UDP包。用网络调试助手比如常见的串口网络调试工具往板子的8080端口发数据。板子端在回调里打印收到的内容。这一步验证接收路径。第三步板子主动发UDP包。板子定时往PC的某个端口发数据PC端用调试助手监听。这一步验证发送路径。这三步走完基本通信就通了。但要注意PC端防火墙可能拦UDP尤其是Windows。我第一次调的时候板子发出去PC收不到查了半天发现是防火墙把入站UDP挡了。关掉防火墙或者加规则就行。4.2 抓包分析Wireshark在裸跑调试中的实际用法裸跑环境没有系统日志抓包是最有效的调试手段。在PC端或者交换机镜像口上跑Wireshark过滤udp.port 8080能看到完整的收发过程。几个典型问题的抓包特征板子发了但PC没收到Wireshark里能看到板子发出的UDP包但PC应用没显示。多半是PC防火墙或者应用绑定地址不对。PC发了但板子没收到Wireshark里能看到PC发出的包但板子回调没触发。检查板子netif的IP是否和PC同网段或者udp_bind的端口是否匹配。包发出去了但内容不对看Wireshark里的payload对比发送代码。常见的是字节序问题网络序是大端x86是小端pbuf_take不做转换需要自己用htons/htonl。我印象最深的一次板子发出去的数据在Wireshark里看payload全是乱的查了半天发现是发送缓冲区是局部变量udp_sendto还没把数据DMA出去函数就返回了缓冲区被栈回收。后来改成静态缓冲区或者等发送完成再释放问题解决。这就是前面说的LWIP_NETIF_TX_SINGLE_PBUF打开后要注意的生命周期问题。4.3 内存与pbuf耗尽的排查思路裸跑LWIP最怕内存耗尽。UDP场景下如果接收回调里忘了pbuf_free或者发送频率太高pbuf池来不及回收很快就会pbuf_alloc返回NULL然后通信中断。排查方法在pbuf_alloc失败的地方加打印同时打印memp_get_num_free之类的统计。LWIP提供了memp_stats和memp_get_num_free可以实时看池子剩余。我一般会在主循环里每秒打印一次关键池子的剩余量跑一段时间看趋势。如果持续下降就是泄漏。另一个隐蔽问题是MEM_SIZE太小。UDP发送时pbuf_alloc(PBUF_RAM)从堆里分配堆不够就失败。MEM_SIZE默认1600如果同时有多个发送任务很容易不够。我一般设4096代价是占4KB RAM对7100来说可以接受。注意LWIP的内存池和堆是两套机制。MEMP_NUM_PBUF管的是pbuf结构体池PBUF_POOL_SIZE管的是pbuf数据池MEM_SIZE管的是堆。三者任何一个不够都会导致分配失败排查时要分别看。5. 性能调优与稳定性加固的实战经验5.1 发送频率与主循环轮询的平衡裸跑LWIP的收发都靠主循环轮询xemacif_input和sys_check_timeouts。如果主循环里还有其他耗时任务轮询间隔变长接收缓冲区可能溢出。UDP没有流控对端发太快板子处理不过来就直接丢。我的做法是把主循环拆成时间片网络轮询保证至少每1ms执行一次。具体实现可以用XTime_GetTime读全局定时器判断距离上次轮询是否超过阈值。如果其他任务实在耗时考虑把网络轮询放到定时器中断里但要注意中断上下文里不能调用会阻塞的LWIP API。发送频率方面UDP发送本身很快但底层DMA发送需要时间。如果连续调用udp_sendto驱动可能返回ERR_MEM或者ERR_BUF。稳妥的做法是发送前检查驱动状态或者加一个发送完成回调。Xilinx的xemacif驱动支持发送完成中断可以注册回调在回调里标记可发送主循环据此控制节奏。5.2 应用层可靠性补偿序号、校验与超时UDP不保证可靠但很多业务场景又需要一定可靠性。我的经验是在应用层加一层轻量协议成本不高但效果明显。具体做法每个UDP包前面加一个自定义头包含序号2字节、时间戳4字节、CRC校验2字节。接收端检查序号是否连续不连续就记录丢包CRC不对就丢弃。发送端维护一个待确认队列收到对端ACK就移除超时没ACK就重发。这套机制听起来像TCP但比TCP轻量得多而且完全可控。裸跑环境下我一般只做序号和CRC重传看业务需求。如果是传感器周期上报丢一两个包无所谓就不重传如果是控制指令必须重传。这里的关键是别把应用层协议做太重否则就失去了用UDP的意义。我见过有人在UDP上实现完整的滑动窗口和拥塞控制那还不如直接用TCP。5.3 长时间运行的稳定性观察要点裸跑系统跑几分钟没问题不代表能跑几天。UDP改造后我一般会做至少24小时的稳定性测试观察几个指标丢包率发送端计数发送包数接收端计数收到包数差值就是丢包。局域网内正常应该接近0。内存趋势前面说的池子剩余量必须平稳不能持续下降。CPU占用裸跑没有操作系统但可以通过主循环计数估算。如果主循环频率明显下降说明有地方阻塞了。温度7100跑网络时PS端会发热长时间运行要关注散热尤其是工业环境。我遇到过一次跑几小时后通信中断的情况最后定位到是sys_check_timeouts没被定期调用导致ARP缓存过期后没刷新。ARP在UDP场景下同样重要因为发UDP前要先解析对端MAC。sys_check_timeouts负责处理ARP超时必须定期调。5.4 与PL端数据交互时的同步处理7100的PL端FPGA经常要往PS端送数据再通过UDP发出去。这里有个同步问题PL写数据到共享内存PS读出来发UDP如果PL写得快PS发得慢数据会覆盖。我的方案是用双缓冲加标志位。PL写缓冲区A写完后置标志PS检测到标志切换去读A同时PL写缓冲区B。这样两边互不阻塞。标志位要用volatile修饰防止编译器优化掉。如果数据量更大可以用FIFOPL端用AXI-Stream写PS端用DMA读。UDP发送时从共享内存拷到pbuf这一步要注意cache一致性。7100的PS端有L1/L2 cachePL写的数据可能还在cache里没落到DDR。发送前要Xil_DCacheFlush或者用非cache地址访问。这个坑很隐蔽表现是发出去的数据偶尔是旧的查起来很头疼。6. 几个让我印象深刻的踩坑记录6.1 回调里调用udp_sendto导致的死锁假象有一次我在接收回调里直接调用udp_sendto回发数据结果程序卡死。查了半天发现不是死锁是pbuf耗尽。回调执行时接收用的pbuf还没释放我又申请发送pbuf池子不够就卡在分配循环里。后来改成回调里只拷贝数据到队列主循环里再发送问题解决。这个问题的本质是回调上下文里资源紧张。接收回调执行时LWIP内部可能还持有一些锁或者资源此时再做重操作容易出问题。最佳实践是回调只做数据搬运复杂逻辑放主循环。6.2 端口绑定成功但收不到包的怪事还有一次udp_bind返回ERR_OK但就是收不到包。抓包看PC确实发了板子网卡也收到了DMA描述符有数据但回调不触发。最后发现是udp_recv注册回调的代码在udp_bind之前执行了绑定后回调被重置。调整顺序先bind再recv问题解决。这个坑的教训是LWIP的API调用顺序有讲究。udp_new→udp_bind→udp_recv这个顺序不能乱。虽然文档没明说但实际代码逻辑是这样。6.3 大数据包分片引发的接收异常UDP单包超过MTU会触发IP分片。裸跑LWIP对分片重组支持有限我测试时发了一个2000字节的UDP包接收端只收到前1472字节后面的丢了。后来把应用层包大小限制在1400字节以内问题消失。如果业务确实需要发大包两个方案一是应用层自己分片每个UDP包不超过1400字节接收端按序号重组二是调大MTU但以太网标准MTU就是1500调大需要整条链路支持不现实。所以还是应用层分片靠谱。6.4 中断优先级配置不当导致的丢包7100的GEM网卡中断优先级如果配低了被其他高优先级中断打断DMA接收描述符可能溢出。我一开始把网卡中断设成最低跑高速UDP时丢包严重。后来调到中等优先级丢包率明显下降。中断优先级配置在xscugic里具体值要看系统里其他中断的分布。原则是网卡中断不能太低但也不能高到影响关键实时任务。我一般设成比普通任务高、比硬实时任务低。7. 改造完成后的代码组织与复用建议整套UDP改造做完代码最好按功能分层组织方便后续维护和移植。我的习惯是分三层驱动层Xilinx的xemacif不动、协议层LWIP配置和初始化、应用层UDP收发和业务逻辑。应用层再拆成udp_comm.c和udp_comm.h对外暴露几个函数udp_comm_init、udp_comm_send、udp_comm_register_handler。这样业务代码不直接碰LWIP API换协议栈或者换芯片时只改这一层。lwipopts.h里的UDP相关配置单独用注释块标出来写清楚每个值的含义和调整依据。下次别人接手或者自己过几个月回头看能快速理解。还有一点把调试打印做成可开关的宏。裸跑环境串口打印很耗时间正式发布时关掉调试时打开。我一般用#define UDP_DEBUG 1控制打印内容包括收发计数、错误码、池子剩余量。这套东西我在几个项目里复用下来基本改改IP和端口就能用。UDP裸跑LWIP没有想象中复杂但细节确实多尤其是内存和回调这两块值得花时间吃透。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。