从IRQ到IRQL(PIC版)

2019-04-15 17:04发布

SoBeIt

这个题目让我想起了小时候学的课文《从百草园到三味书屋》,然后就想起了以前无忧无虑的快乐时光,这是上了大学以后所不再有的,有时常常叹息过去的美好日子不会再有了。sigh~扯远了。

本文所有的东西都不涉及APIC。先来介绍一下名词,免得有些哥们看晕了:)

PIC:Programmed Interrupt Controller,可编程中断控制器,是一块芯片,里面包含了中断请求寄存器、中断在服务寄存器、中断屏蔽寄存器等很多寄存器,用来控制中断。一般我们的电脑里都是用的8259A中断控制器芯片,共有两块,一主一从,每块负责8个中断请求信号线,主的负责IRQ0-IRQ7,从的负责IRQ8-IRQ15。
APIC:Advance Programmed Interrupt Controller,高级可编程中断控制器,用与多处理器,因为它支持100多个以上的中断向量,所以不是用固定映射的方法,而是通过一定算法映射。
IRQ:Interrupt ReQuest,中断请求,当中断发生后,发生中断的设备通过它使用的中断请求信号线象中断控制器报告中断。CPU可以通过IRQ号来识别中断。
IRQL:Interrupt ReQuest Level,中断请求优先级,一个由windows虚拟出来的概念,划分在windows下中断的优先级,这里中断包括了硬中断和软中断,硬中断是由硬件产生,而软中断则是完全虚拟出来的。
假中断:Spurious Interrupt,当中断发生时中断控制器相关在服务位并未置位。windows也把IRQL低于当前IRQL的中断当作假中断来处理。

写驱动的人一开始就会接触到IRQL这个概念,它实现了WINDOWS里的中断优先级制度,高优先级的中断总是可以优先被处理,而低优先级的中断则不得不等待高优先级中断被处理完后才得到处理。这就象一个特权社会的不同特权阶层,社会底层的人被迫服从于社会高层的人的安排。就象当IRQL=0X15时,所有IRQL低于0x15的中断发生时都不得不等待知道这个中断被处理完,IRQL降下来,然后下一个被处理的是IRQL低于0x15而高于其它所有等待的中断IRQL的中断。这种安排使中断处理有序化,重要的中断先于次重要的中断被处理。一个常规的IRQL如下:
31:高
30:掉点
29:处理器间中断
28:时钟
27:配置文件
26



3:设备中断(其实只用了16个)
2:DPC/调度
1:APC
0:无源

但是接触过硬件的人都知道硬件只有IRQ这个概念,而完全没有IRQL这个东东,但我们写驱动时可以不必去理会IRQ,取而代之的是与IRQL打交道。那么IRQ这个东西哪去了呢?我们知道发生中断时,CPU会用中断向量做索引在IDT(中断描述符表)中找到对应的中断服务例程。那么中断向量又从哪来呢?实际上,它保存在8259A中断控制器里的中断向量寄存器中,每个系统有两个8259A中断控制器,关系是一主一从,主中断控制器掌管IRQ0-IRQ7的中断,对应0x20、0x21端口;从中断控制器掌管IRQ8-IRQ15的中断,对应0xa0、0xa1端口。主中断控制器的中断向量寄存器保存IRQ0的中断向量,从中断控制器的中断向量寄存器保存IRQ8的中断向量。中断发生时,CPU从中断向量寄存器中取出IRQ0的中断向量与当前IRQ相加,既可得当前中断向量。中断向量寄存器并不是一开始就是这个值,在实模式下,IRQ分别对应了BIOS中的中断处理程序。但到了保护模式下时原BIOS中断处理程序的中断号都对应了异常处理程序(0x0-0x11),所以进入保护模式后就得进行中断的重映射,向8259A中断控制器编程,使它按操作系统的意图进行中断映射(正因为如此,PIC才叫“可编程”)。听起来好象很难,其实很简单,分别向0x20和0xa0发送4个ICW(初始化命令字)就可以完成对它的编程。ICW1包括是否工作在级联环境、中断请求的触发模式等;ICW2就是IRQ0的中断向量(向0xa1是发IRQ8的中断向量),要求是8位对齐;ICW3是主、从中断控制器的级联状态,指示由IRQx(一般是IRQ2)作为主、从中断控制器连接的中断,向0x20、0xa0端口发送的命令是不一样的;ICW4指示是否工作于x86模式下及是否自动清楚EOI等。windows在启动阶段初始化时对中断控制器编程,ICW2对于0x21端口是0x30,对于0xa1是0x38。至此,中断映射完毕,中断发生后可以直接从IDT中索引中断处理程序。

当中断发生并索引到对应中断处理程序后转入中断处理程序执行,每个中断处理程序开始的代码都是一样的,是一段预处理代码,它是怎么产生的呢?当IoConnectInterrupt注册中断处理程序时,会产生一个KINTERRUPT结构,结构如下:

typedef struct _KINTERRUPT {
CSHORT Type;
CSHORT Size;
LIST_ENTRY InterruptListEntry;
PKSERVICE_ROUTINE ServiceRoutine;
PVOID ServiceContext;
KSPIN_LOCK SpinLock;
ULONG TickCount;
PKSPIN_LOCK ActualLock;
PVOID DispatchAddress;
ULONG Vector;
KIRQL Irql;
KIRQL SynchronizeIrql;
BOOLEAN FloatingSave;
BOOLEAN Connected;
CHAR Number;
UCHAR ShareVector;
KINTERRUPT_MODE Mode;
ULONG ServiceCount;
ULONG DispatchCount;
ULONG DispatchCode[106];
} KINTERRUPT, *PKINTERRUPT;

中断描述符表中保存的中断服务例程的入口地址就是这个KINTERRUPT结构的DispatchCode的地址。这段代码的功能很明白,就是调用HalBeginSystemInterrupt完成从IRQ到IRQL的映射(同样负责映射的函数还有KfRaiseIrql、KfLowerIrql、HalEndSystemInterrupt等函数)。只有完成了映射后才会转到实际的中断处理程序,也就是用户注册的中断处理程序的执行。

IRQL是一个完全虚拟出来的概念,M$为了实现这一个虚拟的机制,完全虚拟了一个中断控制器,它在KPCR中:

+024 byte Irql //IRQL
+028 uint32 IRR //虚拟中断请求寄存器
+02c uint32 IrrActive//虚拟中断在服务寄存器
+030 uint32 IDR //虚拟中断屏蔽寄存器

和一个实际中断控制器几乎一模一样,除去少部分实现IRQL机制的代码外,整个系统其实都是在和这个虚拟出来的东西交流,而上层系统对此是一无所知,对着一个假的东西整天RaiseIrql来LowerIrql去的还玩得不亦乐乎^^。其实IRQL可以理解为是windows硬件抽象层模拟了实际的IRQ的实现方式,使上层和硬件抽象层打交道就象以前直接和硬件打交道一样,并将IRQ的16个中断扩展了为了32个,除去映射了IRQ的16个,剩下的全归系统实现各种功能使用。它的初始化是在前面提到的向保护模式过渡时编程PIC后,会向实际两个8259A中断控制器发中断屏蔽码,屏蔽掉所有中断,这也就是为什么启动时你按什么键系统都不会有反应的原因,全都给屏蔽了。然后第一次调用KfRaiseIrql,提升的IRQL是当前IRQL(KPCR刚刚初始化完,当前IRQL当然是0)。IRQL从0到32,对应32个优先级,相应的寄存器当然必须是32位的,所以IRR、IDR等都是一个DWORD,每个位对应了一个优先级。

扯了那么多,还没扯到关键的IRQ是怎么映射为IRQL上来和IRQL是怎么实现的:)IRQL和IRQ有个很简单的线性关系,就是IRQL=0x27-IRQ。前面提到了每个中断在处理前都会调用HalBeginSystemInterrupt,因为整个系统是由中断驱动的,所以HalBeginSystemInterrupt才是整个IRQL映射机制的心脏,它会在系统第一次被中断时启动这个机制并在系统每一次中断时维持这个机制,而其它象HalEndSystemInterrupt、KfLowerIrql等都是在这个机制被启动后完善这个机制的组件。

BOOLEAN
HalBeginSystemInterrupt(
IN KIRQL Irql
IN CCHAR Vector,
OUT PKIRQL OldIrql)

它的输入参数Irql和Vector从哪来?当然是从前面注册的KINTERRUPT结构中取出了。这个函数首先通过把Vector-0x30获取当前中断的IRQ,然后跳转到一个指针数组,里面包含了对应IRQ的中断的一个简单处理例程,除了少部分IRQ7(并口1中断)、IRQ13(协处理器中断)、IRQ15不一样外,其它的都是指向同一个函数(其实前面那几个不一样的也只是做点小处理,主要是判断是否是假中断,若不是则也跳到那个函数)。真正的工作在这个函数里开始了,从虚拟中断控制器中(KPCR+0x24)取出当前IRQL,并与当前中断的IRQL判断,若当前IRQL小于当前中断IRQL,则修改虚拟中断控制器的IRQL为当前中断IRQL,然后向中断控制器发对应中断EOI表示中断已处理完,可以响应下一个来自这个IRQ的中断(如果中断是IRQ8-IRQ15,属于从中断控制器管理,则还要向主中断控制发一个对应IRQ2的EOI)。若当前中断IRQL小于当前IRQL,则说明有个更高优先级的中断在被处理,则设置虚拟中断控制器中的中断请求寄存器IRR中的相关位,表示该IRQL发生请求,但未被处理。同时从KiI8259MaskTable中取出当前IRQL的掩码(这个掩码是32位,每个IRQL对应一个掩码,一般都是掩码对应IRQL以上的位为1,以下的位为0,表示只接受大于当前IRQL的请求,如11111111111111111111110000000000B是IRQL17的掩码)与当前虚拟中断控制器中的中断屏蔽寄存器IDR相或之后设置虚拟的IDR,表示拒绝来自这些IRQL的请求。并把该掩码发实际的中断控制器,设置中断屏蔽寄存器,防止该未被处理的中断再发生一次。注意,系统并没有向中断控制器发出该未被处理的中断的EOI,表示该中断并没有处理完。最后HalBeginSystemInterrupt返回FALSE(注意,是返回,前面只是跳转到那个函数里,返回地址并没有变),表示这是一个假中断,系统象什么事也没有一样继续干该干的事。

调用完HalBeginSystemInterrupt后开始调用实际由用户注册的中断处理程序,处理完后会调用HalEndSystemInterrupt,调用这个函数时必须是关中断的。这个函数和所有HalEndSoftwareInterrupt、KfLowerIrql、KfLowerIrqlToXXX函数功能差不多,就是降低当前IRQL,从另一个掩码表FindHigherIrqlMask中取出要降低到的IRQL的掩码放到edx中(要说这个表和刚才那个表有啥不同,就是这个表差不多是对上一个每个掩码取反,注意,是差不多,不是完全),与上虚拟中断请求寄存器来判断是否有更高级的IRQL的请求在等待,当然,并没有改变虚拟中断请求寄存器。同时把虚拟中断控制器的IRQL设置为要降低到的IRQL。若没有更高级的IRQL请求在等待,则HalEndSystemInterrupt返回,否则要处理等待的IRQL请求,此时会判断虚拟在服务寄存器是否为空,不为空则表示还有中断在处理,直接返回,这种情况是某些延时了的硬件中断处理。为空的话可以处理等待的中断了,从edx中(edx里是什么内容,往上找吧)找出左边第一个不是0的位,也就是在等待的中断中IRQL最高的一个中断。(当然,这里也会比较一下该中断对应的IRQL是不是已经小于DISPATCH_LEVEL,小于的话已经是软中断了,就会跳到其它地方处理)。然后用虚拟中断屏蔽寄存的值设置实际的两个中断控制器里的屏蔽寄存器的值,接着如果虚拟中断在服务寄存器IrrActive对应要处理中断IRQL的位没有置位的话则置位,表示当前处于在服务状态,并清除原先设置的虚拟中断请求寄存器IRR中相关位。现在到了关键的一步,以当前IRQL为索引,跳转到一个函数指针表中索引对应的函数。这个表叫做SWInterruptHandlerTable,其作用就象实模式下那个中断向量表一样,索引对应的中断处理程序。我们来看看表里有啥内容:

SWInterruptHandlerTable label dword
dd offset FLAT:_KiUnexpectedInterrupt ; irql 0
dd offset FLAT:_HalpApcInterrupt ; irql 1
dd offset FLAT:_HalpDispatchInterrupt2 ; irql 2
dd offset FLAT:_KiUnexpectedInterrupt ; irql 3
dd offset FLAT:HalpHardwareInterrupt00 ; 8259 irq#0
dd offset FLAT:HalpHardwareInterrupt01 ; 8259 irq#1
dd offset FLAT:HalpHardwareInterrupt02 ; 8259 irq#2
dd offset FLAT:HalpHardwareInterrupt03 ; 8259 irq#3
dd offset FLAT:HalpHardwareInterrupt04 ; 8259 irq#4
dd offset FLAT:HalpHardwareInterrupt05 ; 8259 irq#5
dd offset FLAT:HalpHardwareInterrupt06 ; 8259 irq#6
dd offset FLAT:HalpHardwareInterrupt07 ; 8259 irq#7
dd offset FLAT:HalpHardwareInterrupt08 ; 8259 irq#8
dd offset FLAT:HalpHardwareInterrupt09 ; 8259 irq#9
dd offset FLAT:HalpHardwareInterrupt10 ; 8259 irq#10
dd offset FLAT:HalpHardwareInterrupt11 ; 8259 irq#11
dd offset FLAT:HalpHardwareInterrupt12 ; 8259 irq#12
dd offset FLAT:HalpHardwareInterrupt13 ; 8259 irq#13
dd offset FLAT:HalpHardwareInterrupt14 ; 8259 irq#14
dd offset FLAT:HalpHardwareInterrupt15 ; 8259 irq#15

可以看到IRQL2是处理APC的例程,IRQL3的例程会处理DPC和环境切换(我在《SYMANTEC防火墙内核堆栈溢出漏洞利用方法总结》一文中提到过),那么这些HalpHardwareInterruptXX之类的是什么?很简单,就是int xx,然后返回。因为中断被延迟错过了由系统机制索引IDT表然后处理的机会,操作系统只好自己模仿一个中断来索引IDT表找到中断处理程序。前面提到的如果是一个软中断在等待,则会略过前面那些对硬件的操作直接索引IDT表找处理程序,不是IRQL2的就是IRQL3的,所以我前面说过软中断其实所谓“中断”都是虚拟出来的,连int指令都没执行过。处理完并返回到HalEndSystemInterrput后的处理就简单了,将虚拟中断在服务寄存器中相关位清0,并再判断是否还有高于当前IRQL的中断在等待,有,则继续刚才的处理过程;没有,工作完成,可以返回了。HalEndSystemInterrupt返回后,中断处理程序就执行完毕,iret返回被中断的地方。

其它的象KfLowerIrql、HalEndSoftwareInterrput和HalEndSystemInterrupt基本原理是一样的。至于KfRaiseIrql,不要以为它有多复杂,它仅仅是修改了虚拟中断控制器的IRQL而已。

现在回头再来看这套机制,它并不是为了提高效率,如果单为提高效率完全可以通过开/关中断来完成,而它处理每个中断都白白多了那么多代码,还虚拟了一堆东西出来,反而拖了系统速度。这个机制这么实现除了实现系统的一些功能外,几乎可以说是为了移植性,想想那时M$正在编写windows时因为David Culter的事不得不和Digital公司签订的必须支持Alpha处理器的“不平等”条约,使得windows必须把可移植性放在首位。就如前所说,整个系统大部分只需要和一个虚拟出来的中断控制器打交道就行,而不必管实际的中断处理器怎样,在驱动看来,它还是在和硬件打交道。这也就是硬件抽象的含义,把一个具体的东西抽象成一个虚拟的东西。至于用这套机制实现的一些系统功能确实有一定的优越之处,象linux从2.4内核起也实现了softirq这种类似于windows下软中断的概念,而用tasklet这种softirq来处理硬件中断的下半部分(Bottom half),则类似于windows里DPC的作用。

有些朋友可能会说,按这么来说键盘的IRQ是1,中断向量是0x31,那么也应该处于IDT表里的0x31处,为什么在虚拟机里看怎么不是这个位置?这个问题也困扰了我很久,直到我不久前才明白,就是虚拟机默认使用了APIC,不再是那么简单的固定映射。包括HalBeginSystemInterrupt等函数也完全不一样。具体怎么不一样有时间我再分析分析。

写了这么多也不知道说明白了没有。上了大学以后就再没写过作文,语言表达能力明显下降了,明明知道是这么一回事,可是写出来就不一样了。今天考混凝土又被郁闷了,于是一口气完成了这篇文章。难免会有错漏,欢迎与我探讨:)

今天重看了一遍文章,发现有几个地方没说明白。一是为什么KfRaiseIrql之类的函数那么简单,因为在这里M$采用了一种被称为lazy IRQL的思想,和内存管理器实现的lazy write机制是基于同一种思想,为了提高效率。当中断没发生时,是不会修改虚拟中断控制器寄存器的值,只单纯改变一下当前IRQL,直到中断发生后才会修改。二是软中断是怎么发生的,软中断是系统在适当的时候调用HalRequestSoftwareIrql引发的,传入参数为要引发的软中断的IRQL。在PIC系统里,这个函数设置虚拟中断请求寄存器(IRR)中相关位,表示该IRQL有中断在请求。如果要发生软中断的IRQL大于当前IRQL,则直接用IRQL索引到SWInterruptHandlerTable表中取出相关处理例程。否则直接返回,到下一次IRQL降低于请求的软中断时会处理。