很多人第一次在 Linux 上写 UDP 通信都是把 TCP 的代码复制过来把SOCK_STREAM改成SOCK_DGRAM删掉listen和accept编译通过跑起来居然也通了。然后过几天线上出了怪事日志里明明对端说发了本端就是收不到或者本地自环测试百发百中一放到两台机器上就丢三成。这类问题九成不是“UDP 不可靠”这么一句废话能解释的而是使用者在socket()、bind()、sendto()、recvfrom()这几个调用的语义边界上根本没搞清楚。这篇东西就围绕 Linux 下用 C 通过 Socket 套接字实现 UDP 协议通讯这件事把每一步的系统行为、参数取舍、缓冲区大小、超时设计、排障路径完整拆一遍。适合已经能写 hello world 但没真正调过 UDP 的人也适合那些被“偶发丢包”折磨过、想弄清楚内核到底在干什么的人。下面所有代码都是可编译运行的最小片段不依赖任何第三方库只用 POSIX 接口和标准 C。1. 先想清楚UDP在这类场景里到底替我们省掉了什么动手之前得先把一件事说明白UDP 给你的不是一个“更快的 TCP”它给你的是一套完全不同的契约数据报边界、无连接状态、不保证送达、不保证顺序。这四个特性里前两个是白送的收益后两个是必须自己兜底的成本。很多人写 UDP 写崩就是因为只享受了前两个把后两个当成“概率问题”糊弄过去了。1.1 数据报边界这个特性比想象中重要TCP 是字节流recv返回多少字节完全取决于内核缓冲里当前有多少所以你必须在应用层搞长度字段、搞粘包拆包。UDP 不是这样recvfrom一次调用读走的就是一个完整的数据报要么整条给你要么这条就没了绝不会出现“半条消息”的情况。这个语义对日志上报、指标采集、心跳探测这类“一条一条独立”的业务特别友好代码里不需要任何拆包逻辑。但反过来这个特性也带来了一个隐形的坑如果你给的缓冲区小于数据报实际长度多出来的部分会被内核直接丢弃而且默认不告诉你。也就是说recvfrom返回 1024你以为是收到了 1024 字节的消息实际上对端发了 1400 字节剩下那 376 字节已经没了。这个问题在本地测试时几乎不可能暴露因为测试数据通常很短。解决办法在后面第 3 节细说先记住这个行为。1.2 选型判断什么业务该用UDP什么业务别硬上我一般用下面这张表来判断判断标准不是“快不快”而是“丢了之后业务能不能自愈”业务特征推荐协议理由采集类上报允许丢点数据下一条很快又来UDP丢一条不影响整体趋势重传反而增加延迟局域网内设备发现、服务广播UDP需要广播/组播能力TCP 天生不支持实时音视频帧、游戏位置同步UDP过期数据没有重传价值宁丢不等指令下发、配置变更、订单提交TCP必须确认送达业务侧无法容忍静默丢失大文件、大报文传输TCPUDP 单包受 MTU 限制自己切分重传等于重写 TCP有个反直觉的结论如果你的业务需要在应用层做“确认 重传 去重 排序”那你大概率不该用 UDP。这三样加起来基本就是半个 TCP而且你写的版本一定比内核里的差。真正适合 UDP 的场景是那些“重传没有意义”或者“上层已经有幂等保证”的场合。1.3 无连接状态带来的两个直接后果第一服务端不需要listen和accept一个套接字就能同时跟成千上万个对端说话每个包自带源地址内核把地址塞进recvfrom的输出参数里。这让 UDP 服务端的代码结构极其扁平一个while循环收包、处理、发包没有连接管理。第二服务端对“对端是否活着”完全无感知。TCP 连接断开你能拿到read返回 0UDP 没有这回事。所以任何基于 UDP 的长周期业务都必须自己在应用层设计心跳或者超时淘汰机制否则对端进程早就 core dump 了你这边还在往一个死地址发包一直发到天荒地老。2. 服务端落地从socket()到bind()每一步的系统行为写 UDP 服务端核心就三行建套接字、绑地址、循环收。但每一行背后都有需要确认的细节尤其是地址结构体的填充和bind的失败码这两个地方的新手错误率最高。2.1 地址结构体填充的三个高频错误sockaddr_in这个结构体看着简单实际上三个字段每个都有讲究#include arpa/inet.h #include netinet/in.h #include sys/socket.h #include unistd.h #include cstring #include cstdio int main() { int fd ::socket(AF_INET, SOCK_DGRAM, 0); if (fd 0) { std::perror(socket); return 1; } int reuse 1; ::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, reuse, sizeof(reuse)); sockaddr_in addr; std::memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(9000); addr.sin_addr.s_addr htonl(INADDR_ANY); if (::bind(fd, reinterpret_castsockaddr*(addr), sizeof(addr)) 0) { std::perror(bind); ::close(fd); return 1; } // ... 收包循环 }第一个错误是忘记字节序转换。sin_port必须是网络字节序htons不能省sin_addr.s_addr也必须是网络序用htonl(INADDR_ANY)或者inet_pton写入。直接写addr.sin_port 9000在小端机器上会变成端口 0x2328也就是 9000 的字节互换结果表现出来就是“端口怎么都绑不上预期值”。第二个错误是结构体没清零。sockaddr_in里有 8 字节的sin_zero填充区如果不memset清零里面是栈上的随机垃圾。虽然现代内核对这块填充区基本不校验但在某些跨平台代码或者静态检查工具里会报警而且bind传长度的时候容易出诡异问题。养成memset或者sockaddr_in addr{}的习惯不亏。第三个错误是用inet_addr处理字符串地址。这个函数返回in_addr_t失败时返回INADDR_NONE也就是0xFFFFFFFF这恰好是广播地址255.255.255.255的合法值两者没法区分。用inet_pton(AF_INET, 192.168.1.10, addr.sin_addr)更靠谱它返回 1 表示成功、0 表示格式非法、-1 表示地址族不支持语义清楚得多。2.2 bind失败码与端口复用的真实含义bind失败最常见的是EADDRINUSE端口被占用。这里有个细节值得说清楚UDP 的端口占用判据和 TCP 不一样。TCP 的TIME_WAIT会让端口在一段时间内无法复用UDP 没有连接状态理论上进程退出端口立刻释放。但如果你在同一台机器上同时开了多个 UDP 服务监听同一端口第二个必然失败。SO_REUSEADDR在 UDP 上的作用和 TCP 上不同。TCP 上它主要用来处理TIME_WAIT期间的快速重启UDP 上它的实际效果是允许多个套接字绑定到完全相同的地址端口然后由内核决定把包投给谁行为依内核版本而定不是可靠的分发机制。如果你没打算做这种“多进程抢同一个端口”的模型加上SO_REUSEADDR主要是为了解决进程重启瞬间端口还没完全释放的问题属于保险措施。另一个值得掌握的技巧是绑定端口 0。把sin_port设成 0内核会从临时端口范围里挑一个空闲端口给你绑完之后用getsockname取回实际端口socklen_t len sizeof(addr); if (::getsockname(fd, reinterpret_castsockaddr*(addr), len) 0) { int port ntohs(addr.sin_port); std::printf(bound to ephemeral port %d\n, port); }这个手法在客户端和测试代码里非常好用尤其是并行跑多组测试的时候避免端口写死导致的冲突。2.3 收包循环与recvfrom的返回值语义收包主循环的骨架如下重点看len的重置位置char buf[2048]; for (;;) { sockaddr_in peer; socklen_t peer_len sizeof(peer); // 每轮必须重置 std::memset(peer, 0, sizeof(peer)); ssize_t n ::recvfrom(fd, buf, sizeof(buf), 0, reinterpret_castsockaddr*(peer), peer_len); if (n 0) { if (errno EINTR) continue; // 被信号打断重来 std::perror(recvfrom); break; } char ip[INET_ADDRSTRLEN] {0}; ::inet_ntop(AF_INET, peer.sin_addr, ip, sizeof(ip)); std::printf(recv %zd bytes from %s:%d\n, n, ip, ntohs(peer.sin_port)); // 处理 buf[0..n) }socklen_t peer_len必须在循环体内每次重置为sizeof(peer)。它是一个值-结果参数调用前你告诉内核缓冲区多大调用后内核写回实际地址长度。如果写成循环外初始化一次第二轮开始peer_len就是上一轮残留的小值内核会拿这个值当容量上限可能导致地址被截断或者直接返回EINVAL。这个 bug 极其隐蔽因为第一轮永远正常。返回值n 0的情况在 UDP 里是合法的对端确实发了一个零长度的数据报这在某些心跳协议里是常见做法。所以判断条件必须是n 0才算错误不能像 TCP 那样把n 0当对端关闭。这也侧面印证了 UDP 没有“连接关闭”这个概念。3. 一次UDP传输到底能装多少数据“UDP 最大能发 64K”这句话流传很广但按这个数字写代码的人一定会在真实网络里栽跟头。真正决定你能发多少的不是 UDP 协议头的长度字段而是链路的 MTU。3.1 MTU、IP分片与65507的来历UDP 长度字段是 16 位所以理论上 UDP 数据报总长含 8 字节头最多 65535。减去 UDP 头 8 字节、IP 头最小 20 字节应用层数据最大 65507 字节。这是“协议允许”的上限不是“网络允许”的上限。以太网的标准 MTU 是 1500 字节减去 IP 头 20、UDP 头 8单帧能承载的应用数据是1472 字节。超过这个值IP 层就要做分片一个 UDP 数据报被切成多个 IP 分片发送接收端重组。分片带来的问题是任意一个分片丢失整个数据报就废了。原来丢一包只丢一包现在发一个 8KB 的数据报要切成 6 个分片丢包率被放大 6 倍。所以在实际工程里我一般把单个数据报控制在1200 字节以内。为什么是 1200 而不是 1472因为中间的链路不一定都是 1500 的 MTU隧道封装的链路比如 IP-in-IP会额外吃掉几十字节头部PMTU 可能只有 1400 甚至更低。留 200 多字节余量能避开绝大多数链路的坑。这个数字不是教条但作为一个默认值相当稳妥。3.2 验证实际收到的字节数前面提到缓冲区给小了会静默截断。想确认到底有没有被截断可以用MSG_TRUNC标志ssize_t n ::recvfrom(fd, buf, sizeof(buf), MSG_TRUNC, reinterpret_castsockaddr*(peer), peer_len);带MSG_TRUNC时返回值是数据报的真实长度而不是实际拷贝进缓冲区的长度。这样你就能对比n和sizeof(buf)返回值情况含义处理方式n sizeof(buf)数据完整读入正常处理n sizeof(buf)真实长度超出缓冲记录告警加大缓冲本条数据已残缺n 0系统调用失败检查 errnoEINTR 重试线上环境里我一般会在收包函数里加一条判断如果n sizeof(buf)打一条限流日志。这条日志一旦出现说明协议设计和实现已经脱节了必须改。3.3 应用层消息头设计既然单包有上限就需要在应用层定一个固定格式的头把“这是什么消息、多长、序号多少”写进去。一个够用的最小头通常是 8 到 16 字节#pragma pack(push, 1) struct MsgHeader { uint16_t magic; // 0x55AA快速过滤垃圾包 uint8_t version; // 协议版本 uint8_t type; // 消息类型 uint32_t seq; // 序号用于去重和乱序检测 uint16_t payload_len; }; #pragma pack(pop)#pragma pack(1)是为了避免编译器在version后面插 3 字节对齐填充导致两端结构体布局不一致。这个坑在跨编译器、跨架构的通信里必踩GCC 和 MSVC 的默认对齐规则虽然大体一致但涉及uint64_t成员时容易出分歧。更稳妥的做法是手动序列化把整数用htons/htonl转成网络序逐字段写入char数组完全绕开内存布局问题。多写几行代码换掉一整类调试噩梦这笔买卖划算。4. 客户端这一侧超时、重发与connect的隐藏作用客户端的代码比服务端还短但需要做的设计决策更多等多久、要不要重发、怎么知道对端不可达。4.1 采用SO_RCVTIMEO还是selectUDP 客户端发出去之后通常要等回应而recvfrom默认是阻塞的对端不回就永远卡住。最简单的超时方案是给套接字设接收超时struct timeval tv; tv.tv_sec 1; tv.tv_usec 0; ::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv));超时之后recvfrom返回 -1errno是EAGAIN或者EWOULDBLOCK在 Linux 上这两个值相同。注意这个超时是每次调用独立计时的不是总时长。select或者poll的灵活性更高因为它能同时等多路事件。如果你要在一个线程里处理多个套接字或者要同时等信号、等管道那用poll更合适struct pollfd pfd; pfd.fd fd; pfd.events POLLIN; int ret ::poll(pfd, 1, 1000); // 1000ms if (ret 0) { // 超时 } else if (ret 0 (pfd.revents POLLIN)) { // 可读调用 recvfrom 不会阻塞 }两种方式各有场景。单套接字、逻辑简单的用SO_RCVTIMEO代码少需要多路复用或者需要精确控制超时长短的用poll。不要混用见过有代码既设了SO_RCVTIMEO又用select等超时时间设得不一样实际行为取决于哪个先触发排查起来很费劲。4.2 connect一个UDP套接字之后发生了什么这是个很多人不知道的技巧UDP 套接字也可以调用connect。它不建立任何连接不做三次握手它的作用是在内核里记录对端地址。之后你可以用send和recv代替sendto和recvfrom代码更简洁。更有价值的是副作用connect之后内核会过滤掉所有源地址不是对端的包而且如果收到 ICMP 端口不可达下一次读写会返回ECONNREFUSED。这给了你一个探测“对端服务是否在监听”的手段。未connect的 UDP 套接字完全收不到这个错误因为内核不知道你在等谁的回包。代价是失去了“一个套接字跟多个对端通信”的能力。所以我的判断标准是如果这个套接字只跟一个固定对端说话就connect如果是服务端或者需要跟多个对端交互就不要connect。4.3 重传与去重的极简实现如果真的需要保证送达最朴素的做法是“发一次、等超时、重发 N 次”。配合消息头里的seq可以对端做去重std::unordered_setuint32_t seen; // 收到包时 if (seen.count(hdr.seq)) { // 重复包直接丢弃但可以回一个 ACK 帮对端停止重传 } else { seen.insert(hdr.seq); // 正常处理 }这个seen集合必须有上限否则跑久了内存会一直涨。常见做法是维护一个滑动窗口只保存最近 N 个序号或者用位图。如果序号是单调递增的更简单的办法是记录一个max_seq只接受比它大的序号小的直接丢。这个策略在允许乱序的场景下会误杀需要根据业务来定。5. 排障实录收不到包、端口占用、内核丢包UDP 的问题排查比 TCP 难因为没有连接状态可以看没有握手过程可以抓。我梳理一下自己踩过的几类典型问题以及对应的排查路径。5.1 从ss和netstat看队列信息ss -uanp能看到当前所有 UDP 套接字和它们的接收队列长度State Recv-Q Send-Q Local Address:Port Process UNCONN 0 0 0.0.0.0:9000 users:((server,pid1234,fd3))Recv-Q那一列是关键。如果它持续大于 0 而且在增长说明应用层消费的速度跟不上收包速度内核缓冲在堆积。如果它一直是 0 但应用层说收不到包那问题不在缓冲在别处。netstat -su能给出 UDP 层的统计重点关注这几项统计项含义异常信号packets received收到的包数完全不动说明包根本没到这台机器packets to unknown port没有套接字监听的端口数值涨说明端口绑错了或者进程没起receive buffer errors接收缓冲满导致的丢弃涨说明处理太慢要加大缓冲或加消费线程send buffer errors发送缓冲满导致的丢弃涨说明发包太快内核来不及发/proc/net/snmp里的Udp:行也能读到同样的计数器脚本化监控更方便。我一般会在服务里做个定时任务读这个文件把RcvbufErrors做成指标超过阈值就告警。因为这类丢包是静默的应用层完全感知不到不主动监控就只能等业务方来投诉。5.2 ECONNREFUSED与ICMP的关系有个现象很迷惑人客户端往一个没有服务监听的端口发 UDPsendto返回成功程序以为发出去了实际上对端内核回了 ICMP 端口不可达。如果客户端套接字已经connect过下一次send或者recv就会返回ECONNREFUSED如果没connect这个错误就被内核吞掉了。这解释了两件事。第一为什么“UDP 发送永远成功”这个说法不准确——严格说是sendto的返回成功只代表着数据交给了本机内核不代表对端收到了任何东西。第二为什么调试时用connect更早发现配置错误。顺带一提sendto返回成功但后续丢包还有一种情况是本机出去的路由不对。用ip route get 目标IP可以确认内核选的是哪条路由、从哪个网卡出去比抓包更快定位。5.3 系统缓冲上限与突发流量UDP 接收缓冲默认值通常只有 200KB 左右突发流量下很容易打满。打满之后内核直接丢包不通知应用层。调整方法sysctl -w net.core.rmem_max8388608 sysctl -w net.core.rmem_default1048576然后在代码里显式设置int sz 4 * 1024 * 1024; ::setsockopt(fd, SOL_SOCKET, SO_RCVBUF, sz, sizeof(sz));这里有个容易忽略的点Linux 会把设置值翻倍再传给内核所以你设 4MBss里看到的可能是 8MB。而且设置值会被rmem_max截断如果你设的比rmem_max还大内核会静默改成rmem_max的值不返回错误。设置完之后一定要用getsockopt读回来确认生效值int actual 0; socklen_t optlen sizeof(actual); ::getsockopt(fd, SOL_SOCKET, SO_RCVBUF, actual, optlen); std::printf(actual rcvbuf %d\n, actual);这个习惯能帮你在“明明设了 4MB 还是丢包”的时候少走一大圈弯路。我见过有人在一台rmem_max只有 212992 的机器上设 4MB然后对着代码看了两天。6. 把UDP封成类时的几个取舍真到了要复用的阶段大家都会想着封装一层。这一步的坑在于封得太薄没什么价值封得太厚把 UDP 的特性全藏起来了反而限制使用。6.1 封装粒度接口暴露到什么程度我的做法是把“套接字生命周期管理”和“收发”封起来但把sockaddr_in原样暴露给调用方。理由是地址结构是 UDP 的核心语义任何试图把它包装成std::string的封装都会在广播、组播、双栈地址这些场景下丢信息。一个够用的类大概长这样class UdpSock { public: explicit UdpSock(int family AF_INET) { fd_ ::socket(family, SOCK_DGRAM, 0); if (fd_ 0) throw std::runtime_error(socket failed); } ~UdpSock() { if (fd_ 0) ::close(fd_); } UdpSock(const UdpSock) delete; UdpSock operator(const UdpSock) delete; void bindAny(uint16_t port) { sockaddr_in a{}; a.sin_family AF_INET; a.sin_port htons(port); a.sin_addr.s_addr htonl(INADDR_ANY); if (::bind(fd_, reinterpret_castsockaddr*(a), sizeof(a)) 0) throw std::runtime_error(bind failed); } ssize_t sendTo(const void* data, size_t len, const sockaddr_in to) { return ::sendto(fd_, data, len, 0, reinterpret_castconst sockaddr*(to), sizeof(to)); } ssize_t recvFrom(void* buf, size_t cap, sockaddr_in* from, int flags 0) { socklen_t l sizeof(sockaddr_in); return ::recvfrom(fd_, buf, cap, flags, reinterpret_castsockaddr*(from), l); } int fd() const { return fd_; } private: int fd_ -1; };移动构造要不要提供如果是自己项目内部用禁掉拷贝和移动、用unique_ptr持有反而最省心。如果要做成库移动构造值得补上但要记得把源对象的fd_置为 -1否则析构两次close同一个 fd可能把别的线程刚申请的 fd 关掉这类 bug 极难定位。6.2 广播与组播两级开关别搞混广播需要先开SO_BROADCAST否则sendto到广播地址会返回EACCESint on 1; ::setsockopt(fd, SOL_SOCKET, SO_BROADCAST, on, sizeof(on)); // 目标地址填 255.255.255.255 或子网定向广播地址组播则是另一套接口用IP_ADD_MEMBERSHIP让网卡加入某个组播组ip_mreq mreq{}; ::inet_pton(AF_INET, 239.1.1.1, mreq.imr_multiaddr); mreq.imr_interface.s_addr htonl(INADDR_ANY); // 或指定网卡地址 ::setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, mreq, sizeof(mreq));这里最常见的错误是imr_interface填了0.0.0.0但机器有多块网卡。内核会按路由表挑一块挑中的可能不是你以为的那块表现为“加入了组播组但收不到包”。稳妥做法是先用ip addr确认网卡地址然后显式填进去。多网卡环境下的组播问题八成都出在这个字段上。6.3 一个可以直接跑起来的最小骨架把上面的东西拼起来服务端完整可编译的版本如下#include arpa/inet.h #include netinet/in.h #include poll.h #include sys/socket.h #include unistd.h #include cstdio #include cstring #include cerrno int main() { int fd ::socket(AF_INET, SOCK_DGRAM, 0); if (fd 0) { std::perror(socket); return 1; } int on 1; ::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on)); int rcvbuf 2 * 1024 * 1024; ::setsockopt(fd, SOL_SOCKET, SO_RCVBUF, rcvbuf, sizeof(rcvbuf)); sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_port htons(9000); addr.sin_addr.s_addr htonl(INADDR_ANY); if (::bind(fd, reinterpret_castsockaddr*(addr), sizeof(addr)) 0) { std::perror(bind); ::close(fd); return 1; } char buf[2048]; for (;;) { pollfd pfd{fd, POLLIN, 0}; int r ::poll(pfd, 1, -1); if (r 0) { if (errno EINTR) continue; break; } if (!(pfd.revents POLLIN)) continue; sockaddr_in peer{}; socklen_t plen sizeof(peer); ssize_t n ::recvfrom(fd, buf, sizeof(buf), MSG_TRUNC, reinterpret_castsockaddr*(peer), plen); if (n 0) { if (errno EINTR) continue; std::perror(recvfrom); continue; } if (static_castsize_t(n) sizeof(buf)) { std::fprintf(stderr, truncated datagram: %zd %zu\n, n, sizeof(buf)); continue; } char ip[INET_ADDRSTRLEN] {0}; ::inet_ntop(AF_INET, peer.sin_addr, ip, sizeof(ip)); std::printf(recv %zd bytes from %s:%d\n, n, ip, ntohs(peer.sin_port)); // 原样回一个包方便自测 ::sendto(fd, buf, static_castsize_t(n), 0, reinterpret_castsockaddr*(peer), plen); } ::close(fd); return 0; }编译用g -O2 -Wall -Wextra -o udp_server udp_server.cpp几个注意事项值得单独说。第一-Wall -Wextra必须开recvfrom的socklen_t*和size_t之间的隐式转换在新版编译器上会报警告早发现早改。第二poll的第三个参数传 -1 表示永久阻塞生产环境建议改成有限超时比如 1000ms这样主循环有机会做其他事情比如检查退出标志。第三回包用的plen是recvfrom之后的值正好是对端的真实地址长度直接复用即可不需要再sizeof。客户端对着这个服务端发的代码片段sockaddr_in dst{}; dst.sin_family AF_INET; dst.sin_port htons(9000); ::inet_pton(AF_INET, 127.0.0.1, dst.sin_addr); const char* msg ping; ssize_t sent ::sendto(fd, msg, std::strlen(msg), 0, reinterpret_castsockaddr*(dst), sizeof(dst)); if (sent 0) std::perror(sendto);验证的时候可以开两个终端一个跑服务端一个用nc -u 127.0.0.1 9000手动发字符串比写客户端更快。tcpdump -i lo udp port 9000 -X能看到收发的原始字节确认字节序和内容是否符合预期。最后分享一个我在实际项目里踩过的坑早期为了省事把recvfrom的缓冲区定义成char buf[512]本地测试全过一上真实环境就偶尔丢包。查了两天才发现是某个上游模块在消息头里带了一段调试信息长度随配置变化最长的时候超过 512。加了MSG_TRUNC判断之后问题立刻在日志里现形。缓冲区大小不要凭感觉定直接按业务允许的最大报文长度给多给一两百字节的余量成本几乎为零省下的排查时间非常可观。