本文转自网络文章,内容均为非盈利,版权归原作者所有。
转载此文章仅为个人收藏,分享知识,如有侵权,马上删除。
原文作者:jmpcall
专栏地址:https://zhuanlan.kanxue.com/user-815036.htm
记得刚学习C语言时,只要找个包含if语句的程序,然后通过理解整个程序执行到这条语句时,发生了什么,自然就明白if语句的作用了。同样,为了理解"中断"的含义,我特别建议站在可以看见整个系统的角度,去看出现中断时,整个系统这个"大程序"是如何执行的。
时钟中断(硬件触发,对于软件是被动的)、异常(软件缺页、除0bug等情况无意触发)、陷阱(软件显式执行int指令触发)出现时,都会穿过一道"门",跳转到内核在"门"中设置的指令地址处执行,所以它们本质上和执行jmp、call、rte等跳转指令一样,都是打断"大程序"的顺序执行,跳转到指定的指令处执行,只不过在跳转前,CPU硬件层还会做一些额外的操作。
中断、异常、陷阱相互之间,只在两点上稍有区别(根据本篇笔记稍后的内容,可以明白为什么需要这些区别):
① 紧接着中断的发生,硬件层是否关闭该类型的中断;
② 穿过"门"的时候,CPL/RPL权限检查的逻辑。
中断类型(中断/异常/陷阱)的区别,在于触发的形式不同,而"门"类型(中断门/陷阱门/调用门/任务门)的区别,在于穿过"门"时,硬件层执行的动作不同(主要有DPL检查逻辑、压栈内容、返回指令的位置)。Linux几乎只使用了中断门和陷阱门,外设触发的是中断门,CPU本身的异常和int指令,触发的都是陷阱门:
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相反,内核或驱动开发者,可以根据实际需要和对内核全局了解的程度进行选择。
信号
中断/异常:硬件对内核的中断;
信号:内核对应用程序的中断。
跟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)
时钟中断使用的是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};
对于整个系统这个"大程序"来说,如果没有时钟中断进行强制"跳转",任何一个地方死循环(包括内核无法预测的应用程序),就会导致整个系统不工作,相反,就始终有回到内核代码,调度其它部分执行的机会。
跟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 :
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 # 重定位后,为__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指令也可能因为同样的原因,发生缺页异常,书中都已经解释了原因,笔记中就不一一搬过来了。