深入理解Linux内核——内存管理(1)

yanlishao / 2023-08-08 / 原文

提要:系列文章主要参考MIT 6.828课程以及两本书籍《深入理解Linux内核》``《深入Linux内核架构》对Linux内核内容进行总结。

内存管理的实现覆盖了多个领域:

  1. 内存中的物理内存页的管理
  2. 分配戴爱内存的伙伴系统
  3. 分配较小内存的slab、slub、slob分配器
  4. 分配非连续内存块的vmalloc分配器
  5. 进程的地址空间

传统的内存管理主要包括段式存储页式存储段页式存储,这里我们会以这部分开始,逐步介绍Linux内核中的内存管理,而要学习内存管理,首先需要了解内存寻址。所以本节内容主要讲解内存寻址的相关知识,并介绍Linux内核中的段、页式存储。

内存地址

在编程过程中,难免需要通过内存地址来访问内存中的某些内容,那么这个过程中地址是如何映射到对应的物理单元的呢?解决这一问题首先要区分三种不同的地址:

  1. 逻辑地址:逻辑地址是包含在机器语言指令中用来指定一个操作数或者一条指令的地址。每一个逻辑地址都由一个段和一个偏移量组成,偏移量指明了从段开始的地方到实际地址之间的距离。(这在分段结构中表现的极为明显)。
  2. 线性地址:线性地址是一个32位的无符号数,可以用来表示高达4GB的地址。线性地址通常使用十六进制数字表示,值的范围从0x00000000到0xffffffff(常用于页式存储)。
  3. 物理地址:用于内存芯片级内存单元寻址。它们与微处理器的地址引脚发送到内存总线上的电信号相对应。物理地址由32位或36位无符号整数表示。

内存控制单元(MMU)通过分段单元(硬件设备)将逻辑地址转化为线性地址。使用分页单元(硬件设备)把线性地址转化为物理地址:

               --------                     -------
|逻辑地址| --> |分段单元| --> |线性地址| --> |分页单元| --> |物理地址|
               --------                     -------

从格式上简单区别逻辑地址与线性地址,逻辑地址包含了两部分:段和偏移量(注意这两者是分开的),而线性地址知识一个32位无符号数,虽然后期也会根据地址的位数再次进行划分(详见分页部分),但终归只是一个线性的无符号数。

段式存储

硬件将逻辑地址转化为线性地址主要由分段单元完成该任务。逻辑地址由两部分组成:

  1. 段标识符:该字段是一个16位长的字段,称为段选择符负责从众多段中选择出正确的段,因为段信息会存储在一张段描述符表中,因此需要通过段选择符从段描述符表中索引找到正确的段描述符
  2. 指定段内相对地址的偏移量:32位长的字段(因为一个段可能很长,因此偏移量要足够大)

段选择符格式如下:

        15        3     2 1 0
          --------  ----  ---
段选择符 |  index  |  TL | RPL|
          --------  ----  ---

对于每个字段的含义,后续在详细讲解通过段选择符寻找对应段时会对每个字段给出解释。

段选择符被存放在段寄存器中,这使得可以方便快速地找到段选择符,段寄存器主要包括6个,分别为cs,ss,ds,es,fs,gs。其中有3个具有专门的用途:

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

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

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

段描述符

刚才提到过,每个段由一个8字节的段描述符表示,它描述了段的特征。段描述符放在全局描述符表(GDT)或者局部描述符表里(LDT)中(前面提到过)。通常只定义一个GDT,而每个进程除了存放在GDT中的段之外如果还需要创建附加段,就会创建自己的LDT。GDT在主存中的地址和大小存放在gdtr控制寄存器中,当前正被使用的LDT地址和大小存放在ldtr寄存器中。如下给出一个全局段描述符例子:

在段描述符表中通常会使用如下几种段描述符(简单了解各个段的作用即下面第一个表就好,具体字段的名称可以用到再回来查):

描述符名称 描述
代码段描述符 这个段描述符代表一个代码段,它可以放在GDT或LDT中。该描述符置S标志位1(非系统段)
数据段描述符 这个段描述符代表一个数据段,它可以放在GDT或LDT中。该描述符置S标志为1。栈段是通过一般的数据段实现的。
任务状态段描述符(TSSD) 这个段描述符代表一个任务状态段(Task State Segment, TSS),也就是说这个段用于保存处理器寄存器的内容。它只能出现在GDT中。根据相应的进程是否正在CPU上运行,其Type字段的值分别为11或9。这个描述符的S标志置为0。
局部描述符表描述符(LDTD) 这个段描述符代表一个包含LDT的段,它只出现在GDT中。相应的Type字段的值为2,s标志置为0

段描述符格式如下:

段描述符中各个字段含义如下:

字段表 描述
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,因为它从来不把整个段交换到磁盘上去。
D或B 成为D或B的标志,取决于是代码段还是数据段。D或B的含义在两种情况下稍微有区别,但是如果段偏移量的地址是32位长,就基本上把它置为1,如果这个偏移量是16位长,它被清0。
AVL标志 可以由操作系统使用,但是被Linux忽略

快速访问段描述符

在本节主要介绍分段单元将逻辑地址转化为线性地址的过程。我们知道逻辑地址主要包括:16位的段选择符和32位的段偏移量,段选择符存放在段寄存器中。段选择符格式如下:

        15        3     2 1 0
          --------  ----  ---
段选择符 |  index  |  TL | RPL|
          --------  ----  ---

这里我们需要了解3个字段的含义:

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

由于一个段描述符是8个字节长,因此它在GDT或LDT内的相对地址是由段选择符的最高13位的值乘以8得到的(8=2^3,13+3=16)。例如如果GDT在0x00020000(这个值保存在gdtr寄存器中),切由段选择符所指定的索引号为2,那么相应的段描述符地址为0x00020000+(2*8)0x00020010

逻辑地址转换为线性地址流程如下图:

  1. 先检查段选择符的TI字段,以确定段描述符保存在哪一个描述符表(GDT、LDT)中。
  2. 从段选择符的index字段计算段描述符的地址,index字段值乘以8(一个段描述符大小),这个结果与gdtr或ldtr寄存器中的内容相加
  3. 把逻辑地址的偏移量与段描述符的Base字段的值相加就得到了线性地址。

操作系统课本中的介绍通常如下,可以与之进行对比:

注意:GDT的第一项总是设置为0。这就确保空段选择符的逻辑地址会被认为是无效的,因此引起一个处理器异常。能够保存在GDT中的段描述符的最大数目是8191,即2^13-1。

最后,由于整个地址转换过程中,前两个过程,即访问GDT、LDT中段描述符的过程是比较耗时的,为了加速该过程,80x86处理器提供了一种附加的非编程的寄存器供6个可编程的段寄存器使用。每个非编程寄存器含有8个字节的段描述符。通过这个非编程寄存器,达到了如下功能:每当一个段描述符被装入段寄存器时,相应的段描述符就由内存装入到对应的非编程CPU寄存器中,从那时起,针对那个段的逻辑地址转换就可以不访问主存中的GDT或LDT,只有在段寄存器内容改变时,才有必要访问GDT或LDT。

Linux中的分段

Linux以非常有限的方式使用分段,分段可以给一个进程分配不同的线性地址空间,而分页可以把同一线性地址空间映射到不同的物理空间,与分段相比,Linux更喜欢使用分页方式,因为:

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

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

运行在用户态的所有Linux进程都使用一对相同的段来对指令和数据寻址。这两个段就是所谓的用户代码段用户数据段。类似的运行在内核态的所有Linux进程都使用一对相同的段对指令和数据寻址,他们分别是内核代码段内核数据段。下表显示了这四个重要段的段描述符字段的值。

Base G Limit S Type DPL D/B P
用户代码段 0x00000000 1 0xfffff 1 10 3 1 1
用户数据段 0x00000000 1 0xfffff 1 2 3 1 1
内核代码段 0x00000000 1 0xfffff 1 10 0 1 1
内核数据段 0x00000000 1 0xfffff 1 2 0 1 1

上面4个段,G标志都设置为1,即limit以4096为单位。与段相关的线性地址从0开始,达到2^32-1的寻址限长,这意味着在用户态或内核态下的所有进程可以使用相同的逻辑地址。

所有段都从0x00000000开始,表示Linux下逻辑地址和线性地址是一致的,即逻辑地址的偏移量字段的值与相应的线性地址的值总是一致的。

那如何区分这4个段呢?

如前所述,CPU的当前特权级(CPL)反映了进程是在用户态还是内核态,并由存放在cs寄存器中的段选择符的RPL字段指定。只要当前特权级被改变,一些段寄存器必须相应地更新。例如,当CPL=3时(用户态),ds寄存器必须含有用户数据段的段选择符,而当CPL=0时,ds寄存器必须含有内核数据段的段选择符。

页式存储