ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux下UDP接口测试与多人聊天Demo完整实践

Linux下UDP接口测试与多人聊天Demo完整实践

在 Linux 下做网络编程,我猜很多人第一个接触的都是 TCP:连接可靠、代码套路固定、出问题也好排查。但要说真正把网络层的脾气摸清楚,UDP 接口测试是绕不开的一关。前几天我把“UDP 接口测试 && 多人聊天 demo”完整做了一遍,从最基础的 socket 骨架,到好几个客户端同时在线聊天,整体比搞 TCP 顺畅不少,但坑也踩了一堆。这篇文章就把我当时的方案、代码、测试过程和排查思路完整整理出来,给想快速上手 UDP 编程,或者需要实现一个最小 UDP 转发服务的人一份可以直接抄作业的参考。

这个项目拆开看就两件事:第一,把 UDP 通信涉及的核心接口逐一验证,确认行为符合预期;第二,用多人聊天这个场景把这些接口串起来,证明它们组合在一起能承担真实任务。整条链路覆盖了 socket 创建、地址绑定、数据报收发、客户端管理、消息广播和并发测试,基本把 UDP 服务的常见形态都过了一遍。适合刚接触 Linux socket 编程的读者,也适合要做日志上报、在线状态通知这类轻量转发服务的开发者直接复改。

1. 为什么选 UDP 来测接口和做聊天 Demo

1.1 需求拆解:接口测试和多人聊天分别要什么

仔细拆一下这个项目,其实是两个需求叠在一起。

第一个需求是“UDP 接口测试”。这里说的接口不是 HTTP 接口,而是系统调用层的接口,包括socket、bind、sendto、recvfrom、select、setsockopt这些函数。测试的目标是确认它们在目标机器上的行为符合预期:参数怎么取值、返回值代表什么、错误码什么情况下出现、多线程下是否稳定。所以这一步不应该一上来就写大而全的应用,而是先搭一个最小骨架,再逐步加条件验证。

第二个需求是“多人聊天 demo”。作为验证 UDP 接口的载体,多人聊天有个非常好的性质:消息边界天然清晰,每个数据报正好对应一条消息,服务器做转发时不用拆包、拼包、处理粘包,能把注意力集中在网络接口本身。聊天 demo 和接口测试放在一起,最终结果既能看到接口是否正常,又能直观感受到多客户端同时在线时的实时通信效果。

我最后选用的方案很朴素:客户端-服务器模式,所有客户端把消息发给服务器,服务器再把消息广播给其他在线客户端。没有用开源聊天框架,没有引入消息队列中间件,甚至没有设计复杂应用层协议,纯粹靠原生 socket API。原因很简单,测试阶段最重要的就是透明,协议越简单,问题越容易定位。

1.2 对比 TCP 之后为什么选 UDP

先放一张对比表,把 UDP 和 TCP 在这个场景下的差异说清楚:

维度TCPUDP
连接管理三次握手建立连接,断开要四次挥手无连接,sendto 就能发
消息边界字节流,需要应用自定义分包和处理粘包数据报,一次 recvfrom 正好收一条消息
可靠性有序、可靠、丢包重传不可靠,可能丢包、乱序
服务端实现每连接一个 fd,需要事件循环或线程池单一 fd,recvfrom 一次就能拿到源地址
在线管理连接断开可以感知没有连接概念,要靠应用层心跳

对照下来,多人聊天场景里单条消息只有几百字节,丢一条不至于造成事故,顶多让对方喊你重发一次;加上 UDP 的“无连接 + 单 fd + 天然消息边界”三个特性,服务器实现成本低非常多。反过来,如果这个 demo 要做文件传输、在线交易,那肯定不能用 UDP,可靠性和流量控制都得自己补,工程量反而比 TCP 大。

有人可能会问,为什么不用 UDP 广播或者组播来实现多人聊天?我当时的考虑是:广播只能局限在同一网段,跨网段路由器不转发;组播在家庭路由和部分云主机网络里限制很多。用服务器转发虽然在局域网内多一次拷贝,但能稳定覆盖跨网段场景,也更贴近大多数线上 UDP 服务的设计模型。所以最终选了客户端-服务器这种看起来“土”但足够稳的方案。

2. UDP 接口层的基础与最小骨架

2.1 先跑通最小 socket 骨架

正式开始前,我在一台干净的 Linux 机器上准备好环境:gcc 编译器、基础头文件就够。UDP socket 最小骨架只有几行代码:

#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> int main(void) { int sockfd = socket(AF_INET, SOCK_DGRAM, 0); if (sockfd < 0) { perror("socket"); exit(1); } close(sockfd); return 0; }

第一个参数AF_INET指定 IPv4,第二个参数SOCK_DGRAM表示数据报套接字,第三个协议参数传 0,让系统自动选择 UDP 协议。TCP 用的是SOCK_STREAM,这是两者在 socket 层面最直观的区别。

编译命令也很简单:

gcc -o udp_min udp_min.c -Wall

创建出来的 fd 不 bind 也能直接sendto,但那种状态通常只能发包不能收包,适合做纯客户端。如果一个进程要接收消息,就必须先 bind 到某个端口。bind 失败最常见的原因两个:一个是端口被占用,报Address already in use;另一个是权限不足,绑定 1024 以下端口需要 root 权限。

2.2 bind、sendto、recvfrom 三个核心函数

bind 的代码模式几乎是固定的:

struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(8888); if (bind(sockfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); exit(1); }

这里有两个点新手最容易踩:

  • s_addr用htonl(INADDR_ANY)表示绑定所有网卡地址,适合服务器;
  • sin_port必须先htons转成网络字节序。直接写 8888,实际绑定的端口会是另一个数,排查起来非常迷惑。

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);

注意,recvfrom 的最后一个参数是socklen_t *,调用前必须初始化:

socklen_t addrlen = sizeof(src_addr);

我最早写的时候就是漏了这行初始化,结果客户端地址解析出来一直是0.0.0.0,花了大半天才发现问题只在参数没初始化,不是网络问题。这个坑在后面第 5 节还会再次遇到,可以说是 UDP 编程最容易自坑的地方。

UDP 一个隐藏行为是“数据报边界”:发送端每次sendto产出一个数据报,接收端一次recvfrom只能取一个数据报;如果缓冲区小于数据报长度,多余部分会被直接丢弃,不会像 TCP 那样留在缓冲区里下次再读。所以我做聊天 demo 时把缓冲区定成 2048,一条普通聊天消息远小于此值,够用又不会踩截断的坑。

2.3 端口复用和接收超时

接口测试阶段一个很现实的问题是服务器会频繁重启。UDP 虽然没有 TCP 那么多连接状态,但如果旧进程还占着端口,再次 bind 依然会失败。解决方法是在创建 socket 之后立刻加一行:

int opt = 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

这行代码在测试期间几乎是必须的。我调试时经常改代码、杀进程、重新启动,没加它之前基本每两次就要换个端口或者等一等,加了之后从容很多。

如果不想让recvfrom无限期阻塞,可以用setsockopt的SO_RCVTIMEO设置接收超时,也可以用select做事件等待。单线程带超时用前者简单,但多客户端同时在线时我更推荐select,因为 select 是在“fd 是否可读”这个层面等待,不会因为某个数据包的阻塞影响其他客户端的处理。这个特性在后面并发测试和聊天服务器里都会用到。

3. 接口测试实操:从回显到并发

3.1 第一轮:最小回显服务验证主链路

第一轮测试我写了个最小回显服务,逻辑是“你发什么,我原样发回什么”。这一轮把bind + recvfrom + sendto这条主链路完整跑通。

while (1) { struct sockaddr_in client_addr; socklen_t addrlen = sizeof(client_addr); char buf[2048]; int n = recvfrom(sockfd, buf, sizeof(buf) - 1, 0, (struct sockaddr *)&client_addr, &addrlen); if (n < 0) { perror("recvfrom"); continue; } buf[n] = '\0'; printf("recv %s:%d -> %s\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), buf); sendto(sockfd, buf, n, 0, (struct sockaddr *)&client_addr, addrlen); }

两个细节值得留意:recvfrom的缓冲区长度减 1,是为了给字符串结束符留位置;sendto的长度用n,也就是原样发回实际收到的字节数,不要用strlen(buf),否则在消息里包含\0或二进制内容时长度会不一致。

测试客户端我用系统自带的 nc:

echo "hello udp" | nc -u 127.0.0.1 8888

看到服务器打印出源地址、源端口和消息内容,同时客户端收到回显hello udp,第一轮就算通过。不过 nc 只能测单包,后续的多人场景就不够用了。

3.2 第二轮:用 select 处理多客户端并发

进入第二轮,把服务器改成select加recvfrom的写法。select 的作用是监听 fd 的可读事件,替代直接阻塞在 recvfrom 上。

fd_set readfds; FD_ZERO(&readfds); FD_SET(sockfd, &readfds); int ret = select(sockfd + 1, &readfds, NULL, NULL, NULL); if (ret > 0 && FD_ISSET(sockfd, &readfds)) { // 走到这里的 recvfrom 一定不会阻塞 }

select 第一个参数要传“最大 fd 值 + 1”,这是个很细但是关键的规则。写小了在某些内核版本下会静默丢失事件,排查起来很坑。

这一轮我同时起了 5 个客户端,轮流发消息,每个包都能被服务器正常收到并回显,没有丢包和错包。UDP 不像 TCP 那样有连接队列,服务器只要 bind 了端口,所有客户端都能平等地把数据报到同一个 fd 上,这是 UDP 多客户端并发的关键,也是后面聊天 demo 能成立的基础。

3.3 第三轮:接口行为确认清单

三轮测试跑完,我给自己列了一张验收清单,当作接口测试的结论:

  • bind绑定 INADDR_ANY,端口不可重复绑定,开启 SO_REUSEADDR 后重启无压力;
  • sendto无需 connect 就能向任意地址发包,返回值是实际发送字节数;
  • recvfrom每次取一个数据报,源地址和源端口可以准确拿到;
  • select监听 fd 可读状态,单线程处理多客户端收发足够;
  • UDP 数据报边界严格,一次发送对应一次接收,不需要处理粘包。

到这里接口测试部分完成,接下来就是把测试代码扩展成多人聊天 demo。

4. 多人聊天 Demo 完整实现

4.1 服务器端设计:客户端表加广播转发

服务器端的核心逻辑很清晰:维护一张在线客户端表,收到任意一条消息后,把消息转发给表中除了发送者之外的所有客户端。

为什么选择排除发送者?因为发送者本地已经知道自己发了什么,如果服务器再回传一遍,客户端就会在终端看到自己消息的重复打印,观感很差。部分聊天软件会回传并自行去重,各有理由,但 demo 里做排除逻辑最简单直观。

客户端表用结构体数组实现:

#define MAX_CLIENTS 64 #define BUFFER_SIZE 2048 typedef struct { struct sockaddr_in addr; int active; } Client; static Client clients[MAX_CLIENTS];

收到消息后的处理流程是:先从表里找相同 IP 和端口的客户端,找到就复用表项;没找到就找第一个 inactive 的空闲位置插入。表满时打印提示并丢弃新客户端消息。用active字段做空闲标记非常轻量,避免了频繁分配释放内存,在 64 并发以内完全够用。

服务器主要转发逻辑:

static void broadcast(int sockfd, const char *msg, size_t len, const struct sockaddr_in *exclude) { for (int i = 0; i < MAX_CLIENTS; i++) { if (!clients[i].active) continue; if (exclude && clients[i].addr.sin_port == exclude->sin_port && clients[i].addr.sin_addr.s_addr == exclude->sin_addr.s_addr) { continue; } sendto(sockfd, msg, len, 0, (struct sockaddr *)&clients[i].addr, sizeof(clients[i].addr)); } }

main 循环就是 select 等待读事件、recvfrom 收数据、登记客户端、构造带发送者信息的消息、调 broadcast 广播。整段代码去掉注释不到 100 行。对接口测试来说,这种设计的好处是所有网络行为都集中在几行系统调用上,哪一步出错,perror 看报错就能快速锁定是 bind、recvfrom 还是 sendto 的问题。

4.2 聊天客户端:收和发分离

客户端要同时干两件事:从键盘读输入并发送,接收服务器广播并显示。最直接的做法是用两个线程,一个管收,一个管发。

接收线程代码:

void *recv_thread(void *arg) { char buf[BUFFER_SIZE]; while (1) { struct sockaddr_in from; socklen_t fromlen = sizeof(from); int n = recvfrom(sockfd, buf, sizeof(buf) - 1, 0, (struct sockaddr *)&from, &fromlen); if (n > 0) { buf[n] = '\0'; printf("\r%s\n> ", buf); fflush(stdout); } } return NULL; }

主线程则是循环fgets读键盘,去掉换行符后 sendto。打印消息前先输出\r回车,覆盖当前正在输入的内容,避免收到的消息把输入行截断。这个小细节能明显提升实际使用体验。

如果不想用线程,客户端可以改成select同时监听 stdin 和 sockfd,逻辑差不多但要处理两个 fd 的读写状态。线程方案在主逻辑上更直观,所以 demo 用了 pthread。

4.3 Demo 运行实录与结果

编译服务器和客户端:

gcc -o chat_server chat_server.c -Wall gcc -o chat_client chat_client.c -Wall -pthread

启动服务器,再开两个终端分别运行客户端:

./chat_server ./chat_client 127.0.0.1 ./chat_client 127.0.0.1

客户端 A 输入hello everyone,服务器控制台打印一行:

[127.0.0.1:54321] hello everyone

客户端 B 的终端上会显示:

[127.0.0.1:54321] hello everyone

B 回一条hi A,A 也能立刻看到[127.0.0.1:55555] hi A。整个过程没有人为加延迟,select 多路复用下 CPU 占用几乎为零,局域网内消息是实时送达的。我还把三个客户端加一个服务器全部跑在同一台机器上模拟多人场景,也没有出现线程冲突,只有客户端同时进入退出时偶尔打印顺序错位,不影响功能。

有一点需要特别注意:UDP 客户端没有“断线”概念,直接关闭客户端进程后服务器不会收到任何通知,客户端表里依然遗留旧地址。这个问题会在第 5 节展开,因为它是 UDP 聊天场景最容易忽略的隐藏坑。

5. 常见问题与排查技巧实录

5.1 高频异常速查表

把这次测试期间遇到的典型问题整理成一张表:

现象可能原因解决方案
bind 报 Address already in use端口被占用加 SO_REUSEADDR,或换端口
客户端收不到回包服务器地址/端口写错,addrlen 未初始化检查 server_addr,初始化 addrlen
发消息后服务器没反应防火墙拦了 UDP 端口临时放行对应端口,确认防火墙规则
消息显示乱码或截断缓冲区太小,数据报被丢弃增大缓冲区,按 MTU 控制单条消息长度
多客户端之间看不到彼此广播时排除发送者的条件写反核对 broadcast 循环中的 continue 条件
客户端关闭后仍在服务器列表UDP 无连接,服务端无法感知退出增加心跳超时机制

“客户端收不到回包”这个问题我在 2.2 小节提到过,addrlen 没初始化导致的地址不对是最典型的自坑。表格里再强调一次,因为它实在太隐蔽了。

5.2 调试工具组合拳

当消息确实发出去了但找不到问题的时候,别只盯着代码看,上抓包工具。我常用的组合是ss、nc和tcpdump:

  • ss -uap | grep 8888看端口监听状态和关联进程;
  • nc -u 127.0.0.1 8888模拟客户端发一条消息;
  • tcpdump -i lo udp port 8888 -XX在回环接口上抓包,确认数据报的四元组和内容。

有一次测试时,客户端明明显示发送成功,服务器也打印了源 IP,但其他客户端就是收不到。我抓包一看,消息其实已经发到了其他客户端本机,但回环接口的数据报显示目标端口不是固定端口,而是某个随机高位端口,原因就是我在客户端recvfrom时把 addrlen 传错,把地址结构改写了。抓包数据直接定位到问题在地址长度参数,而不是广播逻辑。这种问题靠肉眼读代码非常难发现,抓包一开就清楚了。

tcpdump 输出大致是这样的:

IP 127.0.0.1.12345 > 127.0.0.1.8888 UDP, length 11

看到源地址、目标地址和 length 都正常,才敢确认网络层没问题,再回头查应用层。

6. 实操心得与后续扩展

6.1 综合实操心得

做完这个 demo,我最大的感受是:UDP 接口看起来简单,工程细节一点不比 TCP 少。接口测试阶段一定要分层验证,不要一上来就把服务器、客户端、广播全部写完再调试。我先跑回显,再跑并发,最后才加业务逻辑,每一步出问题都能把范围缩得很小,定位速度非常快。

另一个心得是所有与网络字节序相关的字段都要打出来看。调试日志里同时输出ntohs(client_addr.sin_port)和inet_ntoa(client_addr.sin_addr),能第一时间发现地址解析异常。我见过太多人打印整形端口号,数字莫名其妙,最后发现只是忘了字节序转换。

6.2 心跳与离线检测

第 4 节遗留的“客户端退出后表里残留旧地址”问题,正规解法是加心跳机制。思路不复杂:每个客户端每隔 5 秒向服务器发一条特殊标记的心跳消息;服务器在收到消息时维护一个last_seen时间;服务器再另起一个周期扫描逻辑,把超过 10 秒没心跳的客户端标记为 inactive。实现上只需要在 Client 结构体里加一个time_t last_seen字段,再补一段扫描代码,几十行就搞定。

很多人觉得聊天 demo 不需要管离线,但一旦往生产方向走,客户端表无限增长、僵尸客户端占满表项就是 UDP 服务最常见的问题。至少在数据结构上把扩展位留好,后续加心跳不会伤筋动骨。

6.3 扩展方向

如果要让这个 demo 继续生长,我大概会沿这几个方向走:

  • 把单服务器改成多节点转发,用一致性哈希分配聊天室;
  • 在消息前缀里加入聊天室 ID,实现多房间隔离;
  • 增加 AES 或 RSA 加密,保证消息不会被中间人直接读取;
  • 把 select 改成 epoll,支持上万级并发客户端。

改造成 epoll 时有个 UDP 和 TCP 的差异要注意:UDP 下 epoll 只关心 EPOLLIN 和 EPOLLERR 事件,不需要监听 EPOLLOUT,每次 epoll_wait 返回后仍然要用 recvfrom 去读,而不是像 TCP 那样把 fd 交给业务层长期持有。这部分改动不大,但理解模型差异很重要。

最后补一句个人体会:如果让我重做一次,我还会在阶段二就把心跳加上。很多坑在 demo 阶段不出现,等接入真实用户后一个个冒出来,与其那时候补,不如从一开始就按生产标准搭地基。UDP 接口就这么几个,真正值钱的是把这些接口组合成可靠服务时的那层业务逻辑,这才是这次做完之后最实际的收获。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进