BMS-PCS通信协议深度解析——CAN总线在储能系统中的工程实践
作者:薛定谔的悦 日期:2026-04-14
项目背景:工商业储能管理系统(BESS),支持多品牌BMS并联接入,通过CAN总线与PCS(Power Conversion System)双向通信
一、为什么是CAN总线?
刚加入这个项目时,我问了自己同样的问题。为什么储能系统不用Modbus RTU,不用RS485,而偏偏选择CAN总线?
原因是多层次的:
- 实时性:CAN总线的优先级仲裁机制保证了高优先级帧(如保护命令)在总线竞争中必然胜出,最坏情况延迟可以精确计算。
- 可靠性:CAN协议内置CRC校验、位填充、帧错误检测,单个节点故障不会拉低整条总线。
- 多主通信:BMS可以主动广播数据,PCS也可以主动发送控制命令,无需轮询。
- 工业生态:主流BMS厂商(宁德时代、亿纬锂能、鹏辉能源等)均提供CAN接口,协议文档相对规范。
然而,理论是美好的,现实是:每家厂商的CAN协议都不一样。这就是我们项目中出现 can_pl_batt.c、can_nd_batt.c、can_nd_v33_batt.c、can_infy_batt.c 这四个文件的根本原因——我们需要为每种BMS品牌/版本写一套独立的协议适配层。
二、Linux嵌入式平台的socketCAN配置
在深入协议细节之前,先说说底层的物理链路。我们的控制器运行Linux,CAN接口通过socketCAN驱动暴露给应用层。
2.1 接口初始化
# 配置CAN0接口,波特率250kbps
ip link set can0 type can bitrate 250000
ip link set can0 up
# 或通过libsocketcan
can_set_bitrate("can0", 250000);
can_do_start("can0");
我们的代码支持50K/100K/125K/250K/500K/800K/1M七种波特率,在 comm_socketcan.c 中通过 can_set_bitrate() 动态配置。
2.2 Socket创建与配置
// 创建原始CAN Socket
int sock = socket(PF_CAN, SOCK_RAW, CAN_RAW);
// 禁用本地回环(避免收到自己发出的帧)
int loopback = 0;
setsockopt(sock, SOL_CAN_RAW, CAN_RAW_LOOPBACK, &loopback, sizeof(loopback));
// 设置非阻塞模式
int flags = fcntl(sock, F_GETFL, 0);
fcntl(sock, F_SETFL, flags | O_NONBLOCK);
// 绑定到can0接口
struct sockaddr_can addr;
struct ifreq ifr;
strcpy(ifr.ifr_name, "can0");
ioctl(sock, SIOCGIFINDEX, &ifr);
addr.can_family = AF_CAN;
addr.can_ifindex = ifr.ifr_index;
bind(sock, (struct sockaddr*)&addr, sizeof(addr));
2.3 帧读取与错误过滤
值得注意的是,socketCAN会将总线错误帧也传递给应用层。我们在解析时加了过滤:
// 检查错误帧标志位(bit 29~31)
if ((can_id >> 29) & 0x07 == 0x01) {
// 错误帧,跳过
continue;
}
另一个工程细节:硬件上 can1 对应内核的 can0,can2 对应 can1,这个映射关系踩过坑,要在配置文件里明确标注。
我们的自定义帧格式是16字节:
Byte[0] : 数据长度(通常为8)
Byte[1~4] : 29位扩展帧ID(大端序)
Byte[5~12]: 8字节数据区
Byte[13~15]: 保留/对齐
三、四种BMS协议的帧结构深度对比
3.1 PL协议(can_pl_batt.c)——主从请求-应答模式
PL协议是标准的主从模式:PCS作为主机,定期向BMS发送查询命令,BMS被动应答。
帧ID构造规则:
void CAN_StuffHead(UINT uiCmdType, UINT uiSubCmd, UINT nGrpIdx,
UINT uiLen, BYTE *pbyFrame)
{
UINT uiFrmID = 0;
uiFrmID += ((UINT)(uiCmdType << 8));
uiFrmID += ((UINT)(uiSubCmd + nGrpIdx));
// 写入帧头
*(pbyFrame + 1) = (BYTE)(uiFrmID >> 24);
*(pbyFrame + 2) = (BYTE)(uiFrmID >> 16);
*(pbyFrame + 3) = (BYTE)(uiFrmID >> 8);
*(pbyFrame + 4) = (BYTE)(uiFrmID);
}
帧ID = (CmdType << 8) | (SubCmd + GrpIdx),组号编码在ID低字节中,最多支持16组。
核心查询命令(CmdType=0x42):
| SubCmd | 含义 | 关键数据 |
|---|---|---|
| 0x10 | ESMB 总体信息1 | 总压、总流、温度、SOC、SOH |
| 0x20 | ESMB 总体信息2 | 充/放电截止电压、最大充/放电电流 |
| 0x30 | ESMB 总体信息3 | 最高/最低单体电压及位置 |
| 0x40 | ESMB 总体信息4 | 最高/最低单体温度及位置 |
| 0x50 | ESMB 总体信息5 | 运行状态、故障/告警/保护位图 |
| 0x80 | ESMB 总体信息8 | 充电禁止/放电禁止标志 |
| 0x90 | ESMB 总体信息9 | 扩展故障位图 |
解析示例(0x4210帧):
case BATT_R_ESMB_GRP_42_10: // 0x10
{
s_pl_BattRunData[nGrpIdx].fPackTotalVolt =
((float)CAN_StringToUShort(pFrm->byData)) * 0.1; // 0.1V精度
s_pl_BattRunData[nGrpIdx].fPackTotalCurr =
((float)CAN_StringToUShort(pFrm->byData + 2)) * 0.1 - 3000; // 偏移3000
s_pl_BattRunData[nGrpIdx].fMainControllerTemperature =
((float)CAN_StringToUShort(pFrm->byData + 4)) * 0.1 - 100; // 偏移100
s_pl_BattRunData[nGrpIdx].bySOC = *(pFrm->byData + 6);
s_pl_BattRunData[nGrpIdx].bySOH = *(pFrm->byData + 7);
}
注意电流的偏移量是3000:原始值0对应-3000A,原始值30000对应0A,这是为了用无符号数表示有符号电流。
控制命令(CmdType=0x82):
// 唤醒命令
ERR_CODE_CAN_R Set_SleepOrAwake(int nIdx, void* pRawData, void* pSetData)
{
BOOL bIsEnterSleepMode = (BOOL)(*(int*)pSetData);
frm.byData[0] = (bIsEnterSleepMode == TRUE) ? 0x55 : 0xAA;
ERR_CODE_CAN_R emRet = PL_Do_Comm(pBattData, &frm, 0x82, 0x00, nIdx, TRUE);
...
}
// 充放电命令
ERR_CODE_CAN_R Set_ChrgOrDischrg(int nIdx, void* pRawData, void* pSetData)
{
(bIsChrgCmd == TRUE) ? (frm.byData[0] = 0xAA) : (frm.byData[1] = 0xAA);
ERR_CODE_CAN_R emRet = PL_Do_Comm(pBattData, &frm, 0x82, 0x10, nIdx, TRUE);
...
}
3.2 ND V3.0协议(can_nd_batt.c)——被动监听模式
ND V3.0与PL协议有本质区别:BMS主动广播,PCS被动接收。PCS唯一主动发出的是C2B心跳帧。
地址体系:
- BMS地址:
0x91~0x9A(最多10组) - PCS地址:
0xB9~0xC2
帧ID解析:
BOOL JudgeAndParseMsgID(A_CAN_ND_BATT_FRAME* pFrame)
{
UINT uiRawHead = ((UINT)pFrame->byRawData[1]) << 24
| ((UINT)pFrame->byRawData[2]) << 16
| ((UINT)pFrame->byRawData[3]) << 8
| ((UINT)pFrame->byRawData[4]);
pFrame->byBMSGrpAddr = (BYTE)uiRawHead; // 低8位:BMS地址
pFrame->bySubCmd = (BYTE)(uiRawHead >> 8); // 次低8位:子命令
pFrame->byCmdType = (BYTE)(uiRawHead >> 16); // 次高8位:命令类型
pFrame->byResv1 = (BYTE)(uiRawHead >> 24); // 高8位:保留
...
}
子命令映射:
| SubCmd | 含义 |
|---|---|
| 0x8F | B2C状态帧(SOC、运行状态、PowerON状态) |
| 0x90 | SumData1(总压、总流) |
| 0x91 | SumData2(剩余充放电能量、SOH) |
| 0x93 | SumData3(最高/最低单体电压温度) |
| 0x92 | Limit(最大充放电电流、截止电压) |
C2B心跳帧(每500ms发送一次):
static void Send_C2B_Status(STBATT_SAMPLER_ROUGH *pSTBatt_Rough, int nPCSIdx)
{
BYTE bySubCmd = 0x8F;
PackSendFrameData(pSTBatt_Rough, nPCSIdx, bySubCmd, pbySendBuf);
pSTBatt_Rough->pComm->Write(pSTBatt_Rough->hCanDevHanle, pbySendBuf, CAN_FRAME_LEN);
}
心跳帧中包含一个0~15的滚动生命信号,BMS通过检测该信号判断PCS是否在线。
3.3 ND V3.3协议(can_nd_v33_batt.c)——扩展广播模式
V3.3是V3.0的演进版本,主要变化:
- 地址数量缩减:最多只支持4组BMS(
{0x91, 0x92, 0x93, 0x94}),注释明确说明"Over 4 will Cause SW Reboot"。 - 帧ID从8位扩展到29位:完整利用CAN扩展帧ID空间。
- 增加更多数据类型:S2A告警、INFY专用告警/故障/GPIO、BMS升级状态。
- 增加本地升级地址:
{0x32, 0x33, 0x34, 0x35}用于OTA刷机协议。
V3.3命令注册(ND_V33_Batt_Init):
TAG_ND_BATT_PRO *p = &s_nd_BattPro[0];
INIT_NDBATT_CMD(p, _CAN_R_CMD_B2C_STATU, 0x040E8F, Parse_B2C_Status);
INIT_NDBATT_CMD(p, _CAN_R_CMD_B2C_SUMDATA1, 0x040E90, Parse_B2C_SumData1);
INIT_NDBATT_CMD(p, _CAN_R_CMD_B2C_SUMDATA2, 0x040E91, Parse_B2C_SumData2);
INIT_NDBATT_CMD(p, _CAN_R_CMD_B2C_SUMDATA3, 0x040E93, Parse_B2C_SumData3);
INIT_NDBATT_CMD(p, _CAN_R_CMD_B2C_LIMIT, 0x040E92, Parse_B2C_Limit);
INIT_NDBATT_CMD(p, _CAN_R_CMD_S2A_ALARM, 0x0701FF, Parse_S2A_Alarm);
INIT_NDBATT_CMD(p, _CAN_R_CMD_S2A_SUMDATA3, 0x0B03FF, Parse_S2A_SumData3);
INIT_NDBATT_CMD(p, _CAN_R_CMD_S2A_SUMDATA4, 0x0B04FF, Parse_S2A_SumData4);
INIT_NDBATT_CMD(p, _CAN_R_CMD_B2C_INFYALARM, 0x1435FF, Parse_B2C_InfyAlarm);
INIT_NDBATT_CMD(p, _CAN_R_CMD_B2C_INFYFAULT, 0x1436FF, Parse_B2C_InfyFault);
INIT_NDBATT_CMD(p, _CAN_R_CMD_B2C_INFYGPIO, 0x1438FF, Parse_B2C_InfyGPIO);
INIT_NDBATT_CMD(p, _CAN_R_CMD_B2C_UPGRADESTATU, 0x1CFA02, Parse_B2C_UpgradeStatu);
3.4 Infy协议(can_infy_batt.c)——独立UDS升级协议
Infy(亿纬锂能)协议在V3.3的基础上,增加了完整的UDS(Unified Diagnostic Services)固件升级协议支持。升级流程涉及:握手(0x10)、解锁(0x27)、交换密钥(0x27)、写请求(0x2E)、例行控制(0x31)、下载请求(0x34)、数据传输(0x36)、退出传输(0x37)、重置ECU(0x11)。
升级地址空间定义(来自头文件):
#define BMU_PROGRAM_ADDRESS 0x014000
#define BSU_PROGRAM_ADDRESS 0x0800A000
#define BMU_AUX_PROGRAM_ADDRESS 0x0000A000
#define IMEU2_PROGRAM_ADDRESS 0x0009800
#define IMEU3_PROGRAM_ADDRESS 0x0800C800
#define IMEU3_AIR_ADDRESS 0x08008000
四、四种协议对比总表
| 维度 | PL协议 | ND V3.0 | ND V3.3 | Infy |
|---|---|---|---|---|
| 通信模式 | 主从请求-应答 | BMS广播+PCS心跳 | BMS广播+PCS心跳 | BMS广播+UDS升级 |
| 最大组数 | 16 | 10 | 4(超出崩溃) | 4 |
| 帧ID位数 | 16位有效 | 16位有效 | 29位全用 | 29位全用 |
| CRC校验 | 无 | SAE J1850 CRC8 | SAE J1850 CRC8 | SAE J1850 CRC8 |
| 控制方式 | 直接发CAN命令 | 心跳帧携带CMD字节 | 心跳帧携带CMD字节 | 心跳+UDS |
| 环境告警 | 无 | 无(外挂IMEU) | mapSysFault聚合 | 同V3.3 |
| OTA升级 | 无 | 无 | 基础支持 | 完整UDS |
五、bIsBatt_ChargeForbidden / bIsBatt_DisChargeForbidden 触发条件大汇总
这是上层调度最关心的两个标志,触发路径在各协议中各有不同。
5.1 PL协议:0x4280帧显式携带
case BATT_R_ESMB_GRP_42_80:
{
// byData[0] == 0xAA → 禁止充电
s_pl_BattRunData[nGrpIdx].bIsBatt_ChargeForbidden =
((*(pFrm->byData)) == 0xAA) ? TRUE : FALSE;
// byData[1] == 0xAA → 禁止放电
s_pl_BattRunData[nGrpIdx].bIsBatt_DisChargeForbidden =
((*(pFrm->byData + 1)) == 0xAA) ? TRUE : FALSE;
}
同时,当检测到故障(mapFault > 0)或保护(mapProtect > 0)时,聚合层会强制将最大充放电电流清零:
if(s_pl_BattRunData[n].mapFault.byFlag > 0)
{
pSingleRunInfo->fAllow_MaxDisChargeCurr = 0.0;
pSingleRunInfo->fAllow_MaxChargeCurr = 0.0;
pSingleRunInfo->emBattStatus_Run = STBATT_RUNSTAT_SLEEP;
}
5.2 ND V3.0:由运行状态字推导
switch (s_nd_BattRunData[n].byBattRunStatus)
{
case 2: // Full Charge
bGrpNeedForbid_Charge = TRUE;
pSingleRunInfo->emBattStatus_Run = STBATT_RUNSTAT_SLEEP;
break;
case 3: // Full Discharge
bGrpNeedForbid_DisCharge = TRUE;
pSingleRunInfo->emBattStatus_Run = STBATT_RUNSTAT_SLEEP;
break;
case 4: // Power-Limited Alarm
case 5: // Fault
bGrpNeedForbid_Charge = TRUE;
bGrpNeedForbid_DisCharge = TRUE;
pSingleRunInfo->emBattStatus_Run = STBATT_RUNSTAT_SLEEP;
break;
}
5.3 ND V3.3:运行状态 + 告警层叠逻辑
V3.3的逻辑更精细,在运行状态基础上叠加了BMS告警等级判断:
// 当处于告警状态时(mapNDAlarm.byFlag > 0)
// 不直接禁止,而是看最大允许电流是否为0来决定
if((s_nd_BattRunData[n].fAllow_MaxChargeCurr < 1.0) &&
(s_nd_BattRunData[n].fAllow_MaxDisChargeCurr < 1.0))
{
pSingleRunInfo->emBattStatus_Run = STBATT_RUNSTAT_SLEEP;
pSingleRunInfo->fAllow_MaxDisChargeCurr = 0.0;
pSingleRunInfo->fAllow_MaxChargeCurr = 0.0;
}
else
{
// BMS虽然告警,但仍允许一定电流,继续运行
pSingleRunInfo->emBattStatus_Run = STBATT_RUNSTAT_IDLE;
}
另外,V3.3还增加了SOC保护层:
if(pSingleRunInfo->nSOC <= (pSTBatt_Rough->nProtectSOC + pSTBatt_Rough->nPVSysBattRetSOC))
{
if((pSingleRunInfo->nSOC <= pSTBatt_Rough->nProtectSOC) ||
(pSingleRunInfo->emLast_BattStatus_Run == STBATT_RUNSTAT_SLEEP))
{
pSingleRunInfo->fAllow_MaxDisChargeCurr = 0.0;
pSingleRunInfo->emBattStatus_Run = STBATT_RUNSTAT_SLEEP;
}
}
5.4 多组聚合:一票否决制
在 Map_BattRawData_2Rough() 中,对多组BMS的禁止标志采用OR聚合:
// PL协议聚合示例
bGrpNeedForbid_Charge = s_pl_BattRunData[n].bIsBatt_ChargeForbidden ? TRUE : FALSE;
...
pGrpRunInfo->bIsBatt_ChargeForbidden = bGrpNeedForbid_Charge;
pGrpRunInfo->bIsBatt_DisChargeForbidden = bGrpNeedForbid_DisCharge;
任意一组BMS发出禁止信号,整个电池组就停止充电或放电——这是储能系统安全设计的基本原则。
六、CAN帧丢失、超时与CRC错误的工程处理策略
6.1 超时检测:计数器递增机制
// BATT_COMM_FAIL_TIMES = 40,轮询周期约100ms
// 即4秒无响应 → 通信故障
void Proc_Comm_Statu(STBATT_SAMPLER_ROUGH *pBattData)
{
for(n = 0; n < MAX_SINGLE_STBATT_NUM; n++)
{
if(n >= pBattData->nSystemCfgBattNum) continue;
if(s_pl_BattRunData[n].bFoundReply == FALSE)
{
if(s_pl_BattRunData[n].nInterrupt_Times <= BATT_COMM_FAIL_TIMES)
s_pl_BattRunData[n].nInterrupt_Times++;
}
else
{
s_pl_BattRunData[n].nInterrupt_Times = 0; // 收到则清零
}
s_pl_BattRunData[n].bIsCommFail =
(s_pl_BattRunData[n].nInterrupt_Times > BATT_COMM_FAIL_TIMES)
? TRUE : FALSE;
if(s_pl_BattRunData[n].bIsCommFail)
{
s_pl_BattRunData[n].bBattInfo_Regisited = FALSE; // 需要重新注册
}
}
}
bFoundReply 在每次采样开始时置FALSE,收到有效帧时置TRUE。这样只要连续40次(约4秒)未收到任何有效帧,就判定为通信故障。
6.2 通信故障后的安全动作
V3.3协议在通信故障时还会触发继电器动作:
if(s_nd_BattRunData[n].bIsCommFail)
{
s_nd_BattRunData[n].bBattInfo_Regisited = FALSE;
if(s_nd_BattRunData[n].fPackTotalCurr < 1.0)
{
if(pSTBatt_Rough->bCtrlOutRelayClose[n] == STAT_RELAY_ON)
{
// 电流近似为0时,断开柜内接触器
CtrlBattCabinetContactor(pSTBatt_Rough, n, FALSE);
}
}
}
这里的关键判断是 fPackTotalCurr < 1.0A——只有当前电流接近零时才断接触器,避免带载切断造成拉弧损坏。
6.3 CRC8校验(SAE J1850标准)
V3.3和Infy协议均使用SAE J1850 CRC8,初始值0xFF,异或输出值0xFF:
static uint8_t Crc_ComputeCRC8(const uint16_t Msg_Id,
const uint8_t* Crc_DataPtr,
uint16_t Crc_Length)
{
static const uint8_t Crc8Table[256] = {
0x00u, 0x1du, 0x3au, 0x27u, /* ... 256个查表值 ... */
};
uint8_t crc_value = 0xff;
uint8_t XOR_out = 0xff;
// 先对帧ID低16位做CRC
crc_value = Crc8Table[crc_value ^ (uint8_t)(Msg_Id & 0x00ff)];
crc_value = Crc8Table[crc_value ^ (uint8_t)(Msg_Id >> 8)];
// 再对数据区做CRC
for(uint16_t count = 0; count < Crc_Length; count++)
crc_value = Crc8Table[crc_value ^ Crc_DataPtr[count]];
return crc_value ^ XOR_out;
}
// 使用方式:CRC字节在byRawData[5],校验数据从byRawData[6]开始取7字节
BYTE crcCheck = Crc_ComputeCRC8((uiRawHead & 0xffff), &pFrame->byRawData[6], 7);
注意:CRC校验虽然实现了,但在V3.3代码中是被注释掉的——这是一个工程妥协,因为现场测试发现某些BMS固件版本发出的CRC值有误,为了兼容只能暂时跳过校验。这个问题在生产系统中是需要持续跟踪的技术债。
6.4 数据异常检测与节流日志
static BOOL Parse_B2C_SumData3(A_CAN_ND_BATT_FRAME *pFrmReply)
{
float fMax_CellTemp = ((float)(*(pFrmReply->byData + 5))) - 50;
float fMax_CellVolt = ((float)CAN_StringToUShort(pFrmReply->byData + 1)) * 0.001;
// 物理合理性检查
if((fMax_CellTemp > 127) || (fMin_CellTemp > 127) ||
(fMax_CellVolt > 10.0) || (fMin_CellVolt > 10.0))
{
// 节流:每60秒最多记录一次,避免日志洪泛
if(ABS(_NOW_ - s_nd_BattRunData[nBMSGrpMapIdx].t_LastRecvAbnormalData) > 60)
{
s_nd_BattRunData[nBMSGrpMapIdx].t_LastRecvAbnormalData = _NOW_;
LogOut(MAIN_MODULE_SAMPLER, "BAT", LOG_TYPE_ERROR,
"B2CSD3#Id[%d]%02X%02X%02X%02X%02X|%02X%02X...",
nBMSGrpMapIdx, pFrmReply->byRawData[0], ...);
return FALSE; // 丢弃该帧,保留上次正常值
}
}
// 通过校验才更新
s_nd_BattRunData[nBMSGrpMapIdx].fMax_CellVolt = fMax_CellVolt;
...
}
节流日志(throttled logging)是嵌入式系统的标准做法:异常帧不更新数据(保留上次有效值),但每60秒记录一次原始字节,供后期排查。
七、多BMS并联的地址仲裁与数据聚合
7.1 地址仲裁
每组BMS有固定的CAN地址,PCS通过帧ID的地址字段区分不同的BMS:
static BYTE szBMSGrpMapIdx[] = {0x91, 0x92, 0x93, 0x94}; // V3.3最多4组
static BOOL CompBMSGrpMapIdx(BYTE nBMSGrpAddr, int *nBMSGrpMapIdx)
{
for(int n = 0; n < sizeof(szBMSGrpMapIdx)/sizeof(szBMSGrpMapIdx[0]); n++)
{
if(nBMSGrpAddr == szBMSGrpMapIdx[n])
{
*nBMSGrpMapIdx = n; // 返回组索引0~3
return TRUE;
}
}
return FALSE;
}
V3.3还额外支持Infy升级协议地址(0x32~0x35),与普通BMS地址并行存在:
// Infy UDS升级地址(与运行地址不同!)
static BYTE szLocalBMSGrpMapIdx[] = {0x32, 0x33, 0x34, 0x35};
7.2 线程安全的CAN会话
PCS周期性采样和临时控制命令可能并发,必须用互斥锁保护CAN读写:
static ERR_CODE_CAN_R Do_CanSession(STBATT_SAMPLER_ROUGH *pBattData,
CAN_BATT_SAMPLE_PROC pfnHandler,
int nIdx, BOOL bIsSetCmd, void* pSetData)
{
if(bIsSetCmd)
{
// 控制命令:必须等到锁,不能超时跳过
Mutex_Lock(pBattData->hSamplerLock, WAIT_INFINITE);
}
else
{
// 采样命令:100ms超时,超时则跳过本次采样
if(Mutex_Lock(pBattData->hSamplerLock, MAX_CAN_SAMPLER_LOCK_WAIT) != ERR_MUTEX_OK)
return ERR_CAN_R_NO_LOCK;
}
ERR_CODE_CAN_R emRet = pfnHandler(nIdx, (void*)pBattData, pSetData);
Mutex_Unlock(pBattData->hSamplerLock);
return emRet;
}
这里有个重要设计决策:控制命令用 WAIT_INFINITE,采样命令用有限超时。原因是:控制命令(如紧急停机)不能因为锁竞争而丢失;但采样命令如果本次拿不到锁,下次再采即可,不必阻塞。
7.3 在线数量监控与告警
static int s_LastNumbStatu = 0;
if(pSTBatt_Rough->nSystemCfgBattNum != pSTBatt_Rough->blobGroupBattInfo.nTotalOnlineBatt)
{
// 配置数量 ≠ 在线数量 → 触发告警
varV.emValue = 1;
Push_VarValue(SETTYPE_RAW_SP, pSTBatt_Rough->hSampler, 500, 504, varV);
}
nSystemCfgBattNum 是配置文件中设定的期望电池组数,nTotalOnlineBatt 是实际在线数量,两者不等时触发500号采样器的504号告警点。
八、AI结合:用异常检测模型识别早期故障
8.1 现有数据基础
我们的系统每10秒记录一次数据,最多保留24小时(MAX_OCV_REC_NUM = 6*60*24)。每条记录包含:
- 总压、总流、SOC、SOH
- 最高/最低单体电压和温度
- 告警位图、故障位图
这是训练异常检测模型的良好数据基础。
8.2 推荐方案:Isolation Forest + 滑动窗口
对于嵌入式部署,我推荐Isolation Forest,因为:
- 无需标注数据(无监督)
- 模型小,可部署在ARM Cortex-A处理器上
- 推理速度快(微秒级)
# 离线训练(PC端)
from sklearn.ensemble import IsolationForest
import numpy as np
# 特征工程:取最近N帧的统计特征
def extract_features(frames):
return np.array([
frames['fMax_CellVolt'].mean(),
frames['fMin_CellVolt'].mean(),
frames['fMax_CellVolt'].std(), # 电压不一致性
frames['fMax_CellTemp'].max(),
frames['fPackTotalCurr'].mean(),
frames['mapNDAlarm'].sum(), # 告警频次
])
model = IsolationForest(contamination=0.01, n_estimators=100)
model.fit(training_features)
# 导出为ONNX格式,部署到嵌入式Linux
import skl2onnx
onnx_model = skl2onnx.convert_sklearn(model, ...)
8.3 嵌入式侧集成点
在 Parse_B2C_SumData3() 的数据通过合理性校验后,将特征送入模型:
// 伪代码:在数据更新后调用AI推理
void UpdateAndInfer(int nBMSIdx)
{
// 更新滑动窗口
PushToWindow(&g_featureWindow[nBMSIdx], ExtractFeature(nBMSIdx));
// 每60秒推理一次
if(ABS(_NOW_ - g_lastInferTime[nBMSIdx]) > 60.0)
{
g_lastInferTime[nBMSIdx] = _NOW_;
float anomalyScore = IsolationForest_Infer(&g_featureWindow[nBMSIdx]);
if(anomalyScore < -0.5) // 异常分数阈值
{
LogOut(MAIN_MODULE_SAMPLER, "AI", LOG_TYPE_WARN,
"BMS[%d] anomaly detected, score=%.3f", nBMSIdx, anomalyScore);
// 触发早期预警,而不是立即告警
Push_VarValue(SETTYPE_RAW_SP, pSTBatt_Rough->hSampler,
600 + nBMSIdx, 499, varV);
}
}
}
8.4 典型可检测异常模式
| 异常类型 | 特征信号 | 时间提前量 |
|---|---|---|
| 单体电池加速老化 | 单体电压差异度缓慢增大 | 数周~数月 |
| 内阻增大 | 充放电时端电压波动加剧 | 数天~数周 |
| 温度传感器漂移 | 温度值异常平稳(传感器粘连) | 即时 |
| 均衡电路故障 | 停止充电后单体电压持续分化 | 数小时 |
| CAN通信噪声增大 | 帧错误率上升,数据抖动增大 | 即时 |
九、工程经验总结
经历了这个项目的完整开发周期,我有几点深刻体会:
1. 协议兼容性是永恒的痛。
BMS厂商的协议文档和实际固件行为往往存在偏差。V3.3的CRC校验被注释掉这件事,根本原因是某款BMS固件的CRC计算有bug,但厂商升级周期很长,我们只能在PCS侧做妥协。建议在协议层加入版本协商机制,而不是在代码里写死行为。
2. 地址硬编码是技术债。
szBMSGrpMapIdx[]={0x91, 0x92, 0x93, 0x94} 这种硬编码,在客户现场更换BMS品牌或扩容时会成为麻烦。应该改为配置文件驱动。
3. 采样帧的合理性校验不能省。
我们处理过fMax_CellVolt = 65.535V(原始值0xFFFF)的情况——这是BMS通信异常时发出的无效帧。不加物理合理性过滤,这个值会直接触发高压保护告警,误停整个储能系统。
4. 互斥锁粒度要仔细设计。
控制命令必须等锁,采样命令超时退出——这个设计在高负载时避免了采样线程阻塞控制线程,是正确的。但要注意日志操作(LogOut)不要在持锁期间调用,否则可能死锁。
5. AI异常检测的价值在于趋势,而不是瞬时。
基于规则的阈值告警(电压>4.2V、温度>55°C)系统已经做得很好了。AI的价值在于发现缓慢演变的异常趋势,这是规则引擎无法覆盖的盲区。
十、结语
CAN总线在储能系统中的工程实践,远比协议规范书描述的复杂。从socketCAN的内核驱动,到多品牌协议的适配层,再到安全关键的充放电禁止逻辑,每一层都有坑。
这篇文章是我在这个项目中踩过的坑、做过的设计决策的真实记录。希望对同样在做BMS接入开发的工程师有所参考。
代码会更新,协议会演进,但工程实践中的基本原则是稳定的:合理性校验、超时保护、安全优先、可观测性——这四点在任何工业通信协议接入中都不会过时。
本文基于实际工程项目代码分析撰写,部分敏感信息已做脱敏处理。