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

常用工具

我們首先列出一些在接下來的介紹過程中會頻繁使用的分析工具,如果從事操作系統相關的較底層的工作,那這些工具應該再熟悉不過了。不熟悉的讀者可以先看一下這裏的簡單的功能介紹,我們會在後文中介紹一些詳細的參數選項和使用場景。

另外,建議大家在遇到自己不熟悉的命令時,通過 man 命令來查看手冊,這是最權威的、第一手的資料。

ELF 文件詳解

ELF 文件的三種形式

在 Linux 下,可執行文件 / 動態庫文件 / 目標文件(可重定向文件)都是同一種文件格式,我們把它稱之爲 ELF 文件格式。雖然它們三個都是 ELF 文件格式但都各有不同。以下文件的格式信息可以通過 file 命令來查看。

  1. 可重定位(relocatable)目標文件:通常是. o 文件。包含二進制代碼和數據,其形式可以再編譯時與其他可重定位目標文件合併起來,創建一個可執行目標文件。

  2. 可執行(executable)目標文件:是完全鏈接的可執行文件,即靜態鏈接的可執行文件。包含二進制代碼和數據,其形式可以被直接複製到內存並執行。

  3. 共享(shared)目標文件:通常是. so 動態鏈接庫文件或者動態鏈接生成的可執行文件。一種特殊類型的可重定位目標文件,可以在加載或者運行時被動態地加載進內存並鏈接。注意動態庫文件和動態鏈接生成的可執行文件都屬於這一類。會在最後一節辨析時詳細區分。

因爲我們知道 ELF 的全稱:Executable and Linkable Format,即 ” 可執行、可鏈接格式 “,很顯然這裏的三個 ELF 文件形式要麼是可執行的、要麼是可鏈接的。

其實還有一種 core 文件,也屬於 ELF 文件,在 core dumped 時可以得到。我們這裏暫且不提。

注意:在 Linux 中並不以後綴名作爲區分文件格式的絕對標準。

節頭部表和程序頭表和 ELF 頭

在我們的 ELF 文件中,有兩張重要的表:節頭部表(Section Tables)和程序頭表(Program Headers)。可以通過 readelf -l [fileName] 和 readelf -S [fileName] 來查看。

但並不是所有以上三種 ELF 的形式都有這兩張表,

我們在後面的還會詳細介紹這兩張表。

此外,整個 ELF 文件的前 64 個字節,成爲 ELF 頭,可以通過 readelf -h [fileName] 來查看。我們也會在後面詳細介紹。

可重定位 ELF 文件的內容分析

#include <elf.h>,該頭文件通常在 / usr/include/elf.h,可以自己 vim 查看。

首先有一個 64 字節的 ELF 頭 Elf64_Ehdr,其中包含了很多重要的信息(可通過 readelf -h [fileName] 來查看),這些信息中有一個很關鍵的信息叫做 Start of section headers,它指明瞭節頭部表,Section Headers Elf64_Shdr 的位置。段表中儲存了 ELF 文件中各個的偏移量以記錄其位置。ELF 中的各個段可以通過 readelf -S [fileName] 來查看。

其中各個節的含義如下:

這樣我們就把一個可重定位的 ELF 文件中的每一個字節都搞清楚了。

靜態鏈接

編譯、鏈接的需求

爲了節省空間和時間,不將所有的代碼都寫在同一個文件中是一個很基本的需求。

爲此,我們的 C 語言需要實現這樣的需求:允許引用其他文件(C 標準成爲編譯單元,Compilation Unit)裏定義的符號。C 語言中不禁止你隨便聲明符號的類型,但是類型不匹配是 Undefined Behavior。

假如我們有三個 c 文件,分別是 a.c,b.c,main.c:

// a.c
int foo(int a, int b){
 return a + b;
}
// b.c
int x = 100, y = 200;
// main.c
extern int x, y;
int foo(int a, int b);
int main(){
 printf("%d + %d = %d\n", x, y, foo(x, y));
}

我們在 main.c 中聲明瞭外部變量 x,y 和函數 foo,C 語言並不禁止我們這麼做,並且在聲明時,C 也不會做什麼類型檢查。當然,在編譯 main.c 的時候,我們看不到這些外部變量和函數的定義,也不知道它們在哪裏。

我們編譯鏈接這些代碼,Makfile 如下:

CFLAGS := -Os

a.out: a.o b.o main.o
 gcc -static -Wl,--verbose a.o b.o main.o

a.o: a.c
 gcc $(CFLAGS) -c a.c

b.o: b.c
 gcc $(CFLAGS) -c b.c

main.o: main.c
 gcc $(CFLAGS) -c main.c

clean:
 rm -f *.o a.out

結果生成的可執行文件可以正常地輸出我們想要的內容。

make
./a.out
# 輸出:
# 100 + 200 = 300

我們知道 foo 這個符號是一個函數名,在代碼區。但這時,如果我們將 main.c 中的 foo 聲明爲一個整型,並且直接打印出這個整型,然後嘗試對其加一。即我們將 main.c 改寫爲下面這樣,會發生什麼事呢?

// main.c (changed)
#include <stdio.h>
extern int x, y;
// int foo(int a, int b);
extern int foo;
int main(){
        printf("%x\n", foo);
        foo += 1;
        // printf("%d + %d = %d\n", x, y, foo(x, y));
}

輸出:

c337048d

Segmentation fault (core dumped)

我們發現,其實是能夠打印出四個字節(整型爲 4 個字節),但這四個字節是什麼東西呢?

C 語言中的類型:C 語言中的其實是可以理解爲沒有類型的,在 C 語言的眼中只有內存和指針,也就是內存地址,而所謂的 C 語言中的類型,其實就是對這個地址的一個解讀。比如有符號整型,就按照補碼解讀接下來的 4 個字節地址;又比如浮點型,就是按照 IEEE754 的浮點數規定來解讀接下來的 4 字節地址。

那我們這裏將符號 foo 定義爲了整型,那編譯器也會按照整型 4 個自己來解讀它,而這個地址指針指向的其實還是函數 foo 的地址。那這四個字節應該就是函數 foo 在代碼段的前四個字節。我們不妨用 objdump 反彙編來驗證我們的想法:

objdump -d a.out

輸出(節選):

我們看到,foo 函數在代碼段的前四個字節的地址確是就是我們上面打印輸出的 c3 37 04 8d(注意字節序爲小端法)。

那我們接下來試圖對 foo 進行加一操作相當於是對代碼段的寫操作,而我們知道內存中的代碼段是 可讀可執行不可寫 的,這就對應了上面輸出的 Segmentation fault (core dumped)。

總結一下,通過這個例子,我們應當理解:

  1. 編譯鏈接的需求:允許引用其他文件(C 標準成爲編譯單元,Compilation Unit)裏定義的符號。C 語言中不禁止你隨便聲明符號的類型,但是類型不匹配是 Undefined Behavior。

  2. C 語言中類型的概念:C 語言中的其實是可以理解爲沒有類型的,在 C 語言的眼中只有內存和指針,也就是內存地址,而所謂的 C 語言中的類型,其實就是對這個地址的一個解讀。

程序的編譯 - 可重定向文件

我們先用 file 命令來查看 main.c 編譯生成的 main.o 文件的屬性:

file main.o

輸出:

main.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

我們看到這裏的 main.o 文件是可重定向 (relocatable) 的 ELF 文件,這裏的重定向指的就是我們鏈接過程中對外部符號的引用。也就是說,編譯過的 main.o 文件對於其中聲明的外部符號如 foo,x,y,是不知道的。

既然外部的符號是在鏈接時纔會被 main 程序知道,那在編譯 main 程序,生成可重定向文件時這些外部的符號是怎麼處理的呢?我們同樣通過 objdump 工具來查看編譯出的 main.o 文件(未修改的原版本):

objdump -d main.o

輸出:

main 在編譯的時候,引用的外部符號就只能 ” 留空 (0)“ 了。

我們看到,在編譯但還未鏈接的 main.o 文件中,對於引用的外界符號的部分是用留空的方式用 0 暫時填充的。即上圖中紅框框出來的位置。注意圖中的最後一列是筆者添加的註釋,指明瞭本行中留空的地方對應那個外部符號。

另外注意這裏的 %rip 相對尋址的偏移量都是 0,一會兒我們會講到,在靜態鏈接完成之後,它們的偏移量會被填上正確的數值。

我們已經知道在編譯時生成的文件中外部符號的部分使用 0 暫時留空的,這些外部符號是待鏈接時再填充的。那麼,我們在鏈接時究竟需要填充哪些位置呢?我們可以使用 readelf 工具來查看 ELF 文件的重定位信息:

readelf -r main.o

這個圖中上方是 readelf 的結果,下面是 objdump 的結果,筆者在這裏已經將前兩個外部符號的偏移量的對應關係用紅色箭頭指了出來,其他的以此類推。這種對應也可以證明我們上面的分析是正確的的。

應當講,可重定向 ELF 文件(如 main.o)已經告訴了我們足夠多的信息,指示我們應該將相應的外部符號填充到哪個位置。

另外,注意 %rip 寄存器指向了當前指令的末尾,也就是下一條指令的開頭,所以上圖中最後的偏移量要減 4(如 y - 4)。

程序的靜態鏈接

簡單講,程序的靜態鏈接是會把所需要的文件鏈接起來生成可執行的二進制文件,將相應的外部符號,填入正確的位置(就像我們上面查看的那樣)。

  1. 段的合併

首先會做一個段的合併。即把相同的段(比如代碼段 .text)識別出來並放在一起。

  1. 重定位

重定位表,可用 objdump -r [fileName] 查看。

簡單講,就是當某個文件中引用了外部符號,在編譯時編譯器是不會阻止你這樣做的,因爲它相信你會在鏈接時告訴它這些外部符號是什麼東西。但在編譯時,它也不知到這些符號具體在什麼地址,因此這些符號的地址會在編譯時被留空爲 0。此時的重定位,就是鏈接器將這些留空爲 0 的外部符號填上正確的地址。

具體的鏈接過程,可以通過 ld --verbose 來查看默認的鏈接腳本,並在需要的時候修改鏈接腳本。

我們可以通過使用 gcc 的 -Wl,--verbose 將 --verbose 傳遞給鏈接器 ld,從而直接觀察到整個靜態鏈接的過程,包括:

我們可以通過 objdump 來查看靜態鏈接完成以後生成的可執行文件 a.out 的內容:

objdump -d a.out

注意,這個 a.out 的 objdump 結果圖要與我們之前看到的 main.o 的 objdump 輸出對比着來看。

我們可以看到,之前填 0 留空的地方都被填充上了正確的數值,%rip 相對尋址的偏移量以被填上了正確的數值,而且 objdump 也能夠正確地解析出我們的外部符號名(最後一列)的框。

靜態鏈接庫的構建與使用

假如我們要製作一個關於向量的靜態鏈接庫 libvector.a,它包含兩個源代碼 addvec.c 和 multvec.c 如下:

// addvec.c
int addcnt = 0;

void addvec(int *x, int *y, int*z, int n){
 int i;
 addcnt++;

 for (i=0; i<n; i++) z[i] = x[i] + y[i];
}
// multvec.v
int multcnt = 0;

void multvec(int *x, int *y, int*z, int n){
 int i;
 multcnt++;

 for (i=0; i<n; i++) z[i] = x[i] *  y[i];
}

我們只需要這樣來進行編譯:

gcc -c addvec.c multvec.c

ar rcs libvector.a addvec.o multvec.o

假如我們有個程序 main.c 要調用這個靜態庫 libvector.a:

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

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]);
 return 0;
}
// vector.h
void addvec(int*, int*, int*, int);
void multvec(int*, int*, int*, int);

只需要在這樣編譯鏈接即可:

gcc -c main.c

gcc -static main.o ./libvector.a

靜態鏈接過程圖示

我們以使用剛纔構建的靜態庫 libvector.a 的程序爲例,畫出靜態鏈接的過程。

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

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