80286除了有段描述符来用限制内存的访问之外,还是门描述符用来限制程序代码的切换。 80286有四中门描述符,即调用门描述符、中断门描述符、陷阱门描述符和任务门描述符。 这个描述符也是写在GDT, LDT, IDT这三个描述符表中的,通过Type字段与段描述符区分。调用门描述符可以写在GDT和LDT中,中断门描述符和陷阱门描述符可以写在中断门描述符中,
简单粗暴不严格的讲,调用门描述符主要用来实现系统调用。而且它现在基本已经被操作系统广泛的抛弃了(转而用sys_enter, sys_exit指令)。但我们仍然要学习一下它的基本原理。
x86 CPU分为四个特权级,依次为ring0到ring3。ring0特权最大,ring3特权最小。Linux操作系统只使用了ring0和ring3,分别运行内核态和用户态代码。Linux内核里面有好多个函数,然而只有那么200个左右的函数是直接可以给用户态程序调用的,这些函数称为系统调用。用户如果能够调用非开放的操作系统函数,就会对操作系统造成威胁。
怎么从硬件角度做到让用户态程序能调用操作系统的一部分函数(系统调用)而不能调用另一部分函数呢?
了解了段描述符之后我们可能会想到利用段描述符。我们把不希望用户调用的系统函数代码放在DPL为0的代码段,把允许用户调用的系统函数放在DPL为3的代码段。这样就可以保证用户只能访问DPL为3的代码段上的操作系统代码了。
看起来挺不错的,好像我们已经解决了这个问题。实际上这种方式是有问题的:
- 比如说一个系统调用是someProc,把它放在DPL3的代码,用户就可以随意的调用它。仅仅是调用它是没有问题,问题是用户可以随意地jmp到someProc+offset处的代码上接着执行,这就给各种想搞破坏的人提供了极大的方便。
- 系统调用函数的段描述符DPL为ring3,那如何能让用户代码调用它时切换到ring0,从而有权限调用其他操作系统函数呢?
所以这种方式是行不通的。这里就引入了调用门,而系统调用代码的段描述符DPL还是ring0。
调用门可以让操作系统为某些希望用户程序调用的函数制作一个调用门描述符。用户调用操作系统代码时,不是直接call someProc,而是call这个调用门描述符的选择子。而通过调用门描述符选择子调用函数有以下规则:
- someProcGateDescriptorSelector是调用门描述符的选择子,这个选择子要被送到CS寄存器中。
- 与往常的函数调用不同,这种调用完全不参考IP寄存器里的offset。CPU直接根据门描述符选择子找到门描述符。
- 门描述符中记录了对应的操作系统函数的段选择子和段内偏移,还记录了这个函数有几个参数。
- CPU根据门描述符中的段选择子找到函数所在的代码段描述符,从而确定代码段所在的位置,然后加上门描述符中的offset得到函数的确切地址,然后完成这个调用。
由于这个过程不参考IP寄存器中的值,用户程序也就无法随意跳转到操作系统代码的任意位置执行了。
现在的规则变成这样:
- 所有操作系统代码的段描述符DPL都是0,从而可以互相调用。所有用户代码的段描述符DPL都是1,从而也可以互相调用。
- 操作系统对外开放的API称为系统调用,系统调用代码段的段描述符中的C位置1,称之为一致性代码段。其他所以代码段的段描述符的C位为0,称为非一致性代码段。非致性代码段只的代码只允许同特权级的代码互相访问,绝对禁止跨特权级访问。即用户态代码只能访问用户态代码,内核代码只能访问内核代码。一致性代码段的代码允许用户态代码访问内核代码,但不允许内核态代码访问用户态代码。即不允许高特权级(ring号小的)访问低特权级(ring号大的)代码。
一个跨代码段的函数调用例子如下:
; caller代码段
code1 segment
....
call someProc
....
code1 ends
;callee 代码段
code2 segment
....
someProc PROC
...
RET
someProc ENDP
code2 ends
[SECTION .gdt] ;定义GDT
; GDT
; 段基址, 段界限 , 属性
LABEL_GDT: Descriptor 0, 0, 0 ; 空描述符
LABEL_DESC_NORMAL: Descriptor 0, 0ffffh, DA_DRW ; Normal 描述符
LABEL_DESC_CODE32: Descriptor 0, SegCode32Len-1, DA_C+DA_32; 非一致代码段,32
LABEL_DESC_CODE16: Descriptor 0, 0ffffh, DA_C ; 非一致代码段,16
; **目标代码段的段描述符**
LABEL_DESC_CODE_DEST: Descriptor 0,SegCodeDestLen-1, DA_C+DA_32; 非一致代码段,32
LABEL_DESC_DATA: Descriptor 0, DataLen-1, DA_DRW ; Data
LABEL_DESC_STACK: Descriptor 0, TopOfStack, DA_DRWA+DA_32;Stack, 32 位
LABEL_DESC_LDT: Descriptor 0, LDTLen-1, DA_LDT ; LDT
LABEL_DESC_VIDEO: Descriptor 0B8000h, 0ffffh, DA_DRW ; 显存首地址
; **调用门描述符**
LABEL_CALL_GATE_TEST: Gate SelectorCodeDest, 0, 0, DA_386CGate+DA_DPL0
......
;**目标代码段选择子**
SelectorCodeDest equ LABEL_DESC_CODE_DEST - LABEL_GDT
;**门描述符选择子**
SelectorCallGateTest equ LABEL_CALL_GATE_TEST - LABEL_GDT
Caller代码段:
[SECTION .s32]
[BITS 32]
LABEL_SEG_CODE32:
......
call SelectorCallGateTest:0
; END of [SECTION .s32]
Callee代码段:
[SECTION .sdest]
[BITS 32]
LABEL_SEG_CODE_DEST:
;jmp $
mov ax, SelectorVideo
mov gs, ax ; 视频段选择子(目的)
mov edi, (80 * 12 + 0) * 2 ; 屏幕第 12 行, 第 0 列。
mov ah, 0Ch ; 0000: 黑底 1100: 红字
mov al, 'C'
mov [gs:edi], ax
retf
SegCodeDestLen equ $ - LABEL_SEG_CODE_DEST
; END of [SECTION .sdest]
JMP或Call后跟着48位全指针(16位段选择子+32位地址偏移),且其中的段选择子指向代码段描述符,这样的跳转称为直接(普通)跳转。普通跳转不能使特权级发生跃迁,即不会引起CPL的变化,看下面的详细描述:
要求:CPL >= DPL ,RPL不检查。
转跳后程序的CPL = 转跳前程序的CPL
要求:CPL = DPL AND RPL<= DPL
转跳后程序的CPL = 转跳前程序的CPL
当段间转移指令JMP和段间转移指令CALL后跟着的目标段选择子指向一个调用门描述符时,该跳转就是利用调用门的跳转。这时如果选择子后跟着32位的地址偏移,也不会被cpu使用,因为调用门描述符已经记录了目标代码的偏移。使用调门进行的跳转比普通跳转多一个步骤,即在访问调用门描述符时要将描述符当作一个数据段来检查访问权限,要求指示调用门的选择子的 RPL≤门描述符DPL,同时当前代码段CPL≤门描述符DPL,就如同访问数据段一样,要求访问数据段的程序的CPL≤待访问的数据段的DPL,同时选择子的RPL≤待访问的数据段或堆栈段的DPL。只有满足了以上条件,CPU才会进一步从调用门描述符中读取目标代码段的选择子和地址偏移,进行下一步的操作。
从调用门中读取到目标代码的段选择子和地址偏移后,我们当前掌握的信息又回到了先前,和普通跳转站在了同一条起跑线上(普通跳转一开始就得到了目标代码的段选择子和地址偏移),有所不同的是,此时,CPU会将读到的目标代码段选择子中的RPL清0,即忽略了调用门中代码段选择子的RPL的作用。完成这一步后,CPU开始对当前程序的CPL,目标代码段选择子的RPL(事实上它被清0后总能满足要求)以及由目标代码选择子指示的目标代码段描述符中的DPL进行特权级检查,并根据情况进行跳转,具体情况如下:
要求:CPL >= DPL ,RPL不检查,因为RPL被清0,所以事实上永远满足RPL <= DPL,这一点与普通跳转一致,适用于JMP和CALL。
转跳后程序的CPL = 转跳前程序的CPL,因此特权级没有发生跃迁。
要求:CPL = DPL (RPL被清0,不检查),若不满足要求则程序引起异常。
转跳后程序的CPL = DPL
因为前提是CPL=DPL,所以转跳后程序的CPL = DPL不会改变CPL的值,特权级也没有发生变化。如果访问时不满足前提CPL=DPL,则引发异常。
要求:CPL >= DPL(RPL被清0,不检查),若不满足要求则程序引起异常。
转跳后程序的CPL = DPL
当条件CPL=DPL时,程序跳转后CPL=DPL,特权级不发生跃迁;当CPL>DPL时,程序跳转后CPL=DPL,特权级发生跃迁,这是我们当目前位置唯一见到的使程序当前执行优先级(CPL)发生变化的跳转方法,即用CALL指令+调用门方式跳转,且目标代码段是非一致代码段。
typedef struct _CALL_GATE
{
USHORT OffsetLow;
USHORT Selector;
UCHAR NumberOfArguments:5;
UCHAR Reserved:3;
UCHAR Type:5;
UCHAR Dpl:2;
UCHAR Present:1;
USHORT OffsetHigh;
}CALL_GATE,*PCALL_GATE;
BASE23~16 BASE15~0 : 描述子所描述的那个段的段基地址。
Limit (段限): 该段的最后一个字节的偏移量,指明了该段的大小。
A: 该段是否被访问,该段已被访问过,则 A←1;该段未被访问过,则 A←0。该位与操作系统的时钟相结合,可进行段淘汰算法。
S: 描述子类型,1 代表数据代码段描述符;0 代表系统描述符(如门描述符/任务状态段描述符)
DPL: 共两位,规定可以访问该描述子所描述的那个段的任务的最低特权级。
P: 0 表示该描述子所描述的段不在物理空间;1 表示该描述子所描述的段在物理空间。
TYPE:由三位构成,即数据段(E, ED, W) 或代码段(E, C, R)。这个TYPE对所描述的存储段的具体属性有着极其重要的意义。
若该段为数据段,则 E=0,需要配合TYPE的ED和W。ED为0则段向上生长,所以要求偏移量小于Limit(段限);ED为1则段向下生长,所以要求偏移量大于Limit(段限);W为0则该数据段只能读,不能写;如果W为1则该数据段可读、可写。
若该段为代码段,则E=1,需要配合TYPE的C和R(ED变成了C,W变成了R)。C为0则非一致性代码段访问和被访问代码段特权级相同;C为1则一致性代码段访问和被访问代码段特权级可以不同;R为0则代码段只能执行,不能读;R为1代码段可以执行,也可以读。