肝翻 Linux 內存管理所有知識點

Linux 的內存管理可謂是學好 Linux 的必經之路,也是 Linux 的關鍵知識點,有人說打通了內存管理的知識,也就打通了 Linux 的任督二脈,這一點不誇張。有人問網上有很多 Linux 內存管理的內容,爲什麼還要看你這一篇,這正是我寫此文的原因,網上碎片化的相關知識點大都是東拼西湊,先不說正確性與否,就連基本的邏輯都沒有搞清楚,我可以負責任的說 Linux 內存管理只需要看此文一篇就可以讓你入 Linux 內核的大門,省去你東找西找的時間,讓你形成內存管理知識的閉環。

文章比較長,做好準備,深呼吸,讓我們一起打開 Linux 內核的大門!

Linux 內存管理之 CPU 訪問內存的過程

我喜歡用圖的方式來說明問題,簡單直接:

藍色部分是 cpu,灰色部分是內存,白色部分就是 cpu 訪問內存的過程,也是地址轉換的過程。在解釋地址轉換的本質前我們先理解下幾個概念:

  1. TLB:MMU 工作的過程就是查詢頁表的過程。如果把頁表放在內存中查詢的時候開銷太大,因此爲了提高查找效率,專門用一小片訪問更快的區域存放地址轉換條目。(當頁表內容有變化的時候,需要清除 TLB,以防止地址映射出錯。)

  2. Caches:cpu 和內存之間的緩存機制,用於提高訪問速率,armv8 架構的話上圖的 caches 其實是 L2 Cache,這裏就不做進一步解釋了。

虛擬地址轉換爲物理地址的本質

我們知道內核中的尋址空間大小是由 CONFIG_ARM64_VA_BITS 控制的,這裏以 48 位爲例,ARMv8 中,Kernel Space 的頁表基地址存放在 TTBR1_EL1 寄存器中,User Space 頁表基地址存放在 TTBR0_EL0 寄存器中,其中內核地址空間的高位爲全 1,(0xFFFF0000_00000000 ~ 0xFFFFFFFF_FFFFFFFF),用戶地址空間的高位爲全 0,(0x00000000_00000000 ~ 0x0000FFFF_FFFFFFFF)

有了宏觀概念,下面我們以內核態尋址過程爲例看下是如何把虛擬地址轉換爲物理地址的。

我們知道 linux 採用了分頁機制,通常採用四級頁表,頁全局目錄 (PGD),頁上級目錄 (PUD),頁中間目錄 (PMD),頁表 (PTE)。如下:

  1. 從 CR3 寄存器中讀取頁目錄所在物理頁面的基址 (即所謂的頁目錄基址),從線性地址的第一部分獲取頁目錄項的索引,兩者相加得到頁目錄項的物理地址。

  2. 第一次讀取內存得到 pgd_t 結構的目錄項,從中取出物理頁基址取出,即頁上級頁目錄的物理基地址。

  3. 從線性地址的第二部分中取出頁上級目錄項的索引,與頁上級目錄基地址相加得到頁上級目錄項的物理地址。

  4. 第二次讀取內存得到 pud_t 結構的目錄項,從中取出頁中間目錄的物理基地址。

  5. 從線性地址的第三部分中取出頁中間目錄項的索引,與頁中間目錄基址相加得到頁中間目錄項的物理地址。

  6. 第三次讀取內存得到 pmd_t 結構的目錄項,從中取出頁表的物理基地址。

  7. 從線性地址的第四部分中取出頁表項的索引,與頁表基址相加得到頁表項的物理地址。

  8. 第四次讀取內存得到 pte_t 結構的目錄項,從中取出物理頁的基地址。

  9. 從線性地址的第五部分中取出物理頁內偏移量,與物理頁基址相加得到最終的物理地址。

  10. 第五次讀取內存得到最終要訪問的數據。

整個過程是比較機械的,每次轉換先獲取物理頁基地址,再從線性地址中獲取索引,合成物理地址後再訪問內存。不管是頁表還是要訪問的數據都是以頁爲單位存放在主存中的,因此每次訪問內存時都要先獲得基址,再通過索引 (或偏移) 在頁內訪問數據,因此可以將線性地址看作是若干個索引的集合。

Linux 內存初始化

有了 armv8 架構訪問內存的理解,我們來看下 linux 在內存這塊的初始化就更容易理解了。

創建啓動頁表:

在彙編代碼階段的 head.S 文件中,負責創建映射關係的函數是 create_page_tables。create_page_tables 函數負責 identity mapping 和 kernel image mapping。

arch/arm64/kernel/head.S:
ENTRY(stext)
        bl      preserve_boot_args
        bl      el2_setup                       // Drop to EL1, w0=cpu_boot_mode
        adrp    x23, __PHYS_OFFSET
        and     x23, x23, MIN_KIMG_ALIGN - 1    // KASLR offset, defaults to 0
        bl      set_cpu_boot_mode_flag
        bl      __create_page_tables
        /*
         * The following calls CPU setup code, see arch/arm64/mm/proc.S for
         * details.
         * On return, the CPU will be ready for the MMU to be turned on and
         * the TCR will have been set.
         */
        bl      __cpu_setup                     // initialise processor
        b       __primary_switch
ENDPROC(stext)

__create_page_tables 主要執行的就是 identity map 和 kernel image map:

 __create_page_tables:
......
        create_pgd_entry x0, x3, x5, x6
        mov     x5, x3                          // __pa(__idmap_text_start)
        adr_l   x6, __idmap_text_end            // __pa(__idmap_text_end)
        create_block_map x0, x7, x3, x5, x6

        /*
         * Map the kernel image (starting with PHYS_OFFSET).
         */
        adrp    x0, swapper_pg_dir
        mov_q   x5, KIMAGE_VADDR + TEXT_OFFSET  // compile time __va(_text)
        add     x5, x5, x23                     // add KASLR displacement
        create_pgd_entry x0, x5, x3, x6
        adrp    x6, _end                        // runtime __pa(_end)
        adrp    x3, _text                       // runtime __pa(_text)
        sub     x6, x6, x3                      // _end - _text
        add     x6, x6, x5                      // runtime __va(_end)
        create_block_map x0, x7, x3, x5, x6
 ......

其中調用 create_pgd_entry 進行 PGD 及所有中間 level(PUD, PMD) 頁表的創建,調用 create_block_map 進行 PTE 頁表的映射。關於四級頁表的關係如下圖所示,這裏就不進一步解釋了。

彙編結束後的內存映射關係如下圖所示:

等內存初始化後就可以進入真正的內存管理了,初始化我總結了一下,大體分爲四步:

  1. 物理內存進系統前

  2. 用 memblock 模塊來對內存進行管理

  3. 頁表映射

  4. zone 初始化

Linux 是如何組織物理內存的?

  1. 非一致性內存訪問 NUMA(Non-Uniform Memory Access)意思是內存被劃分爲各個 node,訪問一個 node 花費的時間取決於 CPU 離這個 node 的距離。每一個 cpu 內部有一個本地的 node,訪問本地 node 時間比訪問其他 node 的速度快

  2. 一致性內存訪問 UMA(Uniform Memory Access)也可以稱爲 SMP(Symmetric Multi-Process)對稱多處理器。意思是所有的處理器訪問內存花費的時間是一樣的。也可以理解整個內存只有一個 node。

ZONE 的意思是把整個物理內存劃分爲幾個區域,每個區域有特殊的含義

代表一個物理頁,在內核中一個物理頁用一個 struct page 表示。

爲了描述一個物理 page,內核使用 struct page 結構來表示一個物理頁。假設一個 page 的大小是 4K 的,內核會將整個物理內存分割成一個一個 4K 大小的物理頁,而 4K 大小物理頁的區域我們稱爲 page frame

pfn 是對每個 page frame 的編號。故物理地址和 pfn 的關係是:

物理地址 >>PAGE_SHIFT = pfn

內核中支持了好幾個內存模型:CONFIG_FLATMEM(平坦內存模型)CONFIG_DISCONTIGMEM(不連續內存模型)CONFIG_SPARSEMEM_VMEMMAP(稀疏的內存模型)目前 ARM64 使用的稀疏的類型模式。

系統啓動的時候,內核會將整個 struct page 映射到內核虛擬地址空間 vmemmap 的區域,所以我們可以簡單的認爲 struct page 的基地址是 vmemmap,則:

vmemmap+pfn 的地址就是此 struct page 對應的地址。

Linux 分區頁框分配器

頁框分配在內核裏的機制我們叫做分區頁框分配器 (zoned page frame allocator),在 linux 系統中,分區頁框分配器管理着所有物理內存,無論你是內核還是進程,都需要請求分區頁框分配器,這時纔會分配給你應該獲得的物理內存頁框。當你所擁有的頁框不再使用時,你必須釋放這些頁框,讓這些頁框回到管理區頁框分配器當中。

有時候目標管理區不一定有足夠的頁框去滿足分配,這時候系統會從另外兩個管理區中獲取要求的頁框,但這是按照一定規則去執行的,如下:

內核中根據不同的分配需求有 6 個函數接口來請求頁框,最終都會調用到__alloc_pages_nodemask。

struct page *
__alloc_pages_nodemask(gfp_t gfp_mask, unsigned int order, int preferred_nid,
       nodemask_t *nodemask)
{
  page = get_page_from_freelist(alloc_mask, order, alloc_flags, &ac);//fastpath分配頁面:從pcp(per_cpu_pages)和夥伴系統中正常的分配內存空間
  ......
  page = __alloc_pages_slowpath(alloc_mask, order, &ac);//slowpath分配頁面:如果上面沒有分配到空間,調用下面函數慢速分配,允許等待和回收
  ......
}

在頁面分配時,有兩種路徑可以選擇,如果在快速路徑中分配成功了,則直接返回分配的頁面;快速路徑分配失敗則選擇慢速路徑來進行分配。總結如下:

  1. 如果分配的是單個頁面,考慮從 per CPU 緩存中分配空間,如果緩存中沒有頁面,從夥伴系統中提取頁面做補充。

  2. 分配多個頁面時,從指定類型中分配,如果指定類型中沒有足夠的頁面,從備用類型鏈表中分配。最後會試探保留類型鏈表。

  1. 當上面兩種分配方案都不能滿足要求時,考慮頁面回收、殺死進程等操作後在試。

Linux 頁框分配器之夥伴算法

static struct page *
get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
      const struct alloc_context *ac)
{
  for_next_zone_zonelist_nodemask(zone, z, ac->zonelist, ac->high_zoneidx, ac->nodemask)
  {
    if (!zone_watermark_fast(zone, order, mark, ac_classzone_idx(ac), alloc_flags))
    {
      ret = node_reclaim(zone->zone_pgdat, gfp_mask, order); 
      switch (ret) {
      case NODE_RECLAIM_NOSCAN:
        continue;
      case NODE_RECLAIM_FULL:
        continue;
      default:
        if (zone_watermark_ok(zone, order, mark, ac_classzone_idx(ac), alloc_flags))
          goto try_this_zone;

        continue;
      }
    }
    
try_this_zone: //本zone正常水位
    page = rmqueue(ac->preferred_zoneref->zone, zone, order, gfp_mask, alloc_flags, ac->migratetype);
  }
  
  return NULL;
}

首先遍歷當前 zone,按照 HIGHMEM->NORMAL 的方向進行遍歷,判斷當前 zone 是否能夠進行內存分配的條件是首先判斷 free memory 是否滿足 low water mark 水位值,如果不滿足則進行一次快速的內存回收操作,然後再次檢測是否滿足 low water mark,如果還是不能滿足,相同步驟遍歷下一個 zone,滿足的話進入正常的分配情況,即 rmqueue 函數,這也是夥伴系統的核心。

Buddy 分配算法

在看函數前,我們先看下算法,因爲我一直認爲有了 “道” 的理解纔好進一步理解“術”。

假設這是一段連續的頁框,陰影部分表示已經被使用的頁框,現在需要申請一個連續的 5 個頁框。這個時候,在這段內存上不能找到連續的 5 個空閒的頁框,就會去另一段內存上去尋找 5 個連續的頁框,這樣子,久而久之就形成了頁框的浪費。爲了避免出現這種情況,Linux 內核中引入了夥伴系統算法 (Buddy system)。把所有的空閒頁框分組爲 11 個塊鏈表,每個塊鏈表分別包含大小爲 1,2,4,8,16,32,64,128,256,512 和 1024 個連續頁框的頁框塊。最大可以申請 1024 個連續頁框,對應 4MB 大小的連續內存。每個頁框塊的第一個頁框的物理地址是該塊大小的整數倍,如圖:

假設要申請一個 256 個頁框的塊,先從 256 個頁框的鏈表中查找空閒塊,如果沒有,就去 512 個頁框的鏈表中找,找到了則將頁框塊分爲 2 個 256 個頁框的塊,一個分配給應用,另外一個移到 256 個頁框的鏈表中。如果 512 個頁框的鏈表中仍沒有空閒塊,繼續向 1024 個頁框的鏈表查找,如果仍然沒有,則返回錯誤。頁框塊在釋放時,會主動將兩個連續的頁框塊合併爲一個較大的頁框塊。

從上面可以知道 Buddy 算法一直在對頁框做拆開合併拆開合併的動作。Buddy 算法牛逼就牛逼在運用了世界上任何正整數都可以由 2^n 的和組成。這也是 Buddy 算法管理空閒頁表的本質。空閒內存的信息我們可以通過以下命令獲取:

也可以通過 echo m > /proc/sysrq-trigger 來觀察 buddy 狀態,與 / proc/buddyinfo 的信息是一致的:

Buddy 分配函數

static inline
struct page *rmqueue(struct zone *preferred_zone,
   struct zone *zone, unsigned int order,
   gfp_t gfp_flags, unsigned int alloc_flags,
   int migratetype)
{
  if (likely(order == 0)) { //如果order=0則從pcp中分配
    page = rmqueue_pcplist(preferred_zone, zone, order, gfp_flags, migratetype);
 }
  do {
    page = NULL;
    if (alloc_flags & ALLOC_HARDER) {//如果分配標誌中設置了ALLOC_HARDER,則從free_list[MIGRATE_HIGHATOMIC]的鏈表中進行頁面分配
        page = __rmqueue_smallest(zone, order, MIGRATE_HIGHATOMIC);
    }
    if (!page) //前兩個條件都不滿足,則在正常的free_list[MIGRATE_*]中進行分配
      page = __rmqueue(zone, order, migratetype);
  } while (page && check_new_pages(page, order));
  ......
}

Linux 分區頁框分配器之水位

我們講頁框分配器的時候講到了快速分配和慢速分配,其中夥伴算法是在快速分配裏做的,忘記的小夥伴我們再看下:

static struct page *
get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
      const struct alloc_context *ac)
{
  for_next_zone_zonelist_nodemask(zone, z, ac->zonelist, ac->high_zoneidx, ac->nodemask)
  {
    if (!zone_watermark_fast(zone, order, mark, ac_classzone_idx(ac), alloc_flags))
    {
      ret = node_reclaim(zone->zone_pgdat, gfp_mask, order); 
      switch (ret) {
      case NODE_RECLAIM_NOSCAN:
        continue;
      case NODE_RECLAIM_FULL:
        continue;
      default:
        if (zone_watermark_ok(zone, order, mark, ac_classzone_idx(ac), alloc_flags))
          goto try_this_zone;

        continue;
      }
    }
    
try_this_zone: //本zone正常水位
    page = rmqueue(ac->preferred_zoneref->zone, zone, order, gfp_mask, alloc_flags, ac->migratetype);
  }
  
  return NULL;
}

可以看到在進行夥伴算法分配前有個關於水位的判斷,今天我們就看下水位的概念。

簡單的說在使用分區頁面分配器中會將可以用的 free pages 與 zone 裏的水位 (watermark) 進行比較。

水位初始化

從這張圖可以看出:

Linux 頁框分配器之內存碎片化整理

什麼是內存碎片化

Linux 物理內存碎片化包括兩種:內部碎片化和外部碎片化。

指分配給用戶的內存空間中未被使用的部分。例如進程需要使用 3K bytes 物理內存,於是向系統申請了大小等於 3Kbytes 的內存,但是由於 Linux 內核夥伴系統算法最小顆粒是 4K bytes,所以分配的是 4Kbytes 內存,那麼其中 1K bytes 未被使用的內存就是內存內碎片。

指系統中無法利用的小內存塊。例如系統剩餘內存爲 16K bytes,但是這 16K bytes 內存是由 4 個 4K bytes 的頁面組成,即 16K 內存物理頁幀號 #1 不連續。在系統剩餘 16K bytes 內存的情況下,系統卻無法成功分配大於 4K 的連續物理內存,該情況就是內存外碎片導致。

碎片化整理算法

Linux 內存對碎片化的整理算法主要應用了內核的頁面遷移機制,是一種將可移動頁面進行遷移後騰出連續物理內存的方法。

假設存在一個非常小的內存域如下:

藍色表示空閒的頁面,白色表示已經被分配的頁面,可以看到如上內存域的空閒頁面(藍色)非常零散,無法分配大於兩頁的連續物理內存。

下面演示一下內存規整的簡化工作原理,內核會運行兩個獨立的掃描動作:第一個掃描從內存域的底部開始,一邊掃描一邊將已分配的可移動(MOVABLE)頁面記錄到一個列表中:

另外第二掃描是從內存域的頂部開始,掃描可以作爲頁面遷移目標的空閒頁面位置,然後也記錄到一個列表裏面:

等兩個掃描在域中間相遇,意味着掃描結束,然後將左邊掃描得到的已分配的頁面遷移到右邊空閒的頁面中,左邊就形成了一段連續的物理內存,完成頁面規整。

碎片化整理的三種方式

static struct page *
__alloc_pages_direct_compact(gfp_t gfp_mask, unsigned int order,
  unsigned int alloc_flags, const struct alloc_context *ac,
  enum compact_priority prio, enum compact_result *compact_result)
{
 struct page *page;
 unsigned int noreclaim_flag;

 if (!order)
  return NULL;

 noreclaim_flag = memalloc_noreclaim_save();
 *compact_result = try_to_compact_pages(gfp_mask, order, alloc_flags, ac,
         prio);
 memalloc_noreclaim_restore(noreclaim_flag);

 if (*compact_result <= COMPACT_INACTIVE)
  return NULL;

 count_vm_event(COMPACTSTALL);

 page = get_page_from_freelist(gfp_mask, order, alloc_flags, ac);

 if (page) {
  struct zone *zone = page_zone(page);

  zone->compact_blockskip_flush = false;
  compaction_defer_reset(zone, order, true);
  count_vm_event(COMPACTSUCCESS);
  return page;
 }

 count_vm_event(COMPACTFAIL);

 cond_resched();

 return NULL;
}

在 linux 內核裏一共有 3 種方式可以碎片化整理,我們總結如下:

Linux slab 分配器

在 Linux 中,夥伴系統是以頁爲單位分配內存。但是現實中很多時候卻以字節爲單位,不然申請 10Bytes 內存還要給 1 頁的話就太浪費了。slab 分配器就是爲小內存分配而生的。slab 分配器分配內存以 Byte 爲單位。但是 slab 分配器並沒有脫離夥伴系統,而是基於夥伴系統分配的大內存進一步細分成小內存分配。

他們之間的關係可以用一張圖來描述:

流程分析

kmem_cache_alloc 主要四步:

  1. 先從 kmem_cache_cpu->freelist 中分配,如果 freelist 爲 null

  1. 接着去 kmem_cache_cpu->partital 鏈表中分配,如果此鏈表爲 null

  1. 接着去 kmem_cache_node->partital 鏈表分配,如果此鏈表爲 null

  1. 重新分配一個 slab。

Linux 內存管理之 vmalloc

根據前面的系列文章,我們知道了 buddy system 是基於頁框分配器,kmalloc 是基於 slab 分配器,而且這些分配的地址都是物理內存連續的。但是隨着碎片化的積累,連續物理內存的分配就會變得困難,對於那些非 DMA 訪問,不一定非要連續物理內存的話完全可以像 malloc 那樣,將不連續的物理內存頁框映射到連續的虛擬地址空間中,這就是 vmap 的來源)(提供把離散的 page 映射到連續的虛擬地址空間),vmalloc 的分配就是基於這個機制來實現的。

vmalloc 最小分配一個 page,並且分配到的頁面不保證是連續的,因爲 vmalloc 內部調用 alloc_page 多次分配單個頁面。

vmalloc 的區域就是在上圖中 VMALLOC_START - VMALLOC_END 之間,可通過 / proc/vmallocinfo 查看。

vmalloc 流程

主要分以下三步:

  1. 從 VMALLOC_START 到 VMALLOC_END 查找空閒的虛擬地址空間 (hole)

  2. 根據分配的 size, 調用 alloc_page 依次分配單個頁面.

  3. 把分配的單個頁面,映射到第一步中找到的連續的虛擬地址。把分配的單個頁面,映射到第一步中找到的連續的虛擬地址。

Linux 進程的內存管理之缺頁異常

當進程訪問這些還沒建立映射關係的虛擬地址時,處理器會自動觸發缺頁異常。

ARM64 把異常分爲同步異常和異步異常,通常異步異常指的是中斷(可看《上帝視角看中斷》),同步異常指的是異常。關於 ARM 異常處理的文章可參考《ARMv8 異常處理簡介》。

當處理器有異常發生時,處理器會先跳轉到 ARM64 的異常向量表中:

ENTRY(vectors)
 kernel_ventry 1, sync_invalid   // Synchronous EL1t
 kernel_ventry 1, irq_invalid   // IRQ EL1t
 kernel_ventry 1, fiq_invalid   // FIQ EL1t
 kernel_ventry 1, error_invalid  // Error EL1t

 kernel_ventry 1, sync    // Synchronous EL1h
 kernel_ventry 1, irq    // IRQ EL1h
 kernel_ventry 1, fiq_invalid   // FIQ EL1h
 kernel_ventry 1, error_invalid  // Error EL1h

 kernel_ventry 0, sync    // Synchronous 64-bit EL0
 kernel_ventry 0, irq    // IRQ 64-bit EL0
 kernel_ventry 0, fiq_invalid   // FIQ 64-bit EL0
 kernel_ventry 0, error_invalid  // Error 64-bit EL0

#ifdef CONFIG_COMPAT
 kernel_ventry 0, sync_compat, 32  // Synchronous 32-bit EL0
 kernel_ventry 0, irq_compat, 32  // IRQ 32-bit EL0
 kernel_ventry 0, fiq_invalid_compat, 32 // FIQ 32-bit EL0
 kernel_ventry 0, error_invalid_compat, 32 // Error 32-bit EL0
#else
 kernel_ventry 0, sync_invalid, 32  // Synchronous 32-bit EL0
 kernel_ventry 0, irq_invalid, 32  // IRQ 32-bit EL0
 kernel_ventry 0, fiq_invalid, 32  // FIQ 32-bit EL0
 kernel_ventry 0, error_invalid, 32  // Error 32-bit EL0
#endif
END(vectors)

以 el1 下的異常爲例,當跳轉到 el1_sync 函數時,讀取 ESR 的值以判斷異常類型。根據類型跳轉到不同的處理函數里,如果是 data abort 的話跳轉到 el1_da 函數里,instruction abort 的話跳轉到 el1_ia 函數里:

el1_sync:
 kernel_entry 1
 mrs x1, esr_el1   // read the syndrome register
 lsr x24, x1, #ESR_ELx_EC_SHIFT // exception class
 cmp x24, #ESR_ELx_EC_DABT_CUR // data abort in EL1
 b.eq el1_da
 cmp x24, #ESR_ELx_EC_IABT_CUR // instruction abort in EL1
 b.eq el1_ia
 cmp x24, #ESR_ELx_EC_SYS64  // configurable trap
 b.eq el1_undef
 cmp x24, #ESR_ELx_EC_SP_ALIGN // stack alignment exception
 b.eq el1_sp_pc
 cmp x24, #ESR_ELx_EC_PC_ALIGN // pc alignment exception
 b.eq el1_sp_pc
 cmp x24, #ESR_ELx_EC_UNKNOWN // unknown exception in EL1
 b.eq el1_undef
 cmp x24, #ESR_ELx_EC_BREAKPT_CUR // debug exception in EL1
 b.ge el1_dbg
 b el1_inv

流程圖如下:

do_page_fault

static int __do_page_fault(struct mm_struct *mm, unsigned long addr,
      unsigned int mm_flags, unsigned long vm_flags,
      struct task_struct *tsk)
{
 struct vm_area_struct *vma;
 int fault;

 vma = find_vma(mm, addr);
 fault = VM_FAULT_BADMAP; //沒有找到vma區域,說明addr還沒有在進程的地址空間中
 if (unlikely(!vma))
  goto out;
 if (unlikely(vma->vm_start > addr))
  goto check_stack;

 /*
  * Ok, we have a good vm_area for this memory access, so we can handle
  * it.
  */
good_area://一個好的vma
 /*
  * Check that the permissions on the VMA allow for the fault which
  * occurred.
  */
 if (!(vma->vm_flags & vm_flags)) {//權限檢查
  fault = VM_FAULT_BADACCESS; 
  goto out;
 }

 //重新建立物理頁面到VMA的映射關係
 return handle_mm_fault(vma, addr & PAGE_MASK, mm_flags);

check_stack:
 if (vma->vm_flags & VM_GROWSDOWN && !expand_stack(vma, addr))
  goto good_area;
out:
 return fault;
}

從__do_page_fault 函數能看出來,當觸發異常的虛擬地址屬於某個 vma,並且擁有觸發頁錯誤異常的權限時,會調用到 handle_mm_fault 函數來建立 vma 和物理地址的映射,而 handle_mm_fault 函數的主要邏輯是通過__handle_mm_fault 來實現的。

__handle_mm_fault

static int __handle_mm_fault(struct vm_area_struct *vma, unsigned long address,
  unsigned int flags)
{
  ......
 //查找頁全局目錄,獲取地址對應的表項
 pgd = pgd_offset(mm, address);
 //查找頁四級目錄表項,沒有則創建
 p4d = p4d_alloc(mm, pgd, address);
 if (!p4d)
  return VM_FAULT_OOM;

 //查找頁上級目錄表項,沒有則創建
 vmf.pud = pud_alloc(mm, p4d, address);
 ......
 //查找頁中級目錄表項,沒有則創建
 vmf.pmd = pmd_alloc(mm, vmf.pud, address);
  ......
 //處理pte頁表
 return handle_pte_fault(&vmf);
}

do_anonymous_page

匿名頁缺頁異常,對於匿名映射,映射完成之後,只是獲得了一塊虛擬內存,並沒有分配物理內存,當第一次訪問的時候:

  1. 如果是讀訪問,會將虛擬頁映射到0頁,以減少不必要的內存分配

  2. 如果是寫訪問,用 alloc_zeroed_user_highpage_movable 分配新的物理頁,並用0填充,然後映射到虛擬頁上去

  3. 如果是先讀後寫訪問,則會發生兩次缺頁異常:第一次是匿名頁缺頁異常的讀的處理(虛擬頁到 0 頁的映射),第二次是寫時複製缺頁異常處理。

從上面的總結我們知道,第一次訪問匿名頁時有三種情況,其中第一種和第三種情況都會涉及到 0 頁。

do_fault

do_swap_page

上面已經講過,pte 對應的內容不爲 0(頁表項存在),但是 pte 所對應的 page 不在內存中時,表示此時 pte 的內容所對應的頁面在 swap 空間中,缺頁異常時會通過 do_swap_page() 函數來分配頁面。

do_swap_page 發生在 swap in 的時候,即查找磁盤上的 slot,並將數據讀回。

換入的過程如下:

  1. 查找 swap cache 中是否存在所查找的頁面,如果存在,則根據 swap cache 引用的內存頁,重新映射並更新頁表;如果不存在,則分配新的內存頁,並添加到 swap cache 的引用中,更新內存頁內容完成後,更新頁表。

  2. 換入操作結束後,對應 swap area 的頁引用減 1,當減少到 0 時,代表沒有任何進程引用了該頁,可以進行回收。

int do_swap_page(struct vm_fault *vmf)
{
  ......
 //根據pte找到swap entry, swap entry和pte有一個對應關係
 entry = pte_to_swp_entry(vmf->orig_pte);
  ......
 if (!page)
  //根據entry從swap緩存中查找頁, 在swapcache裏面尋找entry對應的page
  //Lookup a swap entry in the swap cache
  page = lookup_swap_cache(entry, vma_readahead ? vma : NULL,
      vmf->address);
 //沒有找到頁
 if (!page) {
  if (vma_readahead)
   page = do_swap_page_readahead(entry,
    GFP_HIGHUSER_MOVABLE, vmf, &swap_ra);
  else
   //如果swapcache裏面找不到就在swap area裏面找,分配新的內存頁並從swap area中讀入
   page = swapin_readahead(entry,
    GFP_HIGHUSER_MOVABLE, vma, vmf->address);
  ......
 //獲取一個pte的entry,重新建立映射
 vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd, vmf->address,
   &vmf->ptl);
  ......
 //anonpage數加1,匿名頁從swap空間交換出來,所以加1
 //swap page個數減1,由page和VMA屬性創建一個新的pte
 inc_mm_counter_fast(vma->vm_mm, MM_ANONPAGES);
 dec_mm_counter_fast(vma->vm_mm, MM_SWAPENTS);
 pte = mk_pte(page, vma->vm_page_prot);
  ......
 flush_icache_page(vma, page);
 if (pte_swp_soft_dirty(vmf->orig_pte))
  pte = pte_mksoft_dirty(pte);
 //將新生成的PTE entry添加到硬件頁表中
 set_pte_at(vma->vm_mm, vmf->address, vmf->pte, pte);
 vmf->orig_pte = pte;
 //根據page是否爲swapcache
 if (page == swapcache) {
  //如果是,將swap緩存頁用作anon頁,添加反向映射rmap中
  do_page_add_anon_rmap(page, vma, vmf->address, exclusive);
  mem_cgroup_commit_charge(page, memcg, true, false);
  //並添加到active鏈表中
  activate_page(page);
 //如果不是
 } else { /* ksm created a completely new copy */
  //使用新頁面並複製swap緩存頁,添加反向映射rmap中
  page_add_new_anon_rmap(page, vma, vmf->address, false);
  mem_cgroup_commit_charge(page, memcg, false, false);
  //並添加到lru鏈表中
  lru_cache_add_active_or_unevictable(page, vma);
 }

 //釋放swap entry
 swap_free(entry);
  ......
 if (vmf->flags & FAULT_FLAG_WRITE) {
  //有寫請求則寫時複製
  ret |= do_wp_page(vmf);
  if (ret & VM_FAULT_ERROR)
   ret &= VM_FAULT_ERROR;
  goto out;
 }
  ......
  return ret;
}

do_wp_page

走到這裏說明頁面在內存中,只是 PTE 只有讀權限,而又要寫內存的時候就會觸發 do_wp_page。

do_wp_page 函數用於處理寫時複製(copy on write),其流程比較簡單,主要是分配新的物理頁,拷貝原來頁的內容到新頁,然後修改頁表項內容指向新頁並修改爲可寫 (vma 具備可寫屬性)。

static int do_wp_page(struct vm_fault *vmf)
 __releases(vmf->ptl)
{
 struct vm_area_struct *vma = vmf->vma;

 //從頁表項中得到頁幀號,再得到頁描述符,發生異常時地址所在的page結構
 vmf->page = vm_normal_page(vma, vmf->address, vmf->orig_pte);
 if (!vmf->page) {
  //沒有page結構是使用頁幀號的特殊映射
  /*
   * VM_MIXEDMAP !pfn_valid() case, or VM_SOFTDIRTY clear on a
   * VM_PFNMAP VMA.
   *
   * We should not cow pages in a shared writeable mapping.
   * Just mark the pages writable and/or call ops->pfn_mkwrite.
   */
  if ((vma->vm_flags & (VM_WRITE|VM_SHARED)) ==
         (VM_WRITE|VM_SHARED))
   //處理共享可寫映射
   return wp_pfn_shared(vmf);

  pte_unmap_unlock(vmf->pte, vmf->ptl);
  //處理私有可寫映射
  return wp_page_copy(vmf);
 }

 /*
  * Take out anonymous pages first, anonymous shared vmas are
  * not dirty accountable.
  */
 if (PageAnon(vmf->page) && !PageKsm(vmf->page)) {
  int total_map_swapcount;
  if (!trylock_page(vmf->page)) {
   //添加原來頁的引用計數,方式被釋放
   get_page(vmf->page);
   //釋放頁表鎖
   pte_unmap_unlock(vmf->pte, vmf->ptl);
   lock_page(vmf->page);
   vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd,
     vmf->address, &vmf->ptl);
   if (!pte_same(*vmf->pte, vmf->orig_pte)) {
    unlock_page(vmf->page);
    pte_unmap_unlock(vmf->pte, vmf->ptl);
    put_page(vmf->page);
    return 0;
   }
   put_page(vmf->page);
  }
  //單身匿名頁面的處理
  if (reuse_swap_page(vmf->page, &total_map_swapcount)) {
   if (total_map_swapcount == 1) {
    /*
     * The page is all ours. Move it to
     * our anon_vma so the rmap code will
     * not search our parent or siblings.
     * Protected against the rmap code by
     * the page lock.
     */
    page_move_anon_rmap(vmf->page, vma);
   }
   unlock_page(vmf->page);
   wp_page_reuse(vmf);
   return VM_FAULT_WRITE;
  }
  unlock_page(vmf->page);
 } else if (unlikely((vma->vm_flags & (VM_WRITE|VM_SHARED)) ==
     (VM_WRITE|VM_SHARED))) {
  //共享可寫,不需要複製物理頁,設置頁表權限即可
  return wp_page_shared(vmf);
 }

 /*
  * Ok, we need to copy. Oh, well..
  */
 get_page(vmf->page);

 pte_unmap_unlock(vmf->pte, vmf->ptl);
 //私有可寫,複製物理頁,將虛擬頁映射到物理頁
 return wp_page_copy(vmf);
}

Linux 內存管理之 CMA

CMA 是 reserved 的一塊內存,用於分配連續的大塊內存。當設備驅動不用時,內存管理系統將該區域用於分配和管理可移動類型頁面;當設備驅動使用時,此時已經分配的頁面需要進行遷移,又用於連續內存分配;其用法與 DMA 子系統結合在一起充當 DMA 的後端,具體可參考《沒有 IOMMU 的 DMA 操作》。

CMA 區域 cma_areas 的創建

CMA 區域的創建有兩種方法,一種是通過 dts 的 reserved memory,另外一種是通過 command line 參數和內核配置參數。

reserved-memory {
        /* global autoconfigured region for contiguous allocations */
        linux,cma {
                compatible = "shared-dma-pool";
                reusable;
                size = <0 0x28000000>;
                alloc-ranges = <0 0xa0000000 0 0x40000000>;
                linux,cma-default;
        };
};

device tree 中可以包含 reserved-memory node,系統啓動的時候會打開 rmem_cma_setup

RESERVEDMEM_OF_DECLARE(cma, "shared-dma-pool", rmem_cma_setup);

static int __init early_cma(char *p)
{
 pr_debug("%s(%s)\n", __func__, p);
 size_cmdline = memparse(p, &p);
 if (*p != '@') {
  /*
  if base and limit are not assigned,
  set limit to high memory bondary to use low memory.
  */
  limit_cmdline = __pa(high_memory);
  return 0;
 }
 base_cmdline = memparse(p + 1, &p);
 if (*p != '-') {
  limit_cmdline = base_cmdline + size_cmdline;
  return 0;
 }
 limit_cmdline = memparse(p + 1, &p);

 return 0;
}
early_param("cma", early_cma);

系統在啓動的過程中會把 cmdline 裏的 nn, start, end 傳給函數 dma_contiguous_reserve,流程如下:

setup_arch--->arm64_memblock_init--->dma_contiguous_reserve->dma_contiguous_reserve_area->cma_declare_contiguous

將 CMA 區域添加到 Buddy System

爲了避免這塊 reserved 的內存在不用時候的浪費,內存管理模塊會將 CMA 區域添加到 Buddy System 中,用於可移動頁面的分配和管理。CMA 區域是通過 cma_init_reserved_areas 接口來添加到 Buddy System 中的。

static int __init cma_init_reserved_areas(void)
{
 int i;

 for (i = 0; i < cma_area_count; i++) {
  int ret = cma_activate_area(&cma_areas[i]);

  if (ret)
   return ret;
 }

 return 0;
}
core_initcall(cma_init_reserved_areas);

其實現比較簡單,主要分爲兩步:

  1. 把該頁面設置爲 MIGRATE_CMA 標誌

  2. 通過__free_pages 將頁面添加到 buddy system 中

CMA 分配

《沒有 IOMMU 的 DMA 操作》裏講過,CMA 是通過 cma_alloc 分配的。cma_alloc->alloc_contig_range(..., MIGRATE_CMA,...),向剛纔釋放給 buddy system 的 MIGRATE_CMA 類型頁面,重新 “收集” 過來。

用 CMA 的時候有一點需要注意:

也就是上圖中黃色部分的判斷。CMA 內存在分配過程是一個比較 “重” 的操作,可能涉及頁面遷移、頁面回收等操作,因此不適合用於 atomic context。比如之前遇到過一個問題,當內存不足的情況下,向 U 盤寫數據的同時操作界面會出現卡頓的現象,這是因爲 CMA 在遷移的過程中需要等待當前頁面中的數據回寫到 U 盤之後,纔會進一步的規整爲連續內存供 gpu/display 使用,從而出現卡頓的現象。

總結

至此,從 CPU 開始訪問內存,到物理頁的劃分,再到內核頁框分配器的實現,以及 slab 分配器的實現,最後到 CMA 等連續內存的使用,把 Linux 內存管理的知識串了起來,算是形成了整個閉環。相信如果掌握了本篇內容,肯定打開了 Linux 內核的大門,有了這個基石,祝願大家接下來的內核學習越來越輕鬆。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Y7UhKFJR613hSBOo7kYGoQ