☰
16串磷酸铁锂BMS源码实战:AFE采样、保护均衡与SoC估算
2026/10/26 3:15:50 网站建设 项目流程

简介:这是一份面向嵌入式开发与新能源汽车电子学习者的国内电池管理系统源码,围绕主机XC2287M、从机MC9S08DZ60以及500kbps的CAN总线通信展开,可用于理解电池参数采集、均衡控制、故障检测、荷电状态估算、健康状态评估和异常保护等核心逻辑。压缩包共229个文件,以81个头文件、71个C源文件和50个目标文件为主,另含工程配置、烧录与映射文件,整体约951KB,目录保留了从驱动层到应用层的实现线索。已有8035人学习下载。读者可结合模数采样、温度接口、CAN驱动、错误处理和电源管理等代码,梳理分层软件架构与阈值保护策略,并借助调试记录和工程文件还原从硬件接口到控制算法的开发思路,适合作为电池管理系统入门的实践参考和模块化设计对照材料。

1. 台架上那组 16 串电池,充电器不会告诉你的那些事

台架上一组 16 串磷酸铁锂,充电器只报总压 51.2V。第 7 串是不是已经掉到 2.90V、第 12 串是不是鼓包前兆、15℃ 的夜里还能不能充,它一个字都不说。电池管理系统(BMS)存在的意义就是把这种“看不见”变成 CAN 报文里的数字,并且在出事之前把主继电器断开。

这份国内 BMS 源码是一套完整的嵌入式工程:AFE 采样、过压欠压过流过温保护、被动均衡、SoC/SoH 估算、CAN 上报,外加一个 Python 上位机看曲线和标参数。代码量不算大,但链路是全的,从寄存器读到上位机画图能一路走通。它适合两类人:手上有一组电池、想自己攒管理板的两轮车和储能玩家,以及想找个能跑通的 BMS 框架、改自己项目固件的嵌入式开发。下面按“结构—跑通—算法—踩坑—二次开发”的顺序拆开讲。

2. 工程结构与技术栈:从 AFE 采样到 CAN 上报的分层边界

拿到一份陌生的嵌入式工程,我会先看目录,不看代码。目录决定分层,分层决定你改一个功能要动几个文件。这份 BMS 源码的骨架是典型的“应用层不碰寄存器”写法,值得先讲清楚。

2.1 三层目录:app / bsp / middleware 各自管什么

实际目录大致长这样,不同人手里的版本细节会有出入,但分法是共通的:

bms-firmware/ ├── app/ # 业务逻辑,只调 bsp 暴露的接口 │ ├── bms_task.c # 时间片调度,全局节拍在这 │ ├── protect_sm.c # 保护状态机(过压/欠压/过流/过温) │ ├── balance.c # 被动均衡决策 │ ├── soc_estimator.c # 安时积分 + OCV 校正 │ └── soh_calc.c # 容量衰减统计 ├── bsp/ # 跟硬件绑死的一层 │ ├── afe_spi.c # AFE 寄存器读写、通道映射 │ ├── current_adc.c # 分流器/霍尔采样与零点标定 │ ├── temp_ntc.c # NTC 分压转温度 │ ├── relay_ctrl.c # 主正/主负/预充继电器 │ └── param_store.c # 参数区读写 + CRC16 ├── middleware/ │ ├── can_stack.c # 报文打包、周期发送、bus-off 恢复 │ ├── uds_server.c # 诊断服务,读故障码、刷参数 │ └── ring_buffer.c # 故障录波用的环形缓存 ├── config/ │ ├── bms_cfg.h # 串数、容量、阈值宏 │ └── ocv_table.c # OCV-SoC 查表 └── tools/ ├── host_upper/ # Python 上位机 └── dbc/bms.dbc # 报文定义

分层的价值在于:app/里不该出现任何一个SPI_Transmit或者寄存器地址。我见过太多 BMS 工程把保护判断直接写在采样驱动里,结果换一颗 AFE 就得重写半个项目。这份源码把afe_spi.c的出口统一成“16 个 mV 值 + 一个故障标志”,protect_sm.c只认 mV,换硬件时上层一行不改。

config/bms_cfg.h是另一个关键点。串数、额定容量、均衡起始压差、CAN 波特率全在这个头文件里,用宏定义。好处是编译期就能把数组长度定死,不用动态内存;坏处是改串数必须全量重编,不能在线改。

层次常见选型说明
主控Cortex-M3/M4,64~128KB Flash裸机时间片足够,上 RTOS 也行
采集外置 AFE,SPI 隔离单颗覆盖 16 串,菊花链可扩到 32 串
电流分流器 + 双向运放,或霍尔分流器精度高,霍尔温漂小
通信CAN 2.0B,500kbps报文格式贴近 J1939,诊断走 UDS
存储片内 Flash 模拟 EEPROM参数区带 CRC16 和双备份
上位机Python + Qt 绑定实时曲线、参数标定、故障码读取

2.2 参数区:所有能改的数值都不该硬编码在 .c 里

保护阈值、容量、标定系数这些东西,产线和售后的工程师要改,但他们不会编译固件。所以合格的 BMS 都会划一块参数区,用结构体整体读写,头部带版本号和 CRC:

/* bsp/param_store.c —— 参数区布局,放进 Flash 最后两页 */ #define PARAM_MAGIC 0x424D5301u /* "BMS" + 版本,改结构体就升版本 */ #define PARAM_BAK_ADDR (FLASH_BASE + FLASH_SIZE - 0x800) #define PARAM_MAIN_ADDR (FLASH_BASE + FLASH_SIZE - 0x1000) typedef struct { uint32_t magic; uint16_t cell_num; /* 串数,必须与宏一致,否则拒绝加载 */ uint16_t rated_ah_x10; /* 额定容量,0.1Ah 为单位 */ int16_t ov_l1_mv; /* 一级过压告警 */ int16_t ov_l3_mv; /* 三级过压断继电器 */ int16_t uv_l1_mv; int16_t uv_l3_mv; int16_t oc_chg_a_x10; /* 充电过流,0.1A */ int16_t oc_dsg_a_x10; int8_t temp_chg_low_c; /* 低温禁充门限 */ int8_t temp_chg_high_c; uint16_t balance_start_mv; /* 均衡起始压差 */ uint16_t crc16; } bms_param_t; bool param_load(bms_param_t *p) { /* 主区坏就读备份区,两边都坏就加载默认值并置一条故障码 */ if (param_read(PARAM_MAIN_ADDR, p) && param_crc_ok(p) && p->magic == PARAM_MAGIC && p->cell_num == CELL_NUM) { return true; } if (param_read(PARAM_BAK_ADDR, p) && param_crc_ok(p)) { return true; } param_load_default(p); g_fault_flag |= FAULT_PARAM_DEFAULT; return false; }

magic和cell_num两个校验是必须的。血泪经验:有人把 16 串板子的固件刷到 24 串板子上,参数区里存的还是 16 串的阈值,结果后 8 串完全没人管,第一次上电就过充。加上cell_num比对,加载失败自动落到默认值并报故障码,问题在产线就能被发现。

写 Flash 的时候记得先擦后写,并且主备交替写,别在同一个地址反复擦——片内 Flash 的擦写次数通常只有 1 万次量级,标定工程师一天调十几次参数,一年就能把一个扇区写废。

2.3 编译、烧录、第一帧 CAN

工具链用arm-none-eabi-gcc加 Makefile,也有的版本直接给 Keil 工程文件。用命令行的话大概是这样:

# 依赖:交叉编译器、make、openocd(SWD 调试器) sudo apt install -y gcc-arm-none-eabi make openocd # 编译,串数和波特率通过宏覆盖,不传就用 bms_cfg.h 里的默认值 cd bms-firmware make CELL_NUM=16 CAN_BITRATE=500000 -j8 # 烧录,调试器是常见的 SWD 探头 make flash OPENOCD_IF=interface/swd.cfg TARGET=target/cortex_m.cfg # 上位机另开一个终端,socketcan 需要先把 CAN 口拉起来 sudo ip link set can0 up type can bitrate 500000 python3 tools/host_upper/main.py --channel can0

烧完别急着接电池。第一步应该只上 12V 辅助电源和调试器,用万用表量 AFE 的供电和基准,然后看串口日志里的采样值——所有通道应该是 0mV 附近而不是乱跳的 65535。乱跳说明 SPI 没通或者片选时序不对,这时候接上电池组只会得到一堆假保护。

提示:第一次上电务必串一个功率电阻做预充限流,不要让母线电容直接怼上去,浪涌电流会误触发过流保护,还可能烧继电器触点。

3. 核心链路拆解:一个 10ms 周期里采样、保护、均衡怎么排

BMS 的实时性要求并不均匀。电芯电压 10ms 采一次足够,温度 100ms 都嫌快,CAN 报文 500ms 发一帧客户就满意了。难点不在快,在于别让它们互相堵——一次 SPI 读 16 串大概几百微秒,如果所有任务挤在同一个 tick 里跑,抖动会很难看。

3.1 时间片调度表:用 offset 把任务错开

裸机方案里最省事也最稳的做法是一张静态任务表,1ms 的 SysTick 里扫一遍:

/* app/bms_task.c —— 静态时间片,不用 RTOS 也能跑得很稳 */ typedef struct { uint16_t period_ms; uint16_t offset_ms; /* 相对 0 时刻的偏移,用来错峰 */ uint16_t elapsed_ms; void (*fn)(void); } task_t; static task_t s_tasks[] = { { 10, 0, 0, task_sample_fast }, /* 电芯电压、总压、电流 */ { 10, 3, 0, task_protect_scan }, /* 保护判断,慢采样 3ms */ { 100, 11, 0, task_temp_scan }, /* NTC 温度 */ { 100, 23, 0, task_balance }, /* 均衡决策 */ { 200, 37, 0, task_soc_update }, { 500, 51, 0, task_can_report }, /* 周期状态报文 */ {1000, 67, 0, task_diag_service }, /* 诊断会话维持、故障码 */ }; void systick_handler_1ms(void) { for (uint8_t i = 0; i < sizeof(s_tasks)/sizeof(s_tasks[0]); i++) { s_tasks[i].elapsed_ms++; if (s_tasks[i].elapsed_ms >= s_tasks[i].period_ms) { s_tasks[i].elapsed_ms = 0; s_tasks[i].fn(); } } }

offset_ms这个字段看起来多余,其实是整套调度里最值钱的一行。没有它,所有 10ms 和 100ms 的任务会在第 100ms 那个 tick 上撞在一起,加上 500ms、1000ms 的公倍数,最坏情况单次 tick 里要跑六个任务,主循环被拉长到十几毫秒,采样周期直接失准。加了偏移之后峰值被摊平,实测抖动从 ±4ms 降到 ±0.3ms。

period_ms的取值也不是随便定的。电芯电压 10ms 是因为过流和短路保护要靠它,虽然真正的短路保护还是得靠硬件比较器或者 AFE 自带的快速保护,软件这条路只能兜底。温度 100ms 是因为 NTC 的热时间常数在秒级,采再快也是噪声。

3.2 AFE 采样:raw 到 mV 的那一步最容易错

读取本身不难,难在换算。很多工程出问题不是通信不通,是换算系数写错了 10 倍,整组电池天天报过压。

/* bsp/afe_spi.c —— 一次性读回全部单体电压 */ static bool afe_read_cells(bms_sample_t *s) { uint8_t tx[4] = {0x01, 0x00, 0x00, 0x00}; /* 0x01 为读全部电芯命令,按手册改 */ uint8_t rx[2 + CELL_NUM * 2] = {0}; if (spi_transfer(tx, rx, sizeof(rx), 10) != 0) { /* 10ms 超时 */ s->fault_flag |= FAULT_AFE_COMM; return false; } uint32_t sum = 0; for (uint8_t i = 0; i < CELL_NUM; i++) { /* 大端:高字节在前,12 位有效数据右对齐 */ uint16_t raw = ((uint16_t)rx[2 + i * 2] << 8) | rx[3 + i * 2]; raw &= 0x0FFF; /* 换算:Vref=3.000V,12bit 满量程 4095, 内部 1/2 分压 → 每 LSB ≈ 3.000/4095*2 ≈ 1.465mV */ uint16_t mv = (uint16_t)((raw * 1465UL) / 1000UL); s->cell_mv[i] = mv; sum += mv; } s->pack_mv = (uint16_t)sum; return true; }

这段里三处容易翻车:

一是字节序。AFE 输出大端还是小端,不同厂家不一样,手册上写“MSB first”就按上面这样拼,写成rx[2+i*2] | (rx[3+i*2]<<8)得到的是个天文数字,然后整包报过压。

二是换算系数。1465这个数是按 3.000V 基准和 1/2 分压算的,如果你的板子用的是 2.5V 基准或者不同分压比,必须重算。别照抄,量一下——拿标准源给第 1 串送 3.000V,看读出来是多少,反推系数,比算靠谱。

三是累加总压。sum用uint32_t是有意的,16 串 × 3.65V ≈ 58.4V,用uint16_t存 mV 是 58400,还没溢出但已经到 89% 了,扩到 24 串必然爆。总压这个量永远用 32 位。

3.3 保护状态机:阈值、回差、延时,三样缺一不可

保护逻辑写成if (v > 3650) 断开继电器;是新手最容易犯的错。电压在阈值附近抖一下,继电器就开合一次,一天下来触点就废了。正确做法是三级阈值加回差加确认延时:

/* app/protect_sm.c —— 过压三级保护,阈值来自参数区 */ typedef enum { OV_L1 = 0, OV_L2, OV_L3 } ov_level_t; static const struct { int16_t trip_mv; /* 触发阈值 */ int16_t recover_mv; /* 恢复阈值,必须低于 trip 形成回差 */ uint16_t delay_ms; /* 持续多久才认账 */ } s_ov_tbl[3] = { { 3650, 3550, 1000 }, /* 一级:只上报 + 降流请求 */ { 3750, 3620, 300 }, /* 二级:强制降流到 0.1C */ { 3850, 3700, 100 }, /* 三级:断主继电器,锁死 */ }; static uint16_t s_ov_cnt[3] = {0}; void task_protect_scan(void) { uint16_t vmax = cell_max_mv(); for (uint8_t lv = 0; lv < 3; lv++) { uint16_t need = s_ov_tbl[lv].delay_ms / 10; /* 10ms 扫一次 */ if (vmax >= s_ov_tbl[lv].trip_mv) { if (s_ov_cnt[lv] < need) s_ov_cnt[lv]++; if (s_ov_cnt[lv] >= need) { protect_raise(FAULT_OV_BASE + lv); if (lv == OV_L3) { relay_open_all(); /* 三级直接断 */ g_state = ST_FAULT; } } } else if (vmax <= s_ov_tbl[lv].recover_mv) { s_ov_cnt[lv] = 0; protect_clear(FAULT_OV_BASE + lv); } /* 处在回差区间内:计数保持不变,既不升级也不恢复 */ } }

三个参数各有作用,一个都不能省:

trip_mv和recover_mv之间的 100mV 就是回差。磷酸铁锂充满后撤掉充电电流,电压会从 3.65V 掉到 3.40V 附近,这个落差远大于 100mV,所以回差设太小没用,设太大又会让二级保护形同虚设。100~150mV 是这套化学体系里比较稳妥的区间。

delay_ms是防抖。均衡电阻一开,被均衡那串的采样值会瞬间掉几十毫伏,如果一级保护的延时只有 10ms,均衡一启动就误报欠压。给一级 1s、二级 300ms、三级 100ms 的梯度,是因为越严重的情况要求越快响应,而轻微越限允许它自己缓过来。

三级和二级的本质区别是:二级只降流,让用户还能把车开到路边;三级直接断,不管用户乐不乐意。所以三级必须是锁死的,g_state = ST_FAULT之后只有收到明确的复位命令才恢复,不能自动重连——自动重连等于没有保护。

3.4 被动均衡:开几路、开多久、什么时候必须停

均衡是最容易被低估的模块。看着简单,实际上发热、采样干扰、温度保护误动作全跟它有关。

/* app/balance.c —— 带回差和占空限制的被动均衡 */ #define BAL_MAX_ON 4 /* 同时最多开 4 路,限制总发热 */ #define BAL_ON_MS 30000 /* 连续均衡 30s */ #define BAL_COOL_MS 10000 /* 强制休息 10s */ void task_balance(void) { uint16_t vmin = 0xFFFF, vmax = 0; for (uint8_t i = 0; i < CELL_NUM; i++) { if (s_sample.cell_mv[i] < vmin) vmin = s_sample.cell_mv[i]; if (s_sample.cell_mv[i] > vmax) vmax = s_sample.cell_mv[i]; } uint16_t diff = vmax - vmin; /* 冷却期内什么都不做,均衡电阻需要时间散热 */ if (s_bal.cool_ms > 0) { s_bal.cool_ms -= 100; return; } /* 回差:起始 30mV,停止 15mV,避免在阈值上反复开关 */ if (!s_bal.active) { if (diff >= PARAM.balance_start_mv && vmax >= 3300) { s_bal.active = true; s_bal.run_ms = 0; } else { return; } } else if (diff <= 15 || vmax < 3250) { s_bal.active = false; afe_set_balance_mask(0x0000); return; } /* 只对高出 vmin + 10mV 的单体放电,最多 4 路 */ uint16_t mask = 0; uint8_t cnt = 0; for (uint8_t i = 0; i < CELL_NUM && cnt < BAL_MAX_ON; i++) { if (s_sample.cell_mv[i] > vmin + 10) { mask |= (1u << i); cnt++; } } afe_set_balance_mask(mask); s_bal.run_ms += 100; if (s_bal.run_ms >= BAL_ON_MS) { afe_set_balance_mask(0x0000); s_bal.cool_ms = BAL_COOL_MS / 100; s_bal.run_ms = 0; } }

几个说明。vmax >= 3300这个门槛的意思是:只在电池接近充满、电压曲线开始翘头的时候均衡。在平台区(3.26~3.33V)做均衡毫无意义,因为那里的压差可能只是采样误差,你均衡半天压差反而更大。

BAL_MAX_ON = 4是热设计的结论,不是随便写的。假设每路均衡电阻 33Ω、单体 3.3V,单路功耗约 0.33W,16 路全开就是 5.3W,塞在密闭的电池箱里,半小时就能把贴在旁边的 NTC 烤高 15℃,然后温度保护误判为过温,充电中断。限制到 4 路,总功耗 1.3W,配合 30s 开 10s 停的占空比,温升能控制在 3℃ 以内。

均衡期间采样会偏,这个没法完全消除,只能避开:task_balance的调度偏移是 23ms,task_protect_scan是 3ms,两者相差 20ms,等保护扫描跑完,均衡刚打开的那点瞬态也差不多稳了。如果还嫌不够,可以在均衡开启后丢弃第一帧采样。

4. SoC 估算实现:安时积分打底,OCV 只做慢修正

SoC 是 BMS 里最容易玄学的一块。同样的代码,换一组电池就偏 10%,换个温度又偏 15%。原因不复杂——所有方法都有前提假设,而现场总在破坏这些假设。

4.1 安时积分:公式三行,误差全在初值和零点

安时积分本质就是电量守恒,写起来三行:

/* app/soc_estimator.c */ typedef struct { float rated_ah; /* 额定容量,来自参数区 */ float soc; /* 0~100,对外输出 */ float soc_base; /* 本次上电的基准 SoC */ float coulomb_ah; /* 相对 soc_base 的累计安时,充电为正 */ float ocv_soc; /* OCV 查表结果 */ uint32_t rest_ms; /* 连续静置时间 */ } soc_ctx_t; void task_soc_update(void) { const float dt_h = 0.2f / 3600.0f; /* 200ms 周期换算成小时 */ float i = s_sample.pack_current_ma / 1000.0f; /* A,充电为正 */ /* 1) 安时积分 */ ctx.coulomb_ah += i * dt_h; float soc_ah = ctx.soc_base + 100.0f * ctx.coulomb_ah / ctx.rated_ah; /* 2) 静置判定:|I| < 0.5A 持续 30min 才认为电压可信 */ if (fabsf(i) < 0.5f && !relay_is_charging()) { ctx.rest_ms += 200; } else { ctx.rest_ms = 0; } /* 3) OCV 慢修正:每周期最多拉 0.02%,别让表显跳变 */ if (ctx.rest_ms > 30UL * 60UL * 1000UL) { ctx.ocv_soc = ocv_lookup(cell_avg_mv()); float err = ctx.ocv_soc - soc_ah; if (err > 0.02f) err = 0.02f; if (err < -0.02f) err = -0.02f; soc_ah += err; } /* 4) 满充校准:单体到 3.65V 且电流跌到 0.05C 以下 */ if (cell_max_mv() >= 3650 && i > 0.05f && i < 0.05f * ctx.rated_ah) { ctx.coulomb_ah = 0.0f; ctx.soc_base = 100.0f; soc_ah = 100.0f; } /* 5) 输出做一阶低通,抑制单次异常采样 */ ctx.soc += (soc_ah - ctx.soc) * 0.1f; if (ctx.soc > 100.0f) ctx.soc = 100.0f; if (ctx.soc < 0.0f) ctx.soc = 0.0f; }

dt_h的换算要小心。周期是 200ms,换成小时就是0.2/3600,写成0.2*3600的话误差直接放大 1.3 亿倍,跑十分钟 SoC 就飞到太阳系去了。这个错我在两份不同的工程里都见过。

soc_base加coulomb_ah的组合,是为了让满充校准不用去动已累计的安时。校准的时候把coulomb_ah清零、soc_base设成 100,两者相加自然是 100,之后继续往下积分。比“直接改 soc”干净得多,因为soc还挂着一阶低通,直接改会被滤波器慢慢吃掉。

0.05C这个满充判据对应的是 100Ah 电池的 5A。光看电压不行——充电到 3.65V 的时候电流可能还有 0.3C,那只是极化电压顶上去的,撤掉电流马上掉回 3.4V,这时候判满充会让 SoC 虚高。

4.2 OCV 查表:磷酸铁锂的平台区就是个陷阱

查表本身是分段线性插值,二十行代码。真正的问题在表本身:

/* config/ocv_table.c —— 25℃、0.02C 放电、静置 2h 实测 */ typedef struct { uint16_t mv; uint8_t soc; } ocv_point_t; static const ocv_point_t s_ocv_lfp[] = { { 2800, 0 }, { 3000, 5 }, { 3150, 10 }, { 3230, 20 }, { 3260, 30 }, { 3280, 50 }, { 3300, 70 }, { 3320, 85 }, { 3340, 95 }, { 3400, 100 }, }; uint8_t ocv_lookup(uint16_t mv) { const uint8_t n = sizeof(s_ocv_lfp) / sizeof(s_ocv_lfp[0]); if (mv <= s_ocv_lfp[0].mv) return 0; if (mv >= s_ocv_lfp[n-1].mv) return 100; for (uint8_t i = 1; i < n; i++) { if (mv <= s_ocv_lfp[i].mv) { /* 两点线性插值 */ uint16_t v0 = s_ocv_lfp[i-1].mv, v1 = s_ocv_lfp[i].mv; uint8_t s0 = s_ocv_lfp[i-1].soc, s1 = s_ocv_lfp[i].soc; return s0 + (uint8_t)((uint32_t)(mv - v0) * (s1 - s0) / (v1 - v0)); } } return 100; }

看这张表就明白了:3.26V 对应 30%,3.32V 对应 85%。中间 60mV 的电压变化对应 55% 的容量变化,而一个 12 位 ADC 在 1.465mV 分辨率下的噪声就有 ±3mV。也就是说,光靠 OCV 在这段区间里根本分不清 40% 和 60%。

这就是所谓的平台区陷阱。所以在这份源码里 OCV 校正只做“慢修正”,每个周期最多拉 0.02%,一小时最多拉 3.6%——它的作用是把长期累积的积分误差慢慢拽回来,而不是让它去主导 SoC。真正在平台区干活的是安时积分。

补一条实操建议:这张表必须自己测。不同厂家的磷酸铁锂、不同的电解液配方,平台区电压能差 20mV 以上。用 OCV 表去套别的电芯,误差比不用还大。

4.3 校准点与 SoH:把误差圈在 5% 以内

安时积分有两个不可避免的误差源:初值和累计漂移。初值靠 OCV 给,漂移靠校准点清。

校准点只有两个可靠的:满充和放空。满充用上面那段“3.65V + 0.05C”判据,放空用“最低单体 2.80V 且放电电流 < 0.02C”。这两个点每出现一次,累计误差就清零一次。对于天天满充的储能场景,误差基本能压在 3% 以内;对于“充到 80% 就拔”的两轮车,可能一个月都碰不到校准点,这时候误差能到 8%~10%,属于物理限制,不是代码问题。

SoH 相对简单,记每次满充时放出的总安时:

/* app/soh_calc.c —— 满充满放循环里统计实际容量 */ void soh_on_full_discharge(float discharged_ah) { /* 只有从 100% 放到空才算一次有效循环 */ if (ctx.soc_at_start < 95.0f) return; /* 一阶滤波,避免单次异常拉低 */ ctx.capacity_est_ah += (discharged_ah - ctx.capacity_est_ah) * 0.2f; ctx.soh = 100.0f * ctx.capacity_est_ah / ctx.rated_ah; }

0.2这个系数决定收敛速度,五次有效循环基本稳定。别用 1.0 直接覆盖,一次误判就把 SoH 打到 80%,售后电话就来了。

5. 避坑与排查:上电不吸合、SoC 跳变、CAN 掉线

下面这五条都是现场真会遇到的,每条按“现象—原因—解决”写,照着对号入座基本能定位。

5.1 预充超时,主继电器一直不吸合

现象:上电后听到预充继电器“哒”一下又断开,主继电器始终不动,日志里是FAULT_PRECHARGE_TIMEOUT。

原因:预充的判断逻辑是“母线电压达到总压的 90% 以上就算成功”,但母线电容容量、预充电阻阻值、负载侧是不是还挂着 DCDC 都会影响这个时间常数。常见的情况是预充电阻选大了(比如 100Ω 配 2000μF,时间常数 0.2s,到 90% 要 0.46s),而超时阈值只给了 300ms。

解决:先把超时阈值放到 2s 观察,同时用示波器量母线电压曲线,算实际时间常数。如果确实偏慢,把预充电阻降到 20~50Ω,但要注意预充电流不能超过电阻的额定功率。还有一种隐藏原因是母线侧有持续的漏电流(比如 DCDC 一直使能),电压永远充不到 90%,这时候要在预充前先断开负载。

5.2 SoC 上电跳变,从 80% 掉到 62%

现象:同一组电池,昨天关机时显示 80%,今天开机变成 62%,静置一晚上还在慢慢往下爬。

原因:两个可能。一是soc_base上电时被 OCV 查表直接赋值了,而当时电池才静置了 5 分钟,极化电压还没散去,查出来的 OCV 偏低,SoC 自然偏低。二是参数区里存的“上次关机 SoC”没有做掉电保护,读到的是 Flash 里的旧值或者默认值 0。

解决:上电时不要立刻用 OCV 定初值,先加载上次关机时存的 SoC 作为soc_base,然后等静置条件满足(30 分钟)再允许 OCV 修正,而且修正速率限制在每周期 0.02%。另外参数区里的 SoC 要单独留一块,每 5 分钟或者 SoC 变化超过 1% 时写一次,别每个周期都写,Flash 扛不住。

5.3 CAN 总线 bus-off,整包数据断流

现象:上位机曲线突然断了,几秒后又自己恢复,抓包能看到一段连续的错误帧。

原因:节点进入 bus-off 状态后没有恢复机制。CAN 控制器检测到发送错误计数超过 255 就会自动脱离总线,标准要求是等待 128 次连续 11 个隐性位之后恢复,但很多驱动默认不开启自动恢复,需要软件干预。

解决:在 CAN 驱动的错误回调里加状态机:

/* middleware/can_stack.c —— bus-off 恢复 */ void can_error_callback(uint32_t err) { if (err & CAN_ERR_BUSOFF) { can_enter_init_mode(); delay_ms(50); /* 给总线一点冷静时间 */ can_leave_init_mode(); /* 重新同步,自动清错误计数 */ g_can_err_cnt++; if (g_can_err_cnt > 10) { /* 十次还起不来,多半是硬件问题:终端电阻、线序、共地 */ g_fault_flag |= FAULT_CAN_HW; } } }

同时检查物理层:120Ω 终端电阻是不是两端都有、总线是不是手拉手而不是星型、屏蔽层是不是只在一端接地。这三条占 CAN 故障的八成。

5.4 低温禁充误判,0℃ 以上也拒绝充电

现象:环境温度 5℃,充电枪插上没反应,日志里是FAULT_TEMP_LOW。

原因:NTC 分压表的温度换算用的是固定 β 值公式,低温段误差大。更常见的是 NTC 贴的位置不对——贴在均衡电阻旁边或者功率管附近,自己发热把温度读数抬高了 10℃,或者反过来贴在铝壳上,环境一冷它最先冷,比电芯实际温度低 5℃。

解决:先拿标准温度箱标定一遍,在 -20℃、0℃、25℃、45℃ 四个点各放 30 分钟,记录 ADC 原始值和实际温度,重新拟合。如果偏差是固定偏移,直接在参数区加一个temp_offset_c补偿。同时把低温禁充门限从 0℃ 调到 -5℃ 做双重保护,软件门限永远要比电芯厂家给的极限值保守一档。

5.5 参数写 Flash 后掉电丢失

现象:产线标定完参数,断电再上电,全部回到默认值。

原因:写入时只擦了主区没更新备份区,或者写完之后没有回读校验。还有一种更隐蔽的:写 Flash 的时候没关中断,一个中断服务程序里的常量访问触发了 Flash 读,和擦写操作冲突,数据写坏。

解决:写入流程固定成“擦备份区 → 写备份区 → 回读校验 → 擦主区 → 写主区 → 回读校验 → 更新 magic”。整个流程关中断,耗时大概几十毫秒,对 BMS 来说可以接受(继电器状态不变,采样丢几个点无所谓)。回读校验必须做,不能只看写函数返回值——Flash 控制器的返回值在很多情况下是“操作已受理”而不是“数据已写入”。

6. 二次开发:改串数、改阈值、改协议,先动哪一处

拿到这套源码,最常见的诉求是“我这组是 24 串,容量 280Ah,协议要跟自己的上位机对上”。改之前先记住一个顺序:先改配置宏,再改参数区默认值,最后才动协议和逻辑。顺序反了,你会在一堆硬编码里找不到哪一处还在用 16。

6.1 改串数:三个地方必须同步

改CELL_NUM只是第一步,下面三处不同步就一定出错:

位置要改什么不改的后果
config/bms_cfg.hCELL_NUM宏、BAL_MAX_ON数组越界或后半串无人管
bsp/afe_spi.c通道映射表、SPI 读取长度读回的数据错位
config/ocv_table.c保持单体表不变,但校验pack_mv上限总压用 16 位存会溢出

串数上去之后第一件事是把所有跟总压相关的变量从uint16_t换成uint32_t,24 串满压 87.6V,用 mV 表示就是 87600,已经贴近 65535 的边界了。这个改动没什么技术含量,但漏一处就是一个隐蔽的溢出。

6.2 改阈值和协议:参数优先,DBC 最后

阈值全部走参数区,改默认值只需要动param_load_default()里的赋值,别去protect_sm.c里改常量。改完之后用上位机把参数写进去,再读回来核对一遍,确认 CRC 正确。

协议这块,先把tools/dbc/bms.dbc改成自己的报文定义,然后用cantools之类的工具生成解析代码或者直接在上位机里加载。报文 ID 和信号排布一旦定下来就别再动,产线设备、云端、售后工具全依赖它,改一次要跟着改一圈。信号缩放系数建议统一用 0.1 或者 0.01,别混着用 0.001 和 1,解析的时候容易错位。

改完必须做一轮验证,我一般这么走:用单体模拟器把 24 路全部拉到 3.300V 静置两小时,看第一帧上报的 SoC 是不是在 50% 附近;然后单独把第 12 路拉到 3.850V,确认三级过压能在 100ms 内断开继电器;再把这路降到 2.750V,确认欠压三级同样动作;最后拔掉 CAN 线十秒再插回,看 bus-off 能不能自己恢复。

从那以后我每次拿到一份新的 BMS 源码,第一件事都不是编译,而是先用单体模拟器把所有通道拉到 3.30V 静置两小时,看它上电第一帧报的 SoC 是多少——如果这个数跟 50% 差得离谱,说明 OCV 表或者初值逻辑有问题,后面所有调试都是在错的地基上盖楼。这套习惯帮我省下的时间,比任何调试技巧都多。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询