内存管理概述

计算机所谓的“计算”指的是:

  • 进程和线程对于 CPU 的使用
  • 对内存的管理

独享内存空间的原理

每个进程都独享一段内存空间,并且真实物理内存地址对进程不可见,操作系统会给进程分配一个虚拟地址,每个进程看到的内存地址都是从 0 开始。操作系统会将不同进程的虚拟地址和不同内存的物理地址做映射。当程序访问虚拟地址时,由内核的数据结构进行转换,转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址。

规划虚拟地址空间

通过以上的原理,我们可以看出,操作系统的内存管理,主要分为三个方面。

  1. 物理内存的管理;
  2. 虚拟地址的管理;
  3. 虚拟地址和物理地址如何映射;

进程获取了一段独立的虚拟内存空间后,可以不用管其他进程,“任意”使用这片内存,但是也有一点规则。这篇内存需要存放内核态和用户态的内容。高地址存放内核态的内容,低地址存放用户态的内容。具体分界线 64 位与 32 位不同,暂不深究。

我们从最低位开始排起,先是Text Segment、Data Segment 和 BSS Segment。Text Segment 是存放二进制可执行代码的位置,Data Segment 存放静态常量,BSS Segment 存放未初始化的静态变量。是不是觉得这几个名字很熟悉?没错,咱们前面讲 ELF 格式的时候提到过,在二进制执行文件里面,就有这三个部分。这里就是把二进制执行文件的三个部分加载到内存里面。

Responsive Image

接下来是(Heap)。堆是往高地址增长的,是用来动态分配内存的区域,malloc 就是在这里面分配的。 接下来的区域是Memory Mapping Segment。这块地址可以用来把文件映射进内存用的,如果二进制的执行文件依赖于某个动态链接库,就是在这个区域里面将 so 文件映射到了内存中。 再下面就是(Stack)地址段。主线程的函数调用的函数栈就是用这里的。

普通进程不能访问内核空间,如果需要进行更高权限的工作,就需要系统调用进入内核。每一段进程的内存空间存放的内容各不相同,但是进入内核后看到的都是同一个内核空间,同一个进程列表。

内核的代码访问内核的数据结构,大部分的情况下都是使用虚拟地址的,虽然内核代码权限很大,但是能够使用的虚拟地址范围也只能在内核空间,也即内核代码访问内核数据结构。

接下来,我们需要知道,如何将其映射成为物理地址呢?

咱们前面讲 x86 CPU 的时候,讲过分段机制,咱们规划虚拟空间的时候,也是将空间分成多个段进行保存。我们来看看分段机制的原理。

Responsive Image

分段机制下的虚拟地址由两部分组成,段选择子段内偏移量。段选择子就保存在咱们前面讲过的段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址段的界限特权等级等。虚拟地址中的段内偏移量应该位于 0 和段界限之间。如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。

例如,我们将上面的虚拟空间分成以下 4 个段,用 0~3 来编号。每个段在段表中有一个项,在物理空间中,段的排列如下图的右边所示。如果要访问段 2 中偏移量 600 的虚拟地址,我们可以计算出物理地址为,段 2 基地址 2000 + 偏移量 600 = 2600。

Responsive Image

在 Linux 里面,段表全称段描述符表(segment descriptors),放在全局描述符表 GDT(Global Descriptor Table)里面,会有下面的宏来初始化段描述符表里面的表项。

#define GDT_ENTRY_INIT(flags, base, limit) { { { \
    .a = ((limit) & 0xffff) | (((base) & 0xffff) << 16), \
    .b = (((base) & 0xff0000) >> 16) | (((flags) & 0xf0ff) << 8) | \
      ((limit) & 0xf0000) | ((base) & 0xff000000), \
  } } }

一个段表项由段基地址 base、段界限 limit,还有一些标识符组成。

DEFINE_PER_CPU_PAGE_ALIGNED(struct gdt_page, gdt_page) = { .gdt = {
#ifdef CONFIG_X86_64
  [GDT_ENTRY_KERNEL32_CS]    = GDT_ENTRY_INIT(0xc09b, 0, 0xfffff),
  [GDT_ENTRY_KERNEL_CS]    = GDT_ENTRY_INIT(0xa09b, 0, 0xfffff),
  [GDT_ENTRY_KERNEL_DS]    = GDT_ENTRY_INIT(0xc093, 0, 0xfffff),
  [GDT_ENTRY_DEFAULT_USER32_CS]  = GDT_ENTRY_INIT(0xc0fb, 0, 0xfffff),
  [GDT_ENTRY_DEFAULT_USER_DS]  = GDT_ENTRY_INIT(0xc0f3, 0, 0xfffff),
  [GDT_ENTRY_DEFAULT_USER_CS]  = GDT_ENTRY_INIT(0xa0fb, 0, 0xfffff),
#else
  [GDT_ENTRY_KERNEL_CS]    = GDT_ENTRY_INIT(0xc09a, 0, 0xfffff),
  [GDT_ENTRY_KERNEL_DS]    = GDT_ENTRY_INIT(0xc092, 0, 0xfffff),
  [GDT_ENTRY_DEFAULT_USER_CS]  = GDT_ENTRY_INIT(0xc0fa, 0, 0xfffff),
  [GDT_ENTRY_DEFAULT_USER_DS]  = GDT_ENTRY_INIT(0xc0f2, 0, 0xfffff),
......
#endif
} };
EXPORT_PER_CPU_SYMBOL_GPL(gdt_page);

这里面对于 64 位的和 32 位的,都定义了内核代码段、内核数据段、用户代码段和用户数据段。另外,还会定义下面四个段选择子,指向上面的段描述符表项。

#define __KERNEL_CS      (GDT_ENTRY_KERNEL_CS*8)
#define __KERNEL_DS      (GDT_ENTRY_KERNEL_DS*8)
#define __USER_DS      (GDT_ENTRY_DEFAULT_USER_DS*8 + 3)
#define __USER_CS      (GDT_ENTRY_DEFAULT_USER_CS*8 + 3)

通过分析,我们发现,所有的段的起始地址都是一样的,都是 0。所以,在 Linux 操作系统中,并没有使用到全部的分段功能。那分段是不是完全没有用处呢?分段可以做权限审核,例如用户态 DPL 是 3,内核态 DPL 是 0。当用户态试图访问内核态的时候,会因为权限不足而报错。 其实 Linux 倾向于另外一种从虚拟地址到物理地址的转换方式,称为分页(Paging)。对于物理内存,操作系统把它分成一块一块大小相同的页,这样更方便管理,例如有的内存页面长时间不用了,可以暂时写到硬盘上,称为换出。一旦需要的时候,再加载进来,叫作换入。这样可以扩大可用物理内存的大小,提高物理内存的利用率。

这个换入和换出都是以页为单位的。页面的大小一般为 4KB。为了能够定位和访问每个页,需要有个页表,保存每个页的起始地址,再加上在页内的偏移量,组成线性地址,就能对于内存中的每个位置进行访问了。

Responsive Image

虚拟地址分为两部分,页号页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址。这个基地址与页内偏移的组合就形成了物理内存地址。

32 位环境下,虚拟地址空间共 4GB。如果分成 4KB 一个页,那就是 1M 个页。每个页表项需要 4 个字节来存储,那么整个 4GB 空间的映射就需要 4MB 的内存来存储映射表。如果每个进程都有自己的映射表,100 个进程就需要 400MB 的内存。对于内核来讲,有点大了。

页表中所有页表项必须提前建好,并且要求是连续的。如果不连续,就没有办法通过虚拟地址里面的页号找到对应的页表项了。

那怎么办呢?我们可以试着将页表再分页,4G 的空间需要 4M 的页表来存储映射。我们把这 4M 分成 1K(1024)个 4K,每个 4K 又能放在一页里面,这样 1K 个 4K 就是 1K 个页,这 1K 个页也需要一个表进行管理,我们称为页目录表,这个页目录表里面有 1K 项,每项 4 个字节,页目录表大小也是 4K。

页目录有 1K 项,用 10 位就可以表示访问页目录的哪一项。这一项其实对应的是一整页的页表项,也即 4K 的页表项。每个页表项也是 4 个字节,因而一整页的页表项是 1K 个。再用 10 位就可以表示访问页表项的哪一项,页表项中的一项对应的就是一个页,是存放数据的页,这个页的大小是 4K,用 12 位可以定位这个页内的任何一个位置。

这样加起来正好 32 位,也就是用前 10 位定位到页目录表中的一项。将这一项对应的页表取出来共 1k 项,再用中间 10 位定位到页表中的一项,将这一项对应的存放数据的页取出来,再用最后 12 位定位到页中的具体位置访问数据。

Responsive Image

你可能会问,如果这样的话,映射 4GB 地址空间就需要 4MB+4KB 的内存,这样不是更大了吗?当然如果页是满的,当时是更大了,但是,我们往往不会为一个进程分配那么多内存。

比如说,上面图中,我们假设只给这个进程分配了一个数据页。如果只使用页表,也需要完整的 1M 个页表项共 4M 的内存,但是如果使用了页目录,页目录需要 1K 个全部分配,占用内存 4K,但是里面只有一项使用了。到了页表项,只需要分配能够管理那个数据页的页表项页就可以了,也就是说,最多 4K,这样内存就节省多了。

当然对于 64 位的系统,两级肯定不够了,就变成了四级目录,分别是全局页目录项 PGD(Page Global Directory)、上层页目录项 PUD(Page Upper Directory)、中间页目录项 PMD(Page Middle Directory)和页表项 PTE(Page Table Entry)。

进程空间管理

物理内存管理

用户态内存映射

#define GDT_ENTRY_INIT(flags, base, limit) { { { \
  .a = ((limit) & 0xffff) | (((base) & 0xffff) << 16), \
  .b = (((base) & 0xff0000) >> 16) | (((flags) & 0xf0ff) << 8) | \
    ((limit) & 0xf0000) | ((base) & 0xff000000), \
  } } }

通过分析,我们发现,所有的段的起始地址都是一样的,都是 0。这算哪门子分段嘛!所以,在 Linux 操作系统中,并没有使用到全部的分段功能。那分段是不是完全没有用处呢?分段可以做权限审核,例如用户态 DPL 是 3,内核态 DPL 是 0。当用户态试图访问内核态的时候,会因为权限不足而报错。

其实 Linux 倾向于另外一种从虚拟地址到物理地址的转换方式,称为分页(Paging)。

对于物理内存,操作系统把它分成一块一块大小相同的页,这样更方便管理,例如有的内存页面长时间不用了,可以暂时写到硬盘上,称为换出。一旦需要的时候,再加载进来,叫作换入。这样可以扩大可用物理内存的大小,提高物理内存的利用率。