【Linux 网络编程】TCP 跨平台实战:用 Windows 客户端访问 Linux 服务器

🔥 星光编译者 · 个人主页

📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》

🌄 向云端飞扬,编译属于自己的代码星河。


☕ 写在开篇

  你好,这里是 星光编译者。

  这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。

  比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。

  如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。


🔥 本文定位:把 Linux 上学过的 TCP Socket 搬到 Windows 客户端,完成"连接 Linux → 发送数据 → 收到回显 → 观察退出"的完整实验。

💡 学习目标 :理解 Winsock 初始化与链接;掌握 SOCKET / connect / send / recv;区分正常关闭和通信错误;能按顺序排查跨主机连接失败。

📌 实现范围:IPv4、C++17、阻塞式 TCP。Windows 客户端使用 Winsock 2.2,配套 Linux 服务器采用单线程原样回显,便于观察接口语义。

我们仍沿用之前博客的方式:先明确问题,再解释调用链,最后给出代码、验证方法和排错路径。 讲义提供了最小实验;本文在它的基础上补齐输入退出、错误码、短发送、分段接收和资源释放,让示例的行为更明确。

配套约定 :本文附带的 Linux 服务端把收到的每个字节原样回发,不添加任何后缀。讲义截图中的服务器会附加类似 [tcp server echo message] 的内容,因此本文改进版客户端应与随附服务器配套使用。两种响应约定不能直接混用。


文章目录


一、跨平台通信验证的到底是什么

在 Linux 上写过 TCP 客户端后,换成 Windows,很容易先问:"头文件不同,函数细节也不同,两端还能互相通信吗?"

可以。跨主机通信依赖双方共同遵守的网络协议和应用协议,而不是双方必须使用相同的操作系统。 Windows 客户端通过 Winsock 接口调用本机网络栈,Linux 服务器通过 Linux Socket 接口调用自己的网络栈,线上交换的是 TCP 所承载的字节。

1.1 API 不同,线上规则相同

比如 Windows 需要先调用 WSAStartup,Linux 不需要;Windows 用 closesocket 释放 Socket,Linux 用 close。这些是各自系统提供接口的差异,不会要求对端也采用同一套调用方式。

真正需要对齐的是:

约定 本文选择 不一致会怎样
地址族 IPv4 地址准备或连接失败
传输协议 TCP TCP 客户端无法连接 UDP 服务
目标入口 Linux 可达 IP + TCP 8888 连接到错误服务或根本无法连接
请求与响应规则 原样回显,回复字节数等于请求字节数 客户端可能等待不足或读出多余数据
数据解释 先用 ASCII 文本,之后约定 UTF-8 字节相同也可能显示乱码

1.2 实验要形成一个闭环

本文的通信路径是:

text 复制代码
Windows 输入 hello
    → Winsock 发送 5 个字节
    → Linux recv 读取数据
    → Linux 原样回发
    → Windows 累积收到 5 个字节
    → 比较并显示回显结果

connect 成功验证的是连接建立;收到符合约定的回复,才把应用收发也纳入了验证。只看到"Connected",还不能说明服务器业务代码正确。

1.3 服务器看到的来源端口为什么不是 8888

8888 是 Linux 服务端固定的监听端口。Windows 客户端通常不显式 bind,由系统在连接时选择本地地址和临时端口。因此服务端可能打印:

text 复制代码
Connected: 192.168.1.20:53042

这里的 53042 属于客户端,服务器仍然通过自己的 8888 提供服务。若客户端经过 NAT,服务器看到的还可能是 NAT 映射后的地址与端口,不一定与 Windows 本机查看的端点一致。


二、先准备好服务入口与网络路径

2.1 客户端到底应该填写哪个 IP

用下面的表判断,而不是在不同场景里始终填写 127.0.0.1:

Linux 所在位置 Windows 应填写的地址 需要额外确认
同一局域网中的另一台机器 Linux 局域网 IP 两端路由可达,主机规则允许
本机虚拟机 Windows 可达的虚拟机地址 桥接、Host-only、NAT 或端口转发配置
WSL 环境 依据当前网络模式使用可达地址 localhost 转发、镜像网络等行为取决于环境
云服务器 实际公网 IP 或已配置的可达入口 云安全组、系统防火墙、映射关系

127.0.0.1 表示发起访问的当前主机自己的回环地址 。Windows 连接 127.0.0.1 时,默认是在找 Windows 本机服务。只有环境明确提供了转发或映射,才可能通过它访问另一运行环境。

文中偶尔使用 203.0.113.10 说明公网场景,它是文档示例地址,不能拿来直接连接。实际运行时必须换成自己的真实地址。

2.2 bind 0.0.0.0 与连接 0.0.0.0 不是同一回事

Linux 服务端可以绑定 0.0.0.0:8888,表示在本地所有适用的 IPv4 地址上接受该端口的连接。它不是客户端应该填写的远端地址。

如果服务器只绑定 127.0.0.1:8888,本机测试可能通过,其他机器访问却失败。把监听地址改为合适的网卡地址或通配地址,才能让网络路径上的目标入口与监听范围匹配。

云服务器的公网地址可能由平台映射到实例内网地址,并不一定直接配置在 Linux 网卡上。这时不要盲目把公网 IP 写进 bind;应根据实际网卡与映射配置选择本地绑定地址。

2.3 放行的是 TCP 端口

从之前的 UDP 实验过渡过来,最容易留下一个配置误差:安全组只开放了 8888/udp。

本篇用的是 TCP,应确认允许访问 8888/tcp。云侧安全组和 Linux 主机防火墙是两个检查点;服务端能启动,不代表网络请求一定能穿过它们。

例如服务器确实使用 UFW 时,可以按实验来源地址添加一条规则:

bash 复制代码
# 将 192.168.1.20 换成服务端实际看到的实验来源地址
sudo ufw allow from 192.168.1.20 to any port 8888 proto tcp

如果机器不使用 UFW,就检查实际启用的防火墙体系。不要为了测试一次连接,把已有防护规则全部关闭。


三、Winsock 与 Linux Socket 有哪些不同

很多函数名称相同,但移植时不能只替换头文件。

3.1 头文件负责声明,链接库提供实现入口

Windows 客户端包含:

cpp 复制代码
#include <winsock2.h>
#include <ws2tcpip.h>

winsock2.h 提供 Winsock 的核心类型和函数声明,ws2tcpip.h 提供本文使用的地址转换等相关声明。MSVC 还需要链接 Ws2_32.lib:

cpp 复制代码
#pragma comment(lib, "Ws2_32.lib")

这里解决的是链接阶段的依赖。包含头文件后,编译器能识别 socket 的声明;链接时还要找到相应实现。如果出现 unresolved external symbol 或 LNK2019,应检查链接配置。

随附代码把 #pragma comment 放在 _MSC_VER 条件内。MinGW-w64 使用 -lws2_32 显式链接,不能只依靠 MSVC 的 pragma。

3.2 SOCKET 不是 Linux 的 int 文件描述符

项目 Windows / Winsock Linux
句柄类型 SOCKET int 文件描述符
创建失败 INVALID_SOCKET -1
常见接口失败 SOCKET_ERROR -1
获取错误原因 WSAGetLastError() errno
关闭 Socket closesocket() close()
使用前准备 WSAStartup() 不需要对应的库初始化
使用后收尾 WSACleanup() 不需要对应的库收尾

在 Windows 里,应该保存 SOCKET 并与 INVALID_SOCKET 比较,不要直接套用 Linux 的 fd < 0 检查。它也是当前进程使用资源的句柄,不是一个在整个网络中全局唯一的编号。

3.3 WSAStartup 的返回值要单独处理

cpp 复制代码
WSADATA data{};
int rc = WSAStartup(MAKEWORD(2, 2), &data);
if (rc != 0) {
    std::cerr << "WSAStartup failed: " << rc << '\n';
}

这次请求使用 Winsock 2.2。初始化成功后,应用才能继续使用常规 Winsock 接口;返回的 data.wVersion 表示协商的版本,示例会确认它满足要求。

WSAStartup 失败时直接使用它的返回值作为错误码。 其他常规 Socket 操作失败后,才立即调用 WSAGetLastError 读取原因。Microsoft:WSAStartup

3.4 资源释放要按拥有关系收尾

本篇声明 Winsock 运行环境对象后,再声明客户端 Socket 对象。局部对象按构造的逆序析构,因此先 closesocket,后 WSACleanup。正常退出、地址无效、连接失败或收发异常,都可以沿同一条资源释放路径收尾。

这就是这里使用 RAII 的原因:把释放责任绑定到对象生命周期,减少每个错误分支里重复关闭的代码。禁止复制拥有资源的对象,则避免两个对象关闭同一个句柄。


四、逐步读懂客户端的连接流程

客户端的完整链路是:

text 复制代码
WSAStartup → socket → 准备地址 → connect
    → send / recv 循环 → closesocket → WSACleanup

4.1 创建一个 TCP Socket

cpp 复制代码
SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (s == INVALID_SOCKET) {
    int code = WSAGetLastError();
    // 保存并报告 code。
}

AF_INET 选择 IPv4;SOCK_STREAM 选择流式通信;IPPROTO_TCP 显式指定 TCP。创建成功只代表拿到通信资源,还没有连接到 Linux 服务器。

4.2 把文本地址转换成地址结构

cpp 复制代码
sockaddr_in server{};
server.sin_family = AF_INET;
server.sin_port = htons(8888);
int rc = InetPtonA(AF_INET, "192.168.1.10", &server.sin_addr);

{} 先初始化结构体;htons 把端口写成网络字节序;InetPtonA 把 IPv4 文本转换为网络格式的地址。这里的地址仅作局域网示例,要替换成自己的 Linux 地址。

讲义使用 inet_addr 并关闭相关警告。本文改用 InetPtonA,明确区分转换成功、地址文本无效和接口错误,不需要屏蔽整个 4996 警告。它处理的是数字形式的 IP 地址,不负责解析域名 ;如果将来需要连接域名,再引入 getaddrinfo。

4.3 connect 使用的是远端地址

cpp 复制代码
int rc = connect(s, reinterpret_cast<sockaddr*>(&server), sizeof(server));

地址结构描述"要连接谁",不是给客户端配置一个固定本地端口。本篇没有特殊的本地地址需求,所以不显式 bind,让系统选择本地端点。

这里使用阻塞式 Socket。connect 成功后进入通信阶段;失败就记录错误码并退出。本篇没有自动重连,断线重连属于下一份加餐的主题。

4.4 服务端必须先进入监听状态

对应的 Linux 流程是:

text 复制代码
socket → bind → listen → accept → recv / send

accept 返回一个用于这条连接的新 Socket。监听 Socket 继续承担接受连接的职责,数据收发使用新获得的连接 Socket。把数据发送到监听 Socket 上,是对角色的混淆。


五、原始收发循环里有哪些隐藏问题

讲义使用一个直观的循环:输入一行,调用一次 send,调用一次 recv,然后打印。这能帮助我们迅速看到跨平台通信,但不能由此推出 TCP 一次发送就对应一次接收。

5.1 TCP 提供字节流,不保留 send 的消息边界

假设客户端连续发送 hello 和 world,服务器读取时可能得到:

text 复制代码
hello | world
hel | lowor | ld
helloworld

这些例子表达的是应用读取的分组可以变化,字节顺序仍保持一致,不是说数据会随意丢失、重复或重排。实际如何分组取决于网络、缓冲和读取时机,不能用一次短消息实验来推断长期行为。

5.2 send 的返回值不能忽略

send 成功返回的是本次接受发送的字节数,它可能小于请求长度;失败返回 SOCKET_ERROR。所以要用偏移量推进,直到发送完当前内容或遇到错误。Microsoft:send

text 复制代码
待发送 8 字节
第一次 send 返回 3 → 下一次从偏移 3 开始
第二次 send 返回 5 → 累计 8,当前发送完成

这里的 3 + 5 只是返回值语义示意,实际运行不一定出现这种划分。即使全部字节都交给了本机网络栈,也不能把它直接解释成"远端业务已经处理完毕"。回显内容提供了更进一步的应用层观察。

5.3 recv 的三个分支不能合并

对本文的阻塞式 TCP Socket,传入非零接收容量时:

返回值 应如何理解 本篇处理
n > 0 这次读到了 n 个字节 按 n 累积回复
n == 0 对端发送方向正常结束,已有数据读完 结束读取;若预期回复未满,报告不完整
n == SOCKET_ERROR 接收操作失败 立即读取错误码并退出

因此讲义里统一打印 recv failed 的 else 分支,需要进一步细分。正常结束与异常失败不是同一种状态。Microsoft:recv

5.4 缓冲区不是自动完整的字符串

recv 返回一段字节和它的长度,并不自动保证末尾存在 \0。对于文本,预留一个字节补零是一种打印方法;但更通用的做法是按返回值构造或追加:

cpp 复制代码
reply.append(buffer, static_cast<std::size_t>(n));

这样不会把旧缓冲区内容拼进去,也不把正文内部的零字节误当成内容结束。本篇使用 std::string 保存字节,最终比较内容是否与发送内容一致。

5.5 什么时候知道回复已经完整

这才是比"把缓冲区开大一点"更关键的问题。本文选择一个很小的应用约定:

一次只发送一个非空请求;服务端原样回显;客户端收满本次请求的字节数后,才接受下一次输入。

假设请求有 5 字节,第一次 recv 只读到 2 字节,就继续等剩余 3 字节。这解决当前回显实验的完成判断,但它不是通用消息协议。它依赖服务器不加后缀、不主动推送其他内容。

如果业务改成"输入单词,返回不同长度的解释",客户端就不能再按请求长度收回复。需要另外约定分隔符、固定长度或者长度字段,这将进入应用层协议设计。

5.6 输入 EOF 也需要退出

讲义的 while (true) 没有检查 getline 是否成功。输入流结束后,循环可能反复尝试读取,正常清理代码也没有自然执行机会。

本篇把 getline 的结果放进循环条件,并添加本地退出命令 /quit。空行继续跳过,避免把"0 字节发送"误当成一个服务器能识别的独立请求。TCP 不会因为调用一次长度为零的 send,就给对端制造一个消息边界。


六、完整的 Windows TCP 客户端

把下面代码保存为 code/tcp_client_windows.cpp。IP 和端口通过命令行传入,避免每次更换 Linux 地址都需要修改源码重新编译。

cpp 复制代码
#ifndef _WIN32_WINNT
#define _WIN32_WINNT 0x0600
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#include <algorithm>
#include <iostream>
#include <stdexcept>
#include <string>

#ifdef _MSC_VER
#pragma comment(lib, "Ws2_32.lib")
#endif

struct Winsock {
    Winsock() {
        WSADATA data{};
        int rc = WSAStartup(MAKEWORD(2, 2), &data);
        if (rc != 0) throw std::runtime_error("WSAStartup: " + std::to_string(rc));
        if (LOBYTE(data.wVersion) != 2 || HIBYTE(data.wVersion) != 2) {
            WSACleanup();
            throw std::runtime_error("Winsock 2.2 unavailable");
        }
    }
    ~Winsock() { WSACleanup(); }
    Winsock(const Winsock&) = delete;
    Winsock& operator=(const Winsock&) = delete;
};

struct Socket {
    SOCKET value = INVALID_SOCKET;
    Socket() = default;
    ~Socket() { if (value != INVALID_SOCKET) closesocket(value); }
    Socket(const Socket&) = delete;
    Socket& operator=(const Socket&) = delete;
};

[[noreturn]] void socket_error(const char* where) {
    int code = WSAGetLastError();
    throw std::runtime_error(std::string(where) + ": " + std::to_string(code));
}

unsigned short parse_port(const std::string& text) {
    if (text.empty() || text.find_first_not_of("0123456789") != std::string::npos)
        throw std::runtime_error("port must be an integer in 1..65535");
    unsigned long value = std::stoul(text);
    if (value == 0 || value > 65535) throw std::runtime_error("port out of range");
    return static_cast<unsigned short>(value);
}

void send_all(SOCKET s, const std::string& text) {
    std::size_t done = 0;
    while (done < text.size()) {
        int n = send(s, text.data() + done, static_cast<int>(text.size() - done), 0);
        if (n == SOCKET_ERROR) socket_error("send");
        if (n == 0) throw std::runtime_error("send made no progress");
        done += static_cast<std::size_t>(n);
    }
}

std::string receive_echo(SOCKET s, std::size_t expected) {
    std::string reply;
    char buffer[1024];
    while (reply.size() < expected) {
        int capacity = static_cast<int>(std::min(sizeof(buffer), expected - reply.size()));
        int n = recv(s, buffer, capacity, 0);
        if (n == SOCKET_ERROR) socket_error("recv");
        if (n == 0) throw std::runtime_error("server closed before full echo");
        reply.append(buffer, static_cast<std::size_t>(n));
    }
    return reply;
}

int main(int argc, char* argv[]) {
    try {
        if (argc != 3) {
            std::cerr << "Usage: tcp_client_windows IPv4 port\n";
            return 1;
        }
        unsigned short port = parse_port(argv[2]);
        Winsock runtime;
        Socket client;
        client.value = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
        if (client.value == INVALID_SOCKET) socket_error("socket");

        sockaddr_in server{};
        server.sin_family = AF_INET;
        server.sin_port = htons(port);
        int converted = InetPtonA(AF_INET, argv[1], &server.sin_addr);
        if (converted == SOCKET_ERROR) socket_error("InetPtonA");
        if (converted != 1) throw std::runtime_error("invalid IPv4 address");
        if (connect(client.value, reinterpret_cast<sockaddr*>(&server), sizeof(server)) == SOCKET_ERROR)
            socket_error("connect");

        DWORD timeout_ms = 5000;
        if (setsockopt(client.value, SOL_SOCKET, SO_RCVTIMEO,
                       reinterpret_cast<const char*>(&timeout_ms), sizeof(timeout_ms)) == SOCKET_ERROR)
            socket_error("SO_RCVTIMEO");
        if (setsockopt(client.value, SOL_SOCKET, SO_SNDTIMEO,
                       reinterpret_cast<const char*>(&timeout_ms), sizeof(timeout_ms)) == SOCKET_ERROR)
            socket_error("SO_SNDTIMEO");

        std::cout << "Connected. Enter /quit to exit.\n";
        std::string message;
        while (std::cout << "Please Enter@ " && std::getline(std::cin, message)) {
            if (message == "/quit") break;
            if (message.empty()) continue;
            if (message.size() > 4096) {
                std::cerr << "Input exceeds 4096 bytes; skipped.\n";
                continue;
            }
            send_all(client.value, message);
            std::string reply = receive_echo(client.value, message.size());
            if (reply != message) throw std::runtime_error("echo mismatch");
            std::cout << "Received (" << reply.size() << " bytes): " << reply << '\n';
        }
        // All expected echo bytes have already been read. The RAII objects close
        // the socket before releasing the Winsock runtime.
        return 0;
    } catch (const std::exception& ex) {
        std::cerr << ex.what() << '\n';
        return 1;
    }
}

6.1 send_all 与 receive_echo 各负责什么

send_all 只负责把当前字符串发送完。receive_echo 只负责按当前实验约定收满目标字节数。把两者分开之后,每个返回值对应的动作更清晰。

注意接收函数每次只请求"缓冲容量与剩余目标字节数中的较小值"。它不会把后续数据误收进当前回复;不过服务器若违反原样回显约定,多余字节仍会留在流里,所以程序还会比较实际内容是否相同。

6.2 为什么限制输入为 4096 字节

这是本篇实验的应用限制,不是 TCP 的消息长度限制。它让请求足够小,便于观察收发,并确保传给 Winsock 的 int 长度转换处于安全范围。

字节数超过 4096 就直接拒绝当前输入,不截断后继续发送。这样读者看到的回复仍然对应完整的输入,而不是一个悄悄被删掉尾部的请求。

6.3 五秒超时覆盖的是哪里

客户端连接成功后,给阻塞式发送和接收操作配置五秒超时,避免服务器不回复时一直停在通信阶段。超时或其他收发错误发生后,直接放弃当前连接并释放资源。

这个设置不限制前面的 connect 等待时间 ,也不是整个请求的统一五秒截止时间。receive_echo 多次调用 recv 时,每次阻塞调用都有自己的等待过程。需要严格总截止时间时,应另外使用时间点、事件等待或非阻塞机制。

本篇是逐条请求的实验程序;没有额外接收线程。若服务器在用户停留于输入提示时断开,客户端通常到下一次网络操作才观察到状态变化,而不是立即打印通知。


七、配套的 Linux 原样回显服务器

为了让实验不依赖未展示的服务端逻辑,下面提供配套服务器。它按每次 recv 的实际字节数回发,既不拼后缀,也不假定一次读取就对应一行输入。

cpp 复制代码
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstdio>
#include <iostream>
#include <stdexcept>
#include <string>

struct Socket {
    int value;
    explicit Socket(int fd) : value(fd) {}
    ~Socket() { if (value >= 0) close(value); }
    Socket(const Socket&) = delete;
    Socket& operator=(const Socket&) = delete;
};

unsigned short parse_port(const std::string& text) {
    if (text.empty() || text.find_first_not_of("0123456789") != std::string::npos)
        throw std::runtime_error("port must be an integer in 1..65535");
    unsigned long value = std::stoul(text);
    if (value == 0 || value > 65535) throw std::runtime_error("port out of range");
    return static_cast<unsigned short>(value);
}

bool send_all(int fd, const char* data, std::size_t size) {
    std::size_t done = 0;
    while (done < size) {
        ssize_t n = send(fd, data + done, size - done, MSG_NOSIGNAL);
        if (n < 0) {
            if (errno == EINTR) continue;
            std::perror("send");
            return false;
        }
        if (n == 0) return false;
        done += static_cast<std::size_t>(n);
    }
    return true;
}

int main(int argc, char* argv[]) {
    try {
        if (argc != 2) {
            std::cerr << "Usage: tcp_echo_server_linux port\n";
            return 1;
        }
        unsigned short port = parse_port(argv[1]);
        Socket listener(socket(AF_INET, SOCK_STREAM, 0));
        if (listener.value < 0) { std::perror("socket"); return 1; }
        int reuse = 1;
        if (setsockopt(listener.value, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) < 0) {
            std::perror("setsockopt"); return 1;
        }
        sockaddr_in local{};
        local.sin_family = AF_INET;
        local.sin_port = htons(port);
        local.sin_addr.s_addr = htonl(INADDR_ANY);
        if (bind(listener.value, reinterpret_cast<sockaddr*>(&local), sizeof(local)) < 0) {
            std::perror("bind"); return 1;
        }
        if (listen(listener.value, 16) < 0) { std::perror("listen"); return 1; }
        std::cout << "Listening on 0.0.0.0:" << port << std::endl;
        for (;;) {
            sockaddr_in peer{};
            socklen_t length = sizeof(peer);
            int fd = accept(listener.value, reinterpret_cast<sockaddr*>(&peer), &length);
            if (fd < 0) {
                if (errno == EINTR) continue;
                std::perror("accept"); return 1;
            }
            Socket client(fd);
            char ip[INET_ADDRSTRLEN]{};
            if (!inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip))) {
                std::perror("inet_ntop"); continue;
            }
            std::cout << "Connected: " << ip << ':' << ntohs(peer.sin_port) << std::endl;
            char buffer[1024];
            for (;;) {
                ssize_t n = recv(client.value, buffer, sizeof(buffer), 0);
                if (n == 0) { std::cout << "Peer send side closed" << std::endl; break; }
                if (n < 0) {
                    if (errno == EINTR) continue;
                    std::perror("recv"); break;
                }
                std::cout << "Read " << n << " bytes" << std::endl;
                if (!send_all(client.value, buffer, static_cast<std::size_t>(n))) break;
            }
        }
    } catch (const std::exception& ex) {
        std::cerr << ex.what() << '\n';
        return 1;
    }
}

7.1 1024 字节的缓冲区不会限制整个回复为 1024 字节

客户端输入 3000 个 ASCII 字节,服务器会用一轮或多轮读取处理,具体每轮读取长度由运行情况决定。每轮只把读到的那段字节回发,客户端则累积到 3000 字节再显示。

这里没有"缓冲区装不下就把剩余正文丢掉"的逻辑。TCP 流中的后续字节,可以通过后续读取继续取得。

7.2 服务端也要处理短发送

服务器同样使用 send_all。Linux 的接口失败通过 errno 判断,遇到 EINTR 重试;发送时使用 Linux 支持的 MSG_NOSIGNAL,避免向失效连接发送导致 SIGPIPE 终止整个服务进程。

这属于 Linux 端实现细节,不能把该标志原样照搬到 Windows 代码里。两端都解决"正确处理返回值"的问题,只是采用各自系统的接口规则。

7.3 这是一台顺序处理客户端的实验服务器

服务端接收一个连接后,在内层循环处理它;等该连接结束,才回到外层调用 accept 接受下一个。因此,第二个客户端即使完成建连,也可能暂时得不到应用层回复。

listen(..., 16) 不表示创建 16 个工作线程,也不表示同时处理 16 个会话。如果需要多客户端实时服务,再引入线程、进程或者 I/O 多路复用。本篇保持顺序结构,集中观察 TCP 接口与跨平台差异。


八、编译运行与通信验证

8.1 先在 Linux 编译并启动服务端

进入 code 所在目录:

bash 复制代码
g++ -std=c++17 -Wall -Wextra -Wpedantic -O2 \
    tcp_echo_server_linux.cpp -o tcp_echo_server_linux
./tcp_echo_server_linux 8888

预期启动提示如下,这些输出用于说明程序行为,不是某台云服务器的实测截图:

text 复制代码
Listening on 0.0.0.0:8888

在另一个 Linux 终端检查监听:

bash 复制代码
ss -lntp 'sport = :8888'

关注协议状态是否为 LISTEN、本地端口是否为 8888、监听地址是否覆盖准备让 Windows 访问的网卡。使用普通用户看不到进程细节时,再根据权限使用 sudo。

8.2 在 Windows 编译客户端

Visual Studio 项目方式 :创建 C++ 控制台项目,把随附客户端作为唯一的 main 所在源文件;配置 C++17,并保留源码中的链接 pragma。源码以 UTF-8 保存时,可启用 /utf-8 编译选项。

MSVC 命令行方式:在安装了 C++ 工具和 Windows SDK 的 x64 Developer Command Prompt 中执行:

bat 复制代码
cl /nologo /std:c++17 /EHsc /W4 /utf-8 tcp_client_windows.cpp /Fe:tcp_client_windows.exe

普通 PowerShell 如果提示找不到 cl,通常是开发者命令行环境没有加载,不应先归因于 Socket 代码。

MinGW-w64 方式:如果使用它的 Windows 编译器,则执行:

bash 复制代码
g++ -std=c++17 -Wall -Wextra -O2 tcp_client_windows.cpp \
    -o tcp_client_windows.exe -lws2_32

这里用的是 Windows 目标工具链,不能把 Linux 上的普通 g++ 编译命令当成 Windows 本机程序的构建方式。

8.3 再检查 Windows 到服务端端口

例如 Linux 的实际局域网地址是 192.168.1.10:

powershell 复制代码
Test-NetConnection -ComputerName 192.168.1.10 -Port 8888

TcpTestSucceeded 为真说明此次 TCP 探测成功;它不测试回显协议。这个探测本身可能让 Linux 服务端打印一条短暂连接及关闭日志,因为它也建立过一次 TCP 连接。

接着运行客户端:

powershell 复制代码
.\tcp_client_windows.exe 192.168.1.10 8888

预期交互形式:

text 复制代码
Connected. Enter /quit to exit.
Please Enter@ hello
Received (5 bytes): hello
Please Enter@ world
Received (5 bytes): world
Please Enter@ /quit

/quit 在本地被识别,不作为正文发送。所有已发出的请求回复都收满后,客户端结束循环、关闭 Socket,最后释放 Winsock 运行环境。

8.4 按顺序做几组有意义的检查

检查 方法 预期观察
最小请求 输入 hello 回显相同的 5 字节
连续请求 完成一轮后再输入下一行 不混入上一轮回复
超过接收缓冲容量 输入 3000 个 ASCII 字符 仍收满 3000 字节并比较通过
本地退出 输入 /quit 或结束标准输入 结束循环并执行资源释放
不合法参数 无效 IPv4、端口 0 或 70000 清楚报告参数错误
服务端正常关闭 待收回复期间关闭连接 客户端观察 EOF 或连接相关错误
无回复 连接被接受但服务端不发送 接收超时,当前连接结束

在 PowerShell 中也可以通过管道测试一条较长输入:

powershell 复制代码
('A' * 3000), '/quit' | .\tcp_client_windows.exe 192.168.1.10 8888

这组检查能验证应用按长度累计读取,但不能保证本次底层 recv 一定按某个固定分段返回。要验证分段行为,应由测试服务端刻意分批回发,而不是只观察一次长消息。

8.5 中文显示与 TCP 传输是两个层面

先用 ASCII 跑通实验。需要中文时,再让 Windows 终端、输入程序、Linux 程序和显示端统一采用合适的编码,例如 UTF-8。

UTF-8 中的一个汉字可能占多个字节,message.size() 统计字节数,不是字符数。即使一个多字节字符被分到两次 recv 中,只要先按顺序累计完整字节再解码,也不意味着 TCP 把字符损坏了。

本篇已完成 MSVC 客户端与 Ubuntu 24.04(WSL)服务端的编译,以及 Windows → WSL Linux 的实际原样回显验证:连续短请求、3000 字节请求和正常退出均通过。另用 Windows 本机测试服务验证了提前 EOF、超时和响应不匹配等分支。

详细验证范围见 code/验证记录.md。 这里的 WSL 结果不覆盖云服务器公网路径、安全组规则或其他虚拟机网络模式,这些应在自己的环境中按上述步骤验证;示意输出不能替代这些检查。


九、退出连接与异常断线怎样区分

讲义展示了关闭 Windows 客户端后,Linux 服务器打印 client quit。这条日志描述的是服务端对连接状态的观察,不一定表示客户端真的发送了一个内容为 quit 的消息。

9.1 正常关闭首先是一个方向的结束

当对端有序结束发送,接收方先读完队列里已有的数据,再通过 recv == 0 观察到 EOF。这表示对端不会再在这一方向发送数据,不能直接推导本端也已经关闭,或对端进程已完全退出。

如果使用 shutdown(..., SD_SEND),Windows 可以只关闭发送方向,仍保留接收方向。本文没有单独演示半关闭收尾,而是在请求回复完成后直接由对象析构关闭 Socket。

9.2 强制关闭和网络中断可能呈现不同结果

如果连接被重置,Windows 常见错误包括 WSAECONNRESET。拔网线、丢弃数据包或远端无响应时,可能先表现为等待,随后才出现超时等错误,不能期待服务端或客户端总会立刻获知。

所以退出观察要记录"在哪个接口、返回什么值、具体错误码是什么",不能只记录"窗口关了,另一端没反应"。本篇配置了通信超时,但没有实现心跳、断线重连或完整连接存活检测。

9.3 没有数据时,阻塞 recv 通常在等待

"暂时没有数据"不是成功返回 0 的同义词。阻塞式 TCP recv 在没有可读数据、没有 EOF 或错误时,通常继续等待;配置超时后则可能因为等待期限而返回错误。

如果后续学习非阻塞 Socket,还会遇到 WSAEWOULDBLOCK,那是另一种处理模型。本篇不把阻塞与非阻塞的返回路径混在同一个实验里。


十、连接失败时按什么顺序排查

"连不上"不是一个足够具体的错误描述。先判断失败发生在初始化、创建、地址转换、连接,还是已连接后的收发阶段。

10.1 先证明服务在正确的入口监听

检查 Linux 程序是否仍在运行,bind / listen 是否成功,端口是否被另一个进程占用。然后用 ss 确认监听地址与 TCP 端口。

如果监听仅在 127.0.0.1,先修正绑定范围。如果监听入口正确,再看客户端目标地址是否对应这台 Linux 主机和实际映射。

10.2 再检查 TCP 网络路径

从 Windows 用 Test-NetConnection 探测目标端口。对云主机,核对安全组协议、目标端口与允许来源;对虚拟机,核对实际网络模式和端口转发;对 Linux 主机,核对启用的防火墙规则。

ping 检查的是另一类网络报文的响应。ping 通过不能证明 TCP 8888 开放,ping 不通也不能单独证明 TCP 8888 不可用。

必要时在 Linux 抓取相关流量:

bash 复制代码
sudo tcpdump -ni any 'tcp port 8888'

没有看到相关 SYN,先查流量是否到达抓包位置及上游路径;看到 SYN 但连接不成立,再结合返回报文、监听和本机规则判断。已经建立连接但没有回复,就继续检查 accept、服务端处理循环和应用约定。

10.3 错误码是线索,不是唯一结论

错误码 常见解释 下一步检查
10061 / WSAECONNREFUSED 连接被拒绝 目标是否监听,是否有设备主动拒绝
10060 / WSAETIMEDOUT 操作超时 丢弃规则、网络路径、对端响应与当前操作阶段
10054 / WSAECONNRESET 已有连接被对端重置 对端退出方式、服务异常及网络设备行为
10051 / WSAENETUNREACH 网络不可达 本地路由、网卡与目标网段
10093 / WSANOTINITIALISED Winsock 未成功初始化 初始化结果及是否过早清理

同一个错误码可能有多个环境原因,不能看到超时就断言"一定是安全组"。要结合失败接口、服务日志和网络证据定位。Microsoft:Windows Sockets 错误码

10.4 已连接却没有正确回显,检查应用约定

本篇客户端收回复的长度等于请求长度。如果你把它连到讲义截图里那个会添加后缀的服务器,第一次读取可能只得到正文,后缀留在缓冲区中;下一轮读取就可能拿到上一次的尾部,最终触发比较失败。

这不是 Windows 与 Linux 不兼容,也不是通过增加 Sleep 就能解决的问题。应先统一请求与响应的完成规则,再分析程序行为。

如果服务器只接收而没有回发,客户端会等到接收超时;如果第二个连接暂时不被服务端处理,则要看服务器是否是顺序处理结构。建立连接与执行服务端业务是两个不同阶段。


总结

Windows 访问 Linux 的 TCP 实验,把两类问题放在了一起:一类是平台接口的差异,另一类是双方通信约定是否一致。

平台差异体现在初始化、句柄类型、链接库、错误码和资源释放上。客户端使用 Winsock,服务器使用 Linux Socket,只要双方采用一致的 TCP 与应用约定,就能交换同一串字节。

TCP 收发的关键则是返回值与完成规则。send 要检查发送进度,recv 要区分数据、EOF 和错误;一次调用不等于一条完整业务消息。本篇用"原样回显 + 已知回复长度 + 一次一条请求"建立了可观察的闭环,也明确了它不能直接用于任意长度响应的边界。

最后,跨主机排查应从监听入口、目标地址和 TCP 放行路径逐步推进,连接成功后再检查应用协议。这样才能把"运行起来了"逐步变成"理解了每一步为什么能够工作"。

相关推荐
发量惊人的中年网工1 小时前
分公司访问总部ERP慢怎么办?SD-WAN组网的排查与验收要点
服务器·开发语言·php
翼龙云_cloud1 小时前
亚马逊云服务器服务代理商:Bedrock 实践经验 Serverless 架构下的 AI 自动化落地
服务器·架构·serverless·云计算·aws
忆挽篱笙歌2 小时前
环境变量,本地变量
linux·运维·服务器
是真的小外套2 小时前
2024年软考中级信息安全工程师|选择题考点+案例分析真题解析(含答案)
android·网络
吹什么轩4 小时前
linux系统复习:僵尸进程与孤儿进程
linux·运维·php
小天源4 小时前
让访问数据可解释:IP 地球大屏、地区库更新与运行维护设计
网络·网络协议·tcp/ip
张宇Joaquin4 小时前
AMD uProf 工具采集微架构特征使用介绍
服务器
殷忆枫5 小时前
告别依赖地狱!在 Ubuntu 上用 Docker 极速部署 Home Assistant 与 MQTT,并接入 Siri 与嵌入式 C 终端
linux·ubuntu·docker
第404号工位5 小时前
手机到底有没有偷听我们?我抓包了 72 小时,抓到了真凶
linux·人工智能·架构