Linux内核工程导论——内存管理(一)

时间:2022-08-28 21:49:18

Linux内存管理

概要

物理地址管理

很多小型操作系统,例如eCos,vxworks等嵌入式系统,程序中所采用的地址就是实际的物理地址。这里所说的物理地址是CPU所能见到的地址,至于这个地址如何映射到CPU的物理空间的,映射到哪里的,这取决于CPU的种类(例如mips或arm),一般是由硬件完成的。对于软件来说,启动时CPU就能看到一片物理地址。但是一般比嵌入式大一点的系统,刚启动时看到的已经映射到CPU空间的地址并不是全部的可用地址,需要用软件去想办法映射可用的物理存储资源到CPU地址空间。

       通常CPU可见的地址是有限制的,32位的CPU最多看见4G的物理空间,64位的就更大了。所以目前的应用64位可能不需要考虑物理内存CPU可见物理空间的问题,然而32位的基本都是要考虑的。这就诞生了一个需求:动态映射。

       在linux系统中,例如x86架构,由于CPU可见的3G的空间给了用户程序,内核仅留下了1G,而存储的映射都要映射到这1G的,所以大于1G的内存不实用动态映射都无法访问。

       简单的说,就是当需要一个空白内存页的时候动态的将某个物理内存映射到一个地址,再需要就换下已经使用过的重新映射新的到这个地址。

应用程序地址空间隔离

       另外一个需求是现代的系统通常不止跑一两个程序,而每个程序又都可以看见和操作完整的地址,如此安装别人发布的进程就是一个危险性很高的操作。嵌入式系统的容易处理,但PC机就难以处理这个问题。因此每个程序在程序可见的地址空间隔离是非常必要的。于是有了虚拟的程序地址空间。每个进程见到的地址范围都是一样的,然而其访问同一个地址返回的数据却是不一样的。

申请和释放内存

       无论是用户程序还是内核程序,都需要使用内存,所以如何高效的分配和回收内存就是一个很重要的话题。

       实际的需求中,用户可以申请内存,但申请的内存不一定会使用,因此内核也可以不真实的为其预留内存,只是在其真正使用的时候才分配。这种内核机制叫做over_commit,就是内核可以为应用程序分配大于实际拥有的内存量。

       Linux内核会使用大量的空间缓存磁盘中的文件,这部分内存会用掉几乎所有的可用内存。当用户程序对内存有需求的时候,linux就会回收这部分内存的一部分,用来满足用户需要。所以,在linux程序的眼里,linux系统的可用内存几乎永远为0,然而申请内存又通常可以成功。

       这一些内存针对各个功能的需求而设计的机制共同组成了linux的内存管理机制。离开具体功能的内存管理机制是没有意义的。

       如此,内存管理主要有三个需求:动态的物理内存的管理、隔离的用户地址空间的管理和分配和回收内存。

源代码文件结构

       内存相关的,无论是基础架构还是服务于特定需求的代码都位于mm目录下。相关的头文件位于include/mm下。linux内核发展至今,基本的功能已经夯实,辅助功能越来越多。例如kasan用于内存的错误边界检查等。

 Linux内核工程导论——内存管理(一)

内存组织方式

X86组织

       Linux内核内存以页为单位,但整体被组织为zone。一共有3个zone,DMA、Normal和High。DMA是由于有的硬件架构的DMA只能访问一部分地址(如intel的DMA只能访问低16M地址),有的系统可用物理内存远远超过了CPU可见的内存空间,如32位的CPU对于4G以上的内存就无法全部静态映射。但是由于linux的虚拟内存机制,内核能使用的所有空间仅有1GB(在一些架构可变)。

一般来说Linux 内核按照 3:1 的比率来划分虚拟内存(X86等):3 GB 的虚拟内存用于用户空间,1GB 的内存用于内核空间。当然有些体系结构如MIPS使用2:2 的比率来划分虚拟内存:2 GB 的虚拟内存用于用户空间,2 GB 的内存用于内核空间,另外像ARM架构的虚拟空间是可配置(1:3、2:2、3:1)。

以x86为例linux中内核使用3G-4G的线性地址空间,也就是说内核总共只有1G的地址空间可以用来映射物理地址空间。但是,如果内存大于1G的情况下内核态线性地址就不够用了。为此内核引入了一个高端内存的概念,把1G的线性地址空间划分为两部分:小于896M物理地址空间的称之为低端内存,这部分内存的物理地址和3G开始的线性地址是一一对应映射的,也就是说内核使用的线性地址空间(VA)3G--(3G+896M)和物理地址空间(PA)0-896M一一对应,PAGE_OFFSET=0xC0000000;剩下的128M的线性空间用来映射剩下的大于896M的物理地址空间,这也就是我们通常说的高端内存区,这部分空间需要MMU通过TLB表来建立动态的映射关系。

也就是说,在linux下x86的32位系统,真正可以静态映射的内存只有896MB。当内存大于1G时就需要使用高端内存了,否则大于1G的内存就无法使用。所以三个内存的zone,前16MB对应着内核空间的0-16MB,Normal区对应着16-896MB,HIGH区对应着896-1G的动态区,可用大小实际是可变的。从这里我们可以看出如果不需要DMA区(DMA无限制),该区可以删除,如果内存不超过896MB,highmem区也可以删除。

因为内核在响应请求分配空间时是在3个区中都分配的,优先是normal,回收的时候也是3个区都执行回收的。如果能去掉一个区,对于很多内存操作就能节省很多执行代价。

Mips组织

Mips的highmem管理可参见其官方简介:http://www.linux-mips.org/wiki/Highmem

在MIPS32 CPU中不经过MMU转换的内存窗口只有kseg0和kseg1 的512MB的大小,而且这两个内存窗口映射到同一得0~512M的物理地址空间。其余的3G虚拟地址空间需要经过MMU转换成物理地址,这个转换规则是由CPU 厂商实现的。换句话说,在MIPS32 CPU下面访问高于512M的物理地址空间,必须通过MMU地址转换。即按VA=PA+PAGE_OFFSET公式映射的空间最大只有512M,其中PAGE_OFFSET=0x80000000,而在Linux中MIPS32只使用其中的256MB。

 MIPS在higmem使用过程中需要注意两个问题:一是要考虑由higmem带来的整个系统性能和稳定性间的平衡,二是highmem不支持cache aliases。

高端内存

高端内存映射有三种方式:

1.      临时映射空间

固定映射空间是内核线性空间中的一组保留虚拟页面空间,位于内核线性地址的末尾即最高地址部分。其地址编译时确定,用于特定用途(如VSYSCALL系统调用,MIPS的cache着色)。由枚举类型  fixed_addresses决定,内核在FIXADDR_START 到 FIXADDR_TOP 之间

在这个空间中,有一部分用于高端内存的临时映射。这块空间具有如下特点:每个 CPU 占用一块空间;可以用在中断处理函数和可延迟函数的内部,从不阻塞,禁止内核抢占;在每个 CPU 占用的那块空间中,又分为多个小空间,每个小空间大小是 1 个 page,每个小空间用于一个目的,这些目的定义在 kmap_types.h 中的 km_type 中。

当要进行一次临时映射的时候,需要指定映射的目的,根据映射目的,可以找到对应的小空间,然后把这个空间的地址作为映射地址。这意味着一次临时映射会导致以前的映射被覆盖。

接口函数:kmap_atomic/kunmap_atomic。使用从FIX_KMAP_BEGIN到FIX_KMAP_END之间的物理页

2.      长久映射空间

长久映射地址空间是预留的线性地址空间。访问高内存的一种手段。使用方式是先通过alloc_page() 获得了高端内存对应的 page,然后内核从专门为此留出的线性空间分配一个虚拟地址,在 PKMAP_BASE 到 FIXADDR_START 之间。

接口函数:void*kmap(struct*page)、 void kumap(struct*page)

该接口函数在高/低内存都能使用,可以睡眠,数量有限。对于不使用的的 page,及应该时从这个空间释放掉(也就是解除映射关系)。

#definePKMAP_BASE ((FIXADDR_BOOT_START -PAGE_SIZE * (LAST_PKMAP + 1)) & PMD_MASK)

#defineLAST_PKMAP 1024

3.      非连续映射地址空间

非连续映射地址空间适用于为不频繁申请释放内存的情况,这样不会频繁的修改内核页表。总的来说,内核主要在以下情况使用非连续映射地址空间:映射设备的I/O空间;为内核模块分配空间;为交换分区分配空间

每个非连续内存区都对应一个类型为vm_struct 的描述符,通过next字段,这些描述符被插入到一个vmlist链表中。

这种方式下高端内存使用简单,因为通过vmalloc() ,在”内核动态映射空间“申请内存的时候,就可能从高端内存获得页面(参看 vmalloc 的实现),因此说高端内存有可能映射到”内核动态映射空间“中。

接口函数:vmalloc(vfree):物理内存(调用alloc_page)和线性地址同时申请,物理内存是__GFP_HIGHMEM类型(分配顺序是HIGH, NORMAL ,DMA )(可见vmalloc不仅仅可以映射HIGHMEM页框,它的主要目的是为了将零散的,不连续的页框拼凑成连续的内核逻辑地址空间... );

vmap(vumap):vmalloc的简化版;

ioremap(iounmap):分配I/O映射空间;

下图简单表达了linux内核对虚拟地址的映射,其中highmem区域用于对高端内存映射

申请和释放内存

       我们知道现代操作系统的内存都是按页划分组织的,可能不同的系统会在页之上添加页组等概念。但是内核内存管理的基本单元是页。所以,最基本的就是内核如何管理页。

Linux内核工程导论——内存管理(一)

启动时内存的申请和释放:bootmem

       Linux启动时的各个模块也有申请和释放内存的需求,但是此时内核的内存模型还没建立好。于是linux就提供了一个专门用在此时的内存接口bootmem,这个接口很简单,以页为单位,简单的搜索满足需求的连续页空间分配,并且可以应对物理上不连续的存储体。

       这个内存机制还有个最广泛使用的技巧,就是分配超大额的连续内存。因为在系统启动前,这个需求是容易满足的,但是启动后,由于模块众多,内存使用频繁换手,物理连续的内存很难得到,在启动时直接通过bootmem接口预留连续的物理内存后续使用是不二的选择。

       内核完全启动后,bootmem机制不再有效。

启动时ioremap:early_ioremap

       启动时ioremap调用还没有就绪,这时就提供一个早期的ioremap调用,叫做early_ioremap。

申请可DMA的内存

       Dmapool

Mempool

         linux通过slab伙伴系统分配内存,这种方式虽然可以做到在内存不足的时候进行回收来获得内存,但是没办法保证关键路径的内存获得稳定性。用户端编程中也经常用到一个手法就是内存池,在程序运行的开始就分配一些独占内存,如此在运行过程中,就可以确保在一定的时间内获得指定的内存。这是以牺牲使用效率换取稳定服务的做法。


Cma

连续内存分配器。在有这个之前,想要预留一大块连续的内存,基本只能使用bootmem在启动的时候预留,如此预留带来的代价就是linux启动后这部分内存对于内核不可用。而用户预留的内存又不一定一直在使用,导致内存的利用率低。

伙伴算法

       内存在底层是以页为单位分配的,上层一些的分配器如内核的slab,用户控件的malloc等都是在后台先申请了足够的页之后再对用户就行分配。如此后台关于如何申请页就有很多种思路,这些思路的最主要的评价标准有两个:如何最快,如何碎片最少。

       伙伴算法最被广泛使用的,该算法的核心思想是把内存提前分为大小不同的一系列内存块,当申请内存的时候返回最贴近需求内存大小的内存块,没有的合适大小的时候就可能拆分更大的。通过提前的安排,在牺牲内存利用率的前提下,尽可能的实现非碎片化。这个思想也不是一直有效,后来人们还加入了内存页的回收类型属性:可回收、可移动、不可回收。相当于定期的对磁盘进行磁盘整理来让不连续的空闲内存块重新连续起来。由于用户程序使用的内存页都是动态映射来的,所以后台只需要替换一下映射就能实现对用户程序透明的页面置换,所以这种做法的效率也是不错的。
       除了在分配上注意不产生碎片,内核也会定期的回收已经分发出去的页面。合理的分发加上有效的回收构成了linux内核管理的核心。

 

Slab

       内核中有很多常用的结构体,如果使用传统的根据大小进行动态分配,将会频繁的搜索链表,显然使用pool思想更合适.又由于常用结构体有很多,不可能为每一个定义一个池类型,合理的做法应该是尽可能的通用,这个被设计出来的结构体池就是slab内存管理机制.

       slab内存管理机制的得名是由于其将一种结构体的内存池命名为slab,内核中同时存在多个slab,分别是不同的常用结构体的池.为了适应SMP,让每个CPU都管理一系列的独立的slab.

       但是slab在numa上适应能力不行,slub在slab的基础上增加了numa的适应能力,还精简了slab的结构体,提高了slab的效率,但与slab提供的调用接口是一样的.

       二slob则是精简版的slab,增加了内存分配的碎片化概率,本质上是降低了效率,但是需要更少的资源开销(内存和CPU),所以大部分slob是应用在嵌入式系统中,但是目前的嵌入式系统的计算能力也普遍强大,所以slob基本退出历史舞台。

 

内存策略:policy

       由于numa的出现,让用户程序可以控制自己的内存在哪里申请就有必要了。MPOL_DEFAULT, MPOL_PREFERRED, MPOL_INTERLEAVE和MPOL_BIND,内存策略会从父进程继承。

       使用的内存还可以移动,这是内存的migrate功能。