前言

本文的虚拟内存,需要区别于Windows上的虚拟内存,Windows上的虚拟内存应该对应于Linux上的交换内存,是一种把磁盘当内存使用的技术。

本文的虚拟内存,是一种内存管理技术。区别于嵌入式中的应用程序,直接运行在物理内存上。操作系统(如Linux)的应用程序,是直接运行在虚拟内存上,而由内核进行内存转换映射的。直接运行在物理内存上,会发生同时加载两个进程,两个进程可能会访问同一个物理地址。

虚拟内存管理的主要方式有分段式内存和分页式内存两种,以及两种的结合。

本文涉及的内容在于:如何将虚拟内存转换成物理内存

用户内存空间分段

进程的内存空间是独立的且虚拟的,逻辑上将进程地址分为从低地址往上分为代码段、数据段、BSS段、段、文件映射和栈,并且逻辑大小就是占用0xFFFFFFFF字节(4GB, 32位系统)或者0xFFFFFFFFFFFFFFFF(16EB, 64位系统),最后是内核空间。

在32位系统下,0xC0000000-0xFFFFFFFF是内核空间(1GB),其他的是用户空间。

  • 代码段:起始地址并不是0x0,这是因为0x0通常被用作NULL地址,存储着二进制的代码,一个进程的代码段大小不会变
  • 数据段:data段,紧挨着代码段,存储已初始化的静态变量和全局变量,其大小不变
  • BSS段:紧挨着数据段,有时也可以称数据段和BSS段位数据段,存储着未初始化的静态变量和全局变量,操作系统会自动填充0,其大小不变
  • 堆段:动态内存分配的地址,从低地址向高地址增长
  • 文件映射段:动态库,共享内存库等,用于映射文件或磁盘,也可以向上增长
  • 栈段:局部变量,函数调用上下文等,一般最大为8MB(可在操作系统更改),向下增长,起点是0xC0000000

在64位系统中,0x0-0x00007FFFFFFFF000是用户空间(128TB),0xFFFF800000000000-0xFFFFFFFFFFFFFFFF是内核空间(128TB),中间是未定义空间,分段与32位是一样的,只是大小会不一样。

内核空间和用户空间区别在于:进入内核空间的必须是在内核态运行才可(通常是一些系统调用),所有程序的内核空间是共享的(映射到同样的物理地址),用户空间是独立的(映射到不同物理地址)。

分段式内存管理

基本机制

操作系统将用户进程分为若干个段,有栈段,堆段,代码段,数据段等。分段式管理就是,将这些段单独映射到不同的物理内存上。

  • 段表:

    分段式内存管理,存在一个表,叫做段表

    段表保存了每个段的 段基址段界限:段基址就是在物理内存的基地址,段界限就是表示这个段最大是多少

  • 分段机制将虚拟地址分为:段选择因子和段内偏移量

  • 段选择因子:主要包括段号,段号就是在段表中的索引

  • 段内偏移量:就是相对于段基址的偏移地址

  • 计算实际物理地址:

    那么就可以通过 段基址 + 段内偏移量 来计算

    还需要检验段内偏移量是否不大于段界限才行

问题与不足

由于每个段的大小是不一样的,如果有些小段被释放了,总共内存是足够另一个大段使用的,但是有可能这些小段会分布在不连续的物理地址上面,这样另一个大段就无法使用了。这样的小段内存被称之为,内存碎片。

常用的解决方法是,内存重整(这需要移动大量内存),交换内存(需要频繁与磁盘进行内存交换)

分页式内存管理

基本机制

分页式内存管理与分段式最大不同在于,分页式内存管理将内存最小单位划分成一页(Linux下,一般是4KB),不足一页的也分成一页。同样的在于,内核会将这些页映射到不同的物理内存上。

  • 页表:相当于段表

    页表也是存储在内存里面,内存管理单元(MMU)可以将页表的虚拟内存转换成物理内存,属于一种硬件层面的内存管理效率提升

    如果虚拟内存在页表中查找不到,系统会产生一个缺页异常

    页表存储着虚拟页号和物理页号

  • 内存碎片处理:

    由于每页大小都是一样的,释放了一页,另外一页一定可以放入内存中,那么就不存在内存碎片(也称之为外部内存碎片)的问题。但是仍然会存在内部内存碎片的问题(即不够一页的也是使用一页来存储,会导致内存浪费)。

  • 交换内存:

    物理地址仍然是有限的,但是可以利用硬盘来存放不需要用到的物理地址,从而其他需要用的内存就会有空间可用,这种技术称之为交换内存,将内存的页存入磁盘,称之为换出,将磁盘页存入内存,称之为换入。

  • 分页机制将虚拟地址分为:页号和页内偏移量

  • 页号就是在页表中的编号

  • 页内偏移量就是在页中的偏移量

  • 每个进程都会拥有自己独立的页表,而不是由操作系统统一管理的,这是为了限制进程的访问权限

问题与不足

在32位的机器上,虚拟地址空间有$2{32}\text{B}=4\text{GB}$,每个页大小是$2{12}\text{B}=4\text{KB}$,那么就需要$2^{20}$个页,每个页有四个字节,那么就需要$4\text{MB}$的内存存放页表,这是对于一个应用进程来说的。

对于64位操作系统,那么需要更多了。

多级分页式内存管理

以二级分页式管理举例,多级分页式管理就是:

  • 分页机制将虚拟地址分为:一级页号,二级页号,物理页内偏移量。(类比分页式内存管理)

  • 将$2{20}$个页分成$2{10}=1024$个一级页表项,再将一个一级页表项分成1024个二级页表项

  • 一级页号以及一级页表:

    一级页号是索引一级页表的

    一级页表分为一级页号和二级页表地址

    一级页表中的一行数据被分成了1024个二级页数据,并存储在一个二级页表中,也就是说,一级页表有多少行数据,就有多少个二级页表

  • 二级页号以及二级页表:

    二级页号是索引二级页表的

    二级页表分为二级页号和物理页号

    物理页号+物理页内偏移就可以索引到真实物理地址

  • 减少页表内存原理:

    一级页表需要$4\text{B}*2{10}=4\text{KB}$内存,一个二级页表需要$4\text{B}*2{10}=4\text{KB}$内存。多级页表可以在只有用到一级页表的情况下,才创建一级页表。

    假如有$20\%$的一级页表被用到,那就是只需要创建$20\%2^{10}\approx205$个二级页表,那占用的空间就是$4\text{KB}+2054\text{KB}=284\text{KB}$。

    远小于这个一级页表管理

将二级页表管理推广到多级页表中,可减少更多的内存,如$20\%$的一级二级页表被使用,那么就只会创建更少的三级页表,由于每个页表占用空间一样,那么需要的内存就又会除以5。

问题与不足

如果需要使用的内存太多,那么需要创建的页表就会更多,占用的空间反而比纯一级页表的要多了。而且,多级页表的寻址速度会非常慢,这是因为需要一层层才能转换到物理地址。

地址转换加速

通过专用的内存管理单元(Memory Management Unit, MMU)来完成虚拟地址到物理地址的转换,已经是一种加速了。还可以通过页表缓存(Translation Lookaside Buffer)来加速,MMU会先在页表缓存中寻找,如果没有才会一步步进行物理地址寻址工作。

段页式结合地址管理

根据分段机制将程序分成了段,再对每一段进行分页操作,一个用户进程包括一个段表和若干个页表,以一级页表管理来举例:

  • 段表项分为段号,页地址和物理页偏移量
  • 页表项存储虚拟页号和物理页号
  • 寻址工作就是
    1. 通过段来访问对应端项,找到页表地址以及页表号
    2. 访问对应页表的对应页表项,得到物理页号
    3. 通过物理页号和偏移量访问实际的物理地址

Linux 虚拟内存分布

下面是Linux中32位/64位系统中虚拟内存的分配

32位系统中虚拟内存空间整体布局.png

64位系统中虚拟内存空间整体布局.png

PS:图片来源于小林coding图解系统-深入理解Linux物理内存,在此非常感谢小林coding对面试八股的分享。

Linux 系统调用

Linux系统调用是在分配虚拟内存上的资源,而不是直接分配物理内存的,用户常用的是如下两个函数:

void *malloc(size_t size);
void free(void *ptr);

malloc通常使用伙伴算法进行内存分配,也通常采用内存池的方式进行分配。申请的内存包含大小+基址,然后返回基址,因为大小通常是固定的,一般2B大小,然后调用free函数就可以自动往前找2B,这样就只需要传入一个指针就能释放内存了。