
做電池產(chǎn)品的朋友應(yīng)該都有過這種經(jīng)歷以為讀個ADC電壓就能顯示剩余電量結(jié)果電池從滿電到?jīng)]電電壓變化總共就零點幾伏負載一拉電壓劇烈波動屏幕上百分比跟過山車一樣。后來真正用上電量計芯片才發(fā)現(xiàn)問題出在“估算法”本身。這篇文章要講的BQ40Z50是TI一顆很經(jīng)典的電量計支持1到4節(jié)鋰電池內(nèi)部有阻抗追蹤算法可以直接通過I2C/SMBus接口讀出電壓、電流、剩余電量百分比和絕對剩余容量。本文就以STM32為主控手把手把讀取流程和完整代碼講清楚適合電池供電產(chǎn)品開發(fā)、畢業(yè)設(shè)計以及想從“看電壓猜電量”升級到“用電量計”的開發(fā)者參考。1. 為什么用BQ40Z50而不是直接讀ADC電壓1.1 看電壓估電量誤差到底出在哪很多入門項目喜歡用電阻分壓加ADC采樣電池電壓然后查表估算電量。這種方案不是不能用而是誤差來源太多。第一鋰電池的放電曲線中間段非常平緩尤其是磷酸鐵鋰從90%SOC到30%SOC電壓可能只變了不到0.2V而ADC本身有量化誤差和噪聲稍微抖一下就跨了好幾個百分比。第二電池有內(nèi)阻帶載和靜置的電壓差異非常大同樣一格電負載從100mA變成1A端電壓能差出100多毫伏。你用電壓查表根本分不清是負載變化還是電量變化。第三也是最容易被忽視的電池會老化。同一塊電池新的時候內(nèi)阻小用一段時間后內(nèi)阻變大同樣的剩余電量在不同生命周期下電壓表現(xiàn)完全不同。今天標(biāo)的100%明天可能只有95%這種誤差沒法通過軟件“平滑濾波”消除。所以稍微正式一點的電池產(chǎn)品都會選擇電量計芯片而不是干巴巴的電壓查詢。1.2 BQ40Z50的庫侖計與阻抗追蹤邏輯BQ40Z50的核心機制可以拆成兩層。第一層是庫侖計也就是對電流做時間積分。電池里面流過多少電荷芯片通過串聯(lián)在PACK-和電池負極之間的檢流電阻持續(xù)監(jiān)測電流乘以時間就是容量。這個思路本身不復(fù)雜但單純積分有個致命問題誤差會累積。檢流電阻有精度誤差采樣有偏置電池自放電會消耗能量但不經(jīng)過檢流電阻時間一長積分結(jié)果就飄了。所以BQ40Z50在庫侖計之上又加了一套阻抗追蹤算法Impedance Track。簡單說芯片會記錄電池在不同溫度、不同SOC下的開路電壓和內(nèi)阻模型在電池靜置的時候通過OCV修正當(dāng)前SOC再結(jié)合庫侖計的積分結(jié)果做融合。這類似于手機導(dǎo)航里GPS和慣性導(dǎo)航的關(guān)系慣性導(dǎo)航負責(zé)實時推算GPS負責(zé)定期糾偏。這套機制決定了它的精度不是天生就有需要先做完配置和學(xué)習(xí)周期后面我會專門說。2. 硬件接線與通信基礎(chǔ)2.1 模塊接線圖與實際連接要點BQ40Z50市面上有成品模塊也有自己畫板載芯片的方案。無論哪種STM32這一側(cè)要接的線其實很少核心就是I2C的兩根線加共地。我用的是F103開發(fā)板直接拿PB6做SCL、PB7做SDA模塊端的SDA、SCL對應(yīng)接上就行。重點提醒一句BQ40Z50模塊的電源和通信引腳要確認模塊上是否已經(jīng)自帶I2C上拉電阻。如果沒有必須在SCL和SDA上各加一個4.7k歐電阻到3.3V否則通信時序會不穩(wěn)定。如果模塊已經(jīng)帶就別再并聯(lián)了兩個4.7k并聯(lián)會變成2.35k雖然大多數(shù)情況下問題不大但總線上掛的設(shè)備多了還會影響邊沿速率。接線表整理如下STM32引腳BQ40Z50模塊備注PB6SCLI2C時鐘線需上拉4.7k到3.3VPB7SDAI2C數(shù)據(jù)線需上拉4.7k到3.3VGNDGND必須與電池負極側(cè)共地3.3V模塊VCC如需要部分模塊需要外部供電才能通信還有兩個容易踩的坑。第一個是BQ40Z50模塊的P、P-那兩側(cè)是電池的充放電回路電流很大不要把通信引腳或者STM32的地接到P上否則輕則讀數(shù)異常重則燒芯片。第二個是模塊的地和STM32的地必須共地很多人I2C老是不穩(wěn)定最后發(fā)現(xiàn)模塊單獨接了一個電源兩邊地電位不一致信號根本沒有參考平面。共地之后用邏輯分析儀抓波形基本一眼就能看出通信是否正常。2.2 SMBus地址與I2C通信的細節(jié)差異BQ40Z50用的是SMBus協(xié)議基于I2C物理層時序上和標(biāo)準I2C非常接近唯一的區(qū)別主要是速率更慢最大100kHz以及對一些時序參數(shù)要求更嚴格。好消息是絕大多數(shù)情況下STM32的模擬I2C或硬件I2C都能直接兼容SMBus設(shè)備。BQ40Z50的7位I2C地址是0x16也就是說寫操作時發(fā)送的地址字節(jié)是0x16左移一位也就是0x2C讀操作時發(fā)送0x2D。新手經(jīng)常在這里犯迷糊拿著0x16直接往總線上發(fā)那肯定讀不到數(shù)據(jù)。讀取16位寄存器數(shù)據(jù)時時序是這樣的先發(fā)送起始條件然后發(fā)送寫地址0x2C芯片應(yīng)答后發(fā)送寄存器地址比如電壓寄存器0x0A。接下來要做一次repeated start再發(fā)送讀地址0x2D然后連續(xù)讀兩個字節(jié)先低字節(jié)后高字節(jié)最后發(fā)送停止條件。這個repeated start是關(guān)鍵不要寫成先Stop再Start雖然某些情況下也能工作但SMBus規(guī)范推薦用repeated start兼容性更好。理解了這套時序代碼寫起來就順了。3. STM32驅(qū)動代碼從底層I2C到寄存器讀取3.1 模擬I2C底層函數(shù)實現(xiàn)我推薦用模擬I2C來讀BQ40Z50尤其是在做原型驗證的時候。理由很簡單STM32的硬件I2C在不同型號、不同庫版本下表現(xiàn)差異很大調(diào)起來比較費勁而模擬I2C只需要兩個GPIO口代碼完全透明出問題拿著示波器隨時能查。BQ40Z50本身不是高速設(shè)備100kHz讀一遍四個寄存器也就幾毫秒模擬I2C的開銷完全可以忽略。下面先給出底層I2C模塊代碼這個模塊可以復(fù)用到任何I2C設(shè)備上。我使用的是開漏輸出加外部上拉的方式所以在讀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等待從機應(yīng)答 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(); } // 根據(jù)參數(shù)發(fā)送應(yīng)答位ack1發(fā)送ACKack0發(fā)送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; }這段代碼里有個細節(jié)要說明SDA配成開漏輸出當(dāng)代碼寫1時引腳實際上是被上拉電阻拉高的從機可以把它拉低表示ACK。所以發(fā)送完8個數(shù)據(jù)位后直接讀引腳電平就能判斷是否收到應(yīng)答。這個設(shè)計比反復(fù)切換輸入輸出方向要干凈得多也不容易因為切換時序出錯。3.2 讀取16位寄存器數(shù)據(jù)的完整代碼有了底層I2C接下來就是封裝BQ40Z50的寄存器讀取函數(shù)。需要注意地址字節(jié)的細節(jié)寫地址用0x2C讀地址用0x2D。在寫寄存器號之后使用repeated start來讀數(shù)據(jù)。為了增加穩(wěn)定性我加了三重嘗試如果連續(xù)三次都失敗就返回0xFFFF這樣主程序可以識別錯誤狀態(tài)。// 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; }對應(yīng)頭文件// 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的返回判斷上。注意函數(shù)返回值是1表示收到ACK0表示NACK。我在上面代碼中已經(jīng)把ACK定義成返回1所以在if判斷里寫的是 0表示發(fā)送成功這個邏輯別搞反了。如果搞反程序會一直重試又不知道哪里錯最后全返回0xFFFF。4. 主程序讀取電壓、電流與剩余電量4.1 寄存器映射與數(shù)據(jù)換算關(guān)系BQ40Z50遵循SMBus標(biāo)準寄存器映射本文用到的關(guān)鍵寄存器整理如下。這張表建議保存下來調(diào)試時隨手就能查寄存器名稱寄存器地址單位數(shù)據(jù)格式說明Temperature0x080.1Kuint16需要自行換算為攝氏度Voltage0x0AmVuint16電池組總電壓Current0x0CmAint16有符號充電為正放電為負取決于配置RemainingCapacity0x0FmAhuint16剩余可用容量FullChargeCapacity0x10mAhuint16滿充容量StateOfCharge0x2C%uint16電量百分比電壓和電流的換算最直接讀回來是什么數(shù)字就代表多少mV和多少mA不需要乘系數(shù)。剩余容量和滿充容量也一樣單位是mAh。只有溫度需要注意BQ40Z50的溫度寄存器以0.1K為單位需要先除以10再減去273.15才能得到攝氏度。電流的正負方向要單獨說明一下。SMBus標(biāo)準中電流寄存器是有符號數(shù)通常約定充電為正值、放電為負值但BQ40Z50的具體方向要看芯片內(nèi)部的配置。如果讀出來發(fā)現(xiàn)充電是負數(shù)、放電是正數(shù)不用慌這是配置項的問題用bqStudio軟件改一下方向位即可不影響通信本身。4.2 STM32主循環(huán)代碼實現(xiàn)下面給出一份完整的主程序示例。我用了串口1做調(diào)試輸出每3秒讀取一次電量計數(shù)據(jù)并通過printf打印。這里默認你已經(jīng)用CubeMX把串口和GPIO初始化好了重點看BQ40Z50的調(diào)用過程。// 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); } }代碼不多但要注意兩點。第一如果串口打印的數(shù)據(jù)一直是0xFFFF不要先懷疑BQ40Z50壞了先用萬用表量一下SCL和SDA對地電壓這兩個腳正??臻e狀態(tài)應(yīng)該都是高電平。如果有哪個腳被拉低先查上拉電阻和焊接。第二最好先把電壓讀通再去讀電流和SOC。電壓寄存器是最簡單、最容易驗證的配合萬用表測量模塊的P和P-兩邊數(shù)值對上了再往下走就有信心了。操作驗證的步驟我推薦這樣做接上電池后用萬用表直流擋量模塊的電池端電壓串口打印的Voltage字段應(yīng)該和萬用表讀數(shù)基本一致誤差在幾十毫伏以內(nèi)都算正常。然后找一個電子負載或者大功率電阻放電觀察Current字段變?yōu)樨撝禂?shù)值大小和實際放電電流吻合。再做一次充電電流會變?yōu)檎怠_@幾個現(xiàn)象如果都對說明整個鏈路已經(jīng)通了。5. 常見問題排查與避坑實錄5.1 總線卡死、數(shù)據(jù)全FF的排查方法我調(diào)試這塊芯片時遇到最多的問題就是I2C總線卡死現(xiàn)象是SDA一直被拉低SCL還能正常翻轉(zhuǎn)但所有通信都不成功。這種問題大多發(fā)生在第一次上電或者誤接了設(shè)備之后。BQ40Z50內(nèi)部有看門狗但有時候總線狀態(tài)確實會恢復(fù)到一半卡住。這時候最簡單的辦法是把電池斷掉再重新接上讓模塊徹底復(fù)位一次。如果你不想每次都斷電可以在程序初始化里加一段總線恢復(fù)代碼把SCL手動翻轉(zhuǎn)9到10次每個時鐘周期內(nèi)保持SDA釋放狀態(tài)。這個操作能喚醒卡死的從設(shè)備讓它重新識別總線狀態(tài)。我實際測試過對于BQ40Z50這個辦法在大多數(shù)情況下都能讓總線恢復(fù)不用斷電。當(dāng)然如果恢復(fù)不了那就老老實實檢查硬件連接和上拉電阻。還有一個常見情況是返回0xFFFF。排查順序是這樣的先確認模塊的7位地址是不是0x16這個地址雖然出廠默認是這個值但如果之前有人拿bqStudio改過地址可能變了再確認STM32的引腳配置SCL和SDA有沒有接反這個錯誤很蠢但很常見最后確認共地模塊和開發(fā)板之間必須有一根可靠的地線。5.2 電流符號、SOC不變化等數(shù)據(jù)異常電流符號不對這個問題很多初學(xué)者會大驚失色。比如充電時Current讀出來是負的放電時反而變成正的就以為芯片壞了。其實這不是通信問題而是BQ40Z50的電流方向配置在起作用。芯片通過檢流電阻上的電壓極性判斷電流方向方向位決定了正負的含義。這個配置只能在bqStudio里改代碼層面沒法翻轉(zhuǎn)。如果你手里的模塊是別人改過配置的正負方向和標(biāo)準SBS約定不一致也沒關(guān)系代碼里做個判斷顯示的時候乘個-1就行不必強求硬件。SOC一直不變或者一直是0%、100%這個問題通常不是通信問題而是電量計還沒完成校準。BQ40Z50需要知道電芯的化學(xué)ID、設(shè)計容量、設(shè)計電壓還要進行一次完整的充放電學(xué)習(xí)循環(huán)才能把阻抗追蹤模型跑起來。如果買的是裸芯片自己搭的電路第一次上電SOC顯示不準或者不變化是正常的。關(guān)于這部分我建議直接買已經(jīng)配置好的模塊或者用EV2400和bqStudio自己配置單純靠STM32代碼是沒辦法繞過芯片內(nèi)部算法的。還遇到過一種情況連續(xù)讀幾次某一次讀出來的電壓明顯偏大或者偏小。這種偶發(fā)性錯誤多半來自通信干擾或者讀取過程中總線被更高優(yōu)先級的中斷打斷了。我的辦法是在應(yīng)用層做軟件濾波連續(xù)讀三次取中間值開銷不大但能把偶發(fā)錯誤濾掉大半。真正追求穩(wěn)定的量產(chǎn)代碼可以再配合CRC校驗不過BQ40Z50的PEC功能需要額外計算我一般只在通信周期比較長、數(shù)據(jù)量大的場景下才會加。5.3 首次上電前必須用bqStudio完成的配置這里必須給第一次用BQ40Z50的同學(xué)提個醒這顆芯片不是接到STM32上就能輸出準確SOC的。芯片出廠時內(nèi)部有默認配置但那個配置對應(yīng)的是通用電芯模型跟你的實際電池大概率不匹配。想要讀到靠譜的數(shù)據(jù)需要先完成下面幾步第一步準備一套EV2400轉(zhuǎn)接板和TI的bqStudio軟件通過I2C連上模塊。第二步在bqStudio里選擇正確的Chemistry ID也就是電芯的化學(xué)型號這一步直接影響OCV曲線和內(nèi)阻模型選錯了SOC偏差會非常大。第三步設(shè)置Pack Configuration包括串聯(lián)節(jié)數(shù)、并聯(lián)節(jié)數(shù)、設(shè)計容量、設(shè)計電壓等。第四步做硬件校準主要是校準檢流電阻的增益和偏移這決定了電流讀數(shù)的精度。第五步也是很多人忽略的一步做一個完整的充放電學(xué)習(xí)周期讓芯片學(xué)習(xí)實際電芯的容量衰減特征。完成這些之后STM32讀到的數(shù)據(jù)才有實用價值。如果你買的是TB上帶保護的成品模塊賣家一般已經(jīng)幫忙配好了你只要負責(zé)STM32讀取就行那就不需要操心這些。最后說幾個我自己的操作習(xí)慣。第一BQ40Z50的寄存器讀取最好封裝成獨立模塊別和業(yè)務(wù)邏輯混在一起以后換芯片或者調(diào)地址都方便。第二調(diào)試初期把串口打印的頻率調(diào)高一點比如1秒一次配合電池充放電過程能快速判斷數(shù)據(jù)是否合理等穩(wěn)定了再改成低頻讀取。第三如果有邏輯分析儀務(wù)必抓一次完整波形看一眼ACK位和寄存器地址很多玄學(xué)問題瞬間就明白了。我見過不少人折騰半天最后發(fā)現(xiàn)是發(fā)送寄存器號時高位順序?qū)懛戳薎2C高位先發(fā)這是最容易被忽略的基礎(chǔ)細節(jié)。