Linux 操作系统原理 — 虚拟内存
目录
文章目录
为什么需要虚拟内存?
在现代操作系统中,多任务已是标配。多任务并行,大大提升了 CPU 利用率,但却引出了多个进程对内存操作的冲突问题,虚拟内存概念的提出就是为了解决这个问题。
操作系统有一块物理内存(中间的部分),有两个进程(实际会更多)P1 和 P2,操作系统偷偷地分别告诉 P1 和 P2,我的整个内存都是你的,随便用,管够。可事实上呢,操作系统只是给它们画了个大饼,这些内存说是都给了 P1 和 P2,实际上只给了它们一个序号而已。只有当 P1 和 P2 真正开始使用这些内存时,系统才开始使用辗转挪移,拼凑出各个块给进程用,P2 以为自己在用 A 内存,实际上已经被系统悄悄重定向到真正的 B 去了,甚至,当 P1 和 P2 共用了 C 内存,他们也不知道。
操作系统的这种欺骗进程的手段,就是虚拟内存。对 P1 和 P2 等进程来说,它们都以为自己占用了整个内存,而自己使用的物理内存的哪段地址,它们并不知道也无需关心。
虚拟内存
虚拟内存是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间)。
而实际上,虚拟内存通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换,加载到物理内存中来。目前,大多数操作系统都使用了虚拟内存,如 Windows 系统的虚拟内存、Linux 系统的交换空间等等。
虚拟内存地址和用户进程紧密相关,一般来说不同进程里的同一个虚拟地址指向的物理地址是不一样的,所以离开进程谈虚拟内存没有任何意义。每个进程所能使用的虚拟地址大小和 CPU 位数有关。在 32 位的系统上,虚拟地址空间大小是 232=4G,在 64 位系统上,虚拟地址空间大小是 264=16G,而实际的物理内存可能远远小于虚拟内存的大小。
每个用户进程维护了一个单独的页表(Page Table),虚拟内存和物理内存就是通过这个页表实现地址空间的映射的。
当进程执行一个程序时,需要先从内存中读取该进程的指令,然后执行,获取指令时用到的就是虚拟地址。这个虚拟地址是程序链接时确定的(内核加载并初始化进程时会调整动态库的地址范围)。
为了获取到实际的数据,CPU 需要将虚拟地址转换成物理地址,CPU 转换地址时需要用到进程的页表(Page Table),而页表(Page Table)里面的数据由操作系统维护。其中页表(Page Table)可以简单的理解为单个内存映射(Memory Mapping)的链表(当然实际结构很复杂)。
里面的每个内存映射(Memory Mapping)都将一块虚拟地址映射到一个特定的地址空间(物理内存或者磁盘存储空间)。每个进程拥有自己的页表(Page Table),和其他进程的页表(Page Table)没有关系。
Linux 进程的虚拟内存
Linux 的虚拟存储器涉及三个概念: 虚拟存储空间,磁盘空间,内存空间。
可以认为虚拟空间都被映射到了磁盘空间中,(事实上也是按需要映射到磁盘空间上,通过 mmap),并且由页表记录映射位置,当访问到某个地址的时候,通过页表中的有效位,可以得知此数据是否在内存中,如果不是,则通过缺页异常,将磁盘对应的数据拷贝到内存中,如果没有空闲内存,则选择牺牲页面,替换其他页面。
mmap 是用来建立从虚拟空间到磁盘空间的映射的,可以将一个虚拟空间地址映射到一个磁盘文件上,当不设置这个地址时,则由系统自动设置,函数返回对应的内存地址(虚拟地址),当访问这个地址的时候,就需要把磁盘上的内容拷贝到内存了,然后就可以读或者写,最后通过 man_map 可以将内存上的数据换回到磁盘,也就是解除虚拟空间和内存空间的映射,这也是一种读写磁盘文件的方法,也是一种进程共享数据的方法,即共享内存。
- 每个进程都有自己独立的 4G 内存空间,各个进程的内存空间具有类似的结构。
- 一个新进程建立的时候,将会建立起自己的内存空间,此进程的数据,代码等从磁盘拷贝到自己的进程空间,哪些数据在哪里,都由进程控制表中的 task_struct 记录,task_struct 中记录中一条链表,记录中内存空间的分配情况,哪些地址有数据,哪些地址无数据,哪些可读,哪些可写,都可以通过这个链表记录。
- 每个进程已经分配的内存空间,都与对应的磁盘空间映射。
- 每个进程的 4G 内存空间只是虚拟内存空间,每次访问内存空间的某个地址,都需要把地址翻译为实际物理内存地址。
- 所有进程共享同一物理内存,每个进程只把自己目前需要的虚拟内存空间映射并存储到物理内存上。
- 进程要知道哪些内存地址上的数据在物理内存上,哪些不在,还有在物理内存上的哪里,需要用页表来记录。
- 页表的每一个表项分两部分,第一部分记录此页是否在物理内存上,第二部分记录物理内存页的地址(如果在的话)。
- 当进程访问某个虚拟地址,去看页表,如果发现对应的数据不在物理内存中,则缺页异常。
- 缺页异常的处理过程,就是把进程需要的数据从磁盘上拷贝到物理内存中,如果内存已经满了,没有空地方了,那就找一个页覆盖,当然如果被覆盖的页曾经被修改过,需要将此页写回磁盘。
用户进程申请并访问物理内存(或磁盘存储空间)的过程总结如下:
- 用户进程向操作系统发出内存申请请求。
- 系统会检查进程的虚拟地址空间是否被用完,如果有剩余,给进程分配虚拟地址。
- 系统为这块虚拟地址创建内存映射(Memory Mapping),并将它放进该进程的页表(Page Table)。
- 系统返回虚拟地址给用户进程,用户进程开始访问该虚拟地址。
- CPU 根据虚拟地址在此进程的页表(Page Table)中找到了相应的内存映射(Memory Mapping),但是这个内存映射(Memory Mapping)没有和物理内存关联,于是产生缺页中断。
- 操作系统收到缺页中断后,分配真正的物理内存并将它关联到页表相应的内存映射(Memory Mapping)。中断处理完成后,CPU 就可以访问内存了
- 当然缺页中断不是每次都会发生,只有系统觉得有必要延迟分配内存的时候才用的着,也即很多时候在上面的第 3 步系统会分配真正的物理内存并和内存映射(Memory Mapping)进行关联。
另外,值得注意的是,在每个进程创建加载时,内核只是为进程 “创建” 了虚拟内存的布局,具体就是初始化进程控制表中内存相关的链表,实际上并不立即就把虚拟内存对应位置的程序数据和代码(e.g. .text、.data 段)拷贝到物理内存中,只是建立好虚拟内存和磁盘文件之间的映射就好(叫做存储器映射),等到运行到对应的程序时,才会通过缺页异常,来拷贝数据。还有进程运行过程中,要动态分配内存,比如:malloc 时,也只是分配了虚拟内存,即为这块虚拟内存对应的页表项做相应设置,当进程真正访问到此数据时,才引发缺页异常。
在用户进程和物理内存(磁盘存储器)之间引入虚拟内存主要有以下的优点:
- 地址空间:提供更大的地址空间,并且地址空间是连续的,使得程序编写、链接更加简单。
- 进程隔离:不同进程的虚拟地址之间没有关系,所以一个进程的操作不会对其他进程造成影响。
- 数据保护:每块虚拟内存都有相应的读写属性,这样就能保护程序的代码段不被修改,数据块不能被执行等,增加了系统的安全性。
- 内存映射:有了虚拟内存之后,可以直接映射磁盘上的文件(可执行文件或动态库)到虚拟地址空间。这样可以做到物理内存延时分配,只有在需要读相应的文件的时候,才将它真正的从磁盘上加载到内存中来,而在内存吃紧的时候又可以将这部分内存清空掉,提高物理内存利用效率,并且所有这些对应用程序都是透明的。
- 共享内存:比如动态库只需要在内存中存储一份,然后将它映射到不同进程的虚拟地址空间中,让进程觉得自己独占了这个文件。进程间的内存共享也可以通过映射同一块物理内存到进程的不同虚拟地址空间来实现共享。
- 物理内存管理:物理地址空间全部由操作系统管理,进程无法直接分配和回收,从而系统可以更好的利用内存,平衡进程间对内存的需求。
- 既然每个进程的内存空间都是一致而且固定的,所以链接器在链接可执行文件时,可以设定内存地址,而不用去管这些数据最终实际的内存地址,这是有独立内存空间的好处。
- 当不同的进程使用同样的代码时,比如库文件中的代码,物理内存中可以只存储一份这样的代码,不同的进程只需要把自己的虚拟内存映射过去就可以了,节省内存。
- 在程序需要分配连续的内存空间的时候,只需要在虚拟内存空间分配连续空间,而不需要实际物理内存的连续空间,可以利用碎片。
SWAP 交换内存
通常,Linux 的内存已满并且内核没有可写空间时,系统就会崩溃。但如果系统拥有交换分区,那么 Linux 内核和程序就会使用它,但是速度会慢很多。因此,拥有 SWAP 交换空间更安全。
交换分区有一个缺点:它比 RAM 慢很多,因此,添加交换空间不会使你的计算机运行速度更快,它只会帮助克服一些内存不足带来的限制。
虚拟内存的 SWAP 特性并不总是有益,放任进程不停地将数据在内存与磁盘之间大量交换会极大地占用 CPU,降低系统运行效率,所以有时候我们并不希望使用 SWAP。可以修改 vm.swappiness=0 来设置内存尽量少使用 SWAP,或者干脆使用 swapoff 命令禁用掉 SWAP。
虚拟内存的分配
虚拟内存的分配,包括用户空间虚拟内存和内核空间虚拟内存。
注意,分配的虚拟内存还没有映射到物理内存,只有当访问申请的虚拟内存时,才会发生缺页异常,再通过上面介绍的伙伴系统和 slab 分配器申请物理内存。
用户空间内存分配(malloc)
malloc 用于申请用户空间的虚拟内存,当申请小于 128KB 小内存的时,malloc 使用 sbrk 或 brk 分配内存;当申请大于 128KB 的内存时,使用 mmap 函数申请内存;
存在的问题:由于 brk/sbrk/mmap 属于系统调用,如果每次申请内存都要产生系统调用开销,CPU 在用户态和内核态之间频繁切换,非常影响性能。而且,堆是从低地址往高地址增长,如果低地址的内存没有被释放,高地址的内存就不能被回收,容易产生内存碎片。
解决:因此,malloc 采用的是内存池的实现方式,先申请一大块内存,然后将内存分成不同大小的内存块,然后用户申请内存时,直接从内存池中选择一块相近的内存块分配出去。
内核空间内存分配
先来回顾一下内核地址空间。
kmalloc 和 vmalloc 分别用于分配不同映射区的虚拟内存。
kmalloc
kmalloc() 分配的虚拟地址范围在内核空间的直接内存映射区。
按字节为单位虚拟内存,一般用于分配小块内存,释放内存对应于 kfree ,可以分配连续的物理内存。函数原型在 <linux/kmalloc.h> 中声明,一般情况下在驱动程序中都是调用 kmalloc() 来给数据结构分配内存。
kmalloc 是基于 Slab 分配器的,同样可以用 cat /proc/slabinfo 命令,查看 kmalloc 相关 slab 对象信息,下面的 kmalloc-8、kmalloc-16 等等就是基于 Slab 分配的 kmalloc 高速缓存。
vmalloc
vmalloc 分配的虚拟地址区间,位于 vmalloc_start 与 vmalloc_end 之间的动态内存映射区。
一般用分配大块内存,释放内存对应于 vfree,分配的虚拟内存地址连续,物理地址上不一定连续。函数原型在 <linux/vmalloc.h> 中声明。一般用在为活动的交换区分配数据结构,为某些 I/O 驱动程序分配缓冲区,或为内核模块分配空间。
CPU 是如何访问内存的?
从图中可以清晰地看出,CPU、MMU、DDR 这三部分在硬件上是如何分布的。首先 CPU 在访问内存的时候都需要通过 MMU 把虚拟地址转化为物理地址,然后通过总线访问内存。MMU 开启后 CPU 看到的所有地址都是虚拟地址,CPU 把这个虚拟地址发给 MMU 后,MMU 会通过页表在页表里查出这个虚拟地址对应的物理地址是什么,从而去访问外面的 DDR(内存条)。
所以搞懂了 MMU 如何把虚拟地址转化为物理地址也就明白了 CPU 是如何通过 MMU 来访问内存的。
MMU 是通过页表把虚拟地址转换成物理地址,页表是一种特殊的数据结构,放在系统空间的页表区存放逻辑页与物理页帧的对应关系,每一个进程都有一个自己的页表。
CPU 访问的虚拟地址可以分为:p(页号),用来作为页表的索引;d(页偏移),该页内的地址偏移。现在我们假设每一页的大小是 4KB,而且页表只有一级,那么页表长成下面这个样子(页表的每一行是 32 个 bit,前 20 bit 表示页号 p,后面 12 bit 表示页偏移 d):
CPU,虚拟地址,页表和物理地址的关系如下图:
页表包含每页所在物理内存的基地址,这些基地址与页偏移的组合形成物理地址,就可送交物理单元。
上面我们发现,如果采用一级页表的话,每个进程都需要 1 个 4MB 的页表(假如虚拟地址空间为 32 位,即 4GB、每个页面映射 4KB 以及每条页表项占 4B,则进程需要 1M 个页表项,4GB/4KB = 1M),即页表(每个进程都有一个页表)占用 4MB(1M * 4B = 4MB)的内存空间)。
然而对于大多数程序来说,其使用到的空间远未达到 4GB,何必去映射不可能用到的空间呢?也就是说,一级页表覆盖了整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 0.804MB(1K * 4B + 0.2 * 1K * 1K * 4B = 0.804MB
)。
除了在需要的时候创建二级页表外,还可以通过将此页面从磁盘调入到内存,只有一级页表在内存中,二级页表仅有一个在内存中,其余全在磁盘中(虽然这样效率非常低),则此时页表占用了8KB(1K * 4B + 1 * 1K * 4B = 8KB
),对比上一步的 0.804MB,占用空间又缩小了好多倍!总而言之,采用多级页表可以节省内存。
二级页表就是将页表再分页。仍以之前的 32 位系统为例,一个逻辑地址被分为 20 位的页码和 12 位的页偏移 d。因为要对页表进行再分页,该页号可分为 10 位的页码 p1 和 10 位的页偏移 p2。其中 p1 用来访问外部页表的索引,而 p2 是是外部页表的页偏移。