1. 从TCP到UDP:为什么要在7100裸跑环境里换协议栈
做ZYNQ裸跑(bare-metal)开发的朋友,大概率都经历过这样一个阶段:LWIP协议栈跑通了,ping通了,TCP回环测试也过了,心里正美滋滋,结果一到实际业务场景就发现——TCP那套三次握手、重传、拥塞控制的机制,在裸机环境下反而成了拖累。我这次要聊的就是把7100平台上裸跑的LWIP从TCP模式切换到UDP通信方式的完整过程,以及背后那些不看文档根本想不到的细节。
先交代一下背景。ZYNQ 7100是Xilinx Zynq-7000系列里比较经典的一款SoC,PS端双核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_UDP | 0或1 | 1 | 必须打开,否则UDP API全是空宏 |
MEMP_NUM_UDP_PCB | 4 | 4~8 | UDP控制块数量,每个socket占一个 |
MEMP_NUM_PBUF | 16 | 16~32 | pbuf数量,UDP发送频繁时要加 |
PBUF_POOL_SIZE | 16 | 16~32 | pbuf池大小,接收突发数据要够 |
MEM_SIZE | 1600 | 1600~4096 | 堆大小,UDP不需要太大但别太小 |
LWIP_SO_RCVBUF | 0 | 1 | 需要设置接收缓冲区时打开 |
UDP_TTL | 255 | 255 | 默认TTL,一般不用改 |
LWIP_NETIF_TX_SINGLE_PBUF | 0 | 1 | 发送单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, ðernet_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之后还要listen,UDP bind完就能收发了。所以UDP的代码流程更短,但也意味着没有"连接建立"这个状态可以依赖,你得自己维护对端信息。
3.2 接收回调的注册与数据提取
UDP接收靠回调。注册回调用udp_recv:
udp_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_partial:
void 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_sendto:
err_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_take,1.4.1里可能叫pbuf_take也有,但行为略有差异,老版本要确认一下。
发送完成后pbuf_free。这里有个坑:udp_sendto返回ERR_OK只表示数据交给了网卡驱动,不代表对端收到了。UDP没有确认机制,发出去就不管了。所以如果你需要知道发送是否成功,得自己在应用层做ACK。
3.4 从TCP代码迁移时的常见编译错误
直接删TCP代码加UDP代码,编译大概率报一堆错。我整理了几个高频问题:
| 错误信息 | 原因 | 解决 |
|---|---|---|
undefined reference to tcp_new | lwipopts.h里LWIP_TCP被关 | 要么打开TCP,要么彻底删TCP调用 |
udp_newimplicit declaration | LWIP_UDP没开 | 设LWIP_UDP 1 |
pbuf_takeundefined | LWIP版本差异 | 查版本,1.4.1用pbuf_take需确认 |
struct udp_pcbincomplete | 头文件没包含 | #include "lwip/udp.h" |
ERR_USEundefined | err.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修饰,防止编译器优化掉。如果数据量更大,可以用FIFO,PL端用AXI-Stream写,PS端用DMA读。
UDP发送时,从共享内存拷到pbuf这一步要注意cache一致性。7100的PS端有L1/L2 cache,PL写的数据可能还在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没有想象中复杂,但细节确实多,尤其是内存和回调这两块,值得花时间吃透。