实验四 中断
谷建华
2022-10-14 v0.3
#### 实验目的
1. 学习中断描述符,中断处理全流程(特别是执行流、堆栈、权限的切换),包括中断描述符表IDT的功能和设置
2. 学习时钟中断和键盘中断的处理过程
3. 学习分时任务调度
#### 实验预习内容
1. 中断描述表IDT
2. 8259A设置外设中断
3. 分时任务调度原理
4. 键盘中断的字模的获取和处理
#### 实验内容
1. 验证时钟中断的发生
(1) 编译运行实验默认给的源码,观察并分析现象
(2) 修改时钟中断处理程序,使之从输出`#`变成输出`i*(*为第几次中断)`,编译运行后观察并分析现象
2. 修改时钟中断触发时的调度机制,让某个进程被分配到更少的时间片达成进程饥饿的目的(进程必须得活着的能够输出,饥饿不等于删掉进程),编译运行后观察并分析现象.
3. 阅读Orange第6章内容,修改硬件时钟频率,使之大约以1000Hz的频率触发时钟中断,编译运行后观察证实你新修改的频率要比原来的快(这个频率很难测量,只能通过对比的方式估计)
4. 阅读Orange第7章内容并结合实验参考,添加键盘中断,要求如下:
(1) 使之能够正确识别`a`\~`z`(不需要结合shift键区分大小写,就算是shift+`a`也识别为`a`而不是`A`),其余字符一概丢掉.将识别出来的字符通过调用`keyboard.c`中的`add_keyboard_buf`函数将字符添加到字符缓冲区中(需学生自行实现该函数).
(2) 修改`keyboard.c`中的`getch`函数,这是一个非阻塞的函数,即当缓冲区中有字符未输出时,输出缓冲区队头该字符,并将缓冲区队头字符弹出,当缓冲区中没有字符时输出255(u8意义下的-1).
(3) 我们准备了一个贪吃蛇(没错,有了时钟中断和键盘中断就能做一个小游戏了!),修改时钟频率至1000Hz,删除时钟中断处理函数中的所有`kprintf`,在`kernel_main`中仅创建一个单进程,进程入口函数会在`game.h`中给出.最后重新编译kernel就可以游玩了(不用将这一步写进报告).
#### 实验总结
1. 操作系统内核的初始化阶段都完成哪些主要功能?
2. 刚刚进入时钟中断处理程序时系统用的是哪个堆栈,中断号是多少?执行流在哪个位置?在执行中断服务程序的过程中堆栈发生哪些变化?`call [irq_tabel + 4 * %1]`的功能是什么?
3. 外设(时钟、键盘等)中断的中断号是由谁决定的?在哪里决定的?
#### 实验参考
在前几个实验,我们一直是在内核态,而我们的进程一般都是在用户态下执行的,这样进程做出出格的事情也不会伤到内核.那么接下来需要研究`kernel_main`函数是怎么进入用户态.这次实验的重点是从`restart`函数出发进入到用户态,然后又因为中断回到内核态这一整个过程.
##### 1. 中断初始化
###### 如何区分当前执行流为用户态和内核态
平时都说用户态,内核态,但是怎么区分执行流目前的状态靠的就是段寄存器,可以发现段描述符的大小刚好是8字节,所以存储在段寄存器中的段选择子值假设是$ x $,那么$ \lfloor\frac{x}{8}\rfloor $就能够描述选择的是第几个段,即在二进制角度看段选择子的低三位就没有被用上,所以硬件工程师就考虑把这些位利用上,第0~1位用于权限,第2位用于标识段是全局段还是局部段.
对于权限的划分各位需要阅读Orange教材,这里不细展开,总之靠着段选择子的第0~1位可以划分当前段的权限,当权限为用户态时执行流(CS)就是用户态.
###### LDT初始化
LDT(local descriptor table)全称局部描述符表,跟GDT很类似,为什么需要LDT是因为在之前可能不同的任务它们的段寄存器可能会不同,为了区分,每个任务有它自己的独一套LDT,这样切换不同任务时标识更容易些.虽然理论上每个任务都有自己的独一套LDT,但是都是$ 2^{32} $寻址,限制都是靠分页做的(这个我们下个实验再说).所以我们只需要加载一次ldt就能满足所有用户态进程的需求.
为了方便大家理解段之间的区别,这里我们约定GDT里面全是存储内核态会用到的段描述符(除了显存段),LDT里面存储用户态会用到的段描述符.
```C
// 这句话初始化了ldt的段描述符
init_segment(&gdt[5], (u32)ldt, sizeof(ldt) - 1, DA_LDT);
// 在加载了gdt后,ldt就可以通过传入段选择子的方法加载
lgdt [gdt_ptr] ; 使用新的GDT
lldt [SELECTOR_LDT] ; SELECTOR_LDT = 0x28 = 40 = 5 * 8
```
###### 中断
这一次实验我们要开始处理中断了,平时课上也讲过,执行流在用户态的时候肯定不能放心一直将执行流交给用户态(用户不能关中断IF位,要不然就永远无法响应中断了).比如时钟中断,每次执行固定时长后硬件会向内核发送一次中断,在触发中断异常/用户请求系统调用的时候能够回到内核态,不同的触发方式会执行不同的处理函数,那么如何区分这些不同的触发方式就需要IDT了.
###### IDT初始化
IDT(Interrupt Descriptor Table)中断描述符表会根据中断的形式决定进入内核的中断处理函数,而区分形式是靠中断号判断,根据中断号找到对应的门描述符,根据中断描述符找到对应的中断处理函数入口和加载对应的CS段寄存器,将执行流交给内核.
类似gdt, idt需要通过`lidt`命令将idt表的数据结构载入,该数据结构与上一个实验的`gdt_ptr`一致.
```nasm
lidt [idt_ptr]
```
###### 8259A初始化
8259A简单来说是外设中断的实际处理硬件,时钟中断,键盘中断,鼠标中断等都是靠它给内核发送信号触发中断,它也需要初始化与中断描述符之间的联系.
##### 2. 中断处理过程
###### 进入用户态
在内核中,每个进程需要维护一个用于存放进程用户态当前状态的寄存器表,当用户态因为某些原因陷入内核态时用户态的当前所有寄存器信息就存放在寄存器表中,当从内核态又回到用户态时就根据寄存器表恢复用户态当前的状态.
```C
typedef struct s_stackframe {
u32 gs;
u32 fs;
u32 es;
u32 ds;
u32 edi;
u32 esi;
u32 ebp;
u32 kernel_esp;
u32 ebx;
u32 edx;
u32 ecx;
u32 eax;
u32 retaddr;
u32 eip;
u32 cs;
u32 eflags;
u32 esp;
u32 ss;
}STACK_FRAME;
```
这个就是我们这个实验会用到寄存器表,`gs`在低地址,`ss`在高地址,接下来结合源码分析进入用户态这个过程中寄存器的变化.
在`kernel_main`中,我们需要对寄存器做一次初始化:
```c
p_proc->regs.cs = (SELECTOR_FLAT_C & SA_RPL_MASK & SA_TI_MASK)
| SA_TIL | RPL_USER;
p_proc->regs.ds = (SELECTOR_FLAT_RW & SA_RPL_MASK & SA_TI_MASK)
| SA_TIL | RPL_USER;
p_proc->regs.es = (SELECTOR_FLAT_RW & SA_RPL_MASK & SA_TI_MASK)
| SA_TIL | RPL_USER;
p_proc->regs.fs = (SELECTOR_FLAT_RW & SA_RPL_MASK & SA_TI_MASK)
| SA_TIL | RPL_USER;
p_proc->regs.ss = (SELECTOR_FLAT_RW & SA_RPL_MASK & SA_TI_MASK)
| SA_TIL | RPL_USER;
p_proc->regs.gs = (SELECTOR_VIDEO & SA_RPL_MASK & SA_TI_MASK)
| RPL_USER;
p_proc->regs.eip = (u32)entry[i];
p_stack += STACK_PREPROCESS;
p_proc->regs.esp = (u32)p_stack;
p_proc->regs.eflags = 0x1202; /* IF=1, IOPL=1 */
```
这里可以看到初始化的段寄存器中除了`gs`都有`SA_TIL`标志位,它的实际值是4,即二进制意义下的第2位,标志着这个段是选择的是ldt中的段,而ldt中的段都是用户态权限的,所以在进入到用户态时执行流权限就自动切换到用户态.
再之后就是eip,这是执行流的寄存器,esp用于分配栈,eflags用于初始化flags信息.
在`kernel_main`初始化完后会调用`restart`函数进入用户态,这是一个汇编接口函数,关键的代码如下:
```nasm
restart:
mov esp, [p_proc_ready]
lea eax, [esp + P_STACKTOP]
mov dword [tss + TSS3_S_SP0], eax
restart_reenter: ; 我们的代码从这里开始分析,上面的等下会讲
cli
dec dword [k_reenter]
pop gs
pop fs
pop es
pop ds
popad
add esp, 4
iretd
```
先是关中断,我们肯定不希望在恢复用户态寄存器信息时被意外的中断干扰,再接下来是`k_reenter`减1(这个本参考不讲,自行阅读源码),之后开始恢复寄存器信息.先是恢复`gs`\~`ds`一共四个段寄存器信息.再是恢复`edi`\~`eax`这八个寄存器,需要注意的是`kernel_esp`比较特殊,它实际上不起恢复作用,因为现在的`esp`还不是用户态`esp`,而且`popad`指令会略过`esp`的恢复.再是`esp`加4跳过`retaddr`(这个变量是用于`save`这个函数,它存储的是`call save`时`ret`的地址),最后调用`iret`将`eip`\~`ss`这五个寄存器恢复(为什么让这五个寄存器单独用特殊指令恢复原因是这五个与执行流密切相关),由于eflags中IF位被置1中断被重新打开.
###### 返回内核态
执行流肯定不能一直留在用户态,在接受中断的时候需要再次陷入内核态.再次陷入内核态后,硬件保证了在进入中断时eflags的中断IF位为0,不会受到其余中断的影响,这个时候内核调用了`call`函数保存`eax`\~`gs`寄存器(为什么不保存`ss`\~`eip`这五个寄存器在第3部分会讲到):
```nasm
save:
pushad ; `.
push ds ; |
push es ; | 保存原寄存器值
push fs ; |
push gs ; /
mov dx, ss
mov ds, dx
mov es, dx
mov eax, esp ;eax = 进程表起始地址
inc dword [k_reenter] ;k_reenter++;
cmp dword [k_reenter], 0 ;if(k_reenter ==0)
jne .1 ;{
mov esp, StackTop ; mov esp, StackTop <--切换到内核栈
push restart ; push restart
jmp [eax + RETADR - P_STACKBASE]; return;
.1: ;} else { 已经在内核栈,不需要再切换
push restart_reenter ; push restart_reenter
jmp [eax + RETADR - P_STACKBASE]; return;
;}
```
在进入`call`函数中,`ret`的返回地址被存入了寄存器表中`retaddr`的位置,然后调用了`pushad`将`eax`\~`edi`存入寄存器表中,最后将其余段寄存器存入表中,这段代码最后的两个jmp是值得讲的,这个时候别傻乎乎用`ret`指令,返回地址实际上`retaddr`中存着,`ret`的话会把`restart`或`restart_reenter`当返回地址了.
###### 屏蔽中断和置EOI
```nasm
in al, INT_M_CTLMASK ; `.
or al, (1 << %1) ; | 屏蔽当前中断
out INT_M_CTLMASK, al ; /
mov al, EOI ; `. 置EOI位
out INT_M_CTL, al ; /
sti ; CPU在响应中断的过程中会自动关中断,这句之后就允许响应新的中断
```
在保存完寄存器后,需要修改中断掩码使得不再相应相同类型的中断,保证在内核中不会被同类中断干扰.然后还得向`INT_M_CTL`端口发送EOI信号,告诉硬件已经做好准备了,可以接受下一个中断了(有可能在`sti`之后马上又被下一个中断打扰).
###### 重新进入用户态
再接下来就是中断处理程序的调用了,在处理完中断后就可以准备返回用户态了:
```nasm
cli
in al, INT_M_CTLMASK ; `.
and al, ~(1 << %1) ; | 恢复接受当前中断
out INT_M_CTLMASK, al ; /
ret
```
跟上节做的事情相反,将目标中断恢复接受,然后使用ret指令,还记得`save`函数里面的`push restart`和`push restart_reenter`两个指令吗?这个push的地址值就是为了`ret`准备的,`ret`过后会重新回到`restart`,然后最终回到用户态.
##### 3. TSS机制
TSS书上写的很玄乎,很难理解,但是实际上TSS没有那么难,举个实例就可以很清晰的知道TSS的作用,假设我们在用户态执行的程序突然受到一个中断要返回内核态,那么这个时候肯定不能就着用户态的esp存储寄存器信息,需要切换到一个特定的栈(内核栈)存储寄存器信息,那么这个内核栈的ss和esp需要预先存储到一个特定地方用于进入内核态时切换(没错,需要段寄存器,因为用户的段寄存器是低权限的,如果访问内核栈会违反保护模式qemu直接重开,你会看到终端不断闪现boot信息),而这个存放的位置就是TSS,TSS里面存放很多数据,看起来很吓人,但是实际上现在我们只会使用其中的`ss0`和`esp0`(0是内核权限级),当从用户态进入到内核态时,ss和esp会切换内核态的对应寄存器,这个时候就能正常执行内核程序.
TSS是一个段,存放在gdt表中(标识为`DA_386TSS`).下面是gdt表中TSS段的初始化:
```c
tss.ss0 = SELECTOR_FLAT_RW; //ss0的初始化在这里完成
tss.iobase = sizeof(tss); /* 没有I/O许可位图 */
init_segment(&gdt[4], (u32)&tss, sizeof(tss) - 1, DA_386TSS);
```
在初始化完TSS段之后需要通过`ltr`加载TSS选择子让硬件知晓TSS段.
```nasm
_start:
; 把 esp 从 LOADER 挪到 KERNEL
mov esp, StackTop ; 堆栈在 bss 段中
call cstart ; 在此函数中改变了gdt_ptr,让它指向新的GDT
lgdt [gdt_ptr] ; 使用新的GDT
lldt [SELECTOR_LDT]
lidt [idt_ptr]
jmp SELECTOR_KERNEL_CS:csinit
csinit: ; “这个跳转指令强制使用刚刚初始化的结构”——<> P90.
xor eax, eax
mov ax, SELECTOR_TSS ; 选择TSS选择子
ltr ax
jmp kernel_main
```
在每次调用`restart`函数的时候,TSS中的`sp0`寄存器赋值为进程存储的寄存器表的顶部地址,这样保证进入内核态之后第一个压入的寄存器的值对应的是寄存器表中的`ss`.
```nasm
restart:
mov esp, [p_proc_ready]
lea eax, [esp + P_STACKTOP]
mov dword [tss + TSS3_S_SP0], eax
restart_reenter:
...
```
这样当再次陷入内核态时,首先将TSS中的`esp0`和`ss0`赋值到`esp`和`ss`寄存器,再之后`eip`\~`ss`这五个寄存器(其中`esp`和`ss`是用户态下的,虽然从逻辑上感觉不可思议,但是硬件总是可行的)会被压入栈中.
##### 4. 时钟中断
时钟中断反正也挺简单的,Orange书上也有写,也就一个固定频率的晶振电路,触发指定次后向OS发送一个中断信号,这个时候执行流需要陷入内核然后处理时钟中断处理程序.
##### 5. 键盘中断
这又是书上讲的很玄乎的一部分,但是实际上没那么玄乎,实验用不到那么多,在接受到键盘中断后,我们实际上需要解决两个问题:如何获取键盘输入的扫描码?如何将扫描码解析成正常ASCII码字符?解析后的ASCII码字符怎么用?
从键盘输入上获取扫描码这个问题比较简单书上也讲了,存储在标号为`0x60`的端口里,可以通过inb(in_byte)函数将端口的值读出来.如果不及时读出来,你再怎么摁键盘也不会触发键盘中断,可以理解为第一次输入的扫描码直接把端口霸占住了,不让其他扫描码进来.
但是实际上我们读入的是扫描码,是键盘上的一种编码,而我们需要将这种编码进行一步映射将扫描码映射成我们熟悉的ASCII字符,我们从端口里读入的是一个字节的数据,能表达0\~255之间的数,而ASCII字符仅能表达0\~127之间的数.最高位没有被用到,所以被硬件工程师重新利用,要知道我们摁下键盘实际上分摁下和弹起两个操作,对于同一个字符,它摁下与弹起的扫描码的区别在于摁下是最高位置0,而弹起是最高位置1,如果一直摁下会一直发送摁下的扫描码.对于`a`\~`z`这些字符,我们摁一次键会收到两个扫描码(摁下和弹起),但是在我们的日常理解里我们只关心摁下这个操作,所以在这次实验中,我们需要忽略掉弹起的扫描码,只关心摁下的扫描码,接下来需要解决的就是一个映射问题,`inc/keymap.h`里存放着一张扫描码到ASCII码的转换表,通过这张转换表就可以直接将扫描码转化为ASCII字符,这里并不需要考虑shift,ctrl这种特殊的控制字符,只需要实现实验要求的功能即可.
在获取完ASCII码字符后,我们肯定不能把辛苦得来的字符丢掉,但是我们并不知道用户程序什么时候会来索取字符,所以需要一个缓冲区存储字符,在`kern/keyboard.c`放着一个简单的缓冲区:
```c
#define KB_INBUF_SIZE 4
typedef struct kb_inbuf {
u8* p_head;
u8* p_tail;
int count;
u8 buf[KB_INBUF_SIZE];
} KB_INPUT;
static KB_INPUT kb_input = {
.p_head = kb_input.buf,
.p_tail = kb_input.buf,
.count = 0,
};
```
这个数据结构本质上是一个队列,其中`p_head`指的是缓冲区的队首字符,`p_tail`指的是缓冲区的队尾字符,`count`是当前存储的字符数量,`buf`是缓冲区.需要注意的是让缓冲区满的时候所有添加的字符需要丢弃.
当有这么一个缓冲区,触发键盘中断将解析来的字符存放到缓冲区中,当有用户程序需要索取时将缓冲区的队首字符弹出交给用户程序,就完成了整个交互.