低功耗: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 篇留下的悬念。

系列目录

  1. 系列总览:Zig 裸机开发的定位、生态与*台全景
  2. 开发环境搭建:Zig 0.17、microzig 与 probe-rs
  3. 工程结构剖析:build.zig、MicroBuild 与固件产物
  4. 第一个固件:启动流程、向量表与 GPIO 点灯
  5. 寄存器级编程:PAC、MMIO 与 volatile 语义
  6. GPIO 子系统:输入输出模式、上下拉与端口映射陷阱
  7. 时钟树配置:HSE、PLL 与总线分频
  8. 中断系统:NVIC、优先级分组与向量注册
  9. USART 串口通信:轮询收发与日志重定向
  10. 定时器基础:时基、PWM 与呼吸灯
  11. 输入捕获:频率与占空比测量
  12. 中断驱动串口:环形缓冲区与临界区设计
  13. DMA 传输:从外设搬运到内存
  14. I2C 总线:SSD1306 OLED 显示实战
  15. SPI 总线:W25Q64 Flash 读写与 ID 识别
  16. ADC 采样:单次、扫描与内部温度传感器
  17. 内部 Flash 编程:参数持久化与页擦除
  18. 独立看门狗:复位诊断与可靠性设计
  19. 低功耗模式:Sleep、Stop、Standby 与唤醒
  20. 内存布局与链接脚本:512 KB Flash 与 64 KB RAM 规划
  21. Zig 语言进阶:comptime、错误处理与零成本抽象
  22. 并发模型:中断与主循环的协作与临界区
  23. 协作式调度器:状态机与时间片轮转
  24. 实时性分析:中断延迟、抖动与 WCET
  25. RTOS 内核实现(上):任务控制块与上下文切换
  26. RTOS 内核实现(下):信号量、消息队列与优先级继承
  27. USB 设备开发:CDC 虚拟串口
  28. CAN 总线:bxCAN 过滤器与环回自测
  29. 调试与测试:probe-rs、RTT 与硬件在环
  30. 收官:分层架构、可移植性与 Zig 生态展望
posted @ 2026-10-27 08:53  navysummer_hardware  阅读(8)  评论(0)    收藏  举报