🔥 星光编译者 · 个人主页
📚 学习专栏: 《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]的内容,因此本文改进版客户端应与随附服务器配套使用。两种响应约定不能直接混用。
文章目录
- 一、跨平台通信验证的到底是什么
- 二、先准备好服务入口与网络路径
- [三、Winsock 与 Linux Socket 有哪些不同](#三、Winsock 与 Linux Socket 有哪些不同)
- 四、逐步读懂客户端的连接流程
- 五、原始收发循环里有哪些隐藏问题
- [六、完整的 Windows TCP 客户端](#六、完整的 Windows TCP 客户端)
- [七、配套的 Linux 原样回显服务器](#七、配套的 Linux 原样回显服务器)
- 八、编译运行与通信验证
- 九、退出连接与异常断线怎样区分
- 十、连接失败时按什么顺序排查
- 总结
一、跨平台通信验证的到底是什么
在 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 放行路径逐步推进,连接成功后再检查应用协议。这样才能把"运行起来了"逐步变成"理解了每一步为什么能够工作"。