
1. CPU Hotplug 到底在解决什么问题CPU 热插拔CPU Hotplug这个机制刚接触内核功耗子系统的人往往会觉得它离自己很远——毕竟日常开发中CPU 就在那里开机上电、关机断电哪有什么插拔可言。但如果你做过服务器运维、虚拟化平台、或者嵌入式设备的低功耗场景就会知道 CPU Hotplug 是一个绕不开的基础设施。它的核心能力是在系统运行过程中动态地把某个 CPU 核心从调度器中摘出去离线或者重新接回来在线整个过程不需要重启系统。这件事听起来简单做起来极其复杂。原因在于一个 CPU 核心在运行时它不只是执行指令那么简单。它持有调度队列、运行着定时器、可能正在处理中断、参与 RCU 同步、维护着 per-CPU 变量、缓存着各种状态。你要把它摘出去就必须把这些状态全部迁移或清理干净否则轻则数据错乱重则系统崩溃。所以 CPU Hotplug 本质上是一套状态迁移协议而不是简单的关掉一个核心。从功耗子系统的角度看CPU Hotplug 的价值非常直接。现代 SoC 通常有多个核心但在轻负载场景下比如手机待机、IoT 设备空闲、服务器夜间低峰期让所有核心都保持在线是纯粹的浪费。把空闲核心离线可以让该核心进入更深层次的睡眠状态甚至完全断电配合电源域控制减少漏电流这在先进制程下占比越来越高降低散热压力避免风扇噪音和热节流为其他核心腾出共享资源如 L2/L3 缓存、内存带宽但这里有个关键点很多人会忽略CPU Hotplug 和 CPU Idle 是两套不同的机制解决的是不同层次的问题。CPU Idle 是在核心仍然在线的前提下让它进入浅睡或深睡唤醒延迟从微秒到毫秒级而 CPU Hotplug 是把核心彻底从系统视野中移除唤醒延迟在毫秒到几十毫秒级代价大得多但省电效果也更彻底。选择哪一个取决于你的延迟容忍度和省电收益的权衡。我在实际项目中见过不少团队一上来就用 Hotplug 做省电结果发现响应延迟飙升用户体验变差。后来改成轻负载用 Idle、重负载切换、极低负载才 Hotplug的分层策略效果才好起来。这个经验后面会展开讲。2. 内核里 CPU Hotplug 的状态机与核心数据结构要理解 CPU Hotplug 怎么工作必须先搞清楚内核用什么来描述一个 CPU 当前处于什么状态。这套状态机是整个机制的地基搞不明白它后面看代码就是一团乱麻。2.1 四种状态与状态迁移路径内核用cpu_online_mask、cpu_present_mask、cpu_possible_mask、cpu_active_mask这四个位图来描述 CPU 的状态。它们的关系不是简单的包含而是有明确的语义层次位图含义典型场景possible系统理论上支持的最大 CPU 集合编译时 NR_CPUS 决定启动后不变present当前物理上存在的 CPUACPI/设备树枚举后确定热插拔物理槽位会变online当前对调度器可见、可调度的 CPU热插拔操作直接改变的就是它active当前正在参与调度任务的 CPU比 online 更严格用于任务迁移判断这四个位图的关系是possible ⊇ present ⊇ online ⊇ active。注意online和active的区别——一个 CPU 可以 online 但暂时不 active这通常发生在 CPU 正在上线但还没完全准备好接任务的过渡期。内核里判断这个 CPU 能不能跑任务用的是cpu_active()而不是cpu_online()这个细节在写驱动时非常关键。状态迁移的路径大致是离线流程active → online 清除 → 迁移任务 → 关闭中断 → 停定时器 → 通知各子系统 → present 保留物理还在在线流程present → 初始化 per-CPU → 启动定时器 → 打开中断 → 设置 online → 设置 active每一步都有对应的通知链notifier chain回调各子系统在这里做自己的清理和初始化。这就是为什么 CPU Hotplug 的代码看起来到处都是 hook——它必须给所有依赖 per-CPU 状态的子系统一个我要走了/我回来了的信号。2.2 关键数据结构cpuhp_step 与状态机内核 4.10 之后引入了统一的 CPU Hotplug 状态机kernel/cpu.c里的cpuhp_hp_states[]把原来散落各处的 notifier 整合成了一套有序的状态步骤。每个步骤用cpuhp_step描述struct cpuhp_step { const char *name; union { int (*single)(unsigned int cpu); int (*multi)(unsigned int cpu, struct hlist_node *node); } startup; union { int (*single)(unsigned int cpu); int (*multi)(unsigned int cpu, struct hlist_node *node); } teardown; struct hlist_head list; bool cant_stop; bool multi_instance; };这套设计的精妙之处在于它把离线和在线变成了同一套步骤的正反两个方向。每个子系统注册自己的 startup 和 teardown 回调内核保证 teardown 按注册的逆序执行startup 按正序执行。这样就不会出现A 子系统还没清理完B 子系统就急着初始化的竞态。状态编号从CPUHP_OFFLINE开始到CPUHP_ONLINE结束中间插入各个子系统的步骤。比如CPUHP_AP_ONLINE_DYN是给架构相关代码动态注册用的CPUHP_BP_PREPARE_DYN是给普通驱动用的。你在写驱动时如果要注册 hotplug 回调通常用cpuhp_setup_state()这个 API它会自动帮你分配一个动态状态号。提示注册回调时一定要想清楚你的回调属于prepare阶段还是online阶段。prepare 阶段 CPU 还没真正上线不能做可能睡眠的操作online 阶段才相对自由。搞错了阶段轻则警告重则死锁。2.3 per-CPU 数据的迁移难题CPU Hotplug 最棘手的地方在于 per-CPU 数据。内核里大量使用DEFINE_PER_CPU定义的变量每个 CPU 一份副本。当一个 CPU 离线时这些数据怎么办答案是大部分 per-CPU 数据不需要迁移因为它们是该 CPU 专属的CPU 离线后自然不再被访问。但有一类数据必须处理——那些被其他 CPU 引用的 per-CPU 数据。最典型的是调度器的运行队列runqueue。当一个 CPU 离线它 runqueue 里的任务必须迁移到其他 CPU 上否则这些任务就永远得不到执行。任务迁移的过程在take_cpu_down()和sched_cpu_dying()里完成。核心逻辑是先把该 CPU 从调度域中摘除然后遍历它的 runqueue把可运行任务推到其他 CPU最后等待所有正在该 CPU 上执行的任务主动让出。这里有个细节正在该 CPU 上运行的任务无法被强制迁移只能等它自己调度出去。所以离线操作可能会阻塞一段时间这也是为什么 Hotplug 的延迟不可控。RCU 是另一个重灾区。RCU 的 grace period 需要所有 CPU 都报告一次静止状态如果某个 CPU 离线了它就没法报告。内核的处理方式是离线 CPU 在离线前会通知 RCURCU 把它标记为已通过后续的 grace period 不再等它。这个机制叫rcu_cpu_offline在CPUHP_AP_RCU_OFFLINE这个状态步骤里执行。3. 从用户空间触发一次热插拔的完整链路理论讲完了来看实操。从用户空间触发 CPU 热插拔最直接的方式是通过 sysfs 接口。这个接口简单到只有一行命令但背后触发的链路非常长理解这条链路对排查问题极有帮助。3.1 sysfs 接口与触发命令每个 CPU 在/sys/devices/system/cpu/下都有一个目录比如cpu0、cpu1。其中online文件就是控制开关# 查看 CPU1 当前状态 cat /sys/devices/system/cpu/cpu1/online # 让 CPU1 离线 echo 0 /sys/devices/system/cpu/cpu1/online # 让 CPU1 重新上线 echo 1 /sys/devices/system/cpu/cpu1/online注意CPU0 通常不能离线因为它是 boot CPU很多架构代码假设 CPU0 永远在线。你尝试离线 CPU0 会得到-EINVAL或-EPERM。这个限制在cpu_down()里有明确检查。写online文件时内核走的是cpu_subsys的store回调最终调用cpu_device_down()或cpu_device_up()。这两个函数会拿cpu_add_remove_lock和cpu_hotplug_lock两把锁然后调用核心的cpu_down()/cpu_up()。3.2 离线流程的七个阶段cpu_down()的执行可以拆成七个阶段每个阶段都有明确的意图参数校验与锁获取检查 CPU 号合法性、是否 boot CPU、是否已经离线。获取cpu_hotplug_lock的写锁这会阻塞所有get_online_cpus()的读者。状态标记把 CPU 从cpu_active_mask清除调用sched_cpu_deactivate()。此时调度器不再往这个 CPU 派新任务但已有任务还在跑。任务迁移sched_cpu_dying()把 runqueue 里的任务迁移走等待当前运行任务让出。这一步可能耗时较长。中断迁移irq_migrate_all_off_this_cpu()把该 CPU 上的中断重新分配到其他 CPU。注意有些中断是绑定的无法迁移这时会报错并中止离线。定时器迁移把该 CPU 的定时器迁移到其他 CPU停掉本地定时器。通知链回调按状态机逆序执行各子系统的 teardown 回调。这一步是重头戏涉及 RCU、workqueue、hrtimer、perf、cpufreq 等几十个子系统。最终下线调用架构相关的__cpu_die()让 CPU 进入停止状态。在 ARM64 上通常是执行 WFI 或进入 PSCI CPU_OFF。整个流程里第 4 步和第 6 步是最容易出问题的。中断迁移失败会导致离线直接失败通知链回调里如果有子系统没处理好可能死锁或崩溃。3.3 在线流程的对称性在线流程基本是离线流程的镜像但有几个不对称的地方值得注意在线时 CPU 从possible状态开始需要先做架构相关的__cpu_up()让 CPU 真正跑起来然后按状态机正序执行各子系统的 startup 回调最后设置cpu_online_mask和cpu_active_mask不对称的关键点在于离线时任务迁移是推出去的在线时任务不会自动拉回来。新上线的 CPU 是空闲的调度器会在后续的负载均衡中逐渐把任务分过来。所以刚上线的 CPU 利用率是 0需要等一个调度周期才会看到负载。这个特性在动态调频场景下很重要。如果你刚上线一个 CPU 就立刻读它的频率可能还是最低频因为还没任务跑上去。要等负载均衡生效后再观察。4. 功耗子系统视角下的 Hotplug 策略设计前面讲的都是机制现在进入正题在功耗子系统里怎么用好 CPU Hotplug。这部分是纯经验文档里不会写但实际项目里天天遇到。4.1 Hotplug 与 CPUIdle 的收益对比先看一组实测数据。在一台 8 核 ARM64 服务器上空载状态下分别用 Idle 和 Hotplug 处理空闲核心功耗对比如下策略空闲核心状态单核功耗唤醒延迟适用场景CPUIdle WFI浅睡约 120mW 10us交互式负载CPUIdle 深睡深睡保留上下文约 40mW约 100us后台任务CPU Hotplug核心断电约 5mW约 5-20ms长时间空闲电源域关闭整簇断电约 1mW约 50ms极低负载数据很直观Hotplug 的省电效果是 Idle 深睡的 8 倍但唤醒延迟是 100 倍以上。这意味着Hotplug 只适合那些确定长时间不会用到的核心。如果你的负载是突发性的用 Hotplug 会导致每次突发都要等核心上线响应变慢。我的经验是设置一个空闲持续时间阈值核心空闲超过 500ms 才考虑 Hotplug低于这个值只用 Idle。这个阈值可以通过内核的sched_mc_power_savings或自定义的 governor 来调节。4.2 什么时候该用 Hotplug什么时候不该用不是所有场景都适合 Hotplug。我总结了几条判断标准适合用 Hotplug 的场景服务器夜间低峰负载可预测核心可以长时间离线虚拟化平台vCPU 数量动态调整配合热迁移嵌入式设备待机只有少数核心处理传感器中断测试和调试需要模拟不同核心数的系统行为不适合用 Hotplug 的场景高频交易、实时控制延迟敏感负载波动剧烈核心频繁上下线中断绑定紧密迁移代价高有 per-CPU 缓存热数据离线会丢失缓存局部性特别提醒一点频繁的 Hotplug 操作本身很耗电。每次上下线都要走一遍完整的状态机涉及几十个子系统的回调CPU 在这期间是满负荷运行的。如果核心离线 100ms 又上线省的电还不够操作本身消耗的。所以一定要有滞回hysteresis设计避免抖动。4.3 与 cpufreq、cpuidle 的协同CPU Hotplug 不是孤立的它必须和 cpufreq、cpuidle 协同工作。三者的关系可以这样理解cpuidle管在线核心睡多深cpufreq管在线核心跑多快CPU Hotplug管哪些核心在线一个完整的功耗策略应该是分层的先降频cpufreq再进深睡cpuidle最后才离线Hotplug。顺序反了就会出问题——比如你先把核心离线了cpufreq 的 governor 就看不到这个核心的负载无法做全局决策。内核里 cpufreq 的 governor 在 CPU 离线时会收到通知把该 CPU 的策略迁移到其他 CPU。但如果你用的是schedutilgovernor它依赖调度器的负载信息CPU 离线后这部分信息就没了。所以在 Hotplug 场景下ondemand或conservativegovernor 往往比schedutil更稳定因为它们基于独立的采样不依赖调度器。5. 踩坑实录那些让我熬夜的 Hotplug 问题这部分是我这些年踩过的真实坑每个都花了至少一个通宵才定位。分享出来希望你能少走弯路。5.1 中断迁移失败导致的离线卡死现象执行echo 0 /sys/devices/system/cpu/cpu3/online后命令挂住不返回dmesg里刷irq_migrate_all_off_this_cpu相关警告。排查过程先看/proc/interrupts发现有一个中断的 affinity 被设成了 CPU3且这个中断的IRQD_AFFINITY_SET标志被置位意味着用户空间手动绑定了它。内核在迁移中断时遇到手动绑定的中断不会自动迁移而是返回-EBUSY导致整个离线流程卡住。根因某个驱动在初始化时调用了irq_set_affinity_hint()把中断绑到了特定 CPU但没有在 CPU 离线时释放绑定。这是驱动作者的疏忽。修复在驱动的 CPU Hotplug 回调里检测到目标 CPU 离线时把中断 affinity 重置为默认。或者更简单用irq_set_affinity_hint(irq, NULL)清除绑定。经验写驱动时凡是调用了irq_set_affinity_hint()或irq_set_affinity()的地方都要配套注册 CPU Hotplug 回调来清理。这个坑我见过至少三个不同的驱动犯。5.2 RCU stall 与离线超时现象CPU 离线操作执行后系统报rcu_sched detected stalls on CPUs/tasks然后离线失败。排查过程用ftrace跟踪rcu_cpu_offline和rcu_report_qs_rnp发现离线 CPU 在等待一个 RCU grace period 完成但另一个 CPU 上有任务在 RCU 读临界区里长时间不退出。根因某个内核模块在 RCU 读临界区里做了耗时操作比如等待 I/O导致 grace period 无法结束。CPU 离线需要等所有 grace period 完成于是卡住。修复把耗时操作移出 RCU 读临界区或者用rcu_read_unlock()提前退出。经验RCU 读临界区里绝对不能睡眠、不能做耗时操作。这个规则大家都知道但实际代码里违反的情况非常多。建议用CONFIG_PROVE_RCU和CONFIG_RCU_EQS_DEBUG做静态检查能在编译期发现大部分问题。5.3 per-CPU 变量访问导致的空指针现象CPU 离线后某个驱动在中断处理里访问 per-CPU 变量触发空指针或数据错乱。排查过程用 KASAN 定位到具体的访问点发现驱动用this_cpu_ptr()访问了一个 per-CPU 数组但该 CPU 离线后数组被释放了。根因驱动的 per-CPU 数据在 CPU 离线时被释放但中断处理程序没有检查 CPU 是否在线仍然访问。修复在中断处理里加cpu_online()检查或者用get_cpu_ptr()/put_cpu_ptr()配对保护。经验per-CPU 数据的生命周期管理是 Hotplug 里最容易出错的地方。我的建议是凡是 per-CPU 数据都要想清楚它在 CPU 离线时是否还被访问。如果会就必须在 hotplug 回调里做保护。5.4 离线后频率信息丢失现象CPU 离线再上线后cpufreq显示该 CPU 频率为 0或者 governor 报错。排查过程查看cpufreq的 hotplug 回调发现它在 CPU 离线时把 policy 释放了但上线时没有重新初始化。根因cpufreq 的 hotplug 处理有 bug或者驱动没有正确实现cpufreq_driver的online/offline回调。修复确保cpufreq_driver实现了online和offline回调且在online里重新初始化 policy。经验cpufreq 和 Hotplug 的交互是重灾区。如果你在调频率相关的 bug先确认 CPU 上下线时 cpufreq 的状态是否正确。用cat /sys/devices/system/cpu/cpuX/cpufreq/scaling_cur_freq验证。6. 调试 CPU Hotplug 的实用工具箱遇到 Hotplug 问题光看代码是不够的得有一套调试手段。这部分分享我常用的工具和方法。6.1 ftrace 跟踪状态机执行ftrace 是排查 Hotplug 问题的第一利器。内核在kernel/cpu.c里埋了大量 tracepoint可以直接跟踪状态机的每一步# 开启 cpuhp 相关 tracepoint echo 1 /sys/kernel/debug/tracing/events/cpuhp/enable # 执行离线操作 echo 0 /sys/devices/system/cpu/cpu2/online # 查看跟踪结果 cat /sys/kernel/debug/tracing/trace输出会显示每个状态步骤的进入和退出以及耗时。如果某个步骤卡住一眼就能看出来。我通常还会配合function_graphtracer 看具体函数调用链echo function_graph /sys/kernel/debug/tracing/current_tracer echo cpu_down /sys/kernel/debug/tracing/set_ftrace_filter这样能看到cpu_down内部每个函数的执行时间和调用关系定位耗时点非常有效。6.2 用 lockdep 抓死锁Hotplug 涉及大量锁死锁是常见问题。lockdep能在死锁发生前就报警# 确认 lockdep 已开启 cat /proc/sys/kernel/lock_stat # 开启锁统计 echo 1 /proc/sys/kernel/lock_stat执行 Hotplug 操作后如果 lockdep 检测到潜在死锁会在dmesg里打印详细的锁依赖链。我遇到过一个经典死锁cpu_hotplug_lock和某个驱动的 mutex 形成 AB-BA 依赖lockdep 直接指出了问题。注意lockdep 本身有性能开销生产环境通常关闭。调试时临时开启定位完就关掉。6.3 常见错误码速查Hotplug 操作失败时sysfs 会返回错误码。这些错误码的含义和排查方向如下错误码含义排查方向-EINVAL参数非法CPU 号越界、boot CPU 不可离线-EPERM权限不足非 root 用户、CPU 被锁定-EBUSY资源忙中断无法迁移、任务无法迁移-ENOSYS功能未实现架构不支持 Hotplug-ETIMEDOUT超时任务迁移或 RCU 同步超时看到-EBUSY时重点查中断和任务迁移看到-ETIMEDOUT重点查 RCU 和调度器。6.4 用 CPU 隔离做对照实验有时候问题不好定位可以用 CPU 隔离isolcpus做对照。把可疑 CPU 从调度器中隔离出来看问题是否复现# 启动参数加 isolcpus3 # 或者运行时用 cpuset 隔离 mkdir /sys/fs/cgroup/cpuset/test echo 3 /sys/fs/cgroup/cpuset/test/cpuset.cpus如果隔离后问题消失说明问题出在调度器与 Hotplug 的交互上如果问题依旧说明是更底层的架构或驱动问题。这个方法能快速缩小排查范围。7. 写一个自己的 CPU Hotplug 回调最后来点实操如果你在写驱动需要注册 CPU Hotplug 回调怎么做才稳妥。这部分给一个完整的模板和注意事项。7.1 注册回调的正确姿势内核提供了cpuhp_setup_state()系列 API最常用的是static int my_driver_cpu_online(unsigned int cpu) { /* CPU 上线时初始化 per-CPU 资源 */ struct my_percpu *pc per_cpu_ptr(my_data, cpu); return my_percpu_init(pc); } static int my_driver_cpu_offline(unsigned int cpu) { /* CPU 离线时清理 per-CPU 资源 */ struct my_percpu *pc per_cpu_ptr(my_data, cpu); my_percpu_cleanup(pc); return 0; } static int __init my_driver_init(void) { int ret; ret cpuhp_setup_state(CPUHP_AP_ONLINE_DYN, mydriver:online, my_driver_cpu_online, my_driver_cpu_offline); if (ret 0) return ret; my_hp_state ret; return 0; } static void __exit my_driver_exit(void) { cpuhp_remove_state(my_hp_state); }关键点用CPUHP_AP_ONLINE_DYN让内核自动分配状态号不要硬编码保存返回的状态号退出时用cpuhp_remove_state()注销online 和 offline 回调必须成对且要幂等可能被多次调用7.2 回调里能做什么、不能做什么这是最容易踩坑的地方。回调的执行上下文和阶段决定了你能做什么online 回调prepare 阶段不能睡眠不能分配可能睡眠的内存不能获取可能睡眠的锁只能做轻量的初始化online 回调online 阶段可以睡眠可以分配内存可以获取 mutex适合做完整的资源初始化offline 回调执行时 CPU 还在运行但已经不在调度器中不能依赖调度器功能要尽快完成避免阻塞离线流程我的建议是尽量把重活放在 online 阶段offline 阶段只做必要的清理。因为 offline 阶段阻塞会直接拖慢整个离线操作而 online 阶段相对宽松。7.3 一个真实的驱动改造案例我之前改造过一个网络驱动它原本用全局锁保护 per-CPU 统计在 CPU 离线时会丢数据。改造方案是把全局锁改成 per-CPU 变量每个 CPU 独立统计注册 Hotplug 回调在 offline 时把该 CPU 的统计累加到全局在 online 时清零该 CPU 的统计改造后不仅 Hotplug 问题解决了性能还提升了 30%因为去掉了全局锁竞争。这个案例说明Hotplug 问题往往暴露的是更深层的设计问题解决它可能带来额外收益。8. 一些零散但重要的经验写到这里主体内容差不多了再补充几个零散但很实用的点。关于 CPU0虽然内核不允许 CPU0 离线但在某些架构上可以通过maxcpus启动参数限制启动的核心数。如果你需要测试单核场景用maxcpus1比尝试离线 CPU0 靠谱。关于热插拔的原子性一次 Hotplug 操作不是原子的中间可能失败并回滚。回滚过程会重新执行已经执行过的步骤的反向操作。所以你的回调必须支持执行到一半被回滚的情况不能假设所有回调都会成功。关于性能影响Hotplug 操作会持有cpu_hotplug_lock写锁期间所有get_online_cpus()的读者都会被阻塞。如果你的系统里有大量读者比如网络收包路径Hotplug 会造成明显的延迟抖动。所以生产环境要避免频繁 Hotplug最好在低峰期做。关于虚拟化在 KVM 等虚拟化环境下vCPU 的 Hotplug 由 hypervisor 控制guest 内核看到的 Hotplug 是模拟的。调试时要注意区分是 guest 内部问题还是 host 侧问题。用virsh setvcpus之类的工具操作时观察 guest 的dmesg能看到完整的 Hotplug 流程。关于嵌入式很多嵌入式 SoC 的 CPU Hotplug 依赖 PSCI 或类似的固件接口。如果固件实现有问题Hotplug 会失败在__cpu_die()这一步。这时要看固件日志而不是只盯着内核。关于测试写 Hotplug 相关代码后一定要做压力测试。我常用的方法是循环上下线 1000 次同时跑内存压力和网络压力看是否稳定。很多竞态问题只有在高频操作下才会暴露。# 简单的压力测试脚本 for i in $(seq 1 1000); do echo 0 /sys/devices/system/cpu/cpu3/online echo 1 /sys/devices/system/cpu/cpu3/online done这个脚本跑一晚上能暴露大部分 Hotplug 相关的稳定性问题。如果 1000 次都稳基本可以放心了。我个人在实际操作中的体会是CPU Hotplug 这个机制看起来简单用起来处处是坑。它的复杂度不在于接口而在于它牵动的子系统太多。每加一个新驱动、新特性都要考虑它在 Hotplug 场景下的行为。这也是为什么内核社区对 Hotplug 相关的 patch 审查特别严格——一个疏忽就可能导致整个系统不稳定。如果你正在做功耗优化建议先把 Idle 和 cpufreq 调好最后再考虑 Hotplug这样收益和风险的平衡最好。