操作系统--Linux虚拟内存管理

​一、什么是虚拟内存地址

收货地址是一个++虚拟地址++,它是人为定义的

而我们的城市,小区,街道是真实存在的,他们的地理位置就是++物理地址++

以 Intel Core i7 处理器为例,64 位和32位虚拟地址的格式为:


二、为什么要使用虚拟地址访问内存

进程虚拟内存空间中的每一个字节都有与其对应的虚拟内存地址

一个虚拟内存地址表示进程虚拟内存空间中的一个特定的字节

如果不使用虚拟地址,而是直接操作物理内存,我们需要知道每一个变量的位置都被安排在了哪里,而且还要注意和多个进程同时运行的时候,不能共用同一个地址,否则就会造成地址冲突。

而虚拟内存的引入正是要解决上述的问题,虚拟内存引入之后,每个进程都拥有自己独立的虚拟地址空间,进程与进程之间的虚拟内存地址空间是相互隔离,互不干扰的。每个进程都认为自己独占所有内存空间,自己想干什么就干什么。


三、进程虚拟内存空间

一个进程运行起来是为了执行我们交代给进程的工作,执行这些工作的步骤我们通过程序代码事先编写好,然后编译成二进制文件存放在磁盘中,CPU 会执行二进制文件中的机器码来驱动进程的运行。

内核根据进程运行的过程中所需要不同种类的数据而为其开辟了对应的地址空间。分别为:

编译期间(程序运行前)

  • 代码段:用于存放进程程序二进制文件中的机器指令

  • 数据段:用于存放程序二进制文件中**++指定了初始值++的全局变量和静态变量**

  • BSS 段:用于存放程序二进制文件中**++没有指定初始值++的全局变量和静态变量**,这些未初始化的全局变量被加载进内存之后会被初始化为 0 值

程序运行期间

  • 堆:用于在程序运行过程中动态申请内存的,指的是 OS 堆并不是 JVM 中的堆

  • 文件映射与匿名映射区:用于存放动态链接库 以及内存映射区域的文件映射与匿名映射区

  • 栈:用于存放++函数调用++ 过程中的局部变量和函数参数


四、Linux进程虚拟内存空间

4.1 32位机器上进程虚拟内存空间分布

32 位机器上,指针的寻址范围为 2^32,所能表达的虚拟内存空间为 4 GB

  • 总虚拟内存空间为 4 GB,总虚拟内存地址范围为:0x0000 0000 - 0xFFFF FFFF
  • 用户态虚拟内存空间为 3 GB,虚拟内存地址范围为:0x0000 0000 - 0xC000 000
  • 内核态虚拟内存空间为 1 GB,虚拟内存地址范围为:0xC000 000 - 0xFFFF FFFF
  • 0x0000 0000 到 0x0804 8000 这段虚拟内存地址是一段不可访问的保留区,因为在大多数操作系统中,数值比较小的地址通常被认为不是一个合法的地址,这块小地址是不允许访问的。比如在 C 语言中我们通常会将一些无效的指针设置为 NULL,指向这块不允许访问的地址
  • 代码段和数据段,它们是从程序的二进制文件中直接加载进内存中的
  • BSS 段中的数据也存在于二进制文件中,因为内核知道这些数据是没有初值的,所以在二进制文件中只会记录 BSS 段的大小,在加载进内存时会生成一段 0 填充的内存空间
  • 堆空间中地址的增长方向是从低地址到高地址增长。
  • 堆空间的上边是一段待分配区域,用于扩展堆空间的使用。
  • **在文件映射与匿名映射区的地址增长方向是从高地址向低地址增长。**进程运行时所依赖的动态链接库中的代码段,数据段,BSS 段就加载在这里。还有我们调用 mmap 映射出来的一段虚拟内存空间也保存在这个区域。
  • 在栈空间的下边也有一段待分配区域用于扩展栈空间。
  • 栈空间中的地址增长方向是从高地址向低地址增长。这里会保存函数运行过程所需要的局部变量以及函数参数等函数调用信息。每次进程申请新的栈地址时,其地址值是在减少的。
  • 在栈空间的上边就是内核空间了,进程虽然可以看到这段内核空间地址,但是就是不能访问。

4.2 64位机器上进程虚拟内存空间分布


目前的 64 位系统下只使用了 48 位来描述虚拟内存空间,寻址范围为 2^48 ,所能表达的虚拟内存空间为 256TB

  • 总虚拟内存空间为 256TB,总虚拟内存地址范围为:0x0000 0000 0000 0000 0000 - 0xFFFF FFFF FFFF FFFF
  • 用户态虚拟内存空间为128T,虚拟内存地址范围为:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000
  • 内核态虚拟内存空间为128T,虚拟内存地址范围为:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF

64 位系统中的虚拟内存布局和 32 位系统中的虚拟内存布局大体上是差不多的。主要不同的地方有三点:

  • 就是前边提到的由高 16 位空闲地址造成的 canonical address 空洞。在这段范围内的虚拟内存地址是不合法的,因为它的高 16 位既不全为 0 也不全为 1,不是一个 canonical address,所以称之为 canonical address 空洞。

  • 在代码段跟数据段的中间还有一段不可以读写的保护段,它的作用是防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行。

  • 用户态虚拟内存空间与内核态虚拟内存空间分别占用 128T,其中低128T 分配给用户态虚拟内存空间,高 128T 分配给内核态虚拟内存空间。


五、进程虚拟内存空间的管理

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

内核中的描述符 task_struct 结构

包含专门描述进程虚拟地址空间的内存描述符 mm_struct结构,这个结构体中包含了前边几个小节中介绍的进程虚拟内存空间的全部信息。

每个进程都有唯一的 mm_struct 结构体,也就是前边提到的每个进程的虚拟地址空间都是独立,互不干扰的。

通过 fork() 函数创建出的子进程,它的虚拟内存空间以及相关页表相当于父进程虚拟内存空间的一份拷贝,直接从父进程中拷贝到子进程中。

  1. 当我们调用 fork() 函数创建进程的时候,表示进程地址空间的 mm_struct 结构会随着进程描述符 task_struct 的创建而创建。
  2. 随后会在 copy_process 函数中创建 task_struct 结构,并拷贝父进程的相关资源到新进程的 task_struct 结构里,其中就包括拷贝父进程的虚拟内存空间 mm_struct 结构。++这里可以看出子进程在新创建出来之后它的虚拟内存空间是和父进程的虚拟内存空间一模一样的,直接拷贝过来++。
  3. 这里我们重点关注 copy_mm 函数,正是在这里完成了子进程虚拟内存空间 mm_struct 结构的的创建以及初始化。 copy_mm 函数首先会将父进程的虚拟内存空间 current->mm 赋值给指针 oldmm。然后通过 dup_mm 函数将父进程的虚拟内存空间以及相关页表拷贝到子进程的 mm_struct 结构中。最后将拷贝出来的 mm_struct 赋值给子进程的 task_struct 结构。

通过 vfork 或者 clone 系统调用创建出的子进程,父子进程之间使用的虚拟内存空间是一样的,并不是一份拷贝。

  1. 设置 CLONE_VM 标识
  2. 来到 copy_mm 函数 中就会进入 if (clone_flags & CLONE_VM) 条件中,在这个分支中会将父进程的虚拟内存空间以及相关页表直接赋值 给子进程,++这样一来父进程和子进程的虚拟内存空间就变成共享的了++

子进程共享了父进程的虚拟内存空间,这样子进程 就变成了我们熟悉的线程是否共享地址空间几乎是进程和线程之间的本质区别。Linux 内核并不区别对待它们,线程对于内核来说仅仅是一个共享特定资源的进程而已。

内核线程和用户态线程的区别就是内核线程没有相关的内存描述符 mm_struct ,内核线程对应的 task_struct 结构中的 mm 域指向 Null,所以内核线程之间调度是不涉及地址空间切换的。

父进程与子进程的区别,进程与线程的区别,以及内核线程与用户态线程的区别其实都是围绕着这个 mm_struct 展开的。

5.1 内核如何划分用户态和内核态虚拟内存空间

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

  • 32 位系统中用户地址空间和内核地址空间的分界线在 0xC000 000 地址处,那么自然进程的 mm_struct 结构中的 task_size 为 0xC000 000。
  • 64 位系统中用户地址空间和内核地址空间的分界线在 0x0000 7FFF FFFF F000 地址处,那么自然进程的 mm_struct 结构中的 task_size 为 0x0000 7FFF FFFF F000 。

5.2 内核如何++布局++ 不同进程虚拟内存空间

进程的内存描述符mm_struct 结构体内部

cpp 复制代码
struct mm_struct {
/*-------------------------------------------------------------------------------*/
//内核中用 mm_struct 结构体中的下述"属性"来定义虚拟内存空间里的"不同内存区域"
/*-------------------------------------------------------------------------------*/
    unsigned long task_size;               /* size of task vm space */
    //task_size 是内核空间 和 用户态空间分界线,也标志着用户态空间的起始位置
    unsigned long start_code, end_code, start_data, end_data;
    //start_code 和 end_code 定义"代码段"的起始和结束位置
    //start_data 和 end_data 定义"数据段"的起始和结束位置
    //紧挨着"BSS段",用于存放未被初始化的全局变量和静态变量,这些变量在加载进内存时会生成一段 0 填充的内存区域 (BSS段), BSS 段的大小是固定的
    unsigned long start_brk, brk, start_stack;
    //start_brk 和 brk 定义"OS堆"的起始和当前的结束位置
    //start_stack 是"栈"的起始位置在 RBP 寄存器中存储,栈的结束位置也就是栈顶指针 stack pointer 在 RSP 寄存器中存储
    unsigned long arg_start, arg_end, env_start, env_end;
    //arg_start 和 arg_end 是参数列表的位置
    //env_start 和 env_end 是环境变量的位置
    //它们都位于"栈"中的最高地址处
    unsigned long mmap_base;              /* base of mmap area */
    //mmap_base 定义"内存映射区"的起始地址
/*-------------------------------------------------------------------------------*/
//虚拟内存与物理内存映射内容相关的统计变量
/*-------------------------------------------------------------------------------*/
    unsigned long total_vm;               /* Total pages mapped */
    //total_vm 表示在进程虚拟内存空间中总共与物理内存映射的页的总数
    unsigned long locked_vm;              /* Pages that have PG_mlocked set */
    //locked_vm 就是被锁定不能换出的内存页总数
    unsigned long pinned_vm;              /* Refcount permanently increased */
    //pinned_vm 表示既不能换出,也不能移动的内存页总数
/*-------------------------------------------------------------------------------*/    
//进程虚拟内存空间中的虚拟内存使用情况
/*-------------------------------------------------------------------------------*/
    unsigned long data_vm;                /* VM_WRITE & ~VM_SHARED & ~VM_STACK */
    //data_vm 表示数据段中映射的内存页数目
    unsigned long exec_vm;                /* VM_EXEC & ~VM_WRITE & ~VM_STACK */
    //exec_vm 是代码段中存放可执行文件的内存页数目
    unsigned long stack_vm;               /* VM_STACK */
    //stack_vm 是栈中所映射的内存页数目
       ...... 省略 ........
}

5.3 内核如何++管理++ 不同类型的虚拟内存区域

每个虚拟内存区域VMA的内存描述符vm_area_struct结构体内部

每个 vm_area_struct 结构对应于虚拟内存空间中的唯一虚拟内存区域 VMA

cpp 复制代码
struct vm_area_struct {
	unsigned long vm_start;		/* Our start address within vm_mm. */
    //vm_start 指向了这块虚拟内存区域的起始地址(最低地址),vm_start 本身包含在这块虚拟内存区域内
	unsigned long vm_end;		/* The first byte after our end address within vm_mm. */
    //vm_end 指向了这块虚拟内存区域的结束地址(最高地址),而 vm_end 本身包含在这块虚拟内存区域之外
    //vm_area_struct 结构描述的是 [vm_start,vm_end) 这样一段左闭右开的虚拟内存区域

	/*
	 * Access permissions of this VMA.
	 */
	pgprot_t vm_page_prot;
    //页表中关于内存页的访问权限就是由 vm_page_prot 决定的
	unsigned long vm_flags;	
    //vm_flags 则偏向于定于整个虚拟内存区域的访问权限以及行为规范

    //和虚拟内存映射相关
    //虚拟内存区域可以映射到物理内存上,也可以映射到文件中,映射到物理内存上我们称之为匿名映射,映射到文件中我们称之为文件映射
	struct anon_vma *anon_vma;	/* Serialized by page_table_lock */
    //调用 mmap 进行匿名映射时,匿名映射区域就用 struct anon_vma 结构表示
    struct file * vm_file;		/* File we map to (can be NULL). */
    //调用 mmap 进行文件映射时,vm_file 属性就用来关联被映射的文件
	unsigned long vm_pgoff;		/* Offset (within vm_file) in PAGE_SIZE units */	
    //vm_pgoff 则表示映射进虚拟内存中的文件内容,在文件中的偏移
	void * vm_private_data;		/* was vm_pte (shared mem) */
    //vm_private_data 则用于存储 VMA 中的私有数据。具体的存储内容和内存映射的类型有关
	/* Function pointers to deal with this struct. */
	const struct vm_operations_struct *vm_ops;
    //struct vm_operations_struct 结构中定义的都是对虚拟内存区域 VMA 的相关操作函数指针
    //vm_ops 用来指向针对虚拟内存区域 VMA 的相关操作的函数指针
}

5.4 内核如何++组织++ 不同进程不同类型虚拟内存区域

内核的内存描述符vm_area_struct结构体内部(与组织结构相关的一些属性)

cpp 复制代码
struct vm_area_struct {

	struct vm_area_struct *vm_next, *vm_prev;
    //在内核中其实是通过一个 struct vm_area_struct 结构的双向链表将虚拟内存空间中的这些虚拟内存区域 VMA 串联起来的
    // vm_next ,vm_prev 指针分别指向 VMA 节点所在双向链表中的后继节点和前驱节点,内核中的这个 VMA 双向链表是有顺序的,所有 VMA 节点按照低地址到高地址的增长方向排序
    //双向链表中的最后一个 VMA 节点的 vm_next 指针指向 NULL,双向链表的头指针存储在内存描述符 struct mm_struct 结构中的 mmap 中,正是这个 mmap 串联起了整个虚拟内存空间中的虚拟内存区域
	struct rb_node vm_rb;
    //每个 VMA 区域都是红黑树中的一个节点,通过 struct vm_area_struct 结构中的 vm_rb 将自己连接到红黑树中
    //红黑树中的根节点存储在内存描述符 struct mm_struct 中的 mm_rb 中
    struct list_head anon_vma_chain; 
	struct mm_struct *vm_mm;	/* The address space we belong to. */
    //在每个虚拟内存区域 VMA 中又通过 struct vm_area_struct 中的 vm_mm 指针指向了所属的虚拟内存空间 mm_struct
	
    unsigned long vm_start;     /* Our start address within vm_mm. */
    unsigned long vm_end;       /* The first byte after our end address
                       within vm_mm. */
    /*
     * Access permissions of this VMA.
     */
    pgprot_t vm_page_prot;
    unsigned long vm_flags; 

    struct anon_vma *anon_vma;  /* Serialized by page_table_lock */
    struct file * vm_file;      /* File we map to (can be NULL). */
    unsigned long vm_pgoff;     /* Offset (within vm_file) in PAGE_SIZE
                       units */ 
    void * vm_private_data;     /* was vm_pte (shared mem) */
    /* Function pointers to deal with this struct. */
    const struct vm_operations_struct *vm_ops;
}

六、程序编译后的二进制文件如何映射到虚拟内存空间中

进程的虚拟内存空间 mm_struct 以及这些虚拟内存区域 vm_area_struct 是如何被创建并初始化的呢?

我们写的程序代码编译之后会生成一个 ELF 格式的二进制文件,这个二进制文件中包含了程序运行时所需要的元信息,比如程序的机器码,程序中的全局变量以及静态变量等。

这个 ELF 格式的二进制文件中的布局和我们前边讲的虚拟内存空间中的布局类似,也是一段一段的,每一段包含了不同的元数据。

磁盘文件中的段我们叫做 Section,内存中的段我们叫做 Segment,也就是内存区域。

磁盘文件中的这些 Section 会在进程运行之前加载到内存中并映射到内存中的 Segment。通常是多个 Section 映射到一个 Segment。

这些 ELF 格式的二进制文件中的 Section 是如何加载并映射进虚拟内存空间的呢?

内核中完成这个映射过程的函数是 load_elf_binary ,这个函数的作用很大,加载内核的是它,启动第一个用户态进程 init 的是它,fork 完了以后,调用 exec 运行一个二进制程序的也是它。当 exec 运行一个二进制程序的时候,除了解析 ELF 的格式之外,另外一个重要的事情就是建立上述提到的内存映射。

cpp 复制代码
static int load_elf_binary(struct linux_binprm *bprm)
{
      ...... 省略 ........
  // 设置虚拟内存空间中的内存映射区域起始地址 mmap_base
  setup_new_exec(bprm);

     ...... 省略 ........
  // 创建并初始化栈对应的 vm_area_struct 结构。
  // 设置 mm->start_stack 就是栈的起始地址也就是栈底,并将 mm->arg_start 是指向栈底的。
  retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
         executable_stack);

     ...... 省略 ........
  // 将二进制文件中的代码部分映射到虚拟内存空间中
  error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
        elf_prot, elf_flags, total_size);

     ...... 省略 ........
 // 创建并初始化堆对应的的 vm_area_struct 结构
 // 设置 current->mm->start_brk = current->mm->brk,设置堆的起始地址 start_brk,结束地址 brk。 起初两者相等表示堆是空的
  retval = set_brk(elf_bss, elf_brk, bss_prot);

     ...... 省略 ........
  // 将进程依赖的动态链接库 .so 文件映射到虚拟内存空间中的内存映射区域
  elf_entry = load_elf_interp(&loc->interp_elf_ex,
              interpreter,
              &interp_map_addr,
              load_bias, interp_elf_phdata);

     ...... 省略 ........
  // 初始化内存描述符 mm_struct
  current->mm->end_code = end_code;
  current->mm->start_code = start_code;
  current->mm->start_data = start_data;
  current->mm->end_data = end_data;
  current->mm->start_stack = bprm->p;

     ...... 省略 ........
}

七、内核虚拟内存空间

不同进程之间的虚拟内存空间是相互隔离的,彼此之间相互独立,相互感知不到其他进程的存在。使得进程以为自己拥有所有的内存资源。

内核态虚拟内存空间是所有进程共享的,不同进程进入内核态之后看到的虚拟内存空间全部是一样的。

以32位系统下内核空间为例,介绍内核空间的几大块

1.直接映射区

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

物理内存的直接映射区前 16M 专门让内核用来为 DMA 分配内存,这块 16M 大小的内存区域我们称之为 ZONE_DMA。剩下的部分也就是从 16M 到 896M(不包含 896M)这段区域,我们称之为 ZONE_NORMAL

2.动态映射区

本例中我们的物理内存假设为 4G,高端内存区域为 4G - 896M = 3200M,那么这块 3200M 大小的 ZONE_HIGHMEM 区域该如何映射到内核虚拟内存空间中呢?

由于内核虚拟内存空间中的前 896M 虚拟内存已经被直接映射区所占用,而在 32 体系结构下内核虚拟内存空间总共也就 1G 的大小,这样一来内核剩余可用的虚拟内存空间就变为了 1G - 896M = 128M。

显然物理内存中 3200M 大小的 ZONE_HIGHMEM 区域无法继续通过直接映射的方式映射到这 128M 大小的虚拟内存空间中。

物理内存 896M 以上的区域被内核划分为 ZONE_HIGHMEM 区域,我们称之为高端内存。

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

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

3.永久映射区

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

4.固定映射区

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

5.临时映射区

7.1 32 位体系内核虚拟内存空间布局

7.2 64 位体系内核虚拟内存空间布局


八、参考

小林 coding

相关推荐
南暮思鸢13 分钟前
应急响应靶机——linux1
linux·运维·网络安全·centos·write up·应急响应靶机·蓝队溯源
weixin_4143219843 分钟前
Linux 编译Ubuntu24内核
linux·运维·服务器
xiaozhiwise3 小时前
Makefile 之 join
linux
儿时可乖了3 小时前
Linux 定时任务全解析
linux·oracle
北'辰4 小时前
使用ENSP实现默认路由
运维·网络
学习使我飞升4 小时前
OSPF路由状态数据库、type 类型、完整的LSA
服务器·网络·智能路由器
北'辰4 小时前
使用ENSP实现静态路由
运维·网络
学习使我飞升4 小时前
spf算法、三类LSA、区间防环路机制/规则、虚连接
服务器·网络·算法·智能路由器
hgdlip4 小时前
重装系统后ip地址错误,网络无法接通怎么办
服务器·网络协议·tcp/ip·重装系统
程序员奇奥4 小时前
CentOS操作系统下安装Nacos
linux·运维·centos