简介:这份资源是面向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 工程,服务器和客户端两个 exe,一个负责收现场设备的数据,一个负责把数据推到监控界面。机器不能换系统,编译器也不能升级,只能在这套老环境里改。这类需求其实不少见:教学实验、老设备维护、内网小工具,VC6.0 加 Winsock 的组合依然是能跑就行的最优解。这份资源给的就是一套完整的服务器加客户端 Socket 编程示例,服务端负责监听端口、接受连接、收发数据,客户端负责发起连接、发送请求、接收回包。适合两类人:一是刚学网络编程、想找一个能直接编译运行的 C/S 骨架的新手;二是手里有老工程、需要一份干净的 Socket 参考实现来对照改造的熟手。下面按「能干什么、怎么跑、怎么改」的顺序拆开讲。
2. 工程结构与 Winsock 初始化:两个 exe 是怎么搭起来的
2.1 目录与文件构成
这类 VC6.0 的 Socket 示例工程,结构通常很朴素,一个工作区(.dsw)下挂两个工程(.dsp),分别编译出服务端和客户端。常见做法是:
SocketDemo/ ├── Server/ │ ├── Server.dsp # 服务端工程文件 │ ├── Server.cpp # 主流程:初始化、监听、accept、收发 │ ├── Server.h │ └── StdAfx.cpp/.h # 预编译头 ├── Client/ │ ├── Client.dsp # 客户端工程文件 │ ├── Client.cpp # 主流程:初始化、connect、收发 │ ├── Client.h │ └── StdAfx.cpp/.h └── SocketDemo.dsw # 工作区,同时包含两个工程服务端和客户端各自独立编译,互不依赖,这样调试时可以同时开两个进程,一个监听一个连接,链路最直观。工程里一般还会带一个ws2_32.lib的链接配置,这是 Winsock 2 的导入库,少了它链接阶段会报一堆unresolved external symbol。
2.2 Winsock 的启动与清理
Windows 下用 Socket 必须先调WSAStartup,这是整个程序的第一道门槛,也是最容易被新手忽略的一步。典型写法:
#include <winsock2.h> #pragma comment(lib, "ws2_32.lib") WSADATA wsaData; int ret = WSAStartup(MAKEWORD(2, 2), &wsaData); if (ret != 0) { printf("WSAStartup failed: %d\n", ret); return -1; } // ... 后续 socket 操作 WSACleanup(); // 程序退出前必须调用MAKEWORD(2, 2)表示请求 Winsock 2.2 版本,wsaData回填实际协商到的版本信息。WSAStartup失败一般意味着系统网络组件异常或者版本不匹配,返回值可以直接查错误码。WSACleanup要和WSAStartup成对出现,虽然进程退出时系统会兜底,但在长期运行的服务端里不清理会累积资源句柄,属于血泪经验级别的细节。
提示:VC6.0 默认的
windows.h里可能已经间接包含了旧版winsock.h,如果同时包含winsock2.h会冲突。常见做法是把#include <winsock2.h>放在所有头文件最前面,或者在工程设置里定义WIN32_LEAN_AND_MEAN。
2.3 服务端的 socket 生命周期
服务端的核心是四步:创建 socket、绑定地址、监听、接受连接。代码骨架大致如下:
SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listenSock == INVALID_SOCKET) { printf("socket failed: %d\n", WSAGetLastError()); WSACleanup(); return -1; } 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(8888); // 端口号,按需改 if (bind(listenSock, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { printf("bind failed: %d\n", WSAGetLastError()); closesocket(listenSock); WSACleanup(); return -1; } if (listen(listenSock, SOMAXCONN) == SOCKET_ERROR) { printf("listen failed: %d\n", WSAGetLastError()); closesocket(listenSock); WSACleanup(); return -1; }AF_INET是 IPv4 地址族,SOCK_STREAM是面向连接的流式套接字,IPPROTO_TCP明确走 TCP 协议。INADDR_ANY表示绑定本机所有网卡地址,如果只想监听某个内网 IP,把htonl(INADDR_ANY)换成inet_addr("192.168.x.x")即可。htons(8888)把主机字节序转成网络字节序,端口号选 1024 以上避免权限问题。listen的第二个参数是等待队列长度,SOMAXCONN让系统取最大值,小规模场景够用。
接受连接用accept,它会阻塞直到有客户端连进来:
sockaddr_in clientAddr; int clientLen = sizeof(clientAddr); SOCKET clientSock = accept(listenSock, (sockaddr*)&clientAddr, &clientLen); if (clientSock == INVALID_SOCKET) { printf("accept failed: %d\n", WSAGetLastError()); // 继续循环或退出,视业务而定 } printf("client connected: %s:%d\n", inet_ntoa(clientAddr.sin_addr), ntohs(clientAddr.sin_port));accept返回一个新的 socket 专门和这个客户端通信,原来的listenSock继续监听。inet_ntoa把网络字节序的 IP 转成点分字符串,ntohs把端口转回主机序,这两个函数在打印日志时很常用。注意inet_ntoa返回的是静态缓冲区,多线程下不能直接共享,单线程示例无所谓。
2.4 客户端的连接流程
客户端比服务端少一步监听,核心是connect:
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (sock == INVALID_SOCKET) { /* 错误处理 */ } sockaddr_in serverAddr; memset(&serverAddr, 0, sizeof(serverAddr)); serverAddr.sin_family = AF_INET; serverAddr.sin_port = htons(8888); serverAddr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 服务端 IP if (connect(sock, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) { printf("connect failed: %d\n", WSAGetLastError()); closesocket(sock); WSACleanup(); return -1; }inet_addr把点分字符串转成网络字节序整数,本机测试用127.0.0.1,跨机测试换成服务端实际 IP。connect失败最常见的原因是服务端没启动、端口写错、或者防火墙拦了,WSAGetLastError()返回10061(连接被拒绝)基本就是服务端没在监听。
3. 收发数据与阻塞模型:一次请求从发出到回包的完整链路
3.1 send 和 recv 的基本用法
TCP 是字节流,没有消息边界,send和recv的返回值必须认真对待。服务端收到连接后,典型循环是:
char buf[1024]; while (true) { int n = recv(clientSock, buf, sizeof(buf) - 1, 0); if (n == 0) { printf("client closed\n"); break; // 对端正常关闭 } else if (n == SOCKET_ERROR) { printf("recv failed: %d\n", WSAGetLastError()); break; } buf[n] = '\0'; printf("recv: %s\n", buf); const char* reply = "server received"; send(clientSock, reply, (int)strlen(reply), 0); } closesocket(clientSock);recv返回 0 表示对端调用了closesocket或shutdown,这是正常关闭信号,不是错误。返回SOCKET_ERROR才是真错误。sizeof(buf) - 1留一个字节给'\0',避免打印越界。send的返回值是实际发送的字节数,小数据量一般一次发完,大数据量要循环发,这是后面二次开发要重点处理的地方。
客户端对应地发一条收一条:
const char* msg = "hello server"; send(sock, msg, (int)strlen(msg), 0); char buf[1024]; int n = recv(sock, buf, sizeof(buf) - 1, 0); if (n > 0) { buf[n] = '\0'; printf("reply: %s\n", buf); }3.2 阻塞模型的含义与局限
默认创建的 socket 是阻塞模式,accept、connect、recv、send在条件不满足时都会卡住当前线程。这套示例工程基本都跑在阻塞模型上,优点是代码直白、逻辑线性,缺点是服务端一次只能服务一个客户端——accept返回后进入收发循环,第二个客户端连进来只能排队等。教学和小工具场景够用,但要做多客户端就得改。
常见做法有三种:一是每接受一个连接就CreateThread开一个线程专门处理该客户端;二是用select做 I/O 多路复用,单线程管理多个 socket;三是改成非阻塞 socket 加轮询。VC6.0 环境下第一种最省事,第二种最稳。下面给一个线程版的骨架:
DWORD WINAPI ClientThread(LPVOID param) { SOCKET clientSock = (SOCKET)param; char buf[1024]; while (true) { int n = recv(clientSock, buf, sizeof(buf) - 1, 0); if (n <= 0) break; buf[n] = '\0'; send(clientSock, buf, n, 0); // 原样回显 } closesocket(clientSock); return 0; } // accept 循环里 SOCKET clientSock = accept(listenSock, NULL, NULL); if (clientSock != INVALID_SOCKET) { CreateThread(NULL, 0, ClientThread, (LPVOID)clientSock, 0, NULL); }CreateThread的第三个参数是线程函数,第四个参数把客户端 socket 传进去。注意 socket 句柄转LPVOID再转回来,在 32 位下没问题,64 位下要小心,不过 VC6.0 只有 32 位,不用纠结。线程函数里recv返回 0 或错误就退出循环并关闭 socket,避免句柄泄漏。
3.3 粘包与消息边界
TCP 不保证一次recv对应一次send,客户端连发两条消息,服务端可能一次收到两条拼在一起,也可能一条分两次收到,这就是粘包。示例工程如果只做单条短消息演示,通常不处理;一旦上真实业务,必须自己定边界。常见做法是「长度前缀 + 内容」:
// 发送:先发 4 字节长度,再发内容 int len = (int)strlen(payload); send(sock, (char*)&len, 4, 0); send(sock, payload, len, 0); // 接收:先收 4 字节长度,再按长度收内容 int len = 0; recv(sock, (char*)&len, 4, MSG_WAITALL); char* body = new char[len + 1]; recv(sock, body, len, MSG_WAITALL); body[len] = '\0';MSG_WAITALL让recv尽量收满指定字节数再返回,减少循环次数,但它不是万能的,连接异常时仍可能提前返回,所以严格实现还要检查返回值。长度字段本身也有字节序问题,跨平台通信要统一用htonl/ntohl,本机自测可以偷懒。
注意:
send返回的字节数可能小于请求长度,尤其是发送大块数据时。正确做法是循环发送直到全部发完,示例里为了简洁常常省略,二次开发时务必补上。
4. 编译运行与联调:VC6.0 下把两个 exe 跑起来
4.1 工程配置要点
VC6.0 打开.dsw工作区后,先确认两个工程的链接设置里都有ws2_32.lib。路径是 Project → Settings → Link 选项卡 → Object/library modules,追加ws2_32.lib。如果代码里用了#pragma comment(lib, "ws2_32.lib"),这一步可以省,但老工程经常两者都没有,链接报错就是从这里来的。
编译顺序上,服务端和客户端互不依赖,可以分别 Build。Build 菜单选 Set Active Project 切换当前工程,或者直接 F7 全部编译。输出目录默认在工程下的Debug或Release,两个 exe 分别生成在各自目录里。
4.2 本机联调步骤
联调最省事的方式是本机回环:
- 先运行服务端 exe,看到「listening on 8888」之类的日志说明绑定成功。
- 再运行客户端 exe,连接
127.0.0.1:8888。 - 服务端打印客户端 IP 和端口,客户端发送消息,服务端回包,两边日志对得上就通了。
如果服务端启动就报bind failed: 10048,说明端口被占用,换个端口或者用netstat -ano | findstr 8888查一下谁占着。如果客户端报connect failed: 10061,先确认服务端在跑、端口一致、没有防火墙拦截。跨机测试时把客户端里的127.0.0.1换成服务端内网 IP,服务端绑定INADDR_ANY就能接受外部连接。
4.3 调试技巧
VC6.0 的调试器虽然老,但断点、单步、查看变量都够用。联调时常见做法是两个工程都设断点,先启动服务端到accept前,再启动客户端到connect后,然后服务端放行,观察accept返回值。WSAGetLastError()的值可以在 Watch 窗口直接看,比打印日志快。
日志方面,示例工程多用printf,控制台窗口一闪而过是常见翻车点。解决办法是在main末尾加system("pause"),或者在工程设置里把子系统改成 Console。MFC 对话框版的工程则用MessageBox或OutputDebugString配合 DebugView 看输出。
5. 避坑与常见问题:那些年 Socket 联调翻过的车
5.1 链接报错 unresolved external symbol
现象:编译通过,链接阶段报__imp__WSAStartup@8之类的未解析符号。原因:没有链接ws2_32.lib。解决:在工程 Link 设置里加库,或者代码里加#pragma comment(lib, "ws2_32.lib")。还有一种情况是头文件包含了旧版winsock.h,函数声明对不上,把winsock2.h提到最前面即可。
5.2 bind 失败错误码 10048
现象:服务端启动即退出,日志显示bind failed: 10048。原因:端口已被其他进程占用,或者上一次运行的服务端没退干净,socket 处于TIME_WAIT。解决:换端口,或者等几十秒让系统回收;开发阶段可以在bind前设SO_REUSEADDR:
int opt = 1; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, (char*)&opt, sizeof(opt));SO_REUSEADDR允许绑定处于TIME_WAIT的地址,调试时很实用,生产环境要不要开看具体场景。
5.3 客户端连不上但服务端在跑
现象:服务端日志正常,客户端connect返回 10061 或超时。原因:IP 或端口写错、防火墙拦截、服务端绑定的是127.0.0.1而不是INADDR_ANY。解决:逐项核对,先用telnet 服务端IP 端口测连通性,再看服务端绑定地址。跨网段时还要确认路由可达。
5.4 recv 返回 0 被当成错误
现象:客户端正常关闭后,服务端日志刷一堆错误或者死循环。原因:把recv返回 0 当成了SOCKET_ERROR处理。解决:明确区分,0 是对端正常关闭,应该break出循环并closesocket;只有SOCKET_ERROR才查WSAGetLastError()。这个坑几乎每个新手都会踩一次。
5.5 中文乱码
现象:发送中文,接收端显示乱码。原因:VC6.0 默认多字节字符集,源码文件编码和运行环境不一致,或者发送接收两端编码不统一。解决:统一用 GBK 或者统一转 UTF-8 再发,跨平台通信建议在协议层约定编码,别依赖系统默认。
6. 二次开发改造:从示例骨架到能用的 C/S 工具
拿到这套示例,直接上业务通常不够用,得按实际需求补几块。第一块是消息协议,把裸字符串换成「长度前缀 + 命令字 + 载荷」的结构,命令字区分登录、心跳、数据上报,载荷按业务定义,这样服务端才能路由。第二块是多客户端管理,用线程或者select把accept循环和收发解耦,维护一个客户端列表,断线时清理。第三块是异常处理,send/recv全部改成循环收发,处理部分发送和超时,给 socket 设SO_RCVTIMEO避免永久阻塞。
改造时我一般先写一个最小的协议头结构体,两端共用:
#pragma pack(push, 1) struct MsgHeader { int magic; // 魔数,校验用 int cmd; // 命令字 int bodyLen; // 载荷长度,网络字节序 }; #pragma pack(pop)#pragma pack(1)保证结构体不被编译器填充,两端内存布局一致。发送时先htonl各字段再send,接收时先收固定长度头,再按bodyLen收载荷。验证方法很直接:写一个循环发一万条随机长度消息的压测客户端,服务端逐条校验magic和长度,跑通不崩就算协议层稳了。
还有一个容易忽略的点是资源释放顺序:先shutdown再closesocket,最后WSACleanup。shutdown会通知对端「我不再发了」,对端能及时感知,比直接closesocket温和。服务端退出时把监听 socket 和所有客户端 socket 都关掉,别指望进程退出兜底,长期运行的工具重启一次漏一批句柄,迟早出事。
从那以后我每次改这类老 Socket 工程,都强制先跑一遍「启动服务端 → 连三个客户端 → 逐个断开 → 再连一个」的回归流程,确认没有句柄泄漏和错误码误判才动业务代码。希望帮到你。
本文还有配套的精品资源,点击获取