☰
Winsock网络编程实战:从TCP套接字到select模型避坑指南
2026/10/4 21:58:16 网站建设 项目流程

简介:一套参照《Visual C++网络高级编程》(陈坚、陈伟著)2.2节内容编写的WinSock编程入门示例,由客户机(Client)与服务器(Server)两个完整的VC++工程组成,整个示例围绕socket的基本通信流程展开,服务器端能接收并显示来自客户机的信息。代码刻意保持简洁,注释详尽,适合正在学习Visual C++网络编程、需要完成socket课程设计或想弄清WinSock基础接口用法的读者参考。压缩包共含40个文件,除6个cpp源文件和8个h头文件外,还提供4个exe可执行文件,方便直接运行对比效果;余下为dsp、dsw、rc等Visual C++工程及资源配置文件,整体仅73KB,下载解压即可使用,已有262人学习过这份资源。解压后可以看到客户机与服务器两套完整示例工程,目录中Debug与Release两个版本的可执行文件分层存放,源码结构清晰;对照代码阅读和单步调试,可以直观理解socket的创建、连接、收发数据与关闭流程,为后续编写更复杂的网络通信程序打下基础。

1. 简单Winsock示例,为什么值得认真跑一遍

在Windows上写网络工具,最常用的方案就是Winsock。Winsock是Windows Sockets的简称,它把TCP/IP协议栈包装成一组C语言API,客户机和服务器之间收发数据就靠它。这篇笔记讲的是最简单的业务场景:一个客户机连上服务器,发一条消息,服务器原样回一条。看起来简单,但很多刚接触Winsock编程的人连编译都过不了,甚至因为少调一个初始化函数让程序运行时崩溃。反直觉的地方在于:这套API的难点并不在API本身,而在初始化顺序、字节序转换、缓冲区生命周期和对端关闭的判定。把这些细节跑通,后续做局域网工具、工控机数据上报、设备联调都会顺手很多。

2. 环境准备与TCP通信模型:为什么客户机和服务器都绕不开WSAStartup

2.1 Winsock初始化与版本协商:MAKEWORD(2,2)背后的兼容性逻辑

Winsock的API分两个时代。1.1版对应Windows 95时代的winsock.dll,提供最基础的socket函数集;2.0及以上对应现在的ws2_32.dll,增加协议无关的扩展如WSASocket、重叠I/O。现在的Windows系统默认携带ws2_32.dll,但程序必须在调用任何socket函数之前,用WSAStartup显式加载并协商版本。这一步漏掉的后果是:socket()直接返回INVALID_SOCKET,WSAGetLastError()返回10093,也就是WSANOTINITIALISED。

#define WIN32_LEAN_AND_MEAN #include <winsock2.h> #include <ws2tcpip.h> #include <stdio.h> #pragma comment(lib, "ws2_32.lib") int main() { WSADATA wsaData; int rc = WSAStartup(MAKEWORD(2, 2), &wsaData); if (rc != 0) { printf("WSAStartup failed: %d\n", rc); return 1; } printf("Winsock version: %d.%d\n", LOBYTE(wsaData.wVersion), HIBYTE(wsaData.wVersion)); WSACleanup(); return 0; }

MAKEWORD(2,2)是两个参数的组合,低字节表示主版本号,高字节表示副版本号,千万别记反。wsaData.wVersion是系统实际协商出来的版本,LOBYTE取主版本、HIBYTE取副版本。现代Windows系统都支持2.2,所以这个写法基本不会遇到版本协商失败。万一失败,常见错误码是10091(WSASYSNOTREADY,网络子系统未就绪)和10092(WSAVERNOTSUPPORTED,请求的版本不被支持)。

WSAStartup和WSACleanup是配对关系,类似malloc和free。WSACleanup会让引用计数减一,减到零才真正释放底层资源。控制台工具里进程退出时系统会回收,写DLL或者需要长时间运行的服务器程序时,不配对调用就是资源泄漏。我还见过一些示例代码把WSACleanup放在return之后,看起来和资源管理习惯对着干,实际运行也确实埋了隐患。

2.2 选型理由:TCP阻塞模式适合什么,不适合什么

示例选TCP不是随意决定。socket()的第二个参数有两条路:SOCK_STREAM对应TCP,SOCK_DGRAM对应UDP。TCP提供连接状态、确认重传、按序到达,客户机发一条服务器回一条的回声业务,最怕的就是数据在中途丢失或者乱序,所以TCP是合理选择。UDP适合的是实时音视频、局域网广播这种丢了可以容忍的场景,不应作为这个示例的默认选型。

阻塞模式是Winsock的默认行为。线程调用recv之后挂起,直到内核缓冲区有数据或者对端关闭连接。对这个示例来说,阻塞让代码变成线性的“收一条、回一条、再收一条”,逻辑顺序自然清晰,调试时跟着走就行。但同样的特性也限制了并发能力:单线程阻塞模型只能服务一个连接,第二个客户端必须等第一个断开才能accept。如果你服务的是多个工控机同时上报,这个模型直接卡死,后面第6章会讲select模型的弥补方案。

阻塞模式的另一个隐患是没有超时。如果对端既不发送数据也不关闭连接,recv会一直挂住,线程无法退出。要给recv加超时,可以用setsockopt设置SO_RCVTIMEO:

DWORD timeout = 5000; // 5秒 setsockopt(client_sock, SOL_SOCKET, SO_RCVTIMEO, (const char*)&timeout, sizeof(timeout));

设置之后如果超时,recv返回SOCKET_ERROR,WSAGetLastError()返回10060(WSAETIMEDOUT)。这个手段在排查服务器是否存活、客户端是否假死时很好用,我一般会在正式工具里默认加上。

非阻塞模式要配合select或事件对象,代码复杂度上一个台阶。还有WSAAsyncSelect和WSAEventSelect两条Windows专属路线,前者依赖窗口句柄,后者依赖WaitForMultipleObjects。这些在做GUI工具或Windows服务时才有意义,控制台入门阶段先不碰。很多初学者网络编程一开始就冲向IOCP,结果被完成端口和重叠I/O的组合拳劝退。实际上IOCP很难,但阻塞模型没跑通就去碰IOCP是难上加难。

2.3 头文件包含顺序与ws2_32.lib链接:新手最常见的编译翻车点

Windows下Winsock编程第一道坎在编译期。最常见的报错是“C2011: 'sockaddr_in' : 'struct' type redefinition”。原因很典型:windows.h内部默认包含旧的winsock.h(1.1版),你后面再包含winsock2.h,两个头文件里同一批结构体定义撞车。解决手段有两个,要么把winsock2.h放在任何可能间接引入windows.h的头文件之前,要么定义WIN32_LEAN_AND_MEAN宏裁剪掉windows.h里不常用的部分,后者在大型工程里更稳妥。

链接期还有一道坎,报错“LNK2019: unresolved external symbol __imp__send”。这是没链接ws2_32.lib。VS工程里可以右键项目添加依赖项,也可以像前面代码那样直接写#pragma comment(lib, "ws2_32.lib")。第二种做法对跨版本迁移更友好,换机器编译不会丢链接设置。

顺带说下ws2tcpip.h。这个头文件提供了inet_pton、getaddrinfo这些新函数,是IPv6时代推荐的地址转换接口。旧代码常写的inet_addr在winsock2.h里也有声明,但缺少错误校验,而且微软已经标记为弃用。我的建议是统一include winsock2.h、ws2tcpip.h两个头文件,第一版代码就能避开后面地址处理的返工。

3. 服务器端实现:bind、listen、accept三步走

3.1 完整服务器代码:单连接TCP回声示例

服务器端跑的流程是socket、bind、listen、accept、recv、send。socket创建监听socket,bind把socket和IP端口绑定,listen进入监听状态,accept阻塞等待客户端连接到来。客户端连上之后,accept返回一个新的socket,新socket专属于这个连接,监听socket继续留在accept上等下一个客户端。看完整代码。

#define WIN32_LEAN_AND_MEAN #include <winsock2.h> #include <ws2tcpip.h> #include <stdio.h> #pragma comment(lib, "ws2_32.lib") int main() { WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { printf("WSAStartup failed\n"); return 1; } // 1. 创建监听socket,TCP使用SOCK_STREAM SOCKET listen_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listen_sock == INVALID_SOCKET) { printf("socket() failed: %d\n", WSAGetLastError()); WSACleanup(); return 1; } // 2. 绑定地址和端口,INADDR_ANY表示监听所有网卡 sockaddr_in server_addr; server_addr.sin_family = AF_INET; server_addr.sin_port = htons(8888); server_addr.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(listen_sock, (sockaddr*)&server_addr, sizeof(server_addr)) == SOCKET_ERROR) { printf("bind() failed: %d\n", WSAGetLastError()); closesocket(listen_sock); WSACleanup(); return 1; } // 3. 开始监听,backlog交给系统决定 if (listen(listen_sock, SOMAXCONN) == SOCKET_ERROR) { printf("listen() failed: %d\n", WSAGetLastError()); closesocket(listen_sock); WSACleanup(); return 1; } printf("Server listening on port 8888...\n"); // 4. 接受一个客户端连接,accept会阻塞到这里 sockaddr_in client_addr; int addr_len = sizeof(client_addr); SOCKET client_sock = accept(listen_sock, (sockaddr*)&client_addr, &addr_len); if (client_sock == INVALID_SOCKET) { printf("accept() failed: %d\n", WSAGetLastError()); closesocket(listen_sock); WSACleanup(); return 1; } printf("Client connected from %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); // 5. 回声循环:收到什么就原样发回 char buf[1024]; int ret; while (1) { ret = recv(client_sock, buf, sizeof(buf) - 1, 0); if (ret > 0) { buf[ret] = '\0'; printf("Received %d bytes: %s\n", ret, buf); send(client_sock, buf, ret, 0); } else if (ret == 0) { printf("Client closed the connection\n"); break; } else { printf("recv() failed: %d\n", WSAGetLastError()); break; } } closesocket(client_sock); closesocket(listen_sock); WSACleanup(); return 0; }

每步都做了返回检查,失败时打印WSAGetLastError()的错误码再退出。init失败常见10093,bind失败常见10048端口占用,accept失败一般是对端连接异常。通信结束后要先关client_sock再关listen_sock,最后WSACleanup,顺序反了不影响功能但不符合习惯。

这个示例只accept一次,跑通单连接。要支持客户端断开后重新连接,最朴素的做法是外面套一层while(1),每次accept后处理一次回声循环。但这样做也只是串行服务,第二个客户端要等第一个断开,后面第6章再讲多连接方案。

inet_ntoa(client_addr.sin_addr)用于把in_addr转成点分十进制字符串,注意它返回静态缓冲区,多线程同时调用会互相覆盖。单线程控制台没事,生产环境建议换成inet_ntop,这属于一个早晚要踩的坑。

3.2 sockaddr_in结构体与htons字节序:参数填错的典型症状

sockaddr_in是Winsock里出现频率最高的结构体。四个核心字段:sin_family地址族、sin_port端口、sin_addr地址。sin_family填AF_INET表示IPv4地址族,别和AF_INET6混。sin_zero是填充字段,一般清零,有些代码用memset整段清零,也是一种稳妥做法。

端口和地址在填充时都要经过一次字节序转换。TCP/IP协议栈统一使用大端字节序,而x86机器是小端。htons把主机字节序的unsigned short转成网络字节序,htonl处理32位值。这个转换不做的话,htons(8888)在小端机器上会变成0xC228,也就是十进制49704,客户端按8888去连接,服务器却在49704上等待,结果就是客户端报10061拒绝连接,服务器侧一点点动静都没有。遇到这个症状,第一反应就查端口字节序。

INADDR_ANY是0.0.0.0,表示绑定本机所有网卡地址。写INADDR_ANY时注意它是主机字节序的常量,所以要用htonl转一次。只想监听本机调试,可以用htonl(INADDR_LOOPBACK);想绑定指定IP,用inet_pton(AF_INET, "192.168.1.10", &server_addr.sin_addr)。还有个老写法inet_addr("192.168.1.10"),虽然能用,但它不接受非法格式校验,现在推荐inet_pton。

bind和connect函数签名里写的是sockaddr*,不是sockaddr_in*,所以代码里要强转。这在C语言时代是常见的类型伪装手法,看到别扭的强转不用慌,所有Windows示例都这么写。

3.3 recv返回值三态:>0、==0、==SOCKET_ERROR该怎么区分

recv是Winsock阻塞模型里最容易误判的API。它有三种返回情况,每种对应不同的业务决策。

ret大于0时,表示实际接收到的字节数。这个值可能小于你指定的缓冲区大小,比如你给1024字节缓冲,对端发了100字节,很可能一次recv只拿回100。也可能一次recv同时拿到两条业务消息,这就是后面要说的粘包。TCP是字节流,它不懂你的消息边界,recv只保证从流里读出数据,不保证一次就是一条完整消息。

ret等于0时,对端正常关闭了连接,TCP完成四次挥手,发过FIN包。正确动作是关闭socket、退出循环。很多新手把ret == 0当错误处理,打印一条“recv error”然后break,功能上没大问题,但排查问题时这会给出完全错误的信号:这不是错误,是正常事件。

ret等于SOCKET_ERROR(-1)时才是真出错。此时调用WSAGetLastError()查具体错误码,常见的有10054(连接被重置,对端没正常关闭就拔线)、10060(接收超时,配合SO_RCVTIMEO使用)。如果recv在阻塞模式且没有超时设置,它会一直等下去,不会自己返回SOCKET_ERROR。

这里有一个血泪经验:回声循环里ret == 0时break是正确收尾,但如果你没有break,继续对已关闭的socket调用recv,会得到10053或10054。也就是说,while循环里必须正确判断三态,漏掉ret == 0分支,程序会陷入死循环刷错误码。把三态分支写全,是Winsock编程的及格线。

4. 客户机端实现:connect、send、recv的最小闭环

4.1 完整客户机代码:连接、发送、接收、关闭

客户机比服务器简单,核心四步是socket、connect、send/recv、closesocket。三步初始化也跑不掉:WSAStartup、头文件顺序、链接ws2_32.lib。看代码。

#define WIN32_LEAN_AND_MEAN #include <winsock2.h> #include <ws2tcpip.h> #include <stdio.h> #pragma comment(lib, "ws2_32.lib") int main() { WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { printf("WSAStartup failed\n"); return 1; } SOCKET client_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (client_sock == INVALID_SOCKET) { printf("socket() failed: %d\n", WSAGetLastError()); WSACleanup(); return 1; } sockaddr_in server_addr; server_addr.sin_family = AF_INET; server_addr.sin_port = htons(8888); inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr); if (connect(client_sock, (sockaddr*)&server_addr, sizeof(server_addr)) == SOCKET_ERROR) { printf("connect() failed: %d\n", WSAGetLastError()); closesocket(client_sock); WSACleanup(); return 1; } printf("Connected to server\n"); const char* msg = "hello winsock"; send(client_sock, msg, (int)strlen(msg), 0); char buf[1024]; int ret = recv(client_sock, buf, sizeof(buf) - 1, 0); if (ret > 0) { buf[ret] = '\0'; printf("Server echoed: %s\n", buf); } else if (ret == 0) { printf("Server closed connection\n"); } else { printf("recv() failed: %d\n", WSAGetLastError()); } closesocket(client_sock); WSACleanup(); return 0; }

send的返回值是实际写入系统发送缓冲区的字节数。它可能小于你传入的长度,代码里直接用返回值的话要处理这种情况。例子里的消息短,实际开发建议写一个send_all循环,把剩余部分接着发出去。下面是通用封装。

int send_all(SOCKET s, const char* data, int len) { int sent = 0; int n; while (sent < len) { n = send(s, data + sent, len - sent, 0); if (n == SOCKET_ERROR) return SOCKET_ERROR; sent += n; } return sent; }

这个封装在数据超过系统缓冲区时特别有用。阻塞模式下send的缓冲区满时会等待,但不必依赖这个等待,显式循环让你对“发送了多少”有完全掌控。

4.2 inet_pton与127.0.0.1:本机调试时的几个细节

客户机地址转换推荐用inet_pton。它有三个返回值:1表示成功,0表示地址文本非法,-1表示地址族不支持。下面这行代码把点分十进制文本转成in_addr结构体,存进sockaddr_in的sin_addr字段。

inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);

127.0.0.1是回环地址,本机测试首选。它不走物理网卡,能绕开一部分防火墙策略,但绕不开Windows防火墙的端口限制。如果换成本机局域网IP,比如192.168.1.10,客户端和服务器都要确认在同一网段,且网卡防火墙已放行端口。还有一个细节:回环地址的MTU是65536,局域网通常是1500,这会影响send大块数据时的拆分行为,不过对示例来说无所谓。

调试时如果客户端连不上,优先看错误码。connect失败时,WSAGetLastError()返回10061说明目标端口没有进程监听,10060说明连接超时。超时可能是防火墙丢包,也可能是对端负载过高。这两个错误码值得背下来,排查效率完全不一样。

4.3 send与recv的阻塞语义:为什么客户端要等服务端回包

阻塞模式下,send把数据拷贝进内核发送缓冲区就返回,不等待对端真的收到。recv则不同,只要缓冲区里没有数据就会挂住,直到有数据或对端关闭。所以这个示例最自然的状态是:客户端send完,然后阻塞在recv上,等服务器回声。代码顺序上先send后recv就能跑通。

但如果服务端逻辑不是回声,而是“先收后发”的协议,客户端必须严格按照协议规定的方式收发,否则双方同时阻塞在recv上形成死锁。比如服务器要求先收长度再收内容,客户端如果一口气把两个数据包合并发送,服务器第二个recv就会一直等。解决思路是设计一个明确的消息格式,用长度字段判断一条消息是否完整,这就是第6章要说的消息边界处理。

另外要理解closesocket的语义。直接调用closesocket,如果发送缓冲区还有数据,Windows会尽量把数据发完再关闭,但对方可能因为没读完就返回0,造成数据截断。更优雅的收尾是先调用shutdown(SD_SEND)通知对端“我发完了”,然后继续recv直到收到0,最后再closesocket。示例代码没做这个精修,但你在生产工具里值得加上。

shutdown和closesocket的区别经常被忽略。shutdown只影响数据传输方向,socket句柄还在;closesocket才真正释放句柄和内核资源。SD_SEND表示禁止发送方向,SD_RECEIVE表示禁止接收方向。

5. 常见问题与避坑记录:五个让程序翻车的细节

下面五条是Winsock示例程序调试中出现频率最高的坑。每条按“现象、原因、解决”的顺序写,前三条和代码逻辑有关,后两条和环境与协议有关,排查时按这个优先级来。

5.1 WSAStartup忘记调用:运行时崩溃而非编译失败

现象:socket()返回INVALID_SOCKET,运行程序直接报错退出。

原因:所有Winsock函数依赖WSAStartup完成DLL注册。比编译错误更隐蔽的是,代码在编译器眼里完全正常,只有运行到socket函数那一步才炸。我之前见过有人把WSAStartup写在socket调用之后,一样不行,顺序不能反。

解决:在main的开头调用WSAStartup并检查返回值。调试时用WSAGetLastError()确认错误码,如果拿到10093,基本就是初始化没做。

5.2 bind报错10048:TIME_WAIT与端口复用

现象:服务器程序关闭后立刻重启,bind失败,报错10048(WSAEADDRINUSE)。

原因:上一次运行的服务端socket还停留在TIME_WAIT状态,TCP控制的连接没有完全释放。服务器运维场景里,这个现象非常常见,特别是那种频繁修改代码反复重启的开发阶段。

解决:最稳妥的是换一个端口继续调试。代码层面可以用setsockopt设置SO_REUSEADDR允许端口复用:

BOOL reuse = TRUE; setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, (const char*)&reuse, sizeof(reuse));

注意SO_REUSEADDR在Windows下的行为与Linux不完全一致,它有安全考量,正式服务宁可换端口也别乱开。TIME_WAIT持续时长和TCP实现相关,Windows下默认约两分钟,调试期间直接换端口是最快的后悔药。

5.3 recv返回0被当成错误:误判对端关闭

现象:客户端断开后,服务器打印“recv failed: xxx”,或者程序异常退出。

原因:ret == 0是对端正常关闭连接,不是错误分支。TCP四次挥手完成后,对端会发送FIN包,recv读到FIN就返回0。把它当成SOCKET_ERROR处理,等于把正常事件当成异常流程,日志和业务逻辑都会走歪。

解决:三态判断写全,ret > 0处理数据,ret == 0清理连接并break,ret == SOCKET_ERROR才查WSAGetLastError()。这样客户端正常退出和异常退出才能在日志里区分出来。

5.4 客户端发出去服务器收不到:防火墙拦截

现象:客户端connect成功,send调用返回正常字节数,服务器却什么都收不到。

原因:Windows防火墙默认拦截入站连接,其他机器的数据包进不来。本机回环地址测试通常不受影响,换到局域网IP就暴露了。

解决:先确认监听是否生效,命令行跑netstat -ano | findstr 8888,如果看到LISTENING再检查防火墙。测试环境可以手动给程序添加入站规则,或者临时关闭防火墙。第一反应不该是改代码,而是先确认对端防火墙和端口状态。记住一个排查顺序:先看服务器有没有监听、再看防火墙、最后看代码。

5.5 粘包与半包:recv一次收到多条消息

现象:客户端连续发两条消息,服务器一次recv拿到两条拼接的结果;反过来,客户端发一条长消息,服务器一次recv只拿到半条。

原因:TCP是字节流,没有消息边界。send一次不一定对应recv一次,内核可能合并或拆分你的数据。这是Winsock编程最经典的协议设计问题。

解决:自定义协议头,前4个字节约定为消息长度,接收端先读满4字节长度,再按长度读正文。发送端写完长度字段后,记得用htonl转网络字节序,接收端用ntohl转回主机字节序。半包、粘包在这个设计下都有判断依据:读不足长度就继续读,读多了就缓冲等下次再处理。

6. 从单连接到多连接:select模型与消息边界

单连接阻塞模型跑通之后,接手真实需求时别急着开多线程。线程模型在几十个连接内能用,但线程数一多,上下文切换和内存占用都会暴露问题。Winsock的轻量级方案是select模型:用一个线程同时监听多个socket,时间复杂度O(n),对几百个连接以内的工具型程序足够,也是一种典型的异步编程思路。

select的核心是fd_set集合,三个步骤:FD_ZERO清空集合,FD_SET把要监听的socket加进集合,select阻塞等待集合里有socket可读。代码如下。

fd_set readfds; FD_ZERO(&readfds); FD_SET(listen_sock, &readfds); FD_SET(client_sock, &readfds); struct timeval tv; tv.tv_sec = 1; tv.tv_usec = 0; int ret = select(0, &readfds, NULL, NULL, &tv); if (ret > 0) { if (FD_ISSET(listen_sock, &readfds)) { // 新连接到来,accept并加入集合 } if (FD_ISSET(client_sock, &readfds)) { // 已有连接可读,recv处理 } } else if (ret == 0) { // 超时,没有就绪socket }

select第一个参数在Windows下可以填0,它不像Linux那样要求nfds加1。timeval里的tv_sec和tv_usec共同决定超时时间,传NULL则无限等待。读事件触发之后,recv不会阻塞,这也就解决了阻塞模型在多个连接上轮流卡死的问题。

消息边界问题可以一并解决。自定义协议头做法:发送方先发4字节的整型长度,再发正文;接收方先recv满4字节,用ntohl转主机字节序,再按长度recv正文。半包、粘包在这个设计下都有了判断依据。

当年我给一批工控机做数据采集工具,一开始用阻塞单线程,晚上有一台设备断网,整个采集线程卡在recv上,其余十几台设备全部排队,第二天中午才发现。后来换成select模型加4字节长度头,故障隔离和消息完整性都稳了下来。这个教训让我明白:Winsock示例看着简单,但并发边界和消息边界从一开始就要想清楚,别等上线后翻车再补课。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询