ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Modbus RTU与TCP协议差异详解:帧结构、校验与调试实战

Modbus RTU与TCP协议差异详解:帧结构、校验与调试实战 1. 从一条产线调试说起为什么“同一套问答”会在两种协议上翻车前阵子帮朋友处理一条包装线的通讯问题现场情况特别典型上位机用组态软件走 Modbus TCP 读一台网关网关下面挂了三台变频器和两台温控表走的是 Modbus RTU 的 RS-485 总线。朋友在办公室用调试助手单独测每一台从站都没问题一上产线就间歇性丢数据改波特率、换线、加终端电阻都试过时好时坏。最后抓包一看问题根本不在物理层而在于他把“同一套问答”直接套在了两种协议上——功能码、寄存器地址、数据格式确实一模一样但报文外面套的那层“信封”完全不同超时、重试、粘包的处理逻辑也就跟着变了。这个经历让我意识到很多人对 Modbus 的理解停留在“功能码 03 读保持寄存器、06 写单个寄存器”这个层面觉得 RTU 和 TCP 只是“一个走串口一个走网口”。真到现场恰恰是这层“差在哪一层”的认知缺口导致排查方向跑偏。这篇就围绕这个标题把两种协议从帧结构、寻址、校验、超时到实际调试的差异一层一层拆开讲清楚。不管你是刚接触 Modbus 的新手还是已经写过几套采集程序的工程师都能从中找到可以直接抄作业的细节。先给个结论性的定位Modbus 是应用层的协议规范它定义了“问什么、答什么”的语义RTU 和 TCP 是这套语义的两种承载方式差别集中在“怎么把这条问答装进帧里、怎么保证它完整到达”这一层。语义相同封装不同这就是所有差异的根源。理解了这一点后面所有的帧格式、校验、端口号、超时参数都是这个根源推导出来的结果。2. 先搞懂“同一套问答”到底同在哪Modbus 的应用层语义2.1 功能码与数据模型两种协议共享的“普通话”Modbus 的核心是一套主从问答模型。主站发请求从站回响应从站之间不互相通信。这套模型里最稳定的部分就是功能码和数据模型无论底下跑的是串口还是网口这部分完全一致。数据模型上Modbus 把从站的数据分成四类用两个维度区分是位还是字、是只读还是可读写。这就得到了四张表数据区类型访问方式常用功能码线圈 Coils位读写01 读、05 写单个、15 写多个离散输入 Discrete Inputs位只读02 读保持寄存器 Holding Registers16 位字读写03 读、06 写单个、16 写多个输入寄存器 Input Registers16 位字只读04 读这四张表就是“同一套问答”的本体。你写03 00 00 00 02这个请求意思是“从地址 0 开始读 2 个保持寄存器”这个语义在 RTU 和 TCP 上没有任何区别。从站返回的也是同样的结构功能码 字节数 数据。异常响应也一样功能码最高位置 1后面跟一个异常码比如83 02表示“读保持寄存器时非法数据地址”。2.2 地址与字节序容易踩坑但两种协议一致的地方地址这块有个经典误区协议地址和文档地址差 1。很多设备手册写“40001 对应第一个保持寄存器”而协议报文里地址字段填的是0x0000。这是因为文档用的是 1 基址、带区号前缀的表示法协议用的是 0 基址。这个规则 RTU 和 TCP 完全一样不会因为换了承载方式就变。字节序则是另一个高频坑。Modbus 规定寄存器是 16 位传输时高字节在前大端。但一个 32 位浮点数要占两个寄存器这两个寄存器谁在前、每个寄存器内部字节怎么排协议本身没强制规定全靠设备厂商。常见的有 ABCD、CDAB、BADC、DCBA 四种组合。我见过一台流量计说明书只写“32 位浮点”没写字序试了四种才读对。这个坑和 RTU/TCP 无关是设备实现层面的问题但因为两种协议共享同一套数据解析逻辑所以一旦搞错两边都错。提示遇到 32 位数据读出来是乱码或数量级离谱先别怀疑协议优先怀疑字节序和寄存器顺序。用已知值比如设定一个 25.0反推是最快的办法。2.3 为什么大家会产生“两种协议差不多”的错觉错觉来自调试工具。很多 Modbus 调试助手把 RTU 和 TCP 做成同一个界面你填好从站号、功能码、地址、数量点发送两边都能出结果。看起来就是换了个连接方式。但工具帮你隐藏了最关键的部分RTU 需要你自己算 CRC 并附加在帧尾TCP 需要你自己拼 MBAP 头。工具自动做了这些你就以为没有差异。另一个原因是功能码层面的错误处理逻辑相似。不管是 RTU 还是 TCP从站返回异常都是功能码 0x80 再跟异常码。这种一致性让人放松警惕直到遇到粘包、半包、超时重试这些问题才发现底层的差异会直接影响到应用层的稳定性。所以下一节我们就从帧结构入手把这层“信封”彻底拆开。3. 差在哪一层RTU 与 TCP 的帧结构逐字节对比3.1 Modbus RTU 帧靠时间间隔划界的紧凑结构RTU 帧的结构非常紧凑没有起始符也没有结束符靠的是帧间静默时间来划分边界。标准规定帧内字符间隔不能超过 1.5 个字符时间帧与帧之间至少要有 3.5 个字符时间的静默。所谓“字符时间”取决于波特率和数据位比如 9600 波特、8 数据位、无校验、1 停止位一个字符是 10 位那 3.5 个字符时间就是 3.5 × 10 / 9600 ≈ 3.65 毫秒。一个典型的 RTU 读请求长这样01 03 00 00 00 02 C4 0B | | | | | | | | | -- 寄存器数量 2 | | | -------- 起始地址 0 | | -------------- 功能码 03 | ----------------- 从站地址 01 -------------------- CRC16 校验低字节在前注意最后两个字节C4 0B这是 CRC16 校验低字节在前、高字节在后和寄存器数据的大端顺序正好相反。这是新手最容易写错的地方之一。CRC 的计算范围是从站地址到数据最后一个字节不包括 CRC 本身。RTU 帧的最大长度是 256 字节地址 1 PDU 253 CRC 2。地址范围 1 到 2470 是广播地址248 到 255 保留。广播时从站不响应只执行写操作这个特性在多台设备同时设定参数时很有用。3.2 Modbus TCP 帧多了一层 MBAP 头TCP 帧在原来的 PDU 前面加了一个 7 字节的 MBAP 头Modbus Application Protocol header然后整个交给 TCP 传输。结构如下字段长度说明事务标识符 Transaction ID2 字节主站自增响应原样返回用于匹配请求和响应协议标识符 Protocol ID2 字节Modbus 固定为 0长度 Length2 字节后续字节数包括单元标识符 PDU单元标识符 Unit ID1 字节类似 RTU 的从站地址网关场景下用来区分后端设备PDU可变功能码 数据和 RTU 完全一致同样读 2 个保持寄存器的请求TCP 帧是00 01 00 00 00 06 01 03 00 00 00 02 | | | | | | | | | | | | | -- 数量 2 | | | | | -------- 起始地址 0 | | | | ----------- 功能码 03 | | | -------------- 单元标识符 01 | | -------------------- 长度 6单元标识符1 PDU5 | -------------------------- 协议标识 0 -------------------------------- 事务标识 1对比一下就很清楚了TCP 没有 CRC因为 TCP 本身有校验和重传机制TCP 多了事务标识因为一个连接上可以并发多个请求TCP 多了长度字段因为 TCP 是字节流必须靠长度来切分消息。这三个差异直接决定了应用层代码的写法完全不同。3.3 一张表看清两种帧的字段差异对比项Modbus RTUModbus TCP帧起始无靠 3.5 字符静默MBAP 头靠长度字段地址字段从站地址 1 字节单元标识符 1 字节在 MBAP 内校验CRC162 字节低字节在前无依赖 TCP 校验事务匹配靠从站地址 请求响应顺序靠事务标识符最大帧长256 字节受 TCP 和缓冲区限制理论 260 字节默认端口无串口502广播地址 0从站不响应一般不使用TCP 是点对点这张表建议存下来写代码或排查问题时对着看能省很多时间。尤其是“事务匹配”这一行RTU 是严格的一问一答主站发完必须等响应或超时才能发下一条TCP 可以在一个连接上流水线式发多条靠事务标识区分响应。这个差异在写高性能采集程序时是决定性的。4. 校验与可靠性CRC 和 TCP 校验各自扛了什么4.1 CRC16 怎么算手算一遍就懂了CRC 是 RTU 的命根子算错了从站直接丢弃连异常响应都不给。Modbus 用的是 CRC-16/MODBUS多项式0x8005初始值0xFFFF输入输出都反射结果异或0x0000。听起来复杂其实用查表法或位运算法几行代码就能实现。位运算版本的核心逻辑是这样def crc16_modbus(data: bytes) - int: crc 0xFFFF for byte in data: crc ^ byte for _ in range(8): if crc 0x0001: crc (crc 1) ^ 0xA001 else: crc 1 return crc注意0xA001是0x8005的反射形式。算完之后低字节先发高字节后发。比如算出0x0BC4发送顺序是C4 0B。我见过不少人算对了 CRC 但发送顺序反了结果一直通讯失败查半天查不出来。验证方法很简单拿01 03 00 00 00 02去算应该得到0x0BC4。如果你算出来是0xC40B那就是字节序反了。这个自测用例建议记牢调试时第一时间验证自己的 CRC 函数对不对。4.2 TCP 为什么不需要 CRC可靠性下沉到了传输层TCP 本身有 16 位校验和虽然这个校验和比较弱只是反码求和但配合 TCP 的重传、确认、排序机制实际可靠性远高于串口上的 CRC。所以 Modbus TCP 规范里干脆去掉了 CRC把可靠性完全交给 TCP。这不是偷懒是分层设计的体现应用层不该重复做传输层已经做好的事。但这带来一个副作用如果你用 UDP 承载 Modbus虽然不标准但有人这么干就没有 CRC 也没有 TCP 的重传可靠性会非常差。所以标准里 Modbus TCP 明确基于 TCP端口 502。4.3 串口上的“隐形杀手”字符间隔与超时RTU 靠时间划界这就对实时性提出了要求。如果主站发送时被操作系统调度打断帧内字符间隔超过 1.5 个字符时间从站会认为帧结束把后面的字节当成新帧的开始导致解析错乱。在 Windows 上用普通串口 API 发送长帧时这种情况并不罕见。解决办法有两个一是降低波特率给字符间隔留出余量二是用带 FIFO 的串口芯片或实时操作系统。实测下来9600 波特比 115200 波特稳定得多因为字符时间长对调度抖动的容忍度更高。这也是为什么很多工业现场宁愿用低速也不愿意提速。注意RTU 的 3.5 字符静默在波特率高于 19200 时规范建议固定用 1.75 毫秒而不是按波特率计算。因为高速下 3.5 字符时间太短普通硬件根本区分不出来。5. 实操从零写一个能同时跑 RTU 和 TCP 的采集程序5.1 整体架构把“语义”和“承载”分开写代码时最容易犯的错是把协议逻辑和传输逻辑揉在一起结果 RTU 和 TCP 各写一套功能码解析重复两遍。正确的做法是分层应用层负责构造 PDU功能码 数据、解析 PDU、处理异常码。这部分两种协议共用。传输层RTU 负责加地址和 CRC、处理串口读写和超时TCP 负责加 MBAP 头、处理 socket 读写和事务匹配。接口层定义一个统一的send_pdu(unit_id, pdu) - response_pdu接口上层不关心底下是串口还是网口。这样写的好处是新增一种承载方式比如 RTU over TCP很多网关用这个只需要加一个传输层实现应用层代码一行不用改。5.2 RTU 传输层实现要点串口配置的关键参数波特率、数据位 8、停止位 1、校验位常见无校验或偶校验。打开串口后发送前先清空接收缓冲区避免上一次的残留数据干扰。发送流程拼地址 PDU CRC写入串口然后进入读取循环。读取时不能一次性read(256)然后等因为不知道响应多长。正确做法是先读前 3 个字节地址 功能码 字节数或异常码根据功能码判断后续长度再读剩余部分。超时设置一般 100 到 1000 毫秒取决于波特率和从站响应速度。import serial import struct def build_rtu_frame(unit_id, pdu): frame bytes([unit_id]) pdu crc crc16_modbus(frame) frame struct.pack(H, crc) # 小端低字节在前 return frame def read_rtu_response(ser, timeout0.5): ser.timeout timeout head ser.read(3) if len(head) 3: return None func head[1] if func 0x80: tail ser.read(2) # 异常码 CRC return head tail byte_count head[2] tail ser.read(byte_count 2) # 数据 CRC return head tail这段代码里有个细节异常响应的长度是固定的 5 字节地址 功能码 异常码 CRC 2 字节正常响应的长度取决于字节数。判断func 0x80就能区分这是 RTU 解析的关键分支。5.3 TCP 传输层实现要点TCP 这边用 socket连接建立后发送 MBAP PDU。事务标识符用一个自增计数器每次请求加 1响应回来时校验事务标识是否匹配。长度字段填1 len(pdu)因为要算上单元标识符。接收时因为 TCP 是字节流必须按长度字段来切。先读 7 字节 MBAP 头解析出长度再读剩余部分。这里有个坑recv不保证一次读满必须循环读直到读够字节数。import socket import struct def build_tcp_frame(tx_id, unit_id, pdu): length 1 len(pdu) header struct.pack(HHHB, tx_id, 0, length, unit_id) return header pdu def recv_exact(sock, n): buf b while len(buf) n: chunk sock.recv(n - len(buf)) if not chunk: raise ConnectionError(connection closed) buf chunk return buf def read_tcp_response(sock): header recv_exact(sock, 7) tx_id, proto_id, length, unit_id struct.unpack(HHHB, header) body recv_exact(sock, length - 1) return tx_id, unit_id, bodyrecv_exact这个函数是 TCP 编程的基本功任何基于长度字段的协议都要这么写。直接sock.recv(1024)然后假设一次读完在局域网低负载下可能没问题一旦网络抖动或数据量大就会出半包问题。5.4 统一接口与调用示例把两种传输层封装成同一个接口后上层读寄存器的代码就通用了def read_holding_registers(transport, unit_id, start, count): pdu struct.pack(BHH, 0x03, start, count) resp transport.send_pdu(unit_id, pdu) if resp[0] 0x80: raise ModbusException(resp[1]) byte_count resp[1] values struct.unpack( H * (byte_count // 2), resp[2:2byte_count]) return values这段代码不管 transport 是 RTU 还是 TCP 都能跑因为 PDU 的构造和解析完全一样。这就是分层带来的好处也是理解“差在哪一层”之后最直接的收益。6. 现场排查那些年踩过的坑和速查表6.1 RTU 常见问题与排查思路RTU 的问题大多集中在物理层和时序上。下面这张表是我这些年遇到最多的几类现象可能原因排查方法完全无响应接线 A/B 反、波特率不对、从站地址错用调试助手单独测确认参数偶发丢帧帧内字符间隔超时、终端电阻缺失降波特率、加 120 欧终端电阻CRC 错误CRC 算法错、字节序反、线路干扰用已知帧验证 CRC 函数响应错位上一次响应未读完就发下一条发送前清空缓冲区严格一问一答多从站冲突地址重复、总线拓扑分支过长逐台断开排查检查地址设置终端电阻这块补充一句RS-485 总线两端各需要一个 120 欧电阻中间设备不加。很多现场图省事一个都不加短距离低速可能没事一旦距离超过几十米或波特率上去反射就会导致误码。这个电阻不是可选项是规范要求。6.2 TCP 常见问题与排查思路TCP 的问题更多在应用层和网络层。502 端口连不上、事务标识不匹配、粘包是三大高频问题。现象可能原因排查方法连接被拒端口不对、设备未监听、防火墙telnet 测端口确认设备配置响应错乱事务标识未校验、并发请求未隔离每个请求独立事务 ID响应按 ID 匹配半包/粘包未按长度字段切分用 recv_exact 按 MBAP 长度读网关下从站无响应单元标识符填错、网关映射未配确认网关的后端设备映射表长时间无响应未设 socket 超时设置 SO_RCVTIMEO配合重试这里重点说单元标识符。在纯 TCP 设备上这个字段通常填 1 或 0xFF设备可能忽略它。但在“TCP 转 RTU”的网关上这个字段就是后端 RTU 从站的地址填错了网关就不知道转发给谁。我见过一个项目网关手册写“单元标识符填从站地址”结果有人填了 0网关把请求广播给所有从站全部响应数据全乱。这个坑很隐蔽因为 TCP 层看起来完全正常。6.3 一个真实的排查案例间歇性超时回到开头那个包装线的例子。抓包后发现主站发 TCP 请求给网关网关转成 RTU 发给从站从站响应后网关再转回 TCP。问题出在网关的 RTU 侧超时设置太短只有 50 毫秒而现场有一台变频器响应要 80 毫秒左右导致网关偶尔收不到响应返回 TCP 超时。主站看到超时就重试重试的请求和上一次的响应在网关上交错进一步加剧了混乱。解决办法是把网关的 RTU 超时调到 500 毫秒同时主站的 TCP 超时调到 1 秒以上给网关留出转发时间。另外把主站的重试次数从 3 次降到 1 次避免重试风暴。调整后连续跑了 72 小时没再丢数据。这个案例的教训是RTU 和 TCP 的超时是串联的总超时必须是各段之和再加余量。很多人只设主站超时忽略了网关内部的超时结果就是间歇性失败极难排查。6.4 独家避坑技巧汇总CRC 自测用例01 03 00 00 00 02的 CRC 是C4 0B写完 CRC 函数先跑这个。RTU 发送前清缓冲串口接收缓冲区里的残留数据是错位响应的头号原因。TCP 事务 ID 从 1 开始自增不要用 0有些设备对 0 有特殊处理。网关场景先确认单元标识符语义是透传还是映射手册一定要看仔细。超时分层设置主站超时 网关超时 从站响应时间逐级放大。32 位数据先确认字节序用已知值反推别猜。长总线加终端电阻120 欧两端各一个别省。高波特率下固定 1.75ms 静默别按公式算硬件区分不出来。这些技巧没有一条是教科书上会重点讲的但每一条都是现场真金白银换来的。尤其是超时分层和单元标识符这两条坑过的人不在少数。7. 选型与扩展什么时候用 RTU什么时候用 TCP7.1 选型的基本判断逻辑选 RTU 还是 TCP核心看三点距离、设备数量、现有基础设施。距离短、设备少、现场没有网络布线RTU 是最经济的选择。一根双绞线能挂 32 台设备加中继器可扩展到 247 台成本极低。缺点是速率低、排查麻烦、不支持并发。距离远、设备多、已经有以太网TCP 更合适。一个交换机就能覆盖整个车间速率高支持并发排查用抓包工具一目了然。缺点是需要网络配置对 IT 和 OT 的边界管理有要求。实际项目中混合架构最常见现场设备走 RTU 总线通过网关汇聚成 TCP 接入上位机或 SCADA。这种架构兼顾了成本和便利也是开头那个案例的典型场景。7.2 RTU over TCP一个容易混淆的变体有些网关支持“RTU over TCP”就是把完整的 RTU 帧含 CRC原封不动地塞进 TCP 里传输端口可能还是 502也可能自定义。这种模式下TCP 层没有 MBAP 头接收方要靠 CRC 和静默时间或帧长推断来切帧。它既不是标准 RTU 也不是标准 TCP调试时最容易搞混。判断方法很简单看报文里有没有 MBAP 头。如果前两个字节是事务标识、接着是00 00那就是标准 TCP如果直接是从站地址开头、末尾有 CRC那就是 RTU over TCP。用调试助手时选错模式就会出现“明明能连上但数据不对”的情况。7.3 后续可以扩展的方向这套分层思路可以继续往下延伸。比如把传输层抽象成插件支持串口、TCP、UDP、甚至串口服务器把 PDU 层做成通用的 Modbus 客户端支持功能码 01 到 16 的完整实现再加一层设备模板把不同厂商的寄存器映射配置化换设备只改配置不改代码。我在实际项目里就是这么做的一套采集框架跑了三年从 RTU 换到 TCP、从单设备换到多网关应用层代码基本没动过。这种“语义与承载分离”的设计是理解 Modbus 两种协议差异之后最实在的收获。
返回列表