天天看點

程式員的自我修養--連結、裝載與庫筆記:可執行檔案的裝載與程序

可執行檔案隻有裝載到記憶體以後才能被CPU執行。

1. 程序虛拟位址空間

程式和程序有什麼差別:程式(或者狹義上講可執行檔案)是一個靜态的概念,它就是一些預先編譯好的指令和資料集合的一個檔案;程序則是一個動态的概念,它是程式運作時的一個過程,很多時候把動态庫叫做運作時(Runtime)也有一定的含義。

每個程式被運作起來以後,它将擁有自己獨立的虛拟位址空間(Virtual Address Space),這個虛拟位址空間的大小由計算機的硬體平台決定,具體地說是由CPU的位數決定的。硬體決定了位址空間的最大理論上限,即硬體的尋址空間大小,比如32位的硬體平台決定了虛拟位址空間的位址為0到2^32-1,即0x00000000~0xFFFFFFFF,也就是我們常說的4GB虛拟空間大小;而64位的硬體平台具有64位尋址能力,它的虛拟位址空間達到了2^64位元組,即0x0000000000000000~0xFFFFFFFFFFFFFFFF,總共17179869184GB。

從程式的角度看,我們可以通過判斷C語言程式中的指針所占的空間來計算虛拟位址空間的大小。一般來說,C語言指針大小的位數與虛拟位址空間的位數相同,如32位平台下的指針為32位,即4位元組;64位平台下的指針為64位,即8位元組。當然有些特殊情況下,這種規則不成立。

在下文中以32位的位址空間為主,64位的與32位類似。

那麼32位平台下的4GB虛拟空間,我們的程式是否可以任意使用呢?不行。因為程式在運作的時候處于作業系統的監管下,作業系統為了達到監控程式運作等一系列目的,程序的虛拟空間都在作業系統的掌握之中。程序隻能使用那些作業系統配置設定給程序的位址,如果通路未經允許的空間,那麼作業系統就會捕獲到這些通路,将程序的這種通路當作非法操作,強制結束程序。我們經常在Windows下碰到”程序因非法操作需要關閉”或Linux下的”Segmentation fault”很多時候是因為通路了未經允許的位址。

PAE(Physical Address Extension):從硬體層面上來講,原先的32位位址線隻能通路最多4GB的實體記憶體。但是自從擴充至36位位址線之後,Intel修改了頁映射的方式,使得新的映射方式可以通路到更多的實體記憶體。Intel把這個位址擴充方式叫做PAE。擴充的實體位址空間,對于普通應用程式來說正常情況下感覺不到它的存在,因為這主要是作業系統的事,在應用程式裡,隻有32位的虛拟位址空間。那麼應用程式該如何使用這些大于正常的記憶體空間呢?一個很常見的方法就是作業系統提供一個視窗映射的方法,把這些額外的記憶體映射到程序位址空間中來。在Windows下,這種通路記憶體的操作方式叫做AWE(Address Windowing Extensions);而像Linux等UNIX類作業系統則采用mmap()系統調用來實作。

2. 裝載的方式

程式執行時所需要的指令和資料必須在記憶體中才能夠正常運作,最簡單的方法就是将程式運作所需要的指令和資料全都裝入記憶體中,這樣程式就可以順利運作,這就是最簡單的靜态裝入的方法。但是很多情況下程式所需要的記憶體數量大于實體記憶體的數量,當記憶體的數量不夠時,根本的解決辦法就是添加記憶體。程式運作時是有局部性原理,是以我們可以将程式最常用的部分駐留在記憶體中,而将一些不太常用的資料存放在磁盤裡面,這就是動态裝入的基本原理。

覆寫裝入(Overlay)和頁映射(Paging)是兩種很典型的動态裝載方法,它們所采用的思想都差不多,原則上都是利用了程式的局部性原理。動态裝入的思想就是程式用到哪個子產品,就将哪個子產品裝入記憶體,如果不用就暫時不裝入,存放在磁盤中。

覆寫裝入:在沒有發明虛拟存儲之前使用比較廣泛,現在已經幾乎被淘汰了。覆寫裝入的方法把挖掘記憶體嵌入的任務交給了程式員,程式員在編寫程式的時候必須手工将程式分割成若幹塊,然後編寫一個小的輔助代碼來管理這些子產品何時應該駐留記憶體而何時應該被替換掉。這個小的輔助代碼就是所謂的覆寫管理器(Overlay Manager)。覆寫裝入是典型的利用時間換取空間的方法。

頁映射:是虛拟存儲機制的一部分,它随着虛拟存儲的發明而誕生。與覆寫裝入的原理相似,頁映射也不是一下子就把程式的所有資料和指令都裝入記憶體,而是将記憶體和所有磁盤中的資料和指令按照”頁(Page)”為機關劃分成若幹個頁,以後所有的裝載和操作的機關都是頁。硬體規定頁的大小有4096位元組、8192位元組、2MB、4MB等。

3. 從作業系統角度看可執行檔案的裝載

程序的建立:從作業系統的角度來看,一個程序最關鍵的特征是它擁有獨立的虛拟位址空間,這使得它有别于其它程序。很多時候一個程式被執行同時都伴随着一個新的程序的建立。建立一個程序,然後裝載相應的可執行檔案并且執行。在有虛拟存儲的情況下,上述過程最開始隻需要做三件事情:

(1). 首先是建立虛拟位址空間:一個虛拟空間由一組頁映射函數将虛拟空間的各個頁映射至相應的實體空間,那麼建立一個虛拟空間實際上并不是建立空間而是建立映射函數所需要的相應的資料結構。在i386的Linux下,建立虛拟位址空間實際上隻是配置設定一個頁目錄(Page Directory)就可以了,甚至不設定頁映射關系,這些映射關系等到後面程式發生頁錯誤的時候再進行設定。

(2). 讀取可執行檔案頭,并且建立虛拟空間與可執行檔案的映射關系:上面那一步的頁映射關系函數是虛拟空間到實體記憶體的映射關系,這一步所做的是虛拟空間與可執行檔案的映射關系。當程式執行發生頁錯誤時,作業系統将從實體記憶體中配置設定一個實體頁,然後将該”缺頁”從磁盤中讀取到記憶體中,再設定缺頁的虛拟頁和實體頁的映射關系,這樣程式才得以正常運作。當作業系統捕獲到缺頁錯誤時,它應知道程式目前所需要的頁在可執行檔案中的哪一個位置。這就是虛拟空間與可執行檔案之間的映射關系。從某種角度來看,這一步是整個裝載過程中最重要的一步,也是傳統意義上”裝載”的過程。

由于可執行檔案在裝載時實際上是被映射的虛拟空間,是以可執行檔案很多時候又被叫做映像檔案(Image)。

Linux中将程序虛拟空間中的一個段叫做虛拟記憶體區域(VMA, Virtual Memory Area),在Windows中将這個叫做虛拟段(Virtual Section),其實它們都是同一個概念。

(3). 将CPU指令寄存器設定成可執行檔案入口,啟動運作:作業系統通過設定CPU的指令寄存器将控制權轉交給程序,由此程序開始執行。可執行檔案入口即是ELF檔案頭中儲存的入口位址。

頁錯誤(Page Fault):随着程序的執行,頁錯誤也會不斷地産生,作業系統也會為程序配置設定相應的實體頁來滿足程序執行的需求。

4. 程序虛存空間分布

ELF檔案連結視圖和執行視圖:在一個正常的程序中,可執行檔案中包含的往往不止代碼段,還有資料段、BSS等,是以映射到程序虛拟空間的往往不止一個段。當段的數量增多時,就會産生空間浪費的問題。ELF檔案被映射時,是以系統的頁長度作為機關的,那麼每個段在映射時的長度應該都是系統頁長度的整數倍;如果不是,那麼多餘部分也将占用一個頁。一個ELF檔案中往往有十幾個段,那麼記憶體空間的浪費是可想而知的。

ELF檔案中,段的權限往往隻有為數不多的幾種組合,基本上是三種:(1).以代碼段為代表的權限為可讀可執行的段;(2).以資料段和BSS段為代表的權限為可讀可寫的段;(3).以隻讀資料段為代表的權限為隻讀的段。對于相同權限的段,把它們合并到一起當作一個段進行映射。

ELF可執行檔案引入了一個概念叫做”Segment”,一個”Segment”包含一個或多個屬性類似的”Section”。從連結的角度看,ELF檔案是按”Section”存儲的;從裝載的角度看,ELF檔案又可以按照”Segment”劃分。”Segment”的概念實際上是從裝載的角度重新劃分了ELF的各個段。在将目标檔案連結成可執行檔案的時候,連結器會盡量把相同權限屬性的段配置設定在同一空間。比如可讀可執行的段都放在一起,這種段的典型是代碼段;可讀可寫的段放在一起,這種段的典型是資料段。在ELF中把這些屬性相似的、又連在一起的段叫做一個”Segment”,而系統正是按照”Segment”而不是”Section”來映射可執行檔案的。

下面是一個很小的例子程式(SectionMapping.c):

#include <stdlib.h>

int main()
{
	while (1) {
		sleep(1000);
	}

	return 0;
}
           

使用靜态連結的方式将其編譯連結成可執行檔案SectionMapping.elf,執行:

gcc -static SectionMapping.c -o SectionMapping.elf
           

使用readelf可以看到,可執行檔案SectionMappint.elf中總共有31個段(Section),如下圖所示:

程式員的自我修養--連結、裝載與庫筆記:可執行檔案的裝載與程式

正如描述”Section”屬性的結構叫做段表,描述”Segment”的結構叫程式頭(Program Header),它描述了ELF檔案該如何被作業系統映射到程序的虛拟空間,執行結果如下圖所示:

程式員的自我修養--連結、裝載與庫筆記:可執行檔案的裝載與程式

可以看到,這個可執行檔案共有6個Segment。從裝載的角度看,目前隻關心兩個”LOAD”類型的Segment,因為隻有它是需要被映射的,其它的諸如”NOTE”、”TLS”、”GNU_STACK”都是在裝載時起輔助作用的。所有相同屬性的”Section”被歸類到一個”Segment”,并且映射到同一個VMA。總的來說,”Segment”和”Section”是從不同的角度來劃分同一個ELF檔案。這個在ELF中被稱為不同的視圖(View),從”Section”的角度來看ELF檔案就是連結視圖(Linking View),從”Segment”的角度來看就是執行視圖(Execution View)。當我們在談到ELF裝載時,”段”專門指”Segment”;而在其它的情況下,”段”指的是”Section”。

ELF可執行檔案中有一個專門的資料結構叫做程式頭表(Program Header Table)用來儲存”Segment”的資訊。因為ELF目标檔案不需要被裝載,是以它沒有程式頭表,而ELF的可執行檔案和共享庫檔案都有。跟段表結構一樣,程式頭表也是一個結構體數組,它的結構體Elf32_Phdr或Elf64_Phdr(聲明在/usr/include/elf.h)如下:

/* Program segment header.  */
typedef struct
{
  Elf32_Word    p_type;                 /* Segment type */
  Elf32_Off     p_offset;               /* Segment file offset */
  Elf32_Addr    p_vaddr;                /* Segment virtual address */
  Elf32_Addr    p_paddr;                /* Segment physical address */
  Elf32_Word    p_filesz;               /* Segment size in file */
  Elf32_Word    p_memsz;                /* Segment size in memory */
  Elf32_Word    p_flags;                /* Segment flags */
  Elf32_Word    p_align;                /* Segment alignment */
} Elf32_Phdr;

typedef struct
{
  Elf64_Word    p_type;                 /* Segment type */
  Elf64_Word    p_flags;                /* Segment flags */
  Elf64_Off     p_offset;               /* Segment file offset */
  Elf64_Addr    p_vaddr;                /* Segment virtual address */
  Elf64_Addr    p_paddr;                /* Segment physical address */
  Elf64_Xword   p_filesz;               /* Segment size in file */
  Elf64_Xword   p_memsz;                /* Segment size in memory */
  Elf64_Xword   p_align;                /* Segment alignment */
} Elf64_Phdr;
           

Elf32_Phdr或Elf64_Phdr結構體的幾個成員與使用”readelf -l”列印檔案頭表顯示的結果一一對應。結構體的各個成員的基本含義,如下表所示:

程式員的自我修養--連結、裝載與庫筆記:可執行檔案的裝載與程式

對于”LOAD”類型的”Segment”來說,p_memsz的值不可以小于p_filesz,否則就是不符合常理的。如果p_memsz大于p_filesz,就表示該”Segment”在記憶體中所配置設定的空間大小超過檔案中實際的大小,這部分”多餘”的部分則全部填充為”0”。這樣做的好處是,我們在構造ELF可執行檔案時不需要再額外設立BSS的”Segment”了,可以把資料”Segment”的p_memsz擴大,那些額外的部分就是BSS。因為資料段和BSS的唯一差別就是:資料段從檔案中初始化内容,而BSS段的内容全都初始化為0。這也就是在前面的例子中隻看到了兩個”LOAD”類型的段,而不是三個,BSS已經被合并到了資料類型的段裡面。

堆和棧:在作業系統裡面,VMA除了被用來映射可執行檔案中的各個”Segment”以外,它還可以有其它的作用,作業系統通過使用VMA來對程序的位址空間進行管理。程序在執行的時候它還需要用到棧(Stack)、堆(Heap)等空間,事實上它們在程序的虛拟空間中的表現也是以VMA的形式存在的,很多情況下,一個程序中的棧和堆分别都有一個對應的VMA。

在Linux下,可以通過檢視”/proc”來檢視程序的虛拟空間分布,如下圖所示:

程式員的自我修養--連結、裝載與庫筆記:可執行檔案的裝載與程式

上圖的輸出結果中:第一列是VMA的位址範圍;第二列是VMA的權限,”r”表示可讀,”w”表示可寫,”x”表示可執行,”p”表示私有(COW, Copy on Write),”s”表示共享。第三列是偏移,表示VMA對應的Segment在映像檔案中的偏移;第四清單示映像檔案所在裝置的主裝置号和次裝置号;第五清單示映像檔案的節點号。最後一列是映像檔案的路徑。我們可以看到程序中有8個VMA,隻有前兩個是映射到可執行檔案中的兩個Segment。另外六個段的檔案所在裝置主裝置号和次裝置号及檔案節點号都是0,則表示它們沒有映射到檔案中,這種VMA叫做匿名虛拟記憶體位址(Anonymous Virtual Memory Area)。我們可以看到有兩個區域分别是堆(Heap)和棧(Stack),它們的大小分别為(0x0122d000-0x0120a000)/1024=140KB和(0x7ffc01c44000-0x7ffc01c23000)/1024=132KB。這兩個VMA幾乎在所有的程序中存在,我們在C語言程式裡面最常用的malloc()記憶體配置設定函數就是從堆裡面配置設定的,堆由系統庫管理。棧一般也叫做堆棧,每個線程都有屬于自己的堆棧,對于單線程的程式來講,這個VMA堆棧就全都歸它使用。另外有一個很特殊的VMA叫做”vdso”,它的位址已經位于核心空間了,事實上它是一個核心的子產品,程序可以通過通路這個VMA來跟核心進行一些通信。

程序虛拟位址空間的概念:作業系統通過給程序空間劃分出一個個VMA來管理程序的虛拟空間;基本原則是将相同權限屬性的、有相同映像檔案的映射成一個VMA;一個程序基本上可以分為如下幾種VMA區域:(1). 代碼VMA,權限隻讀、可執行;有映像檔案。(2). 資料VMA,權限可讀寫、可執行;有映像檔案。(3). 堆VMA,權限可讀寫、可執行;無映像檔案,匿名,可向上擴充。(4). 棧VMA,權限可讀寫、不可執行;無映像檔案,匿名,可向下擴充。當我們在讨論程序虛拟空間的”Segment”的時候,基本上就是指上面的幾種VMA。

堆的最大申請數量:32位,Linux下虛拟位址空間分給程序本身的是3GB(Windows預設是2GB),一般程式中使用malloc()函數進行位址空間的申請,那麼malloc的最大申請數量會受到作業系統版本、程式本身大小、用到的動态/共享庫數量大小、程式棧數量大小等,甚至有可能每次最大可申請數量都會不同,因為有些作業系統使用了一種叫做随機位址空間分布的技術(主要是出于安全考慮,防止程式受惡意攻擊),使得程序的堆空間變小。

段位址對齊:可執行檔案最終是要被作業系統裝載運作的,這個裝載的過程一般是通過虛拟記憶體的頁映射機制完成的。在映射過程中,頁是映射的最小機關。對于Intel 80x86系列處理器來說,預設的頁大小為4096位元組,也就是說,我們要映射将一段實體記憶體和程序虛拟位址空間之間建立映射關系,這段記憶體空間的長度必須是4096的整數倍,并且這段空間在實體記憶體和程序虛拟位址空間中的起始位址必須是4096的整數倍。由于有着長度和起始位址的限制,對于可執行檔案來說,它應該盡量地優化自己的空間和位址的安排,以節省空間。在ELF檔案中,對于任何一個可裝載的”Segment”,它的p_vaddr除以對齊屬性的餘數等于p_offset除以對齊屬性的餘數。

程序棧初始化:程序剛開始啟動的時候,須知道一些程序運作的環境,最基本的就是系統環境變量和程序的運作參數。很常見的一種做法是作業系統在程序啟動前将這些資訊提前儲存到程序的虛拟空間的棧中(也就是VMA中的Stack VMA)。

5. Linux核心裝載ELF過程簡介

當我們在Linux系統的bash下輸入一個指令執行某個ELF程式時,首先在使用者層面,bash程序會調用fork()系統調用建立一個新的程序,然後新的程序調用execve()系統調用執行指定的ELF檔案,原先的bash程序繼續傳回等待剛才啟動的新程序結束,然後繼續等待使用者輸入指令。execve()系統調用被聲明在/usr/include/unistd.h中。Glibc對execve()系統調用進行了包裝,提供了execl()、execlp()、execle()、execv()和execvp()等5個不同形式的exec系列API,它們隻是在調用的參數形式上有所差別,但最終都會調用到execve()這個系統中。

在進入execve()系統調用之後,Linux核心就開始進行真正的裝載工作。在核心中,execve()系統調用相應的入口是sys_execve(),sys_execve()進行一些參數的檢查複制之後,調用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()去搜尋和比對合适的可執行檔案裝載處理過程。Linux中所有被支援的可執行檔案格式都有相應的裝載處理過程,search_binary_handle()會通過判斷檔案頭部的魔數确定檔案的格式,并且調用相應的裝載處理過程。比如ELF可執行檔案的裝載處理過程叫做load_elf_binary();a.out可執行檔案的裝載處理過程叫做load_aout_binary();而裝載可執行腳本程式的處理過程叫做load_script()。

load_elf_binary()的主要步驟是:

(1). 檢查ELF可執行檔案格式的有效性,比如魔數、程式頭表中段(Segment)的數量。

(2). 尋找動态連結的”.interp”段,設定動态連結器路徑。

(3). 根據ELF可執行檔案的程式頭表的描述,對ELF檔案進行映射,比如代碼、資料、隻讀資料。

(4). 初始化ELF程序環境,比如程序啟動時EDX寄存器的位址應該是DT_FINI的位址。

(5). 将系統調用的傳回位址修改成ELF可執行檔案的入口點,這個入口點取決于程式的連結方式,對于靜态連結的ELF可執行檔案,這個程式入口就是ELF檔案的檔案頭中e_entry所指的位址;對于動态連結的ELF可執行檔案,程式入口點就是動态連結器。

當load_elf_binary()執行完畢,傳回至do_execve()再傳回sys_execve()時,上面的第5步中已經把系統調用的傳回位址改成了被裝載的ELF程式的入口位址了。是以當sys_execve()系統調用從核心态傳回到使用者态時,EIP寄存器直接跳轉到了ELF程式的入口位址,于是新的程式開始執行,ELF可執行檔案加載完成。

6. Windows PE的裝載

PE檔案的裝載跟ELF有所不同,由于PE檔案中,所有段的起始位址都是頁的倍數,段的長度如果不是頁的整數倍,那麼在映射時向上補齊到頁的整數倍,我們也可以簡單地認為在32位的PE檔案中,段的起始位址和長度都是4096位元組的整數倍。由于這個特點,PE檔案的映射過程會比ELF簡單得多,因為它無須考慮如ELF裡面諸多段位址對齊之類的問題,雖然這種會浪費一些磁盤和記憶體空間。PE可執行檔案的段的數量一般很少,不像ELF中經常有十多個”Section”,最後不得不使用”Segment”的概念把它們合并到一起裝載,PE檔案中,連結器在生産可執行檔案時,往往将所有的段盡可能地合并,是以一般隻有代碼段、資料段、隻讀資料段和BSS等為數不多的幾個段。

PE裡面很常見的術語叫做RVA(Relative Virtual Address),它表示一個相對虛拟位址,就是相當于檔案中的偏移量的東西。它是相對于PE檔案的裝載基位址的一個偏移位址。比如,一個PE檔案被裝載到虛拟位址(VA)0x00400000,那麼一個RVA為0x1000的位址就是0x00401000。每個PE檔案在裝載時都會有一個裝載目标位址(Target Address),這個位址就是所謂的基位址(Base Address)。由于PE檔案被設計成可以裝載到任何位址,是以這個基位址并不是固定的,每次裝載時都可能會變化。如果PE檔案中的位址都使用絕對位址,它們都要随着基位址的變化而變化。但是,如果使用RVA這樣一種基于基位址的相對位址,那麼無論基位址怎麼變化,PE檔案中的各個RVA都保持一緻。

裝載一個PE可執行檔案過程:

(1). 先讀取檔案的第一個頁,在這個頁中,包含了DOS頭、PE檔案頭和段表。

(2). 檢查程序位址空間中,目标位址是否可用,如果不可用,則另外選一個裝載位址。這個問題對于可執行檔案來說基本不存在,因為它往往是程序第一個裝入的子產品,是以目标位址不太可能被占用。主要是針對DLL檔案的裝載而言的。

(3). 使用段表中提供的資訊,将PE檔案中所有的段一一映射到位址空間中相應的位置。

(4). 如果裝載位址不是目标位址,則進行Rebasing。

(5). 裝載所有PE檔案所需要的DLL檔案。

(6). 對PE檔案中的所有導入符号進行解析。

(7). 根據PE頭中指定的參數,建立初始化棧和堆。

(8). 建立主線程并且啟動程序。

PE檔案中,與裝載相關的主要資訊都包含在PE擴充頭(PE Optional Header)和段表。

GitHub:https://github.com/fengbingchun/Messy_Test

繼續閱讀