储能 BMS 与 PCS 的 Modbus 对接实战:同一块电池在 CAN 上读数全对,换到 485 就变成 80.0V / 8.0% / 电流反向——我把 T/CIAPS 0009 的那 16 个寄存器逐条对了一遍

2026年10月18日 0 By admin

上一期讲 BMS 与 PCS 的 CAN 对接时说过一句:BMS 走 CAN,本篇不涉及。今天把这句话补上——同一块电池,同一个 BMS,把通信线从 CAN 换成 RS485,走 Modbus,再来一遍。

这次的事故单看数字很吓人:80kWh 的 800V 储能柜,CAN 链路上一切正常(800.0V / 80.0% / +30.0A 放电),切到 485 之后,PCS 的人机界面显示 80.0V、8.0%、−3.0A。三个数同时”小了一个数量级”,电流方向还反了。PCS 立刻判”电池欠压 + 反充”,闭锁开机。

现场第一反应都是”BMS 的 Modbus 程序写错了”。但我抓了一帧原始报文,BMS 发出来的字节一个都没错:0x1F40、0x0320、0x012C,按标准解就是 800.0V、80.0%、+30.0A。错在中间——网关替我们”翻译”了一遍协议,上位机又按标准翻译了第二遍。

这篇就把这套接口从头到尾拆开:标准到底规定了哪 16 个寄存器、每个坑在哪一层、为什么”倍率只能转一次”、以及一条能当场判定”到底是 BMS 错还是中间层错”的对拍法。

文中所有数字都出自配套脚本 ciaps_modbus_calc.py(纯标准库、全离线,34 项断言自检全过)。其中两条断言用的是标准正文 10.3 节的示例报文本身的 CRC(70 37 与 FC 6F),不是我自己的常量——这点很重要,理由在最后一节说。


一、先把现象翻译成指纹:三个数同比例错,说明错在同一层

事故现场记录:

量 抓到的 raw(十六进制) 按标准解读 PCS 界面显示 关系
电池组总电压 0x1F40 = 8000 800.0 V 80.0 V 小 10 倍
SOC 0x0320 = 800 80.0 % 8.0 % 小 10 倍
电池组总电流 0x012C = 300 +30.0 A(放电) −3.0 A(充电) 小 10 倍 + 符号反

三个不同的物理量、三个不同的寄存器,错的比例完全一致。这不是”某个寄存器地址填错了”(那会一个对一个错),也不是”某一帧解析错了”(那会是偶发或全为零)。同比例偏移只可能来自两件事:

  1. 倍率被应用了两次(网关把 raw 乘了 0.1 变成浮点,上位机又当它是 raw 再乘 0.1);
  2. 符号被翻转了奇数次(网关为了对齐另一套”充电为正”的口径翻了一次,而界面标签仍按标准写”放电为正”)。

单看一个量,你永远分不清是 BMS 把 800 发成了 80,还是中间层多除了一次 10。三个量一起看,指纹立刻清楚。 这也是我后面那套”对拍法”的由来:找一个不经过中间层的旁路(CAN 或直接串口抓包),对比同一时刻的 raw,谁错一目了然。

先把标准摆出来,再谈层。


二、这个接口到底长什么样:16 个寄存器,两个功能码,0.2 秒一轮

T/CIAPS 0009-2021《储能变流器与电池管理系统通信协议 第 2 部分:Modbus 通信协议》,2021 年 1 月发布、2 月实施,牵头单位科华恒盛与国网浙江综合能源。它是第 1 部分(CAN,T/CIAPS 0006-2020)的姊妹篇,两个部分解决的是同一个接口的两种物理层。

物理层:RS485 两线,Modbus RTU;波特率 9600 / 19200 / 38400,标准优选 9600;起始位 1、数据位 8、无校验、停止位 1(即 8N1)。帧结构:从机地址 1 字节 + 功能码 1 字节 + 数据 0~252 字节 + CRC(低字节先发)。

功能码只有两个:

功能码 含义 用途
0x04 读输入寄存器 PCS 读 BMS 全部 16 条数据
0x06 写单个保持寄存器 PCS 下发请求充/放电

没有 0x03,没有 0x10,没有 0x0F。这个”克制”是整套协议最值得学的地方,后面会讲到它带来的好处和它埋的坑。

读寄存器全表(标准表 3,偏移地址 0x0100~0x010F,连续 16 条):

地址 数据定义 长度 倍率 单位 符号 备注
0x0100 电池组总电压 2 byte 0.1 V 无
0x0101 电池组总电流 2 byte 0.1 A 有 + 放电,− 充电,补码
0x0102 SOC 2 byte 0.1 % 无
0x0103 SOH 2 byte 0.1 % 无
0x0104 充电限制电流 2 byte 0.1 A 无 最大充电电流
0x0105 放电限制电流 2 byte 0.1 A 无 最大放电电流
0x0106 充电限制电压 2 byte 0.1 V 无 最大充电电压
0x0107 放电限制电压 2 byte 0.1 V 无 最小放电电压
0x0108 充电可用电量 2 byte 0.1 kWh 无
0x0109 放电可用电量 2 byte 0.1 kWh 无
0x010A BMS 状态字 2 byte — — 无 位定义见表 4
0x010B SOP 2 byte 0.1 kW 无 短时峰值功率
0x010C 单体最高电压 2 byte 0.001 V 无 毫伏级
0x010D 单体最低电压 2 byte 0.001 V 无 毫伏级
0x010E 单体最高温度 2 byte 0.1 ℃ 有 补码
0x010F 单体最低温度 2 byte 0.1 ℃ 有 补码
0x0110~0x0135 预留 — — — — 38 条

表尾有一行注,价值极高:PCS 读取 BMS 信息的周期为 0.2s。这是全套协议里唯一一个”时间”约束,后面第五节整节都在算它。

状态字(表 4):

位 长度 定义 说明
bit0~bit3 4 bit 预留
bit4~bit6 3 bit BMS 系统状态 00 初始 / 01 正常 / 02 禁充 / 03 禁放 / 04 告警 / 05 待机 / 06 故障 / 07 保留
bit7~bit11 5 bit 无定义 保留
bit12~bit15 4 bit BMS 心跳 0~15 循环

写寄存器(表 5):只有 0x0200 一条有效,0x0000 无请求、0x5555 请求充电、0xAAAA 请求放电;0x0201~0x0203 预留。

就这些。 一张 A4 纸能印完,没有版本号字段,没有厂商自定义区,没有 32 位量,没有浮点。这种”小到不能再小”的约定,恰恰是它能被几十家厂商在两年内落地原因。


三、M0 地址:0x0100 是”线上地址”,不是 4x 参考号,也不是十进制 100

这是第一个、也是最容易在配置工具里翻车的地方。

标准表 3 的列名叫“偏移地址”。很多工程师看到”偏移”两个字,立刻想到 Modbus 那套 4xxxx 参考号,于是把 0x0100 换算成 40257,或者在组态软件里填 40101,或者在十进制输入框里敲 100。

先看标准自己怎么发:10.3 节示例,从机 0x01,查询从 0x0100 开始的两个寄存器:

01 04 01 00 00 02 70 37
│  │  └──┬──┘ └──┬──┘ └──┬──┘
│  │     │       │       └── CRC(低字节在前)
│  │     │       └────────── 寄存器数量 = 2
│  │     └────────────────── 起始地址 = 0x0100
│  └──────────────────────── 功能码 0x04(读输入寄存器)
└─────────────────────────── 从机地址 0x01

线上发的就是 0x0100 本身。 不做减一,不做 4xxxx 映射,不做 0 基/1 基换算。标准里”偏移”的意思只是”相对于协议数据区起点的编号”,而协议数据区的起点就是 0。

所以四种填法里只有一种对:

你填的起始地址 线上实际发的 结果
0x0100(十进制 256) 01 00 ✅ 正确
十进制 100 00 64 ❌ 落在未定义区,回异常码 02
4×0100 当 256 再加 40001 得 40257 后减 40001 01 00 ⚠️ 结果对,但依赖网关的映射规则,换台网关就错
40101(把 0x0100 当”第 100 个寄存器”) 00 64 ❌ 同上

经验法则:看到”0x01xx / 0x02xx”这种地址,先把配置工具切成十六进制看它显示什么。 如果它显示 256 和 512,说明它按 PDU 地址处理,直接填 0x0100、0x0200;如果它显示 40257,说明它加了 4xxxx 前缀,你填 40257 它内部减 40001 得 256,也对;最怕的是它显示 101,那就是按”参考号后四位”算的,必然错。

还有一个更隐蔽的:同一台设备上,读和写用的是两套地址空间。 标准把读放在 0x01xx(输入寄存器,0x04),写放在 0x02xx(保持寄存器,0x06)。在 Modbus 数据模型里,输入寄存器和保持寄存器是两个互相独立的地址平面——0x0100 在输入寄存器区、0x0200 在保持寄存器区,两个地址互不相干。有些从机偷懒做了平坦映射,你拿 0x03 去读 0x0100 也能读出来,别依赖这个:换一台严格实现的从机就回异常码 02。

还有 38 条预留(0x0110~0x0135)。预留不是”肯定没用”,是”本标准没定义”。有的厂商把单体电压明细、均衡状态、累计充放电量塞在这段里。做上位机时这段要留可配置区,别硬编码成”读到就报错”。

顺带一句:参考号 4xxxx 与 PDU 0 基地址的换算规则,本标准完全没用上。它不写 40001、也不写 4×0100,只写十六进制的线上地址。这套换算的通用版本(五种文档写法、哪一种是真地址)我在逆变器 Modbus 点表那篇里拆过。正因为本标准绕开了参考号,它比厂商私有协议少了一整类错误——但也意味着网关厂商更容易”热心”地替你加上参考号。


四、M1 倍率与符号:六种量纲共用一个 0.1,一个 0.001,三处补码

第二个坑,也是本次事故的直接原因。

4.1 一张表看清”0.1″到底乘给谁

16 条寄存器里,13 条是 0.1 倍率(V、A、%、kWh、kW、℃ 全用同一个数),2 条是 0.001(单体电压),剩下 1 条是状态字(位域,无倍率)。这种”一个数字打天下”的设计很省事,但它把”量纲”这件事从协议里抹掉了——你只能靠地址知道自己在读什么。地址填错一位,0.1 就乘到了错误的对象上,而且不会报任何错。

16 位无符号 × 0.1 的量程上限:

量 倍率 最大可表示 实际需要 余量
总电压 0.1 V 6553.5 V 1500 V 系统 充足
总电流(有符号) 0.1 A ±3276.7 A 集装箱 ±2000 A 勉强够
SOC / SOH 0.1 % 6553.5 % 100.0 % 用了 1.5% 的码值
充/放电限流 0.1 A 6553.5 A 同电流 充足
充/放电限压 0.1 V 6553.5 V 同电压 充足
充/放电可用电量 0.1 kWh 6553.5 kWh 20 尺集装箱 5 MWh 偏紧
SOP 0.1 kW 6553.5 kW 6.5 MW 勉强
单体电压 0.001 V 65.535 V 3.2~3.65 V 充足
单体温度(有符号) 0.1 ℃ ±3276.7 ℃ −40~60 ℃ 充足

两处值得单独说:

① 可用电量的 16 位天花板是 6.55 MWh。 标准 2021 年定稿时面向的多是百 kWh 到 MWh 级的中小型储能,0.1kWh 够用。但今天一个 20 尺液冷集装箱动辄 5 MWh,10 MWh 的直流舱已经不稀奇——10 MWh = 10000 kWh,raw 要 100000,直接溢出 16 位。碰到这种规模,厂商要么在预留区另开一组 kWh 整数寄存器,要么约定”本字段单位为 0.1MWh”。对接前必须问清:0x0108/0x0109 在你这台机器上还是 0.1kWh 吗? 这条不问,SOC 和可用电量会永远对不上。

② 单体电压的 0.001V 只能放单体,不能放簇。 0x010C 上限 65.535V。如果实施的人图省事,把”簇电压”接到这个字段(有的 BMS 只有簇级采样),800V 的簇 → raw 800000 → 溢出回绕成 3392 → 显示 3.392V。看起来像个正常的单体电压,这是最坏的一种错:数值合法、量纲合理、结论全错。

4.2 三处补码,两处极易漏

有符号的只有三条:总电流(0x0101)、单体最高温(0x010E)、单体最低温(0x010F)。规则是标准补码。

raw 按无符号解 按补码解 差
0xFFFF 6553.5 A −0.1 A 6553.6
0x8000 3276.8 A −3276.8 A 6553.6
0xFFF6(温度) 6553.4 ℃ −1.0 ℃ 6553.4
0xFF38(温度) 6533.6 ℃ −20.0 ℃ 6553.6

温度这条尤其阴:冬天 −20℃ 时,按无符号解会显示 6533.6℃,一眼能看出错;但如果只错了一点点——比如 −1.0℃,解成 6553.4℃——很多人会当成”传感器坏了”,去换探头,而不是查解析。凡是出现”温度高到离谱”或”温度恒为 6553.x”,先查补码,别查硬件。

还有一个容易套错的地方:本标准没有零点偏移。 表 3 里”偏移量”这一列是空的,所有量都是”纯倍率”,没有 offset。有些协议规定”温度 = raw − 40″,有些规定”电流 = (raw − 32768) × 0.1″,本标准都没有。−20℃ 就是 raw = −200(0xFF38),不是 −200 + 400 = 200。别把别的协议的习惯带过来。

4.3 符号:+ 放电、− 充电,这一条最容易”被好心改掉”

标准写得很清楚:总电流”有符号数,负数用补码表示。(+:放电;−:充电)”。

为什么这么定?因为站在 PCS 的角度,放电是”电池给出来的能量”,记正;充电是”吃进去的能量”,记负,这样 P 正负就和能量流向一致,功率计算不用再翻符号。

但行业里另一套很流行的口径(Pylontech 那套)也是”充电为负”,而不少逆变器厂自己的内部约定是”充电为正”。于是现场经常出现这种”好心”:

网关厂商为了让上位机”看得顺眼”,在协议转换时把电流乘了 −1。

结果就是我们这次看到的 −3.0A。这个改动不会报错、不会丢帧、不会触发任何自检,它只是让所有涉及功率方向的逻辑静默地反了。 后果比数值错更严重:SOC 上升时显示在放电、PCS 该充的时候在放、告警文案全反。

我给一条硬规矩:协议转换层不许改符号,不许改倍率,只做搬运。 要改就在最末端的人机界面改显示,且必须写进接口文档、在界面上标注约定。中间层每”帮一次忙”,就多一个没人知道的隐式约定。

4.4 量程校验:不做这一步,等于把 98.5% 的非法码值当数据用

SOC 的有效码值是 0~1000(0.0~100.0%),剩下 64535 个码值全是非法。如果从机在故障时把寄存器刷成 0xFFFF,你按 0.1 解就得到 6553.5%,然后这个数会流进告警判断、流进 EMS 报表、流进调度曲线。

每个量都要有合法区间,超了就标”无效”,而不是”取个最大值”。 我常用的一套:

量 合法区间 越界处理
总电压 0 < U < 1500 V 标 INVALID,保持上一帧
SOC / SOH 0 ≤ x ≤ 100.0 % 标 INVALID
总电流 −3000 ~ +3000 A 标 INVALID
单体电压 0.5 ~ 5.0 V 标 INVALID
单体温度 −40 ~ 85 ℃ 标 INVALID
充/放电限流 0 ~ 3000 A 标 INVALID
可用电量 0 ~ 额定能量 × 1.2 标 INVALID
状态字 状态值 ≤ 7(3 位天然满足) —

注意”标 INVALID 并保持上一帧”和”标 INVALID 并清零”是两回事:前者适合显示,后者适合安全逻辑。安全逻辑里,无效必须当作”最不利值”处理(电压无效→按欠压、限流无效→按 0),不能当作 0 或者上一帧。


五、M2 状态字:三比特枚举不是位图,心跳不是时间戳

状态字是这 16 条里唯一一条”看起来最简单、实际最容易判错”的。

5.1 bit4~6 是一个 3 位整数,不是三个开关

02 禁充、03 禁放、06 故障——这是枚举值,不是”第 2 位、第 3 位、第 6 位被置 1″。

正确的取法只有一种:

uint8_t st = (word >> 4) & 0x07;

错误但极常见的写法:

if (word & 0x0004) { /* 以为这是禁充 */ }   /* 错:0x0004 是 bit2,在预留区 */
if (word & 0x0008) { /* 以为这是禁放 */ }   /* 错:0x0008 是 bit3 */

按位与去判,你永远判不出”禁放”,因为在枚举编码里禁放 = 3 = 0b011,落在 bit4 和 bit5 上(整字 0x0030),bit4 和 bit5 同时为 1——word & 0x0030 在”禁放”和”告警(4)”上都是真的,根本区分不开。枚举必须整体比较。

这有个副产品,值得记一下:

0x0020 = 禁充,在 CAN 和 Modbus 两条链路上是同一个数。

第 1 部分(CAN)的 0x18E3 报文里,附录 A 只给了一个锚点:状态字 0x0020 = 禁充。当时我只知道这个数值。今天补上第 2 部分才看明白:把 3 位状态放在 bit4~6、禁充编码为 2,2 << 4 = 0x0020,两部分的编码是同构的。所以:

状态 3 位值 状态字 在 CAN 0x18E3 / Modbus 0x010A 上
初始 0 0x0000(+心跳) 上电前几帧
正常 1 0x0010(+心跳) 运行常态
禁充 2 0x0020 满电、低温、过压
禁放 3 0x0030 欠压、过流后
告警 4 0x0040 可继续但需处理
待机 5 0x0050 空闲
故障 6 0x0060 需停机

这是有依据的推断,不是标准第 1 部分原文(它只给了 0x0020 一个点)。但它至少解释了一个困扰:为什么禁充偏偏是 0x0020 这种”看起来像位图”的数——因为它本来就是”位置 × 编码”的结果。顺带提醒:正因为长得像位图,才更容易被当位图用。

5.2 心跳 0~15:一个 3.2 秒一圈的模 16 计数器

bit12~15 是心跳,取值 0~15 循环,每帧 +1。读周期 0.2s,所以一圈 3.2 秒。

它的用途不是”对时间”,而是回答一个链路层答不了的问题:

现象 结论 该查什么
收不到帧(超时) 链路断了 接线、A/B 反、波特率、从机地址、终端电阻
收到帧,心跳一直在变 链路与 BMS 应用都活着 数据可信度另说
收到帧,心跳卡住不动 BMS 应用层冻结,通信还活着 BMS 的采样任务/主循环、看门狗

第二种是最坑的:帧在来、CRC 对、字节数对,唯一不动的是数据。 如果只看”有没有超时”,这种故障永远不会被发现,你会拿着三天前的一组电压继续运行。

判据必须用模 16 差值,不能直接比较大小:

uint8_t d = (uint8_t)((new_hb - old_hb) & 0x0F);
/* d = 0 : 心跳没动       → 疑似应用冻结,连续 N 帧为 0 则报 BMS_APP_HANG */
/* d = 1 : 正常           → 最理想 */
/* d = 2 : 丢了一帧       → 可容忍,不报警 */
/* d >= 3: 大面积丢帧     → 报通信质量下降 */

15 → 0 的 d = (0 − 15) & 0x0F = 1,是正常的。 如果写成 if (new <= old) 报警,你会每 3.2 秒误报一次——一天 27000 次,日志直接废掉。这跟双区参数存储里序号回绕必须用模 2 的 n 次方有符号差是同一个道理:所有环形计数器,比较方式都必须是”模差”,不能是大小。

顺便说一句:4 bit 心跳只有 16 个状态,它不能用来做时钟同步,也不能区分”BMS 重启了一次”和”BMS 正常跑了 3.2 秒”。要判断重启,得靠别的量(比如状态字从非零跳回 0、累计电量归零、或厂商自定义的启动计数)。

5.3 状态与限值的分工:禁充/禁放是”结论”,限值是”参数”

现场经常见到这种实现:读 0x0104 充电限流 = 0,就认为”当前禁充”。这在多数情况下成立,但两者语义不同:

  • 限值(0x0104~0x0107)是参数:告诉 PCS “最多能用多大电流/电压”,是连续量,可以很小但不为零;
  • 禁充/禁放(状态字)是结论:BMS 根据当前电芯状态(满充、低温、单体过压、压差过大)判定此刻不允许,是一个明确的门。

两者的关系是”与”:PCS 实际可用的充电电流 = min(0x0104, PCS 自身能力),且必须再与”状态 != 禁充”相与。只读限值不读状态,会在”限流 10A 但单体已过压”时继续充;只读状态不读限值,会在”状态正常但限流 0″时按满功率冲。

限值不能替代状态,状态也不能替代限值。 这条和绝缘监测那篇里”连续剩余电流与突变判据服务两个不同对象”是同一种思维:两个字段长得像,服务对象完全不同。


六、M3 时间账:0.2 秒一轮,在 9600 上吃掉四分之一条总线

标准只给了一个时间约束:读周期 0.2s。把这个数代进去算总线占用,会得到一个不太舒服的结论。

9600、8N1,一个字符 10 bit = 1.042 ms。一次完整问答:

段 字节数 时间
请求(0x04,读 16 条) 8 8.33 ms
响应(37 字节 = 1+1+1+32+2) 37 38.54 ms
T3.5 帧间静默 3.5 字符 3.65 ms
合计 45 50.52 ms

50.52 ms / 200 ms = 25.3%。这还是一条总线上只有一个从机、且没有任何重传的情况。

多簇系统立刻撞墙:

簇数(同一 485 总线) 总线占用 @9600 结论
1 25.3% 舒适
2 50.5% 可用,但重传余量小
3 75.8% 临界,任何一次重传都会挤掉下一轮
4 101.0% 物理上不可能

4 个簇挂在一条 9600 的 485 上,按标准要求的 0.2s 周期,带宽根本不够。 这不是”性能差一点”,是 101%——排队永远清不空,超时会持续发生,而你会以为是”干扰”。

三条出路,按代价从低到高:

  1. 提到 19200(标准允许):50.52/2 ≈ 25.3 ms,占用 12.6%,4 簇也只有 50.5%。最省事,改一个波特率参数。
  2. 只读必要的寄存器:如果 PCS 只需要电压/电流/SOC/状态,读 11 条(0x0100~0x010A),响应 27 字节,单次 40.1 ms,占用 20.1%。4 簇仍有 80%,勉强。
  3. 分簇独立 485 口:硬件成本,但最彻底。

顺便:如果每簇独立总线、但主机是轮询式(一个 UART 分时切多路 485),那 4 簇的总周期是 4 × 50.5 = 202 ms,刚好超过 0.2s。这时”每簇 0.2s”这个承诺就已经不成立了,得按 0.4s 设计超时。

超时判据:标准没给数,工程上给 3 个周期

标准只写”未在规定时间内收到正确报文即判超时”,没规定”规定时间”是多少。工程上取 3 个周期 = 0.6s:

  • 1 个周期(0.2s)太紧,一次重传就误报;
  • 3 个周期覆盖两次重传;
  • 超过 3 个周期还没帧,基本可以确认不是偶发干扰。

但“超时”只覆盖了链路,没覆盖应用。完整的判据是两条并行:

判据 阈值 报什么 动作
连续无有效帧 3 个周期(0.6s) COM_TIMEOUT 按通信中断处理(见第九节)
心跳模差连续为 0 5 帧(1.0s) BMS_APP_HANG 数据置无效,禁止继续按旧值充放
心跳模差 ≥ 3 连续 10 帧 COM_QUALITY_LOW 告警,统计丢帧率

第二条阈值比第一条宽,是因为”心跳不动”需要一个观察窗口才能区分”偶尔卡一下”和”真的冻结”。


七、M4 写侧:三个魔法值,以及”没有 0x10″意味着什么

写侧只有一条有效寄存器:

地址 定义 值
0x0200 请求充/放电控制 0x0000 无请求 / 0x5555 请求充电 / 0xAAAA 请求放电

0x5555 和 0xAAAA 是固定魔法值,不是 1/0,不是百分比,不是布尔。写成 1 或 0 会被从机回异常码 03(非法数据值)。这两个值的用意是”抗误写”:0x5555/0xAAAA 是位交替的图案,单比特翻转或半字写入几乎不可能碰巧变成另一个合法值——这是用编码换安全性。

帧长固定 8 字节,回显与请求完全一致:

请求充电:01 06 02 00 55 55 77 1D
请求放电:01 06 02 00 AA AA 76 AD
无请求 :01 06 02 00 00 00 ...

(CRC 已实测:55 55 → 77 1D,AA AA → 76 AD。)

“只有 0x06,没有 0x10″这件事,值得单独讲。

标准没有定义多寄存器写。这在读侧毫无影响(全是 16 位量),但在写侧意味着:0x0200 只能一个寄存器一个寄存器地写,天然原子。

对比一下反例:如果哪天要在预留区加”充放电功率指令”(32 位,比如 0.1kW,支持 ±),写成两个 16 位寄存器,就必须用 0x10 一次写两个,否则会出现”高字写了、低字还没写”的撕裂帧——从机会拿一个高字 0x0001、低字 0x0000 的中间态去执行,功率指令瞬间跳到 6553.6kW。这个坑在逆变器写控制那篇里拆过。

所以:这个标准的”只有 16 位量 + 只有 0x06″,不是能力不足,是把”撕裂”这类问题从设计上删掉了。 代价是它表达不了 32 位量(见 4.1 的可用电量天花板)。

写侧还有三条工程规矩:

  1. 必须校验回显。0x06 的正常响应就是原样回显,逐字节比对(含 CRC),不一致就重发一次,两次不一致报错。别只看”有没有异常响应”。
  2. 请求是电平语义,不是脉冲。0x5555 一直保持表示”持续请求充电”;要撤销就写 0x0000。如果 BMS 侧实现成”收到一次就充电 10 秒”,那你每 10 秒就得重发一次——先问清是电平还是脉冲,这是最容易在联调最后一天才发现的分歧。
  3. 写权要唯一。0x0200 是”请求”,最终是否执行由 PCS 结合自身状态判定。如果 EMS 也在写、本地屏也在写,就是多主互踩。做法:PCS 内部维护一个”当前写主”,非写主的写请求回异常码 04(服务故障)或直接忽略并记录。

八、M5 双协议并存:同一块电池两条链路,谁说了算

第 1 部分(CAN)和第 2 部分(Modbus)描述的是同一个接口。现实中大量设备两个口都引出来了:PCS 走 CAN,EMS 走 485;或者 PCS 主用 CAN、485 作为备份/调试口。

这就带来一个必须提前定死的问题:两边数值不一致时,以谁为准?

8.1 对拍法:一条能当场定性的排查手段

这是本文最实用的一招,也是这次事故能十分钟定性的原因。

CAN 第 1 部分的 0x18E1 与 Modbus 的 0x0100~0x0103 是同一组量,而且倍率完全一样,都是 0.1:

量 CAN 位置 Modbus 地址 倍率 示例 raw 工程值
总电压 0x18E1 byte0-1 0x0100 0.1 V 0x1F40 800.0 V
总电流 0x18E1 byte2-3 0x0101 0.1 A 0x012C 30.0 A(放电)
SOC 0x18E1 byte4-5 0x0102 0.1 % 0x0320 80.0 %
SOH 0x18E1 byte6-7 0x0103 0.1 % 0x03B6 95.0 %
充电限流 0x18E2 byte0-1 0x0104 0.1 A 0x0064 10.0 A
放电限流 0x18E2 byte2-3 0x0105 0.1 A 0x01F4 50.0 A
充电限压 0x18E2 byte4-5 0x0106 0.1 V 0x1F40 800.0 V
放电限压 0x18E2 byte6-7 0x0107 0.1 V 0x1B58 700.0 V

同一时刻、两条链路,raw 应当逐字相等。 因为倍率相同、字长相同、符号约定相同,唯一的差别是”怎么搬过去”(帧 ID 与字节偏移 vs 寄存器地址)。

于是判据变得非常干脆:

现象 结论 往哪查
两边 raw 相等,界面显示不同 中间层(网关/SCADA/组态) 倍率转了几次、符号翻没翻
两边 raw 不等,同比例 某一侧的配置被改过 网关的倍率表、BMS 的参数页
两边 raw 不等,只有个别量 BMS 某一侧的映射错 从机的寄存器/报文映射表
两边都对,但 PCS 动作反了 PCS 侧的解释 PCS 的符号约定、限值来源

这次的事故落在第一行:CAN 上 raw = 8000,485 上抓到的 raw 也是 0x1F40 = 8000,两边一致,界面 80.0V。 一句话排除 BMS,直接把矛头指向网关。

如果你手上没有 CAN 分析仪,退一步的办法是跳过网关直连 BMS 的 485,用 USB-485 抓一帧原始报文。判据一样:raw 对、界面错 → 中间层。

8.2 谁为准:分工写进接口文档

我的建议是”按用途分,不按优先级分“:

用途 以谁为准 理由
实时控制(限流、限压、禁充禁放) CAN(若两路都在) 周期 200ms,比 485 的 0.2s 轮询更稳,且不容易被网关篡改
监视与报表(SOC、SOH、电量) 任一路,但只能选一路 混用两路会出现”同一时刻两个 SOC”
与站级 EMS 对接 Modbus 站内 485 拓扑成熟,点数少
故障录波与追溯 两路都存 raw 出问题时能对拍

三条硬规则:

  1. 限值取交集:两路都报限值时,可用限流 = min(两路),充电限压 = min(两路),放电下限 = max(两路)。宁可保守,不能乐观。
  2. 状态取并集(更严):任一路报禁充/禁放/故障,就按最严处理。
  3. SOC 单一源:只允许一路进 HMI 和 EMS。另一路只进日志。否则操作员会看到两个数在跳,然后开始怀疑人生。

九、M6 安全策略:三级保护,第二级是干接点

标准第 8 节定义了三级防护,这段几乎每个字都值得抄进设计文档:

级别 手段 通道 生效条件
第一级 限值指令(充放电电压电流、禁充/禁放) 通信 通信正常
第二级 故障位 / 干接点 通信正常走故障位;通信失效走干接点 电池故障
第三级 关断电池主电路 BMS 内部 前两级都失效

第二级的设计是整套协议里最”工程”的一笔:干接点默认常闭,故障时断开。

  • 常闭 = 正常时回路导通,PCS 读到”闭合”表示”可以干”;
  • 故障时断开 → PCS 失去信号 → 停机;
  • 关键在失效方向:如果接插件掉了、线断了、BMS 断电了,PCS 看到的都是”断开”——与故障同向。这叫 fail-safe。

反过来,如果设计成”常开、故障时闭合”,那么线一断,PCS 看到的是”无故障”,会继续满功率充——这是把安全信号做成了”沉默即安全”,是最危险的默认值。

这条也解释了一个常见现场现象:BMS 的 485 线被老鼠咬断,PCS 不但没停,反而继续按最后一帧的限值运行。这不是 PCS 的 bug,是根本没有实现第二级——只做了通信超时告警,没接干接点。

工程上要做的三件事:

  1. 干接点必须独立于通信:单独一对线,不经过任何网关、不与 485 共地回路、不进通信管理机的数据库。它是一根”命线”。
  2. PCS 必须把干接点当硬输入:进 DI,进安全链,能直接触发停机,不能只进软件。
  3. 通信失效的默认动作要写死:通信中断后,PCS 应在 T 秒内把功率降到 0(或降到安全值),而不是”保持上一帧”。这个 T 就是第六节那 0.6s 加上执行机构的动作时间。

顺便,标准里”通信失效时通过干接点指令 PCS 保护”这句话隐含了一个前提:干接点由 BMS 驱动。如果实际项目里干接点是从 PCS 自己发的(有些集成商这么干),那第二级就等于没做——PCS 用自己的信号证明自己没故障。验收时一定要拉一次 BMS 的干接点线,看 PCS 是否停机。


十、标准原文里三处”不能照抄”

读标准要读原文,但原文也有需要判断的地方。这三处我在对接时都踩过或见人踩过。

① 10.3 节的标题与示例报文不符。 原文写”设从机地址为 0x01,要查询 BMS 状态字”,但给出的请求是 01 04 01 00 00 02——从 0x0100 起读 2 个寄存器,返回的是 1F 40(800.0V)和 00 64(10.0A),是总电压和总电流,不是状态字。状态字在 0x010A,要读它得发 01 04 01 0A 00 01。

处理:以示例报文和 CRC 为准,不以文字描述为准。 报文是死的、可验证的;文字描述是活的、会写错。这也说明为什么我坚持用标准的两个示例帧 CRC 做自检锚点——70 37 和 FC 6F 是标准自己印出来的字节,我的 CRC 实现必须能重现它们,否则就是我的实现错了。

② “偏移地址”这个词容易被过度解读。 见第三节。它的意思就是”线上地址”,不做任何换算。

③ 表 1 写”数据 0 到 252 字节”,但这个上限在本标准里用不到。 因为只有 0x04 和 0x06,最大数据段是读 125 个寄存器的 251 字节(还得从机支持)。别因为看到 252 就以为”支持多寄存器写”。

另外补一条不算错、但要注意的:表 4 里 bit0~3 写”预留”、bit7~11 写”保留”,两个词混用,含义相同——都没定义,都别用。


十一、固件侧怎么写:一次读全表,快照存 raw,换算只在出口做

11.1 快照结构:存 raw,不存工程量

#include <stdint.h>

typedef struct {
    uint16_t u_bus_dv;        /* 0x0100  0.1 V      */
    int16_t  i_bus_da;        /* 0x0101  0.1 A  +放 -充 */
    uint16_t soc_dp;          /* 0x0102  0.1 %      */
    uint16_t soh_dp;          /* 0x0103  0.1 %      */
    uint16_t i_chg_lim_da;    /* 0x0104  0.1 A      */
    uint16_t i_dis_lim_da;    /* 0x0105  0.1 A      */
    uint16_t u_chg_lim_dv;    /* 0x0106  0.1 V      */
    uint16_t u_dis_lim_dv;    /* 0x0107  0.1 V      */
    uint16_t e_chg_avail_dwh; /* 0x0108  0.1 kWh    */
    uint16_t e_dis_avail_dwh; /* 0x0109  0.1 kWh    */
    uint16_t status;          /* 0x010A  bit4-6 状态, bit12-15 心跳 */
    uint16_t sop_dw;          /* 0x010B  0.1 kW     */
    uint16_t v_cell_max_mv;   /* 0x010C  0.001 V    */
    uint16_t v_cell_min_mv;   /* 0x010D  0.001 V    */
    int16_t  t_cell_max_dc;   /* 0x010E  0.1 ℃ 补码  */
    int16_t  t_cell_min_dc;   /* 0x010F  0.1 ℃ 补码  */
    uint32_t stamp_ms;        /* 采集时刻           */
    uint8_t  valid;           /* 位掩码,按量置位    */
} bms_snap_t;

三条理由:

  1. raw 是唯一没有歧义的东西。 存了工程量,你就永远无法回头判断”当时到底是 raw 错了还是换算错了”。存 raw,任何时候都能重算。
  2. 换算只在一个地方发生,就是出口(显示、控制、上报)。这样”倍率错”只可能错在一处,不会出现”控制用 0.1、显示用 0.01″。
  3. 有效位按量独立,不要一个 valid 管全部。电压有效但 SOC 无效是常态(单体采样故障),一刀切会让整帧作废。

11.2 一次读 16 条,别拆成三次

/* 0x0100 起读 16 条:一帧 37 字节响应,比三次读省 2 次往返和 2 次 T3.5 */
build_read(addr, 0x0100, 16);

好处不只是省时间(拆成三次读、按 6+5+5 条分:3 个请求 24 字节 + 3 个响应 47 字节 = 71 字节 = 74.0ms,再加 4 个 T3.5 共 88.5ms,比一次读的 50.5ms 多 75%),更重要的是同一帧内的数据来自从机的同一次采样(前提是从机实现规范)。拆成三次读,你会拿到”电压是 t0 时刻的、电流是 t0+25ms 时刻的”,在功率突变时会算出不自洽的 P。

11.3 自洽性校验:不用外部仪表就能逮住倍率错

这是我最推荐的一条现场技巧。BMS 报的四个量之间存在物理约束,违反约束就说明换算链有问题,不需要任何外部仪表:

约束 表达式 允许偏差 能发现什么
功率自洽 \ U × I − P\ / P < 10% 10% 电压或电流倍率错
电量自洽 \ E_dis_avail − E_nom × SOC × SOH\ / E_nom < 15% 15% kWh 倍率错、SOC 倍率错
限值自洽 I_chg_lim ≤ I_max, U_chg_lim ≤ U_max — 限值字段接错
单体自洽 V_cell_min ≤ V_cell_max ≤ V_cell_min × 1.5 — 单体电压字段接错
温度自洽 T_min ≤ T_max — 两个温度字段接反
状态自洽 SOC ≥ 99% 时不应为”正常且允许充” — 状态字解析错

第二条最能打。 80kWh、SOC 80%、SOH 95% → 可用电量应在 80 × 0.8 × 0.95 = 60.8 kWh 附近。如果 0x0109 读出来是 6.08(即 0.1kWh 倍率被多除了一次 10),约束立刻破。这次事故里三个量同时小 10 倍,这条校验能在上电第一秒就把它抓住——可惜原始实现里一条校验都没有。

11.4 状态与心跳的解析

static inline uint8_t bms_state(uint16_t w)  { return (uint8_t)((w >> 4) & 0x07); }
static inline uint8_t bms_hb(uint16_t w)     { return (uint8_t)((w >> 12) & 0x0F); }

/* 心跳模差,必须允许 0/1/2 */
static inline uint8_t hb_delta(uint8_t now, uint8_t prev)
{
    return (uint8_t)((now - prev) & 0x0F);
}

11.5 定点解码:别在 MCU 上做浮点

工程量只用整数表达完全够:dv(0.1V)、da(0.1A)、dp(0.1%)、dwh(0.1kWh)。上位机要显示时再除以 10。

唯一要注意的是乘除顺序。比如”把 0.1A 的电流换算成 W”:

/* 错:先除后乘,精度全丢 */
int32_t p_w = (i_da / 10) * (u_dv / 10);

/* 对:先乘后除,中间量用 int64 */
int64_t p_w = ((int64_t)i_da * u_dv) / 100;   /* 0.1A × 0.1V = 0.01W */

300 × 8000 = 2,400,000,除 100 = 24000 W = 24 kW。和 800V × 30A 对得上。这个”通分后一次乘除”的写法,跟绝缘监测那篇里定点反解必须通分是同一条纪律——分步除会把误差放大到百分之几十。


十二、上位机侧:一份声明式点表,和一条”不许翻译”的规矩

12.1 点表

{
  "protocol": "T/CIAPS 0009-2021",
  "transport": { "mode": "rtu", "baud": 9600, "framing": "8N1", "slave": 1 },
  "poll": { "period_ms": 200, "block": { "start": "0x0100", "count": 16 } },
  "points": [
    { "reg": "0x0100", "name": "u_bus",        "type": "u16", "scale": 0.1,   "unit": "V",   "min": 0,    "max": 1500 },
    { "reg": "0x0101", "name": "i_bus",        "type": "i16", "scale": 0.1,   "unit": "A",   "min": -3000, "max": 3000, "note": "正=放电" },
    { "reg": "0x0102", "name": "soc",          "type": "u16", "scale": 0.1,   "unit": "%",   "min": 0,    "max": 100 },
    { "reg": "0x0103", "name": "soh",          "type": "u16", "scale": 0.1,   "unit": "%",   "min": 0,    "max": 100 },
    { "reg": "0x0104", "name": "i_chg_lim",    "type": "u16", "scale": 0.1,   "unit": "A",   "min": 0,    "max": 3000 },
    { "reg": "0x0105", "name": "i_dis_lim",    "type": "u16", "scale": 0.1,   "unit": "A",   "min": 0,    "max": 3000 },
    { "reg": "0x0106", "name": "u_chg_lim",    "type": "u16", "scale": 0.1,   "unit": "V",   "min": 0,    "max": 1500 },
    { "reg": "0x0107", "name": "u_dis_lim",    "type": "u16", "scale": 0.1,   "unit": "V",   "min": 0,    "max": 1500 },
    { "reg": "0x0108", "name": "e_chg_avail",  "type": "u16", "scale": 0.1,   "unit": "kWh", "min": 0,    "max": 7000 },
    { "reg": "0x0109", "name": "e_dis_avail",  "type": "u16", "scale": 0.1,   "unit": "kWh", "min": 0,    "max": 7000 },
    { "reg": "0x010A", "name": "status",       "type": "bits", "scale": 1,    "unit": "-" },
    { "reg": "0x010B", "name": "sop",          "type": "u16", "scale": 0.1,   "unit": "kW",  "min": 0,    "max": 6500 },
    { "reg": "0x010C", "name": "v_cell_max",   "type": "u16", "scale": 0.001, "unit": "V",   "min": 0.5,  "max": 5.0 },
    { "reg": "0x010D", "name": "v_cell_min",   "type": "u16", "scale": 0.001, "unit": "V",   "min": 0.5,  "max": 5.0 },
    { "reg": "0x010E", "name": "t_cell_max",   "type": "i16", "scale": 0.1,   "unit": "C",   "min": -40,  "max": 85 },
    { "reg": "0x010F", "name": "t_cell_min",   "type": "i16", "scale": 0.1,   "unit": "C",   "min": -40,  "max": 85 }
  ],
  "write": [
    { "reg": "0x0200", "name": "req", "type": "enum",
      "map": { "none": "0x0000", "charge": "0x5555", "discharge": "0xAAAA" },
      "echo_check": true }
  ]
}

min/max 不是装饰。它们是 4.4 节那套量程校验的落地形式,用点表驱动校验,比在代码里散落 if 靠谱得多。

12.2 规矩:倍率只能转一次,符号一次都不许转

这次事故的根因不在协议,在架构:

网关把 raw × 0.1 转成浮点 800.0,上位机按”寄存器是 raw”再 × 0.1 得 80.0;

网关顺手把电流符号翻正,上位机按标准标注”放电为正”,显示成充电。

两个”好心”叠在一起,就是三个数同时错。

三条落地规则:

  1. 网关设成透传模式(raw 直出,不做任何量纲转换)。如果网关不支持透传,就在网关里转、上位机不做——二选一,写进接口文档的”数据流图”里。
  2. 符号永不翻转。要给人看”充电为正”,就在显示层加一个 display_sign = -1,并在界面上标注”充电为正(原始数据放电为正)”。
  3. 接口文档必须写清”哪个设备做的换算”。写”倍率 0.1″是不够的,得写”BMS 出 raw,网关透传,SCADA 乘 0.1″。缺这一句,下一个接手的人一定会再乘一次。

十三、现场五招

第一招:先抓一帧,别先改代码。 USB-485 并在总线上(高阻监听,别发),抓一次完整的 0x04 问答。01 04 01 00 00 02 70 37 这个请求发出去,回 37 字节。raw 对、界面错 → 不关 BMS 的事,十分钟定性。

第二招:三个量一起看。 单看一个数永远分不清”少乘 10″还是”发错值”。电压、SOC、电流一起看,同比例偏移就是倍率问题,个别偏移才是映射问题。

第三招:拿 SOC 和可用电量互算。 E_dis_avail ≈ E_nom × SOC × SOH。这条不用仪表、不用拆柜,上电就能算。差 10 倍、100 倍,立刻定位到倍率层。

第四招:改一个已知值,看回声。 想让 BMS 进入某个状态不容易,但可以让它”报一个特定值”:多数 BMS 有调试模式或模拟量注入。写一个已知的总压(比如 800.0V),看上位机显示多少——这是判定”倍率转了几次”最直接的办法,比读代码快。

第五招:拔线。 拔掉 485,看 PCS 多久停机、报什么。如果不停机,说明第二级(干接点)没做——这比任何数值错误都严重,应该立刻整改,而不是继续调数。


十四、17 项对接自查清单

  • ☐ 物理层确认:RS485 两线、8N1、波特率与从机一致(标准优选 9600)
  • ☐ 从机地址确认:1~247,总线上无重复
  • ☐ 终端电阻与偏置电阻:只在总线两端,中间节点不装
  • ☐ A/B 线序确认(接反能通但误码率高,别用”能通”当标准)
  • ☐ 地址口径确认:配置工具显示的是 256(PDU)还是 40257(4x),填对
  • ☐ 一次读 16 条(0x0100 起,数量 16),不拆成多次
  • ☐ 倍率确认:13 条 0.1、2 条 0.001、状态字无倍率
  • ☐ 符号确认:总电流 + 放电 / − 充电;两条温度补码
  • ☐ 零点偏移确认:本标准无 offset,别套 −40
  • ☐ 量程校验:每个量有合法区间,越界标 INVALID 而不是取极值
  • ☐ 状态字用 3 位枚举整体比较,不用位与
  • ☐ 心跳用模 16 差值,15→0 不报警
  • ☐ 两套故障分开报:COM_TIMEOUT 与 BMS_APP_HANG
  • ☐ 写 0x0200 只用 0000/5555/AAAA,且校验回显
  • ☐ 写侧确认是电平语义还是脉冲语义(并写进文档)
  • ☐ 倍率只转一次,转换位置写进接口文档;符号不翻
  • ☐ 第二级防护已接:BMS 干接点常闭,断开时 PCS 停机(实测拉线)

十五、配套脚本怎么用

drafts/_tools/ciaps_modbus_calc.py,约 450 行,纯标准库、不联网。自检 34 项,其中 5 项是”外部锚点”——标准 10.3 的两帧 CRC 和 CRC 目录化检查值 123456789 → 0x4B37。

map                          打印表3 全表 + 表5 写寄存器
read --start 0x0100 --count 16   构造 0x04 请求帧,打印期望响应长度
parse --frame "01 04 20 ..."     解析响应,逐条给工程量
decode --reg 0x0102 --raw 0x0320 单条解码 + 三种误读对照
status --word 0x0020             状态字位域展开
write --req charge               构造 0x06 写 0x0200 的帧(三个值)
timing --count 16 --baud 9600    单次问答耗时与总线占用、多簇上限
timeout --period 0.2 --miss 3    超时预算与心跳回绕
dual                             CAN 与 Modbus 同量对照表
misread                          "同比例小 10 倍"的指纹演示
triage                           17 行症状到层级的速查

几个输出示例(实测):

$ python ciaps_modbus_calc.py timing --count 16 --baud 9600
波特率 9600,8N1,1 字符 = 1.042 ms,T3.5 = 3.646 ms
  请求 8 字节 = 8.33 ms;响应 37 字节 = 38.54 ms
  单次问答(含 T3.5 静默)= 50.52 ms
  读周期 0.2s → 总线占用 = 25.3%
  若同一条 485 上挂 4 个簇(4 个从机)= 101.0%
  只读 0x0100~0x010A 共 11 条 → 响应 27 字节,单次 40.10 ms,占用 20.1%

$ python ciaps_modbus_calc.py status --word 0x0020
状态字 0x0020 = 0b0000000000100000
  bit4~6   BMS 系统状态  = 2  → 禁充
  bit12~15 心跳          = 0

关于自检为什么一定要有”外部锚点”:一个月前我在另一个脚本里把”读写多寄存器”的功能码写成 0x11(正确是 0x17),自检全过——因为它只跟”我自己写的那个错常量”对拍,自洽但不正确。从那以后我给自己定了规矩:自检里必须包含至少一个不依赖我实现的参照物,比如标准正文印出来的 CRC、CRC 目录化检查值、公开规范里的示例向量。这次用的就是 70 37 和 FC 6F——如果哪天我把 CRC 分支写错,这两个断言会立刻炸。


十六、最后:标准是地板,不是天花板

这套协议一共 16 条读寄存器、1 条写寄存器、2 个功能码、1 个时间约束。它没有版本号、没有时间戳、没有单体明细、没有 32 位量、没有浮点、没有多寄存器写。按今天的眼光看,”简陋”两个字不算冤枉。

但它做成了一件很多”功能齐全”的私有协议没做成的事:任何两家厂商,只要各自按这张表实现,插上就能通。 因为表足够小,小到没有解释空间;因为量全是 16 位定点,小到不会出字序问题;因为只有一问一答,小到不需要状态机。

真正让人栽跟头的,从来不是协议本身的复杂度,而是中间层那些”我帮你顺手转一下”的善意。这次的 80.0V / 8.0% / −3.0A 就是三句善意叠出来的。

所以如果只记一句话:协议转换层只做搬运,不做翻译;倍率只转一次,符号一次都不转。

下一篇换个方向,把这块电池的”另一头”算清:低温禁充到底卡在几度、−5℃ 时那个 0.1C 的限流是从哪来的、10kWh 从 15% 充回 95% 在 3kW 充电器上要多久、以及为什么柴油发电机的选型总差一档。


本文的寄存器定义、倍率、位域、示例报文均取自 T/CIAPS 0009-2021 正文(表 3、表 4、表 5、10.3、附录 A),示例帧 CRC 已用独立实现复算一致。文中的事故机型与现场数值为便于叙述做了合并与简化,接口口径与计算方法未作改动。文中对第 1 部分状态字位布局的对应关系属基于两部分编码的推断,已在正文标注,实施前请以所购设备的技术协议为准。