保护模式
保护模式,是一种80286系列和之后的x86上的CPU操作模式。保护模式是设计用来增强多任务体系、内存保护、分页系统,以及硬件支援的虚拟内存。
1.为什么需要保护模式,因为实模式存在以下缺陷:
安全缺陷:没有特权级,用户进程可以任意修改内存
使用缺陷:由于地址总线的限制,内存只能使用1MB(20位,分段机制)。
要启用保护模式,需要以下三步:
(1)打开 A20 。
(2)加载 gdt 。
(3)将 cr0 寄存器的 pe 位置 1 。
保护模式还提供了一些其它功能:比如寄存器拓展,寻址拓展,指令拓展,运行模式拓展
寄存器拓展:解决内存空间太小的问题
除了段寄存器,其他寄存器在原有基础上向高位拓展了16位,成为了32位寄存器。
由于保护模式拓展了寄存器,成为了32位寄存器,所以任意一个段,光用段内偏移都可以访问4GB空间,不用再变化段基址。
寻址拓展:提供了更方便的寻址功能
实模式下的寻址方式固定死了寄存器,而保护模式下基址寄存器可以是所有32位通用寄存器。
地址 = 段寄存器ds + 偏移地址
基址寄存器bx,bp (bx默认寄存器ds,访问数据段。bp默认段寄存器ss,访问栈)
变址寄存器si,di mov [di],ax ;将ax移入ds:di位置
基址变址寻址:[bx+si]
指令拓展:提供了更多指令,以及某些只能在特权级0下运行的指令。
运行模式拓展:
不同模式下,操作数和寻址方式都各不相同,机器码也不会相同。利用伪指令bits,临时指定编译器所写代码所在的运行模式(16位、32位),编译成对应的机器码。这样在不同模式之间不仅可以使用对方模式下的操作数,还可以使用对方模式下的寻址方式。
GDT
打开保护模式需要加载GDT,全局描述符表就是OS分段机制的体现。
GDT是用来设计来解决访问权限和多任务的问题的。
全局描述符表名字详解:
全局:体现在多个程序都可以在里面定义自己的段描述符,是公用的。
描述符:CPU在在段基址上加了些“约束”。GDT的每一个表项为8字节的段描述符,描述各个内存段的起始地址(段基址)、大小(段界限)、权限(体现了安全,不能随意访问)等信息。
表:可以存放多个表项
段保护:需要的访问权限的信息都需要一个数据结构来存放,在保护模式中,CPU用全局描述符表来存放对多个内存段的描述信息。
保护模式机制详解之内存段的保护:
段保护主要体现在段描述符属性字段中。当CPU操作这片内存之前,先会用这些属性来检查动作的合法性。
1.加载段选择子时的检查段选择子是否超出GDT的数组范围
2.检查段的类型,要加载的段寄存器是否有对应用途(段具有对应的读/写/执行属性才能加载到对应的段寄存器中)
3.访问地址时确认地址在内存段范围内
4.栈段的保护
二、全局描述符表中的成员:
段可以分为系统段和非系统段:
非系统段:供软件使用的段
linux中所有用户进程共享两个段(数据段和代码段),内核也有两个段:这四个段都是平坦寻址模式。4GB空间,但是在读/写/可执行权限上有区别。
Linux的虚拟内存(elf文件格式)下有多种section:BSS,data,rodata,text等
汇编代码中,通常用section或者segment表示一段区域,汇编器会将其在目标文件中编译成section节,链接器将多个目标文件中的section节进行合并,将属性相同的section节合并为一个大的section集合,称为segment段,这就是可执行程序中的代码段和数据段了。操作系统加载程序时(加载器使用ELF中的program
header显示的段),并不关心section节的数量和大小,为所有权限属性相同的节(即合成之后的segment段)分配不同的段选择子,从而指向不同的段描述符,实现不同的访问权限。
可以看到代码段和数据段的开始偏移不一样,但是段基址都是一样的。
其他:
linux使用的是平坦寻址模型,这样对于整个4GB空间,所有的用户进程和内核线程都可以访问全部的4GB空间,段保护功能几乎没什么用,现代内核的内存保护发生在分页单元中,这样可以控制访问的精细粒度,为实现进程间通信提供方便。
linux通过平坦模型绕过分段机制的主要目的是为了可移植性,因为其他CPU不支持分段机制。
TSS:
之前已经提到了TSS是装载在GDT中的一个系统段。
TSS:任务状态段,OS原生支持多任务的实现方式。用于存储任务的环境。
TSS 中只记录 2、 1 、 0 特权级的栈,因为返回地址会被call指令等自动入栈,所以不需要记录3特权级下的栈地址。
TSS是由TR寄存器加载的。
在linux中,一个CPU上的所有任务共享一个TSS。
任务切换:
也就是说,我们把当前的环境伪造成中断现场,往堆栈里压入用户程序的 SS、ESP、EFLAGS、CS、EIP,当执行 iret 指令时,处理器会认为是中断程序执行完毕,于是将堆栈里的各个寄存器值弹出到对应寄存器中,并开始执行用户程序,此时 CPL 也变为用户程序的特权级 3。
特权级与内存保护:
在段寄存器加载段选择子(比如试图执行其他段的代码或者访问其他不在当前段中的内存)、中断发生时会发生特权级检查。
加载段选择子时执行特权级检查,随后访问段内其他地址时会靠段描述符中的属性控制当前段的读写权限。
在CPU设计时要求操作系统是0特权级,系统程序分别位于1级特权和2级特权,运行在这两层的程序一般是虚拟机、驱动程序等系统服务。用户程序是3级特权。事实上,现在的OS都只用到了0和3这两个特权级。
计算机特权级的标签体现在 DPL 、 CPL 和RPL
CPL:处理器的当前特权级,因为代码段寄存器CS中装载的是段选择子,所以当前装载的段选择子的特权级就被称为处理器的当前特权级CS.RPL。当前CS和SS段寄存器中的位0和位1。通常等于当前代码段特权级
DPL:描述符特权级,存在段或门描述符的DPL字段中。
RPL:段选择子第0到1位。代表真正资源请求者的CPL
段保护:
访问数据时的特权级检查:当我们试图访问一个段时,只能由高特权级(0)访问低特权级(3)。
数值上可表示为:RPL、CPL <= DPL
执行代码时的特权级检查:代码段只能平级访问,比如CPL为2,那么只能转移到DPL为2的代码段上运行。
在不同特权级间控制转移:
1.一致性代码段:特权级低的程序可以访问到特权级高的数据.但是特权级不会改变
2.通过四种门,比如引发中断的时候,CPU可以从3特权级到0特权级。
但是无论是通过什么方式转移,都只允许低特权级代码调用(或 JMP)高特权级代码,处理器不允许高特权级代码调用低特权级代码(安全)。如果要实现,就必须模拟某个低特权级调用高特权级代码的中间过程,通过返回的形式,从高特权级转移到低特权级。
真正有用的内存保护发生在分页单元中,即从线形地址转化为物理地址的时候。一个内存页就是由一个页表项(page table
entry)所描述的字节块。页表项包含两个与保护有关的字段:一个超级用户标志(supervisor
flag),一个读写标志(read/write
flag)。超级用户标志是内核所使用的重要的x86内存保护机制。当它开启时,内存页就不能被ring
3访问了。尽管读写标志对于实施特权控制并不像前者那么重要,但它依然十分有用。当一个进程被加载后,那些存储了二进制镜像(即代码)的内存页就被标记为只读了,从而可以捕获一些指针错误,比如程序企图通过此指针来写这些内存页。这个标志还被用于在调用fork创建Unix子进程时,实现写时拷贝功能(copy
on write)。
RPL存在的意义:
使用调用门涉及到段选择子加载段选择子,会检查RPL。尽管通过调用门可以暂时使用高特权级的代码段,但由于RPL标明了它实际上是来自一个低特权级的调用,因此有些功能它不能执行,有些内存不能访问。
而使用中断门就不会检查RPL,因为中断向量号只是一个整数,其中不存在RPL。
特权级参考:
http://www.codebelief.com/article/2018/01/operating-system-privilege-mechanism-detailed-explanation/
https://blog.youkuaiyun.com/drshenlei/article/details/4265101
IDT 中断描述符表:
在实模式使用中断向量表,它是中断处理程序的数组,在保护模式下被中断描述符表代替,用于存储中断处理程序入口的表。表中不仅仅有中断描述符,还可以有任务门描述符和陷阱门描述符。
当通过中断门进入中断后,标志寄存器 eflags 中的 E 位自动置 0,也就是在进入中断后,会自动把中断关闭。
处理器根据中断向量号定位中断门描述符。
处理器进行特权级检查。对于软件主动发起的软中断,当前特权级 CPL 必须在门描述符 DPL 和门中目标代码段 DPL 之间。这是为了防止位于3 特权级下的用户程序主动调用某些只为内核服务的例程。且特权转移只能发生在由低向高。
IDTR:中断描述符表寄存器
中断发生的过程
比如,当前为用户进程,特权级为3。发生中断时,虽然会发生特权级检查,但由于中断向量号没有RPL,所以不会检查RPL。
特权级检查通过后,将门描述符目标代码段选择子加载到代码段寄存器 cs 中(此时已为特权级0),把门描述符中中断处理程序的偏移地址加载到 EIP。
如果发生了特权级转移,处理器接着从TSS中拿到新的SS和ESP值,将旧的SS和ESP值压入内核栈。压入eflags等
执行中断处理。
返回时,iret从栈中弹出数据到寄存器 cs、 eip、 eflags 等,根据特权级是否改变,判断是否要恢复旧栈,也就是说是否将栈中位于 SS_old 和 ESP_old 位置的值弹出到寄存器SS 和 esp。新CS值被装载入了CS寄存器,就可以手动指定CPL。
中断发生过程:
1.处理器根据中断向量号定位中断门描述符。
2.处理器进行特权级检查。中断门的特权检查同调用门类似,对于软件主动发起的软中断,当前特权级 CPL 必须在门描述符 DPL 和门中目标代码段 DPL 之间。
3.中断发生后, eflags 中的 NT 位和 TF 位会被置 0。如果中断对应的门描述符是中断门,标志寄存器 eflags中的 E 位被自动置 0,避免中断嵌套。如果是任务门或者陷阱门,不会清0。
IF位只能限制外部设备的中断,对那些影响系统正常运行的中断都无效
系统调用
有三个门可以被用户模式访问:中断向量3和4分别用于调试和检查数值运算溢出。剩下的是一个系统门,被设置为SYSCALL_VECTOR。对于x86体系结构,它等于0x80。它曾被作为一种机制,用于将进程的控制转移到内核,进行一个系统调用(system call),然后再跳转回来。在那个时代,我需要去申请“INT 0x80”这个没用的牌照 J。从奔腾Pro开始,引入了sysenter指令,从此可以用这种更快捷的方式来启动系统调用了。它依赖于CPU上的特殊目的寄存器,这些寄存器存储着代码段、入口点及内核系统调用处理器所需的其他零散信息。在sysenter执行后,CPU不再进行特权检查,而是直接进入CPL 0,并将新值加载到与代码和堆栈有关的寄存器当中(cs,eip,ss和esp)。只有ring 0的代码enable_sep_cpu()可以加载sysenter 设置寄存器。
进程间的通信
进程间的通信,其本质大多数是利用在内核态里开辟一些空间,通过这些空间来进行数据传输(多次拷贝,将内核态缓冲区的数据拷贝到用户态缓冲区copy_to_user)。而通常常见的如mmap及共享内存则是通过另外两种不同的方式进行的;其中共享内存是通过在一个进程的地址空间中创建一个新的段(共享内存区域),然后把该区域挂载到另一个进程(要与之通信的进程),此时两者的虚拟地址不一定一样,但是它们的物理地址却是一样的,所以它们在内存中只保存一份,他们的访问直接通过挂载后的虚拟地址进行访问,不再需要内核空间的copy,这也是为什么共享内存是进程间通信最快的原因。而mmap则是利用文件来做为中转站,达到数据通信的目的,其本质与共享内存有点相似。