ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

VC6.0 Socket编程实战:TCP服务器与客户端源码及避坑指南

VC6.0 Socket编程实战:TCP服务器与客户端源码及避坑指南

简介:这份资源是面向VC6.0初学者的Socket编程入门示例,包含一个可运行的服务器端与客户端配套工程,帮助刚接触网络编程的开发者理解TCP通信的基本流程与代码组织方式。压缩包共27个文件,约1.58MB,以cpp源码、dsp/dsw工程文件、exe可执行程序为主,另含pdb、obj、ilk等调试与编译中间文件,以及一份txt说明,工程结构完整,可直接在VC6.0中打开编译运行。资源已有855人学习下载,说明其在入门群体中具有一定参考价值。读者可从中获得一套现成的双端通信代码框架,对照服务器端与客户端各自的工程配置、源码实现和调试产物,快速弄清Socket创建、连接建立与数据收发的关键环节,减少初次搭建环境时的摸索成本,适合作为课程实验或自学练手的起点。

1. VC6.0 里手搓 Socket 双端:一份能直接编译的服务器加客户端源码包

手上还有一堆 VC6.0 时代的老工控机、老检测设备要维护,这类现场最尴尬的地方在于:机器上只有 VC6.0,没有现代 IDE,也没有现成的网络库,但需求偏偏是「让上位机把采集数据发出去、让另一台机器收回来」。这份 VC6.0 Socket 服务器加客户端源码包,解决的就是这个场景——用最原始的 Winsock 接口,在 VC6.0 里把 TCP 服务端和客户端各写一份,能编译、能跑通、能改。它适合两类人:一类是被老项目绑死、必须用 VC6.0 交差的从业者;另一类是想搞明白 Socket 到底怎么从socket()一步步走到accept()和connect()的学习者。下面按「先立原理、再动手、最后说坑」的顺序拆开讲,代码和参数都能直接抄。

2. Winsock 初始化与工程配置:VC6.0 下最容易翻车的第一步

VC6.0 的 Socket 编程和现在差别最大的不是 API,而是工程配置。现代 IDE 里#pragma comment(lib, "ws2_32.lib")一行搞定,VC6.0 虽然也认这行,但很多人习惯去 Project Settings 里手动加库,加错了路径或者加错了配置(Debug 加了 Release 没加),编译能过、链接报一堆unresolved external symbol。这一章把初始化和工程配置讲透,后面两章的代码才有落脚点。

2.1 为什么必须先调 WSAStartup

Windows 下的 Socket 不是操作系统内核直接暴露的,而是通过 Winsock DLL 提供。进程启动时这个 DLL 还没加载,所以任何 Socket 调用之前必须先WSAStartup,告诉系统「我要用哪个版本的 Winsock,请把对应的 DLL 加载进来」。VC6.0 时代主流是 Winsock 2.2,请求版本用MAKEWORD(2,2)。请求成功后,进程退出前必须WSACleanup,否则 DLL 引用计数不归零,反复调试时会出现资源泄漏,表现为跑几十次之后socket()开始返回INVALID_SOCKET,这种玄学问题十有八九就是忘了清理。

初始化代码我一般封装成一个函数,服务端客户端共用:

#include <winsock2.h> #include <stdio.h> #pragma comment(lib, "ws2_32.lib") // VC6.0 也认这行,省去手动加库 // 返回值:0 成功,非 0 失败 int InitWinsock() { WSADATA wsaData; int ret = WSAStartup(MAKEWORD(2, 2), &wsaData); if (ret != 0) { printf("WSAStartup failed: %d\n", ret); return ret; } // 校验实际协商到的版本,防止系统只给了 1.1 if (LOBYTE(wsaData.wVersion) != 2 || HIBYTE(wsaData.wVersion) != 2) { printf("Winsock version mismatch\n"); WSACleanup(); return -1; } return 0; }

MAKEWORD(2,2)的低字节是主版本、高字节是次版本,LOBYTE/HIBYTE就是取这两个字节做校验。很多示例代码省掉版本校验,在只装了 Winsock 1.1 的老系统上会拿到一个「成功」但行为不一致的环境,后面getaddrinfo之类的新 API 直接不可用。校验这一步不费事,建议保留。

2.2 VC6.0 工程里链接 ws2_32.lib 的两种方式

第一种就是上面代码里的#pragma comment(lib, "ws2_32.lib"),编译器看到这行会自动把库塞进链接命令行,最省心。第二种是菜单路径:Project → Settings → Link 选项卡 → Object/library modules 里追加ws2_32.lib。第二种方式有个血泪经验:VC6.0 的工程配置是分 Debug 和 Release 两套的,你在 Debug 下加了库,切到 Release 编译照样报链接错误,因为 Release 的配置是独立的。所以要么两套都加,要么干脆用#pragma,让代码自己带库,换机器换配置都不丢。

还有一个 VC6.0 特有的坑:新建工程时如果选了「Win32 Console Application」但没勾「A simple application」,生成的是空工程,没有main入口,链接会报unresolved external symbol _main。这不是 Socket 的问题,但新手经常误判成库没加对。确认工程里有main函数,或者用「A simple application」模板起步。

2.3 头文件顺序与 winsock2.h 冲突

VC6.0 自带的老windows.h里可能间接包含winsock.h(1.1 版本),如果你又手动#include <winsock2.h>,两个头文件的类型定义会打架,报redefinition或conflicting types。标准做法是把winsock2.h放在windows.h之前,或者定义WIN32_LEAN_AND_MEAN再包含windows.h,把网络相关的旧头挡掉:

#define WIN32_LEAN_AND_MEAN #include <windows.h> #include <winsock2.h>

顺序反了就会出现「明明代码没错却编译不过」的情况,这类问题在 VC6.0 里特别常见,因为它的头文件依赖关系比现代编译器松散,报错位置经常指不到真正的原因。

3. TCP 服务端实现:从 socket 到 accept 的完整链路

服务端的骨架是固定的五步:socket()创建监听套接字、bind()绑定地址端口、listen()进入监听、accept()接受连接、recv()/send()收发数据。每一步的返回值都要判,VC6.0 没有异常机制兜底,一个INVALID_SOCKET没判,后面全链路崩。这一章把服务端写完整,参数逐个说清。

3.1 创建、绑定与监听的三段式

SOCKET CreateServer(int port) { SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listenSock == INVALID_SOCKET) { printf("socket failed: %d\n", WSAGetLastError()); return INVALID_SOCKET; } // 允许端口复用,避免重启服务端时 bind 报 10048 BOOL opt = TRUE; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, (const char*)&opt, sizeof(opt)); sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡 addr.sin_port = htons((u_short)port); if (bind(listenSock, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { printf("bind failed: %d\n", WSAGetLastError()); closesocket(listenSock); return INVALID_SOCKET; } if (listen(listenSock, SOMAXCONN) == SOCKET_ERROR) { printf("listen failed: %d\n", WSAGetLastError()); closesocket(listenSock); return INVALID_SOCKET; } return listenSock; }

AF_INET是 IPv4 地址族,SOCK_STREAM是面向连接的字节流,IPPROTO_TCP明确协议,三个参数组合起来就是 TCP 套接字。INADDR_ANY表示不限网卡,工控机上有多块网卡时这个最省事;如果只想绑某块内网卡,把htonl(INADDR_ANY)换成inet_addr("192.168.x.x")即可。htons/htonl是主机字节序转网络字节序,端口和地址都必须转,漏了会出现「bind 成功但连不上」的怪现象。SO_REUSEADDR这个选项在调试期价值极高——服务端崩了重启,端口还处于 TIME_WAIT,不加这个选项bind直接返回 10048,加了就能立刻复用。

3.2 accept 循环与单客户端收发

void RunServer(SOCKET listenSock) { while (1) { sockaddr_in clientAddr; int addrLen = sizeof(clientAddr); SOCKET clientSock = accept(listenSock, (sockaddr*)&clientAddr, &addrLen); if (clientSock == INVALID_SOCKET) { printf("accept failed: %d\n", WSAGetLastError()); continue; // 单次失败不退出,继续等下一个 } printf("client connected: %s:%d\n", inet_ntoa(clientAddr.sin_addr), ntohs(clientAddr.sin_port)); char buf[1024]; int n; while ((n = recv(clientSock, buf, sizeof(buf) - 1, 0)) > 0) { buf[n] = '\0'; printf("recv %d bytes: %s\n", n, buf); send(clientSock, buf, n, 0); // 原样回显,验证链路 } if (n == 0) printf("client closed\n"); else printf("recv error: %d\n", WSAGetLastError()); closesocket(clientSock); } }

accept返回的是一个新的套接字,专门对应这条连接,监听套接字继续留着等下一个。recv返回 0 表示对端正常关闭,返回SOCKET_ERROR才是出错,这两种情况要分开处理,混在一起会把正常断开误报成故障。buf留一个字节给'\0',sizeof(buf)-1就是这个道理。回显逻辑是为了验证链路通不通,实际项目里把send换成业务处理即可。注意这个版本是单客户端串行处理,一个客户端连着不放,第二个连不上——这是有意的简化,多客户端要用多线程或select,放到最后一章说。

4. TCP 客户端实现:connect 超时与断线重连的处理

客户端比服务端少一步bind和listen,但多了connect的超时问题。默认的connect在目标不可达时会阻塞几十秒,界面直接假死,这在工控上位机里是致命的。这一章把客户端写完整,重点讲connect超时怎么控、断线怎么重连。

4.1 connect 与收发主循环

SOCKET ConnectServer(const char* ip, int port) { SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (sock == INVALID_SOCKET) return INVALID_SOCKET; sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons((u_short)port); addr.sin_addr.s_addr = inet_addr(ip); // 仅支持点分十进制 if (connect(sock, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { printf("connect failed: %d\n", WSAGetLastError()); closesocket(sock); return INVALID_SOCKET; } return sock; }

inet_addr只接受"192.168.1.10"这种点分十进制字符串,传域名会返回INADDR_NONE,需要域名解析得用gethostbyname。connect成功返回 0,失败返回SOCKET_ERROR,用WSAGetLastError()取具体错误码,10061 是连接被拒绝(服务端没开),10060 是超时(网络不通或防火墙拦了),这两个码在现场排查时最常遇到,记住能省不少时间。

4.2 用非阻塞 connect 加 select 控制超时

默认connect的超时由系统协议栈决定,改不了。要自己控超时,标准做法是把套接字设成非阻塞,connect立刻返回WSAEWOULDBLOCK,然后用select等可写事件,等到就是连上了,等不到就是超时:

SOCKET ConnectWithTimeout(const char* ip, int port, int timeoutMs) { SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (sock == INVALID_SOCKET) return INVALID_SOCKET; // 设为非阻塞 u_long mode = 1; ioctlsocket(sock, FIONBIO, &mode); sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons((u_short)port); addr.sin_addr.s_addr = inet_addr(ip); connect(sock, (sockaddr*)&addr, sizeof(addr)); // 预期返回 WSAEWOULDBLOCK fd_set writeSet, errSet; FD_ZERO(&writeSet); FD_SET(sock, &writeSet); FD_ZERO(&errSet); FD_SET(sock, &errSet); timeval tv; tv.tv_sec = timeoutMs / 1000; tv.tv_usec = (timeoutMs % 1000) * 1000; int ret = select(0, NULL, &writeSet, &errSet, &tv); if (ret <= 0) { closesocket(sock); return INVALID_SOCKET; // 超时或出错 } if (FD_ISSET(sock, &errSet)) { closesocket(sock); return INVALID_SOCKET; // 连接被拒 } // 恢复阻塞模式,后续 recv/send 按阻塞用 mode = 0; ioctlsocket(sock, FIONBIO, &mode); return sock; }

select的第一个参数在 Windows 下被忽略,填 0 即可(这是 Windows 和 Linux 的差异,Linux 要填最大 fd+1)。writeSet可写说明连接建立成功,errSet有事件说明连接失败,两个集合都要检查,只查可写会把「连接被拒」误判成成功。超时后必须closesocket,否则套接字泄漏。连上之后把非阻塞改回阻塞,后面收发逻辑就不用处理WSAEWOULDBLOCK了,简单直接。

4.3 断线检测与重连节奏

客户端跑在现场,网络抖动、服务端重启都会断线。recv返回 0 或SOCKET_ERROR就是断线信号,重连不能死循环猛连,会把服务端打挂。常见做法是退避重连:第一次等 1 秒,第二次 2 秒,第三次 4 秒,封顶 30 秒,连上后重置计数。这个节奏在工控场景够用,既不会长时间失联,也不会在服务端故障时雪崩。

int backoff = 1000; while (1) { SOCKET s = ConnectWithTimeout("192.168.1.10", 9000, 3000); if (s != INVALID_SOCKET) { backoff = 1000; // 连上就重置 DoSession(s); // 收发主循环,返回即断线 closesocket(s); } Sleep(backoff); backoff = (backoff * 2 > 30000) ? 30000 : backoff * 2; }

Sleep的单位是毫秒,VC6.0 里在windows.h中声明。DoSession里就是send/recv循环,返回意味着连接断了,外层负责重连。这套结构比「断了就 while 猛连」稳得多,现场跑几天不掉线是常态。

5. 避坑排查:VC6.0 Socket 调试里最常见的五个翻车点

这一章全是踩过的坑,每条按「现象 → 原因 → 解决」写,遇到对得上的直接照方抓药。

5.1 编译过、链接报 unresolved external symbol

现象:代码里socket、bind全部标红未定义,或者链接阶段报_socket@12之类的符号找不到。原因:ws2_32.lib没链上,或者只在一个配置里链了。解决:优先用#pragma comment(lib, "ws2_32.lib"),让代码自带库;如果坚持手动加,Debug 和 Release 两套配置都要加,加完重新 Build 而不是只 Compile。

5.2 bind 返回 10048

现象:服务端第一次跑正常,关掉再启动就bind failed: 10048。原因:上一次的连接还处于 TIME_WAIT,端口没释放。解决:bind之前加SO_REUSEADDR,代码见 3.1;如果还不行,换端口验证是不是别的进程占着,用netstat -ano | findstr 端口号查占用进程。

5.3 客户端连不上但服务端显示在监听

现象:服务端listen成功,客户端connect返回 10061 或 10060。原因:10061 是服务端没在这个端口监听(端口填错、绑了 127.0.0.1 只允许本机),10060 是网络层不通(防火墙、网段不对)。解决:先用telnet 目标IP 端口验证端口通不通,不通查防火墙;服务端绑INADDR_ANY而不是127.0.0.1,否则跨机连不上。

5.4 recv 收到乱码或半包

现象:发一段中文或长数据,收到的内容缺一截或者乱码。原因:TCP 是字节流,没有消息边界,recv一次返回多少取决于网络缓冲,可能只收到半条消息;中文按多字节存,截断在半个汉字上就乱码。解决:应用层自己定协议,常见做法是「4 字节长度头 + 数据体」,收方先收 4 字节算长度,再循环收到够为止。别指望一次recv收全。

5.5 程序退出后端口仍被占用

现象:进程已经关了,netstat还能看到端口在 LISTENING。原因:closesocket没调,或者WSACleanup没调,套接字句柄没释放。解决:每个socket/accept返回的句柄都要配对closesocket,程序退出前调WSACleanup。调试期频繁启停的程序尤其要注意,漏一次就多占一个句柄,跑久了资源耗尽。

6. 从单连接走向多客户端:select 模型与压测验证

单客户端串行版本只适合验证链路,真上现场至少要能同时接几台设备。VC6.0 没有 C++11 的线程库,多线程要用_beginthreadex,但线程一多共享数据要加锁,复杂度陡增。更轻的方案是select多路复用:一个线程管所有套接字,哪个可读处理哪个,没有锁竞争,代码量也可控。核心是把监听套接字和所有客户端套接字都塞进fd_set,select返回后遍历检查。

fd_set readSet; FD_ZERO(&readSet); FD_SET(listenSock, &readSet); SOCKET maxSock = listenSock; for (int i = 0; i < clientCount; i++) { FD_SET(clients[i], &readSet); if (clients[i] > maxSock) maxSock = clients[i]; } // Windows 下第一个参数忽略,填 0 也行 int ret = select(0, &readSet, NULL, NULL, NULL); if (ret > 0) { if (FD_ISSET(listenSock, &readSet)) { // 有新连接,accept 后加入 clients 数组 } for (int i = 0; i < clientCount; i++) { if (FD_ISSET(clients[i], &readSet)) { // 该客户端有数据,recv 处理 } } }

fd_set在 Winsock 里是个结构体数组,默认最多 64 个套接字(FD_SETSIZE),够一般工控场景用;要更多得自己改宏或者换 IOCP,那是另一个量级的工作。select的最后一个参数传NULL表示无限等待,实际项目里给个几百毫秒超时,方便主循环做别的事。

验证这套代码是否真的能扛多客户端,我一般用两个手段:一是写个简单的压测客户端,开 20 个线程各连一次、各发 1000 条消息,看服务端有没有丢连接、有没有内存涨;二是用netstat -ano观察连接数是否和预期一致,跑完关掉客户端再看连接是否正常回收。压测客户端本身也是用第 4 章的ConnectWithTimeout改的,把重连逻辑去掉、循环发数据即可。

有个细节值得单独说:select返回后fd_set会被内核改写,只保留有事件的套接字,所以每轮循环前必须重新FD_ZERO和FD_SET,不能复用上一轮的集合。这个坑我当年调了大半天,现象是「第一轮正常,第二轮开始漏事件」,原因就是集合没重建。从那以后我每次写select循环,都强制在循环体开头重建fd_set,再检查事件,这个习惯一直保留到现在。希望帮到你。

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

返回列表