从0开始写内核(七)中断

参考书籍:操作系统真相还原

源码:https://github.com/wutiaojian000/AFKernel.git
本文地址:https://www.cnblogs.com/angel-fish/p/18900397

1. 中断分类

1.1 外部中断

外部中断通过INTR和NMI两根信号线通知CPU,如下图所示,

INTR收到的中断不影响系统运行,NMI则代表一些很严重的错误,必须处理。
再讲讲可屏蔽中断,可屏蔽中断通过INTR进入CPU,由eflags寄存器的IF位控制是否将这些设备屏蔽。这些中断进入CPU后,会调用到对应的中断处理例程,在linux将中断处理分为上下部,把需要立即执行的很紧急的部分归到上半部中,不紧急的归到下半部中。
当然还有不可屏蔽中断,这种中断就走的是NMI,代表系统发生致命错误,系统不能再运行了。

1.2 内部中断

内部中断分为软中断和异常。

1.2.1软中断

软中断就是软件主动发起的异常,可以是,
(1)int 8位立即数,用它来实现系统调用;
(2)int3是调试断点指令,触发的中断向量号是3,调试的原理在于调试器fork了一个子进程,父进程在子进程需要调试的指令位置替换成int3指令,子进程运行到int3时会触发中断;
(3)into,是中断溢出指令,触发的中断向量号是4,eflags的OF位是1时才能引发into中断;
(4)bound,是检查数组索引越界指令,中断向量号是5,指令格式是bound 16/32位寄存器,16/32位内存,寄存器存待检查的下标,内存里存数组下标的下边界和上边界。
(5)ud2,未定义指令,表示当前指令无效,触发的中断向量号是6。
除了第一种int 8位立即数以外,剩下的也可以称为异常。

1.2.2 异常

异常是由CPU内部产生的错误引起的,由于是运行时错误,异常也不受eflags的IF位影响。NMI也是,所以说只要是关系到系统正常运行的就都不受IF的影响。
异常按照轻重程度可以分为以下三种:
(1)fault,也叫故障,还可以被修复的一种类型。之后调用中断处理程序时,将返回地址还指向原来导致fault的那条指令,比如说缺页异常,返回后往往指令就正常了;
(2)trap,也叫陷阱。比如上面的int3;
(3)abort,也叫终止,最严重的一类异常。出现这类异常,操作系统只能将程序杀掉。

向量号就是上表中的vector No.
异常和不可屏蔽中断中断号由CPU提供,来自外部设备的可屏蔽中断号是由中断代理提供的(后面会说8259A),软中断是由软件提供的。

2. 中断描述符表

以前有提到过,中断描述符表中可以存放中断门、陷阱门和任务门描述符。


(1)任务门
任务门和TSS提供任务切换机制,任务门中存放TSS选择子,没有偏移。任务门可以存放在gdt、ldt和idt。很多操作系统都不用任务门来切换任务;
(2)中断门
中断门中包含中断服务例程所在段的段选择子和段内偏移地址。用这个方法进入中断时,eflags的IF位自动清0,自动把中断关闭,避免中断嵌套,中断门只存在于idt中;
(3)陷阱门
陷阱门和中断门类似,但陷入陷阱后不会把IF清0,陷阱门也是只存在于idt中;
(4)调用门
上一章详细讲解过了。
我们会把精力放在中断门上。
中断向量表IVT放在实模式下低1MB的开头0-3ff位置,每个中断向量用4字节描述一共256个中断向量。而中断描述符表IDT位置随意,每个描述符由8字节组成。类似gdtr,idt也有一个idtr指向idt,高32位是idt的基地址,低16为表界限。

2.1 中断处理过程及保护

完整中断过程分为两部分,CPU外和CPU内,CPU外的部分在讲intel 8259A的时候会介绍,这里介绍CPU内的。
(1)处理器根据中断向量号定位描述符
(2)处理器进行特权级检查,中断向量号只是一个整数,没有rpl,所以只检查CPL必须在门描述符的DPL和目标代码段的DPL之间,像上一章介绍的调用门一样。
软中断int n、int3、into等由用户进程主动发起的中断,检查目标段DPL<当前CPL<=门描述符DPL;若是外部设备或异常引起的,只检查当前CPL和目标段DPL,DPL<当前CPL。
(3)执行中断处理程序。
从中断中返回的指令是iret,会根据特权级是否改变决定是否恢复旧栈。
CPU有专门控制IF位的指令,cli使IF清0,关中断,sli使IF清0,开中断。进入中断前还要把NT位和TF位清0。TF是陷阱标志位,为0时表示禁止单步执行,进入中断后不允许程序单步调试。NT是任务嵌套标志位,为1代表该任务是嵌套执行的,需要返回到上一个任务;为0表示是在中断环境下,应该从中断中返回。

2.2 中断发生时的压栈

和之前调用门是很类似的,有那么几个地方不同。新栈会压入eflags寄存器,CS、EIP寄存器,异常可能还有错误码,用于报告异常是在哪个段上发生的,也要入栈。中断用iret返回,返回时弹出eflags寄存器,CS、EIP寄存器,如果有错误码则需要手动弹出。如果CPU发现特权级发生变化,还会把ESP、SS弹出。

iret同类指令还有iretw、iretd,iretw是16位的,iretd是32位的,iret可以根据BITS的指示决定用iretd还是iretw。

2.3 中断错误码


EXT表示中断源是否来自处理器外部,即外部中断,1就是是;IDT表示选择子是否指向idt,1就是是,否则指向gdt或ldt;TI还是原来的意思。3-15位保存表中的索引。中断错误码可以全0,表示与特定的段无关,或引用了一个空描述符。通常产生中断错误码的是中断向量号在0-32之内的异常,外部中断和int软中断不会产生错误码,通常也不处理错误码。

3. 可编程中断控制器8259A

intel支持256个中断,8259A只支持管理8个中断,需要串联9个8259A(7*9+1)。8259A上有8个中断请求线IRQ,用下面的方法串联起来,只能有一个8259A连接到CPU的INTR上,

内部工作原理如下,

IMR用于屏蔽中断,未被屏蔽的中断进入IRR中排队,IRQ接口号越低优先级越高。由PR判定优先级,选取最高的送入INT接口,CPU会在INTA响应,响应后将对应的中断请求放入ISR并清空IRR中中断请求的对应位。此后CPU还会再发一次INTA信号用于获取中断向量号,就是0-255的整数,起始中断向量号是可以被修改的。如果8259A的EOI被设置成非自动模式,中断处理程序结束时必须向8259A发送EOI代码,用于把ISR对应为清0;设置成自动模式则在收到第二次INTA信号时就将ISR对应为清0.进入ISR的中断还有可能被优先级更高的中断替换下来,这时候会把被替换的中断请求从ISR中移出到IRR中。

3.1 8259A编程

8259A内部有两组寄存器,一组是初始化命令寄存器ICW,另一组是操作命令寄存器OCW。ICW有4个,ICW1-ICW4;OCW有3个,OCW1-OCW3。对8259A编程也分为初始化和操作两部分。
(1)ICW1

ICW1用于初始化8259A的连接方式和中断信号的触发方式,单片或多片级联、电平触发或边沿触发。ICW1需要写入主片的0x20端口和从片的0xa0端口。
IC4表示需要再后面写入ICW4,x86必须置1;
SNGL表示single,1为单片,0为级联,不过还涉及到主片和从片通过哪个IRQ相连;
ADI用来设置8085的调用间隔,x86不需要;
LTIM用来设置中断检测方式,0表示边沿触发,1表示电平触发;
第4位的1是固定的;
5-7为用于8085处理器,x86不需要。
(2)ICW2
ICW2用来设置起始的中断向量号,需要写到主片的0x21和从片的0xa1端口。

低三位ID0-ID2不用管,只要填T3-T7即可,因为一片8个IRQ,低三位直接对应这8个IRQ,整个ICW2一起表示IRQ实际分配的中断向量号。
(3)ICW3
ICW3在级联的情况下需要,对于主片,接外部设备的IRQ对应位上为0,接从片为1,比如主片IRQ2和IRQ5接有从片,则ICW3为0x00100100,

对于从片,ICW3只有低三位有效,用于指定主片上那个IRQ用于连接自己。在中断响应时,主片发送与从片做级联的IRQ号,从片将这个与ICW3低三位作对比,一样的话就是发给自己的。高5位为0。
ICW3需要写到主片的0x21和从片的0xa1端口。
(4)ICW4

SFNM表示特殊全嵌套模式,0表示全嵌套模式,1表示特殊全嵌套模式;
BUF表示8259A是否工作在缓冲模式,0是非缓冲模式,1是缓冲模式;
多个8259A级联时,M/S用于表示该芯片是主片还是从片,1为主片,0为从片。工作在非缓冲模式下M/S无效;
AEOI表示自动结束中断8259A在接收到下一个中断结束信号时才能处理下一个中断,这一位用于让8259A自动结束中断。AEOI为0表示非自动,为1表示自动结束中断,在中断处理函数中或别的地方手动向8259A主、从片发送EOI信号(end of interrupt),8259A就会结束当前中断;
μPM是为了兼容老处理器,0表示8080或8085,1表示x86。
ICW3需要写到主片的0x21和从片的0xa1端口。

接下来介绍OCW。
(1)OCW1
OCW1用于屏蔽连接在8259A上的外部设备中断信号,就是把OCW1写入了IMR寄存器。OCW1需要写到主片的0x21或从片的0xa1端口。

(2)OCW2
OCW2用于设置中断结束方式和优先级模式,OCW2需要写到主片的0x20或从片的0xa0端口。

SL可以针对某个特定优先级的中断进行操作,以下针对优先级的模式设置和中断结束都可以基于SL做细粒度的控制,
OCW2可以用作发EOI信号结束中断,EOI为1时,SL为1用OCW2的低三位指定位于ISR的那一个中断被结束;SL为0时低三位不起作用,8259A自动结束当前处理的中断,将ISR对应位清0。需要注意中断如果来自主片,向主片发送EOI即可,来自从片的话还需要向从片发EOI,还有EOI是手动结束中断的方法,需要ICW4的AEOI为0也就是非自动;
OCW2还可以设置优先级控制方式,通过R来控制,R为0表示固定优先级方式,即IRQ接口号越低优先级越高;为1表示循环优先级,SL为0时初始优先级是IRQ0>IRQ1>IRQ2>IRQ3>IRQ4>IRQ5>IRQ6>IRQ7,当前中断处理完毕后,对应IRQ优先级降到最低,将原来的优先级传递给之前较第一级中断请求。比如当前IRQ3是最高优先级中断请求,中断响应后,优先级变为IRQ4>IRQ5>IRQ6>IRQ7>IRQ0>IRQ1>IRQ2>IRQ3。SL为1时用低三位指定最低优先级。

(3)OCW3
用不上不看了。

还有一点,就是ICW1、OCW2、OCW3从主片0x20从片0xa0(偶数端口)写入,ICW2-ICW4、OCW1从主片0x21从片0xa1(奇数端口)写入,如何确定是哪一个控制字呢?4个ICW需要满足一定次序写入,这样能确定是哪一个ICW,OCW1是在初始化后有效的,所以初始化后奇数端口写入的数据就是OCW1;至于偶数端口,

总结一下写入步骤:
(1)顺序写入ICW1和ICW2;
(2)需要级联时,向主片和从片写入ICW3;
(3)x86系统需要写入ICW4。

3.2 编写中断处理程序

先写一个时钟中断。

函数名写的很明白。
中断服务程序

;src/kernel/kernel.S
[bits 32]
%define ERROR_CODE nop  ;相关异常中CPU压入了异常码,这里不做操作
%define ZERO push 0     ;相关异常中CPU没有压入异常码,为了同一栈中格式需要压入0

extern  put_str;

section .data
intr_str db "interrupt occur!\n", 0xa, 0
global intr_entry_table
intr_entry_table:

%macro VECTOR 2
section .text
intr%1entry:  ;%1代表第一个参数

    %2        ;push 0或nop
    push intr_str
    call put_str
    add esp, 4

    ;如果是从片上发的中断,除了向从片上发EOI,还要向主片上发EOI
    mov al, 0x20
    out 0xa0, al
    out 0x20, al

    add esp, 4  ;跨过error code
    iret        ;从中断返回,32位下等同于iretd

section .data
    dd intr%1entry  ;存放各个中断服务程序的地址

%endmacro

VECTOR 0x00, ZERO
VECTOR 0x01, ZERO
VECTOR 0x02, ZERO
VECTOR 0x03, ZERO
VECTOR 0x04, ZERO
VECTOR 0x05, ZERO
VECTOR 0x06, ZERO
VECTOR 0x07, ZERO
VECTOR 0x08, ZERO
VECTOR 0x09, ZERO
VECTOR 0x0a, ZERO
VECTOR 0x0b, ZERO
VECTOR 0x0c, ZERO
VECTOR 0x0d, ZERO
VECTOR 0x0e, ZERO
VECTOR 0x0f, ZERO
VECTOR 0x10, ZERO
VECTOR 0x11, ZERO
VECTOR 0x12, ZERO
VECTOR 0x13, ZERO
VECTOR 0x14, ZERO
VECTOR 0x15, ZERO
VECTOR 0x16, ZERO
VECTOR 0x17, ZERO
VECTOR 0x18, ZERO
VECTOR 0x19, ZERO
VECTOR 0x1a, ZERO
VECTOR 0x1b, ZERO
VECTOR 0x1c, ZERO
VECTOR 0x1d, ZERO
VECTOR 0x1e, ERROR_CODE
VECTOR 0x1f, ZERO
VECTOR 0x20, ZERO

看到1.2.2中的表,中断向量0-19为CPU内部固定的中断类型,20-31是intel保留的,我们能用的最低的中断向量号从32开始,也就是上面代码的最后一个中断向量。

// kernel/include/io.h
#ifndef __LIB_IO_H
#define __LIB_IO_H
#include "stdint.h"

//向端口port写入一个字节data
static inline void outb(uint16_t port, uint8_t data)
{
    asm volatie("outb %b0, %w1"::"a"(data), "Nd"(port));//N表示0-255的立即数约束
}

//向端口port写入addr为起始地址word_cnt字节数据
static inline void outsw(uint16_t port, const void *addr, uint32_t word_cnt)
{
    //outsw是把ds:esi处16位写入port端口 +S的意思是把addr约束到esi中
    asm volatie("cld;rep outsw":"+S"(addr), "+c"(word_cnt):"d"(port));//N表示0-255的立即数约束
}

//从端口port读入1字节返回
static inline uint8_t inb(uint16_t port)
{
    uint8_t data;
    asm volatie("inb %w1, %b0":"=a"(data):"Nd"(port) );
    return data;
}

//向端口port读入addr为起始地址word_cnt字节数据
static inline void insw(uint16_t port, const void *addr, uint32_t word_cnt)
{
    //insw是把es:edi处16位写入内存 +D的意思是把addr约束到edi中
    //"memory"是因为es:edi指向的内存被修改
    asm volatie("cld;rep insw":"+D"(addr), "+c"(word_cnt):"d"(port):"memory");
}

#endif

这里实现写在头文件里就是单纯图快,包括static会让函数局限在引用它的文件中,inline会嵌入展开,都是为了针对硬件的操作时会快一点。
还有一些代码,就不具体解释了,下面列出对应的源码路径,
kernel/include/global.h
kernel/include/interrupt.h
kernel/src/kernel/interrupt.c
kernel/include/PIC/8259A_ctrl.h
kernel/src/kernel/PIC/8259A_ctrl.c
我们现在的8259A级联属于是下面这个状态,

编译完成后运行出现了一些问题,objdump出来看一下

objdump -s -d out/kernel/main.bin > main.txt

发现0xc0001500处不是main函数,main函数跑到在后面去了。这是因为链接器先碰到哪个符号就先处理哪个符号了。可以用以下链接脚本+attribute((section(".text.start")))属性的方法将main函数强制放在代码段的开头。

//link.ld
ENTRY(main)

SECTIONS {
    /* 内核加载到 0xC0000000 起始的虚拟地址空间 */

    . = 0xC0001500;      //设置 .text 的起始地址

    .text : {
        *(.text.start)   //叫.text.start的自定义段,顺序上就是.text.start->.text,我们会把main函数放在这个自定义段中,main自然就在text段的开头了
        *(.text)
    }

    . = 0xC0002000;     //设置 .rodata 的起始地址

    .rodata : {
        *(.rodata)
    }

    .data : {
        *(.data)
    }

    .bss : {
        *(.bss COMMON)
    }

    /DISCARD/ : {
        *(.comment)
        *(.note.*)
    }
}
void main(void) __attribute__((section(".text.start")));//把main函数放在.text.start中

void main(void)
{
    init_all();
    asm volatile("sti");//打开中断
    while(1);
}


成功!

3.3 改进

这里实验了一下不单独处理伪中断,

发现没什么问题,不过还是需要忽略,具体原因可以参考这个https://forum.osdev.org/viewtopic.php?f=1&t=23291

4. 可编程计数器8253

硬件计数器有两种计数方式,一种是正计时,一种是倒计时。8253属于倒计时,内部有三个独立的计数器,

端口号分别为0x40-0x42。结构如上图所示,需要注意的是初值寄存器为16位宽度。三个计数器有有不同的用处,

4.1 8253控制字

控制字寄存器端口为0x43,为8位大小寄存器,用来设置指定计数器的工作方式、读写格式及数制。

这里要介绍一下上图中提到的工作方式,

  • 方式0,计数中断结束方式
  • 方式1,硬件可重触发单稳方式
  • 方式2,比率发生器
  • 方式3,方波发生器
  • 方式4,软件触发选通
  • 方式5,硬件触发选通
    计时器开始计数有两个条件,一个是Gate为高电平,是硬件控制的;一个是初值已经写入计数器的减法计数器,这是由软件out控制的。按这两个条件就分有两种启动,一个是软件启动,第一个条件已经完成,等待out写入初值,0/2/3/4工作方式都是软件启动;一个是硬件启动,第二个条件已经完成,等待Gate上升沿,1/5工作方式是硬件启动。
    计数器停止也分为两种,强制终止和自动终止。强制终止用在循环计数的工作模式里,通过破坏计数的条件即Gate置0;自动终止用于单次计数,计数值为0时自动终止。下面介绍一下6种工作模式,
    (1)方式0,计数结束中断方式
    也叫计数结束输出正跳变信号,顾名思义,注意这是软件启动。
    (2)方式1,硬件可重触发单稳方式
    硬件控制,写入初值后,OUT变为高电平,8253在Gate第一个上升沿后时钟的第一个下降沿开始计数,同时OUT变为低电平,时钟每个下降沿处减法计数器减1。计数为0时,OUT产生一个正跳变信号。
    (3)方式2,比率发生器
    就是分频器,属于软件启动。
posted @ 2025-06-02 23:03  横渡大海的神仙鱼  阅读(74)  评论(0)    收藏  举报