最近做了一块低功耗环境监测采集板,MCU 选的是 STM32F091RC,电源部分没有继续沿用以前那种“一颗 DC-DC 加两三颗 LDO”的分离方案,而是换成了带 I2C 接口的电源管理芯片 PCA9422,让 MCU 通过对寄存器读写去控制整块板子的供电路径。项目要求电池供电、待机电流足够低、运行时能根据负载动态调压,还要能实时读取充电和电池状态。刚开始我以为这活儿不难,等到真正把这套系统调通才发现,电源管理这事儿的颗粒度远超想象——上电时序、模式切换、寄存器读改写、异常恢复,任何一个环节没处理好,板子都会用各种出其不意的方式回报你。
这篇文章就按我当时的设计与调试顺序,把完整方案拆开写一遍:先讲为什么选择“PMIC + MCU”组合,再讲电源状态机怎么设计,然后落在 STM32F091RC 驱动 PCA9422 的通信层和几个核心功能实现上,最后分享一下测量和排坑的经验。适合正在做低功耗嵌入式产品、或者第一次在项目里引入 I2C 可编程 PMIC 的工程师参考。
1. 为什么电源管理要交给“PMIC + MCU”组合
1.1 先给“完整电源管理”画个边界
很多朋友一听到“电源管理”,第一反应是“把电压转换做出来就行”。真正做过产品就会发现,一个完整的电源管理系统至少包含下面这些内容:
- 多路电源输出,并保证每一路上电和下电的顺序满足器件要求;
- 电压可动态调整,让负载较轻时自动降电压省电,负载上来后再升回去;
- 电池的充电、放电、充满、异常等状态的检测与处理;
- 整个系统进入低功耗模式和从低功耗唤醒时的电源切换动作;
- 电压跌落、过流、输出短路等异常的监测与保护。
如果全部用分离器件搭,每一路输出配一个 LDO、再加几个比较器和 RC 延时电路实现时序,器件数量会非常可观,而且时序一旦要改动,就得改硬件。我最早做这类设计就是吃了这个亏:上电时序的延时靠 RC 电路调,改一次参数要动电阻电容,BOM 还弄得特别碎。后来换成 PCA9422 这类带 I2C 接口的 PMIC,大部分电源逻辑可以通过寄存器配置完成,硬件调试时只需要改软件,灵活性完全不一样。
1.2 PCA9422 与 STM32F091RC 的分工边界
在我这套系统里,PCA9422 与 STM32F091RC 的分工是这样的:PCA9422 负责“电”的部分,包括输入电源转换、多路可编程输出、各输出的使能时序、工作模式切换以及电源状态标志位;STM32F091RC 负责“策略”的部分,包括根据系统运行状态决定让 PMIC 输出多少电压、何时关断哪一路输出、进入低功耗前如何配置 PMIC、唤醒后如何恢复供电等。
选择 STM32F091RC 主要看中几点:主频 48MHz,资源应对这套逻辑绰绰有余;片上有多个 I2C 外设,其中一个可以专门留给 PMIC 使用;低功耗模式覆盖完整,从 Sleep 到 Stop 再到 Standby 都有,适合做多级低功耗调度。更重要的是,它和 PMIC 之间只需要一组 I2C 信号线加几个 GPIO,PCB 布线比一堆分离电源芯片干净得多。
需要说明的是,我下面的描述里给出的是 PCA9422 这类 I2C 可编程 PMIC 的通用配置思路,具体寄存器名和地址以你手上的数据手册为准,但设计框架可以直接照搬。
2. 系统侧的电源状态机设计:把上电时序和模式切换串起来
2.1 定义五个电源状态
刚开始写电源管理代码时,我犯过一个典型错误:把各种电源操作散写在业务代码里,结果业务状态一多,电源操作被重复调用,上电时序乱得一塌糊涂。后来我改成先定义一套独立的电源状态机,让所有电源行为都收敛到状态机里。
我把这套系统的电源状态划分成 5 个:
| 状态 | 含义 | PMIC 输出 | MCU 状态 |
|---|---|---|---|
| PWR_OFF | 完全关机或深度待机 | 关键负载断电,仅保留唤醒电源 | Standby 或外部关机 |
| PWR_BOOT | 启动过程 | 各路输出按顺序开启 | 正常运行,执行上电时序 |
| PWR_RUN | 正常运行 | 输出按负载动态调节 | 运行状态 |
| PWR_SLEEP | 浅睡眠 | 重要外设降电,保留唤醒源 | Stop 模式 |
| PWR_SHUTDOWN | 主动关机 | 除了保持唤醒电路外全部断电 | Standby 模式 |
优先级是明确的:任何状态只能从当前状态转移到定义好的相邻状态,不支持业务代码里随意调用“某某电源接口”。这样做的最大好处是,后续排查“为什么这个外设没电”的时候,只需要看当前状态是什么、是谁把状态切走的,不需要满工程搜 GPIO 操作。
2.2 状态切换的执行顺序
状态切换不是一句“把状态变量改掉”就完事,每一跳都有一堆收尾和初始化动作。以最典型的“运行态进入睡眠态”为例,我的执行顺序是这样的:
- 先停掉所有会产生中断的外设,包括 ADC、定时器、UART 发送等,避免睡眠过程中被意外唤醒;
- 把待保存的数据落盘或写入备份寄存器;
- 通过 I2C 操作 PCA9422,把需要降电的外设电源输出调到睡眠档位,或者直接把相应输出关断;
- 配置唤醒源,比如 RTC 闹钟、按键 EXTINT、PMIC 的充电检测中断;
- 调低 MCU 自身时钟或进入 Stop 模式。
这个顺序里最容易漏掉的是第 3 步。很多人只把自己 MCU 切到 Stop,却忘了外设还在满电压工作,待机电流自然降不下来。实测下来,把外设电源从 3.3V 降到 1.8V 再进睡眠,整板电流能差出好几毫安。
2.3 状态机代码骨架
状态机本身我建议不要用复杂框架,用一个枚举加一个切换函数就够了:
typedef enum { PWR_OFF = 0, PWR_BOOT, PWR_RUN, PWR_SLEEP, PWR_SHUTDOWN } pwr_state_t; static pwr_state_t pwr_state = PWR_OFF; int pwr_state_transition(pwr_state_t next) { switch (pwr_state) { case PWR_OFF: if (next == PWR_BOOT) { pmic_enable_outputs_in_order(); pwr_state = PWR_BOOT; } break; case PWR_BOOT: if (next == PWR_RUN) { /* 等待所有输出稳定后进入正常运行 */ pmic_wait_power_good(); pwr_state = PWR_RUN; } break; case PWR_RUN: if (next == PWR_SLEEP) { pwr_enter_sleep_sequence(); pwr_state = PWR_SLEEP; } else if (next == PWR_SHUTDOWN) { pwr_shutdown_sequence(); pwr_state = PWR_SHUTDOWN; } break; case PWR_SLEEP: if (next == PWR_RUN) { pwr_wakeup_sequence(); pwr_state = PWR_RUN; } break; default: pwr_state = PWR_OFF; break; } return 0; }所有电源相关函数只在对应状态分支里被调用,业务模块想切换电源状态时只调用这个接口,不直接操作 PMIC 寄存器。这样即便后面有同事改代码,也不会在奇怪的地方多拉一条 GPIO 把某个电源给关了。
3. STM32F091RC 驱动 PCA9422 的 I2C 通信层
3.1 I2C 外设初始化与总线地址确认
PCA9422 是 I2C 从设备,STM32F091RC 作为主机时,通信层是所有电源操作的基础。我这边直接把硬件 I2C1 留给 PMIC,初始化时注意几个容易被忽略的细节:
- 速度选择 400kHz 即可,PMIC 寄存器操作非常轻量,跑 1MHz 反而容易在长线上出现信号质量问题;
CCR、TRISE两个时序参数要根据总线上拉电阻和分布电容实测调整,尤其在板子走线较长时;- 确认设备地址。PMIC 的从机地址一般由地址引脚电平决定,硬件上电前先量一下地址引脚是被拉到地还是拉到 VDD,再在代码里填对应地址。我第一次调试时没确认这脚电平,扫描总线一直超时,折腾了半天才发现地址引脚悬空导致读到的地址不是手册默认值。
初始化代码大致是这样的:
void pmic_i2c_init(void) { I2C_InitTypeDef i2c; GPIO_InitTypeDef gpio; RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_GPIOB, ENABLE); GPIO_PinAFConfig(GPIOB, GPIO_PinSource6, GPIO_AF_1); GPIO_PinAFConfig(GPIOB, GPIO_PinSource7, GPIO_AF_1); gpio.GPIO_Mode = GPIO_Mode_AF; gpio.GPIO_OType = GPIO_OType_OD; gpio.GPIO_PuPd = GPIO_PuPd_UP; gpio.GPIO_Speed = GPIO_Speed_2MHz; gpio.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; GPIO_Init(GPIOB, &gpio); I2C_DeInit(I2C1); i2c.I2C_Mode = I2C_Mode_I2C; i2c.I2C_ClockSpeed = 400000; i2c.I2C_DutyCycle = I2C_DutyCycle_2; i2c.I2C_Ack = I2C_Ack_Enable; I2C_Init(I2C1, &i2c); I2C_Cmd(I2C1, ENABLE); }I2C 引脚一定要配置为开漏并加上拉,这个很多人容易写成推挽输出,结果通信时两个设备互相推拉电平,产生非常奇怪的总线毛刺。
3.2 寄存器读改写封装
PMIC 的寄存器通常是 8 位宽度,很多位域是“读-改-写”的操作模式:你想把某个输出电压档位改掉,但同一寄存器里还有别的使能位,直接整字节写会把其他配置冲掉。为了避免这种问题,我封装了一套统一接口。
static int pmic_read_reg(uint8_t reg, uint8_t *val) { /* 省略具体 I2C 时序,发送寄存器地址后读取一个字节 */ return i2c_read(PMIC_I2C_ADDR, reg, val, 1); } static int pmic_write_reg(uint8_t reg, uint8_t val) { /* 省略具体 I2C 时序,先发送寄存器地址再写入一个字节 */ return i2c_write(PMIC_I2C_ADDR, reg, &val, 1); } static int pmic_update_bits(uint8_t reg, uint8_t mask, uint8_t val) { uint8_t tmp; if (pmic_read_reg(reg, &tmp) != 0) { return -1; } tmp = (tmp & ~mask) | (val & mask); return pmic_write_reg(reg, tmp); }所有对 PMIC 寄存器的修改都通过pmic_update_bits完成,写一个位域时只影响当前位,不会动到同寄存器的其他配置。项目里后面加电池状态上报功能时,我就直接复用这套接口,几乎没有出过因为整字节写导致的配置丢失问题。
3.3 I2C 异常恢复与降级策略
I2C 通信最怕两件事:总线卡死在低电平、设备无响应。前者通常是因为从设备异常拉低 SDA,常见原因包括 PMIC 处于复位状态、电源输出还没建立完毕;后者则是地址配错或 PMIC 没上电。
我的处理策略是分级恢复:
- 每次 I2C 操作加超时,超时时间设 50ms;
- 连续失败 3 次后,尝试对 PMIC 发软复位命令,如果 PMIC 不支持软复位,就拉一下外部复位 GPIO;
- 复位后重新读取关键寄存器,校验恢复后的配置;
- 如果仍然失败,把电源状态机切到 PWR_OFF,系统进入安全关机,避免在异常电压下继续运行造成不可逆损坏。
这里有个很大的坑:低功耗唤醒后立刻读写 PMIC。MCU 从 Stop 模式恢复运行的速度比 PMIC 内部状态恢复快,尤其 PMIC 刚从睡眠模式唤醒时,内部 LDO 还没完全建立,此时发 I2C 很容易收不到 ACK。我在唤醒序列里加的延时是 5ms 到 10ms,实测非常管用。很多“偶发唤醒死机”的 bug 其实就是这一两毫秒的时序差导致的。
4. 核心场景落地:动态电压、低功耗与电池管理
4.1 动态电压调节:让 PMIC 跟着负载走
动态电压调节在这套系统里是最有实用价值的功能之一。环境监测设备平时处于低负载状态,如果一直用高电压供 CPU 和传感器,白白浪费热量和电能。我的做法是:
- 高负载任务触发时,把 CPU 主频提上去之前,先通过 I2C 把 PCA9422 的输出电压抬到目标值;
- 等 PMIC 输出稳定后,再切换 CPU 时钟到高频;
- 任务跑完,先降 CPU 频率,再降 PMIC 输出电压。
顺序不能反。如果先升频再升压,CPU 在高频下的功耗瞬间增加,而此时电压还没到位,轻则触发欠压复位,重则引起逻辑混乱。反过来降频时,先降频再降压,保证 CPU 每个频率档位都在足够电压之上。
static void set_core_voltage(uint8_t voltage_idx) { pmic_update_bits(PMIC_REG_VSEL, PMIC_MASK_VOUT_SEL, voltage_idx << PMIC_SHIFT_VOUT_SEL); /* 等待 PMIC 输出电压稳定 */ pmic_wait_power_good(); }具体每一档对应多少毫伏,一定以数据手册里的电压表为准,不要凭感觉填索引。我犯过一次把索引填高、导致输出超过器件绝对最大额定值的错误,还好发现及时,否则整套传感器阵列都可能报废。
4.2 低功耗切换:进得去还要出得来
低功耗模式切换是验证“完整电源管理”的重要环节。进入睡眠的代码并不难,难的是唤醒后的恢复路径。我的恢复顺序是这样的:
- 读 PMIC 中断状态寄存器,确认这次唤醒原因,是 RTC、按键还是充电插入;
- 如果是充电插入,把电源状态机切到运行态,启动充电流程;
- 恢复各路输出电压到正常档位;
- 等待电压稳定,再初始化外设;
- 开启中断,继续执行主循环。
进入睡眠的代码类似这样:
static void pwr_enter_sleep_sequence(void) { /* 关闭外设 */ adc_disable(); uart_disable(); timer_disable(); /* 调低外设电源电压 */ pmic_update_bits(PMIC_REG_VSEL_IO, PMIC_MASK_IO_VOUT, IO_SLEEP_VOLTAGE); /* 配置唤醒源 */ pmic_irq_enable(PMIC_IRQ_MASK_CHARGE_IN); exti_config(GPIO_PIN_BUTTON, ENABLE); rtc_alarm_enable(); /* 进入 Stop 模式,等待唤醒 */ PWR_EnterSTOPMode(PWR_Regulator_LowPower, PWR_STOPEntry_WFI); SystemClock_Config(); /* 唤醒后恢复 */ pwr_wakeup_sequence(); }我特别想强调SystemClock_Config()这一步。STM32 从 Stop 模式唤醒后,时钟源还停留在低功耗配置状态,必须先恢复系统时钟再操作外设,否则 I2C 时序会偏得离谱,PMIC 寄存器读写表现出全是乱码的现象。
4.3 电池充电与状态上报
带电池的产品,充电管理是躲不开的部分。PCA9422 这类带充电前端的 PMIC,在充电状态、充电电流和电池电压上会有对应的状态寄存器。MCU 要做的事情是周期性读取,并把状态映射到业务逻辑上。
| 电池状态 | 寄存器标志 | 系统动作 |
|---|---|---|
| 未接充电器 | 无充电标志 | 维持电池供电,定期上报电量 |
| 充电中 | 充电中标志 | 通知业务层显示充电中,限制大负载任务 |
| 充满 | 充满标志 | 停止充电流程,进入涓流或完全关闭充电路径 |
| 充电异常 | 过压/过流/温度标志 | 关闭充电,记录故障日志 |
这里很容易踩的坑是“充满”之后不关断充电输入。很多 MCU 只判断“充满”标志就继续允许充电路径供电,结果电池长期处于高浮充状态,发热明显并加速老化。我现在的逻辑是:读到充满标志后,除了保留充电检测中断,直接把 PMIC 的充电使能位清掉,直到下一次充电器插拔事件再重新使能。
如果 PMIC 不带充电前端,这套状态机同样适用,只需要把充电状态列表替换成“外部电源接入/未接入”两个状态即可。我在另一个纯外接电源的项目里就是这么改的,完全复用同一套电源状态机框架。
5. 调试与验证:把看不见的电源行为抓出来
5.1 GPIO 翻转标记时序关键点
电源管理调试和普通软件调试不一样,很多问题是时序相关或者瞬态相关的。我的习惯是从一开始就在代码里埋 GPIO 翻转点,并且把关键事件打上标记:
GPIO_TOGGLE_BOOT_START:开始执行上电时序;GPIO_TOGGLE_VOUT_CHANGED:PMIC 输出设定被修改;GPIO_TOGGLE_WAKEUP:唤醒中断触发;GPIO_TOGGLE_SLEEP_ENTER:进入 Stop 前的最后一步。
然后用逻辑分析仪同时抓这几个 GPIO 和 I2C 信号。比如检查上电时序是否符合要求:先测BOOT_START到第一路输出使能之间的时间差,再测各路输出之间的间隔。这些时间参数直接在数据手册上一般都有推荐值,对照着分析比靠眼睛盯示波器快得多。
实测中我最常用的是低功耗唤醒抓取:把WAKEUP翻转点放在唤醒中断入口,把SLEEP_ENTER放在 Stop 模式之前,然后看两个点之间的 I2C 波形,确认唤醒后是否发生了预期中的 PMIC 配置恢复操作。如果发现唤醒点之后 I2C 没有立刻动作,大概率是前面提到的 PMIC 输出稳定时间没留够。
5.2 电流测量与功耗拆解
整机电流测量要用专门的功耗分析仪,如果没有,可以用精密电阻加示波器的方式做粗略测量:在电池正极串一个 10mΩ 的采样电阻,示波器用差分探头测电阻两端电压,再换算成电流。对于低功耗模式,电流会从运行态的十几毫安掉到几十微安,示波器电流探头不太好抓,这时候需要用支持长时间记录的功耗分析仪来拉完整曲线。
拿到整机电流曲线后,我一般按下面几步拆功耗:
- 先看睡眠段电流是否稳定,如果出现周期性的尖峰,说明有外设没被关干净;
- 对比关掉某路 PMIC 输出前后的电流差,定位哪个外设是耗电大头;
- 用代码逐项注释法,把系统工程里各个模块依次停掉,电流变化最明显的模块就是优化重点。
我在这块板子上做功耗优化时,发现待机电流一直比预期高 0.4mA,怎么都降不下去。最后逐个关 PMIC 输出测试,发现是一颗传感器电源没有跟着进入睡眠档,还在满电压运行。改了一行状态机配置后,待机电流立刻降到了预期水平。
5.3 实测中踩过的典型坑
最后整理一下这套方案里遇到的几个典型问题,都是真实调过的,写在这里供大家参考:
I2C 地址引脚悬空。这个问题最容易出现在样机阶段,PMIC 地址引脚没有明确上下拉,导致默认地址和手册不一致。建议原理图阶段就把地址引脚用电阻固定,不要给软件留“猜地址”的空间。
PMIC 输出变化太快导致浪涌。调低输出电压档位时,如果负载端有大容量电容,PMIC 内部调整速率过快会瞬间产生冲击电流,严重时引起输入电压跌落。解决方法是分两次降档,或者开启 PMIC 的软启动功能,具体看手册支持。
上电先高压后主控。我在早期版本里把外设电源先于 MCU 供电,结果外设通过 IO 保护二极管往 MCU 的 VDD 灌电流,MCU 没上电却被“反向供电”,导致异常启动。后来严格遵循 PCA9422 的时序配置,先给 MCU 供电,等 MCU 初始化完成后再打开外设输出电压,问题就消失了。
看门狗在低功耗模式下的处理。运行态开启的 IWDG 如果不在进入 Stop 前正确刷新,唤醒后很容易立刻复位。我现在的做法是在进入睡眠前临时关闭看门狗,唤醒后重新初始化并喂狗,确保电源管理序列不会被打断。
唤醒后首次 I2C 通信失败。这个其实在第 3.3 节说过,本质是 PMIC 状态恢复比 MCU 慢。加固定延时只治标,更稳妥的做法是读取 PMIC 的电源良好状态位,确认输出真正稳定后再继续操作。
这套“PMIC + MCU”的电源管理方案调通之后,后续项目再换不同容量电池、不同功耗等级的传感器,我基本只需要改状态机里几个电压档位和使能控制,不需要动硬件布局。说实话,从分离电源器件转到 I2C 可编程 PMIC 会有一次学习成本,但一旦把状态机和通信层沉淀成基础组件,后面新项目的电源部分开发时间能缩短 60% 以上。如果让我再选一次,我仍然会优先考虑 PMCIC 和 MCU 组合的方案,只不过这次一定会更早地确认 PMIC 地址引脚的电平。