Linux 下的 ELF 文件、鏈接、加載與庫(中)

可執行文件的裝載

進程和裝載的基本概念的介紹

程序(可執行文件)和進程的區別

現代操作系統如何裝載可執行文件

  1. 給進程分配獨立的虛擬地址空間

  2. 將可執行文件映射到進程的虛擬地址空間(mmap)

  3. 將 CPU 指令寄存器設置到程序的入口地址,開始執行

可執行文件在裝載的過程中實際上如我們所說的那樣是映射的虛擬地址空間,所以可執行文件通常被叫做映像文件 (或者 Image 文件)。

可執行 ELF 文件的兩種視角

可執行 ELF 格式具有不尋常的雙重特性,編譯器、彙編器和鏈接器將這個文件看作是被區段(section)頭部表描述的一系列邏輯區段的集合,而系統加載器將文件看成是由程序頭部表描述的一系列段(segment)的集合。一個段(segment)通常會由多個區段(section)組成。例如,一個 “可加載只讀” 段可以由可執行代碼區段、只讀數據區段和動態鏈接器需要的符號區段組成。

區段(section)是從鏈接器的視角來看 ELF 文件,對應段表 Section Headers,而段(segment)是從執行的視角來看 ELF 文件,也就是它會被映射到內存中,對應程序頭表 Program Headers。

我們用命令 readelf -a [fileName] 中的 Section to Segment mapping 部分來看一下可執行文件中段的映射關係。

可執行文件的程序頭表

我們用 readelf -h [fileName] 命令查看一個可執行 ELF 文件的 ELF 頭時,會發現與可重定位 ELF 文件的 ELF 頭有一個重大不同:可重定位文件 ELF 頭中 Start of program headers 爲 0,因爲它是沒有程序頭表,Program Headers,Elf64_Phdr 的;而在可執行 ELF 文件中,Start of program headers 是有值的,爲 64,也就是說,在可執行 ELF 文件中程序頭表會緊接着 ELF 頭(因爲 ELF 頭的大小即爲 64 字節)。

我們通過 readelf -l [fileName] 可以直接查看到程序頭表。

可執行 ELF 文件個進程虛擬地址空間的映射關係

我們可以通過 cat /proc/[pid]/maps 來查看某個進程的虛擬地址空間。

該虛擬文件有 6 列,分別爲:

vdso 的全稱是虛擬動態共享庫(virtual dynamic shared library),而 vsyscall 的全稱是虛擬系統調用(virtual system call),關於這部分內容有興趣的讀者可以看看 https://0xax.gitbooks.io/linux-insides/content/SysCall/syscall-3.html。

總體來說,在程序加載過程中,磁盤上的可執行文件,進程的虛擬地址空間,還有機器的物理內存的映射關係如下:

Linux 下的裝載過程

接下來我們進一步探究一下 Linux 是怎麼識別和裝載 ELF 文件的,我們需要深入 Linux 內核去尋找答案 (內核實際處理過程涉及更多的過程,我們這裏主要關注和 ELF 文件處理相關的代碼)。

當我們在 bash 下輸入命令執行某一個 ELF 文件的時候,首先 bash 進程調用 fork() 系統調用創建一個新的進程,然後新的進程調用 execve() 系統調用執行指定的 ELF 文件 , 內核開始真正的裝載工作。

下圖是 Linux 內核代碼中與 ELF 文件的裝載相關的一些代碼:

/fs/binfmt_elf.c 中 Load_elf_binary 的代碼走讀:

  1. 檢查 ELF 文件頭部信息 (一致性檢查)

  2. 加載程序頭表 (可以看到一個可執行程序必須至少有一個段(segment),而所有段的大小之和不能超過 64K(65536u))

  3. 尋找和處理解釋器段 (動態鏈接部分會介紹)

  4. 裝入目標程序的段 (elf_map)

  5. 填寫目標程序的入口地址

  6. 填寫目標程序的參數,環境變量等信息 (create_elf_tables)

  7. start_thread 會將 eip 和 esp 改成新的地址,就使得 CPU 在返回用戶空間時就進入新的程序入口

例子:靜態 ELF 加載器,加載 a.out 執行

我們同樣以剛纔介紹靜態鏈接時的 a.c、b.c、main.c 的例子來看一下靜態鏈接的可執行文件的加載。

靜態 ELF 文件的加載:將磁盤上靜態鏈接的可執行文件按照 ELF program header,正確地搬運到內存中執行。

操作系統在 execve 時完成:

  1. 進程還未準備好時,由內核直接執行 ” 系統調用 “

  2. 映射好 a.out 的代碼、數據、堆區、堆棧、vvar、vdso、vsyscall

加載完成之後,靜態鏈接的程序就開始從 ELF entry 開始執行,之後就變成我們熟悉的狀態機,唯一的行爲就是取指執行。

我們通過 readelf 來查看 a.out 文件的信息:

readelf -h a.out

輸出:

我們這裏看到,程序的入口地址是:Entry point address: 0x400a80。我們接着用 gdb 來調試:

上圖是筆者在 gdb 中調試的一些內容:

  1. 我們用 starti 來使得程序在第一條指令就停下,可以看到,程序確實是從 0x400180 開始的,與我們上面查到的入口地址一致。

  2. 而我們用 cat /proc/[PID]/maps 來查看這個程序中內存的內容,看到我們之前提到的代碼、數據、堆區、堆棧、vvar、vdso、vsyscall 都已經被映射進了內存中。

調試的結果符合我們對靜態程序加載時操作系統的行爲的預期。

動態鏈接

什麼是動態鏈接以及爲什麼需要動態鏈接

實際上,鏈接程序在鏈接時一般是優先鏈接動態庫的,除非我們顯式地使用 - static 參數指定鏈接靜態庫,像這樣:

gcc -static hello.c

靜態鏈接和動態鏈接的可執行文件的大小差距還是很顯著的, 因爲靜態庫被鏈接後庫就直接嵌入可執行文件中了。

這樣就帶來了兩個弊端:

  1. 首先就是系統空間被浪費了。這是顯而易見的,想象一下,如果多個程序鏈接了同一個庫,則每一個生成的可執行文件就都會有一個庫的副本,必然會浪費系統空間。

  2. 再者,一旦發現了庫中有 bug 或者是需要升級,必須把鏈接該庫的程序找出來,然後全部需要重新編譯。

libc.so 中有 300K 條指令,2 MiB 大小,每個程序如果都靜態鏈接,浪費的空間很大,最好是整個系統裏只有一個 libc 的副本,而每個用到 libc 的程序在運行時都可以用到 libc 中的代碼。

下圖中的 hello-dy 和 hello-st 是同一個 hello 源文件 hello.c 分別動態 / 靜態鏈接後生成的可執行文件的大小,大家可以感受一下,查了一百倍。而且這只是鏈接了 libc 標準庫,在大型項目中,我們要鏈接各種各樣的第三方庫,而靜態鏈接會把全部在鏈接時就鏈接到同一個可執行文件,那麼其大小是很難接受的。

動態庫的出現正是爲了彌補靜態庫的弊端。因爲動態庫是在程序運行時被鏈接的,所以磁盤上和內存中只要保留一份副本,因此節約了磁盤空間。如果發現了 bug 或要升級也很簡單,只要用新的庫把原來的替換掉就行了。

Linux 環境下的動態鏈接對象都是以. so 爲擴展名的共享對象 (Shared Object)。

真的是動態鏈接的嗎?

我們常說 gcc 默認的鏈接類型就是動態鏈接,而且我們及其中運行的大部分進程也都是動態鏈接的,真的是這樣的嗎?我們不妨來做個實驗驗證一下。

我們通過創建一個動態鏈接庫 libhuge.so, 然後創建 1000 個進程去調用這個庫中的 foo 函數,該函數是 128M 個 nop。如果程序不是動態鏈接的話,1000 * 128MB 的內存佔用足以撐爆大多數個人電腦的內存。而如果程序確實是動態鏈接的,即內存中只有一份代碼,那麼只會有很小的內存佔用。我們是這樣做的:

首先我們有 huge.S:

.global foo
foo:
        # 128MiB of nop
        .fill 1024 * 1024 * 128, 1, 0x90
        ret

這就是我們剛纔說的一個動態鏈接庫的源代碼。我們一會兒會把他編譯成 libhuge.so 供我們的 huge.c 調用,我們的 huge.c 是這樣的:

#include <unistd.h>
#include <stdio.h>
int main(){
 foo(); // huge code, dynamic linked
 printf("pid = %d\n", getpid());
 while (1) sleep(1);
}

它會調用 foo 函數,並在結束後打印自己的 PID,然後睡眠。Makefile 如下:

LIB := /tmp/libhuge.so

all: $(LIB) a.out

$(LIB): huge.S
 gcc -fPIC -shared huge.S -o $@

a.out: huge.c $(LIB)
 gcc -o $@ huge.c -L/tmp -lhuge

clean:
 rm -f *.so *.out $(LIB)

正如我們剛纔所介紹的,我們會先將 huge.S 編譯成動態鏈接庫 libhuge.so 放在 / tmp 下,然後我們的 huge.c 回去動態鏈接這個庫,並完成自己的代碼。這還不夠,我們要創建 1000 個進程來執行上述行爲。這樣才能驗證我們的動態鏈接是不是在內存中真的只有一份代碼,我們用下面的腳本來完成:

#!/bin/bash

# for i in {1...1000}
for i in `seq 1 100`
do
 LD_LIBRARY_PATH=/tmp ./a.out &
done

wait
# ps | grep "a.out" | grep -Po "^(\d)*" | xargs kill -9  用於清空生成的進程

實驗證明,我們的操作系統能夠很好地運行這 1000 個進程,並且內存只多佔用了 400MB。也就是說,庫中的 foo 函數確實是動態鏈接的,內存中只有一份 foo 的副本。

這在操作系統內核不難實現:所有以只讀方式映射同一個文件的部分(如代碼部分)時,都指向同一個副本,這個過程中會創建引用計數。

動態鏈接的例子

假如我們要製作一個關於向量的動態鏈接庫 libvector.so,它包含兩個源代碼 addvec.c 和 multvec.c 如下:我們只需要這樣來進行編譯:

gcc -shared -fpic -o libvector.so addvec.c multvec.c

其中 - fpic 選項告訴編譯器生成位置無關代碼(PIC),而 - shared 選項告訴編譯器生成共享庫。

我們現在拿一個使用到這個共享庫的可執行文件來看一下,其源代碼 main.c:

// main.c
#include<stdio.h>

int addvec(int*, int*, int*, int);

int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];

int main(){
        addvec(x, y, z, 2);
        printf("z = [%d %d]\n", z[0], z[1]);
        while(1);
        return 0;
}

注意我們在最後加了一個死循環是爲了讓進程保持運行,然後去查看進程的虛擬地址空間。

我們先編譯源碼,注意在同目錄下可以直接按以下命令編譯,之後我們會介紹將動態鏈接庫放到環境目錄後的編譯命令。

gcc  main.c ./libvector.so

然後先用 file 命令查看生成的可執行文件 a.out 的文件信息,再用 ldd 命令查看其需要的動態庫,最後查看其虛擬地址空間。

file a.out

輸出:

我們看到,該可執行文件是共享對象,並且是動態鏈接的。

ldd a.out

輸出:

ldd 命令就是用來查看該文件所依賴的動態鏈接庫。

./a.out &

cat /proc/12002/maps

輸出:

我們看到,除了像靜態鏈接時,進程地址空間中的堆、棧、vvar、vdso、vsyscall 等之外,還有了許多動態鏈接庫. so。

動態鏈接的實現機制

我們同樣用 readelf -l [fileName] 來查看動態鏈接的可執行 ELF 文件的程序頭表:

readelf -l a.out

可以看到編譯完成之後地址是從 0x00000000 開始的,即編譯完成之後最終的裝載地址是不確定的。

之前在靜態鏈接的過程中我們提到過重定位的過程,那個時候其實屬於鏈接時的重定位,現在我們需要裝載時的重定位 ,主要使用了以下關鍵技術:

  1. PIC 位置無關代碼

  2. GOT 全局偏移表

  3. GOT 配合 PLT 實現的延遲綁定技術

引入動態鏈接之後,實際上在操作系統開始運行我們的應用程序之前,首先會把控制權交給動態鏈接器,它完成了動態鏈接的工作之後再把控制權交給應用程序。

可以看到動態鏈接器的路徑在. interp 這個段中體現,並且通常它是個軟鏈接,最終鏈接在像 ld-2.27.so 這樣的共享庫上。

我們來看一下和動態鏈接相關的. dynamic 段和它的結構,.dynamic 段其實就是全局偏移表的第一項,即 GOT[0]。

可以通過 readelf -d [fileName] 來查看。

它對應的是 elf.h 中的 Elf64_Dyn 這個結構體。

對於動態鏈接的可執行文件,內核會分析它的動態鏈接器地址,把動態鏈接器映射到進程的地址空間,把控制權交給動態鏈接器。動態鏈接器本身也是. so 文件,但是它比較特殊,它是靜態鏈接的。本身不依賴任何其他的共享對象也不能使用全局和靜態變量。這是合理的,試想,如果動態鏈接器都是動態鏈接的話,那麼由誰來完成它的動態鏈接呢?

Linux 的動態鏈接器是 glibc 的一部分,入口地址是 sysdeps/x86_64/dl-machine.h 中的_start,然後調用 elf/rtld.c 的_dl_start 函數,最終調用 dl_main(動態鏈接器的主函數)。

動態鏈接過程圖示

動態鏈接庫的構建與使用

創建號一個動態鏈接庫(如我們的 libvector.so)之後,我們肯定不可能只在當前目錄下使用它,那樣他就不能被叫做 ” 庫 “了。

爲了在全局使用動態鏈接庫,我們可以將我們自己的動態鏈接庫移動到 / usr/lib 下:

sudo mv libvector.so /usr/lib

之後我們只要在需要使用到相關庫時加上 - l[linName] 選項即可,如:

gcc main.c -lvector

大家也注意到了,上面的命令要用到管理員權限 sudo。適應爲 / usr/lib 和 / lib 是系統級的動態鏈接目錄,我們要創建自己的第三方庫最好不要直接放在這個目錄中,而是創建一個自己的動態鏈接庫目錄,並將這個目錄添加到環境變量 LD_LIBRARY_PATH 中:

mkdir /home/song/dynlib

mv libvector.so /home/song/dynlib

export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/song/dynlib

動態鏈接庫要命名爲:lib[libName].so 的形式。

人人都是極客 人人極客社區專注於 Linux 底層技術,分享體系架構, 內核, 網絡, 安全和驅動。

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