4、Hibernate (Suspend-to-Disk) 原理:镜像创建、压缩、写入交换分区
好,咱们接着聊休眠。上一节讲了 Suspend-to-RAM,说白了就是让系统睡个觉,内存还通着电。那 Hibernate 呢?它更彻底——直接把整个系统的状态拍个快照,存到硬盘上,然后彻底断电。下次开机,再把这个快照恢复回来,就像什么都没发生过一样。
我个人觉得,Hibernate 是嵌入式设备里最实用的省电大招。尤其是那些电池供电、又不能频繁关机的设备,比如车载系统、工业平板。你想想看,用户按一下电源键,系统就「消失」了,再按一下,又「复活」了,体验多好。
4.1 镜像创建:给内存拍一张「全家福」
Hibernate 的第一步,就是把当前内存里的所有数据,打包成一个镜像文件。这个镜像,说白了就是内存的完整快照。
但这里有个坑:内存里不光有用户程序,还有内核本身、设备驱动、页表、中断向量表……这些东西怎么处理?
嗯,Linux 的做法很巧妙。它把整个内存空间分成两部分:可恢复区域和不可恢复区域。
- 可恢复区域:用户空间进程、文件缓存、匿名页等。这些数据可以完整保存,恢复时直接加载。
- 不可恢复区域:内核代码段、设备寄存器映射、DMA 缓冲区等。这些数据不能直接保存,因为恢复时硬件状态已经变了。
那内核怎么处理不可恢复区域呢?答案是:不保存。恢复时,内核会重新初始化这些区域。所以,Hibernate 的镜像,实际上只保存了「用户态 + 部分内核态」的数据。
核心要点:Hibernate 镜像不是内存的逐字节拷贝,而是经过筛选的「有效数据」集合。我见过有人误以为镜像就是内存的 raw dump,结果恢复时各种崩溃。
具体实现上,内核会遍历所有进程的页表,把每个进程的虚拟地址空间映射到物理页,然后把这些物理页的内容写入镜像。这个过程叫 snapshot,由 snapshot.c 中的代码完成。
// 伪代码:镜像创建的核心逻辑
for_each_process(p) {
for_each_vma(vma, p->mm) {
for_each_page_in_vma(vma) {
if (page_is_anonymous(page) || page_is_file_cache(page)) {
snapshot_add_page(page);
}
}
}
}
这里要注意,原子性是关键。镜像创建期间,系统不能有内存分配或释放操作,否则镜像就不一致了。所以内核会先冻结所有用户进程,然后才创建镜像。
警告:镜像创建期间,系统会进入一个「准冻结」状态。如果设备有实时性要求(比如音视频播放),Hibernate 可能会导致短暂的卡顿。我曾经在一个车载项目中遇到过,休眠前音乐播放器还在响,休眠后恢复时,音乐直接跳了 2 秒……嗯,后来我们加了缓冲机制才解决。
4.2 压缩:给镜像「瘦身」
镜像创建完了,下一步就是写入磁盘。但问题来了:内存动辄几 GB,写入磁盘要多久?尤其是嵌入式设备,用的还是 eMMC 或 NAND Flash,写入速度本来就慢。
所以,压缩是必须的。Linux 内核支持多种压缩算法,比如 LZO、LZ4、ZSTD。我个人习惯用 LZ4,因为它压缩速度快,解压也快,适合嵌入式场景。
| 压缩算法 | 压缩比 | 压缩速度 | 解压速度 | 适用场景 |
|---|---|---|---|---|
| LZO | 中等 | 快 | 快 | 通用 |
| LZ4 | 中等 | 极快 | 极快 | 嵌入式、实时系统 |
| ZSTD | 高 | 中等 | 快 | 存储空间紧张 |
压缩是在内核态完成的,由 crypto/ 子系统负责。内核会把镜像数据分成一个个 chunk,每个 chunk 独立压缩。这样做的好处是:恢复时可以并行解压,提高速度。
小技巧:如果你用的是 SSD 或 NVMe,压缩反而可能拖慢速度,因为存储本身的带宽已经很高了。我建议在嵌入式设备上开启压缩,在服务器上关闭压缩。怎么配置?内核参数 CONFIG_HIBERNATION_COMPRESSION 就是干这个的。
压缩后的数据,会写入一个临时缓冲区。这个缓冲区的大小,通常等于镜像大小除以压缩比。嗯,这里要注意:如果压缩比太低(比如内存里全是已压缩的数据,像 JPEG 图片),缓冲区可能会溢出。内核会做 fallback,直接写入未压缩的数据。
4.3 写入交换分区:把镜像「存起来」
压缩完的镜像,最终要写入磁盘。Linux 的 Hibernate 默认使用交换分区作为存储位置。为什么是交换分区?因为交换分区是内核直接管理的,不需要文件系统介入,写入速度更快。
但这里有个前提:交换分区的大小必须大于内存大小。否则,镜像写不下。我见过很多新手踩这个坑——交换分区只有 2GB,内存却有 4GB,结果 Hibernate 直接失败。
避坑指南:我曾经在一个项目中,交换分区设成了 4GB,内存也是 4GB,结果 Hibernate 还是失败。为什么?因为镜像压缩后虽然小了,但内核还需要一些额外空间来存储元数据。所以,我建议交换分区至少比内存大 20%。比如 4GB 内存,交换分区设 5GB 比较稳妥。
写入过程是这样的:
- 内核把压缩后的镜像数据,按块写入交换分区。
- 每个块都有一个签名,用于恢复时校验。
- 写入完成后,内核会在交换分区的头部写入一个 hibernate signature,标记这个分区包含有效的休眠镜像。
// 交换分区布局示意
+------------------+
| Hibernate Signature | // 标记休眠镜像存在
+------------------+
| Metadata Block | // 镜像大小、压缩算法、校验和
+------------------+
| Image Data Block 1 |
+------------------+
| Image Data Block 2 |
+------------------+
| ... |
+------------------+
写入完成后,内核会调用 machine_power_off(),彻底断电。这时候,整个系统就「消失」了。
4.4 恢复流程:从磁盘「复活」
下次开机,引导加载器(比如 GRUB)会检测交换分区是否有 Hibernate Signature。如果有,它会把控制权交给内核,内核进入恢复模式。
恢复流程是创建流程的逆过程:
- 读取交换分区的元数据,获取镜像大小、压缩算法等信息。
- 分配一块临时内存,用于加载镜像。
- 从交换分区读取压缩数据,解压到临时内存。
- 把解压后的数据,恢复到原来的物理内存位置。
- 恢复进程的上下文,包括寄存器、页表、文件描述符等。
- 唤醒所有进程,系统继续运行。
这里有个关键点:恢复时,内核会重新初始化设备驱动。所以,设备的状态必须与休眠前一致。如果某个驱动在休眠后发生了变化(比如固件升级),恢复就可能失败。
经验之谈:我遇到过最奇葩的问题——休眠前插着 USB 鼠标,恢复时拔掉了,结果系统直接卡死。后来查代码发现,USB 驱动在恢复时尝试访问一个不存在的设备,导致无限等待。解决方案是:在休眠前卸载所有可热插拔设备的驱动,恢复后再重新加载。
4.5 总结与避坑
好了,Hibernate 的原理就讲到这里。总结几个关键点:
- 镜像创建:只保存可恢复的内存区域,不可恢复区域由内核重新初始化。
- 压缩:LZ4 是嵌入式场景的首选,速度快,解压快。
- 写入交换分区:分区大小至少比内存大 20%,否则可能失败。
- 恢复:设备驱动必须与休眠前一致,否则可能崩溃。
最后,送你一个避坑指南:
我曾经在一个项目中,Hibernate 总是失败,查了三天才发现是交换分区格式不对。原来,内核要求交换分区必须是 swap 类型,不能是 ext4 或 ntfs。所以,记得用 mkswap 格式化分区,然后用 swapon 激活。
下一节,我们会讲 Hibernate 的触发方式,以及如何调试休眠失败的问题。到时候见。