本文基于一份「单 exe 双角色」的 Windows TCP 回显服务器/客户端示例代码,系统梳理 Windows 平台 TCP Socket 编程的知识点。涵盖头文件与链接、Winsock 生命周期、字节序、地址结构、核心 API、常见错误码与排查等内容。
一、完整示例代码(可直接编译运行)
cpp
#define WIN32_LEAN_AND_MEAN // 阻止 windows.h 自动包含旧版 winsock.h
#include <winsock2.h>
#include <ws2tcpip.h>
#include <cstdio>
#include <cstring>
#include <string>
#include <iostream>
#pragma comment(lib, "ws2_32.lib") // 链接 Winsock 导入库
static const int PORT = 8888;
// ---------------- 服务器 ----------------
int runServer()
{
// (1) 初始化 Winsock
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) {
printf("WSAStartup failed: %d\n", WSAGetLastError());
return 1;
}
// (2) 创建监听套接字
SOCKET srv = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (srv == INVALID_SOCKET) {
printf("socket failed: %d\n", WSAGetLastError());
WSACleanup();
return 1;
}
// (3) 绑定地址
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(PORT);
if (bind(srv, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) {
printf("bind failed: %d\n", WSAGetLastError());
closesocket(srv);
WSACleanup();
return 1;
}
// (4) 监听
if (listen(srv, SOMAXCONN) == SOCKET_ERROR) {
printf("listen failed: %d\n", WSAGetLastError());
closesocket(srv);
WSACleanup();
return 1;
}
printf("Server listening on port %d ...\n", PORT);
// (5) 接受一个连接
sockaddr_in cliAddr{};
int cliAddrLen = sizeof(cliAddr);
SOCKET cli = accept(srv, (sockaddr*)&cliAddr, &cliAddrLen);
if (cli == INVALID_SOCKET) {
printf("accept failed: %d\n", WSAGetLastError());
closesocket(srv);
WSACleanup();
return 1;
}
printf("Client connected.\n");
// (6) 收发循环:原样回显
char buf[1024];
int n;
while ((n = recv(cli, buf, sizeof(buf) - 1, 0)) > 0) {
buf[n] = '\0';
printf("Recv: %s", buf);
send(cli, buf, n, 0);
}
if (n < 0)
printf("recv failed: %d\n", WSAGetLastError());
// (7) 清理
printf("Client disconnected.\n");
closesocket(cli);
closesocket(srv);
WSACleanup();
return 0;
}
// ---------------- 客户端 ----------------
int runClient(const char* host)
{
// (1) 初始化
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) {
printf("WSAStartup failed: %d\n", WSAGetLastError());
return 1;
}
// (2) 创建
SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (s == INVALID_SOCKET) {
printf("socket failed: %d\n", WSAGetLastError());
WSACleanup();
return 1;
}
// (3) 填写目标地址并连接
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
if (inet_pton(AF_INET, host, &addr.sin_addr) != 1) {
printf("invalid ip: %s\n", host);
closesocket(s);
WSACleanup();
return 1;
}
if (connect(s, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) {
printf("connect failed: %d\n", WSAGetLastError());
closesocket(s);
WSACleanup();
return 1;
}
printf("Connected to %s:%d\n", host, PORT);
// (4) 键盘输入 → 发送 → 接收回显
char buf[1024];
std::string line;
while (std::getline(std::cin, line)) {
line += "\n";
send(s, line.c_str(), (int)line.size(), 0);
int n = recv(s, buf, sizeof(buf) - 1, 0);
if (n <= 0) break;
buf[n] = '\0';
printf("Echo: %s", buf);
}
// (5) 清理
closesocket(s);
WSACleanup();
return 0;
}
// ---------------- 入口 ----------------
int main(int argc, char* argv[])
{
if (argc < 2) {
printf("Usage: %s server | client [ip]\n", argv[0]);
return 1;
}
if (strcmp(argv[1], "server") == 0)
return runServer();
return runClient(argc > 2 ? argv[2] : "127.0.0.1");
}
二、编译、启动与测试
2.1 编译命令
MSVC(命令行):
cmd
cl /EHsc /W4 tcp_echo.cpp /link ws2_32.lib
(代码里有 #pragma comment,所以 ws2_32.lib 可省略,但显式写上更清楚。)
MinGW / GCC:
cmd
g++ -Wall -Wextra -o tcp_echo.exe tcp_echo.cpp -lws2_32
Visual Studio :新建控制台项目,把代码粘进 .cpp,直接编译即可(#pragma comment 自动链接)。
2.2 测试步骤
方式 A:VS 调试 + 命令行客户端
- 右键项目 → 属性 → 调试 → 命令参数填
server; Ctrl+F5启动,看到Server listening on port 8888 ...;- 打开
x64\Debug\TcpConsole.exe所在目录,地址栏输入cmd; - 执行
TcpConsole.exe client 127.0.0.1; - 客户端输入
hello回车,预期:- 服务器窗口打印
Recv: hello - 客户端窗口打印
Echo: hello
- 服务器窗口打印
方式 B:两个 cmd 窗口
cmd
:: 窗口 1
TcpConsole.exe server
:: 窗口 2
TcpConsole.exe client 127.0.0.1
效果相同。
结束 :客户端 Ctrl+Z 回车(EOF)或关窗口;服务器打印 Client disconnected. 后退出。
三、为什么 Windows 的 Socket 编程和 Linux "不一样"
如果你写过 Linux 下的 socket,会发现两者 API 名称高度相似(socket、bind、listen、accept、connect、send、recv),但 Windows 上有几个必须注意的差异点,否则代码根本跑不起来:
| 差异点 | Linux | Windows |
|---|---|---|
| 网络库初始化 | 不需要 | 必须调用 WSAStartup ,进程退出前 WSACleanup |
| 套接字类型 | int 文件描述符 |
SOCKET(本质是 UINT_PTR 句柄) |
| 无效套接字 | -1 |
INVALID_SOCKET(~0) |
| 关闭套接字 | close() |
closesocket() |
| 获取错误码 | errno |
WSAGetLastError() |
| 链接方式 | 一般无需额外链接 | 需链接 ws2_32.lib (或动态加载 ws2_32.dll) |
| 头文件 | <sys/socket.h> 等 |
<winsock2.h> + <ws2tcpip.h> |
| 错误返回 | 一般是 -1 |
SOCKET_ERROR(-1)或 INVALID_SOCKET,视函数而定 |
Windows 的网络栈在用户态通过 Winsock DLL 暴露,调用任何网络 API 之前必须先"加载"它,这就是 WSAStartup 存在的原因;而 Linux 的 socket 是内核系统调用,天然可用。
四、Winsock 初始化:WSAStartup 与 WSACleanup
4.1 为什么需要它
Windows 的 ws2_32.dll 是网络 API 的实现载体。WSAStartup 做两件事:
- 加载 Winsock DLL 到进程地址空间;
- 协商版本:告诉系统"我要用 Winsock 2.2"。
没有调用它就直接 socket(),会返回 WSANOTINITIALISED(错误码 10093)。
4.2 版本协商
cpp
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) {
printf("WSAStartup failed: %d\n", WSAGetLastError());
return 1;
}
| 参数 | 含义 |
|---|---|
MAKEWORD(2, 2) |
请求版本 2.2(低字节主版本 2,高字节次版本 2) |
&wsa |
输出参数,系统返回实际支持的版本和实现信息 |
MAKEWORD(2,2) 是一个宏,把两个字节拼成一个 WORD:
c
#define MAKEWORD(a, b) ((WORD)(((BYTE)(a)) | ((WORD)((BYTE)(b))) << 8))
第一个参数 a 在低字节,第二个参数 b 在高字节
即 0x0202。如果系统只支持 2.1,wsa.wVersion 会是 0x0201,此时应当降级或报错。
4.3 成对调用
WSAStartup 和 WSACleanup 应当成对出现 ,且 WSACleanup 会减少内部引用计数。示例代码里服务器和客户端各调一次、各清一次,这在"单进程单角色"场景下是正确且简单的做法。
实践提示 :如果程序有多个模块都用网络,可以在进程入口统一
WSAStartup一次、出口WSACleanup一次,避免重复初始化。多次WSAStartup是允许的(引用计数),但必须对应相同次数的WSACleanup。
五、头文件、宏与链接:三行代码背后的坑
5.1 WIN32_LEAN_AND_MEAN
cpp
#define WIN32_LEAN_AND_MEAN
它的作用是让 <windows.h> 不自动包含旧版 winsock.h。如果不定义:
- 若你先
#include <windows.h>,再#include <winsock2.h>,两个头文件都会定义winsock相关结构,产生大量重复定义错误; - 定义它之后,
windows.h会"瘦身",跳过winsock.h,只保留基础 Win32 API。
虽然这份代码没有直接包含 windows.h,但这是 Windows 网络编程的标准习惯 ,建议永远写上。更彻底的写法是先包含 winsock2.h 再包含 windows.h,这样 winsock2.h 会先被处理,当 windows.h 随后试图包含 winsock.h 时,由于相关的定义已经存在,就会触发头文件的包含保护机制,从而避免冲突。但 WIN32_LEAN_AND_MEAN 是最省心的。
5.2 两个头文件
cpp
#include <winsock2.h>
#include <ws2tcpip.h>
| 头文件 | 提供内容 | 注意 |
|---|---|---|
winsock2.h |
SOCKET、sockaddr_in、socket、bind、listen、accept、connect、send、recv、WSAStartup、WSAGetLastError 等 |
必须早于 windows.h,没宏定义的情况下 |
ws2tcpip.h |
inet_pton、inet_ntop、getaddrinfo 等协议无关(IPv4/IPv6)接口 |
必须在 winsock2.h 之后 ,且不能和旧 winsock.h 共存 |
ws2tcpip.h 的命名含义是 "Winsock 2 TCP/IP helper",专为 Winsock 2 设计的扩展,它在逻辑和代码上都强依赖 winsock2.h ,并且与旧版 winsock.h 互斥
5.3 链接:#pragma comment(lib, ...)
cpp
#pragma comment(lib, "ws2_32.lib")
这行是 MSVC 特有的链接指令 ,等价于在项目里手动加 ws2_32.lib。它告诉链接器:
如果代码里引用了
socket、bind、WSAStartup等符号,去ws2_32.lib里找它们的导入地址。
这些函数的真实实现 在系统目录的 ws2_32.dll 里,ws2_32.lib 只是导入库(import library),负责把调用转发到 DLL。
非 MSVC 编译器 (如 MinGW/GCC)不支持
#pragma comment,需要在命令行显式加-lws2_32。
六、字节序与地址结构:最容易写错的细节
6.1 网络字节序 = 大端
x86/x64 CPU 是小端 (低位字节放低地址),而 TCP/IP 协议规定网络上传输的多字节整数必须是大端 (高位字节放低地址)。所以端口、IP 这类数值在填进 sockaddr_in 之前必须转换。
| 函数 | 方向 | 用途 |
|---|---|---|
htons |
主机 → 网络 | 16 位(端口) |
htonl |
主机 → 网络 | 32 位(IPv4 地址) |
ntohs |
网络 → 主机 | 16 位 |
ntohl |
网络 → 主机 | 32 位 |
记忆:h ost / n etwork / to / s hort / long。
6.2 sockaddr_in 结构
IPv4 专用地址结构:
cpp
struct sockaddr_in {
short sin_family; // 地址族,AF_INET
u_short sin_port; // 端口,网络字节序
struct in_addr sin_addr; // IPv4 地址,网络字节序
char sin_zero[8]; // 填充,通常置 0
};
为什么 bind/connect 的参数类型是 sockaddr* 而不是 sockaddr_in*?因为 sockaddr 是通用 地址结构(IPv4/IPv6/Unix 域都用它),sockaddr_in 是它的 IPv4 具体化。使用时需要强制转换:
cpp
bind(srv, (sockaddr*)&addr, sizeof(addr));
sizeof(addr) 这里传的是 sockaddr_in 的大小,Windows 上 bind 的第三个参数是 int(Linux 是 socklen_t),这也是一个平台差异。
6.3 零初始化的必要性
cpp
sockaddr_in addr{}; // 关键:{} 零初始化
sin_zero 那 8 个字节填充如果不置 0,某些系统实现可能行为异常(虽然现代 Winsock 一般不校验)。用 {} 是最省事的写法。
6.4 INADDR_ANY 与 inet_pton
cpp
addr.sin_addr.s_addr = INADDR_ANY; // 服务器:监听所有网卡
inet_pton(AF_INET, host, &addr.sin_addr); // 客户端:字符串 IP → 二进制
| 写法 | 含义 |
|---|---|
INADDR_ANY(0.0.0.0) |
监听本机所有网卡(127.0.0.1、局域网 IP、公网 IP 全部接受) |
inet_addr("127.0.0.1") |
只监听回环(已过时,不推荐) |
inet_pton(AF_INET, "127.0.0.1", &in) |
推荐 ,现代、可检测错误、支持 IPv6(AF_INET6) |
inet_pton 返回值:1 成功、0 格式非法、-1 系统错误。示例代码没检查,这在小 demo 里可接受,生产代码必须检查。
七、TCP 套接字生命周期全景
先建立整体图景,再看每个函数:
服务器:WSAStartup → socket → bind → listen → accept → recv/send 循环 → closesocket → WSACleanup
客户端:WSAStartup → socket → connect → send/recv 循环 → closesocket → WSACleanup
用一张 ASCII 图表示两端交互:
客户端 服务器
│ │
│ WSAStartup() → 返回 0 │ WSAStartup() → 返回 0
│ socket() → 返回 s │ socket() → 返回 srv
│ │ bind() → 返回 0
│ │ listen() → 返回 0
│ │
│ ┌─ connect(s,...) 调用(阻塞) │ ┌─ accept(srv,...) 调用(阻塞)
│ │ │ │
│ ├──── SYN ──────────────────────────────────────────► │ │
│ │ │ │(内核自动回 SYN+ACK)
│ │◄─────────────────────────────── SYN+ACK ────────────────┤ │
│ │ │ │
│ ├──── ACK ──────────────────────────────────────────► │ │
│ │ │ │ 三次握手完成
│ │ │ │ 连接进入"已完成队列"
│ ▼ │ ▼
│ connect() 返回 0 │ accept() 返回新 SOCKET cli
│ │
│ ┌─ send(s,"hello\n",6,0) 调用 │
│ │ (数据拷入内核发送缓冲区) │
│ ▼ │
│ send() 立即返回 6 │
│ │ │
│ ├──── "hello\n" ────────────────────────────────────► │(内核 ACK,放入接收缓冲区)
│ │ │ ▼
│ │ │ recv(cli,...) 返回 6
│ │ │
│ │ │ ┌─ send(cli,buf,6,0) 调用
│ │ │ ▼
│ │ │ send() 立即返回 6
│ │ │ │
│ │◄─────────────────────────────── "hello\n" ──────────────┤ │
│ ▼ │
│ recv(s,...) 返回 6 │
│ │
│ ┌─ closesocket(s) 调用 │
│ ├──── FIN ──────────────────────────────────────────► │(内核 ACK)
│ │◄─────────────────────────────── ACK ────────────────────┤ │
│ ▼ │ ▼
│ closesocket() 立即返回 │ recv(cli,...) 返回 0
│ (进入 FIN_WAIT_2,等待对端 FIN) │
│ │ ┌─ closesocket(cli) 调用
│◄─────────────────────────────── FIN ──────────────────────┤ │
│ │ ▼
│ ├──── ACK ──────────────────────────────────────────► │ closesocket() 立即返回
│ │ (进入 CLOSED)
│ (进入 TIME_WAIT,等 2MSL 后才真正释放) │
│ │
│ WSACleanup() │ WSACleanup()
八、服务器端逐段详解
服务器端流程:初始化 → socket → bind → listen → accept → 收发循环 → 清理。
8.1 socket():创建套接字
cpp
SOCKET srv = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (srv == INVALID_SOCKET) {
printf("socket failed: %d\n", WSAGetLastError());
WSACleanup();
return 1;
}
三个参数的含义:
| 参数 | 值 | 说明 |
|---|---|---|
af(地址族) |
AF_INET |
IPv4;AF_INET6 为 IPv6 |
type(类型) |
SOCK_STREAM |
面向连接、可靠、有序(TCP);SOCK_DGRAM 为 UDP |
protocol |
IPPROTO_TCP |
显式指定 TCP;也可以传 0 让系统根据 type 自动选择 |
返回值 SOCKET 在 Windows 上是 UINT_PTR(无符号指针大小整数),本质是内核句柄 。失败返回 INVALID_SOCKET((SOCKET)(~0))。
为什么协议参数可以传 0? 因为
SOCK_STREAM + AF_INET唯一对应 TCP,系统能推断出来。但显式写IPPROTO_TCP可读性更好。
8.2 bind():绑定本地地址
cpp
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(PORT);
if (bind(srv, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) {
printf("bind failed: %d\n", WSAGetLastError());
closesocket(srv);
WSACleanup();
return 1;
}
bind 把"本机某 IP:某端口"和套接字关联起来。几个要点:
INADDR_ANY:监听所有网卡。如果只想本机测试,可以inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr)。htons(PORT):端口必须转网络字节序,否则客户端看到的是乱端口。sizeof(addr):Windows 上bind第三个参数是int,不是socklen_t。- 失败常见原因 :端口被占用(
WSAEADDRINUSE,10048)、权限不足(1024 以下端口在部分系统需要管理员)。
8.3 listen():进入监听状态
cpp
listen(srv, SOMAXCONN);
listen 把主动套接字 变成被动监听套接字 ,并设置连接等待队列(backlog)的最大长度。
| 参数 | 说明 |
|---|---|
srv |
已 bind 的套接字 |
SOMAXCONN |
系统允许的最大 backlog,直接用它即可 |
listen 成功后,内核会为这个套接字维护两个队列:
- 未完成连接队列:收到 SYN、还没完成三次握手的连接;
- 已完成连接队列 :三次握手完成、等待
accept取走的连接。
accept 从"已完成队列"里取。当队列满时,新连接会被丢弃或拒绝(取决于系统实现),这就是为什么高并发服务器要合理设置 backlog 并及时 accept。
重要 :
listen只是"允许连接",并不产生连接。真正的连接要等accept取出来。
8.4 accept():接受连接
cpp
sockaddr_in cliAddr{};
int cliAddrLen = sizeof(cliAddr);
SOCKET cli = accept(srv, (sockaddr*)&cliAddr, &cliAddrLen);
if (cli == INVALID_SOCKET) { ... }
accept 是阻塞的:没客户端来就一直卡在这里。有客户端完成三次握手后:
- 返回一个新的套接字
cli,专门代表"和这个客户端的这条连接"; - 原来的
srv仍然处于监听状态 ,可以继续accept; cliAddr被填入对端的 IP 和端口(这里没打印,但结构正确)。
| 参数 | 方向 | 说明 |
|---|---|---|
s |
输入 | 监听套接字 |
addr |
输出 | 对端地址(可为 NULL) |
addrlen |
输入/输出 | 输入是缓冲区大小,输出是实际写入大小 |
核心概念 :
srv和cli是两个不同的套接字。srv只负责"接客",cli才负责"服务"。示例代码只accept了一次,所以一次只能服务一个客户端,客户端断开后服务器就退出了。
8.5 recv() / send():收发循环
cpp
char buf[1024];
int n;
while ((n = recv(cli, buf, sizeof(buf) - 1, 0)) > 0) {
buf[n] = '\0';
printf("Recv: %s", buf);
send(cli, buf, n, 0); // 原样回显
}
recv 的返回值是理解 TCP 的关键:
| 返回值 | 含义 | 处理 |
|---|---|---|
> 0 |
实际读到的字节数 | 处理数据,继续循环 |
= 0 |
对端正常关闭(发了 FIN) | 退出循环,关闭套接字 |
< 0 |
出错(SOCKET_ERROR) |
用 WSAGetLastError() 查原因 |
send 的返回值:
| 返回值 | 含义 |
|---|---|
> 0 |
实际写入内核发送缓冲区的字节数(不保证已到达对端) |
SOCKET_ERROR |
出错 |
几个容易忽略的点:
buf[n] = '\0':recv不会帮你补字符串结束符,必须手动加。sizeof(buf) - 1:留一个字节给'\0'。send(cli, buf, n, 0):把刚收到的n个字节原样发回,这就是"回显"。send不保证一次发完:大数据时返回值可能小于请求长度,需要循环发送。示例数据小、本地回环,基本不会触发。
字节流本质 :
recv一次不保证收到一个完整"消息",send一次也不保证发完。这是 TCP 的固有特性,见第九节。
8.6 清理
cpp
closesocket(cli);
closesocket(srv);
WSACleanup();
先关连接套接字,再关监听套接字,最后卸载 Winsock。顺序反过来也能跑,但先关连接更符合逻辑。
closesocket 会触发 TCP 的四次挥手(如果对端还开着)。如果进程直接退出,内核会代为关闭,但显式关闭更干净。
九、客户端逐段详解
客户端流程:初始化 → socket → connect → 收发 → 清理 。与服务器对称,但少了 bind、listen、accept。
9.1 客户端不需要 bind
cpp
SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
客户端 socket 之后直接 connect,不需要 bind 。connect 时内核会自动分配一个临时端口(ephemeral port,范围通常是 49152--65535)和本机 IP。
如果确实需要指定源地址/源端口(比如多网卡场景),可以先
bind再connect,但不是必须的。
9.2 connect():发起三次握手
cpp
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
inet_pton(AF_INET, host, &addr.sin_addr);
if (connect(s, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) {
printf("connect failed: %d\n", WSAGetLastError());
...
}
connect 做两件事:
- 向服务器发 SYN;
- 阻塞等待三次握手完成(或失败)。
| 返回值 | 含义 |
|---|---|
0 |
连接建立成功 |
SOCKET_ERROR |
失败,WSAGetLastError() 给出原因 |
常见错误码:
| 错误码 | 常量 | 原因 |
|---|---|---|
| 10061 | WSAECONNREFUSED |
服务器没监听该端口 / 端口不对 |
| 10060 | WSAETIMEDOUT |
连接超时(网络不通/防火墙丢包) |
| 10051 | WSAENETUNREACH |
网络不可达 |
| 10049 | WSAEADDRNOTAVAIL |
指定地址不可用 |
9.3 键盘输入 → 发送 → 接收回显
cpp
char buf[1024];
std::string line;
while (std::getline(std::cin, line)) {
line += "\n";
send(s, line.c_str(), (int)line.size(), 0);
int n = recv(s, buf, sizeof(buf) - 1, 0);
if (n <= 0) break;
buf[n] = '\0';
printf("Echo: %s", buf);
}
std::getline(std::cin, line):读一整行,不含换行符;读到 EOF(Ctrl+Z)或出错返回 false。line += "\n":手动补换行,让服务器端printf("Recv: %s")能换行显示。send发出这一行。- 然后同步等待 回显:
recv一次,打印Echo: ...。 n <= 0说明服务器关闭或出错,退出循环。
这是一个一问一答 模型:发一行、收一次。真实场景中如果服务器一次回多条、或回得慢,客户端可能只收到一部分;通常要把接收放到独立线程或使用
select/IOCP等 I/O 多路复用机制。
9.4 客户端清理
cpp
closesocket(s);
WSACleanup();
和服务器一样,成对清理。
十、关键 API 速查表
10.1 核心函数总表
| API | 作用 | 谁用 | 成功返回 | 失败返回 | 备注 |
|---|---|---|---|---|---|
WSAStartup |
初始化 Winsock | 两者 | 0 |
非 0 错误码 | Windows 特有,必须最先调用 |
WSACleanup |
卸载 Winsock | 两者 | 0 |
SOCKET_ERROR |
与 WSAStartup 配对 |
socket |
创建套接字 | 两者 | SOCKET |
INVALID_SOCKET |
失败用 WSAGetLastError |
bind |
绑定本地 IP:端口 | 服务器 | 0 |
SOCKET_ERROR |
客户端一般不需要 |
listen |
进入监听状态 | 服务器 | 0 |
SOCKET_ERROR |
设置 backlog |
accept |
接受一个连接 | 服务器 | 新 SOCKET |
INVALID_SOCKET |
阻塞;返回的新套接字用于通信 |
connect |
连接服务器 | 客户端 | 0 |
SOCKET_ERROR |
触发三次握手 |
send |
发送数据 | 两者 | 实际发送字节数 | SOCKET_ERROR |
不保证一次发完 |
recv |
接收数据 | 两者 | 实际接收字节数;0=对端关闭 |
SOCKET_ERROR |
不保证一次收全 |
closesocket |
关闭套接字 | 两者 | 0 |
SOCKET_ERROR |
Windows 特有(Linux 用 close) |
WSAGetLastError |
获取最近错误码 | 两者 | 错误码 | --- | 线程局部 |
htons / htonl |
主机→网络字节序 | 两者 | --- | --- | 端口/地址必须转换 |
ntohs / ntohl |
网络→主机字节序 | 两者 | --- | --- | 接收端解析时用 |
inet_pton |
字符串 IP → 二进制 | 两者 | 1 |
0(格式错)/-1 |
支持 IPv4/IPv6 |
inet_ntop |
二进制 IP → 字符串 | 两者 | 指向结果 | NULL |
打印对端地址时用 |
getsockname |
获取本地地址 | 两者 | 0 |
SOCKET_ERROR |
客户端查临时端口 |
getpeername |
获取对端地址 | 两者 | 0 |
SOCKET_ERROR |
服务器查客户端 IP |
10.2 socket() 参数组合速查
| 地址族 | 类型 | 协议 | 说明 |
|---|---|---|---|
AF_INET |
SOCK_STREAM |
IPPROTO_TCP |
IPv4 TCP(本文) |
AF_INET6 |
SOCK_STREAM |
IPPROTO_TCP |
IPv6 TCP |
AF_INET |
SOCK_DGRAM |
IPPROTO_UDP |
IPv4 UDP |
AF_INET6 |
SOCK_DGRAM |
IPPROTO_UDP |
IPv6 UDP |
10.3 sockaddr_in 字段速查
| 字段 | 类型 | 赋值 | 字节序 |
|---|---|---|---|
sin_family |
short |
AF_INET |
主机序 |
sin_port |
u_short |
htons(PORT) |
网络序 |
sin_addr.s_addr |
u_long |
INADDR_ANY 或 inet_pton 结果 |
网络序 |
sin_zero[8] |
char[8] |
零初始化即可 | --- |
10.4 recv 返回值语义表(务必记牢)
| 返回值 | 含义 | 正确处理 |
|---|---|---|
> 0 |
收到 n 字节 |
处理,继续循环 |
0 |
对端有序关闭 | 退出循环,closesocket |
SOCKET_ERROR |
出错 | WSAGetLastError 区分致命/可重试 |
WSAGetLastError 的常见值:
| 错误码 | 常量 | 场景 |
|---|---|---|
| 10054 | WSAECONNRESET |
对端强制重置(RST),连接被重置 |
| 10053 | WSAECONNABORTED |
本地软件导致连接中止 |
| 10060 | WSAETIMEDOUT |
超时 |
| 10035 | WSAEWOULDBLOCK |
非阻塞模式下无数据可读(可重试) |
关于 SO_REUSEADDR
setsockopt 是套接字编程中用来调整底层协议栈行为的核心函数。 SO_REUSEADDR、TCP_NODELAY 等,都通过它来设置。
📌 函数原型与参数
c
int WSAAPI setsockopt(
[in] SOCKET s,
[in] int level,
[in] int optname,
[in] const char *optval,
[in] int optlen
);
| 参数 | 类型 | 含义 | 关键说明 |
|---|---|---|---|
s |
SOCKET |
套接字描述符 | 要操作的套接字 |
level |
int |
选项定义所在的协议层级 | 决定 optname 的生效范围,常用 SOL_SOCKET、IPPROTO_TCP |
optname |
int |
要设置的具体选项名 | 必须是 level 层级内定义的选项,否则行为未定义 |
optval |
const char* |
指向选项新值的缓冲区 | 值的数据类型由 optname 决定(布尔、整数、结构体等) |
optlen |
int |
optval 缓冲区的字节大小 |
必须准确匹配 optval 实际指向的数据类型大小 |
返回值 :成功返回 0;失败返回 SOCKET_ERROR,需用 WSAGetLastError() 获取具体错误码。
常见选项速查表(按 level 分类)
SOL_SOCKET 层级(通用套接字选项)
选项 (optname) |
作用 | optval 类型 |
关键注意事项 |
|---|---|---|---|
SO_REUSEADDR |
允许绑定已被使用的地址 | BOOL (int) |
Windows 上语义危险 :可强行绑定活跃端口,导致端口劫持;服务器程序不推荐使用 |
SO_EXCLUSIVEADDRUSE |
独占端口使用权,阻止其他套接字绑定 | BOOL (int) |
Windows 特有,服务器应优先使用;防止端口被劫持,是安全默认值 |
SO_RCVBUF / SO_SNDBUF |
设置接收/发送缓冲区大小 | int |
内核可能不完全满足请求值,实际值需用 getsockopt 查询 |
SO_RCVTIMEO / SO_SNDTIMEO |
设置接收/发送超时 | int (毫秒) |
防止 recv/send 无限阻塞 |
SO_KEEPALIVE |
启用 TCP 保活机制 | BOOL (int) |
检测对端是否存活,但默认探测间隔较长(约 2 小时) |
IPPROTO_TCP 层级(TCP 专属选项)
选项 (optname) |
作用 | optval 类型 |
关键注意事项 |
|---|---|---|---|
TCP_NODELAY |
禁用 Nagle 算法 | BOOL (int) |
设为 1 禁用合并小包,降低延迟;适合交互式应用(游戏、SSH),但会增加小包数量 |
TCP_MAXSEG |
设置 TCP 最大段大小 (MSS) | int |
限制每个 TCP 段携带的数据量,通常由系统自动协商,非必要不修改 |
IPPROTO_IP 层级(IP 层选项,非 TCP 特有)
选项 (optname) |
作用 | optval 类型 |
关键注意事项 |
|---|---|---|---|
IP_ADD_MEMBERSHIP |
加入多播组 | struct ip_mreq |
用于多播接收,与 TCP 无关 |
IP_DROP_MEMBERSHIP |
离开多播组 | struct ip_mreq |
同上 |
典型用法示例
cpp
// 1) 设置服务器独占端口(推荐,bind 之前)
int exclusive = 1;
setsockopt(srv, SOL_SOCKET, SO_EXCLUSIVEADDRUSE,
(const char*)&exclusive, sizeof(exclusive));
// 2) 禁用 Nagle 算法,降低小消息延迟(TCP 连接建立后)
int nodelay = 1;
setsockopt(s, IPPROTO_TCP, TCP_NODELAY,
(const char*)&nodelay, sizeof(nodelay));
// 3) 设置接收超时为 5 秒,防止 recv 永久阻塞
int timeout_ms = 5000;
setsockopt(s, SOL_SOCKET, SO_RCVTIMEO,
(const char*)&timeout_ms, sizeof(timeout_ms));
重要时机 :
SO_EXCLUSIVEADDRUSE、SO_REUSEADDR等地址相关选项必须在bind()之前 设置;TCP_NODELAY等连接属性选项通常在connect()/accept()之后设置即可。部分 TCP/IP 选项在bind之前设置可能不会立即被协议栈校验,导致setsockopt表面成功但bind时才报错。
十一、总结
Windows 下 TCP 套接字编程的要点可以浓缩为几条:
- 初始化是前提 :
WSAStartup/WSACleanup成对出现,Windows 特有。 - 头文件顺序是坑 :
WIN32_LEAN_AND_MEAN+winsock2.h先于windows.h,ws2tcpip.h在后。 - 链接不能忘 :
#pragma comment(lib, "ws2_32.lib")或-lws2_32。 - 字节序必须转 :端口
htons,地址用inet_pton。 - 服务器三件套 :
bind→listen→accept;accept返回新套接字用于通信。 - 客户端两件套 :
socket→connect,无需bind。 recv返回 0 = 对端关闭,这是退出循环的正确条件。- TCP 是字节流:没有消息边界,必须自己设计协议处理粘包/半包。
send可能发不全:生产代码要循环发送。TIME_WAIT导致 10048 :用SO_REUSEADDR或等 2MSL。- 错误码是诊断核心 :
WSAGetLastError()配合错误码表快速定位。 - demo 与生产的差距 :循环
accept+ 线程/IOCP、超时、心跳、优雅关闭、协议设计。
这份回显服务器/客户端虽然只有百来行,却把 Winsock 生命周期、TCP 连接建立与关闭、阻塞 I/O、字节流处理等核心概念都串了起来。把它跑通、再对照本文逐条扩展,就完成了从"能跑"到"理解"再到"能写生产代码"的完整路径。