手写操作系统(二十)-简单中断程序

代码、内容参考来自于包括《操作系统真象还原》、《一个64位操作系统的设计与实现》以及《ORANGE’S:一个操作系统的实现》。

1.概述

开启中断的流程:

init_all函数用来初始化所有的设备和数据结构,我们在主函数中调用它来完成初始化工作。

init_all首先调用idt_init,它用来初始化中断相关的内容。

idt_init调用pic_init和ide_desc_init来完成初始化。

pic_init用来初始化可编程中断控制器8259A。(PIC就是可编程中断控制器 Programmable Interrupt Controller的简称,而 8259A也是PIC 的一种)

ide_desc_init用来完成初始化IDT。

idt_init完成后便可以加载IDT了,到此打开中的条件准备好了。

在汇编中定义宏有多种方式,如果是定义单行的宏,可以用%define指令来实现,这和C语言中的define用法一致,如果是定义多行的宏,就要用%macro来实现,格式如下:

%macro 宏名字参数个数
......
代码体
......
%endmacro

如果想引用某个参数,就要用“%数字”的方式来引用,比如:

%macro mul_add3
mov eax, %1 
add eax, %2
add eax, %3
%endmacro

用此方式调用: mul_add 45, 24, 33,其中%1是45,%2是24,%3是33。

 

2.中断处理程序

下面就是汇编版本的中断处理程序

/kernel/kemel.S

[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    ;0xa 是换行符,0 是字符串的结束符

global intr_entry_table         ;intr_entry_table位于data段,链接时会和宏中的data段放在一起

intr_entry_table:               ;构造intr_entry_table数组,只是构造数组,并不调用中断,中断由硬件触发,目前由时钟触发
;宏开始
%macro VECTOR 2                 ;定义了一个叫VECTOR的宏,接收两个参数。一个参数是中断向量号,第二个参数也是个宏
section .text
intr%1entry:                    ;%1表示第一个参数,不是英文L。中断处理程序的起始地址;每个中队处理程序都要压入中断向量号,所以一个中断类型一个向量号。自己知道自己的向量号是多少
;intr%1entry 表示一个带有参数的标签名。在宏被展开时,%1 将被实际的参数替换,从而形成一个具体的标签名。如果我们调用 VECTOR 0x20, ZERO,宏展开后的结果将是:intr0x20entry:
    %2                          ;调用宏的第二个参数
    push intr_str
    call put_str                ;中断就是打印字符串intr_str
    add esp,4                   ;跳过参数
    ;如果是从片中进入的中断,除了往从片上发送EOI外,还要往主片上发送EOI
    mov al,0x20                 ;中断结束命令EOI。OCW2的第5位是EOI位,其余都是0,所以是0x20。我们要向8259A发送结束标志,不然手动结束的8259A不知道中断结束了。
    out 0xa0,al                 ;向主片发送
    out 0x20,al                 ;向从片发送
    add esp,4                   ;跳过error_code,就算没有error_code,ZERO中也压入了一个0
    iret                        ;从中断返回,32位下=iretd
section .data                   ;表示上一个代码段结束
    dd intr%1entry              ;dd用来定义数组元素的宽度,元素值为intr%lentry;存储各个中断入口程序的地址,形成intr_entry_table数组。这样每个宏调用都会在数组中产生新的地址元素
%endmacro
;宏结束
VECTOR 0x00,ZERO                ;预处理时会将其展开为宏,intr0x00entry为一个符号(符号就是地址),function为.text,该符号会被写入intr_entry_table数组中。
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

代码解释;

上面定义了 33个中断处理程序。每个中断处理程序都一样,就是调用字符串打印函数put_str来打印字符串”interrupt occur!”,之后直接退出中断。

中断向量0~19为处理器内部固定的异常类型, 20~31是Intel保留的,所以我们可用的中断向量号最低是32,将来在设置8259A的时候,会把IRO的中断向量号设置为32 。目前只为演示中断机制的原理,拿连接在主片IRO接口上外部设备–时钟。

可以看到%macro和%endmacro之间定义了名为VECTOR的宏,接收两个参数,然后再后面就是调用 VECTOR 的地方,即预处理后,会有 33个中断处理程序。

VECTOR 0x1e,ERROR_CODE第1个参数 0x1e 是中断向量号,用来表示:本宏是为了此中断向量号而定义的中断处理程序,或者说这是本宏实现的中断处理程序对应的中断向量号,将来要把它装载到中断描述符表中以该中断向量号为索引的中断门描述符位置。第2个参数ERROR_CODE也是个宏,它定义在%define ERROR_CODE nop,它的值为nop,nop是汇编指令,它表示no operation,不操作,什么都不干。在此完全是占位充数用的。

VECTOR 0x1f, ZERO第1个参数0x1f是中断向量号,第2个参数是ZERO,它也是个宏,定义在第3行%define ZERO push 0,也就是说ZERO的值是push 0,是“把0压入栈”这个操作。

有的中断会产生错误码,用来指明中断是在哪个段上发生的,错误码会在进入中断后,处理器在栈中压入寄存器 EIP 之后压入。

在中断发生时,处理器要在目标栈中保存被中断进程的部分寄存器环境,这是处理器自动完成的,不需要手动写码。

保存的寄存器名称及顺序是:

  • 如果发生了特权级转移,比如被中断的进程是 3 特权级,中断处理程序是 0 特权级,此时要把低特权级的栈段选择子 ss 及栈指针 esp 保存到栈中。
  • 压入标志寄存器 eflags。
  • 压入返回地址 cs 和 eip,先压入 cs,后压入 eip
  • 如果此中断没有相应的错误码,至此,处理器把寄存器压栈的工作完成
  • 如果此中断有错误码的话,处理器在压入eip之后会压入错误码,至此,处理器自动压栈工作全部完成。

如图所示:

用iret指令从中断返回时栈顶必须是EIP的值,也就是栈顶esp指向的位置。所以如果栈中有错误码,在iret指令执行前必须要把栈中的错误码跨过。所以如果没有错误码,手工压入一个 32 位的数。对应就是VECTOR 0x1f, ZERO和VECTOR 0x1e,ERROR_CODE

由于EIP和错误码都是处理器自动压入栈的,错误码是在EIP压入栈之后压入的,所以我们手工压入的32位数也应该放在压入EIP之后操作,即它必须是中断处理程序中的第一个指令。

%2会根据实际的参数展开为nop或push 0。

汇编中的section,即节,用来定义一段相同属性的数据,该范围起始于当前section的定义处,一直持续到下一个section的定义处,若没有遇到新的section定义,则一直持续到文件结束处。

在这个宏中嵌套了两个section,. text,用作代码范围的起始定义,以及.data,一是用作上个.text的结束,再有就是此数据范围的起始定义。

由于我们调用了该宏33次,所以在预处理之后,宏中的这两个section将会各出现33次。在. text 的 section 中,我们定义的是中断处理程序.

;intr%1entry就是地址,这是中断处理程序的起始处,所以此标号是为了获取中断处理程序的地址。 由于我们目前有33个中断处理程序,标号不能重名,所以我们在intr和entry之间夹了个%1,参数1是中断向量号,所以最终中断处理程序起始地址的范围是intr[0~32]entry。

为了引用所有中断处理程序的地址,我们在kernel.S中定义了一个数组,数组名为intr_entry_table,并且已经在第9行由global语句导出为全局符号,这样其他程序便可以使用此数组了。

global intr_entry_table

 

为了使此数组中的元素是每个中断处理程序的地址,我们定义了数据段(节),由于32位下的地址是4字节,所以用伪指令dd来定义数组元素的宽度,元素值为intr%1entry。 这样每个宏调用都将在数组中产生新的地址元素。 编译器会将属性相同的 section 合并到同一个大的 segment 中,编译之后,所有中断处理程序的地址都会乖乖地作为数组intr_entry_table的元素紧凑地排在一起。

section .data                   ;表示上一个代码段结束
    dd intr%1entry

 

下面就是打印一个字符串,字符串”interrupt occur!换行0”的地址,结尾的0表示字符串结束。由于要调用put_str,所以有extern声明了put_str,它告诉nasm,put_str定义在别的文件中,链接时可以找到。之后跳过压入栈中的参数,也就是intr_str。

push intr_str
call put_str                ;中断就是打印字符串intr_str
add esp,4                   ;跳过参数

 

这里是往主片和从片中写入0x20,也就是写入EOI。这是 8259A的操作控制字OCW2,其中第5位是EOI位,此位为1,其余位全为0,所以是0x20。由于将来在设置8259A时设置了手动结束,所以得在中断处理程序中手动向8259A发送中断结束标记,否则8259A并不知道中断处理完成了,它会一直等下去,从而不再接受新的中断。也就是说,为了让8259A接受新的中断,必须要让8259A知道当前中断处理程序已经执行完成。

 

3.创建中断向量表IDT

这里完成创建中断向量表IDT,安装中断处理程序到中断描述符表

完整代码

/kernel/interrupt.c

#include "interrupt.h"
#include "stdint.h"
#include "global.h"
#include "io.h"
#include "print.h"

#define IDT_DESC_CNT 0x21           //目前共支持的中断数,33
#define PIC_M_CTRL 0x20             //主片的控制端口是0x20
#define PIC_M_DATA 0x21             //主片的数据端口是0x21
#define PIC_S_CTRL 0xa0             //从片的控制端口是0xa0
#define PIC_S_DATA 0xa1             //从片的数据端口是0xa1

/*中断门描述符结构体*/
struct gate_desc{
    uint16_t func_offset_low_word;  //低32位——0~15位:中断处理程序在目标代码段内的偏移量的第15~0位
    uint16_t selector;              //低32位——16~31位:目标CS段选择子
    uint8_t dcount;                 //高32位——0~7位:此项为双字计数字段,是门描述符中的第4字节,为固定值
    uint8_t attribute;              //高32位——8~15位:P+DPL+S+TYPE
    uint16_t func_offset_high_word; //高32位——16~31位:中断处理程序在目标代码段内的偏移量的第16~31位
};

//静态函数声明,非必须
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中的中断处理函数入口数组

/*创建中断门描述符*/
static void make_idt_desc(struct gate_desc* p_gdesc,uint8_t attr,intr_handler function){ //intr_handler是个空指针类型,仅用来表示地址
    //中断门描述符的指针、中断描述符内的属性、中断描述符内对应的中断处理函数
    p_gdesc->func_offset_low_word = (uint32_t)function & 0x0000ffff;
    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;
}

/*初始化中断描述符表*/
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]);
    }
    put_str("   idt_desc_init done\n");
}

/*初始化可编程中断控制器8259A*/
static void pic_init(void){
    /*初始化主片*/
    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
    /*打开主片上IR0,也就是目前只接受时钟产生的中断*/
    outb(PIC_M_DATA,0xfe);
    outb(PIC_S_DATA,0xff);
    put_str("   pic_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))); //低16位是idt的大小,高48位是IDT的基址。因为idt是32位,左移16位后会丢失高16位,所以先转换为64位再左移

    asm volatile("lidt %0" : : "m" (idt_operand));   //加载IDT,IDT的0~15位是表界限,16~47位是表基址
    put_str("idt_init done\n");
}

 

代码解释

idt_init函数负责所有和中断相关的初始化工作,是中断初始化工作的主函数。

中断描述符IDT本质上就是中断门描述符的数组,门描述符的结构是由文件顶端的struct gate_desc来描述的。

struct gate_desc结构中成员的定义是参照中断门描述符来定义的,描述符都是 8 字节,struct gate_desc 结构体中成员的大小总和便是 8 字节。

中断描述符表就是在文件顶端的 static struct gate_desc idt[IDTDESC_CNT],就是我们定义的中断描述符表,它的数据类型正是struct gate_desc,数组大小是IDT_DESC_CNT,IDT_DESC_CNT其值为0x21,即33,表示33个中断处理程序,也就是要定义33个中断门描述符。另外,由于IDT属于全局数据结构,所以我们声明它为static类型。

接下来的任务便是在每个中断门描述符中安装中断处理程序。

idt_desc_init用来填充中断描述符表,函数体中用了一个for循环,通过调用make_idt_desc函数在中断描述符表中创建了IDT_DESC_CNT个中断门描述符。

make_idt_desc函数是用来创建中断门描述符的,它接受3个参数:中断门描述符的指针、中断描述符内的属性及中断描述符内对应的中断处理函数。

make_idt_desc的原理是将后两个参数写入第1个参数所指向的中断门描述符中,实际上就是用后面的两个参数构建第1个参数指向的中断门描述符。它定义在idt_desc_init函数的上面,内部实现就是为结构体struct gate_desc中的成员赋值。其中为选择子selector赋值的SELECTOR_K_CODE定义在global.h中,它是个指向内核数据段的选择子。

make_idt_desc (&idt[i], IDT_DESC_ATTR_DPLO, intr_entry_table[i]),第1个参数便是中断描述符表idt的数组成员指针,第2个参数IDT_DESC_ATTR_DPLO是描述符的属性,它同样定义在global.h中,第3个参数是在kernel.S中定义的中断描述符地址数组intr_entry_table中的元素值,即中断处理程序的地址。

intr_handler是在 interrupt.h中自定义的类型,其定义为:typedef void* intr_handler;也就是说intr_handler是个空指针类型,该指针没有具体的类型,仅仅用来表示地址。这是因为 intr_handler是用来修饰 intr_entry_table 的, intr_entry_table 中的元素都是普通地址。

上面需要的interrupt.h

/kernel/interrupt.h

#ifndef __KERNEL_INTERRUPT_H
#define __KERNEL_INTERRUPT_H
#include "stdint.h"
typedef void* intr_handler;
void idt_init(void);
#endif

还有/kernel/global.h

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

#define RPL0 0
#define RPL1 1
#define RPL2 2
#define RPL3 3

#define TI_GDT 0
#define TI_LDT 1

#define SELECTOR_K_CODE ((1 << 3) + (TI_GDT << 2) + RPL0)
#define SELECTOR_K_DATA ((2 << 3) + (TI_GDT << 2) + RPL0)
#define SELECTOR_K_STACK SELECTOR_K_DATA
#define SELECTOR_K_GS ((3 << 3) + (TI_GDT << 2) + RPL0)

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

#define IDT_DESC_ATTR_DPL0 ((IDT_DESC_P << 7) + (IDT_DESC_DPL0 << 5) + IDT_DESC_32_TYPE)
#define IDT_DESC_ATTR_DPL3 ((IDT_DESC_P << 7) + (IDT_DESC_DPL3 << 5) + IDT_DESC_32_TYPE)

#endif

 

4.实现端口I/O函数

接下来只要把中断代理8259A设置好就可以啦。

对8259A或任何硬件的控制都要通过端口,之前操作硬盘或显卡时,都是用Intel语法风格的汇编语言编写的,所以这里把常用的端口读写功能封装成C函数。

/lib/kernel/io.h

/*机器模式
    b -- 输出寄存器QImode名称,即寄存器的低8位:[a-d]l
    w -- 输出寄存器HImode名称,即寄存器中2字节的部分,如[a-d]x
    HImode
        "Half-Integer"模式,表示一个两字节的整数
    QImode
        "Quarter-Integer"模式,表示一个一字节的整数
*/

#ifndef __LIB_KERNEL_IO_H
#define __LIB_KERNEL_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*/
    //outb:将一个字节的数据从CPU输出到指定的I/O端口
    asm volatile("outb %b0,%w1" :: "a"(data),"Nd"(port));
}

/*将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));
}

/*将从端口port读入一个字节返回*/
static inline uint8_t inb(uint16_t port){
    uint8_t data;
    asm volatile("inb %w1,%b0":"=a"(data):"Nd"(port));  //inb:从指定的I/O端口读取一个字节的数据,并将其加载到CPU的寄存器中
    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");
}

#endif

我们把函数的实现部分直接放到了以.h文件,里面各函数的作用域都是static,这意味着凡是包含io.h的文件,都会获得一份io.h中所有函数的拷贝,这里的函数都是对底层硬件端口直接操作的,通常由设备的驱动程序来调用,为了快速响应,在我们的实现中,函数的存储类型都是static,并且加了inline关键字,它建议处理器将函数编译为内嵌的方式,就是将所调用的函数体的内容,在该函数的调用处,原封不动地展开,这样编译后的代码中将不包含call指令,也就不属于函数调用了,而是顺次执行。 虽然这会让程序大一些,但减少了函数调用及返回时的现场保护及恢复工作,提升了效率。

io.h 中就定义了4个函数,分别是:

  • 一次写入1个字节的outb函数。
  • 一次写入多个字的outsw函数,注意,是以 2 字节为单位的。
  • 一次读入1个字节的inb函数。
  • 一次读入多个字的 insw函数,同样以2 字节为单位。

outb函数接受两个参数,参数port是16位无符号整型的端口号,此类型可容纳Intel所支持的65536个端口号,参数data是1字节的整型数据,outb的功能是将data中的1字节数据写入port所指向的端口。函数实现是用内联汇编来实现的,内联汇编的格式是:

asm [volatile] ("assembly code": output: input: clobber/modify):

按照以上格式,我们自己的代码是:

asm volatile ( "outb %b0, %w1" : : "a" (data), "Nd" (port));

outb 是一个输出指令,通常用于将数据输出到指定的端口上。

outb指令格式为outb%a1, %dx,其中%a1是源操作数,指的是8位数据,%dx是目的操作数,指的是数据所写入的端口。

input对应”a” (data), “Nd” (port),形参变量port的约束有2个,分别是N和d,N为立即数约束,它表示0~255的立即数,也就是8位1字节所表示的范围,这样把写入的数据限制在1字节之内。 d为寄存器约束,它表示让gcc为port分配的寄存器可以是dl、dx或edx。 outb的目的操作数是dx,我们得想办法将寄存器明确为dx。 这可以用操作码w来实现,它表示使用寄存器的HImode名称,即寄存器中2字节的那个可独立使用的部分,也就是[a-d]x。 操作码是跟序号占位符配合在一起来使用的,所以操作码w要随着序号占位符在内联汇编的”assembly code”中使用。 大家看”assembly code”,port所对应约束的序号占位符是%1,我们目的是使用dx寄存器,所以用%w1来限制目的操作数为寄存器dx。

data变量,它对应的约束为a,这表示用寄存器al、ax或eax来存储该变量的值,前面说过outb指令的源操作数是寄存器al,我们也必须将源操作数使用的寄存器明确为al才行。 这可以通过机器模式操作码b来实现,操作码b表示寄存器的QImode部分,也就是寄存器中最低8位可独立使用的部分,即[a-d]l。 同理,在“assembly code”中用%b0表示 al寄存器。

insw接受三个参数,无符号16位整型变量port是待读入的端口号,空指针变量addr是数据缓冲区,用于存储读出来的数据,无符号32位整型变量word_cnt是以字(2字节)为单位的数据单位量。 insw函数的功能是将从端口port读入的word_cnt个字写入 addr。

insw函数的核心是用同名汇编指令insw来实现的,该指令的功能是从端口port处读入的16位数据写入es: edi指向的内存,即一次读入2字节。

在loader中已经定义好es 和ds指向的是同一个基址为0 的段,只要指定偏移地址就行,这是平坦模型。 平坦模型下的编译器,其所编译出来的程序中的符号地址自然按照平坦模型来编排,即默认段基址为0,符号地址其实就是偏移地址。 指针变量用来存储其他符号的地址,此处指针变量addr中的值便是偏移地址,只要把addr的值约束到edi寄存器就行了。 于是在 output中, “+D” (addr)表示用寄存器约束 D将变量addr的值约束到EDI中。

asm volatile("cld;rep insw":"+D"(addr),"+c"(word_cnt):"d"(port):"memory");

insw函数实现的是将多个字从指定端口读出,而汇编指令insw执行一次只能从端口读取2字节的数据,所以这里用重复指令 rep,它把寄存器ecx作为循环计数器,每执行一次,ecx的值就减1,直到ecx为0时停止执行,”+c” (word_cnt)便是把word_cnt的值约束到寄存器 ecx 中作为循环次数。 +表示所修饰的约束既做输入(位于input中),也做输出(位于output中),也就是告诉gcc所约束的寄存器或内存先被读入,再被写入。寄存器edi和ecx先被读入,又被写入,同时作为指令的输入和输出。

每执行一次insw指令,insw要把ES: EDI作为数据缓冲区,这时EDI作为insw指令的输入。 由于在insw执行前已经用 cld 指令清除了方向位 DF,故 insw 指令执行后,EDI 的值自动加 2,这时 EDI 作为输出。 rep用ecx控制执行后面指令insw的次数,所以每次先要读取ecx的值判断是否为0,,这时ecx作为输入,执行完后, ecx要减1,这时ecx作为输出。 “d” (port)就是把端口号port的值约束到dx寄存器中。

在内联汇编的clobber/modify部,用到了内存破坏memory,由于insw指令往内存es: edi处写入了数据,所以通知gcc这块内存已经改变。edi 不需要在 clobber/modify 中声明,如果在output和input中通过寄存器约束指定了寄存器,gcc必然会知道这些寄存器会被修改,不需要再重复通知。

 

5.设置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 (从片)写入。

代码已经给出,就是上面interrupt.c的pic_init和idt_init函数

在pic_init中依次设置主片和从片8259A,无论是主片,还是从片,都必须按顺序依次写入ICW1、ICW2、ICW3、ICW4。

为方便编码,我们在文件开头定义了四个宏来表示主、从片的控制端口和数据端口。

我们先设置主片,往主片中写入ICW1,ICW1是往主、从片的偶数地址写入的,即主片端口地址是0x20,从片端口地址是0xA0,通过刚刚outb函数往端口 PIC_M_CTRL(也就是主片的控制端口0x20)写入ICW1,其值为0x11,第0位是IC4位,表示是否需要指定ICW4,我们需要在ICW4中设置EOI为手动方式,所以需要ICW4。由于我们要级联从片,所以将ICW1中的第1位SNGL置为0,表示级联。 设置第3位的LTIM为 0,表示边沿触发。 ICW1 中第4位是固定为1。 其他位不设置。

ICW2~ICW4是写入主、从片的奇地址端口,即主片的0x21 和从片的0xA1,ICW2专用于设置8259A的起始中断向量号,由于中断向量号0~31已经被占用或保留,从32起才可用,所以我们往主片PIC_M_DATA端口(主片的数据端口0x21)写入的ICW2值为0x20,即32。 这说明主片的起始中断向量号为0x20,即IRO对应的中断向量号为0x20,这是我们的时钟所接入的引脚。 IR1~IR7对应的中断向量号依次往下排。

接着往主片中写入 ICW3,ICW3专用于设置主从级联时用到的引脚。用IR2引脚作为主片级联从片的接口,第2个引脚,即IR2,在ICW3中将其置为1,故ICW3值为0x04,写入主片奇地址端口0x21,即 PIC_M_DATA。

接着往主片中写入 ICW4,8259A的很多工作模式都在ICW4中设置,我们只要设置其中的第0位: uPM位,它设置当前处理器的类型,我们所在的开发平台是x86,所以要将其置为1。 此外还要设置ICW4的第1位: EOI的工作模式位即End OF Interrupt,就是告诉8259A中断处理程序执行完了,8259A现在可以接受下一个中断信号啦。 EOI的工作模式位就是设置发送EOI的方式。 如果为1,8259A会自动结束中断,这里我们需要手动向8259A发送中断,所以将此位设置为0。其他位按默认就行了,所以ICW4的值为0x01。写入主片奇地址端口0x21,即PIC_M_DATA。至此,已经向主片发送了4个ICW,接下来设置从片8259A。

后面就是从片了。

向从片发送ICW1,其意义和主片的ICW1一致,只不过是往从片的偶数地址端口0xA0发送,这是从片的控制端口,即PIC_S_CTRL。向从片发送ICW2是设置从片的起始中断向量号。由于主片的中断向量号是0x20~0x27,故从片的中断向量号顺着它延续下来,从0x28开始,即ICW2值为0x28,也就是IR[8-15]为0x28~0x2F, ICW2 通过 outb 函数向从片的奇数地址端口 0xA1 写入,即 PIC_S_DATA。

接着向从片发送ICW3,ICW3专用于设置级联的引脚,这里设置从片连接在主片的哪个IRQ引脚上。 刚才在设置主片的时候是设置用IR2引脚来级联从片,所以此处要告诉从片连接到主片的IR2上,即ICW2值为0x02,通过outb函数向从片的奇数地址端口0xA1写入,即PIC_S_DATA。向从片发送ICW4,同主片的ICW4一样,不再解释。

至此主、从片都已经初始化完成了。

这里我们只测试一个中断源。我们拿位于主片上的IRO引脚上的时钟中断举例,位于其他引脚的外部中断信号统统屏蔽。

屏蔽某个外部设备中断信号可以通过设置位于8259A中的中断屏蔽寄存器IMR来实现,只要将相应位置1就达到了屏蔽相应中断源信号的目的,标志寄存器eflags中的IF位对所有外部中断有效,不能通过它来屏蔽某个外设的中断。

/*打开主片上IR0,也就是目前只接受时钟产生的中断*/
outb(PIC_M_DATA,0xfe);
outb(PIC_S_DATA,0xff);
put_str("   pic_init done\n");

这样的命令操作已经不属于初始化了,所以此时再向8259A发送的任何数据都称为操作控制字,即OCW,往IMR寄存器中发送的命令控制字称为OCW1,主片上的OCW1为0xfe,即第0位为0,表示不屏蔽IRO的时钟中断。 其他位都是1,表示都屏蔽。 从片上的所有外设都屏蔽,所以发送的OCW1值为Oxff。 OCW1是写入主、从片的奇地址端口,即主片的0x21端口(PIC_M_DATA)和从片的0xA1端口(PIC_S_DATA)。

 

6.加载 IDT,开启中断

开启中断的最后一个环节就是把中断描述符表 IDT 的信息加载到IDTR 寄存器。

往IDTR中加载IDT的指令是lidt,lidt的操作数也要符合IDTR寄存器的结构,所以lidt的操作数也必须是48位,前16位是界限limit,后32位是基址,只不过这48位的数据必须位于内存中。

所以lidt的用法是:

lidt 48位内存数据

逻辑代码在interrupt.c文件的idt_init函数

/*加载idt*/
uint64_t idt_operand = ((sizeof(idt)-1) | ((uint64_t)(uint32_t)idt << 16)); //低16位是idt的大小,高48位是IDT的基址。因为idt是32位,左移16位后会丢失高16位,所以先转换为64位再左移
asm volatile("lidt %0" : : "m" (idt_operand));   //加载IDT,IDT的0~15位是表界限,16~47位是表基址
put_str("idt_init done\n");

由于C语言中没有48位的数据类型,所以我们用64位的变量idt_operand来代替,这是没问题的,lidt中会取出48位数据做操作数,所以咱们只要保证64位变量中的前48位数据是正确的就行。

先用 sizeof(idt)-1 得到 idt 的段界限 limit,这用作低 16 位的段界限。

接下来再将 idt 的地址挪到高 32位即可,这可以通过把 idt 地址左移16 位的形式实现。由于数组名便是地址,即指针,故先将其转换成整数才能参与后面的左移运算。考虑到32位地址经过左移操作后,高位将被丢弃,万一原地址高16位不是0,这样会造成数据错误,故需要将idt地址转换成64位整型后再进行左移操作,这样其高32位都是0,经过左移操作依然能够保证其精度。由于指针只能转换成相同大小的整型,故32位的指针不能直接转换成64位的整型,所以采取迂回的作法,先将其转换成uint32_t,再将其转换成uint64_t,之后再对这个64位的无符号整型数据进行左移16位操作。这样idt地址被移到了16~48 位,低 16位自动填充为 0。

之后再将以上两步的结果通过“按位或”运算符1组合到一起后,存储到变量 idt_operand 中。

虽然经过以上的三步得到的操作数是64位,但由于lidt的操作数是从内存地址处获得的,所以lidt依然只在该地址处(&idt_operand)取其中的48位数据当作操作数。

然后,通过内联汇编的形式将变量idt_operand通过内存约束m的形式传给lidt作为操作数,该操作数对应的内存约束用序号占位符%0表示,所以lidt %0便将idt载入了IDTR寄存器。

在”lidt %0”中, %0其实是idt_operand的地址&idt_operand,并不是idtoperand的值。 原因是AT&T语法的汇编语言把内存寻址放在最高级,任何数字都被看成是内存地址(所以立即数需要加前缀$表示),所以lidt %0直接便去%0指向的内存地址处获取48位的操作数。 而在Intel汇编语法中,立即数是最高级,也就是说数字就是数字,不代表地址,内存寻址并不是最高级,所以内存寻址需要用显式用中括号D的方式,如lidt [idt_ptr]。

 

init_all用来做所有初始化相关的工作,而中断初始化只是其中之一。

/kernel/init.c

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

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

 

/kernel/init.h

#ifndef __KERNEL_INIT_H
#define __KERNEL_INIT_H

void init_all(void);
#endif

 

/kernel/main.c

为了让中断程序运行,我们得打开中断才行,打开中断是用sti指令,它将标志寄存器eflags中的IF位置1,这样来自中断代理8259A的中断信号便被处理器受理啦。 外部设备都是接在8259A的引脚上,由于我们在8259A中已经通过IMR寄存器将除时钟之外的所有外部设备中断都屏蔽了,这样开启中断后,处理器只会收到源源不断地时钟中断。

#include "print.h"
#include "init.h"
void main(void){
    put_str("I am kernel\n");
    init_all();
    asm volatile("sti");    //为演示中断,在此临时开中断,sti的作用是将IF=1
    while (1);
}

 

 

编译

使用 -fno-builtin 选项编译时,编译器不会将代码中的内建函数优化成编译器提供的优化版本。

当使用 -fno-stack-protector 选项编译时,编译器不会在生成的代码中插入栈保护代码。

nasm -f elf -o build/print.o lib/kernel/print.S
nasm -f elf -o build/kernel.o kernel/kernel.S
gcc -m32 -I lib/kernel -m32 -I kernel/ -c -fno-builtin -o build/main.o kernel/main.c
gcc -m32 -I lib/kernel -m32 -I kernel/ -c -fno-stack-protector -fno-builtin -o build/interrupt.o kernel/interrupt.c
gcc -m32 -I lib/kernel -m32 -I kernel/ -c -fno-builtin -o build/init.o kernel/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

写入硬盘

dd if=bin/mbr.bin of=/bochs/bin/dreams.img bs=512 count=1 conv=notrunc
dd if=bin/loader.bin of=/bochs/bin/dreams.img bs=512 count=4 seek=2 conv=notrunc
dd if=build/kernel.bin of=/bochs/bin/dreams.img bs=512 count=200 seek=9 conv=notrunc

结果图如下:

每执行一个中断处理程序将会打印字符串“interrupt occur!”一次并换行,这里出现了多次中断,成功!

查看中断描述符

info idt

查看中断门描述符

info idt 0x20

 

 

7.参考

郑钢著操作系统真象还原

田宇著一个64位操作系统的设计与实现

丁渊著ORANGE’S:一个操作系统的实现

暂无评论

发送评论 编辑评论

|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇