Virtual Memory

Linux虚拟内存

每个用户进程都只能看到虚拟内存,操作虚拟内存。虚拟内存到物理内存的映射任务由操作系统完成。

每个用户进程看到的虚拟内存地址都是连续的,且具有一定布局规则。

每个用户的虚拟内存之间是相互隔离的,相互独立,无法感知其他进程虚拟内存的存在。

虚拟内存采用lazy loading机制,只有真正用到内存,才会将虚拟内存映射到物理内存。

用户空间

32位Linux用户空间为3G,64下实际用到48位,为128T,布局类似,只是各段大小不同。

布局

image-20230206142734817

每个段具有不同的作用

  • 代码段:存放可执行的二进制文件,Linux系统下为elf格式的文件。
  • 数据段:存放在代码中指定了初始值的全局变量和静态变量。
  • BSS段:没有指定初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做 BSS 段。这些未初始化的全局变量被加载进内存之后会被初始化为 0 值。

上面介绍的这些全局变量和静态变量都是在编译期间就确定的,但是我们程序在运行期间往往需要动态的申请内存,所以在虚拟内存空间中也需要一块区域来存放这些动态申请的内存,这块区域就叫做堆。

  • 堆:动态分配空间
  • 文件映射与匿名映射区:存放动态链接库中的代码段、数据段、BSS段以及通过mmap映射的共享内存区。
  • 栈:存放函数的局部变量、函数参数。

描述

在Linux下,用户进程用task_struck来描述,其中含有一个mm_struct来描述用户空间,而mm_struct描述整个用户空间,组织如下。

此外,由于各个段之间存在共性,面向对象思想,把各个段用一个vm_area_struct来描述,记录段开始、结束,属性,读写权限,行为规范vm_flags

image-20230206144834567

各个vm_area_struct按照管理地址从低到高连成双向链表,同时指回mm_structmm_structmmap指针指向第一个vm_area_struct,组织如下。

image-20230206151247930

此外,为了快速查找,还将每个mm_struct加入到一颗红黑树中,从mm_structmm_rb中可以获取到其根节点,组织如下。

内核空间

每个用户的虚拟内存空间相互独立,但内核空间是所有进程共享的,不同进程进入内核态看到的虚拟内存空间是一样的。

由于内核会涉及到物理内存的管理,所以很多人会想当然地认为只要进入了内核态就开始使用物理地址了,这就大错特错了,千万不要这样理解,进程进入内核态之后使用的仍然是虚拟内存地址,只不过在内核中使用的虚拟内存地址被限制在了内核态虚拟内存空间范围中。

32位布局

直接映射区

虽然这块区域中的虚拟地址是直接映射到物理地址上,但是内核在访问这段区域的时候还是走的直接映射区中的映射关系是一比一映射。映射关系是固定的不会改变

直接映射区中的映射关系是一比一映射。映射关系是固定的不会改变

  • 内核代码相关:代码段、数据段、BSS段在这段 896M 大小的物理内存中,前 1M 已经在系统启动的时候被系统占用,1M 之后的物理内存存放的是内核代码段,数据段,BSS 段(这些信息起初存放在 ELF格式的二进制文件中,在系统启动的时候被加载进内存)。

  • 分配使用相关:各类结构描述符当我们使用 fork 系统调用创建进程的时候,内核会创建一系列进程相关的描述符,比如之前提到的进程的核心数据结构 task_struct,进程的内存空间描述符 mm_struct,以及虚拟内存区域描述符 vm_area_struct 等。这些进程相关的数据结构也会存放在物理内存前 896M 的这段区域中,当然也会被直接映射至内核态虚拟内存空间中的 3G – 3G + 896m 这段直接映射区域中。

  • 内核栈:当进程被创建完毕之后,在内核运行的过程中,会涉及内核栈的分配,内核会为每个进程分配一个固定大小的内核栈(一般是64位两个页,32位一个页,依赖具体的体系结构),每个进程的整个调用链必须放在自己的内核栈中,内核栈也是分配在直接映射区。与进程用户空间中的栈不同的是,内核栈容量小而且是固定的,用户空间中的栈容量大而且可以动态扩展。内核栈的溢出危害非常巨大,它会直接悄无声息的覆盖相邻内存区域中的数据,破坏数据。

我们知道32位Linux物理内存分为ZONE_DMA、ZONE_NORMAL、ZONE_HIGHEM区域。而ZONE_DMA是0~16MB,而ZONE_NORMAL是16MB~896MB。即ZONE_DMA和ZONE_NORMAL都是直接映射区。

而剩下的内存都是ZONE_HIGHEM区,称为高端内存,由于32位下虚拟内存中内核空间仅仅为1GB,就剩下128M的内核虚拟空间,无法直接映射,只能是动态的一部分一部分的分批映射,先映射正在使用的这部分,使用完毕解除映射,接着映射其他部分。

内核虚拟内存空间中的 3G + 896M 这块地址在内核中定义为 high_memory,high_memory 往上有一段 8M 大小的内存空洞。空洞范围为:high_memory 到 VMALLOC_START 。

动态映射区:vmalloc

接下来 VMALLOC_START 到 VMALLOC_END 之间的这块区域成为动态映射区。采用动态映射的方式映射物理内存中的高端内存。

和用户态进程使用 malloc 申请内存一样,在这块动态映射区内核是使用 vmalloc 进行内存分配。由于之前介绍的动态映射的原因,vmalloc 分配的内存在虚拟内存上是连续的,但是物理内存是不连续的。通过页表来建立物理内存与虚拟内存之间的映射关系,从而可以将不连续的物理内存映射到连续的虚拟内存上。

由于 vmalloc 获得的物理内存页是不连续的,因此它只能将这些物理内存页一个一个地进行映射,在性能开销上会比直接映射大得多。因此内核用的更多的是kmalloc直接映射。

永久映射区

PKMAP_BASE 到 FIXADDR_START 之间的这段空间称为永久映射区。在内核的这段虚拟地址空间中允许建立与物理高端内存的长期映射关系。比如内核通过 alloc_pages() 函数在物理内存的高端内存中申请获取到的物理内存页,这些物理内存页可以通过调用 kmap 映射到永久映射区中。

固定映射区

内核虚拟内存空间中的下一个区域为固定映射区,区域范围为:FIXADDR_START 到 FIXADDR_TOP。

在固定映射区中的虚拟内存地址可以自由映射到物理内存的高端地址上,但是与动态映射区以及永久映射区不同的是,在固定映射区中虚拟地址是固定的,而被映射的物理地址是可以改变的。也就是说,有些虚拟地址在编译的时候就固定下来了,是在内核启动过程中被确定的,而这些虚拟地址对应的物理地址不是固定的。采用固定虚拟地址的好处是它相当于一个指针常量(常量的值在编译时确定),指向物理地址,如果虚拟地址不固定,则相当于一个指针变量。

那为什么会有固定映射这个概念呢 ? 比如:在内核的启动过程中,有些模块需要使用虚拟内存并映射到指定的物理地址上,而且这些模块也没有办法等待完整的内存管理模块初始化之后再进行地址映射。因此,内核固定分配了一些虚拟地址,这些地址有固定的用途,使用该地址的模块在初始化的时候,将这些固定分配的虚拟地址映射到指定的物理地址上去。

临时映射区

主要用于数据拷贝。

64位布局

由于64位虚拟内存空间足够的大,即便是内核要访问全部的物理内存,直接映射就可以了,不在需要用到ZONE_HIGHMEM 高端内存那样的动态映射方式。

64位和32位各段作用的大差不差,布局相似,不在赘述,布局如下。

整体布局

32位

64位

加载ELF磁盘文件

进程新创建时会把磁盘中的elf二进制文件的内容加载到内存。

elf文件中的 .text,.rodata 等一些只读的 Section,会被映射到内存的一个只读可执行的 Segment 里(代码段)。而 .data,.bss 等一些可读写的 Section,则会被映射到内存的一个具有读写权限的 Segment 里(数据段,BSS 段)。

Linux内核完成这个映射过程的函数是 load_elf_binary

寻址

寻址就是将虚拟内存转化为物理内存的过程。

Linux下有多级寻址:三四五级。

三级寻址:PGD(Page Global Directory)、PMD(Page Middle Directory)、PT(Page Table)。

四级寻址:PGD、PUD(Page Upper Directory)、PMD、PT。

五级寻址:PGD、P4D、PUD、PMD、PT。

每一级的寻址表除了提供寻址功能外,还有控制读写权限等页面所具有的功能,如果提前发现权限不足即可提前退出。

寻址需要借助硬件来完成,软件作为驱动,在硬件层面完成多级寻址的硬件模块叫做MMU。

通常是四级寻址,每一级用到9个二进制位,共$2^{36}$,而每一页是4Kb也就是$2^{12}$,即$2^{48}$的虚拟内存空间,如下图。

TLB

由上述寻址过程不难发现,寻址是一个耗时的过程,也有了缓存机制, CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 我们称为TLB(Translation lookaside buffer)。TLB也由MMU访问。

同样的,TLB除了存储具体页面体数据外,还存有相关的权限信息。

malloc内存管理

ptmalloc是glibc默认的内存管理器。我们常用的malloc和free就是由ptmalloc内存管理器提供的基础内存分配函数。这块主要讲的是ptmalloc分配的原理。

glic库也是操作系统之上的,因此底层还是调用的操作系统的接口,所以malloc/free也是分配/释放虚拟内存。

因为系统调用需要从用户态陷入内核态的缘故, 不是每调用一次malloc/free都调用一次系统调用,C库对向操作系统申请来的虚拟内存进行一定的管理,来减少系统调用。

malloc

  • 分配内存 < DEFAULT_MMAP_THRESHOLD,走__brk,从内存池获取,失败的话走brk系统调用
  • 分配内存 > DEFAULT_MMAP_THRESHOLD,走__mmap,直接调用mmap系统调用

其中,DEFAULT_MMAP_THRESHOLD默认为128k,可通过mallopt进行设置。

free

  • malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用
  • malloc 通过 mmap() 方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放

我们重点关注内存池。

内存池

数据结构Bins:管理空闲内存

  • fast bins:管理小块内存
  • unsorted bin
  • small bins
  • large bins

malloc分配流程

  1. 获取分配区的锁,防止多线程冲突。
  2. 计算出需要分配的内存的chunk实际大小。
  3. 判断chunk的大小,如果小于max_fast(64b),则取fast bins上去查询是否有适合的chunk,如果有则分配结束。
  4. chunk大小是否小于512B,如果是,则从small bins上去查找chunk,如果有合适的,则分配结束。
  5. 继续从 unsorted bins上查找。如果unsorted bins上只有一个chunk并且大于待分配的chunk,则进行切割,并且剩余的chunk继续扔回unsorted bins;如果unsorted bins上有大小和待分配chunk相等的,则返回,并从unsorted bins删除;如果unsorted bins中的某一chunk大小 属于small bins的范围,则放入small bins的头部;如果unsorted bins中的某一chunk大小 属于large bins的范围,则找到合适的位置放入。
  6. 从large bins中查找,找到链表头后,反向遍历此链表,直到找到第一个大小 大于待分配的chunk,然后进行切割,如果有余下的,则放入unsorted bin中去,分配则结束。
  7. 如果搜索fast bins和bins都没有找到合适的chunk,那么就需要操作top chunk来进行分配了(top chunk相当于分配区的剩余内存空间)。判断top chunk大小是否满足所需chunk的大小,如果是,则从top chunk中分出一块来。
  8. 如果top chunk也不能满足需求,则需要扩大top chunk。主分区上,如果分配的内存小于分配阀值(默认128k),则直接使用brk()分配一块内存;如果分配的内存大于分配阀值,则需要mmap来分配;非主分区上,则直接使用mmap来分配一块内存。通过mmap分配的内存,就会放入mmap chunk上,mmap chunk上的内存会直接回收给操作系统。

unsorted清除时机(放到samll bin或者large bin上):fast bin 和 small bin, unsorted 都没查找到合适大小。

free释放流程

  1. 获取分配区的锁,保证线程安全。
  2. 如果free的是空指针,则返回,什么都不做。
  3. 判断当前chunk是否是mmap映射区域映射的内存,如果是,则直接munmap()释放这块内存。前面的已使用chunk的数据结构中,我们可以看到有M来标识是否是mmap映射的内存。
  4. 判断chunk是否与top chunk相邻,如果相邻,则直接和top chunk合并(和top chunk相邻相当于和分配区中的空闲内存块相邻)。转到步骤8
  5. 如果chunk的大小大于max_fast(64b),则放入unsorted bin,并且检查是否有合并,有合并情况并且和top chunk相邻,则转到步骤8;没有合并情况则free。
  6. 如果chunk的大小小于 max_fast(64b),则直接放入fast bin,fast bin并没有改变chunk的状态。没有合并情况,则free;有合并情况,转到步骤7
  7. 在fast bin,如果当前chunk的下一个chunk也是空闲的,则将这两个chunk合并,放入unsorted bin上面。合并后的大小如果大于64KB,会触发进行fast bins的合并操作,fast bins中的chunk将被遍历,并与相邻的空闲chunk进行合并,合并后的chunk会被放到unsorted bin中,fast bin会变为空。合并后的chunk和topchunk相邻,则会合并到topchunk中。转到步骤8
  8. 判断top chunk的大小是否大于mmap收缩阈值(默认为128KB),如果是的话,对于主分配区,则会试图归还top chunk中的一部分给操作系统。free结束。

参考资料

  1. 《深入Linux内核架构与底层原理》第七章 内存管理
  2. 《Linux内核设计与实现》第十二章 内存管理
  3. 《程序员的自我修养——链接、装载与库》
  4. 4.1 为什么要有内存管理
  5. 4.2 malloc是怎样分配内存的
  6. 4.6 深入理解Linux虚拟内存
  7. glibc内存管理那些事儿
  8. 内存管理器ptmalloc
  9. glibc内存管理ptmalloc源码分析

问题

页表的存放在哪里?内核里头?用户虚拟内存里头?具体在哪里段?

页表存放在内核空间,每个进程拥有独立的页表,进程页表基地址的物理地址存放在mm_struct->pgd里。

当前正在处理的任务的页表根目录物理地址通常放在一个页表基址寄存器PTBR(page table base register )中,比如X86_64CR3寄存器。

上下文切换时,就会切换这个寄存器的值。从新进程的task_struct中的mm_structpgd的值读到CR3寄存器。

什么情况下需要遍历VMA链表?

在查看进程内存情况的时候,采用遍历该链表比在红黑树一个一个查找来的快。当然可能还有其他应用场景。由于版本的不同,该链表可能是双向链表也可能是单向链表甚至可能没有。笔者在查看源码的时候,2.6版本存在该双向链表,但最新版本看不到prevnext指针了,可能是修改了架构也可能是移除了。

brk和mmap虚拟内存申请原理

brk仅仅移动brk指针。

mmap首先判断是匿名映射(即申请虚拟内存)还是文件映射,判断空间是否足够、清楚旧的映射、校验内存可用性。都满足的话,创建出一块vm_area_struct并插入到红黑树进行管理。

以上都是分配虚拟内存,真正用到内存时才会发生缺页中断,建立虚拟内存到物理内存的映射。

TODO

  • 反向映射

  • TLB淘汰策略

  • 换入换出

  • 缺页异常的处理


Virtual Memory
https://messenger1th.github.io/2024/07/24/Linux/Virtual Memory/
作者
Epoch
发布于
2024年7月24日
许可协议