Java自学者论坛

 找回密码
 立即注册

手机号码,快捷登录

恭喜Java自学者论坛(https://www.javazxz.com)已经为数万Java学习者服务超过8年了!积累会员资料超过10000G+
成为本站VIP会员,下载本站10000G+会员资源,会员资料板块,购买链接:点击进入购买VIP会员

JAVA高级面试进阶训练营视频教程

Java架构师系统进阶VIP课程

分布式高可用全栈开发微服务教程Go语言视频零基础入门到精通Java架构师3期(课件+源码)
Java开发全终端实战租房项目视频教程SpringBoot2.X入门到高级使用教程大数据培训第六期全套视频教程深度学习(CNN RNN GAN)算法原理Java亿级流量电商系统视频教程
互联网架构师视频教程年薪50万Spark2.0从入门到精通年薪50万!人工智能学习路线教程年薪50万大数据入门到精通学习路线年薪50万机器学习入门到精通教程
仿小米商城类app和小程序视频教程深度学习数据分析基础到实战最新黑马javaEE2.1就业课程从 0到JVM实战高手教程MySQL入门到精通教程
查看: 524|回复: 0

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

[复制链接]
  • TA的每日心情
    奋斗
    2024-11-24 15:47
  • 签到天数: 804 天

    [LV.10]以坛为家III

    2053

    主题

    2111

    帖子

    72万

    积分

    管理员

    Rank: 9Rank: 9Rank: 9

    积分
    726782
    发表于 2021-8-29 15:57:34 | 显示全部楼层 |阅读模式

          学习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来处理中断。

     

     

     

     

     

     

     

     

     

     

     

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

     

            

     

    哎...今天够累的,签到来了1...
    回复

    使用道具 举报

    您需要登录后才可以回帖 登录 | 立即注册

    本版积分规则

    QQ|手机版|小黑屋|Java自学者论坛 ( 声明:本站文章及资料整理自互联网,用于Java自学者交流学习使用,对资料版权不负任何法律责任,若有侵权请及时联系客服屏蔽删除 )

    GMT+8, 2024-12-23 04:17 , Processed in 0.063497 second(s), 29 queries .

    Powered by Discuz! X3.4

    Copyright © 2001-2021, Tencent Cloud.

    快速回复 返回顶部 返回列表