中断是硬件和软件交互的一种机制,可以说整个操作系统,整个架构都是由中断来驱动的。中断的机制分为两种,中断和异常,中断通常为 设备触发的异步事件,而异常是 执行指令时发生的同步事件。本文主要来说明 外设触发的中断,总的来说一个中断的起末会经历设备,中断控制器,CPU 三个阶段:设备产生中断信号,中断控制器翻译信号,CPU 来实际处理信号。
本文用 的实例来讲解多处理器下的中断机制,从头至尾的来看一看,中断经历的三个过程。其中第一个阶段设备如何产生信号不讲,超过了操作系统的范围,也超过了我的能力范围。各种硬件外设有着自己的执行逻辑,有各种形式的中断触发机制,比如边沿触发,电平触发等等。总的来说就是向中断控制器发送一个中断信号,中断控制器再作翻译发送给 , 再执行中断服务程序对中断进行处理。
说到中断控制器,是个什么东西?中断控制器可以看作是中断的代理,外设是很多的,如果没有一个中断代理,外设想要给 发送中断信号来处理中断,那只能是外设连接在 的管脚上, 的管脚是很宝贵的,不可能拿出那么多管脚去连接外设。所以就有了中断控制器这个中断代言人,所有的 外设连接其上,发送中断请求时就向中断控制器发送信号,中断控制器再通知 CPU,如此便解决了上述问题。
中断控制器有很多,前文讲过 PIC
,PIC
只用于单处理器,对于如今的多核多处理器时代,PIC
无能为力,所以出现了更高级的中断控制器 APIC
,APIC
() 高级可编程中断控制器,APIC
分成两部分 LAPIC
和 IOAPIC
,前者 LAPIC
位于 内部,每个 都有一个 LAPIC
,后者 IOAPIC
与外设相连。外设发出的中断信号经过 IOAPIC
处理之后发送给 LAPIC
,再由 LAPIC
决定是否交由 进行实际的中断处理。
可以看出每个 上有一个 LAPIC
,IOAPIC
是系统芯片组一部分,各个中断消息通过总线发送接收。关于 APIC
的内容很多也很复杂,详细描述的可以参考 开发手册卷三,本文不探讨其中的细节,只在上层较为抽象的层面讲述,理清 APIC
模式下中断的过程。
计算机启动的时候要先对 APIC
进行初始化,后续才能正确使用,前面说过初始化就是设置一些寄存器,这部分我在再谈中断(APIC)有所讲解,本文关于寄存器这一块不会再详述,可以先看一看。下面来看看 APIC
在一种较为简单的工作模式下的初始化过程:
初始化 IOAPIC
就是设置 IOAPIC
的寄存器,IOAPIC
寄存器一览:
所以有了以下定义:
#define REG_ID 0x00 // Register index: ID
#define REG_VER 0x01 // Register index: version
#define REG_TABLE 0x10 // Redirection table base 重定向表
但是这些寄存器是不能直接访问的,需要通过另外两个映射到内存的寄存器来读写上述的寄存器。
这两个寄存器是内存映射的,IOREGSEL
,地址为 ;IOWIN
,地址为 。IOREGSEL
用来指定要读写的寄存器,然后从 IOWIN
中读写。也就是常说的 index/data
访问方式,或者说 ,用 index
端口指定寄存器,从 data
端口读写寄存器,data
端口就像是所有寄存器的窗口。
而所谓内存映射
,就是把这些寄存器看作内存的一部分,读写内存,就是读写寄存器,可以用访问内存的指令比如 mov
来访问寄存器。还有一种是 IO端口映射
,这种映射方式是将外设的 IO端口(外设的一些寄存器)
看成一个独立的地址空间,访问这片空间不能用访问内存的指令,而需要专门的 in/out
指令来访问。
通过 IOREGSEL
和 IOWIN
既可以访问到 IOAPIC
所有的寄存器,所以结构体 如下定义:
struct ioapic {
uint reg; //IOREGSEL
uint pad[3]; //填充12字节
uint data; //IOWIN
};
填充 字节是因为 IOREGSEL
在 ,长度为 4 字节,IOWIN
在 ,两者中间差了 2 字节,所以填充 字节补上空位方便操作。
通过 IOREGSEL
选定寄存器,然后从IOWIN
中读写相应寄存器,因此也能明白下面两个读写函数:
static uint ioapicread(int reg)
{
ioapic->reg = reg; //选定寄存器reg
return ioapic->data; //从窗口寄存器中读出寄存器reg数据
}
static void ioapicwrite(int reg, uint data)
{
ioapic->reg = reg; //选定寄存器reg
ioapic->data = data; //向窗口寄存器写就相当于向寄存器reg写
}
这两个函数就是根据 来读写 IOAPIC
的寄存器。下面来看看 IOAPIC
寄存器分别有些什么意义,了解了之后自然就知道为什么要这样那样的初始化了。下面只说 中涉及到的寄存器,其他的有兴趣见文末链接。
ID Register
索引为 0
:ID
Version Register
索引为 1
表示版本,
表示重定向表项最多有几个,这里就是 23(从 0 开始计数)
重定向表项
IOAPIC
有 24 个管脚,每个管脚都对应着一个 64 位的重定向表项(也相当于 64 位的寄存器),保存在 ,重定向表项的格式如下所示:
这是 大佬在他的 中总结出来的,很全面也很复杂,这里有所了解就好,配合着下面的初始化代码对部分字段作相应的解释。
#define IOAPIC 0xFEC00000 // Default physical address of IO APIC
void ioapicinit(void)
{
int i, id, maxintr;
ioapic = (volatile struct ioapic*)IOAPIC; //IOREGSEL的地址
maxintr = (ioapicread(REG_VER) >> 16) & 0xFF; //读取version寄存器16-23位,获取最大的中断数
id = ioapicread(REG_ID) >> 24; //读取ID寄存器24-27 获取IOAPIC ID
if(id != ioapicid)
cprintf("ioapicinit: id isn't equal to ioapicid; not a MP\n");
// Mark all interrupts edge-triggered, active high, disabled,
// and not routed to any CPUs. 将所有的中断重定向表项设置为边沿,高有效,屏蔽状态
for(i = 0; i <= maxintr; i++){
ioapicwrite(REG_TABLE+2*i, INT_DISABLED | (T_IRQ0 + i)); //设置低32位,每个表项64位,所以2*i,
ioapicwrite(REG_TABLE+2*i+1, 0); //设置高32位
}
}
宏定义 是个地址值,这个地址就是 IOREGSEL
寄存器在内存中映射的位置,通过 方式读取 ID
,支持的中断数等信息。
在 中有记录,关于 我们在 实例讲解多处理器下的计算机启动 一文中提到过,简单来说, 有各种表项,记录了多处理器下的一些配置信息,计算机启动的时候可以从中获取有用信息。多处理器下的计算机启动@@@@一文只说明了处理器类型的表项,有多少个处理器类型的表项表示有多少个处理器。IOAPIC 同样的道理,然后每个 类型的表项中有其 记录。关于 咱们就点到为止,有兴趣的可以去公众号后台获取 的资料文档,有详细的解释。
接着就是一个 循环,来初始化 24 个重定向表项。来看看设置了哪些内容:
IOAPIC
发送中断信号时,IOAPIC
直接屏蔽忽略。因此这里初始化将所有重定向表项设置为边沿触发,高电平有效,所有中断路由到 ,但又将所有中断屏蔽的状态。 的注释描述的是不会将中断路由到任何处理器,这里我认为是有误的,虽然屏蔽了所有中断,但是根据 字段来看应该是路由到 的,若我理解错还请批评指针。
另外为什么要加上一个 呢, 是个宏,值为 32,前 32 个中断向量号分配给了一些异常或者保留,后面的中断向量号 32~255 才是一些外部中断或者 INT n 指令可以使用的。
上述 IOAPIC 初始化的时候直接将管脚对应的中断全都给屏蔽了,那总得有开启的时候吧,不然无法工作也就无意义了,“开启”函数如下所示:
void ioapicenable(int irq, int cpunum)
{
// Mark interrupt edge-triggered, active high,
// enabled, and routed to the given cpunum,
// which happens to be that cpu's APIC ID. 调用此函数使能相应的中断
ioapicwrite(REG_TABLE+2*irq, T_IRQ0 + irq);
ioapicwrite(REG_TABLE+2*irq+1, cpunum << 24); //左移24位是填写 destination field字段
}
为中断向量号,填写到低 8 位 vector 字段,表示此重定向表项处理该中断
为 CPU 的编号, 文件中定义了关于 的全局数组,存放着所有 的信息。 里面,这个数组的索引是就是 也是 ,可以来唯一标识一个 。初始化的时候 为 0,调用此函数没有改变该位,所以还是 0,为物理模式,所以将 写入 字段表示将中断路由到该 。
来做个简单测试,在磁盘相关代码文件 中函数 调用了 :
ioapicenable(IRQ_IDE, ncpu - 1); //让这个CPU来处理硬盘中断
根据上述讲的,这说明使用最后一个 来处理磁盘中断,下面我们来验证,验证方式很简单,在中断处理程序当中打印 编号就行:
首先在 中将 数量设为多个处理器,我设置的是 4:
ifndef CPUS
CPUS := 4
endif
接着在 文件中添加 语句:
case T_IRQ0 + IRQ_IDE: //如果是磁盘中断
ideintr(); //调用磁盘中断程序
lapiceoi(); //处理完写EOI表中断完成
cprintf("ide %d\n", cpuid()); //打印CPU编号
break;
这个函数我们后面会讲到,这里提前看一看,有注释应该还是很好理解的,来看看结果:
的数量为 4,处理磁盘中断的 编号为 3,符合预期, 的初始化就说到这里,下面来看 的初始化。
LAPIC
要比 IOAPIC
复杂的多,放张总图:
不会涉及这么复杂,其主要功能是接收 IOAPIC
发来的中断消息然后交由 处理,再者就是自身也能作为中断源产生中断发送给自身或其他 。同样的初始化 LAPIC
就是设置相关寄存器,但是 LAPIC
的寄存器实在太多了,本文只是说明 xv6
涉及到的寄存器,其他的可以参考前文再谈中断(APIC),或者文末的链接。
LAPIC
的寄存器在内存中都有映射,起始地址一般默认为 ,但这个地址不是自己设置使用的,起始地址在 中可以获取,详见文末链接,所以可以如下定义和获取 地址
/*lapic.c*/
volatile uint *lapic; // Initialized in mp.c
/*mp.c*/
lapic = (uint*)conf->lapicaddr; //conf就是MP Table Header,其中记录着LAPIC地址信息
也可以看作是 型的数组,一个元素 4 字节,所以计算各个寄存器的索引的时候要在偏移量的基础上除以 4。举个例子,ID
寄存器相对 基地址偏移量为 ,那么 ID 寄存器在 数组里面的索引就该为 0x20/4。各个寄存器的偏移量见文末链接(说了太多次,希望不要觉得太啰嗦,因为内容实在太多,又想说明白那就只能这样放链接)
因为是 LAPIC
的寄存器是内存映射,所以设置寄存器就是直接读写相应内存,因此读写寄存器的函实现是很简单的:
static void lapicw(int index, int value) //向下标为index的寄存器写value
{
lapic[index] = value;
lapic[ID]; // wait for write to finish, by reading
}
这里看着是写内存,但是实际上这部分地址已经分配给了 LAPIC
,对硬件的写操作一般要停下等一会儿待写操作完成,可以去看看磁盘键盘等硬件初始配置的时候都有类似的等待操作,这里直接采用读数据的方式来等待写操作完成。
有了读写 LAPIC
寄存器的函数,接着就来看看 LAPIC
如何初始化的,初始化函数为 ,我们分开来看:
lapicw(SVR, ENABLE | (T_IRQ0 + IRQ_SPURIOUS));
#define SVR (0x00F0/4) // Spurious Interrupt Vector
#define ENABLE 0x00000100 // Unit Enable
SVR
伪中断寄存器, 每响应一次 (可屏蔽中断),就会连续执行两个 周期。在 中有描述,当一个中断在第一个 周期后,第二个 周期前变为无效,则为伪中断,也就是说伪中断就是中断引脚没有维持足够的有效电平而产生的。这主要涉及到电气方面的东西,我们了解就好。
中的字段还有其他作用, 置 1 表示使能 LAPIC
,LAPIC
需要在使能状态下工作。
lapicw(TDCR, X1); //设置分频系数
lapicw(TIMER, PERIODIC | (T_IRQ0 + IRQ_TIMER)); //设置Timer的模式和中断向量号
lapicw(TICR, 10000000); //设置周期性计数的数字
#define TICR (0x0380/4) // Timer Initial Count
#define TDCR (0x03E0/4) // Timer Divide Configuration
#define TIMER (0x0320/4) // Local Vector Table 0 (TIMER)
#define X1 0x0000000B // divide counts by 1
#define PERIODIC 0x00020000 // Periodic
LAPIC
自带可编程定时器,可以用这个定时器来作为时钟,触发时钟中断。这需要 、、以及 配合使用,其实还有一个 , 没有使用,这些寄存器的具体配置如上代码所示,解释如下:
这几个寄存器表示 本地中断,LAPIC
除了可以接收 IOAPIC
发来的中断之外,自己也可以产生中断,就是上述列出来的这几种。
从上图可以看出 寄存器 设置 , 设置为 即 模式,从名字就可以看出这是周期性模式,周期性的从某个数递减到 0,如此循环往复。
这个数设置在 寄存器, 设置的值是
递减得有个频率,这个频率是系统的总线频率再分频,分频系数设置在 寄存器, 设置的是 1 分频,也就相当于没有分频,就是使用的是总线频率。
另外 是时钟中断的向量号,设置在 寄存器的低 8 位。
关于时钟中断的设置就是这么多,每个 都有 ,所以每个 上都会发生时钟中断,不像其他中断,指定了一个 来处理。
回到 LAPIC
的初始化上面来:
// Disable logical interrupt lines.
lapicw(LINT0, MASKED);
lapicw(LINT1, MASKED);
连接到了 和 ,但实际上只连接到了 (最先启动的 ),只有 能接收这两种中断。一般对于 如果有 模式(兼容) 设置为 模式, 设置为 模式。如果是 直接设置屏蔽位将两种中断屏蔽掉。 简化了处理,只使用 APIC
模式,所有的 LAPIC
都将两种中断给屏蔽掉了。
if(((lapic[VER]>>16) & 0xFF) >= 4)
lapicw(PCINT, MASKED);
// Map error interrupt to IRQ_ERROR.
lapicw(ERROR, T_IRQ0 + IRQ_ERROR);
// Clear error status register (requires back-to-back writes).
lapicw(ESR, 0);
lapicw(ESR, 0);
#define VER (0x0030/4) // Version
#define ERROR (0x0370/4) // Local Vector Table 3 (ERROR)
#define PCINT (0x0340/4) // Performance Counter LVT
#define ESR (0x0280/4) // Error Status
Version Register
的 是 本地中断的表项个数,如果超过了 4 项则屏蔽性能计数溢出中断。为什么这么操作,这个中断有什么用不太清楚,这个在 intel 手册卷三有描述,看了之后还是懵懵懂懂,感觉平常不会接触,用到的少,就没深入的去啃了,所以也不能拿出来乱说,在此抱歉,有了解的大佬还请告知。
ERROR Register
,设置这个寄存器来映射 中断,当 检测到内部错误的时候就会触发这个中断,中断向量号是
记录错误状态,初始化就是将其清零,而且需要连续写两次。
lapicw(EOI, 0);
#define EOI (0x00B0/4) // EOI
EOI
(),中断处理完成之后要写 EOI
寄存器来显示表示中断处理已经完成。重置初始化后的值应为 0.
lapicw(ICRHI, 0);
lapicw(ICRLO, BCAST | INIT | LEVEL);
while(lapic[ICRLO] & DELIVS)
;
#define ICRHI (0x0310/4) // Interrupt Command [63:32]
#define TIMER (0x0320/4) // Local Vector Table 0 (TIMER)
//ICR寄存器的各字段取值意义
#define INIT 0x00000500 // INIT/RESET
#define STARTUP 0x00000600 // Startup IPI
#define DELIVS 0x00001000 // Delivery status
#define ASSERT 0x00004000 // Assert interrupt (vs deassert)
#define DEASSERT 0x00000000
#define LEVEL 0x00008000 // Level triggered
#define BCAST 0x00080000 // Send to all APICs, including self.
#define BUSY 0x00001000
#define FIXED 0x00000000
ICR
()中断指令寄存器,当一个 想把中断发送给另一个 时,就在 ICR 中填写相应的中断向量和目标 LAPIC
标识,然后通过总线向目标 LAPIC
发送消息。因为同样是向另一个 LAPIC
发送中断消息,所以ICR
寄存器的字段和 IOAPIC
重定向表项较为相似,都有 等等。
. 结合 手册,作用为将所有 的 APIC
的 设置为初始值 。
关于 Arb
,引用 中的解释:
Arb,Arbitration Register,仲裁寄存器。该寄存器用 4 个 bit 表示 0~15 共 16 个优先级(15 为最高优先级),用于确定 LAPIC 竞争 APIC BUS 的优先级。系统 RESET 后,各 LAPIC 的 Arb 被初始化为其 LAPIC ID。总线竞争时,Arb 值最大 的 LAPIC 赢得总线,同时将自身的 Arb 清零,并将其它 LAPIC 的 Arb 加一。由 此可见,Arb 仲裁是一个轮询机制。Level 触发的 INIT IPI 可以将各 LAPIC 的 Arb 同步回当前的 LAPIC ID。
// Enable interrupts on the APIC (but not on the processor).
lapicw(TPR, 0);
#define TPR (0x0080/4) // Task Priority
任务优先级寄存器,确定当前 CPU 能够处理什么优先级别的中断,CPU 只处理比 TPR 中级别更高的中断。比它低的中断暂时屏蔽掉,也就是在 IRR 中继续等到。
上述就是 里面对 LAPIC
的一种简单的初始化方式,其实也不简单,涉及了挺多东西。接下来应该是 CPU 来处理中断的部分,在这之前先来看看 里面涉及到的两个用的比较多的函数:
int lapicid(void) //返回 CPU/LAPIC ID
{
if (!lapic)
return 0;
return lapic[ID] >> 24;
}
这个函数用来返回 ,ID
寄存器 位后表示 因为 与 LAPIC
一一对应,所以这也相当于返回 ,同样也是 数组中的索引。而前面在 一节中出现的 函数相当于就是这个函数的封装。
void lapiceoi(void)
{
if(lapic)
lapicw(EOI, 0);
}
写 EOI
表中断完成,这个函数在中断服务程序中会经常用到用到,下面再来看看 LAPIC 中两个比较重要的寄存器:
IRR
中断请求寄存器,256 位,每位代表着一个中断。当某个中断消息发来时,如果该中断没有被屏蔽,则将 IRR 对应的 bit 置 1,表示收到了该中断请求但 CPU 还未处理。
ISR
服务中寄存器 ,256 位,每位代表着一个中断。当 IRR 中某个中断请求发送给 CPU 时,ISR 对应的 bit 上便置 1,表示 CPU 正在处理该中断。
上述就是 APIC
的初始化和一些重要函数的讲解,有了这些了解之后,来总体的看一看 APIC
部分的中断过程:
IOAPIC
IOAPIC
根据 表将中断信号翻译成中断消息,然后发送给 字段列出的LAPIC
根据消息中的 ,,自身的寄存器 ID
来判断自己是否接收该中断消息,设置 IRR
相应的 位,不是则忽略IRR
中挑选优先级最大的中断,相应位置 0,ISR
相应位置 1,然后送 执行。EOI
表示中断处理已经完成,写 EOI
导致 ISR
相应位置 0,对于 触发的中断,还会向所有的 IOAPIC
发送 EOI
消息,通知中断处理已经完成。上述的过程只是一个很简单的大致过程,没有涉及到不可屏蔽中断,一些特殊的中断,中断嵌套等等,只是来简单认识一下 APIC
在中断时是如何工作的,接下来重点看看 部分对中断的处理。
上述就是 的初始化部分,被 中的 调用,是计算机启动时环境初始化的一部分。下面来看 处理中断的部分。先来复习一下 部分大致是如何处理中断的:
EOI
表中断完成所以在中断正式处理之前就压入一些寄存器,栈中情况如下:
接下来便就是去 IDT
, GDT
中索引门描述符和段描述符,寻找中断服务程序,本文主要讲述中断,所以只来看看 IDT
,GDT
相关内容我在实模式是如何到保护模式的?有所讲述,可以参考参考。
IDT
,中断描述符表,我们得先有这么一个表, 才能使用中断控制器发送来的向量号去 中索引门描述符。
所以得构建一个 IDT
,构建 IDT
就是构建一个个中断描述符,一般称作门描述符,IDT
里面可以存放几种门描述符,如调用门描述符,陷阱门描述符,任务门描述符,中断门描述符。大多数中断都使用中断门描述符,来看看中断门描述符的格式:
其实上述也可以作为陷阱门描述符,两者几乎一模一样,只有 字段不一样,所以如下定义中断门/陷阱门描述符:
struct gatedesc {
uint off_15_0 : 16; // low 16 bits of offset in segment
uint cs : 16; // code segment selector
uint args : 5; // # args, 0 for interrupt/trap gates
uint rsv1 : 3; // reserved(should be zero I guess)
uint type : 4; // type(STS_{IG32,TG32})
uint s : 1; // must be 0 (system)
uint dpl : 2; // descriptor(meaning new) privilege level
uint p : 1; // Present
uint off_31_16 : 16; // high bits of offset in segment
};
从上面部分字段代表的意义可以看出,构建中断门描述符还需要中断服务程序的地址信息,所以咱们首先还得准备好各个中断服务程序,取得它们的地址信息。在 中,所有的中断都有相同的入口程序,而在中断门描述符中填写的就是这个入口程序的地址。
IDT
中支持 256 个表项,支持 256 个中断,所以要有 256 个入口程序,入口程序所做的工作是类似的,所以 使用了 脚本来批量产生代码。脚本文件是 ,生成的代码如下所示:
.globl alltraps
.globl vector0 #向量号为0的入口程序
vector0:
pushl $0
pushl $0
jmp alltraps
#############################
.globl vector8
vector8:
pushl $8
jmp alltraps
##############################
.globl vectors #入口程序数组
vectors:
.long vector0
.long vector1
.long vector2
这是一段汇编代码,所有的中断入口程序都做了相同的三件事或两件事:
第一项 压入 0 只有没有错误码产生的中断/异常才会执行,而错误码主要部分就是选择子,一般不使用。但这是 架构特性,有错误码的时候会自动压入,所以在 脚本中对有错误码的异常做了特殊处理:
if(!($i == 8 || ($i >= 10 && $i <= 14) || $i == 17)){
print " pushl \$0\n";
表示向量号为 号会产生错误码,不需要压入 0。
这 256 个中断入口程序地址写入一个大数组 ,所以中断门描述符要的地址信息不就来了,因此 IDT
的构建如下:
struct gatedesc idt[256];
extern uint vectors[]; // in vectors.S: array of 256 entry pointers
void tvinit(void) //根据外部的vectors数组构建中断门描述符
{
int i;
for(i = 0; i < 256; i++)
SETGATE(idt[i], 0, SEG_KCODE<<3, vectors[i], 0);
SETGATE(idt[T_SYSCALL], 1, SEG_KCODE<<3, vectors[T_SYSCALL], DPL_USER);
initlock(&tickslock, "time");
}
#define SETGATE(gate, istrap, sel, off, d) \ //门描述符,是否是陷阱,选择子,偏移量,DPL
{ \
(gate).off_15_0 = (uint)(off) & 0xffff; \
(gate).cs = (sel); \
(gate).args = 0; \
(gate).rsv1 = 0; \
(gate).type = (istrap) ? STS_TG32 : STS_IG32; \
(gate).s = 0; \
(gate).dpl = (d); \
(gate).p = 1; \
(gate).off_31_16 = (uint)(off) >> 16; \
}
宏就是根据信息构建一个中断描述符,应该很容易看懂。
中断服务程序属于内核程序,段选择子为内核代码段, 设置为 0,但是系统调用需要特殊处理, 字段必须设置为 3。为什么这么设置,原由与特权级检查有关:当前代码段寄存器的 为 ,也就是 。是不是很绕,没办法,事实就是这样。
作何特权级检查呢? 需要大于等于门描述符中选择子的 ,而对于系统调用 还需要小于等于门描述符的 ,不然就会触发一般保护性错异常。系统调用特权级肯定是要转移的,也就是从用户态到内核态,用户态下 ,门描述符 如果还为 0 的话,那特权级检查不能通过,是要触发异常的,所以对于系统调用 得设置为 3。
这说的有点远了,特权级检查是个很复杂的东西,上面还没有加入 的检查呢。这里只是稍作了解就好,后面有机会写一篇捋一捋特权级检查,下面回到 IDT
本身上来,IDT 构建好了之后需要将其地址加载到 IDTR
寄存器,如此 才晓得去哪儿找 IDT
。
void idtinit(void)
{
lidt(idt, sizeof(idt)); //加载IDT地址到IDTR
}
static inline void lidt(struct gatedesc *p, int size) //构造idtr需要的48位数据,然后重新加载到idtr寄存器
{
volatile ushort pd[3];
pd[0] = size-1;
pd[1] = (uint)p;
pd[2] = (uint)p >> 16;
asm volatile("lidt (%0)" : : "r" (pd));
}
IDTR 寄存器有 48 位
IDT
的界限,也就是这个表有好大,表示的最大范围为 ,也就是 ,一个门描述符 8 字节,所以描述符最多 ,但是处理器只支持 256 个中断,也就是 256 个门描述符。IDT
基地址上述代码中数组 就是这 48 位数据,先构造这个数据,然后使用内联汇编,指令 将其加载到 IDTR
寄存器,关于内联汇编不多说,可以参考我前面的文章:内联汇编
准备好之后,这一小节就正式来看中断服务程序的流程,我将其分为三个阶段:中断入口,中断处理,中断退出,咱们一个个来看:
中断入口程序主要是保存中断上下文, 数组中记录的入口程序只能算是一部分,这一部分做了三件事:压入 0/错误码,压入向量号,跳到 。
所以现阶段栈中情况如下:
紧接着程序跳到了 ,来看看这是个什么玩意儿:
.globl alltraps
alltraps:
# Build trap frame. 构建中断栈帧
pushl %ds
pushl %es
pushl %fs
pushl %gs
pushal
# Set up data segments. 设置数据段为内核数据段
movw $(SEG_KDATA<<3), %ax
movw %ax, %ds
movw %ax, %es
# Call trap(tf), where tf=%esp 调用trap.c()
pushl %esp
call trap
addl $4, %esp
可以看出 也主要干了三件事:
1、建立栈帧,保存上下文
建立栈帧保存上下文就是将各类寄存器资源压栈保存在栈中, 直接暴力地将所有的寄存器直接压进去。先是压入各段寄存器,再 压入所有的通用寄存器,顺序为 。
所以现下栈中的情况为:
所以如此定义栈帧:
struct trapframe {
// registers as pushed by pusha
uint edi;
uint esi;
uint ebp;
uint oesp; // useless & ignored esp值无用忽略
uint ebx;
uint edx;
uint ecx;
uint eax;
// rest of trap frame
ushort gs;
ushort padding1;
ushort fs;
ushort padding2;
ushort es;
ushort padding3;
ushort ds;
ushort padding4;
uint trapno; //向量号
// below here defined by x86 hardware
uint err;
uint eip;
ushort cs;
ushort padding5;
uint eflags;
// below here only when crossing rings, such as from user to kernel
uint esp;
ushort ss;
ushort padding6;
};
可以看出定义的中断栈帧结构体与前面的操作是一一对应的,说明两点:
2、设置数据段寄存器为内核数据段
在根据向量号索引门描述符的时候已经进行了特权级检查,将门描述符中的段选择子——内核代码段选择子加载到了 CS,这里就只需要设置数据段寄存器为内核数据段。附加段,附加的数据段,通常与数据段进行一样的设置,在串操作指令中,将附加段作为目的操作数的存放区域,详见前文内联汇编
3、调用中断处理程序
之后 ,标准的函数调用方式,先 参数,再 调用函数。,此时的 esp
是中断栈帧的栈顶元素的地址,也就是说传递的参数是中断栈帧的首地址。随后 调用中断处理程序,压入返回地址( 指令后面那条指令的地址,也就是 语句的地址),之后跳转到 执行程序。
此时栈中情况:
上述操作已经将中断处理程序 需要的参数中断栈帧 的地址压入栈中。其实 也像是中断服务程序的入口,整个程序就是由许多条件语句组成,根据 的向量号去执行不同分支中的中断处理程序,来随便看几个:
if(tf->trapno == T_SYSCALL){ //系统调用
if(myproc()->killed) //如果当前进程已经被杀死
exit(); //退出
myproc()->tf = tf; //当前进程的栈帧
syscall(); //系统调用入口
if(myproc()->killed) //再次确认进程状态
exit();
return; //返回
}
如果向量号表示这是一个系统调用,则进行系统调用,这部分放在后面文章讲解。
switch(tf->trapno){
case T_IRQ0 + IRQ_TIMER: //时钟中断
if(cpuid() == 0){
acquire(&tickslock);
ticks++;
wakeup(&ticks);
release(&tickslock);
}
lapiceoi();
break;
case T_IRQ0 + IRQ_IDE: //磁盘中断
ideintr();
lapiceoi();
break;
/*****************************/
如果是时钟中断,并且是 发出的时钟中断,就将滴答数 加 1。每个 都有自己的 LAPIC
,也就都有自己的 APIC Timer
,都能够触发时钟中断。 记录系统从开始到现在的滴答数,作为系统的时间,发生一次时钟中断其数值就加 1,但是能修改 的应该只能有一个 CPU,不然如果所有的 都能修改 的值的话,那岂不是乱套了?所以这里就选择 也是 来修改 的值。处理完之后写 EOI
表时钟中断完成。
如果是磁盘发出的中断,就调用磁盘中断处理程序,也是磁盘驱动程序的主体,详见前文带你了解磁盘驱动程序。处理完之后就写 EOI
表中断完成。
其他的中断都是这样处理,就不一一举例说明了,其中有一些中断还没有讲到,但所有中断的处理都是如此,根据向量号调用不同的中断处理程序,处理完之后写 EOI
表中断完成。
执行完 函数之后,回到汇编程序 :
# Call trap(tf), where tf=%esp
pushl %esp
call trap
addl $4, %esp
# Return falls through to trapret...
.globl trapret #中断返回退出
trapret:
popal
popl %gs
popl %fs
popl %es
popl %ds
addl $0x8, %esp # trapno and errcode
iret
中断退出程序基本上就是中断入口程序的逆操作。
首先从 返回之后清理参数占用的栈空间,将 ESP
上移 4 字节。一般系统的源码就是汇编和 C 程序,所以使用 调用约定,该约定规定了参数从右往左入栈,EAX,ECX,EDX
由调用者保存,也是调用者来清理栈空间等等。而清理栈空间呢?其实就是为了栈里面的数据正确,显然要当前栈顶指针需要向上移动 4 字节,后面的操作 才正确。
清理了栈空间之后弹出各个寄存器,到错误码向量号的时候直接将 ESP
上移 8 字节跳过。
栈中变化情况如下:
这里说明两点:
现在 指向的是 ,该执行 了, 时先检查是否进行了特权级转移,如果没有特权级转移,那么就要弹出 EIP,CS 和 EFALGS
,如果有特权级转移则还要弹出 ESP,SS
。
原任务的所有状态都恢复了原样,则中断结束,继续原先的任务。
中断的总体过程大致就是这样,不只是 如此,所有基于 架构的系统都有类似的过程,只不过复杂的操作对中断的处理有着更微妙的操作,但总体上看大致过程就是如此。
下面来看一看过程图:
这主要是定位中断服务程序的图,至于实际处理中断的过程图就不画了,把握上面的栈的变化就行了,而栈的变化情况上述的图应该描述的很清楚了,所以这里就不再赘述,说起栈,关于栈上述我们还遗留了一些问题,在这儿解答:
最后再来聊一聊栈的问题,栈一直是一个很困惑的问题,我一直认为,操作系统能把栈捋清楚那基本就没什么问题了。在进入中断的时候,如果特权级发生变化,会先将 SS,ESP
先压入内核栈,再压入 CS,EIP,EFLAGS
。
这句话看着没什么问题,但有没有想过这个问题:怎么找到内核栈的?切换到内核栈之后,ESP 已经指向内核栈,但是我们压入的 ESP 应该是切换栈之前的旧栈栈顶值,所以怎么得到旧栈的值再压入?再者 时如果按栈中的寄存器顺序只是简单的先 ,再 那岂不是又乱套了?
首先怎么切换到内核栈的这个问题,硬件架构提供了一套方法。有个寄存器叫做 TR
寄存器,TR
寄存器存放着 TSS
段选择子,根据 TSS
段选择子去 GDT
中索引 TSS
段描述符,从中获取 TSS
。
那说了半天 TSS
是啥?TSS
(),任务状态段,它是硬件支持的系统数据结构,各级(包括内核)栈的 SS
和 ESP
。所以当特权级变化的时候就会从这里获取内核栈的 SS
和 ESP
。这个 TSS
这里我们只是简介,TSS
什么样子的,怎么初始化,还有些什么用处,它的功能都用到了?这些三言两语说不完,也不是本文重点,后面进程的时候会再次讲述。
接着第二个问题,切换到新栈怎么压入旧栈信息,其实这个问题很简单,我先把旧栈信息保存到一个地方,换栈之后再压入不就行了。关于 时弹出栈中信息是一个道理,查看 手册第二卷可以找到答案,的确也是这样处理的,手册中的伪码明显表示了有 来作为中转站。但这个 具体是个啥就不知道了,手册中也没明确说明,可能是另外的寄存器?这个不得而知,也不是重点没必要研究那么深入。
本文中断关于栈还有一个地方值得聊聊,嗯其实也没多大聊的,就是解释一句。建立栈帧的时候 的问题,这个是用来压入和弹出那 8 个通用寄存器的,还记得中断栈帧结构体中关于 ESP
的注释吗?写的是 ,意思是无用忽略,这是为啥?
这得从 说起, 中压入 ESP
的时候压入的是 执行到 的值吗?非也,压入的是 执行 前的栈顶值,在执行 之前先将 ESP
的值保存到 ,当压入 ESP
的时候执行的时 。
所以 执行到弹出 的时候,就不能将其中的值弹入 ESP
,而是直接将 ESP
的值加 4 跳过 。因为将 弹入 ESP
的话等于换了一个栈了,本来只该跳 4 字节的,结果跳过了很多字节,那明显就不对了嘛。
可以来张图看看,红线叉叉表示出错:
关于 的伪码如下:
中断这一块关于栈方面的问题就是这么多吧,发生中断时有特权级变化就换栈,内核栈地址去 TSS
中找,中断完成后将所有的寄存器信息复原,其中就有 刚进入中断时压入的 SS ESP
(有特权级变化的时候),栈也就恢复到了用户态下的栈。当然如果发生中断时就在内核态,那栈就不用变换,当然这只是 的处理方式,其他系统可能不同,但总的来说中断的处理过程就是这么一个过程。
当然这只是一个普通外设触发的中断,一些特殊中断,中断嵌套开关中断的内容都没有讲述,中断是个很大的概念,内容也很庞杂,本文利用 将一个普通外设触发的中断的处理机制说明的应该还是很清楚的,好啦本文就到这里,有什么错误还请批评指正,也欢迎大家来同我交流讨论学习进步。
相关链接:
https://wiki.osdev.org/APIC#Local_APIC_configuration
https://wiki.osdev.org/IOAPIC
http://blog.chinaunix.net/uid-20499746-id-1663122.html
还有一些手册资料可在我的公众号后台获取