目录
1. 特权级保护机制
1.1 基础段保护机制的不足
1.2 特权级划分
1.3 特权级的表示
1.3.1 当前特权级CPL
1.3.2 描述符特权级DPL
1.3.3 请求特权级RPL
1.4 引入特权级后要解决的问题
1.4.1 代码段
1.4.2 数据段
1.4.3 栈段
1.5 特权级检查的典型时机
2. 引入特权级后的控制转移
2.1 一般原则
2.2 从低特权级转移到高特权级
2.2.1 依从(conforming)代码段
2.2.2 调用门(call gate)
2.3 从高特权级转移到低特权级
2.3.1 什么时候要从高特权级转移到低特权级
2.3.2 用户程序的特权级指定
2.3.3 实现方法
2.3.4 操作系统进入用户态实例
3. 引入特权级后的数据访问
3.1 数据访问权限检查
3.2 请求特权级RPL详解
3.2.1 RPL的引入
3.2.2 请求特权级调整指令ARPL
3.2.3 引入RPL之后的权限检查规则
4. 引入特权级后的任务栈段使用
4.1 栈段使用概述
4.2 不同特权级栈段的分配与记录
4.2.1 栈段信息的保存
4.2.2 创建栈段并登记在TSS中
4.3 通过调用门转移控制时的栈段切换过程
4.4 通过调用门转移控制完整描述
4.4.1 控制转移过程
4.4.2 控制返回过程
5. IO特权级
5.1 IO特权级引入背景
5.2 IO特权级机制实现
5.2.1 EFLAG寄存器IOPL位
5.2.2 TSS段IO许可位串
5.2.3 IO许可位串的使用
① 在之前的章节中,通过GDT和LDT将任务分为全局空间和局部空间。用户程序在一般情况下只访问属于自己的段,当用户程序需要使用操作系统提供的服务时,转入全局空间执行
② 平时用户程序不访问全局空间,并不是因为在机制上不能访问,只是自觉不予访问。但是一个失控的程序或者恶意的程序,可以通过追踪和修改GDT表的方式来达到访问任何内存地址的目的
因此基础的段保护机制对于现实需求而言是不够的
① 特权级(Privilege Level),也称作特权级别,用来划分不同的权限
② Intel处理器拥有0 ~ 3共4个特权级,数值越小,特权级越高
③ 特权级数值存在于描述符及其选择子中,当这些描述符或者选择子所指向的对象要进行某种操作(e.g. 代码段要执行某条指令),或者被别的对象访问时,该数值用于控制他们所能进行的操作,或者限制他们的可访问性
也就是说,特权级保护既限制操作(执行指令),也限制访问
说明1:特权级分配
特权级环型结果如下图所示,
① 因为操作系统是为所有程序提供服务,可靠性最高,而且必须对硬件有完全的控制权,所以操作系统主体必须拥有特权级0,因此处于整个环型结构的中心
这也是操作系统主体被称作内核的原因
② 特权级1和特权级2通常赋予那些可靠性不如内核的系统服务程序,比较典型的就是设备驱动程序
但是在很多流行的操作系统中(e.g. Linux),驱动程序和内核的特权级相同,都是特权级0,这样可以简化操作系统的实现
③ 应用程序的可靠性被认为是最低的,而且通常不需要也不能让其访问硬件和一些敏感的系统资源,因此赋予他最低的特权级3
说明2:特权指令
那些只能由特权级0状态才能执行的指令称为特权指令(Privileged Instructions),典型的特权指令包括lgdt、lldt、ltr、控制寄存器的传送指令、hlt指令等十几条。
特权指令体现了特权级保护对操作的限制
说明3:IO特权级
①除了上述特权级敏感的指令外,处理器还允许对各个特权级所能执行的IO操作进行控制,通常是控制端口访问的许可权
② EFLAGS寄存器中的IOPL(IO Privilege Level)位表示当前任务的IO特权级别
③ 在任务的TSS段中,包含EFLAGS寄存器的副本,用于表示该任务的IO特权级别。当切换到该任务时,处理器会将TSS中的EFLAGS寄存器副本恢复到EFLAGS寄存器中
关于IO特权级的使用方式,详见下文分析
① 无论什么时候,处理器总是在某个代码段执行的。当处理器正在一个代码段中取指令和执行指令时,那个代码段的特权级就是当前特权级CPL(Current Privilege Level)
② 正在执行的代码段的段选择子在段寄存器CS中,其最低2位就是当前特权级的数值,也就是说CPL存在于CS段寄存器中
说明1:对于处理器来说,没有程序的概念,处理器的工作是执行指令,至于执行的指令属于哪个程序,处理器并不知道,也不需要知道
但是当前所执行的指令属于哪一个段,处理器是知道的,因为段寄存器CS正指向这个段
因此,从这个意义上说,特权级的概念首先是代码段的特权级。如果一个代码段的特权级被设置为0,那么原则上,当执行这个代码段时,就会以特权级0执行
说明2:原则上一个程序的所有代码段都应当具有相同的特权级,因此所谓程序的特权级,就是这个程序的代码段的特权级
说明3:引入保护模式后,原来的模式就被称为实模式(实模式与保护模式的概念相比较而存在)
实模式下的程序都是以0特权级运行的,所以从实模式跳转到保护模式时,处理器只是继承了原先的0特权级
以进入保护模式的代码为例,jmp指令之后,CS才体现为段选择器。但是在此之前,已经使能了CR0寄存器中的PE位,即已经进入保护模式。此时处理器也运行在0特权级,继承了原先的实模式下的0特权级(使能PE位前后,CS寄存器中的CPL字段均为0)
① 实施特权级保护的第一步,就是为所有可管理的对象赋予一个特权级,以决定谁能访问他们
② 描述符特权级(Descriptor Privilege Level)存在于描述符中,而描述符总是指向他所描述的对象,因此DPL就是赋予该对象的特权级
① 请求特权级(Request Privilege Level)存在于段选择子中,用于标识对象真实请求者的特权级
② RPL一般与CPL相同,也就是对象的真实请求者就是当前正在执行的代码。但是也存在RPL与CPL不一致的情况,详见下文调用门章节
③ 鉴别真正的请求者由程序员完成,并将他的特权级保存在RPL字段
说明:特权级检查就是围绕CPL / RPL / DPL进行的,其中CPL和RPL标识请求者的权限,DPL则是被请求对象的权限
① 跨特权级的代码段转移
② 需要关注特权级保护机制是否允许转移,以及转移后是否改变CPL
① 对象请求者是否有权限访问指定的数据段
② 需要关注如何通过RPL标识对象的真实请求者
① 特权级保护机制对栈段有特殊的要求,即当前使用的栈段的DPL必须和CPL相同
② 需要关注为任务分配不同特权级的栈段,以及特权级切换时导致的栈切换
① 执行特权指令时,比如ltr和lldt
② 修改段寄存器时,比如retf、jmp far、call far、pop 段寄存器、用mov指令向段寄存器传送段选择子
说明:设置段寄存器时需要进行特权级检查,后续的内存访问不需要特权级检查
因为在访问内存之前需要先设置段寄存器(包括使用转移类指令间接修改CS段寄存器),所以在指定段的时候进行检查即可,后续的内存访问检查没必要也影响效率
这里的内存访问,既包括对数据段的访问,也包括对代码段的访问
① 引入特权级保护机制之后,段间转移就复杂了。每个段都有自己的特权级,引入特权级的一个目的就是对段间转移进行限制
② 控制转移原则上只允许发生在2个特权级相同的段之间,也就是CPL = DPL
③ 通过特殊方法,可以实现从低特权级代码段转移到高特权级代码段执行
④ 任何时候,都不允许从高特权级代码段转移到低特权级代码段执行
说明1:不允许从高特权级代码段转移到低特权级代码段的原因
就是因为一个代码段的可靠性不高,才将其设置为低特权级。既然可靠性不高,自然不允许从可靠性较高的代码段转移到可靠性较低的代码段执行
说明2:注意上图中通过RETF实现的段间转移,通过RETF从代码段A转移到代码段B,一般是先通过CALL指令从代码段B转移到了代码段A
之所以说"一般是",是因为后面将看到,我们可以在没有先使用CALL指令的情况下,人为构造"转移返回"场景。这种方法在操作系统的实现中经常使用,详见下文分析
在操作系统中,用户程序都是运行在低优先级。当用户程序需要调用操作系统提供的内核例程时,就需要从低特权级转移到高特权级运行
但是如上文所述,在引入特权级保护机制之后,不能直接进行跨特权级转移,而是需要通过处理器提供的相关机制。下面就是X86体系结构提供的2种方式
① 在代码段的描述符中,C位是依从位,表示代码段是否依从
② 如果一个代码段是依从的,则可以从低特权级的代码段进入,但是仍然不允许从高特权级代码段进入低特权级代码段,即有如下要求,
CPL >= 因从代码段的DPL
③ 转移之后,当前特权级CPL不变。也就是说,依从的代码段不是在他的DPL上执行,而是依从于转移之前的CPL
以上图所示的场景为例,可以从正在执行的代码段(CPL = 3)转移到依从的代码段(DPL = 2)执行,且执行时CPL仍为3
2.2.2.1 调用门概述
我们再来回顾一下X86体系结构中的描述符分类
① 门描述符描述的不是内存段,而是一些系统管理单元,比如用来描述一个任务或者描述一个例程或子程序
② 调用门是门描述符中的一种,通过调用门,可以从低特权级的代码段通过JMP或CALL指令进入高特权级代码段执行
③ 要想通过调用门进行控制转移,可以使用jmp far或者call far指令,并把调用门描述符的选择子作为操作数
说明1:门描述符可以安装在GDT或LDT中
说明2:通过jmp far和call far使用调用门的区别
① 使用jmp far指令,可以将控制通过调用门转移到比当前特权级高的代码段,但是不改变当前特权级
② 使用call far指令,则当前特权级会提升到目标代码段的特权级别。也就是说,处理器是在目标代码段的特权级上执行
其实jmp far指令不改变CPL是可以理解的,因为通过jmp far指令进行的控制转移不会返回
说明3:后续还会介绍任务门、中断门、陷阱门和系统门,他们也都是X86体系结构中的门描述符
2.2.2.2 调用门描述符格式
① S = 0,表示是系统描述符
② TYPE = 0b1100,表示是调用门
③ P(Present),表示门是否有效
④ DPL,调用门本身的特权级,决定了谁有权通过调用门实施控制转移
⑤ 例程所在代码段选择子 + 段内偏移量,通过调用门要转移到的目标位置
这里需要特别注意,例程所在代码段选择子所指向的代码段描述符中的DPL字段,会参与调用门特权级检查
⑥ 参数个数,切换栈时要拷贝的参数个数
如上文所述,通过call far指令使用调用门可能导致CPL特权级切换,而CPL特权级切换又会伴随着栈段的切换。因此需要将压入旧栈中的参数拷贝到新栈,而这个拷贝工作是由处理器完成的,我们要做的就是指定要拷贝的参数个数
说明1:P位的作用
① 描述符中的P位均表示有效位,通常应该是1。当P位为0时,调用这样的门会导致处理器产生异常中断
② 操作系统可以利用该机制统计调用门的使用频率,具体原理如下,
a. 在部署调用门描述符时,将P位置为0
b. 每当调用该门而产生异常中断时,在中断处理函数中将该门的调用次数加1,同时将门描述符中的P位置为1
c. 对于因P位为0而引起的中断,属于故障中断,从中断处理函数返回时,处理器还会重新执行引起故障的指令。此时,因为P位已经为1,所以可以执行
d. 在调用门描述的例程中,除了完成相应功能,再将P位置为0,以便下次调用时统计
这里的要点是,处理器会重新执行引起故障中断的指令
说明2:关于参数个数的说明
① 表示参数个数的字段共5位,所以最多允许传送31个参数
② 由于调用门描述符中只给出了参数个数就可以实现拷贝,因此每个参数的大小肯定是固定的(应该是4B)
单独说明这点是为了提醒我们,在使用调用门时,压入的参数需要是规定尺寸的
2.2.2.3 调用门特权级检查规则
通过调用门实施控制转移,涉及3个特权级,
① 当前特权级CPL
② 调用门描述符的DPL
③ 目标代码段描述符的DPL
要想穿过调用门,需要满足如下条件,
CPL <= 调用门描述符的DPL 且 CPL >= 目标代码段描述符的DPL
下面对这2个条件进行说明,
① CPL <= 调用门描述符的DPL
当前代码段首先要有权使用这个调用门
② CPL >= 目标代码段描述符的DPL
控制不会从高特权级代码段转移到低特权级代码段
所以,相当于目标代码段描述符的DPL和调用门描述符的DPL设置了调用门高度的上下限
说明1:如果通过jmp far指令使用调用门,由于不会改变CPL,因此成功转移的条件如下,
① 如果目标代码段是依从的
CPL >= 目标代码段描述符的DPL
② 如果目标代码段是非依从的
CPL = 目标代码段描述符的DPL
说明1:从调用门返回
如果通过call far指令使用调用门将控制从低特权级代码段转移到高特权级代码段,那么在从调用门返回时,就会造成一种从高特权级代码段转移到低特权级代码段的场景
在引入特权级保护模式之后,除了从高特权级别的例程返回,不允许从高特权级代码段将控制转移到低特权级代码段
这也就引出了我们从内核态进入用户态的方法,就是模拟从高特权级例程返回
2.2.2.4 为内核接口例程创建调用门
在原先的符号地址检索表中,存储的是内核例程所在的代码段选择子和段内偏移地址。在创建调用门的过程中,需要将其中的代码段选择子替换为调用门选择子
在内核的启动过程中,首先为每个内核例程创建调用门描述符,之后将调用门描述符安装在GDT中,而用于索引调用门描述符的选择子则回调到符号地址检索表中
说明1:make_gate_descriptor函数
说明2:调用门描述符的DPL设置为3,这样运行在特权级3的用户程序才能使用
说明3:由于是调用set_up_gdt_descriptor函数安装调用门描述符,因此返回的选择子中RPL字段为0
后续在调用load_relocate_program函数加载并重定位用户程序时,在重定位符号地址检索表时会将填写到用户程序头部段中的调用门选择子的RPL字段设置为3,与用户程序的运行等级匹配
2.2.2.5 调用门的测试和调用门转移过程
为验证调用门安装是否成功,在内核中会对调用门进行测试
在调用门安装完成后,salt_1标号处的内容如下图所示,
① 通过call far指令使用调用门,指令的操作数为6B内存,其中包含占位的4B偏移量
② 处理器根据dw处的选择子在GDT中查找描述符,并解析给出该描述符为调用门描述符
③ 处理器将调用门描述符中的代码段选择子传输到CS,段内偏移量传输到EIP。在加载CS段寄存器时,会将其指向的代码段描述符加载到CS描述符高速缓存器中
说明1:调用门特权级检查时机
① 门级检查
在使用call far指令访问调用门描述符时,检查CPL是否<=调用门描述符的DPL
② 段级检查
通过门级检查后,在将调用门描述符中的代码段选择子加载到CS段寄存器时,检查CPL是否>=代码段选择子指向的代码段描述符的DPL
说明2:根据上面的示例,call far指令只使用了6B内存中的调用门选择子部分,偏移量部分会被忽略,但是这个占位的偏移量必须要有
说明3:Linux在X86体系结构中实现系统调用时,没有使用依从代码段或调用门,而是使用了软中断和系统门
① 在操作系统中,用户程序运行在低特权级,内核运行在高特权级
② 在系统启动过程中,处理器均在高特权级运行内核代码,但是在启动完成后,需要从内核态转移到用户态,执行系统中的第1个用户程序
③ 后续只有通过系统调用和中断,才能从用户态转移到内核态
在调用load_relocate_program函数加载并重定位用户程序时,将用户程序各段的段描述符DPL设置为3,同时将相应段选择子的RPL也设置为3
这样,当用户程序开始运行时,处理器将处于特权级3
2.3.3.1 方法概述
如上文所述,在引入特权级保护模式之后,除了从高特权级别的例程返回,不允许从高特权级代码段将控制转移到低特权级代码段
因此从高特权级转移到低特权级的实现方法,就是构造一种假装从调用门返回的场景,模拟处理器压入返回参数,之后调用retf指令实现转移
2.3.3.2 压栈参数说明
要想构造从低特权级转移到高特权级的调用门返回场景,首先要知道在通过调用门从高特权级转移到地特权级时,处理器会压入哪些参数
这里涉及栈切换的过程,我们会在下文详细说明,此处仅展示出转移后的栈状态
2.3.3.3 程序实现
根据上面的分析,要想从内核切换到用户程序运行,需要按序向栈中压入如下参数,
① 用户程序栈段选择子
② 用户程序栈指针
③ 用户程序入口点段选择子
④ 用户程序入口点偏移量
之后便可调用retf指令模拟从调用门返回。由于当前特权级CPL为0,而压入栈中的用户程序入口点段选择子指向的代码段描述符的DPL为3,因此会触发处理器自动进行栈切换,从而将栈也切换为用户程序栈
说明:这种"欺骗"手段利用了retf指令的工作原理,retf指令的工作和调用门无关,他的宗旨是:不管你当初是怎么来的,既然你有能力来,我只管放心地让你回去
retf指令的工作很简单,先判断当前特权级和返回后的特权级是否一致,如果一致,则只是从栈中弹出返回地址到CS和EIP;如果不一致,则不仅要弹出返回地址到CS和EIP,还要弹出并恢复原先的栈状态到SS和ESP
下面以Linux操作系统为例,分析他们在内核启动完成后,如何从内核态返回用户态
2.3.4.1 Linux 0.11 + X86体系结构
在内核初始化的最后阶段,会调用move_to_user_mode函数将特权级切换到3,并且继续运行main函数
在Linux 0.11 + X86体系结构中,并不是模拟从调用门返回,而是模拟从中断返回,虽然方法不同但是思路是一致的
进入用户态后,将从标号1的位置开始运行
2.3.4.2 Linux 2.6 + ARMv7体系结构
在内核初始化的最后阶段,会先运行init进程,之后调用ret_to_user函数切换到用户态
在Linux 2.6 + ARMv7体系结构中,通过模拟从SVC中断返回,实现进入用户态
① 和代码段一样,数据段的段描述符中,也有DPL字段,用于指定数据段的特权级。对于数据段而言,DPL决定了访问他所应当具备的最低特权级
② 基于可靠性和安全性的考虑,要访问一个数据段,最基本的规则是CPL必须<=数据段描述符的DPL
上文中已经提到了请求特权级RPL,并且说明其作用为标识对象真实请求者的特权级。也就是说在引入RPL之前,是存在真实请求者特权级不匹配的情况的,而且很显然,是低特权级代码"冒充"了高特权级请求
之前已经介绍过调用门和数据访问的权限检查,下面以用户程序通过调用门读取硬盘数据到内存的场景为例,说明RPL的作用
3.2.1.1 正常访问场景
① 用户程序要将硬盘指定扇区的数据读取到指定内存,需要向内核例程传递3个参数:硬盘逻辑扇区号、数据段选择子和数据段内偏移量,其中后2个构成了目标缓冲区的地址
传递这些参数时,可以使用寄存器,也可以使用栈。我们使用了寄存器
② 通过call far指令使用调用门,在执行内核例程时CPL为0
③ 在正常情况下,用户程序使用用户程序数据段选择子设置DS。此时用户程序是无法用内核数据段选择子的,因为没有足够的权限
3.2.1.2 非法访问场景
① 如果使用栈传递目标数据段选择子,并且在内核例程中设置DS段寄存器,由于CPL为0,则可以使用内核数据段选择子
② 如果用户程序使用非法手段获取了内核数据段选择子,并且传递给内核例程,则可以非法修改内核数据段的内容
③ 这里就可以理解所谓"对象真实请求者"的概念了。在上述示例中,实际请求要访问内存的是用户程序,但是由于调用门导致的CPL特权级切换,使得内核例程成为对象的请求者,从而隐藏了对象真实请求者的身份
3.2.1.3 RPL的作用
RPL的作用,就是标识在上述示例中被隐藏的对象真实请求者的身份。但是需要注意的是,处理器是没有能力区分这个真实请求者的,所以该问题需要处理器和操作系统软硬件结合来解决
① 处理器在检查特权级时,同时使用请求者的CPL和RPL来和要请求对象的DPL进行比较
② RPL由操作系统负责设置,以体现对象请求者的真实身份
根据上文描述,操作系统需要鉴别真正的请求者,并将他的特权级设置到选择子的RPL字段。为实现该功能,处理器提供了ARPL指令,用于调整选择子中的RPL字段
3.2.2.1 ARPL(Adjust RPL field of segment selector)指令格式
arpl r/m16, r16 ; r/m16中存取的也是段选择子
arpl指令需要2个操作数,且均为段选择子。arpl指令比较2个操作数的RPL字段,如果目的操作数的RPL字段 < 源操作数的RPL字段,则修改目的操作数的RPL字段,使其与源操作数的RPL一致。同时,将标志寄存器中的ZF位置为1
否则,不改变目的操作数的RPL,且ZF位清零
说明:通过判断ZF位的值,就知道是否调整了段选择子中的RPL字段
3.2.2.2 ARPL指令使用示例
我们以一段示例的read_hard_disk_with_gate内核例程为例,说明arpl指令的用法
说明1:在进入内核例程并压入3个寄存器后,栈的布局如下
示例代码中使用进入调用门之前的CS,也就是实际请求者的特权级,来调整数据段选择子的RPL字段
说明2:一般情况下RPL的设置
在绝大多数情况下,请求者就是当前代码段,此时只需要将段选择子的RPL字段设置为CPL即可
RPL和CPL不同的场景,一般只发生在从低特权级向高特权级转移的时候,典型的就是通过调用门实施控制转移的时候
3.2.3.1 代码段
① 用jmp、call或者retf指令在2个段之间进行控制转移,而且不通过调用门,其规则如下
a. 如果目标代码段是依从的,则要求CPL和RPL必须低于或等于目标代码段描述符的DPL,即在数值上,
CPL, RPL >= 目标代码段描述符的DPL
b. 如果目标代码段是非依从的,则要求CPL和RPL必须等于目标代码段描述符的DPL,即在数值上,
CPL, RPL = 目标代码段描述符的DPL
② 用jmp、call或者retf指令在2个段之间进行控制转移,并且通过调用门,其规则如下
a. CPL和RPL必须高于或等于调用门描述符的DPL,即在数值上,
CPL, RPL <= 调用门描述符的DPL
b. 如果通过call far指令使用调用门,则要求CPL和RPL必须低于或等于目标代码段描述符的DPL,即在数值上,
CPL, RPL >= 目标代码段描述符的DPL
c. 如果通过jmp far指令使用调用门,则要求CPL和RPL必须等于目标代码段描述符的DPL,即在数值上,
CPL, RPL = 目标代码段描述符的DPL
3.2.3.2 数据段
为了访问数据段,需要将段选择子代入段寄存器DS、ES、FS或GS,成功代入的条件是CPL和RPL都必须高于或等于数据段描述符的DPL,即在数值上,
CPL, RPL <= 目标数据段描述符的DPL
3.2.3.3 栈段
为了访问栈段,需要将段选择子代入段寄存器SS,成功代入的条件是CPL和RPL都必须等于栈段描述符的DPL,即在数值上,
CPL, RPL = 目标栈段描述符的DPL
① 为了安全起见,处理器要求当前使用栈段的描述符特权级必须和当前特权级完全相同
② 如果当前特权级发生了变化(e.g. 通过调用门将控制从低特权级转移到高特权级,相应地就是从调用门返回),则必须切换栈,使其与当前特权级保持一致
TSS中保存了特权级切换时需要使用到的栈段信息,即不同特权级的栈段选择子和栈指针
说明1:任务本级栈段信息并不记录在TSS中
以我们创建的用户程序为例,该任务特权级为3,其本级栈段在加载用户程序时分配,其描述符登记在LDT中,同时将段选择子记录在用户程序头部段中,供用户程序运行时使用
这个栈段信息是不需要记录在TSS中的,因为TSS中记录的栈段信息是在发生特权级切换时供处理器使用,而特权级切换又只能从低特权级到高特权级,因此TSS中只需要记录比任务特权级更高特权级的栈段信息
说明2:为什么TSS中没有特权级3的栈段信息 ?
因为通过调用门实施控制转移时,只有2种可能,
① 转移后和转移前的特权级相同,此时不需要切换栈,也就不需要从TSS种选择新栈
② 从低特权级转移到高特权级,此时需要切换栈,并从TSS中选择和新特权级相同的栈
特权级3是最低的特权级,不可能从其他特权级转移到特权级3,因此也就不需要在TSS中提供特权级3的栈段信息
说明3:剧透一下,保护模式下的中断和异常机制也可能导致当前特权级的变化(e.g. 系统调用或者在用户态被中断打断进入内核态),从而触发栈段的切换
① 首先在加载用户程序时,创建高于用户程序特权级的栈段,将栈段描述符登记在LDT中,同时将栈段选择子和栈指针记录在TCB中,便于后续释放资源
注意其中各个栈段描述符DPL与段选择子RPL的正确设置
⑤ 之后将上述栈段信息登记到TSS中
这里之所以都使用ES段寄存器,因为其指向0 ~ 4GB数据段,而TSS和TCB均是在该段基础上分配而来
假设用户程序通过调用门从特权级3转移控制到特权级0,
① 从当前任务TSS中取出特权级0的栈段选择子和栈指针,并分别传送到段寄存器SS和栈指针寄存器ESP
② 根据加载到SS段寄存器中的栈段选择子在GDT或LDT中查找段描述符
③ 将段描述符的内容加载到SS描述符高速缓存器中,便于处理器后续访问
说明1:栈切换后的状态
① 切换到新栈之后,处理器自动将旧栈的SS和ESP压栈,这样做是为了将来从调用门返回时,可以返回到用户程序原来的栈中
② 之后,处理器根据调用门的设置将旧栈中的参数拷贝到新栈中
此时新栈的状态如下图所示,
注意段选择子是16位的,但是按32位压入,高位补0
说明2:从调用门返回时的栈切换
与上述示例对应,当程序从调用门返回时,处理器会自动切换回原来的旧栈,并返回调用点
说明3:需要特别说明的是,TSS中的栈段信息是静态的,除非软件进行修改,否则处理器不会改变他们
例如通过调用门从特权级3转移到特权级0运行,此时处理器会从TSS中读取SS0 & ESP0进行栈切换。在调用门指向的程序运行过程中,可能会有栈的操作。但是当控制返回时,处理器不会用当前栈信息修改TSS中的SS0 & ESP0
当下次再通过调用门进入特权级0时,仍然使用的是TSS中的静态值
在说明了栈切换的原理与过程后,就可以完整描述通过调用门进行转移控制并返回的过程
① 使用目标代码段的DPL(也就是新的CPL)从当前任务的TSS中选择一个栈,包括栈段选择子和栈指针
② 从TSS中读取所选择的段选择子和栈指针,并用该选择子读取栈段描述符。在此期间,任何违反段界限检查的行为都将引发处理器异常中断(无效TSS)
③ 检查栈段描述符的特权级和类型,并可能引发处理器异常中断(无效TSS)
④ 临时保存当前栈段寄存器SS和栈指针ESP的内容
⑤ 将新的栈段选择子和栈指针代入SS和ESP寄存器,切换到新栈
⑥ 将刚才临时保存的SS和ESP的内容压入新栈
⑦ 根据调用门描述符中"参数个数"字段的指示,从旧栈中将所有参数拷贝到新栈中。如果参数个数为0,则不拷贝参数
⑧ 将当前段寄存器CS和指令指针寄存器EIP的内容压入新栈。通过调用门实施的控制转移一定是远转移,所以要压入CS和EIP
⑨ 从调用门描述符中依次将目标代码段选择子和段内偏移传送到CS和EIP寄存器,开始执行被调用的过程
说明:如果没有改变特权级,则不切换栈,继续使用调用者的当前栈,此时只是在原来的基础上压入当前段寄存器CS和指令指针寄存器EIP的内容
① 检查栈中保存的CS,根据其RPL字段判断返回时是否需要改变特权级
② 从当前栈中读取调用者的CS和EIP压栈值,针对请求特权级(栈中CS的RPL字段)、当前特权级(当前CS的RPL字段)以及目标代码段的描述符特权级(从栈中CS取出描述符索引,读取描述符并检查他的DPL字段)实施特权级检查,以决定因返回而导致的段间转移是否合法
③ 如果retf指令是带参数的(e.g. retf n),则将参数和ESP寄存器的当前值相加,以跳过栈中的参数部分,这个操作是为了让ESP指向调用者的SS和ESP的压栈值
注意:retf指令的参数必须等于调用门中的参数个数乘以每个参数的长度
④ 如果返回时需要改变特权级,则从栈中将SS和ESP的压栈值代入段寄存器SS和栈指针寄存器ESP,切换到调用者的栈。在此期间,一旦检测到有任何越界违例的情况,都将引发处理器异常中断
⑤ 如果远返回指令是带参数的,则将参数和ESP寄存器的当前值相加,以跳过调用者栈中的参数部分,最后的结果是调用者的栈恢复到平衡状态
⑥ 如果返回时需要改变特权级,则需要检查DS、ES、FS和GS寄存器的内容,根据他们找到相应的段描述符。要是段描述符的DPL高于调用者的特权级(也就是返回后的CPL),即在数值上段描述符的DPL < 返回后的新CPL,处理器将把数值0传送到该段寄存器
说明:从高特权级返回后检查数据段寄存器的原因
由于对内存访问权限的检查时机是在加载段选择子时,而不是在每次内存访问时。如果特权级0代码在运行过程中将特权级0数据段加载到段选择子中,那么当控制从调用门返回时,特权级3的代码就可以使用该段选择子继续访问特权级0数据段,而不会进行特权级检查。因此从机制上,就增加了控制返回后的特权级检查
由于0是一个特殊的段选择子,数据段寄存器允许代入,此时不会触发异常。但是如果使用该段选择子访问内存,就会触发异常。从这个角度,也可以理解为什么不能使用全0的段选择子,因为在X86体系结构中他有特殊的作用
当然,在实际编程中,特权级0代码应该在进出时通过压栈 & 出栈来保存 & 恢复会修改到的段寄存器
IO特权级的引入与不同特权级对硬件端口的操作有关,在X86体系结构的构想中,
① 操作系统内核运行在特权级0,拥有最高权限,自然也可以使用in & out指令操作所有端口
② 设备驱动运行在特权级1,端口操作也应该向特权级1开放
③ 对于需要快速反应的场合,即使特权级为3,也需要直接访问某些硬件端口,因此需要向其开发指定端口的操作权限
因此就引入了IO特权级的概念,用于确定任务对端口的操作权限
说明:在实际操作系统的实现中,为了简化实现,并没有遵循X86体系结构的构想。操作系统内核与设备驱动均运行在特权级0;用户程序均运行在特权级3,且不允许访问硬件端口
EFLAGS寄存器中的IOPL位决定了当前任务的IO特权级别,如果当前特权级CPL高于或者等于IOPL,即在数值上,
CPL <= IOPL
则所有IO操作都是允许的,针对所有硬件端口的访问都可以通过
当CPL > IOPL时,也并不意味着所有硬件端口都不能访问,而是由当前任务TSS中的IO许可位串来决定
① TSS可以包含一个IO许可位串,他所占用的区域称为IO许可位映射区
② 在TSS内偏移为102的字单元,保存着IO许可位串的起始地址
③ IO许可位串包含在TSS段范围内,因此,如果该字单元的内容 >= TSS的段界限,则表明没有IO许可位串
④ 如果没有IO许可位串,当CPL > IOPL时,执行任何IO指令都会触发处理器异常中断
说明:在示例代码中,写入TSS第102字单元的内容为TSS段界限,即表示没有IO许可位串
① IO许可位串(IO Permission Bit String)是一个比特序列,最多允许65536比特(因为X86体系结构中的端口范围就是2B,共65536个)
② 从第1个比特开始,各比特用他在串中的位置代表一个端口号。因此,第1个比特代表0号端口,第65536个比特代表65535端口
③ 每个比特的取值决定了相应的端口是否允许访问,为1时禁止访问;为0时,允许访问
④ 处理器检查IO许可位的方式是先计算他在IO许可位映射区的字节编号,并读取该字节,然后进行测试,如果可以访问则放行
说明1:处理器不要求为每个IO端口都提供位映射,对于那些没有在该区域映射的端口,处理器假定他们对应的位为1,也就禁止访问
说明2:IO端口字节编址对IO许可位串检查的影响
在X86体系结构中,IO端口是按字节编址的,也就是说每个端口仅被设计用来读写一个字节的数据。当进行字或者双字进行端口访问时,实际上是访问连续的2个或4个端口,示例如下
in al, 0x3f8 ;访问1个端口
in ax, 0x3f8 ;范围0x3f8 ~ 0x3f9共2个端口
in eax, 0x3f8 ;范围0x3f8 ~ 0x3fb共4个
由于处理器对IO许可位的检查是先读取该位所在的字节,因此,当处理器执行一个字或双字IO指令时,检查的许可位串可能是跨字节的,同时要求相关的比特位均为0,否则引发异常中断
这种跨字节检查就带来一个问题,如果要检查的比特在最后一个字节,那么这个2字节的读操作就会越界(但是要特别注意,此时的IO指令已经访问了IO许可位串范围内的端口了)。因此,处理器要求IO许可位映射区的最后必须附加一个额外的字节,并要求他的所有比特位均为1,也就是0xFF(该字节也在TSS界限范围内)
说明3:由IOPL控制的4条指令
popf / iret / cli / sti 4条指令的执行权限也由IOPL控制,
① 当CPL <= IOPL时,允许执行以上4条指令,也允许访问所有硬件端口
② 当CPL > IOPL时,
a. 执行popf和iret指令会引发处理器异常中断
b. 执行cli和sti时不会引发异常中断,但是不会改变IF位
c. 能否访问特定的IO端口,由TSS中的IO许可位串决定
之所以要限制这4条指令的执行,也是出于保护系统安全的目的,因为他们都会导致EFLAGS寄存器被修改