一、中断介绍
中断(Interrupt)是 CPU 响应外部或内部事件的机制,它会 暂停当前执行的程序,转而去处理更高优先级的任务,处理完成后再恢复原程序的执行。中断是操作系统实现 多任务、设备驱动、异常处理 的核心机制之一。
中断分为内中断和外中断,外中断也称为硬中断,即由外部设备触发的中断,如时钟中断,键盘中断。 外中断又分为可屏蔽中断和不可屏蔽中断,可屏蔽中断可以被 CPU 忽略(通过 CLI 指令关闭中断),必须立即处理,通常用于严重硬件错误(如内存校验错误、电源故障)。不可屏蔽中断。内中断也称软中断,由程序主动触发,内中断包含系统调用,异常(如除零错误),陷阱(用于调试(如 int3 断点指令)。
1.1、中断函数
在CPU遇到中断时,需要去指向相关处理中断的代码,这些代码就被称为中断函数。在汇编中普通函数的调用和返回使用call/ret指令,中断的调用和返回使用int/iret指令。中断指令调用时除了将eip压入栈,还是把cs的eflags程序状态字压入栈。
1.2、中断向量表
中断向量就是中断函数的指针,它属于实模式下的中断, 内存位置在0x000 ~ 0x3ff,4 个字节表示一个中断向量,总共有 256 个中断函数指针。4个字节中前两个字节是 段内偏移,后两个字节是 段地址。在实模式中。寻址是通过段地址*16 + 偏移地址完成的。
二、中断描述符
在保护模式下,CPU对终端的管理是通过中断描述符和中断描述符表IDT来实现的。中断描述符包含一个代码段选择子、段内偏移地址和一些其他属性。中断描述符的大小也是8和字节。
typedef struct gate_t
{
u16 offset0; // 段内偏移 0 ~ 15 位
u16 selector; // 代码段选择子
u8 reserved; // 保留不用
u8 type : 4; // 任务门/中断门/陷阱门
u8 segment : 1; // segment = 0 表示系统段
u8 DPL : 2; // 使用 int 指令访问的最低权限
u8 present : 1; // 是否有效
u16 offset1; // 段内偏移 16 ~ 31 位
} _packed gate_t;
在segment为0时, type有三种情况:
- 0b0101 - 任务门 (Task Gate):很复杂,而且很低效 x64 就去掉了这种门
- 0b1110 - 中断门 (Interrupt Gate) IF 位自动置为 0
- 0b1111 - 陷阱门 (Trap Gate)
多个中断描述符就组成了中断描述符表IDT。中断描述符表和全局描述符表比较类似。CPU也提供一个寄存器进行加载和保存IDT.
gate_t idt[IDT_SIZE]; // IDT的大小,一般是有256个中断描述符
lidt [idt_ptr]; 加载 idt
sidt [idt_ptr]; 保存 idt
typedef struct pointer // 记录IDT的起始位置和长度
{
unsigned short limit; // size - 1
unsigned int base;
} __attribute__((packed)) pointer;
接下来我们实现下IDT的初始化
void interrupt_init()
{
for (size_t i = 0; i < IDT_SIZE; i++)
{
gate_t *gate = &idt[i];
gate->offset0 = (u32)interrupt_handler & 0xffff; // interrupt_handler是处理中断的函数指针
gate->offset1 = ((u32)interrupt_handler >> 16) & 0xffff;
gate->selector = 1 << 3; // 代码段
gate->reserved = 0; // 保留不用
gate->type = 0b1110; // 中断门
gate->segment = 0; // 系统段
gate->DPL = 0; // 内核态
gate->present = 1; // 有效
}
idt_ptr.base = (u32)idt;
idt_ptr.limit = sizeof(idt) - 1;
BMB;
asm volatile("lidt idt_ptr\n"); // 加载IDT,使之生效
}
处理中断的函数,我们这里仅实现一个打印的功能,来验证下效果。在start.asm中调用下int 0x80即可触发中断。
三、异常
异常分为故障、陷阱、终止三种类型。故障属于是可以被修复的一种类型,属于最轻的一种异常, 如除零异常。陷阱通常用于调试,终止是最严重的异常类型,一旦出现由于 错误无法修复,程序将无法继续运行。inter内部定义了一些异常。异常说明 这里我们重点关注了0x0除零异常和0xD一般保护性异常。有些异常有错误码,错误码会被压入栈中。
每一个异常都需要处理函数。这里可以使用宏来定义这些函数,避免大量代码重复。具体代码这里不再展示。需要说明一点是,我们目前没有实现系统调用,即使用int 0x80时会触发一般保护异常,因为0x80中断函数还没定义,这是CPU硬件实现的行为,属于硬件层面的保护机制:CPU 不允许跳转到一个无效的中断处理程序。
四、外中断原理
首先我们介绍下CPU的执行流程,分为四步:取指 -> 译码 -> 执行 -> 中断
- 取指:将 eip 指向的指令读入处理器
- 译码:将指令的微程序写入流水线 (多级 cache)
- 执行:执行指令
- 中断:处理中断
也就是说CPU每执行完一条执行,都会检测有没有中断产生。CPU检测INTR引脚是否为高位判断外中断的产生,然后通过程序状态字的IF位来判断是否允许外中断。
4.1、中断机制
在使用 80x86 组成的 PC 机中,采用了两片 8259a 可编程中断控制芯片。每片可以管理 8 个中断源。通过多片的级联方式,能构成最多管理 64 个中断向量的系统。在 PC/AT 系列兼容机中,使用了两片 8259a 芯片,共可管理 15 个中断向量。其级连示意图下图所示。其中从芯片的 INT 引脚连接到主芯片的 IR2 引脚上。主 8259a 芯片的端口基地址是 0x20,从芯片是 0xa0;
可屏蔽中断是通过 INTR 信号线进入 CPU,断控制器 8259a 就是用来作中断仲裁的。中断控制器的作用在同时发生多个中断时,来决定中断优先级,发送中断信号给CPU,并接受接收来自 CPU 的的中断响应信号。它还可以屏蔽某个外设的中断。 如图为
4.2、中断处理流程
当某个外设发出一个中断信号时,由于主板上已经将信号通路指向了 8259A 芯片的某个 IRQ 接口,所以该中断信号最终被送入了 8259A;8259A 首先检查 IMR 寄存器中是否已经屏蔽了来自该 IRQ 接口的中断信号。IMR 寄存器中的位,为 1,则表示中断屏蔽,为 0,则表示中断放行;
如果该 IRQ 对应的相应位己经被置 1,即表示来自该 IRQ 接口上的中断已经被屏蔽了,则将该中断信号丢弃;否则,将其送入 IRR 寄存器,将该 IRQ 接口所在 IRR 寄存器中对应的位设为 1。IRR 寄存器的作用相当于待处理中断队列;
在某个恰当时机,优先级仲裁器 PR 会从 IRR 寄存器中挑选一个优先级最大的中断,此处的优先级决判很简单,就是 IRQ 接口号越低,优先级越大,所以 IRQ0 优先级最大; 之后,8259A 会在控制电路中,通过 INT 接口向 CPU 发送中断信号,信号被送入了 CPU 的中断接口;
当 CPU 将当前指令执行完后,进入中断检测的阶段,于是检测到有新的中断到来了;马上通过自己的 INTA 接口向 8259A 的 INTA 接口回复一个中断响应信号,表示现在 CPU 我已准备好啦,8259A 你可以继续后面的工作了;
8259A 在收到这个信号后,立即将刚才选出来的优先级最大的中断在ISR 寄存器中对应的位设为 1,此寄存器表示当前正在处理的中断,同时要将该中断从 待处理中断队列 寄存器 IRR 中去掉,也就是在IRR 中将该中断对应的位设为 0;之后,CPU 将再次发送 INTA 信号给 8259A,这一次是想获取中断对应的中断向量号;
用起始中断向量号 + IRQ 接口号便是该设备的中断向量号,由此可见,外部设备虽然会发中断信号,但它并不知道还有中断向量号这回事,不知道自己会被中断代理 (如8259A) 分配一个这样的整数。
随后, 8259A 将此中断向量号通过系统数据总线发送给 CPU,CPU 从数据总线上拿到该中断向量号后,用它做中断向量表或中断描述符表中的索引,找到相应的中断处理程序井去执行;
如果 8259A 的 EOI 通知 (End Of Interrupt) 被设置为非自动模式 (手工模式),中断处理程序结束前必须向 8259A 发送 EOI;8259A 在收到 EOI 后,将当前正处理的中断在 ISR 寄存器中对应的位设为 0;
如果 EOI 通知 被设置为自动模式,在刚才 8259A 接收到第二个INTA 信号后,也就是 CPU 向 8259A 要中断向量号的那个 INTA, 8259A 会自动将此中断在 ISR 寄存器中对应的位设为 0;并不是进入了 ISR 后的中断就可以被 CPU 处理了,它还是有可能被后者换下来的。
比如,在 8259A 发送中断向量号给 CPU 之前,这时候又来了新的中断,如果它的来源 IRQ 接口号比 ISR 中的低,也就是优先级更高,原来 ISR 中准备上 CPU 处理的旧中断,其对应的位就得清 0,同时将它所在的 IRR 中的相应位恢复为 1,随后在 ISR 中将此优先级更高的新中断对应的位设为 1,然后将此新中断的中断向量号发给 CPU。如果新来的中断优先级较低,依然会被放进 IRR 寄存器中等待处理;
4.3、外中断实现
这里我们实现一下外中断的处理,先看代码:
#define PIC_M_CTRL 0x20 // 主片的控制端口
#define PIC_M_DATA 0x21 // 主片的数据端口
#define PIC_S_CTRL 0xa0 // 从片的控制端口
#define PIC_S_DATA 0xa1 // 从片的数据端口
#define PIC_EOI 0x20 // 通知中断控制器中断结束
// 初始化中断控制器, 这写寄存器都是8259a芯片的寄存器
void pic_init()
{
outb(PIC_M_CTRL, 0b00010001); // ICW1: 边沿触发, 级联 8259, 需要ICW4.
outb(PIC_M_DATA, 0x20); // ICW2: 起始中断向量号 0x20
outb(PIC_M_DATA, 0b00000100); // ICW3: IR2接从片.
outb(PIC_M_DATA, 0b00000001); // ICW4: 8086模式, 正常EOI
outb(PIC_S_CTRL, 0b00010001); // ICW1: 边沿触发, 级联 8259, 需要ICW4.
outb(PIC_S_DATA, 0x28); // ICW2: 起始中断向量号 0x28
outb(PIC_S_DATA, 2); // ICW3: 设置从片连接到主片的 IR2 引脚
outb(PIC_S_DATA, 0b00000001); // ICW4: 8086模式, 正常EOI
outb(PIC_M_DATA, 0b11111110); // 关闭时钟中断外的其他中断。
outb(PIC_S_DATA, 0b11111111); // 关闭所有中断
}
// 通知中断控制器,中断处理结束
void send_eoi(int vector)
{
if (vector >= 0x20 && vector < 0x28)
{
outb(PIC_M_CTRL, PIC_EOI);
}
if (vector >= 0x28 && vector < 0x30)
{
outb(PIC_M_CTRL, PIC_EOI);
outb(PIC_S_CTRL, PIC_EOI);
}
}
// 中断处理函数
void default_handler(int vector)
{
// 注意这里使用的是中断门,进入中断后IF被设置为0。 直到中断函数处理完后弹出原来的elfag,IF又恢复为1,继续接收中断。
// send_eoi 只是通知中断控制器可以发送下一个中断了,但是CPU这里并不会处理
send_eoi(vector); // send_eoi
LOGK("[%d] default interrupt called %d...\n", vector, counter++);
}
这里对终端控制器进行了初始化,并在IDT增加了外中断的向量号以及处理函数。因此打开中断时,就会不停地触发时钟中断,调用响应的中断处理函数。在屏幕上打印中断函数内的内容。在中断处理函数中调用send_eoi告诉中断控制器中断处理完毕。
五、中断上下文
上一骗文章在介绍任务上下文时,使用的是手动调度在屏幕上交替打印A和B。这里我们改用中断来实现任务的切换,实现抢占式调度。首先在处理函数这里要进行上下文寄存器的入栈与出栈
interrupt_entry:
push ds ; 保存上文寄存器信息
push es
push fs
push gs
pusha
mov eax, [esp + 12 * 4] ; 找到前面 push %1 压入的 中断向量
push eax ; 向中断处理函数传递参数
call [handler_table + eax * 4] ; 调用中断处理函数,handler_table 中存储了中断处理函数的指针
add esp, 4 ; 对应 push eax,调用结束恢复栈
popa ; 恢复下文寄存器信息
pop gs
pop fs
pop es
pop ds
; 对应 push %1
; 对应 error code 或 push magic
add esp, 8
iret
在中断处理函数那里将所有寄存器值作为参数传入,遇到中断就打印这些寄存器值,帮助获得更多的中断信息。在中断处理函数进行一次调度如下:
void default_handler(int vector)
{
send_eoi(vector);
// 这里通知可以下一个中断可以进来了。但是IF目前为0并不会进行处理。直到进程切换完弹出原来的eflag才会继续处理下一个中断。
// 注意这里a和b的第一个函数都是开中断。
schedule(); // 每次时间中断时进行一次调度切换下一个任务
}
最后创建任务的代码需要进行调整,任务中不再需要进行调度。
u32 _ofp thread_a()
// _ofp 标记省略栈帧是因为并不需要压入ebp,这样会破坏栈结构,
// 因为这里是任务的入口函数,这个函数并不会结束,但是这里笔者进行了测试,
// 不省略也不会影响这个程序的运行,因为这个入口函数是死循环并不会结束,即使任务有结束,也不能return,
// 因为这不是通过call进入的,必须切换到另一个任务上去,否则就会崩溃
{
asm volatile("sti\n");
while (true)
{
printk("A");
}
}
这里任务中先进行开中断,是因为在可屏蔽的外中断触发时会自动关中断,防止当前中断处理程序被其他可屏蔽中断嵌套。因此当任务触发时,如果不进行开中断,程序会一直阻塞在打印一个函数。因为终端处理函数并没有运行完成,中断一直处于关闭状态。
这块可以调试来感受一下,总体思路和任务上下文类似,在进行线程切换是把当前任务的寄存器压栈,当切换回来时,再把这些寄存器出栈,中断上下文和任务上下文不同的是,中断上下文压入的寄存器数量更多。这里你可能会任务这些寄存器被压入了两编, 但是这里他们实现的作用是不同的。中断压入寄存器是为了保存当前进程的断点,以便能恢复。但是中断处理的过程中进行了任务切换是为了保存任务的上下文。
通俗的说:任务A在调度完成那里被切换到了B,下次切换会A时,首先任务恢复到A被切换到B的那行也就是调度结束的那行代码,此时调度结束也就是中断函数被执行完了,因此要回到中断处理函数的下一行代码,也就是恢复栈,最终中断函数通过iret恢复到上次进程A被中断的位置。总之,这里有点绕,需结合调试反复理解。
这里的切换比较复杂,各个细节需要反复调试才能明白,这里可以观察栈和寄存器的变化来理解。监视IF位的表达式为 ($eflags » 9) & 1 。
「真诚赞赏,手留余香」
真诚赞赏,手留余香
使用微信扫描二维码完成支付
