简介:这是一份基于TCP协议实现的多人聊天室系统源码包,面向网络编程初学者与C语言实践者,聚焦传输层可靠通信、用户登录验证与实时消息广播等核心场景。资源包含8个文件,以3个C源文件(server.c、client.c、entry.c)构成服务端与客户端主逻辑,1个头文件public.h定义公共结构,3个文本文件(user.txt、report.txt、readme.txt)提供用户数据、实验报告与使用说明,另含1个Makefile支持一键编译,整体压缩包仅7KB,轻量易部署。已有95人学习下载,适合在Linux环境下动手编译运行,深入理解TCP三次握手、字节流边界处理、多路连接管理及简单会话状态维护。读者可直接复现完整登录-聊天-广播流程,获得可调试的最小可行聊天系统,同时掌握服务端用户列表维护、客户端输入/监听双线程协同等典型网络编程模式。
1. 为什么一个用server.c和client.c实现的 TCP 多人聊天室,至今仍是嵌入式、网络编程入门最硬核的练手项目?
你写过socket()、bind()、listen()、accept(),也调过connect()、send()、recv(),但真正把「TCP 登陆」和「多人聊天室」两个需求串起来——不是单聊、不是回显、不是 echo server,而是多个客户端能独立登陆、身份可识别、消息能广播、断连能感知、不卡死不崩服——你会发现:90% 的初学者代码在第三个人上线时就开始丢消息、第四次重连后accept()就阻塞住、第五次发中文就乱码或截断。这不是玄学,是 TCP 协议栈在真实交互中暴露的边界:缓冲区溢出、TIME_WAIT堆积、select()超时未设、recv()返回值没判零、close()顺序错导致FIN漏发……而tcp.rar这个经典压缩包(内含server.c+client.c)之所以被反复下载、编译、调试、魔改,正因为它用不到 800 行 C 代码,把 TCP 连接管理、用户状态维护、消息路由分发这三座大山,全压在epoll/select+ 线程池/循环监听的裸金属实现上。它不依赖 Qt 或 libevent,不包装成 SDK,不做 WebSocket 封装,就靠man 2 socket里最原始的系统调用,逼你直面SO_REUSEADDR为什么必须开、MSG_NOSIGNAL怎么防 SIGPIPE、shutdown(SHUT_WR)和close()的语义差在哪。适合刚学完《UNIX 网络编程》卷一第 6 章、正在啃poll()和epoll_wait()区别、想验证自己是否真懂「TCP 连接」而非「TCP 函数调用」的工程师。
2. 从tcp.rar解压到本地可运行:编译、启动、验证三步闭环
2.1 解压与目录结构确认:看清server.c和client.c的真实职责边界
tcp.rar是典型教学型压缩包,解压后通常只有 4 个文件:
| 文件名 | 类型 | 作用说明 |
|---|---|---|
server.c | C 源码 | 主服务端逻辑:监听端口、接受连接、维护 client_list 链表、广播消息、处理断连 |
client.c | C 源码 | 客户端逻辑:连接 server、输入用户名、发送消息、接收广播、检测 server 断开 |
Makefile | 构建脚本 | 提供make/make clean,通常已预设-Wall -g -O2编译选项 |
README.md | 文档 | 极简说明:启动命令、默认端口(常为 8888)、用户名长度限制(如 ≤16 字符) |
注意:该包不包含 GUI 界面、不依赖 OpenSSL、不实现 TLS 加密。所有通信明文传输,仅用于局域网学习。若你在公网部署,请务必自行加
SSL_set_accept_state()或迁移到libtls—— 但那已是进阶改造,不在本项目原始目标内。
验证解压完整性:
unrar x tcp.rar ls -l # 应输出:server.c client.c Makefile README.md2.2 编译服务端与客户端:为什么gcc -o server server.c -lpthread是最小可行命令?
server.c必须链接-lpthread,因为其内部使用pthread_create()创建线程处理每个 client(常见做法),或用pthread_mutex_t保护全局client_list链表。漏掉-lpthread会导致undefined reference to 'pthread_create'。
标准编译命令(Linux x86_64):
gcc -Wall -g -O2 server.c -o server -lpthread gcc -Wall -g -O2 client.c -o client-Wall:开启全部警告,帮你捕获recv()返回值未检查、strcpy()缓冲区越界等致命隐患-g:保留调试符号,gdb ./server可直接bt查看崩溃栈-O2:优化但不激进,避免-O3导致内联函数掩盖真实调用路径
编译后验证:
file server client # 应显示 "ELF 64-bit LSB pie executable" ./server --help # 若有 help 参数则输出用法;否则直接运行2.3 启动服务端并监听:netstat -tlnp | grep :8888是你的第一道健康检查
默认端口通常是8888(见server.c中#define PORT 8888),启动前先清空占用:
sudo lsof -i :8888 # 查看谁占着 sudo kill -9 $(lsof -t -i :8888) # 强杀(仅开发环境)启动服务端(后台静默运行,便于后续开多个 client):
./server > server.log 2>&1 & echo $! > server.pid # 记录 PID,方便后续 kill立即验证监听状态:
netstat -tlnp | grep :8888 # 正常输出示例: # tcp6 0 0 *:8888 *:* LISTEN 12345/./server关键点:
LISTEN状态必须出现,且PID/Program name显示为./server。若显示*:*而非*:8888,说明bind()时sin_addr.s_addr = INADDR_ANY正确;若显示127.0.0.1:8888,则bind()绑定了localhost,外部 client 无法连接——这是新手最常翻车的第一步。
2.4 启动多个客户端并完成登陆:./client 127.0.0.1 8888的三次交互流程
在另一个终端启动 client:
./client 127.0.0.1 8888此时 client 会进入三阶段交互:
- 连接建立:
connect()成功后,打印Connected to server - 登陆握手:提示
Enter username:,输入Alice(≤16 字符,无空格)→ server 收到后返回Welcome, Alice!并广播Alice has joined the chat. - 消息收发:输入
Hello everyone!→ server 广播给所有在线 client,包括自己
开第三个终端再启一个 client:
./client 127.0.0.1 8888 # Enter username: Bob # Welcome, Bob! # Alice has joined the chat. # Bob has joined the chat.此时 Alice 终端应看到Bob has joined the chat.,Bob 终端看到Alice has joined the chat.和Bob has joined the chat.——广播逻辑生效标志。
验证技巧:用
tcpdump -i lo port 8888 -w chat.pcap抓包,Wireshark 打开后过滤tcp.stream eq 0,可清晰看到SYN→SYN-ACK→ACK(三次握手),随后Alice\0(登陆包)、Hello everyone!\0(消息包)的原始 payload —— 这是你确认「TCP 登陆」协议层真实行为的后悔药。
3.server.c核心逻辑拆解:连接管理、用户登陆、消息广播的三重锁
3.1 连接管理:accept()循环里的setsockopt(SO_REUSEADDR)是什么?
server.c开头必有:
int opt = 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));SO_REUSEADDR允许bind()重用处于TIME_WAIT状态的端口。若不设置,server 重启时可能报Address already in useTIME_WAIT是 TCP 四次挥手后主动关闭方必须等待的状态(2MSL),防止旧连接的延迟报文干扰新连接- 血泪经验:在频繁启停调试时,
SO_REUSEADDR不是可选项,是生存必需。漏掉它,你会花 2 分钟查端口占用,实际是TIME_WAIT卡住 —— 这不是 bug,是 TCP 协议设计使然
accept()循环典型结构:
while (1) { cli_len = sizeof(cli_addr); new_sock = accept(sockfd, (struct sockaddr*)&cli_addr, &cli_len); if (new_sock < 0) { perror("accept"); // 必须检查!errno=EMFILE 表示 fd 耗尽 continue; } // 启动线程处理 new_sock,或加入 epoll fd 集 }accept()返回new_sock是新创建的连接套接字,与listen()的sockfd完全独立new_sock的生命周期由业务逻辑控制:recv()返回 0 表示 client 正常关闭(FIN到达),应close(new_sock)并从 client_list 移除new_sock若recv()返回 -1 且errno == ECONNRESET,表示 client 异常断开(如 kill -9 client 进程),需立即清理
3.2 用户登陆协议:为什么用\0结尾而非换行符?readn()和writen()的必要性
client.c登陆时发送:
write(sockfd, username, strlen(username) + 1); // +1 包含 '\0'server.c接收时:
n = read(new_sock, buf, sizeof(buf)-1); if (n > 0) { buf[n] = '\0'; // 手动补 '\0' // 然后 strcpy(user->name, buf) }- 使用
\0结尾而非\n,是因为username是 C 字符串,strlen()依赖\0,且避免\n被误认为消息内容 read()是非原子操作:TCP 是字节流,write()发 10 字节,read()可能只收 3 字节,下次再收 7 字节 —— 所以必须循环读直到收满或遇\0- 教学代码常封装
readn(int fd, void *buf, size_t n):ssize_t readn(int fd, void *buf, size_t n) { size_t nleft = n; ssize_t nread; char *ptr = buf; while (nleft > 0) { if ((nread = read(fd, ptr, nleft)) < 0) { if (errno == EINTR) continue; return -1; } else if (nread == 0) break; // EOF nleft -= nread; ptr += nread; } return (n - nleft); }writen()同理,确保整块数据发完。这是TCP 登陆可靠性的底层基石。
3.3 消息广播机制:链表遍历 +send()的原子性陷阱与MSG_NOSIGNAL
广播核心逻辑(伪代码):
for (each client in client_list) { if (client->sock != sender_sock) { // 不发给自己 send(client->sock, msg, len, MSG_NOSIGNAL); } }MSG_NOSIGNAL关键:防止 client 断连时send()触发SIGPIPE信号,导致 server 进程被 killsend()返回值必须检查:- 返回
len:全发完 - 返回
0:对端已关闭(罕见,因 TCP 有 FIN 机制) - 返回
-1且errno == EAGAIN/EWOULDBLOCK:非阻塞 socket 缓冲区满,需重试或丢弃 - 返回
-1且errno == EPIPE:对端已关闭且未处理 FIN,MSG_NOSIGNAL已屏蔽此信号,但需close()该 client
- 返回
广播性能瓶颈:当 client 数量 > 100,链表遍历 +
send()同步阻塞会拖慢整个 server。生产环境必须升级为epoll+ 边缘触发(ET)+sendfile()零拷贝,但教学版用链表足够说明原理。
4.client.c登陆与保活:输入阻塞、心跳缺失、中文乱码的三大雷区
4.1 输入阻塞问题:fgets()为何卡住?select()监听 stdin 的正确姿势
client.c常见错误写法:
while (1) { printf("You: "); fgets(buf, sizeof(buf), stdin); // 卡在这里! send(sockfd, buf, strlen(buf), 0); }fgets()是阻塞 I/O,等待用户敲回车。此时 client 无法接收 server 广播消息,变成单向发送器正确做法:用
select()同时监听stdin(fd=0)和sockfd:fd_set readfds; while (1) { FD_ZERO(&readfds); FD_SET(0, &readfds); // stdin FD_SET(sockfd, &readfds); // network int maxfd = (0 > sockfd) ? 0 : sockfd; int ret = select(maxfd + 1, &readfds, NULL, NULL, NULL); if (ret < 0) { perror("select"); break; } if (FD_ISSET(0, &readfds)) { // stdin 可读 if (fgets(buf, sizeof(buf), stdin)) { send(sockfd, buf, strlen(buf), 0); } } if (FD_ISSET(sockfd, &readfds)) { // socket 可读 n = recv(sockfd, buf, sizeof(buf)-1, 0); if (n > 0) { buf[n] = '\0'; printf("%s", buf); } else if (n == 0) { printf("Server disconnected.\n"); break; } } }select()第四个参数timeout设为NULL表示永久阻塞,直到任一 fd 就绪maxfd + 1是select()的第一个参数,必须是最大 fd + 1
4.2 心跳保活缺失:为什么 client 挂了 server 却不知道?
原始tcp.rar版本不实现心跳。现象:client 突然断网(拔网线),server 的recv()仍阻塞,client_list里该用户永远在线。
解决方案(服务端加心跳):
// 在 accept 后,为每个 client 启动心跳线程 void* heartbeat_thread(void* arg) { struct client* c = (struct client*)arg; while (1) { sleep(30); // 30秒发一次心跳 if (send(c->sock, "PING", 4, MSG_NOSIGNAL) <= 0) { // send 失败,标记 client 为 dead c->status = DEAD; break; } } return NULL; }- 更健壮的做法是
setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, &opt, sizeof(opt)),启用内核级 keepalive(默认 2 小时探测,太长),再配合应用层PING/PONG - 避坑重点:心跳包不能只发不收!server 发
PING,client 必须回PONG,否则 server 无法确认 client 是否存活
4.3 中文乱码与截断:setlocale(LC_ALL, "")和iconv的取舍
client.c输入中文时,若终端是 UTF-8,但server.c用printf("%s", buf)输出,可能显示 ``。
原因:
send()/recv()传输的是 raw bytes,不关心编码printf()默认按 locale 解码,若LANG=C,则按 ASCII 解析,UTF-8 多字节序列被拆成非法字符
解决路径:
- 简单方案:确保所有终端
export LANG=en_US.UTF-8,且server.c/client.c开头加setlocale(LC_ALL, ""); - 严格方案:在
client.c输入后,用iconv()转为 UTF-8(若输入是 GBK):
但教学版通常省略,因iconv_t cd = iconv_open("UTF-8", "GBK"); iconv(cd, &inbuf, &inbytes, &outbuf, &outbytes); iconv_close(cd);tcp.rar定位是协议学习,非国际化产品。
5. 避坑:tcp.rar多人聊天室的 4 个高频翻车现场与根因修复
5.1 现象:启动 server 后netstat看不到LISTEN,./client报Connection refused
- 原因:
bind()时sin_addr.s_addr未设为INADDR_ANY(即0.0.0.0),而是inet_addr("127.0.0.1"),导致只监听 localhost,外部 client 无法访问 - 修复:
server.c中bind()前确保:serv_addr.sin_addr.s_addr = INADDR_ANY; // 关键!不是 inet_addr("127.0.0.1") - 验证:
netstat -tlnp | grep :8888输出应为*:8888,而非127.0.0.1:8888
5.2 现象:第二个 client 登陆后,server 崩溃(Segmentation fault)
- 原因:
client_list链表操作未加互斥锁(pthread_mutex_t),多线程并发修改导致指针野指针 - 修复:在
server.c全局声明pthread_mutex_t client_mutex = PTHREAD_MUTEX_INITIALIZER;,所有client_list插入/删除/遍历前加pthread_mutex_lock(&client_mutex),后pthread_mutex_unlock(&client_mutex) - 血泪经验:即使只用
select()单线程模型,client_list仍可能被信号处理函数(如SIGCHLD)修改,锁仍是刚需
5.3 现象:client 发送长消息(>512 字节)后,server 收到乱码或截断
- 原因:
recv()未循环读取,假设一次recv()必收完整消息;或send()未检查返回值,部分数据未发出 - 修复:
- client 端:
send()后检查返回值,若< len则循环send()剩余部分 - server 端:用
readn()封装,确保收满\0结尾的完整字符串
- client 端:
- 边界验证:用
dd if=/dev/urandom bs=1024 count=1 | base64 | head -c 1000 | ./client 127.0.0.1 8888发 1000 字节测试
5.4 现象:client 正常退出(Ctrl+C),server 日志显示Client disconnected,但client_list未清理,后续广播仍发给该 socket
- 原因:
recv()返回 0 时,server 未执行close(client_sock)和remove_from_list() - 修复:
recv()返回值判断必须覆盖三种情况:n = recv(client_sock, buf, sizeof(buf)-1, 0); if (n > 0) { /* 正常接收 */ } else if (n == 0) { /* 对端关闭 */ printf("Client %d disconnected\n", client_sock); close(client_sock); remove_client_from_list(client_sock); } else if (n < 0) { /* 错误 */ if (errno == ECONNRESET || errno == EPIPE) { close(client_sock); remove_client_from_list(client_sock); } } - 验证技巧:
lsof -p $(cat server.pid) | wc -l,正常运行时应稳定在client 数 + 1(server 自身 listen fd),断连后减 1
6. 进阶改造:从教学 demo 到可用原型的 3 个落地技巧
6.1 用epoll替代select:突破 1024 文件描述符限制的实操步骤
select()最大支持FD_SETSIZE(通常 1024)个 fd,epoll无此限制。改造server.c的核心步骤:
初始化 epoll:
int epfd = epoll_create1(0); // 创建 epoll 实例 struct epoll_event ev, events[64]; ev.events = EPOLLIN; // 监听读事件 ev.data.fd = sockfd; // listen socket epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);主循环替换
select():while (1) { int nfds = epoll_wait(epfd, events, 64, -1); // -1 表示永久阻塞 for (int i = 0; i < nfds; i++) { if (events[i].data.fd == sockfd) { // 新连接 new_sock = accept(sockfd, ...); ev.data.fd = new_sock; ev.events = EPOLLIN | EPOLLET; // ET 模式 epoll_ctl(epfd, EPOLL_CTL_ADD, new_sock, &ev); } else { // 已有连接可读 n = recv(events[i].data.fd, buf, sizeof(buf)-1, 0); if (n <= 0) { // 处理断连:close + epoll_ctl(DEL) } else { // 广播消息 } } } }
EPOLLET(边缘触发)要求recv()必须循环读到EAGAIN,否则遗漏数据epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL)必须在close(fd)前调用,否则 fd 泄漏
6.2 登陆认证增强:从明文用户名到 SHA-256 摘要校验的轻量级方案
原始tcp.rar无密码,仅用户名唯一性校验。加摘要认证只需两步:
client 端:输入用户名后,计算
SHA256(username + salt),发送摘要而非明文// 使用 openssl/sha.h unsigned char hash[SHA256_DIGEST_LENGTH]; SHA256_CTX ctx; SHA256_Init(&ctx); SHA256_Update(&ctx, username, strlen(username)); SHA256_Update(&ctx, "my_salt_123", 12); // salt 硬编码 SHA256_Final(hash, &ctx); // 将 hash 转 hex string 发送server 端:收到摘要后,用相同 salt 计算比对,通过才允许登陆
- 优势:避免用户名明文传输,且 salt 防止彩虹表攻击
- 注意:salt 必须固定,且不随每次连接变化(否则 client 无法预计算)
6.3 日志与监控:用syslog()替代printf()的生产级日志规范
printf()输出到 stdout,无法持久化。生产环境必须用syslog():
#include <syslog.h> // 初始化 openlog("tcp-chat", LOG_PID | LOG_CONS, LOG_USER); // 登陆成功 syslog(LOG_INFO, "User %s logged in from %s:%d", username, inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port)); // 消息广播 syslog(LOG_DEBUG, "Broadcasting %d bytes to %d clients", len, client_count); // 关闭 closelog();- 日志自动写入
/var/log/messages或/var/log/user.log - 可通过
rsyslog配置转发到远程日志服务器 LOG_INFO用于关键事件(登陆/登出),LOG_DEBUG用于调试(消息大小、client 数)
我带新人时,总会让他们先删掉server.c里所有printf(),强制改用syslog()—— 这不是为了炫技,而是让他们习惯:任何脱离 stdout/stderr 的输出,都是不可靠的。日志是线上问题的唯一时间锚点,没有它,TCP 登陆失败时你连“谁在什么时候连过”都查不到。希望帮到你。
本文还有配套的精品资源,点击获取