计算机诞生之初应用程序往往比内存大很多,这种情况下一般的解决方法是对程序进行拆分,必要时在内存和磁盘之间进行数据交换,这样就可以在小内存上运行较大的程序。由此,我们引申出了操作系统中内存管理的概念。
物理内存管理
页与页框
除了硬件保留及内核占用的内存,剩余内存被称为动态内存,使用页框(4K)进行管理
我们使用类型为page的页描述符对页框进行管理,page保存在mem_map
数组中,所需空间为$R{ps}/R{p}$,约等于$1\%$
virt_to_page
:根据虚拟地址找到页描述符地址pfn_to_page
:产生与页框号pfn对应的页描述符地址
NUMA(Non-Uniform Memory Access)
CPU距离某些内存从物理距离上更近,距离另一些更远,访问距离近的内存时速度更快,所以应当尽量使用临近的内存节点,每个节点叫做一个Numa Node
Zone
将Numa Node进一步划分,我们得到不同的Zone,每一个Zone由多个页框(page frame)组成
Page Frame
在linux中,页框由page结构体进行描述:
1 | struct page { |
每个page frame需要一个page对应,一个struct page一般占用32字节,而一个page frame为4KB,消耗的内存占系统的32/4096,略小于1%
内存管理区
由于硬件结构的制约,限制了页框的使用方式,在8086上,Linux将每个内存结点的物理内存划分为三个zone
DMA
和NORMAL
经过线性映射映射至虚拟地址的第4GB,内核可以直接访问。这两块内存中包含了保留的页框池,用于在一条内核控制路径产生原子内存分配请求且内存不足的情况下紧急使用。
页框分配
管理区分配器接受动态内存分配释放请求,该部分搜索一个能满足所请求的一组连续页框内存管理区,并采用伙伴算法对页框进行分配,同时保留了一小部分页框在高速缓存中用于快速响应请求。
- alloc_pages(gfp_mask, order):返回第一个分配页框描述符的地址,gfp_mask是页框标识
- __get_free_pages(gfp_mask, order):返回第一个分配页的线性地址
- __free_pages(page, order):释放页框
- free_pages(addr, order)
高端内存映射
由于32位平台线性地址空间的限制,内核并不能直接访问高端内存,内核线性地址空间的后128MB专门用于映射高端内存页框
- 永久内核映射:长期映射
1 | for (;;) { |
- 临时内核映射:不阻塞当前线程,可用于中断或可延迟函数内部
伙伴系统
连续页框分配,解决外碎片问题,找到最小满足要求的空闲块,如果没有,找到更大的一块,掰成两半使用
- 分配块
1 | // 寻找合适的块 |
- 释放块:按照伙伴系统策略释放页框
内存区管理
伙伴系统用于分配大块内存请求,而对于小的内存区会造成浪费,这时我们采用slab分配,解决内部碎片问题
- 将小块的数据结构对象放入高速缓存中,从中获取或释放
- 内核函数倾向于反复请求同一类型的内存,例如进程描述符,文件对象等
每个slab由一个或多个页框组成,页框中既包含已经分配的对象,也有空闲对象
- 创建slab
1 | // 获取一组连续页框 |
- 释放slab页框
1 | void kmem_freepages(kmem_cache_t *cachep, void *addr) |
给高速缓存分配slab
一个新创建的高速缓存没有任何slab,也没有空闲对象,只有当以下两个条件为真,才分配slab
- 已经发出一个分配新对象的请求
- 高速缓存不包含任何空闲对象
当上述情况发生时,slab分配器进行如下操作:
- 调用
cache_grow
分配新slab(虚slab) - 调用
kmem_getpages
分配一组页框(物理内存) - 调用
alloc_slabmgmt
获取slab描述符0
非连续内存区管理
虚拟地址来源于vmalloc区域,通过调用get_vm_area
可以在VMALLOC_START
和VMALLOC_END
之间查找一块空闲区域,大小至少为size+4096
,步骤如下:
- 调用kmalloc为
vm_struct
获得内存区 - 上锁,遍历
vm_struct
的结构体链表,找到一个大小符合要求的(size+4096
)区域 - 如果找到,初始化描述符字段,释放锁,返回
vm_struct*
,否则返回NULL
vmalloc
实现如下:
1 | void * vmalloc(unsigned long size) |
映射的过程是相对比较机械的,具体步骤如下:
1 | // pgd |
内存管理的主要目的
内存管理主要负责内存的分配与回收,例如在C++中的new与delete;同时,内存管理还负责地址转换的事宜。总结下来内存管理的目的就是实现物理内存的分配以及虚拟内存的映射。内存管理需要实现如下目的:
- 抽象:逻辑地址空间
- 保护:空间独立性
- 共享:访问相同内存、
- 虚拟化:更大地址空间(大于物理内存总量)
内存管理方式
- 重定位:程序加载到内存后,根据加载位置的不同,要将程序中用到的地址进行重新定位,找到正确位置
- 分段:把程序分为代码、数据、堆栈,这样可以使内存不连续
- 分页:将内存分为最基本的单位,找一个合适的粒度大小
- 虚拟存储
内存管理的实现
内存管理的组成
内存管理可以分为物理内存分配和虚拟内存映射。
内核的物理内存分配器
在内存管理中,我们需要为内核提供物理内存分配器,使内核能够分配和释放内存。分配的单元为页,大小一般为4KB。为了实现分配器,我们需要维护一个数据结构记录哪些内存是已经被分配的,哪些是空闲的,多少个进程在使用已经分配的内存。同时,我们还需要一套用于分配和释放的调度策略。
虚拟内存机制
为了将内核和用户软件的虚拟内存地址映射到实际物理地址,我们需要一个虚拟内存映射机制。当指令使用内存时,x86的内存管理单元(MMU)通过一个页表完成映射过程。
为何要进行内存划分
1 解决碎片问题
碎片可以分为内部碎片和外部碎片:内部碎片是固定分区中浪费掉的内存,而外部碎片来自可变分区中进程和进程间空间特别小,但是没办法再利用的碎片。
2 逻辑地址和物理地址的映射关系
逻辑地址可以使我们屏蔽硬件,更具逻辑性,而不必关注底层的内存物理地址。
内存划分方式
非连续分配管理
页式管理
把主存分为大小相等且固定(2的n次幂,如512,4096等)的一页一页的形式,页较小,相对相比于块式管理的划分力度更大,提高了内存利用率,减少了碎片。页式管理通过页表对应逻辑地址和物理地址。
- 页帧:物理地址划分为大小相同的基本分配单位
- 页面:逻辑地址划分为大小相同的基本分配单位,页帧和页面大小必须相等
页管理就是找到页面到页帧之间的转换,依靠页表进行(找到逻辑页号和物理帧号之间的对应关系)。逻辑地址中的页号是连续的,物理地址中的帧号一般是不连续的。
段式管理
段式管理把主存分为一段段的,每一段访问方式和数据属性相同,段的空间又要比一页的空间小很多 。但是,最重要的是段是有实际意义的,每个段定义了一组逻辑信息,例如堆段、栈段、代码段、数据段等。 若干段组成了进程逻辑地址空间。段式管理通过段表对应逻辑地址和物理地址(逻辑连续,物理不连续)。
以linux系统为例,内存的划分可以分为五个段,其相关属性如下:
名称/符号 | 功能 | 读写性 | 动态/静态分配 |
---|---|---|---|
BSS段 .bss | 存放未初始化的全局变量,体现为一个占位符,只记录所需空间大小,而不记录具体的值,一般bss初始化为0 | 静态 | |
数据段 .data | 存放已初始化的全局变量或局部静态变量 | 可读写 | 静态 |
代码段 .text 常量段 .rodata |
存放程序执行代码以及一些常量,一般存放在ROM中 | 只读(一些OS可写) | 静态 |
堆 | 存放动态分配的内存段 | 可读写,必须手动分配及释放 | 动态 |
栈 | 存放临时局部变量 | 可读写,系统自动管理 | 动态 |
分段后,一个程序就被拆分成了若干个段,示意图如下:
段访问下,逻辑地址由二元组(s, addr)
表示
s
:段号addr
:段内偏移
每个段,其内部地址是从0开始的,还有段号区分不同的段,例如代码段某条指令段号为1,段内偏移为1,那么其逻辑地址为$1:1$,当然我们要考虑段号和段内偏移的具体位数,这个和具体硬件有关,例如一个16bit的地址,段号为2bit,偏移为14bit。由逻辑地址找到程序段的过程为,通过段描述符找到段基址和长度,根据这两个值确定物理内存的位置。
段页式管理
段页式管理机制结合了段式管理和页式管理的优点。简单来说段页式管理机制就是把主存先分成若干段,每个段又分成若干页,也就是说 段页式管理机制 中段与段之间以及段的内部的都是离散的。
分页和分段的共同点和区别
共同点
- 都是为了提高内存利用率,减少碎片
- 段和页都是离散存储的,两者都是离散分配模式,但页和段中内存是连续的
区别
- 页是固定大小,由操作系统决定;段大小不固定,由当前运行程序决定
- 分页为了满足操作系统内存管理的需求,而段是逻辑信息的单位,具有明确的意义,例如代码、堆栈和数据段等等。
页面置换算法
虚拟内存实现过程中很重要的一个部分是页面置换算法。在地址映射过程中,如果在页面中发现所要访问的页面不在内存中,就发生缺页中断,即操作系统需要将页面调入主存后再进行访问,被内存映射的文件实际上成了一个分页交换文件。当发生缺页中断,如果当前内存中没有空闲页面,那么就必须选择一个页面移出从而让出空间。移出某个页面的算法就叫做页面置换算法。
- 最佳页面置换算法:所淘汰的页面是以后永不会使用的,但是这种算法物理上不能实现
- FIFO:淘汰在页面中滞留时间最长的
- LRU:最近最久未使用,记录每个页面自上次访问来所经历的时间T,选择T最大的进行淘汰
- LFU:最少使用,选择最少使用的页面进行淘汰
快表和多级页表
在分页内存管理中,很重要的两个要素是:
- 虚拟地址映射到物理地址的速度要快
- 解决虚拟地址空间大,页表也会很大的问题
快表(页表的Cache)
快表是页表缓冲,其内容是页表一部分或全部,功能是加快地址映射速度,通常位于MMU中。使用快表后的地址转换流程:
- 根据虚拟地址中的页号查询快表
- 如果该页面在快表中,则直接从快表读取相应的物理地址
- 如果该页面不在快表中,访问内存中的页表,从页表中得到物理地址,同时将页表中的映射表添加至快表
- 快表填满后,登记新页面时,根据页面置换算法淘汰快表中的一个页面
多级页表
下面以实际的计算演示多级页表所占用的大小
在32位系统中,内存地址为32位,分为两个部分,Page Number和Page Offset,由于offset即为页面大小,因此页面大小为$2^{12}=4096=4K$。而页面数为$2^{20}=1K\times 1K=1M$,意味着我们需要$1M$的空间保存页面映射表,由于页表要求连续,可能很难找到一段连续的空间保存1M,因此我们采取了二级页表划分的方式,将Page Number进一步划分为两个10位的表,即构成多级页表的寻址方式。
外部页表的大小是$1K$,而内部页表共$1K$个,每一个的大小是$1K$,故总大小也为$1M$。但是我们不一定把这些页表都保存起来,可以只保存一部分,这样就节约了资源。
下面以一个完整的例子展示多级页表的计算过程2,一个多级页表的结构如下:
令$Sm$为虚拟地址能表示的最大内存大小,$S_P$为页面大小,$S{PTE}$为保存每个页表项(PTE)的大小。考虑一个内存大小为8GB,一个页大小为8KB(即offset为$2^{13}B$),虚拟地址长度为46bit的计算机,计算需要几级页表。
首先考虑不进行分级,那么只有一级页表,那么PTE的数量$N_{PTE}$(即所需的页数)计算公式为:
所以如果只有一级页表,需要$2^{33}$个PTE,每个PTE大小为32bit,那么$S{PTE}=2^{2}B$,所以页表占用的空间为$S{T}=N{PTE}\times S{PTE}=2^{35}B$。
现在考虑二级分页,在二级分页下,我们用一级页表指向二级页表,而二级页表的空间大小为$2^{35}B$,为了保存这么多的空间,我们需要$2^{35} B / 2^{13}B=2^{22}$个页面存放二级页表,所以一级页表的大小为$2^{22}$。
多进程时的物理内存(DRAM)
当多个进程同时执行时,实际物理地址可能是如下情况:
1 | =====Memory===== |
物理内存中保存有进程1和2的内容,但是存储方式可能是不连续的,同时,可能也只是部分存储,然后通过Cache的方式从Disk中再读取进程中的数据。我们通过逻辑地址和物理地址的映射关系,从而实现寻址过程。
内存分配策略
连续内存分配
定义
给进程分配一块不小于指定大小的连续物理内存空间
缺点
- 内存必须连续
- 存在外部碎片和内部碎片
- 动态修改困难
- 内存利用率低
内存碎片
内存释放后留下的空闲内存,可能没办法再利用,因为可能某个进程内存很小,而后续内存需要的进程都很大。碎片可以分为两种
- 外部碎片:分配单元之间的未被使用的内存
- 内部碎片:由于内存取整导致分配单元内的未被使用内存,例如有个进程想要399字节的内存,但是我只能分配512字节,就会产生没有用的碎片
动态分区分配
当程序加载执行时,分配一个进程指定大小的内存区域,分区的地址是连续的。在该策略下,操作系统需要维护的数据结构包括:
- 所有进程的已分配分区(链表)
- 空闲分区(链表)
假设进程申请的大小为$n$个字节,那么分配策略有
- 最先匹配(First Fit Allocation):找到第一个比$n$大的空闲块就分配,释放时检查是否可与临近分区合并。
- 优点是简单,高地址有大块空闲分区;
- 缺点是外部碎片多、分配大块时较慢
- 最佳匹配(Best Fit Allocation):设所有可分配的空间集合为$N$,那么相当于找$N$的上确界,即$\sup(N)$,即比$n$大,但是是$N$中最小的那个。需要查找所有的空间。空闲分区需要按照大小排序,释放时检查是否可与临近分区合并。
- 优点:可避免大的空间分区被拆分,减少外碎片尺寸
- 缺点:容易产生很多无用的小碎片,释放分区慢,因为要调整排序
- 最差匹配(Worst Fit Allocation):找最大的分配,空闲分区列表从大到小排列,分配时选最大分区,释放时检查是否可与临近分区合并,并调整顺序
- 优点:中等大小分配较多时,效果最好,可以避免出现太多小碎片
- 缺点:释放较慢,容易破坏大的空间分区,依然会有外部碎片
碎片整理3
当内存块不够给应用程序分配时,可以对碎片进行整理,产生大的可用块。常见方法有两种:
- 通过调整进程占用的分区位置,减少碎片化,但是这个需要进程可以动态重定位,需要解决下面的问题:
- 何时搬运进程
- 搬运开销有多大
- 分区对换:抢占并回收处于等待状态的进程的分区,而将原始分区保存至外存中,交替开销非常大,基本现在已经很少用了
伙伴系统4
非连续内存分配
非连续内存分配存在很多问题,例如找到一个大小合适的连续区域可能很难,因此我们提出了非连续内存分配的概念,实际就是我们的段页式分配。非连续分配的设计目标为提高内存效率和灵活性
- 允许一个程序使用非连续物理地址空间
- 允许共享代码和数据
- 支持动态加载和动态链接
非连续分配需要解决的问题
- 如何实现虚拟地址和物理地址的转换?虚拟地址是连续的,但是物理地址可能非连续
- 如何选择非连续分配中内存分块大小?段式管理和页式管理,段和段之间不连续、页和页之间不连续
懒分配(Lazy Allocation)
懒分配就是指直到进程真正使用内存时,才进行内存的分配,例如:
1 | public class Widget { |
上面的代码为一个单例模式的实现,直到get
函数第一次调用,即单例对象还未创建时,才触发内存分配。在linux内核中,懒分配指请求调页,他将页框分配推迟至不能再推迟时,再触发一个缺页异常,然后对这个异常进行处理,分配新的页面或者处理相应的内存错误。懒分配的优缺点如下:
- 优点:能够使系统有更大吞吐量
- 缺点:系统会有额外开销,每一个缺页异常都需要由内核进行处理,浪费了时钟周期,但是由局部性原理可知,缺页异常不会耗费太多的资源,可以将其视为一个稀有时间。
虚拟内存
概念
虚拟内存是指使用磁盘空间对内存进行扩展,当然其效率比不上真正的物理内存。虚拟内存采用连续完整的地址空间,对多个不连续的物理内存进行映射,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。
局部性原理
程序在执行时往往呈现局部性规律,在某个较短时间段内,程序执行局限在某一部分,访问的存储空间也局限在某一部分,这种局部体现在时间和空间两个方面。
- 时间局部性:如果程序中的某条指令一旦执行,不久以后该指令可能再次执行;如果某数据被访问过,不久以后该数据可能再次被访问。产生时间局部性的典型原因,是由于在程序中存在着大量的循环操作。
- 空间局部性:一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也将被访问,即程序在一段时间内所访问的地址,可能集中在一定的范围之内,这是因为指令通常是顺序存放、顺序执行的,数据也一般是以向量、数组、表等形式簇聚存储的。
虚拟存储器
操作系统将内存中暂时不使用的内容换到外存上,从而腾出空间存放将要调入内存的信息,当访问的内容不在内存上时,再从外存向内存进行调用。这样,计算机好像为用户提供了一个比实际内存大的多的存储器——虚拟存储器。例如,一个16MB的程序,通过仔细选择每个时刻将哪4MB内容保留在内存上,并在需要时在内存和磁盘间交换程序片段,可以使该程序运行在4MB的机器上
分页
64位系统一般分为4级页表,分别是PGD、PUD、PMD和PTE,硬件上有一个页表基地址寄存器,用于保存PGD页表首地址。在ARM上是TTBR0_ELx和TTBR1_ELx,区别如下:
- 低位虚拟地址空间位于0x0000,0000,0000,0000到0x0000,FFFF,FFFF,FFFF,
- 高位虚拟地址空间位于0x0000,FFFF,FFFF,FFFF
如果虚拟地址最高位为0,使用低位空间,并且使用TTBR0_ELx存放页表基地址,否则使用TTBR1_ELx
附录:一些简单的计算
16进制到KB、MB、GB的换算
我们一般用16进制表示地址位,那么16进制如何与KB、MB和GB这些单位进行换算呢?例如给定地址位为0xFFFFFFFF,其表示的是4GB所在的地址,计算过程如下:
而1GB=1024MB=1024*1024KB,这样我们就实现了从16进制到KB、MB、GB的换算
内存单位
在内存中,其基本单位为byte,即一个地址对应1 byte,不过有些时候我们需要更大一点的划分,例如一个int对应4byte,这里总结一下内存的一些常用单位
单位名 | 字母 | 长度(以byte记) | push命令 |
---|---|---|---|
byte | B | 1 byte (uint8) | push |
word | W | 2 byte (uint16) | pushl |
double word | D | 4 byte (uint32) | |
quad word | Q | 8 byte (uint64) |