第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-all | 128KB | 75% |
| sstrip | 121KB | 5.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 综合优化流程
说了这么多,实际项目中怎么组合使用?我一般按这个流程来:
- 开发阶段:
-O0 -g,不开任何优化,方便调试 - 功能验证后:
-Os -flto,开启尺寸优化和LTO - 手动调整:对热路径函数加
always_inline,对冷路径加noinline - 发布前:
strip --strip-all,去掉所有符号 - 最终发布:
sstrip,极致压缩
你想想看,一套流程走下来,固件从开发版的500KB缩到发布版的90KB,是不是很有成就感?
最后说一句:代码大小优化不是一锤子买卖。每次加新功能,都记得跑一遍尺寸对比。我习惯在Makefile里加一个make size目标,自动编译、strip、然后打印固件大小。这样每次提交代码都能看到尺寸变化,心里有数。
可可精品资料库