萬字圖文 | 你寫的程式碼是如何跑起來的?

ITPUB社群發表於2022-12-06

今天我們來思考一個簡單的問題,一個程式是如何在 Linux 上執行起來的?

我們就拿全宇宙最簡單的 Hello World 程式來舉例。

#include <stdio.h>
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, 00NULLNULL);
}

在 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 連結串列,根據要載入的檔案格式來查詢合適的載入器。

四、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=0try<2try++) {
  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 系統呼叫中用不到的資訊,進而提高載入效能。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024420/viewspace-2926958/,如需轉載,請註明出處,否則將追究法律責任。

相關文章