深入淺出linux內存管理(一)

前言

最近斷斷續續補充了一些linux內存管理的知識。包括之前看 nginx 源碼,看 tcmalloc 原理也有一些心得。對於內存管理這個話題也有了一些淺薄的見解。現在針對 linux 下的內存管理這個話題做一個整理,整合一些目前學到的內存管理相關知識。
本文涉及操作系統層面的內存管理原理,同時也包括現在主流的內存管理方式,並結合一些優秀的開源項目來針對不同場景的內存管理去理解。在開始之前,我們需要先提出幾個問題,以便更有深度的思考。

  1. 什麼是內存管理? 爲什麼要進行內存管理?
  2. 當前有哪些主流的內存管理方式?
  3. 不同場景下應該如何做好內存管理?
  4. 如何衡量內存管理的效率?

現在,先回到初學者的狀態,帶着以上的問題一步步去解決這些疑問。

linux 虛擬內存系統

虛擬尋址

說到 linux 內存管理,必然要談一談 linux 的虛擬內存系統。我們先從操作系統最底層的內存組織原理開始,一步一步往應用層去理解。
首先,大家都是知道,整個計算機最基本的體系結構就是 CPU+內存。CPU負責計算,內存負責存儲臨時的計算數據。當然實際模型比這個要複雜的多。那麼現在就涉及一個尋址的問題,也就是CPU需要獲取內存的數據,如何進行內存尋址?

假設我們的內存被組織成一個字節數組,每個字節都有唯一的物理地址,按照這種方式,CPU訪問內存最簡單的就是使用物理尋址。
在這裏插入圖片描述

物理尋址有很多問題,一個是進程之間不隔離的問題,如果多個進程同時運行,怎麼能保證進程A不越權訪問進程B的內存呢?一個是內存劃分的問題,沒有一種有效的方式可以最大效率的滿足的各個進程對內存的使用。當然物理尋址方式更加簡單粗暴,現在一些嵌入式系統也採用物理尋址方式。這不是我們當前討論範圍,因爲現代操作系統基本都採用另一種尋址方式——虛擬尋址。
由虛擬尋址又引申出另一個概念——虛擬內存空間。
由虛擬地址組織起來的虛擬內存是一個抽象概念,它使得每個進程都獨佔的使用整個主存。每個進程看到的內存都是一致的,這個進程的內存空間叫做虛擬地址空間。
這樣說可能很不好理解。我們舉個例子。
首先從CPU到磁盤有多級緩存,從上至下分別是寄存器,L1/L2/L3(SRAM) 高速緩存,DRAM,磁盤。
在這裏插入圖片描述

DRAM 呢就是我們最熟悉的內存。比如一臺筆記本內存是16GB,DRAM就是16GB。這16GB是實實在在的物理內存大小。但是對於每個進程就不一樣了。每個進程都有一個虛擬內存空間,這個虛擬內存空間的大小是多少呢? 對於32 位操作系統,是4GB。
爲什麼是4GB呢?
這個大小其實是機器的字長決定的。因爲CPU和內存之間傳送數據是通過系統總線實現的,系統總線每次傳輸單元是一個字。在32位系統上,1個字=32位=4個字節。1個字可以表示的最大範圍是 0~2^32,我們用16進制來表示地址的話,1個字可以表示的最大地址就是 0xFFFFFFFF,也就是虛擬內存空間的尋址範圍是 0~0xFFFFFFFF,也就是4GB。
那麼對於一個32位操作系統,16G內存的計算機來說,每個進程的虛擬內存空間都是4GB大小,這些進程共同使用DRAM=16G的物理內存。當然對於64位操作系統,進程的虛擬內存空間就遠遠不止4GB了,而是 2^64 = 128TB 大小。這個暫時不予討論,我們只關注32位系統下的。

現在既然每個進程都有4GB的虛擬空間,而實際的物理內存又只有16G,那就要考慮如何將這些進程的虛擬內存映射到物理內存上。
上文提到的虛擬尋址就是如何根據一個虛擬空間的地址,獲取到實際的物理內存地址。

負責虛擬地址到物理地址的轉換的是一個叫做MMU( memory manage unit , 虛擬內存管理單元) 的硬件。
每當CPU需要訪問物理內存時,都會產生一個虛擬地址,這個虛擬地址被 MMU 翻譯成物理內存地址。爲了方便地址的管理和調用,linux 將內存分成頁,一頁的大小是4KB,不管是虛擬內存還是物理內存,管理和調度的單元都是以頁爲單位進行的,每一頁已分配的虛擬內存都對應着一頁物理內存。此時虛擬內存空間的頁,我們叫做虛擬頁 VP(Vistural Page), 物理內存的頁叫做物理頁 PP(Physical Page),物理頁又叫頁幀。
MMU 並不存儲這個映射關係,這個映射關係存儲在一個叫做頁表(Page Table)的地方, 頁表是一個結構爲PTE(Page Table Entry) 的數組。每一個PTE都存儲了一個虛擬頁到物理頁的映射。

接下來我們需要知道 PTE 的內容。根據虛擬頁是否分配了物理頁和是否緩存的角度來區分虛擬內存頁的話,分爲3種虛擬內存頁:

  1. 未分配物理頁
  2. 已分配物理頁,且已緩存。
  3. 已分配物理頁,但未緩存。
    爲了區分上述3種情況,在一個PTE中有一個有效標誌位和一個指向物理內存的地址。其中有效標誌位,用於表明是否緩存在DRAM中。
    如果地址爲空,表示沒有爲虛擬頁分配對應的物理頁。
    如果有效位爲1,則表示物理內存頁已分配且已緩存,此時地址指向DRAM中物理內存頁的地址。
    如果有效位爲0,則表示物理內存頁已分配但緩存未命中。
    在這裏插入圖片描述

如圖所示是8個虛擬頁和4個物理頁。其中4個虛擬頁VP1,VP2,VP7,VP4都被緩存在DRAM中,其地址都不爲空,且有效位爲1。VP0和VP5 的地址是空的,表示還沒有分配,VP3,VP6雖然分配了地址,但是有效位爲0,表示沒有被緩存。
上述頁表一般是緩存在L1 cache 中的,而MMU到 L1 cache 所需的指令週期也很長,所以MMU自己也做了一個小緩存,叫做翻譯後備緩衝器TLB((translation Lookaside buffer)。當MMU需要將虛擬內存地址轉換爲DRAM中的內存地址時,此時先查TLB,如果緩存命中直接就得到了DRAM中的地址,否則就需要到頁表中去查。
查詢頁表,找到當前虛擬頁對應的PTE,然後根據PTE的有效標誌位判斷DRAM中是否有緩存,
如果有緩存則直接根據地址去DRAM中獲取數據。如果DRAM緩存不命中,此時將觸發一個缺頁異常。
缺頁異常將程序的控制權轉移給一個缺頁異常程序,缺頁異常程序從DRAM中選擇一個犧牲頁(如果犧牲頁被修改過,會將犧牲頁回寫入磁盤),同時將所需頁面從磁盤複製到DRAM中,替換掉犧牲頁。
當然上述換頁之後,PTE中的有效標誌位也會隨之更新。

在這裏插入圖片描述

這種設計方式會導致程序的性能降低嗎?可能會有人疑問,每個進程都擁有4GB的虛擬內存,但是實際的物理內存卻只有16GB,也就是 DRAM中緩存的物理頁是遠遠小於進程中使用的虛擬頁的,會不會頻繁的出現缺頁異常。實際上,由於程序的局部性原理,進程總是趨向於在一個較小的活動頁面集合上工作,這個工作集合或者說常駐集合,在第一次訪問的時候被 cache 到 DRAM 中之後,後續都會命中緩存。只有當的這個常駐集合大於物理內存的時候,纔會產生不斷的換頁,此時就是內存不夠用了。需要優化程序的性能。

接下來我們繼續討論 PTE,實際上PTE不僅僅只有一個有效標誌位。還記得之前程序隔離的問題嗎?如何保證進程A不會訪問進程B的虛擬頁,如何保證用戶態不會訪問內核態的虛擬頁?答案還是在 PTE裏。
在這裏插入圖片描述

比如上圖中PTE添加了3個許可位。SUP表示進程是否必須運行在內核模式才能訪問該頁。SUP爲1 的頁在用戶態是無法訪問的。同時還有讀權限和寫權限。當某個指令進行越權訪問時,CPU就會觸發一個段錯誤。
一般來說,PTE可以實現以下的功能:

  1. 每個進程的代碼段所在頁是不可修改的
  2. 內核的代碼和數據結構所在頁也是不可修改的
  3. 進程不能讀寫其他進程的私有內存頁
  4. 進程間通信可以通過設置進程間共享頁來實現。即允許多個進程對某一頁進行讀寫。

多級頁表

說完了PTE,接下來再看看頁表。我們先算一算對於32位系統,頁表有多大,一頁4KB,虛擬內存空間是4GB,也就是有4GB/4KB = 10^6 個頁,假設一個 PTE 大小是4個字節,頁表的大小也達到了 4GB/4KB* 4Byte = 4MB。由於頁表是緩存在 L1 裏的。4MB可不是個小數目。
更麻煩的是,如果是64 位系統,虛擬內存空間是128 TB 。頁表的大小將指數增長。
頁表多了也會非常浪費資源,而實際上一個進程雖然有4GB的虛擬內存空間,但大部分進程都不會用滿4GB,這就導致很多頁表項都是空的。爲了壓縮頁表,就需要使用多級頁表,將頁按層次的組織。
以2級頁表爲例,2級頁表每1024頁爲一個單位,由最上層的1級頁表索引,1級頁表的每個PTE將不再代表1頁,而是1024頁,也就是4MB。
如果1級頁表的PTE爲空,表示接下來的4MB內存都是空的,只有PTE中任意一頁不爲空的時候,纔會指向2級頁表。
在這裏插入圖片描述

這種組織方式就類似一顆樹或一個跳錶結構一樣,基於底層的4KB頁建立多級索引。

內存映射

講完了虛擬尋址的過程,接下來我們需要了解進程是如何和虛擬內存空間相關聯的。當然,原理上是通過頁的方式,但是我們需要更進一步的瞭解這個關聯過程。

首先祭出一張大家都非常熟悉的圖,就是32位系統下的進程空間分佈。
在這裏插入圖片描述

最上面的1GB是內核內存空間,用戶態無法訪問。
內核空間向下隨機偏移一個值就是棧空間的起始地址。棧是向下生長的,棧空間最大是8M。
然後再偏移一個隨機值,就是共享內存映射區域的起始地址。同時堆空間向上增長,與共享內存區域相對增長直到耗盡所有可用的區域。
隨機偏移是爲了防止緩衝區溢出的攻擊,畢竟如果每次棧和堆和 mmap 的起始地址都固定的話,非常容易受到攻擊。

接着在代碼層面,linux 爲每個進程都對應了一個結構體 task_struct。
在這裏插入圖片描述
可以看到 mmp 指向一個 vm_area_struct 的鏈表,每個 vm_area_struct 都描述了進程空間中的一個區域。一個具體的區域包括以下字段:

  • vm_start :起始地址。
  • vm_end : 結束地址。
  • vm_prot : 該區域所在頁的讀寫權限。
  • vm_flags : 是共享的還是私有的。
  • vm_next : 下一個區域結構。

有了區域的概念,我們再仔細回顧一下缺頁異常的處理:
當 MMU 試圖尋址一個虛擬地址A時,發現不在 DRAM中,於是觸發一個缺頁異常。接着缺頁異常處理程序將執行以下步驟:

  1. A 是否合法?也就是在不在已有的區域內?此時處理程序需要遍歷整個區域鏈表,看 A 是不是在 vm_start ~ vm_end 中。如果 A 不合法,此時就會觸發一個段錯誤(Segment Fault)。進程退出。當然實際linux爲了提高查找效率額外用一顆樹來組織這些區域。這點暫時不表。
  2. 當A 的地址合法之後,接下來判斷訪問是否合法,也就是是否有讀寫的權限?有些區域只有只讀權限,有些區域只能由內核訪問,任何越權訪問的行爲會觸發一個保護異常。進程終止。
  3. 當地址合法,訪問也合法之後,就會向之前提到的那樣,從 DRAM 中找一個犧牲頁,然後淘汰,換上新的頁。

以下就是整個缺頁異常的處理過程。
在這裏插入圖片描述

有了區域的概念,現在再說下內存映射。內存映射指的是 linux 可以將進程中的區域和一個磁盤對象關聯起來,來初始化這個區域的內容。
這個關聯的對象分爲2種:

  1. linux 系統中普通文件。
  2. 匿名文件。這種情況也叫請求二進制0的頁。

當程序啓動,可執行文件被加載到內存中,就是第一種情況,而我們在堆上或共享內存區域去申請一塊可用內存,就屬於第二種情況。
根據映射對象的訪問性質呢,又可以分爲2種類型——私有對象和共享對象。
比如很多進程會共享相同的內核代碼,這一部分公共代碼映射的就是共享對象。
內存映射有以下幾個好處:

  1. 共享對象的映射減少了浪費,被共享的對象避免了在每個進程間都拷貝一份副本。
  2. 共享對象可以實現進程間的通信。通過多個進程同時讀寫同一個虛擬內存區域,可以進行通信。

下圖展示了一個進程被加載到內存中的時候,不同區域與私有對象和共享對象的映射關係。
在這裏插入圖片描述

講完了進程地址空間的映射關係,接下來說一說與實際開發相關的內存管理API。
寫過C的都知道,在C裏面進行內存的分配和釋放是用的標準庫的API malloc() / free()
malloc/free 實際上並不是系統調用,而是linux系統庫 glibc 標準庫函數。linux 提供的真正的申請內存的函數是 brk()/mmap()

#include <unistd.h> 
int brk( const void *addr);

上述兩個函數的作用都是擴展 heap 的上界 brk
brk()的參數設置爲新的 brk 上界地址,成功返回1,失敗返回 0;
當然,在標準庫裏還有一個

void* sbrk ( intptr_t incr ); 

sbrk()的參數爲申請內存的大小,返回heap新的上界brk的地址。
brk 函數是用於 heap 區域內存的申請。共享內存區域是通過另一個函數來分配的:

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

mmap分爲2種用法:
一種是映射此盤文件到內存中,動態鏈接庫就是用這種方式加載的。比如 dlopen() 等等。
一種是匿名映射,向映射區申請一塊內存。 比如 malloc 。

這裏使用 mmap 的內存映射和上述的內存映射是一樣的,只是一個是用戶級的內存映射和內核級的內存映射。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章