☰
Linux UDP网络编程实战:从报文结构到Socket函数与Wireshark抓包
2026/10/2 2:43:06 网站建设 项目流程

做Linux网络开发的朋友,基本都绕不开UDP。我最早对它的理解停留在“发出去就完事”,直到有一次生产环境出现大批量丢包,查了一整天,最后把Linux下的UDP核心函数接口、数据包结构和wireshark抓包三样东西彻底串起来,才真正明白这个协议在系统层面到底发生了什么。这篇文章就是我整理出来的完整笔记:从UDP报文怎么逐字节组织,到socket编程里那几个关键函数怎么配合,再到wireshark里怎么看懂自己程序发出去的包。适合刚入门网络编程、想把协议栈和代码打通的同学,也适合写了很久UDP却很少看报文的运维和开发参考。

我尽量按一条完整的链路来讲:先是概念边界,然后是报文结构,接着是Linux上的函数调用,最后落到wireshark抓包排错。顺序就是我实际学习时的顺序,每段都尽量给出可以直接拿去用的细节。

1. 先把UDP的边界划清楚:哪些场景该用它,哪些场景别硬上

很多人上来就背八股文——“UDP不可靠、无连接、包头小”,但真到写代码时,这些话到底意味着什么,能说出的人不多。我先把这三句话翻译成代码层面的实际影响,你就知道为什么有些场景非它不可,有些场景碰都不能碰。

1.1 不可靠、无序、无连接,这三个特性如何影响你的代码

先说“无连接”。TCP的socket是流式的,服务端要listen、accept,客户端要connect建立连接,内核帮维护一堆状态。UDP完全不是这套:服务端只要socket+bind就可以收包,不需要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固定是17,TCP是6,ICMP是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下可以是0,IPv6下强制计算

这里的“伪头部”是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 0000,DF位置1,表示不允许分片;40是TTL 64;11是协议号17,确认是UDP。
  • c0 a8 01 64是192.168.1.100,c0 a8 01 01是192.168.1.1。
  • UDP头:c3 50是50000,1f 40是8000,00 0d=13,即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伪连接与常见误解

客户端就更简单了,直接sendto:

int 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_max=4194304这类操作。

第二个是超时。UDP的recvfrom默认是阻塞的,如果一直没有包来,线程就挂在那。你可以在fd上设SO_RCVTIMEO,配合一个struct timeval,超时后recvfrom返回-1,errno置为EAGAIN或EWOULDBLOCK:

struct 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接口,千万别默认选物理网卡。

包出现了之后,接下来按这条链路走:

  1. 确认包确实存在:先确认不是捕获问题,用显示过滤器筛出udp.port == 端口号,看到成对的请求和响应。
  2. 确认UDP长度和payload正确:展开UDP头,对照之前讲的8字节结构,看长度字段是不是比你sendto的payload多8。如果长度和代码里的约定不符,那就是粘包、截断或者自研包头解析错了。
  3. 确认到了应用层:这是最容易被忽略的一步。包都进网卡了,但应用可能没收到,原因是接收缓冲区满了或程序没调用recvfrom。在服务器上执行netstat -su,看UDP段里的RcvbufErrors和InErrors计数。如果计数在增长,就说明报文被内核丢弃了,和你的程序逻辑无关,去调SO_RCVBUF或收缩应用处理耗时。
  4. 看会话统计:菜单里“Statistics -> Endpoints”能看到本机所有UDP会话的包数和字节数,能快速判断哪对通信占了绝大多数流量。抓高吞吐时这个面板比逐包翻高效得多。
  5. Follow UDP Stream:右键任意一个UDP包选择Follow,wireshark会把同一对端之间的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+),目的端口8000,payload是hello udp。
  • 第二行:源端口变成8000,目的端口变成那个临时端口,payload是同样内容,这是服务端的echo。

在Wireshark里点开第一行,从上到下你应该能看到:Frame、Ethernet II(lo接口上有些版本显示的是Linux cooked capture)、Internet Protocol Version 4、User Datagram Protocol、Data。展开User Datagram Protocol,长度字段应该显示9(8字节头 + 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字节的payload,sendto会直接返回EMSGSIZE,不用等到抓包就会在代码里暴露。这个错误很多老手也会一时想不起来,我在这里记一笔。

第三个是丢包统计的入口。抓包看到包进来了,但应用层就是处理不完,优先看netstat -su里的UDP计数。InDatagrams是内核收到的UDP报文数,RcvbufErrors是由于接收缓冲区满被丢弃的数量,两个数的差基本就是应用层丢包总量。如果这个差值一直在涨,调大SO_RCVBUF、提高应用消费速度、或者横向扩容,总得选一个。

按我自己的习惯,每次写完一段UDP代码,第一步不是直接上测试环境,而是先在本地起一个最小服务端,wireshark开在loopback上把收发报文各看一遍,确认端口、长度、payload都对,再谈别的。抓包看到一个incorrect checksum先别慌,先想到offload;看到recvfrom没数据,先查netstat -su的丢包计数。这些经验都是踩坑踩出来的,希望你不用再踩一遍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询