前言:内存映射是一种操作系统提供的技术,它将文件的内容映射到进程的虚拟地址空间中,使得进程可以直接读写文件而无需通过传统的 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的倍数大小进行映射。
参数说明:
start:映射区的开始地址,设置为0时表示由系统决定映射区的起始地址。
length:映射区的长度。//长度单位是 以字节为单位,不足一内存页按一内存页处理
prot:期望的内存保护标志,不能与文件的打开模式冲突。是以下的某个值,可以通过or运算合理地组合在一起
PROT_EXEC: 表示映射的页面是可以执行的
PROT_READ:表示映射的页面是可以读取的
PROT_WRITE :表示映射的页面是可以写入的
PROT_NONE :表示映射的页面是不可访问的
flags:指定映射对象的类型,映射选项和映射页是否可以共享。它的值可以是一个或者多个以下位的组合体
MAP_SHARED:创建一个共享映射的区域,多个进程可以通过共享映射的方式来映射一个文件,这样其他进程也可以看到映射内容的改变,修改后的内容会同步到磁盘文件
MAP_PRIVATE:创建一个私有的写时复制的映射,多个进程可以通过私有映射方式来映射一个文件,其他的进程不会看到映射文件内容的改变,修改后也不会同步到磁盘中
MAP_ANONYMOUS:创建一个匿名映射,即没有关联到文件的映射
MAP_FIXED:
MAP_POPULATE:提前遇到文件内容到映射区
fd:mmap映射释放和文件相关联,可以分为匿名映射和文件映射
文件映射:将一个普通文件的全部或者一部分映射到进程的虚拟内存中。映射后,进程就可以直接在对应的内存区域操作文件内容!
匿名映射:匿名映射没有对应的文件或者对应的文件时虚拟文件(如:/dev/zero),映射后会把内存分页全部初始化为0。
offset:被映射对象内容的起点
返回说明:
成功执行时,mmap()返回被映射区的指针,munmap()返回0。失败时,mmap()返回MAP_FAILED[其值为(void *)-1],munmap返回-1。
当多个进程映射了同一个内存区域时,它们会共享物理内存的相同分页。通过fork()创建的子进程也会继承父进程的映射副本!!!
根据文件关联性和映射区域示范共享等属性,其分为:
私有映射:映射的内容对其他进程不可见。对于文件映射来说,某一个进程在映射内存中改变文件的内容不会反映到被映射的底层文件中。内核会使用copy-on-write(写时复制)技术来解决这个问题:只要有一个进程修改了分页中的内容,内核会为该进程重新创建一个新的分页,并将需要修改的内容复制到新分页中。
共享映射:某一个进程对共享的内存区域操作都对其他进程可见!!!对于文件映射,操作的内容会反映到底层文件中。
注意:进程执行·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个异常。
内存映射,简而言之就是将用户空间的一段内存区域映射到内核空间,映射成功后,用户对这段内存区域的修改可以直接反映到内核空间,同样,内核空间对这段区域的修改也直接反映用户空间。那么对于内核空间<---->用户空间两者之间需要大量数据传输等操作的话效率是非常高的。
分配虚拟内存页 : 在 Linux 系统中 创建 " 内存映射 “ 时 , 会在 ” 用户虚拟地址空间 “ 中 , 分配一块 ” 虚拟内存区域 " ;
缺页异常 : Linux 内核在分配 " 物理内存 “ 时 , 采用了 ” 延迟策略 “ , 即进程第一次访问 , 不会立即分配 物理内存 , 而是产生一个 ” 缺页异常 " ;
分配物理内存页 : 缺页异常后的
内存映射 与 共享内存 关系 :
文件映射 : 在进程间的 " 共享内存 " 就是使用 共享的 " 文件映射 " 实现的 ;
匿名映射 : " 匿名映射 “ 一般是 ” 私有映射 " , 一般不作为 " 共享内存 " 使用 , 如果两个进程之间 共享 匿名映射 , 只能是 父子进程之间 才可以 ;
如果修改了 进程间的 " 共享内存 " 对应的 " 文件映射 " , 修改后不会立刻更新到文件中 , 调用 msync 函数 , 强制同步写入到文件中 ;
在 进程 的 " 用户虚拟地址空间 " 中 , 不同的 内存段 其 内存映射 类型也是不同的 :
代码段 : 私有的 " 文件映射 " ;
数据段 : 私有的 " 文件映射 " ;
未初始化数据段 : 私有的 " 匿名映射 " ;
堆栈 : 私有的 " 匿名映射 " ;
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()来取消内存映射。
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
addr:映射后要存放的虚拟内存地址。如果是NULL,内核会自动帮你选择。
length:映射内存的字节数。
prot:权限保护:PROT_NONE(无法访问),PROT_READ(可读),PROT_WRITE(可写),PROT_EXEC(可执行)。
flags:映射特征:MAP_PRIVATE(私有),MAP_SHARED(共享),MAP_ANONYMOUS。还有一些其他的可查询man手册。
fd:要映射的文件描述符。
offset:文件的偏移量,如果为0,且length为文件长度,代表映射整个文件。
#include <sys/mman.h>
int munmap(void *addr, size_t length);
addr:要解除内存的起始地址。如果addr不在刚刚映射区域的开始位置,解除一部分后内存区域可能会分成两半!!!
length:要解除的字节数。
#include <sys/mman.h>
int msync(void *addr, size_t length, int flags);
addr:要同步的内存起始地址。
length:要同步的字节长度。
flags:MS_SYNC(执行同步文件写入),此操作内核会把内容直接写到磁盘。MS_ASYNC(执行异步文件写入),此操作内核会先把内容写到内核的缓冲区,某个合适的时候再写到磁盘。
添加图片注释,不超过 140 字(可选)
mmap是操作这些设备的一种方法,所谓操作设备,比如IO端口(点亮一个LED)、LCD控制器、磁盘控制器,实际上就是往设备的物理地址读写数据。
但是,由于应用程序不能直接操作设备硬件地址,所以操作系统提供了这样的一种机制——内存映射,把设备地址映射到进程虚拟地址,mmap就是实现内存映射的接口。
操作设备还有很多方法,如ioctl、ioremap
mmap的好处是,mmap把设备内存映射到虚拟内存,则用户操作虚拟内存相当于直接操作设备了,省去了用户空间到内核空间的复制过程,相对IO操作来说,增加了数据的吞吐量。
添加图片注释,不超过 140 字(可选)
每个进程都有4G的虚拟地址空间,其中3G用户空间,1G内核空间(linux),每个进程共享内核空间,独立的用户空间,下图形象地表达了这点驱动程序运行在内核空间,所以驱动程序是面向所有进程的。
用户空间切换到内核空间有两种方法:
(1)系统调用,即软中断
(2)硬件中断
虚拟空间装的大概是上面那些数据了,内存映射大概就是把设备地址映射到上图的红色段了,暂且称其为“内存映射段”,至于映射到哪个地址,是由操作系统分配的,操作系统会把进程空间划分为三个部分:
(1)未分配的,即进程还未使用的地址
(2)缓存的,缓存在ram中的页
(3)未缓存的,没有缓存在ram中
操作系统会在未分配的地址空间分配一段虚拟地址,用来和设备地址建立映射,至于怎么建立映射,后面再揭晓。
现在大概明白了“内存映射”是什么了,那么内核是怎么管理这些地址空间的呢?任何复杂的理论最终也是通过各种数据结构体现出来的,而这里这个数据结构就是进程描述符。从内核看,进程是分配系统资源(CPU、内存)的载体,为了管理进程,内核必须对每个进程所做的事情进行清楚的描述,这就是进程描述符,内核用task_struct结构体来表示进程,并且维护一个该结构体链表来管理所有进程。该结构体包含一些进程状态、调度信息等上千个成员,我们这里主要关注进程描述符里面的内存描述符(struct mm_struct mm)
添加图片注释,不超过 140 字(可选)
现在已经知道了内存映射是把设备地址映射到进程空间地址(注意:并不是所有内存映射都是映射到进程地址空间的,ioremap是映射到内核虚拟空间的,mmap是映射到进程虚拟地址的),实质上是分配了一个vm_area_struct结构体加入到进程的地址空间,也就是说,把设备地址映射到这个结构体,映射过程就是驱动程序要做的事了。
我们来详细介绍一下mmap()的细节和源码分析. 虽然我们使用mmap()只是简单的映射文件至内存中,而mmap()的设计实现主要涉及内核中的虚拟内存空间和内存映射等细节
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);
这里我们先介绍两个关于虚拟内存的数据结构。虚拟内存概念的相关资料网上已经足够的丰富,这里我们从内核的角度来分析。虚拟空间的管理是以进程为基础的,每个进程都有各自的虚存空间,除此之外,每个进程的“内核虚拟空间”是为所有的进程所共享的。一个进程的虚拟地址空间主要由两个数据结构来描述: 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映射执行流程
检查参数,并根据传入的映射类型设置vma的flags.
进程查找其虚拟地址空间,找到一块空闲的满足要求的虚拟地址空间.
根据找到的虚拟地址空间初始化vma.
设置vma->vm_file.
根据文件系统类型,将vma->vm_ops设为对应的file_operations.
将vma插入mm的链表中.
我们接下来进入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:
三个操作函数的意义
.fault: 处理 Page Fault
.map_pages: 映射文件至 Page Cache
.page_mkwrite: 修改文件的状态为可写
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 的机制。
mmap()设置参数MAP_ANONYMOUS即可指定匿名映射,mmap的匿名映射并不执行文件或设备为映射地址,实际上映射的文件为/dev/zero,匿名页的物理内存一般分配用来作为进程的栈或堆的虚拟内存映射。
常用的read()首先从文件的 Page 读取至内核页缓存 (Page Cache),Page Cache 位于内核内存空间, 所以需要再从内核态的内存空间拷贝到用户态的内存空间,而mmap直接建立了文件与虚拟地址空间的映射, 可以直接通过MMU根据虚拟地址空间的地址映射从用户物理内存区域读取数据, 省去了内核态拷贝数据至用户态的开销. 因为mmap的修改直接反映在物理内存时,所以kill -9进程也不会丢数据。
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系统文件操作是如何的呢?首选我们来看看工作流是如何的,其流程如下图所示:
其特点为:
使用页缓存机制,提高读写效率和保护磁盘
读文件时,先将文件从磁盘拷贝到缓存,由于页缓存区是在内核空间,不能被用户空间直接访问,所以需要将页缓存区数据再次拷贝到用户空间,有2次文件拷贝工作
下面来看看使用内存映射文件读/写的流程,其流程图如下图所示:
其特点为:
用户空间与内核空间的交互式通过映射的区域直接交互,用内存的读取代替I/O读写,文件读写效率高
数据拷贝次数少,对文件的读取操作跨过页缓存,减少了数据拷贝一次,效率提高
可实现高效的大规模数据传输
在Linux系统中,根据内存映射的本质和特点,其应用场景在于:
1.实现内存共享,如跨进程通信
2.提高数据读/写效率:如读写操作
对于进程间的通信,其工作流程如下图所示:
创建一块共享的接收区,实现地址映射关系
发送进程数据到自身的虚拟内存区域,数据拷贝1次
由于发送进程的虚拟地址空间与接收进程的虚拟内存地址存在映射关系,所以发送到的数据也存放到接收进程的虚拟内存中,即实现了跨进程间通信
以字符设备驱动为例,一般对字符设备的操作都如下框图:
而内存映射的主要任务就是实现内核空间中的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。
精品文章推荐阅读: