内存的由来

程序要运行,必须先加载到内存。

但在很久以前,准确地说是在操作系统出现以前,程序并不需要加载到内存就能运行。实际上,在那个已经久远的年代里,程序曾经存放在卡片上,计算机每读取一张卡片,就运行一条指令。因此,程序是直接从卡片到执行。

但这种从外部存储媒介上直接执行指令的做法效率极低,且灵活性很差。因此,人们发明了内存储器来将需要运行的程序先行加载,再执行,从而提高效率和灵活性。

此后,人们对内存的要求越来越多,越来越高。

内存架构

理想状态下,程序员或用户对内存的要求是:大容量、高速度和持久性。但程序员面临的物理现实却是一个由缓存、主存、磁盘、磁带等组成的内存架构。在这个内存的实现架构中,缓存的特点是低容量、高速度、高价格;主存的特点则是中容量、中速度、中价格;磁盘则属于大容量、低速度、低成本的存储媒介;磁带则通常更持久但速度更慢。

内存架构

内存管理

很显然,这样一个存储架构与程序员或用户对内存的要求相差甚远。要以这个存储架构为基础来提供程序员所需的内存抽象,我们需要一个巧妙有效的内存管理机制。

内存管理机制负责对内存架构进行管理,使程序在内存架构的任何一个层次上的存放对于用户来说都是一样的。用户无须担心自己的程序是存储在缓存、主存、磁盘还是磁带上,反正运行、计算、输出的结果都一样。

而内存管理实现这种媒介透明的手段就是虚拟内存。虚拟内存就是操作系统提供给用户的一个“幻象”。这个幻象构建在内存架构的顶端,给用户提供一个比物理主存空间大许多的地址空间。

内存管理的目标

内存管理就是要提供一个虚幻的景象,就像钱,虚的东西,实际上一文不值,但是大家认为它有这个价值。

一个东西的价值在于能否满足我们的渴望和需要。如果能,这个东西就有价值。那么内存管理就是要提供一个有价值的虚幻。用术语来说就是抽象。那么内存管理要提供哪些抽象呢?或者说,内存管理要达到什么目标呢?

首先,由于多道程序同时放在内存中,操作系统要保证它们之间互不干扰。所谓互不干扰就是一个进程不能随便访问另一个进程的地址空间。这是内存管理要达到的第一个目标。

那还有没有别的目标呢?我们看一下程序指令执行的过程。程序指令在执行前加载到内存,然后从内存中一条条指令读出,然后执行(从硬件层来看,指令的“读取-执行”循环是计算机的基本操作)。每条执行在执行时需要读取操作数和写入运算结果,要读取操作数,就需要给出操作数所在的内存地址,这个地址不能是物理主存地址。这是因为该程序在何种硬件配置的机器上运行并不能事先确定,操作系统自然不可能对症下药地发出对应于某台机器的物理主存地址。因此,指令里面的地址是程序空间(虚拟空间)的虚拟地址(程序地址)。即程序发出的地址与具体机器的物理主存地址是独立的。这是内存管理要达到的另外一个目标。

综上所述,内存管理要达到如下两个目标:

  • 地址保护:一个程序不能访问另一个程序的地址空间。(程序-程序)

  • 地址独立:程序发出的地址应与物理主存地址无关。(程序-硬件)

这两个目标就是衡量一个内存管理系统是否完善的标准。它是所有内存管理系统必须提供的基本抽象。

虚拟内存

一个程序如果要运行,必须加载到物理主存中。但是物理主存的容量有限,购买更大的物理主存成本太高。那有没有办法在不增加成本的情况下扩大内存容量呢?有,那就是虚拟内存。

用户程序可以部分存放在主存、部分存放在磁盘。而在程序执行时,程序发出的地址到底是在主存还是在磁盘则由操作系统的内存管理模块负责判断,并到相应的地方进行读写操作。

虚拟内存要提供的就是一个空间像磁盘那样大、速度像缓存那样高的主存储系统。而对程序地址所在位值(缓存、主存、磁盘)的判断则是内存管理系统的一个中心功能。

有了虚拟内存,我们编写的程序从此不再受物理主存尺寸的限制。

虚拟内存除了让程序员感觉到内存容量大大增加之外,还让程序员感觉到内存速度也加快了。这是因为虚拟内存将尽可能地从缓存满足用户访问请求,从而给人以速度提升了的感觉。从这个角度看,虚拟内存就是实际存储架构与程序员对内存需求之间的一座桥梁。

虚拟内存所提供的抽象

当然,容量增大也好,速度提升也好,都是虚拟内存提供的一个幻象,实际上并不是这么回事,但用户感觉到是真的,这就是魔术。操作系统的一个角色就是魔术师。

虚拟地址空间的内部又被分为内核空间用户空间两部分,不同字长的处理器,地址空间的范围也不同。最常见的 32 位和 64 位系统的虚拟地址空间如下:

img

通过这里可以看出,32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间。而 64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。

PS:所以打印出地址是 0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF,就可以确定这是内核空间的地址。用户空间的地址为 0x0 ~ 0x00007FFFFFFFF000。

虽然每个进程的地址空间都包含内核空间,但这些内核空间,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。

只有内核才可以直接访问物理内存。

总结:

  • 只有内核可以直接访问物理内存
  • 所有进程的内核空间都被映射到相同的物理内存

内存映射

既然每个进程都有一个那么大的地址空间,那么所有进程的虚拟内存加起来,自然要比实际的物理内存大得多。所以,并不是所有的虚拟内存都会分配物理内存,只有那些实际时用的虚拟内存才分配物理内存。分配后的物理内存,是通过内存映射来管理的。

内存映射,其实就是将虚拟内存地址映射到物理内存地址。为了完成内存映射,内核为每个进程都维护了一张页表,记录映射关系,页表实际上存储在 CPU 的内存管理单元 MMU 中,这样正常情况下,处理器就可以直接通过硬件,找出要访问的内存。

而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。

MMU

MMU 并不以字节为最小单元来管理内存,而是规定了一个内存映射的最小单位,也就是页,通常是 4KB 大小。这样每一次内存映射,都需要关联 4KB 或者 4KB 整数倍的内存空间。

页的大小只有 4KB,导致的另一个问题就是整个页表会变得非常大。解决办法就是多级页表和大页。

虚拟内存空间分布

最上方的内核空间不用多讲,下方的用户空间内存,其实又被分成了多个不同的段。

以 32 位系统为例:

【极客时间】

通过这张图可以看到,用户空间内存,从低到高分别是五种不同的内存段。

  1. 只读段,包括代码和常量等。
  2. 数据段,包括全局变量等。
  3. 堆,包括动态分配的内存,从低地址开始向上增长。
  4. 文件映射段,包括动态库、共享内存等,从高地址开始向下增长。
  5. 栈,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。

在这 5 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc() 或者 mmap(), 就可以分别在堆和文件映射段动态分配内存。

内存分配与回收

malloc() 是 C 标准库提供的内存分配函数,对应到系统调用上,有两种实现方式,即 brk() 和 mmap()。

对小块内存(小于 128K),C 标准库使用 brk() 来分配,也就是通过推动堆顶的位值来分配内存。这些内存释放后并不会立刻归还系统,而是被缓存起来,这样就可以重复使用。

当这两种调用发生后,其实并没有真正分配内存。这些内存,都只在首次访问时才分配,也就是通过缺页异常进入内核中,再由内核来分配内存。