動態符號鏈接的細節

基本概念

ELF

ELF 是 Linux 支持的一種程序文件格式,本身包含重定位、執行、共享(動態鏈接庫)三種類型(man elf)。

代碼:

/* test.c */
#include <stdio.h>    

int global = 0;

int main()
{
        char local = 'A';

        printf("local = %c, global = %d\n", local, global);

        return 0;
}

演示:
通過 -c 生成可重定位文件 test.o,這裏不會進行鏈接:

$ gcc -c test.c
$ file test.o
test.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped

鏈接之後纔可以執行:

$ gcc -o test test.o
$ file test
test: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked (uses shared libs), not stripped

也可鏈接成動態鏈接庫,不過一般不會把 main 函數鏈接成動態鏈接庫,後面再介紹:

$ gcc -fpic -shared -Wl,-soname,libtest.so.0 -o libtest.so.0.0 test.o
$ file libtest.so.0.0
libtest.so.0.0: ELF 32-bit LSB shared object, Intel 80386, version 1 (SYSV), not stripped

雖然ELF文件本身就支持三種不同類型,不過它有一個統一的結構,這個結構是:

文件頭部(ELF Header)
程序頭部表(Program Header Table)
節區1(Section1)
節區2(Section2)
節區3(Section3)
...
節區頭部表(Section Header Table)

無論是文件頭部、程序頭部表、節區頭部表,還是節區,它們都對應着 C 語言裏頭的一些結構體(elf.h 中定義)。

1. 文件頭部主要描述 ELF 文件的類型,大小,運行平臺,以及和程序頭部表和節區頭部表相關的信息
2. 節區頭部表則用於可重定位文件,以便描述各個節區的信息,這些信息包括節區的名字、類型、大小等。
3. 程序頭部表則用於描述可執行文件或者動態鏈接庫,以便系統加載和執行它們。
4. 而節區主要存放各種特定類型的信息,比如程序的正文區(代碼)、數據區(初始化和未初始化的數據)、調試信息、以及用於動態鏈接的一些節區,比如解釋器(.interp)節區將指定程序動態裝載 / 鏈接器 ld-linux.so 的位置,而過程鏈接表(plt)、全局偏移表(got)、重定位表則用於輔助動態鏈接過程。

符號

對於可執行文件除了編譯器引入的一些符號外,主要就是用戶自定義的全局變量,函數等,而對於可重定位文件僅僅包含用戶自定義的一些符號。

生成可重定位文件
  $ gcc -c test.c
  $ nm test.o
  00000000 B global
  00000000 T main
           U printf

上面包含全局變量、自定義函數以及動態鏈接庫中的函數,但不包含局部變量,而且發現這三個符號的地址都沒有確定。

生成可執行文件
 $ gcc -o test test.o
  $ nm test | egrep "main$| printf|global$"
  080495a0 B global
  08048354 T main
           U printf@@GLIBC_2.0

經鏈接,global 和 main 的地址都已經確定了,但是 printf 卻還沒,因爲它是動態鏈接庫 glibc 中定義函數,需要動態鏈接,而不是這裏的“靜態”鏈接。

重定位:是將符號引用與符號定義進行鏈接的過程

從上面的演示可以看出,重定位文件 test.o 中的符號地址都是沒有確定的,而經過靜態鏈接(gcc 默認調用 ld 進行鏈接)以後有兩個符號地址已經確定了,這樣一個確定符號地址的過程實際上就是鏈接的實質。鏈接過後,對符號的引用變成了對地址(定義符號時確定該地址)的引用,這樣程序運行時就可通過訪問內存地址而訪問特定的數據。

我們也注意到符號 printf 在可重定位文件和可執行文件中的地址都沒有確定,這意味着該符號是一個外部符號,可能定義在動態鏈接庫中,在程序運行時需要通過動態鏈接器(ld-linux.so)進行重定位,即動態鏈接。

通過這個演示可以看出 printf 確實在 glibc 中有定義。

$ nm -D /lib/`uname -m`-linux-gnu/libc.so.6 | grep "\ printf$"
0000000000053840 T printf

動態鏈接

動態鏈接就是在程序運行時對符號進行重定位,確定符號對應的內存地址的過程。

Linux 下符號的動態鏈接默認採用 Lazy Mode 方式,也就是說在程序運行過程中用到該符號時纔去解析它的地址。這樣一種符號解析方式有一個好處:只解析那些用到的符號,而對那些不用的符號則永遠不用解析,從而提高程序的執行效率。

不過這種默認是可以通過設置 LD_BIND_NOW 爲非空來打破的(下面會通過實例來分析這個變量的作用),也就是說如果設置了這個變量,動態鏈接器將在程序加載後和符號被使用之前就對這些符號的地址進行解析。

動態鏈接庫

上面提到重定位的過程就是對符號引用和符號地址進行鏈接的過程,而動態鏈接過程涉及到的符號引用和符號定義分別對應可執行文件和動態鏈接庫,在可執行文件中可能引用了某些動態鏈接庫中定義的符號,這類符號通常是函數。

爲了讓動態鏈接器能夠進行符號的重定位,必須把動態鏈接庫的相關信息寫入到可執行文件當中,這些信息是什麼呢?

$ readelf -d test | grep NEEDED
 0x00000001 (NEEDED)                     Shared library: [libc.so.6]

通過 LD_LIBRARY_PATH 參數,它類似 Shell 解釋器中用於查找可執行文件的 PATH 環境變量,也是通過冒號分開指定了各個存放庫函數的路徑。該變量實際上也可以通過 /etc/ld.so.conf 文件來指定,一行對應一個路徑名。爲了提高查找和加載動態鏈接庫的效率,系統啓動後會通過 ldconfig 工具創建一個庫的緩存 /etc/ld.so.cache 。如果用戶通過 /etc/ld.so.conf 加入了新的庫搜索路徑或者是把新庫加到某個原有的庫目錄下,最好是執行一下 ldconfig 以便刷新緩存。

需要補充的是,因爲動態鏈接庫本身還可能引用其他的庫,那麼一個可執行文件的動態符號鏈接過程可能涉及到多個庫,通過 readelf -d 可以打印出該文件直接依賴的庫,而通過 ldd 命令則可以打印出所有依賴或者間接依賴的庫。

$ ldd test
        linux-gate.so.1 =>  (0xffffe000)
        libc.so.6 => /lib/libc.so.6 (0xb7da2000)
        /lib/ld-linux.so.2 (0xb7efc000)

libc.so.6 通過 readelf -d 就可以看到的,是直接依賴的庫;而 linux-gate.so.1 在文件系統中並沒有對應的庫文件,它是一個虛擬的動態鏈接庫,對應進程內存映像的內核部分; 而 /lib/ld-linux.so.2 正好是動態鏈接器,系統需要用它來進行符號重定位。那 ldd 是怎麼知道 /lib/ld-linux.so 就是該文件的動態鏈接器呢?

那是因爲 ELF 文件通過專門的節區指定了動態鏈接器,這個節區就是 .interp 。

$ readelf -x .interp test

Hex dump of section '.interp':
  0x08048114 2f6c6962 2f6c642d 6c696e75 782e736f /lib/ld-linux.so
  0x08048124 2e3200                              .2.

以看到這個節區剛好有字符串 /lib/ld-linux.so.2,即 ld-linux.so 的絕對路徑。

我們發現,與 libc.so 不同的是,ld-linux.so 的路徑是絕對路徑,而 libc.so 僅僅包含了文件名。原因是:程序被執行時,ld-linux.so 將最先被裝載到內存中,沒有其他程序知道去哪裏查找 ld-linux.so,所以它的路徑必須是絕對的;當 ld-linux.so 被裝載以後,由它來去裝載可執行文件和相關的共享庫,它將根據 PATH 變量和 LD_LIBRARY_PATH 變量去磁盤上查找它們,因此可執行文件和共享庫都可以不指定絕對路徑。

動態鏈接器

Linux 下 elf 文件的動態鏈接器是 ld-linux.so,即 /lib/ld-linux.so.2 。通過 man ld-linux 可以獲取與動態鏈接器相關的資料,包括各種相關的環境變量和文件都有詳細的說明。

對於環境變量,除了上面提到過的 LD_LIBRARY_PATH 和 LD_BIND_NOW 變量外,還有其他幾個重要參數,比如 LD_PRELOAD 用於指定預裝載一些庫,以便替換其他庫中的函數,而環境變量 LD_DEBUG 可以用來進行動態鏈接的相關調試。

對於文件,除了上面提到的 ld.so.conf 和 ld.so.cache 外,還有一個文件 /etc/ld.so.preload 用於指定需要預裝載的庫。

從上一小節中發現有一個專門的節區 .interp 存放有動態鏈接器,但是這個節區爲什麼叫做 .interp (interpeter)呢?因爲當 Shell 解釋器或者其他父進程通過 exec 啓動我們的程序時,系統會先爲 ld-linux 創建內存映像,然後把控制權交給 ld-linux,之後 ld-linux 負責爲可執行程序提供運行環境,負責解釋程序的運行,因此 ld-linux 也叫做 dynamic loader (或 intepreter)

那麼在 exec ()之後和程序指令運行之前的過程是怎樣的呢?大體過程如下:

1. 將可執行文件的內存段添加到進程映像中;
2. 把共享目標內存段添加到進程映像中;
3. 爲可執行文件和它的共享目標(動態鏈接庫)執行重定位操作;
4. 關閉用來讀入可執行文件的文件描述符,如果動態鏈接程序收到過這樣的文件描述符的話;
5. 將控制轉交給程序,使得程序好像從 exec() 直接得到控制

關於第 1 步,在 ELF 文件的文件頭中就指定了該文件的入口地址,程序的代碼和數據部分會相繼 map 到對應的內存中。

對於第 2 步,上一節提到的 .dynamic 節區指定了可執行文件依賴的庫名,ld-linux (在這裏叫做動態裝載器或程序解釋器比較合適)再從 LD_LIBRARY_PATH 指定的路徑中找到相關的庫文件或者直接從 /etc/ld.so.cache 庫緩衝中加載相關庫到內存中。

對於第 3 步,在前面已提到,如果設置了 LD_BIND_NOW 環境變量,這個動作就會在此時發生,否則將會採用 lazy mode 方式,即當某個符號被使用時纔會進行符號的重定位。不過無論在什麼時候發生這個動作,重定位的過程大體是一樣的(在後面將主要介紹該過程)。

對於第 4 步,這個主要是釋放文件描述符。

對於第 5 步,動態鏈接器把程序控制權交還給程序。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章