目录
- 进程地址空间
- 内存区域
- mm_struct 数据结构
- VMA 数据结构
- VMA 的属性
- 内核如何管理内存
- malloc函数
- brk 系统调用
- malloc流程
- mmap函数
- 私有匿名映射
- 共享匿名映射
- 私有文件映射
- 共享文件映射
- 总结
进程地址空间
进程地址空间在内核中使用 vm_area_struct 数据结构来描述,简称 VMA,表示进程地址空间或进程线性区。
由于这些地址空间属于各个用户进程,因此在用户进程的 mm_struct 数据结构中有相应的成员,用于对这些 VMA 进行管理。
内存区域
进程地址空间(process address space)是指进程可寻址的虚拟地址空间,进程可以通过内核的内存管理机制动态地添加和删除内存区域,这些内存区域在 linux 内核采用 VMA 数据结构来抽象描述。
每个内存区域具有相关的权限,如可读、可写或者可执行权限。若一个进程访问了不在有效范围的内存区域,或者非法访问了内存区域,或者以不正确的方式访问了内存区域,那么处理器会报告缺页异常。
在 Linux 内核的缺页异常处理中会处理这些情况,严重的会报告“SegmentFault'”并终止该进程。
内存区域主要包含内容如下:
- 代码段映射:可执行文件中包含只读并可执行的程序头,如代码段和 init 段等
- 数据段映射:可执行文件中包含可读/可写的程序头,如数据段和未初始化数据段等
- 用户进程栈:通常位于用户空间的最高地址,从上往下延伸。它包含栈帧,里面包含了局部变量和函数调用参数等
- mmap 映射区域:位于用户进程栈下面,主要用于 mmap 系统调用
- 堆映射区域:malloc() 函数分配的进程虚拟地址就是这段区域
每个进程都有一套页表,这样每个进程地址空间就是相互隔离的。即使两个进程地址空间的虚拟地址是相同的,但是经过两套不同页表的转换之后,它们也会对应不同的物理地址。
mm_struct 数据结构
Linux 内核需要管理每个进程所有的内存区域以及它们对应的页表映射,所以必须抽象出一个数据结构,这就是 mm_struct 数据结构。
进程控制块(Process Control block,PCB)数据结构 task_struct 中有一个指针 mm,该指针指向这个 mm_struct 数据结构。mm_struct 数据结构定义在 include/linux/mm_types.h 文件中,下面是它的主要成员。
mm_struct 数据结构中主要成员的含义如下:
mmap
:进程里所有的 VMA 形成一个单链表,这是该链表的头mmrb
:VMA 红黑树的根节点get_unmapped_area
:用于判断虚拟内存空间是否有足够的空间,返回一段没有映射过的空间的起始地址,这个函数会使用具体的处理器架构的实现mmap_base
:指向 mmap 空间的起始地址。在 32 位处理器中,mmap 空间的起始地是 0x4000 0000pgd
:指向进程的 PGD(一级页表)mm_users
:记录正在使用该进程地址空间的进程数目,如果两个线程共享该地址空间那么 mm_users 的值等于 2mm_count
:mm_struct 结构体的主引用计数mmap_sem
:保护 VMA 的一个读写信号量mmlist
:所有的 mm_struct 数据结构都连接到一个双向链表中,该链表的头是 init_mm 内存描述符,它是 init 进程的地址空间start_code
,end_code:代码段的起始地址和结束地址start_data
,end_data:数据段的起始地址和结束地址start_brk
:堆空间的起始地址brk
:表示当前堆中的 VMA 的结束地址total_vm
:已经使用的进程地址空间总和
从进程的角度来观察内存管理,可以沿着 mm_struct 数据结构进行延伸和思考,如下图所示。
VMA 数据结构
VMA(vm_area_struct)数据结构定义在 mm_types.h 文件中,其主要成员如下。
VMA 数据结构中各个成员的含义如下:
vm_start
和vm_end
:指定 VMA 在进程地址空间的起始地址和结束地址vm_next
和vm_prev
:进程的 VMA 都连接成一个链表vmrb
:VMA 作为一个节点加入红黑树,每个进程的 mm_struct 数据结构中都有一棵红黑树 mm->mm_rbvm_mm
:指向该 VMA 所属进程的 mm_struct 数据结构vm_page_prot
:VMA 的访问权限vm_flags
:描述该 VMA 的一组标志位anon_vma_chain
和anon_vma
:用于管理反向映射(Reverse Mapping,RMAP)vm_ops
:指向许多方法的集合,这些方法用于在 VMA 中执行各种操作,通常用于文件映射vm_pgoff
:指定文件映射的偏移量,这个变量的单位不是字节,而是页面的大小件映射。(PAGE SIZE)。对于匿名页面来说,它的值可以是 0 或者 vm_addr/PAGE_SIZEvm_file
:指向 file 的实例,描述一个被映射的文件
mm_struct 数据结构是描述进程内存管理的核心数据结构,该数据结构提供了管理 VMA 所需要的信息,每个 VMA 都要连接到 mm_struct 中的链表和红黑树,以方便查找。
VMA 按照起始地址以递增的方式插入 mm_struct->mmp 链表中。
当进程拥有大量的 VMA 时,扫描链表和查找特定的 VMA 是非常低效的操作,如在云计算的机器中,所以内核中通常需要红黑树来协助,以便提高查找速度。
站在进程的角度来看,我们可以从进程控制块。task_struct 数据结构里顺藤摸瓜找到该进程所有的 VMA,如图所示。
- task_struct 结结构中有一个 mm 成员指向进程的内存管理描述符 mm_struct 数据结构
- 可以通过 mm_struct 数据结构中的 mmap 成员来遍历所有的 VMA
- 也可以通过 mm_struct 数据结构中的 mm_rb 成员来遍历和查找 VMA
- mm_struct 数据结构的 pgd 成员指向进程的页表,每个进程都有一份独立的页表
- 当 CPU 第一次访问虚拟地址空间时会触发缺页异常。在缺页异常处理中,分配物理页面,利用分配的物理页面来创建页表项并且填充页表,完成虚拟地址到物理地址的映射关系的建立
VMA 的属性
作为一个进程地址空间的javascript区间,VMA 是有属性的,如可读/可写、共享等属性。
vm_flags 成员描述这些属性,描述了该 VMA 的全部页面信息,包括如何映射页面、访问每个页面的权限等信息,VMA 属性的标志位如下所示。
VM_READ
: 可读属性VM_WRITE
: 可写属性VM_EXEC
: 可执行VM_SHARED
: 允许被多个进程共享VM_MAYREAD
: 允许设置 VM_READ 属性VM_MAYWRITE
: 允许设置 VM WRITE 属性VM_MAYEXEC
: 允许设置 VM EXEC 属性VM_MAYSHARE
: 允许设置 VM SHARED 属性VM_GROWSDOWN
: 该 VMA 允许向低地址增长VM_UFFD_MISSING
: 表示该 VMA 适用于用户态的缺页异常处理VM_PFNMAP
: 表示使用纯正的 PFN,不需要使用内核的 page 数据结构来管理物理页面VM_DENYWRITE
: 表示不允许写入VM_UFFD_wp
: 用于页面的写保护跟踪VM_LOCKED
: 表示该 VMA 的内存会立刻分配物理内存,并且页面被锁定,不会被交换到交换分区VM_IO
: 表示 I/0 内存映射VM_SEQ_READ
: 表示应用程序会顺序读该 VMA 的内容VM_RAND_READ
: 表示应用程序会随机读该 VMA 的内容VM_DONTCOPY
: 表示在创建分支时不要复制该 VMAVM_DONTEXPAND
: 通过 mremapo 系统调用禁止 VMA 扩展VM_ACCOUNT
: 在创建 IPC 以共享 VMA 时,检测是否有足够的空闲内存用于映射VM_HUGETLB
: 用于巨页的映射VM_SYNC
: 表示同步的缺页异常VM_ARCH_1
: 与架构相关的标志位VM_WIPEONFORK
: 表示不会从父进程相应的 VMA 中复制页表到子进程的 VMA 中VM_DONTDUMP
: 表示该 VMA 不包含到核心转储文件中VM_SOFTDIRTY
: 软件模拟实现的脏位。用于一些特殊的架构,需要打开 CONFIG_MEM_SOFT_DIRTYVM_MIXEDMAP
: 表示混合使用了纯 PFN 以及 page 数据结构的页面,如使用 vm_insert_page() 函数插入 VMAVM_HUGEPAGE
: 表示在 madvise 系统调用中使用 MADV_HUGEPAGE 标志位来标记该 VMAVM_NOHUGEPAGE
: 表示在 madvise 系统调用中使用 MADV_NOHUGEPAGE 标志位来标记该 VMAVM_MERGEABLE
: 表示该 VMA 是可以合并的,用于 KSM 机制VM_SPECIAL
: 表示该 VMA 是不可以合并的
VMA 属性的标志位可以任意组合,但是最终要落实到硬件机制上,即页表项的属性中。VMA 属性到页表属性的转换如下图所示。vm_area_struct 数据结构中有两个成员和属性相关:一个是 vm_flags 成员,用于描述 VMA 的属性;另外一个是 vm_page_prot 成员,用于将 VMA 属性标志位转换成与处理器相关的页表项的属性,它和具体架构相关。
在创建一个新的 VMA 时使用 vm_get_page_prot() 函数可以把 vm_flags 标志位转化成具页表项的硬件标志位。
这个转化过程得益于内核预先定义了一个内存属性数组 protection_map[], 我们只需要根据 vm_flag 标志位来查询这个数http://www.devze.com组即可,在这个场景下,通过查询 protection_map[] 数组可以获得页表属性。
protection_map[] 数组的每个成员代表一个属性的组合,如__P000 表示无效的 PTE 属性,__P001 表示只读属性,__P1TBWSmYKuCO0 表示可执行属性(PAGE_EXECONLY)等。
下面以只读属性(PAGE_READONLY)来看,它究竟包含哪些页表项的标志位。
把上述的宏全部展开,我们可以得到如下页表项的标志位。
PTE_TYPE_PAGE
:表示这是一个基于页面的页表项,即设置页表项的 Bit[1:O]PTE_AF
:设置访问位PTE_SHARED
:设置内存共享属性MT_NORMAL
:设置内存属性为 normalPTE_USER
:设置 AP 访问位,允许通过用户权限访问该内存PTE_NG
:设置该内存对应的 TLB 只属于该进程PTE_PXN
:表示该内存不能在特权模式下执行PTE_UXN
:表示该内存不能在用户模式下执行PTE_RDONLY
:表示只读属性
内核如何管理内存
Linux 进程在内核中作为进程描述符 task_struct 的实例实现。task_struct 中的 mm 字段指向内存描述符 mm_struct ,它是程javascript序内存的执行内容。它存储了如上所示的内存段的开始和结束、进程使用的物理内存页数(RSS 代表 Resident Set Size)、使用的虚拟地址空间 以及其他信息。
每个虚拟内存区域(VMA)是一个连续的虚拟地址范围;这些区域永远不会重叠。vm_area_struct 的实例完整地描述了一个内存区域,包括其起始和结束地址、用于确定访问权限和行为的标志,以及用于指定该区域映射的文件(如果有)的 vm_file 字段。不映射文件的 VMA 是匿名的。除了内存映射段之外,上面的每个内存段(例如,堆、堆栈)对应于单个 VMA。这不是必需的,尽管这在 x86 机器中很常见。VMA 不关心它们位于哪个段。 程序的 VMA 都以链表形式存储在其内存描述符中 mmap 字段,按起始虚拟地址排序,并作为以 mm_rb 字段为根的红黑树 。红黑树允许内核快速搜索覆盖给定虚拟地址的内存区域。当您读取文件/proc/pid_of_process/maps 时,内核只是遍历进程的 VMA 链接列表并打印每一个 VMA。
VMA 的大小必须是页面大小的倍数。处理器查阅页表以将虚拟地址转换为物理内存地址。每个进程都有自己的一组页表;每当发生进程切换时,用户空间的页表也会切换。Linux 在内存描述符的 pgd 字段中存储指向进程页表的指针。页表中的每个虚拟页都对应一个页表项 (PTE),在常规 x86 分页中,它是一个简单的 4 字节记录,如下所示:
malloc函数
malloc() 函数是 C 标准库封装的一个核心函数,C 标准库做一些处理后会调用 Linux 的系线调用接口 brk 向系统申请内存。
brk 系统调用
brk 系统调用主要实现在 mm/mmap.c 文件中。
详细流程这里不一一列出来了,下面用一张图概括 brk 的流程,如下:
malloc流程
假设不考虑 libc 的因素,malloc() 分配 100 字节,那么内核会分配多少字节呢?处理器的 MMU 的最小处理单元是页面,所以内核分配内存、建立虚拟地址和物理地址映射关系都以页面为单位,PAGE_ALIGN(addr)宏让地址按页面大小对齐。
下图所示为 malloc() 函数的实现流程。
mmap函数
mmap/munmap 函数是用户空间中常用的系统调用函数,无论是在用户程序中分配内存、读写大文件、链接动态库文件,还是多进程间共享内存,都可以看到 mmp/munmap() 函数的身影。mmp/munmap 函数的声明如下。
mmap/munmap 函数的参数如下。
addr
:用于指定映射到进程地址空间的起始地址,为了提高应用程序的可移植性,一般设置为 NULL,让内核来分配一个合适的地址length
:表示映射到进程地址空间的大小prot
:用于设置内存映射区域的读写属性等flags
:用于设置内存映射的属性,如共享映射、私有映射等fd
:表示这是一个文件映射,fd 是打开的文件的句柄offset
:在python文件映射时,表示文件的偏移量。prot 参数通常表示映射页面的读写权限,有如下参数组合PROT_EXEC
:表示映射的页面是可以执行的PROT_READ
:表示映射的页面是可以读取的PROT_WRITE
:表示映射的页面是可以写入的PROT_NONE
:表示映射的页面是不可访问的
flags 参数是一个很重要的参数,可以设置为以下值。
MAP_SHARED
:创建一个共享映射的区域。多个进程可以通过共享映射方式来映射一个文件,这样其他进程也可以看到映射内容的改变,修改后的内容会同步到磁盘文件中MAP_PRIVATE
:创建一个私有的写时复制的映射。多个进程可以通过私有映射的方式来映射一个文件,这样其他进程不会看到映射内容的改变,修改后的内容也不会同步到磁盘文件中MAP_ANONYMOUS
:创建一个匿名映射,即没有关联到文件的映射MAP_FIXED
:使用参数 addr 创建映射,如果在内核中无法映射指定的地址,那么 mmap 会返回失败,参数 addr 要求按页对齐。如果 addr 和 length 指定的进程地址空间和已有的 VMA 重叠,那么内核会调用 do_munmapO 函数把这段重叠区域销毁,然后重新映射新的内容MAP_POPULATE
:对于文件映射来说,会提前预读文件内容到映射区域,该特性只支持私用映射
通过参数 fd 可以看出 mmap 映射是否和文件相关联,因此在 Linux 内核中,映射可以分成匿名映射和文件映射。
- 匿名映射:没有映射对应的相关文件,匿名映射的内存区域的内容会初始化为 0
- 文件映射:映射和实际文件相关联,通常把文件内容映射到进程地址空间,这样应用程序就可以像操作进程地址空间一样读写文件
私有匿名映射
当使用参数 fd=-1 且 flags = MAP_ANONYMOUS|MAP_PRIVATE 时,创建的 mmap 映射是私有匿名映射。
私有匿名映射常见的用途是在 glbc 分配大内存块时,如果需要分配的内存大 MMAP_THREASHOLD(128KB),glibc 会默认使用 mmap 代替 brk 来分配内存。
共享匿名映射
当使用参数 fd=-1 且 flags = MAP_ANONYMOUS | MAP_SHARED 时,创建的 mmap 映射是共享匿名映射。
共享匿名映射让相关进程共享一块内存区域,通常用于父、子进程之间的通信创建共享匿名映射有如下两种方式。
- 使 fd=-1 且 flags = MAP_ANONYMOUS | MAP_SHARED。在这种情况下,do_mmap_pgoffO->mmap_region() 函数最终会调用 shmem_zero_setup():来打开一个特殊的“/dev/zero”设备文件
- 直接打开“/dev/zero”设备文件,然后使用这个文件句柄来创建 mmap
上述两种方式最终都调用 shmem 模块来创建共享匿名映射。
私有文件映射
创建文件射时如果 flags 设置为 MAPP_PRIVATE,就会创建私有文件映射。
私有文件映射常用的场景是加载动态共享库。
共享文件映射
创建文件映射时,如果 flags 设置为 MAP_SHARED,就会创建共享文件映射。
如果 prot 参数指定了 PROT_WRITE,那么打开文件时需要指定 O_RdwR 标志位。共享文件映射通常有 mmap 如下两个常用的场景。
- 读写文件。把文件内容映射到进程地址空间,同时对映射的内容做了修改,内核的回写(writeback)机制最终会把修改的内容同步到磁盘中
- 进程间通信。进程之间的进程地址空间相互隔离,一个进程不能访问另外一个进程的地址空间。如果多个进程同时映射到一个文件,就实现了多进程间的共享内存通信。如果一个进程对映射内容做了修改,那么另外的进程是可以看到的
总结
mmap 机制在 Linux 内核中实现的代码框架和 brk 机制非常类似,其中有很多关于 VMA 的操作。
mmap 机制和缺页中断机制结合在一起会变得复杂很多。
mmap 机制在 Linux 内核中的实现流程如图所示。
以上为个人经验,希望能给大家一个参考,也希望大家多多支持编程客栈(www.devze.com)。
精彩评论