网络编程入门的第一关十有八九是在 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 的不可靠。那是后话先把这个最小闭环跑起来。对比维度UDPTCP连接状态无连接sendto 直接发需要三次握手建立连接可靠性不保证送达、不保证顺序有确认、重传、排序机制传输开销头部 8 字节开销小头部 20 字节起步额外状态多消息边界保留消息边界一包一读字节流需要自行处理粘包典型应用音视频流、游戏同步、设备发现文件传输、网页、数据库连接1.2 工具与方案选型为什么用 VS2015 加 WinSock有人会问VS2015 都那么老了为什么不用最新版的 Visual Studio这里要厘清一个事实socket 编程代码本身跟编译器新不新几乎没有关系它调用的是操作系统提供的网络接口也就是 WinSock。WinSock全称 Windows Sockets是 Windows 系统自带的一套网络编程 APIVS2015 自带的 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.libWinSock 函数不像 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 表示数据报套接字也就是 UDPIPPROTO_UDP 进一步明确协议是 UDP。严格来说AF_INET 加 SOCK_DGRAM 时第三个参数传 0 也可以系统会自动推断为 UDP但我习惯写清楚免得别人看代码时还要猜测。接着构造目标地址用的是 sockaddr_in 结构体这一步是新手重灾区。sin_family 固定填 AF_INETsin_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 的值是 0htonl(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) 循环接收完一包继续等下一包直到用户按 CtrlC 强制终止进程。这是最简单的可靠模型。如果你想让程序收到特定消息后自动退出可以在循环里加一个判断比如收到字符串 “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_WARNINGSbind 返回 10048端口被占用换端口或先关闭占用该端口的程序sendto 返回 10022参数无效检查 socket 是否创建成功、sockaddr_in 是否初始化完整recvfrom 返回 10014fromLen 未初始化fromLen 调用前必须设为 sizeof(sockaddr_in)程序能发但收不到防火墙拦截、IP 配置错误、bind 绑了非 INADDR_ANY允许程序通过防火墙核对端口检查绑定地址5.3 防火墙与跨机器测试的注意事项本机回环测试几乎不会碰到防火墙问题但一换成局域网联调“程序能发但收不到”的现象就频繁出现。第一次完整运行接收端或发送端时Windows 会弹出一个防火墙授权对话框一定要选“允许访问”。如果手快点了取消这个程序以后所有网络通信都会被系统拦在门外而发送端因为 UDP 只管发包不会报任何错误排查起来相当迷惑。跨机测试还有几个刚性要求两台机器必须在同一个网段IP 地址要相互能 ping 通。目标 IP 写的是接收端机器的局域网地址不能还是 127.0.0.1127.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 作为第一个网络程序落地的最重要原因它足够简单简单到你敢动手改改完之后框架依然清晰。