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

入口函數和運行庫

入口函數

初學者可能一直以來都認爲 C 程序的第一條指令就是從我們的 main 函數開始的,實際上並不是這樣,在 main 開始前和結束後,系統其實幫我們做了很多準備工作和掃尾工作,下面這個例子可以證明:

我們有兩個 C 代碼:

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

__attribute((constructor)) void before_main()
{ printf("%s\n",__FUNCTION__); }

int main() {
    printf("%s\n",__FUNCTION__);
}


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

void post(void)
{
    printf("goodbye!\n");
}

int main()
{
    atexit(&post);
    printf("exiting from main\n");
}

分別編譯運行這兩個程序,輸出結果分別爲:

# entry.c
before_main
main
# atexit.c
exiting from main
goodbye!

可見,在 main 開始前和結束後,其實還有一部分程序在運行。

事實上操作系統裝載程序之後首先運行的代碼並不是我們編寫的 main 函數的第一行,而是某些運行庫的代碼,它們負責初始化 main 函數正常執行所需要的環境,並負責調用 main 函數,並且在 main 返回之後,記錄 main 函數的返回值,調用 atexit 註冊的函數,最後結束進程。以 Linux 的運行庫 glibc 爲例,所謂的入口函數,其實 就是指 ld 默認的鏈接腳本所指定的程序入口_start (默認情況下)。

運行庫

glibc = GNU C library

Linux 環境下的 C 語言運行庫 glibc 包括:

事實上運行庫是和平臺相關的,和操作系統聯繫的非常緊密,我們可以把運行庫理解成我們的 C 語言 (包括 c++) 程序和操作系統之間的抽象層,使得大部分時候我們寫的程序不用直接和操作系統的 API 和系統調用直接打交道,運行庫把不同的操作系統 API 抽象成相同的庫函數,方便應用程序的使用和移植。

Glibc 有幾個重要的輔助程序運行的庫 /usr/lib64/crt1.o, /usr/lib64/crti.o, /usr/lib64/crtn.o。

其中 crt1 包含了基本的啓動退出代碼, ctri 和 crtn 包含了關於. init 段及. finit 段相關處理的代碼 (實際上是_init() 和_finit()的開始和結尾部分)

Glibc 是運行庫,它對語言的實現並不太瞭解,真正實現 C++ 語言特性的是 gcc 編譯器,所以 gcc 提供了兩個目標文件 crtbeginT.o 和 crtend.o 來實現 C++ 的全局構造和析構 – 實際上以上兩個高亮出來的函數就是 gcc 提供的,有興趣的讀者可以自己翻閱 gcc 源代碼進一步深入學習。

幾組概念的辨析

動態鏈接的可執行文件和共享庫文件的區別

問題: 可執行文件和動態庫之間的區別?我們在第一節中提到過動態鏈接的可執行文件和動態庫文件 file 命令的查看結果是類似的,都是 shared object,一個不同之處在於可執行文件指明瞭解釋器 intepreter:

可執行文件和動態庫之間的區別,簡單來說:可執行文件中有 main 函數,動態庫中沒有 main 函數,可執行文件可以被程序執行,動態庫需要依賴程序調用者。

在可執行文件的所有符號中,main 函數是一個很特別的函數,對 C/C++ 程序開發人員來說,main 函數是整個程序的起點;但是,main 函數卻不是程序啓動後真正首先執行的代碼。

除了由程序員編寫的源代碼編譯成目標文件進而鏈接到程序內存映射,還有一部分機器指令代碼是在鏈接過程中添加到程序內存映射中。比如,程序的啓動代碼,放在內存映射的起始處,在執行 main 函數之前執行以及在程序終止後完成一些任務編譯動態庫時,鏈接器沒有添加這部分代碼。這是可執行文件和動態庫之間的區別。

靜態鏈接 / 動態鏈接的可執行文件的第一條指令地址

我們之前提到過,靜態鏈接的可執行文件的其實地址就是本文件的_strat,即 readelf -h 所得到的的起始地址。對於一個 hello 程序:

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

int main(){
    printf("Hellow World.\n");
    return 0;
}

我們先用選項 - static 來靜態鏈接它,得到 hello-st:

gcc -static hello.c -o hello-st

我們先用 file 命令看一下:

它是靜態鏈接的可執行文件。

我們用 readelf -h 查看其入口地址,並在 gdb 中 starti 查看它實際的第一條指令的地址:

可以看到,與我們的預期是一致的,確是是從文件本身真正的入口地址 entry point0x400a50 開始執行第一條指令。而在動態鏈接的可執行文件中,我們將看到不同。

我們現在動態鏈接(默認)編譯 hello 程序得到 hello-dy:

gcc hello.c -o hello-dy

還是先來 file 一下:

我們看到 hello-dy 是一個動態鏈接的共享目標文件,當然它也是可執行的,共享庫文件和可執行的共享目標文件的區別我們上面已經介紹過了。大家注意,這裏還多了一個奇怪的傢伙:解釋器,interpreter /lib64/ld-linux-x86-64.so.2。

實際上,它就是動態鏈接文件的鏈接加載器。我們之前已經介紹過,在動態鏈接的可執行文件中,外部符號的地址在程序加載、運行的過程中才被確定下來。這個鏈接加載器 ld 就是負責完成這個工作的。當 ld 將外部符號的地址都確定好之後,纔將指令指針執行程序本身的_start。也就是說,在動態鏈接的可執行文件中,第一條指令應該在鏈接加載器 ld 中。我們接下來還是通過 readelf -h 和 gdb 來驗證一下。

可以看到,我們的動態鏈接的可執行程序的第一條指令的地址並不是本文件的 entry point 0x530,而是鏈接加載器 ld 的第一條指令_start 的地址 0x7ffff7dd4090。

這就驗證了我們上面的說法:動態鏈接的可執行文件的第一條指令是鏈接加載器的程序入口,它會完成外部符號地址的綁定,然後將控制權交還給程序本身,開始執行。

靜態庫和共享庫

庫:有時候需要把一組代碼編譯成一個庫,這個庫在很多項目中都要用到,例如 libc 就是這樣一個庫,我們在不同的程序中都會用到 libc 中的庫函數(例如 printf)。

共享庫和靜態庫的區別:在鏈接 libc 共享庫時只是指定了動態鏈接器和該程序所需要的庫文件,並沒有真的做鏈接,可執行文件調用的 libc 庫函數仍然是未定義符號,要在運行時做動態鏈接。而在鏈接靜態庫時,鏈接器會把靜態庫中的目標文件取出來和可執行文件真正鏈接在一起。

靜態庫好處:靜態庫中存在很多部分,鏈接器可以從靜態庫中只取出需要的部分來做鏈接 (比如 main.c 需要 stach.c 其中的一個函數,而 stach.c 中有 4 個函數,則打包庫後,只會鏈接用到那個函數)。另一個好處就是使用靜態庫只需寫一個庫文件名,而不需要寫一長串目標文件名。

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