第四讲:代码级功耗分析——循环优化、分支预测与函数调用开销

各位同学,咱们今天聊点实在的。

代码怎么写,芯片就怎么耗电。这不是玄学,是实实在在的物理规律。我做了十几年嵌入式,见过太多“功能跑通了,功耗炸了”的案例。今天咱们就掰开揉碎,讲讲循环、分支和函数调用这三个“电老虎”。

一、循环优化:别让CPU空转

循环是嵌入式代码里最常见的结构。但你知道吗?一个糟糕的循环,能让功耗翻倍。

我个人习惯,写循环前先问自己三个问题:

  • 循环次数能不能提前确定?
  • 循环体里有没有不必要的操作?
  • 能不能用查表代替计算?

1.1 循环展开:用空间换时间

先看个例子。假设我们要对数组做累加:

// 原始循环
int sum = 0;
for (int i = 0; i < 100; i++) {
    sum += data[i];
}

// 循环展开(4次)
int sum = 0;
for (int i = 0; i < 100; i += 4) {
    sum += data[i];
    sum += data[i+1];
    sum += data[i+2];
    sum += data[i+3];
}

为什么展开后省电?因为循环控制(i++、比较、跳转)减少了75%。每次跳转,CPU都要刷新流水线,这玩意儿特别耗电。我在项目中遇到过,一个音频解码算法,展开后功耗降了12%。

小技巧: 展开次数一般取2、4、8。别搞太大,否则代码体积膨胀,I-Cache命中率下降,反而得不偿失。

1.2 减少循环内的函数调用

这个坑我踩过。曾经有个项目,循环里调了个判断函数:

// 糟糕的做法
for (int i = 0; i < 1000; i++) {
    if (is_valid(data[i])) {
        process(data[i]);
    }
}

// 优化后
int (*valid_func)(int) = is_valid;
for (int i = 0; i < 1000; i++) {
    if (valid_func(data[i])) {
        process(data[i]);
    }
}

你想想看,每次循环都去查函数地址、压栈出栈,CPU累不累?说白了,循环体越“瘦”,功耗越低。能内联的就内联,能用宏的就用宏。

二、分支预测:猜对了省电,猜错了费电

分支预测是现代CPU的“读心术”。猜对了,流水线跑得飞起;猜错了,整个流水线要清空重来。一次预测失败,浪费大约10-20个时钟周期。

为什么会这样?因为CPU为了提速,会提前把分支后面的指令预取进来。如果猜错了,这些预取工作全白干了,还要重新取指令、译码、执行。这就像你开车走错了路,得掉头重来——油耗自然高。

2.1 让分支可预测

CPU的分支预测器有个特点:它喜欢“规律”。比如:

// 难以预测:随机分布
if (data[i] > 0) { ... }

// 容易预测:大部分为真
if (data[i] > 0) { ... }  // 假设90%的数据都大于0

我建议,写条件判断时,把大概率发生的情况放在前面。比如:

// 好的写法
if (likely(data[i] > 0)) {  // 用likely/unlikely宏提示编译器
    // 常见路径
} else {
    // 异常路径
}
注意: likely/unlikely是GCC扩展,不是标准C。移植时要注意。我曾经在ARMCC上吃过这个亏,编译出来性能反而更差。

2.2 用查表代替分支

这是我最喜欢的手法。比如一个简单的符号函数:

// 分支版本
int sign(int x) {
    if (x > 0) return 1;
    else if (x == 0) return 0;
    else return -1;
}

// 查表版本
int sign_table[3] = {-1, 0, 1};
int sign(int x) {
    return sign_table[(x > 0) - (x < 0) + 1];
}

查表版本没有分支,CPU不用猜,功耗自然低。我在做电机控制算法时,用查表替换了十几个条件判断,整体功耗降了8%。

三、函数调用开销:别小看那点“零头”

很多新手觉得,函数调用不就是几条指令吗?能费多少电?

嗯,这里要注意。一次函数调用,背后是:压栈、保存寄存器、跳转、执行、恢复寄存器、出栈。如果函数很小,调用开销可能比函数体本身还大。

3.1 内联小函数

// 不内联
int add(int a, int b) {
    return a + b;
}

// 内联
static inline int add(int a, int b) {
    return a + b;
}

对于这种只有一两行的函数,内联后没有调用开销,代码直接展开。我建议,函数体小于5行的,都考虑内联。

3.2 减少参数传递

参数越多,压栈越重。ARM架构下,前4个参数用寄存器传,后面的就要压栈了。

// 糟糕:6个参数
void set_pwm(int ch, int freq, int duty, int deadtime, int phase, int polarity);

// 优化:用结构体
typedef struct {
    int ch;
    int freq;
    int duty;
    int deadtime;
    int phase;
    int polarity;
} pwm_config_t;

void set_pwm(pwm_config_t *cfg);

用指针传结构体,只压一个参数。我在项目中遇到过,一个频繁调用的配置函数,参数从8个减到1个指针后,功耗降了5%。

3.3 函数调用频率控制

有些函数没必要每次循环都调。比如:

// 每次循环都调
for (int i = 0; i < 100; i++) {
    update_display();  // 每秒刷新60次就够了
    process_data(data[i]);
}

// 优化:降低调用频率
for (int i = 0; i < 100; i++) {
    if (i % 10 == 0) {
        update_display();  // 每10次刷新一次
    }
    process_data(data[i]);
}

你想想看,显示刷新这种操作,人眼根本看不出区别。但CPU省下的电,电池能感受到。

四、实战总结:一个优化案例

最后,我分享一个真实案例。某款智能手表的心率算法,原始代码长这样:

for (int i = 0; i < SAMPLE_COUNT; i++) {
    int val = read_sensor(i);
    if (val > THRESHOLD) {
        val = filter(val);
        val = downsample(val);
        buffer[i] = val;
    } else {
        buffer[i] = 0;
    }
}

优化后:

// 循环展开4次
// 分支改为查表
// filter和downsample内联
// 减少read_sensor调用次数
for (int i = 0; i < SAMPLE_COUNT; i += 4) {
    int v0 = read_sensor(i);
    int v1 = read_sensor(i+1);
    int v2 = read_sensor(i+2);
    int v3 = read_sensor(i+3);
    
    // 查表代替分支
    buffer[i]   = filter_table[v0 > THRESHOLD] ? filter(v0) : 0;
    buffer[i+1] = filter_table[v1 > THRESHOLD] ? filter(v1) : 0;
    buffer[i+2] = filter_table[v2 > THRESHOLD] ? filter(v2) : 0;
    buffer[i+3] = filter_table[v3 > THRESHOLD] ? filter(v3) : 0;
}

结果:功耗降低了22%,算法运行时间缩短了35%。

核心原则: 让CPU少干活,少猜谜,少跑腿。循环展开、分支优化、函数内联,这三板斧用好了,功耗自然就下来了。

好了,这一讲就到这里。下一讲咱们聊聊“内存访问对功耗的影响”——你会发现,读写内存比算算术费电多了。