一、加了低功耗之后才出现的问题
原来的固件是“一直在跑”的:主循环转得飞快,看门狗随便找个地方喂一下就行; 所有时长统计都靠系统滴答,读一次当前计数减一下基准就有了; 串口中断随时能进来,来了就收。加了休眠之后,这四件事同时出问题:
- 主循环停了,没人喂狗。看门狗的计数不会因为 CPU 睡了就停下, 它照样数到超时,然后复位。现象是“设备每隔一段时间重启一次”,而且周期很稳定—— 稳定到容易被误判成“电源有问题”。
- 系统滴答不再可靠。用滴答做的时长统计在休眠期间可能停走, 也可能在唤醒后被重新初始化。所有“过了多久”“空闲多久了就做某件事”的判断全部失真。
- 串口不再“随时能进来”。它必须先能把 CPU 唤醒,唤醒之后还要把串口与外设 恢复到能收的状态,才谈得上收这一帧。
- 输出引脚的电平在休眠期间不确定。这取决于引脚在低功耗模式下的保持配置。 对外的执行机构可能因为引脚状态变化被带着动一下,这种问题在现场非常难查。
这四条里,前两条是“加了低功耗才出现的”,后两条是“本来就有、只是被休眠暴露出来”。 我一开始以为低功耗是“在空闲分支里加一行进休眠”,后来才明白真正的工作量在于 把“一直在跑”这个假设从代码里一条一条找出来。所以我的建议是: 动低功耗之前,先搜一遍代码里所有读系统滴答的地方、所有依赖中断随时可进的地方、 所有直接写输出引脚的地方,把它们列成一张清单。
其中最根本的矛盾是看门狗与休眠的冲突。看门狗的本意是“你必须周期性地证明自己还活着”, 低功耗的本意是“我尽量什么都不做”。一个要求周期动作,一个要求尽量不动,这两个诉求方向是相反的, 所以必须在设计期就想清楚“休眠期间谁负责让系统醒过来”。如果想不清楚,我宁可明确决定不开看门狗, 也不要在休眠前假装喂一次——把“这个版本不开看门狗,原因是休眠期间没有可靠的喂狗时机”写进设计文档, 比在代码里留一个看起来正确、实际没有任何保护作用的动作要诚实得多。
二、进入休眠前要确认什么
休眠不是一个普通的函数调用,而是一次交接:睡下去之后 CPU 不再执行任何指令, 任何没处理完的事情都不会自己完成。所以我后来把它当成一次“关机前的检查单”来写, 逐项确认之后再进低功耗。检查项和“不检查会怎样”大致是这样:
| 检查项 | 不检查会怎样 | 怎么确认 |
|---|---|---|
| 接收缓冲里没有半帧 | 睡下去把这半帧丢了,对端一直等到超时 | 判帧计时器已空闲,缓冲长度与上次消费时一致 |
| 没有正在等待应答的请求 | 等于主动放弃这次请求,而对端还在等这条应答 | 通信状态机处于空闲态,发送队列为空 |
| 需要落盘的参数与统计已写回 | 掉电或复位后数据丢失,且无法判断丢在哪一步 | 落盘标志已清零,写回操作的完成标志已置位 |
| 唤醒源已配置并使能,旧的挂起标志已清除 | 一个历史事件立刻把你唤醒,现场看起来像“睡了等于没睡” | 先清挂起标志,再使能唤醒源,顺序不能反 |
| 输出引脚进入确定电平 | 休眠期间外部执行机构被误动,或者输入引脚悬空导致误唤醒 | 确认引脚的保持配置或外部上下拉,逐个过一遍输出 |
| 下一次喂狗一定早于看门狗超时 | 睡过头被复位,而且复位原因看起来像“电源不稳” | 唤醒定时周期小于看门狗超时,并把唤醒与恢复的耗时算进去留余量 |
| 调试口或状态寄存器留下“要睡了”的痕迹 | 现场无法区分“在休眠”和“已经死机” | 进休眠前置一个状态位,唤醒后清掉并计数 |
这张表里最容易被忽略、后果又最隐蔽的是顺序那一条:先清挂起标志、再使能唤醒源、 最后才执行进低功耗的指令。如果反过来,在“使能唤醒源”到“真正进低功耗”之间发生的那个事件会丢掉; 而如果挂起标志没清干净,进低功耗之后会被历史事件立刻拽出来。后者的现象很有欺骗性—— 看起来“休眠没生效”,实际是生效了又被立刻唤醒,反复进出低功耗模式反而比不睡更费电。
三、唤醒源怎么选
唤醒源的选择标准只有一句话:空闲的时候,到底有没有事情必须发生? 如果没有,就不要为了“以防万一”加一个周期唤醒——每多一个必须醒来的理由, 就多一份固定功耗,也少了一段真正的空闲。常见四类唤醒源的取舍我整理成了下面这张表:
| 唤醒源 | 适合什么场景 | 代价与注意点 |
|---|---|---|
| 串口接收(总线活动唤醒) | 设备的工作完全由外部请求驱动,总线空闲时确实无事可做 | 收发器与接收引脚在低功耗下必须保持可唤醒能力;唤醒后往往要重新建立串口状态才能收下一帧 |
| 周期定时器 | 有“必须周期发生”的事:喂狗、心跳、定时采集、超时统计 | 定时器与其时钟源必须常开,这本身就是底电流的一部分;周期越短越费电,省电的上限由它决定 |
| 实时时钟闹钟 | 分钟级以上的长周期任务,例如对时、日报、长间隔自检 | 精度与功耗之间要折中;唤醒后仍然要恢复主时钟,不能指望它直接干活 |
| 外部中断(按键、告警输入) | 人机交互,或者需要立刻响应的外部事件 | 引脚在休眠中不能悬空,否则会被噪声误唤醒;机械触点还要消抖,否则一次动作唤醒好几次 |
我那台采集设备最后用的是“串口活动 + 周期定时器”两个源: 串口负责干活,定时器负责喂狗和超时统计。这两个都是必需的,第三个(外部中断)在这个设备上 没有需求,我就没有加。后来现场有人问“能不能加个按键唤醒”,我的回答是:可以加, 但要先接受两件事——休眠期间按键引脚不能悬空,以及按键抖动会带来多次唤醒, 这两件事都要在硬件和软件上一起处理,不是加一行中断配置就完事。
还有一点必须提前算清楚:唤醒不是免费的。唤醒之后要恢复时钟, 有时还要重新初始化外设,这段时间 CPU 是“醒着但不能干活”的。如果空闲阈值定得太短, 设备会在“睡 → 醒 → 还没干完 → 又睡”之间循环,功耗可能比不睡还高, 而且通信会变得不稳定(该应答的时候正在恢复时钟)。这也是我后来把“空闲多久才睡” 当成一个要认真定的参数、而不是随便给个数的原因。
四、休眠期间谁喂狗
这是这一篇的核心问题。看门狗只能在“有代码在跑”的时候被喂,所以问题等价于: 休眠期间靠什么制造出“有代码在跑”的时刻? 我用过也见过两种解法,它们的适用场景和代价完全不同,这里都写清楚。
解法一:用定时器周期性把系统唤醒喂狗,并把原来的计时功能迁到另一个定时器
做法是:一个定时器专门负责“叫醒”,它的周期必须小于看门狗超时并留出余量; 醒来之后除了喂狗,顺便把原来挂在系统滴答上的计时工作(判帧超时、通信超时、空闲计时) 迁到另一个定时器上,或者干脆把这些计时改成“按唤醒次数累加”。
- 适用:系统本来就需要周期做事(采集、心跳、喂狗), 唤醒周期能和业务周期合成一个。这种情况下定时器唤醒不是额外开销,而是本来就要做的事。
- 代价:一旦周期定了,底电流就定了,想再省必须改周期, 而周期往下压又受“最坏唤醒延迟能不能接受”的限制。 另外多了一个必须常开的定时器与时钟源;如果它因为配置错误没跑起来, 喂狗会静默地停掉,直到看门狗复位才暴露——所以这个定时器的工作状态本身应该被计数和上报。
解法二:在休眠等待循环里按定时器置的标志位喂狗
做法是:休眠不是“一条指令睡到底”,而是一个循环——进低功耗等一个唤醒事件, 醒来处理一点事,再决定要不要继续睡。让一个仍然在跑的定时器中断只做一件事: 置一个标志位。休眠循环每次醒来检查这个标志,看到就喂狗并清标志。
/* 休眠等待循环里的喂狗骨架:定时器只置标志,循环负责消费 */
for (;;) {
进入低功耗等待唤醒源();
恢复时基(); /* 唤醒后时钟与滴答可能要重建 */
处理本次唤醒该做的事(); /* 判帧、更新状态、必要时重初始化外设 */
if (喂狗标志 == 1) { /* 由周期定时器中断置位 */
刷新看门狗();
喂狗标志 = 0; /* 必须消费掉,否则会重复刷 */
}
if (空闲时长 已超过 阈值) {
重新配置唤醒定时器(); /* 周期与阈值都来自参数,不写死 */
} else {
退出休眠循环,回到正常运行(); /* 有活干,先干活 */
}
}- 适用:低功耗模式本身允许“被任意中断唤醒后继续执行”, 而且业务确实是事件驱动的。这时不需要额外的唤醒周期——定时器本来就在跑, 它不再负责唤醒,只负责置标志。
- 代价:喂狗的节奏取决于“循环转一圈”的时间,而不是定时器周期。 如果循环里有耗时的处理(一次采集、一次大块擦写),喂狗会被推迟, 标志可能被连续置两次而只被消费一次。所以标志必须是“置位—消费—清零”的语义, 并且循环里不能有长阻塞。另外,如果选的低功耗模式连这个定时器中断都进不来 (有些模式只有特定外设能唤醒),这个解法直接不成立——这是选型时第一个要确认的事。
两种解法我都用过:在“本来就周期做事”的设备上用解法一,在没有周期业务、 完全由总线请求驱动的设备上用解法二。它们共同的底线是同一句话: “睡之前喂一次然后睡很久”不是喂狗,是把看门狗当摆设。 要么保证唤醒间隔小于看门狗超时,要么明确决定不开看门狗,并把决定和理由写下来。
五、计时器在休眠前后的坑:溢出与基准重置
用系统滴答做时长统计,常规写法有两种。一种是直接做无符号减法:
elapsed = (uint32_t)(now - base);。因为在无符号运算下,
即使 now 已经回绕过一次,减法依然能得到两次采样之间的真实间隔
(前提是这个间隔小于计数范围的一半)。这是教科书上的写法,也是我一直推荐的首选。
另一种是显式写“正向 + 溢出”双分支——因为很多人(包括当时的我)觉得“两个分支看得更清楚”。 下面这段就是错误示范:
/* 错误示范:把跨休眠的时长当成“时钟回绕”,并且把异常分支当成“没超时” */
delta = (tick_now >= tick_base) ? (tick_now - tick_base) : 0;
if (delta >= 空闲阈值) {
进入休眠(); /* 阈值来自参数,此处不写具体值 */
}我在这上面踩的坑不是“减法写错了”,而是基准本身在休眠前后不属于同一个时基。 当时的流程是:进低功耗 → 唤醒后恢复系统时钟(滴答从恢复点继续走,或者干脆被重新初始化) → 记录一个新的计数作为基准。于是“休眠之前记下的基准”在唤醒之后变成了一个比当前计数更大的值, 正向分支不成立,程序走进溢出分支;而在我的实现里,溢出分支被当成“还没超时”处理。结果是两种相反的现象:
| 现场现象 | 直接原因 | 怎么处理 |
|---|---|---|
| 刚醒又睡、应答很慢甚至丢帧 | 跨休眠的时长被算成一个巨大的值,“空闲超过阈值”立刻成立 | 唤醒后重新建立计时基准,并把“跨休眠的时长”与“运行期内的时长”分成两个变量统计 |
| 再也不睡、电流下不来 | 跨休眠的时长被算成零,空闲判定永远不成立 | 同上;另外要确认这个计数在休眠期间到底走不走、由哪个时钟源驱动 |
| 时长忽大忽小、规律找不到 | 两个来源的时间被混在同一个变量里累加 | 只用一个时基做判断,另一个时基只用于显示或统计,绝不参与逻辑判断 |
结论是:跨休眠的计时不是一个算术问题,而是一个“时基归属”问题。 写代码之前先把三个问题答清楚:休眠期间这个计数器走不走?唤醒之后它从哪个值继续? 我记录基准的那一刻,用的是哪个时基?这三个问题答不上来,双分支写得再工整也没用。 顺便说一句,如果确实需要“正向 + 溢出”两个分支,那么溢出分支也不能简单地当成“没超时”, 它必须做的是“重置基准并保持累加语义”,否则一次回绕就会永久破坏计时。
六、唤醒后的“提示”设计
低功耗设备最难的不是省电,是证明它在省电。现场没有电流表的时候, 怎么判断这台设备是在休眠,还是已经死机了,或者压根没进休眠?
我的做法是给“唤醒”一个专门的短提示:只要是从休眠中被唤醒的,就用一段与正常运行指示 明显不同的短闪烁(不同的灯,或者明显不同的节奏)。这样维护人员在总线空闲的时候 看一眼指示灯,就能判断这台设备是不是真的在睡——因为如果它“一直在跑”,那个短提示根本不会出现。 这个设计成本极低,但省下来的排查时间很多。
具体实现时有几个要点:
- 提示必须短。它本身就是“醒着”的时间,长了就变成耗电和响应延迟的来源。
- 和正常运行指示必须明显可区分。同一个灯用同样的节奏闪,等于没做这个设计。
- 只在唤醒时提示,进入休眠不提示。否则总线空闲时灯会一直闪, 反而看不出“它在睡”这件事。
- 提示不能阻塞后续处理。先处理通信与喂狗,再闪灯;顺序反了, 会在最需要响应的时候引入延迟。
- 把闪烁频率当成诊断信息。如果现场看到短提示几乎连成一片, 说明唤醒太频繁——这是“空闲阈值定得太短”的一个现场症状,比拿电流表测还直观。
第 5 条是我事后才想明白的。当时我一直在纠结“这个阈值到底给多少”, 后来发现指示灯已经告诉我答案了:短提示连成一片的时候,设备其实一直在进出休眠, 那段时间的功耗比干脆不睡还差。所以这个提示不只是给现场看的,也是给我自己调参用的。
七、几条我反复用到的经验
- 低功耗的工作量不在“进休眠”,在“把一直在跑的假设找出来”。 先列一遍所有依赖系统滴答、依赖中断随时可进、依赖引脚电平的代码。
- 进休眠前逐项确认:没有半帧、没有在等应答、参数已写回、 唤醒源已清挂起标志并使能、输出电平确定、下一次喂狗早于看门狗超时。
- 顺序不能反:先清挂起标志,再使能唤醒源,最后才进低功耗。 反了就会“睡了等于没睡”,而且更费电。
- 看门狗与休眠必须一起设计。两种喂狗解法选一个并写清代价; 做不到“唤醒间隔小于看门狗超时”,就明确决定不开看门狗,别在休眠前假装喂一次。
- 跨休眠计时先问时基归属,再谈正负与溢出。 把“跨休眠的时长”和“运行期内的时长”分成两个变量,永远不要混在一个里累加。
- “空闲多久才睡”不是越小越省电。它至少不应小于一次完整事务的间隔 (否则每次轮询之间都要进出一次休眠),同时要保证“最长唤醒延迟 + 本次处理时间” 仍然落在对端的超时窗口之内。这两个约束一夹,可选范围其实很窄。
- 给唤醒一个看得见的短提示。现场能一眼分清“在睡”和“死了”的设备,才是好维护的设备。
- 把看门狗和低功耗提前做。它们在项目末期最容易被砍掉, 而“卡死”和“重启”这两类问题恰恰最需要它们来定位。
这套东西和主机侧的轮询调度是同一类问题的两面:一边是“怎么让总线上的设备都能被及时问到”, 另一边是“怎么让设备在没人问的时候彻底安静下来,同时还能证明自己活着”。 主机侧的做法我在 《Modbus 主机的轮询调度与离线降级》 里写过;如果设备还要上云、还要和别的板卡通信,那么“哪一层用什么协议”会直接影响 休眠策略能不能成立,这部分我在 《多板卡系统的通信分层》 里整理过。
参考资料与说明
- 芯片厂商公开的低功耗模式应用笔记,内容涵盖停止 / 待机模式的进入与退出、 各模式下仍可工作的外设与唤醒源、以及唤醒时间与时钟恢复的说明,属于公开技术文档。
- 芯片参考手册中关于独立看门狗(IWDG)的独立时钟源、超时计算与刷新窗口的章节,属于公开技术文档。
- ARM 公开文档中关于 SysTick 与 Cortex-M 低功耗模式、异常唤醒行为的说明;ARM 与 Cortex 是 ARM Limited 的商标,此处仅作指示性说明。
- FreeRTOS 官方文档中关于 tickless 低功耗模式与系统节拍抑制的说明 (FreeRTOS 为第三方开源项目,本文只引用其公开文档中的概念,不包含其源码)。
- 文中涉及的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系; 文中的休眠与超时参数一律以“阈值、周期”等符号表述,不含任何产品的实际数值。
- 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码; 第三方组件的名称与许可归其各自所有者。
- 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。
相关阅读:Modbus 主机的轮询调度与离线降级 · 多板卡系统的通信分层 · RS485 帧同步与超时判帧 · 定时器单位的那些坑 · 状态机与错误优先级 · 电池管理单元实验记录 · 多板卡以太网控制实验 · LwIP 上的 UDP 通信 · 返回学习笔记列表