BMS-PCS通信协议深度解析——CAN总线在储能系统中的工程实践

3 阅读18分钟

BMS-PCS通信协议深度解析——CAN总线在储能系统中的工程实践

作者:薛定谔的悦 日期:2026-04-14
项目背景:工商业储能管理系统(BESS),支持多品牌BMS并联接入,通过CAN总线与PCS(Power Conversion System)双向通信


一、为什么是CAN总线?

刚加入这个项目时,我问了自己同样的问题。为什么储能系统不用Modbus RTU,不用RS485,而偏偏选择CAN总线?

原因是多层次的:

  1. 实时性:CAN总线的优先级仲裁机制保证了高优先级帧(如保护命令)在总线竞争中必然胜出,最坏情况延迟可以精确计算。
  2. 可靠性:CAN协议内置CRC校验、位填充、帧错误检测,单个节点故障不会拉低整条总线。
  3. 多主通信:BMS可以主动广播数据,PCS也可以主动发送控制命令,无需轮询。
  4. 工业生态:主流BMS厂商(宁德时代、亿纬锂能、鹏辉能源等)均提供CAN接口,协议文档相对规范。

然而,理论是美好的,现实是:每家厂商的CAN协议都不一样。这就是我们项目中出现 can_pl_batt.ccan_nd_batt.ccan_nd_v33_batt.ccan_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 对应内核的 can0can2 对应 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含义关键数据
0x10ESMB 总体信息1总压、总流、温度、SOC、SOH
0x20ESMB 总体信息2充/放电截止电压、最大充/放电电流
0x30ESMB 总体信息3最高/最低单体电压及位置
0x40ESMB 总体信息4最高/最低单体温度及位置
0x50ESMB 总体信息5运行状态、故障/告警/保护位图
0x80ESMB 总体信息8充电禁止/放电禁止标志
0x90ESMB 总体信息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含义
0x8FB2C状态帧(SOC、运行状态、PowerON状态)
0x90SumData1(总压、总流)
0x91SumData2(剩余充放电能量、SOH)
0x93SumData3(最高/最低单体电压温度)
0x92Limit(最大充放电电流、截止电压)

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的演进版本,主要变化:

  1. 地址数量缩减:最多只支持4组BMS({0x91, 0x92, 0x93, 0x94}),注释明确说明"Over 4 will Cause SW Reboot"。
  2. 帧ID从8位扩展到29位:完整利用CAN扩展帧ID空间。
  3. 增加更多数据类型:S2A告警、INFY专用告警/故障/GPIO、BMS升级状态。
  4. 增加本地升级地址{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.0ND V3.3Infy
通信模式主从请求-应答BMS广播+PCS心跳BMS广播+PCS心跳BMS广播+UDS升级
最大组数16104(超出崩溃)4
帧ID位数16位有效16位有效29位全用29位全用
CRC校验SAE J1850 CRC8SAE J1850 CRC8SAE 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,因为:

  1. 无需标注数据(无监督)
  2. 模型小,可部署在ARM Cortex-A处理器上
  3. 推理速度快(微秒级)
# 离线训练(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接入开发的工程师有所参考。

代码会更新,协议会演进,但工程实践中的基本原则是稳定的:合理性校验、超时保护、安全优先、可观测性——这四点在任何工业通信协议接入中都不会过时。


本文基于实际工程项目代码分析撰写,部分敏感信息已做脱敏处理。