☰
Linux hibernation 休眠唤醒全流程解析:dev_pm_ops 回调顺序与驱动实现避坑指南
2026/10/9 1:03:31 网站建设 项目流程

1. 从一次休眠唤醒失败说起:hibernation 到底在做什么

前阵子调一块嵌入式板子的待机功耗,遇到一个很典型的问题:系统进入 hibernation 之后,按电源键唤醒,屏幕亮了,但 USB 控制器直接失联,插什么设备都没反应。日志里干干净净,连个报错都没有。这种问题最折磨人,因为它不是崩溃,是“看起来正常但功能残废”。后来一路追到dev_pm_ops的回调顺序,才发现是某个驱动在thaw阶段恢复寄存器的时机不对,被后面的restore覆盖掉了。

这件事让我意识到,很多人对 hibernation 的理解停留在“把内存写到磁盘然后关机”这个层面,但真正决定成败的,是内核功耗子系统里那一整套回调的编排逻辑。你如果只是会用systemctl hibernate,那确实不需要懂这些;但只要你碰的是嵌入式、国产化平台、或者需要深度定制电源管理的场景,hibernation 的每一个阶段、每一个dev_pm_ops回调的先后顺序,都会直接决定你的设备能不能稳定唤醒。

这篇内容我打算把 hibernation 的完整过程从头到尾梳理一遍,重点放在内核功耗子系统是怎么组织这件事的,dev_pm_ops在哪些阶段被调用、调用顺序是什么、每个阶段驱动该做什么不该做什么。适合已经有一定 Linux 内核基础、正在做电源管理相关开发或者调试待机功耗问题的朋友。如果你连dev_pm_ops是什么都还没概念,建议先去看看设备模型和驱动基础,不然读起来会比较吃力。

先把结论摆前面:hibernation 不是一个单一动作,它是一条由freeze、prepare、snapshot、power_down、restore、thaw、complete等多个阶段串起来的流水线,每个阶段都会遍历设备树并调用对应的dev_pm_ops回调。理解这条流水线,比记住任何一个 API 都重要。

2. hibernation 的整体设计与阶段划分

2.1 为什么 hibernation 要拆成这么多阶段

先想一个问题:如果让你设计 hibernation,你会怎么做?最直觉的方案是——把内存内容整个 dump 到 swap 分区,然后断电;唤醒时再从 swap 读回来,覆盖内存,继续执行。听起来两步就够了。

但真实内核不能这么干。原因在于,内存里存的不只是你的应用程序数据,还有大量硬件状态:中断控制器的配置、时钟树的开关、电源域的电压、各种外设的寄存器。这些东西在断电之后全部丢失,而且它们不是简单“写回内存”就能恢复的,必须由各自的驱动重新初始化。更麻烦的是,有些设备在 snapshot 之前就必须先停下来,否则你 dump 到一半,DMA 还在往内存里写数据,快照就是脏的。

所以内核把整个过程拆成了多个阶段,每个阶段解决一类问题:

  • 冻结阶段:让所有设备停止活动,不再产生新的中断和 DMA,保证内存状态是“静止”的。
  • 快照阶段:把静止的内存内容写入持久化存储。
  • 断电阶段:真正关闭设备电源,进入最低功耗状态。
  • 恢复阶段:从存储读回内存,然后按相反顺序把设备重新唤醒。

这套设计的核心思想是“有序停机、逆序恢复”。你停的时候先停上层再停下层,恢复的时候先恢复下层再恢复上层,这样依赖关系才不会错乱。比如一个 USB 主机控制器下面挂着 hub,hub 下面挂着设备,你恢复的时候必须先让控制器就绪,hub 才能枚举,设备才能工作。顺序反了,设备就找不到了。

2.2 阶段总览与 dev_pm_ops 的对应关系

内核里这套流程由kernel/power/目录下的代码主导,核心文件是hibernate.c、main.c、snapshot.c和swap.c。设备层面的回调则统一走dev_pm_ops结构体,它定义在include/linux/pm.h里。

dev_pm_ops中和 hibernation 相关的回调主要有这几组:

回调组触发阶段典型用途
prepare/complete快照前后分配快照所需资源、做不可逆的准备工作
freeze/thaw冻结与解冻停止设备活动,保存易失状态
freeze_noirq/thaw_noirq中断关闭后的冻结处理需要关中断才能安全操作的寄存器
poweroff/restore断电与恢复保存/恢复设备完整硬件状态
poweroff_noirq/restore_noirq中断关闭后的断电恢复处理中断控制器、时钟等底层资源
suspend/resume部分场景复用某些驱动用 suspend 路径处理 hibernation

这里有个容易混淆的点:freeze和poweroff看起来都是“停设备”,区别在哪?简单说,freeze是“软停”,目标是让设备不再动,但电源还在,寄存器内容可能还在;poweroff是“硬停”,目标是设备即将彻底断电,所以必须把所有需要保留的状态都存到内存里。freeze阶段失败可以回滚,poweroff阶段基本就是一条路走到黑。

注意:不是所有驱动都需要实现全部回调。很多简单驱动只实现suspend/resume,内核会自动把它们映射到 hibernation 流程里。但如果你做的是复杂外设,尤其是涉及 DMA、中断、时钟域的,最好把freeze/poweroff分开实现,否则很容易在恢复时出现状态不一致。

2.3 用户态触发路径与内核入口

从用户态看,触发 hibernation 的方式有好几种:echo disk > /sys/power/state、systemctl hibernate、或者直接调ioctl。它们最终都会走到kernel/power/main.c里的state_store(),然后根据传入的字符串分发到hibernate()。

hibernate()函数是整个流程的总调度,它做的事情大致是:

  1. 检查是否满足 hibernation 条件(比如 swap 空间够不够、是否有足够内存做快照)。
  2. 调用hibernation_snapshot()完成冻结和快照。
  3. 如果快照成功,调用hibernation_platform_enter()或power_down()进入断电。
  4. 唤醒后从hibernation_restore()走恢复流程。

这里有个细节值得说:hibernation_snapshot()返回之后,如果快照失败,系统会走hibernation_finish()把设备解冻,回到正常运行状态。也就是说,快照阶段是可回滚的。但一旦进入power_down(),就没有回头路了,要么成功唤醒,要么就是一次冷启动。

3. 冻结与快照:hibernation 最关键的阶段

3.1 freeze 阶段到底冻结了什么

freeze阶段的目标只有一个:让系统进入一个“静止”状态,保证接下来做内存快照时,内存内容不会变。听起来简单,做起来很麻烦,因为“静止”意味着:

  • 所有设备停止发起新的 DMA。
  • 所有中断要么被屏蔽,要么被处理完。
  • 所有内核线程要么被冻结,要么进入安全点。
  • 文件系统要么被同步,要么被冻结。

内核用freeze_processes()冻结用户进程和部分内核线程,用dpm_freeze()遍历设备树调用freeze回调。设备遍历的顺序是从根节点往下,先父后子。这个顺序很重要:父设备先冻结,子设备再冻结,这样父设备在冻结时还能正常访问子设备。

freeze回调里驱动该做什么?典型操作包括:

  • 停止设备的工作队列。
  • 关闭 DMA 通道。
  • 保存必要的寄存器到内存。
  • 把设备置为低功耗状态(但不断电)。

不该做什么?不要在freeze里做不可逆的操作,比如释放内存、注销设备、关闭时钟源。因为freeze可能失败,失败后要能回滚。我见过有人在freeze里直接把时钟关了,结果thaw的时候发现时钟源没了,整个系统挂死。

3.2 snapshot 阶段的内存快照机制

冻结完成后,进入snapshot阶段。这个阶段的核心是snapshot_image(),它把当前内存内容复制到一个“快照页”集合里,然后再把这些页写入 swap 分区。

这里有个很巧妙的设计:内核并不是把整个物理内存都 dump 出去,而是只 dump “正在使用的页”。它通过遍历mem_map,检查每个页的引用计数和标志位,跳过空闲页、保留页和不需要保存的页。这样能大幅减少快照体积,也能加快写入速度。

快照页的分配本身也是个技术活。内核需要足够的内存来存放快照,但又不能占用太多,否则会影响正常业务。所以它有一套“内存预算”机制:先计算需要多少页,然后尝试分配,如果分配不到就触发内存回收,实在不行就失败返回。这也是为什么 hibernation 有时候会报“Not enough free memory”的原因——不是 swap 不够,是内存里腾不出足够的空间做快照。

实操心得:如果你在嵌入式设备上做 hibernation,建议把 swap 分区设得比物理内存大 20% 左右。因为快照不只是存内存内容,还要存一些元数据和压缩后的页。设得太紧,容易出现“快照写到一半空间不够”的尴尬情况。

3.3 快照写入 swap 的细节与性能考量

快照写入 swap 的过程由swap_writer完成,它支持两种模式:直接写和压缩写。压缩写会先用 LZO 或 LZ4 压缩每一页,再写入 swap。压缩的好处是减少 I/O 量,加快写入速度,代价是消耗 CPU。在嵌入式设备上,如果 CPU 性能有限但存储速度慢,压缩通常是划算的。

写入过程中,内核会定期调用dpm_suspend()之类的钩子吗?不会。快照写入阶段设备已经冻结了,不会再调用设备回调。但内核会处理一些特殊情况,比如如果写入过程中发生 I/O 错误,它会尝试重试,重试失败则中止 hibernation 并回滚。

这里有个容易被忽略的点:快照写入的 I/O 路径本身也需要设备支持。如果你的存储控制器在freeze阶段被冻得太死,导致写 swap 的时候无法工作,那整个流程就卡住了。所以存储相关的驱动在实现freeze回调时,要确保设备仍然能响应 I/O 请求,只是不再接受新的上层请求。

4. 断电、恢复与 dev_pm_ops 回调顺序实录

4.1 power_down 阶段:真正断电前做了什么

快照写入成功后,进入power_down阶段。这个阶段会调用dpm_poweroff(),遍历设备树调用poweroff回调。和freeze不同,poweroff是“最后的机会”,驱动需要在这里把设备的完整硬件状态保存到内存,因为接下来设备就要彻底断电了。

poweroff回调里典型操作包括:

  • 保存所有需要保留的寄存器值。
  • 配置唤醒源,确保设备能在特定事件下唤醒系统。
  • 关闭设备电源域。
  • 如果是中断控制器,还需要保存中断配置。

顺序上,poweroff是从根节点往下遍历,和freeze一致。但poweroff_noirq是在中断关闭之后调用的,这时候不能再依赖中断,所有操作必须是轮询或直接寄存器访问。

我踩过的一个坑:某款 PMIC 驱动在poweroff里把 I2C 控制器也关了,结果后面poweroff_noirq阶段想通过 I2C 写寄存器时发现总线不通。后来改成在poweroff里只标记状态,真正的寄存器写入放到poweroff_noirq之前完成,才解决。

4.2 restore 阶段:唤醒后的逆序恢复

系统被唤醒后,首先执行的是 bootloader 和内核的早期初始化,然后检测到存在有效的 hibernation 镜像,跳转到hibernation_restore()。恢复流程基本是断电流程的逆操作:

  1. dpm_restore_noirq():中断还没开,先恢复底层设备。
  2. dpm_restore():中断已开,恢复常规设备。
  3. dpm_thaw_noirq()和dpm_thaw():解冻设备。
  4. dpm_complete():完成收尾工作。

恢复顺序是逆序的:先恢复子设备,再恢复父设备。因为父设备可能依赖子设备提供的信息,比如枚举结果。这个逆序规则贯穿整个dev_pm_ops调用链,是理解 hibernation 的关键。

restore回调里驱动该做什么?把poweroff里保存的寄存器值写回去,重新初始化硬件,恢复中断配置。注意,restore阶段内存已经恢复成快照时的状态,所以驱动可以直接访问之前保存的状态变量,不需要重新计算。

4.3 一张表看清 dev_pm_ops 各回调的调用顺序

下面这张表是我根据dpm_list遍历逻辑整理的,假设设备树是“根 -> 父 -> 子”的结构:

阶段遍历顺序回调中断状态
冻结根->父->子prepare开
冻结根->父->子freeze开
冻结根->父->子freeze_noirq关
快照不遍历设备-关
断电根->父->子poweroff_noirq关
断电根->父->子poweroff关
恢复子->父->根restore_noirq关
恢复子->父->根restore开
解冻子->父->根thaw_noirq关
解冻子->父->根thaw开
完成子->父->根complete开

这张表建议存下来,调试的时候对着看,基本能定位到是哪个阶段出的问题。

5. 常见问题与排查技巧实录

5.1 唤醒后设备失联的排查思路

回到开头那个 USB 失联的问题。排查这类问题,我一般按这个顺序走:

  1. 确认唤醒是否真的成功:看串口日志有没有走到hibernation_restore(),还是直接冷启动了。如果直接冷启动,说明快照根本没被识别,问题在 bootloader 或镜像签名。
  2. 确认设备是否被枚举:lsusb能不能看到控制器,dmesg有没有枚举日志。如果控制器在但设备不在,问题在 hub 或下游设备。
  3. 检查restore回调返回值:在驱动里加打印,看restore是否被调用、返回值是什么。如果返回错误,内核会跳过后续恢复。
  4. 对比poweroff和restore的寄存器快照:把关键寄存器的值在poweroff前和restore后各打一遍,看哪些位对不上。

那次问题的根因是:USB 控制器驱动在restore里先恢复了端口状态,但 PHY 的时钟还没使能,导致端口状态写不进去。后来把 PHY 恢复挪到restore_noirq里,问题解决。

5.2 快照失败与内存不足的处理

快照失败最常见的原因是内存不足。内核日志里会打PM: Not enough free memory或者PM: Cannot allocate snapshot image。这时候可以尝试:

  • 增大 swap 分区。
  • 在触发 hibernation 前手动echo 3 > /proc/sys/vm/drop_caches释放缓存。
  • 调整/sys/power/image_size,限制快照镜像大小。

image_size这个参数很实用,它告诉内核“最多用多少内存做快照”。设小一点,快照更容易成功,但恢复时可能需要重新读一些页,速度稍慢。设大一点,快照成功率高,但占用内存多。我一般设成物理内存的 60% 左右,兼顾成功率和性能。

5.3 驱动实现 dev_pm_ops 的避坑清单

最后整理一份避坑清单,都是实际调试中踩过的:

  • 不要在freeze里释放资源:freeze可能失败回滚,释放了就回不来。
  • poweroff和restore要成对:poweroff里保存了什么,restore里就要恢复什么,别漏。
  • 注意noirq阶段的限制:不能睡眠、不能等中断、不能用可能阻塞的锁。
  • 唤醒源配置要在poweroff之前完成:否则设备断电后无法唤醒。
  • complete里做收尾,不要做重初始化:重初始化应该在restore里做完。
  • 用dev_pm_ops而不是 legacy 回调:legacy 的suspend/resume在 hibernation 里行为不一致,容易出问题。

提示:调试 hibernation 时,可以在内核命令行加pm_debug_messages和no_console_suspend,这样能看到完整的阶段切换日志,定位问题快很多。

这套流程我前后调了大概两周,从完全懵到能画出完整的回调时序图,最大的体会是:hibernation 的难点不在某一个 API,而在阶段之间的衔接和顺序。你把顺序理清了,大部分问题都能自己定位。后面如果要做国产化平台的电源管理适配,这套逻辑基本是通用的,换的只是具体驱动的实现细节。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询