操作系统底层特权级机制详述

一、引言

特权级是现代操作系统中极为重要的一个概念,它对于系统的安全性起到了至关重要的作用。

操作系统底层有很多方面都涉及到特权级,通常的书籍会在讲解各部分的时候介绍特权级在某个方面的作用,不过很多读者即使学完了各部分的内容,对特权级的理解还是有点模糊。

为了帮助大家较快地建立起对特权级的整体性概念,这篇文章中将以特权级为中心,讲述特权级在各方面的作用,从整体上理解系统底层的特权级机制。

在阅读这篇文章之前,假定你已经了解了以下基本概念:内存分段、实模式、保护模式、描述符、选择符(选择子)、描述符表、调用门、中断和异常、任务的堆栈、内核态和用户态、模式切换(内核态和用户态之间的切换)。

如果还不了解以上概念,建议先阅读以下材料:

  • 《英特尔 IA-32 架构软件开发者手册》
  • 《x86 汇编语言:从实模式到保护模式》
  • 《Linux 内核完全注释》

英特尔手册主要用于查询,有对各种概念的介绍,遇到不懂或不确定的概念可以查阅参考。

第二本书可以详细阅读,讲解非常清晰易懂,即使是刚入门的读者也能读懂,书中对各个概念有详细分析。

第三本书的最新版本叫做《Linux 内核完全剖析——基于 0.12 内核》,不过旧版有高清文字版 PDF,如果不想买纸质书的话可以看旧版。这本书是基于 Linux 0.12 系统的,如果对 Linux 并不十分感兴趣,可以重点阅读其第四章“80X86保护模式及其编程”,这部分讲的是保护模式的基本概念;还有第五章的 5.8 节“Linux 系统中堆栈的使用方法”,这节讲述了任务的堆栈、内核态和用户态、模式切换这几个重要概念。

二、为什么需要特权级?

在深入探讨特权级之前,我们先来说说为什么会有特权级这个机制。

在 80286 系列处理器之前,CPU 并不存在特权级这个概念,访存采用的是简单的分段模型,那么没有特权级的 CPU 跑起来是什么样呢?

答案是操作系统无法判断当前执行的程序是否可靠,是否应该允许它执行某些危险的指令,只能对所有代码一视同仁。所以一个程序可以任意访问和修改其它程序的代码段或数据段,只要提供对应的内存地址,甚至能修改系统的核心代码!

是不是感觉毫无安全性可言?

三、保护模式下的特权级

从 80286 处理器开始,Intel 引入了 保护模式,而 特权级 是保护模式中的一个重要概念。

我们将 80286 之前的处理器运行的模式称为 实模式,即寻址方式是通过给出代码段的基地址 CS 和偏移地址 IP,就可直接访问该内存。实模式下无法对代码的执行权限和访问权限进行控制。

在保护模式中,CPU 采用了 段保护机制,一个程序需要用到哪些段,需要告诉操作系统,由操作系统登记到描述符表中,并注明段界限、类型等属性,这样当程序想往代码段写数据时,或者让处理器访问超过段界限的内存区域时,处理器就会引发异常中断。

但是只有段保护机制的话,并不能确保操作系统的安全性。

如果一个恶意程序知道 GDT 表的位置,它就可以将系统的段描述符加载到段寄存器中,然后“合法”地访问系统私有数据;又或者,它可以在 GDT 表中私自增加一个描述符,让它指向系统数据区,然后就可以通过加载该描述符到段寄存器来访问系统私有数据!

显然,还需要进一步完善保护机制,于是特权级就派上用场了。特权级的数值范围为 0~3,即数值越小特权级越高。特权级有 DPL、RPL、CPL 三种。要让处理器访问其它数据段或转移到其它代码段时,需要通过 特权级检查,如果不能通过,就会产生 通用保护错误,以此确保代码安全执行。

通常来说,操作系统的核心代码运行在最高特权级(0 特权级)上,而用户程序运行在最低特权级(3 特权级上),特权级 1 和 2 一般用于运行系统服务程序。

下图来自《Linux 内核完全注释》:

四、DPL、RPL、CPL

  • DPL:Descriptor Privilege Level,描述符特权级
  • RPL:Request Privilege Level,请求特权级
  • CPL:Current Privilege Level,当前特权级

描述符特权级(DPL) 位于描述符的属性字段,如下图:

请求特权级(RPL) 位于选择符中的 RPL 字段:

当前特权级(CPL) 位于代码段寄存器(CS)的低两位。我们可以认为 CPL 就是特殊的 RPL,因为它是存放在当前 CS 中的选择符的 RPL 值。

五、为什么需要三种特权级?

上面提到的三种特权级都是为了让系统更安全地运行,阻止某些危险的指令操作。

我们看看定义了这么多特权级,它们分别是用来干嘛的。

CPL 比较容易理解,我们需要用它来判断当前正在执行的代码处于哪个特权级,这样才能对它的行为做出限制。

DPL 表示描述符的特权级,我们根据它来判断程序是否有足够权限对相关的段进行某些操作。

RPL 就是请求者的特权级别。我们要访问某个数据段、跳转到某个代码段、调用某个例程,都可以视为请求,而发出这个请求的就称为请求者。

可能你问:发出请求的不就是当前程序吗?既然当前程序是请求者,直接用 CPL 就可以表示了,为什么要多一个 RPL?

通常情况下,请求者就是当前程序本身,此时 RPL = CPL。但并非所有情况都是如此,例如由于 I/O 特权级的限制,用户程序(特权级为 3)不能自己访问硬盘,但可以通过调用门借助系统例程来完成访问。当执行系统例程进行 I/O 操作时,当前程序就是系统例程,而请求者就是用户程序,CPL = 0,而 RPL = 3。

引入 RPL 是为了帮助处理器在遇到一条将选择子传送到段寄存器的指令时,能够区分真正的请求者是谁。

六、不同特权级间控制转移

代码段的特权级检查非常严格。一般来说,控制转移只允许发生在两个特权级相同的代码段之间。

但是,为了让特权级低的应用程序可以调用特权级高的操作系统例程,处理器允许通过某些方式在不同特权级的代码段之间转移控制,下面将会介绍。

但是无论是通过什么方式转移,都只允许低特权级代码调用(或 JMP)高特权级代码,处理器不允许高特权级代码调用低特权级代码。

我们以内核和用户程序为例,内核的特权级最高(0 特权级),而用户程序的权限最低(3 特权级)。处理器允许用户程序调用内核代码,但不允许内核调用用户程序的代码。为什么?

这跟直观上的感受有点矛盾。特权级越高,不应该越不受限制吗,怎么高特权级反而不能调用低特权级的代码呢?

可以这么理解:内核不会调用可靠性低的用户程序代码。如果内核可以通过 CALL 指令调用用户程序代码,那么万一该程序是恶意的,而返回地址又是由该程序给出的,那么用户程序就很有可能让处理器转移到别的恶意代码中。

使用一致代码段

通过将高特权级代码段定义为一致的(conforming),就能让低特权级程序调用该高特权级代码段。

一致代码段的特点是,当转移到该代码段时,CPL 与之前保持一致。

当一个高特权级代码段被定义为一致性代码段时,那么特权级与它相同,或比它低的程序都可以调用它。

顺便提一下,如果要调用的是非一致代码段,那么特权级必须与之相等。

使用门描述符

另一种在特权级间转移控制的方法是使用门。

段描述符用于描述内存段,门描述符则用于描述可执行的代码,例如一个程序、一个过程或者一个任务。

不同特权级间的过程调用可以使用调用门,中断处理过程使用中断门或陷阱门,任务间的切换使用任务门。

调用门是安装在描述符表(GDT 或 LDT)中的描述符,它的格式如下:

调用门中包含了段选择符,指向目标代码段。调用门就像一个中间者一样,我们可以通过它来转移到更高特权级的目标代码段。

如果使用 jmp far 指令,通过调用门转移控制时,无论目标代码段的特权级是否比当前特权级高,当前特权级都不会发生改变。

如果使用 call far 指令,通过调用门转移控制时,当前特权级会提升到目标代码段的特权级。

强制转移到低特权级

我们之前说过,处理器不允许高特权级调用低特权级代码。

但是如果我们确实需要从高特权级代码转移到低特权级代码段怎么办?就像在《Linux 内核完全注释》的 Linux 0.00 实验中那样,当内核完成初始化的工作之后,要开始执行用户程序,此时就需要从特权级 0 转移到特权级 3.书中采用的方式是 模拟中断返回

也就是说,我们把当前的环境伪造成中断现场,往堆栈里压入用户程序的 SS、ESP、EFLAGS、CS、EIP,当执行 iret 指令时,处理器会认为是中断程序执行完毕,于是将堆栈里的各个寄存器值弹出到对应寄存器中,并开始执行用户程序,此时 CPL 也变为用户程序的特权级 3。

下图截取自 Linux 0.00 的 head.s 程序:

此外,还可以像《x86汇编语言:从实模式到保护模式》第十四章的内核程序那样,在内核初始化结束后,要切换到特权级为 3 的用户程序时,模拟调用门返回

总之,处理器不允许控制权直接从高特权级代码转移到低特权级代码,如果要实现,就必须模拟某个低特权级调用高特权级代码的中间过程,通过返回的形式,从高特权级转移到低特权级。

七、特权级检查规则

数据段

数据段的特权级检查规则与我们的直观感受相符。当前特权级越高,访问数据段就越不会受到限制。

也就是说,当前特权级高于或等于要访问的数据段 DPL 时,才能通过特权级检查。

数值上可表示为:RPL、CPL <= DPL

注意:如果是通过段寄存器 SS 访问数据段,则要求 CPL、RPL = DPL

代码段

如上面所介绍,CPU 只允许低特权级调用高特权级的代码,所以只有在当前特权级低于或等于目标代码段的 DPL 时,才允许进行控制转移。

数值上可表示为:RPL、CPL >= DPL

CPL 只有在一种情况下会改变:CALL 指令通过调用门转移到特权级更高的代码段。

调用门

使用调用门时涉及到两个 DPL:一个是调用门描述符自身的 DPL,另一个是目标代码段(调用门中段选择符对应的代码段)的 DPL。

调用门自身的 DPL 检查规则与数据段的规则一样,就是当前特权级必须足够高,才能够对调用门进行调用。但由于只能低特权级代码调用高特权级代码,所以又要求当前特权级要低于或等于目标代码段的特权级。

因此,检查特权级时,数值上应该满足:

RPL、CPL <= 调用门 DPL

RPL、CPL >= 目标代码段 DPL

任务门

当要使用 JMP 或 CALL 通过任务门来切换任务时,特权级的检查规则与访问数据段的规则一致。

即数值上:RPL、CPL <= DPL

八、任务的特权级栈

任务在执行时,可能会调用其它特权级的例程,或由于其它原因执行不同特权级的代码。

任务在不同特权级执行,就需要不同的栈来保存数据,因此对于一个特权级为 3 的任务,需要定义特权级 0、1、2、3 共四个栈,不过如果系统只使用 0、3 两个特权级,就可以不定义 1、2 特权级栈。

如果任务的特权级为 1,那么就只需要定义特权级 0、1 两个栈,因为任务执行时只能调用特权级更高的代码,当前特权级别只可能更高,所以不需要特权级低的栈。

任务的不同特权级堆栈都记录在了 TSS 中,如下图,0x14 到 0x18 地址分别存放了特权级为 0~2 的栈,而特权级为 3 的栈就是任务在用户态正常执行时使用的栈,段选择符记录在 0x50,ESP 记录在 0x38。

例如,在 Linux 0.11 中,只使用 0、3 两个特权级。

假设现在有一个特权级为 3 的任务正在执行,此时产生了一个系统中断,于是处理器从该任务的用户态转移到了内核态执行。这是因为中断服务程序属于内核代码,因此执行时当前特权级就变为了 0,也就是进到了内核态。

在从用户态切换到内核态的过程中,处理器会将用户态堆栈的 SS 和 ESP 压入到内核态的堆栈中,同时入栈的还有 EFLAGS、CS、EIP,当从内核态退出时,就会恢复用户态堆栈和 EFLAGS、EIP。

九、总结

本文旨在帮助读者从整体上理解特权级,明白它在各方面的作用和联系,相关的知识点还需要通过专业的书籍深入学习。前言中介绍的两本书以及英特尔手册都是优质的学习资源,本文中很多的内容和插图都源自于它们,这里给出这些书籍以及其它相关电子资源的下载地址,由于 CSDN 有最低下载积分限制,因此只能全部设为 2 积分。

虽然我尽可能总结了特权级所涉及的内容,并力图准确概括,但由于能力有限,难免存在纰漏,如果您发现文章存在错误,烦请指出,在此表示感谢。

相关文章

发表评论

电子邮件地址不会被公开。 必填项已用*标注