一步一圖帶你深入理解 Linux 虛擬內存管理

從本文開始我們就正式開啓了 Linux 內核內存管理子系統源碼解析系列,筆者還是會秉承之前系列文章的風格,採用一步一圖的方式先是詳細介紹相關原理,在保證大家清晰理解原理的基礎上,我們再來一步一步的解析相關內核源碼的實現。有了源碼的輔證,這樣大家看得也安心,理解起來也放心,最起碼可以證明筆者沒有胡編亂造騙大家,哈哈~~

內存管理子系統可謂是 Linux 內核衆多子系統中最爲複雜最爲龐大的一個,其中包含了衆多繁雜的概念和原理,通過內存管理這條主線我們把可以把操作系統的衆多核心繫統給拎出來,比如:進程管理子系統,網絡子系統,文件子系統等。

由於內存管理子系統過於複雜龐大,其中涉及到的衆多繁雜的概念又是一環套一環,層層遞進。如何把這些繁雜的概念具有層次感地,並且清晰地,給大家梳理呈現出來真是一件比較有難度的事情,因此關於這個問題,筆者在動筆寫這個內存管理源碼解析系列之前也是思考了很久。

萬事開頭難,那麼到底什麼內容適合作爲這個系列的開篇呢 ?筆者還是覺得從大家日常開發工作中接觸最多最爲熟悉的部分開始比較好,比如:在我們日常開發中創建的類,調用的函數,在函數中定義的局部變量以及 new 出來的數據容器(Map,List,Set ..... 等)都需要存儲在物理內存中的某個角落。

而我們在程序中編寫業務邏輯代碼的時候,往往需要引用這些創建出來的數據結構,並通過這些引用對相關數據結構進行業務處理。

當程序運行起來之後就變成了進程,而這些業務數據結構的引用在進程的視角里全都都是虛擬內存地址,因爲進程無論是在用戶態還是在內核態能夠看到的都是虛擬內存空間,物理內存空間被操作系統所屏蔽進程是看不到的。

進程通過虛擬內存地址訪問這些數據結構的時候,虛擬內存地址會在內存管理子系統中被轉換成物理內存地址,通過物理內存地址就可以訪問到真正存儲這些數據結構的物理內存了。隨後就可以對這塊物理內存進行各種業務操作,從而完成業務邏輯。

本文筆者就來爲大家詳細一一解答上述幾個問題,讓我們馬上開始吧~~~~

1. 到底什麼是虛擬內存地址

首先人們提出地址這個概念的目的就是用來方便定位現實世界中某一個具體事物的真實地理位置,它是一種用於定位的概念模型。

舉一個生活中的例子,比如大家在日常生活中給親朋好友郵寄一些本地特產時,都會填寫收件人地址以及寄件人地址。以及在日常網上購物時,都會在相應電商 APP 中填寫自己的收穫地址。

隨後快遞小哥就會根據我們填寫的收貨地址找到我們的真實住所,將我們網購的商品送達到我們的手裏。

收貨地址是用來定位我們在現實世界中真實住所地理位置的,而現實世界中我們所在的城市,街道,小區,房屋都是一磚一瓦,一草一木真實存在的。但收貨地址這個概念模型在現實世界中並不真實存在,它只是人們提出的一個虛擬概念,通過收貨地址這個虛擬概念將它和現實世界真實存在的城市,小區,街道的地理位置一一映射起來,這樣我們就可以通過這個虛擬概念來找到現實世界中的具體地理位置。

綜上所述,收貨地址是一個虛擬地址,它是人爲定義的,而我們的城市,小區,街道是真實存在的,他們的地理位置就是物理地址。

比如現在的廣東省深圳市在過去叫寶安縣,河北省的石家莊過去叫常山,安徽省的合肥過去叫瀘州。不管是常山也好,石家莊也好,又或是合肥也好,瀘州也罷,這些都是人爲定義的名字而已,但是地方還是那個地方,它所在的地理位置是不變的。也就說虛擬地址可以人爲的變來變去,但是物理地址永遠是不變的。

現在讓我們把視角在切換到計算機的世界,在計算機的世界裏內存地址用來定義數據在內存中的存儲位置的,內存地址也分爲虛擬地址和物理地址。而虛擬地址也是人爲設計的一個概念,類比我們現實世界中的收貨地址,而物理地址則是數據在物理內存中的真實存儲位置,類比現實世界中的城市,街道,小區的真實地理位置。

說了這麼多,那麼到底虛擬內存地址長什麼樣子呢?

我們還是以日常生活中的收貨地址爲例做出類比,我們都很熟悉收貨地址的格式:xx 省 xx 市 xx 區 xx 街道 xx 小區 xx 室,它是按照地區層次遞進的。同樣,在計算機世界中的虛擬內存地址也有這樣的遞進關係。

這裏我們以 Intel Core i7 處理器爲例,64 位虛擬地址的格式爲:全局頁目錄項(9 位)+ 上層頁目錄項(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 會執行二進制文件中的機器碼來驅動進程的運行。所以在進程運行之前,這些存放在二進制文件中的機器碼需要被加載進內存中,而用於存放這些機器碼的虛擬內存空間叫做代碼段。

在程序運行起來之後,總要操作變量吧,在程序代碼中我們通常會定義大量的全局變量和靜態變量,這些全局變量在程序編譯之後也會存儲在二進制文件中,在程序運行之前,這些全局變量也需要被加載進內存中供程序訪問。所以在虛擬內存空間中也需要一段區域來存儲這些全局變量。

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

除此之外,我們的程序在運行過程中還需要依賴動態鏈接庫,這些動態鏈接庫以 .so 文件的形式存放在磁盤中,比如 C 程序中的 glibc,裏邊對系統調用進行了封裝。glibc 庫裏提供的用於動態申請堆內存的 malloc 函數就是對系統調用 sbrk 和 mmap 的封裝。這些動態鏈接庫也有自己的對應的代碼段,數據段,BSS 段,也需要一起被加載進內存中。

還有用於內存文件映射的系統調用 mmap,會將文件與內存進行映射,那麼映射的這塊內存(虛擬內存)也需要在虛擬地址空間中有一塊區域存儲。

這些動態鏈接庫中的代碼段,數據段,BSS 段,以及通過 mmap 系統調用映射的共享內存區,在虛擬內存空間的存儲區域叫做文件映射與匿名映射區。

最後我們在程序運行的時候總該要調用各種函數吧,那麼調用函數過程中使用到的局部變量和函數參數也需要一塊內存區域來保存。這一塊區域在虛擬內存空間中叫做棧。

現在進程的虛擬內存空間所包含的主要區域,筆者就爲大家介紹完了,我們看到內核根據進程運行的過程中所需要不同種類的數據而爲其開闢了對應的地址空間。分別爲:

以上就是我們通過一個程序在運行過程中所需要的數據所規劃出的虛擬內存空間的分佈,這些只是一個大概的規劃,那麼在真實的 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 位系統中的虛擬內存佈局大體上是差不多的。主要不同的地方有三點:

  1. 就是前邊提到的由高 16 位空閒地址造成的  canonical address 空洞。在這段範圍內的虛擬內存地址是不合法的,因爲它的高 16 位既不全爲 0 也不全爲 1,不是一個 canonical address,所以稱之爲 canonical address 空洞。

  2. 在代碼段跟數據段的中間還有一段不可以讀寫的保護段,它的作用是防止程序在讀寫數據段的時候越界訪問到代碼段,這個保護段可以讓越界訪問行爲直接崩潰,防止它繼續往下運行。

  3. 用戶態虛擬內存空間與內核態虛擬內存空間分別佔用 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 方便大家有一個直觀的感受:

MwftaN

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);

    ..... 省略 .......
}

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)
{
      ...... 省略 ........
  // 設置虛擬內存空間中的內存映射區域起始地址 mmap_base
  setup_new_exec(bprm);

     ...... 省略 ........
  // 創建並初始化棧對應的 vm_area_struct 結構。
  // 設置 mm->start_stack 就是棧的起始地址也就是棧底,並將 mm->arg_start 是指向棧底的。
  retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
         executable_stack);

     ...... 省略 ........
  // 將二進制文件中的代碼部分映射到虛擬內存空間中
  error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
        elf_prot, elf_flags, total_size);

     ...... 省略 ........
 // 創建並初始化堆對應的的 vm_area_struct 結構
 // 設置 current->mm->start_brk = current->mm->brk,設置堆的起始地址 start_brk,結束地址 brk。 起初兩者相等表示堆是空的
  retval = set_brk(elf_bss, elf_brk, bss_prot);

     ...... 省略 ........
  // 將進程依賴的動態鏈接庫 .so 文件映射到虛擬內存空間中的內存映射區域
  elf_entry = load_elf_interp(&loc->interp_elf_ex,
              interpreter,
              &interp_map_addr,
              load_bias, interp_elf_phdata);

     ...... 省略 ........
  // 初始化內存描述符 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;

     ...... 省略 ........
}

7. 內核虛擬內存空間

現在我們已經知道了進程虛擬內存空間在內核中的佈局以及管理,那麼內核態的虛擬內存空間又是什麼樣子的呢?本小節筆者就帶大家來一層一層地拆開這個黑盒子。

之前在介紹進程虛擬內存空間的時候,筆者提到不同進程之間的虛擬內存空間是相互隔離的,彼此之間相互獨立,相互感知不到其他進程的存在。使得進程以爲自己擁有所有的內存資源。

而內核態虛擬內存空間是所有進程共享的,不同進程進入內核態之後看到的虛擬內存空間全部是一樣的。

什麼意思呢?比如上圖中的進程 a,進程 b,進程 c 分別在各自的用戶態虛擬內存空間中訪問虛擬地址 x 。由於進程之間的用戶態虛擬內存空間是相互隔離相互獨立的,雖然在進程 a,進程 b,進程 c 訪問的都是虛擬地址 x 但是看到的內容卻是不一樣的(背後可能映射到不同的物理內存中)。

但是當進程 a,進程 b,進程 c 進入到內核態之後情況就不一樣了,由於內核虛擬內存空間是各個進程共享的,所以它們在內核空間中看到的內容全部是一樣的,比如進程 a,進程 b,進程 c 在內核態都去訪問虛擬地址 y。這時它們看到的內容就是一樣的了。

這裏筆者和大家澄清一個經常被誤解的概念:由於內核會涉及到物理內存的管理,所以很多人會想當然地認爲只要進入了內核態就開始使用物理地址了,這就大錯特錯了,千萬不要這樣理解,進程進入內核態之後使用的仍然是虛擬內存地址,只不過在內核中使用的虛擬內存地址被限制在了內核態虛擬內存空間範圍中,這也是本小節筆者要爲大家介紹的主題。

在清楚了這個基本概念之後,下面筆者分別從 32 位體系 和 64 位體系下爲大家介紹內核態虛擬內存空間的佈局。

7.1 32 位體系內核虛擬內存空間佈局

在前邊《5.1 內核如何劃分用戶態和內核態虛擬內存空間》小節中我們提到,內核在 /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

在 32 位體系結構下進程用戶態虛擬內存空間爲 3 GB,虛擬內存地址範圍爲:0x0000 0000 - 0xC000 000 。內核態虛擬內存空間爲 1 GB,虛擬內存地址範圍爲:0xC000 000 - 0xFFFF FFFF。

本小節我們主要關注 0xC000 000 - 0xFFFF FFFF 這段虛擬內存地址區域也就是內核虛擬內存空間的佈局情況。

7.1.1 直接映射區

在總共大小 1G 的內核虛擬內存空間中,位於最前邊有一塊 896M 大小的區域,我們稱之爲直接映射區或者線性映射區,地址範圍爲 3G -- 3G + 896m 。

之所以這塊 896M 大小的區域稱爲直接映射區或者線性映射區,是因爲這塊連續的虛擬內存地址會映射到 0 - 896M 這塊連續的物理內存上。

也就是說 3G -- 3G + 896m 這塊 896M 大小的虛擬內存會直接映射到 0 - 896M 這塊 896M 大小的物理內存上,這塊區域中的虛擬內存地址直接減去 0xC000 0000 (3G) 就得到了物理內存地址。所以我們稱這塊區域爲直接映射區。

爲了方便爲大家解釋,我們假設現在機器上的物理內存爲 4G 大小

雖然這塊區域中的虛擬地址是直接映射到物理地址上,但是內核在訪問這段區域的時候還是走的虛擬內存地址,內核也會爲這塊空間建立映射頁表。關於頁表的概念筆者後續會爲大家詳細講解,這裏大家只需要簡單理解爲頁表保存了虛擬地址到物理地址的映射關係即可。

大家這裏只需要記得內核態虛擬內存空間的前 896M 區域是直接映射到物理內存中的前 896M 區域中的,直接映射區中的映射關係是一比一映射。映射關係是固定的不會改變

明白了這個關係之後,我們接下來就看一下這塊直接映射區域在物理內存中究竟存的是什麼內容~~~

在這段 896M 大小的物理內存中,前 1M 已經在系統啓動的時候被系統佔用,1M 之後的物理內存存放的是內核代碼段,數據段,BSS 段(這些信息起初存放在 ELF 格式的二進制文件中,在系統啓動的時候被加載進內存)。

我們可以通過 cat /proc/iomem 命令查看具體物理內存佈局情況。

當我們使用 fork 系統調用創建進程的時候,內核會創建一系列進程相關的描述符,比如之前提到的進程的核心數據結構 task_struct,進程的內存空間描述符 mm_struct,以及虛擬內存區域描述符 vm_area_struct 等。

這些進程相關的數據結構也會存放在物理內存前 896M 的這段區域中,當然也會被直接映射至內核態虛擬內存空間中的 3G -- 3G + 896m 這段直接映射區域中。

當進程被創建完畢之後,在內核運行的過程中,會涉及內核棧的分配,內核會爲每個進程分配一個固定大小的內核棧(一般是兩個頁大小,依賴具體的體系結構),每個進程的整個調用鏈必須放在自己的內核棧中,內核棧也是分配在直接映射區。

與進程用戶空間中的棧不同的是,內核棧容量小而且是固定的,用戶空間中的棧容量大而且可以動態擴展。內核棧的溢出危害非常巨大,它會直接悄無聲息的覆蓋相鄰內存區域中的數據,破壞數據。

通過以上內容的介紹我們瞭解到內核虛擬內存空間最前邊的這段 896M 大小的直接映射區如何與物理內存進行映射關聯,並且清楚了直接映射區主要用來存放哪些內容。

寫到這裏,筆者覺得還是有必要再次從功能劃分的角度爲大家介紹下這塊直接映射區域。

我們都知道內核對物理內存的管理都是以頁爲最小單位來管理的,每頁默認 4K 大小,理想狀況下任何種類的數據頁都可以存放在任何頁框中,沒有什麼限制。比如:存放內核數據,用戶數據,緩衝磁盤數據等。

但是實際的計算機體系結構受到硬件方面的限制制約,間接導致限制了頁框的使用方式。

比如在 X86 體系結構下,ISA 總線的 DMA (直接內存存取)控制器,只能對內存的前 16M 進行尋址,這就導致了 ISA 設備不能在整個 32 位地址空間中執行 DMA,只能使用物理內存的前 16M 進行 DMA 操作。

因此直接映射區的前 16M 專門讓內核用來爲 DMA 分配內存,這塊 16M 大小的內存區域我們稱之爲 ZONE_DMA。

用於 DMA 的內存必須從 ZONE_DMA 區域中分配。

而直接映射區中剩下的部分也就是從 16M 到 896M(不包含 896M)這段區域,我們稱之爲 ZONE_NORMAL。從字面意義上我們可以瞭解到,這塊區域包含的就是正常的頁框(使用沒有任何限制)。

ZONE_NORMAL 由於也是屬於直接映射區的一部分,對應的物理內存 16M 到 896M 這段區域也是被直接映射至內核態虛擬內存空間中的 3G + 16M 到 3G + 896M 這段虛擬內存上。

注意這裏的 ZONE_DMA 和 ZONE_NORMAL 是內核針對物理內存區域的劃分。

現在物理內存中的前 896M 的區域也就是前邊介紹的 ZONE_DMA 和 ZONE_NORMAL 區域到內核虛擬內存空間的映射筆者就爲大家介紹完了,它們都是採用直接映射的方式,一比一就行映射。

7.1.2  ZONE_HIGHMEM 高端內存

而物理內存 896M 以上的區域被內核劃分爲 ZONE_HIGHMEM 區域,我們稱之爲高端內存。

本例中我們的物理內存假設爲 4G,高端內存區域爲 4G - 896M = 3200M,那麼這塊 3200M 大小的 ZONE_HIGHMEM 區域該如何映射到內核虛擬內存空間中呢?

由於內核虛擬內存空間中的前 896M 虛擬內存已經被直接映射區所佔用,而在 32 體系結構下內核虛擬內存空間總共也就 1G 的大小,這樣一來內核剩餘可用的虛擬內存空間就變爲了 1G - 896M = 128M。

顯然物理內存中 3200M 大小的 ZONE_HIGHMEM 區域無法繼續通過直接映射的方式映射到這 128M 大小的虛擬內存空間中。

這樣一來物理內存中的 ZONE_HIGHMEM 區域就只能採用動態映射的方式映射到 128M 大小的內核虛擬內存空間中,也就是說只能動態的一部分一部分的分批映射,先映射正在使用的這部分,使用完畢解除映射,接着映射其他部分。

知道了 ZONE_HIGHMEM 區域的映射原理,我們接着往下看這 128M 大小的內核虛擬內存空間究竟是如何佈局的?

內核虛擬內存空間中的 3G + 896M 這塊地址在內核中定義爲 high_memory,high_memory 往上有一段 8M 大小的內存空洞。空洞範圍爲:high_memory 到  VMALLOC_START 。

VMALLOC_START 定義在內核源碼 /arch/x86/include/asm/pgtable_32_areas.h 文件中:

#define VMALLOC_OFFSET (8 * 1024 * 1024)

#define VMALLOC_START ((unsigned long)high_memory + VMALLOC_OFFSET)

7.1.3 vmalloc 動態映射區

接下來 VMALLOC_START 到 VMALLOC_END 之間的這塊區域成爲動態映射區。採用動態映射的方式映射物理內存中的高端內存。

#ifdef CONFIG_HIGHMEM
# define VMALLOC_END (PKMAP_BASE - 2 * PAGE_SIZE)
#else
# define VMALLOC_END (LDT_BASE_ADDR - 2 * PAGE_SIZE)
#endif

和用戶態進程使用 malloc 申請內存一樣,在這塊動態映射區內核是使用 vmalloc 進行內存分配。由於之前介紹的動態映射的原因,vmalloc 分配的內存在虛擬內存上是連續的,但是物理內存是不連續的。通過頁表來建立物理內存與虛擬內存之間的映射關係,從而可以將不連續的物理內存映射到連續的虛擬內存上。

由於 vmalloc 獲得的物理內存頁是不連續的,因此它只能將這些物理內存頁一個一個地進行映射,在性能開銷上會比直接映射大得多。

關於 vmalloc 分配內存的相關實現原理,筆者會在後面的文章中爲大家講解,這裏大家只需要明白它在哪塊虛擬內存區域中活動即可。

7.1.4 永久映射區

而在 PKMAP_BASE 到 FIXADDR_START 之間的這段空間稱爲永久映射區。在內核的這段虛擬地址空間中允許建立與物理高端內存的長期映射關係。比如內核通過 alloc_pages() 函數在物理內存的高端內存中申請獲取到的物理內存頁,這些物理內存頁可以通過調用 kmap 映射到永久映射區中。

LAST_PKMAP 表示永久映射區可以映射的頁數限制。

#define PKMAP_BASE  \
 ((LDT_BASE_ADDR - PAGE_SIZE) & PMD_MASK)

#define LAST_PKMAP 1024

7.1.5 固定映射區

內核虛擬內存空間中的下一個區域爲固定映射區,區域範圍爲:FIXADDR_START 到 FIXADDR_TOP。

FIXADDR_START 和 FIXADDR_TOP 定義在內核源碼 /arch/x86/include/asm/fixmap.h 文件中:

#define FIXADDR_START  (FIXADDR_TOP - FIXADDR_SIZE)

extern unsigned long __FIXADDR_TOP; // 0xFFFF F000
#define FIXADDR_TOP ((unsigned long)__FIXADDR_TOP)

在內核虛擬內存空間的直接映射區中,直接映射區中的虛擬內存地址與物理內存前 896M 的空間的映射關係都是預設好的,一比一映射。

在固定映射區中的虛擬內存地址可以自由映射到物理內存的高端地址上,但是與動態映射區以及永久映射區不同的是,在固定映射區中虛擬地址是固定的,而被映射的物理地址是可以改變的。也就是說,有些虛擬地址在編譯的時候就固定下來了,是在內核啓動過程中被確定的,而這些虛擬地址對應的物理地址不是固定的。採用固定虛擬地址的好處是它相當於一個指針常量(常量的值在編譯時確定),指向物理地址,如果虛擬地址不固定,則相當於一個指針變量。

那爲什麼會有固定映射這個概念呢 ?  比如:在內核的啓動過程中,有些模塊需要使用虛擬內存並映射到指定的物理地址上,而且這些模塊也沒有辦法等待完整的內存管理模塊初始化之後再進行地址映射。因此,內核固定分配了一些虛擬地址,這些地址有固定的用途,使用該地址的模塊在初始化的時候,將這些固定分配的虛擬地址映射到指定的物理地址上去。

7.1.6  臨時映射區

在內核虛擬內存空間中的最後一塊區域爲臨時映射區,那麼這塊臨時映射區是用來幹什麼的呢?

筆者在之前文章 《從 Linux 內核角度探祕 JDK NIO 文件讀寫本質》 的 “12.3 iov_iter_copy_from_user_atomic” 小節中介紹在 Buffered IO 模式下進行文件寫入的時候,在下圖中的第四步,內核會調用 iov_iter_copy_from_user_atomic 函數將用戶空間緩衝區 DirectByteBuffer 中的待寫入數據拷貝到 page cache 中。

但是內核又不能直接進行拷貝,因爲此時從 page cache 中取出的緩存頁 page 是物理地址,而在內核中是不能夠直接操作物理地址的,只能操作虛擬地址。

那怎麼辦呢?所以就需要使用 kmap_atomic 將緩存頁臨時映射到內核空間的一段虛擬地址上,這段虛擬地址就位於內核虛擬內存空間中的臨時映射區上,然後將用戶空間緩存區 DirectByteBuffer 中的待寫入數據通過這段映射的虛擬地址拷貝到 page cache 中的相應緩存頁中。這時文件的寫入操作就已經完成了。

由於是臨時映射,所以在拷貝完成之後,調用 kunmap_atomic 將這段映射再解除掉。

size_t iov_iter_copy_from_user_atomic(struct page *page,
    struct iov_iter *i, unsigned long offset, size_t bytes)
{
  // 將緩存頁臨時映射到內核虛擬地址空間的臨時映射區中
  char *kaddr = kmap_atomic(page), 
  *p = kaddr + offset;
  // 將用戶緩存區 DirectByteBuffer 中的待寫入數據拷貝到文件緩存頁中
  iterate_all_kinds(i, bytes, v,
    copyin((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len),
    memcpy_from_page((p += v.bv_len) - v.bv_len, v.bv_page,
         v.bv_offset, v.bv_len),
    memcpy((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len)
  )
  // 解除內核虛擬地址空間與緩存頁之間的臨時映射,這裏映射只是爲了臨時拷貝數據用
  kunmap_atomic(kaddr);
  return bytes;
}

7.1.7 32 位體系結構下 Linux 虛擬內存空間整體佈局

到現在爲止,整個內核虛擬內存空間在 32 位體系下的佈局,筆者就爲大家詳細介紹完畢了,我們再次結合前邊《4.1 32 位機器上進程虛擬內存空間分佈》小節中介紹的進程虛擬內存空間和本小節介紹的內核虛擬內存空間來整體回顧下 32 位體系結構 Linux 的整個虛擬內存空間的佈局:

7.2 64 位體系內核虛擬內存空間佈局

內核虛擬內存空間在 32 位體系下只有 1G 大小,實在太小了,因此需要精細化的管理,於是按照功能分類劃分除了很多內核虛擬內存區域,這樣就顯得非常複雜。

到了 64 位體系下,內核虛擬內存空間的佈局和管理就變得容易多了,因爲進程虛擬內存空間和內核虛擬內存空間各自佔用 128T 的虛擬內存,實在是太大了,我們可以在這裏邊隨意翱翔,隨意揮霍。

因此在 64 位體系下的內核虛擬內存空間與物理內存的映射就變得非常簡單,由於虛擬內存空間足夠的大,即便是內核要訪問全部的物理內存,直接映射就可以了,不在需要用到《7.1.2 ZONE_HIGHMEM 高端內存》小節中介紹的高端內存那種動態映射方式。

在前邊《5.1 內核如何劃分用戶態和內核態虛擬內存空間》小節中我們提到,內核在 /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 爲 0x00007FFFFFFFF000

在 64 位系統中,只使用了其中的低 48 位來表示虛擬內存地址。其中用戶態虛擬內存空間爲低 128 T,虛擬內存地址範圍爲:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000 。

內核態虛擬內存空間爲高 128 T,虛擬內存地址範圍爲:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 。

本小節我們主要關注 0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 這段內核虛擬內存空間的佈局情況。

64 位內核虛擬內存空間從 0xFFFF 8000 0000 0000 開始到 0xFFFF 8800 0000 0000 這段地址空間是一個 8T 大小的內存空洞區域。

緊着着 8T 大小的內存空洞下一個區域就是 64T 大小的直接映射區。這個區域中的虛擬內存地址減去 PAGE_OFFSET 就直接得到了物理內存地址。

PAGE_OFFSET 變量定義在 /arch/x86/include/asm/page_64_types.h 文件中:

#define __PAGE_OFFSET_BASE      _AC(0xffff880000000000, UL)
#define __PAGE_OFFSET           __PAGE_OFFSET_BASE

從圖中 VMALLOC_START 到 VMALLOC_END 的這段區域是 32T 大小的 vmalloc 映射區,這裏類似用戶空間中的堆,內核在這裏使用 vmalloc 系統調用申請內存。

VMALLOC_START 和  VMALLOC_END 變量定義在 /arch/x86/include/asm/pgtable_64_types.h 文件中:

#define __VMALLOC_BASE_L4 0xffffc90000000000UL

#define VMEMMAP_START  __VMEMMAP_BASE_L4

#define VMALLOC_END  (VMALLOC_START + (VMALLOC_SIZE_TB << 40) - 1)

從 VMEMMAP_START 開始是 1T 大小的虛擬內存映射區,用於存放物理頁面的描述符 struct page 結構用來表示物理內存頁。

VMEMMAP_START 變量定義在 /arch/x86/include/asm/pgtable_64_types.h 文件中:

#define __VMEMMAP_BASE_L4 0xffffea0000000000UL

# define VMEMMAP_START  __VMEMMAP_BASE_L4

從 __START_KERNEL_map 開始是大小爲 512M 的區域用於存放內核代碼段、全局變量、BSS 等。這裏對應到物理內存開始的位置,減去 __START_KERNEL_map 就能得到物理內存的地址。這裏和直接映射區有點像,但是不矛盾,因爲直接映射區之前有 8T 的空洞區域,早就過了內核代碼在物理內存中加載的位置。

__START_KERNEL_map 變量定義在 /arch/x86/include/asm/page_64_types.h 文件中:

#define __START_KERNEL_map  _AC(0xffffffff80000000, UL)

7.2.1 64 位體系結構下 Linux 虛擬內存空間整體佈局

到現在爲止,整個內核虛擬內存空間在 64 位體系下的佈局筆者就爲大家詳細介紹完畢了,我們再次結合前邊《4.2 64 位機器上進程虛擬內存空間分佈》小節介紹的進程虛擬內存空間和本小節介紹的內核虛擬內存空間來整體回顧下 64 位體系結構 Linux 的整個虛擬內存空間的佈局:

8. 到底什麼是物理內存地址

聊完了虛擬內存,我們接着聊一下物理內存,我們平時所稱的內存也叫隨機訪問存儲器( random-access memory )也叫 RAM 。而 RAM 分爲兩類:

內存由一個一個的存儲器模塊(memory module)組成,它們插在主板的擴展槽上。常見的存儲器模塊通常以 64 位爲單位( 8 個字節)傳輸數據到存儲控制器上或者從存儲控制器傳出數據。

如圖所示內存條上黑色的元器件就是存儲器模塊(memory module)。多個存儲器模塊連接到存儲控制器上,就聚合成了主存。

而 DRAM 芯片就包裝在存儲器模塊中,每個存儲器模塊中包含 8 個 DRAM 芯片,依次編號爲 0 - 7 。

而每一個 DRAM 芯片的存儲結構是一個二維矩陣,二維矩陣中存儲的元素我們稱爲超單元(supercell),每個 supercell 大小爲一個字節(8 bit)。每個 supercell 都由一個座標地址(i,j)。

i 表示二維矩陣中的行地址,在計算機中行地址稱爲 RAS (row access strobe,行訪問選通脈衝)。 j 表示二維矩陣中的列地址,在計算機中列地址稱爲 CAS (column access strobe, 列訪問選通脈衝)。

下圖中的 supercell 的 RAS = 2,CAS = 2。

DRAM 芯片中的信息通過引腳流入流出 DRAM 芯片。每個引腳攜帶 1 bit 的信號。

圖中 DRAM 芯片包含了兩個地址引腳 ( addr ),因爲我們要通過 RAS,CAS 來定位要獲取的 supercell 。還有 8 個數據引腳(data),因爲 DRAM 芯片的 IO 單位爲一個字節(8 bit),所以需要 8 個 data 引腳從 DRAM 芯片傳入傳出數據。

注意這裏只是爲了解釋地址引腳和數據引腳的概念,實際硬件中的引腳數量是不一定的。

8.1 DRAM 芯片的訪問

我們現在就以讀取上圖中座標地址爲(2,2)的 supercell 爲例,來說明訪問 DRAM 芯片的過程。

  1. 首先存儲控制器將行地址 RAS = 2 通過地址引腳發送給 DRAM 芯片。

  2. DRAM 芯片根據 RAS = 2 將二維矩陣中的第二行的全部內容拷貝到內部行緩衝區中。

  3. 接下來存儲控制器會通過地址引腳發送 CAS = 2 到 DRAM 芯片中。

  4. DRAM 芯片從內部行緩衝區中根據 CAS = 2 拷貝出第二列的 supercell 並通過數據引腳發送給存儲控制器。

DRAM 芯片的 IO 單位爲一個 supercell ,也就是一個字節 (8 bit)。

8.2 CPU 如何讀寫主存

前邊我們介紹了內存的物理結構,以及如何訪問內存中的 DRAM 芯片獲取 supercell 中存儲的數據(一個字節)。本小節我們來介紹下 CPU 是如何訪問內存的:

CPU 與內存之間的數據交互是通過總線(bus)完成的,而數據在總線上的傳送是通過一系列的步驟完成的,這些步驟稱爲總線事務(bus transaction)。

其中數據從內存傳送到 CPU 稱之爲讀事務(read transaction),數據從 CPU 傳送到內存稱之爲寫事務(write transaction)。

總線上傳輸的信號包括:地址信號,數據信號,控制信號。其中控制總線上傳輸的控制信號可以同步事務,並能夠標識出當前正在被執行的事務信息:

這裏大家需要注意總線上傳輸的地址均爲物理內存地址。比如:在 MESI 緩存一致性協議中當 CPU core0 修改字段 a 的值時,其他 CPU 核心會在總線上嗅探字段 a 的物理內存地址,如果嗅探到總線上出現字段 a 的物理內存地址,說明有人在修改字段 a,這樣其他 CPU 核心就會失效字段 a 所在的 cache line 。

如上圖所示,其中系統總線是連接 CPU 與 IO bridge 的,存儲總線是來連接 IO bridge 和主存的。

IO bridge 負責將系統總線上的電子信號轉換成存儲總線上的電子信號。IO bridge 也會將系統總線和存儲總線連接到 IO 總線(磁盤等 IO 設備)上。這裏我們看到 IO bridge 其實起的作用就是轉換不同總線上的電子信號。

8.3 CPU 從內存讀取數據過程

假設 CPU 現在需要將物理內存地址爲 A 的內容加載到寄存器中進行運算。

大家需要注意的是 CPU 只會訪問虛擬內存,在操作總線之前,需要把虛擬內存地址轉換爲物理內存地址,總線上傳輸的都是物理內存地址,這裏省略了虛擬內存地址到物理內存地址的轉換過程,這部分內容筆者會在後續文章的相關章節詳細爲大家講解,這裏我們聚焦如果通過物理內存地址讀取內存數據。

首先 CPU 芯片中的總線接口會在總線上發起讀事務(read transaction)。 該讀事務分爲以下步驟進行:

  1. CPU 將物理內存地址 A 放到系統總線上。隨後 IO bridge 將信號傳遞到存儲總線上。

  2. 主存感受到存儲總線上的地址信號並通過存儲控制器將存儲總線上的物理內存地址 A 讀取出來。

  3. 存儲控制器通過物理內存地址 A 定位到具體的存儲器模塊,從 DRAM 芯片中取出物理內存地址 A 對應的數據 X。

  4. 存儲控制器將讀取到的數據 X 放到存儲總線上,隨後 IO bridge 將存儲總線上的數據信號轉換爲系統總線上的數據信號,然後繼續沿着系統總線傳遞。

  5. CPU 芯片感受到系統總線上的數據信號,將數據從系統總線上讀取出來並拷貝到寄存器中。

以上就是 CPU 讀取內存數據到寄存器中的完整過程。

但是其中還涉及到一個重要的過程,這裏我們還是需要攤開來介紹一下,那就是存儲控制器如何通過物理內存地址 A 從主存中讀取出對應的數據 X 的?

接下來我們結合前邊介紹的內存結構以及從 DRAM 芯片讀取數據的過程,來總體介紹下如何從主存中讀取數據。

8.4 如何根據物理內存地址從主存中讀取數據

前邊介紹到,當主存中的存儲控制器感受到了存儲總線上的地址信號時,會將內存地址從存儲總線上讀取出來。

隨後會通過內存地址定位到具體的存儲器模塊。還記得內存結構中的存儲器模塊嗎 ?

而每個存儲器模塊中包含了 8 個 DRAM 芯片,編號從 0 - 7 。

存儲控制器會將物理內存地址轉換爲 DRAM 芯片中 supercell 在二維矩陣中的座標地址 (RAS,CAS)。並將這個座標地址發送給對應的存儲器模塊。隨後存儲器模塊會將 RAS 和 CAS 廣播到存儲器模塊中的所有 DRAM 芯片。依次通過 (RAS,CAS) 從 DRAM0 到 DRAM7 讀取到相應的 supercell 。

我們知道一個 supercell 存儲了一個字節( 8 bit ) 數據,這裏我們從 DRAM0 到 DRAM7 依次讀取到了 8 個 supercell 也就是 8 個字節,然後將這 8 個字節返回給存儲控制器,由存儲控制器將數據放到存儲總線上。

CPU 總是以 word size 爲單位從內存中讀取數據,在 64 位處理器中的 word size 爲 8 個字節。64 位的內存每次只能吞吐 8 個字節。

CPU 每次會向內存讀寫一個 cache line 大小的數據( 64 個字節),但是內存一次只能吞吐 8 個字節。

所以在物理內存地址對應的存儲器模塊中,DRAM0 芯片存儲第一個低位字節( supercell ),DRAM1 芯片存儲第二個字節,...... 依次類推 DRAM7 芯片存儲最後一個高位字節。

由於存儲器模塊中這種由 8 個 DRAM 芯片組成的物理存儲結構的限制,內存讀取數據只能是按照物理內存地址,8 個字節 8 個字節地順序讀取數據。所以說內存一次讀取和寫入的單位是 8 個字節。

而且在程序員眼裏連續的物理內存地址實際上在物理上是不連續的。因爲這連續的 8 個字節其實是存儲於不同的 DRAM 芯片上的。每個 DRAM 芯片存儲一個字節(supercell)

8.5 CPU 向內存寫入數據過程

我們現在假設 CPU 要將寄存器中的數據 X 寫到物理內存地址 A 中。同樣的道理,CPU 芯片中的總線接口會向總線發起寫事務(write transaction)。寫事務步驟如下:

  1. CPU 將要寫入的物理內存地址 A 放入系統總線上。

  2. 通過 IO bridge 的信號轉換,將物理內存地址 A 傳遞到存儲總線上。

  3. 存儲控制器感受到存儲總線上的地址信號,將物理內存地址 A 從存儲總線上讀取出來,並等待數據的到達。

  4. CPU 將寄存器中的數據拷貝到系統總線上,通過 IO bridge 的信號轉換,將數據傳遞到存儲總線上。

  5. 存儲控制器感受到存儲總線上的數據信號,將數據從存儲總線上讀取出來。

  6. 存儲控制器通過內存地址 A 定位到具體的存儲器模塊,最後將數據寫入存儲器模塊中的 8 個 DRAM 芯片中。

總結

本文我們從虛擬內存地址開始聊起,一直到物理內存地址結束,包含的信息量還是比較大的。首先筆者通過一個進程的運行實例爲大家引出了內核引入虛擬內存空間的目的及其需要解決的問題。

在我們有了虛擬內存空間的概念之後,筆者又近一步爲大家介紹了內核如何劃分用戶態虛擬內存空間和內核態虛擬內存空間,並在次基礎之上分別從 32 位體系結構和 64 位體系結構的角度詳細闡述了 Linux 虛擬內存空間的整體佈局分佈。

在我們清楚了  Linux 虛擬內存空間的整體佈局分佈之後,筆者又介紹了 Linux 內核如何對分佈在虛擬內存空間中的各個虛擬內存區域進行管理,以及每個虛擬內存區域的作用。在這個過程中還介紹了相關的內核數據結構,近一步從內核源碼實現角度加深大家對虛擬內存空間的理解。

最後筆者介紹了物理內存的結構,以及 CPU 如何通過物理內存地址來讀寫內存中的數據。這裏筆者需要特地再次強調的是 CPU 只會訪問虛擬內存地址,只不過在操作總線之前,通過一個地址轉換硬件將虛擬內存地址轉換爲物理內存地址,然後將物理內存地址作爲地址信號放在總線上傳輸,由於地址轉換的內容和本文主旨無關,考慮到文章的篇幅以及複雜性,筆者就沒有過多的介紹。

好了,本文的全部內容到這裏就結束了,感謝大家的收看,我們下篇文章見~~~

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/uWadcBxEgctnrgyu32T8sQ