低功耗:Sleep、Stop 与 Standby
本篇是《Zig 裸机开发实战:STM32F103ZET6 全栈解析》系列第 19 篇。上一篇:看门狗:IWDG 寄存器直写、超时计算与任务监督,下一篇:内存布局:链接脚本、RAM 预算与栈溢出检测,目录见文末。
低功耗:Sleep、Stop 与 Standby
导读
这个系列到现在,芯片一直以 72 MHz 全速运行——哪怕程序在 while (true) 里什么都不做,它也在烧 30 mA 量级的电流。本篇把功耗"拧下来":Cortex-M 提供的睡眠指令 WFI 是统一入口,但 STM32 在它之下铺了三档省电等级——Sleep、Stop、Standby,三档之间是"保留多少现场"与"省多少电"的换算关系。本篇要讲透三件事:一是三档模式的硬件语义与进入/唤醒路径,包括 PA0(WKUP/KEY1 复用引脚)的 EXTI 唤醒配置;二是 microzig 的现状——power 模块没有 enter_stop()/enter_standby() 这类封装,只给了 PowerConfig 配置结构,PWR 寄存器、SCB 的 SLEEPDEEP 位要自己动手写;三是本系列埋到现在的最大一个坑:Stop 唤醒后系统时钟会退回 8 MHz HSI,不重新配回 72 MHz PLL,第 2 篇起建立的所有波特率、时基、捕获周期全部作废——串口直接乱码。另外提醒一句第 18 篇的伏笔:IWDG 在 Stop 模式下照常计数,低功耗和看门狗会打架,本篇常见问题里给出解法。
为什么需要低功耗
先看数字。STM32F103 数据手册给的手册级典型值,加上一块 F103ZET6 最小系统板(LED、稳压器、调试口都算上)的实测量级,大致是:
| 状态 | 典型电流 | 说明 |
|---|---|---|
| Run(72 MHz 全速) | 约 30 mA | 内核 + 外设 + 时钟树全开 |
| Sleep(CPU 停,外设跑) | 约 10 mA | 内核时钟关,AHB/APB 外设照常工作 |
| Stop(1.8 V 域时钟全停) | 约 20 µA | 只有 LSI(若开)和唤醒逻辑在耗电 |
| Standby(1.8 V 域断电) | 约 3 µA | 只剩备份域(VBAT 供电)与 WKUP 检测 |
一块 2000 mAh 的锂电池,Run 状态能撑 3 天,Standby 能撑 20 年以上。这就是为什么所有电池供电设备(水表、遥控器、传感器节点)都把"睡觉"当成设计核心,而不是锦上添花。
Cortex-M3 的睡眠机制只有一条指令:WFI(Wait For Interrupt,微 zig 里是 microzig.cpu.wfi(),逐字依据见 cortex_m.zig 的 pub fn wfi() void)。执行后内核停止取指,等待任意中断或事件把它唤醒。但"睡多深"不是 WFI 决定的,而是由执行 WFI 时的两个状态位决定:
- SCB 的 SCR(System Control Register,地址 0xE000ED10)的 SLEEPDEEP 位(bit 2):0 = 睡浅的(Sleep),1 = 睡深的(进入 Stop 或 Standby,由 PWR 决定)。SCB 是 Cortex-M 内核寄存器,microzig 没有为它提供 HAL 函数,本篇用
@ptrFromInt(0xE000ED10)直写(依据:ARM v7-M 架构手册 B3.2.2,SCR 位定义 SLEEPONEXIT bit1 / SLEEPDEEP bit2 / SEVONPEND bit4)。 - PWR.CR(0x40007000,偏移 0x00)的 PDDS 位(bit 1):在 SLEEPDEEP=1 的前提下,0 = 进 Stop(1.8 V 域保持供电,SRAM 和寄存器内容保留),1 = 进 Standby(1.8 V 域断电)。另有 LPDS(bit 0)选择 Stop 模式下电压调节器是否切到低功耗模式,CWUF(bit 2)/CSBF(bit 3)是唤醒/待机标志的写 1 清除位(依据:RM0008 5.4.1 PWR_CR 位定义)。
三档模式的完整对照表——这张表值得背下来,后面所有代码都是它的展开:
| Sleep | Stop | Standby | |
|---|---|---|---|
| 停什么 | 只停 CPU 时钟 | 1.8 V 域全部时钟(HSI/HSE/PLL 全关) | 1.8 V 域断电 |
| SRAM / 寄存器 | 全保留 | 全保留 | 全部丢失(备份域寄存器除外) |
| 唤醒源 | 任意中断或事件 | EXTI 线上的事件(边沿触发) | WKUP 引脚上升沿、RTC 闹钟、NRST、IWDG 复位 |
| 唤醒延迟 | 约几个时钟周期 | HSI 恢复 + 稳定,微秒级 | 完整复位流程,毫秒级 |
| 唤醒后时钟 | 不变(仍 72 MHz) | HSI 8 MHz(PLL/HSE 需重配) | HSI,且从头执行复位向量 |
| 典型电流 | 约 10 mA | 约 20 µA | 约 3 µA |
| 唤醒后执行点 | WFI 的下一条指令 | WFI 的下一条指令 | main 从头跑 |
Standby 那一行"唤醒后执行点"是它与其他两档的本质区别:唤醒即复位,main 重新执行。所以 Standby 适合"睡几天、醒一次、干完活接着睡"的报表型设备,Stop 适合"*时睡、频繁醒、醒着干活"的交互型设备。
代码案例:三档低功耗与 PA0 唤醒
microzig 的 power 模块(STM32F103/power.zig,78 行)提供的能力是:PowerConfig(字段 deepsleep_mode(.stop/.standby)、volt_regulator_mode(.on/.off)、wakeup_pin)、apply()(把 PDDS/LPDS 写进 PWR.CR)、clear_events()(写 1 清 CWUF/CSBF)、get_events()(读 CSR 的 WUF/SBF)。它没有 enter_stop()、没有 enter_standby()、也没有睡眠指令封装——apply 只写 PWR 寄存器,真正"睡下去"还得我们自己写 SCB SCR + wfi()。下面的例子把这条路走通,并处理唤醒后的时钟恢复。
接线沿用系列约定:PA0 同时是 KEY1 按键输入和 STM32 的 WKUP 引脚——这对 Standby/Stop 唤醒来说正好是"按一下按键就醒",一根引脚两用。
const std = @import("std");
const microzig = @import("microzig");
const stm32 = microzig.hal;
const rcc = stm32.rcc;
const gpio = stm32.gpio;
const time = stm32.time;
const power = stm32.power;
const exti = stm32.exti;
pub const panic = microzig.panic;
pub const std_options = microzig.std_options(.{ .logFn = stm32.uart.log });
comptime {
_ = microzig.export_startup();
}
// ---- 寄存器直写定义 ----
// PWR 基址 0x40007000(RM0008 Table 1);本系列尚未用到 PWR 的任何 HAL 写路径
// 之外的东西,CR 的位定义见 RM0008 5.4.1:LPDS bit0 / PDDS bit1 / CWUF bit2。
const PWR_BASE = 0x4000_7000;
const pwr_cr: *volatile u32 = @ptrFromInt(PWR_BASE);
const CR_LPDS: u32 = 1 << 0;
const CR_PDDS: u32 = 1 << 1;
const CR_CWUF: u32 = 1 << 2;
const CR_CSBF: u32 = 1 << 3;
// SCB SCR:Cortex-M3 内核寄存器,microzig 未封装,直写。
// 位定义(ARM v7-M 手册 B3.2.2):SLEEPDEEP = bit2。
const SCB_SCR: *volatile u32 = @ptrFromInt(0xE000_ED10);
const SCR_SLEEPDEEP: u32 = 1 << 2;
// RCC CFGR(0x40021008)的 SW/SWS 字段:
// SW = bits[1:0] 系统时钟切换(00 HSI / 01 HSE / 10 PLL)
// SWS = bits[3:2] 系统时钟切换状态(硬件回读)
// SW 的目标值直接写位掩码:PLLMUL 等枚举字段名未经逐字核对,统一用 raw。
const RCC_CFGR: *volatile u32 = @ptrFromInt(0x4002_1008);
const CFGR_SW_MASK: u32 = 0b11;
const CFGR_SW_PLL: u32 = 0b10;
const CFGR_SWS_MASK: u32 = 0b11 << 2;
const CFGR_SWS_PLL: u32 = 0b10 << 2;
/// 唤醒后把时钟从 HSI 8 MHz 恢复到 72 MHz PLL。
/// 为什么不用 `rcc.apply`:它的参数是 `comptime config`,时钟树在编译期
/// 展开,而"醒来"是运行时事件——只能手动拼底层函数(本篇最重要的坑)。
fn restore_clock_72mhz() void {
// HSE 和 PLL 在 Stop 期间被硬件关闭,逐个重新打开。
// enable_hse(bypass, css, timeout) 签名见 rcc.zig:185,忙等 HSERDY。
rcc.enable_hse(false, false, 1_000_000) catch {
// 起振失败:留在 HSI 8 MHz 上,至少串口 BRR 改后还能跑
return;
};
// PLLON 置 1 并忙等 PLLRDY(rcc.zig:281)
rcc.enable_pll();
// PLLMUL/PLLSRC 等分频倍频配置在 Stop 期间由硬件保持,无需重写;
// 只需把 SW 切回 PLL 并等 SWS 确认(SW 由复位强制回 HSI,必须重切)。
RCC_CFGR.* = (RCC_CFGR.* & ~CFGR_SW_MASK) | CFGR_SW_PLL;
while ((RCC_CFGR.* & CFGR_SWS_MASK) != CFGR_SWS_PLL) {}
}
/// 进入 Stop 模式,返回时已唤醒并恢复 72 MHz。
fn enter_stop() void {
// 1) 清挂起的唤醒/待机标志(写 1 清除;不清会导致立刻醒来)
pwr_cr.* |= CR_CWUF | CR_CSBF;
// 2) PWR:Stop 模式 + 调节器低功耗(power.apply 逐字等价于
// PWR.CR: LPDS=1, PDDS=0;power.zig:39-46,可直接用 HAL)
power.apply(.{
.deepsleep_mode = .stop,
.volt_regulator_mode = .off, // .off -> LPDS=1,Stop 下 LDO 进低功耗
});
// 3) SLEEPDEEP=1:让下一次 WFI 走深睡路径
SCB_SCR.* |= SCR_SLEEPDEEP;
// 4) 睡。唤醒后从这里继续。
microzig.cpu.wfi();
// 5) 醒来第一件事:时钟已经是 HSI 8 MHz,立刻恢复
SCB_SCR.* &= ~SCR_SLEEPDEEP;
restore_clock_72mhz();
}
/// 进入 Standby 模式。唤醒 = 复位,本函数永远不返回。
fn enter_standby() noreturn {
pwr_cr.* |= CR_CWUF | CR_CSBF; // 清 WUF 与 SBF
power.apply(.{
.deepsleep_mode = .standby, // PDDS=1
});
SCB_SCR.* |= SCR_SLEEPDEEP;
microzig.cpu.wfi();
// Standby 唤醒走复位流程,理论上不会执行到这里
while (true) {}
}
/// 配置 PA0 为 EXTI 事件唤醒源(上升沿)。
fn setup_wakeup_pin() void {
// 用 event 而不是 interrupt:事件直接唤醒内核、不进中断向量,
// 不需要绑 EXTI0_Handler,也省一个 ISR 上下文切换。
// TriggerEdge 枚举值逐字核对自 exti.zig:9:None/rising/falling/both。
exti.apply_line(.{
.line = 0,
.port = .A, // gpio.Port 枚举:A/B/C/D/E/F/G(gpio.zig:9)
.trigger = .rising, // WKUP 检测上升沿;按键松开时 PA0 被上拉回高
.event = true,
.interrupt = false,
});
// PA0 引脚模式:第 6 篇已把 KEY1 配为上拉输入,Stop 期间输入缓冲
// 停止工作但引脚状态仍会被 EXTI 采样,无需额外处理。
}
pub fn main() !void {
// 与第 2 篇/第 18 篇一致的 72 MHz 初始化(显式写一遍,正好与
// "唤醒后手动恢复"的 restore_clock_72mhz 形成对照:apply 是 comptime
// 路径,只在上电时走得通;醒来是运行时事件,只能拼底层函数)
_ = try rcc.apply(.{
.SYSCLKSource = .PLL1_P,
.PLLSourceVirtual = .HSE_Div_PREDIV,
.PLLMUL = .Mul9,
.APB1CLKDivider = .Div2,
.flags = .{ .HSEOscillator = true },
});
time.init_timer(.TIM4); // 系列约定的时基
// 串口日志(第 12 篇约定:USART1 PA9/PA10 + init_logger)
rcc.enable_clock(.GPIOA);
rcc.enable_clock(.USART1);
const uart = stm32.uart.UART.init(.USART1);
const tx = gpio.Pin.from_port(.A, 9);
tx.set_output_mode(.alternate_function_push_pull, .max_50MHz);
try uart.apply_runtime(.{ .clock_speed = rcc.get_clock(.USART1) });
stm32.uart.init_logger(&uart);
setup_wakeup_pin();
const events = power.get_events();
if (events.Wakeup or events.Standby) {
// 上一次是从 Stop/Standby 醒来的(Standby 醒来时走的是复位,
// 所以这一分支主要捕获 Stop 唤醒;Standby 的证据靠 SBF 标志)。
std.log.info("wakeup: wuf={} sbf={}", .{ events.Wakeup, events.Standby });
}
std.log.info("running at full speed, sleeping in 3s", .{});
time.sleep_ms(3000);
std.log.info("entering stop", .{});
// 给 USART1 的移位寄存器留出发送完成时间,再睡
enter_stop();
std.log.info("back from stop", .{});
var ticks: u32 = 0;
while (true) {
std.log.info("alive {d}", .{ticks});
time.sleep_ms(1000);
ticks += 1;
}
}
说明:
time.init_timer(.TIM4)是第 10 篇确立的时基约定(TIM2 留给捕获、TIM3 留给 PWM 重映射)。日志经std_options的logFn = stm32.uart.log走 USART1(第 12 篇约定),std.log的参数里不要带\r\n——logFn 会补齐行尾。
逐段解析
寄存器直写的三处地址
本篇需要的 PWR.CR、SCB.SCR、RCC.CFGR 里,只有 PWR 有 HAL(power.apply),SCB 完全没有(它是 Cortex-M 内核层,microzig 的 cpu 模块只给了 wfi/wfe/dmb 这类指令封装,没有寄存器封装)。所以 SCR 用 @ptrFromInt(0xE000_ED10) 直写——注意 Zig 的数字字面量里下划线是分组分隔符,0xE000_ED10 就是 0xE000ED10。写 SCR 之前先加 microzig.cpu.dsb()(数据同步屏障)是更严谨的写法,保证前面的 PWR 写已到达外设;本例中 PWR 写与 WFI 之间隔着多行代码,在单发射按序执行的 M3 上足以稳定,故省略。
RCC.CFGR 的 SW 字段我们用位掩码直写而不是枚举:regz 生成的 rcc_f1.SW 枚举值名未经逐字核对(本系列的红线是"不确定就用 raw"),而 SW=0b10 表示 PLL 这个语义是 RM0008 7.2.11 里写死的,不依赖命名。
restore_clock_72mhz:本篇最重要的函数
Stop 模式下,HSI、HSE、PLL 全部被硬件关闭;唤醒时只有 HSI 自动恢复,且 SW(系统时钟选择)被硬件强制回 HSI。这意味着醒来后:
- CPU 8 MHz,是配置频率的 1/9;
- USART1 的 BRR 还是按 72 MHz APB2 算出来的旧值,实际波特率变成设定值的 1/9——这就是"唤醒后串口乱码"的完整解释;
- TIM4 时基慢 9 倍、SysTick 慢 9 倍、捕获周期读数全部失真。
恢复顺序必须是"HSE 先就绪 → 再开 PLL → 最后切 SW":PLL 的输入是 HSE,HSE 没稳就开 PLL 是未定义行为;SW 切换要等 SWS 硬件回读确认,否则后续代码可能在 HSI 上执行。enable_hse 的第三个参数是超时循环次数,给个宽松值并 catch 住起振失败的情况——醒在 8 MHz 上运行总比 panic 好。
一个好消息:PLLMUL、PLLSRC、APB/AHB/ADC 分频这些配置位在 Stop 期间由硬件保持,所以不需要重写——只有 SW 这一个"选择器"被复位。这也是为什么恢复函数只需要三步。
另一个要坦白的细节:rcc.apply 内部会更新模块级变量 current_clocks,我们的手动恢复不会更新它。但因为最终时钟和原来完全一样(72 MHz),current_clocks 里的值依然是正确的,后续所有按 rcc.get_sys_clk() 算波特率的代码不受影响。如果恢复成了别的频率(比如 HSE 起振失败退回 HSI),就必须警惕这个内部状态失真。
enter_stop 的五步
第一步清标志是新手最容易漏的:PWR 的 WUF(唤醒标志)如果在上电或上次唤醒后没清掉,WFI 会立即返回——现象是"低功耗根本进不去"。power.clear_events()(pwd.CR.modify(.{ .CWUF = 1, .CSBF = 1 }),power.zig:76)与我们的 raw 写等价,二选一即可。
第二步用 power.apply 而不是 raw 写,因为它一次写好 LPDS/PDDS/PLS 三个字段且经过上游测试;我们已核对它的实现(power.zig:39-46):deepsleep_mode = .stop → PDDS=0,volt_regulator_mode = .off → LPDS=1(Stop 期间 LDO 切低功耗档,电流再省几微安,代价是唤醒时电压稳定稍慢)。
第四步 wfi() 之后的一切(SLEEPDEEP 清零、时钟恢复)都在唤醒之后执行——这是和 Standby 最大的代码结构差异:Stop 醒来接着跑,Standby 醒来从 main 重跑。
setup_wakeup_pin:事件而非中断
EXTI 有两条输出路径:中断路径(进 NVIC,执行 ISR)和事件路径(直接向内核发唤醒脉冲,不进向量表)。低功耗唤醒要的是后者:.event = true, .interrupt = false。好处有三个——不用绑 EXTI0 向量(省掉 microzig_options 的覆盖写法)、ISR 里不用清 pending、唤醒路径更短。TriggerEdge 的四个值 None/rising/falling/both 逐字核对自 exti.zig:9(注意大小写:None 大写,其余小写,这是上游源码的真实风格)。apply_line 内部会写 AFIO.EXTICR 选端口(line 0 → EXTICR[0] 低 4 位,PA0 = 0b0000,恰好是复位值,但显式传 .port = .A 保证正确性,不依赖复位值)。
.trigger = .rising(上升沿)对应 WKUP 硬件检测的固定语义(WKUP 只认上升沿)。我们的 KEY1 按下把 PA0 拉低、松开弹回高——所以是"松手唤醒"。若想要"按下唤醒",改用下降沿触发 EXTI 即可,Standby 唤醒则仍只认上升沿。
enter_standby 与它的"不返回"契约
Standby 前清 CSBF 的原因同 WUF(SBF 残留会让进入失败)。wfi() 执行后 1.8 V 域断电,SRAM 内容、寄存器内容、CPU 状态全部蒸发;WKUP 上升沿到来时电源恢复,内核从复位向量重新启动。所以 enter_standby 的返回类型标 noreturn——这是给读者的文档,也是给编译器的约束。醒来的证据链是:rcc.get_reset_reason() 返回 .low_power(该枚举值逐字核对自 rcc.zig:34-38),或者 power.get_events().Standby(读 PWR.CSR 的 SBF,备份域供电,跨复位保留)。
Standby 下想带数据"过夜",用第 18 篇用过的备份寄存器:stm32.backup.BackupData1(10 个 16 位寄存器,BKP 基址 0x40006C04 起),VDD 或 VBAT 供电期间内容保持。
运行现象
正确的运行日志(115200 波特率):
wakeup: wuf=1 sbf=0 <- 仅在上次是 Stop 唤醒时出现
running at full speed, sleeping in 3s
entering stop
back from stop
alive
alive
...
按下 KEY1(松开时)芯片被唤醒。entering stop 与 back from stop 之间的 3 秒静默期,就是 30 mA 掉到 20 µA 的过程。
验证电流:万用表打到 mA/µA 档串联进电源回路(注意换档会断电重启,Stop 状态会被打断),或者用 INA219/ADC 采样一个低侧采样电阻。实测时注意三点:一是板载稳压器与 LED 的静态电流会污染读数,最好用芯片引脚级测量或去掉 LED;二是调试器(ST-Link)连着时 SWD 会阻止部分睡眠行为,电流测量的最终读数要在调试器断开后取;三是第 12 篇的 LED 如果停在"亮"的状态,一颗 LED 就是 5-10 mA,会淹没所有低功耗效果。
验证乱码坑:把 restore_clock_72mhz() 里的调用注释掉(只保留唤醒),你会发现 back from stop 打出来是乱码——时钟降为 1/9 后波特率同样降为 1/9。这是复现本文头号坑的最快方法。
常见问题
Q1:WFI 执行后立刻返回,芯片根本没睡着?
三个最常见的原因。一,唤醒标志没清:PWR 的 WUF 或 EXTI 的 pending 位残留,WFI 看到挂起事件立即返回——进低功耗前先 clear_events(),并在 exti.apply_line 之后检查 EXTI 的 PR 寄存器没有残留 pending。二,中断竞态:wfi 之前那一刻中断恰好到达,醒来后继续跑,属于正常现象但会让人误判"没睡着";在关中断状态下清标志、配寄存器,最后 wfi 前再开中断,可缩小竞态窗口。三,调试器挂着:DBGMCU_CR(0xE0042004)的 DBG_SLEEP/DBG_STOP 位若被调试器置 1,睡眠时内核时钟继续运行以便调试——调试器断开后行为才与真实一致。
Q2:进 Stop 了,但电流还是几 mA?
Stop 只停 1.8 V 域的时钟,不管引脚。逐项排查:GPIO 悬空输入(浮空引脚在施密特触发器翻转点附*反复振荡,每个翻转都耗电——未用引脚配成模拟输入或带上/下拉,这是数据手册"低功耗引脚处理"一节的要求);输出低但驱动着上拉负载(如我们的 KEY1 上拉:PA0 是输入不受影响,但如果你有输出低驱动 LED 的引脚,一颗 LED 就是几 mA);外设时钟没关(rcc.disable_all_clocks() 在 Stop 下作用有限——Stop 本身停时钟——但对 Sleep 模式是关键);JTAG 引脚 PA13/PA14 默认上拉,调试场景可保留。按这个顺序查,通常能从几 mA 降到几十 µA。
Q3:Stop 和 Standby 怎么选?SRAM 里的数据 Standby 真的全丢?
选型看唤醒频率与现场价值:唤醒间隔秒级、醒来要继续干活(带协议栈、带状态机)→ Stop,醒来接着跑零恢复成本;唤醒间隔小时/天级、醒来从头干活(采一次样发一包数据)→ Standby,3 µA 的电流在长睡眠里碾压一切。SRAM 确实全丢,但"重要数据"有三个去处:备份寄存器 BackupData1/2(10+31 个 16 位,跨 Standby 保持);需要更多就存外部 EEPROM 或内部 Flash(第 17 篇,Flash 在 Standby 断电后内容当然也保持,它是非易失的);RTC 闹钟 + 备份域让 Standby 也能做周期性唤醒(本系列没启用 LSE/RTC 闹钟路径,RTCClockSelection = .LSI 的配置第 12 篇出现过)。
Q4:IWDG 在 Stop 模式下还计数吗?会不会睡一半被狗咬死?
会。IWDG 的时钟是 LSI 独立时钟,Stop/Standby 都不关 LSI,第 18 篇的伏笔在这里兑现。Stop 里睡 10 秒、IWDG 超时 1 秒——永远醒不来(Standby 情况更直接:IWDG 直接把系统复位)。三个解法:一,进低功耗前用带"喂狗任务"的结构,即超时配得比最长睡眠还长(代价是故障检测变慢);二,睡眠期间让另一个定时器周期唤醒喂狗(把长睡拆成短睡拼接);三,调试期在 DBGMCU_APB1FZ(0xE0042008)里置 DBG_IWDG_STOP(bit 8),让 IWDG 在 Stop 期间暂停(第 18 篇讲过)——这只对调试有效,真实运行时 LSI 照跑,产品里别依赖它。
Q5:为什么 microzig 不封装 enter_stop()?唤醒后能指望 HAL 自动恢复时钟吗?
因为唤醒后的行为高度依赖应用:恢复到 72 MHz 还是别的频率、要不要重配外设、SRAM 现场怎么用,都是策略问题,上游把这个决策留给了用户。microzig 给到的是积木:power.apply(PWR 寄存器)、rcc.enable_hse/enable_pll(时钟恢复的底层步骤)、microzig.cpu.wfi(睡眠指令)。HAL 层面没有"唤醒后自动跑时钟恢复"的钩子——启动时的时钟初始化代码只在复位路径上执行。所以"醒来自动恢复 72 MHz"这件事必须应用自己做,rcc.apply 是 comptime 的、帮不上忙,这就是本篇 restore_clock_72mhz 存在的全部理由。
面试考点
考点 1:STM32 三种低功耗模式的状态保持与唤醒源有什么区别?
参考答案:Sleep 只停 CPU 时钟,外设、SRAM、寄存器全保留,任意中断唤醒,唤醒后从 WFI 下一句继续且时钟不变;Stop 关闭 1.8 V 域所有时钟(HSI/HSE/PLL 停),SRAM 与寄存器内容保留,只有 EXTI 事件(边沿)可唤醒,唤醒后时钟退回 HSI 需重新配置 PLL,从 WFI 下一句继续;Standby 断开 1.8 V 域电源,SRAM 与寄存器丢失(仅备份域保持),由 WKUP 引脚上升沿、RTC 闹钟、NRST 或 IWDG 复位唤醒,唤醒等效复位、从复位向量重新执行。电流依次为 Run 的约 1/3、千分之一、万分之一量级。
考点 2:为什么 Stop 唤醒后必须重新配置 PLL?rcc.apply 为什么做不到?
参考答案:Stop 退出时硬件只恢复 HSI 并把系统时钟选择 SW 强制回 HSI,HSE 与 PLL 均处于关闭态;若不重开并切回,系统以 8 MHz 运行,所有按 72 MHz 计算的波特率、定时器周期全部失真。rcc.apply 的签名是 pub fn apply(comptime config: Tree.Config),整个时钟树求解在编译期完成,属于"上电初始化"语义;唤醒是运行时事件,配置参数早已定型,无法在运行时再走一遍 comptime 路径。正确做法是调用底层运行时函数逐项恢复:enable_hse(忙等就绪)→ enable_pll(置 PLLON 等 PLLRDY)→ 写 CFGR.SW 并等 SWS 回读确认;分频倍频配置位在 Stop 期间由硬件保持,无需重写。
考点 3:WFI 与 WFE 的区别是什么?EXTI 的中断和事件路径有什么不同?
参考答案:WFI(Wait For Interrupt)睡眠后由任意挂起的中断或事件唤醒,唤醒后一律执行中断入口/后续指令;WFE(Wait For Event)依赖事件寄存器,若进入前事件位已置位则不睡眠直接跳过(配合 sev 可用于自旋锁、避免竞态空等)。EXTI 模块每个线有两条独立路径:中断路径经 NVIC 进 ISR,需要绑定向量、ISR 内清 pending;事件路径直接向内核发一个唤醒脉冲,不占向量、无 ISR、无上下文保存开销。低功耗唤醒通常选事件路径,只有"醒来后必须做与该边沿相关的处理"时才用中断路径。
考点 4:低功耗设计中 GPIO 为什么要避免悬空输入?还有什么引脚注意事项?
参考答案:悬空输入引脚的施密特触发器阈值附*电*会随噪声反复翻转,输入缓冲器每次翻转都消耗动态电流,数十个悬空引脚的合计漏电可到 mA 级,直接淹没 Stop 的 20 µA 目标。处理规则:未用引脚配为模拟输入(关掉输入缓冲,最省)或接固定上/下拉;不要留输出低驱动上拉负载的引脚(负载电流照流);调试引脚(SWD/JTAG)按需保留;进 Stop 前确认外设引脚(如 I2C 的开漏线靠上拉保持高电*,无静态电流,可以不管)。
考点 5:Standby 模式下如何让设备"记住"状态?复位源如何区分?
参考答案:1.8 V 域断电,SRAM 与普通寄存器全丢,可用存储有三层:备份寄存器(BKP,10+31 个 16 位,VBAT/VDD 供电即保持,代价是容量小);内部 Flash(非易失,但写入有擦写寿命与页擦除耗时,适合低频参数);外挂 EEPROM/FRAM(容量与寿命最优,代价是 BOM 成本)。复位源区分:RCC_CSR 的标志位组(IWDGRSTF、SFTRSTF、PINRSTF 等)与 RCC.get_reset_reason(),Standby 唤醒对应 .low_power(LPWRSTF),配合 PWR.CSR 的 SBF(Standby 标志)可精确判断"上电复位"还是"Standby 唤醒复位",从而决定 main 里是全量初始化还是恢复现场。
小结
本篇把芯片从"常开"改造成"按需清醒":三档模式的本质是"停多少、留多少"的三个刻度——Sleep 留下一切只停 CPU,Stop 留下现场停掉时钟,Standby 连现场一起蒸发。microzig 在这条路上给了一半积木(power.apply、exti.apply_line、cpu.wfi),另一半(SCB SCR、RCC 运行时重配、唤醒后现场重建)要自己拼,而拼积木时最大的坑只有一个:醒来时你身处 8 MHz 的 HSI 世界,不先把时钟拼回 72 MHz,之前 18 篇建立的波特率、时基、捕获全部作废。至此,外设篇(第 4-18 篇)全部完成——最后一篇我们回到工程全局,打开链接脚本,看看 64 KB SRAM 和 512 KB Flash 到底是怎么被安排的,顺便回答"DMA 缓冲区为什么必须住进专属段"这个第 13 篇留下的悬念。
系列目录
- 系列总览:Zig 裸机开发的定位、生态与*台全景
- 开发环境搭建:Zig 0.17、microzig 与 probe-rs
- 工程结构剖析:build.zig、MicroBuild 与固件产物
- 第一个固件:启动流程、向量表与 GPIO 点灯
- 寄存器级编程:PAC、MMIO 与 volatile 语义
- GPIO 子系统:输入输出模式、上下拉与端口映射陷阱
- 时钟树配置:HSE、PLL 与总线分频
- 中断系统:NVIC、优先级分组与向量注册
- USART 串口通信:轮询收发与日志重定向
- 定时器基础:时基、PWM 与呼吸灯
- 输入捕获:频率与占空比测量
- 中断驱动串口:环形缓冲区与临界区设计
- DMA 传输:从外设搬运到内存
- I2C 总线:SSD1306 OLED 显示实战
- SPI 总线:W25Q64 Flash 读写与 ID 识别
- ADC 采样:单次、扫描与内部温度传感器
- 内部 Flash 编程:参数持久化与页擦除
- 独立看门狗:复位诊断与可靠性设计
- 低功耗模式:Sleep、Stop、Standby 与唤醒
- 内存布局与链接脚本:512 KB Flash 与 64 KB RAM 规划
- Zig 语言进阶:comptime、错误处理与零成本抽象
- 并发模型:中断与主循环的协作与临界区
- 协作式调度器:状态机与时间片轮转
- 实时性分析:中断延迟、抖动与 WCET
- RTOS 内核实现(上):任务控制块与上下文切换
- RTOS 内核实现(下):信号量、消息队列与优先级继承
- USB 设备开发:CDC 虚拟串口
- CAN 总线:bxCAN 过滤器与环回自测
- 调试与测试:probe-rs、RTT 与硬件在环
- 收官:分层架构、可移植性与 Zig 生态展望

浙公网安备 33010602011771号