简介:这是一份面向C++初学者与网络编程入门者的WinSock基础实践资源,聚焦Windows平台下的Socket通信原理与双端实现,帮助学习者快速掌握客户端-服务器模型的核心编码逻辑。资源包含40个文件,以8个头文件(.h)和6个源码文件(.cpp)为主体,辅以可直接运行的客户端与服务器.exe程序、工程配置文件(.dsp/.dsw)、资源定义(.rc/.ico)及详细注释的ReadMe说明,整体压缩包仅73KB,轻量易解压,适合边学边练。已有262人下载学习,反映出其在入门教学场景中的实用价值。读者可完整获得一套结构清晰、注释详尽的双端示例工程:服务器能实时接收并显示客户端发送内容,代码严格参照《Visual C++网络高级编程》经典章节实现,关键步骤均有中文注释,工程目录按Client/Sever分模块组织,便于理解项目结构与编译流程,是WinSock编程入门不可多得的参考范例。
1. 为什么一个“简单”的 Winsock 客户机/服务器示例,反而成了 Windows 网络编程的「第一道断崖」?
你写完socket()、bind()、listen()、accept(),编译通过,运行后客户端连不上——不是报错,是卡在connect()无限等待;或者服务器accept()返回了句柄,但recv()读不到任何数据;更玄学的是:同一段代码,在 VS2019 下能跑,在 VS2022 + Windows 11 SDK 下直接WSAStartup失败,错误码 10093(WSASYSNOTREADY)。这不是你代码写得烂,而是 Winsock 这套 API 表面平滑、底层全是暗礁:它不自动初始化协议栈,不帮你处理字节序,不隔离阻塞/非阻塞模式切换的副作用,甚至closesocket()和WSACleanup()的调用顺序错了都会让后续 socket 创建静默失败。这个「简单示例」本质是 Windows 网络编程的最小可信验证集——它不教你 HTTP 或 TLS,只逼你直面 TCP 连接建立、数据收发、资源释放这三根骨头。适合刚脱离printf("Hello World")、正准备啃《Windows 网络编程》第 3 章的 C/C++ 工程师,也适合嵌入式转 PC 开发、需要快速验证设备通信链路的硬件工程师。别跳过它,否则后面所有基于 Winsock 的库(如 libuv、Boost.Asio 底层)你都看不懂为什么崩溃。
2. 从零启动:用最精简的 Winsock 2.2 实现可调试的 TCP 回显服务
Winsock 不是标准 C 库,它是 Windows 提供的一套动态链接接口,必须显式加载、初始化、清理。所谓「简单」,是指不依赖 MFC、ATL、Qt 等框架,纯 C 风格调用。我们以 Windows 10/11 + Visual Studio 2022(使用 v143 工具集)为基准环境,目标是:
- 服务器监听
127.0.0.1:8080,接收任意长度文本,原样返回; - 客户端连接该地址,发送字符串
"Hello from client",接收响应并打印; - 全程无内存泄漏、无句柄泄露、无未处理异常;
- 每个关键步骤附带
WSAGetLastError()错误检查和日志输出。
2.1 初始化 Winsock 并创建监听 socket
Winsock 必须先调用WSAStartup()才能使用。参数wVersionRequested决定协议版本,Winsock 2.2 是当前最稳妥的选择——它兼容所有 Windows NT 5.0+ 系统(即 WinXP 及以后),支持 IPv6、重叠 I/O、服务质量(QoS),且比 Winsock 1.1 更健壮。不要用MAKEWORD(2,2)这种宏,直接写MAKEWORD(2,2)易错,应使用MAKEWORD(2,2)——等等,不对,MAKEWORD是旧宏,现代写法是MAKEWORD(2,2)?不,正确写法是MAKEWORD(2,2)?查证:MAKEWORD(low, high)中 low 是低字节,high 是高字节,所以2.2版本对应low=2, high=2→MAKEWORD(2,2)。但实际开发中,我习惯直接写MAKEWORD(2,2),因为#define MAKEWORD(a,b) ((WORD)(((BYTE)(a))|(((WORD)((BYTE)(b)))<<8))),传入(2,2)得到0x0202,正是 Winsock 2.2 的版本号。
// server.c #include <winsock2.h> #include <stdio.h> #include <stdlib.h> #pragma comment(lib, "ws2_32.lib") // 告诉链接器链接 ws2_32.dll int main() { WSADATA wsaData; int result = WSAStartup(MAKEWORD(2,2), &wsaData); if (result != 0) { printf("WSAStartup failed: %d\n", result); return 1; } // 创建 TCP socket(IPv4, 流式, 默认协议) SOCKET listenSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listenSocket == INVALID_SOCKET) { printf("socket() failed: %d\n", WSAGetLastError()); WSACleanup(); return 1; }提示:
#pragma comment(lib, "ws2_32.lib")是 Visual Studio 特有语法,等价于项目属性 → 链接器 → 输入 → 附加依赖项里加ws2_32.lib。若用 MinGW 编译,需加-lws2_32参数。
socket()的三个参数含义必须刻进肌肉记忆:
AF_INET:IPv4 地址族(不是PF_INET,虽然值相同,但语义上AF_表示 Address Family,PF_是 Protocol Family,POSIX 要求用AF_);SOCK_STREAM:面向连接的字节流(TCP),不是SOCK_DGRAM(UDP);IPPROTO_TCP:明确指定 TCP 协议,避免系统在SOCK_STREAM下默认选错协议(极罕见,但显式指定是血泪经验)。
2.2 绑定地址并开始监听
绑定前需构造sockaddr_in结构体。关键点:
sin_family必须设为AF_INET;sin_port必须用htons()转换为主机字节序 → 网络字节序(大端),否则端口会错乱(如8080写成0x1F90,若不转换直接赋值,小端机器上存为0x901F,网络上解析为36896);sin_addr.s_addr设为INADDR_ANY(0x00000000)表示监听本机所有 IPv4 接口,不是127.0.0.1——后者只监听回环,局域网其他机器无法访问;bind()前建议设置SO_REUSEADDR选项,避免TIME_WAIT状态导致重启时Address already in use错误。
// 设置 SO_REUSEADDR 选项 int optval = 1; setsockopt(listenSocket, SOL_SOCKET, SO_REUSEADDR, (const char*)&optval, sizeof(optval)); struct sockaddr_in serverAddr = {0}; serverAddr.sin_family = AF_INET; serverAddr.sin_port = htons(8080); // 网络字节序端口 serverAddr.sin_addr.s_addr = INADDR_ANY; // 监听所有本地 IPv4 接口 if (bind(listenSocket, (struct sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) { printf("bind() failed: %d\n", WSAGetLastError()); closesocket(listenSocket); WSACleanup(); return 1; } if (listen(listenSocket, SOMAXCONN) == SOCKET_ERROR) { printf("listen() failed: %d\n", WSAGetLastError()); closesocket(listenSocket); WSACleanup(); return 1; } printf("Server listening on 127.0.0.1:8080...\n");SOMAXCONN是系统定义的最大挂起连接数(通常为0x7FFFFFFF),实际有效值受系统限制(Windows 默认 200),但直接传它比硬写100更安全——它表示“尽可能多”,内核会按需裁剪。
2.3 接收连接并回显数据
accept()是阻塞调用,直到有客户端连接才返回。返回的新 socketclientSocket才是与该客户端通信的句柄,原listenSocket仍用于接受新连接。数据收发用recv()/send(),注意:
recv()返回值是实际接收字节数,0表示对方关闭连接(TCP FIN),SOCKET_ERROR表示出错;send()不保证一次发完全部数据,返回值可能小于len,需循环发送(本例简单,只发小文本,暂不实现分片);closesocket()必须在WSACleanup()之前调用,否则 socket 句柄不会真正释放。
while (1) { struct sockaddr_in clientAddr = {0}; int clientAddrLen = sizeof(clientAddr); SOCKET clientSocket = accept(listenSocket, (struct sockaddr*)&clientAddr, &clientAddrLen); if (clientSocket == INVALID_SOCKET) { printf("accept() failed: %d\n", WSAGetLastError()); continue; // 继续监听,不退出 } printf("Client connected from %s:%d\n", inet_ntoa(clientAddr.sin_addr), ntohs(clientAddr.sin_port)); // 简单回显:接收最多 1024 字节,原样发回 char buffer[1024] = {0}; int bytesReceived = recv(clientSocket, buffer, sizeof(buffer)-1, 0); if (bytesReceived > 0) { buffer[bytesReceived] = '\0'; printf("Received: \"%s\"\n", buffer); int bytesSent = send(clientSocket, buffer, bytesReceived, 0); if (bytesSent == SOCKET_ERROR) { printf("send() failed: %d\n", WSAGetLastError()); } } else if (bytesReceived == 0) { printf("Client disconnected gracefully.\n"); } else { printf("recv() failed: %d\n", WSAGetLastError()); } closesocket(clientSocket); // 关闭本次连接 } closesocket(listenSocket); WSACleanup(); return 0; }inet_ntoa()将in_addr转为点分十进制字符串(如192.168.1.100),ntohs()将网络字节序端口转回主机序用于打印。这两个函数线程不安全(内部用静态缓冲区),但在单线程服务器中够用;生产环境应改用inet_ntop()和ntohs()的安全变体。
3. 客户端实现:连接、发送、接收、超时控制缺一不可
客户端逻辑看似简单,但最容易翻车的是连接超时和错误处理。connect()默认阻塞,若服务器宕机或防火墙拦截,它会卡住21 秒以上(Windows TCP 重传策略:SYN 重试 3 次,间隔 1s/2s/4s,总耗时约 7s;但某些场景下会更长)。必须实现超时,否则整个程序假死。
3.1 创建 socket 并设置非阻塞模式实现 connect 超时
Winsock 不提供connect()的超时参数,标准解法是:
- 创建 socket 后,用
ioctlsocket()将其设为非阻塞; - 调用
connect(),它立即返回SOCKET_ERROR,WSAGetLastError()为WSAEWOULDBLOCK(表示正在连接); - 用
select()监听 socket 的可写事件(writefds),超时时间由timeval控制; select()返回后,用getsockopt()检查SO_ERROR选项获取连接结果。
// client.c #include <winsock2.h> #include <stdio.h> #include <stdlib.h> #pragma comment(lib, "ws2_32.lib") int main() { WSADATA wsaData; if (WSAStartup(MAKEWORD(2,2), &wsaData) != 0) { printf("WSAStartup failed\n"); return 1; } SOCKET clientSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (clientSocket == INVALID_SOCKET) { printf("socket() failed: %d\n", WSAGetLastError()); WSACleanup(); return 1; } // 设置非阻塞模式 u_long mode = 1; // 1 = non-blocking ioctlsocket(clientSocket, FIONBIO, &mode); struct sockaddr_in serverAddr = {0}; serverAddr.sin_family = AF_INET; serverAddr.sin_port = htons(8080); serverAddr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 注意:inet_addr() 返回网络字节序 // 发起非阻塞 connect int result = connect(clientSocket, (struct sockaddr*)&serverAddr, sizeof(serverAddr)); if (result == SOCKET_ERROR && WSAGetLastError() != WSAEWOULDBLOCK) { printf("connect() failed immediately: %d\n", WSAGetLastError()); closesocket(clientSocket); WSACleanup(); return 1; } // 使用 select 等待连接完成,超时 5 秒 fd_set writefds; FD_ZERO(&writefds); FD_SET(clientSocket, &writefds); struct timeval timeout = {5, 0}; // 5 秒 int selectResult = select(0, NULL, &writefds, NULL, &timeout); if (selectResult == 0) { printf("Connection timed out after 5 seconds.\n"); closesocket(clientSocket); WSACleanup(); return 1; } else if (selectResult == SOCKET_ERROR) { printf("select() failed: %d\n", WSAGetLastError()); closesocket(clientSocket); WSACleanup(); return 1; } // 检查连接是否成功 int error = 0; socklen_t len = sizeof(error); if (getsockopt(clientSocket, SOL_SOCKET, SO_ERROR, (char*)&error, &len) < 0 || error != 0) { printf("Connection failed: %d\n", error); closesocket(clientSocket); WSACleanup(); return 1; } printf("Connected to server successfully.\n");ioctlsocket(clientSocket, FIONBIO, &mode)是 Winsock 设置非阻塞的核心调用。mode=1启用非阻塞,mode=0恢复阻塞。select()的第一个参数nfds在 Windows 上被忽略(填0即可),但 POSIX 要求是最大 fd+1,此处保持 Windows 习惯。
3.2 发送请求并接收响应
连接成功后,发送固定字符串,然后recv()等待响应。注意recv()也需处理超时,否则服务器不响应时客户端卡死。同样用select()监听readfds:
const char* message = "Hello from client"; int sent = send(clientSocket, message, (int)strlen(message), 0); if (sent == SOCKET_ERROR) { printf("send() failed: %d\n", WSAGetLastError()); closesocket(clientSocket); WSACleanup(); return 1; } printf("Sent %d bytes: \"%s\"\n", sent, message); // 设置 recv 超时:5 秒 FD_ZERO(&writefds); // 复用 writefds 变量,实际用于 readfds FD_SET(clientSocket, &writefds); timeout.tv_sec = 5; timeout.tv_usec = 0; selectResult = select(0, &writefds, NULL, NULL, &timeout); if (selectResult == 0) { printf("Receive timeout.\n"); closesocket(clientSocket); WSACleanup(); return 1; } else if (selectResult == SOCKET_ERROR) { printf("select() for recv failed: %d\n", WSAGetLastError()); closesocket(clientSocket); WSACleanup(); return 1; } char buffer[1024] = {0}; int received = recv(clientSocket, buffer, sizeof(buffer)-1, 0); if (received > 0) { buffer[received] = '\0'; printf("Received %d bytes: \"%s\"\n", received, buffer); } else if (received == 0) { printf("Server closed connection.\n"); } else { printf("recv() failed: %d\n", WSAGetLastError()); } closesocket(clientSocket); WSACleanup(); return 0; }recv()超时检测逻辑与connect()类似,但监听readfds(可读事件)。这里有个细节:select()返回后,FD_ISSET(clientSocket, &writefds)应为真,但代码中没显式检查——因为select()返回正值即表示至少有一个 fd 就绪,且我们只传入一个 fd,所以可直接recv()。生产代码应加FD_ISSET()判断,此处为简洁省略。
4. 避坑指南:Winsock 编程中 5 个让你凌晨三点还在看日志的致命问题
Winsock 的坑不在语法,而在 Windows 网络栈与用户态 API 的耦合细节。以下是我在线上服务中踩过的、查文档都难定位的真问题,每一条都附带现象、根因和修复动作。
4.1 现象:WSAStartup返回 10093(WSASYSNOTREADY),但GetLastError()是 0
原因:WSAStartup要求传入WSADATA结构体指针,该结构体必须零初始化({0}或memset(&wsaData, 0, sizeof(wsaData)))。若结构体中有未初始化的垃圾值,某些 Windows 版本(尤其是 Server 2016+)会拒绝初始化并返回此错误。
解决:永远用WSADATA wsaData = {0};声明,或显式memset。不要依赖编译器自动清零局部变量。
4.2 现象:客户端connect()成功,但send()立即返回WSAECONNRESET(10054)
原因:服务器accept()后未及时recv(),客户端已send()数据,而服务器 socket 的接收缓冲区满(或未调用recv()导致内核认为应用不消费数据),TCP 栈发送 RST 包强制断开。常见于服务器逻辑卡在printf()或文件 I/O 时。
解决:确保accept()后尽快进入recv()循环;对高吞吐场景,用setsockopt(..., SO_RCVBUF, ...)增大接收缓冲区;或启用TCP_NODELAY减少 Nagle 算法延迟。
4.3 现象:recv()返回 0,但客户端明明没调用closesocket()
原因:服务器调用closesocket()后,TCP 发送 FIN 包,客户端recv()收到 EOF(返回 0)。但若服务器进程崩溃或被TerminateProcess()强杀,Windows 可能不发送 FIN,而是 RST,此时recv()返回SOCKET_ERROR且WSAGetLastError()为WSAECONNRESET。
解决:recv()返回 0 时,视为连接正常关闭,应closesocket()并退出循环;返回SOCKET_ERROR且错误码为WSAECONNRESET或WSAESHUTDOWN时,同样清理资源。不要假设 0 一定代表对方主动关闭。
4.4 现象:程序运行几次后,socket()总是返回INVALID_SOCKET,WSAGetLastError()是 10024(WSAEMFILE)
原因:closesocket()调用次数少于socket(),导致 socket 句柄泄漏。Windows 每进程默认 512 个句柄,耗尽后无法创建新 socket。常见于accept()失败时忘记closesocket(clientSocket),或异常路径(如recv()失败)未清理 socket。
解决:每个socket()必须配对closesocket();用 RAII 思维(C++ 中可用std::unique_ptr自定义删除器);C 语言中,用goto cleanup统一出口释放资源。
4.5 现象:inet_addr("127.0.0.1")返回0xFFFFFFFF(INADDR_NONE),连接失败
原因:inet_addr()仅支持 IPv4 点分十进制格式,且字符串必须以\0结尾。若传入的 IP 字符串来自用户输入且未严格校验(如末尾有空格、换行符),inet_addr()解析失败返回INADDR_NONE,connect()用此地址会失败。
解决:优先用InetPtonA(AF_INET, "127.0.0.1", &addr.sin_addr)(Windows XP+),它更安全且支持 IPv6;若必须用inet_addr(),先strncpy()复制并确保\0结尾,再校验返回值是否为INADDR_NONE。
5. 进阶验证与调试:用命令行工具和 Wireshark 把网络行为「看穿」
写完代码只是第一步,真正的 Winsock 工程师要能「看见」数据包。下面这套组合验证法,让我在客户现场 10 分钟内定位 90% 的连接问题。
5.1 用netstat和Get-NetTCPConnection快速确认端口状态
编译运行服务器后,立刻执行:
# Windows 命令行(管理员权限非必需,但能看到 PID) netstat -ano | findstr :8080预期输出类似:
TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 12345其中12345是服务器进程 PID。若显示127.0.0.1:8080,说明绑定了回环地址,局域网无法访问;若无输出,证明bind()/listen()未生效(检查bind()是否在listen()前调用、SO_REUSEADDR是否设置)。
PowerShell 中更精准:
Get-NetTCPConnection -LocalPort 8080 | Select-Object LocalAddress, LocalPort, State, OwningProcess | Format-Table -AutoSizeState列应为Listen,OwningProcess是 PID。配合Get-Process -Id 12345可确认进程名。
5.2 用telnet和curl做最简客户端验证
不用自己编译客户端,先用系统工具验证服务可达性:
# 测试 TCP 连通性(Windows 自带) telnet 127.0.0.1 8080 # 连接成功后,键盘输入 "Hello" + 回车,看是否回显若telnet提示“找不到命令”,需在“启用或关闭 Windows 功能”中勾选“Telnet 客户端”。
curl更进一步:
# 发送 HTTP 风格请求(虽服务器非 HTTP,但 TCP 层能通) curl -v telnet://127.0.0.1:8080 --data-binary "Hello"-v显示详细过程,--data-binary确保二进制发送(避免换行符被 curl 修改)。
5.3 Wireshark 抓包分析三次握手与数据流向
这是终极手段。启动 Wireshark,过滤条件设为ip.addr == 127.0.0.1 and tcp.port == 8080,然后运行客户端。关键观察点:
| 时间点 | 抓包现象 | 正常表现 | 异常表现 |
|---|---|---|---|
| 连接建立 | 客户端发 SYN → 服务器 SYN-ACK → 客户端 ACK | 三包连续,时间间隔 < 1ms(本机) | 缺失 SYN-ACK(服务器未响应)、ACK 延迟 > 100ms(网络卡顿) |
| 数据传输 | 客户端发 PSF(PUSH+ACK)→ 服务器发 ACK → 服务器发 PSF | 数据包 payload 可见明文 | 服务器无 PSF 包(recv()未调用)、payload 为空(send()未执行) |
| 连接关闭 | 任意方发 FIN-ACK → 对方发 ACK → 对方发 FIN-ACK → 原方发 ACK | 四次挥手完整 | 缺失 FIN(closesocket()未调用)、RST 包出现(连接异常终止) |
Wireshark 中右键数据包 → “Follow → TCP Stream”,可直接看到 ASCII 文本交互内容,比printf日志更真实。
5.4 用 Process Monitor 监控 socket 句柄生命周期
当怀疑句柄泄漏时,ProcMon(微软官方工具)比任务管理器更准。设置过滤器:
Process Nameisyour_server.exeOperationisTCP Connect/TCP Receive/TCP SendPathcontains127.0.0.1:8080
观察Result列:SUCCESS表示操作成功,NAME NOT FOUND表示地址解析失败,TIMEOUT表示超时。若大量TCP Connect后无对应TCP Receive,说明连接被丢弃。
最后说个血泪习惯:我写 Winsock 代码时,永远在main()开头加AllocConsole(); freopen("CONOUT$", "w", stdout);(仅调试版),这样printf输出直接弹窗可见,不用切到命令行窗口。上线前删掉这两行。还有,WSACleanup()必须放在return之前最后一行,哪怕exit(0)也要确保它执行——我曾因exit()跳过WSACleanup(),导致后续程序WSAStartup()失败,折腾了两天才发现是前一个进程没清理干净。
希望帮到你。
本文还有配套的精品资源,点击获取