寫在本文開始之前.... 從本文開始我們就正式開啟了 Linux 內核記憶體管理子系統源碼解析系列,筆者還是會秉承之前系列文章的風格,採用一步一圖的方式先是詳細介紹相關原理,在保證大家清晰理解原理的基礎上,我們再來一步一步的解析相關內核源碼的實現。有了源碼的輔證,這樣大家看得也安心,理解起來也放心,最 ...
寫在本文開始之前....
從本文開始我們就正式開啟了 Linux 內核記憶體管理子系統源碼解析系列,筆者還是會秉承之前系列文章的風格,採用一步一圖的方式先是詳細介紹相關原理,在保證大家清晰理解原理的基礎上,我們再來一步一步的解析相關內核源碼的實現。有了源碼的輔證,這樣大家看得也安心,理解起來也放心,最起碼可以證明筆者沒有胡編亂造騙大家,哈哈~~
記憶體管理子系統可謂是 Linux 內核眾多子系統中最為複雜最為龐大的一個,其中包含了眾多繁雜的概念和原理,通過記憶體管理這條主線我們把可以把操作系統的眾多核心系統給拎出來,比如:進程管理子系統,網路子系統,文件子系統等。
由於記憶體管理子系統過於複雜龐大,其中涉及到的眾多繁雜的概念又是一環套一環,層層遞進。如何把這些繁雜的概念具有層次感地,並且清晰地,給大家梳理呈現出來真是一件比較有難度的事情,因此關於這個問題,筆者在動筆寫這個記憶體管理源碼解析系列之前也是思考了很久。
萬事開頭難,那麼到底什麼內容適合作為這個系列的開篇呢 ?筆者還是覺得從大家日常開發工作中接觸最多最為熟悉的部分開始比較好,比如:在我們日常開發中創建的類,調用的函數,在函數中定義的局部變數以及 new 出來的數據容器(Map,List,Set .....等)都需要存儲在物理記憶體中的某個角落。
而我們在程式中編寫業務邏輯代碼的時候,往往需要引用這些創建出來的數據結構,並通過這些引用對相關數據結構進行業務處理。
當程式運行起來之後就變成了進程,而這些業務數據結構的引用在進程的視角里全都都是虛擬記憶體地址,因為進程無論是在用戶態還是在內核態能夠看到的都是虛擬記憶體空間,物理記憶體空間被操作系統所屏蔽進程是看不到的。
進程通過虛擬記憶體地址訪問這些數據結構的時候,虛擬記憶體地址會在記憶體管理子系統中被轉換成物理記憶體地址,通過物理記憶體地址就可以訪問到真正存儲這些數據結構的物理記憶體了。隨後就可以對這塊物理記憶體進行各種業務操作,從而完成業務邏輯。
-
那麼到底什麼是虛擬記憶體地址 ?
-
Linux 內核為啥要引入虛擬記憶體而不直接使用物理記憶體 ?
-
虛擬記憶體空間到底長啥樣?
-
內核如何管理虛擬記憶體?
-
什麼又是物理記憶體地址 ?如何訪問物理記憶體?
本文筆者就來為大家詳細一一解答上述幾個問題,讓我們馬上開始吧~~~~
1. 到底什麼是虛擬記憶體地址
首先人們提出地址這個概念的目的就是用來方便定位現實世界中某一個具體事物的真實地理位置,它是一種用於定位的概念模型。
舉一個生活中的例子,比如大家在日常生活中給親朋好友郵寄一些本地特產時,都會填寫收件人地址以及寄件人地址。以及在日常網上購物時,都會在相應電商 APP 中填寫自己的收穫地址。
隨後快遞小哥就會根據我們填寫的收貨地址找到我們的真實住所,將我們網購的商品送達到我們的手裡。
收貨地址是用來定位我們在現實世界中真實住所地理位置的,而現實世界中我們所在的城市,街道,小區,房屋都是一磚一瓦,一草一木真實存在的。但收貨地址這個概念模型在現實世界中並不真實存在,它只是人們提出的一個虛擬概念,通過收貨地址這個虛擬概念將它和現實世界真實存在的城市,小區,街道的地理位置一一映射起來,這樣我們就可以通過這個虛擬概念來找到現實世界中的具體地理位置。
綜上所述,收貨地址是一個虛擬地址,它是人為定義的,而我們的城市,小區,街道是真實存在的,他們的地理位置就是物理地址。
比如現在的廣東省深圳市在過去叫寶安縣,河北省的石家莊過去叫常山,安徽省的合肥過去叫瀘州。不管是常山也好,石家莊也好,又或是合肥也好,瀘州也罷,這些都是人為定義的名字而已,但是地方還是那個地方,它所在的地理位置是不變的。也就說虛擬地址可以人為的變來變去,但是物理地址永遠是不變的。
現在讓我們把視角在切換到電腦的世界,在電腦的世界里記憶體地址用來定義數據在記憶體中的存儲位置的,記憶體地址也分為虛擬地址和物理地址。而虛擬地址也是人為設計的一個概念,類比我們現實世界中的收貨地址,而物理地址則是數據在物理記憶體中的真實存儲位置,類比現實世界中的城市,街道,小區的真實地理位置。
說了這麼多,那麼到底虛擬記憶體地址長什麼樣子呢?
我們還是以日常生活中的收貨地址為例做出類比,我們都很熟悉收貨地址的格式:xx省xx市xx區xx街道xx小區xx室,它是按照地區層次遞進的。同樣,在電腦世界中的虛擬記憶體地址也有這樣的遞進關係。
這裡我們以 Intel Core i7 處理器為例,64 位虛擬地址的格式為:全局頁目錄項(9位)+ 上層頁目錄項(9位)+ 中間頁目錄項(9位)+ 頁內偏移(12位)。共 48 位組成的虛擬記憶體地址。
虛擬記憶體地址中的全局頁目錄項就類比我們日常生活中收穫地址里的省,上層頁目錄項就類比市,中間層頁目錄項類比區縣,頁表項類比街道小區,頁內偏移類比我們所在的樓棟和幾層幾號。
這裡大家只需要大體明白虛擬記憶體地址到底長什麼樣子,它的格式是什麼,能夠和日常生活中的收貨地址對比理解起來就可以了,至於頁目錄項,頁表項以及頁內偏移這些電腦世界中的概念,大家暫時先不用管,後續文章中筆者會慢慢給大家解釋清楚。
32 位虛擬地址的格式為:頁目錄項(10位)+ 頁表項(10位) + 頁內偏移(12位)。共 32 位組成的虛擬記憶體地址。
進程虛擬記憶體空間中的每一個位元組都有與其對應的虛擬記憶體地址,一個虛擬記憶體地址表示進程虛擬記憶體空間中的一個特定的位元組。
2. 為什麼要使用虛擬地址訪問記憶體
經過第一小節的介紹,我們現在明白了電腦世界中的虛擬記憶體地址的含義及其展現形式。那麼大家可能會問了,既然物理記憶體地址可以直接定位到數據在記憶體中的存儲位置,那為什麼我們不直接使用物理記憶體地址去訪問記憶體而是選擇用虛擬記憶體地址去訪問記憶體呢?
在回答大家的這個疑問之前,讓我們先來看下,如果在程式中直接使用物理記憶體地址會發生什麼情況?
假設現在沒有虛擬記憶體地址,我們在程式中對記憶體的操作全都都是使用物理記憶體地址,在這種情況下,程式員就需要精確的知道每一個變數在記憶體中的具體位置,我們需要手動對物理記憶體進行佈局,明確哪些數據存儲在記憶體的哪些位置,除此之外我們還需要考慮為每個進程究竟要分配多少記憶體?記憶體緊張的時候該怎麼辦?如何避免進程與進程之間的地址衝突?等等一系列複雜且瑣碎的細節。
如果我們在單進程系統中比如嵌入式設備上開發應用程式,系統中只有一個進程,這單個進程獨享所有的物理資源包括記憶體資源。在這種情況下,上述提到的這些直接使用物理記憶體的問題可能還好處理一些,但是仍然具有很高的開發門檻。
然而在現代操作系統中往往支持多個進程,需要處理多進程之間的協同問題,在多進程系統中直接使用物理記憶體地址操作記憶體所帶來的上述問題就變得非常複雜了。
這裡筆者為大家舉一個簡單的例子來說明在多進程系統中直接使用物理記憶體地址的複雜性。
比如我們現在有這樣一個簡單的 Java 程式。
public static void main(String[] args) throws Exception {
string i = args[0];
..........
}
在程式代碼相同的情況下,我們用這份代碼同時啟動三個 JVM 進程,我們暫時將進程依次命名為 a , b , c 。
這三個進程用到的代碼是一樣的,都是我們提前寫好的,可以被多次運行。由於我們是直接操作物理記憶體地址,假設變數 i 保存在 0x354 這個物理地址上。這三個進程運行起來之後,同時操作這個 0x354 物理地址,這樣這個變數 i 的值不就混亂了嗎? 三個進程就會出現變數的地址衝突。
所以在直接操作物理記憶體的情況下,我們需要知道每一個變數的位置都被安排在了哪裡,而且還要註意和多個進程同時運行的時候,不能共用同一個地址,否則就會造成地址衝突。
現實中一個程式會有很多的變數和函數,這樣一來我們給它們都需要計算一個合理的位置,還不能與其他進程衝突,這就很複雜了。
那麼我們該如何解決這個問題呢?程式的局部性原理再一次救了我們~~
程式局部性原理表現為:時間局部性和空間局部性。時間局部性是指如果程式中的某條指令一旦執行,則不久之後該指令可能再次被執行;如果某塊數據被訪問,則不久之後該數據可能再次被訪問。空間局部性是指一旦程式訪問了某個存儲單元,則不久之後,其附近的存儲單元也將被訪問。
從程式局部性原理的描述中我們可以得出這樣一個結論:進程在運行之後,對於記憶體的訪問不會一下子就要訪問全部的記憶體,相反進程對於記憶體的訪問會表現出明顯的傾向性,更加傾向於訪問最近訪問過的數據以及熱點數據附近的數據。
根據這個結論我們就清楚了,無論一個進程實際可以占用的記憶體資源有多大,根據程式局部性原理,在某一段時間內,進程真正需要的物理記憶體其實是很少的一部分,我們只需要為每個進程分配很少的物理記憶體就可以保證進程的正常執行運轉。
而虛擬記憶體的引入正是要解決上述的問題,虛擬記憶體引入之後,進程的視角就會變得非常開闊,每個進程都擁有自己獨立的虛擬地址空間,進程與進程之間的虛擬記憶體地址空間是相互隔離,互不幹擾的。每個進程都認為自己獨占所有記憶體空間,自己想乾什麼就乾什麼。
系統上還運行了哪些進程和我沒有任何關係。這樣一來我們就可以將多進程之間協同的相關複雜細節統統交給內核中的記憶體管理模塊來處理,極大地解放了程式員的心智負擔。這一切都是因為虛擬記憶體能夠提供記憶體地址空間的隔離,極大地擴展了可用空間。
這樣進程就以為自己獨占了整個記憶體空間資源,給進程產生了所有記憶體資源都屬於它自己的幻覺,這其實是 CPU 和操作系統使用的一個障眼法罷了,任何一個虛擬記憶體里所存儲的數據,本質上還是保存在真實的物理記憶體里的。只不過內核幫我們做了虛擬記憶體到物理記憶體的這一層映射,將不同進程的虛擬地址和不同記憶體的物理地址映射起來。
當 CPU 訪問進程的虛擬地址時,經過地址翻譯硬體將虛擬地址轉換成不同的物理地址,這樣不同的進程運行的時候,雖然操作的是同一虛擬地址,但其實背後寫入的是不同的物理地址,這樣就不會衝突了。
3. 進程虛擬記憶體空間
上小節中,我們介紹了為了防止多進程運行時造成的記憶體地址衝突,內核引入了虛擬記憶體地址,為每個進程提供了一個獨立的虛擬記憶體空間,使得進程以為自己獨占全部記憶體資源。
那麼這個進程獨占的虛擬記憶體空間到底是什麼樣子呢?在本小節中,筆者就為大家揭開這層神秘的面紗~~~
在本小節內容開始之前,我們先想象一下,如果我們是內核的設計人員,我們該從哪些方面來規划進程的虛擬記憶體空間呢?
本小節我們只討論進程用戶態虛擬記憶體空間的佈局,我們先把內核態的虛擬記憶體空間當做一個黑盒來看待,在後面的小節中筆者再來詳細介紹內核態相關內容。
首先我們會想到的是一個進程運行起來是為了執行我們交代給進程的工作,執行這些工作的步驟我們通過程式代碼事先編寫好,然後編譯成二進位文件存放在磁碟中,CPU 會執行二進位文件中的機器碼來驅動進程的運行。所以在進程運行之前,這些存放在二進位文件中的機器碼需要被載入進記憶體中,而用於存放這些機器碼的虛擬記憶體空間叫做代碼段。
在程式運行起來之後,總要操作變數吧,在程式代碼中我們通常會定義大量的全局變數和靜態變數,這些全局變數在程式編譯之後也會存儲在二進位文件中,在程式運行之前,這些全局變數也需要被載入進記憶體中供程式訪問。所以在虛擬記憶體空間中也需要一段區域來存儲這些全局變數。
-
那些在代碼中被我們指定了初始值的全局變數和靜態變數在虛擬記憶體空間中的存儲區域我們叫做數據段。
-
那些沒有指定初始值的全局變數和靜態變數在虛擬記憶體空間中的存儲區域我們叫做 BSS 段。這些未初始化的全局變數被載入進記憶體之後會被初始化為 0 值。
上面介紹的這些全局變數和靜態變數都是在編譯期間就確定的,但是我們程式在運行期間往往需要動態的申請記憶體,所以在虛擬記憶體空間中也需要一塊區域來存放這些動態申請的記憶體,這塊區域就叫做堆。註意這裡的堆指的是 OS 堆並不是 JVM 中的堆。
除此之外,我們的程式在運行過程中還需要依賴動態鏈接庫,這些動態鏈接庫以 .so 文件的形式存放在磁碟中,比如 C 程式中的 glibc,裡邊對系統調用進行了封裝。glibc 庫里提供的用於動態申請堆記憶體的 malloc 函數就是對系統調用 sbrk 和 mmap 的封裝。這些動態鏈接庫也有自己的對應的代碼段,數據段,BSS 段,也需要一起被載入進記憶體中。
還有用於記憶體文件映射的系統調用 mmap,會將文件與記憶體進行映射,那麼映射的這塊記憶體(虛擬記憶體)也需要在虛擬地址空間中有一塊區域存儲。
這些動態鏈接庫中的代碼段,數據段,BSS 段,以及通過 mmap 系統調用映射的共用記憶體區,在虛擬記憶體空間的存儲區域叫做文件映射與匿名映射區。
最後我們在程式運行的時候總該要調用各種函數吧,那麼調用函數過程中使用到的局部變數和函數參數也需要一塊記憶體區域來保存。這一塊區域在虛擬記憶體空間中叫做棧。
現在進程的虛擬記憶體空間所包含的主要區域,筆者就為大家介紹完了,我們看到內核根據進程運行的過程中所需要不同種類的數據而為其開闢了對應的地址空間。分別為:
-
用於存放進程程式二進位文件中的機器指令的代碼段
-
用於存放程式二進位文件中定義的全局變數和靜態變數的數據段和 BSS 段。
-
用於在程式運行過程中動態申請記憶體的堆。
-
用於存放動態鏈接庫以及記憶體映射區域的文件映射與匿名映射區。
-
用於存放函數調用過程中的局部變數和函數參數的棧。
以上就是我們通過一個程式在運行過程中所需要的數據所規划出的虛擬記憶體空間的分佈,這些只是一個大概的規劃,那麼在真實的 Linux 系統中,進程的虛擬記憶體空間的具體規劃又是如何的呢?我們接著往下看~~
4. Linux 進程虛擬記憶體空間
在上小節中我們介紹了進程虛擬記憶體空間中各個記憶體區域的一個大概分佈,在此基礎之上,本小節筆者就帶大家分別從 32 位 和 64 位機器上看下在 Linux 系統中進程虛擬記憶體空間的真實分佈情況。
4.1 32 位機器上進程虛擬記憶體空間分佈
在 32 位機器上,指針的定址範圍為 2^32,所能表達的虛擬記憶體空間為 4 GB。所以在 32 位機器上進程的虛擬記憶體地址範圍為:0x0000 0000 - 0xFFFF FFFF。
其中用戶態虛擬記憶體空間為 3 GB,虛擬記憶體地址範圍為:0x0000 0000 - 0xC000 000 。
內核態虛擬記憶體空間為 1 GB,虛擬記憶體地址範圍為:0xC000 000 - 0xFFFF FFFF。
但是用戶態虛擬記憶體空間中的代碼段並不是從 0x0000 0000 地址開始的,而是從 0x0804 8000 地址開始。
0x0000 0000 到 0x0804 8000 這段虛擬記憶體地址是一段不可訪問的保留區,因為在大多數操作系統中,數值比較小的地址通常被認為不是一個合法的地址,這塊小地址是不允許訪問的。比如在 C 語言中我們通常會將一些無效的指針設置為 NULL,指向這塊不允許訪問的地址。
保留區的上邊就是代碼段和數據段,它們是從程式的二進位文件中直接載入進記憶體中的,BSS 段中的數據也存在於二進位文件中,因為內核知道這些數據是沒有初值的,所以在二進位文件中只會記錄 BSS 段的大小,在載入進記憶體時會生成一段 0 填充的記憶體空間。
緊挨著 BSS 段的上邊就是我們經常使用到的堆空間,從圖中的紅色箭頭我們可以知道在堆空間中地址的增長方向是從低地址到高地址增長。
內核中使用 start_brk 標識堆的起始位置,brk 標識堆當前的結束位置。當堆申請新的記憶體空間時,只需要將 brk 指針增加對應的大小,回收地址時減少對應的大小即可。比如當我們通過 malloc 向內核申請很小的一塊記憶體時(128K 之內),就是通過改變 brk 位置實現的。
堆空間的上邊是一段待分配區域,用於擴展堆空間的使用。接下來就來到了文件映射與匿名映射區域。進程運行時所依賴的動態鏈接庫中的代碼段,數據段,BSS 段就載入在這裡。還有我們調用 mmap 映射出來的一段虛擬記憶體空間也保存在這個區域。註意:在文件映射與匿名映射區的地址增長方向是從高地址向低地址增長。
接下來用戶態虛擬記憶體空間的最後一塊區域就是棧空間了,在這裡會保存函數運行過程所需要的局部變數以及函數參數等函數調用信息。棧空間中的地址增長方向是從高地址向低地址增長。每次進程申請新的棧地址時,其地址值是在減少的。
在內核中使用 start_stack 標識棧的起始位置,RSP 寄存器中保存棧頂指針 stack pointer,RBP 寄存器中保存的是棧基地址。
在棧空間的下邊也有一段待分配區域用於擴展棧空間,在棧空間的上邊就是內核空間了,進程雖然可以看到這段內核空間地址,但是就是不能訪問。這就好比我們在飯店裡雖然可以看到廚房在哪裡,但是廚房門上寫著 “廚房重地,閑人免進” ,我們就是進不去。
4.2 64 位機器上進程虛擬記憶體空間分佈
上小節中介紹的 32 位虛擬記憶體空間佈局和本小節即將要介紹的 64 位虛擬記憶體空間佈局都可以通過 cat /proc/pid/maps
或者 pmap pid
來查看某個進程的實際虛擬記憶體佈局。
我們知道在 32 位機器上,指針的定址範圍為 2^32,所能表達的虛擬記憶體空間為 4 GB。
那麼我們理所應當的會認為在 64 位機器上,指針的定址範圍為 2^64,所能表達的虛擬記憶體空間為 16 EB 。虛擬記憶體地址範圍為:0x0000 0000 0000 0000 0000 - 0xFFFF FFFF FFFF FFFF 。
好家伙 !!! 16 EB 的記憶體空間,筆者都沒見過這麼大的磁碟,在現實情況中根本不會用到這麼大範圍的記憶體空間,
事實上在目前的 64 位系統下只使用了 48 位來描述虛擬記憶體空間,定址範圍為 2^48 ,所能表達的虛擬記憶體空間為 256TB。
其中低 128 T 表示用戶態虛擬記憶體空間,虛擬記憶體地址範圍為:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000 。
高 128 T 表示內核態虛擬記憶體空間,虛擬記憶體地址範圍為:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 。
這樣一來就在用戶態虛擬記憶體空間與內核態虛擬記憶體空間之間形成了一段 0x0000 7FFF FFFF F000 - 0xFFFF 8000 0000 0000 的地址空洞,我們把這個空洞叫做 canonical address 空洞。
那麼這個 canonical address 空洞是如何形成的呢?
我們都知道在 64 位機器上的指針定址範圍為 2^64,但是在實際使用中我們只使用了其中的低 48 位來表示虛擬記憶體地址,那麼這多出的高 16 位就形成了這個地址空洞。
大家註意到在低 128T 的用戶態地址空間:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000 範圍中,所以虛擬記憶體地址的高 16 位全部為 0 。
如果一個虛擬記憶體地址的高 16 位全部為 0 ,那麼我們就可以直接判斷出這是一個用戶空間的虛擬記憶體地址。
同樣的道理,在高 128T 的內核態虛擬記憶體空間:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 範圍中,所以虛擬記憶體地址的高 16 位全部為 1 。
也就是說內核態的虛擬記憶體地址的高 16 位全部為 1 ,如果一個試圖訪問內核的虛擬地址的高 16 位不全為 1 ,則可以快速判斷這個訪問是非法的。
這個高 16 位的空閑地址被稱為 canonical 。如果虛擬記憶體地址中的高 16 位全部為 0 (表示用戶空間虛擬記憶體地址)或者全部為 1 (表示內核空間虛擬記憶體地址),這種地址的形式我們叫做 canonical form,對應的地址我們稱作 canonical address 。
那麼處於 canonical address 空洞 :0x0000 7FFF FFFF F000 - 0xFFFF 8000 0000 0000 範圍內的地址的高 16 位 不全為 0 也不全為 1 。如果某個虛擬地址落在這段 canonical address 空洞區域中,那就是既不在用戶空間,也不在內核空間,肯定是非法訪問了。
未來我們也可以利用這塊 canonical address 空洞,來擴展虛擬記憶體地址的範圍,比如擴展到 56 位。
在我們理解了 canonical address 這個概念之後,我們再來看下 64 位 Linux 系統下的真實虛擬記憶體空間佈局情況:
從上圖中我們可以看出 64 位系統中的虛擬記憶體佈局和 32 位系統中的虛擬記憶體佈局大體上是差不多的。主要不同的地方有三點:
-
就是前邊提到的由高 16 位空閑地址造成的 canonical address 空洞。在這段範圍內的虛擬記憶體地址是不合法的,因為它的高 16 位既不全為 0 也不全為 1,不是一個 canonical address,所以稱之為 canonical address 空洞。
-
在代碼段跟數據段的中間還有一段不可以讀寫的保護段,它的作用是防止程式在讀寫數據段的時候越界訪問到代碼段,這個保護段可以讓越界訪問行為直接崩潰,防止它繼續往下運行。
-
用戶態虛擬記憶體空間與內核態虛擬記憶體空間分別占用 128T,其中低128T 分配給用戶態虛擬記憶體空間,高 128T 分配給內核態虛擬記憶體空間。
5. 進程虛擬記憶體空間的管理
在上一小節中,筆者為大家介紹了 Linux 操作系統在 32 位機器上和 64 位機器上進程虛擬記憶體空間的佈局分佈,我們發現無論是在 32 位機器上還是在 64 位機器上,進程虛擬記憶體空間的核心區域分佈的相對位置是不變的,它們都包含下圖所示的這幾個核心記憶體區域。
唯一不同的是這些核心記憶體區域在 32 位機器和 64 位機器上的絕對位置分佈會有所不同。
那麼在此基礎之上,內核如何為進程管理這些虛擬記憶體區域呢?這將是本小節重點為大家介紹的內容~~
既然我們要介紹進程的虛擬記憶體空間管理,那就離不開進程在內核中的描述符 task_struct 結構。
struct task_struct {
// 進程id
pid_t pid;
// 用於標識線程所屬的進程 pid
pid_t tgid;
// 進程打開的文件信息
struct files_struct *files;
// 記憶體描述符表示進程虛擬地址空間
struct mm_struct *mm;
.......... 省略 .......
}
在進程描述符 task_struct 結構中,有一個專門描述進程虛擬地址空間的記憶體描述符 mm_struct 結構,這個結構體中包含了前邊幾個小節中介紹的進程虛擬記憶體空間的全部信息。
每個進程都有唯一的 mm_struct 結構體,也就是前邊提到的每個進程的虛擬地址空間都是獨立,互不幹擾的。
當我們調用 fork() 函數創建進程的時候,表示進程地址空間的 mm_struct 結構會隨著進程描述符 task_struct 的創建而創建。
long _do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr,
unsigned long tls)
{
......... 省略 ..........
struct pid *pid;
struct task_struct *p;
......... 省略 ..........
// 為進程創建 task_struct 結構,用父進程的資源填充 task_struct 信息
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
......... 省略 ..........
}
隨後會在 copy_process 函數中創建 task_struct 結構,並拷貝父進程的相關資源到新進程的 task_struct 結構里,其中就包括拷貝父進程的虛擬記憶體空間 mm_struct 結構。這裡可以看出子進程在新創建出來之後它的虛擬記憶體空間是和父進程的虛擬記憶體空間一模一樣的,直接拷貝過來。
static __latent_entropy struct task_struct *copy_process(
unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *child_tidptr,
struct pid *pid,
int trace,
unsigned long tls,
int node)
{
struct task_struct *p;
// 創建 task_struct 結構
p = dup_task_struct(current, node);
....... 初始化子進程 ...........
....... 開始繼承拷貝父進程資源 .......
// 繼承父進程打開的文件描述符
retval = copy_files(clone_flags, p);
// 繼承父進程所屬的文件系統
retval = copy_fs(clone_flags, p);
// 繼承父進程註冊的信號以及信號處理函數
retval = copy_sighand(clone_flags, p);
retval = copy_signal(clone_flags, p);
// 繼承父進程的虛擬記憶體空間
retval = copy_mm(clone_flags, p);
// 繼承父進程的 namespaces
retval = copy_namespaces(clone_flags, p);
// 繼承父進程的 IO 信息
retval = copy_io(clone_flags, p);
...........省略.........
// 分配 CPU
retval = sched_fork(clone_flags, p);
// 分配 pid
pid = alloc_pid(p->nsproxy->pid_ns_for_children);
. ..........省略.........
}
這裡我們重點關註 copy_mm 函數,正是在這裡完成了子進程虛擬記憶體空間 mm_struct 結構的的創建以及初始化。
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
// 子進程虛擬記憶體空間,父進程虛擬記憶體空間
struct mm_struct *mm, *oldmm;
int retval;
...... 省略 ......
tsk->mm = NULL;
tsk->active_mm = NULL;
// 獲取父進程虛擬記憶體空間
oldmm = current->mm;
if (!oldmm)
return 0;
...... 省略 ......
// 通過 vfork 或者 clone 系統調用創建出的子進程(線程)和父進程共用虛擬記憶體空間
if (clone_flags & CLONE_VM) {
// 增加父進程虛擬地址空間的引用計數
mmget(oldmm);
// 直接將父進程的虛擬記憶體空間賦值給子進程(線程)
// 線程共用其所屬進程的虛擬記憶體空間
mm = oldmm;
goto good_mm;
}
retval = -ENOMEM;
// 如果是 fork 系統調用創建出的子進程,則將父進程的虛擬記憶體空間以及相關頁表拷貝到子進程中的 mm_struct 結構中。
mm = dup_mm(tsk);
if (!mm)
goto fail_nomem;
good_mm:
// 將拷貝出來的父進程虛擬記憶體空間 mm_struct 賦值給子進程
tsk->mm = mm;
tsk->active_mm = mm;
return 0;
...... 省略 ......
由於本小節中我們舉的示例是通過 fork() 函數創建子進程的情形,所以這裡大家先占時忽略 if (clone_flags & CLONE_VM)
這個條件判斷邏輯,我們先跳過往後看~~
copy_mm 函數首先會將父進程的虛擬記憶體空間 current->mm 賦值給指針 oldmm。然後通過 dup_mm 函數將父進程的虛擬記憶體空間以及相關頁表拷貝到子進程的 mm_struct 結構中。最後將拷貝出來的 mm_struct 賦值給子進程的 task_struct 結構。
通過 fork() 函數創建出的子進程,它的虛擬記憶體空間以及相關頁表相當於父進程虛擬記憶體空間的一份拷貝,直接從父進程中拷貝到子進程中。
而當我們通過 vfork 或者 clone 系統調用創建出的子進程,首先會設置 CLONE_VM 標識,這樣來到 copy_mm 函數中就會進入 if (clone_flags & CLONE_VM)
條件中,在這個分支中會將父進程的虛擬記憶體空間以及相關頁表直接賦值給子進程。這樣一來父進程和子進程的虛擬記憶體空間就變成共用的了。也就是說父子進程之間使用的虛擬記憶體空間是一樣的,並不是一份拷貝。
子進程共用了父進程的虛擬記憶體空間,這樣子進程就變成了我們熟悉的線程,是否共用地址空間幾乎是進程和線程之間的本質區別。Linux 內核並不區別對待它們,線程對於內核來說僅僅是一個共用特定資源的進程而已。
內核線程和用戶態線程的區別就是內核線程沒有相關的記憶體描述符 mm_struct ,內核線程對應的 task_struct 結構中的 mm 域指向 Null,所以內核線程之間調度是不涉及地址空間切換的。
當一個內核線程被調度時,它會發現自己的虛擬地址空間為 Null,雖然它不會訪問用戶態的記憶體,但是它會訪問內核記憶體,聰明的內核會將調度之前的上一個用戶態進程的虛擬記憶體空間 mm_struct 直接賦值給內核線程,因為內核線程不會訪問用戶空間的記憶體,它僅僅只會訪問內核空間的記憶體,所以直接復用上一個用戶態進程的虛擬地址空間就可以避免為內核線程分配 mm_struct 和相關頁表的開銷,以及避免內核線程之間調度時地址空間的切換開銷。
父進程與子進程的區別,進程與線程的區別,以及內核線程與用戶態線程的區別其實都是圍繞著這個 mm_struct 展開的。
現在我們知道了表示進程虛擬記憶體空間的 mm_struct 結構是如何被創建出來的相關背景,那麼接下來筆者就帶大家深入 mm_struct 結構內部,來看一下內核如何通過這麼一個 mm_struct 結構體來管理進程的虛擬記憶體空間的。
5.1 內核如何劃分用戶態和內核態虛擬記憶體空間
通過 《3. 進程虛擬記憶體空間》小節的介紹我們知道,進程的虛擬記憶體空間分為兩個部分:一部分是用戶態虛擬記憶體空間,另一部分是內核態虛擬記憶體空間。
那麼用戶態的地址空間和內核態的地址空間在內核中是如何被劃分的呢?
這就用到了進程的記憶體描述符 mm_struct 結構體中的 task_size 變數,task_size 定義了用戶態地址空間與內核態地址空間之間的分界線。
struct mm_struct {
unsigned long task_size; /* size of task vm space */
}
通過前邊小節的內容介紹,我們知道在 32 位系統中用戶態虛擬記憶體空間為 3 GB,虛擬記憶體地址範圍為:0x0000 0000 - 0xC000 000 。
內核態虛擬記憶體空間為 1 GB,虛擬記憶體地址範圍為:0xC000 000 - 0xFFFF FFFF。
32 位系統中用戶地址空間和內核地址空間的分界線在 0xC000 000 地址處,那麼自然進程的 mm_struct 結構中的 task_size 為 0xC000 000。
我們來看下內核在 /arch/x86/include/asm/page_32_types.h
文件中關於 TASK_SIZE 的定義。
/*
* User space process size: 3GB (default).
*/
#define TASK_SIZE __PAGE_OFFSET
如下圖所示:__PAGE_OFFSET 的值在 32 位系統下為 0xC000 000。
而在 64 位系統中,只使用了其中的低 48 位來表示虛擬記憶體地址。其中用戶態虛擬記憶體空間為低 128 T,虛擬記憶體地址範圍為:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000 。
內核態虛擬記憶體空間為高 128 T,虛擬記憶體地址範圍為:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 。
64 位系統中用戶地址空間和內核地址空間的分界線在 0x0000 7FFF FFFF F000 地址處,那麼自然進程的 mm_struct 結構中的 task_size 為 0x0000 7FFF FFFF F000 。
我們來看下內核在 /arch/x86/include/asm/page_64_types.h
文件中關於 TASK_SIZE 的定義。
#define TASK_SIZE (test_thread_flag(TIF_ADDR32) ? \
IA32_PAGE_OFFSET : TASK_SIZE_MAX)
#define TASK_SIZE_MAX task_size_max()
#define task_size_max() ((_AC(1,UL) << __VIRTUAL_MASK_SHIFT) - PAGE_SIZE)
#define __VIRTUAL_MASK_SHIFT 47
我們來看下在 64 位系統中內核如何來計算 TASK_SIZE,在 task_size_max() 的計算邏輯中 1 左移 47 位得到的地址是 0x0000800000000000,然後減去一個 PAGE_SIZE (預設為 4K),就是 0x00007FFFFFFFF000,共 128T。所以在 64 位系統中的 TASK_SIZE 為 0x00007FFFFFFFF000 。
這裡我們可以看出,64 位虛擬記憶體空間的佈局是和物理記憶體頁 page 的大小有關的,物理記憶體頁 page 預設大小 PAGE_SIZE 為 4K。
PAGE_SIZE 定義在 /arch/x86/include/asm/page_types.h
文件中:
/* PAGE_SHIFT determines the page size */
#define PAGE_SHIFT 12
#define PAGE_SIZE (_AC(1,UL) << PAGE_SHIFT)
而內核空間的起始地址是 0xFFFF 8000 0000 0000 。在 0x00007FFFFFFFF000 - 0xFFFF 8000 0000 0000 之間的記憶體區域就是我們在 《4.2 64 位機器上進程虛擬記憶體空間分佈》小節中介紹的 canonical address 空洞。
5.2 內核如何佈局進程虛擬記憶體空間
在我們理解了內核是如何劃分進程虛擬記憶體空間和內核虛擬記憶體空間之後,那麼在 《3. 進程虛擬記憶體空間》小節中介紹的那些虛擬記憶體區域在內核中又是如何劃分的呢?
接下來筆者就為大家介紹下內核是如何劃分進程虛擬記憶體空間中的這些記憶體區域的,本小節的示例圖中,筆者只保留了進程虛擬記憶體空間中的核心區域,方便大家理解。
前邊我們提到,內核中採用了一個叫做記憶體描述符的 mm_struct 結構體來表示進程虛擬記憶體空間的全部信息。在本小節中筆者就帶大家到 mm_struct 結構體內部去尋找下相關的線索。
struct mm_struct {
unsigned long task_size; /* size of task vm space */
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
unsigned long mmap_base; /* base of mmap area */
unsigned long total_vm; /* Total pages mapped */
unsigned long locked_vm; /* Pages that have PG_mlocked set */
unsigned long pinned_vm; /* Refcount permanently increased */
unsigned long data_vm; /* VM_WRITE & ~VM_SHARED & ~VM_STACK */
unsigned long exec_vm; /* VM_EXEC & ~VM_WRITE & ~VM_STACK */
unsigned long stack_vm; /* VM_STACK */
...... 省略 ........
}
內核中用 mm_struct 結構體中的上述屬性來定義上圖中虛擬記憶體空間里的不同記憶體區域。
start_code 和 end_code 定義代碼段的起始和結束位置,程式編譯後的二進位文件中的機器碼被載入進記憶體之後就存放在這裡。
start_data 和 end_data 定義數據段的起始和結束位置,二進位文件中存放的全局變數和靜態變數被載入進記憶體中就存放在這裡。
後面緊挨著的是 BSS 段,用於存放未被初始化的全局變數和靜態變數,這些變數在載入進記憶體時會生成一段 0 填充的記憶體區域 (BSS 段), BSS 段的大小是固定的,
下麵就是 OS 堆了,在堆中記憶體地址的增長方向是由低地址向高地址增長, start_brk 定義堆的起始位置,brk 定義堆當前的結束位置。
我們使用 malloc 申請小塊記憶體時(低於 128K),就是通過改變 brk 位置調整堆大小實現的。
接下來就是記憶體映射區,在記憶體映射區記憶體地址的增長方向是由高地址向低地址增長,mmap_base 定義記憶體映射區的起始地址。進程運行時所依賴的動態鏈接庫中的代碼段,數據段,BSS 段以及我們調用 mmap 映射出來的一段虛擬記憶體空間就保存在這個區域。
start_stack 是棧的起始位置在 RBP 寄存器中存儲,棧的結束位置也就是棧頂指針 stack pointer 在 RSP 寄存器中存儲。在棧中記憶體地址的增長方向也是由高地址向低地址增長。
arg_start 和 arg_end 是參數列表的位置, env_start 和 env_end 是環境變數的位置。它們都位於棧中的最高地址處。
在 mm_struct 結構體中除了上述用於劃分虛擬記憶體區域的變數之外,還定義了一些虛擬記憶體與物理記憶體映射內容相關的統計變數,操作系統會把物理記憶體劃分成一頁一頁的區域來進行管理,所以物理記憶體到虛擬記憶體之間的映射也是按照頁為單位進行的。這部分內容筆者會在後續的文章中詳細介紹,大家這裡只需要有個概念就行。
mm_struct 結構體中的 total_vm 表示在進程虛擬記憶體空間中總共與物理記憶體映射的頁的總數。
註意映射這個概念,它表示只是將虛擬記憶體與物理記憶體建立關聯關係,並不代表真正的分配物理記憶體。
當記憶體吃緊的時候,有些頁可以換出到硬碟上,而有些頁因為比較重要,不能換出。locked_vm 就是被鎖定不能換出的記憶體頁總數,pinned_vm 表示既不能換出,也不能移動的記憶體頁總數。
data_vm 表示數據段中映射的記憶體頁數目,exec_vm 是代碼段中存放可執行文件的記憶體頁數目,stack_vm 是棧中所映射的記憶體頁數目,這些變數均是表示進程虛擬記憶體空間中的虛擬記憶體使用情況。
現在關於內核如何對進程虛擬記憶體空間進行佈局的內容我們已經清楚了,那麼佈局之後劃分出的這些虛擬記憶體區域在內核中又是如何被管理的呢?我們接著往下看~~~
5.3 內核如何管理虛擬記憶體區域
在上小節的介紹中,我們知道內核是通過一個 mm_struct 結構的記憶體描述符來表示進程的虛擬記憶體空間的,並通過 task_size 域來劃分用戶態虛擬記憶體空間和內核態虛擬記憶體空間。
而在劃分出的這些虛擬記憶體空間中如上圖所示,裡邊又包含了許多特定的虛擬記憶體區域,比如:代碼段,數據段,堆,記憶體映射區,棧。那麼這些虛擬記憶體區域在內核中又是如何表示的呢?
本小節中,筆者將為大家介紹一個新的結構體 vm_area_struct,正是這個結構體描述了這些虛擬記憶體區域 VMA(virtual memory area)。
struct vm_area_struct {
unsigned long vm_start; /* Our start address within vm_mm. */
unsigned long vm_end; /* The first byte after our end address
within vm_mm. */
/*
* Access permissions of this VMA.
*/
pgprot_t vm_page_prot;
unsigned long vm_flags;
struct anon_vma *anon_vma; /* Serialized by page_table_lock */
struct file * vm_file; /* File we map to (can be NULL). */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE
units */
void * vm_private_data; /* was vm_pte (shared mem) */
/* Function pointers to deal with this struct. */
const struct vm_operations_struct *vm_ops;
}
每個 vm_area_struct 結構對應於虛擬記憶體空間中的唯一虛擬記憶體區域 VMA,vm_start 指向了這塊虛擬記憶體區域的起始地址(最低地址),vm_start 本身包含在這塊虛擬記憶體區域內。vm_end 指向了這塊虛擬記憶體區域的結束地址(最高地址),而 vm_end 本身包含在這塊虛擬記憶體區域之外,所以 vm_area_struct 結構描述的是 [vm_start,vm_end) 這樣一段左閉右開的虛擬記憶體區域。
5.4 定義虛擬記憶體區域的訪問許可權和行為規範
vm_page_prot 和 vm_flags 都是用來標記 vm_area_struct 結構表示的這塊虛擬記憶體區域的訪問許可權和行為規範。
上邊小節中我們也提到,內核會將整塊物理記憶體劃分為一頁一頁大小的區域,以頁為單位來管理這些物理記憶體,每頁大小預設 4K 。而虛擬記憶體最終也是要和物理記憶體一一映射起來的,所以在虛擬記憶體空間中也有虛擬頁的概念與之對應,虛擬記憶體中的虛擬頁映射到物理記憶體中的物理頁。無論是在虛擬記憶體空間中還是在物理記憶體中,內核管理記憶體的最小單位都是頁。
vm_page_prot 偏向於定義底層記憶體管理架構中頁這一級別的訪問控制許可權,它可以直接應用在底層頁表中,它是一個具體的概念。
頁表用於管理虛擬記憶體到物理記憶體之間的映射關係,這部分內容筆者後續會詳細講解,這裡大家有個初步的概念就行。
虛擬記憶體區域 VMA 由許多的虛擬頁 (page) 組成,每個虛擬頁需要經過頁表的轉換才能找到對應的物理頁面。頁表中關於記憶體頁的訪問許可權就是由 vm_page_prot 決定的。
vm_flags 則偏向於定於整個虛擬記憶體區域的訪問許可權以及行為規範。描述的是虛擬記憶體區域中的整體信息,而不是虛擬記憶體區域中具體的某個獨立頁面。它是一個抽象的概念。可以通過 vma->vm_page_prot = vm_get_page_prot(vma->vm_flags)
實現到具體頁面訪問許可權 vm_page_prot 的轉換。
下麵筆者列舉一些常用到的 vm_flags 方便大家有一個直觀的感受:
vm_flags | 訪問許可權 |
---|---|
VM_READ | 可讀 |
VM_WRITE | 可寫 |
VM_EXEC | 可執行 |
VM_SHARD | 可多進程之間共用 |
VM_IO | 可映射至設備 IO 空間 |
VM_RESERVED | 記憶體區域不可被換出 |
VM_SEQ_READ | 記憶體區域可能被順序訪問 |
VM_RAND_READ | 記憶體區域可能被隨機訪問 |
VM_READ,VM_WRITE,VM_EXEC 定義了虛擬記憶體區域是否可以被讀取,寫入,執行等許可權。
比如代碼段這塊記憶體區域的許可權是可讀,可執行,但是不可寫。數據段具有可讀可寫的許可權但是不可執行。堆則具有可讀可寫,可執行的許可權(Java 中的位元組碼存儲在堆中,所以需要可執行許可權),棧一般是可讀可寫的許可權,一般很少有可執行許可權。而文件映射與匿名映射區存放了共用鏈接庫,所以也需要可執行的許可權。
VM_SHARD 用於指定這塊虛擬記憶體區域映射的物理記憶體是否可以在多進程之間共用,以便完成進程間通訊。
設置這個值即為 mmap 的共用映射,不設置的話則為私有映射。這個等後面我們講到 mmap 的相關實現時還會再次提起。
VM_IO 的設置表示這塊虛擬記憶體區域可以映射至設備 IO 空間中。通常在設備驅動程式執行 mmap 進行 IO 空間映射時才會被設置。
VM_RESERVED 的設置表示在記憶體緊張的時候,這塊虛擬記憶體區域非常重要,不能被換出到磁碟中。
VM_SEQ_READ 的設置用來暗示內核,應用程式對這塊虛擬記憶體區域的讀取是會採用順序讀的方式進行,內核會根據實際情況決定預讀後續的記憶體頁數,以便加快下次順序訪問速度。
VM_RAND_READ 的設置會暗示內核,應用程式會對這塊虛擬記憶體區域進行隨機讀取,內核則會根據實際情況減少預讀的記憶體頁數甚至停止預讀。
我們可以通過 posix_fadvise,madvise 系統調用來暗示內核是否對相關記憶體區域進行順序讀取或者隨機讀取。相關的詳細內容,大家可以看下筆者上篇文章 《從 Linux 內核角度探秘 JDK NIO 文件讀寫本質》中的第 9 小節文件頁預讀部分。
通過這一系列的介紹,我們可以看到 vm_flags 就是定義整個虛擬記憶體區域的訪問許可權以及行為規範,而記憶體區域中記憶體的最小單位為頁(4K),虛擬記憶體區域中包含了很多這樣的虛擬頁,對於虛擬記憶體區域 VMA 設置的訪問許可權也會全部複製到區域中包含的記憶體頁中。
5.5 關聯記憶體映射中的映射關係
接下來的三個屬性 anon_vma,vm_file,vm_pgoff 分別和虛擬記憶體映射相關,虛擬記憶體區域可以映射到物理記憶體上,也可以映射到文件中,映射到物理記憶體上我們稱之為匿名映射,映射到文件中我們稱之為文件映射。
那麼這個映射關係在內核中該如何表示呢?這就用到了 vm_area_struct 結構體中的上述三個屬性。
當我們調用 malloc 申請記憶體時,如果申請的是小塊記憶體(低於 128K)則會使用 do_brk() 系統調用通過調整堆中的 brk 指針大小來增加或者回收堆記憶體。
如果申請的是比較大塊的記憶體(超過 128K)時,則會調用 mmap 在上圖虛擬記憶體空間中的文件映射與匿名映射區創建出一塊 VMA 記憶體區域(這裡是匿名映射)。這塊匿名映射區域就用 struct anon_vma 結構表示。
當調用 mmap 進行文件映射時,vm_file 屬性就用來關聯被映射的文件。這樣一來虛擬記憶體區域就與映射文件關聯了起來。vm_pgoff 則表示映射進虛擬記憶體中的文件內容,在文件中的偏移。
當然在匿名映射中,vm_area_struct 結構中的 vm_file 就為 null,vm_pgoff 也就沒有了意義。
vm_private_data 則用於存儲 VMA 中的私有數據。具體的存儲內容和記憶體映射的類型有關,我們暫不展開論述。
5.6 針對虛擬記憶體區域的相關操作
struct vm_area_struct 結構中還有一個 vm_ops 用來指向針對虛擬記憶體區域 VMA 的相關操作的函數指針。
struct vm_operations_struct {
void (*open)(struct vm_area_struct * area);
void (*close)(struct vm_area_struct * area);
vm_fault_t (*fault)(struct vm_fault *vmf);
vm_fault_t (*page_mkwrite)(struct vm_fault *vmf);
..... 省略 .......
}
-
當指定的虛擬記憶體區域被加入到進程虛擬記憶體空間中時,open 函數會被調用
-
當虛擬記憶體區域 VMA 從進程虛擬記憶體空間中被刪除時,close 函數會被調用
-
當進程訪問虛擬記憶體時,訪問的頁面不在物理記憶體中,可能是未分配物理記憶體也可能是被置換到磁碟中,這時就會產生缺頁異常,fault 函數就會被調用。
-
當一個只讀的頁面將要變為可寫時,page_mkwrite 函數會被調用。
struct vm_operations_struct 結構中定義的都是對虛擬記憶體區域 VMA 的相關操作函數指針。
內核中這種類似的用法其實有很多,在內核中每個特定領域的描述符都會定義相關的操作。比如在前邊的文章 《從 Linux 內核角度探秘 JDK NIO 文件讀寫本質》 中我們介紹到內核中的文件描述符 struct file 中定義的 struct file_operations *f_op。裡面定義了內核針對文件操作的函數指針,具體的實現根據不同的文件類型有所不同。
針對 Socket 文件類型,這裡的 file_operations 指向的是 socket_file_ops。
在 ext4 文件系統中管理的文件對應的 file_operations 指向 ext4_file_operations,專門用於操作 ext4 文件系統中的文件。還有針對 page cache 頁高速緩存相關操作定義的 address_space_operations 。
還有我們在 《從 Linux 內核角度看 IO 模型的演變》一文中介紹到,socket 相關的操作介面定義在 inet_stream_ops 函數集合中,負責對上給用戶提供介面。而 socket 與內核協議棧之間的操作介面定義在 struct sock 中的 sk_prot 指針上,這裡指向 tcp_prot 協議操作函數集合。
對 socket 發起的系統 IO 調用時,在內核中首先會調用 socket 的文件結構 struct file 中的 file_operations 文件操作集合,然後調用 struct socket 中的 ops 指向的 inet_stream_opssocket 操作函數,最終調用到 struct sock 中 sk_prot 指針指向的 tcp_prot 內核協議棧操作函數介面集合。
5.7 虛擬記憶體區域在內核中是如何被組織的
在上一小節中,我們介紹了內核中用來表示虛擬記憶體區域 VMA 的結構體 struct vm_area_struct ,並詳細為大家剖析了 struct vm_area_struct 中的一些重要的關鍵屬性。
現在我們已經熟悉了這些虛擬記憶體區域,那麼接下來的問題就是在內核中這些虛擬記憶體區域是如何被組織的呢?
我們繼續來到 struct vm_area_struct 結構中,來看一下與組織結構相關的一些屬性:
struct vm_area_struct {
struct vm_area_struct *vm_next, *vm_prev;
struct rb_node vm_rb;
struct list_head anon_vma_chain;
struct mm_struct *vm_mm; /* The address space we belong to. */
unsigned long vm_start; /* Our start address within vm_mm. */
unsigned long vm_end; /* The first byte after our end address
within vm_mm. */
/*
* Access permissions of this VMA.
*/
pgprot_t vm_page_prot;
unsigned long vm_flags;
struct anon_vma *anon_vma; /* Serialized by page_table_lock */
struct file * vm_file; /* File we map to (can be NULL). */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE
units */
void * vm_private_data; /* was vm_pte (shared mem) */
/* Function pointers to deal with this struct. */
const struct vm_operations_struct *vm_ops;
}
在內核中其實是通過一個 struct vm_area_struct 結構的雙向鏈表將虛擬記憶體空間中的這些虛擬記憶體區域 VMA 串聯起來的。
vm_area_struct 結構中的 vm_next ,vm_prev 指針分別指向 VMA 節點所在雙向鏈表中的後繼節點和前驅節點,內核中的這個 VMA 雙向鏈表是有順序的,所有 VMA 節點按照低地址到高地址的增長方向排序。
雙向鏈表中的最後一個 VMA 節點的 vm_next 指針指向 NULL,雙向鏈表的頭指針存儲在記憶體描述符 struct mm_struct 結構中的 mmap 中,正是這個 mmap 串聯起了整個虛擬記憶體空間中的虛擬記憶體區域。
struct mm_struct {
struct vm_area_struct *mmap; /* list of VMAs */
}
在每個虛擬記憶體區域 VMA 中又通過 struct vm_area_struct 中的 vm_mm 指針指向了所屬的虛擬記憶體空間 mm_struct。
我們可以通過 cat /proc/pid/maps
或者 pmap pid
查看進程的虛擬記憶體空間佈局以及其中包含的所有記憶體區域。這兩個命令背後的實現原理就是通過遍歷內核中的這個 vm_area_struct 雙向鏈表獲取的。
內核中關於這些虛擬記憶體區域的操作除了遍歷之外還有許多需要根據特定虛擬記憶體地址在虛擬記憶體空間中查找特定的虛擬記憶體區域。
尤其在進程虛擬記憶體空間中包含的記憶體區域 VMA 比較多的情況下,使用紅黑樹查找特定虛擬記憶體區域的時間複雜度是 O( logN ) ,可以顯著減少查找所需的時間。
所以在內核中,同樣的記憶體區域 vm_area_struct 會有兩種組織形式,一種是雙向鏈表用於高效的遍歷,另一種就是紅黑樹用於高效的查找。
每個 VMA 區域都是紅黑樹中的一個節點,通過 struct vm_area_struct 結構中的 vm_rb 將自己連接到紅黑樹中。
而紅黑樹中的根節點存儲在記憶體描述符 struct mm_struct 中的 mm_rb 中:
struct mm_struct {
struct rb_root mm_rb;
}
6. 程式編譯後的二進位文件如何映射到虛擬記憶體空間中
經過前邊這麼多小節的內容介紹,現在我們已經熟悉了進程虛擬記憶體空間的佈局,以及內核如何管理這些虛擬記憶體區域,並對進程的虛擬記憶體空間有了一個完整全面的認識。
現在我們再來回到最初的起點,進程的虛擬記憶體空間 mm_struct 以及這些虛擬記憶體區域 vm_area_struct 是如何被創建並初始化的呢?
在 《3. 進程虛擬記憶體空間》小節中,我們介紹進程的虛擬記憶體空間時提到,我們寫的程式代碼編譯之後會生成一個 ELF 格式的二進位文件,這個二進位文件中包含了程式運行時所需要的元信息,比如程式的機器碼,程式中的全局變數以及靜態變數等。
這個 ELF 格式的二進位文件中的佈局和我們前邊講的虛擬記憶體空間中的佈局類似,也是一段一段的,每一段包含了不同的元數據。
磁碟文件中的段我們叫做 Section,記憶體中的段我們叫做 Segment,也就是記憶體區域。
磁碟文件中的這些 Section 會在進程運行之前載入到記憶體中並映射到記憶體中的 Segment。通常是多個 Section 映射到一個 Segment。
比如磁碟文件中的 .text,.rodata 等一些只讀的 Section,會被映射到記憶體的一個只讀可執行的 Segment 里(代碼段)。而 .data,.bss 等一些可讀寫的 Section,則會被映射到記憶體的一個具有讀寫許可權的 Segment 里(數據段,BSS 段)。
那麼這些 ELF 格式的二進位文件中的 Section 是如何載入並映射進虛擬記憶體空間的呢?
內核中完成這個映射過程的函數是 load_elf_binary ,這個函數的作用很大,載入內核的是它,啟動第一個用戶態進程 init 的是它,fork 完了以後,調用 exec 運行一個二進位程式的也是它。當 exec 運行一個二進位程式的時候,除瞭解析 ELF 的格式之外,另外一個重要的事情就是建立上述提到的記憶體映射。
static int load_elf_binary(struct linux_binprm *bprm)
{
......