拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Delphi Modbus主从站控件开发:串口与TCP通信底层实现指南

简介本资源是一套专为Delphi开发者设计的Modbus主从站通信控件源码面向工控自动化领域的新手与进阶开发人员解决PLC设备与上位机间标准Modbus协议通信的快速集成问题适用于工业现场数据采集、设备监控等典型场景。压缩包共54个文件涵盖10个核心Pas单元如IdModbusClient/Server、ModbusTypes、6个Dpk组件包支持Delphi 5.0–7.0多版本、3个DFM界面文件及配套DCU编译单元、RES资源与EXE演示程序完整呈现控件封装、注册、调用与测试全流程包体仅851KB轻量易集成。已有283人学习下载资源由‘工控老马’出品并经实测校正包含详细readme与history说明文档、多版本兼容包结构、双模式Demo工程master/slave及About组件编辑器等实用模块开箱即可用于二次开发或教学验证。1. Delphi 下的 Modbus Master/Slave 控件不是“拿来即用”的组件而是串口与 TCP 通信的底层 glue layer在工业现场调试 PLC、电表或温控器时很多 Delphi 开发者会直接搜索“Delphi Modbus 控件”期待拖一个 TModbusMaster 组件到窗体上、填几个 IP 或 COM 口就完成数据读写。现实却是多数所谓“控件”只是封装了 Win32 API 的串口操作或简单 TCP socket缺少协议状态机、异常重试、线程安全队列和寄存器缓存机制——结果是读取保持寄存器0x03时偶发丢包写多个线圈0x0F后从站无响应甚至在多线程轮询中触发 Access Violation。这类控件真正价值不在“可视化设计”而在提供可嵌入业务逻辑的稳定通信基座它必须明确区分 Master主站发起请求与 Slave从站响应请求角色支持 RTU/ASCII/TCP 三种帧格式且能暴露底层帧收发日志、超时阈值、重试策略等关键控制点。适合需要长期运行、对接国产工控设备、或需与 OPC UA 网关桥接的 Delphi 工程师——尤其是使用 Delphi 10.4 Sydney 至 12.3 的项目因新版 RTL 对 Unicode 串口字符串处理更严谨旧控件易出现 485 协议帧校验失败或乱码。1.1 Modbus 协议栈在 Delphi 中的分层实现必要性Modbus 协议本身极简功能码 地址 数据 CRCRTU或 LRCASCII但真实工业环境要求远超协议规范。例如某水厂 SCADA 系统需同时轮询 12 台变频器Modbus RTU over RS-485每台含 200 个寄存器若采用单线程顺序读取一轮扫描耗时超 8 秒无法满足 1 秒级监控要求。此时必须拆解为三层物理层COM 口配置波特率、校验位、停止位、TCP 连接池管理、RS-485 方向控制DE/RE 引脚协议层帧组装/解析、事务 IDTCP、单元 IDRTU/ASCII、超时重传默认 1.5 秒、重复请求抑制应用层寄存器地址映射如 40001 → $0000、数据类型转换INT16/UINT32/IEEE754、批量读写合并Read Multiple Holding Registers → 0x03。Delphi 原生 VCL 缺乏现成的 Modbus 栈因此“控件”本质是将这三层胶合的 Pascal 类库。常见错误是把物理层操作如CreateFile打开 COM3和协议层逻辑如计算 CRC16混写在一个 TForm 事件里——这导致无法复用于服务进程Windows Service或跨线程调用。正确做法是Master 控件继承自TComponent内部持有一个TModbusTransport负责串口/TCP 通信和TModbusProtocol负责帧编解码Slave 控件则需实现TModbusServer抽象类注册回调函数处理OnReadHoldingRegisters等事件。提示不要依赖“自动识别 Modbus 设备”的控件。Modbus 无设备发现机制所有从站地址Slave ID必须手动配置。所谓“扫描”功能实为遍历 1~247 地址发送 0x03 请求耗时且可能干扰现场设备。1.2 为什么 Delphi 开发者必须亲手验证控件的线程模型Delphi 的 VCL 窗体控件默认非线程安全而工业通信要求后台线程持续轮询。若控件未显式声明线程模型极易引发 GDI 资源冲突。典型症状在TThread.Execute中调用TModbusMaster.ReadHoldingRegisters(1, 0, 10)后主线程TStringGrid.Cells[0,0] : IntToStr(Value)触发EAccessViolation。根本原因在于 VCL 的TCanvas、TFont等对象绑定到主线程消息循环。解决方案有二推荐控件内部使用Synchronize或Queue将 UI 更新委托给主线程自身通信逻辑完全运行在工作线程替代改用TIdModbus基于 Indy等纯数据类不继承TComponent由开发者自行管理线程同步。验证方法编写压力测试用例在TThread中连续 1000 次调用WriteMultipleCoils同时主线程每 100ms 刷新TEdit.Text。若 5 分钟内无崩溃则控件线程模型合格。注意Delphi 12.3 新增的System.Threading.TTask不适用于此场景因其调度器仍依赖主线程消息泵无法保证实时性。2. 用 TModbusMaster 在本地跑通 Modbus RTU 的最小命令集2.1 创建最小可运行工程从 COM 口初始化开始Delphi 工程需引用System.SysUtils,System.Classes,Winapi.Windows核心是正确打开串口并设置 DCB 结构。以下代码是 TModbusMaster 初始化 COM3 的最小实现非第三方控件展示底层逻辑function OpenModbusPort(const PortName: string; BaudRate: DWORD): THandle; var hPort: THandle; dcb: TDCB; comTimeOuts: TCommTimeouts; begin Result : INVALID_HANDLE_VALUE; hPort : CreateFile(PChar(\\.\ PortName), GENERIC_READ or GENERIC_WRITE, 0, nil, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0); if hPort INVALID_HANDLE_VALUE then Exit; // 清除串口缓冲区 PurgeComm(hPort, PURGE_TXCLEAR or PURGE_RXCLEAR); // 配置串口参数 FillChar(dcb, SizeOf(dcb), 0); dcb.DCBlength : SizeOf(dcb); if not GetCommState(hPort, dcb) then begin CloseHandle(hPort); Exit; end; dcb.BaudRate : BaudRate; dcb.fBinary : True; dcb.fParity : False; dcb.fOutxCtsFlow : False; dcb.fOutxDsrFlow : False; dcb.fDtrControl : DTR_CONTROL_ENABLE; dcb.fDsrSensitivity : False; dcb.fTXContinueOnXoff : True; dcb.fOutX : False; dcb.fInX : False; dcb.fErrorChar : #0; dcb.fNull : False; dcb.fRtsControl : RTS_CONTROL_ENABLE; dcb.fAbortOnError : False; dcb.ByteSize : 8; dcb.Parity : NOPARITY; dcb.StopBits : ONESTOPBIT; if not SetCommState(hPort, dcb) then begin CloseHandle(hPort); Exit; end; // 设置超时 FillChar(comTimeOuts, SizeOf(comTimeOuts), 0); comTimeOuts.ReadIntervalTimeout : MAXDWORD; comTimeOuts.ReadTotalTimeoutConstant : 1000; // 1秒读超时 comTimeOuts.ReadTotalTimeoutMultiplier : 0; comTimeOuts.WriteTotalTimeoutConstant : 1000; comTimeOuts.WriteTotalTimeoutMultiplier : 0; SetCommTimeouts(hPort, comTimeOuts); Result : hPort; end;该函数返回有效THandle是后续一切通信的前提。关键参数说明BaudRate必须与从站设备一致常见值为 9600、19200、115200PurgeComm清除残留数据避免历史帧干扰新会话ReadTotalTimeoutConstantModbus RTU 帧间间隔T1.5通常为 1.75 字符时间设为 1000ms 可覆盖绝大多数设备RTS_CONTROL_ENABLERS-485 方向控制必需否则 DE 引脚无法拉高发送。注意\\.\COM3格式仅 Windows 支持Linux 下需用/dev/ttyUSB0Delphi 12.3 的TSerialPort类虽简化操作但不支持 RTS 控制故工业场景仍需 WinAPI。2.2 构建标准 Modbus RTU 请求帧以读取保持寄存器为例Modbus RTU 帧结构为[Slave ID][Function Code][Start Address Hi][Start Address Lo][Register Count Hi][Register Count Lo][CRC Lo][CRC Hi]。以下函数生成读取从站 ID1、地址 0x0000 开始的 10 个保持寄存器的请求帧function BuildReadHoldingRequest(SlaveID, StartAddr, RegCount: Word): TBytes; var i: Integer; crc: Word; buf: array[0..7] of Byte; begin // 填充固定字段 buf[0] : Byte(SlaveID); // 从站地址 buf[1] : $03; // 功能码读保持寄存器 buf[2] : HiByte(StartAddr); // 起始地址高位 buf[3] : LoByte(StartAddr); // 起始地址低位 buf[4] : HiByte(RegCount); // 寄存器数量高位 buf[5] : LoByte(RegCount); // 寄存器数量低位 // 计算 CRC16-MODBUS crc : $FFFF; for i : 0 to 5 do begin crc : crc xor buf[i]; for j : 0 to 7 do begin if (crc and 1) 1 then crc : (crc shr 1) xor $A001 else crc : crc shr 1; end; end; // 追加 CRC SetLength(Result, 8); Move(buf, Result[0], 6); Result[6] : LoByte(crc); Result[7] : HiByte(crc); end;调用BuildReadHoldingRequest(1, $0000, 10)返回 8 字节数组[01, 03, 00, 00, 00, 0A, 44, 8C]。其中44 8C是 CRC 校验值必须严格匹配否则从站丢弃该帧。常见错误地址偏移错误Modbus 地址 40001 对应寄存器索引 0而非 40001CRC 算法混淆误用 CRC16-CCITT初始值 $FFFF多项式 $1021字节序颠倒HiByte/LoByte顺序写反导致地址错位。2.3 发送与接收完整帧处理 T1.5 和 T3.5 时序Modbus RTU 依赖精确的字符间隔超时判断帧边界。标准规定T1.5两个字符间最大间隔用于判断一帧结束T3.5帧间最小间隔用于区分不同帧。Delphi 中需用WaitCommEvent或轮询GetOverlappedResult实现。以下为简化版接收逻辑function ReadModbusResponse(hPort: THandle; var Response: TBytes): Boolean; var bytesRead: DWORD; buffer: array[0..255] of Byte; startTime: DWORD; timeout: DWORD; begin Result : False; SetLength(Response, 0); timeout : 1500; // 总超时 1.5秒 startTime : GetTickCount; // 读取至少 2 字节Slave ID Function Code while Length(Response) 2 do begin if not ReadFile(hPort, buffer, 1, bytesRead, nil) or (bytesRead 0) then Break; SetLength(Response, Length(Response) 1); Response[High(Response)] : buffer[0]; // 检查是否超时 if GetTickCount - startTime timeout then Exit; end; // 读取剩余字节根据功能码确定长度 if (Length(Response) 2) and (Response[1] in [$03, $04]) then begin // 0x03/0x04 响应[ID][FC][Byte Count][Data...][CRC] if Length(Response) 3 then begin // 读取 Byte Count 字段再读取对应数据 2 字节 CRC SetLength(Response, 3 Response[2] 2); if not ReadFile(hPort, buffer, Response[2] 2, bytesRead, nil) then Exit; Move(buffer, Response[3], Response[2] 2); Result : True; end; end; end;该函数关键点不假设一次ReadFile读完整帧而是按协议动态计算需读字节数Response[2]是数据字节数Byte Count其后紧跟Byte Count个数据字节 2 字节 CRC超时判断基于GetTickCount避免Sleep阻塞线程。3. TModbusSlave 控件的 3 个必调参数与内存映射实现3.1 Slave ID、响应延迟与异常码映射从站行为的三大控制点TModbusSlave 控件并非被动监听其行为由三个核心参数驱动Slave ID必须与主站请求中的Slave ID字段严格匹配否则静默丢弃Response Delay (ms)模拟从站处理时间设为 0 则立即响应设为 50 则在解析请求后延时 50ms 再发回用于测试主站超时逻辑Exception Code Mapping定义非法地址、非法功能码等错误的响应码如0x02非法地址或0x04从站故障。以下为 Delphi 中设置这些参数的典型代码// 创建从站实例 fModbusSlave : TModbusSlave.Create(Self); fModbusSlave.SlaveID : 1; // 必须与主站请求一致 fModbusSlave.ResponseDelay : 20; // 模拟 20ms 处理延迟 // 注册异常码处理 fModbusSlave.OnException : procedure(Sender: TObject; const RequestFrame: TBytes; ExceptionCode: Byte) begin case ExceptionCode of $01: ShowMessage(非法功能码); $02: ShowMessage(Format(非法地址 %d, [GetAddressFromRequest(RequestFrame)])); $03: ShowMessage(非法数据值); $04: ShowMessage(从站故障); end; end;提示ResponseDelay不是“增加延迟”而是“最小响应延迟”。若从站逻辑执行快于该值仍会等待至超时才返回确保主站收到稳定时序。3.2 内存映射寄存器用 TMemoryMap 实现高速读写从站的核心是寄存器数据存储。传统做法用array[0..65535] of Word但存在两大问题内存占用大65536×2128KB实际只用前 1000 个无法区分“未初始化”与“值为 0”。TModbusSlave 应提供TMemoryMap类按需分配页Page每页 256 个寄存器type TMemoryMap class private FPages: TDictionaryWord, TBytes; // Key: Page Index (0,1,2...), Value: 256*2 bytes public function ReadHoldingRegisters(StartAddr, Count: Word): TBytes; procedure WriteHoldingRegisters(StartAddr: Word; const Data: TBytes); function GetRegisterValue(Addr: Word): Word; procedure SetRegisterValue(Addr: Word; Value: Word); end; function TMemoryMap.ReadHoldingRegisters(StartAddr, Count: Word): TBytes; var pageIndex, offsetInPage, i: Word; pageData: TBytes; begin SetLength(Result, Count * 2); for i : 0 to Count - 1 do begin pageIndex : (StartAddr i) div 256; offsetInPage : ((StartAddr i) mod 256) * 2; if not FPages.TryGetValue(pageIndex, pageData) then begin // 按需创建新页初始值全 0 SetLength(pageData, 256 * 2); FPages.Add(pageIndex, pageData); end; Result[i * 2] : pageData[offsetInPage]; Result[i * 2 1] : pageData[offsetInPage 1]; end; end;该实现优势内存按需分配1000 个寄存器仅占 2 页1KBGetRegisterValue可返回0或nil表示未写入便于调试WriteHoldingRegisters直接操作TBytes避免Word类型转换开销。3.3 模拟真实从站行为支持线圈、离散输入、输入寄存器Modbus 定义四类寄存器TModbusSlave 必须全部支持类型功能码地址范围读写性线圈Coils0x01/0x05/0x0F00001-09999读/写离散输入Discrete Inputs0x0210001-19999只读输入寄存器Input Registers0x0430001-39999只读保持寄存器Holding Registers0x03/0x06/0x1040001-49999读/写以下为线圈操作示例地址 00001 对应索引 0// 写单个线圈0x05 procedure TModbusSlave.HandleWriteSingleCoil(const Request: TBytes); var addr: Word; value: Word; begin addr : (Request[2] shl 8) or Request[3]; // 地址 value : (Request[4] shl 8) or Request[5]; // 值0xFF00ON, 0x0000OFF fCoilMap.SetBit(addr, value $FF00); end; // 读线圈0x01 function TModbusSlave.BuildReadCoilsResponse(StartAddr, Count: Word): TBytes; var byteCount: Byte; i, bitPos: Integer; data: TBytes; begin byteCount : (Count 7) div 8; SetLength(Result, 3 byteCount); Result[0] : fSlaveID; Result[1] : $01; Result[2] : byteCount; SetLength(data, byteCount); for i : 0 to Count - 1 do begin bitPos : StartAddr i; if fCoilMap.GetBit(bitPos) then data[i div 8] : data[i div 8] or (1 shl (i mod 8)); end; Move(data, Result[3], byteCount); end;关键细节线圈地址00001→ 索引000002→1以此类推0x01响应中Byte Count字段表示后续数据字节数非位数fCoilMap应为位图BitMap节省内存1000 线圈仅需 125 字节。4. Modbus TCP 的 Socket 层优化解决连接复用与粘包问题4.1 为何不能直接用 TIdTCPClient连接池与心跳机制缺失Indy 的TIdTCPClient适合一次性请求但 Modbus TCP 要求主站需维持长连接避免频繁Connect/Disconnect增加 TCP 开销从站需支持多客户端并发每个连接独立事务 ID网络中断后需自动重连而非抛出异常终止。因此 TModbusMasterTCP 必须内置连接池。以下为连接管理核心逻辑type TModbusConnection class private FSocket: TSocket; FIP: string; FPort: Word; FLastActive: DWORD; FIsConnected: Boolean; public constructor Create(const AIP: string; APort: Word); function Connect: Boolean; function SendFrame(const Frame: TBytes): Boolean; function ReceiveFrame(var Frame: TBytes): Boolean; property IsConnected: Boolean read FIsConnected; end; TModbusConnectionPool class private FConnections: TListTModbusConnection; FCriticalSection: TRTLCriticalSection; public constructor Create; destructor Destroy; override; function GetConnection(const IP: string; Port: Word): TModbusConnection; procedure ReleaseConnection(Connection: TModbusConnection); end; function TModbusConnectionPool.GetConnection(const IP: string; Port: Word): TModbusConnection; var conn: TModbusConnection; i: Integer; begin EnterCriticalSection(FCriticalSection); try // 查找空闲连接 for i : 0 to FConnections.Count - 1 do begin conn : FConnections[i]; if (conn.FIP IP) and (conn.FPort Port) and conn.IsConnected then begin conn.FLastActive : GetTickCount; Result : conn; Exit; end; end; // 创建新连接 conn : TModbusConnection.Create(IP, Port); if conn.Connect then begin FConnections.Add(conn); Result : conn; end else Result : nil; finally LeaveCriticalSection(FCriticalSection); end; end;该连接池特点GetConnection返回已连接实例避免重复connect()FLastActive记录最后使用时间后台线程可清理闲置连接如 300 秒未用TRTLCriticalSection保证多线程安全比TCriticalSection更轻量。4.2 解决 TCP 粘包用 MBAP 头部解析帧边界Modbus TCP 帧以 MBAPModbus Application Protocol头起始[Transaction ID Hi][Transaction ID Lo][Protocol ID Hi][Protocol ID Lo][Length Hi][Length Lo][Unit ID]其中Length字段表示后续字节数不含 MBAP 头的 6 字节。粘包即多个帧被 OS 合并为一次recv()需按Length拆分function TModbusConnection.ReceiveFrame(var Frame: TBytes): Boolean; var mbapHeader: array[0..5] of Byte; frameLength: Word; totalBytes: Integer; buffer: array[0..1023] of Byte; bytesRead: Integer; begin Result : False; // 先读 MBAP 头6 字节 if not ReceiveExact(mbapHeader, 6) then Exit; // 解析 Length 字段字节 4-5 frameLength : (mbapHeader[4] shl 8) or mbapHeader[5]; totalBytes : 6 frameLength; // MBAP 头 协议数据 // 分配缓冲区并读取完整帧 SetLength(Frame, totalBytes); Move(mbapHeader, Frame[0], 6); // 读取剩余部分 if not ReceiveExact(Frame[6], frameLength) then Exit; Result : True; end; function TModbusConnection.ReceiveExact(var Buffer; BytesToRead: Integer): Boolean; var totalRead: Integer; bytesRead: Integer; begin Result : False; totalRead : 0; while totalRead BytesToRead do begin if recv(FSocket, PByte(Buffer)[totalRead]^, BytesToRead - totalRead, 0) 0 then Exit; Inc(totalRead, bytesRead); end; Result : True; end;关键点ReceiveExact循环调用recv()直至读满指定字节数避免recv()返回少于请求长度frameLength是协议数据长度不含 MBAP 头故总帧长为6 frameLength若recv()返回 0表示连接关闭需触发重连逻辑。4.3 事务 IDTransaction ID的线程安全生成策略Modbus TCP 要求同一连接内事务 ID 唯一且主站需匹配响应中的 ID。常见错误是全局静态变量NextID: Word多线程下产生冲突。正确做法是每个连接维护独立 ID 计数器type TModbusConnection class private FTransactionID: Word; FCriticalSection: TRTLCriticalSection; public function GetNextTransactionID: Word; end; function TModbusConnection.GetNextTransactionID: Word; begin EnterCriticalSection(FCriticalSection); try Inc(FTransactionID); if FTransactionID 0 then FTransactionID : 1; // 避免 0 Result : FTransactionID; finally LeaveCriticalSection(FCriticalSection); end; end;生成请求帧时调用GetNextTransactionID并将该值写入 MBAP 头部前两字节。响应帧到达后用TDictionaryWord, TProc存储待处理回调Key 为 Transaction IDValue 为解析后执行的匿名方法确保请求与响应严格配对。5. 实战排错用 Modbus Poll 与 Wireshark 定位 Delphi 控件通信失败5.1 Modbus Poll 作为黄金标准对比工具配置与抓包步骤Modbus Poll 是工业界事实标准测试工具其配置直接影响 Delphi 控件调试效率。关键设置Connection → Read/Write选择 RTU 或 TCPRTU 下填 COM 口、波特率等TCP 下填 IP 和端口Setup → Read/Write设置功能码、起始地址、寄存器数量Display → Response Data勾选 “Show response data as hex” 和 “Show request/response in separate windows”Log → Enable Log启用日志保存为.txt文件供比对。当 Delphi 主站无法读取从站时先用 Modbus Poll 连接同一设备若 Modbus Poll 成功说明硬件链路正常问题在 Delphi 控件的帧构造或超时设置若 Modbus Poll 失败检查 COM 口权限Windows 10/11 默认禁用、USB 转 485 驱动版本、终端电阻RS-485 需 120Ω。抓包对比要点Delphi 发送帧 vs Modbus Poll 发送帧逐字节比对 Slave ID、功能码、地址、CRC响应帧长度Delphi 接收长度是否等于6 ByteCount 2RTU或6 LengthTCPCRC 校验用在线 CRC16-MODBUS 计算器验证 Delphi 生成的 CRC 是否匹配。5.2 Wireshark 过滤 Modbus TCP 流量快速定位网络层问题Wireshark 是诊断 Modbus TCP 的终极武器。安装后按以下步骤启动捕获选择网卡在过滤栏输入tcp.port 502Modbus TCP 默认端口运行 Delphi 主站程序触发一次读请求停止捕获右键任一 Modbus 包 → “Follow → TCP Stream”。关键分析点三次握手是否完成查看 SYN/SYN-ACK/ACK 包若无 ACK说明防火墙拦截或从站未监听 502 端口请求帧 MBAP 头部确认Transaction ID非零、Protocol ID0、Length字段正确响应帧延迟若请求后 1 秒才出现响应检查从站 CPU 占用率或 Delphi 从站线程是否阻塞RST 包出现 RST 表示连接被主动重置常见于从站程序崩溃或bind()失败。提示Wireshark 显示的 “Modbus/TCP” 解析可能误判务必切换到 “Hex Dump” 标签页手动核对字节序列。5.3 Delphi 控件常见崩溃点与修复方案现象根本原因修复方案Access Violation at address XXXXVCL 控件在非主线程更新 UI如TLabel.Caption : ...使用TThread.Synchronize包裹 UI 操作或改用TIdModbus等无 UI 类ReadFile returns 0 bytesCOM 口被其他程序独占如串口调试助手调用CreateFile前检查ERROR_ACCESS_DENIED提示用户关闭冲突程序CRC mismatchCRC16 计算时字节序错误或初始值非$FFFF重写 CRC 函数用已知正确帧如[01,03,00,00,00,01]→44,8C验证TCP connection reset by peer从站未实现SO_KEEPALIVE网络空闲断连在setsockopt()中启用SO_KEEPALIVE或主站定期发0x00心跳Unicode string truncationDelphi 12.3string默认 UTF-16串口驱动期望 ANSI强制转换AnsiString(UTF8Encode(MyString))或使用RawByteString最后验证技巧在 Delphi 主站中添加OnFrameSent和OnFrameReceived事件将原始帧写入TStringList.SaveToFile(log.txt)与 Modbus Poll 日志逐行比对。真正的稳定控件其日志应与 Modbus Poll 完全一致——差异即 bug 根源。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门