引子
从一个问题开始,假设在逻辑上函数 ring0_thread()
只让内核调用,而函数 ring3_thread()
让用户进程调用。那么想一想,当在 ring3_thread()
中调用 ring0_thread()
,会抛出 #GP
异常吗?
下面把问题变具体点,来看看特权级是个什么东西
内核背景
首先为内核定义一些东西,下面是 GDT
的定义:
1 | 0 |
并且需要实现一个方法跳入 ring3
,如下:
1 |
|
然后的用户进程长这个样子:
1 | void ring3_thread(void) { |
这里请注意,函数只有在我们用户视角才是函数,而在处理器眼中,仅仅是一个地址。假设页表映射以及这两个函数的地址如下:
1 | // .map 文件 |
那么实际上,在平坦模式下(也即是所有段描述符表示的地址空间都是 0 ~ 0xffff_ffff)只要用户进程给出 CS : EIP = (3*8)|3 : 0xc000_1000
就可以访问到 ring0_thread()
这个内核函数了,但这样便会抛出 #GP
吗?分情况讨论
情况一:内核函数是普通函数
假设的 ring0_thread()
定义如下,来分析下这个过程看看会不会抛 #GP
:
1 | void ring0_thread(void) { |
首先,ring3_thread()
调用 ring0_thread()
对处理器来说本质上是调用一个地址,也即 call 0xc000_1000
。让先来回顾下 call
指令:
- 近跳转:只会进行段界限检查而不会进行特权级检查
- 远跳转:有两种方式
call 段描述符
:这个段需要带上可执行属性call 门描述符
:调用门
现在先忽略特权级检查规则,先来看整个过程,很明显在平坦模式下 call 线性地址
是近跳转,而当前代码段是用户代码段,界限为 0xf_ffff
,所以通过,能直接跳转过去
进入 ring0_thread()
像这种定义局部变量、读写,不需要特权,用户进程确实可以独自完成,所以不会抛 #GP
情况二:内核函数需要写入内核数据
那么,如果需要读写内核数据,比如内核的页目录表呢,修改下 ring0_thread()
,再来看下这个过程:
1 | extern uint32_t _kernel_page_dir[1024]; |
同样也是从处理器视角去看问题,赋值操作大多数都是调用诸如 mov
这一类指令。这里内核页目录表本质也是个地址,所以最终大概会是类似于 movl $0x12345678, (地址)
这样的赋值。读写一个内存地址自然是不需要特权的(假设用户进程在共享内核线性空间时,拥有 PDE / PTE 读写属性均允许),所以用户进程也可以独立完成(在良好的内核设计中肯定不希望这么做,这里只是举个例子),因此也不会抛 #GP
情况三:内核函数是打印函数
现在来看最后一种情况。假设的 ring0_thread()
负责打印功能,并且它访问显存时需要使用 in
和 out
指令读取光标之类的东西。那么此时 ring3_thread()
调用 ring0_thread()
就会抛出 #GP
,来分析下这个过程
前面流程同理,区别是 in
或 out
指令,关于这些 IO
指令,以下是一些补充:
IO
指令也被称为敏感指令(Sensitive Instructions
),和特权指令(Privileged Instructions
)一样有特权级检查规则。IO
指令受到EFLAGS
的IOPL
字段的限制,只有在数值上CPL <= IOPL
才被允许执行
默认情况下,如果你不使用 POPF/POPFD
指令将特定的值从栈顶弹出到 EFLAGS
的 IOPL
字段,则它为零。那么此时由于 CPL=3
,默认的 IOPL=0
,自然不满足特权级检查而抛出 #GP
结论
所以,特权级的保护机制不在于你执行了什么代码,读写了什么数据。关键是要意识到特权级检查发生的时刻,因为只要这个时刻通过了,后续的代码执行、数据读写就和特权级检查无关
- 对于数据段的特权级检查:总是发生在加载段寄存器那一刻
- 对于代码段的特权级检查,情况会更复杂,因为还有一种是依从属性(段描述符中的
conforming
字段)要考虑,不在本文讨论范围内,本文仅涉及加载可执行段的段寄存器和特权指令、敏感指令