解构内存迷宫:串联虚拟地址、页表与内存使用(一)

介绍

说到内存,读者之前可能听说过很多概念,比如虚拟地址,虚拟内存,页表,内核,物理内存管理等,各个概念可能听上去都知道,但是却很少有文章将这些内存相关的概念串联起来,这篇文章就是通过几个使用场景将这些概念串联起来,让读者能更清楚的了解这些概念的同时,对内存使用的流程有个整体上的了解。 让我们开始吧,一起去探索 程序的一页数据是如何在计算机内存中"安家落户"的。

物理内存和物理地址

我们先从物理内存说起,他是一个物理介质,在通电的情况下可以存储数据,而物理地址就是内存的一个坐标,我们可以通过提供一个物理地址和一个长度来读取内存上对应区域存储的数据。 我们程序在运行时,就经常需要使用内存来写入和读取数据,比如a=0 就是把a变量映射成了一个物理地址,然后在物理地址对应的内存区域上写上0,这样在读取a这个变量时,就可以通过a的物理地址从内存中找到0这个数据,就读取到了变量的值。

这个是理想情况,但是实际上操作系统并不会像是这样直接使用物理地址,而是使用一个称为虚拟地址的方式存储变量的地址,然后要访问变量值时,使用这个虚拟地址,加上一个称为页表的映射结构,转换成物理地址,然后再到对应的内存区域中获取变量的值。

使用物理地址的问题

为什么要特意加上这个转换逻辑呢,直接使用物理地址存储不行么? 其实早期是有直接使用物理内存存储数据的机器的,但是他有几个问题:

  1. 安全问题:进程没有隔离,系统安全性大为下降,因为所有进程使用一个相同的"地址空间",有可能恶意进程会访问不属于他的物理地址,造成权限问题,或者不小心覆盖了不属于他的数据,甚至是操作系统数据。
  2. 内存碎片问题:比如一个进程申请100M的空间,用完后释放了,但是这块内存地址的前后内存块都被使用了,这时候有个进程申请120M的连续内存,这100M就无法被使用,因为他前后的内存已经被占用了,这就造成操作系统上有很多内存空洞,空闲的内存总量是够用的,但是无法分配一个较大的连续内存,这就是内存碎片。
  3. 易用性问题:编程与管理的复杂度高,因为程序员或者编译器必须自己管理物理内存,每次程序启动的变量内存地址都不一样,很容易出错。
  4. 灵活性问题:内存共享与稀疏存储,很难实现内存共享,比如很多程序都需要用到标准库(如libc),使用物理内存的话,只能每个进程都在物理内存中保留一份,造成浪费。

虚拟地址和页表

那么,虚拟地址和页表是如何解决这些问题的呢?首先介绍下这两个概念,虚拟地址是每个进程都拥有的,从0开始的连续的内存地址空间 ,之所以称为虚拟,是因为程序使用这个地址访问时,并不是访问这个地址对应的物理内存。而页表,则是将虚拟地址映射成一个物理地址的数据结构。 需要注意的是,页表是一个数据结构,因此页表也是需要存在物理内存上的,会占用一定内存空间,你可以理解为类似一个java中的map,输入虚拟地址a,通过查询页表,可以一步一步找到a对应的物理地址b。

还有一个需要说明的是这个概念,操作系统是将内存以4k大小为一个最小分配单位,每次进程申请内存时,就会分配多个4k大小的空闲内存页给进程。比如进程申请15k的内存,操作系统就会给他分配4个4k的内存块,然后在页表中增加四个页表项,将4个内存块的虚拟地址映射到实际的物理内存地址。页表页表,顾名思义,就是页的映射表。

虚拟地址的使用

有了虚拟地址后,操作系统只会让程序使用虚拟地址,他会在进程启动时为每个进程生成一个独立的页表,之后不管程序访问什么地址,操作系统都会把这个地址当成虚拟地址,通过程序的页表进行转换成物理地址,然后再进行访问。

举个例子,比如进程访问一个地址为a的虚拟地址,操作系统并不会去访问b这个物理内存地址,而是会去查询页表,找到对应的物理内存b,然后访问b地址中的内存的数据。而且这个b地址不是固定的,他是随机的,是在程序申请a地址的内存时,操作系统取查找空闲的内存页,然后配置到页表上的。

虚拟地址和页表的好处

使用虚拟地址和页表为什么能解决上述问题呢?

  1. 安全问题:使用虚拟地址后,每个程序访问的地址都被当做虚拟地址处理,然后通过页表转换成物理地址,而页表的数据只有操作系统能管理,这样只要操作系统做好内存管理,不要把同一块空闲内存分配个多个进程,就能确保进程之间的隔离性,即使恶意进程想要访问一个别的进程使用的物理地址,他的访问地址也会被当做虚拟地址处理,访问到分配给他的一个他自己的空闲内存上。
  2. 内存碎片问题:在虚拟地址空间中,进程以为自己拥有一段非常长的连续内存,比如他认为自己有1G的连续空间,但是实际上,操作系统通过页表将这1G空间映射到物理内存中分散的内存块上,这样就能通过复用小块内存避免内存碎片问题,因为任何空闲的物理页都能被利用,大大提高了内存利用率
  3. 易用性问题:每个进程都从固定的地址(如0x400000)开始加载代码。链接器和编译器可以提前确定很多地址,编程模型简单统一。
  4. 灵活性问题:共享内存实现起来很简单,比如对于一些标准库,操作系统可以将其在内存中存储一份,然后在启动程序时,如果他们需要标准库的代码,就可以在进程启动时,让操作系统将不同进程的存储标准库代码的虚拟地址页表项映射到同一个物理页,这样,系统的动态链接库只需要在物理内存中保存一份,所有进程共享,节省大量内存。

多级页表

之前介绍了使用页表的原因,但是如果页表的实现只是单纯的将一个虚拟地址映射到另外一个物理地址,这也是有问题的,这种方式被称为单级页表,相当于每个虚拟地址是页表这个map的key,它对应的物理地址,也就是value可以不存在,但是每个key必须存在,所以操作系统必须为每一个虚拟地址生成一个页表项,这就会导致一个程序的页表项太多。之前说过,页表是个数据结构,他本身也是占用一定内存大小的,页表项太多的话就会占用较大内存。

这里有个地方需要注意下,因为linux系统是以4k为最小的内存分配单位,因此单级页表中的每个页表项指向的也是一个4k的内存,所以一个单级页表的总项目数量是 最大物理内存大小(字节) / (4*1024) 字节,比所有地址的数量要少 。两个连续的页表项之间相隔4kb,不是每个字节都有对应的页表项,访问时找的是根据虚拟地址计算出的他所属于的那个页的虚拟地址。

单级页表占用的内存

假设使用的是一个32位的操作系统:

  1. 一个进程的虚拟地址共有2^32=4G,这也是操作系统的最大内存。
  2. 换算成KB是: 4 GB = 4 × 1024 × 1024 KB = 4,194,304 KB
  3. 而一个系统的页大小是4KB,为了存储一个进程的所有虚拟地址,需要 4,194,304/4 = 1,048,576 个页表项。
  4. 每个页表项占用4字节,则 共需要 1,048,576*4/1024/1024=4M 的空间。
  5. 如果启动100个进程,别的内存不算,页表占用的内存就达到了4*100 = 400M,占了系统内存的10%,

通过计算可以看出,单级页表造成了极大浪费,因为一般情况下进程不会使用到页表中大部分的虚拟地址空间。更糟糕的是,这4MB的页表必须是连续的物理内存,随着系统运行,分配一块连续的4MB内存会变得非常困难。

多级页表的优势

那么使用多级页表是怎么解决这个问题的呢?首先介绍下多级页表,顾名思义,他就是采用了多个层级,当寻找一个虚拟地址对应的物理地址时,需要一级一级的查找,经过多次映射后才能找到对应的物理地址。就比如字典,查找一个字时,先找首字母,这个首字母就是顶级页表,找到后在字母中再根据拼音顺序找对应汉字,这个就是第二级,最后再是根据找到的页数,去对应页数找到汉字,这个就是通过三级页表找到了物理地址。

linux系统中一般默认使用四级页表,为了简化,我们假设使用二级列表来看下他是怎么节约内存的,以32位系统为例:

  1. 我们将地址分为 10位(第1级索引) + 10位(第2级索引) + 12位(页内偏移)
  2. 前10位就是顶级页表,一共用了 2^10 = 1024 个条目
  3. 顶级页表的大小 = 1024 * 4字节 = 4KB(正好一页)。

顶级页表是必须有的,但是二级列表可以等使用到内存时再分配,因此在一个程序启动时,只需要用到4k的页表项,这就比之前的4M要小的多了。

多级页表和单级页表的区别

有人可能会问,为什么多级页表不需要提前存储后面几个层级的页表,而单级页表却需要存储所有页表项呢?因为单级页表是直接索引,虚拟地址直接作为索引,去一个巨大的、连续的数组中查找,为了能够通过索引直接定位,这个数组必须完整存在,所以单级页表不能缺少页表项。

而多级页表是间接索引,只要确保第一级存在,第二级可以在你需要用到时才去加载,这是一种"懒加载"策略。

举个例子,比如现在多级页表只有顶级页表,你需要存储一个数据时,输入指定虚拟地址去存数据,操作系统根据虚拟地址去顶级页表这个目录找到地址对应的二级页表地址时,发现二级页表的地址不存在,此时他就可以分配内存,生成需要访问的虚拟地址所在的二级页表的数据,即一个10位的二级页表,此时只是多了一个二级页表,即2^10* 4字节 = 4KB大小。

结语

好了,先介绍到这里,本片文章主要讲解了虚拟地址和页表相关内容,后续内容在下一篇文章中呈现。

相关推荐
悟空码字16 分钟前
SpringBoot整合FFmpeg,打造你的专属视频处理工厂
java·spring boot·后端
独自归家的兔18 分钟前
Spring Boot 版本怎么选?2/3/4 深度对比 + 迁移避坑指南(含 Java 8→21 适配要点)
java·spring boot·后端
superman超哥28 分钟前
Rust 移动语义(Move Semantics)的工作原理:零成本所有权转移的深度解析
开发语言·后端·rust·工作原理·深度解析·rust移动语义·move semantics
superman超哥39 分钟前
Rust 所有权转移在函数调用中的表现:编译期保证的零成本抽象
开发语言·后端·rust·函数调用·零成本抽象·rust所有权转移
源代码•宸42 分钟前
goframe框架签到系统项目开发(实现总积分和积分明细接口、补签日期校验)
后端·golang·postman·web·dao·goframe·补签
无限进步_1 小时前
【C语言】堆(Heap)的数据结构与实现:从构建到应用
c语言·数据结构·c++·后端·其他·算法·visual studio
初次攀爬者1 小时前
基于知识库的知策智能体
后端·ai编程
喵叔哟1 小时前
16.项目架构设计
后端·docker·容器·.net
强强强7951 小时前
python代码实现es文章内容向量化并搜索
后端
A黑桃1 小时前
Paimon 表定时 Compact 数据流程与逻辑详解
后端