拼一个自己的操作系统 SnailOS 0.03的实现
拼一个自己的操作系统SnailOS0.03源代码-Linux文档类资源-CSDN下载
操作系统SnailOS学习拼一个自己的操作系统-Linux文档类资源-CSDN下载
SnailOS0.00-SnailOS0.00-其它文档类资源-CSDN下载
开启异常处理和中断服务
https://pan.baidu.com/s/19tBHKyzOSKACX-mGxlvIeA?pwd=i889
中断字面的意思就是中间断开。确切的说计算机中的中断是在当前代码的执行阶段的某一时刻暂停当前代码的执行,而后去执行另一段代码,这段获得CPU使用权的新代码,被叫做中断处理程序(服务)。中断处理程序完成后,要返回到断开处,继续执行断开处下面的代码。
异常可以理解为一种特殊的中断,它产生于处理器执行程序代码过程中检测到的错误或执行了主动发生异常的指令,异常处理与中断的处理过程几乎完全相同,只不过异常处理完成后,很有可能继续执行刚才产生异常的代码。
中断根据引发产生的源头,可以分为硬件中断和软件中断两种。
硬件中断是处理器外部产生的中断,处理器上接受外部数据的特殊引脚NMI和INTR是专门接受外部中断信号的。当处理器的NMI引脚接受到信号时,非屏蔽的中断已经发生。或者处理器的INTR引脚通过中断代理机构(可编程中断控制器)接受到对应的信号时,若此时的标志的寄存器中的IF(中断允许标志)置位,同时可编程中断控制器初始化完成且对应的硬件中断开启时,此时就发生了对应的可屏蔽中断。这也就是说,硬件中断可以分为非屏蔽和可屏蔽两种中断。
int 【8位立即数】 指令将引发软件的中断,处理器可以接受的软件中断的范围是8位立即数的长度,即0-255之间的立即数。虽然软件中断可以使用其中任意一个,但是在硬件规范中,处理器为异常和中断预留了最开始的0-31号共32个,同时一般的硬件设备的中断从0x20-0x2f开始,所以,若不慎使用了这些立即数作为软件中断的向量,可能引发不可预知的后果。
同样,异常根据引发异常的源头,可以分为检测程序错误异常和异常指令异常。
处理器在运行程序代码的同时,会对代码运行中的诸多问题进行检测,如违反保护规则、段不存在、页不存在、无效指令、除数为零等,当发生此类事件时,处理就会进入异常处理阶段。这就是检测程序错误异常。
指令into、int3、bound是可以被程序直接使用的指令,当程序中含有int3指令时,将引发单步调试异常;含有into指令时,若标志寄存器的一出标志置位(为1),将引发溢出异常;含有bound指令时,若数组的边界超出范围,将引发数组超界异常。
在实际中,根据异常的产生方式和严重性,还通常把异常分为故障类异常、陷阱类异常和终止类异常。关于异常和中断的详细描述,请看下表。
保护模式下的中断和异常表
向量号 | 助记符 | 类型 | 描述 | 来源 |
0 | #DE | 错误 | 除零错误 | DVI和IDIV指令 |
1 | #DB | 错误/陷阱 | 调试异常,用于软件调试 | 任何代码或数据引用 |
2 |
| 中断 | NMI中断 | 不可屏蔽的外部中断 |
3 | #BP | 陷阱 | 断点 | INT 3指令 |
4 | #OF | 陷阱 | 溢出 | INTO指令 |
5 | #BR | 错误 | 数组越界 | BOUND指令 |
6 | #UD | 错误 | 无效指令(没有定义的指令) | UD2指令(奔腾Pro CPU引入此指令)或任何保留的指令 |
7 | #NM | 错误 | 数学协处理器不存在或不可用 | 浮点或WAIT/FWAIT指令 |
8 | #DF | 终止 | 双重错误(Double Fault) | 任何可能产生异常的指令、不可屏蔽中断或可屏蔽中断 |
9 | #MF | 错误 | 向协处理器传送操作数时检测到页错误(Page Fault)或段不存在,486及以后集成了协处理器,本错误就保留不用了 | 浮点指令 |
10 | #TS | 错误 | 无效TSS | 任务切换或访问TSS |
11 | #NP | 错误 | 段不存在 | 加载段寄存器或访问系统段 |
12 | #SS | 错误 | 栈段错误 | 栈操作或加载SS寄存器 |
13 | #GP | 错误 | 通用/一般保护异常,如果一个操作违反了保护模式下的规定,而且该情况不属于其他异常,CPU就是认为是该异常 | 任何内存引用或保护性检查 |
14 | #PF | 错误 | 页错误 | 任何内存引用 |
15 | 保留 |
|
|
|
16 | #MF | 错误 | 浮点错误 | 浮点或WAIT/FWAIT指令 |
17 | #AC | 错误 | 对齐检查 | 对内存中数据的引用(486CPU引入) |
18 | #MC | 终止 | 机器检查(Machine Check) | 错误代码和来源与型号有关(奔腾CPU引入) |
19 | #XF | 错误 | SIMD浮点异常 | SIMD浮点指令(奔腾III CPU引入) |
20~31 | 保留 |
|
|
|
32~255 | 用户自定义中断 | 中断 | 可屏蔽中断 | 来自INTR的外部中断或INT n指令 |
中断描述符、中断描述符表和中断描述表寄存器
为了区分不同的中断源并做出适当的中断处理动作,在保护模式下,一旦某一中断发生,处理器会自动的到中断描述符表中,找到中断向量的对应的中断处理程序的入口地址。那么相关的中断描述符、中断描述符表和中断描述表寄存器的结构就在中断处理中就变得极为重要。下面就让我们先看看中断描述符的样子。
中断或陷阱门描述符
上图便是门描述符的样子,不过仅仅是中断门和陷阱门描述符,因为调用门和任务门我们之前或是以后都不会用到,所以这里就不再列出了。中断门和陷阱门在图上的唯一区别是高32为的第8位,也就是我们用问号代表的一位,当?位是1时,该门描述符是中断门,而当该位为0时,是陷阱门。而中断门和陷阱门的唯一区别是:当进入中断处理程序后,如果是中断门将自动关闭中断,也就是中断门不主动允许中断嵌套,而如果是陷阱门的情况下,当进入中断处理程序后,将主动允许中断嵌套。这让人想起来就有些思维混乱吧。想想如果一个中断还还没有处理什么又来了一个中断,而后又来了若干个相同或者不同的中断,程序的执行流程将是多么的混乱。还好我们仅仅使用了中断门,恰当地避免了中断嵌套带来的麻烦。
中断描述符表就是一个中断描述符组成的数组,这与全局描述符表很类似。
中断描述符表寄存器也是一个和全局描述符表寄存器几乎完全一样的东西,我们来看看。
47 ----------------------------------------------16 15 ----------------------------0
32位中断描述表的物理基地址 16位的表界限
这样大家就明白了吧。因为是16位的表界限,所以应该能够装下2^16/8个表项才对。是不是中断描述符表也有8192个描述符呢?答案是否定的,因为前面我们提到了,int指令的后面只能是8位立即数,所以中断描述符表最多也就256个描述符,大家不要想太多了。不过按说这几个16位的界限不会是假的,所以即使界限稍微大一点,可能也不会有问题吧?不过从处理器的限制来说的话,在中断描述符表中肯定是访问不到向量号大于255的门描述符的。
中断和异常处理的一般过程
处理器自动完成的动作
当中断或异常发生时,无论时那种类型的,处理器都会收到一个中断向量号,根据这个中断向量号,处理器就可以从中断描述表中取得中断处理程序的入口地址,从而完成中断处理。当然最极端的情况下,发生了NMI异常,处理器即使找到了中断处理过程的入口地址也于事无补,只能自安天命,无奈宕机了。
一般的情况下,处理器会自觉地操作栈,以保存标志寄存器、当前代码段、指令指针以及可能产生的错误码。这其实又要分成两种情况,第一种情况就是上面说的压栈动作了,即是eflags、cs、eip、error_code先后入栈,也就是先后被压入内核栈中,从高地址到低地址依次排列。为什么说这里一定是内核栈呢?因为我们现在是运行在最高的特权级上,最高的特权级一定是内核中,所以这些压栈动作也一定发生在内核栈中。
第二种情况,以我们现在的知识储备,理解起来可能就有点难度了。但是,鉴于是屎到屁股门了,现在不得不拉出来了。还是给地球点面子吧,先拉为快,否则再憋出点病来!在不久的将来一般的情况下,我们的程序是以任务的形式运行的,运行的特权级是3,也就是最低的特权级上。在此期间中断又经常发生,尤其是时钟中断,它控制者任务的切换工作,大约每秒种可以发生100来次。而像这样的硬件中断都是运行在特权级0上的,所以这种一个任务从特权级3上切换到特权级0上,然后中断处理完成后,又从特权级0上回到特权级3上的情况,在任务上处理器运行的阶段是频繁发生的。也就是我们通常说的特权级转移。特权级的转移过程处理在此处还通过tss(任务状态段,也是一种系统段,专门由于任务切换工作,在处理器任务切换的规则中没他还真的不行)偷偷摸摸的切换的了栈,也就是任务的中断处理程序代码在运行时,任务处于内核态,内核态要用任务的内核栈,当然我们的操作系统所有任务的内核栈都是一个自己所有的特殊空间。而任务运行在用户态,处理器又偷偷地把堆栈恢复到用户栈。在tss中,恰好就存储着内核栈的段选择符和内核栈指针。那么特权级3的用户栈段选择符和用户栈指针怎么获得呢?这个过程是这样的,当中断发生的一瞬间,处理器执行完当前指令前夕,处理器是运行在用户态3的特权级上的,当前ss和esp中存在的用户态正是3特权级的栈段和栈指针。此时处理器即将进入任务的内核态0特权级(中断处理程序)中,因此处理器要到tss中找到内核栈段和栈指针,当次紧要关头处理器要临时保存特权级3的ss和esp,而后毫不迟疑的切换栈到内核,这一次保存的东西比没有特权级转移的情况下多了两个,分别是特权级3的ss和esp,然后还是将eflags、cs、eip、error_code先后入栈,而此时入栈的位置也是内核栈。
看到了没有,上面两种情况虽然有着质的不同,然而压栈的动作都发生在任务的内核栈上,只是CPU在背后做了很多手脚罢了。现在关于任务状态段和error_code错误码,我们还来不及细说,也没有必要在现在细说,反而是大家徒增烦恼。目前阶段大家只要知道又这些东西,它们在任何切换和特权转移中起到的作用就行了。
人工需要完成的动作
当进入中断或异常处理程序后,难道我们就万事大吉了吗?如果这样认为的话,那我就只能呵呵了!中断处理程序内部因为要使用处理器的所有资源来处理可能是非常复杂的任务,因此首要完成的就是处理器上各个寄存器内容的保存(我们即将采用的任务切换方式倾向于,不使用处理器提供的任务状态段来保存当前任务的寄存器值,原因是原生的任务切换方式效率不高),这些工作可都是人工完成的工作。因此我们要在任务特有的内核栈上,一开始就做一系列的压栈操作,保存任务在转移到中断处理前的状态(保存任务的上文)。进行到了此处,我们才能踏踏实实的进行中断处理的相关工作,以期完成我们的中断处理服务。而中断处理过程中,对内核栈的任何的操作都要恢复到保存上文时的状态,因为在中断处理结束后,我们要把处理器中各个寄存器的值恢复为任务之前的数值,而后用iret指令将最开始入栈的ss、esp、eflags、cs、eip、error_code寄存器反方向出栈。从而恢复任务在中断处理程序之前状态。继续运行任务的下文。
由中断门进入中断处理程序的特权级保护
在特权级的保护方面,处理器遵循着两个基本的原则,第一个原则是对数据段(类数据段)的访问:当前代码段的特权级必须大于或者等于待访问数据段的特权级,它的确切的数学涵义是CPL(当前代码段的特权级)<=DPL(目标数据段的特权级)。也就是说一个当前代码段的特权级为3,那么它仅可以访问特权级为3的数据段;一个特权级为2的当前代码段可以访问特权级为2和3的数据段,一个特权级为1的代码段,可以访问...。第二个原则是基于对代码段的访问,call、int 【8位立即数】以及中断或异常等能够实现段间转移,当前代码段的特权级必须小于或者等于待访问代码段的特权级,同样的确切的数学涵义是CPL(当前代码段的特权级)>=DPL(目标代码段的特权级)。我们知道,当前代码段访问另一个代码段的涵义是处理器使用权的转移。上面的要求给我们提了个醒,也就是说任何时候,高特权级的代码想要转移到低特权级的代码,通过call、int 【8位立即数】以及中断或异常进行正向的访问,依据特权级保护的规则是不可能实现的。这也从某个方面实践了毛主席“为人民服务”的诺言。意即高特权级代码不可以也没有必要引用低特权级的代码而为自己服务。然而,高特权级代码必然要能够转移到低特权级代码,从而实现各种任务的运行。而想要做到这一点必须只能通过ret或iret指令来实现,也就是从高特权级只能通过返回指令到低特权级。这里我们可知,中断或者异常正是类似于call等指令的正向的隐含的转移,因此当前代码只能是低特权级或者同特权级的进入中断或者异常处理这样的较高特权级的代码中。
into、int3以及int 【8位立即数】这些指令可以被用户程序所使用,处理器为了防止它们被别有用心的软件所利用,在特权级保护中还会刻意地检查中断门描述符的特权级,该特权级规则是当前运行程序的代码段描述符的特权级必须高于或等于门描述符的特权级,也就是确切的数学涵义是CPL(当前代码段的特权级)<=DPL(中断门描述符的特权级),这样一来当我们将来实现系统调用代码时,门的特权级必须设置为3,否则就会违反特权级保护规则,引发一般保护异常。当然这是后话,大家可以在调试代码的过程中慢慢体会了。
我们的特权级转换仅仅使用了中断门,所以这里重点也是中断门的特权级规则,如果大家不喜欢这种转换模式,那么就请参考其他书籍的任务切换保护规则了。
异常产生的错误码
产生错误码的是8号、10-14号、以及17号向量引发的异常,32位的错误码,据说有着特殊的涵义。具体涵义大家可以参考相关书籍了。这里的说到错误码的本意是,将来大家在处理这类异常是,一定不要忘记在异常处理的结束处将错误码弹出堆栈,否则将会产生更兖州的错误。
中断或异常的优先级
中断或者异常如果被处理器看作是同时发送信号的话,将会按照优先级的先后顺序被处理,这些规则将是硬件来规划的事情,我们可以高枕无忧了。只是大家要知道,它们其实是有优先级的。
讲了一大堆的理论不知道大家厌烦了没有,连讲的人都觉得有点上气不接下气了。下面还是来些实际的吧。首先让我们来看看异常处理的实现。这次我们新增了一个文件夹,并且在文件夹中添加了intr.h和intr.c两个文件,它们是专门用于放置与中断相关的文件的。由于这次文件内容比较多一些,而且文件有一些交叉的东西,比较乱,因此,大部分的文件是内容全部列出的,只有kernel.c做了节选,相信会有利于大家的阅读。
【int.h】
// intr.h
#ifndef __INTR_H
#define __INTR_H
void idt_init(void);
void exception_handler(void);
void divide_error_handler(unsigned int esp);
void general_protection_handler(void);
void double_fault_handler(void);
void invalid_tss_handler(void);
void seg_not_present_handler(void);
void stack_fault_handler(void);
void align_check_handler(void);
void page_fault_handler(void);
void invalid_opcode_handler(unsigned int esp);
void bound_check_handler(unsigned int esp);
void int3_handler(void);
void into_handler(void);
// 外部函数声明 开始
// 说是外部函数,其实它们只是用nasm汇编出来的,跟gcc编译
// 出来的函数,没有什么本质的区别,只不过gcc编译出来的
// 函数符合某一类规范,我们的有时候往往为了追求效率,
// 做得越简单越好。
extern void out(unsigned short port, unsigned char data);
extern unsigned char in(unsigned short port);
extern void hlt(void);
extern unsigned int get_cr2(void);
extern void divide_error(void);
extern void general_protection(void);
extern void exception(void);
extern void double_fault(void);
extern void invalid_tss(void);
extern void seg_not_present(void);
extern void stack_fault(void);
extern void align_check(void);
extern void page_fault(void);
extern void invalid_opcode(void);
extern void bound(void);
extern void bound_check(void);
extern void int3(void);
extern void into(void);
/*
i_table是一个放置各中断处理程序(以c函数的形式出现)地址表
(数组)。
*/
unsigned int i_table[256];
#endif
【intr.c】
// intr.c
#include "intr.h"
#include "string.h"
#include "gdt_idt_init.h"
// 异常和中断处理程序的C语言部分
// 通用的异常处理函数
void exception_handler(void) {
kprintf_("Exception...!");
while(1) {
void hlt();
}
}
// 除零异常的处理。
void divide_error_handler(unsigned int esp) {
/*
除零异常的处理是比较大胆的,我们首先通过中断处理
程序的汇编部分,给这个真正的处理函数传递了内核栈指针esp
的内容,而后通过esp找到除零指令的地址,并通过打印函数显示
除零指令的机器码。而后直接把放置除零指令的地址的内容修改为
两条nop指令。从而让异常返回时,执行了正确的nop指令,异常
也就不会再发生。正常情况下着显然是不行的,除零异常的发生
绝对不会是我们有意制造的。应该在特定条件下,终止当前任务的
执行。这里就是凑活着给大家展示一下异常的处理。
*/
unsigned int* ret_addr = (unsigned int *)(esp + 14 * 4);
unsigned int addr = *ret_addr;
unsigned short instruction = *((unsigned short*)addr);
kprintf_(" Divide Error...! %x ", instruction);
*((unsigned short*)addr) = 0x9090;
// while(1) {
// void hlt();
// }
}
// 一般保护异常的处理。
void general_protection_handler(void) {
kprintf_("General Protection Exception...!");
while(1) {
void hlt();
}
}
// 双重错误异常的处理。
void double_fault_handler(void) {
kprintf_("Double Fault Exception...!");
while(1) {
void hlt();
}
}
// 无效任务状态段异常的处理。
void invalid_tss_handler(void) {
kprintf_("Invalid Tss Exception...!");
while(1) {
void hlt();
}
}
// 段不存在异常的处理。
void seg_not_present_handler(void) {
kprintf_("Segment Not Present Exception...!");
while(1) {
void hlt();
}
}
// 栈错误异常的处理。
void stack_fault_handler(void) {
kprintf_("Stack Fault Exception...!");
while(1) {
void hlt();
}
}
// 对齐检查异常的处理。
void align_check_handler(void) {
kprintf_("Align Check Exception...!");
while(1) {
void hlt();
}
}
/*
页异常的处理,这里利用cr2错误页寄存器,打印了错误页
的地址。
*/
void page_fault_handler(void) {
unsigned int cr2;
cr2 = get_cr2();
kprintf_("Page Fault Exception...! CR2 == %x", cr2);
while(1) {
void hlt();
}
}
// 无效指令异常的处理。
void invalid_opcode_handler(unsigned int esp) {
unsigned int* ret_addr = (unsigned int *)(esp + 14 * 4);
unsigned int addr = *ret_addr;
unsigned short instruction = *((unsigned short*)addr);
kprintf_(" Invalid Opcode Exception...! %x ", instruction);
/*
由ud2指令产生的异常,因该条指令的长度为2字节,因此这里
直接给返回地址增加2,从而跨过了ud2指令。
*/
if(instruction == 0x0b0f) {
*ret_addr += 2;
*((unsigned short*)addr) = 0x9090;
} else {
while(1) {
void hlt();
}
}
}
/*
下面两个函数分别是单步调试异常、溢出异常和异常的处理。
这些异常发生后,类似于中断完成后,它们将返回到该类指令
的下一条指令继续执行。
*/
void int3_handler(void) {
kprintf_(" INT3 INSTRUCTION is done... ");
}
void into_handler(void) {
kprintf_(" The OF flag is 1!... ");
}
// 数组超界异常的处理。
void bound_check_handler(unsigned int esp) {
unsigned int* ret_addr = (unsigned int *)(esp + 14 * 4);
unsigned int addr = *ret_addr;
kprintf_(" BOUND CHECK EXCEPTION...!");
//该指令的长度为6,依次通过地址增加6的方法跨过该条指令。
*ret_addr += 6;
}
/*
添加异常和中断处理过程描述
一、在C文件中
1.初始化中断描述表基地址寄存器(包括中断描述表的长度及基地址)
2.添加中断处理的C函数
3.在中断处理的C函数表中添加处理句柄的地址
4.在中断描述表中对应项中添加汇编函数的地址、函数所属的段选择符、门属性
5.主函数前要声明对应的外部汇编函数
6.主函数中添加初始化函数
二、在汇编文件中
1.添加汇编通用过程
2.添加专用过程
3.导入外部符号
4.导出将被C语言引用的全局符号
*/
void idt_init(void) {
struct idtr idtr_;
int i;
idtr_.base = 0x7000;
idtr_.limit = 256 * 8 - 1;
for(i = 0; i < 256; i++) {
i_table[i] = (unsigned int)exception_handler;
create_gate(i, (unsigned int)exception, 1 * 8, 0x8e00);
}
i_table[0] = (unsigned int)divide_error_handler;
i_table[3] = (unsigned int)int3_handler;
i_table[4] = (unsigned int)into_handler;
i_table[5] = (unsigned int)bound_check_handler;
i_table[6] = (unsigned int)invalid_opcode_handler;
i_table[8] = (unsigned int)double_fault_handler;
i_table[10] = (unsigned int)invalid_tss_handler;
i_table[11] = (unsigned int)seg_not_present_handler;
i_table[12] = (unsigned int)stack_fault_handler;
i_table[13] = (unsigned int)general_protection_handler;
i_table[14] = (unsigned int)page_fault_handler;
i_table[17] = (unsigned int)align_check_handler;
create_gate(0, (unsigned int)divide_error, 1 * 8, 0x8e00);
create_gate(3, (unsigned int)int3, 1 * 8, 0x8e00);
create_gate(4, (unsigned int)into, 1 * 8, 0x8e00);
create_gate(5, (unsigned int)bound_check, 1 * 8, 0x8e00);
create_gate(6, (unsigned int)invalid_opcode, 1 * 8, 0x8e00);
create_gate(8, (unsigned int)double_fault, 1 * 8, 0x8e00);
create_gate(10, (unsigned int)invalid_tss, 1 * 8, 0x8e00);
create_gate(11, (unsigned int)seg_not_present, 1 * 8, 0x8e00);
create_gate(12, (unsigned int)stack_fault, 1 * 8, 0x8e00);
create_gate(13, (unsigned int)general_protection, 1 * 8, 0x8e00);
create_gate(14, (unsigned int)page_fault, 1 * 8, 0x8e00);
create_gate(17, (unsigned int)align_check, 1 * 8, 0x8e00);
lidtr(&idtr_);
}
【system.asm】
; system.asm 创建者:至强 创建时间:2022年8月
bits 32
global _lgdtr
; 下面的函数是加载伪寄存器描述符到全局描述表的函数
align 16
_lgdtr: ; void lgdtr(struct gdtr* pgdtr)
; 由于没有对此时的堆栈进行任何操作,所以当前栈指针
; 指向函数的返回地址,当栈指针加4后,则指向第一个参数
; 这个参数正是伪寄存器描述符的地址。
mov eax, [esp + 1 * 4]
lgdt [eax]
ret
global _reset_gdt
align 16
_reset_gdt: ; void reset_gdt(void)
; 通过段选择符来更新各段寄存器的内容,0x10是全局描述
; 表中的第三个选择符,所有的数据段都更新为该段。由于
; intel处理器的"怪癖",往段寄存器中写入数据,必须借助
; 其他的寄存器,这里按照惯例使用了ax。还是"怪癖",更新
; cs(代码段寄存器)不能使用mov指令,而要jmp指令,并且
; 采用了双字修饰符形式的段间跳转,双字形式不是必须的,但段
; 间跳转是必须的,因为只有这样处理器才会更新cs。在32位
; 模式下,nasm会把不带双字修饰符的段间跳转指令默认汇编成
; 正确的格式。
mov ax, 0x10
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
jmp dword 0x08 : .1
.1:
ret
global _enter_page
align 16
_enter_page: ; void enter_page(void);
mov eax, 0x8000
mov cr3, eax
mov eax, cr0
; or eax, 0x80000000
; 在nasm中我们可以用这样的二进制形式来表示数据,
; 对于像置位或者清零这样的操作是不是很直观呀!
or eax, 1000_0000_0000_0000_0000_0000_0000_0000b
mov cr0, eax
jmp 0x08 : .1
.1:
ret
global _out
align 16
_out: ; void out(unsigned short port, unsigned char data);
mov dx, [esp + 1 * 4]
mov al, [esp + 2 * 4]
out dx, al
ret
global _in
align 16
_in: ; unsigned char in(unsigned short port);
mov dx, [esp + 1 * 4]
in al, dx
ret
global _exception, _divide_error, _general_protection, _double_fault
global _invalid_tss, _seg_not_present, _stack_fault, _page_fault
global _align_check, _invalid_opcode
align 16
_general_protection:
; esp + 4 * 4 + 8 * 4 + 1 * 4 指向错误码
; esp + 4 * 4 + 8 * 4 + 0 * 4 正好指向异常或中断的向量号
push 13
jmp _err_code_entry
align 16
extern _i_table
_err_code_entry:
pusha
push ds
push es
push fs
push gs
mov eax, [esp + 4 * 4 + 8 * 4 + 0 * 4]
call [_i_table + eax * 4]
jmp _exit
align 16
_divide_error:
; esp + 4 * 4 + 8 * 4 + 1 * 4 正好指向异常或中断的向量号
push 0 ; 向量号
jmp _no_err_code_entry_
global _bound_check
_bound_check:
push 5
jmp _no_err_code_entry_
align 16
_no_err_code_entry:
; 这里起到占位的作用
push 0
pusha
push ds
push es
push fs
push gs
mov eax, [esp + 4 * 4 + 8 * 4 + 1 * 4]
call [_i_table + eax * 4]
jmp _exit
align 16
global _int3
_int3: ; void int3(void);
push 3
jmp _no_err_code_entry
align 16
global _into
_into: ; void into(void);
push 4
jmp _no_err_code_entry
align 16
_exception: ; void exception(void);
push 15
jmp _no_err_code_entry
align 16
_invalid_opcode: ; void invalid_opcode(void);
push 6
_no_err_code_entry_:
push 0
pusha
push ds
push es
push fs
push gs
mov eax, [esp + 4 * 4 + 8 * 4 + 1 * 4]
push esp
call [_i_table + eax * 4]
add esp, 1 * 4
jmp _exit
; 以下是所有存在出错码的异常
align 16
_double_fault: ; void double_falut(void);
push 8
jmp _err_code_entry
align 16
_invalid_tss: ; void invalid_tss(void);
push 10
jmp _err_code_entry
align 16
_seg_not_present: ; void seg_not_present(void);
push 11
jmp _err_code_entry
align 16
_stack_fault: ; void stack_fault(void);
push 12
jmp _err_code_entry
align 16
_page_fault: ; void page_falut(void);
push 14
jmp _err_code_entry
align 16
_align_check: ; void align_check(void);
push 17
jmp _err_code_entry
align 16
global _exit
_exit: ; void exit(void);
pop gs
pop fs
pop es
pop ds
popa
add esp, 2 * 4
iret
align 16
global _hlt
_hlt: ; void hlt(void);
hlt
ret
align 16
global _lidtr
_lidtr: ; void lidtr(struct idtr* pidtr);
mov eax, [esp + 1 * 4]
lidt [eax]
ret
align 16
global _get_cr2
_get_cr2: ; unsigned int get_cr2(void);
mov eax, cr2
ret
align 16
global _bound
extern _limit
_bound: ; void bound(void);
bound eax, [_limit]
【gdt_idt_init.h】
// gdt_idt_init.h 创建者:至强 创建时间:2022年8月
#ifndef __GDT_IDT_INIT
#define __GDT_IDT_INIT
// 定义了全局描述符表寄存器的伪描述符数据结构,并且告诉编译器
// 尊重我们的结构定义,不要做任何尺寸的改动。
struct gdtr {
unsigned short limit;
unsigned int base;
}__attribute__((packed));
// 定义了中断描述符表寄存器的伪描述符数据结构,并且告诉编译器
// 尊重我们的结构定义,不要做任何尺寸的改动。
struct idtr {
unsigned short limit;
unsigned int base;
}__attribute__((packed));
void lgdtr(struct gdtr* pgdtr);
void lidtr(struct idtr* pidtr);
void reset_gdt(void);
void create_gdt_desc(unsigned short gdt_nr, unsigned int base,
unsigned short attr, unsigned int limit);
void create_gate(unsigned short idt_nr, unsigned int offset,
unsigned short selector, unsigned short attrib);
void gdt_init(void);
#endif
【gdt_idt_init.c】
// gdt_idt_init.c 创建者:至强 创建时间:2022年8月
#include "gdt_idt_init.h"
// 构造一个全局描述符的函数,该函数需要4个参数,
// 一是描述符在全局描述符表中的下标,二是描述符所描述段
// 的基地址,三是段的各种属性,四是段限长。
void create_gdt_desc(unsigned short gdt_nr, unsigned int base,
unsigned short attr, unsigned int limit) {
// 凭感觉确定的全局描述表的基地址。 、
unsigned long long* gdt_start = (unsigned long long*)(0x6000);
unsigned long long base_low = base & 0x000000000000ffff;
unsigned long long base_mid = base & 0x0000000000ff0000;
unsigned long long base_high = base & 0x00000000ff000000;
unsigned long long limit_low = limit & 0x000000000000ffff;
unsigned long long limit_high = limit & 0x00000000000f0000;
unsigned long long attrib = attr & 0x000000000000f0ff;
gdt_start[gdt_nr] = limit_low | (limit_high << 32) |
(base_low << 16) | (base_mid << 16) | (base_high << 32) |
(attrib << 40);
}
/*
在中断描述符表中,创建一个中断描述符,该函数接收4个参数
idt_nr是描述符在描述符表中的索引,offset是代码在代码段中的
偏移量,selector是代码段的选择符,attrib是门描述符的属性。
值得注意的是,中断描述符表中的描述符只能是门描述符,而且必然
是中断门、陷阱门、任务门当中的一种。
*/
void create_gate(unsigned short idt_nr, unsigned int offset,
unsigned short selector, unsigned short attrib) {
unsigned long long gate, offset_low, offset_high, selector_t, attrib_t;
unsigned long long* idt_start = (unsigned long long*)(0x7000);
offset_low = offset & 0x0000ffff;
offset_high = offset & 0xffff0000;
selector_t = selector;
attrib_t = attrib;
idt_start[idt_nr] = offset_low | (offset_high << 32) |
(selector_t << 16) | (attrib_t << 32);
}
void gdt_init(void) {
struct gdtr gdtr_;
int i;
// 与上边函数的基地址相对应。
gdtr_.base = 0x6000;
gdtr_.limit = 256 * 8 - 1;
// 全局描述表中第一个描述符必须初始化为0
create_gdt_desc(0, 0, 0, 0);
// 0x08是全局描述符表中第一个描述符的选择符(从0开始计算)
create_gdt_desc(1, 0x0, 0xc09a, 0xffffffff); //代码段
create_gdt_desc(2, 0x0, 0xc092, 0xffffffff); //数据段
// 其余预留的描述符空间目前全部初始化为0。
for(i = 3; i < 256; i++) {
create_gdt_desc(i, 0, 0, 0);
}
// 重新加载全局描述符表寄存器。
lgdtr(&gdtr_);
// 更新内核所用的各段地址,其实就是说说罢了。我们虽然更新
// 了,但是更新后的段跟grub甚至的基地址以及段限长,完全一样
// 的。同时这个函数的名字,也是文不对题,暂且就这样吧。
reset_gdt();
}
【kernel.c 节选】
(上面省略)
i /= 0;
asm("ud2");
asm volatile ("movl $0xffffffff, %ebx;\
movl $0x0, %edx;\
movl $0xffffffff, %eax;\
mul %ebx;");
asm volatile ("into");
asm("int3");
limit = 0xaaaa00000000;
asm volatile("movl $0xaaaa, %eax;bound %eax, (_limit)");
printf_("\n @@@@@@ NEW START! @@@@@@\n");
asm volatile("movl $0xaaab, %eax;bound %eax, (_limit)");
while(1);
}
void init(void) {
gdt_init();
page_init();
idt_init();
}
上面的代码已经添加了很多注释,相信大家不会有太大的问题吧。不过我这来还是像逐句地讲讲中断处理程序中汇编的部分的实现细节。采取逐字句的方式是像让大家弄懂每一个细节,因为只有这样大家才能对中断处理程序有比较深入的认识,从而在今后的程序中减少疑惑。
; 这个标号将被标记为全局标号,也就是说在c程序中将能够看到它,我们将在创建中断描述符表项的时候,把它作为中断处理程序入口地址的偏移量。
_invalid_opcode: ; void invalid_opcode(void);
; 这是压入中断向量,从而让真正的C语言形式的中断处理程序知道发生的是哪个事件。
push 6
; 这个标签被完全同样处理方式的程序段通过jmp指令重复使用。
_no_err_code_entry_:
; 为了保持栈的一致性,和有错误码的异常处理过程相比,这里要多一次压栈操作。
push 0
; 下面的多次压栈操作是保存任务的各个寄存器值在内核栈中,以备返回时恢复各个寄存器的值。
pusha
push ds
push es
push fs
push gs
; 取得中断向量号,从而能够调用到相应的中断处理程序。但我个人认为,前人的这个做法是多余的,把这句删掉,同时也把入栈中断号的那句删掉,直接使用call [_i_table + 6 * 4]也可以达到同样的效果。
mov eax, [esp + 4 * 4 + 8 * 4 + 1 * 4]
; esp入栈的含义是,给真正的中断处理程序转递这个参数,从而能够继续操作栈。
push esp
; 调用中断处理程序
call [_i_table + eax * 4]
; 因为上边通过push esp传递给中断处理程序一个参数,所以这里要通过加法指令恢复函数未调用前栈的状态
add esp, 1 * 4
; 中断处理程序的出口函数,这里的目的也是复用。
jmp _exit
; 作为外部标签,可以被C语言使用。
_exit: ; void exit(void);
; 呼应保存任务状态的入栈操作,在返回任务前恢复任务各个寄存器。
pop gs
pop fs
pop es
pop ds
popa
; 因为中断处理开始分别入栈了向量号和伪错误码,为了恢复栈状态,这里必须丢弃它们。巧合的是,即使处理器压入了错误码,我们处理时也可以引用这里,原因是,有错误码的情况就不入栈伪错误码,但仍然是丢弃两个栈位。
add esp, 2 * 4
; 中断的返回指令,将返回到中断前的任务,因为我们这里仅仅是运行着内核,所以必然返回到内核中异常发生的代码处。
iret
讲完了异常处理程序的汇编部分,下面我们要来单独的拿invalid_opcode_handler()函数来讲一讲,它也就是中断处理程序的C语言部分。
unsigned int* ret_addr = (unsigned int *)(esp + 14 * 4);
unsigned int addr = *ret_addr;
unsigned short instruction = *((unsigned short*)addr);
也就是上面的三句比较难以理解了。首先说esp正是我们在调用这个C函数之前入栈的,所以它就是在调用前esp的值,我们通过掰着手指头数数的方式可以知道,在这个esp向地址高处的14个栈单元(每个栈单元4字节)处是处理器自动压栈的任务的返回地址。所以(esp + 4 * 14)(注意在类型转换时一定要加上括号)就是返回地址的指针。也就是说栈的该处存储着真正的返回任务的地址。而通过上面的介绍我们知道,返回地址处的内容是ud2那条指令。那么我们时怎么知道ud2指令的到底是什么机器码的呢?这时候两个编译器自带的反汇编工具就要大显身手了。
我们先看看nasm工具包中的ndisasm工具。这次陪我们临时演示的汇编文件是xxx.asm。
【xxx.asm】
bits 32
ud2
仅仅是一条伪指令和一条汇编指令。用nasm -fbin -o xxx.o xxx.asm汇编,然后ndisasm -u xxx.o查看反汇编的结果。
C:\Users\free2\.VirtualBox\temp>nasm -fbin -o xxx.o xxx.asm
C:\Users\free2\.VirtualBox\temp>ndisasm -u xxx.o
00000000 0F0B ud2
再看我们“make run”后对虚拟机截屏的结果。
我们看到“Invalid Opcode Exception...!”的后面的16进制数是0xb0f,这个数有点像,但是怎么倒装了呢?通过观察车测试我们知道原来intel指令集的指令是不等长的,如果按照整形数据那样4字节一个那样的显示,显然是不合适的,所以再反汇编时,反汇编器的显示都是从低到高这样排列的。
然后我们再看看gcc中的反汇编工具objdump,同样我们也写了一个叫做xxx.c的陪练程序。
【xxx.c】
void xxx(void) {
asm("ud2");
}
也仅仅是3行和一条语句,不过这个语句大家在些应用的程序的时候不常见到吧。它是嵌入式汇编语言,而且使用at&t格式的汇编语言。不过这个没有操作数的“ud2”指令肯定是看不出来了。
这一次用gcc -c -o xxx.o xxx.c编译,然后用objdump -ds xxx.o查看反汇编后的结果。
C:\Users\free2\.VirtualBox\temp>gcc -c -o xxx.o xxx.c
C:\Users\free2\.VirtualBox\temp>objdump -ds xxx.o
xxx.o: file format pe-i386
Contents of section .text:
0000 5589e50f 0b905dc3 U.....].
Contents of section .rdata$zzz:
0000 4743433a 20284d69 6e47572e 6f726720 GCC: (MinGW.org
0010 47434320 4275696c 642d3229 20392e32 GCC Build-2) 9.2
0020 2e300000 .0..
Contents of section .eh_frame:
0000 14000000 00000000 017a5200 017c0801 .........zR..|..
0010 1b0c0404 88010000 1c000000 1c000000 ................
0020 04000000 08000000 00410e08 8502420d .........A....B.
0030 0544c50c 04040000 .D......
Disassembly of section .text:
00000000 <_xxx>:
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
3: 0f 0b ud2
5: 90 nop
6: 5d pop %ebp
7: c3 ret
上面是关于段的信息,我们忽略不计,看看反汇编的结果怎么样,跟ndisasm一样一样的吧。
另外,我们也可以用nasm -felf -o xxx.o xxx.asm,把xxx.asm汇编成elf32格式的目标文件,然后用objdump产看反汇编结果。
C:\Users\free2\.VirtualBox\temp>objdump -ds xxx.o
xxx.o: file format elf32-i386
Contents of section .text:
0000 0f0b ..
Disassembly of section .text:
00000000 <.text>:
0: 0f 0b ud2
看到了吧,是不是异曲同工之妙。总之这两个工具是我们查看汇编指令和对应机器码的好工具。
当然更好的方法是我们直接看内核代码的反汇编结果,这次我们用了objdump -d .\create\kernel.elf > kernel.txt来生成反汇编的结果达到kernel文件中,之所以这样是因为objdump -s的选项生成的内存布局文件比较大,而且即使不用-s选项仅仅是生成反汇编文件也是比较大的。下面是分别运行的命令以及显示结果。
C:\Users\free2\.VirtualBox\temp>make
make: 'create/kernel.elf' is up to date.
C:\Users\free2\.VirtualBox\temp>objdump -d .\create\kernel.elf > kernel.txt
C:\Users\free2\.VirtualBox\temp>notepad++ kernel.txt
由于生成的kernel.txt的临时文件比较大,这里我们只是节选了_kernel_main这部分
00101010 <_kernel_main>:
101010: 55 push %ebp
101011: 89 e5 mov %esp,%ebp
101013: 53 push %ebx
101014: 83 ec 24 sub $0x24,%esp
101017: 8b 45 08 mov 0x8(%ebp),%eax
10101a: a3 0c 94 14 00 mov %eax,0x14940c
10101f: 8b 45 0c mov 0xc(%ebp),%eax
101022: a3 08 94 14 00 mov %eax,0x149408
101027: c7 05 10 94 14 00 00 movl $0x0,0x149410
10102e: 00 00 00
101031: 8b 45 0c mov 0xc(%ebp),%eax
101034: 89 44 24 04 mov %eax,0x4(%esp)
101038: 8b 45 08 mov 0x8(%ebp),%eax
10103b: 89 04 24 mov %eax,(%esp)
10103e: e8 29 01 00 00 call 10116c <_get_video_addr>
101043: 89 45 f0 mov %eax,-0x10(%ebp)
101046: e8 d8 02 00 00 call 101323 <_screen_init>
10104b: e8 31 03 00 00 call 101381 <_info_area_cls>
101050: e8 fc 00 00 00 call 101151 <_init>
101055: c7 04 24 00 00 00 02 movl $0x2000000,(%esp)
10105c: e8 28 1b 00 00 call 102b89 <_pte_ptr>
101061: 89 c3 mov %eax,%ebx
101063: c7 04 24 00 00 00 02 movl $0x2000000,(%esp)
10106a: e8 07 1b 00 00 call 102b76 <_pde_ptr>
10106f: 89 5c 24 08 mov %ebx,0x8(%esp)
101073: 89 44 24 04 mov %eax,0x4(%esp)
101077: c7 04 24 00 70 14 00 movl $0x147000,(%esp)
10107e: e8 16 14 00 00 call 102499 <_printf_>
101083: c7 04 24 00 00 00 02 movl $0x2000000,(%esp)
10108a: e8 e7 1a 00 00 call 102b76 <_pde_ptr>
10108f: c7 00 07 00 00 02 movl $0x2000007,(%eax)
101095: c7 04 24 00 00 00 02 movl $0x2000000,(%esp)
10109c: e8 e8 1a 00 00 call 102b89 <_pte_ptr>
1010a1: c7 00 07 00 00 02 movl $0x2000007,(%eax)
1010a7: c7 45 f4 01 00 00 00 movl $0x1,-0xc(%ebp)
1010ae: eb 15 jmp 1010c5 <_kernel_main+0xb5>
1010b0: 8b 45 f4 mov -0xc(%ebp),%eax
1010b3: c1 e0 02 shl $0x2,%eax
1010b6: 05 00 00 00 02 add $0x2000000,%eax
1010bb: c7 00 00 00 00 00 movl $0x0,(%eax)
1010c1: 83 45 f4 01 addl $0x1,-0xc(%ebp)
1010c5: 81 7d f4 ff 03 00 00 cmpl $0x3ff,-0xc(%ebp)
1010cc: 7e e2 jle 1010b0 <_kernel_main+0xa0>
1010ce: 0f 20 d8 mov %cr3,%eax
1010d1: 0f 22 d8 mov %eax,%cr3
1010d4: b8 04 00 00 02 mov $0x2000004,%eax
1010d9: 8b 10 mov (%eax),%edx
1010db: b8 00 00 00 02 mov $0x2000000,%eax
1010e0: 8b 00 mov (%eax),%eax
1010e2: 89 54 24 08 mov %edx,0x8(%esp)
1010e6: 89 44 24 04 mov %eax,0x4(%esp)
1010ea: c7 04 24 07 70 14 00 movl $0x147007,(%esp)
1010f1: e8 a3 13 00 00 call 102499 <_printf_>
1010f6: 8b 45 f4 mov -0xc(%ebp),%eax
1010f9: b9 00 00 00 00 mov $0x0,%ecx
1010fe: 99 cltd
1010ff: f7 f9 idiv %ecx
101101: 89 45 f4 mov %eax,-0xc(%ebp)
101104: 0f 0b ud2
101106: bb ff ff ff ff mov $0xffffffff,%ebx
10110b: ba 00 00 00 00 mov $0x0,%edx
101110: b8 ff ff ff ff mov $0xffffffff,%eax
101115: f7 e3 mul %ebx
101117: ce into
101118: cc int3
101119: c7 05 00 94 14 00 00 movl $0x0,0x149400
101120: 00 00 00
101123: c7 05 04 94 14 00 aa movl $0xaaaa,0x149404
10112a: aa 00 00
10112d: b8 aa aa 00 00 mov $0xaaaa,%eax
101132: 62 05 00 94 14 00 bound %eax,0x149400
101138: c7 04 24 0f 70 14 00 movl $0x14700f,(%esp)
10113f: e8 55 13 00 00 call 102499 <_printf_>
101144: b8 ab aa 00 00 mov $0xaaab,%eax
101149: 62 05 00 94 14 00 bound %eax,0x149400
10114f: eb fe jmp 10114f <_kernel_main+0x13f>
上面的“ud2”指令我们用下划线标注了,大家一眼就能看到吧。前面的0x101104是该指令的内存地址。这是我们会想到,是不是用调试器也能显示这条指令呢?过了这么久,大家还记得调试命令吧。干脆在这里也给大家列出来。
C:\Users\free2\.VirtualBox\temp>make dbg
mount_copy.cmd
已复制 1 个文件。
Microsoft DiskPart 版本 10.0.19041.964
Copyright (C) Microsoft Corporation.
在计算机上: LZHQ-SURFACE
DiskPart 已成功选择虚拟磁盘文件。
100 百分比已完成
DiskPart 已成功连接虚拟磁盘文件。
移动了 1 个文件。
Microsoft DiskPart 版本 10.0.19041.964
Copyright (C) Microsoft Corporation.
在计算机上: LZHQ-SURFACE
DiskPart 已成功选择虚拟磁盘文件。
DiskPart 已成功分离虚拟磁盘文件。
start virtualboxvm --startvm "C:\Users\free2\.VirtualBox\temp\temp.vbox" --debug-command-line
C:\Users\free2\.VirtualBox\temp>make resume
vboxmanage controlvm "C:\Users\free2\.VirtualBox\temp\temp.vbox" resume
下面是调试器的屏显。
Welcome to the VirtualBox Debugger!
Current VM is 0cd10000, CPU #0
VBoxDbg> stop
dbgf event: VM 000000000cd10000 is halted! (other)
eax=0000aaab ebx=ffffffff ecx=00000007 edx=00014000 esi=00000000 edi=00000000
eip=0010114f esp=00100fc8 ebp=00100ff0 iopl=0 nv up di pl nz na po nc
cs=0008 ds=0010 es=0010 fs=0010 gs=0010 ss=0010 eflags=00000006
0008:0010114f eb fe jmp -002h (00010114fh)
VBoxDbg> u 0x101104
%0000000000101104 90 nop
%0000000000101105 90 nop
%0000000000101106 bb ff ff ff ff mov ebx, 0ffffffffh
%000000000010110b ba 00 00 00 00 mov edx, 000000000h
%0000000000101110 b8 ff ff ff ff mov eax, 0ffffffffh
%0000000000101115 f7 e3 mul ebx
%0000000000101117 ce into
%0000000000101118 cc int3
%0000000000101119 c7 05 00 94 14 00 00 00 00 00 mov dword [000149400h], 000000000h
%0000000000101123 c7 05 04 94 14 00 aa aa 00 00 mov dword [000149404h], 00000aaaah
在上面标下划线的部分通过u命令反汇编了0x101104地址处,咦怎么出现了两条“nop”指令......
冷静一下仔细想来,我们的程序并没有设置断点,而是运行到了异常处理完成,所以已经把“ud2”替换成了两条“nop”指令了吗。
这里也不由的说一些题外话,截止到笔者写作时,我对virtual box自带的调试器仍然是一知半解的水平,最主要的悲催的地方是不能设置断点,也就是说程序要么单步执行,要么一下运行到底。如果给自己找个合理的理由的话,就是说virtual box debugger的说明文档太简略了,而且太英文化了,还有就是论坛里也不好找到讨论关于调试器的内容。当然话又说回来,即使仅仅这样的使用水平,调试器也还是给了我极大帮助。
还有个问题是我们居然在main函数中使用了at&t的嵌入式汇编代码,这主要是笔者图方便的偷懒做法了。原因采用nasm必须要通过函数调用,因此每次都要切换到汇编文件。这里我们用的是at&t语法(gcc和as目前也支持intel语法的程序了,只要在编译时用形如gcc -masm=intel -c -o kernel.o kernel.c的选项就行了),跟intel的主要区别是源操作数和目标操作数的顺序是相反的,intel是源操作数在右边,目标操作数在左边。at&t则正好相反。考虑到也可以用intel的语法,但是最后放弃了,主要的原因是,第一,很多参考书上用的是at&t语法,我们在移植到自己的程序中的时候,很容易就把代码写错,第二,如果是仅仅使用嵌入式汇编的基本型的话,源操作数和目标操作数位置这一点点问题,我们还是很容易客服的,可以一旦我们用了拓展型的嵌入式编程。我们会发现用序号代替的操作数,会给思维带来极大的混乱。所以如果用还是乖乖的用at&t语法,不然的话最好不用,而是采用函数调用的形式用nasm。这样就不容易出错了。对了as是gcc工具集中的汇编器,用它来汇编的代码已经不再是嵌入式汇编了,他就是纯的汇编代码,代码中也是可以采用at&t或intel两种形式,如果大家对他比较熟悉的话,根本就不用引入nasm这个工具了。嵌入式汇编和as汇编大家可以参考《Linux内核完全剖析 0.12》和《操作系统真相还原》,我这里就不班门弄斧了。
关于异常的东西以笔者的能力,也就只能给大家实验到如此地步了。下面我们就要进入中断处理程序的编制了。
可屏蔽中断、可编程中断控制器及中断处理
处理器只有1个intr引脚,也就是可屏蔽中断的引脚。然而,想要发出中断请求的硬件设备却有好多种,这可怎么办呢?因此硬件专家们设计了叫做可编程中断控制器(pic)的玩意供各种设备直接连接,而intr仅仅连接到pic上便能够接受来自不同设备的中断。而且一片pic被认为是不够的,还采用两片级联的方式。话说这其实是硬件方面的问题了,我们并不太关心。我们更想知道怎么通过编程达到能够响应硬件中断的目的。pic作为处理器接受中断信号的代理机构,它可不是专门为某种特殊的处理器设计的,因此为了适合各种场合,pic有多种工作模式。我们到底有了哪种工作方式呢?呵呵叫做什么工作方式甚至连笔者都说不上来了,只不过大家要是多看几本关于pic甚至的书上的代码就完全明白了,原来一样一样的吗,何必前面讲解那么多,关于芯片甚至的知识吗,完全看不懂,还不如看代码的1分钟。是啊,这里的pic更确切的说是的类intel 8259a了,关于它的工作模式、寄存器作用、端口地址、工作原理及操作方法实在是太难了。但我们仅仅用这一种,还是直接看程序代码的比较好。值得庆幸的是这里笔者添加了一个实时时钟的中断作为开启中断的例子。关于如何初始化可编程中断控制器和实时时钟,请大家参考《0x86汇编语言从实模式到保护模式》这本书吧,我在这里就别班门弄斧了。
【intr.h 节选】
(上面省略)
/*
三角函数的正余弦表的数组,这些离散量是6度为每单位,我在这里
刻意的将数值扩大了10000倍,目的是取得更好的精度效果。
*/
static int sin_tab[60] = {
0, 1045, 2079, 3090, 4067, 5000, 5878, 6691,
7431, 8090, 8660, 9135, 9511, 9781, 9945,
10000, 9945, 9781, 9511, 9135, 8660, 8090, 7431,
6691, 5878, 5000, 4067, 3090, 2079, 1045,
0, -1045, -2079, -3090, -4067, -5000, -5878, -6691,
-7431, -8090, -8660, -9135, -9511, -9781, -9945,
-10000, -9945, -9781, -9511, -9135, -8660, -8090, -7431,
-6691, -5878, -5000, -4067, -3090, -2079, -1045
};
static int cos_tab[60] = {
10000, 9945, 9781, 9511, 9135, 8660, 8090, 7431,
6691, 5878, 5000, 4067, 3090, 2079, 1045,
0, -1045, -2079, -3090, -4067, -5000, -5878, -6691,
-7431, -8090, -8660, -9135, -9551, -9781, -9945,
-10000, -9945, -9781, -9511, -9135, -8660, -8090, -7431,
-6691, -5878, -5000, -4067, -3090, -2079, -1045,
0 , 1045, 2079, 3090, 4067, 5000, 5878, 6691,
7431, 8090, 8660, 9135, 9511, 9781, 9945
};
/*
存储实时时钟小时、分钟、秒的压缩bcd值的数据结构,当然我们
在定义时,可以增加年月日星期的成分,不过我们没有用到,也就
省略了。
*/
struct rtc {
unsigned char h;
unsigned char m;
unsigned char s;
}__attribute__((packed));
(下面省略)
【intr.c 节选】
(上面省略)
i_table[0x28] = (unsigned int)rtc_handler;
(中间省略)
create_gate(0x28, (unsigned int)rtc, 1 * 8, 0x8e00);
(中间省略)
}
struct rtc rtc_data = {0, 0, 0};
void dial(int x, int y);
void clock(int x, int y);
void i8259_init(void) {
/*
向主芯片和从芯片的数据端口(分别是0x21和0xa1),分别写入
11111111b的数据,目的是在初始化之前屏蔽所有硬件的中断,
这主要是稳妥起见,其实不这样做,也应该不会有什么问题。
话说BIOS已经对i8259a进行了初始化,不过工作方式不是我们
想要的,而且各个硬件的端口也不符合现代硬件的要求,所以
我们这里才要从新初始化。哎,真是太麻烦了。另外需要特殊
说明的是1个位代表一个中断向量,该位置位时表示屏蔽中断,
清零(复位)时表示开启中断。
*/
out(0x21, 0xff);
out(0xa1, 0xff);
/*
向主芯片的控制端口0x20发送命令控制字,0x11是ICW1命令字,
它的涵义是采用边沿触发模式、多片级联方式、最后要发送ICW4
命令字以结束初始化。
*/
out(0x20, 0x11);
/*
向主芯片的数据端口0x21发送命令控制字,0x20是ICW2命令字,
它的涵义是主芯片的中断号从0x20开始,也就是主芯片接受的
中断向量是0x20-0x27。
*/
out(0x21, 0x20);
/*
向主芯片的数据端口0x21发送命令控制字,0x04是ICW3命令字,
也就是主芯片的IR2引脚连接从芯片INT引脚,它的涵义是主
芯片的2号中断(向量是0x22),用于接受来自从芯片的中断,
也就是说0x22不会接受来自实质硬件的中断,而是来自从片的
中断。我们在这里必须这样设置,原因是硬件就是这样连接的。
*/
out(0x21, 0x04);
/*
向主芯片的数据端口0x21发送命令控制字,0x01是ICW4命令字,
它的涵义是主芯片的最终工作模式是8086模式、普通EOI、非
缓冲方式、需要发送指令来进行芯片的复位(通过发送指令的
方式来重新激活中断响应)。
*/
out(0x21, 0x01);
/*
向从芯片的控制端口0xa0发送命令控制字,0x11是ICW1命令字,
它的涵义是采用边沿触发模式、多片级联方式、最后要发送ICW4
命令字以结束初始化。
*/
out(0xa0, 0x11);
/*
向从芯片的数据端口0xa1发送命令控制字,0x28是ICW2命令字,
它的涵义是主芯片的中断号从0x28开始,也就是从芯片接受的
中断向量是0x28-0x2f。
*/
out(0xa1, 0x28);
/*
向从芯片的数据端口0xa1发送命令控制字,0x02是ICW3命令字,
它的涵义是从芯片的INT引脚连接到主芯片的IR2引脚上。用这样
的方式进行级联,如果要用0x28-0x2f接受的中断,必须将主芯片
的第2位(从0开始计算)复位(清零)。
*/
out(0xa1, 0x02);
/*
向从芯片的数据端口0x21发送命令控制字,0x01是ICW4命令字,
它的涵义是从芯片的最终工作模式是8086模式、普通EOI、非
缓冲方式、需要发送指令来进行芯片的复位(通过发送指令的
方式来重新激活中断响应)。
*/
out(0xa1, 0x01);
/*
和本函数开头一样,屏蔽所有硬件中断。
*/
out(0x21, 0xff);
out(0xa1, 0xff);
}
void rtc_init(void) {
/*
通过索引端口0x70,使得下一步能够访问到rtc的0x0b寄存器。
在访问rtc期间,为了阻断NMI中断最好将最高位置1,这就是
为什么加上0x80的原因。
*/
out(0x70, 0x80 + 0x0b);
// out(0x70, 0x0b);
/*
通过数据端口0x71将0x12(二进制形式为00010010b)写入到
rtc的0x0b寄存器。这样一来我们就设置了更新周期中断模式。
它的具体涵义是:允许每秒1次的更新周期中断发生、禁止周
期性中断、禁止闹钟功能、允许更新周期中断结束、使用24
小时制、日期和时间为压缩bcd编码。
*/
out(0x71, 0x12);
// out(0x70, 0x0c);
// in(0x71);
}
void rtc_handler(void) {
// kprintf_(" RTC ");
unsigned int* buf = (unsigned int*)(0xe0000000 + 128 * 1024 * 4);
/*
通过索引端口0x70,使得下一步能够访问到rtc的0x04寄存器。
该寄存器中存放的正是"小时"的压缩bcd值。
*/
out(0x70, 0x04);
/*
通过数据端口0x71将"小时"的压缩bcd值读出并保存在全局变
量rtc_data.h中。
*/
rtc_data.h = in(0x71);
/*
通过索引端口0x70,使得下一步能够访问到rtc的0x02寄存器。
该寄存器中存放的正是"分钟"的压缩bcd值。
*/
out(0x70, 0x02);
/*
通过数据端口0x71将"分钟"的压缩bcd值读出并保存在全局变
量rtc_data.h中。
*/
rtc_data.m = in(0x71);
/*
通过索引端口0x70,使得下一步能够访问到rtc的0x00寄存器。
该寄存器中存放的正是"秒"的压缩bcd值。
*/
out(0x70, 0x00);
/*
通过数据端口0x71将"分钟"的压缩bcd值读出并保存在全局变
量rtc_data.h中。
*/
rtc_data.s = in(0x71);
/*
通过索引端口0x70,使得下一步能够访问到rtc的0x0c寄存器。
*/
out(0x70, 0x0c);
/*
通过简单的读取数据端口0x71,使得rtc的0x0c寄存器内容自动
清零,从而使该中断能够再度产生。
*/
in(0x71);
/*
描画钟表的函数。
*/
clock(930, 90);
}
/*
将压缩bcd值转化为二进制值的函数。压缩bcd编码就是在1个
字节的范围内,将该字节的高4位看作十进制数的十位,低4位
看作十进制数的个位,从而存储数值的方法。这样一来1个
字节8位能表示数的范围是0-99,比纯二级制数0-255的范围小
很多呢!大家可别误认为是高4位和低4位分别保存的是十位和
个位的ASCII码呀,也是数值,不过高4位和低4位分别保存,也
就是高4位和低4位没有任何关联了,这两个4位的取值范围也就
是0-9,如果超出这个范围就是违例了。转化的方法也非常简单
了,也就是高4位乘以10然后与个位相加就好了。
*/
unsigned int bcd2bin(unsigned int bcd_num) {
return (bcd_num & 0xf) + (bcd_num >> 4) * 10;
}
/*
一个非常粗糙画表盘刻度的函数。
*/
void dial(int x, int y) {
unsigned int* buf = (unsigned int*)(0xe0000000 + 128 * 1024 * 4);
int i;
/*
表盘是圆的,所以画两个同心圆就可以显示出圆环状的边框。
*/
fill_circle(buf, 1024, x, y, 100 - 20, 0x00dab273);
fill_circle(buf, 1024, x, y, 97 - 20, 0x00dcdcdc);
/*
变量a和b是线段终点的横纵坐标值。
*/
int a, b;
/*
用循环的方法来画出表盘上的刻度,这里计算线段终点坐标的方法
居然用到了三角函数是不是很库的样子,其实根本就是虚张声势
的三角函数了,把每个可能用到的数值放到数组中,然后查表
就好了,当然我们计算的数值更精确一点,这里把三角函数的
数值擅自扩大了10000倍。下面分别是画了不同点位的刻度,然后
用实心圆来擦除多余的部分。
*/
for(i = 0; i < 60; i++) {
if(i % 15) {
a = x + (95 - 20) * sin_tab[i] / 10000;
b = y - (95 - 20) * cos_tab[i] / 10000;
draw_line(buf , 1024, x, y, a, b, 0x00000000);
}
}
fill_circle(buf, 1024, x, y, 87 - 20, 0x00dcdcdc);
for(i = 0; i < 60; i++) {
if(!(i % 5) && (i % 15)) {
a = x + (95 - 20) * sin_tab[i] / 10000;
b = y - (95 - 20) * cos_tab[i] / 10000;
draw_line(buf , 1024, x, y, a, b, 0x00000000);
}
}
fill_circle(buf, 1024, x, y, 82 - 20, 0x00dcdcdc);
for(i = 0; i < 60; i++) {
if((i % 15) == 0) {
a = x + (80 - 20) * sin_tab[i] / 10000;
b = y - (80 - 20) * cos_tab[i] / 10000;
draw_line(buf , 1024, x, y, a, b, 0x00000000);
}
}
fill_circle(buf, 1024, x, y, 74 - 20, 0x00dcdcdc);
/*
把特殊时间的数字也画到表盘上。
*/
put_str_buf(buf, 1024, x - 8, y - 94 + 20, 0x00000000, "12");
put_str_buf(buf, 1024, x - 4, y + 82 - 20, 0x00000000, "6");
put_str_buf(buf, 1024, x - 90 + 20, y - 8, 0x00000000, "9");
put_str_buf(buf, 1024, x + 86 - 20, y - 8, 0x00000000, "3");
}
/*
一个和名字一样的变量,为了使画表盘的函数仅仅运行一次,
目前我们用下下策的方法,也就是全局变量增1的方法。
*/
unsigned int once = 0;
/*
一个显示时钟,增加乐趣的函数,对于开发操作系统的实际意义
不大。
*/
void clock(int x, int y) {
if(once == 0) {
dial(x, y);
once++;
}
unsigned int* buf = (unsigned int*)(0xe0000000 + 128 * 1024 * 4);
/*
变量a和b仍然是线段终点的坐标。而h、m、s最终转化为时、分、
秒的二进制值。
*/
int a, b, h, m, s;
unsigned char t[8];
h = rtc_data.h;
m = rtc_data.m;
s = rtc_data.s;
h = bcd2bin(h);
m = bcd2bin(m);
s = bcd2bin(s);
/*
每次要重新绘制表盘变化的部分。
*/
fill_circle(buf, 1024, x, y, 74 - 20, 0x00dcdcdc);
/*
绘制表盘的数字部分,为了能够对齐加入了条件判断,当数字小于
10的时候,在数字前添加0。
*/
if(h < 10) {
t[0] = '0';
i2a(t + 1, h, 10);
t[2] = '-';
t[3] = 0;
put_str_buf(buf, 1024, x - 32,
y + 8, 0x00000000, t);
} else {
i2a(t, h, 10);
t[2] = '-';
t[3] = 0;
put_str_buf(buf, 1024, x - 32,
y + 8, 0x00000000, t);
}
if(m < 10) {
t[0] = '0';
i2a(t + 1, m, 10);
t[2] = '-';
t[3] = 0;
put_str_buf(buf, 1024, x - 32 + 24,
y + 8, 0x00000000, t);
} else {
i2a(t, m, 10);
t[2] = '-';
t[3] = 0;
put_str_buf(buf, 1024, x - 32 + 24,
y + 8, 0x00000000, t);
}
if(s < 10) {
t[0] = '0';
i2a(t + 1, s, 10);
put_str_buf(buf, 1024, x - 32 + 48,
y + 8, 0x00000000, t);
} else {
i2a(t, s, 10);
put_str_buf(buf, 1024, x - 32 + 48,
y + 8, 0x00000000, t);
}
/*
这一部分时表针的显示,其实也就是画各种不同角度但长度相等的线段,
这样线段的起点是圆心,终点是通过三角函数计算得来的终点横纵坐标的数值。
*/
a = x + (56 - 20) * sin_tab[h % 12 * 5 + m / 12] / 10000;
b = y - (56 - 20) * cos_tab[h % 12 * 5 + m / 12] / 10000;
draw_line(buf , 1024, x, y, a, b, 0x00000000);
fill_circle(buf, 1024, x, y, 4, 0x00000000);
a = x + (66 - 20) * sin_tab[m] / 10000;
b = y - (66 - 20) * cos_tab[m] / 10000;
draw_line(buf , 1024, x, y, a, b, 0x000000ff);
fill_circle(buf, 1024, x, y, 3, 0x000000ff);
a = x + (72 - 20) * sin_tab[s] / 10000;
b = y - (72 - 20) * cos_tab[s] / 10000;
draw_line(buf , 1024, x, y, a, b, 0x00ff0000);
fill_circle(buf, 1024, x, y, 2, 0x00ff0000);
}
【kernel.c 节选】
(上面省略)
/*
向主从芯片的数据端口写入数据,0xfb的二进制形式是11111011b,
也就是通过将第2位(从0计算)复位(清零),使向量0x22能够接收
中断,从而能够开启从芯片的中断。0xfe的二进制形式是11111110b,
正是从芯片的0x28向量对应的中断,该中断便是实时时钟中断。
*/
out(0x21, 0xfb);
out(0xa1, 0xfe);
/*
通过开中断指令,开启处理器的可屏蔽中断。
*/
asm("sti");
printf_("here");
while(1);
}
void init(void) {
/*
全局描述符表的初始化。
*/
gdt_init();
/*
分页机制的初始化。
*/
page_init();
/*
中断描述符表的初始化。
*/
idt_init();
/*
可编程中断控制器的初始化。
*/
i8259_init();
/*
实时时钟的初始化。
*/
rtc_init();
}
【system.asm 节选】
(上面省略)
global _rtc
align 8
_rtc:
push 0x28 ; 向量号
jmp _interrupt_entry
align 8
_interrupt_entry:
; 占位
push 0
pusha
push ds
push es
push fs
push gs
mov al, 0x20
out 0x20, al
out 0xa0, al
mov eax, [esp + 4 * 4 + 8 * 4 + 1 * 4]
call [_i_table + eax * 4]
; 因为紧挨着_exit,所以根本不用跳转,这里只是为了好看
jmp _exit
通过上面代码和注释相信大家不会有什么问题了吧,现在让我们来看看运行的效果图吧。是不是很酷,只不过在书上它真的不能动罢了。
在本章节目的最后,笔者又赠送给大家另外一种virtual box的调试方式,虽然用这种方式调试跟前面的没有什么不同之处,但是由于它是在虚拟机之外用telnet连接端口的方法,因此比虚拟机内部的更灵活。还是让我来看代码吧。
【telnet_dbg.cmd】
%下面的两条命令是启动外部调试的功能%
vboxmanage setextradata "temp" VboxInternal/DBGC/Enabled 1
start virtualBoxVM.exe --startvm "temp" --dbg --start-paused
%延时,否则可能虚拟机还没有启动完,就运行了新的命令行%
ping -n 18 127.0.0.1 > nul
%在虚拟机启动后,通过telnet连接5000端口,进行调试%
start cmd.exe
telnet 127.0.0.1 5000
运行了上述批处理文件并等待大于18秒后,会出现下图的画面,这时候我们还要在【管理员】窗口中输入“make resume”命令(我们之前创建的),然后在【Telnet】窗口中进行调试。
现在让我们来调试一下,首先当虚拟机运行到起来以后,我们可以通过“stop”命令让虚拟机暂停下来,这是钟表也就停止走时了。
然后我们运行“info rtc”命令,查看以下rtc的状态。
Welcome to the VirtualBox Debugger!
Current VM is 0dcd0000, CPU #0
VBoxDbg> stop
dbgf event: VM 000000000dcd0000 is halted! (other)
eax=00000004 ebx=ffffffff ecx=00000007 edx=00014000 esi=00000000 edi=00000000
eip=00101184 esp=00100fc8 ebp=00100ff0 iopl=0 nv up ei pl nz na pe nc
cs=0008 ds=0010 es=0010 fs=0010 gs=0010 ss=0010 eflags=00000202
0008:00101184 eb fe jmp -002h (000101184h)
VBoxDbg> info rtc
Time: 04:12:08 Date: 22-10-02
REG A=26 B=12 C=00 D=80
还可以通过“di”命令查看一下中断描述符表的细节。
VBoxDbg> di
0000 Int32 Sel:Off=0008:00102620 DPL=0 P
0001 Int32 Sel:Off=0008:00102670 DPL=0 P
0002 Int32 Sel:Off=0008:00102670 DPL=0 P
0003 Int32 Sel:Off=0008:00102650 DPL=0 P
0004 Int32 Sel:Off=0008:00102660 DPL=0 P
0005 Int32 Sel:Off=0008:00102624 DPL=0 P
0006 Int32 Sel:Off=0008:00102680 DPL=0 P
0007 Int32 Sel:Off=0008:00102670 DPL=0 P
0008 Int32 Sel:Off=0008:001026a0 DPL=0 P
0009 Int32 Sel:Off=0008:00102670 DPL=0 P
000a Int32 Sel:Off=0008:001026b0 DPL=0 P
000b Int32 Sel:Off=0008:001026c0 DPL=0 P
000c Int32 Sel:Off=0008:001026d0 DPL=0 P
000d Int32 Sel:Off=0008:001025f0 DPL=0 P
000e Int32 Sel:Off=0008:001026e0 DPL=0 P
000f Int32 Sel:Off=0008:00102670 DPL=0 P
0010 Int32 Sel:Off=0008:00102670 DPL=0 P
0011 Int32 Sel:Off=0008:001026f0 DPL=0 P
0012 Int32 Sel:Off=0008:00102670 DPL=0 P
0013 Int32 Sel:Off=0008:00102670 DPL=0 P
0014 Int32 Sel:Off=0008:00102670 DPL=0 P
0015 Int32 Sel:Off=0008:00102670 DPL=0 P
0016 Int32 Sel:Off=0008:00102670 DPL=0 P
0017 Int32 Sel:Off=0008:00102670 DPL=0 P
0018 Int32 Sel:Off=0008:00102670 DPL=0 P
0019 Int32 Sel:Off=0008:00102670 DPL=0 P
001a Int32 Sel:Off=0008:00102670 DPL=0 P
001b Int32 Sel:Off=0008:00102670 DPL=0 P
001c Int32 Sel:Off=0008:00102670 DPL=0 P
001d Int32 Sel:Off=0008:00102670 DPL=0 P
001e Int32 Sel:Off=0008:00102670 DPL=0 P
001f Int32 Sel:Off=0008:00102670 DPL=0 P
0020 Int32 Sel:Off=0008:00102670 DPL=0 P
0021 Int32 Sel:Off=0008:00102670 DPL=0 P
0022 Int32 Sel:Off=0008:00102670 DPL=0 P
0023 Int32 Sel:Off=0008:00102670 DPL=0 P
0024 Int32 Sel:Off=0008:00102670 DPL=0 P
0025 Int32 Sel:Off=0008:00102670 DPL=0 P
0026 Int32 Sel:Off=0008:00102670 DPL=0 P
0027 Int32 Sel:Off=0008:00102670 DPL=0 P
0028 Int32 Sel:Off=0008:00102748 DPL=0 P
0029 Int32 Sel:Off=0008:00102670 DPL=0 P
002a Int32 Sel:Off=0008:00102670 DPL=0 P
002b Int32 Sel:Off=0008:00102670 DPL=0 P
002c Int32 Sel:Off=0008:00102670 DPL=0 P
002d Int32 Sel:Off=0008:00102670 DPL=0 P
002e Int32 Sel:Off=0008:00102670 DPL=0 P
002f Int32 Sel:Off=0008:00102670 DPL=0 P
由于有256项之多,我们这里只是摘取了前32项,并且在向量0x28处,用下划线标注了一下。通过先后运行“make”和“objdump -d .\create\kernel.elf > kernel.txt”命令反汇编内核文件,我们找到下面的地方。
【kernel.txt 节选】
00102748 <_rtc>:
102748: 6a 28 push $0x28
10274a: eb 04 jmp 102750 <_interrupt_entry>
10274c: 90 nop
10274d: 90 nop
10274e: 90 nop
10274f: 90 nop
怎么样0x00102748处正好是实时时钟中断处理程序的入口地址吧,看来今后想蒙混过关都不行了。如果程序出错了,直接就可以去看汇编代码。真是很伤脑筋的问题呀!(笔者真的不想肯那些难懂的汇编代码了)。
在节目的最后,笔者要附送大家几个实用的函数,以备今后使用。
【intr.c 节选】
(上面省略)
/*
下面的4个函数分别是获取处理器中断状态、设置处理器中断
状态、关闭中断、开启中断的函数。其实关闭中断和开启中断
只要1行汇编代码就好了,没有必要做得这样复杂,不过考虑到
程序要知道关闭和开启中断前的中断状态,所以还是把简单的
东西复杂化了。哈哈,画蛇添足的事情有时候还是要做的。
*/
unsigned int get_intr_status(void) {
unsigned int status;
/*
将处理器状态寄存器eflags入栈,出栈到eax中,并最终存储
在变量status中。
*/
asm volatile ("pushfl;popl %0":"=a"(status));
/*
处理器状态寄存器eflags的第9位(从0开始计算)是中断标志,
这里通过与操作获取该值,并最终把它转化成布尔值。
*/
status = ((status & (1 << 9)) ? 1 : 0);
return status;
}
/*
设置中断状态的函数,0是关闭中断,非0为开启中断。
*/
void set_intr_status(unsigned int status) {
if(status) {
asm volatile ("sti");
} else {
asm volatile ("cli");
}
}
/*
关闭中断并返回之前中断状态的函数。
*/
unsigned int intr_disable(void) {
unsigned int old_status = get_intr_status();
set_intr_status(0);
return old_status;
}
/*
开启中断并返回之前中断状态的函数。
*/
unsigned int intr_enable(void) {
unsigned int old_status = get_intr_status();
set_intr_status(1);
return old_status;
}
关于开关中断的小实验大家可以通过在kernel.c中加入相应的代码来完成,这里就请大家自由发挥想象空间吧。
总结,这一章关于可编程中断控制器和实时时钟硬件方面的知识由于设计的硬件知识比较多,其实是挺枯燥的,还好笔者完全省略了这部分内容,不过嘻嘻想起来,要是把这些东西放到本章真的能凑出不少的字数呢。不过以笔者的写作功底就只能是抄书的份了。所以思来想去,笔者决定不剽窃,不做无用功。玩就玩点实在的,这不上边做了个时钟给大家品品中断的味道。其实笔者也不是刻意显摆这个小玩意,我要告诉大家的是中断在整个操作系统的开发中真的很重要,乃至与我们整个的任务切换以及进程同步等都依赖于它。今后,我们还要不断的增加和改进各种中断,从而使多任务的操作系统真正的运行起来。