首页
论坛
课程
招聘

Linux内核笔记008 - 中断的概念及硬件支持

jmpcall 2020-08-18 22:17
1. 概念了解
    有时候,应用程序开发的经验,很容易使我们的视野,停留在单个进程中,反而阻碍理解。我的方法是,把整个系统看成一个"大程序",这个程序最开始只有内核部分,随着执行,它又动态加载了一些指令和数据,作为自身的"生长部分",也就是说,把"进程"理解为整个"大程序"的"生长部分"。这样理解的好处是,会让视野从单个进程中走出来,从而才能看得清整个系统的运行逻辑:

    ① 任何时刻,这个"大程序"只有一条指令在被CPU执行。
    ② 系统启动阶段,从内核的代码开始执行,内核运行过程中,会动态扩展自己的指令和数据,并为"生长部分"分配独立的管理结构(上次从该"生长部分"跳转走的位置等),以及"生长部分"本身独立使用的资源("虚拟地址-物理地址"映射表等)。
    ③ 刚通电时,CPU为"实模式"状态,通过执行内核代码,切换到"保护模式",使得程序中的地址,要经过一次映射,才能得到物理地址,为各个"生长部分"的内存隔离,建立了基础。刚切换到"保护模式"时,CPU可以执行特权指令,并逐步创建了一些进程,由于内核开发人员,无法预测计算机用户,懂不懂自己去控制各种硬件,所以内核封装了大量接口给"生长部分"使用,同时也无法预测用户的程序会不会有bug,所以在跳转到"生长部分"之前,将CPU的特权级别修改为低级别。
    这样,疑问就很自然的产生了:
  • 某个"生长部分"执行while(1)死循环,为什么仍然可以执行到其它"生长部分"?
        CPU硬件层,提供了"时钟中断"功能,内核在启动阶段设置"时钟中断"处理函数并开启"时钟中断"功能,之后每隔一定时间,无论CPU执行到"大程序"的哪个地方,都会被"时钟中断"逻辑,保存CPU当时的执行状态(当时执行的指令位置、特权级别等),然后跳转到"时钟中断"处理函数,进一步再跳转到其它"生长部分"执行。
  • "生长部分"有bug,比如执行"除0"操作,是如何coredump的?
        软件中使用的指令集,都是由硬件提供的,而硬件层的设计保证了,当"除法指令"的除数为0时,保存CPU当时的执行状态,跳转到"除0"的异常处理函数执行,和"时钟中断"处理函数一样,各种异常处理函数,也都是由内核实现并在启动阶段设置。
  • "生长部分"如何调用内核接口?

        由于内核开发人员,只能掌控自己实现的代码,无法预测用户的代码,所以,整个"大程序"在往"生长部分"跳转的同时,一定会降低CPU权限,跳转回内核代码的同时,也必须提升CPU权限。有一种方法是,在软件层保证,每个中断处理函数,都在特权指令之前,执行提升CPU权限的指令,但这样,就增加了内核实现的负担,同时也会让中断处理函数的代码看起来很臃肿。

    所以,硬件层保证了这一点,回忆一下Linux内核笔记001介绍过的段描述符:

    

    如果type字段的二进程值为100/101/110/111,硬件层就会按照"门"的含义解释:

   

   

    利用"门",应用程序中执行一条指令(int/call/jmp),就可以同时完成"向内核代码跳转"和"提升CPU权限"两个动作,具体过程稍后详细学习,这里先根据上述内容,做个小小的展开,这样稍后深入到细节分析时,就不会思维混乱了:

    ① 内核代码,一部分在刚开机时执行,没有进程上下文,一部分在创建进程后,被进程"调用"执行,有进程上下文

    ② 用户态是通过"门",切换到内核态,根据触发条件,分为中断、异常、陷阱。时钟中断,跟当时的进程上下文无关,CPU异常处理函数内部,可能又会出现缺页等异常,或者发生时钟中断,就会出现"中断嵌套"的情况。

        

2. X86 CPU对中断的硬件支持

  • 任务门
        80386提供的4种"门",结构图都可以从上述内容看到,它们有个共同的特点,就是对应段描述符"B15-B0"的位置,都被CPU硬件层设计解释为段选择子,也就是说,通过"门",最终得到的仍然是段描述符,只是多走了一道而已。相比于其它三种"门",任务门的段描述符,指向长度固定为104的段,并且整个段被CPU硬件层设计解释为TSS结构,也正是用于得到段的首地址,就能得到TSS结构的地址,所以对应其它三种"门"中表示位移的位置,空闲不用。
    
    ① 什么是TSS?
    由于CPU硬件设计阶段,无法完全预料内核会放弃使用哪些硬件特征,或者由于历史原因和对兼容性的考虑,会存在一些软件层不愿意使用,但又无法关闭的功能,对于这样的功能,软件层一般只是"假装"使用, 即逻辑上并没有使用,纯粹保证硬件内部执行不出错而已。比如:80386寻址过程中,段式寻址逻辑是必然要执行的,同时段式寻址的设计意图决定了,各个进程的虚拟空间,必须用独立的LDT管理,内核也必须用GDT管理各个LDT所在的段。所以,尽管Linux内核只想用"页"记录进程的虚拟空间使用情况,但仍然要为各个进程安排一个LDT,并在GDT设置一个全局描述符指定它,只不过所有进程都使用同一个LDT,并且LDT中始终只有一个局部描述符,指向0地址开始的整个4G虚拟空间。
    本篇笔记开始部分,将整个系统看作一个"大程序",当时也一步步分析了,"生长部分"需要有资源记录本身的"虚拟地址-物理地址"映射关系,LDT正是用于这个目的,另外还需要其它管理信息,比如,上次从该"生长部分"跳转走时的位置、各个寄存器的值等。为此,CPU硬件层,还要求内核为每个进程,设置一个TSS结构(定义如下),每个进程的TSS要独占一个段,并在GDT中设置一个全局描述符。
struct tss_struct {
	unsigned short	back_link,__blh;
	unsigned long	esp0;
	unsigned short	ss0,__ss0h;
	unsigned long	esp1;
	unsigned short	ss1,__ss1h;
	unsigned long	esp2;
	unsigned short	ss2,__ss2h;
	unsigned long	__cr3;
	unsigned long	eip;
	unsigned long	eflags;
	unsigned long	eax,ecx,edx,ebx;
	unsigned long	esp;
	unsigned long	ebp;
	unsigned long	esi;
	unsigned long	edi;
	unsigned short	es, __esh;
	unsigned short	cs, __csh;
	unsigned short	ss, __ssh;
	unsigned short	ds, __dsh;
	unsigned short	fs, __fsh;
	unsigned short	gs, __gsh;
	unsigned short	ldt, __ldth;
	unsigned short	trace, bitmap;
	unsigned long	io_bitmap[IO_BITMAP_SIZE+1];
	/*
	 * pads the TSS to be cacheline-aligned (size is 0x100)
	 */
	unsigned long __cacheline_filler[5];
};
    ② TSS是不是只能通过任务门访问?
    80386任务门的本质,就是硬件层设计,将所含的段选择子,解释为TSS段描述符:执行int/call/jmp指令时,如果在指令寻址过程中,遇到任务门,CPU就会将当前的状态保存到当前进程的TSS结构(TR寄存器始终指向当前进程的TSS),并让TR寄存器指向新的TSS,从而在硬件层完成进程切换。不过,等到学完第四章"进程与进程调度",就会发现,Linux内核并不使用任务门进行进程切换,那为什么从Linux内核中,还是能看到TSS相关的代码呢?那是由于,要从"生长部分"主动或被动跳转到内核代码,根据存储在内核空间的进程管理信息,才可以进行进程切换,这个过程会发生CPU权限的变化,而CPU在进入新的运行级别时,会自动从当前进程的TSS中装入相应运行级别的SS和ESP,所以Linux内核仍然要为每个进程设置TSS,并且要正确设置esp0、ss0成员的值,保证切换到内核态时,CPU内部执行不出错。
#define INIT_TSS  {						\
	0,0, /* back_link, __blh */				\
	sizeof(init_stack) + (long) &init_stack, /* esp0 */	\
	__KERNEL_DS, 0, /* ss0 */				\
	0,0,0,0,0,0, /* stack1, stack2 */			\
	0, /* cr3 */						\
	0,0, /* eip,eflags */					\
	0,0,0,0, /* eax,ecx,edx,ebx */				\
	0,0,0,0, /* esp,ebp,esi,edi */				\
	0,0,0,0,0,0, /* es,cs,ss */				\
	0,0,0,0,0,0, /* ds,fs,gs */				\
	__LDT(0),0, /* ldt */					\
	0, INVALID_IO_BITMAP_OFFSET, /* tace, bitmap */		\
	{~0, } /* ioperm */					\
}
  • 中断门、陷阱门、调用门
        ① 和任务门相比,中断门/陷阱门/调用门最终指向的都是某个代码段,并且都要指定对应的执行函数,在整个代码段的多少偏移处;
        ② 中断门和陷阱门相比,进入中断门后,CPU会将EFLAGS寄存器中IF标志位清0,即不再响应新的中断,可以用于防止中断嵌套,否则如果时钟中断处理函数执行时间,大于时钟中断产生间隔,就会一直执行时钟中断函数;
        ③ 80386的各种"门",是为不同的使用场合设计的,权限检查过程各不相同且复杂("门"本身有DPL,"门"所指段描述符也有DPL),但Linux内核不使用任务门(主要考虑性能),也几乎不使用调用门(简单就是美);
        ④ 进入中断处理函数时,CPU要将当前的EFLAGS寄存器、CS:EIP内容,压入栈中,如果是由异常引起的,还要压入一个错误码,如果CPU运行级别改变,要先根据TSS切换到新级别对应的栈,并且先将切换前的CS:EIP压入栈中(下一篇笔记,就会看到这些压栈操作的意义);
             
3. 中断向量表IDT的初始化
    硬件设计了GDT/LDT寄存器,指向段描述符表,同样,也设计了IDTR寄存器,指向中断向量表("门描述符"表)。
    ① INT指令的参数,即为IDT的索引;
    ② IDT前20个中断向量,完全由CPU硬件层使用,必须按照CPU的硬件规范设置,比如缺页异常时,CPU会自动穿过14号门,如果内核启动阶段,在14号向量设置的门,不能进入缺页异常处理函数,系统就会运行出错。

    ③ i386的系统结构,支持256个中断向量,软件层从0x20号向量开始使用,共224个,其中80号向量用于系统调用,所以才可以通过"INT 80",从用户态切换到内核态。
  • start_kernel()调用trap_init()、init_IRQ()分别初始化硬件层、软件层使用的部分
start_kernel()
 |  // 最前面19个中断向量,由硬件使用,比如缺页异常时,CPU自动穿过14号向量对应的门
 |- trap_init()
 |   |- set_trap_gate()
 |   |   |  // 15:1111 => D:1,type:111,陷阱门
 |   |   |  // DPL:0   => 防止用户态使用INT指令穿过该门
 |   |   |- _set_gate(idt_table+n,15,0,addr)
 |   |- set_intr_gate()
 |   |   |  // 14:1110 => D:1,type:110,中断门(与陷阱门的唯一区别是:不会中断嵌套)
 |   |   |  // 用于外设中断
 |   |   |- _set_gate(idt_table+n,14,0,addr)
 |   |- set_system_gate()
 |   |   |  // DPL:3   => 用户态可以使用INT指令穿过该门(系统调用)
 |   |   |  // 用户态执行系统调用,最终就是通过"INT 80",切换到内核态
 |   |   |- _set_gate(idt_table+n,15,3,addr)
 |   |- set_call_gate()
 |   |   |  // 12:1100 => D:1,type:100,调用门
 |   |   |  // iBCS、Solaris/x86支持通过调用门进入系统调用,为了保证在这些系统中编译的程序,可以在Linux上执行
 |   |   |- _set_gate(a,12,3,addr)
 |- init_IRQ()    // 本篇笔记最后一节,详细分析
  • trap_init()函数分析
        不管设置哪种门,最终都调用了_set_gate(),它是个宏函数:
      
        Linux内核笔记003专门学习过AT&T格式汇编,以及它嵌入在C语言中的形式,这个宏函数不难分析:
        ① 输入部,将3,edx,__d1初始化为addr,将2,eax,__d0初始化为__KERNEL_CS<<16;
           
        ② "movw %%dx,%%ax":设置AX为DX的值,即addr低16位;
        ③ "movw %4,%%dx":设置DX为0x8000+(dpl<<13)+(type<<8));
           
        ④ "movl %%eax,%0"
        ⑤ "movl %%edx,%1"
            以上将"门描述符"的高低32位内容,构造到了EDX、DAX寄存器中,最后两条指令将"门描述符"写入gate_addr指向的内存中。

4. 中断请求队列的初始化

    软件层从0x20号向量开始使用,80号向量用于系统调用,还剩223个可以使用,Linux内核的使用方式为:
    启动时,为各个门设置处理函数"IRQ0xXX_interrupt->common_interrupt->do_IRQ()",其中IRQ0xXX_interrupt、common_interrupt的代码中,没有RET指令,并且不是用CALL指令跳转,而是用JMP指令跳转的,可以把三者看作一个整体"do_IRQ_XX()",不过,"do_IRQ_XX()"只是执行最终处理函数的"通道",最终处理函数,是挂在irq_desc[0xXX].action链表中的,由"do_IRQ_XX()"遍历间接执行。
    
    这样,通过一个中断向量,就可以执行多个不同的函数。比如,两个不同的驱动中,都希望响应某个相同的中断,它们的处理函数,就可以共用同一个中断向量。
    另外,外设与中断控制器,位置关系大概如下:
    
    PCI驱动显然知道,各个外设插槽与中断控制器引脚的对应关系,相应的,外设驱动将中断信号发送到CPU,对应的IRQ号为多少,也是可以获取的,也就可以,将中断控制器提供的一套引脚操作函数,记录到相应的irq_desc[0xXX].handler,供外设驱动从中断控制器层,对各个"中断通道"进程控制。
    
    介绍到这里,init_IRQ()函数就不难分析了:
init_IRQ()
 |- init_ISA_irqs()
 |   |  // 初始化i8259A中断控制器
 |   |- init_8259A()
 |   |- for (0->15)
 |   |   |- irq_desc[i].handler = &i8259A_irq_type
 | /*
 |  * interrupt函数指针数组:
 |  * void (*interrupt[NR_IRQS])(void) = {
 |  * 	IRQLIST_16(0x0),
 |  * };   |
 |  *      |- IRQ(0x0,0)
 |  * 	   |   |- IRQ0x00_interrupt
 |  *      |- ..
 |  *      |- IRQ(0x0,f)
 |  * 	       |- IRQ0x0f_interrupt
 | */
 | /*
 |  * IRQ0xXX_interrupt()函数定义:
 |  * BUILD_16_IRQS(0x0)
 |  *  |- BI(0x0,0)
 |  *  |   |- BUILD_IRQ(0x00)
 |  *  |   |   |- IRQ_NAME(0x00)
 |  *  |   |       |- IRQ_NAME2(IRQ0x00)
 |  *  |   |           |- IRQ0x00_interrupt(void)
 |  *  |   |- asmlinkage void IRQ0x00_interrupt(void)
 |  *  |   |  __asm__( \
 |  *  |   |    ".align 0"
 |  *  |   |    "IRQ0x00_interrupt:"
 |  *  |   |    "pushl $0x00-256"
 |  *  |   |    "jmp common_interrupt");
 |  *  |- ..
 |  *  |- BI(0x0,f)
 |  *      |- asmlinkage void IRQ0x0f_interrupt(void)
 | */
 |  // 设置20~35号中断向量对应的门,处理函数分别为IRQ0x00_interrupt(), .., IRQ0x0f_interrupt()
 |- for (0->15)
 |   |- set_intr_gate()

分享到:
最新评论 (0)
登录后即可评论