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

相關鏈接