原文:Linux内存管理
说明:本文在原文基础上稍加改动以便阅读理解。
摘要
本章首先以应用程序开发者的角度审视Linux的进程内存管理,在此基础上逐步深入到内核中讨论系统物理内存
管理和内核内存
的使用方法。
前言
本文主要从开发者的角度谈谈对内存管理的理解,并把内核开发中使用内存的经验和对Linux内存管理的认识与大家共享。其中也会涉及到一些诸如段、页等内存管理的基本理论,但会点到为止,不做深究。
进程与内存
进程如何使用内存?
毫无疑问,所有进程(执行的程序)都必须占用一定的内存
,它或是用来存放从磁盘载入的程序代码,或是存放取自用户输入的数据等等。
对任何一个普通进程来讲,它都会涉及到5种不同的数据段
。具体如下:
代码段
:代码段是用来存放可执行文件的操作指令
,也就是说是它是可执行程序在内存中的镜像
。代码段需要防止在运行时被非法修改,所以是不可写
的。
数据段
:数据段用来存放已初始化的全局变量
,换句话说就是存放程序静态分配
的变量和全局变量。
静态分配内存就是编译器在`编译`程序的时候`根据源程序`来分配内存。
动态分配内存就是在程序编译之后, `运行时`调用运行时刻库函数来分配内存的。
静态分配由于是在`程序运行之前`,所以`速度快, 效率高, 但是局限性大`。
动态分配在程序运行时执行, 所以速度慢, 但灵活性高。
BSS
段:BSS段包含了未初始化的全局变量
,在内存中bss段全部置零。
堆
:堆是用于存放进程运行中被动态分配
的内存段,它的大小并不固定,可动态扩张或缩减
。当进程调用malloc等函数分配内存
时,新分配的内存就被动态添加到堆上
(堆被扩张);当利用free等函数释放内存
时,被释放的内存从堆中被剔除
(堆被缩减)。
栈
:栈是用户存放程序临时创建的局部变量
,也就是说我们函数括弧“{}”中定义的变量(但不包括static声明的变量,static
意味着在数据段
中存放变量)。除此以外,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放回栈中。由于栈的先进先出
特点,所以栈特别方便用来保存/恢复调用现场
。从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据
的内存区。
进程如何组织这些区域?
上述几种内存区域中数据段、BSS和堆
通常是被连续存储
的——内存位置上是连续的,而代码段和栈
往往会被独立存放
。有趣的是,堆和栈两个区域关系很“暧昧”,他们一个向下“长”(i386体系结构中栈向下、堆向上),一个向上“长”,相对而生。但你不必担心他们会碰头,因为他们之间间隔很大。
下图简要描述了进程内存区域的分布:
下面我们就继续进入操作系统内核看看,进程对内存具体是如何进行分配和管理的。
从用户向内核看,所使用的内存表象形式会依次经历逻辑地址
——线性地址
——物理地址
几种形式。逻辑地址经段机制
转化成线性地址;线性地址又经过页机制
转化为物理地址。(但是我们要知道Linux系统虽然保留了段机制,但是将所有程序的段地址都定死为0-4G,所以虽然逻辑地址和线性地址是两种不同的地址空间,但在Linux中逻辑地址就等于线性地址,它们的值是一样的)。沿着这条线索,我们所研究的主要问题也就集中在下面几个问题。
1. 进程空间地址如何管理?
2. 进程地址如何映射到物理内存?
3. 物理内存如何被管理?
以及由上述问题引发的一些子问题。如系统虚拟地址分布
;内存分配接口
;连续内存分配与非连续内存分配
等。
进程内存空间
Linux操作系统采用虚拟内存
管理技术,使得每个进程都有各自互不干涉的进程地址空间
。该空间是块大小为4G的线性虚拟空间
,用户所看到和接触到的都是该虚拟地址,无法看到实际的物理内存地址
。利用这种虚拟地址不但能起到保护操作系统
的效果(用户不能直接访问物理内存),而且更重要的是,用户程序可使用比实际物理内存更大的地址空间
(具体的原因请看硬件基础部分)。
在讨论进程空间细节前,这里先要澄清下面几个问题:
- 4G的进程地址空间被人为的分为两个部分——
用户空间与内核空间
。用户空间从0到3G(0xC0000000),内核空间占据3G到4G。用户进程通常情况下只能访问用户空间的虚拟地址,不能访问内核空间虚拟地址。只有用户进程进行系统调用
(代表用户进程在内核态执行
)等时刻可以访问到内核空间
。 - 用户空间对应进程,所以每当
进程切换
,用户空间就会跟着变化
;而内核空间是由内核负责映射,它并不会跟着进程改变,是固定的。内核空间地址有自己对应的页表(init_mm.pgd),用户进程各自有不同的页表
。 -
每个进程的用户空间都是完全独立、互不相干的
。不信的话,你可以把上面的程序同时运行10次(当然为了同时运行,让它们在返回前一同睡眠100秒吧),你会看到10个进程占用的线性地址一模一样
,这说明它们都是在各自内部的虚拟地址空间上运行。
进程内存管理
进程内存管理的对象是进程线性地址空间上的内存镜像
,这些内存镜像其实就是进程使用的虚拟内存区域
(memory region)。进程虚拟空间
是个32或64位的“平坦”(独立的连续区间
)地址空间(空间的具体大小取决于体系结构)。要统一管理这么大的平坦空间可绝非易事,为了方便管理,虚拟空间被划分
为许多大小可变
的(但必须是4096的倍数)内存区域,这些区域在进程线性地址中像停车位一样有序排列。这些区域的划分原则是“将访问属性一致的地址空间存放在一起
”,所谓访问属性在这里无非指的是可读、可写、可执行等
。
对于一个进程来说往往需要多个内存区域来描述它的虚拟空间,如何关联这些不同的内存区域呢?大家可能都会想到使用链表
,的确vm_area_struct结构确实是以链表形式链接,不过为了方便查找,内核又以红黑树
(以前的内核使用平衡树)的形式组织内存
区域,以便降低搜索耗时
。并存的两种组织形式,并非冗余:链表用于需要遍历全部节点
的时候用,而红黑树适用于在地址空间中定位特定内存区域
的时候。内核为了内存区域上的各种不同操作都能获得高性能,所以同时使用了这两种数据结构。
下图反映了进程地址空间的管理模型:
进程的地址空间对应的描述结构是“内存描述符结构”,它表示进程的全部地址空间,——包含了和进程地址空间有关的全部信息,其中当然包含进程的内存区域。
进程内存的分配与回收
创建进程fork()、程序载入execve()、映射文件mmap()、动态内存分配malloc()/brk()等进程相关操作都需要分配内存给进程。不过这时进程申请和获得的还不是实际内存,而是虚拟内存,准确的说是“内存区域”。进程对内存区域的分配最终都会归结到do_mmap()
函数上来。内核使用do_mmap()函数创建一个新的线性地址区间
,它会将一个地址区间加入到进程的地址空间中--无论是扩展已存在的内存区域还是创建一个新的区域。
同样,释放一个内存区域应使用函数do_ummap()
,它会销毁
对应的内存区域。
如何由虚变实!
从上面已经看到进程所能直接操作的地址都为虚拟地址
。当进程需要内存时,从内核获得的仅仅是虚拟的内存区域,而不是实际的物理地址,进程并没有获得物理内存,获得的仅仅是对一个新的线性地址区间的使用权。实际的物理内存只有当进程真的去访问新获取的虚拟地址
时,才会由“请求页机制”产生“缺页
”异常,从而进入分配实际页面
的例程。
该异常是虚拟内存机制赖以存在的基本保证——它会告诉内核去真正为进程分配物理页,并建立对应的页表
,这之后虚拟地址才实实在在地映射
到了系统的物理内存上。(当然,如果页被换出到磁盘,也会产生缺页异常,不过这时不用再建立页表了)
这种请求页机制
把页面的分配推迟
到不能再推迟为止,并不急于把所有的事情都一次做完。之所以能这么做是利用了内存访问的“局部性原理”,请求页带来的好处是节约了空闲内存,提高了系统的吞吐率。
要想更清楚地了解请求页机制,可以看看《深入理解linux内核》一书。
系统物理内存管理
虽然应用程序操作的对象是映射到物理内存之上的虚拟内存,但是处理器直接操作的却是物理内存。所以当应用程序访问一个虚拟地址时,首先必须将虚拟地址转化成物理地址,然后处理器才能解析地址访问请求。地址的转换
工作需要通过查询页表
才能完成,概括地讲,地址转换
需要将虚拟地址分段
,使每段虚地址
都作为一个索引指向页表
,而页表项则指向下一级别的页表或者指向最终的物理页面
。
每个进程都有自己的页表。进程描述符的pgd域指向的就是进程的页全局目录。下面我们借用《linux设备驱动程序》中的一幅图大致看看进程地址空间到物理页之间的转换关系。
上面的过程说起来简单,做起来难呀。因为在虚拟地址映射到页之前必须先分配物理页——也就是说必须先从内核中获取空闲页,并建立页表。下面我们介绍一下内核管理物理内存的机制。
物理内存管理(页管理)
Linux内核管理物理内存是通过分页机制
实现的,它将整个内存划分成无数个4k(在i386体系结构中)大小的页
,从而分配和回收内存的基本单位便是内存页
了。利用分页管理有助于灵活分配内存地址,因为分配时不必要求必须有大块的连续内存
,系统可以东一页、西一页
的凑
出所需要的内存供进程使用。
虽然如此,但是实际上系统使用内存时还是倾向于分配连续的内存块
,因为分配连续内存时,页表不需要更改,因此能降低TLB的刷新率(频繁刷新会在很大程度上降低访问速度)。
鉴于上述需求,内核分配物理页面时为了尽量减少不连续
情况,采用了伙伴关系分配算法
来管理空闲页面。因此空闲页面分配时也需要遵循伙伴关系,最小单位只能是2的幂倍页面大小。内核中分配空闲页面的基本函数是get_free_page/get_free_pages,它们或是分配单页或是分配指定的页面(2、4、8…512页)。
内核内存使用
Slab
所谓尺有所长,寸有所短。以页为最小单位分配内存对于内核管理系统中的物理内存来说的确比较方便,但内核自身最常使用
的内存却往往是很小
(远远小于一页)的内存块——比如存放文件描述符、进程描述符、虚拟内存区域描述符等行为所需的内存都不足一页。这些用来存放描述符的内存相比页面而言,就好比是面包屑与面包。一个整页中可以聚集多个这些小块内存;而且这些小块内存块也和面包屑一样频繁地生成/销毁。
为了满足内核对这种小内存块
的需要,Linux系统采用了一种被称为slab分配器
的技术。Slab分配器的实现相当复杂,但原理不难,其核心思想就是存储池
的运用。内存片段(小块内存)被看作对象,当被使用完后,并不直接释放而是被缓存到存储池里
,留做下次使用,这无疑避免了频繁创建与销毁对象
所带来的额外负载。
Slab技术不但避免了内存内部分片带来的不便(引入Slab分配器的主要目的是为了减少对伙伴系统分配算法的调用次数——频繁分配和回收必然会导致内存碎片
——难以找到大块连续的可用内存
),而且可以很好地利用硬件缓存
提高访问速度。
Slab并非是脱离伙伴关系而独立存在的一种内存分配方式,slab仍然是建立在页面基础之上,换句话说,Slab将页面(来自于伙伴关系管理的空闲页面链表)撕碎成众多小内存块以供分配。
内核非连续内存分配
伙伴关系也好、slab技术也好,从内存管理理论角度而言目的基本是一致的,它们都是为了防止“分片”
,不过分片又分为外部分片
和内部分片
之说,所谓内部分片是说系统为了满足一小段内存区(连续)的需要,不得不分配了一大区域连续内存给它,从而造成了空间浪费
;外部分片
是指系统虽有足够的内存,但却是分散的碎片
,无法满足对大块连续内存
的需求。无论何种分片都是系统有效利用内存的障碍。slab分配器使得一个页面内包含的众多小块内存可独立被分配使用,避免了内部分片,节约了空闲内存。伙伴关系把内存块按大小分组管理,一定程度上减轻了外部分片的危害,因为页框分配不在盲目,而是按照大小依次有序进行,不过伙伴关系只是减轻了外部分片,但并未彻底消除。
所以避免外部分片的最终思路还是落到了如何利用不连续的内存块组合成“看起来很大的内存块”——这里的情况很类似于用户空间分配虚拟内存,内存逻辑上连续,其实映射到并不一定连续的物理内存上。Linux内核借用了这个技术,允许内核程序在内核地址空间中分配虚拟地址,同样也利用页表(内核页表)将虚拟地址映射到分散的内存页上。以此完美地解决了内核内存使用中的外部分片问题。
其他
内存映射(mmap)
是Linux操作系统的一个很大特色,它可以将系统内存映射到一个文件(设备)上
,以便可以通过访问文件内容来达到访问内存
的目的。这样做的最大好处是提高了内存访问速度,并且可以利用文件系统的接口编程
访问内存,降低了开发难度。实现通过文件操作接口可以访问内存。