0x00

操作系统是由中断驱动的

0x01 何为中断

整个操作系统的主体可以看做一个死循环,可以粗陋地看作如下形式

1
2
3
4
while(1)
{
wait_and_handle_interrupt();
}

所谓中断就是告诉 CPU 某件事发生了,你处理一下。那么来自 CPU 外部就是外部中断:外部中断的来源是计算机的其他硬件,所以又称为硬件中断(比如按下键盘)。来自 CPU 内部就是内部中断:可分为软中断和异常。要么来自软件(比如系统调用),要么就是 CPU 出错了(例如除0错误)。

0x02 处理中断

那么操作系统如何处理中断?

0x021 IDT

要处理它首先得让操作系统知道什么是中断:定义一个 IDT (Interrupt Descriptor Table:中断描述符表)。CPU 使用一个专门的寄存器 IDTR 存放内存中 IDT 的位置。这意味着 IDT 需要操作系统自行准备好放在内核内存里。

可使用特权指令 lidtsidt 来读写这个寄存器。

什么又是中断描述符?如下所示

1
2
3
4
5
6
7
8
// 中断描述符结构体
struct IntrDesc{
uint16_t func_offset_low_word; // 中断处理函数地址低16位
uint16_t selector; // 代码段选择子
uint8_t zero; //不用考虑,必须为0
uint8_t attribute; // 类型 属性
uint16_t func_offset_high_word; // 中断处理函数地址高16位
};

此前提到过四中”门”,所谓”门”就是类似这样的程序入口。中断描述符也就是中断门描述符。通过图中的 type 字段确定是那种门(调用门,任务门,陷阱门或是中断门)

一目了然。有了这个结构操作系统就知道应该到哪里去处理一个中断。对于一个中断发生时,根据其对应的中断向量号(每个中断源对应一个中断向量号,粗浅理解为在 IDT 中的索引)找到对应的中断描述符,然后找到其在内核中的处理程序即可。

0x022 处理过程

软件部分:

  • 处理器根据中断向量号定位中断门描述符

由于中断描述符是 8 个字节, 所以处理器用中断向量号乘以 8 后,再与 IDTR 中的中断描述符表地址相加,所求的地址之和便是该中断 向量号对应的中断描述符。

  • 处理器进行特权级检查

如果是用户程序发起的中断,则先检查用户 CPL 是否小于等于中断描述符 DPL (在其13-14比特位,处于定义的 attribute 字段中,注意不是 selectorRPL),即用户特权级是否满足这个中断描述符所需。

检查中断描述符的 selector 中的 RPL 是否小于等于当前 cpu 的 CPL。也就是要求中断只能提升特权级。如果是硬件中断或者异常,则不需要第一个检查,直接检查这个。

  • 执行中断处理程序

将中断描述符中的代码段选择子加载到 cs 寄存器,将中断描述符中的中断处理程序偏移地址加载到 eip。跳转到终端处理程序。

这个过程需要压栈旧的 cseip 等上下文以便于恢复。注意如果中断提升了特权级需要使用不同的特权栈,那么就还要在新的栈中保存旧的栈地址。如下图。

中断处理程序结束后使用特权指令 iret 返回。这个指令作用是从当前栈顶处依次弹出 32 位数据分别到寄存器 EIP、CS、EFLAGS,如果发生了特权级提升,返回时还会弹出 espss

还值得一提的是有些中断还会压栈中断错误码,但是iret不会弹出中断错误码。所以在中断处理程序中需要手动用栈指针跨过错 误码或将其弹出。

整个处理过程如下所示

硬件部分

对于硬件部分的过程不多赘述,一来这是记录操作系统软件实现,二来参考书中的硬件也有些过时了。但还是贴一个流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
设备产生中断请求(IRQ)

8259A 接收到 IRQ 信号

是否被屏蔽?(IMR寄存器)

┌────是────→ 忽略中断 ❌



是否优先级最高?(IRR + ISR 判断)

┌────否────→ 等待(排队)⏳



8259A 向 CPU 发送 INTR 信号

CPU 响应(发出 INTA)

8259A 返回中断向量号

CPU:
查 IDT → 跳转中断处理程序

执行中断处理程序

发送 EOI(End Of Interrupt)

8259A 清除 ISR 标志

允许下一个中断进入

0x03 实现

kernel.S 提供内核处理各个中断的入口,也就是中断描述符中的中断处理程序,主要是分发路由功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
[bits 32]
%define ERROR_CODE nop ; 若在相关的异常中CPU已经自动压入了错误码,这里不做操作
%define ZERO push 0 ; 定义默认的压入错误码操作

extern put_str
extern intrHandler_table

section .data

global intr_entry_table
intr_entry_table:
%macro VECTOR 2 ;这里定义一个多行宏作为 intr_handler,后面的2是指传递两个参数,里面的%1,%2代表参数
section .text
intr%1entry:
; ============= 默认压入错误码或者空操作,保证最后都会需要跨过一个4字节来进行iret
%2
; ============= 保存上下文
push ds
push es
push fs
push gs
pushad ;压入通用寄存器
; ============= 发送中断结束信号
mov al, 0x20 ; 中断结束命令EOI,这里R为0,SL为0,EOI为1
mov dx, 0xa0 ; 中断控制器从片端口
out dx, al ; 向从片发送
mov dx, 0x20 ; 中断控制器主片端口
out dx, al ; 向主片发送
; ============= 压入中断向量号并调用中断处理函数
push %1
call [intrHandler_table + %1*4]
jmp intr_exit
; ============= intr_entry_table数组(编译后会将相同的section合并成一个段)
section .data
dd intr%1entry ;存储各个中断入口程序的地址,
%endmacro ;多行宏结束标志


section .text
global intr_exit
intr_exit:
; =========== 恢复上下文
add esp, 4 ; 跳过中断号
popad
pop gs
pop fs
pop es
pop ds
add esp, 4 ; 跳过error_code
; =========== 返回
iretd


VECTOR 0x00,ZERO
...
VECTOR 0x1e,ERROR_CODE
VECTOR 0x1f,ZERO
VECTOR 0x20,ZERO

interrupt.c 主要是准备好 IDT 和中断处理程序的实际实现(暂时只处理时钟中断以测试)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
#include "interrupt.h"
#include "global.h"
#include "stdint.h"
#include "io.h"

#define IDT_DESC_CNT 0x21 //目前总支持的中断数
#define PIC_M_CTRL 0x20 //主片控制端口
#define PIC_M_DATA 0x21 //主片数据端口
#define PIC_S_CTRL 0xA0 //从片控制端口
#define PIC_S_DATA 0xA1 //从片数据端口

// 中断描述符结构体
struct IntrDesc{
uint16_t func_offset_low_word; // 中断处理函数地址低16位
uint16_t selector; // 代码段选择子
uint8_t zero; // 不用考虑,必须为0
uint8_t attribute; // 类型 属性
uint16_t func_offset_high_word; // 中断处理函数地址高16位
};

// 定义 IDT
static struct IntrDesc IDT[IDT_DESC_CNT]; //idt是中断描述符表,本质上是中断描述符数组

char* intr_name[IDT_DESC_CNT]; //用于保存中断的名字
intrHandler_ptr intrHandler_table[IDT_DESC_CNT]; //定义中断处理程序地址数组
// 引用 kernel.S 中的 intr_entry_table, 每个成员为 intrHandler_ptr 类型(头文件里自定义的void*)
extern intrHandler_ptr intr_entry_table[IDT_DESC_CNT];

// 初始化中断控制器
static void initPIC(void)
{
// 初始化主片
outb(PIC_M_CTRL, 0x11); //ICW1:边沿触发,级联8259,需要ICW4
outb(PIC_M_DATA, 0x20); //ICW2:起始中断向量号为0x20,也就是IRQ0的中断向量号为0x20
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
outb(PIC_S_DATA, 0x02); //ICW3:设置从片连接到主片的IR2引脚
outb(PIC_S_DATA, 0x01); //ICW4:同上

// 打开主片上的IR0,也就是目前只接受时钟产生的中断
outb(PIC_M_DATA, 0xfe); //OCW1:IRQ0外全部屏蔽
outb(PIC_S_DATA, 0xff); //OCW1:IRQ8~15全部屏蔽

put_str("pic init done!\n");
}

// 创建中断描述符
static void makeIntrDesc(struct IntrDesc* p_gdesc, uint8_t attr, intrHandler_ptr function)
{ //参数分别为,描述符地址,属性,中断程序入口
p_gdesc->func_offset_low_word = (uint32_t)function & 0x0000FFFF;
p_gdesc->selector = SELECTOR_K_CODE; // 内核代码
p_gdesc->zero = 0;
p_gdesc->attribute = attr;
p_gdesc->func_offset_high_word = ((uint32_t)function & 0xFFFF0000) >> 16;
}

// 初始化中断描述符表
static void initIDT(void)
{
for(int i = 0; i < IDT_DESC_CNT; i++)
{
makeIntrDesc(&IDT[i], IDT_DESC_ATTR_DPL0, intr_entry_table[i]);
}
put_str("init IDT done\n");
}


// 通用的中断处理函数,一般用在出现异常的时候处理
static void intrHandler_default(uint64_t vec_nr)
{
// IRQ7和IRQ15会产生伪中断,IRQ15是从片上最后一个引脚,保留项,这俩都不需要处理
if(vec_nr == 0x27 || vec_nr == 0x2f) return;
put_str("int vector : 0x"); //这里仅实现一个打印中断号的功能
put_uint(vec_nr);
put_char('\n');
}


static void initIntrHandlerTable(void)
{
for(int i = 0; i < IDT_DESC_CNT; i++)
{
// idt_table数组中的函数是在进入中断后根据中断向量号调用的
intrHandler_table[i] = intrHandler_default; //这里初始化为最初的默认处理函数, 后续再实现各个中断处理函数的注册
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]是保留项,未使用
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 initInterrupt(void)
{
put_str("initInterrupt start\n");
initIDT(); //初始化中断描述符表
initIntrHandlerTable(); //初始化中断处理函数表和中断名称
initPIC(); //初始化8259A

// 加载 IDT
uint64_t idt_operand = (sizeof(IDT)-1) | ((uint64_t)((uint32_t)IDT << 16)); //这里(sizeof(IDT)-1)是表示段界限,占16位,然后我们的idt地址左移16位表示高32位,表示idt首地址
asm volatile("lidt %0" : : "m" (idt_operand));
put_str("init interrupt all done\n");
}

使用 qemu 运行如下

可以看到时钟中断的触发(时钟中断的向量号正是 0x20)