简介:这是一份面向VC++初学者与网络编程入门者的UDP通信演示工程,围绕Windows平台Winsock套接字展开,帮助读者理解无连接传输协议的基本用法与客户端/服务器交互流程。资源包共15个文件,以6个cpp源码、3个h头文件为核心,配合dsp、dsw等工程配置与positions定位文件,整体约11KB,体积轻巧,可直接在Visual Studio中打开编译调试。内容覆盖WSAStartup初始化、socket创建、sockaddr_in地址结构、bind绑定、sendto发送与recvfrom接收等关键环节,并给出错误处理与缓冲区管理的示例思路,客户端与服务器两端代码分目录组织,便于对照阅读。目前已有131人学习下载,适合作为课程实验、自学练手或项目起步的参考模板,帮助读者快速搭建可运行的UDP通信骨架,并在此基础上扩展多线程、异步I/O与安全加固等进阶能力。
1. 从一份 VC++ UDP Demo 说起:为什么它至今仍是网络编程的入门硬通货
如果你手里拿到一个叫UDP.rar的压缩包,解压后看到DEMO_UDP、VC UDP、udp demo、vc++ UDP这类命名,大概率它就是一个用 Visual C++ 写的 UDP 收发演示程序。别小看这个看起来“上古”的东西——在 2026 年的今天,UDP 依然是实时音视频、游戏同步、DNS 查询、IoT 上报、甚至 QUIC 底层传输的核心。而用 VC++ 写一个能跑通的 UDP demo,仍然是理解 socket 编程、字节序、阻塞/非阻塞、端口复用这些概念最直接的方式。
这篇文章面向三类人:一是刚学网络编程、拿到一个 VC++ UDP demo 却不知道怎么编译、怎么改、怎么调试的新手;二是想用 VC++ 快速验证 UDP 协议栈行为、做打流测试或抓包对照的熟手;三是需要把老 demo 迁移到现代 Visual Studio(比如 vc++ 2015-2022)却踩了一堆坑的工程师。我会从这份 demo 的典型结构讲起,拆解 VC++ UDP 的 API 调用链,给出可复现的编译与运行步骤,再讲参数怎么调、抓包怎么看、坑在哪。目标很明确:让你拿到任何一份 VC++ UDP demo,都能在半小时内跑起来,并且知道每一行代码在干什么。
2. 拆开一份 VC++ UDP Demo:从工程结构到 socket 调用链
2.1 典型 VC++ UDP demo 的文件构成与工程类型
一份用 VC++ 写的 UDP demo,常见有两种形态:一种是 Win32 控制台程序,直接main里调WSAStartup、socket、sendto、recvfrom;另一种是 MFC 对话框程序,界面上放几个编辑框和按钮,点“发送”就调sendto,点“接收”就起一个线程recvfrom。你拿到的UDP.rar里如果看到.dsp/.dsw,那是 VC++ 6.0 的工程文件;如果看到.vcxproj/.sln,那是 VS2005 以后的工程。还有一种是纯源码,只有.cpp和.h,需要自己新建工程导入。
先别急着编译。打开源码,找这几个关键点:有没有#include <winsock2.h>和#pragma comment(lib, "ws2_32.lib");有没有WSAStartup(MAKEWORD(2,2), &wsaData);socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)的返回值有没有判断INVALID_SOCKET。这三处是 VC++ UDP demo 的“命门”,缺一个都跑不起来。常见做法是:控制台 demo 把收发放在同一个while循环里,用select或ioctlsocket设非阻塞;MFC demo 则把recvfrom放在AfxBeginThread起的线程里,避免卡界面。
提示:如果你拿到的 demo 里用的是
SOCK_DGRAM但socket第三个参数写了0,在 Windows 上通常也能工作,但显式写IPPROTO_UDP更清晰,也方便排查协议栈问题。
2.2 最小可运行 VC++ UDP 收发代码与逐行说明
下面这段代码是我一般会用来替代老 demo 里“裸奔”收发逻辑的最小版本,控制台程序,直接能在 VS2015-2022 里编译。它做了三件事:初始化 Winsock、创建 UDP socket、绑定本地端口并循环收包,同时支持向指定地址发包。
// udp_demo_min.cpp : 最小 VC++ UDP 收发演示 #include <winsock2.h> #include <ws2tcpip.h> #include <iostream> #pragma comment(lib, "ws2_32.lib") int main() { WSADATA wsaData; // 1. 初始化 Winsock 2.2,失败直接退出 if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { std::cerr << "WSAStartup failed: " << WSAGetLastError() << std::endl; return 1; } // 2. 创建 UDP socket SOCKET sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (sock == INVALID_SOCKET) { std::cerr << "socket failed: " << WSAGetLastError() << std::endl; WSACleanup(); return 1; } // 3. 绑定本地端口 8888,接收任意地址发来的包 sockaddr_in localAddr{}; localAddr.sin_family = AF_INET; localAddr.sin_port = htons(8888); localAddr.sin_addr.s_addr = INADDR_ANY; if (bind(sock, (sockaddr*)&localAddr, sizeof(localAddr)) == SOCKET_ERROR) { std::cerr << "bind failed: " << WSAGetLastError() << std::endl; closesocket(sock); WSACleanup(); return 1; } // 4. 设置接收超时,避免 recvfrom 永久阻塞 DWORD timeout = 3000; // 3 秒 setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (const char*)&timeout, sizeof(timeout)); char buf[1024]; sockaddr_in fromAddr{}; int fromLen = sizeof(fromAddr); std::cout << "UDP demo listening on port 8888..." << std::endl; while (true) { int ret = recvfrom(sock, buf, sizeof(buf) - 1, 0, (sockaddr*)&fromAddr, &fromLen); if (ret == SOCKET_ERROR) { int err = WSAGetLastError(); if (err == WSAETIMEDOUT) { std::cout << "recv timeout, still alive." << std::endl; continue; } std::cerr << "recvfrom failed: " << err << std::endl; break; } buf[ret] = '\0'; char fromIp[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &fromAddr.sin_addr, fromIp, sizeof(fromIp)); std::cout << "recv from " << fromIp << ":" << ntohs(fromAddr.sin_port) << " -> " << buf << std::endl; // 5. 原样回发,方便用调试工具验证双向通路 sendto(sock, buf, ret, 0, (sockaddr*)&fromAddr, fromLen); } closesocket(sock); WSACleanup(); return 0; }逻辑说明:WSAStartup是 Windows 下所有 socket 调用的前置条件,不调用后面全失败。socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)创建的是 UDP 套接字,SOCK_DGRAM表示数据报。bind把 socket 和本地 8888 端口绑死,这样别人发到这台机器 8888 端口的 UDP 包才会到你手里。SO_RCVTIMEO设 3 秒超时,是为了让recvfrom不会永久卡住,方便你观察程序状态。recvfrom的fromAddr会带回发送方地址,inet_ntop把二进制 IP 转成可读字符串。最后sendto原样回发,这样你用iperf3或nc打流时能立刻看到回包。
参数说明:端口 8888 可以改成任意未占用端口;buf大小 1024 对 demo 够用,实际打流建议 1472 以内避免 IP 分片;timeout单位毫秒,设 0 表示不超时(不推荐)。如果你要发广播,把sendto目标地址设成255.255.255.255并打开SO_BROADCAST选项。
2.3 用 iperf3 和抓包工具验证 UDP 通路
代码跑起来只是第一步,真正要确认“UDP 到底通没通、丢没丢包”,得靠外部工具。常见做法是:在另一台机器或本机开一个 iperf3 服务端,用 UDP 模式打流,同时用 Wireshark 抓包看序列号和丢包统计。
# 服务端:监听 UDP 5201,每 1 秒输出一次统计 iperf3 -s -u -p 5201 -i 1 # 客户端:向 192.168.1.100 的 5201 打 UDP 流,带宽 10M,持续 30 秒 iperf3 -c 192.168.1.100 -u -p 5201 -b 10M -t 30逻辑说明:-u表示 UDP 模式,-b 10M指定目标带宽,-t 30持续 30 秒。iperf3 的 UDP 报告里会给出丢包率、抖动和乱序统计,这是判断 UDP 链路质量最直接的依据。抓包时过滤udp.port == 5201,看每个包的Sequence Number是否连续,如果中间缺号,说明链路有丢包。注意:本机回环测试丢包通常为 0,跨机测试才能暴露真实网络问题。
注意:Windows 防火墙默认可能拦截入站 UDP,跑 demo 前先确认 8888 或 5201 端口已放行,否则你会看到“代码没问题但就是收不到”的玄学现象。
3. 把老 VC++ UDP Demo 迁到现代 Visual Studio:工程与 API 的坑
3.1 VC++ 6.0 工程升级到 VS2015-2022 的步骤
很多人拿到的是 VC++ 6.0 时代的.dsp工程,直接双击打不开,或者用新版 VS 打开后一堆编译错误。我一般按这个顺序处理:先新建一个空的控制台或 MFC 工程,把老 demo 的.cpp/.h文件复制进去,然后在项目属性里改三个地方——字符集、运行库、附加依赖项。
具体操作:VS 里右键项目 → 属性 → 配置属性 → 高级 → 字符集,改成“使用多字节字符集”(老代码多半是char而不是TCHAR);C/C++ → 代码生成 → 运行库,Debug 用/MDd,Release 用/MD;链接器 → 输入 → 附加依赖项,加上ws2_32.lib。如果代码里用了inet_ntoa这类老函数,VS2015 以后会报C4996警告,可以在文件顶部加#define _WINSOCK_DEPRECATED_NO_WARNINGS压掉,但更推荐换成inet_ntop。
// 老代码常见写法,VS2015+ 会警告 // char* ip = inet_ntoa(fromAddr.sin_addr); // 推荐替换为 char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &fromAddr.sin_addr, ip, sizeof(ip));逻辑说明:inet_ntoa返回的是静态缓冲区指针,多线程下不安全,而且在新版 SDK 里被标记为 deprecated。inet_ntop是线程安全的,支持 IPv4 和 IPv6,参数里显式传入缓冲区大小,更可控。参数说明:AF_INET指定 IPv4,INET_ADDRSTRLEN是 16,够放255.255.255.255加结束符。
3.2 UDP 协议栈行为差异:Windows 与 Linux 的收发缓冲区
同一个 UDP demo,在 Windows 和 Linux 上跑,行为可能不一样。最典型的是接收缓冲区大小和丢包表现。Windows 默认 UDP 接收缓冲区是 8KB 左右,Linux 默认约 128KB。如果你用 demo 做高速打流,Windows 上更容易丢包,因为缓冲区小,来不及recvfrom就溢出。
调整方法:在bind之前调setsockopt设SO_RCVBUF和SO_SNDBUF。Windows 上这两个值有上限,实际生效值可以用getsockopt读回来确认。
int rcvBuf = 1024 * 1024; // 尝试设 1MB setsockopt(sock, SOL_SOCKET, SO_RCVBUF, (const char*)&rcvBuf, sizeof(rcvBuf)); int actual = 0; int len = sizeof(actual); getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (char*)&actual, &len); std::cout << "actual SO_RCVBUF = " << actual << std::endl;逻辑说明:setsockopt只是“请求”,系统可能给不了那么大,所以必须用getsockopt读回实际值。参数说明:SO_RCVBUF影响接收队列,SO_SNDBUF影响发送队列;打流场景两个都建议调大。注意 Windows 上设太大反而可能增加延迟,一般 256KB 到 1MB 之间比较平衡。
3.3 用 select 和 ioctlsocket 做非阻塞收发的取舍
老 demo 里常见两种写法:一种是recvfrom直接阻塞,界面卡死;另一种是起线程。现代做法更推荐用select或ioctlsocket设非阻塞,在单线程里轮询多个 socket。select的好处是能同时等多个 socket 和超时,适合 demo 里既要收又要发的场景。
// 设非阻塞 u_long mode = 1; ioctlsocket(sock, FIONBIO, &mode); // 或者用 select 等可读 fd_set readSet; FD_ZERO(&readSet); FD_SET(sock, &readSet); timeval tv{0, 100000}; // 100ms int sel = select(0, &readSet, nullptr, nullptr, &tv); if (sel > 0 && FD_ISSET(sock, &readSet)) { // 有数据可收 }逻辑说明:ioctlsocket的FIONBIO把 socket 设成非阻塞后,recvfrom没数据会立刻返回WSAEWOULDBLOCK,需要自己处理。select第一个参数在 Windows 上忽略,填 0 即可;timeval控制等待时间,100ms 适合交互式 demo。参数说明:非阻塞适合高频轮询,select适合等待多个事件;两者不要混用在同一逻辑里,否则容易漏包。
4. UDP Demo 调试避坑:从收不到包到端口占用的排查清单
4.1 收不到包:先查绑定地址和防火墙
现象:demo 跑起来,bind返回成功,但对面发过来的包一个都收不到。原因通常有两个:一是bind时用了INADDR_ANY但实际发到了错误的网卡地址;二是 Windows 防火墙拦了入站 UDP。解决:先用netstat -ano | findstr 8888确认端口在监听,再用 Wireshark 在本机抓包,看包到底有没有到网卡。如果 Wireshark 能看到包但程序收不到,基本就是防火墙或绑定地址问题。把sin_addr.s_addr改成具体网卡 IP 试试,或者在防火墙里给这个程序放行。
4.2 发送失败:WSAEWOULDBLOCK 不是错误
现象:sendto返回SOCKET_ERROR,WSAGetLastError()拿到WSAEWOULDBLOCK。原因:socket 被设成非阻塞,发送缓冲区满了,系统让你稍后再试。解决:这不是致命错误,把包缓存起来,等select报告可写时再发。如果频繁出现,说明发送速率超过了网卡或对端处理能力,需要降速或加大SO_SNDBUF。
4.3 端口被占用:SO_REUSEADDR 的正确用法
现象:bind返回WSAEADDRINUSE,提示端口已被占用。原因:上一个 demo 进程没退干净,或者多个程序抢同一个端口。解决:在bind之前设SO_REUSEADDR,允许重用本地地址。注意 Windows 上SO_REUSEADDR的行为和 Linux 不同,它允许完全重复绑定,可能导致包被分发到错误的 socket,所以只在确实需要重启快速恢复时用。
int opt = 1; setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (const char*)&opt, sizeof(opt));4.4 中文乱码:字符集与编码不一致
现象:demo 里发中文,接收端显示乱码。原因:VC++ 老工程默认多字节字符集,源码文件可能是 GBK,而现代编辑器保存成 UTF-8,编译后字符串常量编码不一致。解决:统一用 UTF-8 保存源码,在项目属性里把字符集设成“使用多字节字符集”,发送前确认字节数用strlen而不是sizeof。如果对端是 UTF-8,直接发原始字节即可,不要做额外转换。
4.5 大包分片:超过 MTU 的 UDP 包会怎样
现象:发 2000 字节的 UDP 包,抓包看到被拆成两个 IP 分片,接收端偶尔收不全。原因:以太网 MTU 通常 1500,UDP 头 8 字节 + IP 头 20 字节,实际数据超过 1472 字节就会分片。分片后任意一片丢失,整个 UDP 包就废了。解决:demo 里把单包控制在 1472 字节以内;如果业务必须发大包,在应用层自己做分片和重组,并加序列号。
5. 进阶:用 VC++ UDP Demo 做打流与协议栈行为验证
5.1 构造可变速率打流客户端
把 demo 改成一个可调速的打流工具,比 iperf3 更灵活,因为你可以控制包内容、包大小和发送间隔。核心逻辑是:用一个循环,按目标 pps(每秒包数)计算sleep时间,或者用QueryPerformanceCounter做更精确的节拍。
// 简易打流:每秒发 targetPps 个包,每个包 payloadSize 字节 int targetPps = 1000; int payloadSize = 512; char* payload = new char[payloadSize]; memset(payload, 'A', payloadSize); LARGE_INTEGER freq, start, now; QueryPerformanceFrequency(&freq); QueryPerformanceCounter(&start); for (int i = 0; i < targetPps * 10; ++i) { // 打 10 秒 sendto(sock, payload, payloadSize, 0, (sockaddr*)&dstAddr, sizeof(dstAddr)); // 忙等控制节奏,比 Sleep 精度高 do { QueryPerformanceCounter(&now); } while ((now.QuadPart - start.QuadPart) * 1000 / freq.QuadPart < (i + 1) * 1000 / targetPps); } delete[] payload;逻辑说明:QueryPerformanceCounter提供微秒级计时,比Sleep(1)的 15ms 精度高得多,适合做精确打流。参数说明:targetPps和payloadSize相乘就是目标带宽,比如 1000 pps × 512 字节 ≈ 4Mbps。注意忙等会吃满一个 CPU 核,实际工具里可以结合select和短Sleep降低占用。
5.2 用序列号和时间戳量化丢包与抖动
在 payload 里塞入 4 字节序列号和 8 字节时间戳,接收端就能算出丢包率和单向延迟抖动。这是验证 UDP 协议栈行为最直接的方法,比只看 iperf3 汇总数据更细。
| 字段 | 偏移 | 长度 | 说明 |
|---|---|---|---|
| 序列号 | 0 | 4 字节 | 网络字节序,每包递增 |
| 时间戳 | 4 | 8 字节 | 发送时刻,微秒 |
| 填充数据 | 12 | 剩余 | 固定内容,便于抓包识别 |
接收端每收到一个包,解析序列号,和上一个比,差值大于 1 就是丢了中间那些包。时间戳差值减去发送间隔,就是抖动。这套方法我在排查无线链路 UDP 质量时反复用过,比 ping 更能反映真实业务表现。
5.3 一个我常犯的错误:忘了处理 recvfrom 的地址长度
最后说个血泪经验。recvfrom的最后一个参数fromLen是传入传出参数,每次调用前必须重新设成sizeof(fromAddr)。我有一次在循环里忘了重置,第二次recvfrom就返回WSAEFAULT,查了半天以为是缓冲区问题。后来养成习惯:把fromLen的赋值放在循环体第一行。这个坑在 VC++ UDP demo 里特别常见,因为老代码经常把它写在循环外面。希望帮到你。
本文还有配套的精品资源,点击获取