一、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;
}
要点:
WSAStartup必须在任何socket之前 ,否则socket可能返回INVALID_SOCKET,错误码WSANOTINITIALISED,10093。MAKEWORD(2,2)请求 Winsock 2.2。WSACleanup与WSAStartup成对。这里在main末尾统一清理,适合单进程单角色小 demo。scanf("%d", &role); getchar();中getchar()用来吃掉回车换行,避免后面fgets第一次读到空行。- 但注意:
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 有两个问题:
- 返回值
INADDR_NONE既可能表示255.255.255.255,也可能表示格式错误,无法区分; - 不支持 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 |
输入/输出 | 输入缓冲区大小,输出实际地址结构大小 |
关键点:
recvfrom是阻塞的:没有数据报就一直等。- 一次
recvfrom取一个数据报。UDP 保留消息边界,不像 TCP 字节流会粘包。 cli被填入来源地址 ,服务器靠它知道回复给谁。sendto又用同一个cli和cliLen把回复发回去。cliLen每轮循环都要重置为sizeof(cli)。如果忘记重置,下一轮recvfrom可能因为长度被改小而出错。buf[n] = '\0'是文本处理需要;UDP 本身不补字符串结束符。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);
细节:
fgets读一行,保留换行;buf[strcspn(buf, "\r\n")] = '\0'去掉\r和\n。- 输入
q退出。 sendto每次指定目标srv,发送strlen(buf)字节,不含'\0'。- 发送后阻塞等待
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。
recvfrom的&srv和srvLen是输出参数。调用后srv会被回复来源地址 覆盖。正常情况下就是服务器地址;但 UDP 未connect时,recvfrom可以接收任何来源的数据报,存在伪造/欺骗风险。若要限制只收服务器回复,可以:- 客户端先
connect(sock, (sockaddr*)&srv, sizeof(srv)),之后用send/recv; - 或
recvfrom后比较来源地址。
- 客户端先
n > 0才打印。n == 0是零长度数据报,SOCKET_ERROR是错误,本代码都静默忽略。- 客户端
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, 来源地址输出, 地址长度输入/输出)