上一篇我们用本机验证了 TCP 的三次握手,但很多人马上会问一个更现实的问题:我平时用的开发机其实是 Windows,而云服务器是 Linux——那我能不能用 Windows 写一个客户端,真的连到那台 Linux 服务器上,把 TCP 通信"实打实"地跑通一次?

这堂课就是加餐,专门回答这个问题。本课不写什么看起来高大上的东西,就做一件实在事:Linux 当服务端跑起来监听一个端口,Windows 当客户端发起连接,两边把数据来回发一通,亲眼看看跨机 TCP 到底是怎么建立、怎么通信、又怎么收尾的。学完之后你会掌握一套能直接拷贝去用的跨平台联调方案,也顺带把 Windows 上那套网络编程接口摸了个门清。

先说清楚,本课需要两份代码:一份 Windows 客户端(用 Winsock 写),一份 Linux 服务端(用标准的 POSIX socket 写)。你可能要问,为什么"跨平台"还得写两份代码?答案就在我们马上要讲的核心矛盾里:Windows 和 Linux 虽然都把"socket"叫作"socket",但它们是两套互不相同的接口。把这两套东西的异同捋顺,正是这节课最值钱的部分。

(本文示例的完整工程代码也可以去课件提供的 Gitee 仓库 windows-udp-tcp 目录下对照下载,方便你直接把它拉下来编译运行。)

这堂加餐课到底在验证什么

在动手敲代码之前,先把"我们要验证的东西"说得清清楚楚,免得写完代码都不知道自己验证了个啥。

前面我们学 TCP 的时候,讲了一堆机理:三次握手建立连接、字节流传输、四次挥手关闭连接。但这些理论大多数是在"同一台机器上"或"PPT 里"验证的——你发一个包,回环设备 127.0.0.1 自己收。那叫本机回环,虽然在逻辑上等价于一次真实的 TCP 通信,但它跳过了一堆现实世界里绕不开的问题:

  • 数据真的跨过了物理网卡、真的上了一段网线/公网了吗?
  • 服务器真的在有"防火墙""安全组"拦截的机器上对外开放端口了吗?
  • 一个 IP 地址是私有地址还是公网地址,真的能互相访问吗?
  • 两个不同的操作系统,它们的 TCP 协议栈真能"对得上话"吗?

把这四个问题的答案挨个坐实,就是这节加餐课要干的事。而为了坐实它们,我们需要一个非常朴素的测试模型:

角色运行在干什么用哪套接口
服务端Linux(可以是云服务器)监听一个端口,接受连接,回显数据POSIX socket
客户端Windows(你的本机)主动连接服务端,发消息、收回显Winsock

当一个"主动连"、一个"被动接",并且两者真的不在同一台机器上时,我们就在做一次教科书意义上的跨机 TCP 通信。下面所有代码、所有坑,都是围绕这张表展开的。请注意"跨机"这个词,后面我们会反反复复用——它特指"通信双方不在同一台主机上",与"本机回环"是两种完全不同的调试心态。

五个必须先认识的关键词

正式写代码前,先把本课会反复出现、且你以后写网络代码天天要见到的几个词一次性讲透。这五个词我都尽量用"一句话"给出定义,再补上它在本课里的角色。

第一,Winsock。 Winsock 是 "Windows Sockets" 的缩写,它是一套在 Windows 平台上提供的 socket 编程接口,是标准 POSIX socket 的"Windows 移植版"。为什么要单独搞一套?因为 Windows 内核的网络实现和 Linux 不一样,为兼容自家系统,微软提供了 winsock2.h 这个头文件和 ws2_32.lib 这个库文件。你只要记住:在 Windows 上写网络程序,用到的那堆函数和常量,都来自这套叫 Winsock 的接口。它和 Linux 下的 socket 函数同名者不少,但细节处处不同——这正是本课"坑"的源头。

第二,connect。 connect 是一个函数,中文可以译作"连接"。它是客户端主动向服务端发起的"建立 TCP 连接"的操作。你调它,内核就会按 TCP 协议去和对方完成一次三次握手;握手成功,返回成功,你就有了一条可用的连接。顺带把三次握手一个不落地给你复习一遍:客户端先发一个 SYN 段;服务端收到后回一个 SYN + ACK;客户端再回一个 ACK。来回三个报文,连接建立。虽然这些报文是内核替你发的、你看不到,但它确确实实发生了——本课我们就有办法"看到"它(后面讲抓包时会提到)。

第三,LISTEN。 这是一个套接字的状态名,英文就是"监听"的意思。服务端调用 listen() 函数之后,它的套接字就进入 LISTEN 状态——在这个状态下,内核会替你在那个端口上"站岗",一旦有客户端的 SYN 进来,就响应它、并把它排进等待队列。你可以把 LISTEN 理解成服务端"在门口挂牌营业、等待客人上门"的状态。它是服务端的专属状态,客户端永远不会进入这个状态。

第四,跨机。 直接了当定义:通信的双方不在同一台主机上。比如你的 Windows 连的是云上那台 Linux。跨机的关键是:数据要真的离开本机、经过网卡和网络、到达另一台机器。这意味着我们在调试时要多关心"IP 该填公网还是私有地址""防火墙放行没有""端口通不通"这些本机回环时根本不存在的变量。

第五,防火墙。 这里指由操作系统或云平台提供的、按规则拦截网络流量的安全机制。常见的两类:一类是 Windows 自带的"Windows 防火墙",它会拦截"入站连接"——也就是从外面主动连进来的请求;另一类是云服务器的"安全组",它也是类似的一张入站/出站规则表。要在跨机场景下跑通 TCP,你必须让"入站方向"对服务端口放行,否则哪怕服务端代码完全正确,连接也会被无声无息地拦掉。这是新手跨机联调栽跟头最多、也最让人抓狂的地方,后面我们用一整节专门踩它。

好了,五个关键词齐了。下面进入重头戏:两份代码。

Windows 端:Winsock TCP 客户端(逐行剖析)

这是源课件里那份核心的样例代码。我先把它完整、可编译地摆出来,再逐段拆讲。注意我用的是纯 C 写法(也可以改成 C++,原理完全一致),这样它在 MSVC 和 MinGW 下都能顺畅编译。

// tcp_win_client.c —— Windows 端 TCP 客户端(Winsock 版本)
// 编译方式:MSVC 下 cl tcp_win_client.c /link ws2_32.lib
//           MinGW 下 gcc tcp_win_client.c -o client.exe -lws2_32
// 使用前请修改下面 serverip / serverport 两个值
 
#include <winsock2.h>     // Winsock 头文件:socket 相关的一切函数、结构、常量都在这
#include <stdio.h>        // printf、fgets 等标准输入输出
#include <string.h>       // strlen:求字符串长度
 
#pragma comment(lib, "ws2_32.lib")  // 告诉 MSVC 链接器,必须把 Winsock 库链进来
                                     // (MinGW 下这一行不起作用,改用 -lws2_32 手工链)
 
const char* serverip = "1.2.3.4";    // 改成你的云服务器公网 IP(字符串形式 "a.b.c.d")
unsigned short serverport = 8888;    // 改成服务端开放的端口号
 
int main()
{
    // ===== 第一步:WSAStartup,初始化 Winsock 库(Windows 独有,必须先做)=====
    WSADATA wsaData;
    // MAKEWORD(2, 2):拼出一个 0x0202,表示"我想用 Winsock 2.2 这个版本"
    int ret = WSAStartup(MAKEWORD(2, 2), &wsaData);
    if (ret != 0)                         // 返回 0 才是成功
    {
        printf("WSAStartup failed: %d\n", ret);
        return 1;
    }
 
    // ===== 第二步:创建一个 TCP 套接字 =====
    // AF_INET       = 使用 IPv4 协议族
    // SOCK_STREAM   = 面向字节流(字节流 + 可靠传输 = TCP)
    // IPPROTO_TCP   = 明确指定 TCP 协议
    SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (sock == INVALID_SOCKET)   // Windows 上创建失败返回 INVALID_SOCKET(-1 的另一种形态)
    {
        printf("socket failed\n");
        WSACleanup();             // 失败也要和 WSAStartup 成对收尾,释放资源
        return 1;
    }
 
    // ===== 第三步:填好服务端地址(IP + 端口)=====
    struct sockaddr_in serverAddr;            // IPv4 地址结构体,装"对方是谁"
    serverAddr.sin_family = AF_INET;          // 协议族:IPv4
    serverAddr.sin_port = htons(serverport);  // 端口号:htons 把它转成网络字节序
    // inet_addr:把 "x.x.x.x" 的点分十进制 IP 字符串,转成一个 4 字节整数
    // 这个整数的字节序也是"网络字节序",所以填进去正好,不用再转
    serverAddr.sin_addr.s_addr = inet_addr(serverip);
 
    // ===== 第四步:connect,主动发起连接(这一步触发三次握手)=====
    ret = connect(sock, (SOCKADDR*)&serverAddr, sizeof(serverAddr));
    if (ret == SOCKET_ERROR)     // Windows 上失败返回 SOCKET_ERROR,值就是 -1
    {
        printf("connect failed\n");
        closesocket(sock);       // Winsock 里关闭套接字用 closesocket(不是 close)
        WSACleanup();
        return 1;
    }
    printf("Connected to %s:%d, type something...\n", serverip, serverport);
 
    // ===== 第五步:读用户输入 → 发给服务器 → 收服务器回显,如此循环 =====
    char line[1024];
    while (1)
    {
        printf("Please Enter@ ");
        // fgets:安全地读一行(含空格),比 scanf("%s") 更贴近真实场景
        if (fgets(line, sizeof(line), stdin) == NULL)
            break;                // 读到 EOF(Windows 下按 Ctrl+Z 再回车)就退出
 
        // fgets 会把行尾的换行符 '\n' 也读进来,这里手动去掉它,避免把换行发到网络里
        size_t len = strlen(line);
        if (len > 0 && line[len - 1] == '\n')
            line[--len] = '\0';
 
        if (len == 0)             // 空行直接跳过,不给服务器发空数据
            continue;
 
        // 把 len 个字节发出去:send 的第 2 个参数要求 char*,这里类型天然匹配
        send(sock, line, (int)len, 0);
 
        // 阻塞等待服务器回显
        char buffer[1024];
        int n = recv(sock, buffer, sizeof(buffer) - 1, 0);
        if (n > 0)
        {
            buffer[n] = '\0';     // 手动补字符串结束符,方便 printf 打印
            printf("Received from server: %s\n", buffer);
        }
        else
        {
            printf("recv failed\n");
        }
    }
 
    closesocket(sock);   // 关闭套接字——落下这条连接的一端
    WSACleanup();        // 释放 Winsock 库——和开头 WSAStartup 一一对应
    return 0;
}

这段代码看着不长,但里面每一段都有 Windows 特有的讲究。下面把它掰开揉碎讲一遍。

关于第一步的 WSAStartup,这是 Windows 网络编程和 Linux 最大的一个区别。 在 Linux 里,你 include 个头文件就能直接用 socket、connect。但在 Windows 里不行——你在用任何 Winsock 函数之前,必须先用 WSAStartup 把整个 Winsock 库"拉起来并初始化好",告诉系统"我要用哪个版本的 Winsock"。这个函数干的事,相当于"跟操作系统打个招呼、签个到,让系统把网络服务准备好给你用"。这个"打招呼"在 Windows 上是硬性要求,漏掉它必出错:如果你不调 WSAStartup 就直接 socket() 或 connect(),它们会返回失败,你调用 WSAGetLastError() 能看到错误码 10093(WSAENOTINITIALISED,意思是"Winsock 还没初始化")。这条坑的具体后果,我们放到"坑"那一节再展开。

关于第三步的 htons。 网络协议规定,多字节整数在网络上传输时用"大端序"(高字节在前),而 x86 CPU 内存里是"小端序"(低字节在前)。所以把端口号从"主机字节序"转成"网络字节序"要调 htons(host to network short)。至于 IP 地址,inet_addr 帮你把点分十进制的字符串转成了网络字节序的整数,所以不用再手动转。

关于第四步的 connect。 connect 失败返回 SOCKET_ERROR(就是 -1)。但你要知道,Windows 下失败的"味道"和 Linux 完全不同:Linux 用全局变量 errno,Windows 用的是 WSAGetLastError() 函数。这是排查时特别容易踩的认知差,记牢了能给你省半天时间。

关于第五步的 send/recv。 我只用了最朴素的单向"发一条、收一条"。这里有个真实的隐患:send 未必一次发完、recv 未必一次收完,而我在示例里都没有认真处理返回值(发送时直接忽略返回值,接收时假设一次就能收到完整数据)。这在"短消息、Linux 本地回环"这种简单场景通常碰巧能工作,但它不是严谨的写法。真正的网络编程中,发送要多发几轮直到发完、接收要循环读直到攒够期望的长度。这个点我们专门放在"坑"那节展开,示例代码保持简单以便你看清主流程。

Linux 端:POSIX TCP 回显服务器(逐行剖析)

有客户端,就得有服务端。为了让上面客户端的 recv 有东西可收,我让服务器干一件最简单又不失意义的事:回显——收到客户端发来什么,就原样把什么发回去(echo)。这样一端发、一端回,两边的 send/recv 就形成了一条完整闭环。下面的服务端用标准 POSIX socket 写,是 Linux 上最正统的写法:

// tcp_echo_server.c —— Linux 端 TCP 回显服务器(POSIX socket)
// 编译运行:gcc tcp_echo_server.c -o server && ./server
 
#include <stdio.h>          // printf、perror 等标准输入输出
#include <string.h>         // memset:把内存清零
#include <unistd.h>         // close:关闭文件描述符
#include <arpa/inet.h>      // 网络结构体(struct sockaddr_in)与 htons/htonl 等
#include <sys/socket.h>     // socket/bind/listen/accept/send/recv 等核心函数
 
int main()
{
    // ===== 第一步:创建 TCP 套接字 =====
    // AF_INET = IPv4;SOCK_STREAM = 面向字节流(即 TCP);第三个参数 0 让内核自己选 TCP
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd < 0)        // Linux 上失败返回负值(-1)
    {
        perror("socket");    // perror:打印"场景名 + 错误原因",非常方便
        return 1;
    }
 
    // ===== 第二步:填好要监听的地址(IP + 端口)=====
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));          // 清零整块内存,避免残留垃圾数据
    addr.sin_family = AF_INET;               // 协议族:IPv4
    addr.sin_port = htons(8888);             // 监听端口 8888(与服务端开放端口一致)
    addr.sin_addr.s_addr = htonl(INADDR_ANY);// 绑定 0.0.0.0,即"监听本机所有网卡"
    // htonl(INADDR_ANY) 这一行很关键:它决定了外部谁能连进来,见"坑"那节
 
    // ===== 第三步:bind,把"地址"绑到套接字上 =====
    if (bind(listenfd, (struct sockaddr*)&addr, sizeof(addr)) < 0)
    {
        perror("bind");
        close(listenfd);
        return 1;
    }
 
    // ===== 第四步:listen,进入监听(LISTEN)状态 =====
    // 第 2 个参数 5 是 backlog:允许内核为还没被 accept 的连接排队的最多数量
    if (listen(listenfd, 5) < 0)
    {
        perror("listen");
        close(listenfd);
        return 1;
    }
    printf("Server listening on 0.0.0.0:8888 ...\n");
 
    // ===== 第五步:accept,接受一个客户端的连接 =====
    // accept 会阻塞在这里,直到有客户端连进来;成功后返回一个"通信专用"的新套接字
    struct sockaddr_in clientAddr;
    socklen_t addrlen = sizeof(clientAddr);  // socklen_t:accept 形参要求的长度类型
    int connfd = accept(listenfd, (struct sockaddr*)&clientAddr, &addrlen);
    if (connfd < 0)
    {
        perror("accept");
        close(listenfd);
        return 1;
    }
    // inet_ntop:把二进制 IP 转回可读的"点分十进制字符串",方便我们打印对方是谁
    char clientip[INET_ADDRSTRLEN];
    inet_ntop(AF_INET, &clientAddr.sin_addr, clientip, sizeof(clientip));
    printf("Accepted client from %s:%d\n", clientip, ntohs(clientAddr.sin_port));
 
    // ===== 第六步:读-回显循环:收到什么就原样回什么 =====
    char buf[1024];
    while (1)
    {
        // recv:阻塞等待客户端发来的数据;n 是实际读到的字节数
        ssize_t n = recv(connfd, buf, sizeof(buf) - 1, 0);
        if (n <= 0)         // n==0:对端已关闭连接;n<0:读取出错
        {
            printf("client closed, server exits loop\n");
            break;
        }
        buf[n] = '\0';                // 手动补字符串结束符,方便 printf 打印
        printf("recv: %s\n", buf);    // 服务端也打印一份,好让我们观察进程
        send(connfd, buf, n, 0);      // 把这 n 个字节原样回给客户端(回显)
    }
 
    close(connfd);      // 关闭通信套接字
    close(listenfd);    // 关闭监听套接字
    return 0;
}

服务端的"五步套路"要刻进脑子里:socket → bind → listen → accept,然后循环 recv/send。 这五步和客户端"两步走(socket → connect)"的画风完全不同:客户端是"两步即连",服务端是"四步等上门"。而且请注意,服务端有两个套接字:

  • listenfd(监听套接字):只负责"站岗、排队、等待连接",接受连接后就不干实事了;
  • connfd(通信套接字):由 accept 返回,专门和那一个客户端通信,收发数据都走它。

听明白"一个站岗、一个通信"这个分工,你对服务端的理解就及格了。再留意一个关键点:我们这份服务端只 accept 了一次,连接建立后就一直在一个 while 循环里服务这个客户端,直到对方关闭才 break。这是源课件贴代码时的有效简化,但它带来一个现实约束——一次只能服务一个客户端,且服务完就结束。这个限制我们放在"坑"那节聊,并给你指出升级方向的入口。

还有一个容易忽视的美丽细节:accept 返回的 connfd 是从客户端 connect 那一刻开始,就已经把三次握手完成的连接的"使用端"。也就是说,连接建立(握手)是内核替你在 accept 前后自动完成的——accept 只是"取走一个已经握好手的连接"而已。

Winsock 与 POSIX socket:一对"表兄弟"的对照

看到这里你可能已经隐约感到:Windows 客户端和 Linux 服务端的代码长得太像了!确实,它们本就是同一套设计思想的"表兄弟",只是各自挂靠的平台不同、细节有别。把差异一次性列成一张表,你以后写代码时对照着抄,几乎不会走错门:

对比维度Linux / POSIXWindows / Winsock
头文件sys/socket.h、netinet/in.h、arpa/inet.hwinsock2.h
链接库不用手工链(在 libc 里)ws2_32.lib(MSVC 用 #pragma comment 或 /link,MinGW 用 -lws2_32)
库初始化不需要必须先 WSAStartup(),用完 WSACleanup()
套接字类型int(0 起的小整数)SOCKET(本质是无符号整型)
失败返回-1,错误在 errnoSOCKET_ERROR(=-1) / INVALID_SOCKET,错误在 WSAGetLastError()
关闭套接字close(fd)closesocket(s)
唤醒套接字接口shutdown()/close()同样有,自己封装
send 第 2 参类型const void*const char*(需要强转某些类型)
演示中的差异点服务端要绑 0.0.0.0客户端填对方公网 IP

这张表里最该优先记住的只有三条:① Windows 必须 WSAStartup;② Windows 用 closesocket;③ Windows 查错误用 WSAGetLastError() 而不是 errno。 把这三条刻进记忆,Windows 网络编程的第一道门槛你就迈过去了。剩下那些,用到时翻表即可。

你可能会问:跨平台时难道不能写一份代码两端通用吗?答案是:不行,每一端要调自己平台的接口。但业界有跨平台库(比如 OpenSSL 的底子、以及你以后会学的封装库)帮大家抹平差异——那是后话。本课先让你把"原生接口"这层肉看清,你才知道那些库到底帮你干了什么。

编译、运行与亲手验证:三次握手到底长什么样

代码都齐了,现在手把手把它跑起来。先说编译——两端各自在自己的系统上编译各自的代码。

Linux 服务端的编译与启动:

gcc tcp_echo_server.c -o server
./server

看到这行就说明服务端已经挂上了监听:

Server listening on 0.0.0.0:8888 ...
Accepted client from 1.2.3.4:50001

Windows 客户端的编译:

在 Visual Studio 的命令行里(或直接建一个控制台工程把源文件放进去):

cl tcp_win_client.c /link ws2_32.lib

如果你用的是 MinGW / 其它 GCC 系,就加链接参数:

gcc tcp_win_client.c -o client.exe -lws2_32

注意:源文件里的 #pragma comment(lib,"ws2_32.lib") 只在 MSVC 下起作用;用 MinGW 时它会被忽略,所以必须靠上面的 -lws2_32 手工把库链接进来。这是我建议你无论用哪种编译器、都把" -lws2_32 / link ws2_32.lib "这件事记牢的原因。

运行验证的完整剧本:

  1. 先在 Linux 上把 ./server 跑起来,看到 Server listening on 0.0.0.0:8888。
  2. 在 Windows 上改好 serverip(填 Linux 那台机器的地址),运行 client.exe。
  3. 如果连上了,Windows 这边打印 Connected to ... :8888;Linux 那边立刻打印 Accepted client from <你的IP>:<随机端口>。
  4. 在 Windows 终端输入一行字,回车,比如 hello over the internet;Windows 端收回到显 Received from server: hello over the internet,同时 Linux 端打印 recv: hello over the internet。
  5. 在 Windows 端按 Ctrl+Z 再回车,或直接关掉客户端窗口;你会看到 Linux 服务端打印 client closed, server exits loop 然后进程退出。

第 3 步里那个":50001"的端口号值得解释一下:它叫临时端口(ephemeral port),是 Windows 内核在你 connect 时自动挑的一个空闲高位端口,专门作为你这边连接的出口。你不需要也不能手动钦定它,内核帮你搞定。

现在,怎么"亲眼看到"三次握手和四次挥手? 用 Linux 上的一线抓包神器 tcpdump。在服务端所在机器上、在启动 server 之前先抓 8888 端口的包:

sudo tcpdump -i any port 8888 -nn

这台机器保持开着 tcpdump,然后去 Windows 端连接、发一条消息、关闭连接。你会看到类似这样的报文序列:

SYN      你的IP.50001 > 服务器IP.8888  Flags [S]
SYN,ACK  服务器IP.8888 > 你的IP.50001  Flags [S.]
ACK      你的IP.50001 > 服务器IP.8888  Flags [.]
... 中间是若干承载数据的 TCP 段 ...
FIN,ACK  ...                       Flags [F.]
ACK      ...
FIN,ACK  ...
ACK      ...

前面那三个 SYN / SYN,ACK / ACK,就是教科书里无数次画过、却很少被亲眼目击的三次握手;后面那串成对出现的 FIN/ACK,就是四次挥手。看到这一段,你才算是把 TCP 的状态机从"纸面理论"真正落地成了"肉眼可见的电信号"。强烈建议你亲手抓一次——这一抓,远比背十遍状态图印象深。

还要解释一句你可能困惑的话:为什么连接建立和关闭的报文我看不到 accept 和 close 造成的"停顿"?因为这全部是内核自动完成的。connect 只要一发,后半段的握手就是内核在跑;accept 取走的是已经握好手的连接。你的应用层代码只负责"把它叫出来"和"在上面收发数据",报文层面你永远看不到应用层函数,只有 tcpdump 能看到。

那些一定会绊你一跤的坑与边界

代码能跑是一回事,在真实环境里跑通是另一回事。这一节把这些"一定会在某天夜里绊你一脚"的坑,一次性给你排清楚。

坑一:忘调 WSAStartup(或者没成对调用 WSACleanup)。 Windows 上的硬规矩:任何 Winsock 函数用之前必须先 WSAStartup。漏掉它,socket/connect 会返回失败,WSAGetLastError() 给出 10093(未初始化)。反过来,别忘了程序结尾 WSACleanup,否则资源不释放。规矩一句话:有多少个 WSAStartup,就要有多少个 WSACleanup,且初始化在全部 socket 代码之前、清理在全部 socket 代码之后。

坑二:Linux 服务端绑了回环地址 127.0.0.1,导致外部永远连不上。 这是跨机联调里最经典的天坑。如果服务端代码写的是 addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK)(或者你填了 127.0.0.1),那么这个服务只在本机自己能看到,Windows 从外面连过来必然会 connect 失败。因为 127.0.0.1 是回环地址,只在本机有效,网卡根本不把你的包发出去。正确姿势就是源课件里那行 htonl(INADDR_ANY):绑定 0.0.0.0 意为"绑定本机所有网卡的任意一个地址",Linux 才会接收来自任何接口、包括公网/Wi-Fi/内网出口的连接。 为什么是 0.0.0.0 而不是服务器的具体 IP?一句话:图省事、避免写错网卡、也不怕服务器 IP 变化。监听所有网卡对安全要求不高的测试用例完全够用。

坑三:防火墙 / 安全组拦截了入站连接。 服务端代码写对了、绑对了,Windows 端 connect 还是超时或拒绝——十有八九是防火墙。分两层查:第一层,云服务器的安全组(在云厂商控制台),必须有一条"入方向、协议 TCP、端口 8888、来源 0.0.0.0/0"(或你的 Windows IP)的放行规则;第二层,Linux 本机的防火墙(如 firewalld/ufw/iptables),如果开着,也要放行这个端口,例如 CentOS。而 Windows 这边首次 connect 时,Windows 防火墙一般会弹窗询问是否允许这个程序/端口对外通信,也要选择"允许"。这个坑的症状极具迷惑性——程序代码看上去全对、却怎么也连不通,九成九都是它。排查顺序建议:先看安全组 → 再看 Linux 防火墙 → 再抓包确认 SYN 有没有出去。

坑四:只 accept 一次,重连必须重启服务端。 前面说了,我们的服务端 while 循环是"服务一个客户端直到它关闭"。一旦客户端关闭,服务端 recv 返回 0,break 退出,整个进程结束——想再连一次,只能重启服务端。这不是 bug,是简化版该有的代价。真实的服务端一定会在 accept 外加一层循环,并且每个连接用多线程/多进程/io多路复用来并发处理。你现在知道"重连要重启 server"这个事实即可,通往高并发服务端的那条路,是后面专门的内容。

坑五:send 发不完、recv 收不满。 前面代码里我故意对它"睁一只眼闭一只眼"。真实网络里,send 可能因为发送缓冲满而只发出部分字节;recv 也可能只收到报文的一部分就返回。对短消息(比如一条不超过几十字节的话)在本地回环下通常没事,但只要消息变长、网络一抖动,就会出现"只发了一半""只收了一半"的诡异现象。严谨的写法:发送用循环把剩余字节发完;接收用一个"还差多少就继续读多少"的循环,直到收满期望长度的数据。这是一个用"一两句话能说明、十行代码能修好"的坑,值得你动手补上。

坑六:send 的第二个参数类型。 Winsock 的 send(SOCKET, const char* buf, int len, int flags),第二个参数是 const char*。如果你传的是别的类型(比如 void*、或 std::string 想直接传),MSVC 会报类型不匹配,需要强转。这也是 Windows 和 Linux 之间接口细节差异的一个典型案例——Linux 的 send 第二个参数是 const void*,用起来宽泛得多,所以同样是 send,两边对参数挑剔程度不一样。

思考题:自测答案详解

每篇都要留几道题,这次也一样。请先自己动脑想一遍,再往下看详解,别直接翻答案。

题 1:为什么不调 WSAStartup 就直接 connect(),会返回什么错误?请在不用查阅手册的前提下,说出这个现象背后的设计动机。

详解: 会返回失败,用 WSAGetLastError() 能查到错误码 10093(WSAENOTINITIALISED,Winsock 尚未初始化)。设计动机是:Winsock 是一整套需要向内核申请资源、绑定 DLL、并核对版本(MAKEWORD(2,2) 表明你要 2.2 版)的库。它必须在一个明确的"初始化状态"下才能工作,WSAStartup 就是那个"上电并自检"的入口;操作系统也借此知道"当前进程要用哪个版本的 Winsock",这样才能为它准备匹配的协议栈资源。不初始化就用,等于"电没上,就想跑程序",自然报错。Linux 不需要这一步,是因为 POSIX socket 的协议栈资源由内核按需提供、且只有一个稳定版本可协商,不需要额外"挂号"。

题 2:如果服务端故意把 listen 之前的 bind 改成绑定 127.0.0.1(而非 0.0.0.0),从本机 Windows 去连接这台 Linux,会发生什么?为什么?

详解: 连接必然失败(connect 报超时或拒绝)。因为 127.0.0.1 是回环地址,只对所绑的那台机器"自己"有效——绑定回环意味着"只接受发往 127.0.0.1 这个地址的连接请求",而外部主机(比如你的 Windows)发出的是发往"服务器公网/局域网 IP"的 SYN,Linux 内核发现目标 IP 不在它监听的回环地址上,直接丢弃或回 RST,不会放进 accept 队列。这就是为什么跨机联调必须绑 0.0.0.0(INADDR_ANY)。判断的服务端语句:只要服务端把监听地址绑成 127.0.0.1,就等价于"只对自己开放",外部永远进不来——你可以把这当作一根排查跨机不互通时的"第一直觉"。

题 3:在跨机场景下,如果服务端代码和客户端代码都"看着完全正确",却 connect 失败,你排查的三步顺序是什么?

详解: 教科书式的排查顺序(恰好也是真实项目中排查跨机不通的顺序):

  1. 云平台安全组:确认"入方向 / TCP / 端口8888 / 来源放行(0.0.0.0/0 或你的 IP)"这条规则存在。云厂商默认常常只放行 22、80 等少数端口,新增端口不手动加规则是连不进的。
  2. 服务器系统防火墙:确认 Linux 上没有把该端口拦出(比如 ufw/firewalld 的正常放行规则),Windows 端自己也别被本机防火墙弹出拦截。
  3. 抓包定位:在该机器执行 tcpdump -i any port 8888,看 Windows 的 SYN 是否真的到达了服务器网卡。SYN 都没到 → 前端网络/安全组问题;SYN 到了但没握手成功 → 再往内核和防火墙深挖。 一句话记忆:先看"门禁"(安全组/防火墙),再往下钻"看电信号(抓包)"。

题 4:为什么代码里 accept 之前要展示一种"服务端两个套接字"的现象?请说出 listenfd 与 connfd 的职责分工。

详解: listenfd 是监听套接字,职责单一:执行 listen 让内核在指定端口"站岗监听"并接收/排队到达的连接请求;它不负责和某个客户端的实际通信。connfd 是通信套接字,由 accept 从等待队列里"取走"一个已经握好手的连接而得到,它是具体和那一个客户端交换数据的通道,recv/send 都走它。一次 accept 回来的连接对应一个 connfd;要同时服务多个客户端就要多次 accept、各自拿各自的 connfd。这个"站岗的"和"通信的"分开的设计,是为了让同一个进程能够接待源源不断的客人而互不干扰——这正是后续高并发模型(多进程/多线程/io多路复用)得以展开的底层结构。

题 5:Windows 端 send 发送的数据,Linux 服务端一次 recv 一定能完整收到吗?请说明原因。如果没收到完整的,严谨的写法应该怎么做?

详解: 不一定。TCP 是字节流协议,它不保证"一次 send 对应一次 recv"——内核可能把你的数据拆分、合并、积压在缓冲区后再交付。尤其在消息较长、网络拥塞、MTU 限制、收发缓冲差异等情况下,一次 recv 完全可能只拿到整条消息的一部分。这就是"完整收包要循环"的原因。严谨的写法是:

  • 发送端:用循环 send,每次记录已发字节数,直到把 len 全部发出;
  • 接收端:设定一个"期望长度",循环 recv 拼接,直到收够期望的字节数为止。

进阶版本会用协议字段(比如报文头里带上"这条消息一共多长")来精确计算期望长度——这是所有实际网络协议(HTTP、自定义 RPC 帧等)都遵守的基本功。你只要现在记住"send/recv 都必须处理部分收发"这条铁律,就比很多"能跑但脆弱"的样例高出一个段位了。

题 6:客户端关闭连接后,服务端日志会打印什么?这背后的 TCP 行为是什么?

详解: 服务端会打印 client closed, server exits loop。背后的行为是:客户端 closesocket(或进程退出)会让内核发起 TCP 的关闭流程(四次挥手)——客户端发 FIN、服务端回 ACK、服务端发 FIN、客户端回 ACK。服务端的 recv 一旦读到"对端已关闭"的信号,就会返回 0(而不是负值),这正是 recv 返回 0 的语义:"对方优雅地关闭了这半条连接"。我们代码里 if (n <= 0) break; 的判断,正是靠 n==0 这个返回值感知到对方关闭,从而优雅退出循环。

收个尾

这一堂加餐课,我们从"到底想验证什么"出发,把 Winsock、connect、LISTEN、跨机、防火墙这五个关键词一次讲透;然后端出一份 Windows 客户端、一份 Linux 服务端,逐行拆解了它们各自的套路;再用一张对照表,把 Winsock 和 POSIX socket 这对"表兄弟"的差异拎得明明白白;接着带你编译、运行、抓包,亲眼看到了三次握手和四次挥手;最后把 WSAStartup 遗漏、绑定 127.0.0.1、防火墙拦截、只 accept 一次、send/recv 不分包、send 参数类型这六个"一定会绊你一跤"的坑,连同它们的真身与解法一起排了个清。

你可能会发现,这堂课的底子里其实只讲了一件事:网络编程里,跨一个平台、跨一台机器,你在课堂上学的那些"理论"才真正开始有分量。 本机回环时,代码怎么写都"看似成立";一旦跨机、真实网卡、真实防火墙都参与进来,你对"连接是谁帮你建的""为什么必须初始化""为什么必须绑 0.0.0.0""为什么收发包要循环"这些问题的理解,才算是扎了根。下次当你再看到一家公司面试官问"Windows 网络编程和 Linux 有什么区别",你已经能从容说出那三条铁律了:WSAStartup、closesocket、WSAGetLastError。

跨平台联调这条线的第一步,到这里就收官了。下一站,我们会在 Linux 本机之内,把上一页的验证升级成"服务端接待任意多个客户端"的并发模型,看看一个进程究竟是怎么做到既专心"站岗"、又从容"通信"的。咱们下一篇见。