Modbus 网关的 unit id 与地址映射:给 3# 柜换了块表,8# 柜反倒没了数——网关里那行是位置,不是身份
一、现象:给 3# 柜换了块表,8# 柜没了数
2024 年冬天接的一个储能站,8 台柜子并在一块母线上,每台柜子里一块三相电表,8 块表的 485 手拉手串到一台 Modbus 网关,网关一个网口进 EMS。站不大,逻辑很直:读 8 台表的电压电流功率,加上每台的累计电量,做需量统计。
2025 年 5 月,3# 柜的电表坏了,换了一块新的,地址还是 3。换完之后,运维报了两件事:
- 8# 柜没数了。 通道 8 一直空着,重启也不回来。
- 3# 柜的数不对。 3# 柜只带照明、监控和除湿,钳表量出来 3kW 上下,EMS 通道 3 上稳定显示 15kW,比 5# 柜还高。
两件事看起来毫无关系:一个是没数,一个是数偏大;一个在编号 8,一个在编号 3。运维的结论是“8 号柜的通信模块坏了,3 号柜的新表有问题”。
我一开始也这么以为。于是有了三次现场:
第一次(奔着 8# 柜去):换了 485 线,加了终端电阻,把 8# 柜那块表拆下来挂到网关另一个口上单独试——表是好的,能读,地址、波特率、校验位都对。装回去还是没数。
第二次(抓包):USB 转 485 挂到总线中间抓了十分钟。总线上有 addr 1、2、4、5、6、7、8 的请求帧,来回都正常。addr 3 的请求帧一次都没出现过。
不是“发了没人应”,是根本没发。而且注意:addr 8 是有帧的,说明 8# 柜那块表一直被问,只是它的答案被送到了别的地方。
第三次(打开配置页):映射表 8 行,前 7 行有内容,第 8 行空着。看起来齐整,但逐行数下去就明白:
| 网关行号 | unit id | 指向从站地址 | 实际打到谁 |
|---|---|---|---|
| 1 | 1 | 1 | 1# 柜 ✓ |
| 2 | 2 | 2 | 2# 柜 ✓ |
| 3 | 3 | 4 | 4# 柜 ✗ |
| 4 | 4 | 5 | 5# 柜 ✗ |
| 5 | 5 | 6 | 6# 柜 ✗ |
| 6 | 6 | 7 | 7# 柜 ✗ |
| 7 | 7 | 8 | 8# 柜 ✗ |
| 8 | 8 | 空 | 无路由,静默丢弃 |
跟换表那件事一对照,来龙去脉就清楚了:
- 2025 年 5 月换 3# 柜的表,运维在网关里删掉了第 3 行;
- 新表到货,点“新增”,但 unit 字段忘了填(新增框默认空着),这一行被网关判为无效行丢弃;
- 于是数组从第 3 行起整体前移:第 3 行变成 addr 4(4# 柜)、第 4 行变成 addr 5……第 7 行变成 addr 8(8# 柜);
- addr 3 从此没有任何一行指向它,3# 柜的那块表再没人问过,它的 3kW 再没进过系统;
- 第 8 行空着,unit 8 没有路由,网关静默丢弃——通道 8 就永远是空的,这就是“8# 柜没数”;
- 而 4#~8# 这 5 台柜子的数,分别显示在标着 3#~7# 的通道上:数值合法、CRC 全对、无任何告警。
一次换表,两个症状:一个“没数”(编号 8),一个“数偏大”(编号 3)。根因是同一次删行。而“重启能好”是假象——重启后 EMS 重新建连,前几个请求恰好排在队列前面,别的柜看着就恢复了,通道 8 从来没有恢复过。
这篇把这三层拆开算。所有数字来自 gateway_unitid_map_calc.py(纯标准库、离线可跑,selftest 24 项全过)。
二、三个“3”:都在说 3,只有一个是 3# 柜
事故真正难查的地方,不是技术,是语言。
同一台柜子,在这个系统里有三种叫法:
| 语言 | 谁在用 | 这个编号是什么 | 3# 柜的编号 |
|---|---|---|---|
| 现场语言 | 运维、施工、验收单 | 柜门丝印,物理身份 | 3# |
| 网关语言 | 网关配置页、映射表 | 行号,是位置不是身份 | 第 3 行(删行后 = 4# 柜) |
| 点表语言 | EMS 点表、组态、报表 | 通道序号,1 基 | 通道 3 |
三个数字都指向“3”,但只有一个指对了东西。删掉中间一行之后:
- 现场说的“3# 柜”,物理上还在那儿,表在,地址 3,但没有任何一行指向 addr 3;
- 网关说的“第 3 行”,位置没变,身份变成了 4# 柜;
- 点表说的“通道 3”,读的是 unit 3,也就是第 3 行,也就是 4# 柜。
于是你查“3”,三个系统都能给你一个“3”,三个都不是 3# 柜。这就是为什么这个故障挂了一年半——每次排查都查到了东西,每次查到的都不是那台。
更麻烦的是,这条对应关系从来没落在纸上。投运的时候,谁配的网关、谁对的点表,都记得“1 到 8 对着 1 到 8”,也确实对过。删行这个动作,把“行号 = 柜号”这个隐含前提悄悄破坏了,而没有任何一个界面会提示你。
规则一:unit id 必须是身份,不是位置。 映射表里每一行都要显式写 unit id,不能靠行号隐含。这一条不花一分钱,能省掉本文后面所有的事故。
三、unit id 到底归谁解释
Modbus TCP 的报文头(MBAP)是 7 个字节:
+----------------+----------------+----------------+---------+
| transaction id | protocol id | length | unit id |
| 2 bytes | 2 bytes | 2 bytes | 1 byte |
+----------------+----------------+----------------+---------+
前 6 个字节是 TCP 的事:事务号用来配对请求和响应,协议号固定 0,长度表示后面还有多少字节。第 7 个字节 unit id,是 Modbus 规范里唯一一个“给网关用”的字段。
规范的意思很直接:当一个系统里既有 TCP 设备又有串行设备时,unit id 用来标识“TCP/IP 网络之外的那台服务器”。所以:
- 对一台原生 Modbus TCP 设备(比如带网口的 PLC),unit id 通常只有一个合法值,就是它的设备标识(常见 1 或 255),别的值应该回异常;
- 对一台网关,unit id 是一张路由表的键,网关拿它查“这一帧该往哪条串口、哪个从站地址发”。
同一个字节,两种设备的解释完全不同。这就解释了为什么“我把 unit id 改成 3 试试”这种动作,在有的网关上能通,在有的网关上直接把连接踢掉——它可能把 3 当成一个没配置的路由项。
还有一个更常见的混乱:市面上大量号称“Modbus 网关”的盒子,其实是 RTU over TCP,不是 Modbus TCP。RTU over TCP 的意思是“把 RTU 帧原样塞进 TCP”,没有 MBAP 头,第一个字节就是从站地址。这种设备不解释 unit id——它根本没有 unit id 这个概念,它只是把 TCP 当成一根很长的串口线。
两者在抓包上很好分:
| 类型 | 报文前 7 字节 | 抓包特征 |
|---|---|---|
| Modbus TCP | 00 01 00 00 00 06 08 |
前 4 字节是事务号+协议号,协议号恒为 0 |
| RTU over TCP | 08 03 00 00 00 0A C5 CD |
第 1 字节就是从站地址,第 2 字节是功能码 |
规则二:先确认你手上的是哪一种。 抓一个包,看第 2、3 字节是不是 00 00。是,Modbus TCP,unit id 有意义;不是,RTU over TCP,你调的“unit id”其实是从站地址,配在网关里也没用,网关只认第一个字节。
这一层没分清,后面所有的表都得重画。相关的一次踩坑在 140 篇里写过:透传链路上连帧边界都保证不了,更别说寻址。
四、网关拿到一个 unit id,有五种处理方式
把市面上常见的实现归一下类,只有五种。每一种的现场症状都不一样,这张表建议直接抄进对接文档:
| # | 策略 | 行为 | 未知 unit id 时 | 现场症状 |
|---|---|---|---|---|
| A | 透传 | 把 unit id 原样当从站地址压上总线 | 照样发,从站不应答 | 超时;总线上能看到帧 |
| B | 重映射 | 查表:unit id → 通道 + 从站地址 | 静默丢弃 | 超时;总线上看不到帧 |
| C | 重映射 + 异常 | 查表,查不到回 0x0B | 回异常码 | 毫秒级报错,定位最快 |
| D | 扁平化 | unit id 折叠进地址高位,不用 unit id | 地址越界,可能回绕 | 读到别人的寄存器,无告警 |
| E | 本机 | unit id = 网关自己,读网关诊断寄存器 | 正常返回 | 值“看起来像”,其实不是表的数据 |
本篇遇到的是 B。这是最坏的一种,因为故障现场什么证据都不留:总线上没有帧,从站没收到,网关日志通常也不记(它认为“没有这条路由”是正常情况),客户端只看到超时。
C 是同一件事的正确做法:一句 0x0B(GATEWAY TARGET DEVICE FAILED TO RESPOND,网关目标设备无响应),就能把“猜”变成“看”。这个异常码在规范里本来就有,只是很多网关固件懒得实现。
E 值得单独说一句。unit 255 被不少厂商拿去当“本机地址”,读回来是网关的固件版本、序列号、运行时间。如果点表里不小心用 255 去读电量,你会得到一个永远不动的数,或者一个单调递增但量纲完全不对的数(运行秒数)。它不像 0 那么显眼,也不像超时那么容易被发现。
D 是另一种隐形错误,第十二节展开。
五、第一件事:看这一帧有没有上总线
排障的顺序比工具重要。遇到“读不到”,不要先改配置,先确定故障发生在哪一段。一个 USB 转 485 加一台笔记本,五分钟能定性:
| 看到的 | 说明 | 下一步 |
|---|---|---|
| 总线上没有这一帧 | 网关内部就丢了 | 查映射表、查 unit id 是否在表内、查白名单 |
| 有请求帧,从站没回 | 从站地址错 / 从站没上电 / DE 时序 | 单独拿表试、查地址拨码 |
| 从站回了异常码 | 从站收到了但不认 | 看码:0x02 地址非法、0x04 从站故障 |
| 从站回了正常数据 | 链路通了,问题在语义 | 读 SN 比对,查映射错位 |
| 有请求有响应,客户端还是超时 | 响应晚于超时 | 查排队(第九节) |
第三条要补一句:0x0B 是网关生成的,不是从站生成的。看到它说明网关查表失败,而不是总线有问题——它跟 0x0A(网关路径不可用)一样,都是“网关自己的诊断”,现场很有用,但很多上位机把它们当普通通信错误丢掉,日志里只剩一个“读失败”。
抓到“总线上没有帧”之后,很多人会去怀疑网关固件、连接数、缓存。这些都有可能,但先把映射表打印出来,逐行核对 unit id 与从站地址,成本最低,命中率最高。我这三次现场,前两次加起来花了六个小时,第三次打开配置页用了三分钟。
六、映射表的三种写法,只有一种不会塌缩
网关固件怎么存这张表,决定了它会不会在某一天集体错位。三种常见写法:
写法 A:数组下标即 unit id
/* table[unit_id] = slave_addr */
uint8_t table[248] = {0, 1, 2, 3, 4, 5, 6, 7, 8};
/* 上位机发 unit=3 -> 网关取 table[3] = 3 -> 打到 addr 3 */
看着最直观,问题在“删除”这个动作上。数组没有空洞,删一行就必须把后面的元素往前挪:
原始 [1, 2, 3, 4, 5, 6, 7, 8]
删第3行后 [1, 2, 4, 5, 6, 7, 8] <- 4,5,6,7,8 整体前移
新增一行(unit 空)-> 被判无效丢弃 -> 还是 7 行
结果就是本篇开头那张表:unit 3 打到 addr 4,unit 4 打到 addr 5……unit 7 打到 addr 8,unit 8 越界,而 addr 3 再也没人指向。
更阴的是,这个错误只在“删中间某一行”时发生。删最后一行不会错位,加在末尾(unit 填对)也不会错位,改某一行的值更不会错位。所以它总是藏在一次看起来毫无风险的换件维护之后。
写法 B:按 unit id 查的字典/链表
struct route { uint8_t unit; uint8_t port; uint8_t addr; };
static struct route routes[16];
static int n_routes;
/* 查找:遍历,找不到就回 0x0B */
for (int i = 0; i < n_routes; i++)
if (routes[i].unit == unit) return &routes[i];
return NULL; /* 上层回 0x0B */
删一条只影响那一条,其它条目不动。这是唯一不会塌缩的写法,也是唯一能让“未知 unit id”变成显式错误的写法。
写法 C:按行号索引,行号由上位机下发
这种最坏。网关不认 unit id,认“第几行”,上位机点表里的通道序号直接当行号用。点表一改行序(加一个中间量、删一个废点、换个模板),全部错位。而且网关和点表两边都“自洽”,谁也看不出问题。
| 写法 | 删中间一行 | 加点表量 | 未知 unit id | 结论 |
|---|---|---|---|---|
| A 数组下标 | 全部前移 | 无影响 | 越界/静默 | 别用 |
| B 字典 | 只影响该条 | 无影响 | 可回 0x0B | 用这个 |
| C 行号 | 只影响该行 | 全部错位 | 无定义 | 禁用 |
规则三:映射表按 unit id 索引,不按行号;未知 unit id 必须回 0x0B,不许静默丢弃。 这是网关选型时的硬指标,采购前问一句“没有路由的 unit id 你们回什么”,比看十页规格书有用。
七、错位之后,读数是“合法”的
这是本篇最该被记住的一段。
8 台表的实际负载(kW):
| 柜号 | 1# | 2# | 3# | 4# | 5# | 6# | 7# | 8# |
|---|---|---|---|---|---|---|---|---|
| 负载 | 10 | 12 | 3 | 15 | 20 | 18 | 14 | 22 |
3# 柜只带照明、监控和除湿,负载最小。这一点在后面很关键。
第 3 行起塌缩之后,EMS 各通道读到的数:
| 通道 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| 本应读 | 1# 10 | 2# 12 | 3# 3 | 4# 15 | 5# 20 | 6# 18 | 7# 14 | 8# 22 |
| 实际读到 | 10 | 12 | 15 | 20 | 18 | 14 | 22 | 0 |
| 实际是 | 1# | 2# | 4# | 5# | 6# | 7# | 8# | 无路由 |
每一个读到的值,都在合理量程内:电压 220~240V、电流 0~100A、功率 0~25kW、电度单调递增。没有任何一个值会触发越界告警。
CRC 全对——因为每一帧都是从一台真实存在的表上读回来的,帧本身没有错。异常码一个都没有——因为从站都正常应答了。通信质量统计 100%——因为链路上确实没有错误。
系统里唯一的变化是:5 台柜子的数字,是另外 5 台柜子的。而 3# 柜的数字,从来没有被人读过。
这就是“错位”比“失联”危险的地方。失联会被发现,错位不会。 通道 8 没数,运维报修了一年半,一直以为是 8# 柜的通信模块;3# 柜的数对不上,被当成新表的问题,单独拆下来试了两回。而真正在动的,是 4#~8# 这 5 台的数字。
这个站最后是怎么发现的?不是通过监控告警,是一次偶然的现场核对:运维在 3# 柜前面用手持钳表量了一次电流,3kW 出头,抬头看 EMS 通道 3 上是 15kW,差了 5 倍。他先是以为钳表坏了,换了块表再量,还是 3kW——这才开始怀疑“通道 3 读的到底是不是这台表”。
错位还有一个副产品:总量几乎不变。第八节专门算这笔账,因为它是很多人以为能兜底的那道防线。
八、总和校验为什么抓不到错位
现场最常见的兜底手段是:“进线总表读数 ≈ 各分表之和”。这个思路在计量上成立(扣除线损),但用来抓映射错位,在数学上就不成立。
原因只有一句话:置换不改变总和。
8 台表整体前移一位、后移一位、循环移位、随机打乱,都是对同一个多重集的置换。置换之后,8 个数的和与原来完全一样。脚本把 7 种循环移位全跑了一遍:
| 移位位数 | 总和 | 偏差 |
|---|---|---|
| 1 | 114.0 kW | 0.00% |
| 2 | 114.0 kW | 0.00% |
| 3 | 114.0 kW | 0.00% |
| 4 | 114.0 kW | 0.00% |
| 5 | 114.0 kW | 0.00% |
| 6 | 114.0 kW | 0.00% |
| 7 | 114.0 kW | 0.00% |
7 种循环移位,7 种都是 0.00%。 这不是巧合,这是恒等式。
那为什么这个站的总和能看出一点异常?因为它不是纯循环移位,是“前移 + 末位越界归零”:
| 项 | 值 |
|---|---|
| 真实总和 | 114.0 kW |
| 读到总和 | 111.0 kW |
| 偏差 | 3.0 kW / 2.63% |
丢掉的正好是 3# 柜那 3kW。如果丢的是最大那台(22kW),偏差 19.30%;如果丢的是最小那台(3kW),偏差 2.63%——这个站恰好丢的是最小的那台。
2.63% 在现场意味着什么?0.5S 级电表的自身误差是 0.5%,2.63% 是它的 5 倍,理论上该被看出来。但现场同时存在线损(1%~3%)、CT 变比误差、负载不平衡、不同表对时不同步。一个 2.63% 的偏差,在月度报表上和一个“线损略偏高但可接受”的站长得一模一样。
所以总和校验的真实能力是:
| 错位形式 | 总和偏差 | 能否被总和校验发现 |
|---|---|---|
| 循环移位(末位回绕) | 0.00% | 永远不能 |
| 整体前移 + 末位归零 | 被丢那台的占比 | 只有占比远大于线损时能 |
| 整体后移 + 首位重复 | 被重复那台的占比 | 同上 |
| 单台映射到错地址 | 两台之差 | 只有差值大时能 |
结论很难听但必须说:总和校验只能抓“缺失/重复”,抓不到“错位”。 而错位恰恰是最常见的形态——写法 A 的数组塌缩、写法 C 的行号漂移,产生的都是置换。
还有一种更绝的情况:如果末位不是归零而是回绕到第 1 台,那么 8 个数只是转了一圈:
| 通道 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| 回绕情况下读到 | 12 | 3 | 15 | 20 | 18 | 14 | 22 | 10 |
总和 114.0,偏差 0.00%。一天都没人会发现。
真正能定案的做法只有两种:
- 逐台读 SN 比对台账。 每台表读一次序列号(通常 4 个寄存器),8 台 × 34.69ms = 277.5ms,一次就够。这是本篇唯一推荐的方法,成本低到没有理由不做。
- 逐台断电比对。 断一台,看哪个通道掉 0。确定,但要停 8 次电,现场不会同意。
还有一个便宜的先验判断:看日电量曲线的形状,不看数值。错位时整列数字跟着走,某台柜子的电量曲线会和它自己的负载曲线对不上——形状比数值更难伪装。这个方法不需要工具,只需要把两条曲线画在一起。
九、并发:网关不是慢,是你的超时假设了并行
第三个故障跟地址无关,但它盖住了前两个。
EMS 的实现是“每台表一个 TCP 连接”,8 台表 8 个连接。写这套代码的人想法很自然:8 个连接并行读,快。
实际情况:网关有 8 个连接,但只有一条 485。8 个连接进来,网关内部 FIFO 排队,一件一件压上总线。
单事务的时间(9600 8E1,读 10 个寄存器):
| 阶段 | 时间 | 占比 |
|---|---|---|
| 请求 8 字节 | 9.167 ms | 17.7% |
| T3.5 帧间隔 | 4.010 ms | 7.7% |
| 从站处理 | 10.000 ms | 19.3% |
| 响应 25 字节 | 28.646 ms | 55.3% |
| RTU 合计 | 51.823 ms | 100% |
| 加网关转换 | 53.023 ms |
8 个连接同时发,第 k 个请求的完成时刻就是 k × 53.023ms:
| k | 完成时刻 | 客户端 300ms 超时 |
|---|---|---|
| 1 | 53.0 ms | ok |
| 2 | 106.0 ms | ok |
| 3 | 159.1 ms | ok |
| 4 | 212.1 ms | ok |
| 5 | 265.1 ms | ok |
| 6 | 318.1 ms | 超时 |
| 7 | 371.2 ms | 超时 |
| 8 | 424.2 ms | 超时 |
一次成功的只有前 5 个,后 3 个必然超时。 这时候总线占用是多少?
单台两帧(实时 10 寄存器 + 电度 4 寄存器)= 53.023 + 39.273 = 92.296ms;8 台一轮 = 738.4ms;1 秒周期占用 73.8%。
注意这里有个容易混淆的点:按“每台两帧”算,一轮 738ms,占用 73.8%,看着“紧但可行”;但上面那张排队表只算单帧 53ms,8 个连接同时发单帧,占用只有 42.4%。
42.4% 的占用,37.5% 的失败率。 这个组合说明问题不在带宽,在瞬时并发:8 个请求同一毫秒到达,最后一个在队列里等了 371ms,比超时还长。
所以“网关慢”这个判断是错的。网关一点都不慢,它只是串行。你的超时预算按“一次事务 53ms,给 300ms 有 5 倍余量”算,但真实等待是“前面还有 7 个”。
要让 8 个全过,超时至少要 425ms。但把超时调到 500ms 只是把问题推到下一层:失败的重试继续插进队列,队列越来越长,最后连第一个也开始超时——这就是“重启能好、过一会儿又不行”的机制。
正确的做法是别并发。改成 1 个连接串行轮询:
| 方案 | 一轮耗时 | 1s 周期占用 | 说明 |
|---|---|---|---|
| 8 连接并发 | 424.2 ms(只算单帧) | 42.4% | 后 3 个必超时 |
| 1 连接串行,每台两帧 | 738.4 ms | 73.8% | 超时按单事务算,稳 |
| 1 连接串行,周期 2s | 738.4 ms | 36.9% | 有余量,推荐 |
串行之后,客户端超时的算法也变了:不再是“一次事务多久”,而是“一次事务 × 队列里还有几个”。这一条和 145 篇里“代理 ACK 1.2ms”的坑是一体两面——快出来的时间,都是从语义或排队里借的。
规则四:一个网关一条串行总线,上位机就只开一个连接。 并发连接不会让一条 485 变快,只会让超时变得不可预测。
十、静默丢弃 vs 显式异常码:22.77 倍
回到 unit 8(空行)那一路。两种实现的代价差多少,直接算。
静默丢弃(现状):请求进了网关,查表失败,丢掉,不回任何东西。客户端只能等满超时。
3 次重试 × 300ms = 900 ms/轮
显式异常(0x0B):网关发现没有路由,立刻回一帧 5 字节的异常响应。
单次 = 请求 9.167ms + T3.5 等待 4.010ms = 13.177 ms
3 次 = 39.5 ms/轮
比值 22.77 倍。
这个倍数在单路故障时已经很明显,但真正的杀伤力在“一轮总时长”上:
| 场景 | 一轮耗时 | 与 1s 周期比 |
|---|---|---|
| 8 台全正常 | 738.4 ms | 73.8% |
| 加 1 路无路由(静默丢弃) | 1638.4 ms | 163.8%,积压 |
| 加 1 路无路由(回 0x0B) | 777.9 ms | 77.8%,仍在周期内 |
一个是 1638ms,超出周期 63.8%,队列从这一轮开始永久滞后,而且每一轮都往后堆;另一个是 778ms,仍在周期内,只多花 39.5ms。
差距不是 22.77 倍,是“能跑”和“跑不动”。
这也解释了为什么现场有“重启能好”的错觉:重启清空了客户端队列,第一轮从空队列开始,738ms 跑完,看着全好;第二轮开始积压,第三轮崩。
写到这里要承认一件事:网关固件改不了。那能做什么?
- 选型时把“未知 unit id 是否回 0x0B”写进技术协议(成本 0);
- 上位机侧做一层本地白名单:点表里配了哪些 unit,就只轮询哪些,配错的一律不发(成本几行代码);
- 启动时做一次地址自检:对每个配置的 unit 发一帧最短请求(读 1 个寄存器),全过才进入正常轮询(成本 8 × 34.69ms = 277.5ms,只在上电时做一次)。
第三条特别值:它把“运行中每轮白等 900ms”变成“上电时一次性暴露”。这也是 139 篇那个“设备端收到就执行”思路的镜像——错误要早暴露,不要留到运行态。
十一、0 和 255:两个谁都能解释的值
unit id 的取值域,规范写得很清楚,但“清楚”的部分只覆盖了中间一段:
| 取值 | 规范定义 | 厂商实际用法 | 风险 |
|---|---|---|---|
| 0 | 广播,无响应 | 透传广播 / 拒绝回 0x0B / 当本机 | 三种都“不算违规”,行为不可假设 |
| 1~247 | 正常从站 | 透传或重映射 | 唯一可移植的区间 |
| 248~254 | 保留 | 部分厂商拿来做内部通道 | 点表用了就等着撞 |
| 255 | 未定义 | 常被占为“本机/诊断” | 值看起来正常,其实不是表的数据 |
广播(unit 0) 的坑在“无响应”这三个字上。规范说广播不响应,但很多上位机是按“发完等回包”的统一框架写的,于是每次广播都要白等一个超时。
| 广播频率 | 每天白等时间 |
|---|---|
| 1 次/天(校时) | 0.3 s |
| 24 次/天(每小时校时) | 7.2 s |
| 96 次/天 | 28.8 s |
| 288 次/天(5 分钟一次) | 86.4 s |
白等本身不贵,贵的是“以为校时成功了”。如果网关不透传广播,校时命令就静默失效,而表内时钟会一直漂:
0.5 s/天 × 365 天 = 182.5 s = 3.04 min/年
3 分钟听起来不多,但分时计费按 15 分钟颗粒切段,每天 4 个计费边界,一个月 120 次边界:
120 × 3.04 / 15 ≈ 24 次/月
每个月有二十几次电量被记到了错误的时段。 峰谷电价差 3 倍,这个损失不会进任何告警。
unit 255 的坑更隐蔽。如果网关把 255 当本机,你读电量读回来的是固件版本号或者运行秒数。前者不动,后者单调递增——两者都“不像坏值”。用 255 读表的点表,往往要等到做电量平衡时才发现这一路永远是 0 或者永远在涨。
规则五:点表只用 1~247。 0 只做广播且必须显式“不等响应”,248 以上一律不用。这条写进点表模板的注释里,比写在规范文档里有效。
十二、扁平化:unit id 消失之后
还有一类网关,干脆不用 unit id。它把 N 台从站拼成一张连续的大地址表:
unit 1 的 0x0000~0x0FFF -> 扁平地址 0x0000~0x0FFF
unit 2 的 0x0000~0x0FFF -> 扁平地址 0x1000~0x1FFF
...
unit k 的 0x0000~0x0FFF -> 扁平地址 (k-1)*0x1000 ~ k*0x1000-1
上位机不需要 unit id,直接读 0x1000 + 寄存器地址。好处是点表简单,坏处是:
| 窗口大小 | 最多从站数 | 越界行为 |
|---|---|---|
| 0x1000(4096) | 16 台 | 第 17 台越过 0xFFFF,回绕到 0x0000,读到第 1 台 |
| 0x2000(8192) | 8 台 | 第 9 台回绕到第 1 台 |
回绕在 16 位地址空间里是必然的:(k-1) × 0x1000 一旦 ≥ 0x10000 就截断。第 17 台读出来的是第 1 台的寄存器,地址合法、CRC 对、异常码没有。
更常见的是偏移算错一格。窗口 0x1000 但偏移写成 0x0800:
想读第 2 台 0x0000 -> 实际落到 0x0800 -> 第 1 台的后半段
结果一样:地址合法、值在量程内、语义全错。
这一类错误跟 107 篇里的“倍率层”不是一回事。107 讲的是 L1 字节、L2 类型、L3 倍率——那是同一个寄存器怎么解释;这里是寄存器找错了对象。倍率再对,读的也是别人的数。
十三、改造:五条规则,一张表
复盘之后,这个站做了五件事。都不贵,可以直接抄。
- unit id = 柜号,不是行号。 每一行显式写 unit,删行、加行都不影响别人。柜门丝印、网关配置、点表通道,三处用同一个数字。
- 映射表按 unit id 索引,不按行号。 删除条目只影响该条,不塌缩。这一点在选型时问清楚,事后改不了。
- 未知 unit id 必须回 0x0B。 网关做不到,就在上位机做白名单,别让请求发出去。
- 一个网关一个连接,串行轮询。 超时按“单事务 × 队列长度”算,不按“单事务 × 5 倍余量”算。
- 点表只用 1~247。 0 只做广播且不等响应,248 以上禁用。
还有一张必须存在的表——三方对照表。这是整件事里唯一没有技术含量、但最能救命的东西:
| 物理柜号 | 柜门丝印 | 电表 SN(后 6 位) | 网关 unit id | 从站 addr | 点表通道 | 换件记录 |
|---|---|---|---|---|---|---|
| 1# | 1# | 203117 | 1 | 1 | 1 | — |
| 2# | 2# | 203118 | 2 | 2 | 2 | — |
| 3# | 3# | 203125 | 3 | 3 | 3 | 2025-05 换表 |
| 4# | 4# | 203119 | 4 | 4 | 4 | — |
| … |
注意 3# 那行多了一列“换件记录”。这张表存在,这个事故就不会发生;这张表不存在,事故只是早晚。而且这张表要跟竣工资料放在一起,不能只存在某个人的 Excel 里。
表里“电表 SN”不是装饰。它是唯一能在不断电、不爬柜子的前提下,验证“这个通道读到的是不是这台表”的东西。第八节那个 277.5ms 的自检,就是拿它比对的。
还有一条流程上的:换件必须走一次“改完再读一遍”。换表、改地址、动映射表,做完之后立刻读一次 SN 并核对,五分钟的事。这次事故的全部成本,都可以用这五分钟抵掉。
十四、验收:现场十分钟能做的五个动作
不用协议分析仪,一台笔记本、一个 USB 转 485、十分钟:
- ☐ 1. 把映射表逐行打印出来,核对每一行的 unit id 与从站地址;再数一遍行数,和现场表的台数对一遍
- ☐ 2. 抓 30 秒总线波形,确认每个地址都有请求帧;缺哪个,那个就是“没下发”
- ☐ 3. 逐台读 SN,与三方对照表比对(8 台共 277.5ms)
- ☐ 4. 看 EMS 开了几个连接;超过 1 个,且网关只有一条 485,就是排队超时
- ☐ 5. 对着任意一台柜子,用钳表量一次电流,和 EMS 上对应通道的数比一次
判据树:
| 现象 | 判定 | 处理 |
|---|---|---|
| 总线上没有该地址的请求帧 | 网关没下发 | 查映射表 / 查白名单 |
| 有请求帧,从站不回 | 从站地址错或未上电 | 查拨码、单独试表 |
| 回 0x0B | 网关查表失败 | 补映射,或让网关回异常更早暴露 |
| 回 0x02 | 从站说地址非法 | 从站侧地址设置 |
| 数据正常,SN 对不上 | 映射错位 | 重排映射表 |
| 数据正常,SN 也对,总量不对 | 计量问题,不是通信 | 查 CT、倍率(见 107) |
| 多台随机超时,重启能好 | 并发排队 | 改单连接串行 |
| 连接被 RST,毫秒级失败 | 网关连接数上限 | 降连接数,不是调超时 |
最后两行要分开看:RST 是毫秒级失败,超时是 300ms 失败。前者是 TCP 层拒绝(连接数满了),后者是应用层没等到。看到“连接被重置”还去调超时,是白费功夫。
十五、三个可以带走的结论
一、unit id 必须是身份,不能是位置。 它只对“带串口的网关”有意义;原生 TCP 设备上它通常只有一个合法值,RTU over TCP 上它根本不存在。而比语义更根本的是:网关里“第 3 行”会变,柜门上“3#”不会变。删一行,位置还在,身份换了。三个系统都给你一个“3”,三个都不是 3# 柜——这个故障能挂一年,靠的就是这个。
二、错位比失联危险,因为失联会被发现。 5 台柜子读着别人的数,数值合法、CRC 全对、异常码为零、通信质量 100%。系统里没有任何一个指标会动。而“进线 = 分表之和”这道防线,在数学上就抓不到置换——7 种循环移位,7 种都是 0.00%。这个站总和差 2.63%,恰好是被丢的那台负载最小(3kW),正好落在线损和 CT 误差的判定带里。能定案的只有逐台读 SN:8 台 277.5ms,一次就够。
三、并发连接不会让一条 485 变快,只会让超时变得不可预测。 8 个连接、42.4% 的总线占用、37.5% 的失败率——这三个数放在一起,说明瓶颈是瞬时排队而不是带宽。而网关对“未知 unit id”静默丢弃,会把一次配置错误放大成 22.77 倍的等待,把一轮 738ms 拖成 1638ms,超出周期 63.8%。故障不是从“读不到”开始的,是从“队列开始积压”开始的。
这篇里所有数字都来自 gateway_unitid_map_calc.py(纯标准库、离线可跑,selftest 24 项全过)。波特率、从站数、超时、连接数、每台寄存器数、负载分布都是参数,改一行就能套到自己现场。模型假设写在每个模式的输出里——它们是用来算量级和方向的,方向比数值稳。
下一篇写《Modbus 从站的地址冲突与重复应答:总线上有两台 8 号表,一台在说真话,一台在说假话》——同地址冲突的物理成因(拨码拨错、备件表没改地址、两台网关重映射重叠)、两台同时应答时的波形特征(差分电平塌陷而不是 CRC 错)、冲突概率与帧长的关系、为什么“读十次有三次对”这种间歇性故障几乎必然是地址冲突,以及上电自检里怎么用一次扫描把重复地址找出来。
本文涉及的具体项目参数已做脱敏处理,数值保留原量级。