ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Delphi UDP广播实现局域网设备发现与心跳超时机制

Delphi UDP广播实现局域网设备发现与心跳超时机制

简介:面向Delphi开发者与网络通信初学者的UDP广播通讯示例代码包,聚焦无连接数据报传输中的广播与单播两种模式,可用于理解低延迟、高效率的UDP通信机制及组件化实现方式。压缩包共111个文件,约7.47MB,以pas源码、dfm窗体、dcu/obj编译单元、exe与dll运行库为主,辅以txt说明、cfg配置和项目文件,便于按源码、可执行文件与依赖模块分层查阅。示例围绕TUDPClient、TUDPServer展开,涉及Broadcast属性、端口与主机设置、SendData发送、OnUDPRead接收事件,以及Memo日志记录和Try…Except异常处理,能帮助读者梳理广播包发送、单播定向接收与调试排错的完整思路。已有1105人学习,适合作为Delphi网络编程入门与课堂示例的参考素材。

1. 一个局域网设备发现需求:为什么这套示例值得照着做

一套内网设备管理工具,最容易被低估的模块不是界面,而是设备怎么被找到。某实验室做过一版巡检程序,最初用 TCP 逐个 IP 轮询,254 个地址扫一遍要十几秒,交换机上还堆着一批半开连接;后来改成 UDP 广播,发一帧不到 1 毫秒,在线设备自己报名字。改动的关键不在速度,而在于把「谁在线」这个问题从「我去挨个问」换成了「你主动报」。这份 Delphi 开发的 UDP 广播通讯示例,解决的就是这类需求:局域网内的设备发现、状态公告、配置下发、通知推送。它适合正在做网络通讯课程设计、毕设,或者要在工控内网工具里加一段轻量发现协议的人——代码量不大,但收发、编解码、网卡绑定、超时判定这几条链路是完整的。

2. 技术选型与协议设计:Indy 组件、报文结构和广播寻址的取舍

动手之前得先把三件事定下来:用什么库收发、报文长什么样、往哪个地址发。这三件事任何一件定错,后面调代码的时间会成倍增加。比较常见的失败情形是:代码跑起来了,本机收发正常,换一台机器就收不到——查了半天是广播地址选了全局广播,中间隔着三层设备;或者报文用竖线分隔字符串,设备名里恰好带了个竖线,解析直接错位。选型和格式在写第一行代码之前就该固定,下面的取舍来自几个实际交付过的内网项目。

2.1 Indy 的 UDP 组件与原生 Winsock 的取舍

Delphi 做 UDP 有两条路:一是 Indy 自带的TIdUDPClient/TIdUDPServer,二是直接调 Winsock 的WSAStartup、socket、bind、sendto、recvfrom。原生 Winsock 控制力最强,能精确控制 socket 选项、缓冲区大小、阻塞模式,但代价是每个项目都要重写一遍生命周期管理,而且select或完成端口的线程模型要自己搭。Indy 把这些包了一层,阻塞调用放在内部线程里,对外暴露事件回调,几十行就能跑通收发。

对比项Indy 组件原生 Winsock
上手成本低,属性赋值即可收发中,需管理 socket 生命周期
线程模型组件内置监听线程自建线程或 select 循环
多网卡绑定通过 Bindings 集合逐条添加每个 socket 单独 bind
广播开关TIdSocketHandle.Broadcastsetsockopt(SO_BROADCAST)
可控性中,部分细节被封装高,所有选项可调
调试难度低,事件里能直接看数据中,需要自己加日志

我一般选 Indy,理由是这套示例的定位是「可复用的最小完整链路」,不是网络底层研究。Indy 在 Windows 和 Linux 上的行为基本一致,同一份通讯单元可以直接编进控制台程序和 FMX 界面程序。要注意的地方有三个:TIdUDPServer的BufferSize默认只有 1024 字节,超过这个长度的报文会被截断,必须显式调大;OnUDPRead回调运行在 Indy 的接收线程里,里面绝对不能碰 VCL 控件;Bindings集合可以一次绑多张网卡,但广播发送时如果只绑了0.0.0.0,实际只会从默认路由那张网卡出去。这三点在后文的避坑章节还会展开。

2.2 自定义报文格式:魔数、版本、命令字与校验位的字段设计

很多人第一版会用设备名|IP|状态这种分隔符字符串,调试时肉眼看很方便,上线就出事:设备名里带分隔符、字段增减版本不兼容、半包截断后仍然当成合法数据解析。UDP 本身有报文边界,一次recvfrom就是一条完整报文,但长度仍要靠自己校验,否则遇到其他程序发到同一端口的杂包会直接崩在解析函数里。

我这套示例用的是定长头加变长载荷的二进制结构,头部 12 字节,后面跟载荷,末尾 2 字节 CRC16:

偏移长度字段说明
04Magic固定值,用于快速识别是否为本协议报文
41Version协议版本,升级时据此兼容旧客户端
51Cmd命令字,1 公告、2 心跳、3 配置下发、4 查询
62Seq序号,用于请求响应配对和去重
82PayloadLen载荷字节数,不含头尾
102Reserved保留,后续扩展字段位
12NPayload载荷,统一 UTF-8 编码
12+N2Crc16对头部除 CRC 外全部字段加载荷做校验

魔数的作用相当于给报文上一个黑匣子标签,收到杂包时第一道判断就把它挡掉,能省掉大量莫名其妙的解析异常。版本字段看着多余,但只要设备固件和上位机不是同一天升级,就一定用得上。PayloadLen是双保险:既校验报文是否被截断,也校验是否被追加了脏数据。载荷统一用 UTF-8,而不是依赖系统 ANSI 代码页,跨机器、跨系统才不会出现中文设备名变问号的情况。

type // 协议常量,改动时只需动这里 TUdpConst = class public const Magic: Cardinal = $4C414E31; // 'LAN1' const Ver: Byte = 1; const HeaderSize = 12; const CrcSize = 2; const MaxPayload = 1400; // 留足 MTU 余量,避免 IP 分片 end; // 解析后的内存结构,业务层只和它打交道 TUdpPacket = record Cmd: Byte; Seq: Word; Payload: TBytes; PeerIP: string; PeerPort: Integer; end; function DecodePacket(const AData: TIdBytes; const APeerIP: string; const APeerPort: Integer; out APacket: TUdpPacket): Boolean; var Len, PayloadLen: Integer; Magic: Cardinal; CalcCrc: Word; begin Result := False; Len := Length(AData); if Len < TUdpConst.HeaderSize + TUdpConst.CrcSize then Exit; // 太短,丢弃 Move(AData[0], Magic, SizeOf(Magic)); if Magic <> TUdpConst.Magic then Exit; // 非本协议报文 if AData[4] <> TUdpConst.Ver then Exit; // 版本不匹配 Move(AData[8], PayloadLen, SizeOf(PayloadLen)); if PayloadLen > TUdpConst.MaxPayload then Exit; // 超长,防越界 if Len <> TUdpConst.HeaderSize + PayloadLen + TUdpConst.CrcSize then Exit; Move(AData[Len - 2], CalcCrc, SizeOf(CalcCrc)); if CalcCrc <> Crc16(@AData[0], Len - 2) then Exit; // 校验失败 APacket.Cmd := AData[5]; Move(AData[6], APacket.Seq, SizeOf(APacket.Seq)); SetLength(APacket.Payload, PayloadLen); if PayloadLen > 0 then Move(AData[TUdpConst.HeaderSize], APacket.Payload[0], PayloadLen); APacket.PeerIP := APeerIP; APacket.PeerPort := APeerPort; Result := True; end;

这段解析函数把所有拒绝路径都放在最前面,只要有一项不满足就返回False,业务层永远拿不到半成品数据。MaxPayload限制在 1400 字节以内是为了避开 IP 分片,UDP 报文一旦分片,任意一片丢失整包就废,内网环境下这个限制几乎不影响功能,却能显著降低丢包后的排查难度。Seq字段在这个示例里只做简单自增,主要用途是让「查询—应答」能对得上号,顺带做重复报文过滤。

2.3 广播地址、端口与网段:全局广播和定向广播的适用边界

广播地址有两种写法。255.255.255.255是受限广播,只在本地链路内传播,不经过路由器;192.168.1.255这种是定向广播,由子网掩码算出来,理论上可以跨路由器,但绝大多数三层设备默认不转发。内网设备发现场景,两者都能用,区别在于多网卡机器上的行为。

一台机器同时插了办公网和设备网两张网卡时,往255.255.255.255发,报文只会从系统默认路由那张网卡出去,另一张网卡上的设备就永远收不到。正确做法是遍历所有活动网卡,按各自的 IP 和掩码算出定向广播地址,逐个发送。这套示例里封装了一个EnumBroadcastAddrs函数,返回TStringList,每项格式为网卡IP|广播地址,发送时按条目绑定源 IP 再发。

端口选择上,避开 1900(SSDP)、5353(mDNS)、137/138(NetBIOS)这些被系统服务占用的知名端口。示例用的是 39001,落在动态端口区间内,冲突概率低。接收端绑定0.0.0.0:39001监听所有网卡,发送端源端口填 0 让系统分配,这样同一台机器上开多个实例也不会互相抢端口。

// 遍历活动网卡,算出每个网段对应的定向广播地址 function EnumBroadcastAddrs: TStringList; var AddrList: TStringList; I: Integer; NicIP, Mask, Bcast: string; begin Result := TStringList.Create; AddrList := TStringList.Create; try GetIpAddrList(AddrList); // 封装好的本机 IPv4 列表,跳过 127.0.0.1 for I := 0 to AddrList.Count - 1 do begin NicIP := AddrList.Names[I]; Mask := AddrList.ValueFromIndex[I]; // 形如 255.255.255.0 if IsLoopback(NicIP) then Continue; Bcast := CalcBroadcast(NicIP, Mask); // 按位或取反,得到 x.x.x.255 Result.Add(NicIP + '|' + Bcast); end; finally AddrList.Free; end; end;

算出定向广播地址之后,发送时用TIdSocketHandle把源 IP 绑到对应网卡,就能保证报文从正确的物理网口出去。这一步多做几行,能省掉现场部署时「为什么这台机器只能发现一半设备」的整轮排查。

3. 总体架构与模块划分:四层结构与设备表的职责边界

广播通讯代码写到最后,最容易失控的不是协议本身,而是「谁在什么时候改设备列表」。收到报文在接收线程里,界面刷新在主线程里,定时器在另一个线程里,三边同时读写同一个TList,跑一天不出问题,跑一周必崩。架构设计要解决的核心就是这个:把通讯、解析、状态维护、界面呈现切开,让每个模块只做一件事,跨模块的交互只走一条明确的通道。

3.1 分层结构:界面层、通讯引擎、协议编解码、设备注册表

这套示例分成四层,层与层之间单向依赖,下层不知道上层的存在。

界面层负责显示设备列表、发起查询、下发配置。它不直接调用任何 socket API,只调用通讯引擎暴露的几个方法:Start、Stop、SendAnnounce、SendConfig,以及订阅一个OnDeviceChanged事件。通讯引擎是唯一持有TIdUDPServer的地方,负责绑定网卡、收发报文、把原始字节丢给编解码层。编解码层是纯函数集合,输入字节数组输出TUdpPacket,没有任何状态,可以单独写单元测试。设备注册表负责维护设备记录、心跳超时、状态迁移,是唯一有权修改设备数据的地方。

这样切的好处很直接:想换掉 Indy 改用原生 Winsock,只动通讯引擎一个单元;想改协议字段,只动编解码层;界面想换成 FMX 或者做成服务,其他三层原样复用。我在另一个项目里就是因为当初没分层,后来要从 VCL 迁到跨平台,通讯代码和窗体代码缠在一起,等于重写了一遍。

3.2 设备表的数据结构与状态机

设备表用TDictionary<string, TDeviceInfo>,键是设备唯一 ID,不是 IP。原因是 DHCP 环境下 IP 会变,用 IP 做主键会出现同一台设备在列表里出现两条记录的情况。设备 ID 由设备自己生成,写死在固件里或者取网卡 MAC 加盐哈希,报文里带上来。

type TDeviceState = (dsOnline, dsSuspect, dsOffline); TDeviceInfo = record DevID: string; // 设备唯一标识,来自报文载荷 DevName: string; // 展示名称 IP: string; // 最近一次报文来源 IP Port: Integer; LastSeen: TDateTime; // 最后一次收到心跳的时刻 FirstSeen: TDateTime; State: TDeviceState; MissCount: Integer; // 连续错过的心跳次数 end;

状态机的规则是这样的:收到任意报文,MissCount归零,状态置dsOnline,更新LastSeen。定时扫描每秒跑一次,如果Now - LastSeen超过 3 个心跳周期,置dsSuspect,界面上把该行标灰但仍然保留;超过 6 个周期置dsOffline,从活动列表移出,但记录保留一段时间用于统计。用三个状态而不是简单的在线/离线,是因为内网无线设备偶尔丢一两个包很正常,直接判离线会导致界面上设备反复闪烁,用「疑似」档缓冲一下,体验稳得多。

设备表的读写必须加锁。示例里用了一个TCriticalSection包住所有公开方法,读操作也走锁,因为TDictionary在遍历过程中被另一线程插入会直接抛异常。锁的粒度控制在「一次方法调用」级别,不在锁内做任何 IO 或界面刷新。

3.3 线程模型:接收线程、发送队列与主线程的交互

线程模型定成这样三条线:

第一条是 Indy 的接收线程,OnUDPRead回调在这里执行。它只做两件事:把AData、ABinding.PeerIP、ABinding.PeerPort打包成一个记录,塞进线程安全队列,然后立刻返回。回调里做任何耗时操作都会拖慢后续报文的接收,队列满了 Indy 会丢包。

第二条是解析线程,从队列里取记录,调DecodePacket,合法的交给设备注册表更新,非法的写日志。单独开一条线程是为了把解析开销从接收线程里剥离出去,尤其是 CRC 校验这种纯计算,放在接收线程里在报文密集时会影响吞吐。

第三条是主线程,靠TThread.Queue或者一个 100 毫秒的TTimer把「设备表变了」这个信号转成界面刷新。绝不在解析线程里直接操作TListView。

// 接收回调:只入队,不做任何业务处理 procedure TCommEngine.DoUDPRead(Sender: TObject; AData: TIdBytes; ABinding: TIdSocketHandle); var Item: PRecvItem; begin New(Item); Item.Data := Copy(AData, 0, Length(AData)); Item.PeerIP := ABinding.PeerIP; Item.PeerPort := ABinding.PeerPort; if not FRecvQueue.Push(Item) then // 队列有界,满了就丢弃并计数 begin Dispose(Item); InterlockedIncrement(FDropCount); end; end;

队列用的是有界环形队列,容量 1024 条,满了直接丢弃并累加计数器。宁可丢包也不能让内存无限增长,这是内网长时间运行的底线。丢包计数在调试面板上显示出来,现场排查时一眼就能看出是处理不过来还是根本没收到。

4. 关键实现:广播发送、接收解析与心跳维护的完整代码路径

前面定了结构和协议,这一章落到具体代码。四条链路:发得出去、收得进来、解得正确、判得准确。每条链路我都给出可以直接编译的片段,并说明参数为什么这么取、改的时候要注意什么。

4.1 广播发送:绑定网卡、开启 SO_BROADCAST 与发送节流

发送用TIdSocketHandle手工构造,而不是图省事用TIdUDPClient,原因是需要在bind之后、发送之前设置Broadcast属性,手工构造的时序最清楚。

// 向指定网卡的定向广播地址发送一帧 procedure TCommEngine.SendFrame(const ANicIP, ABcastAddr: string; const AFrame: TBytes); var Handle: TIdSocketHandle; Buf: TIdBytes; begin Handle := TIdSocketHandle.Create(nil); try Handle.IP := ANicIP; // 绑定源网卡,决定报文从哪个网口出去 Handle.Port := 0; // 源端口交给系统分配 Handle.AllocateSocket(Id_SOCK_DGRAM, Id_IPV4); Handle.Broadcast := True; // 关键:不开这个,发广播会报错 SetLength(Buf, Length(AFrame)); if Length(AFrame) > 0 then Move(AFrame[0], Buf[0], Length(AFrame)); Handle.SendTo(ABcastAddr, cBroadcastPort, Buf); finally Handle.Free; end; end;

Handle.IP这一行是整个函数里最重要的一行。填0.0.0.0时由系统选路,多网卡机器上会走默认路由那张卡;填具体网卡 IP 才能锁定出口。AllocateSocket的第一个参数指定数据报类型,第二个指定 IPv4,两个都不能省。Broadcast := True对应底层的SO_BROADCAST选项,不设置时向广播地址发送会直接抛 socket 异常,错误码通常是 10013。

发送节流方面,公告报文按需发送,心跳由设备侧主动上报,上位机只在启动时发一轮查询。如果业务需要周期性广播(比如每秒一次的服务公告),用一个TTimer控制节奏,不要在循环里连续SendTo,内网交换机对广播风暴的抑制阈值比想象中低,几十台设备同时每秒广播就可能触发端口限速。示例里的做法是公告只在启动和手动刷新时发一次,后续完全靠设备心跳维持,广播报文量压到最低。

4.2 接收与解析:从字节流到设备记录的完整链路

接收端的初始化要显式清空Bindings再逐条添加,因为设计期如果放过绑定,运行期会重复绑定导致端口占用。

procedure TCommEngine.Start; begin FUdpServer := TIdUDPServer.Create(nil); try FUdpServer.BufferSize := 8192; // 默认 1024,必须调大 FUdpServer.Bindings.Clear; with FUdpServer.Bindings.Add do begin IP := '0.0.0.0'; // 监听所有网卡 Port := cBroadcastPort; end; FUdpServer.OnUDPRead := DoUDPRead; FUdpServer.Active := True; except on E: Exception do LogError('UDP 监听启动失败: ' + E.Message); // 多半是端口被占用 end; end;

BufferSize必须调大,这一条踩过不止一次。默认 1024 时,如果载荷里带了较长的配置内容,报文会被静默截断,表现是解析函数一直返回False,日志里看不到任何异常,只有抓包才能发现收到的长度比发出去的短。设成 8192 对内存几乎没影响,却能把这类问题一次挡掉。

解析线程取出记录后调DecodePacket,成功则交给设备注册表:

procedure TParseThread.Execute; var Item: PRecvItem; Packet: TUdpPacket; begin while not Terminated do begin if FQueue.Pop(Item, 200) then // 200ms 超时,便于响应 Terminate begin try if DecodePacket(Item.Data, Item.PeerIP, Item.PeerPort, Packet) then FRegistry.UpdateFromPacket(Packet) // 内部加锁,线程安全 else LogDebug('丢弃非法报文,来源 ' + Item.PeerIP); finally Dispose(Item); end; end; end; end;

UpdateFromPacket里按命令字分派:公告报文解析出设备 ID 和名称,插入或更新记录;心跳报文只更新LastSeen和IP;查询报文触发一次应答;配置下发报文走确认回执。命令字分派用case而不是一堆if,新增命令时只加一个分支,不容易漏。

4.3 心跳、超时与设备离线判定

心跳周期定 2 秒,由设备侧定时上报。上位机这边每秒扫一次表,判断逻辑放在设备注册表里,不放在界面层。

procedure TDeviceRegistry.SweepTimeouts; var Pair: TPair<string, TDeviceInfo>; Elapsed: TDateTime; begin FLock.Enter; try for Pair in FDevices do begin Elapsed := Now - Pair.Value.LastSeen; if Elapsed > cOfflineThreshold then // 12 秒,6 个周期 begin if Pair.Value.State <> dsOffline then begin Pair.Value.State := dsOffline; FChanged := True; end; end else if Elapsed > cSuspectThreshold then // 6 秒,3 个周期 begin if Pair.Value.State <> dsSuspect then begin Pair.Value.State := dsSuspect; FChanged := True; end; end else if Pair.Value.State <> dsOnline then begin Pair.Value.State := dsOnline; FChanged := True; end; end; finally FLock.Leave; end; if FChanged then NotifyChanged; // 通过 TThread.Queue 抛到主线程 end;

阈值取 3 个和 6 个周期不是拍脑袋。3 个周期约 6 秒,足够覆盖内网偶发的两连丢;6 个周期 12 秒,是「基本可以认为掉线」的经验值。如果设备是通过无线接入的,可以把这个值再放宽到 4 和 8 个周期,代价是界面刷新滞后几秒,换来的是不会因为一次漫游切换就误报掉线。

NotifyChanged用TThread.Queue而不是Synchronize,前者不阻塞扫描线程,后者在界面卡顿时会拖住整个超时判定。

4.4 日志与调试开关:让现场问题可复现

日志分三级:错误、信息、调试。错误级只记录启动失败、端口占用、异常;信息级记录设备上下线、配置下发结果;调试级记录每一条收发的原始报文十六进制和长度。调试级默认关闭,通过配置文件或者一个隐藏的界面开关打开。

收发两侧都在入口处打日志,格式统一成时间 | 方向 | 来源或目标 | 长度 | 前 32 字节十六进制。这个格式的好处是抓包文件和日志能对得上,现场只要拿到日志和一份抓包,基本能还原整条链路。日志文件按天切分,单文件超过 10MB 滚动,避免长期运行把磁盘写满。

调试开关做成运行时可切换,不要做成编译期条件。现场排查时让运维人员在界面菜单里点一下就能开,比让客户重新编译一版程序现实得多。

5. 避坑与排查:多网卡、防火墙、粘包这些最容易翻车的地方

这一章列的五个问题,是我在几个内网项目里真实遇到过、并且每次都有人重新踩一遍的。写法统一按「现象 → 原因 → 解决」组织,现象描述尽量还原现场看到的样子,方便对照。

5.1 现象:本机自己收发正常,同网段其他机器一条都收不到

原因通常有三层。最外层是 Windows 防火墙,程序第一次运行时会弹一次网络访问询问,如果当时点了取消或者被策略静默拦截,之后就一直收不到入站报文,而本机回环不受影响,于是表现为「自测通过」。第二层是监听地址,如果接收端绑定的是具体网卡 IP 而不是0.0.0.0,换一台机器网卡地址不同,自然收不到。第三层是发送侧只往255.255.255.255发,多网卡时走错网口。

解决顺序:先在目标机器上netstat -ano | findstr 39001确认端口处于监听状态;再临时关闭防火墙复测一次,能通就说明是规则问题,给程序加了入站规则即可;如果仍不通,用抓包工具在目标机器上看有没有收到 UDP 报文,收到但解析失败说明是协议层问题,完全收不到才是网络层问题。发送侧一律改成按网卡 IP 算定向广播地址,逐个发送。

5.2 现象:发送时报 socket 异常,错误码 10013

原因是没有开启SO_BROADCAST。部分系统上向广播地址SendTo时,如果 socket 没有设置广播权限,会直接拒绝,错误信息通常是「访问权限不允许」。另一个容易混淆的点是:有的人开了广播权限但绑定的源 IP 是127.0.0.1,也会失败。

解决就是在AllocateSocket之后、SendTo之前设置Handle.Broadcast := True,顺序不能反。设置完可以再检查一次Handle.Broadcast的读值。如果用了封装过的组件,确认组件内部没有在Active := True之后重置 socket 选项。

5.3 现象:报文长度对不上,解析函数稳定返回 False

原因基本锁定在接收缓冲区。Indy 的TIdUDPServer.BufferSize默认 1024,超过部分被截断,日志里看到的长度比发送端记录的短。这个问题的隐蔽之处在于不抛异常、不报错,只是解析失败,很容易被误判成协议设计有问题。

解决是把BufferSize调到 8192 甚至 16384,同时在协议层把单帧载荷上限卡在 1400 字节,超过就分片成多帧发送,每帧带同一个序号和分片索引。这样即使某天有人往载荷里塞了大内容,也不会依赖 socket 缓冲区大小。

提示:调完缓冲区后,务必用一条接近上限的长报文做一次回归,别只测短报文。

5.4 现象:程序跑一段时间后界面卡死或者直接崩溃

原因是在OnUDPRead回调里直接更新了 VCL 控件,或者直接操作了没有加锁的设备列表。Indy 的回调在接收线程里执行,VCL 的TListView、TLabel都不是线程安全的,跨线程访问轻则界面错乱,重则访问违例。设备列表同理,一边遍历一边被插入,TDictionary会抛异常。

解决是把回调压缩成「入队」一个动作,业务处理和界面刷新都挪到各自的线程,跨线程通知统一走TThread.Queue。设备表所有公开方法加临界区,锁内不做 IO。改完之后用压力测试跑一遍:模拟 50 台设备每秒各发一帧,连续跑两小时,看内存和句柄数是否平稳。

5.5 现象:中文设备名显示成乱码,或者干脆解析失败

原因是编码不统一。发送端用系统 ANSI 代码页转字节,接收端按 UTF-8 解,中文必然花屏;更糟的情况是长度字段按字符数算而不是字节数,导致整个报文错位。另一种情况是有人用WideString直接Move进缓冲区,忽略了长度翻倍。

解决是协议层强制规定载荷统一 UTF-8,发送前用TEncoding.UTF8.GetBytes转,接收后用TEncoding.UTF8.GetString还原,长度字段一律按字节数填写并在编解码层做一次断言校验。测试用例里专门放一条带中文和特殊符号的设备名,每次改动协议都跑一遍。

6. 验收与答辩:从抓包到压力测试的完整清单

验收这件事,我一般按「能看见的证据」来组织,而不是口头说明功能正常。下面这张表是这套示例交付时用的验收清单,答辩时也可以照着讲。

验收项测试方法通过标准
单机收发本机开两个实例,一个发公告一个收接收端 1 秒内出现设备记录
跨机发现两台同网段机器,A 发 B 收B 的设备列表出现 A,IP 正确
多网卡三网卡机器,向两个网段各发一轮两个网段设备均被发现
非法报文用工具向端口发随机字节程序不崩溃,日志记录丢弃
截断报文手工发一条少了尾部的帧解析返回 False,不产生脏数据
心跳超时拔掉一台设备的网线6 秒内标为疑似,12 秒内标为离线
恢复上线重新插回网线一帧心跳后恢复在线
压力测试模拟 50 台设备每秒一帧,持续 2 小时CPU 平稳,无内存增长,无崩溃
抓包对照抓包工具过滤目标端口收发帧数与日志条数一致

抓包这一步不要省。答辩时如果能把一份抓包截图和程序日志并排放在一页上,说明「这一帧从 A 发出、B 收到、解析成哪条设备记录」,比讲十分钟设计思路都有说服力。过滤条件就是udp.port == 39001,选中一帧看十六进制,对照协议字段表逐个指出来:前四字节是魔数,第五字节是版本,第六字节是命令字。

压力测试要提前跑,不要等到答辩前一天。我吃过一次亏:功能测试全过,现场十几台设备一接上来,界面每秒刷新几十次,直接卡成幻灯片。后来把刷新改成 200 毫秒合并一次,问题才解决。所以验收清单里我固定加一条「模拟 N 台设备的持续上报」,N 取实际规模的 1.5 倍。

答辩时最容易被问的三个问题,提前准备好答案:为什么不用 TCP——设备发现阶段还不知道对方 IP 和端口,TCP 无从连接,且轮询开销大;广播丢包怎么办——发现阶段丢一帧只是晚一轮发现,心跳机制会补上,关键配置下发则用应答加三次重发保证;跨网段怎么办——广播不跨三层,跨网段场景要么部署中继,要么改用组播加三层转发配置,这套示例的定位是单网段内网,不硬撑。

从那以后我每次做这类通讯模块,都强制走一遍「抓包—日志—压力」三件套,哪怕需求再简单也不跳过,因为翻车永远发生在没测的那条路径上。这套示例的完整工程和说明放在下载里,协议字段和端口按自己的场景改一改就能用,希望帮到你。

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

返回列表