第4章:代码大小优化:-Os优化详解、函数内联控制、链接时优化(LTO)、strip与sstrip工具

嵌入式开发里,代码大小往往就是成本。我早年做一款IoT模组,Flash只有256KB,固件超了1KB就得换芯片,一颗贵两毛钱——量产百万台就是20万。从那以后,我对代码大小优化就特别上心。

这一章咱们聊聊怎么把程序「挤瘦」。不是投机取巧,而是用编译器提供的正经手段。

4.1 -Os优化:为尺寸而生

GCC的优化等级里,-Os是个特殊的存在。它不像-O2那样追求速度,也不像-O0那样只管编译快。-Os的目标很明确:在合理的时间内,生成尽可能小的代码。

我习惯把它叫做「尺寸优化模式」。它到底做了什么?

  • 开启-O2的大部分优化,但关闭那些会膨胀代码的选项
  • 禁用循环展开(-fno-unroll-loops)——展开循环虽然快,但代码量会翻倍
  • 禁用函数内联(除非你显式指定inline)
  • 优化跳转指令,尽量用短跳转代替长跳转
  • 复用公共子表达式,减少重复计算

核心对比:

优化等级典型代码大小典型性能编译时间
-O0基准(100%)最慢最快
-Os约60-70%中等中等
-O2约80-90%最快较慢
-Oz约55-65%较慢中等

注意-Oz这个选项。它是Clang/LLVM里的「极致尺寸优化」,比-Os更激进。我曾经在ARM Cortex-M0上试过,-Oz比-Os还能再省5-8%的空间,代价是性能下降约15%。如果你的芯片Flash实在吃紧,可以试试它。

4.2 函数内联控制:该出手时就出手

函数内联是把双刃剑。用好了,既省空间又提速度;用不好,代码膨胀得一塌糊涂。

编译器默认有一套内联策略:小函数自动内联,大函数保持调用。但有时候它判断不准。我遇到过一个问题:一个只有3条指令的getter函数,编译器死活不肯内联,因为它在多个.c文件里都被调用。后来我加了static inline,代码直接小了200字节——因为省去了每次调用的压栈出栈开销。

控制内联的几个关键属性:

  • inline:建议编译器内联,但不强制
  • __attribute__((always_inline)):强制内联,不管函数多大
  • __attribute__((noinline)):禁止内联,保持函数调用
  • static inline:内联且只在当前编译单元可见,避免重复定义

我的经验法则:

  • 函数体小于等于3条指令 → 强制内联
  • 函数体4-10条指令 → 让编译器自己决定
  • 函数体超过10条指令 → 禁止内联,除非是热路径
  • 被调用超过3次 → 慎重内联,考虑代码膨胀

举个例子,一个简单的GPIO置位函数:

// 强制内联,因为只有一条写寄存器指令
static inline __attribute__((always_inline))
void gpio_set(uint32_t pin) {
    GPIO->BSRR = (1 << pin);
}

嗯,这里要注意:always_inline不能滥用。我曾经在一个中断服务函数里强制内联了一个200行的处理函数,结果中断栈直接爆了——因为内联后函数没有自己的栈帧,所有局部变量都挤在中断栈上。那次排查花了我整整一个下午。

4.3 链接时优化(LTO):全局视角的威力

单个源文件的优化,就像管中窥豹。编译器看不到其他.c文件里的函数定义,所以很多优化机会白白浪费了。LTO(Link Time Optimization)解决了这个问题。

说白了,LTO就是把优化推迟到链接阶段。编译器先生成中间表示(IR),链接时再把所有IR合并,做全局分析。它能做什么?

  • 跨文件内联:a.c里调用了b.c的函数,LTO可以把它内联进来
  • 死代码消除:整个工程里没被调用的函数,直接扔掉
  • 常量传播:跨文件的常量值传递和折叠
  • 虚拟函数去虚拟化:C++里特别有用

启用LTO很简单:GCC加-flto,Clang加-flto=thin。我建议嵌入式项目都用上LTO,尤其是C++项目。有一次我把一个STM32项目从-O2切换到-Os -flto,固件从128KB直接降到89KB——省了30%。

LTO的坑:

  • 编译时间变长:链接阶段要做全局分析,大型项目可能多花2-3倍时间
  • 调试信息可能丢失:建议调试阶段关掉LTO,发布时再开
  • 某些库不兼容:比如有些静态库没带IR信息,LTO就失效了
  • 链接器脚本要调整:LTO可能改变符号的排列顺序

我个人习惯在CI/CD流水线里做两遍编译:Debug版关LTO,Release版开LTO。这样既保证了调试体验,又拿到了最小的发布固件。

4.4 strip与sstrip:最后的瘦身

编译出来的ELF文件里,有很多调试信息、符号表、注释段。这些在运行时完全没用,但占着空间。strip工具就是干这个的。

标准strip命令能去掉:

  • .symtab(符号表)
  • .debug_*(调试信息段)
  • .comment(注释段)
  • .note(备注段)

用法很简单:

# 去掉所有符号和调试信息
arm-none-eabi-strip --strip-all firmware.elf

# 只保留外部符号(用于动态链接)
arm-none-eabi-strip --strip-debug firmware.elf

但标准strip有个局限:它不会处理段之间的对齐填充。sstrip(超级strip)更进一步,它会重新排列段,消除对齐空洞,甚至合并相邻的只读段。

实际效果对比(以Cortex-M4固件为例):

处理方式固件大小节省比例
原始ELF(含调试)512KB-
strip --strip-all128KB75%
sstrip121KB5.5%(额外)

sstrip的用法:

# 先strip再sstrip
arm-none-eabi-strip --strip-all firmware.elf
sstrip firmware.elf

我曾经在一个项目里,strip之后固件是96KB,sstrip之后变成了91KB。别小看这5KB,对于Flash只有128KB的芯片来说,这5KB可能就是加不加功能的分界线。

注意:sstrip会破坏调试信息,所以一定要在最终发布前才做。而且sstrip后的文件不能再被gdb加载调试——它把段表都重写了。

4.5 综合优化流程

说了这么多,实际项目中怎么组合使用?我一般按这个流程来:

  1. 开发阶段:-O0 -g,不开任何优化,方便调试
  2. 功能验证后:-Os -flto,开启尺寸优化和LTO
  3. 手动调整:对热路径函数加always_inline,对冷路径加noinline
  4. 发布前:strip --strip-all,去掉所有符号
  5. 最终发布:sstrip,极致压缩

你想想看,一套流程走下来,固件从开发版的500KB缩到发布版的90KB,是不是很有成就感?

代码大小优化流程 开发阶段 -O0 -g 功能验证后 -Os -flto 手动调整 inline/noinline 发布前 strip --strip-all 最终发布 sstrip 迭代优化 最终效果 固件缩小60-80% 本章知识体系 -Os 尺寸优化 函数内联控制 LTO 链接优化 strip/sstrip

最后说一句:代码大小优化不是一锤子买卖。每次加新功能,都记得跑一遍尺寸对比。我习惯在Makefile里加一个make size目标,自动编译、strip、然后打印固件大小。这样每次提交代码都能看到尺寸变化,心里有数。

可可精品资料库