资讯动态

STM32L151搭配CW2015:嵌入式锂电池电量计驱动开发与避坑指南

发布时间:2026/10/15 16:55:11 来源:尧图企业网站定制
简介STM32L151驱动CW2015电量计芯片的嵌入式示例工程面向需要实现电池电量精准监测的开发者。工程覆盖I2C/SPI接口配置、读写操作、中断监控及ADC电压采集等关键环节代码结构完整便于直接移植或学习底层驱动方法。压缩包共135个文件以45个h头文件、36个c源文件为主附带工程配置文件、编译脚本及hex/axf烧录文件整体大小约1.69MB目录划分清晰可快速定位驱动与主程序。截至目前已有2743人学习下载适合有STM32基础、正在开发便携式或低功耗设备的工程师研究使用。通过研读该工程可深入理解CW2015电量计算法解析以及MCU与电量计芯片的协作流程对提升嵌入式电源管理能力有实际帮助。1. STM32L151 配 CW2015这件事的本质是给电池做一个“余额显示”接手过一个温湿度记录仪的项目一节锂电池要撑一年客户要求在屏幕上直接显示剩余电量百分比。那时候我就发现用单片机 ADC 去读电池电压再换算电量是最典型的“看着省事、后期翻车”方案。STM32L151 是低功耗 MCU 里的常青树CW2015 是一颗很小的单节锂电池电量计芯片两者通过 I2C 通信MCU 定期读回电压、电量百分比和温度。下面我从硬件连线开始把 CW2015 的寄存器配置、可直接抄的 C 代码、避坑顺序和校准习惯一次说清楚。文章适合正在做电池供电产品、想把电量显示做得靠谱一点的嵌入式工程师。2. CW2015 这颗电量计为什么选它以及最小硬件电路长什么样2.1 电量计和“读电压估电量”差在哪直接读电压估电量的做法大家都不陌生锂电池空载 4.2V放空约 3.0V理论上把电压线性映射到 0~100% 就行。问题在于锂电池的放电曲线中间段非常平缓3.7V 到 3.6V 之间压差很小对应的剩余电量可能差了 20%。而且负载一变化电池端电压立刻波动读出来的百分比跟着跳用户就会觉得这个产品“电量不准”。CW2015 这类电量计芯片内部有库仑计和电压算法它会持续监测充放电状态再结合电池模型输出一个相对稳定的 SOC 百分比。MCU 只需要通过 I2C 读一个寄存器就能拿到“剩余百分之几”这个结果省得自己维护电池模型。对 STM32L151 这种主打超低功耗的 MCU 来说把电量计算丢给外部芯片MCU 自己该睡就睡系统整体功耗也更容易压下来。CW2015 的供电范围能覆盖单节锂电池的典型工作区间正常工作模式下自身功耗很低适合常年在线的电池供电设备。它还能输出电池电压和温度等于一颗芯片同时干了三件事电压采集、温度采集、电量计算。后面驱动的核心也就是把这三个数据稳定读回来。2.2 最小硬件连接四根线加一个可选中断CW2015 的引脚不多最小系统只需要 VIN、GND、SDA、SCL 四根线。ALRT 是开漏输出的中断引脚需要做低电量报警时再连不做的话悬空或者接上拉都可以。引脚方向连接说明VIN电源输入接电池正极电压范围要落在芯片允许区间内GND地与电池负极、STM32L151 共地SDAI2C 数据开漏外部上拉 4.7kΩSCLI2C 时钟开漏外部上拉 4.7kΩALRT中断输出开漏需要报警功能时接 MCU 的 EXTI 引脚SDA 和 SCL 的上拉电阻是关键。有些开发板内部已经带了上拉可以直接用自己做 PCB 时别省这两颗电阻。我一般会把上拉电阻放在 CW2015 这边而不是 MCU 那边走线更短抗干扰也好一点。CW2015 的 I2C 7 位地址是 0x62换算成 8 位写地址是 0xC4、读地址是 0xC5。硬件上最容易被忽略的是电池和 CW2015 之间的走线。VIN 引脚对电池电压的采样很敏感走线太长或者和开关节点挨太近读回来的电压值会周期性抖动。铺铜时把 VIN 走线当成模拟信号处理别跟 I2C 数据线平行长距离走。2.3 16 位寄存器地址CW2015 的 I2C 时序和普通传感器不一样很多 I2C 传感器用的是 8 位寄存器地址发送流程是起始位、器件地址、寄存器地址、数据。CW2015 不一样它的寄存器地址是 16 位的发送时必须先发高字节、再发低字节。以读 VCELL 电压寄存器为例完整时序是起始位、写地址 0xC4、寄存器高字节 0x00、寄存器低字节 0x09、重复起始位、读地址 0xC5、连续读两个字节、结束。这个差异是移植时最容易踩的坑。以前我把 CW2015 当成普通 8 位寄存器芯片写驱动只发一个寄存器地址结果芯片一直不回 ACK读回来的全是 0xFF当时还以为是芯片坏了。后来翻了手册才反应过来地址是两字节。也就是说不管读哪个寄存器地址部分都要发两个字节高字节在前。寄存器地址的高字节基本固定在 0x00但代码里不能省。CW2015 的 I2C 时序里还包含时钟拉伸从机在内部处理期间会把 SCL 拉低一会儿这个机制在后面避坑章节还要重点讲。先把寄存器地图和初始化流程理清楚再上驱动代码。3. 寄存器地图与初始化配置先把 CW2015 叫醒再谈读数3.1 关键寄存器一览CW2015 的寄存器不算多驱动里真正用到的就这几个MODE、STATUS、SOC、VCELL、TEMP、CONFIG、CMD。用表格列出来方便写代码时对照。寄存器地址作用关键值/说明MODE0x00工作模式0x30 进入 Active0xA0 进入 SleepSTATUS0x01状态标志bit0 为 SOC 有效位SOC0x02电量百分比单位 1%范围 0~100VCELL0x09/0x0A电池电压高字节在前14 位 ADC 结果TEMP0x0B/0x0C电池温度高字节在前有符号数单位 0.1KCONFIG0x0D容量配置按电池容量查表写入CMD0x0E命令寄存器写 0x0015 解锁写 0x005A 保存配置这些地址里SOC 寄存器读出来就是整数百分比直接赋值给 UI 显示。VCELL 和 TEMP 都是两字节读取顺序是高字节在前。CONFIG 寄存器的值是整个驱动里最需要谨慎对待的参数它决定了电量计认为电池“满充容量”是多少配错了后面 SOC 一定不准。3.2 初始化流程解锁、配置、保存、进入 ActiveCW2015 上电后的默认配置不一定匹配你的电池所以程序启动后要按固定顺序做一次初始化。顺序很重要先向 CMD 寄存器写 0x0015 解锁然后写 CONFIG 寄存器再向 CMD 写 0x005A 保存最后把 MODE 写成 0x30 进入 Active 模式。CONFIG 和 CMD 所在的寄存器区域默认是受保护的直接写不进去解锁和保存这两步要是反了配置要么写不进去要么保存不了。下面这段是初始化函数软件 I2C 的底层函数先假设已经有了后面第 4 章会补全。假设头文件里已经定义了 REG_CMD、REG_CONFIG、REG_MODE 这些地址宏。// cw2015.c 片段初始化函数 #define CW2015_CONFIG_SOC 0x0A // 按电池容量查表0x0A 对应 3500mAh 档 uint8_t CW2015_Init(void) { uint8_t cmd; // 第一步写 CMD 0x0015 解锁寄存器 cmd 0x15; if (CW2015_WriteReg(0x00, REG_CMD, cmd, 1) ! 0) { return 1; // I2C 通信失败返回错误 } // 第二步配置 CONFIG 寄存器值由电池容量决定 cmd CW2015_CONFIG_SOC; if (CW2015_WriteReg(0x00, REG_CONFIG, cmd, 1) ! 0) { return 2; } // 第三步写 CMD 0x005A 保存配置掉电不丢失 cmd 0x5A; if (CW2015_WriteReg(0x00, REG_CMD, cmd, 1) ! 0) { return 3; } // 第四步进入 Active 模式 cmd 0x30; if (CW2015_WriteReg(0x00, REG_MODE, cmd, 1) ! 0) { return 4; } return 0; }这段代码里有四个关键参数0x0015 是解锁命令每次修改配置寄存器之前都要先写它0x005A 是保存命令写完配置后执行配置会被写入芯片内部非易失存储下次上电还在0x30 是 Active 模式值CW2015_CONFIG_SOC 这个宏则需要根据实际电池容量查芯片手册里的配置表。手册里会给不同容量档对应的数值比如 0x02 是 470mAh 档、0x0A 是 3500mAh 档。容量介于两档之间时选最接近且略保守的一档宁可让芯片认为电池容量小一点也别让它以为电池能装更多电。CONFIG 写错了不会报错芯片照常出数据只是 SOC 在整个放电周期里都会偏。这个错很隐蔽因为电压读数是准的、温度也是准的只有百分比不准容易让人怀疑是算法问题实际就是配置没配对。3.3 初始化好了怎么确认看 STATUS 寄存器的 SOC 有效位CW2015 的 STATUS 寄存器 bit0 是 SOC 有效标志。芯片刚上电或者刚改完 CONFIG内部需要时间重新学习电池状态这时候读 SOC 寄存器拿到的值可信度不高。驱动里可以在初始化后轮询 STATUS等 bit0 置 1 再允许 UI 显示电量百分比。// 等待 SOC 有效带超时保护 uint8_t CW2015_WaitSOCValid(uint16_t timeout_ms) { uint8_t status; uint16_t cnt 0; while (cnt timeout_ms) { if (CW2015_ReadReg(0x00, REG_STATUS, status, 1) 0) { if (status 0x01) { // bit0 SOC_VALID return 0; // 有效正常退出 } } Delay_ms(10); // 换成你自己的毫秒延时 cnt 10; } return 1; // 超时仍未有效 }这个等待过程不是必须的但产品化的时候建议加上。不加等待开机瞬间显示一个不准确的百分比用户第一印象就差了。超时时间我一般给 5 到 10 秒如果超过这个时间 STATUS 的 bit0 还不置位就要怀疑 CONFIG 配置或者电池接线有没有问题。STATUS 寄存器读完后标志位不会自动清除bit0 会一直保持除非芯片重新进入学习状态所以不需要做额外清除动作。4. 核心驱动实现电压、电量、温度三路读数的 C 代码4.1 软件模拟 I2C为什么我在低功耗项目里放弃硬件 I2CSTM32L151 本身有硬件 I2C 外设用它读 CW2015 完全可以。但我在这类超低功耗项目里更习惯用软件模拟 I2C原因有两个。一是硬件 I2C 在从机做时钟拉伸时容易进 Busy 状态一旦卡死很难自恢复低功耗设备又不能动不动复位。二是软件模拟 I2C 对引脚没有特殊要求任意两个 GPIO 都能用PCB 布局更灵活改板子的时候不用重新配置外设映射。代价是 CPU 会占用一点时间但对 100kHz 的 I2C 速率来说每次读取也就几十微秒对整体功耗影响很小。下面代码用标准外设库风格编写SCL 和 SDA 都配置成开漏输出。开漏的好处是读 SDA 时不用切换 GPIO 方向直接读 IDR 就行因为引脚空闲时被上拉为高从机拉低时读到低。// i2c_soft.c 片段引脚定义和基础时序 #define I2C_SCL_PIN GPIO_Pin_6 #define I2C_SDA_PIN GPIO_Pin_7 #define I2C_PORT GPIOB #define SCL_H() GPIO_SetBits(I2C_PORT, I2C_SCL_PIN) #define SCL_L() GPIO_ResetBits(I2C_PORT, I2C_SCL_PIN) #define SDA_H() GPIO_SetBits(I2C_PORT, I2C_SDA_PIN) #define SDA_L() GPIO_ResetBits(I2C_PORT, I2C_SDA_PIN) #define SDA_READ() GPIO_ReadInputDataBit(I2C_PORT, I2C_SDA_PIN) static void I2C_Delay(void) { volatile uint8_t i 6; while (i--); // 约 5us 32MHz } static void I2C_Start(void) { SDA_H(); SCL_H(); I2C_Delay(); SDA_L(); I2C_Delay(); SCL_L(); } static void I2C_Stop(void) { SDA_L(); SCL_H(); I2C_Delay(); SDA_H(); I2C_Delay(); } static uint8_t I2C_WriteByte(uint8_t dat) { uint8_t i; for (i 0; i 8; i) { if (dat 0x80) SDA_H(); else SDA_L(); SCL_H(); I2C_Delay(); SCL_L(); I2C_Delay(); dat 1; } SDA_H(); // 释放 SDA等待从机 ACK SCL_H(); I2C_Delay(); if (SDA_READ()) { // 高电平说明从机未应答 SCL_L(); return 1; } SCL_L(); return 0; } static uint8_t I2C_ReadByte(void) { uint8_t i, dat 0; for (i 0; i 8; i) { SCL_H(); I2C_Delay(); dat 1; if (SDA_READ()) dat | 1; SCL_L(); I2C_Delay(); } return dat; } static void I2C_Ack(uint8_t ack) { if (ack) SDA_H(); // NACK else SDA_L(); // ACK SCL_H(); I2C_Delay(); SCL_L(); I2C_Delay(); SDA_H(); }I2C_Delay 的延时决定了总线速率我这里按 32MHz 主频给了 6 次空循环实测约 5us。调快调慢都不影响功能但要保证 SCL 高电平时间满足 CW2015 的最小时钟要求建议别低于 2.5us。I2C_WriteByte 的返回值 0 表示收到 ACK1 表示 NACK调用方要根据返回值判断通信是否正常。I2C_ReadByte 每读完一个字节后由主机决定是回 ACK 还是 NACK读最后一个字节时回 NACK这样从机才知道数据传输结束。4.2 读写 CW2015 寄存器的封装有了基础时序接下来封装针对 CW2015 的读写函数。写寄存器要依次发送器件写地址、寄存器高字节、寄存器低字节、数据读寄存器则是在发完地址后加一个重复起始位再发器件读地址然后连续读。// cw2015.c 片段寄存器读写封装 static uint8_t CW2015_WriteReg(uint8_t reg_high, uint8_t reg_low, uint8_t *buf, uint8_t len) { uint8_t i; I2C_Start(); if (I2C_WriteByte(0xC4) ! 0) { // CW2015 写地址 I2C_Stop(); return 1; } I2C_WriteByte(reg_high); // 16 位寄存器地址高字节 I2C_WriteByte(reg_low); // 16 位寄存器地址低字节 for (i 0; i len; i) { if (I2C_WriteByte(buf[i]) ! 0) { I2C_Stop(); return 1; } } I2C_Stop(); return 0; } static uint8_t CW2015_ReadReg(uint8_t reg_high, uint8_t reg_low, uint8_t *buf, uint8_t len) { uint8_t i; I2C_Start(); if (I2C_WriteByte(0xC4) ! 0) { I2C_Stop(); return 1; } I2C_WriteByte(reg_high); I2C_WriteByte(reg_low); I2C_Start(); // 重复起始位 if (I2C_WriteByte(0xC5) ! 0) { // CW2015 读地址 I2C_Stop(); return 1; } for (i 0; i len; i) { buf[i] I2C_ReadByte(); if (i len - 1) I2C_Ack(0); // 前几字节回 ACK else I2C_Ack(1); // 最后一字节回 NACK } I2C_Stop(); return 0; }读寄存器时要注意重复起始位。发送完寄存器地址后不能直接发读地址而是要先再拉一次起始位这是 I2C 协议里的 repeated start。漏掉这一步从机就不知道你要从读模式读数据返回的数据全是乱的。发送寄存器地址那两步我也省略了错误检查工程代码里建议补上避免地址发送失败后继续读。4.3 读电压、电量、温度的换算细节三个应用层函数分别读取 VCELL、SOC、TEMP。SOC 最简单读出来就是整数百分比。VCELL 和 TEMP 需要按芯片手册的换算系数转成物理量。// cw2015.c 片段应用层读取函数 float CW2015_ReadVoltage(void) { uint8_t buf[2]; uint16_t vcell; if (CW2015_ReadReg(0x00, 0x09, buf, 2) ! 0) { return 0.0f; } vcell (buf[0] 8) | buf[1]; // LSB 0.305185mV除以 1000 换成伏特 return (float)vcell * 0.305185f / 1000.0f; } uint8_t CW2015_ReadSOC(void) { uint8_t soc; if (CW2015_ReadReg(0x00, 0x02, soc, 1) ! 0) { return 0xFF; // 读失败返回一个明显无效的值 } return soc; } float CW2015_ReadTempC(void) { uint8_t buf[2]; uint16_t raw; int16_t temp; if (CW2015_ReadReg(0x00, 0x0B, buf, 2) ! 0) { return 0.0f; } raw (buf[0] 8) | buf[1]; // 温度是有符号数单位 0.1K按 10 位符号扩展再换算 if (raw 0x0200) { temp (int16_t)(raw | 0xFC00); } else { temp (int16_t)(raw 0x01FF); } return (float)temp * 0.1f - 273.15f; }VCELL 的换算系数 0.305185 是芯片手册给定的。有些手册说明 VCELL 是 14 位有效数据如果你的代码发现电压读数整体偏大检查一下是不是需要先把数据右移两位再乘系数。TEMP 的符号位位置以手册为准我代码里给的 0x0200 对应 10 位有符号数换成 12 位就把判断位改成 0x0800、掩码改成 0xF000。读失败时我习惯让电压返回 0.0、SOC 返回 0xFF而不是让上层看到一个看似合理的 50%。这样可以靠一个判断把异常读数和正常读数分开。上层 UI 发现 SOC 是 0xFF 时显示“--”而不是一个会误导用户的数字。这个习惯在做产品时很重要电量显示错了比不显示更麻烦。5. CW2015 避坑指南电量跳变、I2C 卡死、SOC 不准的排查顺序5.1 首次上电 SOC 乱跳放一会又变现象新板子第一次上电读到的 SOC 在 30% 到 60% 之间跳有时候开机显示 45%过几分钟变成 52%再放一会又掉回 40%。原因CW2015 内部没有这块电池的历史数据上电后要重新学习电池的开路电压和充放电状态。如果 CONFIG 寄存器里写的容量和实际电池容量差太多学习期间的 SOC 波动会更明显。解决先确认初始化序列里有没有写 CONFIG 并执行 0x005A 保存再确认 CONFIG 的档位和电池容量匹配。排除这两个因素后剩下的是正常学习过程做一次完整的充放电循环让芯片学完SOC 就会稳定下来。不要拿刚上电十分钟的数据去判断芯片好坏。5.2 硬件 I2C 偶发卡死复位才恢复现象用 STM32L151 的硬件 I2C 读 CW2015跑几小时一切正常跑几天后某次读取再也不返回SCL 一直被拉低看门狗复位后又能跑一阵。原因CW2015 内部在做电量计算时会对 SCL 做时钟拉伸也就是把时钟线拉低一会儿等内部处理完再释放。硬件 I2C 对这个过程的处理能力有限遇到超时或仲裁错误后如果没有彻底释放总线就会一直卡在 Busy 状态。解决最省心的做法是换软件模拟 I2C把时序控制握在自己手里。如果必须用硬件 I2C需要加长超时时间并在错误中断里执行 I2C 外设的复位序列。从我的经验看低功耗设备里软件模拟 I2C 的稳定性明显更好代价只是每次读取多花几十微秒 CPU 时间。5.3 夏天读出的电池温度是 85℃现象环境温度 25℃CW2015 读回来的温度显示 85℃ 或者一个更大的数但电压和 SOC 都正常。原因温度寄存器是有符号数按无符号数直接转换就会出现这种离谱结果。还有一个更隐蔽的情况符号位判断错了位数负温度会被展开成大正数。电池不会到 80 多摄氏度读成这样基本就是符号处理的问题。解决先确认读回来的原始值和实际温度之间的关系。25℃ 对应的开尔文温度是 298.1K原始寄存器值大约 2981。如果读回来的是这个数量级说明换算公式写错了。按第 4 章的符号扩展逻辑处理后再乘 0.1 减 273.15。改完如果室温下读数和真实温度差在正负一两度以内就算正常。5.4 低功耗唤醒后 SOC 一直不更新现象STM32L151 进入 STOP 模式定时唤醒读一次 SOC发现无论过了多久读到的百分比都不变。把设备完全断电再上电数据反而变了。原因低功耗设计里 SDA 和 SCL 如果没做好电平处理STOP 模式下 I2C 总线电平不确定CW2015 可能退出了正常通讯状态。另外一个常见原因是唤醒后没有重新把 MODE 寄存器写成 0x30芯片还停在未激活状态。解决唤醒流程里补一步重新初始化至少把 MODE 写成 Active。如果连 CONFIG 都重新写一遍也不会造成副作用只是多花几个毫秒。还要检查 STOP 模式下 SDA 和 SCL 的电平两个引脚都保持高电平才不会让从机误判为起始条件。5.5 读回来的数据全是 0xFF现象上电后读哪个寄存器都是 0xFF用示波器看 SDA 波形有数据但就是没内容。原因八成是寄存器地址发少了CW2015 要求 16 位寄存器地址只发一个字节它不认。另一个可能是 SDA 或 SCL 的 GPIO 配置成了推挽输出开漏配置缺失导致总线冲突。解决对照第 2 章的时序自查确认发送顺序是“器件地址、寄存器高字节、寄存器低字节”。再用万用表量 CW2015 的 SDA 和 SCL 引脚电平空闲时都应该被上拉到高。如果是推挽输出把 SDA 强拉低从机也没法回应改成开漏输出加外部上拉就好。6. 进阶把驱动变成一块“读得准”的电量表6.1 用真实充放电曲线校准 CONFIG驱动能读出数据只是第一步读得准才决定产品体验。拿到一台可以记录放电容量的设备把电池充满然后用固定电流放电到截止电压记录 CW2015 在不同时刻报的 SOC 和实际放出的容量百分比画两条曲线对比。如果 SOC 比实际掉得快说明 CONFIG 里配的容量偏小掉得慢则说明容量偏大往相邻档位调整后再测一轮。6.2 量产前把配置固化进产测流程批量生产时每台设备的电池可能批次不同但 CONFIG 必须和产品标称电池一致。我习惯在产测固件里加一条调试命令让产测工装能直接读回 SOC、VCELL、TEMP 三个值用来确认驱动通路正常。新批次电池变更时先做一轮曲线校准再把新的 CONFIG 值更新到头文件宏定义里避免产线用了旧配置。这两年我养成一个习惯CW2015 的驱动头文件里一定把电池容量、CONFIG 值、换算系数和符号位长度的出处写成注释换人维护时不用重新翻手册。回想之前几次“电量不准”的排查最后都落在配置值和符号处理上很少有真正玄学的问题。希望这篇文章能帮你少走一段弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑