资讯动态

TCP多人聊天室实现:三次握手、select与广播完整解析

发布时间:2026/10/9 10:53:32 来源:尧图企业网站定制
简介这是一个基于TCP协议的多人聊天室学习项目面向网络编程初学者和C语言开发者适合课程设计、面试准备或协议自学也可作为简单网络应用的入门范例。压缩包共八个文件包含三个C语言源文件、一个头文件、三个文本说明及一个构建脚本整体仅七KB代码精简目录结构清晰便于按模块定位。项目涵盖TCP连接建立、客户端登录验证、服务端消息转发与广播等核心环节涉及三次握手、多路复用与分解、消息封装、心跳检测等知识点。通过阅读源码与配套文档读者能够掌握多客户端连接的完整处理流程理解在线用户列表维护与消息广播的实现细节并进一步了解离线消息存储与安全加密等扩展方案。该资源已有九十六人学习下载对于想动手实践TCP编程、建立简单网络应用或复习传输层原理的读者具有不错的参考价值。1. TCP 登录多人聊天室从三次握手到广播转发的完整实现如果你跟我一样是计算机专业出来的大概率做过这样一个课程设计用 C 语言写一个基于 TCP 的多人聊天室支持 stu1 到 stu20 这样的账号登录客户端连上服务器之后能群聊消息要广播给所有在线的人。当时我拿到tcp.rar这份资源时第一反应是又是老掉牙的 socket 作业但真正把tcp server.c、client.c、entry.c这几个文件过了一遍之后发现它其实把 TCP 编程里最核心的东西都串起来了三次握手建立连接、登录认证的数据包设计、select 多路复用、消息广播还有粘包处理和心跳保活。无论你是刚学套接字编程的新手还是想快速搭一个局域网内可用的聊天服务做二次开发这份代码都值得下载下来逐行读一遍。下面我把拆包过程、实现逻辑和踩过的坑一次说清。2. 登录认证设计三次握手背后的数据包与消息边界2.1 登录数据包结构为什么不能直接 send 用户名打开public.h就能看到这个项目最核心的设计——通信协议。客户端登录时要把用户名和密码发给服务端但 TCP 是面向字节流的没有消息边界。你调一次send()对端不一定用一次recv()就能完整接住可能拆成两次也可能两次send()合并成一次到达。这就是著名的粘包/拆包问题。这个项目采用了一个非常朴素但有效的方案固定长度的结构体包头。public.h里定义的消息头大致是这样的#define MAX_NAME_LEN 32 #define MAX_PASS_LEN 32 #define MAX_MSG_LEN 256 typedef struct { int type; // 消息类型: 1-登录请求, 2-登录响应, 3-聊天消息, 4-系统通知 int sender_id; // 发送者编号 char username[MAX_NAME_LEN]; char password[MAX_PASS_LEN]; char content[MAX_MSG_LEN]; } message_t;每次发送都把这个结构体作为一整块数据发出去接收方按sizeof(message_t)这个固定长度去recv()。结构体的长度在所有客户端和服务端之间是编译期确定且一致的所以只要一次收满sizeof(message_t)个字节就能解析出一条完整的消息。这种做法牺牲了一点带宽但换来了极其简单的解析逻辑非常适合课程设计这个量级——你不需要引入 JSON 或者 protobuf一个memcpy就能搞定。实际使用时要注意结构体对齐问题。不同编译器、不同平台下sizeof(message_t)可能不一样比如int是 4 字节还是 8 字节结构体尾部有没有 padding如果客户端和服务端用不同编译器编译sizeof 对不上就会全部错位。我一般会在结构体里手动加#pragma pack(push, 1)或者在代码里写一个static_assert(sizeof(message_t) EXPECTED_LEN)来做编译期校验这个后面避坑章节还会细说。2.2 三次握手与登录时序SYN、ACK 和业务层的握手TCP 的三次握手是内核帮你做的connect()返回成功时三次握手已经完成。但业务层的登录认证其实还有另一套握手客户端发登录请求服务端返回登录结果这是一个一问一答的请求-响应模型。这个项目的client.c里登录流程是这样的message_t msg; memset(msg, 0, sizeof(msg)); msg.type 1; // 登录请求 msg.sender_id atoi(argv[1]); // 从命令行取学号如 stu1 - 1 strncpy(msg.username, user, MAX_NAME_LEN - 1); strncpy(msg.password, pass, MAX_PASS_LEN - 1); if (send(sock, msg, sizeof(msg), 0) 0) { perror(send login request); return -1; } // 阻塞等待服务端响应超时用 alarm 或 select 控制 message_t resp; int n recv(sock, resp, sizeof(resp), 0); if (n 0 resp.type 2 resp.sender_id msg.sender_id) { if (resp.content[0] 1) { printf([登录成功] 欢迎回来%s\n, user); } else { printf([登录失败] %s\n, resp.content 1); return -2; } }这里有两个关键的工程细节。第一recv()是阻塞调用如果服务端挂了或者网络断了客户端会卡死在这个地方所以要有超时机制最简单的是setsockopt(SO_RCVTIMEO)配合alarm()。第二登录响应里用content[0]作为状态标志、后续字节作为错误描述字符串这是一种很紧凑的做法解析起来比单独拉一个status字段再加一个errmsg字段更省事。当然代价是逻辑上稍微隐晦一点读代码的时候要留意注释。服务端的登录校验在tcp server.c里它读取同目录下的user.txt文件来验证账号密码。user.txt的格式一般是每行一条记录比如1 stu1 123456 2 stu2 123456 ... 20 stu20 123456服务端在监听循环开启前把整个文件加载进内存用一个user_t数组存好之后每次登录请求进来直接遍历比对。这种做法的好处是终端用户不需要装数据库一个文本文件就能管住 20 个测试账号坏处是服务端中途加用户必须重启进程才生效。如果你要改造成动态添加用户需要在服务端加一个信号处理函数捕获SIGHUP时重新加载user.txt这个我在后面的扩展部分会说。2.3 登录失败与并发登录控制在线列表维护比较容易被忽略的是重复登录问题。这个项目里服务端维护了一个client_info_t数组来记录每个已连接 socket 对应的用户编号typedef struct { int fd; // 客户端 socket int user_id; // 登录成功后填 -1未登录 char username[MAX_NAME_LEN]; } client_info_t; client_info_t clients[MAX_CLIENTS]; int client_count 0;当某个user_id已经处于在线状态又有同 ID 的客户端来登录服务端应该踢掉旧连接或者拒绝新连接。tcp server.c里的常见做法是广播一条用户已登录的系统消息然后把新连接直接关掉。这个细节看起来不起眼但没有它的话两个客户端拿同一个学号登录聊天记录就会互相串最终收到的消息列表会乱成一锅粥。登录还有一层要考虑的是未登录连接的处理。一个客户端connect()成功之后如果不发登录请求就开始发聊天消息服务端要能识别出来并丢弃不能让未认证连接参与广播。这个项目里判断逻辑很简单从clients数组里找到fd对应的记录如果user_id还是 -1就说明没登录除了type 1的登录请求之外一律不转发。认证之后才能进入广播列表这也是登录型聊天室和裸 socket 聊天室的本质区别。3. 服务端多路复用与消息广播select 模型下的转发核心3.1 选型理由为什么是 select 而不是 fork 或线程看tcp server.c的main()会留意到它用的是select()而不是fork()也不是pthread。这是个值得说一嘴的选型问题。如果每个客户端来了就fork()一个子进程20 个客户端就是 20 个进程进程切换开销大而且fork()之后 socket 描述符要小心处理父进程要关掉子进程持有的副本子进程也要关掉父进程监听的 socket稍不留神就会文件描述符泄漏。如果开线程又得考虑共享数据的锁竞争广播列表的插入删除都要加互斥锁。对于课程设计这个规模select()是最合适的单线程、非阻塞、把所有 socket 描述符放进一个fd_set交给内核去轮询哪些可读然后逐个处理。它的上限受FD_SETSIZE限制默认是 1024在 Linux 下足够应付几十个客户端了。核心监听循环大致是这样的fd_set read_fds; int max_fd listen_fd; while (1) { FD_ZERO(read_fds); FD_SET(listen_fd, read_fds); for (int i 0; i client_count; i) { if (clients[i].fd 0) { FD_SET(clients[i].fd, read_fds); if (clients[i].fd max_fd) max_fd clients[i].fd; } } int ret select(max_fd 1, read_fds, NULL, NULL, NULL); if (ret 0) { perror(select); continue; } if (FD_ISSET(listen_fd, read_fds)) { accept_client(listen_fd); } for (int i 0; i client_count; i) { if (clients[i].fd 0 FD_ISSET(clients[i].fd, read_fds)) { handle_client(i); } } }这里max_fd必须记录当前所有描述符中最大的那个加 1传给select()的第一个参数这是很多人第一次写会漏掉的地方。另外select()每次返回后fd_set会被内核改写所以必须重新FD_ZERO再重新FD_SET不能复用上一轮的集合。3.2 消息广播的实现遍历在线列表逐个转发handle_client()里做的事情我先用伪代码拆解一下然后再贴项目里的关键代码。它接收客户端发来的数据解析出消息类型如果是聊天消息就遍历clients数组往所有已登录且fd ! 当前发送者的 socket 上再send()一次同样的数据。这里的广播语义是除发送者之外的所有人跟群聊的行为一致。void handle_client(int idx) { int fd clients[idx].fd; message_t msg; memset(msg, 0, sizeof(msg)); int n recv(fd, msg, sizeof(msg), 0); if (n 0) { // 客户端主动关闭 close_client(idx); return; } if (n 0) { if (errno EAGAIN || errno EWOULDBLOCK) return; perror(recv); close_client(idx); return; } switch (msg.type) { case 1: // 登录请求上面 2.1 里讲过 do_login(idx, msg); break; case 3: // 聊天消息 if (clients[idx].user_id -1) { // 未登录就发消息直接忽略 return; } // 转发给所有已登录用户排除自己 for (int j 0; j client_count; j) { if (j idx) continue; if (clients[j].user_id ! -1 clients[j].fd 0) { send(clients[j].fd, msg, sizeof(msg), 0); } } break; case 4: // 心跳包 clients[idx].last_active time(NULL); break; default: break; } }广播转发时有一个细节值得注意send()在非阻塞模式下可能只发送部分数据就返回了返回的字节数小于sizeof(msg)。课程设计里因为局域网 RTT 小、每次发送量的字节数也不大一次send()基本能全发出去但严谨的写法应该是循环发送直到把整个缓冲区发完。tcp server.c里如果摸鱼的话就直接一次send()完事这在实际高负载下会造成消息截断。我在自己的版本里加了一个send_all()辅助函数int send_all(int fd, const void *buf, int len) { int sent 0; while (sent len) { int n send(fd, (char *)buf sent, len - sent, 0); if (n 0) return -1; sent n; } return sent; }这个函数的重要之处在于它把发送一次和发送成功解耦了。凡是涉及 TCP 流式 socket 的场景只要消息长度超过一个 MSS通常 1460 字节你就要考虑部分发送的问题。聊天室的消息虽然短但登录响应和系统通知如果拼接了长字符串一样可能越过这个边界。3.3 客户端收发双线程一个线程收一个线程发client.c的文件名看着简单但里面用到了多线程一个线程负责recv()循环接收服务端广播来的消息并打印主线程负责读键盘输入调用send()发送。这是标准做法否则如果你用单线程recv()阻塞着就没法读键盘读着键盘send()就收不到别人的消息。项目里用的是pthread创建接收线程void *recv_thread(void *arg) { int sock *(int *)arg; message_t msg; while (1) { int n recv(sock, msg, sizeof(msg), 0); if (n 0) { printf([连接断开] 服务端关闭了连接\n); break; } if (msg.type 3) { printf([%s] %s\n, msg.username, msg.content); } else if (msg.type 4) { // 服务端系统通知如 xxx 上线、xxx 下线 printf([系统] %s\n, msg.content); } } close(sock); pthread_exit(NULL); }主线程就是普通的fgets()然后组装message_t发送。这里有个常见的资源管理问题主线程读到 EOF 时用户按 CtrlD需要通知接收线程退出否则进程不会正常结束。项目里有的做法是设一个全局volatile int running 1主线程退出前把它置 0然后close(sock)这样接收线程的recv()会立即返回 0从而跳出循环。如果是 Windows 平台还要小心closesocket后的异常Linux 下倒是比较简单。另外一个细节是entry.c它大概是整个项目的入口文件职责是解析命令行参数、初始化日志或者调用main()。我印象里entry.c的作用是区分服务端和客户端的入口比如./chat_server 8888启动服务端./chat_client 127.0.0.1 8888 stu1 123456启动客户端。参数解析逻辑不算复杂但要注意atoi()的容错——如果你直接传argv[1]给atoi()而不检查是否全是数字用户输错参数会导致学号变成 0 甚至负数登录校验时对不上。4. 避坑指南粘包、FD_SETSIZE 与结构体对齐的血泪经验4.1 现象聊天消息偶尔变成两行合并成一行乱码一次我连上聊天室后连续发了三条消息服务端转发出来客户端显示时第一条消息后面跟着半条第二条消息直接串行了。原因客户端发送端三次send()的字节流被接收端两次recv()就全收完了第二次recv()拿到了两条消息的尾部加上第三条消息的全部。因为服务端按sizeof(message_t)固定长度解析如果缓冲区里积累了超过一个message_t的数据解析就会错位。解决接收端必须严格按sizeof(message_t)分帧用一个循环不断recv()每凑满一个完整帧就解析一条消息不能假定一次recv()就是一条消息。下面这个模式我在手中还留着char buf[sizeof(message_t)]; int used 0; while (used sizeof(message_t)) { int n recv(fd, buf used, sizeof(message_t) - used, 0); if (n 0) break; used n; } message_t *msg (message_t *)buf;4.2 现象客户端超过 1024 个之后 select 直接返回 -1有同学用这份代码压测模拟 2000 个客户端连接服务端在 select 处直接报EBADF或者干脆崩溃。原因FD_SET的容量受FD_SETSIZE限制Linux 默认 1024。当clients数组里记录的文件描述符编号超过 1023 时FD_SET写入时就会越界。20 个在线用户没问题但你要压测到几百上千就必须改掉这个机制。解决两个方向。一是重新编译内核参数或者定义FD_SETSIZE宏需要#define __FD_SETSIZE 65536且放在引入头文件之前glibc 认这个宏。二是干脆换用poll()或epoll()poll()没有FD_SETSIZE限制而且 API 和 select 非常接近。我自己的服务器版本就是切到epoll的struct epoll_event ev, events[1024]; int epfd epoll_create1(0); ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); // 然后 epoll_wait 循环处理事件如果你的课程设计是在 Linux 上跑并通过验收建议直接上epoll虽然多写几行代码但写完之后你对多路复用的理解会比select深一个层次。4.3 现象Windows 上编译通过Linux 上报结构体大小不一致代码里如果直接#include winsock2.h和sys/socket.h混用我见过有人这么干在 Windows 上能编译拷到 Linux 上就报错还有一种情况是两端分别在 Windows 和 Linux 上编译相互通信时握手就失败。原因跨平台编译时int宽度不一致Windows 64 位下还是 4 字节Linux 64 位下也是 4 字节但long是 8 字节结构体成员你没显式指定宽度尤其当你把int改成了long用于存学号时两边sizeof(message_t)就对不上了。解决字段全部用固定宽度类型比如int32_t、uint16_t不要用原生int和long。在public.h里这样写#include stdint.h typedef struct { int32_t type; int32_t sender_id; char username[MAX_NAME_LEN]; char password[MAX_PASS_LEN]; char content[MAX_MSG_LEN]; } message_t; _Static_assert(sizeof(message_t) 4 4 MAX_NAME_LEN MAX_PASS_LEN MAX_MSG_LEN, message_t size mismatch across platforms);4.4 现象服务端正常退出客户端却收到 SIGPIPE 信号直接挂掉一个比较隐蔽的坑是服务端主动close()某个客户端 socket 之后如果这个客户端刚好往里send()数据内核会给客户端进程发一个SIGPIPE信号默认动作是终止进程。结果就是你的聊天室客户端毫无征兆地消失了日志里什么都没有。原因TCP 连接已经被关闭send()返回EPIPE错误但默认信号处理器直接把进程杀了你还没走到perror()那一步。解决在客户端初始化时忽略SIGPIPE再用send()的返回值判断错误signal(SIGPIPE, SIG_IGN); int n send(sock, msg, sizeof(msg), 0); if (n 0) { if (errno EPIPE) { printf([连接已被服务端关闭]\n); return -1; } perror(send); }这是一个课程设计里百分之百会遇到、但老师不会主动提醒的信号处理知识点。知道一次之后以后所有 socket 编程你都会习惯性地先写signal(SIGPIPE, SIG_IGN)绝对不吃亏。4.5 现象服务端端口号明明没被占用bind 却报 Address already in use开发调试时经常碰到服务端进程被 CtrlC 杀掉之后立刻重启bind()报EADDRINUSE要等几十秒才能再次启动。原因TCP 连接关闭后进入TIME_WAIT状态占用本地端口约 2MSLLinux 默认 60 秒左右。服务端快速重启时监听 socket 想重新绑定同一个端口就会被拒。解决setsockopt设置SO_REUSEADDR 1一般在bind()之前设置。项目tcp server.c的main()里如果你忘了这行建议加上int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt));注意这个选项要在bind()之前调用顺序反了不生效。5. 聊天室改造进阶心跳保活、历史记录与压测验证当你把基础聊天室跑通之后真正让它从课程设计 demo走向能挂到服务器上的服务还需要补三块心跳保活、消息持久化、以及并发压测来验证瓶颈。这里的每一个都能单独成篇我只讲这次拆包过程中的具体做法。心跳检测是服务端判断客户端是否已死的重要手段。TCP 本身有SO_KEEPALIVE选项但默认触发时间是 2 小时根本不够用。项目里的做法是在服务端记录每个客户端最后一次活跃时间last_active每过一段时间这里推荐 30 秒由服务端主动发送type 4的心跳请求客户端收到后原样回一个心跳响应。如果超过 90 秒没有收到该客户端的任何数据包括心跳响应服务端就把它从在线列表中清除并广播下线通知。核心逻辑我用下面这段表示#define HEARTBEAT_INTERVAL 30 // 秒 #define CLIENT_TIMEOUT 90 // 秒 // 在主循环里每隔 HEARTBEAT_INTERVAL 检查一次 if (time(NULL) - last_heartbeat HEARTBEAT_INTERVAL) { for (int i 0; i client_count; i) { if (clients[i].user_id ! -1) { message_t hb; memset(hb, 0, sizeof(hb)); hb.type 4; hb.sender_id 0; send(clients[i].fd, hb, sizeof(hb), 0); if (time(NULL) - clients[i].last_active CLIENT_TIMEOUT) { printf([超时] user %d 无响应强制下线\n, clients[i].user_id); close_client(i); } } } last_heartbeat time(NULL); }广播历史记录这件事项目里report.txt写了设计思路但没有真正实现数据库存储。我自己的改造是服务端用sqlite3把每条聊天消息插入本地表messages(id, sender, content, ts)新用户登录时直接查最近 50 条发给他这样离线用户重新登录就能看到之前的内容。如果你的服务器上不想引入 sqlite用文件追加写chat.log也是一种方案——注意写 log 时候要加O_APPEND标志并且每次写完后fflush防止缓冲区没落盘就断电丢数据。最后是压测验证。这个项目的makefile里提供了标准的make构建方式但在压测时你要关心的是服务端max_fd的处理能力和send_all是否封得好。我的习惯是自己写一个小工具模拟 500 个客户端加进来每个客户端循环发消息统计平均延迟和丢包率。命令是ulimit -n 65535 ./chat_server 8888 ./stress_client 127.0.0.1 8888 500 1000stress_client会创建 500 个 socket每个连接登录 stu1-stu500需要提前在user.txt里加号然后每 10 毫秒发一条消息持续 1000 条。观察服务端起top看 CPU 占用和内存增长如果 CPU 单核拉满说明 select 遍历成了瓶颈。这套验证做完你才算真正知道自己写的聊天室扛得住多少人在线也才敢把它放到内网里给同事试用。我现在每次拿到这类 socket 课程设计第一件事就是看它的分帧逻辑和send是否做了短写判断这两个点基本决定了一份代码是纸上谈兵还是真正能跑。从头过了一遍tcp.rar里这几个文件之后最实在的感受是TCP 登录聊天室的价值不在于聊天本身而在于它强制你把协议设计、认证状态管理、并发模型和异常处理全走一遍。按上面的步骤自己推倒重写一遍再回来对比tcp server.c和client.c的实现收获会大得多。希望这份拆解能帮到你也祝你改出一个能扛住压测的聊天室服务端。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑