mm模块简单总结

看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真是太复杂了。。)

页帧分配器的客户1——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,用途广泛((文件|匿名)和(共享|私有)的组合),其中一种操作就是把文件挂到内存上

内存的访问是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...

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

发表评论

邮箱地址不会被公开。 必填项已用*标注