從核心世界透視 mmap 記憶體對映的本質(原理篇)

bin的技術小屋發表於2023-09-18

本文基於核心 5.4 版本原始碼討論

之前有不少讀者給筆者留言,希望筆者寫一篇文章介紹下 mmap 記憶體對映相關的知識體系,之所以遲遲沒有動筆,是因為 mmap 這個系統呼叫看上去簡單,實際上並不簡單,可以說是非常複雜的一個系統呼叫。

如果想要給大家把 mmap 背後的技術本質,正確地,清晰地還原出來,還是有一定難度的,因為 mmap 這一個系統呼叫就能撬動起整個記憶體管理系統,檔案系統,頁表體系,缺頁中斷等一大片的背景知識,涉及到的知識面廣且繁雜。

幸運的是這一整套的背景知識,筆者已經在 《聊聊 Linux 核心》 系列文章中為大家詳細介紹過了,所以現在是時候開始動筆了,不過大家不需要擔心,雖然涉及到的背景知識比較多,但是在後面的相關章節裡,筆者還會為大家重新交代。

image

在上一篇文章 《一步一圖帶你構建 Linux 頁表體系》 中,筆者為大家介紹了記憶體對映最為核心的內容 —— 頁表體系。透過一步一圖的方式為大家展示了整個頁表體系的演進過程,並在這個過程中逐步揭開了整個頁表體系的全貌。

image

本文的內容依然是記憶體對映相關的內容,這一次筆者會帶著大家圍繞頁表這個最為核心的體系,在頁表的外圍進行記憶體對映相關知識的介紹,核心目的就是徹底為大家還原記憶體對映背後的技術本質,由淺入深地給大家講透徹,弄明白。

在我們正式開始今天的內容之前,筆者想首先丟擲幾個問題給大家思考,建議大家帶著這幾個問題來閱讀接下來的內容,我們共同來將這些迷霧一層一層地慢慢撥開,直到還原出記憶體對映的本質。

  1. 既然我們是在討論虛擬記憶體與實體記憶體的對映,那麼首先你得有虛擬記憶體,你也得有實體記憶體吧,在這個基礎之上,才能討論兩者之間的對映,而實體記憶體是怎麼來的,筆者已經透過前邊文章 《深入理解 Linux 實體記憶體分配全鏈路實現》 介紹的非常清楚了,那虛擬記憶體是怎麼來的呢 ?核心分配虛擬記憶體的過程是怎樣的呢?

  2. 我們知道記憶體對映是按照實體記憶體頁為單位進行的,而在記憶體管理中,記憶體頁主要分為兩種:一種是匿名頁,另一種是檔案頁,這一點筆者已經在 《一步一圖帶你深入理解 Linux 實體記憶體管理》 一文中反覆講過很多次了。根據實體記憶體頁的型別分類,記憶體對映自然也分為兩種:一種是虛擬記憶體對匿名實體記憶體頁的對映,另一種是虛擬記憶體對檔案頁的對映。關於檔案對映,大家或多或少在網上看到過這樣的論述——" 透過記憶體檔案對映可以將磁碟上的檔案對映到記憶體中,這樣我們就可以透過讀寫記憶體來完成磁碟檔案的讀寫 "。關於這個論述,如果對記憶體管理和檔案系統不熟悉的同學,可能感到這句話非常的神奇,會有這樣的一個疑問,記憶體就是記憶體啊,磁碟上的檔案就是檔案啊,這是兩個完全不同的東西,為什麼說讀寫記憶體就相當於讀寫磁碟上的檔案呢 ?記憶體檔案對映在核心中到底發生了什麼 ?我們經常談到的記憶體對映,到底對映的是什麼?

  3. 在上篇文章中筆者只是為大家展示了整個頁表體系的全貌,以及頁表體系一步一步的演進過程,但是在程式被建立出來之後,核心也僅是會為程式分配一張全域性頁目錄表 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 進行記憶體對映的時候,所需要的虛擬記憶體就是在這個區域中劃分出來的。

image

在檔案對映與匿名對映這段虛擬記憶體區域中,包含了一段一段的虛擬對映區,每當我們呼叫一次 mmap 進行記憶體對映的時候,核心都會在檔案對映與匿名對映區中劃分出一段虛擬對映區出來,這段虛擬對映區就是我們申請到的虛擬記憶體。

那麼我們申請的這塊虛擬記憶體到底有多大呢 ?這就用到了 mmap 系統呼叫的前兩個引數:

  • addr : 表示我們要對映的這段虛擬記憶體區域在程式虛擬記憶體空間中的起始地址(虛擬記憶體地址),但是這個引數只是給核心的一個暗示,核心並非一定得從我們指定的 addr 虛擬記憶體地址上劃分虛擬記憶體區域,核心只不過在劃分虛擬記憶體區域的時候會優先考慮我們指定的 addr,如果這個虛擬地址已經被使用或者是一個無效的地址,那麼核心則會自動選取一個合適的地址來劃分虛擬記憶體區域。我們一般會將 addr 設定為 NULL,意思就是完全交由核心來幫我們決定虛擬對映區的起始地址。

  • length :從程式虛擬記憶體空間中的什麼位置開始劃分虛擬記憶體區域的問題解決了,那麼我們要申請的這段虛擬記憶體有多大呢 ? 這個就是 length 引數的作用了,如果是匿名對映,length 引數決定了我們要對映的匿名實體記憶體有多大,如果是檔案對映,length 引數決定了我們要對映的檔案區域有多大。

addr,length 必須要按照 PAGE_SIZE(4K) 對齊。

image

如果我們透過 mmap 對映的是磁碟上的一個檔案,那麼就需要透過引數 fd 來指定要對映檔案的描述符(file descriptor),透過引數 offset 來指定檔案對映區域在檔案中偏移。

image

在記憶體管理系統中,實體記憶體是按照記憶體頁為單位組織的,在檔案系統中,磁碟中的檔案是按照磁碟塊為單位組織的,記憶體頁和磁碟塊大小一般情況下都是 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;
}

image

上圖中的檔案對映與匿名對映區裡邊其實包含了大量的 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 在檔案對映與匿名對映區中對映出來的這一段虛擬記憶體區域同程式虛擬記憶體空間中的其他虛擬記憶體區域一樣,也都是有許可權控制的。

image

比如上圖程式虛擬記憶體空間中的程式碼段,它是與磁碟上 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 系統呼叫可以動態修改程式虛擬記憶體空間中任意一段虛擬記憶體區域的許可權。

image

我們除了要為 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 項是空的。

image

或者 PTE 中的 P 位為 0 ,這些都是表示虛擬記憶體還未與實體記憶體進行對映。

image

關於頁表相關的知識,不熟悉的讀者可以回顧下筆者之前的文章 《一步一圖帶你構建 Linux 頁表體系》

這時 MMU 就會觸發缺頁異常(page fault),這裡的缺頁指的就是缺少實體記憶體頁,隨後程式就會切換到核心態,在核心缺頁中斷處理程式中,為這段虛擬記憶體區域分配對應大小的實體記憶體頁,隨後將實體記憶體頁中的內容全部初始化為 0 ,最後在頁表中建立虛擬記憶體與實體記憶體的對映關係,缺頁異常處理結束。

當缺頁處理程式返回時,CPU 會重新啟動引起本次缺頁異常的訪存指令,這時 MMU 就可以正常翻譯出實體記憶體地址了。

image

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 段,堆,棧。

image

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 。

image

當程式開啟一個檔案的時候,核心會為其建立一個 struct file 結構來描述被開啟的檔案,並在程式檔案描述符列表 fd_array 陣列中找到一個空閒位置分配給它,陣列中對應的下標,就是我們在使用者空間用到的檔案描述符。

image

而 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 位元組。

image

而在 Linux 的檔案系統中是按照磁碟塊為單位對資料讀寫的,因為每個扇區大小為 512 位元組,能夠儲存的資料比較小,而且扇區數量眾多,這樣在定址的時候比較困難,Linux 檔案系統將相鄰的扇區組合在一起,形成一個磁碟塊,後續針對磁碟塊整體進行操作效率更高。

只要我們找到了檔案中的磁碟塊,我們就可以定址到檔案在磁碟上的儲存內容了,所以使用 mmap 進行記憶體檔案對映的本質就是建立起虛擬記憶體區域 VMA 到檔案磁碟塊之間的對映關係 。

image

呼叫 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 私有檔案對映關係圖是不是清晰多了。

image

page cache 在核心中是使用基樹 radix_tree 結構來表示的,這裡我們只需要知道檔案頁是掛在 radix_tree 的葉子結點上,radix_tree 中的 root 節點和 node 節點是檔案頁(葉子節點)的索引節點就可以了。

當多個程式呼叫 mmap 對磁碟上同一個檔案進行私有檔案對映的時候,核心只是在每個程式的虛擬記憶體空間中建立出一段虛擬記憶體區域 VMA 出來,注意,此時核心只是為程式申請了用於對映的虛擬記憶體,並將虛擬記憶體與檔案對映起來,mmap 系統呼叫就返回了,全程並沒有實體記憶體的影子出現。檔案的 page cache 也是空的,沒有包含任何的檔案頁。

當任意一個程式,比如上圖中的程式 1 開始訪問這段對映的虛擬記憶體時,CPU 會把虛擬記憶體地址送到 MMU 中進行地址翻譯,因為 mmap 只是為程式分配了虛擬記憶體,並沒有分配實體記憶體,所以這段對映的虛擬記憶體在頁表中是沒有頁表項 PTE 的。

image

隨後 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 中檔案頁的許可權是隻讀的。

image

當核心處理完缺頁中斷之後,mmap 私有檔案對映在核心中的關係圖就變成下面這樣:

image

此時程式 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 了。

image

雖然我們採用的是私有檔案對映的方式,但是程式 1 和程式 2 如果只是對檔案對映部分進行讀取的話,檔案頁其實在多程式之間是共享的,整個核心中只有一份。

但是當任意一個程式透過虛擬對映區對檔案進行寫入操作的時候,情況就發生了變化,雖然透過 mmap 對映的時候指定的這段虛擬記憶體是可寫的,但是由於採用的是私有檔案對映的方式,各個程式頁表中對應 PTE 卻是隻讀的,當程式對這段虛擬記憶體進行寫入的時候,MMU 會發現 PTE 是隻讀的,所以會產生一個防寫型別的缺頁中斷,寫入程式,比如是程式 1,此時又會陷入到核心態,在防寫缺頁處理中,核心會重新申請一個記憶體頁,然後將 page cache 中的內容複製到這個新的記憶體頁中,程式 1 頁表中對應的 PTE 會重新關聯到這個新的記憶體頁上,此時 PTE 的許可權變為可寫。

image

從此以後,程式 1 對這段虛擬記憶體區域進行讀寫的時候就不會再發生缺頁了,讀寫操作都會發生在這個新申請的記憶體頁上,但是有一點,程式 1 對這個記憶體頁的任何修改均不會回寫到磁碟檔案上,這也體現了私有檔案對映的特點,程式對對映檔案的修改,其他程式是看不到的,並且修改不會同步回磁碟檔案中。

程式 2 對這段虛擬對映區進行寫入的時候,也是一樣的道理,同樣會觸發防寫型別的缺頁中斷,程式 2 陷入核心態,核心為程式 2 新申請一個實體記憶體頁,並將 page cache 中的內容複製到剛為程式 2 申請的這個記憶體頁中,程式 2 頁表中對應的 PTE 會重新關聯到新的記憶體頁上, PTE 的許可權變為可寫。

image

這樣一來,程式 1 和程式 2 各自的這段虛擬對映區,就對映到了各自專屬的實體記憶體頁上,而且這兩個記憶體頁中的內容均是檔案中對映的部分,他們已經和 page cache 脫離了。

程式 1 和程式 2 對各自虛擬記憶體區的修改只能反應到各自對應的實體記憶體頁上,而且各自的修改在程式之間是互不可見的,最重要的一點是這些修改均不會回寫到磁碟檔案中,這就是私有檔案對映的核心特點

我們可以利用 mmap 私有檔案對映這個特點來載入二進位制可執行檔案的 .text , .data section 到程式虛擬記憶體空間中的程式碼段和資料段中。

image

因為同一份程式碼,也就是同一份二進位制可執行檔案可以執行多個程式,而程式碼段對於多程式來說是隻讀的,沒有必要為每個程式都儲存一份,多程式之間共享這一份程式碼就可以了,正好私有檔案對映的讀共享特點可以滿足我們的這個需求。

對於資料段來說,雖然它是可寫的,但是我們需要的是多程式之間對資料段的修改相互之間是不可見的,而且對資料段的修改不能回寫到磁碟上的二進位制檔案中,這樣當我們利用這個可執行檔案在啟動一個程式的時候,程式看到的就是資料段初始化未被修改的狀態。 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 共享檔案對映之後的核心資料結構關係圖:

image

同私有檔案對映方式一樣,當多個程式呼叫 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 中,整個過程沒有切態,沒有資料複製。

image

現在我們在切換到程式 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 中快取的檔案頁對映關聯起來。

image

現在程式 1 和程式 2 各自虛擬記憶體空間中的這段虛擬記憶體區域 VMA,已經共同對映到了檔案的 page cache 中,由於檔案的 page cache 在核心中只有一份,它是和程式無關的,page cache 中的內容發生的任何變化,程式 1 和程式 2 都是可以看到的。

重要的一點是,多程式對各自虛擬記憶體對映區 VMA 的寫入操作,核心會根據自己的髒頁回寫策略將修改內容回寫到磁碟檔案中。

核心提供了以下六個系統引數,來供我們配置調整核心髒頁回寫的行為,這些引數的配置檔案存在於 proc/sys/vm 目錄下:

image

  • 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 的私有匿名對映,私有檔案對映,以及共享檔案對映之後,共享匿名對映看似就非常簡單了,由於不對檔案進行對映,所以它不涉及到檔案系統相關的知識,而且又是共享的,多個程式透過將自己的頁表指向同一個實體記憶體頁面不就實現共享匿名對映了嗎?

image

看起來簡單,實際上並沒有那麼簡單,甚至可以說共享匿名對映是 mmap 這四種對映方式中最為複雜的,為什麼這麼說的 ?我們一起來看下共享匿名對映的對映過程。

首先和其他幾種對映方式一樣,mmap 只是負責在各個程式的虛擬記憶體空間中劃分一段用於共享匿名對映的虛擬記憶體區域而已,這點筆者已經強調過很多遍了,整個對映過程並不涉及到實體記憶體的分配。

當多個程式呼叫 mmap 進行共享匿名對映之後,核心只不過是為每個程式在各自的虛擬記憶體空間中分配了一段虛擬記憶體而已,由於並不涉及實體記憶體的分配,所以這段用於對映的虛擬記憶體在各個程式的頁表中對應的頁表項 PTE 都還是空的,如下圖所示:

image

當任一程式,比如上圖中的程式 1 開始訪問這段虛擬對映區的時候,MMU 會產生缺頁中斷,程式 1 切換到核心態,開始處理缺頁中斷邏輯,在缺頁中斷處理程式中,核心為程式 1 分配一個實體記憶體頁,並建立對應的 PTE 插入到程式 1 的頁表中,隨後用 PTE 將程式 1 的這段虛擬對映區與實體記憶體對映關聯起來。程式 1 的缺頁處理結束,從此以後,程式 1 就可以讀寫這段共享對映的實體記憶體了。

image

現在我們把視角切換到程式 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 路徑下檢視當前系統所支援的大頁尺寸:

image

要想在應用程式中使用 HugePage,我們需要在核心編譯的時候透過設定 CONFIG_HUGETLBFSCONFIG_HUGETLB_PAGE 這兩個編譯選項來讓核心支援 HugePage。我們可以透過 cat /proc/filesystems 命令來檢視當前核心中是否支援 hugetlbfs 檔案系統,這是我們使用 HugePage 的基礎。

image

因為 HugePage 要求的是一大片連續的實體記憶體,和普通記憶體頁一樣,巨型大頁裡的記憶體必須是連續的,但是隨著系統的長時間執行,記憶體頁被頻繁無規則的分配與回收,系統中會產生大量的記憶體碎片,由於記憶體碎片的影響,核心很難尋找到大片連續的實體記憶體,這樣一來就很難分配到巨型大頁。

所以這就要求核心在系統啟動的時候預先為我們分配好足夠多的大頁記憶體,這些大頁記憶體被核心管理在一個大頁記憶體池中,大頁記憶體池中的記憶體全部是專用的,專門用於巨型大頁的分配,不能用於其他目的,即使系統中沒有使用巨型大頁,這些大頁記憶體就只能空閒在那裡,另外這些大頁記憶體都是被核心鎖定在記憶體中的,即使系統記憶體資源緊張,大頁記憶體也不允許被 swap。而且核心大頁池中的這些大頁記憶體使用完了就完了,大頁池耗盡之後,應用程式將無法再使用大頁。

既然大頁記憶體池在核心啟動的時候就需要被預先建立好,而建立大頁記憶體池,核心需要首先知道記憶體池中究竟包含多少個 HugePage,每個 HugePage 的尺寸是多少 。我們可以將這些引數在核心啟動的時候新增到 kernel command line 中,隨後核心在啟動的過程中就可以根據 kernel command line 中 HugePage 相關的引數進行大頁記憶體池的建立。下面是一些 HugePage 相關的核心 command line 引數含義:

  • hugepagesz : 用於指定大頁記憶體池中 HugePage 的 size,我們這裡可以指定 hugepagesz=2M 或者 hugepagesz=1G,具體支援多少種大頁尺寸由 CPU 架構決定。

  • hugepages:用於指定核心需要預先建立多少個 HugePage 在大頁記憶體池中,我們可以透過指定 hugepages=256 ,來表示核心需要預先建立 256 個 HugePage 出來。除此之外 hugepages 引數還可以有 NUMA 格式,用於告訴核心需要在每個 NUMA node 上建立多少個 HugePage。我們可以透過設定 hugepages=0:1,1:2 ... 來指定 NUMA node 0 上分配 1 個 HugePage,在 NUMA node 1 上分配 2 個 HugePage。

image

  • default_hugepagesz:用於指定 HugePage 預設大小。各種不同型別的 CPU 架構一般都支援多種 size 的 HugePage,比如 x86 CPU 支援 2M,1G 的 HugePage。arm64 支援 64K,2M,32M,1G 的 HugePage。這麼多尺寸的 HugePage 我們到底該使用哪種尺寸呢 ? 這時就需要透過 default_hugepagesz 來指定預設使用的 HugePage 尺寸。

以上為大家介紹的是在核心啟動的時候(boot time)透過向 kernel command line 指定 HugePage 相關的命令列引數來配置大頁,除此之外,我們還可以在系統剛剛啟動之後(run time)來配置大頁,因為系統剛剛啟動,所以系統記憶體碎片化程度最小,也是一個配置大頁的時機:

image

/proc/sys/vm 路徑下有兩個系統引數可以讓我們在系統 run time 的時候動態調整當前系統中 default size (由 default_hugepagesz 指定)大小的 HugePage 個數。

  • nr_hugepages 表示當前系統中 default size 大小的 HugePage 個數,我們可以透過 echo HugePageNum > /proc/sys/vm/nr_hugepages 命令來動態增大或者縮小 HugePage (default size )個數。

  • nr_overcommit_hugepages 表示當系統中的應用程式申請的大頁個數超過 nr_hugepages 時,核心允許在額外申請多少個大頁。當大頁記憶體池中的大頁個數被耗盡時,如果此時繼續有程式來申請大頁,那麼核心則會從當前系統中選取多個連續的普通 4K 大小的記憶體頁,湊出若干個大頁來供程式使用,這些被湊出來的大頁叫做 surplus_hugepage,surplus_hugepage 的個數不能超過 nr_overcommit_hugepages。當這些 surplus_hugepage 不在被使用時,就會被釋放回核心中。nr_hugepages 個數的大頁則會一直停留在大頁記憶體池中,不會被釋放,也不會被 swap。

nr_hugepages 有點像 JDK 執行緒池中的 corePoolSize 引數,(nr_hugepages + nr_overcommit_hugepages) 有點像執行緒池中的 maximumPoolSize 引數。

以上介紹的是修改預設尺寸大小的 HugePage,另外,我們還可以在系統 run time 的時候動態修改指定尺寸的 HugePage,不同大頁尺寸的相關配置檔案存放在 /sys/kernel/mm/hugepages 路徑下的對應目錄中:

image

如上圖所示,當前系統中所支援的大頁尺寸相關的配置檔案,均存放在對應 hugepages-hugepagesize 格式的目錄中,下面我們以 2M 大頁為例,進入到 hugepages-2048kB 目錄下,發現同樣也有 nr_hugepages 和 nr_overcommit_hugepages 這兩個配置檔案,它們的含義和上邊介紹的一樣,只不過這裡的是具體尺寸的 HugePage 相關配置。

我們可以透過如下命令來動態調整系統中 2M 大頁的個數:

echo HugePageNum > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages

同理在 NUMA 架構的系統下,我們可以在 /sys/devices/system/node/node_id 路徑下修改對應 numa node 節點中的相應尺寸 的大頁個數:

echo HugePageNum > /sys/devices/system/node/node_id/hugepages/hugepages-2048kB/nr_hugepages

現在核心已經支援了大頁,並且我們從核心的 boot time 或者 run time 配置好了大頁記憶體池,我們終於可以在應用程式中來使用大頁記憶體了,核心給我們提供了兩種方式來使用 HugePage:

  • 一種是本文介紹的 mmap 系統呼叫,需要在 flags 引數中設定 MAP_HUGETLB。另外核心提供了額外的兩個列舉值來配合 MAP_HUGETLB 一起使用,它們分別是 MAP_HUGE_2MB 和 MAP_HUGE_1GB。

    • MAP_HUGETLB | MAP_HUGE_2MB 用於指定我們需要對映的是 2M 的大頁。
    • MAP_HUGETLB | MAP_HUGE_1GB 用於指定我們需要對映的是 1G 的大頁。
    • MAP_HUGETLB 表示按照 default_hugepagesz 指定的預設尺寸來對映大頁。
  • 另一種是 SYSV 標準的系統呼叫 shmget 和 shmat。

本小節我們主要介紹 mmap 系統呼叫使用大頁的方式:

int main(void)
{
	addr = mmap(addr, length, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, -1, 0);
	return 0;
}

MAP_HUGETLB 只能支援 MAP_ANONYMOUS 匿名對映的方式使用 HugePage

當我們透過 mmap 設定了 MAP_HUGETLB 進行大頁記憶體對映的時候,這個對映過程和普通的匿名對映一樣,同樣也是首先在程式的虛擬記憶體空間中劃分出一段虛擬對映區 VMA 出來,同樣不涉及實體記憶體的分配,不一樣的地方是,核心在分配完虛擬記憶體之後,會在大頁記憶體池中為對映的這段虛擬記憶體預留好大頁記憶體,相當於是把即將要使用的大頁記憶體先鎖定住,不允許其他程式使用。這些被預留好的 HugePage 個數被記錄在上圖中的 resv_hugepages 檔案中。

當程式在訪問這段虛擬記憶體的時候,同樣會發生缺頁中斷,隨後核心會從大頁記憶體池中將這部分已經預留好的 resv_hugepages 分配給程式,並在程式頁表中建立好虛擬記憶體與 HugePage 的對映。關於程式頁表如何對映記憶體大頁的詳細內容,感興趣的同學可以回看下之前的文章 《一步一圖帶你構建 Linux 頁表體系》

image

由於這裡我們呼叫 mmap 對映的是 HugePage ,所以系統呼叫引數中的 addr,length 需要和大頁尺寸進行對齊,在本例中需要和 2M 進行對齊。

前邊也提到了 MAP_HUGETLB 需要和 MAP_ANONYMOUS 配合一起使用,只能支援匿名對映的方式來使用 HugePage。那如果我們想使用 mmap 對檔案進行大頁對映該怎麼辦呢 ?

這就用到了前面提到的 hugetlbfs 檔案系統:

image

hugetlbfs 是一個基於記憶體的檔案系統,類似前邊介紹的 tmpfs 檔案系統,位於 hugetlbfs 檔案系統下的所有檔案都是被大頁支援的,也就說透過 mmap 對 hugetlbfs 檔案系統下的檔案進行檔案對映,預設都是用 HugePage 進行對映。

hugetlbfs 下的檔案支援大多數的檔案系統操作,比如:open , close , chmod , read 等等,但是不支援 write 系統呼叫,如果想要對 hugetlbfs 下的檔案進行寫入操作,那麼必須透過檔案對映的方式將 hugetlbfs 中的檔案透過大頁對映進記憶體,然後在對映記憶體中進行寫入操作。

所以在我們使用 mmap 系統呼叫對 hugetlbfs 下的檔案進行大頁對映之前,首先需要做的事情就是在系統中掛載 hugetlbfs 檔案系統到指定的路徑下。

mount -t hugetlbfs -o uid=,gid=,mode=,pagesize=,size=,min_size=,nr_inodes= none /mnt/huge

上面的這條命令用於將 hugetlbfs 掛載到 /mnt/huge 目錄下,從此以後只要是在 /mnt/huge 目錄下建立的檔案,背後都是由大頁支援的,也就是說如果我們透過 mmap 系統呼叫對 /mnt/huge 目錄下的檔案進行檔案對映,缺頁的時候,核心分配的就是記憶體大頁。

只有在 hugetlbfs 下的檔案進行 mmap 檔案對映的時候才能使用大頁,其他普通檔案系統下的檔案依然只能對映普通 4K 記憶體頁。

mount 命令中的 uidgid 用於指定 hugetlbfs 根目錄的 owner 和 group。

pagesize 用於指定 hugetlbfs 支援的大頁尺寸,預設單位是位元組,我們可以透過設定 pagesize=2M 或者 pagesize=1G 來指定 hugetlbfs 中的大頁尺寸為 2M 或者 1G。

size 用於指定 hugetlbfs 檔案系統可以使用的最大記憶體容量是多少,單位同 pagesize 一樣。

min_size 用於指定 hugetlbfs 檔案系統可以使用的最小記憶體容量是多少。

nr_inodes 用於指定 hugetlbfs 檔案系統中 inode 的最大個數,決定該檔案系統中最大可以建立多少個檔案。

當 hugetlbfs 被我們掛載好之後,接下來我們就可以直接透過 mmap 系統呼叫對掛載目錄 /mnt/huge 下的檔案進行記憶體對映了,當缺頁的時候,核心會直接分配大頁,大頁尺寸是 pagesize

int main(void)
{
    fd = open(“/mnt/huge/test.txt”, O_CREAT|O_RDWR);
    addr=mmap(0,MAP_LENGTH,PROT_READ|PROT_WRITE,MAP_SHARED, fd, 0);
    return 0;
}

這裡需要注意是,透過 mmap 對映 hugetlbfs 中的檔案的時候,並不需要指定 MAP_HUGETLB 。而我們透過 SYSV 標準的系統呼叫 shmget 和 shmat 以及前邊介紹的 mmap ( flags 引數設定 MAP_HUGETLB)進行大頁申請的時候,並不需要掛載 hugetlbfs。

在核心中一共支援兩種型別的記憶體大頁,一種是標準大頁(hugetlb pages),也就是上面內容所介紹的使用大頁的方式,我們可以透過命令 grep Huge /proc/meminfo 來檢視標準大頁在系統中的使用情況:

image

和標準大頁相關的統計引數含義如下:

HugePages_Total 表示標準大頁池中大頁的個數。HugePages_Free 表示大頁池中還未被使用的大頁個數(未被分配)。

HugePages_Rsvd 表示大頁池中已經被預留出來的大頁,這個預留大頁是什麼意思呢 ?我們知道 mmap 系統呼叫只是為程式分配一段虛擬記憶體而已,並不會分配實體記憶體,當 mmap 進行大頁對映的時候也是一樣。不同之處在於,核心為程式分配完虛擬記憶體之後,還需要為程式在大頁池中預留好本次對映所需要的大頁個數,注意此時只是預留,還並未分配給程式,大頁池中被預留好的大頁不能被其他程式使用。這時 HugePages_Rsvd 的個數會相應增加,當程式發生缺頁的時候,核心會直接從大頁池中把這些提前預留好的大頁記憶體對映到程式的虛擬記憶體空間中。這時 HugePages_Rsvd 的個數會相應減少。系統中真正剩餘可用的個數其實是 HugePages_Free - HugePages_Rsvd

HugePages_Surp 表示大頁池中超額分配的大頁個數,這個概念其實筆者前面在介紹 nr_overcommit_hugepages 引數的時候也提到過,nr_overcommit_hugepages 參數列示最多能超額分配多少個大頁。當大頁池中的大頁全部被耗盡的時候,也就是 /proc/sys/vm/nr_hugepages 指定的大頁個數全部被分配完了,核心還可以超額為程式分配大頁,超額分配出的大頁個數就統計在 HugePages_Surp 中。

Hugepagesize 表示系統中大頁的預設 size 大小,單位為 KB。

Hugetlb 表示系統中所有尺寸的大頁所佔用的實體記憶體總量。單位為 KB。

核心中另外一種型別的大頁是透明大頁 THP (Transparent Huge Pages),這裡的透明指的是應用程式在使用 THP 的時候完全是透明的,不需要像使用標準大頁那樣需要系統管理員對系統進行顯示的大頁配置,在應用程式中也不需要向標準大頁那樣需要顯示指定 MAP_HUGETLB , 或者顯示對映到 hugetlbfs 裡的檔案中。

透明大頁的使用對使用者完全是透明的,核心會在背後為我們自動做大頁的對映,透明大頁不需要像標準大頁那樣需要提前預先分配好大頁記憶體池,透明大頁的分配是動態的,由核心執行緒 khugepaged 負責在背後默默地將普通 4K 記憶體頁整理成記憶體大頁給程式使用。但是如果由於記憶體碎片的因素,核心無法整理出記憶體大頁,那麼就會降級為使用普通 4K 記憶體頁。但是透明大頁這裡會有一個問題,當碎片化嚴重的時候,核心會啟動 kcompactd 執行緒去整理碎片,期望獲得連續的記憶體用於大頁分配,但是 compact 的過程可能會引起 sys cpu 飆高,應用程式卡頓。

透明大頁是允許 swap 的,這一點和標準大頁不同,在記憶體緊張需要 swap 的時候,透明大頁會被核心默默拆分成普通 4K 記憶體頁,然後 swap out 到磁碟。

透明大頁只支援 2M 的大頁,標準大頁可以支援 1G 的大頁,透明大頁主要應用於匿名記憶體中,可以在 tmpfs 檔案系統中使用。

在我們對比完了透明大頁與標準大頁之間的區別之後,我們現在來看一下如何使用透明大頁,其實非常簡單,我們可以透過修改 /sys/kernel/mm/transparent_hugepage/enabled 配置檔案來選擇開啟或者禁用透明大頁:

image

  • always 表示系統全域性開啟透明大頁 THP 功能。這意味著每個程式都會去嘗試使用透明大頁。

  • never 表示系統全域性關閉透明大頁 THP 功能。程式將永遠不會使用透明大頁。

  • madvise 表示程式如果想要使用透明大頁,需要透過 madvise 系統呼叫並設定引數 advice 為 MADV_HUGEPAGE 來建議核心,在 addr 到 addr+length 這片虛擬記憶體區域中,需要使用透明大頁來對映。

#include <sys/mman.h>

int madvise(void addr, size_t length, int advice);

一般我們會首先使用 mmap 先對映一段虛擬記憶體區域,然後透過 madvise 建議核心,將來在缺頁的時候,需要為這段虛擬記憶體對映透明大頁。由於背後需要透過核心執行緒 khugepaged 來不斷的掃描整理系統中的普通 4K 記憶體頁,然後將他們拼接成一個大頁來給程式使用,其中涉及記憶體整理和回收等耗時的操作,且這些操作會在記憶體路徑中加鎖,而 khugepaged 核心執行緒可能會在錯誤的時間啟動掃描和轉換大頁的操作,造成隨機不可控的效能下降。

另外一點,透明大頁不像標準大頁那樣是提前預分配好的,透明大頁是在系統執行時動態分配的,在記憶體緊張的時候,透明大頁和普通 4K 記憶體頁的分配過程一樣,有可能會遇到直接記憶體回收(direct reclaim)以及直接記憶體整理(direct compaction),這些操作都是同步的並且非常耗時,會對效能造成非常大的影響。

前面在 cat /proc/meminfo 命令中顯示的 AnonHugePages 就表示透明大頁在系統中的使用情況。另外我們可以透過 cat /proc/pid/smaps | grep AnonHugePages 命令來檢視某個程式對透明大頁的使用情況。

總結

本文筆者從五個角度為大家詳細介紹了 mmap 的使用方法及其在核心中的實現原理,這五個角度分別是:

  1. 私有匿名對映,其主要用於程式申請虛擬記憶體,以及初始化程式虛擬記憶體空間中的 BSS 段,堆,棧這些虛擬記憶體區域。

  2. 私有檔案對映,其核心特點是背後對映的檔案頁在多程式之間是讀共享的,多個程式對各自虛擬記憶體區的修改只能反應到各自對應的檔案頁上,而且各自的修改在程式之間是互不可見的,最重要的一點是這些修改均不會回寫到磁碟檔案中。我們可以利用這些特點來載入二進位制可執行檔案的 .text , .data section 到程式虛擬記憶體空間中的程式碼段和資料段中。

  3. 共享檔案對映,多程式之間讀寫共享(不會發生寫時複製),常用於多程式之間共享記憶體(page cache),多程式之間的通訊。

  4. 共享匿名對映,用於父子程式之間共享記憶體,父子程式之間的通訊。父子程式之間需要依賴 tmpfs 中的匿名檔案來實現共享記憶體。是一種特殊的共享檔案對映。

  5. 大頁記憶體對映,這裡我們介紹了標準大頁與透明大頁兩種大頁型別的區別與聯絡,以及他們各自的實現原理和使用方法。

在我們清楚了原理之後,筆者會在下篇文章為大家繼續詳細介紹 mmap 在核心中的原始碼實現,感謝大家收看到這裡,我們下篇文章見~

相關文章