ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32读取BQ40Z50电量计:从I2C通信到剩余电量显示

STM32读取BQ40Z50电量计:从I2C通信到剩余电量显示 做电池产品的朋友应该都有过这种经历以为读个ADC电压就能显示剩余电量结果电池从满电到没电电压变化总共就零点几伏负载一拉电压剧烈波动屏幕上百分比跟过山车一样。后来真正用上电量计芯片才发现问题出在“估算法”本身。这篇文章要讲的BQ40Z50是TI一颗很经典的电量计支持1到4节锂电池内部有阻抗追踪算法可以直接通过I2C/SMBus接口读出电压、电流、剩余电量百分比和绝对剩余容量。本文就以STM32为主控手把手把读取流程和完整代码讲清楚适合电池供电产品开发、毕业设计以及想从“看电压猜电量”升级到“用电量计”的开发者参考。1. 为什么用BQ40Z50而不是直接读ADC电压1.1 看电压估电量误差到底出在哪很多入门项目喜欢用电阻分压加ADC采样电池电压然后查表估算电量。这种方案不是不能用而是误差来源太多。第一锂电池的放电曲线中间段非常平缓尤其是磷酸铁锂从90%SOC到30%SOC电压可能只变了不到0.2V而ADC本身有量化误差和噪声稍微抖一下就跨了好几个百分比。第二电池有内阻带载和静置的电压差异非常大同样一格电负载从100mA变成1A端电压能差出100多毫伏。你用电压查表根本分不清是负载变化还是电量变化。第三也是最容易被忽视的电池会老化。同一块电池新的时候内阻小用一段时间后内阻变大同样的剩余电量在不同生命周期下电压表现完全不同。今天标的100%明天可能只有95%这种误差没法通过软件“平滑滤波”消除。所以稍微正式一点的电池产品都会选择电量计芯片而不是干巴巴的电压查询。1.2 BQ40Z50的库仑计与阻抗追踪逻辑BQ40Z50的核心机制可以拆成两层。第一层是库仑计也就是对电流做时间积分。电池里面流过多少电荷芯片通过串联在PACK-和电池负极之间的检流电阻持续监测电流乘以时间就是容量。这个思路本身不复杂但单纯积分有个致命问题误差会累积。检流电阻有精度误差采样有偏置电池自放电会消耗能量但不经过检流电阻时间一长积分结果就飘了。所以BQ40Z50在库仑计之上又加了一套阻抗追踪算法Impedance Track。简单说芯片会记录电池在不同温度、不同SOC下的开路电压和内阻模型在电池静置的时候通过OCV修正当前SOC再结合库仑计的积分结果做融合。这类似于手机导航里GPS和惯性导航的关系惯性导航负责实时推算GPS负责定期纠偏。这套机制决定了它的精度不是天生就有需要先做完配置和学习周期后面我会专门说。2. 硬件接线与通信基础2.1 模块接线图与实际连接要点BQ40Z50市面上有成品模块也有自己画板载芯片的方案。无论哪种STM32这一侧要接的线其实很少核心就是I2C的两根线加共地。我用的是F103开发板直接拿PB6做SCL、PB7做SDA模块端的SDA、SCL对应接上就行。重点提醒一句BQ40Z50模块的电源和通信引脚要确认模块上是否已经自带I2C上拉电阻。如果没有必须在SCL和SDA上各加一个4.7k欧电阻到3.3V否则通信时序会不稳定。如果模块已经带就别再并联了两个4.7k并联会变成2.35k虽然大多数情况下问题不大但总线上挂的设备多了还会影响边沿速率。接线表整理如下STM32引脚BQ40Z50模块备注PB6SCLI2C时钟线需上拉4.7k到3.3VPB7SDAI2C数据线需上拉4.7k到3.3VGNDGND必须与电池负极侧共地3.3V模块VCC如需要部分模块需要外部供电才能通信还有两个容易踩的坑。第一个是BQ40Z50模块的P、P-那两侧是电池的充放电回路电流很大不要把通信引脚或者STM32的地接到P上否则轻则读数异常重则烧芯片。第二个是模块的地和STM32的地必须共地很多人I2C老是不稳定最后发现模块单独接了一个电源两边地电位不一致信号根本没有参考平面。共地之后用逻辑分析仪抓波形基本一眼就能看出通信是否正常。2.2 SMBus地址与I2C通信的细节差异BQ40Z50用的是SMBus协议基于I2C物理层时序上和标准I2C非常接近唯一的区别主要是速率更慢最大100kHz以及对一些时序参数要求更严格。好消息是绝大多数情况下STM32的模拟I2C或硬件I2C都能直接兼容SMBus设备。BQ40Z50的7位I2C地址是0x16也就是说写操作时发送的地址字节是0x16左移一位也就是0x2C读操作时发送0x2D。新手经常在这里犯迷糊拿着0x16直接往总线上发那肯定读不到数据。读取16位寄存器数据时时序是这样的先发送起始条件然后发送写地址0x2C芯片应答后发送寄存器地址比如电压寄存器0x0A。接下来要做一次repeated start再发送读地址0x2D然后连续读两个字节先低字节后高字节最后发送停止条件。这个repeated start是关键不要写成先Stop再Start虽然某些情况下也能工作但SMBus规范推荐用repeated start兼容性更好。理解了这套时序代码写起来就顺了。3. STM32驱动代码从底层I2C到寄存器读取3.1 模拟I2C底层函数实现我推荐用模拟I2C来读BQ40Z50尤其是在做原型验证的时候。理由很简单STM32的硬件I2C在不同型号、不同库版本下表现差异很大调起来比较费劲而模拟I2C只需要两个GPIO口代码完全透明出问题拿着示波器随时能查。BQ40Z50本身不是高速设备100kHz读一遍四个寄存器也就几毫秒模拟I2C的开销完全可以忽略。下面先给出底层I2C模块代码这个模块可以复用到任何I2C设备上。我使用的是开漏输出加外部上拉的方式所以在读ACK时直接读IDR寄存器就可以不需要来回切换输入输出模式。// i2c.c #include i2c.h #include stm32f1xx_hal.h #define I2C_SCL_PORT GPIOB #define I2C_SCL_PIN GPIO_PIN_6 #define I2C_SDA_PORT GPIOB #define I2C_SDA_PIN GPIO_PIN_7 static void I2C_Delay(void) { uint8_t i 20; while (i--); } static void I2C_SCL_1(void) { HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET); } static void I2C_SCL_0(void) { HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); } static void I2C_SDA_1(void) { HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_SET); } static void I2C_SDA_0(void) { HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_RESET); } void I2C_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); // SCL 推挽输出 GPIO_InitStruct.Pin I2C_SCL_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(I2C_SCL_PORT, GPIO_InitStruct); // SDA 开漏输出配合外部上拉电阻 GPIO_InitStruct.Pin I2C_SDA_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(I2C_SDA_PORT, GPIO_InitStruct); I2C_SDA_1(); I2C_SCL_1(); } void I2C_Start(void) { I2C_SDA_1(); I2C_SCL_1(); I2C_Delay(); I2C_SDA_0(); I2C_Delay(); I2C_SCL_0(); I2C_Delay(); } void I2C_Stop(void) { I2C_SDA_0(); I2C_SCL_1(); I2C_Delay(); I2C_SDA_1(); I2C_Delay(); } uint8_t I2C_SendByte(uint8_t data) { uint8_t i; for (i 0; i 8; i) { if (data 0x80) I2C_SDA_1(); else I2C_SDA_0(); data 1; I2C_SCL_1(); I2C_Delay(); I2C_SCL_0(); I2C_Delay(); } // 释放SDA等待从机应答 I2C_SDA_1(); I2C_SCL_1(); I2C_Delay(); uint8_t ack (HAL_GPIO_ReadPin(I2C_SDA_PORT, I2C_SDA_PIN) GPIO_PIN_RESET) ? 1 : 0; I2C_SCL_0(); I2C_Delay(); return ack; } uint8_t I2C_RecvByte(uint8_t ack) { uint8_t data 0; uint8_t i; // 释放SDA让从机控制 I2C_SDA_1(); for (i 0; i 8; i) { I2C_SCL_1(); I2C_Delay(); data 1; if (HAL_GPIO_ReadPin(I2C_SDA_PORT, I2C_SDA_PIN) GPIO_PIN_SET) data | 0x01; I2C_SCL_0(); I2C_Delay(); } // 根据参数发送应答位ack1发送ACKack0发送NACK I2C_SCL_0(); if (ack) I2C_SDA_0(); else I2C_SDA_1(); I2C_SCL_1(); I2C_Delay(); I2C_SCL_0(); I2C_SDA_1(); I2C_Delay(); return data; }这段代码里有个细节要说明SDA配成开漏输出当代码写1时引脚实际上是被上拉电阻拉高的从机可以把它拉低表示ACK。所以发送完8个数据位后直接读引脚电平就能判断是否收到应答。这个设计比反复切换输入输出方向要干净得多也不容易因为切换时序出错。3.2 读取16位寄存器数据的完整代码有了底层I2C接下来就是封装BQ40Z50的寄存器读取函数。需要注意地址字节的细节写地址用0x2C读地址用0x2D。在写寄存器号之后使用repeated start来读数据。为了增加稳定性我加了三重尝试如果连续三次都失败就返回0xFFFF这样主程序可以识别错误状态。// bq40z50.c #include bq40z50.h uint16_t BQ40Z50_Read_Reg(uint8_t reg) { uint8_t retry; uint16_t data 0xFFFF; for (retry 0; retry 3; retry) { I2C_Start(); // 写地址7位地址0x16左移1位 0x2C if (I2C_SendByte(0x2C) 0) { if (I2C_SendByte(reg) 0) { // repeated start切换到读模式 I2C_Start(); // 读地址 0x2D if (I2C_SendByte(0x2D) 0) { uint8_t lo I2C_RecvByte(1); uint8_t hi I2C_RecvByte(0); data (uint16_t)((uint16_t)hi 8) | lo; I2C_Stop(); return data; } } } I2C_Stop(); } return data; }对应头文件// bq40z50.h #ifndef __BQ40Z50_H #define __BQ40Z50_H #include stdint.h #define BQ40Z50_ADDR 0x16 #define BQ40Z50_REG_TEMP 0x08 #define BQ40Z50_REG_VOLT 0x0A #define BQ40Z50_REG_CURR 0x0C #define BQ40Z50_REG_REMCAP 0x0F #define BQ40Z50_REG_FULLCAP 0x10 #define BQ40Z50_REG_SOC 0x2C uint16_t BQ40Z50_Read_Reg(uint8_t reg); #endif我实际使用下来一个把这段代码跑通的常见问题是在I2C_SendByte的返回判断上。注意函数返回值是1表示收到ACK0表示NACK。我在上面代码中已经把ACK定义成返回1所以在if判断里写的是 0表示发送成功这个逻辑别搞反了。如果搞反程序会一直重试又不知道哪里错最后全返回0xFFFF。4. 主程序读取电压、电流与剩余电量4.1 寄存器映射与数据换算关系BQ40Z50遵循SMBus标准寄存器映射本文用到的关键寄存器整理如下。这张表建议保存下来调试时随手就能查寄存器名称寄存器地址单位数据格式说明Temperature0x080.1Kuint16需要自行换算为摄氏度Voltage0x0AmVuint16电池组总电压Current0x0CmAint16有符号充电为正放电为负取决于配置RemainingCapacity0x0FmAhuint16剩余可用容量FullChargeCapacity0x10mAhuint16满充容量StateOfCharge0x2C%uint16电量百分比电压和电流的换算最直接读回来是什么数字就代表多少mV和多少mA不需要乘系数。剩余容量和满充容量也一样单位是mAh。只有温度需要注意BQ40Z50的温度寄存器以0.1K为单位需要先除以10再减去273.15才能得到摄氏度。电流的正负方向要单独说明一下。SMBus标准中电流寄存器是有符号数通常约定充电为正值、放电为负值但BQ40Z50的具体方向要看芯片内部的配置。如果读出来发现充电是负数、放电是正数不用慌这是配置项的问题用bqStudio软件改一下方向位即可不影响通信本身。4.2 STM32主循环代码实现下面给出一份完整的主程序示例。我用了串口1做调试输出每3秒读取一次电量计数据并通过printf打印。这里默认你已经用CubeMX把串口和GPIO初始化好了重点看BQ40Z50的调用过程。// main.c #include main.h #include usart.h #include gpio.h #include i2c.h #include bq40z50.h #include stdio.h UART_HandleTypeDef huart1; int fputc(int ch, UART_HandleTypeDef *huart) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 100); return ch; } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); I2C_Init(); printf(BQ40Z50 Demo Start\r\n); while (1) { uint16_t volt BQ40Z50_Read_Reg(BQ40Z50_REG_VOLT); int16_t curr (int16_t)BQ40Z50_Read_Reg(BQ40Z50_REG_CURR); uint16_t soc BQ40Z50_Read_Reg(BQ40Z50_REG_SOC); uint16_t remcap BQ40Z50_Read_Reg(BQ40Z50_REG_REMCAP); uint16_t fullcap BQ40Z50_Read_Reg(BQ40Z50_REG_FULLCAP); uint16_t raw_temp BQ40Z50_Read_Reg(BQ40Z50_REG_TEMP); float temp_c (raw_temp / 10.0f) - 273.15f; printf(Voltage:%d mV, Current:%d mA, SOC:%d%%, RemCap:%d mAh, FullCap:%d mAh, Temp:%.1f C\r\n, volt, curr, soc, remcap, fullcap, temp_c); HAL_Delay(3000); } }代码不多但要注意两点。第一如果串口打印的数据一直是0xFFFF不要先怀疑BQ40Z50坏了先用万用表量一下SCL和SDA对地电压这两个脚正常空闲状态应该都是高电平。如果有哪个脚被拉低先查上拉电阻和焊接。第二最好先把电压读通再去读电流和SOC。电压寄存器是最简单、最容易验证的配合万用表测量模块的P和P-两边数值对上了再往下走就有信心了。操作验证的步骤我推荐这样做接上电池后用万用表直流挡量模块的电池端电压串口打印的Voltage字段应该和万用表读数基本一致误差在几十毫伏以内都算正常。然后找一个电子负载或者大功率电阻放电观察Current字段变为负值数值大小和实际放电电流吻合。再做一次充电电流会变为正值。这几个现象如果都对说明整个链路已经通了。5. 常见问题排查与避坑实录5.1 总线卡死、数据全FF的排查方法我调试这块芯片时遇到最多的问题就是I2C总线卡死现象是SDA一直被拉低SCL还能正常翻转但所有通信都不成功。这种问题大多发生在第一次上电或者误接了设备之后。BQ40Z50内部有看门狗但有时候总线状态确实会恢复到一半卡住。这时候最简单的办法是把电池断掉再重新接上让模块彻底复位一次。如果你不想每次都断电可以在程序初始化里加一段总线恢复代码把SCL手动翻转9到10次每个时钟周期内保持SDA释放状态。这个操作能唤醒卡死的从设备让它重新识别总线状态。我实际测试过对于BQ40Z50这个办法在大多数情况下都能让总线恢复不用断电。当然如果恢复不了那就老老实实检查硬件连接和上拉电阻。还有一个常见情况是返回0xFFFF。排查顺序是这样的先确认模块的7位地址是不是0x16这个地址虽然出厂默认是这个值但如果之前有人拿bqStudio改过地址可能变了再确认STM32的引脚配置SCL和SDA有没有接反这个错误很蠢但很常见最后确认共地模块和开发板之间必须有一根可靠的地线。5.2 电流符号、SOC不变化等数据异常电流符号不对这个问题很多初学者会大惊失色。比如充电时Current读出来是负的放电时反而变成正的就以为芯片坏了。其实这不是通信问题而是BQ40Z50的电流方向配置在起作用。芯片通过检流电阻上的电压极性判断电流方向方向位决定了正负的含义。这个配置只能在bqStudio里改代码层面没法翻转。如果你手里的模块是别人改过配置的正负方向和标准SBS约定不一致也没关系代码里做个判断显示的时候乘个-1就行不必强求硬件。SOC一直不变或者一直是0%、100%这个问题通常不是通信问题而是电量计还没完成校准。BQ40Z50需要知道电芯的化学ID、设计容量、设计电压还要进行一次完整的充放电学习循环才能把阻抗追踪模型跑起来。如果买的是裸芯片自己搭的电路第一次上电SOC显示不准或者不变化是正常的。关于这部分我建议直接买已经配置好的模块或者用EV2400和bqStudio自己配置单纯靠STM32代码是没办法绕过芯片内部算法的。还遇到过一种情况连续读几次某一次读出来的电压明显偏大或者偏小。这种偶发性错误多半来自通信干扰或者读取过程中总线被更高优先级的中断打断了。我的办法是在应用层做软件滤波连续读三次取中间值开销不大但能把偶发错误滤掉大半。真正追求稳定的量产代码可以再配合CRC校验不过BQ40Z50的PEC功能需要额外计算我一般只在通信周期比较长、数据量大的场景下才会加。5.3 首次上电前必须用bqStudio完成的配置这里必须给第一次用BQ40Z50的同学提个醒这颗芯片不是接到STM32上就能输出准确SOC的。芯片出厂时内部有默认配置但那个配置对应的是通用电芯模型跟你的实际电池大概率不匹配。想要读到靠谱的数据需要先完成下面几步第一步准备一套EV2400转接板和TI的bqStudio软件通过I2C连上模块。第二步在bqStudio里选择正确的Chemistry ID也就是电芯的化学型号这一步直接影响OCV曲线和内阻模型选错了SOC偏差会非常大。第三步设置Pack Configuration包括串联节数、并联节数、设计容量、设计电压等。第四步做硬件校准主要是校准检流电阻的增益和偏移这决定了电流读数的精度。第五步也是很多人忽略的一步做一个完整的充放电学习周期让芯片学习实际电芯的容量衰减特征。完成这些之后STM32读到的数据才有实用价值。如果你买的是TB上带保护的成品模块卖家一般已经帮忙配好了你只要负责STM32读取就行那就不需要操心这些。最后说几个我自己的操作习惯。第一BQ40Z50的寄存器读取最好封装成独立模块别和业务逻辑混在一起以后换芯片或者调地址都方便。第二调试初期把串口打印的频率调高一点比如1秒一次配合电池充放电过程能快速判断数据是否合理等稳定了再改成低频读取。第三如果有逻辑分析仪务必抓一次完整波形看一眼ACK位和寄存器地址很多玄学问题瞬间就明白了。我见过不少人折腾半天最后发现是发送寄存器号时高位顺序写反了I2C高位先发这是最容易被忽略的基础细节。
返回列表