# 裝載概述
# 裝載理論篇
## 創建虛擬地址空間
## 讀取可執行文件頭,并且建立虛擬空間與可執行文件的映射關系
## 將CPU指令寄存器設置成可執行文件入口,啟動運行
# Mach-O文件的裝載
# * Linux ELF文件的裝載(了解)
先附上源碼地址:結合 XNU 源碼(應該不是最新的,且不怎么全,不過用來分析學習也差不多了),來看加載器的流程,效果更好。重要的兩個類:
-
bsd/kern/kern_exec.c
:進程執行的相關操作:線程創建、數據初始化等。 -
bsd/kern/mach_loader.c
:Mach-O文件解析加載相關。第二節中提到的Mach-O文件中的內核加載器負責處理的load command 對應的內核中處理的函數都在該文件中,比如處理LC_SEGMET
命令的load_segment
函數、處理LC_LOAD_DYLINKER
命令的load_dylinker
函數(負責調用命令指定的動態鏈接器)。
# 裝載概述
在鏈接完成之后,應用開始運行之前,有一段裝載過程,我們都知道程序執行時所需要的指令和數據必須在內存中才能夠被正常運行。
最簡單的辦法就是將程序運行所需要的指令和數據全都裝入內存中,這樣程序就可以順利運行,這就是最簡單的靜態裝入
的辦法。
但是很多情況下程序所需要的內存數量大于物理內存的數量,當內存的數量不夠時,根本的解決辦法就是添加內存。相對于磁盤來說,內存是昂貴且稀有的,這種情況自計算機磁盤誕生以來一直如此。所以人們想盡各種辦法,希望能夠在不添加內存的情況下讓更多的程序運行起來,盡可能有效地利用內存。后來研究發現,程序運行時是有局部性原理
的,所以我們可以將程序最常用的部分駐留在內存中,而將一些不太常用的數據存放在磁盤里面,這就是動態裝入
的基本原理。(這也是虛擬地址空間
機制要解決的問題,這里不再贅述,大學都學過)
覆蓋裝入(Overlay)和頁映射(Paging)是兩種很典型的動態裝載方法,它們所采用的思想都差不多,原則上都是利用了程序的局部性原理。動態裝入的思想是程序用到哪個模塊,就將哪個模塊裝入內存,如果不用就暫時不裝入,存放在磁盤中。
# 裝載理論篇
在虛擬存儲中,現代的硬件MMU都提供地址轉換的功能。有了硬件的地址轉換和頁映射機制,操作系統動態加載可執行文件的方式跟靜態加載有了很大的區別。
事實上,從操作系統的角度來看,一個進程最關鍵的特征是它擁有獨立的虛擬地址空間,這使得它有別于其他進程。很多時候一個程序被執行同時都伴隨著一個新的進程的創建,那么我們就來看看這種最通常的情形:創建一個進程,然后裝載相應的可執行文件并且執行。在有虛擬存儲的情況下,上述過程最開始只需要做三件事情:
- 創建一個獨立的虛擬地址空間。
- 讀取可執行文件頭,并且建立虛擬空間與可執行文件的映射關系。
- 將CPU的指令寄存器設置成可執行文件的入口地址,啟動運行。
首先是創建虛擬地址空間。一個虛擬空間由一組頁映射函數
將虛擬空間的各個頁
映射至相應的物理空間
,所以創建一個虛擬空間實際上并不是創建空間而是創建映射函數所需要的相應的數據結構
,在i386 的Linux下,創建虛擬地址空間實際上只是分配一個頁目錄(Page Directory)就可以了,甚至不設置頁映射關系,這些映射關系等到后面程序發生頁錯誤的時候再進行設置。
讀取可執行文件頭,并且建立虛擬空間與可執行文件的映射關系。上面那一步的頁映射關系函數是虛擬空間到物理內存的映射關系
,這一步所做的是虛擬空間與可執行文件的映射關系
。我們知道,當程序執行發生頁錯誤時,操作系統將從物理內存中分配一個物理頁,然后將該“缺頁”從磁盤中讀取到內存中,再設置缺頁的虛擬頁和物理頁的映射關系,這樣程序才得以正常運行。
但是很明顯的一點是,當操作系統捕獲到缺頁錯誤時,它應知道程序當前所需要的頁在可執行文件中的哪一個位置。這就是虛擬空間與可執行文件之間的映射關系。從某種角度來看,這一步是整個裝載過程中最重要的一步,也是傳統意義上“裝載”的過程。
由于可執行文件在裝載時實際上是被映射的虛擬空間,所以可執行文件很多時候又被叫做映像文件(Image)。
很明顯,這種映射關系只是保存在操作系統內部的一個數據結構。Linux中將進程虛擬空間中的一個段叫做虛擬內存區域(VMA, Virtual Memory Area);在Windows中將這個叫做虛擬段(Virtual Section),其實它們都是同一個概念。
VMA是一個很重要的概念,它對于我們理解程序的裝載執行和操作系統如何管理進程的虛擬空間有非常重要的幫助。
操作系統在內部保存這種結構,很明顯是因為當程序執行發生段錯誤時,它可以通過查找這樣的一個數據結構來定位錯誤頁在可執行文件中的位置
。
將CPU指令寄存器設置成可執行文件入口,啟動運行。第三步其實也是最簡單的一步,操作系統通過設置CPU的指令寄存器將控制權轉交給進程,由此進程開始執行。這一步看似簡單,實際上在操作系統層面上比較復雜,它涉及內核堆棧和用戶堆棧的切換、CPU運行權限的切換。不過從進程的角度看這一步可以簡單地認為操作系統執行了一條跳轉指令,直接跳轉到可執行文件的入口地址(通常是text區的地址)。
- ELF文件頭中,有
e_entry
字段保存入口地址 - Mach-O文件中的
LC_MAIN
加載指令作用就是設置程序主程序的入口點地址和棧大小)
# Mach-O文件的裝載
(二) Mach-O 文件結構 介紹 Mach Heade
中的 Load Command
加載命令,結合其用途,就可以簡單看出可執行文件的裝載流程:
首先,是由內核加載器(定義在
bsd/kern/mach_loader.c
文件中)來處理一些需要由內核加載器直接使用的加載命令。內核的部分(內核加載器)負責新進程的基本設置——分配虛擬內存,創建主線程,以及處理任何可能的代碼簽名/加密的工作。(這也是本篇內容主要講的)接著,對于需要動態鏈接(使用了動態庫)的可執行文件(大部分可執行文件都是動態鏈接的)來說,控制權會轉交給鏈接器,鏈接器進而接著處理文件頭中的其他加載命令。真正的庫加載和符號解析的工作都是通過
LC_LOAD_DY LINKER
命令指定的動態鏈接器
在用戶態完成的。(下一篇文章再細講dyld
及動態鏈接
)
下面通過代碼來看一下具體的過程。下面通過一個調用棧圖來說明, 這里面每個方法都做了很多事情,這里只注釋了到_dyld_start的關鍵操作,很簡略。有興趣可以詳細看源碼kern_exec.c
、mach_loader.c
▼ execve // 用戶點擊了app,用戶態會發送一個系統調用 execve 到內核
▼ __mac_execve // 主要是為加載鏡像進行數據的初始化,以及資源相關的操作,以及創建線程
▼ exec_activate_image // 拷貝可執行文件到內存中,并根據不同的可執行文件類型選擇不同的加載函數,所有的鏡像的加載要么終止在一個錯誤上,要么最終完成加載鏡像。
// 在 encapsulated_binary 這一步會根據image的類型選擇imgact的方法
/*
* 該方法為Mach-o Binary對應的執行方法;
* 如果image類型為Fat Binary,對應方法為exec_fat_imgact;
* 如果image類型為Interpreter Script,對應方法為exec_shell_imgact
*/
▼ exec_mach_imgact
?? // 首先對Mach-O做檢測,會檢測Mach-O頭部,解析其架構、檢查imgp等內容,判斷魔數、cputype、cpusubtype等信息。如果image無效,會直接觸發assert(exec_failure_reason == OS_REASON_NULL); 退出。
// 拒絕接受Dylib和Bundle這樣的文件,這些文件會由dyld負責加載。然后把Mach-O映射到內存中去,調用load_machfile()
▼ load_machfile
?? // load_machfile會加載Mach-O中的各種load monmand命令。在其內部會禁止數據段執行,防止溢出漏洞攻擊,還會設置地址空間布局隨機化(ASLR),還有一些映射的調整。
// 真正負責對加載命令解析的是parse_machfile()
▼ parse_machfile //解析主二進制macho
?? /*
* 首先,對image頭中的filetype進行分析,可執行文件MH_EXECUTE不允許被二次加載(depth = 1);動態鏈接編輯器MH_DYLINKER必須是被可執行文件加載的(depth = 2)
* 然后,循環遍歷所有的load command,分別調用對應的內核函數進行處理
* LC_SEGMET:load_segment函數:對于每一個段,將文件中相應的內容加載到內存中:從偏移量為 fileoff 處加載 filesize 字節到虛擬內存地址 vmaddr 處的 vmsize 字節。每一個段的頁面都根據 initprot 進行初始化,initprot 指定了如何通過讀/寫/執行位初始化頁面的保護級別。
* LC_UNIXTHREAD:load_unixthread函數,見下文
* LC_MAIN:load_main函數
* LC_LOAD_DYLINKER:獲取動態鏈接器相關的信息,下面load_dylinker會根據信息,啟動動態鏈接器
* LC_CODE_SIGNATURE:load_code_signature函數,進行驗證,如果無效會退出。理論部分,回見第二節load_command `LC_CODE_SIGNATURE `部分。
* 其他的不再多說,有興趣可以自己看源碼
*/
▼ load_dylinker // 解析完 macho后,根據macho中的 LC_LOAD_DYLINKER 這個LoadCommand來啟動這個二進制的加載器,即 /usr/bin/dyld
▼ parse_machfile // 開始解析 dyld 這個mach-o文件
▼ load_unixthread // 解析 dyld 的 LC_UNIXTHREAD 命令,這個過程中會解析出entry_point
▼ load_threadentry // 獲取入口地址
?? thread_entrypoint // 里面只有i386和x86架構的,沒有arm的,但是原理是一樣的
?? //上一步獲取到地址后,會再加上slide,ASLR偏移,到此,就獲取到了dyld的入口地址,也就是 _dyld_start 函數的地址
▼ activate_exec_state
?? thread_setentrypoint // 設置entry_point。直接把entry_point地址寫入到用戶態的寄存器里面了。
//這一步開始,_dyld_start就真正開始執行了。
▼ dyld
▼ __dyld_start // 源碼在dyldStartup.s這個文件,用匯編實現
▼ dyldbootstrap::start()
▼ dyld::_main()
▼ //函數的最后,調用 getEntryFromLC_MAIN,從 Load Command 讀取LC_MAIN入口,如果沒有LC_MAIN入口,就讀取LC_UNIXTHREAD,然后跳到主程序的入口處執行
▼ 這是下篇內容
# * Linux ELF文件的裝載(了解)
首先在用戶層面,bash進程會調用fork()系統調用創建一個新的進程,然后新的進程調用 execve()
系統調用執行指定的ELF文件,原先的bash進程繼續返回等待剛才啟動的新進程結束,然后繼續等待用戶輸入命令。 execve() 系統調用被定義在unistd.h,它的原型如下:
/*
* 三個參數分別是被執行的程序文件名、執行參數和環境變量。
*/
int execve(const char *filename, char *const argv[], char *const envp[]);
Glibc對該系統調用進行了包裝,提供了 execl()、execlp()、execle()、execv()、execvp()等5個不同形式的exec系列API,它們只是在調用的參數形式上有所區別,但最終都會調用到 execve() 這個系統中。
在進入 execve() 系統調用之后,Linux內核就開始進行真正的裝載工作。
sys_execve()
,在內核中,該函數是execve()系統調用相應的入口,定義在arch\i386\kernel\Process.c。 該函數進行一些參數的檢查復制之后,調用 do_execve()。-
do_execve()
,該函數會首先查找被執行的文件,如果找到文件,則讀取文件的前128個字節。目的是判斷文件的格式,每種可執行文件的格式的開頭幾個字節都是很特殊的,特別是開頭4個字節,常常被稱做魔數
(Magic Number),通過對魔數的判斷可以確定文件的格式和類型。比如:- ELF的可執行文件格式的頭4個字節為0x7F、’e’、’l’、’f’;
- Java的可執行文件格式的頭4個字節為’c’、’a’、’f’、’e’;
- 如果被執行的是Shell腳本或perl、python等這種解釋型語言的腳本,那么它的第一行往往是 “#!/bin/sh” 或 “#!/usr/bin/perl” 或 “#!/usr/bin/python” ,這時候前兩個字節
'#'
和'!'
就構成了魔數,系統一旦判斷到這兩個字節,就對后面的字符串進行解析,以確定具體的解釋程序的路徑。
當do_execve()讀取了這128個字節的文件頭部之后,然后調用search_binary_handle()。
-
search_binary_handle()
,該函數會去搜索和匹配合適的可執行文件裝載處理過程。Linux中所有被支持的可執行文件格式都有相應的裝載處理過程,此函數會通過判斷文件頭部的魔數確定文件的格式,并且調用相應的裝載處理過程。比如:- ELF可執行文件的裝載處理過程叫做 load_elf_binary();
- a.out可執行文件的裝載處理過程叫做 load_aout_binary();
- 裝載可執行腳本程序的處理過程叫做 load_script()。
-
load_elf_binary()
,這個函數被定義在fs/Binfmt_elf.c,代碼比較長,它的主要步驟是:- 檢查ELF可執行文件格式的有效性,比如魔數、程序頭表中段(Segment)的數量。
- 尋找動態鏈接的“.interp”段,設置動態鏈接器路徑。
- 根據ELF可執行文件的程序頭表的描述,對ELF文件進行映射,比如代碼、數據、只讀數據。
- 初始化ELF進程環境,比如進程啟動時EDX寄存器的地址應該是 DT_FINI 的地址(動態鏈接相關)。
- 將系統調用的返回地址修改成ELF可執行文件的入口點,這個入口點取決于程序的鏈接方式,對于靜態鏈接的ELF可執行文件,這個程序入口就是ELF文件的文件頭中
e_entry
所指的地址;對于動態鏈接的ELF可執行文件,程序入口點是動態鏈接器。
當 load_elf_binary() 執行完畢,返回至 do_execve() 再返回至 sys_execve() 時, 上面的第5步中已經把系統調用的返回地址改成了被裝載的ELF程序(或動態鏈接器)的入口地址了。所以當 sys_execve()
系統調用從內核態返回到用戶態時,EIP 寄存器直接跳轉到了ELF程序的入口地址,于是新的程序開始執行,ELF可執行文件裝載完成。