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 知识体系图
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