↓推薦關注↓
阿里妹導讀
本文將深入探討Linux系統中的動態連結庫機制,這其中包括但不限於全域性符號介入、延遲繫結以及地址無關程式碼等內容。
引言
在軟體開發過程中,動態庫連結問題時常出現,這可能導致符號衝突,從而引起程式執行異常或崩潰。為深入理解動態連結機制及其工作原理,我重溫了《程式設計師的自我修養》,並透過實踐演示與反彙編分析,瞭解了動態連結的過程。
本文將深入探討Linux系統中的動態連結庫機制,這其中包括但不限於全域性符號介入(Global Symbol Interposition)、延遲繫結(Lazy Binding)以及地址無關程式碼(Position-Independent Code, PIC)等內容。透過對上述概念和技術細節的討論,希望能夠提供一個更加清晰的認知框架,從而揭示符號衝突背後隱藏的本質原因。這樣一來,在實際軟體開發過程中遇到類似問題時,開發者們便能更加遊刃有餘地採取措施進行預防或解決,確保程式穩定執行的同時提升整體質量與使用者體驗。
為便於讀者查閱,本文中提及的一些基本概念,例如ELF、PIC、GOT、PLT、常用的section等,被歸納整理於附錄部分。
一、先舉個
我們將透過一個簡單的 C 語言程式,逐步探討動態連結庫在模組內部及模組間的執行機制,其中涉及變數和函式之間的互動過程。同時,我們將使用 -fPIC 選項,以確保生成位置無關程式碼。
// 靜態變數 a 僅在本模組中可見
staticint a;
// 用 extern 宣告外部全域性變數 b
externint b;
// 在本模組訪問的全域性變數 c
int c = 3;
// 宣告外部函式 ext()
externvoidext();
// 靜態函式 inner() 的作用域僅限於本模組
staticvoidinner(){}
// bar() 函式修改靜態變數 a 和外部全域性變數 b
voidbar(){
a = 1; // 修改靜態變數 a 的值
b = 2; // 修改外部全域性變數 b 的值
c = 4; // 修改模組內的全域性變數 c 的值
}
// foo() 函式內呼叫了 inner、bar 和 ext,並列印變數值
voidfoo(){
inner(); // 呼叫靜態函式 inner()
bar(); // 呼叫函式 bar()
ext(); // 呼叫外部函式 ext()
printf("a = %d, b = %d, c = %d\n", a, b, c); // 輸出變數的值
}
// 定義外部全域性變數 b
int b = 1;
// 外部函式 ext() 修改外部全域性變數 b 的值
voidext(){
b = 3; // 修改外部全域性變數 b 的值
}
// main.c
intmain(){
foo(); // 呼叫 foo() 函式,演示模組間互動
return0; // 程式正常結束
}
gcc -shared -fPIC -o libpic.so pic.c -g
gcc-omainmain.c-L. -lpic
在此程式碼示例中,使用 -fPIC 編譯選項可以生成位置無關的程式碼,適用於建立共享庫。程式碼中包含了多個場景:
-
模組內函式呼叫:foo 函式中呼叫了 inner 和 bar 函式。由於 inner 是靜態函式,其作用域僅限於本模組。bar 函式操作了模組內的靜態變數 a 和全域性變數 c。
-
模組間函式呼叫:foo 函式呼叫了外部函式 ext,這是一個在其他模組中定義的函式。ext 負責修改外部全域性變數 b。
不同型別的變數:
-
靜態變數 a 僅在本模組可見,其值不會在程式的其他模組中改變,也不會因函式呼叫而丟失。
-
外部全域性變數 b 可以在多個模組間共享,其值在整個程式中是唯一且可改變的。
-
模組內的全域性變數 c 僅能在當前模組訪問和修改。
我們都知道動態連結庫需要能夠在多個程序之間共享同一段程式碼。為了實現這一點,程式碼必須是位置無關的,從而可以在載入時按需被連結到不同的地址,編譯時新增編譯選項-fPIC 可以生成地址無關程式碼,那這些函式和變數執行時,如何做到呢?接下來將逐步分析動態連結的過程。
二、從例子來深入動態連結庫
2.1 模組內函式呼叫
例子中 foo 函式實現中有兩個函式呼叫:靜態函式 inner()和非靜態函式 bar(),反彙編後結果。
Disassemblyof section .plt:
0000000000000670<bar@plt-0x10>:
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<bar@plt>:
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>:
:
12 :
staticvoid inner() {}
7e2: 55 push rbp
7e3: 48 89 e5 mov rbp,rsp
7e6: 5d pop rbp
7e7: c3 ret
...
15 :
inner();
7ec: b8 00 00 00 00 mov eax,0x0
7f1: e8 ec ff ff ff call 7e2 <inner>
16 :
bar();
7f6: b8 00 00 00 00 mov eax,0x0
7fb: e8 80 fe ff ff call 680 <bar@plt>
2.1.1 靜態函式呼叫:inner()函式呼叫
和靜態編譯重定位相似,這裡更簡單,具體如下:
7f1: e8 ec ff ff ff call 7e2 <inner>
-
e8:相對偏移呼叫指令 -
ec ff ff ff:小端 0XFFFFFFEC 是-20 的補碼,該數值為目的地址相對於當前指令下一條指令的偏移。即 inner 地址為 0x7f6(下一條指令偏移) – 0x14 = 0x7e2
結論:靜態函式呼叫很簡單,透過相對地址偏移就可以跳轉。
2.1.2 全域性函式呼叫:bar()函式呼叫
首次呼叫
7fb: e8 80 fe ff ff call 680 <bar@plt>
-
解析規則同上,不展開,但是跳轉的地址為 0x680 <bar@plt>, -
第一條指令為jmp QWORD PTR [rip+0x200992],這是一個間接跳轉(jmp)指令,執行跳轉地址 0x201018,該地址是什麼?
objdump-s libpic.so
Contentsof section .got:
200fc800000000 00000000 00000000 00000000 ................
200fd800000000 00000000 00000000 00000000 ................
200fe800000000 00000000 00000000 00000000 ................
200ff800000000 00000000 ........
Contentsof section .got.plt:
201000080e2000 00000000 00000000 00000000 .. .............
20101000000000 00000000 86060000 00000000 ................
20102096060000 00000000 a6060000 00000000 ................
201030b6060000 00000000 c6060000 00000000 ................
-
發現這個地址在.got.plt section,0x00000686, 該地址存的地址為
0000000000000680<bar@plt>:
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>
那上面一系列地址跳轉是在幹什麼?用一個示意圖表示 bar 首次地址重定位過程(橙色是呼叫入口,藍色是執行的指令,紫色代表修正的地址)。

_dl_runtime_resolve()函式實現不展開,該函式的入參為入棧的符號索引 index 和庫 ID,解析過程會依賴.dynamic、.rela.plt 等 section 資訊,解析後重定向地址後填入地址0x201018 。可以檢視下.rela.plt 段內容有什麼。
demo1]# readelf -r libpic.so
Relocationsection '.rela.dyn' at offset 0x4e8 contains 10 entries:
OffsetInfo Type Sym. Value Sym. Name + Addend
000000200de8000000000008 R_X86_64_RELATIVE 780
000000200df0000000000008 R_X86_64_RELATIVE 740
000000200e00000000000008 R_X86_64_RELATIVE 200e00
000000200fc8000200000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0
000000200fd0000300000006 R_X86_64_GLOB_DAT 0000000000000000 b + 0
000000200fd8000500000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000200fe0000e00000006 R_X86_64_GLOB_DAT 0000000000201040 c + 0
000000200fe8000700000006 R_X86_64_GLOB_DAT 0000000000000000 _Jv_RegisterClasses + 0
000000200ff0000800000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0
000000200ff8000900000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize + 0
Relocationsection '.rela.plt' at offset 0x5d8 contains 5 entries:
OffsetInfo Type Sym. Value Sym. Name + Addend
000000201018000b00000007 R_X86_64_JUMP_SLO 00000000000007b8 bar + 0
000000201020000400000007 R_X86_64_JUMP_SLO 0000000000000000 printf + 0
000000201028000500000007 R_X86_64_JUMP_SLO 0000000000000000 __gmon_start__ + 0
000000201030000600000007 R_X86_64_JUMP_SLO 0000000000000000 ext + 0
000000201038000900000007 R_X86_64_JUMP_SLO 0000000000000000 __cxa_finalize + 0
-
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: 0x200e080x7f7a983976a8
0x7f7a98176010: 0x7f7a9818d890 <_dl_runtime_resolve_xsave> 0x7f7a97f75686 <bar@plt+6>
0x7f7a98176020: 0x7f7a97f75696 <printf@plt+6> 0x7f7a97f756a6 <__gmon_start__@plt+6>
0x7f7a98176030: 0x7f7a97f756b6 <ext@plt+6> 0x7f7a97f756c6 <__cxa_finalize@plt+6>
0x7f7a98176040 <c>: 0x30x0
0x7f7a98176050: 0x31303220352e382e0x5228203332363035
0x7f7a98176060: 0x34207461482064650x2936332d352e382e
0x7f7a98176070: 0x20000002c000x8000000
.got.plt 中 bar 地址 = 0x201018 + 0x7F7A97F75000(基地址) = 0x7F7A98176018,0x7F7A98176018 內容為0x7f7a97f75686 <bar@plt+6>,和上圖的相對地址偏移相同,重定向後結果如下
(gdb) x/16a 0x7f7a98176000
0x7f7a98176000: 0x200e080x7f7a983976a8
0x7f7a98176010: 0x7f7a9818d890 <_dl_runtime_resolve_xsave> 0x7f7a97f757b8 <bar>
0x7f7a98176020: 0x7f7a97f75696 <printf@plt+6> 0x7f7a97f756a6 <__gmon_start__@plt+6>
0x7f7a98176030: 0x7f7a97f756b6 <ext@plt+6> 0x7f7a97f756c6 <__cxa_finalize@plt+6>
0x7f7a98176040 <c>: 0x30x0
0x7f7a98176050: 0x31303220352e382e0x5228203332363035
0x7f7a98176060: 0x34207461482064650x2936332d352e382e
0x7f7a98176070: 0x20000002c000x8000000
0x7f7a97f757b8 為程式碼段,0x7f7a97f757b8 – 0x7F7A97F75000(基地址)= 0x7B8,該偏移在.text 的 bar 入口地址,也對應起來了。
抽象一下,如下示意圖:

透過上圖指令跳轉得出,.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()函式的呼叫,檢視彙編,發現和模組內函式呼叫方式一模一樣。彙編指令如下:
17 :
ext();
800: b8 00 00 00 00 mov eax,0x0
805: e8 a6 fe ff ff call 6b0 <ext@plt>
那現在回答上一節的第一個問題,模組內和模組間全域性函式呼叫沒有區別,為什麼呢?
先回憶下載入過程,動態連結器完成自舉後,會將可執行檔案和連結器本身的符號表都合併到一個符號表中,該符號表叫做全域性符號表(Global Symbol Table)。當一個符號需要被加入全域性符號表時,如果相同的符號已經存在,則後加入的符號被忽略,這種規則叫做全域性符號介入。
由於全域性符號介入規則,若上一節的模組內部函式呼叫 bar() 直接採用相對地址呼叫話,可能會被其他模組的同名函式符號覆蓋,那相對地址就是無法準確找到正確的函式地址,故模組內和模組外的函式呼叫,都需要透過.got.plt 重定位方法間接呼叫。
那上一節第二個問題答案也顯而易見,靜態函式不涉及全域性符號介入問題,可以透過模組內部相對地址跳轉就可以。這樣呼叫的定址速度也比全域性函式的定址速度快。
為了更深入理解全域性符號介入,我們再舉個例子。
/* a1.c*/
voida(){
printf("a1.c\n");
}
/* a2.c */
voida(){
printf("a2.c\n");
}
/* b1.c */
voida();
voidb1(){
a();
}
/* b2.c */
voida();
voidb2(){
a();
}
/* main.c */
voidb1();
voidb2();
intmain(){
b1();
b2();
return0;
}
[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,看下反彙編後結果:
voidbar() {
7b8: 55 push rbp
7b9: 48 89 e5 mov rbp,rsp
7 :
a = 1;
7bc: c7 05 82 08 20 00 01 mov DWORD PTR [rip+0x200882],0x1 # 201048 <__TMC_END__>
7c3: 00 00 00
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
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
10 :
}
Idx Name Size VMA LMA File off Algn
CONTENTS, ALLOC, LOAD, DATA
20 .got 000000380000000000200fc8 0000000000200fc8 00000fc8 2**3
CONTENTS, ALLOC, LOAD, DATA
21 .got.plt 0000004000000000002010000000000000201000000010002**3
CONTENTS, ALLOC, LOAD, DATA
22 .data 0000000400000000002010400000000000201040000010402**2
CONTENTS, ALLOC, LOAD, DATA
23 .bss 0000000c 00000000002010440000000000201044000010442**2
ALLOC
static 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")))隱藏的符號,那函式呼叫跳轉會有什麼變化?
staticint a;
externint b;
__attribute__((visibility("hidden"))) int c = 3;
externvoidext();
voidbar() __attribute__((visibility("hidden")));
voidbar(){
a = 1;
b = 2;
c = 4;
}
staticvoidinner(){}
voidfoo(){
inner();
bar();
ext();
printf("a = %d, b = %d, c = %d\n", a, b, c);
}
反彙編後結果
demo1]# objdump -d -M intel -S -l libpic_hidden.so
Disassemblyof section .text:
...
0000000000000738<bar>:
:
7 :
staticint a;
externint b;
int c = 3;
externvoid ext();
voidbar() __attribute__((visibility("hidden")));
voidbar() {
738: 55 push rbp
739: 48 89 e5 mov rbp,rsp
8 :
a = 1;
73c: c7 05 fa 08 20 00 01 mov DWORD PTR [rip+0x2008fa],0x1 # 201040 <__TMC_END__>
743: 00 00 00
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
10 :
c = 4;
753: c7 05 db 08 20 00 04 mov DWORD PTR [rip+0x2008db],0x4 # 201038 <c>
75a: 00 00 00
...
17 :
bar();
773: b8 00 00 00 00 mov eax,0x0
778: e8 bb ff ff ff call 738 <bar>
[root@docker-desktop demo1]# readelf-Slibpic_hidden.so
Thereare 34 sectionheaders, startingatoffset 0x1470:
SectionHeaders:
[Nr]NameTypeAddressOffset
SizeEntSizeFlagsLinkInfoAlign
......
[23].dataPROGBITS 0000000000201038 00001038
0000000000000004 0000000000000000 WA 0 0 4
-
bar: 反彙編後看到呼叫 bar 直接可以透過相對地址跳轉,不需要執行重定位。 -
int c; # 201038 <c> ==> .data section
檢視.rela.plt section
demo1]# readelf -r libpic_hidden.so
Relocationsection '.rela.dyn' at offset 0x4a8 contains 9 entries:
OffsetInfo Type Sym. Value Sym. Name + Addend
000000200df0000000000008 R_X86_64_RELATIVE 700
000000200df8000000000008 R_X86_64_RELATIVE 6c0
000000200e08000000000008 R_X86_64_RELATIVE 200e08
000000200fd0000200000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0
000000200fd8000300000006 R_X86_64_GLOB_DAT 0000000000000000 b + 0
000000200fe0000500000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000200fe8000700000006 R_X86_64_GLOB_DAT 0000000000000000 _Jv_RegisterClasses + 0
000000200ff0000800000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0
000000200ff8000900000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize + 0
Relocationsection '.rela.plt' at offset 0x580 contains 4 entries:
OffsetInfo Type Sym. Value Sym. Name + Addend
000000201018000400000007 R_X86_64_JUMP_SLO 0000000000000000 printf + 0
000000201020000500000007 R_X86_64_JUMP_SLO 0000000000000000 __gmon_start__ + 0
000000201028000600000007 R_X86_64_JUMP_SLO 0000000000000000 ext + 0
000000201030000900000007 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-txxx.a
readelf-rxxx.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: finallinkfailed: Bad value
collect2: error: ld returned 1exitstatus
nopic_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命令看連結順序和初始化順序。
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/i686:tls:i686: (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/i686:tls:i686: (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/i686:tls:i686: (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/i686:tls:i686: (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/i686:tls:i686: (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/i686:tls:i686: (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/i686:tls:i686: (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/i686:tls:i686: (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
......
從日誌中可以看到,動態庫的載入順序如下: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
|
連結
(Linking)
|
設定必要的資訊供連結器(ld.so)使用,為執行時動態連結準備各種表結構和引用佔位符。會在磁碟生成.so 檔案。
詳細過程:
|
gcc-shared-o libtest.so test.o
gcc -o main main.o -L. -ltest
|
裝載(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 地址分佈
|
![]()
|
.rela.dyn 和rela.plt
RA
|
重定位表段,用於儲存重定位資訊。
|
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
|
(.rela.dyn/rela.plt),依賴的執行時庫,庫查詢路徑等
|
readelf-dxxx # 檢視.dynmaic段地址
|
![]() |
.got 和.got.plt
RWA
|
儲存重定位指標的地方
|
readelf-S xxx/objdump-h XXX #檢視 section 地址分佈
readelf-x <section> <xxx.so> # 檢視特定 section 內容
|
![]() ![]() |
.data
RWA
|
用於儲存初始化的全域性變數和靜態變數
|
readelf-S xxx/objdump-h XXX #檢視 section 地址分佈
|
![]() |
.bss
RWA
|
用於儲存未初始化的全域性變數和靜態變數,.bss 並不佔據實際的磁碟空間,它只是一個佔位符.
|
readelf-S xxx/objdump-h XXX #檢視 section 地址分佈
|
|
.symtab
|
不僅包括匯出和匯入的符號,也包括區域性符號(如靜態函式和靜態全域性變數)和調dynsym試符號。
|
readelf -s xxx # 檢視所有符號
|
![]()
|
附錄 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
附錄 3:參考文件
《程式設計師的自我修養》書籍
END
