前面几讲,我们一直在 Linux 这一台机器上玩 UDP:服务端和客户端都是同一个程序、跑在同一个主机上,自己人发、自己人收。虽然把 UDP 的收发流程跑通了,但你心里难免犯嘀咕——这跟真实的网络通信还是隔着一层纸。真实的场景哪有"自己跟自己说话"的?总得有台机器当服务器,另一台机器当客户端,隔着网络线互相对话才行。

所以这一讲是个"加餐":我们真的搞两台机器。一台是 Linux,我们用 Linux 写出 UDP 服务端(回显服务器,你发什么它回什么);另一台是 Windows,我们在 Windows 上写一个 UDP 客户端,去访问那台 Linux 服务端。这就是一次完整的跨机联调。

这一讲你会踩到不少"只有 Windows 才有"的坑,也会第一次感觉到"哦,原来跨到 Windows 上,那套我熟悉的 socket 接口变得不一样了"。别急,我陪你把每一个坎都迈过去。学完这一讲,你脑子里"socket 是通用的"这个印象,会被修正成更精确的版本。

这一讲你需要准备的

动手之前,先把道具备齐:

  • 一台 Linux。可以是你的云服务器(阿里云、腾讯云、华为云都行),也可以是你局域网里的一台 Linux 电脑。它就是我们的"服务端"。
  • 一台 Windows。它就是"客户端",用 VS(Visual Studio)或者 MinGW 都行,只要能编译 C/C++ 程序。
  • 两者能互通。要么在同一局域网,要么 Windows 能通过公网连到云服务器。这是"跨机"的前提——连不通,后面全是白搭。

如果你用的是云服务器,请先登进云厂商的网页控制台,找到"安全组"或者"防火墙"设置,把后面我们要用的端口放行。这一句话现在听起来没头没尾,等我们讲到"防火墙放行"那一节你会彻底明白为什么要做、怎么做。

套接字、Winsock 与跨机联调:先把名词对齐

动手前,先花几分钟把几个词说明白,否则后面代码里全是它们,你不知道在讲啥。

套接字(socket)。这是网络编程里绕不开的基础概念,本系列前面已经反复用过。一句话理解:套接字是"网络通信的端点",是操作系统提供给应用程序的一扇通向网络的"门"。你创建了一个套接字,就能往里写数据(发出去)、往外读数据(收进来)。UDP 用数据报套接字(SOCK_DGRAM),TCP 用流套接字(SOCK_STREAM)。

跨机联调。字面意思就是"跨机器联调",指的是让两个运行在不同物理机器上的程序互相通信、联合调试。别小看这四个字,从"同一台机器的两个进程通信"到"两台机器的两个进程通信",中间隔着的变量可不少:IP 地址怎么填、防火墙放不放行、绑定的地址对不对、端口通不通。这一讲从头到尾要解决的,就是这一堆"跨起来才冒出来的新问题"。

Winsock。这是 Windows 上的"套接字"实现,全称叫 Windows Sockets。我们知道 Linux 上网络编程用的是 POSIX 那套 socket()/bind()/sendto()/recvfrom() 接口,它们是系统对内核网络接口的封装。Windows 上也有几乎同名的一套接口,但它是 Windows 独有的,名字就叫 Winsock(Windows Sockets)。核心区别我们用一句话记住:Linux 的套接字接口是系统调用,Windows 的 Winsock 是一个跑在用户态的库(ws2_32.dll),用之前必须先初始化。这个"先初始化"的一大堆坑,正是本讲的主角。

防火墙放行。防火墙是操作系统或云平台的一道"门禁",它决定哪些数据包能进、哪些不能进。服务器自己开的端口,如果防火墙没放行,外面的包到了门口也会被拦下来丢掉。对我们这件事来说,就是需要在云平台安全组和 Linux 本机上,给 UDP 服务端那个端口(比如 8888)放行,让 Windows 发过来的包能真正进到我们的 UDP 服务端手里。

名词对齐了,我们开始写代码。

Linux 服务端:先把 8888 端口的门打开

既然是"Windows 访问 Linux",那 Linux 这端肯定是"被访问"的一方,也就是服务端。我们用回显服务器的思路:接收客户端发来的任意数据,然后把这份数据原样回发给对方。接收→回显→再接收→再回显,一直循环。

这一段代码和本系列前面讲过的 Linux UDP 服务端几乎一样,唯一需要你特别留心的是绑定地址必须是 0.0.0.0,这个坑我们放到后面专节讲。先把代码建出来:

// udp_server.c —— 运行在 Linux 上的 UDP 回显服务端
#include <stdio.h>      // printf / perror 等标准输入输出函数
#include <string.h>     // memset 内存置零函数
#include <unistd.h>     // close 函数,关闭套接字描述符
#include <sys/socket.h> // socket / bind / recvfrom / sendto 套接字接口
#include <netinet/in.h> // struct sockaddr_in 地址结构体、htons 等
#include <arpa/inet.h>  // inet_ntop / inet_pton 地址转换函数
 
#define SERV_PORT 8888  // 服务端监听的端口,要和后面 Windows 客户端填的一致
 
int main(void)
{
    // 第一步:创建数据报套接字(SOCK_DGRAM 即 UDP)
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)                 // Linux 上 socket 失败返回 -1
    {
        perror("socket");           // 打印具体失败原因
        return 1;
    }
 
    // 第二步:准备本机地址结构体,准备绑定到 0.0.0.0:8888
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server)); // 先清零,防止残留垃圾值
    server.sin_family = AF_INET;        // 选用 IPv4 地址族
    server.sin_port = htons(SERV_PORT); // 端口 8888 转成网络字节序
    // 0.0.0.0 表示"本机所有网卡地址都听",外部机器才能访问进来
    server.sin_addr.s_addr = htonl(INADDR_ANY);
 
    // 第三步:把套接字和这个地址绑定起来
    if (bind(sockfd, (struct sockaddr *)&server, sizeof(server)) < 0)
    {
        perror("bind");             // 绑定失败,很可能是端口被占用
        close(sockfd);              // 记得关闭刚建的套接字
        return 1;
    }
 
    printf("UDP server listening on 0.0.0.0:%d\n", SERV_PORT);
 
    // 第四步:接收 / 回显循环,一直服务下去
    char buf[1024];
    struct sockaddr_in client;      // 用来接收"发送方是谁"的地址
    socklen_t len = sizeof(client); // 地址结构体长度,收发函数需要它
    while (1)
    {
        memset(buf, 0, sizeof(buf)); // 每次收之前先清空缓冲区
        // recvfrom 阻塞等待数据;同时会把发送方的地址填进 client
        ssize_t n = recvfrom(sockfd, buf, sizeof(buf) - 1, 0,
                             (struct sockaddr *)&client, &len);
        if (n < 0)                  // 出错则退出(演示代码从简,真实项目应继续重试)
        {
            perror("recvfrom");
            break;
        }
        buf[n] = '\0';              // 数据不一定以 \0 结尾,这里手动补上
 
        // 把发送方的 IP 转成点分十进制字符串,方便看清是谁发来的
        char ip[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &client.sin_addr, ip, sizeof(ip));
        printf("from %s:%d -> %s\n",
               ip, ntohs(client.sin_port), buf);
 
        // 回显:把刚收到的数据原样发回给那个客户端
        sendto(sockfd, buf, n, 0,
               (struct sockaddr *)&client, sizeof(client));
    }
 
    close(sockfd);                  // UDP 无连接,直接用 close 关闭即可
    return 0;
}

编译很简单,Linux 上用 gcc 一行搞定:

gcc udp_server.c -o udp_server
./udp_server

跑起来会打印一行 UDP server listening on 0.0.0.0:8888,然后程序就停在那里等数据了。注意它没有调用 listen() 和 accept()——那不是 UDP 该干的事,那两个函数属于面向连接的 TCP。UDP 无连接,建好套接字、绑好端口,就能直接 recvfrom 收数据了。这一点等下会放在思考题里再敲打一遍。

Windows 客户端:WSAStartup 是绕不开的第一步

服务端就位了,现在写客户端。这一部分是真正的重头戏,因为你在 Windows 上写网络程序,闭眼也知道会遇到一大堆"只有 Windows 才这样"的规矩。我们先把它读出来,再逐行解释。

写之前先交代一个背景:这个客户端我保留经典的 C++ 写法(用 iostream 和 std::string),方便你直接粘贴到 VS 里跑。你要是更喜欢纯 C,把输入输出换成 printf/scanf 也完全行。

// udp_client.cpp —— 运行在 Windows 上、用 Winsock 写的 UDP 客户端
#include <iostream>   // C++ 的输入输出流,用来和用户交互
#include <string>     // std::string,存用户输入的消息
#include <cstring>    // memset 内存置零函数
#include <cstdint>    // uint16_t 这类定长整数类型
#include <WinSock2.h> // Windows Sockets 2 的 API 头文件(必须最先含,原因见正文)
#include <Windows.h>  // Windows 系统通用头文件
#pragma comment(lib, "ws2_32.lib") // 链接阶段带上 Winsock 2 的库文件
#pragma warning(disable : 4996)    // 关掉 inet_addr 等"旧函数"的安全警告,便于演示
 
int main()
{
    // ============ 第一步:初始化 Winsock,Windows 专属、不能省 ============
    WSADATA wsd;                                    // 用来接收初始化时返回的信息
    // MAKEWORD(2,2) 表示请求 Winsock 2.2 版本;成功返回 0,非 0 即失败
    if (WSAStartup(MAKEWORD(2, 2), &wsd) != 0)
    {
        std::cout << "WSAStartup failed" << std::endl;
        return 1;
    }
 
    // ============ 第二步:填服务端地址:Linux 服务器的 IP 和端口 ============
    const char *serverip = "195.168.1.10"; // 改成你的 Linux 服务器实际 IP
    uint16_t serverport = 8888;            // 和 UDP 服务端保持一致
 
    struct sockaddr_in server;                 // IPv4 地址结构体
    memset(&server, 0, sizeof(server));        // 清零,不留垃圾值
    server.sin_family = AF_INET;               // IPv4 地址族
    server.sin_port = htons(serverport);       // 端口转成网络字节序
    // 把点分十进制的字符串 IP 转成 4 字节整数(网络字节序)存进去
    server.sin_addr.s_addr = inet_addr(serverip);
 
    // ============ 第三步:创建 UDP 套接字 ============
    // 失败时返回 INVALID_SOCKET(不是 Linux 的 -1),这点要留意
    SOCKET sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd == INVALID_SOCKET)
    {
        // WSAGetLastError() 相当于 Windows 版的 errno,可查具体错误码
        std::cout << "socket error, code: " << WSAGetLastError() << std::endl;
        WSACleanup(); // 出错了也要把 Winsock 收拾干净再退出
        return 1;
    }
 
    // ============ 第四步:循环收发 ============
    std::string message;
    char buffer[1024];
    while (true)
    {
        std::cout << "Please Enter@ ";
        std::getline(std::cin, message);  // 从键盘读一整行
        if (message.empty()) continue;    // 空行直接跳过,避免发空包
 
        // 发给服务端;第 5 个参数传目标地址,第 6 个参数传地址长度
        sendto(sockfd, message.c_str(), (int)message.size(), 0,
               (struct sockaddr *)&server, (int)sizeof(server));
 
        // 阻塞等待服务端回显;temp 会填上实际发送方(就是服务端)的地址
        struct sockaddr_in temp;
        int len = (int)sizeof(temp);
        int s = recvfrom(sockfd, buffer, (int)sizeof(buffer) - 1, 0,
                         (struct sockaddr *)&temp, &len);
        if (s > 0)                     // s 是实际收到的字节数
        {
            buffer[s] = '\0';          // 手动补上字符串结尾
            std::cout << "recv: " << buffer << std::endl;
        }
    }
 
    // ============ 第五步:收尾,关闭套接字并清理 Winsock ============
    closesocket(sockfd);  // Windows 关闭套接字用 closesocket,不是 close
    WSACleanup();         // 用完 Winsock 要卸载它,释放资源
    return 0;
}

这段代码能不能跑通、能跑多远,取决于你掌握了下面这些"Windows 专属规矩"。我一条条拆开讲,这每一行背后都是一类面试题。

为什么必须先 WSAStartup?—— Windows 的灵魂谜题

我们把视线停在第一处让 Linux 老手浑身不自在的地方:为什么要先调用 WSAStartup,而 Linux 上从来没见过这玩意儿?

这得从 Winsock 的实现机制说起。Linux 上的 socket()/bind()/sendto()/recvfrom() 是系统调用,也就是直接进内核。写这些函数时你能看到的是内核已经给你准备好了,随叫随到。但 Windows 的 Winsock 不一样,它本质上是一个用户态的动态链接库 ws2_32.dll,并不是系统调用,而是库里提供的函数。程序在调用库里的函数之前,得先做几件事:加载这个 dll、向操作系统声明"我要用 Winsock"、协商一个双方都认可的版本、让库把内部状态和资源初始化好。这一整套"打招呼"的动作,就是 WSAStartup 干的。

它还有个更优雅的称呼叫"版本协商"。MAKEWORD(2, 2) 这个宏的意思是"我请求 Winsock 2.2 版"。MAKEWORD(a, b) 会把两个字节拼成一个 16 位的 WORD:低字节放主版本号、高字节放次版本号,所以 MAKEWORD(2, 2) 请求的就是版本 2.2。Windows 收到后判断自己能不能满足,能满足就返回 0(成功),不能满足就返回非 0 的错误码。返回的信息(版本号、描述、最大套接字数等)会填进第一个参数 WSADATA wsd 里,你一般用不到,但总得给它留个地方。

同时注意,WSAStartup 的返回值必须检查。它不像 Linux 无脑往下跑就行,WSAStartup 失败是实实在在会发生的(比如某种奇葩环境里网络子系统没就绪)。上面代码里 if (WSAStartup(...) != 0) 就是标准写法。

有 WSAStartup 开张,自然就有 WSACleanup 打烊。WSACleanup 的作用是卸载 Winsock 库、释放这次会话占用的资源,跟 WSAStartup 一一对应。规则是:你调用了几次 WSAStartup,就应当调几次 WSACleanup 来平衡。实际的简单小程序里,你只要保证在 WSAStartup 成功之后、程序结束之前调用一次 WSACleanup 就好——别留着不收拾,那是坏习惯。

INVALID_SOCKET 不是 -1:一个被忽略的返回值差异

再看第三步创建套接字。Linux 里 socket 失败返回 -1,所以人人习惯 if (sockfd < 0) 判错。但 Windows 不一样,SOCKET 这个类型不是 int,而是 UINT_PTR(无符号整数指针,64 位下是 8 字节)。它失败时返回的是 INVALID_SOCKET,这个宏的值是 (SOCKET)(~0),翻译过来就是"所有二进制位全是 1"——在 32 位模式下的数值正好也是 0xFFFFFFFF。

所以这里有个挺容易让人困惑的细节:**32 位模式下,INVALID_SOCKET 和 SOCKET_ERROR 数值相同(都是全 1),于是拿 sockfd == SOCKET_ERROR 来判断也能"碰巧"跑对。**但语义上两者根本不是一回事:socket() 失败返回的是 INVALID_SOCKET;而 SOCKET_ERROR 是"函数调用出错"的通用错误标记(很多函数比如 sendto/recvfrom 失败就返回它)。判断"套接字建没建成",规范写法就是判 INVALID_SOCKET。我们把这一条写成铁律记下来,别学那些图省事的写法。

错误信息从哪查?Windows 用 WSAGetLastError() 获取最近一次 Winsock 调用的错误码,功能对应 Linux 的 errno。所以整理一下这张对应关系表:

这件事LinuxWindows(Winsock)
头文件<sys/socket.h> 等一组<WinSock2.h> + <Windows.h>
初始化不需要必须 WSAStartup
清理不需要必须 WSACleanup
是否系统调用是,直接进内核否,是 ws2_32.dll 库函数
套接字类型int fdSOCKET(其实是无符号整数)
创建失败返回值-1INVALID_SOCKET
关闭套接字close(fd)closesocket(sock)
错误码查询errno / perrorWSAGetLastError()
链接库无ws2_32.lib

头文件的顺序是个隐藏的地雷

有个只有 Windows 新手才懂的痛:头文件的包含顺序。

WinSock2.h 这个头文件很娇气,它警告你必须先于 <Windows.h> 被包含。原因很坑爹:老早的 <windows.h> 默认会连带包含 <winsock.h>(Winsock 1.0 的那套旧头文件)。如果你先 include 了 <Windows.h> 再 include <WinSock2.h>,两个头文件里会定义一大堆完全同名的结构体和函数声明,于是编译器报一堆"重定义"错误,把你吓得以为自己写错了字。解决办法很简单,把 <WinSock2.h> 放在 <Windows.h> 前面,让 Winsock 2 先占好位置。我上面代码里就是这么排的,这也是为什么注释里特意写了"必须最先含"。

顺带把链接的方式也说全。代码里的 #pragma comment(lib, "ws2_32.lib") 是 MSVC(Visual Studio 的编译器) 的写法,意思是"链接时带上 ws2_32.lib 这个库"。如果你用的是 MinGW/g++(很多人也这么干),#pragma comment 就不起作用了,得在命令行显式写库名:

g++ udp_client.cpp -o udp_client.exe -lws2_32

这就回答了一个很常见的疑问:为什么我的 VS 直接就能跑?因为 #pragma comment(lib, ...) 帮你把库链接的事都交代好了;而 Linux 下编译网络程序不需要 -l 是因为那是系统调用,内核接口默认就有。

联调前的三道坎:0.0.0.0、防火墙、IP 填哪张网卡

代码都齐了,但你的 UDP 十有八九第一次联不通。不用慌,九成九是栽在这三道坎上。我宁可花一整节把它们讲透,因为你踩一次就能记一辈子。

第一道坎:服务端必须绑 0.0.0.0,而不是 127.0.0.1

这是"本机能通、跨机就死"的头号凶手,也是很多同学在自己 Linux 上测得好好的、一上云服务器就哑火的原因。

127.0.0.1 是回环地址(loopback),它只代表"本机自己"。绑定到 127.0.0.1 意味着:只有这一台机器自己发来的包能进来,别的主机发的包一律不认。你在单机自测时当然"一切正常",因为发和收都是本机;可一旦换成 Windows 从外面连接,数据包连入口都进不来,服务端根本收不到。

0.0.0.0(符号名 INADDR_ANY,意为"任意地址")则代表"本机所有的网络接口"。绑定到它,相当于对操作系统说:"不管数据是从哪块网卡进来的,只要目标端口是 8888,都交给我的套接字处理。"于是无论 Windows 走的是公网还是内网网卡,包都能到达服务端。跨机联调的铁律:对外服务一定要绑 0.0.0.0。我上面的 Linux 服务端用的是 server.sin_addr.s_addr = htonl(INADDR_ANY);,正是为了这个。

第二道坎:防火墙/安全组要放行,而且别只放 TCP

包好不容易到了服务器所在的主机,还得过"防火墙"这一关。这道门禁要是没打开,包会在门口被直接丢掉,服务端连影子都看不到,而客户端表现是"发出去之后收不到任何回复"。

这里有两层防火墙要惦记:

  • 云平台安全组。如果你用的是云服务器,在阿里云/腾讯云/华为云的网页控制台里,找到"防火墙"或"安全组"入口,添加入站规则,放行 UDP 的 8888 端口。这里有个常见失误:很多人点了"放行 TCP 8888",忘了 UDP 是另一种协议,结果 TCP 通、UDP 不通。得明确勾选协议类型为 UDP。
  • Linux 本机防火墙。比如 firewalld、ufw 这类工具。Ubuntu 上常犯的一个错误是 ufw 默认开着,却没有放行对应端口。临时实验可以 sudo ufw allow 8888/udp 放行;如果只是想省事测试,也有人直接 sudo ufw disable 关掉,但生产环境千万别这么干。

一个对 UDP 特别容易让人误解的点:UDP 是无连接的,sendto 永远会"成功",压根不会像 TCP 那样因为连不上就报错给你看。所以当你在 Windows 客户端看到"发出去却没有回复"时,98% 是防火墙/绑定地址的锅,而不是你代码错了。判断思路放到思考题里一起考你。

第三道坎:IP 填哪张网卡,绝不能填 127.0.0.1

回头看客户端里那句 server.sin_addr.s_addr = inet_addr(serverip);,这行填的是**服务端(Linux)**的 IP。填什么,取决于你的网络拓扑:

  • Windows 和 Linux 在同一局域网:就填 Linux 这台机器的内网 IP(长得像 192.168.x.x 这种)。用 ip addr 或 ifconfig 在 Linux 上查。
  • Windows 访问云服务器:填云服务器的公网 IP(长得像 120.x.x.x/47.x.x.x 这种,在云控制台能看到)。

这是最容易踩且最隐蔽的坑:很多人图省事在客户端里填 127.0.0.1。可 127.0.0.1 指的是"运行客户端的那台机器自己"——也就是 Windows 自己,它压根不会出网去找 Linux。填了这个,等于你让 Windows 去找"Windows 自己",永远找不到那台 Linux。

顺带一提,inet_addr 这个函数本身也是个"迷你坑"。它只支持 IPv4,而且转换失败时返回 INADDR_NONE(即 0xFFFFFFFF),这个值和真正的"全 1 地址 255.255.255.255"撞了车,所以你没法靠返回值区分"转换失败"和"转换到了全 1 地址"。虽然 MSVC 下我们靠 #pragma warning(disable:4996) 压掉了它的弃用警告,但更稳妥的做法是改用 inet_pton(Windows Vista 及以后也支持),它的失败返回 0、成功返回 1,语义干净得多。这个作为进阶提醒放这儿,初学先用 inet_addr 跑通即可。

完整的联调动作:一步步让两端对上话

代码、坑都交代完了,我们用一整套标准动作把两端跑起来。结论先行,这六步走完,你就能在 Windows 的黑色控制台里输入一句话,然后立刻在 Linux 的屏幕上看到它,并且 Windows 自己也会收到回显。

# 动作一:在 Linux 上编译并启动 UDP 服务端
gcc udp_server.c -o udp_server
./udp_server
# 预期看到:UDP server listening on 0.0.0.0:8888
# 动作二:云控制台放行 UDP 8888(如果用的是云服务器)
# 设出入站规则:协议 UDP,端口 8888,来源 0.0.0.0/0(或限内网)
# 动作三:Windows 客户端填对服务端 IP,然后编译
#   - VS:直接编译运行(#pragma comment(lib) 已帮你链接 ws2_32.lib)
#   - MinGW:g++ udp_client.cpp -o udp_client.exe -lws2_32
./udp_client.exe

动作四:在 Windows 控制台输入 hello linux 回车。此时 Windows 端会显示 recv: hello linux,Linux 服务端屏幕上会打印一条 from <你的IP>:<临时端口> -> hello linux。

动作五:多敲几句,比如 ping、你好呀,观察两边打印是否成对出现。

动作六:Ctrl+C 分别结束两端程序。

看到那个 from ... 了吗?它证明了 Windows 发出去的数据真的穿过了网络、被 Linux 收到,再被回显回来——这就是一次完整的、真实的跨机 UDP 对话。值得你反复体会:这不是模拟器里的数据,是真实网络上的数据。

排查清单:联不通时按顺序自查

万一没跑通,别乱,按这个顺序查:

  1. 服务端有没有绑 0.0.0.0(看启动打印是不是 0.0.0.0:8888)。绑成 127.0.0.1 必死;
  2. 本地先把客户端里的 IP 换成服务器内网 IP、并保证真的能 ping 通。ping 不通,网络层就断了;
  3. 确认防火墙/安全组放行了 UDP(不是 TCP)8888;
  4. 确认客户端填的 IP 不是 127.0.0.1;
  5. 两端端口一致(都 8888),别一个 8888 一个 8889。

只要这些都对,UDP 联调几乎必通。

Winsock 的常用家底:这一讲认识的那些名字

前面代码里已经出现过一批类型和函数,我把它单独拎出来总结一遍。因为 Winsock 家族的种类不太多,一次认全,比将来反复翻文档强:

  • WSADATA:保存 WSAStartup 初始化时返回的信息(版本、描述等)。你一般只负责把它传进去,用不用担心内容。
  • SOCKET:套接字句柄,代表一个套接字。注意它不是普通 int。
  • sockaddr_in:IPv4 的地址结构体,装 IP 和端口。它的用法和 Linux 几乎一模一样。
  • socket():创建套接字。类型参数 SOCK_DGRAM = UDP。
  • bind():把套接字和本地地址绑定。客户端通常不用 bind(系统会自动分配临时端口),服务端必须 bind。
  • sendto() / recvfrom():UDP 的发送/接收,都要带"对端地址"参数,这是 UDP 无连接的体现。
  • listen() / accept():这里是老朋友但不是本讲主角——这俩属于 TCP,建立连接用的。初学 UDP 的人最容易误以为也要调它们,切记不需要。

这一列表格,其实跟我们在 Linux 上见过的一模一样,只是换了层皮。到了这一步你会越发清楚:socket 这套接口确实"跨平台通用",只是 Windows 在外面裹了一层初始化/清理的壳。

思考题与详解

写完代码、跑通联调,理论还得落地。下面六道题,先自己答,再看我的详解。

题一:Linux 上没有 WSAStartup,为什么 Windows 上就必须有?

详解:两大原因。第一,取决于实现的本质:Linux 的 socket 接口是操作系统内核提供的系统调用,程序直接调用即可,内核早就准备好了;Windows 的 Winsock 是用户态的 ws2_32.dll 库,用它之前必须先把库加载好、把手续办齐,而这个"办手续"的动作就是 WSAStartup,用完还得用 WSACleanup 卸载。第二,版本协商:WSAStartup 通过 MAKEWORD(2,2) 请求一个双方都认可的 Winsock 版本,返回非 0 表示失败,必须检查。总之一句话,Windows 的网络接口不是系统调用而是库,库要"初始化—使用—清理"三部曲。

题二:MAKEWORD(2, 2) 到底在请求什么版本?成功返回 0 代表什么,非 0 呢?

详解:MAKEWORD(a,b) 把两个字节拼成一个 16 位的 WORD,低字节是主版本号、高字节是次版本号,所以 MAKEWORD(2, 2) 请求的版本是 2.2。这个语义要读对:不是"两个 2 相加",而是"版本 2 点 2"。返回 0 表示 WSAStartup 初始化成功、版本协商通过;返回非 0 则是错误码,表示失败——比如网络子系统未就绪等,不同的正整数对应不同的错误定义。

题三:为什么 UDP 服务端要绑 0.0.0.0 而不是 127.0.0.1?绑 127.0.0.1 会怎样?

详解:127.0.0.1 是回环地址,只代表"本机自己",绑上它外部主机的数据包进不来,只能本机自收自测,所以单机测试"一切正常"、一跨机就哑火。0.0.0.0(INADDR_ANY)代表"本机所有网络接口",绑上它后,无论数据是从公网网卡还是内网网卡进来的、目标端口是 8888,系统都会把它交给我们的套接字。对外提供服务的 socket 必须绑 0.0.0.0。这正是"本机自测通过、跨机联调失败"最常见的根因。

题四:客户端里填 127.0.0.1 能访问远端 Linux 吗?应该填什么?

详解:不能。客户端代码里的 IP 填的是服务端(Linux)的地址,而 127.0.0.1 指向的是"运行客户端的那台机器",也就是 Windows 自己,永远不会出网。同局域网时填 Linux 的内网 IP(如 192.168.x.x),从公网访问云服务器时填公网 IP。要区分清楚谁填谁的地址成对使用:服务端绑 0.0.0.0(听所有人),客户端填服务器具体 IP(去找特定的人)。

题五:Windows 的 socket() 失败为什么判 INVALID_SOCKET?SOCKET_ERROR 和它一样吗?

详解:Windows 的 SOCKET 类型是 UINT_PTR(无符号整数),失败时返回宏 INVALID_SOCKET,其值是 (SOCKET)(~0)(全 1)。规范写法是判 INVALID_SOCKET。而 SOCKET_ERROR 是"调用出错"的通用错误标记(值为 -1),很多收发函数失败时返回它。二者在 32 位模式下数值恰好相同(都 0xFFFFFFFF),于是有人用 sockfd == SOCKET_ERROR 判断也能碰巧跑对,但语义上是错的,属于"能跑但不对"的代码。错误原因用 WSAGetLastError() 查询,对应 Linux 的 errno。

题六:UDP 服务端为什么不需要 listen() 和 accept()?

详解:listen() 和 accept() 是面向连接(TCP)的机制。listen 让套接字进入监听状态,accept 在一方连进来时建立一条连接并返回一个新的连接套接字,随后双方用这个新套接字通信。而 UDP 是无连接的:没有"建立连接"这一概念,双方各自建一个套接字,之后直接靠"目标地址"用 sendto 发、recvfrom 收。服务端建好、绑好端口后就能直接 recvfrom 收数据,完全不需要 listen/accept。这也是"UDP 双方地位对等"的本质体现——没有客户端/服务端之分,只有"先建 socket 的一方"和"后建的",通信本身是平级的。

题七(送分但经典):Windows 客户端关套接字为什么是 closesocket 而不是 close?末尾的 WSACleanup 是干什么的?

详解:Linux 下套接字描述符就是一个普通的文件描述符(int fd),用 close 关;Windows 下 SOCKET 不是普通文件描述符,Windows 单独提供了 closesocket() 来关闭套接字句柄,所以关接口两系统不通用。WSACleanup 则是与 WSAStartup 成对的"收尾"动作:在套接字都关闭之后、程序退出之前调用一次,用来卸载 Winsock 库、释放这次会话占用的资源。规则是 WSAStartup 调几次、WSACleanup 调几次,保持平衡。

这一讲我们到底把什么摸透了

来个白描式的收束。这一讲是系列里的一个"加餐",但我们干的事一点不比正餐轻松:你真的让一台上装着 Windows 的机器,跨过网络,和一台 Linux 服务器做了一次完整的 UDP 对话。为了走到这一步,你扳正了三个认知:

第一,Windows 的网络编程和 Linux 长得像、内核完全不同。它是用户态库不是系统调用,因此多了 WSAStartup/WSACleanup 这对初始化与清理,多了 closesocket/WSAGetLastError 这些看似相似、实则必用的接口,多了 INVALID_SOCKET 和 SOCKET_ERROR 之分,也多了 <WinSock2.h> 必须先于 <Windows.h> 的头文件顺序。

第二,跨机联调挑战的不只是代码,还有网络本身。服务端要绑 0.0.0.0 才能对外,防火墙和安全组要放行具体协议的端口,客户端填的 IP 要指向服务器的真实地址。这三件事少一件,UDP 就是"发出去永远没回音"。

第三,UDP 无连接的本质在跨机场景里被无限放大了。没有连接建立,所以 sendto 永远"成功",出了问题也不会第一时间报给你——你得靠自查清单去定位,而不是干瞪眼等一个错误提示。

把这些捡起来,你就已经具备"Windows 客户端 + Linux 服务端"这种最常见的跨平台网络联调能力了。下一讲我们看的是同题的 TCP 版——同样两台机器,TCP 需要 listen/accept、需要建立连接,那又是另一套完全不同的故事。到时候你会发现:把无连接的 UDP 和有连接的 TCP 放在一起对照着学,才是把它们彻底吃进肚子的正解。