All about symbol versioning

2026年1月更新。

1995年Solaris的link editor和ld.so引入了symbol versioning机制。 Ulrich Drepper和Eric Youngdale在1997年借鉴Solaris symbol versioning,设计了用于glibc的GNU风格symbol versioning。

gnu-gabi规范:https://sourceware.org/gnu-gabi/program-loading-and-dynamic-linking.txt#:~:text=versioning

一个shared object更新,某个符号的行为变更(ABI改变(如变更参数或返回值的类型)或行为变化)时,传统上可以bump DT_SONAME:依赖的shared objects必须重新编译、链接才能继续使用;如果不改变DT_SONAME,依赖的shared objects可能悄悄地产生异常行为。 使用symbol versioning可以提供不改变DT_SONAME的backward compatibility。

下面描述表示方式,然后从assembler、链接器、ld.so几个角度描述symbol versioning行为。初次阅读时不妨跳过表示方式部分。

表示方式

在使用symbol versioning的shared object或可执行档中,有至多三个sections,其中.gnu.version_r.gnu.version_d是可选的:

  • .gnu.version(类型SHT_GNU_versym,由DT_VERSYM指向):与.dynsym平行的表,包含N个uint16_t version ID,每个dynamic symbol一个。
  • .gnu.version_r(类型SHT_GNU_verneed,由DT_VERNEED/DT_VERNEEDNUM标记):描述未定义符号所需的version。
  • .gnu.version_d(类型SHT_GNU_verdef,由DT_VERDEF/DT_VERDEFNUM标记):描述定义符号的version。

这种parallel table设计使symbol versioning成为可选的——忽略它的ld.so实现(如musl)会把所有引用绑定到default version。

Verdef描述模块中的version定义。每个Verdef有一个或多个Verdauxvd_aux):第一个给出version名,后续的(如有,vda_next)给出parent version名(用于version继承,如v2 {} v1;)。 vd_cnt等于1加上parent version定义数。然而实际中它不太有用——glibc和FreeBSD rtld忽略它,ld.lld简单地硬编码为1。

Verneed描述对其他shared objects(vn_file)的version需求。每个Verneed有一个或多个Vernaux,指定所需的version名(vna_name)。

Version名不是全局唯一的。以下情况是有效的:

  • 一个Verdef定义version v1,同时一个Verneed需要来自a.so的version v1
  • 一个Verneed需要来自a.so的version v1,同时另一个Verneed需要来自b.so的version v1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Version definitions
typedef struct {
Elf64_Half vd_version; // version: 1
Elf64_Half vd_flags; // VER_FLG_BASE (index 1) or 0 (index != 1)
Elf64_Half vd_ndx; // version index
Elf64_Half vd_cnt; // number of associated aux entries, one plus number of parent version definitions
Elf64_Word vd_hash; // SysV hash of the version name
Elf64_Word vd_aux; // offset in bytes to the verdaux array
Elf64_Word vd_next; // offset in bytes to the next verdef entry
} Elf64_Verdef;

typedef struct {
Elf64_Word vda_name; // version name
Elf64_Word vda_next; // offset in bytes to the next verdaux entry
} Elf64_Verdaux;

// Version needs
typedef struct {
Elf64_Half vn_version; // version: 1
Elf64_Half vn_cnt; // number of associated aux entries
Elf64_Word vn_file; // .dynstr offset of the needed filename
Elf64_Word vn_aux; // offset in bytes to vernaux array
Elf64_Word vn_next; // offset in bytes to next verneed entry
} Elf64_Verneed;

typedef struct {
Elf64_Word vna_hash; // SysV hash of vna_name
Elf64_Half vna_flags; // usually 0; copied from vd_flags of the needed so
Elf64_Half vna_other; // `Version:` in readelf -V output
Elf64_Word vna_name; // .dynstr offset of the version name
Elf64_Word vna_next; // offset in bytes to next vernaux entry
} Elf64_Vernaux;

VER_FLG_WEAK

GNU ld在version node没有关联符号时会在Verdef::vd_flags中设置VER_FLG_WEAK,与Solaris行为一致。 BZ24718#c15提议"set VER_FLG_WEAK on version reference if all symbols are weak",但被拒绝。 2026年的评论要求重新审视VER_FLG_WEAK version references是否应该支持可选依赖——例如,一个可执行档对foo有weak reference并链接了libA.so.1(提供foo@@v1),但运行时使用的libA.so.1缺少v1的version定义。目前这会导致rtld报错。

Version index values

  • Index 0称为VER_NDX_LOCAL(名字有误导性,应该是VER_NDX_NONE)。对于定义的符号,binding将会更改为STB_LOCAL
  • Index 1称为VER_NDX_GLOBAL。没有特殊作用,用于unversioned符号。
  • Index 2到0xffef用于用户定义的versions。

定义的versioned符号有两种形式:

  • foo@@v2,default version
  • foo@v2,non-default version(hidden version)。其version ID设置了VERSYM_HIDDEN bit。

未定义versioned符号只有foo@v2这一种形式。

有一个特殊情况:可执行档中copy relocation引用的version符号。 该符号在运行时relocation处理中作为定义,但其version ID引用.gnu.version_r而非.gnu.version_dPR28158的解决方案选择了@形式。

通常versioned符号只在shared objects中定义,但可执行档也可以有定义的versioned符号。 (当shared object更新时保留旧符号,使其他shared objects不须重新链接;而可执行档通常不提供versioned符号供其他shared objects引用。)

.gnu.version section(由DT_VERSYM标签描述)中,每个符号被分配一个version index:

Index 0的定义符号是local的,不应在.dynsym.gnu.version中有条目。

例子

readelf -V可以导出symbol versioning表。

下面输出的.gnu.version_d section里:

  • Version index 1 (VER_NDX_GLOBAL) is the filename (soname if shared object). The VER_FLG_BASE flag is set.
  • Version index 2 is a user defined version. Its name is LUA_5.3.

下面输出的.gnu.version_r section里,version index 3~10每一个都表示了一个依赖的shared object中的version。名字GLIBC_2.2.5出现了三次,每次对应不同的shared object。

.gnu.version表给每个.dynsym条目分配一个version index。 一个条目(version ID)对应.gnu.version_d中的Index:条目或.gnu.version_r中的Version:条目。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
% readelf -V /usr/bin/lua5.3

Version symbols section '.gnu.version' contains 248 entries:
Addr: 0x0000000000002af4 Offset: 0x002af4 Link: 5 (.dynsym)
000: 0 (*local*) 3 (GLIBC_2.3) 4 (GLIBC_2.2.5) 4 (GLIBC_2.2.5)
004: 5 (GLIBC_2.3.4) 4 (GLIBC_2.2.5) 4 (GLIBC_2.2.5) 4 (GLIBC_2.2.5)
...

Version definition section '.gnu.version_d' contains 2 entries:
Addr: 0x0000000000002ce8 Offset: 0x002ce8 Link: 6 (.dynstr)
000000: Rev: 1 Flags: BASE Index: 1 Cnt: 1 Name: lua5.3
0x001c: Rev: 1 Flags: none Index: 2 Cnt: 1 Name: LUA_5.3

Version needs section '.gnu.version_r' contains 3 entries:
Addr: 0x0000000000002d20 Offset: 0x002d20 Link: 6 (.dynstr)
000000: Version: 1 File: libdl.so.2 Cnt: 1
0x0010: Name: GLIBC_2.2.5 Flags: none Version: 9
0x0020: Version: 1 File: libm.so.6 Cnt: 1
0x0030: Name: GLIBC_2.2.5 Flags: none Version: 6
0x0040: Version: 1 File: libc.so.6 Cnt: 6
0x0050: Name: GLIBC_2.11 Flags: none Version: 10
0x0060: Name: GLIBC_2.14 Flags: none Version: 8
0x0070: Name: GLIBC_2.4 Flags: none Version: 7
0x0080: Name: GLIBC_2.3.4 Flags: none Version: 5
0x0090: Name: GLIBC_2.2.5 Flags: none Version: 4
0x00a0: Name: GLIBC_2.3 Flags: none Version: 3

Symbol versioning in object files

这套GNU symbol versioning允许.symver directive在.o里标注符号的version。在.o里符号名字面包含@@@

Assembler行为

GNU as和LLVM integrated assembler提供实现。

  • 对于.symver foo, foo@v1
    • 如果foo未定义,.o中有一个名为foo@v1的符号
    • 如果foo被定义,.o中有两个符号:foofoo@v1,两者的binding一致(均为STB_LOCAL,或均为STB_WEAK,或均为STB_GLOBAL),st_other一致(visibility一致)。个人认为这个行为是设计缺陷{gas-copy}
  • 对于.symver foo, foo@@v1
    • 如果foo未定义,assembler报错
    • 如果foo被定义,.o中有两个符号:foofoo@@v1,两者的binding和st_other一致
  • 对于.symver foo, foo@@@v1
    • 如果foo未定义,.o中有一个名为foo@v1的符号
    • 如果foo被定义,.o中有一个名为foo@@v1的符号

With GNU as 2.35 (PR25295) or Clang 13:

  • .symver foo, foo@v1, remove
    • 如果foo未定义,.o中有一个名为foo@v1的符号
    • 如果foo被定义,.o中有一个名为foo@v1的符号
    • 我推荐用这种方式定义non-default符号
    • Unfortunately, in GNU as, foo cannot be used in a relocation (PR28157).

链接器行为

链接器在读入object files、archive files、shared objects、LTO files、linker scripts等后就进入符号解析阶段。

GNU ld用indirect symbol表示versioned符号,在很多阶段都有复杂的规则,这些规则都没有文档。 我个人得出的符号解析规则:

  • 定义的foo可以满足未定义的foo(传统unversioned符号规则)
  • 定义的foo@v1可以满足未定义的foo@v1
  • 定义的foo@@v1可以同时满足未定义的foofoo@v1

若存在多个default version的定义(如foo@@v1 foo@@v2),触发duplicate definition error。通常一个符号有零或一个default version(@@)定义,任意个non-default version(@)定义。

ld.lld的实现中,看到shared object中的foo@@v1则在符号表中同时插入foofoo@v1,因此可以满足未定义的foofoo@v1

链接器如果先看到未定义foofoo@v1,会把它们当作两个符号。之后看到定义的foo@@v1时,概念上应该合并foofoo@@v1。若看到的是定义的foo@@v2,应该用foo@@v2满足foo,而foo@v1仍是一个不同的符号。

  • Combining Versions描述了这个问题
  • gold/symtab.cc Symbol_table::define_default_version用一个启发式规则处理这个问题。它特殊判断了visibility,但我感觉这个规则可能不需要也行
  • Before 2.36, GNU ld reported a bogus multiple definition error for defined weak foo@@v1 and defined global foo@v1 PR ld/26978
  • Before 2.36, GNU ld had a bug that the visibility of undefined foo@v1 does not affect the output visibility of foo@@v1: PR ld/26979
  • I fixed the object file side problem of ld.lld 12.0 in https://reviews.llvm.org/D92259 foo Archive files and lazy object files may still have incompatibility issues.

When ld.lld sees a defined foo@@v, it adds both foo and foo@v1 into the symbol table, thus foo@@v1 can resolve both undefined foo and foo@v1. After processing all input files, a pass iterates symbols and redirects foo@v1 to foo@@v1. Because ld.lld treats them as separate symbols during input processing, a defined foo@v cannot suppress the extraction of an archive member defining foo@@v1, leading to a behavior incompatible with GNU ld. This probably does not matter, though.

foofoo@v1同时被定义(在相同位置),foo会被移除。 GNU ld有另一个奇怪的行为:若foofoo@v1同时被定义,foo会被移除。 我坚信这是GNU ld的问题,但maintainer拒绝了PR ld/27210。 我在ld.lld 13.0.0中实现了类似的hack(https://reviews.llvm.org/D107235),但希望binutils能修复assembler的问题(https://sourceware.org/pipermail/binutils/2021-August/117677.html)。

ld.lld分配version indexes的方式:首先,每个Verdef条目从2开始获得index;然后,对于每个解析到shared object定义的dynamic symbol,如果(file, version)对尚未出现过,则分配一个新的Verneed/Vernaux index。

Version script

在输出的shared object或可执行档中定义version必须指定version script。若所有versioned符号均为未定义状态则无需version script。

1
2
3
4
5
6
7
# Make all symbols other than foo and bar local.
{ global: foo; bar; local: *; };

# Assign version FBSD_1.0 to malloc and version FBSD_1.3 to mallocx,
# and make internal local.
FBSD_1.0 { malloc; local: internal; };
FBSD_1.3 { mallocx; };

Version script有三个用途:

  • 定义versions
  • 指定一些模式,使得匹配的、定义的、unversioned的符号具有指定的version
  • Scope reduction
    • 对于一个被local:模式匹配的符号,如果它是定义的、unversioned的,那么它的binding会被更改为STB_LOCAL,不会导出到dynamic symbol table
    • 对一个定义的unversioned符号,它可以被任何version node内的local:模式匹配
    • 对一个定义的versioned符号,它可以被相应的version node内的local:模式匹配。例如,foo@@v1foo@v1都能被v1 { local: foo; };匹配

一个version script由一个anonymous version tag ({...};),或若干named version tags (v1 {...};)组成。 如果一个anonymous version tag和其他version tag一起使用,GNU ld会报错anonymous version tag cannot be combined with other version tagslocal:可以放在任意version tag里。

如果一个定义的符号被多个version tags匹配,如下的优先级规则适用(binutils-gdb/bfd/linker.c:find_version_for_sym):

  1. 第一个有exact pattern(无wildcard)的version tag获胜
  2. 否则,最后一个有非*的wildcard pattern在global:中的version tag获胜
  3. 否则,最后一个有非*的wildcard pattern在local:中的version tag获胜
  4. 否则,最后一个有* pattern的version tag获胜

在gold和ld.lld中,规则如下:

  1. 第一个有exact pattern的version tag获胜
  2. 否则,最后一个有非*的wildcard pattern的version tag获胜。如果version tag在global:local:中都有非*的wildcard pattern,global:的获胜
  3. 否则,最后一个有* pattern的version tag获胜。(在LLD 18之前,是第一个而非最后一个

例如,给定v1 { local: p*;}; v2 { global: pq*;}; v3 { local: pqr*;};,对于定义的non-local符号pqrs,gold和ld.lld选择local: pqr*,而GNU ld选择global: pq*

**也是catch-all pattern,但优先级高于*

GNU ld在一个pattern同时出现在global:local:时报错。

大多数patterns是exact的,所以gold和ld.lld迭代patterns而不是符号来改善性能。

GNU ld和gold为每个定义的version在.symtab.dynsym中添加一个absolute symbol(st_shndx=SHN_ABS)。 ld.so不需要这个符号,所以这个行为看起来很奇怪。

-r链接中,--version-script被忽略。技术上local: version nodes可能与-r一起使用,但GNU ld和ld.lld只是忽略--version-script

Versioned symbol产生方式

一个未定义符号获得version的方式:

  • 名字不包含@(没有使用.symver):某个shared object定义了default version符号
  • 名字包含@:该符号须要被某个shared object定义,否则GNU ld会报错;https://reviews.llvm.org/D92260之后ld.lld也会报错

一个定义的符号获得version的方式:

  • 名字不包含@:被version script的一个named version tag的某个pattern匹配而获得version
  • 名字包含@
    • -shared:version v1须要被version script定义,否则GNU ld会报错(version node not found for symbol)
    • -no-pie-pie:GNU ld不需要version script即会生成version定义v1。这个行为奇怪。

推荐用法

个人推荐:

定义default-version符号时不要用.symver。在version script的相应version node里指定这个符号即可。 如果你确实想用.symver,使用.symver foo, foo@@@v2使得foo不存在。如果需要binutils>=2.35或Clang>=13,.symver foo, foo@@v2, remove也可以。

定义non-default符号时在原符号名后加后缀(.symver foo_v1, foo@v1)防止和foo冲突。这会留下(通常不想要的)foo_v1。如果不从object file中strip foo_v1,可以在version script中用local:模式localize它。使用较新的工具链,可以用.symver foo_v1, foo@v1, remove

1
2
3
4
5
6
7
8
9
10
11
12
cat > a.c <<e
__asm__(".symver foo_v1, foo@v1, remove");
void foo_v1() {}

void foo() {}
e
cat > a.ver <<e
v1 {};
v2 { foo; };
e

cc -fpic a.c -shared -Wl,--version-script=a.ver -o a.so
1
2
3
% readelf -W --dyn-syms a.so | grep @
5: 0000000000001630 7 FUNC GLOBAL DEFAULT 11 foo@@v2
6: 0000000000001629 7 FUNC GLOBAL DEFAULT 11 foo@v1

未定义的versioned符号通常是链接时绑定的,object files不须要指定符号。如果确实要引用,推荐.symver foo, foo@@@v1,即使能.symver foo, foo@v1达到相同效果

多数时候,你希望未定义在链接时绑定到default version。通常没有必要指定.symver。 如果确实要引用,.symver foo_v1, foo@@@v1.symver foo_v1, foo@v1均可。

1
2
3
4
5
6
7
8
9
10
cat > b.c <<e
__asm__(".symver foo_v1, foo@v1"); // foo@@@v1 and foo@v1, remove work as well
void foo_v1();

void bar() {
foo_v1();
}
e

cc -fpic b.c ./a.so -shared -o b.so

未定义符号被绑定到non-default version foo@v1

1
2
% readelf -W --dyn-syms b.so | grep foo
5: 0000000000000000 0 FUNC GLOBAL DEFAULT UND foo@v1 (2)

如果省略.symver,则会被绑定到default version foo@@v2

为什么.symver xxx, foo@v1对定义的符号不好?

有两种情况。

第一,xxx不是foo(versioned符号的unadorned name)。 这是最常见的用法。 不失一般性,我们用.symver foo_v1, foo@v1作为例子。

如果version script没有localize foo_v1,我们会在.dynsym中得到foo_v1。 这个额外的符号几乎总是不想要的。

第二,xxx是foofoo定义可以满足其他TU中的unversioned引用。 如果你仔细想想,non-default version定义在TU外被使用是非常罕见的。

1
2
3
4
5
6
7
# a.s
.symver foo, foo@v1
.globl foo
foo:

# b.s
# 如果打算使用non-default version符号,应该引用foo@v1而不是foo

如果version script包含v1 {};,输出在GNU ld和ld.lld>=13.0.0中只有foo@v。 输出在gold和较旧的ld.lld中会同时有foofoo@v1

如果version script包含v1 { foo; };,输出在GNU ld、gold和ld.lld>=13.0.0中只有foo@v1。 输出在较旧的ld.lld中会同时有foofoo@v1

如果version script包含v2 { foo; };,pattern会被忽略。 不幸的是没有链接器对这种容易出错的情况报警告。


有不同的行为是不幸的。 而且第二种情况需要链接器内部的复杂性。

rtld行为

Linux Standard Base Core Specification, Generic Part 描述了rtld(ld.so)的行为。 kan在2005年给FreeBSD rtld添加了symbol versioning支持。

Dynamic table中的DT_VERNEEDDT_VERNEEDNUM标签界定了shared object/可执行档的version requirement:所需的versions和所需的shared object名(Vernaux::vna_name)。

当载入带有DT_VERNEEDED的object时,glibc rtld执行一些检查(_dl_check_all_versions)。 对于每个Vernaux条目(Verneed的辅助条目),glibc rtld检查被引用的shared object是否有DT_VERDEF表。 如果没有,ld.so将此作为graceful degradation处理并打印no version information available (required by %s); 如果有但表中没有定义该version,ld.so报告(如果Vernaux条目有VER_FLG_WEAK bit)警告或(否则)错误。[verneed-check]

通常minor releases不会bump soname。假设libB.so依赖libA 1.3(soname为libA.so.1)并调用1.2版本不存在的函数。如果使用PLT lazy binding,libB.so在安装了libA 1.2的系统上似乎还能工作,直到1.3的符号的PLT被调用。 如果不使用symbol versioning且想解决这个问题,就得在soname里记录minor version number(libA.so.1.3)。然而,bump soname是全有或全无的:所有依赖的shared objects都需要重新链接。 如果使用symbol versioning,可以继续使用soname libA.so.1。ld.so会在使用libA 1.2时报错,因为libB.so需要的1.3 version不存在。

foo搜索定义时:

  • 对于不含DT_VERSYM的object
    • 可以绑定到foo
  • 对于含DT_VERSYM的object
    • 可以绑定到version VER_NDX_GLOBALfoo。这优先于接下来两条规则
    • 可以绑定到任一default version的foo
    • 在relocation resolving阶段(非dlsym/dlvsym)可以绑定到non-default version index 2的foo。该规则在shared object变成versioned时保持兼容性。

注意(未定义foo绑定到foo@v1 version index 2)是ld.so允许但链接器不允许的{reject-non-default}。 rtld的行为是为了在shared object变成versioned时保持兼容性:最小version(index 2)的符号表示之前unversioned的符号。 如果新版本的shared object需要废弃unversioned bar,可以移除bar并定义bar@compat。使用bar的库不受影响,但禁止新链接到bar

当有多个版本的foo时,dlsym(RTLD_DEFAULT, ...)返回default version。 在glibc 2.36之前,dlsym(RTLD_NEXT, ...)返回第一个版本(BZ14932)。 这是因为在elf/dl-sym.c:do_sym中,RTLD_NEXT分支没有把DL_LOOKUP_RETURN_NEWEST标志传给dl_lookup_symbol_x。 FreeBSD没有这个问题。

foo@v1搜索定义时:

  • 对于不含DT_VERSYM的object
    • 可以绑定到foo。在glibc中,elf/dl-lookup.c:check_match断言filename不匹配vn_file filename
  • 对于含DT_VERSYM的object
    • 可以绑定到foo@v1foo@@v1
    • 在relocation resolving阶段(非dlsym/dlvsym)可以绑定到version VER_NDX_GLOBALfoo

假设b.so引用malloc@GLIBC_2.2.5。可执行档因为链接了malloc实现而定义unversioned malloc。 运行时,b.so中的malloc@GLIBC_2.2.5会绑定到可执行档。 例如,address/memory/thread sanitizers利用这个行为:shared objects不需要链接interceptors;在可执行档中有interceptor就足够了。 libxml2利用这个行为移除符号的versioning,同时保持与链接旧版libxml2的objects的兼容性。

当versioned reference绑定到没有symbol versioning的shared object时,在glibc 2.41之前,elf/dl-lookup.c:check_match曾断言filename不匹配vn_file filename

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
echo 'void foo(); int main() { foo(); }' > a.c
echo 'v1 { foo; };' > c0.ver
echo 'void foo() {}' > c.c
sed 's/^ /\t/' > Makefile <<'eof'
.MAKE.MODE := meta curdirOk=1
CFLAGS := -fpic
LDFLAGS := -Wl,--no-as-needed

a: a.c c.so c0.so
$(LINK.c) a.c c0.so -Wl,-rpath=$$PWD -o $@
c0.so: c.c c0.ver
$(LINK.c) -shared -Wl,-soname=c.so,--version-script=c0.ver c.c -o $@
c.so: c.c
$(LINK.c) -shared -Wl,-soname=c.so -nostdlib c.c -o $@
clean:
rm -f a *.so *.o *.meta
eof
1
2
3
4
5
6
% bmake && ./a   # glibc<2.41
./a: /tmp/d/c.so: no version information available (required by ./a)
Inconsistency detected by ld.so: dl-lookup.c: 107: check_match: Assertion `version->filename == NULL || ! _dl_name_match_p (version->filename, map)' failed!

% bmake && ./a # glibc>=2.41
./a: /tmp/d/c.so: no version information available (required by ./a)

这个glibc<2.41的检查相当愚蠢,因为大多数shared objects由于对libc的versioned references如__cxa_finalize@GLIBC_2.2.5(来自GCC crtbeginS.o)而有DT_VERSYM

除了这个断言,自glibc 2.30 BZ24741起,vn_file在符号搜索中基本被忽略。 之前在relocation resolving期间,当一个object未能提供匹配后,如果它匹配vn_file,rtld会报错symbol %s version %s not defined in file %s with link time reference

glibc 2.30 ld.so: Support moving versioned symbols between sonames [BZ #24741]对weak references有附带好处。 之前,如果b.so有versioned weak reference foo@v1(其中v1引用c.so),当c.so缺少foo@@v1foo@v1时,rtld会报错symbol %s version %s not defined in file %s with link time reference——这与weak reference语义相悖。 较新的rtld只要运行时的c.so定义了version v1就能容忍这种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
echo '#include <stdio.h>\nvoid fb(); int main() { fb(); puts("a"); }' > a.c
echo '__attribute__((weak)) void foo(); void fb() { if (foo) foo(); }' > b.c
echo 'v1 { foo; };' > c-link.ver
echo '#include <stdio.h>\nvoid foo() { puts("foo"); }' > c.c
echo 'v1 { };' > c.ver
echo 'v2 { };' > c2.ver
sed 's/^ /\t/' > Makefile <<'eof'
.MAKE.MODE := meta curdirOk=1
CFLAGS := -fpic

a: a.c b.so c-link.so c.so c2.so
$(LINK.c) a.c b.so -Wl,-rpath=$$PWD -o $@
b.so: b.c c-link.so
$(LINK.c) -shared -Wl,--no-as-needed $> -Wl,-rpath=$$PWD -o $@
c-link.so: c.c c.ver
$(LINK.c) -shared -Wl,-soname=c.so,--version-script=c-link.ver c.c -o $@
c.so: c.c c.ver
$(LINK.c) -shared -Wl,-soname=c.so,--version-script=c.ver -Dfoo=foo1 c.c -o $@
c2.so: c.c c2.ver
$(LINK.c) -shared -Wl,-soname=c.so,--version-script=c2.ver -Dfoo=foo1 c.c -o $@
clean:
rm -f a *.so *.o *.meta
eof

如果c.so完全缺少所需的version,rtld仍会报fatal error:

1
2
3
4
5
6
% bmake
...
% ./a
a
% LD_PRELOAD=c2.so ./a
./a: /tmp/t/v2/c2.so: version `v1' not found (required by /tmp/t/v2/b.so)

2026年的BZ24718#c19评论询问Vernaux::vna_flags中的VER_FLG_WEAK扩展,允许rtld继续执行(带verbose模式警告weak version ... not found)而不是失败。

如果a中的v1 Verneed有VER_FLG_WEAK标志,我们将看到:

1
2
3
% LD_PRELOAD=c2.so ./a
./a: /tmp/t/v2/c2.so: weak version `v1' not found (required by /tmp/t/v2/b.so)
a

例子

运行以下代码创建a.c b.c b0.c Makefile,然后运行bmake

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
cat > ./a.c <<'eof'
#define _GNU_SOURCE // workaround before glibc 2.36 (commit 748df8126ac69e68e0b94e236ea3c2e11b1176cb)
#include <dlfcn.h>
#include <stdio.h>

int foo(int a);

int main(void) {
int res = foo(0);
printf ("foo(0) = %d, %s\n", res, res == 0 ? "ok" : "wrong");

/* Resolve to foo@@v3 in b.so, instead of foo@v1 or foo@v2. */
int (*fp)(int) = dlsym(RTLD_DEFAULT, "foo");
res = fp (0);
printf ("foo(0) = %d, %s\n", res, res == 3 ? "ok" : "wrong");

/* Resolve to foo@@v3 in b.so, instead of foo@v1 or foo@v2. */
fp = dlsym(RTLD_NEXT, "foo");
res = fp(0);
printf ("foo(0) = %d, %s\n", res, res == 3 ? "ok" : "wrong");
}
eof
echo 'int foo(int a) { return -1; }' > ./b0.c
cat > ./b.c <<'eof'

int foo_va(int a) { return 0; } asm(".symver foo_va, foo@va, remove");
int foo_v1(int a) { return 1; } asm(".symver foo_v1, foo@v1, remove");
int foo_v2(int a) { return 2; } asm(".symver foo_v2, foo@v2, remove");
int foo(int a) { return 3; } asm(".symver foo, foo@@@v3");
eof
echo 'va {}; v1 {} va; v2 {} v1; v3 {} v2;' > ./b.ver
sed 's/^ /\t/' > ./Makefile <<'eof'
.MAKE.MODE := meta curdirOk=1
CFLAGS = -fpic -g

a: a.o b0.so b.so
$(CC) -Wl,--no-as-needed a.o b0.so -ldl -Wl,-rpath=$$PWD -o $@

b0.so: b0.o
$(CC) $> -shared -Wl,--soname=b.so -o $@

b.so: b.o
$(CC) $> -shared -Wl,--soname=b.so,--version-script=b.ver -o $@

clean:
rm -f a *.so *.o *.meta
eof

在glibc 2.36之前,输出是:

1
2
3
4
% ./a
foo(0) = 0, ok
foo(0) = 3, ok
foo(0) = 0, wrong
从2.36开始,最后一行是正确的。

glibc中升级过的符号

When to prevent execution of new binaries with old glibc总结了何时引入新的symbol version。

注意,binutils 2.35之前的GNU nm不显示@@@

1
2
3
nm -D /lib/x86_64-linux-gnu/libc.so.6 | \
awk '$2!="U" {i=index($3,"@"); if(i){v=substr($3,i); $3=substr($3,1,i-1); m[$3]=m[$3]" "v}} \
END {for(f in m)if(m[f]~/@.+@/)print f, m[f]}'

在我的x86-64系统上的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
pthread_cond_broadcast  @GLIBC_2.2.5 @@GLIBC_2.3.2
clock_nanosleep @@GLIBC_2.17 @GLIBC_2.2.5
_sys_siglist @@GLIBC_2.3.3 @GLIBC_2.2.5
sys_errlist @@GLIBC_2.12 @GLIBC_2.2.5 @GLIBC_2.3 @GLIBC_2.4
quick_exit @GLIBC_2.10 @@GLIBC_2.24
memcpy @@GLIBC_2.14 @GLIBC_2.2.5
regexec @GLIBC_2.2.5 @@GLIBC_2.3.4
pthread_cond_destroy @GLIBC_2.2.5 @@GLIBC_2.3.2
nftw @GLIBC_2.2.5 @@GLIBC_2.3.3
pthread_cond_timedwait @@GLIBC_2.3.2 @GLIBC_2.2.5
clock_getres @GLIBC_2.2.5 @@GLIBC_2.17
pthread_cond_signal @@GLIBC_2.3.2 @GLIBC_2.2.5
fmemopen @GLIBC_2.2.5 @@GLIBC_2.22
pthread_cond_init @GLIBC_2.2.5 @@GLIBC_2.3.2
clock_gettime @GLIBC_2.2.5 @@GLIBC_2.17
sched_setaffinity @GLIBC_2.3.3 @@GLIBC_2.3.4
glob @@GLIBC_2.27 @GLIBC_2.2.5
sys_nerr @GLIBC_2.2.5 @GLIBC_2.4 @@GLIBC_2.12 @GLIBC_2.3
_sys_errlist @GLIBC_2.3 @GLIBC_2.4 @@GLIBC_2.12 @GLIBC_2.2.5
sys_siglist @GLIBC_2.2.5 @@GLIBC_2.3.3
clock_getcpuclockid @GLIBC_2.2.5 @@GLIBC_2.17
realpath @GLIBC_2.2.5 @@GLIBC_2.3
sys_sigabbrev @GLIBC_2.2.5 @@GLIBC_2.3.3
posix_spawnp @@GLIBC_2.15 @GLIBC_2.2.5
posix_spawn @@GLIBC_2.15 @GLIBC_2.2.5
_sys_nerr @@GLIBC_2.12 @GLIBC_2.4 @GLIBC_2.3 @GLIBC_2.2.5
nftw64 @GLIBC_2.2.5 @@GLIBC_2.3.3
pthread_cond_wait @GLIBC_2.2.5 @@GLIBC_2.3.2
sched_getaffinity @GLIBC_2.3.3 @@GLIBC_2.3.4
clock_settime @GLIBC_2.2.5 @@GLIBC_2.17
glob64 @@GLIBC_2.27 @GLIBC_2.2.5

一个符号发生ABI变更时(如变更参数或返回值的类型)必须要加新version,而API行为变更时有时也会加,比如:

  • realpath@@GLIBC_2.3: 之前的realpath在第二个参数为NULL时返回EINVAL
  • memcpy@@GLIBC_2.14 BZ12518: 之前的memcpy copies forward。当年的Shockwave Flash有个memcpy downward的bug因为memcpy采用了复杂的copy策略而触发
  • quick_exit@@GLIBC_2.24 BZ20198: 之前的quick_exit会调用thread_local objects的destructors
  • glob64@@GLIBC_2.27: 之前的glob不follow dangling symlinks

去除symbol versioning

假设你想用一个有versioned references的预构建shared object构建应用,但只能找到提供unversioned定义的shared objects。链接器会报错:

1
ld.lld: error: undefined reference to foo@v1 [--no-allow-shlib-undefined]

如诊断所示,可以添加--allow-shlib-undefined来消除错误。这不推荐,但构建的应用可能碰巧能工作。

对于这种情况,另一个hack的解决方案是:

1
2
3
4
5
6
7
8
9
# 64-bit
cp in.so out.so
rizin -wqc '/x feffff6f00000000 @ section..dynamic; w0 16 @ hit0_0' out.so
llvm-objcopy -R .gnu.version out.so

# 32-bit
cp in.so out.so
rizin -wqc '/x feffff6f @ section..dynamic; w0 8 @ hit0_0' out.so
llvm-objcopy -R .gnu.version out.so

删除.gnu.version后,链接器会认为out.so引用的是foo而非foo@v1。 然而,llvm-objcopy会把section内容清零。在运行时,glibc ld.so会报错unsupported version 0 of Verneed record。 要让glibc满意,可以从dynamic table中删除DT_VER*标签。上面的代码片段用r2命令定位DT_VERNEED(0x6ffffffe)并重写为DT_NULLDT_NULL条目停止dynamic table的解析)。readelf -d输出的差异大致是:

1
2
3
4
5
6
  0x000000006ffffffb (FLAGS_1)            Flags: NOW
- 0x000000006ffffffe (VERNEED) 0x8ef0
- 0x000000006fffffff (VERNEEDNUM) 5
- 0x000000006ffffff0 (VERSYM) 0x89c0
- 0x000000006ffffff9 (RELACOUNT) 1536
0x0000000000000000 (NULL) 0x0

ld.lld

  • 如果未定义符号没有被shared object定义,GNU ld会报错。ld.lld 12.0之前不会报错(我在https://reviews.llvm.org/D92260中修复了它)。

GNU function attribute

有一个GNU function attribute可以降低为.symver汇编指令。 这个attribute由GCC实现但Clang没有实现。

1
2
extern "C" __attribute__((symver("foo@@v2"))) void foo() {}
extern "C" __attribute__((symver("foo@v1"))) void foo_v1() {}

不幸的是,@@@,remove不被支持。 加上Clang没有实现这个function attribute的原因,我不建议使用这个特性。

评价

GCC/Clang支持asm specifier和#pragma redefine_extname重命名一个符号。比如声明int foo() asm("foo_v1");再引用foo,.o中的符号会是foo_v1

举个例子,musl v1.2.0最大的变化是32-bit架构的time64支持。musl采取了一种使用asm specifier的方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// include/features.h
#define __REDIR(x,y) __typeof__(x) x __asm__(#y)

// API header include/sys/time.h
int utimes(const char *, const struct timeval [2]);
__REDIR(utimes, __utimes_time64);

// Implementation src/linux/utimes.c
int utimes(const char *path, const struct timeval times[2]) { ... }

// Internal header compat/time32/time32.h
int __utimes_time32() __asm__("utimes");

// Compat implementation compat/time32/utimes_time32.c
int __utimes_time32(const char *path, const struct timeval32 times32[2]) { ... }
  • .o中,time32定义仍为utimes,提供ABI兼容旧程序;time64定义则为__utimes_time64
  • Public header用asm specifier重定向utimes__utimes_time64
    • 缺点是倘若用户自行声明utimes而不include public header,会得到deprecated time32定义。这种自行声明的方式是不推荐的
  • 内部实现中"好看的"名字utimes表示time64定义;"难看的"名字__utimes_time32表示deprecated time32定义
    • 假如time32实现被其他函数调用,那么用"难看的"名字能清晰地标识出来"此处和deprecated time32定义有关"

对于上述的例子,用symbol versioning来实作大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// API header include/sys/time.h
int utimes(const char *, const struct timeval [2]);

// Implementation src/linux/utimes.c
int utimes(const char *path, const struct timeval times[2]) { ... }

// Internal header compat/time32/time32.h
// Probably __asm__(".symver __utimes_time32, utimes@time32, rename"); if supported
__asm__(".symver __utimes_time32, utimes@time32");

// Implementation compat/time32/utimes_time32.c
int __utimes_time32(const char *path, const struct timeval32 times32[2])
{
...
}

注意.symver不可用@@@。这个header被定义的translation unit使用,@@@会产生一个default version定义,而我们想要一个non-default version。 根据前文对Assembler行为的讨论,不如意的地方是:定义的translation unit中,__utimes_time32这个符号也存在。链接时注意用version script localize它。

那么symbol versioning还有什么意义呢?我细细琢磨:

  • 在保持运行时兼容旧库的同时拒绝链接旧符号。{reject-non-default}
  • 不需要标注declarations。
  • version定义可以延迟到链接时。version script提供灵活的pattern matching机制来指定versions。
  • Scope reduction。可以说如果version scripts不提供local:,另一个类似--dynamic-list的机制可能会被开发出来。
  • 对用asm specifiers重命名GCC和Clang认识的builtin functions有一些语义问题(它们不知道重命名的符号有内建语义)。见2020-10-15-intra-call-and-libc-symbol-renaming
  • [verneed-check]

对于第一条,asm specifier方案用约定来防止问题(用户应该include header);而symbol versioning可以由ld强制。

设计缺陷:

  • .symver foo, foo@v1 {gas-copy}
  • Verdaux有点多余。实践中一个Verdef只有一个Verdaux辅助条目。
  • 这可以说是个小问题,但对提供多个shared objects的框架来说很烦人。ld.so要求"a versioned symbol is implemented in the same shared object in which it was found at link time",不允许在shared objects之间移动定义。所幸glibc 2.30 BZ24741放宽了这个要求,实质上忽略了Vernaux::vna_name

在此之前,glibc把clock_*函数从librt.so移动到libc.so用的方法是:

1
2
3
// rt/clock-compat.c
__typeof(clock_getres) *clock_getres_ifunc(void) asm("clock_getres");
__typeof(clock_getres) *clock_getres_ifunc(void) { return &__clock_getres; }

libc.so中定义__clock_getresclock_getres。librt.so中用一个名为clock_getres的ifunc引导到libc.so中的__clock_getres

相关链接