操作系统真象还原 学习笔记08--中断

selph
selph
发布于 2021-01-31 / 1580 阅读
0
3

操作系统真象还原 学习笔记08--中断

本篇对应书籍第七章的内容

本篇内容介绍了操作系统的中断处理机制,建立中断描述符表,填充门描述符,以及中断处理程序,初始化8259A中断控制器实现外部中断功能,控制8253定时计数器实现中断频率的提升

本篇难点:

  • 通过端口控制 8259A 芯片和 8253 芯片

本篇坑点:

  • Bochs 对芯片模拟不完整造成一些灵异错误(文内有解决方案)

中断 是什么

CPU 暂停正在运行的程序,转而去运行其他程序,处理完在回来执行刚才的程序,这个过程叫做中断处理,也叫中断。

操作系统是个死循环,在死循环的过程中,等待事情的发生,当有事情发生了,就会转而去处理这个事情,事情是通过中断来告知操作系统的。

操作系统是中断驱动的。

中断 的分类

把中断按事件来源分类,来自 CPU 外部的中断就称为外部中断,来自 CPU 内部的中断称为内部中断。

外部中断按是否导致宕机来划分,可分为可屏蔽中断和不可屏蔽中断两种。

内部中断按是否正常来划分,可分为软中断和异常。

外部中断

外部中断的中断源是硬件。外部硬件的中断通过两根信号线通知 CPU 的,分别是 INTR(INTeRrupt) 和 NMI(Non Maskable Interrupt):

  • 通过 INTR 引脚进入 CPU 的是可屏蔽中断,即使屏蔽也不会对 CPU 造成什么影响;

  • 通过 NMI 引脚进入 CPU 的是不可屏蔽中断,一般问题很大。

image-20210129015249596

在 Linux 中,可屏蔽中断分为上半部和下半部:

操作系统是中断驱动的,中断发生后会执行相应的中断处理程序,中断处理程序中需要立即执行的部分在上半部,完成中断应答或硬件复位等重要紧迫工作。中断处理程序中不紧急的部分则被推迟到下半部中去完成。上半部是在关中断的情况下执行,不可被打扰,下半部则不是。

中断发起时,相应的中断向量号通过 NMI 或 INTR 引脚被传入 CPU,中断向量号是中断向量表中断描述符表里中断项的下标,CPU 根据此中断向量号在中断向量表或中断描述符表中检索对应的中断处理程序并去执行。

中断向量表、中断描述符表中存储的是中断号以及中断号对应的处理程序的位置

内部中断

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

软中断就是由硬件主动发起的中断,因为它来自于软件,所以称之为软中断。

发起中断的指令:

  • int 8位立即数,一般用于系统调用。
  • int3,调试断点指令,其所触发的中断向量号是 3。我们用 gdb 或 bochs 调试程序时,实际上就是调试器 fork 了一个子进程,用于执行被调试的程序。调试器中经常要设置断点,其原理就是父进程修改了子进程的指令,将其用 int3 指令替换,从而子进程调用 int3 触发中断。
  • into,中断溢出指令,中断向量号是 4。
  • bound,检查数组索引越界指令,触发 5 号中断。
  • ud2,未定义指令,触发 6 号中断。

除了int 8位立即数以外的中断也可以算作是异常,异常是指令执行期间 CPU 内部产生的错误引起的,不受 eflags 里的 IF 位约束(只要中断关系到正常运行,就不受 IF 位影响)。

异常按照轻重程度可分为以下三种:

  • Fault,故障,可以被修复,例如 Linux 的虚拟内存就是基于 缺页异常 page fault 的。
  • Trap,陷阱,通常在调试中使用到。
  • Abort,终止,错误无法修复,一般是硬件出错或系统结构出错。

处理器所支持的 256 种中断:

image-20210129021316827

最左边一列就是中断向量号,类似段选择子,不过中断向量号是从中断描述符表中索引中断描述符,其中没有RPL字段,中断号来源:

  • 异常和不可屏蔽中断的中断向量号是 CPU 提供的

  • 来自外设的可屏蔽中断号由中断代理提供的

  • 软中断是由软件提供的

中断描述符表

中断描述符表(Interrupt Descriptor Table,IDT)是保护模式下用于存储中断处理程序的表,实模式下是中断向量表,他们的区别在于:

  • 中段描述符表地址不受限制,中断向量表位于 0x0~0x3ff 共1024 字节
  • 中段描述符表每个描述符 8 字节,中断向量表每个向量 4 字节

中断描述符表不仅有中断描述符,还有陷阱门、中断门等描述符,中断描述符表中的每个描述符都可以叫做,门

段描述符描述的是一段内存区域,门描述符描述的是一段代码,描述符大小都是8字节,门描述符中也有属性,门描述符都属于系统段,S 都为 0,type 不一样,重新回顾一下这几个门的作用:

  1. 任务门
    • 任务门和任务状态段 TSS 是 Intel 硬件级提供的任务切换机制,所以需要任务门配合 TSS 使用。
    • 任务门可以位于 GDT、LDT、IDT 中。
    • type值为0101
    • 任务门大多数操作系统都没使用。

image-20210127233122810


  1. 中断门

    • 中断门包含了中断处理程序所在段的段选择子和段内偏移地址(中断处理程序的地址)。当通过此方式进入中断后,标志寄存器 eflags 的 IF 位自动置零。
    • 中断门可以位于 IDT 中。
    • type值为1110

image-20210127233137181


  1. 陷阱门
    • 陷阱门和中断门非常相似,区别是由陷阱门进入中断后,标志寄存器eflags中的IF位不会自动置0。
    • 陷阱门可以位于 IDT 中。
    • type值1111

image-20210127233146601


  1. 调用门
    • 调用门是提供给用户进程进入特权0级的方式,其DPL为3。
    • 调用门可以位于 GDT 和 LDT 中。
    • type值 1100

image-20210127233154356


现代操作系统很少用到任务门和调用门,主要用到的是中断门,和陷阱门

IDT 同 GDT 一样,CPU 硬件上提供了存储其位置的寄存器,中断描述符表寄存器 IDTR:

image-20210129114748825

前 16 位是界限,后 32 位是基址,理论上可以有 64KB/8KB=8192 个描述符,但 CPU 只支持 256个

加载指令:lidt 48位内存数据

中断处理过程

完整的中断过程分为 CPU 外和 CPU 内两部分:

  • CPU 外:外部设备的中断由中断代理芯片接收,处理后将该中断的中断向量号发送到 CPU。
  • CPU 内:CPU 执行该中断向量号对应的中断处理程序。

CPU 内的过程:

  1. 处理器根据中断向量号定位中断门描述符。
  2. 处理器进行特权级检查。
    • 对于软件发起的软中断,当前特权级 CPL 必须位于 门描述符 DPL 和门中目标代码段描述符 DPL 之间(特权比门高,才能使用门,特权比处理程序低,才能使用门调用处理程序,特权转移只能从低到高进行)。
    • 对于外部设备引起的中断和异常,则只检查 CPL 和目标代码段 DPL ,CPL 权限要小于 DPL 才行。
  3. 执行中断处理程序。

image-20210129115838426

中断发生后,eflags 中的 NT 位和 TF 位会被置零,如果是中断门,则 IF 位也置零

TF 位:Trap Flag,陷阱标志位,用在调试环境中,TF 为 0 的时候,禁止单步执行

NT 位:Nest Task Flag,任务嵌套标志位,用来标记任务嵌套调用情况,用于在当前任务中中断进行新的任务,进行完之后再回来完成当前任务的场景

从中断返回的指令是 iret,从栈中弹出数据到寄存器 cs、eip、eflags 等,根据特权级是否改变决定是否要恢复旧栈

处理器提供了专门控制 IF 位的指令:

  • cli使 IF 位置 0
  • sli使 IF 位置 1

中断发生时的压栈

压栈操作如图所示,出栈则是按照压栈的反方向进行的

如果有中断错误码,处理器并不会主动跳过它的位置,必须手动将其跳过,在准备用 iret 指令返回时,当前栈指针 esp 必须指向栈中备份的 EIP_old 所在的位置。

image-20210129121244118image-20210129121453958


处理器在中断结束后返回的过程中还要进行一次特权级检查:

  1. 从 CS_old 和 EIP_old 中检查 RPL 判断是否有特权级变化
    • 如果检查通过,则更新 cs 和 eip
    • 如果没涉及特权级变化,则当前栈指针还是 esp_old,用的还是旧栈
  2. 将 eflags 弹出到标志寄存器
  3. 如果需要改变特权级,则直接恢复旧栈

中断错误码

中断错误码用来指明中断发生在哪个段上。

image-20210129122218937

  • EXT 表示外部事件,如果中断源来自不可屏蔽中断 NMI 或外部设备,EXT 为 1,否则为 0。

  • IDT 表示选择子是否指向中断描述符表 IDT,IDT 为 1 表示选择子指向中断描述符表,否则指向全局描述符表 GDT 或 局部描述符表 LDT。

  • TI 为 0 是指明选择子是从 GDT 中检索描述符,为 1 时是从 LDT 中检索描述符。

  • 高 13 位索引是在表中索引描述符的下标

通常能够压入错误码的中断属于中断向量号 0 ~ 32 之内的异常,而外部中断(32 ~ 255)和 int 软中断并不会产生错误码。

通常我们不用处理错误码。

可编程中断控制器 8259A

8259A 介绍

8259A 用于管理和控制可屏蔽中断,它表现在屏蔽外设中断,对它们实行优先级判决,向 CPU 提供中断向量号等功能。

一片 8259A 只能管理 8 个中断,通过级联芯片可以支持 7n+1 个中断源。

8259A 芯片的内部结构:

image-20210129130210959

8259A 芯片收到中断信号之后,芯片内部的中断屏蔽寄存器 IMR 会判断该信号是否屏蔽(编程控制),如果屏蔽就丢弃信号,否则送入中断请求寄存器 IRR,IRR 相当于待处理中断队列,时机成熟时,优先级判别器 PR 会从中选择优先级高的中断,通过控制电路 INT 接口向 CPU 发送 INTR 信号;

CPU 处理完成后,通过自己的 INTA 接口向 8259A 的 INTA 接口回复一个响应信号,8259A 收到信号之后,立即将中断服务寄存器 ISR 中对应刚才选择的中断的位设置为 1,表示当前正在处理该中断,同时将该中断从 IRR 队列中去掉,之后 CPU 再次发送 INTA 信号给 8259A,8259A 将中断向量号(编程控制)通过系统数据总线发给 CPU ,CPU 进行执行中断处理程序。


硬件程序是固定的,可编程指的是我们可以控制硬件程序提供的输入和输出,CPU 提供了中断处理的框架,我们只需要提供 CPU 所需要的输入即可让 CPU 自动完成工作,需要的数据是:

  • 中断描述符表
  • 中断向量号

我们只需要在外部设备中设置好中断向量号,然后在中断描述符表中设置好对应的中断处理程序即可。

外部连接的硬件也是固定的:

在这里插入图片描述

8259A 编程

8259A 内部有两组寄存器,一组是初始化命令寄存器组,用来保存初始化命令字(ICW),ICW 共 4 个,ICW1 ~ ICW4。另一组是操作命令寄存器,用来保存操作命令字(OCW),OCW 共 3 个,OCW1 ~ OCW3。

对 8259A 的编程,也分为初始化和操作两部分:

  • 一部分是用 ICW 做初始化,用来确定是否需要级联,设置起始中断向量号,设置中断结束模式。其编程就是向端口发送一系列 ICW,后面的某个设置会依赖前面 ICW 的设置,所以必须依次写入 ICW1~4
  • 另一部分是用 OCW 来操作控制 8259A,中断屏蔽和中断结束。写入顺序无所谓了

ICW1~ICW4

ICW1 用来初始化 8259A 的连接方式和中断信号的触发方式。ICW1 需要写入主片的 0x20 端口和从片的 0xA0 端口。

image-20210129134126208

  • IC4 为 1 时表示要写入 ICW4,为 0 时不需要。x86 系统必须为 1

  • SNGL 表示 single,若 SNGL 为 1,表示单片,否则表示级联,为 0 时主片和从片需要 ICW3

  • ADI 用来设置 8085 的调用时间间隔,x86 不需要设置。

  • LTIM 用来设置中断检测方式,LTIM 为 0 表示边沿触发,LTIM 为 1 表示电平触发。

  • 第 4 位的 1 是固定的,标识这是 ICW1

  • 第 5-7 位是服务 8085 CPU的,x86 不需要


ICW2 用来设置起始中断向量号。ICW2 需要写入到主片的 0x21 端口和从片的 0xA1 端口。

image-20210129134424507

只需要设置 IRQ0 的中断向量号,剩下的就会依次顺延

  • 低 3 位:不用管,会依据接口的排列自动导入
  • 高 5 位:起始中断向量号

ICW3 仅在级联的方式下才需要,用来设置主片和从片用哪个 IRQ 接口互连。ICW3 需要写入主片的 0x21 端口及从片的 0xA1 端口。

主片和从片的 ICW3 结构不同。

  • 主片上中置 1 的那一位对应的 IRQ 接口用于连接从片。

  • 从片上低三位用于表示连接到主片上的 IRQ 接口。

image-20210129134844561

image-20210129134852338


ICW4 用于设置 8259A 的工作模式,ICW3 需要写入主片的 0x21 端口及从片的 0xA1 端口。

  • SFNM 表示特殊全嵌套模式,若 SFNM 为 0,则表示全嵌套模式,为 1 则表示特殊全嵌套模式。

  • BUF 表示是否工作在缓冲模式。BUF 为 0,表示工作在非缓冲模式,BUF 为 1,表示工作在缓冲模式。

  • 如果工作在缓冲模式下,M/S 为 1 表示是主片,M/S 为 0 表示是从片。若工作在非缓冲模式下,M/S 无效。

  • AEOI 表示自动结束中断,8259A 在收到中断结束信号时才能继续处理下一个中断。若 AEOI 为 0,表示非自动。若 AEOI 为 1,表示自动结束中断。

  • μPM 表示位处理器类型。若 μPM 为 0,表示 8080 或 8085 处理器。若 μPM 为 1,表示 x86 处理器。

image-20210129135054166

OCW1~OCW3

OCW1 用来屏蔽连接在 8259A 上的外部设备的中断信号,某位为 1,对应的 IRQ 上的中断信号就屏蔽了。OCW 要写入主片的 0x21 或从片的 0xA1 端口。

image-20210129135124143


OCW2 用来设置中断结束方式和优先级模式。OCW2 写入主片的 0x20 及从片的 0xA0 端口。

OCW2 其中一个作用是发 EOI 信号结束中断。

  • SL 位针对某个特定优先级的中断进行操作。SL 为 0表示自动将正在处理的中断结束,L0-L2 无效

  • R 位 0 表示固定优先级方式,为 1 表示循环优先级方式。

  • EOI,中断结束命令位,为 1 会令 ISR 寄存器中相应位清 0

image-20210129135401173

OCW2 高位可以组合出多种不同的结束方式:

image-20210129135730516


OCW3:这里用不上

编写中断处理程序

Intel 8259A 芯片位于主板的南桥芯片上,8259A 与外设的连接是内部电路实现了的,直接操作即可使用。

开启 中断机制

流程:

  • init_all():用来初始化所有设备以及数据结构
    • ide_init():初始化中断相关内容
      • pci_init():初始化可编程控制器(Programmable interrupt controller),这里指的就是 8259A 芯片
      • ide_desc_init():初始化中断描述符表 IDT
  • 加载中断

中断处理程序

这里用到了汇编的宏 macro,宏是用来代替重复性输入的,格式如下:

%macro mul_add 3		;宏声明 宏名称 宏参数
mov eax, %1				;参数1
add eax, %2				;参数2
add eax, %3				;参数3
%endmacro

;调用如下:
mul_add 12, 23, 34

中断处理程序如下:

kernel/source/kernel.S

这一段代码通过宏创建了 intr_entry_table 数组(公开的成员),成员是33个中断处理程序的地址

[bits 32] 
%define ERROR_CODE nop  ;为了栈中格式统一,如果 CPU 在异常中已经自动压入错误码,这里不做操作
%define ZERO push 0     ;为了栈中格式统一,如果 CPU 在异常中没有自动压入错误码,这里填充 0

extern put_str          ;声明外部函数,告诉编译器在链接的时候可以找到

section .data
intr_str db "interrupt occur!", 0xa, 0

global intr_entry_table
intr_entry_table:

%macro VECTOR 2
section .text
intr%1entry:            ;每个中断处理程序都要压入中断向量号,所以1个中断类型1个处理程序,自己知道自己的中断号是多少
        %2
        push intr_str
        call put_str
        add esp, 4
    
        ;如果从片上进入中断,除了往片上发送 EOI 外,还要往主片上发送 EOI,因为后面要在 8259A 芯片上设置手动结束中断,所以这里手动发送 EOI
        mov al, 0x20    ;中断结束命令 EOI
        out 0xa0, al    ;往从片发送
        out 0x20, al    ;往主片发送
    
        add esp, 4
        iret

section .data           ;这个 section .data 的作用就是让数组里全都是地址,编译器会将属性相同的 Section 合成一个大的 Segmengt,所以这里就是紧凑排列的数组了
        dd intr%1entry  ;存储各个中断入口程序的地址,形成 intr_entry_table 数组

%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, ERROR_CODE

VECTOR 0x1f, ZERO
VECTOR 0x20, ZERO
VECTOR 0x21, ZERO

创建中断描述符表,安装中断处理程序

代码中略的部分(书上也写了略),会在后面小节中补充上来

kernel/source/interrupt.c:

这一段代码创建了中断描述符结构体,使用了一个函数来填充这个结构体,用了另一个函数来将各个中断描述符填充到中断描述符表中去,最后通过idt_init()来进行调用:

#include "interrupt.h"
#include "stdint.h"
#include "global.h"
#include "print.h"
/*略*/

#define IDT_DESC_CNT 0x21       //目前总共支持的中断数量

/*中断描述结构体*/
struct gate_desc{
        uint16_t        func_offset_low_word;
        uint16_t        selector;        uint8_t         dcount;                 //此项位双字计数字段,是门描述符第四字节,是固定值
        uint8_t         attribute;
        uint16_t        func_offset_high_word;
};

// 静态函数声明
static void make_idt_desc(struct gate_desc* p_gdesc,uint8_t attr, intr_handler function);
static struct gate_desc idt[IDT_DESC_CNT];      //idt 本质上就是个中断门描述符数组

extern intr_handler intr_entry_table[IDT_DESC_CNT];     // 声明引用在 kernel.S 中的中断处理函数入口数组
//intr_handler 实际上是 void* 在 interrupt.h 里定义的
/*略*/

/*创建中断门描述符*/
//参数:中断描述符,属性,中断处理函数地址
//功能:向中断描述符填充属性和地址
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;
        p_gdesc->selector = SELECTOR_K_CODE;    //global.h里定义的
        p_gdesc->dcount = 0;
        p_gdesc->attribute = attr;
        p_gdesc->func_offset_high_word = ((uint32_t)function & 0xFFFF0000) >> 16;
}

/*初始化中断描述符表*/
static void idt_desc_init(void){
        int i;
        for(i = 0; i < IDT_DESC_CNT; i++){
                make_idt_desc(&idt[i], IDT_DESC_ATTR_DPL0, intr_entry_table[i]);        //IDT_DESC_DPL0在global.h定义的

        }
        put_str("       idt_desc_init done\n");
}

/*完成有关中断的所有初始化工作*/
void idt_init(){
        put_str("idt_init start\n");
        idt_desc_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/source/interrupt.h:

typedef void* intr_handler;
void idt_init();

kernel/source/global.h:

#ifndef _KERNEL_GLOBAL_H 
#define _KERNEL_GLOBAL_H 
#include "stdint.h"

#define RPLO 0  
#define RPLl 1  
#define RPL2 2
#define RPL3 3

#define TI_GDT 0
#define TI_LDT 1

#define SELECTOR_K_CODE         ((1 << 3) + (TI_GDT << 2) + RPLO) 
#define SELECTOR_K_DATA         ((2 << 3) + (TI_GDT << 2) + RPLO) 
#define SELECTOR_K_STACK        SELECTOR_K_DATA
#define SELECTOR K GS           ((3 « 3) + (TI_GDT « 2) + RPLO)

/*-------------- IDT描述符属性 ------------*/
#define IDT_DESC_P              1 
#define IDT_DESC_DPLO           0 
#define IDT_DESC_DPL3           3 
#define IDT_DESC_32_TYPE        0xE     //32位的门
#define IDT_DESC_16_TYPE        0x6     //16位的门,用不到

#define IDT_DESC_ATTR_DPLO      ((IDT_DESC_P << 7) + (IDT_DESC_DPLO << 5) + IDT_DESC_32_TYPE) 
#define IDT_DESC_ATTR_DPL3      ((IDT_DESC_P << 7) + (IDT_DESC_DPL3 << 5) + IDT_DESC_32_TYPE) 

#endif

用内联汇编封装端口IO函数

到此,和中断相关的数据(中断描述符表,中断向量号)都准备好了,接下来只要把 8259A 设置好即可

这里先把常用的端口IO操作封装成函数,方便以后调用

lib/kernel/io.h:

这里封装了 4 个函数:

  • 向端口写入一个字节:void outb(uint16_t port, uint8_t data)
  • 向端口写入一个字符串:void outsw(uint16_t port, const void* addr, uint32_t word_cnt)
  • 从端口读入一个字节:uint8_t inb(uint16_t port)
  • 从端口读入一个字符串:void insw(uint16_t port, void* addr, uint32_t word_cnt)

static 表示作用域在本文件内,需要调用需要把本文件包含进入,会导致文件体积增大

加了 inline 关键字,函数会在调用处原地展开,编译后的代码不包含call,也就是不属于函数调用了,减少了函数调用相关 的工作,提升了工作效率

牺牲体积来增加运行速度还是不错的。

/****机器模式****
 *      b--输出寄存器最低8位:[a-d]l
 *      w--输出寄存器2个字节:[a-d]x
 * * * * * * ****/

#ifndef __LIB_IO_H
#define __LIB_IO_H
#include "stdint.h"

/* 向端口 port 写入一个字节 */
static inline void outb (uint16_t port, uint8_t data) {

        /*********************************************************
        对端口指定N表示0-255, d表示用dx存储端口号,
        %b0表示对应al,%w1表示对应dx */
        asm volatile ("outb %b0, %w1" : : "a" (data), "Nd" (port));
        /******************************************************/
        // 这里是 AT&T 语法的汇编语言,相当于: mov al. data
        //                                      mov dx, port
        //                                      out dx, al
}       

/* 将addr处起始的word_cnt个字写入端口port */
static inline void outsw(uint16_t port, const void* addr, uint32_t word_cnt) {  
        /*********************************************************
        + 表示此限制既做输入,又做输出,
        outsw 是把 ds:esi 处的 16 位的内容写入 port 端口,
        我们在设置段描述符时,已经将ds,es,ss段的选择子都设置为相同的值了, 此时不用担心数据错乱。 */
        asm volatile ("cld; rep outsw":"+S"(addr),"+c"(word_cnt):"d"(port));
        /*********************************************************/
        // 这里是 AT&T 语法的汇编语言,相当于: cld
        //                                      mov esi, addr
        //                                      mov ecx, word_cnt
        //                                      mov edx, port
        //                                      rep outsw
}  

/* 将从端口 port 读入一个字节返回 */
static inline uint8_t inb(uint16_t port){
        uint8_t data;
        asm volatile ("inb %w1,%b0": "=a"(data):"Nd"(port));
        // 这里是 AT&T 语法的汇编语言,相当于: mov dx, port
        //                                      in  al, dx
        //                                      mov [data], al

        return data;
}

/* 将从端口 port 读入的 word_cnt 个字写入 addr */
static inline void insw(uint16_t port, void* addr, uint32_t word_cnt){
        /*********************************************************
        insw 是将从端口 port 处读入的 16 位内容写入 es:edi 指向的内存,
        我们在设置段描述符时,已经将ds,es,ss段的选择子都设置为相同的值了,此时不用担心数据错乱. */
        asm volatile ("cld; rep insw" : "+D" (addr), "+c" (word_cnt): "d"(port): "memory" );
        /********************************************************/
        // 这里是 AT&T 语法的汇编语言,相当于: cld
        //                                      mov edi, addr
        //                                      mov ecx. word_cnt
        //                                      mov dx, port
        //                                      rep insw
}

#endif

设置 8259A

8259A 的编程就是写入 ICW 和 OCW,其中

  • ICW是初始化控制字, 共4个,ICW1~ICW4, 用千初始化8259A的各个功能。

  • OCW 是操作控制字, 用于同初始化后的8259A进行操作命令交互。 所以,对 8259A 的操作是在其初始化之后,对于8259A的初始化必须最先完成。

因为硬盘是接在了从片的引脚上,将来实现文件系统是离不开硬盘的,所以我们这里使用的8259A要采用主、从片级联的方式。

在x86系统中,对于初始化级联8259A, 4个ICW都需要,必须严格按照ICW1~4顺序写入。

写入端口:

  • ICW1 和 OCW2、OCW3 是用偶地址端口0x20(主片)或0xA0(从片)写入。

  • ICW2 ~ ICW4 和 OCW1 是用奇地址端口0x21(主片)或0xA1(从片)写入。

kernel/source/interrupt.c

这里的内容就是上次写的这个文件中,“略”的部分,这里把对 8259A 芯片的初始化操作加了进去。

//第一个“略”处
#include "io.h"

#define PIC_M_CTRL 0x20         //主片
#define PIC_M_DATA 0x21
#define PIC_S_CTRL 0xA0         //从片
#define PIC_S_DATA 0xA1

//第二个“略”处
/* 初始化可编程中断控制器 8259A */
static void pic_init(void){
        //初始化主片
        outb(PIC_M_CTRL, 0x11);         //ICW1: 0001 0001 ,边沿触发,级联 8259,需要ICW4
        outb(PIC_M_DATA, 0x20);         //ICW2: 0010 0000 ,起始中断向量号为 0x20(0x20-0x27)
        outb(PIC_M_DATA, 0x04);         //ICW3: 0000 0100 ,IR2 接从片
        outb(PIC_M_DATA, 0x01);         //ICW4: 0000 0001 ,8086 模式,正常EOI

        //初始化从片
        outb(PIC_S_CTRL, 0x11);         //ICW1: 0001 0001 ,边沿触发,级联 8259,需要ICW4
        outb(PIC_S_DATA, 0x28);         //ICW2: 0010 1000 ,起始中断向量号为 0x28(0x28-0x2f)
        outb(PIC_S_DATA, 0x02);         //ICW3: 0000 0010 ,设置连接到主片的 IR2 引脚
        outb(PIC_S_DATA, 0x01);         //ICW4: 0000 0001 ,8086 模式,正常EOI

        //打开主片上的 IR0 也就是目前只接受时钟产生的中断
    	//eflags 里的 IF 位对所有外部中断有效,但不能屏蔽某个外设的中断了
        outb (PIC_M_DATA, 0xfe);
        outb (PIC_S_DATA, 0xff);

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

pic_init()函数在最后的idt_init中调用

加载IDT,开启中断

kernel/source/interrupt.c

这一段代码之前写了,现在完善了相关函数,再来回顾一下:

初始化中断描述符表和 8259A 芯片之后,通过lidt命令加载 IDT,开启中断机制。

/*完成有关中断的所有初始化工作*/
void idt_init(){
        put_str("idt_init start\n");
        idt_desc_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/source/init.c

用一个函数专门来启动模块,以后添加新的模块了也添加到这里来启动:

#include "init.h"
#include "print.h"
#include "interrupt.h"

/* 负责初始化所有模块 */
void init_all(){
        put_str("init_all\n");
        idt_init();             // 初始化中断
}

kernel/source/init.h

为了让 main.c 调用 init_all 函数,所以建立一个 init.h

void init_all(void);

kernel/source/main.c

#include "print.h"
#include "init.h"
int main(){
        put_str("I am kernel\n");
        init_all();
        asm volatile("sti");    //开启中断
        while(1);
        return 0;
}

使用sti指令开启中断(sti指令的作用是将 IF 位 set 为 1)

运行 Bochs

为了文件目录的整洁,将所有的目标文件和编译后的内核文件都放在 build 目录下:

gcc -I lib/kernel/ -I kernel/source/ -c -fno-builtin -m32 -fno-stack-protector -o build/main.o kernel/source/main.c && \
nasm -f elf -o build/print.o lib/kernel/print.S && \
nasm -f elf -o build/kernel.o kernel/source/kernel.S && \
gcc -I lib/kernel/ -I kernel/source/ -c -fno-builtin -m32 -fno-stack-protector -o build/interrupt.o kernel/source/interrupt.c && \
gcc -I lib/kernel/ -I kernel/source/ -c -fno-builtin -m32 -fno-stack-protector -o build/init.o kernel/source/init.c && \
ld -m elf_i386 -Ttext 0xc0001500 -e main -o build/kernel.bin build/main.o  build/init.o  build/interrupt.o build/print.o build/kernel.o && \
strip --remove-section=.note.gnu.property build/kernel.bin &&\
dd if=build/kernel.bin of=hd60M.img bs=512 count=200 seek=9 conv=notrunc

gcc 里用到的新的参数:

-fno-builtin 处理内建函数

-I 参数要把所有相关文件的目录都选上

运行 Bochs:

image-20210130140542403

在 Bochs 里使用 info idt 查看当前 IDT:

image-20210130140637049

改进中断处理程序

前情提要

这里先来回顾一下到此为止所学的内容:

中断处理机制是怎么一个流程:

首先,中断分为软件中断和硬件中断:

  • 软件中断通过指令向系统发送中断向量号
  • 硬件中断通过中断控制器判断当前中断的优先级后向 CPU 的 INTR 引脚发送中断中断向量号

得到中断向量号之后,CPU 通过向 IDTR 寄存器查询中断描述符表 IDT 的地址

通过中断向量号索引当前中断在 IDT 中的位置,也就是门描述符,从中获取该中断响应的中断处理程序的地址

跳转到中断处理程序去执行,通过 iret 返回


代码文件分别是做什么的:

interrupt.c 是中断的主要初始化文件,初始化了 PIC 和 IDT

global.h 定义了门描述符和段选择子

io.h 封装了对端口的读写函数

init.c 将 interrupt.c 封装好的初始化程序再次封装,供 main.c 调用


中断是如何开启的:

开启中断准备工作分两部分:

  • 初始化中断描述符表:建立中断描述符表,填充各种中断描述符(门)
  • 初始化可编程控制器:通过端口发送初始化字设置运行模式

准备工作结束后,通过设置 IF 位来开中断

改进中断处理程序

之前中断处理程序都是汇编写的,写起来太麻烦,可以选择用 C 来编写

在 C 语言中建立中断处理函数数组 idt_table,数组元素是 C 版本的中断处理函数地址,供汇编中的 intrXXentry 使用

这就只需要在中断入口程序中,让中断向量号*4,加上 C 语言数组 idt_table 地址索引到对应的中断处理函数,就可以调用C语言的中断处理函数了。

kernel/source/interrupt.c

在这里添加如下代码:

//添加两个声明
char* intr_name[IDT_DESC_CNT];                          // 用于保存异常的名字
intr_handler idt_table[IDT_DESC_CNT];                   // 用于保存处理程序地址

;;;

//在idt_init()前添加:
/*通用的中断处理请求*/
static void general_intr_handler(uint8_t vec_nr){
        if(vec_nr == 0x27 || vec_nr == 0x2f){
                // IRQ7 IRQ15 会产生伪中断,无需处理
                // 0x2f 是从片 8259A 上的最后一个 IRQ 引脚,保留项
                return ;
        }
        put_str("int vector : 0x");
        put_int(vec_nr);
        put_char(' ');
        put_str(intr_name[vec_nr]);
        put_char('\n');
}

/*完成一般中断处理函数注册及异常名称注册*/
static void exception_init(void){
        int i;
        for(i = 0;i < IDT_DESC_CNT; i++){
                // idt_table 数组中的函数是在进入中断后根据中断向量号调用的
                // 见 kernel.S 的 call [idt_table = %1*4]
                idt_table[i] = general_intr_handler;    // 以后用register_handler 来注册具体的处理函数
                intr_name[i] = "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 No七 Available Exception"; 
        intr_name[8] = "JIDF 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[l5]第15项是intel保留项,未使用
        intr_name[16] = "#MF x87 FPU F'loating-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 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 中的地址即可

kernel/source/kernel.S

[bits 32]
%define ERROR_CODE nop  ;为了栈中格式统一,如果 CPU 在异常中已经自动压入错误码,这里不做操作
%define ZERO push 0     ;为了栈中格式统一,如果 CPU 在异常中没有自动压入错误码,这里填充 0

extern put_str          ;声明外部函数,告诉编译器在链接的时候可以找到
extern idt_table        ;声明 c 注册的中断处理函数数组

section .data
intr_str db "interrupt occur!", 0xa, 0

global intr_entry_table
intr_entry_table:

%macro VECTOR 2
section .text
intr%1entry:            ;每个中断处理程序都要压入中断向量号,所以1个中断类型1个处理程序,自己知道自己的中断号是多少
        ;无错误时,压入0占位
        %2

        ;保存上下文环境
        push ds
        push es
        push fs
        push gs
        pushad

        ;如果从片上进入中断,除了往片上发送 EOI 外,还要往主片上发送 EOI,因为后面要在 8259A 芯片上设置手动结束中断,所以这里手动发送 EOI
        mov al, 0x20    ;中断结束命令 EOI
        out 0xa0, al    ;往从片发送
        out 0x20, al    ;往主片发送

        push %1         ;不管中断处理程序是否需要,一律压入中断向量号   
        call [idt_table + %1*4]

        jmp  intr_exit

section .data           ;这个 section .data 的作用就是让数组里全都是地址,编译器会将属性相同的 Section 合成一个大的 Segmengt,所以这里就是紧凑排列的数组了
        dd intr%1entry  ;存储各个中断入口程序的地址,形成 intr_entry_table 数组

%endmacro

section .text
global intr_exit
intr_exit:
        ;恢复上下文环境
        add esp, 4      ;跳过参数中断号
        popad
        pop gs
        pop fs
        pop es
        pop ds
        add esp, 4      ;手动跳过错误码
        iretd

VECTOR 0x00, ZERO
...	;此处省略的内容与之前是一样的
VECTOR 0x20, ZERO

这里主要是修改了宏,现在的宏是先保存上下文环境,然后入栈中断向量号调用 C 语言中 idt_table 相应的处理程序,调用完之后,还原上下文环境,从中断返回。

运行 Bochs

编译,链接,删掉多余的节,写入硬盘:还是刚才的那一套操作:

gcc -I lib/kernel/ -I kernel/source/ -c -fno-builtin -m32 -fno-stack-protector -o build/main.o kernel/source/main.c && \
nasm -f elf -o build/print.o lib/kernel/print.S && \
nasm -f elf -o build/kernel.o kernel/source/kernel.S && \
gcc -I lib/kernel/ -I kernel/source/ -c -fno-builtin -m32 -fno-stack-protector -o build/interrupt.o kernel/source/interrupt.c && \
gcc -I lib/kernel/ -I kernel/source/ -c -fno-builtin -m32 -fno-stack-protector -o build/init.o kernel/source/init.c && \
ld -m elf_i386 -Ttext 0xc0001500 -e main -o build/kernel.bin build/main.o  build/init.o  build/interrupt.o build/print.o build/kernel.o && \
strip --remove-section=.note.gnu.property build/kernel.bin &&\
dd if=build/kernel.bin of=hd60M.img bs=512 count=200 seek=9 conv=notrunc

运行 Bochs:

image-20210130215418099

理论上来说,这里的中断应该是 0x20,经过测试,如果把起始向量号设置为 0x18,则会 0x18 中断,设置为 0x10 则会 0x10中断,但设置为 0x20 却触发的是 0x21,经过调试和查资料,我推断这可能是因为Bochs模拟的不完整导致的,具体情况以后遇到了再说。

可编程计数器/定时器 8253

时钟

计算机上的时钟可以分为两类:内部时钟和外部时钟

  • 内部时钟频率来自晶振,是不可改变的。
  • 外部时钟频率来自内部时钟的分频,叫做外频,外频乘以某个倍数之后称为主频

外部时钟和内部时钟是两套独立运行的定时体系。

定时器和计数器实际上是一回事,都是在做计时的功能,也就是到指定时间后发信号给 CPU

8253 入门

8253 定时/计数器是通过倒计时的方式定时,需要先设置一个初始值,每隔一个时钟周期减去1,减到0就给CPU发送信号,然后重新初始化

8253 芯片的计数器内部有3个主要部件:全都是16位宽的

  • 计数初值寄存器:用来保存初值
  • 减法计数器:每隔一个脉冲信号,减去1,用来计数
  • 输出锁存器:减法计数器的值会存在这里,用来获取当前计数进度

8253 芯片的每隔计数器都有3个引脚:

  • CLK:接时钟输入信号
  • GATE:门控输入信号,用来控制计数器是否开始计数
  • OUT:定时完成后,通过此引脚发出信号通知

image-20210130224931735

8253 芯片内部有3个计数器,工作相互独立,互不影响,作用和端口如图所示:

image-20210130224643175

8253 控制字

端口0x43是控制字寄存器,功能如图所示:

image-20210130225347895

8253 工作方式

image-20210130225818061

计数器启动的条件:

  • 硬件条件:GATE 引脚为高电平,由硬件控制完成
  • 软件条件:计数初值已写入计数器中的减法计数器,由软件 out 指令控制完成

启动类型:

  • 软件启动:硬件条件已经完成,由软件条件来控制启动,工作方式 0/2/3/4
  • 硬件启动:软件条件已经完成,由硬件条件来控制启动,工作方式 1/5

停止类型:

  • 强制终止:将 GATE 信号置 0
  • 自动终止:单次计数完之后自动停止,工作方式 0/1/4/5

六种工作方式:

image-20210130230911068

8253 初始化

让 8253 开始工作的方法比 8259A 简单多了:

  • 通过控制字设置控制模式
  • 向计数器写入初值

device/timer.c:

#include "timer.h" 
#include "io.h"   
#include "print.h"   
  
#define IRQ0_FREQUENCY          100
#define INPUT_FREQOENCY         1193180
#define COUNTER0_VALUE          INPUT_FREQUENCY / IRQ0_FREQUENCY
#define CONTRER0_PORT           0x40
#define COUNTER0_NO             0
#define COUNTER_MODE            2
#define READ_WRITE_LATCH        3
#define PIT_CONTROL_PORT        0x43

/*把操作的计数器counter_no、读写锁属性rwl、计数器模式counter_mode写入模式控制寄存器井赋予初始值counter_value*/
static void frequency_set(uint8_t counter_port,
                        uint8_t counter_no,
                        uint8_t rwl,
                        uint8_t counter_mode,
                        uint16_t counter_value){
        //往控制字寄存器端口 0x43 写入控制字
        outb(PIT_CONTROL_PORT, (uint8_t)(counter_no << 6 | rwl << 4 I counter_mode << 1));

        //先写入低8位
        outb(counter_port, (uint8_t)counter_value);  
        //再写入高8位
        outb(counter_port, (uint8_t)counter_value >> 8);
}       

/*初始化 PIT8253*/
void timer_init(){
        put_str("timer_init start\n");
        frequency_set(CONTRER0_PORT, COUNTER0_NO, READ_WRITE_LATCH, COUNTER_MODE, COUNTER0_VALUE);
}

device/timer.h:

void timer_init();

kernel/source/init.c

#include "init.h"
#include "print.h"
#include "interrupt.h"
#include "timer.h"

/* 负责初始化所有模块 */
void init_all(){
        put_str("init_all\n");
        idt_init();             // 初始化 中断
        timer_init();           // 初始化 PIT
}

运行 Bochs

编译、链接、删除多余的节、写入硬盘:

gcc -I lib/kernel/ -I kernel/source/ -c -fno-builtin -m32 -fno-stack-protector -o build/main.o kernel/source/main.c && \
gcc -I lib/kernel/ -I kernel/source/ -c -fno-builtin -m32 -fno-stack-protector -o build/interrupt.o kernel/source/interrupt.c && \
gcc -I lib/kernel/ -I kernel/source/ -I device/ -c -fno-builtin -m32 -fno-stack-protector -o build/init.o kernel/source/init.c && \
gcc -I lib/kernel/ -c -m32 -o build/timer.o device/timer.c && \

nasm -f elf -o build/print.o lib/kernel/print.S && \
nasm -f elf -o build/kernel.o kernel/source/kernel.S && \

ld -m elf_i386 -Ttext 0xc0001500 -e main -o build/kernel.bin build/main.o  build/init.o  build/interrupt.o build/print.o build/kernel.o build/timer.o && \

strip --remove-section=.note.gnu.property build/kernel.bin &&\

dd if=build/kernel.bin of=hd60M.img bs=512 count=200 seek=9 conv=notrunc

运行:

image-20210131002653634

也不知道是什么原因,修改了定时器的中断频率之后,运行一段时间后开始,中断向量号变成了 0xD,cs 代码段被改变,时钟中断返回到正确的地址,导致引起了新的问题

当我把timer_init()函数注释掉之后,程序正常,说明问题出在timer.c文件中,但是为什么要运行一段时间才出现问题?

cs 代码段为什么会被改变? 当我把时钟中断给屏蔽掉之后,不会出现 0xD 中断,说明问题出在了时钟中断处理程序中。

根据之前解决时钟中断会发出中断号0x21的方法,我猜想,这有可能跟我的这个 Bochs 模拟 8259A 芯片不完整有关系,于是我将设置的中断描述符数量提高到了0x30个,也就是再加了0x10个,再次运行,程序正常输出了

可能是因为对 8259A 模拟的不完整导致出现了来自其他 IR 端口的中断信息,然后在 IDT 中索引门描述符的时候,数组越界访问,访问跳转到了未知的地址上,导致 cs 段寄存器被改变,从而导致访问错误的地址而出现 CPU 抛出的权限访问错误的异常。

正常输出运行:

image-20210131005509252

参考资料


评论