大蠢驴博客

好好学习,天天向上。

0%

内核内存管理专题

背景


周五导师交给我一个任务,让我有空总结下内核的内存管理给组内大家分享,那么今天我就来大致总结下吧。之前零零散散看过一些书,但是成点不成面,刚好借这个机会系统的梳理下。因为看的资料x86架构居多,所以本文主要描述x86的情况,我想其它架构也是类似的。另外,本文只是一个大致的概念梳理,要了解具体细节还是要阅读对应模块的源码和相关书籍。这里推荐下《深入Linux内核架构》和《深入理解linux内核》这两本书。

地址类型澄清


  • 用户虚拟地址

    指进程处于用户态时访问内存所使用的地址。

  • 物理地址

    可以理解为MMU翻译后放到CPU地址总线上的地址。CPU有一个固定位宽的地址总线,可以32位宽或者64位宽。地址总线的特定组合就是一个特定的物理地址,总线的所有组合构成物理地址空间。这个空间可以寻址RAM,也可能寻址PCI设备,cat /proc/iomem可以看到物理地址映射的具体内容。

  • 外设总线地址

    指外设访问内存时所使用的地址,通常而言和物理地址一样。但是有的架构会提供IOMMU,其可以在外设总线地址和物理地址之间做映射工作,这种情况下外设总线地址和物理地址可以不一样。

  • 内核逻辑地址

    特指内核直接映射区(线性映射区)的内存地址,其和物理地址之间通常只有一个常量的偏移关系。在32位系统上,内核逻辑地址可能无法覆盖所有的物理内存,但在64位系统上可以。kmalloc以及其派生函数返回的地址就是逻辑地址。

  • 内核虚拟地址

    内核虚拟地址泛指内核所使用的各种访存地址,内核逻辑地址是内核虚拟地址,但是内核虚拟地址不一定是内核逻辑地址。内核虚拟地址并不要求地址和物理地址之间有一个连续且一一对应的映射关系,比如vmalloc返回的地址就是内核虚拟地址,但不是内核逻辑地址。

地址空间分布


32位系统地址空间

32位系统地址总线宽度只有32位,这意味不管是虚拟地址空间还是物理地址空间,都只有4GB的寻址范围。很明显,对于一些高配系统而言,4GB寻址范围是不够的。为此,软件厂商和硬件厂商都为拓宽CPU的寻址空间进行了努力。

硬件想解决的问题是怎么让CPU可访问的物理内存超过4GB。Intel提出的方案称为PAE,其原理是通过增加额外的寄存器来扩大寻址范围。额外的寄存器可以圈定当前CPU地址总线上的物理地址属于哪个,地址总线上的值为组内偏移。通过PAE技术,使得32位CPU的物理地址寻址空间扩大到了64GB。

软件面对的问题则是如何规划仅有的4GB虚拟地址空间,地址空间划分时必须保证在满足用户态使用的前提下,内核还可以访问所有4GB甚至超过4GB的物理内存。为此,Linux提出了高端内存方案。

对于虚拟地址空间,内核将其分成两个大区域。默认情况下,0~3G虚拟地址空间是每个进程独享的,给用户态指令用,称为用户态虚拟地址空间;3G~4G的虚拟地址空间是各个进程共享的,给内核态用,称为内核地址空间。当然,在编译内核时这个划分线是可以人为修改的。

对于物理地址,内核将其分成多个Zone。0~16M称为ZOME_DMA,16~896M称为ZONE_NORMAL,896M往上称为ZONE_HIGHMEM。物理页面到内核的虚拟地址空间的映射也分两部分。其中ZOME_DMA以及ZONE_NORMAL所占的896M内存,是内核启动的时候一一映射到内核的[3G, 4G-896M]范围的。而ZONE_HIGHMEM的物理内存,则是在内核需要的时候动态映射到[4G-896M, 4G]范围的虚拟地址空间中。

image-20220108192925836

内核直接映射的内存为什么要区分ZOME_DMA和ZONE_NORMAL呢?从名字中可以知道,相对于ZONE_NORMAL,ZONE_DMA的内存是专门为设备DMA预留的。为什么是16M呢,因为32位系统时代,大多数外设只能寻址16M的地址空间。在内核态申请内存时,可以给负责分配内存的辅助函数添加GFP_DMA参数,用于指定从ZONE_DMA中分配内存页面。

64位系统地址空间

相对于32位系统,64位系统的寻址范围就要宽得多。对于虚拟地址的划分,在内核文档中有详细的描述,这里不再重复介绍。下文主要看看内核是怎么给物理内存划分ZONE的。

对于64位系统,很多CPU都采用NUMA架构,与NUMA对应的架构是UMA架构。简单理解,在UMA系统的,内存是平坦化的。平坦化的意思是所有物理页面连续无空洞,所有的CPU核心访问内存的时延是相近的。而在NUMA系统中,物理页面之间可能存在空洞内存,CPU核心和有物理距离上的耦合关系。{PU, 内存}的耦合对在Linux中也称为Numa Node。CPU访问自身所处Node的内存时,时延最短,而跨Numa Node内存访问时延就会相对较长。

image-20220108190937663

通过/proc/buddyinfo可以查看当前系统关于物理内存的一些信息。下图是我在一个两Numa Node系统上的打印结果

可以看到,在Node0上划分出了ZONE_DMA、ZONE_DMA32以及ZONE_NORMAL,而在Node1上则只有ZONE_NOAMAL。两个Node上都没有ZONE_HIGHMEM。实际上在64位系统中,由于虚拟地址空间的极其巨大的原因,内核已经不需要复用一片虚拟地址空间来映射所谓的高端物理内存了。所有的内存页面,都会在内核初始化的时候一一映射到内核虚拟地址空间的线性映射区域。ZONE_DMA在64位系统中保持和32位系统兼容,仍然为16M。理想情况下,这个ZONE应当从代码中删掉,因为如今很少有人在用只能寻址16M的外设。但考虑到可以将这片内存用于紧急分配,所以仍然继续保留。ZONE_DMA32在32位系统中,这片内存的长度将会被设置为0,而在64位系统中,则为4GB。4GB往后的所有物理内存,都归属于ZONE_NORMAL。

有没有可能没有ZONE_NORMAL呢?答案也是可以的。当整个系统的内存少于4G的时候,系统就只会创建ZONE_DMA和ZONE_DMA32。下图就是当系统只有2G内存的时候,从/proc/buddyinfo中读取到的信息

页面组织与分配


页面组织

这一小节我们一起看看内核是如何组织内存页面的。首先需要说明的是,NUMA系统可以看做是UMA系统的超集。事实上内核代码也确实是这么做的,内核将UMA系统看作只有一个Numa Node的NUMA系统。内核会为每个Numa Node创建一个pg_data_t数据结构,用于组织专属于该Numa Node的下列信息:1、负责该Numa Node的swap内核线程task指针;2、所属该Numa Node的页面范围;3、该Numa Node拥有的Zone的数量以及存放所有Zone管理数据结构的数组;4、pg_data_t自身的ID等信息;5、备选ZONE列表,作为内存分配的候补。 所有的pg_data_t数据结构都可以在全局空间通过ID值快速索引到。

image-20220108233926280

struct zone用于管理专属于ZONE_*的内存,其包含下列信息:1、热页面集合,释放后的内存会首先放在热页面集合中,热页面可以降低cache miss率;2、页面回收水线,供swap线程使用;3、供swap使用的lru扫描算法使用的链表;4、下辖内存范围记录;5、buddy系统大数组。

页面分配

前面讲述的内存页面组织结构已经完成了对底层页面的抽象。页面回收内核线程、水线设置、buddy系统、Node区域管理等等所有的代码,都是为了完善这个抽象。这层抽象的意义就在于尽可能的服务好其它需要内存页面的代码。那么这层抽象如何服务其它的代码呢?答案是通过__alloc_pages_nodemask函数,所有的页面分配过程在底层都需要调用这个函数,你尽可以将这个函数看作是页面分配的核心入口。

下面是__alloc_pages_nodemask函数的声明,其包含三个参数

  • gfp_mask是一个flag,用于指定函数在分配页面时的一些行为。flag具体可取的值可以查阅下方表格;

  • order是以2为底数表征所需页面大小的指数,order形式可以快速索引buddy系统大数组;

  • preferred_nid只是期望从哪个Numa Node中分配内存。当期望的某个ZONE内存不足时,__alloc_pages_nodemask会尝试从pg_data_t的备选ZONE列表中按照先后顺序尝试分配内存。nodemask是分配内存时可供候选的Node的掩码,因为直接使用备选ZONE粒度还是太粗,该变量使得内存Numa绑定等动态功能得以实现。

1
2
3
4
/*
* This is the 'heart' of the zoned buddy allocator.
*/
struct page * __alloc_pages_nodemask(gfp_t gfp_mask, unsigned int order, int preferred_nid, nodemask_t *nodemask)

下面是gfp_mask可以支持的所有flag值汇总,摘录自内核代码4.18。可以对这些值做个大致分类:1、指示从Node的那个Zone分配内存;2、指示分配失败时的处理方案情况;2、指示内核页面的最终用途。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/*
* Useful GFP flag combinations that are commonly used. It is recommended
* that subsystems start with one of these combinations and then set/clear
* __GFP_FOO flags as necessary.
*
* GFP_ATOMIC users can not sleep and need the allocation to succeed. A lower
* watermark is applied to allow access to "atomic reserves"
*
* GFP_KERNEL is typical for kernel-internal allocations. The caller requires
* ZONE_NORMAL or a lower zone for direct access but can direct reclaim.
*
* GFP_KERNEL_ACCOUNT is the same as GFP_KERNEL, except the allocation is
* accounted to kmemcg.
*
* GFP_NOWAIT is for kernel allocations that should not stall for direct
* reclaim, start physical IO or use any filesystem callback.
*
* GFP_NOIO will use direct reclaim to discard clean pages or slab pages
* that do not require the starting of any physical IO.
* Please try to avoid using this flag directly and instead use
* memalloc_noio_{save,restore} to mark the whole scope which cannot
* perform any IO with a short explanation why. All allocation requests
* will inherit GFP_NOIO implicitly.
*
* GFP_NOFS will use direct reclaim but will not use any filesystem interfaces.
* Please try to avoid using this flag directly and instead use
* memalloc_nofs_{save,restore} to mark the whole scope which cannot/shouldn't
* recurse into the FS layer with a short explanation why. All allocation
* requests will inherit GFP_NOFS implicitly.
*
* GFP_USER is for userspace allocations that also need to be directly
* accessibly by the kernel or hardware. It is typically used by hardware
* for buffers that are mapped to userspace (e.g. graphics) that hardware
* still must DMA to. cpuset limits are enforced for these allocations.
*
* GFP_DMA exists for historical reasons and should be avoided where possible.
* The flags indicates that the caller requires that the lowest zone be
* used (ZONE_DMA or 16M on x86-64). Ideally, this would be removed but
* it would require careful auditing as some users really require it and
* others use the flag to avoid lowmem reserves in ZONE_DMA and treat the
* lowest zone as a type of emergency reserve.
*
* GFP_DMA32 is similar to GFP_DMA except that the caller requires a 32-bit
* address.
*
* GFP_HIGHUSER is for userspace allocations that may be mapped to userspace,
* do not need to be directly accessible by the kernel but that cannot
* move once in use. An example may be a hardware allocation that maps
* data directly into userspace but has no addressing limitations.
*
* GFP_HIGHUSER_MOVABLE is for userspace allocations that the kernel does not
* need direct access to but can use kmap() when access is required. They
* are expected to be movable via page reclaim or page migration. Typically,
* pages on the LRU would also be allocated with GFP_HIGHUSER_MOVABLE.
*
* GFP_TRANSHUGE and GFP_TRANSHUGE_LIGHT are used for THP allocations. They are
* compound allocations that will generally fail quickly if memory is not
* available and will not wake kswapd/kcompactd on failure. The _LIGHT
* version does not attempt reclaim/compaction at all and is by default used
* in page fault path, while the non-light is used by khugepaged.
*/

__alloc_pages_nodemask的服务对象


这一小节讲Linux如何分配页面,看那些需要使用内存页面的代码具体是如何调用到__alloc_page_nodemask来的。下面列举的内容几乎涵盖所有内存页面分配场景。

用户态page fault

从前面的Flag列表中可以看到,当用户态代码需要分配页面时,内核倾向于使用GFP_HIGHUSER_MOVABLE来分配内存。通常情况下,当指定了这个flag值,__alloc_pages_nodemask会从ZONE_NORMAL中分配一个页面,但这不是绝对的。如前面提到的,当系统的总内存不足4G的时候,就不会有ZONE_NORMAL区域。尽管上层代码指定了GFP_HIGHUSER_MOVABLE,最终页面还是得从ZONE_DMA32中获取。__alloc_pages_nodemask会处理好这个问题,因为有备选ZONE列表。下面是用户态代码发生page_fault后,从page_fault到__alloc_pages_nodemask的栈打印

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#0  __alloc_pages_nodemask (gfp_mask=gfp_mask@entry=6455498, order=order@entry=0, preferred_nid=0, 
nodemask=nodemask@entry=0x0 <fixed_percpu_data>) at mm/page_alloc.c:4900
#1 0xffffffff812efae4 in alloc_pages_vma (gfp=gfp@entry=6455498, order=order@entry=0,
vma=vma@entry=0xffff8881038b95c0, addr=<optimized out>, node=0, hugepage=hugepage@entry=false)
at mm/mempolicy.c:2191
#2 0xffffffff812b18c7 in alloc_zeroed_user_highpage_movable (vaddr=<optimized out>, vma=<optimized out>)
at ./include/linux/topology.h:87
#3 do_anonymous_page (vmf=vmf@entry=0xffffc90001e63df8) at mm/memory.c:3314
#4 0xffffffff812b7aa5 in handle_pte_fault (vmf=0xffffc90001e63df8) at mm/memory.c:4141
#5 __handle_mm_fault (vma=vma@entry=0xffff8881038b95c0, address=address@entry=140266363670528, flags=flags@entry=597)
at mm/memory.c:4276
#6 0xffffffff812b7b9e in handle_mm_fault (vma=0xffff8881038b95c0, address=address@entry=140266363670528,
flags=flags@entry=597) at mm/memory.c:4313
#7 0xffffffff81075f5d in __do_page_fault (regs=0xffffc90001e63f58, hw_error_code=<optimized out>,
address=140266363670528) at arch/x86/mm/fault.c:1450
#8 0xffffffff81076267 in do_page_fault (regs=0xffffc90001e63f58, error_code=6) at arch/x86/mm/fault.c:1540
#9 0xffffffff81a0111e in page_fault () at arch/x86/entry/entry_64.S:1224
#10 0x0000000000000000 in ?? ()

默认情况下,内核从那个Node为用户虚拟地址分配页面呢?这和内存的分配策略有关,建议阅读set_mempolicy的帮助文档。

hugetlbfs分配内存

hugetlbfs就是通常讲的“大页内存”的核心,大页内存可以给以更大的粒度向进程提供内存分配。大页内存将多个连续的页面看作单个页面进行管理,默认情况下内核需要为进程使用的每个页面都创建一个独立的页表项,但使用大页的时候只需要为每个大页的首个4K页创建页表项就可以了。带来的好处是压缩了页表项,提升了TLB的命中率。

大页内存在可以被进程使用前,需要进行初始化。具体而言就是指定大页规格以及大页数量。因为最终地址翻译还是要依靠MMU通过页表和页表项完成,而页表项有和大页的规格有关,所以特定的系统所能支持的大页规格通常是几个特定值,通常包含2M或者1G这两种规格。大页内存的核心代码,也就是hugetlbfs是通过文件系统为用户提供支持的。当前面的大页规格和大页数量指定好了以后,用户需要将hugetlbfs挂载到某个目录。之后用户进程需要使用内存大页的时候只需要在挂载目录中创建文件然后执行mmap,就完成了进程虚拟地址和背后物理页面之间的映射。

组成大页的连续4K小页也是从buddy系统中申请的,分配内存的入口函数是alloc_buddy_huge_page。该函数本质上是对__alloc_pages_nodemask的封装

1
2
3
4
5
6
7
8
9
10
11
12
13
static struct page *alloc_buddy_huge_page(struct hstate *h,
gfp_t gfp_mask, int nid, nodemask_t *nmask,
nodemask_t *node_alloc_noretry)
{
int order = huge_page_order(h);
struct page *page;
```
if (nid == NUMA_NO_NODE)
nid = numa_mem_id();
page = __alloc_pages_nodemask(gfp_mask, order, nid, nmask);
```
return page;
}

alloc_buddy_huge_page函数什么时候会调用的呢?答案是初始化的时候,下面是大页初始化时分配内存的调用栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#0  alloc_buddy_huge_page (node_alloc_noretry=0xffff888103ffe500, nmask=0xffff888103ffe600, nid=0, gfp_mask=6684874, h=0xffffffff830c21e0 <hstates>) at mm/hugetlb.c:1437
#1 alloc_fresh_huge_page (h=h@entry=0xffffffff830c21e0 <hstates>, gfp_mask=gfp_mask@entry=6684874, nid=0, nmask=nmask@entry=0xffff888103ffe600, node_alloc_noretry=node_alloc_noretry@entry=0xffff888103ffe500)
at mm/hugetlb.c:1482
#2 0xffffffff812e6021 in alloc_pool_huge_page (h=h@entry=0xffffffff830c21e0 <hstates>, nodes_allowed=nodes_allowed@entry=0xffff888103ffe600, node_alloc_noretry=node_alloc_noretry@entry=0xffff888103ffe500)
at mm/hugetlb.c:1506
#3 0xffffffff812e621c in set_max_huge_pages (nodes_allowed=0xffff888103ffe600, count=8192, h=0xffffffff830c21e0 <hstates>) at mm/hugetlb.c:2446
#4 __nr_hugepages_store_common (obey_mempolicy=obey_mempolicy@entry=false, h=0xffffffff830c21e0 <hstates>, nid=<optimized out>, count=8192, len=len@entry=5) at mm/hugetlb.c:2564
#5 0xffffffff812e646a in nr_hugepages_store_common (obey_mempolicy=<optimized out>, kobj=0xffff88885fd63f60, buf=<optimized out>, len=5) at mm/hugetlb.c:2589
⬆⬆⬆⬆⬆ 上方是attribute修改操作

⬇⬇⬇⬇⬇ 下方vfs操作
#6 0xffffffff813c5246 in kernfs_fop_write (file=<optimized out>, user_buf=<optimized out>, count=<optimized out>, ppos=0xffffc90004903f08) at fs/kernfs/file.c:316
#7 0xffffffff8132f1e5 in vfs_write (pos=0xffffc90004903f08, count=5, buf=0x557c18838c60 "8192\noot@RockyLinux8:~\an-argcomplete\nU", file=0xffff8884f599c000) at fs/read_write.c:549
#8 vfs_write (file=0xffff8884f599c000, buf=0x557c18838c60 "8192\noot@RockyLinux8:~\an-argcomplete\nU", count=<optimized out>, pos=0xffffc90004903f08) at fs/read_write.c:533
#9 0xffffffff8132f45f in ksys_write (fd=<optimized out>, buf=0x557c18838c60 "8192\noot@RockyLinux8:~\an-argcomplete\nU", count=5) at fs/read_write.c:598
#10 0xffffffff810042bb in do_syscall_64 () at arch/x86/kvm/../../../virt/kvm/kvm_main.c:4570

虚拟机发生page fault

虚拟机使用物理内存会经历以下步骤:

1、Qemu本身是个进程,其分配给虚拟机的内存是其进程自身内存的一部分。当使用大页时,Qemu会在hugtlb_fs的挂载点创建文件,然后将backend文件mmap到自身的虚拟地址空间。如果不使用大业,则直接malloc内存;

2、如果Qemu的参数中包含-overcommit mem-lock=on,则Qemu调用mlock/mlockall API将自己的页面锁定,这将为Qemu进程分配页面并初始化Qemu的页表。mlock的工作原理是在指定VMA的flag中添加VM_LOCKED值,该VMA中的页面将不会被swap线程换出;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#0 __alloc_pages_nodemask (gfp_mask=gfp_mask@entry=2310858, order=order@entry=9, preferred_nid=0, nodemask=nodemask@entry=0x0 <fixed_percpu_data>) at mm/page_alloc.c:4835
#1 0xffffffff812d866d in __alloc_pages (preferred_nid=<optimized out>, order=9, gfp_mask=2310858) at ./include/linux/gfp.h:475
#2 __alloc_pages_node (order=9, gfp_mask=2310858, nid=<optimized out>) at ./include/linux/gfp.h:475
#3 alloc_pages_vma (gfp=gfp@entry=2310858, order=order@entry=9, vma=vma@entry=0xffff8883d3483138, addr=<optimized out>, node=0, hugepage=hugepage@entry=true) at mm/mempolicy.c:2204
#4 0xffffffff812f141e in do_huge_pmd_anonymous_page (vmf=vmf@entry=0xffffc90008163d60) at ./include/linux/topology.h:87
#5 0xffffffff812a0c4a in create_huge_pmd (vmf=0xffffc90008163d60) at mm/memory.c:3763
#6 __handle_mm_fault (vma=vma@entry=0xffff8883d3483138, address=address@entry=140096655327232, flags=flags@entry=21) at mm/memory.c:3984
#7 0xffffffff812a1002 in handle_mm_fault (vma=vma@entry=0xffff8883d3483138, address=140096655327232, flags=flags@entry=21) at mm/memory.c:4050
#8 0xffffffff81297d74 in faultin_page (locked=0xffffc90008163edc, flags=<synthetic pointer>, address=<optimized out>, vma=0xffff8883d3483138, tsk=0xffff88970f46df00) at mm/gup.c:658
#9 __get_user_pages (tsk=0xffff88970f46df00, mm=0xffff88964f921680, start=<optimized out>, start@entry=140030091722752, nr_pages=<optimized out>, nr_pages@entry=20971520, gup_flags=4179,
pages=pages@entry=0x0 <fixed_percpu_data>, vmas=0x0 <fixed_percpu_data>, locked=0xffffc90008163edc) at mm/gup.c:877
#10 0xffffffff812990dd in populate_vma_page_range (vma=vma@entry=0xffff8883d3483138, start=start@entry=140030091722752, end=end@entry=140115991068672, locked=locked@entry=0xffffc90008163edc)
at ./arch/x86/include/asm/current.h:15
#11 0xffffffff8129917d in __mm_populate (start=start@entry=0, len=<optimized out>, ignore_errors=ignore_errors@entry=1) at mm/gup.c:1259
#12 0xffffffff812a3f36 in mm_populate (len=<optimized out>, addr=0) at ./include/linux/mm.h:2384
#13 __do_sys_mlockall (flags=<optimized out>) at mm/mlock.c:828
#14 __se_sys_mlockall (flags=<optimized out>) at mm/mlock.c:804
#15 __x64_sys_mlockall (regs=<optimized out>) at mm/mlock.c:804
#16 0xffffffff8100420b in do_syscall_64 (nr=151, regs=0xffffc90008163f58) at arch/x86/entry/common.c:302
#17 0xffffffff81a000ad in entry_SYSCALL_64 () at arch/x86/entry/entry_64.S:142
#18 0x000055ee6b64695b in ?? ()
#19 0x0000000000000000 in ?? ()

3、如果使用VFIO(设备直通),则VFIO会执行和mlock类似的工作。如果页面不曾分配,则VFIO框架会通过下面的栈为Qemu 进程分配页面并Pin住。VFIO Pin页面的原理是对目标页面执行get_page函数,这会让目标页面的引用计数加1;

1
2
3
4
5
6
7
8
9
10
11
12
13
#0 __alloc_pages_nodemask (gfp_mask=gfp_mask@entry=2310858, order=order@entry=9, preferred_nid=0, nodemask=nodemask@entry=0x0 <fixed_percpu_data>) at mm/page_alloc.c:4835
#1 0xffffffff812d866d in __alloc_pages (preferred_nid=<optimized out>, order=9, gfp_mask=2310858) at ./include/linux/gfp.h:475
#2 __alloc_pages_node (order=9, gfp_mask=2310858, nid=<optimized out>) at ./include/linux/gfp.h:475
#3 alloc_pages_vma (gfp=gfp@entry=2310858, order=order@entry=9, vma=vma@entry=0xffff88964e0d29f8, addr=<optimized out>, node=0, hugepage=hugepage@entry=true) at mm/mempolicy.c:2204
#4 0xffffffff812f141e in do_huge_pmd_anonymous_page (vmf=vmf@entry=0xffffc90007d07b08) at ./include/linux/topology.h:87
#5 0xffffffff812a0c4a in create_huge_pmd (vmf=0xffffc90007d07b08) at mm/memory.c:3763
#6 __handle_mm_fault (vma=vma@entry=0xffff88964e0d29f8, address=address@entry=140661156216832, flags=flags@entry=129) at mm/memory.c:3984
#7 0xffffffff812a1002 in handle_mm_fault (vma=vma@entry=0xffff88964e0d29f8, address=140661156216832, flags=flags@entry=129) at mm/memory.c:4050
#8 0xffffffff81297d74 in faultin_page (locked=0x0 <fixed_percpu_data>, flags=<synthetic pointer>, address=<optimized out>, vma=0xffff88964e0d29f8, tsk=0x0 <fixed_percpu_data>) at mm/gup.c:658
#9 __get_user_pages (tsk=tsk@entry=0x0 <fixed_percpu_data>, mm=mm@entry=0xffff88961c879f80, start=<optimized out>, nr_pages=<optimized out>, gup_flags=74247, pages=0xffffc90007d07cd8, vmas=0xffff88964f084320, locked=0x0 <fixed_percpu_data>) at mm/gup.c:877
#10 0xffffffff81298867 in __get_user_pages_locked (flags=<optimized out>, locked=0x0 <fixed_percpu_data>, vmas=0xffff88964f084320, pages=0xffffc90007d07cd8, nr_pages=<optimized out>, start=<optimized out>, mm=0xffff88961c879f80, tsk=0x0 <fixed_percpu_data>) at mm/gup.c:1065
#11 __gup_longterm_locked (tsk=0x0 <fixed_percpu_data>, mm=0xffff88961c879f80, start=<optimized out>, nr_pages=<optimized out>, pages=0xffffc90007d07cd8, vmas=0x0 <fixed_percpu_data>, gup_flags=73731) at mm/gup.c:1550
#12 0xffffffffc047e7ff in ?? ()

4、Qemu经过前面的步骤,会拿到一个虚拟地址。虚拟地址背后对应的物理页面,可能已经分配并且Pin住,也可能尚未分配。Qemu会将该虚拟地址以及期望映射的GPA,通过KVM提供的ioctl接口告知到KVM。KVM会按照入参说明初始化虚拟机的内存slot等,本质是保存了GPA到HVA的映射关系。

5、当虚拟机首次访问物理内存的时候,EPT会报告EPT页表项缺失,触发缺页异常。KVM会截获该异常,具体发生缺页的物理地址可以从vcpu的VMCS中查到。KVM会将GPA以及vcpu管理数据结构作为参数来调用kvm_tdp_page_fault函数,该函数的主要目的是完成ept页表项的填充。kvm_tdp_page_fault会利用KVM记录的GPA到HVA的映射找到发生EPT页表项缺失的HVA。然后利用该HVA扫描Qemu的页表,拿到真实的页面(如果Qemu自身页表项缺失,则分配新的页面并初始化Qemu的页表项)。最后,利用物理页面的HPA创建EPT页表项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#0 __alloc_pages_nodemask (gfp_mask=gfp_mask@entry=2310858, order=order@entry=9, preferred_nid=0, nodemask=nodemask@entry=0x0 <fixed_percpu_data>) at mm/page_alloc.c:4835
#1 0xffffffff812d866d in __alloc_pages (preferred_nid=<optimized out>, order=9, gfp_mask=2310858) at ./include/linux/gfp.h:475
#2 __alloc_pages_node (order=9, gfp_mask=2310858, nid=<optimized out>) at ./include/linux/gfp.h:475
#3 alloc_pages_vma (gfp=gfp@entry=2310858, order=order@entry=9, vma=vma@entry=0xffff88964fa22488, addr=<optimized out>, node=0, hugepage=hugepage@entry=true) at mm/mempolicy.c:2204
#4 0xffffffff812f141e in do_huge_pmd_anonymous_page (vmf=vmf@entry=0xffffc90007f1b8c0) at ./include/linux/topology.h:87
#5 0xffffffff812a0c4a in create_huge_pmd (vmf=0xffffc90007f1b8c0) at mm/memory.c:3763
#6 __handle_mm_fault (vma=vma@entry=0xffff88964fa22488, address=address@entry=140282567847936, flags=flags@entry=29) at mm/memory.c:3984
#7 0xffffffff812a1002 in handle_mm_fault (vma=vma@entry=0xffff88964fa22488, address=140282567847936, flags=flags@entry=29) at mm/memory.c:4050
#8 0xffffffff81297d74 in faultin_page (locked=0xffffc90007f1ba4c, flags=<synthetic pointer>, address=<optimized out>, vma=0xffff88964fa22488, tsk=0xffff88970eee5f00) at mm/gup.c:658
#9 __get_user_pages (tsk=tsk@entry=0xffff88970eee5f00, mm=mm@entry=0xffff88964f9c7500, start=<optimized out>, start@entry=140282567847936, nr_pages=<optimized out>, nr_pages@entry=1, gup_flags=807,
gup_flags@entry=295, pages=pages@entry=0xffffc90007f1baa0, vmas=0x0 <fixed_percpu_data>, locked=0xffffc90007f1ba4c) at mm/gup.c:877
#10 0xffffffff81298651 in __get_user_pages_locked (flags=295, locked=0xffffc90007f1ba4c, vmas=0x0 <fixed_percpu_data>, pages=0xffffc90007f1baa0, nr_pages=1, start=140282567847936, mm=0xffff88964f9c7500,
tsk=0xffff88970eee5f00) at mm/gup.c:1065
#11 get_user_pages_unlocked (start=start@entry=140282567847936, nr_pages=nr_pages@entry=1, pages=pages@entry=0xffffc90007f1baa0, gup_flags=<optimized out>) at mm/gup.c:1793
#12 0xffffffffc0d5c3a6 in hva_to_pfn_slow (pfn=<synthetic pointer>, writable=<optimized out>, write_fault=<optimized out>, async=0xffffc90007f1bb0f, addr=140282567847936)
at arch/x86/kvm/../../../virt/kvm/kvm_main.c:1868
#13 hva_to_pfn (writable=<optimized out>, write_fault=true, async=0xffffc90007f1bb0f, atomic=false, addr=140282567847936) at arch/x86/kvm/../../../virt/kvm/kvm_main.c:1976
#14 __gfn_to_pfn_memslot (slot=slot@entry=0xffff889708ca1410, gfn=gfn@entry=20106239, atomic=atomic@entry=false, async=async@entry=0xffffc90007f1bb0f, write_fault=write_fault@entry=true, writable=<optimized out>,
writable@entry=0xffffc90007f1bbbb) at arch/x86/kvm/../../../virt/kvm/kvm_main.c:2032
#15 0xffffffffc0d9a7f1 in try_async_pf (vcpu=vcpu@entry=0xffff88961d140000, prefault=prefault@entry=false, gfn=gfn@entry=20106239, cr2_or_gpa=cr2_or_gpa@entry=82355154944, pfn=pfn@entry=0xffffc90007f1bbc8,
write=write@entry=true, writable=0xffffc90007f1bbbb) at arch/x86/kvm/mmu/mmu.c:3666
#16 0xffffffffc0da5b1a in direct_page_fault (vcpu=0xffff88961d140000, gpa=82355154944, error_code=2, prefault=<optimized out>, max_level=3, is_tdp=true) at arch/x86/kvm/mmu/mmu.c:3711
#17 0xffffffffc0da63c9 in kvm_mmu_do_page_fault (prefault=false, err=2, cr2_or_gpa=82355154944, vcpu=0xffff88961d140000) at arch/x86/kvm/mmu.h:117
#18 kvm_mmu_page_fault (vcpu=<optimized out>, cr2_or_gpa=<optimized out>, error_code=4294967298, insn=0x0 <fixed_percpu_data>, insn_len=0) at arch/x86/kvm/mmu/mmu.c:5072
#19 0xffffffffc0d7934c in vcpu_enter_guest () at arch/x86/kvm/mmu/mmu.c:5078
#20 0xffffffffc0d7c1ba in kvm_arch_vcpu_ioctl_run () at arch/x86/kvm/mmu/mmu.c:5078
#21 0xffffffffc0d5971a in kvm_vcpu_ioctl (filp=0xffff88970d462d00, ioctl=<optimized out>, arg=0) at arch/x86/kvm/../../../virt/kvm/kvm_main.c:3238
#22 0xffffffff8132dfe4 in vfs_ioctl (arg=<optimized out>, cmd=<optimized out>, filp=<optimized out>) at fs/ioctl.c:47
#23 file_ioctl (arg=<optimized out>, cmd=<optimized out>, filp=<optimized out>) at fs/ioctl.c:548
#24 do_vfs_ioctl (filp=filp@entry=0xffff88970d462d00, fd=fd@entry=24, cmd=cmd@entry=44672, arg=arg@entry=0) at fs/ioctl.c:735
#25 0xffffffff8132e620 in ksys_ioctl (fd=24, cmd=44672, arg=0) at fs/ioctl.c:752
#26 0xffffffff8132e666 in __do_sys_ioctl (arg=<optimized out>, cmd=<optimized out>, fd=<optimized out>) at fs/ioctl.c:759
#27 __se_sys_ioctl (arg=<optimized out>, cmd=<optimized out>, fd=<optimized out>) at fs/ioctl.c:757
#28 __x64_sys_ioctl (regs=<optimized out>) at fs/ioctl.c:757

6、如果Host需要将虚拟机页面换出,则不仅要更新Qemu进程的页表,还需要同步刷新EPT页表。

内核态page_fault(vmalloc)

32位系统的虚拟地址空间分直接映射区和高端内存映射区,高端内存区主要是vmalloc使用(kmap也可以使用,但只是一部分,见后文)。对于64位系统,从前面提到的描述虚拟地址划分的内核文档中可以看到,内核虚拟地址也分为直接映射区和vmalloc/ioremap区。内核也会发生page_fault,并且内核的page_fault只会发生在vmalloc区域。因为直接映射区域的页表项,在系统启动的时候已经完成初始化了。

内核vmalloc区域虚拟地址的缺页是如何产生的呢?这就得先看一下内核的页表项是怎么使用的。

属于内核自身使用的页表都存放在一个名为init_mm的全局 struct mm中。init_mm中的页表并不像用户进程的struct mm一样将pgd直接load到cr3中来使用,而是作为一个查找表供其它进程索引。具体而言,每个进程的struct mm中,都有一份init_mm的拷贝,这份拷贝只能在进程处于内核态的时候可以访问。内核中所有的进程都有派生关系,创建新进程的时候,子进程会复制父进程的一些数据,init_mm的副本一定包括在拷贝内容之列。所以内核内存管理代码只需要给零号进程手动拷贝内核页表项,之后所有的派生进程的内核页表项的填充动作都可以交给进程管理代码。内核访问内核虚拟地址的时候会临时借用当前CPU上进程的struct mm,因为所有的管理内核虚拟地址的那段页表在所有进程中都基本是一样的,所以这种借用不会有问题。

当vmalloc分配内存的时候,内核采用lazy更新模式。vmalloc会从buddy系统中分配页面,然后在init_mm中初始化好va和pa之间的映射关系。当内核访问对应虚拟地址的时候,如果借来的struct mminit_mm副本中不存在对应虚拟地址的页表项,则会触发内核态的page fault。do_page_fault函数会检查缺页地址,然后将页表将init_mm中的正确页表数据拷贝到目标副本中,完成page fault处理。

那么vfree的时候怎么办呢?答案是通过内核中的反向查找机制。所有通过vmalloc映射的内存都会被精细的记录,该管理是数据结构使得内核可以从页面的管理数据结构struct page反向查找到目标进程。通过反向映射,内核只要找到曾今借用过struct mm的所有进程,删除它们init_mm副本中对应页表项即可。

直接alloc_page然后kmap

在内核态还有一种分配页面的方式,称为持久化映射。这里涉及到的核心函数是kmap,其可以将页面映射成内核态可以访问的虚拟地址。kmap一般在内核需要访问用户态页面数据的时候使用。内核一般不直接访问用户态区域的页面,但是当用户态和内核态之间传输大块数据的时候就需要访问了,比如用户调用各种系统调用的时候。kmap的代码很简短,如下所示。另外还有其它变种,这里不做讨论

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 64位系统实现
static inline void *kmap(struct page *page)
{
might_sleep();
return page_address(page);
}

// 32位系统实现
void *kmap(struct page *page)
{
might_sleep();
if (!PageHighMem(page))
return page_address(page);
return kmap_high(page);
}

kmap在64位系统上的实现,调用的核心函数是page_address,其本质是完成pfn到内核逻辑地址之间的转换。因为64为系统的内核逻辑地址都有和目标页面之间的一一映射关系,所page_address只需要做简单的offset计算就可以完成映射。

在32位系统上,情况要稍微复杂一点,因为内核虚拟地址空间无法覆盖所有的物理页面。在32位系统中,尽管高端映射区域只有一百多兆,但内核还是对其做了精细的划分。存在一段称为非线性映射区的虚拟地址空间,专门供vmalloc使用。还有一段称为持久化映射区域的虚拟地址空间,供kmap使用。在32位系统上,kmap函数会首先判断传入的页面是否为非高端内存。如果是非高端内存,则使用和64位系统同样的偏移计算完成地址映射;如果是高端内存,则通过kmap_high将页面映射到持久映射虚拟地址空间,映射的过程也是在init_mm中创建页表项。

slab、slob以及slub系统(kmalloc)

内核不仅使用buddy系统,还是用sl*b系统。buddy系统管理的是大块内存,分配的最小粒度是页面级别。sl*b则用于管理小块内存,分配的粒度是字节级别。sl*b和buddy系统的核心目的都是为了减少内存碎片,只是sl*b系统处理的粒度更小罢了。因为历史原因,sl*b系统经历过多次重构,衍生出了数个版本,具体差异这里不做讨论。

sl*b自成一个体系,其管理的内存也是源自于buddy系统,其与buddy系统的对接采用多退少补的方式。在内核初始化的时候,会从非高端内存区域中划分一部分页面给sl*b系统,sl*b再切分成匹配于各种内核常用数据结构的资源池以及小粒度内存块。当sl*b资源充裕的时候,空余页面又会被buddy系统回收。

kmalloc是sl*b的对外提供的服务函数入口函数。大家常提到的“kmalloc分配的内存是物理块连续,vmalloc分配的内存物理块不一定连续”的本质原因就是vmalloc背后是buddy系统,kmallc背后是sl*b系统。

Page、PFN以及虚拟地址之间的相互转换


这一小节讲述物理页面、PFN以及内核虚拟地址之间是怎么转化的。首先内核为每个物理内存页面都分配了一个类型为struct page的管理数据结构。所有页面的管理数据结构存放于一个叫mem_map的大数组中。前面提到的pg_data_tstruct node就是通过记录下辖页面在mem_map数组中的index完成内存范围确认的,数组的index本质就是页面的PFN。

将PFN用作这个数组的index可以实现从PFN到page的转换,struct page的地址和mem_map的基地址做差值可以实现从page到pfn的转换。page如果被内核使用,还可以实现page地址、pfn、逻辑地址三者之间的偏移转换。32位系统要注意,如果是页面被映射到高端内存,page到内核虚拟地址之间的转换要用到额外的散列结构。内核提供了一些辅助函数实现各种转换,如下

1
2
3
4
5
6
struct page *virt_to_page(void* kaddr); // 逻辑地址到页面之间的转换
struct page *vmalloc_to_page(const void *vmalloc_addr) // 操作vmalloc分配的地址,转换成page,使用额外散列结构

void *page_address(struct page *page); // 页面到逻辑地址之间的转换
struct page *pfn_to_page(int pfn); // PFN到struct page的转换
int pfn page_to_pfn(struct page *page); // struct page到PFN的转换

结尾

这篇文章虽短,内容却全是干货。以这篇文章为主干,可以更加容易的看懂内核内存管理特定模块的代码。