Windows 下 TCP 套接字编程指南::从 Winsock 初始化到回显服务器

本文基于一份「单 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 调试 + 命令行客户端

  1. 右键项目 → 属性 → 调试 → 命令参数填 server;
  2. Ctrl+F5 启动,看到 Server listening on port 8888 ...;
  3. 打开 x64\Debug\TcpConsole.exe 所在目录,地址栏输入 cmd;
  4. 执行 TcpConsole.exe client 127.0.0.1;
  5. 客户端输入 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 做两件事:

  1. 加载 Winsock DLL 到进程地址空间;
  2. 协商版本:告诉系统"我要用 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 做两件事:

  1. 向服务器发 SYN;
  2. 阻塞等待三次握手完成(或失败)。
返回值 含义
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 套接字编程的要点可以浓缩为几条:

  1. 初始化是前提 :WSAStartup / WSACleanup 成对出现,Windows 特有。
  2. 头文件顺序是坑 :WIN32_LEAN_AND_MEAN + winsock2.h 先于 windows.h,ws2tcpip.h 在后。
  3. 链接不能忘 :#pragma comment(lib, "ws2_32.lib") 或 -lws2_32。
  4. 字节序必须转 :端口 htons,地址用 inet_pton。
  5. 服务器三件套 :bind → listen → accept;accept 返回新套接字用于通信。
  6. 客户端两件套 :socket → connect,无需 bind。
  7. recv 返回 0 = 对端关闭,这是退出循环的正确条件。
  8. TCP 是字节流:没有消息边界,必须自己设计协议处理粘包/半包。
  9. send 可能发不全:生产代码要循环发送。
  10. TIME_WAIT 导致 10048 :用 SO_REUSEADDR 或等 2MSL。
  11. 错误码是诊断核心 :WSAGetLastError() 配合错误码表快速定位。
  12. demo 与生产的差距 :循环 accept + 线程/IOCP、超时、心跳、优雅关闭、协议设计。

这份回显服务器/客户端虽然只有百来行,却把 Winsock 生命周期、TCP 连接建立与关闭、阻塞 I/O、字节流处理等核心概念都串了起来。把它跑通、再对照本文逐条扩展,就完成了从"能跑"到"理解"再到"能写生产代码"的完整路径。

相关推荐
明天也要努力学习1 小时前
IP 协议的相关特性
网络·网络协议·tcp/ip
上海云盾-小余9 小时前
七层 CC 攻击检测难点:如何区分真实用户与攻击请求
网络·人工智能·windows·tcp/ip·udp
JSON_L9 小时前
TCP 粘包拆包解析及示例
网络·网络协议·tcp/ip
HackTwoHub12 小时前
红队低成本 IP 轮换方案 ProxyCat升级版,自动扩容代理池、负载分摊转发,一键部署,告别高价隧道代理
网络·人工智能·网络协议·tcp/ip·安全·web安全·网络安全
幻智星科技13 小时前
UDP桥接IP产品简介
tcp/ip·fpga开发·udp·硬件架构
奇树谦13 小时前
Qt | 基于 GVCP 协议的 GigE 工业相机 IP 配置工具
数码相机·qt·tcp/ip
爱和冰阔落14 小时前
【Linux网络】Socket 编程前要搞懂什么?从 IP、端口到字节序与 sockaddr,一次理清网络通信基础
linux·运维·网络·tcp/ip
ITxiaobing20231 天前
中国公司海外广告投放的 IP 地址库选型:从“查归属”到“验真实”的工程视角
网络·网络协议·tcp/ip
明天也要努力学习1 天前
TCP 协议的相关特性
网络·网络协议·tcp/ip