四、execve 加載用戶程序
具體加載可執行文件的工作是由 execve 系統調用來完成的。
該系統調用會讀取用戶輸入的可執行文件名,參數列表以及環境變量等開始加載并運行用戶指定的可執行文件。該系統調用的位置在 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 系統調用到了 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 字節。
我們來看下初始化棧的相關代碼。
//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 字節。之所以這么干,是為了讀取二進制文件頭為了方便后面判斷其文件類型。
//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 函數遍歷系統中已注冊的加載器,嘗試對當前可執行文件進行解析并加載。
在 3.1 節我們介紹了系統所有的加載器都注冊到了 formats 全局鏈表里了。函數 search_binary_handler 的工作過程就是遍歷這個全局鏈表,根據二進制文件頭中攜帶的文件類型數據查找解析器。找到后調用解析器的函數對二進制文件進行加載。
//file:fs/exec.c
int search_binary_handler(struct linux_binprm *bprm)
{
...
for (try=0; try<2; try++) {
list_for_each_entry(fmt, &formats, lh) {
int (*fn)(struct linux_binprm *) = fmt->load_binary;
...
retval = fn(bprm);
//加載成功的話就返回了
if (retval >= 0) {
...
return retval;
}
//加載失敗繼續循環以嘗試加載
...
}
}
}
在上述代碼中的 list_for_each_entry 是在遍歷 formats 這個全局鏈表,遍歷時判斷每一個鏈表元素是否有 load_binary 函數。有的話就調用它嘗試加載。
回憶一下 3.1 注冊可執行文件加載程序,對于 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 系統調用創建出來的進程中,包含了不少原進程的信息,如老的地址空間,信號表等等。這些在新的程序運行時并沒有什么用,所以需要清空處理一下。
具體工作包括初始化新進程的信號表,應用新的地址空間對象等。
//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 執行 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 執行 Segment 加載過程
//遍歷可執行文件的 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 文件中的地址在內存中進行映射
- 值為對齊到整數頁的開始,物理文件中可能為了可執行文件的大小足夠緊湊,而不考慮對齊的問題。但是操作系統在加載的時候為了運行效率,需要將 Segment 加載到整數頁的開始位置處。
4.5 數據內存申請&堆初始化
因為進程的數據段需要寫權限,所以需要使用 set_brk 系統調用專門為數據段申請虛擬內存。
//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 執行 Segment 加載過程
//4.5 數據內存申請&堆初始化
retval = set_brk(elf_bss, elf_brk);
......
}
在 set_brk 函數中做了兩件事情:第一是為數據段申請虛擬內存,第二是將進程堆的開始指針和結束指針初始化一下。
//file:fs/binfmt_elf.c
static int set_brk(unsigned long start, unsigned long end)
{
//1.為數據段申請虛擬內存
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 跳轉到程序入口執行
在 ELF 文件頭中記錄了程序的入口地址。如果是非動態鏈接加載的情況,入口地址就是這個。
但是如果是動態鏈接,也就是說存在 INTERP 類型的 Segment,由這個動態鏈接器先來加載運行,然后再調回到程序的代碼入口地址。
# 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]
對于是動態加載器類型的,需要先將動態加載器(本文示例中是 ld-linux-x86-64.so.2 文件)加載到地址空間中來。
加載完成后再計算動態加載器的入口地址。這段代碼我展示在下面了,沒有耐心的同學可以跳過。反正只要知道這里是計算了一個程序的入口地址就可以了。
//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 執行 Segment 加載
//4.5 數據內存申請&堆初始化
//4.6 跳轉到程序入口執行
//第一次遍歷 program header table
//只針對 PT_INTERP 類型的 segment 做個預處理
//這個 segment 中保存著動態加載器在文件系統中的路徑信息
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++){
...
}
//如果程序中指定了動態鏈接器,就把動態鏈接器程序讀出來
if (elf_interpreter) {
//加載并返回動態鏈接器代碼段地址
elf_entry = load_elf_interp(&loc->interp_elf_ex,
interpreter,
&interp_map_addr,
load_bias);
//計算動態鏈接器入口地址
elf_entry += loc->interp_elf_ex.e_entry;
} else {
elf_entry = loc->elf_ex.e_entry;
}
//跳轉到入口開始執行
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 來加載并運行新進程的。執行 fork 系統調用的作用是創建一個新進程出來。不過 fork 創建出來的新進程的代碼、數據都還是和原來的 shell 進程的內容一模一樣。要想實現加載并運行另外一個程序,那還需要使用到 execve 系統調用。
在 execve 系統調用中,首先會申請一個 linux_binprm 對象。在初始化 linux_binprm 的過程中,會申請一個全新的 mm_struct 對象,準備留著給新進程使用。還會給新進程的棧準備一頁(4KB)的虛擬內存。還會讀取可執行文件的前 128 字節。
接下來就是調用 ELF 加載器的 load_elf_binary 函數進行實際的加載。大致會執行如下幾個步驟:
- ELF 文件頭解析
- Program Header 讀取
- 清空父進程繼承來的資源,使用新的 mm_struct 以及新的棧
- 執行 Segment 加載,將 ELF 文件中的 LOAD 類型的 Segment 都加載到虛擬內存中
- 為數據 Segment 申請內存,并將堆的起始指針進行初始化
- 最后計算并跳轉到程序入口執行
當用戶進程啟動起來以后,我們可以通過 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]
......
雖然本文非常的長,但仍然其實只把大體的加載啟動過程串了一下。如果你日后在工作學習中遇到想搞清楚的問題,可以順著本文的思路去到源碼中尋找具體的問題,進而幫助你找到工作中的問題的解。
最后提一下,細心的讀者可能發現了,本文的實例中加載新程序運行的過程中其實有一些浪費,fork 系統調用首先將父進程的很多信息拷貝了一遍,而 execve 加載可執行程序的時候又是重新賦值的。所以在實際的 shell 程序中,一般使用的是 vfork。其工作原理基本和 fork 一致,但區別是會少拷貝一些在 execve 系統調用中用不到的信息,進而提高加載性能。
-
Linux
+關注
關注
87文章
11313瀏覽量
209756 -
代碼
+關注
關注
30文章
4793瀏覽量
68703 -
helloworld
+關注
關注
0文章
13瀏覽量
4372
發布評論請先 登錄
相關推薦
評論