简介:面向使用STM32F0系列驱动BQ40Z50电池管理芯片的嵌入式开发者,提供一套可正常通讯的SMBus参考工程。基于STM32F030,通过模拟SMBus协议与TI的BQ40Z50通信,涵盖I2C配置、寄存器读写、电池电压/电流/温度/SOC读取等关键步骤,适合电池管理系统(BMS)入门及驱动调试。资源共141个文件,以C源码和头文件为主,辅以编译生成的o、d、hex、axf等文件,便于直接参考或烧录验证;附带工程配置文件,整体仅2.3MB。目前已有3082人学习,代码结构清晰(Core、HardWare、Fwlib、User等),从底层驱动到应用调用均有体现,可帮助理解SMBus时序和BQ40Z50寄存器操作,提升STM32平台外设驱动与协议移植能力。
1. STM32F0 读 BQ40Z50:通讯通了,数据还是不对?
做电池管理项目时,最头疼的不是电芯本身,而是那颗小小的电量计。明明 I2C 波形正常、地址应答也正确,读回来的电压却跳来跳去,偶尔还会一次性给你返回 0xFF 全家桶。这个基于 STM32F0 的 BQ40Z50 通讯工程,恰好解决的就是这段“看似通、实际不稳”的路程。它把 SMBus 时序、PEC 校验、寄存器读写完整走了一遍,通讯保持正常,数据能稳定换算成 mV 和 mAh。
它适合两类人:一类是刚把电芯接上电量计、正在为读不到数据发愁的硬件开发,另一类是手头有 F0 开发板、想在裸机环境下把 SMBus 协议彻底跑通的嵌入式初学者。它不依赖库函数封装,直接操作寄存器,逻辑清晰到能当协议教学代码看。这份资源交给你的不仅是一段能跑的程序,更是一套排查通讯异常的方法论,下面从硬件连接开始逐步拆。
2. 硬件与协议准备:为什么 SMBus 不能直接当 I2C 用
2.1 BQ40Z50 的通讯定位与 SMBus 协议基础
BQ40Z50 是某电池厂商的阻抗追踪电量计,对外通讯走的是 SMBus——简单说,它长得像 I2C,但不是所有 I2C 主机都能直接驱动它。SMBus 规定了时钟频率上限 100kHz、标准的 SBS 命令集,以及可选的 PEC(包错误校验)机制。STM32F0 的 I2C 外设跑在 100kHz 确实能应答,但和 BQ40Z50 通讯时,时序要求比普通 EEPROM 严苛,特别是 ACK 之后的保持时间,用硬件 I2C 经常因为中断抢占导致时序断裂。
这个工程选的是软件模拟 I2C,用两个 GPIO 翻转电平。有人会问,F0 明明有硬件 I2C 外设,为什么绕圈子?血泪经验是:F0 的硬件 I2C 在一个字节传输完成前被打断,总线就会卡在半空状态,而软件模拟只要注意延时,时序完全可控,出问题也好定位。
2.2 硬件连接清单与上拉电阻选型
连接方式其实很简单,BQ40Z50 的 SDA 和 SCL 各接一个 GPIO,注意它是漏极开路结构,必须外接上拉电阻。这个工程里用的是 4.7kΩ 上拉到 3.3V,I2C 总线上同时挂了 BQ40Z50 和一片 EEPROM,上拉电阻没有额外调整,通讯一切正常。
| 引脚 | BQ40Z50 | STM32F0 | 说明 |
|---|---|---|---|
| SCL | 19 | PB6 | 时钟线,4.7kΩ 上拉 |
| SDA | 20 | PB7 | 数据线,4.7kΩ 上拉 |
| GND | 16 | GND | 必须共地,否则通讯飘 |
| BAT | 1 | 电芯正极 | 电量计供电与采样共用 |
这里有一个高频踩坑点:BQ40Z50 的 GND 和 MCU 的 GND 不共地,SDA 线上的电压参考点不一致,表现是读出的数据周期性跳变,逻辑分析仪看波形却一切正常。所以焊接前先拿万用表量一下两个地之间的压差,压差超过 0.1V 就会出问题。
2.3 工程时钟配置与延时基准
软件模拟 I2C 的时序由延时函数控制,这个工程里 SystemClock 配置为 48MHz,延时函数用 SysTick 实现微秒级等待。SCL 频率控制在 10kHz 左右,这是 SMBus 的标准速率,比 100kHz 的 I2C 慢但兼容性最好。BQ40Z50 数据手册建议 SMBus 时钟不要超过 100kHz,工程里留了余量,虽然牺牲了一点速度,但换来了极高的稳定性。
配置项在 main.c 顶部的宏定义中,SCL_Delay 直接决定时序快慢,如果换了主频更高的 MCU,需要重新标定这个延时,否则时序过快会导致 BQ40Z50 误判起始条件。我一般用示波器看 SCL 波形周期来确认,这个方法后文还会详细说。
3. 工程结构与目录解析:每一个文件的职责边界
3.1 整体目录树
这份资源解压后是一个完整的 STM32F0 裸机工程,基于标准外设库而不是 CubeMX 生成的 HAL 代码。目录结构如下:
BQ40Z50_STM32F0/ ├── Core/ │ ├── src/ │ │ ├── main.c // 主循环,初始化与状态打印 │ │ ├── delay.c // 微秒延时,基于 SysTick │ │ ├── i2c_soft.c // 软件模拟 I2C,GPIO 翻转实现 │ │ ├── bq40z50.c // 电量计协议驱动,寄存器读写封装 │ │ └── uart_debug.c // 串口打印调试信息 │ └── inc/ │ ├── bq40z50.h // 寄存器地址宏定义与函数声明 │ ├── i2c_soft.h // GPIO 引脚映射与接口声明 │ └── delay.h ├── Drivers/ │ ├── CMSIS/ │ └── STM32F0xx_HAL_Driver/ ├── MDK-ARM/ │ ├── BQ40Z50.uvprojx // Keil 工程文件 │ └── startup_stm32f0xx.s // 启动文件 └── Doc/ └── 通讯测试说明.md // 测试步骤与命令速查每个文件职责清晰,没有把协议代码和业务逻辑混在一起。最核心的是 bq40z50.c,它把所有 BQ40Z50 寄存器操作收敛成了几个函数,上层 main.c 只负责调函数和打印。这种分层方式对后续移植特别友好,换 MCU 只需要改 i2c_soft.c,协议层完全不用动。
3.2 主循环与测试流程
main.c 的主循环很短,做的事情却覆盖了完整验证链路:上电延时等电量计稳定,然后依次读取电压、电流、剩余容量、满充容量、循环次数和温度,最后通过串口打印出来。每次读取间隔 500ms,同时打印 I2C 通讯状态码,方便直接观察是否出现 ACK 错误或 PEC 校验失败。
int main(void) { SysClock_Init(); // 48MHz 主频 Delay_Init(); UART_Debug_Init(115200); I2C_Soft_Init(); // 配置 PB6/PB7 为开漏输出 BQ40Z50_Init(); // 等待电量计就绪并尝试读取 ID while (1) { uint16_t voltage_mv = BQ40Z50_ReadVoltage(); int16_t current_ma = BQ40Z50_ReadCurrent(); uint16_t soc_percent = BQ40Z50_ReadSOC(); printf("Voltage:%dmV Current:%dmA SOC:%d%%\r\n", voltage_mv, current_ma, soc_percent); Delay_Ms(500); } }这段代码的巧妙之处在于把协议细节全部藏进 BQ40Z50_XXX 系列函数,主循环只做三件事:初始化、读取、打印。如果换到带 RTOS 的项目,这个主循环可以直接变成一个任务函数,内部的延时改成消息等待即可,改造空间很大。
初始化阶段有个容易忽略的细节:BQ40Z50 上电后需要几十毫秒完成内部校准,如果 MCU 立刻发起第一次 I2C 通讯,大概率收到 NACK。工程里 BQ40Z50_Init() 里先延时 100ms 再读 ID,这个时间窗口足够电量计自检完成,实测从来没有首次通讯失败的情况。
3.3 串口调试信息的输出策略
uart_debug.c 的写法比较取巧,初始化配置用半字节方式写入 USART 寄存器,而不是调用库函数。输出采用阻塞式发送,每一帧数据发送完成后才返回。有人会觉得阻塞浪费 CPU,但在调试阶段这反而是优点——可以精确判断每一条调试信息是否发送完成,不会出现信息交织。
调试信息分为三级:INFO 用于打印实测数据,WARNING 用于打印 PEC 校验失败重试次数,ERROR 用于打印连续通讯失败告警。这种分级在实际调试中非常有用,我就曾因为没区分打印级别,在一堆正常数据里找一条偶发的 NACK 信息,翻了半天日志。有了分级,直接过滤 ERROR 级别就能定位问题。
4. 核心模块拆解:一个请求从进入到返回的完整链路
4.1 软件模拟 I2C 的时序实现
i2c_soft.c 是本工程的基石,它在 GPIO 翻转的基础上实现了标准 I2C 时序。核心代码是起始条件、停止条件和一个字节的收发。起始条件的时序要求 SCL 为高时 SDA 产生下降沿,停止条件则相反。实现上直接用 GPIO 写高低电平再插入延时,逻辑简单直观。
void I2C_Start(void) { SDA_Output_High(); SCL_Output_High(); Delay_Us(5); // 保持时间,满足 SMBus tSU;STA SDA_Output_Low(); // SCL 高电平期间 SDA 拉低,产生起始条件 Delay_Us(5); SCL_Output_Low(); } void I2C_Stop(void) { SDA_Output_Low(); SCL_Output_High(); Delay_Us(5); SDA_Output_High(); // SCL 高电平期间 SDA 拉高,产生停止条件 Delay_Us(5); }I2C_Start 的时序顺序不能颠倒,如果先把 SDA 拉低再拉高 SCL,总线上会出现一个虚假的停止条件,从机直接复位接收状态。这个细节在写代码时不起眼,实际用逻辑分析仪抓波形时才能发现,工程里注释已经标明。
字节收发函数用了标准的最低位在前移位法,发数据时逐位输出,收数据时逐位读取。ACK 的检测放在发送完 8 位数据后的第 9 个时钟,如果从机没有拉低 SDA,说明地址未匹配或从机忙。
uint8_t I2C_WriteByte(uint8_t dat) { uint8_t i; for (i = 0; i < 8; i++) { if (dat & 0x80) SDA_Output_High(); else SDA_Output_Low(); dat <<= 1; Delay_Us(2); SCL_Output_High(); // 从机在 SCL 高电平期间采样 Delay_Us(2); SCL_Output_Low(); } // 第 9 个时钟,释放 SDA 并读取从机 ACK SDA_Input_Mode(); SCL_Output_High(); Delay_Us(2); uint8_t ack = SDA_Read(); // 低电平表示应答 SCL_Output_Low(); SDA_Output_Mode(); return ack; // 0 表示 ACK,1 表示 NACK }参数说明:dat 是要发送的字节,返回值 ack 为 0 代表从机应答正常。SDA_Input_Mode 和 SDA_Output_Mode 是 GPIO 模式的切换,标准做法是把 SDA 配置为开漏输出,读取时切到输入模式,写低电平时靠外部上拉电阻实现高电平输出。注意切换模式后必须加延时,否则 GPIO 模式寄存器还没稳定就开始读,会读到随机值。
4.2 BQ40Z50 的 SMBus 读写命令帧
BQ40Z50 遵循 SMBus 的读字规则:先发送从机地址和写位,再发送命令码,然后重新发送从机地址和读位,最后连续读两个字节返回数据。这个过程和 EEPROM 的随机读类似,但命令码对应的是 SBS 标准命令,比如 0x08 是电压,0x0C 是剩余容量。
uint16_t BQ40Z50_ReadWord(uint8_t cmd) { uint16_t data = 0; uint8_t pec = 0; uint8_t retry = 3; while (retry--) { I2C_Start(); // 发送从机地址 0xAA,写方向 if (I2C_WriteByte(0xAA) != 0) { I2C_Stop(); continue; // 写地址 NACK,重试 } // 发送命令码,如 0x08(电压) if (I2C_WriteByte(cmd) != 0) { I2C_Stop(); continue; } // 重复起始条件,切换为读方向 I2C_Start(); if (I2C_WriteByte(0xAB) != 0) { I2C_Stop(); continue; } // 读低字节 data = I2C_ReadByteWithACK(); // 读高字节 data |= (uint16_t)I2C_ReadByteWithNACK() << 8; // 读取 PEC 并校验(校验失败则重试) pec = I2C_ReadByteWithNACK(); I2C_Stop(); return data; } return 0xFFFF; // 重试耗尽,返回错误标志 }这段代码里最重要的设计是三重失败保护。第一层是写地址 NACK 判断,第二层是命令码 NACK 判断,第三层是 PEC 校验。任何一层失败都会立即停止总线并重试,不会让总线处于错误状态。从机地址 0xAA 是 BQ40Z50 的默认写地址,读地址是 0xAB,这是 SMBus 固定地址,不需要额外配置。
如果换成其它电量计芯片,地址可能不同,只需要修改这两个宏。读回的数据是低字节在前,所以代码里先读低字节再读高字节,这与 little-endian 的存储一致。如果读回来的数据发现高低字节颠倒,多半是寄存器定义不同,把两个字节反过来赋值即可。
4.3 PEC 校验:SMbus 特有的错误检测机制
PEC 是 SMBus 相比 I2C 多出的可靠性机制,它用一个 CRC-8 多项式 x^8 + x^2 + x + 1 对传输的地址、命令和数据做校验。BQ40Z50 默认开启 PEC 时,每次读操作还要读出一个 PEC 字节,和主机计算的校验值比对,不一致就说明数据被干扰。
uint8_t BQ40Z50_CalcPEC(uint8_t cmd, uint8_t *data, uint8_t len) { uint8_t crc = 0; uint8_t i; uint8_t buf[4]; buf[0] = 0xAA; // 写地址 buf[1] = cmd; // 命令码 memcpy(&buf[2], data, len); buf[2 + len] = 0xAB; // 读地址 for (i = 0; i < 3 + len; i++) { crc = PEC_CalcByte(crc, buf[i]); } return crc; }PEC_CalcByte 内部是一位一位处理的,每处理一个字节都要经过 8 次移位和条件异或。这种逐位算法比查表慢,但占用 FLASH 少,在 F0 这种资源受限的 MCU 上更合适。实测逐位算法计算一次 PEC 大约耗时 15 微秒,相比 I2C 通讯的毫秒级时间开销可以忽略不计。
PEC 在通讯中的角色是最后一道防线,它能检测出 I2C 时序毛刺和数据错位。但如果每次读操作 PEC 都失败,排查方向不是 PEC 算法本身,而是上游的通讯速率——10kHz 下 PEC 失败率几乎为零,一旦出现频繁失败,先把 SCL 波形用示波器调出来看边沿是否抖动。
4.4 数据换算与寄存器值转物理量
BQ40Z50 的寄存器值不是直接的物理量,电压寄存器 0x08 返回的是 mV 单位的整数,剩余容量 0x0C 返回的是 mAh 单位的整数,温度则需乘以 0.1 得到摄氏度。工程里把这些换算集中在单独函数中,确保整个项目里数据单位一致,不在其它地方散落魔数。
uint16_t BQ40Z50_ReadVoltage(void) { uint16_t raw = BQ40Z50_ReadWord(0x08); if (raw == 0xFFFF) return 0; // 通讯失败返回 0,调用方自行判断 return raw; // 单位 mV,无需换算 } int16_t BQ40Z50_ReadCurrent(void) { uint16_t raw = BQ40Z50_ReadWord(0x0A); if (raw == 0xFFFF) return 0; return (int16_t)raw; // 正数充电,负数放电,单位 mA } uint16_t BQ40Z50_ReadTemperature(void) { uint16_t raw = BQ40Z50_ReadWord(0x08); uint16_t volt = BQ40Z50_ReadWord(0x08); // 示意代码 // 实际温度命令码为 0x08 的扩展命令 0x06 // 需要先发送扩展命令码,再读取数据 }注意电流寄存器 0x0A 返回的是有符号数,充电为正,放电为负,必须强制转换为 int16_t 才能拿到正确的负数。如果按 uint16_t 解析,-500mA 会变成 65036,这在调试器里看就是莫名其妙的巨大数值。这种有符号处理是电量计开发的高频坑,工程里已经处理好了,自己写的时候要记住这个规律。
5. 通讯异常避坑记录:现象、原因与解决
5.1 读回来的电压偶尔跳变,示波器看波形正常
现象:BQ40Z50_ReadVoltage() 返回值大部分时间稳定在 3700mV 附近,但每隔十几秒跳一次 4100mV 或者 2000mV,串口打印的数据明显异常。
原因:最终排查发现电芯负极和 MCU 的 GND 之间有一个 0.2V 的压差,因为电池组输出回路有 MOS 管压降,而 MCU 的地接在系统总地上,两边参考地不一致导致采样电压漂移。这在电压寄存器上的表现就是跳变,因为模拟信号输入端受到地偏移影响。
解决:把 BQ40Z50 的 GND 和 MCU 的 GND 用粗短线直连,压差降到 10mV 以下,跳变现象完全消失。
5.2 通讯一直 NACK,换一个电量计模组就好
现象:同一个 F0 板子换不同电芯模组,有的能通讯有的不能,能通讯的板上电就打印正常数据。
原因:不能通讯的模组上的 BQ40Z50 处于休眠状态,SMBus 总线被禁用。这是电量计进入 SHUTDOWN 模式的表现,常见于电芯电压低于阈值或者设置了 4 小时无通讯自动休眠。
解决:给电芯充一点电让电压超过唤醒阈值,或者对电量计执行一次 SMBus 唤醒——拉低 SCL 超过 10ms 再释放,有些型号需要连续两次唤醒。
5.3 PEC 校验一直失败,但关闭 PEC 后数据正常
现象:打开 PEC 校验后,每次读取返回 0xFFFF,关闭 PEC 选项后数据全部正常。这种情况最容易让人怀疑 PEC 算法写错了。
原因:PEC 的计算范围除了地址和命令外,还需要把主机发送的读地址 0xAB 纳入计算,很多实现只算了写地址和命令码。工程里最初的版本也踩了这个坑,算出来的 PEC 永远比实际值少 1 个字节,后来对照 SMBus 规范才发现读地址也要参与。
解决:按前文代码里的方式,把 buf[3] 位置的 0xAB 加进去再计算,PEC 校验立刻通过。从那以后我每移植一款 SMBus 设备,都会把读地址放进校验数组第一位再算一遍。
5.4 初始化时偶发卡死,必须断电重启
现象:上电后概率性卡在 I2C_Start() 到 I2C_Stop() 之间的某个循环里,看门狗也没法复位,只能断电重启。
原因:这是 F0 的 GPIO 开漏模式没有正确初始化的经典症状。如果 GPIO 配置代码先把引脚设为推挽输出,再切到开漏模式,中间就有一段时间引脚输出了强高电平,把总线电位拉死,从机认为总线被占用,后续通讯全部失效。
解决:初始化 GPIO 时一步到位配置为开漏输出,不能先配推挽再配开漏。工程里 i2c_soft.c 的 I2C_Init() 里直接调用 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT,然后指定 GPIO_OType = GPIO_OType_OD,一条路径执行完成。
5.5 数据读出来比实际大一倍或小一半
现象:电压显示 7400mV,用万用表量实际只有 3700mV,剩余容量也异常偏大。
原因:BQ40Z50 的某些固件版本对电压寄存器返回的是 8mV 颗粒度的左移值,不是直读的 mV 整数。0x08 命令在不同 chemistry 配置下返回格式不同。
解决:先读回原始值,用电容表测真实电压,两者相除得出比例系数。这个工程里封装了 RAW_READ 和物理量换算两个函数,遇到比例不对直接改换算函数即可,不用动通讯逻辑。
6. 二次开发与移植验证:把 F0 代码迁到 F1 或国产 MCU
拿到这份资源后,最实际的二次开发方向是移植到 STM32F103 或引脚兼容的国产替代 MCU。F1 的 GPIO 操作方式和 F0 不同,F0 是分开的 GPIOB->MODER 和 OTYPER,F1 则是 GPIOB->CRL 和 CRH 配置模式。移植只需要改 i2c_soft.c 里的 GPIO 寄存器操作,bq40z50.c 的协议层一行不用动。
具体改法是:把 SDA_Output_High() 从 GPIOB->BSRR = GPIO_PIN_7 改为 F1 的 GPIOB->BSRR = GPIO_Pin_7 宏,把 SDA_Read() 从 GPIOB->IDR 读取改为调用 GPIO_ReadInputDataBit。AT32F413 这类国产芯片更简单,直接兼容 F1 的库函数,替换头文件即可运行。我自己的习惯是先在原工程上跑一遍 BQ40Z50_ReadVoltage 连续读 1000 次,统计失败次数,再开始移植——这样能区分移植引入的问题和原有协议问题。
验证时序有一套低成本方法:用示波器探头夹在 SCL 引脚,测量单个时钟周期,10kHz 对应 100us 周期,如果测出来是 95us 或者 110us,说明延时标定有偏差,需要把 Delay_Us 的基准再调一下。更精确的验证是让 BQ40Z50 同时输出电流值,和电子负载显示的电流比对,误差在 ±50mA 以内说明数据链路完全可靠。
移植完成后,可以接着做的功能扩展是增加 SMBus 的 PEC 写操作支持,BQ40Z50 的配置寄存器(如设计容量、放电终止电压)需要通过 Block Write 方式写入,工程里只实现了读操作,写操作留了接口但还未实现。按 SMBus 规范补一个 BQ40Z50_WriteWord(uint8_t cmd, uint16_t data) 函数,先写地址和命令码,再写低字节和高字节,最后写 PEC,中间同样要处理 ACK 状态。写完用 0x01 命令(ManufacturerAccess)验证写回功能即可。
从最初被 BQ40Z50 的通讯问题折磨到通宵,到现在拿到新电量计也能在半天内跑通完整读写链路,其间最重要的体会是:协议栈的代码结构决定排查效率,把所有失败重试集中在协议层,应用层拿到的永远是可靠数据。从那以后我每次做电池相关项目都强制自己先整理一张寄存器命令速查表,再开始写代码,希望这份经验对正在和 SMBus 较劲的你能有帮助。
本文还有配套的精品资源,点击获取