储能 BMS 与 PCS 的 CAN 对接实战:51.2V 的电池被逆变器读成 512.0V、一上电就跳机,SOC 还卡在 0%——我把两套「Pylontech 兼容」口径从头对了一遍
上一篇讲的是 Modbus 那条线——从 EMS 到 PCS 的「写控制与参数掉电保持」(逆变器 Modbus 写控制与参数掉电保持实战)。那篇里我写过一句「BMS 走 CAN,本篇不涉及」,然后就被现场追着问了。
这篇兑现那句话。
先说清楚一件事,免得有误会:CAN 只是搬运工。 CAN 管的是「怎么把 8 个字节从 A 搬到 B,搬错了能发现」,它完全不管你搬的是电压还是电压的十分之一。真正让 BMS 和 PCS 对不上的,全在协议层——帧 ID 怎么定、字节序怎么排、倍率是多少、状态字哪一位代表「禁止充电」。这些东西,CAN 控制器一个都不管。
所以这篇文章不讲 CAN 的仲裁和位填充(那在 CAN 总线协议原理与嵌入式实现全攻略、CAN 总线错误帧排查实战 和 CAN FD 与经典 CAN 混合组网实战 里已经讲透了),这篇只讲搬的那 8 个字节长什么样。
一、先把这台机器和这次改造交代清楚
不把现场说清楚,后面所有结论都悬空。
- 设备:100kW / 215kWh 工商业储能柜。电池侧是 8 簇并联,每簇 16S 磷酸铁锂(标称 51.2V,满充静置 53.3V 左右),5.12kWh/簇,合计 40.96kWh……等等,这个数字对不上,我把铭牌再看一眼:实际是每簇 5 个 16S 模组串联成 256V 高压簇,8 簇并联。储能柜里的「电池簇」是高压串联的,不是一堆 48V 并联,这一点是我一开始想当然搞错的地方,后面讲聚合时会再回来。
- 通信:BMS 与 PCS 之间一条独立 CAN,双绞屏蔽线,500 kbps。柜内这段线不到 2 米,两端各 120Ω 终端电阻(这点很重要,第二层会讲)。
- 协议:柜厂给的 PCS 固件自带一份 11 页的 CAN 通信说明,标题写着「Pylontech 兼容」;而 BMS 那边是另一家供应商,说明书里写的是「符合 T/CIAPS 0006-2020」。两套口径,接在一根线上。
- 我的角色:给这柜子做远程监控网关。EMS 那条 Modbus 线是我接的,CAN 这条线本来是柜厂调好的——但它每隔几天就犯一次毛病,柜厂远程看不出来,只能我来抓。
连上之后,客户反馈的工单是这样的:
| # | 现象(客户原话) | 我的第一反应 | 真正的原因 | 在哪一层 |
|---|---|---|---|---|
| 1 | 「一上电 PCS 就报直流过压,直接跳机」 | 电池真的过压了 | BMS 按 0.01V 发电压,PCS 按 0.1V 解,51.2V 读成 512.0V | C3 倍率 |
| 2 | 「SOC 一直显示 0%」 | BMS 没上报 | 0x355 的 SOC 是 1% 一档,PCS 按 0.1% 解,80% 读成 8.0% | C3 倍率 |
| 3 | 「BMS 通信故障报警每 3 秒闪一次」 | 线接触不良 | 上位机用 500ms 判超时,BMS 周期是 1000ms,每帧都「超时」 | C5 时序 |
| 4 | 「用你的分析仪抓不到 BMS 的报文」 | BMS 根本没在发 | 波特率配成 250k(应为 500k),加上终端电阻只有一端 | C0 物理 |
| 5 | 「抓到了帧,但按 0x351 过滤后一条都不剩」 | 固件有 bug | BMS 发的是 29 位扩展帧,我的过滤器只放行了 11 位标准帧 | C1 寻址 |
| 6 | 「8 簇电池,界面里只有 1 簇的数据」 | 只接了一簇 | 各簇各自上报,没有做 bank 聚合 | C5 聚合 |
| 7 | 「换了一块同型号 BMS,反而不能用了」 | 新 BMS 是坏的 | 0x35E 制造商标识变了,逆变器据此判定协议模式 | C6 兼容 |
| 8 | 「BMS 明明报了禁充,PCS 照样在充」 | 禁充功能没做 | PCS 只解析了限值,没解析状态字 | C4 位域 |
| 9 | 「均衡一开始,SOC 就从 100% 掉到 92%」 | 均衡在放电 | 界面取的是瞬时 SOC,不是均衡期间的滤波值 | C3 语义 |
九个现象,九个原因,分布在 C0~C6 七层上。
而且这七层和上一篇 Modbus 那七层没有一层是对应的。 Modbus 的点表是「寄存器地址 → 物理量」的扁平映射;CAN 这边是「帧 ID → 一包结构化数据」的打包模型。同一个物理量,在 Modbus 里占 1 个寄存器,在 CAN 里是 8 个字节里的第 0~1 字节,还带一个可能被两边理解成不同值的倍率。
拆完这七层,我得到一个比上一篇更狠的结论:
Modbus 读错是屏幕上数字不对;CAN 接错是设备真的会照做。 上一篇写控制那篇说的是「写错了设备会照做」,这一篇要补一句:读侧也照样能让设备照做——因为 PCS 的充放电功率,直接由它从 CAN 上读到的限值决定。
二、C0 物理与链路层:连帧都看不到,谈什么协议
这一层不是本篇重点,但它吃掉了我两天时间,必须放在最前面。
2.1 三个数先量出来:波特率、终端电阻、帧格式
现场判断顺序,别跳:
| 检查项 | 怎么量 | 不符的后果 |
|---|---|---|
| 波特率 | 分析仪扫 125k/250k/500k/1M 四档,看哪档 error frame 归零 | 完全收不到帧,或收到满屏错误帧 |
| 终端电阻 | 断电后万用表量 CANH-CANL 直流电阻 | 应为 60Ω(两端各 120Ω 并联);量到 120Ω 说明只有一端,量到无穷大说明两端都没有 |
| 帧格式 | 分析仪看 ID 显示是 0x351 还是 00000351 |
数值对但过滤不到,见第三节 |
| 共地/屏蔽 | 量 CAN 参考地与设备地之间电位差 | 差几伏以上,一切间歇性错误都有解释 |
保险柜内这段线不到 2 米,我们量出 120Ω——只有一端装了终端电阻。柜厂的理由是「线短,无所谓」。线短确实不影响反射,但它影响错误帧的处理:缺少一端终端的 CAN 总线在受扰时更容易进入 error passive,进而 bus-off。这个柜子装在现场、旁边就是 200A 的直流母排,跟实验室台面上完全是两回事。补上第二个 120Ω 之后,「隔几天报一次通信故障」这条就没了。
关于 CAN 的物理层细节(差分电平、偏置、地环流、光隔离),RS485/Modbus 抗干扰实战 里那套方法论(双绞、屏蔽单端接地、必要时隔离)原样能用,不重复。
2.2 一个工程师都会犯的错:把「能收到帧」当成「协议对上了」
我一开始的工作方式是:插上分析仪 → 看到 0x351、0x355、0x356 每秒一条 → 判定「通信正常」,然后就去查业务逻辑了。
这是错的。 能收到帧,只证明 C0(物理与链路)通了。看到 0x351 就证明 C1(寻址)也通了——因为 ID 是对的。但从 C2(字节结构)开始,六层里没有一层被证明过。
我后来给自己定了个规矩,叫「七层验收单」:每往下走一层,必须拿到一条独立的证据,而不是「上一层通了所以这层也应该通」。
三、C1 帧 ID 与寻址:两套协议,两套 ID 体系
3.1 Pylon 兼容协议:数值是 11 位,但线上可能是 29 位
Pylontech 在 2016 年公布了这套 CAN 报文格式,本意只是给自己合作的逆变器厂商用的,不是任何标准。后来别人发现逆变器有「Pylontech 兼容」模式,就照着抄;逆变器厂商又因为市面上电池都这么发,就都加了这个模式。规模效应滚起来,就成了事实标准。
它用到的 ID 就这几个:
| CAN ID | 含义 | 方向 |
|---|---|---|
| 0x305 | 逆变器心跳应答(8 字节全 0) | PCS → BMS |
| 0x351 | 充电电压 / 充电限流 / 放电限流 / 放电截止电压 | BMS → PCS |
| 0x355 | SOC / SOH | BMS → PCS |
| 0x356 | 实时电压 / 电流 / 温度 | BMS → PCS |
| 0x359 | 保护与告警位域 + 模块数量 + 协议标识 | BMS → PCS |
| 0x35A | 告警位域(1.3 版起,与 0x359 的 byte2~3 重叠) | BMS → PCS |
| 0x35C | 充放电请求 / 使能标志 | BMS → PCS |
| 0x35E | 制造商标识(ASCII) | BMS → PCS |
| 0x35F | 电池信息(额定容量等,1.3 版起) | BMS → PCS |
| 0x372 | 并联统计:在线 / 禁充 / 禁放 / 离线模块数 | BMS → PCS |
| 0x373 | 单体极值:最低/最高单体电压、最低/最高温度 | BMS → PCS |
第一个坑就在这:这些 ID 数值都小于 0x7FF,天然适合 11 位标准帧;原厂那版文档里写的确实是「采用标准帧」。但大量逆变器实现是按 29 位扩展帧 接收和过滤的,ID 数值照样填 0x00000351。
于是就有:
标准帧 0x351 ← 11 位,分析仪显示 0x351
扩展帧 0x00000351 ← 29 位,分析仪显示 00000351(或显示成 0x351 但标了 E)
这是两个不同的 CAN ID,不会互相匹配。 我的过滤器只放行了 0x351,BMS 发的是 00000351,结果就是「抓得到帧、过滤后一条不剩」——工单第 5 条。
现场处理:过滤器写成白名单,两种格式都放行:
/* 别只放行一个数值。先两种格式都给,再按实测收敛。 */
static const uint32_t pylon_rx_ids[] = {
0x305, 0x00000305,
0x351, 0x00000351,
0x355, 0x00000355,
0x356, 0x00000356,
0x359, 0x00000359,
0x35C, 0x0000035C,
0x35E, 0x0000035E,
};
顺带说一句:别用「波特率猜协议」这种办法。 500k 既可能是 Pylon,也可能是别的私有协议。判断依据应该是「ID 特征 + 0x35E 里的 ASCII」。
3.2 T/CIAPS 0006-2020:一套真正写成标准的协议
BMS 供应商那份说明书里的 T/CIAPS 0006-2020,是中国化学与物理电源行业协会的团体标准《储能变流器与电池管理系统通信协议 第 1 部分:CAN 通信协议》。它的编制说明里写得很直白:当时国内外都没有公开的 PCS-BMS 通信国标或行标,各家协议差异太大,所以先做个团体标准。
它的 CAN ID 是 29 位、J1939 风格的:
PCS → BMS :0x18F10000 + PS*256 + SA
BMS → PCS :0x18E10000 + PS*256 + SA (0x18E1 ~ 0x18E4)
那个 PS*256 + SA 就是「低 16 位拆成两个地址字段」——不是一个 16 位大整数。按 J1939 的位域拆开看:
| 字段 | 位宽 | 本例(0x18E10101) | 说明 |
|---|---|---|---|
| 优先级 P | 3 | 6 | 0x18E10000 >> 26 = 6 |
| EDP | 1 | 0 | |
| DP | 1 | 0 | |
| PF(PDU Format) | 8 | 0xE1 | 报文类型:E1/E2/E3/E4/F1 |
| PS(PDU Specific) | 8 | 0x01 | PF<240 时为目标地址 |
| SA(Source Address) | 8 | 0x01 | 源地址 |
| PGN | 18 | 0x18E1 | 报文组编号 |
标准的官例(附录 A)是这样的:
| 方向 | CAN ID | 数据 | 含义 |
|---|---|---|---|
| PCS → BMS | 0x18F10101 | 55 00 AA AA 00 00 00 00 |
请求放电 |
| BMS → PCS | 0x18E10101 | 88 13 B8 0B 20 03 B6 03 |
总压 500.0V / 总流 300.0A / SOC 80.0% / SOH 95.0% |
| BMS → PCS | 0x18E20101 | 64 00 F4 01 40 1F 58 1B |
充电限流 10.0A / 放电限流 50.0A / 充电限压 800.0V / 放电限压 700.0V |
| BMS → PCS | 0x18E30101 | 40 1F 58 1B 20 00 20 03 |
充电可用电量 800.0kWh / 放电可用电量 700.0kWh / 状态字 0x0020(禁充)/ SOP 80.0kW |
| BMS → PCS | 0x18E40101 | B8 0B F0 0A F4 01 C8 00 |
单体最高压 3.000V / 最低压 2.800V / 最高温 50.0℃ / 最低温 20.0℃ |
这五行官例是本篇最重要的锚点。 后面每一条倍率结论,我都是拿自己的解码器去对这几行,对不上就是我错。
3.3 地址撞车:多簇系统里最隐蔽的一种「数据对但语义乱」
标准里 PS 和 SA 的分配方式是「依次累加」——PS 累加区分设备类型,SA 累加区分同类设备的编号。8 簇并联时,如果每簇 BMS 都按默认 SA=1 发,就出现 8 簇抢同一个 CAN ID。
这时候分析仪看到的现象特别迷惑:字节结构完全合法,数值也在合理范围内,但两个来源的数据在交替覆盖。 界面上的电压会一秒内跳两次,SOC 忽高忽低。
正确做法是:每簇分配唯一 SA 并在产线固化,上位机按 SA 分表统计;同时 BMS 侧必须有一个「bank 聚合」角色(第三节讲聚合口径)。
用脚本可以快速验算地址:
$ python bms_can_calc.py id 0x18E10101
优先级 Priority 6
PF(PDU Format) 0xE1
PS(PDU Specific) 0x01 → PDU1(目标地址)
SA(源地址) 0x01 (1)
PGN 0x18E1
四、C2 报文结构与字节序:都是小端,但字节偏移别数错
好消息:两套协议的 16 位量都是小端(先发的字节是低字节)。这条没有分歧,可以放心。
坏消息:字节偏移和倍率哪哪都不一样。
4.1 Pylon 兼容协议的字节布局
| CAN ID | 字节 0-1 | 字节 2-3 | 字节 4-5 | 字节 6-7 |
|---|---|---|---|---|
| 0x351 | 充电电压 ×0.1V (u16) | 充电限流 ×0.1A (i16) | 放电限流 ×0.1A (i16) | 放电截止电压 ×0.1V (u16) |
| 0x355 | SOC ×1% (u16) | SOH ×1% (u16) | 预留 | 预留 |
| 0x356 | 电压 ×0.01V 或 ×0.1V (i16) | 电流 ×0.1A (i16) | 平均温度 ×0.1℃ (i16) | 最低温度(部分厂家) |
| 0x359 | 保护组 1 | 保护组 2 | 告警组 1 | 告警组 2 |
| 0x35C | 请求/使能位域 | 预留(部分厂家用 byte1) | 预留 | 预留 |
| 0x372 | 在线模块数 | 禁充模块数 | 禁放模块数 | 离线模块数 |
0x359 的 byte4 ~ byte7 要单独说,这是最容易被当成保护位的四个字节:
| 字节 | 内容 | 官例的值 |
|---|---|---|
| byte4 | 模块数量(8 位无符号) | 0x0A = 10 个模块 |
| byte5 | ASCII 'P' |
0x50 |
| byte6 | ASCII 'N' |
0x4E |
| byte7 | 预留,填 0 | 0x00 |
那一对 'P'、'N' 是协议标识(Pylon 的缩写),很多逆变器拿它来确认「对面真的是 Pylon 兼容设备」,缺了就切回默认模式——第 7 条工单(换 BMS 就用不了)就是这两个字节变了引起的。
解码出来是这样:
$ python bms_can_calc.py pylon --id 0x351 --data "30 02 E8 03 20 03 CC 01"
要求充电电压 CVL 56 V u16 ×0.1
充电电流上限 CCL 100 A i16 ×0.1
放电电流上限 DCL 80 A i16 ×0.1
放电截止电压 DVL 46 V u16 ×0.1
4.2 T/CIAPS 0006-2020 的字节布局
| CAN ID | 字节 0-1 | 字节 2-3 | 字节 4-5 | 字节 6-7 |
|---|---|---|---|---|
| 0x18E1 | 总电压 ×0.1V (u16) | 总电流 ×0.1A (i16,充负放正) | SOC ×0.1% (u16) | SOH ×0.1% (u16) |
| 0x18E2 | 充电限流 ×0.1A | 放电限流 ×0.1A | 充电限压 ×0.1V | 放电限压 ×0.1V |
| 0x18E3 | 充电可用电量 ×0.1kWh | 放电可用电量 ×0.1kWh | BMS 状态字(位域) | SOP ×0.1kW |
| 0x18E4 | 单体最高压 ×0.001V | 单体最低压 ×0.001V | 单体最高温 ×0.1℃ (i16) | 单体最低温 ×0.1℃ (i16) |
| 0x18F1 | byte0 固定 0x55 | 请求充/放电控制(2 字节) | 预留 | 预留 |
注意 0x18E1 和 0x18E4 的温度与电压用了不同的倍率:整体电压是 0.1V 一档,单体电压是 0.001V 一档(3 位小数,因为 3.2V 的判据需要 mV 分辨率);温度是 0.1℃。同一份报文族里倍率不统一,这就是为什么「照着模板改一个倍率」这种做法迟早出事——必须每个字段独立配置。
$ python bms_can_calc.py ciaps --id 0x18E10101 --data "88 13 B8 0B 20 03 B6 03"
电池组总电压 500 V u16 ×0.1
电池组总电流 300 A i16 ×0.1 充负放正
SOC 80 % u16 ×0.1
SOH 95 % u16 ×0.1
和官例解说完全对得上。
4.3 电流符号:两套协议难得一致的地方
充电为负、放电为正。 Pylon 和 T/CIAPS 都是这个口径。
但这是件容易被「优化」掉的事:我在自研网关里第一次写解码时,看着界面上电池在充电却显示 -30.0A,下意识加了个负号让它「看起来对」。三天后做放电测试,所有电流值全反了。
符号定义属于协议,不属于显示。 要改就在显示层用 abs() 或加一个「充/放」标签,别动解码值。
五、C3 倍率与语义:10 倍级错误的诞生地
5.1 0x356 的电压倍率:同一份字节,两个「Pylontech 兼容」
这是本篇的头号坑,也是工单第 1 条(一上电跳机)的根因。
同一个 0x356,字节 0-1 的电压字段,公开文档里能看到两种口径:
| 来源类型 | 0x356 电压倍率 | 依据 |
|---|---|---|
| Pylontech 原厂协议文档(2016 起,多版沿用) | 0.01V | 字段说明写「Unit: 0.01V」 |
| 多家逆变器/电池厂商的「Pylon 兼容」实现文档 | 0.1V | 字段说明写「Battery Terminal Voltage, 0.1V」 |
两边都自称 Pylontech 兼容。 所以「Pylontech 兼容」这个词,在倍率这一层是不成立的。
现场的数字长这样——BMS 上真实电压 51.20V:
BMS 按 0.01V 发:raw = 5120 = 0x1400 → 报文 00 14
PCS 按 0.1V 解:5120 × 0.1 = 512.0V ← 直流过压,跳机
反过来也一样致命:BMS 按 0.1V 发 512,PCS 按 0.01V 解成 5.12V——欠压报警,然后停止放电。
脚本把两种解一起打出来,这个功能我在现场用了不下十次:
$ python bms_can_calc.py pylon --id 0x356 --data "00 14 2C 01 FA 00"
电池电压 51.2 V
总电流 30 A
平均温度 25 ℃
倍率双解对照
原始值 = 5120 (0x1400)
按 0.01V 解 → 51.2 V
按 0.1V 解 → 512 V
注意那个 30.0A 的电流和 25.0℃ 的温度是对的——这是最要命的地方。 一包数据里三个量,错的那个(电压)跳机了你能发现;如果错的是温度(比如按 0.01℃ 解 25.0℃ = 0.25℃),读数「看起来」也合理,低温保护可能就悄悄失效了。
5.2 SOC:1% 和 0.1% 差十倍
| 协议 | 字段 | 倍率 | 80% 时的原始值 |
|---|---|---|---|
| Pylon 0x355 | SOC | ×1% | 80 (0x0050) |
| T/CIAPS 0x18E1 | SOC | ×0.1% | 800 (0x0320) |
工单第 2 条就是这么来的:BMS 发 Pylon 口径的 80,PCS 按 0.1% 解成 8.0%;上位机再做了个「低于 10% 不显示」的取整,结果就是界面常年 0%。
SOC 是唯一一个「错了不会报警、但会误导所有决策」的量。 低于阈值停止放电的判据、EMS 的削峰填谷策略、客户看的那块屏,全都建立在它上面。我现在的做法是:SOC 解码后必须做一次范围自检——如果算出 0% 或 >100%,直接判「口径不匹配」并上报,而不是把那个数画到界面上。
5.3 温度:℃ 还是开尔文
温度字段除了倍率,还有单位问题:
| 口径 | 25.0℃ 对应的原始值 | 说明 |
|---|---|---|
| 0.1℃ | 250 (0x00FA) | 两套主流协议都用这个 |
| 0.1K(开尔文) | 2982 (0x0BA6) | 少数实现用这个,需减 273.15 |
两种都存在。判据很简单:静置常温下看原始值——250 附近是 ℃,2980 附近是 K。别用「温度读数看起来奇怪」来判断:按 ℃ 解 2982 会得到 298.2℃,超温报警;但按 K 解 250 会得到 -23.2℃,变成低温报警。两个方向都会报警,你没法从报警类型反推是哪边错了。
5.4 倍率错的通用特征与唯一可靠验证法
先说三件不能用来判断倍率错的事:
- 「值看起来合理」——0.1V 误读成 0.01V 时 5.12V 也在合理范围里;
- 「量纲对得上」——倍率错不改变量纲,电压还是电压;
- 「示波器波形干净」——倍率错跟信号质量没有任何关系。
唯一可靠的方法:让 BMS 报一个已知量,回读对表。 三个最方便的已知量:
| 已知量 | 怎么造 | 期望值 |
|---|---|---|
| 满充静置电压 | 满充后静置 2 小时,无充放 | 16S LFP ≈ 53.3V(3.33V/单体) |
| 零电流 | 断开 PCS,电池静置 | 电流字段应为 0 ± 一点点 |
| SOC | 满充静置后 | 应接近 100%,且 SOH 应是个稳定值 |
这三个量对着表一填,所有倍率自然就定死了。
5.5 固件侧:倍率必须是配置,不能是常量
把倍率写死在 decode_voltage() 里,是这类项目最常见的结构性错误。因为:
- 同一个产品线换了 BMS 供应商,倍率可能就变了;
- 双协议共存时(同时支持 Pylon 和 T/CIAPS),倍率必须跟着协议走;
- 现场调不通时,你要能在不重新烧固件的前提下把倍率改掉试。
声明式点表,把倍率作为数据:
{
"proto": "pylon",
"frames": [
{
"id": 0x356,
"len": 8,
"fields": [
{"name": "pack_voltage", "off": 0, "type": "i16", "scale": 0.01, "unit": "V",
"note": "0.01V=原厂口径;若对不上改 0.1,现场已见两种"},
{"name": "pack_current", "off": 2, "type": "i16", "scale": 0.1, "unit": "A",
"note": "充负放正,不要为显示反转符号"},
{"name": "pack_temp_avg","off": 4, "type": "i16", "scale": 0.1, "unit": "C"}
]
}
]
}
一条纪律:
scale只允许出现在这张表里。解码函数里出现* 0.1这种字面量,哪怕只有一处,也要揪出来——因为它意味着某个字段的倍率是「猜的」。
六、C4 状态字与位域:限值管「多少」,状态字管「能不能」
这一层是安全事故最容易发生的地方,也是我在这台柜子上花时间最多的一层。
6.1 0x359 的四个字节:保护 vs 告警
0x359 的字节 0~3 是一个 32 位位域,但它分成两组,含义完全不同:
- byte0 ~ byte1 = 保护(Protection):BMS 已经动作了(断开主回路之类);
- byte2 ~ byte3 = 告警(Alarm / Warning):只是提示,还没动作。
关键的是:同一个 bit 位置,在 byte0 和 byte2 里含义不一样。 完整表:
| 字节 | bit0 | bit1 | bit2 | bit3 | bit4 | bit5 | bit6 | bit7 |
|---|---|---|---|---|---|---|---|---|
| byte0 保护组 1 | – | 单体/模块过压保护 | 单体/模块欠压保护 | 单体过温保护 | 单体低温保护 | – | – | 放电过流保护 |
| byte1 保护组 2 | 充电过流保护 | – | – | 系统错误 | – | – | – | – |
| byte2 告警组 1 | – | 单体/模块高压告警 | 单体/模块低压告警 | 单体高温告警 | 单体低温告警 | – | – | 放电大电流告警 |
| byte3 告警组 2 | 充电大电流告警 | – | – | 内部通信失败 | – | – | – | – |
有个细节值得记一下:这套协议的早期版本里,byte0 bit1 的描述写错过一次,后续版本专门勘误为「过压保护」。协议文档自己也会修订,这就是为什么第六节要强调「以现场报文为准」。
解码器要按「保护 / 告警」分组输出,别把 32 位当成一个整体打出来:
$ python bms_can_calc.py bits --data "02 88 00 00 0A 50 4E 00"
Byte0 保护组 1 0x02
Byte1 保护组 2 0x88
Byte4 模块数量 10 个
Byte5/6 协议标识 'PN'
置位明细
bit 1 单体/模块过压保护 PROT_OV
bit 11 系统错误 PROT_SYS
6.2 0x35C 的请求/使能位:版本差异最大的一条
0x35C 的 byte0 是「BMS 请求逆变器做什么」的位域。它的问题不是难解,是版本迭代太快:
| bit | 含义 | 版本 |
|---|---|---|
| 0 | 允许充电 | 初版 |
| 1 | 允许放电 | 初版 |
| 3 | 请求满充(长期未满充时置位) | 1.2 版新增 |
| 4 | 强充请求 2(低电量唤醒用) | 1.1 版新增 |
| 5 | 强充请求(部分型号用 bit5 区分 SOC 区间) | 初版,语义有分叉 |
| 6、7 | 预留 / 厂商自有 | – |
处理办法只有一条:不要猜。 把 0x35C 的 byte0 原样打到日志里,然后在现场造出「禁充」「禁放」「低电量」三种真实状态,各抓一帧,自己把位对出来。厂商文档能给的只是候选。
6.3 T/CIAPS 的状态字:为什么我不给你一张位图
T/CIAPS 0006-2020 的 BMS 状态字在 0x18E3 的字节 4~5,是个 16 位位域。位定义写在标准正文的表 5 里。
我不打算在这里给你一张「通用位图」,原因是它大概率是错的。 我能从公开渠道锚定的只有一个点:附录 A 的官例里,
0x18E30101 DATA = 40 1F 58 1B 20 00 20 03
^^^^^ 状态字 = 0x0020
官例说明:BMS 状态:禁充
也就是说 0x0020 对应「禁充」。仅此而已。
而同一类状态字段,另一家厂商的 PCS 通信手册里用的是完全不同的编码——它把状态塞进 Bit4~6 的 4 bit 编码(0=初始、1=正常、2=充电禁用、3=放电禁用、4=报警、5=故障、6=待机、7=强制充电),还在 Bit12~15 放了个「寿命信号」。
同一类信息、同一段位域、两套编码。 这种情况下照抄任何一张表都是赌博。正确做法是现场取证建表:
| 步骤 | 动作 | 产出 |
|---|---|---|
| 1 | 让 BMS 进入「禁充」(手动设保护阈值或断开单体采样) | 抓一帧 0x18E3,记下状态字 |
| 2 | 恢复,制造「禁放」 | 再记一个值 |
| 3 | 制造「报警」等级事件 | 再记一个值 |
| 4 | 制造「故障」等级事件(拔一根采样线) | 再记一个值 |
| 5 | 用这四个值反过来验证你的解码逻辑 | 属于你自己的位表 |
6.4 三级安全策略:为什么「禁充位」不能被限值代替
T/CIAPS 0006-2020 里明确写了 PCS 与 BMS 之间的三级保护,这一段我觉得是全篇最该抄进设计文档的东西:
| 级别 | 手段 | 触发条件 |
|---|---|---|
| 第一级 | BMS 上传的限制指令(充放电电压电流、禁充/禁放) | 正常工作范围内 |
| 第二级 | BMS 通过故障位或干接点指令 PCS 动作 | 通信正常走故障位;通信失效走干接点 |
| 第三级 | BMS 指令关断电池主电路 | 前两级都失效 |
关于第二级,标准里还有一句特别关键的约定:干接点默认常闭——电池有故障时 BMS 断开干接点通知 PCS,故障消失再恢复。
这句话背后的设计思想是:常闭回路在断线时呈现「故障」状态,是 fail-safe 的;常开回路断线时看不出任何异常。 现场如果看到有人把干接点改成常开以便「省事」,那是把最后一道防线从「失效安全」改成了「失效沉默」。
由此得到一条不能妥协的规则:
限值(限流/限压)和状态位(禁充/禁放/故障)是两种不同粒度的手段,不能互相代替。
把充电限流设成 0 来「实现禁充」是个常见做法,但它意味着 PCS 每个周期都在「按 0 电流充电」——一旦倍率、单位或协议版本有任何一处出问题,它就会变回满功率充电。而禁充位是布尔含义:解析不出来就是解析不出来,不会「变」成允许。
6.5 工单第 8 条的复盘
「BMS 报了禁充,PCS 照样在充。」
我的第一反应是 BMS 没发那条报文(毕竟 PCS 的 CAN 收帧指示灯一直在闪)。抓包一看,0x18E3 每 200ms 稳定在发,状态字也确实是 0x0020。
问题在 PCS 固件:它只解析了 0x18E2(限制信息),没有解析 0x18E3 里的状态字。 因为柜厂做这家 PCS 的适配时,对接的那款 BMS 只发 0x18E1/0x18E2/0x18E4,没人注意到 0x18E3 的字节 4-5 是安全信号。
这种「协议实现不全」在国产 PCS/BMS 里相当常见。验收时必须逐条报文确认「对端真的解析了」,不能只看「对端收到了」——这也是我在自查清单里放了一条「状态位有效性验证」的原因。
七、C5 时序、心跳与聚合:超时判据和「谁的数据算数」
7.1 周期、超时与方向
两套协议的周期差 5 倍,这是超时判据最容易写错的地方:
| 协议 | 报文周期 | 建议超时门限(3 帧) | 工程上限 |
|---|---|---|---|
| Pylon 兼容 | 1000 ms | 3000 ms | 部分逆变器写 5000 ms |
| T/CIAPS 0006-2020 | 200 ms | 600 ms | 1000 ms |
工单第 3 条:上位机用 500 ms 判超时,去监视一个 1000 ms 周期的 Pylon 帧——理论上每一帧都会超时,而实际上因为相位漂移,会变成「一半时间在报警」。
还有一点常被漏掉:两个方向要分别计时。
| 监视方向 | 谁监视 | 超时意味着 |
|---|---|---|
| BMS → PCS(0x18E1 等 / 0x351 等) | PCS 侧 | PCS 应停止充放(拿不到限值就不许动作) |
| PCS → BMS(0x18F1 / 0x305) | BMS 侧 | BMS 可能停发或进入安全状态 |
Pylon 那条 0x305 心跳(逆变器每秒回一帧全 0)的作用就是这个:BMS 靠它判断「逆变器还在听」,收不到就可能停发数据甚至自己断开主回路。 我用一个自制逆变器模拟器调试时就踩过——只发不收,BMS 30 秒后就不理我了。
超时预算可以算:
$ python bms_can_calc.py timeout --period 200 --k 3
报文周期 200 ms
允许丢失帧数 k 3
建议超时门限 600 ms
上电后开始判超时的宽限 1200 ms
7.2 多簇并联的聚合口径:这一节我改了两次
回到第一节留下的那个坑——我一开始以为「8 簇并联」就是 8 个 48V 电池捆一起,那可是彻底搞错了。
储能柜里通常是高压簇:每簇由若干个 16S 模组串联,簇间并联。所以聚合口径必须先分清串联与并联:
| 量 | 串联(簇内) | 并联(簇间) |
|---|---|---|
| 电压 | 相加 | 各簇相等,取平均 |
| 电流 | 各模组相同 | 代数相加 |
| 容量 | 不变 | 相加 |
| SOC | 加权 | 按容量加权 |
簇间的聚合规则(这张表可以直接抄进固件):
| 量 | 聚合规则 | 为什么 |
|---|---|---|
| 总电压 | 各簇平均(先剔除离线簇) | 并联电压相等,平均是去噪 |
| 总电流 | 各簇代数相加(充负放正) | 符号先统一 |
| SOC | 按可用容量加权,不是算术平均 | 容量不一样的簇,简单平均会系统性偏差 |
| 充电限流 | 取 min | 不许任何一簇过流 |
| 放电限流 | 取 min | 同上 |
| 充电限压 | 取 min | 不许任何一簇过充 |
| 放电限压(下限) | 取 max(最高的那个下限) | 不许任何一簇过放 |
| SOH | 取 min | 木桶效应 |
| 保护 / 告警位 | 按位 OR | 任一簇报,整体就报 |
| 模块数量 | 在线簇计数 | 心跳丢失的簇剔除并单独上报 |
SOC 加权这一步值得单独算一遍,看看简单平均能偏多少:
$ python bms_can_calc.py agg --packs 8 --cap 100 --cap2 280
总容量 1520 Ah 各簇容量直接相加
SOC 简单平均 70 % ** 错 **
SOC 容量加权 65.3 % 正确口径
差异 4.7 个百分点
一半 100Ah 的旧簇 + 一半 280Ah 的新簇,简单平均会给出 70%,容量加权是 65.3%——差 4.7 个百分点。在 SOC 50% 附近做削峰填谷策略时,这个偏差足够让策略提前或延后一整个小时。
厂商文档里那句「逆变器只接受聚合值,单个电池不许各自上报」(原文大意:each battery cannot send this data individually)说的就是这张表。工单第 6 条(8 簇只显示 1 簇)就是没做这一步的后果——如果所有簇都按默认地址发,界面只会显示最后一条覆盖的值。
7.3 顺手证明一件事:通信故障不是带宽问题
算一下这条 CAN 的负载:
$ python bms_can_calc.py load --bps 500000 --fps 25 --frame ext
帧格式 扩展帧 29 位
波特率 500 kbps
帧率 25 帧/s
标称位长(8 字节数据) 131 bit
标称负载 0.655 %
CIAPS 五条报文、200ms 周期(25 帧/s)在 500k 上只占 0.66%;即使加急到 100ms 周期,也就 1.3%。
(位长口径:经典 CAN 标准帧 8 字节数据 = 111 bit,扩展帧 = 131 bit,均为不含填充位的标称值;最坏填充情况下分别到 135 / 155 bit。)
结论:BMS↔PCS 这条 CAN 的带宽永远不是瓶颈。 所以一旦出现「通信故障」(不管是 BMS 报的还是 PCS 报的),排查顺序应该是:
- 物理层(终端电阻、屏蔽、共地)→ 2. 超时判据 → 3. 协议口径 → 4. 最后才是总线负载。
把负载计算放在第一位,是典型的「拿熟悉的工具解决陌生的问题」。
八、C6 协议版本与厂商差异:「兼容」是最贵的词
8.1 Pylon 兼容协议的版本史
这套协议一直在长,而且是向后追加式的:
| 版本 | 变化 |
|---|---|
| 1.0 | 0x351/0x355/0x356/0x359/0x35C/0x35E 基本集 |
| 1.1 | 0x35C 增加「强充请求 2」(bit4);勘误:0x359 byte0 bit1 应为「过压保护」 |
| 1.2 | 0x35C 增加「满充请求」(bit3) |
| 1.3(部分厂商) | 新增 0x35A(告警位域)、0x35F(含额定容量) |
| 扩展(部分厂商) | 0x372(并联统计)、0x373(单体极值)、0x400~0x4C3(逐包明细) |
向后追加意味着「对面可能只实现了一个子集」。 你的解码器不能因为「0x35F 没来」就判通信故障,也不能因为「收到了 0x35F」就假设里面是标准布局。
8.2 克隆实现的差异清单(验收时逐条打勾)
这是我从多份「Pylon 兼容」文档里对比出来的实际分歧点,这张表就是验收清单:
| 差异点 | 可能的取值 | 现场怎么判 |
|---|---|---|
| 帧格式 | 11 位标准帧 / 29 位扩展帧 | 分析仪看 ID 显示格式 |
| 0x356 电压倍率 | 0.01V / 0.1V | 已知量对表(满充静置 53.3V) |
| SOC 倍率 | 1% / 0.1% | 满充时看是不是 100 或 1000 |
| 温度单位 | 0.1℃ / 0.1K | 常温静置看原始值 250 还是 2980 |
| 电流符号 | 充负放正 / 反之 | 断开放电,看符号 |
| 字节序 | 小端 / 大端(少数) | 同一串字节两种解,看哪个落在合理范围 |
| 发送周期 | 1000ms / 800~1200ms 抖动 | 分析仪统计周期分布 |
| 0x359 是否发送 | 全发 / 部分发 / 完全不发 | 按 ID 直方图看 |
| 0x35E 标识字符串 | 「PYLON」/ 「PYTES」/ 厂商自有 | ASCII 直接读 |
| 是否含 0x372/0x373 | 有 / 无 | 直方图 |
| 地址(若为 CIAPS 口径) | PS/SA 默认值 vs 逐台递增 | 拆 ID 低 16 位 |
这张表的用法:接到一套新系统,先用分析仪抓 60 秒全量帧,做一张「ID → 帧数 → 周期」的直方图,然后逐行填表。填完这张表,你的解码器参数就定死了。
这跟 逆变器 Modbus 点表与上位机对接实战 那篇里「先定字序再定倍率」是同一个方法论:把口径从文档里搬到你的配置表里,而且只搬一份。
8.3 双协议自动识别
网关要同时支持 Pylon 和 T/CIAPS,怎么自动认?三个特征,按可靠性排序:
| 判据 | 可靠性 | 说明 |
|---|---|---|
| CAN ID 位宽与数值 | 高 | 0x18Ex/0x18Fx 是 29 位,Pylon 数值 ≤ 0x7FF 且 29 位帧的 ID 是 0x000003xx |
| 0x35E 的 ASCII | 中 | 只有 Pylon 侧有;但厂家会改字符串 |
| 数值合理性交叉验证 | 低 | 只做兜底,不做主判据 |
别做「概率猜测」式的自动识别——认错了代价是把 512.0V 当成真电压。正确做法是:自动识别只用来选一份配置,选完之后必须过一遍 5.4 节那三个已知量自检,自检不过就报「协议未确认」并要求人工确认。
九、固件与上位机侧落地
9.1 解码范式:全程定点,先乘后除
CAN 过来的都是整数原始值,倍率是 0.1 / 0.01 / 0.001 这类十进小数。工程上统一做法:
/* 原始值 -> 微单位(µV/mA/m℃),全程 int64 中间量,先乘后除 */
#define SCALE_MICRO(numer, denom) ((int64_t)(numer) * 1000000 / (denom))
static int64_t decode_i16_micro(const uint8_t *d, uint32_t scale_micro)
{
int16_t raw = (int16_t)((uint16_t)d[0] | ((uint16_t)d[1] << 8)); /* 小端 */
return (int64_t)raw * scale_micro / 1000000; /* 先乘后除 */
}
/* 0.01V -> 10000 微伏 0.1A -> 100000 微安 0.001V -> 1000 微伏 */
const uint32_t PYLON_V_SCALE_MICRO = 10000;
const uint32_t PYLON_I_SCALE_MICRO = 100000;
const uint32_t CIAPS_CELL_V_MICRO = 1000;
三条纪律(和 10-12、10-13、10-14、10-15 那几篇一致):
- 先乘后除,中间量用 int64。
raw * 0.1用浮点也能跑,但同一个量在 32 位和 64 位平台上会给出不同的末位,日志对不上就没法排查。 - 不做线性近似的温补——温度补偿一律用除法形式
X_true = X_read × 1e6 ÷ (1e6 + TC_ppm × dT),这条在电流采样链路设计实战里算过账。 - 倍率只在配置表里出现一次(5.5 节)。
9.2 超时与异常分类:至少八类
「BMS 通信故障」这一个笼统的告警,害我在现场多花了一天。分类至少要细到:
| 故障码 | 含义 | 现场动作 |
|---|---|---|
BMS_CAN_BUS_OFF |
控制器进入 bus-off | 查物理层与终端电阻 |
BMS_CAN_ID_UNKNOWN |
收到未登记 ID | 可能换了协议版本,重新做直方图 |
BMS_FRAME_TIMEOUT |
本周期内核心帧缺失 | 看是缺 0x18E1 还是全部 |
BMS_FRAME_PARTIAL |
帧来了但长度/字段不全 | 厂家实现子集 |
BMS_SCALE_MISMATCH |
解码值越界(如 SOC > 100%) | 口径不匹配,不是电池问题 |
BMS_STATUS_CONFLICT |
限值说可充、状态字说禁充 | 以状态字为准(更保守),并升级告警 |
BMS_WATCHDOG |
对端(PCS)无心跳应答 | Pylon 的 0x305 缺失 |
BMS_AGG_INCONSISTENT |
各簇聚合结果自相矛盾 | 地址撞车或聚合规则写错 |
第 5 条 BMS_SCALE_MISMATCH 是我现在最喜欢的一条:它把「倍率错」从「一个安静的错数」变成了「一条明确的告警」。SOC 算出 8.0% 或 800% 的时候,报的是「口径不匹配」,而不是让值班的人去怀疑电池坏了。
9.3 PCS 自测值与 BMS 上报值的交叉校验
PCS 自己会测量直流侧电压电流,BMS 也上报一套。这两个值应该在同一量级、并且趋势一致。
| 校验项 | 判据 | 偏差大的可能原因 |
|---|---|---|
| 直流电压 | 偏差 < 2% | 倍率错、采样链路偏差(见 10-13 母线电压那篇) |
| 直流电流 | 偏差 < 5% | 倍率错、符号反、采样点位置不同 |
| 功率方向 | 符号必须一致 | 一侧搞反了充放电符号 |
这条校验的价值在于:它是唯一一个不依赖「文档对不对」的判据。 倍率、字节序、单位全对不上时,两个独立测量值的交叉对比仍然能告诉你「有问题」。
十、现场五招
这五招按顺序做,基本能在半天内把一套陌生的 BMS↔PCS CAN 摸清楚:
- 先量三个数:波特率(扫四档看错误帧)、终端电阻(断电量 CANH-CANL,应为 60Ω)、帧格式(分析仪看 ID 是否带 E)。三个数不对,后面全是白费。
- 抓 60 秒全量帧做 ID 直方图:每个 ID 的帧数与周期一目了然。缺哪个 ID、周期是多少、有没有非预期 ID,全在这张图里。别用「按已知 ID 过滤」的抓法——那会把你不知道的东西全部过滤掉。
- 用已知量三点对表:满充静置电压(16S LFP ≈ 53.3V)、零电流、满充 SOC。这三个一填,倍率和符号就定了。
- 制造极端状态取证:禁充、禁放、报警、故障,四种状态各抓一帧,把状态字和实况一一对上,自己建位表。这一步没人能替你做,因为位定义在不同版本/厂家之间真的不一样。
- 单簇与多簇各抓一次对比:确认聚合是在 BMS 内部做的、还是上位机做的、还是在 PCS 侧做的。如果三处都没做(工单第 6 条),那就是「谁最后发谁说了算」。
十一、对接自查清单
发版前逐条过一遍,一条打不上勾就别上线:
- ☐ C0 波特率已与 BMS/PCS 双方确认,且用分析仪扫档验证过
- ☐ C0 终端电阻两端各 120Ω(断电实测 60Ω),屏蔽层单端接地
- ☐ C0 已记录 bus-off / error passive 计数基线,并设了阈值告警
- ☐ C1 已确认帧格式是 11 位还是 29 位,过滤器两种格式都放行
- ☐ C1 全部 ID 已列入白名单,未登记 ID 会单独上报而不是丢弃
- ☐ C1(CIAPS)已按簇分配唯一 PS/SA,且在产线固化、有 NVM 落盘
- ☐ C2 已用解码器逐字段对过官方示例帧,字节偏移零误差
- ☐ C2 电流符号(充负放正)在解码层未被显示层改写
- ☐ C3 0x356 电压倍率(0.01V / 0.1V)已用满充静置电压实测确认
- ☐ C3 SOC 倍率(1% / 0.1%)已用满充状态实测确认,且解码后有 0~100% 越界自检
- ☐ C3 温度单位(℃ / K)已用常温静置原始值确认
- ☐ C3 所有倍率只出现在声明式配置表里,代码里无字面量
- ☐ C4 保护位与告警位已分组解码,未把两组合并成 32 位整体
- ☐ C4 已确认对端(PCS)真的解析了禁充/禁放/故障位,而不只是收到了报文
- ☐ C4 干接点回路为常闭,断线时表现为「故障」
- ☐ C5 BMS→PCS 与 PCS→BMS 两个方向的超时分别配置,且门限 ≥ 3 个周期
- ☐ C5 上电宽限期已设置,首帧丢失不会触发告警
- ☐ C5 多簇聚合已按容量加权算 SOC、按 min 取限流、按 OR 合位域
- ☐ C6 已录制 60 秒全量帧直方图,作为「交付时的协议基线」存档
- ☐ C6 双协议识别结果必须过一遍已知量自检,自检不过不允许自动采纳
收尾:这一层的教训
回头看这台柜子,九个现象里真正属于「电池或硬件坏了」的是零个。全部是口径问题。
我在 10-13 那篇写 Modbus 点表时说过一句话:「差的那 2V 不在寄存器里,在地址口径这一层。」 这篇要说的是同一件事的 CAN 版本,而且更极端——
51.2V 和 512.0V 是同一串字节。 区别只在于我按 0.01 还是 0.1 去乘它。CAN 把自己的 CRC、位填充、错误帧都做得无可挑剔,然后把这个歧义原封不动地交给了协议层。
跟储能系统打交道久了会发现,这套系统里几乎每个接口都有一层「口径」:Modbus 是地址与倍率,CAN 是帧 ID 与字节偏移,采样链路是源阻抗与温漂(电流采样、母线电压 那两篇),绝缘监测是判据与建立时间(绝缘监测与漏电流检测),BMS 的 SOC 估算本身也是(电池管理与 SOC 估算实战)。
但每一层都有一个共同的解法:找一个已知量,对表。 对表这件事比读文档可靠,因为文档会修订、厂家会改实现、协议标着「兼容」两个字但绝不完全兼容。
下一篇我打算写离网侧的并网/离网切换(并离网切换时序与上位机联锁)——把上一篇 Modbus 写控制里第七节那笔「安全联锁」展开:并网转离网那 100ms 里到底发生了什么、切换窗口内哪些寄存器必须禁写、BMS 报过流时 PCS 和 EMS 谁的指令优先。那篇会涉及 BMS 与 PCS 的「谁说了算」问题,正好和这篇的状态字那节接上。
配套工具:
drafts/_tools/bms_can_calc.py(纯标准库、全离线、--self-test60 项)。支持 Pylon 与 T/CIAPS 两套协议的解码、CAN ID 拆解与构造、0x359 位域展开、倍率双解对照、多簇聚合口径自检、总线负载与超时预算。自检里用的是 T/CIAPS 0006-2020 附录 A 的四组官方示例帧作外部锚点——如果你的解码和官例对不上,先怀疑工具,但最终以官例为准。