首发公众号:Rand_cs
中断理论部分
中断是硬件和软件交互的一种机制,可以说整个操作系统,整个架构都是由中断来驱动的。中断的机制分为两种,中断和异常,中断通常为 $IO$ 设备触发的异步事件,而异常是 $CPU$ 执行指令时发生的同步事件。本文主要来说明 $IO$ 外设触发的中断,总的来说一个中断的起末会经历设备,中断控制器,$CPU$&$OS$ 三个阶段:设备产生中断,中断控制器接收和发送中断,$CPU$&$OS$ 来实际处理中断。
本文来捋一捋中断需要知道的一些理论知识,主要也是从这三个阶段来说,$emmm$实际两个阶段,其中第一个阶段设备如何产生信号不讲,超过了操作系统的范围,也超过了我的能力范围。不过在讲解各个硬件比如说磁盘,串口时会涉及到一些这些硬件何时会触发中断。各种硬件外设有着自己的执行逻辑,有各种形式的中断触发机制,比如边沿触发,电平触发等等。总的来说就是向中断控制器发送一个中断信号,中断控制器再作翻译发送给 $CPU$,$CPU$ 再执行中断服务程序对中断进行处理。
说到中断控制器,是个什么东西?中断控制器可以看作是中断的代理,外设是很多的,如果没有一个中断代理,外设想要给 $CPU$ 发送中断信号来处理中断,那只能是外设连接在 $CPU$ 的管脚上,$CPU$ 的管脚是很宝贵的,不可能拿出那么多管脚去连接外设。所以就有了中断控制器这个中断代言人,所有的 $IO$ 外设连接其上,发送中断请求时就向中断控制器发送信号,中断控制器再通知 $CPU$,如此便解决了上述问题。
中断控制器的发展可分为 $PIC$ 和 $APIC$ 两个阶段,前者适用于单处理器,在单处理器的时代叱咤风云,风靡全球,不过到了现代的多处理器时代不行了,渐渐地被更高级的 $APIC$ 所代替。但 $PIC $还是值得了解了解的,来看看
中断控制器 PIC
这就是中断控制器 $8259A$ 芯片,我们只需要了解:
- $IRQ0-IRQ7$,8 个引脚,每个引脚可以连接一个外设,也就是说一个 $8259A$ 能支持 8 个中断。
- $8259A$ 通过 $INT$,$INTA$($Interrupt$ $Acknowledge$) 与 $CPU$ $INTR$($Interrupt$ $Request$)通信
一个 8259A 芯片只能支持 8 个中断,的确有点少了,所以有了级联:
所谓级联,就是将多个 $8259A$ 给串起来,通常方式为将一个 $8259A$ 的引脚分出来连接另一个 $8259A$,就如上图所示。一般两个 $8259A$ 级联的时候将主 $8259A$ 的 $IRQ2$ 分配出去连接从 $8259A$。
$8259A$ 的引脚 $IRQ$ 是有优先级的,编号越低,优先级越高,$vector=IRQ编号+32$,前 32 个是留给异常使用和架构保留的。
$8259A$ 最重要的几个寄存器如下所示:
寄存器
- $IRR$,$Interrup$ $Request$ $Register$,共 8 位,对应 $IRQ0-IRQ7$ 8 个中断引脚。当某个引脚接收到中断信号时,$IRR$ 中相应的位 置 1,表示 $PIC$ 已收到该设备的中断请求但还未交由 $CPU$ 处理
- $ISR$,$In$ $Service$ $Register$,共 8 位,同样的每位表示对应引脚表示的中断,当 $IRR$ 中某个中断请求提交给 $CPU$ 时,IRR 相应的位被清 0,$ISR$ 相应的位就被置 1,表示 $CPU$ 正处理该中断。
- $IMR$,$Interrupt$ $Mask$ $Register$,共 8 位,同样的每位表示对应引脚表示的中断,某位置 1 表示屏蔽相应的中断,清 0 表示允许相应的中断。
- $PR$,$Priority$ $Register$,优先级仲裁寄存器,有多个中断同时发生时,它能找出哪个中断优先级最高
- $EOI$,$End$ $of$ $Interrupt$,在 $PIC$ 里面 $EOI$ 不是一个寄存器,它是操作命令寄存器中的一个 $bit$,写 $EOI$ 表示中断结束。
$8259A$ 还有其他的寄存器比如初始化命令寄存器,操作命令寄存器,这里我们后面讲述 $xv6$ 并不会使用 $PIC$,所以这里也就不说明了,最后来看看通过 $PIC$ 的中断流程:
PIC 中断流程
- $IRQ$ 引脚接收到中断信号,若该中断没有被屏蔽,那么 $IRR$ 中相应的位置 1
- $PIC$ 通过 $INT$ 向 $CPU$ 发送 $INTR$ 信号
- $CPU$ 通过 $INTA$ 引脚发送应答信号给 $PIC$
- $PIC$ 收到应答信号后,将 $IRR$ 中最高优先级的中断相应的位清 0,将 $ISR$ 相应的位置 1
- $CPU$ 再次发送 $INTA$ 信号给 $PIC$,$PIC$ 收到后将 $vector$ 送到数据线
- $CPU$ 根据 $vector$ 索引 $IDT$ 中的门描述符,执行中断服务程序
- 中断处理完成之后写 $EOI$,将 $ISR$ 中相应的位清 0 表示中断完成
高级中断控制器 APIC
上述就是中断控制器 $PIC$ 的内容,PIC
只用于单处理器,对于如今的多核多处理器时代,PIC
无能为力,所以出现了更高级的中断控制器 APIC
,APIC
($Advanced$ $Programmable$ $Interrupt$ $Controller$) 高级可编程中断控制器,APIC
分成两部分 LAPIC
和 IOAPIC
,前者 LAPIC
位于 $CPU$ 内部,每个 $CPU$ 都有一个 LAPIC
,后者 IOAPIC
与外设相连。外设发出的中断信号经过 IOAPIC
处理之后发送给 LAPIC
,再由 LAPIC
决定是否交由 $CPU$ 进行实际的中断处理。
可以看出每个 $CPU$ 上有一个 LAPIC
,IOAPIC
是系统芯片组一部分,各个中断消息通过总线发送接收。关于 APIC
的内容很多也很复杂,详细描述的可以参考 $intel$ 开发手册卷三,本文不探讨其中的细节,只在上层较为抽象的层面讲述,理清 APIC
模式下中断的过程。
下面就分别来看看 $IOAPIC$ 和 $LAPIC$:
IOAPIC
$IOAPIC$ 主要负责接收外部的硬件中断,将硬件产生的中断信号翻译成具有一定格式的消息,然后通过总线将消息发送给一个或者多个 LAPIC。$IOAPIC$ 主要组成如下:
- 24 个中断管脚,一个 $IOAPIC$ 支持 24 个中断
- 一张 24 项的中断重定向表($PRT$,$Programmable$ $Redirection$ $Table$),每个表项都是一个 64 位的寄存器
- 一些可编程寄存器,例如窗口寄存器,版本寄存器等等
- 通过 $APIC$ 总线发送和接收 $APIC$ 信息的一个信息单元
我们了解一个硬件主要就是了解它的寄存器,$IOAPIC$ 有两个内存映射的寄存器,$index$ 和 $data$ 寄存器,通过 $index/data$ 的方式访问 $IOAPIC$ 的其他寄存器。
$IOAPIC$ 其他寄存器一览:
内存映射的两个寄存器
这两个寄存器是内存映射的,IOREGSEL
,地址为 $0xFEC0\ 0000$;IOWIN
,地址为 $0xFEC0\ 0010h$。IOREGSEL
用来指定要读写的寄存器,然后从 IOWIN
中读写。也就是常说的 index/data
访问方式,或者说 $adress/data$,用 index
端口指定寄存器,从 data
端口读写寄存器,data
端口就像是所有寄存器的窗口。
而所谓内存映射
,就是把这些寄存器看作内存的一部分,读写内存,就是读写寄存器,可以用访问内存的指令比如 mov
来访问寄存器。还有一种是 IO端口映射
,这种映射方式是将外设的 IO端口(外设的一些寄存器)
看成一个独立的地址空间,访问这片空间不能用访问内存的指令,而需要专门的 in/out
指令来访问。
IOAPIC 寄存器
ID Register
索引为 0
$bit24 - bit27$:ID
Version Register
- 索引为 1
$bit0-bit7$ 表示版本,
$bit16-bit23$ 表示重定向表项最多有几个,这里就是 23(从 0 开始计数)
Redirection Table Entry
重定向表项,IOAPIC
有 24 个管脚,每个管脚都对应着一个 64 位的重定向表项(也相当于 64 位的寄存器),索引为 $0x10-0x3F$,重定向表项的格式如下所示:
这个表项/寄存器包含了该中断的所有属性信息,以什么方式触发中断,传送的方式状态,管脚极性等等,这是 $ZX_WING$ 大佬在他的 $Interrupt\ in\ Linux$ 中总结出来的,很全面也很复杂,只说几点:
- $destination$ $field$ 和 $destination$ $mode$ 字段决定了该中断发送给哪个或哪些 $LAPIC$
- $vector$,中断控制器很重要的一项工作就是将中断信号翻译成中断向量,这个中断向量就是 $IDT$ 的索引,$IDT$ 里面的中断描述符就存放着中断处理程序的地址。在 $PIC$ 中,$vector = IRQ编号+32$,而在 $APIC$ 模式下,$IRQ$ 对应的 $vecotr$ 由操作系统对 $IOAPIC$ 初始化的时候设置分配。
- $IOAPIC$ 的管脚没有优先级之分,不像 $PIC$ 的 $IRQ0$ 的优先级比 $IRQ1$ 的优先级要高,而 $IOAPIC$ 对中断优先级的区分在于管脚对应的重定向表项的 $vector$ 字段。
IOAPIC 总结
由上,对 $IOAPIC$ 的工作总结:当 $IOAPIC$ 的管脚接收到外设发来的中断信号后,根据相应的重定向表项格式化出一条中断消息,然后发送给 $destination$ $field$ 字段列出的 $LAPIC$。
LAPIC
LAPIC
要比 IOAPIC
复杂的多,先看张总图:
本文不会说这么多,也不可能说这么多,有的我也是不太明白,超出我的能力范围,这里只是来看看对其简单编程需要了解的部分:
$LAPIC$ 其主要功能是接收中断消息然后交由 $CPU$ 处理,再者就是自身也能作为中断源产生中断发送给自身或其他 $CPU$。所以其实 $LAPIC$ 能够收到三个来源的中断:
- 本地中断:时钟,温度监测等
- 外部中断:$IOAPIC$ 发来的
- $IPI$:处理器间中断,其他 $LAPIC$ 发来的
$inel$ 手册里面做了更精细复杂的分类,私以为了解这三大类就行了。了解 $LAPIC $从它的一些重要寄存器入手,通过这些寄存器的作用来了解 LAPIC 如何工作的:
IRR(Interrupt Request Register)
中断请求寄存器,256 位,每位代表着一个中断。当某个中断消息发来时,如果该中断没有被屏蔽,则将 $IRR$ 对应的 bit 置 1,表示收到了该中断请求但 $CPU$ 还未处理。
ISR(In Service Register)
服务中寄存器,256 位,每位代表着一个中断。当 $IRR$ 中某个中断请求发送给 $CPU$ 时,$ISR$ 对应的位上便置 1,相应的 $IRR$ 位清零,表示 CPU 正在处理该中断。
TMR(Trigger Mode Register)
触发模式寄存器,256 位,每位代表一种中断的触发模式,若中断的触发模式为 $edge$(边沿)触发则相应的位清 0,若触发模式为电 $level$(电平) 触发则置 1。
上面三种寄存器如下图所示:
EOI(End of Interrupt)
中断结束寄存器,32 位,写 EOI 表示中断处理完成。写 $EOI$ 寄存器会导致 $LAPIC$ 清理 $ISR$ 的对应的位,对于 $level$ 触发的中断,还会向所有的 $IOAPIC$ 发送 $EOI$ 消息,通告中断处理已经完成,通常写 0 就行。
ID
用来唯一标识一个 $LAPIC$,$LAPIC$ 与 $CPU$ 一一对应,所以也用 $LAPIC$ $ID$ 来标识 $CPU$。$APIC$ 分为 $LAPIC$ 和 $IOAPIC$,但是如上图手册所示 $LAPIC$ $ID$ 一般叫做 $APIC$ $ID$,这里我为了将两者区分开就写得是 $LAPIC$ $ID$
TPR(Task Priority Register)
任务优先级寄存器,确定当前 $CPU$ 能够处理什么优先级别的中断,$CPU$ 只处理比 $TPR$ 中级别更高的中断,比它低的中断暂时屏蔽掉,也就是在 $IRR$ 中继续等到,直到 $TPR$ 的优先级下降到低优先级中断能够被处理。这个机制使得操作系统能够暂时屏蔽低优先级中断,防止打扰高优先级的处理。
一共有 256 种中断,所以 $TPR$ 只用到了 $bit0-bit7$,另外 $优先级别=vector/16$,$vector$ 为每个中断对应的中断向量号。所以 $Task$ $Priority$ 的高 4 位表示优先级别,有 $0-15$ 个取值。
如果优先级别设置为 15,则不会接受任何中断,如果优先级别设置为 0,表示接受所有中断,这也是 $Linux$ 设置的默认值。另外一些特殊中断如 $NMI$ 不可屏蔽的中断不受 $TPR$ 的规则限制。
PPR(Processor Priority Register)
处理器优先级寄存器,表示当前正处理的中断的优先级,$PPR$ 的值为 $ISR$ 中正服务的最高优先级中断和 $TPR$ 两者之间选取优先级较大的
IF TPR[7:4] ≥ ISRV[7:4]
PPR[7:0] = TPR[7:0]
ELSE
PPR[7:4] = ISRV[7:4] AND PPR[3:0] = 0
在 $IRR$ 中等待的中断,只有优先级别高于 $PPR$ 的才会被送到 $CPU$ 处理,所以 $TPR$ 就是靠间接控制 $PPR$ 来实现暂时屏蔽比 $TPR$ 优先级小的中断的。
SVR(Spurious Interrupt Vector Register)
伪中断寄存器,$CPU$ 每响应一次 $INTR$(可屏蔽中断),就会连续执行两个 $INTA$ 周期。在 $MP\ Spec$ 中有描述,当一个中断在第一个 $INTA$ 周期后,第二个 $INTA$ 周期前变为无效,则为伪中断,也就是说伪中断就是中断引脚没有维持足够的有效电平而产生的。
这里主要用到 $bit8$,可以通过将这位置 1 来使 $APIC$ 工作,原话 $To$ $enable$ $the$ $APIC$。
ICR(Interrupt Command Register)
中断指令寄存器,当一个 $CPU$ 想把中断发送给另一个 $CPU$ 时,就在 $ICR$ 中填写相应的中断向量和目标 $LAPIC$ 标识,然后通过总线向目标 $LAPIC$ 发送消息。$ICR$ 寄存器的字段和 $IOAPIC$ 重定向表项较为相似,都有 $destination$ $field$, $delivery$ $mode$, $destination$ $mode$ 等等。
这是处理器之间发送中断消息,所以又叫 $IPI$($InterProcessor$ $Interrupt$)消息。在启动的时候我们就用到了这个寄存器,$BSP$ 向 $APs$ 发送 $IPI$ 消息,当时 $BSP$ 发送了 $INIT-SIPI-SIPI$ 消息给 AP,这里我们就清楚了实际就是设置 $delivery$ $mode$ 为 $INIT$、$Start-up$。当然这期间还根据规范设置了其他字段。
本地中断
$LAPIC$ 本身还能作为中断源产生中断,$LVT$($Local$ $Vector$ $Table$) 就是自身作为中断源的一个配置表,总共 7 项,每项 32 位,同 $IOAPIC$,每一项也是一个寄存器,如下所示:
我们主要关注时钟中断,$APIC$ 自带一个时钟,我们可以在 $LVT$ 中配置 $Timer$ 一项来使用这个时钟。
$Timer$ $Mode$,设置时钟计数的模式,$one$-$shot$,一次性的倒数计时,$periodic$,周期性的倒数计时,$TSC$-$deadline$,使用 64 位的时间戳计数器。
一般使用 $periodic$ 来周期性的产生时钟中断,周期性的从某个数递减到 0,如此循环往复。这个数设置在 $TICR$ 寄存器
$TICR$ 寄存器里面存放 $Initial$ $Count$,也就是从哪个数开始倒数。而 $Current$ $Count$ 存放当前初始计数值,每当计时器 $count$ 到 0 ,产生时钟中断时,$Current$ $Count$ 就会自动地从 $Intial$ $Count$ 重新加载,接着新一次的倒数,所以其实 $Current$ $Count$ 似乎没什么用,xv6 里面也没用到这个寄存器。另外如果向 $TICR$ 寄存器里面写 0 的话会停止计时器。
递减得有个频率,这个频率是系统的总线频率再分频,分频系数设置在 $TDCR$ 寄存器
LAPIC 总结
对上面 $LAPIC$ 的寄存有一定了解后,对 $LAPIC$ 的工作应该也有一定了解了,对于从 $IOAPIC$ 发来的中断消息,首先判断自己是否接收这个消息,这要根据重定向表项中的 $destination$ $field$,$destination$ $mode$ 来判断:
$destination$ $mode$ 为 0 时表示物理模式,$destination$ $field$ 字段表示一个 $APIC$ $ID$,$LAPIC$ 根据 $ID$ 寄存器比对判断是否由自己来接收。
$destination$ $mode$ 为 1 时表示逻辑模式,$LAPIC$ 需要另外两个寄存器 $LDR$ 和 $DFR$ 来辅助判断,具体判断方式很复杂,逻辑模式分为 $flat$ 和 $cluster$,$cluster$ 又分为 $flat$ $cluster$ 和 $hierachical$ $cluster$,了解就好,感兴趣的参考手册或者 $interrupt$ $in$ $linux$ ,有着很详细的讲解,这里不赘述。
判断不该自己接收就忽略,否则接收该中断, $IRR$,$ISR$,$TPR$,$PPR$,$EOI$ 等寄存器配合使用,来决定是否将该中断发送到 $CPU$ 进行处理,此外如果中断类型为 $NMI$ 等特殊中断是直接发送给 $CPU$ 进行处理,不需要上述步骤。
要实现处理器间中断,一个处理器想把中断发送给另一个处理器时,就在 $ICR$ 中填写相应的中断向量和目标 $Destination$ $Feild$,然后通过总线向目标 $LAPIC$ 发送消息。
$LAPIC$ 自己也可以作为中断源,可在 $LVT$ 中配置相关中断,主要留意时钟中断的设置,$xv6$ 就是使用 $LAPIC$ 自带的时钟来周期性产生时钟中断。
APIC 部分中断流程总结
$IOAPIC$ 某个引脚收到了对应外设发出的中断信号
$IOAPIC$ 根据引脚对应的重定向表项,将中断信号翻译成中断消息,然后发送给 $destination$ $field$ 字段列出的 $LAPIC$
$LAPIC$ 根据消息中的 $destination$ $mode$,$destination$ $field$,自身的寄存器 $ID$,$LDR$,$DFR$ 来判断自己是否接收该中断消息,不是则忽略
如果该中断是 $SMI$/$NMI$/$INIT$/$ExtINT$/$SIPI$,直接送 $CPU$ 执行,因为这些中断都是负责特殊的系统管理任务。否则的话将 $IRR$ 相应的位置 1,等待 $CPU$ 来处理。
从 $IRR$ 中挑选优先级最大的中断,相应位置 0,$ISR$ 相应位置 1,然后送 $CPU$ 执行。
$CPU$ 执行 $OS$ 中的中断服务程序来处理中断。
中断处理完成后写 $EOI$ 表示中断处理已经完成,写 $EOI$ 导致 $ISR$ 相应位置 0,对于 $level$ 触发的中断,还会向所有的 $IOAPIC$ 发送 $EOI$ 消息,通知中断处理已经完成。
CPU AND OS 处理中断
上述为中断控制器部分,主要功能就是接收外设的中断信号,然后交由 $CPU$ 来处理。最开始我把这部分只归结到了 $CPU$ 部分,后面想想处理中断是个软硬件协作的一个过程,有 $CPU$ 硬件部分,也有 $OS$ 软件部分,但总归是真正处理中断的过程,我就把它们归结到一起了。
在实际谈处理中断前先来了解与中断相关的数据结构
中断描述符表&门描述符&中断向量
中断描述符表 $IDT$ 里面存放的是门描述符,有三种门描述符,任务门,中断门,陷阱门:
任务门和任务状态段是 $intel$ 最开始提供的一种任务切换机制,可以使用任务门来切换任务,但因效率低下,现已经不使用,这部分在进程一节中还会提及。
中断门和陷阱门几乎一模一样,从描述符的结构来看就只有 $bit8-bit11$ 的 $TYPE$ 字段不一样,实际上从运行过程上说两者的唯一区别是中断门会影响 $EFLAGS$ 的 $IF$ 位,而陷阱门不会。
通过中断门访问中断服务程序时,$CPU$ 会对 $EFLAGS$ 的 $IF$ 位清 0,即不允许其他中断打扰当前中断的执行,也就是中断的执行过程中关中断,在通过 $iret$ 指令从中断返回时恢复 $IF$ 位。而这里的恢复是指弹出栈中保存的 $eflags$ 值到 $EFLAGS$ 寄存器,这在后面中断流程再详述。
中断门和陷阱门的格式与 $GDT$ 中的段描述符很相像,段描述符描述符了一个段的位置和属性,同样的门描述符也描述了一个段的位置和属性。段的意思很灵活,就是指内存的一段数据信息,不是说只有代码段数据段才叫段,这里门描述符指向的段就是中断服务程序。
$intel$ 手册里面这个图画的不是很全,我重画了一张,跟段描述符差不多:
- $DPL$,描述符特权级,与 $RPL$ $CPL$ 一起作特权级检查
- $P$,该段在内存中是否存在,0 不存在,1 存在
- $S$,0 表示系统段,1 表示非系统段,门结构都是系统段
- $TYPE$,类型字段
段选择子:段内偏移
指示中断服务程序的地址
定位中断服务程序
所以 $IDT$ 里面就存放着这些门描述符,门描述符又指向一个中断服务程序。在 $GDT$ 中有段选择子来充当索引指向一个段描述符,在 $IDT$ 中也有类似的结构,那就是中断向量 $vector$。它也是上述中断控制器 $APIC$ 中多次出现的那个东西。
所以这里我们对中断控制器应有一个更清晰的认识,中断控制器就是接收中断然后将该中断的 $vector$ 传给 $CPU$,$CPU$ 就会去 $IDT$ 中索引门描述符,根据其中记录的 段选择子:段内偏移
获取中断服务程序,然后执行处理中断。
至于如果从逻辑地址 段选择子:段内偏移
经过段级转换到线性地址,线性地址又经过页级转换到物理地址,这个过程现在大家应该很熟悉了,就不再赘述,如果不是很清楚,可以看看前面启动部分的前导理论。
来看张定位中断服务程序的示意图:
IDT&IDTR
同 $GDT$ 有个 $GDTR$ 指示 $GDT$ 的位置,$IDT$ 也有个 $IDTR$ 指示 $IDT$ 的位置
$IDTR$ 里面存放着 $IDT$ 的地址和界限,很简单,一眼过去应该就能明白,不多说。同 $GDTR$ 有 lgdt 指令来加载 $GDT$ 的位置信息,IDTR 也有 $lidt$ 来加载 $IDT$ 的位置信息,指令格式为 LIDT m16&32
CPU AND OS部分的中断流程
$CPU$ 和 OS 部分处理中断从 $CPU$ 获取到中断控制器发来的 $vector$ 开始,这部分的中断流程可以分为三个步骤:
- 保存上下文
- 执行中断处理程序
- 恢复上下文
保存上下文
保存上下文又分为两部分:
- 硬件 $CPU$ 部分
- 软件 $OS$ 部分
CPU 部分
$CPU$ 根据 $vector$ 索引门描述符,它会根据描述符的 $DPL$,描述符选择子中的 $RPL$,$CS$ 不可见部分的 $CPL$ 进行特权级检查,这里特权级检查很复杂,不是之前说的一般情况下只需要判断 $DPL \ge CPL\ \ \ \&\&\ \ \ DPL \ge RPL$ 即可,但主要一点就是判断是否有特权级转移,我们一般就是用两种特权级,内核态 0,用户态 3。所以这里就是判断发生中断的前一刻处于哪个特权级,如果处于用户态那么就要从用户态进入内核态,否则就不需要。
保存上下文是保存在栈里面,如果没有特权级转移,发生中断前本身就在内核,那么就使用当前的内核栈保存上下文。如果有特权级转移,发生中断前处于用户态,那么就要进入内核态,进入用户态的一个重要标识就是换栈,换成内核栈。
什么叫做换栈,换栈就是更改 $SS$ 和 $ESP$ 寄存器的值,换成内核栈就是将这两个寄存器换成内核栈的 $ss$ 和 $esp$,关键问题是:内核栈的 $ss$ 和 $esp$ 在哪?在 $TSS$ 里面,$TSS$,$Task$ $Segment$ $State$,任务状态段,这个结构我们后面进程会详细讲述,这里就只需要知道,$TSS$ 里面有用户态进入内核时需要的内核栈位置信息。
好了现在栈已经换成中断要使用的栈了,$CPU$ 需要在里面保存上下文,有哪些呢?如下图所示:
上下文也分两种,一种是发生了特权级转移的情况,一种是未发生的情况,发生特权级转移的情况多了 $ss_old$ 和 $esp_old$,也很好理解,换了栈,当然要把旧栈信息保存到新栈对吧。
$eflags$ 表示中断前的一些标识信息,$cs_old$,$eip_old$ 表示中断点,中断退出后任务就会从这儿继续执行。
$error_code $表示错误码,有些中断会产生错误码
其主要部分就是选择子,所以它用来指明中断发生在哪个段上,其他字段 $IDT$ 表示是否指向 $IDT$,为 1 表示指向 $IDT$,0 表示指向 $GDT$/$LDT$,$TI$ 为 0 表示 $GDT$,$TI$ 为 1 表示 $LDT$,$EXT$ 表示中断源是否来自外部。
需要了解的是 Page Fault,缺页异常有错误码,它的错误码最后三位有不同的意思:
- bit0 U/S :
- bit1 W/R:
- bit2 P:
?????????????
我们关注错误码不是因为它有什么作用,当然有用也是有用的,只是这里主要关注它引起的格式问题,虽然有错误码的话 $CPU$ 会自动压入,但是 $iret$ 时 $CPU$ 不会自动弹出,$iret$ 时 $ESP$ 应指向 $eip_old$,所以错误码需要我们手动弹出。那没有错误码的怎么办呢?为此,我们手动地也压入一个值 0,那么栈里面的结构就统一了,如上图所示。栈中结构同意了,我们的操作也就统一了,$iret$ 前先把 $error_code/0$ 给弹出去,再执行 $iret$ 指令。
OS 部分
中断服务程序我也分为了三部分,中断入口程序,中断处理程序,中断退出程序。而中断入口程序主要就是用来保存上下文的,这里所谓的上下文就是各类寄存器的值,通常要高效的话,可以选择性的保存,但是省事简单的话直接一股脑儿地全保存了也没什么问题。另外通常这部分也把向量号 $vector$ 也压进去,比如 $xv6$ 里就是保存完上下文之后就是这样:
执行中断处理程序
这部分其实没啥说的,就是 $CPU$ 来执行一个程序来处理中断,不同的中断处理程序肯定是不同的,实际的中断处理程序分布在各个章节详细讲述,这里就这样,了解流程就行。
恢复上下文
这部分其实也没啥说的,因为就是保存上下文的逆操作,我同样的分为两部分
先是 $OS$ 软件部分,执行中断退出程序,弹出通用寄存器,段寄存器等上下文
接着就是硬件 $CPU$ 部分,执行 $iret$ 弹出 $cs_old$, $eip_old$, eflags, ($ss_old$, $esp_old$,有特权级转移的话)。
开关中断
这里再说说开关中断的问题,$CPU$ 是能够屏蔽可屏蔽中断的,就是通过 $EFLAGS$ 的 $IF$ 位,$IF$ 位为 1 表示允许中断,$IF$ 为 0 表示屏蔽中断。
所以开关中断就是修改 $EFLAGS$ 的 $IF$ 位,有这么几种方式修改 $EFLAGS$ 寄存器值:
- $sti$ 指令将 $EFLAGS$ $IF$ 位置 1,$cli$ 指令将 $EFLAGS$ $IF$ 位清 0,这两个指令有使用条件:$CPL \le IOPL$,IOPL 也是 ELFAGS 里面的位域,指的是 IO 特权级,这里不深入展开,到进程一块讲述 $TSS$ 的时候还会提及
- $pushf$ 指令将 $EFLAGS$ 压栈,还有中断时 $EFLAGS$ 也会压栈,压入栈中后我们就可以修改栈里面的 $EFLAGS$ 的值,待到后续 $popf$ 或者 $iret$ 中断退出将修改后的 $EFLAGS$ 弹出后,就相当于修改了 $EFLAGS$ 的值。
- 通过中断门进入中断时会将 $EFLAGS$ 的 $IF$ 位清 0.
通过更改 $EFLAGS$ 的 $IF$ 位来开关中断就只有这三种方法,所以通常我们处理中断时并不需要额外地做开关中断处理,为什么呢?因为硬件 $CPU$ 已经帮我们做了,通过中断门进入中断时自动地关闭中断,然后 $iret$ 后又恢复中断。
中断流程总结
私以为上述说的中断流程应是很清楚的,只不过像对什么中断处理的分类,中断服务程序分类是我自己杜撰的,可能与您平时看到的不甚一样,不过我认为这样来看是要清楚些,这里将上述说的总结一番:
- 根据 $vector$ 去 $IDT$ 中索引相应的门描述符
- 判断特权级是否发生变化,如果中断发生在用户态,则需要换成内核栈,切换到内核态
- 若发生特权级变化,需要保存用户态的 $ss_old$,$esp_old$ 到内核栈,否则不需要保存,然后再保存 $eflags$,$cs_old$,$ip_old$ 到内核栈中,如果有错误码,还要将错误码压进栈中
- 根据门描述符中的段选择子再去 $GDT$ 中索引相应的段描述符得到段基址,与中断描述符里的段偏移量结合起来找到中断服务程序
- 中断入口程序保存上下文,中断处理程序实际处理中断,中断退出程序恢复一部分上下文
- 最后执行 $iret$ 恢复 $CPU$ 保存的上下文,如果有特权级转移,则换到用户栈回到用户态。
- 中断完成,接着原任务执行
软件中断
最后这一部分简单说说软件中断($Software$-$Generated$ $Interrupts$),注意软件中断和 $Linux$ 里面上下半部分中的软中断机制是两个不同的概念。这里软件中断指的就是 $INT$ $n$ 指令来模拟一个 $n$ 号中断。以前 $Linux$ 中系统调用就是使用 $int$ 0x80 来实现的。
软件中断源来自内部,所以它的处理流程没有中断控制器这个部分,而剩下的 $CPU$&$OS$ 部分可以说是一模一样,$int$ $n$,这个 $n$ 就是 $vector$,后面的如何操作不赘述了,见前。只是说如果使用 $int$ $n$ 指令来实现系统调用的话,总归是有点特殊的,留待系统调用再详述。
好了本节就这样吧,有什么问题还请批评指正,也欢迎大家来同我讨论交流学习进步。
首发公众号:Rand_cs