一文读懂Linux内核PM Core:设备挂起与恢复的完整机制
每次聊 Linux 内核功耗子系统我都会从 PM Core 说起。上个月调试一块工控板规格书上清清楚楚写着支持 suspend to RAM结果 echo mem 之后电流只掉了一点点CPU 睡了几个外设还醒着。顺着 /sys/power/state 一路追进去最后发现根因不是硬件而是某个驱动根本没实现 suspend 回调。从那以后我养成了习惯先翻 drivers/base/power/ 下的代码再动手改设备树。这一篇就想把这个功耗框架的地基讲清楚也给刚接触内核电源管理的朋友一条能顺着走的主线。1. Linux 功耗管理全景图PM Core 的位置与边界1.1 功耗管理从来不是单一模块的事很多人一提内核功耗第一反应就是 cpufreq 调频、cpuidle 调休。实际上一套完整的设备功耗控制至少牵涉四条线CPU 动态调频由 cpufreq 负责根据负载调整频率和电压CPU 空闲管理由 cpuidle 负责让无事可做的核心进入各种深度的 idle 状态设备功耗管理负责每个外设的运行时开关以及系统级挂起/恢复唤醒源管理则保证系统睡下去之后还能被正确叫醒。再加上 thermal 热管理在温度越界时强制降频这五块合起来才是 Linux 功耗管理的全貌。这几条线的代码归属完全不同cpufreq 在 drivers/cpufreqcpuidle 在 drivers/cpuidle 和 kernel/sched 附近thermal 在 drivers/thermal而设备级和系统级的电源管理核心全部落在 drivers/base/power 目录。PM Core 服务的主要是第三、第四条线同时通过 QoS 约束接口给前两条线提供决策依据。拿公司来打比方cpufreq 是根据业绩动态调整加班强度的部门经理cpuidle 是没事别硬扛着的排班员thermal 是再加班就要拖垮身体的强制休假制度PM Core 更像行政后勤总部。它不直接决定谁加班、谁摸鱼但它管着全公司的开关门时间、停电检修流程以及停电期间哪些设备必须保留夜间供电。搞清楚这个定位再去读代码就不会迷失方向。1.2 PM Core 只做机制不做策略先划边界。PM Core 几乎不做功耗策略它只提供机制。它不会聪明地判断当前该关哪个设备因为内核不知道你的产品场景它只保证当你告诉它系统要睡了它会按预定义流程把挂起命令逐个通知到所有注册了回调的设备并在唤醒后按相反的顺序把它们叫醒。当你告诉它某条 DMA 在未来 200 微秒内不能被打断它就维护一张 QoS 约束表供 cpufreq 和 cpuidle 查询。这个机制/策略分离恰恰是分层设计的第一层含义。为什么必须这么拆因为功耗策略太依赖硬件平台和产品定义了手机希望抬手亮屏服务器希望尽量低延迟物联网设备可能要求 10 秒内响应唤醒策略千差万别机制却必须稳定统一。PM Core 作为机制层把怎么通知设备、按什么顺序通知、出错怎么回滚做成标准接口把什么时候发起通知留给上层策略和用户态脚本。从源码目录看drivers/base/power/ 是一个小而精的模块集合。主要文件包括main.c 负责系统级挂起/恢复的主流程和设备链表管理runtime.c 负责运行时 PMwakeup.c 负责唤醒源计数与状态协调qos.c 负责 PM QoS 约束suspend.c 负责 suspend 状态机的 prepare 与 enter 阶段power.h 存放内部数据结构。先记住这个地图下面按图索骥。2. 分层设计的核心模块划分与接口边界2.1 分层的三个实际收益深入读 PM Core 会发现它在逻辑上也自下而上分了三层最上层是对外的状态管理入口中间层是设备统一管理最底层是平台相关的实际挂起执行。sysfs 入口在 kernel/power/main.c 的 state_store对应对外接口层dpm_suspend、dpm_resume 这一组函数对应设备管理层真正让 CPU 进入睡眠的 suspend_enter最终会调用一个由体系结构提供的平台回调比如 ARM64 上的 PSCI 系统挂起接口x86 上的 ACPI 相关逻辑。这样分层带来的第一个好处是平台无关。同一套 dpm_suspend 流程在 x86、ARM、RISC-V 上跑的是几乎一样的代码区别只在最后一跳。第二个好处是接口稳定。设备模型的 power 接口在内核里多年没大改新增平台不需要动框架。第三个好处是可观测性因为中间设备管理逻辑统一出问题时可以直接在 dpm 阶段打印每个设备的挂起耗时快速定位是谁拖慢了整条睡眠流程。还有一点容易被忽略PM Core 以设备对象为一切操作的中心。设备模型本身有父子关系和依赖顺序PM Core 正是利用这个关系来决定挂起顺序。比如一个 MMC 控制器供电来自某个 regulator而 regulator 本身也是一个设备。挂起时 regulator 必须在 MMC 之后关唤醒时得先开 regulator 再碰 MMC。如果让每个驱动各管各的很快就有人踩到电源已断但设备还没挂起的坑。PM Core 通过统一的设备链表和回调框架让I2C 控制器必须比挂在它下面的触摸屏晚挂起这类需求在框架层就被保证。2.2 PM Core 的四个分支地图把 PM Core 比作后勤总部的话它下面有四个分支科室。理解这四个分支就读懂了大半个框架模块核心文件对外接口作用系统挂起/休眠suspend.c、main.c/sys/power/state管理 S2RAM/S2Disk/freeze 状态机运行时 PMruntime.cpm_runtime_xxx 系列设备级动态开关不依赖用户态唤醒事件wakeup.c/sys/power/wakeup_count协调唤醒事件与系统状态切换PM QoSqos.c/dev/cpu_dma_latency汇总延迟约束供调频与调度决策第一个分支系统挂起就是执行 echo mem /sys/power/state 时走的路径负责把所有设备优雅停掉。第二个分支运行时 PM管的是设备在系统运行期间的空闲关断比如 USB 设备空闲几秒自动挂起。第三个分支唤醒事件解决一个经典竞态设备恰好在系统睡眠的瞬间上报了一个唤醒事件这个事件会不会丢wakeup_count 机制就是防过期彩票的。第四个分支 PM QoS 给 cpufreq 和 cpuidle 提供信号灯告诉它们某段 DMA 传输在未来多少微秒内不能被打断。这四个分支共用同一套设备链表和 dev_pm_ops但触发方式不同系统挂起是全局广播运行时 PM 是设备级单播。记住全局广播 vs 设备单播这个差异后面读 runtime.c 时就不会绕晕。3. 从 /sys/power/ 反向读懂 PM Core3.1 state、wakeup_count、autosleep 分别干什么sysfs 是内核面向用户态的窗口调试功耗问题最先接触的就是这里。在板子上敲下面几条命令能直观看到电源管理状态# 查看系统支持哪些睡眠状态内容取决于内核配置和平台 cat /sys/power/state # 查看自动睡眠状态 cat /sys/power/autosleep # 看挂起过程的关键日志 dmesg | grep -i PMstate 文件的内容信息量很大。常见输出是 freeze、standby、memx86 桌面机可能再多个 disk。每个字符串对应内核里的 suspend_state_t 枚举freeze 对应 PM_SUSPEND_FREEZEstandby 对应 PM_SUSPEND_STANDBYmem 对应 PM_SUSPEND_MEMdisk 走的是 hibernation 路径不经过 dpm_suspend。如果系统连 mem 都不支持多半是平台层没有注册 suspend_ops或者 platform_suspend_ops 的 valid 回调拒绝了 mem。排查快得很先看平台驱动有没有定义 suspend_set_ops 相关代码。wakeup_count 是调防误唤醒旋钮的地方。用户态脚本在挂起前先读一次 wakeup_count拿到一个计数内核真正进入睡眠前会检查读到的计数和当前计数是否一致。如果睡眠过程中恰好来了唤醒事件计数变了这次 suspend 就会被中止避免刚躺下就被踹醒的死循环。这个机制救过很多次嵌入式设备的电池寿命。3.2 状态切换时谁会收到通知你写 echo mem /sys/power/statePM Core 不是直接就把设备全关了而是先通知一批关注系统状态的模块。这个机制叫 pm_notifier。内核里不少模块通过 register_pm_notifier 注册回调等 PM_SUSPEND_PREPARE 事件时保存自己的状态等 PM_POST_SUSPEND 事件时恢复。比如某些 GPU 驱动需要在这个时机切换显存供电策略某些音频驱动要在这个点保存 codec 状态。除了 notifier还有一组设备回调需要理解总线驱动层的行为。I2C 总线的 i2c_device_pm、PCI 总线的 pci_pm 都是总线层的默认 PM 实现。当设备自己的 dev_pm_ops 为空时总线层默认回调会接管做基础 PM 操作。这就是为什么驱动不写任何 PM 代码设备也能跟着挂起——总线兜底了。但这往往也是漏电问题的来源因为总线默认挂起只是复位设备、禁用中断级别并不会认真关掉外设的电源轨。这里可以回答一个面试常见题PM notifier 与 dev_pm_ops 的区别。前者是全局状态订阅一套系统只有一套后者是每个设备/驱动自己的回调表。notifier 适合系统级准备与收尾dev_pm_ops 适合设备级操作。把这个区别讲清楚比背多少概念都有用。4. 设备层接口驱动接入功耗框架的入口4.1 dev_pm_ops 里的回调矩阵驱动工程师打交道最多的就是 dev_pm_ops定义在 include/linux/pm.h。系统级挂起相关回调如下回调调用阶段典型用途preparesuspend_prepare 阶段检查设备能否挂起suspenddpm_suspend 阶段保存寄存器、停止数据流suspend_latedpm_suspend_late依赖时钟较晚关闭时用suspend_noirq中断关闭后对必须最后操作的硬件下电resume_noirq中断重新打开前最早恢复适合时钟/中断控制器resume_early中断打开后早期与 suspend_late 对应resumedpm_resume 阶段恢复寄存器、重启数据流completedpm_complete 阶段收尾与通知上层另外还有 runtime 三件套runtime_suspend、runtime_resume、runtime_idle。这套语义由设备级运行时事件触发不由系统睡眠触发。很多人把系统挂起的 suspend 和 runtime_suspend 混为一谈这是电源管理里最常踩的坑。系统挂起是全局事件会经历 noirq 阶段、冻结进程runtime_suspend 是设备自己的事中断照常RCU 照常只是设备被关了。4.2 最小接入示例假设我有一个平台设备需要实现标准挂起/恢复可以这样写#include linux/platform_device.h #include linux/pm_runtime.h static int mydev_suspend(struct device *dev) { struct mydev *d dev_get_drvdata(dev); /* 保存运行状态寄存器唤醒后恢复配置 */ d-saved_ctrl readl(d-base MYDEV_CTRL); /* 关闭时钟这是硬件省电的关键动作 */ clk_disable_unprepare(d-clk); return 0; } static int mydev_resume(struct device *dev) { struct mydev *d dev_get_drvdata(dev); int ret; /* 唤醒后先恢复时钟再写回寄存器 */ ret clk_prepare_enable(d-clk); if (ret) return ret; writel(d-saved_ctrl, d-base MYDEV_CTRL); return 0; } static const struct dev_pm_ops mydev_pm_ops { .suspend mydev_suspend, .resume mydev_resume, .runtime_suspend mydev_suspend, .runtime_resume mydev_resume, }; static struct platform_driver mydev_driver { .probe mydev_probe, .remove mydev_remove, .driver { .name mydev, .pm mydev_pm_ops, }, }; module_platform_driver(mydev_driver);这个例子很朴素但有一个关键提醒把 runtime_suspend/runtime_resume 直接复用为系统级 suspend/resume对很多简单设备确实可行但要注意语义差异。系统级挂起发生在全局 dpm 链路中时钟和中断可能还没全关runtime 挂起发生在设备运行期时钟中断都在。如果设备对先关时钟还是先关中断有严格顺序要求复用就容易出问题。我倾向于至少分开函数哪怕其中一个只是简单调用另一个。4.3 回调顺序设计的深意再往深一步为什么 suspend_noirq 存在有的设备要求在 IRQ 关闭之后才能碰硬件比如一个共享中断的 GPIO 控制器如果它在 IRQ 还开着的时候就被关闭总线上一个毛刺就可能让挂起中途踩到空指针。把这类操作放到 suspend_noirq 阶段就保证了外部中断全部停掉之后我再关你这个寄存器。反过来resume_noirq 阶段比普通 resume 更早执行目的就是先把中断控制器、时钟源这些基础设施恢复好让后续设备的 resume 回调能安全使用中断和时钟。如果某个依赖 clock 的设备写在 resume_noirq而它的时钟控制器反而写在普通 resume那唤醒时几乎必然 crash。记住规律系统挂起时谁的依赖更基础谁就更晚挂起、更早恢复。这本身就是分层设计的一种体现——连回调注册的位置都是一种排序逻辑。5. 一次 suspend-to-RAM 的完整旅程5.1 从 echo mem 到 CPU 睡着的调用链把接口都认识一遍之后走一遍完整流程。假设平台支持 PM_SUSPEND_MEM你在终端执行 echo mem /sys/power/state内核调用路径大致是state_store() - pm_suspend(PM_SUSPEND_MEM) - suspend_prepare() - freeze_processes() // 冻结用户进程与内核线程 - suspend_devices_and_enter() - dpm_suspend_end() - dpm_suspend() // 按设备链表顺序调用 suspend 回调 - dpm_suspend_late() - dpm_suspend_noirq() - suspend_enter() - platform_suspend_ops-enter() // 平台层 PSCI/ACPI 实际睡眠最容易忽视的是 suspend_prepare 里的 freeze_processes 这一步实现在 kernel/power/process.c。它冻结用户空间进程和内核线程确保睡眠期间没有进程在改设备状态。这一步往往也是系统挂起最耗时的部分之一尤其内存压力大的时候。之后的 DPM 三连是核心dpm_suspend、dpm_suspend_late、dpm_suspend_noirq每个阶段遍历一次设备链表调用对应回调。这里的设备顺序不是乱序的而是按依赖关系排序的结果。拿 regulator 和 MMC 的例子来说regulator 在供给端、MMC 在消费端设备链表会把 regulator 排在 MMC 后面于是先挂 MMC再挂 regulator。被依赖方永远后挂起唤醒时则反过来。等走到 suspend_enter 时系统大部分中断已经关闭只剩少量唤醒源还在监听。平台层的 enter 回调最终通过 PSCI 或 ACPI 让硬件进入低功耗状态。到这一步 CPU 自己也停掉只有 RTC 闹钟、按键、网络唤醒包这类唤醒中断能把系统拉起来。5.2 唤醒路径为什么恢复顺序几乎完全倒过来醒来时从哪一步接硬件唤醒后CPU 从异常向量重新启动汇编代码先把栈、页表这些基础环境恢复然后逐级返回平台层 suspend_ops-enter 返回调用 dpm_resume_noirq、dpm_resume_early、dpm_resume整个过程几乎是把挂起流程倒放。倒放的意义在于先重建基础设施。想象一栋大楼停电停电时先断普通住户的电路再断楼道应急照明恢复供电时得先打开应急照明再逐户送电。没有应急照明之前电工在楼道里干活很危险。dpm_resume_noirq 做的就是先开应急照明恢复中断控制器、恢复时钟源保证后续 resume 回调能在正常的中断/时钟环境下运行。设备全部 resume 完成后PM Core 调用 PM_POST_SUSPEND 通知链解冻之前冻结的进程用户看到屏幕重新亮起系统好像什么都没发生。但如果某个驱动的 resume 写错了比如漏恢复时钟系统可能在解冻进程后立刻冒出一堆莫名其妙的超时错误。这类问题最折磨人因为表面现象和 root cause 隔得很远很难一眼想到原来是 resume 没把时钟打开。6. 常见问题与排查技巧实录6.1 设备挂起了电流却下不来这是最典型的功耗 bug。排查顺序我建议这样走先确认系统真的进入了 mem看 dmesg 里的 PM: suspend entry (deep) 和 PM: suspend exit 日志再逐个看外设状态cat /sys/devices/platform/xxx/power/runtime_status确认设备是被挂起还是根本没进状态检查设备树是否配置了 wakeup-source。有些设备不配这个属性系统层不会把它当唤醒源但它仍然会被按普通设备管理最后查驱动代码是否实现了 suspend 回调。没实现时总线层会兜底但兜底往往只是什么都不做或简单复位不会认真关时钟、切电源。我碰到过真实案例一个 LCD 背光驱动suspend 回调只写了个 return 0看起来成功挂起了但背光电源由 GPIO 控制从来没在 suspend 里拉低。结果系统睡下去之后屏幕虽黑控制器关了背光供电还在整机电流凭空多了 200mA。修法就一行在 suspend 里加 gpiod_set_value_cansleep(backlight_gpio, 0)。所以排查漏电光看驱动有没有 suspend 回调不够还得看回调里是不是真的做了硬件动作。6.2 挂起频繁失败或刚睡就醒如果系统总是刚睡就醒先看 /sys/power/wakeup_count。systemd 这类电源管理服务在挂起前会读这个值读取后如果系统又收到唤醒事件挂起会被取消。可以用脚本临时绕过这个保护来定位唤醒源但生产环境不要禁用。更常见的定位途径是查唤醒中断cat /sys/kernel/debug/wakeup_sources这个 debugfs 文件会列出所有注册过的唤醒源以及各自的 wakeup_count、expire_count。如果你看到某个设备的 expire_count 不断增长它基本就是假装唤醒的元凶。注意先打开 CONFIG_PM_DEBUG 和 CONFIG_PM_SLEEP_DEBUG 才能看到这些条目。我调试过一个触控板误唤醒的案子就是靠这个文件查到 GPIO 按键的中断没有正确配置为边沿触发休眠时电平抖动就产生了一次虚假唤醒。6.3 挂起过程慢如何定位耗时设备经常遇到 echo mem 之后要等好几秒才能睡过去这通常不是硬件进入睡眠慢而是某个驱动的 suspend 回调里做了太多事。在开启 CONFIG_PM_SLEEP_DEBUG 的内核里dmesg 会打印每个设备的挂起/恢复耗时dmesg | grep dpm_suspend [ 15.382155] PM: dpm_suspend(): devices_pm_ops-suspend: 442.1 ms [ 15.388420] PM: dpm_suspend(): devices_pm_ops-suspend: i2c-2: 0.3 ms哪个设备花了 400 多毫秒一眼就能看到。很多 USB 控制器、网络控制器会在这里发呆原因是等待链路上的链接断开超时。优化思路一般是把非关键的 shutdown 操作挪到 suspend_noirq或者给无关设备开 async_suspend 让它们并行挂起。开 async_suspend 要谨慎只适合没有依赖关系的设备别在共享总线的设备上乱开否则会把竞态带到板子上。6.4 源码阅读主线与面试答法最后聊一下怎么读 PM Core 的代码最有收获。我的方法是选一台支持 S2RAM 的板子打开 CONFIG_PM_DEBUG从 state_store 往回追调用链把 dpm_suspend 的每一步和 dmesg 日志对应起来。不用一次性读完全部文件先读 main.c 和 suspend.c 就能建立全局观。然后再看 runtime.c对比系统级广播和设备级单播的差异电源管理的很多疑问会迎刃而解。面试被问Linux 电源管理框架怎么分层时别只背模块名。可以这样组织思路PM Core 处于机制层通过 dev_pm_ops 和 dpm 链表统一管理设备挂起恢复cpufreq、cpuidle 属于策略层依赖 PM QoS 获取约束平台代码在最后一环通过 suspend_ops 接入具体硬件。把机制/策略分离和回调顺序设计这两点讲透比罗列一堆文件有说服力得多。现象常见原因排查手段系统进入 mem 后电流偏高驱动没真正关时钟/供电查看 suspend 回调代码量关键电源轨刚睡就自动醒唤醒源误触发查看 /sys/kernel/debug/wakeup_sourcessuspend 过程缓慢设备等待超时dmesg 中 dpm_suspend 时间戳唤醒后设备工作异常resume 没恢复寄存器/时钟resume 回调里打印关键状态个别设备无法 suspend驱动或总线缺少 PM 支持确认 dev_pm_ops 是否注册、设备树属性我自己排查电源问题最大的体会是先看日志再改代码。PM Core 的代码虽然抽象但它留下的日志线索非常直观每个阶段、每个设备都有对应的时间戳和状态输出。把 dmesg 吃透很多问题根本不需要一行行读源码就能先锁定到具体驱动。下一期我打算聊 runtime PM那部分跟普通驱动工程师关系更大。不用动系统休眠就能让设备在空闲时自动进入低功耗这才是嵌入式产品平时省电的主力。到时候结合 autosuspend 的延迟参数聊聊怎么在省电和响应速度之间找平衡。