看 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 实现层面有 slabslubslob 算法

在我的认知里,slubslab 的简化版,也是目前的默认选择,而 slob 更适用于性能敏感的低端设备

slub 做参考,其架构层面从上往下分为 3 个级别:cacheslabobject,每一层级对下一级都是一对多的关系

在这里,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 | anonymousshared | private 的组合),其中一种操作就是把文件挂到内存上

内存的访问是 CPU 异常机制中的一种 page fault 实现

简单来说,在 shell 中执行一个进程,不过是 shell 把自己 fork() 出来一份,然后 exceve() 指定希望执行的文件路径和环境,我们就可以说是执行/创建了一个进程(其实 shell 也只是模仿 init 干的事情)

那我尝试把它们串联起来:

  • 可执行文件是一种 ELF 格式的文件
  • 通过解析 ELF 各个段,分别通过 mmap 指定虚拟地址的位置,分配地址空间,如果你需要与文件内容关联(如 text,同时 mprotect 保护权限),则使用文件映射,否则可以匿名映射(如 bss),每一次“分配”,其实就是给 mm_struct 一个创建 vma。更进一步说,stackheap 都是 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…

啰嗦了一堆(还不含代码),话题却远远没有完结,有空再更吧