提到 UDP 协议,很多做 C++ 开发的同学习惯性先皱眉,“不可靠”“会丢包”“没顺序”,这三个标签确实没错,但关键不是协议本身,而是它用在什么场景。我之前接了一个设备联调需求,两台设备之间要建立一个轻量的通信链路,实时交换状态数据,数据量不大,但对延迟特别敏感,偶尔丢一两条完全可接受。这种条件下 TCP 那套握手、重传、拥塞控制反而成了负担,于是直接选 UDP 落地,用 C++ 把客户端与服务端之间的通信完整实现了一遍。这篇文章不打算只甩代码,而是把从协议选型、环境配置、API 分析、完整实现到联调排错的全过程整理成文,每个关键点都解释“为什么这么做”,坑也一并标出来,方便你直接参考复现。适合刚接触 socket 编程的初学者,也适合需要快速搭一套 UDP 通信 Demo 做联调验证的开发者。
1. UDP协议核心概念与通信方案设计
1.1 为什么这个项目选中UDP而不是TCP
很多教材会把 TCP 比喻成“打电话”,把 UDP 比喻成“寄明信片”,这个类比挺贴切。TCP 建立连接时要三次握手,传输过程有确认、重传、排序、流量控制,数据丢了会补,乱了会排,整体非常可靠;UDP 则完全不同,它不建立连接,数据打包成数据报直接扔出去,能不能到、什么顺序到、会不会重复,协议本身一概不管。
这种“不管”在不少场景里恰恰是优点。我做的这个需求里,通信双方平均每秒要交换几十条状态消息,每条消息只有几十字节,如果走 TCP,握手和确认开销会占掉相当比例的有效带宽,而 UDP 的协议头只有 8 字节,几乎没有额外负担。更关键的是,设备端对数据新鲜度有要求,旧数据比丢数据更可怕——TCP 重传机制如果遇到网络抖动,会把旧包重新送过来,反而干扰状态判断;UDP 则天然“只发最新”,丢掉的旧数据不会回来,应用层拿到的始终是最新的当前状态。
| 对比维度 | TCP | UDP |
|---|---|---|
| 连接状态 | 面向连接,需三次握手 | 无连接,直接发送 |
| 可靠性 | 确认重传、有序到达 | 不保证到达,不保证顺序 |
| 协议头 | 20字节起步 | 固定8字节 |
| 传输边界 | 字节流,需自行分包 | 保留数据报边界 |
| 实时性 | 高延迟场景受限 | 延迟低,适合实时通信 |
| 典型场景 | 文件传输、网页、邮件 | 音视频、游戏、DNS、设备状态同步 |
还有一个很实际的考量是客户端代码的复杂度。TCP 客户端要处理 connect 状态、断线重连、半包粘包这些逻辑,服务端还要管理连接列表;UDP 客户端发一个包就是一次 sendto,服务端一次 recvfrom 就能拿到数据来源地址,整个链路的代码量明显更小。对于联调工具这种“快速验证、快速交付”的需求,方案简单本身就是优势。
1.2 通信模型与数据流转拆解
UDP 的通信模型是典型的“无连接客户端-服务端”结构。服务端在固定端口上监听,谁发数据过来都能收到,收到之后可以从数据报里提取发送方的 IP 和端口,直接回包。客户端不需要提前和服务端建立任何关系,只要知道服务端的 IP 和端口,就能随时发包。
整个数据流可以拆成下面几步:
服务端 客户端 | | | socket() 创建套接字 | socket() 创建套接字 | bind() 绑定到固定端口 | | | | sendto() 发数据给服务端 | <—— 数据报 —— | | | | | | recvfrom() 收到客户端数据 | | | 解析发送方地址 | | | sendto() 向该地址回数据 | | | —— 数据报 ——> | | | | recvfrom() 接收服务端回复这里有个特别容易踩的认知误区:UDP 服务端没有 accept()。TCP 服务端需要 listen 加 accept 循环去接连接,UDP 服务端创建 socket 并 bind 之后就绪了,recvfrom 会一直阻塞等待任何客户端发来的数据报。也正因为没有连接的概念,同一个服务端 socket 可以同时和任意多个客户端通信,每个数据报里都自带来源地址,服务端根本不需要维护连接列表。
我设计的方案里,服务端绑定本机 8888 端口,监听的 IP 使用 INADDR_ANY,表示接收本机所有网卡上的 UDP 数据,这样做的好处是客户端不管是访问回环地址还是局域网 IP,数据都能到达服务端。客户端不需要绑定特定端口,操作系统会自动分配一个临时端口,只要拿到服务端的地址和端口就能主动发包。
2. 开发环境准备与Socket API深度解析
2.1 双平台环境配置与头文件差异
UDP socket 编程在不同操作系统上的 API 非常接近,核心函数都一样,但 Windows 和 Linux 之间有几个细节差异,如果不处理,代码很难直接跨平台编译。
Windows 平台(Visual Studio 或 MinGW)需要引入 winsock2.h,并且在任何 socket 操作之前调用 WSAStartup 初始化 Winsock 库,最后还要 WSACleanup 做清理。socket 描述符类型是 SOCKET,本质上是个无符号整数,错误返回 INVALID_SOCKET。关闭套接字用的是 closesocket。编译时还需要链接 ws2_32.lib,Visual Studio 里可以用 #pragma comment(lib, "ws2_32.lib") 一行搞定,MinGW 则要在编译命令里加 -lws2_32。
// Windows 环境头文件 #include <winsock2.h> #pragma comment(lib, "ws2_32.lib")Linux 平台则干净很多,引入 sys/socket.h、netinet/in.h、arpa/inet.h 即可,不需要初始化库,socket 描述符就是普通的 int,失败返回 -1,关闭用 close 函数。
// Linux 环境头文件 #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h>我常用的做法是写一层简单的宏适配。比如项目里统一用 SOCKET 类型保存套接字,Windows 下它就是原生的 SOCKET,Linux 下用 typedef int SOCKET 去兼容;关闭操作封装一个 closeSocket 函数,内部判断平台分别调用 closesocket 或 close。这样主体逻辑能保持完全一致,只在底层做隔离,联调的时候省了很多无谓的分支判断。
2.2 核心API逐个解剖:socket、bind、sendto、recvfrom
先看创建套接字这一步。socket 函数有三个参数,第一个是地址族,IPv4 场景固定 AF_INET;第二个是套接字类型,UDP 用 SOCK_DGRAM,这是“数据报套接字”,和 TCP 用的 SOCK_STREAM 刚好形成对照;第三个协议传 IPPROTO_UDP 就行。如果你写 0,系统会根据前两个参数自动推导,效果一样,但显式传更好读。
SOCKET sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);然后是重点中的重点,网络字节序。现代 PC 大多是小端字节序,也就是低字节存在低地址,而网络传输规定统一使用大端。如果你直接拿本机内存里的端口号填进 sockaddr_in,另一个机器解析出来会是一个完全不同的端口。所以端口和 IP 在填入结构体之前必须用 htons 和 htonl 做转换,收到数据后用 ntohs、ntohl 转回来。我一开始写 demo 的时候偷懒没转换,结果同一台机器上客户端和服务端居然收不到包,排查半天才发现端口从 8888 变成了 62708,全是字节序在作怪。这个坑新手几乎必踩。
bind 函数的第二个参数是一个 sockaddr 指针,但结构体类型本身已经过时了,现代写法都是先把数据填进 sockaddr_in,再强转成 sockaddr。sin_family 填 AF_INET,sin_port 用 htons 转换后的端口,sin_addr 用 htonl(INADDR_ANY) 表示监听所有网卡。
sendto 和 recvfrom 是 UDP 通信的核心。sendto 除了套接字和数据缓冲区之外,还有一个 to 参数,指向目标地址结构体,这就意味着客户端不需要 connect 就能直接把数据发到任意地址;recvfrom 则相反,它在接收数据的同时会把发送方的地址填进 from 参数,服务端拿到这个地址就能精准回包。这里要注意 sendto 的最后一个参数是地址结构体的实际长度,recvfrom 的对应参数则是一个指向 int 的指针,长度既是输入也是输出,调用前必须初始化成 sizeof,否则某些实现会拒绝写入数据。
我个人的经验是,这类 API 的参数顺序和类型特别容易在写代码时搞混,建议每次调用前对着签名核对一遍,宁可慢一点也别靠记忆硬写,排一个参数错误的时间够写完整个模块了。
3. 服务端与客户端完整代码实现
3.1 服务端完整代码与逐段拆解
下面给出的是我在 Windows 环境跑通的完整服务端代码,用 C++ 编写,逻辑简洁,适合直接复制改造成自己的工具。
#include <iostream> #include <cstring> #include <winsock2.h> #pragma comment(lib, "ws2_32.lib") int main() { // 1. 初始化 Winsock WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { std::cerr << "WSAStartup failed" << std::endl; return -1; } // 2. 创建 UDP 套接字 SOCKET serverSocket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (serverSocket == INVALID_SOCKET) { std::cerr << "socket failed, error: " << WSAGetLastError() << std::endl; WSACleanup(); return -1; } // 3. 绑定本机 8888 端口 sockaddr_in serverAddr{}; serverAddr.sin_family = AF_INET; serverAddr.sin_port = htons(8888); serverAddr.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(serverSocket, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) { std::cerr << "bind failed, error: " << WSAGetLastError() << std::endl; closesocket(serverSocket); WSACleanup(); return -1; } std::cout << "UDP Server is running on port 8888" << std::endl; // 4. 循环接收数据并回复 char recvBuffer[1024]; sockaddr_in clientAddr{}; int clientAddrLen = sizeof(clientAddr); while (true) { int recvLen = recvfrom(serverSocket, recvBuffer, sizeof(recvBuffer) - 1, 0, (sockaddr*)&clientAddr, &clientAddrLen); if (recvLen == SOCKET_ERROR) { std::cerr << "recvfrom failed, error: " << WSAGetLastError() << std::endl; break; } recvBuffer[recvLen] = '\0'; std::cout << "recv from " << inet_ntoa(clientAddr.sin_addr) << ":" << ntohs(clientAddr.sin_port) << " -> " << recvBuffer << std::endl; const char* reply = "Hello from UDP Server!"; sendto(serverSocket, reply, (int)strlen(reply), 0, (sockaddr*)&clientAddr, clientAddrLen); } closesocket(serverSocket); WSACleanup(); return 0; }逐段拆解一下。第一段 WSAStartup 是 Windows 专属的,MAKEWORD(2, 2) 表示请求使用 Winsock 2.2 版本,如果第一个返回值不为 0,说明初始化失败,后面所有 socket 操作都不用做了。第二段创建套接字,返回值检查用 INVALID_SOCKET,这是 Windows 的错误返回值约定。第三段是整个服务端的“锚点”,bind 把 8888 端口和套接字绑定在一起,之后系统收到的目标端口为 8888 的 UDP 数据报都会送到这个套接字。
第四段是实现逻辑的核心。recvfrom 在数据到达之前会阻塞当前线程,收到数据后才返回。clientAddr 变量在调用前填充了一个有效长度,recvfrom 内部会把实际数据来源地址写进这个结构体,同时更新 clientAddrLen 为实际长度。之后直接用 inet_ntoa 把二进制 IP 转成字符串打印,用 ntohs 把端口转回主机字节序展示。回包时 sendto 的目标地址就是刚才 recvfrom 解析出来的 clientAddr,等于完成了“谁给我发,我回给谁”的闭环。
这里我特别想提醒一点:recvfrom 末尾有个 clientAddrLen 参数,是以指针形式传入的,很多网上的老代码会直接传一个 int 变量的地址,但如果这个变量在每次循环前没有被重置为 sizeof(clientAddr),第二次调用时可能因为长度值被内核改掉而收不到数据。所以我在 while 循环体外初始化它,因为循环体内内核会更新它的值,但 sizeof 是每次需要的初始值,更保险的做法是在每次 recvfrom 之前都重置一下。
3.2 客户端完整代码与逐段拆解
客户端代码更简单,关键是填充服务端地址,然后一个 sendto 发出消息,再一个 recvfrom 等待回复。
#include <iostream> #include <cstring> #include <winsock2.h> #pragma comment(lib, "ws2_32.lib") int main() { // 1. 初始化 Winsock WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { std::cerr << "WSAStartup failed" << std::endl; return -1; } // 2. 创建 UDP 套接字 SOCKET clientSocket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (clientSocket == INVALID_SOCKET) { std::cerr << "socket failed, error: " << WSAGetLastError() << std::endl; WSACleanup(); return -1; } // 3. 填充服务端地址 sockaddr_in serverAddr{}; serverAddr.sin_family = AF_INET; serverAddr.sin_port = htons(8888); serverAddr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 4. 发送数据 const char* message = "Hello, UDP Server!"; int sendLen = sendto(clientSocket, message, (int)strlen(message), 0, (sockaddr*)&serverAddr, sizeof(serverAddr)); if (sendLen == SOCKET_ERROR) { std::cerr << "sendto failed, error: " << WSAGetLastError() << std::endl; closesocket(clientSocket); WSACleanup(); return -1; } std::cout << "sent " << sendLen << " bytes: " << message << std::endl; // 5. 接收服务端回复 char recvBuffer[1024]; sockaddr_in fromAddr{}; int fromAddrLen = sizeof(fromAddr); int recvLen = recvfrom(clientSocket, recvBuffer, sizeof(recvBuffer) - 1, 0, (sockaddr*)&fromAddr, &fromAddrLen); if (recvLen == SOCKET_ERROR) { std::cerr << "recvfrom failed, error: " << WSAGetLastError() << std::endl; } else { recvBuffer[recvLen] = '\0'; std::cout << "recv reply from " << inet_ntoa(fromAddr.sin_addr) << ":" << ntohs(fromAddr.sin_port) << " -> " << recvBuffer << std::endl; } closesocket(clientSocket); WSACleanup(); return 0; }客户端没有 bind,这是很多人容易疑惑的地方。TCP 客户端也不需要 bind,通信发起方只需要一个临时的本地端口,让操作系统自动分配即可。recvfrom 的阻塞特性这里也需要考虑清楚:如果服务端没起来,或者数据在网络上丢了,客户端会一直阻塞在 recvfrom 上。这个 demo 里服务端一定会回包,所以能正常跑通;真实项目里,客户端必须加上超时机制,我在后面“优化”部分会专门讲。
inet_addr 这个函数虽然在现代代码里被视为过时函数,官方更推荐 inet_pton,但它用法简单,一眼能看懂,而且在这个 IPv4 的 demo 里完全够用。实际项目中如果客户端需要支持输入 IP 字符串,建议用 inet_pton,因为它在错误处理上更严格,inet_addr 遇到非法 IP 会返回 INADDR_NONE,有时候 debug 起来容易糊涂。
3.3 编译、运行与通信验证实录
代码写完之后就是编译和运行。Windows 上如果用的 Visual Studio,新建一个控制台项目,把代码粘进去,编译器会自动处理 #pragma comment(lib),直接 F5 就能跑。如果是 MinGW,编译命令是这样:
g++ udp_server.cpp -o udp_server -lws2_32 g++ udp_client.cpp -o udp_client -lws2_32Linux 上则需要先做几个小改动:去掉 WSAStartup 和 WSACleanup,把 winsock2.h 换成三个 Linux 头文件,closesocket 换成 close,然后把编译命令改成:
g++ udp_server.cpp -o udp_server g++ udp_client.cpp -o udp_client运行顺序上,先开服务端终端,再开客户端终端。服务端会打印一条 “UDP Server is running on port 8888”,客户端启动后会发送数据,服务端终端会显示收到消息的 IP、端口和内容,紧接着客户端终端会显示服务端回复的内容。我实测整个交互是这样的:
# 服务端终端 UDP Server is running on port 8888 recv from 127.0.0.1:54321 -> Hello, UDP Server!# 客户端终端 sent 17 bytes: Hello, UDP Server! recv reply from 127.0.0.1:8888 -> Hello from UDP Server!这里有个细节值得注意:客户端虽然没 bind,但操作系统给它分配了一个临时端口,这里恰好是 54321。这个端口是随机的,每次运行可能不同,服务端不需要提前知道,因为 recvfrom 已经把客户端的地址和端口捕获到 clientAddr 里了。这也是 UDP 服务端能“同时服务多个客户端”的底层原因——每个数据报的来源地址都是独立的。
如果想把客户端改成连接局域网里的另一台机器,把 inet_addr("127.0.0.1") 换成目标机器的局域网 IP 就行。但注意,服务端 bind 用的 INADDR_ANY 已经覆盖了所有网卡,所以不需要改服务端代码,这个设计在当时确实省了不少事。
4. 常见问题定位与排错心得
4.1 收不到数据:从防火墙到IP配置的排查顺序
收不到数据是 UDP 开发里最常见的故障,因为 UDP 没有连接状态可查,问题出现后表现就是“静默失败”。我遇到过好几次这种场景,代码看得毫无问题,但数据就是送不到,排错的痛苦谁经历谁知道。后来我总结出固定排查顺序,效率高很多。
第一,确认服务端有没有成功 bind。程序启动后如果有 “Server is running” 的打印,基本说明 bind 成功,但最好再用系统命令确认。Windows 用 netstat -ano | findstr 8888,Linux 用 ss -ulnp | grep 8888,能看到 UDP 监听记录就是正常的。如果查不到,说明 bind 失败或者程序起错终端了。
第二,确认数据到底有没有发出去、收到过。客户端 sendto 的返回值如果是正数,意味着数据已经交给了操作系统协议栈,但不代表到了服务端。最直接的验证手段是抓包,Wireshark 里过滤 udp.port == 8888,一眼就能看到数据报是否出现在网络上。没有抓包工具的话,可以在服务端 recvfrom 之前加一行打印,或者用 netcat 工具做最小化验证。
第三,检查防火墙。Windows 防火墙默认会拦截未授权程序的入站请求,尤其是第一次运行编译产物时,系统会弹窗询问是否允许访问网络,如果点了取消,后面怎么发都收不到。打开“高级安全 Windows Defender 防火墙”,在入站规则里给程序加一条“允许”规则即可。局域网联调时,双方的防火墙都要检查,我有过客户端所在机器防火墙拦截出站导致数据半路消失的案例,这类问题不加打印排查不出来。
第四,检查客户端填的 IP 和端口。127.0.0.1 只能用于本机通信,跨机器联调必须填服务端机器的局域网 IP。这个“忘记改 IP”的问题是调试中最常见的,建议直接把服务端地址做成命令行参数或者配置项,而不是硬编码。
4.2 端口绑定失败与字节序问题
bind 返回 SOCKET_ERROR 时,WSAGetLastError 返回 WSAEADDRINUSE,说明端口已经被占用。占用来源通常是三种:另一个 UDP 程序占用了同一端口、上一次运行的程序没有正常关闭导致 TIME_WAIT、或者系统服务占用了这个端口。前两种情况用 netstat 查到占用程序的 PID 后关掉即可,第三种则建议直接换端口,我实际项目中遇到过一次某个系统服务占用了常用端口段,怎么清理都清理不干净,最后把服务端口改到高位段才稳定。
字节序问题比端口占用更隐蔽,因为它的表现不是报错,而是“通了一部分”。比如你服务端 bind 到 8888,客户端也填 8888,但少了 htons 转换,实际通信端口可能是 0 或者 62708 这种值,双方根本不在同一个端口上对话。排查字节序问题,可以在两端打印 ntohs(serverAddr.sin_port) 和原始填充值对比,如果填的是 8888 打印出来是 8888,说明转换正确;如果打印出来是 62708,说明全球通用了错误的方式。这个坑对于第一次写 socket 的程序员来说几乎是必中的,写代码时只要记住“所有发往网络的结构体字段都要转换,所有从网络读出来的字段都要转换”就够了。
4.3 跨平台移植的几个典型坑
如果代码需要在 Windows 和 Linux 之间来回移植,除了头文件和库的差异,还有几个容易翻车的小点。第一是 SOCKET 类型差异,Windows 下是 unsigned int,INVALID_SOCKET 是 0xFFFFFFFF,和普通 int 的 -1 不同,简单判断 if (socket < 0) 在 Windows 上是不生效的,务必用 INVALID_SOCKET 或 -1 的统一封装。第二是错误码打印方式,Windows 的 WSAGetLastError 和 Linux 的 errno 是两套体系,调试封装要做平台区分的转换函数。第三是字符串处理差异,strcpy_s 这类安全版本函数在 Linux 上不存在,如果习惯 Windows 的写法,跨平台代码要注意兼容。第四也是最容易被忽略的,Windows 程序如果没调用 WSAStartup,任何 socket 函数都会返回失败,错误码是 WSANOTINITIALISED,很多人一上来就查后面的逻辑,查了半天才发现是这个基础问题。
Linux 代码移植到 Windows 时,close 需要换成 closesocket,read/write 要换成 recv/send,这些差异不复杂,但如果没有提前封装,移植时改动量会很大。我的做法是,项目从一开始就维护一个 net_platform.h,把所有平台差异集中进去,主逻辑不去碰平台相关 API。这个习惯在项目扩大的时候真的能救命。
5. 从能跑到好用:可靠性与实用性增强
5.1 超时重传与确认机制
UDP 的“不可靠”在 demo 阶段无所谓,但在真实项目里必须处理。最常见的增强是设计一个简单的确认与超时重传机制:发送方给每条消息编一个递增序号,放进数据包头部;接收方收到消息后回一条带相同序号的 ACK;发送方发送后启动定时器,如果在超时时间内没收到对应 ACK,就重传一次,重传次数达到上限后视为失败。
这里有个细节,UDP 的 recvfrom 默认是阻塞的,要支持超时,需要将它设置成非阻塞或采用 select 机制。Windows 上可以这样设置 recvfrom 超时:
DWORD timeout = 3000; // 3秒 setsockopt(clientSocket, SOL_SOCKET, SO_RCVTIMEO, (const char*)&timeout, sizeof(timeout));Linux 上 setsockopt 的第四个参数类型不同,需要传 timeval 结构体,这是跨平台差异的又一典型案例。设置完超时后,recvfrom 超时会返回错误,错误码在 Windows 是 WSAETIMEDOUT,Linux 是 EAGAIN,需要分别适配。这个超时机制也是我之前说的“客户端 recvfrom 一直阻塞”问题的标准解法。
5.2 缓冲区策略与数据边界处理
UDP 是数据报协议,一次 recvfrom 读到的正好是一个完整的数据报,天然没有 TCP 那种粘包问题。但正因如此,接收缓冲区必须大于或等于数据报的实际大小,否则数据会被内核截断,剩余部分丢失。常见的数据报文可以到 64KB,实际项目中受 MTU 限制,建议单包控制在 1400 字节以内,超过这个大小要么在应用层分包,要么换 TCP。我一般的做法是定义数据包头结构,前 4 字节表示包总长,接收端先读固定长度的包头,再根据包长读取实际数据,这样既能做分包又能做边界校验。
发送缓冲区方面,UDP 发送不会因为网络拥堵而停留,数据报交给内核就直接返回,所以应用层不需要复杂的发送缓冲策略。但如果短时间内发送过多数据报,内核发送缓冲区满了之后 sendto 会报 WSAEWOULDBLOCK 或 EAGAIN,这时需要适当降低发送频率或者把数据合并成大包一次发出。实际项目中“发送频率过高导致丢包”比“带宽不足导致丢包”更容易发生,这是一个很多人没意识到的点。
5.3 我在实际项目中常用的几个增强点
这几条经验是反复踩坑后总结出来的,代码上没有多复杂,但每一条都能减少相当比例的调试时间。
第一,定义应用层协议头。哪怕是一个简单的联调工具,我也建议在数据前面加一个固定格式的头,包含魔数字段、版本号、消息类型、序号、负载长度。开头加魔数(比如 0x55AA)能快速识别非法数据,也能在抓包时一眼定位本程序的数据流;序号字段是超时重传和丢包统计的基础。这个习惯从第一版就开始做,后面扩展功能会特别顺手。
第二,打印日志带上时间和对端地址。UDP 没有连接概念,出问题后必须靠日志还原现场。我习惯在服务端每条日志里带上收到数据的时间戳、来源 IP 和端口,客户端的日志带上发送时间和目标地址,加了这些之后,排查“数据是否到达”就变成了简单的文本比对,而不是靠猜。
第三,把服务端地址做成参数。不要硬编码 IP 和端口,至少做成命令行参数,最好从配置文件读。我做联调时经常要在本机回环、测试机器、现场设备之间切换目标地址,如果每次都要改源码重编译,效率低不说,还容易漏改。一个简单的 config 文件或者一组命令行参数,成本很低,收益很高。
最后一件事,UDP 通信代码的单元测试比重比较低,联调才是主要验证手段。我会写一个小小的测试脚本,循环发送不同大小的随机数据包,同时服务端做回显,客户端统计收到的回显字节数和丢包率。这个脚本不只是验证功能,也能顺便测出网络环境的实际可靠性和缓冲区设置的合理性,对后续调优非常有价值。
这个 UDP 通信模块从设计到现在,已经在我手头好几个联调场景里跑过。最初以为只是调几个 API 的小活,真正做完才发现,协议选型、字节序、超时、缓冲区、平台差异,每一个环节都可能让程序“默默地不干活”。我的体会是,UDP 的代码门槛不高,但真正把它做成一个稳定可用的通信模块,需要对它的无连接特性有足够深入的理解。如果你也在做类似的客户端与服务端通信,希望这份记录能帮你少走几段弯路。