中断和异常
中断(interrupt)通常被定义为一个事件,该事件改编处理器执行的指令顺序。这样的事件与CPU芯片内部外部硬件电路产生电信号相对应。
中断通常分为同步中断(synchronous)中断和异步(asynchronous)中断:
同步中断是当指令执行时由CPU控制单元产生的,之所以称为同步,是因为只有在一条指令终止执行后CPU才会发出中断。
异步中断是由其他硬件设备依照CPU时钟信号随机产生的。
中断(异步中断)是由间隔定时器和I/O设备产生的,例如,用户的一次按键会引起一个中断。
异常(同步中断)是由程序的错误产生的,或者是由内核必须处理的异常条件产生。
中断信号的作用
中断信号提供了一种特殊的方式,使处理器转而去运行正常控制流之外的代码。
当一个中断信号达到时,CPU必须停止它当前正在做的事情,并且切换到一个新的活动。为了做到这一点,就要在内核态堆栈保存程序计数器的当前值,并把与中断类型相关的一个地址放进程序计数器。
中断处理是由内核执行的最敏感的任务行为之一,因为它必须满足下列约束:
当内核正打算去完成一些别的事情时,中断随时会到来。因此,内核的目标就是让中断尽可能快地处理完,尽其所能把更多的处理向后推迟。因此,内核相应中断后需要进行的操作分为两部分:关键而紧急的部分,内核立即执行;其余推迟部分,内核随后执行。
因为中断随时会到来,所以内核正在处理其中一个中断时,另一个中断(不同设备)又发生了。应该尽可能多地运行这种情况的发生,因此这能维持更多的I/O设备处于忙状态。因此,中断处理程序必须编写成使相应的内核控制路径能以嵌套的方式执行。
尽管内核在处理前一个中断时可以接受新的中断,但在内核代码中还是存在一些临界区,中断必须被禁止。
中断和异常
Intel文档吧中断和异常分为以下几种:
中断:
可屏蔽中断:I/O设备发出的所有中断请求(IRQ)都产生可屏蔽中断。可屏蔽中断可以处于两种状态:屏蔽的或非屏蔽的;一个屏蔽的中断只要还是屏蔽的,控制单元就忽略它。
非屏蔽中断:只有几个危急事件(硬件故障)才能引起非屏蔽中断,非屏蔽中断由CPU辨认。
异常:
处理探测异常:当CPU执行指令时探测到的一个反常条件所产生的异常。
故障(fault):通常可以纠正。
陷阱(trap):在陷阱指令执行后立即报告;内核把控制权返回给程序后就可以继续它的执行而不失连贯性。陷阱主要用途是为了调试程序。
异常中止(abort):发生一个严重错误;异常中止用于报告严重错误,如硬件故障或系统表中无效的值或不一致的值。
编程异常(programmed exception):在编程者发出请求时发生。
每个中断和异常是由0~255之间的一个数字标识。
IRQ和中断
每个能够发出中断请求的硬件设备控制器都有一条名为IRQ(interrupt request)的输出线。所有现有的IRQ线都与一个名为可编程中断控制器的硬件电路的输入引脚相连。可编程中断控制器执行下列动作:
1、监视IRQ线,检查产生的信号。如果有条或两条以上的IRQ线上产生信号,就选择引脚编号较小的IRQ线。
2、如果一个引发信号出现在IRQ线上:
a、把接收到的引发信号转换成对应的向量。
b、把这个向量存放在中断控制器的一个I/O端口,从而允许CPU通过数据总线读取此向量。
c、把引发信号发送到处理器的INTR引脚,即产生一个中断。
d、等待,直到CPU通过把这个中断信号写进可编程中断控制器的一个I/O端口来确认它,当这种情况发生时,清INTR线。
3、返回第1步。
通过中断控制器端口发布合适的指令,就可以修改IRQ和向量之间的映射。
可以有选择地禁止每条IRQ线。可以通过PIC编程从而禁止IRQ。禁止的中断是丢失不了的,它们一旦被激活,PIC就又把它们发送到CPU。这一特点被大多数中断处理程序使用,因为这允许中断处理程序依次的处理同一类型的IRQ。
高级可编程中断控制器
来自外部硬件设备的中断请求以两种方式在可用CPU之间分发:
静态分布:IRQ信号传递给重定向表相应项中所列出的本地APIC。中断立即传递给一个特定的CPU,或一组CPU,或所有CPU。
动态分布:如果处理器正在执行最低优先级的进程,IRQ信号就传递给这种处理器的本地APIC。
处理器间中断(简称IPI)是SMP体系结构至关重要的组成部分,并由Linux有效地用来在CPU之间交换信息。
异常
80x86微处理器发布了大约20种不同的异常。内核必须为每种异常提供一个专门的异常处理程序。对于某些异常,CPU控制单元在开始执行异常处理程序前会产生一个硬件出错码,并且压入内核态堆栈。
中断描述符表
中断描述符表(Interrupt Descriptor Table,IDT)是一个系统表,它与每一个中断或异常向量相联系,每一个向量在表中有相应的中断或异常处理程序的入口地址。内核在允许中断发生前,必须适当地初始化IDT。
在允许中断之前,必须用lidt汇编指令初始化idtr。
IDT包含三种类型的描述符:
任务门描述符、中断们描述符、陷阱门描述符
Linux利用中断门处理中断,利用陷阱门处理异常。
中断和异常处理程序的嵌套执行
每个中断或异常都会引起一个内核控制路径,或者说代表当前进程在内核态执行单独的指令序列。
内核控制路径可以任意嵌套另一个中断处理程序可以被另一个中断处理程序“中断”,因此引起内核控制路径的嵌套执行。
一个中断处理程序既可以抢占其他的中断处理程序,也可以抢占异常处理程序。相反异常处理程序从不抢占中断处理程序。
在内核态能触发的唯一异常就是缺页异常。但是中断处理程序从不执行可以导致缺页(因此意味着进程切换)的操作。
基于以下两个主要原因,Linux交错执行内核控制路径:
为了提高可编程中断控制器和设备控制器的吞吐量。
为了实现一种没有优先级的中断模型。
初始化中断描述符表
内核启动中断之前,必须把IDT表的初始地址装到idtr寄存器,并初始化表中的每一个项。这项工作在初始化系统是完成。
int指令允许用户态进程发出一个中断信号,其值可以是0~255的任意一个向量。因此,为了防止用户通过int指令模拟非法的中断和异常,IDT的初始化必须非常小心。可以通过把中断或陷阱门的描述符的DPL字段设置为0来实现。
中断门、陷阱门即系统门
Linux使用和Intel稍微不同的细目分类和术语,把中断描述符如下分类:
中断门:用户态的进程不能访问的一个Intel中断门(门的DPL字段为0)。所有的Linux中断处理程序都是通过中断门激活。并且全部限制在内核态。
系统门:用户态的进程可以访问的一个Intel陷阱门(门的DPL字段为3)。通过系统门来激活三个Linux异常处理程序,他们的向量是4,5及128.
系统中断门:能够被用户态进程访问的Intel中断门(门的DPL字段为3)。与向量3相关的异常处理程序由系统中断门激活。
陷阱门:用户态的进程不能访问的一个Intel陷阱门(门的DPL字段为0)。大部分Linux异常处理程序都通过陷阱门来激活。
任务门:不能被用户态经常访问的Intel任务门(门的DPL字段为0)。Linux对“Double fault”异常的处理程序是由陷阱门来激活的。
下列体系相关的函数用来在IDT中插入门:
set_intr_gate(n,addr):在IDT的第n个表项插入一个中断门。DPL字段为0
set_trap_gate(n,addr):在IDT的第n个表项插入一个陷阱门。DPL字段为0
set_system_intr_gate(n,addr):在IDT的第n个表项插入一个中断门。DPL字段为3
set_system_gate(n,addr):在IDT的第n个表项插入一个陷阱门。DPL字段为3
set_task_gate(n,addr):在IDT的第n个表项插入一个中断门。DPL字段为3
IDT的初步初始化
当计算机还运行在实模式时,IDT被初始化并由BIOS例程使用,然而,一旦Linux接管,IDT就被移到RAM的另一个区域,并进行第二次初始化,应为Linux没有任何BIOS例程。
IDT存放在idt_table表中,有256个表项。
内核初始化时Setup_idt()汇编用同一个中断门(即指向ignore_int()中断处理程序)来填充这256个idt_table。
异常处理
CPU产生的大部分异常都由Linux解释为出错条件。当其中一个异常发生时,内核就向引起异常的进程发送一个信号向它通知一个反常条件。
异常处理程序有一个标准的结构,由以下三部分组成:
1、在内核堆栈中保存大多数寄存器的内容。
2、用高级的C语言处理异常。
3、通过ret_from_exception()函数从异常处理程序中退出。
为了利用异常,必须对IDT进行适当的初始化,是的每个被确认的异常都由一个异常处理程序。trap_init()函数的工作就是将一些最终值(即处理异常函数)插入到IDT的非屏蔽中断及异常表项中。
为异常处理程序保存寄存器的值
ENTRY(divide_error)
pushl $0 # no error code
pushl $do_divide_error
ALIGN
当异常发生是,如果控制单元没有自动把一个硬件出错代码插入到栈中,相应的汇编语言片段会包含一条pushl $0指令,在栈中垫上一个控制,然后,把高级C函数的地址压入栈中。
进入和离开异常处理程序
异常处理程序的C函数总是有do_前缀和处理程序名组成。大部分函数把硬件出错码和异常向量保存在当前进程的描述符中,然后,当前进程发送一个适当的信号。
异常处理程序刚一终止,当前进程就关注这个信号。该信号要么由进程自己的信号处理程序来处理,要么由内核来处理。
中断处理
中断处理依赖于中断类型。
I/O中断:某个I/O设备需要关注,相应的中断处理程序需要查询设备以确定适当的操作过程。
时钟中断:某种时钟产生一个中断;这种中断告诉内核一个固定的时间间隔已经过去。
处理器间中断:多处理器系统中一个CPU对另一个CPU发出一个中断。
I/O中断处理
一般而言,I/O中断处理程序必须足够灵活以给多个设备同时提供服务。
中断程序的灵活性是以两种不同的方式实现的:
IRQ共享:中断处理程序执行多个中断服务例程(ISR)。每个ISR是一个与单独设备(共享IRQ线)的相关函数。
IRQ动态分配:一个IRQ线在可能的最后时刻才与一个设备驱动程序相关联;
Linux把紧随中断要执行的操作分为三类:
紧急的、非紧急的、非紧急可延迟的
中断向量
物理IRQ可以分配给32~238范围内的任何向量。不过linux使用向量128实现系统调用。
为IRQ可配置设备选择一条线有三种方式:
设置一个硬件跳接器(仅适用于旧式设备卡)
安装设备时执行一个实用程序。
在系统是执行一个硬件协议。
内核必须在启动中断前发现IRQ与I/O设备之间的对应,IRQ号与I/O设备之间的对应是在初始化每个设备驱动程序是建立的。
IRQ在多处理器系统中的并发
Linux准讯对称多处理器模型(SMP);这意味着,内核本质上对任何一个CPU都不应该偏爱。因而内核试图以轮转的方式把来自硬件设备的IRQ信号在所有CPU之间分发。因此,所有CPU服务于I/O中断的执行时间片几乎相同。
多中类型的内核栈
每个进程的thread_info描述符与thread_union结构中的内核栈紧邻,而根据内核编译时的选项不同,thread_union结构可能占一个或两个页框。
如果thread_union结构的大小为8KB,那么当前进程的内核栈被用于所有类型的内核控制路径:异常、中断和可延迟的函数。
相反,如果thread_union结构的大小为4KB,内核就是用三种类型的内核栈:
异常栈,用于处理异常(包括系统调用),这个栈包含了每个进程的thread_union数据结构,因此对系统中的每个进程,内核使用不同的异常栈。
硬中断请求栈,用于处理中断,系统中的每个CPU都由一个硬中断请求栈,并且每个栈占用一个独立的页框。
软中断请求栈,用于处理可延迟的函数(软中断或tasklet),系统中的每个CPU都由一个软中断请求栈,并且每个栈占用一个独立的页框。
所有的硬中断请求存放在harding_stack数组中,而所有的软中断请求存放在softirq_stack数组中,每个数组元素都是跨越一个单独页框的irq_ctx类型的联合体。
为中断处理程序保存寄存器的值
当CPU接收到一个中断时,就开始执行相应的中断处理程序代码,该代码的地址存放在IDT的相应门中。
调用do_IRQ()函数执行与一个中断相关的所有中断服务例程。
中断服务例程
一个中断服务例程(ISR)实现一种特定设备的操作。当中断处理程序必须执行IRQ时,它就调用handle_IRQ_event()函数。
处理器间中断处理
处理器间中断允许一个CPU向系统中的其他CPU发送中断信号。
在多处理器系统中,Linux定义了下列三种处理器间中断:
CALL_FUNCTION_VECTOR:发往所有的CPU(不包含发送者),强制这些CPU运行发送者传递过来的函数。相应的中断处理程序叫做call_function_interrupt()。
RESCHEDULE_VECTOR:当一个CPU接收这种类型的中断时,相应的处理程序(叫做reschedule_interrupt())限定自己来应答中断。
INVALIDATE_TLB_VECTOR:发往所有的CPU(不包含发送者)强制它们的转换后援缓冲器(TLB)变为无效。相应的处理程序(叫做invalidate_interrupt())刷新处理器的某些TLB表项。
由于下列的一组函数,使得产生处理器中断(IPI)变成一件容易的事情:
send_IPI_all():发送一个IPI到所有的CPU(包括发送者)
send_IPI_allbutself():发送一个IPI到所有的CPU(不包括发送者)
send_IPI_self():发送一个IPI到发送者的CPU。
send_IPI_mask():发送一个IPI到掩码指定的一组CPU。
软中断及tasklet
我们在前面“中断处理”一节中提到,在由内核执行的几个任务之间有些不是紧急的:在必要情况下它们可以延迟一段时间。
在Linux2.6迎接这种挑战是通过两种非紧迫、可中断内核函数:所谓的可延迟函数(包括软中断和tasklets)和通过工作队列来执行的函数。
软中断和tasklet由密切的关系,tasklet是在软中断之上实现的。
事实上,出现在内核代码中的术语“软中断(softirq)”常常表示可延迟函数的所有种类。
软中断的分配是静态地(即在编译时定义),而tasklet的分配和初始化可以在运行时进行(例如:安装一个内核模块时)。软中断可以并发地运行在多个CPU上。
因此软中断是可重入函数而且必须明确地使用自旋锁保护其数据结构。
tasklet不必担心这些问题,因为内核对tasklet的执行进行了更加严格的控制。相同类型的tasklet总是被串行的执行,换而言之,不能再2个CPU上运行相同类型的tasklet。但是类型不同的可以再不同CPU上同事运行。
一般而言,可延迟函数上可以执行四种操作:
初始化(initialization):定义一个新的可延迟函数;这个操作通常在内核自身初始化或者加载模块时进行。
激活(activation):标记一个可延迟函数为“挂起”(在可延迟函数的下一轮调度中执行)。激活可以再任何时候进行(即使正在处理中断)。
屏蔽(masking):有选择地屏蔽一个可延迟函数,这样即使他被激活,内核也不执行它。
执行(execution):执行一个挂起的可延迟函数和同类型的其他所有挂起的可延迟函数;执行是在特定的时间进行。
激活和执行不知何故总是绑定在一起:由给定CPU激活的一个可延迟函数必须在同一个CPU上执行。
软中断
软中断使用的主要数据结构是softirq_vec数组,该数组包含类型为softirq_action的32个元素。一个软中断的优先级是相应的softirq_action元素在数组内的下标。
处理软中断
open_softirq()函数处理软中断的初始化。它使用三个参数:软中断下标、指向要执行的软中断函数的指针及指向可能由软中断函数使用的数据结构的指针。
void open_softirq(int nr, void (*action)(struct softirq_action*), void *data)
open_softirq()限制自己初始化softirq_vec数组中适当的元素。
raise_softirq()函数用来激活软中断,它接受软中断下标nr作为参数,执行下面的操作:
1、执行local_irq_save宏以保存eflags寄存器IF标志的状态值并禁止本地CPU上的中断。
2、把软中断标记为挂起状态,这是通过设置本地CPU的软中断掩码中与下标nr相关的位来实现的。
3、如果in_interrupt()产生为1的值,则跳转到第5步。这种情况说明:要么已经在中断上下文中调用了raise_softirq(),要么当前禁止了软中断。
4、否则,就在需要的时候去调用wakeup_softirqd()以唤醒本地CPU的ksoftirqd内核线程。
5、执行local_irq_restore宏,恢复在第1步保存的IF标志状态。
do_softirq()函数:
如果在这样的一个检查点(local_softirq_pending()不为0)检测到挂起的软中断,内核就调用do_softirq()来处理它们。
__do_softirq()函数:
__do_softirq()函数读取本地CPU的软中断掩码并执行与每个设置为相关的可延迟函数。
由于正在执行一个软中断函数是可能出现新挂起的软中断,所以为了保证可延迟函数的低延迟性,__do_softirq()一直运行到执行完所有挂起的软中断。
ksoftirqd内核线程
在最近的内核版本中,每个CPU都有自己的ksoftirqd/n内核线程。
每个ksoftirqd/n内核线程都运行ksoftirqd()函数。
tasklet
tasklet是I/O驱动程序中实现可延迟函数的首选方法。tasklet建立在两个叫做HI_SOFTIRQ和TASKLET_SOFTIRQ的软中断之上。几个tasklet可以与同一个软中断相关联,每一个tasklet执行自己的函数。两个软中断没有真正的区别,只不过do_softirq()先执行HI_SOFTIRQ的tasklet,后执行TASKLET_SOFTIRQ的tasklet。
工作队列
在Linux2.6中引入了工作队列,用来代替任务队列。它们允许内核函数(非常像可延迟函数)被激活,而且稍后由一种叫做工作者线程的特殊内核线程来执行。
尽管可延迟函数和工作队列非常相似,但是它们的区别还是很大的。主要区别在于:可延迟函数运行的中断上下文中,而工作则队列中的函数运行在进程上下文。执行可阻塞函数的我一方式是在进程上下文中运行。
工作队列的数据结构
与工作队列相关的主要数据结构是名为workqueue_struct的描述符,它包含一个有NR_CPUS个元素的数组,NR_CPUS是系统中CPU的最大数量。每个元素都是cpu_workqueue_struct类型的描述符。
struct cpu_workqueue_struct {
spinlock_t lock; //保护该数据的自旋锁
long remove_sequence; /* Least-recently added (next to run) */
long insert_sequence; /* Next to add */
struct list_head worklist; //挂起链表的头结点
wait_queue_head_t more_work;
wait_queue_head_t work_done;
struct workqueue_struct *wq;//指向workqueue_struct结构的指针,其中包含该描述符
task_t *thread;//指向结构中工作者线程的进程描述符指针
int run_depth; /* Detect run_workqueue() recursion depth 当前执行深度*/
} ____cacheline_aligned;
工作者函数
create_workqueue("foo")函数接受一个字符串作为参数,返回新创建工作队列的workqueue_struct描述符的地址。该函数还创建n个工作者线程(n是当前系统中有效运行的cpu的个数),并根据传递给函数的字符串为工作者线程命名,如:foo/0,foo/1等等。
queue_work()把函数插入工作队列,它接受wq和work两个指针。wq指向workqueue_struct描述符,work指向work_struct描述符。
queue_work()主要执行下面的步骤:
1、检查要插入的函数是否已经在工作队列中(work->pending字段等于1),如果是就结束。
2、把work_struct描述符加到工作队列链表中,然后把work->pending置1。
3、如果工作者线程在本地CPU的cpu_workqueue_struct描述符的more_work等待队列中睡眠,该函数唤醒这个线程。
queue_delayed_work()函数和queue_work()几乎相同,只是queue_delayed_work()函数多接受一个以系统滴答数来表示时间延迟参数,它用于确保挂起函数在执行前的等待时间尽可能短。
每个工作者线程在worker_thread()函数内部不断地执行循环操作,因而,线程的绝大多数时间里处于睡眠状态并等待某些工作被插入队列。工作队列一旦被唤醒就调用run_workqueue()函数,该函数从工作者队列链表中删除所有的work_struct描述符并执行相应的挂起函数。
预定义工作队列
在绝大多数情况下,为了运行一个函数而创建整个工作者线程的开始太大。因此,内核引入叫做events的预定义工作队列,所有的内核开发者都可以随意使用它。
预定义工作队列值是一个包括不同内核层函数和I/O驱动程序的标准工作队列,他的workqueue_struct描述符存放在keventd_wq数组中。
预定义工作队列支持的函数
预定义工作队列函数 等价的标准工作队列函数
schedule_work(w) queue_work(keventd_wq,w)
schedule_delayed_work(w,d) queue_delayed_work(keventd_wq,w,d) (在任何CPU上)
schedule_delayed_work_on(cpu,w,d) queue_delayed_work(keventd_wq,w,d) (在某个CPU上)
flush_schedule_work() flush_workqueue(keventd_wq)
从中断和异常返回
尽管终止阶段的主要目的很清楚,即恢复摸个程序的执行。但是我们还需要考虑到以下几个问题:
内核控制路径并发执行数量:如果只有一个那么CPU就必须切换到用户态。
挂起进程的切换请求:如果有任何请求,内核就必须执行进程调度;否则,把控制权还给当前进程。
挂起的信号:如果一个信号发送到当前进程,就必须处理它。
单步执行模式:如果调试程序正在跟踪当前进程的执行,就必须在进程切换回到用户态之前恢复单步执行。