firewood

linux内核分析——中断与异常

      学习linux的中断异常是前公司所在部门组织的学习任务,参照《深入理解linux内核》,每人选择一个章节进行系统性的深入学习,然后组织大家进行知识分享。这样每个人花费时间认真学习一个章节,就可以获取所有章节的知识,尽量用最少的时间达到最好的效果。当然如果不是自己尽心尽力去系统的学习,听别人讲解一般也就算入门级水平,知道某些概念和框架而已,但也可以节省大量时间了。实际执行过程中,毕竟大家不一定有充裕的时间学习,而且linux基础因人而异,所以在我离职之前也只组织过几次培训,想起来还是蛮怀念那段时间的。当时选择中断和异常这一章,是因为我是从小的嵌入式实时系统转到linux的,之前用的是uc/os,那时候就研究过uc/os的移植包括内核代码,还自学并移植freertos。因为这两个实时系统的移植工作主要是跟中断异常相关的,个人对这方面就会更感兴趣,想知道linux系统中复杂的中断和异常是如何实现的。

        一开始去看《深入理解linux内核》感觉真的是晦涩难懂,而且书本内容属于平铺直叙型,就是单纯的介绍,并不侧重于前后的逻辑性和思路引导。对于一个linux小白并且对逻辑需求又很高的人来说,认真的看完一段话其实只是在脑海中读了一遍而已,大脑完全没有去想这段文字的意思。但是毕竟时间可以改变一切,经历了万事开头难的阶段,以及其后一年多的时间断断续续的巩固和深化理解,终于可以对linux的中断框架总结一些东西。

        本文主要从四个方面来讲,中断和异常向量表的初始化、进入中断、中断描述符、退出中断。因为对细节需求很高,有一个小的环节不明白都会影响我对整个框架的理解,所以中后期的学习基本都是直接查阅代码的,这样可以看到每一个点的细节。所以文中会粘贴不少的源码,我现在使用的内核源码是linux4.20.5版本。学习过程中也拜读了许多大牛的博客。

       一、X86中断硬件体系结构

       X86中的中断控制器涉及到的概念有8259中断控制器(PIC----可编程中断控制器Local APICI/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来处理中断。

 

 

 

 

 

 

 

 

 

 

 

异常处理函数也是在这个文件中定义:

 

        

 

posted on 2019-10-17 18:32  firewood  阅读(1077)  评论(0编辑  收藏  举报

导航