ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32读取BQ40Z50电量计:I2C驱动与寄存器解析实战

STM32读取BQ40Z50电量计:I2C驱动与寄存器解析实战 做电池供电产品时最容易被低估的一个环节就是电量显示。LED灯从四格掉到一格用户还能忍屏幕百分比直接从30%跳到5%那就等着退货吧。所以现在稍微正经一点的锂电方案我都会上电量计而不是靠查表估电压。BQ40Z50是TI在笔记本电池、电动工具、储能PACK里非常常见的一颗电量计最大的特点是把库仑计和电压查表结合起来能算出相对准确的剩余电量。这篇文章就用STM32作为主控把BQ40Z50的电压、电流、温度、剩余电量几个核心数据读出来。代码是从芯片手册直接落地的那种不搞抽象封装拿到就能抄。1. 硬件接线与准备BQ40Z50并不是高不可攀的芯片1.1 芯片供电与引脚梳理很多第一次接触BQ40Z50的人一看到数据手册里密密麻麻的引脚就慌了。实际上你的STM32要跟这颗芯片通信真正关心的只有两根线SMBC和SMDA也就是I2C的时钟线和数据线。剩下的BAT、PACK、PACK-、TS这些引脚是芯片自己在电池包内部工作的外部MCU不需要直接干预。典型接线是这样BQ40Z50引脚作用接到STM32哪里SMBCI2C时钟PB6SCL任意I2C引脚或GPIO模拟都行SMDAI2C数据PB7SDAVCC / REGIN芯片供电电池正极或者开发板上引出的3.3VBAT电池电压采样电池正极PACK / PACK-对外输出端子负载/充电器TSNTC温度采样电池包内的热敏电阻如果你用的是一块带BQ40Z50的电池保护板一般板上已经做好了电池连接和采样电阻。STM32这边只要把SMBC、SMDA、GND三根线对应接上就行。注意共地这是新手最容易忽略的STM32和BQ40Z50必须共地否则I2C波形看着是好的数据就是读不出来。供电方面有两种情况。一种是BQ40Z50的通信逻辑电平由电池包内部供电决定通信引脚电平可能是2.5V到4.2V不等。如果你的STM32是3.3V供电通信引脚耐压一般没问题但稳妥起见用开发板引出的3.3V给EV引脚或者逻辑端供电会更安全。另一种是BQ40Z50工作在纯电池供电模式SMBC、SMDA在芯片内部已经做了处理外部直接接3.3V上拉也能兼容。我的建议是第一次调试先把电池电压控制在3.0V到4.2V之间逻辑电平不会太离谱。调试阶段还有一个安全提醒锂电池测试时千万不要短路电池正负极之间、PACK和PACK-之间都要检查好。我之前见过有人用杜邦线飞线调试线皮破了导致电池直接短路几秒钟线就冒烟了。所以第一轮测试尽量用稳压源串一个限流电阻或者至少确认板子上没有裸露的金属部分。1.2 I2C地址与上拉电阻的细节BQ40Z50的I2C地址是个容易踩坑的地方。芯片默认的SMBus从机地址常见是写地址0xAA、读地址0xAB也就是7位地址0x55。但市面上有些保护板会自动配置成其他地址比如0x16。所以第一次通信失败不要先怀疑代码先确认你手上这块板子的地址到底是什么。确认地址最快的办法是写一个I2C扫描程序把0x00到0x7F的所有地址都发一遍看哪个地址能收到ACK。我在调试时经常用这个方法比翻数据手册快得多。扫描结果如果多个地址都有ACK那就要拔掉其他I2C设备再扫避免误判。上拉电阻这块很多人直接把芯片手册推荐值抄过来结果发现通信不稳定。BQ40Z50的SMBus总线标准上拉电阻一般是4.7k到10k但实际还要看你用的I2C时钟频率和总线电容。如果用的是模拟I2C时钟也就几十kHz10k上拉完全没问题。如果用了硬件I2C跑到400kHz还是用4.7k比较稳。另外上拉电阻要接到通信逻辑电平对应的电源上通常是3.3V不是电池电压。接线的长度也值得一提。SMBC和SMDA这两根线尽量短我用杜邦线超过20厘米的时候400kHz下波形已经有明显振铃了。所以调试板上的线能短就短实在要长距离传输建议把I2C时钟降到100kHz以下。2. 寄存器与协议解析电量计的数据手册到底该怎么看2.1 最常用的四个寄存器BQ40Z50的数据手册有几百页但如果你只是想读电压、电流、温度和剩余电量只需要记住几个寄存器地址比想象中少得多。寄存器地址名称长度单位说明0x06Temperature2字节0.1K电池温度0x08Voltage2字节mV电池电压0x0ACurrent2字节mA电流有符号0x0DRSOC1字节%相对剩余电量这四个是核心其中RSOC就是你屏幕上要显示的百分比。0x08电压和0x0A电流也是两个最常见的调试数据。剩下的Remaining Capacity0x0C、FullChargeCapacity0x10、DesignCapacity0x0E这些容量寄存器等基本的四个读通之后再去扩展也不迟。很多人一开始会犯一个错误把0x0D当作16位寄存器去读两个字节结果从0x0E又读了一个字节回来拼出一个奇怪的电量值。RSOC就是单字节只读一个字节就够了。2.2 数据类型与单位换算BQ40Z50的16位寄存器是大端格式也就是高字节在前。这个跟很多I2C芯片的小端格式不一样我第一次读的时候就被坑过。以电压0x08为例假设读回两个字节是0x0E、0x9C那么实际电压值就是0x0E9C换算成十进制是3740单位mV也就是3.74V。如果你按照小端格式拼成0x9C0E换算出来是39950mV显然不对。所以代码里拼字节的时候一定要高字节左移8位再或上低字节。电流0x0A是16位有符号数单位mA。芯片内部对电流的定义通常是充电为正、放电为负但具体还要看采样电阻的接法。我的经验是第一次接好之后先用电子负载拉一个固定电流看看读出来的值是正是负如果不符合预期就在显示层把符号反过来不用改驱动底层。温度0x06这个寄存器最有迷惑性单位是0.1K不是0.1℃。比如读回0x0B24也就是0x0B24等于2852换算成开尔文是285.2K再减273.15实际温度就是12.05℃。如果不做这一步换算直接显示285℃那数据就完全没法看。这个坑在BQ40Z50系列里一直存在换到BQ76920之类的模拟前端芯片温度寄存器单位又变成0.1℃所以每换一颗芯片都要重新看手册。2.3 读寄存器的严谨时序读单个16位寄存器的I2C时序看起来简单但有几个细节必须注意。标准流程是这样主机发送起始信号发送从机写地址0xAA等待ACK发送寄存器地址0x08等待ACK发送重复起始信号发送从机读地址0xAB等待ACK读高字节主机回ACK读低字节主机回NACK发送停止信号这里最容易错的是第4步。很多人的第一反应是发完寄存器地址之后发停止信号然后再发起始信号去读。这样操作在有些I2C设备上也能工作但SMBus规范要求用重复起始否则部分器件会在停止信号之后进入错误状态。BQ40Z50对这块比较宽容但养成用重复起始的习惯对以后调试其他SMBus设备有好处。还有一个细节是读取结束时的NACK。当主机要读最后一个字节时必须在倒数第二个字节回ACK在最后一个字节回NACK然后才发停止信号。如果主机在最后一个字节回了ACK设备会以为还要继续传数据可能一直拉低SDA不放总线就卡住了。3. 驱动代码实现我用的是一套模拟I2C写法3.1 为什么不用硬件I2C也要留着模拟I2CSTM32的硬件I2C模块在F1系列上名声不太好卡死、无ACK、总线锁死这些情况我都遇到过。虽然F0和F4系列已经好了很多但调试BQ40Z50这种SMBus设备我还是习惯先写一套模拟I2C。原因有两个一是引脚可以任意选不用跟硬件I2C的复用引脚较劲二是逻辑简单每一步的高低电平都看得见出了问题好排查。当然硬件I2C也不是不能用。如果你用的是HAL库一条HAL_I2C_Mem_Read就能完成读寄存器操作代码很简洁。但有个大坑HAL库的DevAddress参数是7位地址不是8位地址。也就是说模拟I2C里你发0xAA到HAL里要传0x55因为HAL内部会左移一位再拼上读写位。我见过太多人把0xAA传进去然后死活读不到数据。3.2 I2C底层读写函数下面这套代码基于STM32F103标准外设库用PB6做SCL、PB7做SDA。初始化的时候把两个引脚都设成开漏输出因为开漏模式下可以直接读引脚电平省去切换输入输出方向的麻烦。#include stm32f10x.h #include delay.h #define I2C_SCL_PIN GPIO_Pin_6 #define I2C_SDA_PIN GPIO_Pin_7 #define I2C_GPIO_PORT GPIOB #define I2C_GPIO_CLK RCC_APB2Periph_GPIOB #define I2C_SCL_H() GPIO_SetBits(I2C_GPIO_PORT, I2C_SCL_PIN) #define I2C_SCL_L() GPIO_ResetBits(I2C_GPIO_PORT, I2C_SCL_PIN) #define I2C_SDA_H() GPIO_SetBits(I2C_GPIO_PORT, I2C_SDA_PIN) #define I2C_SDA_L() GPIO_ResetBits(I2C_GPIO_PORT, I2C_SDA_PIN) #define I2C_SDA_READ() GPIO_ReadInputDataBit(I2C_GPIO_PORT, I2C_SDA_PIN) void I2C_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(I2C_GPIO_CLK, ENABLE); GPIO_InitStructure.GPIO_Pin I2C_SCL_PIN | I2C_SDA_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(I2C_GPIO_PORT, GPIO_InitStructure); I2C_SCL_H(); I2C_SDA_H(); } void I2C_Delay(void) { delay_us(5); } void I2C_Start(void) { I2C_SDA_H(); I2C_SCL_H(); I2C_Delay(); I2C_SDA_L(); I2C_Delay(); I2C_SCL_L(); I2C_Delay(); } void I2C_Stop(void) { I2C_SDA_L(); I2C_SCL_H(); I2C_Delay(); I2C_SDA_H(); I2C_Delay(); }发送一个字节的函数要注意每一位的时序SDA上的数据必须在SCL低电平期间准备好然后在SCL拉高后保持稳定最后再拉低SCL准备下一位。接收ACK时要把SDA释放掉也就是拉高然后读引脚如果是低电平说明从机回了ACK。uint8_t I2C_WriteByte(uint8_t data) { uint8_t i; for (i 0; i 8; i) { if (data 0x80) { I2C_SDA_H(); } else { I2C_SDA_L(); } data 1; I2C_Delay(); I2C_SCL_H(); I2C_Delay(); I2C_SCL_L(); I2C_Delay(); } I2C_SDA_H(); I2C_Delay(); I2C_SCL_H(); I2C_Delay(); if (I2C_SDA_READ()) { I2C_SCL_L(); return 1; } else { I2C_SCL_L(); return 0; } } uint8_t I2C_ReadByte(uint8_t ack) { uint8_t i, data 0; I2C_SDA_H(); for (i 0; i 8; i) { I2C_SCL_H(); I2C_Delay(); data 1; if (I2C_SDA_READ()) { data | 0x01; } I2C_SCL_L(); I2C_Delay(); } if (ack) { I2C_SDA_L(); } else { I2C_SDA_H(); } I2C_Delay(); I2C_SCL_H(); I2C_Delay(); I2C_SCL_L(); I2C_SDA_H(); I2C_Delay(); return data; }读取字节函数里的ack参数1表示主机回ACK表示还要继续读下一字节0表示主机回NACK表示这是最后一个字节。这个细节对应前面说的时序要求。3.3 BQ40Z50寄存器对接与主循环打印有了底层I2C函数读BQ40Z50的寄存器就很简单了。核心逻辑就是发送从机地址、发送寄存器地址、重复起始、读数据。#define BQ40Z50_ADDR_W 0xAA #define BQ40Z50_ADDR_R 0xAB #define BQ40Z50_REG_TEMP 0x06 #define BQ40Z50_REG_VOLT 0x08 #define BQ40Z50_REG_CURR 0x0A #define BQ40Z50_REG_RSOC 0x0D uint16_t BQ40Z50_ReadWord(uint8_t reg) { uint16_t value 0; uint8_t hi, lo; I2C_Start(); I2C_WriteByte(BQ40Z50_ADDR_W); I2C_WriteByte(reg); I2C_Start(); I2C_WriteByte(BQ40Z50_ADDR_R); hi I2C_ReadByte(1); lo I2C_ReadByte(0); I2C_Stop(); value (uint16_t)(hi 8) | lo; return value; } uint8_t BQ40Z50_ReadByte(uint8_t reg) { uint8_t value 0; I2C_Start(); I2C_WriteByte(BQ40Z50_ADDR_W); I2C_WriteByte(reg); I2C_Start(); I2C_WriteByte(BQ40Z50_ADDR_R); value I2C_ReadByte(0); I2C_Stop(); return value; }主循环里按周期读取数据然后通过串口打印出来。温度换算这一步在前面说过转换成摄氏度时要除以10再减273.15。#include stdio.h int main(void) { int16_t current_mA; uint16_t voltage_mV; uint16_t temperature_raw; uint8_t rsoc; float temperature_C; delay_init(); I2C_Init(); UART_Init(115200); while (1) { voltage_mV BQ40Z50_ReadWord(BQ40Z50_REG_VOLT); current_mA (int16_t)BQ40Z50_ReadWord(BQ40Z50_REG_CURR); temperature_raw BQ40Z50_ReadWord(BQ40Z50_REG_TEMP); rsoc BQ40Z50_ReadByte(BQ40Z50_REG_RSOC); temperature_C (float)temperature_raw / 10.0f - 273.15f; printf(Voltage: %d mV, Current: %d mA, Temp: %.2f C, RSOC: %d%%\r\n, voltage_mV, current_mA, temperature_C, rsoc); delay_ms(1000); } }这里有个小技巧电流寄存器是无符号读回来的要显示成有符号数直接强转成int16_t就行。如果读回来的二进制正好是负数的补码强转之后就能正确显示负数。不用手动判断符号位。4. 实测与数据校验读到电压电流后怎么确认没错4.1 典型的充放电实测数据代码跑通之后第一件事不是急着接真实电池而是用稳压源模拟一个电池电压先看看读数是否合理。我调试时常用一个可调电源设到3.7VBQ40Z50读回来应该是3700mV左右误差在几十毫伏以内。如果差得太多说明寄存器拼字节的代码有问题或者地址不对。接上真实18650电芯之后我记录过这样一组数据状态电压(mV)电流(mA)温度(℃)RSOC(%)空载静置3712325.1841A恒流放电3543-103128.3800.5A恒流充电407851226.482空载时电流不是0这是正常的因为采样电阻有微小偏置BQ40Z50内部算法也会有少量偏移。几毫安的误差完全不影响使用。如果空载电流显示几百毫安那才是真有问题常见原因是采样电阻虚焊或者焊接处有额外电阻。放电时电压从3.71V降到3.54V这是电池内阻导致的压降符合预期。电流显示-1031mA和电子负载设定的1A基本吻合。充电时电压上升到4.078V说明电池接近满电电流为正数和放电方向相反。RSOC这个数据在放电过程中变化比较平滑不会像电压那样出现明显的瞬时波动。如果你看到的电量百分比在几秒钟内跳了5%以上大概率是电池没有完成学习循环Qmax不准后面第6节会细说。4.2 用万用表和负载交叉验证读到的数据不等于真实数据这一点做嵌入式的人应该有肌肉记忆。我通常用三个方法交叉验证全部通过才认为驱动没问题。第一个方法是万用表测电压。把万用表打在直流电压档直接量BQ40Z50的电池端引脚和串口打印的电压值对比。误差在±30mV以内都可以接受超过这个范围就要怀疑采样电阻分压电路的问题。第二个方法是电子负载测电流。设定1A恒流放电看读回来的电流是不是接近-1000mA。没有电子负载的话用一个功率电阻串联万用表的电流档也能凑合但要注意万用表电流档的压降会影响电流值。第三个方法是充放电循环。这是最耗时间但最可靠的办法。先把电池充满记录RSOC显示100%时的电压然后以0.5C恒流放到保护板截止电压记录放电容量再充满看看RSOC能不能回到100%。如果放出的实际容量远小于设计容量说明电量计需要学习不是通信问题。5. 踩坑实录I2C没应答、电量不变、电流乱跳怎么办5.1 I2C总线无应答与死锁排查这是新手遇到最多的问题串口打印出来的数据全是0xFF或者超时错误。排查顺序我从实践经验里总结了一个固定套路先查电源再查上拉然后查地址最后抓波形。电源问题最常见的是BQ40Z50根本没有工作。很多保护板在没接电池的时候芯片是不上电的SMBC和SMDA自然没有任何反应。先量一下芯片VCC引脚对地电压确认有电再看通信。上拉电阻的问题前面提过没有上拉或者上拉电阻焊接不良SDA数据线无法被拉高设备根本识别不到起始信号。用万用表量SMBC和SMDA的对地电压正常空闲状态应该是接近3.3V如果一直是0V基本就是上拉没接好。地址问题也经常出现。我调试某块二手的BQ40Z50保护板时明明代码里用的是0xAA结果逻辑分析仪显示芯片一直不回ACK。后来用I2C扫描程序扫了一遍发现这个板子的地址被改成了0x16。所以怀疑地址不对时直接扫描是最省时间的。总线死锁是另一个烦人的问题。现象是程序跑一会儿之后后续的读操作全部超时SCL和SDA都是低电平。这通常是因为通信被异常打断SDA被从机拉低没有释放。解决办法是在模拟I2C初始化时做一个总线恢复操作把SCL翻转9个时钟周期让从机自己释放SDA。void I2C_BusRecover(void) { uint8_t i; I2C_SDA_H(); for (i 0; i 9; i) { I2C_SCL_H(); I2C_Delay(); I2C_SCL_L(); I2C_Delay(); } I2C_Start(); I2C_Stop(); }5.2 电压、电量读数不对的检查项电压读出来是0xFFFF或者0x0000首先怀疑寄存器地址是不是写错了。BQ40Z50的0x08是电压0x09是另一个状态寄存器如果把地址写成0x09读出来的数据自然不对。其次要检查I2C通信过程中是否有ACK错误如果从机没有正确回应读回来的字节全是0xFF。还有一个小概率情况是芯片进入了关机模式。BQ40Z50内部有shutdown功能当电池电压低于某个阈值时芯片会进入超低功耗状态I2C总线不响应。这个时候需要重新接上充电器唤醒芯片或者短按一下唤醒引脚。电量一直显示100%不变这个要分情况。如果电池刚换上去BQ40Z50的Qmax还是默认值没有经过学习RSOC就很难准确。另一种情况是读取的寄存器错了比如把FullChargeCapacity当成RSOC读那数据当然不更新。还有一种情况是电池确实一直在满电状态RSOC显示100%但不掉这不是故障是你没放电。5.3 电流方向与数值异常的处理电流读数比预期大很多比如空载显示几百毫安这通常是采样电阻焊接问题。BQ40Z50对采样电阻的阻值和焊接质量很敏感如果采样电阻两端有额外的接触电阻芯片会认为有大电流流过。检查采样电阻的焊接清理助焊剂必要时用烙铁重新加焊一下。电流方向反了也就是充电显示负数、放电显示正数这个跟采样电阻在电路中的接法有关。不需要改驱动代码在显示层做个符号取反就行。但要注意如果同时读取了功率方向相关的状态寄存器取反会连带影响其他模块最好是确认一下采样电阻的物理接法再决定。还有一个容易忽略的问题是电流的低通滤波特性。BQ40Z50的电流值不是瞬时采样而是经过内部滤波的所以负载突变瞬间读回来的电流变化会慢半拍。这个对于显示电量来说完全够用但如果要对电流做快速保护响应建议直接用STM32自己的ADC去采样采样电阻两端电压不要依赖电量计的电流读数。6. 下一步还能怎么玩从读取到BMS完整链路6.1 首次上电的化学ID与学习循环如果你用的是一块全新的BQ40Z50芯片而不是刷好固件的成品保护板那第一次上电时RSOC大概率不准。芯片需要知道电池的化学体系、设计容量、放电截止电压这些参数还要完成一个完整的学习循环。学习循环的做法不复杂但很耗时先充满电让RSOC到100%然后静置一段时间再以0.5C恒流放电到截止电压静置最后充满。这一轮下来芯片会更新Qmax和Ra表之后RSOC才会逐渐准确。手头没有电子负载的话用电阻丝或者大功率电阻放电也行但电流控制要稳。如果只是给客户做一个电量显示方案我建议直接选刷好固件的电量计模块省去化学ID配置这一步。自己从零配Chemistry ID需要bqStudio和EV2400成本不低而且容易配错。6.2 低功耗场景下的唤醒策略电池供电的设备对功耗很敏感STM32不能一直全速跑。很多产品是MCU进入stop模式每隔几十毫秒唤醒一次读取电量计然后继续睡。BQ40Z50自身也有关机模式但我不建议在低功耗设计里轻易触发芯片关机因为唤醒流程复杂而且有风险。比较合理的做法是保留BQ40Z50一直工作它本身在normal模式的功耗也就几十微安到几百微安级别。STM32通过定时器周期性唤醒读取一次数据更新显示和告警状态再回去睡觉。如果需要更低功耗可以把电量计的告警输出引脚接到STM32的EXTI唤醒引脚上让SOC低于阈值时主动唤醒MCU这个做法在BQ40Z50里是支持的。6.3 数据上抛串口、蓝牙与上位机驱动打通之后把数据上抛到上位机进行可视化是调试和产品演示都很有价值的一步。最简单的就是串口输出用普通的USB转TTL接上STM32电脑端用串口助手把打印数据接成CSV再用Excel或者Python画曲线。我在实际项目里经常把这个数据接到蓝牙透传模块上做一个小型App实时显示电压、电流、电量和温度。这样一来即使产品装进外壳、充电口被封住也能通过手机看到内部电池状态对售后排查很有帮助。如果走CAN总线还可以接入整车或者储能系统这个就根据自己的场景来扩展了。数据上抛时要注意一点BQ40Z50的寄存器读取速度有限I2C时钟100kHz下连续读四个寄存器大约耗时1ms不到完全不影响1秒一次的刷新周期。但如果要高频采集放电曲线比如1kHz那就要考虑DMA和内部FIFO的设计了。最后再分享一个我自己的习惯所有电量计驱动代码里都保留一个原始寄存器打印选项。调试时开着能看到每个寄存器的16进制值和换算后的物理量出货前关掉减少串口输出干扰。这个习惯帮我排查过不少隐蔽问题比如温度寄存器当成电压寄存器读、字节拼反了、地址串了等。所以驱动框架别急着做精简先把原始数据通道留好后面扩展功能会省事很多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进