虚拟内存
内存地址直接操作物理地址可能会存在一些问题:
- 内存中的逻辑地址直接访问物理地址需要考虑开发者手动对数据进行布局,且内存不够的情况下可能会导致程序崩溃;
- 在多进程开发环境中,多个进程之间的协同分配和释放内存会导致系统性能低下和提高开发的复杂度;
基于以上问题提出了虚拟内存的概念,CPU和操作系统联手编织了一个“假象”:每个进程独享4G虚拟内存空间(64位则是128T),并且每个进程的地址空间都是相互隔离的(都在用户态上);
进程持有的虚拟内存地址会通过CPU中的MMU的映射关系然后转变成物理地址,然后再通过物理地址访问内存:
相当于MMU是作为虚拟内核和物理内存转换的一个管理器,两者之间互不关心:
- 进程启动后通过 malloc 等内存分配接口将内存从待分配状态变成已分配状态
- 然后对该内存进行读写时,操作系统为其分配物理内存
MMU管理虚拟地址与物理地址之间的关系的方式主要有两种:内存分段 和 内存分页
内存分段
先看下Linux系统虚拟内存的分段机制原理图:
段选择因子:保存在段寄存器中,里面最重要的是段号,关联到段表中(作为段表的索引);段表中保存的是每个段的段基地址、段的界限和特权等级等。
段内偏移量:偏移量每个段都是唯一的,位于0到段界限之间,映射公式:段基地址+段内偏移量=物理内存地址
举个例子:
如果要访问段3中偏移量500的虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。
注:以上例子参考小林coding的文章
内存分段的优缺点:
先看看分段机制解决的问题:
- 多个进程地址空间不隔离的问题:一个段占用一个虚拟地址空间,不会发生空间增长时碰撞到另一个段的问题,从而避免空间不够而造成编译失败的问题;
- 程序运行时地址不确定:使用分段机制后程序不用关心物理地址,只要虚拟地址没有改变就不会操作到另一个进程的物理地址上去;
再来看看分段机制的缺点:
- 导致外部内存碎片的问题
- 内存交换的效率较低
分段产生内存碎片的原因:
已分配的段有大有小,未使用的段也有大有小,将要分配的段也有大有小,各方需求不一定,举个例子:一个程序需要10kb的段,此时有15kb的空闲,但是不连续分别为9kb和6kb,由于数据段或代码段等对于内存的要求必须是连续的,那么操作系统就无法满足这个10kb 的请求,这就是外部碎片问题;
另外,由于分段管理是根据实际需求分配内存,所以有多少需求就分配多大的段,所以不会出现内部内存碎片
分段导致内存交换的效率低的原因:
由于上一个原因,连续的地址不够多导致无法分配更多内存空间给段,所以势必会导致频繁的将内存中的段 swap 到硬盘上去给需要分配的段腾出足够大小的连续空间,那么这个 swap 的过程就会产生性能瓶颈/
因为硬盘的访问速度要比内存慢太多了,每次 swap 操作都需要将一大段连续的内存数据写到硬盘中去,所以如果内存交换的时候交换的是一个占内存空间很大的程序就会导致卡顿。
为了解决这些问题,更为合理的分页机制就应运而生。
内存分页
单级页表
大概说下单级页表和工作方式吧已经被现代操作系统淘汰了;
介绍:
单级页表的产生是为了解决分段机制带来的那两个问题的,分页把整个虚拟内存和物理内存切分成一段段固定大小的页,在32位操作系统中每一页的大小为4KB(这个大小与硬件架构和历史原因、内存对齐等原因有关不赘述)。
页表是需要存储在内存空间中,然后通过MMU来管理这个映射关系。
映射方式:
在分页机制下虚拟地址分为两个部分,页号和页内偏移,然后页号在页表中有个映射关系,对应的就是物理内存中的基地址,找到对应关系后再加上页内偏移量就找到了物理地址;
解决的问题:
前面说单级页表的产生是为了解决分段机制带来的那两个问题的,主要是外部内存碎片和内存交换效率低的问题;
-
由于虚拟内存空间和物理内存空间都是预先分配好的一段段固定大小的页每个页都是紧密排列的,在存储映射的过程中只需要将虚拟内存中对应的页存储到物理内存中对应的页上去,所以不会存在外部碎片的问题;
但是因为分页机制分配内存的最小单位是一页,即使单位不足一页也会最少分配一页,所以可能存在内部碎片问题;
-
如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称之为换出,需要时再加载进来,称之为换入。所以一次性换入换出也就几个页的大小,不会出现分段机制下换出一大块内存到磁盘上。
存在的问题:
以32位单个页4KB为例,对于单个进程来说虚拟地址空间就有4GB,一个页可以存储4KB大小,每个页表项需要占用4个字节大小内存空间,那么一个进程就需要分配4MB大小的内存来存储页表;若100个进程内存中就要分配400MB来存储页表了,显然是个不合理的设计;
怎么算的:4GB / 4KB * 4 = 4MB
为什么单级页表不能按需加载:
所有虚拟页号必须映射到有效的页表项(即使物理页未分配),因为:
- 硬件直接通过虚拟页号(VPN)计算页表项地址,若该位置无有效项,MMU无法区分是“未分配”还是“非法访问”。
- 若允许部分页表项缺失,硬件需额外支持“页表项缺页”机制,复杂度陡增。
多级页表
要解决上面单级页表的问题,于是采用了一种叫多级页表的解决方案;
多级页表往往需要根据具体的情况来划分为多少级,以32位操作系统为例,只需要划分二级页表即可:
地址划分:
- 页目录项:10位
- 页表项:10位
- 页内偏移:12位
然后根据以下原因,所以必须要先分配一级页表:
- 硬件强制要求:CPU的MMU要求页表必须从一级开始逐级查询,一级页表是地址翻译的根目录,没有一级页表就没法定位二级页表。
- 覆盖全地址空间:一级页表的1024项对应所有可能的二级页表范围(每个一级项覆盖 2^10×2^12=4MB地址空间)。即使进程仅使用1MB内存,一级页表仍需完整存在以覆盖所有可能的地址。
64位系统的多级页表的地址划分如下(48位地址,页大小4KB):
- 全局页目录项 PGD(Page Global Directory):9位
- 上层页目录项 PUD(Page Upper Directory):9位
- 中间页目录项 PMD(Page Middle Directory):9位
- 页表项 PTE(Page Table Entry):9位
- 页内偏移:12位
也是一级页表(PGD)需要预先分配且加载到内存中,总大小为2^9*4B = 2KB
Linux 内存管理
在用户空间中地址从低到高分别是:
- 代码段,包括二进制可执行代码;
- 数据段,包括已初始化的静态常量和全局变量;
- BSS 段,包括未初始化的静态变量和全局变量;
- 堆段,包括动态分配的内存,从低地址开始向上增长;
- 文件映射段,包括动态库、共享内存等,从低地址开始向上增长;
- 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是
8 MB。当然系统也提供了参数,以便我们自定义大小;