储能 BMS 与 PCS 的 CAN 对接实战:51.2V 的电池被逆变器读成 512.0V、一上电就跳机,SOC 还卡在 0%——我把两套「Pylontech 兼容」口径从头对了一遍

2026年10月16日 0 By admin

上一篇讲的是 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 报的),排查顺序应该是:

  1. 物理层(终端电阻、屏蔽、共地)→ 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 那几篇一致):

  1. 先乘后除,中间量用 int64。raw * 0.1 用浮点也能跑,但同一个量在 32 位和 64 位平台上会给出不同的末位,日志对不上就没法排查。
  2. 不做线性近似的温补——温度补偿一律用除法形式 X_true = X_read × 1e6 ÷ (1e6 + TC_ppm × dT),这条在电流采样链路设计实战里算过账。
  3. 倍率只在配置表里出现一次(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 摸清楚:

  1. 先量三个数:波特率(扫四档看错误帧)、终端电阻(断电量 CANH-CANL,应为 60Ω)、帧格式(分析仪看 ID 是否带 E)。三个数不对,后面全是白费。
  2. 抓 60 秒全量帧做 ID 直方图:每个 ID 的帧数与周期一目了然。缺哪个 ID、周期是多少、有没有非预期 ID,全在这张图里。别用「按已知 ID 过滤」的抓法——那会把你不知道的东西全部过滤掉。
  3. 用已知量三点对表:满充静置电压(16S LFP ≈ 53.3V)、零电流、满充 SOC。这三个一填,倍率和符号就定了。
  4. 制造极端状态取证:禁充、禁放、报警、故障,四种状态各抓一帧,把状态字和实况一一对上,自己建位表。这一步没人能替你做,因为位定义在不同版本/厂家之间真的不一样。
  5. 单簇与多簇各抓一次对比:确认聚合是在 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-test 60 项)。支持 Pylon 与 T/CIAPS 两套协议的解码、CAN ID 拆解与构造、0x359 位域展开、倍率双解对照、多簇聚合口径自检、总线负载与超时预算。自检里用的是 T/CIAPS 0006-2020 附录 A 的四组官方示例帧作外部锚点——如果你的解码和官例对不上,先怀疑工具,但最终以官例为准。