虚拟化之内存
早期的操作系统实际上就是一个库,存放在物理内存的0KB到64KB的位置,然后其他的进程将使用剩下的内存,这时的内存对应的就是物理内存上的地址,完全没有抽象的概念。后来随着程序越来越多,人们开始共享机器,并且程序间不应该存在影响,安全显得越发重要。这时候人们就开始考虑程序不应该直接获取物理内存上的位置信息,而是应该把程序和物理内存隔离一下。
地址空间#
操作系统提供了一个简单易用的物理内存抽象,叫做地址空间。一个进程的地址空间包括所运行程序的全部内存状态,比如程序代码,栈、堆,用户管理的内存等等。
例如一块512KB的内存,0到64KB被分配给了操作系统,然后从128KB的位置到192KB的位置被分配给了进程A,从192KB到256KB的位置被分配给了进程B,从320KB到384KB的位置被分配给了进程C,如果每个进程都知道自己在物理内存中的确切位置,这就是没有抽象内存时的做法。现在我们将内存抽象成了地址空间,那么每个进程实际上都认为自己在独占整个内存的,认为它是从内存的0位置开始加载的,而操作系统在硬件支持下(一个叫做MMU的内存管理单元,负责将虚拟地址和对应的物理地址进行转换),能够保证正确地将进程加载到指定位置,比如进程A尽管觉得自己是从OKB位置运行的,但会被正确地加载到128KB的位置。
总之,地址空间的这种内存虚拟化方式提供了3个巨大的好处:
- 透明。程序并不会感觉自己在一个虚拟的内存上运行,相反每个程序都感觉自己拥有整个内存资源。
- 效率。虚拟内存这件事不应该占用太多的时间和空间,毕竟我们的主要目的还是运行程序。
- 保护。每个程序都觉得只有自己在运行,完全不知道自己以外的信息,这就提供了进程间的保护隔离。
可以说,内存虚拟化技术是现代操作系统发展的基础。
内存操作API#
在C中有一对内存操作的函数,没错,就是malloc()
和free()
。而C++对其进行了封装,提供了new
和delete
这对关键字,它可以帮你完成更多的工作,比如在申请到内存后直接在申请到的内存上构造出你想要的对象。
上面的都只能称为库函数,深入到操作系统的话,有一个系统调用叫做brk
,它可以用来改变堆的结束位置,通过传入新的堆位置的地址,来决定是增加还是减小当前的堆。还有一个调用sbrk
要求只能传入一个增量,不过效果差不多。C/C++库函数的底层肯定是调用了这个系统调用或类似的,不过我们在编写程序的时候不应该直接调用系统调用,最好还是使用库函数提供的接口。
另外还有一对接口mmap()
和unmmap()
,实现的是从操作系统中申请获得/释放一块匿名内存空间的映射,这个区域不会与任何文件关联,而是与交换空间相关联,我们可以通过指针像操作堆一样来操作这块内存空间。
地址转换#
地址转换全称为基于硬件的地址转换,硬件将会对每一次的内存访问进行处理,将指令中的虚拟内存转换成物理内存中的实际地址。
每个CPU中都有一个负责地址转换的部分,称为内存管理单元(Memory Managemen Unit),简称MMU,通过一个基址寄存器和一个界限寄存器来转换每个进程的虚拟内存地址到物理内存地址,简单的就是将虚拟内存地址加上基址的偏移,然后界限寄存去负责保证计算出来的地址不会超过当前的界限。
分段#
地址转换无疑是将虚拟地址映射到物理地址极好的方法,但问题是物理地址总是有限的,如果每个进程都使用一对基准寄存器和界限寄存器来映射,即使这个进程没有使用到这么多的内存,这些已经被影射了的物理内存也会被标记为正在使用,造成了极大的浪费。对于操作系统来说,硬件资源总是紧张的,如果我们能够再进行细分,对每个部分都使用一对独立的基址寄存器和界限寄存器来标记,只把真正需要使用到的映射到物理内存中,那么物理内存就会得到更高的利用率,这也就是分段。同样,每个分段都会有自己的边界检查,如果在一个分段中使用了错误的地址,就会出现编程时十分熟悉又害怕的段错误(segmentation fault)。
当我们的代码使用了堆、栈等分段中的数据时,系统需要知道要去哪个分段中寻找,常见的做法就是使用虚拟地址的高几位作为标记,比如00代表代码段,01代表堆段,02代表栈段等,通过识别高位的内容再加上对应的地址偏移即可。
栈需要单独提一下,因为它是从高地址到低地址生长的,于是在虚拟地址中还需要一位来标识地址的偏移方向。
通过分段的方式,还有一些意外的惊喜,比如内存的共享,对于某段很多进程都会使用到的段,完全可以映射到同一个地方,只要再在虚拟地址中增加几位作为读写权限即可。
分段在给物理内存的使用增加了灵活性和高效率的同时,也引入了一个头疼的问题——碎片。假设系统有4096KB的内存空间,一个分段使用了01024KB位置的内存,另一个分段使用了20483000KB位置的内存,当再有一个需要2000KB空间的分段时,发现系统中的空闲内存大小明明大于所需的大小,但是却再也分配不不出来一块连续的物理内存给它了。
碎片是无法避免的,但是却可以有好的策略去减少碎片的产生。几种分配策略:
最优匹配
首先遍历整个空闲列表,找到大于等于请求的空闲块,然后将最小的一块分配出去。
最差匹配
与最优匹配相反,它会找到最大的那个空闲块,然后切割出请求所需的内存分配出去,然后将剩下的部分重新加入空闲列表。
首次匹配
找到一个足够大的块,将请求所需的大小分配出去,然后剩下的部分等待下个请求来分配。
下次匹配
不同于首次匹配的每次都从列表开头开始查找,下次匹配策略会多维护一个指针,指向上一次查找结束的位置。
另外还有一些更高级也更复杂的策略,就留待需要的时候再探索了。
分页#
下面我们需要考虑虚拟地址具体应该如何实现。主流的做法都是将内存分割成固定大小,每个单元称为一页,对应着物理内存上的一块连续的地址,称为页帧。每个页帧都包含了一个虚拟内存页。
这样就实现了虚拟地址和物理内存之间灵活的映射。为了记录地址空间的每个虚拟页对应物理内存的位置,操作系统在每个进程中都维护了一个数据结构称为页表,其中页表的下标表示了对应的哪个虚拟页,而其中的记录对应了映射到哪块物理内存上。
假设在一个操作系统中的虚拟地址空间有64字节,那么我们就需要6位来表示它,因为内存总是以页为最小单位进行整存整取的,假设当前的页帧是16字节大小,那么就可以划分为4页,这就需要2个bit位来表示,那么还剩下4个bit位就可以用来表示具体某个数据在对应的页中的偏移量了。这表示操作系统一次读取6bit就可以完整的获得内存页的信息,而在主流的64位操作系统中,操作系统一次可以读取64bit,假设分页的大小设置为4K的话,那么偏移量需要占用12字节,还剩下52位可供标记页面号和其他操作,整体的操作系统可以使用的虚拟地址空间是2的64次方,这远大于我们日常使用的内存大小。这正好也说明了为什么32位的系统最多只能使用4G的内存。
分页无疑是一个好的方案,但是如果你再仔细计算下或者实际使用时就会发现,似乎使用一张线性表来记录所有的页开销有点太大了。自然而然我们应该想到的,也是工程实践中常用的做法——加一层。既然一张线性表太大了,那不妨再加一张表来再映射一遍页表,通过多级页表的层层映射就可以把线性的大小降低指数级别,当然,这也不是免费的,代价就是性能的降低,以前一次的内存读取,现在可能需要经过多次。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)