1. 整机休眠唤醒到底在解决什么问题
聊 system sleep 之前,得先把场景摆清楚。你手上有一台跑 Linux 的设备,可能是笔记本、工控机、车载主机,也可能是手机或者平板。用户按下电源键,或者合上盖子,期望的是"先歇一会儿,回头还能接着用"。这个"歇一会儿"就是 system sleep,也就是整机休眠唤醒。它跟 CPU idle、runtime PM 不是一回事:CPU idle 是单个核心没事干的时候省点电,runtime PM 是某个外设闲着的时候关掉它,而 system sleep 是整台机器进入一个低功耗状态,CPU 停摆、大部分外设断电,只留一小撮硬件维持"能醒过来"的能力。
这个子系统在 Linux 内核里叫电源管理(Power Management,简称 PM),核心代码集中在kernel/power/目录下。它要解决的问题说白了就三个:第一,怎么让整机安全地睡下去,把该保存的状态保存好、该关的设备关掉;第二,睡下去之后怎么把功耗压到最低,最好能到毫瓦级别;第三,怎么在用户按键、收到网络包、定时器到点的时候干净利落地醒过来,恢复到睡前的样子。这三个问题任何一个出岔子,用户看到的就是"睡死"、"醒不来"、"醒来花屏"或者"醒来后 WiFi 没了"。
我接触这个子系统是从做嵌入式项目开始的。当时一块 ARM 板子,休眠之后电流只从 200mA 降到 180mA,等于没睡。查了整整一周才发现是某个 I2C 设备没进 suspend,一直在后台偷偷工作。从那以后我就明白,system sleep 不是内核一个人的事,它是整个设备树、所有驱动、甚至 bootloader 和硬件设计一起配合的结果。这篇文章我会把整机休眠唤醒的机制、代码路径、实操调试方法、常见坑都掰开讲一遍,适合正在做功耗优化、驱动开发、或者被"睡死"问题折磨的同行参考。哪怕你只是好奇 Linux 内核怎么管睡眠,看完也能有个完整的认知。
2. 内核电源管理子系统的整体设计思路
2.1 为什么要有 system sleep 这一层抽象
Linux 内核的电源管理不是一上来就做整机休眠的。最早只有 CPU idle,后来设备多了,才慢慢演化出 runtime PM 和 system sleep。system sleep 这一层抽象的价值在于:它给用户空间和上层框架提供了一个统一的"睡眠"语义,而不用关心底下每个设备具体怎么关。用户往/sys/power/state写一个字符串,比如mem或者disk,内核就把整台机器按既定流程睡下去。这个接口简单到不能再简单,但背后是一整套状态机。
为什么这么设计?因为整机休眠涉及的设备太多,如果让每个子系统自己管自己,顺序、依赖、唤醒源会乱成一锅粥。内核的做法是定义一个全局的休眠流程,所有设备都注册到这条流程上,按统一的顺序 suspend 和 resume。这样 USB、PCI、I2C、SPI 这些总线上的设备,不管驱动是谁写的,都遵循同一套规则。这就是所谓的"统一设备电源管理模型"。
2.2 四种 sleep 状态:freeze、standby、mem、disk
内核支持的 system sleep 状态不止一种,常见的有四个,写在/sys/power/state里:
| 状态字符串 | 名称 | 特点 | 典型功耗 |
|---|---|---|---|
| freeze | Suspend-to-Idle | 只冻结进程,CPU 进 idle,外设不关 | 较高 |
| standby | Standby | 轻度休眠,部分设备关闭 | 中等 |
| mem | Suspend-to-RAM | 状态存内存,除内存外基本断电 | 低 |
| disk | Suspend-to-Disk | 状态写磁盘,整机断电 | 最低(接近关机) |
freeze最轻,基本就是让所有进程停下来,CPU 进 deepest idle,适合快速响应场景。standby在很多平台上和mem差别不大,具体看平台实现。mem是最常用的,也就是我们常说的"睡眠",内存供电维持,其他设备断电,唤醒速度快。disk就是休眠(hibernation),把内存内容写到 swap 或者专门的 swap 文件,然后整机断电,下次开机再读回来,功耗最低但恢复慢。
选哪个状态,取决于你的功耗目标和唤醒延迟要求。做手机的基本用mem,做服务器的可能用disk做快速启动,做 IoT 设备的可能只用freeze就够了。
2.3 核心数据结构:dev_pm_ops 和 suspend_ops
理解 system sleep,绕不开两个结构体。第一个是dev_pm_ops,每个设备驱动通过它注册自己的电源管理回调:
struct dev_pm_ops { int (*prepare)(struct device *dev); void (*complete)(struct device *dev); int (*suspend)(struct device *dev); int (*resume)(struct device *dev); int (*freeze)(struct device *dev); int (*thaw)(struct device *dev); int (*poweroff)(struct device *dev); int (*restore)(struct device *dev); int (*suspend_late)(struct device *dev); int (*resume_early)(struct device *dev); // ... 还有更多 };这些回调不是随便调的,它们分阶段执行。prepare在所有设备 suspend 之前调用,用来做准备工作,比如检查能不能睡。suspend是主回调,真正关设备。suspend_late在中断关闭之后调用,处理那些依赖中断的设备。resume_early和resume是唤醒时的逆过程。freeze/thaw/poweroff/restore是给 hibernation 用的。
第二个是platform_suspend_ops,这是平台相关的,注册在kernel/power/suspend.c里,负责最后把 CPU 送进低功耗状态:
struct platform_suspend_ops { int (*valid)(suspend_state_t state); int (*begin)(suspend_state_t state); int (*prepare_late)(suspend_state_t state); int (*enter)(suspend_state_t state); void (*wake)(void); void (*finish)(void); // ... };enter是核心,它执行 WFI(Wait For Interrupt)或者平台特定的低功耗指令,CPU 在这里停住,直到有唤醒事件。
2.4 设备 suspend 的顺序为什么这么重要
设备 suspend 的顺序不是随便定的,内核用设备树和注册顺序来决定。基本原则是:父设备后于子设备 suspend,先于子设备 resume。为什么?因为子设备往往依赖父设备提供的资源,比如一个 I2C 从设备依赖 I2C 控制器,如果控制器先睡了,从设备就没法正常关闭。
内核通过dpm_list这个链表管理设备顺序,suspend 时从链表头往尾走,resume 时反过来。这个链表在设备注册时构建,保证父子关系正确。如果你写的驱动 suspend 顺序不对,就会出现"父设备已关,子设备还在访问寄存器"的情况,轻则报错,重则挂死。
提示:调试 suspend 顺序问题时,可以打开
CONFIG_PM_DEBUG和CONFIG_PM_SLEEP_DEBUG,内核会打印每个设备的 suspend/resume 过程,非常直观。
3. 休眠唤醒的完整代码路径拆解
3.1 从用户空间写 /sys/power/state 开始
用户执行echo mem > /sys/power/state,这个写操作最终进到kernel/power/main.c的state_store函数。它解析字符串,找到对应的suspend_state_t,然后调用pm_suspend(state)。pm_suspend在kernel/power/suspend.c里,是整个休眠流程的入口。
int pm_suspend(suspend_state_t state) { int error; if (state <= PM_SUSPEND_ON || state >= PM_SUSPEND_MAX) return -EINVAL; pr_info("PM: suspend entry (%s)\n", mem_sleep_labels[state]); pm_prepare_console(); error = enter_state(state); // ... return error; }enter_state是真正干活的地方,它按顺序调用suspend_prepare、suspend_devices_and_enter、suspend_finish。这三个函数分别对应"准备"、"设备休眠并进低功耗"、"收尾"三个阶段。
3.2 suspend_prepare:冻结进程和同步文件系统
suspend_prepare做的第一件大事是冻结用户进程。内核调用freeze_processes,把所有可冻结的进程(除了内核线程和标记了PF_NOFREEZE的)挂起。为什么要冻结?因为休眠过程中如果用户进程还在跑,可能触发文件系统写、网络请求,导致状态不一致。冻结之后,内核再调用sync把脏页刷回磁盘,保证数据安全。
这一步还有个细节:pm_prepare_console会切换到一个特殊的控制台,避免休眠过程中的打印干扰。同时dpm_suspend_start会调用所有设备的prepare回调,让驱动有机会拒绝休眠。比如某个驱动发现设备正在传输数据,可以返回-EBUSY,内核就会中止休眠。
3.3 suspend_devices_and_enter:设备休眠与 CPU 停摆
这是最核心的阶段。流程大致是:
dpm_suspend_start调用所有设备的prepare和suspend回调。suspend_enter里先调用dpm_suspend_late,执行suspend_late回调。- 关闭非唤醒中断,调用
dpm_suspend_noirq,执行suspend_noirq回调。 - 调用
platform_suspend_ops->prepare_late,做平台相关准备。 - 调用
platform_suspend_ops->enter,CPU 进入低功耗状态,等待唤醒。 - 唤醒后,逆序执行
resume_noirq、resume_early、resume。
dpm_suspend_noirq这一步很关键,它在中断关闭之后执行,处理那些必须在无中断环境下关闭的设备。比如某些中断控制器、时钟控制器,必须在所有中断源关闭之后才能安全断电。
3.4 唤醒源与中断唤醒机制
设备怎么唤醒系统?靠中断。但不是所有中断都能唤醒,只有标记为enable_irq_wake的中断才行。驱动在 suspend 时调用enable_irq_wake(irq),告诉中断控制器"这个中断要能唤醒系统"。硬件上,中断控制器会把这个中断配置成唤醒源,即使 CPU 睡了,中断控制器也能检测到信号并触发唤醒。
常见的唤醒源有:电源键、RTC 定时器、网络唤醒(Wake-on-LAN)、USB 插入检测、触摸屏。每个唤醒源在设备树里通常有wakeup-source属性,驱动解析后决定是否启用。
static int my_device_suspend(struct device *dev) { struct my_dev *mdev = dev_get_drvdata(dev); if (device_may_wakeup(dev)) enable_irq_wake(mdev->irq); // 关闭设备 return 0; }device_may_wakeup检查设备是否被允许作为唤醒源,这个由用户空间通过/sys/devices/.../power/wakeup控制。
3.5 resume 路径:从唤醒中断到系统恢复
唤醒发生时,CPU 从中断向量表跳回platform_suspend_ops->enter之后的位置,然后开始 resume 流程。顺序和 suspend 完全相反:先resume_noirq,再resume_early,再resume,最后complete。每个设备的resume回调负责恢复寄存器、重新初始化硬件、恢复中断。
这里有个容易踩的坑:resume 时设备可能还没完全上电稳定,驱动如果立刻访问寄存器可能失败。所以很多驱动会在resume_early里做最小恢复,在resume里做完整恢复。另外,resume 过程中如果某个设备失败,内核会打印错误但继续恢复其他设备,避免整个系统卡死。
4. 实操:从零配置一次整机休眠
4.1 检查内核配置和平台支持
第一步是确认内核支持 system sleep。检查.config里这几个选项:
CONFIG_PM_SLEEP=y CONFIG_SUSPEND=y CONFIG_HIBERNATION=y # 如果需要 disk 休眠 CONFIG_PM_DEBUG=y # 调试用 CONFIG_PM_SLEEP_DEBUG=y # 调试用然后看/sys/power/state里有哪些状态:
cat /sys/power/state # 输出示例:freeze standby mem disk如果只有freeze,说明平台没注册mem的platform_suspend_ops,需要检查平台代码或者设备树。
4.2 设备树里配置唤醒源
以 ARM 平台为例,设备树里要给唤醒源设备加wakeup-source属性:
&gpio_keys { power_key { gpios = <&gpio0 5 GPIO_ACTIVE_LOW>; linux,code = <KEY_POWER>; wakeup-source; }; }; &rtc { wakeup-source; };驱动解析wakeup-source后,会调用device_init_wakeup(dev, true),这样device_may_wakeup才会返回真。
4.3 驱动里实现 suspend/resume 回调
一个典型的驱动 suspend/resume 实现:
static int mydev_suspend(struct device *dev) { struct mydev *m = dev_get_drvdata(dev); if (device_may_wakeup(dev)) enable_irq_wake(m->irq); disable_irq(m->irq); clk_disable_unprepare(m->clk); regulator_disable(m->vdd); return 0; } static int mydev_resume(struct device *dev) { struct mydev *m = dev_get_drvdata(dev); regulator_enable(m->vdd); clk_prepare_enable(m->clk); enable_irq(m->irq); if (device_may_wakeup(dev)) disable_irq_wake(m->irq); // 恢复寄存器 mydev_restore_regs(m); return 0; } static const struct dev_pm_ops mydev_pm_ops = { .suspend = mydev_suspend, .resume = mydev_resume, };注意顺序:suspend 时先关中断再关时钟和电源,resume 时反过来。这个顺序错了,硬件可能进入异常状态。
4.4 用户空间触发休眠和唤醒
配置好之后,触发休眠:
# 进入 mem 休眠 echo mem > /sys/power/state # 或者用 systemd systemctl suspend唤醒通常靠电源键或者 RTC。测试 RTC 唤醒:
# 设置 10 秒后唤醒 echo +10 > /sys/class/rtc/rtc0/wakealarm echo mem > /sys/power/state10 秒后系统应该自动醒来。如果没醒,检查 RTC 驱动是否支持唤醒、wakealarm是否设置成功。
4.5 用 ftrace 和 pm_debug 追踪流程
调试休眠问题,ftrace 是利器。打开pm_suspend和pm_resume的 trace:
echo 1 > /sys/power/pm_debug_messages echo function > /sys/kernel/debug/tracing/current_tracer echo dpm_* > /sys/kernel/debug/tracing/set_ftrace_filter echo 1 > /sys/kernel/debug/tracing/tracing_on echo mem > /sys/power/state cat /sys/kernel/debug/tracing/trace这样能看到每个设备的 suspend/resume 调用顺序和耗时,快速定位是哪个设备卡住。
5. 常见问题与排查技巧实录
5.1 睡死、醒不来、醒来花屏的排查思路
"睡死"是最常见的问题,表现是系统进入休眠后再也醒不来。排查思路:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全无响应 | 唤醒源未配置 | 检查enable_irq_wake和wakeup-source |
| 电源键无反应 | 电源键驱动 suspend 顺序错 | 看 dmesg 里 suspend 日志 |
| 醒来花屏 | 显示控制器 resume 顺序错 | 检查 DRM 驱动 resume 回调 |
| 醒来 WiFi 丢失 | 网络驱动 resume 失败 | 看 `dmesg |
| 随机睡死 | 竞态条件 | 打开CONFIG_DEBUG_ATOMIC_SLEEP |
我遇到过一次睡死,查了三天,最后发现是某个 GPIO 中断在 suspend 时没关,一直触发,导致 CPU 反复唤醒又睡下,最后卡在中断处理里。解决办法是在 suspend 回调里disable_irq,resume 时再enable_irq。
5.2 设备 suspend 顺序错误的典型表现
顺序错误的表现很有规律:如果父设备先 suspend,子设备后 suspend,子设备访问父设备寄存器时会失败,dmesg 里会出现-ENODEV或者 timeout。反过来 resume 时,如果父设备后 resume,子设备先 resume,同样会失败。
解决办法是检查设备树的父子关系,确保dpm_list顺序正确。如果设备树关系没问题,可能是驱动注册顺序问题,可以用device_link_add显式建立依赖。
5.3 唤醒源不生效的几种原因
唤醒源不生效,常见原因有:
- 设备树没加
wakeup-source,驱动没调device_init_wakeup。 - 驱动 suspend 时没调
enable_irq_wake。 - 中断控制器不支持唤醒,或者唤醒配置被覆盖。
- 用户空间把
/sys/devices/.../power/wakeup设成了disabled。
排查时先看cat /sys/devices/.../power/wakeup,确认是enabled。然后看/proc/interrupts里对应中断的唤醒计数。
5.4 调试工具速查表
| 工具 | 用途 | 命令示例 |
|---|---|---|
| pm_debug_messages | 打印 PM 调试信息 | echo 1 > /sys/power/pm_debug_messages |
| ftrace | 追踪函数调用 | echo function > current_tracer |
| dmesg | 看内核日志 | `dmesg |
| /sys/power/state | 查看支持状态 | cat /sys/power/state |
| /sys/power/wakeup_count | 唤醒计数 | cat /sys/power/wakeup_count |
| /proc/interrupts | 中断统计 | cat /proc/interrupts |
提示:调试休眠问题时,建议先用
freeze状态测试,它最轻量,出问题容易恢复。确认freeze没问题再试mem。
6. 功耗优化与进阶技巧
6.1 如何测量休眠功耗
测量休眠功耗需要硬件支持,通常用电流表串在电源线上。软件上可以看/sys/power/pm_debug_messages和平台提供的功耗统计。有些平台有 PMIC(电源管理芯片),可以读寄存器获取各路电源的电流。
实测时要注意:休眠后等 10 秒再读数,因为有些设备有延迟关闭。另外,唤醒源的待机功耗也要算进去,比如 RTC 一直在跑,功耗不会为零。
6.2 减少唤醒源以降低待机功耗
每个唤醒源都要维持一定的硬件电路工作,功耗叠加起来很可观。优化思路是:只保留必要的唤醒源,比如电源键和 RTC,其他如网络唤醒、USB 唤醒如果不需要就关掉。
# 关闭某个设备的唤醒能力 echo disabled > /sys/devices/.../power/wakeup在设备树里也可以直接去掉wakeup-source属性。
6.3 深入理解 wakeup_count 和 autosleep
/sys/power/wakeup_count是一个原子计数器,每次有唤醒事件就加一。用户空间可以读它,然后写回去,如果值没变说明没有新唤醒事件,可以安全休眠。这个机制用于避免"刚睡下就被唤醒"的竞态。
autosleep是内核自动休眠机制,往/sys/power/autosleep写mem,内核会在系统空闲时自动休眠。它依赖wakeup_count和wakeup_sources框架,适合移动设备。
6.4 从 suspend 到 runtime PM 的协同
system sleep 和 runtime PM 不是孤立的。设备在 system suspend 之前,如果 runtime PM 已经把它关了,suspend 回调可能就不用做太多事。反过来,resume 之后 runtime PM 会重新接管。
协同的关键是pm_runtime_force_suspend和pm_runtime_force_resume这两个函数,它们让驱动在 system sleep 时复用 runtime PM 的逻辑,减少代码重复。
static int mydev_suspend(struct device *dev) { return pm_runtime_force_suspend(dev); } static int mydev_resume(struct device *dev) { return pm_runtime_force_resume(dev); }这样驱动只需要实现 runtime PM 回调,system sleep 自动复用。
我在实际项目里踩过最大的坑,是低估了设备树和驱动配合的复杂度。有一次一个 I2C 触摸屏,休眠后总是自己唤醒,查了半天发现是触摸屏的中断引脚在 suspend 时没拉低,一直触发。后来在驱动 suspend 里加了一句gpiod_set_value(m->irq_gpio, 0)才解决。这种问题文档里不会写,只能靠一行行看代码和实测。所以做功耗优化,耐心和细致比什么都重要。