看 memory management 永远不知道水有多深,这里只做简单总结
从哪里开始:内存探测
如果 kernel 不知道当前物理内存的信息,那么内存管理就无从谈起
可以通过 BIOS 探测得到物理内存的布局,这个内存探测模块称为 E820
得到的内存可以是杂乱无章的信息,以三元组 <start, length, memory type>
的形式来记录每一段内存
E820 所做的只有收集,其它不管,因此你得到的信息是杂乱无章的
总之你探测到的就是一堆三元组
初步的整理:memblock
memblock
/ bootmem
是启动初期的内存分配器,其实也只是简单整理上面 E820 给出的内存信息
简单地说,做的事情求只是:
- 排序
- 去重
也就是说它不考虑不同场合下的物理内存使用特征
模型的确定:SPARSE
上面已经解决了内存的收集路径和内存的大小信息
接下来就是解决内存怎么描述的问题,或者说是描述内存的内存(struct page)该怎么存放的问题,这种就笼统地成为内存模型
Linux 内存模型经历过三次迭代,分别是:
- FLAT:平坦模型
- DISCONTIGMEM:不连续内存模型
- SPARSEMEM:稀疏内存模型
第一种不支持 NUMA,只适用于非常早期的计算机(内存较小,直接用数组表示页帧关系)
第二种和第三种可互相替换,但是 DISCONTIGMEM 不支持内存热插拔,且认为内存是密集分布的
第三种是目前多数体系会选择的内存模型
内存模型决定了物理页帧 PFN 是怎么用的,以及 struct page 在内存中的组织方式
x86-64 的 mm 地图上有一块 vmemmap 的区域,就是放在这里
内存的划分:node 和 zone
(这一块其实我也没咋看)
内存划分要从两个角度去看待:一是物理上的划分,二是人为(管理方便)上的划分
前者就是 node 划分,后者就是 zone 划分
前者是为了对应 NUMA 体系结构上的 node,毕竟 NUMA 划分就是看内存控制器的。在我目前的认知来看,大概就是用图论中的距离(访问成本)来衡量内存使用上的策略(找哪个节点)
后者就是喜闻乐见的 DMA(32)、NORMAL、(已经灭绝的)HIGHMEM
(源码上也有虚拟 ZONE 的存在,比如反碎片 ZONE_MOVABLE,但具体怎么用有待后人挖掘)
特性应该有不少的,不过没了解,大概知道 water mark 这种保底用的阈值
真正的页帧分配器:Buddy
Buddy
应该在每一本 OS 教科书上都有记载了吧?干的事情也确实是这样,即按页分配的分配器
但是 Linux 在实现细节上还是有点差异,比如:
- PCP(per-cpu pageset) 分配
- 反碎片 fallback 策略
- CMA 特性支持
第一个特性是为了快,kernel 认为 page order 为 0 的页是有高度局部性的,因此给了 per cpu 的分配方式,而其它的 order 是按 per zone 分配的(在较新的 kernel 版本中(大于 4.18),认为 costly 的是 order > 3 的页帧们,因此可能会把 per-cpu 放宽到 order <= 3 的阶数)
第二个特性是为了节省碎片,按内存的使用行为划分为几个类型:
- UNMOVABLE
- MOVABLE
- RECLAIMABLE
这些可以通过 GFP 标记来指定
一般 kernel 内常规数据结构分配的是 UNMOVABLE,对应 GFP_KERNEL,低频率的回收和迁移
而用户态进程一般提供的是 MOVABLE,对应 GFP_USER,其特色是通过分页映射使得它可以随意移动(不介意物理内存是怎样的,用户态也无感知)
剩下的 RECLAIMABLE 一般用于 page cache,因为物理内存可能是随时没有的,也最容易受到内存回收机制的关注
第三个特性 CMA 只是非常有限的了解
对于必须提供的大块连续物理内存(contiguous memory),一种经典做法是内核启动时就预留,任何其它用途都不得碰它,显然会因为占坑导致内存利用率下降,而 CMA 则利用内存迁移(反碎片)特性,允许 MOVABLE 临时使用,以提高利用率。我个人认为就是只要保证使用时 CMA 必须能用到即可,至于原先的 MOVABLE 怎么处理,那是后话,你可以重新映射到别的地方,可以 fallback 到其它类型,也可以触发内存回收先睡眠等待(说到睡眠,还有一些 GFP 标记为 HIGH_ATOMIC,表示绝不睡眠,GFP 真是太复杂了。。)
页帧分配器的客户:SLAB 接口
SLAB
是一种接口,负责更细粒度上的内存对象分配
俗话说,内存碎片有 2 种,外部碎片和内部碎片
因此 linux 对于一般意义的内存分配也就拆分了页帧分配器和内存对象分配器,分别处理不同的碎片
SLAB
实现层面有 slab
、slub
、slob
算法
在我的认知里,slub
是 slab
的简化版,也是目前的默认选择,而 slob
更适用于性能敏感的低端设备
以 slub
做参考,其架构层面从上往下分为 3 个级别:cache
、slab
、object
,每一层级对下一级都是一对多的关系
在这里,object
则是我们需要分配得到的“对象”,slab
是一个比较内部实现的存在(注意 slab
不是 slab
算法)
因此一套使用流程就是先分配 cache
,再往 cache
里拿 object
整个算法过程,往简单地说就是维护 partial freelist 的过程
比较特殊的操作有:
- cache alias:当你尝试分配一个
cache
的时候,kernel
可能不会真的分配,而是复用条件相似的已有的cache
给你,然后引用计数 +1(不过条件挺苛刻的) - per-cpu freelist:slab 是通过链表来维护的,但是分为 2 种链表,一种是 cache 内的常规链表,另一种是 cache 内的有限的 per-cpu 无锁链表,利用局部性原理来提高性能
- cache 就是 object:cache 之间也是用链表来维护全局的,而它们都来自于专门管理元数据的 cache,这就很有鸡生蛋的感觉了
地址空间:段页保护的历史
前面说的都很侧重于物理内存,但是内存管理要考虑的还有地址空间
也就是说,内存怎样和(虚拟)地址怎样是区分看待的
这就不得不提 x86 复杂的地址视角:逻辑地址、线性地址、物理地址
简单来说就是一个逻辑地址经过分段能得到线性地址,线性地址经过分页又能得到物理地址
也就是说,地址空间离不开段和页,内存需要它们来实现透明性和隔离性
但是问题来了:
- 为什么会同时有两种机制?用一种不行吗?
- 虚拟地址又是指什么地址?
回答第一个答案:历史原因;能同时也不能
牙膏厂最早引入段机制是为了省钱,在一个 16 位 CPU 上能访问 20 位地址还不用宽度更大的寄存器,这个时候还说不上保护模式,只是纯粹的省 qian,哦不,用巧妙的位移满足程序员的内存访问需求
后来需要考虑内存保护机制了就继续省 qian,哦不,是复用段机制,在此基础上完成透明隔离,以前的段寄存器就是一个值,后来就改为指向内存中的 GDT,里面描述段的起始、偏移、权限等等,套一个中间层(GDT)就完成了保护模式,确实保住了硬件的设计成本
后面进一步第引入分页可能是考虑到段的管理粒度问题,但是令人困惑的是要分页就得先分段(别问为什么,问就看 SDM 手册,我看了,没说),从前面的设计目的来看,Intel 应该还是想省钱
到第二个问题:从 kernel 角度来看的话,虚拟地址算是逻辑地址还是线性地址呢?(肯定不会是物理地址)
其实是没差的,因为 kernel 为了通用性(有些体系结构并没有 x86 这么复杂)基本绕过了段机制(如上,还是要分段,只是所有的内存都在一个段上),只保留实际的页机制
而这个迷惑操作就存在两种理念冲突:Intel 希望通过分段来实现内存保护,而 kernel 把它绕过去了,因此必须要在分页这一块完成隔离操作。只不过以内核页表共享、用户进程页表互相隔离这种操作,基本都足够使用了,这回过头一看,段还真是没啥用啊(据说 fs、gs 有奇效?)
总之,把虚拟地址当作是线性地址,或者是逻辑地址的 effective address 部分,我认为都没有歧义,只是术语上,virtual 指的就是 kernel 视角的地址,其它的地址更多是 CPU 视角(比如 SDM 手册没提过一句 virtual memory/address)
进程地址空间:布局实现
看一个进程地址空间如何,了解 ELF 基本就行了,因为从 ld 到 kernel 也是对着 ELF 文件解析段属性的(在这里不考虑链接阶段的节属性),虽然我也不咋了解 ld 充当加载器时是怎么干的,就说下 kernel 相关的机制吧
kernel 是通过一个 struct mm_struct
的数据结构来维护单个进程的地址空间(这就得吐槽一下 struct address_space
竟然是拿来干别的事情),而 mm_struct
可以认为是多个 vma
来组成(virtual memory area)
而 kernel 存在一种广为人知的内存映射方式 mmap
,用途广泛(file | anonymous
和 shared | private
的组合),其中一种操作就是把文件挂到内存上
内存的访问是 CPU 异常机制中的一种 page fault
实现
简单来说,在 shell
中执行一个进程,不过是 shell
把自己 fork()
出来一份,然后 exceve()
指定希望执行的文件路径和环境,我们就可以说是执行/创建了一个进程(其实 shell
也只是模仿 init
干的事情)
那我尝试把它们串联起来:
- 可执行文件是一种 ELF 格式的文件
- 通过解析 ELF 各个段,分别通过 mmap 指定虚拟地址的位置,分配地址空间,如果你需要与文件内容关联(如 text,同时
mprotect
保护权限),则使用文件映射,否则可以匿名映射(如 bss),每一次“分配”,其实就是给mm_struct
一个创建vma
。更进一步说,stack
和heap
都是mmap
分配的vma
,而brk()
则是针对heap
这一块特殊匿名mmap
区域进行调整的函数 - 此时并不直接分配物理内存和建立 MMU 映射,而是 lazy 策略,留到使用时再通过 vma 的回调执行 fault(vmops,
do_fault
),也就是说,初次的内存访问 CPU 也会认为你是 fault。如果某一段映射是涉及到 file-backed 的,则fault
回调过程需要额外通过文件系统的接口去完成(多说一句,COW 也是通过 fault 回调完成) - 把
fork
时所使用的地址空间给回收掉,以满足execve
替换语义
以上是 kernel exceve 对进程内存布局的工作(不是全部,创建完后接下来的执行是需要 ld
配合)
尝试展开一下从“内存”本体到“程序”执行流的一个过程:
- 同样地,
ld
本身也是文件,通过mmap
放入到内存中 - 走
ld
的流程只需把(控制流)rip
指向ld
对应的代码段的 entry address 即可 ld
完成使命后会把控制流转接到main
中
To Be Continued…
啰嗦了一堆(还不含代码),话题却远远没有完结,有空再更吧