1. 从一次休眠唤醒失败说起:hibernation 到底在做什么
很多人第一次接触 Linux 的 hibernation(休眠),都是被一个很具体的需求逼出来的:笔记本合盖之后希望整机断电,第二天打开还能回到原来的工作状态,内存里的东西一个不少。这个需求和 suspend(挂起)最大的区别在于——suspend 只是把大部分硬件关掉,内存还在供电维持数据;而 hibernation 是把内存里的全部内容写到磁盘上,然后整机彻底断电,唤醒时再从磁盘读回来。换句话说,hibernation 是"用磁盘换电"的方案,代价是慢,收益是断电也不丢状态。
我在实际项目里遇到过最典型的一个故障:设备执行echo disk > /sys/power/state之后屏幕黑了,但电源指示灯还亮着,风扇还在转,等了几分钟没有任何反应,只能长按电源键强制关机。重启之后dmesg里能看到一堆PM: Cannot find swap device和PM: Cannot get swap writer的报错。这个现象背后其实牵扯到 hibernation 整条链路的几个关键环节:swap 空间够不够、swap 设备能不能被内核正确识别、dev_pm_ops的回调有没有按顺序执行、镜像写入过程中有没有设备提前掉线。这篇文章就把这条链路从头到尾梳理一遍,重点讲清楚每个阶段内核到底做了什么、哪些地方最容易出问题、出问题之后怎么定位。
需要先说明的是,hibernation 属于 Linux 内核功耗子系统(Power Management)里最复杂的一块,它同时涉及内存管理、块设备、设备模型、ACPI 等多个子系统。本文不会去逐行读源码,而是以"一个从业者在调试 hibernation 问题时需要掌握的完整心智模型"为目标,把流程、关键数据结构、常见坑点讲透。如果你正在做嵌入式 Linux 的电源管理、或者在调试一台机器的休眠唤醒问题,这篇内容应该能帮你少走不少弯路。
关键词里提到的swap、dev_pm_ops、内核功耗子系统是理解整条链路的三个抓手:swap 决定了镜像往哪写、dev_pm_ops决定了设备怎么被冻结和恢复、功耗子系统则提供了sysfs这套用户态接口。下面按执行顺序逐个展开。
2. hibernation 的四个阶段:freeze、snapshot、write、power down
2.1 用户态触发路径与 sysfs 接口
触发 hibernation 最直接的方式是往/sys/power/state写disk:
echo disk > /sys/power/state这个写操作会进入内核的state_store(),最终调用hibernate()。但在这之前,用户态通常还要做几件事:确认/sys/power/disk里的模式(platform、shutdown、reboot、suspend等),确认/sys/power/image_size的限制,以及最关键的——确保有可用的 swap。
/sys/power/disk这个文件决定了 snapshot 写完之后系统怎么进入低功耗。platform表示交给固件(ACPI 的 S4),shutdown表示走正常的关机流程,reboot表示重启。大多数发行版默认是platform,因为唤醒速度最快。但有些机器固件的 S4 实现有问题,改成shutdown反而更稳,代价是唤醒要走一次完整的开机自检。
提示:调试阶段建议先把
/sys/power/disk设成shutdown,排除固件 S4 路径的干扰,确认 snapshot 和 restore 本身没问题之后,再切回platform。
2.2 阶段一:冻结用户进程与内核线程
hibernation 的第一步和 suspend 一样,是 freeze。内核会遍历所有用户态进程,把它们逐个冻结(freeze_processes()),然后冻结可冻结的内核线程(freeze_kernel_threads())。冻结的本质是给进程发一个"假信号",让它们停在安全点上,不再持有任何会阻碍 snapshot 的锁。
这一步最容易出问题的地方是:某个内核线程没有正确实现冻结回调,或者某个驱动在 workqueue 里提交了无法被冻结的任务。表现就是echo disk之后卡在Freezing user space processes ...这一行不动,等很久之后打印Freezing of tasks failed after 20.00 seconds。遇到这种情况,/sys/power/pm_freeze_timeout可以调大超时,但根本解法还是找到那个不配合的线程。
2.3 阶段二:生成内存快照(snapshot)
冻结完成之后,内核调用hibernate_preallocate_memory()预分配内存,然后进入create_image()。这里的核心动作是把当前内存里的所有页(除了标记为nosave的页)复制到一个"快照"里。注意,这个快照不是立刻写到磁盘,而是先在内存里组织好,因为写磁盘的过程中内核自己还要用内存。
快照的大小直接决定了需要多大的 swap。理论上,需要保存的页数乘以页大小就是镜像的原始大小,但实际写入时会做压缩(LZO 或 LZ4),所以最终占用的 swap 通常比原始内存小。/sys/power/image_size就是用来限制镜像大小的,默认值大约是内存的 2/3。如果你把 swap 分得比内存还大,那基本不会因为空间不足失败;如果 swap 比内存小很多,就要靠压缩率来赌了。
2.4 阶段三:把镜像写入 swap
这是整个流程里最"重"的一步。内核通过 swap writer 把快照数据写到 swap 分区或 swap 文件里。写入过程中,所有非 boot 设备会被逐步关闭(通过dev_pm_ops的freeze/poweroff回调),只留下写 swap 需要的那个块设备。
这里有个关键点:写 swap 的设备本身不能被关掉。如果 swap 在 SATA 盘上,那 SATA 控制器和这块盘必须保持供电直到写完。内核通过dpm_suspend()和dpm_poweroff()两个阶段来控制设备关闭的顺序,dev_pm_ops里的回调就是在这两个阶段被调用的。
2.5 阶段四:进入低功耗与唤醒恢复
镜像写完,内核执行power_down(),根据/sys/power/disk的设置走platform或shutdown。唤醒时,bootloader 或固件把控制权交回内核,内核检测到 swap 里有有效的 hibernation 镜像,就进入 restore 流程:重新初始化必要的设备,把镜像读回内存,然后解冻进程,恢复执行。
整个 restore 过程对用户来说是"透明"的——你看到的还是休眠前的桌面,但底层其实经历了一次完整的设备重新初始化和内存重建。这也是为什么 hibernation 对驱动的要求比 suspend 更高:suspend 只是把设备挂起再恢复,而 hibernation 是设备被彻底断电后重新走一遍 probe 流程(部分设备)或者 restore 回调。
3. swap 空间:hibernation 的硬门槛与常见误判
3.1 swap 分区和 swap 文件的差异
swap 可以是独立分区,也可以是文件。两者在 hibernation 场景下的行为有细微差别。swap 分区的好处是内核可以直接通过块设备偏移访问,不需要经过文件系统层,写入路径更短、更可靠。swap 文件则需要内核能定位到文件在磁盘上的物理块,这在有日志、有 CoW 的文件系统(比如 btrfs)上会变得复杂。
我个人的经验是:如果这台机器的主要用途就是跑 hibernation,优先用 swap 分区。如果只能用 swap 文件,ext4 上相对稳妥,btrfs 上要特别小心,因为 btrfs 的 CoW 特性可能导致文件块在写入过程中被重新分配,内核拿到的物理块映射就失效了。
3.2 到底需要多大的 swap
这是被问得最多的问题。一个常见的经验法则是 swap 至少等于内存大小,但这个说法过于粗糙。更准确的做法是看/sys/power/image_size的当前值和实际内存使用量。
| 场景 | 建议 swap 大小 | 说明 |
|---|---|---|
| 内存 8G,日常占用 3G | 4G 以上 | 压缩后镜像通常 1.5G~2.5G |
| 内存 16G,日常占用 10G | 12G 以上 | 高占用场景压缩收益有限 |
| 内存 32G,跑虚拟机 | 建议 32G | 虚拟机内存页压缩率低 |
| 嵌入式 512M | 512M~768M | 留出压缩和元数据余量 |
判断标准其实很简单:/sys/power/image_size默认是内存的 2/3,如果你的 swap 小于这个值,内核会尝试压缩到 swap 能放下为止,压不下就失败。所以最保险的做法是 swap 不小于image_size。
3.3 swap 没被识别:那些让人抓狂的报错
回到开头那个故障。PM: Cannot find swap device这个报错通常意味着内核在 resume 或者 hibernate 时找不到之前用的 swap。原因可能有几种:
- swap 分区在
/etc/fstab里用的是 UUID,但内核命令行resume=参数用的是设备路径,两者对不上; - swap 文件所在的文件系统在 resume 时还没挂载,内核无法解析文件位置;
- 使用了 LVM 或加密卷,resume 时这些层还没初始化。
对于 swap 分区,正确的做法是在内核命令行里加resume=/dev/sdaX(或者resume=UUID=xxx),并且确保这个参数和实际 swap 一致。对于 swap 文件,还需要额外指定resume_offset=,这个偏移量可以用filefrag -v或者swap-offset工具算出来。
注意:
resume=参数写错不会导致系统起不来,但会导致唤醒时找不到镜像,系统会当成一次冷启动,你休眠前的工作全部丢失。这个坑非常隐蔽,因为冷启动看起来"正常",只是数据没了。
4. dev_pm_ops:设备在休眠链路里的角色分工
4.1 dev_pm_ops 的回调集合
struct dev_pm_ops是设备驱动向功耗子系统注册回调的入口,里面和 hibernation 相关的回调主要有这几组:
freeze/thaw:用于 snapshot 之前的冻结和 snapshot 之后的解冻;poweroff/restore:用于写镜像之前的关设备和唤醒之后的恢复;suspend/resume:suspend 路径用,hibernation 的某些阶段也会复用;prepare/complete:在 freeze 之前和 thaw 之后调用,用于做准备工作。
这几组回调的执行顺序是有严格规定的。prepare先于freeze,freeze先于poweroff。唤醒时顺序反过来:restore先于thaw,thaw先于complete。理解这个顺序,是排查"某个设备在休眠后状态不对"类问题的关键。
4.2 为什么有些设备必须实现 restore 而不是 resume
这是 hibernation 和 suspend 最大的差异点之一。suspend 时设备只是进入低功耗状态,寄存器的值可能还保留着,resume回调只需要把设备唤醒即可。但 hibernation 时设备被彻底断电,寄存器全部丢失,唤醒后设备相当于重新上电,restore回调需要把设备重新初始化到休眠前的状态。
如果一个驱动只实现了resume没实现restore,在 hibernation 唤醒后设备可能处于未初始化状态,表现就是网卡不工作、声卡没声音、触摸板失灵。这类问题的排查方法是:在dmesg里找唤醒后设备 probe 相关的日志,看它走的是哪条路径。
4.3 设备关闭顺序与依赖关系
dpm_poweroff()会按照设备树的层级从叶子往根关闭,dpm_restore()则从根往叶子恢复。这个顺序保证了父设备(比如 PCI 控制器)在子设备(比如挂在它下面的网卡)之后关闭、之前恢复。
如果驱动之间的依赖关系没有通过设备树正确表达,就可能出现"父设备已经关了,子设备还在访问它"的情况,表现是写镜像过程中卡死或者报 I/O 错误。这类问题往往需要看dpm_list的实际顺序,可以通过打开CONFIG_PM_DEBUG和CONFIG_PM_ADVANCED_DEBUG来打印详细的设备顺序。
5. 一次完整的排查链路:从卡死到定位
5.1 现象记录与初步判断
假设你遇到的现象是:echo disk > /sys/power/state之后,终端输出停在PM: Syncing filesystems ... done.然后就没有然后了。这时候不要急着重启,先看能不能通过串口或者 SSH 拿到更多信息。如果是本地终端,试试Alt+SysRq组合键,SysRq的w可以打印当前所有任务栈,SysRq的t可以打印所有线程栈。
拿到任务栈之后,重点看有没有任务卡在hibernate()相关的调用链上,比如create_image、swap_writepage、dpm_poweroff。这一步的目的是判断卡在哪个阶段。
5.2 分阶段验证
如果卡在 freeze 阶段,问题多半在进程冻结。检查/sys/power/pm_freeze_timeout,调大之后重试,看是否能过。如果还是不行,用echo 1 > /sys/power/pm_debug_messages打开详细日志,看是哪个任务没冻结。
如果卡在 snapshot 阶段,通常是内存不足或者有页无法被保存。检查dmesg里有没有PM: Not enough free memory之类的报错。这时候可以尝试减小/sys/power/image_size,让内核少保存一些页。
如果卡在写 swap 阶段,问题多半在块设备或者 swap 本身。检查dmesg里有没有 I/O 错误,确认 swap 设备在写镜像期间保持可用。
5.3 用 ftrace 追踪关键函数
对于更隐蔽的问题,ftrace 是利器。可以这样追踪 hibernation 相关的函数调用:
cd /sys/kernel/debug/tracing echo function > current_tracer echo hibernate > set_ftrace_filter echo 1 > tracing_on echo disk > /sys/power/state # 唤醒后 cat trace这样能看到hibernate路径下所有函数的调用顺序和耗时,快速定位到卡在哪个函数。如果hibernate本身没被追踪到,说明问题发生在更早的阶段,比如state_store或者freeze_processes。
5.4 常见报错与对应处理
| 报错信息 | 可能原因 | 处理方向 |
|---|---|---|
Cannot find swap device | resume 参数错误或 swap 未激活 | 检查内核命令行和 fstab |
Not enough free memory | swap 太小或内存占用过高 | 扩大 swap 或减小 image_size |
Freezing of tasks failed | 某进程/线程无法冻结 | 查任务栈定位 |
PM: Image not found | 镜像写入失败或 resume 参数错 | 检查写入日志和 resume 配置 |
Restarting tasks ... done后设备异常 | 驱动 restore 回调缺失 | 检查驱动 dev_pm_ops |
6. 嵌入式场景下的特殊考量
6.1 内存受限时的策略
嵌入式设备内存通常很小,512M 甚至 256M 很常见。这种场景下 hibernation 的挑战在于:swap 空间可能比内存还小,压缩率成为关键。LZ4 比 LZO 快但压缩率略低,如果 CPU 性能不是瓶颈,选 LZO 能省更多空间。
另一个策略是减少需要保存的页。内核提供了nosave内存区域的概念,一些明确不需要保存的内存(比如 DMA 缓冲区、固件保留区)可以标记为nosave,这样镜像会小很多。具体做法是在设备树或者内核启动参数里配置。
6.2 唤醒源配置
嵌入式设备唤醒通常靠特定的中断源,比如按键、RTC、USB 插入。这些唤醒源需要在设备树里正确配置,并且对应的驱动要支持wakeup能力。如果唤醒源没配好,设备休眠后就"叫不醒",只能断电重启。
配置唤醒源的通用方法是往/sys/devices/.../power/wakeup写enabled,但嵌入式场景下更推荐在设备树里静态配置,避免依赖用户态脚本。
6.3 与 suspend 的取舍
不是所有场景都需要 hibernation。如果设备只是短时间不用,suspend 更快、对驱动要求更低。只有当需要长时间断电、或者电池电量极低需要彻底断电保数据时,hibernation 才有价值。在嵌入式项目里,我通常会把两者都配上,让用户态根据电量和使用场景动态选择。
7. 几个我踩过的坑和对应的经验
第一个坑是 swap 文件放在 btrfs 上。当时图省事,直接在 btrfs 根分区建了个 swap 文件,swapon正常,suspend 也正常,但 hibernation 写入到一半就报 I/O 错误。后来查资料才知道 btrfs 的 CoW 会导致文件块在写入过程中被重新分配,内核预取的物理块映射失效。换成 ext4 的独立分区之后问题消失。这个坑的教训是:hibernation 对 swap 的底层稳定性要求比普通 swap 高得多。
第二个坑是resume=参数用了设备路径,但系统里磁盘顺序会变。有一次机器上插了 U 盘,重启后/dev/sda变成了 U 盘,原来的系统盘变成/dev/sdb,结果resume=/dev/sda2指向了错误的位置,唤醒时找不到镜像,直接冷启动。后来统一改成resume=UUID=xxx,问题再没出现过。UUID 不随设备顺序变化,这是它比设备路径可靠的根本原因。
第三个坑是某个 USB 控制器的驱动没实现restore回调。现象是 hibernation 唤醒后 USB 键盘鼠标全部失灵,但系统本身是活的(能通过网络 SSH 进去)。查驱动代码发现它只实现了resume,而 hibernation 唤醒走的是restore路径,回调为空导致控制器没被重新初始化。补上restore回调之后问题解决。这个案例说明:调试 hibernation 问题时,不能只看"设备有没有被挂起",还要看"唤醒后有没有被正确恢复"。
第四个坑和image_size有关。有一次在一台 16G 内存的机器上,swap 只分了 8G,日常内存占用 6G 左右,按理说够用。但那次刚好开了几个虚拟机,内存占用冲到 12G,hibernation 直接失败。后来把image_size从默认的 2/3 调小到 8G,让内核在 snapshot 阶段就主动丢弃一些可回收页,虽然丢了一些缓存,但至少能成功休眠。这个取舍在内存紧张时很实用。
8. 调试工具与配置清单
把常用的调试手段和配置项整理成一张表,方便排查时对照:
| 工具/配置 | 路径或命令 | 用途 |
|---|---|---|
| power state | /sys/power/state | 触发 suspend/hibernate |
| disk mode | /sys/power/disk | 选择 platform/shutdown |
| image size | /sys/power/image_size | 限制镜像大小 |
| freeze timeout | /sys/power/pm_freeze_timeout | 调整冻结超时 |
| pm debug | /sys/power/pm_debug_messages | 打开详细日志 |
| ftrace | /sys/kernel/debug/tracing | 追踪函数调用 |
| SysRq | Alt+SysRq+w/t | 打印任务栈 |
| resume 参数 | 内核命令行resume= | 指定 swap 位置 |
| swap 状态 | swapon --show、/proc/swaps | 确认 swap 可用 |
配置层面,建议在/etc/default/grub里显式写上resume=UUID=xxx,然后update-grub生效。对于 swap 文件,还要加上resume_offset=。这两个参数是 hibernation 能否正常唤醒的决定性因素,比任何驱动配置都重要。
最后分享一个我常用的验证方法:配置好之后,先手动执行一次systemctl hibernate,等机器完全断电后重新开机,看能否回到休眠前的状态。如果能,再测试合盖休眠、定时休眠等场景。每次改动 swap 或内核参数之后都要重新验证一遍,因为这类配置的错误往往不会立刻暴露,而是在某次唤醒时才突然出现。