從Execve到進程運行:ELF加載的內核實現詳解
在 Linux 的世界里,ELF 文件就像是一個神秘的 “魔法盒子”,承載著程序從源代碼到可執行實體的關鍵信息,而 Linux 進程則是這些信息在系統中鮮活運行的體現。從 ELF 文件的結構剖析,到它如何通過編譯鏈接成為可執行文件,再到 Linux 進程的創建以及 ELF 文件在其中的加載過程,每一個環節都充滿了奇妙的技術細節。
理解從 ELF 文件到 Linux 進程的轉化過程,對于 Linux 開發者、系統管理員以及對計算機底層原理感興趣的技術愛好者來說,都具有重要的意義。它不僅有助于我們優化程序的編譯、鏈接和運行效率,還能在程序出現問題時,通過深入分析 ELF 文件和進程狀態,快速定位和解決問題。例如,在調試程序時,了解 ELF 文件中的符號表和重定位信息,可以幫助我們準確地找到程序中的錯誤代碼位置;在系統性能優化方面,掌握進程的內存布局和動態鏈接機制,可以讓我們更好地管理系統資源,提高程序的運行效率。今天,就讓我們一起揭開從 ELF 文件到 Linux 進程的神秘面紗,深入了解它們之間的奇妙轉化。
一、ELF 文件:Linux 世界的 “靈魂容器”
1.1什么是 ELF 文件
ELF,全稱是 Executable and Linkable Format,即可執行與可鏈接格式 ,是 Linux 下二進制文件的標準格式。就像 Windows 系統中大家熟悉的.exe 文件是可執行程序的載體一樣,ELF 文件在 Linux 系統中承擔著同樣的角色,并且功能更為豐富。
ELF 文件不僅僅局限于可執行文件,它還涵蓋了多種類型:
- 可執行文件:這是我們日常使用的程序,比如常見的命令行工具ls、grep等,或者是我們自己編譯生成的可執行程序。當我們在終端輸入命令運行它們時,系統就會依據 ELF 文件的內容將其轉化為運行中的進程。
- 可重定位文件:通常是編譯過程中生成的目標文件,以.o 為擴展名。這類文件包含了機器代碼和數據,但它們的地址是相對的,還需要經過鏈接過程才能最終成為可執行文件 。比如我們編寫一個簡單的 C 程序,使用gcc -c命令編譯后生成的.o 文件就是可重定位文件。
- 共享對象文件:也就是我們常說的動態鏈接庫,以.so 為擴展名,類似于 Windows 下的.dll 文件。共享對象文件可以在多個程序之間共享代碼和數據,大大節省了系統資源。許多大型軟件項目都會依賴各種共享庫,比如圖形界面程序可能依賴于 GTK 庫,科學計算程序可能依賴于 BLAS、LAPACK 等數學庫。
- 核心轉儲文件:當程序出現異常崩潰時,操作系統會生成一個核心轉儲文件,它記錄了程序崩潰時的內存狀態、寄存器值等信息,對于開發者調試程序非常有幫助。通過分析核心轉儲文件,我們可以找到程序崩潰的原因,比如空指針引用、數組越界等問題。
1.2 ELF 的 “身份證”
在 Linux 系統中,判斷一個文件是否為 ELF 格式其實非常簡單,使用file命令就可以輕松做到。例如,我們想要查看/bin/ls這個文件,在終端輸入file /bin/ls,得到的輸出結果開頭如果是 “ELF”,那就說明它是 ELF 格式的文件 ,如/bin/ls: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, ... 。
從更底層的原理來講,ELF 文件有一個獨特的標識 —— 魔數(Magic Number)。通過hexdump -C -n 16 /bin/ls命令查看/bin/ls文件的前 16 個字節,會看到類似00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|的輸出 ,其中最開始的7f 45 4c 46就是 ELF 文件的魔數,45 4c 46對應的正是 ASCII 碼中的 “ELF” 三個字母,而前面的7f是一個特殊字符 。操作系統在識別文件時,首先就會檢查這四個字節,如果匹配,就可以確定它是一個 ELF 文件,所以說魔數就像是 ELF 文件的 “身份證” 。
1.3 ELF 文件的內部結構剖析
ELF 文件的結構就像是一座精心規劃的大廈,每個部分都有其獨特的功能和作用,共同協作使得程序能夠順利地從磁盤走向內存,最終在 CPU 上運行。
圖片
(1)文件頭(ELF Header)
文件頭就像是這本書的封面和目錄,位于 ELF 文件的開頭,固定占據一定的字節數(32 位系統通常是 52 字節,64 位系統通常是 64 字節 )。它包含了許多關鍵信息,這些信息是理解整個 ELF 文件的基礎。
- 魔數(Magic Number):文件頭的前 4 個字節是魔數,固定為 0x7f 45 4c 46,其中 45 4c 46 分別對應 ASCII 碼中的 'E'、'L'、'F',前面的 0x7f 是一個特殊字符。魔數就像是 ELF 文件的 “身份證”,操作系統在加載文件時,首先會檢查這個魔數,以確定該文件是否為合法的 ELF 文件。
- 文件類型:它表明了該 ELF 文件屬于可執行文件、可重定位文件、共享對象文件還是核心轉儲文件等。通過這個信息,系統可以知道如何處理這個文件。
- 目標機器架構:告知系統該 ELF 文件是為哪種 CPU 架構編譯的,比如常見的 x86_64、ARM 等。不同的 CPU 架構有不同的指令集和寄存器結構,了解目標機器架構能確保系統正確地解釋和執行 ELF 文件中的代碼。
- 入口點地址:程序執行的起始地址,當 ELF 文件被加載到內存并準備執行時,CPU 會從這個地址開始讀取和執行指令。
- 程序頭表和節區頭表的偏移:這兩個偏移量分別指示了程序頭表和節區頭表在 ELF 文件中的位置。通過這些偏移量,系統可以方便地找到并解析這兩個重要的表。
(2)程序頭表(Program Header Table)
程序頭表就像是給操作系統的指南,它由一系列的程序頭(Program Header)組成,每個程序頭描述了一個段(Segment)的信息。這些段告訴操作系統如何將程序加載到內存中執行。只有可執行文件和共享庫中存在程序頭表,目標文件中是沒有的。
每個程序頭是一個結構體,在 64 位系統中通常是 Elf64_Phdr 結構 ,包含以下重要字段:
- 段類型(p_type):指示該段的類型,常見的類型有 LOAD(可加載段,包含代碼或數據)、INTERP(指定程序解釋器,即動態鏈接器的路徑)、DYNAMIC(包含動態鏈接相關信息)等。
- 段標志(p_flags):描述段的權限和屬性,如可讀(R)、可寫(W)、可執行(E)等。比如代碼段通常具有可讀和可執行權限,數據段可能具有可讀和可寫權限。
- 文件偏移(p_offset):表示該段在 ELF 文件中的起始偏移位置,通過這個偏移量,系統可以在文件中準確找到該段的內容。
- 虛擬地址(p_vaddr):段被加載到內存后的虛擬地址,操作系統會根據這個地址將段映射到相應的內存區域。
- 文件大小(p_filesz):段在 ELF 文件中的大小,即實際存儲在文件中的字節數。
- 內存大小(p_memsz):段在內存中占用的大小,有些段在內存中可能需要額外的空間,比如.bss 段在文件中通常不占用空間,但在內存中需要為未初始化的變量分配空間。
在進程創建過程中,操作系統會讀取程序頭表,找到所有類型為 LOAD 的段,并按照段的信息將它們加載到內存中,為進程的運行做好準備。
(3)節區頭表(Section Header Table)
節區頭表是給鏈接器、調試器等工具看的指南,它描述了 ELF 文件中各個節區(Section)的信息。節區頭表由多個節區頭(Section Header)組成,每個節區頭是一個結構體,包含了節區的名稱、類型、地址、大小、偏移量等信息。
通過節區頭表,鏈接器可以在鏈接過程中準確地找到各個目標文件中的節區,并將它們合并和重定位,生成最終的可執行文件或共享庫。調試器也可以利用節區頭表中的信息,獲取調試符號、源代碼行號等調試信息,方便開發者調試程序。
節區(Sections)與段(Segments)
節區是 ELF 文件存儲的基本單位,針對鏈接器而言;段是運行時內存的基本單位,針對加載器而言。可以把段看作是一個或多個功能相似的節區的集合。常見的節區有:
- .text:代碼節區,存放程序的可執行代碼,通常具有可讀和可執行權限。
- .data:已初始化數據節區,存儲已經初始化的全局變量和靜態變量。
- .bss:未初始化數據節區,用于存放未初始化的全局變量和靜態變量,在 ELF 文件中,.bss 節區通常不占用實際的磁盤空間,因為它只需要在內存中為這些未初始化變量分配空間。
- .rodata:只讀數據節區,存放只讀數據,比如字符串常量、常量數組等 。
- .symtab:符號表節區,記錄了程序中定義和引用的符號信息,包括函數名、變量名、全局變量、局部變量等,以及它們的地址、類型等。符號表在鏈接過程中起著關鍵作用,鏈接器通過符號表來解析外部符號的引用。
- .strtab:字符串表節區,保存了符號表中符號的名字以及其他一些字符串信息。由于字符串的長度不一,為了方便管理,將所有字符串集中存儲在這個節區,通過偏移量來訪問具體的字符串。
這些節區會根據其功能和屬性被劃分到不同的段中,比如.text 節區通常會被包含在代碼段中,.data 和.bss 節區會被包含在數據段中 。
二、從 ELF 文件到 Linux 進程
2.1ELF 文件的編譯與鏈接
在 Linux 系統中,從我們編寫的源代碼到最終生成可執行的 ELF 文件,需要經過編譯和鏈接兩個重要階段。這兩個階段就像是一場精密的制造過程,將人類可讀的代碼轉化為計算機能夠理解和執行的指令。
(1)編譯階段:當我們使用 C、C++ 等編程語言編寫好源代碼后,首先要進行的就是編譯。以 C 語言為例,我們通常使用 GCC(GNU Compiler Collection)編譯器。假設我們有一個簡單的 C 程序hello.c:
#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}使用命令gcc -c hello.c進行編譯,這里的-c選項表示只進行編譯,不進行鏈接。編譯過程主要分為以下幾個步驟:
- 預處理:預處理器首先對源代碼進行處理,它會展開頭文件(比如這里的stdio.h),處理宏定義(如果有宏的話),移除注釋等。經過預處理后的代碼會包含所有展開后的內容,方便后續的編譯操作。
- 編譯:編譯器將預處理后的代碼轉化為匯編代碼。對于上面的hello.c,會生成對應的hello.s匯編文件。匯編代碼是一種低級的人類可讀代碼,它與機器指令有著緊密的對應關系,不同的 CPU 架構有不同的匯編語言。
- 匯編:匯編器將匯編代碼進一步轉化為目標文件,通常以.o 為擴展名,這里會生成hello.o。目標文件是一種中間格式,它包含了機器代碼,但這些代碼的地址還不是最終可執行的地址,是相對地址,并且還需要解決外部符號的引用等問題 。目標文件中除了機器代碼外,還包含了符號表、重定位信息等。符號表記錄了程序中定義和引用的符號(如函數名、變量名)及其相關信息;重定位信息則用于在鏈接階段調整代碼和數據的地址,使其能夠正確地運行。
(2)鏈接階段:鏈接的主要作用是將多個目標文件以及所需的庫文件合并成一個可執行文件。鏈接分為靜態鏈接和動態鏈接兩種方式。
①靜態鏈接:在靜態鏈接過程中,鏈接器會將程序所依賴的所有靜態庫文件(通常以.a 為擴展名)中的相關代碼和數據直接拷貝到生成的可執行文件中。這樣生成的可執行文件是一個獨立的文件,不依賴外部的庫文件就可以運行。例如,我們有兩個源文件main.c和func.c,func.c中定義了一個函數add,main.c中調用了這個函數:
// func.c
int add(int a, int b) {
return a + b;
}
// main.c
#include <stdio.h>
int add(int a, int b);
int main() {
int result = add(3, 5);
printf("The result is: %d\n", result);
return 0;
}首先分別編譯這兩個文件:gcc -c main.c和gcc -c func.c,生成main.o和func.o。然后進行靜態鏈接:gcc -o main main.o func.o,這里的-o選項指定生成的可執行文件名為main。在這個過程中,鏈接器會將func.o中的add函數代碼直接合并到main可執行文件中。靜態鏈接的優點是可執行文件的運行比較獨立,不依賴外部庫,移植性好;缺點是生成的可執行文件體積較大,因為它包含了所有依賴的庫代碼,如果多個程序都依賴同一個庫,會導致磁盤空間和內存的浪費。
②動態鏈接:動態鏈接則是在程序運行時才加載和鏈接所需的共享庫文件(以.so 為擴展名)。鏈接器在鏈接時并不會將共享庫的代碼直接拷貝到可執行文件中,而是記錄下對共享庫中符號的引用信息。當程序運行時,動態鏈接器(通常是/lib/ld-linux.so.2等)會根據這些引用信息,在系統中找到對應的共享庫文件,并將其加載到內存中,然后完成符號的解析和重定位,使程序能夠正確地調用共享庫中的函數。
繼續以上面的例子,如果add函數是在一個共享庫libfunc.so中定義的,我們可以這樣進行動態鏈接:首先編譯生成共享庫gcc -shared -fPIC -o libfunc.so func.c,其中-shared表示生成共享庫,-fPIC表示生成位置無關代碼(Position - Independent Code),這是共享庫所必需的。然后編譯main.c并鏈接共享庫gcc -o main main.o -L. -lfunc,這里-L.表示在當前目錄查找庫文件,-lfunc表示鏈接名為libfunc.so的共享庫。動態鏈接的優點是節省磁盤空間和內存,多個程序可以共享同一個共享庫;缺點是程序的運行依賴于共享庫,如果共享庫的版本不兼容或者缺失,可能會導致程序運行出錯。
2.2Linux 進程的創建
當 ELF 文件準備就緒后,它還需要經歷從磁盤到內存,從靜態文件到動態進程的轉變,才能真正在系統中運行起來,與我們進行交互。在 Linux 系統中,創建進程最常用的方式是使用fork函數。fork函數是一個系統調用,它的作用是創建一個新的進程,這個新進程被稱為子進程,而調用fork的進程則是父進程。子進程幾乎是父進程的一個副本,它會繼承父進程的大部分資源,如文件描述符、內存空間(通過寫時拷貝技術實現,父子進程在寫入數據前共享同一塊物理內存,當有一方進行寫入操作時才會復制一份物理內存)、信號處理方式等,但也有一些不同,比如進程 ID(PID)不同,父進程 ID(PPID)不同等。fork函數的返回值很特別,在父進程中,它返回子進程的 PID;在子進程中,它返回 0;如果創建進程失敗,它返回一個負數。
下面是一個簡單的示例代碼:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main() {
pid_t pid;
pid = fork();
if (pid < 0) {
perror("fork error");
return 1;
} else if (pid == 0) {
// 子進程
printf("I am the child process, my pid is %d, my parent's pid is %d\n", getpid(), getppid());
} else {
// 父進程
printf("I am the parent process, my pid is %d, the child's pid is %d\n", getpid(), pid);
}
return 0;
}在這個例子中,當fork函數被調用后,系統會創建一個子進程。父進程和子進程從fork函數返回后,根據返回值的不同,分別執行不同的代碼塊。這種機制使得我們可以方便地創建多進程程序,實現并發處理任務。例如,在一個 Web 服務器程序中,父進程可以負責監聽端口,接受客戶端連接,然后創建子進程來處理每個客戶端的請求,這樣可以同時處理多個客戶端的并發請求,提高服務器的性能。
2.3 ELF 文件的加載
當我們在終端中輸入命令運行一個 ELF 可執行文件時,系統會啟動一個新的進程,并將 ELF 文件加載到這個進程的內存空間中。具體的加載流程如下:
- 創建新進程:首先,通過fork函數創建一個新的子進程。這個子進程繼承了父進程的一些環境信息,如當前工作目錄、用戶 ID 等。
- 加載 ELF 文件:子進程調用exec函數族(如execve等)來執行 ELF 文件。exec函數會替換當前進程的代碼段、數據段、堆和棧等,將 ELF 文件的內容加載到內存中。在加載過程中,會讀取 ELF 文件的程序頭表,根據程序頭表中描述的段信息,將各個段(如代碼段、數據段、只讀數據段等)加載到內存的相應位置。例如,程序頭表中會指定代碼段的虛擬地址、文件偏移、大小等信息,加載器會根據這些信息將代碼段從 ELF 文件中讀取到內存中對應的虛擬地址處。
- 動態鏈接(如果是動態鏈接的 ELF 文件):如果 ELF 文件是動態鏈接的,在加載過程中還會涉及到動態鏈接的步驟。首先,加載器會根據 ELF 文件中INTERP段指定的路徑,找到動態鏈接器(通常是/lib/ld-linux.so.2等),并將其加載到內存中。然后,動態鏈接器會解析 ELF 文件中的動態鏈接信息,找到并加載程序所依賴的共享庫文件。動態鏈接器會在共享庫文件中查找程序所引用的符號(函數、變量等),并將這些符號的地址解析出來,填充到 ELF 文件的相應位置(通過重定位操作),使得程序能夠正確地調用共享庫中的函數。例如,在前面提到的使用共享庫libfunc.so的例子中,動態鏈接器會在運行時找到libfunc.so,解析其中add函數的地址,并將這個地址填充到main程序中調用add函數的地方,這樣main程序就能夠正確地調用libfunc.so中的add函數了。
- 啟動進程:當 ELF 文件和所有依賴的共享庫都加載完成,并且動態鏈接也完成后,系統會將程序的入口點地址(在 ELF 文件頭中指定)設置為進程的執行起始地址,然后開始執行程序的第一條指令,至此,ELF 文件成功地轉變為一個正在運行的 Linux 進程,它可以與系統和用戶進行交互,完成各種任務。
代碼示例如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
// 簡單的ELF程序: 被加載執行的程序
void create_target_elf() {
FILE *f = fopen("target_elf.c", "w");
if (!f) {
perror("無法創建目標文件");
return;
}
fprintf(f, "#include <stdio.h>\n");
fprintf(f, "int main() {\n");
fprintf(f, " printf(\"這是被加載的ELF程序在運行\\n\");\n");
fprintf(f, " printf(\"進程ID: %%d\\n\", getpid());\n");
fprintf(f, " return 0;\n");
fprintf(f, "}\n");
fclose(f);
// 編譯生成ELF可執行文件
system("gcc target_elf.c -o target_elf");
}
int main() {
// 創建一個簡單的ELF可執行文件
create_target_elf();
printf("父進程開始運行, PID: %d\n", getpid());
// 步驟1: 創建新進程(fork)
pid_t pid = fork();
if (pid < 0) {
// fork失敗
perror("fork失敗");
return 1;
}
else if (pid == 0) {
// 步驟2: 子進程 - 加載并執行ELF文件(exec)
printf("子進程創建成功, PID: %d\n", getpid());
printf("子進程開始加載ELF文件...\n");
// 執行ELF文件, 這會替換當前進程的代碼段、數據段等
char *args[] = {"./target_elf", NULL};
if (execvp(args[0], args) == -1) {
perror("execvp執行失敗");
exit(EXIT_FAILURE);
}
// 注意: execvp成功的話, 下面的代碼不會執行
printf("這行代碼不會被執行\n");
}
else {
// 父進程
printf("等待子進程完成...\n");
int status;
waitpid(pid, &status, 0); // 等待子進程結束
printf("子進程已結束, 父進程退出\n");
}
return 0;
}編譯并運行這個程序,可以看到整個流程的輸出,包括父進程 ID、子進程 ID,以及被加載的 ELF 程序的運行情況。這直觀地展示了從命令輸入到 ELF 文件成為運行中進程的整個過程。
三、ELF 文件與 Linux 進程的深度關聯
3.1ELF 文件對進程的重要性
ELF 文件對于 Linux 進程而言,就如同基石對于高樓大廈,是進程得以存在和正常運行的根本。它為進程提供了運行所需的代碼和數據,其結構信息則像精準的導航圖,指導著進程的創建和內存布局。
從代碼層面來看,ELF 文件中的.text 節區存放著可執行代碼,這些代碼是進程執行各種任務的核心指令集。當進程啟動時,CPU 會從 ELF 文件指定的入口點地址開始,讀取并執行.text 節區中的代碼。例如,一個簡單的 C 程序經過編譯鏈接生成 ELF 可執行文件后,程序中的函數調用、變量操作等邏輯都以機器指令的形式存儲在.text 節區。當這個 ELF 文件被加載為進程運行時,CPU 會按照指令順序依次執行,實現程序的功能,如打印信息、計算數據等。
在數據方面,.data 節區保存了已初始化的全局變量和靜態變量,.bss 節區為未初始化的全局變量和靜態變量預留了內存空間。這些數據對于進程的運行狀態和功能實現至關重要。比如一個記錄用戶登錄信息的全局變量,就可能存儲在.data 節區,進程在運行過程中可以隨時讀取和修改這個變量,以實現用戶認證、權限管理等功能。
ELF 文件的程序頭表和節區頭表在進程創建和內存布局中發揮著關鍵作用。程序頭表中的 LOAD 類型段,詳細描述了如何將 ELF 文件中的段加載到內存中,包括段的文件偏移、虛擬地址、大小、權限等信息。操作系統根據這些信息,將代碼段、數據段等加載到內存的相應位置,并為其分配合適的權限(如代碼段可讀可執行,數據段可讀可寫等),從而構建起進程的內存映像。節區頭表則為鏈接器和調試器提供了重要信息,在進程的創建和調試過程中,幫助相關工具準確地定位和處理各個節區的內容。
3.2進程運行時對 ELF 文件的依賴
當進程處于運行時,它對 ELF 文件的依賴依然緊密,尤其是在動態鏈接共享庫方面。對于動態鏈接的 ELF 文件,進程在運行過程中需要動態加載和鏈接所需的共享庫文件。這一過程是由動態鏈接器負責完成的。
以一個使用了libc.so庫的程序為例,當該程序對應的 ELF 文件被加載為進程后,動態鏈接器會首先根據 ELF 文件中記錄的共享庫依賴信息,在系統中查找并加載libc.so共享庫。在查找共享庫時,動態鏈接器會按照一定的路徑順序進行搜索,通常會先在/lib和/usr/lib等系統默認目錄中查找,如果沒有找到,還會根據環境變量LD_LIBRARY_PATH指定的路徑進行查找。
在共享庫加載完成后,動態鏈接器需要解析共享庫中程序所引用的符號(如函數、變量等),并將這些符號的地址解析出來,填充到 ELF 文件的相應位置,這個過程稱為重定位。而在這個過程中,GOT(Global Offset Table,全局偏移表)起著關鍵作用。GOT 表是 ELF 文件中的一個重要數據結構,用于存儲外部符號的地址。當程序調用一個共享庫中的函數時,程序代碼中并不會直接包含該函數的實際地址,而是通過 GOT 表來間接獲取。
具體來說,當程序第一次調用共享庫中的某個函數時,程序會跳轉到該函數對應的 GOT 表項。如果這是第一次調用,GOT 表項中存儲的可能是一個指向動態鏈接器中重定位函數的地址。動態鏈接器會根據這個重定位函數,找到共享庫中該函數的實際地址,并將其填充到 GOT 表項中。這樣,下次程序再調用該函數時,就可以直接從 GOT 表中獲取函數的實際地址,從而實現快速調用。這種機制實現了函數的延遲綁定,只有在函數第一次被調用時才進行地址解析和綁定,提高了程序的加載效率和靈活性 。例如,在一個圖形渲染程序中,可能會頻繁調用共享庫中的圖形繪制函數,通過 GOT 表的延遲綁定機制,可以在程序啟動時快速加載,而不必在一開始就解析所有圖形繪制函數的地址,只有在真正需要繪制圖形時才進行地址綁定,大大提高了程序的啟動速度和運行效率。
3.3ELF 加載與進程運行
(1)進程地址空間的構建
ELF 文件的加載過程與進程地址空間的構建密切相關。當內核加載 ELF 文件時,會根據 ELF 文件頭和程序頭表的信息,在進程的虛擬地址空間中為各個段分配相應的內存區域 。
進程的虛擬地址空間通常可以分為多個部分,每個部分都有其特定的用途和權限,并且這些部分與 ELF 文件中的段存在著明確的對應關系 :
- 代碼段(Text Segment):對應 ELF 文件中的.text節,它包含了程序的可執行機器代碼 。在進程地址空間中,代碼段通常被映射到一個只讀且可執行的區域,這樣可以防止程序運行時對代碼段的意外修改,確保代碼的完整性和穩定性 。例如,當我們運行一個 C 語言編寫的程序時,經過編譯和鏈接生成的 ELF 文件中的.text節會被加載到進程地址空間的代碼段區域,CPU 從這里讀取指令并執行 。
- 數據段(Data Segment):對應 ELF 文件中的.data節,存放已初始化的全局變量和靜態變量 。數據段在進程地址空間中是可讀可寫的,因為程序在運行過程中可能需要修改這些變量的值 。比如在一個程序中定義了一個全局變量int global_var = 10;,這個變量就會被存儲在數據段中,程序運行時可以對global_var進行讀寫操作 。
- BSS 段(Block Started by Symbol):對應 ELF 文件中沒有實際內容的.bss節,用于存放未初始化的全局變量和靜態變量 。BSS 段在程序運行時會被自動初始化為 0,并且它不占用磁盤空間,只在內存中分配空間 。這是因為在編譯時,未初始化的變量并不需要實際的數據存儲,只需要在運行時為它們分配內存并初始化為 0 即可 。例如,定義一個未初始化的全局變量int uninit_global_var;,它就會被分配到 BSS 段中 。
- 堆(Heap):用于動態內存分配,比如通過malloc、new等函數分配的內存都來自堆區 。堆區在進程地址空間中是向上增長的,它的大小在程序運行過程中可以動態變化 。例如,在一個程序中使用malloc函數分配內存int *ptr = (int *)malloc(10 * sizeof(int));,這 10 個整數大小的內存空間就是從堆區分配得到的 。
- 棧(Stack):用于存放函數調用的上下文信息,包括局部變量、函數參數、返回地址等 。棧區在進程地址空間中是向下增長的,每當一個函數被調用時,會在棧頂為其分配棧幀,函數返回時,棧幀被釋放 。比如在一個函數中定義了局部變量int local_var = 5;,這個局部變量就會被存儲在棧區的當前函數棧幀中 。
- 共享庫映射區域:用于映射動態鏈接庫(共享對象文件) 。當程序依賴于動態鏈接庫時,動態連接器會將這些共享庫加載到該區域 。共享庫映射區域的大小也是動態變化的,并且多個進程可以共享同一個共享庫的內存映射,從而節省內存資源 。例如,很多程序都會依賴 C 標準庫(如libc.so),這個共享庫就會被映射到進程地址空間的共享庫映射區域 。
在內核中,mm_struct和vm_area_struct等數據結構在進程地址空間的構建中起著關鍵作用 。mm_struct結構體代表一個進程的內存管理信息,它包含了指向進程地址空間各個區域的指針,以及與內存管理相關的其他信息 。而vm_area_struct結構體則用于描述進程地址空間中的一個虛擬內存區域(VMA),每個 VMA 都有其起始地址、結束地址、權限標志、所屬的mm_struct等信息 。這些數據結構通過鏈表或紅黑樹等方式組織起來,方便內核進行內存管理和地址空間的操作 ,例如在加載 ELF 文件時,內核會根據 ELF 文件的信息創建相應的vm_area_struct結構體,并將其插入到mm_struct的管理結構中,從而完成進程地址空間的構建 。
(2)程序執行的 “幕后英雄”:頁表與 MMU
當 CPU 執行 ELF 程序時,需要將程序中的虛擬地址轉換為物理地址,這一過程主要依賴于頁表和 MMU(內存管理單元) 。
頁表是一種數據結構,用于實現虛擬地址到物理地址的映射 。它以頁(Page)為單位進行管理,每個頁通常大小為 4KB(在一些系統中也可能是其他大小) 。頁表中存儲了虛擬頁號(VPN)與物理頁號(PPN)的對應關系 。例如,在一個 32 位的系統中,虛擬地址空間為 4GB,若頁大小為 4KB,則虛擬地址空間被劃分為 1048576 個頁 。當程序訪問一個虛擬地址時,CPU 會根據虛擬地址的頁號部分在頁表中查找對應的物理頁號 。
MMU 是 CPU 中的一個硬件單元,它在地址轉換過程中起著至關重要的作用 。當 CPU 執行指令時,會將指令中的虛擬地址發送給 MMU 。MMU 首先會根據 CPU 中的頁表基址寄存器(如 x86 架構中的 CR3 寄存器)找到對應的頁表 ,然后根據虛擬地址的頁號在頁表中查找對應的物理頁號 。如果在頁表中找到了匹配的項(稱為頁表項,PTE),MMU 會將物理頁號與虛擬地址的頁內偏移部分組合起來,形成最終的物理地址,然后將該物理地址發送給內存控制器,以訪問實際的內存數據 。例如,假設虛擬地址為 0x08048000,頁大小為 4KB,虛擬地址的高 20 位表示頁號,低 12 位表示頁內偏移 。MMU 根據頁號在頁表中查找對應的物理頁號,假設找到的物理頁號為 0x10000,那么最終的物理地址就是 0x10000 << 12 | 0x08048000 & 0xFFF = 0x10000000 + 0x8000 = 0x10008000 。
在程序執行過程中,PC(程序計數器)指針不斷推進,指向下一條要執行的指令的虛擬地址 。每當 CPU 執行完一條指令后,PC 指針會自動增加,指向下一條指令 。例如,在一個簡單的匯編程序中,指令mov eax, 1執行完畢后,PC 指針會指向下一條指令的地址,CPU 會根據 PC 指針的值從內存中讀取下一條指令的虛擬地址,并通過 MMU 將其轉換為物理地址,從而繼續執行程序 。如果程序中包含函數調用、跳轉等指令,PC 指針會根據指令的要求進行相應的修改,跳轉到指定的地址繼續執行 ,以實現程序的邏輯控制和流程跳轉 。
四、內核加載 ELF 文件全流程
4.1內核初次 “邂逅” ELF
當execve系統調用被觸發后,內核就開始了 ELF 文件的加載之旅。首先,內核會讀取 ELF 文件的頭部信息,這一步就像是我們打開一本書先看目錄一樣 。
內核通過open系統調用打開指定的 ELF 文件,并將文件描述符傳遞給后續的處理函數 。在load_elf_binary函數中(位于 Linux 內核源碼的fs/binfmt_elf.c文件中 ),會讀取 ELF 文件的前 128 個字節,這部分內容包含了 ELF 文件頭的關鍵信息 。
struct linux_binprm *bprm;
// 讀取ELF文件頭前128字節到bprm->buf中
if (kernel_read(bprm->file, bprm->buf, BINPRM_BUF_SIZE, &pos) != BINPRM_BUF_SIZE) {
return -EIO;
}我們可以使用readelf -h命令來查看 ELF 文件頭的關鍵信息,例如對于/bin/ls文件,執行readelf -h /bin/ls會得到類似如下的輸出:
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x403940
Start of program headers: 64 (bytes into file)
Start of section headers: 56440 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 9
Size of section headers: 64 (bytes)
Number of section headers: 30
Section header string table index: 27從這些信息中,內核可以獲取到文件的類型(如可執行文件、共享庫等 )、目標機器架構(如 x86 - 64、ARM 等 )、程序入口地址、程序頭表和節區頭表的偏移量等關鍵內容 。其中,程序入口地址指明了程序執行的起始位置,內核后續會根據這個地址跳轉到程序的起始處開始執行;程序頭表和節區頭表的偏移量則幫助內核找到對應的表,從而進一步獲取程序的段和節區信息 ,這些信息對于內核后續將 ELF 文件正確地加載到內存并運行起著至關重要的指引作用 。
4.2按圖索驥:加載程序段
內核在讀取并解析了 ELF 文件頭后,就會根據程序頭表(Program Header Table)中的信息,將 ELF 文件中的各個段加載到內存中 。程序頭表就像是一份詳細的 “裝載指南”,它描述了每個段在文件中的位置、大小以及被放進內存后所在的位置和大小等信息 。
在程序頭表中,類型為PT_LOAD的段表示需要被加載到內存中的段 。內核會遍歷程序頭表,查找所有類型為PT_LOAD的段,并使用do_mmap函數將這些段的內容映射到進程的虛擬地址空間中 。
struct elf_phdr *elf_ppnt;
elf_ppnt = elf32_getphdr(bprm);
for (i = 0; i < elf_ex.e_phnum; i++, elf_ppnt++) {
if (elf_ppnt->p_type == PT_LOAD) {
unsigned long vaddr = elf_ppnt->p_vaddr;
unsigned long memsz = elf_ppnt->p_memsz;
unsigned long filesz = elf_ppnt->p_filesz;
unsigned long offset = elf_ppnt->p_offset;
int prot = 0;
if (elf_ppnt->p_flags & PF_R) prot |= PROT_READ;
if (elf_ppnt->p_flags & PF_W) prot |= PROT_WRITE;
if (elf_ppnt->p_flags & PF_X) prot |= PROT_EXEC;
// 使用do_mmap將段映射到內存
mm->mmap = do_mmap(file, offset, filesz, prot, MAP_PRIVATE, vma->vm_pgoff);
if (IS_ERR(mm->mmap)) {
return PTR_ERR(mm->mmap);
}
// 如果內存大小大于文件大小,對多出的內存進行清零初始化
if (memsz > filesz) {
vm = vma_merge(mm, vma, vaddr, vaddr + memsz, prot, NULL, NULL, 0);
if (!vm) {
return -ENOMEM;
}
vma = vm;
memset((char *)vma->vm_start + filesz, 0, memsz - filesz);
}
}
}在這個過程中,p_vaddr指定了段在虛擬內存中的起始地址,p_memsz表示段在內存中的大小,p_filesz是段在文件中的大小,p_offset是段在文件中的偏移量 。p_flags則定義了段的權限,如PF_R表示可讀,PF_W表示可寫,PF_X表示可執行 。內核根據這些信息,通過do_mmap函數在進程的虛擬地址空間中為段分配內存,并將段的內容從文件中讀取到分配的內存區域 。如果段在內存中的大小大于在文件中的大小,內核會將多出的內存區域清零初始化 ,以確保內存中的數據是符合程序預期的 。
4.3尋找動態連接器
對于動態鏈接的 ELF 文件,內核在加載完程序段后,還需要找到并加載動態連接器(Dynamic Linker) 。動態連接器在程序運行過程中起著至關重要的作用,它負責解析程序對共享庫的依賴,并將所需的共享庫加載到內存中,同時還處理程序中的符號重定位等工作 。
內核通過分析 ELF 文件中的.interp段來獲取動態連接器的名稱 。.interp段是一個字符串類型的段,它包含了動態連接器的路徑名 。例如,在常見的 x86 - 64 架構的 Linux 系統中,動態連接器的路徑通常是/lib64/ld-linux-x86-64.so.2 。
struct elf_phdr *interp_elf_ppnt;
interp_elf_ppnt = elf32_getphdr(bprm);
for (i = 0; i < elf_ex.e_phnum; i++, interp_elf_ppnt++) {
if (interp_elf_ppnt->p_type == PT_INTERP) {
char *interp_path = kmalloc(interp_elf_ppnt->p_filesz, GFP_KERNEL);
if (!interp_path) {
return -ENOMEM;
}
// 讀取.interp段內容,獲取動態連接器路徑
if (kernel_read(bprm->file, interp_path, interp_elf_ppnt->p_filesz, &interp_elf_ppnt->p_offset) != interp_elf_ppnt->p_filesz) {
kfree(interp_path);
return -EIO;
}
// 加載動態連接器
if (load_elf_interp(interp_path, bprm) < 0) {
kfree(interp_path);
return -EINVAL;
}
kfree(interp_path);
break;
}
}內核找到動態連接器的路徑后,會使用load_elf_interp函數來加載動態連接器 。這個過程類似于加載普通的 ELF 文件,內核會為動態連接器分配內存空間,并將其代碼和數據加載到內存中 。加載完成后,動態連接器就開始接管程序的后續初始化和運行工作 。
4.4動態鏈接與重定位
動態連接器加載到內存后,會首先檢查程序對共享庫的依賴關系 。它通過解析 ELF 文件中的動態段(Dynamic Section)來獲取程序所依賴的共享庫列表 。動態段中包含了一系列的標記(Tags),其中DT_NEEDED標記用于指定程序所依賴的共享庫名稱 。
動態連接器會根據這些依賴信息,在系統中查找并加載相應的共享庫 。它會按照一定的搜索路徑來查找共享庫,首先會檢查環境變量LD_LIBRARY_PATH指定的目錄,如果沒有找到,則會查找/etc/ld.so.cache中的緩存路徑,最后會查找默認的庫路徑(如/lib和/usr/lib) 。
在加載共享庫的過程中,動態連接器還會進行重定位(Relocation)操作 。由于共享庫在編譯時并不知道它最終會被加載到內存的哪個位置,所以其中的代碼和數據中涉及到的地址都是相對地址 。當共享庫被加載到內存后,動態連接器需要根據其實際的加載地址,對共享庫中的地址引用進行修正,使其指向正確的內存位置,這個過程就是重定位 。
動態連接器通過解析共享庫中的重定位表(Relocation Table)來進行重定位操作 。重定位表中記錄了需要重定位的符號以及對應的重定位類型和偏移量等信息 。例如,對于一個需要重定位的符號,動態連接器會根據重定位表中的信息,找到符號在共享庫中的引用位置,并根據共享庫的加載地址對引用的地址進行修正 。
此外,動態鏈接還具有延遲定位(Lazy Binding)的特性 。在程序開始運行時,動態連接器并不會立即對所有的符號引用進行重定位,而是在第一次使用某個符號時才進行重定位 。這樣可以減少程序啟動時的開銷,提高程序的啟動速度 。例如,一個程序中可能有很多函數調用,在程序啟動時,動態連接器并不會對所有函數調用的符號進行重定位,只有當實際調用某個函數時,才會對該函數的符號引用進行重定位 ,這種延遲定位的機制對于那些包含大量函數調用但在程序啟動時并不需要全部使用的程序來說,能夠顯著提升程序的運行效率 。
4.5程序初始化與啟動
當動態連接器完成共享庫的加載和重定位后,就會執行程序的初始化操作 。它會執行 ELF 文件中.init節的代碼,這個節中的代碼通常用于完成一些程序運行前的初始化工作,比如初始化全局變量、設置信號處理函數等 。
typedef void (*initfn_t)(void);
initfn_t init = (initfn_t)elf_entry;
init();在完成初始化后,動態連接器會將控制傳遞給程序 。它會根據ELF文件頭中指定的程序入口地址,跳轉到程序的入口點,開始執行程序的代碼 。此時,程序就正式開始運行了,我們在終端輸入的命令也終于得以按照程序的邏輯執行,并返回相應的結果 ,整個 ELF 文件的加載過程也至此全部完成,從最初的磁盤文件,到在內存中被正確加載、鏈接和初始化,最終成為一個能夠在系統中正常運行的進程 。
五、ELF文件案例分析
為了更直觀地理解從 ELF 文件到 Linux 進程的轉化過程,讓我們以一個簡單的 C 程序為例,逐步展示這個神奇的旅程。
5.1編寫與編譯 C 程序
首先,我們編寫一個簡單的 C 程序sum.c,它的功能是計算兩個整數的和并輸出結果:
#include <stdio.h>
int main() {
int a = 3;
int b = 5;
int sum = a + b;
printf("The sum of %d and %d is %d\n", a, b, sum);
return 0;
}使用 GCC 編譯器將其編譯為 ELF 可執行文件:
gcc -o sum sum.c這條命令會生成一個名為sum的 ELF 可執行文件,它包含了我們程序的代碼和數據,以及 ELF 文件格式所要求的各種頭部和表結構。
5.2分析 ELF 文件結構
接下來,我們使用readelf命令來分析生成的 ELF 文件sum的結構。
(1)查看 ELF 文件頭:使用readelf -h sum命令查看 ELF 文件頭信息
readelf -h sum輸出結果包含了文件類型(可執行文件)、目標機器架構(如 x86_64)、入口點地址、程序頭表和節區頭表的偏移等重要信息。例如,通過入口點地址,我們可以知道程序從何處開始執行;通過文件類型,我們能明確這是一個可直接運行的可執行文件 。
(2)查看程序頭表:使用readelf -l sum命令查看程序頭表:
readelf -l sum程序頭表列出了各個段的信息,如 LOAD 類型的段,包含了代碼段和數據段的加載信息,包括它們在文件中的偏移、加載到內存的虛擬地址、大小以及權限等。我們可以看到代碼段具有可讀和可執行權限,數據段具有可讀和可寫權限,這與我們之前對 ELF 文件結構的理解是一致的。
(3)查看節區頭表:使用readelf -S sum命令查看節區頭表:
readelf -S sum節區頭表展示了各個節區的詳細信息,如.text 節區存放代碼,.data 節區存放已初始化的數據,.bss 節區為未初始化數據預留空間等。我們還可以看到符號表節區.symtab 和字符串表節區.strtab,它們在程序的鏈接和運行過程中起著關鍵作用,符號表記錄了程序中定義和引用的符號信息,字符串表則保存了符號的名字等字符串信息。
5.3跟蹤進程創建與 ELF 文件加載
為了跟蹤從 ELF 文件到 Linux 進程的創建和加載過程,我們使用strace工具。strace是一個強大的系統調用跟蹤工具,可以監視進程執行時與內核的交互,包括文件操作、進程管理、內存分配等。
使用strace運行我們的sum程序:
strace -f -o sum_trace.txt./sum這里的-f選項表示跟蹤子進程,-o sum_trace.txt表示將跟蹤結果輸出到sum_trace.txt文件中。
在生成的sum_trace.txt文件中,我們可以看到一系列系統調用,其中關鍵的系統調用有:
(1)fork系統調用:在進程創建階段,fork系統調用用于創建一個新的子進程。在strace的輸出中,我們可以看到類似這樣的記錄:
fork() = 23456這里的23456是新創建子進程的 PID。
(2)execve系統調用:execve系統調用負責執行 ELF 文件,它是進程執行的核心系統調用。在strace的輸出中,我們可以找到類似這樣的記錄:
execve("./sum", ["./sum"], 0x7ffd12d4) = 0這表示sum程序被執行,execve的第一個參數是 ELF 文件的路徑,第二個參數是傳遞給程序的參數數組,第三個參數是環境變量。
(3)動態鏈接相關的系統調用:如果我們的程序依賴于共享庫(在這個簡單示例中依賴于libc.so庫),在strace的輸出中,我們可以看到與動態鏈接相關的系統調用,如打開共享庫文件、解析符號等操作。例如:
openat(AT_FDCWD, "/lib64/libc.so.6", O_RDONLY|O_CLOEXEC) = 3這表示系統嘗試打開libc.so.6共享庫文件,文件描述符為 3。隨后還會有一系列與符號解析、重定位相關的系統調用,這些操作確保了程序能夠正確地調用共享庫中的函數。





























