19、功耗管理:SMP下的动态调频、CPU热插拔、idle线程管理
说到多核系统的功耗管理,我脑子里第一个蹦出来的画面,是几年前做的一个边缘计算网关项目。四核A53,跑着RT-Thread SMP,功耗指标死活压不下去。客户要求整机功耗不超过5W,我们光散热片就换了三版。后来我意识到,问题不在硬件,在软件——我们没有好好管那些“闲着的核”。
今天这一讲,咱们就聊聊SMP下的功耗管理三板斧:动态调频、CPU热插拔、还有idle线程管理。这三样东西,说白了就是让CPU“该干活时拼命干,该休息时彻底躺平”。
19.1 动态调频:让CPU按需变速
动态调频,也叫DVFS(Dynamic Voltage and Frequency Scaling)。原理很简单:任务重的时候,提高频率和电压;任务轻的时候,降下来。但SMP下有个麻烦——每个核的频率能不能独立调?
我遇到过两种硬件:一种是所有核共享一个电压域,频率必须一致;另一种是每个核有独立的调频接口。RT-Thread的cpufreq框架,对这两种都做了抽象。
先看一个典型的调频接口实现:
/* 定义频率表 */
static const struct cpufreq_frequency_table cpu_freq_table[] = {
{ .frequency = 200000000, .voltage = 900000 }, /* 200MHz, 0.9V */
{ .frequency = 400000000, .voltage = 950000 }, /* 400MHz, 0.95V */
{ .frequency = 800000000, .voltage = 1000000 }, /* 800MHz, 1.0V */
{ .frequency = 1200000000, .voltage = 1100000 },/* 1.2GHz, 1.1V */
{ 0, 0 } /* 结束标志 */
};
/* 调频回调 */
static rt_err_t cpu_set_frequency(int cpu_id, uint32_t freq_khz)
{
uint32_t index;
/* 查表找到对应的电压 */
for (index = 0; cpu_freq_table[index].frequency != 0; index++)
{
if (cpu_freq_table[index].frequency == freq_khz)
{
/* 先调电压,再调频率——顺序很重要! */
pmic_set_voltage(cpu_id, cpu_freq_table[index].voltage);
clock_set_cpu_freq(cpu_id, freq_khz);
return RT_EOK;
}
}
return -RT_EINVAL;
}
嗯,这里要注意:调频的顺序,一定是先升压再升频,先降频再降压。我见过有人搞反了,结果芯片直接死机。为什么?因为频率突然升高,电压跟不上,时序就乱了。
核心要点:动态调频不是越快越好。调频本身有延迟,频繁切换反而浪费功耗。我一般建议用“迟滞策略”——负载超过80%才升频,低于30%才降频,中间留个缓冲区。
19.2 CPU热插拔:让闲核彻底下线
动态调频只能降速,不能断电。有些场景下,某个核完全没事干,那不如直接把它拔掉。这就是CPU热插拔(CPU Hotplug)。
你想想看,一个核即使跑在最低频率,漏电流和时钟树还在耗电。拔掉之后,整个核的电源域都可以关掉,省电效果立竿见影。
RT-Thread SMP的CPU热插拔,核心就两个操作:
- 下线(offline):把该核上的所有任务迁移到其他核,然后关闭该核的中断,最后执行WFI指令让核停住。
- 上线(online):通过IPI唤醒该核,重新初始化它的MMU、缓存、中断控制器,然后开始调度。
我曾经在一个项目里,用热插拔实现了“按需开核”。系统启动时只开一个核,当负载超过阈值时,动态唤醒其他核。代码大概长这样:
/* 动态上线一个核 */
static void online_cpu_if_needed(void)
{
int cpu;
rt_uint8_t cpu_load = system_load_average();
if (cpu_load > 70) /* 负载超过70%,考虑加核 */
{
for (cpu = 1; cpu < RT_CPUS_NR; cpu++)
{
if (cpu_is_offline(cpu))
{
rt_hw_cpu_up(cpu);
rt_kprintf("CPU%d online\n", cpu);
break;
}
}
}
}
/* 动态下线一个核 */
static void offline_cpu_if_idle(void)
{
int cpu;
for (cpu = RT_CPUS_NR - 1; cpu > 0; cpu--)
{
if (cpu_is_online(cpu) && cpu_is_idle(cpu))
{
/* 确保该核上没有实时任务 */
if (cpu_has_realtime_task(cpu) == RT_FALSE)
{
rt_hw_cpu_down(cpu);
rt_kprintf("CPU%d offline\n", cpu);
break;
}
}
}
}
警告:热插拔不是随便拔的。拔核之前,一定要确保该核上的中断已经被迁移,否则中断会丢失。我踩过这个坑——拔掉一个核之后,网卡中断没人收了,系统直接断网。后来加了个中断亲和性迁移的步骤才解决。
19.3 Idle线程管理:让空闲核真正睡过去
动态调频和热插拔都是主动策略。但大多数时候,核只是短暂空闲,不值得拔掉。这时候,idle线程就派上用场了。
RT-Thread SMP里,每个核都有自己的idle线程。当该核上没有其他任务可运行时,就执行idle线程。idle线程的核心工作就是——让CPU进入低功耗模式。
常见的低功耗模式有三种:
| 模式 | 功耗 | 唤醒延迟 | 适用场景 |
|---|---|---|---|
| WFI(Wait For Interrupt) | 中等 | 几微秒 | 短暂空闲,很快会有任务 |
| WFE(Wait For Event) | 中等 | 几微秒 | 等待特定事件,如spinlock释放 |
| 深度睡眠 | 极低 | 几百微秒到几毫秒 | 长时间空闲,如系统待机 |
RT-Thread的idle线程默认执行WFI。但实际项目中,我建议根据空闲时长动态选择模式。比如这样:
void cpu_idle_entry(void)
{
static rt_tick_t last_active_tick = 0;
rt_tick_t idle_duration;
while (1)
{
idle_duration = rt_tick_get() - last_active_tick;
if (idle_duration > 100) /* 空闲超过100个tick,约1秒 */
{
/* 进入深度睡眠 */
enter_deep_sleep();
}
else if (idle_duration > 10) /* 空闲超过10个tick */
{
/* 进入WFI */
__asm volatile("wfi");
}
else
{
/* 短暂空闲,只暂停流水线 */
__asm volatile("nop");
}
last_active_tick = rt_tick_get();
}
}
个人经验:深度睡眠的唤醒延迟很高,如果频繁进出,反而更耗电。我一般会加一个“最小睡眠时间”的约束——预计空闲时间小于唤醒延迟的2倍,就不进深度睡眠。这个阈值需要根据你的硬件实测来调。
19.4 三者的协同配合
动态调频、热插拔、idle管理,这三者不是孤立的。我习惯把它们串成一条策略链:
- 短时空闲(<1ms):idle线程执行WFI,核还在,但功耗降低。
- 中等空闲(1ms~100ms):idle线程进入深度睡眠,同时触发动态调频,把该核频率降到最低。
- 长时间空闲(>100ms):执行CPU热插拔,把核彻底下线,同时把它的任务迁移到其他核。
这套策略我在一个8核的工业控制器上验证过。原本满负荷功耗12W,优化后平均功耗降到4.5W。客户很满意,我也松了口气——毕竟散热片终于可以小一号了。
最后说一句:功耗管理是个系统工程。硬件支持是基础,软件策略是灵魂。RT-Thread SMP给了你工具,但怎么用,还得看你对业务负载的理解。多跑跑数据,多调调参数,你也能做出漂亮的功耗曲线。