任务以及任务的LDT和TSS
现代CPU支持多任务执行,这个支持在硬件上也有对应的表现。最重要的就是LDTR和TR这两个寄存器。LDTR和GDTR类似,不过LDTR所指向的是表示的是每个任务自己私有的内存段(Local Descriptor Table)。而TR指向的是保存任务执行状态的内存段(Task State Segment)。
每个任务都会有自己的LDT和TSS来保存任务的内存以及执行状态,当操作系统运行多个任务时,会有多个LDT&TSS对。当要切换到哪个任务时,CPU会把将要切换的任务的LDT和TSS加载到LDTR和TR寄存器,并开始执行。
因为LDT的出现使得任务能访问的内存区域分成了两部分,即全局空间和局部空间。全局空间包含了操作系统的段,以及常用的代码库。而局部空间则包含了程序自己的数据和代码。当任务调用了操作系统提供的服务时,处理器会转入全局空间执行,结束后会回落到任务自己的局部空间。
特权级别
特权级别是在段描述符以及段选择子中的一个数值,用于表示控制访问权限,称之为DPL(Descriptor Privilege Level)。Intel的CPU规定了DPL有4个级别,从大到小分别是0/1/2/3,最高级别是0,最低级别是3。一般给操作系统的代码和数据的级别是0,操作系统提供的服务是1或者2,而应用程序级别一般是最低的3。
在保护模式下所有的内存访问都需要通过GDT来进行,GDT表项中有DPL字段,它规定了每个内存段的特权级别。对于数据段来说,它规定了访问自己所应当具备的最低特权级别,如果一个数据段的DPL是2,那就只有0/1/2这3个级别能访问。
当处理器正在一个代码段中取指令和执行指令时,那个代码段的特权级别就叫CPL(Current Privil Level),正在执行的这个代码段,其选择子位于段寄存器CS中,其最低两位就是当前特权等级的数值。
依从段和调用门
代码的DPL级别检查很严格,一般来说控制转移只允许发生在两个特权级别相同的代码段之间。如当前特权级别为2,则不允许转移到DPL为0、1、2的代码上执行。不过,为了让特权级别低的程序可以调用高特权级别的操作系统函数,处理器也提供了对应的办法:它们是依从段和调用门。
第一种办法是把一个段定义成可以依从的。就是在GDT中注册内存段时,把TYPE中的C位设置为1,这样的代码段成为依从代码,可以从特权级别比它低的程序调用。反之如果C位是0,则只能被同级别的调用。
调用依从段的代码也有一定条件,就是当前CPL不能高于依从段,即在数值上CPL ⩾ 目标代码的段DPL。如果一个依从段DPL是1,则只能1,2,3级别的程序才能调用,0级别的是不能调用的。这里可能会有点疑问,为什么高级别的代码反而不能调用低级别的代码了?这是因为操作系统等高可靠的代码不允许使用可靠性不如它的代码,所以不允许将控制流程从较高的特权级别转移到较低级别。
而且依从段的代码不是在它的DPL特权级别上运行,而是在发起调用者的特权级别上运行,即当控制流转移到依从段时,CS寄存器的CPL不发生变化,被调用过程的特权等级依从于调用者,这也就是它为什么叫做“依从”段的原因。
第二种办法是使用调用门。调用门是一种描述符,叫做门描述符。和段描述符不同,段描述符用于描述内存段,门描述符则用于描述可执行代码。不过虽然和段描述符类型不同,但它本质上也是描述符,所以它也是要在GDT或者LDT中定义后才能使用。
调用门描述符定义了目标代码所在的代码段的选择子,以及段内偏移,上图的TYPE为1100表示调用门。
想要使用调用门,可以用jmp far
或者call far
指令,并把调用门描述符的选择子作为参数。
使用jmp far
指令,CPU会跳转到调用门所定义的代码中执行,但当前特权级别不会改变。但如果是用call far
的话,当前特权级别会提升到目标代码段的特权级别。因为call指令用到了栈,栈段的特权级别必须同当前特权级别一致。因此,还要切换栈,即从低特权级的栈切换到高特权级的栈。如一个特权级为3的程序必须使用3级别的栈,而它通过调用门进入0特权级别时,特权级别从3变成0,因此栈也要跟着切换,从3级别的栈切换到0级别的栈。这主要是为了防止栈数据交叉引用以及栈空间不足。
为了切换栈,每个任务除了自己固有的栈外,还需要额外定义几套栈,数量取决于自己的特权级别,如果是0则不需要定义,如果是1则需要定义一套1级别的栈,如果是3则需要定义0、1、2这三套栈。这些额外创建的栈,其描述符位于自己的LDT中,同时还需要在TSS中登记。因为在切换任务时,这些栈会由处理器自动加载。
调用门描述符中的DPL和目标代码段描述符的DPL用于决定哪些特权级别的程序可以调用此门。具体的规则时必须同时符合以下两个条件才行:
- 当前特权级别CPL和请求特权级别RPL高于或等于调用门描述符的DPL。即在数值上
- CPL ⩽ 调用门描述符DPL
- RPL ⩽ 调用门描述符DPL
- 当前特权级别CPL低于或等于目标代码描述符的DPL。即在数值上
- CPL ⩾ 目标代码段描述符DPL
综合起来就是一个不等式,即在数值上:调用门描述符的DPL ⩽ 调用者的CPL和RPL ⩽ 目标代码段描述符DPL
TSS段
TSS段是CPU进行任务切换的具体实体,它和之前讲的普通内存段类似,也是一块内存区域,着块内存区域也需要等级到GDT中,不过它的类型位是二进制的1001,表示它是一个TSS段,当中断或跳转指令遇到TSS段时,就会切换到TSS段所指向的任务。
TSS段的结构如图所示:
TSS段的0偏移处是用来跟踪嵌套任务的指针,用来指向上一个任务的地址。
SS0、SS1和SS2分别是0、1、2特权级别的栈段选择子,ESP0、ESP1、ESP2是对应的栈顶指针。
CR3寄存器和分页有关。
剩下的32~92是用来存储CPU寄存器的部分,用来在进行任务切换时保存现场。TSS对应的任务第一次执行时,CPU从这里加载初始化执行环境,并从CS:IP处开始执行任务的第一条指令。此后该区域的内容就由CPU负责更新。
LDT段选择子用来记录任务对应私有内存段。
偏移量100处有个T标志位,在多任务切换时,如果发现TSS的T位是1,会引发一次调试异常中断。
IO映射基地址用来存储一块IO端口权限表的起始地址。有些IO指令(如IN/OUT)由于性能原因需要开放给低权限的程序,但是为了不让低权限的程序不随意读写硬件,在EFLAG寄存器的IOPL位上设置了IO权限级别,当任务的CPL高于或等于IOPL位的权限时(数值上CPL ⩽ IOPL),所有的IO请求都没问题。但如果前面的条件不成立时,并不意味着所有的IO请求都不能通过,事实上处理器的意思时总体上不允许,但是个别端口除外,这个别端口是哪些就需要到IO映射区去查找。
IO端口权限表是一个比特序列,最多有65536位(8KB),从第1比特开始每一个表示一个端口的权限,第1位表示0号端口,第2位表示1号端口,以此类推。当端口位为1时表示禁止访问,0为允许访问。不过实际中基本不会为每个端口都设置权限,也就是说这个比特序列可以小于65536位,对于未指定权限的端口,默认是禁止访问。
IO端口权限表的起始地址是从TSS的起始处算起,因此,如果该地址大于等于TSS的段界限(段界限在TSS描述符中),则表示没有IO权限表,此时计算任务的IO权限就仅仅依据EFLAG寄存器的IOPL位来进行。
IO端口权限表最后还要以固定的一个全部为1的字节结尾。这是因为IO端口被设计成每次操作斤用来读写一个字节的数据,当用字或者双字来访问时,实际上是连续访问2个和4个端口。因此当CPU执行一个字或双字指令时,会检查权限表的2个或4个连续位,而且需要它们都是0。不过这些位可能是跨字节的,有时候刚好检查的位处于最后一个字节里,如果是两个字节的IO操作,就会越界,所以规定权限表最后一定要以一个额外的全是1的字节结尾。
任务切换
中断。中断向量指向的是一个任务门,而导致任务切换,需要用到TSS选择子
call等跳转指令,参数也是TSS段选择子