第18章 中断和异常的处理与抢占式多任务
中断和异常
中断和异常概述
中断(Interrupt):
- 硬件中断是由外围硬件设备发出的中断信号引发的,以请求处理器提供服务。
- 软中断是由int n指令引发的中断处理,n是中断号或者叫类型码。
异常(Exception):是处理器内部产生的中断,表示在指令执行的过程中遇到了错误的状况。当处理器执行一条非法指令,或者因条件不具备,指令不能正常执行时,将引发这种类型的中断。
分为三种类型:
- 指令执行异常:程序中的错误;
- 程序调试异常:为调试程序准备的;
- 机器检查异常:域处理器型号相关的,比如总线错误、奇偶校验异常等。
根据异常情况的性质和严重性,异常又分为以下三种,
- 故障(Faults):故障通常是可以纠正的。
- 陷阱(Traps):陷阱中断通常在执行了截获陷阱条件的指令之后立即产生,如果陷阱条件成立的话。
- 终止(Aborts):最严重的错误,这种异常发生时,程序或者任务都不可能重新启动。
中断描述符表、中断门和陷阱门
中断向量表(Interrupt Vector Table, IVT):实模式下位于内存最低端的1KB内存,是中断向量表(IVT),定义了256种中断的入口地址,包括16位段地址和16位段内偏移量,占用4字节。
中断描述符表(Interrupt Descriptor Table, IDT):在保护模式下,处理器对中断的管理是相似的,但并非使用传统的中断向量表来保存中断处理过程的地址,而是中断描述符表。在这个表里保存的是和中断处理过程有关的描述符,包括中断门、陷阱门和任务门。
中断门格式:
陷阱门格式:和中断门格式相比,就是TYPE位上最后一位为1。
中断描述符表寄存器:在处理器内部,有一个48位的中断描述符表寄存器(Interrupt Descriptor Table Register, IDTR),保存着中断描述符表在内存中的线性基地址和界限。
中断处理过程:
- 在保护模式下,当中断和异常发生时,处理器用中断向量乘以8的结果去访问IDT,从中取得对应的描述符。
- 从中断门或者陷阱门描述符中可以获取到目标代码段描述符的选择子,可以从GDT或者LDT中获取到代码段描述符信息。
- 通过代码段描述符的基地址和中断门陷阱门中的偏移量就可以定位到具体的中断程序了。
本章代码清单
本章代码实现的功能,创建内核任务、用户任务1、用户任务2,然后通过中断的方法在几个任务之间切换,不同的任务打印不同的字符串。
内核的加载和初始化
创建中断描述符表
put_string例程改进:在打印开始的时候关闭中断,返回前开启。避免打印字符串的过程被中断。
;字符串显示例程
put_string:cli
.....sti
中断描述符标IDT位置:在内核程序的开始部分(第13行)增加了一个常量idt_linear_address,被定义为0x1F000,指定中断描述符表IDT的线性地址。
idt_linear_address equ 0x1F000 ;中断描述符表的线性地址
内存布局映像图:
处理器内部异常:在保护模式下,前20个中断向量基本上都是留给处理器内部异常的。原则上每个异常都需要根据产生的原因单独处理。书中只是为了说明问题,提供了一个 general_exception_handler 来处理所有异常。
general_exception_handler: ;通用的异常处理过程,就是打印异常信息,停机。mov ebx,excep_msgcall sys_routine_seg_sel:put_stringhlt
创建处理器需要的20个异常处理程序的中断门描述符:
;前20个向量是处理器异常使用的
mov eax,general_exception_handler ;门代码在段内偏移地址
mov bx,sys_routine_seg_sel ;门代码所在段的选择子
mov cx,0x8e00 ;中断门属性,0特权级
;1 00 0_1110_0000_0000,32位中断、0特权级
;P DPL 0_D110_0000_0000
call sys_routine_seg_sel:make_gate_descriptor ;生成门描述符,EDX:EAX
安装对应20个处理器异常的中断门描述符:
mov ebx,idt_linear_address ;中断描述符表的线性地址xor esi,esi ;从第0个开始安装
.idt0:mov [es:ebx+esi*8],eax ;中断门描述符低32位mov [es:ebx+esi*8+4],edx ;中断门描述符高32位inc esi ;安装下1个cmp esi,19 ;安装前20个异常中断处理过程jle .idt0
安装通用中断处理程序的中断门描述符:
;其余为保留或硬件使用的中断向量mov eax,general_interrupt_handler ;门代码在段内偏移地址mov bx,sys_routine_seg_sel ;门代码所在段的选择子mov cx,0x8e00 ;32位中断门,0特权级call sys_routine_seg_sel:make_gate_descriptormov ebx,idt_linear_address ;中断描述符表的线性地址
.idt1:mov [es:ebx+esi*8],eax ;esi前面已经加到20了,从20开始 mov [es:ebx+esi*8+4],edxinc esicmp esi,255 ;安装普通的中断处理过程jle .idt1
其中 general_interrupt_handler 的处理过程很简单,只是给8259A中断芯片发送中断结束命令。
general_interrupt_handler: ;通用的中断处理过程push eaxmov al,0x20 ;中断结束命令EOIout 0xa0,al ;向从片发送out 0x20,al ;向主片发送pop eaxiretd ;确保从栈中弹出一个双字。
iretd(Interrupt Return Double):确保从栈中弹出一个双字。
[bits 16]
iret ;CF
iretd ;66 CF[bits 32] ;
iret ;CF
iretd ;CF
创建实时时钟中断处理程序的中断门描述符:书中切换进程使用实时时钟中断,默认的中断号是0x70,处理过程为 rtm_0x70_interrupt_handle
创建0x70号中断的中断门,并安装在中断描述符表中,以替换原先那个通用中断处理过程的中断门。
;设置实时时钟中断处理过程
mov eax,rtm_0x70_interrupt_handle ;门代码在段内偏移地址
mov bx,sys_routine_seg_sel ;门代码所在段的选择子
mov cx,0x8e00 ;32位中断门,0特权级
call sys_routine_seg_sel:make_gate_descriptormov ebx,idt_linear_address ;中断描述符表的线性地址
mov [es:ebx+0x70*8],eax ;位置在0x70号位置
mov [es:ebx+0x70*8+4],edx
其中 rtm_0x70_interrupt_handle 例程中调用了任务调度的方法。
rtm_0x70_interrupt_handle: ;实时时钟中断处理过程pushadmov al,0x20 ;中断结束命令EOIout 0xa0,al ;向8259A从片发送out 0x20,al ;向8259A主片发送mov al,0x0c ;寄存器C的索引。且开放NMIout 0x70,alin al,0x71 ;读一下RTC的寄存器C,否则只发生一次中断;此处不考虑闹钟和周期性中断的情况;请求任务调度call sys_routine_seg_sel:initiate_task_switchpopadiretd
中断执行时,可能系统还没有任务,所以 initiate_task_switch 例程中增加了是否有任务的判断:
mov eax,[es:tcb_chain] ;获取tcb链表表头指针
cmp eax,0 ;检测头指针是否为0
jz .return ;为0,则直接返回
加载IDTR:把中断描述符表的基地址和界限值加载到中断描述符表寄存器(IDTR)中
lidt m
内核数据段中,标号pidt这里开辟了6字节的空间。其中,前2字节是中断描述符表的界限值;后4字节是中断描述符表的线性地址。界限值和线性地址是在程序中填写的,所以这里初始化为0。
mov word [pidt],256*8-1 ;IDT的界限
mov dword [pidt+2],idt_linear_address
lidt [pidt] ;加载中断描述符表寄存器IDTR
8259A芯片的初始化
引子:8259A芯片的主片的中断向量为0x08~0x0F,从片的中断向量是0x70~0x77。在32位处理器上,0x08~0x0F已经被处理器用作异常向量。
中断向量0x20~0xFF(32~255)是用户可以自由分配,所以需要将8259A的主片0x080x0F中断向量调整到0x200x27。
实现:使用初始化命令字(Initialize Command Word,ICW),以设置它的工作方式,共有4个初始化命令字,分别是ICW1~ICW4,都是单字节命令。
- ICW1用于设置中断请求的触发方式,以及级联的芯片数量;
- ICW2用于设置每个芯片的中断向量;
- ICW3用于指定用哪个引脚实现芯片的级联;
- ICW4用于控制芯片的工作方式。
这里就是8259A芯片规则进行设置即可.
;设置8259A中断控制器
mov al,0x11 ;设置主片
out 0x20,al ;ICW1:边沿触发/级联方式
mov al,0x20
out 0x21,al ;ICW2:起始中断向量为0x20,只需要设置一个起始的就可以了。
mov al,0x04
out 0x21,al ;ICW3:从片级联到IR2
mov al,0x01
out 0x21,al ;ICW4:非总线缓冲,全嵌套,正常EOImov al,0x11 ;设置从片
out 0xa0,al ;ICW1:边沿触发/级联方式
mov al,0x70
out 0xa1,al ;ICW2:起始中断向量
mov al,0x02 ;!!
out 0xa1,al ;ICW3:从片级联到IR2
mov al,0x01
out 0xa1,al ;ICW4:非总线缓冲,全嵌套,正常EOI
设置和时钟中断有关的硬件状态:在第10章 中断和动态时钟显示有更详细的说明。
;设置和时钟中断相关的硬件
mov al,0x0b ;RTC寄存器B
or al,0x80 ;阻断NMI
out 0x70,al
mov al,0x12 ;设置寄存器B,禁止周期性中断,开放更
out 0x71,al ;新结束后中断,BCD码,24小时制in al,0xa1 ;读8259从片的IMR寄存器
and al,0xfe ;清除bit 0(此位连接RTC)
out 0xa1,al ;写回此寄存器mov al,0x0c
out 0x70,al
in al,0x71 ;读RTC寄存器C,复位未决的中断状态
开放硬件中断:中断设置完成后就可以开放。
sti
中断和异常处理程序的保护
特权级保护:在进入指定的中断处理过程前,处理器要对中断和异常处理程序进行特权级保护。当目标代码段描述符的特权级低于当前特权级时,即在数值上 CPL<目标代码段的DPL 时,不允许将控制转移到中断或者异常处理程序。
简单的说,中断或者异常处理程序的 特权级要更高。
不同之处:中断和异常处理程序的特权级保护也有一些特别之处。
- 不检查RPL:因为中断和异常的向量只是一个代表中断号码的数字,没有RPL字段,故当处理器进入中断或异常处理程序,或者通过任务门发起任务切换时,不检查RPL。
- DPL检测:中断门、陷阱门也有自己的描述符特权级DPL,即门的DPL,但是通常情况下不针对该DPL进行检查,
- 除了用软中断int n和单步中断int3,以及into引发的中断和异常。在这种情况下,当前特权级CPL必须高于或者和门的特权级DPL相同,即在数值上,CPL<=门描述符的DPL。这主要是为了防止低特权级的软件通过软中断指令访问一些只为内核服务的例程,如页故障处理。相反的,对于硬件中断和处理器检测到异常情况而引发的中断处理,不检查们的DPL。
栈切换:当中断和异常发生时,任务可能正在特权级别为0的全局空间(内核)中执行,也可能正在特权级别为3的局部空间内执行。如果异常或中断处理程序运行在较高的特权级别上(数值较低),那么将切换栈。
- 根据处理程序的特权级别,从当前任务的TSS取得相应栈段选择子和栈指针,处理器把旧栈的选择子和栈指针压入新栈。
- (压入旧栈的选择子和栈指针是为了中断返回时,可以回到中断处继续执行。)
- 处理器把EFLAGS、CS和EIP的当前状态压入新栈。
- 对于有错误异常的代码,处理器还要把错误代码压入新栈,紧挨着EIP之后。
EFLAGS中IF位:
- 中断门:通过中断门进入中断处理程序时,寄存器EFLAGS的IF位被处理器自动清零,以禁止嵌套的中断,当中断返回时,将从栈中恢复寄存器EFLAGS的原始状态。(中断门的优先级很高,处理过程不允许中断。)
- 陷阱门:陷阱中断的优先级比较低,当通过陷阱门进入中断处理程序时,寄存器EFLAGS的IF位保持不变,以允许其他中断优先处理。(陷阱的优先级低,允许在陷阱门处理过程中切换到其它中断)
寄存器EFLAGS的IF位仅影响硬件中断,对NMI、异常和int n形式的软件中断不起作用。
我理解就是NMI、异常和int n形式的软件中断不判断EFLAGS的IF位。
中断任务
中断是任务门:当中断发生时,如果根据中断向量表从IDT中找到的描述符是任务门,则不是进行一般的中断处理过程,而是发起任务切换。
使用任务门的好处:
- 被中断的那个程序或任务的整个执行环境可以被完整的保存起来(保存到它的TSS中)
- 由于接管控制的是一个新的任务,因此,可以使用一个全新的0特权级栈。这可以有效地防止因当前任务的0特权级栈遭到破坏而使系统崩溃。
- 由于是切换到一个新任务,因此,它有一个独立的地址空间。
使用任务门的坏处:
- 因为要保持大量的机器状态,并进行一系列的特权级和内存访问的检查工作,速度很慢。(貌似正常的任务切换也要做这些?)
- 中断和异常发起的任务切换,不再保存CS、EIP的状态,但是在任务完成后,处理器需要把错误代码压入到新任务的栈中。(怎么获取新任务的?通过当前任务的TSS中的指向上一个TSS的指针可以获取到。)
- 任务是不可重入的,因此在进入中断任务之后和执行iretd指令之前,必须关中断。
- 只对通过int3、int n和into指令发起的任务切换实施特权级检查,数值上:CPL<=任务门的DPL(当前特权级高于等于任务门的特权级),其他情况不检查任务门的特权级。
错误代码
错误代码的格式如下:
- EXT位:异常是由外部事件引发的(External Event)。等于1,表示异常是由NMI、硬件中断等引发的。
- IDT位:指示描述符的位置(Descriptor Location)。(引用了一个异常的段)
- 为“1”时,表示段选择子的索引部分(错误代码的位15~3)是指向中断描述符表。
- 为“0”时,表示段选择子的索引部分指向GDT或者LDT。
- IT位:TI位仅在IDT位是“0”的情况下才有意义。
- 此位是“0”时,表示段选择子的索引部分指向GDT,
- 否则,指向LDT。
- 段选择子的索引部分:指示GDT/LDT内的段描述符,或者IDT内的门描述符,它就是我们平时所用的段选择子的高13位。
对于外部异常(通过处理器引脚触发),以及用软中断指令int n引发的异常,处理器不会压入错误代码,即使它原本是一个有错误代码的异常。
错误代码全0:
- 异常产生并非由于引用了一个特定的段。
- 引用了一个段,只是段描述符是空描述符。
弹出错误代码:通过iret/iretd指令从中断处理程序返回时,处理器并不会自动弹出错误代码。因此,对于那些有异常代码的异常处理过程来说,必须在执行iret/iretd指令前,先从栈中移去(或弹出)错误代码。
异常处理过程:......pop xxx ;弹出错误代码iret
不会压入错误代码:对于外部异常(通过处理器引脚触发),以及用软中断指令int n引发的异常,处理器不会压入错误代码。
- 分配给外部中断的向量号31~255;
- 8259A或者IO APIC芯片可能会给出一个0~19的向量号;
int 0x0d ;向量号13,常规保护异常#GP
用定时中断实施任务切换
中断随时可能发生:当通过sti指令开放中断后,中断随时可能发生。假定在执行sti后的两行代码发生了0x70中断。
;指向下面两行时发生中断
mov ebx,message_0
call sys_routine_seg_sel:put_string
rtm_0x70_interrupt_handle中断处理过程:
rtm_0x70_interrupt_handle: ;实时时钟中断处理过程pushadmov al,0x20 ;中断结束命令EOIout 0xa0,al ;向8259A从片发送out 0x20,al ;向8259A主片发送mov al,0x0c ;寄存器C的索引。且开放NMIout 0x70,alin al,0x71 ;读一下RTC的寄存器C,否则只发生一次中断;此处不考虑闹钟和周期性中断的情况;请求任务调度call sys_routine_seg_sel:initiate_task_switchpopadiretd
中断结束命令EOI和寄存器C的索引这个在第10章介绍很详细了,我忘记了回去查一下就明白了。
判断TCB为空:对这个例程的调用是在中断处理过程内部进行,当中断发生时,系统中可能还不存在任何任务。因此需要判断任务控制块链表是否为空。
mov eax,[es:tcb_chain] ;获取TCB链表头指针
cmp eax,0 ;为0表示为空,如果有值则是下一个tcb的地址
jz .return ;为0则返回
这里是不是可以用 int 0x70 触发一个中断?
目测是可以,尽管 rtm_0x70_interrupt_handle 有硬件触发的处理部分,但是通过软件触发,硬件的处理程序也没有冲突。
中断内实施任务切换:在中断内实施任务切换,可以使用jmp指令,从当前正在运行的任务切换到另一个空闲任务。中断的发生是随机的,但是,要在中断处理过程内执行任务切换,处理器必须正在执行一个任务。
假定任务在局部空间执行时触发了中断:
内核任务的创建
内核任务的创建和上一章基本一样,一个不同的地方。
临时关闭中断:创建任务之前,必须先禁止中断,创建内核任务完成后才能开启。预防任务创建到一半触发中断,程序崩溃。
;为内核任务创建任务控制块TCB
cli ;这里要先禁止中断
mov ecx,0x46
call sys_routine_seg_sel:allocate_memory
call append_to_tcb_link ;将此TCB添加到TCB链中
mov esi,ecx......;任务寄存器TR中的内容是任务存在的标志,该内容也决定了当前任务是谁。
;下面的指令为当前正在执行的0特权级任务“程序管理器”后补手续(TSS)。
ltr cx
sti ;这里开启中断
用户任务的创建和执行
临时关闭中断:加载用户程序时也需要先关闭中断,任务创建之后再开放中断。
切换到第1个用户任务:假定用户程序创建完成,并开放中断后,发生了0x70中断。这时
- 系统中就内核任务和用户任务两个任务;
- 当前运行的任务时内核任务;
- 发生切换就一定切换到用户任务;
用户任务运行:就是循环打印一个字符串。
.do_prn: ;一直循环打印一个字符串mov ebx,message_1call far [fs:PrintString]jmp .do_prn;message_1定义:
message_1 db '[USER TASK]: ,,,,,,,,,,,,,,,,,,,,,,,',0x0d,0x0a,0
发生0x70中断切回内核任务:在第一个用户任务的执行期间,如果发生了0x70号中断,则又转到中断处理过程,并发起任务切换,这一次就会切回内核任务。
内核任务继续运行:
1)切回内核:切换会内核后,从 initiate_task_switch 例程的 jmp far [edi+0x14] 后一行开始执行。
initiate_task_switch:......jmp far [edi+0x14] ;任务切换;切换回来后从这里开始执行
.return:pop espop dspopadretf
这里执行完后就是返回上次中断发生的地方:
call load_relocate_program
sti
;上次假定是在这里发生了中断
;为说明任务切换而特意添加的无操作指令
nop ;No Operation
nop
nop
2)创建第2个用户任务:接着内核任务继续创建第2个用户任务,第2个用户程序和第1个一样,就是打印的字符串变成一串C。
message_1 db '[USER TASK]: CCCCCCCCCCCCCCCCCCCCCCC',0x0d,0x0a,0
此时系统中就有3个任务了。
3)无限循环:内核任务执行到最后就是无限循环,无论切换到哪个任务再切换回来时都会反复执行这段代码。
.do_switch:mov ebx,core_msg2call sys_routine_seg_sel:put_string;清理已经终止的任务,并回收它们占用的资源call sys_routine_seg_sel:do_task_cleanhltjmp .do_switch;core_msg2 定义
core_msg2 db '[CORE TASK]: I am working!',0x0d,0x0a,0
程序的编译和执行
开始执行本章程序:
- 第15章的mbr.bin写入虚拟硬盘的逻辑0扇区;
- 本章核心core.bin写入虚拟硬盘的逻辑1扇区;
- 第1个用户程序bin写入虚拟硬盘的逻辑50扇区;
- 第2个用户程序bin写入虚拟硬盘的逻辑100扇区;
执行效果:在内核任务、用户1任务、用户2任务之间来回切换,打印字符串。