FreeRTOS 低功耗 Tickless 实战:标称”十年电池”的表计样机,实测待机 3mA,我把 Tickless 从原理到落地啃了一遍
上个月接了个电池供电的抄表终端,客户在需求书上写着”CR123A 锂电池,目标续航 8 年”。我拿万用表一挂:待机 3.2mA。粗算一下,2400mAh 的电池,撑三个月就交代了——跟”十年电池”差着四十倍。最后定位下来,问题不在硬件漏电,而在软件架构:FreeRTOS 一直跑着 1ms 的 SysTick,CPU 空闲时也在 Sleep 里被每毫秒叫醒一次,每次唤醒、进出中断的功耗把平均值顶了上去。这期把 Tickless 模式从原理到落地踩的坑完整记一遍。
一句话结论:想让空闲电流从毫安级掉到微安级,光会 WFI 不够,必须让 SysTick “停摆”,并且有人替它把时间账算回来——这就是 Tickless。
先算账:1ms tick 到底吃掉多少电
先看普通模式下的功耗结构。Cortex-M 在执行 WFI 进 Sleep 时内核时钟停了,但 SysTick 还在跑,每 1ms 产生一次中断:
// 典型的空闲任务:看起来在省电,实际每毫秒醒一次
void vApplicationIdleHook(void)
{
__WFI(); /* Sleep 模式,SysTick 每 1ms 唤醒一次 */
}
每次唤醒的代价:中断响应 + 上下文保存恢复 + 调度判断,在 72MHz 的 M3 上大约 2~5μs 的活跃时间,加上唤醒本身从 Sleep 回 Run 的启动功耗。我用功耗分析仪实测过一块 STM32L151 的板子:
| 状态 | 平均电流 | 说明 |
|---|---|---|
| Run @ 72MHz | 8.6 mA | 主循环全速跑 |
| Sleep + 1ms tick(WFI) | 3.2 mA | 看似睡眠,实际被 tick 打成筛子 |
| Stop + Tickless | 9 μA | 真正的深睡,本期目标 |
从 3.2mA 到 9μA,就是”电池三个月”和”电池十年”的差距。功耗大头不是 CPU 跑代码,而是被高频唤醒这个动作本身。
Tickless 的核心问题:SysTick 在深睡里是死的
浅睡(Sleep)模式下 SysTick 继续计数,tick 不会丢。但要做到 9μA,必须进 Stop 模式——而 Stop 模式下 SysTick 的时钟源(内核时钟)直接停了,唤醒后 SysTick 不会补上睡过去的那段时间。
这就是 Tickless 要解决的根本矛盾:睡眠时间无法用 tick 计量。FreeRTOS 的解法是三个机制拼图:
- 空闲时间预测:Idle 任务发现所有任务都阻塞,计算”下一个最近到期任务还有多久醒”,得出
xExpectedIdleTime; - 低功耗睡眠:把 SysTick 关掉,用另一个能在深睡下工作的定时器(RTC 闹钟 / LPTIM)定一个等长的闹钟,然后进 Stop;
- 时间补偿:醒来后把”实际睡了多少个 tick”一次性补回到调度器的时基里。
FreeRTOS 把这三步抽象成一个可移植接口,portable 目录下各厂商的 port 已经给了参考实现:
/* FreeRTOSConfig.h 中打开 Tickless */
#define configUSE_TICKLESS_IDLE 1
/* 空闲不足这个 tick 数就不值得睡(进出深睡的开销账) */
#define configEXPECTED_IDLE_TIME_BEFORE_SLEEP 2
/* 可选:完全自己接管睡眠流程 */
#define configUSE_TICKLESS_IDLE 2 /* 自定义 vPortSuppressTicksAndSleep */
configUSE_TICKLESS_IDLE = 1 用芯片厂商 port 自带的实现(STM32 系列一般配 LPTIM);= 2 时 FreeRTOS 会调用你自己的 vPortSuppressTicksAndSleep()——项目里时钟树复杂、唤醒源多的时候,我建议直接上 2,自己掌控细节。
第一坑:Stop 模式下唤醒源根本不是 SysTick
这是我踩的第一个坑,也最傻:照着参考例程配好 configUSE_TICKLESS_IDLE,一进 Stop 设备就”死”了—— RTC 闹钟没配,SysTick 又叫不醒它,只能复位。
原因很直白:Stop 模式下能唤醒内核的只有 EXTI / RTC / LPTIM / IWDG 这类仍供电的外设。SysTick 挂在内核时钟上,内核时钟停了它就是块死表。所以睡眠前必须把”下一个任务到期时间”翻译成 RTC 闹钟:
/* 自定义睡眠流程的核心骨架(STM32L4 为例) */
void vPortSuppressTicksAndSleep(TickType_t xExpectedIdleTime)
{
/* 1. 把"还要睡多少 tick"换算成 RTC 闹钟(假设 1 tick = 1ms) */
uint32_t alarm_sec = xExpectedIdleTime / 1000;
uint32_t alarm_sub = (xExpectedIdleTime % 1000) * 32768 / 1000;
RTC_SetAlarm(alarm_sec, alarm_sub);
/* 2. 停 SysTick,进 Stop 模式 */
SysTick->CTRL &= ~SysTick_CTRL_ENABLE_Msk;
HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI);
/* 3. 醒来第一件事:重配系统时钟(Stop 退出后跑在 MSI/HSI 上) */
SystemClock_RestoreFromStop();
/* 4. 计算实际睡了多少 tick,补回时基 */
uint32_t slept_ticks = RTC_ElapsedTicksSinceSleep();
vTaskStepTick(slept_ticks);
/* 5. 重启 SysTick */
SysTick->CTRL |= SysTick_CTRL_ENABLE_Msk;
}
第 3 步是新手最容易漏的:Stop 退出后 STM32 跑在内部低速时钟上,不在你配置的 PLL 频率。忘了重配时钟,醒来后的所有 HAL_Delay、波特率、SPI 速度全是错的,症状是”低功耗模式下醒来后通信全乱”,极具迷惑性。
第二坑:唤醒延迟吃掉时间预算,tick 账算不平
第二个坑隐蔽得多。我做完第一版后用 GPIO 翻转 + 示波器打标,发现调度器时间漂移越攒越多,一天下来慢了 400ms。
拆开看,一次”睡 N 个 tick”的实际时间线是:
- 进入开销:算账、配 RTC、关外设时钟,约 20~50μs;
- 唤醒延迟:Stop 唤醒 + 时钟切换 + PLL 重锁,STM32L4 上典型 5~10μs,带 PLL 重锁的老型号可到几十μs;
- 补偿精度:RTC 用 32.768kHz 晶振,分辨率约 30.5μs,闹钟设定存在向下取整误差。
三段全往”多睡”方向偏,每次睡 100ms 偏 50μs,千次累积就是 50ms。FreeRTOS 的 vTaskStepTick() 只补偿你告诉它的数,不会自己纠偏。我的修法是把账算细:
/* 补偿时不要拿"预期值"糊弄,用 RTC 真实差值,并扣除已知的固定开销 */
uint32_t before = RTC_GetWakeupCounterBase(); /* 睡前基准 */
/* ... 进 Stop、醒来 ... */
uint32_t after = RTC_GetWakeupCounterBase();
/* 实际睡眠时间换算成 tick(含子秒),先减去唤醒固定延迟 */
uint32_t elapsed_us = RTC_ElapsedUs(before, after) - WAKEUP_OVERHEAD_US;
uint32_t slept_ticks = elapsed_us / portTICK_PERIOD_MS;
vTaskStepTick(slept_ticks);
另外两个精度要点:RTC 子秒寄存器要在睡前和醒后各读一次做差,不要相信闹钟设定值;xExpectedIdleTime 本身要用”最近到期任务的时间”减去”当前已经流逝的半个 tick”,FreeRTOS port 里这个细节都处理了,自己写时容易丢。
第三坑:eTaskConfirmSleepModeStatus——有些时刻根本不能睡
Tickless 的默认假设是”所有任务都阻塞了就能睡”。但真实设备里有例外:比如我们这台表计,正在接收红外抄表脉冲(靠 10μs 级的定时器采样),这时候睡进 Stop,外设时钟一停,脉冲直接丢。
FreeRTOS 留了确认钩子,睡眠前逐项询问”现在真的能睡吗”:
/* FreeRTOSConfig.h */
#define configUSE_TICKLESS_IDLE 2
/* portSUPPRESS_TICKS_AND_SLEEP 前会调用,返回 eTaskConfirmSleepTimeStatus 表示"别睡" */
eSleepModeStatus eTaskConfirmSleepModeStatus(void)
{
if (ir_sampling_active) { /* 红外采样窗口内禁止深睡 */
return eAbortSleep;
}
if (uart_dma_in_progress) { /* DMA 没搬完也别睡 */
return eAbortSleep;
}
return eStandardSleep;
}
注意返回 eAbortSleep 时调度器这个 tick 周期不会再尝试睡眠,所以保护条件要精确到”真正互斥的窗口”,不能图省事写个大概。我们最初把”串口打开”当成禁止条件,结果串口常开导致 Tickless 全程失效,电流纹丝不动——检查保护条件时一定要顺便确认条件没有把睡眠全挡掉。
外设账本:Stop 模式下谁活着、谁断片
进 Stop 前,除了时钟还要盘点外设状态,这张表是我们项目实测的(STM32L431,供参考,不同系列差异不小):
| 外设 | Stop 模式下 | 处理方式 |
|---|---|---|
| GPIO 输出电平 | 保持 | 睡前把不用的引脚配成模拟输入防漏电 |
| RTC / LPTIM | 正常运行 | 唤醒源 + 时基补偿 |
| UART / SPI | 停止 | 唤醒后需重初始化(部分系列寄存器保持但时钟需重配) |
| ADC | 停止 | 唤醒后重新校准(L 系列 ADC 唤醒后精度会漂) |
| 独立看门狗 IWDG | 正常运行 | 注意睡眠期间会喂不到狗,睡眠时长必须小于看门狗周期 |
| SRAM / 寄存器 | 保持 | 无需处理 |
两个血泪点:一是引脚漏电,睡前进 Stop 的电流怎么都压不到 15μA 以下,最后发现是浮空的传感器片选脚在漏;全部改模拟输入后立刻达标。二是 IWDG,我们配了 4 秒看门狗, Tickless 一次睡 10 秒——设备睡得好好的,被看门狗咬死了。要么把最长睡眠分段中间喂狗,要么接受”深睡期用低功耗唤醒定时器替代看门狗”的架构决策,提前定好。
调试三板斧:确认它”真的睡了”
Tickless 调不好最常见的结果不是崩溃,而是静默失效——代码跑着,电流没降。我的三板斧:
- 电流档先定性:万用表 μA 档串进电源回路,看平均值。如果 Idle 时电流在 1~3mA 之间比 Run 略低,说明只进了 Sleep 没进 Stop;如果和 Run 一样,说明 Tickless 根本没生效(查
configUSE_TICKLESS_IDLE是否真编进去了)。 - GPIO 打标看节奏:在进 Stop 前拉高一个 GPIO、醒来拉低,示波器上能直接看到睡眠时长、频率和异常的”没睡就醒”。这一招同时能验证
eAbortSleep触发频率。 - 统计计数器:加三个全局计数器——尝试睡眠次数、实际睡眠次数(未被 abort)、累计补偿 tick 数。上线后通过维护口读出来,
实际/尝试比值长期低于 80% 就说明有东西在频繁阻止睡眠。
/* 简单但极有效的睡眠统计 */
typedef struct {
uint32_t try_cnt; /* vPortSuppressTicksAndSleep 被调用次数 */
uint32_t sleep_cnt; /* 真正进 Stop 的次数 */
uint32_t abort_cnt; /* eAbortSleep 次数 */
uint64_t slept_ms; /* 累计深睡时长 */
} sleep_stat_t;
落地后的完整电流画像
改完之后的整周期实测(抄表终端,每小时上报一次,平时深睡等待):
| 阶段 | 时长 | 平均电流 |
|---|---|---|
| 深睡(Stop + Tickless) | 3595 s / 小时 | 7.2 μA |
| 唤醒 + 采样 + 处理 | 3.2 s | 4.1 mA |
| LoRa 发送 | 0.8 s | 88 mA |
| 全周期平均 | — | 约 60 μA |
2400mAh ÷ 0.06mA ≈ 4.5 年,再留电池自放电和低温衰减的余量,”8 年续航”勉强够到脚跟。这个账也说明:Tickless 只是把”空闲”做干净了,通信瞬间的 mA 级脉冲才是平均电流的真正大头——休眠做到了 9μA 之后,下一刀要砍在射频发送策略上(降功率、合并上报、ADR)。
12 项 Tickless 落地自查清单
- ☐
configUSE_TICKLESS_IDLE已开启,且确认厂商 port 的portSUPPRESS_TICKS_AND_SLEEP真被编译链接 - ☐ 深睡唤醒源使用 RTC 闹钟 / LPTIM,绝不等 SysTick 叫醒
- ☐ 唤醒后第一件事重配系统时钟(PLL),再恢复外设
- ☐ tick 补偿用 RTC 真实流逝时间,并扣除唤醒固定开销
- ☐
configEXPECTED_IDLE_TIME_BEFORE_SLEEP按进出开销设过下限(通常 ≥2) - ☐
eTaskConfirmSleepModeStatus覆盖所有不可睡窗口,且未把睡眠全挡掉 - ☐ 睡前无用 GPIO 全部转模拟输入,杜绝浮空漏电
- ☐ IWDG 周期与最长睡眠时长核对过,不存在睡死喂狗
- ☐ ADC / 模拟外设唤醒后是否需要重新校准已确认
- ☐ 睡眠统计计数器(尝试/成功/abort/累计时长)已加入维护口
- ☐ GPIO 打标 + 示波器验证过实际睡眠节奏
- ☐ 用 32.768kHz 晶振而非内部 LSI 做补偿时基(LSI 精度 ±5%,tick 账会漂)
写在最后
回头看,Tickless 本身的 API 一天就能配通,真正花时间的全在”账”上:时钟账、时间补偿账、外设状态账、看门狗账。低功耗设计没有魔法,每 1μA 都是查出来的。
下一篇预告:《上拉/下拉电阻取值实战:同一颗 I2C,为什么别人 4.7kΩ 我得换 2.2kΩ 才稳》——把最不起眼的上拉电阻背后的边沿、漏电流、功耗三角账算清楚。
上期文章:DDR inline ECC 落地与错误上报实战。