实践中需要了解的cpu特性
目录
分段机制
特权级检查
GDT和LDT
堆栈切换
分页机制
中断
分段机制
实模式中cs是一个实实在在的段首地址,ip为cs所指向段的偏移,所以cs<<4+ip是当前cpu执行的指令。我自己学汇编的时候,整天念叨着段地址左移4位加上偏移才是实模式的地址,但到了实际的代码中看到一个实实在在的左移指令时候还是没想到这点。
保护模式中的cs是对一个数组的索引,也就是对GDT或者LDT表的索引。这两个表的数组元素都是8个字节的长度,存储的是:
- 段描述符,有段的信息,比如说段的类型、段的首地址、访问这个段的最低权限等等。主要是分为两大类,代码段和数据段;
- 门描述符,门描述符就像一道关卡,放着你要访问段的索引和相对于段首的偏移。但是你得符合这道关卡的约束,才能得到这个索引,并根据这个索引访问你想要的段;否则的话cpu就会报通用保护的异常。从这个角度看,门描述符仅仅是一份记录,记录着段的索引,并不涉及到具体的段的位置。所以intel对于使用门描述符来定位的访问首先在通过门的检查中按照访问数据段的检查方式来判断本次访问是否有资格获得段的索引,如果通过再按照段的代码段的访问方式来验证一遍。
特权级检查
数值越大,特权级越低,下面说的低特权级就是说选择子低两位值较高。特权级检查是保护模式下的属性,有关的几个名词是CPL、RPL、DPL。
RPL是对于代码跳转那一瞬间而言的,存在于指定的或者默认的选择子中。我自己是一直以为cs或ss就是选择子,所以一直困惑于CPL和RPL的区别,因为这两个玩意怎么可能既是CPL,又是RPL呢。
比如说
jmp abcd:fg
此时指明了abcd就是一个选择子
jmp hijk
此时没有指明段前缀,cs一般就是默认的段选择子
在产生跳转的那一瞬间,参与安全检查的叫做RPL,因为有可能你的cs的特权级很高,但是你用显示跳转的方式,拿上面的abcd来说,可能这个选择子的特权级就没有原本的cs特权级高。那intel为何多此一举搞一个RPL的概念呢?由于RPL的存在,通过max{cpl,rpl},我们不用改变自身的特权级就能临时伪装一个低特权级的身份。
CPL是一个常态的概念,一直存在于cs或者ss中。RPL只参与比较,比较后并不赋值给选择子。
DPL是一个段或者一个门描述符的特权级的描述。
门描述符和段描述符的区别就是:
- 段是赤裸裸的告诉你哪里有代码哪里有数据,根据选择子找到我,并且通过cpu的检查,你就可以在我圈的地里面随意走动了
- 门描述符,是一份数据的记录,这分数据记录的是一种结构体,该结构体成为门描述符,里面存放的有我们想用的代码或者数据的段选择子。只有通过这个检查,获得这份数据记录,才能得到段选择子,再去选择段。我这样说,就是说明,再访问门描述符的时候,cpu是按照访问数据段要求来判断我们时候有资格获得这份数据,如果有资格,那么再按照访问代码去检查。
对于数据段的访问
max{cpl,rpl}<=DPL,符合
对于代码段的访问
有几个原则:
- 高特权级不能访问低特权级
- 一致代码段:控制权转移前后特权级不变
- 非一致代码段:控制权转移前后特权级=DPL
- 是否是一致代码段,代码或者手工指定描述符的属性
对代码段的访问,分为两种情况:
不通过调用门(普通跳转),不能使特权级发生跃迁
jmp或者call后面跟着48位的全指针(其中32偏移+16选择子),并且选择子指向的是段描述符(由cpu硬件读取你所指向的段描述符的属性自行判断)。
访问一致性代码:
要求:CS.CPL>=DestnationCodeDesc.DPL
访问后:CS.CPL=oldCS.CPL
纵使它执行了高特权级的代码,也是不会发生变化,这是cpu硬件赋予一致代码段的功能
访问非一致性代码:
要求:CS.CPL=DestnationCodeDesc.DPL
访问后:CS.CPL=oldCS.CPL
这就保证了前后特权级一致,没有发生跃迁
通过调用门
jmp或者call后面跟着选择子(如果有线性偏移也被忽略,因为门描述符记录了线性偏移),并且选择子指向的是门描述符(由cpu硬件读取你所指向的段描述符的属性自行判断)。
如上所述,我们要得到门描述符的记录数据,就要按照访问数据段的要求做一次检查,也就是要符合max{cpl,rpl}<=DPL。通过之后,cpu会将段选择子中RPL字段清零(也就是最高级),也就是下面的检查忽略RPL的作用,再去按照访问代码段的规则进行检查
访问一致性代码:
要求:
CS.CPL>=DestnationCodeDesc.DPL
RPL<=DPL(事实上,永远满足)
访问后:CS.CPL=oldCS.CPL
纵使它执行了高特权级的代码,也是不会发生变化,这是cpu硬件赋予一致代码段的功能
访问非一致性代码:
Jmp的形式:
要求:
CS.CPL=DestnationCodeDesc.DPL
RPL<=DPL(事实上,永远满足)
访问后:CS.CPL=oldCS.CPL
这就保证了前后特权级一致,没有发生跃迁
Call的形式:
要求:CS.CPL>=DestnationCodeDesc.DPL
如果:CS.CPL=DestnationCodeDesc.DPL
访问后:CS.CPL=DestnationCodeDesc.DPL
如果:CS.CPL>DestnationCodeDesc.DPL
访问后:CS.CPL=DestnationCodeDesc.DPL
这是我们见到的唯一能使特权级发生变化的跳转方式。
以上,在检查不通过的时候会报异常(不过我忘了这个总结是在哪个博客了看到的,自己分个更详细的类)
不过对于通过门描述符访问的指令,代码段的偏移地址已经在门描述符里面写死了,所以即便该指令后面还跟着偏移地址也不会被CPU使用。
GDT和LDT
GDT里面存放的是全局共享的段的描述,LDT是属于进程或者任务独有的。
在实验中,每个进程都有一个LDT描述符,也就是说,每个进程都被当做一个任务看待。在进程结构体中保存的有LDT选择子,有SP0指针,都会在进程初始化时候被填充,关于进程切换很有意思,到时候我们会看一点代码。
一般CS的可见部分的高13位作为在GDT或者LDT里面的索引。第2位TI=0指明本次寻址用GDT,TI=1指明本次寻址用LDT。在GDT中寻址很清楚。在LDT中寻址要中转一下,因为LDT也是一个段,所以也要有段描述符在GDT中保存,那么这个LDT在描述符在GDT表中的索引却不是直接放在CS的高13位里面,而是在任务切换的时候被系统加载进ldtr里面了(这一点自己开始没搞清楚,一直以为找LDT段还是通过CS寻找,这个过程中纠结不已),这个寄存器的高13位指明了本次任务的LDT描述符在GDT表中的位置。等系统用这个索引找到LDT段后,再用CS的高13位在LDT段中做索引,才找到和任务相关的局部代码段或者数据段的描述符,再进行跳转或者访问。
堆栈切换
当低特权级因为某些事件需要跳到高特权级的时候,就需要切换堆栈。以后就简单称为用户态和内核态算了。
为什么用户态的程序跳到内核态就要切换堆栈呢?不切换用同一个esp感觉应该也不会出事情啊。当然,如果能保证控制力非常精准用同一个栈也不是不可以,要不然纯汇编的程序员岂不是郁闷死了。还是为了保护两个字。就拿进程间的切换来说,有不同的栈岂不是不用担心数据覆盖什么的了,而且也不用考虑进程抢占时候栈的处理了。
用户态的程序在进入内核态的时候要把栈切换到内核态的栈,那么内核态的栈指针从何而来呢?Intel将esp0放在TSS中。当要切换用户态到内核态的时候,CPU自动从TSS段中取出esp0加载进esp里面,然后将用户态的ss、esp,还有eflags、cs、eip一次压入此时esp指向的栈空间。根据需要再决定是否要对其它几个寄存器执行压栈操作。
那么TSS里面的esp0又是从哪里来的呢?是从进程控制块(PCB,也就是进程在系统中的户口本)里面,在进程调度的时候能够用的上(也就是一个进程的内核态切换到另一个进程的内核态,中断是用户态切换到内核态,或者是同一个进程的内核态的压栈操作)。当发生进程调度的时候,调度程序是操作系统设计者写的,自然知道每个PCB的哪个位置保存了该进程的内核态指针,然后把这个值载入TSS的esp0,用iret指令返回就好了。
分页机制
对于32位的cpu,一般来说都是二级页表映射。一个页面的大小是固定的4K,每个页面的首地址是4K对齐的,后12位不参与寻址,但是用来保存一些页面的属性,比如说是否在内存中,读写属性等等。
对于页表来说,第一级是页目录,每个页目录存放一个页面的物理地址,对应一个页面,这个页面有4KB/4B=1K个项,每个项都存放一个地址,这个地址地址指向一个页面,这个页面被程序真正用来存放数据或者代码。页目录项和页表项里面存放的都是物理地址。然后在程序中给的地址是线性地址,高10位在页目录表中做索引,找到对应的二级页表所在,中间10位在二级页表中做索引,找到存放数据的页面,低12位用来在存放数据的页面做偏移,得到这个地址所对应的真正的物理地址。
中断
中断主要分为外部中断和int n产生的中断,int n产生的中断和调用门相似,相当于跳转到另一段代码中,Linux中int 0x80就是作为调用门来陷入内核的。而对于外部中断,不过是CPU的INTR引脚接收到了一个电信号,在CPU执行完一条指令后这个电信号会被检测到,然后CPU会给8259A一个响应,让其把中断产生的号发送过来。8259A在发送给CPU中断号之后,会按照优先级屏蔽比这个中断优先级低的中断信号。CPU得到这个中断号之后,会到idtr指定的IDT表中用这个号作为索引找到响应的门描述符,然后执行响应的代码。
在系统初始化的时候需要注意的是,BIOS初始化所占用的中断号和intel规定的用来响应中断或者异常所占用的编号有冲突,没办法,BIOS占用的中断号必须让出来,不然外部挂接的设备产生的中断信号将被解释为intel规定所对应的异常,即使你把这个中断号对应的处理程序绑定为正确的程序,那么由于CPU会做不同的变化,从而得不到我们想要的效果。
比如说BISO设置的8259A让0x9号中断对应于键盘事件,如果我们在跳入保护模式不改过来,那么在发生键盘输入事件的时候,0x9号中断将被cpu寻到保护模式下IDT表的协处理器段越界的fault异常,即便你讲键盘中断函数绑定在9号中断上,而且也正确的读取到了键盘的按键,但是cpu做的处理可能不再是顺着原有流程走了,那么没法保证,有冲突的中断号能按照预期想法工作。
Intel用了前32个IDT号,所以我们要编程8259A,将BIOS占用的几个编号挪到0x20开始的中断。以前在学《微机原理与接口技术》的时候,看到8259A还有编程时候用的ICW命令都是感觉到云里雾里的,不知道其意义所在,现在却是有点明白了。8259A的使命应该就是按照ICW的设置将每个引脚产生的高低电平信号转换为一个编号。就像BIOS可以将8259A设置为将八个引脚的电信号分别转化成0x8-0xf一样,我们自然能将主从(两个)芯片占用的编号设置为0x20-0x2f。其中主从片的控制寄存器的地址分别为0x20和0xA0,作者的书中92页有很详细的解释,不过高低位不要忽略了,以免对照不上每个字段的含义。
在转移BIOS占用的中断号后,先暂时屏蔽所有的外部中断,因为此时的中断表还没有建立,如果响应了中断,cpu将会按照idtr中的值访问中断处理函数,不出意外的话立即就会崩溃。
屏蔽中断时往主片的0x21或者从片的0xA1端口地址写入OCW1,该字节每个位对应一个8259A的屏蔽操作,置为1则屏蔽这个引脚的电信号。另外在每次相应一个中断后,还要发送一次EOI指令,以便继续接收中断,发送EOI是通过往端口0x20或0xA0写OCW2来实现的,对于EOI来讲就是OCW2的内容就是0x20。