《程序員的自我修養(yǎng)》讀書筆記——可執(zhí)行文件的裝載與進程

中間跳過了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/userPATH=/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)是個無底洞。生有涯而學無涯!

?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容