本文將深入探討Linux系統中的動態鏈接庫機制,這其中包括但不限于全局符號介入、延遲綁定以及地址無關代碼等內容。
引言
在軟件開發過程中,動態庫鏈接問題時常出現,這可能導致符號沖突,從而引起程序運行異常或崩潰。為深入理解動態鏈接機制及其工作原理,我重溫了《程序員的自我修養》,并通過實踐演示與反匯編分析,了解了動態鏈接的過程。
本文將深入探討Linux系統中的動態鏈接庫機制,這其中包括但不限于全局符號介入(Global Symbol Interposition)、延遲綁定(Lazy Binding)以及地址無關代碼(Position-Independent Code, PIC)等內容。通過對上述概念和技術細節的討論,希望能夠提供一個更加清晰的認知框架,從而揭示符號沖突背后隱藏的本質原因。這樣一來,在實際軟件開發過程中遇到類似問題時,開發者們便能更加游刃有余地采取措施進行預防或解決,確保程序穩定運行的同時提升整體質量與用戶體驗。
為便于讀者查閱,本文中提及的一些基本概念,例如ELF、PIC、GOT、PLT、常用的section等,被歸納整理于附錄部分。
一、先舉個
我們將通過一個簡單的 C 語言程序,逐步探討動態鏈接庫在模塊內部及模塊間的運行機制,其中涉及變量和函數之間的交互過程。同時,我們將使用 -fPIC 選項,以確保生成位置無關代碼。
#include// 靜態變量 a 僅在本模塊中可見 static int a; // 用 extern 聲明外部全局變量 b extern int b; // 在本模塊訪問的全局變量 c int c = 3; // 聲明外部函數 ext() extern void ext(); // 靜態函數 inner() 的作用域僅限于本模塊 static void inner() {} // bar() 函數修改靜態變量 a 和外部全局變量 b void bar() { a = 1; // 修改靜態變量 a 的值 b = 2; // 修改外部全局變量 b 的值 c = 4; // 修改模塊內的全局變量 c 的值 } // foo() 函數內調用了 inner、bar 和 ext,并打印變量值 void foo() { inner(); // 調用靜態函數 inner() bar(); // 調用函數 bar() ext(); // 調用外部函數 ext() printf("a = %d, b = %d, c = %d ", a, b, c); // 輸出變量的值 }
// 定義外部全局變量 b int b = 1; // 外部函數 ext() 修改外部全局變量 b 的值 void ext() { b = 3; // 修改外部全局變量 b 的值 } // main.c int main() { foo(); // 調用 foo() 函數,演示模塊間交互 return 0; // 程序正常結束 }
gcc -shared -fPIC -o libpic.so pic.c -g
gcc -o main main.c -L. -lpic在此代碼示例中,使用 -fPIC 編譯選項可以生成位置無關的代碼,適用于創建共享庫。代碼中包含了多個場景:
模塊內函數調用:foo 函數中調用了 inner 和 bar 函數。由于 inner 是靜態函數,其作用域僅限于本模塊。bar 函數操作了模塊內的靜態變量 a 和全局變量 c。
模塊間函數調用:foo 函數調用了外部函數 ext,這是一個在其他模塊中定義的函數。ext 負責修改外部全局變量 b。
不同類型的變量:
靜態變量 a 僅在本模塊可見,其值不會在程序的其他模塊中改變,也不會因函數調用而丟失。
外部全局變量 b 可以在多個模塊間共享,其值在整個程序中是唯一且可改變的。
模塊內的全局變量 c 僅能在當前模塊訪問和修改。
我們都知道動態鏈接庫需要能夠在多個進程之間共享同一段代碼。為了實現這一點,代碼必須是位置無關的,從而可以在加載時按需被鏈接到不同的地址,編譯時添加編譯選項-fPIC 可以生成地址無關代碼,那這些函數和變量運行時,如何做到呢?接下來將逐步分析動態鏈接的過程。
二、從例子來深入動態鏈接庫
2.1 模塊內函數調用
例子中 foo 函數實現中有兩個函數調用:靜態函數 inner()和非靜態函數 bar(),反匯編后結果。
Disassembly of section .plt: 0000000000000670: 670: ff 35 92 09 20 00 push QWORD PTR [rip+0x200992] # 201008 <_GLOBAL_OFFSET_TABLE_+0x8> 676: ff 25 94 09 20 00 jmp QWORD PTR [rip+0x200994] # 201010 <_GLOBAL_OFFSET_TABLE_+0x10> 67c: 0f 1f 40 00 nop DWORD PTR [rax+0x0] 0000000000000680 : 680: ff 25 92 09 20 00 jmp QWORD PTR [rip+0x200992] # 201018 <_GLOBAL_OFFSET_TABLE_+0x18> 686: 68 00 00 00 00 push 0x0 68b: e9 e0 ff ff ff jmp 670 <_init+0x20> ... 00000000000007e8 : foo(): 00000000000007e2 : inner(): /mnt/share/demo1/pic.c:12 static void inner() {} 7e2: 55 push rbp 7e3: 48 89 e5 mov rbp,rsp 7e6: 5d pop rbp 7e7: c3 ret ... /mnt/share/demo1/pic.c:15 inner(); 7ec: b8 00 00 00 00 mov eax,0x0 7f1: e8 ec ff ff ff call 7e2 /mnt/share/demo1/pic.c:16 bar(); 7f6: b8 00 00 00 00 mov eax,0x0 7fb: e8 80 fe ff ff call 680
2.1.1 靜態函數調用:inner()函數調用
和靜態編譯重定位相似,這里更簡單,具體如下:
7f1: e8 ec ff ff ff call 7e2
e8:相對偏移調用指令
ec ff ff ff:小端 0XFFFFFFEC 是-20 的補碼,該數值為目的地址相對于當前指令下一條指令的偏移。即 inner 地址為 0x7f6(下一條指令偏移) - 0x14 = 0x7e2
結論:靜態函數調用很簡單,通過相對地址偏移就可以跳轉。
2.1.2 全局函數調用:bar()函數調用
首次調用
7fb: e8 80 fe ff ff call 680
解析規則同上,不展開,但是跳轉的地址為 0x680
第一條指令為jmp QWORD PTR [rip+0x200992],這是一個間接跳轉(jmp)指令,運行跳轉地址 0x201018,該地址是什么?
objdump -s libpic.so Contents of section .got: 200fc8 00000000 00000000 00000000 00000000 ................ 200fd8 00000000 00000000 00000000 00000000 ................ 200fe8 00000000 00000000 00000000 00000000 ................ 200ff8 00000000 00000000 ........ Contents of section .got.plt: 201000 080e2000 00000000 00000000 00000000 .. ............. 201010 00000000 00000000 86060000 00000000 ................ 201020 96060000 00000000 a6060000 00000000 ................ 201030 b6060000 00000000 c6060000 00000000 ................
發現這個地址在.got.plt section,0x00000686, 該地址存的地址為
0000000000000680那上面一系列地址跳轉是在干什么?用一個示意圖表示 bar 首次地址重定位過程(橙色是調用入口,藍色是運行的指令,紫色代表修正的地址)。: 680: ff 25 92 09 20 00 jmp QWORD PTR [rip+0x200992] # 201018 <_GLOBAL_OFFSET_TABLE_+0x18> 686: 68 00 00 00 00 push 0x0 68b: e9 e0 ff ff ff jmp 670 <_init+0x20>
_dl_runtime_resolve()函數實現不展開,該函數的入參為入棧的符號索引 index 和庫 ID,解析過程會依賴.dynamic、.rela.plt 等 section 信息,解析后重定向地址后填入地址0x201018 。可以查看下.rela.plt 段內容有什么。
[root@docker-desktop demo1]# readelf -r libpic.so Relocation section '.rela.dyn' at offset 0x4e8 contains 10 entries: Offset Info Type Sym. Value Sym. Name + Addend 000000200de8 000000000008 R_X86_64_RELATIVE 780 000000200df0 000000000008 R_X86_64_RELATIVE 740 000000200e00 000000000008 R_X86_64_RELATIVE 200e00 000000200fc8 000200000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0 000000200fd0 000300000006 R_X86_64_GLOB_DAT 0000000000000000 b + 0 000000200fd8 000500000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0 000000200fe0 000e00000006 R_X86_64_GLOB_DAT 0000000000201040 c + 0 000000200fe8 000700000006 R_X86_64_GLOB_DAT 0000000000000000 _Jv_RegisterClasses + 0 000000200ff0 000800000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0 000000200ff8 000900000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize + 0 Relocation section '.rela.plt' at offset 0x5d8 contains 5 entries: Offset Info Type Sym. Value Sym. Name + Addend 000000201018 000b00000007 R_X86_64_JUMP_SLO 00000000000007b8 bar + 0 000000201020 000400000007 R_X86_64_JUMP_SLO 0000000000000000 printf + 0 000000201028 000500000007 R_X86_64_JUMP_SLO 0000000000000000 __gmon_start__ + 0 000000201030 000600000007 R_X86_64_JUMP_SLO 0000000000000000 ext + 0 000000201038 000900000007 R_X86_64_JUMP_SLO 0000000000000000 __cxa_finalize + 0.rela.plt是 ELF 文件中包含了函數跳轉槽重定位信息。具體代表含義:
Offset - 表示在內存中的偏移地址,即在 GOT 中重定位項的地址。
Info - 包含兩個部分:符號的索引和重定位類型。在這種情況下,重定位類型是 R_X86_64_JUMP_SLOT,用于處理函數調用的跳轉。
Type - 描述了重定位的類型,這里是 R_X86_64_JUMP_SLOT,用于通過懶加載解析符號的PLT入口。其他類型還有很多,常見的還有
R_X86_64_GLOB_DAT - 設置全局偏移表的內容。
R_X86_64_64 - 64位直接重定位;修改64位的值。
R_X86_64_PC32 - 32位PC相對重定位;修改指令內偏移的32位值。
R_X86_64_GOT32 - 32位的全局偏移表(GOT)入口。
R_X86_64_PLT32 - 用于函數調用的32位PLT重定位。
R_X86_64_GLOB_DAT - 設置全局偏移表的內容。
R_X86_64_RELATIVE - 需要基地址重置,用于模塊加載專用的相對地址調整。
R_X86_64_GOTPCREL - 訪問GOT的PC相對重定位。
Sym. Value - 是符號在它本身定義模塊內的值。在重定位發生之前,符號可能還沒有最終的運行時地址。對于本地符號(比如 bar 函數),這里通常是它們在當前模塊中的偏移地址。對于外部符號(比如 printf),在重定位前這里通常是 0,表示地址還未確定。
Sym. Name + Addend - 顯示了符號的名稱以及添加量。添加量在這里是 0,因為我們正在查看 .rela 格式的重定位項,添加量已經包含在每個重定位項中。
在運行時,動態鏈接器會依據這些重定位項進行地址解析工作。例如,當程序第一次調用 printf 時,控制流首先跳轉到 printf 在 PLT 中的對應項,PLT 中會有一段存根代碼觸發動態鏈接器,動態鏈接器解析出 printf 的真實地址并更新 GOT 中對應的地址。
第二次調用
運行后地址重定位后,第二次調用就會簡單很多,如下圖所示:
使用 GDB 調試運行后,單步調試地址重定向.got.plt 段內容(基地址為:0x7F7A97F75000)。
201000 080e2000 00000000 00000000 00000000 .. .............
(gdb) x/16a 0x7f7a98176000 0x7f7a98176000: 0x200e08 0x7f7a983976a8 0x7f7a98176010: 0x7f7a9818d890 <_dl_runtime_resolve_xsave> 0x7f7a97f756860x7f7a98176020: 0x7f7a97f75696 0x7f7a97f756a6 <__gmon_start__@plt+6> 0x7f7a98176030: 0x7f7a97f756b6 0x7f7a97f756c6 <__cxa_finalize@plt+6> 0x7f7a98176040 : 0x3 0x0 0x7f7a98176050: 0x31303220352e382e 0x5228203332363035 0x7f7a98176060: 0x3420746148206465 0x2936332d352e382e 0x7f7a98176070: 0x20000002c00 0x8000000
.got.plt 中 bar 地址 = 0x201018 +0x7F7A97F75000(基地址)= 0x7F7A98176018,0x7F7A98176018 內容為0x7f7a97f75686
(gdb) x/16a 0x7f7a98176000 0x7f7a98176000: 0x200e08 0x7f7a983976a8 0x7f7a98176010: 0x7f7a9818d890 <_dl_runtime_resolve_xsave> 0x7f7a97f757b80x7f7a97f757b8 為代碼段,0x7f7a97f757b8 - 0x7F7A97F75000(基地址)=0x7B8,該偏移在.text 的 bar 入口地址,也對應起來了。0x7f7a98176020: 0x7f7a97f75696 0x7f7a97f756a6 <__gmon_start__@plt+6> 0x7f7a98176030: 0x7f7a97f756b6 0x7f7a97f756c6 <__cxa_finalize@plt+6> 0x7f7a98176040 : 0x3 0x0 0x7f7a98176050: 0x31303220352e382e 0x5228203332363035 0x7f7a98176060: 0x3420746148206465 0x2936332d352e382e 0x7f7a98176070: 0x20000002c00 0x8000000
抽象一下,如下示意圖:
通過上圖指令跳轉得出,.plt,利用.got.plt 可寫權限,在程序運行時,修正.got.plt 對應函數指向的.text (不可寫)地址,從而實現了地址無關代碼。
該過程還隱藏了一個知識點,延遲綁定(lazy binding)。動態鏈接器在運行時完成,若已一開始執行,要加載完所有的符號的話,想必會減慢程序的啟動速度,影響性能。所以當函數第一次被用到時再進行綁定,如果沒有用就不綁定,這樣可以大大加快程序啟動速度。本例子中的 bar 也是在調用時才進行重定向,不調用不進行地址重定向綁定,即實現了延遲綁定效果。
是不是外部函數重定向一定在 .rela.plt?
不是,如果是PIC 編譯,會在.rela.plt;如果不是PIC 編譯,會在.rela.dyn 出現。
原因:開啟 PIC 調用指令會指向 PLT 中的一個條目,需要.rela.plt section 配合實現 Lazy Binding,.rela.dyn 段用于動態鏈接器在加載時將符號綁定到其運行時地址的重定位條目。它包含了不特定于PLT條目的其他動態重定位信息,.rela.plt 主要針對PLT進行重定位,用于動態鏈接時解析函數地址,實現惰性綁定,而 .rela.dyn 用于更廣泛的動態重定位需求。
疑問?
問題一:模塊內全局函數調用和模塊間全局函數調用有什么區別?
問題二:為什么都是函數調用,靜態函數和全局函數調用跳轉差別這么大?
這兩個問題先不著急回答,我們接著看模塊間函數調用。
2.2 模塊間函數調用
例子中是 foo() 對 ext()函數的調用,查看匯編,發現和模塊內函數調用方式一模一樣。匯編指令如下:
/mnt/share/demo1/pic.c:17 ext(); 800: b8 00 00 00 00 mov eax,0x0 805: e8 a6 fe ff ff call 6b0那現在回答上一節的第一個問題,模塊內和模塊間全局函數調用沒有區別,為什么呢?
先回憶下加載過程,動態鏈接器完成自舉后,會將可執行文件和鏈接器本身的符號表都合并到一個符號表中,該符號表叫做全局符號表(Global Symbol Table)。當一個符號需要被加入全局符號表時,如果相同的符號已經存在,則后加入的符號被忽略,這種規則叫做全局符號介入。
由于全局符號介入規則,若上一節的模塊內部函數調用 bar() 直接采用相對地址調用話,可能會被其他模塊的同名函數符號覆蓋,那相對地址就是無法準確找到正確的函數地址,故模塊內和模塊外的函數調用,都需要通過.got.plt 重定位方法間接調用。
那上一節第二個問題答案也顯而易見,靜態函數不涉及全局符號介入問題,可以通過模塊內部相對地址跳轉就可以。這樣調用的尋址速度也比全局函數的尋址速度快。
為了更深入理解全局符號介入,我們再舉個例子。
/* a1.c*/ #includevoid a() { printf("a1.c "); } /* a2.c */ #include void a() { printf("a2.c "); } /* b1.c */ void a(); void b1() { a(); } /* b2.c */ void a(); void b2() { a(); } /* main.c */ #include void b1(); void b2(); int main() { b1(); b2(); return 0; }
[root@docker-desktop priority]# g++ -fPIC -shared a1.c -o a1.so [root@docker-desktop priority]# g++ -fPIC -shared a2.c -o a2.so [root@docker-desktop priority]# g++ -fPIC -shared b1.c a1.so -o b1.so [root@docker-desktop priority]# g++ -fPIC -shared b2.c a2.so -o b2.so [root@docker-desktop priority]# ldd b1.so a1.so (0x0000004001c2a000) libstdc++.so.6 => /usr/local/gcc-5.4.0/lib64/libstdc++.so.6 (0x0000004001e2c000) libm.so.6 => /lib64/libm.so.6 (0x00000040021ad000) libgcc_s.so.1 => /usr/local/gcc-5.4.0/lib64/libgcc_s.so.1 (0x00000040024b0000) libc.so.6 => /lib64/libc.so.6 (0x00000040026c7000) /lib64/ld-linux-x86-64.so.2 (0x0000004000000000) [root@docker-desktop priority]# ldd b2.so a2.so (0x0000004001c2a000) libstdc++.so.6 => /usr/local/gcc-5.4.0/lib64/libstdc++.so.6 (0x0000004001e2c000) libm.so.6 => /lib64/libm.so.6 (0x00000040021ad000) libgcc_s.so.1 => /usr/local/gcc-5.4.0/lib64/libgcc_s.so.1 (0x00000040024b0000) libc.so.6 => /lib64/libc.so.6 (0x00000040026c7000) /lib64/ld-linux-x86-64.so.2 (0x0000004000000000) [root@docker-desktop priority]# g++ main.c b1.so b2.so -o main [root@docker-desktop priority]# ./main a1.c a1.c在上述例子中,雖然 b1.so 和 b2.so 中都調用了 a() 函數,但由于 main 程序首先鏈接了 b1.so,導致 a() 的實現使用了 a1.so 中的定義。因此,無論 b2.so 如何變化,main 程序中調用的都始終是 a1.so 的實現。這種現象強調了在動態鏈接庫中符號的解析順序及如何影響最終的執行結果,開發者在設計接口時需謹慎考慮符號的命名和庫的加載順序,以避免潛在的符號沖突和不確定性。
2.3 模塊內變量 和模塊間變量
例子中的靜態變量 a 、外部全局變量 b、 內部全局變量 c,看下反匯編后結果:
void bar() { 7b8: 55 push rbp 7b9: 48 89 e5 mov rbp,rsp /mnt/share/demo1/pic.c:7 a = 1; 7bc: c7 05 82 08 20 00 01 mov DWORD PTR [rip+0x200882],0x1 # 201048 <__TMC_END__> 7c3: 00 00 00 /mnt/share/demo1/pic.c:8 b = 2; 7c6: 48 8b 05 03 08 20 00 mov rax,QWORD PTR [rip+0x200803] # 200fd0 <_DYNAMIC+0x1c8> 7cd: c7 00 02 00 00 00 mov DWORD PTR [rax],0x2 /mnt/share/demo1/pic.c:9 c = 4; 7d3: 48 8b 05 06 08 20 00 mov rax,QWORD PTR [rip+0x200806] # 200fe0 <_DYNAMIC+0x1d8> 7da: c7 00 04 00 00 00 mov DWORD PTR [rax],0x4 /mnt/share/demo1/pic.c:10 }
Idx Name Size VMA LMA File off Algn CONTENTS, ALLOC, LOAD, DATA 20 .got 00000038 0000000000200fc8 0000000000200fc8 00000fc8 2**3 CONTENTS, ALLOC, LOAD, DATA 21 .got.plt 00000040 0000000000201000 0000000000201000 00001000 2**3 CONTENTS, ALLOC, LOAD, DATA 22 .data 00000004 0000000000201040 0000000000201040 00001040 2**2 CONTENTS, ALLOC, LOAD, DATA 23 .bss 0000000c 0000000000201044 0000000000201044 00001044 2**2 ALLOCstatic int a; # 201048 <__TMC_END__> ==> .bss
extern int b; # 200fd0 <_DYNAMIC+0x1c8> ==> .got
int c; # 200fe0 <_DYNAMIC+0x1d8> ==> .got
結合上面了解的函數調用,變量調用跳轉類似,static 變量的訪問直接通過偏移量完成,這種方式更高效,因為 static 變量的作用域限制在同一個編譯單元,所以它們的地址可以在編譯時確定(相對于 rip)。而非 static 變量(包括定義在當前模塊的全局變量和 extern 變量)可能被其他模塊引用或修改,其地址需要在運行時通過動態鏈接器解析,對于全局和 extern 變量,共享庫使用基于 rip 的尋址加上 運行時重定位.got 段中地址,以確保位置無關。
全局變量的地址不存在延遲綁定,因為通常會在加載時解析,并通過全局偏移表(Global Offset Table, GOT)來訪問,而不是延遲到首次使用時。因此,把它們的地址解析延遲將不會帶來明顯的優勢,而且會在運行時增加額外的性能負擔。
三、地址無關延伸
3.1 隱藏符號影響
如果把 bar 和變量 c 使用__attribute__((visibility("hidden")))隱藏的符號,那函數調用跳轉會有什么變化?
#includestatic int a; extern int b; __attribute__((visibility("hidden"))) int c = 3; extern void ext(); void bar() __attribute__((visibility("hidden"))); void bar() { a = 1; b = 2; c = 4; } static void inner() {} void foo() { inner(); bar(); ext(); printf("a = %d, b = %d, c = %d ", a, b, c); }
反匯編后結果
[root@docker-desktop demo1]# objdump -d -M intel -S -l libpic_hidden.so Disassembly of section .text: ... 0000000000000738: bar(): /mnt/share/demo1/pic_hidden.c:7 static int a; extern int b; __attribute__((visibility("hidden"))) int c = 3; extern void ext(); void bar() __attribute__((visibility("hidden"))); void bar() { 738: 55 push rbp 739: 48 89 e5 mov rbp,rsp /mnt/share/demo1/pic_hidden.c:8 a = 1; 73c: c7 05 fa 08 20 00 01 mov DWORD PTR [rip+0x2008fa],0x1 # 201040 <__TMC_END__> 743: 00 00 00 /mnt/share/demo1/pic_hidden.c:9 b = 2; 746: 48 8b 05 8b 08 20 00 mov rax,QWORD PTR [rip+0x20088b] # 200fd8 <_DYNAMIC+0x1c8> 74d: c7 00 02 00 00 00 mov DWORD PTR [rax],0x2 /mnt/share/demo1/pic_hidden.c:10 c = 4; 753: c7 05 db 08 20 00 04 mov DWORD PTR [rip+0x2008db],0x4 # 201038 75a: 00 00 00 ... /mnt/share/demo1/pic_hidden.c:17 bar(); 773: b8 00 00 00 00 mov eax,0x0 778: e8 bb ff ff ff call 738
[root@docker-desktop demo1]# readelf -S libpic_hidden.so There are 34 section headers, starting at offset 0x1470: Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align ...... [23] .data PROGBITS 0000000000201038 00001038 0000000000000004 0000000000000000 WA 0 0 4
bar: 反匯編后看到調用 bar 直接可以通過相對地址跳轉,不需要運行重定位。
int c; # 201038
查看.rela.plt section
[root@docker-desktop demo1]# readelf -r libpic_hidden.so Relocation section '.rela.dyn' at offset 0x4a8 contains 9 entries: Offset Info Type Sym. Value Sym. Name + Addend 000000200df0 000000000008 R_X86_64_RELATIVE 700 000000200df8 000000000008 R_X86_64_RELATIVE 6c0 000000200e08 000000000008 R_X86_64_RELATIVE 200e08 000000200fd0 000200000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0 000000200fd8 000300000006 R_X86_64_GLOB_DAT 0000000000000000 b + 0 000000200fe0 000500000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0 000000200fe8 000700000006 R_X86_64_GLOB_DAT 0000000000000000 _Jv_RegisterClasses + 0 000000200ff0 000800000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0 000000200ff8 000900000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize + 0 Relocation section '.rela.plt' at offset 0x580 contains 4 entries: Offset Info Type Sym. Value Sym. Name + Addend 000000201018 000400000007 R_X86_64_JUMP_SLO 0000000000000000 printf + 0 000000201020 000500000007 R_X86_64_JUMP_SLO 0000000000000000 __gmon_start__ + 0 000000201028 000600000007 R_X86_64_JUMP_SLO 0000000000000000 ext + 0 000000201030 000900000007 R_X86_64_JUMP_SLO 0000000000000000 __cxa_finalize + 0.rela.plt 中已經沒有 bar(),.rela.dyn中沒有變量 c ,所以隱藏后,bar() 不需要重定位,變量 c也不需要間接跳轉。隱藏的符號 bar() 和 c 也不會出現在動態鏈接庫的動態符號表(.dynsym)中,因此它們在鏈接時不可見于其他共享對象或者可執行文件,所以隱藏符號不存在全局符號介入的場景。
3.2 關于 PIC 回答幾個小問題
如何區分一個 DSO 是否為 PIC
readelf -d xxx.so | grep TEXTREL
如果沒有輸出,則動態庫是使用 PIC 生成的。文本重定位(TEXTREL)意味著代碼部分(.text section)需要修改以引用正確的地址,在非PIC的代碼中,會存在基于絕對地址的引用,這就需要在加載時進行修改,從而使得代碼能夠正確運行,這個過程就是文本重定位。
2. 如何區分一個靜態庫是否為 PIC
ar -t xxx.a readelf -r xxx.o你需要檢查輸出中是否有基于絕對地址的重定位類型比如 R_X86_64_GOTPCREL 或其他類似的不是專為 PIC 代碼的重定位類型。
3. 假設靜態編譯庫編譯不使用-fPIC,動態庫編譯使用-fPIC,是否 ok?
不行。實測靜態庫 a.a 不使用-fPIC,動態庫 b.so 使用-fPIC,可執行程序 main 鏈接兩個庫會編譯失敗。報錯日志如下:
g++ -c nopic_common.c -o nopic_common.o ar rcs libnopic_common.a nopic_common.o g++ -shared -o libnopic.so pic.c -L. -lnopic_common -fPIC /usr/bin/ld: ./libnopic_common.a(nopic_common.o): relocation R_X86_64_PC32 against symbol `b' can not be used when making a shared object; recompile with -fPIC /usr/bin/ld: final link failed: Bad value collect2: error: ld returned 1 exit statusnopic_common.o 對象文件是沒有使用 -fPIC 編譯的,因此包含以 PC 相對的方式(R_X86_64_PC32 relocation type)引用全局變量 b。這種類型的重定位不兼容于動態庫的創建,因為它要求代碼必須在特定地址執行,而動態庫加載的地址在運行時是未知的,甚至每次運行都可能不同。即靜態庫的代碼假定某些數據或函數存在于固定地址,而該地址已經被其他代碼或庫占用,則可能會導致鏈接錯誤或運行時錯誤。
要修復這個錯誤,你需要重新編譯 nopic_common.o,將其中的代碼編譯為位置無關代碼(PIC)。
4. 為什么動態庫編譯時不默認采用PIC:
歷史原因:歷史慣性,較早的編譯器版本中沒有將生成PIC作為默認選項。
選項傳遞的問題:-fPIC是編譯器的選項,是在源代碼編譯階段決定的,而-shared是鏈接器的選項, 是在不同階段,所以無法通過-shared自動啟用-fPIC。
性能:雖然PIC對于共享庫的高效運行是很重要的,但在某些情況下PIC代碼也可能稍微慢于非PIC代碼,因為它需要使用間接地址引用全局變量和函數。這種性能影響一般是很小的,但在對性能要求非常高的應用程序中,這可能是一個因素。
編譯器和構建系統設計:編譯器和構建系統往往允許開發者根據項目需求選擇是否生成PIC。允許靈活配置使開發者能夠根據具體的使用場景和需求,選擇最合適的編譯選項。
3.3 動態和靜態鏈接的重定向區別
靜態鏈接 | 動態鏈接 | |
階段 | 編譯鏈接階段 | 裝載運行階段 |
執行控制權 | 控制權直接交給可執行文件 | 控制權限交給動態鏈接器,映射完成后再交給可執行文件 |
運行尋址速度 | 速度快 | 由于間接跳轉,比靜態鏈接慢約 1%~5%,使用 lazy binding 改善 |
重定位表名 |
.rela.text 代碼段重定位表 .rela.data 數據段重定位表 |
.rela.plt 代碼段重定位表 .rela.dyn 數據段重定位表 |
四、如何指定全局變量和函數裝載時的順序
上面主要介紹了動態裝載過程,在初始化和反初始化的時候,特別需要關注全局變量和函數的構造與析構順序。這些過程直接影響到模塊間的依賴關系和對象之間的交互。因此,我們需要了解如何通過使用特定的屬性來控制這些順序,以確保程序的穩定性和預期行為。特別是在多模塊動態庫的環境中,合理安排初始化和反初始化的順序,是避免運行時錯誤和崩潰的重要措施。
4.1 全局變量初始化順序
對于跨共享庫的全局變量,其初始化順序受這些共享庫之間的依賴關系影響。如果共享庫 A 依賴于共享庫 B,那么 B 的初始化代碼將會在 A 的初始化代碼之前執行,因此 B 中的全局變量會在 A 中的全局變量之前被初始化。
再來看一下《第一章 2 模塊間函數調用》例子中,通過LD_DEBUG=files ./main命令看鏈接順序和初始化順序。
[root@docker-desktop]# LD_DEBUG=files ./main 112: find library=b1.so [0]; searching 112: search path=/usr/local/gcc-5.4.0/lib64/tls/i686:/usr/local/gcc-5.4.0/lib64/tls:/usr/local/gcc-5.4.0/lib64/i686:/usr/local/gcc-5.4.0/lib64:tls/i686i686: (LD_LIBRARY_PATH) 112: trying file=/usr/local/gcc-5.4.0/lib64/tls/i686/b1.so 112: trying file=/usr/local/gcc-5.4.0/lib64/tls/b1.so 112: trying file=/usr/local/gcc-5.4.0/lib64/i686/b1.so 112: trying file=/usr/local/gcc-5.4.0/lib64/b1.so 112: trying file=tls/i686/b1.so 112: trying file=tls/b1.so 112: trying file=i686/b1.so 112: trying file=b1.so 112: 112: find library=b2.so [0]; searching 112: search path=/usr/local/gcc-5.4.0/lib64:tls/i686i686: (LD_LIBRARY_PATH) 112: trying file=/usr/local/gcc-5.4.0/lib64/b2.so 112: trying file=tls/i686/b2.so 112: trying file=tls/b2.so 112: trying file=i686/b2.so 112: trying file=b2.so 112: 112: find library=libstdc++.so.6 [0]; searching 112: search path=/usr/local/gcc-5.4.0/lib64:tls/i686i686: (LD_LIBRARY_PATH) 112: trying file=/usr/local/gcc-5.4.0/lib64/libstdc++.so.6 112: 112: find library=libm.so.6 [0]; searching 112: search path=/usr/local/gcc-5.4.0/lib64:tls/i686i686: (LD_LIBRARY_PATH) 112: trying file=/usr/local/gcc-5.4.0/lib64/libm.so.6 112: trying file=tls/i686/libm.so.6 112: trying file=tls/libm.so.6 112: trying file=i686/libm.so.6 112: trying file=libm.so.6 112: search cache=/etc/ld.so.cache 112: trying file=/lib64/libm.so.6 112: 112: find library=libgcc_s.so.1 [0]; searching 112: search path=/usr/local/gcc-5.4.0/lib64:tls/i686i686: (LD_LIBRARY_PATH) 112: trying file=/usr/local/gcc-5.4.0/lib64/libgcc_s.so.1 112: 112: find library=libc.so.6 [0]; searching 112: search path=/usr/local/gcc-5.4.0/lib64:tls/i686i686: (LD_LIBRARY_PATH) 112: trying file=/usr/local/gcc-5.4.0/lib64/libc.so.6 112: trying file=tls/i686/libc.so.6 112: trying file=tls/libc.so.6 112: trying file=i686/libc.so.6 112: trying file=libc.so.6 112: search cache=/etc/ld.so.cache 112: trying file=/lib64/libc.so.6 112: 112: find library=a1.so [0]; searching 112: search path=/usr/local/gcc-5.4.0/lib64:tls/i686i686: (LD_LIBRARY_PATH) 112: trying file=/usr/local/gcc-5.4.0/lib64/a1.so 112: trying file=tls/i686/a1.so 112: trying file=tls/a1.so 112: trying file=i686/a1.so 112: trying file=a1.so 112: 112: find library=a2.so [0]; searching 112: search path=/usr/local/gcc-5.4.0/lib64:tls/i686i686: (LD_LIBRARY_PATH) 112: trying file=/usr/local/gcc-5.4.0/lib64/a2.so 112: trying file=tls/i686/a2.so 112: trying file=tls/a2.so 112: trying file=i686/a2.so 112: trying file=a2.so 112: 112: 112: calling init: /lib64/libc.so.6 112: 112: 112: calling init: /lib64/libm.so.6 112: 112: 112: calling init: /usr/local/gcc-5.4.0/lib64/libgcc_s.so.1 112: 112: 112: calling init: /usr/local/gcc-5.4.0/lib64/libstdc++.so.6 112: 112: 112: calling init: a2.so 112: 112: 112: calling init: a1.so 112: 112: 112: calling init: b2.so 112: 112: 112: calling init: b1.so 112: 112: 112: initialize program: ./main 112: 112: 112: transferring control: ./main 112: a1.c a1.c ......從日志中可以看到,動態庫的加載順序如下:b1.so,b2.so,a1.so,a2.so,這些庫根據依賴關系進行加載,使用 find library 語句可以看到它們被搜索并找到成功的路徑。
初始化的順序則是:a2.so,a1.so,b2.so,b1.so
這個順序展示了在執行 main 函數之前,各個庫的構造函數是如何被調用的。從中可以看出,動態庫的初始化是按照依賴順序進行的,即一個庫的初始化會在它所依賴的庫都初始化完成后進行。
__attribute__((__init_priority__(PRIORITY)))是GCC提供的一個特性,用于對一個全局變量或函數的初始化優先級進行控制。只能用于全局或靜態對象的聲明。它改變了對象構造函數的調用順序,其作用是在程序啟動時(即 main() 函數執行之前)確保不同對象的構造函數按照指定的優先級順序調用。PRIORITY 必須是一個介于 101 和 65535 之間的整數,其中 101 是最高優先級(最先初始化),65535 是最低優先級(最后初始化)。
若都沒有定義優先級, 其初始化順序取決于鏈接時,全局變量定義所在’.o’ 在命令行參數中的出現順序。
若部分全局變量使用了init_priority,部分沒有; 所有使用了init_priority的全局變量其初始化順序均先于未使用init_priority 的全局變量。
使用方式如下:
TestClass obj __attribute__((init_priority(102)))
4.2 函數的構造/析構順序
函數可使用 __attribute__(constructor(PRIORITY)) 和 __attribute__(destructor(PRIORITY)) 。
__attribute__(constructor(PRIORITY))屬性用于標記函數,它告訴編譯器這個函數應該在 main() 函數執行之前自動執行。如果指定了 PRIORITY,則可以影響多個此類函數的執行順序:數值較小的 PRIORITY 意味著該初始化函數將更早執行。
__attribute__(destructor(PRIORITY)) 修飾的函數可讓系統在main()函數退出或者調用了exit()之后調用。優先級同上。
使用方式如下:
void __attribute__((constructor(102))) test()
4.3 注意事項
可移植性:__attribute__ 是 GCC 特有的,雖然許多其他編譯器也提供類似的擴展,但它們在不同編譯器之間并不兼容,應考慮使用其他機制或添加兼容性條件編譯。
初始化依賴:當使用這些屬性來修改初始化順序時,必須非常小心地管理對象之間的依賴關系。錯誤地規劃初始化順序會導致程序在使用未初始化或半初始化狀態的對象時崩潰。
默認優先級:對于沒有指定優先級的全局對象,編譯器也會分配一個默認的初始化優先級。然而,這個默認優先級可能因編譯器而異,所以最好顯式指定優先級以避免不確定性。
與其他特性的兼容性:使用構造函數屬性時,請考慮它們可能與其他語言特性(如智能指針、靜態局部變量的延遲初始化等)的兼容性。
五、總結
上述內容闡述了動態鏈接的過程。從程序的整體運行流程來看,可以分為編譯、鏈接、裝載和執行幾個關鍵階段,以下將對這幾個階段進行簡要總結。
主要工作 | 示例命令 | |
編譯(Compile) | 源文件被gcc/g++轉換為ELF格式對象文件,該文件包含編譯后的代碼但未綁定到依賴的地址。會在磁盤生成.o 文件 |
gcc -fPIC -c test.c -o test.o gcc -c main.c -o main.o -fPIC: 表示生成位置無關代碼 -c: 表示只執行編譯步驟,不進行鏈接。 -o test.o: 指定輸出的目標文件的名稱。 |
鏈接 (Linking) |
設置必要的信息供鏈接器(ld.so)使用,為運行時動態鏈接準備各種表結構和引用占位符。會在磁盤生成.so 文件。 詳細過程: 創建符號引用的表,以便裝載器和動態鏈接器用于后續解析。 創建用于運行時符號解析的數據結構,如全局偏移表(GOT)和程序鏈接表(PLT)的占位符。 提供必要的重定向條目,告訴裝載器在哪里找到對動態庫的所有引用。 |
gcc-shared-o libtest.so test.o gcc -o main main.o -L. -ltest -shared: 告訴鏈接器我們要創建一個共享對象,即動態庫。 -o libtest.so: 指定生成的動態庫文件名稱。 |
裝載(Loading) (本文的重點) |
動態鏈接器工作過程,負責動態庫裝載到內存,并結合動態鏈接器解析符號、進行重定向和重新定位,確保程序可以在內存中正確運行。 詳細過程: 1.啟動動態鏈接器,通過GOT、.dynamic信息進行自身的重定位工作,完成自舉。 2.裝載共享目標文件:將可執行文件和鏈接器本身符號合并入全局符號表,依次廣度優先遍歷共享目標文件,它們的符號表會不斷合并到全局符號表中,如果多個共享對象有相同的符號,則優先載入的共享目標文件會屏蔽掉后面的符號 4. 重定位(內存):對需要修正的函數調用、變量地址等進行重定位,使它們指向正確的內存地址。 5. 初始化 。運行動態庫的初始化代碼,如.init和構造函數等。 |
./main |
運行(Running) | 控制權交給main函數運行,在需要時(如延遲綁定的情況),解析并更新更多的符號引用。 |
附錄 1:幾個關鍵概念
ELF (Executable and Linkable Format)
一種執行和鏈接格式標準,被用來作為Unix系統中的標準二進制文件格式,包括可執行文件、對象代碼、共享庫和核心轉儲(core dumps)。ELF文件包含了程序運行所需的所有信息,如程序指令、程序入口點、數據和符號表等。
PIC (Position Independent Code)
概念: 地址無關代碼,指不依賴于具體加載地址能夠執行的代碼。編譯為 PIC 意味著生成的代碼可以在進程的地址空間中的任何位置運行。這在動態庫中尤為重要,因為多個程序可能共享同一動態庫的單個副本,但這個庫可能被加載到這些程序的地址空間中的不同位置。
使用階段:編譯階段。使用 `-fPIC` 選項進行編譯就可以生成位置獨立的代碼。
GOT (Global Offset Table)
概念:全局偏移表,提供了一個固定的位置,用于存儲外部符號的絕對地址,由鏈接器進行填充。用于支持共享庫中的位置無關代碼(PIC)。
使用階段:鏈接/裝載。鏈接器創建 GOT,并在程序啟動時由動態鏈接器(裝載器的一部分)填充。
PLT (Procedure Linkage Table)
概念:程序連接表,與GOT共同工作用于動態鏈接中的函數調用。存有從.got.plt 中查找外部函數地址的代碼,若是第一次調用該函數,則會觸發鏈接器解析函數地址并填充在.got.plt 相應的位置;若函數地址已經存儲在.got.plt 中則直接跳轉到對應地址繼續執行。
使用階段: 鏈接/裝載。與 GOT 類似,PLT 的創建發生在鏈接階段,其填充和更新則是在程序開始運行時、動態符號被首次訪問時發生。
ld.so
Linux系統中的動態鏈接器程序,負責加載共享庫并進行動態鏈接和綁定。它讀取可執行文件指定的動態庫依賴并將這些庫加載到內存中,同時也處理符號的解析和重定位。當你運行一個動態鏈接的可執行文件時,它首先運行的實際上是ld.so,然后才是你的程序本身。ld.so會查看程序所需要的庫,并將它們加載到內存中去。
關鍵 section
section 名 | 查看命令 | 實例結果 | |
.interp | 保存了動態鏈接器的路徑 | objdump -s xxx # 查看所有 section |
.dynsym RA |
僅包含程序運行中需要動態鏈接的符號,若GCC中通過__attribute__((visibility("hidden")))隱藏的符號,在這里不會出現。 | readelf-S xxx/objdump-h XXX #查看 section 地址分布 |
'Ndx'(索引)顯示為 UND(意味著“未定義”的縮寫),表示該符號未在該共享對象中定義,并需要從其他共享對象中解析(導入)。 'Value' 列會有一個非零地址值,表示符號在共享對象文件(.so 文件)中的位置。 |
|||
.rela.dyn 和rela.plt RA |
重定位表段,用于存儲重定位信息。 .rela.dyn 對數據引用修正,修正位置:.got 和數據段 .rela.plt 對函數引用(開啟 PIC 編譯)修正,修正位置:.got.plt。只要有過程鏈表,通常就會有此表,因為plt導致了絕對跳轉,那么所有plt表中所有需要動態鏈接/重定位的絕對地址(可能在.got.plt或.got中,依賴于是否開啟延遲綁定),都需要通過.rela.plt記錄 |
readelf -r xxx #查看重定位表內容 readelf-S xxx/objdump-h XXX #查看 section 地址分布 |
.plt RA |
一組跳板函數,用于實現共享庫函數的延遲綁定。 | readelf-S xxx/objdump-h XXX #查看 section 地址分布 |
.text RA |
代碼 section | readelf-S xxx/objdump-h XXX #查看 section 地址分布 |
.dynamic RWA |
.dynamic中保存的是動態鏈接器用到的基本信息,如動態鏈接符號表(.dynsym),字符串表(.dynstr),重定位表 (.rela.dyn/rela.plt),依賴的運行時庫,庫查找路徑等 |
readelf-dxxx # 查看.dynmaic段地址 |
.got 和.got.plt RWA |
存儲重定位指針的地方 |
readelf-S xxx/objdump-h XXX #查看 section 地址分布 readelf-x |
.data RWA |
用于存儲初始化的全局變量和靜態變量 | readelf-S xxx/objdump-h XXX #查看 section 地址分布 |
.bss RWA |
用于存儲未初始化的全局變量和靜態變量,.bss 并不占據實際的磁盤空間,它只是一個占位符. | readelf-S xxx/objdump-h XXX #查看 section 地址分布 | |
.symtab | 不僅包括導出和導入的符號,也包括局部符號(如靜態函數和靜態全局變量)和調dynsym試符號。 | readelf -s xxx # 查看所有符號 |
'Ndx'(索引)顯示為 UND(意味著“未定義”的縮寫),表示該符號未在該共享對象中定義,并需要從其他共享對象中解析(導入)。 'Value' 列會有一個非零地址值,表示符號在共享對象文件(.so 文件)中的位置。 |
附錄 2:常用命令
顯示運行時鏈接
dlopen:加載動態鏈接庫(.so 文件),返回一個句柄。
dlsym:通過給定的動態鏈接庫句柄和符號名稱,查找并返回符號的地址。
dlclose:關閉由 dlopen 打開的動態鏈接庫句柄,釋放資源。
dlerror:返回描述最后一次錯誤的字符串。如果沒有發生錯誤,則返回NULL。
環境變量:
LD_LIBRARY_PATH: 為動態鏈接器指定額外的庫搜索路徑,預先定義路徑。
LD_PRELOAD:指定在所有其他庫之前加載的共享庫列表。動態鏈接器查看".dynamic"段里 NEEDED 類型,查找路徑依次為LD_LIBRARY_PATH、/etc/ld.so.conf (/etc/ld.so.cache)配置文件指定目錄、/lib、/usr/lib、進行查找。即LD_PRELOAD 環境變量的庫會最先被加載。
LD_DEBUG: 設置此環境變量可以讓動態鏈接器打印出調試信息,幫助開發者了解鏈接過程中發生了什么,包括庫搜索路徑、符號解析等。當被設置時,會輸出大量的信息到標準輸出,這可能會導致性能下降,所以通常只在調試期間使用它。格式為:LD_DEBUG=[參數值] ./[程序名稱] ,例如LD_DEBUG=libs ./your_program。參數如下:
libs打印出每個需要加載的庫的信息,包括庫的搜索和加載過程。
files報告輸入文件即二進制對象(程序或庫)的打開、關閉操作。
symbols報告符號解析的詳細信息,包括符號查找和綁定到具體地址的過程。
bindings提供綁定到全局和局部符號的信息。
versions輸出有關版本化符號信息,可以顯示庫的版本綁定情況。
all輸出上述所有調試信息,提供最全面的調試信息。
工具使用
ldd:用于打印共享庫的依賴關系。例如,運行 ldd /path/to/your/program 可以列出程序運行所需的所有動態鏈接庫。
strip:用于去除程序或庫中的調試信息、符號表.symtab等,可以減小產生的二進制文件大小。使用該命令時,需要注意由于去除了一些信息,會使得調試變得更加困難。使用方法:strip --strip-debug /path/to/library.so
-
Linux
+關注
關注
87文章
11465瀏覽量
212832 -
動態鏈接
+關注
關注
0文章
5瀏覽量
5823
原文標題:動態鏈接的魔法:Linux下動態鏈接庫機制探討
文章出處:【微信號:OSC開源社區,微信公眾號:OSC開源社區】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
關于使用動態鏈接庫及圖像采集的問題
關于labview'的動態鏈接庫的問題
基于動態鏈接庫技術的感應器非線性特性校正
動態鏈接庫在LabVIEW中的高級應用
C++中動態鏈接庫的創建和調用
LINUX環境下CLIPS動態鏈接庫的實現方法
VC++動態鏈接庫編程深入淺出

英創信息技術WinCE設備動態鏈接庫的制作與調用

評論