《程序員的自我修養》學習筆記(五)————可執行文件的裝載與進程

        可執行文件只有裝載到內存中以後才能被CPU執行。早期的程序裝載的基本過程就是把程序從外部存儲器讀到內存中的某個位置。隨着硬件MMU的誕生,多進程、多用戶、虛擬存儲的操作系統的出現,裝載過程變得複雜起來。程序,也就是可執行文件,是一個靜態的概念,裝載到內存中以後就成爲了進程,進程是一個動態的概念,正所謂“Process is a program in execution”。

1.虛擬地址空間

         現在的電腦和操作系統都支持虛擬內存技術,由MMU(Memory Management Unit)進行虛擬地址和物理地址的相互轉換。每個進程擁有獨立的虛擬地址空間(Virtual Address Space),它的大小由計算機的硬件平臺決定。32位的CPU下,程序中能訪問的虛擬地址空間最大爲4G,但是實際上可用的計算機的物理內存空間可以通過PAE(Physical Address Extension),AWE(Address Windowing Extensions)等方式進行擴大。PAE主要是通過擴展地址線來實現的,32位地址線最大4G內存,36位地址線則擴大了物理地址大小,再通過相應的映射方法來使用擴大的物理內存空間。

2.裝載方式

         程序執行時所需要的指令和數據必須在內存中才能正常運行,最簡單的方法就是把程序運行需要的指令和數據全部裝入內存中,這就是靜態裝載。但是這樣會浪費寶貴的內存,並且很多情況下,程序所需要的內存大小大於物理內存。根據局部性原理,我們可以將程序最常用的部分放在內存中,不常用的放在磁盤裏,用的時候再裝入,這就是動態裝載基本原理。覆蓋裝入(Overlay)和頁映射(Paging)是兩種典型的動態裝載的方法。

         覆蓋裝入在虛擬內存技術發明之前使用比較廣泛。程序員在編寫程序時將程序分割成若干塊,然後編寫一個小小的輔助代碼,也就是覆蓋管理器(Overlay Manager)來管理這些模塊何時應該駐留在內存,何時應該被替換掉。程序員需要將這些模塊依據調用關係組織成樹狀結構,以便於確定何時覆蓋和裝入某個模塊。跨模塊的調用都要經過覆蓋管理器,以確保被調用的模塊都在內存中,如果不在還要從磁盤讀取裝入,速度比較慢。

         頁映射是虛擬內存技術的一部分,它將程序和內存以 “頁”(Page)爲單位進行劃分,裝載的單位就是頁。將需要用到的頁從磁盤載入內存中,如果物理內存已被用完,就要由頁替換算法決定被替換的頁。幾乎目前所有的主流操作系統都採用的這種方式裝載可執行文件。

3. 從操作系統角度看可執行文件的裝載

         事實上,從操作系統的角度來看,一個進程最關鍵的特徵是它擁有獨立的虛擬地址空間。創建一個進程,然後裝載相應的可執行文件並且執行,上述過程最開始只需要做三件事:
(1)創建一個獨立的虛擬地址空間。
(2)讀取可執行文件,並且建立虛擬地址空間與可執行文件的映射關係。
(3)將CPU的指令寄存器設置成可執行文件的入口地址,啓動運行。

        創建虛擬地址空間。一個虛擬空間由一組頁映射函數將虛擬空間的各個頁映射至相應的物理空間,那麼創建一個虛擬空間實際上並不是創建空間而是創建映射函數所需要的相應的數據結構,在Linux下,創建虛擬地址空間實際上只是分配一個頁目錄就可以了,甚至不設置映射關係,這些映射關係等後面程序發生也錯誤的時候再進行設置。
        讀取可執行文件,並且建立虛擬地址空間與可執行文件的映射關係。上面那一步的頁映射關係函數是虛擬空間到物理內存的映射關係,這一步所做的是虛擬空間與可執行文件的映射關係。我們知道,當程序執行發生也錯誤的時候,操作系統將從物理內存中分配一個物理頁,然後將該“缺頁”從磁盤中讀取到內存中,再設置缺頁的虛擬頁與物理頁的映射關係,這樣程序才能得以正常運行。但是很明顯的一點是,當操作系統捕獲到缺頁錯誤的時候,它應該知道程序當前所需要的頁在可執行文件中的哪一個位置。這就是虛擬空間與可執行文件之間的映射關係。從某種程度來說,這一步是整個裝載過程中最重要的一步,也是傳統意義上“裝載”的過程。
        將CPU的指令寄存器設置成可執行文件的入口地址,啓動運行。第三步其實也是最簡單的一部,操作系統通過設置CPU的指令寄存器將控制權轉交給進程,由此進程開始執行。此過程中可以簡單地認爲操作系統執行了一條跳轉指令,直接跳轉到可執行文件的入口地址,這個入口地址就是ELF文件頭中保存的入口地址。

4. ELF文件的鏈接視圖和執行視圖

         一個ELF可執行文件往往由很多個節構成,在操作系統裝載可執行文件時,如果把每個節映射到一個頁,由於它們大小不一樣,肯定會產生內存浪費。對於操作系統來說,實際上在裝載時它主要關心頁的權限問題,即可讀、可寫或可執行。而ELF文件中的節的權限往往只有爲數不多的幾種組合:
可讀,可執行,如代碼節
可讀,可寫,如數據節和.bss節
只讀,如只讀數據節

          那麼對於權限相同的節,完全可以把它們合併到一起進行映射。ELF文件引入了一個概念叫做段(Segment),一個段包括一個或多個屬性類似的節(Section)。裝載的時候就把一個段當作一個整體進行映射。這樣可以明顯的減少頁面內部的碎片,節省內存空間。從鏈接的角度看,ELF文件是按照節存儲的,從裝載的角度看,ELF文件又可以按照段進行劃分。從節的角度來看ELF文件就是鏈接視圖(Linking View),從段的角度來看就是執行視圖(Execution View)。段的概念實際上是從裝載的角度重新劃分了ELF的各個節。在將目標文件鏈接成可執行文件時,鏈接器會盡量把權限屬性相同的節分配在同一空間。在ELF中把這些屬性相似的、又連在一起的段叫做一個”Segment”,而系統正是按照”Segment”而不是”Section”來映射可執行文件的。

#include <stdlib.h>
int main(void)
{
	while(1)
	{
		sleep(1000);
	}
	return 0;
}

gcc -static SimpleSection.c -o SimpleSection.elf 

我們以上面的簡單程序爲例,使用

readelf -S SimpleSection.elf

可以發現,可執行文件有33個段(Section)。

         正如描述節的屬性的結構叫做節表,描述段的屬性的結構叫做程序頭表(Program Header Table),它描述了ELF文件如何被操作系統映射到進程的虛擬內存空間。由於ELF目標文件不需要被裝載,它沒有程序頭表,而ELF可執行文件和共享文件都有。

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

/* 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;

         對於”LOAD”類型的”Segment”來說,p_memsz的值不可以小於p_filesz,否則就是不符合常理的。如果p_memsz大於p_filesz, 就表示該”Segment”在內存中所分配的空間大小超過文件中實際的大小,這部分”多餘”的部分則全部填充爲”0”。這樣做的好處是,我們在構造ELF可執行文件時不需要再額外設立BSS的”Segment”了,可以把數據”Segment”的p_memsz擴大,那些額外的部分就是BSS。因爲數據段和BSS的唯一區別就是:數據段從文件中初始化內容,而BSS段的內容全都初始化爲0。這也就是在前面的例子中只看到了兩個”LOAD”類型的段,而不是三個,BSS已經被合併到了數據類型的段裏面。

5 堆和棧

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

        上圖的輸出結果中:第一列是VMA的地址範圍;第二列是VMA的權限,”r”表示可讀,”w”表示可寫,”x”表示可執行,”p”表示私有(COW, Copy on Write),”s”表示共享。第三列是偏移,表示VMA對應的Segment在映像文件中的偏移;第四列表示映像文件所在設備的主設備號和次設備號;第五列表示映像文件的節點號。最後一列是映像文件的路徑。我們可以看到進程中有7個VMA,只有前兩個是映射到可執行文件中的兩個Segment。另外五個段的文件所在設備主設備號和次設備號及文件節點號都是0,則表示它們沒有映射到文件中,這種VMA叫做匿名虛擬內存地址(Anonymous Virtual Memory Area)。我們可以看到有兩個區域分別是堆(Heap)和棧(Stack),這兩個VMA幾乎在所有的進程中存在,我們在C語言程序裏面最常用的malloc()內存分配函數就是從堆裏面分配的,堆由系統庫管理。棧一般也叫做堆棧,每個線程都有屬於自己的堆棧,對於單線程的程序來講,這個VMA堆棧就全都歸它使用。另外有一個很特殊的VMA叫做”vdso”,它的地址已經位於內核空間了,事實上它是一個內核的模塊,進程可以通過訪問這個VMA來跟內核進行一些通信。

6 段地址對齊

        裝載的過程一般是通過虛擬內存的頁映射機制完成的。在映射的過程中,頁是最小單位。對於Intel 80x86系列處理器來說,默認的頁大小爲4096字節。也就是說如果我們要將一段物理內存和進程的虛擬地址空間之間建立映射關係,這段內存空間的長度必須是4096的整數倍,並且這段空間在物理內存和進程虛擬地址空間中的起始地址必須是4096的整數倍。那麼可執行文件就要儘量優化自己的空間和地址安排,以節省空間。
         最簡單的做法就是每個段分開映射,長度不足一個頁的部分也佔據一個頁,也就是說段的首地址對齊到了4096的整數倍。但是這樣會造成很多內部碎片。爲了解決這種問題,有些UNIX系統採用了一種取巧的方法,就是讓那些各個段接壤的部分共享一個物理頁面,然後將該物理頁面分別映射兩次。從某種角度看,好像是整個ELF文件從文件開頭到某個點結束,被邏輯上分成了以4096字節爲單位的若干個塊,每個塊都被裝載到物理內存中去。那些包含了多個段的物理內存中的塊,將會被映射到虛擬地址空間中多次。當然不同的段還有自己的不同對齊屬性,這一點在爲ELF文件分配虛擬內存空間時也要考慮。

7 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可執行文件加載完成。

 

 

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章