Linux UDP编程从入门到实践:C语言实现服务端与客户端

📅 发布时间:2026/10/19 4:56:03
Linux UDP编程从入门到实践:C语言实现服务端与客户端
如果你正在学Linux网络编程我建议先别急着扑向TCP那一堆三次握手、四次挥手的细节先把UDP吃透。UDP的代码量小、逻辑直白跑通一遍之后你对socket、端口、地址结构、内核收发缓冲区的理解会立刻落地。这篇文章我用完整的C语言示例带你从API底层逻辑一路写到可以跑起来的服务端和客户端顺带把我在实际项目中踩过的坑和排错手段也一并交代清楚。1. 先想明白UDP和TCP差在哪你的代码设计才会对1.1 无连接不是不严谨而是把可靠性责任交还给你UDPUser Datagram Protocol用户数据报协议和TCP最大的区别只有一个词无连接。TCP在传输数据之前要建立连接三次握手确认双方都在线传输过程中有序列号、确认应答、超时重传、拥塞控制这一整套机制而UDP直接把数据报扔出去不管对方收没收到不管顺序乱没乱也不管网络堵不堵。很多初学者觉得UDP低级其实不是。UDP把可靠性这件工作从协议栈里挪到了应用层意味着你可以自己决定什么数据需要重传、什么数据丢了就算了。举个例子TCP像寄挂号信每封信都要签收确认UDP像往邮筒里扔明信片你只管投递至于对方什么时候收到、有没有收到那不是邮筒的事。实时音视频通话里的画面帧、游戏里每秒几十次的角色位置更新这些数据本身就有时效性——你重传一个500毫秒之前的位置已经没有意义了还不如直接发最新的。1.2 哪些项目真正适合走UDP从我接触过的场景看UDP的用武之地主要集中在这几类实时音视频传输语音、视频流对延迟极其敏感偶尔丢一帧画面人眼根本察觉不到但一旦重传卡顿用户立刻有感知。游戏状态同步尤其是FPS、MOBA这类高频状态上报场景位置、朝向、血量更新频率极高丢一两个包无伤大雅下一帧数据马上就到。DNS查询DNS协议本身就跑在UDP上查询包那么小一个往返就能搞定没必要为它建连接。物联网设备状态上报传感器、智能硬件的状态数据通常几秒上报一次丢一次等下个周期再报就行。自定义协议的上层可靠性机制很多人会在UDP之上自己实现类似TCP的确认重传逻辑就是为了精确控制哪些数据需要可靠哪些不需要。下面这个表可以帮你快速做架构决策对比项TCPUDP连接状态有连接需要握手/挥手无连接直接发包数据边界字节流需要自己处理粘包/拆包有消息边界一次sendto对应一次recvfrom可靠性内核保证重传、排序、去重内核不保证丢不丢看网络传输效率头部开销大有确认机制头部开销小8字节延迟低编程复杂度高还要处理并发连接低一个socket收、一个socket发即可选型的时候记住一句话如果你的数据丢了会影响正确性、而且不能接受重传延迟那就老老实实用TCP如果时效性比可靠性更重要或者数据天然具有过期无效特性UDP是更合适的选择。2. 动手前必须搞懂的API底层逻辑2.1 socket()SOCK_DGRAM的含义以及第三个参数为什么写0在Linux里创建UDP套接字用的是这一行int sockfd socket(AF_INET, SOCK_DGRAM, 0);第一个参数AF_INET指定使用IPv4地址族第二个参数SOCK_DGRAM意思是数据报套接字这是UDP区别于TCP的关键——TCP对应的是SOCK_STREAM流式套接字。第三个参数传0表示让内核根据地址族和套接字类型自动选择协议。对于AF_INET SOCK_DGRAM内核会默认选IPPROTO_UDP对于AF_INET SOCK_STREAM默认选IPPROTO_TCP。所以你必须理解一点**UDP套接字和TCP套接字在创建的那一刻行为模式就已经分道扬镳了。**UDP套接字没有连接状态没有发送缓冲区的排队重传逻辑它更像一个邮筒口每次sendto就是把一份独立的邮件丢进去。2.2 bind()服务端必须绑定客户端大多数时候不用服务端要接收数据必须告诉内核我在哪个IP的哪个端口上等邮件这就是bind()做的事struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); // 监听所有网卡 server_addr.sin_port htons(8888); bind(sockfd, (struct sockaddr *)server_addr, sizeof(server_addr));这里有两个新手必踩的点。第一INADDR_ANY表示绑定本机所有网络接口的IP这样无论是回环地址127.0.0.1、内网IP还是公网IP进来都能收到数据。如果你只想让某个特定IP接收可以改成inet_pton(AF_INET, 192.168.1.100, server_addr.sin_addr)。第二端口号必须用htons()转换字节序。因为x86这类小端机器上内存里的整数跟网络传输要求的大端字节序是反的htonshost to network short就是做这个转换的。客户端绝大多数情况下不需要bind()。你没绑端口内核会在第一次sendto的时候自动给你分配一个临时端口ephemeral port一般是32768到60999之间这个端口在来回通信期间会被内核记住服务端的回包也能准确送到你手上。只有一种情况客户端必须自己bind()你希望使用固定的源端口比如防火墙只放行了某个端口或者你想让服务端能区分不同的客户端实例。2.3 sendto/recvfrom为什么每次收发都要带上对方地址这是UDP编程跟TCP编程体验差异最大的地方。TCP的send()和recv()不需要带对方地址因为连接建立后内核已经帮你记住了通信对端而UDP是无连接的同一个socket可以跟任意多个对端通信所以每次收发都必须显式传入对端地址sendto(sockfd, buf, len, 0, (struct sockaddr *)dest_addr, sizeof(dest_addr)); recvfrom(sockfd, buf, BUFSIZE, 0, (struct sockaddr *)src_addr, addr_len);注意这里有个C语言里经典的丑陋设计struct sockaddr_in和struct sockaddr长度不同但API统一接收struct sockaddr*指针所以必须强制转换。addr_len在recvfrom里是值-结果参数调用前你要把它初始化为sizeof(struct sockaddr_in)调用后它会变成实际写入的地址长度。这个细节如果漏了某些内核版本上会直接返回EINVAL错误。另外要区分一个概念recvfrom返回的是数据报的实际字节数。TCP里的recv可能会因为缓冲区大小只返回半包而UDP的recvfrom要么拿到完整的一个数据报要么由于缓冲区太小被截断这个后面专门讲。3. 代码实战从零写一个可跑的UDP回声服务与客户端3.1 服务端完整代码与逐段讲解下面是一个最基本的UDP回声服务端收到什么就原样发回什么。别小看这个程序它是验证网络链路、排查防火墙、测试广播行为的最佳工具。// udp_server.c #include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define SERVER_PORT 8888 #define BUFFER_SIZE 1024 int main(void) { int sockfd; struct sockaddr_in server_addr, client_addr; socklen_t addr_len sizeof(client_addr); char buffer[BUFFER_SIZE]; // 1. 创建UDP套接字 sockfd socket(AF_INET, SOCK_DGRAM, 0); if (sockfd 0) { perror(socket); exit(EXIT_FAILURE); } // 2. 填充地址结构并绑定 memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); server_addr.sin_port htons(SERVER_PORT); if (bind(sockfd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(bind); close(sockfd); exit(EXIT_FAILURE); } printf(UDP server listening on port %d\n, SERVER_PORT); // 3. 循环收包、回包 while (1) { memset(buffer, 0, BUFFER_SIZE); ssize_t n recvfrom(sockfd, buffer, BUFFER_SIZE - 1, 0, (struct sockaddr *)client_addr, addr_len); if (n 0) { perror(recvfrom); continue; } buffer[n] \0; char client_ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, client_addr.sin_addr, client_ip, sizeof(client_ip)); printf(recv %zd bytes from %s:%d: %s\n, n, client_ip, ntohs(client_addr.sin_port), buffer); // 原样回发给对端 sendto(sockfd, buffer, n, 0, (struct sockaddr *)client_addr, addr_len); } close(sockfd); return 0; }几个值得注意的工程习惯。recvfrom的第三个参数我写的是BUFFER_SIZE - 1然后Received之后手动buffer[n] \0这是为了确保后面printf(%s)不会越界读内存——虽然UDP是二进制安全协议但你的日志打印如果按字符串处理就必须做好终止符。recvfrom的返回值ssize_t是带符号的判断n 0来捕获错误至于返回0的情况UDP里一个零长度的数据报是合法存在的所以不要用n 0去判断对端关闭——UDP根本没有关闭这回事。打印客户端信息时inet_ntop把二进制IP转成点分十进制字符串ntohs把网络字节序的端口转回主机字节序。这些转换函数看起来繁琐但在实际联调时能救你的命你会第一时间知道数据到底是从哪个IP哪个端口来的是不是预期的那个客户端。3.2 客户端完整代码与逐段讲解客户端这边我让它接收两个命令行参数服务器IP和要发送的消息。之所以不硬编码IP是因为这个程序既能发给本机回环也能发给局域网里的另一台机器。// udp_client.c #include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define SERVER_PORT 8888 #define BUFFER_SIZE 1024 int main(int argc, char *argv[]) { int sockfd; struct sockaddr_in server_addr; char buffer[BUFFER_SIZE]; if (argc ! 3) { fprintf(stderr, Usage: %s server_ip message\n, argv[0]); exit(EXIT_FAILURE); } sockfd socket(AF_INET, SOCK_DGRAM, 0); if (sockfd 0) { perror(socket); exit(EXIT_FAILURE); } memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(SERVER_PORT); if (inet_pton(AF_INET, argv[1], server_addr.sin_addr) 0) { perror(inet_pton); close(sockfd); exit(EXIT_FAILURE); } // 发送数据报 sendto(sockfd, argv[2], strlen(argv[2]), 0, (struct sockaddr *)server_addr, sizeof(server_addr)); // 等待服务端回声 memset(buffer, 0, BUFFER_SIZE); socklen_t addr_len sizeof(server_addr); ssize_t n recvfrom(sockfd, buffer, BUFFER_SIZE - 1, 0, (struct sockaddr *)server_addr, addr_len); if (n 0) { perror(recvfrom); close(sockfd); exit(EXIT_FAILURE); } buffer[n] \0; printf(server reply: %s\n, buffer); close(sockfd); return 0; }inet_pton是presentation to network的缩写负责把192.168.1.10这种字符串转成二进制的struct in_addr。它返回1表示成功0表示字符串格式不对-1表示地址族不支持。这里用 0做判断把两种失败情况都覆盖了。注意客户端recvfrom的目标地址缓冲区复用了一个新的addr_len。我在客户端没有bind()内核自动分配源端口服务端回包时就是回到这个临时端口上。这个回声交互模型是最简单的请求-响应范式实际项目里的服务发现、心跳检测基本都是这个套路。3.3 编译、运行与结果验证两个文件都准备好之后编译非常简单gcc -Wall -o udp_server udp_server.c gcc -Wall -o udp_client udp_client.c先开一个终端跑服务端./udp_server # 输出: UDP server listening on port 8888再开另一个终端跑客户端./udp_client 127.0.0.1 hello udp服务端终端会打印recv 9 bytes from 127.0.0.1:52341: hello udp客户端终端会打印server reply: hello udp注意服务端打印的端口52341不是固定的它是我本机内核随机分配的临时端口。每次运行客户端这个端口都不一样这是正常的。如果你想验证跨机通信把服务端放到一台Linux机器上客户端里的IP改成那台机器的内网IP前提是两台机器网络互通、防火墙放行了UDP 8888端口。我在实际使用中还会配合nc做快速冒烟测试——不想每次写客户端的时候直接一条命令就能往UDP端口发消息echo test from nc | nc -u -w1 127.0.0.1 8888服务端同样能收到并回包nc这边也能看到回显。-w1表示最多等1秒的响应时间避免卡死。这种临时工具在联调阶段非常省事。4. 实战中绕不开的五个细节坑4.1 接收缓冲区UDP为什么也会丢包以及怎么观察很多人以为UDP丢包只发生在网络上其实本机内核缓冲区也会丢。每个UDP套接字都有接收缓冲区默认大小可以通过下面的命令看cat /proc/sys/net/ipv4/udp_rmem_min # 通常是 4096单位是字节实际生效的缓冲区大小还会受net.core.rmem_max等参数影响。当数据到达的速度超过你的程序从recvfrom读走数据的速度时内核缓冲区塞满后续到达的数据报会被直接丢弃而且你的进程根本感知不到——没有异常没有日志下一个recvfrom读到的是更早的数据。这就是UDP静默丢包的一种典型形态。想扩大缓冲区可以用setsockoptint rcvbuf_size 4 * 1024 * 1024; // 4MB setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, rcvbuf_size, sizeof(rcvbuf_size));注意内核实际分配的大小往往是设定值的两倍左右因为内核还要留一部分空间给元数据结构所以如果你设置4MBgetsockopt读回来可能是8MB上下别惊讶。还有一种主动感知丢包的办法在应用层报文里带上序号。比如客户端每发一个包头部带一个自增的counter服务端检测到序号不连续就知道中间丢了。很多实时传输协议就是这么做的。这个方法比任何内核参数都直观也更能反映端到端的真实丢包率。4.2 超时控制让recvfrom不再无限等待默认情况下recvfrom是阻塞的如果客户端发完消息之后服务端一直不回程序会一直卡在那。这个问题在实际项目里很致命——你不能让一个线程永远挂起。解决办法是通过SO_RCVTIMEO给套接字设置接收超时struct timeval tv; tv.tv_sec 3; // 3秒 tv.tv_usec 0; setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv));设置了超时之后如果3秒内没有数据到达recvfrom会返回-1并且errno被设为EAGAIN又叫EWOULDBLOCK。判断方式ssize_t n recvfrom(sockfd, buffer, BUFFER_SIZE - 1, 0, (struct sockaddr *)client_addr, addr_len); if (n 0) { if (errno EAGAIN || errno EWOULDBLOCK) { printf(recvfrom timeout, no data in 3s\n); // 做你的重试或超时处理 } else { perror(recvfrom); } }这里有个细节EAGAIN和EWOULDBLOCK在Linux上是同一个值所以||两边都有也不会有问题。超时机制在请求-响应模型里是标配比如DNS客户端、SNMP采集器都会给每个请求设定超时上限避免一个不可达的服务器把整个采集流程拖死。4.3 消息边界粘包这个词在UDP里该怎么理解很多从TCP转过来的同学会问UDP要不要处理粘包答案是UDP天然没有粘包问题。sendto一次调用发出的数据在接收端recvfrom一次调用恰好把它读出来一个数据报就是一个完整的消息内核帮你维护了这个边界。你连续sendto十次对端recvfrom十次每次读到内容和你发送时一一对应。但有个前提你的接收缓冲区要放得下整个数据报。如果BUFFER_SIZE小于消息长度情况就变成recvfrom返回缓冲区容量大小的字节前面的部分被读了后面多余的部分被内核直接丢弃。而且你没法通过再次调用recvfrom去拿剩余部分——那个数据报已经没了下一个读到的就是之后的新消息。所以UDP接收缓冲区要按可能出现的最大报文长度来设置而不是够用就行。通常建议设成65535——这是UDP理论上的最大报文长度64KB再大就需要在IP层分片了分片会引入更多的丢包可能。这也是为什么很多UDP协议设计时会把单包大小限制在1400字节左右就是为了避免IP分片。4.4 多客户端并存单线程回声服务为什么也够用TCP服务端处理多客户端要么多线程要么事件循环因为每个连接都是独立状态而UDP服务端很多时候一个单线程循环就够了。原因在于recvfrom每次都会把来源地址带回来你的服务天然是无状态应答的——谁发来的包就回给谁不需要记录连接状态。如果业务逻辑很简单收到、处理、回包单线程UDP服务甚至可以跑得很稳处理几千个客户端的请求。只有当处理逻辑耗时较长、或者同一客户端的多个请求之间存在先后依赖时你才需要引入多线程或者队列。实战中常见做法是接收线程只负责收包入队工作线程池负责处理业务和回包。这是UDP服务从demo走向生产环境的关键一步。回包的时候还要注意sendto的对端地址要用recvfrom返回的那个src_addr千万不要自己在应用层维护一个客户列表。客户端没有固定的连接状态它的IP和端口可能每个包都变直接使用每次收包得到的最新地址是最稳妥的。4.5 广播与组播一句话说清适用场景UDP还有一个TCP完全做不到的能力广播。向子网内所有主机发消息只需要把目标IP设成255.255.255.255同时在setsockopt里打开SO_BROADCAST选项int broadcast_enable 1; setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, broadcast_enable, sizeof(broadcast_enable));然后直接sendto到255.255.255.255:8888同网段所有监听8888端口的UDP服务端都能收到。这个机制在局域网设备发现、服务发现场景里非常常用。但广播有局限不能跨网段传播而且会对网络上所有主机造成负载。跨网段的场景要用组播多播地址范围是224.0.0.0到239.255.255.255配合IP_ADD_MEMBERSHIP加入组才能收到发往该组的消息。组播配置比广播复杂一截需要设置接口和组地址结构体struct ip_mreq篇幅关系这里不展开但记住一个方向设备发现用广播跨网段分发用组播一对一通信用单播。5. 排错与调试UDP编程最容易踩的坑5.1 第一件事先用本机回环验证联通性我的习惯是任何UDP程序写好之后第一轮测试永远是127.0.0.1。回环测试通过说明代码本身的API调用、缓冲区管理、地址转换都是对的回环失败的话排除掉网络因素问题一定在代码里。然后再测局域网内第二台机器最后才测跨网段。回环测试都失败的常见原因端口被占用、bind失败、防火墙把本地回环的UDP也拦了极少见但存在。这时候优先看服务端启动时有没有打印bind错误。bind失败最常见的是EADDRINUSE意思是端口被其他进程占了。# 查看哪个进程占了8888端口 ss -ulpn | grep 8888查出来之后要么换个端口要么把占用进程停掉。如果业务上确实需要频繁重启服务端建议在bind之前设置SO_REUSEADDR允许端口快速重用避免TIME_WAIT状态造成的短暂绑定失败。5.2 常见错误码与根因EINVALrecvfrom的addr_len初始值不正确或者传入的地址结构长度非法。EADDRINUSE端口被占用或者没设SO_REUSEADDR导致重启后立即绑定失败。EHOSTUNREACH / ENETUNREACH目标IP不可达。先ping一下再检查路由表。ECONNREFUSED这个错误最迷惑人——UDP没有连接哪来的拒绝其实这是ICMP端口不可达的异步通知。当你向一个没有进程监听的UDP端口发包中间某台机器会回一个ICMP错误把这个错误关联到你这个socket上下一次相关的socket操作就可能返回ECONNREFUSED。解决办法忽略这个错误或者设置SOCK_RAW去单独处理ICMP。还有一个细节很多教程不写错误码不一定出现在sendto调用本身。UDP的sendto只要把数据交给内核缓冲就返回成功了网络不可达、目标关闭等错误往往在之后的某个sendto或recvfrom上才冒出来。所以调试UDP程序看到莫名其妙的后置报错先回忆一下上一次sendto是不是出了问题。5.3 用tcpdump观察数据包比printf更可靠的排错方式有时候程序和程序之间链路是通的但你就是怀疑数据根本没到。与其在代码里加一堆printf不如直接抓包看数据流。Linux下最常用的就是tcpdump# 监听所有网卡上的8888端口UDP流量 sudo tcpdump -i any udp port 8888 -vv执行之后再跑客户端你会看到类似这样的输出IP 127.0.0.1.52341 127.0.0.1.8888: UDP, length 9 IP 127.0.0.1.8888 127.0.0.1.52341: UDP, length 9第一行是客户端发给服务端的包第二行是服务端回给客户端的包。如果你只看到第一行看不到第二行说明服务端收到包之后sendto出了问题或者回包地址写错了。如果你连第一行都看不到问题在客户端、防火墙或者网络路径上。抓包时加一个-X参数还能看到包里的十六进制内容和ASCII字符方便确认你发的消息内容有没有在传输过程中被改坏。对于排查我明明发了A为什么对方收到B这种诡异问题抓包是最快的判定手段。5.4 关于缓冲区大小和数据长度的几个实测心得根据我这几年跑UDP服务的经验有几点值得单独拿出来说。第一别迷信BUFFER_SIZE 65535就万事大吉。虽然那是UDP的理论上限但实际网络中很多中间设备会对超过MTU通常是1500字节的数据报进行分片或直接丢弃。跨公网传输时建议把单条UDP报文控制在1472字节以内1500减去20字节IP头再减去8字节UDP头这是无需分片的最大安全值。本地回环测试可以放松生产环境要按这个标准来。第二日志一定要带对方地址。我在代码里每次都打印客户端IP和端口听起来啰嗦但排查问题时价值极大。曾经有一次服务端收到的数据量远超预期靠日志里的来源IP一眼看出是某个测试脚本没关、还在疯狂发包而不是代码逻辑问题。第三验证UDP丢包需要端到端测试。只在本机回环上测几乎不会丢包真要验证你的程序在真实网络环境下的表现最好在两端机器上同时跑一个带序号的收发程序发一万个包统计接收方缺了多少序号这样才能得出真实的丢包率数据。如果你要做可靠性更强的UDP方案可以考虑这几个方向应用层加序号和确认机制、引入向前纠错编码、或者干脆用现成的可靠UDP库。但万变不离其宗先把今天这套基础API玩熟后面无论怎么扩展都有底气。我在实际项目里体会最深的一点是UDP编程的入门门槛低但写好的门槛很高。它不像TCP那样有内核帮你兜底所有细节都暴露在你面前——缓冲区、超时、丢包、地址管理每一个都是应用层的责任。把这些问题一个个想清楚、在代码里一步步落实你的网络编程功力就真正长在身上了。希望这篇文章能帮你少走几步弯路。