循环优化技巧:循环展开、循环合并、循环交换,减少循环开销
循环,是嵌入式代码里最密集的计算区域。我见过太多工程师把精力花在算法选型上,却忽略了循环本身带来的性能损耗。说白了,循环的每一次迭代都有代价——条件判断、指针移动、寄存器保存与恢复。这些开销单看不大,但乘以几千几万次迭代,就成了性能瓶颈。
今天咱们就聊聊三种最实用的循环优化手段:循环展开、循环合并、循环交换。这三招用好了,能让你的代码在空间和时间之间找到最佳平衡点。
循环展开:用代码体积换执行速度
循环展开的原理很简单——减少循环控制指令的执行次数。每次循环都要做一次条件判断和跳转,展开后这些开销就被摊薄了。
核心思想:将多次迭代合并为一次,减少循环控制开销,同时给编译器更多指令级并行的机会。
看个例子。假设我们要对数组做累加:
// 原始循环
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += array[i];
}
// 2倍展开
int sum = 0;
for (int i = 0; i < 1000; i += 2) {
sum += array[i];
sum += array[i+1];
}
// 4倍展开
int sum = 0;
for (int i = 0; i < 1000; i += 4) {
sum += array[i];
sum += array[i+1];
sum += array[i+2];
sum += array[i+3];
}
// 处理剩余元素
for (int i = 1000 - (1000 % 4); i < 1000; i++) {
sum += array[i];
}
展开倍数怎么选?我个人习惯先看处理器架构。ARM Cortex-M4 这类带 SIMD 指令的,4倍展开往往效果最好。M0+ 这种简单核,2倍就够了,展开太多反而寄存器不够用。
经验之谈:展开倍数一般取 2、4、8。超过 8 倍收益递减,而且代码体积膨胀明显。我在一个音频处理项目里试过 16 倍展开,性能只提升了 3%,代码体积却涨了 40%——得不偿失。
循环合并:减少缓存污染和循环开销
循环合并,就是把两个或多个遍历相同数据范围的循环合并成一个。这样做的好处很明显:数据只需要加载一次到缓存,循环控制开销也减半。
举个例子,图像处理中常见的分离操作:
// 分离循环
for (int i = 0; i < N; i++) {
pixel[i].r = pixel[i].r >> 1; // 亮度调整
}
for (int i = 0; i < N; i++) {
pixel[i].g = pixel[i].g >> 1;
}
for (int i = 0; i < N; i++) {
pixel[i].b = pixel[i].b >> 1;
}
// 合并后
for (int i = 0; i < N; i++) {
pixel[i].r = pixel[i].r >> 1;
pixel[i].g = pixel[i].g >> 1;
pixel[i].b = pixel[i].b >> 1;
}
合并后的代码,数据只遍历一次,缓存命中率明显提升。我在做摄像头图像预处理时,用这个技巧把帧处理时间从 12ms 降到了 8ms。
注意:循环合并不是万能的。如果两个循环访问的数据集完全不同,合并反而会导致缓存抖动。我曾经在一个网络协议栈里合并了包头解析和负载校验两个循环,结果因为数据访问模式冲突,性能反而下降了 15%。
循环交换:优化数据访问局部性
循环交换主要针对多维数组。C 语言中数组按行优先存储,所以内层循环遍历行内元素时,内存访问是连续的,缓存利用率最高。
看个矩阵乘法的例子:
// 低效版本:内层循环遍历列
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
for (int k = 0; k < N; k++) {
C[i][j] += A[i][k] * B[k][j]; // B[k][j] 访问不连续
}
}
}
// 优化版本:交换 j 和 k 循环
for (int i = 0; i < N; i++) {
for (int k = 0; k < N; k++) {
for (int j = 0; j < N; j++) {
C[i][j] += A[i][k] * B[k][j]; // 现在 C[i][j] 和 B[k][j] 都连续访问
}
}
}
交换后,内层循环访问 C[i][j] 和 B[k][j] 时,地址是递增的,缓存预取机制能完美工作。我在一个 256x256 矩阵乘法测试中,这个改动让性能提升了 3 倍。
三种技巧的适用场景对比
| 优化技巧 | 适用场景 | 主要收益 | 代价 | 我推荐的优先级 |
|---|---|---|---|---|
| 循环展开 | 迭代次数固定、循环体简单的循环 | 减少控制开销,提升指令并行度 | 代码体积增大 | 高(对简单循环效果立竿见影) |
| 循环合并 | 多个循环遍历相同数据范围 | 减少缓存污染和循环开销 | 可能增加单次迭代的寄存器压力 | 中(需评估数据访问模式) |
| 循环交换 | 多维数组遍历,内层访问不连续 | 提升缓存命中率 | 代码可读性下降 | 高(对矩阵运算效果显著) |
组合使用:1+1>2
这三种技巧不是孤立的。实际项目中,我经常把它们组合起来用。比如在图像卷积处理中:
// 原始版本
for (int y = 0; y < H; y++) {
for (int x = 0; x < W; x++) {
for (int ky = 0; ky < 3; ky++) {
for (int kx = 0; kx < 3; kx++) {
out[y][x] += in[y+ky][x+kx] * kernel[ky][kx];
}
}
}
}
// 优化版本:循环交换 + 展开
for (int y = 0; y < H; y++) {
for (int x = 0; x < W; x += 4) { // 展开 x 循环
int sum0 = 0, sum1 = 0, sum2 = 0, sum3 = 0;
for (int ky = 0; ky < 3; ky++) {
// 展开 kx 循环
sum0 += in[y+ky][x+0] * kernel[ky][0];
sum0 += in[y+ky][x+1] * kernel[ky][1];
sum0 += in[y+ky][x+2] * kernel[ky][2];
sum1 += in[y+ky][x+1] * kernel[ky][0];
sum1 += in[y+ky][x+2] * kernel[ky][1];
sum1 += in[y+ky][x+3] * kernel[ky][2];
sum2 += in[y+ky][x+2] * kernel[ky][0];
sum2 += in[y+ky][x+3] * kernel[ky][1];
sum2 += in[y+ky][x+4] * kernel[ky][2];
sum3 += in[y+ky][x+3] * kernel[ky][0];
sum3 += in[y+ky][x+4] * kernel[ky][1];
sum3 += in[y+ky][x+5] * kernel[ky][2];
}
out[y][x+0] = sum0;
out[y][x+1] = sum1;
out[y][x+2] = sum2;
out[y][x+3] = sum3;
}
}
这个优化版本同时用了循环交换(把 kx 移到内层)和循环展开(x 方向 4 倍展开)。在 Cortex-M7 上测试,性能提升了 4.2 倍。
避坑指南:我曾经在优化一个 FIR 滤波器时,把循环展开和循环合并一起用,结果寄存器溢出导致性能反而下降。后来发现是展开倍数太大,寄存器不够用,编译器被迫频繁 spill/reload。记住:展开前先确认目标处理器的寄存器数量。
知识结构图
实战建议
说了这么多,最后给几条实在的建议:
- 先测量,再优化:用性能计数器或示波器测量循环执行时间,找到真正的热点。我见过有人花两天优化一个只占 2% CPU 时间的循环,纯属浪费时间。
- 从最内层循环开始:内层循环执行次数最多,优化收益最大。先优化内层,再考虑外层。
- 注意编译器优化:现在的编译器(GCC -O2/-O3)会自动做循环展开和交换。你可以用
-fno-unroll-loops关闭自动展开,对比手动展开的效果。 - 保留原始版本:优化前后做对比测试。我习惯用
#ifdef保留原始代码,方便回归验证。
最后提醒:循环优化是典型的「空间换时间」策略。在 Flash 紧张的 MCU 上,比如只有 32KB 的 STM32G0,过度展开可能导致代码放不下。这时候就要权衡了——是省 10% 的时间,还是省 20% 的空间?没有标准答案,看你的项目需求。