4、代码裁剪基础:使用编译器选项(如-ffunction-sections、-fdata-sections)和链接器垃圾回收(--gc-sections)减少无用代码

TFLite Micro 在嵌入式平台上的 Flash 占用通常在 50KB 到 200KB 之间,而许多 MCU 的片上 Flash 仅有 64KB 或 128KB。面对这样的容量约束,代码裁剪是最直接、成本最低的优化手段。本章聚焦于编译器与链接器层面的裁剪技术——通过 -ffunction-sections、-fdata-sections 和 --gc-sections 的组合,将未使用的函数和数据从最终固件中剔除。这三项选项的配合使用,往往能减少 20% 到 40% 的 Flash 占用,且几乎不需要修改任何业务代码。

代码裁剪链路:从源码到 Flash 固件 源码 (.c/.cpp) 包含所有函数定义 包含所有全局变量 编译 (-ffunction-sections) 每个函数独立 .text.funcname 段 每个变量独立 .data.varname 段 链接 (--gc-sections) 垃圾回收未引用段 只保留可达代码 最终固件 (.elf/.bin) 仅包含被引用的函数与数据 未启用裁剪 • 所有函数均链接进固件 • 未使用的回调、调试函数仍占用 Flash • 典型 Flash 占用:120KB • 链接时间短,但体积大 启用裁剪 • 仅保留被引用函数 • 未使用代码自动剔除 • 典型 Flash 占用:78KB (-35%) • 链接时间略增,体积显著减小

上图展示了从源码到最终固件的完整裁剪链路。编译器负责将每个函数和全局变量放入独立的段(section),链接器则根据入口点(如 Reset_Handler)进行可达性分析,回收所有未被引用的段。这一机制的核心思想是:让链接器能够“看见”哪些代码是真正需要的,从而精确剔除无用部分。

4.1 核心原理:段(Section)与垃圾回收机制

在默认编译模式下,编译器会将所有函数和全局变量放入统一的 .text 和 .data 段中。这意味着链接器只能以整个段为单位进行链接——只要有一个函数被引用,整个 .text 段中的所有函数都会被链接进固件,即使其中 90% 的函数从未被调用。

启用 -ffunction-sections 后,编译器为每个函数生成独立的段,命名格式为 .text.function_name。同理,-fdata-sections 为每个全局变量生成独立的 .data.variable_name 段。此时链接器获得了“细粒度”的控制能力,可以结合 --gc-sections 选项,从入口点开始进行引用遍历,将所有未被引用的段标记为“垃圾”并从最终镜像中移除。

关键理解:这三项选项的组合本质上是将“链接粒度”从“文件级”降低到“函数级”。默认情况下,链接器以目标文件(.o)为最小单位;启用这些选项后,最小单位变为单个函数或单个变量。粒度越细,可裁剪的空间就越大。

值得注意的是,--gc-sections 的回收依据是“引用关系”,而非“是否被 main 函数直接调用”。只要某个函数被任何保留的函数所引用,它就会被保留。因此,中断处理函数、回调函数、构造函数等特殊入口点需要额外注意——它们可能没有显式的调用者,但必须被保留。

4.2 操作流程:在 TFLite Micro 工程中启用裁剪

在 TFLite Micro 的构建系统中,通常使用 CMake 或 Makefile 管理编译选项。以下是在典型 ARM Cortex-M 工程中的配置步骤:

  1. 修改编译选项:在 CMakeLists.txt 或 Makefile 中,为 C 和 C++ 编译器添加 -ffunction-sections -fdata-sections。
  2. 修改链接选项:为链接器添加 --gc-sections(GCC 工具链)或 --remove-section(ARMCC 工具链)。
  3. 检查链接脚本:确保链接脚本(.ld 文件)中的段定义与编译器生成的段名匹配。通常需要添加 KEEP() 指令保护关键段。
  4. 验证裁剪效果:编译后使用 arm-none-eabi-size 工具查看各段大小,对比裁剪前后的 Flash 占用。
# CMakeLists.txt 示例
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -ffunction-sections -fdata-sections")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -ffunction-sections -fdata-sections")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} --gc-sections")

# 链接脚本关键片段(.ld 文件)
.text :
{
  KEEP(*(.isr_vector))      /* 中断向量表必须保留 */
  *(.text*)
  *(.rodata*)
} > FLASH

.data :
{
  *(.data*)
} > RAM AT> FLASH

在 TFLite Micro 的 tensorflow/lite/micro 目录下,如果使用其自带的 Makefile 构建系统,可以通过 EXTRA_CFLAGS 和 EXTRA_LDFLAGS 变量传递这些选项:

make -f tensorflow/lite/micro/tools/make/Makefile \
  TARGET=cortex_m_generic \
  TARGET_ARCH=cortex-m4 \
  EXTRA_CFLAGS="-ffunction-sections -fdata-sections" \
  EXTRA_LDFLAGS="--gc-sections" \
  generate_hello_world_project

4.3 案例拆解:TFLite Micro 语音识别模型裁剪实战

某智能家居项目使用 STM32F411(512KB Flash)运行 TFLite Micro 的语音唤醒词识别模型。初始固件编译后 Flash 占用为 186KB,其中模型数据占 42KB,TFLite Micro 框架代码占 98KB,应用层代码占 46KB。项目要求将总占用控制在 150KB 以内,以预留 OTA 升级空间。

初始现象:使用默认编译选项,未启用任何裁剪。通过 arm-none-eabi-size 查看,.text 段为 92KB,.rodata 段为 48KB,.data 段为 6KB。

分析过程:使用 arm-none-eabi-nm --size-sort 列出所有符号,发现大量未使用的调试函数和备用算子。例如,TFLite Micro 的 AllOpsResolver 默认注册了所有算子,但实际模型仅使用 5 种算子。此外,框架中的 DebugLog 相关函数在 release 版本中完全未被调用。

操作步骤:

  1. 在 Makefile 中添加 -ffunction-sections -fdata-sections --gc-sections,重新编译。
  2. 使用 arm-none-eabi-nm 检查保留的符号,确认 DebugLog 系列函数已被移除。
  3. 进一步裁剪:将 AllOpsResolver 替换为 MicroMutableOpResolver,仅注册模型所需的 5 个算子。
  4. 对模型数据进行量化(从 float32 转为 int8),将模型数据从 42KB 压缩至 11KB。

结果对比:

优化阶段.text 段.rodata 段总 Flash 占用缩减比例
初始状态92KB48KB186KB—
启用 gc-sections61KB35KB142KB23.7%
替换 OpResolver48KB35KB129KB30.6%
模型 int8 量化48KB11KB105KB43.5%

风险与代价:启用 --gc-sections 后,链接时间从 8 秒增加到 15 秒,但仍在可接受范围。主要风险在于中断向量表和启动代码可能被误删,因此必须在链接脚本中使用 KEEP() 指令保护。此外,如果某些函数通过函数指针间接调用,链接器可能无法识别引用关系,导致误删。在本案例中,MicroMutableOpResolver 的注册函数通过函数指针表调用,需要确保这些函数被显式保留。

风险提示:在 TFLite Micro 中,算子注册表(OpResolver)是典型的“间接引用”场景。如果使用 AllOpsResolver,所有算子都会被保留;如果改用 MicroMutableOpResolver,则必须确保注册的算子函数被 KEEP() 或显式引用,否则链接器可能将其误判为无用代码而删除。

4.4 常见误区与总结复盘

误区一:认为启用裁剪后代码一定变小。实际上,如果工程中所有函数都被引用,裁剪效果有限。裁剪的效果取决于代码中“死代码”的比例。对于 TFLite Micro 这类框架,由于支持多种算子,死代码比例通常较高,裁剪效果显著。

误区二:忽略链接脚本的适配。仅仅添加编译选项而不修改链接脚本,可能导致链接失败或段重叠。特别是当使用 KEEP() 指令时,需要明确哪些段必须保留。

误区三:混淆 -ffunction-sections 与 -fdata-sections 的作用范围。前者只影响函数,后者只影响全局变量。局部变量和栈变量不受影响。对于 TFLite Micro 中的大型常量数组(如权重数据),它们属于 .rodata 段,需要 -fdata-sections 才能实现独立裁剪。

误区四:认为裁剪后调试变得困难。虽然裁剪会移除未使用的调试函数,但保留的代码仍然可以通过 -g 选项生成调试信息。建议在开发阶段关闭裁剪,在发布阶段启用。

经验总结:在 TFLite Micro 工程中,建议按以下顺序进行裁剪优化:

  1. 先启用 -ffunction-sections -fdata-sections --gc-sections,这是成本最低的优化。
  2. 检查链接脚本,确保中断向量表、启动代码、异常处理函数被 KEEP() 保护。
  3. 使用 arm-none-eabi-nm 和 size 工具分析裁剪后的符号表,确认关键函数未被误删。
  4. 如果裁剪效果不理想,再考虑替换 OpResolver、量化模型等更深入的优化。

通过本章的编译器选项配置,TFLite Micro 固件的 Flash 占用通常可以降低 20% 至 40%。这一优化不改变任何业务逻辑,仅通过构建系统的调整即可实现,是嵌入式 AI 部署中性价比最高的优化手段之一。在实际项目中,建议将裁剪选项作为默认配置,同时保留一个未裁剪的调试构建版本,以便在开发阶段进行问题定位。