简介:基于STM32F103与BQ76920的锂电池BMS系统设计资料包,面向锂离子电池组管理场景,提供多节串联电池电压、电流、温度采集与综合管理方案,并实现过压、欠压、过流、过热保护,以及SOC估算和均衡控制逻辑。工程源码经实际验证,稳定可靠,特别适合高校电子、自动化、通信等专业师生用于课程实践、毕业设计或原型开发,也可作为嵌入式初学者的完整工程实例。压缩包约26.76MB,包含已验证的C语言源码与工程文件,代码结构便于按模块理解,使用者可根据需要调整保护阈值、扩展通信接口或优化状态估计算法。目前已有59人学习浏览,资源可用于教学演示或科研预研,但仅限个人学习与技术交流,不得用于商业用途。使用时建议先查阅项目文档,并在理解设计原理的基础上进行二次开发。
1. 为什么在这个年代,还要自己用 STM32F103+BQ76920 攒一套 BMS
如果手头是一块 4~5 串的锂电池包,既要实时知道每节电芯电压、出问题能自动断充放电、还要能把数据上报给主控,用 STM32F103 搭配 BQ76920 做一套 BMS,几乎是成本最低、可控性最强的一条路。市面上的成品 BMS 板要么黑匣子不给协议,要么引脚和你的电池接口对不上;自己用这个组合做,硬件上只需要一个最小系统级的 STM32F103 板子加小几十个分立元件,软件上则是标准的 I2C 读取加状态机,C 语言实现的门槛并不高。这篇笔记适合已经有点单片机基础、想自己搞定电池保护的工程师——不聊选型报告,只讲能照着做的电路和代码。
2. STM32F103 与 BQ76920 的硬件分工:从采样链路到 MOS 驱动
2.1 职责划分:BQ76920 管测量与硬件保护,STM32F103 管策略
很多人第一次接触 BMS 会把 BQ76920 理解成“一块 BMS 芯片”,实际上它更像一个电池监测与保护前端,也就是俗称的 BMU。它负责三件事:把每节电芯电压通过内部 ADC 转成数字量、把过压/欠压/过流这些保护阈值在硬件层面实时比较、以及直接驱动充放电 MOS 管。真正的 BMS 管理逻辑——比如 SOC 怎么算、均衡什么时候开、故障怎么上报——全在 STM32F103 这一侧。搞清这个分工,代码才不会写乱:硬件保护是兜底,软件策略是上层,两层之间通过 I2C 交换数据。
这个分工也决定了选型逻辑。BQ76920 支持 3~5 串,内部有 12 位 ADC,自带 OV/UV/OC/SC 保护比较器,CHG/DSG 引脚可以直接推挽驱动 N-MOS,省掉了一堆分立比较器和基准源。STM32F103 在这里不干重活,72MHz 主频绰绰有余,IO 多、资源便宜,就算做 4 串电池包的完整管理,Flash 和 RAM 也只用了零头。整体 BOM 成本压下来之后,这套方案在 4S/5S 级电池包的原型验证和小批量产品里非常常见。
2.2 BQ76920 外围电路:REGIN、VS、CELLx 和 RC 滤波
BQ76920 的外围电路比想象中简单,但几个电源和采样引脚的接法必须较真。先看供电:电池组正极直接接到 VS 引脚,同时通过一个 100Ω 电阻接到 REGIN,REGIN 和地之间放 1μF 陶瓷电容,REGIN 内部 LDO 输出 REGOUT,REGOUT 对地再放一个 1μF 电容。REGOUT 默认输出 3.3V,给 BQ76920 内部逻辑供电的同事,也可以给板上其他芯片做参考,但不建议直接带 STM32F103,原因后面避坑章细说。
电芯电压采样方面,每节电芯正极需要经过一个 RC 低通滤波再进对应 CELLx 引脚,典型参数是串联 10kΩ 电阻、对地 100nF 电容,截止频率约 160Hz,能滤掉功率回路耦合过来的高频开关噪声。所有 CELLx 引脚的输入阻抗也要注意:如果做 3S 或 4S 电池包,没有用到的 CELLx 引脚不能悬空,必须和上一节有效电芯的正极短接在一起,否则内部 ESD 结构和漏电会让电压读数全部漂移。TS1/TS2 温度引脚接 NTC 热敏电阻分压,用来测电芯温度。
| 器件 | 推荐取值 | 说明 |
|---|---|---|
| R(REGIN) | 100Ω | 限制输入浪涌,配合 C 滤波 |
| C(REGIN) | 1μF X7R | 稳压器输入去耦,靠近芯片放置 |
| C(REGOUT) | 1μF X7R | 输出去耦,别用大容量电解 |
| R(CELLx) | 10kΩ | 采样线串联电阻 |
| C(CELLx) | 100nF X7R | 对地滤波电容,不要放太大否则 ADC 建立变慢 |
| R(SDA/SCL) | 4.7kΩ 上拉到 3.3V | I2C 总线上拉 |
| NTC | 10kΩ @25℃ | 配合固定分压电阻 |
2.3 充放电 MOS 的驱动电路:CHG/DSG 引脚怎么接到栅极
BQ76920 的 CHG 和 DSG 引脚是逻辑输出,用来控制充电 MOS 和放电 MOS。常见接法是低侧驱动:两个 N-MOS 串联在电池负极和 PACK- 之间,源极都朝 PACK-,背对背放置。CHG 引脚通过栅极电阻接到充电 MOS 的栅极,DSG 引脚接到放电 MOS 的栅极,每个栅极对源极加一个 10kΩ 下拉电阻,防止上电瞬间 MOS 误导通。栅极串联电阻取 10Ω~100Ω,兼顾开关速度和 EMI。
选 MOS 时注意两点:一是栅极电荷 Qg 要小,BQ76920 内部驱动能力和电荷泵能力有限,挂了两个大 Qg 的 MOS 之后开关边沿会变得很软;二是 DSG 驱动放电 MOS 的电压来自内部电荷泵,如果电池电压低于某一水平,驱动能力会下降,这时即使 CHG/DSG 逻辑输出正常,MOS 也可能工作在放大区。另外 BQ76920 的 CFETOFF 引脚可以强制关闭充电 FET,适合让 MCU 在过温等特殊情况下主动切断充电回路。把这个引脚接到 STM32F103 的一个 GPIO 上,软件可以随时介入。
2.4 布局上三个容易被抄错的点:开尔文采样、地平面和走线隔离
样板间阶段最容易翻车的地方不是原理图,而是 PCB 布局。第一个点是电芯采样线的走线:CELL1~CELL5 的信号应该从电池连接器独立引出,用细走线直接走到 RC 滤波电阻,不要借功率回路的走线,也不要和大电流路径共用一小段铜皮,否则大电流时铜皮上的压降会直接叠加进采样电压。第二个点是采样电阻的差分走线,如果方案里用了库仑计电流采样,SRP/SRN 两条线要用开尔文接法从采样电阻两端独立引出,并且先到 BQ76920 再去滤波电容,让滤波电容靠近芯片一侧,不能先滤波再进引脚,这样电流积分会偏。第三个点是 I2C 和功率线的隔离:SDA/SCL 不要和大电流充放电走线平行超过 2cm,开关节点的高 dv/dt 会通过寄生电容耦合进 I2C 线,轻则丢字节、重则通信卡死。整体地平面保持完整,BQ76920 底下的地不要被功率走线切断。
3. 先让 BQ76920 开口说话:标准库下的 I2C 通信与寄存器初始化
3.1 选择标准库 V3.5 的几个现实理由
STM32F103 的固件开发,到现在还有人纠结标准库还是 HAL。对这个 BMS 项目,我建议直接用标准库 V3.5 的工程模板。理由不是标准库比 HAL 先进,而是这个项目的外设只用 GPIO、I2C、USART、定时器这几个老面孔,标准库对这些外设的封装已经非常成熟,代码读起来一是一二是二,没有 HAL 那种层层句柄和回调带来的隐藏状态。BMS 这种讲究行为确定性的设备,你不需要 CubeMX 帮你生成几百行你可能永远不看的代码,反而需要能把每一个片内外设的寄存器行为看透。标准库的固件库函数风格本身就是一套很好的寄存器文档,配合英文注释,排查问题的时候能少绕很多弯。
用标准库还有一个现实原因:网上关于 STM32F103 的参考代码、最小系统板例程、软件模拟 I2C 的例子,十有八九是基于标准库写的。照着重现这套 BMS 时,你不需要先把资料翻译成 HAL 的写法,直接就能组合复用。工程模板可以自己搭,也可以从原子或野火的老例程里扒一个空的出来,只要确认启动文件和时钟配置是 V3.5 配套的那一套即可。
3.2 软件 I2C vs 硬件 I2C:BQ76920 对时序的脾气
BQ76920 的 I2C 接口本身不挑主机,但它对总线的空闲电平、起始条件和时钟低电平时间是有要求的。STM32F103 的硬件 I2C 外设功能上完全够用,问题是 F103 早期版本的硬件 I2C 在实际项目中偶尔会出现 BUSY 位拉不回来、要复位外设才能继续通信的情况,很多工程师把它列为“玄学”外设。BMS 的通信频率本来就不高,100Hz 轮询已经是上限,所以用 GPIO 模拟软件 I2C 是更稳的常见做法。
软件 I2C 的好处有三个:时序完全自己控制,遇到毛刺可以通过调整延时躲开;不依赖硬件外设的仲裁逻辑,调试时每个电平变化都能用逻辑分析仪看清楚;换引脚不用重新配置外设映射。代价是占用 CPU,但相比 BQ76920 的转换时间,这点开销不算什么。SDA 和 SCL 都需要配置为开漏输出、外部上拉到 3.3V,这样电平逻辑和 BQ76920 一致,也不会出现推挽输出和从机打架的情况。
提示:上拉电阻不要接到 5V。BQ76920 的 I2C 引脚不是 5V 容忍引脚,上拉到 5V 长期运行会缩短寿命,也可能让通信出现不明原因的间歇失败。
3.3 最小 I2C 时序代码:Start/Stop/读一个字节
这里用 PB6/PB7 模拟 I2C 的两个引脚。完整的时序包含起始、停止、发送字节、接收字节、ACK 这几个原语,BQ76920 的寄存器地址是 8 位,所以一次读操作的标准流程是:主机发送设备地址写位、发送寄存器地址、重复起始、发送设备地址读位、读回数据字节。
/* 软件 I2C 引脚宏定义 */ #define BQ_I2C_PORT GPIOB #define BQ_SCL_PIN GPIO_Pin_6 #define BQ_SDA_PIN GPIO_Pin_7 #define BQ_SCL_HIGH() GPIO_SetBits(BQ_I2C_PORT, BQ_SCL_PIN) #define BQ_SCL_LOW() GPIO_ResetBits(BQ_I2C_PORT, BQ_SCL_PIN) #define BQ_SDA_HIGH() GPIO_SetBits(BQ_I2C_PORT, BQ_SDA_PIN) #define BQ_SDA_LOW() GPIO_ResetBits(BQ_I2C_PORT, BQ_SDA_PIN) #define BQ_SDA_READ() GPIO_ReadInputDataBit(BQ_I2C_PORT, BQ_SDA_PIN) static void BQ_I2C_Delay(void) { volatile uint16_t i; for (i = 0; i < 8; i++); /* 约为 1us @72MHz,具体按逻辑分析仪调 */ } static void BQ_I2C_Start(void) { BQ_SDA_HIGH(); BQ_SCL_HIGH(); BQ_I2C_Delay(); BQ_SDA_LOW(); /* SCL 高电平期间 SDA 拉低,产生起始条件 */ BQ_I2C_Delay(); BQ_SCL_LOW(); } static void BQ_I2C_Stop(void) { BQ_SDA_LOW(); BQ_SCL_HIGH(); BQ_I2C_Delay(); BQ_SDA_HIGH(); /* SCL 高电平期间 SDA 拉高,产生停止条件 */ } static uint8_t BQ_I2C_ReadByte(void) { uint8_t i, data = 0; BQ_SDA_HIGH(); /* 释放总线,准备接收从机数据 */ for (i = 0; i < 8; i++) { BQ_SCL_HIGH(); BQ_I2C_Delay(); data = (data << 1) | BQ_SDA_READ(); BQ_SCL_LOW(); BQ_I2C_Delay(); } return data; }这段代码里的延时是软件 I2C 的精髓。BQ76920 的 I2C 时钟在标准模式下最高 100kHz,所以每个 SCL 周期至少要 10μs,上面注释里说的“按逻辑分析仪调”不是敷衍——实际项目里如果代码跑在优化等级不同,延时会有明显变化,上逻辑分析仪看一眼波形比肉眼猜强得多。读字节时每读一位要把 SCL 拉低,让从机有时间准备好下一位数据,这个顺序不能反。
3.4 读写寄存器与读取五节电芯电压
BQ76920 的 7 位 I2C 地址是 0x18,左移一位后写地址是 0x30、读地址是 0x31。寄存器空间是 8 位寻址,常见的内存映射里 SYS_CTRL1 在 0x04,CELL1 电压寄存器从 0x14 开始,每节占两个字节。下面给出读写寄存器和批量读取电芯电压的函数。
#define BQ76920_ADDR 0x18 #define BQ76920_CELL1_REG 0x14 #define BQ76920_SYS_CTRL1 0x04 static uint8_t BQ_I2C_WaitAck(void) { uint8_t ack = 0; BQ_SDA_HIGH(); BQ_SCL_HIGH(); BQ_I2C_Delay(); ack = BQ_SDA_READ(); /* 从机在第9个时钟拉低 SDA 表示 ACK */ BQ_SCL_LOW(); BQ_I2C_Delay(); return ack ? 0 : 1; } uint8_t BQ76920_WriteReg(uint8_t reg, uint8_t val) { BQ_I2C_Start(); BQ_I2C_WriteByte((BQ76920_ADDR << 1) | 0x00); if (!BQ_I2C_WaitAck()) return 1; BQ_I2C_WriteByte(reg); if (!BQ_I2C_WaitAck()) return 2; BQ_I2C_WriteByte(val); if (!BQ_I2C_WaitAck()) return 3; BQ_I2C_Stop(); return 0; } uint16_t BQ76920_ReadCellRaw(uint8_t cellIdx) { uint8_t lo, hi; uint16_t raw; uint8_t addr = BQ76920_CELL1_REG + cellIdx * 2; BQ_I2C_Start(); BQ_I2C_WriteByte((BQ76920_ADDR << 1) | 0x00); BQ_I2C_WriteByte(addr); BQ_I2C_Start(); /* 重复起始,切换为读方向 */ BQ_I2C_WriteByte((BQ76920_ADDR << 1) | 0x01); lo = BQ_I2C_ReadByte(); BQ_I2C_SendAck(0); /* 第二字节前先回 ACK */ hi = BQ_I2C_ReadByte(); BQ_I2C_SendAck(1); /* 最后一字节回 NACK */ BQ_I2C_Stop(); raw = ((uint16_t)hi << 8) | lo; return raw; }读电压时需要注意字节顺序:BQ76920 的电压寄存器是低字节在前,先读回来的是低 8 位,组合时要hi << 8 | lo,写反了电压会变成 16 倍的关系。函数里的BQ_I2C_SendAck接收参数 0 表示回 ACK、1 表示回 NACK,这是多字节读取的标准收尾方式——最后一字节回 NACK,从机才会释放总线。
3.5 初始化序列:阈值寄存器、ADC 模式和保护延时
BQ76920 上电后默认处于低功耗状态,ADC 没有启动,保护比较器按默认值工作。软件初始化要做的事是:配置 ADC 转换模式、写入过压/欠压阈值、使能需要的保护功能。过压和欠压阈值寄存器比较简单,OV_Trip 和 UV_Trip 都是以 50mV 为 LSB,写入 4200/50 就表示 4.2V 过压阈值。
#define BQ76920_OV_TRIP_REG 0x10 #define BQ76920_UV_TRIP_REG 0x12 #define OV_TRIP_MV 4200 #define UV_TRIP_MV 2800 void BQ76920_Init(void) { /* 1. 配置 ADC 为 on-demand 模式,首次上电先做一次转换 */ BQ76920_WriteReg(0x00, 0x01); /* 具体值按手册 ADCCFG 位定义 */ /* 2. 写入过压和欠压阈值,LSB = 50mV */ BQ76920_WriteReg(BQ76920_OV_TRIP_REG, OV_TRIP_MV / 50); BQ76920_WriteReg(BQ76920_UV_TRIP_REG, UV_TRIP_MV / 50); /* 3. PROTECT1~3 配置 OV/UV/OC 使能位和保护延时 */ /* 延时参数与使能位在不同批次芯片上定义有差异,务必对照手册 */ // BQ76920_WriteReg(0x0A, 配置值); // BQ76920_WriteReg(0x0C, 配置值); /* 4. 触发一次 ADC 转换 */ BQ76920_WriteReg(BQ76920_SYS_CTRL1, 0x02); }这段代码里 0x00、0x0A、0x0C 三个寄存器的具体位定义,我故意没有全部写上,因为不同后缀的 BQ76920 在保护延时的可选项上确实有差异,直接抄网上代码很容易踩版本坑。正确做法是打印一份你手上芯片对应的数据手册寄存器表,把 PROTECT1~3 的每一位对着自己的设计需求逐项填。第一次调试时先把所有保护使能位置成最保守值,跑通基本流程后再慢慢放宽延时,比一上来就按理论值配置要稳妥得多。
4. 核心逻辑的 C 语言实现:电压解析、保护状态机与 SOC 估算
4.1 电芯电压数据的解析:12 位 ADC 结果怎么换算成 mV
BQ76920 的电芯电压 ADC 是 12 位,参考电压约 5V,所以每个 LSB 大约对应 1.22mV。读取回来的 16 位寄存器里,12 位有效数据存放在低 12 位。把原始值乘以 1220 再除以 1000,就可以得到毫伏数。STM32F103 没有 FPU,做浮点乘法会白白多花几十个周期,所以这里用整数乘除法来算。
typedef struct { uint16_t cell_mV[5]; /* 最多支持5串 */ uint8_t cell_count; } BmsVoltage_t; BmsVoltage_t g_bmsVolt; void BQ76920_ReadAllVoltages(BmsVoltage_t *volt) { uint8_t i; for (i = 0; i < volt->cell_count; i++) { uint16_t raw = BQ76920_ReadCellRaw(i); volt->cell_mV[i] = (uint16_t)(((uint32_t)raw * 1220UL) / 1000UL); /* 如果你的手册显示寄存器是左对齐,先 raw >>= 4 再换算 */ } }这段代码把“低 12 位右对齐”这个假设放在了注释里,这是有原因的——同样型号的不同批次或者不同参考设计,代码里对对齐方式的处理不完全一样,一个左对齐的原始值硬套右对齐公式,电压会算出来 16 倍的离谱值。上电后第一件事就是把 raw 值直接打印出来,用万用表实测一节电芯做对照,如果 raw 值在 3000 左右而电芯电压 3.7V,说明基本对得上;如果 raw 值远低于预期,优先怀疑对齐方式。
4.2 保护状态机的写法:软保护兜底 + 硬件保护恢复
BQ76920 硬件保护触发时,CHG/DSG 引脚会直接切断 MOS,这个动作不经过 STM32,响应时间在微秒到毫秒级。但硬件保护只负责“切断”,不负责“什么时候恢复”,恢复逻辑必须由 MCU 软件来做。所以软件侧的状态机任务是:周期性读电压和 BQ76920 的状态寄存器,判断是否满足恢复条件,再通过写 SYS_CTRL1 去解除 FET 关断状态。
typedef enum { BMS_IDLE = 0, BMS_CHARGING, BMS_DISCHARGING, BMS_OV_PROTECT, BMS_UV_PROTECT } BmsState_t; BmsState_t g_bmsState = BMS_IDLE; void BMS_ProtectTask(void) { uint8_t i; uint8_t overVolt = 0, underVolt = 0; for (i = 0; i < g_bmsVolt.cell_count; i++) { if (g_bmsVolt.cell_mV[i] > OV_TRIP_MV) overVolt = 1; if (g_bmsVolt.cell_mV[i] < UV_TRIP_MV) underVolt = 1; } if (overVolt) { g_bmsState = BMS_OV_PROTECT; return; /* 停止放电操作,等待电压回落 */ } if (underVolt) { g_bmsState = BMS_UV_PROTECT; return; } /* 清除硬件锁存:确认无故障后写一次 SYS_CTRL1 恢复 FET */ if (g_bmsState == BMS_OV_PROTECT || g_bmsState == BMS_UV_PROTECT) { if (BQ76920_ReadCellRaw(0) > 0) { /* 确认 I2C 通信正常 */ BQ76920_WriteReg(BQ76920_SYS_CTRL1, 0x02); g_bmsState = BMS_IDLE; } } }这段状态机是典型的中断轮询二段式设计:保护触发不靠 MCU 判断,BQ76920 硬件已经切断了;MCU 只负责在条件恢复正常后“解除锁存”。往 SYS_CTRL1 写 0x02 这个动作需要慎重,如果电压还没真正回落到滞回区间就强行恢复,MOS 会在电压刚好在阈值附近来回开关,可能把 FET 折腾坏。所以更稳妥的做法是在恢复条件里加一个滞回判断和延时计数,比如欠压恢复阈值设定为 3.0V,并且连续 10 次采样都高于这个值才真正恢复。
4.3 SOC 估算:开路电压查表 + 安时积分
电量估算是 BMS 软件里最容易让人纠结的部分,因为电芯的充放电曲线不是线性的。常见的做法是把开路电压法(OCV)和安时积分法结合起来:系统刚上电时通过开路电压查表得到初始 SOC,运行过程中用电流对时间积分去更新 SOC,定期用电压再校正一次。BQ76920 可以通过采样电阻做电流采集,这里把电流输入抽象成一个函数,方便接自己的硬件实现。
#define BATTERY_CAPACITY_MAH 3000 static uint16_t g_soc_mAh = 1500; /* 上电时由 OCV 查表初始化 */ static uint16_t OCV_To_SOC(uint16_t ocv_mV) { /* 三元锂电典型 OCV-SOC 对应表,按自己电芯实测重填 */ uint16_t soc = 50; if (ocv_mV > 3700) soc = 80; if (ocv_mV > 3900) soc = 90; if (ocv_mV > 4050) soc = 98; if (ocv_mV < 3300) soc = 10; if (ocv_mV < 3000) soc = 0; return soc; } int16_t BMS_GetCurrentMA(void) { /* 从库仑计或采样电阻读取当前电流,充电为正,放电为负 */ return 0; } void BMS_UpdateSOC(void) { static uint32_t lastTick = 0; uint32_t now = get_tick_ms(); int32_t dt_ms = (int32_t)(now - lastTick); int32_t current_ma; int32_t delta_mAh; if (dt_ms < 100) return; /* 至少100ms积一次,避免频繁计算 */ lastTick = now; current_ma = BMS_GetCurrentMA(); delta_mAh = current_ma * dt_ms / 3600000L; g_soc_mAh = (uint16_t)((int32_t)g_soc_mAh - delta_mAh); if (g_soc_mAh > BATTERY_CAPACITY_MAH) g_soc_mAh = BATTERY_CAPACITY_MAH; if (g_soc_mAh == 0) g_soc_mAh = 1; /* 保留一个最小非零值避免除零 */ }这里有个工程细节:安时积分的累计误差是随时间漂移的,电流采样有零点偏移,积分误差就会线性增长。所以不能只靠积分一路走到黑,需要周期性用电压校正。我一般会在每次充满电时强制把 SOC 拉到 100%,并在静置超过 30 分钟后用 OCV 查表做一次纠偏。OCV 查表的关键是“静置”两个字——刚充完电或者还在放电的过程中,电芯极化电压会让查表结果严重偏高,这时候强行校正反而会把 SOC 搞乱。
4.4 被动均衡的控制流程:CELLBAL 寄存器与时间片
被动均衡的思路很简单:把电压偏高的电芯通过电阻放电,直到和最低电芯的电压差收窄。BQ76920 提供 CELLBAL 寄存器,对应位写 1 就打开该电芯的内部均衡电流源。但这颗料内部均衡能力在毫安级,且热量在芯片内部产生,长时间多路同时均衡对芯片和板子都是考验。
#define BQ76920_CELLBAL_REG 0x30 #define BALANCE_THRESHOLD_MV 20 #define BALANCE_MAX_MINUTES 10 void BMS_BalanceTask(uint16_t *cellMV, uint8_t cellCnt) { uint8_t i, balBits = 0; uint16_t min = 0xFFFF; for (i = 0; i < cellCnt; i++) { if (cellMV[i] < min) min = cellMV[i]; } for (i = 0; i < cellCnt; i++) { if (cellMV[i] - min > BALANCE_THRESHOLD_MV && cellMV[i] > 3700) { /* 只在充电末端均衡 */ balBits |= (1 << i); /* 位与电芯的映射按手册确认 */ } } BQ76920_WriteReg(BQ76920_CELLBAL_REG, balBits); }均衡触发条件里加了一个电压下限 3.7V,这是很多人容易漏掉的:如果电芯本来电量很低,均衡只会白白耗电,没有任何意义。被动均衡应该只在充电接近满电的区间工作,配合时间片轮转——每次只均衡压差最大的一节,运行 10 分钟停掉,换下一节,避免热量堆积。位映射关系上,CELLBAL 的 bit0 到 bit4 对应哪几节,不同参考设计上有分歧,第一次调试时把全部位置 1,然后用万用表实测哪个电芯电压在降,反过来确认映射表。
5. 避坑清单:这个方案最容易翻车的 5 个地方
5.1 拿 REGOUT 给 STM32F103 供电,上电就随机重启
现象:把 BQ76920 的 REGOUT 3.3V 直接接到 STM32F103 的 VDD 上,系统在每次 FET 开关切换时随机复位,严重的时候上电都起不来。
原因:REGOUT 是 BQ76920 内部 LDO 的输出,驱动能力在几十毫安级别,而 STM32F103 跑在 72MHz 时电流轻松超过 50mA,再加上 MOS 驱动瞬间的电流尖峰,REGOUT 电压会被拉垮。BQ76920 自己又是靠这个 LDO 供电工作的,两个芯片抢一份电流,谁都干不好。
解决:STM32F103 单独用一颗 3.3V LDO 从电池组取电供电,BQ76920 的 REGOUT 只给它自己的采样和保护电路用。如果板上实在放不下第二颗 LDO,就把单片机降频到 24MHz,并严格测量 REGOUT 在 FET 开关瞬间的跌落幅度,低于 2.9V 就不能继续这么干。
5.2 I2C 读回全 0xFF:上拉电阻和开漏配置
现象:软件 I2C 读 BQ76920 任何寄存器都返回 0xFF,逻辑分析仪看到 SDA 一直是高,SCL 有正常的时钟输出。
原因:最常见的两个出错点:一是 PB6/PB7 被配置成了推挽输出,SDA 在从机想拉低回 ACK 的瞬间被主机强拉高,总线信号完全错乱;二是漏掉了上拉电阻,SDA/SCL 都悬空,从机默认释放总线,读出来的自然全 1。
解决:GPIO 配置成开漏输出,外部各接 4.7kΩ 电阻上拉到 3.3V。上拉电阻别用 10kΩ,BQ76920 的 I2C 引脚电容加上走线寄生电容,10kΩ 上拉会让上升沿变缓,速率稍微一高就出错。改完配置后用万用表量一下 SCL、SDA 空闲电平,必须都是 3.3V。
5.3 只做 3S/4S 时 CELLx 悬空,电压读数全部飘
现象:做一个 3S 电池包,按标准电路接了三节电芯,BQ76920 上电后 CELL1~CELL3 电压读数都比实测高 200mV 以上,而且毫无规律。
原因:BQ76920 最多支持 5 串,少串时没用到的 CELL4、CELL5 引脚如果悬空,内部保护二极管会把它们拉到参考电位附近,并通过芯片内部漏电路径反向影响正在采样的通道。这个漏电流虽然只有微安级,但经过采样电阻后就变成了可见的电压偏置。
解决:做 3S 时把 CELL4 和 CELL5 引脚直接短接到 CELL3 的输入点(也就是第三节电芯正极),做 4S 时把 CELL5 短接到 CELL4 的输入点。注意是短接到采样点而不是电池正极直接接,中间最好用 0Ω 电阻连过去,方便以后改成 5S 时割断。
5.4 过放保护后插充电器没反应:死锁与恢复时序
现象:电池过放触发 UV 保护,DSG 关断。用户插上充电器,系统没有任何反应,充电电流为 0,反复插拔充电器也无效。
原因:这是低侧 MOS 拓扑的经典死锁。过放保护把放电 MOS 关断了,但充电 MOS 还关着,充电器的正极通过负载电阻到 PACK- 的回路被两个 MOS 截断,电池电压根本抬不起来,BQ76920 永远检测不到电压回升,也就永远不会自动恢复。这个死锁不是芯片 bug,是拓扑的固有限制。
解决:软件要在每次开机或充电器接入时做一次主动恢复尝试。检测到充电器插入(常见判据是 PACK 端电压高于当前电池电压加一定阈值),并且电芯电压虽然低于放电截止但高于 2.0V 的安全下限,就周期性往 SYS_CTRL1 写恢复字,让充电 MOS 先导通,充电器即可给电池充电,电压升上去之后整套系统自动回到正常态。这个流程要加延时保护,防止在电芯深度过放时反复脉冲充电。
5.5 均衡把热量全憋在 BQ76920 内部:换外部均衡
现象:开启均衡半小时后,用热成像看 BQ76920 表面温度超过 60℃,而电芯温度才 30℃。持续均衡几小时后,芯片进入过热保护,采样值出现跳变。
原因:BQ76920 的均衡开关虽然做在芯片内部,但均衡电流是从电芯经过芯片内部 MOS 到放电电阻回路的。内部 MOS 的导通电阻加封装热阻,让芯片成了散热瓶颈。特别是多节同时均衡的时候,热量叠加非常明显。
解决:量产设计一般不用内部均衡,改成外部均衡 MOS 加功率电阻的方案,均衡电流能做到 50~100mA,热量耗散在板级电阻上。原型验证阶段如果坚持用内部均衡,就必须加时间片轮转,同一时刻最多只开一节,并且均衡总时长超过 30 分钟就冷却 10 分钟。软件上同时做芯片温度监测,BQ76920 有内部温度检测能力,温度超过 55℃ 就暂停均衡。
6. 样机验证的最后一公里:校准、串口调试与保护测试
6.1 把 printf 重定向到串口,整机状态一次看全
BMS 这种系统最怕黑匣子,跑起来不知道内部状态,所以我拿到板子第一件事就是重定向 printf 到 USART1。标准库工程里只要重写 fputc 函数,之后所有 printf 就会走串口输出。如果 USART1 被其他功能占了,可以用定时器模拟一个 9600 波特率的软件串口,打印调试信息够用。
#include <stdio.h> int fputc(int ch, FILE *f) { while (!(USART1->SR & USART_FLAG_TXE)); USART1->DR = ch & 0xFF; return ch; }调试阶段我一般每 500ms 打印一次:五节电芯电压、保护状态、FET 开关状态、SOC 百分比、均衡标志。把这行 log 连续跑两个小时,用文本工具抓有没有跳变和异常状态,比盯着示波器高效得多。内部温度采集也可以顺带打印——STM32F103 内部温度传感器精度一般,做不了电芯温度保护,但监测板子自身温度、判断均衡散热是否正常没问题。
6.2 两点校准法:每节电芯的 offset 和 scale
电芯电压采样总会存在误差,RC 滤波电阻的精度和 I/O 引脚偏置电流会让每个通道的读数和真实值差几个毫伏。批量出货前必须做校准。用一个可调电源模拟电芯电压,取 3.0V 和 4.2V 两个点,分别记录 BQ76920 读到的 ADC 原始值和真实电压,然后算出每个通道的 offset 和 scale,存到 Flash 里,上电时加载修正。
校准的精度不取决于算法,取决于源的精度。可调电源要用四位半以上的表实测,不能直接信面板显示值。两个校准点之间用线性插值,覆盖整个 2.5V~4.5V 工作区间,误差可以压到 5mV 以内。做完电压校准再做 NTC 温度校准,冰水混合物和沸水两个点,注意沸水点受海拔影响,最好用恒温槽。
6.3 保护动作的验收测试步骤
板子跑起来之后,保护功能不能靠嘴说“应该没问题”,要逐项测。过压保护测试是把可调电源接到单节电芯两端,缓慢升压到 OV 阈值以上 100mV,用示波器看 DSG/CHG 引脚的电压变化,记录从越过阈值到 MOS 关断的延迟时间。欠压保护反向操作,降压到 UV 阈值以下,确认放电 MOS 关断。过流保护用电子负载拉大电流,触发后看电流是不是在规定的去抖时间内切断。
每项测试记录三组数据:触发阈值、实测触发点、恢复阈值。实测触发点和设定值之间的偏差应该在 50mV/LSB 的量化误差范围内,如果偏差超过两倍 LSB,回头查 RC 滤波和校准流程。恢复阈值则用来验证滞回设计——恢复点和触发点之间必须保持足够电压差,否则系统会在临界点反复开关 MOS,那才是真正的翻车现场。所有保护测试通过后,我习惯把这个 BMS 带着真实电池跑三天充放电循环,期间不人为干预,只靠 log 观察有没有自己恢复不了的异常状态;这套流程走完,才敢说这个方案能交付。希望帮到你。
本文还有配套的精品资源,点击获取