< 更新 更早 >

Linux 2.6内核笔记【中断、异常、抢占内核】

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()再度选择原先那个进程(注意:内核处理中断的时候,必然会禁用内核抢占,所以这里才说是异常)。

中断信号处理的约束

中断信号处理需要满足下面三个严格的约束:

  1. 中断处理要尽可能块地完成、返回。因此只执行关键而紧急的部分,尽可能把更多的后续处理过程仅仅标志一下,放到之后再去执行。

  2. 一个中断还在处理的时候,另外一个中断可能又来了,这个时候最好能先放下手中的处理,先去处理新的中断,然后在回头来接着处理这个中断,这称之为中断和异常处理程序的嵌套执行(nested execution),或者说是内核控制路径的嵌套执行。要实现这一点,有一点必须满足,那就是中断处理程序运行期间不能阻塞,不能发生进程切换。

如果对异常的种类做一番思考,就会发现,异常最多嵌套两层,一个由系统调用产生,一个由系统调用执行过程中的缺页产生(这时必然挂起当前进程,发生进程切换)。与之相反,在复杂的情况下,中断产生的嵌套则可能任意多。

  1. 内核中存在一些临界区,在这些临界区,中断必须被禁止。中断处理程序要尽可能地减少进入临界区的次数和时间,为了内核的响应性能,中断应该在大部分时间都是启用的。

异常的种类

异常有很多种,其中比较有趣的有:

编号 异常 异常处理程序 信号 有趣之处
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异常

异常处理的标准结构

  1. 用汇编把大多数寄存器的值保存到kernel stack;
  2. 用C函数处理异常
  3. 通过ret_from_exception( ) 函数退出处理程序.

I/O中断处理的标准结构

  1. 将IRQ值和寄存器值保存到kernel stack;
  2. 给服务这条IRQ线的PIC发送应答,从而允许它继续发出中断;
  3. 执行和所有共享此IRQ的设备相关联的ISR;
  4. 通过跳转到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,就不会进行内核抢占,这个简单的探测一下子保证了对众多不能抢占的情况的检测。

说明:

  1. 为了避免在可延迟函数访问的数据结构上发生的竞争条件,最简单直接的方法是禁用中断,但禁用中断有时太夸张了,所以有了禁用可延迟函数这回事。

  2. 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标志。

宋皿

Published under (CC) BY-NC-ND tagged with 内核 linux 读书笔记