ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

16串磷酸铁锂BMS源码解析:AFE采集、SOC估算与保护状态机

16串磷酸铁锂BMS源码解析:AFE采集、SOC估算与保护状态机

简介:面向电池管理系统开发者的国内BMS源码包,适合嵌入式工程师、汽车电子学习者及储能项目研发人员研读。内容围绕主机XC2287M与从机MC9S08DZ60的CAN通信架构展开,通信波特率为500kbps,可用于理解电池参数采集、均衡控制、过压欠压与过热保护、SOC估算及分层软件架构等实现思路。压缩包共229个文件,以81个.h头文件、71个.c源文件和50个.o目标文件为主,另含工程配置与烧录相关文件,整体约951KB,结构紧凑,便于按驱动层、应用层和通信模块检索。已有8035人学习,说明其在BMS入门与工程参考方面具备较高关注度。读者可从中获取CAN驱动、ADC采样、温度检测、故障处理与电源管理等代码素材,并对照硬件平台梳理主从机协作流程,积累调试与模块化设计经验。

1. 一套 BMS 源码能解决什么:以 16 串电池包频繁报欠压为例

有开发者拿到一份国内电池管理系统(BMS)的源码,板子焊好、串口能打印、指示灯也会闪,但一接上 16 串磷酸铁锂,跑十几分钟就开始频繁报单体欠压,继电器反复吸合。这类 BMS 源码不是点灯级别的演示工程,它把电压温度采集、SOC 估算、被动均衡、多级保护和 CAN 上报这几件事放进同一个 10ms 调度周期里,其中任何一处的参数配错或者符号搞反,都会在整包层面放大成故障。

这套代码适合谁?做储能小机柜、两轮车与低速车电池包、换电柜的团队,手上已经有一块带采集前端和隔离 CAN 的板子,缺一套能跑起来、能改参数、能接自己协议的固件骨架,它就是冲这个来的。它不负责上位机界面,也不管云端后台,只管板子上那一层实时逻辑。读下去你会看到目录怎么分层、AFE 原始码值怎么换算成毫伏、SOC 怎么用安时积分配合 OCV 修正、保护状态机怎么带迟滞,以及换串数、改报文这类二次开发要动哪几个文件。

2. 工程目录与四层分层:App / Middle / Driver / Bsp 各自负责什么

拿到一份陌生固件,我通常先不急着看main.c,而是把目录树铺开看它怎么分层。分层混乱的工程,改一个串数要翻八个文件;分层清晰的工程,改参数基本只动Config/和一个头文件。这套 BMS 源码属于后者,四层职责切得比较干净,依赖方向是单向的:App 调 Middle 和 Driver,Driver 调 Bsp,反向没有调用。

2.1 目录树逐层拆解

BMS_Firmware/ ├── App/ 应用层:调度、业务逻辑,不直接碰寄存器 │ ├── app_main.c 初始化顺序与主循环入口 │ ├── app_scheduler.c 10ms / 100ms / 1s 三级软定时 │ ├── app_soc.c 安时积分 + OCV 修正 │ ├── app_balance.c 被动均衡控制 │ ├── app_protect.c 三级保护状态机 │ └── app_report.c CAN 报文组包与上报 ├── Middle/ 与硬件无关的通用件,可整体搬到别的项目 │ ├── mb_filter.c 滑动平均 / 一阶低通 │ ├── mb_crc.c CRC8 / CRC16 │ ├── mb_ringbuf.c 串口与 CAN 收发环形缓冲 │ └── mb_lut.c NTC 阻值表和 OCV-SOC 表的查表插值 ├── Driver/ 芯片级驱动,只认芯片手册,不认业务 │ ├── drv_afe.c 采集前端:电压、温度、均衡开关 │ ├── drv_current.c 电流采样通道 │ ├── drv_can.c CAN 控制器初始化与收发 │ └── drv_eeprom.c 参数掉电保存 ├── Bsp/ 板级:引脚、时钟、外设初始化 │ ├── bsp_spi.c 采集前端走 SPI 或菊花链 │ ├── bsp_timer.c 1ms 系统 tick │ ├── bsp_gpio.c │ └── bsp_board.h 板子版本号与引脚映射宏 ├── Config/ │ ├── bms_cfg.h 串数、容量、阈值、周期,全项目唯一真相 │ └── bms_param.c 默认参数表与恢复出厂值 ├── Project/ IDE 工程文件、链接脚本、下载配置 └── Doc/ 原理图说明、CAN 报文定义、标定记录

这个结构里最值得学的是Middle/那一层。滤波、CRC、环形缓冲、查表插值这些代码不带任何寄存器操作,换个 MCU 平台照样能编译通过,二次开发时复用率最高。相反,App/里的文件虽然叫应用层,却依赖Config/bms_cfg.h里的宏,改串数必须同步改这里,这点后面会专门说。

实际工程里还有一个不在目录树里的东西:bsp_board.h里的板子版本宏。同一个固件要支持 V1.0 和 V1.2 两块板,常见做法是用宏在编译期裁剪引脚定义,而不是跑起来再判断。我一般会加一个BOARD_REV宏并在app_main.c里打印出来,烧录后第一件事就是看串口打的是不是对的版本。

2.2 一次 10ms 采样任务的完整调用链

主循环怎么组织,直接决定了这套代码能不能改。上 RTOS 当然可以,但 BMS 这种周期性极强的场景,裸机三级软定时反而更好排查。下面这段是调度器的主干:

/* app_scheduler.c —— 裸机三级软定时,全部基于 1ms tick */ void app_scheduler_run(void) { uint32_t now = bsp_tick_get(); /* 1ms 自增,溢出回绕是正常的 */ if ((now - s_last_fast) >= TASK_FAST_MS) { /* 10ms */ s_last_fast = now; drv_afe_sample_all(); /* 只负责采,不判断 */ app_protect_tick(); /* 保护判定紧跟采样,优先级最高 */ } if ((now - s_last_slow) >= TASK_SLOW_MS) { /* 100ms */ s_last_slow = now; app_soc_tick(); /* 吃采样结果算 SOC */ app_balance_tick(); /* 均衡跟随 SOC 与温升 */ } if ((now - s_last_report) >= TASK_REPORT_MS) { /* 100ms */ s_last_report = now; app_report_tick(); /* 组包 + 发 CAN,放最后 */ } }

逻辑上做了三件事的排序:采样在前、判定紧跟、上报最后。参数上是三个独立的定时计数器,好处是一个任务超时不会拖垮另外两个;如果用单一的if (now % 10 == 0)写法,一旦某个函数执行超过 10ms,整个节拍就乱了,这是新手特别容易踩的坑。

(now - s_last_fast)这种减法比较是必须的写法,now是 32 位无符号数,49 天回绕一次,直接写now >= s_last + 10在回绕点会卡死。很多人觉得这是玄学,其实只是无符号减法在模 2³² 下天然成立而已。

还有一点:drv_afe_sample_all()只做采集不做滤波,滤波放在驱动内部或者独立的一次转换里。这样保护判定拿到的是原始值和滤波值两套数据,欠压这种需要快速响应的场景用原始值,SOC 积分用滤波值,这个区分后面第 3 章会展开。

2.3 配置头文件与编译期防呆

整套代码里改动频率最高的是bms_cfg.h。这份文件写得好不好,决定了换项目时是改十分钟还是改两天:

/* bms_cfg.h —— 整包参数集中在这里,换项目基本只动这个文件 */ #ifndef BMS_CFG_H #define BMS_CFG_H #define BMS_CELL_NUM 16U /* 串数:改这里必须同步 AFE 使能和 CAN 报文 */ #define BMS_TEMP_NUM 4U /* NTC 通道:2 电芯 + 1 功率管 + 1 环境 */ #define BMS_CAPACITY_AH_X100 10000U /* 额定容量,单位 0.01Ah,100Ah 写 10000 */ #define BMS_NOMINAL_MV 3200U /* 单体标称电压,mV */ /* 保护阈值,单位 mV,恢复值单独给,形成迟滞 */ #define OV_PROTECT_MV 3650U #define OV_RECOVER_MV 3550U #define UV_PROTECT_MV 2500U #define UV_RECOVER_MV 2650U /* 调度周期,单位 ms,必须是 tick 的整数倍 */ #define TASK_FAST_MS 10U #define TASK_SLOW_MS 100U #define TASK_REPORT_MS 100U /* 编译期防呆:CAN 电压报文按 8 串一帧打包,串数不是 8 的倍数会错位 */ #if (BMS_CELL_NUM % 8U) != 0U #error "BMS_CELL_NUM 必须是 8 的整数倍,否则电压报文打包会错位" #endif #endif /* BMS_CFG_H */

这里有几个刻意的写法。单位写进宏名,_MV、_X100、_MS一眼能看出来量纲,能挡掉一大半「把伏当毫伏填进去」的事故。整型常量带U后缀,避免在 16 位机上出现有符号溢出。最下面那个#error是编译期断言,串数填成 12 就直接编不过,比烧进去跑起来再查报文错位省事得多。

我一般还会再加一条断言,把容量和 SOC 分母的关系卡死:

#if (BMS_CAPACITY_AH_X100 == 0U) #error "容量不能为 0,SOC 计算会除零" #endif

这类防呆不占运行时间,全是预处理阶段搞定,属于典型的「不留后悔药就等着半夜被叫起来」的活。参数表bms_param.c则放运行期可改的默认值,从 EEPROM 读出来覆盖,两者分工要清楚:能编译期定的放宏,要现场标定的放参数表。

3. 采集链路拆解:AFE 寄存器原始值怎么变成可信的毫伏和摄氏度

采集是整条链路的地基。地基歪了,后面 SOC 算得再漂亮也是错的。这一章从采集前端的上电配置讲起,一路走到滤波和断线判定。

3.1 上电初始化顺序与寄存器配置

采集前端(AFE)常见有两类:一类走 SPI 直连,一类走菊花链 UART 逐级转发。两类初始化顺序不一样,但原则相同——先确认器件在位,再配通道,最后才允许打开硬件保护。

/* drv_afe.c —— 上电配置,顺序不能乱 */ int drv_afe_init(void) { bsp_spi_init(); /* 1. SPI 时钟先降频,别超手册上限 */ bsp_delay_ms(5); /* 2. 等器件内部基准稳定,别省这几毫秒 */ if (afe_read_reg(AFE_REG_ID) != AFE_ID_EXPECT) { return -1; /* 3. 读 ID 失败直接返回,不往下配 */ } afe_write_reg(AFE_REG_SYS_CTRL, AFE_SYS_ADC_EN | AFE_SYS_REF_EN); bsp_delay_ms(2); /* 4. 逐串使能;没用到的通道必须显式关闭,悬空通道会报断线 */ for (uint8_t i = 0U; i < BMS_CELL_NUM; i++) { afe_write_reg(AFE_REG_CELL_EN(i), AFE_CELL_EN_BIT); } for (uint8_t i = BMS_CELL_NUM; i < AFE_MAX_CELL; i++) { afe_write_reg(AFE_REG_CELL_EN(i), 0x00U); } /* 5. 硬件比较器阈值先关,保护交给软件状态机统一管 */ afe_write_reg(AFE_REG_OV_TH, 0x00U); afe_write_reg(AFE_REG_UV_TH, 0x00U); /* 6. 均衡默认全关,上电绝不许自己打开 */ afe_write_reg(AFE_REG_BAL_CFG, 0x00U); return 0; }

第 4 步的两层循环是重点。很多板子设计时预留了 16 串,实际只贴 12 串的器件,如果只使能前 12 通道而不关后 4 通道,那 4 个通道采到的是悬空电平,一会儿接近满量程一会儿接近零,断线检测直接误报。所以「使能有效通道」和「关闭无效通道」必须成对出现。

第 5 步关掉硬件比较器,是为了避免两套保护打架。硬件比较器响应快但阈值死,软件状态机能带迟滞和分级,实际项目里通常只用一套。我一般会让硬件比较器留一个更宽的兜底阈值(比如软件阈值加 200mV),当最后一道保险,但默认不开。

3.2 电压与温度通道的换算

寄存器里拿到的只有 12 位原始码值,得换算成毫伏和摄氏度。这一步最容易出现浮点滥用和精度损失:

/* drv_afe.c —— 原始码值换算,全整数运算,单位统一 */ static uint16_t afe_raw_to_mv(uint16_t raw) { /* 基准 2.5V、12 位满量程 4096:mV = raw * 2500 / 4096 */ return (uint16_t)(((uint32_t)raw * 2500U) / 4096U); } static int16_t ntc_raw_to_temp_x10(uint16_t raw) { /* NTC 10K B3950 上拉 10K,先反算阻值再查表插值 */ if (raw == 0U || raw >= 4095U) { return NTC_OPEN_OR_SHORT; /* 开路/短路单独给标志,别混进温度值 */ } uint32_t r_ntc = ((uint32_t)raw * NTC_PULLUP_OHM) / (4095U - raw); return mb_lut_ntc_to_temp_x10((uint16_t)r_ntc); /* 返回 0.1℃ */ }

电压换算里乘 2500 之前必须先转uint32_t,否则raw * 2500在 16 位机上直接溢出回绕,采出来的电压会莫名其妙跳。这个坑我在不止一个项目里见过,现象是电压偶尔出现个位数的乱值,查半天以为是硬件干扰。

温度这边用查表加线性插值,不用log()也不用浮点库。NTC 的阻值温度曲线是非线性的,常见做法是把 -40℃ 到 125℃ 按 5℃ 一档做成常量表,两点之间线性插值,误差能控制在 0.2℃ 以内,对 BMS 足够了。表放在mb_lut.c里,换不同 B 值的 NTC 只换表不换代码。

还有一个细节:raw == 0和raw >= 4095这两种情况不要强行算出一个温度。NTC 开路时 ADC 读数会顶到满量程,短路时接近零,如果你把它当成 -40℃ 或者 125℃ 参与判定,就会出现「环境温度 120℃ 触发过温保护」的假故障。单独给一个错误码,上层看到错误码直接报传感器故障,这才是正确做法。

3.3 滤波、断线判定与异常通道隔离

采集值拿到之后不能直接进算法,得先过一道滤波。滑动平均是最便宜的方案:

/* mb_filter.c —— 滑动平均,窗口 8,压得住采集端的 ±5mV 抖动 */ #define MA_WIN 8U typedef struct { uint16_t buf[MA_WIN]; uint32_t sum; /* 必须是 32 位:16 * 8 = 131072 超过 16 位上限 */ uint8_t idx; } ma_t; uint16_t ma_push(ma_t *f, uint16_t v) { f->sum -= f->buf[f->idx]; /* 先减掉最旧的一个 */ f->buf[f->idx] = v; f->sum += v; f->idx = (uint8_t)((f->idx + 1U) % MA_WIN); return (uint16_t)(f->sum / MA_WIN); }

sum用 32 位这一点必须强调。窗口取 8、单个值最大按 5000mV 算,总和 40000,16 位装得下,但只要有人把窗口改成 16,或者换成电流通道的毫安值,立刻溢出。用 32 位多占 2 字节 RAM,换的是不用担心后续改窗口,这个买卖划算。

滤波窗口并不是越大越好。10ms 采一次、窗口 8 意味着延迟 40ms,对过流这种要求毫秒级响应的保护来说太慢了。所以实际做法是双轨:保护判定用原始值和连续计数滤波,SOC 积分用滑动平均后的值。两套数据在drv_afe_sample_all()里一次性准备好,上层各取所需,不重复采集。

断线判定则靠「连续 N 次越界」而不是单次越界:

/* 连续 5 次(50ms)落在无效区间,才认定该通道断线 */ if (raw > 4090U || raw < 5U) { if (s_invalid_cnt[i] < 255U) s_invalid_cnt[i]++; } else { s_invalid_cnt[i] = 0U; } g_cell_valid[i] = (s_invalid_cnt[i] < 5U);

判定出无效通道之后,上层要有隔离策略。单体电压无效的通道不能参与最高/最低电压统计,否则最大值会取到一个错误值直接触发过压。常见做法是把无效通道排除出极值计算,同时上报一条「采集通道故障」,让整包进入限功率而不是直接切断——采集坏了但电池可能好好的,一刀切断电在有些场景比带故障运行更危险。

4. SOC 估算、被动均衡与保护状态机:三条互相牵制的逻辑

采集之上就是算。SOC 决定用户看到的电量,均衡决定电池包能用多久,保护决定什么时候必须断电。这三块逻辑彼此耦合,SOC 影响均衡开启,均衡发热又影响温度保护,拆开单看都简单,合在一起就容易出问题。

4.1 安时积分的实现与符号约定

安时积分是所有 BMS 的起点,公式简单到不能再简单,但符号约定错一次就够你查一整天。先看代码:

/* app_soc.c —— 每 100ms 调一次;放电为正、充电为负,符号在驱动层统一 */ void app_soc_tick(void) { int32_t i_ma = drv_current_read_ma(); /* 100ms 内的电量增量,单位 0.001mAh:mA * 0.1s / 3600s */ int64_t delta = ((int64_t)i_ma * 100) / 3600; s_acc_mah_x1000 -= delta; /* 放电扣电量 */ if (s_acc_mah_x1000 < 0) { s_acc_mah_x1000 = 0; /* 到底钳住,别让它继续负下去 */ } if (s_acc_mah_x1000 > (int64_t)BMS_CAPACITY_AH_X100 * 1000) { s_acc_mah_x1000 = (int64_t)BMS_CAPACITY_AH_X100 * 1000; } s_soc_x100 = (uint16_t)((s_acc_mah_x1000 * 100) / ((int64_t)BMS_CAPACITY_AH_X100 * 1000)); s_soc_x100 = app_soc_ocv_correct(s_soc_x100); }

参数上,BMS_CAPACITY_AH_X100单位是 0.01Ah,所以 100Ah 的包写 10000,乘 1000 之后得到的是 0.001mAh 精度的总电量。s_soc_x100是百分比乘 100,50.00% 存成 5000,这样避开浮点的同时保留了两位小数。

符号约定是这段代码的生命线。驱动层drv_current_read_ma()必须保证「放电返回正、充电返回负」,如果换了一个采样方向相反的霍尔传感器,就得在驱动里取反,而不是在这里加负号。我见过一个项目两个人都改了符号,结果负负得正,SOC 在充电时反而下降,上位机数据显示电量越充越少,查了两天才定位到。

代码里两道钳位看起来多余,其实必要。长时间静置时积分有累计误差,或者标定时容量填小了,累加值会溢出到负数再回绕,SOC 直接从 0 跳到 100。钳位之后再配合下一节的 OCV 修正,才能长期稳定。

4.2 OCV 静置修正与满充标定

安时积分的问题是误差只增不减,跑一天差 2%,跑一周就差十几个点。修正是必须的,但修正方式要克制:

/* 静置判定:|I| < 200mA 持续 30 分钟,才允许用开路电压修正 */ static uint16_t app_soc_ocv_correct(uint16_t soc_x100) { static uint16_t quiet_cnt = 0U; if (ABS(drv_current_read_ma()) < 200) { if (quiet_cnt < 0xFFFFU) quiet_cnt++; } else { quiet_cnt = 0U; return soc_x100; /* 有电流就不修正,OCV 此时没意义 */ } if (quiet_cnt < QUIET_NEED_CNT) return soc_x100; uint16_t ocv_mv = app_volt_get_pack_avg_mv(); uint16_t soc_ocv = mb_lut_ocv_to_soc(ocv_mv); /* 每次只挪 1%,避免 SOC 突变引起仪表跳格和告警 */ if (soc_x100 + 1U < soc_ocv) return (uint16_t)(soc_x100 + 1U); else if (soc_x100 > soc_ocv + 1U) return (uint16_t)(soc_x100 - 1U); return soc_x100; }

QUIET_NEED_CNT对应 30 分钟静置,按 100ms 周期算就是 18000 次。为什么要求这么长的静置时间?因为电池刚断开负载时,极化电压还没消散,测出来的端电压偏高,这时候查 OCV 表会得到偏高的 SOC。磷酸铁锂的极化尤其顽固,没有半小时以上的静置基本不准。

「每次只挪 1%」是这套实现里最稳的一招。直接把 SOC 赋成 OCV 查表值当然收敛快,但用户会看到电量从 60% 突然跳到 72%,仪表跳格不说,某些系统里 SOC 突变还会触发上层告警。慢拉的代价是收敛慢,好处是行为可预期。

满充和放空标定是另一条硬校准路径,比 OCV 靠谱得多:

/* 充满判定:最高单体到 3.65V 且电流小于 0.02C 持续 3 分钟 */ if (g_max_cell_mv >= OV_PROTECT_MV && i_ma > -2000 && i_ma < 0) { s_full_cnt++; if (s_full_cnt >= 1800U) { /* 1800 * 100ms = 3 分钟 */ s_acc_mah_x1000 = (int64_t)BMS_CAPACITY_AH_X100 * 1000; /* 强制满 */ s_soc_x100 = 10000U; } } else { s_full_cnt = 0U; }

只要一个完整的充放循环经过满充和放空两个点,SOC 就被拉回真值,中间的漂移在下个循环内不会累积太多。这也是为什么实际产品都建议用户「偶尔充满一次」,不是玄学,是算法层面的需求。

4.3 被动均衡的开启条件与温升约束

被动均衡本质就是给电压高的那一串并一个电阻放电,把能量变成热。逻辑简单,但要开得安全得满足一串条件:

/* app_balance.c —— 四个条件同时成立才允许打开某一路均衡 */ void app_balance_tick(void) { uint16_t v_min = afe_get_min_cell_mv(); for (uint8_t i = 0U; i < BMS_CELL_NUM; i++) { uint16_t v = afe_get_cell_mv(i); bool need = (v >= BAL_START_MV) && ((v - v_min) >= BAL_DIFF_MV); bool safe = (g_max_temp_x10 < BAL_TEMP_LIMIT_X10) && (g_min_cell_mv > UV_RECOVER_MV); if (need && safe) afe_balance_on(i); else afe_balance_off(i); } }

参数上,BAL_START_MV一般取 3400mV,低于这个电压不均衡——在 50% SOC 以下做均衡纯属浪费,电压曲线太平,判不准。BAL_DIFF_MV取 30mV,低于 30mV 的差异不处理,否则来回开关震荡。BAL_TEMP_LIMIT_X10取 450 也就是 45℃,温度高了必须先停,均衡电阻本身就在发热,叠加环境高温容易把板子烤坏。

这里有个容易被忽略的耦合:均衡一开,被均衡那一路的采样电压会因为均衡电阻上的压降而偏低,看起来像是电压掉下去了,于是need条件不成立,均衡关闭,电压又回来,反复开关。解决办法是均衡期间跳过该通道的采样,或者用均衡开关前的最后一次值参与判定。这条在下一章的避坑清单里会详细说。

还有一种做法是「轮流均衡」,每次只开一路,30 秒轮换一次。好处是发热集中在一个电阻上但时间短,散热设计简单;坏处是整体均衡速度慢。串数多、容量大的包一般用轮换,串数少的可以同时开多路。

4.4 三级保护状态机与迟滞设计

保护逻辑写成一堆 if-else 也能跑,但出问题时没法定位。用状态机表达分级,事后看日志一眼能知道卡在哪一级:

typedef enum { PROT_NORMAL = 0, /* 正常 */ PROT_WARN, /* 告警:降功率,不切断 */ PROT_FAULT, /* 故障:切断充放电 */ PROT_LOCK /* 闭锁:必须人工或主控复位 */ } prot_level_t; void app_protect_tick(void) { /* 连续 3 次(30ms)越限才动作,滤掉采样毛刺 */ if (g_max_cell_mv >= OV_PROTECT_MV || g_min_cell_mv <= UV_PROTECT_MV) { s_fault_cnt++; } else { s_fault_cnt = 0U; } if (s_fault_cnt >= 3U && s_level < PROT_FAULT) { s_level = PROT_FAULT; bsp_mosfet_charge_off(); /* 先断充电 */ bsp_mosfet_discharge_off(); /* 再断放电 */ bsp_buzzer_on(); } /* 恢复必须带迟滞,且需要持续满足 30 秒 */ if (s_level == PROT_FAULT) { if (g_max_cell_mv <= OV_RECOVER_MV && g_min_cell_mv >= UV_RECOVER_MV) { s_recover_cnt++; if (s_recover_cnt >= 300U) { /* 300 * 100ms = 30 秒 */ s_level = PROT_LOCK; s_recover_cnt = 0U; } } else { s_recover_cnt = 0U; } } }

三级的设计思路是:告警级只降功率,比如限流到 0.5C;故障级断开 MOS;闭锁级进故障后不自动恢复,必须等主控下发复位命令或者人手动断电重启。为什么要有闭锁?因为有些故障是间歇性的,自动恢复会导致继电器反复吸合,触点寿命直接打对折,严重的还会拉弧。这个血泪经验在换电柜项目里尤其明显。

迟滞和确认时间这两个参数要一起调。确认时间太短容易误动作,太长又保护不及时。过压用 30ms、欠压用 30ms、过流用 10ms、温度用 1 秒,这是我在几个项目里试出来的经验值,思路是按故障发展的快慢来定:电流变化最快,温度最慢。

4.5 CAN 报文打包与周期调度

最后是上报。报文打包出错的典型现象是上位机能收到帧但数据跳格,或者某个字段永远是 0:

/* app_report.c —— 整包汇总帧,小端字节序,100ms 一帧 */ void app_report_tick(void) { uint8_t d[8]; /* byte0-1:总压,单位 0.1V */ uint16_t pack_x10v = (uint16_t)(g_pack_mv / 100U); d[0] = (uint8_t)(pack_x10v & 0xFFU); d[1] = (uint8_t)(pack_x10v >> 8); /* byte2-3:电流,单位 0.1A,有符号;偏移 +3000A 避免负数 */ int16_t i_x10a = (int16_t)(g_current_ma / 100); uint16_t i_off = (uint16_t)(i_x10a + 30000); d[2] = (uint8_t)(i_off & 0xFFU); d[3] = (uint8_t)(i_off >> 8); /* byte4-5:SOC,单位 0.01% */ d[4] = (uint8_t)(g_soc_x100 & 0xFFU); d[5] = (uint8_t)(g_soc_x100 >> 8); /* byte6:最高温度,偏移 +40℃;byte7:保护状态 */ d[6] = (uint8_t)(g_max_temp_x10 / 10 + 40); d[7] = (uint8_t)g_prot_level; drv_can_send(CAN_ID_PACK_STATUS, d, 8U); }

电流用偏移量编码是有意为之:CAN 报文里没有类型信息,接收方如果按无符号解析,负电流会变成一个巨大的正数。加 3000A 偏移后全部落在正区间,接收方减回去就行。这个偏移量写进报文定义文档里,Doc/目录那份就是干这个的。

drv_can_send()内部通常是个发送邮箱队列,如果队列满了要返回失败。失败时的处理策略很关键:丢掉这一帧还是阻塞等待?BMS 上报是周期性的,丢一帧下一帧 100ms 后就补上了,阻塞等待反而可能拖垮整个调度。所以我在驱动里加了一个发送失败计数器,串口能打出来,连续失败说明总线负载过高或者波特率配错,这是排查时的第一手线索。

单体电压帧要按 8 串一帧拆,BMS_CELL_NUM % 8 == 0那个编译期断言就是为这个服务的。16 串就是两帧,每帧 8 个字节各放一个通道的电压偏移值。串数改成 12 的话断言会拦下来,逼你想清楚 12 串怎么拆——是拆成 8+4 还是干脆两帧各 6 串。这种事推给编译器拦,比推到现场发现报文错位强太多了。

5. 避坑与排查:采样跳变、SOC 漂移、CAN 丢帧这些现场故障

代码看着通顺不代表能跑稳。下面这几条都是实际项目里反复出现的故障,每条按现象、原因、解决的顺序写,方便你对照自己的板子。

5.1 五条高频故障记录

第一条:均衡一开,被均衡的那串电压就掉 80mV。

现象是均衡指示灯亮起之后,该通道电压从 3.42V 掉到 3.34V,掉出均衡门槛,均衡关闭,电压又弹回 3.42V,一秒内开关好几次。原因是均衡电阻和采样点共用同一条走线,均衡电流在走线电阻上产生压降,叠加到了采样端。

解决办法有两个层次。硬件上把采样点和均衡回路分开走线,采样点在电芯正极附近单独引一根线出来;软件上在均衡期间跳过该通道采样,用开启均衡前的缓存值参与判定。我一般两个都做,硬件改不了的时候软件兜住。

第二条:SOC 充电时反而下降,越充越少。

现象是插上充电机,电流显示正常,但 SOC 一路往下掉,充满后显示 40%。原因是电流符号约定不统一,采集通道认为充电为正,积分逻辑认为放电为正,符号反了。

解决方法是把符号约定写死在驱动层并加注释,上层只认「放电为正」。换传感器或者换采集方案时,先拿万用表比对一次读数符号,再跑积分。这事听起来低级,但项目里两个人各改一处的概率比你想象的高。

第三条:CAN 报文间歇性丢帧,上位机曲线出现断格。

现象是上位机每十几秒丢一两帧,其他时间正常。查总线负载不到 30%,不像是拥塞。真正的原因是在主循环里直接调用发送,没检查邮箱状态,忙的时候发送失败被静默丢弃。

解决办法是驱动层维护一个发送失败计数并打日志,业务层用「组包和发送分离」的方式,发送失败下一周期重发当前快照。如果丢帧集中在某个特定时刻,比如继电器动作的瞬间,那要考虑是不是电磁干扰导致总线错误帧,这时候得从硬件滤波和屏蔽上找原因。

第四条:断电重启后参数丢失,SOC 从 0 开始。

现象是标定好的容量和阈值,断电再上电全变回默认值,SOC 显示 0%。原因是 EEPROM 写入时机不对,在掉电中断里写,写到一半电就没了,数据区变成半新半旧。

解决办法是双备份加 CRC 校验:参数区存两份,每份带版本号和 CRC,读的时候两份都校验,哪份有效用哪份。写入不在掉电中断里做,改成参数变化后延时 5 秒写入,配合一个「写入中」标志。掉电中断里只置标志不写数据。这样最坏情况是丢最后一次改动,不会整片数据损坏。

第五条:低温环境下充电保护误触发。

现象是冬天早上第一次上电,环境温度显示 -25℃,实际气温只有 -5℃,系统禁止充电。原因是 NTC 分压电路的上拉电源和 ADC 基准不是同一个源,上拉电压随温度漂移,算出来的阻值整体偏大,查表就落到低温区。

解决办法有两个:一是硬件上让分压上拉和 ADC 基准同源,这是根治;二是软件上做两点标定,在已知温度下记录 ADC 原始值,反算修正系数。软件标定只能缓解,换了板子还得重标,所以能改硬件就改硬件。

5.2 排查工具箱:日志分级与离线回放

上面这些故障,有一半是靠日志定位的,另一半是靠回放复现的。这套代码里我建议加两样东西。

第一样是分级日志。串口输出不要一股脑全打,按 ERROR / WARN / INFO / DEBUG 四级,用宏控制编译进去哪些级别。发布版本只留 ERROR 和 WARN,调试版本全开。日志格式统一成「时间戳 级别 模块 内容」,比如[0012345] WARN protect ov cell=3 mv=3661,抓一次现场日志就能看出是哪个通道、什么时刻、什么值触发的。

/* mb_log.h —— 编译期裁剪日志级别,不占发布版空间 */ #define LOG_LEVEL_ERROR 1 #define LOG_LEVEL_WARN 2 #define LOG_LEVEL_INFO 3 #define LOG_LEVEL_DEBUG 4 #define LOG_LEVEL_NOW LOG_LEVEL_WARN #if LOG_LEVEL_NOW >= LOG_LEVEL_WARN #define LOG_W(fmt, ...) log_printf("[%lu] WARN " fmt, bsp_tick_get(), ##__VA_ARGS__) #else #define LOG_W(fmt, ...) do {} while (0) #endif

do {} while (0)这个写法是为了让宏在使用时能加分号,同时不产生空语句警告。日志函数本身要注意不能在中断里调用阻塞式串口发送,常见做法是写环形缓冲,主循环里慢慢吐。

第二样是离线回放。把一段时间内的采样数据(每 100ms 一条:16 路电压、4 路温度、电流)存成二进制文件,格式就是定长结构体加 CRC。然后在 PC 上写个小程序,把这份数据喂给移植过去的保护算法和 SOC 算法,看输出对不对。

这套东西的价值在于可复现。现场出的问题,只要抓到一段数据,回来就能反复跑,不用守着电池包等故障重现。我给几个项目都加了这个功能,存储介质用板载 Flash 划一块环形区,存满覆盖最旧的,一般能存两三个小时的数据,够定位大部分问题。

6. 二次开发改造:换串数、改报文、加数据记录

改这份代码,多数人的需求无非三类:串数变了、协议要对接自己的上位机、想加数据记录。下面按改造点、涉及文件、验证方式列出来,都是可以直接照着动的。

改造点涉及文件验证方式
串数从 16 改 12bms_cfg.h、drv_afe.c、app_report.c编译期断言通过,单串逐一加已知电压比对
换 CAN 协议app_report.c、Doc/报文定义用 CAN 分析仪抓帧,逐字段比对定义文档
加数据记录新增drv_flash_log.c、app_log.c断电重启后读回,校验 CRC 和时间戳
阈值现场可调bms_param.c、drv_eeprom.c改参数后掉电重启,确认读回生效

换串数是改动面最大的一个。改了BMS_CELL_NUM还不够,采集前端的通道使能循环要跟着变,报文帧数要跟着变。如果串数是 12,一帧 8 串加一帧 4 串,第二帧的后 4 字节要填固定值(比如 0xFFFF

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进