深入理解Linux内核内存管理机制与实现(上)

本文涉及的产品
公网NAT网关,每月750个小时 15CU
简介: 深入理解Linux内核内存管理机制与实现

Linux内核的内存管理机制是通过使用虚拟内存来管理系统中的物理内存。虚拟内存将进程的地址空间划分为多个页面,每个页面大小通常为4KB或更大。这些页面被映射到物理内存或者交换空间上。

Linux使用了分页机制来实现虚拟内存管理。每个进程都有自己独立的页表,用于将虚拟地址转换成物理地址。当进程访问一个尚未映射到物理内存的虚拟地址时,发生缺页异常,操作系统会根据需要从磁盘上加载相应的数据,并进行页面映射。

Linux还使用了各种高级技术来优化内存管理性能,例如页表项缓存(Translation Lookaside Buffer, TLB)用于加速地址转换过程,Copy-on-Write(写时复制)机制减少对于共享页面的不必要复制等。

一、CPU寻址原理和分页管理

1.1寻址内存

CPU访问外设,有两种类型,一个是内存空间,一个是IO空间。IO空间,X86通过in/out指令访问外设,IO空间只存在X86架构,在RISC架构不存在;

内存空间,CPU通过指针访问所有内存空间,内存空间分为两类,普通内存和位于内存空间的寄存器。其他设备的寄存器,比如通过I2C总线访问触摸屏的寄存器,与CPU内存空间无关。

MMU原理:

对于一个支持MMU的CPU,只要开启MMU,CPU跟程序员视角一致,看到的永远是虚拟地址;在访问内存空间时,CPU发一个虚拟地址(指针),MMU把虚拟地址映射为物理地址。其他硬件设备,比如DMA/IVE(硬件模块不带mmu)访问也是物理地址。

虚拟地址和物理地址:

比如在两个不同的进程QQ/WECHAT里,可以分别定义一个变量,虚拟地址相同(都为1G);真正访问的时候,经过MMU映射到实际不同的物理地址。

每个进程都维护自己的页表,相同的虚拟地址可以映射到不同的物理地址;

当CPU访问0x1234560时,先查询页地址0x1234对应的物理地址(比如1M),560为页内偏移

patch:

int *p=1m; //对指针赋值物理地址,是错误的

物理地址的本质

  • Linux 内核,物理地址定义为一个无符号64/32位整数;
  • 32处理器虚拟地址最大4G,物理地址不一定32位,可以大于4G;
  • 物理地址位数可以大于虚拟地址位数;

内存地址分为三种不同的地址:逻辑地址、线性地址、物理地址。

  • 逻辑地址:就是我们普遍应用使用的地址。每个逻辑地址都有一个段(段标识符)和偏移量(指定内存相对地址)表示,偏移量表示段开始的地方到实际地址之间的距离。段标识符也叫段选择符,是一个16位长的字段。偏移量是一个32位长的字段。
  • 线性地址:就是虚拟地址。
  • 物理地址:内存真正的地址,用于内存芯片级内训单元寻址。

内存控制单元 通过分段单元的硬件电路把逻辑地址转换为线性地址, 然后通过分页单元的硬件电路将线性地址转换为物理地址。

在多处理器的系统中,多个CPU共享一个内存,但是在RAM芯片上的读写操作必须串行的进行,因此有一种 内存仲裁器(memory arbiter) 的硬件电路插在总线和每个RAM芯片之间,来控制CPU使用内存。这是个硬件电路,编程方面不必考虑。为方便快速查找到段选择符 ,从而找到地址,处理器提供 “段寄存器”来存放段选择符。段寄存器只存放段选择符段寄存器只有6个,但是程序可以使用一个段寄存器用于不同的目的,方法是现将其保存在内存中,使用时再恢复。

段寄存器分为 SS 、CS 、DS 、FS 、 ES 、GS 其中三个是特殊使用,另外三个作为普通使用,可以指向任意的数据段

特殊使用:SS(栈段寄存器)指向包含当前程序栈的段,CS(代码段寄存器)指向包含当前程序指令的段。特殊功能:包含一个两位的字段,指明cpu的当前特权级,分为0(00)和3,0为内核态,3(11)为用户态DS(数据段寄存器)指向包含静态数据或者全局数据的段,段描述符:(8字节)不是段选择符。段描述符描述了段的特征,就像进程描述符与描述了进程的所有特征一样。放在全局描述符表(GDT)或者局部描述符表(LDT)。通常只有一个GDT,每个进程除了有自己的 GDT外一般都有附加的LDT。描述符包含段的字节线性地址,描述符特权级等等,内容不做赘述。

80x86提供一种非编程的寄存器(一个程序员不能设计的寄存器),提供给六个可编程的段寄存器使用,非编程的寄存器存放8字节的段描述符,由相应的段寄存器中的段选择符来指定。每当一个段选择符被装入段寄存器时,相应的段描述符就被装入非编程的CPU寄存器中。此时针对那个段的逻辑地址转换就可以不访问主存中的GDT或者LDT,CPU只需直接引用存放段描述符寄存器即可获得对应的线性地址。当寄存器内的段改变时才会再去访问GDT或者LDT。大致流程是这样的,段选择符被载入段寄存器,根据GDT或者LDT找到相应的段描述符放入非编程的寄存器中,访问非编程的寄存器中的段描述符,获得逻辑地址对应的线性地址。

linux广泛采用的不用类型的段和对应的描述符:

  1. 代码段描述符:表示是一个代码段,可以放在GDT和LDT中。
  2. 数据段描述符:表示是一个数据段,可以放在GDT和LDT中。
  3. 任务状态段描述符:代表一个任务状态段,例如处理器寄存器内容,只能出现在GDT中。
  4. 局部描述符表描述符:表示一个包含LDT的段,只能出现在GDT中。

1.2硬件中的分段

Intel微处理器以两种不同的方式执行地址转换,这两种方式分别称为实模式和保护模式。实模式存在的主要原因是要维持处理器与早期模型兼容,并让操作系统自举。

段选择符和段寄存器:一个逻辑地址由两部分组成:一个段标识符和一个指定段内相对地址的偏移量。为了快速方便地找到段选择符,处理器提供段寄存器,段寄存器的唯一目的是存放段选择符。这些段寄存器称为cs,ss,ds,es,fs和gs。尽管只有6个段寄存器,但程序可以把同一个段寄存器用于不同的目的。6个寄存器中3个有专门的用途:(1)cs代码段寄存器,指向包含程序指令的段;(2)ss栈段寄存器,指向包含当前程序栈的段;(3)ds数据段寄存器,指向包含静态数据或者全局数据段;其它3个段寄存器作一般用途,可以指向任意的数据段。cs寄存器还有一个很重要的功能:包含有一个两位的字段,用以指明CPU的当前特权级(0最高,3最低)。

段描述符:每个段由一个8字节描述符表示,它描述了段的特征。段描述符放在全局描述符(GDT)或局部描述符表(LDT)中。通常只定义一个GDT,而每个进程除了存放在GDT中的段之外如果还需要创建附加的段,就可以有自己的LDT。GDT在主存中的地址和大小存放在gdtr控制寄存器,当前正被使用的LDT地址和大小放在ldtr控制寄存器中。有几种不同类型的段以及和它们对应的段描述符,几种Linux广泛采用的类型:代码段描述符,数据段描述符,任务状态段描述符(TSSD),局部描述表描述符(LDTD)。

快速访问段描述符:为了加速逻辑地址到线性地址的转换,80x86处理器提供一种附加的非编程的寄存器供6个可编程的段寄存器使用。

分段单元:(1)先检查段选择符的TI字段,以决定段描述符保存在哪一个描述符中;(2)从段选择符的index字段计算段描述符的地址,index字段的值乘以8,这个结果与gdtr或ldtr寄存器中的内容相加;(3)把逻辑地址的偏移量与段描述符Base字段的值相加就得到线性地址。

分页单元把线性地址转换为物理地址。其中一个关键任务是把所请求的访问类型与线性地址的访问权限相比较,如果这次访问是无效的,就产生一个缺页异常。为了效率起见,线性地址被分成以固定长度的组,称为页(page)。页内部连续的线性地址被映射到连续的物理地址中。

这样可以指定一个页的物理地址和其存取权限,而不用指定页所包含的全部线性地址的存取权限。分页单元把所有的RAM分成固定长度的页框(物理页)。把线性地址映射在物理内存地址的数据结构称为页表。页表存放在主存中,并在启动分页单元之前必须由内核对页表进行适当的初始化。

常规分页:32位线性地址被分为个域(目录、页表、偏移量),线性地址的转换分两步完成,每一步都基于一种转换表,第一种转换表称为页目录表,第二种转换表称为页表。使用这种二级模式的目的在于减少每个进程页表所需RAM的数量。二级模式通过只为进程实际使用的那些虚拟内存区请求页表来减少内存使用量。页目录项和页表项有同样的结构,每项都包含下面的字段(present标志、Field包含页框物理地址最高20位的字段、Accessed标志、Dirty标志、Read/Write标志、User/Supervisor标志、PCD和PWT标志、Page Size标志、Global标志)。

扩展分页:扩展分页用于把大段连续的线性地址转换成相应的物理地址,在这些情况下,内核可以不用中间页进行地址转换,从而节省内存并保留TLB项(转换后援缓冲器)。通过设置页目录项的Page size标志启用扩展分页功能。在这种情况下,分页单元把32位线性地址分成两个字段(Directory最高10位、Offset其余22位)。扩展分页和正常分页的页目录项基本相同,除了:(1)Page SIze标志位必须被设置;(2)20位物理地址字段只有最高10位是有意义的,这是因为每个物理地址都是在以4M为边界的地方开始的,故这个地址的最低22位为0。

硬件保护方案:分页单元和分段单元的保护方案不同。与段的3种存取权限(读、写、执行)不同的是,页的存取权限只有两种(读、写)。如果页目录项或页表项的Read/Write标志等于0,说明相应的页表或页是只读的,否则是可读写的。

物理地址扩展(PAE)分页机制:处理器所支持的RAM容量受连接到地址总线上的地址管脚数限制。通过将管脚数增加至36已经满足这些需求2^36=64GB,不过需要把32位线性地址转换为36位物理地址才能使用所增加的物理地址。

Intel为了支持PAE已经改变了分页机制:

  • (1)64GB的RAM被分为2^24个页框,页表项的物理地址字段从20位扩展到了24位;
  • (2)引入一个叫作页目录指针表的页表新级别,它由4个64位表项组成;
  • (3)cr3控制寄存器包含一个27位的页目录指针表(PDPT)基地址字段;
  • (4)当把线性地址映射到4KB的页时(页目录项中的PS标志清0),32位线性地址按下列方式解释:cr3指向一个PDPT、位31-30指向PDPT中4个项的一个、位29-21指向页目录中的512个项中的一个、位20-12指向页表中512项中的一个、位11-0 4KB页中的偏移量;
  • (5)当把线性地址映射到2MB的页时(页目录项中的PS标志置为1),32位线性地址按下列方式解释:cr3指向一个PDPT、位31-30指向PDPT中4个项的一个、位29-21指向页目录中的512个项中的一个、位20-0 2MB页中的偏移量。

64位系统中的分页:所有64位处理器的硬件分页系统都使用了额外的分页级别。使用的级别数量取决于处理器的类型。

硬件高速缓存:为了缩小CPU和RAM之间的速度不匹配,引入了硬件高速缓存内存。硬件高速缓存基于著名的局部性原理,该原理既适用程序结构也适用于数据结构。80x86体系结构中引入了一个叫行的新单元,行由几十个连续的字节组成,它们以脉冲突发模式在慢速DRAM和快速的用来实现高速缓存的片上静态RAM(SRAM)之间传送,用来实现高速缓存。高速缓存再被细分为行的子集。

在一种极端的情况下,高速缓存可以是直接映射的,这时主存中的一个行总是存放在高速缓存中完全相同的位置。在另一种极端情况下,高速缓存是充分关联的,这意味着主存中的任意一个行可以存放在高速缓存中的任意位置。但大多数高速缓存再某种程度上是N-路组关联的,意味着主存中的任意一个行可以存放在高速缓存N行中的任意一行中。

高速缓存单元插在分页单元和主内存之间,它包含一个硬件高速缓存内存和一个高速缓存控制器。高速缓存内存存放内存中真正的行。高速缓存控制器存放一个表项数组,每个表项对应高速缓存内存中的一个行。这种内存物理地址通常分为3组:最高几位对应标签,中间几位对应高速缓存控制器的子集索引,最低几位对应行内的偏移量。多处理器系统的每个处理器都有一个单独的硬件高速缓存,因此它们需要额外的硬件电路用于保持高速缓存内容同步。

转换后援缓存器(TLB):80x86处理器还包含了另一个称为转换后援缓存器或TLB的高速缓存用于加快线性地址的转换。当一个线性地址被第一次使用时,通过慢速访问RAM中的页表计算出相应的物理地址,同时物理地址被存放在一个TLB表项中,以便以后对同一线性地址的引用可以快速地得到转换。


1.3Linux中的分段

Linux采用了一种同时适用于32位和64位系统的普通分页模型。从2.6.11版本开始采用四级分页模型:页全局目录、页上级目录、页中间目录、页表。Linux的进程处理很大程度上依赖于分页。事实上,线性地址到物理地址的自动转换使下面的设计目标变得可行:(1)给每个进程分配不同一块不同的物理地址空间,这确保了可以有效地防止寻址错误;(2)区别页(即一组数据)和页框(即主存中的物理地址)之不同,这就允许存放在某个页框中的一个页,然后保存到磁盘上,以后重新装入这同一页时又可以被装在不同的页框中,这就是虚拟内存机制的基本要素。

分段可以给每一个进程分配不同的线性地址空间,而分页可以把同一线性地址空间映射到不同的物理空间。与分段相比,Linux更喜欢使用分页方式,因为:(1)当所有进程使用相同的段寄存器时,内存管理变得更简单,也就是说它们能共享同样的一组线性地址;(2)Linux设计目标之一是可以把它移植到绝大多数流行的处理器平台上,然而,RISC体系结构对分段的支持很有限。运行在用户态的所有Linux进程都使用一对相同的段来对指令和数据寻址,这两个段就是所谓的用户代码段和用户数据段。

线性地址段:PAGE_SHIFT、PMD_SHIFT、PUD_SHIFT、PGDIR_SHIFT等。

页表处理:pte_t,pmd_t,pud_t和pgd_t分别描述页表项,页中间目录项、页上级目录和页全局目录项的格式。

物理内存布局:内核将下列页框记为保留(在不可用的物理地址范围内的页框、含有内核代码和已初始化的数据结构的页框),保留页框中的页绝不能被动态分配或交换到磁盘上。一般来说,Linux内核安装在RAM中从地址0x00100000开始的地方,也就是说从第二个MB开始,为什么内核没有安装在RAM第一个MB开始的地方?因为PC体系结构有几个独特的地方必须考虑。

进程页表:进程的线性地址空间分成两部分:(1)从0x00000000到0xbfffffff的线性地址,无论进程运行到用户态还是内核态都可以寻址;(2)从0xc0000000到0xffffffff的线性地址,只有内核态的进程才能寻址。但是在某些情况下,内核为了检索或存放数据必须访问用户态线性地址空间。

内核页表:内核维持着一组自己使用的页表,驻留在所谓的主页内核全局目录中。第一阶段内核创建一个有限的地址空间,包括内核的代码段和数据段、初始化页表和用于存放动态数据结构的共128KB大小的空间。第二阶段内核充分利用剩余的RAM并适当地建立分页表。具体实施:临时内核页表、当RAM小于896MB时的最终内核页表、当RAM大小在896MB和4096MB之间时的最终内核页表、当RAM大于4096MB时的最终内核页表。

固定映射的线性地址:基本上是一种类似于0xffffc000这样的常量线性地址。概念上类似于对RAM前896MB映射的线性地址。

处理硬件高速缓存和TLB:内存寻址的最后一个主题是关于内核如何使用硬件高速缓存来达到最佳效果。硬件高速缓存和转换后援缓存器(TLB)在提高现代计算机体系结构的性能上扮演着重要角色。为了使高速缓存的命中率达到最优化,内核在下列决策中考虑体系结构:(1)一个数据结构中最常使用的字段放在该数据结构内的低偏移部分,以便它们能够处于高速缓存的同一行中;(2)当为一大组数据结构分配空间时,内核试图把它们都存放在内存中,以便所有高速缓存行按同一方式使用。一般来说,任何进程切换都会暗示着更换活动页表集,相对于过期页表,本地TLB表项必须被刷新,不过在下列情况下将避免TLB被刷新:(1)当两个使用相同页表的普通进程之间执行进程切换时;(2)当在一个普通进程和一个内核线程间执行进程切换时。

Linux GDT:每一个GDT中包含的18个段描述符指向下列的段:

  • (1)用户态和内核态下的代码段和数据段共4个;
  • (2)任务状态段(TSS),每个处理器有1个,每个TSS相应的线性地址空间都是内核数据段相对应线性地址空间的一个小子集;
  • (3)1个包括缺省局部描述表的段,这个段通常是被所有的进程共享的段;
  • (4)3个局部线程存储(TLS)段:这种机制允许多线程应用程序使用最多3个局部于线程的数据段,系统调用set_thread_area()和get_thread_area()分别为正在执行的进程创建和撤销一个TLS段;
  • (5)与高级电源管理(AMP)相关的字段:由于BIOS代码段,所以当Linux APM驱动程序调用BIOS函数来获取或者设置APM设备的状态时,就可以使用自定义的代码段和数据段;
  • (6)与支持即插用(PnP)功能的BIOS服务程序相关的5个段;
  • (7)被内核用来处理“双重错误”异常的特殊的TSS段。

Linux LDT:缺省的局部描述符表存放在default_ldt数组中。在某些情况下,进程仍然需要创建自己的局部描述符表,modify_ldt()系统调用允许进程修改自己的局部描述符表。

【文章福利】小编推荐自己的Linux内核技术交流群:【 865977150】整理了一些个人觉得比较好的学习书籍、视频资料共享在群文件里面,有需要的可以自行添加哦!!!

二、MMU工作原理

MMU是内存管理单元(Memory Management Unit)的缩写。它是计算机系统中的一个硬件组件,负责将虚拟地址映射到物理地址,并提供对内存的访问控制和保护机制。MMU通常用于支持虚拟内存系统,可以将进程的虚拟地址空间划分成多个页,并将这些页映射到物理内存上。通过使用MMU,操作系统可以实现进程之间的隔离、动态内存分配和保护,提高了系统的安全性和可用性。

2.1MMU的产生

许多年以前,当人们还在使用DOS 或者更古老的操作系统的时候,计算机的内存还非常小,一般都是以K 为单位进行计算的,相应的,当时的程序规模也不大,所以内存容量虽然小,但还是可以容纳当时的程序。

但随着图形界在的兴起,用户需求的不断增大,应用程序的规模也随之膨胀起来,终于一个难题出现在程序员的面前,那就是应用程序太大,以至于内存容纳不下该程序。

通常解决的办法是把程序分割成许多份称为覆盖块(overlay)的片段。

覆盖块0 首先运行,结束时他将调用另一个覆盖块。

虽然覆盖块的交换是由OS 完成的,但是必须先由程序员先进行分割,这是一个费时费力的工作,而且相当枯燥。

人们必须找于更好的办法从根本上解决这个问题。

不久人们找到了一个办法,这就是虚拟存储器(virtual memory)。

1)虚拟存储器(Virtual Memory)

虚拟存储存的基本思想是:程序、数据、堆栈的总的大小可以超过物理存储器的大小,操作系统把当前使用的部分保留在内存中,而把其他未被使用的部分保存在磁盘上。

比如,对一个16MB 的程序 和一个内存只有4MB 的机器,OS 通过选择,可以决定各个时刻将哪4MB 的内容保留在内存中,并需要时在内存和磁盘间交换程序片段,这样就可以把这个16MB 的程序运行在一个具有4MB 内存机器上了。而这个16M的程序在运行前不必由程序员进行分割。

任何时候,计算机上都存在一个程序能够产生的地址集合,我们称之为地址范围。

这个范围的大小由CPU 的位数决定,例如一个32位的CPU ,它的地址范围是0x0 ~ 0xFFFF FFFF (4G),而对于一个64位的CPU ,它的地址范围为0x0 ~ 0xFFFF FFFF FFFF FFFF (64T)

这个范围就 是我们程序能够产生的地址范围,我们把这个地址范围称为虚拟地址空间,该空间中的某一个地址我们称之为虚拟地址。

与虚拟地址空间 和 虚拟地址相对应的则是物理地址空间 和 物理地址,大多数时候,我们的系统所具备的物理地址空间只是虚拟地址空间的一个子集,这时举一个最简单的例子直观的说明这两者,对于一台内存为256MB 的32Bit x86 主机来说,它的虚拟地址空间范围是0x0 ~ 0xFFFF FFFF (4G),而物理地址空间范围是0x0000 0000 ~ 0x0FFF FFFF ( 256MB )

在没有使用虚拟地址的机器上,虚拟地址被直接送到内存总线上,使具有相同地址的物理存储被读写。而使用了虚拟存储的情况下,虚拟地址不是被直接送到内存地址总线上,而是送到内存管理单元— MMU

MMU由一个或一组芯片组成,一般存在于协处理器中,其功能是把虚拟地址映射为物理地址。

  1. CPU 看到的是 Virtual Adress (程序中的逻辑地址)
  2. Caches 和 MMU 使用的是 MVA (实际的虚拟地址 MVA = (pid << 25) | VA)
  3. 实际物理设备使用的是 Physical Address (物理地址)

2)MMU的工作过程

大多数使用虚拟存储器的系统都使用一种称为分页(paging)。

虚拟地址空间划分为页(page)的单位,而相应的物理地址空间也被进行划分,单位是页框(frame)。

页和页框的大小必须相同。

接下来配全图片,以一个例子说明页与页框之间在MMU 的调度下是如何进行映射的:

在这个例子中,我们有一个可以生成16位地址的机器,它的虚拟地址范围从0x0000 ~ 0xFFFF(64k),而这台机器只有32K 的物理地址,因此它可以运行64K 的程序,但该程序不能一次性调入内存运行。

这台机器必须有一个达到可以存放64K 程序 的外部存储器(例如磁盘或Flash) 以保证程序片段在需要时可以被调用。

这个例子中,页的大小为64K ,页框大小与页相同(这点必须保证的,内存和外围存储器之间传输总是以页为单位),对 应64K 的虚拟地址和32K 的物理存储器,它们分别包含了16 个页 和 8 个页框。

执行下面这些指令:

MOVE REG,0// 将 0 号地址的值传递进寄存器 REG

虚拟地址 0 将被送往MMU,MMU看到该虚拟地址落在页0 范围内(页0 范围是0 到 4095),从上图我们可以看出页0 所对应的(映射)的页框为 2(页框2的地址范围是8192 到 12287)。

因此,MMU 将该虚拟地址转化为物理地址 8192, 并把地址8192送到地址总结上。

内存对MMU 的映射一无所知,它只看到一个对地址8192的读请求并执行它,MMU 从而将8192 到 12287换虚拟地址解析为对应的物理地址 0 到 4096 。

MOVE REG , 20500
被转换为---->  MOVE REG, 12308

因为虚拟地址20500 在虚页5(虚拟地址范围是20480 到 24575)距开头20个字节处,虚页5映射到页框3(页框3的地址范围是12288 到 16383),于是被映射到物理地址12288 + 20 = 12308

MOV REG , 32780

虚拟地址32780 落在页 8 的范围内,从上图我们看出,页8并没有被 有效的进行映射(该页被打上X),这时又会发生什么呢?

MMU 注意到这个页没有被映射,于是通知CPU 发生一个缺页故障(page fault),这种情况下,操作系统必须处理这个页故障,它必须从8个物理页框中找到一个很少被使用的页框,并把该页框的内容写入外围存储器(这个动作被称为page copy),随后把需要引用的页(本例 是页8)映射到刚才被释放的页框中(这个动作被称为修改映射关系),然后重新执行产生故障的指令(MOV REG, 32780).

假定操作系统,决定释放页框1, 以使以后任何对虚拟地址4K 到 8K 的访问都引起故障而使操作系统做出适当的动作。

其次它把虚页8 对应的页框号由X 变为1, 因此得新执行MOV REG, 32780,MMU 将32780 映射为 4180。

我们已经知道,大多数使用虚拟存储器的系统都使用一种称为分页(paging)的技术,就象我们刚才所举的例子,虚拟地址空间被分为大小相同的一组页,每个页有一个用来标示它的页号(这个页号一般是它在该组中的索引,这点和C/C++中的数组相似)。

在上面的例子中0~4K的页号为0,4~8K的页号为1,8~12K的页号为2,以此类推。

而虚拟地址(注意:是一个确定的地址,不是一个空间)被MMU分为2个部分,第一部分是页号索引(page Index),第二部分则是相对该页首地址的偏移量(offset).

我们还是以刚才那个16位机器结合下图进行一个实例说明,该实例中,虚拟地址8196被送进MMU,MMU把它映射成物理地址。16位的CPU总共能产生的地址范围是0~64K,按每页4K的大小计算,该空间必须被分成16个页。而我们的虚拟地址第一部分所能够表达的范围也必须等于16(这样才能索引到该页组中的每一个页),也就是说这个部分至少需要4个bit。

该地址的页号索引为0010(二进制码),即索引的页为页2,第二部分为000000000100(二进制),偏移量为4。

页2中的页框号为6(页2映射在页框6,见上图),我们看到页框6的物理地址是24~28K。于是MMU计算出虚拟地址8196应该被映射成物理地址24580(页框首地址+偏移量=24576+4=24580)。

同样的,若我们对虚拟地址1026进行读取,1026的二进制码为0000010000000010page index="0000"=0,offset=010000000010=1026

页号为0,该页映射的页框号为2,页框2的物理地址范围是8192~12287,故MMU将虚拟地址1026映射为物理地址9218(页框首地址+偏移量=8192+1026=9218)。

以上就是MMU的工作过程。

2.2虚拟内存管理

现代操作系统普遍采用虚拟内存管理(Virtual Memory Management)机制,这需要处理器中的MMU(Memory Mangement Unit,内存管理单元)提供支持。

首先引入两个概念,虚拟地址和物理地址:

  • 如果处理器没有MMU,或者有MMU 但没有启用,CPU执行单元发出的内存地址将直接传到芯片引脚上,被物理内存芯片接收,这称为物理地址。
  • 如果处理器启用了MMU,CPU执行单元发出的内存地址将被 MMU 截获,从CPU到MMU 的地址称为虚拟地址,而MMU 将这个地址翻译成另一个地址,发到CPU芯片的外部地址引脚上,也就是将VA映射成了PA 了。

如果是32位处理器, 则内存地址总线是32位的,与CPU 执行单元相连,而经过MMU转换后的外地址总线则不一定是32位。

也就是说,虚拟地址空间与物理地址空间是独立的,32位处理器的虚拟地址空间是4GB,而物理地址空间既可以大于也可以小于4G。

MMU 将 VA映射到PA是以页(page)为单位的,32位处理器的页尺寸通常是4KB。

例如:

MMU 可以通过一个映射项将VA 的一页0xB7001000 - 0xB7001FFFF映射到PA 的一页0x2000 ~ 0x2FFF

如果CPU 执行单元要访问虚拟地址0xB7001008,则实际访问到的物理地址是0x2008。

物理内存中的页称为物理页帧(page frame),虚拟内存的哪个页面映射到物理内存的哪个页帧是通过页表(Page Table)来描述的,页表保存在物理内存中,MMU 会查找页表来确定一个VA 应该映射到什么PA.

操作系统和 MMU 是这样配合的:操作系统在初始化或分配、释放内存时会执行一些指令在物理内存中填写页表,然后用指令设置MMU,告诉MMU 页表在物理内存中的什么位置。

设置好之后,CPU 每次执行访问内存的指令都会自动引发MMU 做查表和地址转换操作,地址转换操作由硬件自动完成,不需要用指令控制MMU 去做。

我们在程序中使用的变量和函数都有各自的地址,程序被编译后,这些地址就成了指令中的地址,指令中的地址被 CPU解释执行,就成了CPU的执行单元发出的内存地址,所以在启用MMU 的情况下,程序中使用的地址都是虚拟地址,都会引发MMU 做查表和地址转主换操作。

那为什么要设计这么复杂的内存管理机制呢? 多了一层VA 到 PA 的转换到底换来什么好处?

MMU 除了做地址转换之外,还提供内存保护机制,各种体系结构都有用户模式(User Mode)和特权模式(Privileged Mode)之分,操作系统可以在页表中设置每个内存页面的访问权限,有些页面不允许访问,有些页面只有在CPU 处于特权模式时才允许访问,有些页面在用户模工和特权模式都可以访问,访问权限又分为可读、可写 和可执行三种。

这样设定好之后,当CPU 要访问一个VA 时,MMU都会检查CPU 当前处于用户模式还是特权模式,访问内存的目的是读数据、写数据、还是取指令,如果和操作系统设定的页面权限相符,就允许访问,把它转换成PA ;如果不允许访问,就产生一个异常(Exception)。

异常处理过程和中断类似,不同的是中断由外部设备产生而异常由CPU 内部产生,中断产生的原因和CPU 当前执行的指令无关,而异常的产生就是由于CPU 当前执行的指令出了问题,例如,访问内存的指令被 MMU 检查出权限错误,除法指令的除数为0 都会产生异常。

2.3用户空间和内核空间

通常操作系统把虚拟地址划分为用户空间和内核空间,例如 X86平台的Linux 系统虚拟地址空间是0x00000000 - 0xFFFFFFFF,前3GB(0x00000000 - 0xBFFFFFFF)是用户空间,后1GB(0xC0000000 - 0xFFFFFFFF)是内核空间。

用户程序加载到用户空间,在用户模式下执行,不能访问内核中的数据,也不能跳转到内核代码中执行。

这样可以保护内核,如果一个进程访问了非法地址,顶多这一个进程崩溃,而不会影响到内核和整个系统的稳定性。

CPU 在产生中断和异常时不仅会跳转到中断或异常服务程序,还会自动切换模式,从用户模式切换到特权模式,因此从中断或异常服务程序可以跳转到内核代码中执行。

事实上,整 个内核就是由各种中断和异常处理程序组成的,在正常情况下 ,处理器在用户模式执行用户程序,在中断或异常情况下处理器切换到特权模式执行内核程序,处理完中断或异常之后再返回用户模式继续执行用户程序。

2.4MMU以及RWX权限、kernel和user模式权限

32位处理器,页表有32位,但实际维护页表只用到高20位,低12位用来存放其他信息比如权限

CPU访问一个地址,不仅要做虚拟/物理地址页表地址查询,还要检查页表权限,出现非法操作时,MMU拦截CPU访问请求,并报pagefault,CPU收到MMUpagefault中断时,报段错误,发信号11,进程默认信号处理方式是挂掉。

找不到物理地址,或者权限不对,发生段错误;

tips:gdb layout src

meltdown漏洞,利用时间旁路攻击原理,从用户空间获取内核空间数据;

MMU作用总结:

(1). 内存隔离:防止应用程序访问不属于自己的空间,隔离应用对其它应用空间、内核空间的访问。因为它们只能访问自己的虚拟空间,每个进程的虚拟空间都是独立的。

(2). 权限管理:不同地址段拥有不同的访问权限,不能越权访问。

(3). 地址映射:将不连续的物理地址映射为连续的虚拟地址,并可以通过swap机制可以获得比实际内存大得多的使用空间。

前两者实现数据安全,第三条为程序提供空间便利。

再补充两点:

1.直接使用物理内存,当即将运行的程序内存不足时,需要选择一个进程整体换出,这导致有大量数据的换出与换入,效率低下;即MMU可以解决内存使用效率问题;

2.有效解决碎片化问题;

三、 Linux内存

3.1内存的zone: DMA、Normal和HIGHMEM

32位Linux内核空间物理地址映射在3G~4G,分三个区域,

ZONE_DMA(0~16M):DMA内存分配区;

ZONE_NORMAL(16MB~896MB): 普通映射的内存区域;

ZONE_HIGHMEM(896MB~):高端内存区域,其中的页不能永久映射到内核地址空间;内核一般不使用,如果要使用,通过kmap做动态映射;

存在高端内存的原因是,当物理内存大于虚拟内存寻址范围(1G)时,需要做非线性映射,才能在访问所有物理内存空间范围。32系统一般都划分896M以上物理地址为高端内存。

在目前流行的64位CPU中,由于虚拟寻址位数足够,就不存在高端内存概念了。

896M以下,开机直接映射好(通过virt_to_phys/phys_to_virt简单线性映射),896MB以上动态分配,可以被内核和用户空间访问;

对于64位CPU有足够寻址能力,就不存在869MB问题了,64位处理器,一般没有HGIHMEM ZONE;

对于一个嵌入式设备,内存太小,也不存在 HIGHMEM区域;

DMAzone存在的根本原因是DMA硬件有缺陷,比如一个典型32位系统ISA设备的DMA只有24位寻址能力,也就是只能访问内存的前16M内存,而整个系统的最大物理内存寻址甚至可以大于1G,超过内核的虚拟空间;

DMA没有MMU,所以只能访问连续物理内存;少数最新的DMA具有MMU,也可以访问物理不连续的内存,MMU页表映射即可。

当为一个DMA有缺陷设备申请内存时,传入标记GFP_DMA,在DMA_ZONE区域分配内存,如果DMA可以访问所有内存空间,则不用受限于DMA_ZONE。而其他普通设备,也可以申请DMA_ZONE.

申请DMA内存,会填入设备访问范围,根据范围确定申请的内存区域;

DMA内存不一定来自于DMA ZONE,DMA ZONE也不一定用于DMA,如果系统所有DMA都没有缺陷,则不存在dma_zone;

DMA直接访问内存,不会让外设访问速度更快,DMA最大好处是解放CPU资源,速度受限外设总线及访问频率;

3.2Linux内存管理Buddy算法

Buddy 算法直面物理内存,Buddy算法把内存中的所有页面按照2的幂次进行分块管理,分配的时候如果没有相应大小块,就把大的块二分成小块。释放的时候,回收的块跟相邻的空闲伙伴块又能合并成大块;

BUDDY页面的分配和使用情况可以通过proc接口/proc/buddyinfo来查看:

Buddy有个缺陷,会造成许多内存碎片,比如总和还剩余很大,但是没有足够连续的空余内存可用。

早期采用预留分配方法,给予显卡,摄像头等预留内存,即使设备不使用,也会预留。

3.3连续内存分配器(CMA)

分配专门的CMA区域,用于DMA设备的内存申请,比如摄像头,当摄像头设备不用时,该区域可以用于其他内存分配;当使用摄像头时,将该区域内的所有已申请内存,拷贝到其他分散的页,并且修改对应页表。

这样CMA大块连续内存,就不会被浪费掉,也保持了大块连续内存的访问需求;

把不同的DMA设备,放在不同的CMA,就不会导致内存碎片化;

3.4内存的动态申请

1)slab、kmalloc/kfree、/proc/slabinfo和slabtop

Buddy 是直面物理内存的,所有的内存分配,最终都通过Buddy的get_free_page/page_alloc分配;

Buddy的粒度太大,最小分配一页(4k); 而我们常常需要分配小内存;

所以Linux引入一个二级分配的概念:

  • 1.内核分配内存,调用kmalloc()/kfree()–调用slab–再调用Buddy ;
  • 2.用户空间malloc/free–调用C库–C库通过brk/mmap调用Buddy;

free释放的内存,只是释放给C库,未必真正释放;释放给C库的内存,其他进程无法使用,共享代码段,数据段独立。

slab原理:就是从Buddy拿到一个/多个页,分成多个相同大小的块,比如进程控制块task_struct这种内核中常用的结构体,可以先从Buddy申请一个slab池,实际kmalloc分配的时候,直接从slab里分配。可以提高速度,也可以使内存得到充分使用,减少内存碎片,每一个slab里的所有块,大小一定是相同的。

sudo cat /proc/slabinfo

cat /proc/meminfo

slab也是一个内存泄漏的源头,应用程序free内存,未必一定释放,可以通过mallopt设置free内存的触发阈值,触发阈值,free才真正在Buddy释放。

(2) kmalloc / vmalloc详细过程

(1)kmalloc从低端内存区域分配,该区域是开机就一次性映射好;所以kmalloc申请,不需要做映射, 且在物理内存上是连续的,phys_to_virt/virt_to_phys只是一个简单的内存线性偏移。

(2)vmalloc申请的虚拟内存,可以从普通物理内存空间分配,也可以从低端内存分配;调用vmalloc申请,会有一个虚拟地址到物理地址映射过程。

ioremap映射的也是vmalloc虚拟地址,不过不用申请内存,ioremap映射的是寄存器;

映射跟分配是两回事,低端被映射之后,不一定被使用;

sudo cat /proc/vmallocinfo |grep ioremap
//可以查看虚拟地址映射物理地址/寄存器关系

kmalloc和vmalloc区别大概可总结为:

  • (1)vmalloc分配的一般为普通内存,只有当内存不够的时候才分配低端内存;kmalloc从低端内存分配。
  • (2)vmalloc分配的物理地址一般不连续,而kmalloc分配的物理地址连续,两者分配的虚拟地址都是连续的;
  • (3)vmalloc分配的一般为大块内存,而kmallc一般分配的为小块内存;
相关实践学习
每个IT人都想学的“Web应用上云经典架构”实战
本实验从Web应用上云这个最基本的、最普遍的需求出发,帮助IT从业者们通过“阿里云Web应用上云解决方案”,了解一个企业级Web应用上云的常见架构,了解如何构建一个高可用、可扩展的企业级应用架构。
相关文章
|
10天前
|
算法 Linux 调度
深入理解Linux内核调度器:从基础到优化####
本文旨在通过剖析Linux操作系统的心脏——内核调度器,为读者揭开其高效管理CPU资源的神秘面纱。不同于传统的摘要概述,本文将直接以一段精简代码片段作为引子,展示一个简化版的任务调度逻辑,随后逐步深入,详细探讨Linux内核调度器的工作原理、关键数据结构、调度算法演变以及性能调优策略,旨在为开发者与系统管理员提供一份实用的技术指南。 ####
44 4
|
8天前
|
缓存 Java Linux
如何解决 Linux 系统中内存使用量耗尽的问题?
如何解决 Linux 系统中内存使用量耗尽的问题?
|
4天前
|
算法 Linux 开发者
Linux内核中的锁机制:保障并发控制的艺术####
本文深入探讨了Linux操作系统内核中实现的多种锁机制,包括自旋锁、互斥锁、读写锁等,旨在揭示这些同步原语如何高效地解决资源竞争问题,保证系统的稳定性和性能。通过分析不同锁机制的工作原理及应用场景,本文为开发者提供了在高并发环境下进行有效并发控制的实用指南。 ####
|
11天前
|
缓存 负载均衡 Linux
深入理解Linux内核调度器
本文探讨了Linux操作系统核心组件之一——内核调度器的工作原理和设计哲学。不同于常规的技术文章,本摘要旨在提供一种全新的视角来审视Linux内核的调度机制,通过分析其对系统性能的影响以及在多核处理器环境下的表现,揭示调度器如何平衡公平性和效率。文章进一步讨论了完全公平调度器(CFS)的设计细节,包括它如何处理不同优先级的任务、如何进行负载均衡以及它是如何适应现代多核架构的挑战。此外,本文还简要概述了Linux调度器的未来发展方向,包括对实时任务支持的改进和对异构计算环境的适应性。
33 6
|
12天前
|
缓存 Linux 开发者
Linux内核中的并发控制机制:深入理解与应用####
【10月更文挑战第21天】 本文旨在为读者提供一个全面的指南,探讨Linux操作系统中用于实现多线程和进程间同步的关键技术——并发控制机制。通过剖析互斥锁、自旋锁、读写锁等核心概念及其在实际场景中的应用,本文将帮助开发者更好地理解和运用这些工具来构建高效且稳定的应用程序。 ####
32 5
|
8天前
|
缓存 Linux
如何检查 Linux 内存使用量是否耗尽?
何检查 Linux 内存使用量是否耗尽?
|
12天前
|
缓存 运维 网络协议
深入Linux内核架构:操作系统的核心奥秘
深入Linux内核架构:操作系统的核心奥秘
31 2
|
4月前
|
缓存 监控 关系型数据库
深入理解Linux操作系统的内存管理机制
【7月更文挑战第11天】在数字时代的浪潮中,Linux操作系统凭借其强大的功能和灵活性,成为了服务器、云计算以及嵌入式系统等领域的首选平台。内存管理作为操作系统的核心组成部分,对于系统的性能和稳定性有着至关重要的影响。本文将深入探讨Linux内存管理的基本原理、关键技术以及性能优化策略,旨在为读者提供一个全面而深入的理解视角,帮助开发者和系统管理员更好地优化和管理Linux系统。
|
6月前
|
算法 安全 Linux
深度解析:Linux内核内存管理机制
【4月更文挑战第30天】 在操作系统领域,内存管理是核心功能之一,尤其对于多任务操作系统来说更是如此。本文将深入探讨Linux操作系统的内核内存管理机制,包括物理内存的分配与回收、虚拟内存的映射以及页面替换算法等关键技术。通过对这些技术的详细剖析,我们不仅能够理解操作系统如何高效地利用有限的硬件资源,还能领会到系统设计中的性能与复杂度之间的权衡。
|
6月前
|
缓存 监控 算法
Linux内核的SLAB内存管理机制
Linux内核的SLAB内存管理机制
356 4
下一篇
无影云桌面