寫在本文開始之前....
從本文開始我們就正式開啟了 Linux 內(nèi)核內(nèi)存管理子系統(tǒng)源碼解析系列,筆者還是會秉承之前系列文章的風格,采用一步一圖的方式先是詳細介紹相關(guān)原理,在保證大家清晰理解原理的基礎(chǔ)上,我們再來一步一步的解析相關(guān)內(nèi)核源碼的實現(xiàn)。有了源碼的輔證,這樣大家看得也安心,理解起來也放心,最起碼可以證明筆者沒有胡編亂造騙大家,哈哈~~
內(nèi)存管理子系統(tǒng)可謂是 Linux 內(nèi)核眾多子系統(tǒng)中最為復(fù)雜最為龐大的一個,其中包含了眾多繁雜的概念和原理,通過內(nèi)存管理這條主線我們把可以把操作系統(tǒng)的眾多核心系統(tǒng)給拎出來,比如:進程管理子系統(tǒng),網(wǎng)絡(luò)子系統(tǒng),文件子系統(tǒng)等。
由于內(nèi)存管理子系統(tǒng)過于復(fù)雜龐大,其中涉及到的眾多繁雜的概念又是一環(huán)套一環(huán),層層遞進。如何把這些繁雜的概念具有層次感地,并且清晰地,給大家梳理呈現(xiàn)出來真是一件比較有難度的事情,因此關(guān)于這個問題,筆者在動筆寫這個內(nèi)存管理源碼解析系列之前也是思考了很久。
萬事開頭難,那么到底什么內(nèi)容適合作為這個系列的開篇呢 ?筆者還是覺得從大家日常開發(fā)工作中接觸最多最為熟悉的部分開始比較好,比如:在我們?nèi)粘i_發(fā)中創(chuàng)建的類,調(diào)用的函數(shù),在函數(shù)中定義的局部變量以及 new 出來的數(shù)據(jù)容器(Map,List,Set .....等)都需要存儲在物理內(nèi)存中的某個角落。
而我們在程序中編寫業(yè)務(wù)邏輯代碼的時候,往往需要引用這些創(chuàng)建出來的數(shù)據(jù)結(jié)構(gòu),并通過這些引用對相關(guān)數(shù)據(jù)結(jié)構(gòu)進行業(yè)務(wù)處理。
當程序運行起來之后就變成了進程,而這些業(yè)務(wù)數(shù)據(jù)結(jié)構(gòu)的引用在進程的視角里全都都是虛擬內(nèi)存地址,因為進程無論是在用戶態(tài)還是在內(nèi)核態(tài)能夠看到的都是虛擬內(nèi)存空間,物理內(nèi)存空間被操作系統(tǒng)所屏蔽進程是看不到的。
進程通過虛擬內(nèi)存地址訪問這些數(shù)據(jù)結(jié)構(gòu)的時候,虛擬內(nèi)存地址會在內(nèi)存管理子系統(tǒng)中被轉(zhuǎn)換成物理內(nèi)存地址,通過物理內(nèi)存地址就可以訪問到真正存儲這些數(shù)據(jù)結(jié)構(gòu)的物理內(nèi)存了。隨后就可以對這塊物理內(nèi)存進行各種業(yè)務(wù)操作,從而完成業(yè)務(wù)邏輯。
那么到底什么是虛擬內(nèi)存地址 ?
Linux 內(nèi)核為啥要引入虛擬內(nèi)存而不直接使用物理內(nèi)存 ?
虛擬內(nèi)存空間到底長啥樣?
內(nèi)核如何管理虛擬內(nèi)存?
什么又是物理內(nèi)存地址 ?如何訪問物理內(nèi)存?
本文筆者就來為大家詳細一一解答上述幾個問題,讓我們馬上開始吧~~~~

1. 到底什么是虛擬內(nèi)存地址
首先人們提出地址這個概念的目的就是用來方便定位現(xiàn)實世界中某一個具體事物的真實地理位置,它是一種用于定位的概念模型。
舉一個生活中的例子,比如大家在日常生活中給親朋好友郵寄一些本地特產(chǎn)時,都會填寫收件人地址以及寄件人地址。以及在日常網(wǎng)上購物時,都會在相應(yīng)電商 APP 中填寫自己的收獲地址。

隨后快遞小哥就會根據(jù)我們填寫的收貨地址找到我們的真實住所,將我們網(wǎng)購的商品送達到我們的手里。
收貨地址是用來定位我們在現(xiàn)實世界中真實住所地理位置的,而現(xiàn)實世界中我們所在的城市,街道,小區(qū),房屋都是一磚一瓦,一草一木真實存在的。但收貨地址這個概念模型在現(xiàn)實世界中并不真實存在,它只是人們提出的一個虛擬概念,通過收貨地址這個虛擬概念將它和現(xiàn)實世界真實存在的城市,小區(qū),街道的地理位置一一映射起來,這樣我們就可以通過這個虛擬概念來找到現(xiàn)實世界中的具體地理位置。
綜上所述,收貨地址是一個虛擬地址,它是人為定義的,而我們的城市,小區(qū),街道是真實存在的,他們的地理位置就是物理地址。

比如現(xiàn)在的廣東省深圳市在過去叫寶安縣,河北省的石家莊過去叫常山,安徽省的合肥過去叫瀘州。不管是常山也好,石家莊也好,又或是合肥也好,瀘州也罷,這些都是人為定義的名字而已,但是地方還是那個地方,它所在的地理位置是不變的。也就說虛擬地址可以人為的變來變?nèi)?,但是物理地址永遠是不變的。
現(xiàn)在讓我們把視角在切換到計算機的世界,在計算機的世界里內(nèi)存地址用來定義數(shù)據(jù)在內(nèi)存中的存儲位置的,內(nèi)存地址也分為虛擬地址和物理地址。而虛擬地址也是人為設(shè)計的一個概念,類比我們現(xiàn)實世界中的收貨地址,而物理地址則是數(shù)據(jù)在物理內(nèi)存中的真實存儲位置,類比現(xiàn)實世界中的城市,街道,小區(qū)的真實地理位置。
說了這么多,那么到底虛擬內(nèi)存地址長什么樣子呢?
我們還是以日常生活中的收貨地址為例做出類比,我們都很熟悉收貨地址的格式:xx省xx市xx區(qū)xx街道xx小區(qū)xx室,它是按照地區(qū)層次遞進的。同樣,在計算機世界中的虛擬內(nèi)存地址也有這樣的遞進關(guān)系。
這里我們以 Intel Core i7 處理器為例,64 位虛擬地址的格式為:全局頁目錄項(9位)+ 上層頁目錄項(9位)+ 中間頁目錄項(9位)+ 頁內(nèi)偏移(12位)。共 48 位組成的虛擬內(nèi)存地址。

虛擬內(nèi)存地址中的全局頁目錄項就類比我們?nèi)粘I钪惺斋@地址里的省,上層頁目錄項就類比市,中間層頁目錄項類比區(qū)縣,頁表項類比街道小區(qū),頁內(nèi)偏移類比我們所在的樓棟和幾層幾號。
這里大家只需要大體明白虛擬內(nèi)存地址到底長什么樣子,它的格式是什么,能夠和日常生活中的收貨地址對比理解起來就可以了,至于頁目錄項,頁表項以及頁內(nèi)偏移這些計算機世界中的概念,大家暫時先不用管,后續(xù)文章中筆者會慢慢給大家解釋清楚。
32 位虛擬地址的格式為:頁目錄項(10位)+ 頁表項(10位) + 頁內(nèi)偏移(12位)。共 32 位組成的虛擬內(nèi)存地址。

進程虛擬內(nèi)存空間中的每一個字節(jié)都有與其對應(yīng)的虛擬內(nèi)存地址,一個虛擬內(nèi)存地址表示進程虛擬內(nèi)存空間中的一個特定的字節(jié)。
2. 為什么要使用虛擬地址訪問內(nèi)存
經(jīng)過第一小節(jié)的介紹,我們現(xiàn)在明白了計算機世界中的虛擬內(nèi)存地址的含義及其展現(xiàn)形式。那么大家可能會問了,既然物理內(nèi)存地址可以直接定位到數(shù)據(jù)在內(nèi)存中的存儲位置,那為什么我們不直接使用物理內(nèi)存地址去訪問內(nèi)存而是選擇用虛擬內(nèi)存地址去訪問內(nèi)存呢?
在回答大家的這個疑問之前,讓我們先來看下,如果在程序中直接使用物理內(nèi)存地址會發(fā)生什么情況?
假設(shè)現(xiàn)在沒有虛擬內(nèi)存地址,我們在程序中對內(nèi)存的操作全都都是使用物理內(nèi)存地址,在這種情況下,程序員就需要精確的知道每一個變量在內(nèi)存中的具體位置,我們需要手動對物理內(nèi)存進行布局,明確哪些數(shù)據(jù)存儲在內(nèi)存的哪些位置,除此之外我們還需要考慮為每個進程究竟要分配多少內(nèi)存?內(nèi)存緊張的時候該怎么辦?如何避免進程與進程之間的地址沖突?等等一系列復(fù)雜且瑣碎的細節(jié)。
如果我們在單進程系統(tǒng)中比如嵌入式設(shè)備上開發(fā)應(yīng)用程序,系統(tǒng)中只有一個進程,這單個進程獨享所有的物理資源包括內(nèi)存資源。在這種情況下,上述提到的這些直接使用物理內(nèi)存的問題可能還好處理一些,但是仍然具有很高的開發(fā)門檻。
然而在現(xiàn)代操作系統(tǒng)中往往支持多個進程,需要處理多進程之間的協(xié)同問題,在多進程系統(tǒng)中直接使用物理內(nèi)存地址操作內(nèi)存所帶來的上述問題就變得非常復(fù)雜了。
這里筆者為大家舉一個簡單的例子來說明在多進程系統(tǒng)中直接使用物理內(nèi)存地址的復(fù)雜性。
比如我們現(xiàn)在有這樣一個簡單的 Java 程序。
public static void main(String[] args) throws Exception {
string i = args[0];
..........
}
在程序代碼相同的情況下,我們用這份代碼同時啟動三個 JVM 進程,我們暫時將進程依次命名為 a , b , c 。
這三個進程用到的代碼是一樣的,都是我們提前寫好的,可以被多次運行。由于我們是直接操作物理內(nèi)存地址,假設(shè)變量 i 保存在 0x354 這個物理地址上。這三個進程運行起來之后,同時操作這個 0x354 物理地址,這樣這個變量 i 的值不就混亂了嗎? 三個進程就會出現(xiàn)變量的地址沖突。

所以在直接操作物理內(nèi)存的情況下,我們需要知道每一個變量的位置都被安排在了哪里,而且還要注意和多個進程同時運行的時候,不能共用同一個地址,否則就會造成地址沖突。
現(xiàn)實中一個程序會有很多的變量和函數(shù),這樣一來我們給它們都需要計算一個合理的位置,還不能與其他進程沖突,這就很復(fù)雜了。
那么我們該如何解決這個問題呢?程序的局部性原理再一次救了我們~~
程序局部性原理表現(xiàn)為:時間局部性和空間局部性。時間局部性是指如果程序中的某條指令一旦執(zhí)行,則不久之后該指令可能再次被執(zhí)行;如果某塊數(shù)據(jù)被訪問,則不久之后該數(shù)據(jù)可能再次被訪問??臻g局部性是指一旦程序訪問了某個存儲單元,則不久之后,其附近的存儲單元也將被訪問。
從程序局部性原理的描述中我們可以得出這樣一個結(jié)論:進程在運行之后,對于內(nèi)存的訪問不會一下子就要訪問全部的內(nèi)存,相反進程對于內(nèi)存的訪問會表現(xiàn)出明顯的傾向性,更加傾向于訪問最近訪問過的數(shù)據(jù)以及熱點數(shù)據(jù)附近的數(shù)據(jù)。
根據(jù)這個結(jié)論我們就清楚了,無論一個進程實際可以占用的內(nèi)存資源有多大,根據(jù)程序局部性原理,在某一段時間內(nèi),進程真正需要的物理內(nèi)存其實是很少的一部分,我們只需要為每個進程分配很少的物理內(nèi)存就可以保證進程的正常執(zhí)行運轉(zhuǎn)。
而虛擬內(nèi)存的引入正是要解決上述的問題,虛擬內(nèi)存引入之后,進程的視角就會變得非常開闊,每個進程都擁有自己獨立的虛擬地址空間,進程與進程之間的虛擬內(nèi)存地址空間是相互隔離,互不干擾的。每個進程都認為自己獨占所有內(nèi)存空間,自己想干什么就干什么。

系統(tǒng)上還運行了哪些進程和我沒有任何關(guān)系。這樣一來我們就可以將多進程之間協(xié)同的相關(guān)復(fù)雜細節(jié)統(tǒng)統(tǒng)交給內(nèi)核中的內(nèi)存管理模塊來處理,極大地解放了程序員的心智負擔。這一切都是因為虛擬內(nèi)存能夠提供內(nèi)存地址空間的隔離,極大地擴展了可用空間。

這樣進程就以為自己獨占了整個內(nèi)存空間資源,給進程產(chǎn)生了所有內(nèi)存資源都屬于它自己的幻覺,這其實是 CPU 和操作系統(tǒng)使用的一個障眼法罷了,任何一個虛擬內(nèi)存里所存儲的數(shù)據(jù),本質(zhì)上還是保存在真實的物理內(nèi)存里的。只不過內(nèi)核幫我們做了虛擬內(nèi)存到物理內(nèi)存的這一層映射,將不同進程的虛擬地址和不同內(nèi)存的物理地址映射起來。
當 CPU 訪問進程的虛擬地址時,經(jīng)過地址翻譯硬件將虛擬地址轉(zhuǎn)換成不同的物理地址,這樣不同的進程運行的時候,雖然操作的是同一虛擬地址,但其實背后寫入的是不同的物理地址,這樣就不會沖突了。
3. 進程虛擬內(nèi)存空間
上小節(jié)中,我們介紹了為了防止多進程運行時造成的內(nèi)存地址沖突,內(nèi)核引入了虛擬內(nèi)存地址,為每個進程提供了一個獨立的虛擬內(nèi)存空間,使得進程以為自己獨占全部內(nèi)存資源。
那么這個進程獨占的虛擬內(nèi)存空間到底是什么樣子呢?在本小節(jié)中,筆者就為大家揭開這層神秘的面紗~~~
在本小節(jié)內(nèi)容開始之前,我們先想象一下,如果我們是內(nèi)核的設(shè)計人員,我們該從哪些方面來規(guī)劃進程的虛擬內(nèi)存空間呢?
本小節(jié)我們只討論進程用戶態(tài)虛擬內(nèi)存空間的布局,我們先把內(nèi)核態(tài)的虛擬內(nèi)存空間當做一個黑盒來看待,在后面的小節(jié)中筆者再來詳細介紹內(nèi)核態(tài)相關(guān)內(nèi)容。
首先我們會想到的是一個進程運行起來是為了執(zhí)行我們交代給進程的工作,執(zhí)行這些工作的步驟我們通過程序代碼事先編寫好,然后編譯成二進制文件存放在磁盤中,CPU 會執(zhí)行二進制文件中的機器碼來驅(qū)動進程的運行。所以在進程運行之前,這些存放在二進制文件中的機器碼需要被加載進內(nèi)存中,而用于存放這些機器碼的虛擬內(nèi)存空間叫做代碼段。

在程序運行起來之后,總要操作變量吧,在程序代碼中我們通常會定義大量的全局變量和靜態(tài)變量,這些全局變量在程序編譯之后也會存儲在二進制文件中,在程序運行之前,這些全局變量也需要被加載進內(nèi)存中供程序訪問。所以在虛擬內(nèi)存空間中也需要一段區(qū)域來存儲這些全局變量。
那些在代碼中被我們指定了初始值的全局變量和靜態(tài)變量在虛擬內(nèi)存空間中的存儲區(qū)域我們叫做數(shù)據(jù)段。
那些沒有指定初始值的全局變量和靜態(tài)變量在虛擬內(nèi)存空間中的存儲區(qū)域我們叫做 BSS 段。這些未初始化的全局變量被加載進內(nèi)存之后會被初始化為 0 值。

上面介紹的這些全局變量和靜態(tài)變量都是在編譯期間就確定的,但是我們程序在運行期間往往需要動態(tài)的申請內(nèi)存,所以在虛擬內(nèi)存空間中也需要一塊區(qū)域來存放這些動態(tài)申請的內(nèi)存,這塊區(qū)域就叫做堆。注意這里的堆指的是 OS 堆并不是 JVM 中的堆。

除此之外,我們的程序在運行過程中還需要依賴動態(tài)鏈接庫,這些動態(tài)鏈接庫以 .so 文件的形式存放在磁盤中,比如 C 程序中的 glibc,里邊對系統(tǒng)調(diào)用進行了封裝。glibc 庫里提供的用于動態(tài)申請堆內(nèi)存的 malloc 函數(shù)就是對系統(tǒng)調(diào)用 sbrk 和 mmap 的封裝。這些動態(tài)鏈接庫也有自己的對應(yīng)的代碼段,數(shù)據(jù)段,BSS 段,也需要一起被加載進內(nèi)存中。
還有用于內(nèi)存文件映射的系統(tǒng)調(diào)用 mmap,會將文件與內(nèi)存進行映射,那么映射的這塊內(nèi)存(虛擬內(nèi)存)也需要在虛擬地址空間中有一塊區(qū)域存儲。
這些動態(tài)鏈接庫中的代碼段,數(shù)據(jù)段,BSS 段,以及通過 mmap 系統(tǒng)調(diào)用映射的共享內(nèi)存區(qū),在虛擬內(nèi)存空間的存儲區(qū)域叫做文件映射與匿名映射區(qū)。

最后我們在程序運行的時候總該要調(diào)用各種函數(shù)吧,那么調(diào)用函數(shù)過程中使用到的局部變量和函數(shù)參數(shù)也需要一塊內(nèi)存區(qū)域來保存。這一塊區(qū)域在虛擬內(nèi)存空間中叫做棧。

現(xiàn)在進程的虛擬內(nèi)存空間所包含的主要區(qū)域,筆者就為大家介紹完了,我們看到內(nèi)核根據(jù)進程運行的過程中所需要不同種類的數(shù)據(jù)而為其開辟了對應(yīng)的地址空間。分別為:
用于存放進程程序二進制文件中的機器指令的代碼段
用于存放程序二進制文件中定義的全局變量和靜態(tài)變量的數(shù)據(jù)段和 BSS 段。
用于在程序運行過程中動態(tài)申請內(nèi)存的堆。
用于存放動態(tài)鏈接庫以及內(nèi)存映射區(qū)域的文件映射與匿名映射區(qū)。
用于存放函數(shù)調(diào)用過程中的局部變量和函數(shù)參數(shù)的棧。
以上就是我們通過一個程序在運行過程中所需要的數(shù)據(jù)所規(guī)劃出的虛擬內(nèi)存空間的分布,這些只是一個大概的規(guī)劃,那么在真實的 Linux 系統(tǒng)中,進程的虛擬內(nèi)存空間的具體規(guī)劃又是如何的呢?我們接著往下看~~
4. Linux 進程虛擬內(nèi)存空間
在上小節(jié)中我們介紹了進程虛擬內(nèi)存空間中各個內(nèi)存區(qū)域的一個大概分布,在此基礎(chǔ)之上,本小節(jié)筆者就帶大家分別從 32 位 和 64 位機器上看下在 Linux 系統(tǒng)中進程虛擬內(nèi)存空間的真實分布情況。
4.1 32 位機器上進程虛擬內(nèi)存空間分布
在 32 位機器上,指針的尋址范圍為 2^32,所能表達的虛擬內(nèi)存空間為 4 GB。所以在 32 位機器上進程的虛擬內(nèi)存地址范圍為:0x0000 0000 - 0xFFFF FFFF。
其中用戶態(tài)虛擬內(nèi)存空間為 3 GB,虛擬內(nèi)存地址范圍為:0x0000 0000 - 0xC000 000 。
內(nèi)核態(tài)虛擬內(nèi)存空間為 1 GB,虛擬內(nèi)存地址范圍為:0xC000 000 - 0xFFFF FFFF。

但是用戶態(tài)虛擬內(nèi)存空間中的代碼段并不是從 0x0000 0000 地址開始的,而是從 0x0804 8000 地址開始。
0x0000 0000 到 0x0804 8000 這段虛擬內(nèi)存地址是一段不可訪問的保留區(qū),因為在大多數(shù)操作系統(tǒng)中,數(shù)值比較小的地址通常被認為不是一個合法的地址,這塊小地址是不允許訪問的。比如在 C 語言中我們通常會將一些無效的指針設(shè)置為 NULL,指向這塊不允許訪問的地址。
保留區(qū)的上邊就是代碼段和數(shù)據(jù)段,它們是從程序的二進制文件中直接加載進內(nèi)存中的,BSS 段中的數(shù)據(jù)也存在于二進制文件中,因為內(nèi)核知道這些數(shù)據(jù)是沒有初值的,所以在二進制文件中只會記錄 BSS 段的大小,在加載進內(nèi)存時會生成一段 0 填充的內(nèi)存空間。
緊挨著 BSS 段的上邊就是我們經(jīng)常使用到的堆空間,從圖中的紅色箭頭我們可以知道在堆空間中地址的增長方向是從低地址到高地址增長。
內(nèi)核中使用 start_brk 標識堆的起始位置,brk 標識堆當前的結(jié)束位置。當堆申請新的內(nèi)存空間時,只需要將 brk 指針增加對應(yīng)的大小,回收地址時減少對應(yīng)的大小即可。比如當我們通過 malloc 向內(nèi)核申請很小的一塊內(nèi)存時(128K 之內(nèi)),就是通過改變 brk 位置實現(xiàn)的。
堆空間的上邊是一段待分配區(qū)域,用于擴展堆空間的使用。接下來就來到了文件映射與匿名映射區(qū)域。進程運行時所依賴的動態(tài)鏈接庫中的代碼段,數(shù)據(jù)段,BSS 段就加載在這里。還有我們調(diào)用 mmap 映射出來的一段虛擬內(nèi)存空間也保存在這個區(qū)域。注意:在文件映射與匿名映射區(qū)的地址增長方向是從高地址向低地址增長。
接下來用戶態(tài)虛擬內(nèi)存空間的最后一塊區(qū)域就是??臻g了,在這里會保存函數(shù)運行過程所需要的局部變量以及函數(shù)參數(shù)等函數(shù)調(diào)用信息。??臻g中的地址增長方向是從高地址向低地址增長。每次進程申請新的棧地址時,其地址值是在減少的。
在內(nèi)核中使用 start_stack 標識棧的起始位置,RSP 寄存器中保存棧頂指針 stack pointer,RBP 寄存器中保存的是?;刂?。
在??臻g的下邊也有一段待分配區(qū)域用于擴展棧空間,在棧空間的上邊就是內(nèi)核空間了,進程雖然可以看到這段內(nèi)核空間地址,但是就是不能訪問。這就好比我們在飯店里雖然可以看到廚房在哪里,但是廚房門上寫著 “廚房重地,閑人免進” ,我們就是進不去。

4.2 64 位機器上進程虛擬內(nèi)存空間分布
上小節(jié)中介紹的 32 位虛擬內(nèi)存空間布局和本小節(jié)即將要介紹的 64 位虛擬內(nèi)存空間布局都可以通過 cat /proc/pid/maps 或者 pmap pid 來查看某個進程的實際虛擬內(nèi)存布局。
我們知道在 32 位機器上,指針的尋址范圍為 2^32,所能表達的虛擬內(nèi)存空間為 4 GB。
那么我們理所應(yīng)當?shù)臅J為在 64 位機器上,指針的尋址范圍為 2^64,所能表達的虛擬內(nèi)存空間為 16 EB 。虛擬內(nèi)存地址范圍為:0x0000 0000 0000 0000 0000 - 0xFFFF FFFF FFFF FFFF 。
好家伙 !!! 16 EB 的內(nèi)存空間,筆者都沒見過這么大的磁盤,在現(xiàn)實情況中根本不會用到這么大范圍的內(nèi)存空間,
事實上在目前的 64 位系統(tǒng)下只使用了 48 位來描述虛擬內(nèi)存空間,尋址范圍為 2^48 ,所能表達的虛擬內(nèi)存空間為 256TB。
其中低 128 T 表示用戶態(tài)虛擬內(nèi)存空間,虛擬內(nèi)存地址范圍為:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000 。
高 128 T 表示內(nèi)核態(tài)虛擬內(nèi)存空間,虛擬內(nèi)存地址范圍為:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 。
這樣一來就在用戶態(tài)虛擬內(nèi)存空間與內(nèi)核態(tài)虛擬內(nèi)存空間之間形成了一段 0x0000 7FFF FFFF F000 - 0xFFFF 8000 0000 0000 的地址空洞,我們把這個空洞叫做 canonical address 空洞。

那么這個 canonical address 空洞是如何形成的呢?
我們都知道在 64 位機器上的指針尋址范圍為 2^64,但是在實際使用中我們只使用了其中的低 48 位來表示虛擬內(nèi)存地址,那么這多出的高 16 位就形成了這個地址空洞。
大家注意到在低 128T 的用戶態(tài)地址空間:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000 范圍中,所以虛擬內(nèi)存地址的高 16 位全部為 0 。
如果一個虛擬內(nèi)存地址的高 16 位全部為 0 ,那么我們就可以直接判斷出這是一個用戶空間的虛擬內(nèi)存地址。
同樣的道理,在高 128T 的內(nèi)核態(tài)虛擬內(nèi)存空間:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 范圍中,所以虛擬內(nèi)存地址的高 16 位全部為 1 。
也就是說內(nèi)核態(tài)的虛擬內(nèi)存地址的高 16 位全部為 1 ,如果一個試圖訪問內(nèi)核的虛擬地址的高 16 位不全為 1 ,則可以快速判斷這個訪問是非法的。
這個高 16 位的空閑地址被稱為 canonical 。如果虛擬內(nèi)存地址中的高 16 位全部為 0 (表示用戶空間虛擬內(nèi)存地址)或者全部為 1 (表示內(nèi)核空間虛擬內(nèi)存地址),這種地址的形式我們叫做 canonical form,對應(yīng)的地址我們稱作 canonical address 。
那么處于 canonical address 空洞 :0x0000 7FFF FFFF F000 - 0xFFFF 8000 0000 0000 范圍內(nèi)的地址的高 16 位 不全為 0 也不全為 1 。如果某個虛擬地址落在這段 canonical address 空洞區(qū)域中,那就是既不在用戶空間,也不在內(nèi)核空間,肯定是非法訪問了。
未來我們也可以利用這塊 canonical address 空洞,來擴展虛擬內(nèi)存地址的范圍,比如擴展到 56 位。
在我們理解了 canonical address 這個概念之后,我們再來看下 64 位 Linux 系統(tǒng)下的真實虛擬內(nèi)存空間布局情況:

從上圖中我們可以看出 64 位系統(tǒng)中的虛擬內(nèi)存布局和 32 位系統(tǒng)中的虛擬內(nèi)存布局大體上是差不多的。主要不同的地方有三點:
就是前邊提到的由高 16 位空閑地址造成的 canonical address 空洞。在這段范圍內(nèi)的虛擬內(nèi)存地址是不合法的,因為它的高 16 位既不全為 0 也不全為 1,不是一個 canonical address,所以稱之為 canonical address 空洞。
在代碼段跟數(shù)據(jù)段的中間還有一段不可以讀寫的保護段,它的作用是防止程序在讀寫數(shù)據(jù)段的時候越界訪問到代碼段,這個保護段可以讓越界訪問行為直接崩潰,防止它繼續(xù)往下運行。
用戶態(tài)虛擬內(nèi)存空間與內(nèi)核態(tài)虛擬內(nèi)存空間分別占用 128T,其中低128T 分配給用戶態(tài)虛擬內(nèi)存空間,高 128T 分配給內(nèi)核態(tài)虛擬內(nèi)存空間。
5. 進程虛擬內(nèi)存空間的管理
在上一小節(jié)中,筆者為大家介紹了 Linux 操作系統(tǒng)在 32 位機器上和 64 位機器上進程虛擬內(nèi)存空間的布局分布,我們發(fā)現(xiàn)無論是在 32 位機器上還是在 64 位機器上,進程虛擬內(nèi)存空間的核心區(qū)域分布的相對位置是不變的,它們都包含下圖所示的這幾個核心內(nèi)存區(qū)域。

唯一不同的是這些核心內(nèi)存區(qū)域在 32 位機器和 64 位機器上的絕對位置分布會有所不同。
那么在此基礎(chǔ)之上,內(nèi)核如何為進程管理這些虛擬內(nèi)存區(qū)域呢?這將是本小節(jié)重點為大家介紹的內(nèi)容~~
既然我們要介紹進程的虛擬內(nèi)存空間管理,那就離不開進程在內(nèi)核中的描述符 task_struct 結(jié)構(gòu)。
struct task_struct {
// 進程id
pid_t pid;
// 用于標識線程所屬的進程 pid
pid_t tgid;
// 進程打開的文件信息
struct files_struct *files;
// 內(nèi)存描述符表示進程虛擬地址空間
struct mm_struct *mm;
.......... 省略 .......
}
在進程描述符 task_struct 結(jié)構(gòu)中,有一個專門描述進程虛擬地址空間的內(nèi)存描述符 mm_struct 結(jié)構(gòu),這個結(jié)構(gòu)體中包含了前邊幾個小節(jié)中介紹的進程虛擬內(nèi)存空間的全部信息。
每個進程都有唯一的 mm_struct 結(jié)構(gòu)體,也就是前邊提到的每個進程的虛擬地址空間都是獨立,互不干擾的。
當我們調(diào)用 fork() 函數(shù)創(chuàng)建進程的時候,表示進程地址空間的 mm_struct 結(jié)構(gòu)會隨著進程描述符 task_struct 的創(chuàng)建而創(chuàng)建。
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;
......... 省略 ..........
// 為進程創(chuàng)建 task_struct 結(jié)構(gòu),用父進程的資源填充 task_struct 信息
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
......... 省略 ..........
}
隨后會在 copy_process 函數(shù)中創(chuàng)建 task_struct 結(jié)構(gòu),并拷貝父進程的相關(guān)資源到新進程的 task_struct 結(jié)構(gòu)里,其中就包括拷貝父進程的虛擬內(nèi)存空間 mm_struct 結(jié)構(gòu)。這里可以看出子進程在新創(chuàng)建出來之后它的虛擬內(nèi)存空間是和父進程的虛擬內(nèi)存空間一模一樣的,直接拷貝過來。
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;
// 創(chuàng)建 task_struct 結(jié)構(gòu)
p = dup_task_struct(current, node);
....... 初始化子進程 ...........
....... 開始繼承拷貝父進程資源 .......
// 繼承父進程打開的文件描述符
retval = copy_files(clone_flags, p);
// 繼承父進程所屬的文件系統(tǒng)
retval = copy_fs(clone_flags, p);
// 繼承父進程注冊的信號以及信號處理函數(shù)
retval = copy_sighand(clone_flags, p);
retval = copy_signal(clone_flags, p);
// 繼承父進程的虛擬內(nèi)存空間
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);
. ..........省略.........
}
這里我們重點關(guān)注 copy_mm 函數(shù),正是在這里完成了子進程虛擬內(nèi)存空間 mm_struct 結(jié)構(gòu)的的創(chuàng)建以及初始化。
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
// 子進程虛擬內(nèi)存空間,父進程虛擬內(nèi)存空間
struct mm_struct *mm, *oldmm;
int retval;
...... 省略 ......
tsk->mm = NULL;
tsk->active_mm = NULL;
// 獲取父進程虛擬內(nèi)存空間
oldmm = current->mm;
if (!oldmm)
return 0;
...... 省略 ......
// 通過 vfork 或者 clone 系統(tǒng)調(diào)用創(chuàng)建出的子進程(線程)和父進程共享虛擬內(nèi)存空間
if (clone_flags & CLONE_VM) {
// 增加父進程虛擬地址空間的引用計數(shù)
mmget(oldmm);
// 直接將父進程的虛擬內(nèi)存空間賦值給子進程(線程)
// 線程共享其所屬進程的虛擬內(nèi)存空間
mm = oldmm;
goto good_mm;
}
retval = -ENOMEM;
// 如果是 fork 系統(tǒng)調(diào)用創(chuàng)建出的子進程,則將父進程的虛擬內(nèi)存空間以及相關(guān)頁表拷貝到子進程中的 mm_struct 結(jié)構(gòu)中。
mm = dup_mm(tsk);
if (!mm)
goto fail_nomem;
good_mm:
// 將拷貝出來的父進程虛擬內(nèi)存空間 mm_struct 賦值給子進程
tsk->mm = mm;
tsk->active_mm = mm;
return 0;
...... 省略 ......
由于本小節(jié)中我們舉的示例是通過 fork() 函數(shù)創(chuàng)建子進程的情形,所以這里大家先占時忽略 if (clone_flags & CLONE_VM) 這個條件判斷邏輯,我們先跳過往后看~~
copy_mm 函數(shù)首先會將父進程的虛擬內(nèi)存空間 current->mm 賦值給指針 oldmm。然后通過 dup_mm 函數(shù)將父進程的虛擬內(nèi)存空間以及相關(guān)頁表拷貝到子進程的 mm_struct 結(jié)構(gòu)中。最后將拷貝出來的 mm_struct 賦值給子進程的 task_struct 結(jié)構(gòu)。
通過 fork() 函數(shù)創(chuàng)建出的子進程,它的虛擬內(nèi)存空間以及相關(guān)頁表相當于父進程虛擬內(nèi)存空間的一份拷貝,直接從父進程中拷貝到子進程中。
而當我們通過 vfork 或者 clone 系統(tǒng)調(diào)用創(chuàng)建出的子進程,首先會設(shè)置 CLONE_VM 標識,這樣來到 copy_mm 函數(shù)中就會進入 if (clone_flags & CLONE_VM) 條件中,在這個分支中會將父進程的虛擬內(nèi)存空間以及相關(guān)頁表直接賦值給子進程。這樣一來父進程和子進程的虛擬內(nèi)存空間就變成共享的了。也就是說父子進程之間使用的虛擬內(nèi)存空間是一樣的,并不是一份拷貝。
子進程共享了父進程的虛擬內(nèi)存空間,這樣子進程就變成了我們熟悉的線程,是否共享地址空間幾乎是進程和線程之間的本質(zhì)區(qū)別。Linux 內(nèi)核并不區(qū)別對待它們,線程對于內(nèi)核來說僅僅是一個共享特定資源的進程而已。
內(nèi)核線程和用戶態(tài)線程的區(qū)別就是內(nèi)核線程沒有相關(guān)的內(nèi)存描述符 mm_struct ,內(nèi)核線程對應(yīng)的 task_struct 結(jié)構(gòu)中的 mm 域指向 Null,所以內(nèi)核線程之間調(diào)度是不涉及地址空間切換的。
當一個內(nèi)核線程被調(diào)度時,它會發(fā)現(xiàn)自己的虛擬地址空間為 Null,雖然它不會訪問用戶態(tài)的內(nèi)存,但是它會訪問內(nèi)核內(nèi)存,聰明的內(nèi)核會將調(diào)度之前的上一個用戶態(tài)進程的虛擬內(nèi)存空間 mm_struct 直接賦值給內(nèi)核線程,因為內(nèi)核線程不會訪問用戶空間的內(nèi)存,它僅僅只會訪問內(nèi)核空間的內(nèi)存,所以直接復(fù)用上一個用戶態(tài)進程的虛擬地址空間就可以避免為內(nèi)核線程分配 mm_struct 和相關(guān)頁表的開銷,以及避免內(nèi)核線程之間調(diào)度時地址空間的切換開銷。
父進程與子進程的區(qū)別,進程與線程的區(qū)別,以及內(nèi)核線程與用戶態(tài)線程的區(qū)別其實都是圍繞著這個 mm_struct 展開的。
現(xiàn)在我們知道了表示進程虛擬內(nèi)存空間的 mm_struct 結(jié)構(gòu)是如何被創(chuàng)建出來的相關(guān)背景,那么接下來筆者就帶大家深入 mm_struct 結(jié)構(gòu)內(nèi)部,來看一下內(nèi)核如何通過這么一個 mm_struct 結(jié)構(gòu)體來管理進程的虛擬內(nèi)存空間的。
5.1 內(nèi)核如何劃分用戶態(tài)和內(nèi)核態(tài)虛擬內(nèi)存空間
通過 《3. 進程虛擬內(nèi)存空間》小節(jié)的介紹我們知道,進程的虛擬內(nèi)存空間分為兩個部分:一部分是用戶態(tài)虛擬內(nèi)存空間,另一部分是內(nèi)核態(tài)虛擬內(nèi)存空間。

那么用戶態(tài)的地址空間和內(nèi)核態(tài)的地址空間在內(nèi)核中是如何被劃分的呢?
這就用到了進程的內(nèi)存描述符 mm_struct 結(jié)構(gòu)體中的 task_size 變量,task_size 定義了用戶態(tài)地址空間與內(nèi)核態(tài)地址空間之間的分界線。
struct mm_struct {
unsigned long task_size; /* size of task vm space */
}
通過前邊小節(jié)的內(nèi)容介紹,我們知道在 32 位系統(tǒng)中用戶態(tài)虛擬內(nèi)存空間為 3 GB,虛擬內(nèi)存地址范圍為:0x0000 0000 - 0xC000 000 。
內(nèi)核態(tài)虛擬內(nèi)存空間為 1 GB,虛擬內(nèi)存地址范圍為:0xC000 000 - 0xFFFF FFFF。

32 位系統(tǒng)中用戶地址空間和內(nèi)核地址空間的分界線在 0xC000 000 地址處,那么自然進程的 mm_struct 結(jié)構(gòu)中的 task_size 為 0xC000 000。
我們來看下內(nèi)核在 /arch/x86/include/asm/page_32_types.h 文件中關(guān)于 TASK_SIZE 的定義。
/*
* User space process size: 3GB (default).
*/
#define TASK_SIZE __PAGE_OFFSET
如下圖所示:__PAGE_OFFSET 的值在 32 位系統(tǒng)下為 0xC000 000。

而在 64 位系統(tǒng)中,只使用了其中的低 48 位來表示虛擬內(nèi)存地址。其中用戶態(tài)虛擬內(nèi)存空間為低 128 T,虛擬內(nèi)存地址范圍為:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000 。
內(nèi)核態(tài)虛擬內(nèi)存空間為高 128 T,虛擬內(nèi)存地址范圍為:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 。

64 位系統(tǒng)中用戶地址空間和內(nèi)核地址空間的分界線在 0x0000 7FFF FFFF F000 地址處,那么自然進程的 mm_struct 結(jié)構(gòu)中的 task_size 為 0x0000 7FFF FFFF F000 。
我們來看下內(nèi)核在 /arch/x86/include/asm/page_64_types.h 文件中關(guān)于 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 位系統(tǒng)中內(nèi)核如何來計算 TASK_SIZE,在 task_size_max() 的計算邏輯中 1 左移 47 位得到的地址是 0x0000800000000000,然后減去一個 PAGE_SIZE (默認為 4K),就是 0x00007FFFFFFFF000,共 128T。所以在 64 位系統(tǒng)中的 TASK_SIZE 為 0x00007FFFFFFFF000 。
這里我們可以看出,64 位虛擬內(nèi)存空間的布局是和物理內(nèi)存頁 page 的大小有關(guān)的,物理內(nèi)存頁 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)
而內(nèi)核空間的起始地址是 0xFFFF 8000 0000 0000 。在 0x00007FFFFFFFF000 - 0xFFFF 8000 0000 0000 之間的內(nèi)存區(qū)域就是我們在 《4.2 64 位機器上進程虛擬內(nèi)存空間分布》小節(jié)中介紹的 canonical address 空洞。
5.2 內(nèi)核如何布局進程虛擬內(nèi)存空間
在我們理解了內(nèi)核是如何劃分進程虛擬內(nèi)存空間和內(nèi)核虛擬內(nèi)存空間之后,那么在 《3. 進程虛擬內(nèi)存空間》小節(jié)中介紹的那些虛擬內(nèi)存區(qū)域在內(nèi)核中又是如何劃分的呢?
接下來筆者就為大家介紹下內(nèi)核是如何劃分進程虛擬內(nèi)存空間中的這些內(nèi)存區(qū)域的,本小節(jié)的示例圖中,筆者只保留了進程虛擬內(nèi)存空間中的核心區(qū)域,方便大家理解。

前邊我們提到,內(nèi)核中采用了一個叫做內(nèi)存描述符的 mm_struct 結(jié)構(gòu)體來表示進程虛擬內(nèi)存空間的全部信息。在本小節(jié)中筆者就帶大家到 mm_struct 結(jié)構(gòu)體內(nèi)部去尋找下相關(guān)的線索。
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 */
...... 省略 ........
}
內(nèi)核中用 mm_struct 結(jié)構(gòu)體中的上述屬性來定義上圖中虛擬內(nèi)存空間里的不同內(nèi)存區(qū)域。
start_code 和 end_code 定義代碼段的起始和結(jié)束位置,程序編譯后的二進制文件中的機器碼被加載進內(nèi)存之后就存放在這里。
start_data 和 end_data 定義數(shù)據(jù)段的起始和結(jié)束位置,二進制文件中存放的全局變量和靜態(tài)變量被加載進內(nèi)存中就存放在這里。
后面緊挨著的是 BSS 段,用于存放未被初始化的全局變量和靜態(tài)變量,這些變量在加載進內(nèi)存時會生成一段 0 填充的內(nèi)存區(qū)域 (BSS 段), BSS 段的大小是固定的,
下面就是 OS 堆了,在堆中內(nèi)存地址的增長方向是由低地址向高地址增長, start_brk 定義堆的起始位置,brk 定義堆當前的結(jié)束位置。
我們使用 malloc 申請小塊內(nèi)存時(低于 128K),就是通過改變 brk 位置調(diào)整堆大小實現(xiàn)的。
接下來就是內(nèi)存映射區(qū),在內(nèi)存映射區(qū)內(nèi)存地址的增長方向是由高地址向低地址增長,mmap_base 定義內(nèi)存映射區(qū)的起始地址。進程運行時所依賴的動態(tài)鏈接庫中的代碼段,數(shù)據(jù)段,BSS 段以及我們調(diào)用 mmap 映射出來的一段虛擬內(nèi)存空間就保存在這個區(qū)域。
start_stack 是棧的起始位置在 RBP 寄存器中存儲,棧的結(jié)束位置也就是棧頂指針 stack pointer 在 RSP 寄存器中存儲。在棧中內(nèi)存地址的增長方向也是由高地址向低地址增長。
arg_start 和 arg_end 是參數(shù)列表的位置, env_start 和 env_end 是環(huán)境變量的位置。它們都位于棧中的最高地址處。

在 mm_struct 結(jié)構(gòu)體中除了上述用于劃分虛擬內(nèi)存區(qū)域的變量之外,還定義了一些虛擬內(nèi)存與物理內(nèi)存映射內(nèi)容相關(guān)的統(tǒng)計變量,操作系統(tǒng)會把物理內(nèi)存劃分成一頁一頁的區(qū)域來進行管理,所以物理內(nèi)存到虛擬內(nèi)存之間的映射也是按照頁為單位進行的。這部分內(nèi)容筆者會在后續(xù)的文章中詳細介紹,大家這里只需要有個概念就行。
mm_struct 結(jié)構(gòu)體中的 total_vm 表示在進程虛擬內(nèi)存空間中總共與物理內(nèi)存映射的頁的總數(shù)。
注意映射這個概念,它表示只是將虛擬內(nèi)存與物理內(nèi)存建立關(guān)聯(lián)關(guān)系,并不代表真正的分配物理內(nèi)存。
當內(nèi)存吃緊的時候,有些頁可以換出到硬盤上,而有些頁因為比較重要,不能換出。locked_vm 就是被鎖定不能換出的內(nèi)存頁總數(shù),pinned_vm 表示既不能換出,也不能移動的內(nèi)存頁總數(shù)。
data_vm 表示數(shù)據(jù)段中映射的內(nèi)存頁數(shù)目,exec_vm 是代碼段中存放可執(zhí)行文件的內(nèi)存頁數(shù)目,stack_vm 是棧中所映射的內(nèi)存頁數(shù)目,這些變量均是表示進程虛擬內(nèi)存空間中的虛擬內(nèi)存使用情況。
現(xiàn)在關(guān)于內(nèi)核如何對進程虛擬內(nèi)存空間進行布局的內(nèi)容我們已經(jīng)清楚了,那么布局之后劃分出的這些虛擬內(nèi)存區(qū)域在內(nèi)核中又是如何被管理的呢?我們接著往下看~~~
5.3 內(nèi)核如何管理虛擬內(nèi)存區(qū)域
在上小節(jié)的介紹中,我們知道內(nèi)核是通過一個 mm_struct 結(jié)構(gòu)的內(nèi)存描述符來表示進程的虛擬內(nèi)存空間的,并通過 task_size 域來劃分用戶態(tài)虛擬內(nèi)存空間和內(nèi)核態(tài)虛擬內(nèi)存空間。

而在劃分出的這些虛擬內(nèi)存空間中如上圖所示,里邊又包含了許多特定的虛擬內(nèi)存區(qū)域,比如:代碼段,數(shù)據(jù)段,堆,內(nèi)存映射區(qū),棧。那么這些虛擬內(nèi)存區(qū)域在內(nèi)核中又是如何表示的呢?
本小節(jié)中,筆者將為大家介紹一個新的結(jié)構(gòu)體 vm_area_struct,正是這個結(jié)構(gòu)體描述了這些虛擬內(nèi)存區(qū)域 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 結(jié)構(gòu)對應(yīng)于虛擬內(nèi)存空間中的唯一虛擬內(nèi)存區(qū)域 VMA,vm_start 指向了這塊虛擬內(nèi)存區(qū)域的起始地址(最低地址),vm_start 本身包含在這塊虛擬內(nèi)存區(qū)域內(nèi)。vm_end 指向了這塊虛擬內(nèi)存區(qū)域的結(jié)束地址(最高地址),而 vm_end 本身包含在這塊虛擬內(nèi)存區(qū)域之外,所以 vm_area_struct 結(jié)構(gòu)描述的是 [vm_start,vm_end) 這樣一段左閉右開的虛擬內(nèi)存區(qū)域。

5.4 定義虛擬內(nèi)存區(qū)域的訪問權(quán)限和行為規(guī)范
vm_page_prot 和 vm_flags 都是用來標記 vm_area_struct 結(jié)構(gòu)表示的這塊虛擬內(nèi)存區(qū)域的訪問權(quán)限和行為規(guī)范。
上邊小節(jié)中我們也提到,內(nèi)核會將整塊物理內(nèi)存劃分為一頁一頁大小的區(qū)域,以頁為單位來管理這些物理內(nèi)存,每頁大小默認 4K 。而虛擬內(nèi)存最終也是要和物理內(nèi)存一一映射起來的,所以在虛擬內(nèi)存空間中也有虛擬頁的概念與之對應(yīng),虛擬內(nèi)存中的虛擬頁映射到物理內(nèi)存中的物理頁。無論是在虛擬內(nèi)存空間中還是在物理內(nèi)存中,內(nèi)核管理內(nèi)存的最小單位都是頁。
vm_page_prot 偏向于定義底層內(nèi)存管理架構(gòu)中頁這一級別的訪問控制權(quán)限,它可以直接應(yīng)用在底層頁表中,它是一個具體的概念。
頁表用于管理虛擬內(nèi)存到物理內(nèi)存之間的映射關(guān)系,這部分內(nèi)容筆者后續(xù)會詳細講解,這里大家有個初步的概念就行。
虛擬內(nèi)存區(qū)域 VMA 由許多的虛擬頁 (page) 組成,每個虛擬頁需要經(jīng)過頁表的轉(zhuǎn)換才能找到對應(yīng)的物理頁面。頁表中關(guān)于內(nèi)存頁的訪問權(quán)限就是由 vm_page_prot 決定的。
vm_flags 則偏向于定于整個虛擬內(nèi)存區(qū)域的訪問權(quán)限以及行為規(guī)范。描述的是虛擬內(nèi)存區(qū)域中的整體信息,而不是虛擬內(nèi)存區(qū)域中具體的某個獨立頁面。它是一個抽象的概念??梢酝ㄟ^ vma->vm_page_prot = vm_get_page_prot(vma->vm_flags) 實現(xiàn)到具體頁面訪問權(quán)限 vm_page_prot 的轉(zhuǎn)換。
下面筆者列舉一些常用到的 vm_flags 方便大家有一個直觀的感受:
| vm_flags | 訪問權(quán)限 |
|---|---|
| VM_READ | 可讀 |
| VM_WRITE | 可寫 |
| VM_EXEC | 可執(zhí)行 |
| VM_SHARD | 可多進程之間共享 |
| VM_IO | 可映射至設(shè)備 IO 空間 |
| VM_RESERVED | 內(nèi)存區(qū)域不可被換出 |
| VM_SEQ_READ | 內(nèi)存區(qū)域可能被順序訪問 |
| VM_RAND_READ | 內(nèi)存區(qū)域可能被隨機訪問 |
VM_READ,VM_WRITE,VM_EXEC 定義了虛擬內(nèi)存區(qū)域是否可以被讀取,寫入,執(zhí)行等權(quán)限。
比如代碼段這塊內(nèi)存區(qū)域的權(quán)限是可讀,可執(zhí)行,但是不可寫。數(shù)據(jù)段具有可讀可寫的權(quán)限但是不可執(zhí)行。堆則具有可讀可寫,可執(zhí)行的權(quán)限(Java 中的字節(jié)碼存儲在堆中,所以需要可執(zhí)行權(quán)限),棧一般是可讀可寫的權(quán)限,一般很少有可執(zhí)行權(quán)限。而文件映射與匿名映射區(qū)存放了共享鏈接庫,所以也需要可執(zhí)行的權(quán)限。

VM_SHARD 用于指定這塊虛擬內(nèi)存區(qū)域映射的物理內(nèi)存是否可以在多進程之間共享,以便完成進程間通訊。
設(shè)置這個值即為 mmap 的共享映射,不設(shè)置的話則為私有映射。這個等后面我們講到 mmap 的相關(guān)實現(xiàn)時還會再次提起。
VM_IO 的設(shè)置表示這塊虛擬內(nèi)存區(qū)域可以映射至設(shè)備 IO 空間中。通常在設(shè)備驅(qū)動程序執(zhí)行 mmap 進行 IO 空間映射時才會被設(shè)置。
VM_RESERVED 的設(shè)置表示在內(nèi)存緊張的時候,這塊虛擬內(nèi)存區(qū)域非常重要,不能被換出到磁盤中。
VM_SEQ_READ 的設(shè)置用來暗示內(nèi)核,應(yīng)用程序?qū)@塊虛擬內(nèi)存區(qū)域的讀取是會采用順序讀的方式進行,內(nèi)核會根據(jù)實際情況決定預(yù)讀后續(xù)的內(nèi)存頁數(shù),以便加快下次順序訪問速度。
VM_RAND_READ 的設(shè)置會暗示內(nèi)核,應(yīng)用程序會對這塊虛擬內(nèi)存區(qū)域進行隨機讀取,內(nèi)核則會根據(jù)實際情況減少預(yù)讀的內(nèi)存頁數(shù)甚至停止預(yù)讀。
我們可以通過 posix_fadvise,madvise 系統(tǒng)調(diào)用來暗示內(nèi)核是否對相關(guān)內(nèi)存區(qū)域進行順序讀取或者隨機讀取。相關(guān)的詳細內(nèi)容,大家可以看下筆者上篇文章 《從 Linux 內(nèi)核角度探秘 JDK NIO 文件讀寫本質(zhì)》中的第 9 小節(jié)文件頁預(yù)讀部分。
通過這一系列的介紹,我們可以看到 vm_flags 就是定義整個虛擬內(nèi)存區(qū)域的訪問權(quán)限以及行為規(guī)范,而內(nèi)存區(qū)域中內(nèi)存的最小單位為頁(4K),虛擬內(nèi)存區(qū)域中包含了很多這樣的虛擬頁,對于虛擬內(nèi)存區(qū)域 VMA 設(shè)置的訪問權(quán)限也會全部復(fù)制到區(qū)域中包含的內(nèi)存頁中。
5.5 關(guān)聯(lián)內(nèi)存映射中的映射關(guān)系
接下來的三個屬性 anon_vma,vm_file,vm_pgoff 分別和虛擬內(nèi)存映射相關(guān),虛擬內(nèi)存區(qū)域可以映射到物理內(nèi)存上,也可以映射到文件中,映射到物理內(nèi)存上我們稱之為匿名映射,映射到文件中我們稱之為文件映射。
那么這個映射關(guān)系在內(nèi)核中該如何表示呢?這就用到了 vm_area_struct 結(jié)構(gòu)體中的上述三個屬性。

當我們調(diào)用 malloc 申請內(nèi)存時,如果申請的是小塊內(nèi)存(低于 128K)則會使用 do_brk() 系統(tǒng)調(diào)用通過調(diào)整堆中的 brk 指針大小來增加或者回收堆內(nèi)存。
如果申請的是比較大塊的內(nèi)存(超過 128K)時,則會調(diào)用 mmap 在上圖虛擬內(nèi)存空間中的文件映射與匿名映射區(qū)創(chuàng)建出一塊 VMA 內(nèi)存區(qū)域(這里是匿名映射)。這塊匿名映射區(qū)域就用 struct anon_vma 結(jié)構(gòu)表示。
當調(diào)用 mmap 進行文件映射時,vm_file 屬性就用來關(guān)聯(lián)被映射的文件。這樣一來虛擬內(nèi)存區(qū)域就與映射文件關(guān)聯(lián)了起來。vm_pgoff 則表示映射進虛擬內(nèi)存中的文件內(nèi)容,在文件中的偏移。
當然在匿名映射中,vm_area_struct 結(jié)構(gòu)中的 vm_file 就為 null,vm_pgoff 也就沒有了意義。
vm_private_data 則用于存儲 VMA 中的私有數(shù)據(jù)。具體的存儲內(nèi)容和內(nèi)存映射的類型有關(guān),我們暫不展開論述。
5.6 針對虛擬內(nèi)存區(qū)域的相關(guān)操作
struct vm_area_struct 結(jié)構(gòu)中還有一個 vm_ops 用來指向針對虛擬內(nèi)存區(qū)域 VMA 的相關(guān)操作的函數(shù)指針。
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);
..... 省略 .......
}
當指定的虛擬內(nèi)存區(qū)域被加入到進程虛擬內(nèi)存空間中時,open 函數(shù)會被調(diào)用
當虛擬內(nèi)存區(qū)域 VMA 從進程虛擬內(nèi)存空間中被刪除時,close 函數(shù)會被調(diào)用
當進程訪問虛擬內(nèi)存時,訪問的頁面不在物理內(nèi)存中,可能是未分配物理內(nèi)存也可能是被置換到磁盤中,這時就會產(chǎn)生缺頁異常,fault 函數(shù)就會被調(diào)用。
當一個只讀的頁面將要變?yōu)榭蓪憰r,page_mkwrite 函數(shù)會被調(diào)用。
struct vm_operations_struct 結(jié)構(gòu)中定義的都是對虛擬內(nèi)存區(qū)域 VMA 的相關(guān)操作函數(shù)指針。
內(nèi)核中這種類似的用法其實有很多,在內(nèi)核中每個特定領(lǐng)域的描述符都會定義相關(guān)的操作。比如在前邊的文章 《從 Linux 內(nèi)核角度探秘 JDK NIO 文件讀寫本質(zhì)》 中我們介紹到內(nèi)核中的文件描述符 struct file 中定義的 struct file_operations *f_op。里面定義了內(nèi)核針對文件操作的函數(shù)指針,具體的實現(xiàn)根據(jù)不同的文件類型有所不同。
針對 Socket 文件類型,這里的 file_operations 指向的是 socket_file_ops。

在 ext4 文件系統(tǒng)中管理的文件對應(yīng)的 file_operations 指向 ext4_file_operations,專門用于操作 ext4 文件系統(tǒng)中的文件。還有針對 page cache 頁高速緩存相關(guān)操作定義的 address_space_operations 。

還有我們在 《從 Linux 內(nèi)核角度看 IO 模型的演變》一文中介紹到,socket 相關(guān)的操作接口定義在 inet_stream_ops 函數(shù)集合中,負責對上給用戶提供接口。而 socket 與內(nèi)核協(xié)議棧之間的操作接口定義在 struct sock 中的 sk_prot 指針上,這里指向 tcp_prot 協(xié)議操作函數(shù)集合。

對 socket 發(fā)起的系統(tǒng) IO 調(diào)用時,在內(nèi)核中首先會調(diào)用 socket 的文件結(jié)構(gòu) struct file 中的 file_operations 文件操作集合,然后調(diào)用 struct socket 中的 ops 指向的 inet_stream_opssocket 操作函數(shù),最終調(diào)用到 struct sock 中 sk_prot 指針指向的 tcp_prot 內(nèi)核協(xié)議棧操作函數(shù)接口集合。
5.7 虛擬內(nèi)存區(qū)域在內(nèi)核中是如何被組織的
在上一小節(jié)中,我們介紹了內(nèi)核中用來表示虛擬內(nèi)存區(qū)域 VMA 的結(jié)構(gòu)體 struct vm_area_struct ,并詳細為大家剖析了 struct vm_area_struct 中的一些重要的關(guān)鍵屬性。
現(xiàn)在我們已經(jīng)熟悉了這些虛擬內(nèi)存區(qū)域,那么接下來的問題就是在內(nèi)核中這些虛擬內(nèi)存區(qū)域是如何被組織的呢?

我們繼續(xù)來到 struct vm_area_struct 結(jié)構(gòu)中,來看一下與組織結(jié)構(gòu)相關(guān)的一些屬性:
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;
}
在內(nèi)核中其實是通過一個 struct vm_area_struct 結(jié)構(gòu)的雙向鏈表將虛擬內(nèi)存空間中的這些虛擬內(nèi)存區(qū)域 VMA 串聯(lián)起來的。
vm_area_struct 結(jié)構(gòu)中的 vm_next ,vm_prev 指針分別指向 VMA 節(jié)點所在雙向鏈表中的后繼節(jié)點和前驅(qū)節(jié)點,內(nèi)核中的這個 VMA 雙向鏈表是有順序的,所有 VMA 節(jié)點按照低地址到高地址的增長方向排序。
雙向鏈表中的最后一個 VMA 節(jié)點的 vm_next 指針指向 NULL,雙向鏈表的頭指針存儲在內(nèi)存描述符 struct mm_struct 結(jié)構(gòu)中的 mmap 中,正是這個 mmap 串聯(lián)起了整個虛擬內(nèi)存空間中的虛擬內(nèi)存區(qū)域。
struct mm_struct {
struct vm_area_struct *mmap; /* list of VMAs */
}
在每個虛擬內(nèi)存區(qū)域 VMA 中又通過 struct vm_area_struct 中的 vm_mm 指針指向了所屬的虛擬內(nèi)存空間 mm_struct。

我們可以通過 cat /proc/pid/maps 或者 pmap pid 查看進程的虛擬內(nèi)存空間布局以及其中包含的所有內(nèi)存區(qū)域。這兩個命令背后的實現(xiàn)原理就是通過遍歷內(nèi)核中的這個 vm_area_struct 雙向鏈表獲取的。
內(nèi)核中關(guān)于這些虛擬內(nèi)存區(qū)域的操作除了遍歷之外還有許多需要根據(jù)特定虛擬內(nèi)存地址在虛擬內(nèi)存空間中查找特定的虛擬內(nèi)存區(qū)域。
尤其在進程虛擬內(nèi)存空間中包含的內(nèi)存區(qū)域 VMA 比較多的情況下,使用紅黑樹查找特定虛擬內(nèi)存區(qū)域的時間復(fù)雜度是 O( logN ) ,可以顯著減少查找所需的時間。
所以在內(nèi)核中,同樣的內(nèi)存區(qū)域 vm_area_struct 會有兩種組織形式,一種是雙向鏈表用于高效的遍歷,另一種就是紅黑樹用于高效的查找。
每個 VMA 區(qū)域都是紅黑樹中的一個節(jié)點,通過 struct vm_area_struct 結(jié)構(gòu)中的 vm_rb 將自己連接到紅黑樹中。
而紅黑樹中的根節(jié)點存儲在內(nèi)存描述符 struct mm_struct 中的 mm_rb 中:
struct mm_struct {
struct rb_root mm_rb;
}

6. 程序編譯后的二進制文件如何映射到虛擬內(nèi)存空間中
經(jīng)過前邊這么多小節(jié)的內(nèi)容介紹,現(xiàn)在我們已經(jīng)熟悉了進程虛擬內(nèi)存空間的布局,以及內(nèi)核如何管理這些虛擬內(nèi)存區(qū)域,并對進程的虛擬內(nèi)存空間有了一個完整全面的認識。
現(xiàn)在我們再來回到最初的起點,進程的虛擬內(nèi)存空間 mm_struct 以及這些虛擬內(nèi)存區(qū)域 vm_area_struct 是如何被創(chuàng)建并初始化的呢?

在 《3. 進程虛擬內(nèi)存空間》小節(jié)中,我們介紹進程的虛擬內(nèi)存空間時提到,我們寫的程序代碼編譯之后會生成一個 ELF 格式的二進制文件,這個二進制文件中包含了程序運行時所需要的元信息,比如程序的機器碼,程序中的全局變量以及靜態(tài)變量等。
這個 ELF 格式的二進制文件中的布局和我們前邊講的虛擬內(nèi)存空間中的布局類似,也是一段一段的,每一段包含了不同的元數(shù)據(jù)。
磁盤文件中的段我們叫做 Section,內(nèi)存中的段我們叫做 Segment,也就是內(nèi)存區(qū)域。
磁盤文件中的這些 Section 會在進程運行之前加載到內(nèi)存中并映射到內(nèi)存中的 Segment。通常是多個 Section 映射到一個 Segment。
比如磁盤文件中的 .text,.rodata 等一些只讀的 Section,會被映射到內(nèi)存的一個只讀可執(zhí)行的 Segment 里(代碼段)。而 .data,.bss 等一些可讀寫的 Section,則會被映射到內(nèi)存的一個具有讀寫權(quán)限的 Segment 里(數(shù)據(jù)段,BSS 段)。
那么這些 ELF 格式的二進制文件中的 Section 是如何加載并映射進虛擬內(nèi)存空間的呢?
內(nèi)核中完成這個映射過程的函數(shù)是 load_elf_binary ,這個函數(shù)的作用很大,加載內(nèi)核的是它,啟動第一個用戶態(tài)進程 init 的是它,fork 完了以后,調(diào)用 exec 運行一個二進制程序的也是它。當 exec 運行一個二進制程序的時候,除了解析 ELF 的格式之外,另外一個重要的事情就是建立上述提到的內(nèi)存映射。
static int load_elf_binary(struct linux_binprm *bprm)
{
...... 省略 ........
// 設(shè)置虛擬內(nèi)存空間中的內(nèi)存映射區(qū)域起始地址 mmap_base
setup_new_exec(bprm);
...... 省略 ........
// 創(chuàng)建并初始化棧對應(yīng)的 vm_area_struct 結(jié)構(gòu)。
// 設(shè)置 mm->start_stack 就是棧的起始地址也就是棧底,并將 mm->arg_start 是指向棧底的。
retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
executable_stack);
...... 省略 ........
// 將二進制文件中的代碼部分映射到虛擬內(nèi)存空間中
error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
elf_prot, elf_flags, total_size);
...... 省略 ........
// 創(chuàng)建并初始化堆對應(yīng)的的 vm_area_struct 結(jié)構(gòu)
// 設(shè)置 current->mm->start_brk = current->mm->brk,設(shè)置堆的起始地址 start_brk,結(jié)束地址 brk。 起初兩者相等表示堆是空的
retval = set_brk(elf_bss, elf_brk, bss_prot);
...... 省略 ........
// 將進程依賴的動態(tài)鏈接庫 .so 文件映射到虛擬內(nèi)存空間中的內(nèi)存映射區(qū)域
elf_entry = load_elf_interp(&loc->interp_elf_ex,
interpreter,
&interp_map_addr,
load_bias, interp_elf_phdata);
...... 省略 ........
// 初始化內(nèi)存描述符 mm_struct
current->mm->end_code = end_code;
current->mm->start_code = start_code;
current->mm->start_data = start_data;
current->mm->end_data = end_data;
current->mm->start_stack = bprm->p;
...... 省略 ........
}
setup_new_exec 設(shè)置虛擬內(nèi)存空間中的內(nèi)存映射區(qū)域起始地址 mmap_base
setup_arg_pages 創(chuàng)建并初始化棧對應(yīng)的 vm_area_struct 結(jié)構(gòu)。置 mm->start_stack 就是棧的起始地址也就是棧底,并將 mm->arg_start 是指向棧底的。
elf_map 將 ELF 格式的二進制文件中.text ,.data,.bss 部分映射到虛擬內(nèi)存空間中的代碼段,數(shù)據(jù)段,BSS 段中。
set_brk 創(chuàng)建并初始化堆對應(yīng)的的 vm_area_struct 結(jié)構(gòu),設(shè)置
current->mm->start_brk = current->mm->brk,設(shè)置堆的起始地址 start_brk,結(jié)束地址 brk。 起初兩者相等表示堆是空的。load_elf_interp 將進程依賴的動態(tài)鏈接庫 .so 文件映射到虛擬內(nèi)存空間中的內(nèi)存映射區(qū)域
初始化內(nèi)存描述符 mm_struct
在下篇文章中,筆者會接著為大家介紹進程內(nèi)核態(tài)虛擬內(nèi)存空間的布局,歡迎收看