4、依赖分析:如何识别编译产物中的未使用代码、依赖树分析工具(如nm、objdump、readelf)的使用方法

TFLite Micro 的编译体积膨胀,往往不是某一个源文件写得太大,而是链接器把大量“看似需要、实则无用”的目标文件拉入了最终固件。要精准瘦身,必须从编译产物出发,逆向分析链接过程,找出哪些代码被保留、哪些符号被引用、哪些段可以被剥离。本章围绕依赖分析这一核心主题,讲解如何利用 nm、objdump、readelf 等工具,构建从 ELF 文件到源码依赖的完整分析链路。

依赖分析工具链与未使用代码识别流程 编译产物 .elf / .a / .o 含调试信息 readelf nm objdump 段信息 .text/.data/.bss 符号表 全局/局部/未定义 反汇编 函数级代码定位 未使用代码 死代码/冗余依赖 可裁剪目标文件 ① 查看段大小 readelf -S ② 列出未定义符号 nm -u ③ 定位引用来源 objdump -t / -r ④ 裁剪并验证 -ffunction-sections + --gc-sections 核心思路:从 ELF 段 → 符号表 → 引用关系 → 裁剪决策 工具组合:readelf 看结构,nm 看符号,objdump 看指令级引用

4.1 核心原理:链接器如何决定代码去留

嵌入式 C/C++ 项目的编译过程分为编译、汇编、链接三个阶段。链接器(如 GNU ld、LLD)根据符号引用关系,将多个目标文件(.o)合并为可执行文件。默认情况下,链接器以“目标文件”为最小粒度:只要某个 .o 文件中有任何一个符号被引用,整个目标文件都会被链接进最终产物。这意味着,一个包含 10 个函数的 .o 文件,即使只有 1 个函数被调用,其余 9 个函数也会全部被保留。

TFLite Micro 的代码库中,tensorflow/lite/micro/kernels/ 目录下每个算子通常对应一个独立的 .cc 文件,编译后生成一个 .o。如果链接器以文件为单位,那么只要注册了某个算子,该文件内所有辅助函数都会被带入。更严重的是,某些公共工具文件(如 micro_utils.cpp)被多个模块引用,导致大量辅助代码被间接拉入。

关键概念:链接器的“垃圾回收”(--gc-sections)机制可以打破文件粒度的限制。它要求编译器在编译时使用 -ffunction-sections(将每个函数放入独立段)和 -fdata-sections(将每个全局变量放入独立段),链接器才能识别并丢弃未被引用的段。但前提是:代码中不能有通过函数指针、反射、汇编跳转等“隐式引用”的符号。

4.2 判断方法:三大工具的使用流程

依赖分析的核心是回答三个问题:哪些段被链接了?哪些符号被引用了?哪些代码实际被执行? 下面按操作顺序介绍工具用法。

4.2.1 readelf:查看 ELF 结构

readelf 是查看 ELF 文件结构最全面的工具。常用参数包括:

  • -S:列出所有段(section),包括段名、类型、地址、大小。
  • -s:显示符号表,包括全局符号、局部符号、未定义符号。
  • -l:查看程序头(segment),了解加载布局。
  • -A:显示架构属性。

实际使用中,最常用的是 -S 和 -s。例如:

# 查看段大小,快速定位体积大户
readelf -S build/tflite_micro.elf | grep -E "\.text|\.data|\.bss"

# 查看符号表,关注未定义符号(需要外部解析)
readelf -s build/tflite_micro.elf | grep UND

段大小是初步判断依据:如果 .text 段异常大(例如超过 100KB),说明有大量代码被链接进来。此时需要进一步定位是哪些目标文件贡献了这些代码。

4.2.2 nm:符号表分析

nm 专门用于列出目标文件的符号。常用参数:

  • -u:仅显示未定义符号(即需要外部提供的符号)。
  • -C:解码 C++ 符号名(否则看到的是 mangled 名称)。
  • -S:显示符号大小(如果存在)。
  • -n:按地址排序。

典型用法:

# 列出所有全局函数符号及其大小
nm -C -S --size-sort build/tflite_micro.elf | grep " T "

# 查看未定义符号,判断哪些库函数被引用
nm -C -u build/tflite_micro.elf

通过 nm 可以快速发现:哪些函数占用的体积最大、哪些符号是未定义的(可能来自 libc 或第三方库)。例如,如果发现 malloc、free 被引用,说明代码中使用了动态内存分配,这在 TFLite Micro 中通常是不推荐的。

4.2.3 objdump:反汇编与引用定位

objdump 是功能最强大的工具,可以反汇编代码、查看重定位信息。常用参数:

  • -d:反汇编可执行段。
  • -t:显示符号表。
  • -r:显示重定位信息(哪些地方引用了外部符号)。
  • -h:查看段头。

当需要确认某个函数是否被实际调用时,反汇编是最可靠的方法:

# 反汇编并查找对特定函数的调用
objdump -d build/tflite_micro.elf | grep -A5 "call.*my_function"

# 查看重定位表,了解哪些目标文件引用了外部符号
objdump -r build/tflite_micro.elf | grep "R_ARM_CALL"

反汇编还能帮助识别“间接引用”——例如通过函数指针表调用。如果某个函数只出现在数据段(作为函数指针数组的元素),--gc-sections 可能无法识别这种引用,需要手动保留。

4.3 案例拆解:定位一个 20KB 的“幽灵”依赖

某智能语音项目使用 TFLite Micro 部署关键词识别模型,编译后固件体积为 128KB,超出 Flash 容量限制(96KB)32KB。初步使用 readelf -S 发现 .text 段为 85KB,远大于预期。

分析过程:

  1. 按目标文件统计体积:使用 nm -S --size-sort 对每个 .o 文件单独执行,发现 kernels/conv.cc.o 贡献了 18KB,memory_planner.cc.o 贡献了 6KB,其余文件均在 2KB 以下。
  2. 检查引用关系:对 conv.cc.o 执行 nm -C,发现其中包含 12 个函数,但项目实际只使用了 ConvEval 和 ConvInit 两个函数。其余 10 个函数(如 Conv1x1Eval、DepthwiseConvEval)并未被直接调用。
  3. 确认链接粒度:查看编译选项,发现未使用 -ffunction-sections。因此链接器将整个 conv.cc.o 作为一个整体拉入,导致 10 个无用函数全部被保留。
  4. 验证间接引用:使用 objdump -t 检查符号表,确认 Conv1x1Eval 等函数没有被函数指针表引用,属于纯死代码。

解决方案:

# 修改 CMakeLists.txt,添加编译选项
target_compile_options(tflite_micro PRIVATE
    -ffunction-sections
    -fdata-sections
)
target_link_options(tflite_micro PRIVATE
    -Wl,--gc-sections
    -Wl,--print-gc-sections  # 打印被回收的段,便于验证
)

结果对比:

指标 优化前 优化后 变化
.text 段大小 85KB 61KB -24KB
固件总大小 128KB 94KB -34KB
链接时间 1.2s 1.4s +0.2s

风险与代价:启用 --gc-sections 后,如果代码中存在通过内联汇编、动态链接或特殊 section 属性引用的符号,可能被误删。例如,某些中断服务函数(ISR)通过向量表引用,需要显式标记 __attribute__((used)) 或放入自定义段。此外,链接时间略有增加,但可接受。

注意:不要盲目相信 --gc-sections 能解决所有问题。如果代码中使用了函数指针数组(如算子注册表),链接器无法判断这些指针是否会被调用,因此会保守地保留所有被指针引用的函数。此时需要手动审查注册表,移除未使用的算子。

4.4 常见误区与总结复盘

在依赖分析实践中,开发者常犯以下错误:

  • 只看总大小,不看段分布:固件体积大可能是 .rodata(只读数据)或 .bss(未初始化数据)导致的,不一定是代码段问题。应先通过 readelf -S 明确各段占比。
  • 忽略静态库的链接行为:静态库(.a)的链接粒度是“成员文件”,而不是“函数”。如果 libtflm.a 中某个 .o 被引用,整个 .o 都会被拉入。此时需要拆分源文件,将独立功能拆分为更小的编译单元。
  • 混淆“未定义符号”与“未使用代码”:nm -u 列出的未定义符号表示需要外部提供,并不代表这些符号对应的代码会被执行。例如,memcpy 被引用但可能只在错误处理路径中调用。
  • 忘记检查链接器脚本:某些链接脚本(.ld)会强制保留特定段(如 KEEP() 指令),即使这些段未被引用。检查 --print-gc-sections 输出时,如果发现某些段被保留但未打印,可能是链接脚本强制保留。

推荐的分析流程:

  1. 使用 readelf -S 查看段大小,确定体积瓶颈在代码段、数据段还是只读数据段。
  2. 使用 nm -C -S --size-sort 列出最大的函数符号,定位体积贡献者。
  3. 对可疑目标文件执行 nm -C,列出所有函数,与代码中的实际调用点对比。
  4. 使用 objdump -d 验证间接引用(函数指针、回调表)。
  5. 启用 -ffunction-sections + --gc-sections,并用 --print-gc-sections 验证回收效果。
  6. 检查链接脚本中的 KEEP() 指令,确保没有强制保留无用段。

经验之谈:在 TFLite Micro 项目中,最有效的瘦身手段往往不是删除代码,而是调整编译选项和链接选项。将 -ffunction-sections 和 --gc-sections 作为默认选项,可以在不修改任何源码的情况下,平均减少 15%-30% 的代码体积。但务必在 CI 流程中加入链接后体积检查,防止回归。

依赖分析不是一次性的工作,而是贯穿整个开发周期的持续活动。每次添加新算子或工具函数时,都应该用 nm 检查新增符号是否被实际引用。通过建立“编译产物体积监控”机制,可以尽早发现体积膨胀的趋势,避免在项目后期陷入被动的优化困境。