
做Linux网络开发的朋友基本都绕不开UDP。我最早对它的理解停留在“发出去就完事”直到有一次生产环境出现大批量丢包查了一整天最后把Linux下的UDP核心函数接口、数据包结构和wireshark抓包三样东西彻底串起来才真正明白这个协议在系统层面到底发生了什么。这篇文章就是我整理出来的完整笔记从UDP报文怎么逐字节组织到socket编程里那几个关键函数怎么配合再到wireshark里怎么看懂自己程序发出去的包。适合刚入门网络编程、想把协议栈和代码打通的同学也适合写了很久UDP却很少看报文的运维和开发参考。我尽量按一条完整的链路来讲先是概念边界然后是报文结构接着是Linux上的函数调用最后落到wireshark抓包排错。顺序就是我实际学习时的顺序每段都尽量给出可以直接拿去用的细节。1. 先把UDP的边界划清楚哪些场景该用它哪些场景别硬上很多人上来就背八股文——“UDP不可靠、无连接、包头小”但真到写代码时这些话到底意味着什么能说出的人不多。我先把这三句话翻译成代码层面的实际影响你就知道为什么有些场景非它不可有些场景碰都不能碰。1.1 不可靠、无序、无连接这三个特性如何影响你的代码先说“无连接”。TCP的socket是流式的服务端要listen、accept客户端要connect建立连接内核帮维护一堆状态。UDP完全不是这套服务端只要socketbind就可以收包不需要listen更不需要accept客户端只要知道对方的IP和端口直接sendto发出去就行发完这一份报文内核就和这个对端“没关系”了。所以UDP socket天然支持一对多——一个fd可以同时给N个对端收发数据而TCP一个连接基本就是一对固定对端。再说“不可靠”。内核只做尽力转发不重传、不确认、不保证顺序。这意味着你调sendto成功返回只代表报文进入了本机内核的发送队列不代表对端收到更不代表对端的应用层读到了。报文可能在中间路由器被丢弃可能因为接收端缓冲区满了被静默扔掉也可能在链路上走不同路径导致后发先至。这些情况在代码层面没有直接报错你只能在上层协议自己加序号、加确认、加重传。最后是“报文边界”。这是UDP和TCP最容易被新手忽略的本质差异。TCP是字节流你发10次write对方可能一次read就全读走也可能分几次read你到底读到多少取决于流中的位置。UDP是数据报每调用一次sendto就形成一个独立的报文边界接收端每调用一次recvfrom恰好取走一个完整的数据报不会出现“半个报文”的情况——如果缓冲区太小放不下整个报文Linux默认会截断多出来部分直接丢掉这也是一个非常隐蔽的坑。1.2 实际项目里UDP最常出现的几个位置搞清楚了特性你就能判断哪些场景是UDP的主场DNS查询一次请求一个报文响应也是一个报文天然契合数据报模型丢了大不了客户端重试。NTP时间同步报文体积极小要求低延迟偶尔丢一个包下一轮再同步即可。RTP/音视频传输视频帧丢了可以走前向纠错重传反而增加延迟UDP正合适。syslog日志上报监控日志允许少量丢失不能因为日志堵塞拖垮主流程。局域网设备发现广播和组播只有UDP能做TCP是点对点的天然做不了这种“喊一嗓子全听见”的事。游戏状态同步服务端高频下发位置和状态客户端预测差值丢包靠插值糊弄过去重传没有意义。反过来如果你的业务要求“发了就必须收到而且顺序不能乱”就别在UDP上硬拗。你可以自己实现ACK和重传或者直接引入KCP这类可靠UDP框架但那是另一个工程量级的话题。多数情况下老老实实换TCP或直接用QUIC成本远低于自己造轮子。2. 从字节层面拆解UDP数据包8字节头与上下两层的关系学习网络协议我的建议永远是先看字节再看函数。因为wireshark里展开的每一个字段最终都会对应到你程序里某个参数或某个字节。你看懂了报文很多奇奇怪怪的bug当场就能定位。2.1 以太网帧与IP头抓包面板里你不该忽略的前置字段一个UDP数据包在线路上实际是三层套娃以太网帧头 IP头 UDP头 应用数据。wireshark抓到的完整帧最前面14字节是以太网头6字节目的MAC、6字节源MAC、2字节类型。类型字段如果是0x0800表示后面跟的是IPv4如果是0x86DD就是IPv6。接下来是IP头标准IPv4头20字节。我建议至少认识这几个字段因为抓包排错时经常要用IP头字段位置/长度和UDP的关系版本头长度第1字节常见0x45即IPv4、头长20字节总长度2字节表示IP头UDP头数据的总长可以反推UDP长度协议号第10字节UDP固定是17TCP是6ICMP是1源/目的IP各4字节抓包过滤ip.addr用的就是它分片偏移/标志2字节大UDP包分片时这里会有变化TTL1字节排查跨设备转发问题时会用到IP头的“总长度”和UDP头的“长度”经常让人混淆。IP总长度包括IP头本身而UDP长度只包括UDP头和UDP数据两者差一个IP头长度通常是20字节。记住这个差值你在wireshark里对照两个字段就能快速判断有没有手工构造包的嫌疑。2.2 UDP头部4个字段逐一解读附带边界值计算UDP头固定8个字节4个字段各占2字节字段长度含义源端口16bit发送端端口某些场景可以为0比如不期望回复目的端口16bit接收端端口长度16bitUDP头数据的总字节数最小是8只有头没有数据校验和16bit校验伪头部UDP头数据IPv4下可以是0IPv6下强制计算这里的“伪头部”是UDP和TCP特有的概念。它并不是真实报文的一部分而是从IP头里取出来的源IP、目的IP、协议号和UDP长度拼在一起参与校验和计算目的是防止数据报被路由到错误的地址。你在wireshark里展开UDP的校验和详情会看到“Checksum”下面标了这些伪头部字段就是这个原因。边界值值得单独记一下。UDP长度字段是16bit最大65535但IP总长度字段也只有16bit减去20字节IP头、再减去8字节UDP头一个UDP数据报的payload最大是65507字节。超过这个数你调用sendto会直接返回错误我在实际项目里就见过有人用固定8KB的栈缓冲区去收包结果收到大报文被截断排查了半天才发现是缓冲区不够而不是网络问题。2.3 用真实十六进制报文走一遍解析过程光讲字段太抽象我拿一个实际例子走一遍。假设客户端192.168.1.100:50000向服务器192.168.1.1:8000发送5字节的ASCII字符串“hello”wireshark里看到的原始十六进制是这样的00 0c 29 aa bb cc 00 0c 29 11 22 33 08 00 45 00 00 21 1a 2b 40 00 40 11 00 00 c0 a8 01 64 c0 a8 01 01 c3 50 1f 40 00 0d 45 1b 68 65 6c 6c 6f逐段解读前14字节是以太网头08 00表示IPv4。第15字节45版本4、头长20字节接着00 21是IP总长度0x21占十进制的33正好等于20字节IP头8字节UDP头5字节数据。40 00里的40即0100 0000DF位置1表示不允许分片40是TTL 6411是协议号17确认是UDP。c0 a8 01 64是192.168.1.100c0 a8 01 01是192.168.1.1。UDP头c3 50是500001f 40是800000 0d13即UDP长度8字节头5字节数据45 1b是校验和。最后的68 65 6c 6c 6f就是“hello”的ASCII码。整个过程不超过十秒但如果你能随手写出这种对应关系后面看wireshark基本就是扫一眼的事。uDIF。3. Linux UDP核心函数接口从socket到收发数据的完整调用链报文结构看懂了接下来就是Linux上那一串核心函数接口。我见过太多人把这些函数背得滚瓜烂熟但一旦问“为什么UDP没有listen”“为什么sendto返回了对方却没收到”就答不上来。这一节我把每个关键点背后的原因讲清楚。3.1 服务端最小模型socket/bind/recvfrom先给一个能跑的最小服务端代码#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 addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(8000); addr.sin_addr.s_addr htonl(INADDR_ANY); if (bind(fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); return 1; } char buf[2048]; struct sockaddr_in peer; socklen_t peerlen sizeof(peer); ssize_t n recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr *)peer, peerlen); if (n 0) { perror(recvfrom); return 1; } printf(recv %zd bytes from %s:%d - %s\n, n, inet_ntoa(peer.sin_addr), ntohs(peer.sin_port), buf); close(fd); return 0; }socket的第二个参数SOCK_DGRAM是关键它告诉内核我要的是数据报套接字对应协议就是UDP。第三个参数传0表示让内核根据前三参自动选协议这里会选IPPROTO_UDP。bind的作用是把fd和本地地址绑定。服务端必须bind否则内核不知道去哪找你的fd。INADDR_ANY表示监听本机所有网卡地址如果你只想收特定网卡的包也可以像inet_pton(AF_INET, 192.168.1.1, addr.sin_addr)这样指定。端口要转成网络字节序这里htons(8000)就是干这个的。recvfrom是UDP接收的核心。它比read多出了peer这个参数——调用返回后peer里会填上发送方的IP和端口这样你才知道这包是哪个客户端发来的将来回复它也有目标了。正因为UDP无连接、一个fd对接多个对端所以必须用recvfrom而不是read。如果你对一个UDP fd调用read也不是不行但你永远拿不到源地址。3.2 客户端发送模型sendto、connect伪连接与常见误解客户端就更简单了直接sendtoint fd socket(AF_INET, SOCK_DGRAM, 0); struct sockaddr_in dst; memset(dst, 0, sizeof(dst)); dst.sin_family AF_INET; dst.sin_port htons(8000); inet_pton(AF_INET, 192.168.1.1, dst.sin_addr); ssize_t n sendto(fd, hello, 5, 0, (struct sockaddr *)dst, sizeof(dst)); printf(sendto returned %zd\n, n);这里最容易产生误解的是返回值。sendto返回的字节数只代表“这5字节进了内核发送队列”绝不等于“对端收到了5字节”。中间可能丢在路由器可能丢在对端网卡也可能丢在对端的socket接收缓冲区。UDP没有ACK机制内核根本无从得知对端是否收到所以它只能向你保证“我帮你发出去了”。在客户端调connect是新手一大迷惑点。UDP调用connect并不会发起任何握手不会发送任何报文它的作用只是给这个socket绑定一个默认对端地址之后你可以直接用send/recv代替sendto/recvfrom。这样做有两个实际好处一是内核提前缓存了到对端的路由稍微省一点开销二是只有来自这个对端的包才会上报到这个socket其他来源的报文会被内核直接丢弃同时connect过的UDP socket能收到ICMP端口不可达等错误普通sendto模式下这些错误是收不到的。但注意一旦connect了这个fd就“专一”了想跟多个对端通信就不方便了。需要解除的话可以再connect一个AF_UNSPEC的地址Linux支持这样解除绑定。3.3 缓冲区、超时与非阻塞生产环节必须处理的三个细节写Demo随便写上生产就得面对几个内核细节。第一个是接收缓冲区。UDP的接收缓冲区如果满了新来的报文会被内核直接丢掉而且这个丢包对发送端毫无感知。默认值通常偏小高流量场景必须调大int rcvbuf 1 * 1024 * 1024; setsockopt(fd, SOL_SOCKET, SO_RCVBUF, rcvbuf, sizeof(rcvbuf));有个Linux的特例必须知道内核会把SO_RCVBUF设置值翻一倍再分配这是从内核2.4就有的行为因为一部分空间要用于内核内部记账。所以看实际生效值用getsockopt查别拿自己设置的数直接算。另外如果设置超过net.core.rmem_max也会被悄悄钳制到上限想调大上限要先执行sysctl -w net.core.rmem_max4194304这类操作。第二个是超时。UDP的recvfrom默认是阻塞的如果一直没有包来线程就挂在那。你可以在fd上设SO_RCVTIMEO配合一个struct timeval超时后recvfrom返回-1errno置为EAGAIN或EWOULDBLOCKstruct timeval tv {.tv_sec 5, .tv_usec 0}; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv));第三个是非阻塞。用fcntl加O_NONBLOCK之后recvfrom在没数据时立即返回-1和EAGAIN你就可以把它放进select/poll/epoll的循环里统一管理。UDP fd在epoll里和TCP的用法几乎一样唯一要注意的是发生错误时epoll不会给你一个干净的错误事件往往表现为可读你要用recvfrom的返回值去识别。4. Wireshark上抓UDP过滤器、面板读数与实际排错代码和协议讲完终于到wireshark。说实话wireshark本身不难难的是你不知道看什么、怎么从面板反推代码问题。这一节按我的实操习惯来先讲过滤器怎么写再讲一个完整的排查链路最后讲一个最容易吓到新手的checksum问题。4.1 捕获过滤器与显示过滤器的区别以及我常用的几条这是wireshark新手第一个分不清的点。捕获过滤器Capture Filter用BPF语法是在抓包那一刻由内核过滤的没通过的包根本不会进入wireshark显示过滤器Display Filter是在已抓到的包上做二次筛选只是把某些包隐藏起来数据还在。排查问题首选显示过滤器因为一旦用捕获过滤器滤掉了关键包你就得重新抓。我在Linux上常用的几条整理成表目的显示过滤器只看所有UDPudp只看某个端口的UDPudp.port 8000只看某一对IP之间的UDPudp ip.addr 192.168.1.100只看UDP长度大于100的包udp.length 100找某个载荷特征udp contains hello只看校验和错误的UDP包udp.checksum_bad 1输出特定字段到终端-T fields -e ip.src -e udp.srcport -e udp.length注意ip.addr x是“源或目的任意一个是x”如果你严格只想看某个方向要用ip.src和ip.dst分开写。我在刚开始抓包时就被这个坑过——想只抓A到B的包结果把B到A的也一起混进来了分析时白白多绕了一圈。还有一点经验如果服务端是纯命令行环境没有GUI用tshark代替wireshark抓包效果一样。比如sudo tshark -i any -f udp port 8000 -Y udp-f是捕获过滤器-Y是显示过滤器。带宽大的接口上捕获过滤器粒度越细越好免得抓了几百MB然后发现想要的包被刷掉了。4.2 从Wireshark判断代码问题的完整排查链路我拿一个真实踩过的坑来讲。当时服务端程序说收不到UDP数据客户端那边sendto返回正常我第一反应就是抓包。在服务器上开wireshark发现连包影子都没有。于是我开始怀疑代码又查了半天最后发现问题是客户端和服务端都在同一台机器上数据走的是loopback接口而我抓包时选的是eth0当然什么都看不到。Linux下环回包只出现在lo接口上要么选lo要么直接选any接口千万别默认选物理网卡。包出现了之后接下来按这条链路走确认包确实存在先确认不是捕获问题用显示过滤器筛出udp.port 端口号看到成对的请求和响应。确认UDP长度和payload正确展开UDP头对照之前讲的8字节结构看长度字段是不是比你sendto的payload多8。如果长度和代码里的约定不符那就是粘包、截断或者自研包头解析错了。确认到了应用层这是最容易被忽略的一步。包都进网卡了但应用可能没收到原因是接收缓冲区满了或程序没调用recvfrom。在服务器上执行netstat -su看UDP段里的RcvbufErrors和InErrors计数。如果计数在增长就说明报文被内核丢弃了和你的程序逻辑无关去调SO_RCVBUF或收缩应用处理耗时。看会话统计菜单里“Statistics - Endpoints”能看到本机所有UDP会话的包数和字节数能快速判断哪对通信占了绝大多数流量。抓高吞吐时这个面板比逐包翻高效得多。Follow UDP Stream右键任意一个UDP包选择Followwireshark会把同一对端之间的UDP报文按时间顺序拼成会话视图适合检查请求响应次序和内容连贯性。4.3 checksum offload导致的伪校验和错误新手最容易慌的坑新手第一次抓包十有八九被这个东西吓到wireshark里明明收到一个UDP包展开后校验和字段旁边却标着红色[incorrect]第一反应就是“我的包是不是坏了”。但很多情况下这只是一个假象。现代网卡支持校验和卸载checksum offload也就是说UDP校验和的计算可以交给网卡硬件在发送时完成操作系统驱动在把包交给网卡前先在报文里填一个未计算的占位值。wireshark抓包抓的是驱动层的数据它看到校验和字段是那个未计算的占位值就判定“不正确”。而真正到线缆上的包网卡已经帮你把校验和算好填好了。所以你在本机抓包看到一堆[incorrect]多半是offload造成的显示问题不是真的丢包或损坏。验证办法也很简单用ethtool -K eth0 tx off关掉发送侧的校验卸载再抓一次红色标记就消失了。如果还是红的那才是真的有问题。另外IPv6的UDP校验和是强制要求的驱动不能偷懒所以IPv6抓包中如果出现校验和错误通常值得认真查。5. 一个完整可跑的UDP收发Demo编码、抓包、验证三步闭环前面讲得再细不如亲手跑一遍。这一节给一个能直接编译运行的echo示例然后带你在wireshark里看应该出现的画面。5.1 服务端与客户端代码带注释服务端代码#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 addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(8000); addr.sin_addr.s_addr htonl(INADDR_ANY); if (bind(fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); return 1; } char buf[2048]; struct sockaddr_in peer; socklen_t peerlen sizeof(peer); for (;;) { 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 - %s\n, n, inet_ntoa(peer.sin_addr), ntohs(peer.sin_port), buf); sendto(fd, buf, n, 0, (struct sockaddr *)peer, peerlen); } close(fd); return 0; }客户端代码#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 dst; memset(dst, 0, sizeof(dst)); dst.sin_family AF_INET; dst.sin_port htons(8000); inet_pton(AF_INET, 127.0.0.1, dst.sin_addr); char msg[] hello udp; ssize_t n sendto(fd, msg, strlen(msg), 0, (struct sockaddr *)dst, sizeof(dst)); printf(sendto returned %zd\n, n); char buf[2048]; struct sockaddr_in src; socklen_t srclen sizeof(src); ssize_t r recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr *)src, srclen); if (r 0) { perror(recvfrom); return 1; } printf(recv %zd bytes: %s\n, r, buf); close(fd); return 0; }编译用gcc -o udp_server udp_server.c和gcc -o udp_client udp_client.c先跑服务端再跑客户端。客户端发“hello udp”服务端打印收到然后原样回给客户端客户端再打印出来。这个echo模型是我排查“对方到底收到没有”时最常用的最小验证工具。5.2 跑通后Wireshark里应该看到的画面抓包时接口选lo因为客户端连的是127.0.0.1显示过滤器写udp.port 8000。正常的话你会看到两行第一行源端口是客户端临时端口比如40000目的端口8000payload是hello udp。第二行源端口变成8000目的端口变成那个临时端口payload是同样内容这是服务端的echo。在Wireshark里点开第一行从上到下你应该能看到Frame、Ethernet IIlo接口上有些版本显示的是Linux cooked capture、Internet Protocol Version 4、User Datagram Protocol、Data。展开User Datagram Protocol长度字段应该显示98字节头 9字节的“hello udp”小于等于1472所以没有分片。展开Data能看到对应的ASCII明文。我习惯把源端口、目的端口、UDP长度这几列用右键加到显示列里这样刷屏的包一眼就能看出会话分布。排查高并发问题时这个习惯能帮你节省大量时间。5.3 吞吐、MTU与分片边界条件的补充经验Demo跑通之后有几个边界值得你自己动手试一下它们最能帮你理解协议栈行为。第一个是MTU和分片。标准以太网MTU是1500字节去掉20字节IP头和8字节UDP头UDP payload超过1472字节后IP层就会分片。你用上面的客户端改成发一个3000字节的大包wireshark里会看到原来一个UDP报文变成了多个IP分片第一个分片带UDP头后面的分片只有数据和IP头wireshark会在一行里显示[Reassembled in xxx]或[Segment reassembled]。注意分片是IP层的动作对UDP应用层透明但中间路径上如果有路由丢弃了某个分片整个数据报就废了。所以我个人强烈建议UDP业务报文务必控制在1472字节以内宁可拆包也不赌路径稳定。第二个是发送超大包的行为。向内核发送超过65507字节的payloadsendto会直接返回EMSGSIZE不用等到抓包就会在代码里暴露。这个错误很多老手也会一时想不起来我在这里记一笔。第三个是丢包统计的入口。抓包看到包进来了但应用层就是处理不完优先看netstat -su里的UDP计数。InDatagrams是内核收到的UDP报文数RcvbufErrors是由于接收缓冲区满被丢弃的数量两个数的差基本就是应用层丢包总量。如果这个差值一直在涨调大SO_RCVBUF、提高应用消费速度、或者横向扩容总得选一个。按我自己的习惯每次写完一段UDP代码第一步不是直接上测试环境而是先在本地起一个最小服务端wireshark开在loopback上把收发报文各看一遍确认端口、长度、payload都对再谈别的。抓包看到一个incorrect checksum先别慌先想到offload看到recvfrom没数据先查netstat -su的丢包计数。这些经验都是踩坑踩出来的希望你不用再踩一遍。