
简介这是一套在VC6.0环境下编写的Socket通信入门示例压缩包内区分了服务器端与客户端两个完整工程整体结构清晰。资源共27个文件大小仅1.58MB既有编译好的可执行文件也有源代码、工程配置及相关调试信息文件另附文本说明方便对照理解。已有854人学习下载属于轻量实用的网络编程参考。通过阅读两端源码与工程配置可以把握网络通信中服务器建立监听、客户端发起连接、后续数据互发的基本流程也能学习到该开发环境下典型网络程序的组织方式以及如何借助调试辅助文件排查编译问题同时两个目录各自独立、可单独编译运行便于对照观察两端的不同职责。这份资料没有复杂业务逻辑特别适合初次接触网络编程、想快速跑通通信示例的初学者。1. VC6.0编程Socket服务器客户端老掉牙还是必修课打开 VC6.0新建一个空控制台工程把 winsock2.h 和 ws2_32.lib 配上写一个 TCP 服务器和一个 TCP 客户端互相发一句话——这件事听起来像 2003 年的课设但直到今天它仍然是不少面试题、毕业设计和旧系统维护里绕不开的一道坎。标题「VC6.0 编程 Socket服务器客户端」要解决的就是在 VC6.0 这个老编译器里把 Winsock 的初始化、socket 创建、bind、listen、accept、connect、send、recv 整条链路走通并处理好看不见的字节序与边界细节。刚入门的人靠它能理解 TCP 通信的完整过程有经验的人则能从这里重新审视阻塞模型和错误处理的价值。代码只有几十行坑却远比代码多——这台老机器会给每个轻视它的人上一课。2. 服务器端从零到监听Winsock 初始化、bind、listen 的一条龙2.1 先初始化协议栈版本协商为什么用 MAKEWORD(2,2)在 VC6.0 里写 Socket第一行代码不是 socket()而是 WSAStartup。Winsock 是 Windows 平台的 Socket 实现所有 API 都由 ws2_32.dll 导出使用前必须先告诉系统「我要用哪个版本的协议栈」。很多新手栽在第一步直接调 socket() 返回 INVALID_SOCKET然后对着错误码 10093 发呆——那是因为压根没做初始化。#include winsock2.h #include stdio.h #pragma comment(lib, ws2_32.lib) int init_winsock(void) { WSADATA wsaData; int ret WSAStartup(MAKEWORD(2, 2), wsaData); if (ret ! 0) { printf(WSAStartup failed, error%d\n, ret); return -1; } return 0; }逻辑说明MAKEWORD(2, 2) 表示向上层请求 Winsock 2.2 版本wsaData 是一个输出参数用来接收系统实际支持的协议栈信息。返回值 0 表示协商成功非 0 表示失败。这里的关键点是「请求版本」和「系统版本」不一定相同但只要返回值是 0 就说明系统至少支持你请求的版本可以直接用 2.2 的 API 特性。参数说明MAKEWORD 是宏低字节为主版本号、高字节为次版本号MAKEWORD(2,2) 得到 0x0202WSADATA 里的 wVersion 字段会回填实际协商结果调试时可以打印出来看。#pragma comment(lib, ws2_32.lib)是链接指示VC6.0 的工程设置里也可以手动在 Link 选项卡填 ws2_32.lib二选一我习惯写在代码里免得换工程时漏配。2.2 bind 与 listen端口、backlog 和地址结构体的字节序初始化完成后创建 socket 并绑定地址。这里有两个隐藏考点SOCKADDR_IN 结构体里的端口号要用 htons 转成网络字节序IP 地址要么用 htonl(INADDR_ANY) 表示监听所有网卡要么用 inet_addr(127.0.0.1) 指定本机回环地址。VC6.0 的 C 编译器不支持 C99 的「随手声明变量」风格所有变量声明尽量放在函数或代码块的头部否则编译报错让人摸不着头脑。SOCKET create_server_socket(unsigned short port) { SOCKET srv; SOCKADDR_IN addr; int ret; srv socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (srv INVALID_SOCKET) { printf(socket failed, error%d\n, WSAGetLastError()); return INVALID_SOCKET; } addr.sin_family AF_INET; addr.sin_port htons(port); addr.sin_addr.S_un.S_addr htonl(INADDR_ANY); ret bind(srv, (SOCKADDR*)addr, sizeof(addr)); if (ret SOCKET_ERROR) { printf(bind failed, error%d\n, WSAGetLastError()); closesocket(srv); return INVALID_SOCKET; } ret listen(srv, 5); if (ret SOCKET_ERROR) { printf(listen failed, error%d\n, WSAGetLastError()); closesocket(srv); return INVALID_SOCKET; } return srv; }逻辑说明socket() 的第二个参数 SOCK_STREAM 表示使用 TCP 流式套接字第三个参数填 IPPROTO_TCP 是明确协议bind() 把 socket 和本机地址、端口绑定到一起listen() 将这个 socket 变成被动监听状态。三步完成后服务器端的「基础设施」才算是搭好。参数说明端口号建议选 1024 以上的高位端口避免和系统服务冲突listen 的第二个参数是 backlog表示内核里排队等待 accept 的连接数上限VC6.0 时代填 5 是惯例填大了对单线程模型没实际意义因为处理完一个才能 accept 下一个htonl(INADDR_ANY) 的值是 0表示绑定任意本地地址如果只想本机调试可以改成 inet_addr(127.0.0.1)这样外部机器无法访问安全性更好。2.3 accept 与首次收发一个能跑通的服务器骨架监听之后服务器进入等待连接的阻塞状态accept 一调用线程就卡住直到有客户端连上来才返回一个新的 socket。这个新 socket 专门用于和该客户端通信原来的监听 socket 不动。很多人在这一步犯迷糊以为收发数据还是用最初的 socket结果数据收发全部失败。void echo_server_loop(SOCKET srv) { SOCKET cli; SOCKADDR_IN cliAddr; int addrLen; char buf[1024]; int ret; addrLen sizeof(cliAddr); printf(Server listening, waiting for client...\n); while (1) { cli accept(srv, (SOCKADDR*)cliAddr, addrLen); if (cli INVALID_SOCKET) { printf(accept failed, error%d\n, WSAGetLastError()); break; } printf(client connected: %s\n, inet_ntoa(cliAddr.sin_addr)); ret recv(cli, buf, sizeof(buf) - 1, 0); if (ret 0) { buf[ret] \0; printf(recv %d bytes: %s\n, ret, buf); send(cli, reply from server, 17, 0); } else { printf(recv failed or closed, ret%d error%d\n, ret, WSAGetLastError()); } closesocket(cli); } }逻辑说明accept 的返回值是关键——它返回的是一个全新的 socket 描述符之后的 recv/send 都作用在这个新 socket 上cliAddr 是输出参数用来拿客户端的 IP 和端口。recv 是阻塞调用客户端一直不发数据时服务器线程会停在 recv 这一行这是阻塞模型的正常表现不代表程序死了。每处理完一个客户端就 closesocket然后继续 accept 等下一个。参数说明sizeof(buf) - 1是为了留一个位置给字符串结束符避免当数据到达 1024 字节时越界写buf[ret] \0inet_ntoa 把 32 位整数形式的 IP 转成点分十进制字符串这个函数返回的缓冲区是静态的第二次调用会覆盖上次结果多线程里要用 inet_ntoa 的线程安全版本或自己格式化但单线程调试够用。ret 的返回值大于 0 是收到的字节数0 表示对端已优雅关闭SOCKET_ERROR 表示出错。3. 客户端不比服务器简单connect、send、recv 的最小实现3.1 客户端初始化为什么必须做同样的 WSAStartup客户端看起来就是「连一下、发一句、收一句」但它同样逃不掉 WSAStartup。这是 Winsock 的强制要求进程内只要调用任何 Winsock API就必须先初始化。我见过不少同事把服务器的初始化封装好客户端却漏了这一步然后对着 connect 返回 10093 发呆。客户端的初始化和服务器端完全一样可以复用同一个函数。int client_demo(void) { WSADATA wsaData; SOCKET sock; SOCKADDR_IN srvAddr; int ret; ret WSAStartup(MAKEWORD(2, 2), wsaData); if (ret ! 0) { printf(WSAStartup failed, error%d\n, ret); return -1; } sock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (sock INVALID_SOCKET) { printf(socket failed, error%d\n, WSAGetLastError()); WSACleanup(); return -1; } srvAddr.sin_family AF_INET; srvAddr.sin_port htons(8888); srvAddr.sin_addr.S_un.S_addr inet_addr(127.0.0.1); ret connect(sock, (SOCKADDR*)srvAddr, sizeof(srvAddr)); if (ret SOCKET_ERROR) { printf(connect failed, error%d\n, WSAGetLastError()); closesocket(sock); WSACleanup(); return -1; } printf(connected to server\n); /* 后续 send/recv 在这里继续 */ closesocket(sock); WSACleanup(); return 0; }逻辑说明connect 的作用是向服务器发起 TCP 三次握手握手完成才返回成功。地址结构体里的 sin_addr 填的是服务器的 IP端口要和服务器 bind 的端口一致。整个流程和服务器端唯一的差别是服务器要 bindlistenaccept客户端只需要 socketconnect。参数说明inet_addr(127.0.0.1) 把点分十进制字符串转成网络字节序的 32 位整数。注意 inet_addr 对非法输入返回 INADDR_NONE也就是 0xFFFFFFFF这个值同时也是广播地址用的时候要判断一下返回值别把非法 IP 直接塞进结构体。ws2_32.lib 这个库客户端和服务器端都要链接它在 VC6.0 默认安装目录的 Lib 文件夹里不用额外配置路径。3.2 发送与接收缓冲区、字节数和「只发一次」的陷阱send 和 recv 是整条链路里最容易被低估的两个函数。send 返回的是「本次调用实际写入内核发送缓冲区的字节数」不保证等于你传入的长度尤其是数据量大或网络拥塞时recv 返回的是「本次调用从内核接收缓冲区取出的字节数」也不保证等于对端一次 send 的数据量。TCP 是字节流协议没有消息边界这个特性是后续所有粘包问题的大本营。const char *msg hello from client; int slen (int)strlen(msg); int sent 0; int n 0; char buf[1024]; int ret; while (sent slen) { n send(sock, msg sent, slen - sent, 0); if (n SOCKET_ERROR) { printf(send failed, error%d\n, WSAGetLastError()); break; } sent n; } ret recv(sock, buf, sizeof(buf) - 1, 0); if (ret 0) { buf[ret] \0; printf(recv: %s\n, buf); }逻辑说明这个循环处理了 send 的「部分发送」情况msg sent 是当前未发送部分的起始位置slen - sent 是剩余长度。新手最常见的写法是send(sock, msg, strlen(msg), 0)直接忽略返回值在小数据量、无拥塞的本机测试里看起来没问题一旦跨机器或数据超过几 KB 就翻车。recv 用一块固定大小的缓冲区接收sizeof(buf) - 1同样是给字符串结束符留位置。参数说明send 的第四个参数 flags 填 0 即可MSG_DONTWAIT 这类标志在 Winsock 上不受支持别照搬 Linux 的习惯recv 同理由阻塞控制填 0 表示默认阻塞。strlen 统计的是字节数不含末尾的 \0所以发送时不会把结束符传过去接收方要自己补buf[ret] \0这也是收中文时出错的高发点后面避坑章专门说。3.3 资源释放顺序closesocket 和 WSACleanup 什么时候调用收尾代码的顺序看似简单实际上有讲究。closesocket 负责关闭连接并释放该 socket 相关的内核资源WSACleanup 则是进程级清理调用之后当前进程不能再调任何 Winsock API。顺序反了会出现奇怪的行为先 WSACleanup 再 closesocketclosesocket 可能返回 SOCKET_ERROR错误码 10093意思是 Winsock 未初始化。/* 通信结束后 */ shutdown(sock, SD_BOTH); /* 先通知对端不再收发 */ closesocket(sock); /* 再释放 socket 句柄 */ WSACleanup(); /* 最后做进程级清理 */ return 0; }逻辑说明shutdown 是很多教材不提但实际很重要的函数。SD_BOTH 表示禁止后续的发送和接收它的作用是向对端发送 FIN触发 TCP 四次挥手的正常关闭流程如果直接 closesocket虽然内核也会发送 FIN但一些实现细节上的差异会导致对端 recv 返回错误而不是干净利落的 0。先 shutdown 再 closesocket是 Windows 下推荐的标准写法。参数说明SD_BOTH 等价于 SD_SEND | SD_RECEIVE 同时禁止收发如果你只想停止发送但还想收数据用 SD_SEND。closesocket 之后 socket 句柄作废不能再用于任何 API。WSACleanup 是配对函数每次 WSAStartup 成功都对应一次 WSACleanup多线程程序里只由主线程做一次全局清理即可。4. 联调避坑编译冲突、bind 失败、中文乱码、recv 假死的四个典型场景4.1 现象winsock2.h 和 windows.h 一起包含编译报错铺天盖地VC6.0 里最常见的第一道坎是编译期错误。现象工程里某处包含了 windows.h代码里又写了#include winsock2.h编译时报一堆 C2011 重定义错误指向 timeval、fd_set 这些结构体新手看到几百行报错直接懵了。原因windows.h 默认会包含 winsock.hWinsock 1.0而 winsock2.h 和它是两个不同的头文件里面的结构体定义重复而且冲突。这两个头文件一旦同时进入编译单元必然重定义。解决在代码里不要两个都显式包含。控制台程序只包含 winsock2.h 就行如果因为某些库函数的声明必须包含 windows.h那就把 winsock2.h 放在 windows.h 之前让编译器先用 2.0 的定义。另外检查工程预编译头设置VC6.0 的 MFC 向导工程默认带 stdafx.h里面常间接引入 windows.h写 Socket 代码时最好新建一个空控制台工程省得被预编译头拖累。4.2 现象bind 返回 10048端口明明没在用服务器代码退出后马上重新启动bind 直接失败错误码 10048WSAEADDRINUSE。用 netstat -ano 查端口发现端口处于 TIME_WAIT 状态。这是调试期间最高频的翻车现场。原因TCP 四次挥手结束后主动关闭方通常是先调用 closesocket 的一方会进入 TIME_WAIT 状态持续 2MSL 时间Windows 上默认约 2 分钟。在这个窗口期内内核认为端口仍被占用bind 就失败了。开发时服务器进程被我们 CtrlC 杀掉但之前建立过的连接还挂在 TIME_WAIT 里。解决在 bind 之前调用 setsockopt 设置 SO_REUSEADDR告诉内核允许重用处于 TIME_WAIT 状态的地址。这条选项要在 bind 之前设bind 之后设无效。还有一个隐藏细节SO_REUSEADDR 在 Windows 和 Linux 上的语义有差异Windows 下它能有效绕过 TIME_WAIT 限制但也要注意它可能允许两个 socket 绑定同一端口生产环境不要随意开启。调试期开、上线前评估。4.3 现象客户端发中文服务器收到的是一堆乱码客户端用 printf 收到中文显示正常但服务器端打印出来全是乱码或者反过来服务器发的中文客户端读不懂。更隐蔽的是发 10 个中文字符send 的长度填 20 或 30接收端怎么都收不齐。原因两层问题。第一VC6.0 时代的中文 Windows 系统默认代码页是 GBK源文件保存编码和运行时代码页不一致时字符串常量的字节序列就会错乱第二一个中文字符在 GBK 下占 2 字节UTF-8 下占 3 字节很多人用 sizeof 或直接数「字数」当长度实际应该用 strlen 统计字节数。如果两端一个按 GBK 一个按 UTF-8 解释同一段字节必然乱码。解决调试阶段服务器和客户端都统一用 GBK/ANSI 编码保存源文件并且都用 strlen 取字节长度发送和接收如果跨平台场景约定好网络传输统一用 UTF-8发送前做编码转换。别在 Socket 层做编码猜测协议层面定死编码是最可靠的做法。另外注意 send 后不要立即关闭 socket要给对端留出接收时间。4.4 现象recv 一收就卡死程序像冻住了一样服务器 accept 成功后抱着客户端等数据结果客户端掉线或异常退出服务器的 recv 永远不返回程序看起来像死循环。或者客户端先关闭了连接服务器 recv 返回 0但代码没判断这个返回值继续往下处理数据导致逻辑错乱。原因阻塞模式下 recv 在没有数据时不会返回它一直在等。而对端异常退出时TCP 连接可能处于半开状态recv 未必立刻收到 FIN需要内核超时机制兜底但这个时间可能长达几十秒甚至更久。另外很多初学者只调一次 recv以为能收到完整消息实际上 TCP 是流协议一次 recv 返回的只是当前缓冲区里的部分数据。解决不要依赖单次 recv 收完整数据。要么循环调用 recv 直到收够预期字节数要么用非阻塞模式配合 select 设置超时。对端退出要区分对待recv 返回 0 是正常关闭返回 SOCKET_ERROR 且错误码 10054 是对端异常重置这两种情况都要清理当前 socket 并回到 accept 继续等服务。心跳机制能解决半开连接问题第 5 章给一个最小实现思路。5. 从能跑到可靠粘包处理、超时控制与优雅退出的最小方案让服务器和客户端互相发一句话是入门让它们稳定地互发多条消息是进阶。第一个拦路虎是粘包。TCP 是字节流没有消息边界客户端连续 send 两次「hello」和「world」服务器可能一次 recv 就收到 helloworld也可能只收到 hel。常见的做法是自定义一个简单的应用层协议4 字节包头存长度后面跟内容。收到数据时先攒进缓冲区直到够出整个包再处理。伪代码思路如下while (1) { ret recv(sock, tmpBuf, sizeof(tmpBuf), 0); if (ret 0) break; memcpy(pkgBuf pkgLen, tmpBuf, ret); pkgLen ret; while (pkgLen 4) /* 至少攒够一个包头 */ { pkgSize ntohl(*(int*)pkgBuf); if (pkgLen 4 pkgSize) break; /* 内容还没到齐 */ memcpy(msgBuf, pkgBuf 4, pkgSize); /* 这里处理一条完整消息 */ pkgLen - 4 pkgSize; memmove(pkgBuf, pkgBuf 4 pkgSize, pkgLen); } }逻辑说明这个累缓冲区加定长包头的模式是解决粘包和半包最经典的手段比依赖 Nagle 算法的玄学可靠得多。ntohl 把网络字节序转回主机字节序保证不同平台间大小端一致。memmove 是必须的不能用 memcpy因为源和目标内存区域可能重叠。第二个建议是给 recv 加上超时。Winsock 里可以用 setsockopt 设 SO_RCVTIMEOVC6.0 的 SDK 里这个选项的取值单位是毫秒设置后 recv 在超时时间内收不到数据会返回 SOCKET_ERROR错误码 10060 表示超时。这样程序不会永久卡死在 recv 上。第三个建议是心跳客户端每隔 30 秒发一个固定格式的心跳包服务器端超过 90 秒没收到就判定连接已死主动 closesocket。这个是解决半开连接的唯一可靠手段比任何超时配置都管用。我最早在某个跨平台系统里维护别人留下的 Socket 代码整层都是裸收发、没有协议封装生产环境三天两头出现连接挂死。后来一点点加了包头、超时、心跳连接稳定性才算像样。教训是Socket 通信的可靠性不是靠「多写几行逻辑」堆出来的而是靠每一层都知道自己该干什么——初始化确认版本、收发检查返回值、退出先 shutdown、消息带边界。这套思路在 VC6.0 上成立换到任何语言和平台也同样成立。希望帮到你。本文还有配套的精品资源点击获取