動態鏈接庫相關知識
背景
目前歡樂游戲后台服務器都是由bin+so的方式構成,ServerFrame(bin)提供網絡通信,內存管理,配置管理等基礎的通用服務,so實現各個服務器的特有邏輯。bin文件和So公用一個全局變量G進行數據共享,在G在bin中讀取相應的配置,so中根據G中的參數進行一系列的操作。bin和so還公用一些基礎庫的代碼。
最近一件事情引起了后台同學的注意,服務器so更新后,同學執行bin文件,加載so后會發生錯誤,導致服務器會出現異常。
//TODO::確認一下修改代碼的調用點到底是so還是bin,在so是直接調用還是間接調用
經過查看svn代碼,發現了幾個可疑的地方:
1.在bin和so公用的代碼庫里,有一個函數的定義發生了變化,增加了一個帶有默認值的參數。這個函數會在so中被調用,由於帶有默認參數,因此調用方式與從前一樣,並沒有發生改變。
2.最近的代碼提交中,G的定義發生了變化,在其定義的中間添加了幾個變量。
因為so編譯時用的是最新代碼,而bin文件並沒有重新編譯,因此其中的函數以及結構體的定義還都是老版本的定義,因此現在程序的結構圖大致如下所示:
問題
- Q1:因為調用除的代碼不會變,是否可能so中隊函數的調用依舊會走到了bin中所定義的舊版本的函數呢?
A1:不會。程序中調用哪一個函數是在編譯時決定的。默認參數一般只定義在頭文件中,只有編譯器看到了函數有默認參數的聲明或定義之后,才會在函數調用處根據情況,添加默認的參數,否則可能會出現編譯問題。
在so中編譯時確定了調用名為foo且有兩個int類型參數的函數后,是不會再調用到bin中定義的foo函數的。
關於帶默認值的參數,還需要注意一點:Never redefine an inherited default parameter value
- Q2:現在兩邊看到的定義已經不同了,那么程序會如何運行。
A2:bin會按照老的偏移去給變量賦值,而so會依照新的偏移去獲取數值並使用。因為中間添加了新的變量,導致取出的一部分變量沒問題,一部分變量數值不正確。也正是因為這個問題,導致了更換so之后,服務器無法正常啟動。
臨時解決方案
因為問題的根本原因是由於數據結構的改變引起的不兼容,想要解決問題就必須更新bin文件,用最新的版本的數據結構定義編譯后內存才能夠匹配上。
延伸問題
這次問題的出現使一個沒有注意到的問題浮出水面:
由於bin文件和so公用一些基礎的代碼庫。而兩者又是分開編譯的,如果編譯So時一個公用的函數已經更新,那么bin和so中的函數執行就可能出現問題。
實例:
橙色函數表示foo的新版本,期望的運行方式如圖:
但實際可能出現的運行方式有可能是一下兩種:
為了明白上面的問題,我們要搞清楚一下幾個問題:
- 為什么在bin文件中和so中函數原型相同的兩個函數有着不同的實現還能夠正常的鏈接和運行。
我們的bin文件和so在編譯時互相並不知道對方的存在。bin中通過dlopen打開so,除了在bin中調用的幾個有限的接口之外,編譯時不會知道so中其他的信息。因此在編譯時,兩者都可以編譯通過。
那么為什么通過dlopen打開之后,bin和so中明明有相同函數原型的函數,卻不會出現在編譯時經常可以看到的錯誤信息呢?
symbol "x" redefined: first defined in "./main.obj"; redefined in ***.c
- 是什么決定了bin或者so調用到哪一個函數。
根據分析,既然運行時有多個定義,那么bin或者so又是如何決定選取哪一個定義,我們又是否有辦法來控制bin和so的行為,讓他們按照我們的希望選擇相應的定義呢?
動態鏈接庫相關知識
Position Independent Code
目前我們編譯so時都會用到-fPIC選項,這表示生成的動態鏈接庫(SO)是地址無關代碼(Position Independent Code),那么地址有關無關到底有什么關系呢?
//libdep.c
int g = 1;
int foo(int a){
return g + a ;
}
由於動態鏈接庫無法知道自己將會被加載到內存的哪一個位置,因此也就無法在編譯時決定g的地址。對於非PIC的so,當libdep.so被不同的程序都用到的時候,g的地址也就不一樣,導致ptr的賦值代碼會不同。
如果so代碼在不同的程序都不同,因此這種方式沒有辦法做到同一份指令被多個進程所共享。
而地址無關代碼的so的內存結構如下所示:
地址無關的實現
PIC的實現基本想法是把指令中那些需要被修改的部分分離出來,跟數據放在一起,這樣指令部分就可以保持不變,而數據部分可以在各進程中擁有一個副本。
這個想法之所以能夠實現的前提是,在鏈接階段,鏈接器可以知道數據段和程序段的相對偏移。
還是這段代碼為例
//libdep.c
int g = 1;
int foo(int a){
return g + a ;
}
如果不是地址無關代碼,那么生成的程序可能會是這么描述獲取g值的過程。
將1234地址的內容放到ax寄存器。
而PIC生成的代碼會是這樣的
從數據段中用來實現PIC的數據結構中獲取g的地址,並存到bx寄存器
將bx中地址的內容放到ax
圖示如下:
數據段中用來實現PIC的數據結構有一個名字叫做全局偏移表(Global Offfset Table)。
GOT中包含哪些內容
我們可以把共享庫中對地址的引用分為四類
- 模塊內部的函數調用,跳轉等
- 模塊內部的數據訪問,包括模塊內定義的全局變量,靜態變量
- 模塊外部的函數調用,跳轉等
- 模塊外部的數據訪問,比如定義在其他模塊中定義的全局變量
static int a;
extern int b;
extern void ext();
void bar()
{
a = 1; // Type2, 模塊內部數據訪問
b = 2; // Type4, 模塊外部數據訪問
}
void foo()
{
bar(); // Type1, 模塊內部函數調用
ext(); // Type3, 模塊外部函數調用
}
具體的分析可以看程序員的自我修養7.3.3,7.3.4節。這里我只說一下結論:
PIC代碼中通過GOT訪問的地址引用包括:
- 類型1中沒有限制為static的函數
- 類型2中模塊內部的全局變量
- 類型3,4的地址引用
GOT中的內容何時確定
GOT中的內容無法在編譯時確定,當動態鏈接器將動態鏈接庫加載到內存之后,會進行符號解析和重定位工作。當動態鏈接器發現了某個需要在GOT中保存的符號后,會將這個符號對應的地址填到GOT中。
通過GOT訪問數據會增加一層間接的地址獲取步驟。但是也帶來了一定的好處。
- 在非PIC的代碼中,每對一個符號進行引用都會產生一處重定位的地方。而GOT訪問的方式使得重定位次數從每次引用一次變為每一個符號一次。
- 通過GOT可以使得代碼段成為地址無關的,從而可以在多個進程中共享代碼。
舉一個例子:
$ cat test.c
extern int foo;
int function(void) {
return foo;
}
$ gcc -shared -fPIC -o libtest.so test.c
$ objdump --disassemble libtest.so
[...]
00000000000005ac <function>:
5ac: 55 push %rbp
5ad: 48 89 e5 mov %rsp,%rbp
5b0: 48 8b 05 71 02 20 00 mov 0x200271(%rip),%rax # 200828 <_DYNAMIC+0x1a0>
5b7: 8b 00 mov (%rax),%eax
5b9: 5d pop %rbp
5ba: c3 retq
$ readelf --sections libtest.so
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[...]
[20] .got PROGBITS 0000000000200818 00000818
0000000000000020 0000000000000008 WA 0 0 8
$ readelf --relocs libtest.so
Relocation section '.rela.dyn' at offset 0x418 contains 5 entries:
Offset Info Type Sym. Value Sym. Name + Addend
[...]
000000200828 000400000006 R_X86_64_GLOB_DAT 0000000000000000 foo + 0
延遲綁定
從上面的描述可以看出,為了保證程序的正常運行,GOT中的信息需要在動態鏈接庫被程序加載后立刻填寫正確。這就給采用動態鏈接庫的程序在啟動時帶來了一定額外開銷,從而減緩了啟動速度。ELF采用了做延遲綁定的做法來解決這一問題。基本思想就是通過增加另外一個間接層,使得函數第一次被用到時才進行綁定,這就是PLT(Procedure Linkage Table)的作用。
通過PLT進行函數調用的過程如下圖所示:
- 當func被調用時,編譯器會生成相關代碼func@plt,表示跳轉到plt中表示func的那一項。
- 假設func在plt中為第n項,其內容如圖,這時會繼續跳轉到GOT[n]所指向的地址。
- 而在第一次調用時,GOT[n]內的地址會指回PLT[n]中,這里會做一些初始化的工作,然后跳轉到PLT[0],PLT[0]指向了動態鏈接器中解析符號的函數去,根據准備好的數據,解析func的地址並將其填寫到GOT[n]中
在第一次調用func之后,再對func進行函數調用時的流程如下:
這時GOT[n]中的地址已經是正確的函數地址,因此會直接跳轉到正確的地址去。
下面看一個例子:
$ cat test.c
int foo(void);
int function(void) {
return foo();
}
$ gcc -shared -fPIC -o libtest.so test.c
$ objdump --disassemble libtest.so
[...]
00000000000005bc <function>:
5bc: 55 push %rbp
5bd: 48 89 e5 mov %rsp,%rbp
5c0: e8 0b ff ff ff callq 4d0 <foo@plt>
5c5: 5d pop %rbp
$ objdump --disassemble-all libtest.so
00000000000004d0 <foo@plt>:
4d0: ff 25 82 03 20 00 jmpq *0x200382(%rip) # 200858 <_GLOBAL_OFFSET_TABLE_+0x18>
4d6: 68 00 00 00 00 pushq $0x0
4db: e9 e0 ff ff ff jmpq 4c0 <_init+0x18>
$ readelf --relocs libtest.so
Relocation section '.rela.plt' at offset 0x478 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000200858 000400000007 R_X86_64_JUMP_SLO 0000000000000000 foo + 0
動態符號表
前面解釋了PIC的動態鏈接庫的內部實現原理,那么動態鏈接庫如何被外部的bin或者其他的so調用呢?
想要引用so中的函數或者獲取so中的全局變量,那么bin或者其他的so就必須要知道so中有什么內容。無論是變量還是函數都可以看做是一個符號,符號有其對應的值,對於變量和函數來說,符號值是他們的地址。
ELF格式的so文件中會有一個段叫做動態符號表。動態符號表中包含了動態鏈接庫需要的導入函數(本so沒有定義的)和導出函數(本so定義可以給其他so或bin實用的)
// libso.c
int a;
extern int test();
void bar()
{
a = 1;
}
void foo()
{
test();
bar();
}
$ gcc -shared -fPIC -o libso.so libso.c
$ readelf --dyn-syms libso.so
Symbol table '.dynsym' contains 16 entries:
Num: Value Size Type Bind Vis Ndx Name
[...]
8: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND test
9: 0000000000000735 19 FUNC GLOBAL DEFAULT 11 bar
10: 0000000000000748 26 FUNC GLOBAL DEFAULT 11 foo
11: 0000000000201044 4 OBJECT GLOBAL DEFAULT 22 a
[...]
可以看到默認所有的非static符號都會被導出a,bar,foo,而引用的test作為導入符號也在動態符號表中,其Ndx為UND表示未定義。
程序運行后,動態鏈接器會按照寬度優先的順序加載其依賴的動態鏈接庫以及動態鏈接庫的依賴。如果存在多個相同的符號,那么最先被加載的符號將會干涉(interpose)后面的符號。也就是說,如果有多個相同的符號,那么最先加載的符號定義將會被采用,而后面的鏈接庫中對該符號的引用都會指向最早被加載的那一個符號。
可執行文件中的符號默認並沒有導出,因此不會參與到動態鏈接庫的符號解析過程中去,但是如果在編譯時添加了-rdynamic選項,會將bin文件中的符號導出,從而使得bin中的符號可以被so調用到。
下面看一個例子:
// main.c
#include <stdio.h>
#include <dlfcn.h>
typedef void (*func_ptr)();
short g = 1;
short x = 1;
int main()
{
printf("g in main:%d\n", g);
printf("x in main:%d\n",x );
void* handler = dlopen("./so",RTLD_NOW|RTLD_LOCAL);
func_ptr ptr = (func_ptr)dlsym(handler, "call_back");
(*ptr)();
printf("g in main:%d\n", g);
printf("x in main:%d\n", x);
return 0;
}
// libso.c
#include <stdio.h>
int g;
extern "C"{
void call_back()
{
printf("g in so:%d\n", g);}
g = 3;
}
$ g++ -shared -fPIC libso.c -o so -g
$ g++ -o main main.c -g -ldl
$ ./main
g in main:1
x in main:1
g int so:0
g in main:1
x in main:1
可以看到一開始編譯main的時候沒有加上-rdynamic,雖然main和so中都有一個符號叫做g,但是兩者互不影響。
下面采用新的方式重新編譯
$ g++ -shared -fPIC libso.c -o so -g
$ g++ -o main main.c -g -ldl -rdynamic
$ ./main
g in main:1
x in main:1
g int so:65537
g in main:3
x in main:0
這時候我們發現,so中g的初始值變成了65537也就是0x00010001,也就是short g short x的內存布局,bin中的符號g現在和so中的符號為同一個,且因為so中的類型為int,在編譯時決定了so中對g的賦值時按照4個字節的,因此在so中對g賦值時會覆蓋掉bin中x的內容。
符號查找順序
當存在多個相同符號時,先被加載的符號會覆蓋掉后面的符號,那么不同的加載順序就會影響符號的解析內容,從而改變程序的行為。
默認的符號查找范圍(lookup scope)是全局查找范圍(global lookup scope),一開始包括bin中的符號,之后動態鏈接器會按照寬度優先的順序遍歷可執行文件所依賴的動態鏈接庫以及動態鏈接庫所依賴的庫文件,並將其中的符號添加到全局查找范圍中。
當動態鏈接庫通過dlopen打開時so時,so以及其依賴的庫文件形成另外一個局部查找范圍(local lookup scope)。可以通過RTLD_GLOBAL改變這一行為,使其添加到全局查找范圍中。
默認情況下,符號的查找先從全局查找范圍開始,然后再查找局部查找范圍。
有以下幾個例外:
- 當時用dlsym查找so中的符號時則是從局部查找范圍開始
- 當dlopen中有RTLD_DEEPBIND時so中的符號從局部范圍開始查找
- 當編譯so時添加了-Wl,-Bsymbolic參數,使得so具有DF_DYNAMIC flag時,在該so進行符號查找是,依然會按照先全局在局部的順序查找,但是該so自己會被添加到全局符號中的第一個。
下面是一個例子:
app依賴於一個libone.so libdl.so libc.so, 並且通過dlopen打開了libdynamic.so,libdynamic.so又依賴於libtwo.so。現在的符號查找范圍如圖所示:
Global: app-->libone.so-->libdl.so-->libc.so
Local: libdynamic.so-->libtwo.so-->libc.so
如果libone.so 和libtwo.so中都定義了一個變量g,app和libone.so中都有對g的引用。
按照默認的順序,先全局再局部,這時libdynamic.so中引用的變量將是libone.so中的。
如果dlopen時添加了RTLD_DEEPBIND則先從局部范圍開始查找因此在libdnamic.so中查找到的g是libtwo.so中的,而app中g則是定義在libone.so中的。
假如libdynamic.so具有DT_DYNAMIC,那么這是的查找libdynamic.so中符號的順序會變成如下所示:
libdynamic.so-->app-->libone.so-->libdl.so-->libc.so--> libtwo.so-->libc.so
libdynamic.so和app中所引用的g都會是libone.so中定義的。
遺留問題解答
- 為什么在bin文件中和so中函數原型相同的兩個函數有着不同的實現還能夠正常的鏈接和運行。
- 是什么決定了bin或者so調用到哪一個函數。
在存在多個相同符號的時候,動態鏈接器會選擇最先加載的哪一個符號,PIC程序由於采用GOT的方式訪問數據和函數因此可以在運行時決定對應符號加載的地址,從而實現全局符號介入(Global Symbol Interposition)。因此先被加載的符號會被采用,執行順序會如下圖所示:
如何防止出現這樣的問題
數據結構定義不一致
在查閱一番資料后,對於數據結構定義不一致的問題,沒有發現可以解決的辦法,畢竟數據結構的定義不同直接導致內存布局不同。程序按照原來的內存結構去操作數據肯定會出現問題。
在bin和so公用的關鍵數據結構定義發生變化時,感覺比較好的做法就是禁止程序啟動,要是像這一次導致程序無法啟動還好,如果正常啟動但是還有一些隱藏的問題直接會導致運行出現莫名奇怪的問題,甚至寫錯數據。
函數定義不一致
通過-Wl,-Bsymbolic的編譯選項可以使函數調用到希望的函數,但是也會有一些問題。因為在bin和so之間會通過全局變量進行通信,Bsymbolic使得無法這樣做,因為so會采用自己的全局變量內容,和bin中的全局變量內容不同。為了解決這一個問題我們可以使用編譯選項(-Wl,-Bsymbolic-functions),這樣只對函數的查找改變順序,但是如果有函數指針的在bin和so中傳遞的情況出現,可能出現同一個函數的指針不同的情況。Bsymbolic引起的問題主要原因是采用這一個符號后會使得so和bin中本應該是只有一份的內容產生兩個副本。是否使用這一標記需要待定。
RTLD_DEEPBIND標記和Bsymbolic的功能有一相似,在這里也可以解決我們的問題,但是RTLD_DEEPBIND同Bsymbolic一樣,同樣會有一些問題。
示例如下:
// main.c
#include <stdio.h>
#include <dlfcn.h>
void foo(int a){}
typedef void(*foo_ptr)(int);
typedef void (*func_ptr)(foo_ptr);
int main()
{
void* handler = dlopen("./so",RTLD_NOW|RTLD_LOCAL);
func_ptr ptr = (func_ptr)dlsym(handler, "call_back");
(*ptr)(&foo);
return 0;
}
// libso.c
#include <stdio.h>
int g = 1;
void foo(int a){}
typedef void(*foo_ptr)(int);
extern "C"{
void call_back(foo_ptr p)
{
printf("foo in main:%p\n", p);
printf("foo in so:%p\n", &foo);
if(p == &foo){
printf("ptr is equal \n");
}else{
printf("ptr not equal\n");
}
}
}
// Normal
$ g++ -shared -fPIC libso.c -o so -g
$ g++ -o main main.c -g -ldl -rdynamic
$ ./main
foo in main:0x4008bd
foo in so:0x4008bd
ptr is equal
// Bsymbolic
$ g++ -shared -fPIC libso.c -o so -g -Wl,-Bsymbolic
$ g++ -o main main.c -g -ldl -rdynamic
$ ./main
foo in main:0x4008bd
foo in so:0x7f1e57910755
ptr not equal
// dlopen添加 RTLD_DEEPBIND
$ g++ -shared -fPIC libso.c -o so -g
$ ++ -o main main.c -g -ldl -rdynamic
$ ./main
foo in main:0x4008bd
foo in so:0x7f0a2329a785
ptr not equal
可以看到,本來應該是相等的函數指針,在采用了Bsymbolic和RTLD_DEEPBIND之后都變得不相等了。
gcc還提供了可見性控制,gcc默認將符號全部都導出可見,如果我們僅導出需要使用的函數就可以防止so中的函數被bin中的定義所覆蓋。在編譯時我們可以通過添加-fvisibility=[default,hidden,internal,protected]來控制導出符號默認值,同時在程序中添加__attribute__((visibility ("hidden/default")));
來改變默認的函數或者變量的可見性。但這就需要對所有需要導出的函數去修改代碼,添加控制相關的內容。尤其是GameSvr中判斷是否支持某個新的函數時用的是dlsym是否存在來判斷,如果游戲so代碼沒有即使正確的控制可見性,會導致即使游戲so中已經有了新的函數,但是dlsym依舊會查找不到的情況出現。
gcc還提供了version script可以定義符號的版本以及控制是否導出等,但是和可見性控制一樣,會增加維護的成本,當維護不當時容易出現一些難以發現的問題。
目前我們在so和bin中都有版本控制,當檢測到版本不匹配時會彈出提示。但是這種提示還不夠明確,大部分情況下並不清楚版本不匹配到底會帶來什么問題。我們可以引入主版本號次版本號的概念,當此版本號不同時彈出提示。當修改了so和bin中的關鍵數據后,修改主版本號,強制bin在加載so后啟動失敗。
遺留問題
目前我們使用PIC代碼編譯的so在dlopen時都會采用RTLD_NOW來加載so,不會用到PLT所提供的lazy binding功能。而為此,我們每次函數訪問都會付出一次從plt轉到got讀取地址的過程,必然就帶來了性能損失。
在新版本的gcc新版本(may be 6.0)為此添加了編譯選項(-fno-plt)可以不通過plt來訪問函數。
再進一步,如果我們根本不關心so是否為PIC的來節約內存,我們可以不使用PIC代碼編譯so,直接采用裝載時重定位的方式加載so,將通過got訪問的間接層也去掉。不過64位環境中默認so必須是PIC的,需要通過添加 -mcmodel=large選項來進行編譯。
相關閱讀:
How to write shared library
What exactly does -Bsymblic do?
What exactly does -Bsymblic do? -- update
Load-time relocation of shared libraries
Position Independent Code (PIC) in shared libraries
Position Independent Code (PIC) in shared libraries on x64
PLT and GOT - the key to code sharing and dynamic libraries
Redirecting functions in shared ELF libraries
Bsymbolic與plt
當使用Bsymbolic編譯so后我們會發現原來通過plt訪問的函數調用都變成了直接通過相對地址訪問。這是因為linker在發現了Bsymbolic標記后知道so中的符號都不會被外部所調用,因此會將原本通過plt/got調用的函數改為直接通過相對地址調用。
Bsymbolic與Visibility
Bsymbolic會使得so內部的函數不會被外部的函數所干涉,但是so中的函數依舊會導出,給其他的模塊使用。
而設置visibility hidden之后,內部函數不僅不會被外部函數干涉,而且也無法被其他的模塊使用。