2. 编译器优化:GCC编译选项与链接器脚本
说到内存优化,很多人第一反应是「换更小的芯片」。其实不然。我见过太多项目,芯片选型时留了50%余量,最后却因为固件膨胀不得不换大一号的MCU。说白了,编译器才是你手里最趁手的工具,只是很多人没用对。
这一节,我们聊聊GCC的编译选项、链接器脚本,以及如何把没用的代码段从固件里踢出去。
2.1 GCC编译选项:-Os 与 -Oz
GCC的优化等级有很多:-O0、-O1、-O2、-O3、-Os,还有嵌入式领域特有的-Oz。你可能会问:「这么多,我该用哪个?」
我的建议是:量产固件用 -Os,极端空间受限用 -Oz。
| 选项 | 优化目标 | 典型尺寸变化 | 适用场景 |
|---|---|---|---|
| -O0 | 无优化,调试友好 | 基准(100%) | 开发调试阶段 |
| -O2 | 速度优先 | 约 110%~120% | 对性能有要求的场景 |
| -Os | 尺寸优先,兼顾速度 | 约 60%~80% | 通用嵌入式量产 |
| -Oz | 极致尺寸,牺牲速度 | 约 50%~70% | Flash/KB级MCU |
关键区别:-Os 在「减小代码尺寸」和「保持合理性能」之间取平衡;-Oz 则完全偏向尺寸,哪怕多几条指令能更快,它也选择更短的那个版本。
我在一个STM32G030的项目里遇到过这种情况:用-Os编译,固件32KB;换成-Oz,直接降到26KB。少了6KB,对于64KB Flash的芯片来说,就是多了10%的余量。当然,代价是某些循环慢了大约15%。但说实话,对于大部分控制类应用,用户根本感觉不到。
2.1.1 常用编译选项组合
我个人习惯在Makefile里这样写:
# 调试版本
CFLAGS_DEBUG = -O0 -g3 -Wall -Wextra
# 发布版本(尺寸优化)
CFLAGS_RELEASE = -Os -flto -ffunction-sections -fdata-sections
# 极致尺寸版本(慎用)
CFLAGS_TINY = -Oz -flto -ffunction-sections -fdata-sections \
-fomit-frame-pointer -fno-unroll-loops
小技巧: -flto(链接时优化)配合 -ffunction-sections 和 -fdata-sections 使用,效果翻倍。LTO能让编译器跨文件做优化,把没调用的函数整个删掉。
2.2 链接器脚本优化
链接器脚本(.ld文件)是很多人忽略的角落。你想想看,编译器已经把代码优化得很小了,但链接器如果没配好,照样会把一堆垃圾塞进固件。
我曾经接手过一个项目,同事用了默认的链接脚本,结果固件里包含了完整的C库启动代码,光是 .init 和 .fini 段就占了2KB。对于只有16KB Flash的芯片,这简直是浪费。
2.2.1 去除未使用段
链接器脚本里,最关键的是 KEEP 和 DISCARD 指令:
/* 在SECTIONS块中 */
SECTIONS
{
/* 只保留需要的段 */
.text : {
*(.text*)
*(.rodata*)
} > FLASH
/* 明确丢弃不需要的段 */
/DISCARD/ : {
*(.comment)
*(.note*)
*(.ARM.attributes)
*(.init)
*(.fini)
*(.preinit_array)
*(.init_array)
*(.fini_array)
}
}
注意: 丢弃 .init 和 .fini 段前,请确认你的启动代码不依赖它们。有些RTOS或C++构造会用到这些段,贸然丢弃可能导致启动异常。
2.2.2 内存布局优化
链接器脚本还能帮你做内存布局优化。比如,把频繁访问的变量放到SRAM的高地址,减少指令寻址开销:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
SRAM2 (rw) : ORIGIN = 0x20005000, LENGTH = 4K
}
SECTIONS
{
/* 快速变量放到SRAM2 */
.fast_data : {
*(.fast_data*)
} > SRAM2 AT > FLASH
/* 普通变量放到SRAM */
.data : {
*(.data*)
} > SRAM AT > FLASH
}
嗯,这里要注意:AT > FLASH 表示初始化数据在Flash里存着,运行时再拷贝到SRAM。这个拷贝过程由启动代码完成,别漏了。
2.3 去除未使用段:从源头到终点
去除未使用段,不是链接器一个人的事。它需要编译器、链接器、还有你——开发者,三方配合。
我画了一张图,帮你理清整个流程:
2.4 实战:一个完整的优化案例
拿我之前做的一个温控器项目来说。原始固件用 -O0 编译,大小是 48KB。目标芯片只有 32KB Flash,必须优化。
第一步:改成 -Os + -flto + -ffunction-sections,固件降到 31KB。嗯,刚好卡在边界上,不保险。
第二步:检查链接器脚本,发现保留了完整的 newlib 标准库。我换成了 --specs=nano.specs 和 --specs=nosys.specs,又省了 3KB。
第三步:在链接脚本里加了 /DISCARD/ 段,去掉 .comment 和 .ARM.attributes,再省 0.5KB。
最终固件 27.5KB,留了 4.5KB 余量。整个过程没改一行业务代码。
检查固件大小的命令:
# 查看各段大小
arm-none-eabi-size firmware.elf
# 查看具体符号大小(找「胖子」)
arm-none-eabi-nm --size-sort firmware.elf | tail -20
# 查看段详细信息
arm-none-eabi-objdump -h firmware.elf
2.5 避坑指南
优化虽好,但别踩坑。我总结了几条经验:
- 不要对调试版本用 -Oz——优化后的代码跟源码行号对不上,调试时断点乱跳,你会疯的。
- 检查中断向量表——有些MCU的启动文件里,所有中断都给了默认的弱符号。如果你没实现某个中断,链接器不会报错,但那个弱符号会占空间。
- 小心递归——-Oz 可能会把递归函数展开成循环,如果栈空间本来就不够,直接跑飞。
- 我曾经遇到过:用 -Oz 编译后,一个延时函数从 1ms 变成了 3ms,导致I2C通信时序出错。排查了两天才发现是编译器把循环展开策略改了。
最后提醒: 每次修改编译选项或链接脚本后,务必做一次完整的回归测试。尺寸优化可能会改变代码执行路径,影响时序和功能。