暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

自制 os 极简教程 4:初入内核打通中断

低并发编程 2021-08-12
596

低并发编程,周一很颓废,周四很硬核

回顾

《教程 0:计算机启动流程硬核科普》从整体上讲述了计算机的启动原理及流程,《教程 1:写一个操作系统有多难》从整体上分享了我从零开始写一个操作系统的入坑经历,这两篇算是个引子,为入坑自制 os 做好心理准备。

《教程 2:史上最难的 hello world》就是一篇任何一个技术教程都包含的经典 hello world 教程,只不过操作系统的 hello world 难度系数相对较高,所以得占用一篇来讲解。《教程 3:你离内核还差十万八千里》是在正式写内核代码前要做的苦力,基本上是相对比较固定,没有太多自由发挥空间的几段代码。

有了之前这么多铺垫,今天终于可以正式进入内核代码了!没有读过之前几篇的朋友也不必着急,今天我们的任务就是干干净净地用 c 语言从这个入口函数写起,之前的代码将苦力活都做好后,最后一条汇编语句就是乖乖地跳转到这个入口函数开始执行。

init main.c

int kernel_start() {

}



到目前为止完成了啥

那之前的苦力活都做了什么,我们在这里梳理一下。

除去一些加载和跳转,最重要的其实就是使 CPU 从实模式进入保护模式,并开启了分段机制、中断机制和分页机制,而为了开启这三个机制分别在内存中设置了全局描述符表(GDT)、中断描述符表(IDT)和页目录表与页表。

全局描述符表 GDT 我们是这样设置的

gdt:
  dq 0x0000000000000000 ;无
  dq 0x00c09a0000000fff ;代码段
  dq 0x00c0920000000fff ;数据段
  dq 0x0000000000000000 ;无
  times 252 dq 0        ;留给ldt与tss


中断描述符表 IDT 我们暂时都指向了一个空方法 ignore_int,此时 CPU 如果收到中断信号,将会通过中断号找到中断描述符表的对应项,暂时都指向了这个空方法,我们今天的目标就是要打通几个中断,使其可以产生效果,比如键盘中断。

页目录表与页表 我们分别设置了一个页目录表与四个页表,来覆盖整个可支持的 16MB 的内存。

这三种数据结构,在内存中目前的布局如下。

OK,之前的准备工作做完了,那今天我们的目标只有一个,写几个中断处理程序,并将其绑定在中断描述符表的对应中断号上,就这么一个事。

好了,我们就从这个干干净净的空方法,开启我们的内核之旅吧!

init main.c

int kernel_start() {
    
// 这里就是我们的os代码了,你敢信?
    ... 
    
// 系统怠速
   for (;;) {}
}



中断处理流程

要想实现一个中断的功能,首先就得了解中断处理流程,我们拿键盘举例。

当按下键盘中的按键时,键盘设备便会将按键的编号存储在输出缓冲区,等待程序读取。同时发送信号给 8259A 芯片的指定引脚(IRQ1 号引脚)。

由于我们之前已经对 8259A 芯片进行了编程,使得其 IRQ1 引脚对应到了中断号 0x21 上,因此该芯片将会通过自己的控制电路,向 CPU 发送中断号为 0x21 的信号。过程如下。

CPU 收到了这个中断号后,会去我们之前设置好的中断描述符表中,寻找这个中断号对应的中断描述符,从中取出段选择子和偏移地址,最终经过分段和分页机制,转化成物理地址,这个物理地址就是中断处理程序的内存起始地址,CPU 会将跳到这个地址上开始执行程序。过程如下。

不过在跳转到我们的中断服务程序开始执行前,CPU 在硬件层面帮我们做了一些压栈操作。

1. 如果发生了特权级转移,压入之前的堆栈段寄存器 SS 及栈顶指针 ESP 保存到栈中,并将堆栈切换为 TSS 中的堆栈。(这个后面再说)

2. 压入标志寄存器 EFLAGS。

3. 压入之前的代码段寄存器 CS 和指令寄存器 EIP,相当于压入返回地址。

4. 如果此中断有错误码的,压入错误码 ERROR_CODE

5. 结束(之后就跳转到中断程序了)

所以接下来我们就来实现这个中断处理程序,并且将 0x21 号中断描述符写入中断描述符表,并指向这个中断处理程序。

键盘中断的实现

新建 keyboard.c 文件,写一个函数叫 keyboard_interrupt()
// 键盘 buffer 寄存器端口号为 0x60
#define KBD_BUF_PORT 0x60
// 键盘中断处理函数
void keyboard_interrupt() {
 int scancode = inb(KBD_BUF_PORT);
 dprint_info("  <-- keyboard_intr");
 dprint_info_hex(scancode, 0);
    return;
}


该函数实现的功能很简单,就是将键盘的扫描码读出,然后打印到屏幕上。

接下来看似把这个中断处理程序映射到中断描述符表的对应项上就行了,但还不行,中断处理程序还要保存上下文信息,并在返回时将上下文信息恢复。但这部分是通用的,且用汇编写比较方便,所以单独写在 intr.s 文件中,如下。
extern _keyboard_interrupt
_keyboard_interrupt_entry:
 push _keyboard_interrupt
 xchg [esp],eax
 ;保存上下文
 push ds
 push es
 push fs
 push gs
 pushad
 ;内核代码数据段选择符
 mov edx,10h
 mov ds,dx
 mov es,dx
 mov fs,dx
 ;真正调用中断处理函数
 call eax
 ;中断退出
 popad
 pop gs
 pop fs
 pop es
 pop ds
 pop eax
 iretd


可以看到保存上下文就是将各种寄存器的值压入堆栈,在中断返回之前再将这些值弹出堆栈。

此外还需要做的是将段基址寄存器切换为内核使用的代码段选择子与数据段选择子,代码段选择子在进入中断时已经由硬件机制帮我们在中断描述符表中做了转换,所以这里只需要将数据段寄存器赋值为内核代码的数据段选择子即可。
这些准备工作做好后,就直接跳到刚刚我们用 c 语言写好的 _keyboard_interrupt 函数即可。
这回整个键盘中断的程序就写好啦,我们只需要将这个汇编代码中 _keyboard_interrupt_entry 地址,映射到中断描述符表中 0x21 号中断描述符上即可,下面我们来做这个事。
中断描述符表的结构就不在这里赘述了,过于细节,而且上一篇已经讲过,这里我们直接抽象成一个函数,这个函数的作用就是在中断描述符表中添加一个中断描述符,然后我们利用这个函数将键盘中断程序的地址,添加到 0x21 号中断描述符中。
// 键盘初始化工作
void keyboard_init() {
  // 设置键盘中断陷阱门
  set_intr_gate(0x21, &keyboard_interrupt_entry);
}   



// 设置中断门描述符
#define set_intr_gate(n,addr) \
 _set_gate(idt + n*8,15,8,addr)



// 通用的设置门描述符
#define _set_gate(gate_addr,type,dpl,addr) \
__asm__ ("movw %%dx,%%ax\n\t" \
 "movw %0,%%dx\n\t" \
 "movl %%eax,%1\n\t" \
 "movl %%edx,%2" \
 : \
 : "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \
 "o" (*((char *) (gate_addr))), \
 "o" (*(4+(char *) (gate_addr))), \
 "d" ((char *) (addr)),"a" (0x00080000)



最后,我们在 main.c 里初始化键盘设备。

main.c
int kernel_start() {
    // 初始化键盘设备
  keyboard_init();
  dprintk("keyboard init finish\n");
    // 允许中断
  sti();
  // 系统怠速
  for (;;) {}
}


好了,现在点击 run.bat 启动我们的操作系统,可以看到按下键盘后,扫描码被输出在了屏幕上。

当然,我们更希望有下面这个效果,就是将输出的字符打印在屏幕上。
有了这个效果之后,虽然啥功能都没有,但似乎已经可以冒充一个真实的操作系统了,达到以假乱真的目的。
如何实现呢?我们刚刚的键盘中断程序只是将扫描码打印在了屏幕上。
// 键盘中断处理函数
void keyboard_interrupt() {
  int scancode = inb(KBD_BUF_PORT);
  dprint_info("      <-- keyboard_interrupt");
  dprint_info_hex(scancode, 0);
    return;
}


现在我们还需要加上一段代码,就是将扫描码转换成 ASCII 字符,输出到屏幕上,而且屏幕上的输入游标还要随着不断的输入向右移动,行满或遇到换行符时需要换行,屏幕满时需要向上滚屏。

这一系列功能非常繁琐,我把大体框架写在这,具体代码可以在文末的链接查看。
void keyboard_interrupt() {
    ...

 在数组中找到对应的字符    
char
 cur_char = keymap[index][shift];
    if (cur_char == enter) {
        dprintk("\n");
    } else {
        dprintc(cur_char);
    }
    ...
}

void dprintc(char c) {
    ...
    // 屏幕输出字符(写入显存)
 put_char(c, 0x07, (char*)(pos));
    // 记录光标移动
 pos += 2;
 x++;
  // 刷新光标
 set_cursor();
 return;
}


实现后启动代码,可以看到这个效果。

总结

其实实现中断,知道四件事情即可:

1. 外部设备或软件如何传递给 CPU 中断号

2. CPU 收到中断号后如何寻找到中断程序的入口地址

3. 中断描述符表长什么样子,我如何将一个中断程序绑定在一个中断号上,并注册到中断描述符表中。

4. 找到入口地址并跳转过去之前需要做什么,也就是保存现场

最难理解的就是保存现场的工作,难在细节。
简单用 bochs 验证下,发生中断前查看一下堆栈

<bochs:615> print-stack

 | STACK 0x901c4 [0x00009080] (<unknown>)

 发生键盘中断到跳转到中断程序的第一行代码,此时查看一下堆栈
<bochs:615> print-stack
 | STACK 0x901b8 [0x0000646d] (<EIP>)
 | STACK 0x901bc [0x00000008] (<CS>)
 | STACK 0x901c0 [0x00000202] (<EFLAGS>)
 | STACK 0x901c4 [0x00009080] (<unknown>)
可以看到,本次中断由于都是在 0 特权级(也就是我们常说的内核态),没有发生特权级变化(通常我们操作系统由用户态陷入内核态就是一种特权级变化,但我们此时还没有用户进程的概念,都是在内核态下的代码),所以堆栈没有发生变化。而键盘中断时无错误码的,所以错误码也不存在。
那入栈的信息就只有 EFLAGS、CS、EIP 三个值,我们看 CS 和 EIP,分别是 0x08 和 0x646d,而中断发生前的代码恰好就是在 0x08:0x646d 发生了中断,相当于返回地址了。


后记





本文以一个键盘中断为例,大体讲了一下打通一个中断的过程,键盘中断打通后,其他中断就都是一样的道理了。希望大家结合代码,自己动手尝试一下,因为毕竟文章为了达到极简的效果,只是讲解主干思路。

文末的代码除了实现键盘中断并将字符输出到屏幕游标处之外,还实现了一点点时钟中断和硬盘中断,供大家参考学习。

加油~


配套代码地址

https://gitee.com/sunym1993/flashos_simple_labs

交流群:273392557(本人可能较忙没太多时间经营,大家想加的话可以先加着,相互讨论)

参考资料

《30天自制操作系统》
《操作系统真相还原》
《Linux内核设计的艺术》
《一个 64 位操作系统的设计与实现》
linux-0.11 内核源码

文章转载自低并发编程,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论