ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

IPMSG源码解析:局域网无服务器聊天与文件传输协议实战

IPMSG源码解析:局域网无服务器聊天与文件传输协议实战

简介:IPMSG源码包围绕日本开发者Hajime Tanaka设计的局域网即时通讯协议展开,面向希望深入理解UDP通信机制与即时通讯系统实现的中高级网络编程学习者。源码完整呈现客户端与服务端两部分逻辑,涵盖消息头解析、认证与加解密、数据封装与解封装、多播广播收发、select/poll事件驱动以及超时重传等容错处理,并随包附带协议说明资料,便于对照代码理解交互流程。压缩包共358个文件、约22.8MB,以h头文件与c、cpp源文件为主体,另有rc资源、ico图标、manifest清单等界面相关文件,以及vcxproj、sln、dsp等工程配置,可直接用Visual Studio打开编译;附带的pdf、chm文档与readme说明可辅助阅读。目前已有498人学习。通过梳理目录结构与关键模块,读者能掌握UDP套接字编程、协议设计与跨平台兼容处理的实际写法,并借鉴其中的事件回调与错误恢复思路。

1. 一份二十年前的局域网聊天实现,为什么现在还有人翻它的源码

先说场景:断网的机房里、两排工控机之间、或者一台隔离网段的测试服务器上,要传个文件、发段文本,装不了任何服务端。IPMSG 源码就是为这种场合写的——纯对等的局域网消息与文件传输实现,UDP 和 TCP 都压在 2425 端口上,谁在线靠广播自己喊,不依赖任何中心节点。这份资源除了代码本体,还带了协议说明,这一点比代码本身更值钱:你能对着文档把每一个字节都对上,而不是靠猜。适合两类人,一类是想搞懂「无服务器自动发现」到底怎么落地,另一类是手上有个内网工具要加即时通讯能力、又死活不想引入服务端的。代码风格老、API 老,但协议层的设计一点不过时。

2. 协议层拆解:四个冒号定生死,一个 DWORD 塞两件事

啃这类老项目,我的习惯是先别看代码,先把协议文档读透,否则你会陷进一堆窗口过程和消息循环里出不来。IPMSG 的协议薄得可爱,核心就两条:一条文本行格式,一个 32 位命令字。把这两样吃透,剩下的都是工程实现。

2.1 包结构:一行文本,四个冒号切出五个字段

所有报文(不管走 UDP 还是 TCP)都是一个 ASCII 文本行,字段用冒号分隔:

版本号:包号:用户名:命令字:附加数据

五段的含义得一个个说清,因为后面所有 bug 都藏在这里:

字段说明常见翻车点
版本号十进制数字,经典版本发 1,带 UTF-8 扩展的版本发 2 或 3版本号对不上,对端按本地代码页解,直接乱码
包号发送方自增的序号,十进制用 16 位存,聊久了回绕,消息去重失效
用户名主机名或昵称昵称里带冒号,整包被拆错位
命令字8 位十六进制,大写,如00000020用atoi解析,00000020被当十进制
附加数据从第 4 个冒号之后一直到行尾正文里带冒号是正常的,不能再切

关键点在第 4 个冒号之后:附加数据是「剩下的全部」,里面允许出现冒号。消息正文写一句「注意:明天停电」、文件路径带盘符,都会往附加数据里塞冒号。所以解析只能按前四个冒号切,切完就停手。我见过太多实现直接上strtok(line, ":"),结果消息正文被截成「注意」,后面的词全丢了。

2.2 命令字与选项位:低 8 位是动作,高位是修饰

命令字是一个 32 位整数,但它同时装了两样东西:低 8 位是命令本身,高位是选项标志。这就是为什么SENDMSG是0x00000020,而「要求对方回执」是0x00000100,两者按位或起来就是0x00000120。

命令部分:

命令值走向作用
BR_ENTRY0x00000001UDP 广播我上线了
BR_EXIT0x00000002UDP 广播我下线了
ANSENTRY0x00000003UDP 单播对上线通告的应答
BR_ABSENCE0x00000004UDP 广播切换离开/忙碌状态
SENDMSG0x00000020UDP 单播或广播文本消息,带附件也走这条
RECVMSG0x00000021UDP 单播我收到了并且已经显示
READMSG0x00000030UDP 单播对方真的打开了这条消息
GETINFO0x00000040UDP 单播索取对方主机信息
GETFILEDATA0x00000060TCP请求文件数据
RELEASEFILES0x00000061TCP放弃接收
GETDIRFILES0x00000062TCP目录整体传输

选项部分:

选项值含义
SENDCHECKOPT0x00000100要求接收方回 RECVMSG
SECRETOPT0x00000200密封消息,需要口令才能看
BROADCASTOPT0x00000400广播消息
MULTICASTOPT0x00000800定向多播给一组人
AUTORETOPT0x00002000我离开时自动回复
PASSWORDOPT0x00008000携带口令
FILEATTACHOPT0x00400000附加数据后面挂了文件清单
UTF8OPT0x01000000附加数据按 UTF-8 编解码

提示:判命令一定要先做cmd & 0x000000FF,判选项必须用cmd & 0xFF000000这类掩码再比较,不能拿整个 32 位数跟常量做等号比较,否则一条带 FILEATTACHOPT 的 SENDMSG 会被你判成未知命令。

2.3 组包与解包:二十行代码,两个坑

协议层的活儿就集中在这两个函数上,我按典型实现整理了一版骨架,逻辑和多数版本是一致的:

/* 组包:版本、包号、用户名、命令字、附加数据 */ int build_packet(char *buf, int bufsz, unsigned ver, unsigned pktno, const char *user, unsigned long cmd, const char *extra) { char name[64]; int i = 0; /* 用户名里的冒号必须干掉,它是字段分隔符,混进去整包就错位 */ for (const char *p = user; *p && i < (int)sizeof(name) - 1; p++) name[i++] = (*p == ':') ? '_' : *p; name[i] = '\0'; /* 命令字用 8 位大写十六进制,别用 %d */ return snprintf(buf, bufsz, "%u:%u:%s:%08lX:%s", ver, pktno, name, cmd, extra ? extra : ""); }

snprintf的返回值是「本该写入的长度」,可能大于bufsz。如果返回值大于等于缓冲区大小,说明被截断了,这时候要整包丢掉,而不是照发不误——半截包发出去,对端解析出来的附加数据就是脏的。

/* 解包:只按前 4 个冒号切,剩下的原样交给 extra */ int parse_packet(char *line, packet_t *out) { char *p = line; for (int i = 0; i < 4; i++) { char *c = strchr(p, ':'); if (!c) return -1; /* 段数不够,直接判非法 */ *c = '\0'; switch (i) { case 0: out->version = (unsigned)atoi(p); break; case 1: out->pktno = (unsigned)strtoul(p, NULL, 10); break; case 2: snprintf(out->user, sizeof(out->user), "%s", p); break; case 3: out->cmd = strtoul(p, NULL, 16); break; /* 必须是 16 */ } p = c + 1; } out->extra = p; out->command = (unsigned)(out->cmd & 0x000000FF); out->options = (unsigned)(out->cmd & 0xFFFFFF00); return 0; }

两个坑写死在代码里了:第四段用strtoul(p, NULL, 16)而不是atoi,因为命令字是十六进制字符串;循环只走四次,走完p指向附加数据起点,正文里有多少冒号都不再动。至于文件附件,附加数据的组织方式是在正文之后用一个0x00分隔,后面挂若干条「文件 ID:文件名:大小:修改时间:属性」,条目之间用0x07隔开,属性值 1 是普通文件、2 是目录、3 是「返回上级目录」。这块各版本字段顺序略有出入,包里那份协议文档才是最终裁决,抓一次真实报文比对着看十分钟就通了。

3. 源码骨架:从 main 到广播线程,一条上线通知怎么走完全程

协议看完再看代码,你会发现整个程序就是一个「收包 → 查表 → 回包」的循环。真正难的不是逻辑,是老代码的组织方式和线程模型。

3.1 目录分层:协议、网络、会话、界面各归各

不同版本的目录命名有差异,但职责划分基本是这几块,对着看就行:

分层职责读代码时的切入点
协议层组包解包、命令字与选项常量、文件清单拼装找那个%08X的格式化字符串
网络层UDP socket 建立、广播发送、接收线程、TCP 监听找bind和2425
会话层在线用户表、超时淘汰、包号去重找那张成员表结构和加锁的地方
传输层TCP 收发循环、临时文件、偏移回填找GETFILEDATA的分支
界面层用户列表窗口、消息窗口、托盘只需知道它从哪拿数据,不用细读

读的顺序建议倒过来:先看会话层的成员表结构,你立刻能明白「在线」这个概念在这个项目里就是一个带最后活跃时间的数组项;然后回头看网络层怎么往这个表里塞人。界面层最后看,它只是消费者。

3.2 启动链路:main 里那几步初始化,顺序不能换

一个能收发的最小启动序列大致是这样,C 语言写的,Windows 上要先把WSAStartup补在最前面:

/* 1. UDP:绑定 2425,并且必须打开广播权限 */ int udp = socket(AF_INET, SOCK_DGRAM, 0); int on = 1; setsockopt(udp, SOL_SOCKET, SO_REUSEADDR, (char *)&on, sizeof(on)); setsockopt(udp, SOL_SOCKET, SO_BROADCAST, (char *)&on, sizeof(on)); struct sockaddr_in a; memset(&a, 0, sizeof(a)); a.sin_family = AF_INET; a.sin_port = htons(2425); a.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(udp, (struct sockaddr *)&a, sizeof(a)) != 0) { perror("udp bind 2425"); /* 端口被占,这里必须报错退出 */ return -1; } /* 2. TCP:同一个端口再监听一个,文件传输走这里 */ int tcp = socket(AF_INET, SOCK_STREAM, 0); setsockopt(tcp, SOL_SOCKET, SO_REUSEADDR, (char *)&on, sizeof(on)); bind(tcp, (struct sockaddr *)&a, sizeof(a)); listen(tcp, 8); /* 3. 起接收线程(UDP 和 TCP 各一个),再广播上线 */ start_thread(udp_recv_loop, (void *)(intptr_t)udp); start_thread(tcp_accept_loop, (void *)(intptr_t)tcp); send_broadcast(udp, IPMSG_BR_ENTRY, hostname); /* 我上线了 */

顺序有讲究:SO_BROADCAST要在sendto之前设好,忘了这一步sendto直接返回EACCES,而很多实现不检查返回值,于是程序看起来在跑、朋友列表永远空的。bind的返回值也别省,2425 是双方约定死的端口,被占了你连一个包都收不到,还会以为是协议写错了。

广播地址这里有个必须说的细节:255.255.255.255在单网卡机器上一把梭没问题,多网卡机器上系统只会挑一块网卡发出去,另一块网段的人永远收不到。稳妥做法是遍历本机网卡,按每块网卡的子网掩码算出定向广播地址(比如 192.168.1.255),逐块发一遍。

3.3 消息分发:一个 switch 就是整个业务

收包线程把数据读进缓冲区、交给parse_packet之后,业务逻辑全在这一个switch里:

switch (pkt.command) { case IPMSG_BR_ENTRY: member_add(pkt.user, from_ip); /* 别人上线,记进表 */ send_ansentry(pkt.user, from_ip); /* 关键:必须回一条,告诉他我也在 */ ui_refresh_members(); break; case IPMSG_ANSENTRY: member_add(pkt.user, from_ip); /* 别人应答我,记进表 */ ui_refresh_members(); break; case IPMSG_BR_EXIT: member_remove(pkt.user, from_ip); break; case IPMSG_SENDMSG: /* 对方要求回执才回,不要无脑回,否则会互相刷包 */ if (pkt.options & IPMSG_SENDCHECKOPT) send_recvmsg(pkt.pktno, from_ip); if (pkt.options & IPMSG_FILEATTACHOPT) handle_attach(&pkt, from_ip); /* 先落一份待选文件清单 */ ui_show_message(pkt.user, pkt.extra); break; case IPMSG_GETFILEDATA: tcp_send_file(pkt.extra, from_ip); /* extra 里是文件 ID */ break; default: break; /* 未知命令静默丢弃,别崩 */ }

这段代码里最重要的是BR_ENTRY那一支里的send_ansentry。IPMSG 的发现机制是「双向下线」:我广播上线,你收到之后必须单独回我一条ANSENTRY,我才能把你加进列表。只广播不应答,结果就是「我能看到所有人,所有人都看不到我」,排查起来特别费劲。另一个是default分支——加新命令的时候,老版本客户端收到不认识的值必须静默丢掉,你写个abort()或者弹窗,以后所有人升级节奏一乱就全崩。

4. 跑起来:编译参数、双实例联调与抓包验证

4.1 编译:Windows 工程与 Linux Makefile 两种接法

经典源码基本是 Windows 下、VC6 或 VS 早期版本的工程文件。用高版本 VS 直接打开,第一个迎面而来的是安全函数警告,strcpy、sprintf全被卡住。最快的路子是在工程属性里预定义_CRT_SECURE_NO_WARNINGS,或者干脆全局换成_s版本——先跑起来再说,别一上来就重构。

Linux 上要做的移植比想象中少,主要是四件事:WSAStartup/WSACleanup去掉;SOCKET换成int;closesocket换成close;SOCKADDR_IN换struct sockaddr_in。做个兼容头文件就行:

/* compat.h:一份头文件把两套 socket 捏到一起 */ #ifdef _WIN32 #include <winsock2.h> #pragma comment(lib, "ws2_32.lib") typedef SOCKET sock_t; #define close_sock(s) closesocket(s) #define SOCK_ERR SOCKET_ERROR #else #include <sys/socket.h> #include <arpa/inet.h> #include <unistd.h> #include <pthread.h> typedef int sock_t; #define close_sock(s) close(s) #define SOCK_ERR (-1) #endif

编译命令按下面这样走,-lpthread别漏,很多段错误就是线程函数没链接对:

# 先把协议层和网络层单独编出来,界面层可以先砍掉做命令行版 gcc -O2 -Wall -D_CRT_SECURE_NO_WARNINGS \ -o ipmsg-cli src/proto.c src/net.c src/member.c src/main.c \ -lpthread

注意:如果你只想要一个能收发消息的最小可运行版本,可以先把界面层的文件从编译列表里摘掉,把ui_show_message换成一个printf,把ui_refresh_members换成空函数。这样只剩协议和网络两部分,调试时干扰最少,出问题也一眼能定位到是谁的锅。

4.2 双实例联调:同机、双机、虚拟机三选一

这是最容易卡住新手的一步。同一台机器上起两个实例,收到的灾难通常是「两边都能发,谁也收不到谁」。

原因在广播。程序往255.255.255.255:2425发,这个包由操作系统处理,通常不会回到本机的回环接口上被另一个 socket 收到。所以同机双实例这条路,要么改代码把目标地址改成127.0.0.1做定向单播测试(这样能测通「收包 → 解析 → 应答」的整条链路),要么老老实实上两台机器。

我的推荐顺序是:先用127.0.0.1定向单播把协议层跑通,确认包能解、能回、列表能刷;再上虚拟机。虚拟机网络记得选桥接或者「仅主机」模式,NAT 模式下广播过不去,你会在那儿耗掉一下午。桥接模式下两台虚拟机会和宿主机在同一网段,广播和 2425 端口都通,是最接近真实使用场景的调试环境。

同机测试如果非要保留广播,也有个折中:把第二个实例的目标广播地址写成127.0.0.1,第一个实例监听在真实网卡上,两边用不同端口互相发,靠改端口错开。代价是代码里得留个端口配置项,反而更麻烦。

4.3 抓包验证:这是明文协议,看一眼胜过读十遍文档

这个协议最大的好处是完全明文,UDP 包体就是一行 ASCII。调试时根本不用猜,抓一次包什么都清楚了:

# 看 UDP 2425 上的文本报文,-A 直接把内容当文本打出来 sudo tcpdump -i any -n -s0 -A 'udp port 2425' # 文件传输的 TCP 流,存下来用 Wireshark 跟进具体是哪个块丢了 sudo tcpdump -i any -n 'tcp port 2425' -w file_transfer.pcap

典型的抓包输出长这样,一眼就能看出是谁上了线、回了什么:

1:1:DESKTOP-A:00000001:DESKTOP-A 1:1:DESKTOP-A:00000003: 1:2:DESKTOP-A:00000020:你好,收到请回复 1:2:DESKTOP-B:00000021:1

第一条是 A 广播上线,第二条是 B 回的上线应答,第三条是带内容的消息,第四条是对包号为 1 的RECVMSG回执(回执里带的是原包号,不是自己的包号——这里又是个坑)。

Wireshark 里过滤条件直接写udp.port == 2425,右键「Follow UDP Stream」就能看到一整个会话的完整往来。养成「先抓包再看代码」的习惯,能省掉一半的调试时间。

5. 常见问题排查:收不到人、乱码、文件传一半的五个坑

5.1 现象:好友列表永远是空的,广播发了但没反应

原因基本在三处。一是只处理了BR_ENTRY没回ANSENTRY,对方列表里没有你,这是设计如此,不是 bug。二是SO_BROADCAST没设或者设晚了,sendto返回-1,而代码没查返回值,静默失败。三是多网卡机器上广播全部从默认网卡出去了,另一个网段的对端根本收不到。

解决:sendto的返回值一定要判断,失败就打到日志里;广播前遍历网卡,按子网掩码算定向广播地址逐块发;收到BR_ENTRY无条件回一条ANSENTRY,别加什么「已经在列表里了就不回」的优化——对方的列表可能是空的。

5.2 现象:中文全是问号或者乱码,换台机器就正常

原因有两层。第一层是代码页:Windows 版按 GBK 收发,Linux 版按 UTF-8 收发,两边都没协商字符集,必然花。第二层是UTF8OPT(0x01000000)这个选项位——置位表示附加数据是 UTF-8,很多老实现压根不认这个位,收到也当本地编码解。

解决:收发两端统一走 UTF-8 并置上UTF8OPT;解包后先判这个位,置了就按 UTF-8 转,没置就按本地代码页转,别假定对方是什么编码;版本号也顺手对齐,版本 1 的报文别指望人家懂 UTF-8 扩展。

5.3 现象:消息只显示了前半句,冒号后面的内容全没了

原因就是用了strtok或者sscanf("%[^:]")这类按分隔符一刀切的写法。附加数据里出现冒号完全合法,正文里写「时间:明天上午十点」就交代了。

解决:只按前四个冒号切,切完停手,剩余部分整段作为extra指针往下传。同时组包时把用户名里的冒号替换掉,避免自己这边先埋雷。改完拿一条带三个冒号的测试消息验证一遍,别只测英文短句。

5.4 现象:文件传输卡在 0 字节,或者传到一半就断了

原因通常有三个。第一,GETFILEDATA的附加数据里应该放文件 ID,很多人顺手填了包号,服务端按 ID 去查文件清单查不到,直接关闭连接,所以是 0 字节。第二,接收方没有按偏移量往回填,而是顺序追加,网络乱序一来文件就废了。第三,防火墙只放行了 UDP 2425,TCP 2425 是另一个方向,被拦了。

解决:把文件 ID 和包号在代码里用不同类型或者不同命名区分开,别都用unsigned pktno混着传;落盘时用fseek(fp, offset, SEEK_SET)按偏移写,别fwrite完就完事;调试阶段先把本机防火墙关掉排除干扰,通了之后再加回放行规则。

5.5 现象:程序启动了,但一个包都收不到,也不报错

原因是bind失败但返回值没检查。2425 被另一个实例、同类软件、或者上一次没退干净的进程占着,bind返回错误,代码继续往下跑,创建了线程,界面也出来了,看起来一切正常,实际上根本没有 socket。

解决:bind、socket、listen三处全部检查返回值,失败就打印errno并退出;SO_REUSEADDR只管 TIME_WAIT 状态,抢不过正在运行的进程;排查时用ss -lunp | grep 2425和ss -ltnp | grep 2425分别确认 UDP、TCP 两个方向有没有被占。

6. 二次开发:加一条私有命令,并把兼容性护栏焊死

真要在这套代码上做改造,最实用的切入口是加一条私有命令。低 8 位还有大片空位,0x00000099这种基本不会撞上历史命令。改造分三步走:协议层加常量、分发里加分支、发送端加调用。

/* 1. 协议层:私有命令,只在自家客户端之间生效 */ #define IPMSG_MY_PING 0x00000099 /* 探测对端是否支持扩展 */ /* 2. 分发:不认识的命令静默丢弃,认识的才处理 */ case IPMSG_MY_PING: send_packet(from_ip, IPMSG_MY_PONG, my_caps_string()); /* 回能力位图 */ break; /* 3. 发送端:先探活再发扩展消息,探不到就退回纯文本 */ if (peer_supports(ip, CAP_MY_EXT)) send_packet(ip, IPMSG_MY_EXT_MSG, payload); else send_packet(ip, IPMSG_SENDMSG, fallback_text);

这里的关键是那个「先探活再发」的兜底。你的私有命令对老客户端来说是未知命令,它们大概率会静默丢弃,消息发出去就跟扔进黑洞一样,用户只会觉得「这个功能有时候好使有时候不好使」。带上能力探测之后,对端不支持就走兼容路径,行为可预期。

能力位图我一般用一个 32 位整数,每一位代表一个扩展能力,放在MY_PING的附加数据里回传。这样以后再加功能不用改探测逻辑,只加位就行。扩展消息的负载也建议用「长度前缀 + 数据」而不是纯文本追加,否则以后字段一多,你自己都会回到第 2 章那个「冒号地狱」里。

验证方法做一个两版本交叉矩阵:新版对旧版、旧版对新版、新版对新版,三个组合都要过一遍。每组检查四件事——列表能互见、纯文本能通、扩展功能在新新版生效、在混编时降级为纯文本且不报错。抓包比对一遍包体,确认没往老客户端发它不认识的东西。

从那以后我每次动协议层,都会先把 tcpdump 挂上去跑一遍三组交叉,哪怕只改了一个常量也照跑不误——这份代码看着简单,坑全在细节里,肉眼过一遍是真的靠不住。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进