《趣谈Linux》总结四:内存管理

16 内存管理(上)-规划进程内存空间布局

每个进程应该有自己的内存空间。
内存空间都是独立的、相互隔离的。
对于每个进程来讲,看起来应该都是独占的。

16.1 独享内存空间的原理

内存都被分成一块一块儿的,都编好了号,这些一块一块的地址是实实在在的地址,通过这个地址我们就能够定位到物理内存的位置;
如果所有进程都使用这些地址,同时发生同一个位置的写操作时很容易起冲突

所以使用虚拟地址:物理地址对于进程不可见,谁也不能直接访问这个物理地址。
操作系统会给进程分配一个虚拟地址。
所有进程看到的这个地址都是一样的,里面的内存都是从0开始编号。
在程序里面,指令写入的地址是虚拟地址。
操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。
当程序要访问虚拟地址的时候,由内核的数据结构进行转换,转换成不同的物理地址,这样不同的进程运行
的时候,写入的是不同的物理地址,这样就不会冲突了。

16.2 规划虚拟地址空间

通过16.1的原理可以看出,操作系统的内存管理主要分为三个方面:
第一,物理内存的管理
第二,虚拟地址的管理
第三,虚拟地址和物理地址如何映射

用户态的进程使用虚拟地址,内核态的也基本都是使用虚拟地址,只有内存管理系统才能使用物理地址;

16.2.1 用户态(普通进程)的角度

  • 需求:

代码需要放在内存里面;
全局变量,例如max_length;
常量字符串"Input the string length : ";
函数栈,例如局部变量num是作为参数传给generate函数的,这里面涉及了函数调用,局部变量,函数参
数等都是保存在函数栈上面的;
堆,malloc分配的内存在堆里面;
这里面涉及对glibc的调用,所以glibc的代码是以so文件的形式存在的,也需要放在内存里面。

  • 实现:通过分段

(0号到29号会议室)
在这里插入图片描述
Text Segment是存放二进制可执行代码的位置,Data Segment存放静态常量,BSS Segment存放未初始化的静态变量;
在二进制执行文件里面,就有这三个部分,这里就是把二进制执行文件的三个部分加载到内存里面。

接下来是堆(Heap)段。堆是往高地址增长的,是用来动态分配内存的区域,malloc就是在这里面分配
的。

接下来的区域是Memory Mapping Segment。这块地址可以用来把文件映射进内存用的,如果二进制的执
行文件依赖于某个动态链接库,就是在这个区域里面将so文件映射到了内存中。

再下面就是栈(Stack)地址段。主线程的函数调用的函数栈就是用这里的。

如果普通进程还想进一步访问内核空间,是没办法的。
如果需要进行更高权限的工作,就需要调用系统调用,进入内核。

16.2.2 内核的角度

  • 需求:

内核的代码要在内存里面;
内核中也有全局变量;
每个进程都要有一个task_struct;
每个进程还有一个内核栈;
在内核里面也有动态分配的内存;
虚拟地址到物理地址的映射表放在哪里?

  • 实现:

一旦进入了内核,就换了一副视角。

刚才是普通进程的视角,觉着整个空间是它独占的,没有其他进程存在。
当然另一个进程也这样认为,因为它们互相看不到对方。
这也就是说,不同进程在相同地址上放的东西都不一样。

但是到了内核里面,无论是从哪个进程进来的,看到的都是同一个内核空间,看到的都是同一个进程列表。
虽然内核栈是各用个的,但是如果想知道的话,还是能够知道每个进程的内核栈在哪里的。
所以,如果要访问一些公共的数据结构,需要进行锁保护
也就是说,不同的进程进入到内核后,进入的内存区域是相同的(30号到39号会议室是同一批会议室。)
在这里插入图片描述
内核的代码访问内核的数据结构,大部分的情况下都是使用虚拟地址的,虽然内核代码权限很大,但是能够
使用的虚拟地址范围也只能在内核空间,也即内核代码访问内核数据结构。
只能用30号到39号这些编号,不能用0到29号,因为这些是被进程空间占用的。
而且,进程有很多个。你现在在内核,但是你不知道当前指的0号是哪个进程的0号。
在内核里面也会有内核的代码,同样有Text Segment、Data Segment和BSS Segment:内核启动的时候,内核代码也是ELF格式的。

17 内存管理(下):物理地址和虚拟地址的映射

可以使用分段机制:
在这里插入图片描述
分段机制下的虚拟地址由两部分组成:段选择子和段内偏移量。

段选择子保存在段寄存器里面。
段选择子里面最重要的是段号,用作段表的索引。
段表里面保存的是这个段的基地址、段的界限和特权等级等。

虚拟地址中的段内偏移量应该位于0和段界限之间。如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。

案例:将每个进程的虚拟空间分成以下4个段,用0~3来编号(如图左边)。
每个段在段表中有一个项(如图中间),在物理空间中,段的排列如图右边所示:
在这里插入图片描述
如果要访问段2中偏移量600的虚拟地址,我们可以计算出物理地址为,段2基地址2000 + 偏移量600 =2600。

在Linux里面,段表全称段描述符表(segment descriptors),放在全局描述符表GDT(Global Descriptor
Table)里面,段描述符表里面存储着一个个的段表项

一个段表项由段基地址base、段界限limit,还有一些标识符组成;
对于64位的和32位的系统,都定义了内核代码段、内核数据段、用户代码段和用户数据段。

另外,还会定义四个段选择子,指向段描述符表项

在Linux中,所有的段的起始地址都是一样的,都是0,并没有分段,这可以看出并没有使用到全部的分段功能;
而分段的作用,则是可以做权限审核,例如用户态DPL是3,内核态DPL是0。当用户态试图访问内核态的时候,会因为权限不足而报错。

Linux倾向于另外一种从虚拟地址到物理地址的转换方式,称为分页(Paging)。

对于物理内存,操作系统把它分成一块一块大小相同的页,这样更方便管理;
例如有的内存页面长时间不用了,可以暂时写到硬盘上,称为换出
一旦需要的时候,再加载进来,叫作换入
这样可以扩大可用物理内存的大小,提高物理内存的利用率。

换入和换出都是以页为单位的;
页面的大小一般为4KB;
为了能够定位和访问每个页,需要有个页表,保存每个页的起始地址,再加上在页内的偏移量,组成线性地址,就能对于内存中的每个位置进行访问了:
在这里插入图片描述
虚拟地址分为两部分:页号和页内偏移。
页号作为页表的索引,页表包含物理页每页所在物理内存的基地址。这个基地址与页内偏移的组合就形成了物理内存地址。

案例:虚拟内存中的页通过页表映射为了物理内存中的页
在这里插入图片描述
32位环境下,虚拟地址空间共4GB。(4194304kb)(kb/kb=b)
如果分成4KB一个页,那就是1M个页。(4096kb)
每个页表项需要4个字节(4B)来存储,即每页额外用了4个字节,那么整个4GB空间的映射就需要4MB的内存来存储映射表。
如果每个进程都有自己的映射表,100个进程就需要400MB的内存。
对于内核来讲,有点大了 。

且页表中所有页表项必须提前建好,并且要求是连续的;
如果不连续,就没有办法通过虚拟地址里面的页号找到对应的页表项了;
这样就不能等到需要时再建,也不能使用链表的方式来保证内存不被浪费

那怎么办呢?
可以将页表再分页
4G的空间需要4M的页表来存储映射,把这4M分成1K(1024)个4K,每个4K又能放在一页里面,这样1K个4K就是1K个页;
这1K个页也需要一个表进行管理,我们称为页目录表,这个页目录表里面有1K项,每项4个字节,页目录表大小也是4K。
页目录有1K项,用10位就可以表示访问页目录的其中一项;
这一项其实对应的是一整页的页表项,也即4K的页表项;
每个页表项也是4个字节,因而一整页的页表项是1K个,再用10位就可以表示访问页表项的哪一项,页表项中的一项对应的就是一个页,是存放数据的页,这个页的大小是4K,用12位可以定位这个页内的任何一个位置。

这样加起来正好32位,也就是用前10位定位到页目录表中的一项,将这一项对应的页表取出来共1k项,再用中间10位定位到页表中的一项,将这一项对应的存放数据的页取出来,再用最后12位定位到页中的具体位置访问数据。
在这里插入图片描述
如果这样的话,映射4GB地址空间就需要4MB+4KB的内存;
但是,我们往往不会为一个进程分配那么多内存;
比如说,上面图中,我们假设只给这个进程分配了一个数据页;
如果只使用页表,也需要完整的1M个页表项共4M的内存;
但是如果使用了页目录,页目录需要1K个全部分配,占用内存4K,但是里面只有一项使用了;
到了页表项,只需要分配能够管理那个数据页的页表项页就可以了,也就是说,最多4K,这样内存就节省多了。

对于64位的系统,两级不够,就变成了四级目录;
分别是全局页目录项PGD(Page Global Directory)、上层页目录项PUD(Page Upper Directory)、中间页目录项PMD(Page Middle Directory)和页表项PTE(Page Table Entry):
在这里插入图片描述

  • 总结

内存管理系统可以精细化为下面三件事情:
第一,虚拟内存空间的管理,将虚拟内存分成大小相等的页;
第二,物理内存的管理,将物理内存分成大小相等的页;
第三,内存映射,将虚拟内存也和物理内存也映射起来,并且在内存紧张的时候可以换出到硬盘中。
在这里插入图片描述

18 进程空间管理

内存管理有三个方面:虚拟内存空间的管理、物理内存的管理以及内存映射;
本节详细介绍第一个方面:进程的虚拟内存空间是如何管理的

18.1 用户态和内核态的划分

task_struct里面有一个struct mm_struct结构来管理内存。

里面的unsigned long task_size是用户态地址空间和内核态地址空间的分界线

对于32位系统,最大能够寻址2^32=4G,其中用户态虚拟地址空间是3G,内核态是1G。

对于64位系统,虚拟地址使用了48位,共128T,内核空间也是128T。内核空间和用户空间之间隔着很大的空隙,以此来进行隔离
在这里插入图片描述

18.2 用户态虚拟空间的布局

struct mm_struct里面定义了代码、全局变量、堆、栈、内存映射区等区域的统计信息和位置,以所占用的页的数量为单位,如下图,16.2.1中可以找到其存放的内容
在这里插入图片描述
这些区域是通过struct mm_struct里面的一个结构vm_area_struct来描述的,这是一个单链表,用于将这些区域串起来;
另外还有一个红黑树,用于快速查找一个内存区域,并在需要改变的时候,能够快速修改。

虚拟内存区域可以映射到物理内存,也可以映射到文件,映射到物理内存的时候称为匿名映射;
映射到文件就需要有vm_file指定被映射的文件。(mmap指的是具体内存对象的映射,而之前说的虚拟内存到物理内存的映射是指整块的,mmap就是在这分配的整块里面再进行分配的映射)

struct mm_struct的vm_area_struct到上述区域的内存映射图:
在这里插入图片描述
映射完毕后,什么情况下会被修改:
1 第一种情况是函数的调用,涉及函数栈的改变,主要是改变栈顶指针
2 第二种情况是通过malloc申请一个堆内的空间,底层要么执行brk,要么执行mmap。

18.2.1 brk解析

堆是从低地址向高地址增长的

流程开始:将原来的堆顶和现在的堆顶,都按照页对齐地址,然后比较大小

如果两者相同,说明这次增加的堆的量很小,还在一个页里面,不需要另行分配页。

如果发现新旧堆顶不在一个页里面,则需要跨页;如果发现新堆顶小于旧堆顶,这说明不是新分配内存了,而是释放内存了,而且是至少释放了一页;

如果堆将要扩大,找到原堆顶所在的vm_area_struct的下一个vm_area_struct,看当前的堆顶和下一个vm_area_struct之间还能不能分配一个完整的页;
如果不能,内存空间都被占满了,直接退出返回;
如果还有空间,就调用do_brk进一步分配堆空间,从旧堆顶开始,分配计算出的新旧堆顶之间的页数。

18.3 内核态虚拟空间的布局

内核态的虚拟空间和某一个进程没有关系,所有进程通过系统调用进入到内核之后,看到的虚拟地址空间都是一样的。

32位的内核态的布局(1G):
在这里插入图片描述
前896M称为直接映射区
这一块空间是连续的,和物理内存是非常简单的映射关系:就是虚拟内存地址减去3G,就得到物理内存的位置;
在物理内存的开始的896M的空间,会被直接映射到3G至3G+896M的虚拟地址

896M分解为:
1 在系统启动的时候,物理内存的前1M已经被占用了
2 从1M开始加载内核代码段,然后加载内核的全局变量、BSS等,也是ELF里面涵盖的,即内核的代码段,全局变量,BSS会被映射到3G后的虚拟地址空间里面

物理内存896M以上的是高端内存;
在内核中,除了内存管理模块直接操作物理地址之外,内核的其他模块,仍然要操作虚拟地址,而虚拟地址是需要内存管理模块分配和映射好的

剩下的虚拟内存地址:

  • 在896M到VMALLOC_START之间有8M的空间。

  • VMALLOC_START到VMALLOC_END之间称为内核动态映射空间,也即内核想像用户态进程一样malloc申
    请内存,在内核里面可以使用vmalloc。假设物理内存里面,896M到1.5G之间已经被用户态进程占用了,
    并且映射关系放在了进程的页表中,内核vmalloc的时候,只能从分配物理内存1.5G开始,就需要使用这
    一段的虚拟地址进行映射,映射关系放在专门给内核自己用的页表里面。

  • PKMAP_BASE到FIXADDR_START的空间称为持久内核映射。使用alloc_pages()函数的时候,在物理内存的
    高端内存得到struct page结构,可以调用kmap将其在映射到这个区域。

  • FIXADDR_START到FIXADDR_TOP(0xFFFF F000)的空间,称为固定映射区域,主要用于满足特殊需求。

  • 在最后一个区域可以通过kmap_atomic实现临时内核映射。
    假设用户态的进程要映射一个文件到内存中,先要映射用户态进程空间的一段虚拟地址到物理内存,即将用户态进程虚拟地址和物理内存的映射关系放在用户态进程的页表中,然后将文件内容写入这个物理内存供用户态进程访问。
    分配完毕后,用户态进程可以通过用户态的虚拟地址,也即0至3G的部分,经过页表映射后访问物理内存,并不需要内核态的虚拟地址里面也划出一块来,映射到这个物理内存页。
    但是因为要把文件内容写入物理内存,这件事情要内核来干,所以可以通过kmap_atomic做一个临时映射,写入物理内存完毕后,再kunmap_atomic来解映射即可。

64位的内核布局:64位的内核布局反而简单,因为虚拟空间很大,不需要“高端内存”这个概念,因为内核是128T,根本不可能有物理内存超过这个值:
在这里插入图片描述
从0xffff800000000000开始就是内核的部分,一开始有8T的空档区域。

从 __ PAGE_OFFSET_BASE(0xffff880000000000)开始的64T的虚拟地址空间是直接映射区域,也就是减去PAGE_OFFSET就是物理地址。

虚拟地址和物理地址之间的映射在大部分情况下还是会通过建立页表的方式进行映射。

从VMALLOC_START(0xffffc90000000000)开始到VMALLOC_END(0xffffe90000000000)的32T的空间是
给vmalloc的。

从VMEMMAP_START(0xffffea0000000000)开始的1T空间用于存放物理页面的描述结构struct page的。

从 __ START_KERNEL_map(0xffffffff80000000)开始的512M用于存放内核代码段、全局变量、BSS等;
这里对应到物理内存开始的位置,减去__START_KERNEL_map就能得到物理内存的地址;
这里和直接映射区有点像,但是不矛盾,因为直接映射区之前有8T的空当区域,早就过了内核代码在物理内存中加载的位置。

18.4 总结

一个进程要运行起来需要以下的内存结构:

  • 用户态:

代码段、全局变量、BSS
函数栈

内存映射区

  • 内核态:

内核的代码、全局变量、BSS
内核数据结构例如task_struct
内核栈
内核中动态分配的内存

到了这里都已经有了相应的内存块了

进程运行状态在32位下对应关系:
在这里插入图片描述
64:
在这里插入图片描述

19 物理内存管理

19.1 物理内存的组织形式之页

平坦内存模型(Flat MemoryModel):
把内存想象成它是由连续的一页一页的块组成的。
可以从0开始对物理页编号,这样每个物理页都会有个页号。
由于物理地址是连续的,页也是连续的,每个页大小也是一样的,因而对于任何一个地址,只要直接除一下
每页的大小,很容易直接算出在哪一页。
每个页有一个结构struct page表示,这个结构也是放在一个数组里面,这样根据页号,很容易通过下标找到相应的struct page结构。

经典的内存使用方式:CPU通过总线去访问内存
在这里插入图片描述
在这种模式下,CPU会有多个,在总线的一侧。
而所有的内存条组成一大片内存,在总线的另一侧,所有的CPU访问内存都要过总线,而且距离都是一样的,这种模式称为SMP(Symmetric multiprocessing),即对称多处理器。
缺点:总线会成为瓶颈,因为数据都要通过它来流通:
在这里插入图片描述
为了提高性能和可扩展性,出现了NUMA(Non-uniform memory access):非一致内存访问。
在这种模式下,内存不是一整块。
每个CPU都有自己的本地内存,CPU访问本地内存不用过总线,因而速度要快很多,每个CPU和内存在一起,称为一个NUMA节点。
但是,在本地内存不足的情况下,每个CPU都可以去另外的NUMA节点申请内存,这个时候访问延时就会比较长。这样,内存被分成了多个节点,每个节点再被分成一个一个的页面。
由于页需要全局唯一定位,页还是需要有全局唯一的页号的。
但是由于物理内存不是连起来的了,页号也就不再连续了。于是内存模型就变成了非连续内存模型,管理起来就复杂一些。

NUMA往往是非连续内存模型。而非连续内存模型不一定就是NUMA,有时候一大片内存的情况下,也会有物理内存地址不连续的情况。

后来内存技术慢慢发展,可以支持热插拔了。这个时候,不连续成为常态,于是就有了稀疏内存模型
在这里插入图片描述

  • 物理内存的组织:

从节点到区域到页到小块

  • 物理内存的分配:

对于要分配比较大的内存,例如到分配页级别的,可以使用伙伴系统(Buddy System):
Linux中的内存管理的“页”大小为4KB。
把所有的空闲页分组为11个页块链表,每个块链表分别包含很多个大小的页块,有1、2、4、8、16、32、64、128、256、512和1024个连续页的页块。
最大可以申请1024个连续页,对应4MB大小的连续内存。每个页块的第一个页的物理地址是该页块大小的整数倍:
在这里插入图片描述
当向内核请求分配(2(i-1),2i]数目的页块时,按照2^i页块请求处理。如果对应的页块链表中没有空闲页块,那我们就在更大的页块链表中去找。
当分配的页块中有多余的页时,伙伴系统会根据多余的页块大小插入到对应的空闲页块链表中。

例如,要请求一个128个页的页块时,先检查128个页的页块链表是否有空闲块。
如果没有,则查256个页的页块链表;如果有空闲块的话,则将256个页的页块分成两份,一份使用,一份插入128个页的页块链表中。
如果还是没有,就查512个页的页块链表;如果有的话,就分裂为128、128、256三个页块,一个128的使用,剩余两个插入对应页块链表。

  • 总结:分配系统的实现

如果有多个CPU,那就有多个节点。
每个节点用struct pglist_data表示,放在一个数组里面。
每个节点分为多个区域,每个区域用struct zone表示,也放在一个数组里面。
每个区域分为多个页。为了方便分配,空闲页放在struct free_area里面,使用伙伴系统进行管理和分配,每
一页用struct page表示。
在这里插入图片描述

19.2 物理内存的组织形式之小对象

19.2.1 小内存的分配

如果遇到小的对象,会使用slub分配器进行分配。

工作原理:
调用了kmem_cache_alloc_node函数,在task_struct的缓存区域kmem_cache *task_struct_cach分配了一块内存。

kmem_cache_alloc_node方法的作用:每次创建task_struct的时候,不用到内存里面去分配,而是先在缓存区kmem_cache里面看看有没有直接可用的

kmem_cache_free方法的作用:当一个进程结束,task_struct不用直接被销毁,而是放回到缓存区kmem_cache中

所有的缓存最后都会放在一个链表LIST_HEAD(slab_caches)里面:task_struct、mm_struct、fs_struct等的缓存

缓存结构kmem_cache有三个kmem_cache_order_objects类型的变量。
这里面的order,就是2的order次方个页面的大内存块,objects就是能够存放的缓存对象的数量。

对于缓存来讲,其实就是分配了连续几页的大内存块,然后根据缓存对象的大小,切成小内存块:
在这里插入图片描述
这样就将所有的空闲对象链成一条链,使用数组实现

那这些缓存对象哪些被分配了、哪些在空着,什么情况下整个大内存块都被分配完了,需要向伙伴系统申请
几个页形成新的大内存块?这些信息该由谁来维护呢?
解决这些问题使用到的变量是kmem_cache_cpu和kmem_cache_node,每个NUMA节点上都各有一个。

如图是分配机制相关的图:
在这里插入图片描述
在分配缓存块的时候,要分两种路径,fast path和slow path,也就是快速通道和普通通道。
其中kmem_cache_cpu就是快速通道,kmem_cache_node是普通通道。
每次分配的时候,要先从kmem_cache_cpu进行分配:page有则page,若没有,但partial不为空,则把page的内存替换为partial的内存,然后重新尝试分配,还是失败,则继续;
如果kmem_cache_cpu里面没有空闲的块,那就到kmem_cache_node中进行分配;
如果还是没有空闲的块,才去伙伴系统分配新的页。

在这里,page指向大内存块的第一个页,缓存块就是从里面分配的。freelist指向大内存块里面第一个空闲的项。按照上上图说的,这一项会有指针指向下一个空闲的项,最终所有空闲的项会形成一个链表。

19.2.2 页面换出

无论是32位还是64位,虚拟地址空间都非常大,物理内存不可能有这么多的空间放得下。
所以,一般情况下,页面只有在被使用的时候,才会放在物理内存中。如果过了一段时间不被使用,即便用户进程并没有释放它,物理内存管理也有责任做一定的干预。
例如,将这些物理内存中的页面换出到硬盘上去;将空出的物理内存,交给活跃的进程去使用。

触发的时机:
1 最常见的情况:分配内存的时候,发现没有地方了,就试图回收一下;
2 主动去检测:内核线程kswapd在系统初始化的时候就被创建。它会进入一个无限循环,直到系统停止。
在这个循环中,如果内存使用没有那么紧张,那它就暂时闲着;如果内存紧张了,就需要去检查一下内存,看看是否需要换出一些内存页。

策略:LRU,最近最少使用。也就是说,所有的页面都被挂在LRU列表lru_list中,这个列表里面会按照活跃程度进行排序,这样就容易把不怎么用的内存页拿出来做处理。
内存页总共分两类,一类是匿名页,和虚拟地址空间进行关联;一类是内存映射,不但和虚拟地址空间关联,还和文件管理关联。
每一类都有两个列表,一个是active,一个是inactive,存放在lru_list中
active就是比较活跃的,inactive就是不怎么活跃的。
这两个里面的页会变化,比如过一段时间,活跃的可能变为不活跃,不活跃的可能变为活跃。
如果要换出内存,那就是从不活跃的列表中找出最不活跃的,换出到硬盘:

enum lru_list {
    LRU_INACTIVE_ANON = LRU_BASE,
    LRU_ACTIVE_ANON = LRU_BASE + LRU_ACTIVE,
    LRU_INACTIVE_FILE = LRU_BASE + LRU_FILE,
    LRU_ACTIVE_FILE = LRU_BASE + LRU_FILE + LRU_ACTIVE,
    LRU_UNEVICTABLE,
    NR_LRU_LISTS
};
#define for_each_evictable_lru(lru) for (lru = 0; lru <= LRU_ACTIVE_FILE; lru++)
static unsigned long shrink_list(enum lru_list lru, unsigned long nr_to_scan,
    struct lruvec *lruvec, struct mem_cgroup *memcg,
    struct scan_control *sc)
{
    if (is_active_lru(lru)) {
    if (inactive_list_is_low(lruvec, is_file_lru(lru),
    memcg, sc, true))
    shrink_active_list(nr_to_scan, lruvec, sc, lru);
    return 0;
}
return shrink_inactive_list(nr_to_scan, lruvec, sc, lru);

即,shrink_list会先缩减活跃页面列表,再压缩不活跃的页面列表。
对于不活跃列表的缩减,shrink_inactive_list就需要对页面进行回收;

对于匿名页来讲,需要分配swap,将内存页写入文件系统;
对于内存映射关联了文件的,我们需要将在内存中对于文件的修改写回到文件中。

  • 总结

对于物理内存来讲,从下层到上层的关系及分配模式如下:

物理内存分NUMA节点,分别进行管理;

每个NUMA节点分成多个内存区域;

每个内存区域分成多个物理页面;

伙伴系统将多个连续的页面作为一个大的内存块分配给上层;

kswapd负责物理页面的换入换出;

Slub Allocator将从伙伴系统申请的大内存块切成小块,分配给其他系统。
在这里插入图片描述

20 用户态内存映射

学习了虚拟内存空间如何组织的,也学习了物理页面如何管理的,接下来就是学习如何将虚拟内存空间和物理页面关联起来?使用哪些数据结构?

20.1 mmap原理

每一个进程都有一个列表vm_area_struct,指向虚拟地址空间的不同的内存块,这个变量的名字叫mmap。
在这里插入图片描述
要申请小块内存,就用brk;要申请一大块内存,就用mmap

如果一个进程想映射一个文件到自己的虚拟内存空间,也要通过mmap系统调用。这个时候mmap是映射内存空间到物理内存再到文件。

经过mmap的调用后,虚拟内存的映射就建立起来了,当然这个时候,内存管理并不直接分配物理内存,因
为物理内存相对于虚拟地址空间太宝贵了,只有等真正用的那一刻才会开始分配。

20.2 用户态缺页异常

一旦开始访问虚拟内存的某个地址,如果发现并没有对应的物理页,那就触发缺页中断,调用
do_page_fault。

经过此步处理,物理内存中有了页面,页表也建立好了映射。
接下来,用户程序在虚拟内存空间里面,可以通过虚拟地址经过页表映射的访问物理页面上的数据了。

为了加快映射速度,我们不需要每次从虚拟地址到物理地址的转换都走一遍页表;
页表一般都很大,只能存放在内存中。操作系统每次访问内存都要两步:先通过查询页表得到物理地址,然后访问该物理地址读取指令、数据。
为了提高映射速度,引入了TLB(Translation Lookaside Buffer),经常称为快表,专门用来做地址映射的硬件设备。
它不在内存中,可存储的数据比较少,但是比内存要快。
所以可以想象为TLB就是页表的Cache,其中存储了当前最可能被访问到的页表项,其内容是部分页表项的一个副本。
有了TLB之后,地址映射的过程就如图:
先查快表,快表中有映射关系,然后直接转换为物理地址。如果在TLB查不到映射关系时,才会到内存中查询页表。

这个页表的最顶级的pgd存放在task_struct中的mm_struct的pgd变量里面。
在这里插入图片描述

20.3 总结

用户态的内存映射机制包含以下几个部分:

1 用户态内存映射函数mmap,包括用它来做匿名映射和文件映射。

2 用户态的页表结构,存储位置在mm_struct中。

3 在用户态访问没有映射的内存会引发缺页异常:分配物理页表、补齐页表。
如果是匿名映射则分配物理内存;如果是swap,则将swap文件读入;如果是文件映射,则将文件读入。
在这里插入图片描述

21 内核态内存映射

内核态的内存映射机制,主要包含以下几个部分:

1 内核态内存映射函数vmalloc、kmap_atomic是如何工作的;
2 内核态页表是放在哪里的,如何工作的?swapper_pg_dir是怎么回事;
3 出现了内核态缺页异常应该怎么办?

21.1 内核页表

每个进程的“进程页表”中内核态地址相关的页表项都是“内核页表”的一个拷贝。

和用户态页表不同,在系统初始化的时候,我们就要创建内核页表了:
在这里插入图片描述
XXX_ident_pgt对应的是直接映射区,XXX_kernel_pgt对应的是内核代码区,XXX_fixmap_pgt对应的是固定映射区。

init_top_pgt有三项,上来先有一项,指向的是level3_ident_pgt,也即直接映射区页表的三级目录。

第二项也指向level3_ident_pgt,直接映射区。

第三项指向level3_kernel_pgt,内核代码区。

内核页表定义完了,一开始这里面的页表能够覆盖的内存范围比较小。
例如,内核代码区512M,直接映射区1G。
这个时候,其实只要能够映射基本的内核代码和数据结构就可以了。
可以看出,里面还空着很多项,可以用于将来映射巨大的内核虚拟地址空间,等用到的时候再进行映射。

定义完了内核页表,接下来是初始化内核页表:在系统启动的时候start_kernel会调用setup_arch。

21.2 vmalloc和kmap_atomic原理

在虚拟地址空间里面,有个vmalloc区域,从VMALLOC_START开始到VMALLOC_END,可以用于映射一段物
理内存。

再来看内核的临时映射函数kmap_atomic的实现:
如果是32位有高端地址的,就需要调用set_pte通过内核页表进行临时映射;
如果是64位没有高端地址的,就调用page_address,里面会调用lowmem_page_address。
其实低端内存的映射,会直接使用__va进行临时映射。

可以看出,kmap_atomic和vmalloc不同:
kmap_atomic发现,没有页表的时候,就直接创建页表进行映射了;
而vmalloc没有,它只分配了内核的虚拟地址。所以,访问它的时候,会产生缺页异常。

  • 内核态缺页异常

调用do_page_fault,主要用于关联内核页表项。

21.3 总结

将整个内存管理的体系串起来:

物理内存根据NUMA架构分节点;
每个节点里面再分区域;
每个区域里面再分页;
物理页面通过伙伴系统进行分配。

分配的物理页面要变成虚拟地址让上层可以访问,kswapd可以根据物理页面的使用情况对页面进行换入换出。

对于内存的分配需求,可能来自内核态,也可能来自用户态:

对于内核态的内存分配,kmalloc在分配大内存、以及vmalloc分配不连续物理页的时候,直接使用伙伴系统,分配后转换为虚拟地址,访问的时候需要通过内核页表进行映射;
对于kmem_cache以及kmalloc分配小内存,则使用slub分配器,将伙伴系统分配出来的大块内存切成一小
块一小块进行分配。
kmem_cache和kmalloc的部分不会被换出,因为用这两个函数分配的内存多用于保持内核关键的数据结
构;
内核态中vmalloc分配的部分会被换出,因而当访问的时候,发现不在,就会调用do_page_fault。

对于用户态的内存分配,或者直接调用mmap系统调用分配,或者调用malloc;
调用malloc的时候,如果分配小的内存,就用sys_brk系统调用;
如果分配大的内存,还是用sys_mmap系统调用。
正常情况下,用户态的内存都是可以换出的,因而一旦发现内存中不存在,就会调用do_page_fault。
在这里插入图片描述

发布了235 篇原创文章 · 获赞 264 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/qq_41594698/article/details/103155813