文章目录
前言
先贴一下上一章博客链接:《操作系统真象还原》第六章——完善内核-优快云博客,上一章还有一个函数没有实现,本章开头先实现它。
本章博客参考链接:《操作系统真象还原》第七章 ---- 终进入中断处理拳打脚踢 操作系统日渐成熟 目前所有代码总览_真象还原第7章-优快云博客
第七章主题就是中断,算是补充学习一下之前汇编没有学到的部分。内容比较多,我打算分成两部分完成,每天学习一部分,写一部分的博客。
中断相关知识
什么是中断?
由于 CPU 获知了计算机中发生的某些事,CPU 暂停正在执行的程序,转而去执行处理该事件的程序,当这段程序执行完毕后,CPU 继续执行刚才的程序。整个过程称为中断处理,也称为中断。
操作系统是由中断驱动的。
中断分类
外部中断
可分为可屏蔽中断和不可屏蔽中断
外部中断是来自CPU外部的中断,外部硬件的中断通过两根信号线通知CPU,对于单核CPU,在 CPU 上运行的程序都是串行的。只要从 INTR 引脚收到的中断都是不影响系统运行的,可以随时处理,甚至 CPU 可以不处理,因为它不影响 CPU 运行。而只要从 NMI 引脚收到的中断,称为不可屏蔽中断,CPU 都没有运行下去的必要了。
CPU是通过中断向量表或中断描述符表(中断向量表是实模式下的中断处理程序数组,在保护模式下已经被中断描述符表代替)处理中断。
不可屏蔽中断(NMI引脚)的中断向量号是2。
内部中断
可分为软中断和异常
可以发起中断的指令:
- int 八位立即数
- int3,中断向量号3
- into,中断向量号4
- bound,中断向量号5
- ud2,中断向量号6
中断描述符表
简称IDT,类似于GDT,是在保护模式下用于存储中断处理程序入口的表。其中的描述符称为“门”。
门是一段程序的入口。段描述符描述一段内存区域,门描述一段代码。大小是8字节。贴一下书上的图片。
本章我们主要研究中断门,用它实现系统调用。
IDTR寄存器中,0-15位是表界限,16-47位是idt基地址。可容纳8192个表项,但是处理器只支持256个中断。0号中断可用,表示除0错误。
加载idtr用指令lidt,用法:lidt 48位内存数据
,低16位是表界限,高32位是线性基地址。
中断处理过程
分为CPU外和CPU内两部分。CPU 外:外部设备的中断由中断代理芯片接收,处理后将该中断的中断向量号发送到 CPU。CPU 内:CPU 执行该中断向量号对应的中断处理程序。我们先研究内部部分。
-
处理器根据中断向量号定位中断门描述符。
定位方法是中断向量号×8+中断描述符表地址。
-
处理器进行特权级检查。
进门要求目前CPU的CPL特权级高于门的DPL(数值上CPL<=DPL),出门要求CPL低于目标代码段的DPL。除非通过返回指令,否则特权级只能从低向高转移。
-
执行中断处理程序。
特权级检查通过后,从对应的IDT中找到中断处理程序的选择子,通过选择子在GDT里面找到中断处理程序在内存中的位置,CS:IP跳转到对应位置执行中断处理程序。
如果中断对应的门描述符是中断门,标志寄存器eflags中NT、TF、IF位自动置0。
IF位:IF为0禁止CPU处理那些不重要的中断,但是CPU仍然会处理异常、不可屏蔽中断等。
TF位:TF为0时禁止单步执行
NT位:NT为0时,iret执行中断返回,NT为1执行返回旧任务继续执行。
中断发生时的压栈
这部分的核心问题是段寄存器的加载。我们知道通过中断向量号,我们最终会通过一个新的段选择子,加载一段新内存的中断处理程序。既然涉及到了新内存段,就要远转移,就要把当前的cs和eip压入栈中,后续恢复旧程序还要使用。同时,eflag也必须压入栈中。如果设计到了特权级的改变,栈寄存器ss和esp也要入栈,如果有错误码,也要入栈。
完整过程如下:
- 处理器根据中断向量号找到对应的中断描述符后,拿 CPL 和中断门描述符中选择子对应的目标代码段的 DPL 比对,若 CPL 权限比 DPL 低,即数值上 CPL > DPL,这表示要向高特权级转移,需要切换到高特权级的栈。这也意味着当执行完中断处理程序后,若要正确返回到当前被中断的进程,同样需要将栈恢复为此时的旧栈。于是处理器先临时保存当前旧栈 SS 和 ESP 的值,记作 SS_old 和 ESP_old,然后在TSS 中找到同目标代码段 DPL 级别相同的栈加载到寄存器 SS 和 ESP 中,记作 SS_new 和 ESP_new,再将之前临时保存的 SS_old 和 ESP_old 压入新栈备份,以备返回时重新加载到栈段寄存器 SS 和栈指针 ESP。由于 SS_old 是 16 位数据,32 位模式下的栈操作数是 32 位,所以将 SS_old 用 0 扩展其高 16 位,成为 32位数据后入栈。此时新栈内容如图 7-8 中 A 所示。
- 在新栈中压入 EFLAGS 寄存器,新栈内容如图 7-8 中 B 所示。
- 由于要切换到目标代码段,对于这种段间转移,要将 CS 和 EIP 保存到当前栈中备份,记作 CS_old和 EIP_old,以便中断程序执行结束后能恢复到被中断的进程。同样 CS_old 是 16 位数据,需要用 0 填充其高 16 位,扩展为 32 位数据后入栈。此时新栈内容如图 7-8 中 C 所示。当前栈是新栈,还是旧栈,取决于第 1 步中是否涉及到特权级转移。
- 某些异常会有错误码,此错误码用于报告异常是在哪个段上发生的,也就是异常发生的位置,所以错误码中包含选择子等信息,一会介绍。错误码会紧跟在 EIP 之后入栈,记作 ERROR_CODE。此时新栈内容如图 7-8 中 D 所示。如果在第 1 步中判断未涉及到特权级转移,便不会到 TSS 中寻找新栈,而是继续使用当前旧栈,因此也谈不上恢复旧栈,此时中断发生时栈中数据不包括 SS_old 和 ESP_old。比如中断发生时当前正在运行的是内核程序,这是 0 特权级到 0 特权级,无特权级变化,如图 7-9 所示。
图片:
中断返回
从中断返回的指令是 iret,它从栈中弹出数据到寄存器 cs、eip、eflags 等,根据特权级是否改变,判断是否要恢复旧栈,也就是说是否将栈中位于 SS_old 和 ESP_old 位置的值弹出到寄存 ss 和 esp。当中断处理程序执行完成返回后,通过 iret 指令从栈中恢复 eflags 的内容。
弹栈顺序是固定的:eip,cs,eflags,如果特权级发生过变化,就还有esp和ss。
iret即 interrupt ret,是iretw(16位模式)或iretd(32位模式)的简写,它被编译成iretw还是iretd取决于伪指令bits指定的字长。
iretw 隐含操作数是 16 位,所以只用在 16 位模式下。它依次从栈中分别弹出 2 字节到寄存器 IP、CS和 eflags 中,在不涉及到特权级改变的情况下,栈指针 sp 自减 6。
iretd 隐含操作数是 32 位,所以只用在 32 位模式下。它先从栈中弹出 32 位数据到寄存器 EIP,再弹出 32 位数据,先丢弃高 16 位,只将低 16 位加载到 CS,再弹出 32 位数据到 eflags 中。在不涉及到特权级改变的情况下,栈指针 esp 自减 12。
iret无法处理中断错误码,需要我们手动处理。
返回时一样会进行特权级检查,判断特权级是否转变过。
中断错误码
所谓的中断错误码,就是另一种描述符选择子,用来指定中断发生在哪个段上。格式如图。
按位简单介绍:
- EXT 表示 EXTernal event,即外部事件,用来指明中断源是否来自处理器外部,如果中断源是不可屏蔽中断 NMI 或外部设备,EXT 为 1,否则为 0。
- IDT 表示选择子是否指向中断描述符表 IDT,IDT 位为 1,则表示此选择子指向中断描述符表,否则指向全局描述符表 GDT 或局部描述符表 LDT。
- TI 和选择子中 TI 是一个意思,为 0 时用来指明选择子是从 GDT 中检索描述符,为 1 时是从 LDT 中检索描述符。当然,只有在 IDT 位为 0 时 TI 位才有意义。
- 选择子高 13 位索引就是选择子中用来在表中索引描述符用的下标。
- 高 16 位是保留位,全 0。
通常能够压入错误码的中断属于中断向量号在 0~32 之内的异常,而外部中断(中断向量号在 32~255 之间)和 int 软中断并不会产生错误码。通常我们并不用处理错误码。
可编程中断控制器 8259A
简单介绍一下8259a:它是cpu和外部硬件中断信号之间的代理,外部中断信号经过它处理后,最紧要的中断信号会发送给cpu。设置这个芯片的目的是提高cpu的效率。
一片8259a只能管理8个中断,所以要管理多个中断,需要把多个8259a连接在一起,称为级联。最多级联9片,一片称为主片,剩下8片称为从片,主片和cpu通讯。放一张图片帮助理解。
8259a中,有很多8位寄存器,我们需要编程一些寄存器,把8259a设置成我们需要的样子。具体来说,我们要编辑4个ICW寄存器,3个OCW寄存器。ICW寄存器负责初始化8259a,用来确定是否需要级联,设置起始中断向量号,设置中断结束模式。OCW 来操作控制 8259A,中断屏蔽和中断结束就是通过往 8259A 端口发送 OCW 实现的。
下面是一些我个人的想法:我学《真象还原》,核心目的是写出一个操作系统,学习操作系统相关知识。操作系统毕竟是一个软件,所以对于过于硬件过于底层,同时又和操作系统关系不太大的部分,我选择跳过。所以8259A编程,以及后面的时钟编程,我选择浅尝辄止,相关代码也会copy《真象还原》书上的代码。
编写中断处理程序
最简单的中断处理程序
先编程初始化部分。初始化部分init_all()要先初始化中断相关部分idt_init(),这部分又可以分成两部分初始化8259a pic_init()和初始化中断描述符表ide_desc_init()。
我们先用汇编语言完成初始化系列函数中最核心的ide_desc_init(),之后把它升级成c语言版本。
备忘:
汇编extern声明某符号定义在外部,我用别人定义的。
汇编global声明某符号允许被外部使用,别人用我定义的。
nop是空操作指令,占用一个单位时间,什么都不做。
中断处理程序的实现kernel.S
;这个文件是中断描述符表中,中断处理程序的汇编语言实现
;配合C语言版本的代码,实现初始化中断描述符表
[bits 32]
;------------------------ 宏定义 --------------------------------
%define ERROR_CODE nop
%define ZERO push 0 ;如果某个中断有错误码,调用ERROR_CODE,否则我们手动压入一个假想的错误码0,目的是统一使用宏模板,同时保证不同中断的栈中情况一致
extern put_str ;声明这个符号定义在外部
section .data
intr_str db "interrupt occur!",0xa,0
global intr_entry_table ;允许外部调用
intr_entry_table:
%macro VECTOR 2 ;多行宏定义,有两个参数
section .text
intr%1entry:
%2 ;压入0或者本身的错误码
push intr_str
call put_str
add esp,4 ;跳过字符串这个参数
;如果是从片上进入的中断,除了往从片上发送EOI外,还要往主片上发送EOI
mov al,0x20
out 0xa0,al
out 0x20,al
add esp,4 ;跳过中断错误码
iret
section .data
dd intr%1entry
%endmacro
;33个中断处理程序,目前先占位
VECTRO 0x00,ZERO
VECTRO 0x01,ZERO
VECTRO 0x02,ZERO
VECTRO 0x03,ZERO
VECTRO 0x04,ZERO
VECTRO 0x05,ZERO
VECTRO 0x06,ZERO
VECTRO 0x07,ZERO
VECTRO 0x08,ZERO
VECTRO 0x09,ZERO
VECTRO 0x0a,ZERO
VECTRO 0x0b,ZERO
VECTRO 0x0c,ZERO
VECTRO 0x0d,ZERO
VECTRO 0x0e,ZERO
VECTRO 0x0f,ZERO
VECTRO 0x10,ZERO
VECTRO 0x11,ZERO
VECTRO 0x12,ZERO
VECTRO 0x13,ZERO
VECTRO 0x14,ZERO
VECTRO 0x15,ZERO
VECTRO 0x16,ZERO
VECTRO 0x17,ZERO
VECTRO 0x18,ZERO
VECTRO 0x19,ZERO
VECTRO 0x1a,ZERO
VECTRO 0x1b,ZERO
VECTRO 0x1c,ZERO
VECTRO 0x1d,ZERO
VECTRO 0x1e,ERROR_CODE
VECTRO 0x1f,ZERO
VECTRO 0x20,ZERO
C语言头文件global.h和interrupt.h
先回忆一下门。门是64位8字节的结构,类似段描述符,我们需要在一个头文件中写好它
global.h
#ifndef __KERNEL_GLOBAL_H
#define __KERNEL_GLOBAL_H
//————————段选择子属性————————
//段特权级
#define RPL0 0
#define RPL1 1
#define RPL2 2
#define RPL3 3
#define TI_GDT 0
#define TI_LDT 1
#define SELECTOR_K_CODE ((1 << 3) + (TI_GDT << 2) + RPL0)
#define SELECTOR_K_DATA ((2 << 3) + (TI_GDT << 2) + RPL0)
#define SELECTOR_K_STACK SELECTOR_K_DATA
#define SELECTOR_K_GS ((3 << 3) + (TI_GDT << 2) + RPL0)
//————————IDT描述符属性————————
#define IDT_DESC_P 1
#define IDT_DESC_DPL0 0
#define IDT_DESC_DPL1 1
#define IDT_DESC_DPL2 2
#define IDT_DESC_DPL3 3
#define IDT_DESC_32_TYPE 0xE // 32位的门
#define IDT_DESC_16_TYPE 0x6 // 16位的门,不会用到,定义它只为和 32 位门区分
#define IDT_DESC_ATTR_DPL0 ((IDT_DESC_P<<7)+(IDT_DESC_DPL0<<5)+IDT_DESC_32_TYPE)
#define IDT_DESC_ATTR_DPL3 ((IDT_DESC_P<<7)+(IDT_DESC_DPL3<<5)+IDT_DESC_32_TYPE)
#endif
interrupt.h
#ifndef __KERNEL_INTERRUPT_H
#define __KERNEL_INTERRUPT_H
#include "stdint.h"
typedef void* intr_handler;
void idt_init(void);
#endif
创建中断描述符表 IDT,安装中断处理程序
我们创建c语言文件interrupt.c,配合kernel.S,实现中断描述符表的建立
代码
#include "interrupt.h"
#include "global.h"
#include "stdint.h"
#define IDT_DESC_CNT 0x21 //目前支持的中断数33个
//门描述符,从低到高排序
struct gate_desc{
uint16_t func_offset_low_word //中断处理程序低16位偏移量
uint16_t selector //中断处理程序段描述符选择子
uint8_t dcount //8位固定值
uint8_t attribute //type s dpl p
uint16_t func_offset_high_word //中断处理程序高16位偏移量
}
static struct gate_desc idt[IDT_DESC_CNT];//中断描述符表,结构体数组
static void make_idt_desc(struct gate_desc* p_gdesc,uint8_t attr,intr_handler function);
extern intr_handler intr_entry_table[IDT_DESC_CNT];//引用定义在kernel.S中的处理程序入口数组
//创建中断门描述符
static void make_idt_desc(struct gate_desc* p_gdesc,uint8_t attr,intr_handler function){
p_gdesc->func_offset_low_word=(uint32_t)function&0x0000FFFF;//低16位地址
p_gdesc->selector=SELECTOR_K_CODE;
p_gdesc->dcount=0;
p_gdesc->attribute=attr;
p_gdesc->func_offset_high_word=((uint32_t)function&0xFFFF0000)>>16;
}
//初始化中断描述符表
static void idt_desc_init(void){
int i;
for (i=0;i<IDT_DESC_CNT;i++){
make_idt_desc(&idt[i],IDT_DESC_ATTR_DPL0,intr_entry_table[i]);
}
put_str("idt_desc_init done\n");
}
//完成有关中断的所有初始化工作
void idt_init(){
put_str("idt_init start\n");
idt_desc_init();//初始化中断描述符表
pic_init();//初始化8259A
//加载IDT
uint64_t idt_operand=((sizeof(idt)-1)|((uint64_t)((uint32_t)idt<<16)));
asm volatile("lidt %0"::"m"(idt_operand));
put_str("idt_init done\n");
}
用内联汇编实现端口 I/O 函数
/******************机器模式 *******************
b -- 输出寄存器 QImode 名称,即寄存器中的最低8位:[a-d]x
w -- 输出寄存器 HImode 名称,即寄存器中2个字节的部分,如[a-d]x
HImode
"Half-Integer"模式,表示一个两字节的整数
QImode
"Quarter-Integer"模式,表示一个一字节的整数
******************************************************/
#ifndef __LIB_IO_H
#define __LIB_IO_H
#include "stdint.h"
/*向端口 port 写入一个字节*/
static inline void outb(uint16_t port, uint8_t data) {
/*********************************************************
对端口指定 N 表示 0~255, d 表示用 dx 存储端口号,
%b0 表示对应 al,%w1 表示对应 dx */
asm volatile ( "outb %b0, %w1" : : "a" (data), "Nd" (port));
/******************************************************/
}
/* 将 addr 处起始的 word_cnt 个字写入端口 port */
static inline void outsw(uint16_t port, const void* addr, uint32_t word_cnt) {
/*********************************************************
+表示此限制即做输入,又做输出.
outsw 是把 ds:esi 处的 16 位的内容写入 port 端口,我们在设置段描述符时,
已经将 ds,es,ss 段的选择子都设置为相同的值了,此时不用担心数据错乱。*/
asm volatile ("cld; rep outsw" : "+S" (addr), "+c" (word_cnt) : "d" (port));
/******************************************************/
}
/* 将从端口 port 读入的一个字节返回 */
static inline uint8_t inb(uint16_t port) {
uint8_t data;
asm volatile ("inb %w1, %b0" : "=a" (data) : "Nd" (port));
return data;
}
/* 将从端口 port 读入的 word_cnt 个字写入 addr */
static inline void insw(uint16_t port, void* addr, uint32_t word_cnt) {
/******************************************************
insw 是将从端口 port 处读入的 16 位内容写入 es:edi 指向的内存,
我们在设置段描述符时,已经将 ds,es,ss 段的选择子都设置为相同的值了,
此时不用担心数据错乱。 */
asm volatile ("cld; rep insw" : "+D" (addr), "+c" (word_cnt)
: "d" (port) : "memory");
/******************************************************/
}
#endif
结语
昨天因为实验室配置环境,没有学太多,算是给自己放了一个假。今天看了下第六章代码,打算放到明天补完。
第七章中断,算是处理了70%吧,还有一些代码和知识点,放到第二部分处理吧。
明天处理第6章尾巴,把之前欠下的知识点补完,再把第七章好好写完。
今天就先这样~