中断处理流程
正如我们前文讲到的,中断处理在8086时代就已经引入PC。我们就从这个“远古”的鼻祖说起。
1。中断向量表
你有没有好奇过,0内存地址开始放了些什么东西呢?毕竟是最开始就要用的东西,一定非常重要!没错,那里就是中断向量表的家。在8086开始,中断向量表就占据这里,甚至在我们最新时髦的酷睿x代,它们还在这里。想不想看看这个顽固的家伙的样子?写个简单的 程序:
long *p = (long *) 0;
printf(“%x”, *p);
运行下看看。不出所料你的程序将产生一个异常,导致被强制关闭。还记得我们前面讲过地址转换,这个0地址是虚拟地址而不是物理地址,在保护模式下0的虚拟地址访问会产生一个异常,你是访问不到物理0地址的。我们在内核模式使用一些技巧或者我们进入实模式,我们才能看到它们。每个中断向量(vector)占据4个字节,Intel定义了256个向量,共用去1KB的内存空间。每个向量朴实无华,就是一个地址,指向该中断(INT)处理函数的入口,这也是它起名vector的原因。整个中断向量表就是一个大函数指针表,一个中断发生,CPU硬件就来这里查表,跳到相应地址就行了,好方便!实际情况稍微复杂点,CPU还要保护现场,将当前环境保存起来(一些寄存器和返回地址等压栈),以便处理完后返回现场继续执行。不过相对于我们接下来的保护模式的中断处理简单很多。没错,这个史前遗迹只在实模式发挥作用,我们只草草看看它的样子就行了:
这里主要是异常处理.
硬件中断向量一区。和我们上文的8259连接两相对照一下:
没错,这里就是主8259的IRQ的中断向量区。接下来就是BIOS保留区,作为介绍BIOS的专区,这里必须看一下:
PC传统Legacy BIOS的服务例程都在这里。看到它们不胜唏嘘,曾经INT 10H打字和INT 13H读写磁盘的美(YUAN)好(SHI)日子又浮现在眼前。。。不好,这是个暴露年龄的话题,后面我们就不讲了,下课。
开个玩笑,不过中断向量表今天也只在UEFI BIOS为了兼容传统OS启动的CSM模块中起作用,我们大致了解一下其中的原理即可。
2.中断描述符
在PC进入保护模式,一个复杂但有很多妙处的机制代替了中断向量表,它就是中断描述符表(IDT,interrupt Descriptor Table)。IDT将每个中断或者异常与它的服务例程连接起来。IDT不再固定放在某个位置,而是可以放在IDTR寄存器指向的任意内存(说是任意,也不能太随性,有些小要求,如8字节对齐等),IDT的表项也从4个字节扩展到8个字节,大小也可以不满256,IDTR也指出了它的最大限制。如图:
IDT除了和中断向量一样指向一个例程地址之外,还包括其他一些信息:
其中的DPL(描述符特权级)与CS寄存器的CPL完成特权级的检查,可以避免低特权级的代码通过软件中断形式提权。它的运作形式和中断向量表类似,更多的是安全检查和可能的执行环境切换(例如ring 3 -> ring 0)。
3.中断和异常
ARM体系中断和异常是单独处理的,IRQ中断只是异常列表里的一项而已。X86中断和异常处理却混杂在一起,使用同一套机制,看似比较混乱。其实异常往往是处理器内部发生的,是同步的;而中断却是外部事件,是异步的。而它们的分布也是不同的,0到31号向量保留给异常,而更高的则往往是硬件中断和软件中断。异常分为三种:错误,陷阱和中止。这三种类型CPU对它们有不同的处理原则:错误往往是可以恢复的,错误修正后再执行刚才的错误就不会出问题了,改了就是好同志嘛!例如常用于内存管理的缺页异常,OS常常把内存换出到硬盘,它会在页表上动些手脚,CPU再次访问这块内存会发生异常,OS页面错误异常例程捕获到后赶紧把内存换回来,然后返回原处执行,就像没事发生一样。陷阱是留给软件挖坑的,CPU希望软件自己挖的坑自己能填上,它可以装作没看见,从下条继续。典型的例子是INT 3,我们的几乎所有调试工具(VS,windbg,甚至UEFI的source level debugger)都用它添加软件断点。中止就严重了,意味着发生硬件错误了,它往往能造成Windows蓝屏,linux panic等。异常一览表如下:
20到31被预留将来使用。硬件中断往往就从32开始。
4.中断优先级
PIC模式IRQ数目越低就意味着优先级越高。而在APIC模式下,IOAPIC连接的24个IRQ是平权的,先后并不关乎优先级高低。决定中断优先级的是它对应的中断向量的大小,X86体系有256个vector, 中断优先级的计算公式是:
优先级 = vector num / 16
即每16个中断一组,共享一个优先级,共16个。因为32以下vector被异常和保留占据,2到15是中断的优先级。数字越大越高优先级。中断优先级的控制是靠LAPIC的TPR(Task Priority Register,任务优先级寄存器)来控制的,它的结构如下:
TR只有4位标识可以接受的中断优先级,即16个。CPU内核只处理优先级比TR大的中断,也意味着TR每提高一个数字,就有16个中断被遮蔽!看来我们的中断要想被赶快处理,必须占个好位置。那么是不是IRQ数目越大,vector就越大呢?这是谁来决定的呢?这事可不由BIOS做主,OS是设置vector的主人。而不同的OS的处理也不近相同,我们具体看一下。
中断处理实践
Windows、Linux和BIOS在处理中断上有很多区别。我们从几个方面浮光掠影了解一二。
1. 中断向量设置
PIC如何设置中断向量已经过时,我们就不提了。这里只介绍APIC模式,如果你还记得上节关于IOAPIC的内容,其中最重要的PRT表,它由24个RTE( RedirectionTableEntry)项组成,每一项对应一个IRQ引脚。它的内容除了上节介绍过的Destination Field之外,最低8位是该IRQ对应的vector,可以表示256个vector。OS根据自己的策略,为IRQ分配不同的vector。
Windows
Windows的HAL在设置vector时是根据系统枚举硬件时挨个设置的,因为先枚举的设备其IRQ的大小不确定,所以优先级并无一定之规。从vector不能推导IRQ,IRQ也不能推导vector,可以说全凭运气。为硬件IRQ分配的vector往往从0x31开始分配,应该是为了配合Windows的IRQL概念。大家可以在windbg里输入命令
!idt -a
查看一下自己机器的vector分配情况。这个IRQL比较让人混淆,实际上它并不是个硬件概念,和中断优先级并不同,它是微软定义的一套软件优先级方案。Windows用0到31来表示优先级,数值越大,优先级越高。如下图:
其中DPC/Dispatch是个分水岭,运行在这个优先级的线程不会被其他线程抢占。其上3到26是为了外围硬件保留的。最高的31显得很高大上,谁的地位这么高?你一定见过它,它就是在Windows蓝屏时的IRQL。HAL会把IRQL翻译到不同的硬件平台上,它和X86的中断优先级不是一个概念。
Linux
Linux没有IRQL的概念,他的vector就从0x20(32)开始分配,但是因为0x80(128)因为历史原因,被保留做系统调用(后改用sysenter指令,但为了兼容,还是保留),整个空间被一份为二。后面到0xee(238)为止。因为vector的大小关系到优先级,分配的时候为了保证对各个IOAPIC公平,分配的时候在各个IOAPIC间轮流分配。大家可以在shell里输入以下命令查看一下中断向量的分配情况:
cat /proc/interrupts
2. IRQ在多处理器的分发
还有个问题十分重要。某个vector由哪个CPU内核负责处理呢?Linux为了公平起见,并不会对BSP(bootstrap processor)另眼看待,所有内核一视同仁。Linux通过填写IOAPIC的RTE中的Delivery mode选择最低优先级策略,让TRP都被初始化做固定值,因此IRQ信号就可以公平的在CPU之间分发。感觉很民主有没有?(分分钟被Linus的独裁作风打脸)。有时为了优化性能,我们可以通过Linux的IRQ亲缘性来让特定内核为我们服务。我们可以通过命令
cat /proc/irq/xx/smp_affinity
查看xx IRQ由谁处理,如果是f的话代表是缺省策略,即大家都可以处理。我们可以通过下面命令分配个专有内核处理
echo 2 >/proc/irq/xx/smp_affinity
让APIC ID为2的内核处理。或者通过一些Irqbalance类似的工具来帮我们配置。
3.UEFI固件中的中断
UEFI固件内核中对异常和中断都有处理,还包含很多使用IPI调度内核的源程序,程序短小精干,包括大量注释。感兴趣的同学可以通过它学习中断处理和CPU内核调度。UEFI对中断的初始化和使用都在CPU的开发包里:
tianocore/edk2
有几个地方值得注意,我们来一一看看。
异常
UEFI内核对IDT的初始化程序在UefiCpuPkg的Library/CpuExceptionHandlerLib下。内核为所有的的中断和异常都分配了统一的入口CommonExceptionHandler。它对任何中断和异常没有任何特殊处理,如果没有人对该中断或异常做处理就会dump一些现在的CPU状态如APIC ID, 异常类型等,然后调用CpuDeadLoop陷入死循环,这也是UEFI工程师常见的画面。UEFI驱动可以在自己关心的异常中添加自己的处理函数,如支持通过串口和USB源程序级调试UEFI程序的Source Level Debugger就是个典型的例子,它Hook住了很多异常,用于调试和捕捉错误,它在:
tianocore/edk2
中断
UEFI的CSM模块还兼容以前BIOS使用的INT x软中断方式调用BIOS服务。随着UEFI的广泛推广和传统OS的渐渐淘汰,CSM也日薄西山,有些仅仅面向最新OS的项目都不含CSM的支持,所以关于它的内容这里略过。在保护模式下,UEFI内核仅仅对时钟中断进行了处理,并通过Timer Architectural Protocol开放出来供所有UEFI程序调用。也许你会好奇,那么多种USB设备和网卡等等的UEFI驱动难道不需要中断处理?是的,他们的中断在UEFI阶段都没有开启,他们的驱动通过Timer加Polling的方式来处理。举个例子,我们在UEFI Shell 下插入键盘,它能立刻起作用不是如在OS中USB控制器产生了中断。而是USB驱动注册了个Timer,过一会就Poll一下看看有没有新设备插入。就是这个Timer发现了新插入的键盘的。
这种仅仅依靠Timer的做法在OS阶段是行不通的,会带来严重的效能和功耗问题。但是在Boot阶段却问题不大,而且这样做保证了UEFI内核的简洁性。事实上,UEFI并不禁止驱动自己开启中断,但开启中断需要处理的中断共享、IOAPIC设置等等问题需要驱动自己解决,UEFI并不提供支持。
IPI
内核可以通过写自己LAPIC的ICR(Interrupt Command Register)发出IPI((Inter-Processor Interrupt)调度别的内核完成任务,这也是任务调度的基本方法。事实上,因为APIC ID的不连续性,我们正是通过发送IPI的方法来统计内核的数量。BSP在启动时需要统计系统中可用的内核时,发送广播IPI,让大家都来报道,BSP开始点数,1,2,3。。。并一一记录在案。在启动OS前,通过ACPI table告诉OS有多少个内核。OS不应该自己统计内核数目,事实上固件可以通过瞒报内核的方式将部分内核挪作他用,但谁会这么做呢?
如何发起IPI在CPU package里有大量实例和库,大家可以参考。
其他
一些容易混淆的名词这里要特别说明一下
IRQ x:起源于PIC,指中断引脚,后在APIC时代沿用,泛指中断号。
Vector x/INT x: X是中断向量,如前文所说 IRQ不等于INT和vector.
PIRQ: PCI IRQ。它是描述南桥内部PCI设备的IRQ配置关系的。我们下一篇文章介绍。
GSI:Global System Interrupt,是ACPI spec规定的全局中断表。它为多IOAPIC情况下确定了系统唯一的一个中断号。例如IOAPIC1有24个IRQ,IOAPIC2也有24个IRQ,则IOAPIC2 的GSI是从24开始,GSI = 24 + IRQ(IOAPIC2)。
SCI:System Control Interrupt,系统控制中断,是ACPI定义的,专用于ACPI电源管理的一个IRQ。它在Intel平台上常常与南桥的电源管理模块一起,当外部EC等发生Event后会引发SCI。Windows的SCI ISR程序就是著名的acpi.sys。acpi.sys在收到SCI后会检查GPE状态寄存器以确定是谁引发的event,然后按照ACPI spec要求调用相应Method。详情请参照ACPI spec。可以认为SCI是ACPI定义的所有电源管理事件的总入口,它对应的IRQ在一般情况下是不能修改的。SCI是如何报告和简单的GPE method我们在下一篇中会详细介绍。
结语
说了这么多,如果我们从硬件和软件方面,梳理整个中断设置和处理的链条,会发现还有个环节没有解决。那就设备的IRQ是谁来决定的?是硬件hard wired?还是软件可以配置?OS是如何知道这些信息的呢?OS又是怎么知道IOAPIC的数目和位置的呢?这些都是UEFI固件需要解决的问题,我们在下一篇文章中会详细说明。在此之前,如以往一样,有几个思考问题可以让大家加深对中断和UEFI的理解:
1. 中断的引入,必然带来了代码重入的问题。我们知道,这可以通过设定优先级、信号量/临界区等等办法来解决。UEFI是通过什么方法呢?TPL和IRQL的相似和区别又是什么呢?
2. UEFI内核还不支持多线程,我们如果需要增加多线程调度,仅仅依靠时钟中断,够不够用呢?
3. OS利用缺页异常可以调度内存到硬盘上和实现Lazy loading等等实用的功能。UEFI的SMM内核也开启了缺页异常,但是却为了另外一个目的,你能看出是为了什么吗?
中断体系其他文章:
计算机中断体系一:历史和原理 - 知乎专栏
计算机中断体系三:中断路由 - 知乎专栏
欢迎大家关注本专栏和用微信扫描下方二维码加入微信公众号"UEFIBlog",在那里有最新的文章。同时欢迎大家给本专栏和公众号投稿!