前情提要
我们还是总结一下前面做了什么
1、计算机启动,BIOS将MBR导入到内存,并跳转到相应位置执行
2、MBR将Loader导入到内存,并跳转执行
3、Loader中开启保护模式,准备好GDT表,开启内存分页,从此之后CS中保存的是GDT的选择子,生成的线性地址是虚拟地址了,需要经过页部件的转换才能找到真实的物理地址。Loader将内核导入到内存的 0xc0001500
地址处。
4、Loader跳转到内核执行
上一节,我们在内核里面实现了内核的打印函数,这一节我们讲中断,操作系统中有一个名言就是 操作系统是中断驱动的。
一、什么是中断
在计算机系统中,中断(Interrupt)是一种硬件或软件生成的信号,用于通知处理器某种事件已发生,需要处理器暂时中断当前执行的程序或任务,转而执行相应的中断服务程序(Interrupt Service Routine,ISR)。
当处理器接收到中断信号时,会立即停止当前正在执行的程序,保存当前的执行状态(比如程序计数器、寄存器状态等),然后跳转到对应的中断服务程序开始执行。中断服务程序会处理中断引起的事件,并在处理完成后恢复原来的执行状态,使被中断的程序继续执行。
中断机制可以提高系统的实时性和响应能力,允许处理器及时响应外部事件或异常情况,从而更有效地管理系统资源和处理各种任务。
二、中断分类
中断就是发生了某种事件需要通知CPU处理。所以,不管哪里,只要有事发生就应该让CPU知道。把中断按事件来源分类,来自CPU外部的中断就称为外部中断,来自CPU内部的中断称为内部中断。还可以再细分,外部中断按是否导致宕机来划分,可分为可屏蔽中断和不可屏蔽中断两种,而内部中断按中断是否正常来划分,可分为软中断和异常。
2.1、外部中断
CPU有两根线接收外部中断信号,一个是INTR可屏蔽中断,接收正常的外设中断,一个是NMI不可屏蔽中断,接收一些会导致机器宕机的灾难性错误。只要是从NMI接收的中断,基本全是硬伤,从INTR接收的中断,CPU甚至可以装作不知道。
操作系统是中断驱动的,当发生中断时会执行相应的中断处理程序,我们希望操作系统响应中断的时间越短越好,这样的话可以腾出时间响应更多的中断,但是中断是要完整执行的,所以Linux中将中断处理程序分为上下两个部分。把中断处理程序中需要立即执行的部分(分分钟不能耽误的部分)划分到上半部,这部分是要限时执行的,而中断处理程序中那些不紧急的部分则被推迟到下半部中去完成。
CPU收到中断后,得知道发生了什么事情才能执行相应的处理办法。这是通过中断向量表或中断描述符表来实现的,首先为每一种中断分配一个中断向量号,中断向量号就是一个整数,它就是中断向量表或中断描述符表中的索引下标,用来索引中断项。中断发起时,相应的中断向量号通过NMI或INTR引脚被传入CPU,中断向量号是中断向量表或中断描述符表里中断项的下标,CPU根据此中断向量号在中断向量表或中断描述符表中检索对应的中断处理程序并去执行。
2.2、内部中断
内部中断可以分为软中断和异常,软中断,就是由软件主动发起的中断,因为它来自于软件,所以称之为软中断。异常是指令执行期间CPU内部产生的错误引起的。
2.2.1、软中断
首先看软中断,下面这些是可以发起中断的指令
int 8位立即数
我们以后常用的指令,我们要通过它进行系统调用,256也就是系统支持的中断数
int3
调试断点指令,其所触发的中断向量号是3
into
中断溢出指令,它所触发的中断向量号是4,前提是eflags寄存器中OF位为1才能触发
bound
检查数组索引越界指令,其所触发的中断向量号是5
ud2
未定义指令,其所触发的中断向量号是6
2.2.2、异常
除第一种的“int 8位立即数”之外,其他的几种又可以称为异常。因为它们既具备软中断的“主动”行为,又具备异常的“错误”结果。
下面看异常,异常分为三种
Fault
故障。这种错误是可以被修复的一种类型,属于最轻的一种异常
Trap
陷阱,这一名称很形象地说明软件掉进了CPU设下的陷阱,用于调试
Abort
终止,从名字上看,这是最严重的异常类型,一旦出现,由于错误无法修复,程序将无法继续运行,操作系统为了自保,只能将此程序从进程表中去掉。
看看计算机会提供的一些中断和异常
第一列式中断或者异常的向量号,后面都是一些描述简写,最后一个是错误码,如果值为Y表示CPU会将此中断的错误码压入栈中。
中断机制的本质是来了一个中断信号后,调用相应的中断处理程序。所以,CPU不管有多少种类型的中断,为了统一中断管理,把来自外部设备、内部指令的各种中断类型统统归结为一种管理方式,即为每个中断信号分配一个整数,用此整数作为中断的ID,而这个整数就是所谓的中断向量,然后用此ID作为中断描述符表中的索引,这样就能找到对应的表项,进而从中找到对应的中断处理程序。
三、中断描述符表IDT
中断描述符表(Interrupt Descriptor Table,IDT)是保护模式下用于存储中断处理程序入口的表,当CPU接收一个中断时,需要用中断向量在此表中检索对应的描述符,在该描述符中找到中断处理程序的起始地址,然后执行中断处理程序。
之前其实我们看过中断描述符表的结构,现在我们再看一下
S
位表示这是数据段还是系统段,之前我们都是设置为数据段,现在这里我们设置为代码段了
p
中断处理程序是否在内存中
DPL
表示访问这个门需要的最小特权级
中断门包含了中断处理程序所在段的段选择子和段内偏移地址。当通过此方式进入中断后,标志寄存器eflags中的IF位自动置0,也就是在进入中断后,自动把中断关闭,避免中断嵌套。Linux就是利用中断门实现的系统调用,就是那个著名的int 0x80。中断门只允许存在于IDT中。
以前说过的低端1MB内存布局,位于地址0~0x3ff的是中断向量表IVT,它是实模式下用于存储中断处理程序入口的表。由于实模式下功能有限,运行机制比较“死板”,所以它的位置是固定的,必须位于最低端。大家看到了,已知0~0x3ff共1024个字节,又知IVT可容纳256个中断向量,所以每个中断向量用4字节描述。
对比中断向量表,中断描述符表有两个区别。
(1)中断描述符表地址不限制,在哪里都可以。
(2)中断描述符表中的每个描述符用8字节描述。
既然IDT的位置不固定,当中断发生时,CPU是如何找到它的呢?其实不难想象,你GDT有个专门的寄存器存着,我IDT就不行嘛?中断描述符表寄存器(Interrupt Descriptor Table Register,IDTR),其结构如下
16位的表界限,表示最大范围是0xffff,即64KB。可容纳的描述符个数是64KB/8=8K=8192个。GDT的第一个描述符是不可用的,但是IDT没有这个限制。处理器只支持256个中断,即0~254,中断描述符中其余的描述符不可用。
32位的表基地址,就是IDT的线性基地址。
加载IDTR也有个专门的指令—lidt,其用法和gdtr一模一样。
四、中断处理过程
具体分为四步
(1)处理器根据中断向量号定位中断门描述符。
中断向量号是中断描述符的索引,当处理器收到一个外部中断向量号后,它用此向量号在中断描述符表中查询对应的中断描述符,然后再去执行该中断描述符中的中断处理程序。
(2)处理器进行特权级检查。
由于中断是通过中断向量号通知到处理器的,中断向量号只是个整数,其中并没有RPL,所以在对由中断引起的特权级转移做特权级检查中,并不涉及到RPL。中断门的特权检查同调用门类似,对于软件主动发起的软中断,当前特权级CPL必须在门描述符DPL和门中目标代码段DPL之间。这是为了防止位于3特权级下的用户程序主动调用某些只为内核服务的例程。
(3)执行中断处理程序。
特权级检查通过后,将门描述符目标代码段选择子加载到代码段寄存器CS中,把门描述符中中断处理程序的偏移地址加载到EIP,开始执行中断处理程序。
整体过程如上图所示。
中断发生后,eflags中的NT位和TF位会被置0。如果中断对应的门描述符是中断门,标志寄存器eflags中的IF位被自动置0,避免中断嵌套。若中断发生时对应的描述符是任务门或陷阱门的话, CPU是不会将IF位清0的。因为陷阱门主要用于调试,它允许CPU响应更高级别的中断,所以允许中断嵌套。而对任务门来说,这是执行一个新任务,任务都应该在开中断的情况下进行,否则就独占CPU资源,操作系统也会由多任务退化成单任务了。
IF位只能限制外部设备的中断,对那些影响系统正常运行的中断都无效,如异常exception,软中断,如int n等,不可屏蔽中断NMI都不受IF限制。
NT位表示Nest Task Flag,即任务嵌套标志位,也就是用来标记任务嵌套调用的情况。任务嵌套调用是指CPU将当前正执行的旧任务挂起,转去执行另外的新任务,待新任务执行完后,CPU再回到旧任务继续执行。为什么CPU执行完旧任务后还能回到新任务呢?原因是在执行新任务之前,CPU做了两件准备工作。(1)将旧任务TSS选择子写到了新任务TSS中的“上一个任务TSS的指针”字段中。(2)将新任务标志寄存器eflags中的NT位置1,表示新任务之所以能够执行,是因为有别的任务调用了它。这个别的任务就是“上一个任务TSS的指针”指向的任务。
CPU把新任务执行完后还是要回去继续执行旧任务的,怎样回到旧任务呢?这也是通过iret指令。iret指令因此有了两个功能,一是从中断返回,另外一个就是返回到调用自己执行的那个旧任务,这也相当于执行一个任务。那么问题来了,对同一条iret指令,CPU是如何知道该从中断返回呢,还是返回到旧任务继续执行呢?这就用到NT位了,当CPU执行iret时,它会去检查NT位的值,如果NT位为1,这说明当前任务是被嵌套执行的,因此会从自己TSS中“上一个任务TSS的指针”字段中获取旧任务,然后去执行该任务。如果NT位的值为0,这表示当前是在中断处理环境下,于是就执行正常的中断退出流程。
4.1、修改eflags
修改eflags需要压栈在出栈,这样无法保证数据的一致性,而且由于有内存的参与,效率很低,所以,处理器提供了专门用于控制 IF位的指令,通过它,IF可以直接控制。指令cli使IF位为0,这称为关中断,指令sti使IF位为1,这称为开中断。
4.2、中断处理过程中的压栈
(1)处理器根据中断描述符拿到相应的中断描述符后,就找到了中断处理程序对应的选择子,也就找到了即将跳转的代码的DPL,将当前的CPL与DPL做对比,如果当前CPL的特权级比较低,那么就涉及到低特权级向高特权级的转移,必须保存好旧栈的 SS
和 ESP
的值,这两个值记为 SS_old
与 ESP_old
,然后在TSS中找到同目标代码段DPL级别相同的栈加载到寄存器SS
和ESP
中, 记作SS_new
和ESP_new
,再将之前临时保存的SS_old
和ESP_old
压入新栈备份,以备返回时重新加载到栈段寄存器SS
和栈指针ESP
。SS
为16为数据,为了对齐会被补充到32位。
(2)在新栈中压入EFLAGS
寄存器。
(3)由于要切换到目标代码段,对于这种段间转移,要将CS
和EIP
保存到当前栈中备份,记作CS_old
和EIP_old
,以便中断程序执行结束后能恢复到被中断的进程。同样,CS
寄存器会被对齐到32位。
(4)某些异常会有错误码,记作ERROR_CODE。
如果在第1步中判断未涉及到特权级转移,便不会到TSS中寻找新栈,而是继续使用当前旧栈,因此也谈不上恢复旧栈,此时中断发生时栈中数据不包括SS_old和ESP_old。比如中断发生时当前正在运行的是内核程序,这是0特权级到0特权级,无特权级变化
所以按照顺序压入的是
SS_old -> ESP_old -> EFLAGS -> CS_old -> EIP_old -> ERROR_CODE
处理器进入中断执行完中断处理程序后,还要返回到被中断的进程,这是进入中断的逆过程。中断返回是用iret指令实现的。依次弹出 EIP
,CS
,EFLAGS
,如果这个时候CPU发现CPL发生了变化,那么还会弹出栈寄存器。
注意,如果在返回时需要改变特权级,将会检查数据段寄存器DS、ES、FS和GS的内容,如果在它们之中,某个寄存器中选择子所指向的数据段描述符的DPL权限比返回后的CPL(CS.RPL)高,即数值上返回后的CPL>数据段描述符的DPL,处理器将把数值0填充到相应的段寄存器。原因在介绍调用门时说过啦,原理是选择子为0便指向GDT中第0个段描述符,该段描述符不可用,从而故意使处理器抛异常。
4.3、中断错误码
上面讲了中断发生时会压入一个中断错误码,错误码格式如下
错误码本质上就是个描述符选择子,通过低3位属性来修饰此选择子指向是哪个表中的哪个描述符。
EXT表示EXTernal event,即外部事件,用来指明中断源是否来自处理器外部,如果中断源是不可屏蔽中断NMI或外部设备,EXT为1,否则为0。
IDT表示选择子是否指向中断描述符表IDT,IDT位为1,则表示此选择子指向中断描述符表,否则指向全局描述符表GDT或局部描述符表LDT。
TI和选择子中TI是一个意思,为0时用来指明选择子是从GDT中检索描述符,为1时是从LDT中检索描述符。当然,只有在IDT位为0时TI位才有意义。
选择子高13位索引就是选择子中用来在表中索引描述符用的下标。
有时候不仅错误码的高16位全为0,低16位也全为0,那一个全0的错误码能指明什么信息?当全0的错误码出现时,表示中断的发生与特定的段无关,或者引用了一个空描述符,引用描述符就是往段寄存器中加载选择子的时候,处理器发现选择子指向的描述符是空的。
中断返回时,iret指令并不会把错误码从栈中弹出,所以在中断处理程序中需要手动用栈指针跨过错误码或将其弹出。否则栈顶处若不是EIP(EIP_old)的话,iret返回时将会载入错误的值到后续寄存器。
通常能够压入错误码的中断属于中断向量号在0~32之内的异常,而外部中断(中断向量号在32~255之间)和int 软中断并不会产生错误码。通常我们并不用处理错误码。
结束语
这一节又全是理论,没有代码,但是我个人认为这一节的理论还是比较重要的,他涉及到了我们在中断时的压栈和出栈,以及我们是怎么处理中断的。整个操作系统就是建立在中断之上的。