ELF文件格式是一個開發標准,各種UNIX系統的可執行文件都采用ELF格式,它有三種不同的類型:
現在分析一下上一篇文章中經過匯編之後生成的目標文件max.o和鏈接之後生成的可執行文件max的格式,從而理解匯編、鏈接和加載執行的過程。
一、目標文件
ELF文件格式提供了兩種不同的視角,在匯編器和鏈接器看來,ELF文件是由Section Header Table描述的一系列Section的集合,而執行一個ELF文件時,在加載器看來它是由Program Header Table描述的一系列Segment的集合,如下圖所示:
左邊是從匯編器和鏈接器的視角來看這個文件,開頭的ELF Header描述了體系結構和操作系統等基本信息,並指出Section Header Table和Program Header Table在文件中的什麼位置,Program Header Table在匯編和鏈接過程中沒有用到,所以是可有可無的,Section Header Table中保存了所有Section的描述信息。右邊是從加載器的視角來看這個文件,開頭是ELF Header,Program Header Table中保存了所有Segment的描述信息,Section Header Table在加載過程中沒有用到,所以是可有可無的。注意Section Header Table和Program Header並不一定要位於文件開頭和結尾的,其位置由ELF Header指出。
我們在匯編程序中用.section聲明的Section會成為目標文件中的Section,此外匯編器還會自動添加一些Section(比如符號表)。Segment是指程序運行時加載到內存的具有相同屬性的區域,由一個或多個Section組成,比如有兩個Section都要求加載到內存後可讀可寫,就屬於同一個Segment。有些Section只對匯編器和鏈接器有意義,在運行時用不到,也不需要加載到內存,那麼句不屬於任何Segment。
目標文件需要連接器做進一步處理,所以一定有Section Header Table;可執行文件需要加載運行,所以一定有Program Header Table;而共享庫既要加載運行,又要在加載時做動態鏈接,所以既有Section Header Table又有Program Header Table。
下面用readelf工具讀出目標文件max.o的ELF Header和Section Header Table,然後我們逐段分析。
ELF Header中描述了操作系統是UNIX,體系結構是80386.Section Header Table中有8個Section Header,在文件中的位置(或者叫文件地址)從180(0xc8)開始,每個40字節,共320字節,到文件地址0x1f3結束。這個目標文件沒有Program Header。
從Section Header中讀出各Section的描述信息,其中.text和.data是我們在匯編程序中聲明的Section,而其他Section是匯編器自動添加的。Addr是這些段加載到內存中的地址(程序中的地址都是虛擬地址),加載地址要在鏈接時填寫,現在空缺,所以是00000000.Off和Size兩列指出了各Section的文件地址,比如.data從文件地址0x60開始,一共0x24個字節,我們在max程序中定義了9個4字節的整數,一共是36個字節,十六進制則為0x24個。根據以上信息可以描繪出整個目標文件的布局:
(注意:這只是參考書上的截圖內存地址與上圖實際地址不同,僅供參考之用)
我們用hexdump工具把目標文件的字節全部打印出來看。
左邊一列是文件中的地址,中間是每個字節的16進制表示,右邊是把這些字節解釋成ASCII碼所對應的字符。中介有一個*號表示省略的部分全是0。.data段對應的是這一塊:
這一段將來要原封不動地加載到內存中。
.shstrtab和.strtab這兩個Section中存放的都是ASCII碼:
可見.shstrtab中保存著各Section的名字,.strtab中保存著程序中用到的符號的名字。每個名字都是以'\0'結尾的字符串。
C語言的全局變量如果在代碼中沒有初始化,就會在程序加載時用0初始化。這種數據屬於.bss段,在加載時它和.data段一樣是可讀可寫的數據,但是在ELF文件中.data段需要占用一部分空間保存初始值,而.bss段則不需要。也就是說.bss段在文件中值占一個Section Header而沒有對應的Section,程序加載時.bss段占多大內存空間在Section Header中描述。
我們繼續分析readelf輸出的最後一部分,是從.rel .text和.symtab這兩個Section中讀出的信息
.rel .text告訴鏈接器指令中的哪些地方需要重定位。
.symtab是符號表。Ndx列是每個符號所在的Section編號,例如data_items在第三個Section裡(也就是.data)各Section的編號見Section Header Table。Value列是每個符號所代表的地址,在目標文件中。符號地址都是相對於該符號所在Section的相對地址,比如data_items位於.data段的開頭,所以地址是0。從Bind這一列可以看出_start這個符號是GLOBAL的,而其他符號是LOCAL的。
現在只剩下.text段沒有分析。objdump工具可以把程序中的機器指令反匯編,那麼反匯編的結果是否跟原來寫的匯編代碼一模一樣呢?對比:
左邊是機器指令的字節,右邊是反匯編結果。顯然所有的符號都被替換成地址了,比如je 23,注意沒有加$的數表示內存地址,而不表示立即數。這條指令後面的<loop_exit>並不是指令的一部分,而是反匯編器從.symtab和.strtab查到的符號名稱,寫在後面是為了有更好的可讀性。目前所有的跳轉指令和內存訪問指令(mov 0x0(,%edi,4), %eax)中的地址都是符號的相對地址,下一步鏈接器要修改這些指令,把其中的地址都改成加載時的內存地址,這些指令才能正確執行。
二、可執行文件
我們按上一節的步驟分析可執行文件max,看看鏈接器都做了什麼改動
在ELF Header中,Type改成了EXEC,由目標文件變成可執行文件了,Entry point改成了0x8048074(這是_start符號的地址),還可以看出多了兩個Program Header,少了兩個Section Header。
在Section Heade Table中 .text和.data的加載地址分別改成了0x8048074和0x80490a0。.bss段沒有用到,所以被刪掉了。.rel .text段就是用於鏈接過程的,鏈接完了就沒用了,所以也刪掉了。
多出來的Program Header Table描述了兩個Segment的信息。.text段和前面的ELF Header、Program Header Table一起組成一個Segment(FileSize指出總長度是0x9e), .data段組成另一個Segent(總長度是0x38)VirtAddr列指出第一個Segment加載到虛地址0x08048000,第二個Segment加載到地址0x080490a0。Fig列指出第一個Segment的訪問權限是可讀可執行,第二個Segment的訪問權限是可讀可寫的。最後一列Align的值0x1000(4K)是x86平台的內存頁面大小。在加載時要求文件中的一頁對應內存中的一頁,對應關系如下:
這個可執行文件很小,總共也不超過一頁大小,但是兩個Segment必須加載到內存中兩個不同的頁面,因為MMU的權限保護機制是以頁為單位的,一個頁面只能設置一種權限。此外還規定每個Segment在文件頁面內偏移多少加載到內存頁面仍然偏移多少,比如第二個Segment在文件中的偏移是0xa0,在內存頁面0x0804 9000中的偏移仍然是0xa0,所以是從0x0804 90a0開始,這樣規定是為了簡化鏈接器和加載器的實現。從上圖也可以看出 .text 段的加載地址應該是 0x0804 8074 ,也正是 _start 符號的地址和程序的入口地址。原來目標文件符號表中的 Value 都是相對地址,現在都改成絕對地址了。此外還多了三個符號 __bss_start 、 _edata 和 _end ,這些是在鏈接過程中添進去的,加載器可以利用這些信息把 .bss 段初始化為0。
再看一下反匯編的結果:
指令中的相對地址都改成絕對地址了。我們仔細檢查一下都改了哪些地方。首先看跳轉指令,原來目標文件的指令是這樣:
...... 11: 74 10 je 23 <loop_exit> ...... 1d: 7e ef jle e <start_loop> ...... 21: eb eb jmp e <start_loop> ......
現在改成了這樣:
...... 8048085: 74 10 je 8048097 <loop_exit> ...... 8048091: 7e ef jle 8048082 <start_loop> ...... 8048095: eb eb jmp 8048082 <start_loop> ......
改了嗎?其實只是反匯編的結果不同了,指令根本沒改。為什麼不用改指令就能跳轉到新的地址呢?因為跳轉指令中指定的是相對於當前指令向前或向後跳多少字節,而不是指定一個完整的內存地址,內存地址有32位,這些跳轉指令只有16位,顯然也不可能指定一個完整的內存地址,這稱為相對跳轉。
再看內存訪問指令,原來目標文件的指令是這樣:
...... 5: 8b 04 bd 00 00 00 00 mov 0x0(,%edi,4),%eax ...... 14: 8b 04 bd 00 00 00 00 mov 0x0(,%edi,4),%eax ......
現在改成了這樣:
...... 8048079: 8b 04 bd a0 90 04 08 mov 0x80490a0(,%edi,4),%eax ...... 8048088: 8b 04 bd a0 90 04 08 mov 0x80490a0(,%edi,4),%eax ......
指令中的地址原本是0x0000 0000,現在改成了0x0804 09a0(注意是小端字節序)。那麼鏈接器怎麼知道要改這兩處呢?是根據原來目標文件中的 .rel.text 段提供的重定位信息來改的:
...... Relocation section '.rel.text' at offset 0x2b0 contains 2 entries: Offset Info Type Sym.Value Sym. Name 00000008 00000201 R_386_32 00000000 .data 00000017 00000201 R_386_32 00000000 .data ......
第一列 Offset 的值就是 .text 段需要改的地方,在 .text 段中的相對地址是8和0x17,正是這兩條指令中00 00 00 00的位置。