资讯详情

Linux UDP网络编程:C/S模型从核心API到回显服务器实战

📅 2026/10/20 3:10:02 | 华诺云谱 👁 阅读
Linux UDP网络编程:C/S模型从核心API到回显服务器实战
如果你刚开始写Linux网络编程多半会被TCP的三次握手、四次挥手、状态迁移表绕得头晕。我的建议正好相反先用UDP的C/S模型练手成本低反馈直接几分钟就能跑通一个能互相通信的程序。这篇文章就把Linux下UDP编程的C/S模型从头到尾捋一遍从API、bind、sendto、recvfrom这些基础函数到回显服务器、超时重传、广播多播再聊到实际项目中特别容易踩的丢包和收不到包问题。我不打算做成教科书式的罗列而是用我自己调过UDP服务、被线上数据坑过之后的角度来讲尽量让你看完能直接照着写。1. UDP的C/S模型到底长什么样1.1 为什么UDP比TCP更容易“上手”UDP和TCP最大的区别就是“无连接”。TCP在收发数据之前得先经历三次握手建立连接结束后还得四次挥手断开连接连接状态放在内核里你得关心accept、close、time_wait这些状态。UDP不需要这些进程之间没有“连接”这回事。你把数据报扔给某个IP和端口它就走了别人往你的端口扔数据报你随时能接。内核没有为“这条路”维护状态所以程序从结构上说天然就简单很多。很多新手总觉得“无连接就是不安全、不可靠”这个说法一半对一半错。所谓不可靠指的是UDP不保证数据一定能送达、送达后顺序不变、不丢不重。但UDP的不可靠恰恰是可控的发送方就是我接收方就在那我并不需要一台服务器为每个客户端保存一堆连接状态。很多实时性要求高、自己能做重传协议的场景比如音视频流、游戏位置同步、监控数据上报反而用UDP更合适。C/S模型放在UDP上只是“一个进程负责收一个进程负责发”这样的角色分配更清晰。1.2 C/S模型在UDP下还是“你来我往”吗传统C/S模型很容易让人联想到客户端请求、服务器响应的同步过程。TCP的C/S模型因为有了连接服务器端每个客户端都占用一套文件描述符代码上要处理accept之后的新连接。用UDP写C/S模型最大的区别是服务器只有一个socket文件描述符它不需要accept也没有连接数上限。所有客户端都往同一个端口发包服务器通过recvfrom拿到来源地址再通过sendto把响应发回去。我在实际项目里经常把UDP的C/S模型理解成“邮箱模型”。服务器在某个地址挂了一个邮箱谁来投递信件都能投谁的信件到了就取出来然后根据信封上的地址回信。这个过程中“服务器”不掌握任何客户端的长期状态消息之间互相独立。你的收发函数天然就是对称的sendto负责把数据报发出去recvfrom负责从socket接收数据报。对初学者来说理解这层对称性之后写UDP代码往往比写TCP代码更顺手。1.3 你适合在哪些场景用UDP的C/S模型不是所有业务都适合UDP但下面这几类场景使用UDP反而比TCP更合理。实时性要求高的场景语音、视频通话、在线游戏中的位置和操作同步。这类数据即使偶尔丢一帧也不值得因为重传导致后续数据全部卡住。能容忍丢包但需要低延迟的场景监控指标采集、日志上报。丢了可以等下一条重传反而拖垮发送速度。需要广播或多播的场景局域网设备发现、时钟同步、组播视频分发。TCP没法真正做到一对多分发UDP天然支持广播和组播。自定义协议的场景你打算在传输层之上自己实现确认、重传、拥塞控制那么UDP给了你最大的协议设计自由度。很多企业级高并发网关底层就是UDP加自研传输协议。反过来如果你写的是文件传输、数据库同步、请求响应强一致的服务UDP就不合适。除非你愿意花大量精力去实现类似TCP的可靠性机制但那样往往比直接用TCP复杂得多。2. 核心API与基础骨架socket、bind、sendto、recvfrom2.1 四个函数搞定99%的UDP代码Linux下UDP编程最核心的系统调用其实就这几个socket()创建套接字bind()把套接字绑定到本地地址和端口sendto()发送数据报recvfrom()接收数据报最后用close()关闭。比起TCP少了一套accept/listen/connect流程。就算把边缘情况都算上真正直接打交道的主要还是这四个函数加地址结构体。socket()创建UDP套接字时类型参数写SOCK_DGRAM协议参数一般写0让内核去选择IPPROTO_UDP。注意不要用SOCK_STREAM那是TCP的。很多人一开始写socket(AF_INET, SOCK_DGRAM, 0)的时候不理解第三个参数这里说明一下第三个参数是协议号0表示让系统根据地址族和套接字类型自动推断。bind()在服务器端是必做的。你不bind内核就随机给你分配一个端口客户端根本不知道往哪个端口发。客户端通常不需要bind因为客户端只是发一个数据报出去服务器通过recvfrom能拿到客户端的临时端口和IP直接就能回复。但如果你希望客户端固定端口或者客户端也需要被其他人主动发现那么客户端bind一个固定端口也完全可以。sendto和recvfrom这两个函数都有地址参数。sendto需要“目的地址”recvfrom需要“来源地址”。记住这个对照关系后面看代码就容易了。2.2 服务器端绑定流程拆解服务器端的第一步是创建socket第二步是初始化自己的地址结构体第三步bind上去第四步循环recvfrom。地址结构体struct sockaddr_in里sin_family赋值为AF_INETsin_port用htons()转换端口号sin_addr.s_addr用htonl(INADDR_ANY)表示监听所有本机网卡。这里最重要的坑就是字节序网络字节序是大端x86机器上整数是小端必须用htons、htonl把主机字节序转成网络字节序。就算端口是9090直接赋给sin_port而不做转换的话收到包解析出来的端口很可能就是错的。bind失败最常见的原因是端口被占用或者你没有权限绑定1024以下的端口。我实习的时候绑过1069端口后来同一个进程没关干净bind一直返回EADDRINUSE查了很久才发现是上一个测试进程还占着端口。用ss -ulnp可以查看正在监听的UDP端口和进程PID排查这种问题很快。服务器循环接收数据时recvfrom的最后一个参数必须初始化为struct sockaddr_in结构体的大小。这个参数是“值-结果”参数函数会通过它告诉调用者实际的数据来源地址有多长。很多人第一次写时忘了初始化len导致recvfrom返回EINVAL这个错非常隐蔽。2.3 客户端的“免绑定”设计客户端代码比服务器简单因为客户端可以不bind。socket创建好之后把服务器地址填进struct sockaddr_in结构体直接用sendto发给服务器。内核会在第一次sendto时自动为这个套接字分配一个源端口这个端口是临时的服务器收到数据报后从recvfrom的来源地址里能看到它并用它作为回信地址。所以客户端不需要关心自己的端口只需要知道服务器的IP和端口。这里又引申出一个功能如果客户端用connect()函数把UDP套接字和服务端地址绑定在一起后续就可以直接调用send()和recv()而不必每次带地址参数。这个connect不是TCP三次握手它实际上只是在内核里记录“默认对端”同时还能让内核返回一些与目的地址相关的ICMP错误比如端口不可达。对需要长期通信的UDP客户端来说connect之后写起来更简洁。我在实际代码里客户端一般会做两件事一是设置SO_RCVTIMEO超时防止recvfrom永久阻塞造成程序卡死二是把服务器IP用inet_pton转成网络字节序的二进制地址而不是用老旧的inet_addr。2.4 字节序与地址转换别等到线上才查struct in_addr里面的s_addr是32位二进制网络字节序人写代码习惯性会写成“192.168.1.10”这种点分字符串所以必须做转换。二进制和字符串互转推荐使用inet_pton/inet_ntop它们支持IPv4和IPv6且能正确处理错误。老接口inet_addr不支持IPv6而且它在出错时返回INADDR_NONE这个值本身也是一个合法地址隐蔽性太强最好别用。对于本地地址我经常看到有人写servaddr.sin_addr.s_addr inet_addr(127.0.0.1)也有人在服务器上写servaddr.sin_addr.s_addr htonl(INADDR_ANY)。前者只能绑回环后者绑所有网卡。如果希望服务器在内网里能被其他机器访问直接用INADDR_ANY最省事。要是绑了127.0.0.1就算客户端用192.168段IP来访问包也不会进来。3. 实战可靠UDP回显服务器3.1 服务器端实现与逐行拆解直接上一个最经典的回显服务器客户端发什么服务器就原样返回什么。它虽然简单但把UDP的收、发、地址解析全打通了。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include arpa/inet.h #include sys/socket.h #define PORT 9090 #define MAXLINE 1024 int main(void) { int sockfd; struct sockaddr_in servaddr, cliaddr; char buffer[MAXLINE]; socklen_t len sizeof(cliaddr); sockfd socket(AF_INET, SOCK_DGRAM, 0); if (sockfd 0) { perror(socket); exit(EXIT_FAILURE); } memset(servaddr, 0, sizeof(servaddr)); servaddr.sin_family AF_INET; servaddr.sin_addr.s_addr htonl(INADDR_ANY); servaddr.sin_port htons(PORT); if (bind(sockfd, (struct sockaddr *)servaddr, sizeof(servaddr)) 0) { perror(bind); exit(EXIT_FAILURE); } for (;;) { int n recvfrom(sockfd, buffer, MAXLINE, 0, (struct sockaddr *)cliaddr, len); if (n 0) { perror(recvfrom); continue; } buffer[n] \0; printf(received %d bytes from %s:%d: %s\n, n, inet_ntoa(cliaddr.sin_addr), ntohs(cliaddr.sin_port), buffer); sendto(sockfd, buffer, n, 0, (struct sockaddr *)cliaddr, len); } close(sockfd); return 0; }这段代码的要点很集中recvfrom里的cliaddr用来接收客户端地址len在每次recvfrom之前都要初始化为sizeof(cliaddr)sendto的最后一个参数直接沿用recvfrom返回的len就行。缓冲区大小MAXLINE我设的是1024字节如果你要收更大的数据报可以调成65536或者用recvmsg配合动态缓冲区。用printf打印客户端IP时inet_ntoa返回的是静态缓冲区多线程环境下不能用但演示场景没问题。3.2 客户端带超时重传怎么实现回显服务器很简单客户端却不能无条件等下去。UDP场景下的“可靠”从来不是内核给的是应用层自己做的。最基础的一招就是给recvfrom设置接收超时超时后重发数据。下面这个客户端流程是我在实际测试环境里常用的模板。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include arpa/inet.h #include sys/socket.h #include sys/time.h #define SERVER_PORT 9090 #define MAXLINE 1024 int main(int argc, char *argv[]) { int sockfd; struct sockaddr_in servaddr; char buffer[MAXLINE]; socklen_t len sizeof(servaddr); struct timeval tv {2, 0}; if (argc ! 2) { fprintf(stderr, usage: %s server_ip\n, argv[0]); exit(EXIT_FAILURE); } sockfd socket(AF_INET, SOCK_DGRAM, 0); if (sockfd 0) { perror(socket); exit(EXIT_FAILURE); } if (setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv)) 0) { perror(setsockopt); exit(EXIT_FAILURE); } memset(servaddr, 0, sizeof(servaddr)); servaddr.sin_family AF_INET; servaddr.sin_port htons(SERVER_PORT); if (inet_pton(AF_INET, argv[1], servaddr.sin_addr) 0) { perror(inet_pton); exit(EXIT_FAILURE); } strcpy(buffer, hello udp server); sendto(sockfd, buffer, strlen(buffer), 0, (struct sockaddr *)servaddr, sizeof(servaddr)); printf(sent: %s\n, buffer); int n recvfrom(sockfd, buffer, MAXLINE, 0, (struct sockaddr *)servaddr, len); if (n 0) { if (errno EAGAIN || errno EWOULDBLOCK) { printf(timeout, no response\n); close(sockfd); return 1; } perror(recvfrom); close(sockfd); return 1; } buffer[n] \0; printf(received: %s\n, buffer); close(sockfd); return 0; }关键在SO_RCVTIMEO把接收超时设为2秒。当recvfrom超时返回时errno是EAGAIN或EWOULDBLOCK代码里要判断这个错误而不是直接perror。重传的逻辑我没写进这个例子但思路你一看就明白循环发送每次计数recvfrom超时就count超过最大次数就认定服务不可用中间只要收到了响应就跳出循环。有人会问为什么不用SO_SNDTIMEO因为sendto只是在把数据提交给内核缓冲区时返回代表的含义是“内核收下了”不代表对端一定收到。真正可能超时的地方是等待对端响应所以超时重传的重点在recvfrom。3.3 UDP报文大小与分片问题UDP报文长度上限理论上是65535字节扣掉IP头20字节和UDP头8字节应用层最多667字节不对最多65507字节。但这只是理论值实际网络里绝大多数路径MTU是1500字节扣掉IP头20字节和UDP头8字节应用层一次sendto超过1472字节时IP层就会发生分片。分片后的数据报在网络上独立传输只要一片丢了整个数据报都算丢了。分片带来的问题不只是丢包率上升。很多防火墙和中继设备会直接丢弃分片包尤其是首片之外的片。我自己调一个NAT网关时遇到过这个情况客户端把超过MTU的数据报切成好几片到服务器时只剩一部分recvfrom收到的数据不完整application完全没法解析。排查半天发现是某个交换机配置了分片限制。所以工程上的建议是如果确认在局域网环境报文长度控制在1400字节以内比较稳妥给IP和UDP头留出余量如果要在公网传输建议应用层分包每包控制在1200字节左右并且加上序号字段接收方根据序号重组。不要指望靠扩大缓冲区来避免分片。3.4 多客户端并发单线程够用吗UDP服务器用单线程循环收包代码最简单性能在多数场景下也够用。很多人担心多个客户端同时发来请求会不会因为只处理一个而丢掉其他包。实际上不会。内核socket接收缓冲区会暂存来不及处理的数据报recvfrom只是从缓冲区里取一个包其他包还在队列里等着。只要缓冲区不溢出单线程也能顺序处理完所有请求。单线程的瓶颈出现在“处理每个包耗时过长”的场景。比如服务器收到包之后要查数据库、调第三方接口耗时几百毫秒那么这段时间socket接收缓冲区可能被填满内核开始丢弃后续数据报。这种情况下与其说并发不够不如说处理逻辑太慢。真要高并发有几个方向第一用多线程各自调用recvfrom配合SO_REUSEPORT让内核把数据报分发到多个socket队列上第二用epoll加事件循环把收包和业务处理解耦收到包后放进内存队列worker线程去处理第三把校验、序列化这些CPU密集操作拆到不同线程。但这些都是后面的话题先把单线程模型跑明白再往上加并发会容易得多。4. 从单播到广播与多播4.1 局域网广播实现UDP广播能让一个数据报发给同一广播域里的所有主机。比如你写了一个设备发现协议设备启动后向255.255.255.255发一条“我是谁”的广播局域网里所有监听同一端口的服务器都能收到。实现广播只需要两步setsockopt设置SO_BROADCASTsendto目标地址填广播地址。int broadcast 1; setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, broadcast, sizeof(broadcast));目标地址可以是255.255.255.255也可以是子网定向广播地址比如192.168.1.255。服务器端不需要特殊设置bind到端口后recvfrom就能收到。广播的代价是同一广播域内的所有主机都会被这个包打断一下即使它们不处理UDP网卡也得把包传上来做过滤这会增加无关机器的负载。所以广播只适合数据量小、频率低的场景。我做过一个内网设备留痕功能每台设备启动时发一条广播到固定端口管理端监听后自动登记设备IP。这个方案优点是部署零配置缺点是当设备数量到几千台之后广播风暴会很吓人。后期改成组播才把网络压力降下来。4.2 多播订阅实例多播比广播优雅的地方在于它基于组地址只有加入了某个多播组的主机才会收到发往该组的数据报。IP多播地址范围是224.0.0.0到239.255.255.255。发送方把数据报发往多播地址比如239.255.42.99接收方需要先加入这个多播组才能收到数据。加入多播组的关键调用是setsockopt(IPPROTO_IP, IP_ADD_MEMBERSHIP)。先填一个struct ip_mreq结构体里面有两个字段imr_multiaddr是多播组地址imr_interface是本地网卡地址通常填INADDR_ANY表示用默认网卡。struct ip_mreq mreq; mreq.imr_multiaddr.s_addr inet_addr(239.255.42.99); mreq.imr_interface.s_addr htonl(INADDR_ANY); setsockopt(sockfd, IPPROTO_IP, IP_ADD_MEMBERSHIP, mreq, sizeof(mreq));发多播时sendto的目标IP写多播地址即可。如果要控制多播包的TTL用IP_MULTICAST_TTL选项默认值是1意味着只在本网段传播。如果路由器开启了多播路由协议你想让包跨网段就得把TTL调大。实际项目里我用多播做监控数据分发避免每台服务器单独建TCP连接。需要注意公网上一般不允许随意使用多播多数ISP会屏蔽多播主要用于可控的内网环境。4.3 广播和多播的常见坑第一忘记设置SO_BROADCAST。普通UDP发送到广播地址时如果没设置这个选项sendto会直接返回EACCES。但设置了这个选项之后你也能正常给单播地址发包所以它会一直开着。第二多播地址的字节序。imr_multiaddr赋值必须用inet_addr这里不用htonl因为inet_addr返回的已经是网络字节序的二进制地址。如果画蛇添足再转一次加入的多播组就不是你想要的组收包自然失败。第三接收方必须先加入组再绑定端口。Linux上多播socket的bind地址建议用INADDR_ANY不要bind到具体网卡IP否则某些协议栈实现会过滤掉多播包。加入组之后recvfrom拿到的源地址是发送方的单播地址不是多播地址别搞混了。5. 常见问题排查与性能避坑5.1 收不到包先按这个顺序查UDP编程最常见的现象就是“客户端发了服务器没反应”。我每次排查都按下面这个顺序走一遍基本几分钟就能定位。第一步确认服务器进程真的在监听端口ss -ulnp | grep 9090。没有输出大概率是bind失败或没bind。第二步确认服务器bind的不是127.0.0.1而是0.0.0.0。绑错网卡是最隐蔽的错误之一。第三步确认客户端sendto没有返回错误。如果sendto直接返回-1大概率是目标地址没填对或者广播地址时没开SO_BROADCAST。第四步抓包。tcpdump -i any udp port 9090分别抓服务器端和客户端。如果客户端有发出包而服务器网卡没收到说明中间链路或防火墙挡了。第五步查防火墙。很多环境默认开着firewalld或ufwUDP端口如果没放行包会直接丢弃。检查规则并放行对应UDP端口。还有一点必须提UDP没有连接状态所以即使远端端口不可达sendto通常也是成功的返回错误要等到下一次读。写客户端时不要以为sendto返回值表示发送成功它只表示包进了本机socket缓冲区。5.2 内核与socket缓冲区调优UDP接收缓冲区默认值经常只有几十KB高负载下很容易丢包。我遇到过服务器CPU占用不高但客户端老是超时抓包发现包到网卡了应用进程却没收到最后一查是接收队列溢出。这种情况可以用netstat -su查看UDP接收缓冲区错误也可以读/proc/net/snmp里的UdpRcvbufErrors。调大socket接收缓冲区的方法是setsockopt的SO_RCVBUF选项注意内核会把设置值翻倍因为要留一部分给内核内部结构。想再看更大必须调整内核参数net.core.rmem_max否则你的设置会被内核钳制。sysctl -w net.core.rmem_max8388608 sysctl -w net.core.rmem_default8388608 sysctl -w net.core.wmem_max8388608这套参数的物理意义是每个socket最多能用多大内存来缓存待处理的数据报。调大缓存能扛住瞬时突发流量但缓存解决不了长期处理能力不够的问题。如果持续每秒几万个包单线程处理不完缓存只会把“丢包”推迟不会消除。5.3 UDP会粘包吗先给结论UDP本身没有TCP那样的粘包问题。TCP是字节流应用层不知道消息边界UDP是数据报一次sendto对应对端的一次recvfrom内核帮你把消息边界保住了。但“应用层粘包”依然存在。如果你发送的逻辑是把两个业务消息拼在一次sendto里发或者接收方一次recvfrom只读缓冲区的一部分那么从业务层面上看你仍然是读到一坨分不清边界的数据。所以协议设计的关键不是担心内核粘包而是要自己定义消息边界固定长度包头、长度字段、分隔符三选一。我习惯用“固定包头长度字段”的方式。包头8字节前4字节是魔数后4字节是正文长度正文紧跟在后面。接收端先收到完整包头解析出长度后把剩下的字节按长度截断。这样即使业务方后续把包头和数据包粘在一起发也能拆得干净。5.4 ICMP错误和connect的纠错作用很多人不知道UDP也能收到连接错误。当你向一个没有进程监听的UDP端口发送数据对端网络协议栈会回一个ICMP端口不可达消息。这个ICMP错误是异步回来的如果你的UDP套接字没有调用connect内核收到ICMP错误时找不到具体是哪个socket的关联状态于是这个错误就被丢弃了你的程序毫无感知。如果你把UDP客户端connect到了目标地址内核就能把ICMP错误和这个socket关联起来之后调用send或者recv时返回ECONNREFUSED程序能明确知道目标不可达。这算是UDP客户端connect的一个非常有用的副作用。所以在固定对端通信的客户端里我一般都建议connect。5.5 高并发场景的内核与多队列思路到了每秒收几十万包这个量级再靠单个socket暴力recvfrom是不够的。内核收包路径上有锁竞争、中断处理、内存拷贝。这时有两个实用的优化点。一是开启SO_REUSEPORT让多个socket绑定同一个端口内核会在多个socket之间做负载均衡。每个worker线程独占自己的socket减少锁竞争。注意必须让所有socket都设置SO_REUSEPORT再绑定同一个端口否则bind会失败。二是开启网卡多队列和RSS让不同CPU核心处理不同数据流。Linux上一般用ethtool -L eth0 combined 4把队列数调大。配合SO_REUSEPORT每个队列对应一个worker线程能显著提升收包能力。另一个方向是使用recvmmsg批量接收数据报一次系统调用从内核取多个包减少系统调用次数。实测在包比较小的场景下recvmmsg比单独调recvfrom吞吐能提升不少。内核已经支持这个接口值得列入高并发改造清单。6. 最后给初学者的一些建议我在实际项目里用UDP最多的时候不是做网络基础设施而是做内部服务之间的轻量级通信。因为不需要维护连接状态代码简单重启服务也不影响客户端连接丢包问题通过应用层超时重传就能解决。如果你刚开始学网络编程我强烈建议亲手把上面这个回显服务器跑起来再用tcpdump抓包看看数据报在网络上长什么样然后试着把客户端改成超时重传看看丢一个包之后整个流程怎么走。如果你已经写过TCP的echo server你会发现UDP的模型清爽很多但“清爽”的代价就是可靠性要自己补。真正写生产代码时消息序号、确认应答、重传超时这些业务逻辑一样都不能少。建议先保持简单等把所有坑都踩过一遍再考虑引入复杂的传输协议或外部组件。UDP编程不是高深莫测的东西CPU和内存的管理比套接字原本复杂得多把基础函数和内核缓冲机制搞清楚你后面的路会好走很多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑