首页 / 资讯中心 / 文章详情

BMS源码实战解析:从SOC估算到CAN报文,电池管理系统开发避坑指南

BMS源码实战解析:从SOC估算到CAN报文,电池管理系统开发避坑指南 ★ FEATURED ARTICLE
简介一套基于XC2287M主控与MC9S08DZ60从控的电池管理系统源码面向嵌入式开发及汽车电子领域的学习者与工程师可用于理解BMS底层监测、均衡控制及CAN通信实现。源码通过500kbps波特率的CAN总线完成主从机数据交换覆盖电压/电流/温度采集、SOC与SOH估算等核心环节。压缩包内共228个文件以81个h头文件、71个c源文件为主另含o目标文件、prm链接配置、map映射及s19烧录文件等整体仅817KB结构便于对照学习。目前已有1411人学习下载。该资源同时提供SampleBoard与HostBoard两类目录可帮助开发者梳理主板与从板的软硬件配合关系并在此基础上扩展均衡策略或改进通信协议。1. 电池管理系统BMS源码从SOC跳变到CAN报文这套代码到底能帮你省多少事如果你手头正拿着一块充不满、放不干的电池包先别急着怀疑电芯质量。很多所谓“电池不行”根源在策略层SOC估算漂移、均衡逻辑触发条件太激进、CAN报文丢帧后主控直接进入了保护态。一套能落地的BMS源码价值不在代码行数而在保护逻辑是否完备、CAN链路是否可靠、故障后能否自恢复。这篇文章要拆的是一份完整的电池管理系统BMS源码压缩包覆盖了采样、保护、SOC估算、CAN通信和被动均衡适合正在做锂电PACK、储能柜或两轮车BMS的工程师直接移植。你会看到每个模块的选型理由、关键参数设置和最容易翻车的坑照着改就能跑。2. 先把底层做扎实采样、滤波和保护逻辑不能只靠硬件兜底2.1 电压采样通道ADC配置与滤波窗口的取舍BMS最怕的不是采样不准而是采样“看起来准”。硬件上漏电流、走线压降、接触电阻都会让单体电压在特定温度下悄然偏移软件如果没做滤波和校准SOC误差会从前端一路放大到后端。这份源码里电压采样用的是SPI接口的AFE芯片通道数是12串每串采样周期在10ms级别采样值经过中值滤波后再进入均值滤波两个窗口的长度都可以通过宏定义调整。我一般会把中值滤波窗口设为5次均值滤波窗口设为8次这样既能滤掉脉冲干扰又不会让动态响应太迟钝。注意这里有个关键点AFE的ADC参考电压是内部基准低温漂系数大约在50ppm如果项目对精度要求更高建议在电路板上预留外部精密基准的焊盘并在源码里加一个基准校准函数。/* AFE 电压采样与滤波基于 SPI 读取 */ #define ADC_MEDIAN_WINDOW 5 #define ADC_MEAN_WINDOW 8 uint16_t raw_adc[12]; uint16_t filtered_v[12]; uint16_t median_filter(uint16_t *buf, uint8_t len) { /* 冒泡排序取中值窗口小耗时可接受 */ for (uint8_t i 0; i len - 1; i) { for (uint8_t j 0; j len - i - 1; j) { if (buf[j] buf[j 1]) { uint16_t tmp buf[j]; buf[j] buf[j 1]; buf[j 1] tmp; } } } return buf[len / 2]; } void adc_task(void) { static uint16_t history[ADC_MEDIAN_WINDOW][12]; static uint8_t idx 0; /* 每次采集后写入环形缓冲 */ for (uint8_t ch 0; ch 12; ch) { history[idx][ch] read_afe_voltage(ch); } idx (idx 1) % ADC_MEDIAN_WINDOW; /* 先中值再均值两层滤波都要跑 */ for (uint8_t ch 0; ch 12; ch) { uint16_t tmp[ADC_MEDIAN_WINDOW]; for (uint8_t i 0; i ADC_MEDIAN_WINDOW; i) { tmp[i] history[i][ch]; } uint16_t mid median_filter(tmp, ADC_MEDIAN_WINDOW); filtered_v[ch] (filtered_v[ch] * (ADC_MEAN_WINDOW - 1) mid) / ADC_MEAN_WINDOW; } }这段代码的逻辑分两段第一段收集最近5次采样的原始值第二段先取中值剔除偶发毛刺再滑动均值平滑输出。中值窗口越大对窄脉冲的抗干扰越好但真实电压变化也会被抹平所以在电芯电压本身波动大的场景比如大电流放电瞬间的欧姆压降我建议把均值窗口减小到4次或者干脆只保留中值滤波。均值窗口的系数用递归式计算比每次重新累加所有历史值更省CPU但要注意初始阶段输出会有一段爬升期如果系统上电就要立刻显示电压可以预填首采值。2.2 温度采集与 NTC 查表分压电阻和 B 值有大学问温度采样这块源码用的是10K NTC配合10K分压电阻B值是3435。很多移植者会忽略一个细节NTC查表表是用25度时的电阻值反推出来的但ADC读到的并不是电阻值而是分压后的电压。如果分压电阻的精度是1%温度在-20度时误差可能放大到3度以上直接影响低温充电策略的触发电平。源码里的查表函数用的是线性插值表间隔是5度一档在0度到60度区间误差能控制在0.5度以内超出这个区间最好改用更高密度的表。还要注意源码表里存的是ADC编码值而不是电阻值这样省去了每次换算的除法运算适合低频MCU跑。/* NTC 分压采样 → 查表得到温度返回 0.1℃ 单位 */ #define NTC_TABLE_SIZE 25 const uint16_t ntc_adc_table[NTC_TABLE_SIZE] { 3420, 3100, 2790, 2500, 2230, 1980, 1750, 1540, 1350, 1180, 1030, 900, 780, 680, 590, 510, 440, 380, 330, 285, 245, 210, 180, 155, 133 }; int16_t get_ntc_temp(uint16_t adc_val) { if (adc_val ntc_adc_table[0]) return -400; /* 低于 -40℃ */ if (adc_val ntc_adc_table[NTC_TABLE_SIZE-1]) return 1250; for (uint8_t i 0; i NTC_TABLE_SIZE - 1; i) { if (adc_val ntc_adc_table[i] adc_val ntc_adc_table[i1]) { /* 线性插值adc_val 越小温度越高 */ uint16_t span ntc_adc_table[i] - ntc_adc_table[i1]; uint16_t offset ntc_adc_table[i] - adc_val; int16_t temp i * 50 (int16_t)((int32_t)offset * 50 / span); return temp - 400; /* 表从 -40℃ 开始每格 5℃ */ } } return 1250; }这里对分压电阻的选型说一句源码里默认分压电阻为10K但NTC在25度时的阻值也是10K意味着ADC的输入区间被压缩在VCC/2附近低温时NTC阻值增大ADC读数逼近满量程分辨率反而变差。如果项目工作温度下限是-20度我建议把分压电阻改为4.7K让采样区间往高温段偏移换来的好处是-20度到0度区间的AD值变化更明显温度分辨力提升接近一倍。源码里的表参数只适用于10K对10K的组合改电阻后要重新生成整套查表值。3. SOC估算不是算出来的是猜出来的从开路电压到安时积分的融合策略SOC算法是整套BMS源码里最玄学的一块。很多开源代码只有安时积分跑几轮完整充放之后误差会累积到让你怀疑电芯容量。这份源码采用的是开路电压法加安时积分的混合策略静置足够久、电流小于某个阈值时直接用OCV查表修正SOC动态过程中用安时积分滚动更新同时根据充放电过程中的端电压变化做一些动态修正。3.1 OCV-SOC 标定表的生成方法OCV标定表的准确性直接决定SOC起跳点。源码里出厂带了一张表但那是针对某一款电芯的换了电芯必须重新标定。常见做法是先把电芯按0.2C恒流充满静置2小时以上测端电压再以0.2C放掉5%容量继续静置1小时测端电压重复直到放空。把每个静置点的电压和对应SOC写进查表函数。这张表并非越小越好中间段SOC 30%-70%的OCV曲线本身平坦单纯依赖OCV查表的误差可能在5%以上。所以源码里加了一个限制条件只有当静置时间超过30分钟、电流绝对值小于额定容量的0.02C时才允许用OCV结果修正SOC。如果你手头的电池是磷酸铁锂OCV曲线在中间段更平这个修正阈值建议收紧到只在高SOC和低SOC区间执行否则会把一个正确的SOC“修歪”到错误位置。3.2 安时积分的修正时机与防积分饱和/* SOC 实时计算单位 0.1% */ int16_t soc_current; /* 当前 SOC */ int32_t coulomb_count; /* 累积电量单位 mAh */ int16_t rated_cap 10000; /* 额定容量 10Ah按实际电池修改 */ #define OCV_HYSTER_VOLTAGE 50 /* 静置判定电压波动单位 mV */ #define OCV_HYSTER_CURRENT 200 /* 静置判定电流阈值单位 mA */ void soc_task(void) { int16_t cur_mv get_total_voltage(); int16_t cur_ma get_bus_current(); /* 静置条件满足时走OCV修正 */ if (abs(cur_ma) OCV_HYSTER_CURRENT is_still_voltage(cur_mv, OCV_HYSTER_VOLTAGE)) { int16_t soc_ocv ocv_to_soc(cur_mv); soc_current (soc_current * 3 soc_ocv) / 4; /* 加权防止跳变 */ } else { /* 安时积分电流正为放电负为充电 */ coulomb_count cur_ma * SAMPLE_PERIOD_MS / 1000; int16_t soc_delta (int16_t)((int32_t)coulomb_count * 1000 / rated_cap); if (coulomb_count rated_cap) coulomb_count - rated_cap; if (coulomb_count -rated_cap) coulomb_count rated_cap; soc_current 1000 - soc_delta; /* 1000 表示 100.0% */ } /* 归一化防止溢出和越界 */ if (soc_current 0) soc_current 0; if (soc_current 1000) soc_current 1000; }上面的积分代码做了防饱和处理当累积电量超过额定容量一个方向时直接回卷成反向的剩余值避免int32溢出。OCV修正采用加权平均而非直接覆盖权重是3比1这样没被稳妥标定的电芯不会因为一次跳变把SOC从60%甩到40%。如果你系统里有库仑计比如电流采样分辨率到1mA可以适当把OCV权重降低到2比1甚至1比1但前提是OCV表足够准。静置判定的两个参数要仔细调电压阈值50mV是指静置前后时刻的电压差小于50mV才算稳定但就算电芯真的稳定了由于NTC温度变化带来的电压漂移也可能超过这个值。如果你的系统跑在温差大的环境我建议牺牲一点精度把阈值放宽到80mV修正频率降下来反而更稳。3.3 充放电末端SOC 的收尾策略不能硬怼接近充满和接近放空时SOC计算最容易令人头疼。亲测过的场景电池充到4.2V单体时SOC显示只有92%再充半小时跳到100%——这是末期电压曲线陡OCV表间隔太大线性插值在其中丢失了精度。源码里给了一个修正思路检测到单体电压超过4.15V或低于2.9V且电流小于某个值时强制把SOC朝100%或0%方向以每秒钟0.1%的步进拉近这样即使没有高精度表用户也看不到跳变的数字UI显示是平滑的。4. 电池管理系统BMS CAN通信从报文ID规划到周期抖动的处理CAN总线是BMS和整车控制器之间的唯一信息通路这套源码的CAN模块写得相当直接标准帧250Kbps波特率发送周期分三档——快报周期100ms电压、电流、SOC、慢报周期500ms温度、报警码、故障帧随时发送。整套报文ID规划要提前设计和约定否则两套不同厂商的设备联调时你会被ID冲突坑到怀疑人生。4.1 报文ID与周期规划一张表说清主从关系BMS作为从节点往总线上发的报文ID通常落在0x180到0x1C0区间每帧数据里包含8字节。源码里划分了三个ID群分别是报文ID周期数据内容字节长度0x181100msSOC、总电压、总电流8字节0x182100ms最高单体电压及编号、最低单体电压及编号8字节0x183500ms最高温度、最低温度、均衡状态8字节0x184500ms故障码、预警码、保护状态字8字节0x185事件触发单节电压详细表分包发送8字节如果你接的设备是车企的VCU他们通常会有自己的报文矩阵你的ID要按对方矩阵来改而不是用源码默认值。改ID时要注意把ID改掉还不够滤波配置表也要同步更新源码里有一张接收滤波白名单漏了它你会发现自己发的帧永远进不了接收邮箱。4.2 CAN发送缓冲与优先级处理别让故障帧排队/* CAN 报文发送区分优先级故障帧可以抢占 */ typedef struct { uint32_t id; uint8_t data[8]; uint8_t len; uint8_t prio; /* 0 最高发不出去就丢弃旧缓冲 */ } can_msg_t; can_msg_t fast_queue[8]; /* 100ms 周期帧 */ can_msg_t slow_queue[4]; /* 500ms 周期帧 */ void can_send_task(void) { can_msg_t *fault_msg get_fault_frame(); if (fault_msg ! NULL) { /* 故障帧直接发送不等待周期 */ CAN_SendFrame(fault_msg-id, fault_msg-data, fault_msg-len); return; } static uint8_t fc 0, sc 0; if (fc 1) { /* 快报帧每次循环发一组 */ fc 0; for (uint8_t i 0; i 8; i) { if (fast_queue[i].prio 1) { CAN_SendFrame(fast_queue[i].id, fast_queue[i].data, fast_queue[i].len); } } } if (sc 5) { /* 慢报帧每5次循环发一组 */ sc 0; for (uint8_t i 0; i 4; i) { CAN_SendFrame(slow_queue[i].id, slow_queue[i].data, slow_queue[i].len); } } }这段代码把发送策略分成了两层故障帧通过get_fault_frame()实时获取一旦出现立刻发送不需要排队等周期。快报帧每次循环全发慢报帧每5个循环发一次。这里有一个容易被忽略的性能细节get_fault_frame()如果返回的是同一个全局缓冲区那么连续两次调用之间要加互斥锁否则主循环里更新缓冲区内容时CAN中断发送的正好是这一块数据会出现半帧错乱。我在实际项目里还会加一层发送超时检查如果连续3次发送失败总线关闭或缓冲满直接置一个本地变量提示用户CAN链路异常同时把BMS控制逻辑切到“故障安全模式”——允许继续放电但禁止充电这个是整车安全策略要求的底线。4.3 CAN诊断协议UDS Bootloader 与标定通道源码还带了一段UDS Bootloader走的是ISO 14229标准子集支持0x22读数据、0x2E写数据、0x31例程控制、0x34/0x36/0x37下载请求与传输。这套东西在产线上很有用标定工程师可以直接通过CAN刷写新的SOC标定表或修改保护阈值不需要开壳接调试器。需要注意UDS会话层的定时参数P2 Server待定时间出厂值是50ms如果整车CAN负载率超过60%这个定时很容易超时。源码里把P2可配置化我一般会调大到350ms代价是诊断响应变慢但产线测试通道稳定性优先宁可慢也不能超时中断。刷写Flash期间要断电看门狗和定时器中断这段代码里有专门的处理函数直接用就行但要注意刷写过程中一旦CAN掉线重新握手后要等待20秒以上才能再次进入编程会话具体时间在uds_config.h里定义。4.4 终端电阻和总线波特率软件再对也救不了硬件上的坑CAN总线物理层的坑比协议层多得多。最常见的翻车现场两个节点之间明明都配了120欧终端电阻但用万用表量CANH和CANL之间的阻值却是60欧——你多半是把两个终端电阻装在了同一端。另一个坑是波特率测试不通过时不要急着调分频系数先查总线上的隐性电平是否正常CANH和CANL之间的电压在静态时应该在2.5V附近偏离超过0.3V就意味着收发器供电域有问题。源码里波特率默认250K这个选择兼顾了线缆长度和抗干扰。如果你把线缆拉到10米以上我建议降到125K如果只是设备内部短走线比如BMS到继电器盒那直接上500K也没问题。改波特率只动一个宏定义但注意总线上所有节点要同步改不然你会发现自己的节点能发帧但收不到任何应答。5. BMS源码避坑指南电压跳变、CAN丢帧、均衡失效的五个真实翻车现场这一章整理了我在这类BMS项目里反复踩过、也看别人反复踩的坑。每一条都按现象、原因、解决来写希望能帮你在调代码前先把预期建立起来。5.1 现象单体电压在1分钟内跳变30mV以上怎么查都查不出问题原因AFE芯片输入端并的RC滤波电容坏了一颗或者电芯连接片松动导致接触电阻变大。不是软件问题但确实表现为软件问题——ADC采到的就是真实的端电压只是这个“真实”里包含了接触电阻的压降。解决先量连接片压降正常应为毫伏级如果超过10mV基本可以锁定松动如果压降正常再查RC网络的电阻电容是否虚焊。源码里可以加一个诊断位相邻两次采样电压跳变超过50mV时置一个“电压异常”报警帧发出去方便产线快速定位。5.2 现象CAN报文偶发丢帧重发三次还能丢原因多半是波特率误差超标。两个节点的晶振精度都是100ppm时理论上250K波特率累计误差不会超过0.5%但如果一方用了内部RC振荡器误差可能跑到2%以上总线上的仲裁就会出现“看起来没人发但没人收到”的灵异现象。解决用CANScope看一眼实际波特率不是示波器看波形是那种能解析位时序的仪表。如果确认是RC振荡器把CAN_BTR里的采样点位置从75%改到80%能多容忍一些相位误差。源码里采样点配置是SJW1TSEG113TSEG23这个组合在实际中兼容性不错但遇到极端的晶振偏差还是得用硬件晶振治本。5.3 现象低温下SOC越开越低明明满电却显示40%原因低温时电芯内阻增大同样的静置时间下端电压没回到OCV对应的平衡值OCV修正把SOC往低处拉加上安时积分里的电流采样偏置没有做温度补偿累积误差持续叠加。解决把温度补偿系数变成动态表源码里默认是固定系数0.5%/℃。按我的做法-10℃以下补偿系数提高到1.0%/℃同时把OCV修正的最低静置温度限制在5℃以上低于这个温度不再修SOC只靠安时积分。你可以在temp_comp_table[]里直接填系数配合一个低通滤波避免补偿随温度抖来抖去。5.4 现象均衡功能一直开着但压差一点没有改善反而某些电芯电压掉得更快原因被动均衡电阻在工作时确实发热了但均衡是“开-关-开-关”的振荡模式平均电流远低于预期。源码里的均衡策略是简单比较最高和最低电压差值超过50mV就开启均衡但这个策略忽略了电芯极化电压——刚放电完那几十秒电压偏低是因为极化没恢复此时均衡等于在放电态给低电压电芯做额外负载压差只会越拉越大。解决把均衡触发条件从“压差超50mV”改成“压差超50mV且电流小于5A持续30秒以上”也就是说只在静置或轻载状态下均衡。源码里balance_task()开头本来就有一个abs(cur_ma) BALANCE_CURRENT_GATE的判断默认是5000单位mA我觉得太宽松了压到3000更稳。5.5 现象BMS上电后第一次读电芯电压全是0xFFFF之后又恢复正常原因AFE芯片初始化时序问题。很多人喜欢上电后立刻拉CS、发读命令但AFE内部的基准源和采样电路需要稳定时间有的芯片要等几十毫秒才能读。解决上电后延迟100ms再初始化AFE初始化过程里读一次电压丢弃不用第二次再读才纳入滤波窗口。源码里afe_init()后面有一行HAL_Delay(50)但实测某些芯片蹲到80ms才稳保守起见直接改到120ms。这个坑属于最隐蔽的你在调试器里看永远不会触发因为调试器慢但脱机跑才露馅。6. 把BMS代码从“能跑”调到“可信”验证手段与调试习惯源码给你板子也拿到手接下来就是验证阶段。我习惯先做三个测试再上车第一个是静态功耗测试第二个是CAN总线持续72小时通信压力测试第三个是模拟故障注入。静态功耗测试要在所有外设上电但系统休眠时量BMS整机在关机状态功耗应该低于500μA如果高于这个值通常是CAN收发器没进sleep模式或者AFE芯片还留在normal模式。源码里的低功耗分支在power_save_task()里把CAN收发器强制打到standby同时把ADC采样周期拉长到原来的10倍可以显著降功耗。CAN通信压力测试建议写一个小脚本用USB-CAN卡往总线上发满负载的干扰帧每帧间隔2ms持续压测48小时以上观察BMS发的报文是否出现抖动超过1ms的情况。如果抖动量过大说明发送任务里做了太多耗时操作需要把计算任务挪到空闲中断里执行。故障注入测试最关键把单体电压输入用数字万用表短接到地模拟0V故障把温度传感器拔掉模拟开路故障把电流传感器信号反向模拟电流方向错误。每注入一个故障用CAN分析仪抓对应的故障帧是否在1秒内发出。这个测试我在几个项目里跑完每次都抓出过至少一个故障漏报或误报的case修完代码再上整车就踏实多了。调试过程中别忘了给源码的每一处修改做一张改动记录表我在开发某跨平台系统的BMS代码时把每次CRC校验算法调整和通信重连逻辑改动都记成表格后来做产线回归时直接按表走测试用例省了至少两天的排查时间。从那以后我每次拿到新BMS源码都强制先做一遍“电压校准-电流校准-温度校准-保护阈值确认”的基线流程再动任何策略参数确认无误后把这份配置固化进EEPROM里保存一份存档。希望帮到你让电池管理少点玄学多点可复现的确定性。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站