3. GCC 常用优化选项实战:让代码真正跑起来
编译器优化,说白了就是让工具替我们做代码层面的「瘦身」和「提速」。GCC 提供了几十个优化选项,但真正在嵌入式项目里高频使用的,其实就那么几个。今天我就挑三个最常用的——-funroll-loops、-finline-functions 和 -fomit-frame-pointer,结合实战经验跟你聊聊怎么用、什么时候用、什么时候千万别用。
3.1 -funroll-loops:循环展开,用空间换时间
循环展开是个经典思路。处理器每次循环都要做条件判断和跳转,这些开销在循环次数很大时会被放大。展开后,循环体变长,但跳转次数减少,流水线也能跑得更顺。
3.1.1 什么时候该用?
我个人习惯在以下场景开启 -funroll-loops:
- 循环次数固定且较小(比如 4、8、16 次)
- 循环体本身很轻量(几条指令)
- 对实时性有硬要求,需要可预测的执行时间
举个例子,一个 FIR 滤波器的累加循环:
// 原始循环
for (int i = 0; i < 8; i++) {
sum += coeff[i] * sample[i];
}
// 展开后(GCC 自动处理)
sum += coeff[0] * sample[0];
sum += coeff[1] * sample[1];
// ... 以此类推
我在一个音频处理项目里遇到过这种情况。循环次数固定为 8,开启展开后,处理一个采样点的耗时从 120 个时钟周期降到了 85 个。嗯,效果很明显。
3.1.2 什么时候别用?
另外,循环次数在运行时才能确定的情况,GCC 的展开效果会打折扣。它只能展开编译时已知的部分,剩下的还是得走循环。
3.2 -finline-functions:函数内联,消除调用开销
函数调用有代价——压栈、跳转、弹栈。对于频繁调用的小函数,这个开销占比很高。-finline-functions 让编译器把函数体直接插入调用处,省掉这些步骤。
-O2 和 -O3 已经会内联一部分小函数。手动开启 -finline-functions 会让编译器更激进,连中等大小的函数也可能被内联。
3.2.1 典型应用场景
看一个常见的 GPIO 操作封装:
// 头文件中定义的 inline 函数
static inline void gpio_set(uint32_t pin) {
GPIO->BSRR = pin;
}
// 调用处
gpio_set(GPIO_PIN_5);
gpio_set(GPIO_PIN_6);
开启 -finline-functions 后,这两个调用会被替换为直接的寄存器写入指令。我在一个 LED 点阵驱动项目里用过这个技巧,刷新率从 30fps 提到了 45fps。
3.2.2 内联的代价
你想想看,如果一个函数被调用了 100 次,内联后它的代码就复制了 100 份。代码膨胀是必然的。我建议遵循几条原则:
- 只内联调用频繁且函数体小的函数(通常 < 10 条指令)
- 避免内联递归函数(编译器一般也不会做)
- 用
__attribute__((noinline))标记不想内联的关键函数
3.3 -fomit-frame-pointer:省掉帧指针,多一个寄存器
这个选项比较直接。默认情况下,函数入口会保存栈帧指针(FP),用于调试和异常处理。但如果你不需要这些信息,完全可以省掉它,把 FP 寄存器释放出来给普通计算用。
3.3.1 实际效果
在 ARM Cortex-M 系列上,开启 -fomit-frame-pointer 后,每个函数调用能省 2-4 个字节的栈空间和 2 条指令。对于深度嵌套的调用链,这个节省是累加的。
我记得在一个传感器数据采集任务里,主循环有 5 层函数调用。开启这个选项后,总栈使用量从 512 字节降到了 480 字节。虽然不多,但在栈空间只有 2KB 的 MCU 上,这 32 字节可能就是压死骆驼的最后一根稻草。
3.3.2 什么时候不能省?
setjmp/longjmp 做异常跳转,千万别开这个选项。没有帧指针,回溯就断了,跳转也会出错。我曾经在调试阶段开了这个选项,结果 core dump 根本看不出调用栈,排查了整整一天才发现是它搞的鬼。
3.4 三个选项的协同使用
这三个选项不是孤立的。实际项目中,我通常把它们组合起来用。下面这张图展示了它们各自的作用域和协同关系:
3.5 实战对比:一个完整的例子
下面是一个综合测试,看看这三个选项在不同组合下的效果。测试平台是 STM32F103,主频 72MHz,计算 1000 次 FIR 滤波的耗时。
| 优化选项组合 | 代码体积 (字节) | 执行时间 (us) | 栈使用 (字节) |
|---|---|---|---|
| 无优化 (-O0) | 1240 | 1850 | 128 |
| -O2 | 980 | 920 | 96 |
| -O2 + -funroll-loops | 1120 | 680 | 96 |
| -O2 + -finline-functions | 1050 | 810 | 88 |
| -O2 + -fomit-frame-pointer | 940 | 890 | 80 |
| -O2 + 三个全开 | 1180 | 620 | 72 |
3.6 使用建议总结
说了这么多,最后给你一个快速决策表:
- Flash 充足、追求极致性能: 三个全开,配合
-O3 - Flash 紧张、性能要求一般: 只开
-fomit-frame-pointer,配合-Os - 调试阶段: 关掉所有优化,用
-O0 -g - 实时性要求高: 重点开
-funroll-loops,让循环执行时间可预测
嗯,这三个选项的实战应用就聊到这里。记住一点:优化不是玄学,每次改动都要对比测试。拿数据说话,别凭感觉。