本文來自微信公眾號:開發(fā)內功修煉 (ID:kfngxl),作者:張彥飛 allen
大家好,我是飛哥!
今天我們來思考一個簡單的問題,一個程序是如何在 Linux 上執(zhí)行起來的?
我們就拿全宇宙最簡單的 Hello World 程序來舉例。
#include <stdio.h> int main() { printf("Hello, World!\n"); return 0; }
我們在寫完代碼后,進行簡單的編譯,然后在 shell 命令行下就可以把它啟動起來。
# gcc main.c -o helloworld # ./helloworld Hello, World!
那么在編譯啟動運行的過程中都發(fā)生了哪些事情了呢?今天就讓我們來深入地了解一下。
一、理解可執(zhí)行文件格式
源代碼在編譯后會生成一個可執(zhí)行程序文件,我們先來了解一下編譯后的二進制文件是什么樣子的。
我們首先使用 file 命令查看一下這個文件的格式。
# file helloworld helloworld: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), ...
file 命令給出了這個二進制文件的概要信息,其中 ELF 64-bit LSB executable 表示這個文件是一個 ELF 格式的 64 位的可執(zhí)行文件。x86-64 表示該可執(zhí)行文件支持的 cpu 架構。
LSB 的全稱是 Linux Standard Base,是 Linux 標準規(guī)范。其目的是制定一系列標準來增強 Linux 發(fā)行版的兼容性。
ELF 的全稱是 Executable Linkable Format,是一種二進制文件格式。Linux 下的目標文件、可執(zhí)行文件和 CoreDump 都按照該格式進行存儲。
ELF 文件由四部分組成,分別是 ELF 文件頭 (ELF header)、Program header table、Section 和 Section header table。
接下來我們分幾個小節(jié)挨個介紹一下。
1.1 ELF 文件頭
ELF 文件頭記錄了整個文件的屬性信息。原始二進制非常不便于觀察。不過我們有趁手的工具 - readelf,這個工具可以幫我們查看 ELF 文件中的各種信息。
我們先來看一下編譯出來的可執(zhí)行文件的 ELF 文件頭,使用 --file-header (-h) 選項即可查看。
# readelf --file-header helloworld 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: EXEC (Executable file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x401040 Start of program headers: 64 (bytes into file) Start of section headers: 23264 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 11 Size of section headers: 64 (bytes) Number of section headers: 30 Section header string table index: 29
ELF 文件頭包含了當前可執(zhí)行文件的概要信息,我把其中關鍵的幾個拿出來給大家解釋一下。
Magic:一串特殊的識別碼,主要用于外部程序快速地對這個文件進行識別,快速地判斷文件類型是不是 ELF
Class:表示這是 ELF64 文件
Type:為 EXEC 表示是可執(zhí)行文件,其它文件類型還有 REL(可重定位的目標文件)、DYN(動態(tài)鏈接庫)、CORE(系統(tǒng)調試 coredump 文件)
Entry point address:程序入口地址,這里顯示入口在 0x401040 位置處
Size of this header:ELF 文件頭的大小,這里顯示是占用了 64 字節(jié)
以上幾個字段是 ELF 頭中對 ELF 的整體描述。另外 ELF 頭中還有關于 program headers 和 section headers 的描述信息。
Start of program headers:表示 Program header 的位置
Size of program headers:每一個 Program header 大小
Number of program headers:總共有多少個 Program header
Start of section headers: 表示 Section header 的開始位置。
Size of section headers:每一個 Section header 的大小
Number of section headers: 總共有多少個 Section header
1.2 Program Header Table
在介紹 Program Header Table 之前我們展開介紹一下 ELF 文件中一對兒相近的概念 - Segment 和 Section。
ELF 文件內部最重要的組成單位是一個一個的 Section。每一個 Section 都是由編譯鏈接器生成的,都有不同的用途。例如編譯器會將我們寫的代碼編譯后放到 .text Section 中,將全局變量放到 .data 或者是 .bss Section 中。
但是對于操作系統(tǒng)來說,它不關注具體的 Section 是啥,它只關注這塊內容應該以何種權限加載到內存中,例如讀,寫,執(zhí)行等權限屬性。因此相同權限的 Section 可以放在一起組成 Segment,以方便操作系統(tǒng)更快速地加載。
由于 Segment 和 Section 翻譯成中文的話,意思太接近了,非常不利于理解。所以本文中我就直接使用 Segment 和 Section 原汁原味的概念,而不是將它們翻譯成段或者是節(jié),這樣太容易讓人混淆了。
Program headers table 就是作為所有 Segments 的頭信息,用來描述所有的 Segments 的。。
使用 readelf 工具的 --program-headers(-l)選項可以解析查看到這塊區(qū)域里存儲的內容。
# readelf --program-headers helloworld Elf file type is EXEC (Executable file) Entry point 0x401040 There are 11 program headers, starting at offset 64 Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align PHDR 0x0000000000000040 0x0000000000400040 0x0000000000400040 0x0000000000000268 0x0000000000000268 R 0x8 INTERP 0x00000000000002a8 0x00000000004002a8 0x00000000004002a8 0x000000000000001c 0x000000000000001c R 0x1 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000 0x0000000000000438 0x0000000000000438 R 0x1000 LOAD 0x0000000000001000 0x0000000000401000 0x0000000000401000 0x00000000000001c5 0x00000000000001c5 R E 0x1000 LOAD 0x0000000000002000 0x0000000000402000 0x0000000000402000 0x0000000000000138 0x0000000000000138 R 0x1000 LOAD 0x0000000000002e10 0x0000000000403e10 0x0000000000403e10 0x0000000000000220 0x0000000000000228 RW 0x1000 DYNAMIC 0x0000000000002e20 0x0000000000403e20 0x0000000000403e20 0x00000000000001d0 0x00000000000001d0 RW 0x8 NOTE 0x00000000000002c4 0x00000000004002c4 0x00000000004002c4 0x0000000000000044 0x0000000000000044 R 0x4 GNU_EH_FRAME 0x0000000000002014 0x0000000000402014 0x0000000000402014 0x000000000000003c 0x000000000000003c R 0x4 GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 RW 0x10 GNU_RELRO 0x0000000000002e10 0x0000000000403e10 0x0000000000403e10 0x00000000000001f0 0x00000000000001f0 R 0x1 Section to Segment ming: Segment Sections... 00 01 .interp 02 .interp .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt 03 .init .plt .text .fini 04 .rodata .eh_frame_hdr .eh_frame 05 .init_array .fini_array .dynamic .got .got.plt .data .bss 06 .dynamic 07 .note.gnu.build-id .note.ABI-tag 08 .eh_frame_hdr 09 10 .init_array .fini_array .dynamic .got
上面的結果顯示總共有 11 個 program headers。
對于每一個段,輸出了 Offset、VirtAddr 等描述當前段的信息。Offset 表示當前段在二進制文件中的開始位置,FileSiz 表示當前段的大小。Flag 表示當前的段的權限類型,R 表示可讀、E 表示可執(zhí)行、W 表示可寫。
在最下面,還把每個段是由哪幾個 Section 組成的給展示了出來,比如 03 號段是由“.init .plt .text .fini” 四個 Section 組成的。
1.3 Section Header Table
和 Program Header Table 不一樣的是,Section header table 直接描述每一個 Section。這二者描述的其實都是各種 Section ,只不過目的不同,一個針對加載,一個針對鏈接。
使用 readelf 工具的 --section-headers (-S)選項可以解析查看到這塊區(qū)域里存儲的內容。
# readelf --section-headers helloworld There are 30 section headers, starting at offset 0x5b10: Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align ...... [13] .text PROGBITS 0000000000401040 00001040 0000000000000175 0000000000000000 AX 0 0 16 ...... [23] .data PROGBITS 0000000000404020 00003020 0000000000000010 0000000000000000 WA 0 0 8 [24] .bss NOBITS 0000000000404030 00003030 0000000000000008 0000000000000000 WA 0 0 1 ...... Key to Flags: W (write), A (alloc), X (execute), M (merge), S (strings), I (info), L (link order), O (extra OS processing required), G (group), T (TLS), C (compressed), x (unknown), o (OS specific), E (exclude), l (large), p (processor specific)
結果顯示,該文件總共有 30 個 Sections,每一個 Section 在二進制文件中的位置通過 Offset 列表示了出來。Section 的大小通過 Size 列體現。
在這 30 個 Section 中,每一個都有獨特的作用。我們編寫的代碼在編譯成二進制指令后都會放到 .text 這個 Section 中。另外我們看到 .text 段的 Address 列顯示的地址是 0000000000401040?;貞浨懊嫖覀冊?ELF 文件頭中看到 Entry point address 顯示的入口地址為 0x401040。這說明,程序的入口地址就是 .text 段的地址。
另外還有兩個值得關注的 Section 是 .data 和 .bss。代碼中的全局變量數據在編譯后將在在這兩個 Section 中占據一些位置。如下簡單代碼所示。
//未初始化的內存區(qū)域位于 .bss 段 int data1 ; //已經初始化的內存區(qū)域位于 .data 段 int data2 = 100 ; //代碼位于 .text 段 int main(void) { }
1.4 入口進一步查看
接下來,我們想再查看一下我們前面提到的程序入口 0x401040,看看它到底是啥。我們這次再借助 nm 命令來進一步查看一下可執(zhí)行文件中的符號及其地址信息。-n 選項的作用是顯示的符號以地址排序,而不是名稱排序。
# nm -n helloworld w __gmon_start__ U __libc_start_main@@GLIBC_2.2.5 U printf@@GLIBC_2.2.5 0000000000401040 T _start 0000000000401126 T main
通過以上輸出可以看到,程序入口 0x401040 指向的是 _start 函數的地址,在這個函數執(zhí)行一些初始化的操作之后,我們的入口函數 main 將會被調用到,它位于 0x401126 地址處。
二、用戶進程的創(chuàng)建過程概述
在我們編寫的代碼編譯完生成可執(zhí)行程序之后,下一步就是使用 shell 把它加載起來并運行之。一般來說 shell 進程是通過 fork+execve 來加載并運行新進程的。一個簡單加載 helloworld 命令的 shell 核心邏輯是如下這個過程。
// shell 代碼示例 int main(int argc, char * argv[]) { pid = fork(); if (pid==0){ // 如果是在進程中 //使用 exec 系列函數加載并運行可執(zhí)行文件 execve("helloworld", argv, envp); } else { } }
shell 進程先通過 fork 系統(tǒng)調用創(chuàng)建一個進程出來。然后在子進程中調用 execve 將執(zhí)行的程序文件加載起來,然后就可以調到程序文件的運行入口處運行這個程序了。
這個 fork 系統(tǒng)調用在內核入口是在 kernel / fork.c 下。
//file:kernel/fork.c SYSCALL_DEFINE0(fork) { return do_fork(SIGCHLD, 0, 0, NULL, NULL); }
在 do_fork 的實現中,核心是一個 copy_process 函數,它以拷貝父進程(線程)的方式來生成一個新的 task_struct 出來。
//file:kernel/fork.c long do_fork() { //復制一個 task_struct 出來 struct task_struct *p; p = copy_process(clone_flags, stack_start, stack_size, child_tidptr, NULL, trace); //子任務加入到就緒隊列中去,等待調度器調度 wake_up_new_task(p); }
在 copy_process 函數中為新進程申請 task_struct,并用當前進程自己的地址空間、命名空間等對新進程進行初始化,并為其申請進程 pid。
//file:kernel/fork.c static struct task_struct *copy_process() { //復制進程 task_struct 結構體 struct task_struct *p; p = dup_task_struct(current); //進程核心元素初始化 retval = copy_files(clone_flags, p); retval = copy_fs(clone_flags, p); retval = copy_mm(clone_flags, p); retval = copy_namespaces(clone_flags, p); //申請 pid && 設置進程號 pid = alloc_pid(p-nsproxy-pid_ns); p-pid = pid_nr(pid); p-tgid = p-pid; }
執(zhí)行完后,進入 wake_up_new_task 讓新進程等待調度器調度。
不過 fork 系統(tǒng)調用只能是根據當的 shell 進程再復制一個新的進程出來。這個新進程里的代碼、數據都還是和原來的 shell 進程的內容一模一樣。
要想實現加載并運行另外一個程序,比如我們編譯出來的 helloworld 程序,那還需要使用到 execve 系統(tǒng)調用。
三. Linux 可執(zhí)行文件加載器
其實 Linux 不是寫死只能加載 ELF 一種可執(zhí)行文件格式的。它在啟動的時候,會把自己支持的所有可執(zhí)行文件的解析器都加載上。并使用一個 formats 雙向鏈表來保存所有的解析器。其中 formats 雙向鏈表在內存中的結構如下圖所示。
我們就以 ELF 的加載器 elf_format 為例,來看看這個加載器是如何注冊的。在 Linux 中每一個加載器都用一個 linux_binfmt 結構來表示。其中規(guī)定了加載二進制可執(zhí)行文件的 load_binary 函數指針,以及加載崩潰文件 的 core_dump 函數等。其完整定義如下
//file:include/linux/binfmts.h struct linux_binfmt { int (*load_binary)(struct linux_binprm *); int (*load_shlib)(struct file *); int (*core_dump)(struct coredump_params *cprm); };
其中 ELF 的加載器 elf_format 中規(guī)定了具體的加載函數,例如 load_binary 成員指向的就是具體的 load_elf_binary 函數。這就是 ELF 加載的入口。
//file:fs/binfmt_elf.c static struct linux_binfmt elf_format = { .module = THIS_MODULE, .load_binary = load_elf_binary, .load_shlib = load_elf_library, .core_dump = elf_core_dump, .min_coredump = ELF_EXEC_PAGESIZE, };
加載器 elf_format 會在初始化的時候通過 register_binfmt 進行注冊。
//file:fs/binfmt_elf.c static int __init init_elf_binfmt(void) { register_binfmt(&elf_format); return 0; }
而 register_binfmt 就是將加載器掛到全局加載器列表 - formats 全局鏈表中。
//file:fs/exec.c static LIST_HEAD(formats); void __register_binfmt(struct linux_binfmt * fmt, int insert) { insert ? list_add(&fmt-lh, &formats) : list_add_tail(&fmt-lh, &formats); }
Linux 中除了 elf 文件格式以外還支持其它格式,在源碼目錄中搜索 register_binfmt,可以搜索到所有 Linux 操作系統(tǒng)支持的格式的加載程序。
# grep -r "register_binfmt" * fs/binfmt_flat.c: register_binfmt(&flat_format); fs/binfmt_elf_fdpic.c: register_binfmt(&elf_fdpic_format); fs/binfmt_som.c: register_binfmt(&som_format); fs/binfmt_elf.c: register_binfmt(&elf_format); fs/binfmt_aout.c: register_binfmt(&aout_format); fs/binfmt_script.c: register_binfmt(&script_format); fs/binfmt_em86.c: register_binfmt(&em86_format);
將來在 Linux 在加載二進制文件時會遍歷 formats 鏈表,根據要加載的文件格式來查詢合適的加載器。
四、execve 加載用戶程序
具體加載可執(zhí)行文件的工作是由 execve 系統(tǒng)調用來完成的。
該系統(tǒng)調用會讀取用戶輸入的可執(zhí)行文件名,參數列表以及環(huán)境變量等開始加載并運行用戶指定的可執(zhí)行文件。該系統(tǒng)調用的位置在 fs / exec.c 文件中。
//file:fs/exec.c SYSCALL_DEFINE3(execve, const char __user *, filename, ) { struct filename *path = getname(filename); do_execve(path-name, argv, envp) } int do_execve() { return do_execve_common(filename, argv, envp); }
execve 系統(tǒng)調用到了 do_execve_common 函數。我們來看這個函數的實現。
//file:fs/exec.c static int do_execve_common(const char *filename, ) { //linux_binprm 結構用于保存加載二進制文件時使用的參數 struct linux_binprm *bprm; //1申請并初始化 brm 對象值 bprm = kzalloc(sizeof(*bprm), GFP_KERNEL); bprm-file = ; bprm-filename = ; bprm_mm_init(bprm) bprm-argc = count(argv, MAX_ARG_STRINGS); bprm-envc = count(envp, MAX_ARG_STRINGS); prepare_binprm(bprm); //2遍歷查找合適的二進制加載器 search_binary_handler(bprm); }
這個函數中申請并初始化 brm 對象的具體工作可以用下圖來表示。
在這個函數中,完成了一下三塊工作。
第一、使用 kzalloc 申請 linux_binprm 內核對象。該內核對象用于保存加載二進制文件時使用的參數。在申請完后,對該參數對象進行各種初始化。
第二、在 bprm_mm_init 中會申請一個全新的 mm_struct 對象,準備留著給新進程使用。
第三、給新進程的棧申請一頁的虛擬內存空間,并將棧指針記錄下來。
第四、讀取二進制文件頭 128 字節(jié)。
我們來看下初始化棧的相關代碼。
//file:fs/exec.c static int __bprm_mm_init(struct linux_binprm *bprm) { bprm-vma = vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL); vma-vm_end = STACK_TOP_MAX; vma-vm_start = vma-vm_end - PAGE_SIZE; bprm-p = vma-vm_end - sizeof(void *); }
在上面這個函數中申請了一個 vma 對象(表示虛擬地址空間里的一段范圍),vm_end 指向了 STACK_TOP_MAX(地址空間的頂部附近的位置),vm_start 和 vm_end 之間留了一個 Page 大小。也就是說默認給棧申請了 4KB 的大小。最后把棧的指針記錄到 bprm->p 中。
另外再看下 prepare_binprm,在這個函數中,從文件頭部讀取了 128 字節(jié)。之所以這么干,是為了讀取二進制文件頭為了方便后面判斷其文件類型。
//file:include/uapi/linux/binfmts.h #define BINPRM_BUF_SIZE 128 //file:fs/exec.c int prepare_binprm(struct linux_binprm *bprm) { memset(bprm-buf, 0, BINPRM_BUF_SIZE); return kernel_read(bprm-file, 0, bprm-buf, BINPRM_BUF_SIZE); }
在申請并初始化 brm 對象值完后,最后使用 search_binary_handler 函數遍歷系統(tǒng)中已注冊的加載器,嘗試對當前可執(zhí)行文件進行解析并加載。
在 3.1 節(jié)我們介紹了系統(tǒng)所有的加載器都注冊到了 formats 全局鏈表里了。函數 search_binary_handler 的工作過程就是遍歷這個全局鏈表,根據二進制文件頭中攜帶的文件類型數據查找解析器。找到后調用解析器的函數對二進制文件進行加載。
//file:fs/exec.c int search_binary_handler(struct linux_binprm *bprm) { for try=0; try2; try++ { list_for_each_entry(fmt, &formats, lh) { int (*fn)(struct linux_binprm *) = fmt-load_binary; retval = fn(bprm); //加載成功的話就返回了 if (retval = 0) { return retval; } //加載失敗繼續(xù)循環(huán)以嘗試加載 } } }
在上述代碼中的 list_for_each_entry 是在遍歷 formats 這個全局鏈表,遍歷時判斷每一個鏈表元素是否有 load_binary 函數。有的話就調用它嘗試加載。
回憶一下 3.1 注冊可執(zhí)行文件加載程序,對于 ELF 文件加載器 elf_format 來說,load_binary 函數指針指向的是 load_elf_binary。
//file:fs/binfmt_elf.c static struct linux_binfmt elf_format = { .module = THIS_MODULE, .load_binary = load_elf_binary, };
那么加載工作就會進入到 load_elf_binary 函數中來進行。這個函數很長,可以說所有的程序加載邏輯都在這個函數中體現了。我根據這個函數的主要工作,分成以下 5 個小部分來給大家介紹。
在介紹的過程中,為了表達清晰,我會稍微調一下源碼的位置,可能和內核源碼行數順序會有所不同。
4.1 ELF 文件頭讀取
在 load_elf_binary 中首先會讀取 ELF 文件頭。
文件頭中包含一些當前文件格式類型等數據,所以在讀取完文件頭后會進行一些合法性判斷。如果不合法,則退出返回。
//file:fs/binfmt_elf.c static int load_elf_binary(struct linux_binprm *bprm) { //4.1 ELF 文件頭解析 //定義結構題并申請內存用來保存 ELF 文件頭 struct { struct elfhdr elf_ex; struct elfhdr interp_elf_ex; } *loc; loc = kmalloc(sizeof(*loc), GFP_KERNEL); //獲取二進制頭 loc-elf_ex = *((struct elfhdr *)bprm-buf); //對頭部進行一系列的合法性判斷,不合法則直接退出 if (loc-elf_ex.e_type != ET_EXEC && ){ goto out; } }
4.2 Program Header 讀取
在 ELF 文件頭中記錄著 Program Header 的數量,而且在 ELF 頭之后緊接著就是 Program Header Tables。所以內核接下來可以將所有的 Program Header 都讀取出來。
//file:fs/binfmt_elf.c static int load_elf_binary(struct linux_binprm *bprm) { //4.1 ELF 文件頭解析 //4.2 Program Header 讀取 // elf_ex.e_phnum 中保存的是 Programe Header 數量 // 再根據 Program Header 大小 sizeof(struct elf_phdr) // 一起計算出所有的 Program Header 大小,并讀取進來 size = loc-elf_ex.e_phnum * sizeof(struct elf_phdr); elf_phdata = kmalloc(size, GFP_KERNEL); kernel_read(bprm-file, loc-elf_ex.e_phoff, (char *)elf_phdata, size); }
4.3 清空父進程繼承來的資源
在 fork 系統(tǒng)調用創(chuàng)建出來的進程中,包含了不少原進程的信息,如老的地址空間,信號表等等。這些在新的程序運行時并沒有什么用,所以需要清空處理一下。
具體工作包括初始化新進程的信號表,應用新的地址空間對象等。
//file:fs/binfmt_elf.c static int load_elf_binary(struct linux_binprm *bprm) { //4.1 ELF 文件頭解析 //4.2 Program Header 讀取 //4.3 清空父進程繼承來的資源 retval = flush_old_exec(bprm); current-mm-start_stack = bprm-p; }
在清空完父進程繼承來的資源后(當然也就使用上了新的 mm_struct 對象),這之后,直接將前面準備的進程棧的地址空間指針設置到了 mm 對象上。這樣將來棧就可以被使用了。
4.4 執(zhí)行 Segment 加載
接下來,加載器會將 ELF 文件中的 LOAD 類型的 Segment 都加載到內存里來。使用 elf_map 在虛擬地址空間中為其分配虛擬內存。最后合適地設置虛擬地址空間 mm_struct 中的 start_code、end_code、start_data、end_data 等各個地址空間相關指針。
我們來看下具體的代碼:
//file:fs/binfmt_elf.c static int load_elf_binary(struct linux_binprm *bprm) { //4.1 ELF 文件頭解析 //4.2 Program Header 讀取 //4.3 清空父進程繼承來的資源 //4.4 執(zhí)行 Segment 加載過程 //遍歷可執(zhí)行文件的 Program Header for(i = 0, elf_ppnt = elf_phdata; i < loc->elf_ex.e_phnum; i++, elf_ppnt++) //只加載類型為 LOAD 的 Segment,否則跳過 if (elf_ppnt-p_type != PT_LOAD) continue; //為 Segment 建立內存 mmap, 將程序文件中的內容映射到虛擬內存空間中 //這樣將來程序中的代碼、數據就都可以被訪問了 error = elf_map(bprm-file, load_bias + vaddr, elf_ppnt, elf_prot, elf_flags, 0); //計算 mm_struct 所需要的各個成員地址 start_code = ; start_data = end_code = ; end_data = ; } current-mm-end_code = end_code; current-mm-start_code = start_code; current-mm-start_data = start_data; current-mm-end_data = end_data; }
其中 load_bias 是 Segment 要加載到內存里的基地址。這個參數有這么幾種可能
值為 0,就是直接按照 ELF 文件中的地址在內存中進行映射
值為對齊到整數頁的開始,物理文件中可能為了可執(zhí)行文件的大小足夠緊湊,而不考慮對齊的問題。但是操作系統(tǒng)在加載的時候為了運行效率,需要將 Segment 加載到整數頁的開始位置處。
4.5 數據內存申請 & 堆初始化
因為進程的數據段需要寫權限,所以需要使用 set_brk 系統(tǒng)調用專門為數據段申請?zhí)摂M內存。
//file:fs/binfmt_elf.c static int load_elf_binary(struct linux_binprm *bprm) { //4.1 ELF 文件頭解析 //4.2 Program Header 讀取 //4.3 清空父進程繼承來的資源 //4.4 執(zhí)行 Segment 加載過程 //4.5 數據內存申請&初始化 retval = set_brk(elf_bss, elf_brk); }
在 set_brk 函數中做了兩件事情:第一是為數據段申請?zhí)摂M內存,第二是將進程堆的開始指針和結束指針初始化一下。
//file:fs/binfmt_elf.c static int set_brk(unsigned long start, unsigned long end) { //1為數據段申請?zhí)摂M內存 start = ELF_PAGEALIGN(start); end = ELF_PAGEALIGN(end); if (end start) { unsigned long addr; addr = vm_brk(start, end - start); } //2初始化堆的指針 current-mm-start_brk = current-mm-brk = end; return 0; }
因為程序初始化的時候,堆上還是空的。所以堆指針初始化的時候,堆的開始地址 start_brk 和結束地址 brk 都設置成了同一個值。
4.6 跳轉到程序入口執(zhí)行
在 ELF 文件頭中記錄了程序的入口地址。如果是非動態(tài)鏈接加載的情況,入口地址就是這個。
但是如果是動態(tài)鏈接,也就是說存在 INTERP 類型的 Segment,由這個動態(tài)鏈接器先來加載運行,然后再調回到程序的代碼入口地址。
# readelf --program-headers helloworld Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align INTERP 0x00000000000002a8 0x00000000004002a8 0x00000000004002a8 0x000000000000001c 0x000000000000001c R 0x1 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
對于是動態(tài)加載器類型的,需要先將動態(tài)加載器(本文示例中是 ld-linux-x86-64.so.2 文件)加載到地址空間中來。
加載完成后再計算動態(tài)加載器的入口地址。這段代碼我展示在下面了,沒有耐心的同學可以跳過。反正只要知道這里是計算了一個程序的入口地址就可以了。
//file:fs/binfmt_elf.c static int load_elf_binary(struct linux_binprm *bprm) { //4.1 ELF 文件頭解析 //4.2 Program Header 讀取 //4.3 清空父進程繼承來的資源 //4.4 執(zhí)行 Segment 加載 //4.5 數據內存申請&堆初始化 //4.6 跳轉到程序入口執(zhí)行 //第一次遍歷 program header table //只針對 PT_INTERP 類型的 segment 做個預處理 //這個 segment 中保存著動態(tài)加載器在文件系統(tǒng)中的路徑信息 for (i = 0; i < loc->elf_ex.e_phnum; i++) { ... } //第二次遍歷 program header table, 做些特殊處理 elf_ppnt = elf_phdata; for (i = 0; i < loc->elf_ex.e_phnum; i++, elf_ppnt++){ ... } //如果程序中指定了動態(tài)鏈接器,就把動態(tài)鏈接器程序讀出來 if (elf_interpreter) { //加載并返回動態(tài)鏈接器代碼段地址 elf_entry = load_elf_interp(&loc->interp_elf_ex, interpreter, &interp_map_addr, load_bias); //計算動態(tài)鏈接器入口地址 elf_entry += loc->interp_elf_ex.e_entry; } else { elf_entry = loc->elf_ex.e_entry; } //跳轉到入口開始執(zhí)行 start_thread(regs, elf_entry, bprm->p); ... }
五、總結
看起來簡簡單單的一行 helloworld 代碼,但是要想把它運行過程理解清楚可卻需要非常深厚的內功的。
本文首先帶領大家認識和理解了二進制可運行 ELF 文件格式。在 ELF 文件中是由四部分組成,分別是 ELF 文件頭 (ELF header)、Program header table、Section 和 Section header table。
Linux 在初始化的時候,會將所有支持的加載器都注冊到一個全局鏈表中。對于 ELF 文件來說,它的加載器在內核中的定義為 elf_format,其二進制加載入口是 load_elf_binary 函數。
一般來說 shell 進程是通過 fork + execve 來加載并運行新進程的。執(zhí)行 fork 系統(tǒng)調用的作用是創(chuàng)建一個新進程出來。不過 fork 創(chuàng)建出來的新進程的代碼、數據都還是和原來的 shell 進程的內容一模一樣。要想實現加載并運行另外一個程序,那還需要使用到 execve 系統(tǒng)調用。
在 execve 系統(tǒng)調用中,首先會申請一個 linux_binprm 對象。在初始化 linux_binprm 的過程中,會申請一個全新的 mm_struct 對象,準備留著給新進程使用。還會給新進程的棧準備一頁(4KB)的虛擬內存。還會讀取可執(zhí)行文件的前 128 字節(jié)。
接下來就是調用 ELF 加載器的 load_elf_binary 函數進行實際的加載。大致會執(zhí)行如下幾個步驟:
ELF 文件頭解析
Program Header 讀取
清空父進程繼承來的資源,使用新的 mm_struct 以及新的棧
執(zhí)行 Segment 加載,將 ELF 文件中的 LOAD 類型的 Segment 都加載到虛擬內存中
為數據 Segment 申請內存,并將堆的起始指針進行初始化
最后計算并跳轉到程序入口執(zhí)行
當用戶進程啟動起來以后,我們可以通過 proc 偽文件來查看進程中的各個 Segment。
# cat /proc/46276/maps 00400000-00401000 r--p 00000000 fd:01 396999 /root/work_temp/helloworld 00401000-00402000 r-xp 00001000 fd:01 396999 /root/work_temp/helloworld 00402000-00403000 r--p 00002000 fd:01 396999 /root/work_temp/helloworld 00403000-00404000 r--p 00002000 fd:01 396999 /root/work_temp/helloworld 00404000-00405000 rw-p 00003000 fd:01 396999 /root/work_temp/helloworld 01dc9000-01dea000 rw-p 00000000 00:00 0 [heap] 7f0122fbf000-7f0122fc1000 rw-p 00000000 00:00 0 7f0122fc1000-7f0122fe7000 r--p 00000000 fd:01 1182071 /usr/lib64/libc-2.32.so 7f0122fe7000-7f0123136000 r-xp 00026000 fd:01 1182071 /usr/lib64/libc-2.32.so ...... 7f01231c0000-7f01231c1000 r--p 0002a000 fd:01 1182554 /usr/lib64/ld-2.32.so 7f01231c1000-7f01231c3000 rw-p 0002b000 fd:01 1182554 /usr/lib64/ld-2.32.so 7ffdf0590000-7ffdf05b1000 rw-p 00000000 00:00 0 [stack] ......
雖然本文非常的長,但仍然其實只把大體的加載啟動過程串了一下。如果你日后在工作學習中遇到想搞清楚的問題,可以順著本文的思路去到源碼中尋找具體的問題,進而幫助你找到工作中的問題的解。
最后提一下,細心的讀者可能發(fā)現了,本文的實例中加載新程序運行的過程中其實有一些浪費,fork 系統(tǒng)調用首先將父進程的很多信息拷貝了一遍,而 execve 加載可執(zhí)行程序的時候又是重新賦值的。所以在實際的 shell 程序中,一般使用的是 vfork。其工作原理基本和 fork 一致,但區(qū)別是會少拷貝一些在 execve 系統(tǒng)調用中用不到的信息,進而提高加載性能。
廣告聲明:文內含有的對外跳轉鏈接(包括不限于超鏈接、二維碼、口令等形式),用于傳遞更多信息,節(jié)省甄選時間,結果僅供參考,IT之家所有文章均包含本聲明。