一步一圖帶你深入理解 Linux 虛擬記憶體管理

2022-10-25 15:02:11

寫在本文開始之前....

從本文開始我們就正式開啟了 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 位系統中的虛擬記憶體佈局大體上是差不多的。主要不同的地方有三點:

  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 方便大家有一個直觀的感受:

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)
{
      ...... 省略 ........
  // 設定虛擬記憶體空間中的記憶體對映區域起始地址 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;

     ...... 省略 ........
}
  • setup_new_exec 設定虛擬記憶體空間中的記憶體對映區域起始地址 mmap_base

  • setup_arg_pages 建立並初始化棧對應的 vm_area_struct 結構。置 mm->start_stack 就是棧的起始地址也就是棧底,並將 mm->arg_start 是指向棧底的。

  • elf_map 將 ELF 格式的二進位制檔案中.text ,.data,.bss 部分對映到虛擬記憶體空間中的程式碼段,資料段,BSS 段中。

  • set_brk 建立並初始化堆對應的的 vm_area_struct 結構,設定 current->mm->start_brk = current->mm->brk,設定堆的起始地址 start_brk,結束地址 brk。 起初兩者相等表示堆是空的。

  • load_elf_interp 將程序依賴的動態連結庫 .so 檔案對映到虛擬記憶體空間中的記憶體對映區域

  • 初始化記憶體描述符 mm_struct

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

8.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 分為兩類:

  • 一類是靜態 RAM( SRAM ),這類 SRAM 用於 CPU 快取記憶體 L1Cache,L2Cache,L3Cache。其特點是存取速度快,存取速度為 1 - 30 個時鐘週期,但是容量小,造價高。

  • 另一類則是動態 RAM ( DRAM ),這類 DRAM 用於我們常說的主記憶體上,其特點的是存取速度慢(相對快取記憶體),存取速度為 50 - 200 個時鐘週期,但是容量大,造價便宜些(相對快取記憶體)。

記憶體由一個一個的記憶體模組(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)。

匯流排上傳輸的訊號包括:地址訊號,資料訊號,控制訊號。其中控制匯流排上傳輸的控制訊號可以同步事務,並能夠標識出當前正在被執行的事務資訊:

  • 當前這個事務是到記憶體的?還是到磁碟的?或者是到其他 IO 裝置的?
  • 這個事務是讀還是寫?
  • 匯流排上傳輸的地址訊號(實體記憶體地址),還是資料訊號(資料)?。

這裡大家需要注意匯流排上傳輸的地址均為實體記憶體地址。比如:在 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 虛擬記憶體空間的整體佈局分佈。

  • 我們可以通過 cat /proc/pid/maps 或者 pmap pid 命令來檢視程序使用者態虛擬記憶體空間的實際分佈。

  • 還可以通過 cat /proc/iomem 命令來檢視程序核心態虛擬記憶體空間的的實際分佈。

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

最後筆者介紹了實體記憶體的結構,以及 CPU 如何通過實體記憶體地址來讀寫記憶體中的資料。這裡筆者需要特地再次強調的是 CPU 只會存取虛擬記憶體地址,只不過在操作匯流排之前,通過一個地址轉換硬體將虛擬記憶體地址轉換為實體記憶體地址,然後將實體記憶體地址作為地址訊號放在匯流排上傳輸,由於地址轉換的內容和本文主旨無關,考慮到文章的篇幅以及複雜性,筆者就沒有過多的介紹。

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