ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VC6.0 Socket编程实战:Winsock TCP服务端与客户端完整教程

VC6.0 Socket编程实战:Winsock TCP服务端与客户端完整教程 简介面向初次接触网络编程的 VC6.0 开发者这一套 Socket 服务器与客户端示例工程非常实用。代码极简重点演示了基于 Winsock 的 TCP 通信完整流程从加载套接字库、创建 socket到服务器端依次执行 bind、listen、accept客户端执行 connect随后进行 send 与 recv 数据收发每个环节都有清晰可读的 cpp 源码可供参考。资源包包含服务器端和客户端两个独立项目各自带有可运行的 exe、工程文件 dsw/dsp、源代码 cpp并附上 pdb、ilk 等调试符号文件方便在 VC6 中直接打开、单步跟踪和修改验证。两个项目目录结构清晰便于对照观察同一套 API 在服务端与客户端的不同调用方式。压缩包共 27 个文件整体仅 1.58MB轻量而完整。该资源已有 854 人学习下载对刚接触 Socket 编程的读者具有较高参考价值。通过实际运行这两个小程序可以直观理解 C/S 架构中数据如何从客户端发送到服务器端也可作为课程设计或毕业设计里网络通信模块的起步模板。1. VC6.0 编程 Socket老环境里的服务器客户端值得亲手跑一遍好些人看到 VC6.0 就皱眉觉得这工具早该进博物馆了。可真到写网络实验、维护老系统或者补网络编程基础的时候你会发现大量现成例子和教材依旧用它VC6.0 里的 Winsock API 形态和现代 Windows 几乎没有差别。这篇笔记把用 VC6.0 实现 TCP 服务器和 TCP 客户端的完整链路讲清楚——从 WSAStartup 初始化、socket 创建到服务端 bind/listen/accept再到客户端 connect 后双向收发最后落到真实的踩坑记录。适合赶课程作业的学生、要读懂存量代码的开发者以及想确认网络基础没丢的工程师。读完你就能在本地把两端跑通并且知道下一步往哪走。2. 认识 Winsock头文件、库链接与初始化三关2.1 Winsock 是什么一个 API 层不是协议本身Winsock 是 Windows 平台对 Berkeley Socket 接口的实现它本身不是协议而是把 TCP/IP 协议栈的操作封装成一组 C 函数。你在代码里用 socket() 创建套接字句柄用 bind() 绑定本地地址用 listen() 和 accept() 完成服务端接入用 connect() 发起连接用 send() 和 recv() 传数据。IP 分片、重传、滑动窗口这些事协议栈在底层替你处理应用层只跟句柄打交道这是它最大的价值。选 TCP 还是 UDP是在 socket() 的第二个参数里定的。SOCK_STREAM 对应 TCP面向连接、可靠、字节流SOCK_DGRAM 对应 UDP无连接、数据报。本文只讲 TCP因为课程设计和入门项目大多是服务器等客户端连上来的一问一答模型TCP 语义最直观排错也容易——连接失败会直接返回错误码不像 UDP 发出去就不知道结果。VC6.0 的特殊之处在于它自带的 SDK 年头早默认引入的是 winsock.h对应 Winsock 1.1而我们常用的许多声明在 winsock2.h 里对应 Winsock 2.2。环境搭建的第一个动作就是让工程用上 winsock2.h。这个选择看起来小实际决定了后面所有代码能不能编译过。2.2 VC6.0 工程里接好 winsock2.h 与 ws2_32.lib常见做法是新建 Win32 Console Application选空工程然后手动添加一个 .c 源文件。源文件顶部把库引进来是最省事的一步#include winsock2.h #include stdio.h #pragma comment(lib, ws2_32.lib) int main(void) { WSADATA wsaData; int ret; ret WSAStartup(MAKEWORD(2, 2), wsaData); if (ret ! 0) { printf(WSAStartup error: %d\n, ret); return -1; } printf(Winsock version: %d.%d\n, LOBYTE(wsaData.wVersion), HIBYTE(wsaData.wVersion)); WSACleanup(); return 0; }这段代码的逻辑WSAStartup 是调用任何 socket 函数之前必须先执行的初始化MAKEWORD(2,2) 表示请求 Winsock 2.2 版本系统协商后把实际支持的版本写进 wsaData.wVersionLOBYTE/HIBYTE 取出版本号打印出来验证。程序结束前调 WSACleanup 做反初始化释放协议栈分配的资源。参数说明#pragma comment(lib, ws2_32.lib)是 VC6 里最省事的链接方式编译时这条指令告诉链接器去 ws2_32.lib 里找符号。不想用 pragma 也可以打开 Project Settings → Link → Object/library modules手工填入 ws2_32.lib效果一样。ws2_32.lib 是 Winsock 2.2 的导入库socket、bind、send、recv 的真实实现其实在系统动态库里导入库只负责把调用转发过去。我一般会顺手把协商到的版本打印出来确认确实是 2.2。有一回某同事排错发现他代码里写的是 MAKEWORD(1,1)后面调新版 API 行为怪异问题就出在初始化版本上。请求版本宁高勿低至少 2.2。2.3 WSAStartup 初始化版本协商与返回值WSAStartup 返回值是 int0 表示成功。非 0 时直接打印出来对照错误码表查常见的有 10091系统网络子系统不可用、10092请求版本不支持。出现 10092 通常是 MAKEWORD 的两个参数写反了或者请求版本超过系统支持能力。WSAStartup 内部是引用计数的同一进程可以调多次每调一次就要对应一次 WSACleanup。我在工程里习惯把初始化和清理放在模块入口和出口各一处避免漏清理。虽然进程退出时系统会回收资源但长时间跑的服务端如果反复初始化不清理句柄表会悄悄变脏积到某个点才出问题这类问题最难查。另外要记住顺序WSAStartup 必须在任何 socket 调用之前完成但不需要在窗口创建之前做它不是窗口相关 API。核心原则就一条——先初始化后建 socket先清理后退出。顺序错乱时报错往往不是立即的而是隔了几个函数才冒出来容易误判。3. 服务端socket、bind、listen、accept 七步模型与完整代码3.1 服务端生命周期从创建到清理的七步一个 TCP 服务端生命周期可以拆成七步每一步对应一到两个 API 调用。下面这张表是按执行顺序整理的建议背下来当检查清单用步骤调用作用1WSAStartup初始化 Winsock协商版本2socket创建 TCP 套接字句柄3bind把套接字绑定到本地 IP 和端口4listen进入监听状态开始接收连接请求5accept接受一个客户端连接返回新的套接字6recv/send用新套接字收发数据7closesocket WSACleanup关闭套接字、反初始化这里有个新手最容易混的点accept 返回的套接字和监听套接字是两个不同的句柄。监听套接字负责继续接收后续连接accept 返回的套接字只和某个具体客户端通信。拿监听套接字去 recv 永远收不到数据这是看着对、跑起来错的典型。七步里 bind 到 accept 是服务端特有的客户端完全没有这几步。理解这个差异后面的排错会轻松很多——报错发生在哪一步就说明哪一步之前的条件没满足。3.2 可运行的服务端代码与逐行解释下面是一份能直接编译运行的 TCP 服务端功能是等一个客户端连上来收一条消息回一句问候然后退出#include winsock2.h #include stdio.h #include string.h #pragma comment(lib, ws2_32.lib) int main(void) { WSADATA wsaData; SOCKET serverSocket; SOCKET clientSocket; SOCKADDR_IN serverAddr; SOCKADDR_IN clientAddr; int addrLen sizeof(clientAddr); char recvBuf[1024]; int recvLen; const char *reply Hello from server; int ret; ret WSAStartup(MAKEWORD(2, 2), wsaData); if (ret ! 0) { printf(WSAStartup failed: %d\n, ret); return -1; } serverSocket socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (serverSocket INVALID_SOCKET) { printf(socket failed: %d\n, WSAGetLastError()); WSACleanup(); return -1; } serverAddr.sin_family AF_INET; serverAddr.sin_addr.s_addr htonl(INADDR_ANY); serverAddr.sin_port htons(5000); if (bind(serverSocket, (struct sockaddr *)serverAddr, sizeof(serverAddr)) SOCKET_ERROR) { printf(bind failed: %d\n, WSAGetLastError()); closesocket(serverSocket); WSACleanup(); return -1; } if (listen(serverSocket, 5) SOCKET_ERROR) { printf(listen failed: %d\n, WSAGetLastError()); closesocket(serverSocket); WSACleanup(); return -1; } printf(Server listening on port 5000...\n); clientSocket accept(serverSocket, (struct sockaddr *)clientAddr, addrLen); if (clientSocket INVALID_SOCKET) { printf(accept failed: %d\n, WSAGetLastError()); closesocket(serverSocket); WSACleanup(); return -1; } printf(Client connected: %s:%d\n, inet_ntoa(clientAddr.sin_addr), ntohs(clientAddr.sin_port)); recvLen recv(clientSocket, recvBuf, sizeof(recvBuf) - 1, 0); if (recvLen 0) { recvBuf[recvLen] \0; printf(Received: %s\n, recvBuf); send(clientSocket, reply, (int)strlen(reply), 0); } closesocket(clientSocket); closesocket(serverSocket); WSACleanup(); return 0; }逻辑说明socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) 三个参数分别是地址族、套接字类型、协议。AF_INET 表示 IPv4SOCK_STREAM 表示流式TCP第三个参数在类型和地址族确定后其实可以传 0显式写 IPPROTO_TCP 是为了让代码直白。bind 把套接字和本地地址绑定htonl(INADDR_ANY) 表示绑定本机所有网卡htons(5000) 把主机字节序的端口号转成网络字节序——这两个转换函数是 Winsock 的固定动作写反了端口就错了。accept 是阻塞调用没有客户端连接时一直停在那里。它返回的 clientSocket 是新套接字后面所有收发都走它。recv 读数据时留一个字节位置给末尾 \0方便按字符串打印send 的第三个参数是字节数用 strlen 计算时不包含结尾空字符这个细节容易写错。3.3 端口、backlog、缓冲区三个参数怎么设端口号示例用 5000。原则是避开 1024 以下的系统保留端口在 1024 到 65535 之间选一个固定值方便联调。端口被别的进程占用时bind 返回 10048这是出现频率最高的服务端报错。listen 的第二个参数 5 是 backlog表示内核中已完成三次握手、等待 accept 取走的连接队列长度。单客户端实验设 5 足够生产环境常见设 10或传 SOMAXCONN 让系统决定。backlog 设太大会白占内存设 0 或太小会让并发连接在建立阶段就被拒绝客户端看到的是连接被重置。recvBuf 用 1024 字节能覆盖短消息场景。要提醒的是 TCP 是字节流协议recv 返回的是当前内核缓冲区里可读的数据量不是一个完整消息。对方发 2000 字节你可能一次 recv 全收到也可能分两次各收 1000完全由网络状态决定。演示代码不处理这个问题真实项目必须有消息边界约定最后一章给方案。4. 客户端connect 建立连接的最小代码与联调方法4.1 客户端流程不需要 bind 和 listen客户端流程比服务端短WSAStartup → socket → connect → send/recv → closesocket → WSACleanup。客户端不需要 bind因为操作系统会在 connect 时自动分配一个临时端口不需要 listen 和 accept因为方向反过来了是客户端主动找服务端。connect 的参数是关键目标 IP 和端口必须和服务端 bind 的一致。IP 用 inet_addr 把 127.0.0.1 字符串转成网络字节序的 32 位整数。127.0.0.1 是本机回环地址适合单机联调跨机器调试时改成服务端机器的实际 IP。connect 也是阻塞调用服务端没启动时它会失败并返回 10061不会一直等。这个错误码基本可以直译成目标端口没人监听排查方向非常明确。4.2 可运行的客户端代码与逐行解释#include winsock2.h #include stdio.h #include string.h #pragma comment(lib, ws2_32.lib) int main(void) { WSADATA wsaData; SOCKET clientSocket; SOCKADDR_IN serverAddr; char recvBuf[1024]; int recvLen; const char *msg Hello from client; int ret; ret WSAStartup(MAKEWORD(2, 2), wsaData); if (ret ! 0) { printf(WSAStartup failed: %d\n, ret); return -1; } clientSocket socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (clientSocket INVALID_SOCKET) { printf(socket failed: %d\n, WSAGetLastError()); WSACleanup(); return -1; } serverAddr.sin_family AF_INET; serverAddr.sin_addr.s_addr inet_addr(127.0.0.1); serverAddr.sin_port htons(5000); if (connect(clientSocket, (struct sockaddr *)serverAddr, sizeof(serverAddr)) SOCKET_ERROR) { printf(connect failed: %d\n, WSAGetLastError()); closesocket(clientSocket); WSACleanup(); return -1; } printf(Connected to server\n); send(clientSocket, msg, (int)strlen(msg), 0); recvLen recv(clientSocket, recvBuf, sizeof(recvBuf) - 1, 0); if (recvLen 0) { recvBuf[recvLen] \0; printf(Server reply: %s\n, recvBuf); } closesocket(clientSocket); WSACleanup(); return 0; }逻辑说明客户端特有的一步只有 connect前面初始化、建 socket 和服务端完全一样。connect 的第二个参数要强转成 struct sockaddr *这是 Winsock 的设计——不同地址族共用一套接口靠结构体里的 sin_family 区分类型。强转不是可有可无的语法而是接口规定的用法。send 之后立刻 recv是因为演示协议是客户端先说、服务端再回的一问一答。真实项目里 recv 不一定能立刻等到数据阻塞模式会一直等这时候界面就假死了。处理思路在避坑章节和最后一章展开。4.3 在 VC6.0 里同时跑服务端和客户端联调步骤编译完两个工程后联调顺序不能乱先运行服务端看到输出 Server listening on port 5000...确认监听成功。再运行客户端客户端打印 Connected to server。服务端窗口打印收到的 Hello from client客户端窗口打印 Server reply: Hello from server。打开另一个命令提示符窗口执行 netstat -ano | findstr 5000能看到服务端处于 LISTENING两端连接处于 ESTABLISHED。两个 exe 可以放在不同目录一个从 VC6 里启动、一个直接双击互不影响。顺序不能反客户端先跑必然 10061。提示本机回环 127.0.0.1 联调不会触发防火墙拦截但跨机器测试时服务端机器的 Windows 防火墙第一次运行会弹提示要点允许否则客户端能 ping 通却 TCP 连不上。客户端连不上时第一个动作永远是 netstat 确认端口在监听而不是翻代码。我习惯把端口固定写成一个宏或常量两边改成同一个值避免端口不一致这种低级问题反复出现。5. VC6.0 Socket 避坑5 个高频报错与排查记录5.1 链接失败ws2_32.lib 没进工程现象代码看着没问题编译也通过链接时报一堆 unresolved external symbol __imp__socket12、__imp__bind12 之类的错误。原因socket 系列函数在 ws2_32.lib 导入库里工程没有引入这个库链接器找不到函数实现。这是所有 Winsock 新手遇到的第一个报错也是最容易懵的——错误信息里那一串imp前缀看着吓人实际意思就是链接器没找到 socket 的导入定义。解决在源文件顶部加#pragma comment(lib, ws2_32.lib)这一行在 VC6.0 里被完整支持不需要再去改工程设置。如果加了还报错检查是不是 pragma 写在了被注释掉的区域里或者用了弱引用写法导致没生效。5.2 编译报宏重定义winsock.h 与 winsock2.h 撞车现象include 了 winsock2.h后来又 include 了 windows.h编译报一堆宏重定义错误比如 FD_SET、select 相关定义重复。原因windows.h 内部默认会包含 winsock.h1.1 版头文件和 winsock2.h 里的宏、类型定义冲突。VC6.0 的 SDK 里两个头文件共存同时引入必然撞车。解决最稳妥的是在包含任何头文件之前先定义 WIN32_LEAN_AND_MEAN它会禁止 windows.h 引入网络相关的老声明或者保证 winsock2.h 在 windows.h 之前被 include。纯控制台程序只需要 stdio.h 和 winsock2.h一般遇不到这个坑但工程一旦混用窗口代码或 MFC这个问题立刻出现。5.3 bind 报 10048端口被占用或 TIME_WAIT现象服务端第一次运行正常关掉再运行bind 返回 10048提示地址已被使用。原因上一个服务端进程退出后连接进入 TIME_WAIT 状态端口在几十秒内不会完全释放或者端口正被另一个程序占用。这是服务端重启场景里最有名的玄学问题明明进程都关了端口还是占着。解决等几十秒重试、换个端口或者最干净的方案——在 bind 之前设置地址重用int optval 1; setsockopt(serverSocket, SOL_SOCKET, SO_REUSEADDR, (const char *)optval, sizeof(optval));这段代码必须在 bind 之前执行。我一般在创建 socket 后立即设置这样服务端重启就能立刻复用端口不用每次手动等。注意 setsockopt 的第四个参数类型是 const char*在 VC6 里直接传 optval 也能编译但严格写法是强转避免不同编译器行为差异。5.4 connect 报 10061服务端没启动或端口不一致现象客户端调用 connect 返回 10061连接被拒绝。原因10061 就是 WSAECONNREFUSED目标端口上没有进程在监听。常见三个原因服务端还没启动、两边端口号不一致、服务端 listen 的地址和客户端连的地址对不上。解决先确认服务端窗口有 Server listening 输出再检查两边的端口常量最后在服务端机器用 netstat -ano | findstr 5000 确认监听地址。如果服务端 bind 用了 INADDR_ANY客户端连哪个网卡 IP 都行如果 bind 指定了具体 IP客户端只能连那个 IP填别的就是 10061。5.5 recv 卡死主线程阻塞模式不是万能的现象服务端 accept 成功后程序停住不动窗口看起来像死机或者客户端 send 后一直等不到回复整个界面失去了响应。原因默认套接字是阻塞模式recv 没有数据可读时会一直挂起。演示代码只 recv 一次逻辑上没问题但真实程序里主线程一旦阻塞在 recv消息循环也停了整个程序失去响应。解决演示程序可以接受真实项目要把收发挪出主线程。最简单的线程模型是 accept 后创建一个子线程把这个 clientSocket 交给线程函数去循环 recv主线程回到 accept 继续等下一个连接也可以用 ioctlsocket 把套接字设为非阻塞配合 select 检测可读性。入门阶段先保证阻塞模型跑通再拆线程不要两件事混在一次调试里。6. 进阶用长度头解决 recv 分包收消息不再靠运气6.1 粘包与半包一次 recv 不等于一条消息演示代码里为什么能一次收完整因为本机回环 小消息 没有拥塞内核把整段数据放进了缓冲区。真实网络里对方 send 的两条消息可能被一次 recv 全收下粘包也可能一条消息分成几次 recv 才读完半包。TCP 只保证字节顺序不保证消息边界这是它作为字节流协议的固有属性。常见做法是给每条消息加长度头约定为前 4 字节用网络字节序表示消息体长度后面跟消息体。先 recv 长度头再按长度把消息体读满。对应的接收函数int RecvFull(SOCKET s, char *buf, int len) { int total 0; int n; while (total len) { n recv(s, buf total, len - total, 0); if (n 0) { return -1; } total n; } return total; }逻辑说明这个函数解决recv 一次收不满的问题。len 是期望读到的字节数循环 recv每次把已读到的偏移量向后推直到读满。返回 -1 表示连接断开或出错调用方据此关闭套接字。参数说明buf 是接收缓冲len 是期望长度。配合长度头使用时先调 RecvFull 读 4 字节头部用 ntohl 转回主机序得到 bodyLen再调 RecvFull(s, body, bodyLen) 拿完整消息体。建议给消息体长度设上限比如 64KB防止异常消息把内存吃爆。6.2 验证顺序回环、netstat、跨机器一步都不能跳我的验证习惯固定三步。第一步本机 127.0.0.1 跑通服务端和客户端第二步netstat -ano | findstr 5000 确认 LISTENING 和 ESTABLISHED 状态这一步能排除程序在跑但根本没监听的假象第三步才跨机器先 ping 通再在客户端机器上 telnet 服务端 IP 端口确认 TCP 层通。防火墙是跨机器第一道坎弹窗允许后还连不上就查服务端网卡 IP 是否和客户端填的一致。写网络程序这些年的教训浓缩成一句永远假设 recv 不完整永远别赌单次调用能收全。长度头方案多写几行换来的是省掉几个小时的分包排查。把服务端、客户端跑通再把消息边界处理好这套 VC6.0 的 Socket 基本功就算真正过关了。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进