简介:这是一份面向VC++初学者与网络编程入门者的UDP通信演示工程,围绕Windows平台Winsock套接字展开,帮助读者理解无连接传输协议的基本用法。资源以客户端与服务器双端示例为主线,涵盖套接字库初始化、UDP套接字创建、sockaddr_in地址结构填充、bind绑定、sendto发送与recvfrom接收等核心环节,并涉及错误码捕获与缓冲区管理等常见问题。压缩包共15个文件,约11KB,包含6个cpp源文件、3个头文件、3个dsp工程文件、2个dsw工作区文件及1个positions文件,源文件承载客户端与服务器两端逻辑,头文件提供公共声明,工程与工作区文件便于在旧版VC++环境中直接打开编译。目前已有131人学习下载。读者可据此快速搭建可运行的UDP收发环境,对照代码理解数据报通信流程,并在此基础上扩展多线程或异步处理,为后续网络编程实践打下基础。
1. 从一份 VC++ UDP 示例包说起:为什么它值得你花半小时拆开
如果你手头正好有一份UDP.rar,解压后看到Example.dsw、Client.dsp、Server.dsp这些带着年代感的工程文件,别急着关掉。这不是什么过时的玩具,而是一份把 Winsock UDP 通信链路完整跑通的 VC++ 工程骨架。它同时包含客户端和服务端两个独立工程,外加一个Example主工程,覆盖了WSAStartup初始化、socket创建、sockaddr_in地址填充、sendto/recvfrom收发、WSACleanup清理这条完整链路。对于正在做udp demo验证、需要快速搭一个局域网收发测试环境的工程师来说,这份资源的价值在于:它把最容易翻车的初始化顺序和地址结构体填充逻辑,用可编译的工程形式固定下来了。你不需要从零写StdAfx.h预编译头,也不用纠结.dsp和.dsw的依赖关系,打开就能改端口、改 IP、跑起来看现象。适合谁?刚接触 Winsock 的 C++ 开发者、需要快速验证 UDP 打流链路的测试人员、以及想拿一个最小可运行工程做二次开发底座的嵌入式方向工程师。
2. 拆开 UDP.rar:工程结构与 Winsock 初始化链路
2.1 三个工程文件的分工与依赖关系
解压后你会看到三组工程文件:Example.dsw/Example.dsp、Client.dsp、Server.dsw/Server.dsp。这里有个容易让人懵的点——Client只有.dsp没有.dsw,说明它大概率是作为Example工作区的一个子工程存在的,而Server是独立工作区。常见做法是:先打开Example.dsw,在 Workspace 里能看到 Client 和 Example 两个工程;Server 则单独打开Server.dsw编译。这种结构在 VC6 时代很典型,一个工作区管客户端和公共代码,另一个工作区管服务端,避免端口冲突和调试混乱。
StdAfx.cpp/StdAfx.h是预编译头文件对,VC++ 默认会通过#include "stdafx.h"加速编译。如果你用 VS2015 或更高版本打开,第一件事是确认项目属性里「C/C++ → 预编译头」设置为「使用(/Yu)」还是「不使用预编译头」。我一般会直接改成「不使用预编译头」,省得因为stdafx.h路径问题报一堆C1083错误。Example.cpp、Client.cpp、Server.cpp是三个核心源文件,分别对应主逻辑、客户端收发逻辑、服务端收发逻辑。
2.2 Winsock 初始化的正确顺序与参数含义
UDP 通信的第一步不是创建套接字,而是初始化 Winsock 库。这份示例里用的是WSAStartup(MAKEWORD(2, 2), &wsaData),第一个参数MAKEWORD(2, 2)表示请求 Winsock 2.2 版本,第二个参数是输出参数,系统会把实际支持的版本信息填进WSADATA结构体。很多人会忽略检查返回值,直接往下走,结果socket()返回INVALID_SOCKET还找不到原因。正确的做法是:
#include <winsock2.h> #include <ws2tcpip.h> #include <iostream> #pragma comment(lib, "ws2_32.lib") int main() { WSADATA wsaData; // 请求 Winsock 2.2,失败直接返回,不要继续 int ret = WSAStartup(MAKEWORD(2, 2), &wsaData); if (ret != 0) { std::cerr << "WSAStartup failed with error: " << ret << std::endl; return 1; } // 确认实际协商到的版本,排查环境问题时有用 if (LOBYTE(wsaData.wVersion) != 2 || HIBYTE(wsaData.wVersion) != 2) { std::cerr << "Winsock version mismatch" << std::endl; WSACleanup(); return 1; } // 创建 UDP 套接字:AF_INET 表示 IPv4,SOCK_DGRAM 表示 UDP SOCKET udpSocket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (udpSocket == INVALID_SOCKET) { std::cerr << "socket() failed: " << WSAGetLastError() << std::endl; WSACleanup(); return 1; } // ... 后续收发逻辑 closesocket(udpSocket); WSACleanup(); return 0; }这段代码里几个关键点:#pragma comment(lib, "ws2_32.lib")是 VC++ 特有的链接指令,等价于在项目属性里手动添加ws2_32.lib依赖,少了它会出现LNK2019未解析外部符号。socket()的第三个参数用IPPROTO_UDP比写0更明确,虽然效果一样,但读代码的人一眼就知道这是 UDP。closesocket和WSACleanup必须成对出现,否则反复启动程序会导致端口占用或资源泄漏。
2.3 地址结构体填充:sockaddr_in的四个字段
sockaddr_in是 IPv4 地址结构体,服务端和客户端都要用它来指定地址和端口。四个字段分别是sin_family、sin_port、sin_addr,以及填充用的sin_zero。示例里用的是inet_addr("192.168.1.1")把点分十进制字符串转成网络字节序的整数,但这个函数有个坑:它不支持255.255.255.255广播地址,也不做错误检查。更稳妥的做法是用inet_pton:
sockaddr_in serverAddr; serverAddr.sin_family = AF_INET; serverAddr.sin_port = htons(12345); // 端口号必须用 htons 转网络字节序 // inet_pton 比 inet_addr 更安全,返回值 1 表示成功 if (inet_pton(AF_INET, "192.168.1.100", &serverAddr.sin_addr) != 1) { std::cerr << "Invalid address" << std::endl; closesocket(udpSocket); WSACleanup(); return 1; }htons把主机字节序的端口号转成网络字节序,x86 机器是小端,网络是大端,不转的话端口号会变成完全不同的值。inet_pton的pton是 presentation to network 的缩写,支持 IPv4 和 IPv6,比inet_addr更通用。服务端绑定端口用bind,客户端通常不需要绑定,系统会自动分配一个临时端口。但如果你要做客户端固定端口接收服务端回包,那就得显式bind。
3. 客户端与服务端收发逻辑:sendto/recvfrom 的参数与缓冲区管理
3.1 服务端 bind 与 recvfrom 循环
服务端的核心是bind绑定本地端口,然后进入recvfrom循环等待数据。bind的参数是套接字、sockaddr指针和结构体长度。绑定失败最常见的原因是端口被占用,错误码WSAEADDRINUSE。解决办法是换端口,或者在bind之前设置SO_REUSEADDR选项:
// 允许地址复用,避免 TIME_WAIT 状态导致 bind 失败 BOOL opt = TRUE; setsockopt(udpSocket, SOL_SOCKET, SO_REUSEADDR, (char*)&opt, sizeof(opt)); sockaddr_in localAddr; localAddr.sin_family = AF_INET; localAddr.sin_port = htons(12345); localAddr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡 if (bind(udpSocket, (sockaddr*)&localAddr, sizeof(localAddr)) == SOCKET_ERROR) { std::cerr << "bind() failed: " << WSAGetLastError() << std::endl; closesocket(udpSocket); WSACleanup(); return 1; } char recvBuf[1024]; sockaddr_in clientAddr; int clientAddrLen = sizeof(clientAddr); while (true) { int bytesReceived = recvfrom(udpSocket, recvBuf, sizeof(recvBuf) - 1, 0, (sockaddr*)&clientAddr, &clientAddrLen); if (bytesReceived == SOCKET_ERROR) { std::cerr << "recvfrom() failed: " << WSAGetLastError() << std::endl; break; } recvBuf[bytesReceived] = '\0'; // 手动截断,防止打印越界 std::cout << "Received from " << inet_ntoa(clientAddr.sin_addr) << ":" << ntohs(clientAddr.sin_port) << " -> " << recvBuf << std::endl; }INADDR_ANY表示绑定到本机所有网卡地址,这样无论客户端从哪个网卡发过来都能收到。recvfrom的第五个参数是输出参数,会把发送方的地址和端口填进clientAddr,第六个参数是输入输出参数,传入结构体大小,返回实际大小。注意recvBuf要预留一个字节给\0,否则打印字符串时会越界读到栈上的脏数据。inet_ntoa把网络字节序的整数转回点分十进制字符串,但它返回的是静态缓冲区指针,多线程环境下不可重入,调试打印够用,生产代码建议用inet_ntop。
3.2 客户端 sendto 与超时设置
客户端用sendto发送数据,参数依次是套接字、缓冲区指针、数据长度、标志位(通常填 0)、目标地址结构体指针、结构体长度。UDP 的sendto成功返回只表示数据交给了内核协议栈,不保证对方收到。如果你需要确认对方是否响应,得自己实现应用层确认机制,或者设置recvfrom超时:
// 设置接收超时 3 秒,避免 recvfrom 永久阻塞 DWORD timeout = 3000; setsockopt(udpSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&timeout, sizeof(timeout)); const char* msg = "Hello, UDP!"; int bytesSent = sendto(udpSocket, msg, (int)strlen(msg), 0, (sockaddr*)&serverAddr, sizeof(serverAddr)); if (bytesSent == SOCKET_ERROR) { std::cerr << "sendto() failed: " << WSAGetLastError() << std::endl; } else { std::cout << "Sent " << bytesSent << " bytes" << std::endl; } // 尝试接收服务端回包 char replyBuf[1024]; sockaddr_in fromAddr; int fromAddrLen = sizeof(fromAddr); int ret = recvfrom(udpSocket, replyBuf, sizeof(replyBuf) - 1, 0, (sockaddr*)&fromAddr, &fromAddrLen); if (ret == SOCKET_ERROR) { if (WSAGetLastError() == WSAETIMEDOUT) { std::cout << "No reply within 3 seconds" << std::endl; } } else { replyBuf[ret] = '\0'; std::cout << "Reply: " << replyBuf << std::endl; }SO_RCVTIMEO的单位是毫秒,设成 3000 就是 3 秒。超时后recvfrom返回SOCKET_ERROR,WSAGetLastError()返回WSAETIMEDOUT。这个机制在udp网络调试场景里特别有用——你发一个探测包出去,等几秒没回包就知道对方不在线或端口没开,不用傻等。注意sendto的第二个参数是const char*,如果你传的是std::string,要用.c_str()转换。
3.3 缓冲区大小与数据截断问题
UDP 数据报的最大长度受限于 IP 层,理论上是 65507 字节(65535 减去 IP 头 20 字节和 UDP 头 8 字节)。但实际网络中,MTU 通常是 1500 字节,超过这个大小会触发 IP 分片,增加丢包概率。我一般把缓冲区设成 1472 字节(1500 减去 IP 头 20 和 UDP 头 8),这样不会分片。如果你发的数据超过缓冲区大小,recvfrom会截断数据并返回WSAEMSGSIZE错误,剩余部分直接丢弃。所以发送端和接收端的缓冲区大小要匹配,或者你在应用层做分片重组。
// 推荐缓冲区大小:1472 字节,避免 IP 分片 const int BUF_SIZE = 1472; char sendBuf[BUF_SIZE]; char recvBuf[BUF_SIZE]; // 发送前检查数据长度 int dataLen = (int)strlen(msg); if (dataLen > BUF_SIZE) { std::cerr << "Data too large, would be fragmented" << std::endl; // 实际项目中这里应该做分片发送 }4. 避坑与排查:VC++ UDP 示例跑不起来时先看这几条
4.1 编译报错 LNK2019:未解析的外部符号
现象:编译时提示error LNK2019: 无法解析的外部符号 __imp__WSAStartup@8或类似ws2_32相关符号。原因:项目没有链接ws2_32.lib库。解决:在stdafx.h或主源文件顶部加#pragma comment(lib, "ws2_32.lib"),或者在项目属性 → 链接器 → 输入 → 附加依赖项里手动添加ws2_32.lib。如果用的是 VS2015 以上版本,还要确认「SDK 版本」和「平台工具集」匹配,否则可能链接到错误的库版本。
4.2 bind 失败返回 WSAEADDRINUSE
现象:服务端启动时bind()返回SOCKET_ERROR,WSAGetLastError()得到WSAEADDRINUSE(错误码 10048)。原因:端口已被其他程序占用,或者上一次程序退出后套接字处于TIME_WAIT状态还没释放。解决:先换一个端口测试,比如从 12345 换成 12346。如果必须用原端口,在bind之前加setsockopt(SO_REUSEADDR)。另外检查是否有其他进程占用了该端口,命令行跑netstat -ano | findstr 12345看 PID,再用任务管理器结束对应进程。
4.3 recvfrom 一直阻塞不返回
现象:客户端发了数据,服务端recvfrom卡住不动,程序像死了一样。原因:服务端bind的地址是INADDR_ANY但端口和客户端发送的目标端口不一致;或者防火墙拦截了 UDP 包;或者客户端sendto的目标 IP 写成了127.0.0.1但服务端绑的是外网网卡地址。解决:先用netstat -ano | findstr 端口号确认服务端确实在监听;然后检查客户端sendto的目标端口是否和服务端bind的端口一致;再临时关闭 Windows 防火墙测试。如果服务端和客户端在同一台机器,目标 IP 用127.0.0.1,服务端bind用INADDR_ANY就能收到。
4.4 中文乱码或字符串截断
现象:发送"Hello, UDP!"收到正常,发送中文就乱码或者只收到一半。原因:strlen对中文按字节计算,一个汉字占 2 到 3 个字节,如果缓冲区大小没算对,sendto的长度参数可能截断汉字。另外std::cout输出char*时如果没手动加\0,会一直打印到遇到内存中的零字节为止。解决:发送中文时用strlen计算字节长度没问题,但接收端要确保recvBuf[bytesReceived] = '\0'。如果还是乱码,检查源文件编码和控制台代码页是否一致,VC++ 默认用 GBK,VS2015 以上默认 UTF-8,可以在main函数开头加SetConsoleOutputCP(65001)切到 UTF-8。
4.5 客户端收不到服务端回包
现象:客户端sendto成功,服务端也打印了收到的消息,但客户端recvfrom超时。原因:服务端回包时用的目标地址是recvfrom填出来的clientAddr,但客户端没有bind固定端口,系统分配的临时端口在sendto之后可能已经变了;或者服务端回包时sendto的目标端口写错了。解决:客户端在sendto之前先bind一个本地端口,比如bind到INADDR_ANY:0让系统分配,然后把分配的端口打印出来,服务端回包时用这个端口。更简单的办法是服务端直接用recvfrom拿到的clientAddr原样回发,不要手动改端口。
5. 从示例到可用:多客户端并发与 UDP 打流验证技巧
5.1 用 select 模型处理多客户端
这份示例是单线程阻塞模型,服务端一次只能处理一个recvfrom。如果你要同时接收多个客户端的数据,最轻量的改法是引入select多路复用。select可以同时监控多个套接字的可读事件,哪个有数据就处理哪个,不用开线程:
fd_set readSet; FD_ZERO(&readSet); FD_SET(udpSocket, &readSet); timeval tv; tv.tv_sec = 1; // 每次 select 最多等 1 秒 tv.tv_usec = 0; while (true) { FD_ZERO(&readSet); FD_SET(udpSocket, &readSet); int ret = select(0, &readSet, NULL, NULL, &tv); if (ret == SOCKET_ERROR) { std::cerr << "select() failed: " << WSAGetLastError() << std::endl; break; } if (ret == 0) { // 超时,没有数据到达,继续循环 continue; } if (FD_ISSET(udpSocket, &readSet)) { sockaddr_in clientAddr; int clientAddrLen = sizeof(clientAddr); char buf[1472]; int n = recvfrom(udpSocket, buf, sizeof(buf) - 1, 0, (sockaddr*)&clientAddr, &clientAddrLen); if (n > 0) { buf[n] = '\0'; std::cout << "From " << inet_ntoa(clientAddr.sin_addr) << ":" << ntohs(clientAddr.sin_port) << " -> " << buf << std::endl; } } }select的第一个参数在 Windows 上忽略,填 0 即可。FD_ZERO清空集合,FD_SET把套接字加入集合,select返回后FD_ISSET检查哪个套接字有数据。timeval设成 1 秒是为了让循环有机会处理其他逻辑,比如定时清理超时客户端。这个模型比多线程简单,适合客户端数量不多(几十个以内)的场景。如果客户端上千,就得用 IOCP 或者WSAPoll了。
5.2 用 iperf3 做 UDP 打流验证
写完收发逻辑,怎么确认你的 UDP 链路能跑满带宽、丢包率多少?我一般用iperf3做基准测试。服务端跑iperf3 -s -u,客户端跑iperf3 -c 192.168.1.100 -u -b 100M -t 30,-u表示 UDP,-b 100M表示目标带宽 100 Mbps,-t 30表示持续 30 秒。跑完之后iperf3会输出丢包率、抖动、乱序统计。如果你的程序跑出来的丢包率比iperf3高很多,说明代码里有性能瓶颈,比如缓冲区太小、recvfrom调用太频繁、或者打印日志拖慢了处理速度。
# 服务端 iperf3 -s -u -p 5201 # 客户端:UDP 打流 100Mbps,持续 30 秒 iperf3 -c 192.168.1.100 -u -b 100M -t 30 -p 5201注意iperf3的 UDP 测试默认包长是 1460 字节,接近 MTU 上限。如果你的程序缓冲区设的是 1024,那iperf3的包会被截断,丢包率会虚高。所以打流之前先确认双方缓冲区大小一致。另外iperf3的 UDP 测试不保证可靠性,丢包是正常的,关键看丢包率是否在可接受范围内(局域网通常低于 0.1%)。
5.3 用 Wireshark 抓包定位收发异常
如果sendto返回成功但对方没收到,或者收到的数据不对,下一步就是抓包。Wireshark 过滤规则用udp.port == 12345,只看目标端口的 UDP 包。抓包时重点看三个地方:一是sendto之后有没有对应的 UDP 数据包从本机发出,如果没有,说明sendto的参数有问题或者被防火墙拦截了;二是服务端有没有收到包,如果收到了但程序没打印,说明recvfrom的缓冲区或地址结构体有问题;三是回包有没有从服务端发出,以及客户端有没有收到。Wireshark 的Follow UDP Stream功能可以把一次会话的收发数据按顺序展示出来,比逐包看高效得多。
5.4 端口复用与优雅退出
最后说一个容易被忽略的点:程序退出时的资源清理。closesocket之后套接字立即关闭,但如果还有数据在缓冲区里没发出去,可能会丢。更稳妥的做法是先shutdown(udpSocket, SD_SEND)停止发送,等一会儿再closesocket。另外WSACleanup必须调用,否则反复启动程序会导致 Winsock 引用计数不归零,最终WSAStartup失败。我习惯在main函数里用atexit注册清理函数,或者用 RAII 封装一个WinsockGuard类,构造时WSAStartup,析构时WSACleanup,这样即使中间return也不会漏掉清理。
class WinsockGuard { public: WinsockGuard() { WSADATA wsaData; int ret = WSAStartup(MAKEWORD(2, 2), &wsaData); if (ret != 0) { throw std::runtime_error("WSAStartup failed"); } } ~WinsockGuard() { WSACleanup(); } WinsockGuard(const WinsockGuard&) = delete; WinsockGuard& operator=(const WinsockGuard&) = delete; }; // 使用方式:在 main 开头声明一个局部对象即可 int main() { WinsockGuard guard; // ... 后续代码不用手动调 WSACleanup }从那以后我每次拆这类 VC++ 网络示例,都强制先跑一遍WSAStartup返回值检查和bind错误码打印,再去看收发逻辑。这两个地方不出问题,后面基本就是一马平川。希望帮到你。
本文还有配套的精品资源,点击获取