🔥 星光编译者 · 个人主页
📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》
🌄 向云端飞扬,编译属于自己的代码星河。
☕ 写在开篇
你好,这里是 星光编译者。
这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。
比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。
如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。

🔥 本文定位:沿着Windows 客户端示例,把 UDP 通信从同机测试推进到 Windows 与 Linux 之间的跨平台验证。
💡 学习目标:理解 Winsock 初始化与链接配置;分清 Windows 和 Linux 的 Socket 接口差异;完成一问一答的 UDP 回显;按收发证据排查网络问题。
📌 实现范围:IPv4、C++17、阻塞式 UDP、Windows 控制台客户端与 Linux 回显服务器。每条消息最多 1024 字节,客户端接收等待约 3 秒。
前两篇已经在 Linux 上讨论了 UDP Socket 的地址、接口和业务封装。这次换一个更贴近实际使用的场景:服务器运行在 Linux 上,客户端运行在自己的 Windows 电脑上,两端还能不能直接通信?
答案是可以。需要统一的是地址族、传输协议和应用数据格式;各自使用什么操作系统、怎样调用本地网络接口,并不要求相同。
文章目录
- 一、先确定两个通信端点
- [二、Windows 网络编程先认识 Winsock](#二、Windows 网络编程先认识 Winsock)
- [三、Linux 代码迁移到 Windows 的关键差异](#三、Linux 代码迁移到 Windows 的关键差异)
- [四、逐步拆解 Windows UDP 客户端](#四、逐步拆解 Windows UDP 客户端)
- [五、完整代码:Windows 客户端与 Linux 服务器](#五、完整代码:Windows 客户端与 Linux 服务器)
- 六、编译运行并验证通信闭环
- [七、云服务器的 UDP 端口怎样放行](#七、云服务器的 UDP 端口怎样放行)
- 八、看懂接收结果与示例的边界
- 九、通信失败时按什么顺序排查
- 总结
- 参考资料
一、先确定两个通信端点
写代码之前,先把"谁发给谁"写清楚。本文约定 Linux 服务器提供 8888/udp 服务,Windows 客户端把一条消息发送到服务器,服务器收到后原样回发。
| 项目 | Windows 客户端 | Linux 服务器 |
|---|---|---|
| 角色 | 主动发起请求,等待回显 | 接收请求,回复本次发送者 |
| Socket 类型 | AF_INET + SOCK_DGRAM |
AF_INET + SOCK_DGRAM |
| 本地端口 | 通常由系统分配临时端口 | 显式绑定 8888 |
| 远端地址 | 配置可达的服务器 IPv4 和 8888 |
从 recvfrom 获取客户端来源 |
| 消息约定 | 一次发送一条数据报 | 回发同一组字节 |

1.1 为什么双方不需要使用同一个操作系统
发送端把应用提供的字节交给自己的网络协议栈,接收端再通过自己的 Socket 接口读出数据。Windows 调用 Winsock,Linux 调用 POSIX 风格接口,两者共同遵守 UDP/IP 协议。
可以把这个过程写成:
text
Windows 应用 → Winsock → UDP/IP 网络路径
→ Linux 协议栈 → Linux 应用
真正需要对齐的是 IPv4 + UDP + 目标端口 + 消息解释方式。例如,Windows 发 UTF-8 字节,Linux 也按 UTF-8 解释,才能得到一致的文字。本文服务器直接回显字节,第一次验证建议使用 hello 这样的 ASCII 文本,先把网络链路与终端编码问题分开检查。
1.2 三种 IP 地址不要混用
127.0.0.1 是当前机器的回环地址。 在 Windows 客户端填写它,访问的是 Windows 自己;在 Linux 上填写它,访问的是当前 Linux 实例。除非已经明确配置了本机转发,否则它不能代替另一台主机的地址。
0.0.0.0 是本地通配绑定地址。 Linux 服务器用它绑定,表示接受发往本机各 IPv4 接口、且符合端口和网络策略的数据报。客户端目标地址应填写可达的具体服务器 IP。
服务器的具体 IP 要与所在网络匹配。 同一局域网通常填写 Linux 的局域网地址;访问云服务器通常填写其公网地址。如果 Linux 在虚拟机里,还要确认虚拟网络模式与主机到虚拟机的可达路径。本文示例只接收数字 IPv4,不做域名解析。
云主机的公网 IP 可能通过平台映射到实例内网 IP,并不一定出现在 Linux 网卡上。因此服务器采用 0.0.0.0 绑定,比直接把公网 IP 写进 bind 更适合这个入门场景。
1.3 为什么客户端不需要绑定 8888
8888 是服务器的服务入口,不是双方必须共用的端口。Windows 在第一次对未绑定 UDP Socket 调用 sendto 时,可以隐式完成本地绑定;系统为客户端选择临时端口。Microsoft:sendto
服务器收到数据时,recvfrom 返回来源地址和来源端口。服务器应该回复这个端点,而不是把客户端的目标端口也写死成 8888。
如果客户端经过家庭路由器等 NAT 设备,服务器观察到的来源 IP 和端口可能已经转换。回复仍以本次收包得到的来源为准;不要拿 Windows 的内网地址代替公网回程端点。
二、Windows 网络编程先认识 Winsock
课程代码在通信循环前增加了两件事:包含 WinSock2.h,以及调用 WSAStartup。这两步分别解决编译时的接口声明和运行时的 Winsock 初始化。
2.1 头文件和库各负责什么
本篇客户端使用:
cpp
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <winsock2.h>
#include <ws2tcpip.h>
#ifdef _MSC_VER
#pragma comment(lib, "Ws2_32.lib")
#endif
winsock2.h提供SOCKET、地址结构及主要 Socket 接口声明。ws2tcpip.h提供InetPtonA等地址转换接口。Ws2_32.lib是 MSVC 链接时使用的导入库,相关实现由 Windows 的 Winsock DLL 提供。
头文件让编译器知道函数长什么样;链接库让链接器能够解析函数引用。只包含头文件,却漏掉链接库,常会在链接阶段出现 unresolved external symbol 一类错误。
如果工程还需要显式包含 windows.h,保持 Winsock 2 头文件在它之前,并使用 WIN32_LEAN_AND_MEAN 减少旧 Winsock 头的冲突。本例不需要额外包含 windows.h。课程中的 #pragma warning(disable : 4996) 也不必保留:这里用 InetPtonA 替换旧的地址转换写法。
2.2 WSAStartup 怎样调用
cpp
WSADATA data{};
const int error = WSAStartup(MAKEWORD(2, 2), &data);
if (error != 0) {
// error 本身就是这次初始化的错误码。
}
MAKEWORD(2, 2) 请求 Winsock 2.2,WSADATA 接收初始化信息。成功后再执行本篇的 Socket 创建与通信操作;初始化失败时使用函数返回的错误码,不要套用普通收发函数的错误获取流程。Microsoft:WSAStartup
完整代码还检查返回的 wVersion。对初学者来说,关键是先记住两种错误来源:WSAStartup 失败读返回值,普通 Winsock 调用失败读 WSAGetLastError()。

2.3 为什么最后既要 closesocket,又要 WSACleanup
它们处理不同层面的资源:closesocket 关闭一个 Socket,WSACleanup 与一次成功的 Winsock 初始化配对。成功初始化多少次,就应相应清理多少次。Microsoft:WSAStartup 的清理约定
课程示例把这两句放在 while (true) 后面,却没有安排退出分支,正常交互时很难走到那里。本文增加 /quit 和输入 EOF 退出,并使用 RAII:先构造 Winsock 管理对象,再构造 Socket 管理对象;离开作用域时按相反顺序析构,保证先关 Socket,再清理 Winsock。
三、Linux 代码迁移到 Windows 的关键差异
socket、sendto、recvfrom 看起来很熟悉,但不能把 Linux 文件中的几行代码原样粘贴到 Windows 后就结束迁移。

| 检查项 | Linux | Windows |
|---|---|---|
| Socket 保存类型 | int |
SOCKET |
socket 创建失败 |
-1,检查 < 0 |
INVALID_SOCKET |
| 收发函数返回类型 | ssize_t |
int |
| 收发失败 | -1 |
SOCKET_ERROR |
| 地址长度参数 | socklen_t |
int |
| 错误码 | errno |
WSAGetLastError() |
| 关闭资源 | close(fd) |
closesocket(sock) |
| 初始化/清理 | 无 Winsock 这一层 | WSAStartup / WSACleanup |
3.1 SOCKET 不要存进 int
在 Windows 上,用 API 定义的 SOCKET 类型保存返回值,避免对句柄宽度和取值范围作错误假设。它是当前应用访问本地网络资源的句柄,不是 IP、端口或网络中的全局编号。
创建 Socket 时,应使用接口约定的失败值:
cpp
SOCKET sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (sock == INVALID_SOCKET) {
const int error = WSAGetLastError();
// 记录 error,终止这次初始化。
}
课程示例用 SOCKET_ERROR 检查 socket 返回值,本文改为 INVALID_SOCKET;收发函数等返回 int 的调用仍按各自约定检查 SOCKET_ERROR。Microsoft:socket
3.2 recvfrom 的地址长度类型也要改
Windows 的 recvfrom 最后一个参数是 int*,因此写法是:
cpp
sockaddr_in peer{};
int peer_length = static_cast<int>(sizeof(peer));
调用前它表示地址缓冲区容量,调用后反映实际返回的地址长度。每次接收前重新初始化;不要把 Linux 的 socklen_t* 当作 Windows 的签名使用。Microsoft:recvfrom 参数
3.3 本例不使用 listen 和 accept
PDF 的 Winsock 基础说明列举了 listen、accept,它们属于建立面向连接服务时会用到的接口。本篇使用 UDP 数据报 Socket,服务器直接 bind → recvfrom → sendto,客户端 sendto → recvfrom。
不要为了"让 UDP 接收数据"额外加上 listen 或 accept。同样,UDP 和 TCP 不能因为端口数字相同就互通:8888/udp 与 8888/tcp 是不同的传输层入口。
四、逐步拆解 Windows UDP 客户端

4.1 构造的是远端服务器地址
cpp
sockaddr_in server{};
server.sin_family = AF_INET;
server.sin_port = htons(port);
const int converted = InetPtonA(AF_INET, server_ip, &server.sin_addr);
这里的 server 用来告诉 sendto 数据发往哪里。它不是客户端本地绑定地址,也不会因为填了 8888 就让客户端占用该端口。
htons 把端口数值转换成网络字节序。InetPtonA 把点分十进制地址转换成网络格式,成功返回 1,无效地址返回 0,接口错误返回 -1;不要对转换后的 IP 再重复执行 htonl。Microsoft:InetPton
完整程序从命令行读取目标 IP 和端口,同时校验端口必须是 1..65535 范围内的十进制整数。这样换服务器时不需要修改源码再编译。
4.2 sendto 成功以后,为什么还要等待回应
sendto 返回发送字节数,只能说明这次本地发送调用成功,不能证明服务器已经收到或处理消息。Microsoft:sendto 的交付说明
本文用回显作为应用层验证:Linux 从 recvfrom 得到消息后,再通过 sendto 回复同一组字节。Windows 观察到回显,才能确认这次请求与响应形成闭环。
普通 UDP 数据报不是 TCP 字节流。不要把发送失败当成"还剩一点字节,再补发一段";本例校验发送长度是否与消息长度一致,异常时结束运行。
4.3 为什么增加接收超时
如果服务器没启动、IP 写错或路径上丢包,阻塞式 recvfrom 可能一直等待,看上去像程序卡住。本文通过 SO_RCVTIMEO 设置约 3 秒的等待时间:
cpp
const DWORD timeout_ms = 3000;
setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO,
reinterpret_cast<const char*>(&timeout_ms),
static_cast<int>(sizeof(timeout_ms)));
Windows 这里的值类型是 DWORD,单位是毫秒;不要直接搬用 Linux 常见的 timeval 配置。完整程序也检查 setsockopt 的返回值,确保超时设置确实成功。Microsoft:SO_RCVTIMEO
本例超时后关闭 Socket 并退出,修正问题后重新启动客户端。它没有自动重发,因此不会把一次超时直接变成重复业务请求。
4.4 收到消息后按长度输出
课程示例用 buffer[s] = 0 再把缓冲区当 C 字符串打印。对普通文本可工作,但必须预留终止符空间;若内容含 \0,还会提前停止显示。
本文直接按返回长度输出:
cpp
std::cout.write(buffer, received);
不需要往缓冲区尾部写终止符,刚好收到 1024 字节时也不会产生 buffer[1024] 越界。客户端还对比响应来源与配置的服务器端点,防止把无关端点的数据当成回显;这种来源检查只是一项一致性检查,不构成身份认证。
五、完整代码:Windows 客户端与 Linux 服务器
下面两个源文件各自独立,可以直接编译。为了集中观察 Winsock 与 UDP 调用,程序没有引入线程池、业务回调或聊天室协议。
服务器与课程截图的显示方式稍有区别:本篇原样回显请求字节,不拼接 server echo message 前缀。 这使消息长度和内容的核对更直接。
5.1 Windows UDP 客户端
保存为 windows_udp_client.cpp:
cpp
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <winsock2.h>
#include <ws2tcpip.h>
#include <charconv>
#include <cstdint>
#include <cstring>
#include <iostream>
#include <stdexcept>
#include <string>
#include <system_error>
#ifdef _MSC_VER
#pragma comment(lib, "Ws2_32.lib")
#endif
std::uint16_t parse_port(const char* text) {
unsigned int value = 0;
const char* end = text + std::strlen(text);
const auto result = std::from_chars(text, end, value);
if (result.ec != std::errc{} || result.ptr != end ||
value == 0 || value > 65535) {
throw std::invalid_argument("Port must be an integer in 1..65535");
}
return static_cast<std::uint16_t>(value);
}
struct WinsockSession {
WinsockSession() {
WSADATA data{};
const int error = WSAStartup(MAKEWORD(2, 2), &data);
if (error != 0) {
throw std::runtime_error("WSAStartup error=" + std::to_string(error));
}
if (LOBYTE(data.wVersion) != 2 || HIBYTE(data.wVersion) != 2) {
WSACleanup();
throw std::runtime_error("Winsock 2.2 is unavailable");
}
}
~WinsockSession() { WSACleanup(); }
WinsockSession(const WinsockSession&) = delete;
WinsockSession& operator=(const WinsockSession&) = delete;
};
struct SocketOwner {
SOCKET value = INVALID_SOCKET;
SocketOwner() = default;
~SocketOwner() {
if (value != INVALID_SOCKET) closesocket(value);
}
SocketOwner(const SocketOwner&) = delete;
SocketOwner& operator=(const SocketOwner&) = delete;
};
int main(int argc, char* argv[]) {
if (argc != 3) {
std::cerr << "Usage: windows_udp_client.exe <server-ipv4> <port>\n";
return 1;
}
try {
const auto port = parse_port(argv[2]);
WinsockSession winsock; // Created first, destroyed after the socket.
sockaddr_in server{};
server.sin_family = AF_INET;
server.sin_port = htons(port);
if (InetPtonA(AF_INET, argv[1], &server.sin_addr) != 1 ||
server.sin_addr.s_addr == htonl(INADDR_ANY) ||
server.sin_addr.s_addr == htonl(INADDR_BROADCAST)) {
throw std::invalid_argument("Use a concrete unicast server IPv4 address");
}
SocketOwner sock;
sock.value = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (sock.value == INVALID_SOCKET) {
const int error = WSAGetLastError();
throw std::runtime_error("socket error=" + std::to_string(error));
}
const DWORD timeout_ms = 3000;
if (setsockopt(sock.value, SOL_SOCKET, SO_RCVTIMEO,
reinterpret_cast<const char*>(&timeout_ms),
static_cast<int>(sizeof(timeout_ms))) == SOCKET_ERROR) {
const int error = WSAGetLastError();
throw std::runtime_error("setsockopt error=" + std::to_string(error));
}
std::cout << "Target " << argv[1] << ':' << port
<< " (UDP). Enter /quit to exit. Max 1024 bytes.\n";
std::string message;
char buffer[1024];
while (std::cout << "Please Enter@ " && std::getline(std::cin, message)) {
if (message == "/quit") break;
if (message.size() > sizeof(buffer)) {
std::cerr << "Message exceeds 1024 bytes; skipped.\n";
continue;
}
const int sent = sendto(sock.value, message.data(),
static_cast<int>(message.size()), 0,
reinterpret_cast<const sockaddr*>(&server),
static_cast<int>(sizeof(server)));
if (sent == SOCKET_ERROR) {
const int error = WSAGetLastError();
throw std::runtime_error("sendto error=" + std::to_string(error));
}
if (sent != static_cast<int>(message.size())) {
throw std::runtime_error("Unexpected datagram send length");
}
sockaddr_in peer{};
int peer_length = static_cast<int>(sizeof(peer));
const int received = recvfrom(sock.value, buffer,
static_cast<int>(sizeof(buffer)), 0,
reinterpret_cast<sockaddr*>(&peer),
&peer_length);
if (received == SOCKET_ERROR) {
const int error = WSAGetLastError();
if (error == WSAETIMEDOUT) {
std::cerr << "No reply within 3 seconds; closing socket.\n";
} else if (error == WSAEMSGSIZE) {
std::cerr << "Reply exceeds 1024 bytes; discarded.\n";
}
throw std::runtime_error("recvfrom error=" + std::to_string(error));
}
if (peer_length != static_cast<int>(sizeof(peer)) ||
peer.sin_family != AF_INET ||
peer.sin_addr.s_addr != server.sin_addr.s_addr ||
peer.sin_port != server.sin_port) {
throw std::runtime_error("Reply came from an unexpected endpoint");
}
std::cout << "Reply (" << received << " bytes): ";
std::cout.write(buffer, received); // Includes a valid zero-byte datagram.
std::cout << '\n';
}
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
这里有两个很小的资源管理对象。WinsockSession 管理初始化引用,SocketOwner 管理 Socket;禁用复制,避免两个对象错误地关闭同一个资源。异常用于把错误交回 main 的统一处理位置,核心收发流程仍然是顺序执行。
退出条件包括 /quit、输入结束和通信错误。普通空行会发送一个零长度 UDP 数据报,方便验证 recvfrom == 0 的语义。输入长度按字节计算,超过 1024 字节时跳过发送;/quit 是本地控制命令,不会发给服务器。
5.2 Linux UDP 回显服务器
保存为 linux_udp_server.cpp:
cpp
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <charconv>
#include <cstdint>
#include <cstring>
#include <iostream>
#include <stdexcept>
#include <system_error>
std::uint16_t parse_port(const char* text) {
unsigned int value = 0;
const char* end = text + std::strlen(text);
const auto result = std::from_chars(text, end, value);
if (result.ec != std::errc{} || result.ptr != end ||
value == 0 || value > 65535) {
throw std::invalid_argument("Port must be an integer in 1..65535");
}
return static_cast<std::uint16_t>(value);
}
struct SocketOwner {
int value = -1;
SocketOwner() = default;
~SocketOwner() { if (value >= 0) close(value); }
SocketOwner(const SocketOwner&) = delete;
SocketOwner& operator=(const SocketOwner&) = delete;
};
int main(int argc, char* argv[]) {
if (argc != 2 && argc != 3) {
std::cerr << "Usage: linux_udp_server <port> [bind-ipv4]\n";
return 1;
}
try {
const auto port = parse_port(argv[1]);
const char* bind_ip = argc == 3 ? argv[2] : "0.0.0.0";
SocketOwner sock;
sock.value = socket(AF_INET, SOCK_DGRAM, 0);
if (sock.value < 0) throw std::runtime_error(std::strerror(errno));
sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(port);
if (inet_pton(AF_INET, bind_ip, &local.sin_addr) != 1) {
throw std::invalid_argument("Invalid bind IPv4 address");
}
if (bind(sock.value, reinterpret_cast<const sockaddr*>(&local),
sizeof(local)) < 0) {
throw std::runtime_error(std::strerror(errno));
}
std::cout << "UDP echo on " << bind_ip << ':' << port << std::endl;
char buffer[1024];
while (true) {
sockaddr_in peer{};
socklen_t peer_length = sizeof(peer);
const ssize_t received = recvfrom(sock.value, buffer, sizeof(buffer),
MSG_TRUNC,
reinterpret_cast<sockaddr*>(&peer),
&peer_length);
if (received < 0) {
if (errno == EINTR) continue;
throw std::runtime_error(std::strerror(errno));
}
if (received > static_cast<ssize_t>(sizeof(buffer))) {
std::cerr << "Oversized datagram discarded: " << received << '\n';
continue;
}
char ip[INET_ADDRSTRLEN]{};
if (!inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip))) {
throw std::runtime_error(std::strerror(errno));
}
std::cout << "From " << ip << ':' << ntohs(peer.sin_port)
<< " (" << received << " bytes): ";
std::cout.write(buffer, received);
std::cout << std::endl;
const ssize_t sent = sendto(sock.value, buffer,
static_cast<std::size_t>(received), 0,
reinterpret_cast<const sockaddr*>(&peer),
peer_length);
if (sent < 0) {
std::cerr << "sendto: " << std::strerror(errno) << '\n';
} else if (sent != received) {
std::cerr << "Unexpected datagram send length\n";
}
}
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
服务器默认绑定 0.0.0.0,也可通过第三个命令行参数指定本地 IPv4。它每次接收都重新准备来源地址,并回复同一次接收返回的端点。
代码中的 MSG_TRUNC 是这里使用的 Linux UDP 接收约定 :借助返回的原始数据报长度识别超长消息。长度大于缓冲区时直接丢弃,绝不拿这个大于容量的长度去读取缓冲区。Linux:recv 手册 不要把这段参数原样移植到 Windows 客户端;Winsock 的过长数据报错误在第八节单独说明。
六、编译运行并验证通信闭环
6.1 在 Linux 上启动服务器
进入保存源文件的目录:
bash
g++ -std=c++17 -Wall -Wextra -Wpedantic -O2 linux_udp_server.cpp -o linux_udp_server
./linux_udp_server 8888
启动时应显示:
text
UDP echo on 0.0.0.0:8888
另开一个 Linux 终端确认入口:
bash
ss -lunp
检查 UDP 列表中是否存在 0.0.0.0:8888。-u 选择 UDP,-l 查看监听/绑定入口,-n 使用数字地址与端口,-p 查看关联进程;查看其他用户进程信息可能需要适当权限。
服务器可以长期阻塞在 recvfrom 等待消息,这是正常状态。测试结束后用 Ctrl+C 停止;本文没有额外实现信号驱动的优雅退出,进程终止时操作系统会回收其 Socket。
6.2 在 Windows 上编译客户端
使用 Visual Studio 的 C++ 控制台项目,把源文件加入项目并选择 C++17 或更新标准。链接库已经通过 MSVC 的 #pragma comment 指定,也可以在链接器的附加依赖项中明确填写 Ws2_32.lib。
如果使用 Developer Command Prompt for Visual Studio,可执行:
bat
cl /nologo /EHsc /std:c++17 /W4 /utf-8 windows_udp_client.cpp /Fe:windows_udp_client.exe
普通 PowerShell 中找不到 cl,通常是编译环境尚未加载,不代表 Socket 代码写错。如果安装的是 MinGW-w64,可使用相应工具链:
bash
g++ -std=c++17 -Wall -Wextra -O2 windows_udp_client.cpp -o windows_udp_client.exe -lws2_32
MSVC 的 #pragma comment 已放在条件编译中;使用 MinGW-w64 时通过命令行 -lws2_32 链接。
6.3 填写真正可达的服务器 IP
假设 Linux 在同一局域网中的实际地址是 192.168.1.50,PowerShell 示例为:
powershell
.\windows_udp_client.exe 192.168.1.50 8888
192.168.1.50 只是示例,必须替换为你自己的服务器地址。访问云服务器时使用实际可达的公网 IPv4,并先完成第七节的网络规则检查。
下面是预期输出格式,端口、地址和实际内容以运行结果为准:
text
Target 192.168.1.50:8888 (UDP). Enter /quit to exit. Max 1024 bytes.
Please Enter@ hello
Reply (5 bytes): hello
Please Enter@ /quit
Linux 端对应的日志示意:
text
From 192.168.1.20:53021 (5 bytes): hello
这时的 53021 是客户端来源端口示例,并非固定配置。不同启动、不同主机或经过 NAT,观察到的来源都可能不同。
6.4 怎样判断这次验证成立
依次核对四件事:Linux 进程绑定正确入口;Linux 日志记录本次请求;Windows 获得预期来源的回显;回显长度与内容符合约定。只看客户端 sendto 没报错,还不足以证明完整链路通了。
本文配套 Linux 服务器在本机 WSL 中完成了编译及本地数据报验证,详细记录见 代码验证记录。Windows 客户端在当前环境中未完成编译与实机运行,Windows 到独立 Linux 主机的网络联调也未执行;上面的跨主机日志是预期格式,不是本次运行截图。
七、云服务器的 UDP 端口怎样放行
课程特别提醒要在云平台后台开放服务器端口。排查时要把这句话拆成三个层面:云侧网络策略、Linux 主机防火墙、服务进程绑定。

7.1 云侧规则至少核对这些字段
各平台界面可能使用安全组、云防火墙等名称。不要照搬某个旧界面的按钮位置,先核对规则语义:
| 字段 | 本次测试要核对的值 |
|---|---|
| 方向 | 到 Linux 实例的入站流量 |
| 协议 | UDP |
| 目标端口 | 8888 |
| 来源 | 实际客户端来源 IP/网段,经过 NAT 时通常为出口公网地址 |
| 对象 | 规则确实应用在当前实例或对应网卡上 |
| 动作 | 允许,且未被其他有效规则阻断 |
测试来源固定时,可以按其出口公网 IP 设置 /32 来源范围。不要把 Windows 的 192.168.x.x 内网地址写成云实例看到的公网来源;也不要仅因为端口数字一样,就以为 TCP 规则能放行 UDP。
7.2 Linux 主机防火墙也要允许对应流量
云安全组通过,不意味着 Linux 本机策略也通过。先确认系统实际使用哪一种防火墙管理工具,再检查对应配置。
例如系统已经使用并启用了 UFW,可以先查看:
bash
sudo ufw status verbose
如果需要为当前测试来源增加 UDP 规则,示例为:
bash
# 将 198.51.100.25 替换为实际客户端出口公网 IP。
sudo ufw allow from 198.51.100.25 to any port 8888 proto udp
示例地址 198.51.100.25 仅用于说明。规则语法可对照 Ubuntu:UFW 手册。使用 firewalld 或直接维护 nftables 的系统,应检查其实际规则,不要同时套用多套管理命令,也不必为了本例停掉整个防火墙。
7.3 最后看进程到底绑定在哪里
如果服务器只绑定 127.0.0.1:8888,即便云规则和主机规则均允许,外部数据也不能投递到这个回环入口。本文默认 0.0.0.0:8888,也可以绑定实例实际拥有的某个具体本地 IP。
如果只是同一局域网的两台机器通信,就按实际网络检查主机防火墙和路由;无需寻找并不存在的云安全组。若虚拟机 NAT 模式要求转发外部入站,还需确认配置的是 UDP 端口映射。
八、看懂接收结果与示例的边界

8.1 recvfrom 返回 0,不等于 UDP 断开连接
UDP 可以发送零长度数据报。Winsock 收到这种数据报时,recvfrom 返回 0;它仍是一次成功接收。Microsoft:recvfrom 数据报语义
因此本文先检查 received == SOCKET_ERROR,成功分支处理所有非负长度。不要从 TCP 的"读取 0 字节可能表示对端有序关闭"直接推导出 UDP 的结论。
本客户端按空行会发空包,服务器原样回显后应显示 Reply (0 bytes):。课程里的 message.empty() 跳过空行是交互选择,不是 UDP 禁止空消息。
8.2 超时只表示没有及时观察到回应
请求可能没有到达,服务器也可能已经处理但响应丢失。约 3 秒的超时是本篇为实验设置的等待策略,不是 UDP 协议规定的交付期限,更不能据此认定远端业务未执行。
本例超时后退出。如果以后扩展为自动重试,需要同时考虑请求编号、重复处理和幂等性。若让同一个 Socket 在超时后继续发送新请求,还可能收到旧请求的迟到响应;单靠"来自同一服务器"不足以匹配请求。
8.3 缓冲区装不下数据报时怎么办
Winsock 在数据报超过缓冲容量时会报告 WSAEMSGSIZE;对于 UDP,超出部分丢失,不能下一次继续读取剩下的内容。本客户端丢弃这次过长响应并结束运行。Microsoft:recvfrom 的过长消息处理
服务器则按 Linux 的 MSG_TRUNC 返回长度检查超长请求并丢弃。两端都采用 1024 字节的实验上限,它是应用约定,并不是 UDP 最大有效载荷的协议上限。
中文消息要注意 std::string::size() 数的是字节;一个文字占几个字节与编码有关。回显成功却显示乱码时,再检查终端编码和应用编码是否一致。
8.4 这个实验验证了什么
这套程序用于观察跨平台 UDP 的请求、响应与端点信息。它没有实现可靠传输、业务请求编号、重传策略或认证机制。一次回显成功,只说明这次实验的数据经过了完整路径;不能推导出以后每个数据报都会到达。
如果后续要发送结构化消息,还需约定字段顺序、整数编码和长度。不要把 Windows 中一个 C++ 结构体的内存原样发送,就假设 Linux 上同名结构体的布局必然相同。
九、通信失败时按什么顺序排查
最容易浪费时间的方式,是看到"没有回显"就反复改发送代码。更有效的方法是判断数据走到了哪里。

9.1 先检查编译和初始化
| 现象 | 优先检查 |
|---|---|
winsock2.h 找不到 |
Windows SDK 与 C++ 编译工具是否安装并正确配置 |
WSAStartup 等符号无法链接 |
MSVC 的 Ws2_32.lib 或 MinGW-w64 的 -lws2_32 |
| Winsock 类型重复定义 | windows.h 与 Winsock 头文件包含顺序 |
WSAStartup 返回非零 |
直接记录其返回错误码,不进入后续通信 |
socket 返回无效句柄 |
立即记录 WSAGetLastError() |
先解决这些本地问题,再检查远端网络。没有成功建立本地 Socket 资源,修改云安全组不会产生帮助。
9.2 Linux 有没有正确的 UDP 入口
使用 ss -lunp 核对协议、端口和绑定地址。启动服务器出现 Address already in use 时,先确认是否已有进程占用相应入口,不要把另一个未关闭的测试服务器误当成新程序。
如果只绑定回环地址或绑定到不属于本机的 IP,需要修正绑定参数。Windows 配置的目标端口也必须与服务器实际绑定端口一致。
9.3 请求有没有到达 Linux
在 Linux 上对测试端口抓包:
bash
sudo tcpdump -ni any 'udp port 8888 or icmp'
-n 避免名字解析,-i any 观察 Linux 上的各接口。过滤器同时保留 ICMP,便于观察端口不可达等网络反馈。
如果看不到请求,优先核对客户端目标 IP、实际来源、路由和前面的入站规则。如果能看到请求,但应用没有日志,继续检查目的地址是否匹配绑定入口、主机防火墙是否阻断,以及数据报是否超出本篇应用容量。
抓包只证明数据到达抓包位置。 它不能替代应用日志,也不能单独证明 recvfrom 已经返回。
9.4 Linux 是否发出了正确响应
若应用日志已记录请求,但 Windows 未收到回显,查看服务器 sendto 有没有错误,并在抓包中核对回复的目的 IP、端口是否等于收到的来源端点。
如果 Linux 已发出响应,继续检查出站策略、回程路由、NAT 和客户端侧的实际防火墙策略。某些网络错误会通过 ICMP 回传;Windows 的 UDP recvfrom 也可能报告 WSAECONNRESET,例如此前发送触发了端口不可达,不要把它理解为 TCP 连接断线。Microsoft:recvfrom 错误码
9.5 ping 和 TCP 端口测试为什么不够
ping 观察的是 ICMP 可达性;ICMP 的允许与否不等于 8888/udp 的允许与否。PowerShell 的 Test-NetConnection -Port 8888 检查 TCP 端口,也不能用来判断本篇 UDP 服务是否工作。Microsoft:Test-NetConnection
本篇直接使用 UDP 客户端、Linux 日志与抓包形成验证证据:发出了什么、服务器收到了什么、又回了什么。它比单看"主机能 ping 通"更贴近要解决的问题。
总结
Windows 与 Linux 可以通过 UDP 通信,因为双方遵守相同的网络协议和消息约定。Windows 端增加的工作主要在本地接口层:链接 Winsock 库、初始化 Winsock、使用正确的句柄和错误处理方式,并在结束时清理资源。
这次加餐可以收拢为五个要点:
- 服务器固定
8888/udp,客户端通常使用系统选择的临时端口;服务器按收包来源回复。 WSAStartup成功后进入通信,先closesocket,再WSACleanup。- Windows 使用
SOCKET、INVALID_SOCKET、SOCKET_ERROR和WSAGetLastError,不要直接照搬 Linux 类型。 sendto成功、Linux 收到请求、Windows 获得回显,是三个不同的观察点。- 没有回显时,按绑定入口、请求到达、应用处理和响应回程逐层排查。
把这条链路跑通,后面再学习 Windows 与 Linux 之间的 TCP 验证,就能更清楚地分辨:哪些是平台 API 的差异,哪些是传输协议本身带来的差异。