逆变器 Modbus 点表与上位机对接实战:同一台储能逆变器,厂家上位机显示 231.0V、我读到 233.0V——差的那 2V 不在寄存器里,在地址口径这一层

2026年10月13日 0 By admin

上个月接了个活:给一台 5kW 储能混合逆变器写一套自己的上位机。

设备是标准的 Modbus RTU 从站,9600-8N1,从站地址 1,厂家给了 60 页的通信协议文档,里面有完整点表。我按文档写完,连上跑了一遍——六个点里有四个不对。

现场记录(我抄的原话,一个字没改):

我读的点 厂家上位机显示 我的上位机显示 差在哪
电池电压 51.2 V 512 刚好 10 倍
电池电流(放电 2.35A) −2.35 A 653.01 A 跳到了 65000 那一带
光伏总有功 4200 W 2.00424 W 一个”看起来正常”的假数
电网电压 231.0 V 233.0 V 只差 2V,而且随负载变
累计发电量 12345.6 kWh 用了两小时后归零 回绕
逆变器状态字 “故障:交流过压” 27 一个光秃秃的数字

六个症状,六个不同的原因,分布在四个完全不同的层次上。

这篇就是把它们一层一层拆开的过程。拆完你会发现一个规律:症状的形状本身就是分诊线索——差 10 倍、差固定小常数、跳到 1e−41、跳到 65000,这几种形状各自指向的层次差别很大,几乎不会串。


一、先把这一台设备交代清楚

不把现场说清楚,后面所有结论都是悬空的。

  • 设备:5kW 单相储能混合逆变器(光伏 + 电池 + 电网/负载),Modbus RTU 从站。
  • 链路:逆变器 RS485 口 → 双绞屏蔽线 3 米 → USB-RS485 转换器 → 我的笔记本。A/B 没接反(接反的现象是”一个字节都不回”,跟本篇无关)。
  • 串口参数:9600-8N1,从站地址 1,无校验。
  • 厂家侧:厂家上位机(Windows 那套),同一根线、同一台设备,显示全部正常。
  • 我的侧:自己写的 Python 上位机,直接拼 RTU 帧,没有用任何第三方 Modbus 库——这一步后来救了我,因为库的”默认约定”会把问题藏起来(后面第五节讲)。
  • 文档:厂家《通信协议 V1.4》,点表用 40001 这种五位参考号书写。

这里有个细节值得单独说:我先用厂家上位机读了一轮,确认设备本身没问题、参数没配错,然后才换自己的程序。 这一步是分诊的地基——“厂家工具对、我的工具错”这个信息,直接把排查范围从”整条链路”缩到”我的解码逻辑”。

如果连厂家工具也不对,那问题在更下面(接线、波特率、从站地址、终端电阻),那是另一篇文章的事——物理层的问题我在 RS485/Modbus 抗干扰实战 里从头捋过一遍,本篇不重复。协议本身的基本盘(功能码、数据模型、异常响应)在 Modbus 协议在工业物联网中的实现 里也写过。


二、四层错位模型:错位一定发生在某一层

我一开始的排查方式是错的——我按”我觉得最可能的原因”顺序去猜,猜了半小时。后来改成按”层”走,二十分钟全部定位。

Modbus 读取的本质是一条流水线,从线上到屏幕上要过五道关:

线上字节 ──► [L0 地址] ──► [L1 字节序/字序] ──► [L2 数据类型] ──► [L3 语义倍率] ──► 工程值
             取哪个寄存器    这四个字节怎么排      当成什么类型       乘多少加多少
                                                        │
                                                        └──► [L4 有效性] 这个值可信吗?

关键认知:这五层是串联的,任何一层错,后面全废;而且同一层错,症状形状高度一致。

五种症状形状 → 对应五层

症状形状 落在哪层 原因
恰好差 10 / 100 / 1000 倍 L3 语义 倍率漏乘或多乘
差一个固定的、小的常数,且随工况轻微变化 L0 地址 读到了相邻的另一个点
值跳到 1e−41 或 1e19 这种”不像物理量”的量级 L1 字节 32 位量的字序/字节序错位(但不是所有字序错都会离谱,见第四节)
值跳到 65000 附近,或者变成负数 L2 类型 有符号按无符号解(或反之)
一个光秃秃的数字,说不上对错 L2 类型 位域没拆
偶尔一次大跳变,下次读又好了 传输层 32 位量撕裂
所有点都读不到 传输层 CRC 字节序、波特率、从站地址
某几个点回异常码 0x02 L0 地址 地址口径错,或该段是保留空洞
值是对的,但几小时后归零 L2/L4 16 位溢出,或哨兵被当 0

“差 2V”这一行就是本篇的关键线索:差值小、恒定、且随负载微动——这不是倍率问题(倍率错会是数量级差异),也不是字序问题(字序错会离谱)。它是一个”物理上真实存在的差值”,说明我读到的不是我以为的那个点。

顺着这条线索,我把它排到了第一个——因为它的判据最硬。


三、L0 地址层:本篇那 2V 的真凶

先把参考号和 PDU 地址这件事说透

Modbus 的数据模型有四张独立的表,每张表内部都从 0 开始编址:

参考号前缀 表 位宽 常用功能码 PDU 0 基地址范围
0xxxx 线圈 Coil 1 bit 读写 01 / 05 / 0F 0 ~ 65535
1xxxx 离散输入 Discrete Input 1 bit 只读 02 0 ~ 65535
3xxxx 输入寄存器 Input Register 16 bit 只读 04 0 ~ 65535
4xxxx 保持寄存器 Holding Register 16 bit 读写 03 / 06 / 10 / 16 / 17 0 ~ 65535

参考号(40001 这种)是给人看的传统编号,不是地址。

它由两部分拼成:前导数字告诉你”这是哪张表”,后面的数字是”这张表里的第几个”(1 基)。而 PDU 里实际传的是 0 基地址。所以:

40001  →  表内第 1 个  →  PDU 地址 0
40034  →  表内第 34 个 →  PDU 地址 33
30001  →  PDU 地址 0

用工具算一下最省事:

$ python modbus_point_table.py addr 40034
── 地址口径: 40034 ────────────────────────────
  项            值
  ----------  --------------------------
  参考号表示        40034
  参考号前缀        4 → 保持寄存器 Holding Register
  位宽语义        16 bit 可读写
  常用功能码        03 / 06 / 10 / 16 / 17
  表内序号(1基)     34
  PDU 地址(0基)    33
  1 基地址        34
  错误的填法        34(会读到下一个点,症状是差值随工况变)
  结论:代码/库函数里填 33(0 基);若工具要求 1 基则填 34。
         参考号前缀只说明「这是哪张表」,不参与 PDU 地址计算。

三种口径,三种说法,指向三个数

同一份文档,不同厂家书写方式不同,而不同的库/工具默认的口径也不同。这是 Modbus 对接里最高频的坑,没有之一。

文档写法 含义 你要填进 PDU 的数
40034 参考号,表内第 34 个 33
4x0034 / 4:0034 同上(Wago / PLC 风格) 33
400034 6 位扩展参考号,同上 33
34(”寄存器号”) 1 基表内序号 33
33(”地址”) PDU 0 基 33

只有这五种写法里写着”33″的那一种才不需要换算。 更麻烦的是:有些库的 API 收的是”参考号”,有些收的是”0 基地址”,有些(少数)收”1 基地址”——它们的函数名往往都叫 read_holding_registers(address, count)。

我踩的就是这个

我的文档上写的是:

40023   电网电压       uint16   ×0.1   V
40024   负载电压       uint16   ×0.1   V

我当时的想法是”文档写了 40023,那地址就是 40023 呗”,于是往 PDU 里塞了 40023……不对,塞 40023 会立刻被设备回 0x02。

真实情况更阴——我的代码里写的是 int("40023"[1:]) - 1 = 22,这算对了;但我把”电网电压”和”负载电压”的键值对抄错了一行,我的程序里 grid_voltage 的地址是 23(也就是”负载电压”)。

所以我在读一个真实存在、物理上相邻的点,只是名字错了。

  • 厂家上位机显示”电网电压” = 231.0V(PDU 23 的真实值,我读的也是 23,但我的界面上叫它”电网电压”……不对)

等一下,把这个说清楚一点,因为这里正好是最容易讲混的地方:

我的程序里,grid_voltage 这个名字绑到了 PDU 地址 23;而厂家文档里 PDU 23 是负载电压,PDU 22 才是电网电压。所以我显示”电网电压 233.0V”时,实际读的是负载电压。

为什么这个结论是硬的——空载试验

只看到”差 2V”不能定案。我做了两个可证伪的试验:

  1. 空载试验:把逆变器交流输出断开(负载为零)。此时电网侧和负载侧应该是同一个电位,两个点应该几乎相等。实测:我读到 231.2V,厂家显示 231.1V——差 0.1V,吻合。
  2. 带载试验:接上 3.2kW 阻性负载。我读到 227.4V,厂家显示 231.0V——差 3.6V,而且差值随负载成正比变大。

“差值随负载变化”这一条,直接把”倍率错”和”偏移错”全部排除——倍率错是乘性误差,空载时也应该错;偏移错是加性常数,不随负载变。只有”读到了另一个物理点”,差值才会随工况变。

顺手说一句:这 2V~3.6V 是真实存在的线损,逆变器输出端到负载端之间那段线和继电器的压降(3.2kW / 230V ≈ 13.9A,3.6V / 13.9A ≈ 0.26Ω 回路阻抗,对一个 5kW 机器来说很正常)。厂家没骗人,设备没故障,是我读错了点。

L0 的通用判据

  • 差值形状:固定的、小的、随工况微动的差值 → 读相邻点。
  • 最快的确认法:把读到的地址扫 −4 到 +4,把九个寄存器全打出来。你会发现”你以为的那个点”就在隔壁,而且它的值跟你期望的完全吻合。
  • 别用”单点验证”收工。同一组 (倍率, 偏移) 有无穷多组合能拟合一个点。至少要两个不同工况点,才能把直线定死——这一点跟 母线电压采样链路 里”单点标定必然留量程误差、必须两点注入标定”是同一个道理。

四、L1 字节层:32 位量的四种排列

这是最”反直觉”的一层,因为你以为规范定死了字节序,其实它只定死了一半。

规范定死的部分

Modbus 的 PDU 里,单个 16 位寄存器在线上是”高字节先发”。这一条是规范的(Modbus Application Protocol 的 Data Encoding 一节),你也改不了它,设备也不会违反。

所以如果你只读 16 位量,字节序这件事根本不存在。 我这六个症状里,前四个(电池电压、电流、电网电压、状态字)都是 16 位量,跟字节序一点关系都没有——这是很多人第一个想错的地方。

规范没定的部分

问题出在 32 位量占两个寄存器时:先发哪个寄存器。规范没说。

于是现场存在四种排列。我用 A B C D 表示一个 32 位量的四个字节(A 是最高有效字节),四种排列就是:

字序 PDU 字节排列 常见叫法 谁在用
ABCD A B C D 大端字序 + 大端字节序 Modbus 事实默认,多数国产设备
CDAB C D A B Mid-Big,字交换 一部分逆变器、电表(我这台就是)
BADC B A D C Mid-Little,字内字节交换 上位机侧直接 memcpy 到 x86 struct 的结果
DCBA D C B A 完全小端 一部分国产 PLC 习惯

厂商的叫法是一团乱麻:同一篇文章里 “big-endian byte swap” 可能指 CDAB 也可能指 BADC,取决于作者那天心情。所以我建议你彻底放弃记名字,只记字节排列。

用 230.5 看四种排列

拿一个具体数字最直观。230.5 的 IEEE 754 单精度是 0x43668000:

$ python modbus_point_table.py bytes 230.5 --type float32
  逻辑字节 A B C D = 43 66 80 00

  字序    PDU 字节       寄存器N    寄存器N+1  说明
  ----  -----------  ------  ------  --------------
  ABCD  43 66 80 00  0x4366  0x8000  大端字序 + 大端字节序
  CDAB  80 00 43 66  0x8000  0x4366  字与字交换,字内字节不变
  BADC  66 43 00 80  0x6643  0x0080  字序不变,字内字节交换
  DCBA  00 80 66 43  0x0080  0x6643  字与字交换 + 字内字节交换

现在看上位机按错字序解会读出什么。注意一个很漂亮的性质:“设备是大端 × 上位机按 CDAB 解”和”设备是 CDAB × 上位机按大端解”,结果完全一样——字序变换是自逆的,所以一张表两种读法共用:

  设备实际字序 / 上位机假设字序  对「按大端解读」的读法
  ----------------  -----------  ----
  ABCD              230.5        (正确)
  CDAB              -2.4178e-41  错位解读
  BADC              2.30218e+23  错位解读
  DCBA              1.17916e-38  错位解读

230.5 在错误字序下会变成 −2.4e−41 或 2.3e+23——这是”经典”的字节层症状:值离谱到不像物理量。

不过我这台设备给了一个更有意思的例子。它的光伏功率是 float32、设备实际字序是 CDAB:

$ python modbus_point_table.py bytes 4200 --type float32
  逻辑字节 A B C D = 45 83 40 00

  字序    PDU 字节       寄存器N    寄存器N+1  说明
  ----  -----------  ------  ------  --------------
  ABCD  45 83 40 00  0x4583  0x4000  大端字序 + 大端字节序
  CDAB  40 00 45 83  0x4000  0x4583  字与字交换,字内字节不变
  BADC  83 45 00 40  0x8345  0x0040  字序不变,字内字节交换
  DCBA  00 40 83 45  0x0040  0x8345  字与字交换 + 字内字节交换

  设备实际字序 / 上位机假设字序  对「按大端解读」的读法
  ----------------  ------------  ----
  ABCD              4200          (正确)
  CDAB              2.00424       错位解读
  BADC              -5.78934e-37  错位解读
  DCBA              5.92456e-39   错位解读

设备按 CDAB 发 4200.0W(PDU 是 40 00 45 83),我按大端解,读出来 2.00424。

现场最快的定位法,就是把 raw 原样贴进去、四种字序全解一遍:

$ python modbus_point_table.py decode 0x4000 0x4583 --type float32
  PDU 字节序 = 40 00 45 83

  字序                    解码值
  --------------------  ----------------------
  ABCD  大端字序 + 大端字节序    2.0042426586151123
  CDAB  字与字交换,字内字节不变    4200.0
  BADC  字序不变,字内字节交换     5.924562389005073e-39
  DCBA  字与字交换 + 字内字节交换  -5.789338376392315e-37

哪个解出来像物理量,就是哪个——排除法,不需要先知道答案。

一个要修正的成见:字序错不一定会”离谱”

上面这张表里藏着一个我原本想错的地方,值得专门写一段。

我一开始的判断是”字序错 → 值一定离谱到不像物理量”。看 230.5 那个例子确实是这样,三种错位全是 1e−41、1e+23 这种量级。但 4200 这个例子里,三种错位里有一种给出了 2.00424。

2.00424 是一个看起来完全正常的数。 它落在”功率 0~5000W”的合理区间里,不会触发任何物理量程闸门,界面上显示”2.00 W”——而此刻光伏正在发 4200W。

这就是字序错位里最危险的那一类:它伪装成一个合法的数。 所以:

  • “值离谱”可以作为发现字序问题的线索,但不能作为排除字序问题的依据。
  • 真正定死字序的办法只有两个:写已知值反查(下一小节),或者让设备跑一个已知工况、看哪个排列解得对。
  • 我这台设备是靠”光伏晴天中午应该是 4kW 量级,而它显示 2.00″才起的疑心——如果那天是阴天,功率本来就低,这个 bug 可能一直藏着。

定死字序的正解:写一个已知值反查

上面是”事后确认”。如果你想事前就把字序定死,用这个办法:找一个可写的 32 位寄存器,写一个已知值进去,再读回来对字节。

推荐两个测试值,各有各的好:

(一)float32 写 1.0 —— 0x3F800000,四字节里只有两个非零(3F 和 80),而且紧挨着,所以四种排列边界最清楚:

读回来的字节 字序
3F 80 00 00 ABCD
00 00 3F 80 CDAB
80 3F 00 00 BADC
00 00 80 3F DCBA

1.0 有个小副作用:它在”字内字节交换”那一族里,3F 和 80 会互换位置,所以如果你心里没底,可能会怀疑自己看反了。

(二)int32 写 0x12345678 —— 四个字节互不相同,四种排列四个完全不同的样子,绝无歧义。要写 32 位量又拿不准字序时,我首选这个:

读回来的字节 字序
12 34 56 78 ABCD
56 78 12 34 CDAB
34 12 78 56 BADC
78 56 34 12 DCBA

两个前提,缺一不可:

  1. 这个寄存器得是可写的(保持寄存器 + 文档标注可写),而且写进去不会被设备拒绝或忽略。写之前先确认它不是什么关键参数(别拿”输出频率设定”做实验——那是会带负载的)。
  2. 写完要能读回来。有些点的写值会被设备做限幅(比如设成 1.0 被夹到 0.5),那样就对不齐了。所以第一次试的时候,趁设备空载、且这个参数不影响运行的时候做。

五、L2 类型层:有符号、位域、浮点

字序弄对了,还有一层:当成什么类型解。

有符号:电池电流那一项

我的电池电流读出 653.01A,设备其实在放电 2.35A。

原因很朴素:

放电 2.35A,倍率 ×0.01  →  寄存器值 = −235
−235 的 16 位补码 = 0xFF15 = 65301
我按 uint16 解读,65301 × 0.01 = 653.01 A   ← 我显示的数
按 int16 解读,(int16)0xFF15 = −235 → −2.35 A  ← 正确值

特征极好认:值跳到 65000 附近。 因为负数在 16 位补码里落在 0x8000 ~ 0xFFFF 区间,只要倍率不是特别小,解出来就是几万。

这里有个我强烈推荐的对称性测试,不用知道任何答案就能定案:

让设备在正反两个方向跑同一个幅值(这里是充电 2.35A / 放电 2.35A),看两个读数。

– 一侧正常、另一侧跳到 65000 附近 → 100% 是符号问题。

– 两侧都正常但符号相反 → 设备用的是”有符号 + 正负号约定”,你只需要对齐”正号代表充电还是放电”。

– 两侧差一个固定常数 → 那是零点偏移,不是符号。

我在别的文章里用过同一招(电流采样链路设计实战 里的”正反双向同电流”,用在协议层一样好使)。

顺带记一个容易错的边界:0x8000 作为 int16 是 −32768,不是 +32768。 这不是废话——很多设备拿 0x8000 当”传感器断线”的哨兵值,而它在 int16 域里恰好又是一个合法数值。这就是哨兵和合法值撞车的经典场景,第七节细讲。

位域:那个光秃秃的 27

厂家上位机显示”故障:交流过压”,我显示 27。27 = 0x001B = 0b0000_0000_0001_1011。

拆开就是四个 bit 同时置位:

bit 值 含义(按厂家文档)
0 1 交流过压
1 1 逆变器过载
3 1 电池欠压告警
4 1 温度告警

位域(bit field)是 Modbus 点表里被严重低估的一类:一个 16 位寄存器塞 16 个开关量,是厂家省寄存器的标准手段。上位机如果只把它当数字显示,运维看到的就是一个”27″,完全无法行动。

处理原则三条:

  1. 每个 bit 都要有名字,从点表抄,别自己猜(bit0 是”故障”还是”运行”各厂不同)。
  2. 区分”故障位”和”告警位”:故障要停机处理,告警只需记录。分不清就会出现”天天报故障,运维第三个月把它关掉”的结局。
  3. 保留 raw 值:位定义表会随固件版本增加,界面显示拆解结果的同时,raw 也要能查到。

浮点、BCD,和一些更少见的

  • float32:现代带 DSP 的逆变器很常见(我这台光伏功率就是)。字节序问题在它身上最严重,因为四个字节全参与,错位后数值范围能从 1e−45 跨到 1e38。
  • float32 的哨兵:0x7FC00000 是标准 NaN,0x7F800000 是 +Inf。有些设备用它们表示”无有效值”。上位机必须专门判,否则界面上会出现 nan 或者 inf。
  • BCD:每个寄存器放 4 位十进制(比如 0x1234 表示 1234)。识别特征很硬:把 raw 按十进制打印,如果每一位都是 0~9(十六进制里不出 A~F),且数值看起来恰好是十进制的样子,那就是 BCD。 表计类设备常见。
  • float64 / 4 寄存器:少见,但电力仪表(电能质量类)会有。字节序组合更多,别自己推导,抄文档 + 写值验证。
  • 打包的 32 位无符号:(regN << 16) | regN+1,注意 C 里要强转 uint32_t 再移位,否则 16 位机上会截断。

六、L3 语义层:倍率、偏移、量纲

为什么厂家爱用定点整数

这是我在文档里翻到的一句原话:”电压:0.1V/LSB”。翻译过来就是 倍率 0.1。

厂家为什么不用浮点,把 231.0 直接放寄存器里?三个理由,都是硬的:

  1. 成本:低成本 MCU/DSP 没有 FPU,浮点运算靠软件库,占 Flash 又慢。
  2. 确定性:定点整数的加、减、比大小、CRC、累加全是比特级可复现的;浮点在不同编译器/优化等级下结果可能不一致。这一点在”厂家用自己的工具和自己的固件”时无所谓,但在”设备算一遍、上位机再算一遍、两边要一致”时就是刚需。
  3. 字节序问题:32 位浮点跨两个寄存器,”哪个寄存器在前”没有标准。用定点整数的话,16 位量根本不涉及这个问题——厂家是在用设计规避协议缺陷。

代价就是:你必须带着点表的 scale/offset 走,不能裸读。

常见倍率分布(我这台设备)

物理量 倍率 满量程 备注
电池/母线电压 ×0.1 0 ~ 6553.5 V uint16
交流电压 ×0.1 0 ~ 6553.5 V uint16
电流 ×0.01 ±327.67 A int16
功率(W) ×1 ±32767 W int16,双向
频率 ×0.01 0 ~ 655.35 Hz uint16
温度 ×0.1 −40 ~ 150 ℃ int16
SOC ×1 0 ~ 100 % uint16
累计发电量 ×0.1 kWh 见下 uint16 → 会绕

注意”倍率不统一”这件事。 同一份点表里,电压 0.1、电流 0.01、功率 1、频率 0.01、温度 0.1。用”一个全局倍率”硬套整张点表,必然错一半。 我最初那版代码就是图省事写了个全局 SCALE = 0.1,于是电流的 0.01 被当成 0.1——这一条直接造成了”电流差 10 倍”这类假象(跟符号问题叠加在一起,更难认)。

工具可以直接算量程:

$ python modbus_point_table.py range --type int16 --scale 0.01
  整数域      = -32768 ~ 32767
  物理量程    = -327.68 ~ 327.67
  分辨率      = 0.01(1 个 LSB)
  ⚠ 16 位 + 倍率 0.01:上限只有 327.67,像「电度 kWh」「累计发电量」这类会一直涨的量必定溢出回绕

偏移 offset 与量纲

  • 温度偏移:不少设备把温度存成”实测 + 40″,因为要覆盖 −40℃,又不想用有符号。读出来 65 其实是 25℃。
  • 双向功率:光伏发电为正、给电池充电为负(或者反过来),这个约定每个厂家都不一样,必须在点表里找到一句话确认。搞反了不会报错,只会让你的”发电量”和”用电量”全部互换——这种错最难发现,因为它看起来完全正常。
  • 量纲:kW vs W、Ah vs kWh、Var vs kVar。点表里的单位列一定要读,别靠量级猜。电池的 SOC 和 SOH 都是百分比但含义相反,累计电量(kWh)和电池容量(Ah)差一个电压因子——电池管理与 SOC 估算实战 里把这两个量的关系捋过。

倍率反推:当文档没写清楚时

偶尔会遇到文档只写”×0.1″但没写是哪个寄存器的、或者干脆没写倍率。这时候用倍率反推:让设备停在一个你能量出真值的工况,拿 raw 反算。

$ python modbus_point_table.py scale 512 51.2
  精确倍率 k = 0.1
  最接近的常用档位 = 0.1   (偏差 0.0000%)

反推能不能定案,取决于这个点选得好不好:

  • 选一个”必然存在且容易测准”的点:空载时的电池电压(数字万用表直接量,精度足够)。
  • 别选会飘的点(带载电流、瞬时功率)。
  • 反推出来落在常用档位(1 / 0.1 / 0.01 / 0.001)附近才算命中;如果反推出来是 0.0997 或者 0.1 的偏差超过 0.2%,那要么不是倍率问题,要么你的物理值取点不准。

七、L4 有效性层:哨兵值、点表版本、新鲜度

前三层解决了”算得对不对”,这一层解决”这个值该不该信”。

哨兵值(sentinel)

设备想表达”这个点我现在给不出有效值”,但寄存器里必须填个数,于是填一个约定好的特殊值。

$ python modbus_point_table.py sentinel 65535 32768 0 --scale 0.1
  raw     无符号值   物理量(×0.1)  判读
  ------  -----  ---------  ----------------------------------
  0xFFFF  65535  6553.5     0xFFFF:最常见的「未支持/无效」哨兵,不是 65535
  0x8000  32768  3276.8     0x8000:有符号越界或「传感器断线」哨兵
  0x0000  0      0          0x0000:既可能是真 0,也可能是「未刷新」——必须配合时间戳判

上位机必须区分三件事:

  1. 读到 0 —— 设备确实输出 0(比如夜间光伏功率)。
  2. 读到哨兵 —— 设备说”这个点我没有”(该版本不支持、传感器未接)。
  3. 超时 / 异常码 —— 根本没读到。

把三者都显示成 0,是”显示值莫名归零”类投诉的根源。 运维看到 0 会去查设备,其实设备好得很——是协议层在说”我没有这个数”。

项目里我统一的处理是:工程值旁边永远跟一个状态标记(ok / sentinel / stale / timeout / err:0x02),界面上把非 ok 的值灰掉或者打上符号。宁可显示”—”,也不显示一个假的 0。

点表版本:同一型号,不同固件

这条是最容易被忽略、后果最难查的一类问题。

同一型号的逆变器,固件从 V1.3 升到 V1.4,厂家在中间插了两个寄存器(新增了”电池 SOH”和”绝缘阻抗”)——从插入点开始,后面所有寄存器的地址整体后移 2。

结果是:你的上位机对 V1.3 的设备完全正确,对 V1.4 的设备从插入点往后全部错位。而且错位的表现正是本文第三节那种”差一个固定的小常数”——你会以为是地址口径问题,翻来覆去算 40001 → 0 怎么又算错了。

两个防身做法:

  1. 把点表版本写进点表文件本身,解码时先读设备的版本号,对不上就拒绝解码并告警,别猜。
  2. 读设备识别码 —— Modbus 有标准功能码 0x2B(Read Device Identification),能拿到厂商名、产品码、固件版本:
对象 ID 内容
0x00 VendorName 厂商名
0x01 ProductCode 产品代码
0x02 MajorMinorRevision 固件版本
0x03 VendorUrl 厂商网址
0x04 ProductName 产品名
0x05 ModelName 型号
0x06 UserApplicationName 应用名
0x80+ 厂商自定义

请求格式:功能码 0x2B、MEI 类型 0x0E、读取类别(1=基本/2=常规/3=扩展/4=特定)、起始对象 ID。不是所有设备都支持(低成本栈经常不实现,会回异常码 0x01),但支持的设备上,这是你锁定点表版本最干净的办法。少数老设备还有功能码 0x11(Report Server ID)可以替代。

新鲜度

“读到了”和”值有效”是两件事。 每条数据都要带三个字段:采集时间戳、本次读取耗时、距上次变化的时间。

判据举例:

  • 一个温度点 40 分钟一位不变 → 大概率是设备在缓存旧值,标记 stale。
  • 一个功率点在 0.5 秒内从 4200W 跳到 0W 又跳回来 → 先怀疑撕裂(见第八节),别急着报警。
  • 一个电压点的变化率超过物理可能(比如 100V/ms)→ 标记异常帧并丢弃,同时计数上报。

八、传输层那几个不算”错位”、但同样致命的坑

这四条不属于前面五层,但它们在”值不对/读不到”的现场占比很高。

1. CRC 是 Modbus 里唯一的小端字段

这条极其容易错,而且错法的症状特别有迷惑性。

Modbus RTU 帧尾的 CRC 是 16 位值,但在线路上是低字节先发。算出来的 16 位值和你写出去的字节顺序不一样:

$ python modbus_point_table.py crc 01 03 00 00 00 0A
  输入(帧体)     = 01 03 00 00 00 0A   (6 字节)
  CRC (数值)     = 0xCDC5
  线上传输顺序   = C5 CD   ← 低字节在前!
  完整 ADU       = 01 03 00 00 00 0A C5 CD

顺手给一个可以用来验证自己实现的固定检查值:输入 ASCII 字符串 123456789,Modbus CRC-16 的结果是 0x4B37。这个值在 CRC 参数库里有目录,是全世界通用的自检向量。再给一个报文级向量:11 03 00 6B 00 03 的 CRC 是 0x8776,线上是 76 87。

为什么错的症状有迷惑性:CRC 错的帧,设备会静默丢弃(规范要求如此),不回任何东西。而很多自研上位机在”收不到应答”时的默认反应是”波特率不对”或者”线接反了”——于是你花两小时去查物理层,实际是自己在拼帧时把 CRC 的两个字节写反了。

自研 RTU 实现的两条纪律:一是先算 16 位值、再拆低字节写;二是别用 % 取模写 CRC,用移位和异或。

2. 3.5 个字符的静默间隔

RTU 靠”静默间隔”划帧边界,不靠任何起始符。规范要求帧间静默 ≥ 3.5 个字符时间,帧内字节间隔 ≥ 1.5 个字符时间。

波特率 1 字符时间(8N1,10 bit) 1.5 字符(T1.5) 3.5 字符(T3.5)
9600 1.042 ms 1.56 ms 3.65 ms
19200 0.521 ms 0.78 ms 1.82 ms
38400 0.260 ms 0.39 ms 0.91 ms
115200 0.087 ms 0.13 ms 0.30 ms

上位机侧的实际影响:如果你发完请求不等应答就发下一条,或者在应答中间插入字节,设备会把两条帧当成一条垃圾帧丢掉。多线程/异步写同一个串口是这里的经典翻车方式——两个线程各发各的,帧在物理层上就粘在一起了。串口句柄必须有独占锁。

顺便,这条也解释了为什么高速率下 Modbus RTU 反而更”难伺候”:115200 时 T3.5 只有 0.3ms,操作系统调度抖动都可能超过它。要用高波特率,就得靠硬件(UART + 定时器/DMA)而不是靠软件延时来判帧。(这条我在 UART 波特率漂移踩坑记 里从时钟侧也写过一遍。)

3. 单次读取的条数上限,和”寄存器空洞”

  • 读寄存器(0x03/0x04)单次最多 125 个寄存器(因为响应 ADU 上限 256 字节:1 从站 + 1 功能码 + 1 字节数 + 250 数据 + 2 CRC = 255)。超了设备会回异常码 0x03 或者干脆截断。
  • 读线圈(0x01/0x02)单次最多 2000 个。
  • 写多个寄存器(0x10)单次最多 123 个。

更麻烦的是寄存器空洞:点表里经常出现”40050 有用、40051~40055 保留、40056 有用”这种。你一次性读 40050~40056,设备很可能直接回异常码 0x02(非法数据地址),整个请求全废——包括那几个有效点。

处理办法:批量读之前先按点表的”连续段”分组。 一段一段读,洞跳过。代价是请求数变多;收益是一个洞不会拖垮一整片点。想简化的话,把有效地址分成若干连续区间,逐个区间发一个请求,一次轮询发 5~15 个请求,在 9600 下也完全够用。

4. 32 位量的撕裂(tear)

读 32 位量要发两个寄存器,但这是两次总线事务(或者一次请求里设备分两次填响应)。如果设备恰好在两次之间刷新了值,你会读到一半旧值 + 一半新值,解出来既不是旧值也不是新值:

旧值 4200.0 W → 45 83 40 00
新值    0.0 W → 00 00 00 00
撕裂结果 ①(高字取旧、低字取新)→ 45 83 00 00 → 4192.0 W   ← 看起来很正常!
撕裂结果 ②(高字取新、低值取旧)→ 00 00 40 00 → 2.296e-41 W ← 离谱

注意 ① 这一行:4192.0W。 设备此刻真实功率是 0W(云挡住了),你却读到 4192W——它落在合理量程内,界面不会报任何异常。 撕裂最坑的地方就在这里:一半的概率它给你一个”看起来对”的数。

算一下概率:

$ python modbus_point_table.py tear 10 8 1000
  设备刷新率 = 10 Hz → 刷新周期 T = 100.000 ms
  读窗口     = 8 ms(读完第一个寄存器 → 读完第二个寄存器的时间)
  轮询周期   = 1000 ms → 该点 1.000 次/s

  单次读取撕裂概率 p ≈ w/T = 8.0000%
  期望撕裂次数 ≈ 0.080000 次/s = 288.000 次/h = 6912.00 次/24h

注意每小时 288 次这个量级——按一天算就是近 7000 次。运维一定会问”你们监控里的功率为什么老是有尖峰”。

(这个估算是一阶近似:p ≈ w/T,假设刷新相位与读窗口相位不相关。真实的放大因素是设备端的刷新粒度——如果设备是”整块快照”式刷新,撕裂根本不会发生;如果它一个寄存器一个寄存器地填,撕裂概率就趋近于 100%。这也是为什么”设备端怎么刷新”必须写进点表备注。)

三个处理办法,按性价比排:

  1. 读两次比对:连续读两次,一致才采信。最简单可靠,代价是耗时翻倍。对慢速量的轮询完全可接受。
  2. 用 0x17(Read/Write Multiple Registers)或者设备提供的”快照寄存器”:让设备在一次事务里把一组关联量一起给你,从根上消除撕裂。这是最好的办法,但需要设备端支持。
  3. 合理性闸门 + 变化率检查:拿前后帧比对,跳变帧丢弃并计数。这是兜底,不解决根因,但能让界面干净。

判据:撕裂是”偶发大跳变、下次读就好”,不是”慢漂”,也不是”一直错”。 一直错是 L1/L2/L3 的问题。

异常码速查

码 名称 现场最常见的真实原因
0x01 非法功能 把 0x04 当 0x03 用;设备是简化栈不支持
0x02 非法数据地址 地址口径差 1;或该段是保留空洞
0x03 非法数据值 写超范围;写到了只读点
0x04 从站设备故障 设备自身故障字置位了
0x05 确认 已接受但需要时间,继续轮询
0x06 从站设备忙 退避重试
0x08 存储奇偶性差错 参数区校验失败
0x0A / 0x0B 网关路径不可用 / 网关目标失败 网关后设备不在线

0x02 值得背下来——本文那 2V 的排查,第一次试探就是靠它收敛的:不知道口径时,先用 1 基读一遍、再用 0 基读一遍,哪个不回 0x02 就是哪个。


九、上位机工程实现:声明式点表 + 分层解码

前面都是”怎么找问题”。这一节是”怎么不再犯”。

点表必须是数据,不是代码

最大的重构就是这一条:把点表从”代码里的 if/else 和一堆魔数”变成一个独立的、可以被非程序员读懂的配置文件。

$ python modbus_point_table.py point
{
  "device": "hybrid-inverter",
  "fw": "1.4.2",
  "point_table_version": "v3",
  "points": [
    {
      "pid": "batt_voltage",
      "addr": 33, "func": 3, "type": "uint16",
      "byte_order": "ABCD", "scale": 0.1, "offset": 0.0, "unit": "V",
      "phys_min": 0, "phys_max": 60.0, "sentinel": ["0xFFFF"],
      "writable": false,
      "note": "点表 40034,PDU 33;0.1V 倍率"
    },
    ...
  ]
}

几个字段的设计理由:

  • addr 统一存 PDU 0 基地址,并在 note 里保留原文档写法(40034)——这样”地址口径”这件事只在录入点表时做一次,之后全项目只有一个约定。文档改写一次,你只需要改这一处。
  • type / byte_order 是显式必填,没有默认值。强迫自己每次都想一遍”这到底是 int16 还是 uint16″,比事后猜便宜得多。
  • phys_min / phys_max 是物理合理性闸门。一个电压点解出 2300V 或者 2.3e−41,闸门直接拦下来标 suspect,而不是把 2.3e−41 显示到界面上再让运维来问。
  • sentinel 是无效值白名单,明确列出这个点的哨兵。比在解码器里写 if raw == 65535 可维护得多。
  • point_table_version + fw 是版本锚点,用来做第七节说的”版本对不上就拒绝解码”。

三层解耦

┌─────────────────────────────────────────────┐
│ 业务层  单位换算 / 量纲校验 / 告警判定 / 存库     │
├─────────────────────────────────────────────┤
│ 解码层  raw 数组 + 点表条目 → 工程值 + 状态标记   │  ← 这一层是纯函数,最好写单元测试
├─────────────────────────────────────────────┤
│ 传输层  组帧 / CRC / 超时 / 重试 / 串口独占      │  ← 这一层只管字节,不懂任何物理量
└─────────────────────────────────────────────┘

要点一:批量读 + 逐点解码,绝不”整块统一解码”。 一次请求读 40 个寄存器很快,但这 40 个里可能混着 uint16 / int16 / uint32(跨两个字)/ 不同倍率。批量只在传输层发生,解码必须回到每个点自己的六件套(addr / func / type / byte_order / scale / offset)。

要点二:解码层是纯函数。 输入 (raw 字节数组, 点表条目),输出 (工程值, 状态)。没有副作用、不碰串口、不读全局。这样你可以把厂家文档里那几行示例报文原样抄下来当测试向量,每次改代码跑一遍——文档里给的 raw=512 → 51.2V 就是一条现成的断言。

要点三:日志里永远同时打 raw 和 decoded。

[09:12:03.221] pid=grid_voltage   addr=23 func=03 raw=[0x0906]            dec=231.0 V      ok
[09:12:03.245] pid=pv_power_total addr=60/61 func=03 raw=[0x459A,0x0000]  dec=4200 W  bo=CDAB ok
[09:12:03.268] pid=batt_soc      addr=48 func=03 raw=[0xFFFF]            dec=--           sentinel
[09:12:03.291] pid=load_voltage  addr=24 func=03 raw=[0x091A]            dec=233.0 V      ok

“raw 和 decoded 同时打”这一条,能省掉现场 80% 的往返电话。 运维发一段日志过来,你在办公室就能判断问题在传输层、解码层还是语义层——因为 raw 是事实,decoded 是你的解读,两者并排,一眼分工。

C 侧定点解码范式

如果上位机是 MCU 或者嵌入式网关,解码要用定点整数,别用浮点累加:

/* 电压:raw(uint16) × 0.1V → 毫伏 */
int32_t mv   = (int32_t)raw_u16 * 100;          /* 0.1V = 100mV,先乘 */

/* 电流:raw(int16) × 0.01A → 毫安。先符号扩展,再乘 */
int32_t ma   = (int32_t)(int16_t)raw_u16 * 10;

/* 功率:raw(int16) × 1W → 瓦 */
int32_t watt = (int32_t)(int16_t)raw_u16;

/* 累计电度:raw32 × 0.1kWh → 0.01kWh 整数,用 int64 中间量 */
int64_t ekwh_x100 = (int64_t)raw_u32 * 10;

/* 位域拆解 */
#define ST_AC_OVP_BIT   0
#define ST_OVL_BIT      1
#define ST_BAT_LOW_BIT  3
uint16_t status = raw_u16;
int ac_ovp  = (status >> ST_AC_OVP_BIT)  & 1u;
int overload= (status >> ST_OVL_BIT)     & 1u;

四条纪律:

  1. 先乘后除,中间量放大到 int64,最后再除——避免定点截断累积误差。
  2. 符号扩展必须显式写:(int16_t)raw。C 里 uint16_t 直接参与运算不会变负。
  3. 累计量(电度)用 int64 累加,永远不用 float。float 在 24 位有效数字后就不再增大了,累计电度上会”停住不涨”。
  4. 移位前先提升位宽:(uint32_t)hi << 16 | lo,别在 16 位机上直接 hi << 16。

十、现场五招

五个动作,按”成本从低到高”排,前四招不用仪器、不用改设备。

第一招:先打印 raw,永远。

不要看解码后的值,不要看界面。把寄存器原始数组打出来。“raw 对不对”和”raw 解出来对不对”是两个独立问题,先解决第一个。 我这次能二十分钟定位,靠的就是从第一分钟起日志里就有 raw。

第二招:扫相邻 ±4 个寄存器。

地址错位、字序错位、撕裂——这三类问题都会在相邻地址露出原型。把目标地址前后各 4 个寄存器全读出来摆成一行,你会看到”你以为的那个值”就在隔壁。这一招 30 秒就能做,命中率极高。

第三招:写已知值反查字序。

找可写寄存器,写 0x12345678(int32)或者 1.0(float32),读回来对字节。四种排列四个样子,一次搞定,不用再猜。

第四招:用两个”物理上必然成立”的关系做交叉验证。

  • 空载时,交流输出侧和负载侧电压应该几乎相等 → 验证这两个点有没有读串。
  • 充放对称的两个同幅值电流,应该只差符号 → 验证符号位。

这招不需要任何标准源,只需要你愿意让设备空载/对称跑一次。

第五招:抓原始字节,和厂家工具同时抓。

用逻辑分析仪或者串口分析仪挂在总线上,同时跑厂家上位机和你的程序,抓两份对比。两份一摆,问题落在哪一层立刻就分开了——如果两份的原始字节一模一样,问题 100% 在你的解码;如果不一样,问题在你发出去的请求本身。这是终极手段,但有了它就没有”猜”的环节了。 抓包的具体做法我在 逻辑分析仪使用手册 里写过。


十一、对接自查清单

新接一台 Modbus 设备时,我会走完这 14 项。打勾之前不写业务逻辑。

  • ☐ 1. 拿到完整点表文档,确认版本号(有 changelog 更好)
  • ☐ 2. 确认串口参数(波特率 / 数据位 / 校验 / 停止位)与从站地址,与文档一致
  • ☐ 3. 先用厂家上位机跑通一遍,确认设备侧无故障
  • ☐ 4. 读设备识别码(0x2B),记录下来锁版本;不支持就记下”不支持”
  • ☐ 5. 点表里每个地址都换算成 PDU 0 基再录入,并在备注保留原写法
  • ☐ 6. 用空载 + 带载两个工况验证至少两个电压/电流点(不能单点收工)
  • ☐ 7. 用正反双向同幅值验证所有双向量(电流、功率)的符号约定
  • ☐ 8. 对每个 32 位量,用写已知值确认字序并在点表里写死
  • ☐ 9. 对每个点,记录 scale / offset / unit,逐个确认,不用全局倍率
  • ☐ 10. 对每个点,写死 物理量程闸门(phys_min / phys_max)
  • ☐ 11. 列出所有哨兵值,并在解码器里与”真 0″分开处理
  • ☐ 12. 把所有位域状态字的每个 bit 都抄出名字,分”故障/告警”两类
  • ☐ 13. 把点表按连续段分组做批量读,验证每个段都不回 0x02
  • ☐ 14. 把厂家文档里的示例报文做成单元测试向量,跑通

第 6 项和第 7 项是清单里最容易被跳过的两项,也是最能省时间的两项——它们把”单点拟合”升级成”两工况定直线”,是唯一能在交付前把倍率错和地址错位分开的手段。


十二、三个可以带走的结论

第一,症状的形状就是分诊线索,别靠猜。

回头看我那六个症状:

症状 形状 层
电池电压 512 差 10 倍 L3 倍率
电池电流 653.01A 跳到 65000 L2 符号
光伏功率 2.00424 看着正常但是假的 L1 字序
电网电压差 2V 小的固定差、随工况变 L0 地址错位
发电量两小时后归零 回绕 L2 位宽/量程
状态字显示 27 光秃秃的数字 L2 位域

六种形状,六种原因,一一对应,没有一个例外。 所以下次遇到”值不对”,第一件事不是打开点表重算,而是先给症状定性——它的形状指向哪一层,就从哪一层开始查。这一步能把排查范围一次性砍掉 80%。

第二,排查顺序应该按”改一个变量的成本”排,不按直觉排。

我最初按直觉走:先怀疑地址(因为差得最像)、再怀疑倍率、再怀疑字序,来回试了半小时。正确的顺序是:

打印 raw(零成本) → 扫相邻寄存器(30 秒) → 验地址口径(1 个请求)
→ 试四种字序(纯本地计算) → 定类型符号(对称测试) → 最后才是倍率与版本

倍率放在最后,因为它是”看起来最像但实际最不需要动脑子”的一层——而且它错了通常会有明显的数量级特征,一眼能认出来,不值得优先排查。

第三,点表必须是”数据”,不是”代码里的 if”。

我这套代码最后的重构,就是把点表从 Python 字典搬进 JSON、把魔数全部删掉、把 (addr, func, type, byte_order, scale, offset) 六件套做成每个点的必填字段。重构之后,再遇到一台新设备,我只需要写一份 JSON——一行代码都不用改。 而且这份 JSON 可以直接交给现场工程师,让他对照文档核对,不需要他懂 Python。


十三、下一篇

这一篇讲的是读。但逆变器的 Modbus 通道上还有一半是写——输出频率设定、充放电电流限值、并离网切换、故障复位、参数掉电保持。写控制比读多了三类坑:写保护与权限位、参数落 NVM 的时序(写了什么时候生效、掉电会不会丢)、以及”写下去但设备没接受”的静默失败。

另外还有一条线一直想写:储能 PCS 的绝缘监测与漏电流检测——那个选题我在 母线电压采样链路 里已经预告过一次,是把那里的”板面漏电”从”测量误差”升级成一个保护功能——从”它让我读错 2%”变成”它要能救命”。

两篇都会写。哪篇先出,看下一期手上哪台设备更配合。