動態鏈接,動態在哪?

hello 大家好,我是升哥。

這次聊聊動態鏈接。


動態鏈接

要解決靜態鏈接空間浪費和更新困難這兩個問題最簡單的辦法就是把程序的模塊相互分割開來,形成獨立的文件,而不再將它們靜態地鏈接在一起。

簡單地講,就是不對那些組成程序的目標文件進行鏈接,等到程序要運行時才進行鏈接。也就是說,把鏈接這個過程推遲到了運行時再進行,這就是動態鏈接(Dynamic Linking ) 的基本思想。

在 Linux 系統中,ELF 動態鏈接文件被稱爲動態共享對象(DSO, Dynamic Shared Objects ),簡稱共享對象,它們一般都是以 “.so” 爲擴展名的一些文件;而在 Windows 系統中,動態鏈接文件被稱爲動態鏈接庫(Dynamical Linking Library),它們通常就是我們平時很常見的以 “.dll” 爲擴展名的文件。

手動製作動態共享對象

有四個源代碼文件:

// Lib.h
#ifndef LIB_H
#define LIB_H

void foobar(int i);

#endif
// Lib.c
#include<stdio.h>

void foobar(int i){
    printf("Printing from Lib.so: %d\n", i);
}
// Program1.c
#include"Lib.h"

int main(){
    foobar(1);
    return0;
}
// Program2.c
#include"Lib.h"

int main(){
    foobar(2);
    return0;
}

將 Lib.c 編譯成一個共享對象文件:

gcc -fPIC -shared -o Lib.so Lib.c

再鏈接:

gcc -o Program1 Program1.c ./Lib.so
gcc -o Program2 Program2.c ./Lib.so

當程序模塊 Program1 .c 被編譯成爲 Program1.o 時, 編譯器還不不知道 foobar() 函數的地址。當鏈接器將 Program1.c 鏈接成可執行文件時,這時候鏈接器必須確定 Program1.o 中所引用的 foobar() 函數的性質。

鏈接器如何知道 foobar 的引用是一個靜態符號還是一個動態符號?Lib.so 中保存了完整的符號信息。把 Lib.so 也作爲鏈接的輸入文件之一,鏈接器在解析符號時就可以知道:foobar 是一個定義在 Lib.so 的動態符號。這樣鏈接器就可以對 foobar 的引用做特殊的處理,使它成爲一個對動態符號的引用。

地址

回顧一下之前提到的,程序模塊的指令和數據中可能會包含一些絕對地址的引用,我們在 鏈接產生輸出文件的時候,就要假設模塊被裝栽的目標地址。

可執行文件基本可以確定自己在進程虛擬空間中的起始位置,因爲可執行文件往往是第一個被加載的文件,它可以選擇一個固定空閒的地址,比如 Linux 下一般都是 0x08040000, Windows 下一般都是 0x0040000。

但是在動態鏈接的情況下,如果不同的模塊目標裝載地址都一樣是不行的。**共享對象在編譯時不能假設自己在進程虛擬地址空間中的位置。**共享對象的最終裝載地址在編譯時是不確定的,而是在裝載時,裝載器根據當前地址空間的空閒情況,動態分配一塊足夠大小的虛擬地址空間給相應的共享對象。

重定位

爲了能夠使共享對象在任意地址裝載,我們首先能想到的方法就是靜態鏈接中的重定位。這個想法的基本思路就是,在鏈接時,對所有絕對地址的引用不作重定位,而把這一步推遲到裝載時再完成。一旦模塊裝載地址確定,即目標地址確定,那麼系統就對程序中所有的絕對地址引用進行重定位。

我們前面在靜態鏈接時提到過重定位,那時的重定位叫做鏈接時重定位(Link Time Relocation ), 而現在這種情況經常被稱爲裝載時重定位(Load Time Relocation ), 在 Windows 中,這種裝載時重定位又被叫做基址重置(Rebasing)。

但是它有一個很大的缺點是(重定位後的)指令部分無法在多個進程之間共享,這樣就失去了動態鏈接節省內存的一大優勢。

地址無關代碼

我們的目的是希望程序模塊中共享的指令部分在裝載時不需要因爲裝載地址的改變而改變,所以實 的基本想法就是把指令中那些需要被修改的部分分離出來,跟數據部分放在一起,這樣指令部分就可以保持不變,而數據部分可以在每個進程中擁有一個副本。這種方案就是目前被稱爲地址無關代碼(PIC,Position-independent Code ) 的技術。

模塊中各種類型的地址引用方式可以看作 4 種:

  1. 模塊內部的函數調用、跳轉等。

  2. 模塊內部的數據訪問,比如模塊中定義的全局變量、 靜態變量。

  3. 模塊外部的函數調用、跳轉等。

  4. 是模塊外部的數據訪問,比如其他模塊中定義的全局變量。

staticint a;
externint b;
extern void ext();

void bar(){
    a = 1;//模塊內部的數據訪問,比如模塊中定義的全局變量、 靜態變量。
    b = 2;//是模塊外部的數據訪問,比如其他模塊中定義的全局變量。
}

void foo(){
    bar();//模塊內部的函數調用、跳轉等。
    ext();//模塊外部的函數調用、跳轉等。
}

類型一:模塊內部調用或跳轉

因爲被調用的函數與調用者都處於同一個模塊,它們之間的相對位置是固定的,所以這種情況比較簡單。對於現代的系統來講,模塊內部的跳轉、函數調用都可以是相對地址調用,或者是基於寄存器的相對調用,所以對於這種指令是不需要重定位的。

類型二:模塊內部數據訪問

我們知道,一個模塊前面一般是若干個頁的代碼, 後面緊跟着若干個頁的數據,這些頁之間的相對位置是固定的,也就是說,任何一條指令與 它需要訪問的模塊內部數據之間的相對位置是固定的,那麼只需要相對於當前指令加上固定 的偏移量就可以訪問模塊內部數據了

類型三:模塊間數據訪問

模塊間的數據訪問比模塊內部稍微麻煩一點,因爲模塊間的數據訪問目標地址要等到裝載時才決定,比如上面例子中的變量 b,它被定義在其他模塊中,並且該地址在裝載時才能確定。

要使得代碼地址無關,基本的思想就是把跟地址相關的部分放到數據段裏面

ELF 的做法是在 數據段裏曲建立一個指向這些變量的指針數組。也被稱爲全局偏移表 (Global Offset Table, GOT),當代碼需要引用該全局變量時,可以通過 GOT 中相對應的項間接引用。

當指令中需要訪問變量 b 時,程序會先找到 GOT,然後根據 GOT 中變量所對應的項找 到變量的目標地址。

由於 GOT 本身是放在數據段的,所以它可以在模塊裝載時被修改,並且每個進程都可以有獨立的副本,相互不受影響。

類型四:模塊間調用、跳轉

與上面的方法類似,GOT 中相應的項保存的是目標函數的地址,當模塊需要調用目標函數時,可以通過 GOT 中的項進行間接跳轉。

延遲綁定

在動態鏈接下,在程序開始執行前,動態鏈接會耗費不少時 間用於解決模塊之間的函數引用的符號査找以及重定位,而且在一個程序運行過程中,可能很多函數在程序執行完時都不會被用到,因此如果一開始就把所有函數都鏈接好實際上是一種時間與 CPU 上的浪費。

ELF 採用了一種叫做延遲綁定( Lazy Binding ) 的做法,基本的思想就是當函數第一次被用到時才進行綁定(符號査找、重定位等),如果沒有用到則不進行綁定。

ELF 動態鏈接實現

現在我們看看可執行文件中與動態鏈接相關的結構:

動態鏈接步驟

動態鏈接情況可執行文件的裝載與靜態鏈接情況類似:

  1. 首先操作系統會讀取可執行文件的頭部,檢査文件的合法性。

  2. 從頭部中的 “Program Header” 中讀取每個 “Segment” 的虛擬地址、文件地址和屬性,並將它們映射到進程虛擬空間的相應位置。

  3. 因爲可執行文件依賴於很多共享對象,這時候可執行文件裏對有很多外部符號的引用還處於無效地址的狀態。即還沒有跟相應的共享對象中的實際位置鏈接起來。所以在映射完可執行文件之後,操作系統會先啓動一個動態鏈接器(Dynamic Linker )。在 Linux 下,動態鏈接器 ld.so 實際上是一個共享對象,操作系統同樣通過映射的方式將它加載到進程的地址空間中。操作系統在加載完動態鏈接器之後, 就將控制權交給動態鏈接器的入口地址。

  4. 當動態鏈接器得到控制權之後,它開始執行一系列自身的初始化操作。因爲動態鏈接器本身也是一個共享對象,它的重定位工作需要自己完成。首先是,動態鏈接器本身不可以依賴於其他任何共享對象;其次是動態鏈接器本身所需要的全局和靜態變量的重定位工作由它本身完成,這種具有一定限制條件的啓動代碼往往被稱爲 ** 自舉(Bootstrap)**。動態鏈接器入口地址即是自舉代碼的入口,自舉代碼需要獲得動態鏈接器本身的重定位表和符號表等,從而得到動態鏈接器本身的重定位入口,先將它們全部重定位。

  5. 完成基本自舉以後,動態鏈接器將可執行文件和鏈接器本身的符號表都合併到一個符號表當中,我們可以稱它爲全局符號表(Global Symbol Table ),然後鏈接器開始尋找吋執行文件所依賴的共享對象。找到相應的文件後打開該文件,讀取相應的 ELF 文件頭和 “.dynamic” 段,然後將它相應的代碼段和數據段映射到進程空間中。如果我們把依賴關係看作一個圖的話, 那麼這個裝載過程就是一個圖的遍歷過程,鏈接器可能會使用深度優先或再廣度優先或各其他的順序來遍歷整個圖。

  6. 鏈接器開始重新遍歷可執行文件和每個共享對象的重定位表, 將它們的 GOT/PLT 中的每個需要重定位的位置進行修正。因爲此時動態鏈接器己經擁有了 進程的全局符號表,所以這個修正過程也顯得比較容易,跟我們前面提到的地址重定位的原理基本相同。

  7. 重定位完成之後,如果某個共享對象有 “.init” 段,那麼動態鏈接器會執行 “.init” 段中的代碼,用以實現共享對象特有的初始化過程。

  8. 當所有動態鏈接工作完成以後,動態鏈接器會將控制權轉交到可執行文件的入口地址, 程序開始正式執行。

顯式運行時鏈接 / 運行時加載

顯式運行時鏈接 (Explicit Run-time Linking), 有時候也叫做運行時加載,就是讓程序自己在運行時控制加載指定的模塊,並且可以在不需要該模塊時將其卸載。

這種共享對象往往被叫做動態裝載庫(Dynamic Loading Library ), 其實本質上它跟一般的共享對象沒什麼區別,只是程序開發者使用它的角度不同。

這種運行時加載使得程序的模塊組織變得很靈活,可以用來實現一些諸如插件、驅動等 功能。當程序需要用到某個插件或者驅動的時候,纔將相應的模塊裝載進來,而不需要從一開始就將他們全部裝載進來,從而減少了程序啓動時間和內存使用。

在 Linux 中,從文件本身的格式上來看,動態庫實際上跟一般的共享對象沒有區別,主要的區別是共享對象是由動態鏈接器在程序啓動之前負責裝載和鏈接的,這一系列步驟都由動態連接器自動完成,對於程序本身是透明的;而動態庫的裝載則是通過一系列由動態鏈接器提供的 API, 具體地講共有 4 個函數:打開動態庫(dlopen )、 查找符號(dlsym )、錯誤處理 (dleiror) 以及關閉動態庫(dlclose ),程序可以通過這幾個 API 對動態庫進行操作。

其他問題

Q1:動態鏈接器本身是動態鏈接的還是靜態鏈接的?

A1:動態鏈接器本身應該是靜態鏈接的,它不能依賴於其他共享對象,動態鏈接器本身是用 來幫助其他 ELF 文件解決共享對象依賴問題的,如果它也依賴於其他共享對象,那麼誰來幫它解決依賴問題?所以它本身必須不依賴於其他共享對象。這一點可以使用 Idd 來判斷:

qmmms@qmmms-virtual-machine:~/shared/SimpleDynamicLinking$ ldd /lib64/ld-linux-x86-64.so.2
	statically linked

Q2:動態鏈接器本身必須是 PIC 的嗎?

A2:是不是 PIC 對於動態鏈接器來說並不關鍵,動態鏈接器可以是 PIC 的也可以不是,但往往使用 PIC 會更加簡單一些。一方面,如果不是 PIC 的話,會使得代碼段無法共享,浪 費內存;另一方面也會使 ld.so 本身初始化更加複雜,因爲自舉時還需要對代碼段進行 重定位。實際上的 ld - linux.so.2 是 PIC 的。

Q3:動態鏈接器可以被當作可執行文件運行,那麼的裝載地址應該是多少?

A3:ld.so 的裝載地址跟一般的共享對象沒區別,即爲 0x00000000。這個裝載地址是一個無 效的裝載地址,作爲一個共享庫,內核在裝載它時會爲其選擇一個合適的裝載地址。

Linux 共享庫

由於動態鏈接的諸多優點,大量的程序開始使用動態鏈接機制,導致系統裏面存在數最極爲龐大的共享對象。如果沒有很好的方法將這些共享對象組織起來,整個系統中的共享對象文件則會散落在各個目錄下,給長期的維護、升級造成了很大的問題。

操作系統一般會對共享對象的目錄組織和使用方法有,這裏介紹 Linux 下共享庫的管理問題。

從文件結構上來講, 共享庫和共享對象沒什麼區別,Linux 下的共享庫就是普通的 ELF 共享對象。由於共享對象 可以被各個程序之間共享,所以它也就成爲了庫的很好的存在形式,很多庫的開發者都以共 享對象的形式讓程序來使用,久而久之,共享對象和共享庫這兩個概念己經很模糊了,所以 廣義上我們可以將它們看作是同 —個概念。

命名規則

Linux 有一套規則來命名系統中的每一 個共享庫,它規定共享庫的文件名規則必須如下:

libname.so.x.y.z

依賴關係

程序中有一個它所依賴的共享庫的列表,其中每一項對應於它所依賴的一個共享庫。

Linux 採用一種叫做 SO-NAME 的命名機制來 記錄共享庫的依賴關係。

每個共享庫都有一個對應的 “SO-NAME”,這個 SO-NAME 即共享庫的文件名去掉次版本號和發佈版本號,保留主版本號。比如一個共享庫叫做 libfoo.so.2.6.1 , 那麼它的 SO-NAME 即 libfoo.so.2。

在 Linux 系統中, 系統會爲每個共享庫在它所在的目錄創建一個跟 “SO-NAME” 相同的並且指向它的軟鏈接 (Symbol Link )。比如系統中有存在一個共享庫 “ /lib/libfoo.so.2.6.1”,那麼 Linux 中的共享庫管理程序就會爲它產生一個軟鏈接 “ /lib/Iibfoo.so.2”,例如:

qmmms@qmmms-virtual-machine:/lib/x86_64-linux-gnu$ ls -l libc*
-rw-r--r-- 1 root root 6027298  7月  7  2022 libc.a
lrwxrwxrwx 1 root root      20  7月 12 22:22 libcaca++.so.0 -> libcaca++.so.0.99.19
lrwxrwxrwx 1 root root      18  7月 12 22:22 libcaca.so.0 -> libcaca.so.0.99.19

這樣保證了所有的以 SO-NAME 爲名的軟鏈接都指向系統中最新版的共 享庫.

如果某文件 A 依賴於某文件 B, 那麼 A 的 “.dynamic” 段中會有 DT_NEED 類型的字段,字段的值就是 B。

qmmms@qmmms-virtual-machine:~/shared/SimpleDynamicLinking$ readelf -d Lib.so

Dynamic section at offset 0x2e20 contains 24 entries:
  標記        類型                         名稱/值
 0x0000000000000001 (NEEDED)             共享庫:[libc.so.6]

當共享庫進行升級的時候,如果保持主版本號不變,只改變次版 本號或發佈版本號,那麼我們修改 SO-NAME 的軟鏈接指向新版本共享庫,即可實現升級。

然而,當某個程序依賴於較高的次版本號的共享庫, 而運行於較低次版本號的共享庫系統時,就吋能產生缺少某些符號的錯誤。因爲次版本號只保證向後兼容,並不保證向前兼容。

這個問題叫做次版本號交會問題(Minor-revision Rendezvous Problem)。現代的系統通過 - 一種更加精巧的方式來解決,那就是符號版本機制。

符號版本

Linux 下的 Glibc 從版本 2.1 之後開始支持一種叫做基於符號的版本機制 (Symbol Versioning) 的方案。這個方案的基本思路是讓每個導出和導入的符號都有一個相關聯的版 本號,它的實際做法類似於名稱修飾的方法。

例如,當我們將 Iibfoo.so.1.2 升級至 1.3 時,仍然 保持 libfoo.so.1 這個 SO-NAME, 但是給在 1.3 這個新版中添加的那些全局符號打上一個標記,比如 “VERS_1.3”。

我們現在實操一下,準備要編譯成共享庫的 lib.c:

int foo()
{
    return10086;
}

lib.h

#ifndef LIB_H
#define LIB_H

int foo();

#endif

main.c

#include"lib.h"

int main(){
    return foo();
}

符號腳本版本文件 lib.ver:

VERS_1.2{
    global:
        foo;
    local:
        *;
};

現在根據符號腳本版本文件編譯共享庫 lib.so,注意現在符號版本是 1.2

gcc -shared -fPIC lib.c -Xlinker --version-script lib.ver -o lib.so

編譯 main.c,現在 main 使用的 lib.so 符號版本是 1.2

gcc main.c ./lib.so -o main

現在我們悄咪咪地把 lib.so 刪了,更改符號腳本版本文件 lib.ver 的版本爲 1.1,重新編譯共享庫 lib.so,注意現在 lib.so 符號版本是 1.1,落後於 main 使用的 lib.so 符號版本

VERS_1.1{
    global:
        foo;
    local:
        *;
};

運行 main,不出意外的話報錯:

qmmms@qmmms-virtual-machine:/mnt/hgfs/shared/SimpleSymbolVersioning$ ./main
./main: ./lib.so: version `VERS_1.2' not found (required by ./main)

共享庫的創建和安裝

比如我們有 libfoo1.c 和 libfoo2.c 兩個源代碼文件,希鎮產生一個 libfoo.so.1.0.0 的共享 庫,這個共享庫依賴於 libfoo1.c 和 libfoo2.c 這兩個共享庫,我們可以使用如下命令行:

gcc -c -g -Wall -o libfoo1.o libfoo1.c
gcc -c -g -Wall -o libfoo2.o libfoo2.c
ld -shared -soname libfoo.so.1 -o libfoo.so.1.0.0 libfoo1.o libfoo2.o

正常情況下編譯出來的共享庫或可執行文件裏面帶有符號信息和調試信息,這些信息在 調試時非常有用,但是對於最終發佈的版本來說. 這些符號信息用處並不大,並且使得文件 尺寸變大。我們可以使用一個叫 “strip" 的工具清除掉共享厙或可執行文件的所有符號和調 試信息(“strip” 是 binutils 的一部分):

strip libfoo.so.1.0.0

最簡單的安裝辦法就是將共享庫複製到某個標準的共享庫目錄,如 / lib、/usr/lib 等,然後運行 ldconfig 即可。

sudo cp libfoo.so.1.0.0 /lib/libfoo.so.1.0.0
sudo ldconfig

執行 ldconfig 時,它會讀取默認的共享庫路徑(例如/etc/ld.so.conf文件中列出的路徑),檢查這些路徑下的共享庫文件,並更新共享庫緩存。

ldconfig 通常在共享庫的安裝或刪除之後使用,以確保系統能夠正確地加載和鏈接新的或已刪除的共享庫。在絕大多數情況下,不需要手動運行 ldconfig,因爲安裝或卸載共享庫的工具會自動調用它。

想要更多相關代碼和筆記?找找這個倉庫:

https://gitee.com/QMMMS/reading-notes

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Ya8o8WgzrRn5qoTA3ABKzQ