本文是笔者参考许多网站做的一些笔记心得来加深自己对操作系统的理解,非常感谢小林的网站给我一个整体的框架,我同时汇总一些相关的问题,方便日后查阅。如果有任何不清楚的地方,欢迎在评论区评论,笔者持续更新。
本文参考:
小林coding的网站
Linux内存管理
操作系统页面置换算法
Linux内核内存回收逻辑和算法(LRU)
操作系统高频面试题
操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来,让每个进程都有互不干涉的虚拟地址空间(32位系统中每个进程操作4G虚拟内存),这里需要将「多进程」的思想理解起来。如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这里操作系统帮我们做好了转换工作。
进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问物理内存(也就是买的电脑标明的那种内存)。
MMU管理虚拟地址与物理地址之间的关系有两种方式:内存分段和内存分页。
分段内存管理当中,地址是二维的,一维是段号,二维是段内地址;其中每个段的长度是不一样的,而且每个段内部都是从0开始编址的。由于分段管理中,每个段内部是连续内存分配,但是段和段之间是离散分配的,因此也存在一个逻辑地址到物理地址的映射关系,相应的就是段表机制。
段表机制下,可以通过虚拟地址的段选择因子,在段表内找到对应的物理内存段基地址,通过加上段内偏移量找到真正的物理内存地址,如下图所示。
内存分段管理值得关注的问题是内存碎片的产生,这里我们关注三个对象,内部内存碎片(可以理解为分配了物理空间但没用完的),外部内存碎片(本来使用的但是现在不用的物理内存),磁盘。内存分段管理可以做到段根据实际需求分配,因此不会出现内部内存碎片。但是对应到物理内存上,可能会出现程序释放的可能性,留下了一段空闲内存,但是这段空闲内存又不足以容纳新的程序,从而产生一系列的外部内存碎片,如下图所示。
解决「外部内存碎片」的方式是内存交换,即内存和磁盘的swap。如下图所示,我们可以先把音乐所占的256M内存写出到外界磁盘中,然后重新装载进来拼接到游戏的512M内存后面,这样子就能腾出空闲的物理内存。在 Linux 系统里,也就是我们常看到的 「Swap 空间」,这块空间是从硬盘划分出来的,用于物理内存与硬盘的空间交换。由于内存和磁盘的访问速度过慢了,因此内存交换的效率也不高。
分页是把整个虚拟空间按顺序划分成一个个固定尺寸大小的块,叫做页,大小一般为4KB
,物理空间同样按顺序划分成同等大小的块,叫做页框。虚拟地址到物理地址的转换是,MMU通过页表,先找到虚拟地址的页号对应的物理页号,然后加上偏移量,得到对应的物理地址。
分页地址由于分配单位是页,因此即使程序不满一页仍然会被分到一页,因此产生内部内存碎片。如果内存不够,分页也可以效率很高地从磁盘换入或者换出磁盘。更进一步地,每个进程创建加载时,内核只是为进程“创建”了虚拟内存的布局,具体就是初始化进程控制表中内存相关的链表,实际上并不立即就把虚拟内存对应位置的程序数据和代码拷贝到物理内存中,只是建立好了虚拟内存和物理内存的映射。等到运行程序需要用到的物理页在通过虚拟地址找不到,这可以从页表的标识位判断出来,就会产生「缺页异常」,将硬盘中的页换入。
分页是怎么解决分段的「外部内存碎片和内存交换效率低」的问题?
内存分页由于内存空间都是预先划分好的,也就不会像内存分段一样,在段与段之间会产生间隙非常小的内存,这正是分段会产生外部内存碎片的原因。而采用了分页,页与页之间是紧密排列的,所以不会有外部碎片。
但是,因为内存分页机制分配内存的最小单位是一页,即使程序不足一页大小,我们最少只能分配一个页,所以页内会出现内存浪费,所以针对内存分页机制会有内部内存碎片的现象。
如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。
更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
分页机制下,虚拟地址和物理地址是如何映射的?
在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址,见下图。
总结一下,对于一个内存地址转换,其实就是这样三个步骤:
在 32 位的环境下,计算机可以操作的虚拟地址空间共有 4GB,假设一个页的大小是 4KB(212),那么就需要大约 100 万 (220) 个页,每个「页表项」需要 4 个Byte大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。
在多进程的情况下,每个进程都有自己的虚拟地址空间映射关系,就是都有自己的页表,100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。
一个页的大小是 4KB(212),每个「页表项」需要 4 个Byte大小来存储,那么一个页能存储1024个页表项(210),我们这里用1024(210)个表项作为一级页表,每个表项可以查找1024(210)个二级页表。如此一来,一级页表就可以覆盖整个 4GB 虚拟地址空间,我们的进程也可以通过一级页表操作整个虚拟空间,非多级页表的页表项是1对1,多级页表的一级页表项是1对1024。页表在访问时,本质上都是找到页表起始地址+在该地址上的偏移量,多级页表就需要多次转换。
由于程序的「局部性原理」,大多数程序并不需要使用到所有4GB虚拟空间,一级页表项很多都是空的,根本没有分配。如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表,全部都要涵盖) + 20% * 4MB(二级页表)= 0.804MB,是一笔巨大的节约。
把二级页表推广到多级页表也是如此,都是归功于「局部性原理」。
**多级页表解决了空间上的问题,但是却多了几道转换步骤,带来了时间上的开销。**程序具有「局部性」,频繁访问的程序段局限在某个存储区域上,于是可以在CPU芯片中加入一个专门存放程序最常访问的页表项的 Cache,它就叫快表(TLB)。有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,直接一步找到对应的物理内存地址,如果没找到,才会继续查常规的页表。
内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,那么组合起来后,通常称为段页式内存管理。
段页式内存管理实现的方式:
段页式地址变换中要得到物理地址须经过三次内存访问:
Intel 处理器的发展历史
早期 Intel 的处理器从 80286 开始使用的是段式内存管理。但是很快发现,光有段式内存管理而没有页式内存管理是不够的,这会使它的 X86 系列会失去市场的竞争力。因此,在不久以后的 80386 中就实现了页式内存管理。也就是说,80386 除了完成并完善从 80286 开始的段式内存管理的同时还实现了页式内存管理。
但是这个 80386 的页式内存管理设计时,没有绕开段式内存管理,而是建立在段式内存管理的基础上,这就意味着,页式内存管理的作用是在由段式内存管理所映射而成的地址上再加上一层地址映射。
由于此时由段式内存管理映射而成的地址不再是“物理地址”了,Intel 就称之为“线性地址”(也称虚拟地址)。于是,段式内存管理先将逻辑地址映射成线性地址,然后再由页式内存管理将线性地址映射成物理地址。
这里说明下逻辑地址和线性地址:
逻辑地址是「段式内存管理」转换前的地址,线性地址则是「页式内存管理」转换前的地址。
逻辑地址的形式是这样的:「段基址 :偏移值」 ,并不是一个具体的值,而是一个包含段基址和偏移值的对数。
将段基址(映射部分段表找到段号对应的页表起始地址)与偏移值(未映射部分段内页号+页内位移)相加,就得到线性地址(虚拟地址)了。再交由 CPU 的 MMU 将线性地址映射为物理地址(页表起始地址+段内页号得到物理页号+页内偏移量),然后就可以交给地址总线去进行读写了。
Linux 采用了什么方式管理内存?
Linux内存采用的是「段页式的虚拟内存技术」,这主要是上面 Intel 处理器发展历史导致的,因为 Intel X86 CPU 一律对程序中使用的地址先进行段式映射,然后才能进行页式映射。既然 CPU 的硬件结构是这样,Linux 内核也只好服从 Intel 的选择。
Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的(前面的段表映射的页表起始地址都是一样的)。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。
页是信息的物理单位,实现离散化分配,提高了内存的使用率。段则是信息的逻辑单位,满足用户管理一段相对完整的信息,提供访问控制和内存保护。段机制是Linux在「组织虚内存」,而页机制则是在「实现虚拟内存」。
在实现虚拟内存的过程中,当进程要求运行的时,不是将他的全部信息装入内存,而是将其一部分先装入物理内存,另一部分暂时留在磁盘中,进程在运行过程中,要使用信息不在物理内存时,发生中断,由操作系统将他们调入物理内存,以保证进程的正常运行。但是进程仍然会认为自己已经将程序完全调入进来了,拥有连续可用的内存。
常见的页面置换算法可以看这篇文章操作系统页面置换算法,将操作系统基本的页面置换算法进行了解释。
Linux采用的页面置换算法是一种改进地LRU算法–最近最少使用(LRU)页面的衰老算法,详细的实现可以参考这篇文章Linux内核内存回收逻辑和算法(LRU),Linux操作系统对 LRU 的实现主要是基于一对双向链表:active 链表
和 inactive 链表
,同时引入了两个页面标志符 PG_active
和 PG_referenced
用于标识页面的活跃程度,PG_active
用于表示页面当前是否是活跃的,如果该位被置位,则表示该页面是活跃的。PG_referenced
用于表示页面最近是否被访问过,每次页面被访问,该位都会被置位。那些最近最少使用的页面会被逐个放到 inactive 链表的尾部。进行页面回收的时候,Linux 操作系统会从 inactive 链表的尾部开始进行回收。
在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同。比如最常见的 32 位和 64 位系统,如下所示:
通过这里可以看出:
虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存,它能够控制计算机的硬件资源,例如协调CPU资源,分配内存资源。
内核空间是由内核负责映射,不会跟着进程变化;内核空间地址有自己对应的页表,用户进程各自有不同的页表。
固定映射区(Fixing Mapping Region):该区域和4G的顶端只有4k的隔离带,其每个地址项都服务于特定的用途,如ACPI_BASE等。
永久内存映射区(PKMap Region):该区域可访问高端内存。访问方法是使用alloc_page(_GFP_HIGHMEM)分配高端内存页或者使用kmap函数将分配到的高端内存映射到该区域。
动态内存映射区(Vmalloc Region):该区域由内核函数vmalloc来分配,特点是:虚拟空间连续,但是对应的物理空间不一定连续。vmalloc分配的虚拟地址所对应的物理页可能处于低端内存,也可能处于高端内存。
直接映射区(Direct Memory Region):虚拟空间中从3G开始往上最大896M的区间,为直接内存映射区,该区域的虚拟地址和物理地址存在线性转换关系:虚拟地址=3G+物理地址。
static char *pszUserName=“jiangxuzhao”
static char *pszUserName
进程内存管理的对象是进程使用的虚拟内存区域(virtual memory areas,即VMA)。
相关数据结构之间的关系:
Linux内核通过一个被称为进程描述符的task_struct 结构体来管理进程,这个结构体包含了一个进程所需的所有信息。task_struct中有一个结构体被称为内存描述符的mm_struct,描述了一个进程的整个虚拟地址空间。每个进程正是因为都有自己的mm_struct,才使得每个进程都有自己独立的虚拟的地址空间。mm_struct中的pgd域为页表的指针,就是前面讲的一级页表。
每一段已经分配的虚拟内存区域都会以一个vm_area_struct结构体表示,而组织这些结构一共有两种形式:其中mm_struct->mm_rb是所有vm_area_struct组成的红黑树,而mm_struct->mmap是所有vm_area_struct组成的链表,vm_area_struct这个数据结构被双重管理主要是为了加速查找速度(空间换时间)。
mm_struct中的mmap指针指向的vm_area_struct链表的每一个节点就代表进程的一段虚拟地址空间,即一段VMA。一段VMA最终可能对应ELF可执行程序的数据段、代码段、堆、栈、或者动态链接库的某个部分。可以理解成进程管理着很多逻辑分段,和前面「Linux段页式虚拟内存技术」一致。
mm_struct内存描述符基本字段:
vm_area_structs主要字段:
先看看硬件层面上的UMA和非UMA:
在每个内存管理区(Zone)内,页框由伙伴系统Buddy来分配,伙伴算法负责大块连续物理内存的分配和释放,以页框为基本单位,可以避免外部碎片,可以分配20~29大小的物理内存块。
我们知道经过页机制我们可以不必要求有大块连续物理内存,我们可以通过页机制东拼西凑出一块内存,虽然物理内存不连续,但是通过页机制,程序员感觉在操作一块连续的虚拟内存。但我们更倾向于分配连续的物理页框,这有利于页表的管理。那么为了能够分配连续的物理内存,就需要解决页外碎片(外部碎片)的问题,在Linux中采用伙伴算法来解决。
Buddy 算法将所有的空闲物理页分成 10 组,每组分别包含大小 1,2,4,8,16,32,64,128,256,512 个连续物理页。每一组用链表组织起来。
分配方法:
回收方法:
回收算法根据提供的块大小,将块放到大小对应的链表中。如果放入过程中发现有空闲伙伴块, 则合并伙伴, 形成更大的块放到对应链表中。是否为伙伴块需要满足的条件:
通过伙伴算法分配的页框都是4K的,但是内核使用的很多都是小对象,可能就几十字节,比如存放文件描述符、进程描述符、虚拟内存区域描述符等行为所需的内存都不足一页,这就产生了内部碎片。Linux为了解决内部碎片的问题,在内核实现了slab分配器,主要针对内核中经常分配并释放的对象。
应用程序通过 malloc 函数申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存。但是虚拟内存在不同位数(32或者64)的操作系统上,也有上限。
当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存, 这时会发现这个虚拟内存没有映射到物理内存, CPU 就会产生缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理。