gcc 編譯時,鏈接器安排的【虛擬地址】是如何計算出來的?
問題描述
昨天下午,旁邊的同事在學習Linux
系統中的虛擬地址映射 (經典書籍《程序員的自我修養 - 鏈接、裝載與庫》),在看到6.4
章節的時候,對於一個可執行的ELF
文件中,虛擬地址的值百思不得其解!
例如下面這段C
代碼:
首先編譯出32
位的可執行程序 (爲了避開一些與主題無關的干擾因素,採用了靜態鏈接):
gcc -m32 -static test.c -o test
編譯得到ELF
格式的可執行文件:test
。
這個時候,使用readelf
工具來查看這個可執行文件中的段信息 (segment
):
上圖中的紅色矩形框中,第二個段的地址爲什麼是 0x080e_9f5c
?
這篇文章主要根據書中的解釋,來具體的分析這個值的來龍去脈。
ELF 文件格式
在Linux
系統中,有4
種類型的文件都是ELF
格式,包括:目標文件,可執行文件,動態鏈接庫文件、核心轉儲文件。
如果想系統掌握Linux
系統中的底層知識,研究ELF
的格式是避免不了的事情。
之前總結過這篇文章:《Linux 系統中編譯、鏈接的基石 - ELF 文件:扒開它的層層外衣,從字節碼的粒度來探索》,裏面詳細總結了ELF
文件的內部結構。
這裏就不再贅述了,只要記住2
點:
從編譯器的角度看,ELF 文件是由很多的節 (Section) 組成的;
從程序加載器的角度看,ELF 文件是又很多的段 (Segment) 組成的;
其實它倆沒有本質區別,只不過是鏈接器在鏈接階段,把不同目標文件中相同的section
組織在一起,形成一個 segment
。
對於剛纔編譯出的test
可執行文件,其加載視圖如下:
可以看到該文件一共有5
個段 (segment
),前2
個需要LOAD
到內存的段,它們屬性分別是:讀、執行 (R E
) 和 讀、寫 (RW
),它們分別是代碼段和數據段。
綠色的箭頭反映出:代碼段中包含了很多的 section
;黃色的箭頭反映出數據段也包含了很多的 section
。
地址轉換和內存映射
從地址轉換的角度來看:
Linux
系統中CPU
中使用的都是虛擬地址,該虛擬地址在尋址的時候,需要經過MMU
地址轉換,得到實際的物理地址,然後才能在物理內存中讀取指令,或者讀取、寫入數據。
在現代操作系統中,MMU
地址轉換單元基本上都是通過頁表來進行地址轉換的:
當然了,有些系統是兩級轉換 (頁目錄、頁表),有些系統是三級或者四級頁表。
從內存映射的角度來看:
操作系統在把一個可執行程序加載到系統中時,把ELF
文件中每個段的內容讀取到物理內存中,然後把這個物理內存映射到該段對應的虛擬地址上 (VirtAdd
r)。
假設一個可執行程序中的代碼段長度是1.2K
字節, 數據段長度是1.3K
字節。
操作系統在把它倆讀取到內存中時,需要 2 個物理內存頁來分別存儲它們 (每 1 個物理頁的長度是4K
):
雖然每一個物理內存頁的大小是 4K,但是代碼段和數據段實際上只使用了每個頁面剛開始的一段空間。
當CPU
中需要讀取物理內存上代碼段中的指令時,使用的虛擬地址是 0x0000_1000
~ 0x0000_1000
+ 1.2K
這個區間的地址,MMU
單元經過頁錶轉換之後,就會得到這個存放着代碼段的物理頁的物理地址。
數據段的尋址方式也是如此:當CPU
中需要讀寫物理內存上數據段中的數據時,使用的虛擬地址是 0x0000_2000
~ 0x0000_2000
+ 1.3K
這個區間的地址。
MMU
單元經過頁錶轉換之後,就會得到存放着數據段的物理頁的物理地址。
可以看出在這樣的安排下,每一個段的虛擬地址,都是按照4K(0x1000)
對齊的。
如果操作系統都是這樣簡單映射的話,那麼事情就簡單多了。
如果按照這樣的安排,來分析一下文章開頭的 test 可執行程序中的虛擬地址安排:
代碼段安排的開始虛擬地址是 0x0804_8000,這是 4K 對齊的;
代碼段的結束虛擬地址就應該是 0x0804_8000 + 0xa0725 = 0x080e_8725;
那麼數據段的開始地址就可以安排在 0x080e_8725 之後的下一個 4K 對齊的邊界地址,即:0x080e_9000。
但是這樣的地址安排,嚴重浪費了物理內存空間!
1.2K
字節的代碼段加上1.3K
字節的數據段,本來只需要1
個物理頁就夠了 (4KB
),但是這裏卻消耗掉2
個物理頁 (8KB
)。
爲了減少物理內存的浪費,Linux
操作系統就採用了一些巧妙的辦法來減少物理內存的浪費,那就是: 把文件中接壤部分的代碼段和數據段,讀取到同一個物理內存頁中,然後在虛擬地址空間中映射兩次,詳述如下。
Linux 中的內存重複映射
先來看一下test
文件的結構:
代碼段在文件中的開始位置是:0x00000
,長度是 0xa0725
。
數據段的開始位置是:0xa0f5c
,長度是0x1024
。
可以看到它倆之間有一個空白區間,長度是: 0xa0f5c - 0xa0725 = 0x837
(十進制:2103
字節)。
由於操作系統在把test
文件讀取到物理內存的時候,從文件開始代碼段的0x00000
地址開始讀取,按照4KB
爲一個單位存放到一個物理頁中。
文件中代碼段的 0x00000 ~ 0x00FFF 讀取到一個物理頁中;
文件中代碼段的 0x01000 ~ 0x01FFF 讀取到物理頁中;
下面的內容都是如此分割、複製;
也就是說:相當於把test
文件從開始位置,按照4KB
爲一個單位進行 "切割",然後複製到不同的物理內存頁中,如下所示:
注意:這些物理頁的地址很可能是不連續的。
這裏有意思的是:代碼段與數據段接壤的這個4KB
的空間,它的開始地址是0xA0000
,結束地址是0xA0FFF
,被複制到物理內存中最上面的橙色物理頁中。
再來看一下代碼段的虛擬地址:在執行gcc
指令的的時候,鏈接器把代碼段的虛擬地址安排在0x0804_8000
處:
也就是說:當CPU
中 (或者說程序代碼中),使用0x0804_8000 ~ 0x0804_7FFF
這個區間的地址時,經過地址映射,就會找到物理內存中淺綠色的物理頁,而這個物理頁也對應着test
可執行文件開始的第一個4KB
的空間。
而且,從虛擬地址的角度看,它的地址都是連續的,對應着test
文件中連續的內容,這也是虛擬地址映射的本質。
把代碼段的開始位置安排在 0x0804_8000 地址,這是 Linux 操作系統確定的。
那麼考慮一下:代碼段的最後一部分指令相應的4K
頁面,其對應的開始虛擬地址是多少呢?
上圖中已經標記出來了,就是虛擬地址中橙色部分:0x080e_8000
,計算如下:
通過代碼段的開始地址0x0804_8000
,再加上代碼段在內存中的長度0xa0725
,結果就是 0x080e_8725
。
按照4K (0x1000)
對齊之後,最後一個虛擬頁就應該是0x080e_8000
。
也就是說:虛擬地址中0x080e_8000 ~ 0x080e_8724
這個區間就對應着test
文件中代碼段的最後一部分指令 (0x725
個字節)。
此外,上圖中最右側:test
文件結構中的2
個紅色地址:0xA0000, 0xA1000
,是如何計算得到的?
代碼段的長度是 0xA0725
,按照4K
爲一個單位來進行分割,也就是把0xA0725
對0x1000
進行整除,就得到這個4KB
的開始地址0xA0000
。
同理,下一個4KB
的開始地址就是0xA1000
。
把文件中這部分4K
的數據 (包括:一部分代碼段內容 + 0x837
字節空洞 + 一部分數據段內容),複製到上圖中物理內存中最上面的橙色物理頁中。
又因爲虛擬地址空間中,0x080E_8000
開始的這個4KB
空間映射到這個物理頁中,所以:在這個虛擬地址空間中,也有一個0x837
字節的空洞,如下所示:
空洞的下方,是代碼段的指令;空洞的上方,是數據段的數據。
現在,這個物理頁中即存放了代碼,又存放了數據。
那麼CPU
中在查找部分的代碼和數據的時候,必須都能夠找得到纔行!
對於代碼段比較好理解:從這個物理頁開始的前0x725
個字節是有效的,從虛擬地址的角度看,就是從0x080e_8000
開始的前0x725
個字節是有效的。
因此,對於這部分代碼的尋址,使用的虛擬地址處於0x080e_8000 ~ 0x080e_8724
這個區間中。
那麼數據段呢?
重點來了:Linux
系統把虛擬地址空間 0x080e_9000 ~ 0x080e_9FFF
也映射到圖中物理內存中最上面的橙色物理頁上!
如下所示:
因爲物理頁中,是從0x837
個字節空洞的上面開始,纔是真正的數據段內容,那麼相應的: 虛擬地址0x080e_9000 ~ 0x080e_9FFF
空間中,0x837
字節上面的內容纔是數據段內容。
那麼在虛擬地址空間中,這個數據段的開始地址應該是多少呢?
只要計算出0x837
字節空洞的上方,距離這個4K
頁面開始地址的偏移量就可以了,然後再加上這個4K
頁面的起始地址 0x080E_9000
,就得到了數據段的開始地址 (虛擬地址)。
因爲虛擬地址、物理地址、test 文件中,都是按照4K
的單位進行劃分的,因此這個偏移量就等於:test
文件中數據段的開始地址 (0xA0F5C
) 距離 這個頁面的開始地址 (0xA0000
) 的偏移量。
0xA0F5C - 0xA0000 = 0xF5C
。
即:從這個4K
頁面的開始地址,偏移量爲0xF5C
的地方,纔是數據段內容的開始。
因此對於虛擬地址來說,從0x080e_9000
地址開始,偏移量爲0xF5C
之後的內容纔是數據段的內容,這個地址值就是:0x080e_9000 + 0xF5C = 0x080e_9F5C
,如下所示:
這個地址正是readelf
工具讀所顯示的:數據段加載到虛擬地址空間中的開始地址,如下所示:
至此,就解釋了文章開頭提出的問題!
再來看一下整個數據段的內容:在內存中數據段佔據的空間是 0x01e48
(readelf
工具讀取到的 MemSiz
),那麼數據段的結束地址就是 (虛擬地址):
0x080e_9F5C + 0x01e48 = 0x080e_bda4
如下所示:
小結
Linux
系統中的這個操作:對屬於不同段的內容進行重複映射,有點類似於共享內存的味道了。
只不過這裏重複映射之後,每個段的虛擬地址還是需要修正爲該段的合法地址。
經過這樣的操作之後,在虛擬地址中每一個段的界限是涇渭分明的,但是映射到的物理內存頁,則有可能是同一個。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/gnBtFV_VZZMNlSKQoKLZ2g