这个系列写到第四十篇,终于轮到把动态符号版本化这块补齐了。平时用 ELF 逆向或排查动态库问题时,.gnu.version_d是一个很容易被忽略、但非常影响兼容性的节。它的正式名字叫 version definition,作用一句话就能讲清:记录当前这个共享库自身定义过哪些符号版本,比如DEMO_1.0、DEMO_1.1,以及这些版本之间的继承关系。写第三方库的人、做二进制兼容性分析的人、或者被version 'xxx' not found这种运行期报错折磨过的人,读这篇能把整条链路补通。这篇文章不只贴 readelf 输出,我会把数据结构和字节布局拆开,再拿一个小的 demo 库从头做一遍解析,最后聊几个实战里容易踩进去的坑。
1. 符号版本机制是从哪来的:先读懂为什么要有这个节
1.1 没有版本定义的时代,共享库升级有多痛
在符号版本化出现之前,动态链接器判断一个符号是否可用只看名字。这种模式在小规模软件里够用,但库一多、升级一频繁,问题就全冒出来了。
第一个大问题是符号冲突。两个功能不同的共享库,可能都导出了trace()或status()这种很常见的函数名。链接时谁先被搜索到,应用就可能调用到谁的实现,结果完全不可控。第二个问题是 ABI 静默破裂。旧版库里parse_config()接受一个参数,新版为了扩展功能改成两个参数,函数名没变,但调用约定变了。旧程序如果没重新编译,加载新库后参数栈错位,轻则返回错误数据,重则直接崩溃,而且很难定位。
这两个问题本质上都是缺一个约束:应用和库之间缺少一份双方都认可的“契约版本号”。引入符号版本化之后,符号名可以带着版本一起出现在链接和运行期,比如parse_config@LIB_2.0和parse_config@LIB_1.0,互不干扰,旧程序继续用旧版本实现,新程序用新版本实现。.gnu.version_d就是这份“版本契约说明书”里,记录库自己能提供哪些版本的那一页。
1.2 三张表各管一段:.gnu.version、.gnu.version_d、.gnu.version_r
GNU 工具链的符号版本化并不是单独一个节,而是由三张表配合完成,缺一不可。.gnu.version是一张和动态符号表.dynsym一一对应的 uint16 数组,每个符号对应一个版本索引值;.gnu.version_d是当前库自己定义的版本清单,包括版本名和父版本关系;.gnu.version_r则记录当前库对其它库的版本需求,也就是链接时依赖了别人哪些版本。
| 节名 | ELF 类型 | 归属方向 | 对应动态段标签 | 一句话作用 |
|---|---|---|---|---|
.gnu.version | SHT_GNU_versym | 符号到版本索引 | DT_VERSYM | 给每个动态符号标记版本号 |
.gnu.version_d | SHT_GNU_verdef | 自己提供的版本 | DT_VERDEF/DT_VERDEFNUM | 定义本库有哪些版本 |
.gnu.version_r | SHT_GNU_verneed | 依赖别人的版本 | DT_VERNEED/DT_VERNEEDNUM | 记录对其它库的版本要求 |
可以这样理解:.gnu.version_d是“我能提供什么”,.gnu.version_r是“我需要什么”,.gnu.version是“我每个符号具体落在哪个版本上”。一张表负责声明供给,一张表负责声明需求,一张表负责把符号挂到具体版本上。后面在分析一个库时,往往要把这三张表放在一起看,才能还原完整的版本关系。
2. 数据结构拆到底:Verdef 与 Verdaux 字段逐个拆
2.1 两张结构体定义与内存布局
.gnu.version_d的原始数据,是按一定规则排列的一组 ElfNN_Verdef 结构体。ELF32 和 ELF64 在字段宽度上很统一,头部字段一半是 2 字节的Half,一半是 4 字节的Word,所以结构体大小正好都是 20 字节。
typedef struct { uint16_t vd_version; /* 结构版本号,当前恒为 1 */ uint16_t vd_flags; /* 版本标志:BASE / WEAK / INFO */ uint16_t vd_ndx; /* 版本索引,>= 1 */ uint16_t vd_cnt; /* 辅助条目(Verdaux)数量 */ uint32_t vd_hash; /* 版本名称的哈希值 */ uint32_t vd_aux; /* 从本 Verdef 头部到第一个 Verdaux 的偏移 */ uint32_t vd_next; /* 从本 Verdef 头部到下一个 Verdef 的偏移 */ } Elf64_Verdef; typedef struct { uint32_t vda_name; /* 版本名在 .dynstr 中的偏移 */ uint32_t vda_next; /* 从所在 Verdef 头部到下一个 Verdaux 的偏移 */ } Elf64_Verdaux;一个 Verdef 后面可以跟一个或多个 Verdaux。第一个 Verdaux 通常是该版本自己的名字,后续的 Verdaux 则是它继承的父版本名字。父版本可以理解为“新版本是在旧版本基础上扩展出来的”,动态链接器在解析符号时,如果找不到精确版本,还可以通过父版本链逐级向上找兼容版本。
内存排列上,每个 Verdef 的vd_aux和vd_next都是相对偏移,不是绝对地址。比如第一条 Verdef 从节偏移 0 开始,vd_aux = 20表示第一个 Verdaux 在节偏移 20 处。第二条 Verdef 如果从 0x1c 开始,那么它的 Verdef 内部偏移也都相对于 0x1c 计算。这个相对偏移的设计在解析时最容易搞错,后面手工 hexdump 时会具体演示。
2.2 vd_flags、vd_ndx 这些字段到底在表达什么
vd_version这个字段目前全是 1,对应 ELF 符号版本化的修订版本号,不用做特殊处理,但解析时最好还是校验一下,遇到不是 1 的值应该警惕是否读错了偏移。
vd_flags按位组合,常见三个标志:
| 标志值 | 名称 | 含义 |
|---|---|---|
| 0x1 | VER_FLG_BASE | 这是基础版本,所有库的第一个版本定义 |
| 0x2 | VER_FLG_WEAK | 弱版本定义,匹配优先级更低 |
| 0x4 | VER_FLG_INFO | GNU 扩展,隐藏版本,符号不导出 |
VER_FLG_BASE是最常见的,readelf 打印时会在 Flags 列显示BASE。一个共享库可以没有显式命名的新版本,但几乎一定会有一个 Base 版本作为兜底。Base 的版本索引固定是 1,这个索引值和.gnu.version里的VER_NDX_GLOBAL是同一个值,所以很多没有显式版本名的全局符号,在符号表里就落在 Base 版本上。
vd_ndx是版本索引,从 1 开始分配。1 通常是 Base 版本,后续新版本依次是 2、3、4。.gnu.version表给每个动态符号分配的索引值,正是对应到这里的vd_ndx。需要特别注意,索引 0 在版本语义里是本地符号,不参与全局导出。
vd_cnt说明辅助条目数量。如果只有自身名字,vd_cnt = 1;如果还继承了别的版本,后面就会多一个 Parent 条目,vd_cnt就会大于 1。vd_hash是对版本名称做 SysV 风格哈希的结果,链接器用它做快速匹配,手工解析时一般不会优先依赖它,更多还是靠名字。
2.3 节头 sh_link / sh_info 与 DT_VERDEF 标签的索引关系
在节区表里,.gnu.version_d的类型是SHT_GNU_verdef,数值是0x6ffffffd。readelf 会把它显示为VERDEF。节头里有两个字段值得关注:sh_link指向关联的.dynsym动态符号表,sh_info则保存当前节包含多少个版本定义条目。
[ 7] .gnu.version_d VERDEF 0000000000000400 00000400 0000000000000040 0000000000000000 E 6 2 8上面这段是典型的 readelf -S 输出片段,含义是:节偏移 0x400,大小 0x40 字节,sh_link = 6说明索引号为 6 的节是动态符号表,sh_info = 2说明有 2 个版本定义条目。
除了节头,动态段里也有配套信息。DT_VERDEF记录.gnu.version_d在内存中的虚拟地址,DT_VERDEFNUM记录版本定义条目的数量。用 readelf -d 能看到类似(VERDEF) 0x400、(VERDEFNUM) 2这样的输出。如果节区表被裁剪掉了,还可以靠动态段这两个 tag 找到它,这就是动态加载器真正使用的途径。
3. 实操:构建一个带符号版本的小库并完整解析
3.1 从源码和版本脚本到 .gnu.version_d
只看定义比较抽象,干脆自己造一个最小共享库,名字就叫libdemo.so。源码很简单,两个函数,一个负责打招呼,一个负责做加法。
#include <stdio.h> int greet(void) { printf("hello from DEMO_1.0\n"); return 1; } int calc(int a, int b) { printf("calc via DEMO_1.1\n"); return a + b; }接着写版本脚本demo.map:
DEMO_1.0 { global: greet; }; DEMO_1.1 { global: calc; } DEMO_1.0;这里DEMO_1.1后面跟了个DEMO_1.0,意思是新版本继承旧版本。这样设计的好处是:链接了旧版符号的程序,在新版库上依然能跑。构建命令很简单:
gcc -shared -fPIC -Wl,--version-script=demo.map -o libdemo.so demo.c用 readelf 看一眼节区,就能确认.gnu.version_d已经生成了。这一个操作就是很多做库封装的人需要掌握的常规步骤,理解了它,再看市场上的各种发行版库就心里有数了。
3.2 readelf --version-info 的输出怎么读
readelf 直接提供了--version-info选项,可以把.gnu.version_d和.gnu.version_r一起打印出来。我们这个小库的版本定义部分输出如下:
Version definition section '.gnu.version_d' contains 2 entries: 0x0000: Rev: 1 Flags: BASE Index: 1 Cnt: 1 Name: DEMO_1.0 0x001c: Rev: 1 Flags: none Index: 2 Cnt: 2 Name: DEMO_1.1 0x0038: Parent 1: DEMO_1.0先看第一行,Rev: 1是vd_version,Flags: BASE说明这是基础版本,Index: 1是vd_ndx,Cnt: 1表示只有一个辅助条目,名字就是DEMO_1.0。Base 版本没有父版本,所以只列自身名字。
第二行是第二个版本定义,Rev: 1,Flags: none,Index: 2,Cnt: 2,名字是DEMO_1.1。Cnt: 2说明后面跟了两个辅助名,一个当然是DEMO_1.1自身,另一个就是第三行显示的Parent 1: DEMO_1.0,表示它继承了DEMO_1.0。
这个输出看似简单,但已经能回答很多问题了:当前库能提供哪些版本、哪个是基础版本、哪个是新版本、新旧版本之间是否有继承链。接下来我们再往下抠一层字节,看看这些输出是怎么从原始数据里解码出来的。
3.3 用 hexdump 把结构字段一对一手工校一遍
拿到节偏移和大小后,可以直接把节内容导出来。假设节偏移是 0x400,大小 0x40,用 dd 抽出来再 hexdump 即可。为了贴合上面的结构体,我把这份 demo 库的关键字节按字段拆开,会得到类似下面这样的结果,其中字符串表偏移因库而异,我用的是本 demo 库实际值。
0x00: 01 00 01 00 01 00 01 00 f2 13 4a 51 14 00 00 00 1c 00 00 00 0x14: 24 01 00 00 00 00 00 00 0x1c: 01 00 00 00 02 00 02 00 a7 56 8b 29 14 00 00 00 00 00 00 00 0x30: 2b 01 00 00 1c 00 00 00 0x38: 24 01 00 00 00 00 00 00逐字段对照第一条 Verdef,起始在 0x00:
01 00:vd_version = 101 00:vd_flags = 1,即VER_FLG_BASE01 00:vd_ndx = 101 00:vd_cnt = 1f2 13 4a 51:vd_hash,是DEMO_1.0这个名字的 SysV 哈希值14 00 00 00:vd_aux = 20,指向 0x14 处1c 00 00 00:vd_next = 28,指向 0x1c 处
接着看 0x14 处的第一个 Verdaux:
24 01 00 00:版本名在.dynstr中的偏移,即 0x12400 00 00 00:vda_next = 0,这是最后一个辅助条目
第二条 Verdef 从 0x1c 开始,字段依次是:
01 00:vd_version = 100 00:vd_flags = 002 00:vd_ndx = 202 00:vd_cnt = 2a7 56 8b 29:版本名DEMO_1.1的哈希值14 00 00 00:vd_aux = 20,从本 Verdef 起点 0x1c 到第一个辅助条目 0x3000 00 00 00:vd_next = 0,说明没有下一条定义
0x30 处是第二个 Verdef 的第一个 Verdaux,存放DEMO_1.1自己的名字偏移;0x38 处是第二个 Verdaux,存放父版本DEMO_1.0的名字偏移。父版本这行里的vda_next = 0,表示辅助条目到这就结束了。
这个手工过程最容易犯的错是把vda_next当成相对当前 Verdaux 自身地址来算。实际上从第一眼看到这个字段定义就要记住:它相对的是所在 Verdef 的起点。比如 0x30 处的 aux,vda_next = 0x1c,从 0x1c 起算正好指向 0x38。
3.4 和 .gnu.version 联动:哪个符号挂在哪个版本上
.gnu.version_d只回答“库里有这些版本”,还不回答“具体哪个符号属于哪个版本”。这就要看.gnu.version表。它是 16 位数组,与.dynsym符号一一对应。我们这个小库的符号表大致如下:
Symbol table '.dynsym' contains 5 entries: Num: Value Size Type Bind Vis Ndx Name 8: 0000000000001109 9 FUNC GLOBAL DEFAULT 12 greet@DEMO_1.0 9: 0000000000001112 11 FUNC GLOBAL DEFAULT 12 calc@DEMO_1.1可以看到greet被标到了DEMO_1.0,calc被标到了DEMO_1.1。映射关系就是:.gnu.version表中greet对应索引 1,calc对应索引 2。而索引 1、2 正好是.gnu.version_d里两条 Verdef 的vd_ndx值。
如果换一个工具看,比如objdump -T libdemo.so,可能会看到greet的版本列显示成BASE,calc显示成DEMO_1.1。这是因为索引 1 的语义同时对应 Base 版本,工具在展示时直接用了更通用的BASE字样。第一次看到时挺容易困惑,心想明明版本定义名是DEMO_1.0,怎么符号列表里写的是BASE?记住这个约定就好:索引 1 几乎总是 Base。
4. 链接期和运行期,版本定义是怎么被用起来的
4.1 链接器写入依赖:另一侧的 .gnu.version_r
.gnu.version_d定义了库能给什么,但应用在链接时还会形成自己的版本需求,写入可执行文件的.gnu.version_r。拿一个小程序去链接libdemo.so,它会记录自己用了DEMO_1.0还是DEMO_1.1。
int greet(); int calc(int, int); int main() { return greet() + calc(2, 3); }编译链接:
gcc main.c -L. -ldemo -o app执行readelf --version-info app,会看到Version needs section '.gnu.version_r'这一段记录。它列出File: libdemo.so,下面跟着需要的版本名,比如DEMO_1.0和DEMO_1.1。运行时动态链接器拿着 app 的需求,去和 libdemo.so 的.gnu.version_d供给清单做匹配,两边对上了才放行。
这正好形成了一个闭环:.gnu.version_d是供给侧的合同条款,.gnu.version_r是需求侧的合同条款,.gnu.version是把每个符号挂到具体条款上的索引。
4.2 动态链接器的版本匹配流程与典型报错
动态链接器在加载共享库时,会先读取被加载库的版本定义表,再把当前目标文件里的未解析版本需求拿过来比对。如果库里确实存在对应版本,或者能找到父版本兼容链,就正常绑定;如果完全找不到,就会以version 'DEMO_1.1' not found (required by ./app)的形式报错退出。
举个例子,如果系统里只有旧版本的libdemo.so,它只提供了DEMO_1.0,而 app 是在DEMO_1.1出现之后链接的,运行就会失败。这种问题的排查思路很直接:先readelf --version-info app看需求,再readelf --version-info libdemo.so看供给,需求里有的版本供给里没有就是根源。这里有个容易被忽略的点:库文件名一致不代表版本一致,.gnu.version_d里的版本定义才是真正的兼容性契约。
4.3 用 dlvsym 按版本号精确取符号
除了让链接器和加载器自动匹配,我们自己写代码时也能主动指定版本。glibc 提供了dlvsym,它比dlsym多一个版本名参数,可以在运行时精确获取某个特定版本的符号。
#define _GNU_SOURCE #include <dlfcn.h> #include <stdio.h> int main() { void *handle = dlopen("./libdemo.so", RTLD_NOW | RTLD_LOCAL); if (!handle) { fprintf(stderr, "dlopen: %s\n", dlerror()); return 1; } int (*calc_fn)(int, int) = dlvsym(handle, "calc", "DEMO_1.1"); if (calc_fn) { printf("calc(2, 3) = %d\n", calc_fn(2, 3)); } dlclose(handle); return 0; }dlvsym常用于插件系统,或者需要在同一库的不同版本间做选择性调用的场景。它本质上也是在问.gnu.version_d要版本定义,所以理解了这张表,运行时 API 的行为也就顺理成章了。
5. 实战踩坑与排查速查表
5.1 版本脚本从入门到翻车的常见姿势
版本脚本虽然语法简单,但翻车点不少。第一种是符号没被纳入任何版本节点。脚本里只写了部分global符号,其余符号默认就留在 Base/未版本状态,行为可能和预期不符。如果想所有导出符号都自动带当前版本,可以加global: *;或者用链接器选项--default-symver。
第二种是版本继承关系写错。比如新版本写了DEMO_1.2 {} DEMO_1.1;,但库里根本不叫DEMO_1.1,链接器会报错或生成错误的依赖关系。继承版本名必须与已有版本定义完全一致,大小写也要注意。
第三种是.symver的使用。要让同一个函数名同时导出多个版本,需要用内联汇编或.symver指令把旧实现映射到带@旧版本的别名上。这种写法很强大,但也最容易出现version node not found的链接错误。建议先在小函数上练熟再铺开用。
第四种是发布后删版本节点。典型的错误流程是:DEMO_1.0发布后,新版本DEMO_1.1里改动太大,干脆把DEMO_1.0从脚本里删掉。结果已经链接到DEMO_1.0的老程序直接跑不起来。正确的做法永远是保留老版本定义,并在新版本节点里继承它,正如前面 demo 所写的那样。
5.2 常见报错对照表
| 报错信息 | 原因 | 排查方向 |
|---|---|---|
version node not found for symbol | 版本脚本里的符号名与目标文件导出符号不匹配 | 检查符号名拼写、是否被隐藏 |
undefined reference to 'foo@VER_1.0' | 链接时指定了版本,但库中没有该版本定义 | 检查库的.gnu.version_d是否包含该版本 |
version 'VER_1.1' not found (required by ...) | 库太旧,没有对方需要的版本 | 对比 app 的.gnu.version_r和库的.gnu.version_d |
warning: .gnu.version_d section is not loaded | 手动解析时偏移或映射做错了 | 确认节头sh_offset,或改用动态段定位 |
这些信息在分析开源软件打包问题、升级底层库导致应用崩溃的问题时非常常见。对照表的作用不是背答案,而是提醒你往版本供给和需求两侧去找。
5.3 二进制层面解析时的几个最后提醒
手工解析.gnu.version_d时有三个点最值得注意。第一是字节序,x86、ARM 小端平台要按小端读,否则 16 位和 32 位字段全乱。第二是偏移的相对性,vd_aux、vd_next、vda_next都是相对偏移,比较容易混淆的是vda_next,它不是相对当前 aux 自己,而是相对包含它的 Verdef 起点。第三是不要只盯着节头,某些瘦身后的二进制可能没有完整的节区表,这时动态段里的DT_VERDEF和DT_VERDEFNUM才是唯一可靠入口。
如果要在脚本或工具里解析,建议先遍历动态符号表得到符号总数,再按顺序读 Verdef。每条先固定读 20 字节头部,然后通过vd_aux跳到辅助区,通过vda_next遍历完所有 aux,最后用vd_next判断是否有下一条。这样即使遇到多个版本、复杂继承链,也不会读乱。
6. 最后分享几点个人经验
版本化机制解决的是长期兼容问题,不是短期开发便利问题。如果只是自己写个内部小库,版本可以适当简化;一旦要发布给别人链接,版本节点就要当成对外承诺来对待。我的建议是:每新增一组 ABI 相关的函数或行为变化,开一个新版本节点并继承旧的;不要为了强迫症把版本号整理得很密集,版本过多会显著增加维护成本。
解析.gnu.version_d时,最容易帮助理解的做法是同时打开三个 readelf 输出:--version-info看版本定义和需求,--dyn-syms看符号归属,-S看节偏移。三份信息放在一起,比单独盯一份更能看清全貌。如果刚开始接触这系列内容,强烈建议照第三章的过程,自己编译一个十行的小库,再手工 hexdump 一次。踩过一遍“字段偏移算错”的坑之后,以后再遇到各种复杂的二进制版本问题,思路会清晰很多。