简介:这是一套基于C++与Winsock API实现的简易聊天系统源码,面向C/C++初学者及网络编程入门者,帮助理解TCP通信、客户端-服务器模型与Windows平台Socket编程核心机制。资源包含56个文件,涵盖3个关键源文件(chatClient.cpp、chatServer.cpp、chatServer.h)、CMake构建配置(CMakeLists.txt)、可执行程序(4个exe)、调试符号(pdb/ilk)及日志与说明文档(README.md、txt),整体压缩包仅1.23MB,轻量易上手。目前已有44人学习下载,适合课程设计、大作业或工程实训中快速搭建可运行的网络通信原型。读者可直接编译运行双端程序,观察消息收发流程;通过阅读清晰分层的代码结构(客户端/服务器分离、头文件接口定义),掌握网络编程基础框架;还可基于现有逻辑扩展多用户支持、消息持久化或图形界面,具备扎实的二次开发基础。
1. 一个 Win32 控制台聊天系统:不依赖 Qt/MFC,纯 C++ + Winsock 实现双端实时通信,适合网络编程入门者手撕 TCP 连接逻辑
你刚学完《计算机网络》的 TCP 三次握手,课本上画着 SYN、SYN-ACK、ACK 的箭头,但脑子里还是黑匣子?想亲手敲出“客户端连上服务端后,两边能互相发消息、不卡死、不丢包、按回车就发、收到就刷屏”——不是调用现成的 WebSocket 库,也不是跑个 Python socket demo 看个热闹,而是真正在 Windows 上用原生 Winsock API 拉起一条稳定 TCP 链路,从WSAStartup到closesocket全链路可控。这个项目就是干这事的:一个不到 800 行 C++ 代码的控制台聊天系统,服务端监听、客户端连接、收发分离线程、消息缓冲区管理、异常断连检测全在chat_server.cpp和chat_client.cpp里摊开写。它不炫技,不套壳,没有 GUI 框架干扰,所有 socket 选项(SO_REUSEADDR)、IO 模式(阻塞式)、错误码处理(WSAGetLastError())都裸露在源码里。如果你正卡在“为什么recv()一直阻塞不返回”“为什么客户端断了服务端没感知”“怎么让发送和接收不互相锁死”,那这份代码就是你的调试对照组——它跑得通,而且每一步都经得起打断点、查 errno、抓包验证。
2. 技术栈与工程结构:C++11 标准 + Winsock2.h + 多线程,零第三方依赖
这个项目不是 Visual Studio 向导生成的空壳工程,而是一个可直接用cl.exe命令行编译的轻量级工程。它只依赖 Windows SDK 自带的Winsock2.h和ws2_32.lib,不引入 Boost.Asio、Poco 或任何跨平台网络库,目的很明确:让你看清 Winsock 在 Windows 平台上的原始调用姿势。整个压缩包解压后共 5 个文件,结构极简:
简易聊天系统 C++ winsock/ ├── chat_server.cpp // 服务端主逻辑:初始化、监听、accept、收发线程管理 ├── chat_client.cpp // 客户端主逻辑:初始化、连接、输入/接收双线程 ├── common.h // 公共宏定义、消息结构体、错误码包装函数 ├── Makefile // MinGW/MSVC 双兼容编译脚本(含 cl.exe 调用参数详解) └── README.md // 编译命令、运行步骤、默认端口说明(12345)提示:
common.h是关键粘合剂。它定义了统一的消息帧格式struct ChatMessage,包含type(LOGIN/TEXT/LOGOUT)、sender_id(客户端自生成 ID)、content[256](UTF-8 编码文本),并封装了check_socket_error()函数——每次send()/recv()后自动检查WSAGetLastError(),把WSAECONNRESET、WSAETIMEDOUT等错误转为可读字符串,避免你在调试时对着10054发呆。
2.1 服务端核心流程:从socket()到accept()的完整生命周期
服务端启动后,先调用WSAStartup(MAKEWORD(2,2), &wsaData)初始化 Winsock DLL,这是 Windows 下所有 socket 操作的前提。接着创建监听 socket:
// chat_server.cpp 第 42 行 SOCKET listen_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listen_sock == INVALID_SOCKET) { check_socket_error("socket() failed"); return 1; }这里AF_INET表示 IPv4 地址族,SOCK_STREAM指定 TCP 流式传输,IPPROTO_TCP明确协议类型。紧接着设置SO_REUSEADDR选项,这是新手最容易翻车的点——若不设此选项,服务端异常退出后端口会进入TIME_WAIT状态,下次启动立即报错Address already in use:
// chat_server.cpp 第 51 行 int optval = 1; setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, (const char*)&optval, sizeof(optval));然后绑定地址:
// chat_server.cpp 第 57 行 sockaddr_in server_addr{}; server_addr.sin_family = AF_INET; server_addr.sin_port = htons(12345); // 默认端口,可改 server_addr.sin_addr.s_addr = INADDR_ANY; // 监听本机所有网卡 if (bind(listen_sock, (sockaddr*)&server_addr, sizeof(server_addr)) == SOCKET_ERROR) { check_socket_error("bind() failed"); closesocket(listen_sock); return 1; }注意htons()—— 将主机字节序(小端)转为网络字节序(大端),这是 TCP/IP 协议栈的硬性要求。INADDR_ANY表示不限定具体 IP,比写死127.0.0.1更通用,支持局域网其他机器连接。
最后进入监听状态,并在循环中调用accept()等待客户端连接:
// chat_server.cpp 第 72 行 if (listen(listen_sock, SOMAXCONN) == SOCKET_ERROR) { check_socket_error("listen() failed"); closesocket(listen_sock); return 1; } printf("Server listening on port 12345...\n"); while (true) { sockaddr_in client_addr{}; int client_addr_len = sizeof(client_addr); SOCKET client_sock = accept(listen_sock, (sockaddr*)&client_addr, &client_addr_len); if (client_sock == INVALID_SOCKET) { check_socket_error("accept() failed"); continue; // 不退出,继续等待下一个连接 } // 启动新线程处理该客户端 std::thread client_handler(handle_client, client_sock, inet_ntoa(client_addr.sin_addr)); client_handler.detach(); // 分离线程,避免主线程等待 }SOMAXCONN是系统允许的最大挂起连接数(通常为 200),accept()返回的是已建立连接的 socket,它和listen_sock是两个独立句柄:前者负责与特定客户端通信,后者继续监听新连接。detach()是权衡之举——项目定位是教学 Demo,不追求生产级线程池管理,用分离线程简化模型,但你要知道:真实项目中必须用join()或线程池回收资源,否则会内存泄漏。
2.2 客户端连接与双线程模型:输入与接收彻底解耦
客户端逻辑更贴近真实用户操作:启动后先让用户输入服务器 IP 和端口(默认127.0.0.1:12345),再发起连接:
// chat_client.cpp 第 38 行 SOCKET client_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (client_sock == INVALID_SOCKET) { check_socket_error("socket() failed"); return 1; } sockaddr_in server_addr{}; server_addr.sin_family = AF_INET; server_addr.sin_port = htons(port); server_addr.sin_addr.s_addr = inet_addr(ip.c_str()); if (server_addr.sin_addr.s_addr == INADDR_NONE) { // 若 IP 字符串非法,尝试 DNS 解析(如输入 "localhost") hostent* host = gethostbyname(ip.c_str()); if (host == nullptr) { fprintf(stderr, "Invalid IP or hostname: %s\n", ip.c_str()); return 1; } memcpy(&server_addr.sin_addr, host->h_addr_list[0], host->h_length); }inet_addr()只支持点分十进制格式(如192.168.1.100),遇到localhost或域名会返回INADDR_NONE,此时需调用gethostbyname()进行 DNS 查询。这段代码暴露了 Winsock 的历史包袱——现代开发更倾向用getaddrinfo()统一处理 IPv4/IPv6,但本项目刻意保留inet_addr+gethostbyname组合,因为它是教材和老项目中最常出现的写法,你迟早要读懂。
连接成功后,立即启动两个独立线程:一个负责从std::cin读取用户输入并send(),另一个循环recv()服务端消息并printf():
// chat_client.cpp 第 85 行 std::thread input_thread(send_message, client_sock); std::thread recv_thread(recv_message, client_sock); input_thread.join(); // 主线程等待输入线程结束(即用户输入 "quit") recv_thread.detach(); // 接收线程后台运行,直到 socket 关闭send_message()中的关键是消息帧封装:
// chat_client.cpp 第 112 行 ChatMessage msg{}; msg.type = MSG_TYPE_TEXT; strcpy_s(msg.content, content.c_str()); // 使用安全函数防溢出 msg.sender_id = client_id; // 客户端自生成 ID,如 "Client_001" int sent_bytes = send(client_sock, (char*)&msg, sizeof(msg), 0); if (sent_bytes != sizeof(msg)) { check_socket_error("send() incomplete"); }recv_message()则严格按sizeof(ChatMessage)接收,确保结构体对齐:
// chat_client.cpp 第 138 行 ChatMessage msg{}; int recv_bytes = recv(client_sock, (char*)&msg, sizeof(msg), 0); if (recv_bytes > 0) { if (msg.type == MSG_TYPE_TEXT) { printf("[%s]: %s\n", msg.sender_id, msg.content); } else if (msg.type == MSG_TYPE_LOGIN) { printf("[System] %s joined the chat.\n", msg.sender_id); } } else if (recv_bytes == 0) { printf("[System] Connection closed by server.\n"); exit(0); } else { check_socket_error("recv() failed"); }注意recv_bytes == 0表示对端正常关闭连接(FIN 包),这是 TCP 协议层的优雅终止信号,比检查错误码更可靠。
2.3 common.h:消息协议与错误处理的底层契约
common.h是整个项目的协议基石。它定义的ChatMessage结构体必须保证内存布局完全一致,否则客户端和服务端解析会错位:
// common.h 第 15 行 #pragma pack(push, 1) // 强制 1 字节对齐,禁用编译器自动填充 struct ChatMessage { uint8_t type; // 1 字节:0=LOGIN, 1=TEXT, 2=LOGOUT char sender_id[16]; // 16 字节:客户端标识,如 "Client_001\0" char content[256]; // 256 字节:UTF-8 文本内容 }; #pragma pack(pop)#pragma pack(1)是关键!若无此指令,编译器可能在type和sender_id之间插入 3 字节填充(因char[16]要求 4 字节对齐),导致sizeof(ChatMessage)在不同编译器下不一致。服务端send()发 273 字节,客户端recv()读 273 字节,但若结构体实际大小是 276 字节,content字段就会被错位解析——这是典型的“玄学 bug”,调试时printf看起来一切正常,但中文消息总乱码。common.h还封装了跨平台友好的错误打印:
// common.h 第 42 行 inline void check_socket_error(const char* context) { int err = WSAGetLastError(); const char* err_msg = ""; switch (err) { case WSAECONNREFUSED: err_msg = "Connection refused"; break; case WSAETIMEDOUT: err_msg = "Connection timed out"; break; case WSAECONNRESET: err_msg = "Connection reset by peer"; break; case WSAENOTCONN: err_msg = "Socket is not connected"; break; default: err_msg = "Unknown error"; } fprintf(stderr, "%s: %s (WSA Error %d)\n", context, err_msg, err); }这个函数把枯燥的数字错误码转为可读字符串,且只在出错时触发,不影响正常流程性能。
3. 编译与运行:三步走通,从 cl.exe 到双控制台对话
这个项目不依赖 Visual Studio IDE,用命令行工具链即可完成全流程。我推荐两种方式:一是用微软原生cl.exe(VS 自带),二是用 MinGW-w64(更轻量)。无论哪种,核心都是链接ws2_32.lib。
3.1 方案一:使用 Visual Studio Developer Command Prompt(推荐)
首先确认你已安装 Visual Studio(2019 或更新版本),并启用“使用 CMake 的 Visual C++ 工具”工作负载。打开x64 Native Tools Command Prompt for VS 2022(务必选 x64,避免 32/64 位混用):
# 进入项目目录 cd "D:\projects\简易聊天系统 C++ winsock" # 编译服务端(生成 chat_server.exe) cl /EHsc /W4 chat_server.cpp ws2_32.lib # 编译客户端(生成 chat_client.exe) cl /EHsc /W4 chat_client.cpp ws2_32.lib参数说明:
/EHsc:启用 C++ 异常处理(虽然本项目没用 try/catch,但防止未来扩展出问题)/W4:最高级别警告,能捕获未初始化变量、隐式类型转换等隐患ws2_32.lib:Winsock 核心库,必须显式链接,否则socket()等函数链接失败
编译成功后,目录下会出现chat_server.exe和chat_client.exe两个可执行文件。
3.2 方案二:使用 MinGW-w64(免装 VS)
若你偏好轻量环境,下载 MinGW-w64 (选择 x86_64-win32-seh 版本),将其bin目录加入系统 PATH。然后在项目根目录执行:
# 编译服务端 g++ -std=c++11 -Wall -Wextra chat_server.cpp -lws2_32 -o chat_server.exe # 编译客户端 g++ -std=c++11 -Wall -Wextra chat_client.cpp -lws2_32 -o chat_client.exe-lws2_32对应cl.exe的ws2_32.lib,-std=c++11确保std::thread等特性可用。
3.3 运行实操:双控制台验证通信链路
编译完成后,打开两个独立的命令行窗口(不能在一个窗口里用&&顺序执行,否则无法交互):
窗口 1(服务端):
chat_server.exe你会看到输出:
Initializing Winsock... Server listening on port 12345...窗口 2(客户端):
chat_client.exe程序会提示:
Enter server IP (default: 127.0.0.1): Enter server port (default: 12345):直接按两次回车,使用默认值。连接成功后显示:
Connected to 127.0.0.1:12345 You can start typing messages now. Type 'quit' to exit.此时在客户端窗口输入任意文字(如Hello, this is client!),回车后,服务端窗口会立即打印:
[Client_001]: Hello, this is client!反过来,在服务端代码中,handle_client()函数里有一段广播逻辑(见chat_server.cpp第 185 行),当收到消息时,会向所有已连接客户端转发。因此,若你启动第二个客户端chat_client.exe,第一个客户端也能看到第二个人发的消息——这就是一个最简化的群聊雏形。
注意:服务端本身不提供控制台输入接口,它的“发言”是通过向所有客户端广播实现的。若你想让服务端也能发消息,需在
handle_client()线程中增加std::cin读取逻辑(但要注意多线程竞争std::cin,需加 mutex)。
4. 常见问题排查:5 个真实踩坑记录,覆盖从编译到断连全链路
这个项目虽小,但在 Windows 网络编程的典型陷阱一个没少。以下是我在本地复现并验证过的 5 个高频问题,按“现象 → 原因 → 解决”结构整理,全是血泪经验。
4.1 现象:编译时报错LNK2019: unresolved external symbol __imp__socket@12
原因:链接器找不到socket()函数的实现,根本原因是未链接ws2_32.lib。cl.exe不像 GCC 默认链接基础库,Winsock 必须显式指定。
解决:检查cl命令末尾是否包含ws2_32.lib。若用 Makefile,确认LDFLAGS包含-lws2_32(MinGW)或ws2_32.lib(MSVC)。常见误写是wsock32.lib(旧版 Winsock 1.1 库,不支持getaddrinfo等新 API)。
4.2 现象:服务端启动报错bind() failed: Address already in use (WSA Error 10048)
原因:上次运行的服务端异常退出(如 Ctrl+C),导致监听端口仍处于TIME_WAIT状态,操作系统禁止立即重用。
解决:在bind()前添加SO_REUSEADDR选项(项目已实现)。若仍报错,手动用netstat -ano | findstr :12345查看占用端口的进程 PID,用taskkill /PID <pid> /F杀掉;或换端口(改chat_server.cpp第 57 行htons(12345))。
4.3 现象:客户端能连上服务端,但发消息后服务端收不到,recv()一直阻塞
原因:客户端send()发送的数据长度与服务端recv()期望的长度不匹配。本项目中send()发sizeof(ChatMessage)(273 字节),若服务端recv()只传入sizeof(char[256])等错误大小,会导致接收缓冲区错位。
解决:严格检查send()和recv()的第三个参数,必须都是sizeof(ChatMessage)。用printf("Sending %zu bytes\n", sizeof(ChatMessage));打印验证。
4.4 现象:中文消息显示为乱码(如ä½ å¥½),英文正常
原因:Windows 控制台默认编码是 GBK,而程序中content字段按 UTF-8 存储。printf()直接输出 UTF-8 字节流,控制台用 GBK 解释,必然乱码。
解决:在main()开头添加_setmode(_fileno(stdout), _O_U16TEXT);并改用wprintf(),但本项目为简化未采用。更实用的临时方案:右键控制台标题栏 → “属性” → “字体” → 改为 “Lucida Console” 或 “Consolas”,它们支持 UTF-8 渲染;或在代码中将 UTF-8 转 GBK 再输出(需调用MultiByteToWideChar)。
4.5 现象:客户端断开后,服务端recv()返回 0,但handle_client()线程未退出,继续recv()报错WSAENOTCONN
原因:recv()返回 0 表示对端关闭,但线程未主动return,后续循环再次调用recv()时 socket 已无效,触发WSAENOTCONN。
解决:在handle_client()的recv()循环中,必须判断recv_bytes == 0并break退出循环,随后closesocket(client_sock)。项目代码(chat_server.cpp第 198 行)已正确处理,若你修改过逻辑,请核对此处。
5. 二次开发指南:从单聊到群聊、从控制台到日志持久化
现在你已经跑通了基础通信,下一步不是重写,而是基于现有骨架做增量改造。我以三个真实需求为例,给出可直接抄作业的代码补丁和验证方法。
5.1 需求一:支持用户名登录,替代自动生成的Client_001
当前客户端 ID 是硬编码生成的(chat_client.cpp第 65 行sprintf_s(client_id, "Client_%03d", ++client_counter);),体验差。改为让用户输入昵称,并在首次send()时发送MSG_TYPE_LOGIN类型消息。
修改步骤:
- 在
chat_client.cpp的main()中,连接成功后添加输入逻辑:
// chat_client.cpp 第 75 行附近 printf("Enter your nickname: "); fgets(nickname, sizeof(nickname), stdin); nickname[strcspn(nickname, "\n")] = 0; // 去除换行符 if (strlen(nickname) == 0) strcpy_s(nickname, "Anonymous");- 构造登录消息并发送(在
send_message()调用前):
// chat_client.cpp 第 80 行 ChatMessage login_msg{}; login_msg.type = MSG_TYPE_LOGIN; strcpy_s(login_msg.sender_id, nickname); strcpy_s(login_msg.content, "has joined the chat."); send(client_sock, (char*)&login_msg, sizeof(login_msg), 0);- 在服务端
handle_client()中,接收并解析登录消息(chat_server.cpp第 170 行):
// 替换原有的 sender_id 赋值逻辑 if (msg.type == MSG_TYPE_LOGIN) { strncpy_s(client_info.id, msg.sender_id, sizeof(client_info.id)-1); printf("[System] %s joined as %s\n", inet_ntoa(client_addr.sin_addr), client_info.id); } else if (msg.type == MSG_TYPE_TEXT) { // 原有逻辑:用 client_info.id 作为发送者 }验证方法:启动服务端,再启动两个客户端,分别输入Alice和Bob,观察服务端输出是否为[System] Alice joined as Alice,且后续消息显示为[Alice]: hello。
5.2 需求二:消息持久化到文件,供事后审计
群聊场景下,需要记录所有消息到chat_log.txt。由于多客户端并发写入,必须加锁。
修改步骤:
- 在
chat_server.cpp全局作用域添加:
#include <fstream> #include <mutex> std::ofstream log_file("chat_log.txt", std::ios::app); std::mutex log_mutex;- 在
broadcast_message()函数(第 185 行)中,每次广播前写入日志:
// chat_server.cpp 第 188 行 { std::lock_guard<std::mutex> lock(log_mutex); log_file << "[" << std::asctime(std::localtime(&t)) << "] " << msg.sender_id << ": " << msg.content << std::endl; log_file.flush(); // 确保立即写入磁盘 }验证方法:运行一段时间后,查看chat_log.txt是否按时间顺序记录所有消息,格式如[Wed Jun 12 10:30:45 2024] Alice: hello。
5.3 需求三:服务端支持命令行管理,如list users、kick Alice
当前服务端是纯被动监听,无法主动管理客户端。我们给服务端加一个独立的管理线程,监听std::cin输入命令。
修改步骤:
- 在
chat_server.cpp的main()末尾(while(true)循环后)添加:
// chat_server.cpp 第 105 行 std::thread admin_thread(admin_console); admin_thread.detach();- 实现
admin_console()函数:
void admin_console() { printf("\nAdmin console started. Commands: list, quit\n"); std::string cmd; while (true) { printf("admin> "); std::getline(std::cin, cmd); if (cmd == "list") { std::lock_guard<std::mutex> lock(clients_mutex); printf("Active clients (%zu):\n", clients.size()); for (const auto& c : clients) { printf(" %s (%s)\n", c.id, inet_ntoa(c.addr.sin_addr)); } } else if (cmd == "quit") { printf("Shutting down server...\n"); exit(0); } } }注意:clients是存储所有客户端信息的std::vector<ClientInfo>,需在全局声明并用clients_mutex保护。
验证方法:服务端运行后,在其控制台输入admin> list,应列出所有已连接客户端 IP 和昵称。
从那以后我每次做网络 Demo,都强制走一遍netstat -ano | findstr :12345查端口占用,再cl /EHsc /W4编译,最后用 Wireshark 抓包验证三次握手——这三步成了我的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取