萬字圖文 | 你寫的代碼是如何跑起來的?
今天我們來思考一個簡單的問題,一個程序是如何在 Linux 上執(zhí)行起來的?
我們就拿全宇宙最簡單的 Hello World 程序來舉例。
#include?<stdio.h>
int?main()
{
???printf("Hello,?World!\n");
???return?0;
}
我們在寫完代碼后,進(jìn)行簡單的編譯,然后在 shell 命令行下就可以把它啟動起來。
#?gcc?main.c?-o?helloworld
#?./helloworld
Hello,?World!
那么在編譯啟動運(yùn)行的過程中都發(fā)生了哪些事情了呢?今天就讓我們來深入地了解一下。
一、理解可執(zhí)行文件格式
源代碼在編譯后會生成一個可執(zhí)行程序文件,我們先來了解一下編譯后的二進(jìn)制文件是什么樣子的。
我們首先使用 file 命令查看一下這個文件的格式。
#?file?helloworld
helloworld:?ELF?64-bit?LSB?executable,?x86-64,?version?1?(SYSV),?...
file 命令給出了這個二進(jìn)制文件的概要信息,其中 ELF 64-bit LSB executable 表示這個文件是一個 ELF 格式的 64 位的可執(zhí)行文件。x86-64 表示該可執(zhí)行文件支持的 cpu 架構(gòu)。
LSB 的全稱是 Linux Standard Base,是 Linux 標(biāo)準(zhǔn)規(guī)范。其目的是制定一系列標(biāo)準(zhǔn)來增強(qiáng) Linux 發(fā)行版的兼容性。
ELF 的全稱是 Executable Linkable Format,是一種二進(jìn)制文件格式。Linux 下的目標(biāo)文件、可執(zhí)行文件和 CoreDump 都按照該格式進(jìn)行存儲。
ELF 文件由四部分組成,分別是 ELF 文件頭 (ELF header)、Program header table、Section 和 Section header table。

接下來我們分幾個小節(jié)挨個介紹一下。
1.1 ELF 文件頭
ELF 文件頭記錄了整個文件的屬性信息。原始二進(jìn)制非常不便于觀察。不過我們有趁手的工具 - readelf,這個工具可以幫我們查看 ELF 文件中的各種信息。
我們先來看一下編譯出來的可執(zhí)行文件的 ELF 文件頭,使用 --file-header (-h) 選項(xiàng)即可查看。
#?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 文件頭包含了當(dāng)前可執(zhí)行文件的概要信息,我把其中關(guān)鍵的幾個拿出來給大家解釋一下。
- Magic:一串特殊的識別碼,主要用于外部程序快速地對這個文件進(jìn)行識別,快速地判斷文件類型是不是 ELF
- Class:表示這是 ELF64 文件
- Type:為 EXEC 表示是可執(zhí)行文件,其它文件類型還有 REL(可重定位的目標(biāo)文件)、DYN(動態(tài)鏈接庫)、CORE(系統(tǒng)調(diào)試 coredump文件)
- Entry point address:程序入口地址,這里顯示入口在 0x401040 位置處
- Size of this header:ELF 文件頭的大小,這里顯示是占用了 64 字節(jié)
以上幾個字段是 ELF 頭中對 ELF 的整體描述。另外 ELF 頭中還有關(guān)于 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 文件內(nèi)部最重要的組成單位是一個一個的 Section。每一個 Section 都是由編譯鏈接器生成的,都有不同的用途。例如編譯器會將我們寫的代碼編譯后放到 .text Section 中,將全局變量放到 .data 或者是 .bss Section中。
但是對于操作系統(tǒng)來說,它不關(guān)注具體的 Section 是啥,它只關(guān)注這塊內(nèi)容應(yīng)該以何種權(quán)限加載到內(nèi)存中,例如讀,寫,執(zhí)行等權(quán)限屬性。因此相同權(quán)限的 Section 可以放在一起組成 Segment,以方便操作系統(tǒng)更快速地加載。

由于 Segment 和 Section 翻譯成中文的話,意思太接近了,非常不利于理解。所以本文中我就直接使用 Segment 和 Section 原汁原味的概念,而不是將它們翻譯成段或者是節(jié),這樣太容易讓人混淆了。
Program headers table 就是作為所有 Segments 的頭信息,用來描述所有的 Segments 的。。
使用 readelf 工具的 --program-headers(-l)選項(xiàng)可以解析查看到這塊區(qū)域里存儲的內(nèi)容。
#?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?mapping:
??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
上面的結(jié)果顯示總共有 11 個 program headers。
對于每一個段,輸出了 Offset、VirtAddr 等描述當(dāng)前段的信息。Offset 表示當(dāng)前段在二進(jìn)制文件中的開始位置,F(xiàn)ileSiz 表示當(dāng)前段的大小。Flag 表示當(dāng)前的段的權(quán)限類型, R 表示可都、E 表示可執(zhí)行、W 表示可寫。
在最下面,還把每個段是由哪幾個 Section 組成的給展示了出來,比如 03 號段是由“.init .plt .text .fini” 四個 Section 組成的。

1.3 Section Header Table
和 Program Header Table 不一樣的是,Section header table 直接描述每一個 Section。這二者描述的其實(shí)都是各種 Section ,只不過目的不同,一個針對加載,一個針對鏈接。
使用 readelf 工具的 --section-headers (-S)選項(xiàng)可以解析查看到這塊區(qū)域里存儲的內(nèi)容。
#?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)
結(jié)果顯示,該文件總共有 30 個 Sections,每一個 Section 在二進(jìn)制文件中的位置通過 Offset 列表示了出來。Section 的大小通過 Size 列體現(xiàn)。
在這 30 個Section中,每一個都有獨(dú)特的作用。我們編寫的代碼在編譯成二進(jìn)制指令后都會放到 .text 這個 Section 中。另外我們看到 .text 段的 Address 列顯示的地址是 0000000000401040?;貞浨懊嫖覀冊?ELF 文件頭中看到 Entry point address 顯示的入口地址為 0x401040。這說明,程序的入口地址就是 .text 段的地址。
另外還有兩個值得關(guān)注的 Section 是 .data 和 .bss。代碼中的全局變量數(shù)據(jù)在編譯后將在在這兩個 Section 中占據(jù)一些位置。如下簡單代碼所示。
//未初始化的內(nèi)存區(qū)域位于?.bss?段
int?data1?;?????
//已經(jīng)初始化的內(nèi)存區(qū)域位于?.data?段
int?data2?=?100?;??
//代碼位于?.text?段
int?main(void)
{
?...
}
1.4 入口進(jìn)一步查看
接下來,我們想再查看一下我們前面提到的程序入口 0x401040,看看它到底是啥。我們這次再借助 nm 命令來進(jìn)一步查看一下可執(zhí)行文件中的符號及其地址信息。-n 選項(xiàng)的作用是顯示的符號以地址排序,而不是名稱排序。
#?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 函數(shù)的地址,在這個函數(shù)執(zhí)行一些初始化的操作之后,我們的入口函數(shù) main 將會被調(diào)用到,它位于 0x401126 地址處。
二、用戶進(jìn)程的創(chuàng)建過程概述
在我們編寫的代碼編譯完生成可執(zhí)行程序之后,下一步就是使用 shell 把它加載起來并運(yùn)行之。一般來說 shell 進(jìn)程是通過fork+execve來加載并運(yùn)行新進(jìn)程的。一個簡單加載 helloworld?命令的 shell 核心邏輯是如下這個過程。
//?shell?代碼示例
int?main(int?argc,?char?*?argv[])
{
?...
?pid?=?fork();
?if?(pid==0){?//?如果是在子進(jìn)程中
??//使用?exec?系列函數(shù)加載并運(yùn)行可執(zhí)行文件
??execve("helloworld",?argv,?envp);
?}?else?{
??...
?}
?...
}
shell 進(jìn)程先通過 fork 系統(tǒng)調(diào)用創(chuàng)建一個進(jìn)程出來。然后在子進(jìn)程中調(diào)用 execve 將執(zhí)行的程序文件加載起來,然后就可以調(diào)到程序文件的運(yùn)行入口處運(yùn)行這個程序了。
在上一篇文章《Linux進(jìn)程是如何創(chuàng)建出來的?》中,我們詳細(xì)介紹過了 fork 的工作過程。這里我們再簡單過一下。
這個 fork 系統(tǒng)調(diào)用在內(nèi)核入口是在 kernel/fork.c 下。
//file:kernel/fork.c
SYSCALL_DEFINE0(fork)
{
?return?do_fork(SIGCHLD,?0,?0,?NULL,?NULL);
}
在 do_fork 的實(shí)現(xiàn)中,核心是一個 copy_process 函數(shù),它以拷貝父進(jìn)程(線程)的方式來生成一個新的 task_struct 出來。
//file:kernel/fork.c
long?do_fork(...)
{
?//復(fù)制一個?task_struct?出來
?struct?task_struct?*p;
?p?=?copy_process(clone_flags,?stack_start,?stack_size,
????child_tidptr,?NULL,?trace);
?//子任務(wù)加入到就緒隊(duì)列中去,等待調(diào)度器調(diào)度
?wake_up_new_task(p);
?...
}
在 copy_process 函數(shù)中為新進(jìn)程申請 task_struct,并用當(dāng)前進(jìn)程自己的地址空間、命名空間等對新進(jìn)程進(jìn)行初始化,并為其申請進(jìn)程 pid。
//file:kernel/fork.c
static?struct?task_struct?*copy_process(...)
{
?//復(fù)制進(jìn)程?task_struct?結(jié)構(gòu)體
?struct?task_struct?*p;
?p?=?dup_task_struct(current);
?...
?//進(jìn)程核心元素初始化
?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?&&?設(shè)置進(jìn)程號
?pid?=?alloc_pid(p->nsproxy->pid_ns);
?p->pid?=?pid_nr(pid);
?p->tgid?=?p->pid;
?......
}
執(zhí)行完后,進(jìn)入 wake_up_new_task 讓新進(jìn)程等待調(diào)度器調(diào)度。
不過 fork 系統(tǒng)調(diào)用只能是根據(jù)當(dāng)?shù)?shell 進(jìn)程再復(fù)制一個新的進(jìn)程出來。這個新進(jìn)程里的代碼、數(shù)據(jù)都還是和原來的 shell 進(jìn)程的內(nèi)容一模一樣。
要想實(shí)現(xiàn)加載并運(yùn)行另外一個程序,比如我們編譯出來的 helloworld 程序,那還需要使用到 execve 系統(tǒng)調(diào)用。
三. Linux 可執(zhí)行文件加載器
其實(shí) Linux 不是寫死只能加載 ELF 一種可執(zhí)行文件格式的。它在啟動的時候,會把自己支持的所有可執(zhí)行文件的解析器都加載上。并使用一個 formats 雙向鏈表來保存所有的解析器。其中 formats 雙向鏈表在內(nèi)存中的結(jié)構(gòu)如下圖所示。

我們就以 ELF 的加載器 elf_format 為例,來看看這個加載器是如何注冊的。在 Linux 中每一個加載器都用一個 linux_binfmt 結(jié)構(gòu)來表示。其中規(guī)定了加載二進(jìn)制可執(zhí)行文件的 load_binary 函數(shù)指針,以及加載崩潰文件 的 core_dump 函數(shù)等。其完整定義如下
//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ī)定了具體的加載函數(shù),例如 load_binary 成員指向的就是具體的 load_elf_binary 函數(shù)。這就是 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 進(jìn)行注冊。
//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 在加載二進(jìn)制文件時會遍歷 formats 鏈表,根據(jù)要加載的文件格式來查詢合適的加載器。
四、execve 加載用戶程序
具體加載可執(zhí)行文件的工作是由 execve 系統(tǒng)調(diào)用來完成的。
該系統(tǒng)調(diào)用會讀取用戶輸入的可執(zhí)行文件名,參數(shù)列表以及環(huán)境變量等開始加載并運(yùn)行用戶指定的可執(zhí)行文件。該系統(tǒng)調(diào)用的位置在 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)調(diào)用到了 do_execve_common 函數(shù)。我們來看這個函數(shù)的實(shí)現(xiàn)。
//file:fs/exec.c
static?int?do_execve_common(const?char?*filename,?...)
{
?//linux_binprm?結(jié)構(gòu)用于保存加載二進(jìn)制文件時使用的參數(shù)
?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.遍歷查找合適的二進(jìn)制加載器
?search_binary_handler(bprm);
}
這個函數(shù)中申請并初始化 brm 對象的具體工作可以用下圖來表示。

在這個函數(shù)中,完成了一下三塊工作。
第一、使用 kzalloc 申請 linux_binprm 內(nèi)核對象。該內(nèi)核對象用于保存加載二進(jìn)制文件時使用的參數(shù)。在申請完后,對該參數(shù)對象進(jìn)行各種初始化。
第二、在 bprm_mm_init 中會申請一個全新的 mm_struct 對象,準(zhǔn)備留著給新進(jìn)程使用。
第三、給新進(jìn)程的棧申請一頁的虛擬內(nèi)存空間,并將棧指針記錄下來。
第四、讀取二進(jìn)制文件頭 128 字節(jié)。
我們來看下初始化棧的相關(guān)代碼。
//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?*);
}
在上面這個函數(shù)中申請了一個 vma 對象(表示虛擬地址空間里的一段范圍),vm_end 指向了 STACK_TOP_MAX(地址空間的頂部附近的位置),vm_start 和 vm_end 之間留了一個 Page 大小。也就是說默認(rèn)給棧申請了 4KB 的大小。最后把棧的指針記錄到 bprm->p 中。
另外再看下 prepare_binprm,在這個函數(shù)中,從文件頭部讀取了 128 字節(jié)。之所以這么干,是為了讀取二進(jìn)制文件頭為了方便后面判斷其文件類型。
//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 函數(shù)遍歷系統(tǒng)中已注冊的加載器,嘗試對當(dāng)前可執(zhí)行文件進(jìn)行解析并加載。

在 3.1 節(jié)我們介紹了系統(tǒng)所有的加載器都注冊到了 formats 全局鏈表里了。函數(shù) search_binary_handler 的工作過程就是遍歷這個全局鏈表,根據(jù)二進(jìn)制文件頭中攜帶的文件類型數(shù)據(jù)查找解析器。找到后調(diào)用解析器的函數(shù)對二進(jìn)制文件進(jìn)行加載。
//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;
???}
???//加載失敗繼續(xù)循環(huán)以嘗試加載
???...
??}
?}
}
在上述代碼中的 list_for_each_entry 是在遍歷 formats 這個全局鏈表,遍歷時判斷每一個鏈表元素是否有 load_binary 函數(shù)。有的話就調(diào)用它嘗試加載。
回憶一下 3.1 注冊可執(zhí)行文件加載程序,對于 ELF 文件加載器 elf_format 來說, load_binary 函數(shù)指針指向的是 load_elf_binary。
//file:fs/binfmt_elf.c
static?struct?linux_binfmt?elf_format?=?{
?.module??=?THIS_MODULE,
?.load_binary?=?load_elf_binary,
?......
};
那么加載工作就會進(jìn)入到 load_elf_binary 函數(shù)中來進(jìn)行。這個函數(shù)很長,可以說所有的程序加載邏輯都在這個函數(shù)中體現(xiàn)了。我根據(jù)這個函數(shù)的主要工作,分成以下 5 個小部分來給大家介紹。
在介紹的過程中,為了表達(dá)清晰,我會稍微調(diào)一下源碼的位置,可能和內(nèi)核源碼行數(shù)順序會有所不同。
4.1 ELF 文件頭讀取
在 load_elf_binary 中首先會讀取 ELF 文件頭。

文件頭中包含一些當(dāng)前文件格式類型等數(shù)據(jù),所以在讀取完文件頭后會進(jìn)行一些合法性判斷。如果不合法,則退出返回。
//file:fs/binfmt_elf.c
static?int?load_elf_binary(struct?linux_binprm?*bprm)
{
?//4.1?ELF?文件頭解析
?//定義結(jié)構(gòu)題并申請內(nèi)存用來保存?ELF?文件頭
?struct?{
??struct?elfhdr?elf_ex;
??struct?elfhdr?interp_elf_ex;
?}?*loc;
?loc?=?kmalloc(sizeof(*loc),?GFP_KERNEL);
?//獲取二進(jìn)制頭
?loc->elf_ex?=?*((struct?elfhdr?*)bprm->buf);
?//對頭部進(jìn)行一系列的合法性判斷,不合法則直接退出
?if?(loc->elf_ex.e_type?!=?ET_EXEC?&&?...){
??goto?out;
?}
?...
}
4.2 Program Header 讀取
在 ELF 文件頭中記錄著 Program Header 的數(shù)量,而且在 ELF 頭之后緊接著就是 Program Header Tables。所以內(nèi)核接下來可以將所有的 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?數(shù)量
?//?再根據(jù)?Program?Header?大小?sizeof(struct?elf_phdr)
?//?一起計(jì)算出所有的?Program?Header?大小,并讀取進(jìn)來
?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 清空父進(jìn)程繼承來的資源
在 fork?系統(tǒng)調(diào)用創(chuàng)建出來的進(jìn)程中,包含了不少原進(jìn)程的信息,如老的地址空間,信號表等等。這些在新的程序運(yùn)行時并沒有什么用,所以需要清空處理一下。

具體工作包括初始化新進(jìn)程的信號表,應(yīng)用新的地址空間對象等。
//file:fs/binfmt_elf.c
static?int?load_elf_binary(struct?linux_binprm?*bprm)
{
?//4.1?ELF?文件頭解析
?//4.2?Program?Header?讀取
?//4.3?清空父進(jìn)程繼承來的資源
?retval?=?flush_old_exec(bprm);
?...
?current->mm->start_stack?=?bprm->p;
}
在清空完父進(jìn)程繼承來的資源后(當(dāng)然也就使用上了新的 mm_struct 對象),這之后,直接將前面準(zhǔn)備的進(jìn)程棧的地址空間指針設(shè)置到了 mm 對象上。這樣將來?xiàng)>涂梢员皇褂昧恕?/p>
4.4 執(zhí)行 Segment 加載
接下來,加載器會將 ELF 文件中的 LOAD 類型的 Segment 都加載到內(nèi)存里來。使用 elf_map 在虛擬地址空間中為其分配虛擬內(nèi)存。最后合適地設(shè)置虛擬地址空間 mm_struct 中的 start_code、end_code、start_data、end_data 等各個地址空間相關(guān)指針。

我們來看下具體的代碼:
//file:fs/binfmt_elf.c
static?int?load_elf_binary(struct?linux_binprm?*bprm)
{
?//4.1?ELF?文件頭解析
?//4.2?Program?Header?讀取
?//4.3?清空父進(jìn)程繼承來的資源
?//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?建立內(nèi)存?mmap,?將程序文件中的內(nèi)容映射到虛擬內(nèi)存空間中
??//這樣將來程序中的代碼、數(shù)據(jù)就都可以被訪問了
??error?=?elf_map(bprm->file,?load_bias?+?vaddr,?elf_ppnt,
????elf_prot,?elf_flags,?0);
??//計(jì)算?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 要加載到內(nèi)存里的基地址。這個參數(shù)有這么幾種可能
- 值為 0,就是直接按照 ELF 文件中的地址在內(nèi)存中進(jìn)行映射
- 值為對齊到整數(shù)頁的開始,物理文件中可能為了可執(zhí)行文件的大小足夠緊湊,而不考慮對齊的問題。但是操作系統(tǒng)在加載的時候?yàn)榱诉\(yùn)行效率,需要將 Segment 加載到整數(shù)頁的開始位置處。
4.5 數(shù)據(jù)內(nèi)存申請&堆初始化
因?yàn)檫M(jìn)程的數(shù)據(jù)段需要寫權(quán)限,所以需要使用 set_brk 系統(tǒng)調(diào)用專門為數(shù)據(jù)段申請?zhí)摂M內(nèi)存。
//file:fs/binfmt_elf.c
static?int?load_elf_binary(struct?linux_binprm?*bprm)
{
?//4.1?ELF?文件頭解析
?//4.2?Program?Header?讀取
?//4.3?清空父進(jìn)程繼承來的資源
?//4.4?執(zhí)行?Segment?加載過程
?//4.5?數(shù)據(jù)內(nèi)存申請&堆初始化
?retval?=?set_brk(elf_bss,?elf_brk);
?......
}
在 set_brk 函數(shù)中做了兩件事情:第一是為數(shù)據(jù)段申請?zhí)摂M內(nèi)存,第二是將進(jìn)程堆的開始指針和結(jié)束指針初始化一下。

//file:fs/binfmt_elf.c
static?int?set_brk(unsigned?long?start,?unsigned?long?end)
{
?//1.為數(shù)據(jù)段申請?zhí)摂M內(nèi)存
?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;
}
因?yàn)槌绦虺跏蓟臅r候,堆上還是空的。所以堆指針初始化的時候,堆的開始地址 start_brk 和結(jié)束地址 brk 都設(shè)置成了同一個值。
4.6 跳轉(zhuǎn)到程序入口執(zhí)行
在 ELF 文件頭中記錄了程序的入口地址。如果是非動態(tài)鏈接加載的情況,入口地址就是這個。
但是如果是動態(tài)鏈接,也就是說存在 INTERP 類型的 Segment,由這個動態(tài)鏈接器先來加載運(yùn)行,然后再調(diào)回到程序的代碼入口地址。
#?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 文件)加載到地址空間中來。

加載完成后再計(jì)算動態(tài)加載器的入口地址。這段代碼我展示在下面了,沒有耐心的同學(xué)可以跳過。反正只要知道這里是計(jì)算了一個程序的入口地址就可以了。
//file:fs/binfmt_elf.c
static?int?load_elf_binary(struct?linux_binprm?*bprm)
{
?//4.1?ELF?文件頭解析
?//4.2?Program?Header?讀取
?//4.3?清空父進(jìn)程繼承來的資源
?//4.4?執(zhí)行?Segment?加載
?//4.5?數(shù)據(jù)內(nèi)存申請&堆初始化
?//4.6?跳轉(zhuǎn)到程序入口執(zhí)行
?//第一次遍歷?program?header?table
?//只針對?PT_INTERP?類型的?segment?做個預(yù)處理
?//這個?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);
??//計(jì)算動態(tài)鏈接器入口地址
??elf_entry?+=?loc->interp_elf_ex.e_entry;
?}?else?{
??elf_entry?=?loc->elf_ex.e_entry;
?}
?//跳轉(zhuǎn)到入口開始執(zhí)行
?start_thread(regs,?elf_entry,?bprm->p);
?...
}
五、總結(jié)
看起來簡簡單單的一行 helloworld 代碼,但是要想把它運(yùn)行過程理解清楚可卻需要非常深厚的內(nèi)功的。
本文首先帶領(lǐng)大家認(rèn)識和理解了二進(jìn)制可運(yùn)行 ELF 文件格式。在 ELF 文件中是由四部分組成,分別是 ELF 文件頭 (ELF header)、Program header table、Section 和 Section header table。
Linux 在初始化的時候,會將所有支持的加載器都注冊到一個全局鏈表中。對于 ELF 文件來說,它的加載器在內(nèi)核中的定義為 elf_format,其二進(jìn)制加載入口是 load_elf_binary 函數(shù)。
一般來說 shell 進(jìn)程是通過 fork + execve 來加載并運(yùn)行新進(jìn)程的。執(zhí)行 fork 系統(tǒng)調(diào)用的作用是創(chuàng)建一個新進(jìn)程出來。不過 fork 創(chuàng)建出來的新進(jìn)程的代碼、數(shù)據(jù)都還是和原來的 shell 進(jìn)程的內(nèi)容一模一樣。要想實(shí)現(xiàn)加載并運(yùn)行另外一個程序,那還需要使用到 execve 系統(tǒng)調(diào)用。
在 execve 系統(tǒng)調(diào)用中,首先會申請一個 linux_binprm 對象。在初始化 linux_binprm 的過程中,會申請一個全新的 mm_struct 對象,準(zhǔn)備留著給新進(jìn)程使用。還會給新進(jìn)程的棧準(zhǔn)備一頁(4KB)的虛擬內(nèi)存。還會讀取可執(zhí)行文件的前 128 字節(jié)。
接下來就是調(diào)用 ELF 加載器的 load_elf_binary 函數(shù)進(jìn)行實(shí)際的加載。大致會執(zhí)行如下幾個步驟:
- ELF 文件頭解析
- Program Header 讀取
- 清空父進(jìn)程繼承來的資源,使用新的 mm_struct 以及新的棧
- 執(zhí)行 Segment 加載,將 ELF 文件中的 LOAD 類型的 Segment 都加載到虛擬內(nèi)存中
- 為數(shù)據(jù) Segment 申請內(nèi)存,并將堆的起始指針進(jìn)行初始化
- 最后計(jì)算并跳轉(zhuǎn)到程序入口執(zhí)行

當(dāng)用戶進(jìn)程啟動起來以后,我們可以通過 proc 偽文件來查看進(jìn)程中的各個 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]
......
雖然本文非常的長,但仍然其實(shí)只把大體的加載啟動過程串了一下。如果你日后在工作學(xué)習(xí)中遇到想搞清楚的問題,可以順著本文的思路去到源碼中尋找具體的問題,進(jìn)而幫助你找到工作中的問題的解。
最后提一下,細(xì)心的讀者可能發(fā)現(xiàn)了,本文的實(shí)例中加載新程序運(yùn)行的過程中其實(shí)有一些浪費(fèi),fork 系統(tǒng)調(diào)用首先將父進(jìn)程的很多信息拷貝了一遍,而 execve 加載可執(zhí)行程序的時候又是重新賦值的。所以在實(shí)際的 shell 程序中,一般使用的是 vfork。其工作原理基本和 fork 一致,但區(qū)別是會少拷貝一些在 execve 系統(tǒng)調(diào)用中用不到的信息,進(jìn)而提高加載性能。
