深入理解內存映射:加速文件訪問的神奇原理

**前言:**內存映射是一種操作系統提供的技術,它將文件的內容映射到進程的虛擬地址空間中,使得進程可以直接讀寫文件而無需通過傳統的 I/O 操作。通過內存映射,文件被視爲內存中的一部分,進程可以像訪問普通內存一樣對文件進行讀寫操作。

在內存映射過程中,操作系統會將文件數據按頁(通常是 4KB)進行劃分,並在物理內存和虛擬地址空間之間建立對應關係。當進程需要訪問文件時,它只需要使用指針來讀寫相應的內存地址即可,而無需手動調用 read() 或 write() 函數進行 I/O 操作。這種直接訪問的方式可以提高讀寫效率,並且簡化了程序邏輯。

內存映射適用於大型文件處理、數據庫管理、共享內存等場景。它不僅減少了磁盤 I/O 的次數,還能夠實現多個進程之間的數據共享。但同時也要注意控制好文件與內存之間的同步和保護機制,避免數據不一致或競態條件導致的問題。

一、內存映射概念

內存映射 概念 : "內存映射 “就是在 進程的” 用戶虛擬地址空間" 中 , 創建一個 映射 , "內存映射" 有 2 種情況 , ① 文件映射 , ② 匿名映射 ;

內存映射,簡而言之就是將用戶空間的一段內存區域映射到內核空間,映射成功後,用戶對這段內存區域的修改可以直接反映到內核空間,同樣,內核空間對這段區域的修改也直接反映給用戶空間,對於用戶空間和內核空間兩者之間需要進行大量數據傳輸等操作的話效率是非常高的。如下圖所示

實現這樣的映射後,進程就可以採用指針的方式讀寫操作這一段內存,而系統會自動回寫髒頁到對應的文件磁盤上,就可以完成對於文件的操作,而不需要再調用 read/write 等系統調用函數。同時,內核空間對於這段區域的修改也可以直接反映到用戶空間,從而可以實現不同進程間的文件共享。

mmap/munmap 接口是常用的內存映射的系統調用接口,無論是在用戶空間分配內存、讀寫大文件、連接動態庫文件,還是多進程間共享內存,都可以看到其身影,其聲明如下:

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

條件:

mmap() 必須以 PAGE_SIZE 爲單位進行映射,而內存也只能以頁爲單位進行映射,若要映射非 PAGE_SIZE 整數倍的地址範圍,要先進行內存對齊,強行以 PAGE_SIZE 的倍數大小進行映射。

參數說明:

PROT_EXEC: 表示映射的頁面是可以執行的
PROT_READ:表示映射的頁面是可以讀取的
PROT_WRITE :表示映射的頁面是可以寫入的
PROT_NONE :表示映射的頁面是不可訪問的

flags:指定映射對象的類型,映射選項和映射頁是否可以共享。它的值可以是一個或者多個以下位的組合體

MAP_SHARED:創建一個共享映射的區域,多個進程可以通過共享映射的方式來映射一個文件,這樣其他進程也可以看到映射內容的改變,修改後的內容會同步到磁盤文件
MAP_PRIVATE:創建一個私有的寫時複製的映射,多個進程可以通過私有映射方式來映射一個文件,其他的進程不會看到映射文件內容的改變,修改後也不會同步到磁盤中
MAP_ANONYMOUS:創建一個匿名映射,即沒有關聯到文件的映射
MAP_FIXED:
MAP_POPULATE:提前遇到文件內容到映射區

fd:mmap 映射釋放和文件相關聯,可以分爲匿名映射和文件映射

文件映射:將一個普通文件的全部或者一部分映射到進程的虛擬內存中。映射後,進程就可以直接在對應的內存區域操作文件內容!
匿名映射:匿名映射沒有對應的文件或者對應的文件時虛擬文件(如:/dev/zero),映射後會把內存分頁全部初始化爲0。

返回說明:

成功執行時,mmap() 返回被映射區的指針,munmap() 返回 0。失敗時,mmap() 返回 MAP_FAILED[其值爲 (void *)-1],munmap 返回 - 1。

當多個進程映射了同一個內存區域時,它們會共享物理內存的相同分頁。通過 fork() 創建的子進程也會繼承父進程的映射副本!!!

根據文件關聯性和映射區域示範共享等屬性,其分爲:

注意:進程執行 ·exec()· 調用後,先前的內存映射會丟失,而 ·fork()· 創建的子進程會繼承父進程的,映射的特徵 (私有和共享) 也會被繼承。

異常信號:

當映射內存的屬性設置只讀時,如果進行寫操作會產生 SIGSEGV 信號。

當映射內存的字節數大於被映射文件的大小,且大於該文件當前的內存分頁大小時。如果訪問的區域超過了該文件分頁大小,會產生 SIGBUS 信號。

有點繞口,舉個簡單的例子:

假設內核維護的內存分頁是 4k(一般都是 4k,4096 字節),一個普通文件 a.txt 的大小是 10 字節。如果創建一個映射內存爲 4097 字節,並映射該文件。此時,因爲 a.txt 的大小用一個分頁就可以完全映射,10 字節遠小於一個分頁的 4096 字節,所以內核只會給它一個分頁。內存地址是從 0 開始,0-9 區間的內容對應 a.txt 文件的數據,我們也是可以訪問 10-4095 的區間。但如果訪問 4096 區間時,已經超過一個分頁的大小了,此時會產生 SIGBUS 信號!!!

等會我們用個簡單的例子演示下這 2 個異常。

二、內存映射的原理

內存映射,簡而言之就是將用戶空間的一段內存區域映射到內核空間,映射成功後,用戶對這段內存區域的修改可以直接反映到內核空間,同樣,內核空間對這段區域的修改也直接反映用戶空間。那麼對於內核空間 <----> 用戶空間兩者之間需要大量數據傳輸等操作的話效率是非常高的。

  1. 分配虛擬內存頁 : 在 Linux 系統中 創建 "內存映射 “時 , 會在” 用戶虛擬地址空間 “ 中 , 分配一塊 ” 虛擬內存區域" ;

  2. 缺頁異常 : Linux 內核在分配 "物理內存 “時 , 採用了” 延遲策略 “ , 即進程第一次訪問 , 不會立即分配 物理內存 , 而是產生一個 ” 缺頁異常" ;

  3. 分配物理內存頁 : 缺頁異常後的

2.1 共享內存

內存映射 與 共享內存 關係 :

如果修改了 進程間的 "共享內存" 對應的 "文件映射" , 修改後不會立刻更新到文件中 , 調用 msync 函數 , 強制同步寫入到文件中 ;

2.2 進程內存段的內存映射類型

在 進程 的 "用戶虛擬地址空間" 中 , 不同的 內存段 其 內存映射 類型也是不同的 :

三、函數接口

mmap 函數是 unix/linux 下的系統調用,詳細內容可參考《Unix Netword programming》卷二 12.2 節。

mmap 系統調用並不是完全爲了用於共享內存而設計的。它本身提供了不同於一般對普通文件的訪問方式,進程可以像讀寫內存一樣對普通文件的操作。而 Posix 或系統 V 的共享內存 IPC 則純粹用於共享目的,當然 mmap() 實現共享內存也是其主要應用之一。

mmap 系統調用使得進程之間通過映射同一個普通文件實現共享內存。普通文件被映射到進程地址空間後,進程可以像訪問普通內存一樣對文件進行訪問,不必再調用 read(),write()等操作。mmap 並不分配空間, 只是將文件映射到調用進程的地址空間裏(但是會佔掉你的 virutal memory), 然後你就可以用 memcpy 等操作寫文件, 而不用 write() 了. 寫完後,內存中的內容並不會立即更新到文件中,而是有一段時間的延遲,你可以調用 msync() 來顯式同步一下, 這樣你所寫的內容就能立即保存到文件裏了. 這點應該和驅動相關。不過通過 mmap 來寫文件這種方式沒辦法增加文件的長度, 因爲要映射的長度在調用 mmap() 的時候就決定了. 如果想取消內存映射,可以調用 munmap() 來取消內存映射。

3.1 創建映射

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

3.2 解除映射

#include <sys/mman.h>
int munmap(void *addr, size_t length);

3.3 同步映射區

#include <sys/mman.h>
int msync(void *addr, size_t length, int flags);

四、mmap 在 linux 哪裏?

添加圖片註釋,不超過 140 字(可選)

mmap 是操作這些設備的一種方法,所謂操作設備,比如 IO 端口(點亮一個 LED)、LCD 控制器、磁盤控制器,實際上就是往設備的物理地址讀寫數據。

但是,由於應用程序不能直接操作設備硬件地址,所以操作系統提供了這樣的一種機制——內存映射,把設備地址映射到進程虛擬地址,mmap 就是實現內存映射的接口。

操作設備還有很多方法,如 ioctl、ioremap

mmap 的好處是,mmap 把設備內存映射到虛擬內存,則用戶操作虛擬內存相當於直接操作設備了,省去了用戶空間到內核空間的複製過程,相對 IO 操作來說,增加了數據的吞吐量。

4.1 虛擬地址空間

添加圖片註釋,不超過 140 字(可選)

每個進程都有 4G 的虛擬地址空間,其中 3G 用戶空間,1G 內核空間(linux),每個進程共享內核空間,獨立的用戶空間,下圖形象地表達了這點驅動程序運行在內核空間,所以驅動程序是面向所有進程的。

用戶空間切換到內核空間有兩種方法:

虛擬空間裝的大概是上面那些數據了,內存映射大概就是把設備地址映射到上圖的紅色段了,暫且稱其爲 “內存映射段”,至於映射到哪個地址,是由操作系統分配的,操作系統會把進程空間劃分爲三個部分:

操作系統會在未分配的地址空間分配一段虛擬地址,用來和設備地址建立映射,至於怎麼建立映射,後面再揭曉。

現在大概明白了 “內存映射” 是什麼了,那麼內核是怎麼管理這些地址空間的呢?任何複雜的理論最終也是通過各種數據結構體現出來的,而這裏這個數據結構就是進程描述符。從內核看,進程是分配系統資源(CPU、內存)的載體,爲了管理進程,內核必須對每個進程所做的事情進行清楚的描述,這就是進程描述符,內核用 task_struct 結構體來表示進程,並且維護一個該結構體鏈表來管理所有進程。該結構體包含一些進程狀態、調度信息等上千個成員,我們這裏主要關注進程描述符裏面的內存描述符(struct mm_struct mm)

4.2 內存描述符

添加圖片註釋,不超過 140 字(可選)

現在已經知道了內存映射是把設備地址映射到進程空間地址(注意:並不是所有內存映射都是映射到進程地址空間的,ioremap 是映射到內核虛擬空間的,mmap 是映射到進程虛擬地址的),實質上是分配了一個 vm_area_struct 結構體加入到進程的地址空間,也就是說,把設備地址映射到這個結構體,映射過程就是驅動程序要做的事了。

五、mmap 源碼分析(內核版本: 4.20.1)

我們來詳細介紹一下 mmap() 的細節和源碼分析. 雖然我們使用 mmap() 只是簡單的映射文件至內存中,而 mmap() 的設計實現主要涉及內核中的虛擬內存空間和內存映射等細節

5.1 函數原型

void *mmap(void *addr, size_t length, int prot, int flags,
           int fd, off_t offset);

這是 mmap 的函數原型,而系統調用的接口在 mm/mmap.c 中的:

unsigned long ksys_mmap_pgoff(unsigned long addr, unsigned long len,
                              unsigned long prot, unsigned long flags,
                              unsigned long fd, unsigned long pgoff);

5.2 虛擬內存區域管理

這裏我們先介紹兩個關於虛擬內存的數據結構。虛擬內存概念的相關資料網上已經足夠的豐富,這裏我們從內核的角度來分析。虛擬空間的管理是以進程爲基礎的,每個進程都有各自的虛存空間,除此之外,每個進程的 “內核虛擬空間” 是爲所有的進程所共享的。一個進程的虛擬地址空間主要由兩個數據結構來描述: mm_struct(內存描述符) 和 vm_area_struct(虛擬內存區域描述符)。

The Memory Descriptor(內存描述符)

mm_struct 包括進程中虛擬地址空間的所有信息,mm_struct 定義在 include/linux/mm_types.h:

struct mm_struct {
        struct {
            struct vm_area_struct *mmap;           /* vm_area_struct的鏈表 */
            pgd_t * pgd;			    /* 指向進程的頁目錄 */
            
            /* ... */
            int map_count;			    /* vm_area_struct數量 */
            /* ... */
            unsigned long total_vm;		    /* 映射的Page數量 */
	    /* ... */
	    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;	  /* 參數段,環境段的起始結束位置 */
            /* ... */
        }
        
}

結合 mm_struct 和下圖 32 位系統典型的虛擬地址空間分佈更能直觀的理解(來自《深入理解計算機系統》):

Virtual Memory Area(虛擬內存區域描述符)

vm_area_struct 描述了虛擬地址空間的一個區間, 一個進程的虛擬空間中可能有多個虛擬區間, vm_area_struct 同樣定義在 include/linux/mm_types.h:

/*
 * This struct defines a memory VMM memory area. There is one of these
 * per VM-area/task.  A VM area is any part of the process virtual memory
 * space that has a special rule for the page-fault handlers (ie a shared
 * library, the executable area etc).
 */
struct vm_area_struct {
        /* The first cache line has the info for VMA tree walking. */

        unsigned long vm_start;         /* 在虛擬地址空間的起始位置 */
        unsigned long vm_end;           /* 在虛擬地址空間的結束位置*/

        /* linked list of VM areas per task, sorted by address */
        struct vm_area_struct *vm_next, *vm_prev; /* 虛擬內存區域鏈表中的前繼,後繼指針 */
        struct rb_node vm_rb;

        /*
         * Largest free memory gap in bytes to the left of this VMA.
         * Either between this VMA and vma->vm_prev, or between one of the
         * VMAs below us in the VMA rbtree and its ->vm_prev. This helps
         * get_unmapped_area find a free area of the right size.
         */
        unsigned long rb_subtree_gap;

        /* Second cache line starts here. */

    	/* Function pointers to deal with this struct. */
        const struct vm_operations_struct *vm_ops;	/* 虛擬內存操作集合 */
    
        struct mm_struct *vm_mm;        /* vma所屬的虛擬地址空間 */
        pgprot_t vm_page_prot;          /* Access permissions of this VMA. */
        unsigned long vm_flags;         /* Flags, see mm.h. */
	unsigned long vm_pgoff;         /* 以Page爲單位的偏移. */
    	struct file * vm_file;          /* 映射的文件,匿名映射即爲nullptr*/

下圖是某個進程的虛擬內存簡化佈局以及相應的幾個數據結構之間的關係:

添加圖片註釋,不超過 140 字(可選)

mmap 映射執行流程

  1. 檢查參數,並根據傳入的映射類型設置 vma 的 flags.

  2. 進程查找其虛擬地址空間,找到一塊空閒的滿足要求的虛擬地址空間.

  3. 根據找到的虛擬地址空間初始化 vma.

  4. 設置 vma->vm_file.

  5. 根據文件系統類型,將 vma->vm_ops 設爲對應的 file_operations.

  6. 將 vma 插入 mm 的鏈表中.

5.3 源碼分析

我們接下來進入 mmap 的代碼分析:

(1)do_mmap()

do_mmap() 是整個 mmap() 的具體操作函數, 我們跳過系統調用來直接看具體實現:

unsigned long do_mmap(struct file *file, unsigned long addr,
                        unsigned long len, unsigned long prot,
                        unsigned long flags, vm_flags_t vm_flags,
                        unsigned long pgoff, unsigned long *populate,
                        struct list_head *uf)
{
        struct mm_struct *mm = current->mm;	/* 獲取該進程的memory descriptor
        int pkey = 0;

        *populate = 0;
	/*
	  函數對傳入的參數進行一系列檢查, 假如任一參數出錯,都會返回一個errno
	 */
        if (!len)
                return -EINVAL;

        /*
         * Does the application expect PROT_READ to imply PROT_EXEC?
         *
         * (the exception is when the underlying filesystem is noexec
         *  mounted, in which case we dont add PROT_EXEC.)
         */
        if ((prot & PROT_READ) && (current->personality & READ_IMPLIES_EXEC))
                if (!(file && path_noexec(&file->f_path)))
                        prot |= PROT_EXEC;
    	/* force arch specific MAP_FIXED handling in get_unmapped_area */
        if (flags & MAP_FIXED_NOREPLACE)
                flags |= MAP_FIXED;
		
    	/* 假如沒有設置MAP_FIXED標誌,且addr小於mmap_min_addr, 因爲可以修改addr, 所以就需要將addr設爲mmap_min_addr的頁對齊後的地址 */
        if (!(flags & MAP_FIXED))
                addr = round_hint_to_min(addr);

        /* Careful about overflows.. */
	/* 進行Page大小的對齊 */
        len = PAGE_ALIGN(len);
        if (!len)
                return -ENOMEM;

        /* offset overflow? */
        if ((pgoff + (len >> PAGE_SHIFT)) < pgoff)
                return -EOVERFLOW;

        /* Too many mappings? */
	/* 判斷該進程的地址空間的虛擬區間數量是否超過了限制 */
        if (mm->map_count > sysctl_max_map_count)
                return -ENOMEM;

        /* Obtain the address to map to. we verify (or select) it and ensure
         * that it represents a valid section of the address space.
         */
    	/* get_unmapped_area從當前進程的用戶空間獲取一個未被映射區間的起始地址 */
        addr = get_unmapped_area(file, addr, len, pgoff, flags);
	/* 檢查addr是否有效 */
    	if (offset_in_page(addr))
                return addr;

	/*  假如flags設置MAP_FIXED_NOREPLACE,需要對進程的地址空間進行addr的檢查. 如果搜索發現存在重合的vma, 返回-EEXIST。
	    這是MAP_FIXED_NOREPLACE標誌所要求的
	*/
        if (flags & MAP_FIXED_NOREPLACE) {
                struct vm_area_struct *vma = find_vma(mm, addr);

                if (vma && vma->vm_start < addr + len)
                        return -EEXIST;
        }

        if (prot == PROT_EXEC) {
                pkey = execute_only_pkey(mm);
                if (pkey < 0)
                        pkey = 0;
        }

        /* Do simple checking here so the lower-level routines won't have
         * to. we assume access permissions have been handled by the open
         * of the memory object, so we don't do any here.
         */
        vm_flags |= calc_vm_prot_bits(prot, pkey) | calc_vm_flag_bits(flags) |
                        mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC;
    	/* 假如flags設置MAP_LOCKED,即類似於mlock()將申請的地址空間鎖定在內存中, 檢查是否可以進行lock*/
    	if (flags & MAP_LOCKED)
                if (!can_do_mlock())
                        return -EPERM;

        if (mlock_future_check(mm, vm_flags, len))
                return -EAGAIN;
    
    	if (file) { /* file指針不爲nullptr, 即從文件到虛擬空間的映射 */
                struct inode *inode = file_inode(file); /* 獲取文件的inode */
                unsigned long flags_mask;

                if (!file_mmap_ok(file, inode, pgoff, len))
                        return -EOVERFLOW;

                flags_mask = LEGACY_MAP_MASK | file->f_op->mmap_supported_flags;

                /*
                  ...
                  根據標誌指定的map種類,把爲文件設置的訪問權考慮進去。
		  如果所請求的內存映射是共享可寫的,就要檢查要映射的文件是爲寫入而打開的,而不
		  是以追加模式打開的,還要檢查文件上沒有上強制鎖。
		  對於任何種類的內存映射,都要檢查文件是否爲讀操作而打開的。
		  ...
		*/
        } else {
                switch (flags & MAP_TYPE) {
                case MAP_SHARED:
                        if (vm_flags & (VM_GROWSDOWN|VM_GROWSUP))
                                return -EINVAL;
                        /*
                         * Ignore pgoff.
                         */
                        pgoff = 0;
                        vm_flags |= VM_SHARED | VM_MAYSHARE;
                        break;
                case MAP_PRIVATE:
                        /*
                         * Set pgoff according to addr for anon_vma.
                         */
                        pgoff = addr >> PAGE_SHIFT;
                        break;
                default:
                        return -EINVAL;
                }
        }
    	/*
         * Set 'VM_NORESERVE' if we should not account for the
         * memory use of this mapping.
         */
        if (flags & MAP_NORESERVE) {
                /* We honor MAP_NORESERVE if allowed to overcommit */
                if (sysctl_overcommit_memory != OVERCOMMIT_NEVER)
                        vm_flags |= VM_NORESERVE;

                /* hugetlb applies strict overcommit unless MAP_NORESERVE */
                if (file && is_file_hugepages(file))
                        vm_flags |= VM_NORESERVE;
        }

        addr = mmap_region(file, addr, len, vm_flags, pgoff, uf);
        if (!IS_ERR_VALUE(addr) &&
            ((vm_flags & VM_LOCKED) ||
             (flags & (MAP_POPULATE | MAP_NONBLOCK)) == MAP_POPULATE))
                *populate = len;
        return addr;

(2)mmap_region()

do_mmap() 根據用戶傳入的參數做了一系列的檢查,然後根據參數初始化 vm_area_struct 的標誌 vm_flags,vma->vm_file = get_file(file) 建立文件與 vma 的映射, mmap_region() 負責創建虛擬內存區域:

unsigned long mmap_region(struct file *file, unsigned long addr,
		unsigned long len, vm_flags_t vm_flags, unsigned long pgoff,
		struct list_head *uf)
{
	struct mm_struct *mm = current->mm;	// 獲取該進程的memory descriptor
	struct vm_area_struct *vma, *prev;
	int error;
	struct rb_node **rb_link, *rb_parent;
	unsigned long charged = 0;

	/* Check against address space limit. */
	/* 檢查申請的虛擬內存空間是否超過了限制. */
	if (!may_expand_vm(mm, vm_flags, len >> PAGE_SHIFT)) {
		unsigned long nr_pages;

		/*
		 * MAP_FIXED may remove pages of mappings that intersects with
		 * requested mapping. Account for the pages it would unmap.
		 */
		nr_pages = count_vma_pages_range(mm, addr, addr + len);

		if (!may_expand_vm(mm, vm_flags,
					(len >> PAGE_SHIFT) - nr_pages))
			return -ENOMEM;
	}

	/* 檢查[addr, addr+len)的區間是否存在映射空間,假如存在重合的映射空間需要munmap */
	while (find_vma_links(mm, addr, addr + len, &prev, &rb_link,
			      &rb_parent)) {
		if (do_munmap(mm, addr, len, uf))
			return -ENOMEM;
	}

	/*
	 * Private writable mapping: check memory availability
	 */
	if (accountable_mapping(file, vm_flags)) {
		charged = len >> PAGE_SHIFT;
		if (security_vm_enough_memory_mm(mm, charged))
			return -ENOMEM;
		vm_flags |= VM_ACCOUNT;
	}

	/* 檢查是否可以合併[addr, addr+len)區間內的虛擬地址空間vma*/
	vma = vma_merge(mm, prev, addr, addr + len, vm_flags,
			NULL, file, pgoff, NULL, NULL_VM_UFFD_CTX);
	if (vma) /* 假如合併成功,即使用合併後的vma, 並跳轉至out */
		goto out;

	/*
	 * Determine the object being mapped and call the appropriate
	 * specific mapper. the address has already been validated, but
	 * not unmapped, but the maps are removed from the list.
	 */
	/* 如果不能和已有的虛擬內存區域合併,通過 Memory Descriptor 來申請一個 vma */
	vma = vm_area_alloc(mm);
	if (!vma) {
		error = -ENOMEM;
		goto unacct_error;
	}
	
	/* 初始化 vma */
	vma->vm_start = addr;
	vma->vm_end = addr + len;
	vma->vm_flags = vm_flags;
	vma->vm_page_prot = vm_get_page_prot(vm_flags);
	vma->vm_pgoff = pgoff;

	if (file) { /* 假如指定了文件映射 */
		if (vm_flags & VM_DENYWRITE) { /* 映射的文件不允許寫入,調用 deny_write_accsess(file) 排斥常規的文件操作 */
			error = deny_write_access(file);
			if (error)
				goto free_vma;
		}
		if (vm_flags & VM_SHARED) { /* 映射的文件允許其他進程可見, 標記文件爲可寫 */
			error = mapping_map_writable(file->f_mapping);
			if (error)
				goto allow_write_and_free_vma;
		}

		/* ->mmap() can change vma->vm_file, but must guarantee that
		 * vma_link() below can deny write-access if VM_DENYWRITE is set
		 * and map writably if VM_SHARED is set. This usually means the
		 * new file must not have been exposed to user-space, yet.
		 */
		vma->vm_file = get_file(file);  /* 遞增 File 的引用次數,返回 File 賦給 vma */
		error = call_mmap(file, vma);   /* 調用文件系統指定的 mmap 函數,後面會介紹 */
		if (error)
			goto unmap_and_free_vma;

		/* Can addr have changed??
		 *
		 * Answer: Yes, several device drivers can do it in their
		 *         f_op->mmap method. -DaveM
		 * Bug: If addr is changed, prev, rb_link, rb_parent should
		 *      be updated for vma_link()
		 */
		WARN_ON_ONCE(addr != vma->vm_start);

		addr = vma->vm_start;
		vm_flags = vma->vm_flags;
	} else if (vm_flags & VM_SHARED) {
		/* 假如標誌爲 VM_SHARED,但沒有指定映射文件,需要調用 shmem_zero_setup()
		   shmem_zero_setup() 實際映射的文件是 dev/zero
		*/
		error = shmem_zero_setup(vma);
		if (error)
			goto free_vma;
	} else {
		/* 既沒有指定 file, 也沒有設置 VM_SHARED, 即設置爲匿名映射 */
		vma_set_anonymous(vma);
	}
	
	/* 將申請的新 vma 加入 mm 中的 vma 鏈表*/
	vma_link(mm, vma, prev, rb_link, rb_parent);
	/* Once vma denies write, undo our temporary denial count */
	if (file) {
		if (vm_flags & VM_SHARED)
			mapping_unmap_writable(file->f_mapping);
		if (vm_flags & VM_DENYWRITE)
			allow_write_access(file);
	}
	file = vma->vm_file;
out:
	perf_event_mmap(vma);
	/* 更新進程的虛擬地址空間 mm */
	vm_stat_account(mm, vm_flags, len >> PAGE_SHIFT);
	if (vm_flags & VM_LOCKED) {
		if ((vm_flags & VM_SPECIAL) || vma_is_dax(vma) ||
					is_vm_hugetlb_page(vma) ||
					vma == get_gate_vma(current->mm))
			vma->vm_flags &= VM_LOCKED_CLEAR_MASK;
		else
			mm->locked_vm += (len >> PAGE_SHIFT);
	}

	if (file)
		uprobe_mmap(vma);

	/*
	 * New (or expanded) vma always get soft dirty status.
	 * Otherwise user-space soft-dirty page tracker won't
	 * be able to distinguish situation when vma area unmapped,
	 * then new mapped in-place (which must be aimed as
	 * a completely new data area).
	 */
	vma->vm_flags |= VM_SOFTDIRTY;

	vma_set_page_prot(vma);

	return addr;

unmap_and_free_vma:
	vma->vm_file = NULL;
	fput(file);

	/* Undo any partial mapping done by a device driver. */
	unmap_region(mm, vma, prev, vma->vm_start, vma->vm_end);
	charged = 0;
	if (vm_flags & VM_SHARED)
		mapping_unmap_writable(file->f_mapping);
allow_write_and_free_vma:
	if (vm_flags & VM_DENYWRITE)
		allow_write_access(file);
free_vma:
	vm_area_free(vma);
unacct_error:
	if (charged)
		vm_unacct_memory(charged);
	return error;
}

mmap_region() 調用了 call_mmap(file, vma): call_mmap 根據文件系統的類型選擇適配的 mmap() 函數,我們選擇目前常用的 ext4。

ext4_file_mmap() 是 ext4 對應的 mmap, 功能非常簡單,更新了 file 的修改時間 (file_accessed(flie)),將對應的 operation 賦給 vma->vm_flags:

三個操作函數的意義

static const struct vm_operations_struct ext4_file_vm_ops = {
        .fault          = ext4_filemap_fault,
        .map_pages      = filemap_map_pages,
        .page_mkwrite   = ext4_page_mkwrite,
};

static int ext4_file_mmap(struct file *file, struct vm_area_struct *vma)
{
        struct inode *inode = file->f_mapping->host;

        if (unlikely(ext4_forced_shutdown(EXT4_SB(inode->i_sb))))
                return -EIO;

        /*
         * We don't support synchronous mappings for non-DAX files. At least
         * until someone comes with a sensible use case.
         */
        if (!IS_DAX(file_inode(file)) && (vma->vm_flags & VM_SYNC))
                return -EOPNOTSUPP;

        file_accessed(file);
        if (IS_DAX(file_inode(file))) {
                vma->vm_ops = &ext4_dax_vm_ops;
                vma->vm_flags |= VM_HUGEPAGE;
        } else {
                vma->vm_ops = &ext4_file_vm_ops;
        }
        return 0;
}

通過分析 mmap 的源碼我們發現在調用 mmap()的時候僅僅申請一個 vm_area_struct 來建立文件與虛擬內存的映射,並沒有建立虛擬內存與物理內存的映射。假如沒有設置 MAP_POPULATE 標誌位,Linux 並不在調用 mmap()時就爲進程分配物理內存空間,直到下次真正訪問地址空間時發現數據不存在於物理內存空間時,觸發 Page Fault 即缺頁中斷,Linux 纔會將缺失的 Page 換入內存空間. 後面的文章我們會介紹 Linux 的缺頁 (Page fault) 處理和請求 Page 的機制。

5.4 匿名映射

mmap() 設置參數 MAP_ANONYMOUS 即可指定匿名映射,mmap 的匿名映射並不執行文件或設備爲映射地址,實際上映射的文件爲 / dev/zero,匿名頁的物理內存一般分配用來作爲進程的棧或堆的虛擬內存映射。

常用的 read() 首先從文件的 Page 讀取至內核頁緩存 (Page Cache),Page Cache 位於內核內存空間, 所以需要再從內核態的內存空間拷貝到用戶態的內存空間,而 mmap 直接建立了文件與虛擬地址空間的映射, 可以直接通過 MMU 根據虛擬地址空間的地址映射從用戶物理內存區域讀取數據, 省去了內核態拷貝數據至用戶態的開銷. 因爲 mmap 的修改直接反映在物理內存時,所以 kill -9 進程也不會丟數據。

5.5 其他問題

vm_area_struct 如何尋找對應的物理內存頁?

vm_area_struct 結構中並沒有直接的存放 Page 指針的結構體,但包含虛擬地址的起始地址和結束地址 vm_start 和 vm_end, 通過虛擬地址轉換物理地址的方法可以直接尋找到指定的 Page。

如何處理變長的文件?

RocksDB 使用了 mmap 的方式寫文件, 首先 fallocate 固定長度 len 的文件,然後通過 mmap 建立映射,使用一個 base 指針來滑動寫入位置,寫滿長度 len 之後,調用 munmap. 假如 Close 文件時寫不夠長度 len, 即 mummap 寫入的長度,然後使用 ftruncate() 將多餘的映射部分截去。

mmap() 之後 memcpy() 出現 SIGBUS 錯誤:

SIGBUS 出現在缺頁中斷處理的過程中,即前面我們提到的 ext4_file_vm_ops 的 ext4_file_vm_ops():do_mmap() 有一行 len = PAGE_ALIGN(len), 即根據傳入的參數 len 進行頁對齊後的長度來映射文件,但這裏並沒有考慮文件 size。

而缺頁中斷後真正的文件映射讀取會考慮文件長度,即讀取的 offset 假如超過了文件 size 頁對齊後的長度,即會返回 SIGBUS。

/*
 * DIV_ROUND_UP()意爲向上取整, i_size_read(inode) 返回文件的長度 (inode->i_size)
 * 假如文件長度爲 7000, 經過 DIV_ROUND_UP(), max_off 返回 8192
 */
max_off = DIV_ROUND_UP(i_size_read(inode), PAGE_SIZE);

/*
 * offset 爲 memcpy() 中目標地址 addr 所指向的偏移位置,假如超過了 max_off,返回了 SIGBUS
 */
if (unlikely(offset >= max_off))
	return VM_FAULT_SIGBUS;

mmap() 之後 memcpy() 出現 SIGSEGV 錯誤: (mm/memory.c:handle_mm_fault())

if (!arch_vma_access_permitted(vma, flags & FAULT_FLAG_WRITE,
                                    flags & FAULT_FLAG_INSTRUCTION,
                                    flags & FAULT_FLAG_REMOTE))
	/* 
	 * 當進程訪問試圖訪問非法的虛擬地址空間,返回 SIGSEGV 錯誤
	 */ 
        return VM_FAULT_SIGSEGV;

mmap 是銀彈嗎?

不是, 隨機寫頻繁觸發的 Page Fault 和髒頁回寫使得 mmap 避免在內核態與用戶態之間的拷貝的優勢減弱,下圖是 Linux 環境寫文件如何穩定跑滿磁盤 I-O 帶寬中方案三的 mmap 順序寫入的火焰圖,我們可以更直觀的看到 mmap 的瓶頸所在:

mmap 設置 MAP_SHARED, 這部分使用的內存會計算在 RSS 中嗎?

會,RSS(Resident set size) 意爲常駐使用內存,一般理解爲真正使用的物理內存,當這部分設置了 MAP_SHARED 的內存觸發了 Page Fault,被 OS 真正分配了物理內存,就會在 RSS 的數值上體現。

mmap 設置 MAP_SHARED 的匿名共享內存可以被 swap 嗎?

可以, 設置 swap file, 匿名共享內存就可以被置換。

六、應用場景

對於傳統的 linux 系統文件操作是如何的呢?首選我們來看看工作流是如何的,其流程如下圖所示:

其特點爲:

下面來看看使用內存映射文件讀 / 寫的流程,其流程圖如下圖所示:

其特點爲:

在 Linux 系統中,根據內存映射的本質和特點,其應用場景在於:

對於進程間的通信,其工作流程如下圖所示:

七、內存映射的實現

以字符設備驅動爲例,一般對字符設備的操作都如下框圖:

而內存映射的主要任務就是實現內核空間中的 mmap() 函數,先來了解一下字符設備驅動程序的框架。

以下是 mmap_driver.c 的源代碼:

[cpp] view plain copy
//所有的模塊代碼都包含下面兩個頭文件  
#include <linux/module.h>  
#include <linux/init.h>  
  
#include <linux/types.h> //定義dev_t類型  
#include <linux/cdev.h> //定義struct cdev結構體及相關操作  
#include <linux/slab.h> //定義kmalloc接口  
#include <asm/io.h>//定義virt_to_phys接口  
#include <linux/mm.h>//remap_pfn_range  
#include <linux/fs.h>  
  
#define MAJOR_NUM 990  
#define MM_SIZE 4096  
  
static char driver_name[] = "mmap_driver1";//驅動模塊名字  
static int dev_major = MAJOR_NUM;  
static int dev_minor = 0;  
char *buf = NULL;  
struct cdev *cdev = NULL;  
  
static int device_open(struct inode *inode, struct file *file)  
{  
    printk(KERN_ALERT"device open\n");  
    buf = (char *)kmalloc(MM_SIZE, GFP_KERNEL);//內核申請內存只能按頁申請,申請該內存以便後面把它當作虛擬設備  
    return 0;  
}  
  
static int device_close(struct inode *indoe, struct file *file)  
{  
    printk("device close\n");  
    if(buf)  
    {  
        kfree(buf);  
    }  
    return 0;  
}  
  
static int device_mmap(struct file *file, struct vm_area_struct *vma)  
{  
    vma->vm_flags |= VM_IO;//表示對設備IO空間的映射  
    vma->vm_flags |= VM_RESERVED;//標誌該內存區不能被換出,在設備驅動中虛擬頁和物理頁的關係應該是長期的,應該保留起來,不能隨便被別的虛擬頁換出  
    if(remap_pfn_range(vma,//虛擬內存區域,即設備地址將要映射到這裏  
                       vma->vm_start,//虛擬空間的起始地址  
                       virt_to_phys(buf)>>PAGE_SHIFT,//與物理內存對應的頁幀號,物理地址右移12位  
                       vma->vm_end - vma->vm_start,//映射區域大小,一般是頁大小的整數倍  
                       vma->vm_page_prot))//保護屬性,  
    {  
        return -EAGAIN;  
    }  
    return 0;  
}  
  
static struct file_operations device_fops =  
{  
    .owner = THIS_MODULE,  
    .open  = device_open,  
    .release = device_close,  
    .mmap = device_mmap,  
};  
  
static int __init char_device_init( void )  
{  
    int result;  
    dev_t dev;//高12位表示主設備號,低20位表示次設備號  
    printk(KERN_ALERT"module init2323\n");  
    printk("dev=%d", dev);  
    dev = MKDEV(dev_major, dev_minor);  
    cdev = cdev_alloc();//爲字符設備cdev分配空間  
    printk(KERN_ALERT"module init\n");  
    if(dev_major)  
    {  
        result = register_chrdev_region(dev, 1, driver_name);//靜態分配設備號  
        printk("result = %d\n", result);  
    }  
    else  
    {  
        result = alloc_chrdev_region(&dev, 0, 1, driver_name);//動態分配設備號  
        dev_major = MAJOR(dev);  
    }  
      
    if(result < 0)  
    {  
        printk(KERN_WARNING"Cant't get major %d\n", dev_major);  
        return result;  
    }  
      
      
    cdev_init(cdev, &device_fops);//初始化字符設備cdev  
    cdev->ops = &device_fops;  
    cdev->owner = THIS_MODULE;  
      
    result = cdev_add(cdev, dev, 1);//向內核註冊字符設備  
    printk("dffd = %d\n", result);  
    return 0;  
}  
  
static void __exit char_device_exit( void )  
{  
    printk(KERN_ALERT"module exit\n");  
    cdev_del(cdev);  
    unregister_chrdev_region(MKDEV(dev_major, dev_minor), 1);  
}  
  
module_init(char_device_init);//模塊加載  
module_exit(char_device_exit);//模塊退出  
  
MODULE_LICENSE("GPL");  
MODULE_AUTHOR("ChenShengfa");

下面是測試代碼 test_mmap.c 下面是 makefile 文件 下面是 makefile 文件

[cpp] view plain copy
 
#include <stdio.h>  
#include <fcntl.h>  
#include <sys/mman.h>  
#include <stdlib.h>  
#include <string.h>  
  
int main( void )  
{  
    int fd;  
    char *buffer;  
    char *mapBuf;  
    fd = open("/dev/mmap_driver", O_RDWR);//打開設備文件,內核就能獲取設備文件的索引節點,填充inode結構  
    if(fd<0)  
    {  
        printf("open device is error,fd = %d\n",fd);  
        return -1;  
    }  
    /*測試一:查看內存映射段*/  
    printf("before mmap\n");  
    sleep(15);//睡眠15秒,查看映射前的內存圖cat /proc/pid/maps  
    buffer = (char *)malloc(1024);  
    memset(buffer, 0, 1024);  
    mapBuf = mmap(NULL, 1024, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);//內存映射,會調用驅動的mmap函數  
    printf("after mmap\n");  
    sleep(15);//睡眠15秒,在命令行查看映射後的內存圖,如果多出了映射段,說明映射成功  
      
    /*測試二:往映射段讀寫數據,看是否成功*/  
    strcpy(mapBuf, "Driver Test");//向映射段寫數據  
    memset(buffer, 0, 1024);  
    strcpy(buffer, mapBuf);//從映射段讀取數據  
    printf("buf = %s\n", buffer);//如果讀取出來的數據和寫入的數據一致,說明映射段的確成功了  
      
      
    munmap(mapBuf, 1024);//去除映射  
    free(buffer);  
    close(fd);//關閉文件,最終調用驅動的close  
    return 0;  
}

文件映射實例

/** * 
@file mmap_file.c 
*/

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <signal.h>
#include <unistd.h>
#include <sys/mman.h>

#define MMAP_FILE_NAME "a.txt"
#define MMAP_FILE_SIZE 10

void err_exit(const char *err_msg)
{
    printf("error:%s\n", err_msg);
    exit(1);
}

/* 信號處理器 */
void signal_handler(int signum)
{
    if (signum == SIGSEGV)
        printf("\nSIGSEGV handler!!!\n");
    else if (signum == SIGBUS)
        printf("\nSIGBUS handler!!!\n");
    exit(1);
}

int main(int argc, const char *argv[])
{
    if (argc < 2)
    {
        printf("usage:%s text\n", argv[0]);
        exit(1);
    }

    char *addr;
    int file_fd, text_len;
    long int sys_pagesize;

接上面的代碼:

    /* 設置信號處理器 */
    if (signal(SIGSEGV, signal_handler) == SIG_ERR)
        err_exit("signal()");
    if (signal(SIGBUS, signal_handler) == SIG_ERR)
        err_exit("signal()");

    if ((file_fd = open(MMAP_FILE_NAME, O_RDWR)) == -1)
        err_exit("open()");

    /* 系統分頁大小 */
    sys_pagesize = sysconf(_SC_PAGESIZE);
    printf("sys_pagesize:%ld\n", sys_pagesize);

    /* 內存只讀 */
    //addr = (char *)mmap(NULL, MMAP_FILE_SIZE, PROT_READ, MAP_SHARED, file_fd, 0);
    
    /* 映射大於文件長度,且大於該文件分頁大小 */
    //addr = (char *)mmap(NULL, sys_pagesize + 1, PROT_READ | PROT_WRITE, MAP_SHARED, file_fd, 0);

    /* 正常分配 */
    addr = (char *)mmap(NULL, MMAP_FILE_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, file_fd, 0);
    if (addr == MAP_FAILED)
        err_exit("mmap()");

    /* 原始數據 */
    printf("old text:%s\n", addr);

    /* 越界訪問 */
    //addr += sys_pagesize + 1;
    //printf("out of range:%s\n", addr);

    /* 拷貝新數據 */
    text_len = strlen(argv[1]);
    memcpy(addr, argv[1], text_len);

    /* 同步映射區數據 */
    //if (msync(addr, text_len, MS_SYNC) == -1)
    //    err_exit("msync()");

    /* 打印新數據 */
    printf("new text:%s\n", addr);

    /* 解除映射區域 */
    if (munmap(addr, MMAP_FILE_SIZE) == -1)
        err_exit("munmap()");

    return 0;
}

(1) 首先創建一個 10 字節的文件:

$:dd if=/dev/zero of=a.txt bs=1 count=10

(2) 把程序編譯運行後,依次執行 2 寫入:

以看到本機的分頁大小是 4096 字節。第一次寫入 9 個字節,原來用 dd 命令創建的文件爲空,old text 爲空。第二次寫入 4 個字節,只覆蓋了最前面的 1234。

(3) 驗證可訪問現有分頁的內存。寫入超過 10 字節的數據:

上面我們寫入了 17 個字節,雖然 64 行的 mmap()映射了 MMAP_FILE_SIZE=10 字節。但從輸入 new text 可以看出,我們當然可以訪問 10 字節後面的內存,因爲該數據都在一個分頁 (4096) 裏面。cat 查看 a.txt 後,只有前 10 個字節寫入了 a.txt。

(4) 驗證 SIGSEGV 信號。把 64 行註釋調,58 行打開,設置映射屬性爲只讀,編譯後訪問:

設置只讀屬性後,第 77 行有寫操作。我們自定義的信號處理器就捕捉到了該信號。如果沒有自定義信號處理器,終端就會輸出 Segmentation fault。

(5) 驗證 SIGBUS 信號。用 61 行的方法來映射內存。映射了一個分頁大小再加 1 字節的內存,並放開 72,73 行的代碼,讓指針指向一個分頁後的區域。編譯後運行:

SIGBUS 信號被自定義處理器捕捉到了。如果沒有自定義信號處理器,終端就會輸出 Bus error。

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