《深入理解linux内核》-2-内存寻址

学习自书籍:

  • 《深入理解linux内核》第二章

这一章详细介绍了分段、分页机制对应于地址转换的过程(逻辑地址=>线性地址=>物理地址)

此书的编写方式是首先详细的介绍普遍操作系统的概念,然后在针对当前的linux操作系统叙述其对于该方面的实现

内存寻址

1. 区分不同地址名词(逻辑地址/线性地址/物理地址)

当使用80x86微处理器时,需要区分如下三个地址的概念:

  • 逻辑地址logic address: 在机器语言指令中用来指定一个操作数或者一条指令的地址,在80x86的分段式寻址方式中很常见,每个逻辑地址都是由一个段选择符和段偏移量组成

    奇怪的是,有些地方也将虚拟地址称为逻辑地址,包括这本书的第一章(在Intel 80x86架构下)

  • 线性地址linear address/ 虚拟地址virtual address: 一个32位无符号整数,通常用16进制表示,从0x00000000 ~ 0xffffffff,如果采用了分页,那么这个地址的作用就是一个中间地址的状态,线性地址空间就是线性的、连续的

    经过分段的地址转换之后,每一个段都是一个线性地址空间

  • 物理地址physical address: 用于内存芯片级别的单元地址,由32位或36位无符号整数表示

    线性地址空间经过分页的转换最终映射到物理地址

首先看一下段页式,其核心特点就是为每个分段维护一个页表从而减小进程的页表

image-20220704213929230

具体可以对应到上面的过程:

  • 逻辑地址: 可以初步理解为转换前最开始的地址,包含段选择符,其后是偏移量
  • 线性地址:查询到某个分段后,转换为段基地址(在MMU段基址寄存器中拿)+偏移量此时就是线性地址
  • 物理地址:再经过页面将VPN转为PFN+偏移量就是最终的物理地址

2. 什么是内存仲裁器?作用是什么?

插在总线和每个RAM芯片之间,作用是多CPU处理器同时访问RAM时(RAM是被多个处理器共享的,意味着所有在RAM上的操作必须是串行的执行),控制并发操作的安全性

具体的过程例如:

  • 如果此时RAM芯片空闲,那么就允许一个CPU访问
  • 如果此时RAM芯片忙碌(处理另一个CPU的操作),那么此请求的CPU就推迟访问

仲裁谁有权利访问

注意:对于单处理器来说,内存仲裁器还是存在的,因为DMA的存在!DMA是用于协调内存和设备之间数据传输的独立于cpu的一个特殊设备,所以可以简单的将其看作是一个微CPU,所以还是会有竞争

DMA的工作任务可以基本参考下图(都是《操作系统导论》里的脑图笔记):

image-20220704220231535

可以看到,其核心作用就是在数据从内存拷贝到磁盘(这里只是举例)时将CPU解放出来给其他进程用,而不是操作系统,因为这样的操作已经由CPU转移到了DMA

3. 地址转换的两种模式

实模式

实模式的存在主要原因在于维持处理器与早期模型的兼容,并让操作系统自举

操作系统自举的意思就是操作系统能够靠“自己”加载启动起来,然后再由操作系统执行那些依靠自举代码无法实现的更加复杂的功能(人必须先站起来再行走…)。自举只有两个功能:加电自检和磁盘引导,具体可见:https://blog.csdn.net/gioc/article/details/88639745

保护模式

一个逻辑地址 = 段选择符 + 该指定段内相对地址的偏移量

下面的内容描述都是在保护模式下的描述

4. 段选择符和段寄存器细节

段选择符

段选择符的具体结构如下:

image-20220709210208948

具体字段释义:

image-20220709215216220

具体GDT和LDT会在下面的介绍中看到

需要注意的是:请求者的特权级别的确定时机是在对应的段选择赋装入到cs寄存器中时cpu当前的特权级

段寄存器

段寄存器的唯一目的就是存放段选择符(16位),这些寄存器称为:cs、ss、ds、es、fs、gs

其中3个寄存器有专门的用途:

  • cs: 代码段寄存器,指向包含程序指令的段

    cs段还有特殊的功能,其有一个两位的字段RPL表示当前CPU的特权级别CPL(CPU的特权级别也就是对应了当前进程是在用户态还是在内核态),0表示最高,3表示最低优先级

    linux中只用0级和3级,并且分别将其称之为内核态和用户态

  • ss: 栈段寄存器,指向包含当前程序栈的段

  • ds: 数据段寄存器,指向包含静态数据或者全局数据段

另外三个寄存器就是用作一般的用途,指向任意的数据段

5. 段描述符

注意:段描述符与段选择符不是一个东西

段描述符用于描述段的特征,放在全局描述符表GDT中或者在局部描述符表LDT中

  • 全局描述符表:是全局的一个表,用于存放每个进程的段描述符关系
  • 局部描述符表:如果进程有自己定义的段/附加的段,那么就可以有自己的局部描述表来描述

段描述符的详细字段如下:

image-20220709212156210

比较有意思的是DPL描述符的特权级别,用于限制这个段的读写

如果当前DPL设置为0,那么只有CPU的特权级别CPL设置为0(即在内核态)才可以去访问此段,当DPL为3的时候,任何CPL的值都是可以访问的

对于Type字段指定了不同的段描述符类型,例如:

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

6. 第一步:从逻辑地址到线性地址的转化 ***

对于上面的几个概念,就构成了一个总的地址转换思路,解决的问题是:逻辑地址转换为线性地址的过程是如何的??

下面的图展示了基本的结构:

image-20220709214502981

加载时会将段选择符加载到段寄存器中,相应的段描述符就会加载到内存,放在描述符表中

==> 硬件加速

为了更加快速的实现这个地址转换过程,80x86处理器提供了寄存器支持,也就是图中的非编程寄存器(就是程序员不能操作的寄存器)

具体的原理在于:当上面的第二步,段描述符加载到内存时,同步的将其加载到一个非编程寄存器中,这样CPU就直接引用存放段描述符的寄存器即可,只有当寄存器的内容改变的时候,才会去访问GDT/LDT描述符表

下面的图就描述了完整的转换过程:

image-20220709220831509

整个过程由分段单元(segmentation unit)负责

  1. 首先根据段选择符中的TI确定找哪个描述符表(GDT/LDT)

  2. 分段单元具体的会从gdtr/ldtr(描述符表的基址寄存器)得到对应的描述符表的基址地址

  3. 然后读取段选择符的前13位也就是index,将其值*8(每个描述符为8字节),然后在将此值+gdtr/ldtr的基址得到偏移量

    因为段描述符是8字节长,所以具体的指向就是段选择符的前13位的值 * 8

    例如GDT的开始地址为0x00020000(这个地址保存在gdtr寄存器中),索引号为2,那么对应的地址就是2*8=16 + 0x00020000,结果也就是0x00020010

  4. 段描述符的基址(也就是上一步的结果)+ 偏移量最后就得到了线性地址

7. Linux中使用的分段/Linux不喜欢分段的原因

分段可以给每个进程分配不同的线性地址空间,而分页则可以把同一线性地址空间映射到不同的物理地址空间

对于分段来说,linux更加喜欢使用分页,原因在于:

  • 所有进程使用相同的段寄存器值,内存处理起来简单(下面会详细介绍为啥)
  • 因为RISC精简指令集对分段的支持很有限,这与Linux的目标之一:高移植性不符合

2.6版本的Linux只有在80x86结构下才会使用分段

8. Linux中的分段

所有用户态进程使用一对相同的段来对指令和数据寻址,也就是用户代码段用户数据段

对于内核来说同样的,就是内核代码段内核数据段

具体描述符字段的值如下:

image-20220709224718599

可以从表中看出:所有段都是从0x0开始,那么可以得出:Linux下逻辑地址与线性地址是一致的即逻辑地址的偏移量与相应的线性地址的值是一样的

相应的段选择符在linux源码中的宏定义是(分别对应上面的四个段描符字段):

(注:这是我在arm架构对应下找到的头文件linux-2.6.11.1/include/asm-arm/segment.h,而且我发现不同架构的定义初始值是不同的)

#define __KERNEL_CS   0x0
#define __KERNEL_DS   0x0

#define __USER_CS     0x1
#define __USER_DS     0x1

例如i386的定义如下:

#define GDT_ENTRY_DEFAULT_USER_CS	14
#define __USER_CS (GDT_ENTRY_DEFAULT_USER_CS * 8 + 3)

#define GDT_ENTRY_DEFAULT_USER_DS	15
#define __USER_DS (GDT_ENTRY_DEFAULT_USER_DS * 8 + 3)

#define GDT_ENTRY_KERNEL_BASE	12

#define GDT_ENTRY_KERNEL_CS		(GDT_ENTRY_KERNEL_BASE + 0)
#define __KERNEL_CS (GDT_ENTRY_KERNEL_CS * 8)

#define GDT_ENTRY_KERNEL_DS		(GDT_ENTRY_KERNEL_BASE + 1)
#define __KERNEL_DS (GDT_ENTRY_KERNEL_DS * 8)

9. 为什么Linux中所有进程用一个段寄存器的值会更加简单呢?

因为当一个指向指令或者数据结构的指针保存的时候(保存到内存、寄存器),一般来说需要为其记录信息,但是linux内核根本就不需要为其设置逻辑地址(对应那个指针的值)的段选择符,因为cs寄存器就已经含有当前的段选择符(或者说当前的段选择符指向整个用户代码段描述符,所以程序代码不需要切换/保存这个段选择符),除非发生上下文切换

其实也就是将例如整个用户区分为代码段和数据段,如果分段非常大的话就可以认为是没有分段,因为都在一个段下

例如内核调用函数执行一条call汇编语言指令的时候,这个指令只需要指定其逻辑地址的偏移量部分,因为段选择器不用设置,因为已经隐含/包括cs中了

如果发生用户态到内核态的转换,也只需要切换代码段(由用户代码段到内核代码段),cpu切换时将__KERNEL_CS宏的值(就是内核代码段的段选择符定义)装进cs寄存器就ok了

这些同样的道理适用于数据段寄存器ds

总的来说,linux通过分别给用户态和内核态设置几个很大逻辑分段,这样进程在运行的时候就不会需要频繁的切换cs等寄存器的值,从而简单的管理内存

10. Linux的GDT、LDT细节

单核CPU对应一个GDT,而多核CPU对应多个GDT

所有的GDT都存放在cpu_gdt_table数组中,而所有GDT的地址与大小(即描述其的信息,初始化gdtr寄存器的时候使用)都存放在cpu_gdt_descr数组中

具体可以在源码linux-2.6.11.1/arch/i386/kernel/head.S中看到:

image-20220710185709303

image-20220710185754627

每一个GDT中包含18个段描述符和14个空的、未使用的保留的项,可以在下图中看到共18个段描述符分别指向的段和段选择符对应的地址值

image-20220710190351366

其中TLS是局部线程存储(Thread-Local Storage)段,允许多线程程序应用使用最多三个局部用于线程的数据段,系统调用set_thread_areaget_thread_area分别为给正在执行的进程创建和撤销一个TLS段

其他段的具体信息可见书本P49


大多数的Linux程序不会使用局部描述符表LDT,内核定义了一个缺省的LDT供大多数进程共享(也就是上图右侧的第二个段)

缺省的局部描述符表存放在default_ldt数组中

有时候进程需要创建自己的局部描述符表,modify_ldt()系统调用可以实现,当cpu执行该进程时对应的GDT中的LDT表项就会被修改为自定义的局部描述符表

11. 分页的基本概念

分页单元(paging unit)负责将线性地址转换为物理地址,其中的一个关键的任务就是限制访问权限

先看一下几个常规的基础概念:

  • :将连续的线性地址分为一个固定长度的一个组,就是一个页,一个页内部的线性地址会被映射到连续的物理地址中,不加以区分的话,通常说的页包括其中的连续线性地址和其中的数据
  • 页帧/页框(page frame):将RAM分割为固定长度的物理页也叫页框,其与页大小相同
  • 页表:将线性地址映射到物理地址的数据结构就是页表。页表存储在主存中,并且在分页单元启动之前会适当的做初始化

有了这样的映射机制,限制权限也变得简单,无论一个页框被多少个页映射,设置权限时只需要针对页框即可

所有的80x86处理器都支持分页,可以通过设置cr0寄存器的PG标志启动PG=0时启动分页,线性地址被映射为物理地址

12. 常规分页:线性地址=>物理地址 ***

一般来说分页单元会使用二级模式的两次线性地址到物理地址的转换,也就是常说的使用页目录表再到页表

使用页目录的二级指向的好处核心在于:减小页表的大小/减少每个进程页表所需要的RAM的大小 (当然还有其他方法:更大的页、反向页表等)

具体的原因可以看下图:(来自《操作系统导论-Three Easty Pieces》)

image-20220710200434842

先前不使用多级页表(左侧)中间无效的/未使用的占用了页表空间(也就是主存空间)却无意义,使用了多级页表之后(右侧),只有当一个页目录项指向的那些页表项中至少有一个有效位为1时才会为其(页表项)分配实际的RAM内存,这就是节省内存的原因,代价是多一次映射计算

内核RAM分配的延迟机制/惰性处理

  • 一个活动进程可以先分配页目录、页表,但是不会立即给其对应的页表在RAM上分配内存,而是延迟到真正需要使用/访问的时候再分配

Intel处理器的分页单元处理**4KB的页**

32位的线性地址分为如下的三个部分:

  • Directory目录:10位
  • Table页表:10位
  • Offser偏移量:12位

正在使用页目录的物理地址存放在cr3控制寄存器,基本的映射流程如下图:

image-20220710201548813

cr3基址+DIRECTORY => 页目录项

页目录项指向对应页表基址 + TABLE => 页表项

页表项指向物理页帧基址 + OFFSET => 物理页

13. 页表项和页目录项的细节

页表项和页目录项的结构相同,字段很多,全部细节见书P52

重要的字段:

  • Present存在位:

    表示该页或页表(对应页表、页目录结构)是否在主存中,为0表示不在,那么这个表项的剩余位可以由操作系统自己使用

    缺页中断:

    • 如果分页单元在做转换的时候,发现此位为0,那么就会将等待转换的线性地址保存在cr2中,然后发出一个缺页异常
  • Field

    物理页框最高20位字段,因为每个页框是4KB,所以地址必须是4096的倍数(因为地址一累加就是+4KB),所以一般物理地址的最低12位都是0(因为2^12=4096, 这样一定就是4096的倍数了,全零作为这个页帧的起始地址,这12位就对应偏移量),所以只需要前20位

    这也体现了了页大小决定了偏移量的位数

    如果这是一个页目录项(因为页目录项也是在主存中存储的,也是一个页框地址)的字段,那么其值就指向一个页表

    如果是一个页表项的字段,其值就指向一页帧

  • Accessed标志

    每当分页单元对相应的页框进行寻址的时候就会设置这个标志,不会重置

  • Dirty标志/脏页标志

    每当对一个页框/物理页写入过后就会设置此位,不会重置

  • 还有其他如图:

    image-20220710205624885

这些字段在另一本导论中也有相关介绍:

image-20220710204113380

可以看出PFN也就是物理地址的前20位

14. 拓展分页

80x86引入拓展分页,允许页框的大小为4MB而不是4KB,其实也就是更大的页来减少页表项的存储压力,从而不再需要多级页表式的多次映射

其映射的过程简化为:

image-20220710205118064

不需要中间页表进行转换,从而节省内存保留TLB项, 也就是快表

15. 简述PAE物理地址拓展机制

出现的原因:32位物理地址对应着可以使用4GB的RAM空间,但是实际上由于用户进程线性地址空间的需要,内核不能直接对1GB以上的RAM进行寻址,所以必须拓展32位80x86结构支持的RAM的容量

解决方案:

  • 硬件上:Intel将管脚数由32增加到了26
  • 软件上:新的分页机制把32位线性地址转换为36位物理地址 => PAE物理地址拓展(linux采用的机制)

变化:

  • 首先是物理页框地址由32位变为36位,其中的物理地址由原本的20位拓展到了24位
  • 页表项由32位变为64位增加了一倍,那么页表包含的总体页表项个数就减少了一倍,例如4KB就从1024变为512个页表项
  • 新引入了一个页目录指针表PDPT,由4个64位组成,其指向页目录就像页目录项指向页表。它的基址存储在cr3控制寄存器(基址27位)

所以PAE的基本原理如下:

将页目录项和页表项的数目由1024个减少到了512个,这样页表的每一项就从32位扩大到了64位,这样就能够适应物理页框地址由20位变成了24位,同时因为页目录项的数目减少,所以使用第三级别分页(PDPT),再做一层映射

所以新的映射方式如下(32位线性地址映射到4KB的页):

image-20220710213835297

可以看到多划出2位给了PDPT

此外,还有两点较为重要:

  • PAE没有扩大进程的线性地址空间,对于进程来说是无感知的
  • 用户态的进程是无法操作>4GB的地址空间的,因为只有内核态才可以修改进程的页表(注意是内核态即可,而不是只有内核)

现实中我们有很多服务器装的明明是32位的操作系统,但是内存是8G或者16G,而且操作系统也可以认出来,这是怎么回事呢?

其实这就是PAE的作用, 即使是32位也可以使用更大的内存

16. 64位系统中的分页

两级分页(也就是页目录项)在32位微处理器中较为普遍,但是在64位中并不适用(而是更多级的分页)

原因在于一句话总结:地址位数太多,如果使用两级分页能表示的页目录、页表项太多

以完全使用64位地址为例,一个64位的线性地址,以4KB的页大小为例,偏移量是12位,去除调用偏移量,TableDirectory共还剩下52位,假设只使用18位作为Table,那么每个进程的页表的页表项也会有2^18>256000项,太多了。。。

所以64位的处理器硬件都会使用额外的分页级别,具体取决于处理器的类型,见下表:

image-20220716163414394

image-20220716163422042

不同的硬件提供了不同的分页规格,那么linux是如何适用的呢?

  • linux提供了一种通用的分页模型适合绝大多数支持的分页系统,见下面

17. 硬件高速缓存内存(hardware cache memory)

也就是我们熟知的片上高速缓存L1-cache、L2-cache、L3-cache

  • 出现的原因?

    cpu的时钟周期频率是几个GHz,但是动态RAM的读取和写入的时间要慢的多,所以为了加速这个过程,就在CPU(每个处理器)的内部实现了一个SRAM的高速缓存内存

    image-20220716165507780

    高速缓存单元在分页单元和主存之间,其构成:

    • 硬件高速缓存内存SRAM
    • 高速缓存控制器
  • 作用?

    提高CPU读取/写入主存的效率

  • 基本构成?

    行: 一个新的单位,其由连续的是几个字节组成,其作为在SRAM和DRAM之间通信的基本单位,如此交互来实现高速缓存,高速缓存SRAM就是由多个行来划分的

    有字节单位了为什么还提出行的概念?

    • 经典理论:局部性原则,一般来说线性内存相邻的字节被同时访问到的概率是很大的,所以将连续的字节作为一个基本的行单位用于交互更加符合局部性原则,效率更高

    高速缓存控制器: 维护了一个表项数据,也就是用来映射高速缓存SRAM中的行与主存DRAM内存中的行之间的映射关系(通过标签和一些状态标志;物理高几位对应标签,中间几位对应子集索引,最后几位就是偏移量,注意:这里的地址是物理地址,已经是完全映射之后的事了

    映射的关系包括:

    • 直接映射:主存的行存放在高速缓存中完全相同的位置
    • N路关联:主存中的任意一个行可以在高速缓存N行中的任意一行中
  • 工作流程?如何实现

    当访问一个RAM存储单元的时候,CPU从物理地址中提取出子集的索引号,然后将确定了的子集的所有行标签与物理地址的头几位比较,如果高位相同,则表示命中,否则未命中

    • 命中后,控制器的操作:

      • 读:从高速缓存中读取数据直接发送给CPU,而不需要等待访问RAM主存的时间了
      • 写:
        • 通写:高速缓存和主存都写入
        • 回写:只更新高速缓存数据行,当CPU执行一条刷新高速缓存的指令的时候再将这些数据回写到RAM主存中
    • 未命中,那么读取/写入操作则都是对应在主存中,如果需要的话,会将正确的行从RAM读取出来放在高速缓存表项中

  • 问题:每个CPU都维护这样的一个高速缓存,那么如何保持同步?

    当一个CPU更新了高速缓存,那么就需要通知其他同样有这一条缓存的CPU更新,这种活动叫做:高速缓存侦听,这一切硬件都已经实现了,所以对于上层的操作系统来说,无需关心 (多级高速缓存的同步是)

    image-20220716171623497

  • 如何开启使用?

    处理器的cr0寄存器的CD标志位用于启动或禁用高速缓存,此寄存器中的NW标志指明高速缓存使用的是通写策略还是回写策略

    Pentium处理器高速缓存的特点是让操作系统将不同的高速缓存管理策略与每个页框相关,PCD控制对于访问在此页框中的数据时是否开启高速缓存,PWT表明时回写策略还是通写策略

  • linux如何使用高速缓存?

    linux对这些标识位都清除了,所以所有的页框都启用高速缓存,并且都采用的是回写策略

18. TLB 快速地址转换/转换后援缓冲器

TLB主要解决的是线性地址转换为物理地址比较慢,所以使用硬件做了一个缓存(缓存表),这样就避免了转换过程中的两次+(如果是二级及以上的分页级别则更多次)地址引用

因为比较简单,之前也学过,所以这里只记录到两个比较关键的点:

  • 每个CPU都有自己的TLB,并且不需要像高速缓存内存一样需要硬件支持同步(在多CPU之间),因为运行在CPU上的进程可以使用同一个线性地址与不同的物理地址关联

  • 当CPU的cr3控制寄存器被修改后,TLB的所有缓存项都变得不可用,因为都是旧数据

    cr3的修改就意味着发生了进程的切换,所以需要切换页表,旧的缓存数据当然也不可用了

19. Linux中的分页 ***

linux采用了一种适用于32位和64位的通用分页模型

直到2.6.10版本,Linux采用三级分页,从2.6.11版本之后,linux采用了四级分页模型,具体包括:

  • 页全局目录
  • 页上级目录
  • 页中间目录
  • 页表

因此线性地址被分为五个部分:

image-20220716185516687

(图中没有标明位数,因为这与具体的计算机体系结构有关)

对于不同位数的分页模式,Linux采用了如下的策略:

  • 32位:只使用两级页表,直接将页上目录和页中目录对应的位全部设置为0(不过这两部分在指针序列的位置保留,以便于代码能在64位下运行)

    如果是起用了物理地址拓展PAE的32位系统,则会使用三级页表,其中的PDPT(80x86)也就对应页全局目录,取消了页上级目录,页中间目录对应80x86页目录,页表则对应80x86页表

  • 64位:使用三级页表还是四级页表这取决于硬件对线性地址的划分

20. 物理内存布局

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

一般会设置保留/不可用的页框包括:

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

这些页框绝对不会被动态分配或交换到磁盘中

Linux内核一般从物理地址0x00100000开始(跳过第一个MB),这是因为前面一MB一般会留给加电自检、BIOS、其他特定计算机模型设置等使用,并且为了地址更加连续,Linux更加愿意跳过RAM的第一个MB

image-20220716194936635

21. TLB内核线程优化:懒惰TLB

优化的主要目的就是避免多处理器系统中无用的TLB刷新

基本思想:

如果几个CPU在使用相同的页表,并且必须对这些CPU上的一个TLB表项刷新,那么在某些情况下运行内核线程的那些CPU就可以延迟刷新

因为内核线程其实也使用的是一个普通用户进程的页表集,CPU开始运行一个内核线程的时候,首先设置为懒惰模式,当发出清除TLB表项的请求之后(也对应着切换进程/线程),处理懒惰TLB模式的每个CPU都不刷新对应的TLB页表项,此时虽然主存中的页表已经是切换之后的那个进程的页表(这与当前未更新的TLB缓存表项符不符合还不知道,只是现在先不清除),但是可以肯定的是因为这是内核线程,所以对与用户进程来说旧TLB表项是完全无效的。只有当同一个设置了懒惰TLB的CPU运行的新切换的用户进程页表集与内核进程不同时,硬件就自动刷新TLB表项,同时修改懒惰模式为非懒惰TLB


版权声明:本文为weixin_43988498原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。