概念先行
先理解内存管理中的几个概念:内存,主存,缓存,外存,虚拟内存,物理内存,虚拟地址,物理地址
外存:
计算机的外部存储,比如硬盘(机械硬盘、固态硬盘、混合硬盘),usb
内存:
用于计算机运行过程中的临时存储,断电清除(这也是我们计算机休眠能保留进程状态,而断电关机不行的原因)。内存现在特指动态随机存储器(DRAM),就是我们电脑的内存条,也就是我们说的物理内存,等价于主存。也就是内存~物理内存~主存~内存条
缓存:
在内存管理子系统中指的是静态随机存储器(SRAM),比如cpu与主存之间的多级缓存(L1, L2 和 L3 高速缓存),当然计算机广义上的缓存远不止这些
虚拟内存:
抽象概念,虚拟内存的引入旨在为每个进程提供统一线性一致的地址空间,真正的地址则有MMU(内存管理单元)控制映射至对应的物理内存空间,而虚拟地址,物理地址则代表对应空间的偏移地址
需要注意的是,对于某一特定指令架构的计算机(比如32位),虚拟内存能够映射的地址,一部分映射至物理内存,当物理内存已满时,则进行页面置换映射至对应的磁盘空间。也就是虚拟内存地址包括(物理内存与部分磁盘空间)的地址映射
为什么呢?比如32位系统,内存条只有256M,此时32位系统最多只能用4G内存(虚拟内存):32位系统,即内存地址长度为32,最多映射2^32=2^2*2^30=4G个字节。远远大于内存条,则在进程运行过程中,当运行数据超过内存限度,部分数据自动“溢出”,这时系统会将磁盘上的部分空间模拟成虚拟内存,并将原来映射到物理内存中的暂时不运行的程序或不使用的数据置换到磁盘中,腾出更多物理内存空间并重新映射至虚拟内存中被调用
了解了概念,简单看下内存管理子系统虚拟地址(virtual adress)与物理地址(physical address)的关系
进程访问某个虚拟地址(虚拟页号+偏移量),cpu通过内存管理单元(MMU)找到对应到物理内存中的物理地址并返回,过程如下:
查询快表缓存(TLB)找到是否有缓存的页帧(物理页号),有则直接返回缓存的内容
否则在页表(分页管理)中,找到虚拟地址的虚拟页号对应的物理页号的起始地址,返回物理地址(物理地址=物理起始地址+偏移量)
用户空间与内核空间
为了避免用户进程直接操作内核,保证内核安全,操作系统将虚拟内存划分为两部分,一部分是内核空间(Kernel-space),一部分是用户空间(User-space)。在 Linux 系统中,内核模块运行在内核空间,对应的进程处于内核态;而用户程序运行在用户空间,对应的进程处于用户态。
用户空间的进程无法直接访问内核函数,比如由调度系统切换至内核态,才能调用内核函数。这也就是为啥我们说调用glibc库的fread函数的时候需要会发生上下文切换,并且发生两次拷贝(磁盘 -> 内核缓冲区(read buffer) -> 用户缓冲区)的原因
虚拟内存分为用户空间与内核空间,通常按3:1分配,比如32位系统的能表示的最大虚拟地址为4G,将最高的 1G 的字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF)供内核进程使用,称为内核空间;而较低的 3G 的字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个用户进程使用,称为用户空间。下图是一个进程的用户空间和内核空间的内存布局:
内核空间
内核空间总是驻留在内存中,它是为操作系统的内核保留的。应用程序是不允许直接在该区域进行读写或直接调用内核代码定义的函数的。上图左侧区域为内核进程对应的虚拟内存,按访问权限可以分为进程私有和进程共享两块区域。
进程私有的虚拟内存:每个进程都有单独的内核栈、页表、task 结构以及 mem_map 结构等。
进程共享的虚拟内存:属于所有进程共享的内存区域,包括物理存储器、内核数据和内核代码区域。
用户空间
每个用户进程都有一个单独的用户空间,用户空间包括以下几个内存区域:
运行时栈:由编译器自动释放。存放函数的参数值,局部变量和方法返回值等。每当一个函数被调用时,该函数的返回类型和一些调用的信息被存储到栈顶,调用结束后调用信息会被弹出弹出并释放掉内存。栈区是从高地址位向低地址位增长的,是一块连续的内在区域,最大容量是由系统预先定义好的,申请的栈空间超过这个界限时会提示溢出,比如递归申请会出现“栈溢出”现象
内存映射区域:例如将动态库,共享内存等虚拟空间的内存映射到物理空间的内存,一般是 shm,mmap 函数所分配的虚拟内存空间。
运行时堆:用于存放进程运行中被动态分配的内存段,位于 BSS 和栈中间的地址位。由开发人员申请分配(malloc)和释放(free)。堆是从低地址位向高地址位增长,采用链式存储结构。频繁地 malloc/free 造成内存空间的不连续,产生大量碎片。当申请堆空间时,库函数按照一定的算法搜索可用的足够大的空间。因此堆的效率比栈要低的多。
未初始化的数据段:存放未初始化的全局变量,BSS 的数据在程序开始执行之前被初始化为 0 或 NULL。
已初始化的数据段:存放已初始化的全局变量,包括静态全局变量、静态局部变量以及常量。
代码段:存放 CPU 可以执行的机器指令,该部分内存只能读不能写。通常代码区是共享的,即其它执行程序可调用它。假如机器中有数个进程运行相同的一个程序,那么它们就可以使用同一个代码段。
当我们写完代码,编译,链接并且生成可执行文件后,得到的这个东西就是一系列二进制代码的集合,我们管这东西叫做程序,存储在磁盘上。只有当我们执行这个文件后,程序才会被操作系统读入内存运行,比如
[mqq@9-37-26-84 ~]$ size /usr/local/bin/curltext data bss dec hex filename155348 1348 272 156968 26528 /usr/local/bin/curl
上面命令输出的地址就是各个分段的虚拟地址,当进程执行一个程序时,需要先从先内存中读取该进程的指令,然后执行,获取指令时用到的就是虚拟地址。这个虚拟地址是程序链接时确定的(内核加载并初始化进程时会调整动态库的地址范围)。为了获取到实际的数据,CPU 需要将虚拟地址转换成物理地址,CPU 转换地址时需要用到进程的页表(Page Table),而页表(Page Table)里面的数据由操作系统维护。
内存管理机制
分段管理(Segmentation)
分段机制下的虚拟地址由两部分组成,段选择子和段内偏移量。
段选择子:保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等控制位。
段内偏移量:位于 0 和段界限之间,如果段内偏移量是合法的,则段基地址+段内偏移量=物理内存地址。
程序若干个逻辑分段(栈,堆,数据等)会映射到对应的物理内存分段,如果要访问(栈)段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为,段 3 在物理内存的基地址 7000 + 偏移量 500 = 7500。
分段的内存碎片问题(内存碎片的产生可自行google)比较严重,所以引入了分页管理(更小颗粒度)
分页管理
分页是把整个虚拟和物理内存空间切成一段段固定尺寸的页大小。在 Linux 下,1页=4KB。
在分页机制下的虚拟地址有两部分组成,分别是页号和页内偏移。
页号:作为页表的索引,映射虚拟页号到物理页号在物理内存的基地址
页内偏移量:上面的基地址+页内偏移=物理内存地址
下面举个例子,虚拟内存中的页通过页表映射为了物理内存中的页,如下图:
这里讲一下MMU中具体的页表映射逻辑
虚拟页面中每一项包含多个控制位
VALID 位表示是否缓存
SUP 位表示进程是否必须运行在超级用也就是内核模式下才能访问该页
WRITE 位控制页面的写访问
EXRC 位控制页面的执行
在任意时刻,虚拟页面分为三个不同的状态:
未分配的,VM 系统还未分配(或者创建)的页,未分配的页没有任何数据和它们关联,因此不占用任何内存空间。
缓存的,当前已缓存在物理内存中的已分配页。
未缓存的,未缓存在物理内存中的已分配页。
思考个问题,如果进程访问某个虚拟页面,但没有对应的物理内存映射,会发生什么?
会触发页中断,就是我们说的Page Fault
上图所示,访问不到虚拟页的内存的时候
(2)检测Valid=0,发生缺页中断,然后交给缺页中断处理器处理逻辑
(3)缺页中断处理器,从物理内存中选择某一合适的页置换(swap)到磁盘,然后从磁盘中创建新的虚拟页面,更新虚拟页与物理页的映射,然后返回用户进程。当异常处理程序返回时,它会重启执行导致缺页的指令,该指令会将导致缺页的虚拟地址重新发送到MMU。此时该虚拟页号已经在主存(物理内存)中了,那么就是页命中了。
简单分页会有空间的问题,因为每个进程都维护自己的页表,空间浪费,所以引入了多级页表机制,从而大大减少页表空间
回到刚才的内存碎片的问题,内存碎片通常分为内部碎片和外部碎片:
内部碎片是由于采用固定大小的内存分区,当一个进程不能完全使用分给它的固定内存区域时就产生了内部碎片,通常内部碎片难以完全避免。
外部碎片是由于某些未分配的连续内存区域太小,以至于不能满足任意进程的内存分配请求,从而不能被进程利用的内存区域。
一般我们解决的是外部碎片问题,而现在操作系统普遍采用的段页式内存分配方式,就是将进程的内存区域分为不同的段,然后将每一段由多个固定大小的页组成。通过页表机制,使段内的页可以不必连续处于同一内存区域,从而减少了外部碎片,然而同一页内仍然可能存在少量的内部碎片,只是一页的内存空间本就较小,从而使可能存在的内部碎片也较少。
段页式内存管理
段页式内存管理机制下,虚拟地址由段号、段内页号和页内位移三部分组成。
段页式地址变换中要得到物理地址须经过三次内存访问:
第一次访问段表,得到页表起始地址;
第二次访问页表,得到物理页号;
第三次将物理页号与页内位移组合,得到物理地址。
总的来说,避免外碎片的方法有两种:
利用分页单元把一组非连续的空闲页框映射到连续的线性地址
开发一种适当的技术来记录现存的空闲的连续页框块的情况,以尽量避免为满足对小块的请求而分割大的空闲快
第一种方案的意思是,我们使用地址转换技术,把非连续的物理地址转换成连续的线性地址。就是上面提到的段页式内存分配
第二种方案的意思是,开发一种特有的分配技术来记录下来空闲内存的情况,从而解决内存碎片问题。linux系统下采取了伙伴系统(buddy)和slab以及相关的分配算法,回收机制提高了内存的利用率(具体感兴趣的可以自行google)。
整体内存管理架构
总结:
内存分为虚拟内存和物理内存,虚拟内存分成用户空间和内核空间(3:1),内存管理有分段,分页,段页式机制,地址转换由MMU负责(TLB+页表)映射,涉及知识点有页面置换,缺页错误,内存碎片等
要对内存管理子系统有深入的了解,还需要了解虚拟内存管理子系统中的各种机制与算法
buddy (伙伴系统(buddy system):以页为单位管理和分配内存)
slab(基于伙伴系统分配的大内存进一步细分成小内存分配)
zone(内核地址映射逻辑)
kswapd(页面换入换出,页缓存回收,zone扫描)
bdflush(延迟dirty buffers刷盘机制)
但是本篇文章旨在为了对内存管理有个整体的了解,更深入的会放到后面的文章中