关于操作系统:从零开始写-OS-内核-中断处理

4次阅读

共计 7395 个字符,预计需要花费 19 分钟才能阅读完成。

系列目录

  • 序篇
  • 筹备工作
  • BIOS 启动到实模式
  • GDT 与保护模式
  • 虚拟内存初探
  • 加载并进入 kernel
  • 显示与打印
  • 全局描述符表 GDT
  • 中断解决
  • 虚拟内存欠缺
  • 实现堆和 malloc
  • 创立第一个内核线程
  • 多线程运行与切换
  • 锁与多线程同步
  • 过程的实现
  • 进入用户态
  • 一个简略的文件系统
  • 加载可执行程序
  • 零碎调用的实现
  • 键盘驱动
  • 运行 shell

中断

中断在 CPU 中扮演着十分重要的角色,对硬件的响应,工作的切换,异样的解决都离不开中断。它既是驱动所有的源能源,又是给咱们带来各种苦楚的万恶之源。

中断相干的问题将会贯通整个 kernel 的开发,它的难点就在于它对代码执行流的打乱,以及它的不可预知性。本篇只是初步搭建起中断解决的框架,前面它会始终如影随形,同时也是考验 kernel 设计实现的试金石。

概念筹备

常常有一些对于中断、异样,硬中断,软中断等文字上的概念混同,而且中英文在这些术语的应用也有些不对立。为了了解上的对立,咱们在前面的术语应用上做一个申明:

  • 中断,这个词用作总体概念,即它包含各种类型的中断和异样;

而后在中断这个总概念下,做如下分类:

  • 异样(exception):外部中断,它是 CPU 外部执行时遇到的谬误,在英文表述上它有 exceptionfaulttrap 等几类,咱们个别都统称为 exception;此类问题个别是不可屏蔽,必须被解决的;
  • 硬中断(interrupt):内部中断,个别就是其它硬件设施发来的,例如时钟,硬盘,键盘,网卡等,它们能够被屏蔽;
  • 软中断(soft int):严格来说这不是中断,因为这是由 int 指令被动触发的,最罕用的就是零碎调用,这是用户被动申请进入 kernel 态的形式;它的解决机制和其它中断是一样的,所以也归在中断里;

前面咱们就用英文单词 exception 来指第一类,即 CPU 外部异样和谬误,这一点没有歧义;而 interrupt 这个单词用来专指第二类,即硬中断,这也是 Intel 文档上的原始用法;至于第三类,能够先疏忽,因为目前咱们还不须要探讨它;

至于 中断 这个中文词,咱们用来指代包含上述的所有类型,是一个大概念,留神咱们不将它与单词 interrupt 等同。

留神这纯正是我的集体用法和规定,只是为了不便前面术语的表述和了解上的对立。

中断表述符表

之所以 中断 这个词会引起歧义,我想可能是因为所有以上这些货色的处理函数都放在了 中断描述符表 IDT(Interrupt Descriptor Table)里治理,导致如同 中断 超出了 interrupt 这个词自身的领域,把 exception 也给囊括了进来。这也是为什么我想用中文词 中断 来示意总体概念,而用英文的 interruptexception 示意它上面的两个子概念。

IDT 表项

回到中断描述符表 IDT,它的次要作用就是定义了各种中断 handler 函数,它的每个 entry 的构造定义如下:

struct idt_entry_struct {
  // the lower 16 bits of the handler address
  uint16 handler_addr_low;
  // kernel segment selector
  uint16 sel;
  // this must always be zero
  uint8 always0;
  // attribute flags
  uint8 attrs;
  // The upper 16 bits of the handler address
  uint16 handler_addr_high;
} __attribute__((packed));
typedef struct idt_entry_struct idt_entry_t;

代码链接在 src/interrupt/interrupt.h,对于 IDT 的文档能够参考这里。

具体每个字段的含意请参考以上文档,这里细讲一下其中两个比拟重要的字段:

  • sel:即 selector,这里规定了这个中断处理函数所在的 segment。进入中断解决后,CPU 的 code 段寄存器即 cs 就被替换为该值,所以这个 selector 必须指向 kernelcode segment
  • DPL:这是 attrs 字段中的 2 个 bit,它规定了可能调用,或者说进入这个 handler 所须要的 CPU 的最低特权级;对咱们来说必须将它置为特权级 3 即用户级,否则在用户态下就无奈进入中断了;

当然,下面的 IDT entry 里咱们还缺了一个最重要的局部,就是中断处理函数的地址,这个前面再讲。

构建 IDT

而后就能够定义 IDT 构造,代码在 src/interrupt/interrupt.c:

static idt_entry_t idt_entries[256];

这里预留了 256 个 entry,曾经入不敷出满足咱们的需要。

这其中前 0 ~ 31 项是留给 exception 的。从第 32 项开始,用作 interrupt 处理函数。每一个中断都会有一个中断号,对应的就是 IDT 表中的第几项,CPU 就是依据此找到中断 handler 并跳转过来。

其中 exception 是以下这些,我间接从 wiki 上拿的图:

其中第 14 个 page faualt,即缺页异样,咱们在下一篇虚拟内存欠缺中会重点解决。其它 exception 咱们目前无需关注,因为它们失常状况下不应该呈现。

IDT 从第 32 项开始就是给 interrupt 用的,例如第 32 项就是时钟(timerinterrupt 用的。

中断处理函数

咱们回到下面提到的中断处理函数,或者叫中断 handler,它的地址被写在了下面 IDT 的每一个 entry 里。目前它们还不存在,所以咱们须要定义这些函数。

每个中断的 handler 当然都不一样,然而进入以及来到这些 handler 的前后都会有一些共性的工作须要做,那就是保留以及复原中断产生前的现场,或者叫上下文(context),这次要包含各种 register,它们将会被保留在 stack 中。因而中断的处理过程是相似这样的:

save_context();
handler();
restore_context();

值得注意的是,context 的保留和复原工作是由 CPU 和咱们共同完成的,即 CPU 会主动压入一部分 register 到 stack 中,咱们则依据须要也压入一部分 register 和其它信息到 stack 中。这两局部内容独特组成了中断的 context

中断 CPU 压栈

首先来看 CPU 主动压入 stack 的寄存器:

这里有两种状况:

  • 如果从 kernel 态进入中断的,则只会压入左图的三个值;
  • 如果是从用户态进入的中断,那么 CPU 会压入右图的五个值;

两者的区别就是顶部的 user ss 和 user esp。

咱们能够看一下这里的外在逻辑:CPU 做的事件的目标是很明确的,它保留的是指令 执行流 的上下文状态,这里蕴含了两个外围因素:

  • 中断产生前的 instruction 地位(cseip 提供);
  • 中断产生前的 stack 地位(ssesp 提供);

但为什么只有在产生特权级转换的时候(用户态进入 kernel 态)才须要压入以后 ssesp?因为用户态代码执行和 kernel 态代码是在不同的 stack 里进行的,从用户态进入中断解决,须要转换到 kernel 的 stack 中;等中断解决完结后再回到用户的 stack 里,所以须要将用户的 stack 信息保留下来。

下图展现了在用户态下产生中断时,stack 的跳转:

而如果中断产生在 kernel 态,那么状况会变得简略很多,因为本来就在 kernel 的 stack 中,中断解决依然是在同一个 stack 中执行,所以它的状况有点像是一次一般的函数调用(当然略有不同):

咱们看到 CPU 只负责保留了 instructionstack 相干的寄存器,却没有保留 data 相干的寄存器,这次要包含了几个通用寄存器 eaxecxedxebxesiedi 以及 ebp,当然还有几个 data 段寄存器 dsesfsgs 等。

为什么 CPU 不论这些寄存器呢?其实我也不太明确,只能说这是 CPU 体系架构设计决定的。我集体的了解是,CPU 是一个指令执行者,它只关怀指令的执行流,这包含了 instructionstack 这两个外围因素;至于 data,则应交由下层逻辑,也就是代码自身的逻辑来负责管理。这外面的设计理念,实际上是要对硬件和软件各自负责的逻辑做一个切分,实际上这很难界定,也能够了解为是历史遗留,从一开始就这么定下了。

中断 handler

扯远了,咱们回到中断 context 保留的问题。既然 CPU 没有保留 data 相干的寄存器,那就由咱们本人来保留。

咱们自顶向下,来看中断 handler 的代码。首先,每一个中断显然都有它本人的中断 handler,或者叫 isr (interrupt service routine)

isr0
isr1
isr2
...

这里每一个 isr* 有一个通用的构造,这里用了 asm 里的 macro 语法来定义:

; exceptions with error code pushed by CPU
%macro DEFINE_ISR_ERRCODE 1
  [GLOBAL isr%1]
  isr%1:
    cli
    push byte %1
    jmp isr_common_stub
%endmacro

; exceptions/interrupts without error code
%macro DEFINE_ISR_NOERRCODE 1
  [GLOBAL isr%1]
  isr%1:
    cli
    push byte 0
    push byte %1
    jmp isr_common_stub
%endmacro

而后咱们就能够定义所有 isr*:

DEFINE_ISR_NOERRCODE  0
DEFINE_ISR_NOERRCODE  1
DEFINE_ISR_NOERRCODE  2
DEFINE_ISR_NOERRCODE  3
DEFINE_ISR_NOERRCODE  4
DEFINE_ISR_NOERRCODE  5
DEFINE_ISR_NOERRCODE  6
DEFINE_ISR_NOERRCODE  7
DEFINE_ISR_ERRCODE    8
DEFINE_ISR_NOERRCODE  9
...

为什么会有两种 isr 定义呢?其实下面对于 CPU 主动压栈有一点遗记说了,除了下面提到的对于 instructionstack 的信息保留,对于某些 exception,CPU 还会压入一个 error code。至于哪些 exception 会压入 error code,能够参考下面给出那张表格。

因为存在这么一个奇葩的不一致性,为了对立起见,对于那些不会压入 error code 的 exception,咱们手动补充一个 0 进去。

所以总结下,isr 是中断解决的总入口,它次要做了这几件事件:

  • 敞开 interrupt,留神这个只能屏蔽硬中断,对于 exception 是有效的;
  • 压入中断号码;
  • 跳转进入 isr_common_stub

来到 isr_common_stub,代码在 src/interrupt/idt.S,这里是保留和复原 data 相干寄存器上下文,以及进入真正的中断解决的中央。

这是它的前半段:

[EXTERN isr_handler]

isr_common_stub:
  ; save common registers
  pusha

  ; save original data segment
  mov ax, ds
  push eax

  ; load the kernel data segment descriptor
  mov ax, 0x10
  mov ds, ax
  mov es, ax
  mov fs, ax
  mov gs, ax

  call isr_handler

紧接着是它的后半段:

interrupt_exit:
  ; recover the original data segment
  pop eax
  mov ds, ax
  mov es, ax
  mov fs, ax
  mov gs, ax

  popa
  ; clean up the pushed error code and pushed ISR number
  add esp, 8

  ; make sure interrupt is enabled
  sti
  ; pop cs, eip, eflags, user_ss, and user_esp by processor
  iret

这里其实是只有一个函数 isr_common_stub(前半段并没有 ret)。interrupt_exit 只是我加的一个标记,因为当前在别的中央会用到。当然其实汇编里原本也没什么函数的概念,实质上都是标记而已。

咱们看到前半段做了几件事件:

  • pusha 保留了所有通用寄存器;
  • 接着保留 data 段寄存器 ds
  • 批改 data 段寄存器为 kernel 的,而后调用真正的中断解决逻辑 isr_handler(前面再讲);

在中断处理完毕后,后半段的复原阶段做了以下几件事件,实质上是前半段的逆操作:

  • 复原原来的 data 段寄存器;
  • popa 复原所有通用寄存器;
  • 跳过栈里的 error code 和中断号;
  • 复原中断并返回;

这样咱们能够画出残缺的中断产生时的 stack,其中绿色局部是保留的中断上下文,包含了 CPU 主动压入和咱们本人压入的局部:

其中下方粉红色局部是真正的中断解决外围函数 isr_handler,它是用 C 语言编写的:

typedef void (*isr_t)(isr_params_t);

void isr_handler(isr_params_t regs);

其中参数 isr_params_t 构造定义为:

typedef struct isr_params {
  uint32 ds;
  uint32 edi, esi, ebp, esp, ebx, edx, ecx, eax;
  uint32 int_num;
  uint32 err_code;
  uint32 eip, cs, eflags, user_esp, user_ss;
} isr_params_t;

之所以 isr_handler 能够以此构造作为参数,正是因为图中绿色局部的压栈,而后通过 call isr_handler,绿色局部就正好就对应了 isr_params_t 构造。红色箭头指向的正是参数 isr_params_t 的地址。

通过 isr_params_t 这个参数,咱们在 isr_handler 里就能获取无关中断的所有信息:

void isr_handler(isr_params_t params) {
  uint32 int_num = params.int_num;

  // ...

  // Bottom half of interrupt handler - now interrupt is re-enabled.
  enable_interrupt();

  // handle interrupt
  if (interrupt_handlers[int_num] != 0) {isr_t handler = interrupt_handlers[int_num];
    handler(params);
  } else {monitor_printf("unknown interrupt: %d\n", int_num);
    PANIC();}
}

上半局部都是对于 CPU 和中断外设芯片的必要交互,你能够临时疏忽。isr_handler 作为一个通用的中断解决入口,它会依据中断号来找到对应中断的真正处理函数,这些函数咱们定义在了 interrupt_handlers 这个数组里:

static isr_t interrupt_handlers[256];

它们通过 register_interrupt_handler 函数来设置:

void register_interrupt_handler(uint8 n, isr_t handler) {interrupt_handlers[n] = handler;
}

以上的代码都位于 src/interrupt/ 中,代码不多然而比拟绕,仔细阅读应该不难理解。

关上时钟 interrupt

以上都是实践局部,咱们须要一个真正的中断来实际一下成果。最现实的 interrupt 当然是时钟(timer)中断,这也是前面用来驱动多任务切换的外围中断。初始化 timer 的代码在 src/interrupt/timer.c 里,这里不多赘述,次要都是硬件端口相干的操作,设置了时钟频率,以及最重要的注册中断处理函数:

register_interrupt_handler(IRQ0_INT_NUM, &timer_callback);
  • IRQ0_INT_NUM 就是 32,这是时钟中断号;
  • timer_callback 咱们能够简略做打印解决:
static uint32 tick = 0;

static void timer_callback(isr_params_t regs) {monitor_printf("tick = %d\n", tick++);
}

而后就能够尝试验证:

int main() {init_gdt();

  monitor_clear();

  init_idt();
  init_timer(TIMER_FREQUENCY);
  enable_interrupt();

  while (1) {}}

运行 bochs,运气好的话能看到这个:

触发 exception

timer 是硬中断 interrupt,咱们再来看一个 exception 的例子,比方 page fault,中断号为 14:

register_interrupt_handler(14, page_fault_handler);
void page_fault_handler(isr_params_t params) {monitor_printf("page fault!\n");
}

如何引发 page fault?很简略,只有咱们去拜访一个 page table 里没有被映射的 virtual 地址就能够了。

int main() {init_gdt();

  monitor_clear();

  init_idt();
  register_interrupt_handler(14, page_fault_handler);
    
  int* ptr = (int*)0xD0000000;
  *ptr = 5;

  while (1) {}}
void page_fault_handler(isr_params_t params) {monitor_printf("page fault!\n");
}

运行 bochs,运气好的话能看到这个:

能够看到它在不停地打印,这是因为咱们的 page fault 处理函数除了打印什么也没做。page fault 解决完结后,CPU 会尝试再度去拜访之前引发 page fault 的内存地址,所以会再次触发 page fault。对于 page fault 的真正解决,咱们留待下一篇,虚拟内存欠缺。

正文完
 0