Symbol versioning

Ulrich Drepper和Eric Youngdale在1990+年借鉴Solaris symbol versioning,设计了用于glibc的GNU风格symbol versioning,意图是给shared objects提供backward compatibility:当一个shared object升级须要变更某个符号的行为时,在添加新的符号的同时,保留compatible符号兼容旧的依赖的shared objects。

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

表示方式

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, always 1 in practice
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 depended 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 or VER_FLG_WEAK; copied from vd_flags of the depended so
Elf64_Half vna_other; // unused
Elf64_Word vna_name; // .dynstr offset of the version name
Elf64_Word vna_next; // offset in bytes to next vernaux entry
} Elf64_Vernaux;

Version index values

Index 0称为VER_NDX_LOCAL,标记一个定义的符号的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

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

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

Assembler行为

GNU as和LLVM integrated assembler提供实现。

  • 对于.symver foo, foo@v1
    • 如果foo未定义,.o中有一个名为foo@v1的符号
    • 如果foo被定义,.o中有两个符号:foofoo@v1,两者的binding一致(均为STB_LOCAK,或均为STB_WEAK,或均为STB_GLOBAL),st_other一致(visibility一致)。个人认为这个行为是设计缺陷
  • 对于.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的符号

个人推荐:

  • 定义default-version符号时使用.symver foo, foo@@@v2,在.o中只产生foo@@v2,不产生foo
  • 定义non-default符号时在原符号名后加后缀(.symver foo_v1, foo@v1)防止和foo冲突。在.o中会同时有foo_v1foo@v1。目前没有便捷方法去除(通常不想要的)foo_v1,一般在指定version script时注意把foo_v1设置为local
  • 未定义的versioned符号通常是链接时绑定的,object files不须要指定符号。如果确实要引用,推荐.symver foo, foo@@@v1,即使能.symver foo, foo@v1达到相同效果

在.o中,@是实际出现在symbol table中的。

链接器行为

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

  • 定义的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(@)定义。

(LLD的实现中,看到shared object中的foo@@v1则在符号表中同时插入foofoo@v1,因此可以满足未定义的foofoo@v1。)
(GNU ld用indirect symbol表示versioned符号,在很多阶段都有复杂的规则。)

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

  • 定义versions
  • 指定一些模式,使得匹配的、定义的、unversioned的符号具有指定的version
  • Local version:local:可以改变匹配的、定义的、unversioned的符号的binding为STB_LOCAL,不会导出到dynamic symbol table

在shared objects和可执行档中,对于static symbol table,@直接出现在符号名中;对于dynamic symbol table,version index信息由一个parallel table .gnu.version(DT_VERSYM)提供。实际的version信息则存储在.gnu.version_d(DT_VERDEF)和.gnu.version_r(DT_VERNEED)中。

假如一个ld.so不支持symbol versioning(忽略DT_VERSYM,DT_VERDEF,DT_VERNEED),那么它能够继续工作,就好像所有符号都没有version一样。musl ld.so就属于此类。

Versioned symbols产生方式

对于一个符号,它获得version的可能途径:

  • 它是未定义的。该符号须要被某个shared object定义,否则GNU ld会报错
  • 它是定义的
    • 在.o中符号名形如foo@v1foo@@v1。Version v1须要被version script定义,否则报错
    • 原本unversioned,被version script的规则匹配而获得version

ld.so行为

Dynamic table中的DT_VERNEEDDT_VERNEEDNUM标识了一个shared object/可执行档需要的外部version定义,及该定义须由哪个shared object(Vernaux::vna_name)提供。
如果该Vernaux项(附属于Verneed)没有VER_FLG_WEAK标志,且目标shared object中DT_VERDEF表存在但没有定义需要的version,报错。

接下来是符号解析阶段。

  • 未定义unversioned foo可以解析到定义foofoo@@v2(v2的version index应为1(VER_NDX_GLOBAL)或2)
  • 未定义versioned foo@v1可以解析到定义foofoo@v1foo@@v1

注意(未定义versioned foo@v1解析到定义foo)这种情况是ld.so允许而链接器不允许的[link]。这提供了一种机制:在不阻碍运行时符号解析的情况下拒绝链接旧的符号。
假如某个旧版本shared object定义bar而希望在新版本废弃这个符号,可以去除bar而定义bar@compat。依赖该.so的库中的未定义bar仍可以解析,但该库无法重新链接。

LLD

LLD的实现有尚有一些不足。

1
2
3
4
5
6
7
8
9
10
# RUN: not ld.lld a.o b.o
# RUN: ld.bfd a.o b.o

//--- a.s
.symver foo, foo@@@v1
.globl foo
foo:
//--- b.s
.symver foo, foo@@@v1
call foo

去除symbol versioning

1
llvm-objcopy -R .gnu.version -R .gnu.version_d -R .gnu.version_r in.so out.so

执行该命令后,链接时使用out.so即可防止输出引用in.so定义的versions。

llvm-objcopy会把删除的sections在文件中的空间清零,因此这样得到的out.so不可用于运行时。
若须用于运行时,可以把dynamic table中的DT_VER*删除。

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

整个方案就是:

1
2
3
cp in.so out.so
r2 -wqc '/x feffff6f00000000 @ section..dynamic; w0 16 @ hit0_0' a.so
llvm-objcopy -R .gnu.version -R .gnu.version_d -R .gnu.version_r out.so

评价

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

举个例子,musl v.1.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(cosnt 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]) { ... }
  • 内部实现中“好看的”名字utimes表示time64定义;“难看的”名字__utimes_time32表示deprecated time32定义
    • 假如time32实现被其他函数调用,那么用“难看的”名字能清晰地标识出来“此处和deprecated time32定义有关”
  • Public header用asm specifier,同时作用与内部实现和对外的API
    • time32实现作为ABI而不是公开的API存在。用户若include public header,一般无法链接到__utimes_time32
    • 缺点是倘若用户自行声明utimes而不include public header,会得到deprecated time32定义。这种自行声明的方式是不推荐的
  • .o中,time32定义仍为utimes,提供ABI兼容旧程序;time64定义则为__utimes_time64

对于上述的例子,用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(cosnt 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不可用@@@,因为我们希望定义一个non-default version。
根据前文对Assembler行为的讨论,不如意的地方是:定义的translation unit中,__utimes_time32这个符号也存在。链接时注意用version script localize它。

那么symbol versioning还有什么意义呢?我细细琢磨,有如下优点:

  • 在不阻碍运行时符号解析的情况下拒绝链接旧的符号
  • version定义可以延迟决定到链接时。链接时的version script提供灵活的pattern matching机制指定versions
  • version script中的local:可以使符号获得值为VER_NDX_LOCAL的version,具有把weak/global符号变成local的效果
  • 对编译器认识的builtin functions,在GCC/Clang的实现里重命名有一些语义上的问题(符号foo含有内建语义X)2020-10-15-intra-call-and-libc-symbol-renaming
  • 对于一个.so,ld.so检查是否所有DT_VERNEED需要的versions都存在,不存在则在符号解析前报错

其实可能只有前两条是比较好的。
对于第一条,asm specifier的方案用约定来避免意外链接(用户不应自行链接__utimes_time32);而symbol versioning可以用ld强制。
local:有用,但假如没有version script也可以设计一个其他的类似--version-script--dynamic-list的机制提供该功能。

设计缺点:

  • .symver foo, foo@v1foo被定义时的行为:保留符号foo(链接时有个多余的符号)、binding/st_other保持同步(不方便设置不同的binding/visibility)
  • Verdaux有点多余。实践中一个Verdef只有一个Verdaux
  • Verneed/Vernaux的结构绑定了提供version定义的shared object的soname,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