ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

VC6.0下Socket编程实战:服务端与客户端源码解析

VC6.0下Socket编程实战:服务端与客户端源码解析

简介:这份资源是面向VC6.0初学者的Socket编程入门示例,包含一个可运行的服务器端与客户端配套工程,帮助刚接触网络编程的开发者理解TCP通信的基本流程与代码组织方式。压缩包共27个文件,约1.58MB,以cpp源码、dsp/dsw工程文件、exe可执行程序为主,另含pdb、obj、ilk等调试与编译中间文件,以及一份txt说明,工程结构完整,可直接在VC6.0中打开编译运行。资源已有855人学习下载,说明其在入门场景中具有一定参考价值。读者可从中获得一套最小可用的Socket通信范例,对照服务器端与客户端两份工程,观察连接建立、数据收发与工程配置的对应关系,并借助调试文件排查编译与运行问题,适合作为课程实验或自学练手的起点。

1. 老编译器里的网络编程:VC6.0 下 Socket 服务端与客户端到底能跑出什么

现在打开一台新机器,装个现代 IDE 写 Socket 通信,几乎是十分钟的事。但如果你手上维护的是一套老工控上位机、教学实验箱,或者某个只认 VC6.0 的遗留项目,事情就完全不一样了:编译器是 1998 年的,C++ 标准停在 C++98 之前,连<cstdint>都没有,winsock2.h和windows.h的包含顺序能让你编译报几十个重定义错误。这套 VC6.0 Socket 服务端加客户端的源码包,解决的正是这个场景——它把 TCP 服务端监听、客户端连接、收发数据、连接关闭这一整条链路,用 VC6.0 能直接编译的写法固定下来,不依赖任何第三方网络库,只用 Winsock 2。适合谁?适合要在一台装着 VC6.0 的机器上快速验证 C/S 通信、要拿一份能跑通的最小骨架去改造成自己业务协议的人,也适合刚接触网络编程、想看清阻塞式 Socket 每一步在干什么的新手。下面我按「先讲清 Winsock 在 VC6.0 里的初始化逻辑,再拆服务端和客户端代码,最后说坑」的顺序走一遍。

2. Winsock 2 在 VC6.0 里的初始化:链接、头文件与阻塞模型

2.1 为什么必须先 WSAStartup,而不是直接 socket()

很多人第一次在 VC6.0 里写socket(),编译能过,一运行就返回INVALID_SOCKET,WSAGetLastError()拿到 10093。这不是代码写错了,是 Winsock 动态库还没加载。Windows 下 Socket 不是操作系统内核直接暴露的系统调用,而是一层由ws2_32.dll提供的接口,进程必须先调用WSAStartup协商版本,Winsock 才会把函数表挂进来。VC6.0 的工程默认不会自动帮你做这件事,所以每个用到 Socket 的进程入口都得自己写。

常见做法是在main或WinMain最前面做一次初始化,退出前WSACleanup。版本号用MAKEWORD(2, 2),请求 Winsock 2.2。这里有个细节:MAKEWORD(2,2)请求的是 2.2,但实际协商出来的版本可能更低,wsaData.wVersion会告诉你真实值。如果机器上只有 1.1,WSAStartup依然返回 0,但后续用 2.x 专有特性会失败。所以严格点应该校验一下协商结果。

#include <winsock2.h> #include <windows.h> #pragma comment(lib, "ws2_32.lib") int main() { WSADATA wsaData; // 请求 Winsock 2.2 int ret = WSAStartup(MAKEWORD(2, 2), &wsaData); if (ret != 0) { printf("WSAStartup failed: %d\n", ret); return -1; } // 校验协商出的版本,防止只拿到 1.1 if (LOBYTE(wsaData.wVersion) != 2 || HIBYTE(wsaData.wVersion) != 2) { printf("Winsock 2.2 not available\n"); WSACleanup(); return -1; } // ... 业务代码 ... WSACleanup(); return 0; }

逻辑说明:WSAStartup的第一个参数是请求版本,第二个参数是输出结构,里面wVersion是协商结果、wHighVersion是库支持的最高版本。LOBYTE取低字节是主版本,HIBYTE取高字节是次版本。#pragma comment(lib, "ws2_32.lib")是 VC6.0 支持的写法,等价于在工程设置里手动加库,省得每个新建工程都去 Link 选项卡里翻。参数上唯一要改的是版本号,如果你的目标机器很老、只保证 1.1,就把MAKEWORD(2,2)换成MAKEWORD(1,1),但那样getaddrinfo之类的新接口就用不了,得退回gethostbyname。

2.2 头文件包含顺序:winsock2.h 必须在 windows.h 之前

这是 VC6.0 时代最经典的翻车点。windows.h内部会包含winsock.h(1.1 版),如果你先包含windows.h再包含winsock2.h,两套定义撞在一起,编译器会甩出一堆redefinition和conflicting types。正确顺序永远是winsock2.h在前,windows.h在后。如果某个第三方头文件间接包含了windows.h,你可以在包含winsock2.h之前定义WIN32_LEAN_AND_MEAN,把windows.h里的一堆可选组件裁掉,减少冲突面。

#define WIN32_LEAN_AND_MEAN #include <winsock2.h> // 必须先于 windows.h #include <windows.h>

这个宏不是万能的,它只是减少windows.h的膨胀,不改变包含顺序的硬规则。我一般会在工程里建一个net_common.h,把这两行和ws2_32.lib的 pragma 固定进去,所有源文件只包含这一个头,避免有人在某个 .cpp 里写反了顺序导致整个工程编译不过。

2.3 阻塞模型的选择理由

这份源码用的是阻塞式 Socket,也就是accept、recv、send默认都是阻塞的。为什么不用非阻塞或 IOCP?因为在 VC6.0 的教学和轻量工具场景里,阻塞模型的代码路径最短、最容易看懂,一个服务端一个客户端、连接数个位数的时候完全够用。代价是:accept会卡住整个线程,recv没数据时也会卡住,所以服务端如果要同时处理多个客户端,就得给每个连接开线程,或者用select做多路复用。源码包给的是单连接骨架,改造多连接时这是第一个要动的地方。选型上没有对错,只有场景匹配:你要的是「能跑通、能改」,不是「高并发」。

3. 服务端骨架:从 socket 到 accept 的完整链路

3.1 创建、绑定、监听三步的参数怎么填

服务端的固定套路是socket→bind→listen→accept。每一步的返回值都要判INVALID_SOCKET或SOCKET_ERROR,VC6.0 不会帮你抛异常,错一步后面全是连锁失败。socket的三个参数:地址族AF_INET、类型SOCK_STREAM(TCP)、协议IPPROTO_TCP(也可以填 0 让系统根据类型推断)。bind需要一个sockaddr_in结构,sin_family填AF_INET,sin_port用htons转网络字节序,sin_addr.s_addr填INADDR_ANY表示监听本机所有网卡。listen的第二个参数是等待队列长度,填SOMAXCONN让系统用默认最大值。

SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listenSock == INVALID_SOCKET) { printf("socket failed: %d\n", WSAGetLastError()); return -1; } sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(9527); // 端口按需改,避开系统占用段 addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡 if (bind(listenSock, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { printf("bind failed: %d\n", WSAGetLastError()); closesocket(listenSock); return -1; } if (listen(listenSock, SOMAXCONN) == SOCKET_ERROR) { printf("listen failed: %d\n", WSAGetLastError()); closesocket(listenSock); return -1; }

参数说明:端口选 9527 只是示例,实际要避开 1024 以下的系统保留段和已知服务端口,改端口只动htons里那个数字。INADDR_ANY换成inet_addr("192.168.1.10")就只绑某块网卡,多网卡机器上这个区别很关键——绑错了客户端连不上,但bind本身不报错。memset清零不能省,sockaddr_in里有填充字节,不清零在某些环境下bind会返回 10049。closesocket在失败分支里必须调,否则句柄泄漏,反复启动几次就耗尽。

3.2 accept 之后:收发循环与关闭顺序

accept返回一个新的SOCKET,代表这条具体连接,原来的listenSock继续监听。收发用recv和send,注意recv返回 0 表示对端正常关闭,返回SOCKET_ERROR才是出错。TCP 是字节流,没有消息边界,所以recv一次读到的长度不保证等于对方send的长度,业务层要么定长、要么加长度头、要么用分隔符,这是协议设计的事,骨架里只做透传。

sockaddr_in clientAddr; int addrLen = sizeof(clientAddr); SOCKET clientSock = accept(listenSock, (sockaddr*)&clientAddr, &addrLen); if (clientSock == INVALID_SOCKET) { printf("accept failed: %d\n", WSAGetLastError()); closesocket(listenSock); return -1; } printf("client connected: %s:%d\n", inet_ntoa(clientAddr.sin_addr), ntohs(clientAddr.sin_port)); char buf[1024]; while (true) { int n = recv(clientSock, buf, sizeof(buf), 0); if (n == 0) // 对端关闭 break; if (n == SOCKET_ERROR) { printf("recv failed: %d\n", WSAGetLastError()); break; } send(clientSock, buf, n, 0); // 原样回显,验证链路 } closesocket(clientSock); // 先关连接 closesocket(listenSock); // 再关监听

逻辑说明:inet_ntoa返回的是静态缓冲区指针,多线程下不可重入,单线程骨架里没问题,改造多线程时要换成inet_ntoa的副本或InetNtop(VC6.0 没有,得自己转)。关闭顺序是先clientSock后listenSock,反过来的话正在处理的连接可能被强制中断。send的返回值也要判,虽然骨架里省略了,但真实业务里send可能只发出去一部分,需要循环发直到发完,这个坑在第 5 章细说。

3.3 把服务端改成能接多个客户端的最小改法

单连接骨架跑通后,最常见的需求是「同时接两个客户端」。最省事的改法是把accept之后的收发逻辑抽成一个函数,每次accept成功就CreateThread开一个线程跑这个函数,主线程回到accept继续等。VC6.0 的CreateThread签名和现在一样,线程函数是DWORD WINAPI。要注意线程里用的clientSock必须按值传进去,不能传栈上变量的地址,否则主循环下一轮accept会覆盖它。更稳的做法是new一个结构体把SOCKET和客户端地址打包传进去,线程结束时delete。这套改造不复杂,但它是从「玩具」到「能用」的分水岭,源码包给的单连接版本正好是改造的起点。

4. 客户端骨架:连接、超时与地址解析

4.1 connect 的阻塞特性与超时控制

客户端比服务端少一步bind和listen,直接socket→connect→ 收发。但connect在阻塞模式下有个让人难受的特性:对端不可达时,它可能卡几十秒才返回失败,具体时长取决于系统的 TCP 重传策略。VC6.0 没有现成的连接超时参数,常见做法是把 Socket 设成非阻塞,connect立刻返回WSAEWOULDBLOCK,然后用select等可写事件,配合timeval控制等待上限,超时就判失败。

SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (sock == INVALID_SOCKET) return -1; // 设为非阻塞,让 connect 立即返回 u_long mode = 1; ioctlsocket(sock, FIONBIO, &mode); sockaddr_in serverAddr; memset(&serverAddr, 0, sizeof(serverAddr)); serverAddr.sin_family = AF_INET; serverAddr.sin_port = htons(9527); serverAddr.sin_addr.s_addr = inet_addr("127.0.0.1"); connect(sock, (sockaddr*)&serverAddr, sizeof(serverAddr)); fd_set writeSet; FD_ZERO(&writeSet); FD_SET(sock, &writeSet); timeval tv; tv.tv_sec = 3; // 连接超时 3 秒 tv.tv_usec = 0; int sel = select(0, NULL, &writeSet, NULL, &tv); if (sel <= 0) { printf("connect timeout or error\n"); closesocket(sock); return -1; } // 连上后恢复阻塞模式,后续收发按阻塞处理 mode = 0; ioctlsocket(sock, FIONBIO, &mode);

参数说明:ioctlsocket的FIONBIO命令配u_long变量,1 是非阻塞、0 是阻塞。select第一个参数在 Windows 下被忽略,填 0 即可。timeval的tv_sec就是超时秒数,改这个数字控制等待上限。select返回大于 0 只说明「可写」,不代表连接一定成功,严格点还要用getsockopt查SO_ERROR确认。这段代码比直接阻塞connect长,但换来的是可控的失败时间,做工具类程序时这个体验差别很大。

4.2 用 inet_addr 还是 gethostbyname

inet_addr("127.0.0.1")只认点分十进制字符串,填域名直接返回INADDR_NONE。如果客户端要支持填主机名,就得用gethostbyname,它返回hostent结构,h_addr_list[0]是第一个解析到的地址。VC6.0 里gethostbyname是阻塞的,解析慢的域名会卡住,而且它返回的是静态数据,多线程下不安全。轻量工具里单线程用没问题,但要知道这个边界。

hostent* host = gethostbyname("localhost"); if (host == NULL) { printf("resolve failed: %d\n", WSAGetLastError()); return -1; } memcpy(&serverAddr.sin_addr, host->h_addr_list[0], host->h_length);

h_length通常是 4(IPv4),memcpy比直接赋值安全,因为h_addr_list[0]的类型是char*。这段只做地址填充,sin_family和sin_port还是按前面的方式设。实际项目里我一般把「是不是纯 IP」判断一下,是 IP 走inet_addr,不是再走gethostbyname,省掉不必要的解析开销。

4.3 客户端收发的对称写法

客户端的send和recv跟服务端完全对称,区别只在业务语义。骨架里客户端发一条、收一条,验证回显是否正确。这里要提醒的是send之后不要假设对方立刻能收到,TCP 有缓冲,send返回成功只代表数据进了本机发送缓冲区。要确认对方收到,得靠应用层应答,这是协议层的事,不是 Socket API 能保证的。源码包里的回显逻辑就是最朴素的「发什么收什么」,拿它当连通性测试足够,当业务协议还得自己加帧头。

5. 避坑与排查:VC6.0 Socket 最容易翻车的五个地方

5.1 编译报 winsock2.h 与 winsock.h 冲突

现象:一堆redefinition of struct sockaddr之类的错误,指向winsock2.h和winsock.h。原因:包含顺序反了,或者某个第三方头间接拉了windows.h。解决:把winsock2.h提到所有包含之前,加WIN32_LEAN_AND_MEAN,再检查工程里有没有别的 .cpp 单独包含了windows.h而没走统一头文件。这个错误在 VC6.0 里几乎每个新手都会撞一次,撞过就记住了。

5.2 运行时报 10093,WSAStartup 没调或调晚了

现象:socket()返回INVALID_SOCKET,WSAGetLastError()是 10093(WSANOTINITIALISED)。原因:进程里没有任何地方调WSAStartup,或者调用了但在socket之后。解决:把初始化放到main第一行,所有 Socket 调用之前。如果是 MFC 程序,放在InitInstance里,别放在某个按钮响应里——按钮可能被点多次,WSAStartup重复调用虽然引用计数会加,但配对漏掉WSACleanup就会泄漏。

5.3 bind 返回 10048,端口被占用

现象:服务端启动失败,错误码 10048(WSAEADDRINUSE)。原因:上一次进程没退干净,端口还在TIME_WAIT,或者别的程序占了这个端口。解决:换端口是最快的;想复用端口就在bind之前设SO_REUSEADDR,用setsockopt把SO_REUSEADDR置 1。注意SO_REUSEADDR在 Windows 上的语义和某些 Unix 不同,它允许绑定到已被占用的地址,可能带来意料之外的连接分配,生产环境慎用,调试阶段图方便可以开。

int opt = 1; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, (char*)&opt, sizeof(opt));

5.4 recv 返回 0 被当成出错

现象:客户端正常关闭后,服务端日志打出一堆「recv failed」,或者循环不退出。原因:把recv返回 0 和返回SOCKET_ERROR混为一谈。recv返回 0 是对端发了 FIN,属于正常关闭,应该break出循环并closesocket;只有返回SOCKET_ERROR才是真错误。解决:按第 3 章代码里的分支写,n == 0和n == SOCKET_ERROR分开处理。这个坑的隐蔽性在于,单机测试时你手动关客户端,服务端看起来「卡住」了,其实是循环没退。

5.5 send 只发出去一部分,大包被截断

现象:发几 KB 的小数据正常,发几百 KB 的文件时对方收到的长度对不上。原因:send的返回值是「本次实际写入发送缓冲区的字节数」,不等于你传入的长度,缓冲区满时它只写一部分就返回。解决:循环send直到累计发完,每次指针偏移、长度递减。recv同理,一次读到的可能少于请求长度,业务层要按协议自己拼包。骨架里数据小,这个问题不暴露,一旦上真实数据量就是血泪经验。

int total = 0; while (total < dataLen) { int n = send(sock, data + total, dataLen - total, 0); if (n == SOCKET_ERROR) { /* 处理错误并退出 */ break; } total += n; }

6. 进阶验证:用抓包和错误码把链路钉死

骨架跑通只是第一步,真正要确认「这条连接到底通没通、数据对不对」,我一般会做两件事:抓包看三次握手和字节流,以及把每个 API 的错误码打全。抓包用系统自带的网络监视工具或者任意一款能看 TCP 流的抓包软件,过滤端口 9527,看SYN、SYN-ACK、ACK是否完整,send出去的数据在抓包里是不是原样出现。这一步能区分「代码问题」和「网络问题」——如果抓包里根本没有SYN,那connect之前就错了;如果有SYN没SYN-ACK,是对端没监听或被防火墙拦了。

错误码这块,VC6.0 的WSAGetLastError返回的是数字,查表麻烦,我习惯在工程里放一个小函数把常见码翻译成中文,比如 10048 端口占用、10060 连接超时、10054 对端重置、10053 本机中止。日志里带上错误码和出错的那一步,排查时不用猜。下面这个表是我常用的对照,放在net_common.h里随用随查:

错误码宏典型场景
10048WSAEADDRINUSEbind 时端口被占
10049WSAEADDRNOTAVAILbind 的地址不属于本机
10060WSAETIMEDOUTconnect 超时
10054WSAECONNRESET对端强制关闭,连接被重置
10053WSAECONNABORTED本机协议栈中止连接
10093WSANOTINITIALISED没调 WSAStartup

还有一个验证技巧:把服务端和客户端分别跑在两台机器上,而不是本机回环。本机127.0.0.1走的是回环接口,很多网卡、防火墙、地址绑定的问题都被掩盖了,一到真实网络就暴露。我吃过这个亏——本机测了一下午都正常,换到两台机器上bind的INADDR_ANY没问题但客户端填错了服务端 IP,抓包一看SYN发到了错误的地址。从那以后我每次交付 Socket 相关的东西,都强制在两台机器上各跑一遍,先ping通、再抓包确认握手、最后才看业务数据。希望这套骨架和这些排查习惯能帮到你,少走几段我当年走过的弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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