博客 / 詳情

返回

從內核世界透視 mmap 內存映射的本質(源碼實現篇)

本文基於內核 5.4 版本源碼討論

通過上篇文章 《從內核世界透視 mmap 內存映射的本質(原理篇)》的介紹,我們現在已經非常清楚了 mmap 背後的映射原理以及它的使用方法,其核心就是在進程虛擬內存空間中分配一段虛擬內存出來,然後將這段虛擬內存與磁盤文件映射起來,整個 mmap 系統調用就結束了。

而在 mmap 內存映射的整個過程中,最為核心且複雜燒腦的環節其實不是內存映射的邏輯,而是虛擬內存分配的整個流程。筆者曾在之前的文章 《深入理解 Linux 物理內存分配全鏈路實現》 中詳細地為大家介紹了物理內存的分配過程,那麼虛擬內存的分配過程又是什麼樣的呢?

本文我們將進入到內核源碼實現中,來看一下虛擬內存分配的過程,在這個過程中,我們還可以親眼看到前面介紹的 mmap 內存映射原理在內核中具體是如何實現的,下面我們就從 mmap 系統調用的入口處來開始本文的內容:

image

1. 預處理大頁映射

SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len,
        unsigned long, prot, unsigned long, flags,
        unsigned long, fd, unsigned long, off)
{         
    error = ksys_mmap_pgoff(addr, len, prot, flags, fd, off >> PAGE_SHIFT);
}
unsigned long ksys_mmap_pgoff(unsigned long addr, unsigned long len,
                  unsigned long prot, unsigned long flags,
                  unsigned long fd, unsigned long pgoff)
{
    struct file *file = NULL;
    unsigned long retval;

    // 預處理文件映射
    if (!(flags & MAP_ANONYMOUS)) {
        // 根據 fd 獲取映射文件的 struct file 結構
        audit_mmap_fd(fd, flags);
        file = fget(fd);
        if (!file)
            // 這裏可以看出如果是匿名映射的話必須要指定 MAP_ANONYMOUS 否則這裏就返回錯誤了
            return -EBADF;
        // 映射文件是否是 hugetlbfs 中的文件,hugetlbfs 中的文件默認由大頁支持
        if (is_file_hugepages(file))
            // mmap 進行文件大頁映射,len 需要和大頁尺寸對齊
            len = ALIGN(len, huge_page_size(hstate_file(file)));
        retval = -EINVAL;
        // 這裏可以看出如果想要使用 mmap 對文件進行大頁映射,那麼映射的文件必須是 hugetlbfs 中的
        // mmap 文件大頁映射並不需要指定 MAP_HUGETLB,並且 mmap 不能對普通文件進行大頁映射
        if (unlikely(flags & MAP_HUGETLB && !is_file_hugepages(file)))
            goto out_fput;
    } else if (flags & MAP_HUGETLB) {
        // 從這裏我們可以看出 MAP_HUGETLB 只能支持 MAP_ANONYMOUS 匿名映射的方式使用 HugePage
        struct user_struct *user = NULL;
        // 內核中的大頁池(預先創建)
        struct hstate *hs;
        // 選取指定大頁尺寸的大頁池(內核中存在不同尺寸的大頁池)
        hs = hstate_sizelog((flags >> MAP_HUGE_SHIFT) & MAP_HUGE_MASK);
        if (!hs)
            return -EINVAL;
        // 映射長度 len 必須與大頁尺寸對齊
        len = ALIGN(len, huge_page_size(hs));
 
        // 在 hugetlbfs 中創建 anon_hugepage 文件,並預留大頁內存(禁止其他進程申請)
        file = hugetlb_file_setup(HUGETLB_ANON_FILE, len,
                VM_NORESERVE,
                &user, HUGETLB_ANONHUGE_INODE,
                (flags >> MAP_HUGE_SHIFT) & MAP_HUGE_MASK);
        if (IS_ERR(file))
            return PTR_ERR(file);
    }

    flags &= ~(MAP_EXECUTABLE | MAP_DENYWRITE);
    // 開始內存映射
    retval = vm_mmap_pgoff(file, addr, len, prot, flags, pgoff);
out_fput:
    if (file)
        // file 引用計數減 1
        fput(file);
    return retval;
}

ksys_mmap_pgoff 函數主要是針對 mmap 大頁映射的情況進行預處理,從該函數對大頁的預處理邏輯中我們可以提取出如下幾個關鍵信息:

  • 在使用 mmap 進行匿名映射的時候,必須在 flags 參數中指定 MAP_ANONYMOUS 標誌,否則映射流程將會終止,並返回 EBADF 錯誤。
  • mmap 在對文件進行大頁映射的時候,映射文件必須是 hugetlbfs 中的文件,flags 參數無需設置 MAP_HUGETLB, mmap 不能對普通文件進行大頁映射,這種映射方式必須提前手動掛載 hugetlbfs 文件系統到指定路徑下。映射長度需要與大頁尺寸進行對齊。
  • MAP_HUGETLB 需要和 MAP_ANONYMOUS 配合一起使用,MAP_HUGETLB 只能支持匿名映射的方式來使用 HugePage,當 mmap 設置 MAP_HUGETLB 標誌進行匿名大頁映射的時候,在這裏需要為進程在大頁池(hstate)中預留好本次映射所需要的大頁個數,注意此時只是預留,還並未分配給進程,大頁池中被預留好的大頁不能被其他進程使用。當進程發生缺頁的時候,內核會直接從大頁池中把這些提前預留好的內存映射到進程的虛擬內存空間中。
這部分被預留好的大頁會記錄在 cat /proc/meminfo 命令中的 HugePages_Rsvd 字段上。

在內核中,通過 is_file_hugepages 函數來判斷映射文件是否由大頁支持,我們在用户態使用的大頁一般是由兩種類型的系統調用來支持的:

  1. mmap 系統調用,背後依賴的是 hugetlbfs 文件系統,這種情況下只需要判斷映射文件的 struct file 結構中定義的文件操作是否是 hugetlbfs 文件系統相關的操作,這樣就可以確定出映射文件是否為 hugetlbfs 文件系統中的文件。
  2. SYSV 標準的系統調用 shmget 和 shmat,背後依賴 shm 文件系統,同理,只需要判斷映射文件是否為 shm 文件系統中的文件即可。
static inline bool is_file_hugepages(struct file *file)
{
    // hugetlbfs 文件系統中的文件默認由大頁支持
    // mmap 通過映射 hugetlbfs 中的文件實現文件大頁映射
    if (file->f_op == &hugetlbfs_file_operations)
        return true;

    // 通過 shmat 使用匿名大頁,這裏不需要關注
    return is_file_shm_hugepages(file);
}

bool is_file_shm_hugepages(struct file *file)
{
     // SYSV 標準的系統調用 shmget 和 shmat 通過 shm 文件系統來共享內存
     // 通過 shmat 的方式使用大頁會設置,這裏我們不需要關注
    return file->f_op == &shm_file_operations_huge;
}

2. 是否立即為映射分配物理內存

在一般情況下,我們調用 mmap 進行內存映射的時候,內核只是會在進程的虛擬內存空間中為這次映射分配一段虛擬內存,然後建立好這段虛擬內存與相關文件之間的映射關係就結束了,內核並不會為映射分配物理內存。

而物理內存的分配工作需要延後到這段虛擬內存被 CPU 訪問的時候,通過缺頁中斷來進入內核,分配物理內存,並在頁表中建立好映射關係。

但是當我們調用 mmap 的時候,如果在 flags 參數中設置了 MAP_POPULATE 或者 MAP_LOCKED 標誌位之後,物理內存的分配動作會提前發生。

首先會通過 do_mmap_pgoff 函數在進程虛擬內存空間中分配出一段未映射的虛擬內存區域,返回值 ret 表示映射的這段虛擬內存區域的起始地址。

緊接着就會調用 mm_populate,內核會在 mmap 剛剛映射出來的這段虛擬內存區域上,依次掃描這段 vma 中的每一個虛擬頁,並對每一個虛擬頁觸發缺頁異常,從而為其立即分配物理內存。

unsigned long vm_mmap_pgoff(struct file *file, unsigned long addr,
    unsigned long len, unsigned long prot,
    unsigned long flag, unsigned long pgoff)
{
    unsigned long ret;
    // 獲取進程虛擬內存空間
    struct mm_struct *mm = current->mm;
    // 是否需要為映射的 VMA,提前分配物理內存頁,避免後續的缺頁
    // 取決於 flag 是否設置了 MAP_POPULATE 或者 MAP_LOCKED,這裏的 populate 表示需要分配物理內存的大小
    unsigned long populate;

    ret = security_mmap_file(file, prot, flag);
    if (!ret) {
        // 對進程虛擬內存空間加寫鎖保護,防止多線程併發修改
        if (down_write_killable(&mm->mmap_sem))
            return -EINTR;
        // 開始 mmap 內存映射,在進程虛擬內存空間中分配一段 vma,並建立相關映射關係
        // ret 為映射虛擬內存區域的起始地址
        ret = do_mmap_pgoff(file, addr, len, prot, flag, pgoff,
                    &populate, &uf);
        // 釋放寫鎖
        up_write(&mm->mmap_sem);
        if (populate)
            // 提前分配物理內存頁面,後續訪問不會缺頁
            // 為 [ret , ret + populate] 這段虛擬內存立即分配物理內存
            mm_populate(ret, populate);
    }
    return ret;
}

mm_populate 函數的作用主要是在進程虛擬內存空間中,找出 [ret , ret + populate] 這段虛擬地址範圍內的所有 vma,併為每一個 vma 填充物理內存。

int __mm_populate(unsigned long start, unsigned long len, int ignore_errors)
{
    struct mm_struct *mm = current->mm;
    unsigned long end, nstart, nend;
    struct vm_area_struct *vma = NULL;
    long ret = 0;

    end = start + len;

    // 依次遍歷進程地址空間中 [start , end] 這段虛擬內存範圍的所有 vma
    for (nstart = start; nstart < end; nstart = nend) {

              ........ 省略查找指定地址範圍內 vma 的過程 ....

        // 為這段地址範圍內的所有 vma 分配物理內存
        ret = populate_vma_page_range(vma, nstart, nend, &locked);
        // 繼續為下一個 vma (如果有的話)分配物理內存
        nend = nstart + ret * PAGE_SIZE;
        ret = 0;
    }

    return ret; /* 0 or negative error code */
}

populate_vma_page_range 函數則是在 __mm_populate 的處理基礎上,為指定地址範圍 [start , end] 內的每一個虛擬內存頁,通過 __get_user_pages 函數為其分配物理內存。

long populate_vma_page_range(struct vm_area_struct *vma,
        unsigned long start, unsigned long end, int *nonblocking)
{
    struct mm_struct *mm = vma->vm_mm;
    // 計算 vma 中包含的虛擬內存頁個數,後續會按照 nr_pages 分配物理內存
    unsigned long nr_pages = (end - start) / PAGE_SIZE;
    int gup_flags;

    // 循環遍歷 vma 中的每一個虛擬內存頁,依次為其分配物理內存頁
    return __get_user_pages(current, mm, start, nr_pages, gup_flags,
                NULL, NULL, nonblocking);
}

__get_user_pages 會循環遍歷 vma 中的每一個虛擬內存頁,首先會通過 follow_page_mask 在進程頁表中查找該虛擬內存頁背後是否有物理內存頁與之映射,如果沒有則調用 faultin_page,其底層會調用到 handle_mm_fault 進入缺頁處理流程,內核在這裏會為其分配物理內存頁,並在進程頁表中建立好映射關係。

static long __get_user_pages(struct task_struct *tsk, struct mm_struct *mm,
        unsigned long start, unsigned long nr_pages,
        unsigned int gup_flags, struct page **pages,
        struct vm_area_struct **vmas, int *nonblocking)
{
    long ret = 0, i = 0;
    struct vm_area_struct *vma = NULL;
    struct follow_page_context ctx = { NULL };

    if (!nr_pages)
        return 0;

    start = untagged_addr(start);
    // 循環遍歷 vma 中的每一個虛擬內存頁
    do {
        struct page *page;
        unsigned int foll_flags = gup_flags;
        unsigned int page_increm;
        // 在進程頁表中檢查該虛擬內存頁背後是否有物理內存頁映射
        page = follow_page_mask(vma, start, foll_flags, &ctx);
        if (!page) {
            // 如果虛擬內存頁在頁表中並沒有物理內存頁映射,那麼這裏調用 faultin_page
            // 底層會調用到 handle_mm_fault 進入缺頁處理流程,分配物理內存,在頁表中建立好映射關係
            ret = faultin_page(tsk, vma, start, &foll_flags,
                    nonblocking);

    } while (nr_pages);

    return i ? i : ret;
}

3. 虛擬內存映射整體流程

image

do_mmap 是 mmap 系統調用的核心函數,內核會在這裏完成內存映射的整個流程,其中最為核心的是如下兩個方面的內容:

  1. get_unmapped_area 函數用於在進程地址空間中尋找出一段長度為 len,並且還未映射的虛擬內存區域 vma 出來。返回值 addr 表示這段虛擬內存區域的起始地址。
  2. mmap_region 函數是整個內存映射的核心,它首先會為這段選取出來的映射虛擬內存區域分配 vma 結構,並根據映射信息進行初始化,以及建立 vma 與相關映射文件的關係,最後將這段 vma 插入到進程的虛擬內存空間中。

除了這兩個核心內容之外,do_mmap 函數還承擔了對一些內存映射約束條件的檢查,比如:內核規定一個進程虛擬內存空間內所能映射的虛擬內存區域 vma 是有數量限制的,sysctl_max_map_count 規定了進程虛擬內存空間所能包含 VMA 的最大個數,我們可以通過 /proc/sys/vm/max_map_count 內核參數來調整 sysctl_max_map_count。

image

進程虛擬內存空間中現有的虛擬內存區域 vma 個數保存在 mm_struct 結構的 map_count 字段中。

struct mm_struct {
    int map_count;            /* number of VMAs */
}

所以在內存映射開始之前,內核需要確保 mm->map_count 不能超過 sysctl_max_map_count 中規定的映射個數。

mmap 系統調用的本質其實就是在進程虛擬內存空間中劃分出一段未映射的虛擬內存區域,隨後內核會為這段映射出來的虛擬內存區域創建 vma 結構,並初始化 vma 結構的相關屬性。

#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

而 mmap 系統調用參數 prot (用於指定映射區域的訪問權限),flags (指定內存映射方式),最終是要初始化進 vma 結構的 vm_flags 屬性中。

struct vm_area_struct {
    unsigned long vm_flags; 
}

內核會通過 calc_vm_prot_bits 函數和 calc_vm_flag_bits 函數來分別將 mmap 系統調用中指定的參數 prot,flags 轉換為 vm_ 前綴的標誌位,隨後一起設置到 vm_flags 中。

前面我們也提到了,如果我們在 flags 參數中設置了 MAP_LOCKED,那麼 mmap 系統調用在分配完虛擬內存之後,會立即分配物理內存,並且分配的物理內存會一直駐留鎖定在內存中,不會被 swap out 出去。

而在內核中,允許被鎖定的物理內存容量是有規定限額的,所以在內存映射之前,內核還需要檢查需要鎖定的物理內存數量是否超過了規定的限額,如果超過了則會停止映射,返回 EPERM 或者 EAGAIN 錯誤。

我們可以通過修改 /etc/security/limits.conf 文件中的 memlock 相關配置項來調整能夠被鎖定的內存資源配額,設置為 unlimited 表示不對鎖定內存進行限制。

image

進程的虛擬內存空間是非常龐大的,遠遠地超過真實物理內存容量,這就容易給我們造成一種錯覺,就是當我們調用 mmap 為應用進程申請虛擬內存的時候,可以無限制的申請,反正都是虛擬的嘛,內核應該痛痛快快的給我們。

但事實上並非如此,內核會對我們申請的虛擬內存容量進行審計(account),結合當前物理內存容量以及 swap 交換區的大小來綜合判斷是否允許本次虛擬內存的申請。

內核對虛擬內存使用的審計策略定義在 sysctl_overcommit_memory 中,我們可以通過內核參數 /proc/sys/vm/overcommit_memory 來調整 。

image

內核定義瞭如下三個 overcommit 策略,這裏的 commit 意思是需要申請的虛擬內存,overcommit 的意思是向內核申請過量的(遠遠超過物理內存容量)虛擬內存:

#define OVERCOMMIT_GUESS        0
#define OVERCOMMIT_ALWAYS        1
#define OVERCOMMIT_NEVER        2
  • OVERCOMMIT_GUESS 是內核的默認 overcommit 策略。在這種模式下,特別激進的,過量的虛擬內存申請將會被拒絕,內核會對虛擬內存能夠過量申請多少做出一定的限制,這種策略既不激進也不保守,比較中庸。
  • OVERCOMMIT_ALWAYS 是最為激進的 overcommit 策略,無論進程申請多大的虛擬內存,只要不超過整個進程虛擬內存空間的大小,內核總會痛快的答應。但是這種策略下,虛擬內存的申請雖然容易了,但是當進程遇到缺頁,內核為其分配物理內存的時候,會非常容易造成 OOM 。
  • OVERCOMMIT_NEVER 是最為嚴格的一種控制虛擬內存 overcommit 的策略,在這種模式下,內核會嚴格的規定虛擬內存的申請用量。
這裏我們先對這三種 overcommit 策略做一個簡單瞭解,具體內核在 OVERCOMMIT_GUESS 和 OVERCOMMIT_NEVER 模式下分別能夠允許進程 overcommit 多少虛擬內存,筆者在後面相關源碼章節在做詳細分析。

當我們使用 mmap 系統調用進行虛擬內存申請的時候,會受到內核 overcommit 策略的影響,內核會綜合物理內存的總體容量以及 swap 交換區的總體大小來決定是否允許本次虛擬內存用量的申請。mmap 申請過大的虛擬內存,內核會拒絕。

但是當我們在 mmap 系統調用參數 flags 中設置了 MAP_NORESERVE,則內核在分配虛擬內存的時候將不會考慮物理內存的總體容量以及 swap space 的限制因素,無論申請多大的虛擬內存,內核都會滿足。但缺頁的時候會容易導致 oom。

MAP_NORESERVE 只會在 OVERCOMMIT_GUESS 和 OVERCOMMIT_ALWAYS 模式下才有意義,因為如果內核本身是禁止 overcommit 的話,設置 MAP_NORESERVE 是無意義的。

在我們清楚了以上這些前置知識之後,再來看這段源碼實現就非常好理解了:

unsigned long do_mmap(struct file *file, unsigned long addr,
            unsigned long len, unsigned long prot,
            unsigned long flags, vm_flags_t vm_flags,
            unsigned long pgoff, unsigned long *populate,
            struct list_head *uf)
{
    struct mm_struct *mm = current->mm;

            ........ 省略參數校驗 ..........
        
    // 一個進程虛擬內存空間內所能包含的虛擬內存區域 vma 是有數量限制的
    // sysctl_max_map_count 規定了進程虛擬內存空間所能包含 VMA 的最大個數
    // 可以通過 /proc/sys/vm/max_map_count 內核參數調整 sysctl_max_map_count
    // mmap 需要再進程虛擬內存空間中創建映射的 VMA,這裏需要檢查 VMA 的個數是否超過最大限制
    if (mm->map_count > sysctl_max_map_count)
        return -ENOMEM;

    // 在進程虛擬內存空間中尋找一塊未映射的虛擬內存範圍
    // 這段虛擬內存範圍後續將會用於 mmap 內存映射
    addr = get_unmapped_area(file, addr, len, pgoff, flags);

    // 通過 calc_vm_prot_bits 和 calc_vm_flag_bits 將 mmap 參數 prot , flag 中   
    // 設置的訪問權限以及映射方式等枚舉值轉換為統一的 vm_flags,後續一起映射進 VMA 的相應屬性中,相應前綴轉換為 VM_  
    vm_flags |= calc_vm_prot_bits(prot, pkey) | calc_vm_flag_bits(flags) |
            mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC;

    // 設置了 MAP_LOCKED,表示用户期望 mmap 背後映射的物理內存鎖定在內存中,不允許 swap
    if (flags & MAP_LOCKED)
        // 這裏需要檢查是否可以將本次映射的物理內存鎖定
        if (!can_do_mlock())
            return -EPERM;
    // 進一步檢查鎖定的內存頁數是否超過了內核限制
    if (mlock_future_check(mm, vm_flags, len))
        return -EAGAIN;

        ....... 省略設置其他 vm_flags 相關細節 .......      

    // 通常內核會為 mmap 申請虛擬內存的時候會綜合考慮 ram 以及 swap space 的總體大小。
    // 當映射的虛擬內存過大,而沒有足夠的 swap space 的時候, mmap 就會失敗。
    // 設置 MAP_NORESERVE,內核將不會考慮上面的限制因素
    // 這樣當通過 mmap 申請大量的虛擬內存,並且當前系統沒有足夠的 swap space 的時候,mmap 系統調用依然能夠成功
    if (flags & MAP_NORESERVE) {
        // 設置 MAP_NORESERVE 的目的是為了應用可以申請過量的虛擬內存
        // 如果內核本身是禁止 overcommit 的,那麼設置 MAP_NORESERVE 是無意義的
        // 如果內核允許過量申請虛擬內存時(overcommit 為 0 或者 1)
        // 無論映射多大的虛擬內存,mmap 將會始終成功,但缺頁的時候會容易導致 oom
        if (sysctl_overcommit_memory != OVERCOMMIT_NEVER)
            // 設置 VM_NORESERVE 表示無論申請多大的虛擬內存,內核總會答應
            vm_flags |= VM_NORESERVE;

        // 大頁內存是提前預留出來的,並且本身就不會被 swap
        // 所以不需要像普通內存頁那樣考慮 swap space 的限制因素
        if (file && is_file_hugepages(file))
            vm_flags |= VM_NORESERVE;
    }
    // 這裏就是 mmap 內存映射的核心
    addr = mmap_region(file, addr, len, vm_flags, pgoff, uf);

    // 當 mmap 設置了 MAP_POPULATE 或者 MAP_LOCKED 標誌
    // 那麼在映射完之後,需要立馬為這塊虛擬內存分配物理內存頁,後續訪問就不會發生缺頁了
    if (!IS_ERR_VALUE(addr) &&
        ((vm_flags & VM_LOCKED) ||
         (flags & (MAP_POPULATE | MAP_NONBLOCK)) == MAP_POPULATE))
        // 設置需要分配的物理內存大小
        *populate = len;
    return addr;
}

當我們期望對 mmap 背後映射的物理內存進行鎖定的時候,內核首先需要調用 can_do_mlock 函數,對能夠鎖定的物理內存資源配額進行判斷,如果配額不足則不能對本次映射的物理內存進行鎖定,mmap 返回 EPERM 錯誤,流程結束。

bool can_do_mlock(void)
{
    // 內核會限制能夠被鎖定的內存資源大小,單位為bytes
    // 這裏獲取 RLIMIT_MEMLOCK 能夠鎖定的內存資源,如果為 0 ,則不能夠鎖定內存了。
    // 我們可以通過修改 /etc/security/limits.conf 文件中的 memlock 相關配置項
    // 來調整能夠被鎖定的內存資源配額,設置為 unlimited 表示不對鎖定內存進行限制
    if (rlimit(RLIMIT_MEMLOCK) != 0)
        return true;
    // 檢查內核是否允許 mlock ,mlockall 等內存鎖定操作
    if (capable(CAP_IPC_LOCK))
        return true;
    return false;
}

進程的相關資源限制配額定義在 task_struct->signal_struct->rlim 數組中:

struct task_struct {
  struct signal_struct    *signal;
}

struct signal_struct {
  // 進程相關的資源限制,相關的資源限制以數組的形式組織在 rlim 中
  // RLIMIT_MEMLOCK 下標對應的是進程能夠鎖定的內存資源,單位為bytes
  struct rlimit rlim[RLIM_NLIMITS];
}

struct rlimit {
    __kernel_ulong_t    rlim_cur;
    __kernel_ulong_t    rlim_max;
};

內核中通過 rlimit 函數獲取進程相關的資源限制:

// 定義在文件:/include/linux/sched/signal.h
static inline unsigned long rlimit(unsigned int limit)
{
    // 參數 limit 為相關資源的下標
    return task_rlimit(current, limit);
}

static inline unsigned long task_rlimit(const struct task_struct *task,
        unsigned int limit)
{
    return READ_ONCE(task->signal->rlim[limit].rlim_cur);
}

當通過 can_do_mlock 的檢驗之後,內核還需要近一步通過 mlock_future_check 函數來檢查本次映射需要鎖定的物理內存頁數加上進程已經鎖定的物理內存頁數總體上是否超過了內存資源鎖定限額 rlimit(RLIMIT_MEMLOCK)。如果已經超過限額,本次 mmap 流程就會停止。

static inline int mlock_future_check(struct mm_struct *mm,
                     unsigned long flags,
                     unsigned long len)
{
    unsigned long locked, lock_limit;

    if (flags & VM_LOCKED) {
        // 需要鎖定的內存頁數
        locked = len >> PAGE_SHIFT;
        // 更新進程內存空間中已經鎖定的內存頁數
        locked += mm->locked_vm;
        // 獲取內核還能允許鎖定的內存頁數
        lock_limit = rlimit(RLIMIT_MEMLOCK);        
        lock_limit >>= PAGE_SHIFT;
        // 如果超出允許鎖定的內存限額,那麼就返回錯誤
        if (locked > lock_limit && !capable(CAP_IPC_LOCK))
            return -EAGAIN;
    }
    return 0;
}

4. 虛擬內存的分配流程

image

mmap 系統調用分配虛擬內存的本質其實就是在進程的虛擬內存空間中的文件映射與匿名映射區,找出一段未被映射過的空閒虛擬內存區域 vma,這個 vma 就是我們申請到的虛擬內存。

由此可以看出 mmap 主要的工作區域是在文件映射與匿名映射區,而在映射區查找空閒 vma 的過程又是和映射區的佈局息息相關的,所以在為大家介紹虛擬內存分配流程之前,還是有必要介紹一下文件映射與匿名映射區的佈局情況,這樣方便大家後續理解虛擬內存分配的邏輯。

4.1 文件映射與匿名映射區的佈局

文件映射與匿名映射區的佈局在 linux 內核中分為兩種:一種是經典佈局,另一種是新式佈局,不同的體系結構可以通過內核參數 /proc/sys/vm/legacy_va_layout 來指定具體採用哪種佈局。 1 表示採用經典佈局, 0 表示採用新式佈局。

image

在經典佈局下,文件映射與匿名映射區的地址增長方向是從低地址到高地址,也就是説映射區是從下往上增長,這也就導致了 mmap 在分配虛擬內存的時候需要從下往上搜索空閒 vma。

image

經典佈局下,文件映射與匿名映射區的起始地址 mm_struct->mmap_base 被設置在 task_size 的三分之一處,task_size 為進程虛擬內存空間與內核空間的分界線,也就説 task_size 是進程虛擬內存空間的末尾,大小為 3G。

這表明了文件映射與匿名映射區起始於進程虛擬內存空間開始的 1G 位置處,而映射區恰好位於整個進程虛擬內存空間的中間,其下方就是堆了,由於代碼段,數據段的存在,可供堆進行擴展的空間是小於 1G 的,否則就會與映射區衝突了。

這種佈局對於虛擬內存空間非常大的體系結構,比如 AMD64 , 是合適的而且會工作的非常好,因為虛擬內存空間足夠的大(128T),堆與映射區都有足夠的空間來擴展,不會發生衝突。

但是對於虛擬內存空間比較小的體系結構,比如 IA-32,只能提供 3G 大小的進程虛擬內存空間,就會出現上述衝突問題,於是內核在 2.6.7 版本引入了新式佈局。

在新式佈局下,文件映射與匿名映射區的地址增長方向是從高地址到低地址,也就是説映射區是從上往下增長,這也就導致了 mmap 在分配虛擬內存的時候需要從上往下搜索空閒 vma。

image

在新式佈局中,棧的空間大小會被限制,棧最大空間大小保存在 task_struct->signal_struct->rlimp[RLIMIT_STACK] 中,我們可以通過修改 /etc/security/limits.conf 文件中 stack 配置項來調整棧最大空間的限制。

由於棧變為有界的了,所以文件映射與匿名映射區可以在棧的下方立即開始,為確保棧與映射區不會衝突,它們中間還設置了 1M 大小的安全間隙 stack_guard_gap。

這樣一來堆在進程地址空間中較低的地址處開始向上增長,而映射區位於進程空間較高的地址處向下增長,因此堆區和映射區在新式佈局下都可以較好的擴展,直到耗盡剩餘的虛擬內存區域。

4.2 內核具體如何對文件映射與匿名映射區進行佈局

進程虛擬內存空間的創建以及初始化是由 load_elf_binary 函數負責的,當進程通過 fork() 系統調用創建出子進程之後,子進程可以通過前面介紹的 execve 系統調用加載並執行一個指定的二進制執行文件。

execve 函數會調用到 load_elf_binary,由 load_elf_binary 負責解析指定的 ELF 格式的二進制可執行文件,並將二進制文件中的 .text , .data 映射到新進程的虛擬內存空間中的代碼段,數據段,BSS 段中。

隨後會通過 setup_new_exec 創建文件映射與匿名映射區,設置映射區的起始地址 mm_struct->mmap_base,通過 setup_arg_pages 創建棧,設置 mm->start_stack 棧的起始地址(棧底)。這樣新進程的虛擬內存空間就被創建了出來。

static int load_elf_binary(struct linux_binprm *bprm)
{
    // 創建文件映射與匿名映射區,設置映射區的起始地址 mm_struct->mmap_base
    setup_new_exec(bprm);
    // 創建棧,設置  mm->start_stack 棧的起始地址(棧底)
    retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
                 executable_stack);
}

由於本文主要討論的是 mmap 系統調用,mmap 最重要的一個任務就是在進程虛擬內存空間中的文件映射與匿名映射區劃分出一段空閒的虛擬內存區域出來,而劃分的邏輯是和文件映射與匿名映射區的佈局強相關的,所以這裏我們主要介紹文件映射與匿名映射區的佈局情況,方便大家後續理解 mmap 分配虛擬內存的邏輯。

void setup_new_exec(struct linux_binprm * bprm)
{
    // 對文件映射與匿名映射區進行佈局
    arch_pick_mmap_layout(current->mm, &bprm->rlim_stack);
}

文件映射與匿名映射區的佈局分為兩種,一種是經典佈局,另一種是新佈局。不同的體系結構可以通過設置 HAVE_ARCH_PICK_MMAP_LAYOUT 預處理符號,並提供 arch_pick_mmap_layout 函數的實現來在這兩種不同佈局之間進行選擇。

// 定義在文件:/arch/x86/include/asm/processor.h
#define HAVE_ARCH_PICK_MMAP_LAYOUT 1
// 定義在文件:/arch/x86/mm/mmap.c
void arch_pick_mmap_layout(struct mm_struct *mm, struct rlimit *rlim_stack)
{
    if (mmap_is_legacy())
        // 經典佈局下,映射區分配虛擬內存方法
        mm->get_unmapped_area = arch_get_unmapped_area;
    else
        // 新式佈局下,映射區分配虛擬內存方法
        mm->get_unmapped_area = arch_get_unmapped_area_topdown;
    // 映射區佈局
    arch_pick_mmap_base(&mm->mmap_base, &mm->mmap_legacy_base,
            arch_rnd(mmap64_rnd_bits), task_size_64bit(0),
            rlim_stack);
}

由於在經典佈局下,文件映射與匿名映射區的地址增長方向是從低地址到高地址增長,在新佈局下,文件映射與匿名映射區的地址增長方向是從高地址到低地址增長。

所以當 mmap 在文件映射與匿名映射區中尋找空閒 vma 的時候,會受到不同佈局的影響,其尋找方向是相反的,因此不同的體系結構需要設置 HAVE_ARCH_UNMAPPED_AREA 預處理符號,並提供 arch_get_unmapped_area 函數的實現。這樣一來,如果文件映射與匿名映射區採用的是經典佈局,那麼 mmap 就會通過這裏的 arch_get_unmapped_area 來在映射區查找空閒的 vma。

如果文件映射與匿名映射區採用的是新佈局,地址增長方向是從高地址到低地址增長。因此不同的體系結構需要設置 HAVE_ARCH_UNMAPPED_AREA_TOPDOWN 預處理符號,並提供 arch_get_unmapped_area_topdown 函數的實現。mmap 在新佈局下則會通過這裏的 arch_get_unmapped_area_topdown 函數在文件映射與匿名映射區尋找空閒 vma。

arch_get_unmapped_area 和 arch_get_unmapped_area_topdown 函數,內核都會提供默認的實現,不同體系結構如果沒有特殊的定製需求,無需單獨實現。

無論是經典佈局下的 arch_get_unmapped_area,還是新佈局下的 arch_get_unmapped_area_topdown 都會設置到 mm_struct->get_unmapped_area 這個函數指針中,後續 mmap 會利用這個 get_unmapped_area 來在文件映射與匿名映射區中劃分虛擬內存區域 vma。

struct mm_struct {

        unsigned long (*get_unmapped_area) (struct file *filp,
                unsigned long addr, unsigned long len,
                unsigned long pgoff, unsigned long flags);
}

內核通過 mmap_is_legacy 函數來判斷進程虛擬內存空間佈局採用的是經典佈局(返回 1)還是新式佈局(返回 0)。

static int mmap_is_legacy(void)
{
    if (current->personality & ADDR_COMPAT_LAYOUT)
        return 1;

    return sysctl_legacy_va_layout;
}

首先內核會判斷進程 struct task_struct 結構中的 personality 標誌位是否設置為 ADDR_COMPAT_LAYOUT,如果設置了 ADDR_COMPAT_LAYOUT 標誌則表示進程虛擬內存空間佈局應該採用經典佈局。

 #include <sys/personality.h>
 int personality(unsigned long persona);

 struct task_struct {
      // 通過系統調用 personality 設置 task_struct->personality 標誌位
      unsigned int    personality;
 }

task_struct->personality 如果沒有設置 ADDR_COMPAT_LAYOUT,則繼續判斷 sysctl_legacy_va_layout 內核參數的值,如果為 1 則表示採用經典佈局,為 0 則採用新式佈局。

用户可通過設置 /proc/sys/vm/legacy_va_layout 內核參數來指定 sysctl_legacy_va_layout 變量的值。

image

當我們為 mmap 設置好了真正的 mm_struct->get_unmapped_area 函數指針之後,內核會調用 arch_pick_mmap_base 函數來進行具體的文件映射與匿名映射區的佈局工作:

mmap 為進程分配虛擬內存的具體工作由這裏的 get_unmapped_area 負責。
static void arch_pick_mmap_base(unsigned long *base, unsigned long *legacy_base,
        unsigned long random_factor, unsigned long task_size,
        struct rlimit *rlim_stack)
{
    // 對文件映射與匿名映射區進行經典佈局,經典佈局下映射區的起始地址設置在 mm_struct->mmap_legacy_base
    *legacy_base = mmap_legacy_base(random_factor, task_size);
    if (mmap_is_legacy())
        *base = *legacy_base;
    else
        // 對文件映射與匿名映射區進行新佈局,無論在新佈局下還是在經典佈局下
        // 映射區的起始地址最終都會設置在 mm_struct->mmap_base
        *base = mmap_base(random_factor, task_size, rlim_stack);
}

mmap_legacy_base 負責對文件映射與匿名映射區進行經典佈局,經典佈局下,映射區的起始地址設置在 mm_struct->mmap_legacy_base 字段中。

mmap_base 負責對文件映射與匿名映射區進行新式佈局,新佈局下,映射區的起始地址設置在 mm_struct->mmap_base 字段中。

struct mm_struct {
        // 文件映射與匿名映射區的起始地址,無論在經典佈局下還是在新佈局下,起始地址最終都會設置在這裏
        unsigned long mmap_base;    /* base of mmap area */
        // 文件映射與匿名映射區在經典佈局下的起始地址
        unsigned long mmap_legacy_base; /* base of mmap area in bottom-up allocations */
        // 進程虛擬內存空間與內核空間的分界線(也是用户空間的結束地址)
        unsigned long task_size;    /* size of task vm space */
        // 用户空間中,棧頂位置
        unsigned long start_stack;
}

在經典佈局下,文件映射與匿名映射區的起始地址 mmap_legacy_base 被設置為 __TASK_UNMAPPED_BASE,其值為 task_size 的三分之一,也就是説文件映射與匿名映射區起始於進程虛擬內存空間的三分之一處:

image

#define __TASK_UNMAPPED_BASE(task_size)    (PAGE_ALIGN(task_size / 3))

static unsigned long mmap_legacy_base(unsigned long rnd,
                      unsigned long task_size)
{
    return __TASK_UNMAPPED_BASE(task_size) + rnd;
}

如果我們開啓了進程虛擬內存空間的隨機化,全局變量 randomize_va_space 就會為 1,進程的 flags 標誌將會設置為 PF_RANDOMIZE,表示對進程地址空間進行隨機化佈局。

我們可以通過調整內核參數 /proc/sys/kernel/randomize_va_space 的值來開啓或者關閉進程虛擬內存空間佈局隨機化特性。

在開啓進程地址空間隨機化佈局之後,進程虛擬內存空間中的文件映射與匿名映射區起始地址會加上一個隨機偏移 rnd。

事實上,不僅僅文件映射與匿名映射區起始地址會加隨機偏移 rnd,虛擬內存空間中的棧頂位置 STACK_TOP,堆的起始位置 start_brk,BSS 段的起始位置 elf_bss,數據段的起始位置 start_data,代碼段的起始位置 start_code,都會加上一個隨機偏移。

image

static int load_elf_binary(struct linux_binprm *bprm)
{
    // 是否開啓進程地址空間的隨機化佈局
    if (!(current->personality & ADDR_NO_RANDOMIZE) && randomize_va_space)
        current->flags |= PF_RANDOMIZE;
    // 創建文件映射與匿名映射區,設置映射區的起始地址 mm_struct->mmap_base
    setup_new_exec(bprm);
    // 創建棧,設置  mm->start_stack 棧的起始地址(棧底)
    retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
                 executable_stack);
}

內核中通過 arch_rnd 函數來獲取進程地址空間隨機化偏移值:

arch_pick_mmap_base(&mm->mmap_base, &mm->mmap_legacy_base,
            arch_rnd(mmap64_rnd_bits), task_size_64bit(0),
            rlim_stack);

static unsigned long arch_rnd(unsigned int rndbits)
{
    // 關閉進程地址空間隨機化,偏移值就會為 0 
    if (!(current->flags & PF_RANDOMIZE))
        return 0;
    return (get_random_long() & ((1UL << rndbits) - 1)) << PAGE_SHIFT;
}

下面是文件映射與匿名映射區的新式佈局,這裏需要注意的是在新式佈局下,映射區地址的增長方向是從高地址到低地址的,所以這裏映射區的起始地址 mm->mmap_base 位於高地址處,從上往下增長。

image

進程虛擬內存空間中棧頂 STACK_TOP 的位置一般設置為 task_size,也就是説從進程地址空間的末尾開始向下增長,如果開啓地址隨機化特性,STACK_TOP 還需要再加上一個隨機偏移 stack_maxrandom_size。

整個棧空間的最大長度設置在 rlim_stack->rlim_cur 中,在棧區和映射區之間,有一個 1M 大小的間隙 stack_guard_gap。

映射區的起始地址 mmap_base 與進程地址空間末尾 task_size 的間隔為 gap 大小,gap = rlim_stack->rlim_cur + stack_guard_gap。gap 的最小值為 128M,最大值為 (task_size / 6) * 5。

task_size 減去 gap 就是映射區起始地址 mmap_base 的位置,如果啓用地址隨機化特性,還需要在此基礎上減去一個隨機偏移 rnd。

// 棧區與映射區之間的間隔 1M
unsigned long stack_guard_gap = 256UL<<PAGE_SHIFT;

static unsigned long mmap_base(unsigned long rnd, unsigned long task_size,
                   struct rlimit *rlim_stack)
{
    // 棧空間大小
    unsigned long gap = rlim_stack->rlim_cur;
    // 棧區與映射區之間的間隔為 1M 大小,如果開啓了地址隨機化,還會加上一個隨機偏移 stack_maxrandom_size
    unsigned long pad = stack_maxrandom_size(task_size) + stack_guard_gap;
    unsigned long gap_min, gap_max;

    // gap 在這裏的語義是映射區的起始地址 mmap_base 距離進程地址空間的末尾 task_size 的距離
    if (gap + pad > gap)
        gap += pad;

    // gap 的最小值為 128M
    gap_min = SIZE_128M;
    // gap 的最大值
    gap_max = (task_size / 6) * 5;

    if (gap < gap_min)
        gap = gap_min;
    else if (gap > gap_max)
        gap = gap_max;
    // 映射區在新式佈局下的起始地址 mmap_base,如果開啓隨機化,則需要在減去一個隨機偏移 rnd
    return PAGE_ALIGN(task_size - gap - rnd);
}

現在 mmap 的主要工作區域:文件映射與匿名映射區在進程虛擬內存空間中的佈局情況,我們已經清楚了。那麼接下來,筆者會以 AMD64 體系結構的經典佈局為基礎,為大家介紹 mmap 是如何分配虛擬內存的。

4.3 虛擬內存的分配

get_unmapped_area 主要的目的就是在具體的映射區佈局下,根據佈局特點,真正負責劃分虛擬內存區域的函數。經過上一小節的介紹我們知道,在經典佈局下,mm->get_unmapped_area 指向的函數為 arch_get_unmapped_area。

如果 mmap 進行的是私有匿名映射,那麼內核會通過 mm->get_unmapped_area 函數進行虛擬內存的分配。

如果 mmap 進行的是文件映射,那麼內核則採用的是特定於文件系統的 file->f_op->get_unmapped_area 函數。比如,我們通過 mmap 映射的是 ext4 文件系統下的文件,那麼 file->f_op->get_unmapped_area 指向的是 thp_get_unmapped_area 函數,專門為 ext4 文件映射申請虛擬內存。

const struct file_operations ext4_file_operations = {
        .mmap           = ext4_file_mmap
        .get_unmapped_area = thp_get_unmapped_area,
};

如果 mmap 進行的是共享匿名映射,由於共享匿名映射的本質其實是基於 tmpfs 的虛擬文件系統中的匿名文件進行的共享文件映射,所以這種情況下 get_unmapped_area 函數是需要基於 tmpfs 的虛擬文件系統的,在共享匿名映射的情況下 get_unmapped_area 指向 shmem_get_unmapped_area 函數。

unsigned long
get_unmapped_area(struct file *file, unsigned long addr, unsigned long len,
        unsigned long pgoff, unsigned long flags)
{
    // 在進程虛擬空間中尋找還未被映射的 VMA 這段核心邏輯是被內核實現在特定於體系結構的函數中
    // 該函數指針用於指向真正的 get_unmapped_area 函數
    // 在經典佈局下,真正的實現函數為 arch_get_unmapped_area
    unsigned long (*get_area)(struct file *, unsigned long,
                  unsigned long, unsigned long, unsigned long);

    // 映射的虛擬內存區域長度不能超過進程的地址空間
    if (len > TASK_SIZE)
        return -ENOMEM;
    // 如果是匿名映射,則採用 mm_struct 中保存的特定於體系結構的 arch_get_unmapped_area 函數
    get_area = current->mm->get_unmapped_area;
    if (file) {
        // 如果是文件映射話,則需要使用 file->f_op 中的 get_unmapped_area,來為文件映射申請虛擬內存
        // file->f_op 保存的是特定於文件系統中文件的相關操作
        if (file->f_op->get_unmapped_area)
            get_area = file->f_op->get_unmapped_area;
    } else if (flags & MAP_SHARED) {
        // 共享匿名映射是通過在 tmpfs 中創建的匿名文件實現的
        // 所以這裏也有其專有的 get_unmapped_area 函數
        pgoff = 0;
        get_area = shmem_get_unmapped_area;
    }
    
    // 在進程虛擬內存空間中,根據指定的 addr,len 查找合適的VMA
    addr = get_area(file, addr, len, pgoff, flags);
    if (IS_ERR_VALUE(addr))
        return addr;
    // VMA 區域不能超過進程地址空間
    if (addr > TASK_SIZE - len)
        return -ENOMEM;
    // addr 需要與 page size 對齊
    if (offset_in_page(addr))
        return -EINVAL;

    return error ? error : addr;
}

如果我們仔細觀察 ext4 文件系統下的 thp_get_unmapped_area 函數以及 tmpfs 虛擬文件系統下的 shmem_get_unmapped_area,會發現,它們最終都會調用到 mm->get_unmapped_area 函數指針指向的函數。

image


const struct file_operations ext4_file_operations = {
        .mmap           = ext4_file_mmap
        .get_unmapped_area = thp_get_unmapped_area,
};


unsigned long __thp_get_unmapped_area(struct file *filp, unsigned long len,
                loff_t off, unsigned long flags, unsigned long size)
{
        ........... 省略 ........

        addr = current->mm->get_unmapped_area(filp, 0, len_pad,
                                              off >> PAGE_SHIFT, flags);
        return addr;
}
unsigned long shmem_get_unmapped_area(struct file *file,
                      unsigned long uaddr, unsigned long len,
                      unsigned long pgoff, unsigned long flags)
{
    unsigned long (*get_area)(struct file *,
        unsigned long, unsigned long, unsigned long, uns

         ........... 省略 ........

    get_area = current->mm->get_unmapped_area;
    
    return addr;
}

在經典佈局下,mm->get_unmapped_area 指向的是 arch_get_unmapped_area 函數,mmap 虛擬內存分配的秘密就隱藏在這裏:

image

首先我們需要明確一下,mmap 可以映射的虛擬內存範圍必須在進程虛擬內存空間 mmap_min_addr 到 mmap_end 這段地址範圍內,mmap_min_addr 為 TASK_SIZE 的三分之一,mmap_end 為 TASK_SIZE。

內核需要檢查本次 mmap 映射的虛擬內存長度 len 是否超過了規定的映射範圍,如果超過了則返回 ENOMEM 錯誤,並停止映射流程。

如果映射長度 len 在規定的映射地址範圍內,內核則會根據我們指定的映射起始地址 addr,以及映射長度 len,開始在文件映射與匿名映射區,為本次 mmap 映射尋找一段空閒的虛擬內存區域 vma 出來。

#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

如果在 flags 參數中指定了 MAP_FIXED 標誌,則意味着我們強制要求內核在我們指定的起始地址 addr 處開始映射 len 長度的虛擬內存區域,無論這段虛擬內存區域 [addr , addr + len] 是否已經存在映射關係,內核都會強行進行映射,如果這塊區域已經存在映射關係,那麼後續內核會把舊的映射關係覆蓋掉。

image

如果我們指定了 addr,但是並沒有指定 MAP_FIXED,則意味着我們只是建議內核優先考慮從我們指定的 addr 地址處開始映射,但是如果 [addr , addr+len] 這段虛擬內存區域已經存在映射關係,內核則不會按照我們指定的 addr 開始映射,而是會自動查找一段空閒的 len 長度的虛擬內存區域。這一部分的工作由 vm_unmapped_area 函數承擔。

如果通過查找發現, [addr , addr+len] 這段虛擬內存地址範圍並未存在任何映射關係,那麼 addr 就會作為 mmap 映射的起始地址。這裏面會分為兩種情況:

  1. 第一種是我們指定的 addr 比較大,addr 位於文件映射與匿名映射區中所有映射區域 vma 的最後面,這樣一來,[addr , addr + len] 這段地址範圍當然是空閒的了。
  2. 第二種情況是我們指定的 addr 恰好位於一個 vma 和另一個 vma 中間的地址間隙中,並且這個地址間隙剛好大於或者等於我們指定的映射長度 len。內核就可以將這個地址間隙映射起來。

image

// 內核標準實現 
unsigned long
arch_get_unmapped_area(struct file *filp, unsigned long addr,
        unsigned long len, unsigned long pgoff, unsigned long flags)
{
    struct mm_struct *mm = current->mm;
    struct vm_area_struct *vma, *prev;
    struct vm_unmapped_area_info info;
    // 進程虛擬內存空間的末尾 TASK_SIZE
    const unsigned long mmap_end = arch_get_mmap_end(addr);
    // 映射區域長度是否超過進程虛擬內存空間
    if (len > mmap_end - mmap_min_addr)
        return -ENOMEM;
    // 如果我們指定了 MAP_FIXED 表示必須要從我們指定的 addr 開始映射 len 長度的區域
    // 如果這塊區域已經存在映射關係,那麼後續內核會把舊的映射關係覆蓋掉
    if (flags & MAP_FIXED)
        return addr;

    // 沒有指定 MAP_FIXED,但是我們指定了 addr
    // 我們希望內核從我們指定的 addr 地址開始映射,內核這裏會檢查我們指定的這塊虛擬內存範圍是否有效
    if (addr) {
        // addr 先保證與 page size 對齊
        addr = PAGE_ALIGN(addr);
        // 內核這裏需要確認一下我們指定的 [addr, addr+len] 這段虛擬內存區域是否存在已有的映射關係
        // [addr, addr+len] 地址範圍內已經存在映射關係,則不能按照我們指定的 addr 作為映射起始地址
        // 在進程地址空間中查找第一個符合 addr < vma->vm_end  條件的 VMA
        // 如果不存在這樣一個 vma(!vma), 則表示 [addr, addr+len] 這段範圍的虛擬內存是可以使用的,內核將會從我們指定的 addr 開始映射
        // 如果存在這樣一個 vma ,則表示  [addr, addr+len] 這段範圍的虛擬內存區域目前已經存在映射關係了,不能採用 addr 作為映射起始地址
        // 這裏還有一種情況是 addr 落在 prev 和 vma 之間的一塊未映射區域
        // 如果這塊未映射區域的長度滿足 len 大小,那麼這段未映射區域可以被本次使用,內核也會從我們指定的 addr 開始映射
        vma = find_vma_prev(mm, addr, &prev);
        if (mmap_end - len >= addr && addr >= mmap_min_addr &&
            (!vma || addr + len <= vm_start_gap(vma)) &&
            (!prev || addr >= vm_end_gap(prev)))
            return addr;
    }

    // 如果我們明確指定 addr 但是指定的虛擬內存範圍是一段無效的區域或者已經存在映射關係
    // 那麼內核會自動在地址空間中尋找一段合適的虛擬內存範圍出來
    // 這段虛擬內存範圍的起始地址就不是我們指定的 addr 了
    info.flags = 0;
    // VMA 區域長度
    info.length = len;
    // 這裏定義從哪裏開始查找 VMA, 這裏我們會從文件映射與匿名映射區開始查找
    info.low_limit = mm->mmap_base;
    // 查找結束位置為進程地址空間的末尾 TASK_SIZE
    info.high_limit = mmap_end;
    info.align_mask = 0;
    return vm_unmapped_area(&info);
}

4.4 find_vma_prev 查找是否有重疊的映射區域

find_vma_prev 的作用就是根據我們指定的映射起始地址 addr,在進程地址空間中查找出符合 addr < vma->vm_end 條件的第一個 vma 出來(下圖中的藍色部分)。

image

然後在進程地址空間中的 vma 鏈表 mmap 中,找出它的前驅節點 pprev (上圖中的綠色部分)。

struct mm_struct {
    struct vm_area_struct *mmap;  /* list of VMAs */
}

如果不存在這樣一個 vma(addr < vma->vm_end),那麼內核直接從我們指定的 addr 地址處開始映射就好了,這時 pprev 指向進程地址空間中最後一個 vma。

image

如果存在這樣一個 vma,那麼內核就會判斷,該 vma 與其前驅節點 pprev 之間的地址間隙 gap 是否能容納下一段 len 長度的映射區間,如果可以,那麼內核就映射在這個地址間隙 gap 中。如果不可以,內核就需要在 vm_unmapped_area 函數中重新到整個進程地址空間中查找出一個 len 長度的空閒映射區域,這種情況下映射區的起始地址就不是我們指定的 addr 了。

image

struct vm_area_struct *
find_vma_prev(struct mm_struct *mm, unsigned long addr,
            struct vm_area_struct **pprev)
{
    struct vm_area_struct *vma;
    // 在進程地址空間 mm 中查找第一個符合 addr < vma->vm_end 的 VMA
    vma = find_vma(mm, addr);

    if (vma) {
        // 恰好包含 addr 的 VMA 的前一個虛擬內存區域 
        *pprev = vma->vm_prev;
    } else {
        // 如果當前進程地址空間中,addr 不屬於任何一個 VMA 
        // 那麼這裏的 pprev 指向進程地址空間中最後一個 VMA
        struct rb_node *rb_node = rb_last(&mm->mm_rb);

        *pprev = rb_node ? rb_entry(rb_node, struct vm_area_struct, vm_rb) : NULL;
    }
    // 返回查找到的 vma,不存在則返回 null(內核後續會創建 VMA)
    return vma;
}

根據指定地址 addr 在進程地址空間中查找第一個符合 addr < vma->vm_end 條件 vma 的操作在 find_vma 函數中進行,內核為了高效地在進程地址空間中查找特定條件的 vma,會按照地址的增長方向將所有的 vma 組織在一顆紅黑樹 mm_rb 中。

struct mm_struct {
     struct rb_root mm_rb;
}

image

find_vma 會根據我們指定的 addr 在這顆紅黑樹中查找第一個符合 addr < vma->vm_end 條件的 vma 。

/* Look up the first VMA which satisfies  addr < vm_end,  NULL if none. */
struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr)
{
    struct rb_node *rb_node;
    struct vm_area_struct *vma;

    // 進程地址空間中緩存了最近訪問過的 VMA
    // 首先從進程地址空間中 VMA 緩存中開始查找,緩存命中率通常大約為 35%
    // 查找條件為:vma->vm_start <= addr && vma->vm_end > addr
    vma = vmacache_find(mm, addr);
    if (likely(vma))
        return vma;

    // 進程地址空間中的所有 VMA 被組織在一顆紅黑樹中,為了方便內核在進程地址空間中查找特定的 VMA
    // 這裏首先需要獲取紅黑樹的根節點,內核會從根節點開始查找
    rb_node = mm->mm_rb.rb_node;

    while (rb_node) {
        struct vm_area_struct *tmp;
        // 獲取位於根節點的 VMA
        tmp = rb_entry(rb_node, struct vm_area_struct, vm_rb);

        if (tmp->vm_end > addr) {
            vma = tmp;
            // 判斷 addr 是否恰好落在根節點 VMA 中: vm_start <= addr < vm_end
            if (tmp->vm_start <= addr)
                break;
            // 如果不存在,則繼續到左子樹中查找
            rb_node = rb_node->rb_left;
        } else
            // 如果根節點的 vm_end <= addr,説明 addr 在根節點 vma 的後邊
            // 這種情況則到右子樹中繼續查找
            rb_node = rb_node->rb_right;
    }

    if (vma)
        // 更新 vma 緩存
        vmacache_update(addr, vma);
    // 返回查找到的 vma,如果沒有查找到,則返回 Null,表示進程空間中目前還沒有這樣一個 VMA ,後續需要新建了。
    return vma;
}

如果我們找到的這個 vma 與 [addr , addr +len] 這段虛擬地址範圍有重疊的部分,那麼內核就不能按照我們指定的 addr 開始映射,內核需要重新在文件映射與匿名映射區中按照地址的增長方向,找到一段 len 大小的空閒虛擬內存區域。這一部分的邏輯由 vm_unmapped_area 函數承擔。

image

4.5 vm_unmapped_area 尋找未映射的虛擬內存區域

/*
 * Search for an unmapped address range.
 *
 * We are looking for a range that:
 * - does not intersect with any VMA;
 * - is contained within the [low_limit, high_limit) interval;
 * - is at least the desired size.
 * - satisfies (begin_addr & align_mask) == (align_offset & align_mask)
 */
static inline unsigned long
vm_unmapped_area(struct vm_unmapped_area_info *info)
{
    // 按照進程虛擬內存空間中文件映射與匿名映射區的地址增長方向
    // 分為兩個函數,來在進程地址空間中查找未映射的 VMA
    if (info->flags & VM_UNMAPPED_AREA_TOPDOWN)
        // 當文件映射與匿名映射區的地址增長方向是從上到下逆向增長時(新式佈局)
        // 採用 topdown 後綴的函數查找
        return unmapped_area_topdown(info);
    else
        // 地址增長方向為從下倒上正向增長(經典佈局),採用該函數查找
        return unmapped_area(info);
}

本文是以 AMD64 體系為例展開討論的,在 AMD64 體系結構下,文件映射與匿名映射區的佈局採用的是經典佈局,地址的增長方向從低地址到高地址增長。因此這裏我們選擇 unmapped_area 函數。

image

我們苦苦尋找的 unmapped_area 一定是在文件映射與匿名映射區中某個 vma 與其前驅 vma 之間的地址間隙 gap 中產生的。

image

所以這就要求這個 gap 的長度必須大於等於映射 length,這樣才能容納下我們要映射的長度。gap 的起始地址 gap_start 一般從 prev 節點的末尾開始:gap_start = vma->vm_prev->vm_end 。gap 的結束地址 gap_end 一般從 vma 的起始地址結束:gap_end = vma->vm_start 。

在此基礎之上,gap 還會受到 low_limit(mm->mmap_base)和 high_limit(TASK_SIZE)的地址限制。

因此這個 gap 的起始地址 gap_start 不能高於 high_limit - length,否則我們從 gap_start 地址處開始映射長度 length 的區域就會超出 high_limit 的限制。

gap 的結束地址 gap_end 不能低於 low_limit + length,否則映射區域的起始地址就會低於 low_limit 的限制。

unmapped_area 函數的核心任務就是在管理進程地址空間這些 vma 的紅黑樹 mm_struct-> mm_rb 中找到這樣的一個地址間隙 gap 出來。

image

首先內核會從紅黑樹中的根節點 vma 開始查找,判斷根節點的 vma 與其前驅節點 vma->vm_prev 之間的地址間隙 gap 是否滿足上述條件,如果根節點 vma 的起始地址 vma->vm_start 也就是 gap_end 低於了 low_limit + length 的限制,那就説明根節點 vma 與其前驅節點之間的 gap 不適合用來作為 unmapped_area,否則 unmapped_area 的起始地址 gap_start 就會低於 low_limit 的限制。

image

由於紅黑樹是按照 vma 的地址增長方向來組織的,左子樹中的所有 vma 地址都低於根節點 vma 的地址,右子樹的所有 vma 地址均高於根節點 vma 的地址。

現在的情況是 vma->vm_start 的地址太低了,已經小於了 low_limit + length 的限制,所以左子樹的 vma 就不用看了,直接從右子樹中去查找。

如果根節點 vma 的起始地址 vma->vm_start 也就是 gap_end 高於 low_limit + length 的要求,説明 gap_end 是符合我們的要求的,但是目前我們還不能馬上對 gap_start 的限制要求進行檢查,因為我們需要按照地址從低到高的優先級來查看最合適的 unmapped_area 未映射區域,所以我們需要到左子樹中去查找地址更低的 vma。

如果我們在左子樹中找到了一個地址最低的 vma,並且這個 vma 與其前驅節點vma->vm_prev 之間的地址間隙 gap 符合上述的三個條件:

  1. gap 的長度大於等於映射長度 length : gap_end - gap_start >= length
  2. gap_end >= low_limit + length 。
  3. gap_start <= high_limit - length。

image

這裏內核還有一個小小的優化點,如果我們遍歷完了當前 vma 節點的所有子樹(包括左子樹和右子樹)依然無法找到一個 gap 的長度可以滿足我們的映射長度: gap_end - gap_start < length。那我們不是白白遍歷了整棵樹嗎?

能否有一種機制,使我們通過當前 vma 就可以知道其子樹中的所有 vma 節點與其前驅節點 vma->vm_prev 之間的地址間隙 gap 的最大長度(包括當前 vma)。

這樣我們在遍歷一個 vma 節點的時候,只需要檢查一下其左右子樹中的最大 gap 長度是否能夠滿足映射長度 length ,如果不能滿足,説明整棵樹中的 vma 節點與其前驅節點之間的間隙都不能容納我們要映射的長度,直接就不用遍歷了。

事實上,內核會將一個 vma 節點以及它所有子樹中存在的最大間隙 gap 保存在 struct vm_area_struct 結構中的 rb_subtree_gap 屬性中:

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. */

    /* linked list of VM areas per task, sorted by address */
    struct vm_area_struct *vm_next, *vm_prev;

    struct rb_node vm_rb;

    // 在當前 vma 的紅黑樹左右子樹中的所有節點 vma (包括當前 vma)
    // 這個集合中的 vma 與其 vm_prev 之間最大的虛擬內存地址 gap (單位字節)保存在 rb_subtree_gap 字段中
    unsigned long rb_subtree_gap;
}

當我們遍歷 vma 節點的時候發現:vma->rb_subtree_gap < length。那麼整棵紅黑樹都不需要看了,我們直接從進程地址空間中最後一個 vma->vm_end 處開始映射就好了。

當前進程虛擬內存空間中,地址最高的一個 VMA 的結束地址位置保存在 mm_struct 結構中的 highest_vm_end 屬性中:

struct mm_struct {
    // 當前進程虛擬內存空間中,地址最高的一個 VMA 的結束地址位置
    unsigned long highest_vm_end;   /* highest vma end address */
}

以上就是內核在文件映射與匿名映射區尋找 unmapped_area 的核心邏輯,我們明白了這些,在看源碼就會清晰很多了:

unsigned long unmapped_area(struct vm_unmapped_area_info *info)
{
    /*
     * We implement the search by looking for an rbtree node that
     * immediately follows a suitable gap. That is,
     * - gap_start = vma->vm_prev->vm_end <= info->high_limit - length;
     * - gap_end   = vma->vm_start        >= info->low_limit  + length;
     * - gap_end - gap_start >= length
     */

    struct mm_struct *mm = current->mm;
    // 尋找未映射區域的參考 vma (該區域以存在映射關係)
    struct vm_area_struct *vma;
    // 未映射區域產生在 vma->vm_prev 與 vma 這兩個虛擬內存區域中的間隙 gap 中
    // length 表示本次映射區域的長度
    // low_limit ,high_limit 表示在進程地址空間中哪段地址範圍內查找,一個地址下限(mm->mmap_base),另一個標識地址上限(TASK_SIZE)
    // gap_start, gap_end 表示 vma->vm_prev 與 vma 之間的 gap 範圍,unmapped_area 將會在這裏產生
    unsigned long length, low_limit, high_limit, gap_start, gap_end;

    // gap_start 需要滿足的條件:gap_start =  vma->vm_prev->vm_end <= info->high_limit - length
    // 否則 unmapped_area 將會超出 high_limit 的限制
    high_limit = info->high_limit - length;

    // gap_end 需要滿足的條件:gap_end = vma->vm_start >= info->low_limit + length
    // 否則 unmapped_area 將會超出 low_limit 的限制
    low_limit = info->low_limit + length;

    // 首先將 vma 紅黑樹的根節點作為 gap 的參考 vma
    if (RB_EMPTY_ROOT(&mm->mm_rb))
        // 'empty' nodes are nodes that are known not to be inserted in an rbtree
        goto check_highest;
    // 獲取紅黑樹根節點的 vma
    vma = rb_entry(mm->mm_rb.rb_node, struct vm_area_struct, vm_rb);

    // rb_subtree_gap 為當前 vma 及其左右子樹中所有 vma 與其對應 vm_prev 之間最大的虛擬內存地址 gap
    // 最大的 gap 如果都不能滿足映射長度 length 則跳轉到 check_highest 處理
    if (vma->rb_subtree_gap < length)
        // 從進程地址空間最後一個 vma->vm_end 地址處開始映射
        goto check_highest;

    while (true) {
        // 獲取當前 vma 的 vm_start 起始虛擬內存地址作為 gap_end
        gap_end = vm_start_gap(vma);
        // gap_end 需要滿足:gap_end >= low_limit,否則 unmapped_area 將會超出 low_limit 的限制
        // 如果存在左子樹,則需要繼續到左子樹中去查找,因為我們需要按照地址從低到高的優先級來查看合適的未映射區域
        if (gap_end >= low_limit && vma->vm_rb.rb_left) {
            struct vm_area_struct *left =
                rb_entry(vma->vm_rb.rb_left,
                     struct vm_area_struct, vm_rb);
            // 如果左子樹中存在合適的 gap,則繼續左子樹的查找
            // 否則查找結束,gap 為當前 vma 與其 vm_prev 之間的間隙    
            if (left->rb_subtree_gap >= length) {
                vma = left;
                continue;
            }
        }
        // 獲取當前 vma->vm_prev 的 vm_end 作為 gap_start
        gap_start = vma->vm_prev ? vm_end_gap(vma->vm_prev) : 0;
check_current:
        // gap_start 需要滿足:gap_start <= high_limit,否則 unmapped_area 將會超出 high_limit 的限制
        if (gap_start > high_limit)
            return -ENOMEM;

        if (gap_end >= low_limit &&
            gap_end > gap_start && gap_end - gap_start >= length)
            // 找到了合適的 unmapped_area 跳轉到 found 處理
            goto found;

       // 當前 vma 與其左子樹中的所有 vma 均不存在一個合理的 gap
       // 那麼從 vma 的右子樹中繼續查找
        if (vma->vm_rb.rb_right) {
            struct vm_area_struct *right =
                rb_entry(vma->vm_rb.rb_right,
                     struct vm_area_struct, vm_rb);
            if (right->rb_subtree_gap >= length) {
                vma = right;
                continue;
            }
        }

        // 如果在當前 vma 以及它的左右子樹中均無法找到一個合適的 gap
        // 那麼這裏會從當前 vma 節點向上回溯整顆紅黑樹,在它的父節點中嘗試查找是否有合適的 gap
        // 因為這時候有可能會有新的 vma 插入到紅黑樹中,可能會產生新的 gap
        while (true) {
            struct rb_node *prev = &vma->vm_rb;
            if (!rb_parent(prev))
                goto check_highest;
            vma = rb_entry(rb_parent(prev),
                       struct vm_area_struct, vm_rb);
            if (prev == vma->vm_rb.rb_left) {
                gap_start = vm_end_gap(vma->vm_prev);
                gap_end = vm_start_gap(vma);
                goto check_current;
            }
        }
    }

check_highest:
    // 流程走到這裏表示在當前進程虛擬內存空間的所有 VMA 中都無法找到一個合適的 gap 來作為 unmapped_area
    // 那麼就從進程地址空間中最後一個 vma->vm_end 開始映射
    // mm->highest_vm_end 表示當前進程虛擬內存空間中,地址最高的一個 VMA 的結束地址位置
    gap_start = mm->highest_vm_end;
    gap_end = ULONG_MAX;  /* Only for VM_BUG_ON below */
    // 這裏最後需要檢查剩餘虛擬內存空間是否滿足映射長度
    if (gap_start > high_limit)
        // ENOMEM 表示當前進程虛擬內存空間中虛擬內存不足
        return -ENOMEM;

found:
    // 流程走到這裏表示我們已經找到了一個合適的 gap 來作為 unmapped_area 
    // 直接返回 gap_start (需要與 4K 對齊)作為映射的起始地址
    /* We found a suitable gap. Clip it with the original low_limit. */
    if (gap_start < info->low_limit)
        gap_start = info->low_limit;

    /* Adjust gap address to the desired alignment */
    gap_start += (info->align_offset - gap_start) & info->align_mask;

    VM_BUG_ON(gap_start + info->length > info->high_limit);
    VM_BUG_ON(gap_start + info->length > gap_end);
    return gap_start;
}

5. 內存映射的本質

流程走到這裏,我們就來到了 mmap 系統調用最為核心的部分了,在之前的內容中,內核已經通過 get_unmapped_area 函數為我們在進程地址空間中挑選出一段地址範圍為 [addr , addr + len] 的虛擬內存區域供 mmap 進行映射。

注意:這裏的 addr 並不一定是我們指定的映射起始地址。

image

現在我們只是確定了 [addr , addr + len] 這段虛擬內存區域是可以映射的,這段區域只是被內核先劃分出來了,但是還未分配出去,在 mmap_region 函數中,需要為這段虛擬內存區域分配 vma 結構,並根據映射方式對 vma 進行初始化,這樣這段虛擬內存才算真正的被分配給了進程。

而在進程虛擬內存空間中允許被映射的虛擬內存總量是有限制的,所以在 mmap_region 開始分配虛擬內存之前,內核需要通過 may_expand_vm 檢查本次需要映射的虛擬內存頁數 len >> PAGE_SHIFT 是否已經超過了進程地址空間中可以被映射的虛擬內存總量限制。

如果未超過,則內核可以順利的進行後續的內存映射流程,如果已經超過,內核則需近一步考慮能否消減一下不必要的虛擬內存用量。那麼什麼可以算作是不必要的虛擬內存用量呢?

#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

比如,我們在 mmap 系統調用的 flags 參數中指定了 MAP_FIXED,強制內核從我們指定的 addr 地址處開始映射。

image

這樣一來,[addr , addr + len] 這段範圍的虛擬內存就會有很大的可能與現有虛擬內存映射區 vma(上圖中藍色部分)發生重疊,因為這裏我們指定的是強制映射 MAP_FIXED,所以內核會將這部分重疊的部分通過 do_munmap 函數先解除映射,然後建立新的映射關係,效果就是將這部分重疊的虛擬內存覆蓋掉了。

由於這部分重疊的虛擬內存部分是之前已經分配出去的,本次映射不需要再重新申請,所以真實虛擬內存的用量需要減去這部分重疊的部分。

內核通過 count_vma_pages_range 函數計算出這部分重疊的虛擬內存頁個數,然後用本次申請的虛擬內存頁個數 len >> PAGE_SHIFT 減去重疊的頁數就是本次映射真實的虛擬內存用量。

最後重新通過 may_expand_vm 函數判斷是否超過進程地址空間中可以被映射的虛擬內存總量限制,如果依然超過,則返回 ENOMEM 異常。如果沒有超過,則正式進入虛擬內存分配的流程。

説到虛擬內存的分配,我們不由的會想到進程的虛擬內存空間,每個進程的虛擬內存空間都是獨立的,而且虛擬內存空間的容量非常巨大,在 64 位系統中進程的虛擬內存空間為 128T,在這麼巨大的虛擬內存空間下申請虛擬內存,我們想當然的會認為,進程可以隨意申請,隨意折騰。

image

理論上是這樣,但是事實上,虛擬內存説到底最終還是要映射到物理內存上的,背後需要物理內存作為支撐,如果進程申請的虛擬內存遠遠超過物理內存大小,那麼在運行的過程中就會導致部分內存被 swap 來 swap 去,甚至頻繁的發生 oom,導致性能下降嚴重。

進程申請虛擬內存的過程就好比我們向銀行貸款一樣,進程的虛擬內存空間好比是現實中的銀行,虛擬內存空間中的虛擬內存非常龐大,銀行裏的錢也非常多,但這並不意味着我們要多少銀行就會貸給我們多少,銀行需要對我們的資產進行審計,我們的資產越多,銀行給我們貸款也會越多,我們的資產越少,銀行給我們的貸款也越少。

同樣的道理,內核也會對進程申請的虛擬內存進行審計(account),物理內存空間越大,swap 交換區越大,進程能能夠申請到的虛擬內存也就越多。內核對虛擬內存申請的審計(account)策略就是我們前面提到的 overcommit_memory 策略,後面的相關章節筆者會詳細的介紹,這裏大家只需要知道內核的這個 overcommit_memory 策略會影響到進程申請虛擬內存大小。

內核通過 accountable_mapping 函數來判斷是否需要對進程申請的虛擬內存進行審計,這就好比我們去銀行貸款,如果客户的信用值一般,銀行就需要對客户進行審計,如果客户端的信用值很高,資產優質,那麼銀行就不需要對客户的貸款進行審計。進程對虛擬內存的申請也是一樣。

如果需要對虛擬內存進行審計,那麼內核接着會調用 security_vm_enough_memory_mm 函數根據 overcommit_memory 策略判斷是否允許進程申請這麼多的虛擬內存,如果不通過,則返回 ENOMEM 停止虛擬內存申請流程。如果通過則將虛擬內存分配給進程。

內核為進程分配虛擬內存的本質其實就是在進程的虛擬內存空間中,找出一段未被映射的空閒虛擬內存地址範圍 [addr , addr + len],就像之前介紹的 get_unmapped_area 函數那樣。

image

然後再 mmap_region 函數中為這段空閒的虛擬內存地址範圍 [addr , addr + len],創建 vma 結構,並初始化 vma 相關的屬性。然後將這個 vma 結構插入到進程的虛擬內存空間中。

內核為了精細化的控制內存的開銷,避免創建沒有必要的 vma 結構,內核會本着能省則省的原則,在創建新的 vma 之前,按照最大程度合併的原則,內核會嘗試看能不能將當前尋找出來的空閒虛擬內存區域 [addr , addr + len] 與其前一個 vma 以及後一個 vma 進行合併,然後重新調整合並後的 vma 相關屬性,比如:vm_start , vm_end , vm_pgoff,以及涉及到相關數據結構的改變。這樣一來,內核就不需要為這段空閒虛擬內存創建新的 vma 了。

如果不能合併,內核則只能從 slab 緩存中拿出一個 vma 結構來描述這段虛擬內存地址範圍 [addr , addr + len]。並根據 mmap 映射的這段虛擬內存區域屬性初始化 vma 結構中的相關字段。

    vma->vm_start = addr;
    vma->vm_end = addr + len;
    vma->vm_flags = vm_flags;
    vma->vm_page_prot = vm_get_page_prot(vm_flags);
    vma->vm_pgoff = pgoff;

如果 mmap 進行的是文件映射,那麼這裏內核會將映射的文件與虛擬映射區關聯起來。

vma->vm_file = get_file(file);

然後內核會通過 call_mmap 函數,將虛擬內存的相關操作函數映射成文件相關的操作函數,大家或多或少在網上看到過這樣的論述——" 通過內存文件映射可以將磁盤上的文件映射到內存中,這樣我們就可以通過讀寫內存來完成磁盤文件的讀寫 ",其實本質就在 call_mmap 函數中,因為經過該函數處理之後,虛擬內存相關的操作函數已經變成文件相關的操作函數了。

struct vm_area_struct {

    struct file * vm_file;      /* File we map to (can be NULL). */

    /* Function pointers to deal with this struct. */
    const struct vm_operations_struct *vm_ops;
}

struct vm_operations_struct {

    vm_fault_t (*fault)(struct vm_fault *vmf);

    void (*map_pages)(struct vm_fault *vmf,
            pgoff_t start_pgoff, pgoff_t end_pgoff);

    vm_fault_t (*page_mkwrite)(struct vm_fault *vmf);
}

我們接着來看 call_mmap 函數,mmap 文件映射的本質就在這裏:

static inline int call_mmap(struct file *file, struct vm_area_struct *vma)
{
    return file->f_op->mmap(file, vma);
}

內核將文件相關的操作全部定義在 struct file 結構中的 f_op 屬性中:

struct file {
    const struct file_operations  *f_op;
}

文件的操作與其所在的文件系統是緊密相關的,在 ext4 文件系統中,相關文件的 file->f_op 指向 ext4_file_operations 操作集合:

const struct file_operations ext4_file_operations = {
    .mmap        = ext4_file_mmap,
};

其中 file->f_op->mmap 函數專門用於文件與內存的映射,在這裏內核將 vm_area_struct 的內存操作 vma->vm_ops 設置為文件系統的操作 ext4_file_vm_ops,當通過 mmap 將內存與文件映射起來之後,讀寫內存其實就是讀寫文件系統的本質就在這裏。

static int ext4_file_mmap(struct file *file, struct vm_area_struct *vma)
{
        ........ 省略 ........
        
      vma->vm_ops = &ext4_file_vm_ops;
      
        ........ 省略 ........    
}
static const struct vm_operations_struct ext4_file_vm_ops = {
    .fault      = ext4_filemap_fault,
    .map_pages  = filemap_map_pages,
    .page_mkwrite   = ext4_page_mkwrite,
};

image

如果 mmap 進行的是共享匿名映射,父子進程之間需要依賴 tmpfs 文件系統中的匿名文件對共享內存進行訪問,當進行共享匿名映射的時候,內核會在 shmem_zero_setup 函數中,到 tmpfs 文件系統裏為映射創建一個匿名文件(shmem_kernel_file_setup),隨後將 tmpfs 文件系統中的這個匿名文件與虛擬映射區 vma 中的 vm_file 關聯映射起來,當然了,vma->vm_ops 也需要映射成 shmem_vm_ops。

當父進程調用 fork 創建子進程的時候,內核會將父進程的虛擬內存空間全部拷貝給子進程,包括這裏創建的共享匿名映射區域 vma,這樣一來,父子進程就可以通過共同的 vma->vm_file 來實現共享內存的通信了。

這裏可以看出 mmap 的共享匿名映射其實本質上還是共享文件映射,只不過這個文件比較特殊,創建於 dev/zero 目錄下的 tmpfs 文件系統中。
int shmem_zero_setup(struct vm_area_struct *vma)
{
    struct file *file;
    loff_t size = vma->vm_end - vma->vm_start;
    // tmpfs 中獲取一個匿名文件
    file = shmem_kernel_file_setup("dev/zero", size, vma->vm_flags);
    if (IS_ERR(file))
        return PTR_ERR(file);

    if (vma->vm_file)
        // 如果 vma 中已存在其他文件,則解除與其他文件的映射關係
        fput(vma->vm_file);
    
    // 將 tmpfs 中的匿名文件映射進虛擬內存區域 vma 中
    // 後續 fork 子進程的時候,父子進程就可以通過這個匿名文件實現共享匿名映射 
    vma->vm_file = file;
    // 對這塊共享匿名映射區相關操作這裏直接映射成 shmem_vm_ops
    vma->vm_ops = &shmem_vm_ops;

    return 0;
}

static const struct vm_operations_struct shmem_vm_ops = {
    .fault        = shmem_fault,
    .map_pages    = filemap_map_pages,
#ifdef CONFIG_NUMA
    .set_policy     = shmem_set_policy,
    .get_policy     = shmem_get_policy,
#endif
};

如果 mmap 這裏進行的是私有匿名映射的話,情況就變得簡單了,由於私有匿名映射並不涉及到與文件之間的映射,所以只需要簡單的將 vma->vm_ops 設置為 null 即可。

流程走到這裏,本次 mmap 映射所產生的虛擬內存區域 vma 結構就被初始化好了,整個內存映射的核心工作就此完成了,剩下要做的事情就是將這個 vma 結構插入到進程虛擬內存空間中。

經過前面的介紹我們知道,在進程的虛擬內存空間中,所有的 vma 結構是被兩種數據結構來組織管理的。一種是 mm_struct->mmap 指向的鏈表結構,另一種是 mm_struct->mm_rb 指向的紅黑樹結構。

image

vma_link 要做的工作就是按照虛擬內存地址的增長方向,將本次映射產生的 vma 結構插入到進程地址空間這兩個數據結構中。

static void vma_link(struct mm_struct *mm, struct vm_area_struct *vma,
            struct vm_area_struct *prev, struct rb_node **rb_link,
            struct rb_node *rb_parent)
{
    // 文件 page cache
    struct address_space *mapping = NULL;

    if (vma->vm_file) {
        // 獲取映射文件的 page cache
        mapping = vma->vm_file->f_mapping;
        i_mmap_lock_write(mapping);
    }
    // 將 vma 插入到地址空間中的 vma 鏈表 mm_struct->mmap 以及紅黑樹 mm_struct->mm_rb 中
    __vma_link(mm, vma, prev, rb_link, rb_parent);
    // 建立文件與 vma 的反向映射
    __vma_link_file(vma);

    if (mapping)
        i_mmap_unlock_write(mapping);

    // map_count 表示進程地址空間中 vma 的個數
    mm->map_count++;
    validate_mm(mm);
}

除此之外,vma_link 還做了一項重要工作,就是通過 __vma_link_file 函數建立文件與虛擬內存區域 vma (所有進程)的反向映射關係。説起反向映射,筆者在之前的文章 《一步一圖帶你深入理解 Linux 物理內存管理》 中的 “6.1 匿名頁的反向映射” 小節中為大家介紹過關於匿名頁的反向映射過程,感興趣的同學可以回看下。

匿名頁的反向映射還是相對比較複雜的,文件頁的反向映射就很簡單了,在之前的文章中筆者曾介紹過,struct file 結構中的 f_maping 屬性指向了一個非常重要的數據結構 struct address_space。

struct address_space {
    struct inode        *host;      /* owner: inode, block_device */
    // page cache
    struct radix_tree_root  i_pages;    /* cached pages */
    atomic_t        i_mmap_writable;/* count VM_SHARED mappings */
    // 文件與 vma 反向映射的核心數據結構,i_mmap 也是一顆紅黑樹
    // 在所有進程的地址空間中,只要與該文件發生映射的 vma 均掛在 i_mmap 中
    struct rb_root_cached   i_mmap;     /* tree of private and shared mappings */
}

struct address_space 結構中有兩個非常重要的屬性,其中一個是 i_pages ,它指向了我們熟悉的 page cache。另一個就是 i_mmap,它指向的是一顆紅黑樹,這顆紅黑樹正是文件頁反向映射的核心數據結構,反向映射關係就保存在這裏。

image

我們知道,一個文件可以被多個進程一起映射,這樣一來在每個進程的地址空間 mm_struct 結構中都會有一個 vma 結構來與這個文件進行映射,與該文件發生映射關係的所有進程地址空間中的 vma 就掛在 address_space-> i_mmap 這顆紅黑樹中,通過它,我們可以找到所有與該文件進行映射的進程。

__vma_link_file 函數建立文件頁反向映射的核心其實就是將 mmap 映射出的這個 vma 插入到這顆紅黑樹中。

static void __vma_link_file(struct vm_area_struct *vma)
{
    struct file *file;

    file = vma->vm_file;
    if (file) {
        struct address_space *mapping = file->f_mapping;
        // address_space->i_mmap 也是一顆紅黑樹,上面掛着的是與該文件映射的所有 vma(所有進程地址空間)
        // 這裏將 vma 插入到 i_mmap 中,實現文件與 vma 的反向映射
        vma_interval_tree_insert(vma, &mapping->i_mmap);
    }
}

好了,mmap 內存映射最為核心的部分,到這裏筆者就為大家介紹完了,映射原理我們清楚了,接下來我們跟着這副 mmap_region 流程圖,來看源碼實現就很清晰了:

image

unsigned long mmap_region(struct file *file, unsigned long addr,
        unsigned long len, vm_flags_t vm_flags, unsigned long pgoff,
        struct list_head *uf)
{
    struct mm_struct *mm = current->mm;
    struct vm_area_struct *vma, *prev;
    int error;
    struct rb_node **rb_link, *rb_parent;
    unsigned long charged = 0;

    // 檢查本次映射是否超過了進程虛擬內存空間中的虛擬內存容量的限制,超過則返回 false
    if (!may_expand_vm(mm, vm_flags, len >> PAGE_SHIFT)) {
        unsigned long nr_pages;

        // 如果 mmap 指定了 MAP_FIXED,表示內核必須要按照用户指定的映射區來進行映射
        // 這種情況下就會導致,我們指定的映射區[addr, addr + len] 有一部分可能與現有映射重疊
        // 內核將會覆蓋掉這段已有的映射,重新按照用户指定的映射關係進行映射
        // 所以這裏需要計算進程地址空間中與指定映射區[addr, addr + len]重疊的虛擬內存頁數 nr_pages
        nr_pages = count_vma_pages_range(mm, addr, addr + len);
        // 由於這裏的 nr_pages 表示重疊的虛擬內存部分,將會被覆蓋,所以這部分被覆蓋的虛擬內存不需要額外申請
        // 這裏通過 len >> PAGE_SHIFT 減去這段可以被覆蓋的 nr_pages 在重新檢查是否超過虛擬內存相關區域的限額
        if (!may_expand_vm(mm, vm_flags,
                    (len >> PAGE_SHIFT) - nr_pages))
            return -ENOMEM;
    }

   // 如果當前進程地址空間中存在於指定映射區域 [addr, addr + len] 重疊的部分
   // 則調用  do_munmap 將這段重疊的映射部分解除掉,後續會重新映射這部分
    while (find_vma_links(mm, addr, addr + len, &prev, &rb_link,
                  &rb_parent)) {
        if (do_munmap(mm, addr, len, uf))
            return -ENOMEM;
    }
   
    /*
     * 判斷將來是否會為這段虛擬內存 vma ,申請新的物理內存,比如 私有,可寫(private writable)的映射方式,內核將來會通過 cow 重新為其分配新的物理內存。
     * 私有,只讀(private readonly)的映射方式,內核則會共享原來映射的物理內存,而不會申請新的物理內存。
     * 如果將來需要申請新的物理內存則會根據當前系統的 overcommit 策略以及當前物理內存的使用情況來  
     * 綜合判斷是否允許本次虛擬內存的申請。如果虛擬內存不足,則返回 ENOMEM,這樣的話可以防止缺頁的時候發生 OOM
     */
    if (accountable_mapping(file, vm_flags)) {
        charged = len >> PAGE_SHIFT;
        // 根據內核 overcommit 策略以及當前物理內存的使用情況綜合判斷,是否能夠通過本次虛擬內存的申請
        // 虛擬內存的申請一旦這裏通過之後,後續發生缺頁,內核將會有足夠的物理內存為其分配,不會發生 OOM
        if (security_vm_enough_memory_mm(mm, charged))
            return -ENOMEM;
        // 凡是設置了 VM_ACCOUNT 的 VMA,表示這段虛擬內存均已經過 vm_enough_memory 的檢測
        // 當虛擬內存發生缺頁的時候,內核會有足夠的物理內存分配,而不會導致 OOM 
        // 其虛擬內存的用量都會被統計在 /proc/meminfo 的 Committed_AS  字段中    
        vm_flags |= VM_ACCOUNT;
    }

    // 為了精細化的控制內存的開銷,內核這裏首先需要嘗試看能不能和地址空間中已有的 vma 進行合併
    // 嘗試將當前 vma 合併到已有的 vma 中
    vma = vma_merge(mm, prev, addr, addr + len, vm_flags,
            NULL, file, pgoff, NULL, NULL_VM_UFFD_CTX);
    if (vma)
        // 如果可以合併,則虛擬內存分配過程結束
        goto out;

    // 如果不可以合併,則只能從 slab 中取出一個新的 vma 結構來
    vma = vm_area_alloc(mm);
    if (!vma) {
        error = -ENOMEM;
        goto unacct_error;
    }
    // 根據我們要映射的虛擬內存區域屬性初始化 vma 結構中的相關字段
    vma->vm_start = addr;
    vma->vm_end = addr + len;
    vma->vm_flags = vm_flags;
    vma->vm_page_prot = vm_get_page_prot(vm_flags);
    vma->vm_pgoff = pgoff;

    // 文件映射
    if (file) {
        // 將文件與虛擬內存映射起來
        vma->vm_file = get_file(file);
        // 這一步中將虛擬內存區域 vma 的操作函數 vm_ops 映射成文件的操作函數(和具體文件系統有關)
        // ext4 文件系統中的操作函數為 ext4_file_vm_ops
        // 從這一刻開始,讀寫內存就和讀寫文件是一樣的了
        error = call_mmap(file, vma);
        if (error)
            goto unmap_and_free_vma;

        addr = vma->vm_start;
        vm_flags = vma->vm_flags;
    } else if (vm_flags & VM_SHARED) {
        // 這裏處理共享匿名映射
        // 前面提到共享匿名映射依賴於 tmpfs 文件系統中的匿名文件
        // 父子進程通過這個匿名文件進行通訊
        // 該函數用於在 tmpfs 中創建匿名文件,並映射進當前共享匿名映射區 vma 中
        error = shmem_zero_setup(vma);
        if (error)
            goto free_vma;
    } else {
        // 這裏處理私有匿名映射
        // 將  vma->vm_ops 設置為 null,只有文件映射才需要 vm_ops 這樣才能將內存與文件映射起來
        vma_set_anonymous(vma);
    }
    // 將當前 vma 按照地址的增長方向插入到進程虛擬內存空間的 mm_struct->mmap 鏈表以及mm_struct->mm_rb 紅黑樹中
    // 並建立文件與 vma 的反向映射
    vma_link(mm, vma, prev, rb_link, rb_parent);

    file = vma->vm_file;
out:
    // 更新地址空間 mm_struct 中的相關統計變量
    vm_stat_account(mm, vm_flags, len >> PAGE_SHIFT);
    return addr;
}

5.1 may_expand_vm 檢查映射的虛擬內存是否超過了內核限制

進程地址空間中對虛擬內存的用量是有限制的,限制分為兩個方面:

  1. 對進程地址空間中能夠映射的虛擬內存頁總數做出限制。
  2. 對進程地址空間中數據區的虛擬內存頁總數做出限制。

這裏的數據區,在內核中定義的是所有私有,可寫的虛擬內存區域(棧區除外):

/*
 * Data area - private, writable, not stack
 */
static inline bool is_data_mapping(vm_flags_t flags)
{
    // 本次需要映射的虛擬內存區域是否是私有,可寫的(數據區)
    return (flags & (VM_WRITE | VM_SHARED | VM_STACK)) == VM_WRITE;
}

以上兩個方面的限制,我們可以通過修改 /etc/security/limits.conf 文件進行調整。

image

內核對進程地址空間中相關區域的虛擬內存用量限制依然保存在 task_struct->signal_struct->rlim 數組中,我們可以通過 RLIMIT_AS 以及 RLIMIT_DATA 下標進行訪問。

// 進程地址空間中允許映射的虛擬內存總量,單位為字節
# define RLIMIT_AS        9    /* address space limit */
// 進程地址空間中允許用於私有可寫(private,writable)的虛擬內存總量,單位字節
# define RLIMIT_DATA        2    /* max data size */

當前進程地址空間中已經映射的虛擬內存頁數保存在 mm_struct->total_vm 中,數據區(私有,可寫)已經映射的虛擬內存頁數保存在 mm_struct->data_vm 中。

struct mm_struct {
    // 進程地址空間中所有已經映射的虛擬內存頁總數
    unsigned long total_vm;    /* Total pages mapped */
    // 進程地址空間中所有私有,可寫的虛擬內存頁總數
    unsigned long data_vm;     /* VM_WRITE & ~VM_SHARED & ~VM_STACK */
}

may_expand_vm 函數的核心邏輯就是判斷經過本次 mmap 映射之後(mmap 需要映射的虛擬內存頁數為 npages),mm->total_vm + npages 是否超過了 rlimit(RLIMIT_AS) 中的限制,mm->data_vm + npages 是否超過了 rlimit(RLIMIT_DATA) 中的限制。如果超過,那麼本次 mmap 內存映射流程在這裏就會停止進行。

// 檢查本次映射是否超過了進程虛擬內存空間中的虛擬內存總量的限制,超過則返回 false
bool may_expand_vm(struct mm_struct *mm, vm_flags_t flags, unsigned long npages)
{
    // mm->total_vm 表示當前進程地址空間中映射的虛擬內存頁總數
    // npages 表示此次要映射的虛擬內存頁個數
    // rlimit(RLIMIT_AS) 表示進程地址空間中允許映射的虛擬內存總量,單位為字節
    if (mm->total_vm + npages > rlimit(RLIMIT_AS) >> PAGE_SHIFT)
        // 如果映射的虛擬內存頁總數超出了內核的限制,那麼就返回 false 表示虛擬內存不足
        return false;

    // 檢查本次映射是否屬於數據區域的映射,這裏的數據區域指的是私有,可寫的虛擬內存區域(棧區除外)
    // 如果是則需要檢查數據區域裏的虛擬內存頁是否超過了內核的限制
    // rlimit(RLIMIT_DATA) 表示進程地址空間中允許映射的私有,可寫的虛擬內存總量,單位為字節
    // 如果超過則返回 false,表示數據區虛擬內存不足
    if (is_data_mapping(flags) &&
        mm->data_vm + npages > rlimit(RLIMIT_DATA) >> PAGE_SHIFT) {
        /* Workaround for Valgrind */
        if (rlimit(RLIMIT_DATA) == 0 &&
            mm->data_vm + npages <= rlimit_max(RLIMIT_DATA) >> PAGE_SHIFT)
            return true;

        pr_warn_once("%s (%d): VmData %lu exceed data ulimit %lu. Update limits%s.\n",
                 current->comm, current->pid,
                 (mm->data_vm + npages) << PAGE_SHIFT,
                 rlimit(RLIMIT_DATA),
                 ignore_rlimit_data ? "" : " or use boot option ignore_rlimit_data");

        if (!ignore_rlimit_data)
            return false;
    }

    return true;
}

5.2 內核的 overcommit 策略

正如前邊筆者所介紹到的,內核的 overcommit 策略會影響到進程申請虛擬內存的用量,進程對虛擬內存的申請就好比是我們向銀行貸款,我們在向銀行貸款的時候,銀行是需要對我們的還款能力進行審計的,我們抵押的資產越優質,銀行貸款給我們的也會越多。

同樣的道理,進程再向內核申請虛擬內存的時候,也是需要物理內存作為抵押的,因為虛擬內存説到底最終還是要映射到物理內存上的,背後需要物理內存作為支撐,不能無限制的申請。

所以進程在申請虛擬內存的時候,內核也是需要對申請的虛擬內存用量進行審計的,審計的對象就是那些在未來需要為其分配物理內存的虛擬內存。這也是符合常理的,因為只有在未來需要分配新的物理內存的時候,內核才需要綜合物理內存的容量來進行審計,從而決定是否為進程分配這麼多的虛擬內存,否則將來可能到處都是 OOM。如果未來不需要為這段虛擬內存分配物理內存,那麼內核自然不會對虛擬內存用量進行審計。這取決於 mmap 的映射方式。

比如,這段虛擬內存是私有,可寫的,那麼在未來,當進程對這段虛擬內存進行寫入的時候,內核會通過 cow 的方式為其分配新的物理內存,但是這段虛擬內存是共享的或者是隻讀的話,內核將不會為這段虛擬內存分配新的物理內存,而是繼續共享原來已經映射好的物理內存(內核中只有一份)。

如果進程在向內核申請的虛擬內存在未來是需要重新分配物理內存的話,比如:私有,可寫。那麼這種虛擬內存的使用量就需要被內核審計起來,因為物理內存總是有限的,不可能為所有虛擬內存都分配物理內存。內核需要確保能夠為這段虛擬內存未來分配足夠的物理內存,防止 oom。這種虛擬內存稱之為 account virtual memory

而進程向內核申請的虛擬內存並不需要內核為其重新分配物理內存的時候(共享或只讀),反正不會增加物理內存的使用負擔,這種虛擬內存就不需要被內核審計。

/*
 * We account for memory if it's a private writeable mapping,
 * not hugepages and VM_NORESERVE wasn't set.
 */
static inline int accountable_mapping(struct file *file, vm_flags_t vm_flags)
{
    /*
     * hugetlb 類型的大頁有其自己的統計方式,不會和普通的虛擬內存統計混合
     */
    if (file && is_file_hugepages(file))
        return 0;
    // 私有,可寫,並且沒有設置 VM_NORESERVE 的相關 VMA 是需要被 account 審計起來的。這樣在後續發生缺頁的時候,不會導致 OOM
    return (vm_flags & (VM_NORESERVE | VM_SHARED | VM_WRITE)) == VM_WRITE;
}

由於大頁內存都是被預先分配在大頁內存池中的,所以針對大頁的虛擬內存不需要被審計,另外如果這段虛擬內存 vma 設置了 VM_NORESERVE 標誌的話,也不需要被內核審計。

所以 account virtual memory 特指那些私有,可寫(private ,writeable)的虛擬內存區域,並且這些虛擬內存區域的 vm_flags 沒有設置 VM_NORESERVE 標誌位,以及這部分虛擬內存不能是映射大頁的

這部分 account virtual memory 被記錄在 vm_committed_as 字段中,表示被審計起來的虛擬內存,這些虛擬內存在未來都是需要映射新的物理內存的,站在物理內存的角度 vm_committed_as 可以理解為當前系統中已經分配的物理內存和未來可能需要的物理內存總量。

// 定義在文件:/include/linux/mman.h
extern struct percpu_counter vm_committed_as;

static inline void vm_acct_memory(long pages)
{
    percpu_counter_add_batch(&vm_committed_as, pages, vm_committed_as_batch);
}

static inline void vm_unacct_memory(long pages)
{
    vm_acct_memory(-pages);
}

每當有進程向內核申請或者釋放虛擬內存(account virtual memory )的時候,內核都會通過 vm_acct_memory 和 vm_unacct_memory 函數來更新 vm_committed_as 的值。

當我們使用 mmap 進行內存映射的時候,如果映射出的虛擬內存區域 vma 為私有,可寫的,並且參數 flags 沒有設置 MAP_NORESERVE 標誌,那麼這部分虛擬內存就需要被記錄在 vm_committed_as 字段中。

vm_committed_as 的值最終會反應在 /proc/meminfo 中的 Committed_AS 字段上。用來記錄當前系統中,所有進程申請到的 account virtual memory 總量。

static int meminfo_proc_show(struct seq_file *m, void *v)
{
    struct sysinfo i;
    unsigned long committed;


    committed = percpu_counter_read_positive(&vm_committed_as);
  
    show_val_kb(m, "Committed_AS:   ", committed);
}

image

現在 account virtual memory 的概念我們清楚了,那麼接下來就該來看一下,內核是如何對這部分虛擬內存的申請進行審計的(account)。

如果 accountable_mapping 函數返回值為 true,表示內核需要對當前進程申請的這部分虛擬內存進行審計,審計的邏輯封裝在 __vm_enough_memory 函數中,返回 0 表示有足夠的虛擬內存,返回 ENOMEM 表示虛擬內存不足。這裏正是內核 overcommit 策略的核心實現。

我們可以通過內核參數 /proc/sys/vm/overcommit_memory 來調整 overcommit 策略 。

image

內核定義瞭如下三種 overcommit 策略:

#define OVERCOMMIT_GUESS        0
#define OVERCOMMIT_ALWAYS        1
#define OVERCOMMIT_NEVER        2

OVERCOMMIT_GUESS 是內核默認的 overcommit 策略,在這種策略下,進程對虛擬內存的申請不能超過物理內存總大小和 swap 交換區的總大小 之和。

    if (sysctl_overcommit_memory == OVERCOMMIT_GUESS) {
        if (pages > totalram_pages() + total_swap_pages)
            goto error;
        return 0;
    }

OVERCOMMIT_ALWAYS 策略下應用進程無論申請多大的虛擬內存,內核總是會答應,分配虛擬內存非常的激進。

 if (sysctl_overcommit_memory == OVERCOMMIT_ALWAYS)
        return 0;

OVERCOMMIT_NEVER 策略下,內核會嚴格控制進程申請虛擬內存的用量,虛擬內存的限制通過 vm_commit_limit 函數計算得出,一般情況下為 (總物理內存大小 - 大頁佔用的內存大小) * 50% + swap 交換區總大小。所有進程申請到的虛擬內存總量不能超過該值。

vm_commit_limit 函數返回值體現在 /proc/meminfo 中的 CommitLimit 字段中。

image

注意:只有在 OVERCOMMIT_NEVER 策略下,CommitLimit 的限制才會生效

除此之外,內核會在 CommitLimit 的基礎上為進程預留一部分內存,用於在緊急情況下做一些恢復的操作,這部分預留的內存包括兩種,一種是 sysctl_admin_reserve_kbytes,另一種是 sysctl_user_reserve_kbytes。它們的大小均可以在 /proc/sys/vm 目錄下相應的配置文件中進行調整,單位為 KB。

image

  • sysctl_admin_reserve_kbytes 表示當進程擁有 root 權限的時候,內核需要為 root 相關的操作保留一部分內存,這樣可以使進程在任何情況下都可以順利執行 root 權限的相關操作。
  • sysctl_user_reserve_kbytes 用於在緊急情況下用户恢復系統。比如系統卡死,用户主動 kill 資源消耗比較大的進程,這個動作需要預留一些 user_reserve 內存。

所以在 OVERCOMMIT_NEVER 策略下,進程可以申請到的虛擬內存容量需要在 CommitLimit 的基礎上再減去 sysctl_admin_reserve_kbytes 和 sysctl_user_reserve_kbytes 配置的預留容量。

注意這裏對虛擬內存申請的限制是針對所有進程已經申請到的虛擬內存總量 + 本次 mmap 申請的虛擬內存總和的限制

// 用於檢查進程虛擬內存空間中是否有足夠的虛擬內存可供本次申請使用(需要結合 overcommit 策略來綜合判定)
// 返回 0 表示有足夠的虛擬內存,返回 ENOMEM 表示虛擬內存不足
int __vm_enough_memory(struct mm_struct *mm, long pages, int cap_sys_admin)
{
    // OVERCOMMIT_NEVER 模式下允許進程申請的虛擬內存大小
    long allowed;
    // 虛擬內存審計字段 vm_committed_as 增加 pages
    vm_acct_memory(pages);

    // 虛擬內存的 overcommit 策略可以通過修改 /proc/sys/vm/overcommit_memory 文件來設置,
    // 它有三個設置選項:
    // OVERCOMMIT_ALWAYS 表示無論應用進程申請多大的虛擬內存,內核總是會答應,分配虛擬內存非常的激進
    if (sysctl_overcommit_memory == OVERCOMMIT_ALWAYS)
        return 0;
    // OVERCOMMIT_GUESS 則相對 always 策略稍微保守一點,也是內核的默認策略
    // 它會對進程能夠申請到的虛擬內存大小做一定的限制,特別激進的申請比如申請非常大的虛擬內存則會被拒絕。
    if (sysctl_overcommit_memory == OVERCOMMIT_GUESS) {
        // guess 默認策略下,進程申請的虛擬內存大小不能超過 物理內存總大小和 swap 交換區的總大小之和
        if (pages > totalram_pages() + total_swap_pages)
            goto error;
        return 0;
    }

    // OVERCOMMIT_NEVER 是最為嚴格的一種控制虛擬內存 overcommit 的策略
    // 進程申請的虛擬內存大小不能超過 vm_commit_limit(),該值也會反應在 /proc/meminfo 中的 CommitLimit 字段中。
    // 只有採用 OVERCOMMIT_NEVER 模式,CommitLimit 的限制才會生效
    // allowed =(總物理內存大小 - 大頁佔用的內存大小) * 50%  + swap 交換區總大小 
    allowed = vm_commit_limit();

    // cap_sys_admin 表示申請內存的進程擁有 root 權限
    if (!cap_sys_admin)
        // 為 root 進程保存一些內存,這樣可以保證 root 相關的操作在任何時候都可以順利進行
        // 大小為 sysctl_admin_reserve_kbytes,這部分內存普通進程不能申請使用
        // 可通過 /proc/sys/vm/admin_reserve_kbytes 來配置
        allowed -= sysctl_admin_reserve_kbytes >> (PAGE_SHIFT - 10);

    /*
     * Don't let a single process grow so big a user can't recover
     */
    if (mm) {
        // 可通過 /proc/sys/vm/user_reserve_kbytes 來配置
        // 用於在緊急情況下,用户恢復系統,比如系統卡死,用户主動 kill 資源消耗比較大的進程,這個動作需要預留一些 user_reserve 內存
        long reserve = sysctl_user_reserve_kbytes >> (PAGE_SHIFT - 10);

        allowed -= min_t(long, mm->total_vm / 32, reserve);
    }
    // Committed_AS (系統中所有進程已經申請的虛擬內存總量 + 本次 mmap 申請的)不可以超過 CommitLimit(allowed)
    if (percpu_counter_read_positive(&vm_committed_as) < allowed)
        return 0;
error:
    vm_unacct_memory(pages);

    return -ENOMEM;
}

下面我們來看一下,OVERCOMMIT_NEVER 策略下,CommitLimit 的計算邏輯。

有兩個內核參數會影響 CommitLimit 的計算,它們分別是 sysctl_overcommit_kbytes 和 sysctl_overcommit_ratio,可通過 /proc/sys/vm 目錄下相應的配置文件中進行調整。

image

如果我們配置了 overcommit_kbytes (單位為 KB), CommitLimit (單位為頁)的值就是 sysctl_overcommit_kbytes >> (PAGE_SHIFT - 10) + total_swap_pages

如果我們沒有配置 overcommit_kbytes,內核則會根據 overcommit_ratio 的值(默認為 50)計算 CommitLimit :(總物理內存大小 - 大頁佔用的內存大小) * overcommit_ratio % + total_swap_pages

overcommit_kbytes 的優先級要大於 overcommit_ratio
/*
 * Committed memory limit enforced when OVERCOMMIT_NEVER policy is used
 */
unsigned long vm_commit_limit(void)
{
    // 允許申請的虛擬內存大小,單位為頁
    unsigned long allowed;
    // 該值可通過 /proc/sys/vm/overcommit_kbytes 來修改
    // sysctl_overcommit_kbytes 設置的是 Committed memory limit 的絕對值
    if (sysctl_overcommit_kbytes)
        // 轉換單位為頁
        allowed = sysctl_overcommit_kbytes >> (PAGE_SHIFT - 10);
    else
        // sysctl_overcommit_ratio 該值可通過 /proc/sys/vm/overcommit_ratio 來修改,設置的 commit limit 的比例
        // 默認值為 50,(總物理內存大小 - 大頁佔用的內存大小) * 50%
        allowed = ((totalram_pages() - hugetlb_total_pages())
               * sysctl_overcommit_ratio / 100);

    // 最後都需要加上 swap 交換區的總大小
    allowed += total_swap_pages;
    // (總物理內存大小 - 大頁佔用的內存大小) * 50%  + swap 交換區總大小 
    return allowed;
}

5.3 vma_merge 函數解析

經過前面的介紹我們知道,當 mmap 在進程虛擬內存空間中映射出一段 [addr , end] 的虛擬內存區域 area 時,內核需要為這段虛擬內存區域 area 創建一個 vma 結構來描述。

而在創建新的 vma 結構之前,內核會在這裏嘗試看能不能將 area 與現有的 vma 進行合併,這樣就可以避免創建新的 vma 結構,節省了內存的開銷。

內核會本着合併最大化的原則,檢查當前映射出來的 area 能否與其前後兩個 vma 進行合併,能合併就合併,如果不能合併就只能從 slab 中申請新的 vma 結構了。合併條件如下:

  1. area 的 vm_flags 不能設置 VM_SPECIAL 標誌,該標誌表示 area 區域是不可以被合併的,只能重新創建 vma。
  2. area 的起始地址 addr 必須要與其 prev vma 的結束地址重合,這樣,area 才能和它的前一個 vma 進行合併,如果不重合,area 則不能和前一個 vma 進行合併。
  3. area 的結束地址 end 必須要與其 next vma 的起始地址重合,這樣,area 才能和它的後一個 vma 進行合併,如果不重合,area 則不能和後一個 vma 進行合併。如果前後都不能合併,那就只能重新創建 vma 結構了。
  4. area 需要與其要合併區域的 vm_flags 必須相同,否則不能合併。
  5. 如果兩個合併區域都是文件映射區,那麼它們映射的文件必須是同一個。並且他們的文件映射偏移 vm_pgoff 必須是連續的。
  6. 如果兩個合併區域都是匿名映射區,那麼兩個 vma 映射的匿名頁 anon_vma 必須是相同的。
  7. 合併區域的 numa policy 必須是相同的。關於 numa policy 的介紹,感興趣的同學可以查看筆者之前的文章 《一步一圖帶你深入理解 Linux 物理內存管理》 第 “3.2.1 NUMA 的內存分配策略” 小節的內容。
  8. 要合併的 prev 和 next 虛擬內存區域中,不能包含 close 操作,也就是説 vma->vm_ops 不能設置有 close 函數,如果虛擬內存區域操作支持 close,則不能合併,否則會導致現有虛擬內存區域 prev 和 next 的資源無法釋放。

can_vma_merge_after 函數用於判斷其參數中指定的 vma 能否與其後一個 vma 進行合併。can_vma_merge_before 的邏輯也是一樣,用於判斷參數指定的 vma 能否與其前一個 vma 合併。

static int
can_vma_merge_after(struct vm_area_struct *vma, unsigned long vm_flags,
            struct anon_vma *anon_vma, struct file *file,
            pgoff_t vm_pgoff,
            struct vm_userfaultfd_ctx vm_userfaultfd_ctx)
{
    // 判斷參數中指定的 vma 能否與其後一個 vma 進行合併
    if (is_mergeable_vma(vma, file, vm_flags, vm_userfaultfd_ctx) &&
        is_mergeable_anon_vma(anon_vma, vma->anon_vma, vma)) {
        pgoff_t vm_pglen;
        // vma 區域的長度
        vm_pglen = vma_pages(vma);
        // 判斷 vma 和 next 兩個文件映射區域的映射偏移 pgoff 是否是連續的
        if (vma->vm_pgoff + vm_pglen == vm_pgoff)
            return 1;
    }
    return 0;
}

is_mergeable_vma 函數用於判斷兩個 vma 是否能夠合併:

static inline int is_mergeable_vma(struct vm_area_struct *vma,
                struct file *file, unsigned long vm_flags,
                struct vm_userfaultfd_ctx vm_userfaultfd_ctx)
{
    // 對比 prev 和 area 的 vm_flags 是否相同,這裏需要排除 VM_SOFTDIRTY
    // VM_SOFTDIRTY 用於追蹤進程寫了哪些內存頁,如果 prev 被標記了 soft dirty,那麼合併之後的 vma 也應該繼續保留 soft dirty 標記
    if ((vma->vm_flags ^ vm_flags) & ~VM_SOFTDIRTY)
        return 0;
    // prev 和 area 如果是文件映射區的話,這裏需要檢查兩者映射的文件是否相同
    if (vma->vm_file != file)
        return 0;
    // 如果 prev 虛擬內存區域中包含了 close 的操作,後續可能會釋放 prev 的資源
    // 所以這種情況下不能和 prev 進行合併,否則就會導致 prev 的資源無法釋放
    if (vma->vm_ops && vma->vm_ops->close)
        return 0;
    // userfaultfd 是用來在用户態實現缺頁處理的機制,這裏需要保證兩者的 userfaultfd 相同
    // 不過在 mmap_region 中傳入的 vm_userfaultfd_ctx 為 null,這裏我們不需要關注
    if (!is_mergeable_vm_userfaultfd_ctx(vma, vm_userfaultfd_ctx))
        return 0;
    return 1;
}

在我們清楚了 vma 之間的的合併條件之後,接下來我們來看一下 vma 的合併過程,整個合併過程其實還蠻複雜的,總共涉及到 8 種場景,不過大家別擔心,筆者會帶着大家從最簡單的場景出發來逐漸演變。

經過前面內容的介紹,我們知道,通過 mmap 在進程地址空間中映射出的這個 area 一般是在兩個 vma 中產生的,內核源碼中使用 prev 指向 area 的前一個 vma,使用 next 指向 area 的後一個 vma,這個原則請大家務必牢記。

image

如果我們在 mmap 系統調用參數 flags 中設置了 MAP_FIXED 標誌,表示需要內核進行強制映射,在這種情況下,area 區域有可能會與 prev 區域和 next 區域有部分重合。

image

如上圖所示,如果 area 區域的結束地址 end 與 next 區域的結束地址重合,內核會將 next 指針繼續向後移動一下,指向 next->vm_next 區域。保證 area 始終處於 prev 和 next 之間的 gap 中。

 if (area && area->vm_end == end)        
        next = next->vm_next;

以上這兩種基本佈局,大家要好好記住,多看幾眼,後面 8 種合併情況基本都是脱胎於這兩個基本佈局。

下面即將要介紹的這 8 種合併情況從總體上來講會分為兩個大的類別:

  1. 第一個類別是 area 的前一個 prev vma 的結束地址與 area 的起始地址 addr 重合,判斷條件為:prev->vm_end == addr
  2. 第二個類別是 area 的後一個 next vma 的起始地址與 area 的結束地址 end 重合,判斷條件為:end == next->vm_start

其中這兩個大的類別將會分別根據前面兩個基本佈局展開進行,下面我們來看源碼中的 case 1 。

注意下面的 8 種 case,筆者按照從簡單到複雜的順序來展示。

image

case 1 是在基本佈局 1 中,area 的起始地址 addr 與 prev vma 的結束地址重合,同時 area 的結束地址 end 與 next vma 的起始地址重合,內核將會刪除 next 區域,擴充 prev 區域,也就是説將這三個區域統一合併到 prev 區域中。

case 1 在基本佈局 2 下,就演變成了 case 6 的情況,內核會將中間重疊的藍色區域覆蓋掉,然後統一合併到 prev 區域中。

image

如果只是 area 的起始地址 addr 與 prev vma 的結束地址重合,但是 area 的結束地址 end 不與 next vma 的起始地址重合,就會出現 case 2 , case 5 , case 7 三種情況。

其中 case 2 的情況是 area 的結束地址 end 小於 next vma 的起始地址,內核會擴充 prev 區域,將 area 合併進去,next 區域保持不變。

image

case 5 的情況是 area 的結束地址 end 大於 next vma 的起始地址,內核會擴充 prev 區域,將 area 以及與 next 重疊的部分合併到 prev 區域中,剩下的繼續留在 next 區域保持不變。

image

case 2 在基本佈局 2 下又會演變成 case 7 , 這種情況下內核會將下圖中的藍色區域覆蓋,並擴充 prev 區域。next 區域保持不變。

image

如果只是 area 的結束地址 end 與 next vma 的起始地址重合,但是 area 的起始地址 addr 不與 prev vma 的結束地址重合,同樣的道理也會分為三種情況,分別是下面介紹的 case 4 , case 3 , case 8。

case 4 的情況下,area 的起始地址 addr 小於 prev 區域的結束地址,那麼內核會縮小 prev 區域,然後擴充 next 區域,將重疊的部分合併到 next 區域中。

image

如果 area 的起始地址 addr 大於 prev 區域的結束地址的話,就是 case 3 的情況 ,內核會擴充 next 區域,並將 area 合併到 next 中,prev 區域保持不變。

image

case 3 在基本佈局 2 下就會演變為 case 8 ,內核繼續保持 prev 區域不變,然後擴充 next 區域並覆蓋下圖中藍色部分,將 area 合併到 next 區域中。

image

好了,現在 vma 合併的流程我們也清楚了,合併的條件也清楚了,接下來在看這部分源碼就很簡單了。

struct vm_area_struct *vma_merge(struct mm_struct *mm,
            struct vm_area_struct *prev, unsigned long addr,
            unsigned long end, unsigned long vm_flags,
            struct anon_vma *anon_vma, struct file *file,
            pgoff_t pgoff, struct mempolicy *policy,
            struct vm_userfaultfd_ctx vm_userfaultfd_ctx)
{
    // 本次需要創建的 VMA 區域大小
    pgoff_t pglen = (end - addr) >> PAGE_SHIFT;
    // area 表示當前要創建的 VMA,next 表示 area 的下一個 VMA
    // 事實上 area 會在其 prev 前一個 VMA 和 next 後一個 VMA 之間的間隙 gap 中創建產生
    struct vm_area_struct *area, *next;
    int err;

    // 設置了 VM_SPECIAL 表示 area 區域是不可以被合併的,只能重新創建 VMA,直接退出合併流程。
    if (vm_flags & VM_SPECIAL)
        return NULL;
    // 根據 prev vma 是否存在,設置 area 的 next vma,基本佈局 1
    if (prev)
        // area 將在 prev vma 和 next vma 的間隙 gap 中產生
        next = prev->vm_next;
    else
        // 如果 prev 不存在,那麼 next 就設置為地址空間中的第一個 vma。
        next = mm->mmap;

    area = next;
    // 新 vma 的 end 與 next->vm_end 相等 ,表示新 vma 與 next vma 是重合的,基本佈局 2
    // 那麼 next 指向下一個 vma,prev 和 next 這裏的語義是始終指向 area 區域的前一個和後一個 vma
    if (area && area->vm_end == end)        /* cases 6, 7, 8 */
        next = next->vm_next;
 
    // 判斷 area 是否能夠和 prev 進行合併
    if (prev && prev->vm_end == addr &&
            mpol_equal(vma_policy(prev), policy) &&
            can_vma_merge_after(prev, vm_flags,
                        anon_vma, file, pgoff,
                        vm_userfaultfd_ctx)) {
        /*
         * 如何 area 可以和 prev 進行合併,那麼這裏繼續判斷 area 能夠與 next 進行合併
         * 內核這裏需要保證 vma 合併程度的最大化
         */
        if (next && end == next->vm_start &&
                mpol_equal(policy, vma_policy(next)) &&
                can_vma_merge_before(next, vm_flags,
                             anon_vma, file,
                             pgoff+pglen,
                             vm_userfaultfd_ctx) &&
                is_mergeable_anon_vma(prev->anon_vma,
                              next->anon_vma, NULL)) {
            // 流程走到這裏表示 area 可以和它的 prev ,next 區域進行合併  /* cases 1,6 */
            // __vma_adjust 是真正執行 vma 合併操作的函數,這裏會重新調整已有 vma 的相關屬性,比如:vm_start,vm_end,vm_pgoff。以及涉及到相關數據結構的改變
            err = __vma_adjust(prev, prev->vm_start,
                     next->vm_end, prev->vm_pgoff, NULL,
                     prev);
        } else                  /* cases 2, 5, 7 */
            // 流程走到這裏表示 area 只能和 prev 進行合併
            err = __vma_adjust(prev, prev->vm_start,
                     end, prev->vm_pgoff, NULL, prev);
        if (err)
            return NULL;
        khugepaged_enter_vma_merge(prev, vm_flags);
        // 返回最終合併好的 vma
        return prev;
    }

    // 下面這種情況屬於,area 的結束地址 end 與 next 的起始地址是重合的
    // 但是 area 的起始地址 start 和 prev 的結束地址不是重合的
    if (next && end == next->vm_start &&
            mpol_equal(policy, vma_policy(next)) &&
            can_vma_merge_before(next, vm_flags,
                         anon_vma, file, pgoff+pglen,
                         vm_userfaultfd_ctx)) {
        // area 區域前半部分和 prev 區域的後半部分重合
        // 那麼就縮小 prev 區域,然後將 area 合併到 next 區域
        if (prev && addr < prev->vm_end)    /* case 4 */
            err = __vma_adjust(prev, prev->vm_start,
                     addr, prev->vm_pgoff, NULL, next);
        else {                  /* cases 3, 8 */
            // area 區域前半部分和 prev 區域是有間隙 gap 的
            // 那麼這種情況下 prev 不變,area 合併到 next 中
            err = __vma_adjust(area, addr, next->vm_end,
                     next->vm_pgoff - pglen, NULL, next);
            // 合併後的 area
            area = next;
        }
        if (err)
            return NULL;
        khugepaged_enter_vma_merge(area, vm_flags);
        // 返回合併後的 vma
        return area;
    }
    
    // prev 的結束地址不與 area 的起始地址重合,並且 area 的結束地址不與 next 的起始地址重合
    // 這種情況就不能執行合併,需要為 area 重新創建新的 vma 結構
    return NULL;
}

總結

到現在為止,筆者通過兩篇文章,一篇原理,一篇源碼,深入到內核世界中,將 mmap 內存映射的本質給大家呈現了出來,知識點比較密集且比較燒腦,因此筆者又畫了一副 mmap 內存映射的整體思維導圖方便大家回顧。

image

在原理篇中筆者首先通過五個角度為大家詳細介紹了 mmap 的使用方法及其在內核中的實現原理,這五個角度分別是:

  1. 私有匿名映射,其主要用於進程申請虛擬內存,以及初始化進程虛擬內存空間中的 BSS 段,堆,棧這些虛擬內存區域。
  2. 私有文件映射,其核心特點是背後映射的文件頁在多進程之間是讀共享的,但多個進程對各自虛擬內存區的修改只能反應到各自對應的文件頁上,而且各自的修改在進程之間是互不可見的,最重要的一點是這些修改均不會回寫到磁盤文件中。我們可以利用這些特點來加載二進制可執行文件的 .text , .data section 到進程虛擬內存空間中的代碼段和數據段中。
  3. 共享文件映射,多進程之間讀寫共享(不會發生寫時複製),常用於多進程之間共享內存(page cache),多進程之間的通訊。
  4. 共享匿名映射,用於父子進程之間共享內存,父子進程之間的通訊。父子進程之間需要依賴 tmpfs 中的匿名文件來實現共享內存。是一種特殊的共享文件映射。
  5. 大頁內存映射,這裏我們介紹了標準大頁與透明大頁兩種大頁類型的區別與聯繫,以及他們各自的實現原理和使用方法。

介紹完原理之後,在本文的源碼實現篇中筆者花了大量的篇幅介紹了 mmap 在內核中的源碼實現,其中最核心的兩個函數是:

  1. get_unmapped_area 函數用於在進程虛擬內存空間中為本次 mmap 映射尋找出一段未被映射的空閒虛擬內存地址範圍。其中筆者還為大家介紹了文件映射與匿名映射區在進程虛擬內存空間的佈局情況。
  2. map_region 函數主要是對這段空閒虛擬內存地址範圍進行映射,在映射過程中涉及到的重要內容有:

    • 內核的 overcommit 策略
    • vm_merge 合併的流程,其中涉及到 8 種合併場景和 2 中基本佈局。

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

user avatar jichenssg 頭像 wuduyouou_5de642de9c1e5 頭像 yilaguan_6110d16933c03 頭像 thinkfault 頭像
4 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.