操作系統-內存映射[半原創]

文章大部分來自微信公衆號 bin的技術小屋 ,非原創 ,小部分是自己的學習批註

前言

  我認爲的映射, 就是大的方向說有兩部分 ,我們先看轉化的是什麼 : `文件 -- > (進程)  --> 虛擬內存  -->  物理內存` , 所以 
  1. 映射1 : 文件 -- > (進程)  --> 虛擬內存  , 這部分就是主要體現在虛擬內存上 
  2. 映射2 : 虛擬內存  -->  物理內存 , 這部分就是之前寫過的關於虛擬地址通過分段分頁機制的映射內容

關於虛擬地址通過分段分頁機制的映射內容, 可參考 : 操作系統-分頁管理存儲的實現操作系統-IA32的地址轉換

讓我們思考一下 ,計算機啓動後 ,操作系統接管計算機 ,然後我們就會運行我們的業務系統, 計算機最小的單位運行單位是進程 , 我們知道在計算機的世界操作的都是一個虛擬地址 ,而不是物理地址 , 也就是說 文件 -- > (進程) --> 虛擬內存 --> 物理內存, 那麼第一步就是進程是如何在虛擬內存中存在的,以一個什麼樣的形式 , 下面是一個進程的虛擬空間分佈

32位虛擬內存空間分佈
img

img

64位虛擬內存空間分佈
img

內存映射原理

    Linux通過將一個虛擬內存區域與一個磁盤上的對象(object)關聯起來,以初始化這個虛擬內存區域的內容,這個過程稱爲內存映射(memory mapping)。虛擬內存區域可以映射到兩種類型的對象中的一種:

    (1) Linux文件系統中的普通文件:一個區域可以映射到一個普通磁盤文件的連續部分,例如一個可執行的目標文件。文件區(section)被分成頁大小的片,每一片包含一個虛擬頁面的初始內容。因爲按需進行頁面調度,所以這些虛擬頁面沒有實際交換進入物理內存,直到CPU第一次引用到頁面(即發射一個虛擬地址,落在地址空間這個頁面的範圍之內)。如果區域比文件區要大,那麼就用零來填充這個區域的餘下部分。

    (2) 匿名文件:一個區域也可以映射到一個匿名文件,匿名文件是由內核創建的,包含的全是二進制零。CPU第一次引用這樣一個區域內的虛擬頁面時,內核就在物理內存中找到一個合適的犧牲頁面,如果該頁面被修改過,就將這個頁面換出來,用二進制零覆蓋犧牲頁面並更新頁面,將標記爲是駐留在內存中的。(這不就是將進程相關的東西 swap 到外部的文件去了嗎 ) 注意在磁盤和內存之間並沒有實際的數據傳送。因爲這個原因,映射到匿名文件的區域中的頁面有時也叫做請求二進制零的頁(demand-zero page)。

無論哪種情況下,一旦一個虛擬頁面被初始化了,它就在一個由內核維護的專門的交換文件(swap file)之間換來換去。交換文件也叫作交換空間(swap space)或者交換區域(swap area)。需要意識到的很重要的一點是,在任何時刻,交換空間都限制着當前運行着的進程能夠分配的虛擬頁面的總數。

第一種對象實際上的應用不就是我們 elf 文件加載到內存虛擬空間嗎 , 第二種則是進程的切換 ,進程A 被換到了內存之外的空間 ,例如下圖Linux 的交換空間就是匿名文件來的

img

下面 swap 空間就屬於匿名文件.

內存映射-共享庫的讀寫

之前學習共享庫只知道共享庫可以映射到任意的虛擬地址空間上 ,那麼共享的位置位於哪裏呢?? 位於下圖標出的地方 :

img

那麼具體是如何共享的呢 ?

img

上面的圖例可以很清晰地看到兩個進程共享同一個代碼庫 ,讀是沒問題了,因爲都映射到各自的虛擬空間上 , 那寫呢?

私有對象使用了一種叫做寫時複製(copy-on-write)的巧妙技術被映射到虛擬內存中. 只要沒有進程試圖寫它自己的私有區域, 他們就可以繼續共享物理內存中對象的一個單獨副本. 然後只要有一個進程試圖寫私有區域內的某個頁面, 那麼就會出發一個保護故障 ,它就會在物理內存中創建一個頁面的副本, 更新頁表條目指向新的副本,當故障處理程序返回時 ,CPU 重新執行這個寫操作. 現在在新創建的頁面上這個寫操作就可以正常執行了.

img

可以看到這種方式除了費內存就是速度快了,用空間換時間 , 還有一個問題,寫的頁面是什麼時候flush 迴文件的 ?

內存映射-匿名映射

匿名映射的動機是什麼呢 ?

進程虛擬內存空間的管理

用戶進程內存空間表示

主要是進程在代碼中的表示
img

img

ELF 中各個 section 是以 VMA 的結構組織起來的 , 各個section 以雙鏈表的形式組織起來, 同時作爲 task_struct 的一個紅黑樹節點 . 我們下面來看一下linux 中的源碼實現


    下面的內容來自參考文章, 非原創 , 參考文章中未提到使用到的 linux 版本 ,於是我參考了下面(參考資料-在線linux)中 v5.0.21 的代碼 

task_struct

struct task_struct {
        // 進程id
     pid_t    pid;
        // 用於標識線程所屬的進程 pid
     pid_t    tgid;
        // 進程打開的文件信息
        struct files_struct  *files;
        // (重要)內存描述符表示進程虛擬地址空間
        struct mm_struct  *mm;

        .......... 省略 .......
}

當我們調用 fork() 函數創建進程的時候,表示進程地址空間的 mm_struct 結構會隨着進程描述符 task_struct 的創建而創建。

long _do_fork(unsigned long clone_flags,
       unsigned long stack_start,
       unsigned long stack_size,
       int __user *parent_tidptr,
       int __user *child_tidptr,
       unsigned long tls)
{
        ......... 省略 ..........
        struct pid *pid;
         struct task_struct *p;

        ......... 省略 ..........
        // 爲進程創建 task_struct 結構,用父進程的資源填充 task_struct 信息
        p = copy_process(clone_flags, stack_start, stack_size,
        child_tidptr, NULL, trace, tls, NUMA_NO_NODE);

         ......... 省略 ..........
}

隨後會在 copy_process 函數中創建 task_struct 結構,並拷貝父進程的相關資源到新進程的 task_struct 結構裏,其中就包括拷貝父進程的虛擬內存空間 mm_struct 結構。這裏可以看出子進程在新創建出來之後它的虛擬內存空間是和父進程的虛擬內存空間一模一樣的,直接拷貝過來

static __latent_entropy struct task_struct *copy_process(
     unsigned long clone_flags,
     unsigned long stack_start,
     unsigned long stack_size,
     int __user *child_tidptr,
     struct pid *pid,
     int trace,
     unsigned long tls,
     int node)
{

        struct task_struct *p;
        // 創建 task_struct 結構
        p = dup_task_struct(current, node);

        ....... 初始化子進程 ...........

        ....... 開始繼承拷貝父進程資源  .......      
           // 繼承父進程打開的文件描述符
        retval = copy_files(clone_flags, p);
           // 繼承父進程所屬的文件系統
        retval = copy_fs(clone_flags, p);
           // 繼承父進程註冊的信號以及信號處理函數
        retval = copy_sighand(clone_flags, p);
        retval = copy_signal(clone_flags, p);
           // 繼承父進程的虛擬內存空間
        retval = copy_mm(clone_flags, p);
           // 繼承父進程的 namespaces
        retval = copy_namespaces(clone_flags, p);
           // 繼承父進程的 IO 信息
        retval = copy_io(clone_flags, p);

        ...........省略.........
         // 分配 CPU
        retval = sched_fork(clone_flags, p);
         // 分配 pid
        pid = alloc_pid(p->nsproxy->pid_ns_for_children);

     ..........省略.........
}

這裏我們重點關注 copy_mm 函數,正是在這裏完成了子進程虛擬內存空間 mm_struct 結構的的創建以及初始化。

static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
         // 子進程虛擬內存空間,父進程虛擬內存空間
         struct mm_struct *mm, *oldmm;
         int retval;

                ...... 省略 ......

         tsk->mm = NULL;
         tsk->active_mm = NULL;
            // 獲取父進程虛擬內存空間
         oldmm = current->mm;
         if (!oldmm)
          return 0;

                ...... 省略 ......
         // 通過 vfork 或者 clone 系統調用創建出的子進程(線程)和父進程共享虛擬內存空間
         if (clone_flags & CLONE_VM) {
                // 增加父進程虛擬地址空間的引用計數
                mmget(oldmm);
                // 直接將父進程的虛擬內存空間賦值給子進程(線程)
                // 線程共享其所屬進程的虛擬內存空間
                mm = oldmm;
                goto good_mm;
         }

         retval = -ENOMEM;
         // 如果是 fork 系統調用創建出的子進程,則將父進程的虛擬內存空間以及相關頁表拷貝到子進程中的 mm_struct 結構中。
         mm = dup_mm(tsk);
         if (!mm)
          goto fail_nomem;

        good_mm:
         // 將拷貝出來的父進程虛擬內存空間 mm_struct 賦值給子進程
         tsk->mm = mm;
         tsk->active_mm = mm;
         return 0;

                ...... 省略 ......

由於本小節中我們舉的示例是通過 fork() 函數創建子進程的情形,所以這裏大家先佔時忽略 if (clone_flags & CLONE_VM) 這個條件判斷邏輯,我們先跳過往後看~~

copy_mm 函數首先會將父進程的虛擬內存空間 current->mm 賦值給指針 oldmm。然後通過 dup_mm 函數將父進程的虛擬內存空間以及相關頁表拷貝到子進程的 mm_struct 結構中。最後將拷貝出來的 mm_struct 賦值給子進程的 task_struct 結構。

通過 fork() 函數創建出的子進程,它的虛擬內存空間以及相關頁表相當於父進程虛擬內存空間的一份拷貝,直接從父進程中拷貝到子進程中。

而當我們通過 vfork 或者 clone 系統調用創建出的子進程,首先會設置 CLONE_VM 標識,這樣來到 copy_mm 函數中就會進入 if (clone_flags & CLONE_VM) 條件中,在這個分支中會將父進程的虛擬內存空間以及相關頁表直接賦值給子進程。這樣一來父進程和子進程的虛擬內存空間就變成共享的了。也就是說父子進程之間使用的虛擬內存空間是一樣的,並不是一份拷貝。

子進程共享了父進程的虛擬內存空間,這樣子進程就變成了我們熟悉的線程,是否共享地址空間幾乎是進程和線程之間的本質區別。Linux 內核並不區別對待它們,線程對於內核來說僅僅是一個共享特定資源的進程而已。

內核線程和用戶態線程的區別就是內核線程沒有相關的內存描述符 mm_struct ,內核線程對應的 task_struct 結構中的 mm 域指向 Null,所以內核線程之間調度是不涉及地址空間切換的。

當一個內核線程被調度時,它會發現自己的虛擬地址空間爲 Null,雖然它不會訪問用戶態的內存,但是它會訪問內核內存,聰明的內核會將調度之前的上一個用戶態進程的虛擬內存空間 mm_struct 直接賦值給內核線程,因爲內核線程不會訪問用戶空間的內存,它僅僅只會訪問內核空間的內存,所以直接複用上一個用戶態進程的虛擬地址空間就可以避免爲內核線程分配 mm_struct 和相關頁表的開銷,以及避免內核線程之間調度時地址空間的切換開銷。

父進程與子進程的區別,進程與線程的區別,以及內核線程與用戶態線程的區別其實都是圍繞着這個 mm_struct 展開的。

現在我們知道了表示進程虛擬內存空間的 mm_struct 結構是如何被創建出來的相關背景,那麼接下來筆者就帶大家深入 mm_struct 結構內部,來看一下內核如何通過這麼一個 mm_struct 結構體來管理進程的虛擬內存空間的。

mm_struct

struct mm_struct {
    unsigned long task_size;    /* size of task vm space */
    unsigned long start_code, end_code, start_data, end_data;
    unsigned long start_brk, brk, start_stack;
    unsigned long arg_start, arg_end, env_start, env_end;
    unsigned long mmap_base;  /* base of mmap area */
    unsigned long total_vm;    /* Total pages mapped */
    unsigned long locked_vm;  /* Pages that have PG_mlocked set */
    unsigned long pinned_vm;  /* Refcount permanently increased */
    unsigned long data_vm;    /* VM_WRITE & ~VM_SHARED & ~VM_STACK */
    unsigned long exec_vm;    /* VM_EXEC & ~VM_WRITE & ~VM_STACK */
    unsigned long stack_vm;    /* VM_STACK */

   // (重要)頁表目錄信息 
   pgd_t * pgd; 
   // (重要) 各個虛擬地址的映射 
   struct vm_area_struct *mmap;		/* list of VMAs */
   // (重要) 紅黑樹 節點 ,也就是說一個進程作爲一個節點 
	struct rb_root mm_rb;



       ...... 省略 ........
}

VMA

內存區域 vm_area_struct 會有兩種組織形式,一種是雙向鏈表用於高效的遍歷,另一種就是紅黑樹用於高效的查找。

struct vm_area_struct {

   // 這應該就是虛擬地址了!!!! 
   unsigned long vm_start;  /* Our start address within vm_mm.   */
   unsigned long vm_end;  /* The first byte after our end  address
          within vm_mm. */
   /*
    * Access permissions of this VMA.
    */
    //(重要) 權限相關 
   pgprot_t vm_page_prot;
   unsigned long vm_flags; 

   struct anon_vma *anon_vma; /* Serialized by page_table_lock   */
      struct file * vm_file;  /* File we map to (can be NULL). */
   unsigned long vm_pgoff;  /* Offset (within vm_file) in  PAGE_SIZE
          units */ 
   void * vm_private_data;  /* was vm_pte (shared mem) */
   /* Function pointers to deal with this struct. */
   const struct vm_operations_struct *vm_ops;
}

內核空間表示

提示幾點需要注意的是

  1. 並不是說只要進入了內核態就開始使用物理地址了,這就大錯特錯了,千萬不要這樣理解,進入內核態之後使用的仍然是虛擬內存地址(言外之意就是依舊要走頁表 , 依舊要MMU 的地址轉化),只不過在內核中使用的虛擬內存地址被限制在了內核態虛擬內存空間範圍中
  2. 內核空間裏面的東西各個進程都是一樣的 , 並不是進程私有

img

而對應映射到物理內存中的內容分區如下圖 :

img

直接映射區

img
注意圖片上的比例實際不是那樣, 實際比例用戶空間:內核空間 = 3:1

在這段 896M 大小的物理內存中,前 1M 已經在系統啓動的時候被系統佔用,1M 之後的物理內存存放的是內核代碼段,數據段,BSS 段(這些信息起初存放在 ELF格式的二進制文件中,在系統啓動的時候被加載進內存)。

當我們使用 fork 系統調用創建進程的時候,內核會創建一系列進程相關的描述符,比如之前提到的進程的核心數據結構 task_struct,進程的內存空間描述符 mm_struct,以及虛擬內存區域描述符 vm_area_struct 等。

這些進程相關的數據結構也會存放在物理內存前 896M 的這段區域中,當然也會被直接映射至內核態虛擬內存空間中的 3G -- 3G + 896m 這段直接映射區域中。

這部分的虛擬內存區域映射到物理內存上去就是 ZONE_DMAZONE_NORMAL 這兩塊區域, 其中 ZONE_DMA 佔 16M , 其他的給到 ZONE_NORMAL

  ZONE_DMA 和 ZONE_NORMAL 的內容我們放到了其他章節進一步詳細的介紹 

vmallo 動態映射區

img
這張圖我們也可以看到, 虛擬內存空間 , 內存出去 896M 後實際最多隻有 128M 了, 而對應物理內存 , 還有3.2G 的空間, 如果內核要全部映射完 ,那怎麼辦呢 ?
於是就出現了 vmallo 動態映射區 , 最大的時候只有 128M , 映射對面的物理內存 128M 以後給A進程使用 ,然後B再進行映射另外的 128M , 相當於多個房間共享一個廁所的道理一樣.

和用戶態進程使用 malloc 申請內存一樣,在這塊動態映射區內核是使用 vmalloc 進行內存分配。由於之前介紹的動態映射的原因,vmalloc 分配的內存在虛擬內存上是連續的,但是物理內存是不連續的。通過頁表來建立物理內存與虛擬內存之間的映射關係,從而可以將不連續的物理內存映射到連續的虛擬內存上。(當需要使用另外的 128M 空間的時候 , 直接修改頁表就可以了)

由於 vmalloc 獲得的物理內存頁是不連續的,因此它只能將這些物理內存頁一個一個地進行映射,在性能開銷上會比直接映射大得多。

永久映射區

內核的這段虛擬地址空間中允許建立與物理高端內存(ZONE_HIGHMEM)的長期映射關係。比如內核通過 alloc_pages() 函數在物理內存的高端內存中申請獲取到的物理內存頁,這些物理內存頁可以通過調用 kmap 映射到永久映射區中。

永久映射區和動態映射區有點像

  alloc_pages() 函數 在後面會介紹 , 而且這個 alloc_pages() 可重要了!!!! 分配物理內存頁的重要方法 !!  
  這裏留個坑位 , 爲什麼要映射呢?? 肯定是爲了方便內核操作 

固定映射區

在固定映射區中的虛擬內存地址可以自由映射到物理內存的高端地址上,但是與動態映射區以及永久映射區不同的是,在固定映射區中虛擬地址是固定的,而被映射的物理地址是可以改變的。也就是說,有些虛擬地址在編譯的時候就固定下來了,是在內核啓動過程中被確定的,而這些虛擬地址對應的物理地址不是固定的。

採用固定虛擬地址的好處是它相當於一個指針常量(常量的值在編譯時確定),指向物理地址,如果虛擬地址不固定,則相當於一個指針變量。

那爲什麼會有固定映射這個概念呢 ? 比如:在內核的啓動過程中,有些模塊需要使用虛擬內存並映射到指定的物理地址上,而且這些模塊也沒有辦法等待完整的內存管理模塊初始化之後再進行地址映射。因此,內核固定分配了一些虛擬地址,這些地址有固定的用途,使用該地址的模塊在初始化的時候,將這些固定分配的虛擬地址映射到指定的物理地址上去。

  這樣的話 ,也就是這部分虛擬空間是內核特定的功能空間區域咯 ,不能隨便給其他用途的

臨時映射區

筆者在之前文章 《從 Linux 內核角度探祕 JDK NIO 文件讀寫本質》 的 “ 12.3 iov_iter_copy_from_user_atomic ” 小節中介紹在 Buffered IO 模式下進行文件寫入的時候,在下圖中的第四步,內核會調用 iov_iter_copy_from_user_atomic 函數將用戶空間緩衝區 DirectByteBuffer 中的待寫入數據拷貝到 page cache 中。

img

背景就是 : IO 爲了快速地寫入(write)到內存中 , 傳統的 write 需要多次拷貝 , 於是就出現了一個臨時映射的區域

但是內核又不能直接進行拷貝,因爲此時從 page cache 中取出的緩存頁 page 是物理地址,而在內核中是不能夠直接操作物理地址的,只能操作虛擬地址。

那怎麼辦呢?所以就需要使用 kmap_atomic 將緩存頁臨時映射到內核空間的一段虛擬地址上,這段虛擬地址就位於內核虛擬內存空間中的臨時映射區上,然後將用戶空間緩存區 DirectByteBuffer 中的待寫入數據通過這段映射的虛擬地址拷貝到 page cache 中的相應緩存頁中。 (也就是頁表直接將 物理內存即緩存頁 page映射到虛擬地址即臨時映射區, 那麼讀寫 臨時映射區 就是讀寫 緩存頁 page ) 這時文件的寫入操作就已經完成了

由於是臨時映射,所以在拷貝完成之後,調用 kunmap_atomic 將這段映射再解除掉

size_t iov_iter_copy_from_user_atomic(struct page *page,
    struct iov_iter *i, unsigned long offset, size_t bytes)
{
  // 將緩存頁臨時映射到內核虛擬地址空間的臨時映射區中
  char *kaddr = kmap_atomic(page), 
  *p = kaddr + offset;
  // 將用戶緩存區 DirectByteBuffer 中的待寫入數據拷貝到文件緩存頁中
  iterate_all_kinds(i, bytes, v,
    copyin((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len),
    memcpy_from_page((p += v.bv_len) - v.bv_len, v.bv_page,
         v.bv_offset, v.bv_len),
    memcpy((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len)
  )
  // 解除內核虛擬地址空間與緩存頁之間的臨時映射,這裏映射只是爲了臨時拷貝數據用
  kunmap_atomic(kaddr);
  return bytes;
}

虛擬內存到物理內存的映射

虛擬內存通過頁表, 映射到物理內存中去
img

地址翻譯

地址翻譯就是虛擬地址是如何映射到對應的物理地址上去的, 這個建議參看袁春風老師的課程 , 這裏就不贅述了.
img

其他

物理內存區域劃分

ZONE_DMA

範圍 : 16M
在 X86 體系結構下,ISA 總線的 DMA (直接內存存取)控制器,只能對內存的前16M 進行尋址,這就導致了 ISA 設備不能在整個 32 位地址空間中執行 DMA,只能使用物理內存的前 16M 進行 DMA 操作。

該區域的物理頁面專門供I/O設備的DMA使用。之所以需要單獨管理DMA的物理頁面,是因爲DMA使用物理地址訪問內存,不經過MMU,並且需要連續的緩衝區,所以爲了能夠提供物理上連續的緩衝區,必須從物理地址空間專門劃分一段區域用於DMA。

因此直接映射區的前 16M 專門讓內核用來爲 DMA 分配內存,這塊 16M 大小的內存區域我們稱之爲 ZONE_DMA。

ZONE_NORMAL

範圍 : 16M~896M
該區域存放kernel代碼、GDT、IDT、PGD、mem_map數組

ZONE_HIGHMEM

範圍 : 896M 以上區域
用戶數據(業務數據,例如一個進程裏面的堆棧等等 ,就是業務數據)、頁表(PT)等不常用數據 , 只在要訪問這些數據時才建立映射關係(kmap())。比如,當內核要訪問I/O設備存儲空間時,就使用ioremap()將位於物理地址高端的mmio區內存映射到內核空間的vmalloc area中,在使用完之後便斷開映射關係。

64位虛擬內存空間分佈

64 位體系內核虛擬內存空間佈局

img

64 位體系下的內核虛擬內存空間與物理內存的映射就變得非常簡單,由於虛擬內存空間足夠的大,即便是內核要訪問全部的物理內存,直接映射就可以了,不在需要用到 ZONE_HIGHMEM 高端內存介紹的高端內存那種動態映射方式.

參考資料

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