目录

Linux内存

内存概论

虚拟内存

虚拟内存(virtual memory)是 Unix 系统中一种对内存的抽象。虚拟内存作为一种逻辑层,处于应用程序的内存请求与硬件内存管理单元(Memory Management Unit, MMU)之间。虚拟内存有很多用途和优点:

  • 若干个进程可能并发地执行。
  • 应用程序所需内存大于可用物理内存时也可以运行。
  • 程序只有部分代码装入内存时进程可以执行它。
  • 允许每个进程访问可用物理内存的子集。
  • 进程可以共享库函数或程序的一个单独内存映象。
  • 程序时可重定位的,也就是说,可以把程序放在物理内存的任何地方。
  • 程序员可以编写与机器无关的代码,因为他们不必关心有关物理内存的组织结构。

虚拟内存子系统的主要成分是虚拟地址空间(virtual address space)的概念。进程所用的一组内存地址不同于物理内存地址。当进程使用一个虚拟地址时,内核和MMU协同定位其在内存中的实际物理地址位置。

现在的 CPU 包含了能自动把虚拟地址转换成物理地址的硬件电路。为了达到这个目标,把可用 RAM 划分成长度为 4KB 或 8KB 的页框(page frame),并引入一组页表来指定虚拟地址与物理地址之间的对应关系。

一块连续的虚拟地址请求可以通过分配一组非连续的物理地址页框而得到满足。

随机访问存储器(RAM)的使用

Unix 操作系统将 RAM 划分成两部分。其中若干兆字节专门用于存放内核映象(也就是内核代码和内核静态数据结构)。RAM的其余部分通常由虚拟内存系统来处理,并且用在以下三种可能的方面:

  • 满足内核对缓冲区,描述符及其他动态内存数据结构的请求。
  • 满足进程对一般内存区的请求及对文件内存映射的请求。
  • 借助与高速缓存从磁盘及其他缓冲设备获得较好的性能。

内核内存分配器

内核内存分配器(Kernel Memory Allocator, KMA)是一个子系统,它试图满足系统中部分对内存的请求。其中一些请求来自内核其他子系统,它们需要一些内核使用的内存,还有一些请求来自于用户程序的系统调用,用来增加用户进程中地址空间。一个好的 KMA 应该具有下列特点:

  • 必须快。因为它由所有的内核子系统(包括中断处理程序)调用。
  • 必须把内存的浪费减到最少。
  • 必须努力减轻内存的碎片(fragmentation)问题。
  • 必须能与其他内存管理子系统合作,以便借用和释放页框。

基于不同的算法技术实现的KMA:

  • 资源图分配算法(allocator)
  • 2的幂次方空闲链表
  • McKusick-Karels 分配算法
  • 伙伴 (Buddy) 系统
  • Mach 的区域 (Zone) 分配算法
  • Dynix 分配算法
  • Solaris 的 Slab 分配算法

进程虚拟地址空间处理

进程的虚拟地址空间包括了进程可以引用的所有虚拟内存地址。内核通常用一组内存区描述符描述进程虚拟地址空间。例如,当进程通过 exec() 类系统调用开始某个程序执行时,内核分配给进程的虚拟地址空间有以下内存区组成。

  • 程序的可执行代码。
  • 程序的初始化数据。
  • 程序的未初始化数据。
  • 初始程序栈(即用户态栈)。
  • 所需共享库的可执行代码和数据。
  • 堆(由程序董太太请求的内存)。

现代 Unix 操作系统采用了请求调页(demand paging)的内存分配策略。有了请求调页,进程可以在它的页还没有在内存时就开始执行。当进程访问一个不存在的页时,MMU产生一个异常,异常处理程序找到受影响的内存区,分配一个空闲的页,并用适当的数据把啊初始化。同理,当进程通过调用 malloc()brk()(由malloc()在内部调用)系统调用动态地请求内存是,内核仅仅修改进程的堆内存区的大小。只有试图引进进程的虚拟内存而产生异常时,才给进程分配页框。

虚拟地址空间也采用其他更有效的策略,如写时复制策略。例如,当一个新进程被创建时,内核仅仅把父进程的页框赋给子进程的地址空间,但是把这些页框标记为只读。一旦父或子进程修改页中的内容时,一个异常就会产生。异常处理程序把新页框赋给受影响的进程,并用原来也中的内容初始化新页框。

高速缓存

物理内存的一大优势就是用作磁盘和其他块设备的高速缓存。通常,在最早的 Unix 系统中就已经实现的一个策略是: 尽可能地推迟写磁盘的时间,因此,从磁盘读入内存的数据即使任何进程都不再使用它们,它们也继续留在 RAM 中。

新进程请求从磁盘读或写的数据,就是被撤销进程曾拥有的数据。当一个进程请求访问磁盘时,内核首先检查进程请求的数据是否在缓存中,如果在(把这种请求叫做缓存命中),内核就可以为进程请求提供服务而不用访问磁盘。

sync() 系统调用把所有"脏"的缓冲区写入磁盘来强制磁盘同步。为了避免数据丢失,所有的操作系统都会注意周期性地把脏缓存区写回磁盘。

内存地址

x86微处理器的三种不同地址:

  • 逻辑地址(logical address): 每一个逻辑地址都由一个段(segment)和偏移量(offset或displacement)组成,偏移量指明了从段开始的地方到实际地址之间的距离。
  • 线性地址(linear address)(也称虚拟地址 virtual address): 线性地址通常用十六进制数字表示,值得范围从 0x00000000 到 0xffffffff。可以表示高达 4GB 的地址。
  • 物理地址(physical address): 用于芯片级内存单元寻址。他们从微处理器的地址引脚发送到内存总线上的电信号相对应。

内存控制单元(MMU)通过分段单元(segmentation unit)的硬件电路把一个逻辑地址转换成线性地址。通过分页单元(paging unit)的硬件电路把线性地址转化成物理地址。

在多处理器系统中,所有 CPU 都共享同一内存。RAM芯片上的读和写操作必须串行执行,因此内存仲裁器(memory arbiter)的硬件电路插在总线和每个 RAM 芯片之间。其作用就是如果某个 RAM 芯片空闲,就准予一个 CPU 访问,如果该芯片忙于为另一个处理器提出的请求服务,就延迟这个 CPU 的访问。

硬件中的分段

80206 开始,Intel 微处理器具有两种方式执行地址转换:

  • 实模式(real mode)。
  • 保护模式(protected mode)。

实模式存在是处于兼容性考虑,主要还是保护模式。

段选择符和段寄存器

一个逻辑地址由两部分组成:一个段标识符和一个指定段内相对地址的偏移量。段标识符是一个 16 位长的字段,称为段选择符(Segment Selector)。偏移量是一个 32 位长的字段。

https://raw.githubusercontent.com/xingyys/myblog/main/posts/images/20211012090918.png

专门存放段选择符的寄存器: cs, ss, ds, es, fs 和 gs。其中3个有专门的用途:

  • cs 代码段寄存器,指向包含程序指令的段。
  • ss 栈段寄存器,指向包含当前程序栈的段。
  • ds 数据段寄存器,指向包含静态数据或者全局数据段。

其他3个段寄存器作一般用途,可以指向任意的数据段。

cs 寄存器还有一个很重要的功能: 它含有一个两位的字段,用以指明 CPU 的当前特权级 (Current Privilege Level, CPL)。值为0代表最高优先级,为值为3代表最低优先级。Linux 只用0级和3级,分别称之为内核态和用户态。

段描述符

每个段由一个8字节的段描述符(Segment Descriptor)表示,它描述了段的特征。段描述符放在全局描述符表(Global Descriptor Table, GDT)或局部描述符表(Local Descriptor Table, LDT)中。

GDT 通常只有一个,在主存中的地址和大小存放在 gdtr 控制寄存器中,当前正被使用的 LDT 地址和大小放在 ldtr 控制寄存器中。

段描述符字段

字段 含义
Base 包含段的首字节的线性地址
G 粒度标志:如果改位清0,则大小以字节为单位,否则以4096字节的倍数计。
Limit 存放段中最后一个内存单元的偏移量,从而决定段的长度。如果 G 被置为0,则一个段的大小在1个字节到1MB之间变化;否则,将在 4KB 到 4GB 之间变化。
S 系统标志:如果它被清0,则这是一个系统段,存储诸如 LDT 这种关键的数据结构,否则它是一个普通的代码段或数据段。
Type 描述了段的类型特征和它的存取权限。
DPL 描述符特权级(Descriptor Privilege Level)字段:用于限制对这个段的存取。它表示为访问这个段而要求的CPU最小的优先级。因此,DPL设为0的段只能当 CPL 为0时(即在内核态)才是可访问的,而DPL设为3的段对任何CPL值都是可访问的
P Segment-Present标志:等于0表示段当前不在主存中。Linux总是把这个表示(第47位)设为1,因为它从来不把整个daunt交换到磁盘上去。
D 或 B 称为D或B的标志,取决于代码段还是数据段。D或B的含义在两种情况中有所区别,但是如果段偏移量的地址是32位长,就基本上把它置为1,如果这个偏移量是16位长,它被清0。
AVL 标志 可以由操作系统使用,但是被 Linux 忽略。

Linux 中被广泛采用的类型:

  • 代码段描述符
  • 数据段描述符
  • 任务状态段描述符

https://raw.githubusercontent.com/xingyys/myblog/main/posts/images/20211013084126.png

段选择符字段:

字段 含义
index 指定了放在 GDT 或 LDT 中的相应段描述符的入口。
TI TI ((Table Indicator)标志:指明段描述符是在 GDT 中 (TI=0) 或在 LDT 中(TI=1))。
RPL 请求者特权级:当相应的段选择符装入到 cs 寄存器中时,指示出 CPU 当前的特权级;它还可以用于在访问数据段时有选择地削弱处理器的特权级。

为了加速逻辑地址到线性地址的转换,80x86处理器提供一种附加的非编程的寄存器,供6个可编程的段寄存器使用。现在,针对那个段的逻辑地址转化就可以不访问主存中的 GDT 或 LDT,处理器只需直接引用存放段描述符的CPU寄存器即可。

分段单元

分段单元(segmentation unit)执行以下操作,将一个逻辑地址转换成相应的线性地址。

  • 先检查段选择符的TI字段,以决定段描述符保存在哪一个描述表中。TI字段指明描述符是在GDT中(在这种情况下,分段单元从gdtr寄存器中得到GDT的线性基地址)还是在激活的LDT中(在这种情况下,分段单元从ldtr寄存器中得到LDT的线性基地址)。
  • 从段选择符的 index 字段计算段描述符的地址,index字段的值乘以8(一个段描述符的大小),这个结果与gdtr或ldtr寄存器中的内存相加。
  • 把逻辑地址的偏移量与段描述符 Base 字段的值相加就得到了线性地址。

Linux 中的分段。

Linux 中使用分段的方式十分有限。分段可以给每个进程分配不同的线性地址空间,而分页可以把同一线性地址空间映射到不同的物理空间。与分段相比,Linux 中倾向采用分页方式,因为:

  • 当所有的进程使用相同的段寄存器值时,内存管理变得更简单,也就是说它们能共享同样的一组线性地址。
  • Linux 设计目标之一是可以把它移植到绝大多数流行的处理器平台上。然而,RISC体系结构对分段的支持很有限。

2.6 版的 Linux 只有在 80x86 结构下才需要使用分段。

https://raw.githubusercontent.com/xingyys/myblog/main/posts/images/20211014090014.png

相应的段选择符由宏 __USER_CS, __USER_DS, __KERNEL_CS 和 __KERNEL_DS 分别定义。例如,为了对内存代码段寻址,内核只需要把 __KERNEL_CS 宏产生的值装进 cs 段寄存器即可。

所有段都从 0x00000000 开始,这可以得出另一个重要的结论,就是在 Linux 下逻辑地址与线性地址是一致的,机逻辑地址的偏移量字段的值与相应的线性地址的值总是一致的。

Linux GDT

在单处理器系统中只有一个 GDT,而在多处理器系统中每个 CPU 对应一个 GDT。所有的 GDT 都存放在 cpu_gdt_table 数组中。以下是 GDT 的布局示意图。每个 GDT 包含 18 个段描述符合 14 个空的,未使用的,或保留的项。

https://raw.githubusercontent.com/xingyys/myblog/main/posts/images/20211014091048.png

每一个 GDT 中包含 18 个段描述符指向下列的段:

  • 用户态和内核态下的代码段和数据段共 4 个。
  • 任务状态段 (TSS),每个处理器有 1 个。
  • 1 个包括缺省局部描述符表的段。
  • 3 个局部线程存储。
  • 与高级电源管理 (AMP) 相关的 3 个段。
  • 与支持即插即用 (PnP) 功能的 BIOS 服务程序相关的 5 个段。
  • 被内核用来处理“双重错误”(处理一个异常是可能引发另一个异常,在这个情况下产生的双重错误)异常的特殊 TSS 段。

Linux LDT

大多数用户态下的 Linux 程序不使用局部描述符表,这样内核就定义了一个缺省的 LDT 供大多数进程共享。缺省的局部描述符表存放在 default_ldt 数组中。

在某些情况下,进程仍然需要创建自己的局部描述符表,这对有些应用程序很有用,如 Wine 程序。

硬件中的分页

分页单元(paging unit)把线性地址转换成物理地址。线性地址被分成以固定长度为单位的组,称为页(page)。页内部连续的线性地址被映射到连续的物理地址中。

分页单元把所有的 RAM 分成固定长度的页框 (page frame) (有时叫做物理页)。每一个页框包含一个页 (page),页就是说一个页框的长度与一个页的长度一致。

把线性地址映射到物理地址的数据结构称为页表 (page table)。页表放在主存中,并在启用分页单元之前必须由内核对页表进行适当的初始化。

从 80386 开始,所有的 80x86 处理器都支持分页,它通过设置 cr0 寄存器的 PG 标志启用。 当 PG=0 时,线性地址就被解释成物理地址。

常规分页

从 80386 起,Intel 处理器的分页单元处理 4K 的页。 32 位的线性地址被分成 3 个域:

  • Directory (目录) 最高 10 位。
  • Table (页表) 中间 10 位。
  • Offset (偏移量) 最低 12 位。

线性地址的转换分两步完成,每一步都基于一种转化表,第一种转换表称为页目录表 (page directory),第二种转换表称为页表 (page table)。

正在使用的页目录的物理地址存放在控制寄存器 cr0 中。线性地址内的 Directory 字段决定目录中的目录项,而目录项指向适当的页表。地址的 Table 字段依次又决定页表中的表项,而表项含有页所在页框的物理地址。Offset 字段决定页框也的相对位置。由于它是 12 长,故每一页含有 4096 字节的数据。

https://raw.githubusercontent.com/xingyys/myblog/main/posts/images/20211015090255.png

假设进程需要读线性地址 0x20021406 中的字节。这个地址由分页单元按下面的方法处理:

  • Directory 字段的 0x80 用于选择页目录的第 0x80 目录项,此目录项指向和该进程的页相关的页表。
  • Table 字段 0x21 用于选择页表的第 0x21 表项,此表项指向包含所需页的页框。
  • 最后,Offset 字段 0x406 用于在目标页框中读偏移量为 0x406 中的字节。

硬件高速缓存

当今的微处理器时钟频率接近几个 GHz,而动态 RAM 芯片的存取时间是时钟周期的数百倍。这意味着,当从 RAM 中取操作数或向 RAM 中存放结果这样的指令执行时,CPU 可能登台很长时间。

为了缩小 CPU 和 RAM 之间的速度不匹配,引入了硬件高速缓存内存(hardware cache memory)。硬件高速缓存基于著名的局部性原理(locality principle),该原理既适用程序结构也适用数据结构。

80x86 体系结构中引入了一个叫行 (line) 的新单位。行由几十个连续的字节组成,它们以脉冲突发模式 (burst mode) 在慢速 DRAM 和快速的用来实现高速缓存的片上静态 RAM (SRAM) 之间传送,用来实现高速缓存。

如图,高速缓存单元插在分页单元和主内存之间。它包含一个硬件高速缓存内存 (hardware cache memory) 和一个高速缓存控制器 (cache controller)。高速缓存内存存放内存中真正的行。高速缓存控制器存放一个表项数组,每个表项对应高速缓存内存中的一个行。每个表项有一个标签(tag)和描述高速缓存行状态的几个标志(flag)。这种内存物理地址通常分为3组:最高几位对应标签,中间几位对应高速缓存控制器的子集索引,最低几位对应行内的偏移量。

https://raw.githubusercontent.com/xingyys/myblog/main/posts/images/20211018093652.png

当访问一个 RAM 存储单元时,CPU 从物理地址中提取出子集的索引号并把子集中所有行的标签与物理地址的高几位相比较。如果发现某一个行的标签与这个物理地址的高位相同,则 CPU 命中一个高速缓存 (cache hit);否则,高速缓存没有命中 (cache miss)。

对于读操作,控制器从高速缓存行中选择数据并送到 CPU 寄存器。对于写操作,控制器可能采用以下两个基本策略之一:

  • 通写(write-through): 控制器总是既写 RAM 也写高速缓存行,为了提高写操作的效率关闭高速缓存。
  • 回写(write-back): 只更新高速缓存行,不改变 RAM 的内存,提供了更快的效率。

处理器的 cr0 寄存器的 CD 标志位用来启用或禁用高速缓存电路。这个寄存器中的 NW 标志指明高速缓存是使用通写还是回写策略。

Linux 中的分页

Linux 采用了一种同时适用于32位和64位的普通分页模型。在 2.6.10 版本之前,Linux 采用三级分页的模型。从 2.6.11 版本开始,采用了四级分页模型:

  • 页全局目录 (Page Global Directory)
  • 页上级目录 (Page Upper Directory)
  • 页中间目录 (Page Middle Directory)
  • 页表 (Page Table)

页全局目录包含若干页上级目录的地址,页上级目录有一次包含若干页中间目录的地址,而页中间目录又包含若干页表的地址。每一个页表项指向一个页框。线性地址因此被分成五个部分。

https://raw.githubusercontent.com/xingyys/myblog/main/posts/images/20211020091014.png

Linux 的进程处理很大程度上依赖于分页。事实上,线性地址到物理地址的自动转换使下面的设计目标变得可行:

  • 给每一个进程分配一块不同的物理地址空间,这确保了可以有效地防止寻址错误。
  • 区别页(即一组数据)和页框(即主存中的物理地址)的不同。这就允许存放在某个页框中的一个页,然后保存到磁盘上,以后重新装入这同一页是又可以被装载不同的页框中。这就是虚拟内存机制的基本要素。

每一个进程有它自己的页全局目录和自己的页表集。当发生进程切换时,Linux 把 cr3 控制寄存器的内存保存在前一个执行进程的描述符中,然后把下一个要执行进程的描述符的值装入 cr3 寄存器中。

线性地址字段

页表处理

物理内存布局

在初始化阶段,内核必须建立一个物理地址映射来指定哪些物理地址范围对内核可用而哪些不可用。

内核将下列页框记为保留:

  • 在不可用的物理地址范围内的页框。
  • 含有内核代码和已初始化的数据结构的页框。

从 0x07ff0000 到 0x07ff2fff 的物理地址范围中存有加电自检(POST)阶段由BIOS写入的系统硬件设备信息。在初始化阶段,内核把这些信息拷贝到一个合适的内核数据结构中,然后认为这些页框是可用的。相反,从 0x07ff3000 到 0x07ffffff 的物理地址范围被映射到硬件设备的 ROM 芯片。从 0xffff0000 开始的物理地址范围标记为保留,因为它由硬件地址映射到 BIOS 的 ROM 芯片。

Linux 用 PC 体系结构未保留的页框来动态存放所分配的页。

进程页表

进程的线性地址空间分成两部分:

  • 从 0x00000000 到 0xbfffffff 的线性地址,无论进程运行在用户态还是内核态都可以寻址。
  • 从 0xc0000000 到 0xffffffff 的线性地址,只有内核态的进程才能寻址。

当进程运行在用户态时,它产生的线性地址小于 0xc0000000;当进程运行在内核态时,它执行内核代码,所产生的地址大于等于 0xc0000000。

内核页表

内核维持着一组自己使用的页表,驻留在主内核页全局目录(master kernel Page Global Directory)中。

内核如何初始化自己的页表:

  1. 内核创建一个有限的地址空间,包括内核的代码段和数据段、初始页表和用于存放动态数据结构的128KB大小的空间。这个最小限度的地址空间仅够将内核装入RAM和对其初始化的数据结构。
  2. 内核充分利用剩余的RAM并适当地建立分页表。

建立分页表的详细步骤: 临时页全局目录放在 swapper_pg_dir 变量中,临时页表在 pg0 变量出开始存放,紧接在内核未初始化的数据段后面。内核在初始化的第一阶段,可以通过与物理地址相同的线性地址或者通过从 0xc0000000 开始的 8MB 线性地址对 RAM 的前 8MB 进行寻址。

内核通过把 swapper_pg_dir 所有项都填充为0来创建期望的映射,0、1、0x300(768)和0x301(769)除外,它们的初始化如下:

  • 0 项和 0x300 项的地址字段置为 pg0 的物理地址,而 1 项和 0x301 项的地址字段置为紧随 pg0 后的页框的物理地址。
  • 把这四个项中的 Present、Read/Write 和 User/Supervisor 标志置零。
  • 把这四个项中的 Accessed、Dirty、PCD、PWD 和 Page Size 标志请0。

固定映射的线性地址

我们看到内核线性地址第四个GB的初始化部分映射系统的物理内存。但是,至少128MB的线性地址总是留作它用,因为内核使用这些线性地址实现非连续内存分配和固定映射的线性地址。

固定映射的线性地址(fix-mapped linear address)基本上是一种类似于 0xffffc000 这样的常量线性地址,其对应的物理地址不必等于线性地址减去 0xc000000。内核使用固定映射的线性地址来代替指针变量,因为这些指针变量的值从不改变。