1. 项目缘起与整体设计思路
1.1 为什么要在裸机环境下折腾UDP
做过嵌入式网络开发的人都有一个共识:在资源受限的平台上跑通网络协议栈,从来都不是一件轻松的事。这次要聊的项目,是在ZYNQ平台上,把原本基于LWIP协议栈的TCP通信方式,改成UDP通信方式,而且整个系统是裸跑的,没有操作系统支撑。
先说说这个项目的背景。ZYNQ系列芯片内部集成了ARM处理器和FPGA逻辑资源,很多做工业控制、数据采集、视频传输的朋友都会选它。在裸机环境下使用LWIP协议栈,通常默认的示例代码都是TCP Server或者TCP Client模式。TCP的好处是可靠传输,有重传机制、有拥塞控制、有连接管理,但代价就是协议栈本身占用的内存和CPU资源更多,而且每次通信都需要维护连接状态。
UDP就不一样了。UDP是无连接协议,发送数据之前不需要建立连接,收到数据也不需要确认。对于周期性上报传感器数据、实时控制指令下发、视频流传输这类场景,UDP的低延迟和低开销优势非常明显。我这次要做的,就是把一个已经跑通的TCP通信工程,改成UDP方式,同时保证底层网络驱动、PHY芯片配置、DMA描述符这些环节都不出问题。
这个内容适合谁看?如果你正在用ZYNQ做裸机网络开发,或者你手头有一个基于LWIP的TCP工程想改成UDP,再或者你刚接触LWIP协议栈,想搞清楚UDP在裸机环境下到底怎么配置,那这篇经验分享应该能帮你省下不少调试时间。
1.2 整体方案选型与架构拆解
在动手改代码之前,我先梳理了一下整个系统的架构。ZYNQ的裸机网络通信,底层依赖的是GEM(Gigabit Ethernet MAC)控制器,外接PHY芯片,通过MIO或者EMIO接口连接。LWIP协议栈跑在ARM核上,通过中断或者轮询方式处理数据包收发。
原来的TCP方案,LWIP的配置里需要开启LWIP_TCP宏,同时lwipopts.h里要配置TCP相关的参数,比如TCP_MSS、TCP_WND、TCP_SND_BUF这些。改成UDP之后,这些TCP相关的配置可以关掉,但UDP相关的宏必须打开,比如LWIP_UDP。另外,MEMP_NUM_UDP_PCB这个参数决定了同时能有多少个UDP控制块,如果只是简单的点对点通信,默认值通常够用,但如果要做多播或者多个端口监听,就需要适当调大。
还有一个关键点:TCP模式下,LWIP内部会维护大量的连接状态结构体,内存占用比较大。改成UDP之后,内存压力会小很多,但这也意味着可靠性需要应用层自己保证。比如数据包丢了怎么办?顺序乱了怎么办?这些在TCP里是协议栈自动处理的,在UDP里就得自己写逻辑。
我选择的方案是:保留原有的GEM驱动和PHY配置,只修改LWIP的应用层接口和协议栈配置。具体来说,把tcp_echo或者tcp_server的示例代码替换成udp_echo或者自定义的UDP收发函数。这样改动量最小,风险也最可控。
提示:在裸机环境下改协议栈配置,一定要先备份原来的
lwipopts.h文件。这个文件里的参数非常多,改错一个可能导致协议栈完全跑不起来,而且报错信息往往很隐晦。
2. 核心细节解析与实操要点
2.1 LWIP协议栈的关键配置项
LWIP的配置文件lwipopts.h是整个协议栈行为的控制中心。从TCP改成UDP,有几个宏必须重点关注。
首先是LWIP_UDP,这个宏必须定义为1,否则UDP相关的代码根本不会被编译进去。其次是LWIP_TCP,如果你确定不再需要TCP,可以把它定义为0,这样能省下不少代码空间和内存。但要注意,有些LWIP的内部机制可能依赖TCP的某些结构体,关掉之后需要检查编译是否通过。
然后是MEMP_NUM_UDP_PCB,这个参数定义了UDP协议控制块的数量。每个UDP socket都需要一个PCB,如果你只用一个端口收发数据,设置为4就够了。但如果要做广播或者多播,可能需要调到8或者更多。
还有一个容易被忽略的参数是UDP_TTL,这是UDP数据包的生存时间。默认值通常是255,但在某些网络环境下,如果路由器跳数限制比较严格,可能需要调小。不过对于局域网内的通信,这个值一般不用改。
内存池方面,MEMP_NUM_PBUF和PBUF_POOL_SIZE决定了数据包缓冲区的数量。UDP通信如果数据量比较大,比如每秒要发几百个包,这两个值需要适当调大,否则会出现pbuf_alloc失败的情况。
// lwipopts.h 中与UDP相关的关键配置 #define LWIP_UDP 1 #define LWIP_TCP 0 #define MEMP_NUM_UDP_PCB 8 #define UDP_TTL 255 #define PBUF_POOL_SIZE 32 #define MEMP_NUM_PBUF 322.2 UDP控制块与端口绑定
在LWIP里,UDP通信的核心是udp_pcb结构体。创建一个UDP通信端点,需要调用udp_new()分配一个PCB,然后调用udp_bind()绑定本地IP和端口,最后用udp_recv()注册接收回调函数。
这里有一个细节:udp_bind()的时候,IP地址可以指定为具体的地址,也可以用IP_ADDR_ANY表示监听所有网络接口。在ZYNQ裸机环境下,通常只有一个网口,所以用IP_ADDR_ANY或者具体的本地IP都可以。
端口号的选择也有讲究。如果只是内部测试,可以用1024以上的端口,避免和系统保留端口冲突。如果是正式产品,最好遵循行业惯例,比如视频流常用5000或者8000系列端口。
注册接收回调函数是UDP通信的关键一步。udp_recv()的原型是:
void udp_recv(struct udp_pcb *pcb, udp_recv_fn recv, void *recv_arg);其中recv是一个函数指针,当有数据包到达绑定的端口时,LWIP会调用这个函数。回调函数的参数包括PCB指针、数据包pbuf、发送方IP和端口。你可以在回调里解析数据,然后决定是否回复。
注意:回调函数是在LWIP的上下文里执行的,如果你在回调里做了耗时操作,比如大量数据拷贝或者复杂计算,可能会阻塞其他网络包的处理。建议在回调里只做数据标记,把实际处理放到主循环里。
2.3 数据发送的两种方式
UDP发送数据有两种常用方式:一种是udp_send(),另一种是udp_sendto()。前者发送到PCB绑定时指定的目标地址,后者可以动态指定目标IP和端口。
在裸机环境下,我通常推荐用udp_sendto(),因为这样更灵活。比如你的设备需要向多个上位机发送数据,就可以在每次发送时指定不同的目标地址,而不需要创建多个PCB。
发送之前需要申请一个pbuf,把数据拷贝进去,然后调用发送函数。发送完成后,LWIP会自动释放pbuf,不需要手动释放。但要注意,如果发送失败,比如网络不可达,udp_sendto()会返回错误码,这时候需要根据错误码决定是否重试。
struct pbuf *p = pbuf_alloc(PBUF_TRANSPORT, data_len, PBUF_RAM); if (p != NULL) { pbuf_take(p, data, data_len); err_t err = udp_sendto(upcb, p, &dest_ip, dest_port); if (err != ERR_OK) { // 发送失败处理 } pbuf_free(p); }这里有一个经验:pbuf_alloc的第二个参数是数据长度,第三个参数是pbuf类型。PBUF_RAM表示数据存储在内存堆里,适合小数据量。如果数据量很大,比如超过1500字节,可能需要用PBUF_POOL或者链式pbuf。
3. 实操过程与核心环节实现
3.1 从TCP示例工程剥离出UDP框架
假设你手头有一个已经跑通的TCP Echo工程,工程里有一个tcp_echo.c文件,里面实现了TCP Server的初始化和数据收发。现在要改成UDP,第一步是把TCP相关的代码全部注释掉或者删除,然后新建一个udp_echo.c文件。
在udp_echo.c里,首先定义一个全局的udp_pcb指针:
static struct udp_pcb *g_udp_pcb = NULL;然后写一个初始化函数,在系统启动时调用:
void udp_echo_init(void) { err_t err; ip4_addr_t ipaddr, netmask, gw; // 设置本地IP、掩码、网关 IP4_ADDR(&ipaddr, 192, 168, 1, 10); IP4_ADDR(&netmask, 255, 255, 255, 0); IP4_ADDR(&gw, 192, 168, 1, 1); // 初始化LWIP lwip_init(); // 添加网络接口 netif_add(&g_netif, &ipaddr, &netmask, &gw, NULL, &xemacif_init, ðernet_input); netif_set_default(&g_netif); netif_set_up(&g_netif); // 创建UDP PCB g_udp_pcb = udp_new(); if (g_udp_pcb == NULL) { return; } // 绑定端口 err = udp_bind(g_udp_pcb, IP_ADDR_ANY, LOCAL_PORT); if (err != ERR_OK) { return; } // 注册接收回调 udp_recv(g_udp_pcb, udp_receive_callback, NULL); }这段代码里,xemacif_init是ZYNQ的GEM驱动初始化函数,ethernet_input是LWIP的以太网输入函数。这两个函数在原来的TCP工程里应该已经有了,直接复用即可。
3.2 接收回调函数的实现细节
接收回调函数是UDP通信的核心。当有数据包到达绑定的端口时,LWIP会调用这个函数。回调函数的签名是固定的:
void udp_receive_callback(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { if (p == NULL) { return; } // 处理接收到的数据 // p->payload 指向数据内容,p->len 是数据长度 // 如果需要回复,可以在这里调用udp_sendto // 释放pbuf pbuf_free(p); }这里有几个关键点。第一,p可能为NULL,这通常表示某种错误,直接返回即可。第二,p->payload指向数据内容,但要注意pbuf可能是链式的,如果数据长度超过单个pbuf的容量,需要遍历整个链。第三,处理完数据后必须调用pbuf_free(p)释放内存,否则会内存泄漏。
我在实际调试中发现,如果接收回调里做了太多事情,比如打印大量调试信息,会导致后续数据包丢失。因为串口打印是阻塞的,而网络数据包到达是异步的。所以建议在回调里只做数据拷贝,把处理逻辑放到主循环里。
3.3 主循环与网络轮询
裸机环境下没有操作系统,LWIP的协议栈处理需要手动轮询。通常的做法是在主循环里调用xemacif_input()或者类似的函数,检查是否有新的数据包到达。
while (1) { // 处理网络输入 if (g_netif_flags & XEMACIF_INPUT_FLAG) { xemacif_input(&g_netif); } // 其他任务 // ... // 周期性发送UDP数据 if (timer_expired(&send_timer)) { udp_send_data(); timer_reset(&send_timer); } }这里的xemacif_input是ZYNQ GEM驱动的接收处理函数,它会从DMA描述符里读取数据包,然后交给LWIP处理。如果中断模式,也可以在中断服务函数里设置标志位,主循环检查标志位后再调用。
发送定时器可以用简单的计数器实现,也可以用ZYNQ的私有定时器。如果对时间精度要求不高,用计数器就够了。
实操心得:在调试UDP通信时,建议先用网络调试助手在PC上发数据给ZYNQ,确认接收正常。然后再让ZYNQ主动发数据给PC,确认发送正常。最后再测试双向通信。这样分步骤调试,比一上来就搞双向通信效率高得多。
4. 常见问题与排查技巧实录
4.1 网络不通的排查思路
UDP通信调不通,原因可能有很多。我一般按照从底层到上层的顺序排查。
第一步,检查PHY芯片是否正常。用PHY的寄存器读取函数,确认链路状态寄存器显示Link Up。如果Link Down,检查网线、PHY地址、MDIO接口配置。
第二步,检查IP地址和网关配置。在PC上ping ZYNQ的IP,如果ping不通,说明底层网络还没通。这时候可以用Wireshark抓包,看看ZYNQ有没有发出ARP请求,PC有没有回应。
第三步,检查UDP端口是否绑定成功。udp_bind的返回值如果是ERR_OK,说明绑定成功。如果返回ERR_USE,说明端口已经被占用。
第四步,检查防火墙。PC上的防火墙可能会拦截UDP数据包,尤其是Windows防火墙。调试时可以先临时关闭防火墙。
4.2 数据包丢失的常见原因
UDP本身不保证可靠传输,但如果在局域网内丢包严重,那就不正常了。常见原因有这几个:
- pbuf数量不足。如果
PBUF_POOL_SIZE设置太小,高并发时会出现pbuf_alloc失败,导致数据包被丢弃。可以适当调大这个值。 - 接收回调处理太慢。如果回调里做了耗时操作,LWIP的接收队列会满,后续数据包会被丢弃。
- 网络带宽饱和。如果发送频率太高,超过了百兆或者千兆网的实际吞吐量,丢包是必然的。可以降低发送频率或者压缩数据。
- DMA描述符配置问题。ZYNQ的GEM驱动使用DMA描述符环,如果描述符数量太少,高速收发时会出现描述符耗尽。
4.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| ping不通 | PHY链路未建立 | 读取PHY状态寄存器 | 检查网线、PHY地址配置 |
| ping不通 | IP地址冲突 | 在PC上arp -a查看 | 更换IP地址 |
| UDP绑定失败 | 端口被占用 | 检查返回值 | 更换端口号 |
| 收不到数据 | 防火墙拦截 | 临时关闭防火墙 | 添加防火墙规则 |
| 收不到数据 | 回调未注册 | 检查udp_recv调用 | 确保回调函数正确注册 |
| 发送失败 | 目标地址不可达 | 检查目标IP和网关 | 确认目标设备在线 |
| 丢包严重 | pbuf不足 | 查看pbuf_alloc返回值 | 调大PBUF_POOL_SIZE |
| 丢包严重 | 处理太慢 | 检查回调函数耗时 | 优化回调逻辑 |
4.4 调试工具与技巧
调试UDP通信,我常用的工具有三个:网络调试助手、Wireshark、串口打印。
网络调试助手用来手动发送和接收UDP数据包,验证基本通信是否正常。Wireshark用来抓包分析,可以看到每个数据包的源地址、目标地址、端口、长度、内容。串口打印用来输出LWIP内部的调试信息,比如LWIP_DEBUG开启后可以看到协议栈的处理流程。
这里有一个小技巧:在lwipopts.h里开启UDP_DEBUG和IP_DEBUG,可以看到UDP和IP层的详细日志。但要注意,调试信息会占用大量串口带宽,正式发布时要关掉。
还有一个技巧:如果PC和ZYNQ不在同一个网段,UDP通信可能会失败。因为UDP广播包通常不会被路由器转发。所以调试时最好让PC和ZYNQ在同一个交换机下,或者配置正确的网关和路由。
5. 性能优化与进阶建议
5.1 提高UDP吞吐量的几个手段
如果UDP通信的数据量比较大,比如要做视频流传输,吞吐量就是一个关键指标。我试过几个优化手段,效果比较明显。
第一个是增大pbuf的大小。默认的PBUF_POOL_BUFSIZE通常是1500左右,如果每次发送的数据超过这个值,LWIP会分配多个pbuf组成链。链式pbuf的处理效率比单个pbuf低。可以把PBUF_POOL_BUFSIZE调到2048或者更大,减少链式pbuf的出现。
第二个是使用零拷贝发送。如果数据已经在内存里,可以用pbuf_alloced_custom或者直接构造pbuf,避免pbuf_take的内存拷贝。这个优化在高带宽场景下效果很明显。
第三个是调整DMA描述符数量。ZYNQ的GEM驱动里,XEMACPS_RX_BD_NUM和XEMACPS_TX_BD_NUM决定了DMA描述符环的大小。适当增大这两个值,可以减少描述符耗尽的概率。
5.2 应用层可靠性的简单实现
UDP不保证可靠,但很多场景又需要一定的可靠性。我的做法是在应用层加一个简单的确认和重传机制。
比如发送方在数据包里加一个序列号,接收方收到后回复一个ACK包,ACK包里带上收到的序列号。发送方如果在一定时间内没收到ACK,就重传。这个机制不需要太复杂,对于局域网内的通信,重传一两次通常就能成功。
还有一种做法是加前向纠错编码,比如在发送的数据里加入冗余信息,接收方即使丢了部分数据包也能恢复。但这个实现起来比较复杂,适合对实时性要求极高的场景。
5.3 从UDP扩展到多播和广播
UDP天然支持多播和广播。在LWIP里,要接收多播数据,需要调用igmp_joingroup加入多播组。要发送广播数据,把目标地址设为255.255.255.255或者子网广播地址即可。
多播在工业控制里很有用,比如一个控制器同时向多个执行器发送指令。广播则适合设备发现场景,比如新设备上线时广播一个发现包,等待服务器回应。
不过要注意,多播和广播会增加网络负载,如果网络里设备很多,可能会造成广播风暴。所以实际使用时,要合理规划多播组和广播域。
6. 个人实操体会与避坑总结
这个项目从TCP改到UDP,前前后后花了大概一周时间,其中大部分时间都花在调试网络不通和丢包问题上。踩过的坑主要有这几个:
第一个坑是PHY地址配错。ZYNQ的GEM控制器可以连接多个PHY,每个PHY有一个地址。如果地址配错,MDIO读写会失败,链路永远起不来。我一开始就是没注意这个,查了半天才发现。
第二个坑是lwipopts.h里的MEM_SIZE设置太小。TCP改成UDP后,虽然协议栈本身的内存需求小了,但如果应用层的数据缓冲区设得太大,还是会内存不足。后来我把MEM_SIZE从默认的1600调到了4096,问题就解决了。
第三个坑是接收回调里调用了printf。在裸机环境下,printf通常是阻塞的,如果回调里打印大量信息,会导致后续数据包丢失。后来我把调试信息改成写入环形缓冲区,在主循环里统一输出,问题就消失了。
第四个坑是没开LWIP_UDP宏。这个听起来很蠢,但确实发生了。因为原来的工程是TCP的,lwipopts.h里LWIP_UDP默认是0。我改了应用层代码,但忘了改这个宏,结果编译出来的固件根本没有UDP功能。所以改配置的时候,一定要把相关的宏都检查一遍。
最后分享一个小技巧:在调试UDP通信时,可以在PC上开两个网络调试助手,一个用来发数据,一个用来收数据。这样可以同时观察发送和接收的情况,比只用一个工具效率高很多。另外,Wireshark的过滤器可以设置为udp.port == 你的端口号,这样只显示相关的数据包,避免被其他流量干扰。
这个工程后续还可以继续扩展,比如加入DHCP自动获取IP,或者加入SNMP简单网络管理协议,方便远程监控设备状态。如果要做产品化,还需要考虑网络异常时的自动恢复机制,比如链路断开后自动重连。这些内容等以后有机会再单独整理分享。