Linux的内存管理---学习笔记

内存管理

README

在内核里分配内存可不像在其他地方分配内存那么容易·造成这种局而的因素很多·从根本上讲,是因为内核本身不能像用户空间那样奢侈地使用内存·内核与用户空间不同,它不具备这种能力,它不支持简单便撻的内存分配方式·比如,内核一般不能睡眠.此外,处理内存分配错误对内核来说也绝非易事·正是由于这些限制·再加上内存分配机制不能太复杂·所以在内核中获取内存要比在用户空间复杂得多·不过·从程序开发者角度来看·也不是说内核的内存分配就困难得不得了,只是和用户空间中的内存分配不太一样而已.

  • 本文讨论的是在内核之中获取内存的方法·在深入研究实际的分配接口之前,我们需要理解内核是如何管理内存的.
  • 内核把物理页作为内存管理的基本单位·尽管处理器的最小可寻址单位通常为字(甚至字节)·但是,内存管理单元(MMU,管理内存并把虚拟地址转換为物理地址的硬件)通常以页为单位进行处理.
  • 正因为如此,MMU以页(page)大小为单位来管理系统中的页表(这也是页表名的来由).从虚拟内存的角度来看·页就是最小单位·
  • 体系结构不同,支持的页大小也不尽相同,还有些体系结构甚至支持几种不同的页大小.大多32位体系结构支持4KB的页·而64位体系结构一般会支持8KB的页·这就意味着,在支持4KB页大小并有1GB物理内存的机器上·物理内存会被划分为262144个页·

API

内核用struct page结构表示系统中的每个物理页,该结构位于<linux/mm_types.h>中.一一簡化了定义,去除了两个容易混淆的,我们讨论主要的联合结构体:

img

让我们看一下其中比较重要的域·flag用来存放页的状态·这些状态包括页是不是脏的,

是不是被锁定在内存中等.flag的每一位单独表示一种状态,所以它至少可以同时表示出32种不同的状态·这些标志定义在<linux/page_flags>中·

_count域存放页的引用计数一一也就是这一页被引用了多少次·当计数值变为-1时,就说明当前内核并没有引用这一页.于是,在新的分配中就可以使用它·内核代码不应当直接检测该域·而是调用page_count()函数进行检查,该函数唯一的参数就是page结构·当页空闲时,尽管该结构内部的_count值是负的,但是对page_count()函数而言,返回0表示页空闲,返回一个正整数表示页在使用·一个页可以由页缓存使用(这时,mapping域指向和这个页关联的addresss_space对象),或者作为私有数据(由private指向)·或者作为进程页表中的映射.

virtual域是页的虚拟地址·通常情况下,它就是页在虚拟内存中的地址·有些内存(即所谓的高端内存)并不永久地映射到内核地址空间上·在这种情况下,这个域的值为NULL,需要的时候,必须动态地映射这些页·稍后我们将讨论高端内存·

  • 必须要理解的一点是page结构与物理页相关,而并非与虚拟页相关.因此,该结构对页的描述只是短暂的·即使页中所包含的数据续存在,由于交换等原因,它们也可能并不再和同一个page结构相关联·内核仅仅用这个数结构来描述当前时刻在相关的物理页中存放的东西·

  • 这种数据结构的目的在于描述物理内存本身,而不是描述包含在其中的数据.

内核用这一结构来管理系銃中所有的页,因为内核需要知道一个页是否空闲(也就是页有没有被分配).如果页已经被分配,内核还需要知道谁拥有这个页·拥有者可能是用户空间进程、动态分配的内核数据、静态内核代码或页高速缓存等·

系统中的每个物页都要分配一个这样的结构体,开发者常常对此感到惊讶·他们会想,"这得浪费多少内存呀”!让我们来算算对所有这些页都这么做,到底要消耗掉多少内存·就算struct page占40字节的内存吧,假定系统的物理页为8KB大小·系統有4GB物理内存·那么,系统中共有页面524288个,而描述这么多页面的page结构体消耗的内存只不过是20MB:也许绝对值不小,但是相对系4GB内存而言·仅是很小的一部分罢了·因此,要管理系统中这么多物理页面,这个代价并不算太高·

由于硬件的限制·内核并不能对所有的页一视同仁·有些页位于内存中特定的物理地址上·所以不能将其用于一些特定的任务·由于存在这种限制,所以内核把页划分为不同的区(zone)·内核使用区对具有相似特性的页进行分组.Linux必须处理如下两种由于硬件存在缺陷而引起的内存寻址问题:

  • 一些硬件只能用某些特定的内存地址来执行DMA(直接内存访同)·
  • 一些体系结构的内存的物理址范围比虚拟地址范围大得多.这样,有一些内存不能永久地映射到内核空间上·
  • 因为存在这些制约条件,Linux主要使用了四种区:
  • ZONE_DMA一一一这个区包含的页能用来执行DMA操作·
  • ZONE_DMA32—和ZOME_DMA类似·该区包含的页面可用来执行DMA操作:而和ZONE_DMA不同之处在于·这些页面只能被32位设备访问·在某些体系结构中,该区域将比ZONE_DMA更大·
  • ZONE一NORMAL一一这个区包含的都是能正常映射的页
  • ZONE_HIGHEM—这个区包含“高端内存"其中的页并不能永久地映射到内核地址空间·
    这些区(还有两种不大重要的)在<linux/rnmzone.h>中定义·

  • 这些内存区的实际使用和分布是与体系结构相关的·

  • 例如,某些体系结构在内存的任何地址上执行DMA都没有问题, ·在这些体系结构中·ZONE_DMA为空,ZONE_NORMAL就可以直接用于分配.与此相反,在x86体系结构上,ISA设备就不能在整个32位( 有些糟糕的PCI设备只在在24位地址空间内\执行DMA操作 )的地址空间中执行DMA,因为ISA设备只能访问物理内存的前16MB.因此,ZONE_DMA在x86上包含的页都在0--16MB的内存范围里·

  • _HIGHMEM的工作方式也差不多·能否直接映射取决于体系结构.在32位x86系统上,ZONE_HIGHMEM为高于896MB的所有物理内存.在其他体系结构上·由于所有内存都被直接映射,所以ZONE_HIGHMEM为空·ZONE_HIGHMEM所在的内存就是所谓的高端内存(high memory).系繞的其余内存是所谓的低内存(low memory).

  • 各取所需之后,剩余的就由ZONE_NORMAL区独享了.在x86上,ZONE_NORMAL是从16MB到896MB的所有物理内存.在其他(更幸运)的体系结构上,ZONE_NORMAL是所有的可用物理内存·

  • 表12-1是每个区及其在x86-32上所占页的列表·

img

Linux把系銃的页划分为区,形成不同的内存池,这样就可以根据用途进行分配了·例如,ZONE_DMA内存池让内核有能力为DMA分配所需的内存·如果需要这样的内存,那么·内核就可以从ZONE_DMA中按照请求的数目取出页·注意,区的划分没有任何物理意义·这只不过是内核为了箐理页而采取的一种逻辑上的分组.

某些分配可能需要从特定的区中获取页,而另外一些分配则可以从多个区中获取页.比如·尽管用于DMA的内存必须从ZONE_DMA中进行分配·但是一觳用途的内存却能从ZONE_DMA分配·也能从ZONE_NORMAL分配,不过不可能同时从两个区分配,因为分配是不能跨区界限的·当然·内核更希望一般用途的内存从常规区分配·这样能节省ZONE_DMA中的页,

保证满足DMA的使用需求·但是,如果可供分配的资不够用了(如果内存已变得很少了),那么,内核就会去占用其他可用区的内存·不是所有的体系结构都定义了全部区,有些64位的体系结构,如intel的x86-64体系结构可以映射和处理64位的内存空间·所以x86-64没有ZONE_HIGHMEM区,所有的物理内存都处于ZONE_DMA和ZONE_NORMAL区·

  • NOTE: (Linux的高端内存和DOS的高端内存没有关系,DOS的高端内存是围绕DOS和x86的"实模式"的空间范围限制而言的)

    • 每个区都由struct zone表示,在<linux/mmzone.h>中定义:

img

这个结构体很大,但是,系统中只有三个区.因此,也只有三个这样的结构,让我们着一下其中一些要的域.

  • lock域是一个自旋锁,它防止该结构被并发访问·

    • 注意,这个域只保护结构体,而不保护驻留在这个区中的所有页·没有特定的锁来保护单个页,但是,部分内核可以锁住在页中驻留的watermark数组持有该区的最小值、最低和最高水位值.内核使用水位为每个内存区设置合适的内存消耗基准·该水位空内存的多少而变化.
  • name域是一个以NULL结束的字符串表示这个区的名字·内核启动期间初始化这个值,其代码位于mm/page_alloc.c中 ,三个区的名字分别为"DMA","Normal"和"HighMem"

获得页

  • 我们己经对内核如何管理内存(页、区等)有所了解了,现在让我们看一下内核实现的接口,我们正是通过这些接口在内核分配和释放内存的.

  • 内核提供了一种请求内存的底层机制,并提供了对它进行访问的几个接口·所有这些接口都以页为单位分配内存,定义于<linux/gfp.h>中.最核心的函数是:

img

该函数分配2^(order) ,,即(1<<order)个连续的物理页,并返回一个指针.该指针指向第一个页的page结构体:如果出错,就返回NULL.

  • 你可以用下面这个函数把给定的页转换成它的逻辑地址:

img

  • 释放内存页

该函数返回一个指针,指向给定物理页当前所在的逻辑地址·如果你无须用到struct_page.

你可以调用:

img

这个函數与alloc_pages()作用相同,不过它直接返回所请求的第一个页的逻辑地址·因为页是连续的·所以其他页也会紧随其后.

  • 如果你只需一页,就可以用下面个封装好的函数,它能让你少敲几下键盘:

img

这两个函数与其兄弟函数工作方式相同,只不过传递给order的值为0(2^0 = 1页)

  • 获得填充为0的页

如果你需要让返回的页的内容全为0,请用下而这个函数,

img

这个函数与__get_free_pages()工作方式相同·只不过把分配好的页都填充成了0一一字节中的每一位都要取消设置·如果分配的页是给用户空间的,这个函数就非常有用了·虽说分配好的页中应该包含的都是随机产生的垃圾信息,但其实这些信息可能并不是完全随机的.一一.它很可能“随机地”包含某些敏感数据.用户空间的页在返回之前,所有数据必须填充为0,或做其他清理工作,在保障系统安全这一点上,我们决不妥协·


  • 分配页

表12-2是所有底层的页分配方法的列表·

img

  • 释放页

当你不再需要页时可以用下面的函数释放它们:

img

释放页时要谨慎,只能释放属于你的页·传递了错误的struct page或地址,用了错误的order值,这些都可能导致系繞崩憒·请记住·内核是完全信赖自己的·这点与用户空间不同,如果你有非法操作,内核会开开心心地把自己挂起来,停止运行.


内存使用例子

让我们看一个例子·其中·我们想得到8个页:

img

GFP_KERNEL参数是gfp_mask标志的一个例子·前面我们已经简要讨论过.

  • Tip : 用_get_free_page()之后要注意进行错误检查·内核分配可能失败,因此你的代码必须进行检查并做相应的处理.这意味在此之前,你所做的所有工作可能前功些弃·甚至还需要回归到原来的状态,正因为如此,在程序开始时先进行内存分配是很有意义的,这能让错误处理得容易一点·如果你不这么做,那么在你想要分配内存的时候如果失败了,局面就可能就难以控制了·

kmalloc

  • 当你需要以页为单位的一族连续物理页时,尤其是在你只需要一两页时,这些低级页函数很有用·对于常用的以字节为单位的分配来说·内核提供的函数是kmalloc()
kmalloc()

kmalloc()函数与用户空间的malloc()一族函数非常类似,只不过它多了一个flags参数·
kmalloc()函数是一个简单的接口,用它可以获得以字节为单位的一块内核内存·如果你需要整个页·那么,前面讨论的页分配接口可能是更好的选择·但是,对于大多数内核分配来说,kmalloc()接口用得更多·

  • kmalloc()在<linux/slab.h>中声明:

img

函数返回一个指向内存块的指针·其内存块至少要有size大小.所分配的内存区在物理上是连续的·在出错时,它返回NULL.除非没有足够的内存可用,否劓内核总能分配成功·

  • kmalloc()调用之后,你必须桧查返回的是不是NULL,如果是,要适当地处理错误·

  • 我们看一个例子·我们随便假定存在一个dog结构体·现在需要为它动态地分配足够的空间:

img

如果kmalloc()调用成功,那么,ptr现在指向一个内存块,内存块的大小至少为所请求的大小

  • GFP_KERNEL标志表示在试图获取内存并返回给kmalloc()的调用者的过程中,内存分配器将要采取的行为·

gfp_mask标志

  • 我们已经看过了几个例子,发现不管是在低级页分配函数中,还是在kmalloc()中,都用到了分配器标志.现在,我们深人讨论一下这些标志.
  • 这些标志可分为三类,行为修饰符、区修饰符及类型·

    行为修饰符表示内核当如何分配所需的内存·在某些特定情况下,只能使用某些特定的方法分配内存·

    • 例如,中断处理程序就要求内核在分配内存的过程中不能睡眠(因为中断处理程序不能被重新调度)

    区修饰符表示从哪儿分配内存·前面我们已经看到,内核把物理内存分为多个区,每个区用于不同的目的·区修饰符指明到底从这些区中的哪一区中进行分配,

    • 类型标准组合了行为修饰符和区修饰符,将各种可能用到的组合归纳为不同类型,简化了修饰符的使用;这样,你只需指定一个类型标志就可以了·
    • GFP_KERNEL就是一种类型标志,内核中进程上下文相关的代码可以使用它·我们来看一下这些标志.

行为修饰符

所有这些标志,包括行为描述符都是在<linux/gfp.h>中声明的·不过,在<linux/slab.h>中包含有这个头文件,因此,你一般不必直接包含引用它.实际上一般只使用类型修饰符就够了,我们随后会看到这点.因此·最好对每个标志都有所了解

img
img

  • 可以同时指定这些分配标志·例如;

img

说明页分配器(最终调用alloc_pages()),在分配时可以阻塞、执行I/O,在必要时还可以执行文件系统操作·这就让内核有很大的自由度,以便它尽可能找到空闲的内存来满足分配请求·

  • 大多数分配都会指定这些修饰符,但一般不是这样直接指定,而是采用我们随后讨论的类型标志·别担心,你不会在分配内存时为怎样使用这些标志而犯愁的!

区修饰符

区修饰符表示内存区应当从何处分配·通常,分配可以从任何区开始·不过,内核优先从ZONE_NORMAL开始,这样以确保其他区在需要时有足够的空闲页可供使用·

  • 实际上只有两个区修饰符,因为除了ZONE_NORMAL之外只有两个区(默认都是从ZONE_NORMAL区进行分配)·

表12.4是区修饰符的列表·

img

指定以上标志中的一个就可以改变内核试图进行分配的区,_GFP_DMA标志强制内核从ZONE_DMA分配.这个标志在说,有了这种奇怪的标识,我绝对可以拥有进行DMA的内存·

  • 相反·如果指定__GFP_HIGHEM标志,则从ZONEHIGHMEM(优先)ZONE_NORMAL分配.这个标志在说,我可以使用高端内存,因此,我可以是一个玩偶,给你退还一些内存·但是,常规内存还照常工作·

  • 如果没有指定任何标志,则内核从ZONE_DMA或ZONE_NORMAL进行分配,当然优先从ZONE_NORMAL进行分配·不管区标志说什么了,只要它行为正常,我就不关心了·


    • 不能给_get_free_pages()或kalloc()指定ZONE_HIGHMEM,因为这个函数返回的都是逻辑地址,而不是page结构·这两个函数分配的内存当前有可能还没有映射到内核的拟地址空间,因此,也可能根本就没有逻辑地址.只有alloc_pages()才能分配高端内存·
    • 实际上·你的分配在大多数情况下都不必指定修饰符,ZONE_NORMAL足矣·

类型标志

类型标志指定所需的行为和区描述符以完成特殊类型的处理.正因为这一点,内核代码趋向于使用正确的类型标志,而不是一味地指定它可能需要用到的多个描述符·这么做既简单又不容易出错误·

  • 表12·5是类型标志的列表,而表12.6显示了每个类型标志与些修符相关联·

img

让我们看一下最常用的标志以及你什么时候、为什么需要使用它们·

  • 内核中最常用的标志是GFP_KERNEL.这种分配可能会引起睡眠·它使用的是普通优先级.因为用可能阻塞·因此这个标志只用在可以童新安全调度的进程上下文中(也就是没有锁被持有等情况)·因为这个标志对内核如何获取请求的内存没有任何约柬,所以内存分配成功的可能性很高.
  • 另一个截然相反的标志是GFP_ATOMIC.因为这个标志表示不能睡眠的内存分配,因此想要满足调用者获取内存的请求将会受到很严格的限制·即使没有足够的连续内存块可供使用,内存也很可能无法释放出可用内存来,因为内核不能让调用者睡眠·
  • 相反,GFP_KERNEL分配可以让调用者、交換、刷新一些页到硬盘等·因为GFP_ATOMIC不能执行以上任何操作,因此与GFP_KERNEL相比较·它分配成功的机会较小(尤其在内存短缺时)·即便如此·在当前代码(如中断处理程序、软中断和tasklet)不能睡眠时,也只能选择GFP_ATOMIC

在以上两种标志中间的是GFP_NOIO和GFP_NOFS.以这两个标志进行的分配可能会引起阻塞,但它们会避免执行某些其他操作·GFP_NOIO分配绝不会启动任何磁盘I/O来帮助满足请求·

而GFP_NOFS可能会启动磁盘I/O,但是它不会启动文件系铳I/O.

你为什么需要这些标志?

它们分别用在某些低级块I/O或文件系统的代码中·设想·如果文件系统代码中需要分配内存,但没有使用GFP_NOFS.这种分配可能会引起更多的文件系统操作,而这些操作又会导致另外的分配,从而再引起更多的文件系统操作!这会一直持续下去·这样的代码在用分配器的时候,必须确保分配器不会再执行到代码本身,否則·分配就可能产生死锁.也别緊张,内核使用这两个标志的地方是极少的·GFP_DMA标志表示分配器必须足从ZONE_DMA进行分配的请求·这个标志用在需要

  • DMA的内存的设备驱动程序中·一般你会把这个标志与GFP_ATOMIC和GFP_KERNEL结合起来使用· 在你编写的绝大多数代码中,用到的要么是GFP_KERNEL,要么是GFP_ATOMIC.

    • 表12.7是通常情形和所用标志的列表·

不管使用哪种分配类型,你都必须进行检查,并对错误进行处理·

img

kfree()

kmallloc()的另一端就是kfree(),它声明于<linux/slab.h>中:

img

kfree()函数释放由kmalloc()分配出来的内存块·如果想要释放的内存不是由kmalloc()分配的,或者想要释放的内存早就被释放了,比如说释放属于内核其他部分的内存,调用这个函数会导致严重的后果·与用户空间类似,分配和回收要注意配对使用,以避免内存泄漏和其他bug.注意,调用kfree(NULL)是安全的·

  • 让我们看一个在中断处理程序中分配内存的例子·在这个例子中,中断处理程序想分配一个缓冲区来保存输人数据,BUF_SIZE定义为以字节为单位的冲区长度,它应该是大于两个字节的·

img

之后,当我们不再需要这个内存时,别忘了释放它:

kfree(buf);


vmalloc()

vmalloc()函数的工作方式似于kmalloc(),只不过前者分配的内存虚拟地址是连续的,而物理地址則无須连续·这也是用户空间分配函数的工作方式:由malloc()返回的页在进程的虚拟地址空间内是连续的,但是·这并不保证它们在物理RAM中也是连续的.

kmalloc与vmalloc的主要区别
  • kmalloc()函数确保页在物理地址上是连续的(虚拟地址自然也是连续的).、
  • vmalloc()函数只保页在虚拟地址空间内是连续的·它通过分配非连续的物理内存块,再“修正"页表,把内存映射到逻地址空同的连续区域中·就能做到这点.

​ 大多数情况下·只有硬件设备需要得到物理地址连续的内存·在很多体系结构上,硬件设备存在于内存管理单元以外,它根本不理解什么是虚拟地址.因此·硬件设备用到的任何内存区都必须是物理上连续的块,而不仅仅是虚拟地址连续上的块·而仅供软件使用的内存块(例如与进程相关的鍰冲区)就可以使用只有虚拟地址连续的内存块·但在你的编程中,根本察觉不到这种差异·对内核而言,所有内存起来都是逻辑上连续的·

尽管在某些情况下才需要物理上连续的内存块,但是·很多内核代码都kamlloc()来获得内存,而不是vmalloc().这主要是出于性能的考虑.vmalloc()函数为了把物理上不连续的页转换为虚拟地址空间上连续的页,必须专门建立页表项.糟糕的是,通过、vmalloc()获得的页必须一个一个地进行映射(因为它们物理上是不连续的),这就会导致比直接内存映射大得多的TLB(translation lookaside buffer),是一种硬缓冲区,很多体系结构用它緩存虚拟地址到物理地址的映射关系·它极大地高了系统的性能·因为大多数内存都要进行虚拟寻址抖动·因为这些原因,vmalloc()仅在不得已时才会使用,典型的是为了获得大块内存时·例如,当模块被动态插人到内存中时,就把模块装载到由vmalloc()分配的内存上·

  • Note: 物理与虚拟地址都连续的只用于小内存申请的kmalloc,物理不连续而虚拟地址连续的vmalloc.

vmalloc函数声明在<linux/vmalloc.h>中,定义在<mm/vmalloc.c>中.·用法与用户空间的malloc()相同:

img

该函数返回一个指针·指向逻辑上连续的一块内存区,其大小至少为size,·在发生错误时,函数返回NULL,

  • 函数很可能睡眠,因此,不能从中断上下文中进行调用·也不能从其他不允许阻塞的情况下进行调用·
要释放vamalloc()所获得的内存·使用下面的函数

void vfree(const void *addr)

这个函数会释放从addr开始的内存块,其中addr是以前由vmalloc()分配的内存块的地址·

  • 这个函数也可以睡眠,因此,不能从中断上下文中调用·它没有返回值·用起来比较简单:

img

slab层

分配和释放数据结构是所有内核中最普遍的操作之一·为了便于数据的频繁分配和回收,鯿程人员常常会用到空闲链表·空闲链表包含可供使用的、已经分配好的数据结构块·当代码需要一个新的数据结构实例时,就可以从空闲链表中抓取一个,而不需要分配内存,再把数据放进去.以后,当不再需要这个数据结构的实例时,就把它放回空闲链表,而不是释放它·从这个意义上说,空链表相当于对象高速缓存一一一快速存储频繁使用的对象类型·

在内核中,空闲链表面的主要问题之一是不能全局控制·当可用内存变得紧缺时,内核无法通知每个空闲链表,让其收缩缓存的大小以便释放出一些内存来·实际上,内核根本就不知道存在任何空闲链表·为了弥补这一缺陷,也为了使代码更加稳固,Linux内核提供了、slab层(也就是所谓的slab分配器)·slab分配置扮瀆了通用数据结构缓存层的角色·


img

  • slab分配的概念首先在Sun公司的SunOS5·4作系统中得以实现👆
  • Linux数据结构缓存层具有同样的名字和基本设计思想·

slab分配器试图在几个摹本原劓之间尋求一种平衡:

  • 频繁使用的据结构也会频繁分配和释放,因此应当缓存它们·
  • 頻驚分配和回收必然会导致内存碎片(难以找到大块连续的可用内存)·为了避免这种现像,空闲链表的缓存会连续地存放.因为已释放的数据结构又会放回空闲链表,因此不会导致碎片·
  • 回收的对象可以立即投人下一次分配,因此·对于的分配和释放·空链表能够提高其性能· 如果分配知道对象大小、页大小和总的高速缓存的大小这样的概念,它会做出更明智的决策
  • 如果让部分缓存专属于单个处理器(对系銃上的每个处理器独立而唯一),那么,分配和释放就可以在不加SMP锁的情况下进行.
  • 如果分配器是与NUMA相关的,它就可以从相同的内存节点为请求者进行分配.
  • 对存放的对象进行缓存着色color,可以防止多个对象映射到相同的高速缓存行( cache line)
    Linux的slab层在设计和实现时充分考虑了上述原則.

to be continue 20191116 16:02 edited


slab层的设计

slab把不同的对象划分为所谓高速缓存组,其中每个高速缓存组都存放不同类型的对象·

  • 每种对象类型对应一个高速缓存·
  • 例如,一个高速缓存用于存放进程描述符(task_struct结构的一个空闲链表),而另一个高速缓存存放索引节点对象(struct inode)·有趣的是,kmalloc()接口建立在slab层之上,使用了一组通用高速缓存,;然后·这些高速缓存又被划分为slab(这也是这个子系统名字的来由)slab由一个或多个物理上连续的页组成·一般情况下,slab也就仅仅由一页组成.每个高速缓存可以由多个slab组成·每个slab都包含一些对象成员,这里的对象指的是被缓存的数据结构·每个slab处于三种状态之一:满,部分满或空·一个满的slab没有空闲的对象(slab中的所有对都已被分配)·
    一个空的slab没有分配出任何对象(slab中的所有对象都是空闲的)·一个部分满的slab有一些对象已分配出去,有些对象还空闲着·当内核的某一部分需要一个新的对象时,先从部分满的slab中进行分配·如果没有部分满的slab,从空的slab中进行分配·如果没有空的slab,就要建一个slab了·显然·满的slab无法满足请求,因为它根本就没有空闲的对象·这种策略能减少碎片·
  • 作为一个例子,让我们考察一下inode结构。该结构是磁盘索引节点在内存中的体现.这些数据结构会频繁地创建和释放,因此,用slab分配器来管理它们就很有必要·因而struct inode就由inode_cachep高速缓存(这是一种标准的命名规范)进行分配·

  • 这种高速缓存由一个或多个slab组成一一一由多个slab组成的可能性大一些,因为这样的对象数量很大·每个slab包含尽可能多的struct inode对象·当内核请求分配一个新的inode结构时,内核就从部分满的slab或空的slab(如果没有部分满的slab),返回一个指向已分配但未使用的结构的指针·

  • 当内核用完inode对象后,slab分配器就把该对象标记为空闲·

  • 图12-1显示了高速缓存、slab及对象之间的关系.

img

  • 每个高速缓存都使用kmem_cache结构来表示·这个结构包含三个链表:slabs_full,slabs_partial和slabs_empty , 均存放在kmem_list3结构内,该结构在mm/slab.c中定义.这些链表包含高速缓存中的所有slab,
    • slab描述符struct slab用来描述每个slab

img

  • slab描述符要么在slab之外另行分配,要么就放在slab自身开始的地方.如果slab很小,或者slab内部有足够的空间容纳slab描述符,那么描述符就存放在slab里面·

  • slab分配器可以创建新的slab,这是通过__get_free_pages()低级内核页分配器进行的:

img

  • 该函数使用__get_free_pages()来为高速缓存分配足够多的内存·该函数的第一个参数就指向需要很多页的特定高速缓存·第二个参数是要传给__get_free_pages(),的标志·注意这个标志是如何与另一个值进行二进制“或”运算的·这相当于把高速缓存需的缺省标志加到flags参数上·

  • 分配的页大小为2的幂次方·存放在cachep->gfporder中·由于与分配器NUMA相关的代码的关系前面这个函数比想象的要复杂一些.当nodeid是一个非负數时,分配器就试图对从相同的内存节点给发出的请求进行分配·这在NUMA系銃上提供了较好的性能,但是访问节点之外的内存会导致性能的损失.

  • 为了便于理解,我们可以忽酪与NUMA相关的代码,写一个简单的kmem_getpages()函数:

img

  • 接着,调用kmem_freepages()释放内存,而对给定的高速缓存页,kmem_freepages()最终调用的是free_pages().当然,slab层的关键就是避免频繁分配和释放页.由此可知,slab层只有当给定的高速缓存部分中既没有满也没有空的slab时才会调用页分配函数·而只有在下列情下才会用释放函数:当可用内存变得紧缺时·系统试图释放出更多内存以供使用;或者当高速缓存显式地被撤销时·

  • slab层的管理是在每个高速缓存的基础上·通过提供给整个内核一个简单的接口来完成的·通过接口就可以创建和撤销新的高速缓存,并在高速缓存内分配和释放对象·高速鍰存及其内slab的复杂管理完全通过slab层的内部机制来处理·当你创建了一个高速鍰存后·slab层所起的作用就像一个专用的分配器,可以为具体的对象类型进行分配.

slab分配的接口

一个新的高遽缓存通过以下函数创建:

img

  • 第一个参数是一个字符串,存放着高速缓存的名字;第二个参数是高速缓存中每个元素的大小;第三个参数是slab内第一个对象的偏移·它用来确保在页内进行特定的对齐.通常情猊下,0就可以满足要求,也就是标准对齐·flags参数是可选的设置项,用来控制高速缓存的行为·它可以为0,表示没有特殊的行为,或者与以下标志中的一个或多个进行“或”运算:

  • SLAB_HWCACHE_ALIGN.一一这个标志命令slab层把一个slab内的所有对象按高速缓存进行对齐.这就防止了“错误的共享",(两个或多个对象尽管位于不同的内存地址,但映射到相同的高速鍰存行)·这可以提高性能·但以增加内存开销为代价·因为对齐越严格,浪费的内存越多·到底会费掉多少内存,取于对齐的大小以及对象相对于系统高速缓存行对齐的方式·对于会频使用的高速缓存,而且代码本身对性能要求又很严格的情况,设置该选项是理想的选择:否则,请三思而后行·

  • SLAB_POISON一一一这个标志使slab层用已知的值(a5a5a5a5)填充slab.这就是所谓的“中毒”,有利于对未初始化内存的访问·

  • SLAB_RED_ZONE一一一这个标志导致slab层在已分配的内存周围插人“红色警界区”以探测缓冲越界·

  • SLAB_PANIC-一一这个标志当分配失败时提slab层·这在要求分配只能成功的时非常有用·比如,在系统初启时,分配一个VMA结构的高速缓存

  • SLAB_CACHE_DMA—这个标志命令slab层使用可以执行DMA的内存给每个slab分配空间·只有在分配的对象用于DMA,而且必须驻留在ZONE_DMA区时才需要这个标志·否则,你不需要也不应该设置这个标志·

  • 最后一个参数ctor是高速缓存的构造函数·只有在新的页追加到高速缓存时.构造函数才被调用·实际上,Linux内核的高速缓存不使用构造函数,事实上这里曾经还有过一个析构函数参数,但是由于内核代码并不需要它,因此已经被抛弃了·你可以将ctor参数赋值为NULL.

  • kmem_cache_create()在成功时会返回一个指向所创建高速缓存的指针;否则·返回NULL.这个函数不能在中断上下文中调用,因为它可能会睡泯·要撤销一个高速缓存,则调用:顾名思义·这样就可以撤销给定的高速缓存·这个函数通常在模块的注销代码中被调用,当然,这里指创建了自己的高速缓存的模块·同样,也不能从中断上下文中调用这个函数,因为它也可能睡眠·调用该函数之前必确保存在以下两个条件:高速缓存中的所有slab都必须为空.其实,不管哪个slab中·只要还有一个对象被分配出去并正在使用的话,那怎么可能撤销这个高速缓存呢?在调用kmem_cache_destory()过程中(更不用说在调用之后了)不再访问这个高速缓存·调用者必须确保这种同步·该函数在成功时返回0,否则返回非0值.

从缓存中分配

创建高速缓存之后,可以通过下列函数获取对象:

该函数从给定的高速缓存cachep中返回一个指向对象的指针·如果高速缓存的所有slab中都没有空闲的对象,那么slab必须通过kmem_getpages()获取新的页,flags的值传递给_get_free_pages().这与我们前面看到的标志相同·你用到的该GFP_KERNEL或GFP_ATOMIC.

最后释放一个对象,并把它返回给原先的slab,可以使用下面这个函数:这样就能把cachep中的对象标记为空闲·

slab分配器的使用实例,让我们考察一个鲜活的实例,这个例子用的是task_struct结构(进程描述符)·代码稍微有点复杂,取自kernel/fork.c

首先,内核用一个全局变量存放指向task_struct高遽缓存的指针,在内核初始化期间,在定义于kernel/fork.c的fork_init()中会创建高速缓存:这样就创建了一个名为task_struct的高速鍰存,其中存放的就是类型为struct task_struct的对象·该对被创建后存放在slab中偏移量为ARCH_MIN_TASKALIGN个字节的地方,

ARCH_MIN_TASKALIGN預定义值与体系结构相关.通常将它定义为L1_CACHE_BYTES

-------L1高速缓存的字节大小· 没有构造函数或析构函数·注意不用查返回值是否为失效标记NULL,因为SLAB_PANIC标志已被设竇了·如果分配失效,slab分配器就调用panic()函数·如果没有提供SLAB_PANIC标志,就必须自己检查返回值.SLAB_PANIC标志用在这儿是因为这是系统操作必不可少的高速缓存(没有进程描述符,机器自然不能正常运行)·

每当进程调用fork()时,一定会创建一个新的进程描述符这是在dup

tasks_struct()中完成的,而该函数会被do_fork()调用

进程执行完后,如果没有子进程在等待的话,它的进程描述符就会被释放,并返回给task_struct_cachep slab高速缓存·这是在free_task_struct()中执行的(这里,tsk是现有的进程):

由于进程描述符是内核的核心组成部分,时刻都要用到·因此task_struct_cachep高遽缓存绝不会被嫩销掉·即使真能撤销,我们也要通过下列函数阻止其被撤销:

很容易吧?slab层负责内存缺情下所有底层的对齐、着色、分配、释放和回收等·如果你要创建很多相同类型的对,那么,就应该考虑使用slab高速缓存·也是说.不要自己去实空困链表!

在栈上的静态分配

在用户空间,我们以前所讨论到的那些分配的例子,有不少都可以在栈上发生·因为我们毕竟可以事先知道所分配空间的大小.用户空间能够奢侈地负担起非常大的栈·而且栈空间还可以动态增长,相反,内核却不能这么奢侈一一一内核栈小而且固定.当给每个进程分配一个固定大小的小栈后,不但可以减少内存的消耗·而且内核也无须负担太重的栈管理任务·

每个进程的内核栈大小既依赖于体系结构·也与编译时的选项有关·历史上,每个进程有两页的内核栈·因为32位和64位体系结构的页面大小分别是4KB和8KB,所以通常它们的内核栈的大小分别是8KB和16KB.

单页内核栈

但是,在2.6内核的早期,引人了一个选项设竇单页内核栈·当激活这个选项时,每个进程的内核栈只有一页那么大,根体系结构的不同·或为4KB或为8KB·这么做出于两个原因:首先,可以让每个进程减少内存消耗·其次·也是最重要的,随着机器运行时间的增加,寻找两个未分配的、连续的页变得越来越困难·物理内存渐渐变为碎片,因此,给一个新进程分配虚拟内存(VM)的压力也在增大.

还有一个更复杂的原因·:我们几乎掌握了关于内核栈的全部知识·现在,每个进程的整个调用链必須放在自己的内核栈中.不过,中断处理程序也曾经使用它们所中断的进程的内核栈,这样,中断处理程序也要放在内核栈中.这当然有效简单,但是,这同时会把更严格的约東条件加在这可怜的内核栈上·当我们转而使用只有一个页面的内栈时,中断处理霪序就不放在栈中了·

为了矫正这个同题·内核开发者们实现了一个新功能:中断栈·中断栈为每个进程提供一个用于中断处序的栈·有了这个选项·中断处理程序不用再和被中断进程共享一个内栈,它们可以使用自己的栈了·对每个进程来说仅仅耗费了一页而已·

总的来说,内核栈可以是1页,也可以是2页,这取决于编译时选项·栈的大小因此在4~16KB的范围内·历史上,中断处理程序和被中断进程共享一个栈·当1页栈的选项激活时,中断处理程序获得了自己的栈·在任何情况下,无限制的递归和alloca()显然是不帔允许的·

在栈上光明正大地工作

在任意一个函数中·你都必须尽节省栈资源·这并不难,也没有什么窍门·只需要在具体的函数中让所有局部变量(即所谓的自动变量)所占空间之和不要超过几百字·在栈上进行大量的静态分配(比如分配大型数组或大型结构体)是很危险的·要不然,在内核中和在用户空间中进行的栈分配就没有什么差别了·栈溢出时悄无声息,但势必会引起严的问题·因为内核没有在管理内栈上做足工作,因此,当栈溢出时,多出的数据会直接溢出来,覆盖掉紧邻堆栈末端的东西,首先面临考驗的就是thread_info结构(这个结构就贴着每个进程内核堆栈的末端)在堆栈之外,任何内核数都可能存在潜在的危险·当栈溢出时·最好的情祝是机器宕机,最坏的情是悄无声息地破坏数据·因此,进行动态分配是一种明智的选择,本章前面有关大块内存的分配就是采用这种方式·

  • Note: (不要在内核中声明超过一定大小的数组,和结构体等大块数据,如果需要请尽量是用malloc等函数进行申请)
高端内存的映射

根琚定义,在高端内存中的页不能永久地映射到内核地址空间上·因此,通过alloc_pages()函数以__GFP_HIGHMEM标志获得的页不可能有逻辑地址.在x86体系结构上,高于896MB的所有物理内存的范围大都是高端内存,它并不会永久

地或自动地映射到内核地址空间·尽管x86处理器能够寻址物理RAM的范围达到4GB(启用PAE(PAE是Physical Address Extension的缩写,这是x86处器的特点,这种特点使得x86处理器尽管只有32位的虚拟地址空间,但从物理上能寻址到36位(64GB)的内存空间 )可以寻址到64GB).一旦这些页被分配,就必須映射到内核的逻辑地址空间上·在x86上,高端内存中的页被映射到3GB~4GB.

永久映射

要映射一个给定的page结构到内核地址空间,可以使用定义在文件<linux/highmem.h>中的这个函数:

void *kmap(struct page *page)

这个函数在高端内存或低内存上都能用·如果page结构对应的是低端内存中的一页.

函数只会单纯地返回该页的虚拟地址·如果页位于高端内存·则会建立一个永久映射,再返回地址.这个函数可以睡眠,因此kmap()只能用在进程上下文中.

因为允许永久映射的数量是有限的(如果没有这个限制,我们不必搞得这么复杂,把所有内存通通映射为永久内存行了)·当不再需要高端内存时,应该解除映射·这可以通过下列函数完成:

void kunmap(struct page *page)

临时映射

当必须创建一个映射而当前的上下文又不能睡眠时·内核提供了临时映射(也就是所谓的原子映射)有一组保留的映射,它们以存放新创建的临时映射·内核可以原子地把高端内存中的一个页映射到某个保留的映射中·因此,临时映射可以用在不能睡眠的地方,比如中断处理序中,因为获取映射时绝不会阻塞·

通过下列函数建立一个临时映射,参数type是下列枚举类型之一,这些枚举类型描述了临时映射的目的·它们定义于<asm/

kmap_types.h>中,这个函数不会阻塞,因此可以用在中断上下文和其他不能重新调度的地方·它也禁止内核抢占·这是有必要的,因为映射对每个处理器都是唯一的(调度可能对哪个处理器执行哪个进程做变动)·

通过下列函数取消映射:这个函数也不会阻塞·在很多体系结构中,除非激活了内核抢占,否劓kmap_atomic()根本就无事可做,因为只有在下一个临时映射到来前上一个临时映射才有效·因此,内完全可以“忘掉"kmap_atomic()映射,kunmap_atomic()也无须做什么实际的事情·下一个原子映射将自动覆盖前一个映射·

每个CPU的分配

支持SMP的现代操作系銃使用每个CPU上的数据,对于给定的处理器其数据是唯一的·一般来说,每个CPU的数据存放在一个数组中.數组中的每一项对应着系銃上一个存在的处理器.按当前处理器号确定这个数组的当前元素·这是2.4内核处理每个CPU数据的方式·式还不错,因此,2.6内核的很多代码依然用它·可以声明数据如下:

然后,按如下方式访问它:注意·上面的代码中并没有出现锁·这是因为所操作的数据对当前处理器来说是唯一的·除

了当前处理器之外,没有其他处理器可接到这个数据,不存在并发访问问题,所以当前处理器可以在不用锁的情下安全访问它.现在,内核抢占成为了唯一需要关注的问题了,内核抢占会引起下面提到的两个问题:

如果你的代码被其他处理器抢占并重新调度,那么这时CPU变量就会无效,因为它指向的是错误的处理器(通常·代码获得当前处理器后是不可以睡眠的)· 如果另一个任务抢占了你的代码,那么有可能在同一个处理器上发生并发访问my_percpu的情况,显然这属于一个竞争条件·

虽然如此,但是你大可不必惊懂·因为在获取当前处理号·即调用get_cpu()时.就已经禁止了内核抢占.相应的在调用put_cpu()时又会重新瀲活当前处理器号.注意,只要你总使用上述方法来保护数裾安全,那么,内核抢占不需要你自己去禁止·

新的每个CPU接口,2.6内核为了方便创建和操作每个CPU数据,而引进了新的操作接口,称作perecpu该接口归纳了前面所述的操作行为,简化了创建和操作每个CPU的数据·

但前面我们讨论的创建和访同每个CPU的方法依然有效,不过大型对称多处理器计算机要求对每个CPU数据操作更简单,功能更强大,正是在这种背景下,新接口应运而生.

头文件<linux/percpu·h>声明了所有的接口操作例程·你可以在文件mm/slab.c和<asm/percpu.h>中找到它们的定义.

编译时的每个CPU数据在编译时定义每个CPU变量易如反掌

这个语句为系铳中的每一个处理器都创建了一个类型为type,名字为name的变量实例,如果你需要在别处声明变量,以防范编译时警告·那么下面的宏将是你的好帮手:你可以利用get_cpu_var()和put_cpu_var()例程操作变量,调用get_cpu_var()返回当前处理器上的指定变量·同时它将禁止抢占:另一方面put_cpu_var()将相应的重新瀲活抢占·

使用此方法你需孌格外小心.因为per_cpu()函數既不会禁止内核抢占,也不会提供任何形式的保护·如果一些处理器可以接到其他处理的数据·那么你就必改要给数据上锁.注意,

另外还有一个需要提醒的问题:这些编译时每个CPU数据的例子并不能在模块内使用·因

为连接程序实际上将它们创建在一个唯一的可执行段中(.data.percpu),如果你需要从模块块中访同每个CPU数据·或者如果你需要动态创建这数据,那还是有希望的.

运行时的每个CPU数据

内核实现每个CPU数据的动态分配方法类似于kmalloc().该例程为系统上的每个处理器创

建所需内存的实例,其原型在文件<linux/percpu.h>中:

宏alloc_percpu()给系统中的每个处理器分配一个指定类型对象的实例·它其实是宏__alloc_percpu()的一个封装,这个原始宏接收的参数有两个:一个是要分配的实际字节數,一个是分配时要按多少字节对齐·而封装后的alloc_percpu()按照单字节对齐一一.按照给定类型的自然边界对齐·这种对齐方式最为常用·比如:

__alignof__是gcc的一个功能,它会返回指定类型或lvalue所需的(或建议的·要知道有

些古怪的体系结构并没有字节对齐的要求)对齐字节数.它的语义和sizeof一样,比如,下列程序在x86体系中将返回4 .

如果指定一个lvalue,那么将返回lvalue的最大对齐字节数.比如一个结构中的Ivalue相比结构外的lvalue可能有更大的对齐字节需求·这是结构本身的对齐要求的缘故.有关对齐的进一步讨论我们放在第19章中介绍·

相应的调用free_percpu()将释放所有处理器上指定的每个CPU数据

无论是是alloc_percpu()或是__alloc_percpu()都会返回一个指针,它用来间接引用动态创建的每个CPU数据,内核提供了两个宏来利用指针获取每个CPU数据:

get_cpu_var()宏返回了一个指向当前处理數据的特殊实例,它同时会禁止内核抢占:而在宏中会重新激活内核抢占.

我们来看一个使用这些函数的完整例子·当然这个例子有点无聊,因为通常你会一次分配够内存(比如,在某些初始化函数中),就可以在各种地方使用它·或再一次释放(比如,在一些清理函数中).不过·这个例子可清楚地说明如何使用这些函数.

使用每个CPU数据的原因

使用每个CPU数据具有不少好处·首先是减少了數据锁定,因为桉照每个处理器访同每个CPU数据的逻辑,你可以不再需要任何锁·记住“只有这个处理器能访问这个数据”的规则纯粹是一个编程约定·你需要保本地处理器只会访问它自己的唯一数据,系统本身并不存在任何措施禁止你从事欺骗活动·

第二个好处是使用每个CPU数据可以大大减少缓存失效·失效发生在处理试图使它们的缓存保特同步时.如果一个处理器操作某个数据·而该数据又存放在其他处理器缓存中,那么存放该数据的那个处器必須清理或刷新自己的缓存·持续不断的缓存失效称为缓存抖动,这样对系銃性能影响颇大·使用每个CPU数据将使得缓存影响降至最低,因为理想情睨下只会访问自己的数据·percpu接口缓存一对齐(cache-align)所有数据·以便确保在访问一个处理器的数据时,不会将另一个处理器的數据带入同一个缓存线上·

综上所述,使用每个CPU数会省去很多(或最小化)数据上锁,它唯一的安全要求就是要禁止内核抢占.而这点代价相比上锁小得多,而且接口会自动帮你完成这个步骤·每个CPU数据在中断上下文或进程上下文中使用都很安全·但要注意·不能在访同每个CPU数据过程中眠一一一否则,你就可能釃来后已经到了其他处理器上了·

目前并不要求必须使用每个CPU的新接口·只要你禁止了内核抢占·用手动方法(利用我们原来讨论的数组)就很好,但是新接口在将来更容易使用,而且功能也会得到长足的优化·如果确实决定在你的内核中使用每个CPU数,请考虐使用新接口·但我要提的是一一一新接口并不向后蒹容之前的内核.

分配函数的选择

在这么多分配函數和方法中,有时并不能搞清楚到底该选择那种方式分配一一但这确实很重要·

  • 如果你需要连续的物理页,就可以使用某个低级页分配器或kmaloc()·这是内核中内存分配的常用方式,也是大多数情况下你自己应该使用的内存分配方式·回忆一下,传递给这些函数的两个最常用的标志是GFP_ATOMIC和GFP_KERNEL.

    • GFP_ATOMIC表示进行不睡眠的高优先级分配,这是中断处理程序和其他不能睡眠的代码段的需要·
    • 对于可以睡眠的代码,(比如没有持自旋锁的进上下文代码)則应该使用GFP_KERNEL获取所需的内存·这个标志表示如果有必要,分配时可以睡眠.
  • 如果你想从高端内存进行分配·使用alloc-pages(),alloc-pages()函数返回一个指向struct_page结构的指针,而不是一个指向某个逻辑地址的指针,因为高端内存很可能并没有被映射,因此,访问它的唯一方式就是通过相应的struct page结构·为了获得真正的指针,应该调用kmap()把高端内存映射到内核的逻辑地址空间.

  • 如果你不需要物理上连续的页,而仅仅需要拟地址上连续的页,那么使用vmlloc()(不过要记住vmalloc()相对于kmalloc()来说,有一定的性能损失)·vmalloc()函数分配的内存虚地址是连续的,但它本身并不保证物理上的连续,这与用户空间的分配非常类似.它也是把物理内存块映射到连续的逻地址空间上·

  • 如果你要创建和撤销很多大的数据结构,那么考虑建立slab高速鍰存.slab层会给每个处理器维持一个对象高速缓存(空闲链表)·这种高速缓存会极大地提高对分配和回收的性能·、slab层不是频繁地分配和释放内存,而是为你把事先分配好的对象存放到高速缓存中·当你需要一块新的内存来存放数结构时·slab层一般无须另外去分配内存,而只需要从高速缓存中得到一个对象就可以了.

小结

本文中,我们学习了Linux内核如何管理内存.我们首先看到了内存空间的各种不同的描述单位,包括字节、页面和区(在进程地址空间中可看到4种不同层次的内存单位)·我们接着讨论了各种内存分配机制,其中包括页分配器和slab分配器·在内核中分配内存并非总是轻而易举·因为你必须小心地确保分配过程遵从内核特定的状态约束·比如分配过程中不得堵塞,或者访同文件系统等约束·为此我们讨论了gfp标识以及使用每个标志的针对场景·分配内存相对复杂是内核开发和用户程序开发的最大区别之一,本章使用大量篇幅描述内存分配的各种不同接口.一一通过这些不同调用接口·你应该能感觉到内核中分配内存为什么更复杂的原因·

虚拟文件系统(VFS)---------负责管理文件系统且为用户空间程序提供一致性接口的内核子系銃·

posted on 2020-04-15 10:48  freedo  阅读(507)  评论(0编辑  收藏  举报

导航