
作为一个常年跟嵌入式固件打交道的开发者我越来越确信一件事编译体积优化不只是“省几个KB”的抠门行为它直接决定了产品的硬件成本、启动速度、OTA成功率甚至在某些场景下决定了方案能不能落地。很多朋友在项目里遇到Flash不够用第一反应是换大容量芯片但有时候一颗芯片的差价就是几毛钱甚至几块钱批量一上就是几十万片这笔账算下来你会心甘情愿地把时间花在“抽脂”上。这篇文章我准备从CMake构建系统的角度切入把固件体积优化的底层逻辑完整拆一遍。核心会围绕几个关键词展开编译选项、链接器行为、section的合并与裁剪、map文件的读法以及一套能直接抄作业的CMake配置模板。不管你是刚接触嵌入式开发的学生还是在职的固件工程师只要能看懂C代码、能跑通一个基础CMake工程这篇文章的内容就能直接用在你自己的项目里。说句实在话这类知识散落在编译器手册、链接器文档和各种零碎的技术帖里很少有人把它们串成一条完整的逻辑链。我写这篇就是想把这个链条补齐从源码到目标文件从目标文件到可执行镜像每一步体积是怎么“膨胀”的又该在哪里“动刀”。1. 体积问题的根源与优化思路1.1 固件体积为什么会失控很多人对固件体积的第一印象是“代码多所以体积大”但实际做优化的时候你会发现体积膨胀的原因远比“代码多”复杂。我总结下来固件体积失控主要有三大源头。第一个源头是库函数的默认链接行为。C标准库里的某些函数比如printf家族如果你用的实现比较重比如某些桌面系统的glibc风格那一个printf就能拖进来好几KB甚至十几KB的附属代码。即便你只用printf输出一个字符串链接器为了满足格式化和浮点打印等潜在需求会把一整套机制都塞进最终固件里。这在资源紧张的MCU上是非常奢侈的。第二个源头是函数和数据的默认放置方式。在没有人为干预的情况下编译器生成的每个函数单独成段但链接的时候是否把所有用到的函数都收进来、把没用的裁掉取决于链接脚本和链接器参数。尤其当你使用了静态库.a文件库里面可能包含几十个函数你只用了其中3个结果链接器如果没开死代码裁剪整个库模块都会被拖进来。第三个源头是编译选项的保守设置。大部分IDE创建工程时默认的优化等级是-O0或-O1这样的代码体积会明显大于-Os。而且默认情况下调试信息是完整保留的调试信息在Flash里也是要占地方的虽然它在map文件里看不见。所以很多时候你并不是真的需要那么多代码只是构建配置太“富裕”了。这三个源头指向同一个结论体积优化必须从构建系统层面发力而不是在代码层面一块一块地抠。因为编译器比你更清楚哪些代码是冗余的你要做的是给它正确的指令。1.2 体积优化的三个层次根据我自己的实践固件体积优化可以分成三个层次从易到难、从收益大到收益小。第一层是配置优化。这一层不改变代码只调整编译选项和链接选项收益通常最大。典型操作包括开启-Os、开启-ffunction-sections -fdata-sections、开启链接器的--gc-sections、关闭RTTI和异常C项目、裁剪标准库等。很多项目做到这一层就能瘦身30%~50%而且是纯“白嫖”式的优化风险最低。第二层是代码重构。比如把重复的初始化流程抽成通用函数、把常量的存储类型从const挪到Flash、避免在头文件里定义大数组、用查表代替实时计算等。这一层需要你对业务逻辑有一定的重构意愿收益也是实实在在的但需要花费精力去验证功能。第三层是算法级优化。比如把浮点运算改成定点运算、用更紧凑的编码格式、精简通信协议字段、用DMA代替CPU搬运等。这层优化不仅减体积通常还能提性能但开发成本最高需要深入理解业务。我见过不少开发者一上来就搞第三层在算法层面精雕细琢结果编译选项里连-Os都没开这是典型的捡芝麻丢西瓜。正常情况下你要做的是从第一层起步把能白嫖的收益全部拿到手再决定要不要动代码。这篇文章的重点就是第一层里的完整打法。1.3 从ELF的视角理解体积构成想在构建层面做好优化你必须理解一个ELF可执行文件或者MCU用的axf/elf文件里面到底放了什么。用size命令扫一下你会看到text、data、bss三个段。text段存放代码和只读常量这部分在MCU上通常放在Flash里。data段存放已初始化的全局变量和静态变量初值在Flash里运行时会被拷贝到RAM。bss段存放未初始化的全局变量和静态变量不占Flash空间只占RAM空间。很多人容易忽略的是data段在Flash里存放的初值镜像也是要占空间的这部分在链接脚本里通常以__data_load__这类符号标记。所以你在看Flash占用时不能只看text段而要关注最终镜像文件如bin、hex的实际大小。还有一个特殊的地方是.rodata段它存放只读数据。如果你的代码里有const char* str hello这类字符串字符串内容就在.rodata里。某些链接脚本会把.rodata单独安排或者在读时拷贝如XIP模式下直接从Flash读取和写时拷贝如非XIP模式需要搬运到RAM之间做选择。不同的安排方式对Flash/RAM体积的权衡完全不同这也是为什么同一个工程换一下链接脚本体积可能差很多的原因。理解这些段的意义在于当你打开map文件看到体积明细时你能大概猜出是哪一类变量或函数占了大头不会一头雾水。2. 核心编译选项详解与CMake落地方案2.1 -Os到底优化了什么-Os是GCC里面向体积的优化等级它基于-O2的优化集合但会额外禁用那些可能增加代码体积的优化。比如某些循环展开在-O2下可能会让代码变大而-Os会克制地不展开或者在展开与不展开之间通过估值模型选择体积更小的方案。很多人担心-Os会影响性能。这个担心部分有道理但嵌入式场景下大部分代码是逻辑控制类代码性能瓶颈更多在硬件外设和算法复杂度上编译优化等级对整体性能的影响通常不超过10%。对MCU项目来说省下的Flash空间能换来更低成本的芯片选型这个交易非常划算。在CMake里我一般这样设置if(CMAKE_BUILD_TYPE MATCHES Release|MinSizeRel) add_compile_options(-Os) endif()但这里有个细节需要提醒CMake自己的构建类型会覆盖编译选项。如果你用了CMAKE_C_FLAGS_RELEASE里自带的-O3那后加的add_compile_options(-Os)优先级是低于变量里直接写的flags的可能不会生效。更靠谱的做法是直接覆盖对应构建类型的flagsset(CMAKE_C_FLAGS_MINSIZEREL -Os -DNDEBUG) set(CMAKE_C_FLAGS_RELEASE -Os -DNDEBUG)这么做有个坑NDEBUG会关掉assert如果工程有测试代码依赖assert可能导致线上行为变化。我的习惯是正式版本里不依赖assert做业务判断这类宏只用于开发期自检所以开NDEBUG没有心理负担。另外-Os和-O2存在“各有所长”的情况。若你的项目里某几个函数对性能极其敏感可以在文件级对它们单独指定更高优化等级。CMake里可以给特定源文件设置编译选项set_source_files_properties( sensor_fusion.c PROPERTIES COMPILE_OPTIONS -O2 )这样既有整体的小体积又保住了关键路径的性能。2.2 -ffunction-sections和--gc-sections的黄金组合很多人听过这两个选项但搞不清它们为什么必须配在一起用。我尽量用最简单的方式讲清楚。GCC在默认情况下会把一个源文件里所有函数放在同一个.text段里所有数据放在同一个.data段里。链接的时候链接器以段为单位决定要不要把内容放进最终镜像。如果你引用了这个文件里的任何一个函数整个.text段都被标记为“需要”段里所有函数都进来了——哪怕你只用了其中一个。-ffunction-sections的作用是让编译器把每个函数单独放进一个section名称为.text.function_name每份数据也单独一个section。这样一来每个函数都成了独立的链接单元。配合链接器的--gc-sectionsgarbage collect sections链接器就能逐段检查被引用的段保留没被引用的段丢弃。GC机制对一个文件多个函数、一个静态库几十个模块的场景效果极其显著。比如你的utils.c里面有20个工具函数你只用了其中的2个默认链接下20个全都进固件开了GC后只进2个。这两项配在一起的标准CMake写法是add_compile_options(-ffunction-sections -fdata-sections) add_link_options(-Wl,--gc-sections)注意-ffunction-sections和-fdata-sections是编译期选项必须加在编译源文件的flags里--gc-sections是链接期选项必须加在链接flags里。很多人把-ffunction-sections加在链接选项里结果完全不生效这就是一个字亏。开了--gc-sections之后还有一个好处是错误提示会变少很多“未引用的函数”相关的告警不再出现因为根本没被保留。2.3 隐藏的“减肥药”-fno-exceptions、-fno-rtti与链接脚本配合如果你的项目是C写的或者部分模块用了C那体积优化又多了一个巨大的操作空间。C异常处理exceptions和运行时类型识别RTTI在MCU上几乎算不上刚需但它们会引入大量的运行时支撑代码包括类型表、栈展开信息这些在固件里动辄占用数KB。关闭它们的CMake写法add_compile_options(-fno-exceptions -fno-rtti) add_link_options(-Wl,--exclude-libs,libstdc_nano.a)最后这行--exclude-libs是针对ARM GCC工具链里nano库的保留处理具体名称要看工具链有的放矢地排除未用库不要盲目照抄。C语言环境下没有异常和RTTI这回事但标准库的体积也需要关注。ARM GCC工具链通常提供newlib-nano版本的标准库它比完整版的newlib小很多代价是部分printf功能受限比如默认不支持浮点打印。在CMake里add_link_options(-Wl,--specsnano.specs)配合-u _printf_float若需要浮点打印可以手动引入浮点支持。这是一个典型的“质量和体积”的权衡。实际项目里我通常的做法是如果只需要整型日志直接用nano.specs不启用浮点打印。如果需要浮点日志先尝试用自己的定点转字符串函数代替printf而不是直接加_printf_float。2.4 链接脚本裁剪别让你的section“平白无故”变大链接脚本.ld文件对体积的影响往往被忽略但它其实非常关键。举个例子某芯片的Flash从地址0x08000000开始默认链接脚本可能会把.rodata和.text连续放置中间保留一些对齐padding。如果你的FLASH区域定义里留了太多“保留区”用于OTA双Bank之类的预留那计算整体镜像的时候某些工具会把这部分保留空间也算进去造成“明明代码没多大镜像却很大”的假象。这种问题不一定是链接脚本的“错”而是使用方式的问题。但有两种情况是链接脚本本身导致体积浪费第一种每个输入section都强制对齐到较大字节数。比如某些脚本写.text : ALIGN(8)那每个段之间就可能出现最多7字节的padding上百个函数段累计起来也是几百字节的浪费。第二种在脚本中用KEEP()强制保留了不该保留的section。KEEP()是gc-sections的豁免指令如果你在脚本里对某些调试段或未使用的中断向量表写KEEP()它们就永远不会被裁剪。这在特定启动流程里是必要的但如果KEEP的范围过宽就白白让体积变大。建议的做法是打开编译生成的.map文件找到各个段的加载地址和大小看看有没有异常的padding或KEEP段。map文件是体积优化里最重要的侦察工具下一节我会专门讲怎么高效地读它。3. map文件与size信息体脂率的“体检报告”3.1 快速读懂map文件很多朋友不敢碰map文件因为动辄几万行看着就头大。但其实你只需要关注三个区域就能掌握80%的信息。第一个区域是Linker script and memory map这是map文件的主干按内存区域列出所有被链接的section。你重点看每一个section的大小和归属目标文件。比如.text.foo 0x0000000008012340 0x120 ./src/foo.o这意味着foo.o里的foo函数占用了0x120字节。如果你发现某个.o文件下有几十个section而整个工程实际用到的只是一两个函数你就知道这个文件被打包得过于完整了。第二个区域是Discarded input sections在开启--gc-sections后map文件会单独列出被丢弃的section。扫一眼这个区域可以确认GC是否生效。如果你发现一个体积很大的section出现在“discarded”里说明它被正确地裁掉了如果它出现在正式链接区里而你确信代码没用到它那就要检查是不是有动态引用或者KEEP。第三个区域是Memory Configuration它列出了FLASH和RAM的总大小、已用、剩余。这一般在map文件的末尾或者头部是判断当前余量的最快路径。3.2 用size命令建立量化基线在你开始优化之前必须记录一份“优化前”的体积数据每做一步改动就重新记录一次。没有量化基线所有优化效果都只是感觉。GCC工具链自带的size命令是量化基线的利器arm-none-eabi-size build/firmware.elf输出结果类似text data bss dec hex filename 12340 1120 2040 15420 3c3c firmware.elf这里text表示代码和只读数据data是已初始化数据bss是未初始化数据dec是三者总和不是Flash总占用哦。MCU的Flash总占用通常等于text data因为data的初值要存进Flash而RAM占用等于data bss。如果你希望看到“最终烧录镜像”的大小直接看.bin或者.hex文件的体积更直观arm-none-eabi-objcopy -O binary build/firmware.elf build/firmware.bin ls -l build/firmware.binbin文件的大小才是真正烧进Flash的量不含地址信息也是你优化时最该盯着的指标。3.3 从map定位“大块头”一个实用技巧有一种情况经常碰到总体看来每个文件都挺正常但Flash还是不够用。这时候你要学会用脚本抓“体积Top N”。我常用的一个命令组合grep -E ^\.(text|rodata|data) build/firmware.map | awk {print $3, $2} | sort -k2 -rn | head -20这段命令会从map文件里提取所有section的名字和大小按大小降序取前20个。多数情况下“罪魁祸首”立刻浮出水面可能是一个巨大的查表数组、一个过度模板化的C函数、或者长期没清理的调试函数。我见过一个最极端的例子某项目的map文件里出现了一个5KB的static数组来源是一个键盘扫描的const映射表但实际业务早就改成配置读取了这个表只是历史遗留。因为它在头文件里是static const每个包含它的源文件都生成一份拷贝只因为一个文件引用了它所有拷贝都被链进来了。这就是一个典型的“用GC救不了的场景”因为每份拷贝都有静态链接关系必须从代码层面删除。这类经验说明map文件不仅能定位“哪个段大”还能帮你发现“同一个数据被复制了多少份”。建议你每轮优化后都把Top N表格记录到笔记里方便对比。4. 实操一个模拟项目的完整瘦身过程4.1 项目背景与初始优化为了把这套逻辑串起来我准备用一个模拟项目示例来说话你可以把它理解成一台基于某Cortex-M0 MCU的温控器固件功能包括按键扫描、LCD显示、温度采集、PID控制、串口日志。假设它的Flash大小为32KB而初始编译出来的固件有34KB显然超了。第一步我会先建立基线数据。打开工程用默认Release配置编译运行size命令假设得到text data bss dec hex filename 28932 1560 2340 32832 8030 firmware.elfFlash占用是28932 1560 30492字节约29.8KB已经接近32KB了如果再加点功能就爆。第二步我把构建类型切到MinSizeRel并在CMakeLists.txt里显式覆盖优化选项为-Os同时加上add_compile_options(-ffunction-sections -fdata-sections) add_link_options(-Wl,--gc-sections)重新编译再查体积text data bss dec hex filename 20130 1480 2340 23950 5d8e firmware.elfFlash占用变成20130 1480 21610字节约21.1KB。直接省下8.7KB瘦身28.5%。这一步没改一行应用代码。4.2 进一步裁剪标准库替换与printf优化启动GC后再看体积发现串口日志模块的printf占了不小的空间。我决定把标准库从完整newlib切换到newlib-nano同时停用浮点打印目前日志里的浮点参数全改成整型毫值传输。CMake链接选项add_link_options(-Wl,--specsnano.specs)重新编译得到text data bss dec hex filename 16250 1440 2340 20030 4e3e firmware.elfFlash占用约17.3KB比第一步又省了4.3KB。这里有个小插曲切到nano库之后printf不再支持%f我之前日志里有几处浮点格式化编译直接报错链接告警或运行时输出空。处理方式是把浮点值乘以1000转换成整型用%d打印毫值然后手动在小数点位置插入符号。这需要改几行代码但换来的体积收益是实打实的。如果你确实需要浮点打印可以在nano库基础上加-u _printf_float体积会回退一些但通常仍比完整newlib小。具体要不要为浮点打印多付费取决于你的产品需不需要在日志里输出小数。4.3 这块不罢工bootloader与RAM函数导致的“假大个”有些朋友按上面的流程跑完后可能会发现某个链接脚本里KEEP()的section体积很大比如中断向量表或者启动代码。这时候千万别一股脑把KEEP删掉你必须先搞清楚哪些section是运行时的“地基”。我当时就踩过一个坑为了瘦身把启动文件里的.isr_vector的KEEP()注释掉了结果芯片一上电就跑飞。原因很简单中断向量表虽然有冗余项但它的地址被芯片硬件固定为Flash起始位置链接器必须把它放在那里任何“裁剪”的念头都是自作聪明。但这不代表KEEP区域不能优化。常见的可行方向是精简启动文件里未用到的中断处理函数。中断向量表里每个表项默认指向一个弱定义的Default_Handler如果链接脚本把整个.isr_vector定义为KEEP那么即使某些中断完全没被使用表项本身会保留但Default_Handler的代码只有一个实例不会被重复复制。真正会膨胀的是那些你写了中断处理函数但系统里根本没触发源的中断——它们占了处理函数代码但这部分逻辑本身也该在功能里删掉。另外如果项目有RAM执行需求比如Flash擦写期间从RAM执行代码-fdata-sections可能会把RAM函数拆得更细要注意确保RAM函数段被正确放置在可执行区域且SECTIONS命令里对这些段有明确的装载和执行地址。这些属于链接脚本语法层面的事我建议在优化前先做一次脚本备份。4.4 符号裁剪与strip选项还有一个容易被忽略的空间吃掉者是符号表和调试信息。在Release构建里默认的CMAKE_BUILD_TYPE会加-g吗不会默认不加。但如果你从别的构建系统迁移过来可能还带着调试符号。这时候最终镜像里会包含大量符号表尤其在某些工具链下即便不开-g也可能因为库本身带符号而增加体积。稳妥的做法是在正式镜像生成阶段统一stripadd_link_options(-Wl,--strip-all)或者如果你需要保留部分符号用于后续崩溃定位比如通过addr2line反查函数名那就别用--strip-all而是一边在编译选项开-g生成独立的调试文件一边在链接阶段用-Wl,--strip-debug只去掉调试信息。这样既能得到瘦身固件又能保留可供离线调试的symbol信息。这一步做完后我的模拟项目最终体积大约为16KB左右和最初的30KB相比几乎是“对半砍”。而整个过程除了改了几行日志格式、删了一个历史遗留的大数组其余没有动业务逻辑。5. 常见问题与排查实录5.1 开了gc-sections为什么体积没变化如果你确认已经开了-ffunction-sections -fdata-sections和-Wl,--gc-sections但体积一点没变不要慌。你要检查三件事编译选项有没有真正应用到源文件上很多CMake工程子目录里用add_compile_options时作用域没覆盖到目标源文件或者被target_compile_options的配置覆盖了。链接选项有没有放在正确位置add_link_options和target_link_options的生效时机不同如果用add_link_options放在add_executable之后有时因为目录作用域的问题没被传递。是不是所有section都已经被“最小化”了如果你源文件每个函数本来就只有极少量代码且全部被main直接或间接引用那GC当然裁不掉东西。排查手段很简单打开map文件看看有没有Discarded input sections列表。如果这个列表是空的说明GC压根没工作如果列表里有内容但总体积没变说明丢弃的内容本来就不大。5.2 修改优化等级后功能异常从-O0切到-Os之后出现功能异常是很正常的现象但根因往往不在优化选项本身而在代码里存在未定义行为UB。比如未初始化的局部变量被读取整数溢出后的符号依赖依赖回绕行为多线程/中断环境下的共享变量缺少volatile用指针别名修改了编译器认为不会修改的数据这类问题在低优化等级下可能恰好“表现正常”但优化器在-Os下会做更激进的假设UB就被显现出来了。处理方式是在源文件里修复UB而不是退回-O0。如果实在定位不到可以用Git二分法配合编译选项在两个commit间切换找出“引入优化问题”的代码段。5.3 关于strip掉符号后无法定位崩溃很多团队在优化体积后遇到崩溃问题但这时候固件已经被strip了用addr2line也查不出函数名。我的习惯是这样的从CMake层面保留一个带完整调试符号的elf文件编译选项开-g但生成的bin/hex在发布前做一遍strip。这样Flash里的固件是瘦的而你本地留了完整的elf出现问题时用backtrace里的PC地址对照elf内的地址段就能解析出具体函数。add_custom_command(TARGET firmware POST_BUILD COMMAND ${CMAKE_OBJCOPY} -O binary $TARGET_FILE:firmware firmware.bin COMMAND ${CMAKE_OBJCOPY} --strip-debug $TARGET_FILE:firmware firmware_release.elf )这样firmware.elf是完整调试版firmware_release.elf是精简版而firmware.bin是烧录文件。实际排障的时候用完整elf解析发布的时候用精简elf作为版本存档。5.4 map文件太大打开卡顿如果map文件非常大可以用grep和awk做定向提取而不是整套打开。我给自己定了一个速查流程查看总Flash/RAM使用在map里搜Memory Configuration直接看最后几行。查看最大段用之前的Top N命令。查看某函数是否被裁剪在map里搜函数名如果在Discarded区出现说明它被裁了如果在主区出现说明被保留。这套流程比打开一个几十万行的文件从头翻高效得多。6. 我自己的几条“顽固”经验做了这么多年的固件构建有几条经验我想特别拿出来说因为它们每一句话背后都有实打实的教训。第一条优化前后每一步都要量体积。不要凭感觉判断哪一步起了作用用脚本记录每一次编译的size输出形成一张趋势表。很多时候你以为某个选项是“主刀”实际上另一个选项早就把肉割完了。第二条验证功能要在优化后的完整固件上进行。只优化不测试等于没有优化。我在一个项目里就遇到过切了-Os之后一个延时循环被优化器改变了循环结构导致时序偏差电机控制出现轻微抖动。这类问题只有在上板测试时才能发现。所以建议每次优化完至少冒烟测试一遍关键外设路径。第三条把构建配置当作代码来维护。为编译选项写清晰的注释在CMake里把“Release”和“Debug”的差异显式列出来这样后来的人才能理解为什么某些选项要这么设而不是两眼一抹黑地改。我见过太多项目的CMakeLists被改得面目全非没人说得清每一个flag是干嘛的。第四条给自己留出体积余量。即便优化到目标范围内我通常还会再多留出15%~20%的Flash余量给后续功能迭代和bug修复留空间。如果优化后你的Flash只剩5%那下次加个功能又得重新折腾一轮而且还可能面临“改一行代码就要重新做一次全量验证”的窘境。最后再说一个小技巧当你拿到一个“没优化过”的老工程时先不要改任何代码直接把整个工程的构建选项拍下来然后在自己的分支里做一轮“配置级优化”记录体积变化。一旦功能测试通过再考虑代码级优化。我见过不少团队因为一开始就动了业务代码导致优化出了线上事故回头排查时根本分不清是构建配置的锅还是代码重构的锅。把改动面控制和隔离好是体面地完成固件“抽脂”手术的先决条件。希望这套逻辑对你有用也欢迎你拿着具体的map文件数据来跟我讨论。天下没有减不掉的固件只有没找对方向的构建配置。