DTU 透传模式下的 Modbus 实战:三个月里每天几次”通信中断”,去看又好了——透传 DTU 根本不知道帧边界在哪
配电房里有三台多功能电表、一台储能逆变器,通过一台 4G DTU 接到云平台。DTU 配的是透传模式(透明传输),云端跑的是 Modbus RTU over TCP——RTU 帧原样封进 TCP,CRC 由云端自己算,DTU 只管搬字节。串口 9600 8E1。
上线三个月,攒下三个症状:
- 每天 3~8 次”设备离线”告警,持续几十秒,自己恢复。运维跑过去看,一切正常。
- 偶发 CRC 校验错,重发一次就好。日志里一周几十条。
- 某台表的三相电压偶尔”两相数值互换”,没人报障,是我拿现场抄表本对账时才发现的。
前两条只是吵,第三条要命——它不报错,CRC 全对,长度全对,功能码全对,只是数据是上一帧的。
三个症状查到最后是同一个根:Modbus RTU 的帧边界是一段时间,而透传链路最不能保证的偏偏是时间。
读侧的对接细节在 储能 BMS 与 PCS 的 Modbus 对接实战 里写过,写侧的权限与防呆在 Modbus 写参数的权限矩阵与防呆实战 里写过——那两篇讲的都是”应用层该怎么做”。这篇讲更下面一层:字节到底是怎么从云端走到那根 485 线上的。
DTU 是个字节管道,它不知道一帧从哪开始、到哪结束;它只知道”收到字节就往串口写”。于是所有帧边界的判断,都被推给了那条链路上最不可控的两个量:网络抖动和打包延迟。
这篇文章把这条链路上的账一笔一笔算清楚。所有数值都能用一个离线脚本复算,不引用任何”经验值”。
一、帧边界是时间,不是字节
Modbus RTU 没有任何长度字段、没有起始符、没有转义。接收端判断”一帧结束”的唯一依据是静默:总线安静超过 3.5 个字符时间,就认为上一帧完了。
规范(Modbus over Serial Line V1.02)写得很明确:
- 帧内字符间隔超过 1.5 个字符时间,判为帧损坏;
- 帧间静默超过 3.5 个字符时间,判为帧结束;
- 波特率高于 19200 时,这两个值一律固定为 1.750ms,不再按字符时间算。
第三条是很多人漏掉的。用脚本把各波特率的账摊开:
$ python dtu_modbus_frame_calc.py char
波特率 字符ms T1.5ms T3.5ms T3.5@8N1 1ms tick量化 误差
1200 9.17 13.75 32.08 29.17 33ms 2.86%
2400 4.58 6.88 16.04 14.58 17ms 5.97%
4800 2.29 3.44 8.02 7.29 9ms 12.21%
9600 1.15 1.72 4.01 3.65 5ms 24.68%
19200 0.57 0.86 2.01 1.82 3ms 49.61%
38400 0.29 1.75 1.75 0.91 2ms 14.29%
57600 0.19 1.75 1.75 0.61 2ms 14.29%
115200 0.10 1.75 1.75 0.30 2ms 14.29%
9600 8E1 下,一个字符 1.146ms,T3.5 = 4.01ms。注意这是 8E1(11 bit/字符);如果从站是 8N1(10 bit),同一个波特率下 T3.5 是 3.65ms,差 10%。串口校验位配错,帧间隔阈值就跟着错 10%,这类错误在”偶尔 CRC 错”的现场里非常常见——两端都能通信,只是在边界帧上开始翻车。
再看最后两列。嵌入式 RTOS 的 tick 通常是 1ms,4.01ms 要么取 4 个 tick(欠 0.3%),要么取 5 个 tick(多等 24.7%)。取整本身不致命,致命的是这个阈值只有 4ms,比一次任务切换的抖动还小。
而 115200 那一行更值得看:如果实现按 3.5 × 字符时间 硬算,得到 0.334ms——离 1.75ms 的规范要求差了 80.9%。在 1ms tick 的系统上,这个值要么被取整成 1ms(仍然欠 43%),要么在”小于一个 tick”时永远等不到,直接退化成”不等静默”。
38400bps: 硬算 1.003ms → 1ms tick 取 2ms(规范要求 ≥1.75ms,欠 42.7%)
57600bps: 硬算 0.668ms → 1ms tick 取 1ms(规范要求 ≥1.75ms,欠 61.8%)
115200bps: 硬算 0.334ms → 1ms tick 取 1ms(规范要求 ≥1.75ms,欠 80.9%)
这就是”提高波特率之后反而更容易粘包”的机制:数据传得快了,但判帧的窗口缩得比数据还快,最后窗口小到调度器根本分辨不出来。
二、拆帧:一帧被 TCP 切成两段
透传 DTU 的行为可以用一句话概括:TCP 收到多少字节,就往串口写多少字节,中间不插任何等待。
那么问题来了:TCP 是字节流,接收端的 recv 边界和发送端的 write 边界没有任何对应关系。 上位机明明一次性 write 了 8 个字节,这段数据在中途被拆成两个 TCP 段到达,完全符合协议——路径 MTU 变了、经过了一个重组代理、或者发送端底层本来就分两次下发,都会这样。
假设一帧 8 字节的读请求被切成两段到达。DTU 收到第一段就写串口,写完串口空着,等第二段(几十到几百毫秒后)再写第二段。
从站看到的是:半截帧、然后一段 30ms 的静默、然后剩下的字节。30ms 远大于 4.01ms 的 T3.5,从站判定”前半截是一帧”。
$ python dtu_modbus_frame_calc.py split
网络RTT 单向间隔 9600 T3.5 是否拆帧 115200 T3.5 是否拆帧
10ms 5.00ms 4.01ms 是 1.75ms 是
20ms 10.00ms 4.01ms 是 1.75ms 是
50ms 25.00ms 4.01ms 是 1.75ms 是
100ms 50.00ms 4.01ms 是 1.75ms 是
200ms 100.00ms 4.01ms 是 1.75ms 是
400ms 200.00ms 4.01ms 是 1.75ms 是
只要网络单向延迟超过 3.6ms,拆帧就是必然的——4G 上是常态,连跨城光纤专线也常见。
这里有个反直觉的结论,值得单独说:在 Modbus 透传链路上关掉 Nagle,会让事情变坏。
Nagle 算法会把小包攒着等 ACK,配合 TCP 的延迟确认(典型 40ms),本该分成两段的数据往往会被合并成一个 TCP 段发出——延迟变差了 40ms,但帧是完整的。一旦你为了”降延迟”关掉 Nagle(TCP_NODELAY),两个段立刻各走各的,拆帧率从 0 跳到接近 100%。用 40ms 的延迟换一个完整的帧,在 Modbus 场景里是笔划算的买卖。
真正该改的是上位机的写法:一帧 RTU 拼成一个 buffer,一次 write 出去。这是零成本的修复,但很多串口/网络库的封装层会先写帧头再写数据,文档里不会提。
拆帧之后更麻烦的是从站的恢复行为。前半截进来,CRC 必然错,多数实现会”丢弃这一帧”——但丢弃的是帧标志,不是接收缓冲。缓冲里那半截字节还在,下一帧的帧头接上去,整帧错位一格,于是”一个请求换回一串连环错位”。
正确的从站实现只有一句:CRC 错 → 清空整个接收缓冲 → 重新等 3.5 字符静默再开始收。这一句没写,拆帧就从”丢一帧”升级成”丢一片”。
三、粘包:DTU 是字节管道,它不知道帧在哪结束
拆帧的反面是粘包。两个来源:
第一,上位机 pipeline。 云端为了提速,把多个请求连着下发(不同线程读不同从站,或者一个批处理里连发三帧)。三个帧在 DTU 的 TCP 接收缓冲里首尾相接,DTU 一次性写进串口——帧间静默 = 0ms。从站收到的是 24 个连续字节,它会把这当成一帧,CRC 错;或者更糟,把第二帧的从站地址当成第一帧的数据,长度刚好对得上,CRC 也刚好过。
第二,一问一答也不能幸免。 上一帧的尾部和下一帧的头部如果在 DTU 缓冲里撞上(上一帧还没写完,下一帧已经到了),一样是零间隔。
| 主站帧间隔(线上) | 9600bps 的 T3.5 | 判定 |
|---|---|---|
| 0.0ms | 4.01ms | 粘包 |
| 2.0ms | 4.01ms | 粘包 |
| 4.0ms | 4.01ms | 粘包 |
| 5.0ms | 4.01ms | 正常分帧 |
| 20.0ms | 4.01ms | 正常分帧 |
4ms 和 5ms 之间就是生死线,而这条线在 TCP 侧根本不可控——你无法在应用层保证”两帧之间一定隔 4.01ms”,因为字节到达 DTU 的时刻由网络决定。
所以结论很硬:透传 DTU 在原理上无法保证 Modbus RTU 的帧边界。这不是配置问题,是架构问题。
出路只有一条:让 DTU 会算帧长。Modbus RTU 的帧长是可推导的:
| 功能码 | 方向 | 帧长(字节) | 判据 |
|---|---|---|---|
| FC01/02/03/04 | 请求 | 8 | 固定 |
| FC01/02 | 响应 | 3 + 2N | 字节计数字段 N |
| FC03/04 | 响应 | 3 + 2N | 字节计数字段 N |
| FC05/06 | 请求/响应 | 8 | 固定 |
| FC15/16 | 请求 | 9 + 2N | 字节计数字段 N |
| FC15/16 | 响应 | 8 | 固定 |
| 任意 | 异常响应 | 5 | 功能码 bit7 置位 |
| 广播(地址 0) | 请求 | 按功能码 | 无响应,不能等 |
有了这张表,DTU 就能做一个确定性的状态机:收到帧头 → 按功能码定帧长 → 收满才写串口 → 帧间强制插入 4 个字符的静默。帧边界从”猜时间”变成”数字节”。
这就是“Modbus 网关”和”透传 DTU”的唯一本质区别——不是协议转换(RTU 到 RTU 有什么可转的),是帧同步。市面上叫”Modbus 网关模式””协议感知模式””主动采集模式”的,本质都是这一层状态机。
关键结论:整帧缓冲不增加延迟。 请求帧本来就要收满 8 字节才能发;响应帧按字节计数收满才写——这一层是纯状态机,零额外等待。唯一的成本是帧间那 4 个字符:
额外延迟 = 4×1.15ms = 4.58ms
端到端 179.81 → 184.40ms(+2.5%)
4.58ms,2.5%。 用一个帧间静默换掉一整类帧边界问题,这是这条链路上性价比最高的一次改动。
四、端到端延迟预算:大头痛不在 485
很多人调这条链路的第一个动作是”把波特率提到 115200″。先看预算表。
读 10 个寄存器,请求 8 字节、响应 25 字节,9600 8E1:
| 场景 | 网络往返 | DTU 收转 | 请求帧 | 从站处理 | 响应帧 | DTU 打包 | 重传 | 合计 |
|---|---|---|---|---|---|---|---|---|
| 典型(RTT 50ms,打包 50ms) | 50.00 | 2.00 | 9.17 | 20.00 | 28.65 | 50.00 | 0.00 | 159.81 |
| p99(RTT 400ms,打包 50ms) | 400.00 | 2.00 | 9.17 | 50.00 | 28.65 | 50.00 | 0.00 | 539.81 |
| p99 + 1 次重传(RTO 200ms) | 400.00 | 2.00 | 9.17 | 50.00 | 28.65 | 50.00 | 200.00 | 739.81 |
| p99 + 2 次重传(200+400ms) | 400.00 | 2.00 | 9.17 | 50.00 | 28.65 | 50.00 | 600.00 | 1139.81 |
| 打包 200ms 的默认配置 | 50.00 | 2.00 | 9.17 | 20.00 | 28.65 | 200.00 | 0.00 | 309.81 |
(单位 ms。dtu_modbus_frame_calc.py budget 可复算。)
看清楚这几件事:
- 串口那两帧加起来 37.81ms,只占 159.81ms 的 23.7%。
- DTU 打包间隔 50ms,比整条 485 上的收发时间还长。
- 把波特率从 9600 提到 115200,端到端只从 159.81 降到 125.15ms——降 21.7%。
- 把 DTU 打包间隔从 50ms 改到 10ms,端到端从 159.81 降到 119.81ms——降 25.0%。
改一个 DTU 的配置项,比把所有从站波特率改成 115200、重新拉一遍布线还管用。 而且后者还要承担改错校验位的风险。
那个打包间隔到底在干什么?DTU 上行(设备→云)时不是收到一个字节就发一个 TCP 包——那样一个 25 字节的响应会拆成好几个小包,流量和开销都受不了。它要攒:等到”打包间隔到”或者”打包长度满”才封包发出。
| 打包间隔 | 打包长度 | 25B 响应等待 | 触发方式 |
|---|---|---|---|
| 10ms | 1024B | 10ms | 等满间隔 |
| 50ms | 1024B | 50ms | 等满间隔 |
| 200ms | 1024B | 200ms | 等满间隔 |
| 200ms | 32B | 0ms | 长度先触发 |
Modbus 响应永远小于 256 字节,所以”打包长度”这一项在 Modbus 场景里几乎永远是”等满间隔”那一支。 出厂默认 1024B,等于这一项从来没起过作用,起作用的只有打包间隔——而它直接加在每一次事务的端到端延迟上。
顺带说一句出厂默认 200ms 的那一档:典型条件下端到端就是 309.81ms。主站超时要是按”本地串口经验”设 300ms,这个配置下永远超时。
五、超时怎么定:p99、重传、每天几次误判
超时是这条链路上最容易被拍脑袋定的参数。现场的分布是这样的:
典型=159.81ms p99=539.81ms p99+1重传=739.81ms p99+2重传=1139.81ms
按 4G 的丢包率(0.5%~2%/方向)算,一个事务涉及两个方向,丢一次由 RTO 200ms 补回,丢两次补回需要 200+400=600ms:
| 超时设定 | 能覆盖到 | 误判 p=0.5% | 误判 p=1% | 误判 p=2% |
|---|---|---|---|---|
| 300ms | p99 都不够 | — | — | — |
| 500ms | p99 都不够 | — | — | — |
| 600ms | 无重传 | 172.4/天 | 343.9/天 | 684.3/天 |
| 800ms | 1 次重传 | 0.4/天 | 1.7/天 | 6.9/天 |
| 1000ms | 1 次重传 | 0.4/天 | 1.7/天 | 6.9/天 |
| 1200ms | 2 次重传 | ≈0 | ≈0 | ≈0 |
(每日事务数按 5s 一轮、单设备算,17280 次/天。)
1000ms 这个”看起来很美”的默认值,刚好卡在刀口上:能覆盖一次重传,覆盖不了两次。 丢包率 1% 时每天约 1.7 次误判,2% 时每天 6.9 次。
这就是现场那句”每天总有几次通信中断告警,去看又好了”的准确来源。不是设备坏了,是超时贴着 p99 设,长尾全被判成了故障。
500ms 以下在 4G 上直接不可用——p99 就已经 540ms。很多网关出厂默认 300ms,那是给本地串口主站设计的,照搬到 4G 上必然大面积误报。
但也别一味加大。超时越长,一次真故障吃掉的轮询预算越多:
| 设备数 | 墙钟合计 | 5s 周期占用 | 10s 周期占用 | 撞上一次 1s 超时后 |
|---|---|---|---|---|
| 10 | 1.60s | 31.96% | 15.98% | 51.96% |
| 20 | 3.20s | 63.92% | 31.96% | 83.93% |
| 31 | 4.95s | 99.08% | 49.54% | 119.08% |
| 100 | 15.98s | 319.62% | 159.81% | 339.62% |
5s 周期在 31 台设备时就是 100% 满载。 满载系统的特征是:任何一次 1s 超时都让整轮滑出周期,下一轮立刻追尾,然后全员超时——雪崩。
工程上留 40% 余量(占用 ≤60%)不是保守,是底线。按这个标准,5s 周期最多接 18 台。
推荐配置:超时 = p99 × 1.5 ≈ 800~1200ms;重试 2 次、退避 500ms;重试前必须做重同步(下一节)。 另外,超时下限还有一个硬约束:不能小于最长响应帧的传输时间。9600bps 下 256 字节的满长帧要 293.33ms,读 100 个寄存器的响应是 234.90ms——超时设 200ms,这种请求永远读不回来。
六、错位:最危险的错误是”CRC 全对”
现在说第三条症状,也就是那个”三相电压偶尔互换”。
Modbus RTU 没有事务号。两帧之间唯一的区分是长度和功能码——两次 FC03 读同样数量的寄存器,帧长完全相同,物理上没有任何办法区分。
于是这个场景成立了:主站发出请求 A,超时(1000ms),立刻发请求 B;请求 A 的响应在 1140ms 时终于到达——此时主站正等着 B 的响应,它把这帧 CRC 完全正确、长度完全正确、功能码完全正确的数据,当成了 B 的答案。
$ python dtu_modbus_frame_calc.py desync
丢失次数 重传退避 实际到达 相对超时 落到哪一帧
0 0.00ms 539.81ms -460.19ms 本帧内(正常)
1 200.00ms 739.81ms -260.19ms 本帧内(正常)
2 600.00ms 1139.81ms 139.81ms 下一帧 +140ms(错位)
3 1400.00ms 1939.81ms 939.81ms 下一帧 +940ms(错位)
四种结局里有两种落在”下一帧的等待窗内”。 而且这不是小概率——丢两次包在 1% 丢包率下每天就有 1.7 次,三个月下来一百多次,完全够把”偶发”变成”对账时才发现”。
什么时候是静默错误? 看轮询序列的构成:
- 如果序列是
Ua → Ia → P,错位后电压位置显示电流值(220 变成 5.2),一眼假,人工能看到。 - 如果序列是
Ua → Ub → Uc三次独立事务,错位后 Ub 位置显示 Ua 的值。三相电压差通常不到 2%——没有任何告警会响。
错位最危险的地方,恰恰是同类数据之间的错位。
修复只有两条路,都不贵:
路一:应用层带事务标记。 设备侧提供一个递增的”通信计数”寄存器(每次收到合法请求 +1),主站每次读数据块时顺带读它,校验是否连续。多花 2 个字节,能抓 100% 的错位。这个寄存器几乎所有电表都有(叫”通信次数””帧计数””运行计数”之类),只是很少有人用。
路二:超时后重同步。 丢弃接收缓冲 → 发一个固定读请求 → 连续两次返回同一值才恢复轮询。代价是一次重同步约 2 个事务时间(320ms),比一个静默错误便宜两个数量级。
还有一个更物理的坑:半双工总线冲突。485 是半双工的,DTU 自己控制 DE 引脚。主站超时重试时,从站的响应可能还在路上(DTU 正在往串口写),此时下一个请求到达,DTU 立刻切发送——两边同时驱动总线,数据全毁。透传 DTU 不知道从站什么时候发完,它只能靠串口静默判断;而网络延迟恰好让”下一个请求”在”上一个响应”中途到达。
这个现象的表征是”连续几帧全错,然后突然好了”,很容易被误判成干扰。根治办法还是同一个:让 DTU 知道帧边界,收满一帧才发下一帧。
七、心跳:连接还在,路已经没了
还有一个独立的坑,跟帧边界无关,但症状很像”设备离线”。
4G 网络里,运营商的 NAT 表项有超时——典型 60~300 秒。而 Linux 的 TCP keepalive 默认是 7200 秒。
| 场景 | NAT 表项 | TCP keepalive | 建议心跳 | 余量 |
|---|---|---|---|---|
| 家宽 / 企业出口 | 300s | 7200s | 30s | 10.0x |
| 运营商 4G(典型) | 180s | 7200s | 30s | 6.0x |
| 运营商 4G(保守) | 60s | 7200s | 30s | 2.0x |
| 部分物联网专网 | 30s | 7200s | 15s | 2.0x |
keepalive 是 NAT 表项的 24~120 倍。 表项被回收后,两端都认为”连接已建立”:主站发数据没人应,TCP 会一直重传直到判死,这个过程可能几十分钟。期间所有请求都在往一个死连接里灌。
现场表现:“设备白天正常,晚上掉线”或者”断线间隔特别规律”——因为夜里的空闲期更长,更容易撞上 NAT 回收。
修法:应用层心跳,间隔取 NAT 超时的 1/2,最多 30s。6 字节心跳 × 2880 次/天 = 17.3KB/天,流量可以忽略。但它会把 4G 模组从空闲态唤醒,Cat.1 平均多耗约 1.3mA——市电设备无所谓,电池设备要把这笔账算进去。
“连接还在、路已经没了”这类问题不是 Modbus 独有的,MQTT over TLS 实战 里那套长连接保活的思路可以整段平移过来——协议换一个,NAT 不讲情面的程度一样。
八、吞吐:485 很空,轮询很慢
最后算一笔吞吐账,因为它决定了这套系统能接多少台设备。
$ python dtu_modbus_frame_calc.py poll
请求帧 9.17ms + 响应帧 28.65ms + 2×静默 8.02ms = 45.83ms(总线占用)
端到端墙钟 159.81ms
总线利用率 = 28.7% ← 485 有 70% 的时间在等网络
485 上 71% 的时间是空的,但轮询就是快不起来,因为墙钟被网络和打包间隔占满了。
除了硬扛这个墙钟,还有两条路,按性价比排:
| 方案 | 单次墙钟 | 100 个寄存器的总耗时 | 相对收益 |
|---|---|---|---|
| A:10 次「读 10 个」 | 159.81ms | 1598.12ms | 基准 |
| B:1 次「读 100 个」 | 446.06ms | 446.06ms | 3.58 倍 |
| C:网关本地轮询,云端读缓存 | 55ms | — | 2.9 倍(且不受网络抖动影响) |
(B 方案响应帧 205 字节,线缆时间 234.90ms。)
合并读是最大的一笔:把 10 次小事务合成 1 次大事务,省掉的不是串口时间,是 9 次网络往返和 9 次打包等待。上限是 RTU 的 256 字节 ADU——响应 205 字节对应 100 个寄存器,想到 120 个以上就得拆两次或者用 FC23。
方案 C 是架构级解法:让 DTU 兼做 Modbus 主站,本地按固定周期轮询,云端只读网关缓存。云端延迟降到 55ms,而且总线不再受网络抖动影响——网络断了,本地采集照常,恢复后补传。代价是网关要维护一份点表和缓存,复杂度上一个台阶;缓存怎么掉电不丢、擦写寿命怎么算,是 Flash 存储与参数管理实战 里那笔账。
九、落地清单与排查表
上位机侧:
- ☐ 一帧 RTU 拼成一个 buffer 一次
write,不要分两次下发 - ☐ 不要为了降延迟关 Nagle——它会提高拆帧率
- ☐ 超时 ≥ 最长响应帧传输时间(9600 下满帧 293ms),并按 p99×1.5 设
- ☐ 重试 2 次、退避 500ms,重试前必须重同步
- ☐ 读数据块时同时读”通信计数”寄存器,校验事务连续性
- ☐ 应用层心跳 ≤30s,不依赖 TCP keepalive
DTU 侧:
| 参数 | 出厂常见 | 建议 | 理由 |
|---|---|---|---|
| 工作模式 | 透传 | Modbus 网关(帧同步) | 帧边界靠算不靠猜 |
| 打包间隔 | 50 / 200ms | 10~20ms | 直接加在每事务延迟上 |
| 打包长度 | 1024B | 保持 ≥256B 或关闭 | Modbus 帧永远小于 256B,永不触发 |
| 注册包 | 跟随数据 | 独立发送 / 仅首次 | 混入数据流会被当帧头 |
| 心跳 | 关闭 | 30s | NAT 表项 60~300s |
| 串口校验位 | 8N1 | 与从站严格一致 | 8N1/8E1 差 10% 字符时间,T3.5 跟着变 |
从站固件侧(如果你是做设备的那一端):
- ☐ CRC 错 → 清空整个接收缓冲,重新等 3.5 字符静默
- ☐ T3.5 在 >19200bps 时按 1.750ms 固定值判,不要按字符时间硬算
- ☐ 提供递增的通信计数寄存器
- ☐ 半双工 DE 释放留 1 个字符的余量,别在最后一个停止位就切
现场排查表:
| 现象 | 最可能根因 | 一条验证路径 |
|---|---|---|
| 偶发 CRC 错、重发就好 | 拆帧 / 粘包 | 抓串口原始字节,看帧间静默是否 <3.5 字符 |
| 每天几次通信中断告警 | 超时贴 p99 | 统计超时时长分布,看是否集中在 1.0s 附近 |
| 读回值在合理范围但不对 | 响应错位 | 连续读同一寄存器 10 次,看是否跳变 |
| 三相电压某一相偶尔像另一相 | 同类数据错位 | 加通信计数寄存器校验连续性 |
| 白天正常、晚上掉线 | NAT 表项超时 | 看断线间隔是否固定,加 30s 心跳 |
| 上电后第一帧总是错 | 注册包混入数据流 | 关掉”注册包跟随数据” |
| 读 100 个寄存器必超时 | 响应帧 234ms > 超时 | 超时 ≥ 帧长×字符时间 + 网络预算 |
| 提高波特率没变快 | 瓶颈不在串口 | 拆端到端预算,看打包间隔与 RTT |
| 多主站时整段卡死 | 半双工总线冲突 | DTU 是否在从站响应中途抢发 |
| 换 DTU 就好、换回来又坏 | 透传 vs 网关模式 | 看 DTU 有没有帧长解析状态机 |
回头看那三个月:每天几次的”离线”告警,是超时贴着 p99 设出来的;偶发的 CRC 错,是 TCP 分段让 DTU 把一帧拆成两段转发;而那个真正危险的”两相电压互换”,是超时重试把迟到响应喂给了下一帧——三个症状,一个根:透传链路没有帧边界的概念。
改完之后是这样的:DTU 换成网关模式(整帧缓冲 + 帧间 4 字符静默),打包间隔 50→20ms,上位机改成单次 write,超时 1000→900ms 并加重同步,读块时顺带校通信计数。端到端延迟从 179.81ms 变成 184.40ms,慢了 2.5%;三个症状全部消失。
这笔交易的关键在于:2.5% 的延迟,换掉了一整类”CRC 全对但数据是上一帧”的错误。在工业现场,宁可慢一点,也不能错得无声无息。
下一篇预告:帧边界这笔账算完,剩下的是设备端怎么把它落实成几行代码。USART 的 IDLE 中断到底能不能顶替 T3.5 定时器、DMA 循环缓冲的读写指针怎么防撕裂、CRC 错之后为什么必须先关 DMA 再清标志、DE 引脚在最后一个停止位上切换会切掉什么——《Modbus RTU 从站接收状态机实现:IDLE 中断省掉了一个定时器,也顺手省掉了半帧数据》,把这几行代码的时序拆开看。
本文所有数值由离线脚本 dtu_modbus_frame_calc.py 复算(29 项自检全过),场景数据来自现场复盘,时间参数按 9600 8E1 取值。
本文为个人工程实践记录,具体设计请以所用芯片手册与协议规范为准。