资讯动态

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

发布时间:2026/10/26 3:15:42 来源:尧图企业网站定制
简介面向电池管理系统开发者的国内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 和 DriverDriver 调 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.01Ah100Ah 写 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 容量不能为 0SOC 计算会除零 #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 位满量程 4096mV 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 算总和 4000016 位装得下但只要有人把窗口改成 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.001mAhmA * 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是百分比乘 10050.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-5SOC单位 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 串怎么拆——是拆成 84 还是干脆两帧各 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 cell3 mv3661抓一次现场日志就能看出是哪个通道、什么时刻、什么值触发的。/* 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) #endifdo {} 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本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑