文章目录
写在前面:自制操作系统Gos 第二章第九篇:主要内容是中断相关知识已经在内核中如何取实现中断
关于中断的基础知识请移步至此博客:什么是中断
Gos完整代码:Github
中断描述符表
和其他的概念一样,我们首先要了解的就是操作系统是如何识别 执行中断的。这靠的其实就是中断表描述符和中断表描述符表。
中断描述符表(IDT,interrupt descriptor table)其实就是保护模式下面我们用于存储中断处理程序函数指针的一个数据结构。当CPU收到一个中断的时候,需要用中断向量号在此数据结构中检索相应的函数指针,得到了处理程序在内存中所处的位置之后,执行中断处理程序。
中断描述符
在中断描述符表中,其实除了中断描述符还有任务门描述符和陷阱门描述符。
- 中断门描述符:中断门包含了中断处理程序所在段的选择子和段内偏移地址。当通过此方式激怒中断后,标志寄存器eflags的IF位自动置为0,也就是进入中断之后,不能重复进入中断。
注意:D位为0表示16位模式,为1表示32位模式
其用代码表示如下:
// @brief 中断描述符结构体
struct gate_desc
{
uint16_t func_offset_low_word; //中断处理程序在目标文件中偏移低16位
uint16_t selector; //选择子,其实也就是下标
uint8_t dcount; //此项为双字计数字段,是门描述符中的第4字节。此项固定值,不用考虑
uint8_t attribute; //描述符属性
uint16_t func_offset_high_word; //中断处理程序在目标文件中偏移高16位
};
- 任务门描述符:任务门和任务状态段是Intel处理器在硬件一级提供的任务切换基址,所以任务门需要和TSS配合在一起使用。在任务门中记录的是TSS选择子。
- 陷阱门描述符:陷阱门和中断门十分相似,区别是由陷阱门进入中断后,标志寄存器eflags的IF位不会置为0。
- 调用门描述符:调用门其实也就是系统调用,给用户进程提供给进入特权0级的方式。当然,其不存在与中断描述符表IDT中,一般是在全局描述符表GDT或本地描述符表LDT中。
可以看到,这几个描述符的结构其实差不多,唯一的差距可能就在于TYPE字段了。
中断描述符表寄存器
现在我们已经知道中断描述符表长什么样子,下一步就是解决中断描述符表存哪里的问题。根据🐢的屁股来说,中断描述符表的位置是不确定的。所以我们需要一个寄存器来指向它,这个寄存器就叫中断描述符表寄存器(IDTR)。该寄存器结构如下:
而加载中断描述符寄存器需要用到以下命令:
lidt 48位地址
中断错误码
什么是中断中我们提到了中断压栈时,如果发生了错误会压入错误码。其也是一个结构,如下:
其本质时就是描述符的选择子,通过低3位属性来修饰指向的是哪个表中的哪个描述符:
- EXT:表示外部事件,用来判断中断源是否来自处理器外部
- IDT:表示选择子是否指向中断描述符表IDT
- TI:为0表示从GDT中检索,1表示是LDT
可编程中断控制器
可编程中断控制器(Programmable interrupt control,PIC)是处理器CPU和外设沟通的桥梁,一般由外设发出的中断请求都由它进行处理,之后再转交给CPU。
而在x86-64体系下面,我们使用的是Intel 8259A
,所以我们要介绍的其实也就是8259A。
注:至于为什么要有这个可编程中断控制器呢?
我们思考一下如何和CPU进行交互呢?一般来说,外设和CPU进行沟通是通过电信号的,要传递电信号肯定需要CPU和外设都支持引脚吧,同时还要有一根导线连接这两个引脚。
但是外设的数量理论上是无限的,而CPU不是蜈蚣,它的引脚数量是有限的,这就会导致CPU引脚不够用,所以我们需要加一层,引入可编程中断控制器PIC。
在什么是中断中我提到过,可屏蔽中断时通过INTR
信号线进入CPU的。而向常见的外设:鼠标、键盘、打印机等等发出的都是可屏蔽中断,其共享这根INTR信号线,通过这根信号线进入CPU。而一旦涉及到共享资源,熟悉编程的同学第一反映就是线程安全问题,中断也不例外。可编程中断控制器PIC中维护了一个中断等待队列,当鼠标和键盘两个外设同时触发中断,那么就会按照它们发出的中断信号进入PIC的时间,挨个进入到中断等待队列。之后,PIC会挨个从中断等待队列中取出中断信息,串行的交给CPU去执行。
注:怎么样,是不是有点像消息队列的原理呢?伟大的思想都是相通的
8259A
8258A用于管理外设的中断,其功能其实拍一下脑袋就想出来了:
- 屏蔽外设中断
- 中断优先级判断
- 中断向量号输入
那么它长什么样子呢?哈哈哈哈哈,我给大家画个图:
我们可以看到,单个PIC的能够支持的中断请求(Interrupt Request,IRQ)的数量为8个,但是Intel支持256个中断哇,这咋搞?
这就要涉及到一个概念了,级联!我们想一下,当我们编写服务器程序的时候,如果遇到一个服务器不能处理请求量的时候,我们是不是就可以采用增加服务器的水平伸缩方式,这样就可以提供更多的并发量了。而一旦增加服务器,就需要有一个管事的,不能群龙无首,这就诞生了经典的主从架构!PIC采用的设计思想也是一样的,一个PIC不够,我就加一个,两个不够,我再加一个就是了。而其中直接和CPU连接的被称为主片(Master),其他的则为从片(Slave),从片通过IRQ与主片相连,便有了如下的架构图:
注:
这样的主从架构使得操作系统有了支持8*8-8 = 56
个中断的能力(有8个引脚被从片占了),距离Intel Cpu支持的256个其实还有很大的差距,所以我们可以采用更换更好的PIC的垂直伸缩的方式。
8259A和CPU的交互
我们现在再来审视一下8259A内部对中断的支持,看看是如何支持外设中断的。
首先先上一个8259A芯片的内部结构图:
哈哈哈哈,图有点复杂,我先来讲讲每个寄存器是干嘛的吧:
- INT:8259A选出优先级最高的中断请求后,发信号通知CPU。
- INTA:INT Acknowledge,中断相应信号,其是用来接收CPU那边发来的中断响应信号的。
- IMR:interrupt mask register,中断屏蔽寄存器,有8位,每一位都表示屏蔽来自一个引脚的信号。比如
0011 0000
就表示屏蔽IRQ2 和IRQ3 这两个引脚。 - IRR:interrupt request register,中断请求寄存器,其实接收来自IMR的寄存器信号并所存,其也是中断队列的实体。如果其值为
0011 0000
则表示IRQ2 和IRQ3 这两个引脚的信号还没有被处理。 - PR:priority register,优先级仲裁寄存器,其相当于有多个中断同时发生的时候,仲裁哪个信号该优先级被处理。一般是IRQ下标小的优先级越高,也就是说当IRQ1和IRQ2同时发生时,优先处理IRQ1的信号,这也是时钟为什么经常被设置为IRQ0的原因。
- ISR:In-Service register,中断服务寄存器,其表示哪个中断信号正在被处理。如其值为
0001 0000
,则表示IRQ3的信号正在被处理。
其实介绍完每个寄存器是干嘛的了,我觉得你应该对整个中断处理过程有概念了,但是我还是要好好讲一下:
- 8259A首先检查IMR寄存器是否已经频闭了来自该IRQ的接口的中断信号。屏蔽了,直接丢弃不处理,没屏蔽进入下一步。
- 将其送入IRR寄存器,其中对应的位设置为1.
- 优先级仲裁器PR会优先的从IRR寄存器中选择一个优先级最大的中断进行处理。
- 8259A会在控制电路中通过INT接口向CPU发生INTR信号,其会被送入CPU的INTR接口
- CPU在收到INTR信号后,在执行完当前指令后,通过自己的INTA接口向8259A发送INTR中断响应信号,表示CPU收到信号了,可以开始处理了。很明显,这个对于8259A是同步的。
- 将刚刚选出来优先级最大的中断在ISR寄存器中对应的位置为1,表示正在主力当前中断信号。
- 之后CPU会再次发送一个INTA信号,这次表示其需要一个中断向量号
- 随后,8259A将中断向量号发送给CPU,CPU收到中断向量号之后,就会去中断向量表寻找对应的中断处理程序进行处理。
- 之后,如果8259A是处于自动模式,那么它就会自动把ISR中的相应位置为0,表示这个信号处理完了;如果处于手动模式则需要CPU恢复一个EOI(end of interrupt)信号。
8259A编程
我们刚刚已经讲过了8259A中对中断的处理过程了,现在我们要介绍的就是如何对其进行初始化,也就是设置主片与从片的级联方式、指定起始中断向量号和设置工作模式。
其实,在开机启动的时候,也就是实模式的情况下,BIOS里面的程序其实就设置过PIC,将其IRQ0~IRQ7
分配了0x8~0xf
的中断向量号。而在保护模式下,中断向量号0x8~0xf
的范围已经被CPU分配给了各个异常。这里要说一下,中断向量号是逻辑上的东西,它在物理上是8259A上的各种接口号,我们可以通过设置8259A将IRQ接口映射到不同的中断向量号。
在8259A中,有两组寄存器。一是初始化命令寄存器组,用来保存初始化命令字(initialization command word,ICW),ICW是4个,ICW1~ICW4
;另一组则是操作命令寄存器(Operation command word,OCW),OCW一个三个,OCW1~OCW3
。所以我们初始化的工作如下:
- ICW初始化:用来确定是否需要级联,设置起始中断向量号,设置中断结束模式。这里要注意的就是要依次i写入ICW1、ICW2、ICW3、ICW4 。
- OCW初始化:用来操作控制8259A,通过往其中发送OCW可以达到中断屏蔽和中断结束。
ICW初始化
首先就是初始化ICW1,ICW1是用来初始化8259A的连接方式和中断信号的触发方式。连接方式是指用单片工作还是用多片级联的方式;触发方式是指请求信号是电平触发还是边沿触发。我们先看一下ICW1寄存器的结构:
- IC4:表示是否要写入ICW4。
- SNGL:表示级联方式,如果其为1,表示单片;如果其为0,表示级联。
- ADI:表示8085的调用时间间隔,x86不用设置这一项。
- LTIM:设置冲断检测方式,如果为1表示电平触发;如果为0表示边沿触发
- 第4位:固定为1,表示ICW1的标记
- 第5~7位:x86中固定为0
注:
ICW1需要写到主片的0x20端口和从片的0xA0端口
然后就是初始化ICW2,其用来设置起始中断向量号,其实就是硬件IRQ端口到逻辑中断向量号的映射,如果我们这里这质量IRQ0的中断向量号位0,其他的IRQ端口会自动映射至1~7 。其结构如下:
这里我们只用填写高五位的T3~T7
,ID0~ID2
这低三位我们不用管,填写的数字便是中断向量号啦。比如我们在主片的IRQ0高五位写入0000 1
,那么主片的其他IRQ接口就是:0000 1001,0000 1010,0000 1011
…,这样如果是从片写入 0001 0
,那么其上的IRQ接口也会顺延的。
注:
ICW2需要写到主片的0x21端口和从片的0xA1端口
其次就是初始化ICW3,其是在级联方式下才需要,用来设置主片和从片的哪个IRQ接口互联,其结构如下:
对于主片来讲,ICW3中置为1的那一位表示这个IRQ接口和从片向量,比如给主片的2、6位接了从片,ICW3就需要进行如下设置:0100 0100
;而对于从片来说,其只需要设置从片连接的哪个接口负责连接主片就可以了,比如如果设置0000 0010
,表示从片的IRQ1和主片相连。
注:
ICW3需要写到主片的0x21端口和从片的0xA1端口
最后便是初始化ICW4,其结构比较复杂,先放结构图:
- SFNM:表示特殊全嵌套模式,如果其为0,则表示全嵌套模式;如果其为1,则表示特殊全嵌套模式。
- BUF:表示本芯片是否工作在缓冲模式,如果其为0,表示工作在非缓冲模式;如果其为1,表示其工作在缓冲模式。
- M/S:表示当前是否是主片,如果是1则表示当前是主片。
- AEOI:表示自动结束中断,其实也就是刚刚讲过的主动模式和手动模式啦。其为1,表示自动模式;为0,则表示手动模式。
- PM:表示CPU类型,其为0,表示8080或8085处理器;其为1,则表示x84处理器。
OCW操作
首先是OCW1,其用来屏蔽连接在8259A上的外部设备的中断信号,实际上就是写入IMR寄存器。比如我们写入的是 0000 0001
,表示屏蔽来自IRQ0的信号。其结构如下:
注:
OCW1需要写到主片的0x21端口和从片的0xA1端口
其次就是OCW2了,其用来设置中断结束方式和优先级模式。结构如下:
- SL:针对某个特定优先级的中断进行操作,如果其为1,则OCW2的低三位
L0~L2
来指定位于ISR寄存器的哪个中断被终止。比如其为011,则表示ISR上正在运行的IRQ3信号被终止。 - R:表示优先级控制方式,如果其为1,表示采用循环优先级的方式,如果此时SL为0,那么最高优先级是IRQ0,之后依次递减。如果SL为1,那么
L0~L3
表示的IRQ端口便是最低优先级。比如此时IRQ2,那么IRQ3>IRQ4>IRQ5>IRQ6>IRQ7>IRQ0>IRQ1>IRQ2 。 - EOI:中断结束命令位,如果其为1,则会零ISR寄存器的相应位清零,其是手工中断才需要被设置的,一般为0 。
- 第3~4位:表示此为OCW2
注:
OCW2需要写到主片的0x20端口和从片的0xA0端口
OCW3我们用不到就不介绍啦!感兴趣的同学自己去看一下吧。
Gos中的中断
说了半天,其实我们的操作系统要做的事情很少,主要如下:
- 构造好中断向量表IDT
- 提供中断向量号
- 还有就是初始化8259A
初始化中断的在init_all
中进行,其代码如下:
void init_all()
{
put_str("init Gos's all begin,please wait...\n");
idt_init(); //初始化中断
...
}
而idt_init
中主要会做两件事情:
- 初始化 PIC
- 初始化 IDT
/**
* @brief 完成有关中断初始化的所有工作
*
*/
void idt_init()
{
put_str("idt_init start\n");
idt_desc_init(); // 初始化中断描述符表
exception_init(); // 异常名初始化并注册通常的中断处理函数
pic_init(); // 初始化8259A
// 加载idt
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");
}
初始化中断描述符表
在这一步,我们首先需要初始化中断处理程序,其被定义在kernel.S
文件中,主要做的事情其实就是创建一张中断处理程序入口表,要注意这里其实只是初始化了中断处理程序的入口数组,本质调用的还是idt_table
。至于为什么要加这么一个中介层呢?大家想想就明白了,idt_table中的中断处理程序其实是可变的,如果没有中间层,那么就意味着灵活性的缺失。
global intr_entry_table
intr_entry_table:
; 定义宏函数 中断处理程序
%macro VECTOR 2 ;表示接受两个参数,arg1:中断向量号 arg2:是否发生错误
section .text
intr%1entry: ;这个是%1代表第一个参数,这样每个中断处理程序就进行数组排列了
%2
;保存上下文环境
...
;如果是从片上的中断信号,除了给从片发还给主片发
mov al,0x20 ;中断结束命令EOI
out 0xa0,al ;给从片发
out 0x20,al ;给主片发送
push %1 ;压入中断向量号
call [idt_table+%1*4] ;调用中断处理函数
jmp intr_exit
section .data
dd intr%1entry ;存储各个中断入口程序的地址,形成intr_entry_table数组
因为这里是初始化,所以我们我们压入一个空就好了:
%define ZERO push 0 ;若没有压入错误码,就添加一个0
...
VECTOR 0x00,ZERO
之后,我们就可以初始化中断描述符了:
/**
* @brief 创建中断描述符
*
* @param p_gdesc 待初始化的中断描述符
* @param attr 中断描述符属性
* @param function 中断处理程序
*/
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; //取得中断处理程序的低16位
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; //取得中断处理程序的高16位
}
而初始化中断描述符表,其实也就是填充这一个个中断描述符到表里面:
/**
* @brief 初始化中断描述符表
*
*/
static void idt_desc_init(void)
{
int i, lastindex = IDT_MEM_DESC_CNT - 1;
for (i = 0; i < IDT_MEM_DESC_CNT; i++)
{
//IDT_DESC_ATTR_DPL0-->1000 1110
make_idt_desc(&idt[i], IDT_DESC_ATTR_DPL0, intr_entry_table[i]);
}
// 单独处理系统调用,其实也就是0x80中断,系统调用对应的中断门dpl为3,
// 中断处理程序为单独的syscall_handler
//IDT_DESC_ATTR_DPL3-->1110 1110
make_idt_desc(&idt[lastindex], IDT_DESC_ATTR_DPL3, syscall_handler);
put_str(" idt_desc_init done\n");
}
好了,现在我们会创建如下的中断描述符表:
之后,我们要做的内容其实就是填充idt_table
里面的内容了,这个我们由于是初始化,就直接填充一个通用的处理函数就可以:
for (i = 0; i < IDT_MEM_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
}
这样之后,中断描述符表就会变成这个样子:
初始化PIC
刚刚原理已经讲过了,直接放代码啦:
// 初始化主片
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
// IRQ2用于级联从片,必须打开,否则无法响应从片上的中断
// 主片上打开的中断有IRQ0的时钟,IRQ1的键盘和级联从片的IRQ2,其它全部关闭
outb(PIC_M_DATA, 0xf8);
// 打开从片上的IRQ14,此引脚接收硬盘控制器的中断
outb(PIC_S_DATA, 0xbf);
中断实现锁机制
对于锁机制的实现其实就是依靠中断,回忆一下为什么需要的锁机制?锁机制的需要就是不希望当前执行的过程被打断,那么什么机制能够打断当前的执行过程呢?没错,就是中断。理论上,我们在需要锁的时候,把中断机制关闭;不需要锁的时候将中断机制打开就可以了。
信号量的实现
这里我们就简单来实现信号量。信号量就是0以上的整数值,当为0的时候表示没有可用信号,因此其岱庙某种信号的积累量,被称之为信号量。而信号量的本质就是计数器,它的计数值是自然数,用来记录所积累信号的数量。
增加信号量的up
包括两个子操作:
- 将信号量的值+1
- 唤醒再次信号量上等待的线程
/*
* @brief 信号量psema的down操作
* @param psema 待操作的信号量
*/
void sema_up(struct semaphore *psema)
{
enum intr_status old_status = intr_disable();
ASSERT(psema->value == 0);
if (!list_empty(&psema->wait_threads))
{
//得到一个被锁住的线程
struct task_struct *thread_blocked = elem2entry(struct task_struct, general_tag, list_pop(&psema->wait_threads));
thread_unblock(thread_blocked);
}
psema->value++;
ASSERT(psema->value == 1);
intr_set_status(old_status);
}
而信号量减少down
则包括三个子操作:
- 判断信号量是否大于0
- 若信号量的大于0,则将信号量-1
- 若信号量等于0,则将当前线程阻塞
/*
* @brief 信号量down操作
* @param psema 待操作的信号量
*/
void sema_down(struct semaphore *psema)
{
enum intr_status old_status = intr_disable();
while (psema->value == 0)
{
//代表锁被别人持有
//判断不在等待队列中
ASSERT(!elem_find(&psema->wait_threads, &running_thread()->general_tag));
if (elem_find(&psema->wait_threads, &running_thread()->general_tag))
{
PANIC("sema_down: thread blocked has been in wait_threads");
}
//加入等待队列
list_append(&psema->wait_threads, &running_thread()->general_tag);
thread_block(TASK_BLOCKED);
}
//下面代码处于value为1或者被唤醒的情况下
psema->value--;
ASSERT(psema->value == 0);
intr_set_status(old_status);
}
线程阻塞与唤醒
刚刚在在信号量的实现中,我们提到了线程的阻塞和唤醒。这个过程其实很简单,只用把当前线程的运行状态换一下,然后换下个线程调度这个过程就完成了。不过要注意这个过程是原子的。
/*
* @brief 当前线程将自己阻塞,标志其状态为state
* @param state 线程待设置的状态,必须为blocked、waiting以及hangding三者之一
*/
void thread_block(enum task_status state)
{
//只有处于blocked、waiting以及hangding状态才不会被调度
ASSERT(((state == TASK_BLOCKED) || (state == TASK_WAITING) || (state == TASK_HANGING)));
//关中断
enum intr_status old_status = intr_disable();
struct task_struct *current_thread = running_thread();
current_thread->task_status = state;
schedule();
intr_set_status(old_status); //这个由于此线程已经处于不可执行态了,只能等其再次被调度才能执行
}
锁的实现
对于锁来说,我们需要知道当前持有锁的是谁,后面等待持有锁的又是谁,再外加一个表示锁本身属性的信号量其实就构成了锁这个数据结构:
// * @brief 锁结构体
struct lock
{
struct task_struct *holder; //锁的持有者
struct semaphore sema; //二元信号量实现锁
uint32_t holder_repeat_nr; //锁的持有者重复申请锁的次数
};
而上锁操作则是首先判断当前锁的持有者是不是本身,如果不是就需要将当前线程加入等待序列,等待别人主动释放锁。等别人释放锁之后呢,其实就是占有这个锁的操作;如果是锁的持有者本身,那么就增加重复申请次数,这个过程就是多重锁。
/*
* @brief 获得锁plock的所有权
* @param plock 待操作的锁
*/
void get_lock(struct lock *plock)
{
if (plock->holder != running_thread())
{
//while(1)等待别的线程主动释放锁
sema_down(&plock->sema);
//此时代表持有锁的线程已经释放了
plock->holder = running_thread();
ASSERT(plock->holder_repeat_nr == 0);
plock->holder_repeat_nr = 1;
}
else
{
plock->holder_repeat_nr++;
}
}
解锁操作则是一个相反的过程:
/*
* @brief 锁的持有者放弃plock的所有权
* @param plock 待操作的锁
*/
void abandon_lock(struct lock *plock)
{
ASSERT(plock->holder == running_thread());
if (plock->holder_repeat_nr > 1)
{
plock->holder_repeat_nr--;
return;
}
ASSERT(plock->holder_repeat_nr == 1);
plock->holder = NULL;
plock->holder_repeat_nr = 0;
sema_up(&plock->sema);
}
参考文献
[1] 操作系统真相还原