引言
操作系统作为一个管理资源的复杂软件,如何管理连接到计算机上的硬件就成了一个麻烦的问题,想要有效的管理这些设备显然我们不能够一直去等待硬件的信号发生;又因为众所周知处理器与外部设备的运行速度相差甚远,所以如果我们选择让处理器发出请求,等待外设回复的话,显然是对CPU的一种极大的浪费。尤其是单处理器中,我们更希望这个宝贵CPU的每一秒的算力都被充分利用,由此很自然的想到我们需要一种异步的处理方案,使得外设的操作完成以后直接通知CPU,CPU进行处理,这样就解决了之前提到的问题,这其实就是中断机制。
其实中断的本质就是电信号,当外设,比如磁盘,键盘,鼠标发出一条指令以后,这个指令会传递给CPU旁边的PIC(Programmable Interrupt Controller),PIC会在做了一些必要的处理(后面会提到)以后把信号传递给CPU,当CPU执行完一条指令后,检查到INTR管脚有信号,然后会识别出这个中断号,通过IDTR寄存器中得到IDT的基地址,通过中断号得到一个表项,其中记录了段选择符和段内偏移,这样我们就得到了中断处理程序的地址。这个中断处理程序就是中断的第一个核心内容,即如何处理特定的中断。
这里我们引入几个问题:
- 中断如何处理?
- 中断如何发生?
- 利用中断能干什么?
我们会在剩下的文字中阐明这些问题。
中断处理程序
我们上面其实已经提到了中断处理程序是什么,它其实就是一个在特定的外部信号到达CPU时异步执行的函数,而这个函数是抢占执行的,也就是说正常的程序在执行,中断一发生需要在一条指令执行完毕以后切换到中断处理程序的代码段执行,且这个中断处理程序是运行在中断上下文中的(也称原子上下文),也就是在运行时是独占CPU的。所以我们希望其越快越好。
其实上面这幅图显示了一个大概的过程,但还是精细不足,我们现在就细致的来看看CPU究竟如何处理一个已经发生的中断:
- 首先我们知道CPU执行的下一条指令存储在cs和eip寄存器中,每在执行新指令以前控制单元都会检查执行前一条指令的过程中有没有发生中断或者异常(统称为ECF Exceptional Control Flow)。如果有的话就进入下一步
- 确定这个信号关联的是哪一个中断,我们可以根据信号得到一个[0,256)的下标。
- 通过idtr寄存器得到IDT(中断描述符表)的基地址,每一项都是四个字节。下标乘4就得到了中断相应的中断描述符项,每一个表项由下面几项组成。
- 我们可以通过段选择子和偏移量得到对应中断处理程序的基地址。
- 首先将当前特权级CPL(存放在cs寄存器的低两位)与段描述符(即DPL,存放在GDT中)的描述符特权级比较,如果CPL小于DPL,就产生一个“通用保护”异常,此时也会发生特权级的转换,也就是用户态到内核态的过程。
- 如果特权级不相同的话会通过tr寄存器获得进程的TSS段,把对应特权级相关的栈段和栈指针装载到ss和esp中(陷入内核态时显然是内核栈),在新的栈中保存ss和esp以前的值,这些值定义了与旧特权级相关的栈的逻辑地址,用于在结束程序以后返回用户栈中。
- 如果故障已发生,用引起异常的指令地址装载cs和eip寄存器,从而使得这条指令能再次被执行。比如缺页中断。
- 栈中保存通用寄存器比如eflag,cs,eip的值,这也其实是保护现场,用以正确的返回到用户态中断处的代码,装载cs和eip寄存器,这其实就是中断处理程序,也就是说处理完中断信号后,控制单元所执行的指令就是被选中的处理程序的第一条指令。
- 中断或异常被处理完后,相应的处理程序必须产生一条iret指令,把控制权转交给被中断的进程。具体的过程就是首先用保存在栈中的值装载cs、eip、或eflag寄存器,检查处理程序的CPL是否等于cs中最低两位的值(这意味着被中断的进程与处理程序运行在同一特权级)。如果是,iret终止执行;否则从栈中装载ss和esp寄存器,因此,返回到与旧特权级相关的栈,并执行原程序。
如何注册中断处理函数
我们该如何注册一个中断呢?答案就是request_irq()
函数原型如下:
int request_irq(unsigned int irq, irq_handler_t handler,
unsigned long irqflags, const char *devname, void *dev_id)
- irq是要申请的硬件中断号。
- handler是向系统注册的中断处理函数,是一个回调函数,中断发生时,系统调用这个函数,dev_id参数将被传递给它。
- irqflags是中断处理的属性,若设置了IRQF_DISABLED ,则表示中断处理程序被调用时屏蔽所有中断,不设置意味着中断处理程序可以和除了本身以为的处理程序同时进行;若设置了IRQF_SHARED ,则表示多个设备共享中断,这与后面的中断处理例程息息相关;若设置了IRQF_SAMPLE_RANDOM,表示对系统熵有贡献,对系统获取随机数有好处。(这几个flag是可以通过或的方式同时使用的)
- devname设置中断名称,通常是设备驱动程序的名称 在cat /proc/interrupts中可以看到此名称。
- dev_id在中断共享时会用到,用于在中断处理程序要被释放时提供唯一的标识信息,因为同一个中断号可能有多个中断处理程序,一般设置为这个设备的设备结构体或者NULL。
中断上下文
中断的Top Half包括在中断机制2中要提到的一部分软中断,它们都是运行在我们所谓的中断上下文中的一种特殊上下文中,这种上下文又被称为原子上下文,在这种上下文中,内核不能访问用户空间,而且不可以睡眠,也就是说中断上下文会一直运行至结束,不会被抢占。中断上下文不允许以下操作:
- 进入睡眠状态或主动放弃CPU。
- 占用互斥体。
- 执行耗时任务。
- 访问用户空间虚拟内存。因为中断上下文是和特定进程无关的,它是内核代表硬件运行在内核空间,所以在中断上下文无法访问用户空间的虚拟地址。
- 中断处理例程不应该设置成reentrant(可被并行或递归调用的例程)。
那么回答为什么中断上下文中不能进入sleep呢?进程上下文有丰富的、属于自己的资源:例如有硬件上下文(寄存器),有用户栈、有内核栈,有用户空间的正文段、数据段等等。这些是可以存放在进程结构体内再次被调度的。而中断上下文什么也没有,只有一段执行代码及其附属的数据,首先无法切换上下文(因为没有对应的存储中断信息的结构,当前上下文的状态得不到保存),其次,没有人来唤醒它,因为它不是操作系统的调度单位。在[6]中还有一些其他的讨论,感兴趣的朋友可以看看。
中断如何发生
首先在这些概念的翻译上其实有一些误差,CSAPP的翻译中把中断,陷阱,故障,终止四种情况统称为ECF(异常控制流),但是其实这四种都是IDT(中断描述符)表中的项,也就是说它们其实都是中断,更为细化的:
- 中断:用于处理处理器外部的I/O设备的信号的结果。
- 陷阱:即有意的异常,可以理解为系统调用。
- 故障:由错误情况引起,执行完毕以后回到上一条指令,最经典的就是缺页异常。
- 终止:通常是不可恢复的致命错误造成的后果,通常是一些硬件错误。
这四种都是中断,各有各自的引起原因。第一种可以参考[6][7],这两篇文章的作者通过8259详细的介绍了中断(第一个)的触发;陷阱即系统调用其实就是128号中断,这是很特殊的一个中断处理程序,它通过system_call相关调用直接调用int 80H,在这之前会保存系统调用号和参数在寄存器中,然后在128号中断处理程序中根据系统调用号执行相关系统调用;
故障以缺页举例,是由中央处理器的内存管理单元(CMU)所发出的中断。
终止的具体触发其实我并不清楚。
那么我们是否能利用中断呢?
[3]中的BIOS中断部分描述了简单的使用,其实我对于这方面也并不了解,但是我认为为一个新的外设编写驱动程序一定会用到(这不是废话吗。。)
参考:
- 博文《中断解析》
- 博文《request_irq() | 注册中断服务》
- 博文《中断和中断的应用》
- 博文《进程上下文、中断上下文及原子上下文》
- 博文《为什么中断上下文不可以休眠》
- 博文《保护模式下的8259A 芯片编程及中断处理探究 上》
- 博文《保护模式下的8259A 芯片编程及中断处理探究 下》
- 博文《深入理解【缺页中断】及FIFO、LRU、OPT这三种置换算法》
胡庆伟 kernel/os-truth/lib/user----syscall.c
--------------------------------------------\ syscall.h
kernel/os-truth/kernel/kernel.s 中断处理程序