Linux mmap()系统调用
mmap()系统调用的作用与使用
我们可以通过man mmap来查看一下mmap()的说明:

名字
mmap, munmap -- 映射或者取消映射文件或设备到内存
概要
#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()在调用进程的虚拟地址空间中创建一个新映射。 新映射的起始地址在 addr 中指定。 length 参数指定映射的长度。
如果 addr 为 NULL,则内核选择创建映射的地址; 这是创建新映射的最便携方法。 如果 addr 不为 NULL,则内核将其作为关于放置映射位置的提示; 在 Linux 上,映射将在附近的页面边界处创建。 新映射的地址作为调用结果返回。
文件映射的内容(与匿名映射相反;请参阅下面的 MAP_ANONYMOUS)使用从文件描述符 fd 引用的文件(或其他对象)中的偏移偏移量开始的长度字节进行初始化。 offset 必须是 sysconf(_SC_PAGE_SIZE)返回的页面大小的倍数。
prot 参数描述了映射所需的内存保护(并且不得与文件的打开模式冲突)。 它是 PROT_NONE 或以下一个或多个标志的按位或:
- PROT_EXEC:页面带执行属性。
- PROT_READ:页面带可读属性。
- PROT_WRITE:页面带可写属性。
- PROT_NONE:页面可能不能访问。
flags 参数确定映射的更新是否对映射同一区域的其他进程可见,以及更新是否传递到底层文件。 此行为是通过在标志中准确包含以下值之一来确定的:
- MAP_SHARED:共享此映射。 映射的更新对映射此文件的其他进程可见,并传递到底层文件。 (要精确控制何时将更新传递到底层文件需要使用 msync(2)。)
- MAP_PRIVATE:创建私有的写时复制映射。 映射的更新对映射同一文件的其他进程不可见,也不会传递到底层文件。 未指定在调用 mmap() 之后对文件所做的更改在映射区域中是否可见。
此外,可以在标志中对以下零个或多个值进行 OR 运算:
MAP_32BIT:将映射放入进程地址空间的前 2 GB。 对于 64 位程序,此标志仅在 x86-64 上受支持。 添加它是为了允许在前 2GB 内存中的某处分配线程堆栈,以提高某些早期 64 位处理器上的上下文切换性能。 现代 x86-64 处理器不再有这个性能问题,所以在这些系统上不需要使用这个标志。 设置 MAP_FIXED 时,将忽略 MAP_32BIT 标志。
MAP_ANON:MAP_ANONYMOUS 的同义词。 已弃用。
MAP_ANONYMOUS:映射不受任何文件的支持; 它的内容被初始化为零。
fd和offset参数被忽略; 但是,如果指定了MAP_ANONYMOUS(或MAP_ANON),某些实现要求fd为-1,并且便携式应用程序应确保这一点。MAP_ANONYMOUS与MAP_SHARED结合使用仅在 Linux 内核 2.4 后才支持。MAP_EXECUTABLE:该标志被忽略。
MAP_FILE:兼容性标志。 忽略。
MAP_FIXED:不要将 addr 解释为提示:将映射准确放置在该地址处。 addr 必须是页面大小的倍数。 如果 addr 和 len 指定的内存区域与任何现有映射的页面重叠,则现有映射的重叠部分将被丢弃。 如果无法使用指定的地址, mmap() 将失败。 因为需要一个固定地址的映射不太容易移植,所以不鼓励使用这个选项。
MAP_GROWSDOWN:用于堆栈。 向内核虚拟内存系统指示映射应在内存中向下扩展。
MAP_HUGETLB:(since Linux 2.6.32)使用“大页面”分配映射。 有关更多信息,请参阅 Linux 内核源文件 Documentation/vm/hugetlbpage.txt,以及下面的 NOTES。
-
MAP_HUGE_2MB, MAP_HUGE_1GB:与
MAP_HUGETLB结合使用以在支持多个Hugetlb页面大小的系统上选择替代的Hugetlb页面大小(分别为2 MB和1 GB)。
更一般地,可以通过在偏移MAP_HUGE_SHIFT处的六位中编码所需页面大小的以 2 为底的对数来配置所需的大页面大小。 (该位域中的零值提供了默认的大页面大小;可以通过/proc/meminfo公开的Hugepagesize字段发现默认大页面大小。)因此,上述两个常量定义为:#define MAP_HUGE_2MB (21 << MAP_HUGE_SHIFT) #define MAP_HUGE_1GB (30 << MAP_HUGE_SHIFT)可以通过列出
/sys/kernel/mm/hugepages中的子目录来发现系统支持的大页面大小范围。 MAP_LOCKED:(since Linux 2.5.37)以与
mlock()相同的方式标记要锁定的mmaped区域。 此实现将尝试填充(预错)整个范围,但如果失败,则mmap调用不会因ENOMEM而失败。 因此,稍后可能会发生重大故障。 所以语义不如mlock()强。 当映射初始化后无法接受主要故障时,应使用mmap()加mlock()。MAP_LOCKED标志在较旧的内核中被忽略。MAP_NONBLOCK:仅与
MAP_POPULATE结合使用才有意义。 不要执行预读:只为RAM中已经存在的页创建页表条目。 从 Linux 2.6.23 开始,这个标志会导致MAP_POPULATE什么也不做。 有一天,可能会重新实现MAP_POPULATE和MAP_NONBLOCK的组合。MAP_NORESERVE:不要为此映射保留交换空间。 保留交换空间时,可以保证可以修改映射。 当没有保留交换空间时,如果没有可用的物理内存,则可能会在写入时获得
SIGSEGV。 另请参阅 proc(5) 中对文件/proc/sys/vm/overcommit_memory的讨论。 在 2.6 之前的内核中,此标志仅对私有可写映射有效。MAP_POPULATE:(since Linux 2.5.46)为映射填充页表(prefault,预先触发page fault)。 对于文件映射,这会导致对文件进行预读。 这将有助于减少以后的页面错误阻塞。
MAP_POPULATE仅自 Linux 2.6.23 起支持私有映射。MAP_STACK:在适合进程或线程堆栈的地址分配映射。 这个标志目前是一个空操作,但在 glibc 线程实现中使用,因此如果某些架构需要对堆栈分配进行特殊处理,稍后可以透明地为 glibc 实现支持。
MAP_UNINITIALIZED:不要清除匿名页面。 此标志旨在提高嵌入式设备的性能。 仅当内核配置了 CONFIG_MMAP_ALLOW_UNINITIALIZED 选项时,才会使用此标志。 由于安全隐患,该选项通常仅在嵌入式设备(即可以完全控制用户内存内容的设备)上启用。
上述标志中,只有 MAP_FIXED 在 POSIX.1-2001 和 POSIX.1-2008 中指定。 但是,大多数系统也支持 MAP_ANONYMOUS(或其同义词 MAP_ANON)。
一些系统记录了附加标志 MAP_AUTOGROW、MAP_AUTORESRV、MAP_COPY 和 MAP_LOCAL。
由 mmap()映射的内存跨 fork() 保留,具有相同的属性。
文件以页面大小的倍数进行映射。 对于不是页面大小倍数的文件,剩余内存在映射时清零,并且对该区域的写入不会写出到文件中。 未指定在对应于文件的添加或删除区域的页面上更改映射的基础文件大小的影响。
munmap()系统调用删除指定地址范围的映射,并导致对该范围内地址的进一步引用以生成无效的内存引用。 当进程终止时,该区域也会自动取消映射。 另一方面,关闭文件描述符不会取消区域映射。
地址 addr 必须是页面大小的倍数(但长度不必是)。 包含指定范围一部分的所有页面都未映射,对这些页面的后续引用将生成 SIGSEGV。 如果指示的范围不包含任何映射页面,这不是错误。
返回值与错误码就不看了, mmap()系统调用的主要作用总结下来有这么几个:
- 创建文件映射,可以使文件的内容读到进程的虚拟内存中,可以省略传统的
malloc()之后再read()的过程,并且可以直接修改内存上的数据,不需要调用write()系统调用回写。减少了系统调用的次数,可以提高读写速度。 - 同上一条,创建文件映射时可以设置为共享映射, 那么修改的内容在其他进程可见。
- 映射设备的地址到进程内存,那么内核与进程的数据可以不需要常规的
copy_to/from_user()接口去拷贝,实现内核与进程内存共享的功能,减少拷贝,对一些比如USB驱动等比较有用。 - 创建匿名映射,作用暂时不了解。
内核mmap()系统调用的实现
整体mmap()流程
这里的代码基于linux 4.0的arm代码。
我们可以在arch/arm/kernel/entry-common.S找到这个sys_mmap2的定义,还有一个sys_mmap系统调用,但这里看起来是没有实现的,只实现了sys_mmap2。sys_mmap与sys_mmap2的差别是off参数一个单位是字节,一个单位是page。
可以看到这里的宏实际是调用的sys_mmap_pgoff():

从 man 手册中我们可以看到, 如果是匿名映射,fd=-1就行了; 否则,不是匿名映射的,需要找到 fd 对应的 file 结构体。sys_mmap_pgoff()一开始的地方是根据是否是匿名映射去找file结构体,关于HUGEPAGE和HUGETLB的暂时就不看了。

判断完后,将后续的处理交给vm_mmap_pgoff():

vm_mmap_pgoff()获取当前进程的内存描述符,用信号量保护内存映射的过程,映射过程交给do_mmap_pgoff()实现:

do_mmap_pgoff()这里一开始主要是参数检查。mmap()时输入的PROT_READ参数在MNT_NOEXEC标记的文件系统下会默认增加PROT_EXEC可执行标记;如果带有MAP_FIXED标记,那么传入的addr是不允许调整,否则可以根据情况来调整一下addr,比如调整为mmap_min_addr;对len长度进行校验以及向上对齐;检查pgoff+len是否会有溢出;检查当前进程mmap的数量是否超出了sysctl 的限制。

通常我们mmap()传入的地址是非0值,然后这里round_hint_to_min()就给addr调整一下,调整为mmap_min_addr,0值不调整,继续往后面的函数get_unmapped_area()传递:

这里通过get_unmapped_area()获取要映射的地址;然后将prot属性和flags标记都转换为虚拟内存的标记vm_flags;并检测MAP_LOCKED的权限以及能否mlock。

这里是文件映射的标记检查过程,文件映射包括共享映射和私有映射。检查对应的文件属性以及prot属性是否相匹配。

非文件映射,即匿名映射,也分为共享映射和私有映射。

这里将是映射内存以及是否需要触发预读判定。

获取可以映射的地址
通过get_unmapped_area()获取可以映射的内存区域,默认是当前进程的内存管理结构体current->mm->get_unmapped_area,如果是文件映射且对应的操作集存在,则是file->f_op->get_unmapped_area。

这里进程默认的mm->get_unmapped_area应该是由arch_pick_mmap_layout()决定的,传统布局,mmap低地址由低到高申请;新布局则由高到低申请;分别由arch_get_unmapped_area()和arch_get_unmapped_area_topdown()实现。

判断使用哪个布局,mmap_is_legacy()看三个条件:如果当前进程的属性是有ADDR_COMPAT_LAYOUT,或者进程的栈大小是没有限制的,默认返回是传统的布局;否则根据sysctl参数决定。

当前设备下的返回参数是0,即新布局:
/ # cat /proc/sys/vm/legacy_va_layout
0
传统布局
arch_get_unmapped_area()获取可以映射的虚拟内存地址,这里有VIPT,VIVP的优化,通过find_vma()找到一个具体的vma。


目前代码走的是arch_get_unmapped_area_topdown(),部分代码放下面新布局下看。
新布局
布局在arch_pick_mmap_layout()里面选,基地址mmap_base也是里面传递的。先看一个普通进程的mmap_base地址:(有一个奇怪的地方,这里栈的起始地址start_stack减去mmap_base起始地址,居然是小于ulimit -s的8M的?栈也向下增加,mmap也是向下映射的,那如果栈大于了start_stack减去mmap_base预留的这个值,不就会==导致mmap的地址和栈的地址重叠==了吗?)

重新在应用程序下执行mmap()操作,并在特定长度的条件下打断点:这个时候mmap的地址和栈的地址之间的范围就超过8M的栈大小了。前面一开始打断点,第一次进入sys_mmap()系统调用的话还是在C库里面,准备加载程序、加载动态库的一些过程。

看arch_get_unmapped_area_topdown()函数:


find_vma()先从当前进程的vmacache里面找,找不到再从红黑树里面找,找到后更新到当前进程的vmacache里。

vmacache_find()遍历一下当前进程的vmacache里,找一个合适的vma:根据vma的起始地址和结束地址来判定。

如果mmmap()系统调用传入的addr=0,或者上面的find_vma()失败了,那就要走vm_unmapped_area()且找一个未映射过的地址:

unmapped_area_topdown()遍历红黑树找一个未映射的地址,返回gap_end低地址。



地址范围加入VMA红黑树
这里主要是检查一下要映射的地址范围在当前进程的虚拟内存是否可以满足需求,如果是固定映射,内存不足时可以回收之前固定映射的虚拟地址空间,来尝试满足这一次的固定映射。意味着:如果第一次固定映射的长度比较长,第二次固定映射长度较短,这个时候虚拟内存是可以满足的,否则,可能出现虚拟内存不足。在查找要插入红黑树的位置过程中,如果出现了地址重叠,需要将地址范围取消映射后再次找出要插入的位置。取消映射失败,那么就返回内存不足。

这里尝试与前面找到的红黑树节点进行合并,合并成功的话公用一个VMA结构体,合并不成功就要申请一个新的VMA结构体存放这个地址范围空间。

这里主要是文件映射的文件系统的mmap回调处理,调用具体的mmap回调,比如通用的generic_file_mmap(),ext4文件系统的ext4_file_mmap(),或者底层驱动类似mmap_mem()都可以。

如果是匿名共享映射,会创建一个临时文件,然后赋值给VMA。将VMA加入到红黑树,并统计内存信息。

将新的VMA或者经过扩展的VMA标记为软脏状态,具体后续怎么处理的,暂时不了解。

may_expand_vm()检查进程的虚拟地址空间是否会超过限制:

count_vma_pages_range()计算传入的addr~end地址范围空间与当前进程VMA相交的页面数量,结合在mmap_region()里面使用的场景,可以知道,如果第一次固定映射的地址长度比较长,后续进程虚拟地址空间不足时,可以继续通过固定映射传入小长度,去减少第一次固定映射所申请的虚拟地址空间。

find_vma_intersection()检查当前进程的VMA是否存在与传入的地址范围相交的,如果有,返回对应的VMA。这里地址范围与VMA相交的话,要求是VMA的起始地址小于等于传入的起始地址,VMA的结束地址大于传入的结束地址。

find_vma_links()看代码应该是找与addr~end相邻的一个红黑树节点已经其父节点,用于后续将addr~end返回的VMA加入到红黑树去。

accountable_mapping()检查内存是否带有写属性:

shmem_zero_setup()会在内存中创建一个临时文件,叫/dev/zero,然后把这个文件的一些描述符信息给到匿名共享映射的VMA。

可以从这里的图看到:(这里的地址与mmap()返回的地址一致,但代码中好像没有看到用到这个地址?)

在内核中修改名字后:

用户内存申请--进行缺页异常处理
如果mmap()映射的标志带有VM_LOCKED或MAP_POPULATE标志位,这里要对内存页面进行填充或者锁定。



__mlock_vma_pages_range()尝试将vma范围内的用户地址进行内存申请。


__get_user_pages()将对地址范围内的用户内存进行缺页异常,以达到申请内存的目的。





接下来就是faultin_page()->handle_mm_fault()->__handle_mm_fault()->pud_alloc(),pmd_alloc()->handle_pte_fault()->do_fault()->...等一系列操作,等后续对内存管理等其他代码进行阅读后再回过头来看这部分代码吧。
当然,如果mmap()时没有设置VM_LOCKED或MAP_POPULATE标志位,缺页异常是在用户访问返回的内存时触发,而不是上面的主动触发的一个流程。
先到这里吧,后续看有需求的时候再补上吧。