linux内存管理(Linux内存管理)

linux内存管理
快来关注我们吧!

前言
内存管理是操作系统的一项重要内容。无论网上或者书本上都大量存在内存管理的教材和资料。本文斗胆深入到linux内核中介绍内存管理,希望读后能有一个直观的理解。当前处理器的主流是多核支持64位寻址,在本文中还是以32位系统为例子进行阐述。
地址映射
1
内存地址
当使用 Intel 80×86微处理器时,必须区分逻辑地址、线性地址和物理地址:
逻辑地址由一个段标识符+段内偏移量组成;
线性地址是一个32位无符号整数,常用16进制;
物理地址是指用于芯片级内存寻址的地址。
通过叫做分段单元的硬件,能把逻辑地址转换为线性地址。通过叫分页单元的硬件,能够把线性地址转换为物理地址。三种地址的具体用途在后面会涉及到。
2
逻辑地址转线性地址
前面逻辑地址由一个段标识符+段内偏移量组成。段标识符相当于一个索引,用于在段描述符表中找到段描述符,段描述符中保存了段的第一个字节的线性地址。在执行逻辑地址转换的时候,先根据段标识符找到段的第一个字节的线性地址,再加上段内的偏移量就是这个逻辑地址对应的线性地址。
Linux在设计时,感覺某种程度上分段和分页在某种程度上多余:他们都可以把逻辑地址映射到各个物理地址。所以Linux 有限的采用分段,把所有的段寄存器的值设置为同样的值(也就是0),进而让诸如代码段、数据段等都映射到同一个线性地址空间中。
3
现性地址转物理地址
线性地址被分成以固定长度(如常见的分页长度4096字节)为单位的一组,成为页。一页内连续的线性地址被映射到连续的物理地址中。
把线性地址映射到物理地址的结构称为页表。页表放在主存中。
Linux引入了2级页表,把32位线性地址分成3部分:最高10位,中间10位和最后的12位。最高10位用来作为第一级页表(也被称为目录,可以理解为页表的页表)的页表的索引;中间10位用来作为第二级页表的索引;而最后12位用来在页内寻址,刚好能寻址4096字节的一页大小。
在这里我们以一个具体的例子来说明怎么通过页表进行地址转换。假设我们有一个线性地址为0x40000000。把他转成2进制为0010000000 0000000000 000000000000。他的最高10位以10进制表示为:256,指向进程页目录第257项,这一项需要包含页表的物理地址。中间部分10位表示在页表中第1项中会有这个线性地址对应的物理地址。示意图如图1。

图 1通过页表把线性地址转为物理地址
通过页表把线性地址转为物理地址时,一级页表也能够胜任工作,只是页表项会有1M项,比较耗内存。
内存管理
1
物理内存管理
页框可以理解为在内存中与线性地址中的一页相对的一片连续内存区域。一个页框大小为4KB(也有4M的页框,会涉及到扩展分页概念,跟本文没有密切关系特忽略),内核采用页框描述符来记录页框状态,诸如页框被引用次数、页框用途等。
Linux采用伙伴系统算法来管理空闲页框。伙伴系统算法能够合并相邻的页框进而较好的解决外部碎片问题。外部碎片指页框内存足够,但是这些页框不连续,导致分配一大块连续的内存需求不能被满足的问题。
这里简单阐述下伙伴算法思想:构造了10个链表,如果第 i个链表中有节点,那么这个节点表示存在一片包含pow(2,i-1)个页框的连续物理内存区域,这片物理区域的物理地址起始于pow(2,i-1)*pow(2,12)的倍数。这里 pow(2,i)表示2的i次方。内核初始化的时候尽量的把内存切分成具有512(也就是pow(2,10-1))个连续页框的片段,插入到第10个链表中。
假设当需要申请16个页框,内核首先到第5个链表(16==pow(2,5-1))去查看是否有空闲的节点,如果找到就返回;如果没找到,那么去第6个链表里面找,如果还没找到继续找第7个链表,直到第10个链表也没找到为止。假设在第7个链表找到了一块空闲内存,那么这块内存包含64个页框,内核会把它切成16+16+32的3块物理内存,把1块大小为16个页框的内存和大小为32个页框的内存分别插入到第五个链表和第六个链表中,然后返回剩下的那个长度为16个页框的内存。
伙伴系统回收内存时,检测代回收的内存块是否能与相邻的内存块合并。合并需要满足2个条件:他们相邻;他们2块合并后的起始地址是pow(2,i-1)*pow(2,12)的倍数。如果能合并则合并,并进一步检测合并后的内存块是否可以继续合并,以此循环直到不能合并为止,插入到某个链表中。
2
起始物理地址用途
内存物理地址从0x00000000开始的一部分地址被用作特殊用途:
第一个页框被BIOS使用,用于加电检测时检测到的存储硬件配置;
从0x000a000开始的640KB内存,被BIOS 保留;
所以Linux内核通常被安装在内存物理地址0x00100000开始往后的地方。内核具体占用了几M内存由内核大小决定。
进程内存管理
1
线性地址的划分
在Linux中32位线性地址通常被划分为2部分:从0到3G-1这个范围无论用户态还是内核态都可以访问;从3G到4G部分为内核独有,仅限内核访问。低端部分内存用途见图5,高端部分内存见图4,后面会分别介绍。
由于如下2个原因:内核是操作系统中优先级最高的部分,如果内核需要内存,那么操作系统应该尽量满足;内核相信自己是正确的,每个需要内存的需求确实需要内存。因此内核内存分配与用户进程的内存分配策略是很不一样的。前者有需求尽量满足;而用户进程的内存需求会尽量的去推迟,用户进程分配内存时,往往只是获得对一段线性地址的使用权,并不真正的获得内存。本部分第3、4小节较为详细分析进程低地址线性区内存的管理,5、6小节内容稍微分析进程高地址线性区内存管理。
2
线性区
内核通过一种被称为线性区的资源表示线性区间。线性区是有起始线性地址、长度、以及权限等组成。每个线性区可以包含连续的一个或者多个页。线性区有2个属性这里特地提一下,后面可能会用到:VM_GROWSDOWN和VM_GROWSUP,分别表示这个线性区向上增长和向下增长。一个进程拥有的所有线性区通过一个链表串在一起。当进程线性区数量过多的时候,会启用一个额外的平衡二叉树加速查询速度。
当请求创建一个长度为l的新的线性区时,会执行如下操作:
申请一块线性地址区域,这通过扫描进程自身的线性区来实现,搜索并找到一段线性区之间或者末尾的线性区地址区域,其长度不小于l;
初始化线性区属性;
如果新申请的线性区与已有的线性区相邻,那么合并线性区;
为新线性区分配页表,注意在这里只分配页表不分配页框(也就是物理内存的页)。
3
缺页异常
前面提到建立线性区之后,只分配了页表,并不分配页框。这种情况下,当真正对线性地址进行访问的读写的时候,会引发缺页异常。这时候内核处理缺页异常的流程可以简单的如图2所示:

图 2缺页异常处理流程示意
图2所示的流程,只是个简化的流程,真实的流程比这个复杂不少,有兴趣的可以了解相关数据。例如压栈操作引发缺页异常的处理流程以及下面一节将要提到的写实复制流程就没有在图中涵盖。

4
写时复制
早期的linux在创建子进程的时候,内核原样复制整个的父进程的地址空间,这种行为是非常耗时并且不必要的,因为许多子进程往往装入一个新的程序进行执行。
现代Linux内核采用写实复制技术极大的减少了创建子进程的开销。其思想就在于父子进程共享页框而不复制页框;如果一个页框是可写入的,那么父子进程共享之后这个页框后,这个页框被标记为只读的。
父子进程共享页框之后,如果父子进程对页框进行读,不会引发任何问题。而当对只读页框进行写入的时候,按照第3小节的介绍,访问类型和权限不匹配,会引发异常。这时候内核将采用如下流程处理:
分配一个新的页框;
检测老的只读页框是否只属于某一个进程,如果是则直接把页框改为可的,释放在一开始申请的页框;
如果老的只读页框属于多个进程,就把旧的页框复制的一开始申请的新的页框,并断开当前进程与老的页框的联系。写时复制如图3所示。

图 3写时复制示意

通过写时复制技术,如果子进程创建后跟父进程分道扬镳,那么创建开销比较小。而当他们不分道扬镳,那么经过一段时间的写实复制,他们会有属于自己的页框。
5
Slab分配器
伙伴系统分配的内存至少为1页。如果内核需要几个字节几十个字节的数据,这种情况下分配一页的话会存在巨大的浪费,另外频繁的调用伙伴系统也容易导致外部碎片。Linux内核引入Slab来进行小块内存分配。
Slab分配器其核心思想是:根据内核某个特定的内存需求N字节,申请一组页框,并把它切分成近似于N字节的块的大小。当需要的时候如果发现没有相匹配的小内存块,就申请一组新的页框,并进行拆分,然后返回一个新拆分的内存块;否则直接返回一块拆分过的内存块。Slab具体怎么工作可以参考 Linux内核相关的资料,本小节的描述只是让大家有一个直观的理解,并不严谨。
6
进程高端内存
linux高端内存范围为3g-4g这个线性地址区间。根据用途被分为了几部分:直接映射物理页帧、动态映射区域、持久映射以及固定映射,如图4所示。

图 4高位内存轮廓
线性空间中从3G开始最大896M的区间, 为直接内存映射区,该区域的线性地址和物理地址存在线性转换关系:线性地址=3G+物理地址。
动态内存映射区开始于 vmalloc_start,结束于vmalloc_end。这段线性地址空间根据需要被组织成一段一段地址空间,以映射物理内存。其主要用途是将物理上不连续的内存映射到连续的线性地址范围上。映射大致包含三个操作:通过合法性检查之后,扫描当前高位内存区域,找到一个合适的先行地址范围;给刚分配的线性地址范围建立页表;对线性地址的每一页分配页框。这种方式分配的内存,物理内存可能不是连续的,不能充分利用快表技术(TLB)加速,访问内存速度要慢些。
永久内存映射区可访问高端内存。使用alloc_page(_GFP_HIGHMEM)分配高端内存页或者使用kmap函数将分配到的高端内存映射到该区域。
固定映射区和4G的顶端只有4k的隔离带,其每个地址项都服务于特定的用途。
G++进程内存管理
1
进程内存分布
Linux系统在处理 elf格式的程序文件时,会调用加载器把执行文件中的各个段加载到某一地址开始的空间,进程内存分布如图5所示,可以分为如下几段:
代码段:也就是程序代码,该部分空间只能读不能写;
数据段:存储程序中初始化过的全局变量和静态变量,可读写;
BSS段:存储程序中未初始化的全局变量和静态变量,可读写;
堆:存储动态分配的小块内存,堆顶位置可以用brk、sbrk来动态调整;
内存映射区域:存放动态库、共享内存、文件映射内存,一般由mmap调用分配地址空间;
栈:用户维护函数调用的上下文空间,默认8M,可以通过ulimit –s 查看默认栈大小;
内核空间:由内核管理。

图 5进程内存分布
2
Glibc 内存管理
Glibc的内存管理是通过一个叫ptmalloc的内存分配器管理的。ptmalloc通过提供malloc、calloc、realloc、free等接口来支持动态内存的管理。为了下次分配的时候能够较快的响应,ptmalloc并不立即释放调用free释放的小块内存,而是缓存起来,以用于下次释放。ptmalloc通过chunk来记录小块内存使用情况。每个的晓得内存块对应一个chunk,不管这块内存是在使用的还是处于空闲状态的。
ptmalloc将堆中大小相同的chunk用链表连接起来,这样的链表被称为一个 chunk。ptmalloc 维护了128个bin,如图6所示。在所有的bin中,bin0没用;bin1称为 unsorted bin;bin2-bin63被称为small bin,相邻2个bin的 chunk大小差8字节;bin64到bin127称为large bin,相邻2个bin之间差距不等。当被释放的内存不小于max_fast(64B)阈值时,内存块被直接被放入到unsorted bin中;否则一股脑先都丢到 fast bin 中,同时分配内存的时候如果不大于max_fast这个阈值,会先从fast bin 找相应的空闲块。

图 6 fast bin与bin 示意图,上面是fast bin ,下面是bin  他们相互独立
malloc 分配内存的时候,首先检测是否大于某个阈值(128K),大于阈值则调用mmap在内存映射区域分配以及释放;否则先尝试在bin中进行内存分配,如果失败则调用brk推动堆的栈顶指针进行分配。图7 和图8是malloc 内存分配和释放示意图,真实的ptmalloc内存管理机制比这个要复杂些,原理类似,有兴趣的可以自行查看相关资料。

图 7 malloc 内存分配

图 8 malloc内存释放
3
ptmalloc的不足
从上一小节可以看出,如果堆的顶部如果由于某些原因一直得不到释放的话,堆下面的内存也是得不到释放的。这将导致隐性的内存泄露。另外可以想象在多线程程序中,会引发频繁的锁争抢,进而性能比较低下。
有一些第三方的内存管理器,比如tcmalloc、jemalloc等,较好的解决了上面2个问题,在这里就不多对他们再进行描述。

数天技术
让技术 · 更有趣

linux内存管理相关文章

版权声明