☰
基于STM32F103与BQ76920的锂电池BMS设计实战
2026/11/3 16:52:49 网站建设 项目流程

简介:这套基于STM32F103与BQ76920的锂电池BMS系统设计,以C语言完成核心逻辑,面向高校电子、自动化、计算机等专业师生及嵌入式开发工程师,适用于课程实践、毕业设计、科研预研或电池管理原型开发。压缩包约26.76MB,内含C语言源代码与完整工程文件,覆盖电压/电流/温度采集、过压欠压过流过热保护、SOC估算与均衡控制等关键模块,代码经实际运行验证,可直接部署于STM32F103平台;设计上预留了阈值调整与通信扩展空间,便于开发者为不同容量电池组二次开发。目前已有59人学习下载,工程内包含完整的初始化配置与中断处理流程,对深入理解ADC采样、状态机调度和锂电保护策略很有帮助,嵌入式初学者可结合实物板卡边读源码边验证。资源仅用于个人学习与技术交流,请勿用于商业用途。

1. 一块18V电池包背后的BMS:为什么选STM32F103加BQ76920

很多工程师第一次认真考虑自研BMS,不是因为在做电动车项目,而是被一个18V电钻电池包炸醒的:专用保护IC只有过充过放,客户却要你上报每节电芯电压、估算剩余电量、记录故障历史。这个时候,保护板已经不只是保护板,得换成一套能用C语言控制的电池管理系统。STM32F103和BQ76920这个组合,是入门自研BMS最稳妥的一条路:前者是工程师最熟悉的MCU,后者是TI专为3到5节锂电设计的模拟前端,两者通过I2C通信。你不需要一开始就啃复杂的电量计算法,先把电压、电流、温度读准,把保护逻辑跑稳,就能覆盖绝大多数便携设备、电动工具和轻型储能场景。

这篇文章不打算给你一个完整开源工程的逐行注释,而是按硬件接线、C语言驱动、保护状态机、联调避坑、验证手段这条线,讲清楚做这一套系统需要理解的关键设计点,以及那些翻车之后才知道的细节。

2. BMS硬件架构与接口选型:BQ76920负责感知,STM32F103负责策略

任何一个可用的BMS硬件,都绕不开“谁采集、谁决策、谁执行”这三个角色。BQ76920负责采集电芯电压、电流、温度,也自带一组模拟比较器用于硬件级保护;STM32F103则通过I2C读取这些采集结果,跑保护策略、计算状态、控制充电和放电通路。这套分工决定了你画板子和写驱动时的模块边界。

2.1 bms和bmu的区别:BQ76920不是完整BMS,它是模拟前端

不少刚接触这个领域的硬件工程师会把BQ76920直接叫作“BMS芯片”,这个说法在选型评审时容易被较真的人挑出来。准确一点讲,BQ76920是BMU(Battery Monitor Unit),也就是电池监控单元,负责把电芯的模拟量变成数字量,并执行硬件级的过压、欠压、过流比较。完整意义上的BMS还要包含策略层和控制层——比如SOC估算、均衡策略、充放电MOS驱动、与外部主控的通信协议,这些是STM32F103要干的活。

BQ76920支持3到5节电芯串联,内部集成了ADC、库仑计输入端、三个热敏电阻通道和充放电保护FET的驱动引脚。它有一个ALERT引脚,采集到异常状态时会主动通知MCU,不需要MCU每毫秒轮询。你要是只做一个4串的电动工具电池包,BQ76920加上一颗STM32F103C8T6就完全够用;如果目标是十几串的动力电池,那就要换成BQ76930或者BQ76940这样的多串AFE,但驱动框架和通信协议是同一套思路。

这种BMU加MCU的方案,最大的好处是保护和策略解耦。即使MCU死机或者固件跑飞,BQ76920内部比较器依然能独立触发保护动作,把充放电FET断开。这是很多集成式BMS芯片做不到的——它把鸡蛋放在一个篮子里。做产品化设计时,这个冗余点往往是客户能否接受你方案的关键。

2.2 stm32f103最小系统与关键引脚分配:I2C、中断和充放电控制

如果你手头有现成的stm32f103最小系统板,验证阶段可以直接飞线接BQ76920评估板,不用先画完整PCB。但正式做产品时,我建议自己画一个精简最小系统,把MCU、晶振、复位、SWD调试口和电源都集成到BMS板上,避免用现成开发板带来的地线环路和装配问题。

下面是这套方案里我常用的引脚分配,重点是I2C、ALERT中断和充电放电使能:

信号MCU引脚方向说明
I2C1_SCLPB6输出接BQ76920 SCL,开漏加4.7k上拉
I2C1_SDAPB7双向接BQ76920 SDA,开漏加4.7k上拉
BQ76920_ALERTPA0输入配置为EXTI下降沿中断
CHG_ENPB0输出控制充电MOS的使能三极管
DSG_ENPB1输出控制放电MOS的使能三极管
UART2_TX/RXPA2/PA3输出/输入调试日志与上位机通信

I2C1是STM32F103原生支持的一个硬件I2C外设,但很多人最终会放弃它转用软件模拟I2C,原因我放到第3章细讲。ALERT引脚必须接在支持外部中断的引脚上,因为BQ76920的过压、欠压、过流告警都是通过这个引脚的电平变化通知MCU的,如果靠主循环轮询寄存器,反应时间会被循环周期拖慢。

CHG_EN和DSG_EN这两个引脚控制的是“是否允许充电/放电”,而不是直接驱动功率MOS。功率MOS的栅极由BQ76920的CHG和DSG引脚直接驱动,MCU这边用GPIO控制一个三极管或MOS管去开关充放电回路的使能电源。这种两级控制的好处是,即使MCU误拉高了CHG_EN,BQ76920检测到过压后照样能从硬件层面拉低CHG输出,形成冗余保护。

2.3 外围电路三个必查点:REGIN供电、分流电阻、NTC接法

BQ76920的外围电路不复杂,但三个点容易画错。第一是REGIN和REGOUT的关系。REGIN直接从电池组正端取电,经过芯片内部稳压后从REGOUT输出一个低压电源,这个电源可以用来给外部上拉电阻、光耦或者逻辑电路供电。很多第一次画板的人以为REGOUT会默认输出,实际必须在SYS_CTRL1寄存器里置位REGOUT_EN才会打开。如果你的固件初始化顺序不对,REGOUT永远是0V,I2C上拉没有电源,整条总线直接哑掉。

第二是分流电阻的布局。BQ76920的电流采集是通过外部分流电阻两端差分电压实现的,这个电阻一般选0.5mΩ到2mΩ,取决于你的最大持续电流。画PCB时,分流电阻的采样走线必须用开尔文接法,从电阻焊盘内侧单独引一对细线到芯片的SRP和SRN引脚,绝对不能直接铺铜连过去,否则铜箔上的压降会把电流误差放大到不可接受的程度。

第三是热敏电阻通道。BQ76920提供TS1、TS2、TS3三个温度采集引脚,每个引脚内部有可配置的电流源,接NTC后通过ADC测电压换算温度。这里有个新手容易犯的错误:看到STM32F103内部也有温度传感器,就想省掉NTC直接用MCU内部温度。答案是不行,MCU内部温度传感器测的是芯片自身温度,跟电芯温度没有任何关系,而且误差经常超过正负5度。电池组的热管理必须用贴在电芯表面的NTC,这个没得省。

3. 用C语言写BQ76920驱动:模拟I2C、PEC校验与初始化序列

硬件画完,接下来的核心工作就是用C语言把BQ76920的寄存器读写跑通。BQ76920本身不复杂,它就是一颗I2C从机,但你一旦启用PEC校验,通信帧的拼装、校验和多字节读写的时序都要处理干净,否则会因为偶发错帧导致保护寄存器被写成错误值。

3.1 驱动分层与标准库V3.5.0工程组织:把驱动从业务里剥出来

工程组织上,我习惯沿用stm32f103标准库V3.5.0的模板,把代码分成四个目录:core(启动文件和时钟配置)、periph(GPIO、定时器、DMA等外设封装)、bms(BQ76920驱动、保护逻辑、状态机)、app(主循环和业务逻辑)。BMS驱动绝不直接散落在main.c里,因为后续你要换BQ76930或者改成SPI接口的AFE芯片时,只需要重写bms目录下的驱动文件,策略代码一行都不用动。

驱动层内部再分两个文件:bq76920_i2c.c负责最底层的I2C字节收发,bq76920.c负责寄存器读写、PEC计算和功能函数封装。业务层只跟bq76920.c的接口函数打交道,不感知底层是软件I2C还是硬件I2C。这个分层在联调时价值很大,出了问题能快速定位是通信层的问题还是策略层的问题,不用拿着逻辑分析仪去逐行查业务代码。

3.2 模拟I2C实现:STM32F103硬件I2C的坑与绕行

先给出我常用的软件I2C实现。GPIO配置为开漏输出,外部接4.7kΩ上拉电阻到3.3V,这样可以避免输出模式切换带来的毛刺:

#define I2C_SCL_PORT GPIOB #define I2C_SCL_PIN GPIO_Pin_6 #define I2C_SDA_PORT GPIOB #define I2C_SDA_PIN GPIO_Pin_7 #define SCL_HIGH() GPIO_SetBits(I2C_SCL_PORT, I2C_SCL_PIN) #define SCL_LOW() GPIO_ResetBits(I2C_SCL_PORT, I2C_SCL_PIN) #define SDA_HIGH() GPIO_SetBits(I2C_SDA_PORT, I2C_SDA_PIN) #define SDA_LOW() GPIO_ResetBits(I2C_SDA_PORT, I2C_SDA_PIN) #define SDA_READ() GPIO_ReadInputDataBit(I2C_SDA_PORT, I2C_SDA_PIN) void i2c_gpio_init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); gpio.GPIO_Pin = I2C_SCL_PIN | I2C_SDA_PIN; gpio.GPIO_Mode = GPIO_Mode_OUT_OD; // 开漏输出,配合外部上拉 gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(I2C_SCL_PORT, &gpio); SCL_HIGH(); SDA_HIGH(); } static void i2c_delay(void) { volatile uint32_t t = 400; while (t--) ; } static void i2c_start(void) { SDA_HIGH(); SCL_HIGH(); i2c_delay(); SDA_LOW(); i2c_delay(); SCL_LOW(); i2c_delay(); } static void i2c_stop(void) { SCL_LOW(); i2c_delay(); SDA_LOW(); i2c_delay(); SCL_HIGH(); i2c_delay(); SDA_HIGH(); i2c_delay(); } static uint8_t i2c_write_byte(uint8_t dat) { for (uint8_t i = 0; i < 8; i++) { if (dat & 0x80) SDA_HIGH(); else SDA_LOW(); dat <<= 1; i2c_delay(); SCL_HIGH(); i2c_delay(); SCL_LOW(); i2c_delay(); } SDA_HIGH(); // 释放SDA,等待从机ACK i2c_delay(); SCL_HIGH(); i2c_delay(); uint8_t ack = (SDA_READ() == 0) ? 1 : 0; SCL_LOW(); i2c_delay(); return ack; } static uint8_t i2c_read_byte(uint8_t ack) { uint8_t dat = 0; SDA_HIGH(); // 释放SDA,让从机驱动数据线 for (uint8_t i = 0; i < 8; i++) { dat <<= 1; SCL_HIGH(); i2c_delay(); if (SDA_READ()) dat |= 0x01; SCL_LOW(); i2c_delay(); } if (ack) SDA_LOW(); else SDA_HIGH(); // 最后一个字节回NACK SCL_HIGH(); i2c_delay(); SCL_LOW(); i2c_delay(); SDA_HIGH(); return dat; }

这段代码的逻辑很简单:i2c_start和i2c_stop严格按I2C时序翻转SCL和SDA,i2c_write_byte在8个时钟周期内把数据移位输出,并在第9个时钟读取从机ACK;i2c_read_byte在8个时钟周期内读取数据,然后根据参数决定回ACK还是NACK。i2c_delay用空循环产生大约1微秒的延时,整个时序跑在100kHz标准模式,对BQ76920这种低速器件完全够用。

为什么绕开STM32F103的硬件I2C?因为F103的硬件I2C在总线发生异常时序后,状态机容易卡在BUSY状态,软件复位都未必能立刻拉回来。很多工程师把这个现象归结为玄学,实际是芯片设计的问题,社区里早就有了共识。你要在量产产品上用硬件I2C,必须加大量的超时恢复逻辑,得不偿失。软件I2C虽然占CPU,但对于BQ76920这种几百Hz采样率的器件来说,CPU开销可以忽略。

3.3 带PEC的寄存器读写:BQ76920通信帧完整实现

BQ76920支持PEC(Packet Error Checking)校验,能让每个通信帧都带上CRC-8校验字节,防止总线干扰导致寄存器被写成错误值。CRC算法使用多项式0x07,初值为0,计算范围覆盖从机地址、寄存器地址和数据字节。下面是校验函数和读写函数:

#define BQ76920_I2C_ADDR 0x18 // 7位地址 #define BQ76920_W (BQ76920_I2C_ADDR << 1) // 0x30 #define BQ76920_R ((BQ76920_I2C_ADDR << 1) | 1) // 0x31 static uint8_t bq76920_pec(uint8_t *buf, uint8_t len) { uint8_t crc = 0; for (uint8_t i = 0; i < len; i++) { crc ^= buf[i]; for (uint8_t b = 0; b < 8; b++) { if (crc & 0x80) crc = (uint8_t)((crc << 1) ^ 0x07); else crc <<= 1; } } return crc; } uint8_t bq76920_write_reg(uint8_t reg, uint8_t val) { uint8_t buf[4] = {0}; i2c_start(); if (!i2c_write_byte(BQ76920_W)) { i2c_stop(); return 1; } if (!i2c_write_byte(reg)) { i2c_stop(); return 1; } if (!i2c_write_byte(val)) { i2c_stop(); return 1; } buf[0] = BQ76920_W; buf[1] = reg; buf[2] = val; if (!i2c_write_byte(bq76920_pec(buf, 3))) { i2c_stop(); return 1; } i2c_stop(); return 0; } uint8_t bq76920_read_reg(uint8_t reg, uint8_t *val) { uint8_t buf[4] = {0}; uint8_t rx_pec; i2c_start(); if (!i2c_write_byte(BQ76920_W)) { i2c_stop(); return 1; } if (!i2c_write_byte(reg)) { i2c_stop(); return 1; } buf[0] = BQ76920_W; buf[1] = reg; if (!i2c_write_byte(bq76920_pec(buf, 2))) { i2c_stop(); return 1; } i2c_start(); if (!i2c_write_byte(BQ76920_R)) { i2c_stop(); return 1; } *val = i2c_read_byte(1); // 第1个数据字节,回ACK以继续读PEC rx_pec = i2c_read_byte(0); // 第2个字节是PEC,回NACK结束事务 i2c_stop(); buf[0] = BQ76920_R; buf[1] = reg; buf[2] = *val; if (bq76920_pec(buf, 3) != rx_pec) return 2; // PEC不匹配 return 0; }

读写帧的结构需要仔细理解。写寄存器时,主机发出从机地址、寄存器地址、数据字节之后,还要追加一个PEC字节,PEC的计算范围是“写地址+寄存器地址+数据”三个字节。读寄存器时分两段:第一段发送写地址和寄存器地址并追加PEC,第二段重新发送读地址,然后从从机读取数据字节和PEC字节,最后校验PEC是否一致。这个两段式结构是BQ76920系列比较特殊的地方,不少人第一次用其他TI电量计芯片的习惯来写,结果读回来的永远是0xFF。

使能PEC之后,任何不带PEC的帧都会被从机忽略。所以如果你在调试阶段发现寄存器写不进去,先检查是不是初始化时开了PEC但读写函数没带校验字节。我在代码里建议始终启用PEC,因为I2C总线在电机驱动或者MOS开关附近很容易受到干扰,错一帧数据对保护逻辑来说可能就是灾难。

3.4 初始化配置序列:REGIN使能、PEC开启、ADC启动

BQ76920上电后的初始化顺序是有讲究的。我习惯按下面这个流程配置:

void bms_hw_init(void) { uint8_t stat = 0xFF; /* 第1步:读SYS_STAT,确认通信建立了再往下走 */ if (bq76920_read_reg(0x00, &stat)) { /* 通信失败,需要检查硬件和上拉 */ return; } /* 第2步:SYS_CTRL1,置位REGOUT_EN(0x80)和PEC_EN(0x40) */ bq76920_write_reg(0x02, 0xC0); /* 第3步:配置ADC采样选项并启动转换 */ bq76920_write_reg(0x11, 0x41); // ADCOPT,选择ADC时钟和采样模式 bq76920_write_reg(0x12, 0x00); // ADCCTL,启动连续转换 /* 第4步:写保护阈值,编码值需要按电芯规格查手册换算 */ bq76920_write_reg(0x05, BQ76920_OV_TRIP_CODE); bq76920_write_reg(0x06, BQ76920_UV_TRIP_CODE); /* 第5步:使能硬件级保护功能 */ bq76920_write_reg(0x04, BQ76920_PROTECT_EN_MASK); }

初始化流程的第1步我建议保留:上电后先读一次SYS_STAT寄存器,确认I2C通信真正建立,再往里写配置。这样做的好处是,如果硬件接错或上拉电阻没焊,你能在初始化阶段就发现,而不是等到保护不动作时再排查。第3步里ADCOPT和ADCCTL的取值直接决定采样速度和功耗,对电池包这种常驻系统来说,ADC采样周期要根据你需要的响应速度来选,不是越快越好,越快功耗越高。

初始化完成之后,BQ76920会按配置周期性地转换电压、电流和温度,结果存放在对应的数据寄存器里。STM32这边要做的事情,就是按一定周期去读这些寄存器,然后跑保护逻辑。读取周期我一般设置在100ms到500ms之间,这个频率对电芯保护足够,又不会把I2C总线占用得太厉害。

4. 采集换算与保护状态机:从寄存器原始值到充放电控制

通信打通、驱动能读写寄存器之后,真正体现BMS设计水平的地方在于:怎么把原始码值换算成有物理意义的毫伏和毫安,怎么设计保护阈值和恢复条件,怎么用状态机把充放电过程管起来。

4.1 电压与电流数据读取:16位原始值怎么换算成mV和mA

BQ76920的电芯电压和电流都通过ADC转换,数据存放在16位的寄存器中,高位寄存器和低位寄存器在地址上是连续的。读取时我用一个通用函数,避免每个数据项都写一遍I2C逻辑:

/* 电芯电压寄存器起始地址,具体宏以手册为准 */ #define BQ76920_CELL1_LO 0x08 #define BQ76920_CELL1_HI 0x09 uint16_t bms_read_cell_mv(uint8_t cell_index) { uint8_t lo = 0, hi = 0; uint16_t raw; bq76920_read_reg(BQ76920_CELL1_LO + cell_index * 2, &lo); bq76920_read_reg(BQ76920_CELL1_HI + cell_index * 2, &hi); raw = ((uint16_t)hi << 8) | lo; /* 原始码值乘以校准系数得到毫伏,系数为千分值避免浮点 */ return (uint16_t)((uint32_t)raw * g_cell_mv_per_lsb / 1000); } int16_t bms_read_current_ma(void) { uint8_t lo = 0, hi = 0; int16_t raw; bq76920_read_reg(BQ76920_CC_LO, &lo); bq76920_read_reg(BQ76920_CC_HI, &hi); raw = (int16_t)((hi << 8) | lo); return (int16_t)((int32_t)raw * g_i_ma_per_lsb / 1000); }

这里的换算系数g_cell_mv_per_lsb和g_i_ma_per_lsb不用一开始就查手册填理论值。我一般先按1mV/LSB和1mA/LSB跑通代码,然后拿万用表实测电芯电压,对比原始码值,用“实测毫伏除以原始码值”得出一个初始系数。多测两个电压点取平均,再把这个系数固化成常量。电流换算同理,用电子负载加恒定电流,把读回来的原始码值跟实际电流对比算系数。

校准系数是BMS产品化的一件后悔药:同一个硬件,换一批电芯或者换一个分流电阻批次,系数就要微调。所以我建议在产品里留一个通过串口指令在线修改系数的后门,产线校准的时候写进Flash,而不是让生产工人去改源码重新编译。

4.2 过充、过放、过流保护参数设计:阈值、迟滞与恢复条件

BQ76920的保护分为硬件保护和软件保护两层。硬件保护由芯片内部的模拟比较器完成,阈值配置在OV_TRIP、UV_TRIP、PROTECT这些寄存器里,触发后BQ76920直接控制CHG和DSG引脚断开MOS,不需要MCU参与,响应时间在毫秒级。软件保护则由STM32在自己的主循环里做判断,好处是阈值可以随时修改,还能加上温度、压差等硬件比较器不覆盖的条件。

以常见的4串锂电包为例,我通常这样配置:

保护项硬件阈值(示例)软件阈值恢复条件
单节过充4.25V4.20V告警,4.25V断充电电压低于4.10V且持续2秒
单节欠压2.80V2.90V告警,2.80V断放电电压高于3.00V且持续2秒
充放电流硬件比较器限制软件按工况分级电流回落到阈值以下持续1秒
温度不依赖硬件0至45度充,-20至60度放温度回落到边界内持续5秒

设计保护参数时,阈值和恢复条件一定要分开,不能用一个固定值既做触发又做恢复。比如过充触发是4.25V,如果恢复条件也是4.25V,电池在临界点来回抖动会导致MOS反复开关,一个晚上就能把继电器触点或者MOS管折腾坏。正确的做法是加迟滞:触发保护用一个较高阈值,恢复条件降低一些,再叠加时间条件,过滤掉瞬时尖峰。

4.3 主循环状态机实现:IDLE、充电、放电、FAULT

BMS主循环本质是一个状态机,状态之间的迁移依据是采集到的电压、电流和温度。我实现了一个简化版本:

typedef enum { BMS_STATE_IDLE = 0, BMS_STATE_CHARGE, BMS_STATE_DISCHARGE, BMS_STATE_FAULT, } bms_state_t; static bms_state_t g_state = BMS_STATE_IDLE; static uint8_t g_fault_reason = 0; #define BMS_FAULT_OV 0x01 #define BMS_FAULT_UV 0x02 #define BMS_FAULT_OC 0x04 #define BMS_FAULT_OT 0x08 void bms_loop(void) { uint16_t cell_mv[4]; int16_t cur_ma; uint8_t flags = 0; /* 1. 采集最新数据 */ for (uint8_t i = 0; i < 4; i++) { cell_mv[i] = bms_read_cell_mv(i); } cur_ma = bms_read_current_ma(); /* 2. 依次判定各保护条件,汇总到flags */ for (uint8_t i = 0; i < 4; i++) { if (cell_mv[i] > BMS_OV_LIMIT_MV) flags |= BMS_FAULT_OV; if (cell_mv[i] < BMS_UV_LIMIT_MV) flags |= BMS_FAULT_UV; } if (cur_ma > BMS_DIS_OC_LIMIT_MA) flags |= BMS_FAULT_OC; /* 3. 有故障则进入FAULT,无故障则从FAULT恢复 */ if (flags) { bms_enter_fault(flags); } else { bms_clear_fault(flags); } /* 4. 根据电流方向或使能信号决定工作状态 */ if (g_state != BMS_STATE_FAULT) { g_state = bms_decide_run_state(cur_ma); } }

状态机的逻辑要点在于:故障进入要快,故障恢复要慢。bms_enter_fault里除了记录故障原因,还要立刻关掉CHG_EN和DSG_EN对应的GPIO,同时把故障码存到备份寄存器或Flash,方便事后排查。bms_clear_fault里实现恢复条件:先检查故障标志是否消失,再检查恢复延时是否满足,两者都通过才允许从FAULT回到IDLE。

bms_decide_run_state这个函数根据当前电流方向和系统使能信号决定状态:有充电使能且电流为负,进入充电态;有放电使能且电流为正,进入放电态;两者都没有,回到空载态。这里的电流方向正负取决于分流电阻的接入方向,实际调试时以你定义的充电电流符号为准。

5. 联调避坑:I2C死锁、上电时序、串口丢数据与寄存器失效

BMS硬件和固件都做好之后,真正让人掉头发的是联调阶段。这里把我踩过的一些坑列出来,每一条都是现象、原因、解决三段式,方便你对照排查。

5.1 STM32F103硬件I2C总线忙死锁:是玄学但能解

现象:初始化代码执行到I2C_GenerateSTART()之后,程序卡死在等待标志位的循环里,读I2C->SR1发现BUSY位一直为1。或者更诡异:第一次读写正常,程序跑了一会儿再调I2C接口,返回全是超时。

原因:STM32F103的硬件I2C在从机无ACK、总线被外部干扰拉低、或者主机在错误时序下发起操作时,内部的仲裁状态机可能进入不可恢复的BUSY状态。GPIO复位寄存器只能恢复引脚的电气状态,恢复不了I2C外设内部的状态机。

解决:我最后选择彻底放弃硬件I2C,改用软件模拟I2C,就是第3章那套代码。如果项目里一定要用硬件I2C,至少要在每次操作前加超时判断,超时后把I2C外设彻底去初始化再重新初始化,并且把SCL线翻转9个时钟周期尝试释放可能被从机锁住的总线。但说实话,这套恢复代码写下来,比软件I2C的开销还要大,没有必要。

5.2 BQ76920上电后REGIN有了但REGOUT没输出:查这三个点

现象:板子供电正常,用万用表量REGIN引脚电压正确,但REGOUT引脚始终是0V,I2C通信完全无响应,示波器看SDA上拉电压都拉不起来。

原因:第一,固件里没有置位REGOUT_EN寄存器位,芯片默认不输出;第二,REGOUT输出端的电容取值太大或者漏电,导致启动时过流保护;第三,REGIN电压刚好落在芯片的欠压锁定门限附近,芯片反复进入上电复位。

解决:初始化流程里第一件事就是配置SYS_CTRL1置位REGOUT_EN,这个步骤不能省。REGOUT电容我一般用2.2uF陶瓷电容,不要用钽电容或者大容量的电解电容。REGIN需要确认在芯片规定的正常工作范围之内,如果用的是4串电池组空载电压偏高的情况,中间要加一级预稳压或者适当降低串联电芯数。

5.3 串口中断接收掉数据包:调试口别拖垮BMS主循环

现象:通过串口每秒打印一次BMS状态数据,在电池包带载放电时,日志会随机丢掉几个字节甚至一整包,偶尔还会出现打印内容错位。把波特率从115200降到9600之后,现象减轻但仍存在。

原因:串口接收用的逐字节中断方式,每接收一个字节就进一次中断。如果主循环正在执行I2C读写或者其他比较长的临界区,串口中断得不到及时响应,接收数据寄存器的内容还没被取走就被下一个字节覆盖,产生ORE溢出错误。BMS主循环里I2C等待从机应答的时间是不确定的,这两个事件很容易撞在一起。

解决:最简单的办法是把调试串口改成DMA接收,配合空闲中断判断一包数据结束。实在调不出来,也有个偏方:用定时器实现软件串口,把波特率降到38400以下,给主循环留出足够的处理时间。但这只是过渡方案,量产固件里建议彻底去掉调试串口,只保留一个可配置的静默模式,用的时候再开启。

5.4 电压采集跳动和偏大:校准系数与滤波顺序

现象:同一节电芯,万用表量出来3.600V,BMS读回来3.620V,而且在3.610V到3.635V之间来回跳动,跳动幅度超过20mV。

原因:硬件层面,可能是采样走线太长引入了噪声,或者分流电阻的地线没处理好导致ADC参考地被抬高;软件层面,可能是校准系数没标定,且没做任何滤波处理,直接把单次转换结果拿来做保护判断。

解决:先做硬件检查,把电芯采样线的位置从MOS管附近挪开,确保模拟地和功率地在PCB上单点连接。软件上,我加了一个简单的滑动平均滤波,取最近4次采样做平均,并且把滤波放在保护判断之前。但要注意:滤波会延迟保护响应,过压这种高速保护用滤波后的值去做判断等于自废武功。正确做法是硬件保护用原始值快速触发,软件层面只用滤波值做状态显示和SOC计算。

5.5 写进去的寄存器不生效:PEC、回读与喂狗三件事

现象:初始化代码执行完没有报错,读回OV_TRIP寄存器也是期望值,但真把电池充到设定电压时,BQ76920没有触发保护,MOS管照常导通。

原因:大概率是PEC计算错误,写入的帧被从机拒绝,但I2C主机侧没有感知到。也可能是BQ76920的内部看门狗超时,把配置过的保护功能重置回了默认状态,保护寄存器被清零,而你读到的值是默认值恰好跟期望值一样。

解决:写完关键寄存器之后必做回读,把回读值和写入值对比,不一致就打印错误。同时检查PEC使能后的帧格式,确认PEC计算范围覆盖到了从机地址和寄存器地址。关于看门狗,BQ76920要求主机周期性地跟它通信,否则会触发一个保护关闭动作,把CHG和DSG输出禁掉。所以主循环里要有一个专门的喂狗操作,我习惯用100ms定时器周期性地读一次SYS_STAT寄存器,既喂了狗,又顺带确认通信链路正常。

6. 进阶验证技巧:逻辑分析仪抓I2C帧,离线仿真跑保护策略

通信调通、保护逻辑跑起来之后,不要急着拿真电池组做满充满放试验。先花半天时间做好验证,能省下后面大量的返工时间。

6.1 先抓一次完整I2C事务:把BQ76920通信从黑匣子变成明盘

把逻辑分析仪的通道接在SCL和SDA上,采样率调到1MHz以上,触发条件设置为I2C地址0x30。跑一遍初始化代码,然后把逻辑分析仪的I2C协议解码打开,逐帧核对波形。重点检查三个地方:第一,帧头有没有合法的起始条件和停止条件;第二,ACK位是否每个都在正确位置出现,尤其是PEC字节后面的ACK;第三,PEC校验字节是否跟软件计算结果一致。

这一步能一次性排除引脚接反、上拉电阻缺失、I2C时序不达标、PEC计算错误这几类最隐蔽的问题。不要对着示波器肉眼看波形判断ACK,直接用协议解码器,可以精确到每个字节。

6.2 Renode和Simulink在BMS验证里能干什么、不能干什么

如果没有硬件板子,又想先验证STM32F103侧的代码逻辑,可以在Renode里跑STM32F103的仿真环境。Renode能模拟GPIO、串口和定时器,代码里基于软件I2C的逻辑可以在仿真环境里跑通。但这里有个边界:Renode模拟不了BQ76920的模拟前端,你没法在仿真环境里制造真实的过压场景。我的做法是给驱动层写一个mock接口,在仿真环境下用一段伪数据模拟寄存器返回值,用单元测试跑一遍状态机的迁移逻辑,确保代码里没有死循环或者数组越界。

如果你所在的团队习惯用Simulink做算法仿真,可以把保护状态机在Simulink里建模,输入电压、电流、温度的时间序列,观察状态输出的时序是否符合预期。但Simulink模型只能验证策略层的逻辑正确性,寄存器读写、I2C时序、PCB布局带来的干扰,还是要拿到真板上验证。

真板验证我建议按下面的表格做一轮最小用例集,每一项记录实测动作时间:

测试项注入条件期望动作实测记录
过充保护用程控电源将最高节电压缓慢升到4.30V充电MOS关闭,ALERT拉低,MCU记录故障动作时间应小于1秒
过放保护电子负载放电至最低节2.80V放电MOS关闭,系统进入休眠态动作时间应小于1秒
过流保护负载电流拉升至超过阈值放电MOS关闭,故障码记录动作时间应小于500ms
温度保护NTC用电阻箱模拟高温充放电路径断开按产品需求定

我现在开新板子,习惯是先写好I2C回读和喂狗逻辑,再画MOS驱动电路,逻辑分析仪通道也留好测试点,这些准备工作帮我挡掉过不少返工。做BMS就是这样,前期通信层的每一步都验证扎实,后期电池包整pack联调才有底气。希望帮到你。

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

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

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

立即咨询