第五章 中断与异常 中断的基本知识 中断描述符表的初始化 中断处理 中断的下半部处理机制 中断的应用-时钟中断
中断掠影 中断控制的主要优点: 外部中断: 内部中断: CPU只有在I/O需要服务时才响应 外部设备所发出的I/O请求 也称之为“异常”,是为解决机器运行时所出现的某些随机事件及编程方便而出现的 中断控制是计算机发展中一种重要的技术。最初它是为克服对I/O接口控制采用程序查询所带来的处理器低效率而产生的。中断控制的主要优点是只有在I/O需要服务时才能得到处理器的响应,而不需要处理器不断地进行查询。由此,最初的中断全部是对外部设备而言的,即称为外部中断(或硬件中断)。 但随着计算机系统结构的不断改进以及应用技术的日益提高,中断的适用范围也随之扩大,出现了所谓的内部中断(或叫异常),它是为解决机器运行时所出现的某些随机事件及编程方便而出现的。因而形成了一个完整的中断系统。本章主要讨论在IA32保护模式下中断机制在Linux中的实现 < >
中断常识 中断向量 : 外设可屏蔽中断: 异常及非屏蔽中断 : 中断描述符表 : 描述中断的相关信息 中断相关的汇编指令 : 中断源的编号 屏蔽外部I/O请求 异常及非屏蔽中断 : CPU内部中断或计算机内部硬件出错引起的异常 中断描述符表 : 描述中断的相关信息 中断相关的汇编指令 : < >
中断向量-中断源的类型 中断向量-每个中断源都被分配一个8位无符号整数作为类型码,即中断向量 中断的种类: 中断: 外部可屏蔽中断 外部非屏蔽中断 异常:不使用中断控制器,不能被屏蔽 故障 陷阱 Intel x86系列微机共支持256种向量中断,为使处理器较容易地识别每种中断源,将它们从0到256编号,即赋以一个中断类型码n,Intel把这个8位的无符号整数叫做一个向量,因此,也叫中断向量。所有256种中断可分为两大类:异常和中断。异常又分为故障(Fault)和陷阱(Trap),它们的共同特点是既不使用中断控制器,又不能被屏蔽(异常其实是CPU发出的终端信号)。中断又分为外部可屏蔽中断(INTR)和外部非屏蔽中断(NMI),所有I/O设备产生的中断请求(IRQ)均引起屏蔽中断,而紧急的事件(如硬件故障)引起的故障产生非屏蔽中断。 非屏蔽中断的向量和异常的向量是固定的,而屏蔽中断的向量可以通过对中断控制器的编程来改变。Linux对256个向量的分配如下: (1) 从0~31的向量对应于异常和非屏蔽中断。 (2) 从32~47的向量(即由I/O设备引起的中断)分配给屏蔽中断。 剩余的从48~255的向量用来标识软中断。Linux只用了其中的一个(即128或0x80向量)用来实现系统调用。 < >
外设可屏蔽中断 外部I/O请求的屏蔽: Intel x86通过两片中断控制器8259A来响应15个外中断源,每个8259A可管理8个中断源。 外部设备拥有相应权限时 ,可以向特定的中断线发送中断请求信号 外部I/O请求的屏蔽: 从CPU的角度, 清除eflag的中断标志位 从中断控制器的角度,将中断屏蔽寄存器的相应位置位 Intel x86通过两片中断控制器8259A来响应15个外中断源,每个8259A可管理8个中断源。第一级(称主片)的第二个中断请求输入端,与第二级8259A(称从片)的中断输出端INT相连,如图5.1所示。我们把与中断控制器相连的每条线叫做中断线,要使用中断线,就要进行中断线的申请,也就是IRQ(Interrupt ReQuirement ),因此我们也常把申请一条中断线称为申请一个IRQ或者是申请一个中断号。IRQ线是从0开始顺序编号的;因此,第一条IRQ线通常表示成IRQ0。IRQn的缺省向量是n+32;如前所述,IRQ和向量之间的映射可以通过中断控制器端口来修改。 并不是每个设备都可以向中断线上发中断信号的,只有对某一条确定的中断线拥有了控制权,才可以向这条中断线上发送信号。由于计算机的外部设备越来越多,所以15条中断线已经不够用了,中断线是非常宝贵的资源,所以只有当设备需要中断的时候才申请占用一个IRQ,或者是在申请IRQ时采用共享中断的方式,这样可以让更多的设备使用中断。 对于外部I/O请求的屏蔽可分为两种情况,一种是从CPU的角度,也就是清除eflag的中断标志位(IF),当IF=0时,禁止任何外部I/O的中断请求,即关中断;一种是从中断控制器的角度,因为中断控制器中有一个8位的中断屏蔽寄存器,每位对应8259A中的一条中断线,如果要禁用某条中断线,则把中断屏蔽寄存器相应的位置1,要启用,则置0。 < >
异常就是CPU内部出现的中断,即在CPU执行特定指令时出现的非法情况。 异常及非屏蔽中断 异常就是CPU内部出现的中断,即在CPU执行特定指令时出现的非法情况。 非屏蔽中断就是计算机内部硬件出错时引起的异常情况 Intel把非屏蔽中断作为一种异常来处理 在CPU执行一个异常处理程序时,就不再为其他异常或可屏蔽中断请求服务 < >
中断描述符表 中断描述符表(IDT):即中断向量表,每个中断占据一个表项 < > 在实地址模式中,CPU把内存中从0开始的1K字节作为一个中断向量表。表中的每个表项占四个字节,由两个字节的段地址和两个字节的偏移量组成,这样构成的地址便是相应中断处理程序的入口地址。但是,在保护模式下,由四字节的表项构成的中断向量表显然满足不了要求。这是因为,除了两个字节的段描述符,偏移量必用四字节来表示;要有反映模式切换的信息。因此,在保护模式下,中断向量表中的表项由8个字节组成,如图5.2所示,中断向量表也改叫做中断描述符表IDT(Interrupt Descriptor Table)。其中的每个表项叫做一个门描述符(gate descriptor),“门”的含义是当中断发生时必须先通过这些门,然后才能进入相应的处理程序。 其中类型占3位,表示门描述符的类型,主要门描述符为: (1)中断门(Interrupt gate) 其类型码为110,中断门包含了一个中断或异常处理程序所在段的选择符和段内偏移量。当控制权通过中断门进入中断处理程序时,处理器清IF标志,即关中断,以避免嵌套中断的发生。中断门中的请求特权级(DPL)为0,因此,用户态的进程不能访问Intel的中断门。所有的中断处理程序都由中断门激活,并全部限制在内核态。 (2)陷阱门(Trap gate) 其类型码为111,与中断门类似,其唯一的区别是,控制权通过陷阱门进入处理程序时维持IF标志位不变,也就是说,不关中断。 (3)系统门(System gate) 这是Linux内核特别设置的,用来让用户态的进程访问Intel的陷阱门,因此,门描述符的DPL为3。系统调用就是通过系统门进入内核的。 最后,在保护模式下,中断描述符表在内存的位置不再限于从地址0开始的地方,而是可以放在内存的任何地方。为此,CPU中增设了一个中断描述符表寄存器IDTR,用来存放中断描述符表在内存的起始地址。中断描述符表寄存器IDTR是一个48位的寄存器,其低16位保存中断描述符表的大小,高32位保存中断描述符表的基址 < >
相关汇编指令 调用中断过程的指令INT 中断返回指令IRET 加载中断描述符表的指令LIDT 调用过程指令CALL : CALL 过程名 为了有助于读者对中断实现过程的理解,下面介绍几条相关的汇编指令: (1)调用过程指令CALL 指令格式: CALL 过程名 说明:在取出CALL指令之后及执行CALL指令之前,使指令指针寄存器EIP指向紧接CALL指令的下一条指令。CALL指令先将EIP值压入栈内,再进行控制转移。当遇到RET指令时,栈内信息可使控制权直接回到CALL的下一条指令 (2)调用中断过程的指令INT 指令格式:INT 中断向量 说明:EFLAG、CS及EIP寄存器被压入栈内。控制权被转移到由中断向量指定的中断处理程序。在中断处理程序结束时,IRET指令又把控制权送回到刚才执行被中断的地方。 (3)中断返回指令IRET 指令格式:IRET 说明:IRET与中断调用过程相反:它将EIP、CS及EFLAGS寄存器内容从栈中弹出,并将控制权返回到发生中断的地方。IRET用在中断处理程序的结束处。 (4)加载中断描述符表的指令LIDT 格式:LIDT 48位的伪描述符 说明:LIDT将指令中给定的48位伪描述符装入中断描述符寄存器IDTR。 < >
Linux内核在系统的初始化阶段要初始化可编程控制器8259A;将中断描述符表的起始地址装入IDTR寄存器,并初始化表中的每一项 初始化中断描述符表 Linux内核在系统的初始化阶段要初始化可编程控制器8259A;将中断描述符表的起始地址装入IDTR寄存器,并初始化表中的每一项 当计算机运行在实模式时,中断描述符表被初始化,并由BIOS使用 。 真正进入了Linux内核,中断描述符表就被移到内存的另一个区域,并为进入保护模式进行预初始化 用户进程可以通过INT指令发出一个中断请求,其中断请求向量在0~255之间。为了防止用户使用INT指令模拟非法的中断和异常,必须对中断描述符表进行谨慎的初始化。其措施之一就是将中断门或陷阱门中的请求特权级DPL域置为0。如果用户进程确实发出了这样一个中断请求,CPU会检查出其当前特权级CPL(3)与所请求的特权级DPL(0)有冲突,因此产生一个“通用保护”异常。 但是,有时候必须让用户进程能够使用内核所提供的功能(比如系统调用),也就是说从用户态进入内核态,这可以通过把中断门或陷阱门的DPL域置为3 来达到。 当计算机运行在实模式时,中断描述符表被初始化,并由BIOS使用。然而,一旦真正进入了Linux内核,中断描述符表就被移到内存的另一个区域,并为进入保护模式进行预初始化:用汇编指令LIDT对中断向量表寄存器IDTR进行初始化,即把IDTR置为0。把中断描述符表IDT的起始地址装入IDTR 用setup_idt()函数填充中断描述表中的256个表项。在对这个表进行填充时,使用了一个空的中断处理程序。因为现在处于初始化阶段,还没有任何中断处理程序,因此,用这个空的中断处理程序填充每个表项。 在对中断描述符表进行预初始化后, 内核将在启用分页功能后对IDT进行第二遍初始化,也就是说,用实际的陷阱和中断处理程序替换这个空的处理程序。一旦这个过程完成,对于每个异常,IDT都由一个专门的陷阱门或系统门,而对每个外部中断,IDT都包含专门的中断门。 < >
IDT表项的设置 IDT表项的设置通过_set_gaet()函数实现 调用该函数在IDT表中插入一个中断门:set_intr_gate(unsigned int n, void *addr) 调用该函数在IDT表中插入一个陷阱门:set_trap_gate(unsigned int n, void *addr) 调用该函数在IDT表中插入一个系统门:set_system_gate(unsigned int n, void *addr) IDT表项的设置是通过_set_gaet()函数实现的,在此,我们给出如何调用该函数在IDT表中插入一个门: (1) 插入一个中断门 void 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,所以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,所以set_trap_gate()设置的是陷阱门,偏移域设置成异常处理程序的地址addr。 (3) 插入一个系统门 static void __init set_system_gate(unsigned int n, void *addr) _set_gate(idt_table+n,15,3,addr); 在第 n个表项中插入一个系统门。这个门的段选择符设置成代码段的选择符,DPL域设置成3,15表示D标志位为1而类型码为111,所以set_system_gate()设置的也是陷阱门,但因为DPL为3,因此,系统调用在用户态可以通过“INT 0x80”顺利穿过系统门,从而进入内核态。 < >
初始化陷阱门和系统门 trap_init()函数用于设置中断描述符表开头的19个陷阱门和系统门 这些中断向量都是CPU保留用于异常处理的 ,例: set_trap_gate(0,÷_error); set_trap_gate(1,&debug); set_trap_gate(19,&simd_coprocessor_error); set_system_gate(SYSCALL_VECTOR,&system_call); < >
set_intr_gate(vector, interrupt[i]); } 中断门的设置 for (i = 0; i< NR_IRQS; i++) { int vector = FIRST_EXTERNAL_VECTOR + i; if (vector != SYSCALL_VECTOR) set_intr_gate(vector, interrupt[i]); } 中断门的设置是由init_IRQ( )函数中的一段代码完成的 : 设置时必须跳过用于系统调用的向量0x80 中断处理程序的入口地址是一个数组interrupt[],数组中的每个元素是指向中断处理函数的指针。 < >
中断处理 中断和异常的硬件处理 : 中断请求队列的建立: 中断处理程序的执行 从中断返回: 从硬件的角度看CPU如何处理中断和异常 方便外设共享中断线 中断处理程序的执行 从中断返回: 调用恢复中断现场的宏RESTORE_ALL,彻底从中断返回 < >
中断和异常的硬件处理 当CPU执行了当前指令之后,CS和EIP这对寄存器中所包含的内容就是下一条将要执行指令的虚地址。 < >
中断和异常处理中CPU的工作 确定所发生中断或异常的向量i(在0~255之间) 通过IDTR寄存器找到IDT表,读取IDT表第i项(或叫第i个门) 分“段”级、“门”级两步进行有效性检查 检查是否发生了特权级的变化 中断处理程序堆栈 堆栈增长方向 当CPU执行了当前指令之后,CS和EIP这对寄存器中所包含的内容就是下一条将要执行指令的虚地址。在对下一条指令执行前,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内部异常而产生的异常,也就是说,如果产生了中断或异常,就免去了“门”级检查。 检查是否发生了特权级的变化。当中断发生在用户态(特权级为3),而中断处理程序运行在内核态(特权级为0),特权级发生了变化,所以会引起堆栈的更换。也就是说,从用户堆栈切换到内核堆栈。而当中断发生在内核态时,即CPU在内核中运行时,则不会更换堆栈,如图5.4所示。 从图可以看出,当从用户态堆栈切换到内核态堆栈时,先把用户态堆栈的值压入中断程序的内核态堆栈中,同时把 EFLAGS寄存器自动压栈,然后把被中断进程的返回地址压入堆栈。如果异常产生了一个硬错误码,则将它也保存在堆栈中。如果特权级没有发生变化,则压入栈中的内容如图5.4中。此时,CS:EIP的值就是IDT表中第i项门描述符的段选择符和偏移量的值,于是,CPU就跳转到了中断或异常处理程序。 SS ESP EFLAGS CS EIP ERROR CODE 中断发生前夕的SS:ESP EFLAGS CS EIP ERROR CODE 返回地址 < > 错误码
中断请求队列的建立 由于硬件条件的限制,很多硬件设备共享一条中断线 为方便处理,Linux为每条中断线设置了一个中断请求队列 中断服务例程与中断处理程序 中断线共享的数据结构 注册中断服务例程 < >
中断服务例程与中断处理程序 中断服务例程(Interrupt Service Routine ):每个中断请求都有自己单独的中断服务例程 中断处理程序:共享同一条中断线的所有中断请求有一个总的中断处理程序 在Linux中,15条中断线对应15个中断处理程序 < >
中断线共享的数据结构 Handler:指向一个具体I/O设备的中断服务例程 Flags:用一组标志描述中断线与I/O设备之间的关系。 SA_INTERRUPT:中断处理程序执行时必须禁止中断 SA_SHIRQ:允许其它设备共享这条中断线。 struct irqaction { void (*handler)(int, void *, struct pt_regs *); unsigned long flags; unsigned long mask; const char *name; void *dev_id; struct irqaction *next; }; < >
中断线共享的数据结构 SA_SAMPLE_RANDOM:内核可以用它做随机数产生器。 SA_PROBE:内核正在使用这条中断线进行硬件设备探测。 Name:I/O设备名 dev_id:指定I/O设备的主设备号和次设备号(参见第9章)。 Next:指向irqaction描述符链表的下一个元素 struct irqaction { void (*handler)(int, void *, struct pt_regs *); unsigned long flags; unsigned long mask; const char *name; void *dev_id; struct irqaction *next; }; < >
注册中断服务例程 初始化IDT表之后,必须通过 request_irq() 函数将相应的中断服务例程挂入中断请求队列,即对其进行注册 在关闭设备时,必须通过调用free_irq()函数释放所申请的中断请求号 int request_irq(unsigned int irq, void (*handler)(int, void *, struct pt_regs *), unsigned long irqflags, const char * devname, void *dev_id) 第一个参数irq表示要分配的中断号。对某些设备,如传统PC设备上的系统时钟或键盘,这个值通常是预先确定的。而对于大多数其它设备来说,这个值要么可以通过探测获取,要么可以动态确定。其他几个参数的含义与irqaction数据结构中域的含义相同。 这里要说明的是,在驱动程序初始化或者在设备第一次打开时,首先要调用request_irq()函数,以申请使用参数中指明的中断请求号irq,另一参数handler指的是要挂入到中断请求队列中的中断服务例程。假定一个程序要对/dev/fd0/(第一个软盘对应的设备)设备进行访问,通常将IRQ6分配给软盘控制器,给定这个中断号6,软盘驱动程序就可以发出下列请求,以将其中断服务例程挂入中断请求队列: request_irq(6, floppy_interrupt, SA_INTERRUPT|SA_SAMPLE_RANDOM, "floppy", NULL); 我们可以看到,floppy_interrupt()中断服务例程运行时必须禁止中断(设置了SA_INTERRUPT标志),并且不允许共享这个IRQ(清SA_SHIRQ标志),但允许根据这个中断发生的时间产生随机数(设置了SA_SAMPLE_RANDOM标志,用于建立熵池,以供系统产生随机数使用)。 在关闭设备时,必须通过调用free_irq()函数释放所申请的中断请求号。例如,当软盘操作终止时,驱动程序就放弃这个中断号: free_irq(6, NULL); < >
中断处理程序的执行 CPU从中断控制器的一个端口取得中断向量I 根据I从中断描述符表IDT中找到相应的中断门 从中断门获得中断处理程序的入口地址 判断是否要进行堆栈切换 调用do_IRQ()对所接收的中断进行应答 ,并禁止这条中断线 调用handle_IRQ_event()来运行对应的中断服务例程 从前面的介绍,我们已经了解了中断机制及有关的初始化工作。现在,我们可以从中断请求的发生到CPU的响应,再到中断处理程序的调用和返回,沿着这一思路走一遍,以体会Linux内核对中断的响应及处理。 假定外设的驱动程序都已完成了初始化工作,并且已把相应的中断服务例程挂入到特定的中断请求队列。 又假定当前进程正在用户空间运行(随时可以接受中断),且外设已产生了一次中断请求。当这个中断请求通过中断控制器8259A到达CPU的中断请求引线INTR时(参看图5.1),CPU就在执行完当前指令后来响应该中断。 CPU从中断控制器的一个端口取得中断向量I,然后根据I从中断描述符表IDT中找到相应的表项,也就是找到相应的中断门。因为这是外部中断,不需要进行“门级”检查,CPU就可以从这个中断门获得中断处理程序的入口地址,假定为IRQ0x05_interrupt。因为这里假定中断发生时CPU运行在用户空间(CPL=3),而中断处理程序属于内核(DPL=0),因此,要进行堆栈的切换。当CPU进入IRQ0x05_interrupt时,内核栈如图5.4的,栈中除用户栈指针、EFLAGS的内容以及返回地址外再无其他内容。另外,由于CPU进入的是中断门(而不是陷阱门),因此,这条中断线已被禁用,直到重新启用。 我们用IRQn_interrupt表示从IRQ0x00_interrupt 到IRQ0x0f_interrupt任意一个中断处理程序。这个中断处理程序要调用do_IRQ()函数。do_IRQ()对所接收的中断进行应答,并禁止这条中断线,然后要确保这条中断线上有一个有效的中断服务例程,而且这个例程已经启动但是目前还没有执行。这时,do_IRQ()调用handle_IRQ_event()来运行为这条中断线所安装的中断服务例程 < >
从中断返回 当处理所有外设中断请求的函数do_IRQ()执行时,内核栈顶包含的就是do_IRQ()的返回地址,这个地址指向ret_from_intr 从中断返回时,CPU要调用恢复中断现场的宏RESTORE_ALL,彻底从中断返回。 < >
中断的下半部处理机制 中断服务例程在中断请求关闭的条件下执行,避免嵌套使中断控制复杂化 系统不能长时间关中断运行,因此内核应尽可能快的处理完中断请求,尽其所能把更多的处理向后推迟 内核把中断处理分为两部分:上半部(top half)和下半部(bottom half),上半部内核立即执行,而下半部留着稍后处理 首先,一个快速的“上半部”来处理硬件发出的请求,它必须在一个新的中断产生之前终止。通常,除了在设备和一些内存缓冲区(如果你的设备用到了DMA,就不止这些)之间移动或传送数据,确定硬件是否处于健全的状态之外,这一部分做的工作很少。 下半部运行时是允许中断请求的,而上半部运行时是关中断的,这是二者之间的主要区别。 Linux内核下半部的实现机制在内核的演变过程中不断得到改进,在以前的内核中,这个机制叫做bottom half(简称bh),在2.4以后的版本中有了新的发展和改进,改进的目标使下半部可以在多处理机上并行执行,并有助于驱动程序的开发者进行驱动程序的开发。 < >
小任务机制 小任务是指待处理的下半部,其数据结构为tasklet_struct,每个结构代表一个独立的小任务 小任务既可以静态地创建,也可以动态地创建 struct tasklet_struct { Struct tasklet_struct *next; unsigned long state; atomic_t count; void (*func) (unsigned long); unsigned long data; }; 大多数情况下,为了控制一个寻常的硬件设备,小任务机制是实现下半部的最佳选择。小任务可以动态创建,使用方便,执行起来也比较快。 我们既可以静态地创建小任务,也可以动态地创建它。选择那种方式取决于到底是想要对小任务进行直接引用还是一个间接引用。如果准备静态地创建一个小任务(也就是对它直接引用),使用下面两个宏中的一个: DECLARE_TASKLET(name, func, data) DECLARE_TASKLET_DISABLED(name, func, data) 这两个宏都能根据给定的名字静态地创建一个tasklet_struct结构。当该小任务被调度以后,给定的函数func会被执行,它的参数由data给出。这两个宏之间的区别在于引用计数器的初始值设置不同。第一个宏把创建的小任务的引用计数器设置为0,因此,该小任务处于激活状态。另一个把引用计数器设置为1,所以该小任务处于禁止状态。例如: DECLARE_TASKLET(my_tasklet, my_tasklet_handler, dev); 这行代码其实等价于 struct tasklet_struct my_tasklet = { NULL, 0, ATOMIC_INIT(0), tasklet_handler, dev}; 这样就创建了一个名为my_tasklet的小任务,其处理程序为tasklet_handler,并且已被激活。当处理程序被调用的时候,dev就会被传递给它。 < >
编写并调度自己的小任务 void tasklet_handler(unsigned long data) 小任务不能睡眠,不能在小任务中使用信号量或者其它产生阻塞的函数。但它运行时可以响应中断 通过调用tasklet_schedule()函数并传递给它相应的tasklet_struct指针,该小任务就会被调度以便适当的时候执行: tasklet_schedule(&my_tasklet) 在小任务被调度以后,只要有机会它就会尽可能早的运行 在小任务被调度以后,只要有机会它就会尽可能早的运行。在它还没有得到运行机会之前,如果一个相同的小任务又被调度了,那么它仍然只会运行一次。 可以调用tasklet_disable()函数来禁止某个指定的小任务。如果该小任务当前正在执行,这个函数会等到它执行完毕再返回。调用tasklet_enable()函数可以激活一个小任务,如果希望把以DECLARE_TASKLET_DISABLED()创建的小任务激活,也得调用这个函数,如: tasklet_disable(&my_tasklet); /* 小任务现在被禁止,这个小任务不能运行 */ tasklet_enable(&my_tasklet); /* 小任务现在被激活 */ 也可以调用tasklet_kill()函数从挂起的队列中去掉一个小任务。该函数的参数是一个指向某个小任务的tasklet_struct的长指针。在小任务重新调度它自身的时候,从挂起的队列中移去已调度的小任务会很有用。这个函数首先等待该小任务执行完毕,然后再将它移去。 < >
下半部 下半部是一个不能与其他下半部并发执行的高优先级小任务 bh_base是一个指向下半部的指针数组,用于组织所有下半部 Linux常用的下半部 下半部 外部设备 TIMER_BH 定时器 TQUEUE_BH 周期性任务队列 SERIAL_BH 串行端口 IMMEDIATE_BH 立即任务队列 假设有一个bh叫做FOO_BH(FOO表示任意一个),其处理函数为foo_bh(),则 (1)通过init_bh(FOO_BH, foo_bh)函数对foo_bh()进行初始化,也就是把foo_bh()函数的地址插入bh_base的第 FOO_BH(对应一个数字)项。 (2)通过mark_bh(FOO_BH)函数提出对foo_bh()的执行请求。 < >
任务队列 任务队列就是指以双向队列形式连接起来的任务链表,每一个链表元素都描述了一个可执行的内核任务 三个特殊的任务队列: tq _immediate任务队列,由IMMEDIATE_BH下半部运行,该队列中包括要执行的内核函数和标准的下半部。 tq_timer任务队列,由TQUEUE_BH下半部运行,每次时钟中断都激活这个下半部。 tq_disk任务队列,用于块设备任务。 < >
中断的应用-时钟中断 大部分PC机中有两个时钟源,分别是实时时钟(RTC)和 操作系统(OS)时钟 实时时钟也叫硬件时钟,它靠电池供电,即使系统断电,也可以维持日期和时间。 RTC和OS时钟之间的关系通常也被称作操作系统的时钟运作机制 不同的操作系统,其时钟运作机制也不同 < >
时钟运作机制 一般来说,RTC是OS时钟的时间基准,操作系统通过读取RTC来初始化OS时钟,此后二者保持同步运行,共同维持着系统时间。所谓同步,是指操作系统在运行过程中,每隔一个固定时间会刷新或校正RTC中的信息。 Linux中的时钟运作机制如图5.7所示。OS时钟和RTC之间要通过BIOS的连接,是因为传统PC机的BIOS中固化有对RTC进行有关操作的函数,例如INT 1AH等中断服务程序,通常操作系统也直接利用这些函数对RTC进行操作,例如从RTC中读出有关数据对OS时钟初始化、对RTC进行更新等等。Linux在内核初始化完成后就完全抛弃了BIOS中的程序。 RTC处于最底层,提供最原始的时钟数据。OS时钟建立在RTC之上,初始化完成后将完全由操作系统控制,和RTC脱离关系。操作系统通过OS时钟提供给应用程序所有和时间有关的服务。因为OS时钟完全是一个软件问题,其所能表达的时间由操作系统的设计者决定,将OS时钟定义为整型还是长整型或者大的超乎想象都是由设计者决定。
Linux时间系统 OS时钟是由可编程定时/计数器产生的输出脉冲触发中断而产生的 Linux中用全局变量jiffies表示系统自启动以来的时钟节拍数目 < >
时钟中断处理程序 每一次时钟中断的产生都触发下列几个主要的操作: 自系统启动以来所花费的时间 更新时间和日期 确定当前进程在CPU 上已运行了多长时间,如果已经超过了分配给它的时间,则抢占它 更新资源使用统计数 检查定时器时间间隔是否已到,如果是,则调用适当的函数 因为考虑到第一个操作是紧迫的,因此,由时钟中断处理程序本身来完成。其余的四个操作是次紧迫之的,可以通过TIMER_BH和TQUEUE_BH下半部调用的函数来完成。 在内核启动时运行初始化函数time_init( ),并设置IRQ0对应的中断门。一旦IRQ0中断门被初始化,就可以调用时钟中断处理程序timer_interrupt( )。这个函数以关中断开始运行,大约每10ms被调用一次。 timer_interrupt( )调用的主要函数为do_timer_interrupt( ),后者有两个主要任务:一是维持实时时钟RTC(每隔一定时间间隔进行刷新),另一个是调用do_timer( )函数,这是时钟中断处理程序的核心函数。 以关中断运行的do_timer( )函数,必须被尽可能快地执行。因此,它只不过是简单地更新一个基本的值(系统自启动以来所用的时间),而把剩余的所有活动都委托给两个下半部处理。 do_timer( )函数等价于: void do_timer(struct pt_regs * regs) { jiffies++; lost_ticks++; mark_bh(TIMER_BH); if (!user_mode(regs)) lost_ticks_system++; if (tq_timer) mark_bh(TQUEUE_BH); } 函数中除了jifies外,还有两个变量lost_ticks和lost_ticks_system,这是用来记录timer_bh()执行前时钟中断发生的次数。因为时钟中断发生的频率很高(每10ms一次),所以在timer_bh()下半部执行之前,可能已经有时钟中断发生了,而timer_bh()要提供定时、记费等重要操作,所以为了保证时间计量的准确性,使用了这两个变量。lost_ticks用来记录timer_bh()执行前时钟中断发生的次数,如果时钟中断发生时当前进程运行于内核态,则lost_ticks_system用来记录timer_bh()执行前在内核态发生时钟中断的次数,这样可以对当前进程精确记费。 另外,只有tq_timer任务队列不为空,TQUEUE_BH 下半部才被激活。 < >
时钟中断的下半部处理 timer_bh( )函数与TIMER_BH 下半部相关联,它在每个时钟节拍都被激活 TIMER_BH 下半部以关中断调用update_times( )函数,该函数会以关中断来更新xtime 更新了系统时钟xtime之后,update_times( )再次打开中断 timer_bh( )函数与TIMER_BH 下半部相关联,它主要调用了update_times( )函数,下面给予简述。 在系统初始化期间,time_init( )调用get_cmos_time( )从实时时钟中读时间和日期,并存放在全局变量xtime(类型为struct timeval)中。一旦日期被读取,内核将再也不需要实时时钟RTC,而是依靠TIMER_BH 下半部,它会在每个时钟节拍都被激活。 TIMER_BH 下半部以关中断调用update_times( )函数,该函数会以关中断来更新xtime。并执行下列语句: if (lost_ticks) update_wall_time(lost_ticks); update_wall_time( )函数连续调用update_wall_time_one_tick( )函数lost_ticks次;每次调用都更新xtime。 更新了系统时钟xtime之后,update_times( )再次打开中断,并执行下列操作: l 把lost_ticks的值保存在局部变量ticks后,清lost_ticks l 把lost_ticks_system的值保存在局部变量system 后,清lost_ticks_system l 调用calc_load(ticks) l 调用update_process_times(ticks, system) calc_load( )函数计算处于TASK_RUNNING或TASK_UNINTERRUPTIBLE状态的进程个数,并利用这个数更新CPU使用统计数。而update_process_times( )函数更新内核的一些统计数;然后,它又调用update_one_process( )更新存放统计数的一些域。 < >
定时器及应用 定时器是管理内核所花时间的基础,也被称为动态定时器或内核定时器 定时器的使用:执行一些初始化工作,设置一个到期时间,指定到时后执行的函数,然后激活定时器就可以了 定时器由timer_list结构表示 struct timer_list { struct list_head entry; unsigned long expires; unsigned long data; void (*function)(unsigned long); }; 内核提供了一组与定时器相关的接口简化了对定时器的操作。 创建定时器首先需要先定义它: struct timer_list my_timer; 接下来需要通过一个辅助函数初始化定时器数据结构的内部值,初始化必须在对定时器操作前完成。 init_timer(&my_timer); 现在就可以填充结构中需要的值: my_timer.expires = jiffies + delay; /* 定时器到期节拍数*/ my_timer.data = 0;/* 给定时器处理函数传入0值*/ my_timer.function = my_function;/*定时器到期调用的函数*/ my_timer.expires表示到期时间,它是以节拍为单位的绝对计数值。如果当前jiffies计数等于或大于my_timer.expires,由my_timer.function指向的处理函数就会开始执行,另外该函数还要使用长整型参数my_timer.data。我们从timer_list结构可以看到,处理函数必须符合下面的函数原形: void my_timer_function(unsigned long data) data参数不同,则可以对应不同的定时器。如果不需要这个参数,可以简单传递0(或任何其它值)。 最后,必须激活定时器: add_timer(&my_timer); 到此为止,定时器可以工作了。但请注意定时值的重要性。当前节拍计数等于或大于指定的到期时间,内核就开始执行定时器处理函数。虽然内核可以保证不会在定时时间到期前运行定时器处理函数,但是有可能延误定时器的执行。一般来说,定时器都在到期后马上就会执行,但是也有可能被推迟到下一次时钟节拍才能运行,所以不能用定时器实现任何硬实时任务。 如果需要在定时器到期前停止定时器,可以使用del_timer()函数: del_timer(&my_timer); 被激活或未被激活的定时器都可以使用该函数,如果定时器还未被激活,该函数返回0;否则返回1。注意,不需要为已经到期的定时器调用该函数,因为它们会自动被删除。 < >
定时器的使用 定义定时器: struct timer_list my_timer; 初始化定时器: init_timer(&my_timer); 激活定时器: add_timer(&my_timer); 如果需要在定时器到期前停止定时器,可以使用del_timer()函数: del_timer(&my_timer); < >
内核在时钟中断发生后执行定时器,定时器在下半部中被run_timer_list( )执行 定时器的执行与应用 内核在时钟中断发生后执行定时器,定时器在下半部中被run_timer_list( )执行 定时器的使用: 例:创建和使用进程延时 < >
< > timeout = 2 * HZ; /*1HZ等于100,因此为2000ms*/ set_current_state(TASK_INTERRUPTIBLE); remaining = schedule_timeout(timeout); 内核用定时器实现进程的延时,调用schedule_timeout( )函数,该函数执行下列语句:struct timer_list timer; expire = timeout + jiffies; init_timer(&timer); timer.expires = expire; timer.data = (unsigned long) current; timer.function = process_timeout; add_timer(&timer); schedule( ); /* 进程被挂起直到定时器到期 */ del_timer_sync(&timer); timeout = expire - jiffies; return (timeout < 0 ? 0 : timeout); 当延时到期时,内核执行下列函数: void process_timeout(unsigned long data) { struct task_struct * p = (struct task_struct *) data; wake_up_process(p); } < >
“内核之旅 ”网站 http://www.kerneltravel.net/ 电子杂志栏目是关于内核研究和学习的资料 第八期“中断”,将向读者依次解释中断概念,解析Linux中的中断实现机理以及Linux下中断如何被使用。