中断和异常理论详解,Linux操作系统原理与应用

目录

一、中断的相关描述

        1、中断是什么

        2、为什么要引入中断

        3、中断向量

        4、外设可屏蔽中断

        5、异常及非屏蔽中断

        6、中断描述符表

        1、中断门(Interrupt Gate)

        2、陷阱门(Trap Gate)

        3、系统门(System Gate)

        7、相关的汇编指令

二、中断描述符表的初始化

        1、IDT表项的设置

        2、对陷阱门和系统门的初始化

        3、中断门的设置

        4、中断处理程序的形成

三、中断处理

        1、中断和异常的硬件处理

        2、中断请求队列的建立

        3、中断处理程序的执行

四、中断的下半部分处理机制

       1、为什么要把中断分为两部分来处理

        2、小任务机制(Tasklet)

        3、工作队列(Work Queue)

五、中断应用——时钟中断

        1、时钟硬件

        2、时钟运作机制

        3、Linux时间系统

        4、时钟中断

         5、定时器及应用


一、中断的相关描述

        1、中断是什么

        中断是中断源向处理器发出的请求。中断可分为两大类:中断和异常。中断又分为外部可屏蔽中断(INTR)和外部非屏蔽中断(NMI)。异常又分为故障(Fault)和陷阱(Trap)。Intel把非屏蔽中断作为异常的一种。

        2、为什么要引入中断

        (1)解决快速的CPU与慢速的外部设备之间的传送数据的速度不匹配的矛盾。

        (2)实现并发活动,实现计算机的自动化工作。

        3、中断向量

        Intel x86系列微机共支持256种向量中断,把这256从0~255进行编号,即给每种中断向量赋予一个中断类型码,这个中断类型码称为中断向量。类似系统调用号

        使用中断向量目的:为了使处理器较容易地识别每种中断源。

        非屏蔽中断的向量和异常的向量是固定的,而屏蔽中断的向量可以通过对中断控制器的编程来改变。Linux对256个向量的分配如下:

        (1)从0~31的向量对应于异常和非屏蔽中断。

        (2)从32~47的向量(即由I/O设备引起的中断)分配给屏蔽中断。

        (3)剩余的从48~255的向量用来标识软中断。Linux只用了其中一个(128向量或0x80向量)用来实现系统调用。

        可以在使用下面的命令来查看当前系统中各种外设的IRQ命令:

$ cat /proc/interrupts

        4、外设可屏蔽中断

       中断控制器是一个芯片,有中断控制器8258A。

        Intel x86通过两片中断控制器8259A来响应15个外中断源,每个8259A可管理8个中断源。第一级(称为主片)的第二个中断请求输入端,与第二级8259A(称为从片)的中断输出端INT相连。把与中断控制器相连的每条线叫做中断线。如果要使用中断线,就要进行中断线的申请,也就是IRQ,因此也常把申请一条中断线称为申请一个IRQ或者申请一个中断号。IRQ线是从0开始顺序编号的。IRQ和向量之间的映射可以通过中断控制器端口来修改。

         并不是每个设备都可以向中断线上发送中断信号,只有对某一确定的中断线拥有了控制权,才可以向这条中断线上发送信号。由于计算机的外部设备越来越多,而中断线是非常宝贵的资源,导致不够用,所以只有当设备需要中断的时候才申请占用一个IRQ,或者是在申请IRQ是采用共享中断的方式,才可以让更多的设备使用中断。

        对于外部I/O请求的屏蔽可以分为两种情况:

        (1)从CPU的角度,清除EFLAG的中断标志位(IF),当IF = 0时,禁止任何外部I/O的中断请求,即关中断。

        (2)从中断控制器的角度,因为中断控制器中有一个8位的中断屏蔽寄存器,每位对应8259A中的一条中断线,如果要禁用某条中断线,则把中断屏蔽寄存器对应位置为1,要启用,则置为0。

        5、异常及非屏蔽中断

        异常就是CPU内部出现的中断,也就是说,在CPU执行特定指令时出现的非法情况。如分母为零,数组越界,数据溢出等。

        非屏蔽中断就是计算机内部硬件出错引起的异常情况。

        异常和非屏蔽中断与外部I/O接口没有任何关系。Intel把非屏蔽中断作为异常的一种来处理。Linux内核必须为每种异常提供一个专门的异常处理程序。

        在CPU执行一个异常处理程序时,就不再为其他异常或可屏蔽中断请求服务,也就是说,在某个异常被响应后,CPU清除EFLAG中的IF位,禁止任何可屏蔽中断。但如果又有异常产生,则由CPU锁存(D触发器之类的锁存触发器具有锁存功能),即CPU具有缓冲异常的能力,待这个异常处理完毕后,才响应被锁存的异常。

        6、中断描述符表

        中断描述符表IDT又称中断向量表。其中每一个表项叫做一个门描述符,“门”的含义是当中断发生时必须先通过这些门,然后才能进入相应的处理程序。

        在实地址模式中,CPU把内存中从0开始的1K字节作为一个中断向量表。表中的每个表项占四个字节,由两个字节的段地址和两个字节的偏移量组成,这样构成的地址便是相应中断处理程序的入口地址。

        在保护模式下,由四字节的表项构成的中断向量表显然满足不了要求。这是因为,除了两个字节的段描述符,偏移量必用四字节来表示;要有反映模式切换的信息。因此,在保护模式下,中断向量表中的表项由8个字节组成。

        中断描述符表在内存的位置不再限于从地址0开始的地方,而是可以放在内存的任何地方。(只有CPU知道存放在哪里,CPU通过IDTR来寻找到起始地址)为此,CPU中增设了一个中断描述符表寄存器IDTR,用来存放中断描述符表在内存的起始地址。

        中断描述符表寄存器IDTR是一个48位的寄存器,其低16位保存中断描述符表的大小,高32位保存中断描述符表的基址。

        

         其中门类型码占3位,主要有以下三种:

        1、中断门(Interrupt Gate)

        其类型码为110,中断门包含了一个中断或异常处理程序所在的段的选择符和段内偏移量。当控制权通过中断门进入中断处理程序时,处理器请IF标志,即关中断,以避免嵌套中断的发生。中断门中的请求特权级(DPL)为0,因此,用户态的进程不能访问Intel的中断门。所有的中断程序都由中断门激活,并全部限制在内核态。

        2、陷阱门(Trap Gate)

        其类型码为111,与中断门类似,其唯一的区别是,控制权通过陷阱门进入处理程序时维持IF标志位不变,即不关中断

        3、系统门(System Gate)

        这是Linux内核特别设置的,用来让用户态的进程访问Intel的陷阱门,因此从某种意义来说,系统门也是一个陷阱门。门描述符的DPL为3系统调用就是通过系统门进入内核的。

        7、相关的汇编指令

        (1)调用过程指令CALL :CALL  过程名 在取出CALL指令之后及执行CALL指令之前,使指令指针寄存器EIP指向紧接CALL指令的下一条指令。在取出CALL指令之后及执行CALL指令之前,使指令指针寄存器EIP指向紧接CALL指令的下一条指令。

        (2)调用中断过程的指令INT :INT  中断向量      EFLAG、CS及EIP寄存器被压入栈内。控制权被转移到由中断向量指     定的中断处理程序。在中断处理程序结束时,IRET指令又把控制权送      回到刚才执行被中断的地方。

        (3)中断返回指令IRET:IRET 将EIP、CS及EFLAGS寄存器内容从栈中弹出,并将控制权返回到发生中断的地方。IRET用在中断处理程序的结束处。

        (4)加载中断描述符表的指令LIDT: LIDT  48位的伪描述符  LIDT将指令中给定的48位伪描述符装入中断描述符寄存器IDTR。

二、中断描述符表的初始化

        中断描述符表的初始化过程如下:

        (1) Linux内核在系统的初始化阶段要初始化可编程控制器8259A;将中断描述符表的起始地址装入IDTR寄存器,并初始化表中的每一项。

        (2) 当计算机运行在实模式时,中断描述符表被初始化,并由BIOS使用 。

        (3)真正进入了Linux内核,中断描述符表就被移到内存的另一个区域,并为进入保护模式进行预初始化:用汇编指令LIDT对中断向量表寄存器IDTR进行初始化,即把IDTR置为0。把中断描述符表IDT的起始地址装入IDTR

        用setup_idt()函数填充中断描述表中的256个表项,在对这个表进行了填充时,使用了一个空的中断处理程序。因为现在处于初始化阶段,还没有任何中断处理程序,因此只能用这个空的中断处理程序填充每个表项。

        在对中断描述符表进行了预初始化后,内核将在启用分页功能后(在保护模式下)对IDT进行了第二遍初始化,也就是说,用实际的陷阱和中断处理程序替换这个空的处理程序。一旦这个过程完成,对于每个异常,IDT都有一个专门的陷阱门或系统门,而对每个外部中断,IDT都包含专门的中断门。

        1、IDT表项的设置

        IDT表项的设置是通过_set_gate()函数实现的,下面给出如何调用该函数在IDT表中插入一个门。

        (1)插入一个中断门

static void _init set_intr_gate(unsigned int n, void *addr)
{
    _set_gate(idt_table + n, 14, 0, addr);
}

        其中,idt_table是中断描述符表IDT在程序中的符号表示,n表示在第n个表项中插入一个中断门。这个门的段选择符设置成代码的选择符,DPL域设置成0,14表示D标志位为1(表示32位),而类型码为110,(14的二进制表示为1110),所以set_intr_gate()设置的是中断门,偏移域设置成中断处理程序的地址addr。

        (2)插入一个陷阱门

static void _init set_trap_gate(unsigned int n, void *addr)
{
    _set_gate(idt_table + n, 15, 0, addr);
}

        在第n个表项中插入一个陷阱门。这个门的段选择符设置成代码段的选择符,DPL域设置成0,15表示D标志位为1,而类项码为111(15的二进制表示为1111),所以set_trap_gate()设置的是陷阱门,偏移域设置成异常处理程序的地址addr。

        (3)插入一个系统门

static void _init set_system_gate(unsinged int n, void *addr)
{
    _set_gate(idt_table + n, 15, 3, addr);
}

       在第n个表项中插入一个系统门。这个门的段选择符设置成代码段的选择符,DPL域设置成3,15表示D标志位为1,而类项码为111(15的二进制表示为1111),所以set_system_gate()设置的也是陷阱门(之前说过系统门从某个程度上也是陷阱门,就是在这个程度上)。但因为DPL为3,因此,系统调用在用户态通过“INT 0x80”顺利穿过系统门,从而进入内核态。

        2、对陷阱门和系统门的初始化

        trap_init()函数就是用来设置中断描述符表开头的19个陷阱门和系统门的,这些中断向量都是CPU保留用于异常处理的:

set_trap_gate(0, &divide_error);
set_trap_gate(1, &debug);
...
set_trap_gate(19, &simd_coprocess_error);

set_system_gate(SYSCALL_VECTOR, &system_call);

        3、中断门的设置

        中断门的设置是由init_IRQ()函数中的一段代码完成的:

for(i = 0; i < (NR_VECTORS - FIRST_EXTERNEL_VECTOR); i++)
{
    int vector = FIRST_EXTERNEL_VECTOR + i;

    if(i >= NR_IRQS)
    {

        break;
    }    
    
    if(vector != SYSCALL_VECTOR)
    {
        set_intr_gate(vector, interrupt[i]);
    }
}

       设置时必须跳过用于系统调用的向量0x80。

        中断处理程序的入口地址是一个数组interrupt[],数组中的每个元素是指向中断处理函数的指针。

        4、中断处理程序的形成

        不同的中断处理程序,不仅名字不同,其内容也不同;   内核以统一的方式形成其函数名和函数体:

static void (*interrupt[NR_VECTORS - FIRST_EXTERNAL_VECTOR])(void) = {
        IRQLIST_16(0x2), IRQLIST_16(0x3),
        IRQLIST_16(0x4), IRQLIST_16(0x5), IRQLIST_16(0x6), IRQLIST_16(0x7),
         IRQLIST_16(0x8), IRQLIST_16(0x9), IRQLIST_16(0xa), IRQLIST_16(0xb),
        IRQLIST_16(0xc), IRQLIST_16(0xd), IRQLIST_16(0xe), IRQLIST_16(0xf)
    };

     IRQLIST_16()宏的定义如下:

#define IRQLIST_16(x) \
    IRQ(x,0), IRQ(x,1), IRQ(x,2), IRQ(x,3), \
    IRQ(x,4), IRQ(x,5), IRQ(x,6), IRQ(x,7), \
    IRQ(x,8), IRQ(x,9), IRQ(x,a), IRQ(x,b), \
    IRQ(x,c), IRQ(x,d), IRQ(x,e), IRQ(x,f)

        这样就有224(14 * 16)个函数指针了。一共有256种向量,其中(0~31)共32,对应于异常和非屏蔽中断,不属于这里的中断处理程序的范畴。

三、中断处理

        1、中断和异常的硬件处理

        从硬件的角度看CPU如何处理中断和异常:

        当CPU执行了当前指令之后,CS和EIP这对寄存器中所包含的内容就是下一条将要执行指令的虚地址。

        在对下一条指令执行前,CPU先要判断在执行当前指令的过程中是否发生了中断或异常。即在CPU执行某条指令时,不能响应中断或异常,只能等该指令执行完后,才能响应中断或异常(不是每个时钟周期,因为一条指令可能有多个时钟周期)。

        如果发生了一个中断或异常,那么CPU将做以下事情 :

        (1)确定所发生中断或异常的向量i(在0~255之间)

        (2)通过IDTR寄存器找到IDT表,读取IDT表第i项(或叫第i个门)

        (3)分“段”级、“门”级两步进行有效性检查

        首先是“段”级检查,将CPU的当前特权级CPL(存放在CS寄存器的最低两位)与IDT中第i项段选择符中的DPL相比较,如果DPL(3)大于CPL(0),就产生一个“通用保护”异常,因为中断处理程序的特权级不能低于引起中断的进程的特权级(因为一般是优先级高的先执行,所以如果要执行另外一个进程,则该进程的优先级必须高于正在执行的进程的优先级)这种情况发生的可能性不大,因为中断处理程序一般运行在内核态,其特权级为0。

        然后是“门”级检查,把CPL与IDT中第i个门的DPL相比较,如果CPL大于DPL,也就是当前特权级(3)小于这个门的特权级(0)(优先级数大的,优先级反而小),CPU就不能“穿过”这个门,于是产生一个“通用保护”异常,这是为了避免用户应用程序访问特殊的陷阱门或中断门。但是请注意,这种“门”级检查是针对一般的用户程序,而不包括外部I/O产生的中断或因CPU内部异常而产生的异常,也就是说,如果产生了中断或异常,就免去了“门”级检查。

        (4)检查是否发生了特权级的变化

        当中断发生在用户态(特权级为3),而中断处理程序运行在内核态(特权级为0),特权级发生了变化,所以会引起堆栈的更换。也就是说,从用户堆栈切换到内核堆栈。而当中断发生在内核态时,即CPU在内核中运行时,则不会更换堆栈。

         当从用户态堆栈切换到内核态堆栈时,先把用户态堆栈的值压入中断程序的内核态堆栈中,同时把 EFLAGS寄存器自动压栈,然后把被中断进程的返回地址压入堆栈。如果异常产生了一个硬错误码,则将它也保存在堆栈中。如果特权级没有发生变化,则压入栈中的内容如右图所示。此时,CS:EIP的值就是IDT表中第i项门描述符的段选择符和偏移量的值,于是,CPU就跳转到了中断或异常处理程序。 

        2、中断请求队列的建立

        建立中断请求队列的原因:由于硬件条件的限制,很多硬件设备不得不共享一条中断线。

        为方便处理,Linux为每条中断线设置了一个中断请求队列。

        中断服务程序ISR与中断处理程序不同。类似系统调用里的系统调用服务例程与系统调用处理程序。总体来说,中断处理程序相当于某个中断向量的总处理程序,如果这个中断向量由多个设备共享,则每个设备分别有对应的中断服务程序。

        概括为以下三点:

        (1)每个中断请求都有自己单独的中断服务例程

        (2)共享同一条中断线的所有中断请求有一个总的中断处理程序

        (3)在Linux中,15条中断线对应15个中断处理程序

        中断线共享的数据结构

        为了让设备能共享一条中断线,内核设置了一个叫irqaction的数据结构

// 声明一个函数指针类型irq_handler_t,
// 其指向的函数的返回值类型为irqreturn_t, 函数的类型为(int, void*)
typedef irqreturn_t (* irq_handler_t) (int, void *);

struct irqaction
{
    irq_handler_t handler;    // 指向一个具体I/O设备的中断服务程序
    unsigned long flags;
    cpumask mask;
    const char *name;    // 设备名
    void *dev_id;    // 用来指定I/O设备的主设备号和次设备号
    struct irqaction *next;    // 指向下一个节点
    int irq;
    ...
};

       其中flags域的含义:用一组标志描述中断线与I/O设备之间的关系,具体如下:

        (1)IRQF_DISABLED

                中断处理程序执行时必须禁止中断。

        (2)IRQF_SHARDE

                允许其他设备共享这条中断线。

        (3)IRQF_SAMPLE_RANDOM

                可以把这个设备看做是随机事件发生源,因此,内核可以用它做随机数产生器。

        注册中断服务程序

        注册的原因:在IDT表初始化完成之初,每个中断服务队列还为空。因为具体的中断服务程序还没有挂入某个中断向量的请求中断队列。所以,在设备驱动程序的初始化阶段,必须通过request_irq()函数将相应的中断服务程序挂入中断请求队列的,也就是对其进行注册。

        request_irq()函数原型为:

int request_irq(unsigned int irq,          // 表示要分配的中断号
                 irq_handler_t handler,    // 指向处理这个中断的实际中断服务程序
                 unsigned long irqflags,   // flags标志位
                 const char *devname,      // 与中断相关的设备名
                 void *dev_id)             // 设备号

        其中,dev_id 主要用于共享中断线。当一个中断服务程序需要释放时,dev_id 将提供唯一的标志信息,以便从共享中断线的诸多中断服务程序中删除指定的那一个。如果没有这个参数,那么内核不可能知道在给定的中断线上到底要删除哪一个处理程序。如果无须共享中断线,那么将该参数赋为空值(NULL),但是,如果中断线是被共享的,那么就必须传递唯一的信息。

        在驱动程序初始化或者在设备第一次打开时,首先要调用request_irq()函数,以申请使用参数中指明的中断请求号irq。该函数可能会睡眠,因此,不能在中断上下文或其他不允许阻塞的代码中调用该函数。

        注销中断服务程序

        卸载驱动程序时,需要注销相应的中断处理服务程序,并释放中断线。可以调用void free_irq(unsigned int irq, void *dev_id)来释放中断线。

        如果指定的中断线不是共享的,那么,该函数删除处理程序的同时将禁用这条中断线。如果中断线是共享的,则仅删除dev_id()所对应的服务程序,而这条中断线本身只有在删除了最后一个服务程序时才会被禁用。

        必须从进程上下文中掉调用free_irq(),即必须把函数写在一个脚本文件,让它运行。

        3、中断处理程序的执行

         假定此时的,外设的驱动程序都已完成了初始化工作,并且已把相应的中断服务程序挂入到特定的中断请求队列。又假定当这个当前进程正在用户空间运行(随时可以接收中断),且外设已产生了一次中断请求。当这个中断请求通过中断控制器2859A到达CPU的中断请求引线INTR时,CPU就会再执行完当前指令后来响应该中断(具体原因前面有分析)

        接下来CPU按照下面流程来处理中断:

        (1)CPU从中断控制器的一个端口取得中断向量I。

        (2)根据I从IDT中找到相应的中断门(表项)。

        (3)从中断门获得中断处理程序的入口地址,用IRQn_interrupt表示。(外部中断,不需要进行“门级”检查)

        (4)判断是否要进行堆栈切换,即判断特权级是否发生改变。

        (5)调用do_IRQ()函数对所接受的中断进行应答,并禁止这条中断线(关中断)。

        (6)调用handle_IRQ_event()来运行对应的中断服务例程。

        流程图如下:

        

        函数具体介绍:     

        (1) 中断处理程序IRQn_interrupt

        一个中断处理程序主要包含以下两条语句。

IRQ_interrupt:
    pushl $n - 256    // 把中断号减256的结果保存在栈中,每个中断处理程序唯一的不同之处
    jmp common_interrupt    // 所有的中断处理程序跳到一段相同的代码


common_interrupt:
    SAVE_ALL    // 一个宏,把中断处理程序会使用的所有CPU寄存器都保存在栈中
    call do_IRQ    // common_interrupt的返回地址被压入栈中
    jmp ret_from_intr 

         (2)do_IRQ()函数

        do_IRQ()函数处理所有外设的中断请求。do_IRQ()对中断请求队列的处理主要是通过调用handle_IRQ_event()函数完成的,handle_IRQ_event()函数的主要代码片段为:

retval = 0;
// 循环依次调用请求队列中的每个中断服务程序
do
{
    retval |= action->handler(irq, action->dev_id);
    action = action->next;
}while(action);

         特别要注意:中断服务程序都在关中断的条件下运行(不包括非屏蔽中断),这也就是为什么CPU在穿过中断门时自动关闭中断的原因。但是,关中断的时间绝不能太长,否则就可能丢失其他重要的中断。也就是说,中断服务程序应该处理最紧急的事情,而把剩下的事情交给另外一部分来处理,即下半部来处理。

        4、中断的简单应用

        这里的应用统一放到另外一篇博客里,想了解的可以在我的博客里找。

四、中断的下半部分处理机制

       1、为什么要把中断分为两部分来处理

        分段处理的原因:系统不能长时间关中断运行,否则就可能丢失其他重要的中断,因此内核应尽可能快的处理完中断请求,尽其所能把更多的处理向后推迟。

        内核把中断处理分为两部分:上半部(top half)和下半部(bottom half),上半部内核立即执行,而下半部留着稍后处理 中断服务例程在中断请求关闭的条件下执行,即执行上半部,避免嵌套使中断控制复杂化。

        两者主要区别:下半部运行时是允许中断请求的,而上半部运行时是关中断的

        

         内核到底什么时候执行下半部?以何种方式组织下半部呢?

        下半部实现机制是软中断(SOFTIRQ)机制,常用的是小任务机制工作队列机制

        2、小任务机制(Tasklet)

        小任务是指对要推迟执行的函数进行组织的一种方式。其数据结构为tasklet_struct,每个结构代表一个独立的小任务。 小任务既可以静态地创建,也可以动态地创建。

struct tasklet_struct
{
    struct tasklet_struct *next;    // 指向链表的下一个结构
    unsigned long state;            // 小任务的状态
    atomic_t count;                 // 引用计数器
    void (*func) (unsigned long);   // 要调用的函数
    unsigned long data;             // 传递给函数的参数
};

        state域的取值为以下两种的一种:

        (1)TASKLET_STATE_SCHED,表示小任务已被调度,正准备投入运行。

        (2)TASKLET_STATE_RUN,表示小任务正在运行。只有在多处理器系统上才使用。

        count域是小任务的引用计数器。如果它不为0,则小任务被禁止,不允许执行;只有当它为0,小任务才被激活,并且在被设置为挂起时,小任务才能执行。 

        当小任务被调度以后,给定的函数func会被执行,它的参数由data给出。大多数情况下,为了控制一个寻常的硬件设备,小任务机制是实现下半部的最佳选择。

        (1)如果要静态创建一个小任务使用以下两个宏的中的任意一个:

DECLARE_TASKLET(name, func, data)
DECLARE_TASKLET_DISABLED(name, func, data)

        (2)小任务处理程序为void tasklet_handler(unsigned long data) 小任务不能睡眠,不能在小任务中使用信号量或者其它产生阻塞的函数。但它运行时可以响应中断。

        (3)通过调用tasklet_schedule()函数并传递给它相应的tasklet_struct指针,该小任务就会被调度以便适当的时候执行:    tasklet_schedule(&my_tasklet)。但是只能被调度一次。即在它没有得到运行机会之前,如果一个相同的小任务又被调度了,那么它仍然只会运行一次。

        (4)在小任务被调度以后,只要有机会它就会尽可能早的运行 可以调用tasklet_disable()函数来禁止某个指定的小任务。如果该小任务当前正在执行,这个函数会等到它执行完毕再返回。

        (5)调用tasklet_enable()函数可以激活一个小任务。

        (6)可以调用tasklet_kill()函数从挂起的队列中去掉一个小任务。

        3、工作队列(Work Queue)

        工作队列是另外一种将工作推后执行的形式。工作队列可以把工作推后,交由一个内核线程执行,即这个下半部分可以在进程上下文执行。最重要的是工作队列允许被重新调度甚至是睡眠。

        (1)什么情况使用小任务机制,什么情况使用工作队列呢?

        如果推后执行的任务需要睡眠,就选择工作队列。

        如果推后执行的任务不需要睡眠,就选择小任务机制。

        如果需要用一个可以重新调度的实体来执行下半部的处理,就选择工作队列。

        如果需要获取信号量或者需要执行阻塞式的I/O操作时,就选择工作队列。

        如果不需要用一个内核线程来推后执行工作,可以选择小任务机制。

        (2)工作,工作队列和工作站线程

        工作:推后执行的任务,描述它的数据结构为work_struct。

        工作队列:工作以队列的形式组成的任务集,其数据结构为workqueue_struct。

        工作者线程:负责执行工作队列中的工作。系统默认为events,自己也可以创建。

        当一个工作者线程被唤醒时,它会执行它的链表上的所有工作。工作被执行完毕,它就将相应的work_struct对象从链表上移去。当链表上不再有对象的时候,它就会继续睡眠。

// 在linux/workqueue.h 中定义了 work_struct 结构

struct work_struct
{
    unsigned long pending;     // 这个工作是否正在等待处理
    struct list_head entry;    // 工作的链表
    void (*func) (void *);     // 要执行的函数
    void *data;                // 传递给函数的参数
    void *wq_data;             // 内部使用
    struct timer_list timer;   // 延迟的工作队列所用到的定时器
};

        (3)创建推后的工作

        可以通过DELCLARE_WORK在编译时静态地创建该结构:

DECLARE_WORK(name, void (*func) (void *), void *data);

       也可以在运行时通过指针动态创建一个工作:

INIT_WORK(struct work_struct *work, void (*func) (void *), void *data);

        (4)工作队列中待执行的函数

        函数原型:void work_handler(void *data)。这个函数有一个工作者线程执行,因此,函数运行在进程上下文中。默认情况下,允许响应中断,并且不持有任何锁。需要注意的是,尽管该函数运行在进程上下文中,但它不能访问用户空间,因为内核线程在用户空间没有相关的内存映射。通常在系统调用发生时,内核代表用户空间的进程运行,此时它才能访问用户空间,也只有在此时它才映射用户空间的内存。

        (5)对工作进行调度

        想要把给定工作的待处理函数提交给缺省的events工作线程,只需调用

schedule_work(&work);

工作马上就被调度,一旦其所在的处理器上工作者线程被唤醒,它就被执行。有时候并不希望工作马上就被执行,而是希望它经过一段延迟以后再执行。在这种情况下,可以调度它在指定的时间执行:

schedule_delayed_work(&work, delay);

        (6)工作队列的简单应用

        具体代码在我的另外的一篇博客里找,这里不做演示。

五、中断应用——时钟中断

       时间系统是整个操作系统活动的动力。

        1、时钟硬件

        大部分PC中有两个时钟源,它们分别叫做RTC和OS时钟。

        (1)什么是RTC时钟?

        RTC(Real Time Clock)实时时钟,也叫做CMOS时钟,它是PC主机板上的一块芯片,它靠电池供电,即使系统断电,也可以维持日期和时间。由于它独立于操作系统,所有也被称为硬件时间,它为整个计算机提供一个计时标准,是最原始最底层的时钟数据。

        (2)什么是OS时钟?

        OS时钟产生于PC板上的定时/计数芯片,由操作系统控制这个芯片的工作,OS时钟的基本单位就是该芯片的计数周期。OS时钟只有在开机时才有效,而且完全由操作系统控制,所用也被称为软时钟或者系统时钟

        2、时钟运作机制

        不同的操作系统,RTC和OS时钟的关系是不同的。RTC和OS时钟之间的关系通常也被称做操作系统的时钟运作机制。

        一般来说,RTC是OS时钟的时间基准,操作系统通过读取RTC来初始化OS时钟,此后二者保持同步运行,共同维持着系统时间。所谓同步,是指操作系统在运行过程中,每隔一个固定时间会刷新或校正RTC中的信息。

        3、Linux时间系统

        不同的操作系统采用不同的“时间基准”。定义“时间基准”的目的是为了简化计算,时间基准是有操作系统的设计者规定的。Linux的时间基准是1970年1月1日凌晨0点。

        OS时钟记录的时间也就是通常所说的系统时间。系统时间是以“时钟节拍”为单位的,而时钟中断的频率(简称节拍率)决定了一个时钟节拍的长短。节拍率等于Hz,周期为1/Hz秒。

#define Hz 1000     // 内核时钟频率

       因此,每秒钟时钟中断1000次。

        (1)节拍数jiffies

        Linux中用全局变量jiffies表示系统自启动以来的时钟节拍数目。启动时,内核将该变量初始化为0,此后,每次时钟中断处理程序都会增加该变量的值。因为一秒内时钟中断的次数等于Hz,所以 jiffies 一秒内增加的值也就是Hz。

        节拍数 jiffies 与秒 s 的相互转化关系:jiffies = s * Hz

        通常用jiffies多一些,比如经常需要设置一些将来的时间:

unsigned long time_stamp = jiffies;        // 现在
unsigned long next_tick = jiffies + 1;     // 从现在开始 1 个节拍
unsigned long later = jiffies + 5 * Hz;    // 从现在开始 5s 

        (2)实际时间xtime

        实际时间存放在内核的xtime中,系统启动时内核通过读取RTC来初始化实际时间 。就是实际生活中以 s 为单位的时间。Linux 还定义了更加符合大众习惯的时间表示:年、月、日。

        4、时钟中断

        (1)时钟中断如何产生

        Linux 的 OS 时钟的物理产生原因是可编程定时/ 计数器产生的输出脉冲,这个脉冲送入CPU,就可以引发一个中断请求信号,此时,就叫做时钟中断。

        (2)时钟中断的作用

        时钟中断是一个特别重要的中断,因为整个操作系统的活动都要受到它的激励。时钟中断是整个操作系统的脉搏。从本质上说,时钟中断只是一个周期性的信号,完全是硬件行为,该信号触发CPU去执行一个中断服务程序。

        (3)时钟中断处理程序

        每一次时钟中断的产生都触发一系列的操作,其中主要调用了do_timer(  )函数,操作过程如下: 给jiffies变量增加1

        更新资源消耗的统计值

        执行已经到期的定时器

        执行sheduler_tick()函数 更

        新墙上时间,该时间存放在xtime变量中

        计算平均负载值 

        因为do_timer(  )函数以关中断运行的,因此它必须被尽可能快地执行,它亲自更新一个基本的值(系统自启动以来所用的时间),而把剩余的所有活动都委托给单独的函数处理(相当于下半部)。 do_timer(  )执行代码如下:

void do_timer(struct pt_regs *regs)
{
    jiffies++;
    update_process_times(user_mode(regs)); // 更新进程时间
    update_times();    // 更新墙上时钟
}


void update_process_times(int user_tick)
{
    struct task_struct *p = current;
    int cpu = smp_processor_id();
    int system = user_tick ^ 1;

    update_one_process(p, user_tick, system, cpu);    // 更新进程的时间
    run_local_timers();    // 处理所有到期的定时器
    scheduler_tick(user_tick, system);    // 减少当前运行进程的时间片技术值
}

void update_times(void)
{
    unsigned long ticks;
    
    ticks = jiffies - wall_jiffies;
    if(ticks)
    {
        wall_jiffies += ticks;
        update_wall_time(ticks);    // 更新xtime
    }
    last_time_offset = 0;
    calc_load(ticks);    // 计算平均负载
}

           do_timer()函数执行完毕后返回具体的时钟中断处理程序,继续执行后面的工作,释放xtime_lock锁,然后退出。以上全部工作每 1/Hz 秒都要发生一次,也就是说在PC上时钟中断处理程序每秒执行 100 或者 1000 次。

         5、定时器及应用

        定时器是管理内核所花费时间的基础,有时也被称为动态定时器或者内核定时器。定时器的使用很简单,只需要执行一些初始化工作,设置一个到期时间,指定到时候执行的函数,然后激活的定时器就可以了。指定的函数将在定时器到期时自动执行。

        注意定时器并不周期运行,它在到期后就自行销毁,这也正是这种定时器被称为动态定时器的一个原因。动态定时器不断地创建和销毁,而且它的运行次数也不受限制。定时器在内核中用得非常普遍。

        (1)使用定时器

        定时器由 time_list 机构表示,定义如下:

struct timer_list 
{
    struct list_head entry;        // 包含定时器的链表
    unsigned long expires;         // 以节拍为单位的定时值
    spinlock_t lock;               // 保护定时器的锁
    void (*function) (unsigned long);    // 定时器到时要执行的函数
    unsigned long data;            // 传递给处理函数的长整型参数
};

       使用流程如下:

// 1、创建定时器首先要定义它
struct timer_list my_timer;

// 2、在对定时器操作前完成初始化
init_timer(&my_timer);

// 填充结构中需要的值
my_timer.expires = jiffies + delay;    // 定时器到期节拍数
my_timer.data = 0;         // 给定时器处理函数传入 0 值
my_timer.function = my_function;     // 定时器到期调用的函数

// 激活定时器
add_timer(&my_timer);

// 如果需要在定时器到期前停止定时器可以调用函数
del_timer(&my_timer);

        如果当前节拍数等于或大于指定的到期时间,内核就开始执行定时器处理函数。虽然内核可以保证不会在定时时间到期前运行定时器处理函数,但是有可能延误定时器的执行。一般来说,定时器都在到期后马上就会执行,但是也有可能被推迟到下一次时钟节拍才能运行,所以不能用定时器实现任何硬实时任务。

        注意,不需要为已经到期的定时器调用该函数,因为它们会自动删除。

        (2)执行定时器

        定时器作为软中断在下半部中执行。具体来说,时钟中断处理程序会执行update_process_timers()函数,该函数随即调用run_local_timers()函数:

void run_local_timers(void)
{
    rasie_softirq(TIMER_SOFTIRQ);
}

       run_timer_softirq()函数处理软中断TIMER_SOFTIRQ,其处理函数为run_timer_softirq,该函数用来处理所有的软件时钟,从而在当前处理器上运行所有的超时定时器。

        (3)定时器的应用

        这里只讲简单的理论,具体的实现在我的另外一篇博客里。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值