本文基於內核 5.4 版本源碼討論 通過上篇文章 《從內核世界透視 mmap 記憶體映射的本質(原理篇)》的介紹,我們現在已經非常清楚了 mmap 背後的映射原理以及它的使用方法,其核心就是在進程虛擬記憶體空間中分配一段虛擬記憶體出來,然後將這段虛擬記憶體與磁碟文件映射起來,整個 mmap 系統調用就結束了 ...
本文基於內核 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 就會