1. 从一个实际需求说起为什么要手写UDP客户端很多人第一次接触网络编程都是从TCP开始的。三次握手、可靠传输、面向连接这些概念听起来就让人安心。但真正到了某些场景下你会发现TCP反而成了累赘。比如做局域网设备发现、实时游戏同步、音视频推流、DNS查询这些场景有一个共同特点宁可丢包也不要延迟。这时候UDP就成了首选。我最早写UDP客户端是为了做一个局域网内的设备状态广播工具。当时用TCP写了个版本设备离线后服务端要等很久才能感知到因为TCP的连接断开需要经过完整的挥手流程中间还有各种超时重传。换成UDP之后客户端每隔几秒发一个心跳包服务端超过一定时间没收到就标记离线逻辑简单响应也快。这篇文章要聊的就是C socket编程中的UDP客户端。我会从整体设计思路讲起把核心API的细节掰开揉碎再给出一份可以直接编译运行的完整代码最后把我这些年踩过的坑和排查经验整理出来。不管你是刚学网络编程的新手还是想从TCP转到UDP的老手应该都能从中找到有用的东西。需要提前说明的是本文基于Linux平台下的Berkeley socket接口来讲解Windows平台下需要额外处理Winsock的初始化和清理核心API的名字基本一致差异我会在对应位置标注出来。2. UDP客户端的整体设计与核心思路2.1 为什么UDP客户端比TCP客户端简单这么多先看一张对比表直观感受一下两者的差异对比维度TCP客户端UDP客户端连接建立需要connect三次握手无需握手直接sendtosocket类型SOCK_STREAMSOCK_DGRAM数据传输send/recv面向字节流sendto/recvfrom面向数据报可靠性内核保证可靠有序应用层自己保证连接状态有状态无状态服务端数量一对一可以一对多TCP客户端要经历socket、connect、send、recv、close这一整套流程而UDP客户端只需要socket、sendto、recvfrom、close。少了一个connect环节看起来简单了不少。但这里有个容易被忽略的点UDP的简单是表面的复杂性被转移到了应用层。TCP帮你处理了丢包重传、乱序重组、流量控制UDP把这些全部交还给你。所以写UDP客户端代码量可能少了但需要考虑的问题反而更多。2.2 一个UDP客户端最少需要哪几个步骤抛开所有花哨的功能一个能跑通的UDP客户端核心就四步创建socket调用socket()传入AF_INET和SOCK_DGRAM准备目标地址填充sockaddr_in结构体设置服务端IP和端口发送数据调用sendto()指定目标地址接收数据调用recvfrom()获取服务端响应这四步就是UDP客户端的骨架。剩下的工作比如设置超时、处理错误、重传机制、数据序列化都是在这个骨架上长出来的肉。2.3 关于connect的一个常见误解很多人以为UDP不能用connect其实不是。UDP的connect不会发起任何网络握手它只是在本地内核里记录了对端的地址信息。调用connect之后你可以直接用send和recv不用每次都写sendto和recvfrom。更重要的是connect后的UDP socket只会接收来自指定对端的数据其他地址发来的包会被内核直接丢弃这在一定程度上能过滤掉一些杂包。但connect也有代价一旦connect了这个socket就只能和那一个对端通信了。如果你的客户端需要同时和多个服务端交互就不能用connect。我的建议是如果确定只和一个服务端通信用connect简化代码如果需要一对多老老实实用sendto。3. 核心API细节与参数解析3.1 socket函数的三个参数怎么选socket()的原型是int socket(int domain, int family, int protocol);对于UDP客户端参数选择如下domain填AF_INET表示IPv4。如果要支持IPv6填AF_INET6。现在很多环境已经开始推IPv6了如果你的程序需要兼容双栈可以考虑用AF_INET6配合IPV6_V6ONLY选项或者干脆用getaddrinfo()来动态决定。type填SOCK_DGRAM这是UDP的标志。填成SOCK_STREAM就变成TCP了。protocol通常填0让系统根据type自动选择。也可以显式填IPPROTO_UDP效果一样。返回值是一个文件描述符失败返回-1。这里有个细节每次创建socket都要检查返回值我见过太多人写完不检查结果后面sendto一直失败排查半天才发现是socket创建就没成功。3.2 sockaddr_in结构体的填充要点sockaddr_in是IPv4地址结构体定义大致如下struct sockaddr_in { sa_family_t sin_family; // 地址族AF_INET in_port_t sin_port; // 端口号网络字节序 struct in_addr sin_addr; // IP地址网络字节序 char sin_zero[8]; // 填充字段全零 };填充时有三个关键点第一字节序问题。端口号必须用htons()转成网络字节序IP地址用inet_pton()或inet_addr()转换。我见过有人直接写serv_addr.sin_port 8080;在小端机器上实际发出去的端口号是0x1F90的反序服务端根本收不到。第二sin_zero字段。虽然现代系统不强制要求清零但用memset()把整个结构体清零是个好习惯能避免一些奇怪的兼容性问题。第三IP地址的转换。inet_addr()是老接口不支持IPv6而且对错误输入返回INADDR_NONE和广播地址255.255.255.255冲突。推荐用inet_pton()它明确区分成功和失败更安全。3.3 sendto和recvfrom的参数详解ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen); ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);sendto的关键点buf和len是你要发送的数据和长度。UDP是面向数据报的一次sendto对应一个完整的数据报。如果你发了100字节接收方要么收到完整的100字节要么什么都收不到不会出现收到50字节的情况。flags通常填0。如果需要非阻塞发送可以填MSG_DONTWAIT。dest_addr和addrlen指定目标地址。如果socket已经connect了这两个参数可以填NULL和0。返回值是实际发送的字节数。对于UDP来说要么等于len要么返回-1。如果返回值小于len说明发生了错误不要以为部分发送是正常的。recvfrom的关键点src_addr和addrlen是输出参数用来获取发送方的地址。如果不需要知道是谁发的可以填NULL。addrlen是值-结果参数调用前要设置成sizeof(struct sockaddr_in)调用后内核会修改为实际地址长度。返回值是接收到的字节数。如果返回0对于UDP来说是一个合法的空数据报不是连接关闭。这点和TCP完全不同TCP的recv返回0表示对端关闭了连接。如果缓冲区太小装不下整个数据报多余的部分会被丢弃recvfrom返回的是缓冲区大小并设置MSG_TRUNC标志如果flags参数支持的话。所以缓冲区一定要给够。3.4 数据报大小的限制UDP数据报的理论最大长度是65507字节65535减去IP头20字节和UDP头8字节。但实际上MTU最大传输单元通常是1500字节超过这个大小的数据报会在IP层被分片。分片会带来两个问题一是任何一个分片丢失整个数据报都要重传二是某些网络设备会直接丢弃分片包。我的经验是UDP数据报控制在1400字节以内比较稳妥这样能避免IP分片。如果需要传输更大的数据应该在应用层自己做分片和重组。4. 完整可运行的UDP客户端实现4.1 基础版本发一条收一条先给一个最简版本功能是向指定服务端发送一条消息然后等待响应#include iostream #include cstring #include sys/socket.h #include arpa/inet.h #include unistd.h int main() { // 1. 创建socket int sockfd socket(AF_INET, SOCK_DGRAM, 0); if (sockfd 0) { std::cerr socket创建失败: strerror(errno) std::endl; return -1; } // 2. 设置服务端地址 struct sockaddr_in serv_addr; memset(serv_addr, 0, sizeof(serv_addr)); serv_addr.sin_family AF_INET; serv_addr.sin_port htons(9090); if (inet_pton(AF_INET, 127.0.0.1, serv_addr.sin_addr) 0) { std::cerr IP地址转换失败 std::endl; close(sockfd); return -1; } // 3. 发送数据 const char* msg hello udp server; ssize_t sent sendto(sockfd, msg, strlen(msg), 0, (struct sockaddr*)serv_addr, sizeof(serv_addr)); if (sent 0) { std::cerr sendto失败: strerror(errno) std::endl; close(sockfd); return -1; } std::cout 已发送 sent 字节 std::endl; // 4. 接收响应 char buf[1500]; struct sockaddr_in from_addr; socklen_t from_len sizeof(from_addr); ssize_t recvd recvfrom(sockfd, buf, sizeof(buf) - 1, 0, (struct sockaddr*)from_addr, from_len); if (recvd 0) { std::cerr recvfrom失败: strerror(errno) std::endl; } else { buf[recvd] \0; char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, from_addr.sin_addr, ip, sizeof(ip)); std::cout 收到来自 ip : ntohs(from_addr.sin_port) 的响应: buf std::endl; } close(sockfd); return 0; }编译命令g -o udp_client udp_client.cpp -stdc11这个版本能跑通但有几个明显的问题没有超时机制如果服务端不响应recvfrom会一直阻塞没有重传丢包了就丢了没有错误重试。下面逐个解决。4.2 加上超时让recvfrom不再无限等待给recvfrom设置超时有两种方式用setsockopt设置SO_RCVTIMEO或者用select/poll多路复用。前者简单后者灵活。方式一SO_RCVTIMEOstruct timeval tv; tv.tv_sec 3; // 3秒超时 tv.tv_usec 0; setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv));设置之后recvfrom如果超过3秒没收到数据会返回-1并设置errno为EAGAIN或EWOULDBLOCK。注意这两个错误码在不同系统上可能不同判断时要两个都检查。方式二selectfd_set readfds; FD_ZERO(readfds); FD_SET(sockfd, readfds); struct timeval tv; tv.tv_sec 3; tv.tv_usec 0; int ret select(sockfd 1, readfds, nullptr, nullptr, tv); if (ret 0 FD_ISSET(sockfd, readfds)) { // 有数据可读调用recvfrom } else if (ret 0) { // 超时 } else { // 出错 }select的好处是可以在等待期间做其他事情比如检查退出标志。缺点是每次调用都要重新设置fd_set和timeval因为select会修改它们。我个人的习惯是简单的请求-响应模式用SO_RCVTIMEO需要同时处理多个socket或者需要精细控制超时逻辑的用select/poll。4.3 加上重传让丢包不再致命UDP不保证可靠但很多场景下我们需要一定的可靠性。最简单的做法是超时重传const int MAX_RETRY 3; const int TIMEOUT_SEC 2; for (int i 0; i MAX_RETRY; i) { ssize_t sent sendto(sockfd, msg, strlen(msg), 0, (struct sockaddr*)serv_addr, sizeof(serv_addr)); if (sent 0) { std::cerr 第 i1 次发送失败 std::endl; continue; } ssize_t recvd recvfrom(sockfd, buf, sizeof(buf) - 1, 0, (struct sockaddr*)from_addr, from_len); if (recvd 0) { // 收到响应处理并退出循环 break; } else if (errno EAGAIN || errno EWOULDBLOCK) { std::cout 第 i1 次等待超时准备重传 std::endl; } else { std::cerr recvfrom错误: strerror(errno) std::endl; break; } }这里有个细节重传时要重新设置from_len因为recvfrom会修改它。我见过有人忘了重置第二次recvfrom时addrlen变成了0导致地址信息获取失败。重传次数和超时时间需要根据实际网络环境调整。局域网内可以设短一点比如1秒超时、2次重传广域网或者移动网络下超时时间要适当放长3到5秒比较合适。4.4 处理EINTR被信号打断怎么办在Linux下如果程序收到信号阻塞中的recvfrom可能会被中断返回-1并设置errno为EINTR。这不是真正的错误应该重新调用。处理方式ssize_t recvd; do { recvd recvfrom(sockfd, buf, sizeof(buf) - 1, 0, (struct sockaddr*)from_addr, from_len); } while (recvd 0 errno EINTR);这个细节在调试时经常被忽略导致程序莫名其妙地失败。尤其是当程序用了定时器信号或者被CtrlC中断时EINTR出现的概率不低。4.5 封装成一个可复用的类把上面的逻辑封装成一个类用起来会方便很多class UdpClient { public: UdpClient() : sockfd_(-1) {} ~UdpClient() { if (sockfd_ 0) close(sockfd_); } bool init(const std::string server_ip, uint16_t server_port, int timeout_sec 3) { sockfd_ socket(AF_INET, SOCK_DGRAM, 0); if (sockfd_ 0) return false; memset(serv_addr_, 0, sizeof(serv_addr_)); serv_addr_.sin_family AF_INET; serv_addr_.sin_port htons(server_port); if (inet_pton(AF_INET, server_ip.c_str(), serv_addr_.sin_addr) 0) { close(sockfd_); sockfd_ -1; return false; } struct timeval tv; tv.tv_sec timeout_sec; tv.tv_usec 0; setsockopt(sockfd_, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv)); return true; } bool sendAndReceive(const std::string request, std::string response, int max_retry 3) { char buf[1500]; for (int i 0; i max_retry; i) { ssize_t sent sendto(sockfd_, request.c_str(), request.size(), 0, (struct sockaddr*)serv_addr_, sizeof(serv_addr_)); if (sent 0) continue; struct sockaddr_in from_addr; socklen_t from_len sizeof(from_addr); ssize_t recvd; do { recvd recvfrom(sockfd_, buf, sizeof(buf) - 1, 0, (struct sockaddr*)from_addr, from_len); } while (recvd 0 errno EINTR); if (recvd 0) { response.assign(buf, recvd); return true; } if (errno ! EAGAIN errno ! EWOULDBLOCK) break; } return false; } private: int sockfd_; struct sockaddr_in serv_addr_; };用起来就三行UdpClient client; client.init(127.0.0.1, 9090); std::string resp; client.sendAndReceive(ping, resp);5. 常见问题与排查技巧实录5.1 发送成功但服务端收不到这是最常见的问题排查思路按顺序来第一步确认服务端在监听。用netstat -ulnp | grep 端口号看服务端是否真的绑定了UDP端口。注意UDP的netstat输出和TCP不一样State列显示的是UNCONN这是正常的。第二步确认地址和端口正确。检查inet_pton的返回值检查htons有没有漏掉。我遇到过一次端口号写的是htons(9090)但服务端监听的是9090的字符串形式实际转换后不一致。第三步检查防火墙。Linux下用iptables -L -n看规则云服务器还要检查安全组。UDP没有连接状态很多防火墙对UDP的处理和TCP不同容易漏配。第四步抓包确认。用tcpdump -i any udp port 9090 -nn在服务端抓包看数据包到底有没有到达。如果抓到了包但程序没收到说明是程序的问题如果没抓到说明是网络或防火墙的问题。5.2 recvfrom返回-1但errno是EAGAIN这说明超时了不是错误。但如果你的程序逻辑里把EAGAIN当成致命错误处理就会出问题。正确的做法是if (recvd 0) { if (errno EAGAIN || errno EWOULDBLOCK) { // 超时走重传或退出逻辑 } else if (errno EINTR) { // 被信号打断重试 } else { // 真正的错误 } }5.3 收到的数据不完整或乱码UDP是面向数据报的不会出现TCP那种半包问题。如果收到的数据不对通常是这几个原因发送方和接收方的字节序不一致。如果传输的是二进制数据要统一用网络字节序。缓冲区太小。前面说过缓冲区装不下整个数据报多余部分会被丢弃。数据报被分片后丢失了部分分片。这种情况在大包传输时容易出现解决办法是控制包大小在MTU以内。5.4 端口被占用导致bind失败客户端通常不需要bind内核会自动分配一个临时端口。但如果你确实需要bind一个固定端口可能会遇到EADDRINUSE。UDP的端口复用和TCP不同两个程序不能同时bind同一个UDP端口除非设置了SO_REUSEADDR或SO_REUSEPORT。排查方法netstat -ulnp | grep 端口号找到占用端口的进程要么换端口要么先停掉那个进程。5.5 常见问题速查表现象可能原因排查方法sendto返回-1socket未创建成功、地址错误检查socket返回值、inet_pton返回值服务端收不到包防火墙、地址端口错误tcpdump抓包、检查iptablesrecvfrom一直阻塞未设置超时、服务端未响应设置SO_RCVTIMEO、检查服务端日志recvfrom返回EAGAIN超时正常现象走重传逻辑收到乱码字节序问题、缓冲区太小统一网络字节序、增大缓冲区bind失败EADDRINUSE端口被占用netstat查找占用进程5.6 几个我踩过的坑坑一忘了设置SO_RCVTIMEO程序卡死。早期写的一个工具服务端挂了之后客户端一直阻塞在recvfrom整个程序假死。后来加了超时问题解决。坑二重传时没有重置addrlen。这个前面提过第二次recvfrom时addrlen变成0导致获取不到发送方地址。虽然不影响数据接收但如果你需要根据来源做判断就会出问题。坑三在信号处理函数里调用recvfrom。信号处理函数里只能调用异步信号安全的函数recvfrom不在其中。正确做法是在信号处理函数里设置一个标志主循环检查标志后退出。坑四忽略了sendto的返回值。UDP的sendto返回-1不代表数据一定没发出去可能是本地缓冲区满了。但无论如何检查返回值是好习惯。坑五用UDP传大文件。UDP不适合传大文件分片重组的开销和丢包概率都很大。如果非要传一定要在应用层做分片、编号、确认、重传工作量不比直接用TCP小。6. 进阶话题让UDP客户端更健壮6.1 非阻塞模式与IO多路复用如果客户端需要同时处理多个任务阻塞式的recvfrom就不合适了。可以把socket设置成非阻塞int flags fcntl(sockfd, F_GETFL, 0); fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);非阻塞模式下recvfrom如果没有数据会立即返回-1并设置errno为EAGAIN。配合epoll或poll可以同时管理多个socket适合做高并发的客户端。6.2 心跳与保活UDP没有连接状态服务端无法感知客户端是否还在线。常见的做法是客户端定期发送心跳包服务端维护一个最后活跃时间表。心跳间隔根据业务需求定一般5到30秒。心跳包要尽量小一个字节或者一个固定的魔数就够了。6.3 应用层协议设计UDP客户端和服务端之间的数据格式需要自己定义。最简单的就是纯文本复杂一点可以用TLVType-Length-Value或者protobuf。不管用什么格式一定要有长度字段或者结束标志否则接收方无法判断数据报的边界。虽然UDP本身保证了数据报的完整性但应用层协议还是要有自己的校验机制比如加一个magic number或者CRC校验。6.4 关于广播和组播UDP支持广播和组播这是TCP做不到的。广播地址通常是255.255.255.255或者子网广播地址需要设置SO_BROADCAST选项。组播需要加入组播组用IP_ADD_MEMBERSHIP。这两个话题展开讲又是一大篇这里只提一句广播和组播在局域网设备发现、服务通告等场景下非常有用值得单独花时间研究。7. 一些个人体会写UDP客户端这些年最大的感受是简单的东西往往最考验功底。TCP帮你屏蔽了很多细节UDP把这些细节全部暴露出来逼着你去理解网络协议的本质。字节序、MTU、超时、重传、错误码这些在TCP里可能一辈子都不用关心的东西在UDP里都是家常便饭。另一个体会是不要试图用UDP去实现TCP。我见过有人想在UDP上做可靠传输加了一堆确认、重传、拥塞控制最后代码复杂度比直接用TCP还高性能却没见得好多少。UDP的价值在于它的轻量和灵活用在合适的场景下才能发挥优势。如果你的场景需要可靠传输直接用TCP如果需要低延迟、能容忍丢包再用UDP。最后分享一个小技巧调试UDP程序时先在本机用127.0.0.1跑通再换到局域网最后再上公网。每换一个环境问题都会不一样。本机跑通说明代码逻辑没问题局域网跑通说明地址和防火墙配置没问题公网跑通说明NAT和路由没问题。按这个顺序排查能省下不少时间。