面试官:说一下进入内核态是什么意思?

这个问题我们从 CPU 的发展史来看可能看得会更明白些

1971 年世界上第一块 4 位 CPU-4004 微处理器横空出世,1974 年 Intel 研发成功了 8 位 CPU-8080,这两款 CPU 都是使用的绝对物理地址来寻址的,指令地址只存在于 IP 寄存器中(即只使用 IP 寄存器即可确定内存地址)。由于是使用绝对物理地址寻址,也就意味着进程之间的内存数据可能会互相覆盖,很不安全,所以这两者只支持单进程

分段

1978 年英特尔又研究成功了第一款 16 位 CPU - 8086,这款 CPU 可以说是 x86 系列的鼻祖了,设计了 16 位的寄存器和 20 位的地址总线,所以内存地址可以达到 2^20 Byte 即 1M,极大地扩展了地址空间,但是问题来了,由于寄存器只有 16 位,那么 16 位的 IP 寄存器如何能寻址 20 位的地址呢,首先 Intel 工程师设计了一种分段的方法:1M 内存可以分为 16 个大小为 64 K 的段,那么内存地址就可以由「段的起始地址(也叫段基址) + 段内偏移(IP 寄存器中的值)」组成,对于进程说只需要关心 4 个段 ,代码段 ,数据段,堆栈段 ,`附加段,这几个段的段基址分别保存在 CS,DS,SS,ES 这四个寄存器中

1fef987d984e4da59acc12e6fc76b744.png
这四个寄存器也是 16 位,那怎么访问 20 位的内存地址呢,实现也很简单,将每个寄存器的值左移四位,然后再加上段内偏移即为寻址地址,CPU 都是取代码段 中的指令来执行的,我们以代码段内的寻址为例来计算内存地址,指令的地址 = CS << 4 + IP ,这种方式做到了 20 位的寻址,只要改变 CS,IP 的值,即可实现在 0 到最大地址 0xFFFFF 全部 20 位地址的寻址 

举个例子:假设 CS 存的数据为 0x2000,IP 为 0x0003,那么对应的指令地址为

567f917979e54057a0c3bfa7fec9cf2b.png
图示为真实的物理地址计算方式,从中可知, CS 其实保存的是真实物理地址的高 16 位 

分段的初衷是为了解决寻址问题,但本质上CS:IP 计算得到的还是真实物理地址,所以它也无法支持多进程,因为使用绝对物理地址寻址意味着进程可以随意修改 CS:IP,将其指向任意地址,很可能会覆盖正在运行的其他进程的内存,造成灾难性后果。

我们把这种使用真实物理地址且未加任何限制的寻址方式称为实模式(real mode,即实际地址模式)

保护模式

实模式上的物理地址由 段寄存器中的段基址:IP 计算而来,而段基址可由用户随意指定,显然非常不安全,于是 Intel 在之后推出了 80286 中启用了保护模式,这个保护是怎么做的呢

首先段寄存器保存的不再是段基址了,而是段选择子(Selector),其结构如下

80064bdc07324155aa2366a38204b9c1.png
其中第 3 到 15 位保存的是描述符索引,此索引会根据 TI 的值是 0 还是 1 来选择是到 GDT(全局描述符表,一般也称为段表)还是 LDT 来找段描述符,段描述符保存的是段基址和段长度,找到段基址后再加上保存在 IP 寄存器中的段偏移量即为物理地址,段描述符的长度统一为 8 个字节,而 GDT/LDT 表的基地址保存在 gdtr/ldtr 寄存器中,以 GDT (此时 TI 值为 0)为例来看看此时 CPU 是如何寻址的 

649e99179444476cab7c6560a189c1a5.png
可以看到程序中的地址是由段选择子:段内偏移量组成的,也叫逻辑地址,在只有分段内存管理的情况下它也被称为虚拟内存 

GDT 及段描述符的分配都是由操作系统管理的,进程也无法更新 CS 等寄存器中值,这样就避免了直接操作其他进程以及自身的物理地址,达到了保护内存的效果,从而为多进程运行提供了可能,我们把这种寻址方式称为保护模式

那么保护模式是如何实现的呢,细心的你可能发现了上图中在段选择子和段描述符中里出现了 RPL 和 DPL 这两个新名词,这两个表示啥意思呢?这就涉及到一个概念:特权级

特权级

我们知道 CPU 是根据机器指令来执行的,但这些指令有些是非常危险的,比如清内存,置时钟,分配系统资源等,这些指令显然不能让普通的进程随意执行,应该始终控制在操作系统中执行,所以要把操作系统和普通的用户进程区分开来

我们把一个进程的虚拟地址划分为两个空间,用户空间和内核空间,用户空间即普通进程所处空间,内核空间即操作系统所处空间

4a5eebdabdd6491b9b84ab6143474513.png
当 CPU 运行于用户空间(执行用户空间的指令)时,它处于用户态,只能执行普通的 CPU 指令 ,当 CPU 运行于内核空间(执行内核空间的指令)时,它处于内核态,可以执行清内存,置时钟,读写文件等特权指令,那怎么区分 CPU 是在用户态还是内核态呢,CPU 定义了四个特权等级,如下,从 0 到 3,特权等级依次递减,当特权级为 0 时,CPU 处于内核态,可以执行任何指令,当特权级为 3 时,CPU 处于用户态,在 Linux 中只用了 Ring 0,Ring 3 两个特权等级 

32b38dd0803e4c3d9b2681b92edc6305.png
那么问题来了,怎么知道 CPU 处于哪一个特权等级呢,还记得上文中我们提到的段选择子吗 

785d673754a443f996b3f6ba108b0687.png
其中的 RPL 表示请求特权((Requested privilege level))我们把当前保存于 CS 段寄存器的段选择子中的 RPL 称为 CPL(current priviledge level),即当前特权等级,可以看到 RPL 有两位,刚好对应着 0,1,2,3 四个特权级,而上文提到的 DPL 表示段描述符中的特权等级(Descriptor privilege level)知道了这两个概念也就知道保护模式的实现原理了,CPU 会在两个关键点上对内存进行保护 

目标段选择子被加载时


当通过线性地址(在只有段式内存情况下,线性地址为物理地址)访问一个内存页时。由此可见,保护也反映在内存地址转换的过程之中,既包括分段又包括分页(后文分提到分页)


CPU 是怎么保护内存的呢,它会对 CPL,RPL,DPL 进行如下检查

44c24fc6c676495c9fcc1524082761c9.png
只有 CPL <= DPL 且 RPL <= DPL(申请特权等级待以及当前特权等级必须比调用的目标代码段的特权级更高,以防普通程序直接调用目标代码段) 时,才会加载目标代码段执行,否则会报一般保护异常 (General-protection exception) 

那么特权等级(也就是 CPL)是怎么变化的呢,我们之前说了 CPU 运行于用户空间时,处于用户态,特权等级为 3,运行于内核空间时,处于内核态,特权等级为 0,所以也可以换个问法 CPU 是如何从用户空间切换到内核空间或者从内核空间切换到用户空间的,这就涉及到一个概念:系统调用

系统调用

我们知道用户进程虽然不能执行特权指令,但有时候也需要执行一些读写文件,发送网络包等操作,而这些操作又只能让操作系统来执行,那该怎么办呢,可以让操作系统提供接口,让用户进程来调用即可,我们把这种方式叫做系统调用,系统调用可以直接由应用程序调用,或者通过调用一些公用函数库或 shell(这些函数库或 shell 都封装了系统调用接口)等也可以达到间接调用系统调用的目的。通过系统调用,应用程序实现了陷入(trap)或者说进入内核态的目的,这样就从用户态切换到了内核态中,如下

1be0b1fb26cc453583f2a3e9d1fe2324.png
那么系统调用又是怎么实现的呢,主要是靠中断实现的,接下来我们就来了解一下什么是中断 

中断

陷入内核态的系统调用主要是通过一种 trap gate(陷阱门)来实现的,它其实是软件中断的一种,由 CPU 主动触发给自己一个中断向量号,然后 CPU 根据此中断向量号就可以去中断向量表找到对应的门描述符,门描述符与 GDT 中的段描述符相似,也是 8 个字节,门描述符中包含段选择子,段内偏移,DPL 等字段 ,然后再根据段选择子去 GDT(或者 LDT,下图以 GDT 为例) 中查找对应的段描述符,再找到段基地址,然后根据中断描述符表的段内偏移即可找到中断处理例程的入口点,整个中断处理流程如下

601cdb1d13c645bbb7138f3da4bbe395.png
画外音:上图中门描述符和段描述符只画出了关键的几个字段,省略了其它次要字段 

当然了,不是随便发一个中断向量都能被执行,只有满足一定条件的中断才允许被普通的应用程序调用,从发出软件中断再到执行中断对应的代码段会做如下的检查

0aaff3b7bda3437692b830823db7a2b0.png
一般应用程序发出软件中断对应的向量号是大家熟悉的 int 0x80(int 代表 interrupt),它的门描述符中的 DPL 为 3,所以能被所有的用户程序调用,而它对应的目标代码段描述符中的 DPL 为 0,所以当通过中断门检查后(即 CPL <= 门描述符中的 DPL 成立),CPU 就会将 CS 寄存器中的 RPL(3) 替换为目标代码段描述符的 DPL(0),替换后的 CPL 也就变成了 0,通过这种方式完成了从用户态到内核态的替换,当中断代码执行后执行 iret 指令又会切换回用户态 

另外当执行中断程序时,还需要首先把当前用户进程中对应的堆栈,返回地址等信息,以便切回到用户态时能恢复现场

可以看到 int 80h 这种软件中断的执行又是检查特权级,又是从用户态切换到内核态,又是保存寄存器的值,可谓是非常的耗时,光看一下以下图示就知道像 int 0x80 这样的软件中断开销是有多大了

34012701d84b47258639f120312e03d1.png
所以后来又开发出了 SYSENTER/SYSCALL 这样快速系统调用的指令,它们取消了权限检查,也不需要在中断描述表(Interrupt Descriptor Table、IDT)中查找系统调用对应的执行过程,也不需要保存堆栈和返回地址等信息,而是直接进入CPL 0,并将新值加载到与代码和堆栈有关的寄存器当中(cs,eip,ss 和 esp),所以极大地提升了性能 。

猜你喜欢

转载自blog.csdn.net/m0_72088858/article/details/127275274