Modbus 网关代理的写校验:EMS 上写着“写成功”,第二天限流还是旧值——那张回执上的 OK,是网关自己签的

2026年10月31日 0 By admin

凌晨一点半,运维在群里发了一张截图: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 全对、帧长全对,只有值被安到了别的事务头上。查日志查不出来,抓包也抓不出来,只能靠“这个值我根本没写过”这种业务侧的怀疑。

两个动作能挡掉大部分:

  1. 超时后主动清表,不是把事务标成“失败”留在表里等响应
  2. 事务间隔 ≥ 最大可能响应时间 – 超时;做不到就别开流水线,老老实实一问一答(代价是吞吐,收益是可诊断)

九、预算: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 只写了一行“充电限流”,没人写“这个寄存器有两个写者”。等到出事,两边的开发都觉得自己没错。

十二、验收:三个现场就能做的动作

不用协议分析仪也能做,一把螺丝刀加十分钟:

  1. 拔掉代理的上行(WAN / 4G 天线),再写一次。 如果仍然秒回 OK,说明 ACK 来自代理,不是从站——这条链路当前的“写成功”不可信。
  2. 写一个必定越界的值(比如 3000,合法域 0~1000)。 回异常码 03 的从站,异常码可以依赖;回 OK 且读回被 clamp 到 1000 的从站,异常码永远等不到,只能靠读回。
  3. 写完 Δ=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 这两个特殊值、单连接多从站的排队、广播穿透网关之后变成什么。

本文涉及的具体项目参数已做脱敏处理,数值保留原量级。