简介:这是一个基于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 时(用户按 Ctrl+D),需要通知接收线程退出,否则进程不会正常结束。项目里有的做法是设一个全局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
开发调试时经常碰到,服务端进程被 Ctrl+C 杀掉之后立刻重启,bind()报EADDRINUSE,要等几十秒才能再次启动。
原因:TCP 连接关闭后进入TIME_WAIT状态,占用本地端口约 2MSL(Linux 默认 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的实现,收获会大得多。希望这份拆解能帮到你,也祝你改出一个能扛住压测的聊天室服务端。
本文还有配套的精品资源,点击获取