Modbus 网关代理的写校验:EMS 上写着“写成功”,第二天限流还是旧值——那张回执上的 OK,是网关自己签的
凌晨一点半,运维在群里发了一张截图:EMS 的“充电限流”是 30.0A,下面一行小字“下发成功 01:12:47”。他问,为什么早上八点去看,柜子还在按 100A 充。
我登上去把三条日志按时间排开,三条都写着成功:
- 01:12:44 EMS → 云平台:HTTP 200
- 01:12:44 云平台 → 网关:写指令已受理(MQTT PUBACK)
- 01:12:44 网关 → 云平台:Modbus 写成功(ACK,1.2ms 后)
截图上的“01:12:47”是云平台落库的时间,帧本身 1.2 毫秒就回了。而从站那台 PCS 的寄存器里,从头到尾都是 1000(100.0A)。
三条“成功”没有一条撒谎——它们各自只承诺了自己那一层的事。问题在于,我们把三层不同的承诺,当成了同一句话。
这篇就把这张回执拆开:一次“写成功”,到底经过几个人签字,每个人签的字能证明什么,以及在哪几种网关形态下,那张回执上的 OK 根本不是从站签的。
一、先把“成功”拆成三层
一条写指令从页面点到寄存器,最少经过四跳:EMS 页面 → 云平台 → 网关代理 → 485 从站。每一跳都有自己的“完成”定义,而且一层比一层弱。
| 层 | 判据 | 谁签的字 | 能证明什么 | 典型耗时 |
|---|---|---|---|---|
| 链路成功 | TCP 200 / MQTT PUBACK | 云平台和网关的 TCP 栈 | 这条消息被对端收下了 | 20~200ms |
| 协议成功 | Modbus 响应帧(echo 或异常码) | 从站的协议栈 | 从站收到了这一帧 | 23.3ms(9600) |
| 业务成功 | 寄存器值 = 期望值,且撑过 ≥2 个控制周期 | 从站的应用逻辑 | 值真的生效了,且没被别人改回去 | 1~5s |
三层里,只有第三层是我们真正想要的东西,而它恰好是唯一没有回执的一层。
工程上最常见的错,是把第一层的成功当成第三层的成功——因为界面上只有一个“成功”,而那一个字段是链路层填的。第二层稍微好一点,它至少来自从站,但等下我们会看到,从站回的 OK 也有四种不同的含金量。
二、回执的解剖:Modbus 的 ACK 里到底有什么
先把帧摊开。9600 8E1,一个字符 11 位,1.1458ms。
FC06(写单个寄存器)的请求是 8 字节,响应也是 8 字节,而且响应就是把请求原样复读一遍:
请求 01 06 01 00 01 2C CRC ← 从站1,写 0x0100 = 300
响应 01 06 01 00 01 2C CRC ← 一模一样的 8 个字节
很多人看到响应里有 01 2C(300),就认为“从站确认它收到了 300”。严格说,从站确认的是“我复读能力正常”。这两件事在 99% 的实现里是一回事,在剩下 1% 里差着一次事故。
FC16(写多个寄存器)更彻底:
请求 01 10 01 00 00 0A 14 <20 字节数据> CRC
响应 01 10 01 00 00 0A CRC ← 只有起始地址和数量,一个值都没有
响应里连你写的值都不出现。所以“我在 ACK 里看到了我写的值”这句话,在 FC16 上根本不成立——批量写参数时,回执的信息量比单写还低。
一次写的总线时间是固定的:
| 波特率 | 字符(ms) | 请求 8B(ms) | 响应 8B(ms) | 合计(ms) |
|---|---|---|---|---|
| 2400 | 4.5833 | 36.667 | 36.667 | 78.333 |
| 4800 | 2.2917 | 18.333 | 18.333 | 41.667 |
| 9600 | 1.1458 | 9.167 | 9.167 | 23.333 |
| 19200 | 0.5729 | 4.583 | 4.583 | 14.167 |
| 115200 | 0.0955 | 0.764 | 0.764 | 6.528 |
(含从站处理 5ms)
23.3ms 这个数记住,后面所有的账都从它出发。它是总线上真实发生了一次写的物理成本。任何比它快一个数量级的“写成功”,都值得停下来问一句:这一帧到底发出去没有。
回 OK 之后的四种下场
把 0x0100 充电限流(0.1A/LSB,合法域 0~1000,即 0~100.0A)单独拎出来看。同一句“回 OK”,后面有四种完全不同的结果:
| 类型 | 写入(寄存器) | 实际生效 | 读回 | 说明 |
|---|---|---|---|---|
| A 范围 clamp | 3000 → 300.0A | 1000 → 100.0A | 能抓 | 读回 100.0A ≠ 300.0A |
| B 运行态只读 | 300 → 30.0A | 300 → 30.0A | 能抓 | 读回原值,值压根没变 |
| C 写入后覆盖 | 300 → 30.0A | 100 → 10.0A | 看时机 | Δ=50ms 读回 30.0A 通过,1s 后变 10.0A |
| D 影子区未提交 | 300 → 30.0A | 200 → 20.0A | 常抓错 | 读到影子区的新值,主寄存器还是旧值 |
A/B 读回能抓,所以“读回校验”本身是有用的;C/D 才是它失效的地方——C 是时机问题,D 是读错了地址的问题。
影子区本身是对的(139 篇那种“影子区 + 提交字”的写法,正是为了避免 FC16 半提交)。但读回如果读的是影子区,提交失败时就会得到一个“看起来对”的答案。读回必须读生效区,或者把提交字一起读回来判。
三、五种代理形态,五种回执语义
网关代理不是一个东西,是五类东西,回执语义完全不同。这是这篇的核心表:
| 形态 | ACK 来自 | ACK 延迟 | 假成功 | 校验误报 | 暴露半径 |
|---|---|---|---|---|---|
| 透明直通 | 从站(原样回) | 83.3ms | 0% | 0% | 同一帧内 |
| 写穿透 + 读缓存 | 从站(原样回) | 83.3ms | 0% | 90% | 同一帧内 |
| 写回 write-back | 代理内存 | 1.2ms | 100% | 0% | 5s(刷写周期) |
| 期望值回填 | 代理内存 | 1.2ms | 7% | 0% | 代理重启才暴露 |
| 存储转发 | 代理队列 | 1.2ms | 100% | 0% | 到链路恢复(可 40min) |
(延迟按 4G Cat.1 RTT 60ms + 485 段 23.3ms 计)
这张表要横着读:ACK 延迟每降一个数量级,“成功”的含义就掉一层。
透明直通最好理解:你发什么它转什么,从站回什么它给什么,代理是个哑巴。代价是 4G 上每次写要 83.3ms。
写回(write-back)是省流量省出来的:代理先在内存里改,攒够一批或等刷写周期到了再下发。ACK 1.2ms,比 83.3ms 快 69.4 倍。但它签的字是“我记住了”,不是“从站改了”。
期望值回填是最坏的一种,而且很常见:代理不只是缓存读,它还在写成功之后主动把缓存里的值改成你写进去的值。这样上位机写一次、读一次,读到的是自己刚写进去的数,闭环完美。它的问题在于——代理缓存里那份“期望值”,和从站寄存器里的“实际值”,从此是两份独立的数据,而且代理没有任何机制去发现它们分叉了。
存储转发是为了断链容忍:链路断了先进队列,恢复后补发。ACK 在入队瞬间就回了,真实执行可能滞后 40 分钟。
回到开头那台柜子。它的代理是第四种:期望值回填,而且把从站的异常码吞了。
后来抓 485 才看到真相:代理下发的那一帧地址是 0x0101(点表配错了一格),从站回了 01 86 02——异常码 02,非法数据地址。代理的处理逻辑是“非超时即视为已下发”,异常码被归一化成“指令已下发”,同时把缓存里的 0x0100 改成 300,好让上层的读回校验通过。于是:从站一直 1000,代理缓存一直 300,界面一直“成功”。
这个组合最毒的地方在于它自洽。写有回执、读有回读、两个值还对得上,唯一错的是那一格地址——而没有任何一层会去核对“我下发的地址,和你点表里写的是不是同一个”。
四、第一次翻车:读回校验被代理的读缓存截获
把上面那台的代理换成透明直通,异常码就不会被吞——代价是每次写都要 83.3ms。同一周我在另一个站遇到了第二种:网关是“写穿透 + 读缓存”,写直发从站,读走 500ms 的轮询缓存。
我按常规做法写完立刻读回,Δ=50ms。读到的还是 1000。我以为写失败,重写一次,再读,还是 1000。第三次读完我顺手在网关上抓了一帧 485,从站明明回了 echo,值也是新的。
问题在读回这一侧。代理每 500ms 把从站整张表读一遍,缓存项的“年龄”在 0~500ms 之间均匀分布。你在写完 Δ 之后去读,命中旧缓存的条件是“缓存项的年龄 + Δ < 500ms”:
| 轮询周期 | Δ=20ms | Δ=50ms | Δ=100ms | Δ=200ms | Δ=500ms |
|---|---|---|---|---|---|
| 200ms | 90.0% | 75.0% | 50.0% | 0% | 0% |
| 500ms | 96.0% | 90.0% | 80.0% | 60.0% | 0% |
| 1000ms | 98.0% | 95.0% | 90.0% | 80.0% | 50.0% |
| 2000ms | 99.0% | 97.5% | 95.0% | 90.0% | 75.0% |
Δ=50ms、轮询 500ms → 90%。
也就是说:九成的“写失败”是校验方法失效,不是写失败。 而现场遇到这种情况会做什么?重试。重试第三次的时候缓存过期了,读到了新值,于是日志上留下一条“重试后成功”。
这条日志比故障本身更害人。它训练了运维一个习惯:写失败就重试。等到真正写不进去的那天,重试三次也没用,而历史记录里全是“重试后成功”,谁也不会去查。
读回校验有一个隐含前提,平时没人说出来:读和写看到的是同一个世界。 一旦中间加了一层缓存,这个前提就没了。
五、第二次翻车:写进去是真的,两分钟后自己变回去
把读回改成直读(绕过代理缓存)之后,我以为这事完了。一周后同一个客户又来:限流改完,界面上是 30A,两分钟后自己变回 100A。
这次的原因不在这条链路上,在两个写者。
那台柜子里有两个东西会写 0x0100:本地控制策略(1s 周期,从配置文件里恢复设定值)和云端 EMS(5s 周期)。本地策略每 1s 把内存里的设定值刷进寄存器一次——它是“保护逻辑”,设计意图是“上电或通信中断时回到本地安全值”。一旦云端写的值没有同步进本地策略的内存,1s 后就被刷回去了。
时间轴很清楚:
t=0.00s 云端写入 300(30.0A),从站 echo
t=0.05s 我读回 300 → 通过
t=0.98s 本地策略刷写,写入 1000(100.0A)
t=2.00s 我再读回 1000 → 失败
Δ=50ms 的读回通过率是 95%(本地策略还没轮到),1s 后 100% 被覆盖。
这类故障对“立即读回”是系统性失效,不是概率问题。判据很简单:连续读 ≥2 个本地控制周期,只要有一个值不对就是被覆盖。
(别和另一条路混了:“写进去当场是对的,第二天早上变回默认值”是掉电保持的问题,109 篇专门算过这笔账。这里讲的是值在运行期间被另一路逻辑刷掉,两者的现象像,根因不同。)
顺带说一句:这不是“谁有 bug”。两个写者都合法,缺的是仲裁——谁的值是权威、覆盖之后要不要告警、云端写的值要不要回写到本地策略的持久化配置里。最后写者胜(last-writer-wins)不是一种策略,它只是没策略时自然发生的结果。
六、双输窗口:Δ 调不出来的那个数
到这里有两个失效方向,而且方向相反:
- Δ 太小 → 读回被缓存截获,读到写之前的旧值(假失败)
- Δ 太大 → 中间被别的写者覆盖,读到的是别人的值(假失败)
两个都不对,那总有一个最好的 Δ 吧?我把它算出来了,模型是:轮询 500ms,本地策略 1000ms。
| 读回延迟 | 命中旧缓存 | 已被覆盖 | 净判读正确 |
|---|---|---|---|
| 20ms | 96.0% | 2.0% | 3.9% |
| 50ms | 90.0% | 5.0% | 9.5% |
| 100ms | 80.0% | 10.0% | 18.0% |
| 200ms | 60.0% | 20.0% | 32.0% |
| 400ms | 20.0% | 40.0% | 48.0% |
| 500ms | 0% | 50.0% | 50.0% |
| 800ms | 0% | 80.0% | 20.0% |
| 1000ms | 0% | 100% | 0% |
最好的点是 Δ=500ms,净判读正确率 50.0%。
一半。而且这一半还要求你把延时正好卡在轮询周期上——轮询周期是代理的配置项,随时可能被人从 500 改成 1000 去“降低总线负载”。你的校验逻辑会跟着它漂。
结论不是“调 Δ”,是别在缓存后面做单次读回。要么让读回绕过缓存(直读从站),要么把校验从“读一次”改成“读一段时间”——连续采样,覆盖 ≥2 个控制周期,任何一次不符就判失败。
(这里也解释了为什么很多网关厂商的“写校验”功能形同虚设:它就是在代理内部读自己的缓存,读到的一定是自己刚填进去的值。)
七、存储转发:一条 40 分钟前的“写成功”
第二个客户是 4G 信号差的山顶站。网关开了存储转发,链路一天断好几次,每次十几分钟到一小时。表现是“参数偶尔自己变回老的”。
账很好算。断链 40 分钟,上位机 2s 刷一次设定值:
- 积压 1200 条
- 恢复后按 23.33ms/条灌入,28.0s 才能清空
- 按 (unit, fc, addr) 合并、只留最新 → 1 条,滞后 23.3ms,合并率 99.92%
- TTL 设 30s 的话,队列里只有最后 15 条(1.25%)还有效,其余 1185 条(98.75%)已过期
1200 条里,1195 条是同一个寄存器的历史值,只有最后一条有意义。不合并 = 用 28 秒的带宽,去传输 1195 个已经被自己推翻的意图。
合并之后还要判 TTL。setpoint 类的写意图有效期 30s 就够了——40 分钟前的“充电限流 30A”,现场早就人工改过了,补发等于用旧意图覆盖新意图,而操作员完全不知道。
队列容量 500 条 < 积压 1200 条时溢出,两种实现都错:
- FIFO 丢新值:扔掉的正是最新的那条,恢复后灌进去的是 40 分钟前的 30A
- 环形覆盖旧值:方向对了,但前 700 条白占带宽,中间过程不可追溯
还有一类命令根本不该进队列:复位、停机、跳闸、清故障。40 分钟前的“复位”没有意义,40 分钟前的“停机”可能把正在检修的人关在设备里。这类必须是“链路不通就当场失败”,让人看见失败,而不是替他把命令存起来。
八、事务号:RTU 没有,代理自己造,然后配错
Modbus RTU 的帧里没有事务号。地址 + 功能码 + 寄存器地址 + CRC,没有一个字段能唯一标识“这是第几次请求”。所以代理必须自己造一个 key,通常是 (unit, fc, addr, seq)。
造出来就要维护一张“未完成事务表”,超时清、响应到就配。错配的条件很清晰:响应到达的时刻,越过了下一条事务的发出时刻。
| 超时 | 事务间隔 | 错配阈值 | 正常(23.33ms) | 从站擦 Flash(+40ms) | 从站忙(+430ms) |
|---|---|---|---|---|---|
| 300ms | 20ms | 320ms | 安全 | 安全(63.33ms) | 错配(453.33ms) |
| 300ms | 0ms | 300ms | 安全 | 安全 | 错配 |
| 100ms | 20ms | 120ms | 安全 | 安全 | 错配 |
| 500ms | 20ms | 520ms | 安全 | 安全 | 安全(453.33ms) |
那个 +430ms 不是编的。从站在擦 Flash 的时候,一次擦除 40ms(见135 篇的实测),参数区 compaction 叠加起来能到 120ms 以上;再叠上从站主循环里别的耗时,450ms 完全可能出现。而它一旦超过“超时 + 间隔”,迟到的响应就会配给下一条事务——下一条事务的值,被上一条的响应“确认”了。
这和140 篇讲的粘包不是一回事。粘包是字节流层面两帧拼在一起,你能在字节流里看见;事务错配时字节流完全干净、CRC 全对、帧长全对,只有值被安到了别的事务头上。查日志查不出来,抓包也抓不出来,只能靠“这个值我根本没写过”这种业务侧的怀疑。
两个动作能挡掉大部分:
- 超时后主动清表,不是把事务标成“失败”留在表里等响应
- 事务间隔 ≥ 最大可能响应时间 – 超时;做不到就别开流水线,老老实实一问一答(代价是吞吐,收益是可诊断)
九、预算:4G 上“每次写都全链路校验”是 120 分钟/天
我一度想给每条写都加一次直读回。算完就放弃了。
| 路径 | 单次(ms) | 10 个参数(s) |
|---|---|---|
| 直连 485:写 + 直读回 | 46.67 | 0.467 |
| 4G 代理:写 + 直读回(不缓存) | 166.67 | 1.667 |
| 4G 代理:写 + 读缓存(无效校验) | 84.53 | 0.845 |
| 4G 代理:写 + 代理 ACK(无校验) | 1.20 | 0.012 |
每天写 43200 次(2s 周期):
- 4G 全校验:7200 秒/天 = 120 分钟/天 的上行占用
- 只写不校验:3600 秒/天 = 60 分钟/天
- 代理本地 ACK:52 秒/天
120 分钟的上行,在一个 Cat.1 按流量计费的站上,是能直接把套餐跑穿的。而且它占的是上行——4G 上行本来就比下行窄。
所以工程解不是“降低校验频率”,也不是“相信 ACK”,是把校验挪到代理侧,但证据必须来自从站:代理替你做批量直读 + 修改计数比对,把结论(而不是原始帧)上报。代理可以替你跟从站说话,但不能替你签那张回执。
十、证据链:五层证据各能判多少
把上面所有东西收成一张表。这是我现在的判断口径,“可判定”是单靠这一层能判出的写故障比例(模型粗估,不是统计):
| 证据 | 能证明 | 可判定 | 不能证明 |
|---|---|---|---|
| 代理本地 ACK | 值进了代理内存 | 0% | 从站是否收到、是否生效 |
| 从站异常码 01/02/03/04 | 明确拒绝 + 原因 | 30% | 静默忽略(这类从站根本不回码) |
| FC05/06 的 echo | 协议层收到(照抄一遍) | 40% | 值是否生效;FC16 连值都没有 |
| 直读寄存器值 | 此刻寄存器里是什么 | 70% | 下一个控制周期还在不在 |
| 直读 + 修改计数 +1 | 确实执行了一次写 | 90% | 写的是不是你那个值(与上条组合) |
| 值 + 计数 + 两个时间点 | 写进去了且稳住了 | 99% | —— |
第二行的 30% 值得单独说:异常码是强否定证据——只要从站回了 01/02/03/04,就一定没写成,而且原因明确(非法功能/非法地址/非法值/从站故障)。它的问题是覆盖率,不是可靠性。现场最常见的失效恰恰是“回 OK 不干活”,这类从站永远不给你异常码。
第四、五行之间的那个“修改计数”,是整套判据里最被低估的东西。很多电表、保护装置、PCS 都有一个“参数修改次数”或“配置版本号”寄存器,平时没人读。写之前读一次、写之后再读一次,计数 +1 才证明真的执行了一次写;计数不变而值变了,说明你读到的是缓存或影子区。
最后一行是关键:值 + 计数 + 两个时间点。两个时间点之间的间隔要 ≥2 个控制周期(那台柜子是 2s),才能排除“写完被刷回去”。
十一、落地:代理写路径的 12 条约束
按重要性排,前四条是硬约束:
- ☐ 1. 写命令的 ACK 必须携带“签字人”标识:
src=slave|proxy|queue,让上位机能区分 - ☐ 2. 写回 / 存储转发模式下,UI 上不许出现“写成功”,只能出现“已受理”
- ☐ 3. 读回校验必须走独立通道,不得命中写路径用的那份缓存
- ☐ 4. 保护类命令(复位/停机/跳闸/清故障)禁止进存储转发队列
- ☐ 5. 入队即合并:key=(unit,fc,addr),只留最新值 + 时间戳
- ☐ 6. 出队前再判一次 TTL,过期直接丢弃并上报“过期丢弃 N 条”
- ☐ 7. 队列溢出策略显式化:丢新值还是覆盖旧值,必须在文档里写死
- ☐ 8. 超时后清事务表,不允许“标记失败但保留”
- ☐ 9. 未完成事务表加容量上限,满了拒绝新写(而不是覆盖最老的)
- ☐ 10. 校验判据用“连续 N 次读、覆盖 ≥2 个控制周期”,不用单次读
- ☐ 11. 点表里标注每个寄存器的“权威写者”,双写者必须显式仲裁
- ☐ 12. 每次写落一条审计:时间、来源、旧值、新值、回执来源、校验结果
第 11 条是那台柜子真正的根因。点表上 0x0100 只写了一行“充电限流”,没人写“这个寄存器有两个写者”。等到出事,两边的开发都觉得自己没错。
十二、验收:三个现场就能做的动作
不用协议分析仪也能做,一把螺丝刀加十分钟:
- 拔掉代理的上行(WAN / 4G 天线),再写一次。 如果仍然秒回 OK,说明 ACK 来自代理,不是从站——这条链路当前的“写成功”不可信。
- 写一个必定越界的值(比如 3000,合法域 0~1000)。 回异常码 03 的从站,异常码可以依赖;回 OK 且读回被 clamp 到 1000 的从站,异常码永远等不到,只能靠读回。
- 写完 Δ=50ms 直读一次,再等 2s 读第二次。 第一次不对是缓存,第二次不对是覆盖,两次都不对是根本没写进去。
判据树:
| 动作 | 看到 | 结论 |
|---|---|---|
| 拔上行再写 | 仍然秒回 OK | ACK 来自代理,不是从站 |
| 抓 485 波形 | 总线上没有这一帧 | 写根本没下发(写回/队列) |
| 写 3000 | 回 OK 且读回被 clamp | 从站在做保护,异常码不可靠 |
| 写 3000 | 回异常码 03 | 从站会明确拒绝,可依赖 |
| 写完 Δ=50ms 直读 | 读到旧值 | 读回被缓存截获,改直读 |
| 写完 Δ=50ms 直读 | 读到新值,2s 后变回 | 双写覆盖,需连续读 ≥2 个周期 |
| 写前后读“修改次数” | 计数没变 | 从站静默忽略,问题在从站 |
| 计数 +1 但值不对 | 值被 clamp 或倍率错 | 点表/倍率问题,不是链路问题 |
最后一行要单独拎出来:一旦确认“确实执行了一次写、但值不对”,链路就洗清嫌疑了,问题在量纲。倍率与单位这一层在 107 篇里按 L1/L2/L3 拆过,写侧和读侧共用同一张点表,错法也共用。
十三、三个可以带走的结论
一、回执的可信度,等于签字人的层级。 代理 ACK 只证明“内存改了”,从站 echo 只证明“帧收到了”,直读回值只证明“此刻是新的”。把三层混成一句“写成功”,是在用最弱的那层的成本,去承诺最强的那层的结果。而这三层的耗时差着 69.4 倍(1.2ms vs 83.3ms)——快出来的每一毫秒,都是从语义里扣的。
二、在带缓存的代理后面,单次读回校验的正确率上限是 50%,而且这个上限要求 Δ 正好等于轮询周期。 这不是调参能解决的问题。要么让读回绕开缓存,要么把“读一次”改成“读一段时间”。顺带一个反直觉的数:Δ 越小越糟,Δ=20ms 时净判读正确率只有 3.9%。
三、存储转发的危险不在丢包,在“过期意图”。 1200 条积压里只有 15 条(1.25%)还在 TTL 内;不合并等于用 28 秒带宽传输 1195 个已被推翻的值。而保护类命令一旦进了队列,恢复瞬间执行的那条“停机”,可能比不执行更危险。
这篇里所有数字都来自 gateway_proxy_write_calc.py(纯标准库、离线可跑,selftest 31 项全过)。轮询周期、控制周期、TTL、队列容量都是参数,改一行就能套到自己现场。模型假设写在每个模式的输出里,别把它们当统计结论用——它们是用来算“量级”和“方向”的,方向比数值稳。
下一篇写《Modbus 网关的 unit id 与地址映射:8 台从站挂在一个 IP 后面,第 3 台永远读不到》——unit id 透传与重映射、0 和 255 这两个特殊值、单连接多从站的排队、广播穿透网关之后变成什么。
本文涉及的具体项目参数已做脱敏处理,数值保留原量级。