文章 35
评论 44
浏览 88268
[7]了解中断的实现——逐步深入底层

[7]了解中断的实现——逐步深入底层

中断

1:中断概述

并发和并行的区别:

  1. 单核cpu-并发:单位时间内的积累工作量,如在一秒内cpu处理了100个请求量
  2. 多核cpu-并行:真正同时进行的工作量,如在任意瞬间cpu正在同时处理100个请求量

系统有了中断,才能够并发运行,能够不断的切换进程。

操作系统是中断驱动的,本身是一个死循环while(1){ 操作系统代码 },所以系统是在循环中等待事件的发生,被动的调用资源去处理,而事件是由中断去通知系统的的。

2:中断类型

外部中断:由外部硬件发出的中断信号,再细分有可屏蔽中断和不可屏蔽中断

内部中断:内部发出,再细分有软中断和异常

补充:软中断int3断点调试指令,常用在GDB和Bochs调试器,具体的原理是

  1. 父进程(调试器)fork了一个子进程,来运行被调试的程序,当用户在调试器(Bochs举例)上输入b 0xXXXXXXXX指令时,父进程会将该地址的指令的第一个字节备份,再用0xcc(int3的机器指令)替换
  2. 这样再输入c运行,子进程运行到断点处就会触发3号中断,进行中断处理程序,但在此之前,还需保存当前的寄存器和栈的状态,所以用户查看寄存器和栈都是在栈中的值。
  3. 当继续运行调试程序即输入n,父进程又会把原先备份替换回去,并把寄存器和栈恢复,修改返回地址为断点地址,用iret退出中断。

2.1:处理器的特权级检查:

中断是通过中断向量号通知处理器的,所以不涉及RPL(如代码段描述符需要用段选择子来通知)。

(a):如果是软中断int n,int3,into等,这些是用户进程主动引起的,

数值上要求目标代码段DPL <= CPL <= 门描述符DPL,为了避免特权级为3的用户进程主动调用某些只用于内核的例程(设置了门槛下限

(b):如果是外部中断、异常,这些是进程被动引发的,所以不需要考虑上面的情况

数值上要求目标代码段DPL <= CPL

2.2:IF位

eflags寄存器中开关中断的标志位,仅限于限制外部设备的中断。

调用中断门需要关闭,陷阱门和任务门不用

  1. 由于中断门要避免中断嵌套,即处理过程中又调用中断,会触发一般保护性异常;
  2. 处理器允许陷阱门嵌套优先级更高的中断,任务门必须要嵌套才能实现多任务并发

改变IF位的方式采用cli和sti专门的指令:由于通过pushf压栈的方式涉及到内存的访问,可以拆分成多步骤,不满足原子性要求

3:可编程中断控制器(PIC)8259A

3.1:引入原因

任务是串行在CPU执行的,每次只能执行一个任务,但让CPU来维护一个任务队列造成资源浪费,所以引入一个中介代理=中断控制器。
目前集成在南桥芯片上

往期博客 [操作系统真象还原3]cpu的8086实模式、显卡、硬盘

3.2:主要作用

管理和控制可屏蔽中断,通过编程的方式实现

  1. 屏蔽外设中断,对它们实行优先级判决
  2. 向 CPU 提供 中断向量号

3.3:级联

Intel处理器支持256个中断,一个8259A支持8个中断,所以每片引出一个引脚接到另一片上构成级联结构。
n片PIC支持7n+1个中断源 。级联结构的PICs包含一个主片,n-1个从片,所有中断只能由主片发送给CPU。

如交换机和集线器都用这种结构做扩展。

级联交换机原理:交换机上通向网关的接口是单独的,下级交换机必须用该接 口通过网线接在核心交换机的某个普通网卡接口上

在这里插入图片描述

说明:

  1. INTR是发送可屏蔽中断信号,NMI是发送不可屏蔽中断,不受处理器内部中断允许标志控制,优先级大于INTR
  2. IRQx是每个外部设备提供的一个中断源的发出的中断请求信号。开机时IRQx被分配给该接口上的外部设备。
  3. IRQ2是从片发出中断请求信号的接收口

3.4: 8259A内部一些信号和寄存器:

  1. IMR:Interrupt Mask Register,中断屏蔽寄存器,宽度是8位(下同),用来屏蔽某个外设的中断
  2. IRR:Interrupt Request Register中断请求寄存器,待处理中断队列,将IMR中未被丢弃的中断信号入队
  3. PR:Priority Resolver ,优先级仲裁器 ,从IRR中找出优先级最大的中断,一般中断号越低级别越高
  4. ISR:In-Servi Register ,中断服务寄存器,保存CPU正在处理的中断号
  5. INTA:INT Acknowledge ,中断响应信号,位于CPU来回复PlC一个中断响应信号。
  6. INT:8259A 选出优先级最高的中断请求后,发信号通知CPU

寄存器的宽度为8位:类似于位图的索引方式,每一位对应一个IRQx的状态

说明:IMR(x)表示IMR寄存器的第x位的状态。

那么8259A是如何响应中断信号的呢,流程如下:

  1. 一个或多个IR 引脚被触发中断(脉冲或者边缘),若对应的中断没有被屏蔽(IMR(x)=0),IRR(X)=1
  2. PR找出最高优先级,8259通过INTR 管脚发送INT信号通知CPU 或上一级8259中断发生。
  3. CPU 通过INTA 引脚响应8259,表示中断请求收到, 8259则将IRR 中具有最高优先级位 清零,并设置ISR 中对应的位(置1)。在CPU发出中断查询脉冲后,8259将中断号提交到数据总线上。
  4. 中断号对应的中断向量处理程序被调用,OS或者BIOS中断处理程序开始处理中断,完毕后写EOI并扫尾。手动模式要在中断处理程序中向8259发送EOI,自动模式则是8259接受到第二个中断信号时将ISR(X)=0
  5. 在处理中断过程中,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

        ;手动发送结束EOIOCW2操作控制字
        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个中断处理函数,在编译后,会被拼接在一起就抽象成了一个表结构,看下图:

在这里插入图片描述

中断处理程序有几个重要的点

  1. 需要将处理程序的入口地址记录下来,用于中断门描述符的建立,这里是通过intr%1entry的符号地址来表示
  2. 错误码的处理,由于有些中断会自动将错误码压栈,有些则不会,为了统一程序处理标准,对不压栈的写入0错误码
  3. 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)

  1. static静态结构,表示函数只作用于调用的文件(会自动copy一份)。内联函数一般都写成static inline原因是内联函数一般写在.h文件中,这个文件的原则其实是不写函数而只写函数名,现在写了函数了,很多.c文件可能都会包含这个.h文件,如果不写static,编译的时候就会在链接过程中编译不过,其实意思就是同一个工程中不能有相同的文件名。(可以试下)
  2. 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+2addr=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发送EOIOCW2操作控制字
    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

除了外部声明了一个中断处理程序数组外,修改部分都在宏定义内,过程主要是

  1. 压入错误码(具体看注释)
  2. 调用c程前将段寄存器和8个通用寄存器压栈
  3. 设置结束方式和中断优先级
  4. 压入中断号
  5. 调用c程:表中每个地址都为32位(4字节),所以[idt_table+中断号*4]即可访问
  6. 跳过错误码,弹栈,跳过中断号
  7. 中断返回

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级别)

要处理外部和内部两个不同时钟的设备能够同步通信,有两个方法:

  1. 在软件上设定循环计时器,但白白占用CPU运算资源,不采用
  2. 在硬件上可以用到定时器来给内部时钟分频产生外部时钟,独立于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倍,符合程序设计的期望。


标题:[7]了解中断的实现——逐步深入底层
作者:abandon
地址:HTTPS://www.songsci.com/articles/2021/07/20/1645758881827.html

Life Is Like A Boat

取消