循环优化(上):循环展开、循环合并、循环交换的原理与实战
循环,是嵌入式代码里最密集的算力消耗区。我见过太多项目,一上板子就卡死,最后定位下来,问题全在几个关键循环里。今天咱们就把循环优化的三板斧——展开、合并、交换——讲透。
为什么循环需要优化?
你想想看,CPU 执行循环时,每次迭代都要做四件事:
- 检查循环条件
- 跳转回循环头
- 刷新流水线
- 重新加载指令
这些开销,叫循环控制开销。迭代次数越多,浪费越大。我做过一个音频滤波算法,原始代码跑了 2000 次循环,光跳转指令就占了总时间的 15%。
核心思想:减少循环次数,就是减少控制开销。但要注意,不能破坏数据依赖关系。
一、循环展开(Loop Unrolling)
说白了,就是把循环体复制多份,一次循环干好几件事。比如原来一次处理 1 个元素,现在一次处理 4 个。
基础展开示例
// 原始循环
for (int i = 0; i < 100; i++) {
a[i] = b[i] * c[i];
}
// 展开 4 次
for (int i = 0; i < 100; i += 4) {
a[i] = b[i] * c[i];
a[i+1] = b[i+1] * c[i+1];
a[i+2] = b[i+2] * c[i+2];
a[i+3] = b[i+3] * c[i+3];
}
展开后,循环次数从 100 次降到 25 次。控制开销直接砍掉 75%。
我的经验:展开因子不是越大越好。ARM Cortex-M4 上我试过展开 8 次,寄存器不够用,反而导致栈溢出。一般 2~4 次最稳妥。
展开的代价
| 维度 | 收益 | 代价 |
|---|---|---|
| 代码体积 | 无 | 增大(复制多份代码) |
| 执行速度 | 减少跳转、提升流水线效率 | 可能增加寄存器压力 |
| 可读性 | 无 | 变差(代码变长) |
我曾经在一个 I2C 驱动里展开了一个 8 次循环,结果代码段从 200 字节膨胀到 600 字节,Flash 不够用了。嗯,这里要注意:代码压缩和速度优化经常是矛盾的。
二、循环合并(Loop Fusion)
循环合并,就是把两个或多个遍历相同范围的循环,合并成一个。好处很明显——减少一次完整的循环控制开销,还能提高数据局部性。
合并示例
// 合并前
for (int i = 0; i < N; i++) {
a[i] = b[i] + 1;
}
for (int i = 0; i < N; i++) {
c[i] = a[i] * 2;
}
// 合并后
for (int i = 0; i < N; i++) {
a[i] = b[i] + 1;
c[i] = a[i] * 2;
}
合并后,a 数组只被加载一次,CPU 缓存命中率大幅提升。我在做图像处理时,把三个分离的像素操作循环合并,处理一帧 640x480 的图像,时间从 12ms 降到了 8ms。
注意:合并的前提是循环边界相同,且数据依赖关系允许。如果第二个循环依赖第一个循环的完整结果,就不能简单合并。
什么时候不该合并?
- 循环边界不同(一个到 N,一个到 M)
- 中间结果需要全部计算完才能用
- 合并后寄存器压力过大
三、循环交换(Loop Interchange)
循环交换,主要针对嵌套循环。交换内外层循环的顺序,让内存访问更连续。说白了,就是让 CPU 少等内存。
交换示例
// 交换前(按列访问)
for (int j = 0; j < COLS; j++) {
for (int i = 0; i < ROWS; i++) {
matrix[i][j] = 0;
}
}
// 交换后(按行访问)
for (int i = 0; i < ROWS; i++) {
for (int j = 0; j < COLS; j++) {
matrix[i][j] = 0;
}
}
C 语言中,二维数组按行存储。交换前,每次访问 matrix[i][j] 都要跨过整行,缓存行利用率极低。交换后,连续访问同一行的相邻元素,缓存命中率飙升。
实测数据:我在 STM32H743 上测试过,一个 512x512 的矩阵清零操作,交换前耗时 2.3ms,交换后 0.4ms。差了将近 6 倍。
交换的判断标准
| 存储布局 | 内层循环应遍历 | 原因 |
|---|---|---|
| C 语言(行主序) | 列索引(j) | 连续内存访问 |
| Fortran(列主序) | 行索引(i) | 连续内存访问 |
你想想看,如果内层循环跳着访问内存,CPU 每次都要去 DRAM 取数据,那速度能快才怪。
三种技术的对比与选择
| 技术 | 适用场景 | 主要收益 | 主要代价 |
|---|---|---|---|
| 循环展开 | 循环体小、迭代次数多 | 减少控制开销 | 代码膨胀 |
| 循环合并 | 多个循环遍历相同范围 | 提高缓存局部性 | 可能破坏依赖 |
| 循环交换 | 嵌套循环、非连续访问 | 改善内存访问模式 | 需要理解存储布局 |
我的建议:先做循环交换,再做循环合并,最后考虑展开。因为交换和合并对代码体积影响小,展开是最后的大招。
知识结构图
实战中的避坑指南
我曾经在一个电机控制项目里,把循环展开和循环合并一起用了。结果代码跑起来,电机反而抖得更厉害。查了半天,发现是展开后寄存器溢出,编译器自动插入了栈操作,把实时性全毁了。
避坑要点:
- 展开后一定要检查反汇编,确认没有多余的栈操作
- 合并前先画数据流图,确保没有循环间依赖
- 交换后跑一遍全量测试,内存访问顺序变了,结果可能不同
说白了,循环优化不是无脑套公式。你得理解你的硬件——缓存多大?寄存器几个?内存是 SRAM 还是 DRAM?这些决定了优化策略。
我的习惯:每次优化前,先用性能计数器跑一遍基线。优化后对比,如果加速比不到 1.5 倍,就回退。不值得为了 10% 的提升牺牲代码可维护性。