2009.6.18更新:参考http://linux.derkeiler.com/Mailing-Lists/Kernel/2004-03/4562.html ,查证LXR,重新诠释PREEMPT_ACTIVE标志。
中断信号分类
中断信号是一个统称,统称那些改变CPU指令执行序列的事件。但它又分为两种:
-
一种是同步的,没那么突然,因为它只在一个指令的执行终止之后才发生,书中依从Intel的惯例,称为异常(Exception)。一般是编程错误(一般的处理是发信号)或者内核必须处理的异常情况(内核会采取恢复异常所需的一些步骤);
-
一种是异步的,突然一些,因为它是由间隔定时器和I/O设备产生的,只遵循CPU时钟信号,所以可能在任何时候产生,书中也依从Intel的惯例,称为中断(Interrupt)。
内核控制路径
内核在允许中断信号到来之前,必须先准备好对它们的处理,也就是适当地初始化中断描述符表(Interrupt Descriptor Table, IDT)。
中断信号一来,CPU控制单元就自动把当前的程序计数器(eip、cs)和eflags保存到内核stack,然后把事先与发生的中断信号类型关联好的处理程序的地址(保存在IDT中)放进程序计数器。这时,内核控制路径(kernel control path)横空出世。
什么是内核控制路径?它是不是一个进程?不是。内核进程?也不是。它虽然也需要切换上下文,需要保存那些它可能使用的寄存器的并在返回时恢复,但这是一个非常轻的上下文切换。它诞生的时候并没有发生进程切换,处理中断的主语仍然是中断发生时正在执行的那个进程。那个进程就像突然被内核抓进了一间小屋做事,或者突然潜入了水(内核)里不见踪影,但它仍然在使用分配给它的那段时间片。
有趣的是,如果一个进程还在处理一个异常的时候,分配给它的时间片到期了,会发生什么事情呢?这取决于有没有启用内核抢占(Kernel Preemption),如果没有启用,进程就继续处理异常,如果启用了,进程可能会立即被抢占,异常的处理也就暂停了,直到schedule()
再度选择原先那个进程(注意:内核处理中断的时候,必然会禁用内核抢占,所以这里才说是异常)。
中断信号处理的约束
中断信号处理需要满足下面三个严格的约束:
-
中断处理要尽可能块地完成、返回。因此只执行关键而紧急的部分,尽可能把更多的后续处理过程仅仅标志一下,放到之后再去执行。
-
一个中断还在处理的时候,另外一个中断可能又来了,这个时候最好能先放下手中的处理,先去处理新的中断,然后在回头来接着处理这个中断,这称之为中断和异常处理程序的嵌套执行(nested execution),或者说是内核控制路径的嵌套执行。要实现这一点,有一点必须满足,那就是中断处理程序运行期间不能阻塞,不能发生进程切换。
如果对异常的种类做一番思考,就会发现,异常最多嵌套两层,一个由系统调用产生,一个由系统调用执行过程中的缺页产生(这时必然挂起当前进程,发生进程切换)。与之相反,在复杂的情况下,中断产生的嵌套则可能任意多。
- 内核中存在一些临界区,在这些临界区,中断必须被禁止。中断处理程序要尽可能地减少进入临界区的次数和时间,为了内核的响应性能,中断应该在大部分时间都是启用的。
异常的种类
异常有很多种,其中比较有趣的有:
编号 | 异常 | 异常处理程序 | 信号 | 有趣之处 |
---|---|---|---|---|
1 | Debug | debug() | SIGTRAP | 用于调试 |
3 | Breakpoint | int3() | SIGTRAP | |
7 | Device not available | device_not_available() | None | 用于在需要的时候才加载FPU 、MMX 、XMM(当cr0的TS标志被设置) |
14 | Page Fault | page_fault() | SIGSEGV | 如果是正常缺页,内核会挂起 |
4 | Overflow | overflow() | SIGSEGV | 调试时非常常见的一个信号SIGSEQV ,Segment Violation ,呵呵,关注一下都是什么异常导致的。 |
5 | Bounds check | bounds() | SIGSEGV | |
10 | Invalid TSS | invalid_TSS() | SIGSEGV | |
13 | General protection | general_protection() | SIGSEGV |
中断描述符
Intel 80x86 CPU认得三种中断描述符,Linux为了检验权限,将其细分为:
- Interrupt Gate, DPL = 0的中断门,
set_intr_gate(n,addr)
,所有中断 - System Interrupt Gate,DPL = 3的中断门,
set_system_intr_gate(n,addr)
,int3异常 - System Gate,DPL = 3的陷阱门,
set_system_gate(n,addr)
,into、bound、int $0x80异常 - Trap Gate, DPL = 0的陷阱门,
set_trap_gate(n,addr)
,大部分异常 - Task Gate, DPL = 0的任务门,
set_task_gate(n,gdt)
,double fault异常
异常处理的标准结构
- 用汇编把大多数寄存器的值保存到kernel stack;
- 用C函数处理异常
- 通过ret_from_exception( ) 函数退出处理程序.
I/O中断处理的标准结构
- 将IRQ值和寄存器值保存到kernel stack;
- 给服务这条IRQ线的PIC发送应答,从而允许它继续发出中断;
- 执行和所有共享此IRQ的设备相关联的ISR;
- 通过跳转到
ret_from_intr()
的地址结束中断处理。
IRQ(Interrupt ReQuest)线(IRQ向量)的分配
IRQ共享:几个设备共享一个IRQ,中断来时,每个设备的中断服务例程(Interrupt Service Routine,ISR)都执行,检查一下是否与己有关;
IRQ动态分配:IRQ可以在使用一个设备的时候才与一个设备关联,这样同一个IRQ就可以被不同的设备在不同时间使用。
中断向量中,0-19用于异常和非屏蔽中断,20-31被Intel保留了,32-238这个范围内都可以分配给物理IRQ,但128(0x80)被分配给用于系统调用的可编程异常。
延后的工作谁来做?
首先是两种非紧迫的、可中断的内核函数——可延迟函数(deferrable functions ),然后是通过工作队列(work queues )来执行的函数。
软中断(softirq)是可重入函数而且必须明确地使用自旋锁保护其数据结构;tasklet在软中断基础上实现,但由于内核保证不会在两个CPU上同时运行相同类型的tasklet,所以它不必是可重入的。
六种软中断
Softirq | Index (priority) | Description |
---|---|---|
HI_SOFTIRQ | 0 | Handles high priority tasklets |
TIMER_SOFTIRQ | 1 | Tasklets related to timer interrupts |
NET_TX_SOFTIRQ | 2 | Transmits packets to network cards |
NET_RX_SOFTIRQ | 3 | Receives packets from network cards |
SCSI_SOFTIRQ | 4 | Post-interrupt processing of SCSI commands |
TASKLET_SOFTIRQ | 5 | Handles regular tasklets |
内核会在一些检查点(适宜的时候,其中有时钟中断)检查挂起的软中断,用__do_softirq()
执行它们。__do_softirq()
会循环若干次,以保证处理掉一些在处理过程中新出现的软中断,但如果还有更多新挂起的软中断,__do_softirq()
就不管了,而是调用wakeup_softirq()
唤醒每CPU内核进程ksoftirqd/n(这样就可以被调度,而不会一直占着CPU),来处理剩下的软中断。
这种做法是为了解决一个矛盾:与网络相关的软中断是高流量的,也是对实时性有一定要求的。但是如果do_softirq()
为了实时性一直处理它们,就会一直不返回,结果用户程序就僵在那里了;如果do_softirq()
理完一些软中断就返回,不论这中间机器有无空闲,直到下一个时钟中断才又处理其余的,网络处理需要的许多实时性就得不到保证。现在的做法,唤醒内核进程,让它在后台调度,由于内核进程优先级很低,用户程序就有机会运行,不会僵死;但如果机器空闲下来,挂起的软中断很快就能被执行。
tasklet则多用于在I/O驱动程序的开发中实现可延迟函数。
但是,可延迟函数有一个限制,它是运行在中断上下文的,它执行时不可能有任何正在运行的进程,它也不能调用任何可阻塞(从而会休眠)的函数。这就是工作队列的意义所在。工作队列把需要执行的内核函数交给一些内核进程来执行。
处于效率的考虑,内核预定义了叫做events的工作队列,内核开发者可以用schedule_work族函数随意呼唤它们。
内核抢占(Kernel Preemption)
本章在很多地方都涉及到了内核抢占,我觉得还是将内核抢占在本章的笔记记完,不必像原书那样等到内核同步一章了。
在非抢占内核的情形,一个执行在内核态的进程是不可能被另外的进程取代的(进程切换);而在抢占内核的情形,是有可能的:但只有当内核正在执行异常处理程序(尤其是系统调用),而且内核抢占没有被显式禁用的时候,才可能抢占内核。
一个例子:当A在处理异常的时候,一个中断的处理程序唤醒了优先级更高的B,在抢占内核的情形,就会发生强制性进程切换。这样做的目的是减少dispatch latency,即从进程(结束阻塞)变为可执行状态到它实际开始运行的时间间隔,降低了它被另外一个运行在内核态的进程延迟的风险。
进程描述符中的thread_info字段中有一个32位的preempt_counter字段,0-7位为抢占计数器,用于记录显式禁用内核抢占的次数;8-15位为软中断计数器,记录可延迟函数被禁用的次数;16-27为硬中断计数器,表示中断处理程序的嵌套数(irq_enter()
增它,irq_exit()
减它);28位为PREEMPT_ACTIVE标志。只要内核检测到preempt_counter整体不为0,就不会进行内核抢占,这个简单的探测一下子保证了对众多不能抢占的情况的检测。
说明:
-
为了避免在可延迟函数访问的数据结构上发生的竞争条件,最简单直接的方法是禁用中断,但禁用中断有时太夸张了,所以有了禁用可延迟函数这回事。
-
PREEMPT_ACTIVE标志的本意是说明正在抢占,设置了之后preempt_counter就不再为0,从而执行抢占相关工作的代码不会被抢占。
它可被非常tricky地这样使用:
preempt_schedule()
内核抢占时进程调度的入口,其中调用了schedule()
它在调用schedule()
设置PREEMPT_ACTIVE标志,调用后清除这个标志。而schedule()
检查这个标志,对于不是TASK_RUNNING(state != 0)的进程,如果设置了PREEMPT_ACTIVE标志,就不会调用deactivate_task()
而deactivate_task()
工作是把进程从runqueue移除。
你可能会疑惑,为什么要预防已经不在RUNNING状态的进程从runqueue中移除?设想一下,一个进程刚把自己标志为TASK_INTERRUPTIBL,就被preempt了,它还没来得及把自己放进wait_queue中...这个时候当然要让它回头接着运行,直到把自己放进wait_queue然后自愿进程切换,那时才可以把它从runqueue中移除。
在面对内核的时候,思维不能僵化在操作系统提供给用户的进程切换的抽象中,而要想象一个永不停歇运行着的、虽然有意识地跳来跳去的指令流的。所以,没有标志为RUNNING不意味就不会还剩下一些(比如处理状态转换的)代码需要执行哦。
通过这个标志,保证了被抢占的进程将可以被正确地重新调度和运行。
在中断、异常、系统调用返回过程中也会设置PREEMPT_ACTIVE标志。