Windows 下 UDP 套接字编程指南


一、UDP 代码的整体结构

cpp 复制代码
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#define _CRT_SECURE_NO_WARNINGS

#include <winsock2.h>
#include <cstdio>
#include <cstring>
#pragma comment(lib, "ws2_32.lib")

const int PORT = 8888;
const char* SERVER_IP = "127.0.0.1";

void runServer();
void runClient();

int main() {
    WSAStartup(MAKEWORD(2, 2), &wsa);
    选择角色;
    role == 1 ? runServer() : runClient();
    WSACleanup();
}

生命周期与 TCP 对照:

项目 上期 TCP 示例 本 UDP 示例
套接字类型 SOCK_STREAM SOCK_DGRAM
协议 IPPROTO_TCP 0,由 SOCK_DGRAM + AF_INET 推断为 UDP
服务器流程 socket → bind → listen → accept → recv/send socket → bind → recvfrom/sendto
客户端流程 socket → connect → send/recv socket → sendto/recvfrom
连接 有三次握手、四次挥手 无连接,每个数据报自带目标/来源地址
接收 recv 字节流 recvfrom 一次收一个数据报,并拿到来源地址
发送 send 已连接套接字 sendto 每次指定目标地址
服务器并发模型 accept 返回新套接字 一个 UDP 套接字服务所有客户端,靠来源地址区分
--- --- ---
接口 典型场景 说明
send/recv TCP、已 connect() 的 UDP 套接字已绑定对端,读写不传地址
sendto/recvfrom 未连接 UDP、原始套接字等 发送指定目的地址,接收获取源地址

UDP 全景:

text 复制代码
服务器:WSAStartup → socket(SOCK_DGRAM) → bind → recvfrom/sendto 循环 → closesocket → WSACleanup

客户端:WSAStartup → socket(SOCK_DGRAM) → sendto/recvfrom 循环 → closesocket → WSACleanup

没有 listen、accept、connect,也没有"连接套接字"和"监听套接字"的区分。服务器的 sock 既收又发;客户端的 sock 也既发又收。


二、头文件、宏与链接

2.1 两个宏

cpp 复制代码
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#define _CRT_SECURE_NO_WARNINGS
  • _WINSOCK_DEPRECATED_NO_WARNINGS:MSVC 下关闭 inet_ntoa、inet_addr 等"已弃用"警告。本代码用了这两个旧 API,所以需要它。
  • _CRT_SECURE_NO_WARNINGS:关闭 CRT 安全警告,例如 scanf 等。
  • 这两个宏必须写在 #include 之前,否则头文件已经处理过,宏不生效。

2.2 头文件

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

这里只包含 winsock2.h,没有包含 ws2tcpip.h。因为代码用的是旧接口:

  • inet_addr:字符串 IPv4 → 二进制,旧接口;
  • inet_ntoa:二进制 IPv4 → 字符串,旧接口;
  • 没有用 inet_pton/inet_ntop,所以不需要 ws2tcpip.h。

如果改成现代写法:

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

inet_pton(AF_INET, SERVER_IP, &srv.sin_addr);
inet_ntop(AF_INET, &cli.sin_addr, ipbuf, sizeof(ipbuf));

就需要加 ws2tcpip.h。

本代码没有定义 WIN32_LEAN_AND_MEAN,但也没有直接包含 windows.h,所以暂时不会触发旧 winsock.h 冲突。若后续加入 windows.h:

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

2.3 链接

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

MSVC 自动链接 Winsock 导入库。MinGW/GCC 不支持这行,需要命令行加 -lws2_32。

编译命令:

cmd 复制代码
cl /EHsc /W4 udp_echo.cpp /link ws2_32.lib
cmd 复制代码
g++ -Wall -Wextra -o udp_echo.exe udp_echo.cpp -lws2_32

三、Winsock 初始化:本代码放在 main 统一做

cpp 复制代码
int main() {
    WSADATA wsa;
    if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) {
        printf("WSAStartup 失败\n");
        return 1;
    }

    printf("1 - 服务器\n2 - 客户端\n请选择: ");
    int role = 2;
    scanf("%d", &role);
    getchar();

    if (role == 1) runServer();
    else           runClient();

    WSACleanup();
    return 0;
}

要点:

  1. WSAStartup 必须在任何 socket 之前 ,否则 socket 可能返回 INVALID_SOCKET,错误码 WSANOTINITIALISED,10093。
  2. MAKEWORD(2,2) 请求 Winsock 2.2。
  3. WSACleanup 与 WSAStartup 成对。这里在 main 末尾统一清理,适合单进程单角色小 demo。
  4. scanf("%d", &role); getchar(); 中 getchar() 用来吃掉回车换行,避免后面 fgets 第一次读到空行。
  5. 但注意:runServer() 是死循环,正常路径永远走不到 closesocket(sock) 和 WSACleanup()。按 Ctrl+C 结束进程时,操作系统会回收套接字和 Winsock 资源。

四、字节序与地址结构:和 TCP 完全一致

UDP 也使用 sockaddr_in,端口和地址同样要网络字节序。

服务器:

cpp 复制代码
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
addr.sin_addr.s_addr = INADDR_ANY;

客户端:

cpp 复制代码
sockaddr_in srv{};
srv.sin_family = AF_INET;
srv.sin_port = htons(PORT);
srv.sin_addr.s_addr = inet_addr(SERVER_IP);
字段 赋值 字节序
sin_family AF_INET 主机序
sin_port htons(PORT) 网络序
sin_addr.s_addr INADDR_ANY 或 inet_addr() 结果 网络序
sin_zero[8] {} 零初始化 ---

INADDR_ANY 表示服务器监听本机所有网卡。inet_addr("127.0.0.1") 返回网络序 IPv4 地址。打印客户端端口时要 ntohs(cli.sin_port) 转回主机序。

但 inet_addr 有两个问题:

  1. 返回值 INADDR_NONE 既可能表示 255.255.255.255,也可能表示格式错误,无法区分;
  2. 不支持 IPv6。

可改用:

cpp 复制代码
inet_pton(AF_INET, SERVER_IP, &srv.sin_addr);

并检查返回值:1 成功,0 格式非法,-1 系统错误。

同样,inet_ntoa(cli.sin_addr) 返回的是静态缓冲区 ,多线程不安全;并且一次 printf 中不要多次调用它。本代码只调用一次,问题不大,可改为 inet_ntop。


五、UDP 与 TCP 的核心差异:无连接、数据报

TCP 是字节流 :recv 一次不保证一个完整消息,存在粘包/半包。UDP 是数据报 :一次 sendto 对应一个数据报,recvfrom 一次取一个数据报,保留消息边界。

特性 TCP UDP
连接 面向连接 无连接
可靠性 可靠、有序、自动重传 不保证到达、不保证顺序、可能重复
消息边界 无,字节流 有,一次一数据报
服务器 API listen/accept 无
客户端 API connect 通常无,直接 sendto
发送 send sendto
接收 recv recvfrom
服务器区分客户端 accept 返回新套接字 每个数据报的来源地址

UDP 的"无连接"不等于不能 connect。客户端也可以 connect 一个 UDP 套接字,之后用 send/recv,内核会固定对端并过滤来源;但本代码没有这样做。


六、服务器端逐段详解

6.1 创建 UDP 套接字

cpp 复制代码
SOCKET sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock == INVALID_SOCKET) { printf("socket 失败\n"); return; }
参数 值 含义
af AF_INET IPv4
type SOCK_DGRAM 数据报套接字,对应 UDP
protocol 0 让系统根据 AF_INET + SOCK_DGRAM 推断为 UDP

也可以显式写 IPPROTO_UDP,可读性更好:

cpp 复制代码
socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);

失败返回 INVALID_SOCKET。这里只打印"socket 失败",没有打印 WSAGetLastError()。

6.2 bind:绑定本地端口

cpp 复制代码
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
addr.sin_addr.s_addr = INADDR_ANY;

if (bind(sock, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) {
    printf("bind 失败: %d\n", WSAGetLastError());
    closesocket(sock);
    return;
}

UDP 服务器也必须 bind,否则系统不知道它该在哪个端口收数据报。INADDR_ANY 表示所有网卡。htons(PORT) 不能漏。

失败常见原因:

错误码 常量 原因
10048 WSAEADDRINUSE 端口已被占用
10013 WSAEACCES 权限不足,低端口在部分系统需要管理员
10049 WSAEADDRNOTAVAIL 指定地址不可用

6.3 接收循环:recvfrom

cpp 复制代码
char buf[1024];
sockaddr_in cli{};
int cliLen = sizeof(cli);

while (true) {
    cliLen = sizeof(cli);
    int n = recvfrom(sock, buf, sizeof(buf) - 1, 0,
                     (sockaddr*)&cli, &cliLen);
    if (n <= 0) continue;

    buf[n] = '\0';
    printf("收到 [%s:%d]: %s\n",
           inet_ntoa(cli.sin_addr),
           ntohs(cli.sin_port),
           buf);

    const char* reply = "hello from server";
    sendto(sock, reply, (int)strlen(reply), 0,
           (sockaddr*)&cli, cliLen);
}

这是 UDP 服务器核心。

recvfrom 参数:

参数 方向 说明
sock 输入 已绑定端口的 UDP 套接字
buf 输出 接收缓冲区
sizeof(buf)-1 输入 最多收多少字节,留一个给 '\0'
0 输入 flags
(sockaddr*)&cli 输出 内核填入发送方地址
&cliLen 输入/输出 输入缓冲区大小,输出实际地址结构大小

关键点:

  1. recvfrom 是阻塞的:没有数据报就一直等。
  2. 一次 recvfrom 取一个数据报。UDP 保留消息边界,不像 TCP 字节流会粘包。
  3. cli 被填入来源地址 ,服务器靠它知道回复给谁。sendto 又用同一个 cli 和 cliLen 把回复发回去。
  4. cliLen 每轮循环都要重置为 sizeof(cli)。如果忘记重置,下一轮 recvfrom 可能因为长度被改小而出错。
  5. buf[n] = '\0' 是文本处理需要;UDP 本身不补字符串结束符。
  6. sizeof(buf)-1 留一个字节给 '\0'。如果数据报大于缓冲区,Windows 下 recvfrom 可能返回 SOCKET_ERROR,错误码 WSAEMSGSIZE,10040。代码 n <= 0 会直接 continue,把错误和零长度数据报都忽略了。

recvfrom 返回值语义:

返回值 含义 本代码处理
> 0 收到 n 字节 正常处理
0 合法的零长度 UDP 数据报 被 n <= 0 忽略
SOCKET_ERROR 出错 被忽略,可能忙循环

按返回值区分:

cpp 复制代码
if (n == SOCKET_ERROR) {
    int err = WSAGetLastError();
    if (err == WSAEMSGSIZE) { /* 数据报被截断 */ }
    else { /* 记录错误,必要时退出 */ }
    continue;
}
if (n == 0) { /* 零长度数据报,可选择处理 */ }

6.4 sendto:向来源地址回复

cpp 复制代码
const char* reply = "hello from server";
sendto(sock, reply, (int)strlen(reply), 0,
       (sockaddr*)&cli, cliLen);

sendto 参数:

参数 说明
sock UDP 套接字
reply 数据缓冲区
strlen(reply) 发送长度,不含 '\0'
0 flags
(sockaddr*)&cli 目标地址,这里就是刚才的来源地址
cliLen 目标地址结构长度

sendto 成功返回实际交给内核的字节数,失败返回 SOCKET_ERROR。但注意:sendto 成功只表示数据报交给了本地内核,不保证到达对端 。UDP 没有重传、没有 ACK、没有顺序保证。

flags 是 sendto 调用时的"附加选项" ,一个位掩码 :每一位代表一种特殊行为,可以用 | 组合

常见 flags 含义

flags 作用 备注
0 默认行为 最常用
MSG_DONTWAIT 本次发送非阻塞 Linux 常用;即使 socket 是阻塞的,这次也不等
MSG_NOSIGNAL 出错时不产生 SIGPIPE 信号 Linux;主要对 TCP/流式套接字有意义,UDP 基本无关
MSG_DONTROUTE 不经过正常路由,直接发本地网络 特殊场景
MSG_OOB 发送带外数据 TCP 用,UDP 通常不支持
MSG_MORE 告诉内核"后面还有数据",可延迟/合并发送 Linux 上 UDP 也有特殊语义,普通代码很少用
MSG_CONFIRM 告诉内核邻居地址仍然有效 Linux,底层网络细节

多个标志可以组合:

cpp 复制代码
sendto(sock, buf, len, MSG_DONTWAIT | MSG_NOSIGNAL, ...);

为什么普通 UDP 代码一般写 0

因为绝大多数 UDP 发送就是:

把这个数据报发给这个地址,能发就发,发不出去就返回错误。

不需要非阻塞、带外、路由控制这些特殊行为,所以写 0 最简单、可移植。

注意:flags 和 socket 本身的非阻塞是两回事

  • socket(..., SOCK_DGRAM, 0) 创建的是普通阻塞 socket。
  • 如果 socket 设了 O_NONBLOCK,那所有发送默认非阻塞。
  • MSG_DONTWAIT 是只让本次调用非阻塞,不改 socket 状态。

例如:

cpp 复制代码
sendto(sock, buf, len, MSG_DONTWAIT, ...);

如果发送缓冲区满了,立刻返回 -1,错误码 EAGAIN/EWOULDBLOCK,而不是卡住等。

七、客户端逐段详解

7.1 创建 UDP 套接字

cpp 复制代码
SOCKET sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock == INVALID_SOCKET) { printf("socket 失败\n"); return; }

客户端不需要 bind 。第一次 sendto 时,内核会自动为它分配一个临时端口(ephemeral port),服务器回复时就发到这个临时端口。

7.2 填写服务器地址

cpp 复制代码
sockaddr_in srv{};
srv.sin_family = AF_INET;
srv.sin_port = htons(PORT);
srv.sin_addr.s_addr = inet_addr(SERVER_IP);

SERVER_IP 默认 127.0.0.1。换机器测试时改成服务器实际 IP。

inet_addr 没有检查返回值。如果 SERVER_IP 写错,inet_addr 返回 INADDR_NONE,后续 sendto 可能失败。现代写法:

cpp 复制代码
if (inet_pton(AF_INET, SERVER_IP, &srv.sin_addr) != 1) {
    printf("invalid ip: %s\n", SERVER_IP);
    closesocket(sock);
    return;
}

7.3 输入、发送、接收回复

cpp 复制代码
char buf[1024];
while (true) {
    printf("输入要发送的消息(q 退出): ");
    if (!fgets(buf, sizeof(buf), stdin)) break;
    buf[strcspn(buf, "\r\n")] = '\0';
    if (strcmp(buf, "q") == 0) break;

    sendto(sock, buf, (int)strlen(buf), 0,
           (sockaddr*)&srv, sizeof(srv));

    int srvLen = sizeof(srv);
    int n = recvfrom(sock, buf, sizeof(buf) - 1, 0,
                     (sockaddr*)&srv, &srvLen);
    if (n > 0) {
        buf[n] = '\0';
        printf("回复: %s\n", buf);
    }
}
closesocket(sock);

细节:

  1. fgets 读一行,保留换行;buf[strcspn(buf, "\r\n")] = '\0' 去掉 \r 和 \n。
  2. 输入 q 退出。
  3. sendto 每次指定目标 srv,发送 strlen(buf) 字节,不含 '\0'。
  4. 发送后阻塞等待 recvfrom。如果服务器没开、回复丢包、网络不通,客户端会一直卡在 recvfrom。可设置接收超时:
cpp 复制代码
int timeout_ms = 5000;
setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO,
           (const char*)&timeout_ms, sizeof(timeout_ms));

超时后 recvfrom 返回 SOCKET_ERROR,WSAGetLastError() 为 WSAETIMEDOUT,10060。

  1. recvfrom 的 &srv 和 srvLen 是输出参数。调用后 srv 会被回复来源地址 覆盖。正常情况下就是服务器地址;但 UDP 未 connect 时,recvfrom 可以接收任何来源的数据报,存在伪造/欺骗风险。若要限制只收服务器回复,可以:
    • 客户端先 connect(sock, (sockaddr*)&srv, sizeof(srv)),之后用 send/recv;
    • 或 recvfrom 后比较来源地址。
  2. n > 0 才打印。n == 0 是零长度数据报,SOCKET_ERROR 是错误,本代码都静默忽略。
  3. 客户端 closesocket(sock) 可达,然后 main 执行 WSACleanup(),资源清理正确。

八、UDP 关键 API 速查

API 作用 本代码 成功 失败
WSAStartup 初始化 Winsock main 0 非 0
WSACleanup 清理 Winsock main 0 SOCKET_ERROR
socket 创建套接字 服务器/客户端 SOCKET INVALID_SOCKET
bind 绑定本地 IP:端口 仅服务器 0 SOCKET_ERROR
sendto 向指定地址发数据报 两者 发送字节数 SOCKET_ERROR
recvfrom 收数据报并取来源地址 两者 字节数;0 零长度数据报 SOCKET_ERROR
closesocket 关闭套接字 两者 0 SOCKET_ERROR
WSAGetLastError 取错误码 部分使用 --- ---
htons/ntohs 端口字节序转换 两者 --- ---
inet_addr 字符串 IPv4 → 网络序 客户端 网络序地址 INADDR_NONE,有歧义
inet_ntoa 网络序 IPv4 → 字符串 服务器打印 静态字符串 NULL,线程不安全

sendto/recvfrom 和 TCP 博客里的 send/recv 对应关系:

text 复制代码
TCP:  send(已连接套接字, buf, len, 0)
UDP:  sendto(套接字, buf, len, 0, 目标地址, 地址长度)

TCP:  recv(已连接套接字, buf, len, 0)
UDP:  recvfrom(套接字, buf, len, 0, 来源地址输出, 地址长度输入/输出)

相关推荐
日常筹谋记7 小时前
数据中心逆变设备运维|通讯对接、动环接入与多系统联动方案科普
运维·网络
学习论之费曼学习法7 小时前
无人值守AI内容矩阵实战(10):Windows 上让 Python 进程 7×24 活着
人工智能·windows·python·矩阵·powershell·进程守护
上海研博数据7 小时前
HY-K 太阳能控制器协议|水利物联太阳能供电系统接入指南
网络
zerwave7 小时前
从慢订阅者到发布者:LiteBus 如何用 Unix Socket 背压保护可靠 Topic
网络·机器人·unix
piaoyiren8 小时前
Python FTP 服务端项目详细功能文档
网络·数据库·python
半杯咖啡半行码8 小时前
QML进阶实战(二):异步网络、性能优化与工程化落地
网络·qt·性能优化·qml
少年的范儿8 小时前
Windows 安装 MySQL 8.4 教程(ZIP 解压版):配置 my.ini、初始化数据库与启动服务
数据库·windows·mysql
悟天特斯8 小时前
安防一体化:从“各管各的“到“全局联动“
java·大数据·网络·人工智能·物联网
胖纸不争8 小时前
EnergyStarZ:让 Windows 后台进程给前台让路的能效管理器(v1.2 发布)
windows·性能优化·c#·win32·ef core