
简介一套面向VC开发者的UDP通信示例工程演示Windows环境下利用Winsock实现客户端与服务器端收发数据。工程完整覆盖WSAStartup初始化、socket创建、sockaddr_in地址配置、bind绑定、sendto发送以及recvfrom接收等关键API调用并给出库链接与错误处理参考适合正在学习Windows网络编程或需要快速搭建UDP原型的开发人员。资源包仅11KB文件结构精简直观共15个文件其中6个cpp源文件承载客户端与服务端逻辑3个h头文件声明接口配合3个dsp工程文件、2个dsw工作区文件与1个辅助文件可在Visual C中直接打开构建。该资源当前已有131人学习浏览。通过该demo可直观理解UDP无连接、不可靠但高效的数据报模型直接复用客户端与服务端基础代码将套接字初始化、地址绑定、数据收发等模块迁移到自己的网络项目中便于继续扩展组播、并发收发等高级功能。1. 用 VC 写一个 UDP Demo它解决的问题比 TCP 更刁钻在做网络调试工具、设备联调或者上位机通信时你大概率会碰到这样的场景手里只有一个 IP 和端口对方既不跟你握手也不关心你发的东西它到底收没收到你只想把一包字节丢过去再看对面有没有回应——这就是 UDP。UDP.rar_DEMO_UDP_VC UDP_udp demo_vc UDP这类工程就是把 WinSock 里那套 UDP 收发逻辑抽出来做成一个双击就能跑的 demo让你不用从头搭界面和线程模型直接对照着学会sendto/recvfrom的完整套路。它适合三类人刚接触 socket 编程的 C 新手、要快速验证设备 UDP 协议的老手以及需要把 UDP 通信模块嵌进 MFC 对话框项目里的桌面应用开发者。UDP 的麻烦在于它没有连接概念收发两端各自为政调试时你根本不知道数据是没发出去、没收到还是收到了但解析错了。所以这个 demo 的价值不在“能发能收”而在把抓包、参数设置、缓冲区处理和错误码解读这几件事一次讲透。下面我按自己平时搭 UDP 调试工具的路径从 API 选择讲到参数调优再讲到那些经常让新手熬夜的坑。2. WinSock 下的 UDP 编程模型为什么说它比 TCP 更依赖“初始化”2.1 先分清楚UDP 的 socket 不需要 listen 和 accept但必须 WSAStartup无论你是写 TCP 还是 UDP在 Windows 上用 VC 调 socket 函数第一步永远是WSAStartup。很多人在 VC 里写 UDP 翻车翻在第一步拿 TCP 的经验去套 UDP以为要connect、listen、accept其实 UDP 只需要socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)创建套接字然后直接sendto/recvfrom。#include winsock2.h #include ws2tcpip.h #pragma comment(lib, ws2_32.lib) WSADATA wsaData; // 请求 2.2 版本的 WinSock int ret WSAStartup(MAKEWORD(2, 2), wsaData); if (ret ! 0) { // 返回 0 才代表成功WSAGetLastError() 可以拿到详细错误码 printf(WSAStartup failed: %d\n, ret); return -1; }这段代码的逻辑说明MAKEWORD(2, 2)是让系统加载 WinSock 2.2早期程序有人写MAKEWORD(1, 1)问题不大但建议直接 2.2。ws2_32.lib是静态链接库#pragma comment让编译器自动带上省得去工程设置里手动加。参数上注意一点WSAStartup返回 0 才表示成功非 0 是错误码不是 SOCKET_ERROR这是新手最容易看错的地方。UDP 的 socket 创建跟 TCP 不一样的地方在于第二个参数SOCK_DGRAM它告诉系统你要的是数据报服务而不是字节流。这直接影响底层行为系统不再帮你维护序号、重传、滑动窗口每个sendto就是一整个数据报接收端必须用足够大的缓冲区一次读走。2.2 bind 不 bind决定了你是“客户端”还是“服务端”UDP 没有连接但bind依旧有用。只发不收可以不 bind系统自动分配一个临时端口发出去要收就必须 bind否则系统不知道把到达这个端口的数据报投递给谁。sockaddr_in localAddr; localAddr.sin_family AF_INET; localAddr.sin_port htons(9000); // 监听端口 localAddr.sin_addr.s_addr htonl(INADDR_ANY); // 监听所有本地网卡 if (bind(sock, (sockaddr*)localAddr, sizeof(localAddr)) SOCKET_ERROR) { printf(bind failed: %d\n, WSAGetLastError()); closesocket(sock); WSACleanup(); return -1; }参数说明htons是把端口从主机字节序转成网络字节序端口小于 1024 在 Unix 下需要 root 权限Windows 上没这个限制但建议用 1024 以上避开和其他系统服务的冲突。INADDR_ANY代表监听所有网卡如果你只希望从某个固定 IP 收数据把s_addr设成那个 IP 的网络字节序值就行。这个 bind 的设计看似多余实际隐含了一个关键问题一个 UDP socket 只能 bind 一个端口。你如果需要同时监听多个端口得创建多个 socket各自 bind这是 dotnet 和 VC 里 UDP 调试工具最常见的设计模式。3. 把 Demo 的收发主循环写出来sendto 和 recvfrom 的细节都在参数里3.1 发送端sendto 的第五个参数为什么必须强制类型转换发送端代码核心就一个函数调用但参数全是有讲究的。看这段我常用的最小发送实现SOCKET sendSock socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); sockaddr_in serverAddr; serverAddr.sin_family AF_INET; serverAddr.sin_port htons(9000); // 把点分十进制 IP 字符串转换成网络字节序的 32 位值 inet_pton(AF_INET, 127.0.0.1, serverAddr.sin_addr); const char* msg UDP demo message; int sendLen sendto(sendSock, msg, (int)strlen(msg), 0, (sockaddr*)serverAddr, sizeof(serverAddr)); if (sendLen SOCKET_ERROR) { printf(sendto failed: %d\n, WSAGetLastError()); }逻辑说明sendto的第四、五、六个参数是给“目标是哪个地址”这个问题用的。第四个参数 flags 在 UDP 下传 0 就好MSG_DONTWAIT这类标志在 Windows 上不完整别依赖它。第五个参数传的是目标地址结构体指针必须强转成sockaddr*否则 C 编译器直接报错。第六个参数是地址结构体大小传sizeof(serverAddr)如果用sockaddr_storage就传这个结构体的大小不要传指针大小。inet_pton要重点讲它是把127.0.0.1这种字符串转成IN_ADDR结构体的推荐函数。老代码里常见inet_addr但它对于255.255.255.255会返回-1跟错误值混淆所以 VC 工程里我一般用inet_pton。如果编译环境是 VS2008 之前的版本没有inet_pton就用inet_addr然后判断返回值是否是INADDR_NONE这是历史兼容性的常见妥协。3.2 接收端阻塞模式下的 recvfrom 默认是“等到死”接收端比发送端多一个麻烦你不知道数据什么时候到。下面这段是阻塞收数据的标准写法SOCKET recvSock socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); sockaddr_in bindAddr; bindAddr.sin_family AF_INET; bindAddr.sin_port htons(9000); bindAddr.sin_addr.s_addr htonl(INADDR_ANY); bind(recvSock, (sockaddr*)bindAddr, sizeof(bindAddr)); char buf[2048]; sockaddr_in remoteAddr; int addrLen sizeof(remoteAddr); // 阻塞在这里直到收到数据或 socket 被关闭 memset(buf, 0, sizeof(buf)); int recvLen recvfrom(recvSock, buf, sizeof(buf) - 1, 0, (sockaddr*)remoteAddr, addrLen); if (recvLen 0) { buf[recvLen] \0; char ipStr[INET_ADDRSTRLEN]; inet_ntop(AF_INET, remoteAddr.sin_addr, ipStr, sizeof(ipStr)); printf(recv %d bytes from %s:%d: %s\n, recvLen, ipStr, ntohs(remoteAddr.sin_port), buf); }这里的隐患是缓冲区大小recvfrom一次只能读一个数据报如果数据报比你给的缓冲区大多余部分直接丢弃不会像 TCP 那样留在内核里等你下次读。我给的 2048 是大多数局域网调试场景够用的值但如果你知道上游设备会发 4K 以上的报文就得放大到 4096 或 65535UDP 最大理论负载 65507 字节。缓冲区不是越大越好越大占内存但 UDP 下宁大勿小因为丢数据是静默的连MSG_TRUNC标志在 Windows 上都不好使。addrLen这个参数要注意它既是输入又是输出进入函数时要放sizeof(remoteAddr)函数返回后里面存的是实际写入的地址长度。如果你传进去初始值是 0recvfrom会直接报WSAEFAULT。这个坑很多人遇到症状就是明明该收到数据却总是收不到检查发现recvfrom返回的是SOCKET_ERROR。4. 让 Demo 具备实战能力超时、非阻塞和 WSAEventSelect 三件套4.1 SO_RCVTIMEO 是 UDP 调试的后悔药单线程不会卡死阻塞的recvfrom在数据一直不来的时候会卡到天荒地老这在调试设备通信时是致命的你的程序挂起界面假死连强制退出的机会都要靠任务管理器。我一般先给 socket 设接收超时这是成本最低的解决方案。DWORD timeout 3000; // 单位毫秒 setsockopt(recvSock, SOL_SOCKET, SO_RCVTIMEO, (const char*)timeout, sizeof(timeout));setsockopt的第三个参数SO_RCVTIMEO在 Windows 下的取值是 DWORD 类型的毫秒数跟 Linux 下用struct timeval不一样。这是 VC 环境下最容易跟网上的 Linux 教程混淆的地方Linux 代码拿到 Windows 上编译不过或者行为诡异就是这类细节引起的。设置了超时之后recvfrom一旦超时返回SOCKET_ERRORWSAGetLastError()得到WSAETIMEDOUT10060这时你可以选择重试、发重传请求、或者退出线程。超时值怎么选200-500 毫秒适合对延迟敏感且需要快速重试的场合3-5 秒适合等待设备周期性上报的场景。太短会频繁误判“对端没响应”太长则失去超时的意义。我在调试时一般先用 3000 毫秒跑通流程再按实际设备响应时间收紧。4.2 非阻塞模式 select线程不卡死的另一种解法SO_RCVTIMEO解决了卡死但它是“每一次调用都等 3 秒”如果你要在等待期间同时响应用户的取消操作等 3 秒也太长了。这时候把 socket 设成非阻塞配合select做可读性判断是更灵活的做法。u_long mode 1; // 1 非阻塞 ioctlsocket(recvSock, FIONBIO, mode); fd_set readSet; FD_ZERO(readSet); FD_SET(recvSock, readSet); timeval waitTime; waitTime.tv_sec 1; waitTime.tv_usec 0; int ready select(0, readSet, NULL, NULL, waitTime); if (ready 0) { // socket 有数据可读此时 recvfrom 会立即返回 } else if (ready 0) { // 超时没有数据 } else { // select 本身出错 }逻辑说明ioctlsocket把 socket 切成非阻塞模式后recvfrom在没数据时立即返回SOCKET_ERROR错误码是WSAEWOULDBLOCK10035。select的第一个参数在 Windows 上被忽略传 0 就行这和 Unix 系要传最大 fd1 不一样。timeval在 Windows 上的精度是毫秒级tv_usec实际会被截断所以别指望微秒级等待。这套模式的好处是把“等数据”和“处理 UI 消息”分离你可以在等待期间做别的事比如检查用户是否点了取消按钮、或者更新界面上的倒计时。代价是代码变复杂如果你只是做个纯命令行的调试工具SO_RCVTIMEO就够了。4.3 WSAEventSelect多 socket 监听的 VC 传统方案当你要同时监听两个 UDP 端口或者一个 UDP 加一个 TCP 时select的问题就暴露了fd_set在 Windows 上默认上限是 64虽然可以扩展但管理起来繁琐。更传统的 VC 做法是用WSAEventSelect把一个 socket 和一个事件对象绑定然后WaitForMultipleObjects等事件。WSAEVENT eventObj WSACreateEvent(); WSAEventSelect(recvSock, eventObj, FD_READ | FD_CLOSE); DWORD waitRet WaitForSingleObject(eventObj, 1000); if (waitRet WAIT_OBJECT_0) { WSANETWORKEVENTS netEvents; WSAEnumNetworkEvents(recvSock, eventObj, netEvents); if (netEvents.lNetworkEvents FD_READ) { // 有数据可读 } else if (netEvents.lNetworkEvents FD_CLOSE) { // 对端关闭UDP 下一般不会触发 } } WSACloseEvent(eventObj);参数说明WSAEventSelect的第三个参数是要监听的事件掩码FD_READ表示有数据到达时触发事件。WaitForSingleObject的第二个参数是等待毫秒数这里设 1000。注意WaitForSingleObject返回后必须调用WSAEnumNetworkEvents来获取具体事件类型否则事件状态不会被清除下一次Wait会立即返回形成忙等。这段逻辑很多老 MFC 工程里都有适合那些把网络监听放在 UI 线程里、又想避免消息循环阻塞的场景。5. UDP Demo 的避坑手册5 个我实际踩过的坑5.1 坑一没调用 WSAStartupsocket 函数全部返回 SOCKET_ERROR现象socket()返回的是INVALID_SOCKETWSAGetLastError()是WSAENETDOWN10050或者WSAEINPROGRESS10036。原因WinSock 库没有初始化系统不知道你要用哪个版本的 API。这个问题多发生在把网上的 Linux UDP 代码直接搬到 VC 里跑的场景因为 Linux 不需要显式初始化。解决在任何 socket 调用之前执行WSAStartup并且检查返回值。配套的WSACleanup要在不需要再调 socket API 之后调用程序退出前调一次即可。更隐蔽的场景是你调了WSAStartup但程序里有多个模块某个模块的静态初始化函数在WSAStartup之前就调了 socket API这种问题靠加日志定位。5.2 坑二bind 失败但 WSAGetLastError 是 WSAEADDRINUSE现象第二次运行同一个 UDP demobind报错错误码WSAEADDRINUSE10048。原因上一次运行的进程没有完全退出系统认为端口被占用。Windows 下还有个特殊情况如果程序崩溃但 socket 资源没释放这个端口会停留几分钟才能复用。解决确认所有进程真的关闭用命令netstat -ano | findstr 9000查看谁占着端口找到 PID 后在任务管理器里结束。如果要在代码层面允许快速重用端口调用setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, ...)但注意这会让两个进程同时 bind 同一个端口成为可能行为变得不确定建议只在调试工具场景下用。5.3 坑三recvfrom 收到的数据总是比发送端多几个字节现象发送端发送字符串hello5 字节接收端打印长度却是 8 或 9尾部多出乱码。原因接收缓冲区没有清空或者打印时没有按实际接收长度截断。recvfrom的返回值代表实际接收字节数但如果你把整块 buffer 当作 C 字符串用%s打印它会一直读到第一个\0为止。因为 UDP 报文不一定以\0结尾缓冲区后面残留的旧数据就被读出来了。解决像我在 3.2 节写的那样memset(buf, 0, sizeof(buf))清空缓冲区收到数据后立即buf[recvLen] \0。如果你的数据本身就是二进制且中间可能含有\0就不要用字符串函数处理按长度指针循环读取。5.4 坑四发到局域网其他机器的 UDP 包收不到但 127.0.0.1 没问题现象demo 在本地回环地址上收发正常改成局域网 IP 后对方收不到包。原因Windows 防火墙阻止了入站 UDP 流量。UDP 没有连接状态防火墙无法像 TCP 那样通过“出站连接的回包”来判断是否放行所以默认策略更严格。另外某些路由器或交换机开启了 UDP flood 防护也会丢包但这个概率在局域网内较低。解决在开发机上以管理员身份执行netsh advfirewall firewall add rule nameUDPDemo dirin actionallow protocolUDP localport9000给指定端口放行。注意这条命令在 Windows 10 上会新建一条防火墙规则不需要时可以delete rule nameUDPDemo删掉。用 VC 开发的程序如果要做安装包建议把防火墙规则做到安装程序里但也要声明用途否则用户会怀疑你的软件动机。5.5 坑五recvfrom 阻塞卡死关不掉线程现象程序启动后调用recvfrom阻塞等待数据用户点击“关闭”按钮线程却迟迟退不出最后只能用任务管理器杀进程。原因阻塞的recvfrom在 socket 上等待数据主线程如果直接closesocket在某些 VC 环境下并不会立刻唤醒阻塞中的recvfrom或者关闭时机不当导致未定义行为。解决推荐做法是操作系统的标准解法——closesocket加WaitForSingleObject等线程退出但注意closesocket要在阻塞调用的线程之外执行Windows 的 Winsock 实现通常能唤醒阻塞调用并让它返回WSAEINTR或WSAENOTSOCK。更稳妥的方案是给 socket 设置超时SO_RCVTIMEO让recvfrom最多等 3 秒就返回一次检查线程退出标志再继续等。这样设计线程退出流程是最保险的代价是 CPU 在超时期间空转的一部分时间换来了可控性。6. 从一个能跑的 Demo 到一个能用的工具验证手段与进阶技巧Demo 跑通只是第一步UDP 通信的“能不能用”要靠验证手段说话。这里分享一个我常用的三层验证方法。基础层用命令行版 UDP 调试工具对测。Windows 自带的netstat -ano能看端口监听状态但它看不到数据内容所以我习惯在另外一台机器上跑一个工具和 VC demo 互发报文确认协议格式没问题。网络上不少 UDP 网络调试小工具原理同样是 socket不需要第三方库拿来对测很顺手。网络层要确认包真的到了网卡用抓包工具最直接。Wireshark 过滤条件写udp.port 9000能看到每个报文的时间戳、源 / 目的 IP、长度和负载比打印日志更接近真相。抓包时注意本机回环流量要走 Npcap 的 loopback 接口抓之前确认选对了网卡。压力层用 iperf3 测带宽和丢包率。命令格式是iperf3 -s服务端和iperf3 -c 192.168.1.100 -u -b 10M客户端打 UDP 流。这里有个关键参数-b是目标带宽如果你设 10M而实际网络只有 1M丢包率会非常高这不是应用的错是链路拥塞。我在验证 demo 的接收能力时会把-b从 1M 往上慢慢加观察丢包率曲线直到出现明显拐点这个值就是当前缓冲区 处理逻辑的极限。最后一个进阶技巧是把 demo 里的 socket 封装成类把收发放到独立线程里用消息或回调通知业务层。这样你的代码才不是“教学 demo”而是可以直接嵌进 MFC 对话框、Qt 界面里用的通信模块。UDP 的收发本身不复杂复杂的是错误处理、线程退出、缓冲区管理、以及和 UI 生命周期的高效绑定。从 demo 走向工具的路本质上就是把我在 4.1 节讲的超时、4.2 节的 select、5.5 节的线程退出这三件事组合成一个稳定循环的问题。我的习惯是每个 release 版本留一个隐藏的调试开关打开后可把每一条收发记录写到日志文件并打印准确的字节数和时间戳这样用户在产线说“丢了数据”的时候我能直接从日志定位是不是缓冲区溢出导致的内核静默丢包。UDP 调试没有银弹细节全在日志里希望这个思路能帮到你。本文还有配套的精品资源点击获取