本文基於核心 5.4 版本原始碼討論
透過上篇文章 《從核心世界透視 mmap 記憶體對映的本質(原理篇)》的介紹,我們現在已經非常清楚了 mmap 背後的對映原理以及它的使用方法,其核心就是在程式虛擬記憶體空間中分配一段虛擬記憶體出來,然後將這段虛擬記憶體與磁碟檔案對映起來,整個 mmap 系統呼叫就結束了。
而在 mmap 記憶體對映的整個過程中,最為核心且複雜燒腦的環節其實不是記憶體對映的邏輯,而是虛擬記憶體分配的整個流程。筆者曾在之前的文章 《深入理解 Linux 實體記憶體分配全鏈路實現》 中詳細地為大家介紹了實體記憶體的分配過程,那麼虛擬記憶體的分配過程又是什麼樣的呢?
本文我們將進入到核心原始碼實現中,來看一下虛擬記憶體分配的過程,在這個過程中,我們還可以親眼看到前面介紹的 mmap 記憶體對映原理在核心中具體是如何實現的,下面我們就從 mmap 系統呼叫的入口處來開始本文的內容:
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 函式來判斷對映檔案是否由大頁支援,我們在使用者態使用的大頁一般是由兩種型別的系統呼叫來支援的:
-
mmap 系統呼叫,背後依賴的是 hugetlbfs 檔案系統,這種情況下只需要判斷對映檔案的 struct file 結構中定義的檔案操作是否是 hugetlbfs 檔案系統相關的操作,這樣就可以確定出對映檔案是否為 hugetlbfs 檔案系統中的檔案。
-
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. 虛擬記憶體對映整體流程
do_mmap 是 mmap 系統呼叫的核心函式,核心會在這裡完成記憶體對映的整個流程,其中最為核心的是如下兩個方面的內容:
-
get_unmapped_area 函式用於在程式地址空間中尋找出一段長度為 len,並且還未對映的虛擬記憶體區域 vma 出來。返回值 addr 表示這段虛擬記憶體區域的起始地址。
-
mmap_region 函式是整個記憶體對映的核心,它首先會為這段選取出來的對映虛擬記憶體區域分配 vma 結構,並根據對映資訊進行初始化,以及建立 vma 與相關對映檔案的關係,最後將這段 vma 插入到程式的虛擬記憶體空間中。
除了這兩個核心內容之外,do_mmap 函式還承擔了對一些記憶體對映約束條件的檢查,比如:核心規定一個程式虛擬記憶體空間內所能對映的虛擬記憶體區域 vma 是有數量限制的,sysctl_max_map_count 規定了程式虛擬記憶體空間所能包含 VMA 的最大個數,我們可以透過 /proc/sys/vm/max_map_count
核心引數來調整 sysctl_max_map_count。
程式虛擬記憶體空間中現有的虛擬記憶體區域 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 表示不對鎖定記憶體進行限制。
程式的虛擬記憶體空間是非常龐大的,遠遠地超過真實實體記憶體容量,這就容易給我們造成一種錯覺,就是當我們呼叫 mmap 為應用程式申請虛擬記憶體的時候,可以無限制的申請,反正都是虛擬的嘛,核心應該痛痛快快的給我們。
但事實上並非如此,核心會對我們申請的虛擬記憶體容量進行審計(account),結合當前實體記憶體容量以及 swap 交換區的大小來綜合判斷是否允許本次虛擬記憶體的申請。
核心對虛擬記憶體使用的審計策略定義在 sysctl_overcommit_memory 中,我們可以透過核心引數 /proc/sys/vm/overcommit_memory
來調整 。
核心定義瞭如下三個 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. 虛擬記憶體的分配流程
mmap 系統呼叫分配虛擬記憶體的本質其實就是在程式的虛擬記憶體空間中的檔案對映與匿名對映區,找出一段未被對映過的空閒虛擬記憶體區域 vma,這個 vma 就是我們申請到的虛擬記憶體。
由此可以看出 mmap 主要的工作區域是在檔案對映與匿名對映區,而在對映區查詢空閒 vma 的過程又是和對映區的佈局息息相關的,所以在為大家介紹虛擬記憶體分配流程之前,還是有必要介紹一下檔案對映與匿名對映區的佈局情況,這樣方便大家後續理解虛擬記憶體分配的邏輯。
4.1 檔案對映與匿名對映區的佈局
檔案對映與匿名對映區的佈局在 linux 核心中分為兩種:一種是經典佈局,另一種是新式佈局,不同的體系結構可以透過核心引數 /proc/sys/vm/legacy_va_layout
來指定具體採用哪種佈局。 1 表示採用經典佈局, 0 表示採用新式佈局。
在經典佈局下,檔案對映與匿名對映區的地址增長方向是從低地址到高地址,也就是說對映區是從下往上增長,這也就導致了 mmap 在分配虛擬記憶體的時候需要從下往上搜尋空閒 vma。
經典佈局下,檔案對映與匿名對映區的起始地址 mm_struct->mmap_base 被設定在 task_size 的三分之一處,task_size 為程式虛擬記憶體空間與核心空間的分界線,也就說 task_size 是程式虛擬記憶體空間的末尾,大小為 3G。
這表明了檔案對映與匿名對映區起始於程式虛擬記憶體空間開始的 1G 位置處,而對映區恰好位於整個程式虛擬記憶體空間的中間,其下方就是堆了,由於程式碼段,資料段的存在,可供堆進行擴充套件的空間是小於 1G 的,否則就會與對映區衝突了。
這種佈局對於虛擬記憶體空間非常大的體系結構,比如 AMD64 , 是合適的而且會工作的非常好,因為虛擬記憶體空間足夠的大(128T),堆與對映區都有足夠的空間來擴充套件,不會發生衝突。
但是對於虛擬記憶體空間比較小的體系結構,比如 IA-32,只能提供 3G 大小的程式虛擬記憶體空間,就會出現上述衝突問題,於是核心在 2.6.7 版本引入了新式佈局。
在新式佈局下,檔案對映與匿名對映區的地址增長方向是從高地址到低地址,也就是說對映區是從上往下增長,這也就導致了 mmap 在分配虛擬記憶體的時候需要從上往下搜尋空閒 vma。
在新式佈局中,棧的空間大小會被限制,棧最大空間大小儲存在 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 變數的值。
當我們為 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 的三分之一,也就是說檔案對映與匿名對映區起始於程式虛擬記憶體空間的三分之一處:
#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,都會加上一個隨機偏移。
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 位於高地址處,從上往下增長。
程式虛擬記憶體空間中棧頂 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 函式指標指向的函式。
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 虛擬記憶體分配的秘密就隱藏在這裡:
首先我們需要明確一下,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] 是否已經存在對映關係,核心都會強行進行對映,如果這塊區域已經存在對映關係,那麼後續核心會把舊的對映關係覆蓋掉。
如果我們指定了 addr,但是並沒有指定 MAP_FIXED,則意味著我們只是建議核心優先考慮從我們指定的 addr 地址處開始對映,但是如果 [addr , addr+len] 這段虛擬記憶體區域已經存在對映關係,核心則不會按照我們指定的 addr 開始對映,而是會自動查詢一段空閒的 len 長度的虛擬記憶體區域。這一部分的工作由 vm_unmapped_area 函式承擔。
如果透過查詢發現, [addr , addr+len] 這段虛擬記憶體地址範圍並未存在任何對映關係,那麼 addr 就會作為 mmap 對映的起始地址。這裡面會分為兩種情況:
-
第一種是我們指定的 addr 比較大,addr 位於檔案對映與匿名對映區中所有對映區域 vma 的最後面,這樣一來,[addr , addr + len] 這段地址範圍當然是空閒的了。
-
第二種情況是我們指定的 addr 恰好位於一個 vma 和另一個 vma 中間的地址間隙中,並且這個地址間隙剛好大於或者等於我們指定的對映長度 len。核心就可以將這個地址間隙對映起來。
// 核心標準實現
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 出來(下圖中的藍色部分)。
然後在程式地址空間中的 vma 連結串列 mmap 中,找出它的前驅節點 pprev (上圖中的綠色部分)。
struct mm_struct {
struct vm_area_struct *mmap; /* list of VMAs */
}
如果不存在這樣一個 vma(addr < vma->vm_end),那麼核心直接從我們指定的 addr 地址處開始對映就好了,這時 pprev 指向程式地址空間中最後一個 vma。
如果存在這樣一個 vma,那麼核心就會判斷,該 vma 與其前驅節點 pprev 之間的地址間隙 gap 是否能容納下一段 len 長度的對映區間,如果可以,那麼核心就對映在這個地址間隙 gap 中。如果不可以,核心就需要在 vm_unmapped_area 函式中重新到整個程式地址空間中查詢出一個 len 長度的空閒對映區域,這種情況下對映區的起始地址就不是我們指定的 addr 了。
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;
}
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 函式承擔。
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 函式。
我們苦苦尋找的 unmapped_area 一定是在檔案對映與匿名對映區中某個 vma 與其前驅 vma 之間的地址間隙 gap 中產生的。
所以這就要求這個 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 出來。
首先核心會從紅黑樹中的根節點 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 的限制。
由於紅黑樹是按照 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 符合上述的三個條件:
-
gap 的長度大於等於對映長度 length : gap_end - gap_start >= length
-
gap_end >= low_limit + length 。
-
gap_start <= high_limit - length。
這裡核心還有一個小小的最佳化點,如果我們遍歷完了當前 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 並不一定是我們指定的對映起始地址。
現在我們只是確定了 [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 地址處開始對映。
這樣一來,[addr , addr + len] 這段範圍的虛擬記憶體就會有很大的可能與現有虛擬記憶體對映區 vma(上圖中藍色部分)發生重疊,因為這裡我們指定的是強制對映 MAP_FIXED,所以核心會將這部分重疊的部分透過 do_munmap 函式先解除對映,然後建立新的對映關係,效果就是將這部分重疊的虛擬記憶體覆蓋掉了。
由於這部分重疊的虛擬記憶體部分是之前已經分配出去的,本次對映不需要再重新申請,所以真實虛擬記憶體的用量需要減去這部分重疊的部分。
核心透過 count_vma_pages_range 函式計算出這部分重疊的虛擬記憶體頁個數,然後用本次申請的虛擬記憶體頁個數 len >> PAGE_SHIFT 減去重疊的頁數就是本次對映真實的虛擬記憶體用量。
最後重新透過 may_expand_vm 函式判斷是否超過程式地址空間中可以被對映的虛擬記憶體總量限制,如果依然超過,則返回 ENOMEM 異常。如果沒有超過,則正式進入虛擬記憶體分配的流程。
說到虛擬記憶體的分配,我們不由的會想到程式的虛擬記憶體空間,每個程式的虛擬記憶體空間都是獨立的,而且虛擬記憶體空間的容量非常巨大,在 64 位系統中程式的虛擬記憶體空間為 128T,在這麼巨大的虛擬記憶體空間下申請虛擬記憶體,我們想當然的會認為,程式可以隨意申請,隨意折騰。
理論上是這樣,但是事實上,虛擬記憶體說到底最終還是要對映到實體記憶體上的,背後需要實體記憶體作為支撐,如果程式申請的虛擬記憶體遠遠超過實體記憶體大小,那麼在執行的過程中就會導致部分記憶體被 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 函式那樣。
然後再 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,
};
如果 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 指向的紅黑樹結構。
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,它指向的是一顆紅黑樹,這顆紅黑樹正是檔案頁反向對映的核心資料結構,反向對映關係就儲存在這裡。
我們知道,一個檔案可以被多個程式一起對映,這樣一來在每個程式的地址空間 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 流程圖,來看原始碼實現就很清晰了:
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 檢查對映的虛擬記憶體是否超過了核心限制
程式地址空間中對虛擬記憶體的用量是有限制的,限制分為兩個方面:
-
對程式地址空間中能夠對映的虛擬記憶體頁總數做出限制。
-
對程式地址空間中資料區的虛擬記憶體頁總數做出限制。
這裡的資料區,在核心中定義的是所有私有,可寫的虛擬記憶體區域(棧區除外):
/*
* 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
檔案進行調整。
核心對程式地址空間中相關區域的虛擬記憶體用量限制依然儲存在 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);
}
現在 account virtual memory 的概念我們清楚了,那麼接下來就該來看一下,核心是如何對這部分虛擬記憶體的申請進行審計的(account)。
如果 accountable_mapping 函式返回值為 true,表示核心需要對當前程式申請的這部分虛擬記憶體進行審計,審計的邏輯封裝在 __vm_enough_memory 函式中,返回 0 表示有足夠的虛擬記憶體,返回 ENOMEM 表示虛擬記憶體不足。這裡正是核心 overcommit 策略的核心實現。
我們可以透過核心引數 /proc/sys/vm/overcommit_memory
來調整 overcommit 策略 。
核心定義瞭如下三種 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 欄位中。
注意:只有在 OVERCOMMIT_NEVER 策略下,CommitLimit 的限制才會生效
除此之外,核心會在 CommitLimit 的基礎上為程式預留一部分記憶體,用於在緊急情況下做一些恢復的操作,這部分預留的記憶體包括兩種,一種是 sysctl_admin_reserve_kbytes,另一種是 sysctl_user_reserve_kbytes。它們的大小均可以在 /proc/sys/vm
目錄下相應的配置檔案中進行調整,單位為 KB。
-
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
目錄下相應的配置檔案中進行調整。
如果我們配置了 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 結構了。合併條件如下:
-
area 的 vm_flags 不能設定 VM_SPECIAL 標誌,該標誌表示 area 區域是不可以被合併的,只能重新建立 vma。
-
area 的起始地址 addr 必須要與其 prev vma 的結束地址重合,這樣,area 才能和它的前一個 vma 進行合併,如果不重合,area 則不能和前一個 vma 進行合併。
-
area 的結束地址 end 必須要與其 next vma 的起始地址重合,這樣,area 才能和它的後一個 vma 進行合併,如果不重合,area 則不能和後一個 vma 進行合併。如果前後都不能合併,那就只能重新建立 vma 結構了。
-
area 需要與其要合併區域的 vm_flags 必須相同,否則不能合併。
-
如果兩個合併區域都是檔案對映區,那麼它們對映的檔案必須是同一個。並且他們的檔案對映偏移 vm_pgoff 必須是連續的。
-
如果兩個合併區域都是匿名對映區,那麼兩個 vma 對映的匿名頁 anon_vma 必須是相同的。
-
合併區域的 numa policy 必須是相同的。關於 numa policy 的介紹,感興趣的同學可以檢視筆者之前的文章 《一步一圖帶你深入理解 Linux 實體記憶體管理》 第 “3.2.1 NUMA 的記憶體分配策略” 小節的內容。
-
要合併的 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,這個原則請大家務必牢記。
如果我們在 mmap 系統呼叫引數 flags 中設定了 MAP_FIXED 標誌,表示需要核心進行強制對映,在這種情況下,area 區域有可能會與 prev 區域和 next 區域有部分重合。
如上圖所示,如果 area 區域的結束地址 end 與 next 區域的結束地址重合,核心會將 next 指標繼續向後移動一下,指向 next->vm_next 區域。保證 area 始終處於 prev 和 next 之間的 gap 中。
if (area && area->vm_end == end)
next = next->vm_next;
以上這兩種基本佈局,大家要好好記住,多看幾眼,後面 8 種合併情況基本都是脫胎於這兩個基本佈局。
下面即將要介紹的這 8 種合併情況從總體上來講會分為兩個大的類別:
-
第一個類別是 area 的前一個 prev vma 的結束地址與 area 的起始地址 addr 重合,判斷條件為:
prev->vm_end == addr
。 -
第二個類別是 area 的後一個 next vma 的起始地址與 area 的結束地址 end 重合,判斷條件為:
end == next->vm_start
。
其中這兩個大的類別將會分別根據前面兩個基本佈局展開進行,下面我們來看原始碼中的 case 1 。
注意下面的 8 種 case,筆者按照從簡單到複雜的順序來展示。
case 1 是在基本佈局 1 中,area 的起始地址 addr 與 prev vma 的結束地址重合,同時 area 的結束地址 end 與 next vma 的起始地址重合,核心將會刪除 next 區域,擴充 prev 區域,也就是說將這三個區域統一合併到 prev 區域中。
case 1 在基本佈局 2 下,就演變成了 case 6 的情況,核心會將中間重疊的藍色區域覆蓋掉,然後統一合併到 prev 區域中。
如果只是 area 的起始地址 addr 與 prev vma 的結束地址重合,但是 area 的結束地址 end 不與 next vma 的起始地址重合,就會出現 case 2 , case 5 , case 7 三種情況。
其中 case 2 的情況是 area 的結束地址 end 小於 next vma 的起始地址,核心會擴充 prev 區域,將 area 合併進去,next 區域保持不變。
case 5 的情況是 area 的結束地址 end 大於 next vma 的起始地址,核心會擴充 prev 區域,將 area 以及與 next 重疊的部分合併到 prev 區域中,剩下的繼續留在 next 區域保持不變。
case 2 在基本佈局 2 下又會演變成 case 7 , 這種情況下核心會將下圖中的藍色區域覆蓋,並擴充 prev 區域。next 區域保持不變。
如果只是 area 的結束地址 end 與 next vma 的起始地址重合,但是 area 的起始地址 addr 不與 prev vma 的結束地址重合,同樣的道理也會分為三種情況,分別是下面介紹的 case 4 , case 3 , case 8。
case 4 的情況下,area 的起始地址 addr 小於 prev 區域的結束地址,那麼核心會縮小 prev 區域,然後擴充 next 區域,將重疊的部分合併到 next 區域中。
如果 area 的起始地址 addr 大於 prev 區域的結束地址的話,就是 case 3 的情況 ,核心會擴充 next 區域,並將 area 合併到 next 中,prev 區域保持不變。
case 3 在基本佈局 2 下就會演變為 case 8 ,核心繼續保持 prev 區域不變,然後擴充 next 區域並覆蓋下圖中藍色部分,將 area 合併到 next 區域中。
好了,現在 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 記憶體對映的整體思維導圖方便大家回顧。
在原理篇中筆者首先透過五個角度為大家詳細介紹了 mmap 的使用方法及其在核心中的實現原理,這五個角度分別是:
-
私有匿名對映,其主要用於程式申請虛擬記憶體,以及初始化程式虛擬記憶體空間中的 BSS 段,堆,棧這些虛擬記憶體區域。
-
私有檔案對映,其核心特點是背後對映的檔案頁在多程式之間是讀共享的,但多個程式對各自虛擬記憶體區的修改只能反應到各自對應的檔案頁上,而且各自的修改在程式之間是互不可見的,最重要的一點是這些修改均不會回寫到磁碟檔案中。我們可以利用這些特點來載入二進位制可執行檔案的 .text , .data section 到程式虛擬記憶體空間中的程式碼段和資料段中。
-
共享檔案對映,多程式之間讀寫共享(不會發生寫時複製),常用於多程式之間共享記憶體(page cache),多程式之間的通訊。
-
共享匿名對映,用於父子程式之間共享記憶體,父子程式之間的通訊。父子程式之間需要依賴 tmpfs 中的匿名檔案來實現共享記憶體。是一種特殊的共享檔案對映。
-
大頁記憶體對映,這裡我們介紹了標準大頁與透明大頁兩種大頁型別的區別與聯絡,以及他們各自的實現原理和使用方法。
介紹完原理之後,在本文的原始碼實現篇中筆者花了大量的篇幅介紹了 mmap 在核心中的原始碼實現,其中最核心的兩個函式是:
-
get_unmapped_area 函式用於在程式虛擬記憶體空間中為本次 mmap 對映尋找出一段未被對映的空閒虛擬記憶體地址範圍。其中筆者還為大家介紹了檔案對映與匿名對映區在程式虛擬記憶體空間的佈局情況。
-
map_region 函式主要是對這段空閒虛擬記憶體地址範圍進行對映,在對映過程中涉及到的重要內容有:
- 核心的 overcommit 策略
- vm_merge 合併的流程,其中涉及到 8 種合併場景和 2 中基本佈局。
好了,本文的內容到這裡就結束了,感謝大家的收看,我們下篇文章見~