内核的雏形(下) -- 添加异常中断响应机制

2022-06-27 14:58:49 浏览数 (1)

1. 引言

经过上一篇文章中历史性的一跳,以及堆栈和 GDT 的切换,我们终于进入到了内核:

内核的雏形(上) -- 创建属于 kernel 的堆栈与 GDT

接下来我们要做的当然就是在内核中创建进程并且调度起来,但在这之前,我们要问,到底应该如何调度进程呢?

要想在一个 CPU 上不断切换进程以实现多个进程的并发调度,我们就必须借助于中断机制,因此,在实现内核进程前,我们首先需要初始化和添加中断处理。

此前的文章中,我们已经介绍过,添加中断处理的工作只有两部分:

  1. 建立中断描述附表 IDT
  2. 初始化 8259A 可编程中断控制器

本文基本上完全是之前文章的重复,有任何疑问,请回顾参看此前的两篇文章:

保护模式下的中断和异常(上) -- 硬件原理篇

保护模式下的中断和异常(下) -- 软件实战篇

由于本文将要介绍的代码在上述两篇文章中均有详细介绍,且本次新增代码较多,为了便于阅读,本文不再详细罗列所有代码,因此,如果想要阅读详细代码,请先拉取源码:

https://github.com/zeyu203/techlogOS

tag:v0.0.2

  • 温馨提示:本文是一个自制操作系统系列,如果你是首次读到这篇文章,很可能理解起来会非常吃力,请翻到本文文末列出的本系列目录,从头阅读,一定能让你 0 基础从头编写属于你自己的操作系统!

2. 代码组织方式

本次代码的目录做了一些调整:

代码语言:javascript复制
.
├── Makefile
├── README.md
├── bochsrc.bxrc
├── boot
│   ├── boot.asm
│   ├── inc
│   │   ├── fat12hdr.asm
│   │   ├── lib.asm
│   │   └── pm.asm
│   └── loader.asm
├── boot.img
├── include
│   ├── functions.h
│   └── global.h
├── kernel
│   ├── global.c
│   ├── i8259.c
│   ├── i8259.h
│   ├── kernel.asm
│   ├── protect.c
│   └── protect.h
└── lib
    ├── extern.asm
    ├── functions.c
    └── interrupt.asm

5 directories, 19 files

2.1 boot

BOIS 完成初始化后,最先跳转进入 boot 目录下的 boot.asm,如前文介绍的,boot.asm 做的事情就是在他所在的软盘上寻找 loader 并加载到内存,然后跳转。

loader.asm 中就是 loader 的源码了,他的工作主要是从软盘读取 kernel,初始化保护模式所需的内存数据结构,跳转进入内核。

2.2 kernel

当前代码中,kernel 的主要工作是将 loader 初始化过的保护模式所需的内存数据结构,也就是 GDT 复制到内核的内存地址空间中,然后就是本文将要叙述的初始化 IDT 并加载以及对 8259A 进行初始化开启异常、中断响应机制的逻辑。

具体的实现代码,我们都是在相应的 C 语言执行文件中实现的,例如 GDT 的复制逻辑在 protect.c 中,异常/中断逻辑在 i8259.c 中,原则上,每个 .c 文件对应一个同名的 .h 文件用来声明 .c 中定义供外部使用的函数,而对于仅在 .c 文件中使用的局部函数,则直接在 .c 文件中进行声明,并标识 static 关键字。

比较特殊的是 global.c,他通过宏 GLOBAL_VARIABLES_HERE 实现让 include/global.h 中声明的变量完成仅有一次的定义操作。

2.3 依赖的库文件

项目所依赖的公共函数均放置在 lib 目录下,用于声明他们的 .h 文件放置在 include 目录下。

3. 建立 IDT

3.1 中断描述符

此前我们已经有过充分介绍,也编写了相应的汇编代码,此时,我们完全可以照搬当时的所有代码,但我们现在也可以通过 C 语言来实现他们,这样会显得更加简洁一些。

因此中断描述符我们可以选择在 C 语言中通过结构体来定义(参见 kernel/protect.h):

代码语言:javascript复制
/* IDT 中描述符的个数 */
#define    IDT_SIZE 256

/* 门描述符 */
typedef struct s_gate
{
    unsigned short   offset_low;      /* Offset Low */
    unsigned short   selector;        /* Selector */
    unsigned char    dcount;          /* 该字段只在调用门描述符中有效。*/
    unsigned char    attr;            /* P(1) DPL(2) DT(1) TYPE(4) */
    unsigned short   offset_high;     /* Offset High */
} GATE;

门描述符中存在一个 char 类型的 dcount 字段,他只在调用门描述符中起作用,如果在利用调用门调用子程序时引起特权级的转换和堆栈的改变,需要将外层堆栈中的参数复制到内层堆栈。这个字段就是用于说明这种情况发生时,要复制的双字参数的数量。

3.2 创建 IDT

有了门描述符的定义,我们就可以划分出 IDT_SIZE 个门描述符的空间,实现中断描述符表 IDT 的创建了(参见 kernel/protect.c):

代码语言:javascript复制
unsigned char gdt_ptr[6];

/* idt_ptr[6] 共 6 个字节:0~15:Limit  16~47:Base。用作 sidt/lidt 的参数。*/
unsigned short* p_idt_limit = (unsigned short*)(&idt_ptr[0]);
unsigned int* p_idt_base  = (unsigned int*)(&idt_ptr[2]);
*p_idt_limit = IDT_SIZE * sizeof(GATE) - 1;
*p_idt_base  = (unsigned int)&idt;

3.3 加载 IDT

在 kernel.asm 中,紧接着上一篇文章最后 lgdt 加载 gdt 的代码,我们通过 lidt 指令加载 idt 的内容(参见 kernel/kernel.asm):

代码语言:javascript复制
extern    idt_ptr
lidt    [idt_ptr]

4. 初始化 8259A

4.1 底层 io 指令实现

我们现在希望尽可能通过 C 语言来实现我们的内核,因此,我们只在汇编语言中封装最基本的指令,比如我们即将要使用的 in、out 指令(参见 lib/extern.asm):

代码语言:javascript复制
global  in_byte
global  out_byte

; ------------------------------------------------------------
; void out_byte(unsigned short port, unsigned char value);
; ------------------------------------------------------------
out_byte:
    mov    edx, [esp   4]        ; port
    mov    al, [esp   4   4]    ; value
    out    dx, al
    nop                        ; 延迟等待硬件操作完成
    nop
    ret

; --------------------------------------------------
; unsigned char in_byte(unsigned short port);
; --------------------------------------------------
in_byte:
    mov    edx, [esp   4]        ; port
    xor    eax, eax
    in    al, dx
    nop                     ; 延迟等待硬件操作完成
    nop
    ret

4.2 定义 8259A 端口与中断向量

接着,我们在 C 语言中通过宏定义 8259A 的端口与中断向量(参见 kernel/i8259.h):

代码语言:javascript复制
/* 8259A interrupt controller ports. */
#define INT_M_CTL     0x20 /* I/O port for interrupt controller       <Master> */
#define INT_M_CTLMASK 0x21 /* setting bits in this port disables ints <Master> */
#define INT_S_CTL     0xA0 /* I/O port for second interrupt controller<Slave>  */
#define INT_S_CTLMASK 0xA1 /* setting bits in this port disables ints <Slave>  */

/* 中断向量 */
#define    INT_VECTOR_IRQ0            0x20
#define    INT_VECTOR_IRQ8            0x28

4.3 初始化 8259A

一切准备就绪,接下来我们就可以实现 8259A 的初始化逻辑了(参见 kernel/i8259.c):

代码语言:javascript复制
void init_8259A()
{
    /* Master 8259, ICW1. */
    out_byte(INT_M_CTL,    0x11);

    /* Slave  8259, ICW1. */
    out_byte(INT_S_CTL,    0x11);

    // ...
}

5. 定义 CPU 异常响应函数

完成了 8259A 中断控制器的初始化工作,idt 表也已经建立并被加载,此时我们操作系统的中断机制所需要的一切准备条件都已经就绪了,这时就可以编译运行起来了。

但是,你会发现编译运行后的结果与上一篇文章最后我们跳转进入 kernel,完成 gdt 迁移后的效果完全一致,这是当然的,我们既没有通过定义中断响应函数告诉操作系统触发中断后要执行什么指令,也没有触发中断,自然什么都没有发生。

那么,接下来,就让我们编写中断响应函数。

5.1 创建处理异常的中断响应函数

正如前面介绍的,硬件触发的中断分为异常与硬件中断两种,硬件中断是通过级联在两块 8259A 芯片所暴露出来的 15 个端口触发的,而异常则是 CPU 预设的,相比于硬件中断,异常机制更加容易触发,因此这里先对异常进行处理,下文再来处理硬件中断。

我们的异常响应函数逻辑很简单,就是通过红色的字体打印出具体的异常原因和 error code,具体的异常对照表在前文中已经介绍过,这里就不赘述了(参见 kernel/i8259.c)。

代码语言:javascript复制
void exception_handler(int vec_no, int err_code, int eip, int cs, int eflags) {
    int i;
    int text_color = 0x74; /* 灰底红字 */

    char * err_msg[] = {
        "#DE Divide Error",
        "#DB RESERVED",
        "--  NMI Interrupt",
        "#BP Breakpoint",
        "#OF Overflow",
        "#BR BOUND Range Exceeded",
        "#UD Invalid Opcode (Undefined Opcode)",
        "#NM Device Not Available (No Math Coprocessor)",
        "#DF Double Fault",
        "    Coprocessor Segment Overrun (reserved)",
        "#TS Invalid TSS",
        "#NP Segment Not Present",
        "#SS Stack-Segment Fault",
        "#GP General Protection",
        "#PF Page Fault",
        "--  (Intel reserved. Do not use.)",
        "#MF x87 FPU Floating-Point Error (Math Fault)",
        "#AC Alignment Check",
        "#MC Machine Check",
        "#XF SIMD Floating-Point Exception"
    };

    clear_screen();

    print_with_color("Exception! --> ", text_color);
    print_with_color(err_msg[vec_no], text_color);
    print("nn");
    print_with_color("EFLAGS:", text_color);
    disp_int(eflags);
    print_with_color("CS:", text_color);
    disp_int(cs);
    print_with_color("EIP:", text_color);
    disp_int(eip);
    print_with_color("Error code:", text_color);
    disp_int(err_code);
}

5.2 定义中断处理程序

参见 kernel/kernel.asm

代码语言:javascript复制
; 中断处理程序
divide_error:
    push    0xFFFFFFFF    ; no err code
    push    0            ; vector_no    = 0
    jmp    exception
single_step_exception:
    push    0xFFFFFFFF    ; no err code
    push    1            ; vector_no    = 1
    jmp    exception

; ...

exception:
    call    exception_handler
    add    esp, 4*2        ; 让栈顶指向 EIP,堆栈中从顶向下依次是:EIP、CS、EFLAGS
    hlt

5.3 创建中断描述符

下面我们就来定义这前 32 个代表 CPU 异常的 IDT 表项(参见 kernel/i8259.c):

代码语言:javascript复制
// -------------------
// 初始化中断门
// -------------------
void init_prot()
{
    init_8259A();

    // 全部初始化成中断门(没有陷阱门)
    init_idt_desc(INT_VECTOR_DIVIDE,    DA_386IGate,
                  divide_error,         PRIVILEGE_KRNL);

    init_idt_desc(INT_VECTOR_DEBUG,     DA_386IGate,
                  single_step_exception,PRIVILEGE_KRNL);

    // ...
}

// --------------
// 初始化中断门
// --------------
void init_idt_desc(unsigned char vector, unsigned char desc_type,
        int_handler handler, unsigned char privilege)
{
    GATE *    p_gate     = &idt[vector];
    unsigned int base    = (unsigned int)handler;
    p_gate->offset_low   = base & 0xFFFF;
    p_gate->selector     = SELECTOR_KERNEL_CS;
    p_gate->dcount       = 0;
    p_gate->attr         = desc_type | (privilege << 5);
    p_gate->offset_high  = (base >> 16) & 0xFFFF;
}

此处用到的中断向量、描述符类型、特权级等等宏比较多,就不单独在这里展示了,请参看文末源码

6. 触发异常

一切准备就绪,我们只需要在执行一个错误的指令,就可以触发异常了。

比如跳转到一个被保护的内存区域:

代码语言:javascript复制
jmp 0x40:0

显然,这样会触发一个保护模式异常,让我们来运行一下:

7. 添加硬件中断响应函数

经过一系列的设置,我们终于让我们的操作系统内核可以响应 CPU 异常了。

可是我们更加关注的是硬件触发的中断响应,这才是我们设置 8259A 的初衷。

7.1 创建中断响应函数

首先,我们用 C 语言编写一个通用的中断响应函数,函数很简单,打印中断号(参见 kernel/i8259.c):

代码语言:javascript复制
// ------------------
// 硬件中断响应函数
// ------------------
void spurious_irq(int irq)
{
    print("spurious_irq: n");
    disp_int(irq);
}

7.2 创建中断响应跳转地址

然后,我们需要在 kernel.asm 中添加跳转地址,这部分代码放置在 lib/interrupt.asm 中:

代码语言:javascript复制
; ---------------------------------
; 中断和异常 -- 硬件中断
; ---------------------------------
%macro  hwint_master  1
    push  %1
    call  spurious_irq
    add   esp, 4
    hlt
%endmacro
; ---------------------------------

ALIGN   16
hwint00:        ; Interrupt routine for irq 0 (the clock).
    hwint_master  0

; ...

7.3 添加 idt 表项

接着,我们需要使用这 15 个硬件中断跳转地址初始化 15 个对应的 idt 表项,接着上文的 32 个 idt 表项,给两片级联的 8259A 所暴露出来的 15 个引脚分别定义一个 idt 表项(参见 kernel/i8259.c):

代码语言:javascript复制
// 初始化 8259A 对应的硬件中断
init_idt_desc(INT_VECTOR_IRQ0   0,      DA_386IGate,
              hwint00,                  PRIVILEGE_KRNL);

init_idt_desc(INT_VECTOR_IRQ0   1,      DA_386IGate,
              hwint01,                  PRIVILEGE_KRNL);

// ...

8. 触发硬件中断

8.1 放开中断屏蔽

我们在此前的代码中,已经屏蔽了所有的硬件中断(参见 kernel/i8259.c):

代码语言:javascript复制
out_byte(INT_M_CTLMASK, 0xFF);

下面我们测试一下键盘中断的响应,首先需要将对应的屏蔽位置为 0(参见 kernel/i8259.c):

代码语言:javascript复制
out_byte(INT_M_CTLMASK, 0xFD);

然后在 kernel/kernel.asm 中通过 sti 指令置 IF 位。

8.2 运行系统

我们编译运行系统:

8.3 触发中断

看起来什么都没有发生,此时我们按下键盘,可以看到,中断响应函数正确执行,显示出了键盘中断号:

参考资料

《Orange’s 一个操作系统的实现》。 《linux 内核完全注释》。

0 人点赞