☰
STM32L151驱动CW2015电量计:I2C通信与低功耗实现指南
2026/10/15 16:55:11 网站建设 项目流程

简介:这是一套基于STM32L151与CW2015的完整C语言驱动工程,面向嵌入式开发者和便携式电池设备设计者,解决微控制器读取电量计芯片并进行低功耗监测的问题。工程内部按功能划分模块,包含串行总线通信、电量计算、电压采集、中断处理等源码,可直接移植到实际项目中。压缩包共135个文件,主体为45个头文件和36个C源文件,另有工程配置、编译脚本、烧录镜像及辅助文档,整体大小仅1.69MB,组织结构清晰,适合直接导入开发环境查看。目前已有2743人学习下载。通过学习这份工程,可以掌握STM32L1系列的低功耗外设配置、CW2015的寄存器读写流程与数据解析技巧,以及电量算法在嵌入式端的落地方式,对开发电池供电产品、延长待机时间有直接的参考价值。

1. 在 STM32L151 上为电池电量单独挂一颗 CW2015:这套 C 语言程序解决什么问题

手持式锂电设备想显示剩余电量,最省事的做法是 MCU 用 ADC 分压采电池电压,再查表映射成百分比。做过的人都知道这条路有多不靠谱:电池在 3.7V 附近曲线极平,电压只差几十毫伏,SOC 却可能从 80% 一路掉到 20%;温度一变,电压跟着飘,百分比显示就成了玄学。STM32L151 是低功耗项目里常选的 Cortex-M3 单片机,CW2015 是一颗走 I2C 接口的单节锂电池电量计,把两者配成一套 C 语言程序,核心就是把 I2C 驱动、寄存器配置、容量初始化和电量换算写成稳定可维护的代码。做便携检测设备、手持仪表、TWS 充电仓这类产品的嵌入式工程师,照着这套思路可以直接搭出可用的电量采集链路。

2. CW2015 的 I2C 地址与寄存器映射:布线、上拉和上电后的第一轮读取

2.1 7 位地址 0x62:读写地址换算与多设备冲突排查

CW2015 的 I2C 从机地址是 7 位的 0x62,这是芯片定死的,没有地址引脚可以改。STM32 的 HAL 库 I2C 接口里,地址参数要求传 8 位格式(7 位左移一位再拼上读写方向),所以实际传给 HAL 函数的地址是0x62 << 1:

  • 写操作:0xC4
  • 读操作:0xC5

第一次调这块芯片时最容易在这里翻车:直接把 0x62 塞进 HAL 函数,主机发出去的地址是 0xC4 的高 7 位不匹配,从机不 ACK,读回来的数据全是 0xFF。排查方式很简单——用逻辑分析仪抓一次起始条件后的第一个字节,如果抓到的地址和预期差了一倍,就是这个换算的问题。

总线上如果有第二颗器件也占 0x62,CW2015 没有改地址的手段,只能把其中一颗挪到另一条 I2C 总线上。我一般会在硬件设计阶段就确认板子上没有第二颗 0x62 器件,免得软件层做地址转发这种吃力不讨好的事。

2.2 常用寄存器速查表:配置寄存器、状态寄存器、数据寄存器分开记

CW2015 的寄存器空间不大,我把工程里真正会用到的整理成一张表。注意不同批次芯片的行为一致,但个别位定义要以你手里那份手册为准,动配置寄存器之前先读一次默认值。

地址名称读写工程用途
0x00VERSION只读上电后第一个读它,确认 I2C 通
0x02配置寄存器1读写温度单位、电池节数等,动前先读默认值
0x04SOC_H只读剩余电量整数百分比
0x05SOC_L只读电量低字节,做平滑显示时用
0x06VOLT_H只读电压高字节
0x07VOLT_L只读电压低字节
0x08TEMP_H只读温度高字节
0x09TEMP_L只读温度低字节
0x0ACURR_H只读电流高字节,带符号
0x0BCURR_L只读电流低字节
0x1E容量配置读写设计容量低 8 位,高 2 位在 0x02
0x20MODE读写睡眠/正常模式切换
0x22CMD读写唤醒、复位等命令入口

寄存器按用途分三类记会比较顺:0x02、0x1E、0x20、0x22 是配置类,上电初始化时碰;0x04 到 0x0B 是数据类,正常运行时周期读;0x00 是状态类,只在调试验证时看。

2.3 最小电路与上电检查:先抓到 ACK 再写驱动

CW2015 的外围电路很简单,模块上一般就是四个脚:VCC、GND、SCL、SDA。关键点是 VCC 要接电池正极或者和电池直通的电源轨,不能接在 MCU 的 3.3V 稳压输出后面——CW2015 通过 VCC 感知电池电压,如果你给它喂一个恒定的 3.3V,电压寄存器读出来永远不变。SCL 和 SDA 各接一颗上拉电阻到 VCC,4.7k 适合调试和常规使用,10k 在低功耗场景更合适。

上电后不要急着写驱动,先用逻辑分析仪挂在 SCL/SDA 上,让 MCU 发一个读 0x00 的操作。正常的波形是:START + 0xC4 + ACK + 0x00 + 再 START + 0xC5 + ACK + 数据字节。只要有 ACK,硬件链路就是通的,后面写软件时出问题可以放心去查时序逻辑;如果一直 NACK,优先查地址换算、上拉电阻和虚焊,不要一头扎进代码里调半天。

3. 用 STM32L151 硬件 I2C 写 CW2015 驱动:初始化、读写封装与数据换算

3.1 I2C1 初始化关键参数:100kHz 与 7 位地址模式

STM32L151 的 I2C 外设用 HAL 库初始化很快,下面的配置是 CubeMX 生成后我实际改过的版本。我一般把 CW2015 挂在 I2C1 上,引脚用默认的 PB6/PB7 复用功能,具体 AF 映射以你板子的原理图为准,CubeMX 里选好引脚它会自动配。

I2C_HandleTypeDef hi2c1; void MX_I2C1_Init(void) { hi2c1.Instance = I2C1; hi2c1.Init.ClockSpeed = 100000; /* 100kHz 标准模式 */ hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2; hi2c1.Init.OwnAddress1 = 0; /* 主机模式,不关心自身地址 */ hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE; hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE; if (HAL_I2C_Init(&hi2c1) != HAL_OK) { Error_Handler(); } }

100kHz 是 CW2015 标准模式下的稳妥选择。这颗芯片不是高速设备,电量数据本身变化慢,400kHz 对功耗和稳定性都没有收益,反而在长走线上更容易被干扰。时钟源保持 HAL 默认即可,不需要对 I2C 的时钟树做特殊处理。初始化失败时先看引脚复用有没有配对,再看 APB1 时钟有没有被别的外设改乱。

3.2 寄存器读写封装:Mem_Read 与 Mem_Write 的合理使用

CW2015 是标准的寄存器寻址 I2C 器件,HAL 库的 Mem_Read 和 Mem_Write 正好对应这种访问方式。调用时注意第一个地址参数传的是 8 位地址 0xC4,不是 7 位的 0x62。

#define CW2015_I2C_ADDR (0x62 << 1) /* 0xC4,HAL 需要 8 位地址 */ uint8_t cw2015_read_reg(uint8_t reg, uint8_t *buf, uint8_t len) { HAL_StatusTypeDef st = HAL_I2C_Mem_Read(&hi2c1, CW2015_I2C_ADDR, reg, I2C_MEMADD_SIZE_8BIT, buf, len, 100); /* 超时 100ms */ return (st == HAL_OK) ? 0 : 1; } uint8_t cw2015_write_reg(uint8_t reg, uint8_t val) { HAL_StatusTypeDef st = HAL_I2C_Mem_Write(&hi2c1, CW2015_I2C_ADDR, reg, I2C_MEMADD_SIZE_8BIT, &val, 1, 100); return (st == HAL_OK) ? 0 : 1; }

Mem_Read 内部会自动处理 START + 写寄存器地址 + 再 START + 读数据的时序,不需要自己拼重复起始条件。读函数返回 0 表示成功,调用侧只要判断返回值就能决定是重试还是进入错误流程。CW2015 对连续写比较敏感,MODE 和 CMD 这种寄存器写完以后,我习惯加 2 到 5ms 延时再操作其他寄存器,给芯片内部状态机一点反应时间。

3.3 一次读取 SOC、电压、温度、电流:数据解析与换算系数处理

CW2015 的数据寄存器从 0x04 开始连续排布,一次读 8 个字节可以把四组数据全部拿回来。返回结构体定义成整型和定点数,方便上层直接显示,省得每个地方都做浮点换算。

typedef struct { uint16_t soc_pct_x10; /* 电量,0.1% 分辨率,1000 表示 100.0% */ uint16_t voltage_mv; /* 电压,单位 mV */ int16_t temp_c_x10; /* 温度,0.1℃ 分辨率,250 表示 25.0℃ */ int16_t curr_ua; /* 电流,单位 uA,正负方向见注释 */ } cw2015_data_t; uint8_t cw2015_read_all(cw2015_data_t *d) { uint8_t buf[8]; if (cw2015_read_reg(0x04, buf, 8)) /* 一次连读 0x04 ~ 0x0B */ return 1; /* 电量:高字节 0x04 是整数百分比,低字节按 1/255 折算 */ d->soc_pct_x10 = (uint16_t)((buf[0] + (float)buf[1] / 255.0f) * 10.0f); /* 电压:14 位,LSB = 0.3125mV,右对齐 */ uint16_t vraw = ((uint16_t)buf[2] << 8) | buf[3]; d->voltage_mv = (uint16_t)((float)vraw * 0.3125f); /* 温度:常见 0.1K 步进,先转成开尔文再减偏移 */ uint16_t traw = ((uint16_t)buf[4] << 8) | buf[5]; d->temp_c_x10 = (int16_t)((float)traw * 0.1f * 10.0f - 2731.5f); /* 电流:带符号,LSB 按手册确认,默认 1uA/LSB */ int16_t craw = (int16_t)(((uint16_t)buf[6] << 8) | buf[7]); d->curr_ua = (int16_t)((float)craw * 1.0f); return 0; }

SOC 高字节直接就是整数百分比,这是 CW2015 比较方便的地方,调试时读 0x04 看到 60,就说明电量在 60% 附近。电压换算里的 0.3125mV 来自 5.12V 量程除以 14 位 ADC 的 16384 个码值,如果读到的电压和万用表差得远,优先怀疑这里。温度和电流的 LSB 在部分手册版本里有差别,温度有一种直接按 0.1℃ 输出的定义,电流的 LSB 也和采样电阻有关,代码里两处系数我用宏或常量单独列出来,现场验证时只改这两处。电流正负方向表示充电还是放电,极性定义要对着手册确认一次,显示的时候才不会把充电画成放电。

4. CW2015 电量不准的典型坑与排查:容量配置、学习周期与那些玄学跳变

4.1 新片上电读 SOC 不可信:设计容量必须按电池实际写

CW2015 内部跑的是库仑计加电压修正的混合算法,算法里最关键的一个参数是电池设计容量。新片出厂时容量寄存器里是默认值,和你实际用的电池容量几乎不可能一样。容量配错会怎样:假设你电池是 500mAh,芯片以为有 2000mAh,放电时 SOC 下降速度会被拉长到四倍——你看电压已经到 3.5V 了,SOC 还挂着 40%,然后某一天它突然跌到 0%,用户完全没法预测。

配置容量的常见做法是把容量值拆成 10 位,低 8 位写 0x1E,高 2 位放 0x02 的 bit0 和 bit1。这里不同资料写法略有出入,我一般先读 0x02 默认值,只修改最低两位,避免把其他配置位冲掉。

void cw2015_set_capacity(uint16_t mAh) { uint8_t cfg, cap_low; uint8_t cap_high; cap_high = (mAh >> 8) & 0x03; /* 10 位容量的高 2 位 */ cap_low = mAh & 0xFF; /* 低 8 位 */ cw2015_read_reg(0x02, &cfg, 1); /* 先读回原值,只改低 2 位 */ cfg = (cfg & 0xFC) | cap_high; cw2015_write_reg(0x02, cfg); cw2015_write_reg(0x1E, cap_low); }

写完容量后读回确认一次,因为 0x02 里还藏着温度单位和电池节数之类的东西,万一读回结果和写入不一致,就停下来对手册,不要继续往下跑。容量参数的精度直接决定电量曲线的形状,这是整篇程序里最值得花时间核对的一个数。

4.2 SmartEngine 学习周期:满充满放一次,算法才会收敛

CW2015 内部有一个自学习机制,它会在充放电过程中记录电压、电流、温升和 SOC 的变化关系,逐步修正电池模型。这个机制决定了:新板子第一次上电,即使容量配准了,电量显示也不会立刻变准。

我第一次用这颗芯片时,配好容量后充满电显示 100%,放到 50% 一切正常,再往下走就开始跳:一会儿 40%,一会儿 55%,重启一下又回到 60%,看起来完全没有规律。后来才明白,算法还在学习阶段,它没见过你这节电池的完整放电曲线,中间段只能靠默认模型猜。

正确做法是让系统完整跑一个循环:充满到保护电压,然后正常放电直到关机保护,中间不要频繁重启、不要中途拔电池。跑完这一轮后,芯片会把实际看到的曲线和内置模型做对照修正,之后的 SOC 会明显平稳。工程上这一步叫“学习周期”,产线上如果条件允许,出货前至少完成一次满充满放,否则用户拿到手的前几天电量一定是飘的。

4.3 五个常见坑:从 I2C 全 F 到温度 200 摄氏度

下面这几条是我在这个方案上实际踩过的,按“现象、原因、解决”写清楚,遇到类似症状可以直接对照。

  1. 读 VERSION 永远是 0xFF,或者 I2C 一直 NACK。原因是 HAL 地址参数用了 7 位地址 0x62,或者 SCL/SDA 缺上拉。解决:地址改成 0x62 左移一位,波形上确认有 ACK,再查上拉电阻焊接。

  2. SOC 读回来一直是 0 或者 100,电压却有变化。原因是芯片可能还在睡眠状态,或者容量没写。解决:先写 0x22 命令唤醒,再确认 0x1E 容量寄存器不是默认值。

  3. 电压正常,SOC 中段剧烈跳变。原因是学习未完成,算法对你这节电池没有建立模型。解决:完整跑一次充放电循环;如果跳变伴随大电流负载,把读取频率提到 1s 一次,靠软件滤波压住。

  4. 温度读出来 200℃ 或者负的离谱值。原因是换算系数用错:温度寄存器常见是 0.1K 步进,不是 0.1℃;也有个别版本直接输出 0.1℃。解决:室温下读一次原始值,套两种公式算一遍,哪个接近当前室温就用哪个。

  5. I2C 偶尔死锁,读数据出现错位或卡死。原因是从机状态异常或者总线被拉死,没有做错误恢复。解决:每次读写判断返回状态,连续失败三次就重新初始化 I2C 外设,再做一次唤醒流程。这种做法简单粗暴,但对 I2C 偶发故障是最有效的兜底。

5. 低功耗整机方案:L151 停止模式与 CW2015 睡眠模式的配合

5.1 三种电量读取节奏:轮询、RTC 定时唤醒、事件触发

电量芯片是那种“没必要一直看着它”的外设,SOC 在几分钟内不会有本质变化,所以读取策略要跟整机功耗目标绑在一起。项目早期我用 100ms 轮询,开发调试很方便,但整机电流很难看;后期产品化时改成低功耗节奏,效果差很多。三种方式对比如下。

读取方式电流代价适用场景
100ms 轮询高,MCU 无法深睡调试阶段、需要实时看曲线
RTC 30s 唤醒读一次低,平均电流可接受放电态产品、显示刷新要求不高
事件触发 + 周期兜底最低,只在需要时醒充电仓、便携设备等带插拔检测的产品

RTC 定时唤醒是 L151 上的常见做法:MCU 进 STOP 前把 RTC 闹钟设好,唤醒后读一次 CW2015,更新显示缓存,再睡回去。30 秒一次的频率对绝大多数产品够用,用户盯着屏幕看电量变化的场景不多。事件触发适合有充电插入检测的产品,检测到插入或拔出的瞬间立刻读一次电量,其余时间拉长周期。

5.2 进入睡眠的时序:先睡电量计,再睡 MCU,唤醒顺序反过来

低功耗模式下,MCU 和 CW2015 都要睡。这里有个顺序问题:要先让 CW2015 进睡眠,再让 MCU 进 STOP;唤醒时反过来,MCU 先醒,再把 CW2015 叫起来。顺序反了会出现一种情况——MCU 都睡过去了,CW2015 还在正常跑库仑计,白白多耗几十微安。

void enter_low_power(void) { /* 1. 先睡 CW2015:MODE 寄存器写睡眠位,常见值是 0x04 */ cw2015_write_reg(0x20, 0x04); HAL_Delay(2); /* 2. 释放 I2C 引脚,避免 STOP 期间总线状态不确定 */ HAL_I2C_DeInit(&hi2c1); /* 3. MCU 进 STOP,低功耗调节器模式 */ HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI); } void exit_low_power(void) { /* 1. 恢复系统时钟,STOP 唤醒后时钟树需要重新配置 */ SystemClock_Config(); /* 2. 重新初始化 I2C */ MX_I2C1_Init(); /* 3. 唤醒 CW2015:CMD 寄存器写 0x11,多数固件用这个命令 */ cw2015_write_reg(0x22, 0x11); HAL_Delay(5); }

HAL_PWR_EnterSTOPMode 的第一个参数在 L151 上选低功耗调节器,唤醒后必须重新调 SystemClock_Config,否则外设时钟频率不对,I2C 时序直接乱掉。唤醒 CW2015 后加 5ms 延时,让它内部状态稳定了再读数据,实测不加这个延时会偶尔读到旧缓存。

5.3 上拉电阻吃掉的电流:低功耗项目最容易漏算的一笔账

I2C 总线上那两颗上拉电阻,很多人设计时随手放个 4.7k,到低功耗调试时电流下不去,最后查到是总线电平被拉低导致的。原理是:I2C 空闲时 SDA 和 SCL 都是高电平,上拉电阻两端压差接近零,确实没有电流;但只要总线上任何一个设备把 SDA 拉低,4.7k 到 VCC 之间就会流过 3.3V / 4.7k 约 0.7mA 的电流。SCL、SDA 两条线都拉低就是 1.4mA,比整机睡眠电流预算大出两三个数量级。

这类问题最容易出现在 STOP 模式下:CW2015 已经睡了,如果 MCU 的 I2C 引脚在 DeInit 后变成浮空输入,外部设备某个状态把 SDA 钳在低电平,漏电就产生了。排查时用万用表量 STOP 状态下 SDA 和 SCL 的对地电压,只要有一根低于 VCC,就顺着总线找是谁拉低的。彻底的做法是把上拉电阻的供电单独引到一个 GPIO 控制的电源轨上,睡眠时直接断电;省事一点的做法是睡眠前把 I2C 两个引脚显式配置成 GPIO 推挽输出高,让总线电平被主动拉住。这两种方式我都在项目里用过,后者少一根线,前者更干净。

6. 把电量显示做到不挨骂:滤波、限幅与产线校准技巧

6.1 SOC 显示别直接刷:滑动滤波加死区

CW2015 输出的 SOC 在负载波动时会上下抖动,尤其是大电流放电瞬间,库仑计和电压修正打架,百分比可能在两三个数之间跳。直接把这个值刷到 UI 上,用户看着屏幕跳来跳去就会觉得产品有问题。

常见的处理是显示端做一次限幅滤波:相邻两次显示值的变化率限制在每分钟 5% 以内,超过就按限制值走;同时在当前值附近设一个 2% 的死区,只有偏离超过 2% 才触发刷新。这样屏幕上的电量是缓慢滑过去的,而不是一格一格蹦。RTC 唤醒读一次本来就是低频,配合这套滤波,整条链路在 UI 上的表现会非常平滑。

6.2 完成充放电后记录电压-SOC 曲线,一图看出方案对不对

整机调完以后,我最推荐做的验证是连续记录一次完整放电过程:从充满到关机保护,每隔一分钟存一条电压和 SOC 记录,最后画成曲线。正常曲线应该是中段平滑、SOC 和电压单调对应,没有明显的台阶或回跳。如果在曲线上看到电压还在 3.8V,SOC 却已经掉到个位数,不用怀疑,容量参数还是不对,重新核对 0x1E 和 0x02 的配置。

产线校准这块,我自己踩过的教训是:不要把学习过半的芯片随便换到另一台机器上。CW2015 记录的模型是针对某一节电池特征的,换电池后如果不重新配置容量,显示误差会变大。量产时的可靠做法是:装配时写好容量参数,整机测试阶段完成一次完整的充放循环,出货前再读一次 SOC 确认在合理范围。第一次做这个方案时我图省事,默认容量没改就送到测试台,结果设备充满电显示 100%,用半小时掉到 5%,差点以为是芯片坏了——后来才知道是容量寄存器背着我没告诉它的坑。希望这些经验帮到你少走这段弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询