Linux深入理解内存管理33(基于Linux6.6)---匿名映射介绍
一、概念
匿名映射 是指将虚拟内存区域映射到一个未命名的、无文件关联的内存区域。与文件映射不同,匿名映射不涉及任何文件,而是直接在进程的虚拟地址空间中分配内存。
在 Linux 和其他类 Unix 操作系统中,匿名映射通常通过 mmap()
系统调用来实现。当你使用 mmap()
创建匿名映射时,通常不会指定文件名或文件描述符,而是使用特殊的标志来表示这是一个匿名映射。
匿名映射的特点
-
没有文件关联:
- 匿名映射不与磁盘文件关联,表示该内存区域并不是从某个文件中加载的,而是由操作系统直接分配的。
-
虚拟内存分配:
- 内存区域是由操作系统通过虚拟内存管理机制分配的。这些内存区域通常会在进程退出时自动释放。
-
支持共享与私有映射:
- 匿名映射可以选择是 共享(
MAP_SHARED
)还是 私有(MAP_PRIVATE
)。共享映射的内存区域在多个进程间共享,而私有映射的内存区域在各个进程中是独立的。
- 匿名映射可以选择是 共享(
-
页面交换:
- 匿名映射的内存区域通常可以被交换到磁盘上(通过交换空间),以便腾出物理内存供其他进程使用。内存中的页面在不使用时会被换出(swap out),当再次需要时会被换入(swap in)。
-
可用于动态内存分配:
- 通过匿名映射,程序可以动态地分配内存区域,而无需依赖堆(heap)或栈(stack)。这对于需要大块内存的应用程序(如数据库、大型数据处理等)非常有用。
-
默认初始化为零:
- 通常,使用
mmap()
创建的匿名映射内存会被初始化为零。程序可以通过访问这些内存来读取和写入数据。
- 通常,使用
由于应用程序申请内存后,内核分配了虚拟内存,当访问所需要的的内存时候,才会分配实际的物理内存,才能节省实际内存的使用。来看看匿名映射和文件映射的区别
- 匿名页面,是指那些没有关联到文件页,如进程堆、栈、数据段和任务已修改的共享库等,不是以文件形式存在,因此无法和磁盘文件交换
- 文件页面,也就是映射文件的页,对于文件映射背景的页面,程序可以通过read/write/mmap去读,当通过任何一种方式从磁盘读取文件时,内存都会给你申请一个pace cache来缓存硬盘内容。例如通过mmap映射文件到虚拟内存然后读文件,进程的代码段等,这些页都是由缓存
1.1、匿名映射缺页异常的触发情况
知道了匿名页,那么在什么情况下会触发匿名映射的缺页异常呢?我们比较常见的有
- 当应用程序使用malloc来申请一块内存(堆分配),在没有使用这块内存之前,仅仅是分配了虚拟内存,并没有分配物理内存,当第一次去访问的时候,才会通过缺页异常来分配物理页面,建立和虚拟页面的映射关系。
- 当应用程序使用mmap来创建匿名的内存映射的时候,页同样只是分配了虚拟内存,并没有分配物理内存,第一次去访问的时候,才会通过触发缺页异常来分配物理页面,建立和虚拟页的映射关系。
- 当函数的局部变量比较大,或者函数调用的层次比较深,导致当前的栈不够用,这个时候需要扩大栈,这个时候也会触发缺页异常来分配物理页面。
1.2、零页面时什么?
在系统初始化过程中分配了一个页面的内存,这段内存在初始化的时候全部填充为0,内核是如何分配零页面的。
/*
* empty_zero_page is a special page that is used for
* zero-initialized data and COW.
*/
struct page *empty_zero_page;
EXPORT_SYMBOL(empty_zero_page);
并在paging_init中对这个零页进行了初始化,可以看到定义了一个全局变量empty_zero_page,在下面的代码中为这个分配了4K的内存空间,并完成了虚拟到物理的映射
zero_page = early_alloc(PAGE_SIZE);
bootmem_init();
empty_zero_page = virt_to_page(zero_page);
__flush_dcache_page(NULL, empty_zero_page);
那么为什么需要零页面映射呢?
-
它的数据都是0填充,读的数据都是0
-
其主要作用就是只要用户引用一个只读的匿名页面,并没有进行写操作,缺页中断处理中,内核就不会给用户进程分配新的页面,都会映射到这个页面中,从而节约内存空间
二、代码流程
在缺页中断处理中,匿名页面的触发条件为下面的两个条件,当满足这两个条件的时候就会调用do_anonymous_page函数来处理匿名映射缺页异常,代码实现在mm/memory.c文件中。
- 发生缺页的地址所在页表项不存在
- 是匿名页,即是vma->vm_ops为空,即vm_operations函数指针为空
知道在进程的task_struct结构中包含了一个mm_struct结构的指针,mm_struct用来描述一个进程的虚拟地址空间。进程的 mm_struct 则包含装入的可执行映像信息以及进程的页目录指针pgd。该结构还包含有指向 vm_area_struct 结构的几个指针,每个 vm_area_struct 代表进程的一个虚拟地址区间。vm_area_struct 结构含有指向vm_operations_struct 结构的一个指针,vm_operations_struct 描述了在这个区间的操作。vm_operations 结构中包含的是函数指针;其中,open、close 分别用于虚拟区间的打开、关闭,而nopage 用于当虚存页面不在物理内存而引起的“缺页异常”时所应该调用的函数。
2.1、do_anonymous_page流程
内核用vma_is_anonymous()判断vma是否为匿名映射,其实就是检查vma->vm_ops是否为空。
include/linux/mm.h
static inline bool vma_is_anonymous(struct vm_area_struct *vma)
{
return !vma->vm_ops;
}
mm/memory.c
/*
* We enter with non-exclusive mmap_lock (to exclude vma changes,
* but allow concurrent faults), and pte mapped but not yet locked.
* We return with mmap_lock still held, but pte unmapped and unlocked.
*/
static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
struct page *page;
vm_fault_t ret = 0;
pte_t entry;
/* File mapping without ->vm_ops ? */
if (vma->vm_flags & VM_SHARED)
return VM_FAULT_SIGBUS;
/*
* Use pte_alloc() instead of pte_alloc_map(). We can't run
* pte_offset_map() on pmds where a huge pmd might be created
* from a different thread.
*
* pte_alloc_map() is safe to use under mmap_write_lock(mm) or when
* parallel threads are excluded by other means.
*
* Here we only have mmap_read_lock(mm).
*/
if (pte_alloc(vma->vm_mm, vmf->pmd))
return VM_FAULT_OOM;
/* See comment in handle_pte_fault() */
if (unlikely(pmd_trans_unstable(vmf->pmd)))
return 0;
/* Use the zero-page for reads */
if (!(vmf->flags & FAULT_FLAG_WRITE) &&
!mm_forbids_zeropage(vma->vm_mm)) {
entry = pte_mkspecial(pfn_pte(my_zero_pfn(vmf->address),
vma->vm_page_prot));
vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd,
vmf->address, &vmf->ptl);
if (!pte_none(*vmf->pte)) {
update_mmu_tlb(vma, vmf->address, vmf->pte);
goto unlock;
}
ret = check_stable_address_space(vma->vm_mm);
if (ret)
goto unlock;
/* Deliver the page fault to userland, check inside PT lock */
if (userfaultfd_missing(vma)) {
pte_unmap_unlock(vmf->pte, vmf->ptl);
return handle_userfault(vmf, VM_UFFD_MISSING);
}
goto setpte;
}
/* Allocate our own private page. */
if (unlikely(anon_vma_prepare(vma)))
goto oom;
page = alloc_zeroed_user_highpage_movable(vma, vmf->address);
if (!page)
goto oom;
if (mem_cgroup_charge(page_folio(page), vma->vm_mm, GFP_KERNEL))
goto oom_free_page;
cgroup_throttle_swaprate(page, GFP_KERNEL);
/*
* The memory barrier inside __SetPageUptodate makes sure that
* preceding stores to the page contents become visible before
* the set_pte_at() write.
*/
__SetPageUptodate(page);
entry = mk_pte(page, vma->vm_page_prot);
entry = pte_sw_mkyoung(entry);
if (vma->vm_flags & VM_WRITE)
entry = pte_mkwrite(pte_mkdirty(entry));
vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd, vmf->address,
&vmf->ptl);
if (!pte_none(*vmf->pte)) {
update_mmu_tlb(vma, vmf->address, vmf->pte);
goto release;
}
ret = check_stable_address_space(vma->vm_mm);
if (ret)
goto release;
/* Deliver the page fault to userland, check inside PT lock */
if (userfaultfd_missing(vma)) {
pte_unmap_unlock(vmf->pte, vmf->ptl);
put_page(page);
return handle_userfault(vmf, VM_UFFD_MISSING);
}
inc_mm_counter_fast(vma->vm_mm, MM_ANONPAGES);
page_add_new_anon_rmap(page, vma, vmf->address);
lru_cache_add_inactive_or_unevictable(page, vma);
setpte:
set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
/* No need to invalidate - it was non-present before */
update_mmu_cache(vma, vmf->address, vmf->pte);
unlock:
pte_unmap_unlock(vmf->pte, vmf->ptl);
return ret;
release:
put_page(page);
goto unlock;
oom_free_page:
put_page(page);
oom:
return VM_FAULT_OOM;
}
对于do_anonymous_page其处理流程如下图所示:
- 检查是否是共享进程,主要是为了防止共享的VMA进入匿名页面的缺页中断。
- 使用pte_alloc来分配一个PTE,并且把PTE设置称为对应的PMD页表项中。
- 根据参数flags来判断是否需要可写权限,当需要分配的内存只有只读属性的时候,系统会使用一个内容填充为0的全局页面,也就是0页面empty_zero_page。
- my_zero_pfn获取系统零页的帧号,pte_mkspecial用来设置PTE中的PET_SPECIAL位,用来表示这个特殊映射页面。
- pte_offset_map_lock获取PTE,获取成功后,就跳转到setpte标签处。
- 处理VMA属性是可写的情况,首先,anon_vma_prepare为建立RMAP做准备,使用alloc_zeroed_user_highpage_movable函数来分配一个可移动的匿名页面,其分配页面的最终会调用伙伴系统的核心函数alloc_pages,这里的页面分配会优先使用高端内存,但是对于64位的系统已经没有高端内存的概念了。
- 通过mk_pte、pte_mkdirty、pte_mkwrite等生成一个新的PTE,这个新的PTE是基于刚分配的物理页面的帧号建立起来的。
- pte_offset_map_lock获取address对应的PTE,同时会申请一个自旋锁。
- inc_mm_counter_fast增加进程匿名页面的计数,匿名页面的计数类型是MM_ANONPAGES。
- page_add_new_anon_rmap把匿名页面添加到RMAP系统中。
- lru_cache_add_active_or_unevictable把匿名页面添加到LRU链表中,在以后的kswap内核模块中会用到LRU链表。
在setpte标签处,通过set_pte_at函数将设置到硬件的页表中,通过更新cache的配置:
在匿名页面的缺页异常中,我们使用了系统零页页面。因为对于malloc函数来说,分配的内存仅仅是进程地中空间中的虚拟内存。当用户程序需要读这个malloc分配的虚拟内存,那么系统就会返回全是0的数据,因此linux内核就不必为这种情况单独分配物理内存,而使用系统零页,即使系统零页来映射malloc分配的虚拟内存。当程序需要写这个页面的时候,就会触发一个缺页异常,于是缺页异常就变成了写时复制的缺页异常。总之,应用程序使用malloc来分配虚拟内存时,有以下几种情况
- malloc分配内存后,直接读:这种情况下,在linux内核中会进入匿名页面的缺页中断,使用系统零页进行映射,这时映射的PTE属性是只读
- malloc分配内存后,先读后写: 这种情况下,读的操作会让linux内核使用系统零页来建立页表的映射关系,这时PTE的属性是只读的。当应用程序需要往这个虚拟内存中写内容时,又触发了另外一个缺页异常,也就是后面的写时复制技术
- malloc分配内存后,直接写: 这种情况下,在Linux内核中会进入缺页匿名的缺页异常中,使用alloc_zeroed_user_highpage_movable函数分配一个新的页面,并且使用该页面来设置PTE,这时候这个PTE的属性是可写的。
2.2、第一次读匿名页
对于第一次读匿名映射,内核的处理代码如下所示:
mm/memory.c
static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
...
/* Use the zero-page for reads */
if (!(vmf->flags & FAULT_FLAG_WRITE) &&
!mm_forbids_zeropage(vma->vm_mm)) {
entry = pte_mkspecial(pfn_pte(my_zero_pfn(vmf->address),
vma->vm_page_prot));
vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd,
vmf->address, &vmf->ptl);
if (!pte_none(*vmf->pte)) {
update_mmu_tlb(vma, vmf->address, vmf->pte);
goto unlock;
}
ret = check_stable_address_space(vma->vm_mm);
if (ret)
goto unlock;
/* Deliver the page fault to userland, check inside PT lock */
if (userfaultfd_missing(vma)) {
pte_unmap_unlock(vmf->pte, vmf->ptl);
return handle_userfault(vmf, VM_UFFD_MISSING);
}
goto setpte;
}
...
}
pfn_pte用来将页帧号和页表属性拼接为页表项值,是将pfn左移PAGE_SHIFT位(一般为12bit),或上pgprot_val(prot)
#define pfn_pte(pfn,prot) (__pte(((phys_addr_t)(pfn) << PAGE_SHIFT) | pgprot_val(prot)))
而对于pfn,则使用内核初始化设置的empty_zero_page这个零页得到的页帧号,这个之前已经介绍过。
static inline unsigned long my_zero_pfn(unsigned long addr)
{
extern unsigned long zero_pfn;
return zero_pfn;
}
而第二个参数port是vma->vm_page_port,这个是vma的访问权限,在做内存映射的mmap的时候被设置,那么代码中如何设置的呢?mm/mmap.c中do_brk会通过以下配置这个参数:
vma->vm_page_prot = vm_get_page_prot(flags);
pgprot_t vm_get_page_prot(unsigned long vm_flags)
{
return __pgprot(pgprot_val(protection_map[vm_flags &
(VM_READ|VM_WRITE|VM_EXEC|VM_SHARED)]) |
pgprot_val(arch_vm_get_page_prot(vm_flags)));
}
vm_get_page_prot函数会根据传递来的vmflags是否为VMREAD|VMWRITE|VMEXEC|VMSHARED来转换为保护位组合,继续往下看:
static pgprot_t protection_map[16] __ro_after_init = {
[VM_NONE] = __PAGE_NONE,
[VM_READ] = __PAGE_READONLY,
[VM_WRITE] = __PAGE_COPY,
[VM_WRITE | VM_READ] = __PAGE_COPY,
[VM_EXEC] = __PAGE_READONLY_EXEC,
[VM_EXEC | VM_READ] = __PAGE_READONLY_EXEC,
[VM_EXEC | VM_WRITE] = __PAGE_COPY_EXEC,
[VM_EXEC | VM_WRITE | VM_READ] = __PAGE_COPY_EXEC,
[VM_SHARED] = __PAGE_NONE,
[VM_SHARED | VM_READ] = __PAGE_READONLY,
[VM_SHARED | VM_WRITE] = __PAGE_SHARED,
[VM_SHARED | VM_WRITE | VM_READ] = __PAGE_SHARED,
[VM_SHARED | VM_EXEC] = __PAGE_READONLY_EXEC,
[VM_SHARED | VM_EXEC | VM_READ] = __PAGE_READONLY_EXEC,
[VM_SHARED | VM_EXEC | VM_WRITE] = __PAGE_SHARED_EXEC,
[VM_SHARED | VM_EXEC | VM_WRITE | VM_READ] = __PAGE_SHARED_EXEC
};
DECLARE_VM_GET_PAGE_PROT
对于私有匿名映射的页,假设设置的vmflags为VMREAD|VMWRITE则对应的保护位组合为:P110即为PAGE_READONLY_EXEC=pgprot(_PAGE_DEFAULT | PTE_USER | PTE_RDONLY | PT_ENG | PTE_PXN)不会设置为可写。对于这种匿名页读之后再去写的时候,其处理流程如下
2.3、第一次写匿名页
当判断不是读操作导致的缺页的时候,则是写操作造成,处理写私有的匿名页情况,请记住这依然是第一次访问这个匿名页只不过是写访问而已。首先使用anon_vma_prepare准备好逆向映射机制的数据结构,以接受一个新的匿名区域,这个后面反向映射的时候会单独学习。然后使用alloc_zeroed_user_highpage_movable分配对应物理页。其处理流程如下:
2.4、读之后写匿名页
读之后写匿名页,其实已经很简单了,那就是发生COW写时复制缺页,其处理流程如下:
三、总结
1. 匿名映射的缺页异常说明
匿名映射缺页异常是应用程序访问内存空间常见的一种异常,对于匿名映射是用户空间通过malloc/mmap接口函数来分配内存,映射完成后,只是获取了一块虚拟内存,并没有为其分配物理内存。
- 当第一次访问的时候,如果是读访问,将会映射到零页上去,以减小不必要的内存分配。
- 如果是写操作,则会分配的新的物理页,并用0来填充,然后映射到虚拟页。
- 如果是写读访问,然后是写访问,则会发生两次缺页异常,第一次采用匿名缺页异常的读操作处理,第二次则是按照写时复制技术处理。
2. 匿名映射的缺页异常流程
以下是匿名映射缺页异常的简要流程:
-
进程访问虚拟内存:进程尝试读取或写入匿名映射的虚拟内存区域。
-
缺页检测:由于该虚拟页面尚未被映射到物理内存,硬件会触发一个缺页异常(Page Fault)。此时,操作系统会通过页表判断该页面是否已被加载到内存中。
-
检查映射类型:操作系统会检查这个虚拟页面的映射类型:
- 如果是匿名映射(
MAP_ANONYMOUS
),并且没有内容预先填充,则该页面通常会被初始化为零,或者根据映射的权限,进行必要的操作(如私有映射的写入保护)。 - 如果是共享映射(
MAP_SHARED
),多个进程可能会共享这个页面,操作系统需要确保这些共享页面的一致性。
- 如果是匿名映射(
-
加载或分配页面:操作系统通过以下方式来处理缺页:
- 对于匿名映射,操作系统通常会为该页面分配物理内存。如果页面是私有的(如
MAP_PRIVATE
),操作系统会分配一个新的物理页面,并将其清零(初始化为零)。 - 对于共享映射,操作系统可能需要同步其他进程对该页面的访问。
- 对于匿名映射,操作系统通常会为该页面分配物理内存。如果页面是私有的(如
-
更新页表:操作系统会更新进程的页表,将新的物理页面映射到虚拟地址上。
-
返回到进程执行:一旦缺页异常处理完成,进程继续执行,访问的虚拟页面被成功加载到物理内存中。
3. 匿名映射的缺页异常处理
在匿名映射中,缺页异常的处理有一些特殊的注意事项,主要包括以下几点:
-
初始化为零:当进程首次访问匿名映射的页面时,操作系统会通常将该页面初始化为零(除非设置了其他初始化选项)。这意味着进程访问的匿名映射页面可能包含空白内容,直到进程实际写入数据。
-
按需加载(Demand Paging):在大多数现代操作系统中,匿名映射会使用按需分页的方式。当进程访问一个未加载的页面时,操作系统仅在此时才将页面加载到内存中,而不是提前加载整个映射区域。这可以减少内存的浪费,尤其是在内存较大的情况下。
-
私有映射与共享映射的区别:对于
MAP_PRIVATE
类型的匿名映射,缺页异常通常会导致操作系统为该进程分配一页新的物理内存,并将其内容清零。而对于MAP_SHARED
类型的匿名映射,缺页异常可能会影响到其他共享该页面的进程,因此操作系统需要确保所有进程能够看到相同的内存内容。
4. 缺页异常的性能考虑
缺页异常的处理通常涉及以下几个步骤,这会引入一定的性能开销:
-
磁盘I/O:如果缺页的页面不在物理内存中,操作系统可能需要从磁盘或交换空间中加载页面。这一过程可能会显著影响性能,特别是在内存不足或页面很大时。
-
内存分配和映射:为处理缺页异常,操作系统需要进行内存分配和页表更新,这会占用 CPU 时间,并且可能引起缓存失效。
-
页面交换(Swapping):如果物理内存资源紧张,操作系统可能会将其他不常用的页面交换到磁盘(swap),然后再加载所需的页面,这可能导致更多的延迟。