☰
ELF节组(Section Groups)深度解析:readelf -g 实操与链接器机制剖析
2026/10/20 20:59:34 网站建设 项目流程

聊到ELF格式,很多人对节头表、符号表、重定位表已经熟得不能再熟,但一提section groups(节组),能当面说清楚的人就少了一大截。我第一次认真研究节组,是在给一个C++接口库做链接体积排查的时候。当时用readelf -g去翻一堆产物对象文件,看到满屏的.group段,每个组里都挂着一两个函数节和一小块只读数据,心里冒出的念头是:“编译器这是在段表里给我建了个文件夹?”后来把节组的来龙去脉啃完,才明白这玩意不是锦上添花,而是现代工具链能正常工作的地基之一。

简单来说,节组解决的是“多个编译单元各自产出一份内容几乎一样的代码/数据,链接时怎么安全地只留一份”的问题。你写的每个模板函数、每个内联函数,在不同.cpp里都会实例化出一份副本,如果没有一种统一的“去重”协议,链接器要么报重复定义,要么糊里糊涂地保留两份,要么干脆把这些等价副本当成不同的东西,白白浪费空间。节组就是ELF体系下官方钦定的解法,而readelf -g就是把这张牌翻给你看的工具。这篇文章从原理讲起,再落到命令行实操,适合所有写C/C++、做二进制分析、搞性能优化和做工具链开发的读者。

1. 节组是什么:链接器“撞车”问题的标准解法

1.1 一个让所有C++开发者头疼的老问题

先从一个最常见的报错说起。如果你写两个.c文件,里面都定义了同一个全局函数,链接器会毫不客气地告诉你“multiple definition offoo”。这是因为全局函数默认是强符号,同一个可执行程序里只允许一个定义。可C++的模板规则完全不同:模板在实例化时,编译器根本不管别的编译单元里有没有同名实例,它只会老老实实地把自己看到的类型组合全部生成一遍。于是每个.cpp文件里都会有自己的一份std::vector<int>::push_back、自己的一份std::map<std::string, int>::operator[]。

如果链接器把这几份副本当成普通强符号,那链接几乎必然失败。所以早年GNU工具链开了一个后门:把这类符号标记成弱符号,并把承载这些符号的节段放进带特殊名字的“linkonce”段里。链接器遇到.gnu.linkonce.text.xxx这种段的时候,知道它代表“这个代码片段是可有可无的副本”,如果已经处理过一个,后面再见就直接忽略。这套机制能用,但问题不少。最大的麻烦是它只“约定”了段的命名格式,却没有在ELF规范层面建立一个统一的数据结构;不同编译器、不同优化选项生成的linkonce段散落一地,--gc-sections这种段回收功能对它们也无从下手,常常搞得段表又脏又乱。

1.2 从linkonce段到section groups:演进与选型

后来越来越多的人意识到,单纯靠“特殊段名”来表达“可替换副本”实在太脆弱,于是ELF gABI(通用ABI规范)正式引入了section groups,也就是节组。节组的思路非常直白:与其把一个函数节的“等价副本”身份写在名字里,不如专门建一个索引段,把同一逻辑单元相关的所有节都登记进去,再给整个组挂一个“签名符号”。链接器看到组,就知道这是一堆必须一起被处理的东西。

为什么必须“一起”?因为现代编译器产出的一个函数,远不止一个代码节。Optimize出来的产物可能是.text主逻辑节、.text.unlikely冷路径节、.rodata.cst字符串字面量节、.eh_frame异常处理信息节、甚至还有对应的.debug_info片段。这些内容天生属于同一个逻辑函数,合并时要么一起留下,要么一起丢弃。如果只去重其中一个主代码节,其他附属节留在原地,链接器的很多优化和地址重排逻辑都会乱套。节组通过天然的“打包”结构,把这个问题一举解决。

从工具链选型的角度看,GNU ld到今天依然保留了对老式linkonce段的兼容,但新产物一般都会主动生成节组,-ffunction-sections、-O2、模板实例化、内联函数展开这些场景基本都会撞上它。这也解释了为什么你用readelf -g看一个普通的C++目标文件时,节组几乎必然是扎堆出现的。

2. SHT_GROUP内部结构:读懂组头与成员关系

2.1 组节(group section)的头部字段

在ELF文件里,节组本身的信息是存放在一个类型为SHT_GROUP的节段里的。这个段的命名通常就是.group,当文件里有多个节组时,很多工具链会继续追加.group.1、.group.2之类的名字,但命名不是硬性规范,真正的身份靠类型和节头字段来识别。

看一个组节,重点看它的节头(section header):

  • sh_type:必须是SHT_GROUP,十六进制值是17。
  • sh_link:指向这个节组对应的符号表段。在普通可重定位文件里通常是.symtab,如果这个段参与动态链接,也可能指向.dynsym。
  • sh_info:在这个组节里有特殊含义,它保存的是“组签名符号”在符号表中的索引,而不是一般节头sh_info那种含义。
  • sh_entsize:gABI规定组节内每个条目占4个字节,所以sh_entsize通常就是4。
  • sh_flags:组节本身一般不设SHF_GROUP,因为它不是“某个组的成员”,而是组信息的容器。

这几个字段是理解readelf -g输出的钥匙。你看到输出里的signature,本质上就是通过sh_link找到符号表,再拿sh_info去符号表里按索引取出来的那个符号的名字。

2.2 组体的字节布局

SHT_GROUP节的内容不像普通代码段那样是一团字节流,而是一串32位无符号整数,格式非常紧凑:

  • 第1个32位整数:组标志(flags)。目前最重要的一位是GRP_COMDAT,值为1,含义是“这个组是可合并的等价副本组”。如果标志是0,表示这个组是普通非COMDAT组,链接器不应主动拿它去和其它组去重。
  • 第2个32位整数:签名符号在sh_link指向的符号表中的索引。拿它去符号表里查一下,就能知道这个节组“代表”谁。
  • 第3个及以后:组成员节的索引列表。每一项都是一个节头索引,指向这个组实际包含的节段。

这个布局意味着,一个组节的长度是可变化的,有多少成员就有多少个32位整数加额外的两个头字。不同字节序的目标文件里,这些整数会按文件的ELF数据编码方式来存储,所以分析时让工具替你解就行,手工用十六进制编辑器看的时候记得考虑大小端。

2.3 成员节的SHF_GROUP标志与约束

另一个容易忽略的细节是:节组里的每个成员节,它的sh_flags里都会被设置一个SHF_GROUP位,也就是十六进制的0x200。这个位相当于给所有成员打了一个“组内人”的烙印。凡是设置了这个位的普通节,其sh_info字段不再是普通的含义,而是反过来记录“我属于哪个组节索引”。

这就形成了一种双向索引:组节通过数组指出“我包含谁”,成员节通过SHF_GROUP和sh_info指出“我属于谁”。这种冗余设计不是为了好看,而是让各种工具可以分别从组的角度和节的角度快速建立索引,不用每次都线性扫描整个段表。

同时规范有一个硬性约束:一个节最多只能属于一个组。两个组不能共享同一个成员节,组之间也不能互相嵌套。这个约束在大多数情况下不会给你带来麻烦,但如果你的构建脚本里用了objcopy之类的工具手工改段,就非常容易踩雷。曾经我在剥离调试信息时把.debug_*节重新拼装,一不留神就造出过同时挂在两个组下的畸形成员节,结果下游分析工具直接行为异常。

我们在编写分析脚本时,最简单的校验逻辑就是:如果一个节带SHF_GROUP,它的sh_info必须指向一个合法的SHT_GROUP节,且查无此节或者索引越界,这个ELF文件基本就是坏的。

3. readelf -g实操:从命令到逐行解读

3.1 命令行用法与注意事项

readelf -g是GNU binutils里展示节组信息的主要入口。完整的等效写法是readelf --section-groups。它读入一个ELF文件,然后把文件里所有类型为SHT_GROUP的节组段和它们包含的成员节全部打印出来。

命令行强度最大的组合是readelf -gW。-W是--wide,让输出不折行,否则当成员节很多、节名很长的时候,输出会被截断成两段,阅读体验和脚本解析体验都很糟糕。如果是脚本解析,我强烈建议只用-gW,不要省略W。

这个选项并不要求文件必须是可重定位文件。理论上你拿readelf -g去看一个已链接的可执行文件或者共享库也可以,但实际意义有限:大部分节组信息在链接完成后已经内化或者被丢弃了,只剩一些被保留的组,信息密度远远不如目标文件阶段。所以日常最常被-g照顾到的,还是.o文件和部分中间产物。

3.2 输出字段逐项拆解

假设你有一个典型的C++目标文件,执行readelf -gW,输出会长得类似下面这样(不同binutils版本的措辞有细微差异,但字段的逻辑保持一致):

$ readelf -gW demo.o Group section [ 5] `.group`: [Index 1] .text._Z3foov [Index 2] .text._Z3foov.cold [Index 3] .rodata.str1.1 [Index 6] .eh_frame

逐行拆开看:

  • Group section [ 5] .group:[5]是这个组节的节头索引,.group是节名。对应的就是SHT_GROUP类型的那个段。
  • 后面的每一行[Index N],代表这个文件里“节头索引为N的那个节是组的成员”。比如[Index 1] .text._Z3foov,意思是节头索引1的那个函数节属于这个组。
  • 组标志和签名符号不一定总是在每行里直接显示。有些版本会在组节名后面附带括号里的符号名,有些版本需要靠readelf -s交叉验证。

再看一个更完整的输出示意。当组带有GRP_COMDAT标志时,readelf通常在节组信息的第一行标注具体签名内容。比如:

Group section [ 7] `.group` [foo(): COMDAT group: [Index 1] .text.foo() [Index 4] .rodata

上面只是示意格式,不必太纠结文本长相,真正要抓住的信息维度就三个:这个组有没有COMDAT标志、签名符号是谁、组里挂了哪些节。三个信息拼起来,你就能判断出这个组的去重身份。

3.3 用readelf其他参数交叉验证

只看-g还不够,你经常会想确认它给出的组节索引、符号索引对不对。这时候可以用常规命令组合交叉验证:

readelf -h demo.o # 看节头表起点和节头数量 readelf -SW demo.o # 看所有节段,SHT_GROUP类型在这里会显示为 GROUP readelf -sW demo.o # 看符号表,根据sh_info索引去定位签名符号

readelf -SW是我们平时看图的好帮手,它会把每个节的类型、地址、偏移、大小、标志全部列出来。当一个节的类型栏显示GROUP时,它的Link列存放的就是sh_link,Info列存放的就是sh_info。我排查看现场时经常先把-SW输出存一份,然后用-g输出跟它对表,基本可以做到百发百中。

如果遇到一个文件里节组特别多,比如几百上千个,我还会顺手加readelf -SW | grep GROUP先统计一下总数量。节组数量暴增往往暗示着模板实例化爆炸,这一步能很快摸清对象文件的“拥挤程度”。

4. 链接器视角:COMDAT合并、GC与符号解析

4.1 COMDAT组怎么被链接器消费掉

链接器处理节组的核心逻辑很简单:按签名符号去重。假设你链接七八个.o文件,其中三份里都出现了同一个签名符对应的COMDAT组,链接器只把第一个遇到的组留下,后面遇到的相同签名组直接整组丢弃。这样做的好处是,不管这个组在各自编译单元里叫出什么花样的节名,只要签名符一样,它就是同一逻辑片的等价副本。

这里有一个关键点:COMDAT组的签名符号一定是弱语义的。正常情况下模板函数、内联函数生成的符号都是弱符号,所以链接器不会因为副本之间的符号定义冲突而报错。但如果你手工把某些符号改成强符号,或者在链接脚本里做了特殊合并,就会出现“看起来同一个组,链接器却因为强符号冲突报警”的情况,非常诡异。

另外一个容易出问题的点是:COMDAT组的成员是整体的。在不同编译单元里,同一个函数的产物可能略有差别。举个很常见的例子,一个文件里用-O2编译模板函数时,编译器生成了一份含冷路径的.text.cold节,另一个文件里因为内联决策不同,这个冷路径可能没生成。两个组挂着同样签名符,成员列表却不一样。链接器一旦选择保留第一个组,第二个组整个丢弃,那么第二个编译单元里那些“多余”的节就没了,但符号解析依然正常,因为第一个组里的符号已经能满足重定位需求。也就是说,最终二进制的代码是“某个编译单元的那个版本”,而不是“所有编译者都能完全复现的版本”。在跨模块调试时,你会看到函数行号信息依然有效,但代码体跟另一个编译单元里看到的不尽相同,这容易让人摸不着头脑。解决思路通常是保持各编译单元优化选项一致,尽量别混用优化等级。

4.2 与--gc-sections的配合

提到链接优化,自然少不了--gc-sections。这个开关通过回收“没被引用到的节”来减小最终体积,但它能不能安全运作,跟节组息息相关。

如果一个函数既没有被直接引用,也没有被节组里的其它节引用,那它的节在GC阶段就会被拿走。但由于节组是一个整体单元,链接器在判断“这个组有没有被引用”的时候,通常是看这个组的签名符号有没有被真正的代码引用,或者组内其它成员有没有被外部重定位指到。换句话说,节组相当于给GC提供了一个“锚点”:锚点活着,全组留下;锚点死了,全组回收。这比老式的linkonce段天然适合GC,因为linkonce段没有明确的组关系,回收器只能靠猜。

实践中,我们为了极致压缩体积,常对C++项目同时开-ffunction-sections -fdata-sections加链接时--gc-sections,配合节组的强锚定能力,往往能砍掉大量未调用的模板实例。这个组合的关键收益正在于:每个函数/数据都成了独立节,而模板实例独立的节又被包进节组,GC可以精确到函数粒度。

4.3 动态符号、异常处理等场景下的节组

节组并不只在静态链接阶段起作用。在动态链接场景里,如果目标文件是-fPIC编译的,组内的代码节可能涉及动态重定位,签名符号依然维持弱符号语义,动态链接器在加载多个共享库时同样会用去重逻辑避免符号语义冲突。STB_GNU_UNIQUE这种特殊的符号绑定也在某些场景下和节组签名符号纠缠,用来保证符号在不同共享库之间保持唯一。

异常处理数据也很值得一提。C++的.eh_frame通常不是一整个统一段,而是每个函数都有自己的小块。这些小块常常被编进对应函数的节组里,这样当组被GC回收时,它的异常处理表也会一起消失,不会留下一个指着已回收代码的死引用。你可以试着对一个带异常的函数开readelf -g,大概率能看到组里混着.eh_frame节。同理,与排序相关的节比如.init_array、.fini_array以及某些SHF_LINK_ORDER特殊节,在组内的顺序处理也需要格外小心,工具链一般不会让你出错,但如果你手写汇编或者通过脚本后处理,就要特别注意保持顺序语义。

5. 常见问题与排查实录

5.1 日常排查命令组合

围绕节组做排障,我最常用的命令组合是这四板斧:

readelf -gW file.o readelf -SW file.o readelf -sW file.o objdump -h file.o

objdump -h主要用来看节名和可读文本,辅助判断一个节到底是不是组的一部分。如果你只想快速确认“这个文件有没有节组”,一条readelf -SW | grep GROUP就够;如果你要深入看签名和成员归属,readelf -gW是主菜;如果还要把符号表对应起来看,readelf -sW补上就好。

这几个命令都来自binutils,几乎所有Linux发行版都有,不用额外装东西。解析脚本如果嫌readelf文本格式不好用,也可以用pyelftools直接按二进制格式抓,它的SHT_GROUP解析逻辑已经封装得比较完善。

5.2 我踩过的坑与解决方案

有次帮某开发者排查一个大型C++模块的链接异常。现象是链接时总出现莫名奇妙的重复符号提示,但查了好几轮代码都没找到真正的强符号冲突。我拿readelf -gW逐个看可疑对象文件,发现有一个.o是从一个很老的构建环境里混进来的,它还在用老的.gnu.linkonce段体系,跟新工具链生成的节组混在一起。错误信息在符号层面看着像强冲突,实际上是因为新链接器把两种去重机制并行处理时,没有把老linkonce段的“副本候选”身份跟新节组的“等效副本”身份统一起来。解法很简单:全量重编,把那个老对象文件用同一套工具链重新生成,问题当场消失。所以如果你的构建环境里混用了非常老和非常新的编译器,遇到节组相关奇怪链接问题时,先做一次全量干净重编,往往比抠代码更快。

另一个坑出在--gc-sections上。有一个项目的链接脚本里把某个组内的节强制放到了输出段的固定地址,结果GC认为组签名符号没有被引用,把整个组回收了,输出段里留下一片虚空。当时现象是程序运行到某个函数调用时直接跳到无效地址。排查时我的第一反应是拿readelf -g确认签名符号还在不在引用区里,结果发现那个组被GC吃掉,说明强制放在固定地址的裸节若不挂在有效锚点上,是没法阻止回收的。教训是:链接脚本里手工固定节地址时,要确保该节对应的节组或者签名符号被其它正常引用拴住,否则GC不认你这套强留操作。

还有一种比较隐蔽的情况:objcopy --only-keep-debug之后,调试文件里会保留节组信息,但符号表可能被抽走只剩调试符号。这时用readelf -g看调试文件,会发现有些组的签名符号变成了索引数值,符号名可能查不到。这是正常现象,不是文件坏了。理解这一点能帮你省下不少自我怀疑的时间。

5.3 常见问题速查表

现象可能原因推荐手段
链接报multiple definition强符号冲突,或者混用了老linkonce体系与新节组体系全量重编,统一工具链版本
COMDAT组数量巨大,链接缓慢模板实例化爆炸,函数级节过多用extern template抑制实例,检查优化选项
GC后运行跳飞或函数调用无效组签名符号没被引用,但节被手工固定在输出段重新确认链接脚本锚点,去掉手工固定
调试文件里的组签名符号显示为数字符号表被剥离,只保留了调试符号这是--only-keep-debug的正常结果,不必慌
分析脚本扫描到重复SHF_GROUP节手工objcopy重组段时破坏了成员唯一性检查sh_flags和sh_info,修复成员归属

节组的细节远不止这些,但把这套知识理顺之后,再看readelf -g的输出就不再是雾里看花。对我来说,节组最大的价值在于它把“等价副本”这个模糊的链接器概念,变成了一种可解析、可验证、可编程处理的ELF标准结构。无论是做链接体积优化、排查重复符号,还是写二进制分析脚本,沿着节组这条线索往下顺,几乎总能找到真相。我最后一次遇到它,是在给一个大型C++服务做启动分区时,通过readelf -gW把上千个函数级节组按签名分组统计,很快就找出了哪些冷函数占着热区段,精准调整了链接布局。这种“按组分析”的视角,是单看节表、符号表给不了的。如果你下次在一个陌生ELF文件里看见.group,不妨先别急着跳过,用readelf -gW翻一下它的签名和成员,多半能挖出不少编译器想告诉你的东西。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询