学习linux的中断异常是前公司所在部门组织的学习任务,参照《深入理解linux内核》,每人选择一个章节进行系统性的深入学习,然后组织大家进行知识分享。这样每个人花费时间认真学习一个章节,就可以获取所有章节的知识,尽量用最少的时间达到最好的效果。当然如果不是自己尽心尽力去系统的学习,听别人讲解一般也就算入门级水平,知道某些概念和框架而已,但也可以节省大量时间了。实际执行过程中,毕竟大家不一定有充裕的时间学习,而且linux基础因人而异,所以在我离职之前也只组织过几次培训,想起来还是蛮怀念那段时间的。当时选择中断和异常这一章,是因为我是从小的嵌入式实时系统转到linux的,之前用的是uc/os,那时候就研究过uc/os的移植包括内核代码,还自学并移植freertos。因为这两个实时系统的移植工作主要是跟中断异常相关的,个人对这方面就会更感兴趣,想知道linux系统中复杂的中断和异常是如何实现的。
一开始去看《深入理解linux内核》感觉真的是晦涩难懂,而且书本内容属于平铺直叙型,就是单纯的介绍,并不侧重于前后的逻辑性和思路引导。对于一个linux小白并且对逻辑需求又很高的人来说,认真的看完一段话其实只是在脑海中读了一遍而已,大脑完全没有去想这段文字的意思。但是毕竟时间可以改变一切,经历了万事开头难的阶段,以及其后一年多的时间断断续续的巩固和深化理解,终于可以对linux的中断框架总结一些东西。
本文主要从四个方面来讲,中断和异常向量表的初始化、进入中断、中断描述符、退出中断。因为对细节需求很高,有一个小的环节不明白都会影响我对整个框架的理解,所以中后期的学习基本都是直接查阅代码的,这样可以看到每一个点的细节。所以文中会粘贴不少的源码,我现在使用的内核源码是linux4.20.5版本。学习过程中也拜读了许多大牛的博客。
一、X86中断硬件体系结构
X86中的中断控制器涉及到的概念有8259中断控制器(PIC----可编程中断控制器)、Local APIC和I/O APIC(APIC------高级可编程中断控制器),另外在PCI /PCIE中还存在MSI中断。每个8259芯片支持8个中断信号,采用2级级联的方式可以支持15个中断信号。APIC是为支持多核CPU引入的,Local APIC和I/O APIC分别在CPU和chipset上,I/O APIC通过总线将中断信息分派给每颗CPU的LocalAPIC,LocalAPIC可以智能的决定是否接受总线上传来的中断信息,而且它还可以处理本地CPU中断的pending、nesting、masking。MSI中断依赖于中断控制器实现。
不管是哪种中断控制器,CPU都需要对其进行初始化。具体的初始化操作没有详细了解。
中断必然要涉及到中断向量表,在X86中有IVT和IDT两种结构,IVT适用于最开始的实模式,他的每一项就是相应中端的中断入口地址。IDT主要用于保护模式及之后的模式,IVT和IDT的基地址存储在IDTR寄存器中,CPU通过该寄存器获取中断向量表的基地址进而实现中断寻址。IDT的每一个表项是一个中断描述符结构,中断描述符由段寻址的地址+标志位组成,地址转换+权限判断,所以适用于保护模式。中断描述符的结构如下所示。
两个描述符的格式摘自 https://www.jianshu.com/p/54c1bf1b4aef
中断描述符中,描述符段选择子+偏移量共同实现了中断处理程序的寻址,这个与X86的段寻址结构设计相关。中断处理程序的地址=全局描述符表[段选择子] + 偏移量。但是在linux中并没有采用段式管理机制,所以全局描述符表中的段基址都被设置为0,其实偏移量就是最终的虚拟地址。其他还有一些bit,是用于设置CPU安全等级等相关操作的,与CPU架构设计息息相关,此处不再详细介绍。
参见 https://www.2cto.com/kf/201702/561719.html
http://news.eeworld.com.cn/qrs/2015/0821/article_24256.html
二、中断和异常向量表的初始化
linux的链接文件是/arch/x86/kernel/目录下的vmlinux.lds文件,从该文件可以看一下内存分配。系统的入口地址,中断向量表的定义等。从vmlinux.lds文件中看到,初始位置存放的是HEAD_TEXT段,也就是*(.head.text)段。从命名方式上看,这个段应该是跟makefile中指定的head-y中添加的内容相关,所以去head-y中定义的head_$(BITS).s中查找.head.text段的定义。
果然,在head_32/64.s文件开头分别看到了下面的定义,其中__HEAD在/inlude/linux/init.h中定义为*(.head.text)。这里定义的就是入口函数 startup_32和startup_64.
head_32.s:
__HEAD
ENTRY(startup_32)
movl pa(initial_stack),%ecx
/* test KEEP_SEGMENTS flag to see if the bootloader is asking
us to not reload segments */
testb $KEEP_SEGMENTS, BP_loadflags(%esi)
jnz 2f
head_64.s:
.text
__HEAD
.code64
.globl startup_64
startup_64:
UNWIND_HINT_EMPTY
跑题了,实际上中断和异常向量表的初始化并不是在head-32/64.s中定义的,而是在entry_32/64.s中定义的。entry.s这个文件定义的都是跟中断(中断和异常的进入退出、中断和异常处理入口)、fork退出(ret_from_fork)、任务调度(switch_to_asm)等相关的汇编代码。
1、中断处理函数定义
下面就看中断处理函数在entry.s中的定义:
/*
* Build the entry stubs with some assembler magic.
* We pack 1 stub into every 8-byte block.
*/
.align 8
ENTRY(irq_entries_start)
vector=FIRST_EXTERNAL_VECTOR
.rept (FIRST_SYSTEM_VECTOR - FIRST_EXTERNAL_VECTOR)
pushl $(~vector+0x80) /* Note: always in signed byte range */
vector=vector+1
jmp common_interrupt
.align 8
.endr
END(irq_entries_start)
分析上面的代码,真正定义的中断处理函数有两句话,内容如下:
pushl $(~vector + 0x80)
jmp common_interrupt
使用伪代码循环定义了所有外部中断的中断处理函数,循环次数即为外部中断的个数(FIRST_SYSTEM_VECTOR -- FIRST_EXTERNAL_VECTOR)。因为所有的中断处理函数的保存现场、跳转到内核中断处理函数的动作都是相同的,所以这部分工作是统一由common_interrupt实现的,只需要将当前的中断向量号推入堆栈即可,类似于向后面的commin_interrupt传入了一个参数,通知它当前处理的是哪一个中断。
这段汇编代码相当于定义了(FIRST_SYSTEM_VECTOR -- FIRST_EXTERNAL_VECTOR)个外部中断处理函数。所有的外部中断处理函数在内存中的存储形式如下图,这个图在后续中断向量表初始化过程中需要用到。
除了外部中断处理函数,异常处理函数也在entry.s中定义,在x86中前32个中断/异常向量编号服务于异常处理。截取部分异常处理代码如下图。异常处理的入口代码会有不同的处理方式,所以都是分开定义的,其通用的保存现场、调用异常处理子函数、异常返回等操作定义在了common_exception函数中。common_exception函数中需要直接调用不同的异常处理子函数。所以在这部分每个异常单独拥有的代码中,需要将对应的异常处理子函数的地址传递给common_exception,当然还会根据具体的异常处理子函数的设计,看是否需要向其输入参数。
ENTRY(coprocessor_error)
ASM_CLAC
pushl $0
pushl $do_coprocessor_error
jmp common_exception
END(coprocessor_error)
ENTRY(device_not_available)
ASM_CLAC
pushl $-1 # mark this as an int
pushl $do_device_not_available
jmp common_exception
END(device_not_available)
综上所述,entry.s中定义了所有的异常向量处理函数和外部中断处理函数。
谈到中断/异常向量表的初始化,还需要涉及到x86的中断体系架构。这部分内容对于每个CPU都不同,ARM架构和X86都有自己独特的体系结构,所以是在arch目录下定义的。X86的中断向量表称为IDT(interrupt description table),每一个表项称为中断描述符,这连续存放的每个表项就对应相应编号的异常/中断。像某些嵌入式系统的CPU,中断向量表可能就是直接存放中断处理程序的地址。对于复杂的X86架构,中断描述符不仅需要指明中断处理程序的入口地址,还包括一些权限说明。IDT表的首地址存放在IDTR寄存器中,对于CPU来说,中断响应时会根据IDTR中的数据获取IDT表的位置,然后去获取对应的中断描述符,进而根据中断描述符中的地址跳转执行。
中断描述符中,描述符段选择子+偏移量共同实现了中断处理程序的寻址,这个与X86的段寻址结构设计相关。中断处理程序的地址=全局描述符表[段选择子] + 偏移量。但是在linux中并没有采用段式管理机制,所以全局描述符表中的段基址都被设置为0,其实偏移量就是最终的虚拟地址。其他还有一些bit,是用于设置CPU安全等级等相关操作的,与CPU架构设计息息相关,此处不再详细介绍。
2、中断描述符表初始化
中断处理程序都定义好了,接下来就是要根据中断处理程序的首地址初始化中断描述符表了。在start_kernel()中,看到了init_IRQ()函数,顾名思义就是初始化中断相关的内容。
在init_IRQ()函数中调用了x86_init.irqs.intr_init(); x86_init可是一个很重要的结构体,这个结构体中包含了中断、时钟、页表、资源等等一系列的初始化操作。该结构体在/arch/x86/kernel/x86_init.c中定义,intr_init被赋值为native_init_IRQ。
void __init native_init_IRQ(void)
{
/* Execute any quirks before the call gates are initialised: */
x86_init.irqs.pre_vector_init();
idt_setup_apic_and_irq_gates(); //组建中断描述符表irq_desc.
lapic_assign_system_vectors();
if (!acpi_ioapic && !of_ioapic && nr_legacy_irqs())
setup_irq(2, &irq2);
irq_ctx_init(smp_processor_id());
}
在idt_setup_apic_and_irq_gates()函数中,可以看到这个函数初始化从中断号0xec开始的中断,中断号0xec就是system_vector_start。这是X86的系统中断,区别于外部中断。
void __init idt_setup_apic_and_irq_gates(void)
{
int i = FIRST_EXTERNAL_VECTOR;
void *entry;
idt_setup_from_table(idt_table, apic_idts, ARRAY_SIZE(apic_idts), true); //这个函数初始化从中断号0xec开始的中断。
for_each_clear_bit_from(i, system_vectors, FIRST_SYSTEM_VECTOR) {
entry = irq_entries_start + 8 * (i - FIRST_EXTERNAL_VECTOR); //初始化外部中断,看到了irq_entries_start
set_intr_gate(i, entry);
}
#ifdef CONFIG_X86_LOCAL_APIC
for_each_clear_bit_from(i, system_vectors, NR_VECTORS) {
set_bit(i, system_vectors);
set_intr_gate(i, spurious_interrupt);
}
#endif
}
二、中断处理
1、通用中断入口处理程序
common_interrupt的代码实现如下。
/*
* the CPU automatically disables interrupts when executing an IRQ vector,
* so IRQ-flags tracing has to follow that: */ .p2align CONFIG_X86_L1_CACHE_SHIFT common_interrupt: ASM_CLAC addl $-0x80, (%esp) /* Adjust vector into the [-256, -1] range */ //修正堆栈中中断向量号的格式 SAVE_ALL switch_stacks=1 //SAVE_ALL宏,保存现场 ENCODE_FRAME_POINTER TRACE_IRQS_OFF movl %esp, %eax //将堆栈中的数据(中断向量号)放到EAX寄存器,因为X86架构通过EAX向子函数传递参数 call do_IRQ //调用do_IRQ,相当于C代码中执行 do_IRQ(中断向量号)。 jmp ret_from_intr //跳转到中断退出函数(包括linux中断退出前的操作、恢复现场等操作) ENDPROC(common_interrupt)
2、保存现场
看一下SAVE_ALL的宏实现
.macro SAVE_ALL pt_regs_ax=%eax switch_stacks=0
cld //清除方向标志
PUSH_GS
pushl %fs
pushl %es
pushl %ds
pushl \pt_regs_ax
pushl %ebp
pushl %edi
pushl %esi
pushl %edx
pushl %ecx
pushl %ebx //至此,将段寄存器、通用寄存器的数值推入堆栈,防止原来的状态被中断处理过程污染
movl $(__USER_DS), %edx
movl %edx, %ds
movl %edx, %es
movl $(__KERNEL_PERCPU), %edx
movl %edx, %fs
SET_KERNEL_GS %edx
/* Switch to kernel stack if necessary */
.if \switch_stacks > 0
SWITCH_TO_KERNEL_STACK //是否切换到内核堆栈
.endif
.endm
注意,由于中断的不可预知性,CPU的硬件必须要对中断机制提供硬件支持,硬件实现与X86的体系架构也是绑定的。首先肯定是中断寻址(先找到中断描述符),还要针对中断描述符中的权限设置进行权限检查,还要根据中断描述符将CPU切换到RING0模式,如果发生了CPU模式的切换还需要切换堆栈(硬件首先从TSS结构中获得新的堆栈指针,然后将原来的堆栈指针推入新堆栈,然后将SP修改为新的堆栈指针),然后需要将被中断时的PC指针推入新堆栈,最后还要将中断入口地址推入PC寄存器实现硬件跳转。
所以进入中断处理程序时,(用户态进入内核态的情况)内核堆栈中已经包含原状态的堆栈指针和PC指针。然后跳转到中断处理程序后,首先将当前的中断号推入堆栈,然后通过SAVE_ALL宏将段寄存器、通用寄存器等都推入了堆栈中,完成了中断现场的保护。SAVE_ALL执行完成后,内核堆栈中的数据分布如下图。
3、x86中断处理程序do_IRQ
do_IRQ函数也是x86架构的专用代码,定义在/arch/x86/kernel/irq.c中。
/*
* do_IRQ handles all normal device IRQ's (the special
* SMP cross-CPU interrupts have their own specific
* handlers).
*/
__visible unsigned int __irq_entry do_IRQ(struct pt_regs *regs)
{
struct pt_regs *old_regs = set_irq_regs(regs);
struct irq_desc * desc;
/* high bit used in ret_from_ code */
unsigned vector = ~regs->orig_ax;
entering_irq();
/* entering_irq() tells RCU that we're not quiescent. Check it. */
RCU_LOCKDEP_WARN(!rcu_is_watching(), "IRQ failed to wake up RCU");
desc = __this_cpu_read(vector_irq[vector]); //根据中断号,获取linux的中断描述符结构 每CPU变量
if (!handle_irq(desc, regs)) { //子函数handle_irq,在该函数中响应中断
ack_APIC_irq(); //x86特有的中断应答
if (desc != VECTOR_RETRIGGERED) {
pr_emerg_ratelimited("%s: %d.%d No irq handler for vector\n",
__func__, smp_processor_id(),
vector);
} else {
__this_cpu_write(vector_irq[vector], VECTOR_UNUSED);
}
}
exiting_irq();
set_irq_regs(old_regs);
return 1;
}
handle_irq也是x86的处理函数,其定义在/arch/x86/kernel/irq_32.c中。说明x86和x64的处理方式是不同的。一开始看代码,看到generic_handle_irq_desc()函数就恍然大悟,就是在这里通过它直接调用desc->handle_irq函数呀。但是研究一下前面的代码结构,只有if成立的时候才会执行这个分支,如果if不成立则直接退出。为什么?研究一下execute_on_irq_stack()函数就知道了,顾名思义,“使用中断堆栈执行”,在这个函数中判断如果当前不是中断堆栈,需要切换到中断堆栈,然后执行desc->handle_irq,最后恢复到原来的堆栈(内核栈?),此时返回1,则if分支不成立,无需再次执行中断服务程序。user_mode(regs)这个判断条件没太想明白,如果是从用户态响应中断,那么就无需切换中断栈直接在内核堆栈运行??是担心内核态响应的话本来内核就会占用堆栈,容易导致内核堆栈溢出????
bool handle_irq(struct irq_desc *desc, struct pt_regs *regs)
{
int overflow = check_stack_overflow();
if (IS_ERR_OR_NULL(desc))
return false;
if (user_mode(regs) || !execute_on_irq_stack(overflow, desc)) { //excute_on_irq_stack():切换中断栈、调用中断处理函数、恢复内核堆栈。
if (unlikely(overflow))
print_stack_overflow();
generic_handle_irq_desc(desc); //linux通用中断处理函数,直接调用desc->handle_irq.
}
return true;
}
execute_on_irq_stack()函数没有必要研究那么细致,他的功能已经介绍了,因为涉及堆栈的切换,所以内部使用了内嵌汇编来实现。
static inline int execute_on_irq_stack(int overflow, struct irq_desc *desc)
{
struct irq_stack *curstk, *irqstk;
u32 *isp, *prev_esp, arg1;
curstk = (struct irq_stack *) current_stack();
irqstk = __this_cpu_read(hardirq_stack);
/*
* this is where we switch to the IRQ stack. However, if we are
* already using the IRQ stack (because we interrupted a hardirq
* handler) we can't do that and just have to keep using the
* current stack (which is the irq stack already after all)
*/
if (unlikely(curstk == irqstk))
return 0;
isp = (u32 *) ((char *)irqstk + sizeof(*irqstk));
/* Save the next esp at the bottom of the stack */
prev_esp = (u32 *)irqstk;
*prev_esp = current_stack_pointer; //当前堆栈存储到新堆栈底部,为什么?
if (unlikely(overflow))
call_on_stack(print_stack_overflow, isp);
asm volatile("xchgl %%ebx,%%esp \n" //堆栈指针ESP与EBX交换,ESP推入EBX,EBX推入ESP
CALL_NOSPEC //该宏定义展开是一段汇编代码,其中call *%[thunk_target]调用了中断服务程序。
"movl %%ebx,%%esp \n" //恢复堆栈指针
: "=a" (arg1), "=b" (isp)
: "0" (desc), "1" (isp),
[thunk_target] "D" (desc->handle_irq)
: "memory", "cc", "ecx");
return 1;
}
三、linux通用中断处理架构
linux通用的中断处理结构是这样的,对于每个中断号,都对应一个中断描述符结构irq_desc,一个中断号对应的信息都在这个结构体中维护。中断处理程序使用irqaction结构体来维护。对于共享中断的情况,一个中断信号可能由多个设备共享使用,所以使用action链表来维护所有设备的中断处理函数。
在x86的中断处理函数中,是调用了desc->handle_irq函数来处理中断。因为多个中断服务程序并不好维护,所以又封装了一个handle_irq函数,来判断当前是哪个设备的中断,并执行中断服务程序。
desc->handle_irq是在中断初始化的过程中已经配置好的,不受设备驱动动态管控。关于desc->handle_irq的配置可以从内核的C入口程序,start_kernel开始看,看内核是在什么地方初始化中断描述符。
start_kernel ---------> time_init() //在time_init函数中,只做了一件事儿,就是赋值late_time_init = x86_late_time_init;
----------> late_time_init() //定时器相关的初始化,并且调用x86_init.irqs.intr_mode_init();
--------->x86_init.irqs.intr_mode_init(); //其中intr_mode_init()被赋值为apic_intr_mode_init函数。
apic_intr_mode_init() ----->apic_bsp_setup() ------>setup_IO_APIC() ------>init_IO_APIC_traps() ------>
对每一个有效中断执行legacy_pic->make_irq(irq); = make_8259A_irq(irq); --------> irq_set_chip_and_handler(irq, &i8259A_chip, handle_level_irq); 相当于将handle_level_irq()赋值给了desc的handle_irq函数。也就是说对每一个中断,都采用handle_level_irq来处理中断。
异常处理函数也是在这个文件中定义:
|