本文基於內核 5.4 版本源碼討論 之前有不少讀者給筆者留言,希望筆者寫一篇文章介紹下 mmap 記憶體映射相關的知識體系,之所以遲遲沒有動筆,是因為 mmap 這個系統調用看上去簡單,實際上並不簡單,可以說是非常複雜的一個系統調用。 如果想要給大家把 mmap 背後的技術本質,正確地,清晰地還原出來 ...
本文基於內核 5.4 版本源碼討論
之前有不少讀者給筆者留言,希望筆者寫一篇文章介紹下 mmap 記憶體映射相關的知識體系,之所以遲遲沒有動筆,是因為 mmap 這個系統調用看上去簡單,實際上並不簡單,可以說是非常複雜的一個系統調用。
如果想要給大家把 mmap 背後的技術本質,正確地,清晰地還原出來,還是有一定難度的,因為 mmap 這一個系統調用就能撬動起整個記憶體管理系統,文件系統,頁表體系,缺頁中斷等一大片的背景知識,涉及到的知識面廣且繁雜。
幸運的是這一整套的背景知識,筆者已經在 《聊聊 Linux 內核》 系列文章中為大家詳細介紹過了,所以現在是時候開始動筆了,不過大家不需要擔心,雖然涉及到的背景知識比較多,但是在後面的相關章節里,筆者還會為大家重新交代。
在上一篇文章 《一步一圖帶你構建 Linux 頁表體系》 中,筆者為大家介紹了記憶體映射最為核心的內容 —— 頁表體系。通過一步一圖的方式為大家展示了整個頁表體系的演進過程,併在這個過程中逐步揭開了整個頁表體系的全貌。
本文的內容依然是記憶體映射相關的內容,這一次筆者會帶著大家圍繞頁表這個最為核心的體系,在頁表的外圍進行記憶體映射相關知識的介紹,核心目的就是徹底為大家還原記憶體映射背後的技術本質,由淺入深地給大家講透徹,弄明白。
在我們正式開始今天的內容之前,筆者想首先拋出幾個問題給大家思考,建議大家帶著這幾個問題來閱讀接下來的內容,我們共同來將這些迷霧一層一層地慢慢撥開,直到還原出記憶體映射的本質。
-
既然我們是在討論虛擬記憶體與物理記憶體的映射,那麼首先你得有虛擬記憶體,你也得有物理記憶體吧,在這個基礎之上,才能討論兩者之間的映射,而物理記憶體是怎麼來的,筆者已經通過前邊文章 《深入理解 Linux 物理記憶體分配全鏈路實現》 介紹的非常清楚了,那虛擬記憶體是怎麼來的呢 ?內核分配虛擬記憶體的過程是怎樣的呢?
-
我們知道記憶體映射是按照物理記憶體頁為單位進行的,而在記憶體管理中,記憶體頁主要分為兩種:一種是匿名頁,另一種是文件頁,這一點筆者已經在 《一步一圖帶你深入理解 Linux 物理記憶體管理》 一文中反覆講過很多次了。根據物理記憶體頁的類型分類,記憶體映射自然也分為兩種:一種是虛擬記憶體對匿名物理記憶體頁的映射,另一種是虛擬記憶體對文件頁的映射。關於文件映射,大家或多或少在網上看到過這樣的論述——" 通過記憶體文件映射可以將磁碟上的文件映射到記憶體中,這樣我們就可以通過讀寫記憶體來完成磁碟文件的讀寫 "。關於這個論述,如果對記憶體管理和文件系統不熟悉的同學,可能感到這句話非常的神奇,會有這樣的一個疑問,記憶體就是記憶體啊,磁碟上的文件就是文件啊,這是兩個完全不同的東西,為什麼說讀寫記憶體就相當於讀寫磁碟上的文件呢 ?記憶體文件映射在內核中到底發生了什麼 ?我們經常談到的記憶體映射,到底映射的是什麼?
-
在上篇文章中筆者只是為大家展示了整個頁表體系的全貌,以及頁表體系一步一步的演進過程,但是在進程被創建出來之後,內核也僅是會為進程分配一張全局頁目錄表 PGD(Page Global Directory)而已,此時進程虛擬記憶體空間中只存在一張頂級頁目錄表,而在上圖中所展示的四級頁表體系中的上層頁目錄 PUD(Page Upper Directory),中間頁目錄 PMD(Page Middle Directory)以及一級頁表是不存在的,那麼上圖展示的這個頁表完整體系是在什麼時候,又是如何被一步一步構建出來的呢?
本文的主旨就是圍繞上述這幾個問題來展開的,那麼從何談起呢 ?筆者想了一下,還是應該從我們最為熟悉的,在用戶態經常接觸到的記憶體映射系統調用 mmap 開始聊起~~~
1. 詳解記憶體映射系統調用 mmap
#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
// 內核文件:/arch/x86/kernel/sys_x86_64.c
SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len,
unsigned long, prot, unsigned long, flags,
unsigned long, fd, unsigned long, off)
mmap 記憶體映射里所謂的記憶體其實指的是虛擬記憶體,在調用 mmap 進行匿名映射的時候(比如進行堆記憶體的分配),是將進程虛擬記憶體空間中的某一段虛擬記憶體區域與物理記憶體中的匿名記憶體頁進行映射,當調用 mmap 進行文件映射的時候,是將進程虛擬記憶體空間中的某一段虛擬記憶體區域與磁碟中某個文件中的某段區域進行映射。
而用於記憶體映射所消耗的這些虛擬記憶體位於進程虛擬記憶體空間的哪裡呢 ?
筆者在之前的文章《一步一圖帶你深入理解 Linux 虛擬記憶體管理》 中曾為大家詳細介紹過進程虛擬記憶體空間的佈局,在進程虛擬記憶體空間的佈局中,有一段叫做文件映射與匿名映射區的虛擬記憶體區域,當我們在用戶態應用程式中調用 mmap 進行記憶體映射的時候,所需要的虛擬記憶體就是在這個區域中劃分出來的。
在文件映射與匿名映射這段虛擬記憶體區域中,包含了一段一段的虛擬映射區,每當我們調用一次 mmap 進行記憶體映射的時候,內核都會在文件映射與匿名映射區中劃分出一段虛擬映射區出來,這段虛擬映射區就是我們申請到的虛擬記憶體。
那麼我們申請的這塊虛擬記憶體到底有多大呢 ?這就用到了 mmap 系統調用的前兩個參數:
-
addr : 表示我們要映射的這段虛擬記憶體區域在進程虛擬記憶體空間中的起始地址(虛擬記憶體地址),但是這個參數只是給內核的一個暗示,內核並非一定得從我們指定的 addr 虛擬記憶體地址上劃分虛擬記憶體區域,內核只不過在劃分虛擬記憶體區域的時候會優先考慮我們指定的 addr,如果這個虛擬地址已經被使用或者是一個無效的地址,那麼內核則會自動選取一個合適的地址來劃分虛擬記憶體區域。我們一般會將 addr 設置為 NULL,意思就是完全交由內核來幫我們決定虛擬映射區的起始地址。
-
length :從進程虛擬記憶體空間中的什麼位置開始劃分虛擬記憶體區域的問題解決了,那麼我們要申請的這段虛擬記憶體有多大呢 ? 這個就是 length 參數的作用了,如果是匿名映射,length 參數決定了我們要映射的匿名物理記憶體有多大,如果是文件映射,length 參數決定了我們要映射的文件區域有多大。
addr,length 必須要按照 PAGE_SIZE(4K) 對齊。
如果我們通過 mmap 映射的是磁碟上的一個文件,那麼就需要通過參數 fd 來指定要映射文件的描述符(file descriptor),通過參數 offset 來指定文件映射區域在文件中偏移。
在記憶體管理系統中,物理記憶體是按照記憶體頁為單位組織的,在文件系統中,磁碟中的文件是按照磁碟塊為單位組織的,記憶體頁和磁碟塊大小一般情況下都是 4K 大小,所以這裡的 offset 也必須是按照 4K 對齊的。
而在文件映射與匿名映射區中的這一段一段的虛擬映射區,其實本質上也是虛擬記憶體區域,它們和進程虛擬記憶體空間中的代碼段,數據段,BSS 段,堆,棧沒有任何區別,在內核中都是 struct vm_area_struct 結構來表示的,下麵我們把進程空間中的這些虛擬記憶體區域統稱為 VMA。
進程虛擬記憶體空間中的所有 VMA 在內核中有兩種組織形式:一種是雙向鏈表,用於高效的遍歷進程 VMA,這個 VMA 雙向鏈表是有順序的,所有 VMA 節點在雙向鏈表中的排列順序是按照虛擬記憶體低地址到高地址進行的。
另一種則是用紅黑樹進行組織,用於在進程空間中高效的查找 VMA,因為在進程虛擬記憶體空間中不僅僅是只有代碼段,數據段,BSS 段,堆,棧這些虛擬記憶體區域 VMA,尤其是在數據密集型應用進程中,文件映射與匿名映射區里也會包含有大量的 VMA,進程的各種動態鏈接庫所映射的虛擬記憶體在這裡,進程運行過程中進行的匿名映射,文件映射所需要的虛擬記憶體也在這裡。而內核需要頻繁地對進程虛擬記憶體空間中的這些眾多 VMA 進行增,刪,改,查。所以需要這麼一個紅黑樹結構,方便內核進行高效的查找。
// 進程虛擬記憶體空間描述符
struct mm_struct {
// 串聯組織進程空間中所有的 VMA 的雙向鏈表
struct vm_area_struct *mmap; /* list of VMAs */
// 管理進程空間中所有 VMA 的紅黑樹
struct rb_root mm_rb;
}
// 虛擬記憶體區域描述符
struct vm_area_struct {
// vma 在 mm_struct->mmap 雙向鏈表中的前驅節點和後繼節點
struct vm_area_struct *vm_next, *vm_prev;
// vma 在 mm_struct->mm_rb 紅黑樹中的節點
struct rb_node vm_rb;
}
上圖中的文件映射與匿名映射區裡邊其實包含了大量的 VMA,這裡只是為了清晰的給大家展示虛擬記憶體在內核中的組織結構,所以只畫了一個大的 VMA 來表示文件映射與匿名映射區,這一點大家需要知道。
mmap 系統調用的本質是首先要在進程虛擬記憶體空間里的文件映射與匿名映射區中劃分出一段虛擬記憶體區域 VMA 出來 ,這段 VMA 區域的大小用 vm_start,vm_end 來表示,它們由 mmap 系統調用參數 addr,length 決定。
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 */
}
隨後內核會對這段 VMA 進行相關的映射,如果是文件映射的話,內核會將我們要映射的文件,以及要映射的文件區域在文件中的 offset,與 VMA 結構中的 vm_file,vm_pgoff 關聯映射起來,它們由 mmap 系統調用參數 fd,offset 決定。
struct vm_area_struct {
struct file * vm_file; /* File we map to (can be NULL). */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE */
}
另外由 mmap 在文件映射與匿名映射區中映射出來的這一段虛擬記憶體區域同進程虛擬記憶體空間中的其他虛擬記憶體區域一樣,也都是有許可權控制的。
比如上圖進程虛擬記憶體空間中的代碼段,它是與磁碟上 ELF 格式可執行文件中的 .text section(磁碟文件中各個區域的單元組織結構)進行映射的,存放的是程式執行的機器碼,所以在可執行文件與進程虛擬記憶體空間進行文件映射的時候,需要指定代碼段這個虛擬記憶體區域的許可權為可讀(VM_READ),可執行的(VM_EXEC)。
數據段也是通過文件映射進來的,內核會將磁碟上 ELF 格式可執行文件中的 .data section 與數據段映射起來,在映射的時候需要指定數據段這個虛擬記憶體區域的許可權為可讀(VM_READ),可寫(VM_WRITE)。
與代碼段和數據段不同的是,BSS段,堆,棧這些虛擬記憶體區域並不是從磁碟二進位可執行文件中載入的,它們是通過匿名映射的方式映射到進程虛擬記憶體空間的。
BSS 段中存放的是程式未初始化的全局變數,這段虛擬記憶體區域的許可權是可讀(VM_READ),可寫(VM_WRITE)。
堆是用來描述進程在運行期間動態申請的虛擬記憶體區域的,所以堆也會具有可讀(VM_READ),可寫(VM_WRITE)許可權,在有些情況下,堆也具有可執行(VM_EXEC)的許可權,比如 Java 中的位元組碼存儲在堆中,所以需要可執行許可權。
棧是用來保存進程運行時的命令行參,環境變數,以及函數調用過程中產生的棧幀的,棧一般擁有可讀(VM_READ),可寫(VM_WRITE)的許可權,但是也可以設置可執行(VM_EXEC)許可權,不過出於安全的考慮,很少這麼設置。
而在文件映射與匿名映射區中的情況就變得更加複雜了,因為文件映射與匿名映射區里包含了數量眾多的 VMA,尤其是在數據密集型應用進程里更是如此,我們每調用一次 mmap ,無論是匿名映射也好還是文件映射也好,都會在文件映射與匿名映射區里產生一個 VMA,而通過 mmap 映射出的這段 VMA 中的相關許可權和標誌位,是由 mmap 系統調用參數里的 prot,flags 決定的,最終會映射到虛擬記憶體區域 VMA 結構中的 vm_page_prot,vm_flags 屬性中,指定進程對這塊虛擬記憶體區域的訪問許可權和相關標誌位。
除此之外,進程運行過程中所依賴的動態鏈接庫 .so 文件,也是通過文件映射的方式將動態鏈接庫中的代碼段,數據段映射進文件映射與匿名映射區中。
struct vm_area_struct {
/*
* Access permissions of this VMA.
*/
pgprot_t vm_page_prot;
unsigned long vm_flags;
}
我們可以通過 mmap 系統調用中的參數 prot 來指定其在進程虛擬記憶體空間中映射出的這段虛擬記憶體區域 VMA 的訪問許可權,它的取值有如下四種:
#define PROT_READ 0x1 /* page can be read */
#define PROT_WRITE 0x2 /* page can be written */
#define PROT_EXEC 0x4 /* page can be executed */
#define PROT_NONE 0x0 /* page can not be accessed */
-
PROT_READ 表示該虛擬記憶體區域背後映射的物理記憶體是可讀的。
-
PROT_WRITE 表示該虛擬記憶體區域背後映射的物理記憶體是可寫的。
-
PROT_EXEC 表示該虛擬記憶體區域背後映射的物理記憶體所存儲的內容是可以被執行的,該記憶體區域內往往存儲的是執行程式的機器碼,比如進程虛擬記憶體空間中的代碼段,以及動態鏈接庫通過文件映射的方式載入進文件映射與匿名映射區里的代碼段,這些 VMA 的許可權就是 PROT_EXEC 。
-
PROT_NONE 表示這段虛擬記憶體區域是不能被訪問的,既不可讀寫,也不可執行。用於實現防範攻擊的 guard page。如果攻擊者訪問了某個 guard page,就會觸發 SIGSEV 段錯誤。除此之外,指定 PROT_NONE 還可以為進程預先保留這部分虛擬記憶體區域,雖然不能被訪問,但是當後面進程需要的時候,可以通過 mprotect 系統調用修改這部分虛擬記憶體區域的許可權。
mprotect 系統調用可以動態修改進程虛擬記憶體空間中任意一段虛擬記憶體區域的許可權。
我們除了要為 mmap 映射出的這段虛擬記憶體區域 VMA 指定訪問許可權之外,還需要為這段映射區域 VMA 指定映射方式,VMA 的映射方式由 mmap 系統調用參數 flags 決定。內核為 flags 定義了數量眾多的枚舉值,下麵筆者將一些非常重要且核心的枚舉值為大家挑選出來並解釋下它們的含義:
#define MAP_FIXED 0x10 /* Interpret addr exactly */
#define MAP_ANONYMOUS 0x20 /* don't use a file */
#define MAP_SHARED 0x01 /* Share changes */
#define MAP_PRIVATE 0x02 /* Changes are private */
前邊我們介紹了 mmap 系統調用的 addr 參數,這個參數只是我們給內核的一個暗示並非是強制性的,表示我們希望內核可以根據我們指定的虛擬記憶體地址 addr 處開始創建虛擬記憶體映射區域 VMA。
但如果我們指定的 addr 是一個非法地址,比如 [addr , addr + length]
這段虛擬記憶體地址已經存在映射關係了,那麼內核就會自動幫我們選取一個合適的虛擬記憶體地址開始映射,但是當我們在 mmap 系統調用的參數 flags 中指定了 MAP_FIXED
, 這時參數 addr 就變成強制要求了,如果 [addr , addr + length]
這段虛擬記憶體地址已經存在映射關係了,那麼內核就會將這段映射關係 unmmap 解除掉映射,然後重新根據我們的要求進行映射,如果 addr 是一個非法地址,內核就會報錯停止映射。
操作系統對於物理記憶體的管理是按照記憶體頁為單位進行的,而記憶體頁的類型有兩種:一種是匿名頁,另一種是文件頁。根據記憶體頁類型的不同,記憶體映射也自然分為兩種:一種是虛擬記憶體對匿名物理記憶體頁的映射,另一種是虛擬記憶體對文件頁的也映射,也就是我們常提到的匿名映射和文件映射。
當我們將 mmap 系統調用參數 flags 指定為 MAP_ANONYMOUS
時,表示我們需要進行匿名映射,既然是匿名映射,fd 和 offset 這兩個參數也就沒有了意義,fd 參數需要被設置為 -1 。當我們進行文件映射的時候,只需要指定 fd 和 offset 參數就可以了。
而根據 mmap 創建出的這片虛擬記憶體區域背後所映射的物理記憶體能否在多進程之間共用,又分為了兩種記憶體映射方式:
-
MAP_SHARED
表示共用映射,通過 mmap 映射出的這片記憶體區域在多進程之間是共用的,一個進程修改了共用映射的記憶體區域,其他進程是可以看到的,用於多進程之間的通信。 -
MAP_PRIVATE
表示私有映射,通過 mmap 映射出的這片記憶體區域是進程私有的,其他進程是看不到的。如果是私有文件映射,那麼多進程針對同一映射文件的修改將不會回寫到磁碟文件上
這裡介紹的這些 flags 參數枚舉值是可以相互組合的,我們可以通過這些枚舉值組合出如下幾種記憶體映射方式。
2. 私有匿名映射
MAP_PRIVATE | MAP_ANONYMOUS
表示私有匿名映射,我們常常利用這種映射方式來申請虛擬記憶體,比如,我們使用 glibc 庫里封裝的 malloc 函數進行虛擬記憶體申請時,當申請的記憶體大於 128K 的時候,malloc 就會調用 mmap 採用私有匿名映射的方式來申請堆記憶體。因為它是私有的,所以申請到的記憶體是進程獨占的,多進程之間不能共用。
這裡需要特別強調一下 mmap 私有匿名映射申請到的只是虛擬記憶體,內核只是在進程虛擬記憶體空間中劃分一段虛擬記憶體區域 VMA 出來,並將 VMA 該初始化的屬性初始化好,mmap 系統調用就結束了。這裡和物理記憶體還沒有發生任何關係。在後面的章節中大家將會看到這個過程。
當進程開始訪問這段虛擬記憶體區域時,發現這段虛擬記憶體區域背後沒有任何物理記憶體與其關聯,體現在內核中就是這段虛擬記憶體地址在頁表中的 PTE 項是空的。
或者 PTE 中的 P 位為 0 ,這些都是表示虛擬記憶體還未與物理記憶體進行映射。
關於頁表相關的知識,不熟悉的讀者可以回顧下筆者之前的文章 《一步一圖帶你構建 Linux 頁表體系》
這時 MMU 就會觸發缺頁異常(page fault),這裡的缺頁指的就是缺少物理記憶體頁,隨後進程就會切換到內核態,在內核缺頁中斷處理程式中,為這段虛擬記憶體區域分配對應大小的物理記憶體頁,隨後將物理記憶體頁中的內容全部初始化為 0 ,最後在頁表中建立虛擬記憶體與物理記憶體的映射關係,缺頁異常處理結束。
當缺頁處理程式返回時,CPU 會重新啟動引起本次缺頁異常的訪存指令,這時 MMU 就可以正常翻譯出物理記憶體地址了。
mmap 的私有匿名映射除了用於為進程申請虛擬記憶體之外,還會應用在 execve 系統調用中,execve 用於在當前進程中載入並執行一個新的二進位執行文件:
#include <unistd.h>
int execve(const char* filename, const char* argv[], const char* envp[])
參數 filename 指定新的可執行文件的文件名,argv 用於傳遞新程式的命令行參數,envp 用來傳遞環境變數。
既然是在當前進程中重新執行一個程式,那麼當前進程的用戶態虛擬記憶體空間就沒有用了,內核需要根據這個可執行文件重新映射進程的虛擬記憶體空間。
既然現在要重新映射進程虛擬記憶體空間,內核首先要做的就是刪除釋放舊的虛擬記憶體空間,並清空進程頁表。然後根據 filename 打開可執行文件,並解析文件頭,判斷可執行文件的格式,不同的文件格式需要不同的函數進行載入。
linux 中支持多種可執行文件格式,比如,elf 格式,a.out 格式。內核中使用 struct linux_binfmt 結構來描述可執行文件,裡邊定義了用於載入可執行文件的函數指針 load_binary,載入動態鏈接庫的函數指針 load_shlib,不同文件格式指向不同的載入函數:
static struct linux_binfmt elf_format = {
.module = THIS_MODULE,
.load_binary = load_elf_binary,
.load_shlib = load_elf_library,
.core_dump = elf_core_dump,
.min_coredump = ELF_EXEC_PAGESIZE,
};
static struct linux_binfmt aout_format = {
.module = THIS_MODULE,
.load_binary = load_aout_binary,
.load_shlib = load_aout_library,
};
在 load_binary 中會解析對應格式的可執行文件,並根據文件內容重新映射進程的虛擬記憶體空間。比如,虛擬記憶體空間中的 BSS 段,堆,棧這些記憶體區域中的內容不依賴於可執行文件,所以在 load_binary 中採用私有匿名映射的方式來創建新的虛擬記憶體空間中的 BSS 段,堆,棧。
BSS 段雖然定義在可執行二進位文件中,不過只是在文件中記錄了 BSS 段的長度,並沒有相關內容關聯,所以 BSS 段也會採用私有匿名映射的方式載入到進程虛擬記憶體空間中。
3. 私有文件映射
#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
我們在調用 mmap 進行記憶體文件映射的時候可以通過指定參數 flags 為 MAP_PRIVATE
,然後將參數 fd 指定為要映射文件的文件描述符(file descriptor)來實現對文件的私有映射。
假設現在磁碟上有一個名叫 file-read-write.txt 的磁碟文件,現在多個進程採用私有文件映射的方式,從文件 offset 偏移處開始,映射 length 長度的文件內容到各個進程的虛擬記憶體空間中,調用完 mmap 之後,相關記憶體映射內核數據結構關係如下圖所示:
為了方便描述,我們指定映射長度 length 為 4K 大小,因為文件系統中的磁碟塊大小為 4K ,映射到記憶體中的記憶體頁剛好也是 4K 。
當進程打開一個文件的時候,內核會為其創建一個 struct file 結構來描述被打開的文件,併在進程文件描述符列表 fd_array 數組中找到一個空閑位置分配給它,數組中對應的下標,就是我們在用戶空間用到的文件描述符。
而 struct file 結構是和進程相關的( fd 的作用域也是和進程相關的),即使多個進程打開同一個文件,那麼內核會為每一個進程創建一個 struct file 結構,如上圖中所示,進程 1 和 進程 2 都打開了同一個 file-read-write.txt 文件,那麼內核會為進程 1 創建一個 struct file 結構,也會為進程 2 創建一個 struct file 結構。
每一個磁碟上的文件在內核中都會有一個唯一的 struct inode 結構,inode 結構和進程是沒有關係的,一個文件在內核中只對應一個 inode,inode 結構用於描述文件的元信息,比如,文件的許可權,文件中包含多少個磁碟塊,每個磁碟塊位於磁碟中的什麼位置等等。
// ext4 文件系統中的 inode 結構
struct ext4_inode {
// 文件許可權
__le16 i_mode; /* File mode */
// 文件包含磁碟塊的個數
__le32 i_blocks_lo; /* Blocks count */
// 存放文件包含的磁碟塊
__le32 i_block[EXT4_N_BLOCKS];/* Pointers to blocks */
};
那麼什麼是磁碟塊呢 ?我們可以類比記憶體管理系統,Linux 是按照記憶體頁為單位來對物理記憶體進行管理和調度的,在文件系統中,Linux 是按照磁碟塊為單位對磁碟中的數據進行管理的,它們的大小均是 4K 。
如下圖所示,磁碟盤面上一圈一圈的同心圓叫做磁軌,磁碟上存儲的數據就是沿著磁軌的軌跡存放著,隨著磁碟的旋轉,磁頭在磁軌上讀寫硬碟中的數據。而在每個磁碟上,會進一步被劃分成多個大小相等的圓弧,這個圓弧就叫做扇區,磁碟會以扇區為單位進行數據的讀寫。每個扇區大小為 512 位元組。
而在 Linux 的文件系統中是按照磁碟塊為單位對數據讀寫的,因為每個扇區大小為 512 位元組,能夠存儲的數據比較小,而且扇區數量眾多,這樣在定址的時候比較困難,Linux 文件系統將相鄰的扇區組合在一起,形成一個磁碟塊,後續針對磁碟塊整體進行操作效率更高。
只要我們找到了文件中的磁碟塊,我們就可以定址到文件在磁碟上的存儲內容了,所以使用 mmap 進行記憶體文件映射的本質就是建立起虛擬記憶體區域 VMA 到文件磁碟塊之間的映射關係 。
調用 mmap 進行記憶體文件映射的時候,內核首先會在進程的虛擬記憶體空間中創建一個新的虛擬記憶體區域 VMA 用於映射文件,通過 vm_area_struct->vm_file 將映射文件的 struct flle 結構與虛擬記憶體映射關聯起來。
struct vm_area_struct {
struct file * vm_file; /* File we map to (can be NULL). */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE */
}
根據 vm_file->f_inode 我們可以關聯到映射文件的 struct inode,近而關聯到映射文件在磁碟中的磁碟塊 i_block,這個就是 mmap 記憶體文件映射最本質的東西。
站在文件系統的視角,映射文件中的數據是按照磁碟塊來存儲的,讀寫文件數據也是按照磁碟塊為單位進行的,磁碟塊大小為 4K,當進程讀取磁碟塊的內容到記憶體之後,站在記憶體管理系統的視角,磁碟塊中的數據被 DMA 拷貝到了物理記憶體頁中,這個物理記憶體頁就是前面提到的文件頁。
根據程式的時間局部性原理我們知道,磁碟文件中的數據一旦被訪問,那麼它很有可能在短期內被再次訪問,所以為了加快進程對文件數據的訪問,內核會將已經訪問過的磁碟塊緩存在文件頁中。
一個文件包含多個磁碟塊,當它們被讀取到記憶體之後,一個文件也就對應了多個文件頁,這些文件頁在記憶體中統一被一個叫做 page cache 的結構所組織。
每一個文件在內核中都會有一個唯一的 page cache 與之對應,用於緩存文件中的數據,page cache 是和文件相關的,它和進程是沒有關係的,多個進程可以打開同一個文件,每個進程中都有有一個 struct file 結構來描述這個文件,但是一個文件在內核中只會對應一個 page cache。
文件的 struct inode 結構中除了有磁碟塊的信息之外,還有指向文件 page cache 的 i_mapping 指針。
struct inode {
struct address_space *i_mapping;
}
page cache 在內核中是使用 struct address_space 結構來描述的:
struct address_space {
// 這裡就是 page cache。裡邊緩存了文件的所有緩存頁面
struct radix_tree_root page_tree;
}
關於 page cache 的詳細介紹,感興趣的讀者可以回看下 《從 Linux 內核角度探秘 JDK NIO 文件讀寫本質》 一文中的 “5. 頁高速緩存 page cache” 小節。
當我們理清了記憶體系統和文件系統這些核心數據結構之間的關聯關係之後,現在再來看,下麵這幅 mmap 私有文件映射關係圖是不是清晰多了。
page cache 在內核中是使用基樹 radix_tree 結構來表示的,這裡我們只需要知道文件頁是掛在 radix_tree 的葉子結點上,radix_tree 中的 root 節點和 node 節點是文件頁(葉子節點)的索引節點就可以了。
當多個進程調用 mmap 對磁碟上同一個文件進行私有文件映射的時候,內核只是在每個進程的虛擬記憶體空間中創建出一段虛擬記憶體區域 VMA 出來,註意,此時內核只是為進程申請了用於映射的虛擬記憶體,並將虛擬記憶體與文件映射起來,mmap 系統調用就返回了,全程並沒有物理記憶體的影子出現。文件的 page cache 也是空的,沒有包含任何的文件頁。
當任意一個進程,比如上圖中的進程 1 開始訪問這段映射的虛擬記憶體時,CPU 會把虛擬記憶體地址送到 MMU 中進行地址翻譯,因為 mmap 只是為進程分配了虛擬記憶體,並沒有分配物理記憶體,所以這段映射的虛擬記憶體在頁表中是沒有頁表項 PTE 的。
隨後 MMU 就會觸發缺頁異常(page fault),進程切換到內核態,在內核缺頁中斷處理程式中會發現引起缺頁的這段 VMA 是私有文件映射的,所以內核會首先通過 vm_area_struct->vm_pgoff 在文件 page cache 中查找是否有緩存相應的文件頁(映射的磁碟塊對應的文件頁)。
struct vm_area_struct {
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE */
}
static inline struct page *find_get_page(struct address_space *mapping,
pgoff_t offset)
{
return pagecache_get_page(mapping, offset, 0, 0);
}
如果文件頁不在 page cache 中,內核則會在物理記憶體中分配一個記憶體頁,然後將新分配的記憶體頁加入到 page cache 中,並增加頁引用計數。
隨後會通過 address_space_operations 重定義的 readpage 激活塊設備驅動從磁碟中讀取映射的文件內容,然後將讀取到的內容填充新分配的記憶體頁。
static const struct address_space_operations ext4_aops = {
.readpage = ext4_readpage
}
現在文件中映射的內容已經載入進 page cache 了,此時物理記憶體才正式登場,在缺頁中斷處理程式的最後一步,內核會為映射的這段虛擬記憶體在頁表中創建 PTE,然後將虛擬記憶體與 page cache 中的文件頁通過 PTE 關聯起來,缺頁處理就結束了,但是由於我們指定的私有文件映射,所以 PTE 中文件頁的許可權是只讀的。
當內核處理完缺頁中斷之後,mmap 私有文件映射在內核中的關係圖就變成下麵這樣:
此時進程 1 中的頁表已經建立起了虛擬記憶體與文件頁的映射關係,進程 1 再次訪問這段虛擬記憶體的時候,其實就等於直接訪問文件的 page cache。整個過程是在用戶態進行的,不需要切態。
現在我們在將視角切換到進程 2 中,進程 2 和進程 1 一樣,都是採用 mmap 私有文件映射的方式映射到了同一個文件中,雖然現在已經有了物理記憶體了(通過進程 1 的缺頁產生),但是目前還和進程 2 沒有關係。
因為進程 2 的虛擬記憶體空間中這段映射的虛擬記憶體區域 VMA,在進程 2 的頁表中還沒有 PTE,所以當進程 2 訪問這段映射虛擬記憶體時,同樣會產生缺頁中斷,隨後進程 2 切換到內核態,進行缺頁處理,這裡和進程 1 不同的是,此時被映射的文件內容已經載入到 page cache 中了,進程 2 只需要創建 PTE ,並將 page cache 中的文件頁與進程 2 映射的這段虛擬記憶體通過 PTE 關聯起來就可以了。同樣,因為採用私有文件映射的原因,進程 2 的 PTE 也是只讀的。
現在進程 1 和進程 2 都可以根據各自虛擬記憶體空間中映射的這段虛擬記憶體對文件的 page cache 進行讀取了,整個過程都發生在用戶態,不需要切態,更不需要拷貝,因為虛擬記憶體現在已經直接映射到 page cache 了。
雖然我們採用的是私有文件映射的方式,但是進程 1 和進程 2 如果只是對文件映射部分進行讀取的話,文件頁其實在多進程之間是共用的,整個內核中只有一份。
但是當任意一個進程通過虛擬映射區對文件進行寫入操作的時候,情況就發生了變化,雖然通過 mmap 映射的時候指定的這段虛擬記憶體是可寫的,但是由於採用的是私有文件映射的方式,各個進程頁表中對應 PTE 卻是只讀的,當進程對這段虛擬記憶體進行寫入的時候,MMU 會發現 PTE 是只讀的,所以會產生一個防寫類型的缺頁中斷,寫入進程,比如是進程 1,此時又會陷入到內核態,在防寫缺頁處理中,內核會重新申請一個記憶體頁,然後將 page cache 中的內容拷貝到這個新的記憶體頁中,進程 1 頁表中對應的 PTE 會重新關聯到這個新的記憶體頁上,此時 PTE 的許可權變為可寫。
從此以後,進程 1 對這段虛擬記憶體區域進行讀寫的時候就不會再發生缺頁了,讀寫操作都會發生在這個新申請的記憶體頁上,但是有一點,進程 1 對這個記憶體頁的任何修改均不會回寫到磁碟文件上,這也體現了私有文件映射的特點,進程對映射文件的修改,其他進程是看不到的,並且修改不會同步回磁碟文件中。
進程 2 對這段虛擬映射區進行寫入的時候,也是一樣的道理,同樣會觸發防寫類型的缺頁中斷,進程 2 陷入內核態,內核為進程 2 新申請一個物理記憶體頁,並將 page cache 中的內容拷貝到剛為進程 2 申請的這個記憶體頁中,進程 2 頁表中對應的 PTE 會重新關聯到新的記憶體頁上, PTE 的許可權變為可寫。
這樣一來,進程 1 和進程 2 各自的這段虛擬映射區,就映射到了各自專屬的物理記憶體頁上,而且這兩個記憶體頁中的內容均是文件中映射的部分,他們已經和 page cache 脫離了。
進程 1 和進程 2 對各自虛擬記憶體區的修改只能反應到各自對應的物理記憶體頁上,而且各自的修改在進程之間是互不可見的,最重要的一點是這些修改均不會回寫到磁碟文件中,這就是私有文件映射的核心特點。
我們可以利用 mmap 私有文件映射這個特點來載入二進位可執行文件的 .text , .data section 到進程虛擬記憶體空間中的代碼段和數據段中。
因為同一份代碼,也就是同一份二進位可執行文件可以運行多個進程,而代碼段對於多進程來說是只讀的,沒有必要為每個進程都保存一份,多進程之間共用這一份代碼就可以了,正好私有文件映射的讀共用特點可以滿足我們的這個需求。
對於數據段來說,雖然它是可寫的,但是我們需要的是多進程之間對數據段的修改相互之間是不可見的,而且對數據段的修改不能回寫到磁碟上的二進位文件中,這樣當我們利用這個可執行文件在啟動一個進程的時候,進程看到的就是數據段初始化未被修改的狀態。 mmap 私有文件映射的寫時複製(copy on write)以及修改不會回寫到映射文件中等特點正好也滿足我們的需求。
這一點我們可以在負責載入 elf 格式的二進位可執行文件並映射到進程虛擬記憶體空間的 load_elf_binary 函數,以及負責載入 a.out 格式可執行文件的 load_aout_binary 函數中可以看出。
static int load_elf_binary(struct linux_binprm *bprm)
{
// 將二進位文件中的 .text .data section 私有映射到虛擬記憶體空間中代碼段和數據段中
error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
elf_prot, elf_flags, total_size);
}
static int load_aout_binary(struct linux_binprm * bprm)
{
............ 省略 .............
// 將 .text 採用私有文件映射的方式映射到進程虛擬記憶體空間的代碼段
error = vm_mmap(bprm->file, N_TXTADDR(ex), ex.a_text,
PROT_READ | PROT_EXEC,
MAP_FIXED | MAP_PRIVATE | MAP_DENYWRITE | MAP_EXECUTABLE,
fd_offset);
// 將 .data 採用私有文件映射的方式映射到進程虛擬記憶體空間的數據段
error = vm_mmap(bprm->file, N_DATADDR(ex), ex.a_data,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_FIXED | MAP_PRIVATE | MAP_DENYWRITE | MAP_EXECUTABLE,
fd_offset + ex.a_text);
............ 省略 .............
}
4. 共用文件映射
#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
我們通過將 mmap 系統調用中的 flags 參數指定為 MAP_SHARED
, 參數 fd 指定為要映射文件的文件描述符(file descriptor)來實現對文件的共用映射。
共用文件映射其實和私有文件映射前面的映射過程是一樣的,唯一不同的點在於私有文件映射是讀共用的,寫的時候會發生寫時複製(copy on write),並且多進程針對同一映射文件的修改不會回寫到磁碟文件上。
而共用文件映射因為是共用的,多個進程中的虛擬記憶體映射區最終會通過缺頁中斷的方式映射到文件的 page cache 中,後續多個進程對各自的這段虛擬記憶體區域的讀寫都會直接發生在 page cache 上。
因為映射文件的 page cache 在內核中只有一份,所以對於共用文件映射來說,多進程讀寫都是共用的,由於多進程直接讀寫的是 page cache ,所以多進程對共用映射區的任何修改,最終都會通過內核回寫線程 pdflush 刷新到磁碟文件中。
下麵這幅是多進程通過 mmap 共用文件映射之後的內核數據結構關係圖:
同私有文件映射方式一樣,當多個進程調用 mmap 對磁碟上的同一個文件進行共用文件映射的時候,內核中的處理都是一樣的,也都只是在每個進程的虛擬記憶體空間中,創建出一段用於共用映射的虛擬記憶體區域 VMA 出來,隨後內核會將各個進程中的這段虛擬記憶體映射區與映射文件關聯起來,mmap 共用文件映射的邏輯就結束了。
唯一不同的是,共用文件映射會在這段用於映射文件的 VMA 中標註是共用映射 —— MAP_SHARED
struct vm_area_struct {
// MAP_SHARED 共用映射
unsigned long vm_flags;
}
在 mmap 共用文件映射的過程中,內核同樣不涉及任何的物理記憶體分配,只是分配了一段虛擬記憶體,在共用映射剛剛建立起來之後,文件對應的 page cache 同樣是空的,沒有包含任何的文件頁。
由於 mmap 只是在各個進程中分配了虛擬記憶體,沒有分配物理記憶體,所以在各個進程的頁表中,這段用於文件映射的虛擬記憶體區域對應的頁表項 PTE 是空的,當任意進程對這段虛擬記憶體進行訪問的時候(讀或者寫),MMU 就會產生缺頁中斷,這裡我們以上圖中的進程 1 為例,隨後進程 1 切換到內核態,執行內核缺頁中斷處理程式。
同私有文件映射的缺頁處理一樣,內核會首先通過 vm_area_struct->vm_pgoff 在文件 page cache 中查找是否有緩存相應的文件頁(映射的磁碟塊對應的文件頁)。如果文件頁不在 page cache 中,內核則會在物理記憶體中分配一個記憶體頁,然後將新分配的記憶體頁加入到 page cache 中。
然後調用 readpage 激活塊設備驅動從磁碟中讀取映射的文件內容,用讀取到的內容填充新分配的記憶體頁,現在物理記憶體有了,最後一步就是在進程 1 的頁表中建立共用映射的這段虛擬記憶體與 page cache 中緩存的文件頁之間的關聯。
這裡和私有文件映射不同的地方是,私有文件映射由於是私有的,所以在內核創建 PTE 的時候會將 PTE 設置為只讀,目的是當進程寫入的時候觸發防寫類型的缺頁中斷進行寫時複製 (copy on write)。
共用文件映射由於是共用的,PTE 被創建出來的時候就是可寫的,所以後續進程 1 在對這段虛擬記憶體區域寫入的時候不會觸發缺頁中斷,而是直接寫入 page cache 中,整個過程沒有切態,沒有數據拷貝。
現在我們在切換到進程 2 的視角中,雖然現在文件中被映射的這部分內容已經載入進物理記憶體頁,並被緩存在文件的 page cache 中了。但是現在進程 2 中這段虛擬映射區在進程 2 頁表中對應的 PTE 仍然是空的,當進程 2 訪問這段虛擬映射區的時候依然會產生缺頁中斷。
當進程 2 切換到內核態,處理缺頁中斷的時候,此時進程 2 通過 vm_area_struct->vm_pgoff 在 page cache 查找文件頁的時候,文件頁已經被進程 1 載入進 page cache 了,進程 2 一下就找到了,就不需要再去磁碟中讀取映射內容了,內核會直接為進程 2 創建 PTE (由於是共用文件映射,所以這裡的 PTE 也是可寫的),並插入到進程 2 頁表中,隨後將進程 2 中的虛擬映射區通過 PTE 與 page cache 中緩存的文件頁映射關聯起來。
現在進程 1 和進程 2 各自虛擬記憶體空間中的這段虛擬記憶體區域 VMA,已經共同映射到了文件的 page cache 中,由於文件的 page cache 在內核中只有一份,它是和進程無關的,page cache 中的內容發生的任何變化,進程 1 和進程 2 都是可以看到的。
重要的一點是,多進程對各自虛擬記憶體映射區 VMA 的寫入操作,內核會根據自己的臟頁回寫策略將修改內容回寫到磁碟文件中。
內核提供了以下六個系統參數,來供我們配置調整內核臟頁回寫的行為,這些參數的配置文件存在於 proc/sys/vm
目錄下:
-
dirty_writeback_centisecs 內核參數的預設值為 500。單位為 0.01 s。也就是說內核預設會每隔 5s 喚醒一次 flusher 線程來執行相關臟頁的回寫。
-
drity_background_ratio :當臟頁數量在系統的可用記憶體 available 中占用的比例達到 drity_background_ratio 的配置值時,內核就會喚醒 flusher 線程非同步回寫臟頁。預設值為:10。表示如果 page cache 中的臟頁數量達到系統可用記憶體的 10% 的話,就主動喚醒 flusher 線程去回寫臟頁到磁碟。
-
dirty_background_bytes :如果 page cache 中臟頁占用的記憶體用量絕對值達到指定的 dirty_background_bytes。內核就會喚醒 flusher 線程非同步回寫臟頁。預設為:0。
-
dirty_ratio : dirty_background_* 相關的內核配置參數均是內核通過喚醒 flusher 線程來非同步回寫臟頁。下麵要介紹的 dirty_* 配置參數,均是由用戶進程同步回寫臟頁。表示記憶體中的臟頁太多了,用戶進程自己都看不下去了,不用等內核 flusher 線程喚醒,用戶進程自己主動去回寫臟頁到磁碟中。當臟頁占用系統可用記憶體的比例達到 dirty_ratio 配置的值時,用戶進程同步回寫臟頁。預設值為:20 。
-
dirty_bytes :如果 page cache 中臟頁占用的記憶體用量絕對值達到指定的 dirty_bytes。用戶進程同步回寫臟頁。預設值為:0。
-
內核為了避免 page cache 中的臟頁在記憶體中長久的停留,所以會給臟頁在記憶體中的駐留時間設置一定的期限,這個期限可由前邊提到的 dirty_expire_centisecs 內核參數配置。預設為:3000。單位為:0.01 s。也就是說在預設配置下,臟頁在記憶體中的駐留時間為 30 s。超過 30 s 之後,flusher 線程將會在下次被喚醒的時候將這些臟頁回寫到磁碟中。
關於臟頁回寫詳細的內容介紹,感興趣的讀者可以回看下 《從 Linux 內核角度探秘 JDK NIO 文件讀寫本質》 一文中的 “13. 內核回寫臟頁的觸發時機” 小節。
根據 mmap 共用文件映射多進程之間讀寫共用(不會發生寫時複製)的特點,常用於多進程之間共用記憶體(page cache),多進程之間的通訊。
5. 共用匿名映射
#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
我們通過將 mmap 系統調用中的 flags 參數指定為 MAP_SHARED | MAP_ANONYMOUS
,並將 fd 參數指定為 -1 來實現共用匿名映射,這種映射方式常用於父子進程之間共用記憶體,父子進程之間的通訊。註意,這裡需要和大家強調一下是父子進程,為什麼只能是父子進程,筆者後面再給大家解答。
在筆者介紹完 mmap 的私有匿名映射,私有文件映射,以及共用文件映射之後,共用匿名映射看似就非常簡單了,由於不對文件進行映射,所以它不涉及到文件系統相關的知識,而且又是共用的,多個進程通過將自己的頁表指向同一個物理記憶體頁面不就實現共用匿名映射了嗎?
看起來簡單,實際上並沒有那麼簡單,甚至可以說共用匿名映射是 mmap 這四種映射方式中最為複雜的,為什麼這麼說的 ?我們一起來看下共用匿名映射的映射過程。
首先和其他幾種映射方式一樣,mmap 只是負責在各個進程的虛擬記憶體空間中劃分一段用於共用匿名映射的虛擬記憶體區域而已,這點筆者已經強調過很多遍了,整個映射過程並不涉及到物理記憶體的分配。
當多個進程調用 mmap 進行共用匿名映射之後,內核只不過是為每個進程在各自的虛擬記憶體空間中分配了一段虛擬記憶體而已,由於並不涉及物理記憶體的分配,所以這段用於映射的虛擬記憶體在各個進程的頁表中對應的頁表項 PTE 都還是空的,如下圖所示:
當任一進程,比如上圖中的進程 1 開始訪問這段虛擬映射區的時候,MMU 會產生缺頁中斷,進程 1 切換到內核態,開始處理缺頁中斷邏輯,在缺頁中斷處理程式中,內核為進程 1 分配一個物理記憶體頁,並創建對應的 PTE 插入到進程 1 的頁表中,隨後用 PTE 將進程 1 的這段虛擬映射區與物理記憶體映射關聯起來。進程 1 的缺頁處理結束,從此以後,進程 1 就可以讀寫這段共用映射的物理記憶體了。
現在我們把視角切換到進程 2 中,當進程 2 訪問它自己的這段虛擬映射區的時候,由於進程 2 頁表中對應的 PTE 為空,所以進程 2 也會發生缺頁中斷,隨後切換到內核態處理缺頁邏輯。
當進程 2 開始處理缺頁邏輯的時候,進程 2 就懵了,為什麼呢 ?原因是進程 2 和進程 1 進行的是共用映射,所以進程 2 不能隨便找一個物理記憶體頁進行映射,進程 2 必須和 進程 1 映射到同一個物理記憶體頁面,這樣才能共用記憶體。那現在的問題是,進程 2 面對著茫茫多的物理記憶體頁,進程 2 怎麼知道進程 1 已經映射了哪個物理記憶體頁 ?
內核在缺頁中斷處理中只能知道當前正在缺頁的進程是誰,以及發生缺頁的虛擬記憶體地址是什麼,內核根據這些信息,根本無法知道,此時是否已經有其他進程把共用的物理記憶體頁準備好了。
這一點對於共用文件映射來說特別簡單,因為有文件的 page cache 存在,進程 2 可以根據映射的文件內容在文件中的偏移 offset,從 page cache 中查找是否已經有其他進程把映射的文件內容載入到文件頁中。如果文件頁已經存在 page cache 中了,進程 2 直接映射這個文件頁就可以了。
struct vm_area_struct {
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE */
}
static inline struct page *find_get_page(struct address_space *mapping,
pgoff_t offset)
{
return pagecache_get_page(mapping, offset, 0, 0);
}
由於共用匿名映射並沒有對文件映射,所以其他進程想要在記憶體中查找要進行共用的記憶體頁就非常困難了,那怎麼解決這個問題呢 ?
既然共用文件映射可以輕鬆解決這個問題,那我們何不借鑒一下文件映射的方式 ?
共用匿名映射在內核中是通過一個叫做 tmpfs 的虛擬文件系統來實現的,tmpfs 不是傳統意義上的文件系統,它是基於記憶體實現的,掛載在 dev/zero
目錄下。
當多個進程通過 mmap 進行共用匿名映射的時候,內核會在 tmpfs 文件系統中創建一個匿名文件,這個匿名文件並不是真實存在於磁碟上的,它是內核為了共用匿名映射而模擬出來的,匿名文件也有自己的 inode 結構以及 page cache。
在 mmap 進行共用匿名映射的時候,內核會把這個匿名文件關聯到進程的虛擬映射區 VMA 中。這樣一來,當進程虛擬映射區域與 tmpfs 文件系統中的這個匿名文件映射起來之後,後面的流程就和共用文件映射一模一樣了。
struct vm_area_struct {
struct file * vm_file; /* File we map to (can be NULL). */
}
最後,筆者來回答下在本小節開始處拋出的一個問題,就是共用匿名映射只適用於父子進程之間的通訊,為什麼只能是父子進程呢 ?
因為當父進程進行 mmap 共用匿名映射的時候,內核會為其創建一個匿名文件,並關聯到父進程的虛擬記憶體空間中 vm_area_struct->vm_file 中。但是這時候其他進程並不知道父進程虛擬記憶體空間中關聯的這個匿名文件,因為進程之間的虛擬記憶體空間都是隔離的。
子進程就不一樣了,在父進程調用完 mmap 之後,父進程的虛擬記憶體空間中已經有了一段虛擬映射區 VMA 並關聯到匿名文件了。這時父進程進行 fork() 系統調用創建子進程,子進程會拷貝父進程的所有資源,當然也包括父進程的虛擬記憶體空間以及父進程的頁表。
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;
......... 省略 ..........
// 拷貝父進程的所有資源
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
......... 省略 ..........
}
當 fork 出子進程的時候,這時子進程的虛擬記憶體空間和父進程的虛擬記憶體空間完全是一模一樣的,在子進程的虛擬記憶體空間中自然也有一段虛擬映射區 VMA 並且已經關聯到匿名文件中了(繼承自父進程)。
現在父子進程的頁表也是一模一樣的,各自的這段虛擬映射區對應的 PTE 都是空的,一旦發生缺頁,後面的流程就和共用文件映射一樣了。我們可以把共用匿名映射看作成一種特殊的共用文件映射方式。
6. 參數 flags 的其他枚舉值
#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
在前邊的幾個小節中,筆者為大家介紹了 mmap 系統調用參數 flags 最為核心的三個枚舉值:MAP_ANONYMOUS,MAP_SHARED,MAP_PRIVATE。隨後我們通過這三個枚舉值組合出了四種記憶體映射方式:私有匿名映射,私有文件映射,共用文件映射,共用匿名映射。
到現在為止,筆者算是把 mmap 記憶體映射的核心原理及其在內核中的映射過程給大家詳細剖析完了,不過參數 flags 的枚舉值在內核中並不只是上述三個,除此之外,內核還定義了很多。在本小節的最後,筆者為大家挑了幾個相對重要的枚舉值給大家做一些額外的補充,這樣能夠讓大家對 mmap 記憶體映射有一個更加全面的認識。
#define MAP_LOCKED 0x2000 /* pages are locked */
#define MAP_POPULATE 0x008000 /* populate (prefault) pagetables */
#define MAP_HUGETLB 0x040000 /* create a huge page mapping */
經過前面的介紹我們知道,mmap 僅僅只是在進程虛擬記憶體空間中劃分出一段用於映射的虛擬記憶體區域 VMA ,並將這段 VMA 與磁碟上的文件映射起來而已。整個映射過程並不涉及物理記憶體的分配,更別說虛擬記憶體與物理記憶體的映射了,這些都是在進程訪問這段 VMA 的時候,通過缺頁中斷來補齊的。
如果我們在使用 mmap 系統調用的時候設置了 MAP_POPULATE
,內核在分配完虛擬記憶體之後,就會馬上分配物理記憶體,併在進程頁表中建立起虛擬記憶體與物理記憶體的映射關係,這樣進程在調用 mmap 之後就可以直接訪問這段映射的虛擬記憶體地址了,不會發生缺頁中斷。
但是當系統記憶體資源緊張的時候,內核依然會將 mmap 背後映射的這塊物理記憶體 swap out 到磁碟中,這樣進程在訪問的時候仍然會發生缺頁中斷,為了防止這種現象,我們可以在調用 mmap 的時候設置 MAP_LOCKED
。
在設置了 MAP_LOCKED
之後,mmap 系統調用在為進程分配完虛擬記憶體之後,內核也會馬上為其分配物理記憶體併在進程頁表中建立虛擬記憶體與物理記憶體的映射關係,這裡內核還會額外做一個動作,就是將映射的這塊物理記憶體鎖定在記憶體中,不允許它 swap,這樣一來映射的物理記憶體將會一直停留在記憶體中,進程無論何時訪問這段映射記憶體都不會發生缺頁中斷。
MAP_HUGETLB
則是用於大頁記憶體映射的,在內核中關於物理記憶體的調度是按照物理記憶體頁為單位進行的,普通物理記憶體頁大小為 4K。但在一些對於記憶體敏感的使用場景中,我們往往期望使用一些比普通 4K 更大的頁。
因為這些巨型頁要比普通的 4K 記憶體頁要大很多,而且這些巨型頁不允許被 swap,所以遇到缺頁中斷的情況就會相對減少,由於減少了缺頁中斷所以性能會更高。
另外,由於巨型頁比普通頁要大,所以巨型頁需要的頁表項要比普通頁要少,頁表項里保存了虛擬記憶體地址與物理記憶體地址的映射關係,當 CPU 訪問記憶體的時候需要頻繁通過 MMU 訪問頁表項獲取物理記憶體地址,由於要頻繁訪問,所以頁表項一般會緩存在 TLB 中,因為巨型頁需要的頁表項較少,所以節約了 TLB 的空間同時降低了 TLB 緩存 MISS 的概率,從而加速了記憶體訪問。
7. 大頁記憶體映射
在 64 位 x86 CPU 架構 Linux 的四級頁表體系下,系統支持的大頁尺寸有 2M,1G。我們可以在 /sys/kernel/mm/hugepages
路徑下查看當前系統所支持的大頁尺寸:
要想在應用程式中使用 HugePage,