Linux内核内存管理

1、内存模型概览

通常CPU可见的地址是有限制的,32位的CPU最多可见4GB的物理空间,64位的CPU可见的空间会更大。32位的系统一般都需要考虑通过“动态映射”方法拓展物理内存的可见性问题。
通常程序访问的地址都是虚拟地址,用32位操作系统来讲,访问的地址空间为4G,linux将4G分为两部分。如图1所示,其中0~3G为用户空间,3~4G为内核空间。通过MMU这两部分空间都可以访问到实际的物理内存。
进程在用户态只能访问0~3G,只有进入内核态才能访问3G~4G  
    *进程通过系统调用进入内核态
    *每个进程虚拟空间的3G~4G部分是相同的  
    *进程从用户态进入内核态不会引起CR3的改变但会引起堆栈的改变

Linux内核内存管理

现在的系统通常不止跑一两个程序,而每个程序又都可以看见和操作完整的地址。每个程序在程序可见的地址空间隔离是非常有必要的,于是有了虚拟的程序地址空间。每个进程见到的地址范围都是一样的,然而访问同一个地址返回的数据却不一定是一样的。 

Linux的内存管理主要出于三个目的:
  • 动态的物理内存的管理
  • 隔离的用户地址空间管理
  • 内存的分配与回收

在实际的需求中,用户可以申请内存,但申请的内存不一定会使用,因此内核也可以为用户预留内存,只是在其真正使用时才分配。这种内核机制叫作over_commit,就是内核可以为应用程序分配大于实际拥有的内存量。Linux内核会使用大量的空间缓存磁盘中的文件,这部分内存会用掉几乎所有的可用内存。当用户对内存有需求的时候,Linux就会回收一部分内存,用来满足用户的需求。所以表面看Linux系统的可用内存几乎永远为0,然而申请内存又通常可以成功。

2、内存组织方式

常见的x86组织方式。
Linux内核内存以页为单位,但整体被组织为zone(区域),一共有3个zone:
  • DMA,直接内存访问区,通常是物理内存起始的16MB,主要被一些外设使用,使得外围设备和主内存之间直接传输它们的I/O数据,而不需要系统CPU的参与。
  • Normal,从16MB-896MB内存区。
  • High,896MB以后的内存区。
内核空间又将1G的虚拟空间按区划分为3个部分:ZONE_DMA(内存开始的16MB) 、ZONE_NORMAL(16MB~896MB)、ZONE_HIGHMEM(896MB ~ 结束)。
ZONE_DMA zone包含低端物理内存适合于那些无法访问超过16MB物理内存的设备。ZONE_NORMAL则被直接映射到kernel线性地址空间的低地址区。我们将在4.1节进一步讨论。ZONE_HIGHMEM则是那些无法直接映射到kernel空间的剩余内存。

为什么要有高端内存的概念呢?
如果按固定的一对一地址映射,那内核只能访问1G物理内存。若机器中安装8G物理内存,那么内核就只能访问前1G物理内存,后面7G物理内存将会无法访问
那么如内核是如何借助128MB高端内存地址空间是如何实现访问可以所有物理内存?
当内核想访问高于896MB物理地址内存时,从0xF8000000 ~ 0xFFFFFFFF地址空间范围内找一段相应大小空闲的逻辑地址空间,借用一会。借用这段逻辑地址空间,建立映射到想访问的那段物理内存(即填充内核PTE页面表),临时用一会,用完后归还。这样别人也可以借用这段地址空间访问其他物理内存,实现了使用有限的地址空间,访问所有所有物理内存。

对一个内存节点来说,优先从HighMem分配,再从Normal或者DMA分配。
每一个zone都有三个watermarks:分别称为pages_low,pages_min和pages_high,可以用来判断zone当前的内存压力。每个zone区中都存在一套这三个参数,其中从小到大的顺序依次是watermark[min],watermark[low],watermark[high].
系统根据zone内page使用情况,在处于不同的watermark时,会采取不同的动作:
  • pages_low: 当空闲页面数目低于pages_low时,kswapd被buddy allocator唤醒释放pages。值缺省是pages_min的两倍。
  • pages_min: 当空闲页面数目低于pages_min时,allocator将代替kswapd同步释放pages,也就是直接回收
  • pages_high: 当kswapd被唤醒来释放pages时,回收的pages已经达到pages_high标记的页面数,kswapd将停止回收,进入休眠。缺省值一般为pages_min的三倍。
linux内存回收机制如何被触发:
当某个进程针对页面发出请求时,系统内核会首先检查首选项NUMA(非一致内存访问)区是否有足够的空余内存,以及是否存在1%以上的可回收的页面。这个百分比可以调节,并由vm.min_unmaped_ratio sysctl来决定。可回收页面属于由文件支持的页面(即与页面缓存存在映射关系的文件所产生的页面),但目前并未被映射到任何进程中的那些页面。在/proc/meminfo中,可以很清楚地看到,所谓"可回收页面(reclaimable pages)"就是那些"活动(文件)+非活动(文件)-被映射"(Active(file)+Inactive(file)-Mapped)的内容。

使用不是很广泛的MIPS组织方式。
MIPS,一种采用RISC的处理器架构。设计上的成功,未带来商业上的成功。
在MIPS 32CPU中,不经过MMU转换的内存窗口只有kseg0和kseg1的512MB的大小,而且这两个内存窗口映射到同一0~512MB的物理地址空间。其余的3GB虚拟地址空间需要经过MMU转换成物理地址。

3、高端内存

内核内存管理的基本单元是页,所以最基本的内容就是内核如何管理页。
高端内存是指物理地址大于 896M 的内存。对于这样的内存,无法在“内核直接映射空间”进行映射。
如果系统中的物理内存(包括内存孔洞)大于896MB,那么将前896MB物理内存固定映射到内核逻辑地址空间0xC0000000~0xC0000000+896MB(=high_memory)上,而896MB之后的物理内存则不建立到内核线性地址空间的固定映射,这部分内存就叫高端物理内存。此时内核线性地址空间high_memory~0xFFFFFFFF之间的128MB空间就称为高端内存线性地址空间,用来映射高端物理内存和I/O空间。896MB是x86处理器平台的经验值,留了128MB线性地址空间来映射高端内存以及I/O地址空间,在嵌入式系统中可以根据具体情况修改这个阈值,比如,MIPS中将这个值设置为0x20000000B(512MB)

高端内存映射有三种方式:
  • 映射到“内核动态映射空间”
  • 永久内核映射
  • 临时映射
Linux内核内存管理

拟地址、物理地址、逻辑地址、线性地址
 虚拟地址又叫线性地址。linux没有采用分段机制,所以逻辑地址和虚拟地址(线性地址)(在用户态,内核态逻辑地址专指下文说的线性偏移前的地址)是一个概念。物理地址自不必提。内核的虚拟地址和物理地址,大部分只差一个线性偏移量。用户空间的虚拟地址和物理地址则采用了多级页表进行映射,但仍称之为线性地址。由于内核的虚拟和物理地址只差一个偏移量:物理地址 = 逻辑地址 – 0xC0000000。所以如果1G内核空间完全用来线性映射,显然物理内存也只能访问到1G区间,这显然是不合理的。HIGHMEM就是为了解决这个问题,专门开辟的一块不必线性映射,可以灵活定制映射,以便访问1G以上物理内存的区域。

1)线性地址空间:是指Linux系统中从0x00000000到0xFFFFFFFF整个4GB虚拟存储空间。
2)内核空间:内核空间表示运行在处理器*别的超级用户模式(supervisor mode)下的代码或数据,内核空间占用从0xC0000000到0xFFFFFFFF的1GB线性地址空间,内核线性地址空间由所有进程共享,但只有运行在内核态的进程才能访问,用户进程可以通过系统调用切换到内核态访问内核空间,进程运行在内核态时所产生的地址都属于内核空间。
3)用户空间:用户空间占用从0x00000000到0xBFFFFFFF共3GB的线性地址空间,每个进程都有一个独立的3GB用户空间,所以用户空间由每个进程独有,但是内核线程没有用户空间,因为它不产生用户空间地址。另外子进程共享(继承)父进程的用户空间只是使用与父进程相同的用户线性地址到物理内存地址的映射关系,而不是共享父进程用户空间。运行在用户态和内核态的进程都可以访问用户空间。
4)内核逻辑地址空间:是指从PAGE_OFFSET(3G)到high_memory(物理内存的大小,最大896)之间的线性地址空间,是系统物理内存映射区,它映射了全部或部分(如果系统包含高端内存)物理内存。内核逻辑地址空间中的地址与RAM内存物理地址空间中对应的地址只差一个固定偏移量(3G),如果RAM内存物理地址空间从0x00000000地址编址,那么这个偏移量就是PAGE_OFFSET。
5)低端内存:内核逻辑地址空间所映射物理内存就是低端内存(实际物理内存的大小,但是小于896),低端内存在Linux线性地址空间中始终有永久的一一对应的内核逻辑地址,系统初始化过程中将低端内存永久映射到了内核逻辑地址空间,为低端内存建立了虚拟映射页表。低端内存内物理内存的物理地址与线性地址之间的转换可以通过__pa(x)和__va(x)两个宏来进行,#define __pa(x) ((unsigned long)(x)-PAGE_OFFSET) __pa(x)将内核逻辑地址空间的地址x转换成对应的物理地址,相当于__virt_to_phys((unsigned long)(x)),__va(x)则相反,把低端物理内存空间的地址转换成对应的内核逻辑地址,相当于((void *)__phys_to_virt((unsigned long)(x)))。
6)高端内存:低端内存地址之上的物理内存是高端内存(物理内存896之上),高端内存在Linux线性地址空间中没有固定的一一对应的内核逻辑地址,系统初始化过程中不会为这些内存建立映射页表将其固定映射到Linux线性地址空间,而是需要使用高端内存的时候才为分配的高端物理内存建立映射页表,使其能够被内核使用,否则不能被使用。高端内存的物理地址于线性地址之间的转换不能使用上面的__pa(x)和__va(x)宏。
7)高端线性地址空间:从high_memory到0xFFFFFFFF之间的线性地址空间属于高端线性地址空间,其中VMALLOC_START~VMALLOC_END之间线性地址:(1)被vmalloc()函数用来分配物理上不连续但线性地址空间连续的高端物理内存,或者(2)被vmap()函数用来映射高端或低端物理内存,或者(3)由ioremap()函数来重新映射I/O物理空间。其中PKMAP_BASE开始的LAST_PKMAP(一般等于1024)页线性地址空间:被kmap()函数用来永久映射高端物理内存。FIXADDR_START开始的KM_TYPE_NR*NR_CPUS页线性地址空间:被kmap_atomic()函数用来临时映射高端物理内存,其他未用高端线性地址空间可以用来在系统初始化期间永久映射I/O地址空间。

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

    为什么要使用bootmem分配器,内存管理不是有buddy系统和slab分配器吗?由于在系统初始化的时候需要执行一些内存管理,内存分配的任务,这个时候buddy系统,slab分配器等并没有被初始化好,此时就引入了一种内存管理器bootmem分配器在系统初始化的时候进行内存管理与分配,当buddy系统和slab分配器初始化好后,在mem_init()中对bootmem分配器进行释放,内存管理与分配由buddy系统,slab分配器等进行接管。
     bootmem分配器使用一个bitmap来标记物理页是否被占用,分配的时候按照第一适应的原则,从bitmap中进行查找,如果这位为1,表示已经被占用,否则表示未被占用。为什么系统运行的时候不使用bootmem分配器呢?bootmem分配器每次在bitmap中进行线性搜索,效率非常低,而且在内存的起始端留下许多小的空闲碎片,在需要非常大的内存块的时候,检查位图这一过程就显得代价很高。bootmem分配器是用于在启动阶段分配内存的,对该分配器的需求集中于简单性方面,而不是性能和通用性。

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

5、Mempool

linux 2.6引入了mempool(内存池)。
内核中有些地方的内存分配是不允许失败的。为了确保这种情况下的成功分配,内核开发者建立了一种称为内存池的抽象。内存池其实就是某种形式的后备高速缓存,它试图始终保存空闲的内存,以便在紧急状态下使用。正常情况下,分配对象时是不会去动mempool里面的资源的,照常通过slab去分配。到系统内存紧缺,已经无法通过slab分配内存时,才会使用 mempool中的内容。

6、CMA(连续内存分配器)

Contiguous Memory Allocator是智能连续内存分配技术是Linux Kernel内存管理系统的扩展。在有这个之前,想达到同样的目的,基本上只能在启动的时候使用bootmem预留,但这样预留的代价是Linux系统启动后,这部分内存对于内核不可用。而用户预留的内存又不一定一直在使用,从而导致内存的使用率偏低。
CMA其工作原理是:预留一段的内存给驱动使用,但当驱动不用的时候,memory allocator(buddy system)可以分配给用户进程用作匿名内存或者页缓存。而当驱动需要使用时,就将进程占用的内存通过回收或者迁移的方式将之前占用的预留内存腾出来, 供驱动使用。

7、伙伴算法(buddy memory allocation

在系统运行过程中,经常需要分配一组连续的页,而频繁的申请和释放内存页会导致内存中散布着许多不连续的页,这样,当某一时刻要申请一块较大的连续内存时,虽然系统内存余量足够,即很多页是空闲的,但找不到一大块连续的内存供使用。
内存管理最重要的两个指标莫过于:减少碎片,提高利用率; 分配和释放的速度要快。
Linux内核中使用伙伴系统(buddy system)算法来管理内存页。它是一种更快的内存分配技术,它将内存划分为 2 的幂次方个分区,并使用 best-fit 方法来分配内存请求。当用户释放内存时,就会检查 buddy 块,查看其相邻的内存块是否也已经被释放。如果是的话,将合并内存块以最小化内存碎片。这个算法的时间效率更高,但是由于使用 best-fit 方法的缘故,会产生内存浪费。它把所有的空闲页放到11个链表中,每个链表分别管理大小为1,2,4,8,16,32,64,128,256,512,1024个页的内存块。当系统需要分配内存时,就可以从buddy系统中获取。例如,要申请一块包含4个页的连续内存,就直接从buddy系统中管理4个页连续内存的链表中获取。当系统释放内存时,则将释放的内存放回buddy系统对应的链表中,如果释放内存后发现有两块相邻的内存又可以合并为一个更高阶的内存块,例如释放4个页,而恰好相邻的内存也为4个页的空闲内存,则合并这两块内存并放到buddy系统管理8个连续页的链表中。同样的,如果系统需要申请3个页的连续内存,则只能在4个页的链表中获取,剩下的一个页被放到buddy系统中管理1个页的链表中。
buddy分配器分配的最小单位是一个页。要分配小于一页的内存需要用到slab分配器,而slab是基于buddy分配器的。

合理的分发加上有效的回收构成了Linux内存管理的核心。

8、slab

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

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

slab在NUMA上的适应能力不好,因此在精简了slab的结构体后,推出了slub,增加了对NUMA的适应能力。

在嵌入式系统中要求低功耗,因此出现了精简版的slab,即slob。但slob的使用,会增加内存分配 的碎片化概率,本质上是降低了效率。由于目前嵌入式系统的计算能力越发强大,slob基本已经被淘汰了。

9、用户端内存管理基础组件

malloc的全称是memory allocation,中文叫动态内存分配,当无法知道内存具体位置的时候,想要绑定真正的内存空间,就需要用到动态的分配内存。
C语言提供了动态内存管理功能, 在C语言中, 程序员可以使用 malloc() 和 free() 函数显式的分配和释放内存.
那么malloc到底是从哪里获取的内存呢? 
答案是从堆里面获得空间;malloc的应用必然是某一个进程调用,而每一个进程在启动的时候,系统默认给它分配了heap。
下面我们就看看进程的内存空间布局:这个是x86 虚拟地址空间的默认布局
Linux内核内存管理           Linux内核内存管理

当前针对各大平台主要有如下几种堆内存管理机制:
  • dlmalloc – General purpose allocator
  • ptmalloc2 – glibc
  • jemalloc – FreeBSD and Firefox
  • tcmalloc – Google
  • libumem – Solaris
本来linux默认的是dlmalloc,但是由于其不支持多线程堆管理,所以后来被支持多线程的prmalloc2代替了。
当然在linux平台*malloc本质上都是通过系统调用brk或者mmap实现的。
tcmalloc是谷歌提供的内存分配管理模块
jemalloc是FreeBSD提供的内存分配管理模块
glibc是Linux提供的内存分配管理模块

函数调用关系图:
Linux内核内存管理

GNU Libc 的内存分配器( allocator ) — ptmalloc 起源于 Doug Lea 的 malloc (请参看[1]). ptmalloc 实现了 malloc() , free() 以及一组其它的函数. 以提供动态内存管理的支持. allocator 处在用户程序和内核之间, 它响应用户的分配请求, 向操作系统申请内存, 然后将其返回给用户程序, 为了保持高效的分配, allocator 一般都会预先分配一块大于用户请求的内存, 并通过某种算法管理这块内存. 来满足用户的内存分配要求, 用户 free 掉的内存也并不是立即就返回给操作系统, 相反, allocator 会管理这些被 free 掉的空闲空间, 以应对用户以后的内存分配要求. 也就是说, allocator 不但要管理已分配的内存块, 还需要管理空闲的内存块, 当响应用户分配要求时, allocator 会首先在空闲空间中寻找一块合适的内存给用户, 在空闲空间中找不到的情况下才分配一块新的内存. 为实现一个高效的 allocator, 需要考虑很多的因素. 比如, allocator 本身管理内存块所占用的内存空间必须很小, 分配算法必须要足够的快.

在glibc malloc中将整个堆内存空间分成了连续的、大小不一的chunk,即对于堆内存管理而言chunk就是最小操作单位。Chunk总共分为4类:1)allocated chunk; 2)free chunk; 3)top chunk; 4)Last remainder chunk。从本质上来说,所有类型的chunk都是内存中一块连续的区域,只是通过该区域中特定位置的某些标识符加以区分。

jemalloc/tcmalloc在多核情况下表现非常优秀,大部分情况下,jemalloc/tcmalloc都会是理想的选择。

处理glibc的内存泄漏问题,一般可以使用mtrace命令查看malloc内存的申请和释放信息。这需要在进程中添加一行代码(setenv("MALLOC_TRACE","output",1)),这样进程就会产生一个内存文件,mtrace能读取这个文件从而输出报告。
大部分的内存泄漏检测都会有更成熟的工具,目前使用量最大的应该是valgrind套件。

10、BDI(backing device info)

在Linux的sys下浏览到每一个设备时会很容易看到一个BDI目录,它用于描述备用存储设备相关的描述信息,这在内核代码里用一个结构体backing_dev_info来表示。它是备用存储设备,简单地说就是能够用来存储数据的设备,而这些设备存储的数据能够保证在计算机电源关闭时也不会丢失。所以像USB存储设备、硬盘存储设备都是所谓的备用存储设备,而内存显然不是。

没有BDI的磁盘设备也是可以正常工作的。但是BDI为所有的磁盘设备提供了高层次的数据缓存功能。这个缓存层位于文件系统的下层和通用块层的上层,显然这个缓存功能属于内存管理的一部分。

相对于内存来说,BDI后端设备的读写速度是非常慢的,因此为了提高系统的整体性能,Linux系统对BDI设备的读写内容进行了缓冲,那些读写的数据会临时保存在内存里,以避免每次都直接操作BDI设备。但这就需要在一定的时机把它们同步到BDI设备中,否则容易丢失。
负责这个数据同步工作的进程,在2.6.32之前名叫pdflush,此后的内核版本中对此做了优化,改成了bdi_writeback机制。会产生多个内核进程,bdi-default、flush-x:y等。这种变化主要解决bdi_writeback机制为每个磁盘都创建一个线程,专门负责这个磁盘的page cache或者buffer cache的数据刷新工作,从而实现了每个磁盘的数据刷新程序在线程级的分离,这种处理可以提高IO性能。

bdi-default、flush-x:y(事实上flush-x:y为多个)的关系为父子的关系,即bdi-default根据当前的状态create或destroy flush-x:y,x为块设备类型;y为此类型设备的序号


参考资料:
《深入Linux内核架构与底层原理》