今天我們來思考一個簡單的問題,一個程序是如何在 Linux 上執行起來的?
我們就拿全宇宙最簡單的 Hello World 程序來舉例。
#include
int main()
{
printf("Hello, World!\\n");
return 0;
}
我們在寫完代碼后,進行簡單的編譯,然后在 shell 命令行下就可以把它啟動起來。
# gcc main.c -o helloworld
# ./helloworld
Hello, World!
那么在編譯啟動運行的過程中都發生了哪些事情了呢?今天就讓我們來深入地了解一下。
一、理解可執行文件格式
源代碼在編譯后會生成一個可執行程序文件,我們先來了解一下編譯后的二進制文件是什么樣子的。
我們首先使用 file 命令查看一下這個文件的格式。
# file helloworld
helloworld: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), ...
file 命令給出了這個二進制文件的概要信息,其中 ELF 64-bit LSB executable 表示這個文件是一個 ELF 格式的 64 位的可執行文件。x86-64 表示該可執行文件支持的 cpu 架構。
LSB 的全稱是 Linux Standard Base,是 Linux 標準規范。其目的是制定一系列標準來增強 Linux 發行版的兼容性。
ELF 的全稱是 Executable Linkable Format,是一種二進制文件格式。Linux 下的目標文件、可執行文件和 CoreDump 都按照該格式進行存儲。
ELF 文件由四部分組成,分別是 ELF 文件頭 (ELF header)、Program header table、Section 和 Section header table。
接下來我們分幾個小節挨個介紹一下。
1.1 ELF 文件頭
ELF 文件頭記錄了整個文件的屬性信息。原始二進制非常不便于觀察。不過我們有趁手的工具 - readelf,這個工具可以幫我們查看 ELF 文件中的各種信息。
我們先來看一下編譯出來的可執行文件的 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 文件頭包含了當前可執行文件的概要信息,我把其中關鍵的幾個拿出來給大家解釋一下。
- Magic:一串特殊的識別碼,主要用于外部程序快速地對這個文件進行識別,快速地判斷文件類型是不是 ELF
- Class:表示這是 ELF64 文件
- Type:為 EXEC 表示是可執行文件,其它文件類型還有 REL(可重定位的目標文件)、DYN(動態鏈接庫)、CORE(系統調試 coredump文件)
- Entry point address:程序入口地址,這里顯示入口在 0x401040 位置處
- Size of this header:ELF 文件頭的大小,這里顯示是占用了 64 字節
以上幾個字段是 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中。
但是對于操作系統來說,它不關注具體的 Section 是啥,它只關注這塊內容應該以何種權限加載到內存中,例如讀,寫,執行等權限屬性。因此相同權限的 Section 可以放在一起組成 Segment,以方便操作系統更快速地加載。
由于 Segment 和 Section 翻譯成中文的話,意思太接近了,非常不利于理解。所以本文中我就直接使用 Segment 和 Section 原汁原味的概念,而不是將它們翻譯成段或者是節,這樣太容易讓人混淆了。
Program headers table 就是作為所有 Segments 的頭信息,用來描述所有的 Segments 的。 。
使用 readelf 工具的 --program-headers(-l)選項可以解析查看到這塊區域里存儲的內容。
# 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
上面的結果顯示總共有 11 個 program headers。
對于每一個段,輸出了 Offset、VirtAddr 等描述當前段的信息。Offset 表示當前段在二進制文件中的開始位置,FileSiz 表示當前段的大小。Flag 表示當前的段的權限類型, R 表示可都、E 表示可執行、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)選項可以解析查看到這塊區域里存儲的內容。
# 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 中占據一些位置。如下簡單代碼所示。
//未初始化的內存區域位于 .bss 段
int data1 ;
//已經初始化的內存區域位于 .data 段
int data2 = 100 ;
//代碼位于 .text 段
int main(void)
{
...
}
1.4 入口進一步查看
接下來,我們想再查看一下我們前面提到的程序入口 0x401040,看看它到底是啥。我們這次再借助 nm 命令來進一步查看一下可執行文件中的符號及其地址信息。-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 函數的地址,在這個函數執行一些初始化的操作之后,我們的入口函數 main 將會被調用到,它位于 0x401126 地址處。
二、用戶進程的創建過程概述
在我們編寫的代碼編譯完生成可執行程序之后,下一步就是使用 shell 把它加載起來并運行之。一般來說 shell 進程是通過fork+execve來加載并運行新進程的。一個簡單加載 helloworld 命令的 shell 核心邏輯是如下這個過程。
// shell 代碼示例
int main(int argc, char * argv[])
{
...
pid = fork();
if (pid==0){ // 如果是在子進程中
//使用 exec 系列函數加載并運行可執行文件
execve("helloworld", argv, envp);
} else {
...
}
...
}
shell 進程先通過 fork 系統調用創建一個進程出來。然后在子進程中調用 execve 將執行的程序文件加載起來,然后就可以調到程序文件的運行入口處運行這個程序了。
在上一篇文章[《Linux進程是如何創建出來的?》]中,我們詳細介紹過了 fork 的工作過程。這里我們再簡單過一下。
這個 fork 系統調用在內核入口是在 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;
......
}
執行完后,進入 wake_up_new_task 讓新進程等待調度器調度。
不過 fork 系統調用只能是根據當的 shell 進程再復制一個新的進程出來。這個新進程里的代碼、數據都還是和原來的 shell 進程的內容一模一樣。
要想實現加載并運行另外一個程序,比如我們編譯出來的 helloworld 程序,那還需要使用到 execve 系統調用。
三. Linux 可執行文件加載器
其實 Linux 不是寫死只能加載 ELF 一種可執行文件格式的。它在啟動的時候,會把自己支持的所有可執行文件的解析器都加載上。并使用一個 formats 雙向鏈表來保存所有的解析器。其中 formats 雙向鏈表在內存中的結構如下圖所示。
我們就以 ELF 的加載器 elf_format 為例,來看看這個加載器是如何注冊的。在 Linux 中每一個加載器都用一個 linux_binfmt 結構來表示。其中規定了加載二進制可執行文件的 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 中規定了具體的加載函數,例如 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 操作系統支持的格式的加載程序。
# 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 鏈表,根據要加載的文件格式來查詢合適的加載器。
-
Linux
+關注
關注
87文章
11313瀏覽量
209756 -
代碼
+關注
關注
30文章
4793瀏覽量
68703 -
helloworld
+關注
關注
0文章
13瀏覽量
4372
發布評論請先 登錄
相關推薦
評論