Linux内核源代码情景分析---第三章 中断、异常和系统调用

http://blog.sina.com.cn/s/blog_6b94d5680101va6e.html



前言

 总言之,有两大种代码的中断方式
     1:CPU的外部中断导致CPU代码执行中断
     2:CPU本身在执行代码过程出现的代码中断情况
 具体说,CPU的中断可以细分为下
     1:外部中断 ==>这种中断的发生完全是"异步"的,根本无法预测此类中断会在什么时候发生。因此,CPU(或者软件)对外部中断的响应完全是被动的。不过,软件可以通过"关中断"指令关闭对中断的响应,把它"反映情况"的途径掐断
     2:陷阱(软件中断)==>由专设的指令,如x86中的INTn,在程序中有意地产生的,所以是主动的,"同步"的。只要CPU执行了一条INT指令,就知道在开始执行下一条指令之前一定要先进入中断服务程序。这种主动的中断称为"陷阱"
     3:异常 ==>一般也是异步的,多半由于"不小心"犯了规才发生。例如,当你在程序中发出一条除法指令DIV,而除数为0时,就会发生一次异常。这多半是因为不小心,而不是故意的,所以也是被动的。当然,也不排除故意的可能性。我们在第2章中看到过通过页面异常扩展堆栈区间的情景,那就是故意安排的。

3.1 x86CPU对中断的硬件支持
 Intel的CPU在实现保护时,对中断响应机制也做了大幅修改。
  1:中断向量 不再是简单的入口地址,而变成了一串用来描述所谓 “门”gate的描述值
   当中断发生时,需要通过这些门(软件解析这些描述值)才能进入相应的服务程序。
    这样的门并不光是为中断而设的,只要想切换0?;;的运行状态,即其优先级别,例如从用户的3级进入系统的0级,就都要通过一道门。而从用户态进入系统态的途径也并不只限于中断(或异常,或陷阱〕,还可以通过子程序调用指令CALL和转移指令JMP来达到目的。而且,当中断发生时不但可以切换CPU的运行状态并转入中断服务程序,还可以安排进行一次任务切换〔所谓"上下文切换"〕,立即切换到另一个进程。同此在操作系统中可以设立一个"中断服务进程(任务)",每当中断发生时就切换到该进程。
    按不同的用途和目的,CPU中一共有四种门,即 任务门(task gate)中断门(interruptgate)陷阱门(trap gate)以及 调用门(callgate)。其中除任务门外其它三种门的结构基本相同,不过调用门并不是与中断向量表相联系的。
    64位任务门:任务门其实就是通过TSS段选择码去切换一个新任务(X86 CPU 有一个TSS段,类似于 代码段 数据段,所以TSS段选择码类似于 CS DS等段寄存器),不过linux系统不采用这个任务门来切换任务,所以一般不用到这个门 以及TSS段。
Linux内核源代码情景分析---第三章 <wbr>中断、异常和系统调用
    TSS段选择码的作用和段寄存器〔CSDS 等相似,通过GDT或LDT指向特殊的"系统段"中的一种,称为 "任务状态段"(taskstate segment)TSS。  TSS实际上是一个用来保存任务运行"现场"的数据结构,其中包括CPU中所有与具体进程有关的寄存器的内容(包含页面目求指针CR3),还包括了三个堆栈指针。中断发生时,CPU在中断向量表中找到相应的表项。如果此表项是一个任务门,并且通过了优先级别的检查, CPU就会将当前任务的运行现场保存在相应的TSS中,并将任务门所指向的TSS作为当前任务,将其内容装入CPU中的各个寄存器,从而完成了一次任务的切换。为此目的,CPU中又增设了一个"任务寄存器"TE,用来指向当前任务的TSS。 在linux内核中,一个任务就是一个进程,但是进程的"控制块",即task_struct结构中需要存放更多的信息。所以,从这个意义上讲,linux的进程又并不完全是intel设计意图中的任务。读者后面就会看到,内核并不采用任务门作为进程切换的手段。通过任务门切换到一个新的任务并不是惟一的途径,例如在程序中也可以用CALL指令或JMP指令通过调用门达到同样的目的。
    中断门  陷阱门 调用门三者的结构基本相同
    Linux内核源代码情景分析---第三章 <wbr>中断、异常和系统调用

   三种门之间的不同之处在于3位的类型码。中断门的类型码是110,陷阱门的类型码是111,而调用门的类型码是100。与任务门相比,不同之处主要在于:在任务门中不需要使用段内位移,因为任务门并不指向某一个子程序的入门,TSS本身是作为一个段来对待的,而中断门、陷阱门和调用门则都要指向一个子程序,所以必须结合使用段选择码和段内位移。此外,任务门中相对于0标志位的位置上永远是0。

    中断门和陷阱门在使用上的区别不在于中断是外部产生的或是由CPU本身产生的,而是在于通过中断门进入中断服务程序时CPU会自动将中断关闭,也就是将CPU中EFLAGS寄存器的IF标志位清成0,以防嵌套中断的发生;而在通过陷阱门进入服务程序时则维持IF标志位不变。这就是中断门和陷阱门的惟一区别

   不管什么门,都通过段选择码指向一个存储段。段选择码的作用与普通的段寄存器一样。我们在第2章中讲过,在保护模式下段寄存器的内容并不直接指向一个段的起始地址,而是指向由GDTR或LDTR决定的某个段描述表中的一个表项,所以才又称为"段选择码"。至于到底是由GDTR还是由LDTR所指向的段描述表,则取决于段选择码中的一个TI标志位。在内核中,实际上只使用全局段描述表GDTR,而局部段描述表LDTR只是在特殊应用中〔主要是WINE〕才使用。对于中断门、陷阱门和调用门来说,段描述表中的相应表项显然应该是一个代码段描述项。而任务门所指向的描述项,则是专门为TSS而设的TSS描述项。TSS描述项的结构与我们在第2章中所讲的基本上是相同的,但是BIT44的S标志位为0,表示不是一般的代码段或数据段。

每个段描述项中都有一个DPL位段,即"描述项优先级别"位段。当CPU通过中断门找到一个代码段描述项,并进而转入相应的服务程序时,就把这个代码段描述项装入CPU中,而描述项的DPL就变成CPU的当前运行级别,称为CPL。这与我们在前面所说的PDP=11在中断时从向量表中同时装入PSW和服务程序入口地址是一致的。可是,在中断门中也有一个DPL,那是干什么用的呢?这就是要讲到 i386的保护模式中对运行和访问级别进行检查比对的机制了。

linux内核不使用任务门,基本不使用调用门(为兼容,有时使用)。 

   当通过一条INT指令进入一个中断服务程序时,在指令中给出一个中断向量。 CPU先根据该向量在中断向量表中找到一扇门(描述项),在这种情况下一般总是中断门 (要看中断向量表被初始化成什么样的门以及 什么中断)。然后, 就要将这个门的DPL与CPU的CPL相比,CPL必须小于或等于DPL,也就是优先级别不低于DPL,(CPU)才能穿过这扇门。不过, 如果中断是由外部产生或是因CPU异常而产生的话,那就免去了这一层检验【只用陷阱类中断才需要做优先级的检测】穿过了中断门之后,还要进一步将目标代码段描述项中的DPL与CPL比较(以前是门的DPL与CPU的CPL比较,现在是CPU的CPL与穿过门后的代码段的DPL比较) ,目标段的DPL必须小于或等于CPL。也就是说,通过中断门时只允许保持或提升CPU的运行级别;而不允许降低其运行级别。这两个环节中的任何一个失败都会产生一次全面保护异常。
   进入中断服务程序时,CPU要将当前EFLAGS寄存器的内容以及返回地址压入堆栈,返回地址是由段寄存器CS的内容和取指令指针EIP的内容共同组成的。如果中断是由异常引起的,则还要将一个表示异常原因的出错代码也压入堆栈。进一步, 如果中断服务程序的运行级别,也就是目标代码段的DPL,与中断发生时的CPL不同,那就要引起更换堆栈。前面提到过,TSS结构中除所有常规的寄存器内容(包括当前的SS和ESP〕外,还有三个额外的堆栈指针〔SS加ESP〕。这三个额外的堆栈指针分别用于当CPU在目标代码段中的运行级别为0,1以及2时。所以,CPU根据寄存器TR的内容找到当前TSS结构,并根据目标代码段的DPL,从这TSS结构中取出新的堆栈指针〔SS加ESP〕,并装入其堆栈段寄存器SS和堆栈指针(寄存器)ESP,达到更换堆栈的目的。在这种情况下,CPU不但要将EFLAGS、返回地址以及出错代码压入堆栈,还要先将原来的堆栈指针也压入堆栈(新堆栈)。
============================
当通过一条INT指令进入一个中断服务程序时,在指令中给出一个中断向量。CPU先根据该向量在中断向量表中找到一扇门(也就是中断向量表中其中一项描述值),在这种情况下一般总是中断门(是什么门,由初始化中断向量表决定,在中断向量对应的中断向量表位置上初始化成你想要的门)。然后,就要将这个门的DPL与CPU的CPL相比,CPL必须小于或等于DPL,CPU才能才能穿过这个门。但如果中断不是陷阱,而是外部中断或者系统异常的话,就不要做优先级的检测。穿过中断门之后,还要进一步将目标代码段描述项中的DPL与CPL比较(以前是门的DPL与CPU的CPL比较,现在是CPU的CPL与穿过门后的代码段的DPL比较),目标段的DPL必须小于或等于CPL……
============================
Linux内核源代码情景分析---第三章 <wbr>中断、异常和系统调用
Linux内核源代码情景分析---第三章 <wbr>中断、异常和系统调用
    具体到linux内核。当中断发生在用户状态。也就是CPU在用户空间中运行时,由于用户态的运行级别为3,而在内核中的中断服务程序的运行级别为0,所以会引起堆栈的更换。也就是说,从用户堆栈切换到系统堆栈。而当中断发生在系统状态时,也就是当CPU在内核中运行时,则不会更换堆栈。最后,在保护模式中,中断向量表在内存中的位置也不再限于从地址0开始的地方,而是像GDT和LDT那样可以放在内存中的任何地方。为此目的,在CPU中又增设了一个寄存器IDTR指向当前中断向量表IDT,或者说当前中断描述表
Linux内核源代码情景分析---第三章 <wbr>中断、异常和系统调用
3.2  中断向量表IDT的初始化【即初始化中断向量表IDT,IDT被初始化为各种“门”值】
       int n 就是用来触发软件中断的, n就是指定一个向量号(IDT表 数组中的 位置);至于n对应的是 中断门还是陷阱门,那就要看初始化的结果了; 如果是中断门,则不检查优先级,如果调用int n,n对应的是陷阱门,则需要检测优先级;
      Linux内核在初始化阶段完成了对页式虚拟管理的初始化后,便调用trap_init()和init_IRQ()两个函数进行中断机制的初始化。其中 trap_init()中要是对一些系统保留的中断向量的初始化,而init_IRQ()主要是用于外设的中断。    程序中先设置中断向量表开头的19个陷阱门(初始化时会给该门设置好对应的门类型),这些中断向量都是CPU保留用于异常处理(通过陷阱门(软件中断)实现了异常,比如缺页异常等),比如向量14就是为页面异常保留的,CPU硬件在页面映射及访问的过程中发生问题(如缺页),就会产生一次以14(0xE)为中断向量的异常。操作系统的设计和实现必须遵循这些规定。   接下来是对系统调用向量的初始化,常数SYSCALL_VECTOR在include/asm-i386/hw_irq.h中定义为0x80,所以执行一条” int $0x80”指令就是进行一次系统调用。【这里系统调用 也是一种通过陷阱(软件中断),因为系统调用初始化函数内部调_set_gate(x,x,x,x)时初始化门的类型为陷阱门111
 
   
  
Linux内核源代码情景分析---第三章 <wbr>中断、异常和系统调用
   SYSCALL_VECTOR被定义为0X80;
   int &0x80就是一次系统调用;
   INT n:软件中断,n指定的中断向量表中对应的中断向量;如中断向量表中的第二项为不可屏蔽中断的服务程序(中断门,且DPL为0),那如果用户进程在用户空间通过INT 2来用陷阱的方式试图进入这个中断服务程序,这时CPU的CPL为3,而对应的中断门的DPL为0,所以是不能通过中断门的,这时CPU会产生一次异常。   Linux并不是使用调用门,接下来为了兼容,这里还初始化了两个调用门,   陷阱门与中断门的不同仅在于通过中断门进入服务程序时自动关中断,而通过陷阱门进入服务程序时则维持不变。所以CPU因页面异常进入服务程序时,中断多半是开着的,这时CPU的状态都是可以中断的、此外系统调用也是一种陷阱门,所以系统调用也是可中断的,系统调用时在用户空间通过int $0x80 进行的,只有将陷阱门的DPL设成3才能让系统顺利穿过,否则就会把系统调用拒之门外。   在init_ISA_irq()中对PC的中断控制器8259A进程初始化,并且初始化一个结构数组irq_desc[];这个数组的每一个元素就是各个中断向量对应的中断请求队列的头结点;【外部中断】   i386的系统结构支持256个中断向量,还要扣除一些为CPU本身保留的向量。但作为一个通用的操作系统,很难说剩下的这些中断向量是否够用,而且,很多外部设备由于种种原因可能本来就不得不公用一个中断向量。所以,在像linux这样的系统中,限制每个中断源都必须独占使用一个中断向量是不现实的。解决方法就是为共用的中断向量提供一种方法。因此,系统中为每个中断向量设置了一个队列,而根据每个中断源所使用(产生)的中断向量,将其中断服务程序挂到相应的队列中去,而数字irq_desc[]中的每个元素就是这样一个队列的头结点。当中断发生是,首先执行与中断向量相对应的一段总服务程序,根据具体中断源的设备号在其所属队列中找到特定的服务程序加以执行。
  实际上由外设产生的中断处理全部进入一段公共的程序common_interrupt中,而在此之前分别跑到IRQ0x01_interrupt或者IRQ0x02_interrupt等等的目的,只在于由此得到一个与中断向量相关的数组(压入堆栈)对应于IRQ0x01_interrupt到IRQ0x0f_interrupt,该数组分别为0x0fffff00至0xffffff0f
3.3  中断请求队列的初始化
         中断向量表IDT 有两种表项:
       一种是保留专用于CPU本身的中断门(这个中断指通用的中断),主要用于CPU产生的异常(如系统异常 与 软件中断【陷阱】),如“除数为0"、“页面错”等等,以及由用户程序通过INT指令产生的中断(指陷阱),主要用于产生系统调用(另还有个用于debug的INT3),这些中断门(广义中断)的向量除用于系统调用的0x80外都在0x20(IDT数组表的下标)以下,。
       另一种是从0x20开始,共224项目,都是用于外设的通用中断门(外部中断用的中断门);
       两者的区别是 第二种用于外设的通用中断门可以为多个中断源所共享(有中断请求队列),而专用中断门则是为特定的中断源所专用(无中断请求队列)。
     由于通用中断门是让多个中断源共用的,而且允许这种共用的结构在系统运行的过程中动态地变化,所以在IDT的初始化阶段只是为每个中断向量,也即每个表项准备下一个"中断请求队列",从而形成一个中断请求队列的数组,这就是数组irq_desc[]。
     结构数组irq_desc[];这个数组的每一个元素就是各个中断向量对应的中断请求队列的头结点;头结点具体的结构不在此详细列举了;
    计算机系统在使用中常常 有产生随机数的要求,但是要产生 真正的随机数是不可能的(所以由计算机产生的随机数称为 "伪随机数"〕。为了达到尽可能的随机, 需要在系统的运行中引入一些随机的因素,称为"嫡"(entropy)。由各种中断源产生的中断请求在时间上大多是相当随机的,可以用来作为这样的随机因素。所以linux内核提供了一种手段,使得可以根据中断发生的时间来引入一点随机性。需要在某个中断请求队列,或者说中断请求通道中引入这种随机性时,可以在调用参数irqflags中将标志位SA_SAMPLE_RANDOM设成1。而这里调用的rand_initialize_irq()就据此为该中断请求队列初始化一个数据结构,用来记录该中断的时序。

3.4中断的响应和服务(do_IRQ()调用具体的中断服务程序)
   CPU从中断控制器取得中断向量,然后根据具体的中断向量从中断向量表IDT中找到相应的表项,而该表项应该是一个中断门。这样,CPU就根据中断门的设置而到达了该通道的总服务程序的入口,假定为IRQ0x30_interrupt。由于中断是当CPU在用户空间中运行时发生的,当前的运行级别CPL为3;而中断服务程序属于内核,其运行级别DPL为0, 二者不同。所以, CPU要从寄存器TR所指的当前TSS中取出用于内核〔0级)的堆栈指针,并把堆栈切换到内核堆栈,即当前进程的系统空间堆栈应该指出,CPU每次使用内核堆栈时对堆栈所作的操作总是均衡的,所以每次从系统空间返回到用户空间时堆栈指针一定回到其原点,或曰"堆栈底部"。也就是说,当CPU从TSS中取出内核堆栈指针并切换到内核堆栈时,这个堆栈一定是空的。这样,当CPU进入IRQ0x30_interrupt时,堆栈中除寄存器EFLAGS的内容以及返回地址外就一无所有了。另外,由于所穿过的是中断门(而不是陷阱门),所以中断已被关断;在重新开启中断之前再没有其它的中断可以发生了。
  中断服务的总入口IRQ0xYY_interrupt的代码以前已经见到过了,但为方便起见再把它列出在这里。再说,我们现在的认识也可以更深入一些了。
  如前所述,所有公用中断请求的服务程序总入口是由GCC的预处理阶段生成的,全部都具有相同的模式:
Linux内核源代码情景分析---第三章 <wbr>中断、异常和系统调用
   这段程序的目的在于将一个与中断请求号相关的数值压入堆栈,使得在common_interrupt中可以通过这个数值来确定这次中断的来源。可是为什么要从中断请求号0x03中减去256使其变成负数呢?就用数值0x03不是更直截了当吗?这是因为,系统堆栈中的这个位置在因系统调用而进入内核时要用来存放系统调用号,而系统调用又与中断服务共用一部分子程序。这样,就要有个手段来加以区分。当然,要区分系统调用号和中断请求号并不非得把其中之一变成负数不可。例如,在中断请求号上加上一个常数,比方说0x1000,也可以达到目的。 但是,如果考虑到运行时的效率,那么把其中之一变成负数无疑是效率最高的。将一个整数装入到一个通用寄存器之后, 要判断它是否大于等于0是很方便的,只要一条寄存器指令就可以 。而如果要与另一个常数相比较,那就至少要多访问一次内存从这个例子也可以看出,内核中的有些代码看似简单,好像只是作者随意的决定,但实际上却是经过精心推敲的。
   common_interrupt -->do_IRQ();
  在进入中断时自动做的,实际上都是在为do_IRQ()建立一个模拟的子程序调用环境,使得在do_IRQ()中既可以方便地知道进入中断前夕各个寄存器的内容,又可以在执行完毕后返回到ret_from_intr,并且从那里执行中断返回。可想而知, 当do_IRQ()调用具体的中断服务程序时也一定会把pt_regs数据结构【指向一个包含各个寄存器的数据结构】的内容传下去,不过那时只要传一个指针就够了。
  比如do_page_fault(),其调用参数表为:
   asmlinkage void do_page_fault(struct pt_regs*regs, unsigned long error_code);
  第一个参数regs 就是指向structpt_regs的指针,实际上就是指向系统堆栈中的那块地方。不过页面异常并不是通用的外部中断请求,而是CPU保留专用的,所以该中断发生时并不经过do_IRQ()这条路线,但是对于系统堆栈的这种安排基本上是一致的。
 对系统堆栈的这种安排不光用于中断,还用于系统调用。
 从逻辑的角度说对中断请求的服务似乎已经完毕,可以返回了。可是linux内核在这里有个特殊的考虑,这就是所谓softirq,即"(在时间上)软性的中断请求",以前称为"bottomhalf"。在linux中,设备驱动程序的设计人员可以将中断服务分成两"半",其实是两"部分",而并不一定是两^半"。第一部分是必须立即执行,一般是在关中断条件下执行的,并且必须是对每次请求都单独执行的。而另一部分,即"后半"部分,是可以稍后在开中断条件下执行的,并且往往可以将若干次中断服务中剩下来的部分合并起来执行。这些操作往往是比较费时的,因而不适宜在关中断条件下执行,或者不适宜一次占据CPU时间太长而影响对其它中断请求的服务。这就是所谓的"后半"(bottomhalf),在内核代码中常简称为bh。作为一个比喻,读者不妨想像在"cookedmode"下从键盘输入字符串的过程(详见设备驱动),每当按一个键的时候,首先要把字符读进来,这要放在"前半"中执行;而进一步检查所按的是否"问车"键,从而决定是否完成了一个字符串的输入,并进一步把睡眠中的进程唤醒,则可以放在"后半"中执行。


3.5 软中断 和bottom half(后半)
   中断服务一般都是在将中断请求关闭的条件下执行的,以避免嵌套而使控制复杂化。可是, 如果关中断的时间持续太长就可能因为CPU不能及时响应其它的中断请求而使中断〔请求)丢失,为此,内核允许在将具体的中断服务程序挂入中断请求队列时将SA_INTERRUPT标志置成0,使这个中断服务程序在开中断的条件下执行。然而,实际的情况往往是: 若在服务的全过程关中断则"扩大打击面",而全程开中断则又造成"不安定因素",很难取舍。一般来说,一次中断服务的过程常常可以分成两部分开头的部分往往是必须在关中断条件下执行的。这样才能在不受干扰的条件下"原子"地完成一些关键性操作。同时,这部分操作的时间性又往往很强,必须在中断请求发生后"立即"或至少是在一定的时间限制中完成,而且相继的多次中断请求也不能合并在一起来处理。而后半部分,则通常可以、而且应该在开中断条件下执行,这样才不至于因将中断关闭过久而造成其它中断的丢失。同时,这些操作常常允许延迟到稍后才来执行,而且有可能将多次中断服务中的相关部分合并在一起处理。这些不同的性质常常使中断服务的前后两半明显地区分开来,可以、而且应该分别加以不同的实现。这里的 后半部分就称为"bottlomhalf", 在内核代码中常常缩写为bh。这个概念在相当程度上来自RISC系统结构。在RISC的CPU中,通常都有大量的寄存器。当中断发生时,要将所有这些寄存器的内容都压入堆栈,并在返回时加以恢复,为此而付出很高的代价。所以,在RISC结构的系统中往往把中断服务分成两部分。第一部分只保存为数不多的寄存器(内容〕,并利用这为数不多的寄存器来完成有限的关键性的操作,称为"轻量级中断"。而另一部分,那就相当于这里的bh了。虽然i386的结构主要是CISC的,面临的问题不尽相同,但前述的问题已经使bh的必要性在许多情况下变得很明显了。
   为了简化bh函数的设计,在 do_bottom_half()中也像do_IRQ()中一样,把 bh函数的执行严格地"串行化"了。这种串行化有两方面的考虑和措施:
  一方面,bh函数的执行不允许嵌套。如果在执行bh函数的过程中发生中断,那么由于每次中断服务以后在do_IRQ()中都要检查和处理bh函数的执行,就有可能嵌套。为此,在 do_bottom_half()中针对同一CPU上的嵌套执行加了锁。这样,如果进入 do_bottom_half()以后发现已经上了锁,就立即返回。因为这说明CPU在本次中断发生之前已经在这个函数中了。
   另一方面,是在多CPU系统中,在同一时间内最多只允许一个CPU执行bh函数,以防有两个甚至更多个CPU同时来执行bh函数而互相干扰。为此在 do_bottom_half()中针对不同CPU同时执行bh函数也加了锁。这样,如果进入 do_bottom_half()以后发现这个锁已经锁上,就说明已经有CPU在执行bh函数,所以也立即返回。
   这两条措施,特别是第二条措施,保证了从单CPU结构到多CPU SMP结构的平稳过渡。可是,在当时的linux内核可以在多CPU SMP结构上稳定运行以后,就 慢慢发现这样的处理对于多CPUSMP结构的性能有不利的影响。原因就在于上述的第二条措施使bh函数的执行完全串行化了。当系统中有很多bh函数需要执行时,虽然系统中有多个CPU存在,却只有一个CPU这么一个"独木桥"。跟do_IRQ() 作一比较就可以发现, 在do_IRQ()中的串行化只是针对一个具体中断通道的而bh函数的串行化却是全局性的所以是"防卫过当"了。既然如此,就应该考虑放宽上述的第二条措施。但是,如果放宽了这一条,就要对bh函数本身的设计和实现有更高的要求(例如对使用全局量的互斥〕,而原来已经存在的bh函数显然不符合这些要求。所以, 比较好的办法是保留bh,另外再增设一种或几种机制,并把它们纳入一个统一的框架中。这就是2.4版中的 "软中断"softirq机制
   从字面上说softirq就是软中断,可是"软中断"这个词(尤其是在中文里)已经被用作"信号〃signal的代名词,因为信号实际上就是"以软件手段实现的中断机制"。但是,另一方面,把类似于他的机制称为"软中断"又确实很贴切。这一方面反映了上述bh函数与中断之间的类比,另一方面也反映了这是一种在时间要求上更为软性的中断请求。实际上,这里所体现的是层次的不同。 如果
说"硬中断"通常是外部设备对CPU的中断,那么softirq通常是"硬中断服务程序"对内核的中断,而"信号"则是由内核〔或其它进程)对某个进程的中断。后面这二者都是由软件产生的"软中断"。所以,对"软中断"这个词的含意要根据回上下文加以区分。
   系统在初始化时通过函数softirq_init()对内核的软中断机制进行初始化。
   软中断本身是一种机制,同时也是一个框架。在这个框架里有bh机制,这是一种特殊的软中断,也可以说是设计最保守的,但却是最简单、最安全的软中断。除此之外,还有其它的软中断;,如下:
    enum
    {
        HI_SOFTIRQ = 0,
        NET_TX_SOFTIRQ ,
        NET_RX_SOFTIRQ, 
        TASKLET_SOFTIRQ,    
    }
   这里最值得注意的是TASKLET_SOFTIRQ ,代表着一种称为tasklet的机制。也许采用这个词的原意在于表示这是一片小小的"任务",但是这个词容易使人联想到task即进程而引起误会,其实这二者毫无关系。 NET_TX_SOFTIRQ  NET_RX_SOFTIRQ 两种软中断是专为网络操作而设的,所以在softirq_init()中只对TASKLET_SOFTIRQ 和HI_SOFTIRQ 两种软中断进行初始化。
   先看 bh机制的初始化内核中为bh机制设置了一个结构数组bh_task_vec[] ,这是tasklet_struct数据结构的数组。
Linux内核源代码情景分析---第三章 <wbr>中断、异常和系统调用
   代码的作者加了详细的注释,说 tasklet是"多序"(不是"多进程"或"多线程")的bh函数。为什么这么说呢?因为对 tasklet的串行化不像对bh函数那样严格,所以允许在不同的cpu上同时执行tasklet,但必须是不同的tasklet。 一个 tasklet_struct数据结构就代表着一个tasklet,结构中的函数指针func指向其服务程序。那么,为什么在bh机制中要使用这种数据结构呢?这是 因为bh函数的执行〔并不是bh函数本身)就是作为一个tasklet来实现的,在此基础上再加上更严格的限制,就成了bh。
  在softirq_init()中,对用于 bh的32个tasklet_struct结构调用tasklet_init()以后,它们的函数指针func全都指向bh_action()
  对其它软中断的初始化是通过open_softirq()完成的,其代码也在同一文件中:
  内核中为软中断设置了一个以"软中断号"为下标的数组softirq_vec[] ,类似于中断机制中的irq_desc[]。
Linux内核源代码情景分析---第三章 <wbr>中断、异常和系统调用
    数组softirq_vec[] 是个全局量,系统中的各个CPU所看到的是同一个数组。但是,每个CPU各有其自己的"软中断控制/状况结构",所以这些数据结构形成一个以CPU编号为下标的数组irq_stat[]。这个数组也是全局量,但是各个CPU可以按其自身的编号访问相应的数据结构。
    ......


3.6 页面异常的进入与返回
   我们在第2章中介绍内核对页面异常处理时,是从 do_page_fault()开始的。当时因为尚未介绍CPU的中断和异常机制,所以暂时跳过了对页面异常的响应过程,也就是从发生异常至CPU到达do_page_fault()之间的那一段路程,以及从do_page_fault() 返回之后到CPU返回到用户空间这一段路程。现在,我们可以来补上这个缺口了。
    与外设中断不同,各种异常都有为其保留的专用中断向量,因此相应的初始化也是直截了当的,这一点我们已经在初始化一节中看到了
  为页面异常设置的中断门【中断向量表的某项是什么门,由初始化决定】指向程序入口page_fault(),所以当发生页面异常时,CPU穿过中断门以后就直接到达了 page_fault();CPU因异常而穿过中断门的过程,包括堆栈的变化,与因外设中断而引起的过程基本上是一样的,读者可以参阅外设中断一节。但是,有一点很重要的不同。当中断发生时,CPU将寄存器EFLAGS的内容,以及代表着返回地址的CS和EIP两个寄存器的内容压入堆栈。如果CPU的运行级别发生变化,则在此之前还要发生堆栈的切换,并且要把代表老堆栈指针的SS和ESP的内容压入堆栈。这一点,我们已经在前面介绍过了。当异常发生时, 在上述这些操作之后,还要加上附加的操作。那就是:如果所发生的异常产生出错代码的话,就把这个出错代码也压入堆栈。并非所有的异常都产生出错代码,有关详情可参考INTEL的技术资料或相关专著,但是绝大多数异常,包括我们这里所关心的页面异常是会产生出错代码的。而且,实际上我们在第2章中已经看到 do_page_fault()如何通过这个出错代码识别发生异常的原因。可是, CPU只是在进入异常时才知道是否应该把出错代码压入堆栈。而从异常处理通过iret指令返回时已经时过境迁,CPU已经无从知道当初发生异常的原因,因此不会自动跳过堆栈中的这一项,而要靠相应的异常处理程序对堆栈加以调整,使得在CPU开始执行iret指令时堆栈顶部是返回地址。由于这个不同,对异常的处理和对中断的处理在代码中也要有所不同。
页面异常处理的入口page_fault()是在arch/i386/kernel/entry.S中定义的:
       Linux内核源代码情景分析---第三章 <wbr>中断、异常和系统调用

这里的跳转目标error_code就好像外设中断处理中的common_interrupt()一样,是各种异常处理所共用的程序入口。而将服务程序do_page_fault()的地址压入堆栈,则为进入具体的服务程序作好了准备。程序入口error_code的代码也在同一文件arch/i386/kernel/entry.S中:

 ......

   3.7 时钟中断

    在所有的外部中断中,时钟中断起着特殊的作用,其作用远非单纯的计时所能相比。当然,即使是单纯的计时也已经足够重要了。别的不说,没有正确的时间关系,你用来重建内核的工具make就不能正常运行了,因为make是靠时间标记来确定是否需要重新编译以及连接的。可是时钟中断的重要性还远不止于此。我们在中断一节中看到,内核在每次中断(以及系统调用和异常)服务完毕返回用户空间之前都要检查是否需要调度,若有需要就进行进程调度。事实上,调度只是当CPU在内核中运行时才可能发生【这是因为各种进程控制块都在内核中,都在系统空间】。在进程一章中,读者将会看到进程调度发生在两种情况下一种是"自愿"的,通过像sleep()之类的系统调用实现;或者是在通过其它系统调用进入内核以后因某种原因受阻需要等待,而"自愿"让内核调度其它进程先来运行另一种是"强制"的,当一个进程连续运行的时间超过一定限度[时间片调度]时,内核就会强制地调度其它进程来运行。如果没有了时钟,内核就失去了与时间有关的强制调度的依据和时机,而只能依赖于各个进程的"思想觉悟"了。试想,如果有一个进程在用户空间中陷入了死循环,而在循环体内也没有作任何系统调用,并且也没有发生外设中断,那么,要是没有时钟中断,整个系统就在原地打转什么事也不能做了。这是因为,在这种情况下永远不会有调度,而死抓住CPU不放的进程则陷在死循环中。退一步讲,即使我们还有其它的准则〔例如进程的优先级)来决定是否应该调度,那也得要有中断、异常或系统调用使CPU进入内核运行才能发生调度。而惟一可以预测在一定的时间内必定会发生的,就是"时钟中断"。所以,对于像LINUX这样的"分时系统"来说,时钟中断是维护"生命"的必要条件,难怪人们称时钟中断为"heartbeat",也即"心跳"

   时钟中断和调度是密切联系在一起的。以前也讲到过,一旦开始有时钟中断就可能要进行调度,所以要先完成对调度机制的初始化,作好准备。
   当提及"系统时钟"时,实际上是指着内核中的两个全局量之一。
          个是数据结构xtime,其类型为strcut_timeval.数据结构中记载的是从历史上某一刻开始的时间的"绝对值",其数值来自计算机中一个CMOS晶片,常常称为"实时时钟"。这块CMOS 晶片是由电池供电的,所以即使机器断了电也还能维持正确的时间。通过get_cmos_time()从CMOS时钟晶片中把当时的实际时间读入xtime,时间的精度为秒。而时钟中断,则是由另一个晶片产生的.
         另一个全局量是个无符号整数,叫jiffies,记录着从开机以来时钟中断的次数每个jiffy的长度就是时钟中断的周期,有时候也称为一个tick,取决于系统中的一个常数HZ,这个常数定义于param.h中。以后读者会看到, 在内核中jiffies远远比xtime重要,是个经常要用到的变量
   系统中有很多因素会影响到时钟中断在时间上的精确度,所以要通过好多手段来加以校正。在比较新的i386CPU中(主要是pentium及以后),还设置了一个特殊的64位寄存器,称为"时间印记计数器"TSC(timestamp counter)。这个计数器对驱动CPU的时钟脉冲进行计数,例如要是CPU的时钟脉冲频率为500MHZ,则TSC的计时精度为2nS。由于TSC是个64位的计数器,其计数要经过连续运行上千年才会溢出。显然,可以利用的TSC的读数来改善时钟中断的精度。不过,我们在这里并不关心时间的精度,所以跳过了代码中有关的部分,而只关注带有本质性的部分
   重点关注下do_timer(),
    Linux内核源代码情景分析---第三章 <wbr>中断、异常和系统调用
 
  为什么这里不用简单的jiffies++,而要使用这么一种奇怪的方式呢?这是 因为代码的作者要使将递增jiffies的操作在一条指令中实现,成为一个"原子"的操作。gcc将这条语句翻译成一条对内存单元的INC指令。而若采用jiffies++,则有可能会被编译成先将jiffies的内容MOV至寄存器EAX,然后递增,再MOV回去。二者所耗费的CPU时钟周期几乎是相同的,但前者保证了操作的"原子"性。

发布了3 篇原创文章 · 获赞 1 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/W11098/article/details/47334259