中断
1:中断概述
并发和并行的区别:
- 单核cpu-并发:单位时间内的积累工作量,如在一秒内cpu处理了100个请求量
- 多核cpu-并行:真正同时进行的工作量,如在任意瞬间cpu正在同时处理100个请求量
系统有了中断,才能够并发运行,能够不断的切换进程。
操作系统是中断驱动的,本身是一个死循环while(1){ 操作系统代码 }
,所以系统是在循环中等待事件的发生,被动的调用资源去处理,而事件是由中断去通知系统的的。
2:中断类型
外部中断:由外部硬件发出的中断信号,再细分有可屏蔽中断和不可屏蔽中断
内部中断:内部发出,再细分有软中断和异常
补充:软中断int3断点调试指令,常用在GDB和Bochs调试器,具体的原理是
- 父进程(调试器)fork了一个子进程,来运行被调试的程序,当用户在调试器(Bochs举例)上输入
b 0xXXXXXXXX
指令时,父进程会将该地址的指令的第一个字节备份,再用0xcc(int3的机器指令)替换- 这样再输入
c
运行,子进程运行到断点处就会触发3号中断,进行中断处理程序,但在此之前,还需保存当前的寄存器和栈的状态,所以用户查看寄存器和栈都是在栈中的值。- 当继续运行调试程序即输入
n
,父进程又会把原先备份替换回去,并把寄存器和栈恢复,修改返回地址为断点地址,用iret退出中断。
2.1:处理器的特权级检查:
中断是通过中断向量号通知处理器的,所以不涉及RPL(如代码段描述符需要用段选择子来通知)。
(a):如果是软中断int n,int3,into等,这些是用户进程主动引起的,
数值上要求目标代码段DPL <= CPL <= 门描述符DPL
,为了避免特权级为3的用户进程主动调用某些只用于内核的例程(设置了门槛下限)
(b):如果是外部中断、异常,这些是进程被动引发的,所以不需要考虑上面的情况
数值上要求目标代码段DPL <= CPL
2.2:IF位
eflags寄存器中开关中断的标志位,仅限于限制外部设备的中断。
调用中断门需要关闭,陷阱门和任务门不用
- 由于中断门要避免中断嵌套,即处理过程中又调用中断,会触发一般保护性异常;
- 处理器允许陷阱门嵌套优先级更高的中断,任务门必须要嵌套才能实现多任务并发
改变IF位的方式采用cli和sti专门的指令:由于通过pushf压栈的方式涉及到内存的访问,可以拆分成多步骤,不满足原子性要求。
3:可编程中断控制器(PIC)8259A
3.1:引入原因
任务是串行在CPU执行的,每次只能执行一个任务,但让CPU来维护一个任务队列造成资源浪费,所以引入一个中介代理=中断控制器。
目前集成在南桥芯片上
3.2:主要作用
管理和控制可屏蔽中断,通过编程的方式实现
- 屏蔽外设中断,对它们实行优先级判决
- 向 CPU 提供 中断向量号
3.3:级联
Intel处理器支持256个中断,一个8259A支持8个中断,所以每片引出一个引脚接到另一片上构成级联结构。
n片PIC支持7n+1个中断源 。级联结构的PICs包含一个主片,n-1个从片,所有中断只能由主片发送给CPU。
如交换机和集线器都用这种结构做扩展。
级联交换机原理:交换机上通向网关的接口是单独的,下级交换机必须用该接 口通过网线接在核心交换机的某个普通网卡接口上
说明:
- INTR是发送可屏蔽中断信号,NMI是发送不可屏蔽中断,不受处理器内部中断允许标志控制,优先级大于INTR
- IRQx是每个外部设备提供的一个中断源的发出的中断请求信号。开机时IRQx被分配给该接口上的外部设备。
- IRQ2是从片发出中断请求信号的接收口
3.4: 8259A内部一些信号和寄存器:
- IMR:Interrupt Mask Register,中断屏蔽寄存器,宽度是8位(下同),用来屏蔽某个外设的中断
- IRR:Interrupt Request Register中断请求寄存器,待处理中断队列,将IMR中未被丢弃的中断信号入队
- PR:Priority Resolver ,优先级仲裁器 ,从IRR中找出优先级最大的中断,一般中断号越低级别越高
- ISR:In-Servi Register ,中断服务寄存器,保存CPU正在处理的中断号
- INTA:INT Acknowledge ,中断响应信号,位于CPU来回复PlC一个中断响应信号。
- INT:8259A 选出优先级最高的中断请求后,发信号通知CPU
寄存器的宽度为8位:类似于位图的索引方式,每一位对应一个IRQx的状态
说明:IMR(x)表示IMR寄存器的第x位的状态。
那么8259A是如何响应中断信号的呢,流程如下:
- 一个或多个IR 引脚被触发中断(脉冲或者边缘),若对应的中断没有被屏蔽(IMR(x)=0),IRR(X)=1
- PR找出最高优先级,8259通过INTR 管脚发送INT信号通知CPU 或上一级8259中断发生。
- CPU 通过INTA 引脚响应8259,表示中断请求收到, 8259则将IRR 中具有最高优先级位 清零,并设置ISR 中对应的位(置1)。在CPU发出中断查询脉冲后,8259将中断号提交到数据总线上。
- 中断号对应的中断向量处理程序被调用,OS或者BIOS中断处理程序开始处理中断,完毕后写EOI并扫尾。手动模式要在中断处理程序中向8259发送EOI,自动模式则是8259接受到第二个中断信号时将ISR(X)=0
- 在处理中断过程中,8259A收到优先级更高的信号,则会将旧中断再次放回IRR中等待,重复上述步骤执行心新中断。
4:实现打印功能的中断
整体流程表:
4.1:kernel.S(主要部分)
/*from kernel.S*/
global intr_entry_table ;声明全局变量 中断处理函数的表
intr_entry_table:
%macro vector 2 ;宏定义
section .text
intr%1entry: ;一个中断入口
;处理过程
%2 ;nop或者push 0
push intr_str ;需要打印的字符串地址
call print_string
add esp,4
;手动发送结束EOI(OCW2操作控制字)
mov al,0x20 ;0010_0000,普通EOI结束方式
out 0xa0,al ;向从片发送
out 0x20,al ;向主片发送
add esp,4 ;跳过错误码或者0(重要)
iret ;获取栈中cs:ip地址返回
section .data
dd intr%1entry
%endmacro
vector 0x00,ZERO
...
vector 0x20,ZERO
为什么会说intr_entry_table
是中断处理函数的表呢?
我们看通过宏定义将标准的中断处理函数封装起来,23-25行为定义了33个中断处理函数,在编译后,会被拼接在一起就抽象成了一个表结构,看下图:
中断处理程序有几个重要的点
- 需要将处理程序的入口地址记录下来,用于中断门描述符的建立,这里是通过
intr%1entry
的符号地址来表示 - 错误码的处理,由于有些中断会自动将错误码压栈,有些则不会,为了统一程序处理标准,对不压栈的写入0错误码
- OCW2的操作,手动发送EOI来结束中断
最后一个是要清楚上面的中断号有什么意义:我们最后会在IDT中存储33个中断,其实中断号0~31已经提前被cpu设置占用,所以8259A主片开始中断号是0x20(对应的是时钟中断),那cpu是如何查到IDT中对应该中断号的描述符呢?现在先说一下,**描述符地址是IDT的地址+中断号*8**
之前没有定义33个中断,就导致没有中断处理程序发生,因为0x20中断号没有对应的处理程序啊。
4.2:interrupt.c
/*from interrupt.C*/
//设置中断门描述符
struct gate_desc
{
uint16_t offset_low_word;
uint16_t selector; //代码段选择子
uint8_t dcount; //固定字段
uint8_t attribute; //描述符的属性
uint16_t offset_high_word;
}
static struct gate_desc IDT[IDT_DESC_COUNT];
根据下图可以定义中断门描述符的结构体
/*from interrupt.C*/
extern void* intr_entry_table[IDT_DESC_COUNT]; //外部声明中断处理程序入口地址表
//参数分别为指向门描述符地址(写入地址)的指针,描述符的属性,中断处理程序的地址
static void make_idt_desc(struct gate_desc *desc, uint8_t attr, void* intr_handler_addr)
{
//按位与--->把地址变为整型是必须的
desc->offset_low_word = (uint32_t)intr_handler_addr & 0x0000ffff;
desc->selector = selector_code;
desc->dcount = 0;
desc->attribute = attr;
desc->offset_high_word = ( (uint32_t)intr_handler_addr & 0xffff0000) >> 16;
}
//初始IDT的描述符表
void idt_desc_init()
{
int i;
for(i = 0; i < IDT_DESC_COUNT; ++i){
make_idt_desc(&IDT[i], intr_idt_DPL0, intr_entry_table[i]);
}
print_string("idt init success\n");
}
void* intr_handler_addr
无类型指针存储的是intr_entry_table的地址,如果加了&的话,intr_handler_addr
存储的就是指针地址了。
15-24行:循环将所有的中断门描述符顺序写入IDT中
4.3:PIC初始化
端口读写的内嵌函数
这函数不是普通的调用函数,特殊点在于**在头文件中直接定义内嵌函数,以此实现使用的该函数CPU不需要常规的call指令操作,速度起飞~ **。
拿向端口写入一字节的函数举例static inline void outb(uint16_t port, uint8_t data)
- static静态结构,表示函数只作用于调用的文件(会自动copy一份)。内联函数一般都写成
static inline
原因是内联函数一般写在.h文件中,这个文件的原则其实是不写函数而只写函数名,现在写了函数了,很多.c文件可能都会包含这个.h文件,如果不写static,编译的时候就会在链接过程中编译不过,其实意思就是同一个工程中不能有相同的文件名。(可以试下) - inline内嵌函数:调用内嵌函数不需要常规的函数操作(函数调用、返回时现场保护和恢复工作),因为拒绝了编译器优化,所以在函数的调用处,原封不动的展开,这样编译后的代码运行速度就会快很多。
知道了为何要使用内嵌函数后,我们来实现。
/*io.h*/
/*将1字节的数据写入端口port中*/
static inline void outb(uint16_t port, uint8_t data)
{
asm volatile("outb %b0, %w1": :"a"(data), "Nd"(port));
//%b0-1字节al %w1-2字节长但是“Nd”限制在立即数0~255范围内的dx
}
/*将 addr 处起始的 word_cnt 个字写入端口 port */
static inline void outsw(uint16_t port, void* addr,uint32_t word_cnt)
{
asm volatile("cld; rep outsw" :"+S"(addr), "+C"(word_cnt): "d"(port));
//cld 正方向即每次执行完outsw,esi+2;+代表该寄存器可以被写入和读出
//ds:esi->port 注意是大写的S
}
”+S“的寄存器约束,指的是esi既能被写入,也能被读出,由于字符串复制,首先 esi=addr
,esi被写入;接着每复制一个字,esi = esi+2
,addr=esi
,下一轮再 esi=addr
."+C"也是一样。
内嵌函数采用的是不同于intel汇编的AT&T格式,目的操作数在后面(mov A,B等价于A->B)
接下来就是8259A初始化了,需要用到上面的端口读写函数
/*from interrupt.C*/
//最基本的8259A初始化
static void PIC_init()
{
//初始化主片
outb(PIC_M_CTRL,0x11); //ICW1:级联,需要ICW3,ICW4
outb(PIC_M_DATA,0x20); //ICW2:要求中断号IRQ从32开始
outb(PIC_M_DATA,0x04); //ICW3:IRQ2连接从片
outb(PIC_M_DATA,0x01); //ICW4:手动发送EOI,置1位表示x86模式
//初始化从片
outb(PIC_S_CTRL,0x11); //ICW1:级联,需要ICW3,ICW4
outb(PIC_S_DATA,0x28); //ICW2:要求从片中断号IRQ从40开始
outb(PIC_S_DATA,0x02); //ICW3:写入从片连接到主片的哪个IRQX,X的值(不是哪一位)
outb(PIC_S_DATA,0x01); //ICW4:手动发送EOI,置1位表示x86模式
//初始化OCW1,实际上是修改IMR寄存器控制位,除了时钟中断(IRQ0)都先屏蔽
//OCW2在中断处理程序,用于手动发送EOI(OCW3没用上)
outb(PIC_M_DATA,0xfe);
outb(PIC_S_DATA,0xff);
print_string("PIC init success\n");
}
关于8259A如何初始化的内容在真相还原书上有具体说明,太懒了不想写了。
4.4:中断初始化
//中断初始化
void idt_init()
{
idt_desc_init(); //将中断描述符放入IDT中
PIC_init(); //8259A的初始化
print_string("intr init success\n");
/*加载idtr*/
uint64_t idt_desc = ( (sizeof(IDT)-1) | ( (uint64_t)(uint32_t)IDT << 16));
asm volatile("lidt %0": :"m"(idt_desc));
print_string("load idtr success\n");
}
前面两个函数都已经讲到了。重点是要加载IDTR了,加载需要一个内存地址,存储48位数据,低16位为idt的limit,高32位为idt的地址
第9行:按照规则,IDT的32位地址只能先转化为32位的整数,由于左移16位,高16位可能会丢失数据,所以再转化为64位左移(注意是按位或,逻辑或只有两个真值结果0,1)
第11行:”m“是内存约束,把idt_desc变量的内存地址作为操作数,指令访问该地址,获取idt_desc的值
补充:AT&T中内存地址是最高级,任何数字都被当成内存地址(所以立即数要加上$)
而intel汇编中,立即数是最高级,数字默认就是立即数,所以要表示成内存地址需要显式的加上[]
5:完成调用及调试运行
/*from init.C*/
#include "interrupt.h" //包含中断的初始化函数
#include "print.h" //打印函数包含print_string
void init_all(void)
{
print_string("init all\n");
idt_init();
}
/*from main.C*/
#include "print.h"
#include "init.h"
void main(void)
{
print_string("hhh kernel\n");
init_all();
asm volatile("sti"); //开放中断
while(1);
}
运行
开放中断之后(if->IF)
IDT表的情况:
IDTR加载情况:
中断目前无法捕捉(之后会了再解决),但是可以使用show extint
指令,当中断发生时会有提示
查看IDT的0x20号中断处理程序的地址为0008 : c0001b65
,中断提示入口地址为0008 : c0001b67
,跳过了push 0
的操作(暂时没发现原因)
但在验证过程中看到了一个问题(与本章无关)
push 0x00的机器码是6a00,但访问存放该指令的地址时,却显示006a。
经过一番调查,由于intel硬件采用的是小字节序,即低字节存放在低地址。所以bochs中6a00是按照从低到高的地址顺序显示的,6a对应低字节。正如我们所知的MBR需要用0xaa55结束,在hexviwer中看到的顺序时0x55,0xaa一样
6:改进
中断处理程序日后会越来越复杂,用汇编写显然是很难受的,但c语言写还要保证调用程序上下文不被破坏,所以还是需要汇编做准备和收尾工作,但可以调用c语言写的具体处理程序。
6.1:kernel.S
extern idt_table ;存放目前中断处理函数的地址
%macro VECTOR 2 ;宏定义
section .text
intr%1entry: ;一个中断入口
;处理过程
%2 ;nop或者push 0
;cpu自动压入错误码的话就不处理,反之压入0
;保护上下文
push ds
push es
push fs
push gs
pushad
;设置成中断结束后向8259A发送EOI(OCW2操作控制字)
mov al,0x20 ;0010_0000,普通EOI结束方式
out 0xa0,al ;向从片发送(那么怎么知道是哪个从片呢)
out 0x20,al ;向主片发送
push %1 ;用于异常调试 知道中断号打印异常名称intr_name[]
call [idt_table+%1*4] ;跳转到C程中执行处理程序
add esp,4 ;跳过错误码或者0(重要)
popad
pop gs
pop fs
pop es
pop ds
add esp,4
iret ;获取栈中cs:ip地址返回
;interrupt ret专门用于中断返回,iret包含iretw(16b)和ired(32b),只用iret就根据伪指令BITS的大小
section .data
dd intr%1entry
%endmacro
除了外部声明了一个中断处理程序数组外,修改部分都在宏定义内,过程主要是
- 压入错误码(具体看注释)
- 调用c程前将段寄存器和8个通用寄存器压栈
- 设置结束方式和中断优先级
- 压入中断号
- 调用c程:表中每个地址都为32位(4字节),所以
[idt_table+中断号*4]
即可访问 - 跳过错误码,弹栈,跳过中断号
- 中断返回
6.2:interrupt.C
char* intr_name[IDT_DESC_COUNT]; //异常名
void* idt_table[IDT_DESC_COUNT]; //中断处理程序数组表,记录32位地址
//默认中断处理程序
//
static void general_handler(uint8_t intr_count)
{
if(intr_count == 0x27 || intr_count == 0x2f) //不处理委中断
return;
print_string("interrupt occur\n");
return;
}
//初始化中断处理程序表 先将所有处理函数程序默认设置,再给20个异常赋予正确名称
//
static void handler_idt_table_init()
{
int i;
for(i = 0; i < IDT_DESC_COUNT; i++){
idt_table[i] = general_handler;
intr_name[i] = "intr_default_name";
}
intr_name[0] = "#DE Divide Error";
intr_name[1] = "#DB Debug Exception";
......//省略其他异常名
intr_name[19] = "#XF SIMD Floating-Point Exception";
print_string("handler_idt_table_init success\n");
}
kernel.S中的intr_entry_table[i]
还是中断门描述符中中断处理程序入口地址,但实际的具体处理程序地址是在
idt_table
中。
第6-12行:默认中断处理程序,idt_table初始化是存储这个程序的地址的,之后有自定义的中断,再修改这个数组。
第16-28行:循环设置IDT_DESC_COUNT
个默认中断名,再给其中的20个异常名设置规定名称,这些异常是cpu自主提供的,主要是为了方便调试找错。
0-19是异常,20-31是保留项,32-255是外部设备的可屏蔽中断(可以自定义)。
7:时钟 定时器8253
时钟表示的是设备运行的频率,工作节拍
内部时钟:由晶体振荡器产生,内部硬件固定设置好的,无法改变(ns级别)
外频:由内部时钟频率经过分频就是主板的外频,用于CPU和南北桥的通信
主频:外频×倍频,处理器取指令、执行指令的频率
外部时钟:CPU与外设(接在南桥上)之间通信的时钟(ms或s级别)
要处理外部和内部两个不同时钟的设备能够同步通信,有两个方法:
- 在软件上设定循环计时器,但白白占用CPU运算资源,不采用
- 在硬件上可以用到定时器来给内部时钟分频产生外部时钟,独立于CPU运行,提升cpu资源利用率,采用
7.1:编写8253控制字
8253控制寄存器的端口为0x43,是一个8位寄存器
SC1和SC0是选择计数器,8253内部有3个独立计数器,有各自的控制模式。而低6位的设置都是针对高2位选择的计数器而言的。
RW1和RW0组成了4个读写方式,设置待操作计数器(通道)的读写及锁存方式,。
工作方式如下图:
#define IRQ0_frequency 1000 //外部时钟中断期望达到的频率
#define TIMER_SELF_FREQUENCY 1193180//计数器硬件的内部时钟频率
#define COUNT_VALUE TIMER_SELF_FREQUENCY / IRQ0_frequency //计数器初值
//8253控制字
#define TIMER_CTRL_PORT 0x43 //控制寄存器写入端口
#define TIMER_TYPE 0 //选择0号计数器
#define DATA_PORT 0x40 //0号计数器初值寄存器的端口
#define READ_WRITE_LOCK 3 //读写方式,先读写低字节,再高字节
#define TIMER_WORK_MODE 2 //工作方式为2,比率发生器用于分频
上述是模式控制寄存器需要的相关量
static void timer_ctrl_set(uint8_t timer_ctrl_port, uint8_t timer_type,\
uint8_t data_port, uint8_t read_write_lock, \
uint8_t timer_work_mode, uint16_t count_value)
{
//向0x43端口写入8253控制寄存器
outb(timer_ctrl_port,(uint8_t)(timer_type<<6+read_write_lock<<4+timer_work_mode<<1));
//向0x40端口写入计数器初值的低8位
outb(data_port,(uint8_t)count_value);
//向0x40端口写入计数器初值的高8位
outb(data_port,(uint8_t)(count_value >>8 ));
}
void timer_init()
{
print_string("timer init start\n");
timer_ctrl_set(TIMER_CTRL_PORT, TIMER_TYPE,\
DATA_PORT, READ_WRITE_LOCK,\
TIMER_WORK_MODE, COUNT_VALUE);
print_string("timer init success\n");
}
第6行:将选择哪个计数器,读写方式,工作模式,数值形式(默认二进制)根据上图po出的控制寄存器格式填入
第9、11行:写入计数器初值,COUNT_VALUE可以随便调,不超过65535就可以,根据内部时钟频率/期望的外部时钟频率
公式,可得到计数器初值。
1193180即1s发出1193180次脉冲信号,每发出一个内部脉冲信号,计数器就减1,1s内会减1193180次1,计数器为0时,计数器就发出一个输出信号,此输出信号用于向处理器发出时钟中断信号;而外部时钟期望频率1000即1s发出1000次时钟中断信号,那么计数器为1193,每1193次减1,外部向处理器发出一个时钟中断。
通过计数器将高频率的内频进行分频输出低频率的外频
要想加快时钟中断的频率,就降低计数器初值。默认频率是18.206HZ,则1秒触发约18个中断,我要1秒触发1000个中断,这里设置为期望的频率为1000(计数器初值为1193)即可。
未修改时钟频率:
修改时钟频率为1000HZ后:
图片前面的数字表示程序运行到这里总共执行的指令数,我们姑且将cpu处理指令的速度看作是相同的
未修改的两个时钟中断间隔平均219702个指令,修改过的间隔平均4000个指令,差不多有54.9倍,1000/18=55.5倍,符合程序设计的期望。