linux内核中断、异常、系统调用的分析以及实践

来源:互联网 发布:mysql删除全部表 编辑:程序博客网 时间:2024/05/16 14:58
中断、异常、系统调用的分析
  
报告内容
中断是由间隔定时器和和I/O设备产生的。
异常则是由程序的错误产生,或者由内核必须处理的异常条件产生。第一种情况下,内核通过发送一个信号来处理异常;第二种情况下,内核执行恢复异常需要的所有步骤,或对内核服务的一个请求。
中断和异常改变处理器执行的指令顺序,通常与CPU芯片内部或外部硬件电路产生的电信号相对应。它们提供了一种特殊的方式,使处理器转而去执行正常控制流之外的代码。
中断是异步的,由硬件随机产生,在程序执行的任何时候可能出现。异常是同步的,在(特殊的或出错的)指令执行时由CPU控制单元产生。
每个中断和异常由0~255之间的一个数(8位)来标识,Intel称其为中断向量(vector)。非屏蔽中断的向量和异常的向量是固定的,可屏蔽中断的向量可以通过对中断控制器的编程来改变。
 
Linux对中断描述符进行了如下分类:
1.中断门
用户态的进程不能访问的一个中断门(特权级为0),所有的中断都通过中断门激活,并全部在内核态。由set_intr_gate()函数负责在IDT表项中插入一个中断门。
2.系统门
用户态的进程可以访问的一个陷阱门(特权级为3),通过系统门来激活4个linux异常处理程序,它们的向量是3,4,5和128。因此,在用户态下可以发布int3,into,bound和int $0x80四条汇编指令。由set_system_gate ()函数负责在IDT表项中插入一个系统门。
3.陷阱门
用户态的进程不能访问的一个陷阱门(特权级为0),大部分linux异常处理程序通过陷阱门激活。由set_trap_gate ()函数负责在IDT表项中插入一个陷阱门。
三个门均调用了_set_gate宏,代码如下:
#define _set_gate(gate_addr,type,dpl,addr) /
do { /
 int __d0, __d1; /
 __asm__ __volatile__ ("movw %%dx,%%ax/n/t" /
       "movw %4,%%dx/n/t" /          //将转化后的dpl值存进dx寄存器
       "movl %%eax,%0/n/t" /          //eax寄存器的值存进gate_addr,即idt_table+n
       "movl %%edx,%1" /
       :"=m" (*((long *) (gate_addr))), /
        "=m" (*(1+(long *) (gate_addr))), "=&a" (__d0), "=&d" (__d1) /
       :"i" ((short) (0x8000+(dpl<<13)+(type<<8))), /
        "3" ((char *) (addr)),"2" (__KERNEL_CS << 16)); /
} while (0)
 
中断:
每个能够发出中断请求的硬件设备控制器都有一条称为IRQ(Interrupt ReQuest)的输出线。所有的IRQ线都与一个中断控制器的输入引脚相连,中断控制器与CPU的INTR引脚相连。
下面将分析一下内核处理中断的步骤和代码。
内核启用中断以前,必须把IDT表的初始地址装到idtr寄存器,并初始化表中的每一项,这个动作是在初始化系统时,由arch/i386/kernel/head.S中的Startup_32()函数完成。
setup_idt:
       lea ignore_int,%edx
       movl $(__KERNEL_CS << 16),%eax
       movw %dx,%ax            /* selector = 0x0010 = cs */
       movw $0x8E00,%dx     /* interrupt gate - dpl=0, present */
 
       lea SYMBOL_NAME(idt_table),%edi
       mov $256,%ecx
rp_sidt:
       movl %eax,(%edi)
       movl %edx,4(%edi)
       addl $8,%edi
       dec %ecx
       jne rp_sidt
       ret
这里setup_idt()汇编函数用一个空的处理程序ignore_int()填充了所有256个idt_table表项。
紧接着内核将会在IDT中进行第二遍初始化,在init/main.c中的start_kernel()函数中调用trap_init()和init_IRQ(),来分别的用有意义的陷阱和中断处理程序替换这个空的处理程序。
void __init init_IRQ(void)
{
……
       /*
        * NR_IRQS=224
        * FIRST_EXTERNAL_VECTOR定义为0x20,十进制32
        * SYSCALL_VECTOR定义为0x80,中断号为128的中断向量
        */
       for (i = 0; i < NR_IRQS; i++) {
              int vector = FIRST_EXTERNAL_VECTOR + i;    //中断向量为32+i
              if (vector != SYSCALL_VECTOR)
                     set_intr_gate(vector, interrupt[i]);        //为除了0x80外的向量设置中断门
/*
*这段代码在interrupt数组中找到用
*于建立中断门的中断处理程序地址。
     */
       }
此时系统就具备了处理中断的能力。
不管引起中断的设备是什么,所有的I/O中断处理程序都执行四个相同的基本操作:
1,在内核态堆栈保存IRQ的值和寄存器的内容
2,为正在给IRQ线服务的PIC发送一个应答,这将允许PIC进一步发出中断
3,执行共享这个IRQ的所有设备的中断服务例程(ISR)
4,跳到ret_from_intr()的地址
中断处理程序入口点由BUILD_IRQ宏产生,其展开为以下代码:
IRQn_interrupt:
pushl $n-256
jmp common_interrupt
common_interrupt行则由BUILD_COMMON_IRQ宏展开:
common_interrup:
    SAVE_ALL
    call do_IRQ
jmp $ret_from_intr
SAVE_ALL负责在栈中保存中断处理程序可能会使用的所有的CPU寄存器,而eflags、cs、eip、ss、esp寄存器由控制单元自动保存。然后把内核数据段选择符装到ds和es寄存器。
do_IRQ()函数执行与一个中断相关的所有中断服务例程。
asmlinkage unsigned int do_IRQ(struct pt_regs regs)
{     /*
*读取从栈顶传递过来的IRQ向量,
*$n-255转换成n,取得对应的中断向量
     */
       int irq = regs.orig_eax & 0xff;
       int cpu = smp_processor_id();
       irq_desc_t *desc = irq_desc + irq;
       struct irqaction * action;
       unsigned int status;
 
       kstat.irqs[cpu][irq]++;
       spin_lock(&desc->lock);       //自旋锁,在单处理系统中没有作用
/*
        *应答PIC的中断,并禁用这条IRQ线。(为串行处理同类型中断)
        */
       desc->handler->ack(irq);      //应答
       status = desc->status & ~(IRQ_REPLAY | IRQ_WAITING);  //设置IRQ线状态
       status |= IRQ_PENDING; /* we _want_ to handle it */
 
       action = NULL;           //在真正开始工作之前,检查相关标志位
       if (!(status & (IRQ_DISABLED | IRQ_INPROGRESS))) {
              action = desc->action;
              status &= ~IRQ_PENDING; /* we commit to handling */
              status |= IRQ_INPROGRESS; /* we are handling it */
       }
       desc->status = status;
 
       if (!action)          //actionnull则跳出
              goto out;
 
       for (;;) {
              spin_unlock(&desc->lock);             //释放中断自旋锁
              handle_IRQ_event(irq, &regs, action);    //在循环中执行中断服务例程
              spin_lock(&desc->lock);               //执行完一次则上锁
             
              if (!(desc->status & IRQ_PENDING))
                     break;
//PENDING标志位清0,则循环结束,中断不进一步传递给另一个CPU
              desc->status &= ~IRQ_PENDING;
       }
       desc->status &= ~IRQ_INPROGRESS;      //清除IRQ_INPROGERSS标志位
out:
       desc->handler->end(irq);
/*
*调用主IRQ描述符的end方法,单处理系统上相应的
*end_8259A_irq()函数重新激活IRQ线,允许处理同类型中断
*/
       spin_unlock(&desc->lock);               //do_IRQ释放自旋锁
 
       if (softirq_pending(cpu))              //检查下半部分是否执行
              do_softirq();
       return 1;
}
handle_IRQ_evnet()函数依次调用这些设备例程:
int handle_IRQ_event(unsigned int irq, struct pt_regs * regs, struct irqaction * action)
{
……
       irq_enter(cpu, irq);
/*调用irq_enter方法增加正执行CPUirq_stat元素的__local_irq_count字段*/
……
       if (!(action->flags & SA_INTERRUPT))
              __sti();     //如果SA_INTERRUPT标志被清0,用sti指令打开本地中断
 
       do {
              status |= action->flags;
              action->handler(irq, action->dev_id, regs);
              action = action->next;
       } while (action);    //循环执行每个中断的中断服务例程
       if (status & SA_SAMPLE_RANDOM)
              add_interrupt_randomness(irq);
       __cli();          //cli指令打开本地中断
       irq_exit(cpu, irq);
/*调用irq_exit方法减少正执行CPUirq_stat元素的__local_irq_count字段*/
       return status;
}
 
异常:
异常可以是由程序错误产生,或者由内核必须处理的异常条件产生的。linux下发生异常,内核会自动产生一个异常中断。在这异常中断处理程序中会判断异常来自用户程序或者内核,如果是发生在用户程序,内核通过发送一个信号来处理异常,,再根据异常信号的回调函数通知用户程序发生异常。如果发生在内核里面,那么内核执行恢复异常需要的所有步骤(例如缺页),或对内核服务的一个请求,即会搜索内核模块的异常结构表,找到相应的处理调用地址,修改异常中断的返回地址为异常处理的地址,中断返回的时候程序就跳到异常处理程序处理执行了。
异常处理有一个标准的结构,由三部分组成:
1.在内核态堆栈中保存大多数寄存器的内容
2.调用C语言的函数
3.通过ret_from_exception()从异常处理程序退出
 
为了利用异常,必须对IDT进行初始化,使得每个被确认的异常都有一个异常处理程序。Trap_init()函数是将一些最终值插入到IDT表中的非屏蔽中断及异常表项中。这是由set_trap_gate和set_system_gate宏实现该IDT表项的初始化。
void __init trap_init(void)
{
……
       set_trap_gate(0,&divide_error);      // set_trap_gate()函数设置陷阱门
       set_trap_gate(1,&debug);
       set_intr_gate(2,&nmi);
       set_system_gate(3,&int3);           // set_system_gate()函数设置系统门
       set_system_gate(4,&overflow);
       set_system_gate(5,&bounds);
       set_trap_gate(6,&invalid_op);
       set_trap_gate(7,&device_not_available);
       set_trap_gate(8,&double_fault);
       set_trap_gate(9,&coprocessor_segment_overrun);
       set_trap_gate(10,&invalid_TSS);
       set_trap_gate(11,&segment_not_present);
       set_trap_gate(12,&stack_segment);
       set_trap_gate(13,&general_protection);
       set_intr_gate(14,&page_fault);
       set_trap_gate(15,&spurious_interrupt_bug);
       set_trap_gate(16,&coprocessor_error);
       set_trap_gate(17,&alignment_check);
       set_trap_gate(18,&machine_check);
       set_trap_gate(19,&simd_coprocessor_error);
       set_system_gate(SYSCALL_VECTOR,&system_call);
    // SYSCALL_VECTOR=0x80,即十进制128
……
}
在/arch/i386/kernel/entry.S中我们可以看到每个为异常处理程序定义入口,如page_fault的入口如下:
ENTRY(page_fault)
       pushl $ SYMBOL_NAME(do_page_fault)    //执行的实体――do_page_fault()函数
       jmp error_code               //跳到对所有异常处理程序都相同的异常处理断
error_code代码段先把高级C函数可能用到的寄存器保存在栈中,再把栈中位于(esp+36)处的硬件出错码复制到eax中,在栈中同一位置存入值-1,该值用来隔离0x80异常和其他异常。把保存在栈中esp+32位置的do_XXX()高级C函数的地址装入edi寄存器,然后在这个位置写入es值。把内核数据段选择符装进ds和es寄存器,然后把ebx寄存器的值设置为当前进程描述符的地址。接着把要传给高级C函数do_XXX()的参数保存在栈中,也就是异常硬件出错码和用户态寄存器的内容在栈中的位置地址。调用地址在edi中的高级C函数,返回地址即C函数结束后将执行的指令地址,包括所保存的用户态寄存器栈地址和硬件出错码。
ret_from_exception代码段:
ret_from_exception:
       movl EFLAGS(%esp),%eax         #保存eflagCSEFLAGS=0x30
       movb CS(%esp),%al
       testl $(VM_MASK | 3),%eax     # return to VM86 mode or non-supervisor?
       jne ret_from_sys_call
       jmp restore_all                #恢复上下文,返回(用户态)
 
系统调用:
操作系统为用户态进程与硬件设备进行交互提供了一组接口——也就是系统调用。提供系统调用有很多的优点,譬如:把用户从底层的硬件编程中解放出来;极大的提高了系统的安全性;使用户程序具有可移植性,等等。
当用户态进程调用一个系统调用时,CPU切换到内核态并开始执行一个内核函数。在Linux中是通过执行int $0x80这条汇编语言来执行系统调用的,这条汇编指令产生向量为128的编程异常。
内核实现了很多不同的系统调用,进程必须传递一个名为系统调用号的参数来指明需要调用的系统调用,eax寄存器就用作这个目的。
系统调用处理程序也其他异常处理程序的结构类似,执行下列操作:
1.在进程的内核态堆栈中保存大多数寄存器的内容(即保存恢复进程到用户态执行所需要的上下文);
2.调用名为系统调用服务例程的相应的C函数来处理系统调用;
3.通过ret_from_sys_call()从系统调用返回。
xxx()系统调用对应的服务例程名字通常是sys_xxx(),为了把系统调用号与相应的服务例程关联起来,内核利用了一个系统调用分派表,该表存放在sys_call_tale数组(见entry.S文件)中,有256表项,第n个表项对应了系统调用号为n的服务例程的入口地址的指针。前面提到,内核初始化期间调用trap_init()函数建立IDT表中向量128对应的表项。
system_call()函数实现了系统调用处理程序。它首先把系统调用号和这个异常处理程序可以用到的所有寄存器保存到栈中(除了eflags、cs、eip、ss、esp由控制单元自动保存)。
ENTRY(system_call)
       pushl %eax
       SAVE_ALL           #SAVE_ALL宏将众多寄存器压栈
       GET_CURRENT(%ebx)
       testb $0x02,tsk_ptrace(%ebx)               #检查是否有调试程序正在跟踪
#执行程序对系统调用的调用
       jne tracesys
       cmpl $(NR_syscalls),%eax #参数检查,判断传递过来的系统调用号是否大于或等于256
       jae badsys
       call *SYMBOL_NAME(sys_call_table)(,%eax,4)      #真正开始调用对应的服务例程
在内核打算满足用户的请求之前,必须仔细的检查所有的系统调用参数。只要一个参数指定的是地址,那么内核必须检查它是否在这个进程的地址空间之内,Linux自2.2版本后仅仅验证这个线性地址小于PAGE_OFFSET,该方法比较高效,至于其余导致出错的情况将会在后续过程中被捕获到。
内核可以访问到所有的内存,所以要防止用户将一个内核地址作为参数传递给内核,这将导致它借用内核代码来读写任意内存。检查的方法是,判断最高地址:addr+size-1,
是否超出3G边界,或者是否超出当前进程的地址边界。对于用户进程最高地址一定不大于3G;而对于内核线程:可以使用整个4G。
verify_area()函数可以完成验证工作:
static inline int verify_area(int type, const void * addr, unsigned long size)
{
       return access_ok(type,addr,size) ? 0 : -EFAULT; //调用access_ok
}
#define access_ok(type,addr,size) ( (__range_ok(addr,size) == 0) && / //__rang_ok宏检查边界
                      ((type) == VERIFY_READ || boot_cpu_data.wp_works_ok || /
                      segment_eq(get_fs(),KERNEL_DS) || /    //检查是否为内核数据段
                      __verify_write((void *)(addr),(size))))      //验证写内存权限
    至于系统调用的返回,则可参考中断的返回。
实验内容
1.采用某种方法截获一个中断,例如键盘中断
2.采用某种方法制造缺页异常
3.自己编写一个系统调用
 
实验程序
1.键盘中断
如果我们想截获键盘中断,我们就需要在中断处理程序中添加我们自己的中断服务例程。在此,我们为了方便,仅在arch/i386/kernel/irq.c中的do_IRQ函数中修改。由于Linux系统中,键盘的IRQ号为1,则我们在do_IRQ中添加如下条件语句,便可处理我们自己定义的键盘中断服务。如下图行123-124所示:

截获键盘中断号,并产生特定输出

 
 
当然,我们可以在键盘中断的服务例程中添加我们的代码,键盘的中断服务例程是在内核初始化过程当中,由键盘驱动程序注册到内核中的,其实际处理函数在linux/drivers/char/keyboard.c中定义。
 
产生内核镜像文件:
装载内核镜像:
重新启动,在Grub中选择新加载的内核镜像:
在键盘上敲击用户名:root
我们可以看到,当我们按下一个键时,会出现两个“~”字符,但是当我们按住一个键不动时,则会按照我们预想的出现一个“~”字符。
   
2.缺页异常
缺页异常作为linux系统中默认的异常情况,在系统初始化的时候于entry.S中定义了其异常处理例程,即do_page_fault()函数,该函数在arch/i386/mm/fault.c中定义了。所以我们要制造一个缺页异常,并显示我们所定义的特定的提示信息,对该文件中的do_page_fault()进行分析即可。
该函数首先对内核进程、中断处理、临界区以及请求的地址不在线性区的特殊情况进行了处理。在good_area中才转入正常的缺页异常处理,在此区域的起始地方加入我们的提示信息:
  

修改处:添加提示信息
 

 

函数handle_mm_fault()进行页框分配,当返回VM_FAULT_MINOR和VM_FAULT_MAJOR时,表示分配成功,在这里,我们也显示提示信息。

提示信息
提示信息

在内核启动过程中,由于要从磁盘读取大量的文件,必然产生缺页中断,以下为系统启动时所产生的提示信息:

读取配置文件,产生缺页中断

再尝试当执行ls命令时,由于要读取磁盘的文件信息,产生的缺页中断,显示如下:
 
3.系统调用
要在Linux内核中新增加一个系统调用,首先,我们必须在内核源代码中添加实现该调用功能的函数代码。在此,我们为了编译方便,没有选则新建一个文件实现该函数,而是在kernel/sys.c文件中的最后添加函数代码:
该函数输入一个整形变量,返回它的平方值。
然后,我们还必须在源代码中添加该系统调用的系统调用号,修改文件:include/asm-i386/unistd.h

将原系统调用总数+1
新增的系统调用号

 
 
接着,我们还需要添加内核初始化阶段所需的新增的系统调用函数的入口地址,在linux 2.4.x内核中,该入口地址在arch/i386/kernel/entry.S中声明,由于我们实验用的是linux 2.6.18的内核,系统调用的地址表被单独地分离到arch/i386/kernel/syscall_table.S中了,因此,我们修改该文件如下:

新增系统调用函数的地址

 
最后,我们编译内核,并重新加载新编译好的内核,编写用户态的函数:、
#include <stdio.h>
#include <linux/unistd.h>
 
_syscall1(int,forever,int,a)
 
int main()
{
       int a,b;
       printf(“This is my syscall!/n”);
       printf(“Please enter an integer:”);
       scanf(“%d”,&a);
       b=forever(a);          //使用自己添加的系统调用
       printf(“%d*%d=%d/n”,a,a,b);
       return 0; 
}
进行编译、链接,执行结果如图:
其中,我们包含了头文件linux/unistd.h,Linux通过内核与用户共享相同的头文件来达到对系统调用的引用。在该文件中定义了对_syscall1(int,forever,int,a)的宏展开,所以,程序的第四行相当于定义了一个内核系统调用给用户呈现的一个接口函数。由该函数,我们在程序的第十二行进行了系统调用,调用了我们在kernel/sys.c定义的系统调用。
http://blog.csdn.net/icyfire0105/article/details/1898523
0 0