
簡介一份基于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并調用sendsend是阻塞操作可能耗時長所以應該在取到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_BUFLinux下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這樣更通用??蛻舳艘部梢园咽盏降臍v史記錄追加寫入本地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第二次發bodybody較小時兩次可能落入同一個TCP段接收方先讀12字節頭部沒問題然后要根據header里的body_len繼續讀。但我在第三個recv階段只調用一次如果body沒到齊就返回0會直接break導致消息丟失。所以需要把讀取操作封裝成readnssize_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??梢杂胹ignal(SIGINT, handler)注冊清理函數handler里將全局運行標志置為0主循環退出后執行清理。5.4 用strace快速定位臨時故障如果客戶端發送消息后服務器端沒有轉發一個高效排查手段是strace跟蹤進程的系統調用。假設服務器pid是1234strace -p 1234 -e tracenetwork,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里鏈表插入刪除的邏輯把它畫成圖理解指針操作后再去改比我在這里寫一千字都有效。本文還有配套的精品資源點擊獲取