15、宏实现循环展开:编译时循环优化,do-while(0)技巧,宏实现 Duff's Device

循环展开,说白了就是让编译器把循环体复制几份,减少循环控制的开销。我刚开始做嵌入式时,总觉得编译器会自动优化,后来发现——嗯,它有时候真不靠谱。尤其是在资源受限的MCU上,手动展开往往能带来肉眼可见的性能提升。

今天我们就聊聊怎么用宏来实现循环展开。这可不是什么花架子,我在项目里靠这招解决过不少实时性瓶颈。

15.1 为什么需要循环展开?

先看一个简单的例子:

// 原始循环
for (int i = 0; i < 4; i++) {
    arr[i] = i * 2;
}

// 手动展开
arr[0] = 0;
arr[1] = 2;
arr[2] = 4;
arr[3] = 6;

展开后少了循环变量i的递增、比较、跳转指令。在循环次数固定的场景下,这招特别管用。我曾经在一个音频采样率转换的算法里,把循环展开后,处理时间直接砍掉了30%。

核心思想:用空间换时间。把循环体复制多份,减少分支预测失败和循环控制开销。

15.2 宏实现循环展开

手动写展开代码太累了,而且容易出错。用宏来自动生成,才是正经做法。

// 定义一个展开单元
#define PROCESS_ONE(i)  do { arr[i] = i * 2; } while(0)

// 展开4次
#define PROCESS_4(i)    do { \
    PROCESS_ONE(i+0);       \
    PROCESS_ONE(i+1);       \
    PROCESS_ONE(i+2);       \
    PROCESS_ONE(i+3);       \
} while(0)

// 展开16次
#define PROCESS_16(i)   do { \
    PROCESS_4(i+0);          \
    PROCESS_4(i+4);          \
    PROCESS_4(i+8);          \
    PROCESS_4(i+12);         \
} while(0)

你看,这样一层层嵌套,想展开多少次都行。我习惯把展开因子定为2的幂次,这样编译器处理起来更顺手。

15.3 do-while(0) 技巧

为什么每个宏外面都要套个 do { ... } while(0)?这个问题我当年也困惑过。

直接看个反面教材:

#define BAD_MACRO(x)  func1(x); func2(x)

if (condition)
    BAD_MACRO(y);   // 展开后:if (condition) func1(y); func2(y);
                    // func2永远都会执行!

do-while(0) 包裹后:

#define GOOD_MACRO(x)  do { func1(x); func2(x); } while(0)

if (condition)
    GOOD_MACRO(y);  // 展开后:if (condition) do { func1(y); func2(y); } while(0);
                    // 完美!

小技巧:do-while(0) 里的语句必须以分号结尾。while(0)后面不加分号,调用时再加。这样宏看起来就像普通函数调用一样自然。

15.4 Duff's Device 的宏实现

Duff's Device 是个经典技巧,它把循环展开和switch-case结合在了一起。我最早是在一个串口驱动里看到它的,当时觉得——这写法也太骚了吧?

原始C代码长这样:

void send_to_device(short *to, short *from, int count) {
    int n = (count + 7) / 8;
    switch (count % 8) {
        case 0: do { *to++ = *from++;
        case 7:      *to++ = *from++;
        case 6:      *to++ = *from++;
        case 5:      *to++ = *from++;
        case 4:      *to++ = *from++;
        case 3:      *to++ = *from++;
        case 2:      *to++ = *from++;
        case 1:      *to++ = *from++;
                } while (--n > 0);
    }
}

用宏封装一下,让它更通用:

#define DUFF_DEVICE_8(CODE) \
    do {                     \
        int _duff_n = (count + 7) / 8; \
        switch (count % 8) { \
            case 0: do { CODE; \
            case 7:      CODE; \
            case 6:      CODE; \
            case 5:      CODE; \
            case 4:      CODE; \
            case 3:      CODE; \
            case 2:      CODE; \
            case 1:      CODE; \
                    } while (--_duff_n > 0); \
        }                    \
    } while(0)

// 使用示例
void copy_data(short *to, short *from, int count) {
    DUFF_DEVICE_8(*to++ = *from++);
}

注意:Duff's Device 依赖switch-case的fall-through特性。有些编译器在严格模式下会警告。我建议只在性能敏感且经过测试的代码里使用。

15.5 知识体系图

宏实现循环展开知识体系 循环展开宏 基础宏展开 PROCESS_ONE → PROCESS_4 → PROCESS_16 嵌套展开,2的幂次因子 do-while(0)技巧 解决宏的多语句安全问题 避免if/else悬挂问题 Duff's Device switch-case + 循环展开 处理任意长度的数据 适用场景 固定循环次数 | 性能敏感路径 | 实时性要求高 注意:增加代码体积,需在空间和时间之间权衡

15.6 实际项目中的避坑指南

我曾经在一个电机控制项目里,用宏展开优化了PID计算循环。结果代码体积膨胀了3倍,Flash差点不够用。后来我学乖了——只在最内层、执行次数最多的循环上做展开。

几个实用建议:

  • 展开因子不要太大——我一般控制在4到8之间。超过16反而可能因为指令缓存溢出导致性能下降。
  • 配合编译器优化选项——有些编译器(比如GCC的 -funroll-loops)能自动做展开。先看看编译器生成的汇编,再决定要不要手动搞。
  • 用宏参数传递操作——就像上面Duff's Device的CODE参数一样,让宏更通用。
  • 注意宏的副作用——如果展开的表达式有副作用(比如 i++),展开多次后结果可能不是你想要的。

我的习惯:在性能测试通过后,我会把展开后的代码用 #ifdef 包裹起来,留一个未展开的版本做备选。这样调试时切回普通版本,发布时用展开版本。

15.7 总结

宏实现循环展开,说白了就是让预处理器帮你写重复代码。do-while(0) 保证了宏的安全性,Duff's Device 则展示了switch-case和循环的奇妙组合。

记住一点:宏展开是编译时的优化,它不会增加运行时的分支判断。但代价是代码体积变大。用不用、用多少,得看你的具体场景。

我在实际项目中,通常只在中断服务函数、音频处理、通信协议栈这些地方用。普通业务逻辑,还是交给编译器去操心吧。


公众号:蓝海资料掘金营,微信deep3321