资讯详情

Delphi UDP通信实战:设备发现、线程安全与多网卡排错

📅 2026/11/2 9:44:42 | 华诺云谱 👁 阅读
Delphi UDP通信实战:设备发现、线程安全与多网卡排错
1. 为什么我在Delphi项目里优先考虑UDP前几年接手一个设备巡检类上位机现场有几十台采集终端需要在上位机上一键扫出当前网段里在线的设备。第一版我用TCP写的挨个IP建连接超时设1秒一次扫一个C段要等两分多钟界面直接卡成假死。后来改成UDP广播加异步收包整个扫描过程压到三秒以内代码量还少了一半。这个经历基本定下了我的选型习惯——Delphi实现UDP通信这件事真正的价值不在协议本身有多高深而在于它特别适合短、平、快、散的数据交互场景尤其是局域网内的小报文收发、设备发现、状态心跳和日志上报。这篇文章面向的是手里有Delphi工具链、正在做桌面端或工控上位机、需要跟一堆硬件或服务端进程打交道的开发者。如果你只是想知道TIdUDPClient怎么Send翻官方帮助更省事我更想聊的是那些文档里不会写、但真正决定你程序能不能在现场跑稳的东西绑定地址到底该填什么、OnUDPRead跑在哪个线程、多大的包会静默丢掉、广播为什么在本机收不到而换台机器就正常。这些坑我基本都踩过一遍有些还踩过两遍。Delphi在这类需求上有个天然优势它既能直接调Indy这种成熟的封装库一行代码搞定收发又能随时退回原生WinSock去抠SO_RCVBUF、SO_REUSEADDR这些底层开关。这种上得去下得来的弹性在做现场维护型软件时特别值钱——客户机环境千奇百怪你永远不知道什么时候需要绕过封装层自己动手。1.1 UDP在Delphi上位机里的真实位置很多人一上来就把UDP当成不可靠的TCP来理解这个认知其实带偏了方向。UDP更像是明信片你写好了投进邮筒邮局不保证送达、不保证顺序、也不保证对方收到的是完整一张。听起来很糟但反过来看它没有握手、没有确认、没有重传、没有连接状态发一包的代价极低而且天然支持一对多。工控现场大量场景就是我喊一嗓子谁在线谁答应一声这种情况下你根本不需要连接状态TCP那套三次握手反而是纯开销。具体到Delphi能干的活我大致分四类。第一类是设备发现与网段扫描上面提到的场景就是典型广播一个探询包各设备回复自身标识。第二类是周期心跳与状态上报终端每隔几百毫秒发一个几十字节的状态包丢一两个完全无所谓下一个周期就补上了。第三类是实时性要求高于可靠性的控制指令比如某些按键触发、光标位置同步旧的丢了就丢了重要的是最新的那条要快。第四类是日志与埋点投递本地程序产生的事件流发到一个中心进程接收端丢几条不影响整体分析但要求发送端绝对不能因为网络问题卡住主线程。反过来有几类活我绝对不会用UDP碰文件传输、数据库写入指令、需要严格顺序的业务流程、单包超过MTU还要求必达的配置下发。这几种情况一旦丢包排查成本会成倍上升还不如老老实实用TCP或者干脆走本地进程间通信。分清这条界线比学会几个API重要得多。1.2 Indy还是原生WinSock两条路的取舍Delphi生态里做UDP主流就两条路。一条是Indy自带的TIdUDPClient和TIdUDPServer这是绝大多数人的起点也是我日常使用频率最高的方案。它的好处非常直接组件化、事件驱动、跨平台、编码转换有现成函数接收数据直接以TIdBytes给你还附带BytesToString这类工具方法二三十行就能跑通一个完整的收发闭环。另一条是直接用Winapi.Winsock2调socket、bind、sendto、recvfrom。这条路代码量至少翻三倍但换来的是完全的控制权。什么时候我会退到这一层两种情况。一是需要精细控制socket选项比如想调大SO_RCVBUF防止突发流量丢包或者必须设置SO_REUSEADDR让多个进程监听同一端口做负载分担Indy虽然提供了TIdSocketHandle.SetSockOpt但事件时序上有些选项设置得太晚就不生效。二是需要做阻塞式收包配合自定义线程模型Indy的监听线程有它自己的调度逻辑某些极端场景下你希望完全自己掌控recvfrom什么时候返回。我的建议很明确先用Indy把功能跑通确认业务逻辑没问题之后再评估要不要下沉到底层。绝大多数项目根本不需要下沉Indy的性能足以应付每秒几千个数据报的量级。我见过不少同行一上来就手撸WinSock结果卡在htons忘了加、WSAStartup忘记调用或者AddrLen没赋初值这类基础问题上白白耗掉两天。先跑通再优化这个顺序别搞反。提示Indy的不同版本在TIdBytes和TBytes的类型定义、编码函数的名称上有细微差异。如果你拿到的是别人几年前的老工程先确认Indy版本再照搬代码否则会撞上一堆类型不匹配的编译错误。1.3 先想清楚边界什么活UDP干不了在动手写第一行代码之前有个问题必须先回答清楚这条数据的丢失业务上能不能接受如果答案是不能那就别用UDP或者至少要在应用层自己补一套确认与重传机制。很多人对应用层可靠传输想得太简单以为加个序号加个ACK就完事了实际要处理的是重传定时器、重复包去重、乱序重组、拥塞退避一整套东西工作量比直接用TCP大得多可靠性还不如。还有个容易被忽略的点UDP没有连接意味着发送端永远不知道对方是不是还活着。你SendBuffer返回成功只代表数据报交给了协议栈不代表对方收到了更不代表对方处理了。所以凡是需要知道对方状态的地方就得自己在应用层加心跳和超时判定。我的习惯是给每个设备维护一个最后活跃时间戳超过三个心跳周期没收到包就标灰界面上直接显示离线比等TCP的OnDisconnect及时得多。最后还有个隐性成本UDP的调试难度高于TCP。TCP有连接状态、有重传计数、用抓包工具看得很清楚哪一步出了问题UDP在抓包工具里就是一条条孤立的数据报丢在哪里往往只能靠日志推断。所以在做UDP模块时我会在收发两端都加详细日志把时间戳、来源地址、报文长度、报文前几十字节的十六进制全部打出来。这套日志在后期排查时能救命。2. 核心机制拆解UDP通信里最容易翻车的四个点写UDP代码API层面的门槛其实很低真正让人栽跟头的是四个概念性问题地址绑定到底绑的是什么、数据报有没有粘包、回调跑在哪个线程、字符串怎么变成字节。这四个点只要有一个理解偏了程序在开发机上跑得欢到现场就各种诡异现象。我逐个拆。2.1 端口、地址与绑定语义绑定这个词听起来抽象用生活场景类比就很清楚端口相当于你家的门牌号绑定地址相当于你站在屋里哪个位置应门。绑定0.0.0.0INADDR_ANY意味着所有网卡上的这个端口我都接绑定192.168.1.50意味着只有发往这个网卡这个端口的包我才接绑定127.0.0.1则意味着只有本机自己发来的包我接外面的包一律不理。新手最常犯的错误就是把接收端绑到了127.0.0.1然后在本机用同一个地址测试收发一切正常部署到局域网另一台机器上发过来一个包都收不到。因为包确实是到了这台机器的网卡上但目标地址不是回环地址绑在回环上的socket自然收不到。这个坑我在早期项目里踩过排查了大半天最后用抓包工具才看明白包已经到了、只是没人认领。端口的选择也有讲究。低于1024的端口在部分系统上需要提权尽量别碰。5000以内有不少被系统服务占用的常见端口也建议避开。我个人习惯用8000到9999这个区间冲突概率低又便于记忆。不过更重要的是端口被占用时的行为默认情况下第二次bind同一端口会失败如果业务上确实需要多个进程监听同一端口比如做灰度升级时新旧版本并存就需要设置SO_REUSEADDR。在Indy里这个动作要在OnBeforeBind事件里做因为组件激活之后再设置已经来不及了。procedure TForm1.UDPServerBeforeBind(ASender: TIdSocketHandle); begin // 允许端口重用便于新旧版本进程并行运行做灰度验证 ASender.ReuseSocket : rsTrue; end;发送端的地址语义是另一回事。SendBuffer(192.168.1.77, 8888, Buf)里的地址是目的地址跟本机绑了哪个IP没关系协议栈会根据路由表自动选择出口网卡。这一点在多网卡机器上会引发一些隐性问题第五节细说。2.2 数据报边界没有粘包但有截断做过TCP的人一听UDP第一反应是是不是也要处理粘包。答案是不需要但需要处理截断和整包丢失。UDP是面向消息的协议你发一次sendto对方就对应一次recvfrom中间不会出现两条消息拼在一起的情况也不会有半条消息的问题。消息边界由协议栈帮你保住了这是UDP相对TCP在实现上的一个明显便利。但有另一类边界问题在等着你单包长度上限。理论上UDP载荷最大可以到65507字节65535减去IP头20字节和UDP头8字节但实际传输时只要超过链路的MTUIP层就会把它切成多个分片分别发送。以太网MTU是1500字节减去20字节IP头和8字节UDP头安全载荷是1472字节。超过这个值就会分片而分片传输有个致命特点任何一片丢失整个数据报在接收端就凑不齐直接整体丢弃。分片越多丢失概率越高而且丢一个分片等于丢整包代价被放大。所以我的经验值很粗暴局域网内单包控制在1400字节以内跨不可靠网络控制在512字节以内。如果你的业务数据确实很大那就在应用层自己做分片与重组加上序号和总片数接收端攒够再拼。虽然麻烦但至少你能控制分片策略、能做超时丢弃、能记录哪一片丢了。还有一层截断发生在API层面。Indy的TIdUDPBase.BufferSize默认是8192接收缓冲小于实际报文长度时超出的部分会被丢弃某些版本还会抛出异常。发送端也有类似限制SendBuffer在报文长度超过BufferSize时会直接报错。所以如果你确实要发大包必须显式调大这个值并且清楚调大之后分片问题依然存在。FUDPServer.BufferSize : 16 * 1024; // 仅解决API层截断不解决IP分片2.3 线程上下文OnUDPRead到底跑在哪个线程这是Delphi写UDP最容易埋雷的地方没有之一。在Indy 10的实现里TIdUDPServer的OnUDPRead事件默认在内部监听线程的上下文中执行不是主线程。这意味着你在事件处理函数里直接碰TMemo.Lines.Add或者改TLabel.Caption轻则界面不刷新重则抛访问冲突异常。更坑的是这种错误在开发机上往往不出现——因为你发包频率低、机器快时序恰好错开了到了现场高频发包立刻就炸。解决方式有两种。第一种最省事把ThreadedEvent设为TrueIndy会用线程池来分派事件但它依然不是主线程界面更新还是得同步。第二种是我一直在用的写法在事件里只做数据解析和入队界面更新交给主线程。procedure TForm1.UDPServerRead(AThread: TIdUDPListenerThread; const AData: TIdBytes; ABinding: TIdSocketHandle); var Raw: TBytes; Packet: TMyPacket; begin // 只做字节拷贝不碰任何界面对象 SetLength(Raw, Length(AData)); if Length(AData) 0 then Move(AData[0], Raw[0], Length(AData)); Packet.SourceIP : ABinding.PeerIP; Packet.SourcePort : ABinding.PeerPort; Packet.Payload : ParsePayload(Raw); // 用 Queue 而不是 Synchronize避免阻塞监听线程 TThread.Queue(nil, procedure begin PushToUI(Packet); // 这里才允许操作界面 end); end;这里有个细节值得强调优先用TThread.Queue而不是TThread.Synchronize。Synchronize会阻塞调用线程直到主线程处理完如果主线程正好在忙比如用户在拖窗口、在等一个数据库查询监听线程就被卡住了后续的包全部堆积在系统缓冲区里堆满就丢。Queue是异步投递投完就走监听线程的响应性完全不受主线程影响。这个小改动在收包频率上千的时候效果非常明显。还要提醒一点OnUDPRead里绝对不要做耗时操作。有人习惯在这里直接写数据库、直接发HTTP请求、直接读文件这些动作动辄几十毫秒一秒钟只能处理几十个包缓冲区很快就满了。正确做法是快速解析、快速入队、快速返回真正耗时的处理放到独立的工作线程队列里去消化。2.4 编码与字节序中文乱码的根子字符串转字节看着简单实际上乱码问题在UDP项目里出现的频率高得离谱。根源在于两个环节都可能出错一是Delphi内部的字符串编码二是Indy的转换函数选择。Delphi 2009之后string默认是UnicodeUTF-16而网络上传输的是字节流。你要发设备上线这四个字得先决定用什么编码变成字节。如果发送端用GBK接收端用UTF-8去解必然乱码。我现在的固定约定是全线UTF-8不用ANSI不用系统默认编码理由很简单跨语言、跨平台、跨版本都稳跟外部系统对接时基本不用做额外转换。// 发送string - UTF-8 字节 var Buf: TIdBytes; begin Buf : ToBytes(edtMsg.Text, IndyTextEncoding(TEncoding.UTF8)); FUDPClient.SendBuffer(edtHost.Text, StrToInt(edtPort.Text), Buf); end;// 接收字节 - string var S: string; begin S : BytesToString(AData, IndyTextEncoding(TEncoding.UTF8)); end;如果你的Indy版本偏老找不到IndyTextEncoding这个函数用Move拷贝成TBytes再走TEncoding.UTF8.GetString也一样只是要多写两行。别图省事用string(AData)强转那种写法在Unicode版本下会把字节当成字符码点来解读结果是一堆问号。字节序是另一个维度。网络字节序是大端x86和ARM默认是小端所以端口号、整型字段在网络传输时要做转换。好消息是Indy的APort: TIdPort参数已经替你处理了你直接传8888就行。坏消息是你自己打包的二进制协议字段没有这层保护。比如你定义一个4字节的序号字段发送端用Move(Seq, Buf[0], 4)直接拷进去接收端再用Move(Buf[0], Seq, 4)读出来在本机小端对测没问题跟大端设备对接就全乱了。// 手工处理 32 位网络字节序大端 function HostToNet32(V: Cardinal): Cardinal; begin Result : ((V and $000000FF) shl 24) or ((V and $0000FF00) shl 8) or ((V and $00FF0000) shr 8) or ((V and $FF000000) shr 24); end;我的习惯是只要协议里出现多字节整数一律显式转成大端再发接收后转回来。多写两行代码省掉后期跟硬件厂商扯皮的无数个小时。3. 完整Demo实操从零搭一个能用的收发程序理论铺垫够了下面把一整套东西搭起来。我会给出两个版本Indy版负责快速可用WinSock版负责精细控制。两个版本我都实际用在过项目里代码可以直接抄去改。3.1 工程骨架与依赖梳理新建一个VCL Forms Application主窗体上放这些控件两个TEdit分别填目标IP和端口一个TEdit填发送内容一个TButton触发发送一个TMemo显示收发日志一个TCheckBox切换是否用广播。组件面板上拖一个TIdUDPServer和一个TIdUDPClient下来Name分别改成UDPServer和UDPClient这样代码里引用清晰。需要引用的单元uses System.SysUtils, System.Classes, System.SyncObjs, IdGlobal, IdSocketHandle, IdUDPClient, IdUDPServer, IdBaseComponent, IdComponent, IdUDPBase;如果你打算用组播再加IdIPMCastClient用原生socket则换成Winapi.Winsock2。这里有个容易被忽略的工程配置项表单的DoubleBuffered建议设True。UDP高频收包时频繁刷TMemo会有明显闪烁开了双缓冲能缓解不少。TMemo本身刷新开销大生产环境我一般换成TListView虚拟模式或者自绘的日志控件Demo里用TMemo只是为了省事。3.2 接收端TIdUDPServer的最小可用实现初始化放在FormCreate里。注意顺序先设DefaultPort和BufferSize最后才把Active置True。反过来写的话组件会在你还没配置好之前就开始监听之后改的配置不一定生效。procedure TForm1.FormCreate(Sender: TObject); begin UDPServer.DefaultPort : 8888; UDPServer.BufferSize : 8192; UDPServer.OnUDPRead : HandleUDPRead; UDPServer.OnException : HandleUDPException; UDPServer.Active : True; UDPClient.ReceiveTimeout : 1000; // 毫秒 end;OnException这个事件很多人不挂结果监听线程内部出了问题悄无声息。挂上它至少能拿到异常信息打日志。接收处理函数是核心。为了让Demo能直接看出效果我在这里做一个收到PING就回PONG的应答逻辑这个模式在实际设备发现里用得非常多。procedure TForm1.HandleUDPRead(AThread: TIdUDPListenerThread; const AData: TIdBytes; ABinding: TIdSocketHandle); var Msg, Reply: string; ReplyBuf: TIdBytes; LogLine: string; begin if Length(AData) 0 then Exit; try Msg : BytesToString(AData, IndyTextEncoding(TEncoding.UTF8)); except // 编码异常不影响服务继续运行 Msg : decode failed; end; LogLine : Format([%s] %s:%d - %d bytes | %s, [FormatDateTime(hh:nn:ss.zzz, Now), ABinding.PeerIP, ABinding.PeerPort, Length(AData), Msg]); TThread.Queue(nil, procedure begin Memo1.Lines.Add(LogLine); end); // 简单的应用层应答收到 PING 回 PONG if SameText(Trim(Msg), PING) then begin Reply : PONG; ReplyBuf : ToBytes(Reply, IndyTextEncoding(TEncoding.UTF8)); // 用同一个 binding 回发源端口才会是 8888 ABinding.SendTo(ABinding.PeerIP, ABinding.PeerPort, ReplyBuf); end; end;这里有个关键细节值得单独说回包时用ABinding.SendTo而不是UDPClient.SendBuffer。前者用的是服务端那个已经绑定在8888端口的socket发出的包源端口就是8888后者用的是客户端组件的socket源端口是系统随机分配的。很多设备的协议栈比较死板要求对方的源端口必须是约定端口用后者就会对不上。3.3 发送端TIdUDPClient与超时控制发送侧分两种用法一种是不等回应的单向投递直接SendBuffer完事另一种是发出后等应答用于设备探询、状态查询这类需要往返的场景。后者必须配超时否则接收端不在线时你的线程会一直挂在那里。function TForm1.SendAndWait(const AHost: string; APort: Word; const AText: string; ATimeoutMs: Integer): string; var Buf: TIdBytes; Resp: TIdBytes; Peer: TIdSocketHandle; begin Result : ; Buf : ToBytes(AText, IndyTextEncoding(TEncoding.UTF8)); UDPClient.SendBuffer(AHost, APort, Buf); UDPClient.ReceiveTimeout : ATimeoutMs; if UDPClient.ReceiveBuffer(Resp, ATimeoutMs) then Result : BytesToString(Resp, IndyTextEncoding(TEncoding.UTF8)); end;ReceiveBuffer返回False就是超时这时候不要去区分是对方没收到还是对方收到了没回UDP层面根本区分不出来业务上统一按无响应处理即可。如果你的场景是发一条命令给一批设备然后收集一小段时间内的所有回复那就不能用一个客户端阻塞等了得改成发完后开个时间窗口让接收端持续收集。这种模式下发送和接收是解耦的两个组件集合点放在一个TDictionarystring, TDeviceState里按IP索引。// 探询命令广播出去各设备自行回复 procedure TForm1.BroadcastProbe; var Buf: TIdBytes; begin UDPClient.BroadcastEnabled : True; Buf : ToBytes(WHO_ARE_YOU, IndyTextEncoding(TEncoding.UTF8)); UDPClient.SendBuffer(255.255.255.255, 8888, Buf); UDPClient.BroadcastEnabled : False; FProbeDeadline : Now (3 / SecsPerDay); // 3 秒收集窗口 end;3.4 广播与组播设备发现的两种打法广播和组播解决的同一个问题——不知道对方在哪里先喊一声。区别在于广播是往整个子网喊同子网所有主机都能收到会被网卡和协议栈逐层过滤组播是往一个约定的组地址喊只有加入了该组的主机才处理。局域网设备发现用广播最简单跨网段或者要减少无关主机干扰时用组播。广播这块有个坑必须提前知道255.255.255.255这种受限广播地址在多网卡机器上不一定发得出去。Windows会按路由表选一个默认出口如果你有几块网卡有线、无线、虚拟网卡很可能选中的不是你要的那块包发到了错误的子网。厂商那边的设备等不到探询包你的界面上就一台设备都扫不出来而换个单网卡机器一切正常。稳妥的做法是算出目标子网的定向广播地址再发。比如本机IP是192.168.1.50、掩码255.255.255.0那定向广播地址就是192.168.1.255。这个地址明确指定了子网归属协议栈不会乱选出口。function GetDirectedBroadcast(const AIP: Cardinal; AMask: Cardinal): Cardinal; begin Result : (AIP and AMask) or (not AMask); end; function IPv4ToStr(AIP: Cardinal): string; begin Result : Format(%d.%d.%d.%d, [AIP shr 24, (AIP shr 16) and $FF, (AIP shr 8) and $FF, AIP and $FF]); end;组播的写法用Indy的TIdIPMCastClient更省事procedure TForm1.InitMulticast; begin FMCast : TIdIPMCastClient.Create(Self); FMCast.MulticastGroup : 239.255.43.21; // 组织本地管理范围 FMCast.Port : 9999; FMCast.OnIPMCastRead : HandleMCastRead; FMCast.Active : True; end;组播地址的选择有讲究224.0.0.0到224.0.0.255是本地链路范围路由器不会转发适合纯局域网239.0.0.0到239.255.255.255是管理范围可以自己规划我一般用这一段。组播在多网卡机器上同样要指定网卡否则加入组的动作可能落在错误的接口上。注意广播和组播都要求发送端的socket开启了广播权限SO_BROADCAST。Indy的BroadcastEnabled : True就是干这个的。设完发完记得改回False否则后续所有发送都带广播语义某些安全软件会弹告警。3.5 原生WinSock版本把控制权拿回来当Indy满足不了的时候退回底层。下面这段是我在做一个需要精确控制接收缓冲区大小的项目时写的去掉了所有业务逻辑只保留socket骨架。type TUdpRaw class private FSocket: TSocket; FWsaOk: Boolean; public function Open(APort: Word; ARcvBuf: Integer): Boolean; function Receive(out AData: TBytes; out AHost: string; out APort: Word): Boolean; function SendTo(const AHost: string; APort: Word; const AData: TBytes): Boolean; procedure Close; end; function TUdpRaw.Open(APort: Word; ARcvBuf: Integer): Boolean; var Wsa: TWSAData; Addr: TSockAddrIn; OptVal: Integer; begin Result : False; if WSAStartup($0202, Wsa) 0 then Exit; FWsaOk : True; FSocket : socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if FSocket INVALID_SOCKET then Exit; // 端口重用便于同一台机器起多个进程做灰度 OptVal : 1; setsockopt(FSocket, SOL_SOCKET, SO_REUSEADDR, PAnsiChar(OptVal), SizeOf(OptVal)); // 调大接收缓冲区抗突发流量 if ARcvBuf 0 then setsockopt(FSocket, SOL_SOCKET, SO_RCVBUF, PAnsiChar(ARcvBuf), SizeOf(ARcvBuf)); FillChar(Addr, SizeOf(Addr), 0); Addr.sin_family : AF_INET; Addr.sin_port : htons(APort); Addr.sin_addr.S_addr : INADDR_ANY; if bind(FSocket, PSockAddr(Addr)^, SizeOf(Addr)) SOCKET_ERROR then begin closesocket(FSocket); Exit; end; Result : True; end;接收函数。注意recvfrom的地址长度参数是双向的调用前要给个缓冲大小返回后它变成实际地址长度。这里我用了PSockAddr(Addr)^做类型转换因为Delphi的声明里参数类型是var TSockAddr而实际存的是TSockAddrIn直接传会编译不过。function TUdpRaw.Receive(out AData: TBytes; out AHost: string; out APort: Word): Boolean; var Buf: array[0..2047] of Byte; Addr: TSockAddrIn; AddrLen, N: Integer; begin Result : False; AData : nil; AHost : ; APort : 0; AddrLen : SizeOf(Addr); N : recvfrom(FSocket, Buf, SizeOf(Buf), 0, PSockAddr(Addr)^, AddrLen); if N SOCKET_ERROR then Exit; // 超时或出错交给调用方判断 SetLength(AData, N); if N 0 then Move(Buf[0], AData[0], N); AHost : string(AnsiString(inet_ntoa(Addr.sin_addr))); APort : ntohs(Addr.sin_port); Result : True; end;发送函数里sendto不需要connect直接带地址发就行这正是UDP一对多的基础。function TUdpRaw.SendTo(const AHost: string; APort: Word; const AData: TBytes): Boolean; var Addr: TSockAddrIn; N: Integer; begin Result : False; FillChar(Addr, SizeOf(Addr), 0); Addr.sin_family : AF_INET; Addr.sin_port : htons(APort); Addr.sin_addr.S_addr : inet_addr(PAnsiChar(AnsiString(AHost))); if Length(AData) 0 then Exit; N : sendto(FSocket, AData[0], Length(AData), 0, PSockAddr(Addr)^, SizeOf(Addr)); Result : N SOCKET_ERROR; end;有一点必须提醒这套代码里recvfrom是阻塞的除非你设了SO_RCVTIMEO。它只能跑在独立线程里绝对不能放主线程否则界面直接假死。我在项目里的做法是起一个TThread后代while not Terminated循环收包收到就丢进队列退出时先closesocket把阻塞的recvfrom打断再WaitFor。4. 常见问题与排查技巧实录下面这些都是我在真实项目里遇到过的按发生频率从高到低排。有些问题看起来低级但当时确实卡了很久写出来希望能帮后来人省点时间。4.1 收不到包的五层排查法碰到发了但没收到别急着改代码按这个顺序一层层往下查基本能在十分钟内定位。**第一层发送端到底发了没有。**在SendBuffer后面立刻打日志确认返回值、确认目标地址和端口拼写没打错。我遇到过一次是把端口8888写成了8880测试时两边都是手输的改了一边忘了另一边白查半小时。**第二层包出了网卡没有。**用抓包工具在发送机上抓看有没有对应的数据报出去。没有的话说明是代码问题或者被安全软件拦了。这一步能砍掉一半的问题。**第三层包到了接收机没有。**在接收机上抓看目标IP和目标端口对不对。如果包到了机器但目标IP是127.0.0.1或者错误的网卡地址那就是发送端地址算错了多半是广播地址或路由选择的问题。**第四层接收进程认领了没有。**看netstat -ano -p UDP的输出确认你的进程确实在那个端口上监听并且绑定地址不是你不想看到的。如果显示没在监听说明Active : True没执行成功或者bind失败了但你没检查返回值。**第五层防火墙。**这一层放最后是因为它最常见也最容易被忽略。Windows防火墙对入站UDP默认是拦的第一次运行时弹窗问你要不要放行如果当时点了取消或者那个弹窗被别的窗口盖住了后面就一直静默拦截。测试时如果怀疑是防火墙先临时关掉验证确认之后再补一条入站规则别养成长期关防火墙的习惯。4.2 高频问题速查表现象最可能的原因处理办法本机自测正常跨机收不到接收端绑到了127.0.0.1改成INADDR_ANY或指定网卡IP广播发出去了一台设备都没回应用的受限广播255.255.255.255出口网卡选错换用子网定向广播地址如192.168.1.255中文显示成问号或乱码收发两端编码不一致统一为UTF-8显式指定编码转换大包收不到小包正常超过MTU触发IP分片某片丢失整包丢弃单包控制在1400字节以内大包自己分片偶尔丢包频率高了更明显OnUDPRead里做了耗时操作缓冲区堆积事件里只解析入队耗时逻辑交给工作线程界面卡死或抛访问冲突在OnUDPRead里直接操作VCL控件用TThread.Queue切回主线程更新组件激活时报端口占用端口被其他进程或上次没退干净的自己占着检查netstat或启用ReuseSocket服务端重启后客户端发不进来旧socket处于TIME_WAIT少见但存在设置SO_REUSEADDR别用固定低位端口收包长度总是被截断成固定值BufferSize小于实际报文显式调大BufferSize发送直接抛异常报文长度超过BufferSize限制调大BufferSize或改走分片逻辑4.3 缓冲区与性能的调优经验关于SO_RCVBUF这里有个反直觉的现象调太大反而不一定好。系统缓冲区越大包在缓冲区里排队的时间越长如果你的业务对时延敏感比如实时控制大缓冲区会引入额外延迟。我的经验是对时延敏感的用64KB左右对吞吐敏感的可以用到256KB甚至更大具体看包的平均大小和发送频率。计算方式大致是这样假设每秒发2000个包、平均每包200字节那每秒数据量约400KB如果想让系统能扛住100毫秒的突发缓冲区至少要40KB留点余量设64KB比较合适。这只是估算实际还要看主线程处理速度处理得慢就得调大。还有一个容易忽略的点接收线程的处理速度决定了实际吞吐上限。系统缓冲区再大也是有限的如果消费速度长期低于生产速度迟早会溢出丢包。所以性能调优的重点从来不是调参数而是让OnUDPRead尽快返回。我在一个项目里把OnUDPRead里的一个字符串格式化操作挪出去之后丢包率从3%降到了几乎为零——那个格式化平时只要几微秒但在每秒三千包的压力下就成了瓶颈。另外如果你用Indy且开了ThreadedEvent : True注意线程池的并发数设置。默认值在高并发下可能不够用但也不能开太大线程切换本身也有成本。我一般设成CPU核心数的两倍左右实测下来比较平衡。5. 多网卡、跨网段与UDP不够用时的扩展思路前面四节讲的是能把包收发起来这一节讲的是在复杂环境下还能稳住。现场环境永远比开发机复杂这一节的内容基本都是从现场问题里总结出来的。5.1 多网卡环境下的绑定策略开发机通常只有一块网卡现场机器经常有有线、无线、虚拟网卡、蓝牙网络适配器加起来五六块。这时候0.0.0.0绑定虽然能收到所有网卡的包但发送时的出口选择就完全交给路由表了而路由表的选择结果往往跟你的预期不一致。我的做法是在界面上给用户一个网卡选择下拉框让用户明确指定用哪块网卡。程序启动时枚举本机所有IPv4地址过滤掉回环和未连接的列出来让用户选。选定之后接收端绑定到该网卡的IP而不是0.0.0.0发送广播时用该网卡的子网计算定向广播地址。这样行为完全确定不会出现换台机器就好了这种玄学问题。// 简易网卡枚举依赖 Indy 的 GStack procedure TForm1.LoadLocalIPs; var L: TStringList; begin L : TStringList.Create; try GStack.AddLocalAddressesToList(L); cbLocalIP.Items.Assign(L); if cbLocalIP.Items.Count 0 then cbLocalIP.ItemIndex : 0; finally L.Free; end; end;拿到IP之后还要算掩码因为定向广播地址需要它。Indy没直接给掩码可以用GetAdaptersAddresses这套系统API取或者退一步让用户在界面上手工填掩码前缀比如/24虽然土但足够可靠。我在中小型项目里更倾向后者——少一层API依赖少一堆兼容问题。5.2 在UDP上做轻量可靠传输的取舍有些业务确实需要UDP的低开销但又不能接受丢包。这时候就得上应用层的轻量可靠机制。我要强调的是别试图在UDP上重新实现一遍TCP那是个无底洞。针对具体业务做最小化的补丁就够了。以配置下发这类场景为例我的做法只有三条每条指令带一个自增序号接收端回复ACK序号发送端超时未收到ACK就重发最多重发三次接收端用一个小窗口记住最近的序号重复的直接丢弃并重新ACK。这三条加起来不到一百行代码能覆盖99%的丢包场景比完整实现滑动窗口简单太多。反过来状态上报这类场景完全不需要这套东西丢就丢了下个周期补上。判断标准很简单这条数据丢了业务上会不会产生错误结果。会就加确认不会就别加加了纯属给自己找麻烦。还有个折中方案值得提一下用序号做去重但不做重传。发送端给每个包打上递增序号接收端记住最新收到的序号收到比它小的就直接丢弃。这样能解决网络抖动导致的乱序重复成本极低适合那些能容忍丢包但不能容忍重复的业务。5.3 协议设计给报文加个自描述头最后聊一个偏架构的点。我见过很多项目的UDP协议就是一串裸字符串用逗号分隔字段字段顺序一变就全乱套加个字段还得所有端同步升级。这种设计在项目初期省事后期维护成本极高。我的建议是至少加一个固定长度的报文头四到八个字节就够两字节魔数用于识别是不是自己的包、一字节版本号、一字节消息类型、两字节载荷长度。有了这几样接收端就能做三件事快速过滤掉网络上其他程序发来的无关UDP包、按版本号做兼容处理、按消息类型分派到不同的处理函数。载荷部分再按类型各自定义字符串用UTF-8整数用大端结构清晰得多。type TUdpHeader packed record Magic: Word; // 固定值如 $44 55 (DU) Version: Byte; // 协议版本 MsgType: Byte; // 消息类型 PayLen: Word; // 载荷长度大端 end;收包时第一步校验Magic对不上直接Exit。这一步能挡掉大量莫名其妙的垃圾包尤其是在端口号比较常见、容易被其他软件误发的环境下。我做过一个项目接收端口上经常收到别的软件的探测包加了魔数校验之后日志干净了一大半排查问题时的干扰也少了很多。版本号这一字节看着不起眼但它给了你平滑升级的空间。新版本增加字段时老版本收到不认识的消息类型可以直接忽略而不是崩溃新版本收到老版本的包也能按旧格式解析。这种向前兼容的能力在多端部署、升级不同步的现场环境里价值极大。最后分享一个我在实际项目里养成的习惯给UDP模块单独写一个自测用的对拍工具。一个极简的小程序能按指定频率往指定地址发指定长度的包同时统计收包数量、丢包率和最大时延。这个东西看起来只是个小工具但每次现场出问题它都能帮我快速区分是网络环境问题还是我的代码问题——把对拍工具放到同一台机器、同一个网段跑一遍结论立刻就出来了。我后来把这个工具做成了主程序里的一个隐藏调试页按快捷键调出来比单独维护一个工程方便得多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑