可執行文件的裝載與進程
可執行文件只有裝載到內存以后才能被CPU執行。
本章會介紹:
- 什么是進程的虛擬地址空間?
- 為什么進程要有自己獨立的虛擬地址空間?
- 裝載的幾種方式,包括覆蓋裝載、頁映射。
- 虛擬地址空間的分布情況,比如代碼段、數據段、BSS段、堆、棧。
進程虛擬地址空間
程序是一個靜態的概念,它就是一些預先編譯好的指令和數據集合的一個文件;進程則是一個動態的概念,它是程序運行時的一個過程。
每個程序被運行起來以后,它將擁有自己獨立的虛擬地址空間(Virtual Address Space),這個虛擬地址空間的大小由計算機的硬件平台決定,具體地說是由CPU的位數決定的。硬件決定了地址空間的最大理論上限,即硬件的尋址空間大小。一般來說,C語言指針大小的位數與虛擬空間的位數相同,比如32位平台下的指針為32位,即4字節。
那么32位平台下的4GB虛擬空間是否可以任意使用呢?很遺憾,不行。因為程序在運行的時候處於操作系統的監管下,操作系統為了達到監控好處呢個系運行等一系列目的,進程的虛擬空間都在操作系統的掌握之中。進程只能使用那些操作系統分配給進程的地址,如果訪問未經允許的空間,那么操作系統就會捕捉到這些訪問,將進程的這種訪問當做非法操作,強制結束進程。我們經常在Windows下碰到令人討厭的“進程因非法操作需要關閉”或Linux下的“Segmentation fault”很多時候是因為進程訪問了未經允許的地址。並且,這4GB被操作系統本身用去了一部分。
那么32位的CPU下,程序使用的空間能不能超過4GB呢?如果空間指的是虛擬地址空間,那么答案是否;如果空間指的是計算機的內存空間,那么答案是肯定的。從硬件層面上來講,原先的32位地址線只能訪問最多4GB的物理內存。但是自從擴展至36位地址線之后,Intel修改了頁映射的方式,使得新的映射方式可以訪問到更多的物理內存。Intel把這個地址擴展方式叫做PAE(Physical Address Extension)。
當然擴展的物理地址空間,對於普通應用程序來說正常情況下感覺不到它的存在,因為這主要是操作系統的事,在應用程序里,只有32位的虛擬地址空間。那么應用該如何使用這些大於常規的內存空間呢?一個很常見的辦法就是操作系統提供一個窗口映射的方法,把這些額外的內存映射到進程地址空間中來。應用程序可以根據需要來選擇申請和映射。在Windows下,這種訪問內存的操作方式叫做AWE(Address Windowing Extension);而像Linux等UNIX類操作系統則采用mmap()系統調用來實現。
當然這只是一種補救32位地址空間不夠大時的非常規手段,真正的解決方法還是應該使用64位的處理器和操作系統。
裝載的方式
程序執行時所需要的指令和數據必須在內存中才能正常運行,最簡單的辦法就是將其全都裝入內存中,這就是靜態裝入的方法。但是很多情下程序鎖需要的內存數量大於物理內存的數量,當內存的數量不夠時,根本的解決辦法就是添加內存 。相對於磁盤來說,內存是昂貴且稀有的,這種情況自計算機磁盤誕生以來一直如此。所以人們想盡各種辦法,希望能夠在不添加內存的情況下讓更多的程序運行起來,盡可能有效地利用內存。后來研究發現,程序運行時是有局部性原理的,所以我們可以將程序最常用的部分駐留在內存中,而將一些不太常用的數據存放在磁盤里面,這就是動態裝入的基本原理。
覆蓋裝入(Overlay)和頁映射(Paging)是兩種很典型的動態裝載方法,它們所采用的思想都差不多,原則上都是利用了程序的局部性原理。動態裝入的思想是程序用到哪個模塊,就將哪個模塊裝入內存,如果不用就暫時不裝入,存放在磁盤中。
覆蓋裝入
覆蓋裝入的方法把挖掘內存潛力的任務交給了程序員,程序員在編寫程序的時候必須手工將程序分割成若干塊,然后編寫一個小的輔助代碼來管理這些模塊何時應該駐留內存而何時應該被替換掉。這個小的輔助代碼就是所謂的覆蓋管理器(Overlay Manager)。在多個模塊的情況下,程序員需要手工將模塊按照它們之間的調用依賴關系組織成樹狀結構,覆蓋管理器需要保證兩點:
- 這個樹狀結構中從任何一個模塊到樹的根模塊都叫調用路徑。當該模塊被調用時,整個調用路徑上的模塊必須都在內存中。
- 禁止跨樹間調用。任意一個模塊不允許跨過樹狀結構進行調用。
當然,由於跨模塊間的調用都需要經過覆蓋管理器,以確保所有被調用的模塊都能夠正確地駐留在內存,而且一旦模塊沒有在內存中,還需要從磁盤或其他存儲器讀取相應的模塊,所以覆蓋裝入的速度比較慢,不過這也是一種折中的方案,是典型的利用時間換取空間的方法。
頁映射
頁映射是虛擬存儲機制的一部分,它隨着虛擬存儲的發明而誕生。頁映射是將內存和所有磁盤中的數據和指令按照頁為單位划分成若干個頁,以后所有的裝載和操作的單位就是頁。以目前的情況,硬件規定的頁的大小有4KB、8KB、2MB、4MB等。
假設機器有4個頁的內存,而程序有8個頁。如果這時候程序只需要4個頁,就能一直運行下去。但如果這時候需要訪問第5個頁,那么裝載管理器必須做出抉擇,它必須放棄目前正在使用的4個內存頁中的其中一個來裝載新的頁。至於選擇哪個頁,我們有很多種算法可以選擇,比如可以選擇第一個被分配掉的內存頁(FIFO先進先出算法);可以選擇很少被訪問到的頁(LUR最少使用算法)。
從操作系統角度看可執行文件的裝載
如果程序使用武力地址直接進行操作,那么每次頁被裝入時都需要進行重定位。在虛擬存儲中,現代的硬件MMU都提供地址轉換的功能。有了硬件的地址轉換和頁映射機制,操作系統動態加載可執行文件的方式跟靜態加載有了很大的區別。
進程的建立
從操作系統的角度來看,一個進程最關鍵的特征是它擁有獨立的虛擬地址空間,這使得它有別於其他進程。很多時候一個程序被執行同時都伴隨着一個新的進程的創建,那么我們就來看看這種最通常的情形:創建一個進程,然后裝載相應的可執行文件並且執行。在有虛擬存儲的情況下,上述過程最開始只需要做三件事情:
- 創建一個獨立的虛擬地址空間。
- 一個虛擬空間由一組頁映射函數將虛擬空間的各個頁映射至相應的物理空間,那么創建一個虛擬空間實際上並不是創建空間而是創建映射函數所需要的相應的數據結構。
- 讀取可執行文件頭,並且建立虛擬空間與可執行文件的映射關系。
- 當程序執行發生頁錯誤時,操作系統將從物理內存中分配一個物理頁,然后將該“缺頁”從磁盤中讀取到內存中,再設置缺頁的虛擬頁和物理頁的映射關系,這樣程序才得以正常運行。當操作系統捕獲到缺頁錯誤時,它應知道程序當前所需要的頁在可執行文件中的哪一個位置。
- 這種映射關系只是保存在操作系統內部的一個數據結構。Linux中將進程虛擬空間中的一個段叫做虛擬內存區域(VMA,Virtual Memory Area);在Windows中將這個叫做虛擬段(Virtual Section)。
- 將CPU的指令寄存器設置成可執行文件的入口地址,啟動運行。
- 操作系統通過設置CPU的指令寄存器將控制權轉交給進程,由此進程開始執行。這一步看似簡單,實際上在操作系統層面上比較復雜,它涉及內核堆棧和用戶堆棧的切換、CPU運行權限的切換。不過從進程的角度來看這一步可以簡單地認為操作系統執行了一條跳轉指令,直接跳轉到可執行文件的入口地址。
頁錯誤
上面的步驟執行完以后,其實可執行文件的真正指令和數據都沒有被裝入內存中。操作系統只是通過可執行文件頭部的信息建立起可執行文件和進程虛擬之間的映射關系而已。當CPU開始打算執行這個地址的指令時,發現是個空頁面,於是它就認為這是一個頁錯誤(Page Fault)。CPU將控制權交給操作系統,操作系統有專門的頁錯誤處理例程來處理這種情況。操作系統將查詢這個數據結構,然后找到空頁面所在的VMA,計算出相應的頁面在可執行文件中的偏移,然后在物理內存中分配一個物理頁面,將進程中該虛擬頁與分配的物理頁之間建立映射關系,然后把控制權再還給進程,進程從剛才頁錯誤的位置重新開始執行。
隨着進程的執行,頁錯誤也會不斷地產生,操作系統也會為進程分配相應的物理頁面來滿足進程執行的需求。當然有可能進程所需要的內存會超過可用的內存數量,特別是在有多個進程同時執行的時候,這時候操作系統就需要精心組織和分配物理內存,甚至有時候應將分配給進程的物理內存暫時收回等,這就涉及了操作系統的虛擬存儲管理。
進程虛存空間分布
ELF文件鏈接視圖和執行視圖
當段的數量增多時,就會產生空間浪費的問題。因為我們知道,ELF文件被映射時,是以系統的頁長度作為單位的,那么每個段在映射時的長度應該都是系統頁長度的整數倍;如果不是,那么多余部分也將占用一個頁。一個ELF文件中往往有十幾個段,那么內存空間的浪費是可想而知的。
當我們站在操作系統裝載可執行文件的角度看問題時,可以發現它實際上並不關心可執行文件各個段所包含的實際內容,操作系統只關心一些跟裝載相關的問題,最主要的是段的權限(可讀、可寫、可執行)。ELF文件中,段的權限往往只有為數不多的幾種組合,基本上是三種:
- 以代碼段為代表的權限為可讀可執行的段。
- 以數據段和BSS段為代表的權限為可讀可寫的段。
- 以只讀數據段為代表的權限為只讀的段。
那么我們可以找到一個很簡單的方案就是:對於相同權限的段,把它們合並一起當做一個段進行映射。這樣做的好處是可以很明顯地減少頁面內部碎片,從而節省了內存空間。Segment的概念實際上是從裝載的角度重新划分了ELF的各個段。將目標文件鏈接成可執行文件的時候,鏈接器會盡量把相同權限屬性的段分配在同一空間。
所以總的來說,Segment和Section是從不同角度來划分同一個ELF文件。這個在ELF中被稱為不同的視圖(View),從Section的角度來看ELF文件就是鏈接視圖(Linking View)。從Segment的角度來看就是執行視圖(Execution View)。當我們在談到ELF裝載時,段專門指Segment;而在其他情況下,段指的是Section。
ELF可執行文件中有一個專門的數據結構叫做程序頭表(Program Header Table)用來保存Segment的信息。因為ELF目標文件不需要被裝載,所以它沒有程序頭表,而ELF的可執行文件和共享庫文件都有。它的結構體如下:
typedef struct {
Elf32_Word p_type; // 類型
Elf32_Off p_offset; // 在文件中的偏移
Elf32_Addr p_vaddr; // 第一個字節進程虛擬地址空間的起始位置
Elf32_Addr p_paddr; // 物理裝載地址
Elf32_Word p_filesz;// 在ELF文件中所占空間的長度
Elf32_Word p_memsz; // 在進程虛擬地址空間中所占用的長度
Elf32_Word p_flags; // 權限屬性(可讀R、可寫W、可執行X)
Elf32_Word p_align; // 對齊屬性(2的p_align次方字節)
} Elf32_Phdr
堆和棧
在操作系統里面,VMA除了被用來映射可執行文件中的各個Segment以外,它還可以有其他的作用,操作系統通過使用VMA來對進程的地址空間進行管理。我們知道進程在執行的時候它還需要用到堆和棧等空間,事實上它們在進程的虛擬空間中的表現也是以VMA的形式存在的,很多情況下,一個進程中的堆和棧分別都有一個對應的VMA。
另外三個段的文件所在設備主設備號和次設備號及文件節點號都是0,則表示它們沒有映射到文件中,這種VMA叫做匿名虛擬內存區域(Anonymous Virtual Memory Area)。我們可以看到有兩個區域分別是堆和棧,這兩個VMA幾乎在所有的進程中存在,我們在C語言程序里面最常用的malloc()內存分配函數就是從堆里面分配的,堆由系統庫管理。棧一般也叫作堆棧,每個線程都有屬於自己的堆棧,對於單線程的程序來講,這個VMA堆棧就全都歸它使用。另外有一個很特殊的VMA叫做“vdso”,它的地址已經位於內核空間了(即大於0xC0000000的地址),事實上它是一個內核的 模塊,進程可以通過訪問這個VMA來跟內核進行一些通信。
小結關於進程虛擬地址空間的概念:
- 操作系統通過給進程空間划分出一個個VMA來管理進程的虛擬空間;
- 基本原則是將相同權限屬性的、有想用映像文件的映射成一個VMA;
- 一個進程基本上可以分為如下幾種VMA區域:
- 代碼VMA,權限只讀、可執行;有映像文件。
- 數據VMA,權限可讀寫、可執行;有映像文件。
- 堆VMA,權限可讀寫、可執行;無映像文件,匿名,可向上擴展。
- 棧VMA,權限可讀寫、不可執行;無映像文件,匿名,可向下擴展。
堆的最大申請數量
malloc的最大申請數量會受到哪些因素的影響呢?實際上,具體的數值會受到操作系統版本、程序本身大小、用到的動態/共享庫數量、大小、程序棧數量、大小等,甚至有可能每次運行的結果都會不同,因為有些操作系統使用了一種叫做隨機地址空間分布的技術(主要是出於安全考慮,防止程序受惡意攻擊),使得進程的堆空間變小。
段地址對齊
可執行文件最終是要被操作系統裝載運行的,這個裝載的過程一般是通過虛擬內存的頁映射機制完成的。在映射過程中,頁是映射的最小單位。我們要映射將一段物理內存和進程虛擬地址空間之間建立映射關系,這段內存空間的長度必須是頁大小的整數倍,並且這段空間在物理內存和進程虛擬地址空間的起始地址必須是頁大小的整數倍。由於有着長度和起始地址的限制,對於可執行文件來說,它應該盡量地優化自己的空間和地址的安排,以節省空間。
一種最簡單的映射辦法就是每個段分開映射,對於長度不足一個頁的部分則占一個頁。這種對齊方式在文件段的內部會有很多內部碎片,浪費磁盤空間。為了解決這種問題,有些UNIX系統采用了一個很取巧的辦法,就是讓那些各個段接壤部分共享一個物理頁面,然后將該物理頁面分別映射兩次。這種映射方式下,對於一個物理頁面來說,它可能同時包含了兩個段的數據,甚至可能是多於兩個段。
因為段地址對齊的關系,各個段的虛擬地址就往往不是系統頁面長度的整數倍了。
進程棧初始化
我們知道進程剛開始啟動的時候,需知道一些進程運行的環境,最基本的就是系統環境變量和進程的運行參數。很常見的一種做法是草早系統在進程啟動前將這些信息提前保存到進程的虛擬空間的棧中(也就是VMA中的Stack VMA)。
進程在啟動以后,程序的庫部分會把堆棧里的初始化信息中的參數信息傳遞給main()函數,也就是我們熟知的main()函數的兩個argc和argv兩個參數,這兩個參數分別對應這里的命令行參數數量和命令行參數字符串指針數組。
Linux內核裝載ELF過程簡介
ELF可執行文件的裝載過程(略)
Windows PE的裝載
PE文件的裝載跟ELF有所不同,由於PE文件中,所有段的起始地址都是頁的倍數,段的長度如果不是頁的整數倍,那么在映射時向上補齊到頁的整數倍,我們也可以簡單地認為在32位的PE文件中,段的起始地址 和長度都是4096字節的整數倍。由於這個特點,PE文件的映射過錯會比ELF簡單得多,因為它無需考慮如ELF里面諸多段地址對齊之類的問題,雖然這樣會浪費一些磁盤和內存空間。PE可執行文件的段的數量一般很少,不像ELF中經常有十多個"Section",最后不得不使用“Segment”的概念把它們合並到一起裝載,PE文件中,鏈接器在生產可執行文件時,往往將所有的段盡可能地合並,所以一般只有代碼段、數據段、只讀數據段和BSS等為數不多的幾個段。
在討論結構的具體裝載過程之前,我們要先引入一個PE里面很常見的術語叫做RVA(Relative Virtual Address),它表示一個相對虛擬地址。其實它的概念很簡單,就是相當於文件中的偏移量的東西。它是相對於PE文件的裝載基地址的一個偏移地址。每個PE文件在裝載時都會有一個裝載目標地址(Target Address),這個地址就是所謂的基地址(Base Address)。由於PE文件被設計成可以裝載到任何地址,所以這個基地址並不是固定的,每次裝載時都可能會變化。如果PE文件中的地址都使用絕對地址它們都要隨着基地址的變化而變化。但是,如果使用RVA這樣一種基於基地址的相對地址,那么無論基地址怎么變化,PE文件中的各個RVA都保持一致。
裝載一個PE可執行文件是個比ELF文件相對簡單的過程:
- 先讀取文件中的第一個頁,在這個頁中,包含了DOS頭、PE文件頭和段表。
- 檢查進程地址空間中,目標地址是否可用,如果不可用,則另外選一個裝載地址。這個問題對於可執行文件來說基本不存在,因為它往往是進程第一個裝入的模塊,所以目標地址不太可能被占用。
- 使用段表中提供的信息,將PE文件中所有的段一一映射到地址空間中相應的位置。
- 如果裝載地址不是目標地址,則進行Rebasing。
- 裝載所有PE文件所需要的DLL文件
- 對PE文件中的所有導入符號進行解析
- 根據PE頭中指定的參數,建立初始化棧和堆。
- 建立主線程並且啟動進程。
成員 | 含義 |
---|---|
Image Base | PE文件的優先裝載地址 |
AddressOfEntryPoint | PE裝載器准備運行的PE文件的第一個指令的RVA |
SectionAlignment | 內存中段對齊的粒度 |
FileAlignment | 文件中段對齊的粒度 |
MajorSubsystemVersion、MinorSubsystemVersion | 程序運行所需要的Win32子系統版本 |
SizeOfImage | 內存中整個PE映像體的尺寸 |
SizeOfHeaders | 所有頭+節表的大小,也就是等於文件尺寸減去文件中所有節的尺寸 |
Subsystem | NT用來識別PE文件術語哪個子系統 |
SizeOfCode | 代碼段的長度 |
SizeOfInitializedData | 初始化了的數據段長度 |
SizeOfUninitializedData | 未初始化的數據段長度 |
BaseOfCode | 代碼段起始RVA |
BaseOfData | 數據段起始RVA |