静态库 --gc-sections 死代码剔除机制与实测:粒度是 section 不是符号

📅 2026/8/27 18:44:37
静态库 --gc-sections 死代码剔除机制与实测:粒度是 section 不是符号
很多人在链接脚本里加了--gc-sections就以为工程里没用到的函数会被自动剔除。我也这么以为过。直到有一次一个只做广播连接的最小从机 demo编译出来还是带着整套主机扫描、绑定的代码体积死活下不去。查了一圈才发现问题不在--gc-sections本身而在我对它剔的是什么的理解从一开始就错了。一、--gc-sections 到底在剔什么是 section不是符号先看 GNU ld 官方文档对--gc-sections的原话--gc-sectionsdecides which input sections are used by examining symbols and relocations. The section containing the entry symbol and all sections containing symbols undefined on the command-line will be kept... Once this initial set of sections has been determined, the linker recursively marks as used any section referenced by their relocations.关键词是input section。链接器做的是一次可达性分析从入口符号_start出发沿着重定位关系递归标记被用到的 section没被标记到的 section 整段丢弃。注意分析的最小单位是section不是符号。为什么是 section 而不是符号因为 ELF 里 section 是地址连续的一整块是链接器能整段丢弃的最小单元。一个 section 里塞了十个函数链接器没法只丢其中三个——那会从中间挖个洞后面所有函数的地址全得重排重定位表整个乱掉。按 section 整段丢就没这个问题地址连续性保得住。这就引出一个推论一个 section 里只要有一个符号被引用整个 section 都会被保留。哪怕另外九个函数你一个都没调用。二、-ffunction-sections让剔除能按函数粒度进行的前提既然--gc-sections按 section 剔那想让没用到的函数被剔掉前提就是每个函数各自独占一个 section。这正是-ffunction-sections干的事。GCC 文档原话Place each function or data item into its own section in the output file if the target supports arbitrary sections. The name of the function or the name of the data item determines the sections name in the output file.开了它编译器会把每个函数放进独立的.text.函数名section。这样--gc-sections才能按函数粒度判断这个函数的 section 没被引用整段丢掉。不开它呢一个.c文件编译出来的所有函数挤在一个.textsection 里。--gc-sections只能按这一整个.text判断——只要里面有一个函数被引用整块保留其余函数全跟着留下。所以-ffunction-sections是--gc-sections能按函数剔除的前提不是--gc-sections自己能决定的。-fdata-sections同理作用对象换成全局变量和静态变量拆成.data.name/.bss.name。三、静态库的坑.o 按需取用 单 .text 整块保留到这里逻辑还算清楚但静态库.a会让事情悄悄变味。.a本质是一堆.o的归档。链接器对静态库的处理是按需取用只有当某个.o里有被引用的符号时这个.o才会被取出来参与链接没被引用的.o根本不进链接。听起来很省问题在于取出来的.o如果没开-ffunction-sections它内部所有函数仍挤在一个.text里。--gc-sections的判断粒度就退化到了.o源文件级别这个.o里只要有一个函数被引用整个.o的所有函数全保留。举个具象的例子。某个库有个ble_adapt.c里面既实现了从机广播也实现了主机扫描、绑定、密钥管理。你的从机 demo 只调用了广播相关的几个函数。链接器发现广播函数被引用把ble_adapt.c对应的.o取出来。但因为这个.o是单.text主机扫描、绑定那一堆函数跟着这个.text一起被保留进了 elf。你以为剔掉了实际一个没少。四、实测最小 demo 仍拖进整套主机代码我在某 RISC-V MCU SDK 上踩的就是这个坑SDK 名、工程名均为化名下同。主目标的工具链配置里编译标志带了-fdata-sections -ffunction-sections链接标志带了-Wl,--gc-sections看着很标准。我用objdump -h看主目标自己编译的main.c.obj每个函数确实独占一个 section.text.app_main_thread_init 0x34 .text.app_main_thread_entry 0x92 .text.main_loop 0x30 .text.AK_Main 0x4c .text.app_proc 0xdc但看库里的ble_adapt.c.obj只有一个.text大小 0x4f3e 字节——所有函数全打包在里面。然后我写了个最小从机 demo只做广播和连接不碰主机扫描、不碰绑定。编译完用nm数库函数符号库一共 314 个函数elf 里全部 314 个都在剔除数 0。EzBle_GattClientSubscribe、EzBle_Connect、EzBle_AddWhitelist、EzBle_StartScan这些主机/扫描相关的 API我一行都没调用却全链接进去了。这就是第三节说的退化库没开-ffunction-sectionsble_adapt.c.obj是单.text只要有一个函数被引用整块 0x4f3e 字节原样保留。怎么自己验证几条命令就够不用猜# 看库的 .o 是不是单 .text单段没开 -ffunction-sections riscv64-unknown-elf-objdump -h ble_adapt.c.obj # 数 elf 里实际保留了库的多少个函数 riscv64-unknown-elf-nm ez_demo.elf | grep -c [Tt] # 对比库定义的函数和elf 里保留的函数差集就是被剔掉的 # 库定义的函数从 .a 里取 ar x libxxx.a nm *.o | grep [Tt] | awk {print $3} | sort -u lib_funcs.txt # elf 里保留的 nm ez_demo.elf | grep [Tt] | awk {print $3} | sort -u elf_funcs.txt # 库有、elf 没有 被剔掉的 comm -23 lib_funcs.txt elf_funcs.txt | wc -lobjdump -h看段、nm数符号、ar x把.a拆成.o、comm算差集——这套组合拳能从感觉没剔掉变成精确知道剔了几个、是哪几个。我当时就是靠它把 314 全保留、剔除 0 这个事实钉死的不然光看体积根本说不清。五、COMPILE_FLAGS 覆盖陷阱为什么 toolchain 的标志对库没生效这里有个更隐蔽的坑也是我一开始没想通的明明 toolchain.cmake 里设了-ffunction-sections -fdata-sections为什么库没用上翻库的CMakeLists.txt发现这么一行行号、内容均为化名示意set_target_properties(ez_middleware PROPERTIES COMPILE_FLAGS -marchrv32imac -mabiilp32 -mcmodelmedlow -Os C_STANDARD 11 )问题在COMPILE_FLAGS。CMake 里这个属性是覆盖式的不是追加。toolchain.cmake 通常通过CMAKE_C_FLAGS设全局编译标志子项目默认继承。可一旦某个 target 显式设了COMPILE_FLAGS它就不再继承父级的CMAKE_C_FLAGS等于把 toolchain 里那套 section 拆分标志整个丢了。所以现象就是你以为库继承了 toolchain 的-ffunction-sections实际被COMPILE_FLAGS覆盖没了库的.o还是单.text--gc-sections对库失效。要追加而不是覆盖应该用target_compile_options或者手动把-ffunction-sections -fdata-sections也写进COMPILE_FLAGS里。六、正确改法与代价库自己开 -ffunction-sections实测省 12KB改法很直接在库的CMakeLists.txt里把 section 拆分标志补上set_target_properties(ez_middleware PROPERTIES COMPILE_FLAGS -marchrv32imac -mabiilp32 -mcmodelmedlow -Os -ffunction-sections -fdata-sections C_STANDARD 11 )重新编译。ble_adapt.c.obj的.text从 1 个变成 81 个每个函数独立成段。再数符号库函数保留从 314 降到 246剔除 68 个。体积上ez_demo.bin从 387116 字节降到 374264 字节省了 12852 字节差不多 12KB。被剔掉的 68 个函数也符合预期EzBle_AutoConnect、EzBle_AutoConnectStop、EzBle_GattServerIndicate、EzBle_RemoveBond、EzBle_LoadBondInfo、EzBle_ClearKeyInfo、EzBle_SetIoCapability——清一色主机自动连接、服务端指示、绑定管理、密钥管理从机 demo 根本用不到。为了验证剔除确实跟着引用走我又做了个用得越多 bin 越大的实验在 demo 里额外引用 4 个之前被剔掉的函数EzBle_AutoConnect、EzBle_RemoveBond、EzBle_GetConnInfo、EzBle_SetIoCapability。结果 bin 从 374264 涨到 374568多了 304 字节保留函数从 246 涨到 250剔除从 68 降到 64。引用一个对应 section 就回来一个逻辑闭环。有个细节值得记一笔第一次做这个实验时我只是写了句volatile变量赋值去引用这些函数结果体积纹丝不动。因为编译器发现这个赋值没有实际副作用直接优化掉了引用根本没进 elf。后来改成在真实路径上调用体积才涨上去。这也印证了--gc-sections的可达性是从_start真正能走到的引用才算数。代价方面-ffunction-sections会增加 section 数量elf 头和 section 表的开销会涨一点链接时间也略增。但实测省下的 12KB Flash 远大于这点开销对 Flash 寮迫的 MCU 来说稳赚。还有个容易忽略的点这个工程同时有 debug 和 release 两种构建。release 用的是预编译好的库.a文件直接随 SDK 发布不是现场编译。所以光改库的CMakeLists.txt还不够——得把改完重新编译出的库覆盖到 release 取库的路径上类似cp build/ez_middleware/libez_middleware.a output/ez_lib/libxxx.a。我验证过覆盖之后 release 模式编出来的 bin 是 374264 字节和 debug 一模一样保留 246、剔除 68。说明改的是库本身跟 debug/release 的优化级别无关结论可复现。顺带提一个没采用的方案。除了让库开-ffunction-sections还有一种思路是把大源文件拆小——把ble_adapt.c里主机和从机的实现拆到两个.c文件各自编译成独立.o。这样即使不开 section 拆分剔除粒度也从整个ble_adapt.c细化到主机那个.o或从机那个.o。我没用这个方案原因有二一是改源文件结构动静大还要动构建脚本风险高二是它只是把粒度从一个.o细化到两个.o还是源文件级远不如-ffunction-sections直接做到函数级来得彻底。除非你改不了库的编译选项比如库是第三方给的、碰不了 CMakeLists否则没理由选它。七、KEEP 段与函数指针哪些引用 gc 不会动有两类引用--gc-sections是不会动的容易踩坑。一是链接脚本里KEEP()标记的段。比如这个工程的链接脚本里有KEEP(*(FSymTab))保护的是 MSH 命令表。命令表是通过宏AK_MSH_CMD_EXPORT注册的没有显式函数调用正常可达性分析根本走不到它。KEEP()就是告诉链接器这段无条件保留别 gc 掉。所以命令表里注册的函数即使代码里没调用也不会被剔除。二是函数指针和回调注册。你把一个函数地址塞进某个表、某个结构体、某个回调字段这算重定位引用是可达的。被注册的回调函数会被保留。这点和volatile那个实验对照看就清楚了函数指针注册是实打实的引用能进 elf纯赋值无副作用则被编译器优化掉不构成引用。所以判断一个函数会不会被 gc 剔除标准就一句话从_start出发沿着真实可达的重定位链能不能走到它。走得到就留走不到就剔KEEP()段和函数指针注册是两条绕过正常调用链的保留通道。八、库厂商为什么不默认开既然开了稳赚为什么很多 SDK 的库默认不带-ffunction-sections我琢磨过大概两层原因。一是历史包袱。-ffunction-sections在某些老工具链或裸机环境下会增加 section 表体积极端情况下对小工程反而净亏。库要兼容各种用户工程干脆默认不开让用户自己决定。这种保守默认在嵌入式 SDK 里很常见跟它讲道理不如自己改。二是调试体验。每函数独立 section 后objdump -d反汇编的输出会变得更碎函数之间隔着 section 头注释单步调试时符号跳转的观感也会变。库厂商做 release 时往往优先保反汇编好看、调试顺体积优化甩给用户。但对我们这种 Flash 已经快溢出的人体积优先级远高于反汇编观感所以该开就开。理解了这两层就不会觉得库没开是 bug了——它是厂商的取舍只是这个取舍不一定站在你这边。你按自己的工程目标改就行。九、写在最后回过头看这个坑的本质是粒度错配--gc-sections按 section 剔但库没开-ffunction-sections函数没拆成独立 section剔除粒度退化到源文件级剔函数变成了剔整个 .o。判别口诀记三条就够--gc-sections剔的是 section不是符号一个 section 有一个符号被引用就整段留。想按函数剔编译端必须-ffunction-sections数据加-fdata-sections这是前提不是可选。库要自己开主目标开了不传染当心COMPILE_FLAGS覆盖CMAKE_C_FLAGS把标志偷掉。下次发现--gc-sections没生效别急着怀疑链接器先objdump -h看一眼库的.o是不是单.text——多半问题就在那儿。有用的话点个在看让更多踩同样坑的工程师看到。嵌入式开发 #链接器 #静态库 #死代码剔除 #RISC-V