UDP套接字编程指南:从API、丢包排查到iperf3压测
聊UDP套接字之前先摆一个我去年调嵌入式板卡时遇到的现象PC端用UDP狂发数据板卡这边recvfrom一直收不到但板卡ping PC是通的TCP收发也正常。折腾一上午后查来查去最后发现是板卡程序里把socket(AF_INET, SOCK_STREAM, 0)写成了SOCK_STREAM——想用UDP却建了个TCP套接字目标端口上永远不会出现这个socket。这类问题在拿UDP做高速传输的人里太常见了所以我决定把UDP套接字从API、协议栈到调试压测完整梳理一遍。这篇内容适合正在写网络传输程序、需要快速定位UDP问题的开发者和嵌入式工程师看完之后你至少能回答三个问题UDP套接字该怎么建、数据从发出到进入应用要经历什么、以及UDP丢包到底该从哪下手查。1. UDP套接字到底是干什么的1.1 从“无连接”这个词说起UDP套接字最核心的特征是“无连接”。所谓无连接不是说不需要IP和端口而是指发送方在sendto之前不需要跟接收方有任何握手过程。只要你知道对方的IP和端口数据就能直接丢出去。这非常像寄平信你在信封上写好收件地址丢进邮筒就走收件人是否在家、地址是否正确、中途会不会丢寄出那一刻你全都不知道。套接字在这里扮演的角色是“应用层与内核协议栈之间的门”。你的应用程序通过socket()拿到一个文件描述符之后用sendto把数据交给内核内核负责加UDP头、IP头再通过网卡发出去反过来网卡收到UDP包内核解完头之后会按照目标端口找到对应的socket把数据放进接收缓冲区应用再用recvfrom取出来。整个过程里没有连接建立、没有序号确认、没有重传这就是它“轻”的根本原因。很多初学者会把“无连接”理解成“随便绑个端口就能收”这就错了。接收方必须先用bind把本地IP和端口绑定到socket上数据包到达之后协议栈才能根据五元组中的目标端口找到该socket。发送方可以不bind因为内核会自动分配一个临时端口但接收方不bind就等于没地址别人发不进来。1.2 和TCP套接字的本质差异要理解UDP套接字必须把它和TCP套接字放在一起对比。除了“是否握手”这个表面区别更大的差异在数据边界和可靠性语义。对比项UDP套接字TCP套接字连接状态无连接sendto直接发面向连接connect成功才能收发数据边界保留报文边界一次sendto对应一次recvfrom字节流不保边界需要自己做分包组包可靠性不保证到达、不保证顺序、不重传确认、重传、排序、流量控制开销协议头小握手成本为零三次握手、确认包、状态表维护拥塞控制无应用想多快就多快有会根据网络状况调整发送速率校对机制只做校验和出错就丢CRC/校验和重传保证正确性UDP套接字保留报文边界这一点非常容易被误解。TCP的send调用1000字节对面recv可能要分两次才能读完整因为TCP没有边界概念。但UDP的sendto发1000字节只要对端缓冲区够大recvfrom一次就能收到1000字节不会把两次发送的内容粘在一起。这个特性让UDP天然适合请求-响应模型比如DNS查询、SNMP采集也适合那些“一帧数据就是一条完整消息”的场景。但边界也带来麻烦每条UDP报文大小有限制。以太网MTU通常是1500字节扣掉IP头20字节和UDP头8字节应用层载荷如果超过1472字节就可能触发IP分片。分片会显著增加丢包概率因为只要一个分片丢了整个报文就废了。所以很多UDP程序会把单包控制到1400字节左右这也是后面要讲的“分包组包”的由来。1.3 什么样的业务非它不可既然UDP不靠谱为什么还有那么多业务在用核心原因是实时性。TCP的可靠背后是重传和拥塞控制而这两个机制在坏链路上会引入不确定延迟。音视频通话里比起收到一帧迟到2秒的老画面用户更愿意直接丢弃它继续看新画面。游戏同步同理玩家操作的是当前状态旧状态重传过来反而造成卡顿。典型场景有实时音视频传输、游戏帧同步、局域网设备发现、时间同步NTP/PTP、监控数据采集、DNS解析、DHCP、以及各种传感器广播。与之相对文件传输、数据库事务、交易系统这类“少传一个字节都完蛋”的业务除非你自己在UDP之上实现可靠的ARQ协议否则不应该用UDP套接字。从工程角度还有一个战术价值UDP套接字实现起来比TCP简单太多。不需要维护连接池、不需要考虑半包和粘包、不需要处理四次挥手写个收发demo半小时就能跑通。这也是很多原型系统先用UDP验证链路、再升级为可靠协议的原因。2. 动手写一个UDP套接字从零到能跑2.1 核心API拆解socket、bind、sendto、recvfromUDP编程本质上就是围绕四个函数转socket()创建套接字bind()绑定本地地址sendto()发数据recvfrom()收数据。理解每个参数的语义比背模板重要得多。socket(AF_INET, SOCK_DGRAM, 0)里的AF_INET表示IPv4地址族SOCK_DGRAM表示数据报套接字第3个参数填0时内核会自动选择IPPROTO_UDP。如果你写成了SOCK_STREAM那就变成了TCP套接字行为天差地别前面我自己的排障经历就是栽在这里。sendto(int fd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen)这组参数中dest_addr是目标地址结构体里面装的是对方的IP和端口。它不需要提前connect每次发送都指定一次目标所以同一个socket可以给多个不同目标发数据。recvfrom(int fd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen)则是阻塞等待数据到达。src_addr会回填发送方的地址这样你就知道数据是谁发来的。len是本地缓冲区大小如果实际收到的报文大于缓冲区长多余数据会被内核丢弃而且你不会收到任何错误提示这一点尤其坑人。bind函数只在接收端必须使用发送端可调可不调。它把socket绑定到某个IP和端口上比如192.168.1.100:9000。如果IP填INADDR_ANY表示监听本机所有网卡。2.2 一个最简单的UDP收发程序我平时写C语言UDP测试习惯先把发送端和接收端分开。下面这段是接收端的骨架功能是绑定9000端口循环接收并打印报文字节数。#include stdio.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h int main() { int fd socket(AF_INET, SOCK_DGRAM, 0); if (fd 0) { perror(socket); return 1; } struct sockaddr_in local; memset(local, 0, sizeof(local)); local.sin_family AF_INET; local.sin_port htons(9000); local.sin_addr.s_addr htonl(INADDR_ANY); if (bind(fd, (struct sockaddr *)local, sizeof(local)) 0) { perror(bind); return 1; } char buf[2048]; struct sockaddr_in peer; socklen_t peerlen sizeof(peer); while (1) { ssize_t n recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr *)peer, peerlen); if (n 0) { perror(recvfrom); break; } printf(recv %zd bytes from %s:%d\n, n, inet_ntoa(peer.sin_addr), ntohs(peer.sin_port)); } close(fd); return 0; }发送端更简单不需要bind直接填目标地址调用sendto#include stdio.h #include string.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h int main() { int fd socket(AF_INET, SOCK_DGRAM, 0); struct sockaddr_in peer; memset(peer, 0, sizeof(peer)); peer.sin_family AF_INET; peer.sin_port htons(9000); inet_pton(AF_INET, 192.168.1.100, peer.sin_addr); const char *msg hello udp; int n sendto(fd, msg, strlen(msg), 0, (struct sockaddr *)peer, sizeof(peer)); if (n 0) { perror(sendto); return 1; } close(fd); return 0; }注意几点。第一Linux下sin_family必须填AF_INET如果写成AF_UNSPECbind会直接失败。第二端口号要用htons转成网络字节序这是新手最容易漏的细节。第三默认socket是阻塞模式recvfrom会一直等下去如果对端一直不发数据程序就卡死在那里。我在调试时通常会给它加一个超时用setsockopt配合SO_RCVTIMEO避免因为等待数据而挂起整个线程。2.3 Windows下创建套接字的差异C语言写UDP套接字Windows和Linux最大的区别不是逻辑而是启动和关闭方式。Windows下必须先调用WSAStartup初始化Winsock库结束再WSACleanup否则socket()直接返回INVALID_SOCKET。头文件也要从sys/socket.h换成winsock2.h链接时需要加ws2_32.lib。错误处理方面Windows用WSAGetLastError()获取错误码Linux则是errno。关闭socketWindows用closesocket()Linux用close()。从代码结构看Windows版大致是这样#include winsock2.h #include ws2tcpip.h #pragma comment(lib, ws2_32.lib) WSADATA wsa; WSAStartup(MAKEWORD(2, 2), wsa); SOCKET fd socket(AF_INET, SOCK_DGRAM, 0); if (fd INVALID_SOCKET) { printf(WSA error: %d\n, WSAGetLastError()); return 1; } // bind/sendto/recvfrom 与Linux基本相同 closesocket(fd); WSACleanup();我看过不少Windows上的UDP测试程序一大半的“socket创建失败”都是因为忘了WSAStartup。这个初始化只做一次就行但很多人写进每次收发函数里反复调用反而浪费资源。2.4 C#的UDP分包与组包实操热词里出现了“C# UDP发送分包组包”这说明实际业务中很多UDP消息长度超过单包上限或者需要把大数据拆成多帧发送。C#的UdpClient封装了底层细节用起来比C舒服很多但分包组包逻辑完全得自己写。分包的核心思路是先把数据切成不超过MTU的块然后每块前面加一个自定义头带上序号和总包数。例如定义头部结构为2字节序列号、2字节分包总数、4字节原始消息ID。发送端循环发送接收端按消息ID收集等到齐再拼起来。public static Listbyte[] SplitUdp(byte[] raw, int mtu 1400) { var result new Listbyte[](); int id (int)(DateTime.Now.Ticks 0xffff); int total (raw.Length mtu - 1) / mtu; for (int i 0; i total; i) { int offset i * mtu; int len Math.Min(mtu, raw.Length - offset); byte[] packet new byte[8 len]; packet[0] (byte)(id 0xff); packet[1] (byte)((id 8) 0xff); packet[2] (byte)(i 0xff); packet[3] (byte)((i 8) 0xff); packet[4] (byte)(total 0xff); packet[5] (byte)((total 8) 0xff); // packet[6..7] 可放原始消息长度 Buffer.BlockCopy(raw, offset, packet, 8, len); result.Add(packet); } return result; }接收端的组合要维护一个字典key是消息IDvalue是已收到的包数组。每次收到包先解析头判断该ID的包集是否存在不存在就新建放入对应序号检查是否收齐收齐后拼接并清除该ID。UDP不是可靠传输所以丢包就丢包组不齐的数据只能交给上层决定是否请求重发。为什么很多C#项目还要用UDP而不是TCP典型原因是接收方是多台设备用同一个端口探测或者接收广播UDP的灵活性比TCP好得多。分包组包的代价是协议复杂度上升但换取的是应用层对实时性的控制权。3. 数据包到了协议栈之后发生了什么3.1 UDP协议栈的接收路径搞清楚“数据从网线到应用”的路径对排查UDP问题帮助极大。整体链路是网卡收到数据帧后通过DMA写入内存触发中断或NAPI轮询驱动将sk_buff交给协议栈IP层核对地址后将载荷交给udp_rcvUDP层根据目标端口查找socket如果找到就把数据投递到该socket的接收队列应用调用recvfrom时从队列里拷贝出去。期间还要做校验和计算如果校验失败包直接丢弃不会通知应用。这个流程解释了三个现象。第一为什么程序没运行或者没bind端口时发过去的UDP包会石沉大海因为协议栈找不到对应的socket只能回一个ICMP端口不可达甚至很多程序忽略ICMP的回报。第二为什么数据量大时应用层来不及处理会丢包因为socket接收队列是有长度上限的队列满了之后后来的包直接丢弃。第三为什么普通UDP没有“对方没收到”的通知因为一切都在栈内静默进行除非你在应用层做回包确认否则发送方永远不知道结果。3.2 内核缓冲区与丢包的关系很多开发者把UDP丢包归咎于“网络不好”但实际上一大半丢包发生在本机协议栈内部链路本身并没有丢帧。原因就是应用线程消费速度跟不上内核投递速度。接收队列就像一个蓄水池进水速率大于出水速率池子满了就溢水。Linux下可以用netstat -su查看UDP统计信息重点关注packet receive errors和packet receive errors旁边的计数。数值持续增长基本可以判断是接收侧丢包。ss -uam可以看到每个UDP socket的收发队列大小和已丢弃数据量。如果确认是缓冲区不足可以用setsockopt调大SO_RCVBUFint rcvbuf 4 * 1024 * 1024; // 4MB需要同时调整rmem_max setsockopt(fd, SOL_SOCKET, SO_RCVBUF, rcvbuf, sizeof(rcvbuf));这里有个细节内核实际生效的缓冲区是应用传入值的两倍并且不能超过/proc/sys/net/core/rmem_max。如果设置值超过上限内核会静默截断所以最好先通过sysctl把上限调大。发送侧同理SO_SNDBUF决定了应用层sendto能堆积多少数据如果发送队列满了sendto会阻塞或返回错误。3.3 套接字选项的正确打开方式UDP调试中经常需要摆弄几个选项我按实际使用频率排个序。SO_REUSEADDR应该是出现频率最高的。它的作用是允许两个socket绑定同一个IP和端口主要用于服务重启时快速复用地址。UDP场景下它还有一个特别用途多播程序需要在每个网卡上创建socket并绑定同一端口不开SO_REUSEADDR会绑定失败。如果你在开发一个并发服务多个worker进程都想接收同一组端口的数据也必须打开这个选项。SO_BROADCAST是发送广播包的前置条件。默认情况下socket不允许往255.255.255.255这类广播地址发送数据必须显式设置SO_BROADCAST否则sendto直接返回权限错误。很多人在开发局域网设备搜索功能时忘了这一步发现广播包发不出去实际不是被交换机拦了而是本地socket根本没放行。SO_RCVTIMEO和SO_SNDTIMEO用来给阻塞调用加超时。默认的UDP收发没有超时recvfrom会无限等下去。在事件驱动的架构里我更倾向于把socket设成非阻塞但如果是多线程中每个线程负责一个端口我习惯用超时轮询的方式这样能定期检查线程退出标志。超时设置后如果recvfrom超时返回会返回-1且errno为EAGAIN或EWOULDBLOCK这个必须做区分判断否则会被误判为真正错误。还有一个容易被忽略但很实用的选项SO_BINDTODEVICE。多网卡设备上即使你bind了源IP也可能因为路由表原因导致数据从错误的网卡发出去。在Linux下可以用它把socket明确绑定到某个网络接口这样发送方向就完全可控了。嵌入式开发和网络测试经常遇到这种“明明地址对就是不通”的场景多半就是多网卡路由问题。4. 用iperf3给UDP套接字做体检4.1 建立打流环境写完了UDP程序下一步自然是验证性能。iperf3是目前最常用的打流工具比手写for循环发UDP靠谱得多。它的UDP模式可以控制目标带宽、报文大小和持续时间并且准确统计吞吐、抖动和丢包率。一个标准的UDP打流分两步。先在被测接收端启动服务iperf3 -s -u -p 5201-u表示UDP模式-p指定端口可以改成自己程序实际使用的端口。然后在发送端执行iperf3 -c 192.168.1.100 -u -b 100M -l 1200 -t 10 -i 1-c是接收端IP-b目标带宽-l是单个报文的长度-t是测试时长-i是每秒输出一次统计。这里的100M表示要以100Mbps的速率发送不是限制上限所以如果实际支持200Mbpsiperf3也会给你打满100Mbps。我通常建议分档测试先打30M、再50M、再100M、再200M观察丢包率随带宽的增长曲线。如果所有档位都稳定说明网络余量很足如果到某一档突然丢包率飙升那就接近瓶颈了。工具只是工具这份测试记录才是最终交付给客户最有价值的东西。4.2 打流结果怎么看一次典型的iperf3 UDP输出如下[ ID] Interval Transfer Bitrate Jitter Lost/Total Datagrams [ 5] 0.00-10.00 sec 124 MBytes 104 Mbits/sec 0.028 ms 2/89270 (0.0022%)这5个字段各有用处。Bitrate是实际测得的平均速率如果低于你设置的-b值说明链路没跑到目标速率。Jitter代表报文到达时间的抖动音视频场景特别关注小于1ms非常不错超过5ms就该排查网络拥塞。Lost/Total Datagrams里的Lost数量是真正丢掉的UDP包数后面的百分比是丢包率。对UDP测试来说丢包率是最核心的指标0是最理想但实际链路上总会有一点只要低于1%通常可以接受。注意iperf3默认只测单方向。如果你想测反向需要在接收端再加一个-R参数。测双向时我习惯跑两遍第一遍正向第二遍加-R这样数据更清晰。还要注意IP地址不要设错很多人在虚拟机里跑iperf3NAT网络下回环都能通但跨物理机测就会遇到防火墙拦截。4.3 把UDP带宽测满的坑UDP打流的坑主要有四个我全部踩过。第一个坑是防火墙。Windows防火墙默认会拦截大量UDP流量尤其是反向的iperf3 -R。测试前最好在接收端和发送端都放行iperf3的端口netsh advfirewall firewall add rule nameiperf dirin actionallow protocolUDP localport5201。Linux防火墙则要看清ufw或firewalld规则否则数据在进入socket之前就被DROP了。第二个坑是报文长度设置过大。-l 1400和-l 8000在链路好时看不出大区别但一旦有路由设备MTU不一致大包会触发IP分片分片重组的开销在UDP模式下没有TCP那种MSS协商来规避丢包率会陡然上升。稳妥的做法是用1400以内的载荷测试局域网跨公网时使用1200左右。第三个坑是自环测试结果的迷惑性。把server和client放在同一台机器上-b 1000M也能跑出结果但这个结果只验证了协议栈和本地环回接口不能代表物理链路。真正要测嵌入式板卡或者交换机链路一定要把两台设备放在网络两侧中间经过你想考核的路径。第四个坑是iperf3的CPU占用。UDP速率打得很高时单核CPU可能先到瓶颈导致客户端发送线程来不及维护包间隔。此时看到的丢包其实是本机发送队列溢出而不是网络丢包。解决办法是限制带宽-b或者换更快的机器做测试。5. UDP调试与排查实战5.1 UDP端口到底通不通UDP端口能不能通本质上是个伪命题因为UDP没有连接建立的过程不像TCP那样可以用connect判断成功失败。所谓“端口通”只能理解成“发送方发出的包接收方有没有收到并能回复”。所以纯用telnet测UDP是完全错误的telnet是TCP客户端。最基础的检查是看本机有没有进程在监听UDP端口。Linux下ss -u -l -nWindows下netstat -an -p UDP能看到UDP 0.0.0.0:9000之类的监听记录。能查到监听只能说明socket绑定成功不代表网络能到达。真正验证UDP连通性我推荐三种办法。第一种是用ncLinux端在接收机执行nc -u -l 9000发送端执行echo hello | nc -u 目标IP 9000接收端能打印出hello就说明链路通。第二种是写一个收发一体的回环程序收到数据立即原路返回发送端计时等回包这比nc更符合真实业务。第三种是抓包验证用tcpdump -i eth0 udp and host 目标IP或者Wireshark看UDP包是否到达对端网卡。如果抓包看到包已经进来但应用没收到问题就在协议栈或socket层如果抓包都没看到问题就在网络链路或防火墙。5.2 常见问题速查表UDP套接字的故障七成以上集中在少数几个原因上。我整理成一张速查表排查时直接按行对号。问题现象可能原因解决办法recvfrom一直收不到接收端没bind端口、IP填错、防火墙拦截检查bind参数用回环程序自测放行UDP端口sendto返回成功但对方没收到目标地址或端口写错、广播未设置核对远端IP端口抓包验证出口广播发不出去未设置 SO_BROADCASTsetsockopt打开广播选项数据明明到了抓包也看不到本机防火墙DROP用tcpdump确认再检查iptables/firewalld高负载下大量丢包接收缓冲区太小、应用消费慢调SO_RCVBUF提高recv频率分线程处理多网卡时从错误网卡出去路由表问题bind具体源IP或设置SO_BINDTODEVICE绑定端口报Address already in use上次进程还没释放、无SO_REUSEADDR先看进程占用再开SO_REUSEADDR数据乱序网络路径变化、多路径转发UGP不保证顺序应用层加序号排序收到截断数据recv缓冲区比实际包小加大buf注意单包长度限制其实还有一个经典问题UDP发送端不bind就用随机临时端口接收端回包时是针对这个临时端口回的如果发送端不是服务器模式回包可能因为防火墙状态机制被拦。有些NAT环境甚至需要发送端固定bind一个端口否则双方无法建立稳定的通信关系。5.3 一次嵌入式Zynq以太网UDP测试实录最后拿一个Zynq板卡的案例来收尾。Zynq跑Linux系统想验证以太网口在UDP负载下还能不能稳定工作。当时硬件上板卡和PC之间用一根普通网线直连PC的网卡IP设置为192.168.1.1板卡设为192.168.1.10。刚开始ping不通排查才发现PC网卡的“自动协商”和板卡驱动在直连下没有协商成一致手动把PC网口降到百兆全双工后就通了。链路通了之后我先在PC端起iperf3服务端板卡上执行客户端iperf3 -c 192.168.1.1 -u -b 50M -l 1200 -t 30结果很顺利丢包率0.000%抖动0.05ms。这时候还不算完我又用自己写的C程序在板卡上跑了一遍自定义UDP回环测试。目的是验证实际业务代码里socket选项是否生效以及长时间运行是否有内存泄漏。让板卡每5ms发送一个64字节心跳包PC端回显连续跑12小时统计丢包和延迟波动。这种长时间稳定性测试比架构评审里的“理论上没问题”有说服力得多。如果你手头没有iperf3也可以用Python快速搭一个UDP回环做连通验证。代码不到20行import socket s socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.bind((0.0.0.0, 9000)) while True: data, addr s.recvfrom(2048) print(frecv {len(data)} bytes from {addr}) s.sendto(data, addr)这个脚本虽然是玩具等级但在现场排查链路通不通时非常管用。配合发送端的nc或者自己的C客户端能很快判定问题出在哪个层次。我个人在实际操作中的体会是UDP套接字并不复杂真正复杂的是养成“无连接思维”。不要用TCP的确定性去期待UDP所有可靠性都必须由应用层自己补齐。每次调试UDP我给自己定死的顺序是先抓包确认物理链路和防火墙再看socket是否绑定正确、缓冲区是否合理最后才怀疑业务代码。按这个顺序排查大多数问题不会超过半小时。最后再分享一个小技巧调试UDP前如果担心自己写的接收端有bug先用现成工具验证一下链路再把自己的程序接进去这样可以把“网络问题”和“代码问题”彻底分开。