中間跳過了Winods PE/COFF這一節(jié),以及最后Windows內(nèi)核裝載也會省略掉。因為我們主要面向的Mac、Linux。這一節(jié)介紹可執(zhí)行文件的裝載與進程。
本文導圖

進程虛擬地址(操作系統(tǒng)講得比較多)
程序(可執(zhí)行文件)是一個靜態(tài)的概念,不知是一些預先編譯好的指令和數(shù)據(jù)集合的一個文件;而進程是一個動態(tài)的概念,它是程序運行時的一個過程。一個比方:把程序與進程的概念跟廚房做菜比較
- 程序就是菜譜
- CPU就是人
- 廚具就是其他硬件
- 整個炒菜就是一個進程
計算機按此程序指令把數(shù)據(jù)加工成輸出數(shù)據(jù),就像菜譜指導人把原料做成菜肴一樣
每個程序被運行起來以后,它將擁有獨立的虛擬地址空間。從程序角度來講,C語言程序中指針所占用的空間來計算虛擬地址空間位數(shù)大小。一般來將,C語言指針大小與虛擬空間的位數(shù)相同,如32位平臺,指針就是32位,既4字節(jié)。
進程只能使用那些操作系統(tǒng)分配給地址空間,如果訪問了未經(jīng)許可的空間,那么操作系統(tǒng)就會捕獲這些訪問,將進程這種訪問視為非法訪問,并強制結束進程。
如32位中,整個4GB被劃分為兩個部分,從地址0xC000 0000到0xFFFF FFFF共1GB。剩下的從0x0000 0000地址到0xBFFF FFFF共3GB空間都是給進程用的,其實這3GB還要減去一些物理設備的RAM大小。
PAE
32位CPU最大的尋址能力是0到4GB;Intel1995年就開始采用36位的物理地址,也就是可以訪問64GB的物理內(nèi)存,并且修改了頁映射方式,使得新的映射方式可以訪問到更多的物理內(nèi)存,這種地址擴展方式叫做PAE(Physical Address Extensio)。
雖然擴展了物理地址空間,但是對于普通的應用程序而言感覺不到它的存在,因為操作系統(tǒng)會處理好映射關系。操作系統(tǒng)提供一個窗口映射的方法,把額外的內(nèi)存映射到進程地址空間中來。
裝載方式
靜態(tài)裝載,也就是把程序所有的數(shù)據(jù)和指令裝載到內(nèi)存中。但是很浪費資源。因為程序運行有局部性原理,可以吧最常用的部分駐留在內(nèi)存中,將不太常用的數(shù)據(jù)存在磁盤中?!摂M內(nèi)存
覆蓋裝入
以一個程序為單位進行內(nèi)存的換入換出。詳細內(nèi)容直接去看操作系統(tǒng)。
頁映射方式
將換入換出的粒度變得更小,以一頁的大小為單位進程換入換出。
一個列子:

程序剛剛執(zhí)行的入口是P0,這時裝載掛你器發(fā)現(xiàn)P0不在內(nèi)存中,于是將F0分配給P0,并且將P0內(nèi)容裝載到F0,運行一段時候后發(fā)現(xiàn)需要用到P5,于是裝載器將P5裝入F1,依次類推當程序需要用到P3和P6的時候,分別被載入到F2,F(xiàn)3中?!悬c類似懶加載
如果程序只需要這4個頁,那么程序就能夠一直運行下去,但是,如果程序要訪問P4,那么必須舍棄4個內(nèi)存頁其中的一個來裝載P4。至于選擇哪一個頁,可以根據(jù)算法選擇。比如先進先出,LUR(最少使用算法)
現(xiàn)代的操作系統(tǒng)都是按照這樣的方式裝載可執(zhí)行文件。
操作系統(tǒng)角度理解可執(zhí)行文件的裝載
如上面所示,程序使用物理地址直接操作,每次頁被載入時都需要重定位。在虛擬內(nèi)存中現(xiàn)代的硬件MMU(內(nèi)存管理單元)提供了地址轉換功能,有了硬件地址轉換和頁映射機制,可執(zhí)行文件加載動態(tài)加載方式和靜態(tài)加載有非常大的不同。
進程的建立(三步)
從操作系統(tǒng)的角度來講,一個進程最關鍵的特征就是它有獨立的虛擬地址空間,因此有別于其他進程。
創(chuàng)建一個進程,裝載之后然后執(zhí)行需要下面三個步驟:
- 創(chuàng)建一個獨立的虛擬地址空間:創(chuàng)建映射函數(shù)所需要的相應數(shù)據(jù)結構,在Linux下,創(chuàng)建虛擬地址實際上只是分配了一個頁目錄,還沒有頁映射,映射關系等到后面程序發(fā)生頁錯誤的時候在設置。
- 讀取可執(zhí)行文件頭,建立虛擬地址與可執(zhí)行文件的映射關系:上一步是頁映射關系函數(shù)時虛擬地址到物理地址的映射,這一步是虛擬地址與可執(zhí)行文件的映射。當程序執(zhí)行發(fā)生頁錯誤的時候,操作系統(tǒng)將從物理內(nèi)存中分配一個物理頁,然后將該
缺頁從磁盤中讀取到內(nèi)存中,在設置缺頁的虛擬頁和物理頁的映射關系?!敳僮飨到y(tǒng)捕獲到缺頁錯誤的時候,知道當前程序所需要的頁在可執(zhí)行文件哪一個位置,這就是虛擬空間與可執(zhí)行文件映射關系。 - 將CPU的指令寄存器設置成可執(zhí)行文件入口地址,啟動運行:操作系統(tǒng)通過設置CPU指令的寄存器將控制權交給進程,由此進程開始執(zhí)行。ELF文件頭中保存了入口地址。
現(xiàn)在對第二個步驟說明一下。假設現(xiàn)在EFL文件有一個代碼段.text,虛擬地址是0x0804 8000,文件大小為0x00021,對齊為0x1000(4096,也就是一個虛擬頁的大?。?。因為該.text段不到一個頁,考慮到對齊該段占用一個段,所以可執(zhí)行完一旦被裝載,映射關系如下:

這種映射關系只是保存在操作系統(tǒng)內(nèi)部的一個數(shù)據(jù)結構,Linux將進程虛擬空間中的一段叫做虛擬內(nèi)存區(qū)域(VMA)——一種數(shù)據(jù)結構.
頁錯誤(懶加載的方式)
上面步驟執(zhí)行完之后可執(zhí)行文件的指令并沒有被裝載到內(nèi)存中,只是通過可執(zhí)行文件頭部的信息建立可執(zhí)行文件和進程虛擬內(nèi)存之間的映射關系而已。也就是先占個位置。
具體流程:如上面的例子,程序入口地址為0x08048000,剛好是.text段的起始地址,當CPU執(zhí)行這個地址的指令時,發(fā)現(xiàn)頁面0x0804 8000 到 0x0804 9000 是個空頁,也是認為這個是頁錯誤,CPU將控制權給操作系統(tǒng),操作系統(tǒng)有專門處理頁錯誤的例程。這個時候操作系統(tǒng)將會查詢剛才第二步中建立的數(shù)據(jù)結構(映射關系),找到空白頁所在VMA,計算出相應頁面在可執(zhí)行文件中的偏移,然后在物理內(nèi)存中分配一個物理頁面,將進程中該虛擬頁與分配的物理頁之間建立映射關系,然后再把控制權給進程,進程從剛才的頁錯誤重新執(zhí)行。——遇到錯誤頁——》控制區(qū)給操作系統(tǒng)——》確定映射關系——》控制權交給進程——》從錯誤頁重新執(zhí)行
也就是可執(zhí)行文件和虛擬內(nèi)存建立先建立映射關系,然后在發(fā)生錯誤頁的時候根據(jù)之前建立映射的數(shù)據(jù)結構確定物理頁與虛擬內(nèi)存的映射關系。
隨著進程的執(zhí)行,錯誤頁不斷產(chǎn)生,操作系統(tǒng)也會為進程分配相應的物理頁來滿足進程的執(zhí)行需求。簡單來講如下圖所示:

進程虛擬內(nèi)存分布(合并)
ELF文件鏈接視圖和執(zhí)行視圖
上面的例子中是以一個.text為例,然后被操作系統(tǒng)裝載到進程空間,對應一個VMA。但是一般可執(zhí)行文件由很多段,就會產(chǎn)生空間浪費。因為ELF文件映射到VMA中是以頁長為單位的,每個段映射之后的長度都是系統(tǒng)頁長的整數(shù)倍,不足的就會補足多用的部分,造成空間浪費。
最終是通過合并來解決上面的問題。操作系統(tǒng)并不關心實際各個段包含的內(nèi)容,只關心一些跟裝載相關的問題,比如各個段的讀寫權限等。段可以分為:
- 以代碼段為代表的可讀可執(zhí)行段
- 以數(shù)據(jù)段、BBS段為代表的可讀可寫段
- 以只讀數(shù)據(jù)段為代表的權限為只讀的段。
對于相同權限的段,把它們合并到一起當做一個段進行映射?!喜?/strong>
如下圖所示如果.text段為4097.init段為512。那么這兩個段就需要三個頁大小。如果把它們合并只需要兩個頁大小。

Segment(合并段的稱號)
ELF可執(zhí)行文中引入了一個概念叫做
Segment,一個Segment包含一個或多個屬性類型的Section。上面的例子中如果將.text和.init段合并在一起看作一個segment,那么裝載的時候就可以將他們看作一個整體一起映射——也就是映射以后在進程的虛擬地址空間只有一個相對應的VMA??梢詼p少頁面內(nèi)部碎片,節(jié)省內(nèi)存空間。
這里可能把Segment和Section弄混了。
- Segment是從裝載的角度重新劃分了ELF的各個段
- 目標文件鏈接成可執(zhí)行文件的時候,鏈接器會把相同權限相同的段分在同一個空間,如可讀可執(zhí)行的段放在一起(代碼段),可讀可寫放在一起(數(shù)據(jù)段),把這些屬性相似的、又連在一起的段叫做segment
- 系統(tǒng)按照Segmen而不是Section來映射可執(zhí)行文件。
也就是Section適用于目標文件,Segment適用于可執(zhí)行文件。
一個列子:



可以使用readelf命令查看ELFD額segment,類比之前的section屬性結構,相應的segment的結構叫做程序頭,描述了ELF文件改如何被操作系統(tǒng)映射到進程的虛擬空間中。
根據(jù)上面的圖,這個可執(zhí)行文件最終有5個segment。目前只關心加載相關的Load類型的Segment,只有它是被需要映射的。

可執(zhí)行文件重新被換房了三個部分,一些短被歸入了可讀可執(zhí)行的,統(tǒng)一被映射到VMA0;可讀可寫的映射到了VMA1;還有一部分段在程序裝載的時候沒有被映射,比如一些調(diào)試信息和字符串表等,這些段在程序執(zhí)行時沒有作用,所以不需要映射?!邢嗤膕ection都被歸類到了一個segment中,映射到同一個VMA
在ELF的時候,段指的是segment,其他情況都是值section。
ELF可執(zhí)行文件有一個專門的數(shù)據(jù)結構叫做程序頭用來保存Segment信息,因為目標文件不需要裝載所以沒有程序頭,動態(tài)庫和ELF可執(zhí)行文件都有。類似于段表,程序頭同樣是一個結構體數(shù)組?!獙φ麄€可執(zhí)行文的segment的說明

各個字段的含義如下:

堆和棧
堆和棧在進程的虛擬空間同樣是以VMA的形式存在,分別都有一個對應的VMA。Linux下可以使用/proc來查看進程的而虛擬空間分別。

解釋:可以看到該進程有5個VMA,只有前兩個被映射到了可執(zhí)行文件宏的兩個segment,另外三個沒有映射,這三個叫做匿名虛擬內(nèi)存區(qū)域。可以看到還有兩個區(qū)域分別是heap和stack大小分別是140kb,88kb,這兩個區(qū)域在所有進程中都存在。
每個線程都有屬于自己的堆棧,對于單線程的程序來講,這個VMA堆棧就全歸它使用。
操作系統(tǒng)通過給進程空間劃分一個VMA來掛你進程的虛擬空間,將相同權限、有相同映射文件的合成一個VMA。一個進程基本上有如下幾種VMA:
- 代碼VMA,只讀、可執(zhí)行,有映射文件
- 數(shù)據(jù)VMA,可讀可寫、可執(zhí)行,有映射文件
- 堆VMA,可讀可行、可執(zhí)行,無映射文件,匿名,可向上擴展
- 棧VMA,可讀可行、不可執(zhí)行,無映射文件,匿名,可向下擴展

段地址對齊
這里的段值Segment。前面講過裝載過程是通過虛擬內(nèi)存額頁映射機制完成的,也是映射的最小單位,大小是4096。所以映射的物理內(nèi)存和虛擬內(nèi)存都徐亞是4096的整數(shù)倍。由于長度和起始地址的顯示,應該盡可能的優(yōu)化空間和地址安排,節(jié)省空間。
假設現(xiàn)在一個ELF可執(zhí)行文件有三個段,SEG0,SEG1,SEG2,長度和偏移如下:

每個段的長度都不是頁長度的整數(shù)倍,一種簡單的思路就是把每個段分開映射但是會導致非常多內(nèi)存碎片的,因為長度不足4096的需要補足4096。


在UNIX中,采用的是讓各個段接壤部分共享一個物理頁面,然后將該物理頁面分別映射兩次,比如SGE0和SGE1接壤的那個物理頁,系統(tǒng)將它們映射兩份到虛擬地址空間。UNIX中系統(tǒng)將ELF的文件頭也看做是系統(tǒng)的一個段(可以從下面的圖中看到),也會映射到虛擬地址空間,這樣進程中的某一段區(qū)域就是整個ELF文件的鏡像了,這就操作ELF文件頭就可以直接讀寫內(nèi)存的來實現(xiàn)操作。
ELF文件從開始到某個點結束也4096位單位劃分為若干個塊,每個塊單獨裝載到物理內(nèi)存中,如果位于段中間的塊,就會被映射兩次。


這樣內(nèi)次空間就得到了充分的利用,上面例子看出本來用到5個物理頁,現(xiàn)在只需要3個。一個極端情況,如果文件頭、代碼段、數(shù)據(jù)段加起來都沒有4096那么只需要一個物理頁就夠了。
進程棧初始化
進程剛開始啟動的時候,需要知道一些進程運行的環(huán)境,最基本的就是系統(tǒng)環(huán)境變量和進程運行參數(shù)。常見的做法就是操作系統(tǒng)在進程啟動前將這些信息提前保存到虛擬空間的棧中(也就是VMA中的Stack VMA)?!到y(tǒng)環(huán)境變量,進程運行參數(shù)
假設有如下環(huán)境變量:
HOME=/home/user
PATH=/usr/bin
假設堆棧段底部地址為0xBF80 2000,那么進程初始化后的堆棧就如下圖所示:

- 棧頂寄存器esp指向的位置是初始化以后堆棧的頂部,最前面4個字節(jié)表示命令行參數(shù)的數(shù)量。
- 對應這個例子是
prog 123 - 緊跟著的就是分布指向這兩個參數(shù)字符串的指針,后面跟了一個0
- 緊接著是兩個指向環(huán)境變量的字符串指針,分別指向字符串
HOME=/home/user和PATH=/usr/bin - 后面緊跟著一個0表示結束。
進程啟動之后,程序的庫會把堆棧里面的初始化信息中的參數(shù)信息傳遞給main()函數(shù),也就是我們熟知的argc和argv連個參數(shù),兩個參數(shù)分別對應這里的命令參數(shù)數(shù)量和命令行參數(shù)字符串指針數(shù)組。
Linux內(nèi)核裝載ELF過程(可以省略,有點深)
當在Linux系統(tǒng)bash下輸入一個命令執(zhí)行ELF程序時.
首先在用戶層面,bash進程會調(diào)用fork,系統(tǒng)會創(chuàng)建一個新的進程,新的進程會用exeve()系統(tǒng)調(diào)用指定ELF文件,原先bash進程繼續(xù)返回等待剛才啟動新進程結束,開始等待用戶輸入命令。
最終會調(diào)用到execve,函數(shù)原型
int execve(const char * __file, char * const * __argv, char * const * __envp)。三個參數(shù)分別是:
- 可執(zhí)行文件名
- 執(zhí)行參數(shù)
- 環(huán)境變量
調(diào)用過程中還會調(diào)用到do_excve(),do_excve()會讀取文件的前128個字節(jié),判斷文件格式,特別是開頭的四個字節(jié)常常被稱作魔數(shù),通過魔數(shù)可以判斷文件的格式及類型。
上面步驟可以確定文件的類型及格式了,然后調(diào)用search_binary——handle()去搜索和匹配合適的可執(zhí)行文件裝載處理過程。匹配過程是通過判斷文件頭部的魔數(shù)確定的,比如ELF可執(zhí)行文件裝載處理過程叫做load_elf_binary(),a.out可執(zhí)行文件的裝載處理過程叫做load_aout_binary(),可執(zhí)行腳本處理過程叫做load_script()。比如load_elf_binary()主要經(jīng)歷了如下步驟。
- 檢查ELF可執(zhí)行危機格式的有效性。如魔數(shù),程序頭表中段的數(shù)量
- 尋找動態(tài)鏈接的
.interp段,設置動態(tài)鏈接路徑 - 根據(jù)ELF可執(zhí)行文件的程序頭表描述,對ELF文件進行映射,比如代碼數(shù)據(jù),只讀數(shù)據(jù)
- 初始化ELF進程環(huán)境,比如進程啟動是EDX寄存器地址應該是DT_FINI地址(動態(tài)鏈接會講)
- 將系統(tǒng)調(diào)用的返回地址修改成ELF可執(zhí)行文件的入口點,這個入口點取決于程序的鏈接方式。對于靜態(tài)鏈接的ELF可執(zhí)行文件,入口點就是ELF文件的文件頭中e_entry所指的地址;動態(tài)鏈接的ELF,入口點就是動態(tài)鏈接器
當load_elf_binary執(zhí)行完畢,返回到do_execve在返回到sys_exevce()時,第5步中已經(jīng)把系統(tǒng)調(diào)用的返回地址改為了被裝載的ELF的程序入口 地址。所以當sys_execve()從內(nèi)核態(tài)返回到用戶態(tài)的時候,EIP寄存器直接跳到ELF程序入口地址,新的程序開始執(zhí)行。
總結
關于可執(zhí)行文件的裝載和進程進程就介紹到這里。很多東西如果去深究,會發(fā)現(xiàn)是個無底洞。生有涯而學無涯!