网络编程入门的第一关,十有八九是在 UDP socket 上。项目标题叫“VS2015使用C++创建基于UDP的socket程序”,听起来像教科书练习题,但它本质上回答两个最朴素的诉求:用 VS2015 写出能编译能跑的 C++ WinSock 代码,并且让两个程序真的把数据从一端送到另一端。这个项目能帮你把“socket 编程到底分几步”这件事彻底理清,从环境配置、协议选型,到收发两个方向完整跑通,每一步都看得见、跑得动。适合刚学完 C++、正想接触网络编程的新手,也适合急着在局域网内快速搭一个通信原型的开发者参考。
我见过不少人一上来就啃 TCP,结果被三次握手、粘包拆包、阻塞重传折腾到怀疑人生。UDP 这条路线清清爽爽:不建立连接、不维护状态、调用一次 sendto 就是一包数据出去,正好把注意力集中在 socket 本身。这篇文章我会把发送端和接收端拆开讲,给出完整可复制的代码,再把 VS2015 环境里常见的编译链接坑、联调排错经验一次说清楚。
1. 项目拆解:这个 UDP 程序到底在做什么
1.1 协议选型:为什么选 UDP 而不是 TCP
先解决一个绕不开的疑问:同样是写通信程序,为什么用 UDP?教科书上的标准答案是 UDP 无连接、不可靠、但快。听起来有点抽象,我用寄快递来打比方:TCP 像挂号信,有回执、能追踪、丢了重寄,流程严谨但额外开销大;UDP 像平信,扔进邮筒就不管了,快是快,但有没有送到、顺序对不对,全看运气。
实际开发里,UDP 并不是“次等方案”,而是某些场景下唯一合理的方案。实时游戏的位置同步、音视频推流、局域网设备发现,这些场景对延迟极其敏感,偶尔丢一帧画面、丢一个坐标,用户根本感知不到,但如果你用 TCP 去等重传、等排序,反而会让延迟飙升,体验更差。UDP 没有连接状态,一个 sendto 丢过去就算完事,代码里也少了一大半握手和收尾逻辑。
更重要的是,从学习角度来说,UDP 是理解 socket 全流程的最短路径。socket 要经历的“创建、绑定、发送、接收、关闭”几步,UDP 一个不落,但没有 TCP 那套 listen、accept、connect 的状态迁移,不至于让新手一上来就被术语淹没。先把 UDP 跑通,回头再看 TCP,你会发现思路完全能迁移,只是多了几个步骤。
当然,教程选 UDP 不代表实际项目里可以无脑用 UDP。真实系统经常是两者混用:控制指令走 TCP,实时媒体流走 UDP,甚至同一套业务里还要自己做超时重传来弥补 UDP 的不可靠。那是后话,先把这个最小闭环跑起来。
| 对比维度 | UDP | TCP |
|---|---|---|
| 连接状态 | 无连接,sendto 直接发 | 需要三次握手建立连接 |
| 可靠性 | 不保证送达、不保证顺序 | 有确认、重传、排序机制 |
| 传输开销 | 头部 8 字节,开销小 | 头部 20 字节起步,额外状态多 |
| 消息边界 | 保留消息边界,一包一读 | 字节流,需要自行处理粘包 |
| 典型应用 | 音视频流、游戏同步、设备发现 | 文件传输、网页、数据库连接 |
1.2 工具与方案选型:为什么用 VS2015 加 WinSock
有人会问,VS2015 都那么老了,为什么不用最新版的 Visual Studio?这里要厘清一个事实:socket 编程代码本身跟编译器新不新几乎没有关系,它调用的是操作系统提供的网络接口,也就是 WinSock。WinSock,全称 Windows Sockets,是 Windows 系统自带的一套网络编程 API,VS2015 自带的 SDK 里就有完整的头文件和库,不需要额外安装任何第三方包。
我推荐用系统 API 而不是引入 Boost.Asio、Poco 这类网络库来学这个项目,道理很简单:第三方库帮你封装好了细节,但也把底层过程遮住了。你调一个 async_send 确实方便,可一旦出了问题,你连“是 socket 没创建成功还是地址填错了”都分不清。用原生 WinSock API 亲手走一遍 WSAStartup、socket、bind、sendto、recvfrom,每一层调用究竟在做什么,编译器会逼你一五一十搞清楚。等项目跑通了,再考虑封装也不迟。
1.3 整体程序结构设计
这个项目我建议拆成两个独立的控制台程序,一个发送端,一个接收端。接收端先启动,绑定本地某个 UDP 端口,然后阻塞等待;发送端启动后,把一段字符串通过 UDP 发给目标 IP 和端口;接收端收到之后打印出来。先用本机回环地址 127.0.0.1 验证逻辑,确认无误后再把 IP 改成局域网里另一台机器,真正测一次跨机通信。
把收发拆成两个进程,是为了让 UDP 的“无连接”特性看得更直观。你不需要维护一条连接,接收端永远等在端口上,发送端随时过来发一包就走,发完就结束。如果非要用一个程序加两个线程来模拟,也可以,但进程之间切换更容易观察时序和状态,新手的理解成本更低。
2. 动手前先把 VS2015 的环境收拾利索
2.1 新建项目与控制台子系统
VS2015 新建项目的操作比较简单:文件 -> 新建 -> 项目 -> 选择 Win32 控制台应用程序,输入项目名后点下一步,在后续设置里勾上“空项目”,完成。空项目的好处是没有预编译头和一堆样板代码,Socket 学习阶段越干净越好,省得编译器报一堆跟你无关的错误。
这里第一次踩坑的机会就来了:项目类型必须是控制台应用程序,子系统保持默认的 CONSOLE,千万别手痒改成 WINDOWS。WINDOWS 子系统意味着程序不帮你创建控制台窗口,你的 printf 输出全都不知道飞哪去了,调试起来一头雾水。如果哪天在编译时看到 LNK2019 找不到 WinMain 的错误,八成就是这个设置被改坏了。
2.2 链接库:解决 ws2_32.lib
WinSock 函数不像 C 标准库那样自动链接,你需要在编译时告诉链接器去加载一个叫 ws2_32.lib 的系统库。这个库的名字是历史遗留,里面的 32 指的是位宽,跟你的系统是 32 位还是 64 位没有关系,64 位的 Windows 上同样用这个名字。
解决方式有两种。第一种是代码里直接加一行:
#pragma comment(lib, "ws2_32.lib")把这行放在源文件顶部,编译时会自动帮你链接。我强烈推荐这种方式,因为它是跟着源文件走的,项目换到别的电脑、新建其他工程,只要把代码复制过去,就不会忘。第二种是在项目属性 -> 链接器 -> 输入 -> 附加依赖项里手动填写 ws2_32.lib,效果一样,但每新建一个项目就要重新配置一次,容易漏。
2.3 头文件包含顺序与字符集问题
头文件的包含顺序是另一个经典坑。winsock2.h 必须出现在 windows.h 之前,或者说,最省事的办法就是干脆不要直接包含 windows.h。原因在于 winsock2.h 里重新定义了大量 socket 相关的结构体和函数,如果你先包含了 windows.h,它会去加载一份老旧的 winsock.h,新旧两份头文件一撞,编译器直接扔出一堆 C1189 致命错误。
字符集方面,VS2015 新建项目默认是 Unicode 字符集,这对本项目影响不大,socket 函数本身不依赖 TCHAR。但要注意,像 inet_addr、inet_pton 这类地址转换函数接收的是 char*,不是 wchar_t*。如果你在代码里用 TEXT("127.0.0.1") 这种宏,到这儿就会编译不过。现阶段我建议把字符集保持默认,代码里写普通的字符串字面量就够了。
3. 发送端代码:从 WSAStartup 到 sendto 的完整骨架
3.1 初始化:WSAStartup 到底在干嘛
每个 WinSock 程序,第一句有效调用基本必须是 WSAStartup,最后一句一定是 WSACleanup,这两头动都不能少。WSAStartup 的作用是初始化 WinSock 动态库,告诉系统“我要开始用网络功能了”,同时协商使用哪个版本的 API。
#define _WINSOCK_DEPRECATED_NO_WARNINGS #include <stdio.h> #include <string.h> #include <winsock2.h> #pragma comment(lib, "ws2_32.lib") int main() { WSADATA wsaData; int result = WSAStartup(MAKEWORD(2, 2), &wsaData); if (result != 0) { printf("WSAStartup failed: %d\n", result); return -1; } SOCKET sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (sock == INVALID_SOCKET) { printf("socket failed: %d\n", WSAGetLastError()); WSACleanup(); return -1; } sockaddr_in serverAddr; serverAddr.sin_family = AF_INET; serverAddr.sin_port = htons(9527); serverAddr.sin_addr.s_addr = inet_addr("127.0.0.1"); const char* msg = "hello udp socket"; int sendLen = (int)strlen(msg); int ret = sendto(sock, msg, sendLen, 0, (SOCKADDR*)&serverAddr, (int)sizeof(serverAddr)); if (ret == SOCKET_ERROR) { printf("sendto failed: %d\n", WSAGetLastError()); } else { printf("send %d bytes OK\n", ret); } closesocket(sock); WSACleanup(); return 0; }MAKEWORD(2, 2) 的意思是向系统请求 WinSock 2.2 版本,第二个参数 wsaData 是一个输出参数,用来接收系统实际返回的实现信息。如果返回值不是 0,说明版本协商失败或者初始化失败,这时候继续做任何 socket 调用都没有意义,直接退出。我见过有新手把 WSAStartup 放在 main 的一堆业务代码之后才调用,结果前面创建的 socket 一律是无效句柄,所以记住:这行必须排第一。
3.2 创建套接字与构造目标地址
创建套接字的代码是 socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)。三个参数分别说明:AF_INET 表示使用 IPv4 地址,SOCK_DGRAM 表示数据报套接字也就是 UDP,IPPROTO_UDP 进一步明确协议是 UDP。严格来说,AF_INET 加 SOCK_DGRAM 时第三个参数传 0 也可以,系统会自动推断为 UDP,但我习惯写清楚,免得别人看代码时还要猜测。
接着构造目标地址,用的是 sockaddr_in 结构体,这一步是新手重灾区。sin_family 固定填 AF_INET;sin_port 必须经过 htons 转换,把本机字节序的 9527 转成网络字节序;sin_addr.s_addr 用 inet_addr 函数把“127.0.0.1”这个点分十进制字符串转换成网络序整数。三个字段少写一个,程序往往还能编译,但运行时要么连接失败,要么数据发到完全无关的端口上。
3.3 sendto 发送与清理
sendto 的前三个参数分别是 socket、字符串指针、字符串长度。注意这里没有带结束符的 strlen,我传的是不含末尾空字符的长度,接收端收到多少字节就是多少。第四个参数 flags 暂时传 0 即可。后面两个参数是目标地址结构体指针和它的长度,因为 sendto 函数签名用的是通用的 SOCKADDR* 类型,所以需要把 sockaddr_in 指针强制转换过去。
sendto 的返回值是实际发送的字节数,如果失败会返回 SOCKET_ERROR,此时用 WSAGetLastError() 查具体错误码。有一点必须说清楚:UDP 是一种把数据交给系统就算成功的协议,sendto 返回成功,只代表数据已经离开你的进程,完全不代表对端真的收到了。这个认知如果没建立起来,后面联调排查会走很多弯路。
程序退出前,先 closesocket(sock) 关闭套接字,再 WSACleanup() 释放 WinSock 资源。顺序不能反,反了的话系统资源没释放干净,虽然这个进程退出后都会清理,但养成好习惯对后续复杂项目很重要。
4. 接收端代码:bind 与 recvfrom 的正确姿势
4.1 bind 的必要性与地址绑定
发送端没有 bind 也能跑,因为 sendto 的时候系统会给 socket 自动分配一个临时端口。但接收端必须 bind,原因很简单:你得告诉操作系统,“我要在 9527 这个端口上等数据,来包往这个 socket 上放”。
#define _WINSOCK_DEPRECATED_NO_WARNINGS #include <stdio.h> #include <winsock2.h> #pragma comment(lib, "ws2_32.lib") int main() { WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { printf("WSAStartup failed: %d\n", WSAGetLastError()); return -1; } SOCKET sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (sock == INVALID_SOCKET) { printf("socket failed: %d\n", WSAGetLastError()); WSACleanup(); return -1; } sockaddr_in localAddr; localAddr.sin_family = AF_INET; localAddr.sin_port = htons(9527); localAddr.sin_addr.s_addr = htonl(INADDR_ANY); if (::bind(sock, (SOCKADDR*)&localAddr, (int)sizeof(localAddr)) == SOCKET_ERROR) { printf("bind failed: %d\n", WSAGetLastError()); closesocket(sock); WSACleanup(); return -1; } printf("waiting for data on port 9527 ...\n"); char buf[1024]; sockaddr_in fromAddr; int fromLen = (int)sizeof(fromAddr); while (true) { int ret = recvfrom(sock, buf, sizeof(buf) - 1, 0, (SOCKADDR*)&fromAddr, &fromLen); if (ret == SOCKET_ERROR) { printf("recvfrom failed: %d\n", WSAGetLastError()); break; } buf[ret] = '\0'; printf("recv %d bytes from %s:%d : %s\n", ret, inet_ntoa(fromAddr.sin_addr), ntohs(fromAddr.sin_port), buf); } closesocket(sock); WSACleanup(); return 0; }由于 INADDR_ANY 的值是 0,htonl(INADDR_ANY) 转换之后还是 0,但这是规范写法。它的含义是:绑定本机所有网络接口。也就是说,不管是 127.0.0.1 还是局域网 IP 进到这台机器的数据包,只要端口是 9527,都能收到。如果你把这一行改成 inet_addr("127.0.0.1"),那只有本机回环流量能进来,局域网里的其他机器无论怎么发都不可能收到,这种问题藏在代码里非常隐蔽。
4.2 recvfrom 阻塞接收与来源地址解析
recvfrom 是阻塞调用,它返回的时候一定是有数据可读了。前三个参数好理解,缓冲区的大小我特意写成 sizeof(buf) - 1,多留一个字节给字符串结束符,防止 printf 打印时越界。接收完把 buf[ret] 置为 '\0',这就是把缓冲区当字符串处理的基本功。
后三个参数容易被忽略。fromAddr 是一个输出参数,函数返回后里面存放的是发送方的地址和端口。fromLen 是输入输出参数,调用之前必须初始化为 sizeof(fromAddr),函数返回后会被修改成实际写入的地址长度。我见过不少新手忘了初始化 fromLen,导致 recvfrom 直接返回 10014 错误,所以这句话值得记牢:传入的 fromLen 必须是一个指向 sockaddr_in 大小的整数的指针。
拿到 fromAddr 之后,用 inet_ntoa 把 32 位的网络序地址转回点分十进制的字符串访问,用 ntohs 把端口从网络字节序转回主机字节序。发送端用 htons,接收端用 ntohs,这两个函数是对应的,搞反了打出乱码别奇怪。
4.3 一个可循环收包的接收端
我写的接收端用了 while(true) 循环,接收完一包继续等下一包,直到用户按 Ctrl+C 强制终止进程。这是最简单的可靠模型。如果你想让程序收到特定消息后自动退出,可以在循环里加一个判断,比如收到字符串 “exit” 就 break,然后清理退出。这个扩展很容易,但对理解“程序生命周期”很有帮助。
另外一个实用细节:如果 recvfrom 返回失败,代码里直接 break 退出循环,但真实的网络程序往往会因为阻塞超时等原因偶发失败,直接退出并不合理。实际项目里更稳妥的做法是记录错误码,如果错误码表示“暂时无数据可读”,就 continue 继续等,而不是直接自杀。
5. UDP 联调测试与疑难杂症排查
5.1 本机联调的标准操作流程
拿到代码后,我建议的测试流程是:先启动接收端程序,看到 “waiting for data on port 9527 ...” 的输出,再启动发送端程序。虽然 UDP 无连接,理论上接收端后启动也能收到,但先接收端后发送端的固定顺序能帮你排除不少干扰因素。
然后观察接收端窗口是否打印出一行 “recv 13 bytes from 127.0.0.1:xxxxx : hello udp socket”,其中 xxxxx 是发送端自动分配的临时端口,每次运行可能不一样。看到这行,说明整个链路:初始化 -> 建 socket -> bind -> sendto -> recvfrom 已经完整跑通。这一步成功后,再改目标 IP 为局域网内另一台机器的地址,就进入真正的跨机通信验证了。
5.2 常见错误速查表
| 现象 | 常见原因 | 处理办法 |
|---|---|---|
| 编译报 LNK2019 unresolved external symbol | 没有链接 ws2_32.lib | 加 pragma comment 或属性配置附加依赖项 |
| 编译报 C1189 winsock.h 与 winsock2.h 冲突 | 头文件包含顺序不对 | 确保 winsock2.h 在 windows.h 之前 |
| C4996 警告 inet_addr 已弃用 | 安全函数告警 | 文件顶部定义 _WINSOCK_DEPRECATED_NO_WARNINGS |
| bind 返回 10048 | 端口被占用 | 换端口,或先关闭占用该端口的程序 |
| sendto 返回 10022 | 参数无效 | 检查 socket 是否创建成功、sockaddr_in 是否初始化完整 |
| recvfrom 返回 10014 | fromLen 未初始化 | fromLen 调用前必须设为 sizeof(sockaddr_in) |
| 程序能发但收不到 | 防火墙拦截、IP 配置错误、bind 绑了非 INADDR_ANY | 允许程序通过防火墙,核对端口,检查绑定地址 |
5.3 防火墙与跨机器测试的注意事项
本机回环测试几乎不会碰到防火墙问题,但一换成局域网联调,“程序能发但收不到”的现象就频繁出现。第一次完整运行接收端或发送端时,Windows 会弹出一个防火墙授权对话框,一定要选“允许访问”。如果手快点了取消,这个程序以后所有网络通信都会被系统拦在门外,而发送端因为 UDP 只管发包,不会报任何错误,排查起来相当迷惑。
跨机测试还有几个刚性要求:两台机器必须在同一个网段,IP 地址要相互能 ping 通。目标 IP 写的是接收端机器的局域网地址,不能还是 127.0.0.1,127.0.0.1 只在本机有效,指到另一台机器上去等于发给了空气。如果条件允许,先用命令行的 ping 工具确认双向可达再跑程序,能少排查一半问题。
如果怀疑丢包,可以用命令行 netstat -an 查看端口监听状态,或者在程序里给每个报文加序号,接收到后检查序号是否连续。UDP 在正常局域网下丢包率很低,但跨路由器或者 Wi-Fi 信号差的环境下丢个几包很正常,这是协议特性,不是代码逻辑错了。
6. 让 UDP 程序更实用的几个经验笔记
6.1 单包大小与分片问题
UDP 的单个数据报理论上限是 65507 字节(65535 减去 IPv4 和 UDP 的头部长度),但这个数字在真实网络里几乎用不到。以太网链路的 MTU 通常是 1500 字节,去掉 IP 头部 20 字节和 UDP 头部 8 字节,单包载荷如果超过 1472 字节,IP 层就会把这个数据报拆成多个分片传输。
分片是个大坑。因为 UDP 没有重传机制,只要分片里有一个丢了,整个数据报就宣告作废,应用层永远收不到。接收方只看到丢了一包,但这一包的实际影响是几百上千字节全部白费。很多新手拿 2KB、4KB 的包做测试,发现丢包率高得离谱,还以为是代码写错了,其实问题出在 MTU 分片上。局域网内开发,我建议单包载荷保持在 1472 字节以内;如果消息超过这个长度,要么在应用层拆包,要么为每个分片加序号重组,要么干脆换用流式协议。
6.2 非阻塞与 select 超时模型
阻塞模式的 recvfrom 有个很明显的问题:程序停在那等数据,如果对面一直不发,你连退出的机会都没有。简单方案是设置接收超时:
int timeout = 2000; // 毫秒 setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (const char*)&timeout, sizeof(timeout));这样 recvfrom 最多阻塞 2 秒,超时后返回 SOCKET_ERROR,错误码是 WSAETIMEDOUT。如果不想让超时影响那些真实的数据包判断,就这么处理:超时就 continue,不等于错误。
更通用的做法是 select 模型。select 能同时监听多个 socket 的可读状态,并给整个等待过程设置超时:
fd_set readSet; FD_ZERO(&readSet); FD_SET(sock, &readSet); timeval tv; tv.tv_sec = 1; tv.tv_usec = 0; int n = select(0, &readSet, NULL, NULL, &tv); if (n > 0 && FD_ISSET(sock, &readSet)) { // 此时再调 recvfrom,基本不会空等 }注意在 WinSock 环境下 select 的第一个参数可以传 0,它不像 Linux 那样要求传最大的文件描述符加一。这个模型的好处是,你可以在等待数据的同时干别的逻辑,还可以在这个模式上继续扩展出更复杂的消息处理循环,是入门到工程之间的必经一步。
6.3 广播、多线程收发与后续扩展
UDP 的一个独特能力是广播。在局域网里,发送端可以把目标 IP 设置成 255.255.255.255,这样同一网段所有设备都能收到这条数据。但使用广播前必须先打开 socket 的广播权限:
BOOL allow = TRUE; setsockopt(sock, SOL_SOCKET, SO_BROADCAST, (const char*)&allow, sizeof(allow));广播常用于局域网设备发现、协议自动配置这类场景。注意广播报文不会被路由器转发,而且在开了 AP 隔离的 Wi-Fi 环境下,客户端之间也可能收不到广播,排查时别忽略这一层。
如果后续你要做一个同时能收能发的程序,记住一个关键经验:WinSock 允许同一个 socket 在多个线程中分别执行 sendto 和 recvfrom,互不阻塞。常用模型是主线程处理用户输入并循环 sendto,另一个线程专门阻塞在 recvfrom 上,收到数据就打印或存入队列。这个模型在五六个 socket 规模下依然好使,不必急着上复杂框架。
我习惯把这个 UDP 收发模板存成一个独立的工程,以后遇到“先用 UDP 验证下思路”的需求,直接复制过去改几行就能用。等真正需要可靠传输时,再在这个模板上加上编号确认、超时重传,或者干脆把底层换成 TCP,业务逻辑的骨架几乎不用大动。这也是我建议你把 UDP 作为第一个网络程序落地的最重要原因:它足够简单,简单到你敢动手改,改完之后框架依然清晰。