
简介面向嵌入式开发者和电池管理方向的学习者这份资源基于STM32F030单片机通过软件模拟SMBus总线协议与TI公司的BQ40Z50电池管理芯片正常通信给出了读取电压、电流、温度、荷电状态SOC等电池状态量的完整实现。压缩包共141个文件大小2.3MB以C源码和头文件为主涵盖标准外设库、I2C转SMBus底层驱动、BQ40Z50寄存器读写示例并附带Keil工程配置、hex/axf固件以及map、lst编译输出目录按Core、HardWare、Fwlib、User模块划分便于从底层驱动到应用层逐项对照学习。已有3071人学习下载。读者可借此掌握单片机GPIO和I2C接口配置、SMBus时序控制、电量计命令与寄存器映射理解从编译、链接到下载调试的完整流程对嵌入式驱动开发、电池管理芯片应用和低速总线协议设计均有实际参考价值。 做电池类项目绕不开电量计。BQ40Z50这颗芯片在备电、电动工具、便携设备里几乎是默认选择TI内部做了阻抗追踪算法能直接给出SOC、电压、电流、温度这些关键数据省去自己在MCU里做查表和积分。我最近把一套BQ40Z50通讯正常的旧工程整体整理到了STM32F0上重新梳理了SMBus通讯底层代码这里把完整实现过程和踩过的坑记下来。这个项目不涉及BQ40Z50的化学配置和电量计参数标定重点聚焦在STM32F0怎么通过SMBus把电量计里的数据稳定读回来。适合三类人看刚开始做BMS却不知道怎么打通通讯的手上只有低成本的M0核想跑SMBus的以及已经能读到数据但经常被时序、PEC、地址问题折腾的。如果你只是想知道怎么把BQ40Z50里的电压温度读出来用这份笔记也够用。说实话STM32F0这颗芯片的硬件I2C外设在F0系列上并不算好用很多人调了半天都是卡在状态机上。我后来直接改成软件模拟I2C反而一次跑通。下方内容是实测后整理的笔记代码可以直接拿来改。1. BQ40Z50通讯方案选型为什么用SMBus模拟I2C1.1 BQ40Z50到底解决什么问题BQ40Z50是TI推出的多节电池电量计支持1-4节串联内部除了ADC采集电压电流以外还有一套完整的阻抗追踪知识库。MCU通过SMBus总线读它内部寄存器就能拿到当前电芯电压、平均电流、剩余容量和温度等状态。SMBus协议本身是基于I2C的地址、起始位、停止位、ACK机制几乎一致只是更规范比如定义了命令字、块读、PEC校验等。在备电或者锂电池工具项目里用BQ40Z50比自己在MCU里做库仑计要可靠得多。你的程序只负责“问”电量计负责“算”分工很清晰。通讯接口上BQ40Z50暴露出来的是两根线SMBC和SMBD一个时钟一个数据再配合电源和地总共四根线就能把电池状态全部拿走。需要注意一点BQ40Z50不是上电就能用的它需要先配置电芯化学成分ID、串联节数、容量等参数。我这个工程里通讯正常是因为这部分已经在量产状态里配置好了。如果你拿到的板子还是空片先别急着测通讯读出来的数据很可能全是0或者异常值。这时候排除问题要回到data flash配置而不是怀疑I2C代码。1.2 为什么选STM32F0和软件模拟I2CSTM32F0是ST在入门Cortex-M0核上出货量很大的一颗芯片价格便宜、主频48MHz、内置完整的外设大部分场景用标准外设库开发效率也不错。我手头这个项目选它主要为了替代老平台把成本压下来同时保留足够的串口、GPIO和定时器资源给上位机协议用。关于通讯方式有人可能会问F0明明有硬件I2C外设为什么还要用软件模拟I2C答案很简单在BQ40Z50这颗芯片的通讯场景里软模拟反而比硬件I2C好控制。F0的硬件I2C遇到NACK、总线繁忙、时钟延展等情况时状态机处理起来比较绕出问题时你很难判断是哪一步卡住。而软件模拟I2C每一拍都在代码里拿逻辑分析仪一看就能对应上时序排查起来非常直接。代价也有就是比较占CPU。但SMBus这种100kHz低速总线对我来说根本不算负担一次寄存器读取大概几十微秒放在100ms的轮询周期里完全不影响其他任务。况且F0本来就便宜多出的这点CPU占用换来的是一套一眼能看懂的通讯流程后期维护的人也不用去翻F0参考手册里的状态机图。对BQ40Z50的通讯场景来说稳定性和可排查性远比省那点CPU更重要。2. 硬件接线与工程环境准备2.1 关键引脚与接线要求BQ40Z50对外通讯相关的引脚主要有SMBC、SMBD、PRES、电源和地。SMBC和SMBD本质上就是标准I2C的SCL和SDA接线时必须要加上拉电阻。一般选4.7kΩ总线长度在30cm以内够用如果环境干扰大一些可以降到2.2kΩ但不能太小否则SMBus低电平可能拉不干净。我用的是4.7kΩ贴片电阻实际测试很长一段时间没有出现因上拉导致的通讯异常。STM32F0的GPIO有开漏输出模式这对模拟I2C非常关键。开漏模式下GPIO输出1时引脚是高阻配合外部上拉电阻才能正确抬起电平输出0时引脚拉低。这样同一根线既能输出又能接收不需要反复切换输入输出方向。只要BQ40Z50和STM32F0的供电电压都在3.3V左右SMBC/SMBD可以直接连不需要额外电平转换。PRES引脚一般接到普通GPIO上做电池在位检测代码里读电平就能知道电池是否插着。要注意PRES的默认逻辑和data flash配置有关不同配置可能反过来我一般在硬件上做上拉或下拉然后软件里通过打印确认当前插拔状态再决定怎么判。电源方面BQ40Z50和STM32F0共地是必须的否则通讯会发生很奇怪的偶发失败。2.2 固件库准备与工程要点STM32F0的工程我保留了标准外设库方式ST官方固件库包叫STSW-STM32048网站上搜一下“STM32F0标准外设库”都能找到。如果你的项目是基于STM32CubeMX生成的HAL也没关系本文代码只用到了GPIO和延时移植到HAL工程只是改一下宏定义的事情。工程里最重要的一个配置是GPIO模式。模拟SMBus的两个引脚要设置为开漏输出不是推挽。如果配置成推挽SDA读取时会读到寄存器输出值而不是外部电平通讯会直接失败。另一个核心是延时函数它决定总线频率。我用的F0默认时钟HSI 8MHz延时循环40次大约产生5微秒总线频率约100kHz刚好满足SMBus规范。如果主频改了循环次数必须重新标定。调试初期建议把延时调大一点也就是把总线频率降到50kHz甚至更低先确认通讯能通起来再把频率慢慢提上去。我习惯用一段可调的分频数组来观察波形找到最低可用的循环次数后再把固定值写死。很多“时通时不通”的现象本质上就是上升沿不够快或者时序裕量不足而不是代码逻辑错了把频率降低一级往往就消失了。3. SMBus通讯代码实现与验证3.1 模拟I2C底层时序先看SMBus最底层的基础时序。起始条件是时钟线SCL为高时数据线SDA拉低停止条件反过来SCL为高时SDA拉高。写数据时在SCL低电平期间改变SDA读数据时在SCL高电平期间采样。这些和标准I2C一致代码可以直接写成下面这样。#define SMBC_PORT GPIOA #define SMBC_PIN GPIO_Pin_6 #define SMBD_PORT GPIOA #define SMBD_PIN GPIO_Pin_7 #define SCL_1() GPIO_SetBits(SMBC_PORT, SMBC_PIN) #define SCL_0() GPIO_ResetBits(SMBC_PORT, SMBC_PIN) #define SDA_1() GPIO_SetBits(SMBD_PORT, SMBD_PIN) #define SDA_0() GPIO_ResetBits(SMBD_PORT, SMBD_PIN) #define SDA_READ() GPIO_ReadInputDataBit(SMBD_PORT, SMBD_PIN) static void smbus_delay(void) { volatile int i; for (i 0; i 40; i); } void smbus_start(void) { SDA_1(); SCL_1(); smbus_delay(); SDA_0(); smbus_delay(); SCL_0(); smbus_delay(); } void smbus_stop(void) { SDA_0(); SCL_0(); smbus_delay(); SCL_1(); smbus_delay(); SDA_1(); smbus_delay(); }起始和停止之间每一帧都以SCL低电平开始这也是总线释放的基础。真正容易出错的是读字节函数里的ACK处理主机读完第8个bit后第9个时钟要把SDA拉高释放然后在SCL高电平期间读从机返回的ACK。写字节则相反第8个bit结束后释放SDA然后读从机是否拉低ACK。如果从机忙或者地址不对它会保持高电平这时候返回1表示NACK。读取字节的时候还要区分是发送ACK还是NACK。在SMBus读操作中除了最后一个字节返回NACK通知从机“不用再发了”其余字节都要回ACK。这个逻辑写错最典型的现象就是读到的数据错位或者通讯后面直接挂死。3.2 BQ40Z50寄存器读写与块读有了底层时序读BQ40Z50的寄存器就是拼一个完整的总线事务。以读取16位数据为例SMBus的Word Read流程是起始→写入设备地址写位→写入寄存器命令字→重复起始→写入设备地址读位→读取低字节并回ACK→读取高字节并回NACK→停止。BQ40Z50默认地址常见是0xAA写和0xAB读实际值以数据Flash中的DEVICE_ADDRESS配置为准所以调不通时先确认地址。unsigned short smbus_read_word(unsigned char reg) { unsigned char lo, hi; smbus_start(); smbus_write_byte(0xAA); smbus_write_byte(reg); smbus_start(); /* repeated start */ smbus_write_byte(0xAB); lo smbus_read_byte(1); /* ACK */ hi smbus_read_byte(0); /* NACK */ smbus_stop(); return (unsigned short)((hi 8) | lo); }直接拿这段代码去读温度寄存器0x06、电芯电压寄存器0x08/0x09/0x0A都行单位分别是0.1K和mVSOC和电流也有对应的标准寄存器可以读。实际项目里我不建议一个寄存器一个寄存器去读因为每次完整事务都需要起始和停止读多了效率会下降。更好的做法是把多个寄存器打包用块读一次拉回来既减少了总线占用也避免了频繁切换重复起始带来的时序抖动。BQ40Z50支持Block Read从机在响应时先返回一个字节长度后面按顺序吐出寄存器数据主机的代码逻辑只多了一个长度字段的处理。这里提醒一句BQ40Z50如果配置了PEC包错误校验SMBus时序里会在数据末尾多一个PEC字节这个字节由地址、命令和数据计算出来多项式是0x07初值是0。调试阶段建议先在数据Flash里把PEC选项关掉等基本读写稳定后再打开。开了PEC之后主机的读取流程要重新计算一遍CRC并进行比对不匹配的数据要丢弃。unsigned char smbus_pec_calc(unsigned char *data, unsigned char len) { unsigned char crc 0; unsigned char i, j; for (i 0; i len; i) { crc ^ data[i]; for (j 0; j 8; j) { if (crc 0x80) crc (crc 1) ^ 0x07; else crc 1; } } return crc; }3.3 实测读取温度与电压的示例把上述函数组合起来主循环里就能周期刷新电池数据。我测试时大概这样的每200ms读一次温度、两节电芯电压和BatteryStatus如果读取失败计数加一连续失败就点亮故障灯。unsigned short temp_raw, volt1, volt2, status; temp_raw smbus_read_word(0x06); volt1 smbus_read_word(0x08); volt2 smbus_read_word(0x09); status smbus_read_word(0x16); /* 温度0.1K单位减去2731得到0.1摄氏度 */ int temp_c (int)temp_raw - 2731; /* 电压mV单位直接显示 */实测下来F0每次读一组数据大约耗时几百微秒200ms轮询完全不会影响系统负担。要注意温度寄存器是16位有符号数如果读到类似0xFFFF这种值直接转成int再计算别用unsigned short硬算否则负数会变成很大的正数显示出来就是几千度。从逻辑分析仪波形看整个事务的起始、重复起始、地址、命令、数据和停止位非常清晰这也验证了模拟I2C在这个场景下的可靠性。4. 通讯异常排查方法与经验整理4.1 现象速查表我在调试过程中整理了一个速查表遇到读数据不对的时候直接对着排查比重新翻协议图解方便得多。现象大概率原因排查方向读回全0xFF从机没响应或SDA被外部上拉检查地址、供电、时序读回全0x00SDA被拉死或GPIO配置成推挽示波器看SDA是否恒低起始后无ACK地址错误或从机在忙确认DEVICE_ADDRESS降低频率首字节正常后续超时PEC配置开启或重复起始异常先关闭PEC再测电压电流全为0BQ40Z50未配置化学ID或电芯未插入检查PRES和数据Flash配置这个表在项目移植的时候也可以直接当checklist用每一行对应一类问题省得反复盲试。我实际用下来出现频率最高的是前两类也就是地址和GPIO配置问题。曾有一块板子用了新供应商提供的改版样品后读回全0xFF查了半天才发现新样品的SMBD焊盘虚焊表里列的因素越早排除越能节省后面的调代码时间。4.2 排除步骤和几个容易踩的坑如果通讯完全不通我习惯按这个顺序排查先看硬件用万用表量SMBC和SMBD对地电阻确认上拉存在且阻值合理再用示波器或逻辑分析仪看启动瞬间线上有没有波形接着用树莓派或者USB转I2C工具做主站直接读一次BQ40Z50这样能快速判断是BQ40Z50本身没工作还是STM32F0这侧的问题。软件方面最容易踩的坑有三个。第一个是GPIO没有配成开漏推挽输出模式下SDA被输出寄存器强制控制读输入引脚电平根本没意义。第二个是重复起始没写对也就是读操作前先发了一个停止再发起始这在SMBus里虽然允许但很多从机不喜欢最好严格按照“先start→写完命令→再start→读数据”的顺序来。第三个是地址位宽问题写地址和读地址差了最低位很多人换算错了结果从机永远不响应。还有一个小坑在延时上。模拟I2C在内部主频48MHz和8MHz下同样一次延时循环的时长相差6倍。如果你把网上找到的工程直接拿到48MHz系统时钟下跑总线频率可能会到600kHz远超BQ40Z50的100kHz上限自然通不了。所以不管移植谁的代码第一时间把延时函数标定好。4.3 把数据交给上位机集成FreeModbus的思路很多项目里STM32F0不光是要读BQ40Z50还要把电池状态上报给上位机或者HMI。最省事的做法是在同一个工程里集成FreeModbus从站走Modbus RTU协议串口输出。这样上位机只要通过一组保持寄存器就能读到温度、电压、电流、SOC完全不用接触SMBus底层的细节。我实现的时候把BQ40Z50的读取放在主循环里每200ms刷新一次更新到一个全局结构体Modbus保持寄存器数组直接映射到结构体对应的地址。FreeModbus的eMBPoll放在定时器中断调用上位机随时读取两个模块互不干扰。这个扩展在资源紧张的MCU上也能跑起来F0的RAM和Flash虽然不大但跑一个FreeModbus从站加几组保持寄存器完全没问题总共增加的代码量很小却能大幅降低上位机开发人员的工作量。最后分享一个我在这类项目里反复用到的技巧不管上层协议用什么底层那个读取BQ40Z50的函数里尽量返回成功或失败标志而不是直接舍弃错误数据。Modbus侧如果发现连续几次SMBus读取失败就把寄存器里的值标记成无效上位机看到失效值就知道该告警而不是显示一个假的电压。这个设计在可靠性和用户体验上都很关键。本文还有配套的精品资源点击获取