简介一份基于VC6.0的UDP通讯例程包含完整可编译的客户端与服务器端工程适合学习MFC网络编程、初次接触异步Socket机制的开发者。程序以CAsyncSocket类为核心演示从创建套接字、绑定端口、服务器监听、客户端发起请求到通过OnReceive与OnSend回调完成数据收发和状态提示的完整流程可运行版本便于验证UDP无连接通信中数据送达、丢包与乱序等基本现象。压缩包内共56个文件h与cpp为MFC源码dsp、dsw、rc等为工程与界面资源obj、exe、pdb为编译输出和调试符号整体约4.43MB目录按客户端、服务器和Debug子目录划分查找方便。已有585人学习下载对理解VC6.0下Winsock封装、异步事件驱动以及自定义应用层重传或排序机制都有直接参考价值。1. 一个内网丢包案例讲清楚这个UDP通讯Demo到底能干什么早些年我在某公司的生产网维护一套传感器数据采集系统服务端挂在工控机上上位机通过UDP把采集指令广播给几十台设备。某天现场反馈“指令发出去了但设备没反应”我抓包一看UDP数据报确实从网卡出去了设备端也收到了但应用层没处理——因为设备端的接收缓冲区溢出后来的包被静默丢弃。那次排查让我意识到UDP通讯看着简单真正落地时坑全在看不见的地方。这个基于VC6.0的UDP通讯例子就是一个完整的Socket收发Demo包含服务端与客户端两个工程覆盖了Windows平台UDP编程的初始化、收发、关闭全流程。适合两类人一是刚接触Socket编程、想找一个能编译能跑的最小范例的新手二是需要在老工控机或VC6环境下做局域网通讯、想复用一段可靠代码的维护开发者。2. 技术选型与工程结构为什么是UDPVC6工程怎么组织2.1 UDP vs TCP这个Demo为什么选无连接协议先说要解决什么问题。UDP是传输层协议和TCP最大的区别在于它不给可靠性承诺没有三次握手、没有确认重传、没有拥塞控制数据报发出去就是发出去了对端收没收到协议栈不管。代价是换来极低的延迟和极小的报文开销头部只有8字节而TCP最小也是20字节。这个Demo选UDP实际上是奔着它的三个特性去的。第一个是实时性适合传感器数据、语音流、视频帧这类对延迟敏感的场景第二个是广播与组播支持TCP是点对点连接天然做不了广播而UDP可以用sendto往广播地址发数据一个包全网都收得到第三个是资源占用UDP不需要为每个连接维护状态服务端RecvFrom一个循环就能服务任意数量的客户端非常适合“一问一答”这种简单交互模型。VC6.0是上世纪90年代末的IDE放在今天看界面老旧但它在工业控制、教育机房、老设备维护场景里依然大量存在。很多工控机上跑着Windows 2000或XP装不了新版的Visual StudioVC6反而是最稳妥的选择。这个Demo就是基于VC6的MFC或Win32控制台工程写的不依赖任何第三方库纯WinSock API完成全部通讯逻辑。2.2 工程目录结构两个子工程怎么协作整个Demo是一个工作区文件包含两个子工程典型的VC6组织方式。在VC6里.dsw文件是工作区.dsp文件是工程文件编译时哪个工程设为Active就能先编译哪个。UdpDemo/ ├── UdpDemo.dsw // 工作区文件 ├── UdpServer/ │ ├── UdpServer.dsp // 服务端工程 │ ├── UdpServer.cpp // 服务端主程序 │ ├── StdAfx.cpp │ └── StdAfx.h ├── UdpClient/ │ ├── UdpClient.dsp // 客户端工程 │ ├── UdpClient.cpp // 客户端主程序 │ ├── StdAfx.cpp │ └── StdAfx.h └── Readme.txt // 编译与使用说明服务端工程的职责有三块初始化Socket、绑定本地端口、进入循环接收客户端数据并原样回显。客户端工程的职责与之对应初始化Socket、向指定IP和端口发送数据报、等待接收服务端回显。两端共用同一套WinSock编码风格便于对照理解。2.3 编译环境与依赖用到的头文件和库写UDP程序绕不开WinSock。在VC6里需要做两件配置一是包含头文件winsock2.h二是在工程设置里链接ws2_32.lib。常见做法是在源文件顶部写#pragma comment也可以在Project Settings的Link页签里手动加。#pragma comment(lib, ws2_32.lib) #include winsock2.h #include stdio.h#pragma comment是给编译器看的指令告诉链接器去系统库目录里找ws2_32.lib。如果漏了这一步链接时会报一堆 unresolved external symbol 错误比如LNK2001: unresolved external symbol __imp__sendto24。这是新手最容易翻车的地方。另一个注意点是winsock2.h和windows.h的包含顺序如果先包含了windows.h再包含winsock2.h会因为头文件重定义宏导致编译报错所以要么只包含winsock2.h要么确保它在前。3. 核心模块拆解一个数据报从发送到回显的完整链路3.1 Windows Socket初始化WSAStartup是唯一的入口在Windows上使用Socket第一步永远是调WSAStartup它通知操作系统加载WinSock实现并返回协议栈版本信息。这个函数在VC6环境下特别容易出问题因为老工程经常忘记调用或者调用了但没检查返回值。WSADATA wsaData; int iResult WSAStartup(MAKEWORD(2, 2), wsaData); if (iResult ! 0) { printf(WSAStartup failed: %d\n, iResult); return 1; }MAKEWORD(2, 2)表示请求2.2版本的WinSock这是Windows 2000以后系统都支持的版本。iResult返回值是0才代表初始化成功任何非零值都说明加载失败常见原因是系统WinSock组件异常或版本不匹配。VC6自带的库文件比较老但ws2_32.dll在系统里一直存在所以这一步通常不会出错。初始化做完后下一步是创建Socket和填充SOCKADDR_IN地址结构。SOCKET udpSocket socket(AF_INET, SOCK_DGRAM, 0); if (udpSocket INVALID_SOCKET) { printf(socket failed: %d\n, WSAGetLastError()); WSACleanup(); return 1; } SOCKADDR_IN serverAddr; serverAddr.sin_family AF_INET; serverAddr.sin_port htons(9000); serverAddr.sin_addr.S_un.S_addr htonl(INADDR_ANY);socket函数的三个参数分别指定地址族、套接字类型和协议。AF_INET代表IPv4SOCK_DGRAM是数据报套接字第三个参数0让系统根据前两个参数自动推导等价于IPPROTO_UDP。这里如果用SOCK_STREAM出来的就是TCP套接字代码后面调用bind、sendto的语义就完全变了。SOCKADDR_IN里的sin_family固定为AF_INETsin_port用htons做字节序转换——x86机器是小端网络字节序是大端htons把主机字节序转成网络字节序。sin_addr的赋值方式决定了服务端监听哪块网卡INADDR_ANY表示所有网卡如果填具体IP则只监听该IP上的数据报。3.2 服务端接收与回显recvfrom和sendto的对称性服务端绑定完端口后进入主循环。核心逻辑就两个函数recvfrom收数据sendto发数据。这两个函数的长相非常对称参数里都带一个SOCKADDR_IN结构区别在于recvfrom用这个结构取出发送方的地址sendto用这个结构指定接收方的地址。char recvBuf[1024]; SOCKADDR_IN clientAddr; int clientAddrLen sizeof(clientAddr); while (1) { int recvLen recvfrom(udpSocket, recvBuf, 1024, 0, (SOCKADDR*)clientAddr, clientAddrLen); if (recvLen 0) { recvBuf[recvLen] \0; printf(收到来自 %s:%d 的消息: %s\n, inet_ntoa(clientAddr.sin_addr), ntohs(clientAddr.sin_port), recvBuf); sendto(udpSocket, recvBuf, recvLen, 0, (SOCKADDR*)clientAddr, clientAddrLen); } }recvfrom是阻塞调用没有数据时线程卡在这里不返回这是UDP编程的默认工作方式。参数里第四个0是flags正常收发填0即可。第六个参数clientAddrLen是输入输出双向参数调用前传sizeof(clientAddr)函数返回后会写入实际地址长度。这里的clientAddr是完整的发送方信息服务端要回数据给谁直接拿这个结构体去调sendto就行不用自己去解析IP和端口。注意inet_ntoa返回的是一个静态缓冲区连续打印多条消息时内容会被覆盖。工程里如果只做演示无所谓做实际项目最好自己拷贝一份字符串。刚才这段代码里我之所以能把接收到的消息直接回显是因为UDP保留了消息边界recvfrom一次拿到的就是一个完整的sendto发送的数据报。TCP是字节流一次recv可能只拿到半个包两者处理方式完全不同。3.3 客户端发送与接收先发先等再收客户端这边的流程更加直观创建Socket后构造服务端地址然后sendto发一条字符串再用recvfrom等回显。这个Demo没有做重传机制一个典型的发送代码如下SOCKADDR_IN serverAddr; serverAddr.sin_family AF_INET; serverAddr.sin_port htons(9000); serverAddr.sin_addr.S_un.S_addr inet_addr(127.0.0.1); char sendBuf[] Hello UDP Server; int sendLen sendto(udpSocket, sendBuf, strlen(sendBuf), 0, (SOCKADDR*)serverAddr, sizeof(serverAddr)); if (sendLen SOCKET_ERROR) { printf(sendto failed: %d\n, WSAGetLastError()); closesocket(udpSocket); WSACleanup(); return 1; } SOCKADDR_IN serverAckAddr; int addrLen sizeof(serverAckAddr); char recvBuf[1024]; int recvLen recvfrom(udpSocket, recvBuf, 1024, 0, (SOCKADDR*)serverAckAddr, addrLen); if (recvLen 0) { recvBuf[recvLen] \0; printf(收到服务端回显: %s\n, recvBuf); }inet_addr(127.0.0.1)把点分十进制的IP字符串转成32位无符号整数这一步在VC6里可以用但在新版Windows SDK里已经被inet_pton替代后者更安全。这个Demo面向VC6环境所以用老接口没问题。sendto的第五个参数是目标地址结构第六个参数sizeof(serverAddr)告诉系统要发送的地址长度。这里有一个很长时间里都会被忽略的细节sendto只负责把数据交给本机协议栈它返回的发送长度只代表数据被协议栈接受了不代表对端已经收到——UDP协议栈不会给你任何对端确认这是理解整个Demo运行时行为的基石。调用顺序上客户端是先sendto再recvfromrecvfrom会阻塞直到服务端回显到达。如果服务端没起来或者回显被防火墙拦了客户端就会一直卡在recvfrom上看起来像程序死了。这个属于UDP编程的正常表现不是Bug后续的避坑章节会展开说明。3.4 收尾清理closesocket与WSACleanupVC6的Socket编程在结尾处必须做两件事closesocket关闭套接字句柄WSACleanup释放WinSock资源。这俩不调程序退出时系统最终也会回收但持续跑的服务端程序如果不释放会积累句柄泄漏跑上几天就会报“内存不足”或“无法创建Socket”。closesocket(udpSocket); WSACleanup();closesocket让操作系统注销这个Socket在内核中的缓冲区WSACleanup通知WinSock解除初始化。两者之间没有严格的先后顺序但建议先关Socket再清库。服务端程序是死循环正常不会走到这两行通常靠CtrlC或关闭窗口终止进程由OS接管回收。实际项目里合理的设计是收到某个特定指令或注册了CtrlC事件后再执行清理逻辑。4. 运行与联调从编译到两台机器互通4.1 编译步骤VC6里的一次标准操作VC6编译工程步骤很固定打开UdpServer.dsw按F7编译编译当前工程然后切换到另一个工程再编译。如果直接编译整个工作区VC6会自动按依赖关系处理但由于两个工程是独立的控制台应用没有相互依赖先后顺序无所谓。编译前要确认两件事一是当前构建配置是Win32 Debug还是Release。Debug版生成的文件名带D后缀运行依赖调试运行库跨机器拷贝时常因缺少MSVCRTD.dll而启动失败Release版不带D后缀依赖运行时库版本更通用生产环境建议用Release。二是确认平台SDK路径。VC6默认安装时会把WinSock头文件和库放在Include和Lib目录下正常情况下不需要额外配置。如果系统里还装了其他版本的Visual Studio环境的INCLUDE和LIB环境变量可能会指向新版本目录导致VC6取的winsock2.h不兼容。解决办法是在VC6的Tools → Options → Directories里强制把Include和Lib路径改回VC6自带目录。4.2 联调测试从本机回环到跨机器验证编译通过后先在本机做回环测试。启动服务端再把客户端的目标IP设成127.0.0.1发送一条消息服务端窗口里打印出“收到来自 127.0.0.1:1042 的消息”客户端窗口里打印“收到服务端回显”。回环通过说明两端Socket创建、地址填充、sendto和recvfrom这条链路是通的。接下来换成跨机器调试。把服务端程序拷到另一台Windows机器上运行客户端目标IP改成那台机器的局域网地址。这个时候最容易出的问题是防火墙弹窗——Windows默认会拦截入站UDP包第一次跑的时候Windows安全中心会弹提示需要勾选“允许访问”。如果用的是域环境或者精简版系统弹窗可能不出现那就得手动在防火墙高级设置里添加入站规则放开对应UDP端口。验证两台机器已经连上的另一个办法是ping但在某些禁用了ICMP的局域网里ping不通不代表TCP/UDP不通。更直接的做法是在服务端机器上用netstat -an查看UDP监听状态netstat -an | findstr 9000看到UDP 0.0.0.0:9000 *:*说明服务端已经把9000端口绑起来了。客户端发数据后这个条目不会显示连接状态因为UDP没有连接概念但只要服务端程序收到了数据并回显客户端窗口就会更新这是最可靠的验收标尺。4.3 抓包观测数据报在网线上真实长什么样联调通过后我建议你花十分钟抓个包看一下UDP报文结构。在服务端机器上装一个抓包工具过滤条件写udp.port9000然后让客户端发一条消息。你会看到客户端发出一个IPv4包IP头里Protocol字段是17UDP编号接着才是UDP头源端口、目标端口、长度、校验和四个字段各2字节。UDP头和IP头加起来整整28字节而这份Demo里你发送的“Hello UDP Server”只有18字节有效数据只占字节数的一半不到——这就是UDP低开销的含义相比TCP最少20字节的头部UDP做小报文交互时优势明显。抓包还能让你直观看到UDP的不保证顺序特性同时发多包时抓包列表里的顺序和发送顺序可能不一致。这个现象在局域网里不常见但在无线网络或跨运营商网络时很频繁。知道这一点你在设计上层协议时就得加序列号否则接收端拿到的数据顺序错乱会引发严重业务问题。这个回显Demo不需要序列号数据量小且单发单收不会乱序。5. 常见问题避坑UDP开发里最容易翻车的五个点5.1 sendto成功了但服务端就是收不到现象客户端sendto返回正常返回值等于发送字节数服务端没有任何打印抓包工具里也看不到数据包到达服务端网卡。原因三种情况最常见。目标IP写错给了服务端机器一个不存在或不可达的地址防火墙拦截了入站UDP包而且没有弹窗提示服务端绑定的端口和客户端发送的端口不一致。解决第一步在客户端机器上抓包看包是否从本机网卡出去了。如果出去了看目标IP和端口对不对。第二步到服务端机器上抓包如果包到了网卡但应用没收到查防火墙如果包根本没到查路由器ACL或中间防火墙规则。这套从本机出发逐跳排查的思路适用于所有UDP不通的场景。5.2 收包正常但打印出来是乱码现象服务端收到的recvBuf长度正确但printf打印出来是乱码或者中文变成了一堆菱形字符。原因VC6的printf默认按当前代码页解析字符串。工程里如果文件保存成UTF-8编码但系统代码页是GBKprintf输出的中文就会乱。另外接收端没有对 recvBuf[recvLen] 做截断处理打印时越界读了未初始化内存也会出现随机乱码。解决统一编码是根治方案。工程所有.CPP文件用ANSIGBK编码保存系统区域设置保持简体中文。接收端在 recvBuf[recvLen] \0 强制加结束符这个Demo里已经做了。如果你一定要在UTF-8和GBK之间转换用MultiByteToWideChar和WideCharToMultiByte手动转码不要指望printf自动处理。5.3 bind失败错误码10013或10048现象服务端运行时报bind失败WSAGetLastError()返回10013或10048。原因10048是端口被占用典型原因是上一个服务端进程没退干净Socket还占着端口。10013是权限不足在Windows上绑定小于1024的端口需要管理员权限VC6里如果直接双击运行exe不是以管理员身份启动的会报这个错。解决被杀掉残留进程后重启服务端。用netstat -ano | findstr 9000查到占用进程的PID然后在任务管理器里结束它。端口号建议直接在代码里改成9000以上避开特权端口或者右键exe用“以管理员身份运行”。工程里把端口写在代码开头宏定义处改起来很方便。5.4 多网卡机器上客户端收不到服务端回显现象服务端是多网卡服务器有内网IP和外网IP客户端从内网发的消息能被服务端收到但服务端回显客户端时客户端等不到。原因服务端bind用的是INADDR_ANY监听所有网卡收到消息后sendto回显的目标地址是客户端内网IP本身没问题。问题出在服务端发送回显时的源地址选择上——Windows路由表会根据目标地址选择出口网卡如果路由表错误地把内网目标路由到外网网卡回显包就会从错误的源IP发出去客户端收到后发现源IP不对直接丢弃。解决临时解法是在服务端加一条静态路由把内网网段强制指向内网网卡根治方案是把bind的IP从INADDR_ANY改成具体的内网网卡IP代码里把htonl(INADDR_ANY)替换成inet_addr(192.168.1.10)就可以。具体用哪个IP以ipconfig输出为准。5.5 recvfrom阻塞在非调试模式下退不出去现象客户端调recvfrom等待服务端回显但服务端异常崩溃了客户端一直卡在recvfromCtrlC都退不掉。原因recvfrom是阻塞调用UDP协议栈感知不到对端进程的消亡。服务端机器断电或进程被杀不会给客户端发任何通知。TCP有FIN包告诉对端连接关闭UDP没有这个概念。解决给recvfrom加接收超时用setsockopt设置SO_RCVTIMEO超时后函数返回SOCKET_ERROR错误码10060表示超时。下面的代码在超时后打印一条提示并退出循环int timeout 5000; setsockopt(udpSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)timeout, sizeof(timeout)); int recvLen recvfrom(udpSocket, recvBuf, sizeof(recvBuf), 0, (SOCKADDR*)serverAckAddr, addrLen); if (recvLen SOCKET_ERROR) { int err WSAGetLastError(); if (err WSAETIMEDOUT) { printf(等待回显超时服务端可能未运行\n); } }把超时设成5秒是一个保守值局域网内一个来回通常不超过10毫秒设5秒足够。如果做的是跨公网通讯可以放宽到10秒但不要更长——超时时间过长会让客户端卡死很久体验很差。6. 二次开发实战给UDP例子加上封帧协议和确认重传6.1 封帧协议解决UDP没有消息分组的问题原始的sendto和recvfrom虽然保留了消息边界但网络层会把大包分片实际收到的数据可能不完整。生产级的UDP通信首先要做封帧把业务数据外面包一层帧头。我一般用这样一个极简的帧结构#pragma pack(push, 1) typedef struct { uint16_t magic; // 魔数固定为0xA5A5用于校验帧合法性 uint16_t seq; // 序列号检测丢包和乱序 uint16_t length; // 数据长度不含帧头 uint8_t data[512]; // 业务数据 } UdpFrame; #pragma pack(pop)#pragma pack(push, 1)让结构体按1字节对齐避免编译器为了对齐插入空洞。魔数用于快速过滤垃圾包序列号用于检测丢包length字段告诉接收端本次有效数据多长。发送时填充这三个字段接收时先检查magic再按length取数据。6.2 确认重传机制简单可靠性的落地方案给UDP加可靠性最简单的做法是确认加重传也就是模仿TCP的ARQ机制。发送方发一条消息后启动5秒定时器等接收方回一个确认帧。超时没收到就重发最多重发3次。接收方在收完数据后把它携带的seq原样写进确认帧发回去即可。// 发送端 for (int retry 0; retry 3; retry) { int sendLen sendto(sock, (char*)frame, totalLen, 0, (SOCKADDR*)serverAddr, sizeof(serverAddr)); if (sendLen SOCKET_ERROR) { printf(第%d次发送失败\n, retry 1); continue; } // 等待确认SO_RCVTIMEO设成5秒 int recvLen recvfrom(sock, (char*)ackFrame, sizeof(ackFrame), 0, (SOCKADDR*)serverAddr, addrLen); if (recvLen 0 ackFrame.magic 0xA5A5) { if (ackFrame.seq frame.seq) { printf(第%d次发送后收到确认\n, retry 1); break; } } else { printf(确认超时准备重发\n); } }这套逻辑在局域网里基本能保证不丢包但它不是完美的可靠传输——它没有处理重复包接收方收到重复seq要自己过滤确认帧本身也可能丢失发送方会因此误判重发。要彻底解决需要给接收方维护一个已确认序列号的窗口工程量就大了。所以这里要清醒UDP加重传只能缓解丢包无法做到TCP级别的全序可靠流。如果你的场景需要强一致性、严格有序、面向连接直接用TCP是更合理的选择不必在这个Demo上强行打补丁。6.3 验证方法改造完成后怎么验收改造完的代码不能直接上线先跑三轮验证。第一轮用回环地址本地测确认单条消息收发、确认返回都正常。第二轮在服务端设一个丢包模拟器在recvfrom后随机丢弃部分数据验证客户端触发了重发且没有产生重复业务数据。第三轮用抓包工具统计UDP包收发数量确认重发造成的额外流量在可接受范围内——这个数据直接决定了这个可靠层能否支撑大流量并发如果重发率超过5%说明链路质量太差该上TCP了。每一轮验证后把发送端的seq、接收端的确认日志打印出来对照看序列号是否有缺口、重复或乱序。这套验证做完这个Demo就不只是一个例子而是一个可以直接嵌入业务逻辑的最小可靠传输模块。从那以后我每次上手一个Socket例子都强制先跑一遍回环测试再谈改业务代码。因为回环测试能过滤掉八成以上的编码低级错误剩下的联调时间全花在排查真正的网络问题上而不是和语法错误较劲。希望帮到你。本文还有配套的精品资源点击获取