多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Linux内核runtime PM机制详解:从状态机到驱动实战

Linux内核runtime PM机制详解:从状态机到驱动实战 做嵌入式Linux或者驱动开发的朋友应该都遇到过这种场景设备大部分时间闲着但系统级suspend/resume又不敢随便进因为整个系统的休眠唤醒流程太重了动不动就几十上百毫秒从suspend到resume再回到正常工作用户都能感觉到卡一下。但设备一直全速跑着功耗又下不来。Linux内核的runtime pm机制就是专门解决这个问题的它把电源管理的粒度从“整个系统”缩小到“单个设备”设备自己根据使用情况动态地在工作状态和低功耗状态之间切换。这篇文章就来把runtime pm这套机制从头到尾梳理一遍从状态机、引用计数到驱动接入方法和调试技巧一次性讲明白。runtime pm在整个Linux内核功耗子系统里的定位非常特殊。它不依赖系统处于哪个全局电源状态它管的是“设备运行时”的电源管理。系统suspend是需要内核里所有驱动配合、所有设备统一进入低功耗状态而runtime pm是单个设备自己判断“我现在没人用可以睡了”于是自己把内部时钟关了、把I2C通信停了、把IO口拉成低电平。等到要用了再通过一次runtime resume把设备唤醒重新初始化硬件。我最早接触runtime pm是在做一款带触摸屏的嵌入式设备时。触摸屏控制器大部分时间其实都在等用户点击但如果不做任何处理它就一直满功耗运行。后来在驱动里接入了runtime pm配合autosuspend设定500ms没有触摸事件就自动挂起触摸时自动唤醒整机功耗直接降了一截。这个体验让我觉得runtime pm是每个做低功耗产品的驱动工程师都必须掌握的基础能力。1. 从系统suspend到设备级runtime pm为什么需要这套机制1.1 系统级电源管理的粒度问题Linux的系统级挂起suspend-to-RAM、suspend-to-idle是一个全局协同的过程内核需要先冻结进程、然后按设备依赖顺序suspend所有设备、最后让CPU进入深度睡眠状态。这套流程为了安全和一致性做了大量工作代价就是延迟高、流程重。更重要的是系统级别的suspend只适合“整个系统都没事干”的场景。但现实中大量场景是“系统还在忙但某个外设已经闲了”。比如一块触摸屏用户在看视频时屏幕是亮着的但触摸控制器已经很久没有被触摸了。再比如一个USB蓝牙适配器蓝牙连接已经建立了但大部分时间没有数据传输。如果每个空闲设备都触发一次系统suspend那系统根本没法正常工作。所以内核需要一个机制让单个设备在驱动层面自己管理自己的电源状态这就是runtime pm的核心诉求。1.2 runtime pm到底做了什么runtime pm的核心理念就是一个引用计数加一个状态机。驱动在需要使用设备时调用get系列接口增加计数器使用完毕调用put系列接口减少计数器。当计数器降到0内核自动触发设备的runtime suspend回调让驱动把硬件切到低功耗模式。当计数器从0变成正数内核自动触发runtime resume回调让驱动把硬件唤醒。这个机制由内核电源管理核心提供具体实现在drivers/base/power/runtime.c每个struct device里都内嵌了一个struct dev_pm_info用来保存这个设备当前的runtime PM状态、引用计数、挂起/活跃时间等数据。对驱动开发者来说不需要额外分配任何数据结构只要在驱动里调用对应API并实现几个回调函数就行。1.3 和系统suspend的配合关系runtime pm并不是要替代系统suspend它和设备在系统级suspend中的行为是配合关系。系统进入挂起前内核会先把所有设备通过runtime resume恢复到active状态再进行系统级suspend流程目的是避免设备带着运行时挂起的脏状态进入系统休眠。反过来系统从休眠中恢复后runtime pm机制继续正常工作。理解这一点很重要很多初学者会以为有了runtime pm就可以不用管系统suspend了。实际上系统suspend走的是一套独立的dev_pm_opssuspend/resume/freeze/thaw等回调runtime pm走的是另一套runtime_suspend/runtime_resume/runtime_idle两者各管各的。好的驱动会同时实现这两套回调并且保持它们之间的逻辑一致性。2. runtime pm状态机与三个关键计数看懂机制的核心2.1 五种运行状态与状态流转runtime pm内部为每个设备维护一个状态变量定义在include/linux/pm_runtime.h里。设备在运行过程中会在以下几个状态之间切换状态含义典型停留场景RPM_ACTIVE设备处于正常工作状态硬件已上电、时钟开启设备正在被使用或刚完成resumeRPM_SUSPENDING正在执行runtime_suspend回调引用计数降为0内核正在切低功耗RPM_SUSPENDED设备已进入低功耗状态设备空闲硬件已关闭RPM_RESUMING正在执行runtime_resume回调有新的使用请求内核正在唤醒设备RPM_ERROR上一步操作失败进入错误状态回调返回错误或运行时发生异常状态流转的路径很清晰。设备在active状态下如果最后一个使用者调用了put引用计数降为0内核会把设备先置为suspending调用驱动的runtime_suspend回调回调成功后就进入suspended。之后如果有新的使用请求内核把设备置为resuming调用runtime_resume回调成功后就回到active。这里有个容易忽略的细节如果runtime_suspend回调返回了错误码内核会把这个错误记录到runtime_error字段同时设备回到之前的active状态并且短时间内不会再次尝试自动挂起。同理runtime_resume回调失败也有类似的处理。所以在驱动的runtime回调里如果硬件操作失败不要心存侥幸一定要返回错误否则后续状态不一致会很难排查。2.2 usage_count、child_count和runtime_status影响设备能否挂起的原因有三个关键值都在/sys/devices/.../power/下面能看到分别是runtime_status当前状态是active还是suspended。runtime_usage引用计数也就是usage_count。runtime_active_kids/child_count活跃子设备数量。usage_count是判断设备能不能挂起的首要条件。大于0说明还有人持有这个设备绝对不能挂起。等于0说明没有显式的使用者内核才有资格尝试挂起它。这一点像是一个共享资源的锁计数驱动用好它就是关键。child_count管理的是设备依赖关系。如果父设备和子设备有电源域依赖——比如一个MMC控制器下面挂了eMMC芯片MMC控制器是父设备eMMC是子设备。只要eMMC处于active状态MMC控制器就不能进入runtime suspend尽管它自己的usage_count可能已经是0了。内核通过child_count这个计数器来保证父设备不会在子设备还在工作时断电。反过来如果父设备并不依赖子设备的电源状态驱动可以调用pm_suspend_ignore_children让核心忽略这个约束。2.3 引用计数API的变体与选择runtime pm提供的get/put接口有一堆变体初学者经常搞混。我整理一下常用的pm_runtime_get_sync/pm_runtime_put_sync同步执行调用时会直接等待resume或suspend流程完成必须保证当前上下文可以睡眠。pm_runtime_get/pm_runtime_put异步版本只提交请求就返回实际的resume/suspend在后续的工作队列里执行适合在中断上下文或原子上下文调用。pm_runtime_get_noresume/pm_runtime_put_noidle只改引用计数不触发对应的resume或挂起动作。这两个接口用在“我知道设备已经处于正确状态只想维护计数”的场景。我的建议是常规驱动路径优先用同步版本因为逻辑直观出问题时好排查。只有在中断处理函数里才不得不改用异步版本。get_noresume和put_noidle这种精确控制版本是在你已经理解了整个状态机之后才建议使用的新手阶段先避开。3. 驱动接入runtime pm一个I2C设备驱动的完整实例3.1 注册runtime pm回调在驱动里接入runtime pm第一步是在dev_pm_ops里实现三个可选的回调函数runtime_suspend、runtime_resume、runtime_idle。下面是一个I2C触摸屏驱动的典型定义static int xxx_runtime_suspend(struct device *dev) { struct xxx_data *data dev_get_drvdata(dev); // 把硬件切到低功耗模式停掉内部电路、关掉时钟源 regmap_update_bits(data-regmap, XXX_REG_PWR, XXX_PWR_EN_MASK, 0); return 0; } static int xxx_runtime_resume(struct device *dev) { struct xxx_data *data dev_get_drvdata(dev); // 重新上电、恢复寄存器配置 regmap_update_bits(data-regmap, XXX_REG_PWR, XXX_PWR_EN_MASK, XXX_PWR_EN); xxx_restore_hw_config(data); return 0; } static int xxx_runtime_idle(struct device *dev) { // 返回0表示允许进入suspend流程 pm_runtime_suspend(dev); return 0; } static const struct dev_pm_ops xxx_pm_ops { SET_RUNTIME_PM_OPS(xxx_runtime_suspend, xxx_runtime_resume, xxx_runtime_idle), };runtime_idle回调在引用计数降为0时会被调用。在这个回调里驱动可以决定“是立刻挂起还是再等等”。如果返回0核心会继续执行挂起流程返回非0则取消本次挂起。开启autosuspend后runtime_idle的行为会有变化后面会细说。3.2 probe阶段的标准接入流程在probe函数里接入runtime pm我习惯按下面这个顺序来static int xxx_probe(struct i2c_client *client) { struct xxx_data *data; data devm_kzalloc(client-dev, sizeof(*data), GFP_KERNEL); if (!data) return -ENOMEM; dev_set_drvdata(client-dev, data); // 1. 初始化硬件到工作状态 xxx_hw_init(data); // 2. 使能runtime pm在设备上电后设置初始状态为active pm_runtime_enable(client-dev); pm_runtime_set_active(client-dev); // 3. 开启autosuspend设置500ms延迟 pm_runtime_set_autosuspend_delay(client-dev, 500); pm_runtime_use_autosuspend(client-dev); return 0; }注意顺序不能乱。先初始化硬件再pm_runtime_enable接着pm_runtime_set_active。pm_runtime_enable会让设备进入active状态但如果在此之前驱动没有正确设置硬件状态后面runtime suspend时硬件会处于异常状态。很多驱动会在probe里用一次pm_runtime_get_sync/pm_runtime_put_sync来保证初始状态这个做法也可以但更干净的做法是先置active再enable。还有一个容易踩的坑pm_runtime_enable在设备已经使能的情况下再次调用内核会打印警告并返回-EINVAL。如果驱动支持devicetree的probe/remove来回调用卸载时记得调用pm_runtime_disable并在probe时判断返回值避免重复使能。3.3 业务路径上的get/put姿势设备接入runtime pm之后最关键的一步就是在业务路径上正确使用get和put。还拿触摸屏举例驱动在open函数里调用pm_runtime_get_sync保证设备处于工作状态在release函数里调用pm_runtime_put_sync释放引用static int xxx_open(struct inode *inode, struct file *file) { struct xxx_data *data container_of(...); pm_runtime_get_sync(data-dev); // 正常打开设备启动触摸检测 return 0; } static int xxx_release(struct inode *inode, struct file *file) { struct xxx_data *data container_of(...); // 停止检测释放设备 pm_runtime_put_sync(data-dev); return 0; }在中断处理或轮询线程里如果设备可能进入suspended状态也要在访问寄存器前保证它是active的。但千万别在原子上下文调用pm_runtime_get_sync这会睡眠。正确做法是调用异步的pm_runtime_get或者在进程上下文中先做一次get再关闭中断。3.4 autosuspend延迟时间怎么定autosuspend的核心目的就是防止设备在“刚挂起又马上要使用”的场景下反复折腾。比如用户触摸屏幕的频率可能是随机的如果每次释放都立刻挂起下一次触摸再立刻唤醒一秒内可能来回切换好几次这个开销比省下来的电还大。pm_runtime_set_autosuspend_delay设置的是一个延迟时间单位是毫秒。设备引用计数归0后不会立刻执行挂起而是等这个延迟时间过去。如果延迟期间有人再次调用get挂起就被取消。这个延迟时间的设置没有标准答案只能根据设备的使用频度来定设备类型建议延迟原因触摸屏300ms ~ 1s用户点击间隔较长频繁唤醒会卡顿蓝牙模块1s ~ 5s连接保活和数据传输间隔不定USB外设2s ~ 5sUSB枚举和断开开销大尽量保持稳定传感器低频采样0ms ~ 200ms采样间隔固定可快速挂起运行时还可以通过sysfs动态调整这个值方便在实际产品里做功耗调优echo 1000 /sys/devices/.../power/autosuspend_delay_ms调整完成后可以观察suspended_time和active_time的比值变化判断延迟设置是否合理。这是我每做一个新设备时都会做的一步先用默认值跑通功能再花几分钟实际调一调这个延迟看功耗和性能之间怎么平衡。4. 用sysfs和trace把runtime pm看通透调试手段汇总4.1 sysfs节点逐个说清楚runtime pm为每个设备都暴露了完整的sysfs接口路径是/sys/devices/.../power/。这排节点是调试时最直接的信息来源。我把常用的几个列出来节点内容使用价值runtime_status当前状态如active、suspended快速判断设备是否在预期状态runtime_usage引用计数排查是否有get后忘put的情况runtime_active_kids活跃子设备数量排查父设备为何无法挂起runtime_suspended_time累计挂起时间ms评估设备空闲时间占比runtime_active_time累计工作状态时间ms结合上面的值看功耗优化效果autosuspend_delay_ms自动挂起延迟ms运行时调参不用编译内核controlauto或onon强制禁用runtime挂起auto允许自动挂起wakeup是否使能远程唤醒判断设备是否能被外部事件唤醒在调试时我通常会先看一下runtime_status和runtime_usage。如果设备应该挂起但一直active基本就是usage不为0如果usage已经是0却还是active再去看runtime_active_kids是不是有子设备还活跃。这几行就能定位大多数问题。4.2 trace event抓运行时行为sysfs只能看到当前快照想要知道设备什么时候挂起、什么时候唤醒、每次耗时多少就得靠trace。内核在runtime pm的核心路径上埋了trace事件事件名分别是rpm_idle、rpm_suspend、rpm_resume在/sys/kernel/debug/tracing/events/power/下面。用trace-cmd抓取非常方便trace-cmd record -e rpm_suspend -e rpm_resume \ -e rpm_idle -T sleep 30 trace-cmd report | grep -i xxx_i2c输出里会显示设备名、回调返回值、状态切换前后的值。一次正常的挂起流程大概长这样先是idle紧接着suspend状态字从ACTIVE变成SUSPENDED。如果看到反复的suspend/resume在短时间内交替出现就说明autosuspend延迟没设好或者是get/put没有配对好。4.3 一个典型排查案例触摸屏反复唤醒导致卡顿我做过的那个触摸屏项目最初接入runtime pm后遇到一个现象屏幕触摸起来偶尔卡一下特别是在快速连续点击时。用trace-cmd抓了一下发现rpm_resume事件特别频繁每次resume间隔只有十几毫秒基本是每次点击都触发了挂起和唤醒。排查步骤是这样的先看power/runtime_status确认触摸屏确实在active和suspended之间频繁切换。用power/runtime_usage看引用计数发现release后usage归0了说明没有泄漏。抓trace确认每次触摸事件结束后的几十毫秒内就发生suspend下一次触摸又立即resume。根因是autosuspend延迟设得太短只有50ms导致挂起恢复频率过高。最终把延迟调到500ms卡顿消失功耗也没有明显增加因为500ms内没有触摸的话设备照样会进入挂起状态。5. 易错点清单那些我在runtime pm上踩过的坑5.1 引用计数泄漏导致设备永远挂不住这是最典型的问题表现是设备已经空闲很久runtime_status却一直是active。用runtime_usage一眼就能看到引用计数不为0。多数原因是某个业务路径调了pm_runtime_get_sync但没有对应地调pm_runtime_put_sync。排查方法很土但很有效在驱动的所有get和put路径上临时加上打印打印出每次调用前后的usage计数跑一遍业务场景看哪次get之后没有落地的put。这种问题在驱动的异常分支里特别容易漏建议在写代码时就养成习惯——所有提前return的错误路径里都要记得put。5.2 runtime回调里拿锁导致死锁runtime suspend/resume回调是内核在工作队列或进程上下文中同步调用的在回调里可以睡眠。但正因为如此驱动经常会在回调里拿一个锁去保护硬件寄存器访问而这个锁如果是自旋锁问题就来了自旋锁持有期间如果被中断打断中断处理函数里又试图pm_runtime_get_sync就会直接死锁。我的经验是runtime回调里的寄存器访问、供电开关等操作尽量使用能睡眠的互斥锁mutex并且在整个驱动里保持锁的顺序一致。如果硬件访问非常高频必须用自旋锁那中断路径里的runtime pm调用就必须换成异步版本。5.3 中断上下文调用同步API导致内核报错pm_runtime_get_sync和pm_runtime_put_sync在内部会调用wait_for_completion或者触发完整的状态机流程过程中可能会睡眠。在硬中断上下文调用它们内核会打印“scheduling while atomic”之类的警告严重时直接死锁或panic。在中断路径里应该使用pm_runtime_get异步或者pm_runtime_get_noresume配合稍后在进程上下文中完成的resume。如果设备实在需要在中断里立刻恢复工作那么正确的姿势是把恢复动作延后到worker线程里做。5.4 忘记处理device tree里的power-domainsdevice tree中如果设备节点带power-domains属性那么设备的电源还受一个电源域PM domain控制。这种情况下runtime pm的suspend/resume不仅要驱动自己实现还要经过电源域层的power_on/power_off回调配合。很多驱动写完只测了没有power-domains的平台一切正常一上带电源域的平台就发现设备永远无法真正关电或者resume时电源域还没就绪。排查这类问题可以看/sys/kernel/debug/pm_genpd/下电源域的状态确认每个域的status是否随着设备的runtime状态同步变化。5.5 suspend回调里没有真正关硬件runtime pm的状态切换和硬件实际功耗是两回事。就算runtime_suspend回调返回0如果回调里没有真正把硬件寄存器配置到低功耗模式那设备在sysfs里显示suspended实际功耗一点没降。我见过有驱动为了省事直接在runtime suspend回调里只做了计数逻辑没碰硬件。这样功耗优化效果为零而且还白白增加了代码复杂度。建议在验证时除了看sysfs状态还要量一下实际电流确认suspend回调执行前后整机电流有明显下降。6. runtime pm和autosuspend联调的经验顺序最后分享一个我实际调试runtime pm的固定流程照着这个顺序走基本上能避免大多数问题先把基础功能跑通不急于调功耗。此时把power/control设成on强制关闭runtime挂起保证功能稳定。然后开启runtime pm把延迟设成一个比较稳妥的中间值比如500ms看设备能否正常挂起和唤醒。确认没有死锁、没有明显卡顿后再逐步调小延迟或者用实际业务场景压测找到功耗和性能的平衡点。整个过程中随时用sysfs和trace确认状态不要凭感觉调参。另外kern.log里如果出现Runtime PM: disabled device ...之类的提示通常是驱动在设备active状态下直接调用pm_runtime_disable导致的这种也要先把状态恢复好再disable否则后续重新使能会碰到状态不一致的问题。runtime pm这套东西说到底核心就三件事设备状态的切换逻辑、引用计数的维护、以及硬件在suspend/resume时做了正确的事。状态机和计数规则是内核帮你兜底的但硬件层面的低功耗设计以及业务路径上get/put的配对永远得靠驱动开发者自己保证。我自己的习惯是每写一个新的驱动都会先把runtime pm这套流程完整跑一遍再用量化的方式验证功耗效果这样后面做整机功耗优化时心里才踏实。
返回列表