ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux下UDP Socket编程实战:C语言聊天室完整实现

Linux下UDP Socket编程实战:C语言聊天室完整实现 1. 项目定位与技术选型为什么是UDP聊天室1.1 这个项目到底要做什么经常有朋友问我Linux网络编程怎么入门。我的回答一直都挺简单——先找一个小而完整的场景把它真正跑起来比翻十本教材都管用。今天这篇就写一个经典项目在Linux环境下基于UDP的Socket编程实现一个能用的简单聊天室。聊天室这个场景很妙。它要求一台服务器同时服务多个客户端要求一条消息能从一个人扩散到所有人还要求系统能区分“这是谁说的”。这三个要求恰好把UDP socket编程的核心知识点全串起来了socket创建、端口绑定、地址结构体处理、数据报收发、线程模型、在线用户管理。一个项目学完Linux网络编程的地基就算打牢了。先明确功能边界。我们要做的不是一个企业级IM不需要离线消息、历史记录、消息加密更不需要分布式部署。第一版的全部目标就三条服务端在Linux上启动并绑定一个固定端口客户端连接上来后可以发送文本消息服务端收到消息后加上发送者标识和时间戳再转发给其他在线客户端。功能就这些足够练习也不会在一开始就被各种复杂设计淹没。1.2 为什么用UDP而不是TCP“聊天室不都是用TCP吗”这是每个看到标题的人都会问的问题。市面上的IM产品底层确实大量使用TCP但那是为了保证消息必达。我们做的是局域网内的文本聊天室本质是广播场景——一条消息发给所有人。广播场景里UDP反而更贴合直觉也更能让你看清Socket本身的运作方式。第一个理由UDP不需要维护连接状态。TCP必须先三次握手再为每个客户端单独维护一条连接服务端代码里躲不开accept、listen还要处理每个连接的文件描述符。而UDP是“无连接”的服务端只需要一个socket就能接收来自任意客户端的消息。聊天室的广播逻辑因此变得非常直接收到一条数据报然后遍历客户端列表挨个发出去。第二个理由UDP天然有消息边界。发送端每调用一次sendto接收端调用recvfrom拿到的必然是完整的一条数据报。TCP是流式协议数据像水管里的水一样连续流动应用层必须自己处理“粘包”和“半包”问题要引入缓冲区和长度字段。这些逻辑本身很有价值但放在入门阶段反而会冲淡对Socket本体机制的理解。先学UDP可以绕开这一层干扰。第三个理由UDP的编程接口非常统一socket、bind、sendto、recvfrom四个函数一套到底。今天搞懂了这套模型后面切换TCP时也不过是多了connect、listen、accept三个环节基础能力完全复用。代价当然也得说清楚。UDP不保证送达、不保证顺序、甚至可能重复送达。所以如果你的场景是传文件、转账交易、消息必须可靠那就老老实实走TCP或者基于UDP自己做超时重传和序号机制。协议没有绝对的好与坏只有合不合适。我们选UDP是场景匹配后的选择。注意初学阶段别急着给UDP贴“低级”标签。很多高性能分布式系统恰恰利用UDP的轻量特性来设计私有协议比如各种游戏同步、视频流传输。理解UDP的取舍是后面选择一切通信方案的基础。2. 核心原理拆解Socket API与UDP协议的关键点2.1 UDP最影响代码的三个特性写代码之前先把UDP协议层的关键特性过一遍你会发现代码结构完全是被这些特性决定的。第一个特性是地址与端口绑定。UDP数据报头部只有8个字节源端口、目标端口、长度、校验和。但你在socket编程里打交道最多的不是头部本身而是系统用来记录“这个包该发给谁、来自哪里”的地址结构体struct sockaddr_in。收发数据的时候源地址和目标地址分别以sendto和recvfrom参数的形式出现必须由程序显式传递。这个特点决定了代码里要重复处理地址信息。第二个特性是报文长度限制。UDP数据报理论最大长度是65507字节IP包最大65535字节减去IP头20字节和UDP头8字节。但实际网络传输中典型以太网MTU是1500字节扣掉IP头和UDP头数据部分超过1472字节就可能触发IP分片。分片后只要有一个分片丢失整个数据报就废了。所以聊天室里的消息长度最好控制在512字节到1400字节以内。这个结论不是拍脑袋而是直接来自MTU计算后面讲缓冲区设计时还会用到。第三个特性是无连接、不可靠。UDP没有握手和挥手sendto丢出去就不管了接收方是否收到完全听天由命。反映到编程里就是服务端不需要为每个客户端创建独立socket客户端也不会有对端关闭的“通知”。你无法像TCP那样通过read返回0来感知对方下线。这也直接决定了聊天室的“用户管理”必须有自己的策略后面会专门讲。2.2 四个系统调用串起整个流程UDP socket编程核心就是四个系统调用socket、bind、sendto、recvfrom。把它们的调用逻辑理清楚聊天室的主干就已经搭好了。socket函数负责创建一个网络文件描述符。socket(AF_INET, SOCK_DGRAM, 0)中AF_INET指定IPv4地址族SOCK_DGRAM指定数据报套接字类型第三个参数传0表示根据前两者由系统自动选择协议在这里就是UDP。返回值是一个非负整数后面所有收发操作都靠它。bind函数把socket和一个本地地址绑定起来。服务端必须调用bind把端口固定下来否则客户端不知道该把消息发到哪。讲个典型场景服务端bind到8888端口客户端启动后第一次sendto操作系统会临时分配一个高位端口给客户端这个临时端口就变成了客户端在服务端眼中的“身份标识”。客户端一般不需要显式bind但做调试时绑一个固定端口会更方便用抓包工具分析。sendto和recvfrom是最有意思的一对。sendto的第五、第六个参数是目标地址和目标地址长度因为UDP没有连接每次发送都要指定“这封信寄给谁”。recvfrom恰恰相反第四、第五、第六个参数是接收缓冲区、来源地址和地址长度调用返回后来源地址里就填上了“这封信是谁寄来的”。服务器正是靠这个来源地址识别客户端的。这里面藏着一个新手最容易踩的坑recvfrom第六个参数addrlen是“输入输出参数”调用前必须初始化为sizeof(struct sockaddr_in)否则函数不知道地址缓冲区有多大调用直接失败或拿不到完整地址。我第一次写UDP程序的时候就在这栽了排查了半天才发现是忘了初始化一个非常隐蔽的小问题却能卡住你一整晚。2.3 消息格式的简单设计与用户识别聊天室的消息格式不需要花哨我用的是“原文服务端加工”的方案。客户端把一行文本直接塞进sendto发送不包装任何额外信息服务端收到后取出来源地址和当前时间拼接成“[HH:MM:SS] 用户ID: 内容”这样的完整消息再广播给其他客户端。为什么不在客户端就组装好带上ID和时间因为服务端是唯一的消息汇聚点由它统一加工可以保证同一时刻所有客户端显示的消息格式一致。客户端保持轻薄只负责两件事收用户键盘输入、显示服务端发来的消息。这也是整个项目里“单一职责”思想的体现。用户识别是这个项目的难点。TCP有连接概念accept后每个连接天然对应一个客户端。UDP没有连接服务端只能靠数据报的来源地址IP端口组合来区分客户端。第一次收到陌生地址的消息时把它登记进用户表之后每次收到消息先查这个地址是否已在表中在就直接广播不在就加入再广播。提示客户端重启后IP不变但端口变了旧用户表里会残留旧地址轻则显示重复用户重则消息被发到一个已经没人监听的端口。基础版本可以先忽略但后续如果你想做得更完善建议在消息里增加一个自报的用户ID用ID替代IP端口作为主键。3. 完整代码实现服务端与客户端的每一行3.1 服务端核心代码与逐段说明下面进入实操。服务端的整体逻辑是创建socket、绑定端口、然后进入一个无限循环每次调用recvfrom接收消息识别用户拼接广播内容遍历在线用户表逐个sendto。代码使用pthread库但先说明一点我这里并没有给每条广播单独开线程而是采用同步遍历发送的方式。在局域网、少量客户端的前提下这种方式足够快代码也更好理解。// server.c —— UDP聊天室服务端 // 编译gcc -o server server.c -lpthread // 运行./server #include stdio.h #include stdlib.h #include string.h #include unistd.h #include time.h #include arpa/inet.h #include sys/socket.h #include netinet/in.h #include pthread.h #define PORT 8888 #define MAX_MSG 1024 #define MAX_CLIENTS 64 typedef struct { struct sockaddr_in addr; int valid; int id; } ClientNode; ClientNode clients[MAX_CLIENTS]; int client_count 0; pthread_mutex_t lock PTHREAD_MUTEX_INITIALIZER; int server_fd; int find_client(struct sockaddr_in *addr) { for (int i 0; i MAX_CLIENTS; i) { if (clients[i].valid clients[i].addr.sin_port addr-sin_port clients[i].addr.sin_addr.s_addr addr-sin_addr.s_addr) { return i; } } return -1; } int add_client(struct sockaddr_in *addr) { pthread_mutex_lock(lock); for (int i 0; i MAX_CLIENTS; i) { if (!clients[i].valid) { clients[i].addr *addr; clients[i].valid 1; clients[i].id i; client_count; fprintf(stderr, [INFO] 新客户端 %d: %s:%d\n, i, inet_ntoa(addr-sin_addr), ntohs(addr-sin_port)); pthread_mutex_unlock(lock); return i; } } pthread_mutex_unlock(lock); return -1; } int main() { struct sockaddr_in server_addr; char buffer[MAX_MSG]; server_fd socket(AF_INET, SOCK_DGRAM, 0); if (server_fd 0) { perror(socket); exit(1); } memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(PORT); server_addr.sin_addr.s_addr htonl(INADDR_ANY); int opt 1; setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); if (bind(server_fd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(bind); close(server_fd); exit(1); } printf([INFO] UDP聊天服务器已启动监听端口 %d\n, PORT); while (1) { struct sockaddr_in client_addr; socklen_t addr_len sizeof(client_addr); memset(buffer, 0, MAX_MSG); ssize_t n recvfrom(server_fd, buffer, MAX_MSG - 1, 0, (struct sockaddr *)client_addr, addr_len); if (n 0) { perror(recvfrom); continue; } buffer[n] \0; int idx find_client(client_addr); if (idx 0) { idx add_client(client_addr); if (idx 0) { fprintf(stderr, [WARN] 客户端数量已满丢弃来自 %s:%d 的消息\n, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); continue; } char welcome[128]; snprintf(welcome, sizeof(welcome), [系统] 你已加入聊天室当前在线人数%d\n, client_count); sendto(server_fd, welcome, strlen(welcome), 0, (struct sockaddr *)client_addr, sizeof(client_addr)); } time_t now time(NULL); struct tm *tm_info localtime(now); char time_str[32]; strftime(time_str, sizeof(time_str), %H:%M:%S, tm_info); char msg[MAX_MSG 64]; snprintf(msg, sizeof(msg), [%s] 用户%d: %s, time_str, idx, buffer); pthread_mutex_lock(lock); for (int i 0; i MAX_CLIENTS; i) { if (clients[i].valid i ! idx) { sendto(server_fd, msg, strlen(msg), 0, (struct sockaddr *)clients[i].addr, sizeof(clients[i].addr)); } } pthread_mutex_unlock(lock); printf([%s] 用户%d: %s\n, time_str, idx, buffer); } close(server_fd); return 0; }这一段代码里最值得多看两眼的是用户表的遍历和锁的使用。clients数组可能会被多个地方操作同一时刻可能有两个线程调用sendto和访问用户表所以整个遍历发送过程都被pthread_mutex_lock保护了起来。这是很多人初次写多线程网络程序容易忽略的点如果你不在遍历时加锁另一边恰好有用户下线或上线很可能遍历到一半数组内容变了轻则漏发重则段错误。3.2 客户端代码与收发线程的分工客户端的做法是“一个接收线程加一个发送主循环”。接收线程专门负责从socket里读服务端发来的广播消息并打印到屏幕主循环负责读用户在终端里输入的文字通过sendto发给服务端。拆成两个线程的理由很直接如果单线程只做发送那在这台客户端发消息的间隙别人发来的消息就会一直积压在缓冲区里没人管根本没法实时聊天。// client.c —— UDP聊天室客户端 // 编译gcc -o client client.c -lpthread // 运行./client 127.0.0.1 #include stdio.h #include stdlib.h #include string.h #include unistd.h #include arpa/inet.h #include sys/socket.h #include netinet/in.h #include pthread.h #define SERVER_PORT 8888 #define MAX_MSG 1024 int sock_fd; void *receive_thread(void *arg) { char buf[MAX_MSG]; while (1) { memset(buf, 0, MAX_MSG); ssize_t n recvfrom(sock_fd, buf, MAX_MSG - 1, 0, NULL, NULL); if (n 0) { buf[n] \0; printf(\r%s\n , buf); fflush(stdout); } } return NULL; } int main(int argc, char *argv[]) { if (argc 2) { fprintf(stderr, 用法: %s 服务器IP\n, argv[0]); exit(1); } struct sockaddr_in server_addr; 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); exit(1); } sock_fd socket(AF_INET, SOCK_DGRAM, 0); if (sock_fd 0) { perror(socket); exit(1); } pthread_t tid; if (pthread_create(tid, NULL, receive_thread, NULL) ! 0) { perror(pthread_create); exit(1); } printf(输入消息回车发送输入 quit 退出\n ); fflush(stdout); char line[MAX_MSG]; while (fgets(line, sizeof(line), stdin) ! NULL) { line[strcspn(line, \n)] \0; if (strcmp(line, quit) 0) { break; } if (strlen(line) 0) { sendto(sock_fd, line, strlen(line), 0, (struct sockaddr *)server_addr, sizeof(server_addr)); } printf( ); fflush(stdout); } close(sock_fd); return 0; }这里有个容易被忽略但是影响使用体验的细节接收线程打印消息时在消息前加了\r打印完提示符后用fflush(stdout)刷新输出。由于终端里主线程很可能正在显示“ ”等待输入别人发来的消息会在同一行插入。加上\r能先把光标拉回行首再打印新消息并重新输出提示符这样终端里的画面才不容易混乱。3.3 缓冲区、端口复用与并发模型的取舍代码写完了把这几个关键参数为什么这么定说清楚。第一是缓冲区大小。MAX_MSG设为1024接收缓冲区是MAX_MSG - 1留一个字节给字符串结尾符。这跟前面提到的MTU计算是对应的——正常以太网传输下超过1400字节就可能分包1024这个值预留了足够的余量在传输效率和消息容量之间取了平衡。如果改成8192甚至更大跨路由器传输时反而更容易发生分片丢失收不完整的概率反而上升。第二是SO_REUSEADDR端口复用。服务端代码里通过setsockopt开启这个选项作用是当服务端异常退出、端口处于TIME_WAIT状态时可以快速重新绑定同一个端口。调试阶段这几乎是必须的没有它你CtrlC掉服务端再想重启常常会收到No such file or directory或者“Address already in use”的报错非常烦躁。第三是并发模型。这个版本采用了线程加锁的方式但实际广播用的是同步发送。也就是说发送过程被锁保护的这段时间其他线程无法修改用户表但sendto本身可能因网络拥塞而变慢极端情况下会拖住接收循环。局域网、少量客户端时无所谓。如果以后要容纳大量客户端建议引入线程池或者专门的消息队列把“接收”和“广播”解耦。初学阶段先把同步模型跑通性能优化是后面的事。4. 实测运行记录从本机环回到跨机通信4.1 本机环回测试三个终端模拟在线聊天代码写完最直观的验证方式是本机环回测试。环回地址127.0.0.1数据不经过任何物理网卡直接在协议栈内部返回是UDP逻辑验证最简单可靠的环境。开三个终端第一个跑服务端第二个跑第一个客户端第三个跑第二个客户端。顺序分别是# 终端1 gcc -o server server.c -lpthread ./server # 终端2 gcc -o client client.c -lpthread ./client 127.0.0.1 # 终端3 ./client 127.0.0.1服务端启动后会打印监听端口客户端启动后会立即收到一条“你已加入聊天室”的欢迎消息。终端2输入“大家好”终端3和服务端控制台会同时看到“[20:30:15] 用户0: 大家好”这样的输出。如果一切正常说明消息从客户端2发出经过服务端识别、广播到达客户端3的整条链路已经打通。我在实际测试时通常会特意在客户端1和客户端2之间交替发送几条长文本和空行验证消息边界和空文本的处理。代码里专门加了strlen(line) 0的判断空输入直接跳过不会产生只有时间戳和用户名、没有内容的垃圾广播。4.2 跨虚拟机与局域网实测本机环回验证通过后就要测真实网络环境。这里说一个很多人没意识到的问题如果你和我一样是在虚拟机里跑Linux虚机默认是NAT网络模式宿主机和虚拟机之间的网络行为跟真实局域网有很大差别。建议先把虚拟机网络模式改成桥接或者直接把另一台物理机器拉进来测。跨机器测试时客户端启动命令里把IP换成服务端的局域网IP就行。但真实网络中有几个因素可能让“原本在环回上好好的”程序突然失灵。第一个就是防火墙。Linux上用ufw status或firewall-cmd --list-all检查端口是否放行UDP没有连接状态防火墙很难像TCP那样自动识别“合法回包”。最直接的排查方式是先临时关闭防火墙或放行UDP 8888端口确认通之后再收紧规则。另外服务端绑定地址用了INADDR_ANY也就是0.0.0.0表示监听本机所有网卡这点不需要改。客户端在发送时用inet_pton把字符串IP转成二进制地址这里如果填错或者填成了环回地址跨机通信自然不通。4.3 实测结果与丢包现象观察下面是我在一次局域网测试中记录的现象三台设备服务端放在一台物理机上两个客户端分别跑在虚拟机和另一台物理机上。测试项操作结果双客户端互通客户端A发消息客户端B观察正常收到延迟 1ms长文本传输发送约900字节的中文字符串正常收到无截断快速连续发送10条消息在2秒内连续发出收到9条丢失1条服务端重启客户端不退出服务端重启后继续发送客户端不再收到消息直到重新加入快速连续发送丢包这个现象太典型了。原因有两层一是UDP本身不保证可靠传输二是如果发送速率太快服务端recvfrom来不及读内核接收缓冲区很快填满新到的数据报直接被丢弃。这不是代码bug而是UDP的固有限制。如果想降低丢包率一是增加接收 socket 的内核缓冲区大小用setsockopt调大SO_RCVBUF二是缩短服务端接收循环里每条消息的处理时间。但这些优化有上限真要保证必达还是得走TCP或者自己设计重传机制。5. 高频坑位排查我在这个项目里踩过的雷5.1 收不到消息先做这五项检查收不到消息是这个项目里最常见的问题我自己排查过很多次也帮别人排查过很多次。总结下来大多数时候跑不掉下面这五个原因。检查项具体操作典型报错或现象地址结构体是否清零确认使用了memset(addr, 0, sizeof(addr))乱码目标地址sendto返回-1sin_family是否赋值每个sockaddr_in都要设置sin_family AF_INETrecvfrom返回错误或地址无效端口字节序是否转换用htons()和ntohs()转换端口端口错位消息进不了预期进程addrlen是否初始化recvfrom前len sizeof(addr)recvfrom返回-1errno为EINVAL防火墙与端口冲突ss -ulnp查看端口占用检查ufw/firewalldbind报Address already in use第一项容易被忽略因为struct sockaddr_in没有主动清零的话里面的填充字段是随机值在某些情况下会影响地址比较。我在find_client里比较地址时是把s_addr和sin_port都拿出来比较的如果结构体里有残留数据这个比较很可能永远不相等导致用户被重复登记。还有一点排查时用strace跟踪系统调用是最快定位问题的手段。比如strace -e tracesocket,bind,recvfrom,sendto ./server系统调用这一层有没有报错一目了然。这个工具我几乎每次调试网络程序都会用强烈推荐。5.2 消息截断与乱码的根源消息截断是个隐藏很深的坑。多数情况下是因为接收方缓冲区定义得比发送方的数据短。比如服务端MAX_MSG设成1024但客户端line数组也是1024。如果用户手输超过1024字节fgets会截断sendto只发送截断后的内容看起来没有问题。真正的问题出在服务端的广播缓冲区上char msg[MAX_MSG 64]如果原始消息接近1024加上时间戳和用户ID就有可能超过这个长度这时snprintf会安全截断但你的消息后半截就丢了。乱码的原因则通常是编码问题。中文在UTF-8编码下一个字占3个字节1024字节的缓冲区其实只能容纳大约340个汉字。没做转码的情况下如果发送端终端用GBK码服务端终端用UTF-8收到的中文内容就会显示成乱码。这个和网络无关纯粹是终端编码不一致导致的。提示在做聊天室这种文本应用时最好在项目文档里明确约定“统一使用UTF-8编码”。同时把缓冲区上限写得保守一点比如1024字节并在多个地方做长度校验这是成本最低的防崩溃方案。5.3 非阻塞与超时什么时候才用得上默认情况下recvfrom是阻塞的程序会一直停在调用处等待数据。聊天室服务端这个模型没有问题因为它是“一直等消息”的。但如果你需要在等待消息的同时处理别的事比如检测用户是否超时离线那阻塞模型就不够用了。两种常见做法一是用fcntl把socket设为非阻塞模式让recvfrom立即返回如果没有数据就返回-1并设置errno为EAGAIN或EWOULDBLOCK二是用setsockopt配合SO_RCVTIMEO设置超时时间超时后recvfrom返回-1errno同样为EAGAIN。我做这个项目时没有引入非阻塞一是为了保持代码简洁二是聊天室场景里“一直等着收”本来就是合理行为。但如果未来要加心跳超时检测、要定期清理离线用户就绕不开这个机制。到时候你会发现UDP用户表的清理逻辑一定不能靠“等recvfrom告诉你对端消失”因为UDP根本不给这种通知你必须自己在逻辑层定时扫描。5.4 打印错乱与线程安全的隐藏坑最后说一个特别容易被忽视的细节。在客户端的接收线程里我用printf(\r%s\n , buf)直接在终端打印收到的消息看起来只是行输出而已但这里如果处理不好会出现两种问题。第一种是终端画面错乱。假设你正在输入一条很长的消息消息还没按回车别人的广播消息在这时到了。接收线程打印广播内容主线程的输入缓冲区还保留着你打了一半的文字回车发送后你输入的内容和广播内容会叠在一起。使用\r加上重绘提示符能够减轻这个问题但严格来说标准的做法是使用ncurses库把你输入区和消息显示区分开。这个我在基础版本里没做但你在实际使用中一定会遇到。第二种是线程安全问题。接收线程调用printf的同时主线程也可能正在printf写入提示符两个线程同时操作同一个标准输出存在竞态风险。虽然大多数情况下printf有自己的内部锁不至于crash但输出顺序依然不可控。我最后选择用fflush(stdout)强制刷新在大多数终端上表现稳定但不能保证在所有环境都绝对安全。再回到服务端的用户表。pthread_mutex_t lock锁保护的是整个用户表的读写但这个锁并不能保证客户端状态是“永远正确”的。比如客户端进程崩溃后操作系统会释放端口但服务端用户表里的登记信息不会自动消失。后续如果做心跳检查就能及时清理这些僵尸用户。这也是我建议你把这个项目跑通之后下一步马上加的东西之一。我个人在实际操作中的体会是这个项目最大的价值不在于聊天室本身而在于它把“UDP无连接”这个抽象概念变成了你能亲手摸到的现实。跑通本机环回、再到两台真实机器之间通信你会对IP地址、端口、数据报边界、缓冲区、线程模型这些东西建立起非常具体的感知。之后再学TCP也好学epoll也好都会顺畅很多。如果还想继续扩展可以试着把服务端改成多播模式让“广播”不再靠逐个发送而是通过IP多播地址一次投递那又会打开一个新的世界。先把这一版跑起来后面路宽着呢。
返回列表