Linux - 虚拟内存

虚拟内存概念以及作用

在没有虚拟内存概念的时候(DOS时代),程序寻址用的都是物理地址(实际存在硬件里面的空间地址,Physical Memory Address )。程序能寻址的范围是有限的,这取决于CPU的地址线条数。比如在32位平台下,寻址的范围是2^32^也就是 4G。并且这是固定的,如果没有虚拟内存,且每次开启一个进程都给4G的物理内存,就可能会出现很多问题:

  • 因为物理内存时有限的,当有多个进程要执行的时候,都要给4G内存,很显然你内存小一点,这很快就分配完了,于是没有得到分配资源的进程就只能等待。当一个进程执行完了以后,再将等待的进程装入内存。这种频繁的装入内存的操作是很没效率的;

  • 由于指令都是直接访问物理内存的,那么我这个进程就可以修改其他进程的数据,甚至会修改内核地址空间的数据,这是我们不想看到的;

    image-20230919154452221

  • 因为内存时随机分配的,所以程序运行的地址也是不正确的。

于是针对上面会出现的各种问题,虚拟内存技术就出来了。

虚拟内存是物理内存和进程之间的中间层。

img

虚拟内存 从字面上理解就是一个假象的内存空间,非真实物理硬件内存空间,是操作系统内核为了对进程地址空间进行管理而精心设计的一个逻辑意义上的内存空间概念,是为程序运行和操作使用的内存地址

虚拟内存它的作用有:

  • 安全隔离
    • 操作系统给每个进程分配了属于自己的私有虚拟内存,保证了进程的独立运行;
    • 隔离了物理地址, 凡是程序运行过程中可能需要用到的指令或者数据都必须在虚拟内存空间中;
  • 共享内存
    • 利用映射技术,多个进程之间通过共享内存的方式有效共享代码库等;
  • 创建给主存更多的空间以及更善用碎片空间
    • 虚拟内存地址通常是连续的地址空间,解决主存非连续空间分配内存给某进程善用碎片空间;
    • 操作系统的内存管理模块控制下,在触发缺页中断时利用分页技术将实际的物理内存分配给虚拟内存,可以获取虚拟内存空间远远大于实际物理内存大小,例如 32位系统4g物理内存下,每个进程都可以获得4G虚拟内存,如果运行20个进程,则相当于可以使用80g的虚拟内存空间;
  • 作为缓存用,进行更有效的寻址

虚拟内存实现原理概念如下图:

image-20230919155444037

进程的虚拟内存空间结构

虚拟内存的管理是以进程为基础,每个进程都有各自的虚拟地址空间,空间大小由CPU的位数决定。每个进程的内核空间是所有进程所共享的。

32位系统为例,32Linux系统理论上每个进程最大可分配的虚拟地址空间(232 B = 4 GB),其中0-3G为用户空间,3-4G为内核空间。

64位系统为例,64Linux系统理论上每个进程最大可分配的虚拟地址空间(264 B = 17179869184 GB), 但实现64位长的地址只会增加系统的复杂度和地址转换的成本, 带不来任何好处. 所以目前的x86-64架构CPU都遵循AMDCanonical form, 即只有虚拟地址的最低48位才会在地址转换时被使用, 且任何虚拟地址的48位至63位必须与47位一致(sign extension)。也就是说, 总的虚拟地址空间为 256TB( 248 B), 其中128T为用户空间,128T为内核空间。

那么这个进程独占的虚拟内存空间到底是什么样子呢?在本小节中,就为大家揭开这层神秘的面纱。

首先我们会想到的是一个进程运行起来是为了执行我们交代给进程的工作,执行这些工作的步骤我们通过程序代码事先编写好,然后编译成二进制文件存放在磁盘中,CPU 会执行二进制文件中的机器码来驱动进程的运行。所以在进程运行之前,这些存放在二进制文件中的机器码需要被加载进内存中,而用于存放这些机器码的虚拟内存空间叫做代码段。为了防止代码和常量遭到修改,代码段被设置为只读。

image-20230919160657158

在程序运行起来之后,总要操作变量吧,在程序代码中我们通常会定义大量的全局变量和静态变量,这些全局变量在程序编译之后也会存储在二进制文件中,在程序运行之前,这些全局变量也需要被加载进内存中供程序访问。所以在虚拟内存空间中也需要一段区域来存储这些全局变量。

  • 那些在代码中被我们指定了初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做数据段(Data段)。数据段属于静态内存分配(静态存储区),可读可写。
  • 那些没有指定初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做 BSS 段。这些未初始化的全局变量被加载进内存之后会被初始化为零值。

image-20230919161336793

上面所说的全局变量和静态变量都是在编译期间就确定的,但是我们程序在运行期间往往需要动态的申请内存,所以在虚拟内存空间中也需要一块区域来存放这些动态申请的内存,这块区域就叫做堆(Heap)。注意这里的堆指的是 OS 堆并不是 JVM 中的堆。

image-20230919161803330

除此之外,我们的程序在运行过程中还需要依赖动态链接库,这些动态链接库以 .so 文件的形式存放在磁盘中,比如 C 程序中的 glibc,里边对系统调用进行了封装。glibc 库里提供的用于动态申请堆内存的 malloc 函数就是对系统调用 sbrkmmap 的封装。这些动态链接库也有自己的对应的代码段,数据段,BSS 段,也需要一起被加载进内存中。

还有用于内存文件映射的系统调用 mmap,会将文件与内存进行映射,那么映射的这块内存(虚拟内存)也需要在虚拟地址空间中有一块区域存储。

这些动态链接库中的代码段,数据段,BSS 段,以及通过 mmap 系统调用映射的共享内存区,在虚拟内存空间的存储区域叫做文件映射与匿名映射区

image-20230919162029409

最后我们在程序运行的时候总该要调用各种函数吧,那么调用函数过程中使用到的局部变量和函数参数也需要一块内存区域来保存。这一块区域在虚拟内存空间中叫做栈(Stack)。

image-20230919165317931

至此,进程的虚拟内存空间所包含的主要区域已经介绍完成,再次总结下:

  • 代码(Code)段: 存放进程程序二进制文件中的机器指令。这块区域的大小在程序运行时就已经确定,并且为了防止代码和常量遭到修改,代码段被设置为只读;
  • 数据(Data)段: 存放程序中已初始化且初值不为0全局变量静态局部变量。数据段属于静态内存分配(静态存储区),可读可写;
  • BSS段:存放没有指定初始值的全局变量和静态变量;
  • 堆(Heap):堆用来存放动态分配的内存。堆内存由用户申请分配和释放,从低地址向高地址增长。不同于数据结构中的堆,存储空闲内存的方式类似链表,因此空闲内存分布不连续,可动态扩张或缩减
  • 内存映射段 : 是高效的I/O映射方式,用于装载一个共享的动态内存库。用户可使用系统接口创建共享共享内存,做进程间通信。它也叫共享区,主要包括 共享内存、动态链接库 等共享资源,从低地址向高地址增长;
  • 栈(Stack)区: 栈用来存放程序中临时创建的局部变量,如函数的参数、内部变量等。每当一个函数被调用时,就会将参数压入进程调用栈中,调用结束后返回值也会被放回栈中。同时,每调用一次函数就会在调用栈上维护一个独立的栈帧 ,所以在递归较深时容易导致栈溢出。栈内存的申请和释放由 编译器 自动完成,并且 栈容量由系统预先定义 。栈从高地址向低地址增长。

以上就是我们通过一个程序在运行过程中所需要的数据所规划出的虚拟内存空间的分布,这些只是一个大概的规划,在真实的 Linux 系统中,进程的虚拟内存空间的又是如何的呢?

在真实的Linux系统下进程的虚拟空间结构在 32 位机器上和64位机器上是不同的:

  • 32 位机器上,指针的寻址范围为 2^32^,所能表达的虚拟内存空间为 4 GB,进程的虚拟内存地址范围为:0x0000 0000 - 0xFFFF FFFF

    • 其中用户态虚拟内存空间为 3 GB,虚拟内存地址范围为:0x0000 0000 - 0xC000 000
    • 内核态虚拟内存空间为 1 GB,虚拟内存地址范围为:0xC000 000 - 0xFFFF FFFF

    image-20230920103007470

    从图中可以看出用户态虚拟内存空间中的代码段并不是从 0x0000 0000 地址开始的,而是从 0x0804 8000 地址开始。

    0x0000 00000x0804 8000 这段虚拟内存地址是一段不可访问的保留区,因为在大多数操作系统中,数值比较小的地址通常被认为不是一个合法的地址,这块小地址是不允许访问的。比如在 C 语言中我们通常会将一些无效的指针设置为 NULL,指向这块不允许访问的地址。

    堆空间中地址的增长方向是从低地址到高地址增长,因此堆空间的上边是一段待分配区域,用于扩展堆空间的使用。

    栈空间中地址的**增长方向是从高地址向低地址增长,**因此在栈空间的下边也有一段待分配区,域用于扩展栈空间。

    再来看看用户态的地址空间和内核态的地址空间在32位系统内核中是如何被划分的呢?

    这就用到了进程的内存描述符 mm_struct 结构体中的 task_size 变量,task_size 定义了用户态地址空间与内核态地址空间之间的分界线,源码如下:

    // linux  v5.6  path: include/linux/mm_types.h
    struct mm_struct {
      ......
       unsigned long task_size;	/* size of task vm space */
      ......
    }
    

    我们来看下内核在关于 TASK_SIZE 的定义:

    // linux  v5.6  path: /arch/x86/include/asm/page_32_types.h
    
    #define __PAGE_OFFSET_BASE	_AC(CONFIG_PAGE_OFFSET, UL)
    #define __PAGE_OFFSET		__PAGE_OFFSET_BASE
    /*
     * User space process size: 3GB (default).
     */
    #define TASK_SIZE		__PAGE_OFFSET
    

    __PAGE_OFFSET在内核/arch/arm/Kconfig文件中的相关定义:

    image-20230920174031403

    可以从代码中看出用户地址空间和内核地址空间的分界线在 0xC000 000 地址处,那么自然进程的 mm_struct 结构中的 task_size0xC000 000

  • 64 位机器上,指针的寻址范围为 2^64^,所能表达的虚拟内存空间为 16 EB 。虚拟内存地址范围为:0x0000 0000 0000 0000 0000 - 0xFFFF FFFF FFFF FFFF16 EB 的内存空间,笔者都没见过这么大的磁盘,在现实情况中根本不会用到这么大范围的内存空间,事实上在目前的 64 位系统下只使用了 48 位来描述虚拟内存空间,寻址范围为 2^48^ ,所能表达的虚拟内存空间为 256TB

    • 其中低 128 T 表示用户态虚拟内存空间,虚拟内存地址范围为:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000
    • 128 T 表示内核态虚拟内存空间,虚拟内存地址范围为:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF

    image-20230920104700081

从上图中我们可以看出 64 位系统中的虚拟内存布局和 32 位系统中的虚拟内存布局大体上是差不多的。主要不同的地方有三点:

  1. 我们都知道在 64 位机器上的指针寻址范围为 2^64^,但是在实际使用中我们只使用了其中的低 48 位来表示虚拟内存地址,那么这多出的高 16 位就形成了这个地址空洞,称为 canonical address 空洞。在这段范围内的虚拟内存地址是不合法的,因为它的高 16 位既不全为 0 也不全为 1,不是一个 canonical address,所以称之为 canonical address 空洞;
  2. 在代码段跟数据段的中间还有一段不可以读写的保护段,它的作用是防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行;
  3. 用户态虚拟内存空间与内核态虚拟内存空间分别占用 128T,其中低128T 分配给用户态虚拟内存空间,高 128T 分配给内核态虚拟内存空间。

再来看看用户态的地址空间和内核态的地址空间在64位系统内核中是如何被划分的呢?

来看看64位系统中关于 TASK_SIZE 的定义:

image-20230920175512966

PAGE_SIZE 定义:

image-20230920180201122

从代码中可以看出,64 位系统中内核通过task_size_max() 来计算 TASK_SIZE,计算逻辑是 1 左移 47 位得到的地址是 0x0000800000000000,然后减去一个 PAGE_SIZE (默认为 4K),就是 0x00007FFFFFFFF000,共 128T。所以在 64 位系统中的 TASK_SIZE0x00007FFFFFFFF000

内核空间的虚拟内存相关

内核对进程虚拟内存区域划分与组织

内核如何为进程划分这些虚拟内存区域呢?

要解答这个问题前,我们先要熟悉几个内核的结构:

task_struct 结构

每个进程在内核中都有一个进程控制块(PCB)来维护进程相关的信息,Linux内核的进程控制块是task_struct结构体:

struct task_struct
{
   /*...*/
  pid_t pid;										// 进程id,进程的唯一标识
  pid_t tgid;										// 用于标识线程所属的进程 pid
	struct mm_struct		*mm;			//mm_struct类型的指针,指向他的虚拟地址空间的用户空间部分
	struct files_struct *files;		//进程打开的文件信息
  /*...*/
}

在进程描述符 task_struct 结构中,有一个专门描述进程虚拟地址空间的内存描述符 mm_struct 结构,每个进程都有唯一的 mm_struct 结构体,也就是前边提到的每个进程的虚拟地址空间都是独立,互不干扰的。

mm_struct 结构

内存描述符的 mm_struct 结构体来表示进程虚拟内存空间的全部信息。在内核中用 mm_struct 结构体中的上述属性来定义上图中虚拟内存空间里的不同内存区域。

struct mm_struct {
    unsigned long task_size;    //内核和用户态的起始地址
    unsigned long start_code, end_code, start_data, end_data;
    unsigned long start_brk, brk, start_stack;
    unsigned long arg_start, arg_end, env_start, env_end;
    unsigned long mmap_base;  /* base of mmap area */
    unsigned long total_vm;    /* Total pages mapped */
    unsigned long locked_vm;  /* Pages that have PG_mlocked set */
    unsigned long pinned_vm;  /* Refcount permanently increased */
    unsigned long data_vm;    /* VM_WRITE & ~VM_SHARED & ~VM_STACK */
    unsigned long exec_vm;    /* VM_EXEC & ~VM_WRITE & ~VM_STACK */
    unsigned long stack_vm;    /* VM_STACK */
    
  	struct vm_area_struct *mmap;
    struct rb_root mm_rb;
    /*...*/
}

mm_struct 结构体中,如下划定不同的虚拟内存空间区域:

  • start_codeend_code 定义代码段的起始和结束位置,程序编译后的二进制文件中的机器码被加载进内存之后就存放在这里。
  • start_dataend_data 定义数据段的起始和结束位置,二进制文件中存放的全局变量和静态变量被加载进内存中就存放在这里。

后面紧挨着的是 BSS 段,用于存放未被初始化的全局变量和静态变量,这些变量在加载进内存时会生成一段 0 填充的内存区域 (BSS 段), BSS 段的大小是固定的。

  • 接着是堆(Heap)了,在堆中内存地址的增长方向是由低地址向高地址增长, start_brk 定义堆的起始位置,brk 定义堆当前的结束位置。我们使用 malloc 申请小块内存时(低于 128K),就是通过改变 brk 位置调整堆大小实现的。
  • 接下来就是内存映射区,在内存映射区内存地址的增长方向是由高地址向低地址增长,mmap_base 定义内存映射区的起始地址。进程运行时所依赖的动态链接库中的代码段,数据段,BSS 段以及我们调用 mmap 映射出来的一段虚拟内存空间就保存在这个区域。
  • start_stack 是栈的起始位置在 RBP 寄存器中存储,栈的结束位置也就是栈顶指针 stack pointerRSP 寄存器中存储。在栈中内存地址的增长方向也是由高地址向低地址增长。arg_startarg_end 是参数列表的位置, env_startenv_end 是环境变量的位置。它们都位于栈中的最高地址处。

mm_struct 结构体中除了上述用于划分虚拟内存区域的变量之外,还定义了一些虚拟内存与物理内存映射内容相关的统计变量,操作系统会把物理内存划分成一页一页的区域来进行管理,所以物理内存到虚拟内存之间的映射也是按照页为单位进行的。

  • total_vm 表示在进程虚拟内存空间中总共与物理内存映射的页的总数。
  • locked_vm表示被锁定不能换出的内存页总数,pinned_vm 表示既不能换出,也不能移动的内存页总数。因为当内存吃紧的时候,有些页可以换出到硬盘上,而有些页因为比较重要,不能换出。
  • data_vm 表示数据段中映射的内存页数目,exec_vm 是代码段中存放可执行文件的内存页数目,stack_vm 是栈中所映射的内存页数目,这些变量均是表示进程虚拟内存空间中的虚拟内存使用情况。

​ 通过上述两个结构体,我们可以对虚拟内存空间的布局有了更新一个层面的了解,我们用下面的图更新下我们对虚拟内存空间图:

image-20230921121137947

vm_area_struct 结构

通过对mm_struct 结构体,我们大致上了解了内核中对虚拟内存的区域划分情况,那么接下来的问题就是在内核中这些虚拟内存区域是如何被组织的呢?

对的,就是通过mm_struct 结构体中的vm_area_struct结构体:

struct mm_struct {
    /*...*/
  	struct vm_area_struct *mmap;
    /*...*/
}

vm_area_struct结构体它表示的是一块连续虚拟地址空间区域,进程使用的。内核每次为用户空间中分配一个空间使用时,都会生成一个vm_are_struct结构用于记录跟踪分配情况,一个vm_are_struct就代表一段虚拟内存空间。

vm_are_struct结构体定义:

//linux 5.6  include/linux/mm_types.h
struct vm_area_struct {
	unsigned long vm_start; //虚存区起始
	unsigned long vm_end;   //虚存区结束
	struct vm_area_struct *vm_next, *vm_prev;   //前后指针
	struct rb_node vm_rb;   //红黑树中的位置
	unsigned long rb_subtree_gap;
	struct mm_struct *vm_mm;    //所属的 mm_struct
	pgprot_t vm_page_prot;      
	unsigned long vm_flags;     //标志位
	struct {
		struct rb_node rb;
		unsigned long rb_subtree_last;
	} shared;	
	struct list_head anon_vma_chain;
	struct anon_vma *anon_vma;
	const struct vm_operations_struct *vm_ops;  //vma对应的实际操作
	unsigned long vm_pgoff;     //文件映射偏移量
	struct file * vm_file;      //映射的文件
	void * vm_private_data;     //私有数据
	atomic_long_t swap_readahead_info;
#ifndef CONFIG_MMU
	struct vm_region *vm_region;	/* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMA
	struct mempolicy *vm_policy;	/* NUMA policy for the VMA */
#endif
	struct vm_userfaultfd_ctx vm_userfaultfd_ctx;
} __randomize_layout;

linux内核使用vm_area_struct结构来表示一个独立的虚拟内存区域,由于每个不同质的虚拟内存区域功能和内部机制都不同,因此一个进程使用多个vm_area_struct结构来分别表示不同类型的虚拟内存区域。各个vm_area_struct结构使用链表或者树形结构链接,方便进程快速访问。

每个 vm_area_struct 结构对应于虚拟内存空间中的唯一虚拟内存区域 VMAvm_start 指向了这块虚拟内存区域的起始地址(最低地址),vm_start 本身包含在这块虚拟内存区域内。vm_end 指向了这块虚拟内存区域的结束地址(最高地址),而 vm_end 本身包含在这块虚拟内存区域之外,所以 vm_area_struct 结构描述的是 [vm_start,vm_end) 这样一段左闭右开的虚拟内存区域。

vm_area_struct 结构中的 vm_nextvm_prev 指针分别指向 VMA 节点所在双向链表中的后继节点和前驱节点,内核中的这个 VMA 双向链表是有顺序的,所有 VMA 节点按照低地址到高地址的增长方向排序。

双向链表中的最后一个 VMA 节点的 vm_next 指针指向 NULL,双向链表的头指针存储在内存描述符 struct mm_struct 结构中的 mmap 中,正是这个 mmap 串联起了整个虚拟内存空间中的虚拟内存区域。

在每个虚拟内存区域 VMA 中又通过 struct vm_area_struct 中的 vm_mm 指针指向了所属的虚拟内存空间 mm_struct

而扫描链表找到与特定地址关联的区域,在有大量区域时是非常低效的操作(数据密集型的应用程序就是这样)。因此vm_area_struct的各个实例还通过红黑树管理,可以显著加快扫描速度。每个 VMA 区域都是红黑树中的一个节点,通过 struct vm_area_struct 结构中的 vm_rb 将自己连接到红黑树中。

而红黑树中的根节点存储在内存描述符 struct mm_struct 中的 mm_rb 中:

struct mm_struct {
     struct rb_root mm_rb;
}

再来看看vm_page_protvm_flags,这两字段都是用来标记 vm_area_struct 结构表示的这块虚拟内存区域的访问权限和行为规范。

  • vm_page_prot 偏向于定义底层内存管理架构中页这一级别的访问控制权限,它可以直接应用在底层页表中,它是一个具体的概念。虚拟内存区域 VMA 由许多的虚拟页 (page) 组成,每个虚拟页需要经过页表的转换才能找到对应的物理页面。页表中关于内存页的访问权限就是由 vm_page_prot 决定的。

  • vm_flags 则偏向于定于整个虚拟内存区域的访问权限以及行为规范。描述的是虚拟内存区域中的整体信息,而不是虚拟内存区域中具体的某个独立页面。它是一个抽象的概念。可以通过 vma->vm_page_prot = vm_get_page_prot(vma->vm_flags) 实现到具体页面访问权限 vm_page_prot 的转换。

    下面列举一些常用到的 vm_flags 方便大家有一个直观的感受:

    • VM_READVM_WRITEVM_EXEC 定义了虚拟内存区域是否可以被读取,写入,执行等权限;
    • VM_SHARD 用于指定这块虚拟内存区域映射的物理内存是否可以在多进程之间共享,以便完成进程间通讯;
    • VM_IO 的设置表示这块虚拟内存区域可以映射至设备 IO 空间中。通常在设备驱动程序执行 mmap 进行 IO 空间映射时才会被设置;
    • VM_RESERVED 的设置表示在内存紧张的时候,这块虚拟内存区域非常重要,不能被换出到磁盘中;
    • VM_SEQ_READ 的设置用来暗示内核,应用程序对这块虚拟内存区域的读取是会采用顺序读的方式进行,内核会根据实际情况决定预读后续的内存页数,以便加快下次顺序访问速度;
    • VM_RAND_READ 的设置会暗示内核,应用程序会对这块虚拟内存区域进行随机读取,内核则会根据实际情况减少预读的内存页数甚至停止预读。
    vm_flags访问权限
    VM_READ可读
    VM_WRITE可写
    VM_EXEC可执行
    VM_SHARD可多进程之间共享
    VM_IO可映射至设备 IO 空间
    VM_RESERVED内存区域不可被换出
    VM_SEQ_READ内存区域可能被顺序访问
    VM_RAND_READ内存区域可能被随机访问

最后用图梳理下VM的基本模型概念:

image-20230922110636651

Linux 6.1 版本开始,VMA布局使用了 maple tree (它是一颗B树)来替代红黑树,也删除了vma之间的额外的双向链表。所呈现的VMA结构就不会像我们上图所示的这样了。

maple tree 的设计中也是按照 lockless 方式的要求来的,使用 read-copy-update (RCU) 方式。一个进程的每个 VMA 块都会链接到mm_struct中的maple_tree

内核虚拟内存空间

32 位体系内核虚拟内存布局

直接映射区

所谓的直接映射区,就是这一块空间是连续的,地址范围为 3G3G + 896m,之所以这块 896M 大小的区域称为直接映射区或者线性映射区,是因为这块连续的虚拟内存地址会映射到 0 - 896M 这块连续的物理内存上。

也就是这块区域中的虚拟内存地址直接减去 0xC000 0000 (3G) 就得到了物理内存地址

内核态虚拟内存空间的前 896M 区域是直接映射到物理内存中的前 896M 区域中的,直接映射区中的映射关系是一比一映射。映射关系是固定的不会改变

image-20231008174337483

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

[root@VM-16-10-centos ~]# cat /proc/iomem
......
00001000-0009fbff : System RAM
0009fc00-0009ffff : reserved
000a0000-000bffff : PCI Bus 0000:00
000c0000-000c95ff : Video ROM
000c9800-000ca1ff : Adapter ROM
000ca800-000ccbff : Adapter ROM
000f0000-000fffff : reserved
  000f0000-000fffff : System ROM
00100000-bffddfff : System RAM
  25000000-34ffffff : Crash kernel
  bb000000-bb7a1279 : Kernel code
  bb7a127a-bbd6c2bf : Kernel data
  bbf65000-bc272fff : Kernel bss
bffde000-bfffffff : reserved
......

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

而在直接映射区域中,设置了前 16M 专门让内核用来为 DMA 分配内存,这块 16M 大小的内存区域我们称之为 ZONE_DMA。而直接映射区中剩下的部分也就是从 16M896M(不包含 896M)这段区域,我们称之为 ZONE_NORMAL。(注意这里的 ZONE_DMAZONE_NORMAL 是内核针对物理内存区域的划分)

而什么是DMA呢?DMA其实就是直接存储器访问,是单片机的一个外设,它的主要功能是用来搬数据,但是不需要占用CPU,即在传输数据的时候,CPU可以干其他的事情,好像是多线程一样。在 X86 体系结构下,ISA 总线的 DMA (直接内存存取)控制器,只能对内存的前16M 进行寻址,这就导致了 ISA 设备不能在整个 32 位地址空间中执行 DMA,只能使用物理内存的前 16M 进行 DMA 操作。

image-20231011122513713

高端内存 - HIGH_MEMORY

物理内存 896M 以上的区域被内核划分为 ZONE_HIGHMEM 区域(如上图已经标记出来),我们称之为高端内存。该区域是x86-32下特有的。

我们假设我们的物理内存假设为 4G,高端内存区域为 4G - 896M = 3200M,而32系统下内核虚拟空间只有1G大小,无法管理全部的内存空间,那么这块 3200M 大小的 ZONE_HIGHMEM 区域该如何映射到内核虚拟内存空间中呢?

这样一来物理内存中的 ZONE_HIGHMEM 区域就只能采用动态映射的方式映射到 128M 大小的内核虚拟内存空间中。

0xF8000000 ~ 0xFFFFFFFF地址空间范围内找一段相应大小空闲的逻辑地址空间,借用一会。借用这段逻辑地址空间,建立映射到想访问的那段物理内存(即填充内核PTE页面表),临时用一会,用完后归还。这样别人也可以借用这段地址空间访问其他物理内存,实现了使用有限的地址空间,访问所有所有物理内存。

为了防止访问越界,针对内核故障的保护,系统会在 high_memoryVMALLOC区域留8M,因此假如理论上vmalloc size300M,实际可用的也是只有292M

//  linux v6.5 path: include/asm/pgtable_32_areas.h
#define VMALLOC_OFFSET	(8*1024*1024)

再来看看VMALLOC区域,虚拟内存中连续、但物理内存中不连续的内存区,可以在vmalloc区域分配。该机制通常用于用户过程,内核自身会试图尽力避免非连续的物理地址。内核通常会成功,因为大部分大的内存块都在启动时分配给内核,那时内存的碎片尚不严重。但在已经运行了很长时间的系统上,在内核需要物理内存时,就可能出现可用空间不连续的情况。此类情况,主要出现在动态加载模块时。

vmalloc区域的起始地址,取决于在直接映射物理内存时,使用了多少虚拟地址空间内存(因此也依赖于上文的high_memory变量)。vmalloc区域从可被VMALLOC_OFFSET整除的地址开始。VMALLOC_STARTVMALLOC_END 之间称为内核动态映射空间,也即内核想像用户态进程一样 malloc 申请内存,在内核里面可以使用 vmalloc。假设物理内存里面,896M1.5G 之间已经被用户态进程占用了,并且映射关系放在了进程的页表中,内核 vmalloc 的时候,只能从分配物理内存 1.5G 开始,就需要使用这一段的虚拟地址进行映射,映射关系放在专门给内核自己用的页表里面。

vmalloc区域代码:

//  linux v6.5 path: include/asm/pgtable_32_areas.h
#define VMALLOC_START	((unsigned long)high_memory + VMALLOC_OFFSET)
#ifdef CONFIG_HIGHMEM
# define VMALLOC_END	(PKMAP_BASE - 2 * PAGE_SIZE)
#else
# define VMALLOC_END	(LDT_BASE_ADDR - 2 * PAGE_SIZE)
#endif

vmalloc区域图示:

image-20231011165312937

永久映射区

内核从 PKMAP_BASEFIXADDR_START这段空间用于映射高端内存,在这段虚拟地址空间中允许建立与物理高端内存(ZONE_HIGHMEM)的长期映射关系。

通常情况下,这个空间是 4M 大小,因此仅仅需要一个页表即可,内核通过来 pkmap_page_table 寻找这个页表。通过 kmap(),可以把一个 page 映射到这个空间来。由于这个空间是 4M 大小,最多能同时映射 1024page。因此,对于不使用的的 page,及应该时从这个空间释放掉(也就是解除映射关系),通过 kunmap() ,可以把一个 page 对应的线性地址从这个空间释放出来。

//  linux v6.5 path: include/asm/pgtable_32_areas.h
#define LAST_PKMAP 1024    //表示永久映射区可以映射的页数限制
#define PKMAP_BASE		\
	((LDT_BASE_ADDR - PAGE_SIZE) & PMD_MASK)

示意图如下:

image-20231011175751371

固定映射区

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

FIXADDR_STARTFIXADDR_TOP 定义代码如下:

//  linux v6.5 path: include/asm/fixmap.h
extern unsigned long __FIXADDR_TOP;
#define FIXADDR_TOP	((unsigned long)__FIXADDR_TOP)
#else
#define FIXADDR_TOP	(round_up(VSYSCALL_ADDR + PAGE_SIZE, 1<<PMD_SHIFT) - \
			 PAGE_SIZE)
#endif

#define FIXADDR_START		(FIXADDR_TOP - FIXADDR_SIZE)

固定映射是与物理地址空间中的固定页关联的虚拟地址空间项,但具体关联的页帧可以自由选择。它与通过固定公式与物理内存关联的直接映射页相反,虚拟固定映射地址与物理内存位置之间的关联可以自行定义,关联建立后内核总是会注意到的。

最后一个内存段由固定映射占据。这些地址指向物理内存中的随机位置。相对于内核空间起始处的线性映射,在该映射内部的虚拟地址和物理地址之间的关联不是预设的,而可以自由定义,但定义后不能改变。固定映射区域会一直延伸到虚拟地址空间顶端。

固定映射地址的优点在于,在编译时对此类地址的处理类似于常数,内核一启动即为其分配了物理地址。此类地址的解引用比普通指针要快速。内核会确保在上下文切换期间,对应于固定映射的页表项不会从TLB刷出,因此在访问固定映射的内存时,总是通过TLB高速缓存取得对应的物理地址。

对每个固定映射地址都会创建一个常数,加入到fixed_addresses枚举值列表中,内核提供了fix_to_virt函数,用于计算固定映射常数的虚拟地址。

image-20231012155438392

临时映射区

临时映射区(Temporary Mapping Area)通常指的是内核中的一块用于临时映射物理内存页面的区域。这个区域的主要作用是在内核中进行页表管理、物理内存管理和一些高级操作时,提供一个暂时的、虚拟地址映射到物理内存的地方。

内核中不能直接进行拷贝,因为此时从 page cache 中取出的缓存页 page 是物理地址,而在内核中是不能够直接操作物理地址的,只能操作虚拟地址。

所以就需要使用 kmap_atomic 将缓存页临时映射到内核空间的一段虚拟地址上,这段虚拟地址就位于内核虚拟内存空间中的临时映射区上,然后将用户空间缓存区 DirectByteBuffer 中的待写入数据通过这段映射的虚拟地址拷贝到 page cache 中的相应缓存页中。这时文件的写入操作就已经完成了。

由于是临时映射,所以在拷贝完成之后,调用 kunmap_atomic 将这段映射再解除掉。

void *kmap_atomic(struct *page, enum kmtype type);
void kunmap_atomic(void *kvaddr, enum km_type type);

临时映射区示意图如下:

image-20231012160751562

至此,32位系统下总体内核空间以及用户空间已经基本点到,用图表示下32 位体系结构 Linux 的整个虚拟内存空间的布局:
在这里插入图片描述

64 位体系内核虚拟内存布局

因为32位系统下,内核内存空间有限,但是到了64位系统,内核虚拟内存空间的布局和管理就变得容易多了,因为进程虚拟内存空间和内核虚拟内存空间各自占用 128T 的虚拟内存,实在是太大了,我们可以在这里边随意翱翔。

因此在 64 位体系下的内核虚拟内存空间与物理内存的映射就变得非常简单,由于虚拟内存空间足够的大,即便是内核要访问全部的物理内存,直接映射就可以了,不在需要用到高端内存那种动态映射方式。

内核在通过 TASK_SIZE 将进程虚拟内存空间和内核虚拟内存空间分割开来。代码定位如下:

//  linux v6.5 path: include/asm/page_64_types.h
#define __VIRTUAL_MASK_SHIFT	47
#define task_size_max()		((_AC(1,UL) << __VIRTUAL_MASK_SHIFT) - PAGE_SIZE)

#define TASK_SIZE_MAX		task_size_max()
#define IA32_PAGE_OFFSET	((current->personality & ADDR_LIMIT_3GB) ? \
					0xc0000000 : 0xFFFFe000
#define TASK_SIZE		(test_thread_flag(TIF_ADDR32) ? \
					IA32_PAGE_OFFSET : TASK_SIZE_MAX)

从上述代码中,可以得出64 位系统中的 TASK_SIZE0x00007FFFFFFFF000

64 位系统中,只使用了其中的低 48 位来表示虚拟内存地址。其中用户态虚拟内存空间为低 128 T,虚拟内存地址范围为:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000

内核态虚拟内存空间为高 128 T,虚拟内存地址范围为:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 。如图:

image-20231013121259697

此处我们主要关注 0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 这段内核虚拟内存空间的布局情况。

64 位内核虚拟内存空间从 0xFFFF 8000 0000 0000 开始到 0xFFFF 8800 0000 0000 这段地址空间是一个 8T 大小的内存空洞区域。

紧着着 8T 大小的内存空洞下一个区域就是 64T 大小的直接映射区。这个区域中的虚拟内存地址减去 PAGE_OFFSET 就直接得到了物理内存地址。

然后是VMALLOC_STARTVMALLOC_END 的这段区域是 32T 大小的 vmalloc 映射区,这里类似用户空间中的堆,内核在这里使用 vmalloc 系统调用申请内存。在vmalloc 映射区上下各有1T的保留区。

VMALLOC_STARTVMALLOC_END 变量定义:

//  linux v6.5 path: include/asm/pgtable_64_types.h
#define __VMALLOC_BASE_L4	0xffffc90000000000UL
# define VMALLOC_START		__VMALLOC_BASE_L4
#define VMALLOC_SIZE_TB_L4	32UL
# define VMALLOC_SIZE_TB	VMALLOC_SIZE_TB_L4
#define VMEMORY_END		(VMALLOC_START + (VMALLOC_SIZE_TB << 40) - 1)
#define VMALLOC_END		VMEMORY_END

VMEMMAP_START 开始是 1T 大小的虚拟内存映射区,用于存放物理页面的描述符 struct page 结构用来表示物理内存页。VMEMMAP_START 变量定义:

//  linux v6.5 path: include/asm/pgtable_64_types.h
#define __VMEMMAP_BASE_L4	0xffffea0000000000UL
# define VMEMMAP_START		__VMEMMAP_BASE_L4

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

__START_KERNEL_map 变量定义:

//  linux v6.5 path: include/asm/page_64_types.h
#define __START_KERNEL_map	_AC(0xffffffff80000000, UL)

到现在为止,整个内核虚拟内存空间在 64 位体系下的布局基本清晰,用图来表示下:

image-20231013154109757

好了,虚拟内存机制的内容就先讲到这里了。后续对内存的分页以及映射进行相关介绍。

参考资料:

bin的技术小屋 https://www.cnblogs.com/binlovetech/p/16824522.html

Go 语言设计与实现https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-memory-allocator

Linux内核 https://zhuanlan.zhihu.com/p/451405962

Haonan https://zhuanlan.zhihu.com/p/404813126

Jormungand https://blog.csdn.net/Jormungand_V/article/details/119382609

驿站Eventually https://blog.csdn.net/z97i714/article/details/109902209?spm=1001.2101.3001.6650.14&utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7ESEARCHCACHE%7Edefault-14-109902209-blog-119382609.pc_relevant_priorsearch_v1&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7ESEARCHCACHE%7Edefault-14-109902209-blog-119382609.pc_relevant_priorsearch_v1&utm_relevant_index=23

eleven_yw https://www.cnblogs.com/yaoxiaowen/p/7805964.html

凌桓丶 https://oreki.blog.csdn.net/article/details/111084633?spm=1001.2101.3001.6650.10&utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromBaidu%7Edefault-10-111084633-blog-116699177.pc_relevant_default&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromBaidu%7Edefault-10-111084633-blog-116699177.pc_relevant_default&utm_relevant_index=16

https://zhuanlan.zhihu.com/p/488123510

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值