简介:一份基于Linux平台、使用C语言实现的局域网聊天室源码包,面向网络编程初学者以及课程设计、毕业设计的学生,演示如何通过TCP/IP与多线程机制构建一个小型局域网聊天服务。源码采用客户端与服务器分离模式,完整实现消息群发、历史数据查询、好友列表管理、好友上下线提醒等常用功能,其中涉及套接字通信、多客户端并发处理、在线状态广播、聊天记录存储与检索等关键知识点,兼具可读性与可扩展性。压缩包共七个文件,主要包括三个C源文件、一个头文件、一个Makefile构建脚本、一份txt说明文档以及一份docx版程序设计文档,整体大小仅六十四KB,内容精炼,解压后可直接阅读和编译。附带的设计文档能帮助理解各模块的设计思路,便于对照源码深入调试与二次开发。目前已有100人学习下载,适合通过实际项目掌握Linux网络编程、多线程协作以及基础协议应用,并可根据需要进一步添加私聊、文件传输等新功能。
1. 从Socket到聊天室:为什么这个项目值得动手拆一遍
不少人在学完Linux网络编程后,卡在同一个地方:read函数会阻塞、write返回值不检查、多线程下共享变量乱改,这些零散知识点单独看都能理解,一拼成完整的聊天室就四处漏风。这个基于Linux使用C语言实现的局域网聊天室源码,恰好把网络编程的核心链路串起来了——TCP连接管理、多线程并发、消息广播、历史记录落盘、上下线状态通知。它不做花哨的界面,全部走终端交互,反而能让注意力集中在数据传输和处理逻辑上。适合刚啃完APUE卷socket章节的学生,也适合想快速回顾select/poll之前那套经典多线程模型的在职开发。配套的docx文档把设计思路写得很直白,源代码结构简单到可以直接从头读完,改造成自己项目的通信底层也非常方便。下面我按实际拆源码的顺序,把协议设计、服务器端并发模型、客户端交互和历史查询这几块逐一说明。
2. TCP选型、数据帧格式与多线程模型
2.1 为什么局域网即时通讯首选TCP而非UDP
聊天室最基础的需求是“消息不能丢”。UDP虽然省去了握手和保活的麻烦,但局域网环境下的丢包率虽然低,客户端突然掉线时UDP根本无从感知,服务器维护在线列表会变得十分不可靠。TCP提供的流式传输和连接状态检测,能让服务器立刻通过read返回0或errno发现对端关闭,这是实现“好友上下线提醒”的前提。另一个原因是代码复杂度:TCP的accept、recv、send接口更直观,多线程模型下每个连接一个fd,逻辑边界清晰。如果你练习过UDP的sendto循环,就知道要在业务层自己实现确认和重传,那工作量已经接近重写一个迷你TCP了。局域网场景下带宽充足,TCP头部开销几乎不影响体验,所以这个项目选TCP是合理且务实的。
2.2 自定义协议帧:消息头和消息体分离
源码中的common.h定义了通信双方约定的报文格式,这是整个聊天室的技术地基。协议设计为固定长度头部加上变长消息体,避免粘包和半包。
#define MSG_MAX_LEN 1024 #define NAME_MAX_LEN 32 typedef enum { MSG_TYPE_BROADCAST = 1, // 群发消息 MSG_TYPE_HISTORY, // 历史记录请求/响应 MSG_TYPE_ONLINE_LIST, // 在线列表查询 MSG_TYPE_LOGIN, // 登录通知 MSG_TYPE_LOGOUT, // 退出通知 MSG_TYPE_PRIVATE // 预留的私聊消息 } msg_type_t; typedef struct { int type; // 消息类型,见msg_type_t int from_len; // 发送者名字长度 int body_len; // 消息体长度 } msg_header_t; typedef struct { msg_header_t header; char from[NAME_MAX_LEN]; // 发送者名字 char body[MSG_MAX_LEN]; // 消息内容 } chat_message_t;头部的三个int字段用定长方式传输,接收方先读满12字节的msg_header_t,再从from_len和body_len得知后续需要读取多少字节。这种设计的好处是解析时不需要扫描整个数据流找分隔符,效率高也不会误拆中文内容。注意msg_header_t没有使用#pragma pack或__attribute__((packed)),这是因为在网络传输中发送方和接收方可能在不同的Linux发行版上编译,结构体默认对齐可能产生额外填充字节。更稳妥的做法是像项目里这样把各字段单独发送,或者将头部字段统一为网络字节序的uint32_t。我在自己的代码里会额外增加一个magic字段用于校验,防止把错误的数据流当成消息处理。
2.3 多线程与多进程的选择:客户端连接处理
项目采用一连接一线程(thread-per-connection)模型,服务器主线程执行accept循环,每接受一个新的客户端连接就创建一个pthread_t线程专门处理该连接。相比fork子进程,线程共享进程地址空间,操作同一份在线链表和消息计数不需要借助IPC,直接加锁读写即可。代价是一个线程栈默认约8MB,如果客户端数量达到数百,虚拟内存压力会比较大。对于局域网聊天室这种几十人量级的场景,这是最清晰的并发方案。
void *client_handler(void *arg) { int client_fd = *(int *)arg; char peer_ip[INET_ADDRSTRLEN]; // ... 获取客户端IP,加入在线列表 ... chat_message_t msg; while (1) { memset(&msg, 0, sizeof(msg)); // 分两次读取:先读头,再读体 if (recv(client_fd, &msg.header, sizeof(msg.header), 0) <= 0) break; if (recv(client_fd, msg.from, msg.header.from_len, 0) <= 0) break; if (recv(client_fd, msg.body, msg.header.body_len, 0) <= 0) break; handle_message(client_fd, &msg); // 根据type分发处理 } remove_client(client_fd); pthread_detach(pthread_self()); close(client_fd); return NULL; }recv三次变长读取减少了粘包概率,但没完全解决:如果网络出现半包,第二次recv可能只收到一部分。严谨的写法是循环读取直到收满所需字节,我在改进时会封装一个readn函数。另外需要注意线程参数arg指向的client_fd是栈上变量,所以建议用堆上分配的方式传参,否则主线程继续循环时可能修改同一块内存。源码里用malloc分配新fd副本再传入,这点值得学习。
3. 服务器端:消息群发、在线列表与上下线通知
3.1 服务器核心数据结构与锁
维护在线客户端需要一张全局链表,每个节点保存fd、昵称、IP等。由于多线程同时读写这张表,必须用互斥锁保护。源码中在clientlist.c里实现了这个结构,插入和删除都封装成独立函数。
typedef struct client_node { int fd; char name[NAME_MAX_LEN]; struct sockaddr_in addr; struct client_node *next; } client_node_t; static client_node_t *head = NULL; static pthread_mutex_t clients_mutex = PTHREAD_MUTEX_INITIALIZER; void add_client(client_node_t *node) { pthread_mutex_lock(&clients_mutex); node->next = head; head = node; pthread_mutex_unlock(&clients_mutex); } void remove_client(int fd) { pthread_mutex_lock(&clients_mutex); client_node_t *cur = head, *prev = NULL; while (cur) { if (cur->fd == fd) { if (prev) prev->next = cur->next; else head = cur->next; free(cur); break; } prev = cur; cur = cur->next; } pthread_mutex_unlock(&clients_mutex); }加锁粒度要控制好。很多新手会把整个广播循环也放在锁内,导致一次慢客户端拖住所有发送。正确做法是在遍历时先锁住链表,然后逐个取出fd并调用send,send是阻塞操作可能耗时长,所以应该在取到fd后尽快解锁。比较稳妥的方式是用引用计数或浅拷贝的方式拿一份fd数组出来,然后释放锁再执行send。
3.2 消息广播与上下线提醒的实现
广播函数遍历所有在线节点,将收到的消息原样转发给除自己外的其他客户端。上下线提醒本质上是广播一条特殊类型的系统消息,只是消息体写的是“xx上线了”。
void broadcast_message(chat_message_t *msg, int sender_fd) { pthread_mutex_lock(&clients_mutex); client_node_t *cur = head; while (cur) { if (cur->fd != sender_fd) { ssize_t sent = send(cur->fd, msg, sizeof(*msg), 0); if (sent == -1) { // 发送失败,通常会走到remove_client流程 perror("send error"); } } cur = cur->next; } pthread_mutex_unlock(&clients_mutex); } void notify_status(const char *name, int online) { chat_message_t sys_msg; memset(&sys_msg, 0, sizeof(sys_msg)); sys_msg.header.type = online ? MSG_TYPE_LOGIN : MSG_TYPE_LOGOUT; sys_msg.header.from_len = strlen(name); snprintf(sys_msg.body, sizeof(sys_msg.body), "%s", online ? "上线了" : "下线了"); sys_msg.header.body_len = strlen(sys_msg.body); strncpy(sys_msg.from, name, NAME_MAX_LEN); broadcast_message(&sys_msg, -1); }注意broadcast_message用sender_fd == -1来表示系统广播,这样所有客户端都会收到。上下线提醒应该在客户端加入链表之前发送,还是之后发送?如果先发通知再插入链表,其他客户端无法看到这位新用户,因为发送通知时他还没在列表里。正确顺序是:先插入链表,再广播上线的同时广播一份在线列表给所有客户端。这样收到上线通知的客户端可以主动向新用户问好,而新用户也能立刻拿到完整的成员名单。源码main.c里就是按这个顺序处理的。
3.3 历史数据查询的服务端存储策略
源码将聊天记录保存在一个普通文本文件chat.log中。每个广播或私聊消息在转发的同时,同步追加写入文件。查询历史时客户端发送MSG_TYPE_HISTORY类型消息,服务器打开文件读出全部内容,通过send返回给客户端。
写入时要注意多线程写文件的原子性。两个线程同时调用write到同一文件描述符,如果消息长度不超过PIPE_BUF(Linux下4096字节),内核会保证write系统调用是原子的。这里每条消息通常不到几百字节,所以直接用write追加问题不大。更稳妥的做法是给文件写操作单独加一把锁,或让所有写入集中在专门的日志线程。我建议在关键函数里加锁,因为如果把write放在broadcast的循环里,广播期间的任何失败都会影响写日志。
void append_history(chat_message_t *msg) { FILE *fp = fopen("chat.log", "a"); if (!fp) { perror("fopen chat.log"); return; } fprintf(fp, "[%ld] %s: %s\n", time(NULL), msg->from, msg->body); fclose(fp); }每次打开和关闭文件会有开销,但对聊天室这种低频写入完全可接受。开发环境里用fopen/fclose能保证数据立即落盘,避免缓冲区滞留。查询时使用fgets逐行读取并拼接,通过send一次性或分段发送给请求者。还记得MSG_MAX_LEN为1024吗?如果历史文件超过这个长度,服务器端要拆包发送,客户端则需要循环接收。源码里直接用了定长数组返回,这个在长聊天记录下会截断,我实测后把发送逻辑改成了按行拆包。
4. 客户端实现:交互线程、好友列表与本地记录
4.1 客户端主流程与双线程结构
客户端程序main.c的主函数逻辑清晰:创建socket、connect到服务器、启动接收线程处理来自服务器的数据,同时主线程循环读取用户输入并发送。接收线程的存在是为了及时处理广播消息、上下线提醒、在线列表更新等异步事件,避免用户正输入时错过消息。
void *recv_thread(void *arg) { int sock_fd = *(int *)arg; chat_message_t msg; while (1) { int n = recv(sock_fd, &msg, sizeof(msg), 0); if (n <= 0) { printf("服务器连接已断开\n"); exit(EXIT_FAILURE); } switch (msg.header.type) { case MSG_TYPE_BROADCAST: printf("\n[%s] %s\n", msg.from, msg.body); break; case MSG_TYPE_ONLINE_LIST: printf("当前在线用户:\n%s\n", msg.body); break; case MSG_TYPE_LOGIN: printf(">>> %s 上线了\n", msg.from); break; case MSG_TYPE_LOGOUT: printf(">>> %s 下线了\n", msg.from); break; case MSG_TYPE_HISTORY: printf("-----历史记录-----\n%s\n", msg.body); break; default: break; } printf("> "); fflush(stdout); } return NULL; }注意接收线程里printf之后要重新打印提示符并刷新stdout,否则用户正在输入的内容会和消息混在一起。这里有个小技巧:在printf消息前输出换行,消息结束后再打印“> ”提示符并fflush,这样即使主线程正在等待输入,用户也能看到新消息插入且自己的输入内容不会被冲掉。真正的控制台聊天室还会加ncurses库做独立输入区,但这个项目的终端交互方式对学习socket更友好。
4.2 好友列表查看与上下线状态维护
服务器返回在线列表的方式有两种:一种是指令触发时服务器遍历链表拼装字符串,另一种是每次客户端登录或退出时服务器主动推送最新列表。源码采用后者,好处是客户端无需主动请求就能维持较新的好友状态视图。客户端侧只需要在收到MSG_TYPE_ONLINE_LIST时用strtok按换行符拆分,然后更新本地的一个char online_names[][NAME_MAX_LEN]数组即可。
void update_online_list(const char *data) { memset(online_names, 0, sizeof(online_names)); online_count = 0; char tmp[MSG_MAX_LEN]; strncpy(tmp, data, sizeof(tmp) - 1); char *token = strtok(tmp, "\n"); while (token && online_count < MAX_CLIENTS) { strncpy(online_names[online_count++], token, NAME_MAX_LEN - 1); token = strtok(NULL, "\n"); } }注意strtok会修改原字符串,所以必须先复制一份data。判断一个好友是否在线,只需遍历online_names;下线则意味着列表中没有对应名字。这个设计不需要客户端维护好友关系数据库,一切以服务器广播的列表为准,属于无状态模式。坏处是如果客户端错过了某次列表推送,会导致状态不准确,所以还需要一个主动查询指令,也就是发送MSG_TYPE_ONLINE_LIST请求。
4.3 历史记录查询与本地文件缓存
客户端发送查询请求时,直接把MSG_TYPE_HISTORY类型的空消息发给服务器,服务器就返回整个聊天日志。这里要处理网络传输长度不稳定的情况,所以客户端的接收逻辑不能只依赖一次recv。我用如下循环处理可能的多段响应:
void request_history(int sock_fd) { chat_message_t req; memset(&req, 0, sizeof(req)); req.header.type = MSG_TYPE_HISTORY; send(sock_fd, &req, sizeof(req.header), 0); char buf[MSG_MAX_LEN]; int total = 0; int n; while ((n = recv(sock_fd, buf + total, sizeof(buf) - total - 1, 0)) > 0) { total += n; if (total >= sizeof(buf) - 1 || n < MSG_MAX_LEN) break; } buf[total] = '\0'; printf("%s\n", buf); return; }这个循环要想稳定工作,前提是服务器一次性发送完整数据后关闭连接或发送特定的结束标记。当前源码里服务器用一次send发送历史文件内容,然后保持连接不变,这样客户端会阻塞在下一个recv等待新消息。所以更好的做法是服务器将历史响应用一个独有的body长度标记,客户端通过body_len判断是否收完整。我在自己改进版里是让服务器把文件内容分多次发送,并在末尾发送一个“END”字符串,客户端循环接收直到遇到END,这样更通用。
客户端也可以把收到的历史记录追加写入本地history_YYYYMMDD.log,方便离线查看。写入时的打开方式用O_APPEND保证多写不覆盖。这个功能虽然不是必须,但面试聊到“如何做持久化”时可以多一个加分项。
5. 进阶优化与排错:让聊天室从能跑到好跑
5.1 用netstat和telnet直接验证服务器状态
源码编译后先用最基本的方式验证网络栈是否正常。服务器运行./server后,用netstat -tlnp查看监听端口,应能看到LISTEN状态的IPv4 socket。然后使用telnet作为简易模拟客户端,手动敲入二进制消息较麻烦,但可以用printf管道配合nc工具。比如:
nc -v 127.0.0.1 8888如果项目源码监听的端口不是8888,需要去main.c里确认#define PORT的设置。nc连接上后输入任意文本,如果服务器有回显或广播其他端口,说明链路通。实际排查中我发现最常见的错误是socket bind失败,原因是端口被占用或没有设置SO_REUSEADDR。在bind前加一行:
int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));这样可以快速重启服务器而不需要等TIME_WAIT超时。
5.2 粘包与半包问题定位方法
局域网高并发下,多条小消息可能连续到达触发TCP的Nagle算法合并成一个大包,接收方一次recv拿到多条消息,这就是粘包。本项目使用固定头部加变长体的做法,能区分每一条消息的边界。但前提是recv必须完整地读到头和体。我在调试中曾遇到客户端发两次send,第一次发header,第二次发body,body较小时两次可能落入同一个TCP段,接收方先读12字节头部没问题,然后要根据header里的body_len继续读。但我在第三个recv阶段只调用一次,如果body没到齐就返回0,会直接break导致消息丢失。所以需要把读取操作封装成readn:
ssize_t readn(int fd, void *buf, size_t count) { size_t left = count; char *ptr = (char *)buf; while (left > 0) { ssize_t ret = recv(fd, ptr, left, 0); if (ret <= 0) return ret; ptr += ret; left -= ret; } return count; }然后用readn替换所有直接recv。这样处理半包问题后,粘包问题其实也迎刃而解,因为每次都严格按照头部声明的长度读取,剩下的数据会留到下次recv再解析。
5.3 线程安全的日志写入与退出清理
历史记录写入chat.log时,如果广播线程A和线程B同时调用append_history,可能发生交错写入,行内容穿插。在append_history内部加一把全局日志锁:
static pthread_mutex_t log_mutex = PTHREAD_MUTEX_INITIALIZER; void append_history_safe(chat_message_t *msg) { pthread_mutex_lock(&log_mutex); FILE *fp = fopen("chat.log", "a"); if (fp) { fprintf(fp, "[%ld] %s: %s\n", time(NULL), msg->from, msg->body); fclose(fp); } pthread_mutex_unlock(&log_mutex); }服务器main函数收到SIGINT退出时,一定要先遍历链表并逐个close所有客户端fd,再释放链表内存,最后关闭监听fd。否则客户端socket不会被内核立即回收,重启服务器时可能报Address already in use。可以用signal(SIGINT, handler)注册清理函数,handler里将全局运行标志置为0,主循环退出后执行清理。
5.4 用strace快速定位临时故障
如果客户端发送消息后服务器端没有转发,一个高效排查手段是strace跟踪进程的系统调用。假设服务器pid是1234:
strace -p 1234 -e trace=network,write,read -o /tmp/server_trace.log然后让另一台客户端发一条消息,查看trace日志里recv返回的字节数、send调用的目标fd和返回值。如果send返回-1且errno为EPIPE,说明对端已关闭连接;如果recv返回0,说明客户端主动断开了。这样能快速判断问题在网络层还是业务逻辑。这个技巧比打印log更快,尤其适合排查只在特定网络场景下才出现的偶发问题。
5.5 扩展方向:select/poll多路复用与private消息
当前项目是线程阻塞模型,客户端数量增加后在大量线程切换上会有开销。作为进阶练习,可以用select或poll重写服务器事件循环,将listenfd和所有客户端fd放入fd_set,统一处理可读事件。这样单线程就能支撑上百连接。另外协议里预留了MSG_TYPE_PRIVATE,实现私聊很简单:消息体里包含“目标名字:内容”,服务器解析后转发给对应fd即可。这些改动都在可控范围内,建议把源码复制一份出来先备份,再逐项重构,每次都能跑通再继续下一步。
最后有个实用小技巧:把makefile里的CFLAGS改为-Wall -Wextra -g,编译时把警告全部暴露出来。我看到源码Makefile里只有一行gcc -o,这在实际工作中不够用。加上-fno-stack-protector调试栈问题时关闭保护,但上线前务必恢复。多读几遍clientlist.c里链表插入删除的逻辑,把它画成图,理解指针操作后再去改,比我在这里写一千字都有效。
本文还有配套的精品资源,点击获取