分段和分页

起因是上《操作系统高级教程》时,突然发现对这块的知识已经混沌了,所以重新总结下。
参考:
https://blog.csdn.net/yzy1103203312/article/details/78529067
https://blog.csdn.net/qq_32740495/article/details/102924136

为何需要分段

在8086处理器诞生之前,内存寻址方式就是直接访问物理地址(实模式)。8086处理器为了寻址1M的内存空间,把地址总线扩展到了20位。但是,ALU的宽度只有16位,即ALU不能计算20位的地址。为了解决这个问题,从而引入了分段机制

IA32框架的内存寻址

三类地址

IA32的三类地址如下:

  • 逻辑地址机器语言指令用这类地址指定一个操作数的地址或一条指令的地址,最原始的地址就是逻辑地址。
  • 线性地址:将逻辑地址经过分段机制转换之后,便得到了线性地址,每个线性地址都由一个段基址和段内偏移量组成
  • 物理地址:线性地址经过分页单元的处理之后得到一个实际物理地址,也就是内存单元的实际地址,用于芯片级内存单元寻址。

地址空间:操作系统给每个进程用的一段连续的虚拟内存空间。这个地址范围不是真实的,是虚拟地址的范围,有时甚至会超过实际物理内存的大小。

三类地址的转化

以上3类地址通过MMU(内存管理单元)来进行转换。其中MMU处理时包含2个过程,分段和分页。在这里简单的说明下具体过程:

  1. 当一条机器指令给出一个地址时,这时候的地址便是逻辑地址
  2. 为了得到线性地址,需要从相应的段寄存器中取出16位的段标识符(段选择符),通过这个段标识符可以得到一个段基址。然后将得到的段基址与指令中的地址相加,从而得到一个线性地址
  3. 有了线性地址之后,再通过分页单元得到实际的物理地址
    在这里插入图片描述

分段

硬件中的分段

段是虚拟地址空间的基本单位,分段机制必须把虚拟地址空间的一个地址转换为线性地址空间的一个线性地址。
为了实现这种映射,仅仅用段寄存器来确定一个基地址是不够的,至少还得描述段的长度,并且还需要段的一些其他信息,比如访问权之类。所以,这里需要的是一个数据结构——段描述符,它包括三个方面的内容:

  • 段的基地址(Base Address):在线性地址空间中段的起始地址。
  • 段的界限(Limit):在虚拟地址空间中,段内可以使用的最大偏移量。
  • 段的保护属性(Attribute):表示段的特性。例如,该段是否可被读出或写入,或者该段是否作为一个程序来执行,以及段的特权级等等。

多个段描述符组成的表称为段描述符表。

逻辑地址的段寄存器中的值提供段描述符,然后从段描述符中得到段基址段界限,然后加上逻辑地址的偏移量,就得到了线性地址,线性地址通过分页机制得到物理地址。

Linux的分段

为了支持分段,8086处理器设置了四个段寄存器:CS, DS, SS, ES。每个段寄存器都是16位的,都包含着相应段的基址。访存指令中的地址也是16位的,但是,在送入地址总线之前,CPU先把它与某个段寄存器内的值按以下方式相加:

1
实际物理地址 = (段寄存器地址 << 4) + (指令访存地址)

这四个段寄存器的段首地址均是0,也就是说,段首地址+逻辑地址=线性地址,这个公式里面的段首地址为0,也就意味着在linux中,逻辑地址=线性地址,这就是linux的分段技术。

分页

对于物理内存,分页单元把它分为固定长度的页框(page frame),每一个页框包含一个页(page)。对于虚拟地址空间,也把它分为一个个的页。为了访问每一个物理页,需要有一个页表,记录每个物理页的起始地址,简单说通过页表就可以将一个虚拟内存中的页与具体的物理页一一对应起来。虚拟地址可以分为两部分,页号和页内偏移,页号为页表的索引,得到页的基地址后,加上偏移地址就可以得到具体的物理地址。

以32位环境为例,虚拟地址空间为4G,一般一页为4KB,这样4G内存可以分为1M个页,由于每个页表项需要4个字节来描述,因此一共需要4M的存储空间,因为每个进程都有自己的页表,所以1个进程就需要4M的存储空间。

特权级CPL、DPL和RPL

相关概念

x86 处理器中,提供了4个特权级别:0,1,2,3。数字越小,特权级别越高!一般来说,操作系统是的重要性、可靠性是最高的,需要运行在0特权级;应用程序工作在最上层,来源广泛、可靠性最低,工作在3特权级别。中间的1和2两个特权级别,一般很少使用。这几个个特权级均由两位(bit)组成,可以表示0~3共4个等级。

在处理器中,有3个相关的术语与特权级密切相关:

  • CPL:当前进程的权限级别(Current Privilege Level),是当前正在执行的代码所在的段的特权级,存在于cs寄存器的低两位。
  • RPL: 说明的是进程对段访问的请求权限(Request Privilege Level),是对于段选择子而言的,每个段选择子有自己的RPL,它说明的是进程对段访问的请求权限,有点像函数参数。而且RPL对每个段来说不是固定的,两次访问同一段时的RPL可以不同。RPL可能会削弱CPL的作用,例如当前CPL=0的进程要访问一个数据段,它把段选择符中的RPL设为3,这样虽然它是0特权,但对该段仍然只有特权为3的访问权限。
  • DPL: 存储在段描述符中,规定访问该段的权限级别(Descriptor Privilege Level),每个段的DPL固定。当进程访问一个段时,需要进程特权级检查,一般要求 DPL >= max {CPL, RPL}

在保护模式下,cpu利用cpl/rpl/dpl对程序的访问操作进行特权级检查,数据段和代码段的特权级检查规则有所不同。

对数据段和堆栈段访问时的特权级控制:

要求访问数据段或堆栈段的程序的CPL ≤ 待访问的数据段或堆栈段的DPL,同时选择子的 RPL ≤ 待访问的数据段或堆栈段的 DPL。即程序访问数据段或堆栈段要遵循一个准则:只有相同或更高特权级的代码才能访问相应的数据段。这里,RPL可能会削弱CPL的作用,访问数据段或堆栈段时,默认用CPU和RPL中的最小特权级去访问数据段,所以max {CPL, RPL} ≤ DPL,否则访问失败。

对代码段访问的特权级控制(代码执行权的特权转移):

一些“定律”:

  • 所有的程序转跳,CPU都不会把段选择子的RPL赋给转跳后程序的CS.RPL.
  • 转跳后程序的CPL(CS.RPL)只会有下面的2种可能:
    • 转跳后程序的CPL(CS.RPL) = 转跳前程序的CPL(CS.RPL)
    • 转跳后程序的CPL(CS.RPL) = 转跳后程序的CodeDescriptor.DPL
  • CPU不允许程序向低特权级跳转(认为低特权级的代码不可靠,有风险)
  • 只有一种方式能够使特权级发生改变,call + 调用门 + 非一致代码段,且当前cpl大于目标段dpl,且特权级只能向上跳转。

GDT TSS IDT LDT

回顾Linux寻址

Linux中的寻址: logical addr --> linear addr --> physical addr
第一个转换是通过GDT的分段机制,第二个转换是通过分页机制。CPU使用logical addr, CPU中的MMU部件使用physical addr。比如一个程序编译后,代码段的指令地址是0x08048888,这就是logical addr,CPU就取这个地址。GDT是一个表,用来实现logical addr–> linear addr的转化,也就是分段思想的实现。gdtr寄存器指向GDT在内存中的首地址,用CS,DS中的内容做为index,这个index的学名叫segment selector。

CS:在保护模式下的段选择器,我们一直都只把它看做一个段描述符的“索引号”,用来在 GDT (全局描述描述符表) 中查找一个段描述符。
用户程序拥有自己私有的描述符表 LDT(Local Descriptor Table),并且拥有自己的特权级别(总不能让用户程序与操作系统一样,工作在非常高的 0 特权级别)。

正如处理器中有一个寄存器 GDTR,保存着 GDT 的开始地址和长度;处理器中还有一个寄存器 LDTR,存储着当前正在执行的那个应用程序的 LDT 开始地址和长度。

GDT

在Protected Mode下,对一个段的描述则包括3方面因素:【Base Address, Limit, Access】,它们加在一起被放在一个64-bit长的数据结构中,被称为段描述符。但是,无法通过16-bit长度的段寄存器来直接引用64-bit的段描述符。解决的方法就是把这些长度为64-bit的段描述符放入一个数组中,而将段寄存器中的值作为下标索引来间接引用(事实上,是将段寄存器中的高13 -bit的内容作为索引)。这个全局的数组就是GDT。

LDT

除了GDT之外,IA-32还允许程序员构建与GDT类似的数据结构,它们被称作LDT(Local Descriptor Table,局部描述符表),但与GDT不同的是,LDT在系统中可以存在多个,并且从LDT的名字可以得知,LDT不是全局可见的,它们只对引用它们的任务可见,每个任务最多可以拥有一个LDT。另外,每一个LDT自身作为一个段存在,它们的段描述符被放在GDT中。

由于每个进程都有自己的一套程序段、数据段、堆栈段,有了局部描述符表则可以将每个进程的程序段、数据段、堆栈段封装在一起,只要改变LDTR就可以实现对不同进程的段进行访问。

段选择子是什么?

保护模式下,处理器提供段寄存器,处理器提供了6个段寄存器来保存段描述符。这些段寄存器称为cs、ss、ds、es、fs和gs。每个段寄存器都由可见部分和不可见部分组成,当段选择子(可见部分)被加载至段寄存器时,处理器也通过段选择子所指向的段描述符获取了这个段的不可见部分。

段选择子段选择符为16位,描述段的一些信息,它不是直接指向段,指向在GDT或LDT中的段描述符。它的高13位作为被引用的段描述符在GDT/LDT中的下标索引,bit 2用来指定被引用段描述符被放在GDT中还是到LDT中,bit 0和bit 1是RPL——请求特权等级,被用来做保护目的。

段选择子包括三部分:描述符索引(index)、TI、请求特权级(RPL)。它的index(描述符索引)部分表示所需要的段描述符在描述符表的位置,由这个位置再根据在GDTR中存储的描述符表基址就可以找到相应的描述符。然后用描述符表中的段基址加上逻辑地址(SEL:OFFSET)的OFFSET就可以转换成线性地址,段选择子中的TI值只有一位0或1,0代表选择子是在GDT选择,1代表选择子是在LDT选择。请求特权级(RPL)则代表选择子的特权级,共有4个特权级(0级、1级、2级、3级)。

TSS: 任务状态段

顾名思义,任务状态段就是用来存储和恢复任务的状态信息。

经常听到一个术语:任务上下文。
TSS是一个特殊的段。在Linux中,CPU从系统态切换到用户态时,会用到TSS里面的ss0和esp0。每个CPU只维护一个TSS。TR寄存器指向这个TSS,切换时里面的ss0和esp0会有改变。相应有一个TSSD放在GDT中,是GDT的一个表项。

可以看到:任务寄存器中可见部分的段选择符加载TSS描述符的段选择符,访问GDT中TSS描述符,通过TSS描述符访问TSS。同时TSS描述符中的基址和界限字段的值又加载到任务寄存器的不可见部分的基址和界限字段,这样做的目的是下次访问该TSS时可以不用通过GDT中的TSS描述符访问TSS,而是直接通过缓存在任务寄存器中的基址和界限字段访问TSS,加快了系统对TSS的访问。