中断
关于中断的一些基础定义在计组里面已经学习过了,这里直接记一些跟操作系统有关的知识
中断的分类
- 外部中断
来自CPU外部的中断,必须是某个硬件,所以又称硬件中断。
为了让CPU能跟踪到每一个中断,最直接的方法是每个要发生中断的硬件接一根线到CPU上,但是理论上外设的数量是无限的,且这样CPU的体积也会非常庞大,所以跟总线类似,CPU只有两根线来接收中断一根叫NMI(Non Maskable Interrupt)用于接收“灾难性”中断,一根叫INTR(INTeRrupt)用于接收普通中断。
- 可屏蔽中断
这类中断从INTR传入,如硬盘、网卡等发出来的中断,表示CPU可以不理会,因为它们不会让CPU宕机,可以通过设置EFLAGS的IF位来将所有外设的中断给屏蔽掉。另外这些中断是由外面的一个设备代理的,也可以操控这个设备提前对某些中断进行屏蔽。
- 不可屏蔽中断
从NMI进入CPU,这类中断表示系统发生了致命错误,一般见于以下三种原因:
1. 内存读写错误
2. 电源掉电
3. 总线奇偶校验错误
- 内部中断
- 软中断
由软件主动发起的的中断,来自软件所以叫软中断,下面是引起中断的指令。
* 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位决定了这个描述符的类型,剩下的一些信息用来限定进入这个门的条件和描述门所通往的地方。
几种描述符的格式及布局如下:
简单介绍一下各个门:
* 任务门:任务门和任务状态段(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内执行中断的过程:
- 处理器根据中断向量号定位中断门描述符
- 处理器进行特权级检查
由于中断是通过中断向量号通知处理器的,而中断向量号只是一个整数,没有RPL,所以不涉及对RPL的检查,中断门的特权检查同调用门类似,CPL必须位于门描述的DPL和目标代码段的DPL之间,这是为了防止用户去调用一些只为内核服务的例程。
如果是int n,int3,into这类由用户程序引发的中断,在数值上CPL<=门描述符DPL,CPL>目标代码段DPL,也就是说除了用户返回指令从高特权级返回,特权级转移只能发生于从低到高的情况
如果是外设引起的中断,直接检查CPL和目标代码段的DPL
- 执行中断处理程序,将门描述符中的选择子加载到CS中,把里面的偏移量加载到EIP中
以上过程可概括成如下图片:
总结一下执行的过程:首先是用户或硬件产生了一个中断,这个中断在被外部的中断控制器处理后会向CPU发出该中断对应的中断向量号,接着CPU会进行一个特权级的检查,通过之后CPU会从门描述符里得到对应的CS和IP,并将它们加载到对应寄存器
中断发生时的压栈
发生中断后为了能返回,必须要存储一些必要的信息,这些都是CPU自动进行的。
如果在转移过程中发生了特权级的变换,那压栈的顺序如下:
其中的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