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 去除未使用段:从源头到终点

去除未使用段,不是链接器一个人的事。它需要编译器、链接器、还有你——开发者,三方配合。

我画了一张图,帮你理清整个流程:

去除未使用段:三阶段协作流程 编译器阶段 -ffunction-sections 链接器阶段 --gc-sections 手动阶段 DISCARD + KEEP 核心原理:每个函数/变量独立成段 → 链接器自动丢弃未被引用的段 编译器做了什么? • 每个函数放到独立 .text.funcname 段 • 每个全局变量放到独立 .data.varname 段 • 为链接器提供「可裁剪」的粒度 链接器做了什么? • 扫描所有段的引用关系 • 从入口点(Reset_Handler)开始 • 删除未被任何入口引用的段 常见「去不掉」的坑 1. 中断向量表里声明了但没实现的中断函数 → 链接器认为「被引用」 2. 通过函数指针调用的函数 → 编译器无法静态分析 3. 汇编文件里 .global 导出的符号 → 链接器默认保留 4. 弱符号(__attribute__((weak)))→ 即使未被覆盖也保留

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通信时序出错。排查了两天才发现是编译器把循环展开策略改了。

最后提醒: 每次修改编译选项或链接脚本后,务必做一次完整的回归测试。尺寸优化可能会改变代码执行路径,影响时序和功能。