Linux 内核源代码情景分析(一)(上):https://developer.aliyun.com/article/1597928
2、中断的响应和服务
搞清了 i386 CPU的中断机制和内核中有关的初始化以后,我们就可以从中断请求的发生到 CPU 的响应,再到中断服务程序的调用与返回,沿着 CPU 所经过的路线走一遍。这样,既可以弄清和理解 Linux 内核对中断响应和服务的总体的格局和安排,还可以顺着这个过程介绍内核中的一些相关的“基础设施”。对此二者的了解和理解,有助于读者对整个内核的理解。
这里,我们假定外设的驱动程序都已经完成了初始化,并且已把相应的中断服务程序挂入到特定的中断请求队列中,系统正在用户空间正常运行 (所以中断必然是开着的),并且某个外设已经产生了 一次中断请求。该请求通过中断控制器 i8259A 到达了 CPU 的“中断请求”引线 INTR 。由于中断是开着的,所以 CPU 在执行完当前指令后就来响应该次中断请求。
CPU 从中断控制器取得中断向量,然后根据具体的中断向量从中断向量表 IDT 中找到相应的表项, 而该表项应该是一个中断门。这样,CPU 就根据中断门的设置而到达了该通道的总服务程序的入口, 假定为 IRQ0x03_interrupt。由于中断是当 CPU 在用户空间中运行时发生的,当前的运行级别 CPL 为 3; 而中断服务程序属于内核,其运行级别 DPL 为 0, 二者不同。所以,CPU 要从寄存器 TR 所指的当前 TSS 中取出用于内核 (0级) 的堆栈指针,并把堆栈切换到内核堆栈,即当前进程的系统空间堆栈。应该指山,CPU 每次使用内核堆栈时对堆栈所作的操作总是均衡的,所以每次从系统空间返回到用户空间时堆栈指针一定回到其原点,或曰 “堆栈底部”。也就是说,当 CPU 从 TSS 中取出内核堆栈指针并切换到内核堆栈时,这个堆栈一定是空的。这样,当 CPU 进入IRQ0x03_interrupt 时,堆栈中除寄存器 EFLAGS 的内容以及返回地址外就一无所有了。另外,由于所穿过的是中断门(而不是陷阱门),所以中断已被关断,在重新开启中断之前再没有其他的中断可以发生了。
中断服务的总入口 IRQ0xYY_interrupt 的代码以前已经见到过了,但为方便起见再把它列出在这里。再说,我们现在的认识也可以更深入一些了。
如前所述,所有公用中断请求的服务程序总入口是由 gcc 的预处理阶段生成的,全部都具有相同的模式:
__asm__ ( \ "\n" \ "IRQ0x03_interrupt: \n\t" \ "pushl $0x03 - 256 \n\t" \ "jmp common_interrupt");
这段程序的目的在于将一个与中断请求号相关的数值压入堆栈,使得在 common_interrupt 中可以通过这个数值来确定该次中断的来源。可是为什么要从中断请求号 0x03 中减去 256 使其变成负数呢? 就用数值0x03不是更直截了当吗?这是因为,系统堆栈中的这个位置在因系统调用而进入内核时要用来存放系统调用号,而系统调用又与中断服务共用一部分子程序。这样,就要有个手段来加以区分。 当然,耍区分系统调用号和中断请求号并不非得把其中之一变成负数不可。例如,在中断请求号上加上个常数,比方说 0x1000,也可以达到目的。但是,如果考虑到运行时的效率,那么把其中之一变成负数无疑是效率最高的。将一个整数装入到一个通用寄存器之后,要判断它是否大于等于0是很方便的,只要一条寄存器指令就可以了,如 "orl%%eax,%%eax” 或 “testl %%ecx, %%ecx” 都可以达到目的。而如果要与另一个常数相比较,那就至少要多访问一次内存。从这个例子也可以看出l,内核中的有些代码看似简单,好像只是作者随意的决定,但实际上却是经过精心推敲的。
公共的跳转目标 common_interrupt() 是在 include/asm-i386/hw_irq.h 中定义的:
// include/asm-i386/hw_irq.h #define BUILD_COMMON_IRQ() \ asmlinkage void call_do_IRQ(void); \ __asm__( \ "\n" __ALIGN_STR"\n" \ "common_interrupt:\n\t" \ SAVE_ALL \ "pushl $ret_from_intr\n\t" \ SYMBOL_NAME_STR(call_do_IRQ)":\n\t" \ "jmp "SYMBOL_NAME_STR(do_IRQ));
这里主要的操作是宏操作 SAVE_ALL ,就是所谓 “保存现场”,把中断发生前夕所有寄存器的内容都保存在堆栈中,待中断服务完毕要返回之前再来“恢复现场"。 SAVE_ALL 的定义在arch/i386/kernel/entry.S 中。
回到 common_interrupt 的代码。在 SAVE_ALL 以后,又将一个程序标号 (入口) ret_from_intr 压入堆栈,并通过 jmp 指令转入另一段程序 do_IRQ() 。 读者可能已注意到,IRQ0x03_interrupt 和 common_interrupt 本质上都不是函数,它们都没有与 return 相当的指令,所以从 common_interrupt 不能返回到 IRQ0x03_interrupt,而从 IRQ0x03_interrupt 也不能执行中断返回。可是,do_IRQ() 却是一个函数。所以,在通过 jmp 指令转入 do_IRQ() 之前将返回地址ret_from_intr 压入堆栈就模拟了一次函数调用,仿佛对 do_IRQ() 的调用就发生在 CPU 进入ret_from_intr 的第一条指令前夕一样。这样,当从 do_IRQ() 返回时就会“返回”到 ret_from_intr 继续执行。do_IRQ() 是在 arch/i386/kernel/irq.c 中定义的, 我们先来看开头几行:
(1)do_IRQ
// arch/i386/kernel/irq.c /* * do_IRQ handles all normal device IRQ's (the special * SMP cross-CPU interrupts have their own specific * handlers). */ asmlinkage unsigned int do_IRQ(struct pt_regs regs) { /* * We ack quickly, we don't want the irq controller * thinking we're snobs just because some other CPU has * disabled global interrupts (we have already done the * INT_ACK cycles, it's too late to try to pretend to the * controller that we aren't taking the interrupt). * * 0 return value means that this irq is already being * handled by some other CPU. (or is disabled) */ int irq = regs.orig_eax & 0xff; /* high bits used in ret_from_ code */ int cpu = smp_processor_id(); irq_desc_t *desc = irq_desc + irq; struct irqaction * action; unsigned int status; kstat.irqs[cpu][irq]++; spin_lock(&desc->lock); desc->handler->ack(irq); /* REPLAY is when Linux resends an IRQ that was dropped earlier WAITING is used by probe to mark irqs that are being tested */ status = desc->status & ~(IRQ_REPLAY | IRQ_WAITING); status |= IRQ_PENDING; /* we _want_ to handle it */ /* * If the IRQ is disabled for whatever reason, we cannot * use the action we have. */ action = NULL; if (!(status & (IRQ_DISABLED | IRQ_INPROGRESS))) { action = desc->action; status &= ~IRQ_PENDING; /* we commit to handling */ status |= IRQ_INPROGRESS; /* we are handling it */ } desc->status = status; /* * If there is no IRQ handler or it was disabled, exit early. Since we set PENDING, if another processor is handling a different instance of this same irq, the other processor will take care of it. */ if (!action) goto out; /* * Edge triggered interrupts need to remember * pending events. * This applies to any hw interrupts that allow a second * instance of the same irq to arrive while we are in do_IRQ * or in the handler. But the code here only handles the _second_ * instance of the irq, not the third or fourth. So it is mostly * useful for irq hardware that does not mask cleanly in an * SMP environment. */ for (;;) { spin_unlock(&desc->lock); handle_IRQ_event(irq, ®s, action); spin_lock(&desc->lock); if (!(desc->status & IRQ_PENDING)) break; desc->status &= ~IRQ_PENDING; } desc->status &= ~IRQ_INPROGRESS; out: /* * The ->end() handler has to deal with interrupts which got * disabled while the handler was running. */ desc->handler->end(irq); spin_unlock(&desc->lock); if (softirq_active(cpu) & softirq_mask(cpu)) do_softirq(); return 1; }
函数的调用参数是一个 pt_regs 数据结构。注意,这是一个数据结构,而不是指向数据结构的指针。
也就是说,在堆栈中的返回地址以上的位置上应该是一个数据结构的映象。数据结构 struct pt_regs 是 include/asm-i386/ptrace.h 中定义的。
当通过中断门进入中断服务时,CPU 的中断响应机制就自动被关断了。既然已经关闭中断,为什么567行还耍调用 spin_lock() 加锁呢?这是为多处理器的情况而设置的,我们将在"多处理器SMP系统结构”一章中讲述,这里暂且只考虑单处理器结构。
中断处理器 (如18259A) 在将中断请求“上报”到 CPU 以后,期待 CPU 给它一个确认 (ACK), 表示“我已经在处理”,这里的568行就是做这件事。对函数指针 desc->handler->ack 的设置前面已经讲过。从569行至586行主要是对 desc->status,即中断通道状态的处理和设置,关键在于将其 IRQ_INPROGRESS 标志位设成 1,而将 IRQ_PENDING 标志位清 0 。其中 IRQ_INPROGRESS 主要是为多处理器设置的,而 IRQ_PENDING 的作用则下面就会看到:
如果某一个中断请求队列的服务是关闭着的( IRQ_DISABLED 标志位为1 ),或者 IRQ_INPROGRESS 标志位为1,或者队列是空的,那么指针 action 为 NULL (见580和582行),无法往下执行了,所以只好返回。但是,在这几种情况下 desc->status 中的 IRQ_PENDING 标志为 1 (见574 和583行)。这样,以后当 CPU (在多处理器系统结构中有可能是另一个 CPU )开启该队列的服务时, 会看到这个标志位而补上一次中断服务,称为 “IRQ_REPLAY”。而如果队列是空的,那么整个通道也必然是关着的,因为这是在将第一个服务程序挂入队列时才开启的。所以,这两种情形实际上相同。 最后一种情况是服务已经开启,队列也不是空的,可是 IRQ_INPROGRESS 标志为 1。这只有在两种情形下才会发生。一种情形是在多处理器 SMP 系统结构中,一个 CPU 正在中断服务,而另一个 CPU 又进入了 do_IRQ() ,这时候由于队列的 IRQ_INPROGRESS 标志为 1 而经595行返回,此时 desc->status 中的 IRQ_PENDING 标志位也是 1。第 2 种情形是在单处理器系统中 CPU 已经在中断服务程序中,但是因某种原因又将中断开启了,而且在同一个中断通道中又产生了一次中断。在这种情形下后面发生的那次中断也会因为 IRQ_INPROGRESS 标志为1而经595行返回,但也是将 desc->status 的 IRQ_PENDING 设置成为 1。总之,这两种情形下最后的结果也是一样的,即 desc->status 中的 IRQ_PENDING 标志位为 1。
那么,IRQ_PENDING 标志位到底是怎样起作用的呢?请看612和613两行。这是在一个无限for 循环中,具体的中断服务是在609行的 handle_IRQ_event() 中进行的。在进入609行时,desc->status 中的 IRQ_PENDING 标志必然为0。当CPU完成了具体的中断服务返回到610行以后,如果这个标志位仍然为0,那么循环就在613行结束了。而如果变成了 1,那就说明已经发生过前述的某种情况,所以又循环回到609行再服务一次。这样,就把本来可能发生的在同一通道上 (甚至可能来自同一中断源) 的中断嵌套化解成为一个循环。
这样,同一个中断通道上的中断处理就得到了严格的“串行化”。也就是说,对于同一个CPU 而言不允许中断服务嵌套,而对于不同的 CPU 则不允许并发地进入同一个中断服务程序。如果不是这样处理的话,那就要求所有的中断服务程序都必需是“可重入”的“纯代码”,那样就使中断服务程序的设计和实现复杂化了。这么一套机制的设计和实现,不能不说是非常周到、非常巧妙的。而 Linux 的稳定性和可靠性也正是植根于这种从 Unix 时代继承下来、并经过时间考验的设计中。当然,在极端的情况下,也有可能会发生这样的情景:中断服务程序中总是把中断打开,而中断源又不断地产生中断请求,使得 CPU 每次从 handle_IRQ_event() 返回时 IRQ_PENDING 标志永远是 1,从而使607行的 for 循环变成一个真正的“无限”循环。如果真的发生这种情况而得不到纠正的话,那么该中断服务程序的作者应该另请高就了。
还要指出,对 desc->status 的任何改变都是在加锁的情况下进行的,这也是出于对多处理器 SMP 系统结构的考虑。
最后,在循环结束以后,只要本队列的中断服务还是开着的,就要对中断控制器执行一次“结束中断服务”操作(622行),具体取决于中断控制器硬件的要求,所调用的函数也是在队列初始化时设置好的。
再看上面 for 循环中调用的 handle_IRQ_event() ,这个函数依次执行队列中的各个中断服务程序, 让它们辨认本次中断清求是否来自各自的服务对象,即中断源,如果是就进而提供相应的服务。
(2)handle_IRQ_event
// arch/i386/kernel/irq.c /* * This should really return information about whether * we should do bottom half handling etc. Right now we * end up _always_ checking the bottom half, which is a * waste of time and is not what some drivers would * prefer. */ int handle_IRQ_event(unsigned int irq, struct pt_regs * regs, struct irqaction * action) { int status; int cpu = smp_processor_id(); irq_enter(cpu, irq); status = 1; /* Force the "do bottom halves" bit */ if (!(action->flags & SA_INTERRUPT)) __sti(); do { status |= action->flags; action->handler(irq, action->dev_id, regs); action = action->next; } while (action); if (status & SA_SAMPLE_RANDOM) add_interrupt_randomness(irq); __cli(); irq_exit(cpu, irq); return status; }
其中 430 行的 irq_enter() 和 446 行的 irq_exit() 只是对一个计数器进行操作,二者均定义于 include/asm-i386/hardirq.h:
// include/asm-i386/hardirq.h #define irq_enter(cpu, irq) (local_irq_count(cpu)++) #define irq_exit(cpu, irq) (local_irq_count(cpu)--)
当这个计数器的值为非 0 时就表示 CPU 正处于具体的中断服务程序中,以后读者会看到有些操作是不允许在此期间进行的。
一般来说,中断服务程序都是在关闭中断 (不包括“不可屏蔽中断” NMI) 的条件下执行的,这也是 CPU 在穿越中断门时自动关中断的原因。但是,关中断是个既不可不用,又不可滥用的手段,特别是当中断服务程序较长,操作比较复杂时,就有可能因关闭中断的时间持续太长而丢失其他的中断。 经验表明,允许中断在同一个中断源或同一个中断通道嵌套是应该避免的,因此内核在 do_IRQ() 中通过 IRQ_PENDING 标志位的运用来保证了这一点。可是,允许中断在不同的通道上嵌套,则只要处理得当就还是可行的。当然,必须十分小心。所以,在调用 request_irq() 将一个中断服务程序挂入某个中断服务队列时,允许将参数 irqflags 中的一个标志位 SA_INTERRUPT 置成 0 ,表示该服务程序应该在开启中断的情况下执行。这里的434〜435行和444行就是为此而设的( _sti() 为开中断,_cli() 为关中断)。
然后,从437行至441行的 do_while 循环就是实质性的操作了。它依次调用队列中的每一个中断服务程序。调用的参数有二:irq 为中断请求号;action->dev_id 是一个 void 指针,由具体的服务程序自行解释和运用,这是由设备驱动程序在调用 request_irq() 时自己规定的;最后一个就是前述的 pt_regs 数据结构指针 regs 了。至于具体的中断服务程序,那是设备驱动范畴内的东西,这里就不讨论了。
读者或许会问,如果中断请求队列中有多个服务程序存在,每次有来自这个通道的中断请求时就要依次把队列中所有的服务程序依次都执行一遍,岂非使效率大降?回答是:确实会有所下降,但不会严重。首先,在每个具体的中断服务程序中都应该 (通常都确实是) 一开始就检查各自的中断源, 一般是读相应设备 (接口卡上) 的中断状态寄存器,看是否有来自该设备的中断请求,如没有就马上返回了,这个过程一般只需要几条机器指令;其次,每个队列中服务程序的数量一般也不会太大。所以,实际上不会有显著的影响。
最后,在442至443行,如果队列中的某个服务程序要为系统引入一些随机性的话,就调用 add_interrupt_randomness() 来实现。有关详情在设备驱动一章中还会讲到。
从 handle_IRQ_event() 返回的 status 的最低位必然为 1,这是在432行设置的。代码中还为此加了些注解(418〜424行),其作用在看了下面这一段以后就会明白。
// arch/i386/kernel/irq.c asmlinkage unsigned int do_IRQ(struct pt_regs regs) { // ... if (softirq_active(cpu) & softirq_mask(cpu)) do_softirq(); return 1; }
从逻辑的角度说对中断请求的服务似乎已经完毕,可以返回了。可是 Linux 内核在这里有个特殊的考虑,这就是所谓 softirq,即 “(在时间上)软性的中断请求”,以前称为 “bottomhalf" 。 在 Linux 中,设备驱动程序的设计人员可以将中断服务分成两“半”,其实是两“部分”,而并不 一定是两“半”。第一部分是必须立即执行,一般是在关中断条件下执行的,并且必须是对每次请求都单独执行的。而另一部分,即“后半”部分,是可以稍后在开中条件下执行的,并且往往可以将若干次中断服务中剩下来的部分合并起来执行。这些操作往往是比较费时的,因而不适宜在关中断条件下执行, 或者不适宜一次占据 CPU 时间太长而影响对其他中断请求的服务。这就是所谓的 “后半”(bottom half),在内核代码中常简称为 bh。作为一个比喻,读者不妨想像在 “cooked mode” 下从键盘输入字符串的过程 (详见设备驱动),每当按一个键的时候,首先要把字符读进来,这要放在“前半”中执行;而进一步检查所按的是否“回车”键,从而决定是否完成了一个字符串的输入,并进一步把睡眠中的进程唤醒,则可以放在“后半”中执行。
执行 bh 的机制是内核中的一项“基础设施”,所以我们在下一节单独加以介绍。这里,读者暂且只要知道有这么个事就行了。
(3)ret_from_intr
在 do_softirq() 中执行完相关的 bh 函数 (如果有的话) 以后,就到了从 do_IRQ() 返回的时候了 。 返回到哪里? entry.S 中的标 号 ret_from_intr 处,这是内核中处心积虑安排好了的。其代码在 arch/i386/kernel/entry.S 中 。
# arch/i386/kernel/entry.S ENTRY(ret_from_intr) GET_CURRENT(%ebx) movl EFLAGS(%esp),%eax # mix EFLAGS and CS movb CS(%esp),%al testl $(VM_MASK | 3),%eax # return to VM86 mode or non-supervisor? jne ret_with_reschedule jmp restore_all
这里的 GET_CURRENT(%ebx) 将指向当前进程的 task_struct 结构的指针置入寄存器 EBX。275行和276行则在寄存器 EAX 中拼凑起由中断前夕寄存器 EFLAGS 的商16位和代码段寄存器CS的(16 位)内容构成的32位长整数。其目的是要检验:
中断前夕CPU是否运行于VM86模式。
中断前夕CPU运行于用户空间还是系统空间。
VM86 模式是为在i386保护模式下模拟运行 DOS 软件而设置的。在寄存器 EFLAGS 的高 16 位中有个标志位表示 CPU 正在 VM86 模式中运行,我们对 VM86 模式不感兴趣,所以不予深究。而 CS 的最低两位,那就有文章了。这两位代表着中断发生时CPU的运行级别CPL 。我们知道 Linux 只采用两种运行级别,系统为0,用户为3。所以,若是 CS 的最低两位为非 0 ,那就说明中断发生于用户空间。
顺便说下一下,275行的 EFLAGS (%esp) 表示地址为堆栈指针 %esp 的当前值加上常数 EFLAGS 处的内容,这就是保存在堆栈中的中断前夕寄存器 %eflags 的内容。常数 EFLAGS 我们已经在前面介绍过,其值为 0x30。276行中的 CS(%esp) 也是一样。
如果中断发生于系统空间,控制就直接转移到 restore_all ,而如果发生于用户空间 (或 VM86 模式) 则转移到 ret_with_reschedule。这里我们假定中断发生于用户空间,因为从ret_with_reschedule 最终还会到达 restore_all。
# arch/i386/kernel/entry.S ret_with_reschedule: cmpl $0,need_resched(%ebx) jne reschedule cmpl $0,sigpending(%ebx) jne signal_return restore_all: RESTORE_ALL ALIGN signal_return: sti # we can get here from an interrupt handler testl $(VM_MASK),EFLAGS(%esp) movl %esp,%eax jne v86_signal_return xorl %edx,%edx call SYMBOL_NAME(do_signal) jmp restore_all
这里,首先检查是否需要进行一次进程调度。上面我们已经看到,寄存器 EBX 中的内容就是当前进程的 task_struct 结构指针,而 need_resched(%ebx) 就表示该 task_struct 结构中位移为 need_resched 处的内容。220 行的 sigpending(%ebx)也是一样。常数 need_resched 和 sigpending 的定义为:(见 entry.S)
/* * these are offsets into the task-struct. */ state = 0 flags = 4 sigpending = 8 addr_limit = 12 exec_domain = 16 need_resched = 20 tsk_ptrace = 24 processor = 52 ENOSYS = 38
3、软中断与 Bottom Half
中断服务一般都是在将中断请求关闭的条件下执行的,以避免嵌套而使控制复杂化。可是,如果关中断的时间持续太长就可能因为 CPU 不能及时响应其他的中断请求而使中断(请求)丢失,为此, 内核允许在将具体的中断服务程序挂入中断请求队列时将 SA_INTERRUPT 标志置成 0,使这个中断服务程序在开中的条件下执行。然而,实际的情况往往是:若在服务的全过程关中断则“扩大打击面”, 而全程开中则又造成“不安定因素”,很难取舍。一般来说,一次中断服务的过程常常可以分成两部分。 开头的部分往往是必须在关中断条件下执行的。这样才能在不受干扰的条件下“原子”地完成一些关键性操作。同时,这部分操作的时间性又往往很强,必须在中断请求发生后“立即”或至少是在一定的时间限制中完成,而且相继的多次中断请求也不能合并在一起来处理。而后半部分,则通常可以, 而且应该在开中条件下执行,这样才不至于因将中断关闭过久而造成其他中断的丢失。同时,这些操作常常允许延迟到稍后才来执行,而且有可能将多次中断服务中的相关部分合并在一起处理。这些不同的性质常常使中断服务的前后两半明显地区分开来,可以、而且应该分别加以不同的实现。这里的后半部分就称为“bottom half”,在内核代码中常常缩写为 bh。这个概念在相当程度上来自 RISC 系统结构。在 RISC 的 CPU 中,通常都有大量的寄存器。当中断发生时,要将所有这些寄存器的内容都压入堆栈,并在返回时加以恢复,为此而付出很高的代价。所以,在 RISC 结构的系统中往往把中断服务分成两部分。第一部分只保存为数不多的寄存器(内容),并利用这为数不多的寄存器来完成有限的关键性的操作,称为“轻量级中断“。而另一部分,那就相当于这里的 bh 了。虽然 i386 的结构主要是 CISC 的,面临的问题不尽相同,但前述的问题已经使 bh 的必要性在许多情况下变得很明显了。
Linux 内核为将中断服务分成两半提供了方便,并设立了相应的机制。在以前的内核中,这个机制就称为 bh。但是,在2.4版(确切地说是 2.3.43 )中有了新的发展和推广。
以前的内核中设置了一个函数指针数组 bh_base[],其大小为 32,数组中的每个指针可以用来指向一个具体的 bh 函数。同时,又设置了两个 32 位无符号整数 bh_active 和 bh_mask ,每个无符号整数中的 32 位对应着数组 bh_base[] 中的 32 个元素。
我们可以在中断与 bh 二者之间建立起一种类比。
数组 bh_base[] 相当于硬件中断机制中的数组 irq_desc[]。不过 irq_desc[] 中的每个元素代表着一个中断通道,所以是一个中断服务程序队列。而 bh_base[] 中的每个元素却最多只能代表一个 bh 函数。但是,尽管如此,二者在概念上还是相同的。
无符号整数 bh_active 在概念上相当于硬件的“中断请求寄存器”,而 bh_mask 则相当于“中断屏蔽寄存器”。
需要执行一个 bh 函数时,就通过一个函数 mark_bh() 将 bh_active 中的某一位设成 1,相当于中断源发出了中断请求,而所设置的具体标志位则类似于“中断向量”。
如果相当于“中断屏蔽寄存器”的 bh_mask 中的相应位也是1,即系统允许执行这个 bh 函数, 那么就会在每次执行完 do_IRQ() 中的中断服务程序以后,以及每次系统调用结束之时,在一个函数do_bottom_half() 中执行相应的 bh 函数。而 do_bottom_half(),则类似于 do_IRQ()。
为了简化 bh 函数的设计,在 do_bottom_half() 中也像 do_IRQ() 中一样,把 bh 函数的执行严格地 “串行化” 了。这种串行化有两方面的考虑和措施:
一方面,bh 函数的执行不允许嵌套。如果在执行 bh 函数的过程中发生中断,那么由于每次中断服务以后在 do_IRQ() 中都要检查和处理 bh 函数的执行,就有可能嵌套。为此,在 do_bottom_half() 中针对同一 CPU 上的嵌套执行加了锁。这样,如果进入 do_bottom_half() 以后发现已经上了锁,就立即返回。因为这说明 CPU 在本次中断发生之前已经在这个函数中了。
另一方面,是在多 CPU 系统中,在同一时间内最多只允许一个 CPU 执行 bh 函数,以防有两个甚至更多个 CPU 同时来执行 bh 函数而互相干扰。为此在 do_bottom_half() 中针对不同 CPU 同时执行 bh 函数也加了锁。这样,如果进入 do_bottom_half() 以后发现这个锁已经锁上,就说明已经有 CPU 在执行 bh 函数,所以也立即返回。
这两条措施,特别是第二条措施,保证了从单 CPU 结构到多 CPU SMP 结构的平稳过渡。可是, 在当时的 Linux 内核可以在多 CPU SMP 结构上稳定运行以后,就慢慢发现这样的处理对于多 CPU SMP 结构的性能有不利的影响。原因就在于上述的第二条措施使 bh 函数的执行完全串行化了。当系统中有很多 bh 函数需要执行时,虽然系统中有多个 CPU 存在,却只存一个 CPU 这么一个“独木桥”。跟 do_IRQ() 作一比较就可以发现,在 do_IRQ() 中的串行化只是针对一个具体中断通道的,而 bh 函数的串行化却是全局性的,所以是“防卫过当” 了。既然如此,就应该考虑放宽上述的第二条措施。但是,如果放宽了这一条,就要对 bh 函数本身的设计和实现有更高的要求 (例如对使用全局量的互斥),而原来已经存在的 bh 函数显然不符合这些要求。所以,比较好的办法是保留 bh ,另外再增设一种或几种机制,并把它们纳入一个统一的框架中。这就是 2.4 版中的“软中断” (softirq) 机制。
从字面上说 softirq 就是软中断,可是“软中断”这个词 (尤其是在中文里) 已经被用作“信号” (signal)的代名词,因为信号实际上就是 “以软件手段实现的中断机制”。但是,另一方面,把类似于 bh 的机制称为“软中断”又确实很贴切。这一方面反映了上述 bh 函数与中断之间的类比,另一方面也反映了这是一种在时间要求上更为软性的中断请求。实际上,这里所体现的是层次的不同。如果说“硬中断”通常是外部设备对 CPU 的中断,那么 softirq 通常是 “硬中断服务程序” 对内核的中断, 而“信号”则是由内核(或其他进程)对某个进程的中断。后面这二者都是由软件产生的“软中断”。 所以,对“软中断”这个词的含意要根据上下文加以区分。
(1)softirq_init
下面,我们以 bh 函数为主线,通过阅读代码来叙述 2.4 版内核的软中断 (softirq) 机制。
系统在初始化时通过函数 softirq_init() 对内核的软中断机制进行初始化。其代码在 kemel/softirq.c
// kernel/softirq.c void __init softirq_init() { int i; for (i=0; i<32; i++) tasklet_init(bh_task_vec+i, bh_action, i); open_softirq(TASKLET_SOFTIRQ, tasklet_action, NULL); open_softirq(HI_SOFTIRQ, tasklet_hi_action, NULL); }
软中断本身是一种机制,同时也是一个框架。在这个框架里有 bh 机制,这是一种特殊的软中断, 也可以说是设计最保守的,但却是最简单、最安全的软中断。除此之外,还有其他的软中断,定义于 include/linux/interrupt.h :
// include/linux/interrupt.h enum { HI_SOFTIRQ=0, NET_TX_SOFTIRQ, NET_RX_SOFTIRQ, TASKLET_SOFTIRQ };
这里最值得注意的是 TASKLET_SOFTIRQ,代表着一种称为 tasklet 的机制。也许采用 tasklet 这个词的原意在于表示这是一片小小的“任务”,但是这个词容易使人联想到 “task” 即进程而引起误会, 其实这二者毫无关系。显然,NET_TX_SOFTIRQ 和 NET_RX_SOFTIRQ 两种软中断是专为网络操作而设的,所以在 softirq_init() 中只对 TASKLET_SOFTIRQ 和 HI_SOFTIRQ 两种软中断进行初始化。
先看 bh 机制的初始化。内核中为 bh 机制设置了一个结构数组 bh_task_vec[],这是 tasklet_struct 数据结构的数组。这种数据结构的定义也在 interrupt.h中:
// include/linux/interrupt.h /* Tasklets --- multithreaded analogue of BHs. Main feature differing them of generic softirqs: tasklet is running only on one CPU simultaneously. Main feature differing them of BHs: different tasklets may be run simultaneously on different CPUs. Properties: * If tasklet_schedule() is called, then tasklet is guaranteed to be executed on some cpu at least once after this. * If the tasklet is already scheduled, but its excecution is still not started, it will be executed only once. * If this tasklet is already running on another CPU (or schedule is called from tasklet itself), it is rescheduled for later. * Tasklet is strictly serialized wrt itself, but not wrt another tasklets. If client needs some intertask synchronization, he makes it with spinlocks. */ struct tasklet_struct { struct tasklet_struct *next; unsigned long state; atomic_t count; void (*func)(unsigned long); unsigned long data; };
代码的作者加了详细的注释,说 tasklet 是“多序” (不是“多进程”或“多线程”!) 的 bh 函数。 为什么这么说呢?因为对 tasklet 的串行化不像对 bh 函数那样严格,所以允许在不同的 CPU 上同时执行 tasklet ,但必须是不同的 tasklet 。一个 tasklet_struct 数据结构就代表着一个 tasklet ,结构中的函数指针 func 指向其服务程序。那么,为什么在 bh 机制中要使用这种数据结构呢?这是因为 bh 函数的执行 (并不是bh函数本身) 就是作为一个 tasklet 来实现的,在此基础上再加上更严格的限制,就成了 bh。
Linux 内核源代码情景分析(一)(下):https://developer.aliyun.com/article/1597931