一文讀懂 Linux 內(nèi)存分配全過程
本文轉(zhuǎn)載自微信公眾號「Linux內(nèi)核那些事」,作者songsong001。轉(zhuǎn)載本文請聯(lián)系Linux內(nèi)核那些事公眾號。
在《你真的理解內(nèi)存分配》一文中,我們介紹了 malloc 申請內(nèi)存的原理,但其在內(nèi)核怎么實(shí)現(xiàn)的呢?所以,本文主要分析在 Linux 內(nèi)核中對堆內(nèi)存分配的實(shí)現(xiàn)過程。
本文使用 Linux 2.6.32 版本代碼
內(nèi)存分區(qū)對象
在《你真的理解內(nèi)存分配》一文中介紹過,Linux 會(huì)把進(jìn)程虛擬內(nèi)存空間劃分為多個(gè)分區(qū),在 Linux 內(nèi)核中使用 vm_area_struct 對象來表示,其定義如下:
- struct vm_area_struct {
- struct mm_struct *vm_mm; // 分區(qū)所屬的內(nèi)存管理對象
- unsigned long vm_start; // 分區(qū)的開始地址
- unsigned long vm_end; // 分區(qū)的結(jié)束地址
- struct vm_area_struct *vm_next; // 通過這個(gè)指針把進(jìn)程所有的內(nèi)存分區(qū)連接成一個(gè)鏈表
- ...
- struct rb_node vm_rb; // 紅黑樹的節(jié)點(diǎn), 用于保存到內(nèi)存分區(qū)紅黑樹中
- ...
- };
我們對 vm_area_struct 對象進(jìn)行了簡化,只保留了本文需要的字段。
內(nèi)核就是使用 vm_area_struct 對象來記錄一個(gè)內(nèi)存分區(qū)(如 代碼段、數(shù)據(jù)段 和 堆空間 等),下面介紹一下 vm_area_struct 對象各個(gè)字段的作用:
- vm_mm:指定了當(dāng)前內(nèi)存分區(qū)所屬的內(nèi)存管理對象。
- vm_start:內(nèi)存分區(qū)的開始地址。
- vm_end:內(nèi)存分區(qū)的結(jié)束地址。
- vm_next:通過這個(gè)指針把進(jìn)程中所有的內(nèi)存分區(qū)連接成一個(gè)鏈表。
- vm_rb:另外,為了快速查找內(nèi)存分區(qū),內(nèi)核還把進(jìn)程的所有內(nèi)存分區(qū)保存到一棵紅黑樹中。vm_rb 就是紅黑樹的節(jié)點(diǎn),用于把內(nèi)存分區(qū)保存到紅黑樹中。
假如進(jìn)程 A 現(xiàn)在有 4 個(gè)內(nèi)存分區(qū),它們的范圍如下:
- 代碼段:00400000 ~ 00401000
- 數(shù)據(jù)段:00600000 ~ 00601000
- 堆空間:00983000 ~ 009a4000
- 棧空間:7f37ce866000 ~ 7f3fce867000
那么這 4 個(gè)內(nèi)存分區(qū)在內(nèi)核中的結(jié)構(gòu)如 圖1 所示:
在 圖1 中,我們可以看到有個(gè) mm_struct 的對象,此對象每個(gè)進(jìn)程都持有一個(gè),是進(jìn)程虛擬內(nèi)存空間和物理內(nèi)存空間的管理對象。我們簡單介紹一下這個(gè)對象,其定義如下:
- struct mm_struct {
- struct vm_area_struct *mmap; // 指向由進(jìn)程內(nèi)存分區(qū)連接成的鏈表
- struct rb_root mm_rb; // 內(nèi)核使用紅黑樹保存進(jìn)程的所有內(nèi)存分區(qū), 這個(gè)是紅黑樹的根節(jié)點(diǎn)
- unsigned long start_brk, brk; // 堆空間的開始地址和結(jié)束地址
- ...
- };
我們來介紹下 mm_struct 對象各個(gè)字段的作用:
- mmap:指向由進(jìn)程所有內(nèi)存分區(qū)連接成的鏈表。
- mm_rb:內(nèi)核為了加快查找內(nèi)存分區(qū)的速度,使用了紅黑樹保存所有內(nèi)存分區(qū),這個(gè)就是紅黑樹的根節(jié)點(diǎn)。
- start_brk:堆空間的開始內(nèi)存地址。
- brk:堆空間的頂部內(nèi)存地址。
我們來回顧一下進(jìn)程虛擬內(nèi)存空間的布局圖,如 圖2 所示:
start_brk 和 brk 字段用來記錄堆空間的范圍, 如 圖2 所示。一般來說,start_brk 是不會(huì)變的,而 brk 會(huì)隨著分配內(nèi)存和釋放內(nèi)存而變化。
虛擬內(nèi)存分配
在《你真的理解內(nèi)存分配》一文中說過,調(diào)用 malloc 申請內(nèi)存時(shí),最終會(huì)調(diào)用 brk 系統(tǒng)調(diào)用來從堆空間中分配內(nèi)存。我們來分析一下 brk 系統(tǒng)調(diào)用的實(shí)現(xiàn):
- unsigned long sys_brk(unsigned long brk)
- {
- unsigned long rlim, retval;
- unsigned long newbrk, oldbrk;
- struct mm_struct *mm = current->mm;
- ...
- down_write(&mm->mmap_sem); // 對內(nèi)存管理對象進(jìn)行上鎖
- ...
- // 判斷堆空間的大小是否超出限制, 如果超出限制, 就不進(jìn)行處理
- rlim = current->signal->rlim[RLIMIT_DATA].rlim_cur;
- if (rlim < RLIM_INFINITY
- && (brk - mm->start_brk) + (mm->end_data - mm->start_data) > rlim)
- goto out;
- newbrk = PAGE_ALIGN(brk); // 新的brk值
- oldbrk = PAGE_ALIGN(mm->brk); // 舊的brk值
- if (oldbrk == newbrk) // 如果新舊的位置都一樣, 就不需要進(jìn)行處理
- goto set_brk;
- ...
- // 調(diào)用 do_brk 函數(shù)進(jìn)行下一步處理
- if (do_brk(oldbrk, newbrk-oldbrk) != oldbrk)
- goto out;
- set_brk:
- mm->brk = brk; // 設(shè)置堆空間的頂部位置(brk指針)
- out:
- retval = mm->brk;
- up_write(&mm->mmap_sem);
- return retval;
- }
總結(jié)上面的代碼,主要有以下幾個(gè)步驟:
1、判斷堆空間的大小是否超出限制,如果超出限制,就不作任何處理,直接返回舊的 brk 值。
2、如果新的 brk 值跟舊的 brk 值一致,那么也不用作任何處理。
3、如果新的 brk 值發(fā)生變化,那么就調(diào)用 do_brk 函數(shù)進(jìn)行下一步處理。
4、設(shè)置進(jìn)程的 brk 指針(堆空間頂部)為新的 brk 的值。
我們看到第 3 步調(diào)用了 do_brk 函數(shù)來處理,do_brk 函數(shù)的實(shí)現(xiàn)有點(diǎn)小復(fù)雜,所以這里介紹一下大概處理流程:
- 通過堆空間的起始地址 start_brk 從進(jìn)程內(nèi)存分區(qū)紅黑樹中找到其對應(yīng)的內(nèi)存分區(qū)對象(也就是 vm_area_struct)。
- 把堆空間的內(nèi)存分區(qū)對象的 vm_end 字段設(shè)置為新的 brk 值。
至此,brk 系統(tǒng)調(diào)用的工作就完成了(上面沒有分析釋放內(nèi)存的情況),總結(jié)來說,brk 系統(tǒng)調(diào)用的工作主要有兩部分:
把進(jìn)程的 brk 指針設(shè)置為新的 brk 值。
把堆空間的內(nèi)存分區(qū)對象的 vm_end 字段設(shè)置為新的 brk 值。
物理內(nèi)存分配
從上面的分析知道,brk 系統(tǒng)調(diào)用申請的是 虛擬內(nèi)存,但存儲(chǔ)數(shù)據(jù)只能使用 物理內(nèi)存。所以,虛擬內(nèi)存必須映射到物理內(nèi)存才能被使用。
那么什么時(shí)候才進(jìn)行內(nèi)存映射呢?
在《你真的理解內(nèi)存分配》一文中介紹過,當(dāng)對沒有映射的虛擬內(nèi)存地址進(jìn)行讀寫操作時(shí),CPU 將會(huì)觸發(fā) 缺頁異常。內(nèi)核接收到 缺頁異常 后, 會(huì)調(diào)用 do_page_fault 函數(shù)進(jìn)行修復(fù)。
我們來分析一下 do_page_fault 函數(shù)的實(shí)現(xiàn)(精簡后):
- void do_page_fault(struct pt_regs *regs, unsigned long error_code)
- {
- struct vm_area_struct *vma;
- struct task_struct *tsk;
- unsigned long address;
- struct mm_struct *mm;
- int write;
- int fault;
- tsk = current;
- mm = tsk->mm;
- address = read_cr2(); // 獲取導(dǎo)致頁缺失異常的虛擬內(nèi)存地址
- ...
- vma = find_vma(mm, address); // 通過虛擬內(nèi)存地址從進(jìn)程內(nèi)存分區(qū)中查找對應(yīng)的內(nèi)存分區(qū)對象
- ...
- if (likely(vma->vm_start <= address)) // 如果找到內(nèi)存分區(qū)對象
- goto good_area;
- ...
- good_area:
- write = error_code & PF_WRITE;
- ...
- // 調(diào)用 handle_mm_fault 函數(shù)對虛擬內(nèi)存地址進(jìn)行映射操作
- fault = handle_mm_fault(mm, vma, address, write ? FAULT_FLAG_WRITE : 0);
- ...
- }
do_page_fault 函數(shù)主要完成以下操作:
獲取導(dǎo)致頁缺失異常的虛擬內(nèi)存地址,保存到 address 變量中。
調(diào)用 find_vma 函數(shù)從進(jìn)程內(nèi)存分區(qū)中查找異常的虛擬內(nèi)存地址對應(yīng)的內(nèi)存分區(qū)對象。
如果找到內(nèi)存分區(qū)對象,那么調(diào)用 handle_mm_fault 函數(shù)對虛擬內(nèi)存地址進(jìn)行映射操作。
從上面的分析可知,對虛擬內(nèi)存進(jìn)行映射操作是通過 handle_mm_fault 函數(shù)完成的,而 handle_mm_fault 函數(shù)的主要工作就是完成對進(jìn)程 頁表 的填充。
我們通過 圖3 來理解內(nèi)存映射的原理,可以參考文章《一文讀懂 HugePages的原理》:
下面我們來分析一下 handle_mm_fault 的實(shí)現(xiàn),代碼如下:
- int handle_mm_fault(struct mm_struct *mm, struct vm_area_struct *vma,
- unsigned long address, unsigned int flags)
- {
- pgd_t *pgd; // 頁全局目錄項(xiàng)
- pud_t *pud; // 頁上級目錄項(xiàng)
- pmd_t *pmd; // 頁中間目錄項(xiàng)
- pte_t *pte; // 頁表項(xiàng)
- ...
- pgd = pgd_offset(mm, address); // 獲取虛擬內(nèi)存地址對應(yīng)的頁全局目錄項(xiàng)
- pud = pud_alloc(mm, pgd, address); // 獲取虛擬內(nèi)存地址對應(yīng)的頁上級目錄項(xiàng)
- ...
- pmd = pmd_alloc(mm, pud, address); // 獲取虛擬內(nèi)存地址對應(yīng)的頁中間目錄項(xiàng)
- ...
- pte = pte_alloc_map(mm, pmd, address); // 獲取虛擬內(nèi)存地址對應(yīng)的頁表項(xiàng)
- ...
- // 對頁表項(xiàng)進(jìn)行映射
- return handle_pte_fault(mm, vma, address, pte, pmd, flags);
- 18}
handle_mm_fault 函數(shù)主要對每一級的頁表進(jìn)行映射(對照 圖3 就容易理解),最終調(diào)用 handle_pte_fault 函數(shù)對 頁表項(xiàng) 進(jìn)行映射。
我們繼續(xù)來分析 handle_pte_fault 函數(shù)的實(shí)現(xiàn),代碼如下:
- static inline int
- handle_pte_fault(struct mm_struct *mm, struct vm_area_struct *vma,
- unsigned long address, pte_t *pte, pmd_t *pmd,
- unsigned int flags)
- {
- pte_t entry;
- entry = *pte;
- if (!pte_present(entry)) { // 還沒有映射到物理內(nèi)存
- if (pte_none(entry)) {
- ...
- // 調(diào)用 do_anonymous_page 函數(shù)進(jìn)行匿名頁映射(堆空間就是使用匿名頁)
- return do_anonymous_page(mm, vma, address, pte, pmd, flags);
- }
- ...
- }
- ...
- }
上面代碼簡化了很多與本文無關(guān)的邏輯。從上面代碼可以看出,handle_pte_fault 函數(shù)最終會(huì)調(diào)用 do_anonymous_page 來完成內(nèi)存映射操作,我們接著來分析下 do_anonymous_page 函數(shù)的實(shí)現(xiàn):
- static int
- do_anonymous_page(struct mm_struct *mm, struct vm_area_struct *vma,
- unsigned long address, pte_t *page_table, pmd_t *pmd,
- unsigned int flags)
- {
- struct page *page;
- spinlock_t *ptl;
- pte_t entry;
- if (!(flags & FAULT_FLAG_WRITE)) { // 如果是讀操作導(dǎo)致的異常
- // 使用 `零頁` 進(jìn)行映射
- entry = pte_mkspecial(pfn_pte(my_zero_pfn(address), vma->vm_page_prot));
- ...
- goto setpte;
- }
- ...
- // 如果是寫操作導(dǎo)致的異常
- // 申請一塊新的物理內(nèi)存頁
- page = alloc_zeroed_user_highpage_movable(vma, address);
- ...
- // 根據(jù)物理內(nèi)存頁的地址生成映射關(guān)系
- entry = mk_pte(page, vma->vm_page_prot);
- if (vma->vm_flags & VM_WRITE)
- entry = pte_mkwrite(pte_mkdirty(entry));
- ...
- setpte:
- set_pte_at(mm, address, page_table, entry); // 設(shè)置頁表項(xiàng)為新的映射關(guān)系
- ...
- return 0;
- }
do_anonymous_page 函數(shù)的實(shí)現(xiàn)比較有趣,它會(huì)根據(jù) 缺頁異常 是由讀操作還是寫操作導(dǎo)致的,分為兩個(gè)不同的處理邏輯,如下:
如果是讀操作導(dǎo)致的,那么將會(huì)使用 零頁 進(jìn)行映射(零頁 是 Linux 內(nèi)核中一個(gè)比較特殊的內(nèi)存頁,所有讀操作引起的 缺頁異常 都會(huì)指向此頁,從而可以減少物理內(nèi)存的消耗),并且設(shè)置其為只讀(因?yàn)?零頁 是不能進(jìn)行寫操作)。如果下次對此頁進(jìn)行寫操作,將會(huì)觸發(fā)寫操作的 缺頁異常,從而進(jìn)入下面步驟。
如果是寫操作導(dǎo)致的,就申請一塊新的物理內(nèi)存頁,然后根據(jù)物理內(nèi)存頁的地址生成映射關(guān)系,再對頁表項(xiàng)進(jìn)行填充(映射)。
總結(jié)
本文主要介紹了 Linux 內(nèi)存分配的整個(gè)過程,當(dāng)然只是介紹從堆空間分配的內(nèi)存的過程。Linux 分配內(nèi)存的方式還有很多,比如 mmap、HugePages 等,有興趣的可以查閱相關(guān)的資料和書籍。