C语言+Dev-C+++WinSock:UDP局域网消息收发与故障排查 局域网里两台机器互传一句话这个需求听起来简单得不像个项目但我见过太多人卡在第一步Dev-C 里敲完代码点编译报一堆undefined reference to WSAStartup8然后就没有然后了。C 语言本身不带网络功能Windows 上想发一个 UDP 包必须借 WinSock 这层壳而 Dev-C 这个老牌 IDE 又因为编译器版本杂、默认不链接系统库把坑叠了一层。这套组合——C 语言 Dev-C WinSock UDP 局域网 IP 直发——几乎是每个想入门网络编程的人都会撞上的第一道墙也是课程设计、小工具、内网通知器最常见的起步形态。我把这套东西完整走了一遍从环境配置到能稳定收发中文消息中间踩的坑比代码行数还多。下面这些内容适合三类人完全没碰过 socket 的新手可以照着抄出一份能跑的程序写过 TCP 但没细究过 UDP 的能补齐sockaddr_in、字节序、广播地址这些容易含糊的地方已经能发出去但收不到、或者换个网就失效的第五节的排查路径基本能对上号。1. 局域网传消息为什么我建议先拿 UDP 练手1.1 UDP 和 TCP 在这个场景下的真实差别同样是发消息TCP 要先connect、三次握手、确认对端在线才能开始传数据UDP 则是把数据塞进一个报文里写上目标 IP 和端口sendto一出函数立刻返回剩下的交给网卡和路由。这个差别决定了代码量一个能跑的 UDP 发送端核心逻辑不超过二十行同样功能的 TCP 版本得处理连接建立、send返回值不满、断线重连代码量翻三倍不止。放到局域网直发消息这个具体需求上UDP 的优势非常明显。第一是无需服务端常驻监听也能发你写个只有发送逻辑的小程序对方开着接收端就收没开就丢包程序不会卡住等握手。第二是天然支持一对多一个sendto打到广播地址网段内所有监听该端口的机器都能收到做上线通知屏幕广播这类功能时几乎零成本。第三是延迟低局域网内 RTT 通常在 1 毫秒以内UDP 省掉握手和确认实际体感就是按下回车对方屏幕上立刻出现。代价也要说清楚UDP 不保证送达不保证顺序不保证不重复。局域网环境丢包率极低但如果你的消息必须一条不差地到达比如传配置文件、传一段 JSON 指令那要么自己加确认重传要么老老实实换 TCP。我个人的判断标准是消息丢了不影响业务用 UDP丢一条就出问题用 TCP 或者自己在 UDP 上补一层。1.2 这套方案的边界能做什么做不了什么用 WinSock 的 UDP 在局域网内发消息能力边界其实很清晰。能做的是同一网段内比如都是 192.168.1.x两台或多台机器之间互发明文或二进制数据能指定目标 IP 精确投递能通过广播做服务发现能传几百字节到几 KB 的消息体。这些足够支撑一个内网聊天小工具、一个状态上报器、一个简易的远程指令台。做不到或者做起来很别扭的是跨网段、跨路由的通信广播包会被路由器直接拦掉穿透 NAT 或者跨公网那是完全另一个话题不在本文范围内大文件传输UDP 单个报文超过 MTU以太网一般 1500 字节就会被 IP 层分片任何一片丢了整个包就废了实际使用中建议把单条消息控制在 1400 字节以内。还有一点常被忽略UDP 不发心跳就没有在线概念对方进程崩了你这边完全无感除非自己设计超时机制。1.3 我在选型时真正权衡的三件事第一件是开发环境的现实约束。如果手头只有 Dev-C那就别折腾 CMake 和 vcpkg直接用 WinSock2 这套原生 API头文件winsock2.h系统自带链接库ws2_32也是系统自带不需要装任何第三方依赖编译出来是单文件 exe拷到别的机器上双击就能跑。这种零依赖在给别人演示或者交作业时价值极高。第二件是要不要引入线程。最朴素的实现是发送端和接收端分成两个程序一个只发一个只收逻辑最干净。但实际用起来你会发现谁都想既能发又能收。这时候就得面对并发问题recvfrom是阻塞的主线程一进去就出不来没法再读键盘输入。解决方案有两个方向——开一个接收线程或者用select做多路复用。我倾向于先写单发单收两个程序跑通确认链路没问题再合并成一体的这样出问题时能快速定位是发送端的事还是接收端的事。第三件是要不要上广播。精确 IP 直发的好处是可控坏处是每次换机器都得改代码里的 IP 常量或者传命令行参数。广播的好处是我不用知道对方 IP坏处是同一个网段里所有人都能收到而且很多公司网络和公共网络是禁广播的。我的做法是主流程走精确 IP另写一个可选的服务发现模块走广播两者分开互不影响。2. 环境准备让 Dev-C 真的能链接上 WinSock2.1 Dev-C 的版本选择与新建项目Dev-C 是个老 IDE但不同发行版差别不小。原版 Bloodshed Dev-C 早就停止维护现在常见的是 Orwell 维护的 Dev-C 5.11 以及后续分支。这些版本默认带的编译器主要是 TDM-GCC 和 MinGW-w64 的 32 位版本两者在 WinSock 上的表现基本一致但要注意一个隐藏问题32 位编译器和 64 位编译器的库搜索路径不同如果你在 64 位系统上装了 64 位的 Dev-C却按老教程去拷 32 位的 lib 文件会一直报找不到库。新建项目时选Console Application语言选 C 或 C 都行。我个人建议直接建 C 项目源文件后缀用.c这样编译器会按 C 标准来不会因为你写了个struct sockaddr_in addr;后面没加分号还给你报 C 的错误提示排查信息更直观。项目建好后先别急着写网络代码写个printf(hello\n);编译运行一次确认工具链本身是通的——这一步能帮你排掉一半环境问题。2.2 链接 ws2_32 库的两种做法这里有个大坑WinSock 的函数实现不在 C 运行库里而在系统 DLL 对应的导入库ws2_32.libGCC 下叫libws2_32.a中。不链接这个库编译器能过链接器必炸。网上教程最常给的是这一行#pragma comment(lib, ws2_32.lib)这行是 MSVC 的语法作用是让编译器把链接指令写进目标文件的一个特殊段里链接器读到了就自动加库。MinGW/GCC 对这个 pragma 的支持是不可靠的很多 Dev-C 自带的 GCC 版本会直接忽略它结果就是你在代码里写了这一行照样报undefined reference to WSAStartup8然后怀疑人生。我的做法是两者都用上代码里保留#pragma comment图个方便同时一定在项目属性里手动加链接参数。操作路径是菜单栏项目 → 项目属性 → 参数在链接器那一栏加上-lws2_32如果你习惯用命令行编译等价的写法是gcc main.c -o main.exe -lws2_32 -fexec-charsetGBK注意-lws2_32一定要放在源文件后面链接器对参数顺序是敏感的放在前面会找不到符号引用。这个细节坑过不少人尤其是习惯了把编译选项全堆在最前面的人。注意Dev-C 的项目属性里有编译器和链接器两栏文本框库参数写错栏位不会报错只会静默失效。加完之后点确定再重新全部重新编译不要只点编译否则旧的 .o 文件会被复用。2.3 最小验证程序先确认 WinSock 能用在写任何业务逻辑之前先跑一个只做初始化和清理的空壳用来确认头文件、库、编译器三者都对上了#include stdio.h #include winsock2.h int main(void) { WSADATA wsaData; int ret; ret WSAStartup(MAKEWORD(2, 2), wsaData); if (ret ! 0) { printf(WSAStartup failed, code %d\n, ret); return 1; } printf(WinSock version requested: 2.2\n); printf(WinSock version got: %d.%d\n, LOBYTE(wsaData.wVersion), HIBYTE(wsaData.wVersion)); WSACleanup(); return 0; }编译运行后应该看到版本号 2.2。如果这里就报WSAStartup未定义那百分之百是链接参数没生效回头检查 2.2 节。如果运行时报WSAStartup failed, code 10092说明这个系统不支持请求的版本一般是MAKEWORD参数写错了回去看是不是写成了MAKEWORD(2, 2)之外的值。2.4 字符集、编码与 32/64 位这三个隐性变量先说编码。Dev-C 新建的源文件默认编码跟你系统区域设置有关中文 Windows 下常是 GBK但也可能是 UTF-8。如果你在文件里写了中文提示词编译出来控制台显示乱码问题就出在这。两种解决办法一是把源文件另存为 ANSI 编码二是保持 UTF-8 但在main开头加一句SetConsoleOutputCP(65001);把控制台切到 UTF-8。我倾向于前者改起来最省事也不会影响strlen对字符串长度的判断。再说位数。SOCKET类型在 32 位和 64 位下大小不同前者 4 字节无符号整型后者是 8 字节的UINT_PTR如果你不小心把它当int来打印或者比较INVALID_SOCKET的判断可能失效。永远用SOCKET类型声明套接字变量永远用INVALID_SOCKET做失败判断永远用WSAGetLastError()取错误码这三条守住能避开九成类型相关的诡异问题。最后提醒一个细节printf输出SOCKET值时不要用%d用%llu或者转型成unsigned long long否则 64 位下会打出截断的数字让你误以为 socket 创建失败了。3. 核心 API 拆解一个 UDP 报文是怎么发出网卡的3.1 WSAStartup 与 WSACleanup 的配对逻辑Windows 的网络功能不是天生就绪的WSAStartup的作用是让进程加载 WinSock 的 DLL、协商版本、初始化内部数据结构。它必须在任何其他 WinSock 函数之前调用包括socket。参数第一个是版本号用MAKEWORD(主版本, 次版本)拼出来MAKEWORD(2, 2)表示请求 2.2 版第二个参数是指向WSADATA的指针函数成功后会往里面填实际加载的版本信息。WSACleanup则是反向操作通知系统本进程不再使用 WinSock递减内部引用计数。规则很简单每个成功的WSAStartup对应一次WSACleanup而且要在所有套接字都closesocket之后调用。我见过有代码在main里WSAStartup之后直接在中间某个错误分支return漏掉了清理短时间跑没事但如果程序是个常驻进程、反复初始化和退出资源泄漏会累积。实际写法上我习惯把初始化和清理包成两个小函数并且给WSAStartup加一个重试逻辑因为极少数情况下系统刚启动、网络栈还没就绪时会返回失败int net_init(void) { WSADATA wsaData; int ret WSAStartup(MAKEWORD(2, 2), wsaData); if (ret ! 0) { printf(WSAStartup failed: %d\n, ret); return -1; } if (LOBYTE(wsaData.wVersion) ! 2 || HIBYTE(wsaData.wVersion) ! 2) { printf(WinSock 2.2 not supported\n); WSACleanup(); return -1; } return 0; }那个版本二次校验很有必要。WSAStartup只要找到任意可用版本就返回成功但它可能给你一个 1.1 的版本而 1.1 里没有getaddrinfo这类函数后面用到就崩了。校验一下能提前发现问题。3.2 socket() 的三个参数到底怎么填socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)这三个参数新手最容易含糊的是第三个。AF_INET表示 IPv4 地址族。用 IPv6 就是AF_INET6两套 API 逻辑一样但结构体不同初学阶段先别混。SOCK_DGRAM表示数据报套接字也就是 UDP。SOCK_STREAM是 TCP。第三个参数理论上可以填 0让系统根据前两个自动推断显式写IPPROTO_UDP更清楚也能避免某些平台上 0 被推断成别的协议。返回值是SOCKET类型失败返回INVALID_SOCKET。注意这个宏的值不是 0而是(SOCKET)(~0)所以千万不要写if (s 0)来判断失败。正确的判断和错误处理是这样的SOCKET s socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (s INVALID_SOCKET) { printf(socket() failed: %d\n, WSAGetLastError()); WSACleanup(); return -1; }WSAGetLastError()是 WinSock 版的errno每个失败调用后立刻取中间不要插入别的 WinSock 调用否则错误码会被覆盖。3.3 sockaddr_in 与字节序最容易写反的地方UDP 发送需要告诉系统发给谁这个信息装在struct sockaddr_in里。它的三个关键字段是字段含义赋值方式常见错误sin_family地址族固定写AF_INET写成PF_INET多数情况等价但不规范sin_port目标端口htons(端口号)直接写数字导致端口错乱sin_addr目标 IPinet_addr(192.168.1.100)不做返回值检查端口必须用htons转成网络字节序因为 x86 是小端网络传输规定用大端。举个具体例子端口 9000 的十六进制是0x2328小端存进内存是28 23直接发出去对方解析成0x2823即 10275完全对不上。htons就是干这个翻转的h是 hostn是 networks是 short16 位。IP 地址用inet_addr转换它内部已经处理了字节序不需要再套htonl。用inet_addr有个坑它失败时返回INADDR_NONE0xFFFFFFFF而255.255.255.255这个广播地址转换后恰好也是0xFFFFFFFF无法区分。如果你要发广播就得换用inet_ptonstruct in_addr addr; if (inet_pton(AF_INET, 255.255.255.255, addr) ! 1) { printf(bad ip\n); }inet_pton在 Vista 之后的系统上可用MinGW 的部分版本需要定义_WIN32_WINNT大于等于 0x0600 才有声明。这也是个常见报错来源遇到隐式声明警告就是这个原因。3.4 sendto 与 recvfrom 的参数语义sendto的签名是sendto(s, buf, len, flags, to, tolen)。前四个跟send一样重点是后两个目标地址结构体指针和它的长度。这里的长度必须写sizeof(struct sockaddr_in)不要写实际用了几个字节。返回值是实际发送的字节数失败返回SOCKET_ERROR即 -1。很多人不检查返回值结果消息太大被拒WSAEMSGSIZE也毫无察觉。判断逻辑应该是int n sendto(s, msg, (int)strlen(msg), 0, (struct sockaddr *)addr, sizeof(addr)); if (n SOCKET_ERROR) { printf(sendto failed: %d\n, WSAGetLastError()); } else if (n ! (int)strlen(msg)) { printf(partial send: %d of %d\n, n, (int)strlen(msg)); }接收端的recvfrom(s, buf, len, flags, from, fromlen)多了两个输出参数from用来回填发送方地址fromlen是值-结果参数——调用前你要把它设成缓冲区大小函数返回后它变成实际写入的地址结构长度。这一条如果不初始化recvfrom会返回WSAEFAULT报错误的地址让人一头雾水。struct sockaddr_in from; int fromlen sizeof(from); int n recvfrom(s, buf, sizeof(buf) - 1, 0, (struct sockaddr *)from, fromlen); if (n 0) { buf[n] \0; printf(from %s:%d - %s\n, inet_ntoa(from.sin_addr), ntohs(from.sin_port), buf); }注意buf[n] \0这一句recvfrom不会帮你补字符串结束符缓冲区里残留的旧数据会让打印结果后面拖一串垃圾。还有ntohs别忘了from.sin_port是网络字节序直接打印是错乱的数字。3.5 端口和 IP 该怎么选这是个纯经验问题没有标准答案但有些约定俗成的选择能省事端口避开 1024 以下需要管理员权限或者会被系统服务占用避开 3389、445、135 这些系统端口建议用 8000-65535 之间的随机值。我常用 9000 和 9001 做测试好记冲突概率低。发送端绑定可以不bind系统在第一次sendto时自动分配一个临时端口。这样最省事但如果你希望对方能回复你就必须bind一个固定端口否则对方不知道往哪回。接收端绑定 IP用INADDR_ANY值为 0表示监听本机所有网卡。如果你明确只想收某张网卡的包才填具体 IP。绝大多数情况下填INADDR_ANY。本机 IP 怎么查在命令行执行ipconfig找IPv4 地址通常是192.168.x.x或者10.x.x.x。要确认两台机器在同一个网段看子网掩码255.255.255.0 意味着前三段相同才算同网段。4. 动手实现一份能双向收发中文消息的完整代码4.1 发送端支持命令行传 IP 和端口发送端我不写死 IP而是从命令行参数读这样同一个 exe 可以发给不同的人用#include stdio.h #include string.h #include winsock2.h #include ws2tcpip.h #pragma comment(lib, ws2_32.lib) static int send_udp(const char *ip, unsigned short port, const char *msg) { SOCKET s; struct sockaddr_in to; int n, ok -1; s socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (s INVALID_SOCKET) { printf(socket failed: %d\n, WSAGetLastError()); return -1; } memset(to, 0, sizeof(to)); to.sin_family AF_INET; to.sin_port htons(port); to.sin_addr.s_addr inet_addr(ip); if (to.sin_addr.s_addr INADDR_NONE) { printf(invalid ip: %s\n, ip); closesocket(s); return -1; } n sendto(s, msg, (int)strlen(msg), 0, (struct sockaddr *)to, sizeof(to)); if (n SOCKET_ERROR) { printf(sendto failed: %d\n, WSAGetLastError()); } else { printf(sent %d bytes to %s:%u\n, n, ip, port); ok 0; } closesocket(s); return ok; } int main(int argc, char *argv[]) { const char *ip 127.0.0.1; unsigned short port 9000; char line[1024]; if (net_init() ! 0) return 1; if (argc 2) ip argv[1]; if (argc 3) port (unsigned short)atoi(argv[2]); while (1) { printf( ); if (fgets(line, sizeof(line), stdin) NULL) break; line[strcspn(line, \r\n)] \0; if (strcmp(line, quit) 0) break; if (line[0] \0) continue; send_udp(ip, port, line); } WSACleanup(); return 0; }这里有个细节值得说每次send_udp都新建再关闭套接字。对于发消息这种低频场景完全够用代码也最干净。但在高频发送时比如每秒几百条反复创建销毁套接字会带来明显的开销这时候应该把socket()提到循环外面全局只建一次。我在做状态上报器时就吃过这个亏改成单例后 CPU 占用肉眼可见地降下来。line[strcspn(line, \r\n)] \0;这句是去掉fgets留下的换行符。Windows 下回车是\r\n两个字节用strcspn找第一个出现的换行字符位置置零比手写循环判断更简洁也不会漏掉孤立的\r。4.2 接收端bind 是绕不过去的一步接收端必须bind一个端口否则系统不知道把收到的包交给谁#include stdio.h #include string.h #include winsock2.h #include ws2tcpip.h #pragma comment(lib, ws2_32.lib) int main(int argc, char *argv[]) { SOCKET s; struct sockaddr_in local, from; char buf[2048]; unsigned short port 9000; int fromlen, n; if (net_init() ! 0) return 1; if (argc 2) port (unsigned short)atoi(argv[1]); s socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (s INVALID_SOCKET) { printf(socket failed: %d\n, WSAGetLastError()); WSACleanup(); return 1; } memset(local, 0, sizeof(local)); local.sin_family AF_INET; local.sin_port htons(port); local.sin_addr.s_addr htonl(INADDR_ANY); if (bind(s, (struct sockaddr *)local, sizeof(local)) SOCKET_ERROR) { printf(bind failed: %d\n, WSAGetLastError()); closesocket(s); WSACleanup(); return 1; } printf(listening on udp port %u ...\n, port); while (1) { fromlen sizeof(from); n recvfrom(s, buf, sizeof(buf) - 1, 0, (struct sockaddr *)from, fromlen); if (n SOCKET_ERROR) { printf(recvfrom failed: %d\n, WSAGetLastError()); continue; } buf[n] \0; printf([%s:%u] %s\n, inet_ntoa(from.sin_addr), ntohs(from.sin_port), buf); } closesocket(s); WSACleanup(); return 0; }local.sin_addr.s_addr htonl(INADDR_ANY);这里的htonl不能省。虽然INADDR_ANY的值是 0大端小端都是 0加不加看起来没区别但保持这个习惯能让你在写具体 IP 时不忘记转换少犯一次错。recvfrom的循环里我把错误处理写成了continue而不是return。原因是 UDP 的recvfrom在某些情况下会返回错误比如对方发来的包校验和出错、或者缓冲区太小这些都不应该让整个接收端退出。只有WSAEINTR这类信号中断错误可以忽略其他的打个日志继续跑就行。4.3 合成一体用 select 实现边发边收单发单收跑通之后自然想做成一个程序既能发又能收。最直接的想法是开线程但在 Dev-C 里配线程库要额外加参数对新手不友好。用select更轻量它能在同一个线程里同时等待有数据可读和标准输入有内容fd_set readfds; struct timeval tv; while (1) { FD_ZERO(readfds); FD_SET(s, readfds); tv.tv_sec 0; tv.tv_usec 200000; /* 200ms 超时避免死等 */ int r select(0, readfds, NULL, NULL, tv); if (r SOCKET_ERROR) { printf(select failed: %d\n, WSAGetLastError()); break; } else if (r 0) { /* 超时去检查键盘输入 */ } else if (FD_ISSET(s, readfds)) { /* 有数据可读走 recvfrom */ } }select的第一个参数在 Windows 上是被忽略的填 0 即可这点和 Linux 不同别照搬。超时时间是我反复调出来的设成 0 会变成忙轮询把 CPU 占满设成 1 秒以上又会明显感觉输入卡顿。200 毫秒是个平衡点按下回车到消息发出去之间的延迟人感觉不到CPU 占用也很低。另外select只能告诉你有数据可读真正读还得靠recvfrom。如果同时来了多个包select返回一次但你循环里只读一次剩下的会留在内核缓冲区下次select立刻返回。所以正常做法是读完一次后不睡直接再进下一轮判断。4.4 编译运行与实测记录编译命令命令行方式gcc sender.c -o sender.exe -lws2_32 -O2 gcc receiver.c -o receiver.exe -lws2_32 -O2实测环境是两台同一网段的机器。接收端在 A 机上开receiver.exe 9000输出listening on udp port 9000 ...。发送端在 B 机上执行sender.exe 192.168.1.100 9000然后输入测试消息回车A 机控制台立刻出现[192.168.1.101:52344] 测试消息。从按下回车到显示体感在 10 毫秒以内。测试中文时遇到过一次乱码两边都按 GBK 存源文件、控制台也是 GBK按理不该有问题最后发现是发送端用strlen算长度时正好但接收端buf[n] \0之后printf用的是%s没问题——真正的原因是 A 机控制台设成了 UTF-8 代码页。改回默认代码页后正常。这件事提醒我跨机器调试时控制台编码也要对齐不能只对齐源文件。4.5 打包分发时的两个小处理一个是给 exe 加个图标和版本信息用 Dev-C 的项目属性可以配但对调试没帮助我一般留到最后做。另一个更实用在接收端启动时打印本机所有网卡的 IP这样对方问我该发给哪个 IP的时候你直接让他看屏幕就行。用gethostname加gethostbyname能列出所有地址char host[256]; struct hostent *ph; if (gethostname(host, sizeof(host)) 0) { ph gethostbyname(host); for (int i 0; ph ph-h_addr_list[i]; i) { struct in_addr a; memcpy(a, ph-h_addr_list[i], 4); printf(local ip: %s\n, inet_ntoa(a)); } }gethostbyname在新版里被标记为过时推荐用getaddrinfo但前者在 MinGW 下兼容性更好代码也短用在启动时打印一下完全够用。注意结果里会混进127.0.0.1和虚拟网卡的地址需要人工筛选——这一步没法完全自动化多网卡环境下哪个才是能通的只有你自己知道。5. 局域网发不出去的八个故障与排查路径5.1 bind 失败与端口占用bind返回SOCKET_ERRORWSAGetLastError()是10048WSAEADDRINUSE意思是端口被占了。最常见的原因是上一次运行的接收端进程没退干净——你可能直接关了控制台窗口但进程还在后台。解决办法是打开任务管理器按名字找或者换个端口试。要验证是不是占用可以在命令行跑netstat -ano | findstr 9000最后一列是占用进程的 PID。还有一种情况是端口被系统保留。Windows 有个动态端口范围服务端程序绑定时如果正好撞上也会失败。用netsh int ipv4 show excludedportrange protocoludp能看到被排除的区间选端口时避开就行。提示调试阶段可以在bind之前加setsockopt(s, SOL_SOCKET, SO_REUSEADDR, ...)能让多个进程绑同一端口UDP 下行为是复用而非独占。但这个选项在某些场景下会掩盖真正的端口冲突问题上线前建议去掉。5.2 防火墙第一次运行必弹的框Windows 防火墙对入站连接默认拦截。你的接收端第一次启动时系统会弹一个Windows 安全警报对话框问你允不允许这个程序通信。很多人手快点了取消之后就再也收不到包而且因为不再弹框根本不知道问题在哪。判断方法很简单发送端sendto返回成功但接收端什么都没收到八成是防火墙。验证方式是临时把接收端和发送端都放行控制面板 → Windows Defender 防火墙 → 允许应用通过防火墙或者干脆在测试时临时关掉专用网络的防火墙。注意只关专用网络别动公用网络公用网络那个开关影响面太大。还有一层容易被忽略如果你换了端口、换了可执行文件名防火墙规则可能不匹配需要重新授权。所以测试阶段尽量固定端口和 exe 名字别频繁改。5.3 广播地址算错想发广播却发不出去先检查两件事套接字有没有开广播权限目标地址算对没有。开权限的代码是int on 1; setsockopt(s, SOL_SOCKET, SO_BROADCAST, (char *)on, sizeof(on));不开这个选项往广播地址sendto会直接失败错误码10013WSAEACCES权限被拒。广播地址怎么算把 IP 和子网掩码按位取反后做或运算。以192.168.1.100、掩码255.255.255.0为例掩码取反是0.0.0.255与 IP 相或得到192.168.1.255这就是定向广播地址。用255.255.255.255是受限广播只在本地链路有效且很多路由器不转发。优先用定向广播地址成功率更高。目标地址写法类型适用范围常见问题192.168.1.255定向广播指定网段需要正确计算掩码255.255.255.255受限广播本地链路部分网络设备会丢弃192.168.1.100单播精确投递IP 写错或对方离线224.0.0.x组播需加入组必须IP_ADD_MEMBERSHIP5.4 IP 选错多网卡与虚拟网卡的陷阱一台机器上装虚拟机、装抓包工具之后会有好几张虚拟网卡每张都有自己的 IP。用ipconfig能看到一长串192.168.1.x可能是物理网卡也可能是虚拟网卡的地址。如果你把消息发到了虚拟网卡的 IP本机的接收端能收到别的机器完全收不到。区分办法看ipconfig里网卡的名称和描述物理网卡一般带厂商名虚拟网卡会写 VMware、Hyper-V 之类。实在分不清就逐个试或者让接收端打印所有网卡地址见 4.5 节让对方发到每一个上面看哪个能通。还有一个容易混的点127.0.0.1是回环地址只能本机自己发自己收发给它永远到不了别的机器。测试阶段用回环验证收发逻辑没问题但验证完一定要换成真实 IP 再测一遍别把回环通了当成局域网通了。5.5 收不到的长包与缓冲区溢出如果短消息能收到长消息收不到或者被截断问题在缓冲区。recvfrom的第四个参数是缓冲区大小超过这个长度的报文会被截断多出来的部分直接丢弃而且函数不会告诉你丢了。Windows 下recvfrom返回的是截断后的长度看起来成功了实际上数据不全。排查方式是打印n和strlen(buf)对比或者干脆把缓冲区开大一点。我一般用 2048 字节起步因为要留空间给中文和其他字段。但别无限开大recvfrom的缓冲区在栈上的话4KB 以上就有栈溢出风险超过这个量级改用malloc堆分配。5.6 故障速查表把上面这些整理成一张表出问题时按表走比漫无目的地改代码高效得多现象最可能的原因快速验证处理编译报undefined reference to WSAStartup没链接ws2_32看项目属性链接器参数加-lws2_32WSAStartup返回 10092版本号写错检查MAKEWORD参数改成(2,2)bind返回 10048端口被占netstat -ano查端口换端口或杀进程bind返回 10013端口权限不足端口小于 1024换高位端口sendto成功但对端收不到防火墙拦截临时关专用网防火墙添加放行规则sendto返回 10013发广播未开权限检查有无SO_BROADCAST加setsockopt收到但中文乱码控制台或源文件编码不一致对照两端代码页统一成 GBK 或都设 UTF-8长消息截断接收缓冲区太小打印n与缓冲区大小扩大缓冲区或用堆分配程序收完一条就卡住recvfrom只调用一次看循环结构加while(1)6. 把这套东西用得更好从能发到好用6.1 在 UDP 上补一层轻量可靠UDP 不可靠但局域网丢包率极低极少丢和保证不丢之间加的东西可以很少。我的做法是在消息体前面加 8 个字节的头2 字节序号、2 字节总片数、2 字节当前片号、2 字节校验。接收端维护一个已收序号的位图超时没收到就发一个重传请求的单播包回去。这套机制的代码量大约五十行收益是长消息的完整率从看运气变成基本确定。实现要点是序号必须用htons转字节序接收端用ntohs转回来这一点和端口处理完全一致别因为是自定义协议就忘了。另外重传次数要有上限我设的是 3 次超过就放弃并报错否则网络真断了会无限重传。6.2 用广播做局域网内的自动发现每次手动问对方 IP 太麻烦可以让每台机器启动时往广播地址发一个我上线了的包包里带自己的标识和端口其他机器收到就记下来。这样后续发消息直接用记住的 IP不用再问。具体做法是接收端在bind之后另开一个套接字发广播同时在主循环里收到广播包时解析出源 IPrecvfrom的from参数里就有不用额外字段。这里有个细节——自己的广播自己也会收到网卡回环所以要在包内容里放一个唯一标识收到自己的就跳过否则会把自己加进通讯录。6.3 多播替代广播的适用场景广播的缺点是全网段所有人都收到别人的程序如果也监听同一端口就会串。多播组播能缓解这个问题只有主动加入同一个组播组的程序才会收到。代码上多两步一是用IP_ADD_MEMBERSHIP加入组struct ip_mreq mreq; mreq.imr_multiaddr.s_addr inet_addr(239.255.0.1); mreq.imr_interface.s_addr htonl(INADDR_ANY); setsockopt(s, IPPROTO_IP, IP_ADD_MEMBERSHIP, (char *)mreq, sizeof(mreq));二是发送时把目标地址写成组播地址。组播地址的范围是224.0.0.0到239.255.255.255其中224.0.0.x是本地链路保留的建议自己用239.x.x.x段。不过说实话在只有几台机器的家里或小办公室环境广播完全够用多播的额外复杂度不一定值得。我一般是在网络里存在别人的程序、端口冲突频繁时才切多播。6.4 发文件时的分片策略UDP 发文件不是不能做但要严格分片。单包负载控制在 1400 字节以内留 100 字节给 IP 和 UDP 头确保不超过 1500 的 MTU每片带片号和总片数接收端按片号重组缺片就请求重传。这个方案在局域网内传几 MB 的文件完全可行速度比 TCP 慢一些但代码更简单。真正麻烦的是接收端乱序。UDP 不保证顺序第 5 片可能比第 3 片先到。所以接收端不能按到达顺序追加必须用数组按片号存全部到齐后再合并。另外要设一个总超时超过 30 秒还没收齐就整体放弃否则内存里会积压一堆半截的缓冲区。我个人对文件传输的建议还是用 TCPUDP 那套重传逻辑写起来容易出边界 bug调试成本远高于收益。UDP 更适合传状态和指令这类小而短的数据。7. 几个让我印象深刻的实操教训第一次跑通的时候我信心满满地把接收端发给同一间办公室的同事结果他那边一直收不到。排查了两个小时最后发现是他在自己机器上也开了接收端端口被占了bind失败但错误码被我们的代码吞掉了。从那以后我在所有bind和socket后面都加了明确的错误打印宁可日志啰嗦一点也不要让失败静默。第二个教训跟closesocket有关。早期版本我在退出流程里写了WSACleanup()但忘了closesocket(s)单个程序跑没问题后来做成常驻进程反复启停发现端口偶尔会短时间无法重新绑定。查下来是套接字没关句柄还挂着。正确的顺序永远是先closesocket所有套接字再WSACleanup反过来会出各种奇怪的状态残留。第三个是中文长度的问题。我用strlen算发送长度时一直是对的但有一次改成从文本框读内容文本框里的内容带了个宽字符strlen算出的长度比实际字节数少导致最后一个字被截断。后来统一改成用strlen只处理char数组绝不在wchar_t上用它问题再没出现过。最后一个小技巧把接收端做成开机自启 日志文件兜底比盯着控制台可靠得多。控制台窗口一旦被误关消息就全丢了写日志的话第二天还能翻记录。日志文件记得按天切割不然跑一个月能写出几百 MB。