从虚拟地址到物理页框:Linux 页表与内存管理全解析

前言:虚拟内存、物理内存与页表,是现代操作系统内存管理的三大核心。本文将从原理、结构、映射机制等角度,系统讲解虚拟地址空间、页表工作方式、物理内存管理,带你彻底理解程序背后的内存世界。

文章目录

问题引入
为引入今天的话题,我们先来看下面一段程序:

cpp 复制代码
  #include<stdio.h>
  #include<sys/types.h>
  #include<unistd.h>
  int main()
  {
      int k=10;
      pid_t id=fork();//创建子进程
      if(id==0)
      {
        int n=3;
        while(n--)
        {
             k++;
             printf("子进程:&k=%p,k=%d\n",&k,k);
             sleep(1);//休眠1秒
        }
      }
      else
      {
          int n=3;
          while(n--)                                                                                                                       
          {
              printf("父进程:&k=%p,k=%d\n",&k,k);
              sleep(1);
          }
      }
      return 0;
  }

输出结果:

​​

我们发现父进程和子进程的k值是不同的,这个很好理解,因为父子进程在共用资源时如果其中一方需要对资源进行修改就会发生写时拷贝,从而使得父子进程保持独立性。++不过在这里最难让人理解的是父进程与子进程中变量 k 的地址相同,但值却不同,这实在难以让人接受😱,难道是同一块内存地址还储存两个不同的值?这是根本不可能的。++

其实这里的地址并不是物理地址,而是虚拟地址,虚拟地址与物理内存地址之间还存在一个媒介。接下来给大家详细讲解。

一、什么是虚拟内存

如果一个物理内存总大小为4G,那么每个进程都会创建一个自己的虚拟内存,大小和物理内存大小一样是4G,用于映射物理内存为其分配的实际存储空间。虚拟内存比物理内存复杂得多,需要做栈区、堆区等等的区域划分。如下图解:

在虚拟内存与物理内存之间存在着一个媒介,它就是页表,起到一个++交通枢纽++ 的作用,它实际上是一个映射关系,把虚拟内存上的值通过页表映射得到对应的物理内存。当然页表的作用不止于此,它还起到权限管理的作用,即每个地址都用自己的rwx权限,对野指针、空指针等进行访问,就是在页表这里被拦截的。

有了虚拟内存的认识我们就可以解释问题引入了。

问题的解决:

一个进程(pcb)是有自己的属性和代码和数据的,父进程创建子进程后,子进程的代码和数据是和父进程共用的,所以++子进程会复制父进程的虚拟地址空间和页表,指向相同的物理内存。所以才会有问题引入那里父子进程的k的地址是一样的,实际上是虚拟地址。++

而当子进程发生写时拷贝的时候,就会把原来的虚拟地址映射到新开辟的内存地址。也就是父子进程各有自己的虚拟内存和页表,而它们的虚拟内存是一样的,当发生写时拷贝时,页表的映射关系发生了改变。所以才会有父子进程虚拟内存地址相同,物理内存不同的情况。

二、虚拟内存的描述与组织

进程有自己的虚拟内存,那么就需要对它进行管理,既然要管理就需要对它进行描述与组织,所以在Linux中就有了mm_struct结构体对虚拟内存进行描述。它所在的文件为mm_types.h。可以翻阅文档进行查看。

可以说,mm_struct结构是对整个用户空间的描述。每⼀个进程都会有⾃⼰独⽴的mm_struct,这样每⼀个进程都会有⾃⼰独⽴的地址空间才能互不⼲扰。

三、页表的优势

  • 提供连续、有序的虚拟地址视图,屏蔽物理内存的碎片化:在把数据代码加载到内存的时候,并不是有序储存的而是混乱的,也没有什么栈区,堆区等等这些区分。而使用页表做一个映射关系则可以把这些空间在虚拟地址上变得有序,方便管理。
  • 保护物理内存:页表除了有一个映射的作用,它还控制了每个地址空间的rwx这些权限,从而达到保护物理内存的目的。
  • 解耦合:不直接从虚拟内存去与物理内存匹配,而是在此之间加一个页表,这样可以使得虚拟内存和物理内存保持自己的独立性,从而起到一个解耦合的作用。

解耦合的意义极其重要。它使得程序执行变得更加灵活。比如在++创建进程的时候,只需要把虚拟地址空间和页表搭建好,可以先不把内存加载入内存,等执行到该程序在查询页表时发现代码数据并没有加载到物理内存,这个时候再数据加载入内存。++ 这就是缺页中断。

那么同理,我们就可以理解挂起操作了,++操作系统在执行过程中发现内存不足,能够实现把阻塞状态或优先级低的进程的代码数据从内存中释放,留出更多的内存给其它进程。++就是因为只要保留了虚拟内存地址和页表,那么对应的代码数据就能再次加载入内存。

四、虚拟内存区域划分

在上面我们提到虚拟地址空间是使用mm_struct来描述的,而mm_struct把虚拟内存划分为各个分区,它整体记录了各个分区的开始和结束的地址,每个分区的大小不是固定的,不同程序对各内存区域的大小需求各不相同。如下为mm_struct的部分源码。

c 复制代码
struct mm_struct
{
    /*...*/
    struct vm_area_struct *mmap; /* 指向虚拟区间(VMA)链表 */
    struct rb_root mm_rb; /* red_black树 */
    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;
    /*...*/
}

那既然每⼀个进程都会有⾃⼰独⽴的mm_struct,操作系统肯定是要将这么多进程的mm_struct组织起来的!虚拟空间的组织⽅式有两种:

  • 当虚拟区较少时采取单链表,由mmap指针指向这个链表。
  • 当虚拟区间多时采取红⿊树进⾏管理,由mm_rb指向这棵树。

linux内核使⽤ vm_area_struct 结构来表⽰⼀个独⽴的虚拟内存区域(VMA),由于每个不同质的虚拟内存区域功能和内部机制都不同,因此⼀个进程使⽤多个vm_area_struct结构来分别表⽰不同类型的虚拟内存区域。上⾯提到的两种组织⽅式使⽤的就是vm_area_struct结构来连接各个VMA,以便进程快速访问。

图解:

五、物理空间理解

磁盘是按4KB数据块存取的,可执行文件本质就是文件,存储在磁盘上。而物理内存并不是以字节为单位进行管理的,而是以4KB的内存块来管理(要与磁盘等外设统一)。所以内存和磁盘的数据是以4KB为单位进行交互的,这个4KB空间称为页框或页帧 (记住这个概念,后文都用页框来描述)。

关于磁盘到系统的原理学习链接:https://blog.csdn.net/2302_80105876/article/details/157979876?spm=1001.2014.3001.5501

而如果内存空间是4GB,那么就需要划分4GB/4KB=1,048,576个数据块。

  • 注意:内存条是一个整体不是什么小块划分,这里说的数据块是系统层面的一个逻辑结构。

那么怎么知道那些内存没有被占用?有多少的内存块?那些不能被刷新到磁盘?那些准备释放了?那些是共享内存?

所以我们需要对内存(页框)有一套管理方法,需要将它进行描述和组织,这就是struct page,用来管理一个页框,那么管理整个内存就需要一个struct page数组,即struct page mem[1048576]。对内存的增删改查,本质就是对mem数组的增删改查。

内存是连续的,只要知道page的下标就能查到页框起始物理地址 了,物理地址=下标x4KB。所以page里面就不用存储起始物理地址。如何通过物理地址知道它是那个page?物理地址/4KB=page数组下标。

  • 注1:page对页框的管理,主要是设置一些标记位。
  • 注2:page本身也要占用内存,所以这个结构体必须特别小。
  • 注3:物理内存是以4KB为单位的,尽管你只申请了size个字节它会给4KB(或它的整数倍)的空间,只不过只让你用你申请的那size个字节。同样比如子进程看似只是改变一个4字节的变量,但它写时拷贝的是整个4KB的空间。共享内存只能以4KB的整数倍申请空间的原因。

为什么这么做?这是在资源浪费和效率之间找平衡,比如STL库的扩容机制

从磁盘载入数据本质是什么?在page数组中检查标志位找到没有被占用的page,然后把磁盘数据载入到该page,然后把相应标识位进行设置,表示该page已经被占用。

struct page源码(取自Linux-2.6.18版本):

c 复制代码
struct page {
	unsigned long flags;		/* Atomic flags, some possibly
					 * updated asynchronously */
	atomic_t _count;		/* Usage count, see below. */
	atomic_t _mapcount;		/* Count of ptes mapped in mms,
					 * to show when page is mapped
					 * & limit reverse map searches.
					 */
	union {
	    struct {
		unsigned long private;		/* Mapping-private opaque data:
					 	 * usually used for buffer_heads
						 * if PagePrivate set; used for
						 * swp_entry_t if PageSwapCache;
						 * indicates order in the buddy
						 * system if PG_buddy is set.
						 */
		struct address_space *mapping;	/* If low bit clear, points to
						 * inode address_space, or NULL.
						 * If page mapped as anonymous
						 * memory, low bit is set, and
						 * it points to anon_vma object:
						 * see PAGE_MAPPING_ANON below.
						 */
	    };
#if NR_CPUS >= CONFIG_SPLIT_PTLOCK_CPUS
	    spinlock_t ptl;
#endif
	};
	pgoff_t index;			/* Our offset within mapping. */
	struct list_head lru;		/* Pageout list, eg. active_list
					 * protected by zone->lru_lock !
					 */
	/*
	 * On machines where all RAM is mapped into kernel address space,
	 * we can simply calculate the virtual address. On machines with
	 * highmem some memory is mapped into kernel virtual memory
	 * dynamically, so we need a place to store that address.
	 * Note that this field could be 16 bits on x86 ... ;)
	 *
	 * Architectures with slow multiplication can define
	 * WANT_PAGE_VIRTUAL in asm/page.h
	 */
#if defined(WANT_PAGE_VIRTUAL)
	void *virtual;			/* Kernel virtual address (NULL if
					   not kmapped, ie. highmem) */
#endif /* WANT_PAGE_VIRTUAL */
};

page中比较重要的参数

  • flags :⽤来存放⻚的状态 。这些状态包括⻚是不是脏的,是不是被锁定在内存中等。flag的每⼀位单独表示⼀种状态,所以它⾄少可以同时表⽰出32种不同的状态。这些标志定义在<linux/page-flags.h>中。其中⼀些⽐特位⾮常重要,如PG_locked++⽤于指定⻚是否锁定++ ,PG_uptodate++⽤于表示⻚的数据已经从块设备读取并且没有出现错误++。
  • _mapcount :++表示在⻚表中有多少项指向该⻚(计数器)++,也就是这⼀⻚被引⽤了多少次。当计数值变为-1时,就说明当前内核并没有引⽤这⼀⻚,于是在新的分配中就可以使⽤它。
  • virtual :是⻚的虚拟地址。通常情况下,它就是**⻚在虚拟内存中的地址**。有些内存(即所谓的⾼端内存)并不永久地映射到内核地址空间上。在这种情况下,这个域的值为NULL,需要的时候,必须动态地映射这些⻚。

要注意的是++struct page 与物理⻚相关,⽽并⾮与虚拟⻚相关++ 。⽽系统中的每个物理⻚都要分配⼀个这样的结构体,假设 struct page 占40个字节的内存、系统的物理⻚为 4KB ⼤⼩、系统有 4GB 物理内存。这么多⻚⾯的page结构体消耗的内存只不过40MB ,相对⽽⾔,仅是很⼩的⼀部分罢了。因此,要管理系统中这么多物理⻚⾯,这个代价并不算太⼤。

要知道的是,⻚的⼤⼩对于内存利⽤和系统开销来说⾮常重要,⻚太⼤,⻚内必然会剩余较⼤不能利⽤的空间(⻚内碎⽚)。⻚太⼩,虽然可以减⼩⻚内碎⽚的⼤⼩,但是⻚太多,会使得⻚表太⻓⽽占⽤内存,同时系统频繁地进⾏⻚转化,加重系统开销。因此,⻚的⼤⼩应该适中,通常为 512B - 8KB ,Windows/Linux系统的⻚框⼤⼩为4KB。

注:内存的申请通常由进程或线程发起。例如,打开文件、申请地址空间等操作都需要消耗内存资源。

关于文件缓冲区

这个slots存储的就是page的地址,page地址/4KB=page所在的数组下标,数组下标x4KB=物理地址。所以文件缓冲区本质是内存上的页框。

可以看到这里的page是树形结构,其实page不止是数组结构,为了方便管理它在不同模块里被不同的结构存储。

关于共享内存

所以共享内存的本质是一块文件缓冲区。

六、页表映射原理

虚拟地址是面向程序员的,是以字节为单位对物理内存的映射。

  • 注意:页表本身也要占内存空间!!

假设我们用页表给虚拟地址与物理地址一对一的直接映射,如果是64位机器,那么地址要用8字节来储存,整个内存有4GB,存储所有映射就需要4GBx8=32GB的空间来存储页表,这就太离谱?很明显不合理!

页表的设计

为了方便讲解,这里我们使用32为机器为例:

  • 虚拟地址 00000000 00 000000 00000000 00000000

把它划分为3部分,分别是10bite、10bite、12bite。

两个页结构:

  • 页目录(数组结构,只有一个):存储一级页表地址。
  • 页表(数组结构,有1024个):存储目标页框的起始地址。(页表可以有分多级,这里只讲没有分级的)

虚拟地址怎么查物理地址:

  • 前10个bite位:作为页目录数组下标的索引,找到页表的地址。
  • 中间10个bite位:作为页表数组的下标索引,找到页框地址(这里只存它的前20个bite位)。
  • 最后12个bite位:作为页框内偏移量,找到目标字节的物理地址。

图解:

  • 注意:页表中一个元素的32bite位只有前20位是目标物理地址,在找物理页时,但20个bite位又怎么能找到物理地址呢?它把后面的12位用0填充去找物理页起始地址。而页表的元素的后12位也没闲着,它是用来做访问管理,比如鉴权,检测地址合法性等等。

页目录 → 存的是页表的物理地址

页表 → 存的是目标物理页框的地址
页表项中的地址 + 12位偏移 = 最终物理地址

现在可以在来看看整个页结构占多少空间,一个目录页占的大小=1024x4字节,一个页表大小=1024x4字节。整个页结构大小=4096+4096x1024个页=4,198,400字节,即4MB。

4MB已经很低了,但实际程序运行中不可能把整个页表结构完全填充,都是用到才创建,不用就销毁,占用空间远远低于4MB,这也是20多GB的内存能运行上百GB游戏的原因之一。

  • 注:单级⻚表对连续内存要求⾼,于是引⼊了多级⻚表,但是多级⻚表也是⼀把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。

为什么要用后12位作为页偏移量呢?

12个bite位能表示 2 12 = 4096 2^{12}=4096 212=4096个字节,而一个页框(数据块)的大小刚好是4096字节。++那么只要是高20位的虚拟地址相同,就必然使得虚拟地址映射到同一个地址块中++ 。有很好的聚集效果,根据局部性原理,能使得数据访问更高效。

现在知道虚拟地址怎么通过页表找到物理地址了。但问题有来了,页目录地址是在物理内存上存储的,我们又怎么找到页目录呢?在CPU内存有一个CR3寄存器,保存了当前进程下页表目的起始地址,通过CR3访问页目录。

此外CPU内集成了MMU(内存管理单元)它的工作是将虚拟地址通过页表找物理地址,虚拟地址(进)->CPU->物理地址(出)

MMU首先不会直接去通过页表找物理地址,每次都这么找还是有些慢,而是先查询一个高速缓存结构 ------TLB(Translation Lookaside Buffer,也称为快表 ),当 CPU 给 MMU 传新虚拟地址之后, MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存。MMU会去⻚表中找到物理地址之后,会把这条映射关系给到TLB。

如果CPU给MMU的虚拟地址,在 TLB 和⻚表都没有找到对应的物理⻚,就是缺⻚异常 Page Fault ,它是⼀个由硬件中断触发的可以由软件逻辑纠正的错误;如果⽬标内存⻚在物理内存中没有对应的物理⻚或者存在但⽆对应权限,CPU就⽆法获取数据,这种情况下CPU就会报告⼀个缺⻚错误 ;由于CPU没有数据就⽆法进⾏计算,CPU罢⼯了用户进程也就出现了缺⻚中断 ,进程会从用户态切换到内核态,并将缺⻚中断交给内核的Page Fault Handler 处理。

其根据缺⻚中断的不同类型会进⾏不同的处理:

  • Hard Page Fault 也被称为 Major Page Fault ,翻译为硬缺⻚错误/主要缺⻚错误,这时物理内存中没有对应的物理⻚,需要CPU打开磁盘设备读取到物理内存中,再让MMU建⽴虚拟地址和物理地址的映射。
  • Soft Page Fault 也被称为 Minor Page Fault ,翻译为软缺⻚错误/次要缺⻚错误,这时物理内存中是存在对应物理⻚的,只不过可能是其他进程调⼊的,发出缺⻚异常的进程不知道⽽已,此时MMU只需要建⽴映射即可,⽆需从磁盘读取写⼊内存,⼀般出现在多进程共享内存区域。
  • Invalid Page Fault 翻译为**⽆效缺⻚错误**,⽐如进程访问的内存地址越界访问,⼜⽐如对空指针解引⽤内核就会报 segment fault 错误中断进程直接挂掉。


如何理解我们之前的 newmalloc

内存申请的本质就是分配虚拟地址空间(即改变指针指向),但并不直接在物理内存中开辟空间,只有在用该内存时才会开辟空间建立页表映射。即延迟申请,你申请了不用,可以先给别人用,提高内存使用的充分度。

  • 总结1:进程可以看做一张页目+n张页表构建的映射体现,以虚拟地址作为索引,物理地址页框是目标。
  • 总结2:资源划分/共享本质是页表条目的划分/共享。

非常感谢您能耐心读完这篇文章。倘若您从中有所收获,还望多多支持呀!

相关推荐
袁袁袁袁满1 小时前
Linux如何导出指定时间的日志?
linux·运维·服务器·linux日志·linux日志导出
捷利迅分享1 小时前
Xshell高效运维实战技术大纲(含企业级案例+命令示例)
运维
Never_Satisfied1 小时前
在c#中,Jint的AsString()和ToString()的区别
服务器·开发语言·c#
键盘鼓手苏苏2 小时前
Flutter for OpenHarmony:cider 自动化版本管理与变更日志生成器(发布流程标准化的瑞士军刀) 深度解析与鸿蒙适配指南
运维·开发语言·flutter·华为·rust·自动化·harmonyos
阿林爱吃大米饭2 小时前
课题组远程服务器Git版本控制实战
服务器·git·elasticsearch
未来之窗软件服务2 小时前
服务器运维(三十九)日服务器mysql错误日志分析工具—东方仙盟
运维·服务器·服务器运维·仙盟创梦ide·东方仙盟
怀旧诚子2 小时前
podman搭建freeswitch服务器
服务器·podman
skywalk81632 小时前
Easytier进行服务器安装@Ubuntu22.04
linux·运维·服务器
浩子智控2 小时前
提升linux串口通信实时性的编程实践
linux·单片机·嵌入式硬件