STM32L151+CW2015电量计驱动:I2C SOC采集与低功耗设计

发布时间:2026/10/26 4:43:22
STM32L151+CW2015电量计驱动:I2C SOC采集与低功耗设计 简介基于STM32L151RET6超低功耗微控制器与CW2015电量计芯片的C语言工程面向具备一定嵌入式基础、希望掌握电池状态监测方案的开发者与学生。内容以驱动开发为主线涵盖I2C/SPI通信建立、CW2015寄存器读写、中断服务程序配置、ADC电池电压采集与校准以及剩余电量数据解析等环节可用于搭建便携设备与电池供电项目的电量监测原型。压缩包共135个文件约1.69MB包含45个h头文件、36个c源文件另有o、d、crf等编译中间文件及uvprojx、sct、hex、map等Keil工程与构建产物便于直接打开参考或移植。已有2752人学习说明该方案在同类资源中具有一定参考价值。读者可从中获取微控制器外设初始化、I2C/SPI时序处理、电量计数据换算和低功耗模式配置的完整代码脉络也能借鉴工程目录组织与调试排错思路适合作为CW2015驱动开发与STM32L1系列硬件实践的对照样本。1. 项目定位拿到一块只有电池和 MCU 的板子先把 CW2015 的 SOC 读出来样机装好外壳、接上电池屏幕电量却卡在 100% 不动——这个 STM32L151 CW2015 的 C 语言程序要解决的就是让低功耗 MCU 通过 I2C 把电量计的电压、SOC、剩余可用时间读出来并且做到 STOP 模式下不把整机功耗拖上去。它适合手上已经有一版 L 系列硬件、正缺电量计驱动的人也适合刚接触电量计、想看清楚寄存器怎么变成百分比数值的开发者。CW2015 程序本身不算大真正容易出问题的地方只有三处上电时序、电池参数表、低功耗唤醒后的外设重建。后面按工程结构一层层拆能直接抄的部分我都标出来。2. 工程结构与技术栈从 CubeMX 生成物到 cw2015 驱动目录这份程序的技术栈很朴素STM32L151 的裸机 CHAL 库I2C 外设做主机没有 RTOS也没有动态内存。朴素是好事电量计这种东西一旦掺进多任务读写时序和低功耗切换就会互相影响出问题调试起来很麻烦。真正要设计的是分层——哪些代码跟 CW2015 这颗芯片绑死哪些代码只关心“电池还剩多少”这两类必须搁在不同文件里否则以后换电量计型号或者换电芯就要满工程搜宏定义。2.1 STM32L151 的 I2C 资源与时钟约束STM32L151 是 Cortex-M3 内核I2C1 通常落在 PB6/PB7 或 PB8/PB9I2C2 在 PB10/PB11。CW2015 走标准 I2C 从机协议100 kHz 完全够用没必要上 400 kHz 给自己添时序余量上的麻烦。这里有个容易被忽略的点I2C 的时序寄存器是按 PCLK 算出来的如果工程里为了省电把系统时钟从 32 MHz 切到 2 MHz 的 MSI而 I2C 初始化还停留在原来的配置通信就会时好时坏表现为“有时候能读到重启一次又不行”。我的做法是把 I2C 初始化函数单独拎出来任何一次系统时钟变更之后都重新调一遍别指望 HAL 自动帮你重算。上拉电阻要按硬件实际情况来。CW2015 的 SDA/SCL 是开漏结构模块上一般自带 4.7k 到 10k 的上拉如果板子把上拉省了指望 MCU 内部那点弱上拉示波器上看上升沿会明显变缓100 kHz 下勉强能通温度一低就丢包。这一点在打样阶段就要确认不要相信代码能救回来。提示I2C 引脚在进入 STOP 模式前要确认电平状态。如果从机正拉着 SDA 低电平MCU 直接睡下去醒来第一件事就是总线死锁后面所有读写都会超时。2.2 目录分层把芯片相关代码关进一个文件夹我把工程切成四层芯片相关的全部收在cw2015.c/h里业务层只认结构体。这样以后换一颗电量计改动范围就是两个文件加一个初始化调用。路径职责Core/Inc/cw2015.h对外接口、寄存器宏、状态码、数据结构Core/Src/cw2015.cI2C 读写封装、寄存器解析、总线恢复Core/Src/battery.c滤波、告警阈值、对外暴露业务接口Core/Src/batinfo.c电池模型参数表按电芯型号准备Core/Src/main.c采集调度、STOP 模式进出Core/Src/i2c.cCubeMX 生成的 I2C 初始化会被重复调用这套分层的价值在调试阶段最明显。读数不对的时候先看cw2015.c返回的状态码是 I2C 层挂了还是数据层换算错了一眼能分开业务层的人不需要知道寄存器地址是 0x00 还是 0x02他只需要一个千分比的 SOC 值。2.3 上电到第一次读到 SOC 的完整顺序顺序错了后面全是白费功夫。我一般按这个流程走上电先延时 5 ms 让芯片电源稳定然后读版本号寄存器确认通信链路通不通接着读配置寄存器把休眠位清掉让芯片进入工作态再批量写电池参数延时 20 ms 等第一次 ADC 转换完成最后读一次 SOC 并丢弃。第一次读到的值经常是芯片内部默认值或者上一次掉电前的残留直接显示出去用户看到的就是“刚开机 87%两秒后变 12%”这种奇怪现象。初始化函数返回状态码不要在里面死循环重试。上层拿到错误码可以先显示一个“电量检测中”等下一轮采集再试比整机卡死在这里好得多。我在main.c里用一个全局错误标志记录初始化失败界面层读到就显示占位符用户至少知道设备还活着。3. 通信层拆解寄存器读写、字节序和总线自愈CW2015 的访问模型很简单一个 8 位寄存器地址后面跟若干字节数据。麻烦的是三件事——地址要左移、多字节数据是高字节在前、总线被拉死之后得手动恢复。这三件事在 HAL 库的封装下不会自动帮你处理都得自己写一层。3.1 一层薄封装统一读写入口所有对芯片的访问都走这两个函数不要在东一处西一处直接调HAL_I2C_Mem_Read。统一入口的好处是超时时间只有一处可改出错日志也只打一个地方。#include cw2015.h #include i2c.h /* 50ms 对 100kHz 下的几字节传输足够宽裕 再长就是总线有问题继续等没有意义 */ #define CW2015_I2C_TIMEOUT_MS 50 static cw2015_status_t cw2015_read_regs(uint8_t reg, uint8_t *buf, uint16_t len) { HAL_StatusTypeDef st; /* DevAddress 必须是左移一位后的 8 位形式 这里 CW2015_I2C_ADDR 已经在头文件里做过 0x62 1 */ st HAL_I2C_Mem_Read(hi2c1, CW2015_I2C_ADDR, reg, /* 寄存器地址 */ I2C_MEMADD_SIZE_8BIT, /* 地址宽度 8 位 */ buf, len, CW2015_I2C_TIMEOUT_MS); return (st HAL_OK) ? CW2015_OK : CW2015_ERR_I2C; } static cw2015_status_t cw2015_write_regs(uint8_t reg, const uint8_t *buf, uint16_t len) { HAL_StatusTypeDef st; st HAL_I2C_Mem_Write(hi2c1, CW2015_I2C_ADDR, reg, I2C_MEMADD_SIZE_8BIT, (uint8_t *)buf, /* HAL 这个参数没加 const只能强转 */ len, CW2015_I2C_TIMEOUT_MS); return (st HAL_OK) ? CW2015_OK : CW2015_ERR_I2C; }len参数决定这是一次单字节读还是连续读。CW2015 的电压、SOC、剩余时间都是 2 字节寄存器连续读两次单字节和一次读两字节在结果上一样但后者少一次 START出问题的概率更低。超时给 50 ms 是经验值I2C 挂死的时候 HAL 会一直等到超时才返回给太长会把整个采集周期拖住给太短又容易被正常的时钟拉伸误判。3.2 批量写电池参数为什么比逐字节写稳电池模型参数是一段连续地址的数据长度按手册来常见是几十字节。有人图省事写个 for 循环一个字节一次写能跑通但每次写都要重新发 START、地址、寄存器号中间任何一次被中断打断参数区就只写了一半。芯片拿着半套参数算出来的 SOC 会偏得离谱而且这种偏差是“静默”的界面上看不出来只有放电曲线对不上才会发现。cw2015_status_t cw2015_load_batinfo(const uint8_t *info, uint16_t len) { uint8_t check[4]; if (info NULL || len 0 || len CW2015_BATINFO_MAX) { return CW2015_ERR_PARAM; } /* 一次连续写中途不释放总线参数区要么全写进去要么全失败 */ if (cw2015_write_regs(CW2015_REG_BATINFO, info, len) ! CW2015_OK) { return CW2015_ERR_I2C; } /* 写完回读前 4 字节做一次基本校验 注意有些批次回读的是内部影子寄存器值可能和写入不一致 这一步只做链路确认不做逐字节比对 */ if (cw2015_read_regs(CW2015_REG_BATINFO, check, sizeof(check)) ! CW2015_OK) { return CW2015_ERR_I2C; } return CW2015_OK; }len的上限写成宏别用魔法数字。回读校验这里要留个心眼部分版本的芯片回读参数区拿到的是内部加载后的值和写进去的原始字节不一定逐位相同所以我把校验降级成“能读回来就算链路 OK”真正的参数正确性靠放电曲线验证不靠寄存器比对。这一点吃过亏当时写了逐字节比对结果每次上电都报参数错误查了一整天才发现是回读语义的问题。3.3 总线被拉死之后的 9 个时钟恢复STM32 的 I2C 外设有个老毛病从机在传输中途掉电或者复位可能把 SDA 一直拉低MCU 这边的BUSY标志就永远清不掉之后所有读写都超时。这时候软件复位外设也不一定管用得手动发时钟把从机的移位寄存器顶出去。void cw2015_bus_recover(void) { GPIO_InitTypeDef gpio {0}; /* 先把 I2C 外设整个关掉引脚交还给 GPIO */ HAL_I2C_DeInit(hi2c1); /* PB6/PB7 是本板 I2C1 的引脚换板子要跟着改 */ gpio.Pin GPIO_PIN_6 | GPIO_PIN_7; gpio.Mode GPIO_MODE_OUTPUT_OD; /* 必须开漏推挽会把从机顶坏 */ gpio.Pull GPIO_PULLUP; gpio.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, gpio); /* 先释放两条线为高电平 */ HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6 | GPIO_PIN_7, GPIO_PIN_SET); HAL_Delay(1); /* 打 9 个 SCL 脉冲从机最多卡在 8 位数据中间 9 个脉冲足够让它把当前字节走完并释放 SDA */ for (int i 0; i 9; i) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); HAL_Delay(1); } /* 补一个 STOP 条件SCL 高电平期间 SDA 由低变高 */ HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET); HAL_Delay(1); /* 引脚交回外设重新初始化 */ MX_I2C1_Init(); }HAL_Delay(1)看起来慢实际上 9 个脉冲总共也就 20 ms 左右只在错误恢复路径上走不影响正常采集。真正要注意的是最后必须重新调MX_I2C1_Init()因为HAL_I2C_DeInit把外设时钟和寄存器都清了不重新初始化后面读写全是HAL_ERROR。这套恢复逻辑我一般挂在“连续 N 次读失败”之后触发N 取 3不要一次失败就恢复偶尔的单次超时是正常现象。4. 数据层拆解从 14 位原始值到屏幕上的百分比通信通了只是第一步接下来是把两个字节变成一个能显示的数字。这一步看着简单实际是精度问题的高发区尤其是电池参数和电芯不匹配的时候读出来的 SOC 曲线能歪到没法看。4.1 电压与 SOC 的换算基准CW2015 的电压寄存器是 14 位有效高字节在前最低两位可能是状态位取的时候要屏蔽掉。换算系数按手册给的 LSB 来常见是 305 μV 左右不同版本可能有细微差别最终以手头那份手册为准。SOC 寄存器同样是 14 位常见的换算方式是原始值除以 256 得到百分比。/* 14 位电压值转毫伏屏蔽高位状态再按 LSB 换算 */ static uint16_t cw2015_parse_voltage(const uint8_t *b) { uint16_t raw ((uint16_t)b[0] 8) | b[1]; /* 高字节在前 */ raw 0x3FFF; /* 只保留低 14 位 */ /* 305uV/LSB先乘后除避免整数截断丢精度 */ return (uint16_t)(((uint32_t)raw * 305u) / 1000u); } /* SOC 转千分比业务层不用浮点显示时再除以 10 */ static uint16_t cw2015_parse_soc_permille(const uint8_t *b) { uint32_t raw (((uint32_t)b[0] 8) | b[1]) 0x3FFF; uint32_t permille; /* 原始值 / 256 得到百分比再乘 10 变成千分比 */ permille (raw * 10u) / 256u; if (permille 1000u) { permille 1000u; /* 超范围一律夹到 100%不要显示 103% */ } return (uint16_t)permille; }用千分比而不是浮点是因为很多小屏的显示代码里根本没链接浮点库一旦引入floatflash 占用会明显涨一截。先乘后除的顺序也别改raw * 305 / 1000和raw / 1000 * 305在整数运算下结果差很多后者会把小数部分全丢掉。注意字节序是最常见的翻车点。CW2015 是高字节在前而有些同类的电量计是低字节在前直接抄别人的解析函数读数会是正常值的几十倍。4.2 电池参数表决定了一半的精度驱动写得再漂亮参数表不对SOC 就是估算不准的。CW2015 需要一组描述电芯特性的参数包括不同温度、不同负载下的开路电压和 SOC 对应关系通常由厂家工具根据电芯型号生成。这份程序里我把它单独放在batinfo.c用一个 const 数组保存好处是换电芯只改一个文件不用碰驱动。参数表不能随便拿同容量电芯的来顶。同样标称 2000 mAh 的两款电芯放电平台可能差 100 mV 以上参数一错SOC 在 30% 到 70% 这段会明显偏离。判断参数对不对光看静态读数没用得跑一次完整放电恒流放到截止电压每 10% 记一个点和参数表里的曲线比偏差超过 5% 就得重新生成。这套验证很枯燥但不做的话后面所有电量显示的争论都没有依据。4.3 滤波与滞回别把原始值直接扔给 UI芯片输出的 SOC 本身是带波动的负载突变的时候跳几个百分点很正常。直接把原始值送显示用户会看到电量在 47% 和 49% 之间来回横跳观感极差。我的做法是一阶低通滤波系数取 1/8兼顾跟随速度和抗跳变。#define SOC_FILTER_SHIFT 3 /* 1/8 的滤波系数 */ static uint32_t soc_filtered; void battery_update(void) { cw2015_data_t d; if (cw2015_read(d) ! CW2015_OK) { /* 读失败保持上一次的值绝不把 0 显示出去 */ return; } /* filtered filtered - filtered/8 raw/8 用移位代替除法Cortex-M3 上一条指令就够 */ soc_filtered soc_filtered - (soc_filtered SOC_FILTER_SHIFT) ((uint32_t)d.soc_permille SOC_FILTER_SHIFT); }滤波之外还要加显示滞回只有变化超过 1% 才刷新屏幕否则保持原值。这两层加起来界面上的电量才会是“慢慢往下走”而不是“心电图”。滤波系数别调太小1/16 以上跟随会明显滞后快充时用户会觉得电量涨得慢半拍。5. 排查清单CW2015 读数异常时先看这五个地方5.1 上电后读数固定不变现象是电压值正常刷新SOC 却一直停在同一个数字插拔电池也不变。原因通常是芯片还停在休眠态配置寄存器里的休眠位没清掉ADC 只在初始那一刻转换过一次后面不再更新。解决办法是在初始化里读回配置寄存器清掉休眠位再写回去写完延时 20 ms 让转换完成。这里有个容易踩的细节有些版本的配置寄存器是写 1 清零有些是写 0 清零极性搞反了会越写越睡必须按手册逐位核对别凭经验猜。5.2 I2C 一直返回 HAL_ERROR示波器上却看不到波形现象是初始化第一步读版本号就失败逻辑分析仪抓不到任何 START 条件。原因多半是引脚复用没配对CubeMX 里选了 I2C1实际焊的是 I2C2 的引脚或者 GPIO 模式被别的初始化函数改回了普通输出。解决办法是先确认MX_I2C1_Init()在MX_GPIO_Init()之后调用再量一下引脚在空闲时是不是高电平。如果两条线都是低说明被从机拉着走一遍 9 时钟恢复流程如果两条线一高一低检查上拉电阻有没有漏贴。5.3 SOC 在某个区间来回跳变现象是电量在 50% 附近反复横跳幅度两三个百分点。原因是原始值没滤波或者参数表和实际电芯不匹配导致曲线上有拐点。先加一阶低通压住抖动如果压完还是在特定区间跳那就是参数问题跑一次完整放电对比曲线。还有一种可能是采样周期不稳定忙的时候 500 ms 读一次闲的时候 3 s 读一次滤波效果会变形。把采集周期固定成 1 s 的定时任务跳变会小很多。5.4 进入 STOP 模式唤醒后 I2C 全部超时现象是低功耗模式加进去之后设备睡一觉醒来就读不到电量了必须复位才行。原因是 STOP 模式下 I2C 外设的时钟被关掉唤醒后系统时钟回到默认的 HSI而 I2C 的时序寄存器还是按睡眠前的 PCLK 配的两边对不上。解决办法是唤醒之后按顺序重配系统时钟、重新调MX_I2C1_Init()再读数据。我一般把这段封装成board_restore_after_stop()主循环里睡醒第一件事就调它别指望 HAL 内部帮你收拾。5.5 电压读数正常但 SOC 明显偏低现象是万用表量电池 3.9 V程序读出来也是 3.9 VSOC 却只有 20%。原因是参数表加载失败或者写进去的参数被芯片拒绝芯片退回到默认模型在算。解决办法是检查cw2015_load_batinfo()的返回值以及在初始化顺序里参数写入是不是在清休眠位之后——顺序反了芯片还没醒参数写进去会被丢掉。另外确认参数长度没超过芯片参数区的容量超了之后多出来的字节会覆盖到相邻寄存器症状会更奇怪。6. 二次开发把「能读数」改成「能管电」的三步改造驱动跑通只是拿到了数据真正要做产品得让电量参与决策。第一步是把接口抽出来业务层不要直接依赖cw2015_read中间垫一层结构体换芯片的时候只改实现。typedef struct { int (*init)(void); int (*read)(battery_info_t *out); /* 电压、SOC、剩余时间 */ void (*sleep)(void); /* 进入低功耗前的收尾 */ } battery_ops_t; /* battery.c 里只认这个指针 */ static const battery_ops_t *g_bat_ops cw2015_ops;第二步是加状态机。低电量不能只看 SOC得电压和 SOC 双条件SOC 低于 5% 且电压低于 3.4 V 才触发关机单看 SOC 在低温下会误判。阈值要带 2% 到 3% 的迟滞否则在临界点附近会反复弹出告警又消失。第三步是验证。改造完别只盯着屏幕看数字拿电子负载做一次恒流放电同时用串口把每秒的电压和 SOC 打出来最后和参数表曲线对比。三个判据满电到空电的总时长和标称容量偏差在 10% 以内SOC 单调下降不出现回升关机点电压落在 3.3 V 到 3.5 V 之间。这套改造我前后翻过两次车一次是忘了给sleep()留钩子睡下去之前没把 I2C 引脚置高醒来总线死锁另一次是迟滞写成了单向电量低于阈值关机之后再也没法开机。从那以后我每次加低功耗逻辑都强制走一遍“满电—放电—关机—充电—开机”的完整循环不看代码看现象。希望帮到你。本文还有配套的精品资源点击获取