可執行檔案的裝載與程式
介紹ELF檔案在Linux下的裝載過程,探尋可執行檔案裝載的本質
- 什麼是程式的虛擬地址空間
- 為什麼程式要有自己獨立的虛擬地址空間
- 幾種裝載方式
- 程式虛擬地址空間的分佈情況
程式虛擬地址空間
32位硬體平臺決定了虛擬地址空間的地址為0 到2^32 -1,即0x00000000 ~ 0xFFFFFFFF
,也就是4GB的虛擬空間大小;而63位的硬體平臺具有64位定址能力,它的虛擬地址空間達到了2^64 位元組,即0x0000000000000000 ~ 0xFFFFFFFFFFFFFFFF
,總共17179869184GB
。
而從程式的角度看,C語言中的指標所佔空間可用於計算虛擬地址空間的大小,一般情況下,C語言指標大小的位數與虛擬空間的位數相同,如32位平臺下的指標為32位,4位元組。
以下以32為地址空間為主,64位作為擴充套件。
預設情況下Linux系統將程式的虛擬地址空間作如下分配:
其中的作業系統使用的空間,程式是不被允許訪問的,且程式並不能完全使用剩下的3GB虛擬空間,其中一部分是預留給其他用途的。
PAE
Linux下Intel在1995年的Pentium Pro CPU便開始使用36位的實體地址,即可以訪問64GB的實體記憶體。這時,作業系統只能有4GB的虛擬地址空間,無法全部讀取完64GB的實體記憶體,而PAE就是為了解決這個問題出現的。
PAE(Physical Address Extension)是一種地址擴充套件方式,Inter修改了頁對映的方式後,使得新的對映方式可以訪問更多的實體記憶體。作業系統提供一個視窗對映的方法,將額外的記憶體對映進地址空間中。應用程式根據需要選擇申請和對映。比如應用程式中的0x10000000~0x20000000這一段256MB的虛擬地址空間作為視窗,程式從高於4GB的物理空間中申請多個大小為256MB的物理空間,編號為A,B,C,然後根據需要將視窗對映到不同的物理空間塊,用到A時將0x10000000~0x20000000對映到A,用到B,C時在對映過去,如此重複。在Windows下,這種記憶體操作方式為AWE(Address Windows Extensions)。像Linux等UNIX系統則採用mmp()系統呼叫來實現。
裝載方式
覆蓋裝入
沒有發明虛擬儲存之前使用得比較廣泛,現已幾乎被淘汰。
覆蓋裝入的方法把挖掘記憶體潛力的任務交給了程式設計師程式設計師在編寫程式時必須手動將程式分割成若干塊,然後編寫小的輔助程式碼管理這些模組何時駐留在記憶體,何時被替換。這個輔助程式碼被稱為覆蓋管理器(Overlay Manager),比如下圖
模組A與B之間相互沒有呼叫依賴關係,因此兩模組共享記憶體區域,當
使用A時則覆蓋該記憶體,使用B時覆蓋該記憶體,覆蓋管理器則作為常駐記憶體。
多模組則如下,程式設計師需要手工將模組按照它們之間的呼叫依賴關係組織成樹狀結構。
因此覆蓋管理器需要保證一下亮點。
-
樹狀結構中從任何一個模組到樹的根(main)都叫呼叫路徑,當模組被呼叫時,這個呼叫路徑上的模組必須在記憶體之中。比如C模組正在執行時,B和main都需要在記憶體中,確保E執行完畢後能正確返回到模組B和main。
-
禁止跨樹間呼叫
任意模組不允許跨樹狀結構進行呼叫,比如A不可以呼叫B,E,F。但很多時候兩個模組都依賴於同一個模組,如模組E和模組C需要另外一個模組G,則最方便的方法就是把模組G併入到main模組中,這樣G就在E和C的呼叫路徑上了。
頁對映
頁對映是虛擬儲存機制的一部分,隨著虛擬儲存的發明而誕生。
頁對映將記憶體和所有磁碟中的資料及指令按照**頁(Page)**為單位劃分若干頁,以後所有的裝載和操作單位就是頁。
假設程式所有的指令和資料總共32KB,那麼程式被分為8頁,並編號P0~P7。但16KB記憶體無法將32KB程式裝入,此時將按照動態裝入的原理進行裝入過程。如果程式執行入口在P0則裝載管理器發現程式的P0不在記憶體中,則將記憶體F0分配給P0,並將P0的記憶體扎un購入F0中。執行後使用P5,則將P4裝入F1,以此類推,如下所示:
如果程式繼續執行需要訪問P4,則裝載管理器必須選擇放棄目前正在使用的4哥記憶體頁中的其中一個來裝載P4,放棄的演算法有很多,如:
- FIFO先進先出,則放棄F0,P4裝入F0
- LUR最少使用,則放棄F2,P4裝入F2
等演算法。而這裡所謂的裝載管理器就是現代的作業系統,準確說影視就是作業系統的儲存管理器。
從作業系統角度看可執行檔案的裝載並在程式中執行
程式的建立
程式關鍵特徵在於它擁有獨立的虛擬地址空間。一個程式被執行,往往在最開始時需要做三件事:
-
建立獨立的虛擬地址空間
即建立對映函式所需要的相應的資料結構,而在i386的Linux下,建立虛擬地址空間實際上只是分配一個頁目錄,甚至不需要設定對映關係。也就是完成虛擬空間到實體記憶體的對映關係。
-
讀取可執行檔案頭,建立虛擬空間與可執行檔案的對映關係
完成虛擬空間與可執行檔案的對映關係,這一步是整個裝載過程中最重要的一步,也就是傳統意義上的“裝載”。
如圖,考慮最簡單的例子,虛擬地址如圖,檔案大小為0x000e1,對齊為0x1000。由於.text段大小不到0x1000,因此需要對齊。
這種對映關係是儲存在作業系統內部的一個資料結構。Linux將程式虛擬空間中的一個段叫做虛擬記憶體區域(VMA.Virtual Memory Area)。windows叫做虛擬段(Virtual Section)。在上面例子中,會在程式相應的資料結構中設定有一個.text段的VMA,它在虛擬空間中的地址為
0x08048000~0x08049000
對應ELF檔案中偏移為0的.text,屬性為只讀。 -
將CPU的指令暫存器設定成可執行檔案的入口地址,啟動執行
頁錯誤
上述步驟執行完後,只是通過可執行檔案頭部資訊建立起可執行檔案和程式虛存之間的對映關係,並沒有將可執行檔案的指令和資料裝入記憶體。
假設在上面的例子中,程式入口地址為0x08048000
,剛好是.text段的其實地址,CPU打算執行時發現為空頁面時,便認為這是個頁錯誤(Page Fault)。CPU將控制權交給作業系統,作業系統將查詢上面說到的資料結構,找到空頁面所在VMA,計算出相應的頁面在可執行檔案中的偏移,再在實體記憶體中分配一個物理頁面,將程式中該虛擬頁與分配的物理頁之間建立對映關係,再把控制權還給程式,程式從剛才頁錯誤的位置重新開始執行,如下圖所示,為可執行檔案,程式虛存與實體記憶體之間的關係:
程式虛存空間分佈
ELF檔案連結檢視和執行檢視
在實際場景裡面,ELF檔案段數量是比較多的,但由於需要進行頁對齊等操作,如果以一個段進行頁的分配的話,勢必會造成較大的浪費。
但是在作業系統的角度來看裝載可執行檔案(作業系統並不需要知道哪個段名稱是什麼,作用如何等等資訊),發現並不關心可執行檔案實際內容,而只是關心跟裝載相關的問題,最主要的就是段的許可權問題。
段的許可權組合基本是以下三種
- 以程式碼段為代表的可讀可執行段
- 以資料段和BSS段為代表的可讀可寫段
- 以只讀資料段為代表的許可權為只讀的段
因此找到一個以許可權種類為劃分,將相同許可權的段合併在一起進行對映的方案,而合併後的資料稱之為Segment,如.text
段和.init
段合在一起看作為一個Segment,那麼在裝載時便可以把他們看作一個個整體進行裝載,這樣就可以達到明顯減少頁面內部碎片化的問題,從而節省空間。
對比如下圖,左邊為按段裝載,又邊為合併後按Segment裝載
下面編寫一個例子程式:
SectionMapping.c
#include <stdlib.h>
int main(){
while(1){
sleep(1000);
}
return 0;
}
gcc -static SectionMapping.c -o SectionMapping.elf
複製程式碼
然後再使用readelf
readelf -S SectionMapping.elf
複製程式碼
得到如下資訊
There are 33 section headers, starting at offset 0xc4d50:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .note.ABI-tag NOTE 0000000000400190 00000190
0000000000000020 0000000000000000 A 0 0 4
[ 2] .note.gnu.build-i NOTE 00000000004001b0 000001b0
0000000000000024 0000000000000000 A 0 0 4
readelf: Warning: [ 3]: Link field (0) should index a symtab section.
[ 3] .rela.plt RELA 00000000004001d8 000001d8
00000000000001f8 0000000000000018 AI 0 24 8
[ 4] .init PROGBITS 00000000004003d0 000003d0
0000000000000017 0000000000000000 AX 0 0 4
[ 5] .plt PROGBITS 00000000004003e8 000003e8
00000000000000a8 0000000000000000 AX 0 0 8
[ 6] .text PROGBITS 0000000000400490 00000490
00000000000874fc 0000000000000000 AX 0 0 16
[ 7] __libc_freeres_fn PROGBITS 0000000000487990 00087990
00000000000024e9 0000000000000000 AX 0 0 16
[ 8] __libc_thread_fre PROGBITS 0000000000489e80 00089e80
00000000000003d7 0000000000000000 AX 0 0 16
[ 9] .fini PROGBITS 000000000048a258 0008a258
0000000000000009 0000000000000000 AX 0 0 4
[10] .rodata PROGBITS 000000000048a280 0008a280
000000000001bd7c 0000000000000000 A 0 0 32
[11] __libc_subfreeres PROGBITS 00000000004a6000 000a6000
0000000000000048 0000000000000000 A 0 0 8
[12] __libc_IO_vtables PROGBITS 00000000004a6060 000a6060
00000000000006a8 0000000000000000 A 0 0 32
[13] __libc_atexit PROGBITS 00000000004a6708 000a6708
0000000000000008 0000000000000000 A 0 0 8
[14] .stapsdt.base PROGBITS 00000000004a6710 000a6710
0000000000000001 0000000000000000 A 0 0 1
[15] __libc_thread_sub PROGBITS 00000000004a6718 000a6718
0000000000000010 0000000000000000 A 0 0 8
[16] .eh_frame PROGBITS 00000000004a6728 000a6728
0000000000009c38 0000000000000000 A 0 0 8
[17] .gcc_except_table PROGBITS 00000000004b0360 000b0360
0000000000000085 0000000000000000 A 0 0 1
[18] .tdata PROGBITS 00000000006b0b40 000b0b40
0000000000000020 0000000000000000 WAT 0 0 8
[19] .tbss NOBITS 00000000006b0b60 000b0b60
0000000000000040 0000000000000000 WAT 0 0 8
[20] .init_array INIT_ARRAY 00000000006b0b60 000b0b60
0000000000000010 0000000000000008 WA 0 0 8
[21] .fini_array FINI_ARRAY 00000000006b0b70 000b0b70
0000000000000010 0000000000000008 WA 0 0 8
[22] .data.rel.ro PROGBITS 00000000006b0b80 000b0b80
0000000000000464 0000000000000000 WA 0 0 32
[23] .got PROGBITS 00000000006b0fe8 000b0fe8
0000000000000008 0000000000000008 WA 0 0 8
[24] .got.plt PROGBITS 00000000006b1000 000b1000
00000000000000c0 0000000000000008 WA 0 0 8
[25] .data PROGBITS 00000000006b10c0 000b10c0
0000000000001af0 0000000000000000 WA 0 0 32
[26] .bss NOBITS 00000000006b2bc0 000b2bb0
0000000000001718 0000000000000000 WA 0 0 32
[27] __libc_freeres_pt NOBITS 00000000006b42d8 000b2bb0
0000000000000028 0000000000000000 WA 0 0 8
[28] .comment PROGBITS 0000000000000000 000b2bb0
0000000000000025 0000000000000001 MS 0 0 1
[29] .note.stapsdt NOTE 0000000000000000 000b2bd8
0000000000001408 0000000000000000 0 0 4
[30] .symtab SYMTAB 0000000000000000 000b3fe0
000000000000a6c8 0000000000000018 31 693 8
[31] .strtab STRTAB 0000000000000000 000be6a8
0000000000006532 0000000000000000 0 0 1
[32] .shstrtab STRTAB 0000000000000000 000c4bda
0000000000000176 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
複製程式碼
檢視ELF的Segment
資訊,正如稱Section屬性的結構叫做段表,描述Segment的結構為程式頭(Program Header),它描述ELF檔案該如何被作業系統對映進程式的虛擬空間:
readelf -l SectionMapping.elf
複製程式碼
結果如下:
Elf file type is EXEC (Executable file)
Entry point 0x400a00
There are 6 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
0x00000000000b03e5 0x00000000000b03e5 R E 0x200000
LOAD 0x00000000000b0b40 0x00000000006b0b40 0x00000000006b0b40
0x0000000000002070 0x00000000000037c0 RW 0x200000
NOTE 0x0000000000000190 0x0000000000400190 0x0000000000400190
0x0000000000000044 0x0000000000000044 R 0x4
TLS 0x00000000000b0b40 0x00000000006b0b40 0x00000000006b0b40
0x0000000000000020 0x0000000000000060 R 0x8
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
GNU_RELRO 0x00000000000b0b40 0x00000000006b0b40 0x00000000006b0b40
0x00000000000004c0 0x00000000000004c0 R 0x1
Section to Segment mapping:
Segment Sections...
00 .note.ABI-tag .note.gnu.build-id .rela.plt .init .plt .text __libc_freeres_fn __libc_thread_freeres_fn .fini .rodata __libc_subfreeres __libc_IO_vtables __libc_atexit .stapsdt.base __libc_thread_subfreeres .eh_frame .gcc_except_table
01 .tdata .init_array .fini_array .data.rel.ro .got .got.plt .data .bss __libc_freeres_ptrs
02 .note.ABI-tag .note.gnu.build-id
03 .tdata .tbss
04
05 .tdata .init_array .fini_array .data.rel.ro .got
複製程式碼
從裝載的角度看,我們只需要關心兩個“LOAD”型別的Segment,其他只是在裝載中起輔助作用。這裡可以看到,檔案被重新劃分成三個部分:
- 可讀可執行LOAD段
- 可讀寫的LOAD段
- 沒有被對映的段
因此所有相同屬性的Section被歸類到了同一個Segment,並對映到同一個VMA裡面。所以說Segment和Section在不同的角度給ELF檔案進行劃分。
- 連結檢視:從Section的角度進行劃分
- 執行檢視:從Segment的角度進行劃分
可以如下圖表示可執行檔案的段與程式虛擬空間的對映關係:
ELF可執行檔案和共享庫檔案有個專門的資料結構叫**程式頭表(Program Header Table)**用來儲存Segment資訊,因為ELF檔案不需要被裝載,因此沒有程式頭表具體結構如下,並與readelf -l
讀出來的資料一一對應
typedef struct{
Elf32_Word p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Word p_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
}Elf32_Phdr;
複製程式碼
基本含義如下
其中,p_memsz >= p_filesz
但如果p_memsz <= p_filesz
則表示Segment段在記憶體中分配的空間大小超過檔案中實際的大小,這部分多餘的空間則被全部填充為”0“。這樣我們構造ELF可執行檔案時就不需要再額外設立BSS的Segment了,可以把資料Segment的p_memsz擴大,那些額外的部分就是BSS。資料段和BSS的區別在於,資料段從檔案中初始化內容,而BSS則全部被初始化為0。因此可以看到前面的BSS其實已經被併入資料型別段裡面,而沒有顯示出來。
堆和棧
檢視程式虛擬空間分佈如圖所示:
其中意義可以見我另外一篇文章,《/proc/{pid}/maps的檔案結構解析》
其中,主裝置號和次裝置號及檔案節點號都是0,表示沒有對映到檔案,這種VMA叫做匿名虛擬記憶體區域(Anonymous Virtual Memory Area)。我們目前關注Heap和Stack這兩個VMA幾乎在所有程式中都存在,malloc()函式記憶體分配就是從堆裡面進行分配的,堆由系統庫管理,而vsyscall
位於核心空間,具體作用待深究,不過在名稱來看猜測應該是和核心通訊相關的VMA了。
一個程式可主要分如下幾個區域:
- 程式碼VMA,許可權只讀,可執行;有映像檔案
- 資料VMA,許可權可讀寫;有映像檔案
- 堆VMA,可讀可寫不可執行;匿名,可向上擴充套件
- 棧VMA,可讀可寫不可執行;匿名,可向下擴充套件
具體如圖:
Linux核心裝載ELF過程
-
bash程式呼叫
fork()
系統呼叫建立一個新的程式 -
呼叫
execve()
系統呼叫執行指定的ELF檔案,原先的bash程式繼續返回等待剛才啟動的新程式結束,然後等待使用者輸入命令。execve()
被定義在unsitd.h
檔案中,原型如下int execve(const char * filename,char * const argv[ ],char * const envp[ ]);
三個引數分別是被執行的程式檔名,執行引數,環境變數
啊~~~這裡步驟比較多且複雜,沒有實際實驗也看不太懂,就先直接貼圖將就著看吧;
然後最後得到的結果就是返回地址改成被裝載的ELF程式入口地址:
- 靜態連結:ELF檔案的標頭檔案中e_entry所指的地址
- 動態連結:動態連結器地址
至此,可執行檔案的裝載部分已介紹完畢