这是一个基于 Linux 平台开发的 TCP 网络聊天室,采用经典的 C/S(客户端/服务端)架构,支持多用户同时在线群聊。开发环境为 Ubuntu 22.04 LTS + VMware Workstation 17,使用 C 语言和 POSIX 网络编程接口实现。
| 参数 | 值 |
|---|---|
| 通信模式 | C/S(客户端/服务端) |
| 网络协议 | TCP IPv4 流式套接字(SOCK_STREAM) |
| 端口号 | 3333 |
| 最大客户端 | 5 人 |
| 服务端并发 | 主线程监听 + pthread 多线程 |
| 客户端并发 | fork 父子进程,读写 IO 分离 |
| 线程同步 | pthread_mutex_t 互斥锁 |
| 核心功能 | 消息全局广播、上下线提示、/quit 优雅退出 |
做这个项目的初衷很简单:把课本上学的 Socket 编程、多线程、进程通信这些零散的知识点,通过一个完整的项目串起来。从写第一行 socket() 到最终能跟室友在局域网里聊天,这个过程确实学到了很多课本上没有的细节。
整个聊天室按分层思想设计,从底向上分为三层:
Text┌─────────────────────────────────────────────┐ │ 客户端应用层 client.c │ │ ├─ 参数解析与域名解析 │ │ ├─ TCP 连接建立 │ │ ├─ fork 进程拆分(读/写分离) │ │ └─ ANSI 终端排版美化 │ ├─────────────────────────────────────────────┤ │ Linux 系统网络接口层 │ │ socket / bind / listen / accept │ │ pthread / fork / mutex │ ├─────────────────────────────────────────────┤ │ 服务端应用层 server.c │ │ ├─ 本机网卡 IP 遍历打印 │ │ ├─ Socket 初始化/绑定/监听 │ │ ├─ accept 等待客户端接入 │ │ ├─ pthread 多线程会话处理 │ │ ├─ 互斥锁同步在线客户端列表 │ │ └─ 消息格式化与全局广播转发 │ └─────────────────────────────────────────────┘
自己画这个分层图的时候,对"分层"这个概念才有了实感。每一层只管自己的事,下层给上层提供服务——以前觉得抽象,写代码之后才理解为什么这样做 方便调试、方便换实现。
整个聊天室的通信流程可以分为 4 个阶段:
服务端启动后先遍历本机所有网卡,打印出 IPv4 地址供客户端连接。然后创建 TCP socket,绑定 0.0.0.0:3333,开启监听。主线程死循环阻塞在 accept() 上,等待客户端上门。
客户端运行时传入服务端 IP,通过 gethostbyname() 解析地址后创建 socket 发起连接。连接成功后立即 fork() 一分为二:子进程负责发消息,父进程负责收消息——这样读写互不阻塞。
这是最核心的逻辑:
客户端输入 /quit 后调用 shutdown(SHUT_WR) 关闭写通道,子进程退出;服务端 read() 返回 0,判定断开,加锁清理客户端列表并关闭 socket。整个过程不丢消息、不留僵尸进程。
踩坑记录: 一开始没做互斥锁,两个客户端同时断开的时候直接 crash 了。后来加了 pthread_mutex_lock 才稳定下来——多线程共享数组不保护等于自爆。
| 方案 | 适用场景 | 优势 | 同步方式 |
|---|---|---|---|
| 服务端 pthread 多线程 | 一个客户端一个线程 | 线程轻量,共享全局数组,广播简单 | 互斥锁 |
| 客户端 fork 多进程 | 父子进程读写分离 | 地址空间独立,无锁,互不阻塞 | 无需锁 |
当初纠结过到底用线程还是进程。服务端选线程是因为 广播时需要访问同一个客户端数组,线程共享内存比进程间通信简单太多。客户端选 fork 则是因为父子进程各干各的(一个读一个写),不需要共享数据,进程隔离反而更安全。
服务端收到某客户端发来的消息后,上锁遍历所有在线 socket,循环 write() 发送,跳过消息发送者。代码核心就是下面这段:
Cpthread_mutex_lock(&clientMutex); for (int i = 0; i < clientCount; ++i) if (clientSockets[i] != sock) write(clientSockets[i], msg, strlen(msg)); pthread_mutex_unlock(&clientMutex);
纯黑底白字太枯燥了,我们用 ANSI 转义序列 做了一点终端美化:
\033[H\033[J — 清空整个终端屏幕\033[2K — 清空当前行\033[1G — 光标移到行首\033[F — 光标上移一行效果就是聊天时输入框固定在屏幕最下方,消息从下往上滚动,类似微信电脑版的布局感。
Bash# 编译服务端(必须链接 pthread 库) gcc server.c -o server -lpthread # 编译客户端 gcc client.c -o client
./server 启动服务端,查看打印的本机局域网 IP./client 服务端IP 接入群聊/quit 正常退出TextTCP-ChatRoom/ ├── server.c # 多线程广播服务端 ├── client.c # fork 读写分离客户端 ├── README.md # 使用说明书 └── 软件架构.md # 本文档
整个项目就两个 .c 文件,加起来不到 300 行。但麻雀虽小五脏俱全,TCP 通信、多线程、互斥锁、进程 fork、ANSI 终端控制 这些知识点全用上了。适合作为 Linux 网络编程的入门练手项目。
以下是 服务器端 server.c 的核心实现:
C#include <stdlib.h> #include <stdio.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <pthread.h> #include <ifaddrs.h> #define BUFF_SIZE 128 #define PORT 3333 #define MAX_CLIENTS 5 int clientSockets[MAX_CLIENTS]; int clientCount = 0; pthread_mutex_t clientMutex = PTHREAD_MUTEX_INITIALIZER; struct ClientInfo { int clientSocket; struct sockaddr_in clientAddr; }; void* handle_client(void* arg) { struct ClientInfo* info = (struct ClientInfo*)arg; int sock = info->clientSocket; struct sockaddr_in addr = info->clientAddr; free(info); char buffer[BUFF_SIZE]; int len; while ((len = read(sock, buffer, BUFF_SIZE - 1)) > 0) { buffer[len] = '\0'; char msg[2 * BUFF_SIZE]; snprintf(msg, sizeof(msg), "--------------------------------\n" ">>Client/%s/%d :\n>>%s", inet_ntoa(addr.sin_addr), ntohs(addr.sin_port), buffer); printf("%s", msg); pthread_mutex_lock(&clientMutex); for (int i = 0; i < clientCount; ++i) if (clientSockets[i] != sock) write(clientSockets[i], msg, strlen(msg)); pthread_mutex_unlock(&clientMutex); } close(sock); /* 从客户端列表中移除 */ pthread_mutex_lock(&clientMutex); for (int i = 0; i < clientCount; ++i) if (clientSockets[i] == sock) { clientSockets[i] = clientSockets[--clientCount]; break; } pthread_mutex_unlock(&clientMutex); printf("✕ Client/%s/%d disconnected.\n", inet_ntoa(addr.sin_addr), ntohs(addr.sin_port)); return NULL; } int main(int argc, char** argv) { /* 获取并打印本机 IP */ struct ifaddrs *ifap, *ifa; getifaddrs(&ifap); for (ifa = ifap; ifa; ifa = ifa->ifa_next) if (ifa->ifa_addr && ifa->ifa_addr->sa_family == AF_INET) printf("-->%s: %s\n", ifa->ifa_name, inet_ntoa(((struct sockaddr_in*)ifa->ifa_addr)->sin_addr)); freeifaddrs(ifap); /* 创建、绑定、监听 */ int server = socket(PF_INET, SOCK_STREAM, 0); struct sockaddr_in sAddr; memset(&sAddr, 0, sizeof(sAddr)); sAddr.sin_family = AF_INET; sAddr.sin_addr.s_addr = htonl(INADDR_ANY); sAddr.sin_port = htons(PORT); bind(server, (struct sockaddr*)&sAddr, sizeof(sAddr)); listen(server, 5); printf("Init server...\nWaiting for connections...\n"); while (1) { socklen_t addrSize = sizeof(struct sockaddr_in); struct sockaddr_in cAddr; int client = accept(server, (struct sockaddr*)&cAddr, &addrSize); printf("✓ Client/%s/%d connected.\n", inet_ntoa(cAddr.sin_addr), ntohs(cAddr.sin_port)); pthread_mutex_lock(&clientMutex); if (clientCount < MAX_CLIENTS) { clientSockets[clientCount++] = client; pthread_mutex_unlock(&clientMutex); struct ClientInfo* ci = malloc(sizeof(struct ClientInfo)); ci->clientSocket = client; ci->clientAddr = cAddr; pthread_t tid; pthread_create(&tid, NULL, handle_client, ci); pthread_detach(tid); } else { pthread_mutex_unlock(&clientMutex); close(client); printf("Maximum clients reached. Rejected.\n"); } } close(server); return 0; }
以下是 客户端 client.c 的核心片段:
Cint main(int argc, char *argv[]) { /* 解析主机名 → 连接服务器 */ struct hostent *host = gethostbyname(argv[1]); int sock = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in sAddr; sAddr.sin_family = AF_INET; sAddr.sin_addr.s_addr = *((unsigned long*)host->h_addr_list[0]); sAddr.sin_port = htons(PORT); connect(sock, (struct sockaddr*)&sAddr, sizeof(sAddr)); pid_t pid = fork(); if (pid == 0) writeRoutine(sock, buffer); // 子进程发 else { readRoutine(sock, buffer); // 父进程收 waitpid(pid, &status, 0); } close(sock); return 0; } void readRoutine(int sock, char* buf) { while (1) { int n = read(sock, buf, BUFF_SIZE); if (n <= 0) { printf("\n✕ Server disconnected.\n"); return; } /* ANSI 控制光标,刷新显示 */ printf("\033[2K\033[1G\033[F\033[2K\033[1G\033[F\033[2K\033[1G"); printf("%s\n--------------------------------\n<<You :\n<<", buf); fflush(stdout); } } void writeRoutine(int sock, char* buf) { while (1) { fputs("--------------------------------\n<<You :\n<<", stdout); fgets(buf, BUFF_SIZE, stdin); if (strncmp(buf, "/quit\n", 2) == 0) { shutdown(sock, SHUT_WR); return; } write(sock, buf, strlen(buf)); } }