循环优化(上):循环展开、循环合并、循环交换的原理与实战

循环,是嵌入式代码里最密集的算力消耗区。我见过太多项目,一上板子就卡死,最后定位下来,问题全在几个关键循环里。今天咱们就把循环优化的三板斧——展开、合并、交换——讲透。

为什么循环需要优化?

你想想看,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 取数据,那速度能快才怪。

三种技术的对比与选择

技术 适用场景 主要收益 主要代价
循环展开 循环体小、迭代次数多 减少控制开销 代码膨胀
循环合并 多个循环遍历相同范围 提高缓存局部性 可能破坏依赖
循环交换 嵌套循环、非连续访问 改善内存访问模式 需要理解存储布局

我的建议:先做循环交换,再做循环合并,最后考虑展开。因为交换和合并对代码体积影响小,展开是最后的大招。

知识结构图

循环优化三种技术关系图 循环优化 循环展开 循环合并 循环交换 优化顺序建议:先交换 → 再合并 → 最后展开 展开因子一般取 2~4,避免寄存器溢出 减少控制开销 提高缓存局部性 改善内存访问 注意:三种技术可以组合使用,但必须验证数据依赖和寄存器压力

实战中的避坑指南

我曾经在一个电机控制项目里,把循环展开和循环合并一起用了。结果代码跑起来,电机反而抖得更厉害。查了半天,发现是展开后寄存器溢出,编译器自动插入了栈操作,把实时性全毁了。

避坑要点:

  • 展开后一定要检查反汇编,确认没有多余的栈操作
  • 合并前先画数据流图,确保没有循环间依赖
  • 交换后跑一遍全量测试,内存访问顺序变了,结果可能不同

说白了,循环优化不是无脑套公式。你得理解你的硬件——缓存多大?寄存器几个?内存是 SRAM 还是 DRAM?这些决定了优化策略。

我的习惯:每次优化前,先用性能计数器跑一遍基线。优化后对比,如果加速比不到 1.5 倍,就回退。不值得为了 10% 的提升牺牲代码可维护性。