linux 源码学习 - 内存管理
linux 版本为 v6.18
结构体定义
主要涉及 include/linux/mm_types.h,定义了内存管理中所需的基本数据结构
空间优化
在内核代码的结构体定义中,有一种广泛使用的设计是这样的:
1 | struct Foo { |
也就是利用匿名结构体和匿名联合体的结合,这样写不仅可以利用顶层 struct 的变量直接访问底层变量,还能够充分发挥 union 的空间优势
以内存页元数据结构体为例,通常情况下物理页的大小是固定的 4KB,但同一个物理页可以有不同的用途,例如文件缓存、匿名内存(进程堆栈)、DMA 缓冲区、swap 缓存、网络栈页面池等,不同用途下页面所需要的元数据也是不同的,因此一种直接的思路是为每一种用途设计一个对应的结构体,例如 struct file_cache_page,struct anomyous_page 等等
然而,在内核开发中,时间和空间复杂度的优先级是高于代码简洁性与易读性的,如果按照上面的思路来实现,那就无法用一个数组来存储所有页的元数据,从 pfn 映射到元数据的开销会有所上升;并且当有一个物理页的用途发生变化的时候,就需要有结构体的析构和构造,这种开销在内核中是无法接受的
linux 采用的这种实现实际上是用 C 语言实现了一种特殊的“多态”,对于内存空间进行了极致压缩,在同一块内存空间中存储逻辑上互斥访问的多条数据
page 和 folio
struct page 是 linux 内核最基础的内存描述单元,一个 page 对应一个物理页帧(大小通常为 4KB)
现如今,由于内存变得越来越大,使用 4KB 作为页大小很多时候会导致性能问题,例如内核或用户进程需要 2MB 的物理内存时,4KB 的页会导致至少要 512 次 TLB miss 和缺页异常才能够完全建立页表
为了解决这个问题,内核引入了 compound_page 设计,一个 compound_page 代表一组连续的物理页面,由一个 head_page 和若干 tail_page 组成,其元数据由 head_page 对应的 struct page 管理,具体的实现方法仍然是在 struct page 中添加 union,这就导致了 struct page 语义严重过载,在使用的时候往往要增加一系列繁杂的判断
因此,在 linux 5.16 中引入了 struct folio,它代表一个高阶连续物理内存单元(大小为 个页),将传统上分散在多个 page 结构体中的逻辑聚合为一个统一对象
下面来看二者的具体实现:
page 实现
1 | struct page { |
只保留了一些很基础的字段,例如第一个 union 中的若干个匿名 struct,实际上就表示了这一个 page 的不同用途,在不通过用途下按照不同的方式来解释数据,比如如果访问了 page.buddy_list,就表示了这一个 page 已经不再被使用,并且位于伙伴系统;而访问 page.lru 则代表它正在被使用,并且使用 LRU 算法决定要不要被置换;当访问 page.compound_head 的时候就说明这一页是隶属于某个复合页,可以通过这个变量访问对应的 head_page
mapping 这个字段可以用于区分页缓存和匿名页,这里 mapping 的最低位如果是 1 的话,就代表这是一个匿名页,mapping 指向的地址是一个 struct anon_vma,这个的具体原因后面 vma 部分会说。由于内核中的结构体至少需要按照字节对齐,因此结构体指针最后的几位 bit 通常情况下并未被使用,因此用来存储这个信息是安全的
结构体中有两个易混的变量,_mapcount 和 _refcount,可以看出 _refcount 并不在 union 中,因此所有页面都有这个属性,它代表的是内核中有多少个地方正在引用这个物理页(例如一页刚被 alloc 的时候就会增加这个值);而 _mapcount 代表的是一个数据页被多少个进程映射了,非数据页(例如页表页、伙伴系统空闲页)则不需要这个字段,而是被 page_type 代替
最后是 virtual 字段,这个字段是为了解决 32 位机器上内核虚拟地址无法和物理地址一一映射的问题。在 32 位机器上,虚拟地址空间共有 4GB,通常会分配 1GB 给内核地址空间,因此如果物理内存大于 1GB,则无法在内核启动时就建立好一一映射的静态页表,因此 linux 将物理内存分为了两个部分,第一部分是低内存(通常 896MB),这部分物理地址和内核虚拟地址之间是线性映射的关系;第二部分则是高内存 highmem,当内核想要访问高内存的时候,就需要找到一个空闲的虚拟地址,并且建立该虚拟地址和要访问的物理页之间的临时映射,这个虚拟地址就存在 virtual 里面。由于 64 位机器的虚拟地址空间高达 16 EB,因此不需要考虑这个问题了
folio 实现
1 | struct folio { |
struct folio 是设计用于解决复合页和大页的开销,简单来看它的内存布局似乎是 4 个 struct page 的组合,并且在后续利用了大量的 assert 来保证内存结构的一致性,这样保证的是在出现 struct folio 的时候,它一定对应着 head page,因此可以显著减少分支判断,而内存布局的一致性则保证了可以将 struct page* 和 struct folio* 之间灵活的转换,转换代码在 include/linux/page-flags.h 中,具体来说:
1 |
其中利用了泛型语法以保持常量属性
此外还有 struct ptdesc,这是将 struct page 描述页表页的功能独立出来,这里就不说了
pglist_data 和 zone
这两个结构体是对于 numa 架构中物理内存的抽象。在 numa 架构中,完整的物理内存被划分为了多个 node(每个 node 有若干个本地 cpu,本地访问的性能很高),而每个 node 又被划分为若干个 zone,struct pglist_data 记录了 numa node 的元数据,而 struct zone 则是 zone 的元数据
pglist_data 实现
1 | typedef struct pglist_data { |
struct pgdata_list 是 numa node 的元数据,由于这些元数据比较确定,所以这里面没有使用 union,里面的变量大部分都有很清晰的注释,这里再做一些记录:
node_zonelists记录的是所有 node 的 zones 引用,其中每一个元素对应一个 node 的node_zones,数组的顺序是内存分配的优先级,也即先从node_zonelists[0]中开始分配,之后是node_zonelists[1],以此类推node_start_pfn记录的是这个 node 的起始物理地址页帧号,它和node_present_pages以及node_spanned_pages一起决定了 node 所对应的物理地址区间kswapd指向一个内核进程,这个进程会在其他进程发现内存不足时被唤醒,将未使用的页写回到磁盘的 swap 分区,其他的kswapd_变量都是配合这个线程的,例如kswapd_wait是这个进程的等待队列,虽然队列中只有这一个内核进程,但是其中包含一个自旋锁保证操作原子性
zone 内核实现
1 | struct zone { |
这里面最核心的成员是 free_area,这也是伙伴系统的核心结构体,它保存的是不同 order 的空闲页面链表,具体来说,struct free_head 的定义为:
1 | struct free_area { |
下标 order 对应的链表中,空闲块的大小为 1 << order 个页,即
可以看到,struct page 中有一个 struct list_head buddy_list,当页面空闲的时候,这个 buddy_list 变量就会被挂到这里对应阶数的 free_list 链表下,也即 free_list -> next = buddy_list,之后通过 container_of 宏来找到对应的页
_watermark记录了这个 zone 的剩余内存“水位线”,数组中的值升序排列,当 zone 中的剩余内存低于某一个值的时候,会采取对应的方式回收内存(例如唤醒kswapd、阻塞进程同步回收)per_cpu_这两个变量是为了性能提升用的,由于 cpu 分配页面的时候需要锁住zone.lock,这导致多 cpu 频繁分配页面时性能会很差,因此给每个 cpu 分一小块缓存,尽量先从本地的 pageset 中分配页面,修改本地的 zonestat,迫不得已时才访问全局变量_pages这几个变量详细记录了 zone 中不同属性的页面,这个注释写的太清晰了所以忍不住放上来!
在内核中,zone 被分为了若干类型,分别代表了不同的寻址方式,使用 enum zone_type 描述,常见的包括:
ZONE_DMA:用于兼容无法访问整个地址空间的老式外围设备ZONE_NORMAL:直接线性映射到物理地址的内核虚拟地址空间,即前面struct page中所说的低内存ZONE_HIGHMEM:需要临时映射的高内存,在 64 位机器上并不存在
vm_area_struct
vm_area (VMA) 描述的是进程地址空间中一段连续的、具有相同属性的虚拟内存区间,也即对于进程来说,整个虚拟地址空间并不是一个整体,而是被分成了若干连续的 vma,相邻的 vma 之间有着不同的属性(例如读写权限、映射来源等)
在 linux 中,可以使用 cat /proc/xxx/maps 来访问某一进程的内存映射表,其中第一列就是不同 vma 的地址区间
struct vm_area_struct 记录了 vma 的元数据,包括但不限于该区间的起止地址、权限、以及缺页中断处理方式等
vm_area_struct 内核实现
1 | struct vm_area_struct { |
当 vma 正在被使用的时候,开头的 union 中记录了这个区域在内存空间中所管理的地址区间,即 area 管理的是 [vm_start, vm_end) 这个区间;当 vma 空闲的时候,其会被加入空闲链表中,链表指针就是 union 中的 vm_freeptr
对于一个 vma 来说,其权限管理由 vm_page_prot 和 vm_flags 这二者共同控制,其中 vm_page_prot 是硬件权限,也即执行权限,而 vm_flags 是软件权限,也即逻辑权限,这么设计主要是因为:
- 不同的硬件架构的权限位规范是有区别的,而
vm_page_prot的值最终会被写入到 MMU 的页表项中,因此它的具体值需要适配底层架构。例如同样是可读可写的一片 vma,它的vm_page_prot在 x86 平台上的值和在 arm 平台上的值就是不一样的 - 在一些特殊情况下,会出现逻辑可行但实际不可执行的情况,例如进程 A fork 出进程 B,二者共享一片可写的 vma,此时在逻辑上,两个进程都会认为这片内存是可写的,因此在
vm_flags中会标记可写;而由于二者是共享这一片内存,因此为了防止写入竞争,二者的vm_page_prot中都不会标记可写。这样当某一个进程写入这片内存的时候,就会触发 COW 机制
至于为什么 vm_flags 要设计一个 union,在神秘魔法一章分析
anon_vma 和 vm_file 这二者则决定了该 vma 的映射来源,也即到底是一个匿名映射还是一个文件映射,这决定了当发生缺页异常的时候内核应该从何处去加载所需要的页
在 vm_flags 中,记录了这个 vma 的映射类型,分为共享映射 MAP_SHARED 和私有映射 MAP_PRIVATE 及其他一些类型,核心点在于不同进程对于这片物理内存的修改是共享的还是私有的,注释中比较详细的说明了不同映射来源与不同映射类型的 vma 所存在于的数据结构,下面来稍微详细解释一下:
内核中有一个结构体 struct address_space,它是管理页缓存的核心架构,每个 inode 会有一个对应的 address_space,其中主要记录的是这个文件有哪些页在内存中被缓存了。结构体中有一个重要的成员变量是 struct rb_root_cached i_mmap,它对应着一棵红黑树,树中保存着所有通过 mmap() 映射了该文件的 vma,这样内核就可以在文件发生变动的时候,快速遍历所有可能需要改动的 vma 及其所属的进程。当一个 vma 是文件映射的时候,它就会被插入到 i_mmap 这棵树中,对应的 struct vm_area_struct 中的 vm_file 变量也会是非空
而当一个 vma 是匿名映射的时候,其中的 struct anon_vma *anon_vma 会被赋值。而 struct anon_vma 是连接匿名物理页与 vma 的桥梁,其中保存着一棵区间树struct rb_root_cached rb_root(基于红黑树实现),这棵树中保存着所有相关的 vma,这里说的“相关”指的是树中的 vma 都映射了同一片匿名内存。前面说过当物理页是一个匿名页的时候,其 mapping 变量会指向一个 struct anon_vma,这样当物理页需要知道自己被哪些进程映射时,就可以通过 page->mapping 来访问其中的区间树,进而快速遍历。同时还有一个成员变量 struct list_head anon_vma_chain,这是一个链表头,通过这个链表,vma 可以知道自己属于哪些 struct anon_vma,也即知道自己存在于哪些树中,这样当 vma 被破坏的时候,也能够通知所有的 struct anon_vma 进行对应的修改
这样我们就能解释 A file’s MAP_PRIVATE vma can be in both i_mmap tree and anon_vma list… 这段注释了:
- 当 vma 是一个文件映射的时候,首先它一定会存在于 i_mmap 树上
- 当映射类型是
MAP_SHARED的时候,其实是多个进程映射同一个文件(例如进程间通信),此时不会发生 COW,没有匿名页产生,这个 vma 也就不会存在于 anon_vma 的区间树中 - 当映射类型是
MAP_PRIVATE时,多个进程以私有映射的形式共享同一个文件(例如fork()调用),当有进程尝试写入该页的时候会触发 COW,赋值出一个新的匿名页,该 vma 就会被插入对应 anon_vma 的区间树里面,并且此时不会把 vma 从 i_mmap 树中删掉(也不会清除结构体中的vm_file等变量),这是因为 COW 机制只会复制需要被写入的页,其它页仍然是共享的,需要通过文件来访问
- 当映射类型是
- 当 vma 是一个
MAP_PRIVATE的匿名映射、堆栈指针等情况时,它就只能存在于对应 anon_vma 的区间树中,不会存在于 i_mmap 树上
对于不同类型的映射,vma 提供了统一的内存操作接口,例如 open(), close() 等,这部分被定义在成员变量 const struct vm_operations_struct *vm_ops 中,这个结构体里面是一堆函数指针,注意这个写法是常量指针,所以 vm_ops 可修改而 *vm_ops 不可修改
anon_vma_chain
1 | struct anon_vma_chain { |
这是一个精妙的数据结构设计,通过 struct anon_vma_chain 可以维护 vma 和 anon_vma 之间的多对多关系,来捋一下:
- 一个 vma 可以属于多个 anon_vma,通过
vm_area_struct -> anon_vma_chain这个链表来访问 - 一个 anon_vma 中可以包含着多个 vma,通过
anon_vma -> rb_root这棵树来访问
但是,链表的节点并不是 anon_vma,红黑树的节点也不是 vma,它们的节点都是 struct anon_vma_chain ,这个结构体中最重要的四个变量是:
vma和anon_vma:指向连接的双方,也即有对应关系的两个结构体same_vma:一个链表节点,被挂载在vma -> anon_vma_chain这个链表中rb:一个红黑树节点,被挂载在anon_vma -> rb_root这棵红黑树中
也就是说,这个结构体中有一个成员变量被挂在链表上,有一个成员变量被挂在红黑树上,因此利用 container_of 宏,这个结构体本身就既能通过链表访问,又能通过红黑树访问,实现了双向连接
这么做的一个好处是能绕过红黑树节点的唯一性,也即一个 vma 属于多个 anon_vma 时,它需要存在于多棵红黑树中,而一个 rb_node 只能存在于一颗红黑树中,因此如果在 vma 里面静态添加 struct rb_node 是非常困难的,一个直观的想法是在 vma 里面加一个链表头,链表的每一个节点里面存一个红黑树节点,这其实就是 struct anon_vma_chain 的实现思路了
mm_struct
struct mm_struct 是描述进程整个内存布局的结构体,等看进程管理的时候再来补
内存分配算法
这一章主要关注的话题是,当用户在 C 语言中写下 ptr = malloc(1 << 24); memset(ptr, 0, 1 << 24) 这行代码之后,执行的过程中的会发生什么?
首先介绍一下 linux 的延迟分配机制,当用户进程的堆内存不够的时候,库函数会调用 brk 或 mmap 这两个系统调用。brk 系统调用是移动进程堆的结束地址来分配或释放堆内存,通常是在 malloc 一些较小的区域的时候被调用,而当所申请的大小超过系统阈值的时候,就会调用 mmap 系统调用,内核会为此划分一块新的 vma 并记录,但是并不会建立页表映射。而当进程访问这块内存的时候,会触发缺页异常,进而分配物理页
如题,如果申请的空间是 1 << 24 = 16MB,那基本上会超过阈值,调用 mmap,对于 x86 系统上的用户态程序来说,调用流程是:
arch/x86/um/syscalls_64.c中提供了sys_mmap的接口:SYSCALL_DEFINE6(mmap, ...)- 调用
mm/mmap.c中的ksys_mmap_pgoff() - 调用
mm/util.c中的vm_mmap_pgoff() - 调用
mm/mmap.c中的do_mmap(),这里会做大量的边界检查与权限控制 - 调用
mm/vma.c中的mmap_region(),其中会进一步调用 static 函数__mmap_region(),这其中会首先尝试将 vma 与相邻的区域合并,如果无法合并则会调用__mmap_new_vma()分配一块新的 vma
分配完 vma 之后 mmap 的工作就完成了,接下来当访问这片内存的时候会触发缺页异常,还是以 x86 系统为例,这个流程是:
- 触发缺页异常时,错误码和触发异常的线性地址压栈,并跳转到内核入口
exc_page_fault - 调用
mm/fault.c中的handle_page_fault(),进而调用do_user_addr_fault() - 调用
mm/memory.c中的handle_mm_fault(),其中会调用 static 函数__handle_mm_fault(),落到handle_pte_fault(),这个函数中当发现虚拟地址不存在页表项的时候,会调用do_pte_missing(),对于堆内存(属于匿名内存),会进一步调用do_anonymous_page(),当没有错误发生的时候,会进入alloc_anon_folio(),最终会进入folio_prealloc() - 调用
include/linux/gfp.h中的vma_alloc_folio(),此时会经过一系列映射与转换:vma_alloc_folio() -> vma_alloc_folio_noprof() -> folio_alloc_noprof() -> __folio_alloc_node_noprof() - 调用
mm/memory.c中的__folio_alloc_noprof(),然后进入__alloc_pages_noprof(),最终到达__alloc_frozen_pages_noprof(),这里是伙伴系统分配器的最终实现!
至于缺页异常的时候为什么会跳转到 exc_page_fault,这涉及到了更底层的硬件处理
伙伴分配算法
TODO