Linux内核笔记009 - 中断、异常、陷阱、Bottom half、信号

发布者:jmpcall
发布于:2020-09-18 17:38
1. 中断本质:保存当前执行现场+触发指令跳转

    记得刚学习C语言时,只要找个包含if语句的程序,然后通过理解整个程序执行到这条语句时,发生了什么,自然就明白if语句的作用了。同样,为了理解"中断"的含义,我特别建议站在可以看见整个系统的角度,去看出现中断时,整个系统这个"大程序"是如何执行的。

    时钟中断(硬件触发,对于软件是被动的)、异常(软件缺页、除0bug等情况无意触发)、陷阱(软件显式执行int指令触发)出现时,都会穿过一道"门",跳转到内核在"门"中设置的指令地址处执行,所以它们本质上和执行jmp、call、rte等跳转指令一样,都是打断"大程序"的顺序执行,跳转到指定的指令处执行,只不过在跳转前,CPU硬件层还会做一些额外的操作

    中断、异常、陷阱相互之间,只在两点上稍有区别(根据本篇笔记稍后的内容,可以明白为什么需要这些区别):

    ① 紧接着中断的发生,硬件层是否关闭该类型的中断;

    ② 穿过"门"的时候,CPL/RPL权限检查的逻辑。

    中断类型(中断/异常/陷阱)的区别,在于触发的形式不同,而"门"类型(中断门/陷阱门/调用门/任务门)的区别,在于穿过"门"时,硬件层执行的动作不同(主要有DPL检查逻辑、压栈内容、返回指令的位置)。Linux几乎只使用了中断门和陷阱门,外设触发的是中断门,CPU本身的异常和int指令,触发的都是陷阱门:

2. 外设通用中断(使用中断门)

  • 中断过程分析
        Linux内核笔记008已经介绍过,i386的系统结构支持256个中断向量,0~19号"门",必须按照CPU的硬件规范进行设置,其余的"门"由内核自行使用。其中,Linux内核选择使用80号"门"实现系统调用功能,用于提供给应用层程序,通过执行int指令,切换到内核态,而将从0x20号开始的其它223个"门",设计成了通用中断通道,用于提供给外设使用。有了外设通用中断通道,中断控制器监测到某个外设的某个动作时,向CPU发送中断信号后,CPU硬件层的逻辑,会自动且原子的执行一组指令,进行栈的切换(如果运行级别改变)和部分寄存器的压栈操作(见下图),并穿过相应的"门",跳转到IRQ0xXX_interrupt代码处执行(见以下代码片段[code1]、[code2]、[code3])。

[code1]arch/i386/kernel/i8259.c,36~51:
BUILD_COMMON_IRQ()    // 展开得到:common_interrupt代码块定义(见[code3])

// ③ 展开得到16个代码块的定义:IRQ0x00_interrupt~IRQ0x0f_interrupt(见[code2])
#define BI(x,y) \
	BUILD_IRQ(x##y)

// ② 展开得到:BUILD_IRQ(0x00)~BUILD_RIQ(0x0f)
#define BUILD_16_IRQS(x) \
	BI(x,0) BI(x,1) BI(x,2) BI(x,3) \
	BI(x,4) BI(x,5) BI(x,6) BI(x,7) \
	BI(x,8) BI(x,9) BI(x,a) BI(x,b) \
	BI(x,c) BI(x,d) BI(x,e) BI(x,f)

// ① 展开得到:BI(0x0,0)~BI(0x,f)
/*
 * ISA PIC or low IO-APIC triggered (INTA-cycle or APIC) interrupts:
 * (these are usually mapped to vectors 0x20-0x2f)
 */
BUILD_16_IRQS(0x0)
[code2]include/asm-i386/hw_irq.h,172~178:
// 所有的IRQ0xXX_interrupt,都将(xx-256)压入栈中,然后跳转到common_interrupt处执行
#define BUILD_IRQ(nr) \
asmlinkage void IRQ_NAME(nr); \
__asm__( \
"\n"__ALIGN_STR"\n" \
SYMBOL_NAME_STR(IRQ) #nr "_interrupt:\n\t" \
	"pushl $"#nr"-256\n\t" \
	"jmp common_interrupt");
[code3]include/asm-i386/hw_irq.h,152~160:
/*
 * ① SAVE_ALL:向栈中压入一个struct pt_regs结构数据
 * ② pushl $ret_from_intr,向栈中压入ret_from_intr指令地址
 * ③ jmp到do_IRQ()函数,注意不是call,所以步骤②压入的指令地址,对于do_IRQ()函数来说,是返回地址 !!
 *   do_IRQ()函数原型:asmlinkage unsigned int do_IRQ(struct pt_regs regs),注意参数类型是一个完整的结构,而不是指针,所以正好是步骤①压入参数 !!
*/
#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));

    根据[code1]、[code2]、[code3]三处代码,可归纳中断发生时的跳转过程:

IRQ0x00_interrupt           // 代码块
  |- jmp common_interrupt   // 代码块
       |- jmp do_IRQ()      // 函数
    其中,SAVE_ALL执行过后,栈中的内容如下图所示,同时由于希望do_IRQ()函数返回到ret_from_intr指令处,而不是"jmp $do_IRQ"的下一条指令,所以common_interrupt是将$ret_from_intr压栈,通过jmp指令"调用"do_IRQ()函数,并且在返回地址上面构造的是do_IRQ()函数的参数。


    进入do_IRQ()函数后,根据IRQ0xXX_interrupt向栈的ORIG_EAX位置压入的通用中断号,并遍历执行irq_desc[regs.orig_eax].action链表中的函数:

do_IRQ()
 |- irq = regs.orig_eax & 0xff
 |- spin_lock(&desc->lock)
 |- IRQ_INPROGRESS
 |-  for(;;)
 |    |- IRQ_PENDING
 |    |- handle_IRQ_event()
 |- do_softirq()
    ① spin_lock(&desc->lock)的作用
        中断门与陷阱门硬件特性的区别是,CPU穿过中断门时,会自动关闭中断(将EFLAGS寄存器中的"I"标志位清零,在该标志位重新置1之前,硬件层不再响应任何中断源发送的中断信号),而穿过陷阱门时,则不会。do_IRQ()函数,正是通过中断门执行到的,虽然当前CPU不会再次通过中断门执行进来,但其它CPU仍然可以,所以加锁保证多核之间相互干扰。
    ② IRQ_INPROGRESS、IRQ_PENDING标志的作用
        根据do_IRQ()的源代码可以看出,并不是整个函数都是加锁的,在handle_IRQ_event()调用期间,就是unlock的,因为遍历执行irq_desc[regs.orig_eax].action链表中的函数,可能需要花费很长的时间,所以在调用之前unlock,可以避免其它CPU也跟着一起白白的等待。但这并不表示handle_IRQ_event()可以由多个核同时执行,相反,内核最终就是要保证handle_IRQ_event()不会被多核同时执行,只是为了避免锁加的太粗暴而已。
        为了保护handle_IRQ_event(),加了锁,却又为了减小核与核之间的竞争,反而又将handle_IRQ_event()放在锁的范围之外,总而言之,是不是感觉白白加了个废锁?
        其实,这里是将中断嵌套转化成了一个循环,或者说是将handle_IRQ_event()的执行"串行化"  。已经有一个CPU正在执行handle_IRQ_event()时,会设置一下IRQ_INPROGRESS标志,其它CPU此时进入do_IRQ()执行时,发现设置了IRQ_INPROGRESS标志,就不会也进入handle_IRQ_event(),而是设置一下IRQ_PENDING标志就返回了,这样,执行handle_IRQ_event()的CPU,会在for(;;)循环中,再次进入handle_IRQ_event()执行。
        在handle_IRQ_event()执行期间,可能有多个核进入do_IRQ()函数设置IRQ_PENDING标志,也有可能某一个核设置多次,最终都只会再调用一次handle_IRQ_event()函数,比如网卡接收了3个报文,进入do_IRQ()三次,但最终可能只调用handle_IRQ_event()一次,同时处理了缓存中的3个接收报文。

     

    ③ handle_IRQ_event()函数开中断执行

        EFLAGS寄存器并不能精确控制每道"门"的开关,只能通过一个"I"标志位,整体打开/关闭所有"门",所以从CPU穿过某道中断门到再次将"I"标志位置1期间,任何中断源发送的中断信号,都会丢失,虽然中断源没有收到CPU的响应信号,一般会再次发送,Linux内核还是通过软件层的设计,尽量缓解中断信号的丢失。其实,不同irq_desc[X].action链表中的函数,既然是处理不同的外设中断,所以访问的资源一般是相互独立的,很少会出现竞争的情况,所以Linux内核将是否允许重入handle_IRQ_event()(即正在该函数内部执行时接收到中断信号,又要重新从IRQ0xXX_interrupt开始,执行到该函数),留给action开发者选择。

        比如,如果开发者可以保证,irq_desc[0x00].action链表上的函数,与其它action链表上的函数,不存在资源竞争,那么,就可以通过设置actions->flags的SA_INTERRUPT标志,让handle_IRQ_event()函数在入口处执行sti指令,快速恢复当前CPU的中断功能。这样,由于do_IRQ()在调用handle_IRQ_event()前,执行了unlock,所以再次进入do_IRQ(),不会发生死锁;另外,由于没有资源竞争,所以交叉执行也不会有任何问题(irq_desc[0x00].action未执行完 -> 执行irq_desc[0x01].action -> 根据中断时保存的现场,恢复执行irq_desc[0x00].action)。

      

        还有另外一种场景:CPU正在执行0号通用中断的处理函数,这时又产生了0号通用中断。其实,这就跟多CPU同时执行同一中断通道的场景相同, handle_IRQ_event()会被IRQ_INPROGRESS、IRQ_PENDING"串行化"执行,所以也没有问题:

      

        可以看出,选择中断门和使用"串行化"设计,对外设中断进行管理,大大减小了action开发者的负担,相应也减少了产生bug的根源。

  • Bottom Half
        do_IRQ()执行完handle_IRQ_event(),还会在清空IRQ_INPROGRESS标志和unlock之后,调用do_softirq()函数(比Linux-2.4.0版本老一些的内核中,调用的是do_bottom_half()函数)
     
        相对于SA_INTERRUPT标志,do_softirq()是用于提供给通用中断使用者,更精细化的选择"开中断"执行的范围。
        这块逻辑比较绕,直接看图吧:

        初始化:
softirq_init()
 |- tasklet_init()  // 初始化bh_task_vec[32],func成员都指向bh_action()
 |- open_softirq()  // 初始化softirq_vec[32]
     |- softirq_vec[HI_SOFTIRQ].action = tasklet_hi_action()      // HI_SOFTIRQ用于兼容老的bottom hafl机制
     |- softirq_vec[TASKLET_SOFTIRQ].action = tasklet_action()    // TASKLET_SOFTIRQ用于新扩展的bottom hafl机制
	 |- irq_stat[所有CPU].__softirq_mask的HI_SOFTIRQ、TASKLET_SOFTIRQ位置1,从而每次执行do_softirq()时,就会调用tasklet_hi_action()、tasklet_action()

sched_init()        // 别的模块也会根据需要注册其它bh函数
 |- init_bh(TIMER_BH, timer_bh)
 |- init_bh(TQUEUE_BH, tqueue_bh)
 |- init_bh(IMMEDIATE_BH, immediate_bh)
        这时,再假设CPU0接收到某个中断信号,顺着整个中断的响应过程过一遍,就会发现:
        这套设计,是为了将耗时并且不要求在"关中断"条件下执行的操作,从handle_IRQ_event()->action中"支开",action完成少量必须在"关中断"条件下执行的操作后,然后只要通过标记"通知"一下do_softirq()后续需要做什么,就可以快速恢复中断功能了。
        其中,HI_SOFTIRQ、TASKLET_SOFTIRQ,是在老版本bottom half机制上又扩展的一层逻辑,HI_SOFTIRQ在调用到最终的bh_base[X]()之前,必须先经过bh_action()函数,bh_action()函数中做了很严格的保护操作,使得多核之间的竞争和中断丢换更多,同时也对bh函数的实现要求更低,TASKLET_SOFTIRQ相反,内核或驱动开发者,可以根据实际需要和对内核全局了解的程度进行选择。
    
  • 信号

        中断/异常:硬件对内核的中断;
        信号:内核对应用程序的中断。

3. 缺页异常(使用陷阱门,DPL为0)

    跟IRQ0xXX_interrupt类型,直接看代码。

arch/i386/kernel/entry.S,410~412:

ENTRY(page_fault)
	pushl $ SYMBOL_NAME(do_page_fault)
	jmp error_code
arch/i386/kernel/entry.S,295~321:
error_code:
	pushl %ds
	pushl %eax
	xorl %eax,%eax
	pushl %ebp
	pushl %edi
	pushl %esi
	pushl %edx
	decl %eax			# eax = -1
	pushl %ecx
	pushl %ebx
	cld
	movl %es,%ecx
	movl ORIG_EAX(%esp), %esi	# get the error code(硬件自动压入)
	movl ES(%esp), %edi		# get the function address(执行page_fault时压入)
	movl %eax, ORIG_EAX(%esp)
	movl %ecx, ES(%esp)
	movl %esp,%edx
	pushl %esi			# push the error code(do_page_fault()的error_code参数)
	pushl %edx			# push the pt_regs pointer(do_page_fault()的regs参数)
	movl $(__KERNEL_DS),%edx
	movl %edx,%ds
	movl %edx,%es
	GET_CURRENT(%ebx)
	call *%edi                      # 调用do_page_fault()
	addl $8,%esp
	jmp ret_from_exception
    error_code和common_interrupt类似,也是一份公用代码,CPU发生各种异常时,最终都会执行到这里。但是,对比这里的pushl指令和SAVE_ALL的代码,就会发现最开始少了一条"pushl %es"指令,那是因为缺页异常时,硬件除了将"EFLAGS->CS->EIP"自动压栈,还会压入一个导致缺页异常的错误码(也是栈的这个位置叫ORIG_EAX的原因),内核为了让中断、异常的最终处理函数,可以统一使用pt_regs结构,所以还是按照pt_regs结构压栈,最后再用"movl %es,%ecx"和"movl %ecx, ES(%esp)"两条指令,将es寄存器的值,存入栈的ES位置处。

    但是,有些异常没有更加详细的错误码,相应的,CPU也不会向栈中多压个值,为了仍然可以使用error_code处的代码,异常入口处,会向栈中补压一个值,比如:
ENTRY(coprocessor_error)
	pushl $0
	pushl $ SYMBOL_NAME(do_coprocessor_error)
	jmp error_code
arch/i386/mm/fault.c,106:

void do_page_fault(struct pt_regs *regs, unsigned long error_code)

4. 时钟中断(使用中断门)
    时钟中断使用的是0号通用中断门:
time_init()  // arch/i386/kernel/time.c, 626~706
 |- setup_irq(0, &irq0)  // 向irq_desc[0]注册action

// arch/i386/kernel/time.c, 547
static struct irqaction irq0 = { timer_interrupt, SA_INTERRUPT, 0, "timer", NULL, NULL};
    对于整个系统这个"大程序"来说,如果没有时钟中断进行强制"跳转",任何一个地方死循环(包括内核无法预测的应用程序),就会导致整个系统不工作,相反,就始终有回到内核代码,调度其它部分执行的机会。

5. 系统调用(使用陷阱门,DPL为3)

    跟CPU异常一样,系统调用也是用陷阱门实现:

static void __init set_trap_gate(unsigned int n, void *addr)
{
	_set_gate(idt_table+n,15,0,addr);    // 15: D:1,type:111(陷阱门),DPL: 0
}

static void __init set_system_gate(unsigned int n, void *addr)
{
	_set_gate(idt_table+n,15,3,addr);    // 15: D:1,type:111(陷阱门),DPL: 3
}
    外设中断和CPU异常时,硬件会忽略DPL检查,所以异常处理程序对应的"门",DPL设置为0,是用于防止程序在用户态穿过该"门",而系统调用正是提供给应用程序调用内核的接口,所以DPL设置为3。
    按照书上sethostname()系统调用的例子,过一遍:
// int sethostname(cost char *name, size_t len);
00000000 <sethostname>:
0: 89 da                mov %ebx,%edx
2: 8b 4c 24 08          mov 0x8(%esp,1),%ecx  # len 参数
6: 8b 5c 24 04          mov 0x4(%esp,1),%ebx  # name参数
a: b8 4a 00 00 00       mov $0x4a,%eax        # sethostname()函数对应的系统调用号
f: cd 80                int $0x80
11: 89 d3               mov %edx,%ebx
13: 3d 01 f0 ff ff      cmp $0xfffff001,%eax       # eax寄存器为内核接口的返回值,负数表示出错
18: 0f 83 fc ff ff ff   jae 1a <sethostname+0x1a>  # 重定位后,为__syscall_error()函数地址(将exa绝对值保存到errno,并将eax修改为-1,表示向上层程序返回-1)
1e: c3                  ret
    esp寄存器指向sethostname()函数的栈顶,沿着地址的增加,分别为返回地址、name参数、len参数,但由于系统调用会导致CPU运行级别变化,所以内核接口使用的是切换后的栈,所以必须复制到寄存器中传给内核接口。
    然后,CPU通过80号中断向量,"跳转"到system_call代码处:
ENTRY(system_call)
	pushl %eax			# save orig_eax,将eax寄存器中的系统调用号,压入系统栈的ORIG_EAX位置(终于看到这个名称的来历,外设中断时保存中断号,异常时保存错误码)
	SAVE_ALL                        # SAVE_ALL最后压入栈中的ecx、ebx,正好为long sys_sethostname(char *name, int len)的参数,跟外设中断和异常的处理函数不同,参数不再是struct pt_regs结构
	GET_CURRENT(%ebx)               # 将当前进程的task_struct管理结构的地址,保存到ebx寄存器(第四章)
	cmpl $(NR_syscalls),%eax
	jae badsys
	testb $0x02,tsk_ptrace(%ebx)	# PT_TRACESYS,如果当前进程被strace调试工具跟踪,跳转到tracesys()执行(暂不关心)
	jne tracesys
	call *SYMBOL_NAME(sys_call_table)(,%eax,4)    # 跳转到系统调用号对应的函数执行,即sys_sethostname()
	movl %eax,EAX(%esp)		# save the return value
ENTRY(ret_from_sys_call)
#ifdef CONFIG_SMP
	movl processor(%ebx),%eax
	shll $CONFIG_X86_L1_CACHE_SHIFT,%eax
	movl SYMBOL_NAME(irq_stat)(,%eax),%ecx		# softirq_active
	testl SYMBOL_NAME(irq_stat)+4(,%eax),%ecx	# softirq_mask
#else
	movl SYMBOL_NAME(irq_stat),%ecx		# softirq_active
	testl SYMBOL_NAME(irq_stat)+4,%ecx	# softirq_mask
#endif
	jne   handle_softirq

################# 以下部分,暂时了解即可 #################
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
    从system_call入口,最终是进入了sys_sethostname()函数:
sys_sethostname()
 |- copy_from_user()  // 将主机名修改到内核空间,从而所有进程可以看到新的主机名
     |- ..
         |- __copy_user_zeroing()  // 汇编代码,建议仔细品一品
    __copy_user_zeroing()代码分析:

    sys_sethostname()的name指针是从用户态传过来的,为了确保这个指针没有指飞,老版本内核是根据当前进程的mm_struct(记录已使用的虚拟区间,见Linux内核笔记005)进行检查,但是存在bug的代码相比于正常的代码,往往很少很少,换句话说,对于绝大多数这种情况,都是白白的做一次低效的检查。所以,新版本的内核取消了对name指针的合法性检查,直接使用,如果真的遇到错误指针,肯定会触发缺页异常进入do_page_fault()函数,就是说对这种情况的处理,可以移到do_page_fault()函数中实现:
// do_page_fault()函数片段
no_context:
	/* Are we prepared to handle this kernel fault?  */
	if ((fixup = search_exception_table(regs->eip)) != 0) {    // 在"异常表"中,查找导致异常的那条指令的地址(__copy_user_zeroing()的后面部分代码,就是向该表中加入"出错指令地址-修复地址"对应关系)
		regs->eip = fixup;    // 如果找到了,修改异常进程的eip,让它跳转到修复地址执行(否则回到原指令,又会触发缺页异常)
		return;
	}
        ...
do_sigbus:
	...

	/* Kernel mode? Handle exceptions or die */
	if (!(error_code & 4))    // 在内核态发生的缺页异常
		goto no_context;
	return;
    这样,再回到__copy_user_zeroing()看黑色字体的注释,就很容易理解了,gcc会将程序中.section属性指定的内容,添加到elf编译文件中的相应段中,运行时,由ld加载到内存作为"异常表"。除了__copy_user_zeroing()函数,SAVE_ALL中和iret指令也可能因为同样的原因,发生缺页异常,书中都已经解释了原因,笔记中就不一一搬过来了。

声明:该文观点仅代表作者本人,转载请注明来自看雪