低并发编程,周一很颓废,周四很硬核
回顾
《教程 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
页目录表与页表 我们分别设置了一个页目录表与四个页表,来覆盖整个可支持的 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. 结束(之后就跳转到中断程序了)

键盘中断的实现
键盘中断的实现
// 键盘 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;
}
该函数实现的功能很简单,就是将键盘的扫描码读出,然后打印到屏幕上。
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
可以看到保存上下文就是将各种寄存器的值压入堆栈,在中断返回之前再将这些值弹出堆栈。
// 键盘初始化工作
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 里初始化键盘设备。
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:615> print-stack
| STACK 0x901c4 [0x00009080] (<unknown>)
后记
后记
本文以一个键盘中断为例,大体讲了一下打通一个中断的过程,键盘中断打通后,其他中断就都是一样的道理了。希望大家结合代码,自己动手尝试一下,因为毕竟文章为了达到极简的效果,只是讲解主干思路。
文末的代码除了实现键盘中断并将字符输出到屏幕游标处之外,还实现了一点点时钟中断和硬盘中断,供大家参考学习。
配套代码地址:
https://gitee.com/sunym1993/flashos_simple_labs
交流群:273392557(本人可能较忙没太多时间经营,大家想加的话可以先加着,相互讨论)
参考资料:




