中断

  1. 中断
    1. 中断的分类
    2. 中断描述符表
    3. 中断过程及保护
    4. 中断发生时的压栈
    5. 可编程中断控制器 8259A

中断

关于中断的一些基础定义在计组里面已经学习过了,这里直接记一些跟操作系统有关的知识

中断的分类

  1. 外部中断

来自CPU外部的中断,必须是某个硬件,所以又称硬件中断。
为了让CPU能跟踪到每一个中断,最直接的方法是每个要发生中断的硬件接一根线到CPU上,但是理论上外设的数量是无限的,且这样CPU的体积也会非常庞大,所以跟总线类似,CPU只有两根线来接收中断一根叫NMI(Non Maskable Interrupt)用于接收“灾难性”中断,一根叫INTR(INTeRrupt)用于接收普通中断。

  • 可屏蔽中断

这类中断从INTR传入,如硬盘、网卡等发出来的中断,表示CPU可以不理会,因为它们不会让CPU宕机,可以通过设置EFLAGS的IF位来将所有外设的中断给屏蔽掉。另外这些中断是由外面的一个设备代理的,也可以操控这个设备提前对某些中断进行屏蔽。

  • 不可屏蔽中断

从NMI进入CPU,这类中断表示系统发生了致命错误,一般见于以下三种原因:
1. 内存读写错误
2. 电源掉电
3. 总线奇偶校验错误

  1. 内部中断
  • 软中断

由软件主动发起的的中断,来自软件所以叫软中断,下面是引起中断的指令。
* int imm8 8位立即数表示256种中断
* int3 调试断点指令,触发3号指令
* into 中断溢出指令,触发4号指令,不过能否触发要看EFLAGS的OF是否为1
* bound imm16/imm32 检查数组越界指令,触发5号指令
* ud2 未定义指令,触发6号指令

  • 异常

是另一种CPU内部中断,由指令执行期间CPU内部错误产生所引起,由于是在运行时产生的所以不受IF位影响,无法向用户隐瞒

并不是所有的异常都很致命,按照轻重分为三种:
1. Fault,故障。属于最轻的一种异常,发生此类异常时CPU将机器状态恢复到异常之前,调用中断处理程序完了依然返回导致fault的指令,比如缺页故障,等申请好物理页后又回来执行
2. Trap,陷阱。表示软件掉进了CPU设下的陷阱导致停了下来,比如int3,为了能在中断处理完之后程序能继续运行,CPU会将中断的返回地址指向导致异常的下一条指令
3. Abort,终止。一旦出现程序将会从进程表中被去除掉。

中断描述符表

IDT(Interrupt Descriptor Table)是保护模式下用于存储中断处理程序入口的表, 在进入保护模式前这个叫中断向量表(Interrupt Vector Table)。

IDT中不仅仅只有中断描述符,还可以有任务门和陷阱门描述符,这些存放于IDT中的描述符统称为门。段描述的是一片区域,门描述的是一段代码

所有的描述符都是64位的,在高32位的TYPE字段和S位决定了这个描述符的类型,剩下的一些信息用来限定进入这个门的条件和描述门所通往的地方。

几种描述符的格式及布局如下:
任务门描述符.jpg

中断门,陷阱门,调用门描述符.jpg

简单介绍一下各个门:
* 任务门:任务门和任务状态段(TSS)是Intel在硬件级别上提供的任务切换机制,在任务门中记录的是TSS的选择子。任务门可以存在于GDT,LDT,IDT中。
* 中断门:包含中断处理程序所在段的选择子和段内偏移地址,此方式进入中断后IF位自动置0,避免中断嵌套中断门只允许存在于IDT中
* 陷阱门:与中断门非常相似,只不过IF不会自动置0,也只存在于IDT中
* 调用门:提供给用户进程进入特权级0的方式,其DPL为3。只能用jmp和call调用。可以安装在GDT和LDT中

在实模式下的IVT位置固定,在0~0x3ff是它的空间,共1024个字节,每个中断向量用4字节描述,共能存储256个向量,这些就是与IDT的不同,IDT位置不固定,表项占8字节

由于位置不固定,所以CPU里有一个寄存器IDTR用以存放其位置,跟之前的GDTR类似。

中断过程及保护

完整的中断过程分为CPU外和CPU内两部分
CPU外:外部设备的中断由中断代理设备接收处理,然后将处理后的信号给CPU
CPU内:CPU执行该中断向量号所对应的中断处理程序

CPU内执行中断的过程:

  1. 处理器根据中断向量号定位中断门描述符
  2. 处理器进行特权级检查

由于中断是通过中断向量号通知处理器的,而中断向量号只是一个整数,没有RPL,所以不涉及对RPL的检查,中断门的特权检查同调用门类似,CPL必须位于门描述的DPL和目标代码段的DPL之间,这是为了防止用户去调用一些只为内核服务的例程。

如果是int n,int3,into这类由用户程序引发的中断,在数值上CPL<=门描述符DPL,CPL>目标代码段DPL,也就是说除了用户返回指令从高特权级返回,特权级转移只能发生于从低到高的情况

如果是外设引起的中断,直接检查CPL和目标代码段的DPL

  1. 执行中断处理程序,将门描述符中的选择子加载到CS中,把里面的偏移量加载到EIP中
    以上过程可概括成如下图片:
    中断处理过程.jpg

总结一下执行的过程:首先是用户或硬件产生了一个中断,这个中断在被外部的中断控制器处理后会向CPU发出该中断对应的中断向量号,接着CPU会进行一个特权级的检查,通过之后CPU会从门描述符里得到对应的CS和IP,并将它们加载到对应寄存器

中断发生时的压栈

发生中断后为了能返回,必须要存储一些必要的信息,这些都是CPU自动进行的。
如果在转移过程中发生了特权级的变换,那压栈的顺序如下:
中断的压栈.jpg
其中的ERROR_CODE视某些中断类型确定是否存在,如果没有发生特权级的变化那就没有前面的SS_old与ESP_old

可编程中断控制器 8259A

8529A内部有两组寄存器,一组用来初始化(Initial Command Words,ICW)共四个,另一组寄存器是操作寄存器(Operation Command Words,OCW),共3个。

对8259A的编程也分为初始化和控制两个部分。

  • 首先是对其的初始化,诸如是否级联,确定起始中断向量号,设置结束模式等。编程的内容就是往这四个寄存器写入一段内容来设定其工作状态,由于后面的某个设置可能会依赖于前面的设置,所以这里的编程往往有一定的先后顺序
  • 另一部分是OCW来控制8259A,像屏蔽中断和中断结束都是往OCW写入内容实现的。OCW的发送顺序不固定。

具体的一些关于ICW与OCW的内容这里不详述,只说一下用到的:

  • 无论8259A是否级联,ICW1与ICW2是必要的且要顺序写入
  • 只有当ICW1中的SNGL为0时表示级联
  • 只有当ICW1中的ICW4为1时才表示需要ICW4,x86系统下必须为1

下面是一些代码
用纯汇编的风格进行一个中断处理:

;段代码叫kernel.s,预定义一些由中断处理程序
[bits 32]
;宏定义,用于下面定义中断处理程序
%define ERROR_CODE nop
%define ZERO push 0

extern put_str
section .data
intr_str db "interrupt occur!",0xa,0
global intr_entry_table
intr_entry_table:
;汇编的宏定义,表示宏VECTOR需要两个参数,这个VECTOR用来生成中断处理程序
%macro VECTOR 2
section .text
intr%1entry:

;当CPU进入到这里的时候已经完成了一部分的压栈工作,也就是前面的自动压栈过程,由于最后一步
;是ERROR_CODE的压栈,有些中断不会压入,为了统一栈的布局,不压ERROR_CODE的中断在这里多执行一步PUSH 0的操作,自动压入的则是一句nop指令,或许有人会疑问如果为了栈的统一布局,
;发生特权级变化和不发生变化压出来的栈是不一样的,这要如何统一,其实这个工作是在iret时
;cpu去做了,如果发现返回地址发生了特权级的变化它就知道该更换栈了,所以我们只需要把返回
;地址之后的布局统一就行了

;参考前面进入中断的过程,有些中断会自动压入一个ERROR_CODE有些不会,这里统一都压入,方便返回的时候都弹出,如果是自动压入的那%2就应该是nop
;在此之前需要手动去构建中断门描述符,使其CS:IP指向intr%1entry,中断发生后CPU会根据IDT找到这里,下面的内容对CPU来说就是中断处理程序
    %2
    ;调用 put_str之前的压栈
    push intr_str
    call put_str
    ;调用者负责清除压栈数据
    add esp,4
    ;向主片和从片发送中断结束信号EOI,即0x20
    mov al,0x20
    out 0xa0,al
    out 0x20,al
    add esp,4;弹出ERROR_CODE
    iret
section .data
    dd intr%1entry
%endmacro
VECTOR 0x00,ZERO
VECTOR 0x01,ZERO
;...
VECTOR 0x1e,ERROR_CODE
;可以看到整段代码有section .text,section .data,intr_entry_table展开后每一个VECTOR都有两个section,书上说这里编译器会把属性相同的section合成一个段,经实际编程也发现确实是这样,也就意味着每个VECTOR的.data段被合并在了一起,这样通过intr_entry_table[i]就能索引到目标入口,这里每个中断程序做的事情都一样打印一个字符串

在上面的代码中,中断发生后的处理程序核心是call put_str,但是要编写的中断其实有很多,后面可能还要进行增加,而且处理的过程可能也比较复杂,所以用C语言编写中断处理的函数会比较方便,用一个函数数组来装这些函数,进到中断入口后通过中断向量号去调用这个数组里的函数

另外还要注意的是当某个进程正在进行,如果发生了中断,跳到某个对应的int_entry这里时只发生了最基本的一些压栈,即eflagh和返回地址(或者ss和esp也压了),如果直接调用某段程序会破坏当前进程的环境,CPU自动进行的压栈只保证自己将来能回到这里,但我们还想回来之后能接着之前的状态运行,所以需要手动将”当前环境”保护起来,对一个进程来说”当前环境”就是一套寄存器的取值,所以我们把寄存器内容压栈保护就行了,下面是修改的代码:

extern idt_table
%macro VECTOR 2
section .text
int%1entry:
    %2
    push ds
    push es
    push fs
    push gs
    pushad
    push %1
    call [idt_table + %1*4]
    mov al,0x20
    out 0xa0,al
    out 0x20,al
    jmp intr_exit
section .data
    dd int%1entry
%endmacro
section .text
global intr_exit
intr_exit:
    add esp,4
    popad
    pop gs
    pop fs
    pop es
    pop ds
    add esp,4
    iretd

还有一点不同的是中断的退出被当成一个函数了,这是为了后面实现用户进程时使用,现在不理解也没关系
上面就是kernel.s的主要内容,总结一下就是汇编定义了一系列的中断入口,而这些入口做一些简单的保护工作后通过call的形式去执行真正的中断程序

下面的interrupt.c进行一些跟中断有关的工作,这里我贴的是实现系统调用之后的interrupt.c,跟系统调用有关的内容可以不看,不影响理解,另外中断这里是已经开了三个的情况:键盘,时钟,和硬盘

#include "interrupt.h"
#include "stdint.h"
#include "global.h"
#include "io.h"
#include "print.h"

#define PIC_M_CTRL 0x20           // 这里用的可编程中断控制器是8259A,主片的控制端口是0x20
#define PIC_M_DATA 0x21           // 主片的数据端口是0x21
#define PIC_S_CTRL 0xa0           // 从片的控制端口是0xa0
#define PIC_S_DATA 0xa1           // 从片的数据端口是0xa1

#define IDT_DESC_CNT 0x81     // 目前总共支持的中断数

#define EFLAGS_IF   0x00000200       // eflags寄存器中的if位为1
#define GET_EFLAGS(EFLAG_VAR) asm volatile("pushfl; popl %0" : "=g" (EFLAG_VAR))


/*中断门描述符结构体*/
struct gate_desc {
   uint16_t    func_offset_low_word;
   uint16_t    selector;
   uint8_t     dcount;   //此项为双字计数字段,是门描述符中的第4字节。此项固定值,不用考虑
   uint8_t     attribute;
   uint16_t    func_offset_high_word;
};

static struct gate_desc idt[IDT_DESC_CNT];   // idt是中断描述符表,本质上就是个中断门描述符数组,以后IDTR存的地址就是idt的地址

char* intr_name[IDT_DESC_CNT];             // 用于保存异常的名字


/********    定义中断处理程序数组    ********
 * 在kernel.s中定义的intrXXentry只是中断处理程序的入口,
 * 最终调用的是ide_table中的处理程序*/
 //下面这个数组就是用C语言编写的真正来处理中断的函数数组,其实也就是这些函数的地址,用void*存就行

typedef void* intr_handler;
intr_handler idt_table[IDT_DESC_CNT];

/********************************************/
extern intr_handler intr_entry_table[IDT_DESC_CNT];        // 声明引用定义在kernel.s中的中断处理函数入口数组
//系统调用注册函数
extern uint32_t syscall_handler(void);


/* 初始化可编程中断控制器8259A */
static void pic_init(void) {

   /* 初始化主片 */
   outb (PIC_M_CTRL, 0x11);   // ICW1: 边沿触发,级联8259, 需要ICW4.
   outb (PIC_M_DATA, 0x20);   // ICW2: 起始中断向量号为0x20,也就是IR[0-7] 为 0x20 ~ 0x27.
   outb (PIC_M_DATA, 0x04);   // ICW3: IR2接从片. 
   outb (PIC_M_DATA, 0x01);   // ICW4: 8086模式, 正常EOI

   /* 初始化从片 */
   outb (PIC_S_CTRL, 0x11);    // ICW1: 边沿触发,级联8259, 需要ICW4.
   outb (PIC_S_DATA, 0x28);    // ICW2: 起始中断向量号为0x28,也就是IR[8-15] 为 0x28 ~ 0x2F.
   outb (PIC_S_DATA, 0x02);    // ICW3: 设置从片连接到主片的IR2引脚
   outb (PIC_S_DATA, 0x01);    // ICW4: 8086模式, 正常EOI
   
   //打开键盘和时钟以及硬盘中断
   outb (PIC_M_DATA, 0xf8);
   outb (PIC_S_DATA, 0xbf);
   put_str("   pic_init done\n");

}

/* 创建中断门描述符 */
static void make_idt_desc(struct gate_desc* p_gdesc, uint8_t attr, intr_handler function) { 
   p_gdesc->func_offset_low_word = (uint32_t)function & 0x0000FFFF;
   p_gdesc->selector = SELECTOR_K_CODE;
   p_gdesc->dcount = 0;
   p_gdesc->attribute = attr;
   p_gdesc->func_offset_high_word = ((uint32_t)function & 0xFFFF0000) >> 16;
}

/*初始化中断描述符表*/
static void idt_desc_init(void) {
   int i;
   //中断发生后我们是先让CPU跳转到对应的entry进行简单的保护工作,所以中断门所存储的偏移是intr_entry_table的内容而不是idt_table对应的内容
   for (i = 0; i < IDT_DESC_CNT; i++) {
      make_idt_desc(&idt[i], IDT_DESC_ATTR_DPL0, intr_entry_table[i]); 
   }

/* 单独处理系统调用,系统调用对应的中断门dpl为3,
 * 中断处理程序为单独的syscall_handler */
   make_idt_desc(&idt[IDT_DESC_CNT-1],IDT_DESC_ATTR_DPL3,syscall_handler);
   put_str("   idt_desc_init done\n");
}



/* 通用的中断处理函数,一般用在异常出现时的处理 */
static void general_intr_handler(uint8_t vec_nr) {
   if (vec_nr == 0x27 || vec_nr == 0x2f) {    // 0x2f是从片8259A上的最后一个irq引脚,保留
      return;        //IRQ7和IRQ15会产生伪中断(spurious interrupt),无须处理。
   }
   set_cursor(0);                                         //光标设置在0号位
   int cursor_pos = 0;
   while((cursor_pos++) < 320)                //一行80字 4行空格
       put_char(' ');            
   
   set_cursor(0);
   put_str("!!!!!!            excetion message begin            !!!!!!\n");
   set_cursor(88);                        //第二行第八个字开始打印
   put_str(intr_name[vec_nr]);                            //打印中断向量号
   //14号中断是Page Fault中断,发生这个中断CPU会自动把错误的地址放到cr2寄存器里面,所以可以打印看一下
   if(vec_nr == 14)
   {
       int page_fault_vaddr = 0;
       asm("movl %%cr2,%0" : "=r" (page_fault_vaddr));   //把虚拟地址 出错的放到了这个变量里面
    put_str("\npage fault addr is ");
    put_int(page_fault_vaddr);
    put_str("\n");
   }
   put_str("!!!!!!            excetion message end              !!!!!!\n");
   
   while(1);                                              //悬停
}

/* 完成一般中断处理函数注册及异常名称注册 */
static void exception_init(void) {                // 完成一般中断处理函数注册及异常名称注册
   int i;
   for (i = 0; i < IDT_DESC_CNT; i++) {

/* idt_table数组中的函数是在进入中断后根据中断向量号调用的,
 * 见kernel/kernel.S的call [idt_table + %1*4] */
      idt_table[i] = general_intr_handler;            // 默认为general_intr_handler。
                                // 以后会由register_handler来注册具体处理函数。
      intr_name[i] = "unknown";                    // 先统一赋值为unknown 
   }
   intr_name[0] = "#DE Divide Error";
   intr_name[1] = "#DB Debug Exception";
   intr_name[2] = "NMI Interrupt";
   intr_name[3] = "#BP Breakpoint Exception";
   intr_name[4] = "#OF Overflow Exception";
   intr_name[5] = "#BR BOUND Range Exceeded Exception";
   intr_name[6] = "#UD Invalid Opcode Exception";
   intr_name[7] = "#NM Device Not Available Exception";
   intr_name[8] = "#DF Double Fault Exception";
   intr_name[9] = "Coprocessor Segment Overrun";
   intr_name[10] = "#TS Invalid TSS Exception";
   intr_name[11] = "#NP Segment Not Present";
   intr_name[12] = "#SS Stack Fault Exception";
   intr_name[13] = "#GP General Protection Exception";
   intr_name[14] = "#PF Page-Fault Exception";
   // intr_name[15] 第15项是intel保留项,未使用
   intr_name[16] = "#MF x87 FPU Floating-Point Error";
   intr_name[17] = "#AC Alignment Check Exception";
   intr_name[18] = "#MC Machine-Check Exception";
   intr_name[19] = "#XF SIMD Floating-Point Exception";

}

/*完成有关中断的所有初始化工作*/
void idt_init() {
   put_str("idt_init start\n");
   idt_desc_init();       // 初始化中断描述符表
   exception_init();       // 异常名初始化并注册通常的中断处理函数
   pic_init();           // 初始化8259A

   /* 加载idt */
   //注意下面类型转化那里书上写错了,先转成32位数然后转成64位数然后再进行移位操作,虽然书上说是这个意思但是写出来的意思是先32位转换然后移位然后再转64位,这样出来的结果可能会错误
   uint64_t idt_operand = ((sizeof(idt) - 1) | ((uint64_t)(uint32_t)idt << 16));
   asm volatile("lidt %0" : : "m" (idt_operand));
   put_str("idt_init done\n");
}
//下面一些函数是提供给其它程序使用的,enum instr_status定义在interrupt.h中
void register_handler(uint8_t vec_no,intr_handler function)
{
    //把相关向量号的注册函数指针放进去就行
    idt_table[vec_no] = function;
}


enum intr_status intr_enable()
{
    if(intr_get_status() != INTR_ON)
    {
        asm volatile("sti");
        return INTR_OFF;
    }
    return INTR_ON;
}

enum intr_status intr_disable()
{
    if(intr_get_status() != INTR_OFF)
    {
       asm volatile("cli");
       return INTR_ON;
    }
    return INTR_OFF;
}

enum intr_status intr_set_status(enum intr_status status)
{
    return (status == INTR_ON) ? intr_enable() : intr_disable();
}

enum intr_status intr_get_status()
{
    uint32_t eflags = 0;
    GET_EFLAGS(eflags);
    return (eflags & EFLAGS_IF) ? INTR_ON : INTR_OFF; 
}

总结一下C语言版本的中断处理过程:
其中用到的3个数组:idt-中断描述符数组,这里面每个元素就是一个中断门,idtr寄存器保存它的物理地址,将来CPU会根据向量号来这里查询对应的中断门

idt_table:中断函数数组,这里放的都是C语言写好的一些函数地址,等CPU根据idt里的中断门进入到某个中断入口后,那个入口后面的代码很可能会调用这里的函数
intr_entry_table:中断入口地址数组,中断描述符里的中断门里将CPU引导到中断入口,这个数组就是填的入口的地址,因为入口地址只有在定义中断向量那里才知道,所以这个数组是extern的
下面是简单的流程表示:
中断发生–>CPU拿到向量号–>CPU从IDTR指向的idt找到对应的中断门–>中断门里有入口地址,CPU第一次跳转–>到了入口地址之后进行了一些保护操作然后call 其它函数处理,CPU第二次跳转–>函数返回–>中断返回


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 2128099421@qq.com

×

喜欢就点赞,疼爱就打赏