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

本文转自网络文章,内容均为非盈利,版权归原作者所有。
转载此文章仅为个人收藏,分享知识,如有侵权,马上删除。
原文作者:jmpcall
专栏地址:https://zhuanlan.kanxue.com/user-815036.htm

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()

 

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值