你真的理解內存分配嗎?
內存是計算機中必不可少的資源,因爲 CPU 只能直接讀取內存中的數據,所以當 CPU 需要讀取外部設備(如硬盤)的數據時,必須先把數據加載到內存中。
我們來看看可愛的內存長什麼樣子的吧,如圖 1 所示:
一、內存申請
通常使用高級語言(如 Go、Java 或 Python 等)都不需要自己管理內存(因爲有垃圾回收機制),但 C/C++ 程序員就經常要與內存打交道。
當我們使用 C/C++ 編寫程序時,如果需要使用內存,就必須先調用 malloc
函數來申請一塊內存。但是,malloc
真的是申請了內存嗎?
我們通過下面例子來觀察 malloc
到底是不是真的申請了內存:
1#include <stdlib.h>
2
3int main(int argc, char const *argv[])
4{
5 void *ptr;
6
7 ptr = malloc(1024 * 1024 * 1024); // 申請 1GB 內存
8
9 sleep(3600); // 睡眠3600秒, 方便調試
10
11 return 0;
12}
上面的程序主要通過調用 malloc
函數來申請了 1GB 的內存,然後睡眠 3600 秒,方便我們查看其內存使用情況。
現在,我們編譯上面的程序並且運行,如下:
1$ gcc malloc.c -o malloc
2$ ./malloc
並且我們打開一個新的終端,然後查看其內存使用情況,如圖 2 所示:
圖 2 中的 VmRSS
表示進程使用的物理內存大小,但我們明明申請了 1GB 的內存,爲什麼只顯示使用 404KB 的內存呢?這裏就涉及到 虛擬內存
和 物理內存
的概念了。
二、物理內存與虛擬內存
下面先來介紹一下 物理內存
與 虛擬內存
的概念:
-
物理內存
:也就是安裝在計算機中的內存條,比如安裝了 2GB 大小的內存條,那麼物理內存地址的範圍就是 0 ~ 2GB。 -
虛擬內存
:虛擬的內存地址。由於 CPU 只能使用物理內存地址,所以需要將虛擬內存地址轉換爲物理內存地址才能被 CPU 使用,這個轉換過程由MMU(Memory Management Unit,內存管理單元)
來完成。虛擬內存
大小不受物理內存
大小的限制,在 32 位的操作系統中,每個進程的虛擬內存空間大小爲 0 ~ 4GB。
程序中使用的內存地址都是虛擬內存地址,也就是說,我們通過 malloc
函數申請的內存都是虛擬內存。實際上,內核會爲每個進程管理其虛擬內存空間,並且會把虛擬內存空間劃分爲多個區域,如 圖 3 所示:
我們來分析一下這些區域的作用:
-
代碼段
:用於存放程序的可執行代碼。 -
數據段
:用於存放程序的全局變量和靜態變量。 -
堆空間
:用於存放由malloc
申請的內存。 -
棧空間
:用於存放函數的參數和局部變量。 -
內核空間
:存放 Linux 內核代碼和數據。
三、brk 指針
由此可知,通過 malloc
函數申請的內存地址是由 堆空間
分配的(其實還有可能從 mmap
區分配,這種情況暫時忽略)。在內核中,使用一個名爲 brk
的指針來表示進程的 堆空間
的頂部,如 圖 4 所示:
所以,通過移動 brk
指針就可以達到申請(向上移動)和釋放(向下移動)堆空間的內存。例如申請 1024 字節時,只需要把 brk
向上移動 1024 字節即可,如 圖 5 所示:
事實上,malloc
函數就是通過移動 brk
指針來實現申請和釋放內存的,Linux 提供了一個名爲 brk()
的系統調用來移動 brk
指針。
四、內存映射
現在我們知道,malloc
函數只是移動 brk
指針,但並沒有申請物理內存。前面我們介紹虛擬內存和物理內存的時候介紹過,虛擬內存地址必須映射到物理內存地址才能被使用。如 圖 6 所示:
如果對沒有進行映射的虛擬內存地址進行讀寫操作,那麼將會發生 缺頁異常
。Linux 內核會對 缺頁異常
進行修復,修復過程如下:
-
獲取觸發
缺頁異常
的虛擬內存地址(讀寫哪個虛擬內存地址導致的)。 -
查看此虛擬內存地址是否被申請(是否在
brk
指針內),如果不在brk
指針內,將會導致 Segmention Fault 錯誤(也就是常見的 coredump),進程將會異常退出。 -
如果虛擬內存地址在
brk
指針內,那麼將此虛擬內存地址映射到物理內存地址上,完成缺頁異常
修復過程,並且返回到觸發異常的地方進行運行。
從上面的過程可以看出,不對申請的虛擬內存地址進行讀寫操作是不會觸發申請新的物理內存。所以,這就解釋了爲什麼申請 1GB 的內存,但實際上只使用了 404 KB 的物理內存。
五、總結
本文主要解釋了內存申請的原理,並且瞭解到 malloc
申請的只是虛擬內存,而且物理內存的申請延遲到對虛擬內存進行讀寫的時候,這樣做可以減輕進程對物理內存使用的壓力。
我們介紹了 malloc
申請內存的原理,但其在內核怎麼實現的呢?所以,本文主要分析在 Linux 內核中對堆內存分配的實現過程。
本文使用 Linux 2.6.32 版本代碼
六、內存分區對象
在前文中介紹過,Linux 會把進程虛擬內存空間劃分爲多個分區,在 Linux 內核中使用 vm_area_struct
對象來表示,其定義如下:
1struct vm_area_struct {
2 struct mm_struct *vm_mm; // 分區所屬的內存管理對象
3
4 unsigned long vm_start; // 分區的開始地址
5 unsigned long vm_end; // 分區的結束地址
6
7 struct vm_area_struct *vm_next; // 通過這個指針把進程所有的內存分區連接成一個鏈表
8 ...
9 struct rb_node vm_rb; // 紅黑樹的節點, 用於保存到內存分區紅黑樹中
10 ...
11};
我們對 vm_area_struct 對象進行了簡化,只保留了本文需要的字段。
內核就是使用 vm_area_struct
對象來記錄一個內存分區(如 代碼段
、數據段
和 堆空間
等),下面介紹一下 vm_area_struct
對象各個字段的作用:
-
vm_mm
:指定了當前內存分區所屬的內存管理對象。 -
vm_start
:內存分區的開始地址。 -
vm_end
:內存分區的結束地址。 -
vm_next
:通過這個指針把進程中所有的內存分區連接成一個鏈表。 -
vm_rb
:另外,爲了快速查找內存分區,內核還把進程的所有內存分區保存到一棵紅黑樹中。vm_rb
就是紅黑樹的節點,用於把內存分區保存到紅黑樹中。
假如進程 A 現在有 4 個內存分區,它們的範圍如下:
-
代碼段
:00400000 ~ 00401000 -
數據段
:00600000 ~ 00601000 -
堆空間
:00983000 ~ 009a4000 -
棧空間
:7f37ce866000 ~ 7f3fce867000
那麼這 4 個內存分區在內核中的結構如 圖 1 所示:
在 圖 1 中,我們可以看到有個 mm_struct
的對象,此對象每個進程都持有一個,是進程虛擬內存空間和物理內存空間的管理對象。我們簡單介紹一下這個對象,其定義如下:
1struct mm_struct {
2 struct vm_area_struct *mmap; // 指向由進程內存分區連接成的鏈表
3 struct rb_root mm_rb; // 內核使用紅黑樹保存進程的所有內存分區, 這個是紅黑樹的根節點
4 unsigned long start_brk, brk; // 堆空間的開始地址和結束地址
5 ...
6};
我們來介紹下 mm_struct
對象各個字段的作用:
-
mmap
:指向由進程所有內存分區連接成的鏈表。 -
mm_rb
:內核爲了加快查找內存分區的速度,使用了紅黑樹保存所有內存分區,這個就是紅黑樹的根節點。 -
start_brk
:堆空間的開始內存地址。 -
brk
:堆空間的頂部內存地址。
我們來回顧一下進程虛擬內存空間的佈局圖,如 圖 2 所示:
start_brk
和 brk
字段用來記錄堆空間的範圍, 如 圖 2 所示。一般來說,start_brk
是不會變的,而 brk
會隨着分配內存和釋放內存而變化。
七、虛擬內存分配
在前文中說過,調用 malloc
申請內存時,最終會調用 brk
系統調用來從堆空間中分配內存。我們來分析一下 brk
系統調用的實現:
1unsigned long sys_brk(unsigned long brk)
2{
3 unsigned long rlim, retval;
4 unsigned long newbrk, oldbrk;
5 struct mm_struct *mm = current->mm;
6 ...
7 down_write(&mm->mmap_sem); // 對內存管理對象進行上鎖
8 ...
9 // 判斷堆空間的大小是否超出限制, 如果超出限制, 就不進行處理
10 rlim = current->signal->rlim[RLIMIT_DATA].rlim_cur;
11 if (rlim < RLIM_INFINITY
12 && (brk - mm->start_brk) + (mm->end_data - mm->start_data) > rlim)
13 goto out;
14
15 newbrk = PAGE_ALIGN(brk); // 新的brk值
16 oldbrk = PAGE_ALIGN(mm->brk); // 舊的brk值
17 if (oldbrk == newbrk) // 如果新舊的位置都一樣, 就不需要進行處理
18 goto set_brk;
19 ...
20 // 調用 do_brk 函數進行下一步處理
21 if (do_brk(oldbrk, newbrk-oldbrk) != oldbrk)
22 goto out;
23
24set_brk:
25 mm->brk = brk; // 設置堆空間的頂部位置(brk指針)
26out:
27 retval = mm->brk;
28 up_write(&mm->mmap_sem);
29 return retval;
30}
總結上面的代碼,主要有以下幾個步驟:
-
1、判斷堆空間的大小是否超出限制,如果超出限制,就不作任何處理,直接返回舊的
brk
值。 -
2、如果新的
brk
值跟舊的brk
值一致,那麼也不用作任何處理。 -
3、如果新的
brk
值發生變化,那麼就調用do_brk
函數進行下一步處理。 -
4、設置進程的
brk
指針(堆空間頂部)爲新的brk
的值。
我們看到第 3 步調用了 do_brk
函數來處理,do_brk
函數的實現有點小複雜,所以這裏介紹一下大概處理流程:
-
通過堆空間的起始地址
start_brk
從進程內存分區紅黑樹中找到其對應的內存分區對象(也就是vm_area_struct
)。 -
把堆空間的內存分區對象的
vm_end
字段設置爲新的brk
值。
至此,brk
系統調用的工作就完成了(上面沒有分析釋放內存的情況),總結來說,brk
系統調用的工作主要有兩部分:
-
把進程的
brk
指針設置爲新的brk
值。 -
把堆空間的內存分區對象的
vm_end
字段設置爲新的brk
值。
八、物理內存分配
從上面的分析知道,brk
系統調用申請的是 虛擬內存
,但存儲數據只能使用 物理內存
。所以,虛擬內存必須映射到物理內存才能被使用。
那麼什麼時候才進行內存映射呢?
在前面介紹過,當對沒有映射的虛擬內存地址進行讀寫操作時,CPU 將會觸發 缺頁異常
。內核接收到 缺頁異常
後, 會調用 do_page_fault
函數進行修復。
我們來分析一下 do_page_fault
函數的實現(精簡後):
1void do_page_fault(struct pt_regs *regs, unsigned long error_code)
2{
3 struct vm_area_struct *vma;
4 struct task_struct *tsk;
5 unsigned long address;
6 struct mm_struct *mm;
7 int write;
8 int fault;
9
10 tsk = current;
11 mm = tsk->mm;
12
13 address = read_cr2(); // 獲取導致頁缺失異常的虛擬內存地址
14 ...
15 vma = find_vma(mm, address); // 通過虛擬內存地址從進程內存分區中查找對應的內存分區對象
16 ...
17 if (likely(vma->vm_start <= address)) // 如果找到內存分區對象
18 goto good_area;
19 ...
20
21good_area:
22 write = error_code & PF_WRITE;
23 ...
24 // 調用 handle_mm_fault 函數對虛擬內存地址進行映射操作
25 fault = handle_mm_fault(mm, vma, address, write ? FAULT_FLAG_WRITE : 0);
26 ...
27}
do_page_fault
函數主要完成以下操作:
-
獲取導致頁缺失異常的虛擬內存地址,保存到
address
變量中。 -
調用
find_vma
函數從進程內存分區中查找異常的虛擬內存地址對應的內存分區對象。 -
如果找到內存分區對象,那麼調用
handle_mm_fault
函數對虛擬內存地址進行映射操作。
從上面的分析可知,對虛擬內存進行映射操作是通過 handle_mm_fault
函數完成的,而 handle_mm_fault
函數的主要工作就是完成對進程 頁表
的填充。
我們通過 圖 3 來理解內存映射的原理:
下面我們來分析一下 handle_mm_fault
的實現,代碼如下:
1int handle_mm_fault(struct mm_struct *mm, struct vm_area_struct *vma,
2 unsigned long address, unsigned int flags)
3{
4 pgd_t *pgd; // 頁全局目錄項
5 pud_t *pud; // 頁上級目錄項
6 pmd_t *pmd; // 頁中間目錄項
7 pte_t *pte; // 頁表項
8 ...
9 pgd = pgd_offset(mm, address); // 獲取虛擬內存地址對應的頁全局目錄項
10 pud = pud_alloc(mm, pgd, address); // 獲取虛擬內存地址對應的頁上級目錄項
11 ...
12 pmd = pmd_alloc(mm, pud, address); // 獲取虛擬內存地址對應的頁中間目錄項
13 ...
14 pte = pte_alloc_map(mm, pmd, address); // 獲取虛擬內存地址對應的頁表項
15 ...
16 // 對頁表項進行映射
17 return handle_pte_fault(mm, vma, address, pte, pmd, flags);
18}
handle_mm_fault
函數主要對每一級的頁表進行映射(對照 圖 3 就容易理解),最終調用 handle_pte_fault
函數對 頁表項
進行映射。
我們繼續來分析 handle_pte_fault
函數的實現,代碼如下:
1static inline int
2handle_pte_fault(struct mm_struct *mm, struct vm_area_struct *vma,
3 unsigned long address, pte_t *pte, pmd_t *pmd,
4 unsigned int flags)
5{
6 pte_t entry;
7
8 entry = *pte;
9
10 if (!pte_present(entry)) { // 還沒有映射到物理內存
11 if (pte_none(entry)) {
12 ...
13 // 調用 do_anonymous_page 函數進行匿名頁映射(堆空間就是使用匿名頁)
14 return do_anonymous_page(mm, vma, address, pte, pmd, flags);
15 }
16 ...
17 }
18 ...
19}
上面代碼簡化了很多與本文無關的邏輯。從上面代碼可以看出,handle_pte_fault
函數最終會調用 do_anonymous_page
來完成內存映射操作,我們接着來分析下 do_anonymous_page
函數的實現:
1static int
2do_anonymous_page(struct mm_struct *mm, struct vm_area_struct *vma,
3 unsigned long address, pte_t *page_table, pmd_t *pmd,
4 unsigned int flags)
5{
6 struct page *page;
7 spinlock_t *ptl;
8 pte_t entry;
9
10 if (!(flags & FAULT_FLAG_WRITE)) { // 如果是讀操作導致的異常
11 // 使用 `零頁` 進行映射
12 entry = pte_mkspecial(pfn_pte(my_zero_pfn(address), vma->vm_page_prot));
13 ...
14 goto setpte;
15 }
16 ...
17 // 如果是寫操作導致的異常
18 // 申請一塊新的物理內存頁
19 page = alloc_zeroed_user_highpage_movable(vma, address);
20 ...
21 // 根據物理內存頁的地址生成映射關係
22 entry = mk_pte(page, vma->vm_page_prot);
23 if (vma->vm_flags & VM_WRITE)
24 entry = pte_mkwrite(pte_mkdirty(entry));
25 ...
26setpte:
27 set_pte_at(mm, address, page_table, entry); // 設置頁表項爲新的映射關係
28 ...
29 return 0;
30}
do_anonymous_page
函數的實現比較有趣,它會根據 缺頁異常
是由讀操作還是寫操作導致的,分爲兩個不同的處理邏輯,如下:
-
如果是讀操作導致的,那麼將會使用
零頁
進行映射(零頁
是 Linux 內核中一個比較特殊的內存頁,所有讀操作引起的缺頁異常
都會指向此頁,從而可以減少物理內存的消耗),並且設置其爲只讀(因爲零頁
是不能進行寫操作)。如果下次對此頁進行寫操作,將會觸發寫操作的缺頁異常
,從而進入下面步驟。 -
如果是寫操作導致的,就申請一塊新的物理內存頁,然後根據物理內存頁的地址生成映射關係,再對頁表項進行填充(映射)。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/njbVUu_JStKMnnD9eb4_SQ