Shared Library Versioning

相容性問題

使用 library 其中一個目標是不需要重新 compile 就能方便的升級程式的部份功能或者修正 bug。理想上,升級 library 只需要用新的檔案取代舊的。但是事情沒那麼美好,舊程式不見得能正常使用新 library,新舊程式間會有相容性問題。

依據舊程式能不能使用新 library,library 的更新分為相容更新與不相容更新。相容更新通常不修改 interface,可能只是修正 bug 或者加點新東西。不相容更新則改變了 interface,這時舊版程式無法正常使用新版 library。

library 以 binary 的形式發佈,代表 interface 是 binary 層級,也就是 ABI(Application Binary Layer),包含 call function 的 stack 結構、symbol 命名、memory 配置、參數的結構等等。

ABI 跟語言有很大關係,以 C 語言來說,只要跟 export 的 symbol(function 跟 variable)有關的修改,都會動到 ABI,例如增加 export 變數、增減 export 的 function 參數、修改 export 的資料結構(會改變 memory 配置,程式依舊配置使用新結構會踩壞記憶體)等等。至於 C++ 基本上是場災難,因為各家 compiler 甚至相同 compiler 的不同版本可能都對 C++ 的各種特性諸如 virtual function 等有不同實作方式,這會造成 ABI 不相容。C++ 有一些避免改到 interface 的原則及作法,例如 pimpl。

版號

既然有相容性問題,程式如何知道能不能使用新 library?系統必須有方法確保相容性、讓程式能正常執行,其中一個方法是以版號區分不同版本的 library,並讓程式指定它相容於哪些版本的 library。

在 Linux 系統中,shared library 的檔案命名規則為 libname.so.x.y.z。最前面是字首 lib,中間是 library 名稱,接著 .so,最後跟著三個版號數字。x 表示 major versison number,y 是 minor version number,z 為 release version number。

major version number 代表 library 有重大升級,不同 major version number 的 library 是不相容的。使用舊版的程式必須要經過修改及重新 compile 才能使用新版 library。系統中需要保留舊版 library 以讓未更新至使用新版 library 的程式依然能正常執行。

minor version number 表示 library 是增量升級,也就是加一些新的 interface 並且保持原本的 symbol 在名稱與語意上皆不變,例如增加一個 function。在 major version number 相同的狀況下,高 minor version number 的 library 向後相容低 minor version number 的 library,使用舊版 library 的程式可以使用新版 library,例如使用 1.1.z libary 的程式可以在 1.2.z library 上正常執行。

release version number 是 library 的 bug 修正、效能改進等等,不添加新 interface 也不改動舊 interface。因此相同 major version number 以及 minor version number 的不同 release version 之間的 library 完全相容,依賴某個 release version 的程式可以使用其他任一 release version。

SO-NAME

library 有版號之後,程式如何指定使用的 library 版本?

在 Solaris 跟 Linux 裡採用 SO-NAME 命名機制,SO-NAME 是 library 版號只保留 major version number,去掉 minor version 以及 release version,例如 libfoo.so.3.2.1 的 SO-NAME 是 libfoo.so.3

Linux 系統會建立檔名為 SO-NAME 並指向 major version 相同、最新 minor 及 release version 的 library 實體檔案的 soft link,例如 /usr/lib/libcln.so.6 指向 /usr/lib/libcln.so.6.0.4。這麼做的好處是系統中的程式只需要記錄 library 的 SO-NAME,再藉由 soft link 就能使用該 major version 最新的 library。

記錄 SO-NAME 提供了一些彈性,讓 library 有一定的升級空間,不會讓程式只限定於某版本的 library。同時又能知道何種狀況是大幅度的更新,系統不應該自動使用新 major version 的 library,需保留舊版 library 讓使用的程式可以正常執行。當然,話是這麼說,版號只是個規則,還是需要由開發者判斷新版是否向後相容以決定如何升版號。

ELF 在 .dynamic 以 SO-NAME 記錄所需的 shared library:

1
2
3
4
5
$ readelf -d main
Dynamic section at offset 0x868 contains 26 entries:
Tag Type Name/Value
...
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]

Linux 指令 ldconfig 用來更新上述 library 的 soft link,它會掃過所有預設 shared library directory,例如 /lib/usr/lib 等,更新所有 soft link,將其指向目前系統中最新的 library 或為新 library 建立 soft link。安裝或更新 library 需要執行 ldconfig

compile 時我們以 -lXXX 指定要 link 的 library,這個 XXX 稱為 link name。指定 link name 時省略了版號,compiler 會到系統相關的搜尋路徑中尋找最新版本的 XXX library。

Symbol Versioning

SO-NAME 有升級彈性,但仍有問題──假設依賴的 library 是 libfoo.2.3.1,SO-NAME 是 libfoo.2,但執行環境中只有 libfoo.2.2.1,以 SO-NAME 來看是正確的,但卻可能不相容,因為程式可能使用 libfoo.2.3.1 才有的 interface,這在 libfoo.2.2.1 中找不到,因而執行錯誤。這個問題稱為 Minor-revision Rendezvous Problem。

為解決 Minor-revision Rendezvous Problem,Solaris 2.5 發展出 symbol versioning 機制,提供 versioning 以及 scoping 機制。

Versioning

基本概念是在每個 import 及 export symbol 加上版號,例如 libfoo.1.3 要更新為 libfoo.1.4 時,就把 libfoo.1.4 增加的 symbol 加上 VERS_1.4 的標記。如此 SO-NAME 是 libfoo.1 各版 library 裡會有 VERS_1.2VERS_1.3VERS_1.4 等等擁有不同版號標記的 symbol,就能區別 symbol 的版本了。

versioning 定義了一些 symbol 集合。symbol 集合有名稱,例如 VERS_1.2,每個集合包含一些 symbol,一個集合能包含另一個集合,例如 VERS_1.2 包含 VERS_1.1,這種包含關係也像是繼承,VERS_1.2 繼承 VERS_1.1 的 symbol。symbol 集合由 symbol version script 指定,在 gcc 可以用 -Xlinker --version-script <script file> 指定。

versioning 機制讓 symbol 可以標上版號,build 執行檔時會記錄它需要的 symbol 及其版號。因為 symbol 只能有一個 version,在相同 major version 的 library 中,同一個 symbol 的 version 是固定的,否則會導致向後不相容。因此,執行檔執行時可以用 SO-NAME 找到系統中最新的 library,接著看它是不是有所需要版本的 symbol,如果有就能正常執行,以此解決 Minor-revision Rendezvous Problem。

但 Solaris 2.5 的 symbol versioning 有個限制:在一個 shared library 中每個 symbol 只能有一個 version,也就是如果 symbol 是 VERS_1.1 就不能再是 VERS_1.2。這造成如果 interface 只有小修改,例如只加一個參數、依然向後相容(只是擴充,舊有功能不變),由於無法讓新版 library 標示該 symbol 為新版,只能透過增加 major version 來表示 interface 改變,有點小題大作。

Example

source code

foo-1.0.h
1
int foo(int x, int y);
foo-1.0.c
1
int foo(int x, int y) { return (x + y); }
foo-1.1.h
1
2
int foo(int x, int y);
int foo2(int x);
foo-1.1.c
1
2
int foo(int x, int y) { return (x + y); }
int foo2(int x) { return (x + x); }
main1.c
1
2
3
4
5
6
7
#include "foo-1.0.h"
#include <stdio.h>
int main()
{
printf("%d\n", foo(2, 3));
return 0;
}
main2.c
1
2
3
4
5
6
7
8
#include "foo-1.1.h"
#include <stdio.h>
int main()
{
printf("%d\n", foo(2, 3));
printf("%d\n", foo2(12));
return 0;
}
foo.1.0.ver
1
2
3
4
5
6
VERS_1.0 {
global:
foo;
local:
*;
};
foo.1.1.ver
1
2
3
4
5
6
7
8
9
10
11
VERS_1.0 {
global:
foo;
local:
*;
};

VERS_1.1 {
global:
foo2;
} VERS_1.0;

library 版本更新的時候,可由新的 symbol 集合看出它的 interface 改動,例如上面 1.1 版繼承了 1.0 版、增加了 symbol foo2

Makefile
1
2
3
4
5
6
7
8
9
10
11
12
1.0:
gcc -shared -fPIC foo-1.0.c -Xlinker --version-script foo.1.0.ver -o libfoo.1.0.so
rm -f libfoo.so
ln -s libfoo.1.0.so libfoo.so
gcc main1.c ./libfoo.so -o main1

1.1:
gcc -shared -fPIC foo-1.1.c -Xlinker --version-script foo.1.1.ver -o libfoo.1.1.so
rm -f libfoo.so
ln -s libfoo.1.1.so libfoo.so
gcc main2.c ./libfoo.so -o main2

看看 library 的 export symbol:

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
$ readelf --dyn-syms libfoo.1.0.so 

Symbol table '.dynsym' contains 9 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 00000000000004b0 0 SECTION LOCAL DEFAULT 10
2: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTab
3: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
4: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _Jv_RegisterClasses
5: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable
6: 0000000000000000 0 FUNC WEAK DEFAULT UND __cxa_finalize@GLIBC_2.2.5 (3)
7: 0000000000000600 20 FUNC GLOBAL DEFAULT 12 foo@@VERS_1.0
8: 0000000000000000 0 OBJECT GLOBAL DEFAULT ABS VERS_1.0

$ readelf --dyn-syms libfoo.1.1.so

Symbol table '.dynsym' contains 11 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000528 0 SECTION LOCAL DEFAULT 10
2: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTab
3: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
4: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _Jv_RegisterClasses
5: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable
6: 0000000000000000 0 FUNC WEAK DEFAULT UND __cxa_finalize@GLIBC_2.2.5 (4)
7: 0000000000000680 20 FUNC GLOBAL DEFAULT 12 foo@@VERS_1.0
8: 0000000000000694 14 FUNC GLOBAL DEFAULT 12 foo2@@VERS_1.1
9: 0000000000000000 0 OBJECT GLOBAL DEFAULT ABS VERS_1.0
10: 0000000000000000 0 OBJECT GLOBAL DEFAULT ABS VERS_1.1

兩個 library 的 symbol foo 後面加上 VERS 資訊。如果多個 symbol 集合裡有相同 symbol,第一次出現 symbol 的集合是該 symbol 的 version。另外,會被包含的集合必須先寫。也就是說 symbol version 的前後關係是由 version script 指定的,通常遵照數字順序寫(不然會誤導別人啦)。

執行檔可以看到使用的 symbol 版本:

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 --dyn-syms main1

Symbol table '.dynsym' contains 8 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTab
2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND foo@VERS_1.0 (2)
3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND printf@GLIBC_2.2.5 (3)
4: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.2.5 (3)
5: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
6: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _Jv_RegisterClasses
7: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable

$ readelf --dyn-syms main2

Symbol table '.dynsym' contains 9 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND foo2@VERS_1.1 (2)
2: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTab
3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND foo@VERS_1.0 (3)
4: 0000000000000000 0 FUNC GLOBAL DEFAULT UND printf@GLIBC_2.2.5 (4)
5: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.2.5 (4)
6: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
7: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _Jv_RegisterClasses
8: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable

修改 soft link libfoo.so 指向的 library,main1 依然可以正常使用 1.1 版的 library,但 main2 無法使用 1.0 版的 library,會跳 ./main2: ./libfoo.so: version 'VERS_1.1' not found (required by ./main2) 找不到 symbol 的 error。

Scoping

在 symbol 集合裡,local: 的設置會將原本 global 的 symbol 變成 local 的,程式以及其他 shared library 無法使用到這些 symbol。這稱為 scoping 機制,算是補強 ELF 處理 C 的 global symbol scoping,因為 ELF 把所有 global symbol 當作 export symbol,這也是為什麼 Linux 下不需要特別指定哪些 global symbol 要 export 就能 export symbol(windows 就需要)。

上面的例子裡如果把 foo.1.0.verlocal: *; 拿掉,原本其他的 export symbol 就不會被設成 local:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ readelf --dyn-syms libfoo.1.0.so

Symbol table '.dynsym' contains 14 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000570 0 SECTION LOCAL DEFAULT 10
2: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTab
3: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
4: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _Jv_RegisterClasses
5: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable
6: 0000000000000000 0 FUNC WEAK DEFAULT UND __cxa_finalize@GLIBC_2.2.5 (3)
7: 00000000002009b8 0 NOTYPE GLOBAL DEFAULT 22 _edata
8: 00000000000006c0 20 FUNC GLOBAL DEFAULT 12 foo@@VERS_1.0
9: 00000000002009c0 0 NOTYPE GLOBAL DEFAULT 23 _end
10: 0000000000000000 0 OBJECT GLOBAL DEFAULT ABS VERS_1.0
11: 00000000002009b8 0 NOTYPE GLOBAL DEFAULT 23 __bss_start
12: 0000000000000570 0 FUNC GLOBAL DEFAULT 10 _init
13: 00000000000006d4 0 FUNC GLOBAL DEFAULT 13 _fini

GCC 對 symbol version 的擴充

GCC 對於 symbol version 提供兩個擴充。第一個是可以指定 symbol 的 version,例如 asm(".symver foo_new, foo@VERS_1.1"); 指定 foo_new 是 symbol foo1.1 版。

第二個擴充是允許同個 symbol 有多個版本,這補充了 Solaris 版本機制的限制。例如上面例子裡想在 1.1 版的 foo() 增加一個參數,但希望仍保留舊版功能好能向後相容,這時候就能以指定不同版本來達到。

foo-1.1.c
1
2
3
4
5
6
asm(".symver foo_old, foo@VERS_1.0");
asm(".symver foo_new, foo@@VERS_1.1");

int foo_old(int x, int y) { return x + y; }
int foo_new(int x, int y, int z) { return (x + y + z); }
int foo2(int x) { return (x + x); }

兩個 .symver 分別指定 foo_old()foo 的版本 1.0foo_new() 是版本 1.1。因為已經用 .symver 自訂 foo 這個 symbol,如果又寫 foo() link 時會有 multiple definition 錯誤。foo@@VERS_1.1 表示 foo 預設版本是 1.1,一個 symbol 的預設版號只能有一個,實驗看起來預設版號會影響 compile 執行檔時記錄的 symbol。由於仍然有 1.0foomain1 用新版 library 仍能正常執行。

foo-1.1.h
1
2
int foo(int x, int y, int z);
int foo2(int x);
main2.c
1
2
3
4
5
6
7
8
#include "foo-1.1.h"
#include <stdio.h>
int main()
{
printf("%d\n", foo(2, 3, 5));
printf("%d\n", foo2(12));
return 0;
}

header 跟 library 檔一起發佈,在 compile 執行檔時讓執行檔知道有什麼 symbol,所以必須要是新版 foo 的 prototype,而且 function name 也要是 foo,而非 foo_new。(我想還是有別的方式可以做到同樣的事情,甚至可以刻意使用舊版 symbol,這只是我認為合理的 library 發佈以及使用方式)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ readelf --dyn-syms libfoo.1.1.so 

Symbol table '.dynsym' contains 12 entries:
Num: Value Size Type Bind Vis Ndx Name
7: 0000000000000690 20 FUNC GLOBAL DEFAULT 12 foo@VERS_1.0
8: 00000000000006a4 28 FUNC GLOBAL DEFAULT 12 foo@@VERS_1.1
9: 00000000000006c0 14 FUNC GLOBAL DEFAULT 12 foo2@@VERS_1.1
10: 0000000000000000 0 OBJECT GLOBAL DEFAULT ABS VERS_1.0
11: 0000000000000000 0 OBJECT GLOBAL DEFAULT ABS VERS_1.1

$ readelf --dyn-syms main2

Symbol table '.dynsym' contains 9 entries:
Num: Value Size Type Bind Vis Ndx Name
1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND foo2@VERS_1.1 (2)
6: 0000000000000000 0 FUNC GLOBAL DEFAULT UND foo@VERS_1.1 (2)

Semantic Versioning

Tom Preston-Werner 提出的 Semantic Versioning 以 spec 的形式提供了版號規範,例如每個數字代表的意義、何時該增加版號、如何增加版號以及如何區分版本的新舊等等。它定義的版號包含 MAJOR.MINOR.PATCH 三個數字,並且可以加上如 -alpha-beta 等 pre-release 資訊。概念與上面說的 library 版號類似,interface 有變更時需增加 major verion,增加向後相容的功能時要增加 minor version,修 bug 則增加 patch version,只是它以精準定義的方式描述這些規則。

當然,它只定義了版號本身的規則,至於「什麼改變是有向後相容?是否更改的 interface?」等問題仍然要由開發者判斷。