首发公众号:Rand_cs
中断代码部分
本文来说码,看看中断到底是个啥样,同前面启动先来看看总图:
公众号后台回复 $interrupt$ 可获取原图,另外我说明一下我画的流程图啊,的确是不标准的,有很多环了,我有试过只画一根线比如说 $iret$ 出去一根线后,按理说不会回到 $iret$ 而是直接指向原任务那个块。但是因为整个流程图的元素太多,这样画很难看很难看,所以我没采用。虽然如上图那么画不是那么准确,但是意思表达应该还是很明确的,而且相对来说好看些。诸位有什么好的建议还请指出,谢谢。不多说废话了,来看 $xv6$ 的中断机制
环境准备(APIC部分)
中断机制要正确运行,得有个正确的环境,这一部分就来说说中断需要哪些,这也是启动的一部分。
IOAPIC 初始化
读写寄存器
硬件初始化就是读写它的寄存器,$IOAPIC$ 也是如此,$IOAPIC$ 的寄存器访问是通过内存映射的两个寄存器:
- $IOREGSEL$,32 位,位于 $0xfec00000$,用来指定一个寄存器
- $IOWIN$,32 位,位于 $0xfec00010$,窗口寄存器,从这个寄存器读写 $IOREGSEL$ 中指定的寄存器
两者之间差了 12 个字节,所以如下定义 $ioapic$ 结构体:
struct ioapic {
uint reg; //IOREGSEL
uint pad[3]; //填充12字节
uint data; //IOWIN
};
因为是内存映射,所以读写寄存器就变得很简单:
static uint ioapicread(int reg) //读取reg寄存器,reg是个索引值
{
ioapic->reg = reg; //选定寄存器reg
return ioapic->data; //从窗口寄存器中读出寄存器reg数据
}
static void ioapicwrite(int reg, uint data) //向reg寄存器写data,reg是个索引值
{
ioapic->reg = reg; //选定寄存器reg
ioapic->data = data; //向窗口寄存器写就相当于向寄存器reg写
}
初始化
#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位
}
}
/*********mp.c***********/
void mpinit(void){
/*********略***********/
case MPIOAPIC: //如果是IOAPIC表项
ioapic = (struct mpioapic*)p; //强制转换为IOAPIC类型
ioapicid = ioapic->apicno; //记录IOAPIC ID
p += sizeof(struct mpioapic); //移到下一个表项
continue;
/*********略***********/
}
宏定义 $IOAPIC$ 是个地址值,这个地址就是 IOREGSEL
寄存器在内存中映射的位置,通过 $index/data$ 方式读取 ID
,支持的中断数等信息。
$IOAPIC\ ID$ 在 $MP$ $Configuration$ $Table$ $Entry$ 中也有记录,关于 $MP\ Table$ 我们在MultiProcessor提到过,简单来说,$MP\ Table$ 有各种表项,记录了多处理器下的一些配置信息,计算机启动的时候可以从中获取有用信息。MultiProcessor处只说明了处理器类型的表项,有多少个处理器类型的表项表示有多少个处理器。$IOAPIC$ 同样的道理,然后每个 $IOAPIC$ 类型的表项中有其 $ID$ 记录。关于 $MP\ Table$ 咱们就点到为止,有兴趣的可以去公众号后台获取 $MP\ Spec$ 的资料文档,有详细的解释。
接着就是一个 $for$ 循环,来初始化 24 个重定向表项。来看看设置了哪些内容:
- $T_IRQ0+i$,这个表示中断向量号,一个中断向量号就表示一个中断。表明此重定向表项处理 $T_IRQ0+i$ 这个中断。
- $#define\ \ INT_DISABLED\ \ 0x00010000$,设置此位来屏蔽与该重定向表项相关的中断,也就是说当硬件外设向 $IOAPIC$ 发送中断信号时,$IOAPIC$ 直接屏蔽忽略。
- 设置 $bit13$ ,$bit15$ 为 0, 分别表示管脚高电平有效,触发模式为边沿触发,这是数字逻辑中的概念,应该都知道吧,不知的话需要去补补了,基本东西还是需要知道。
- 设置 $bit11$ 为 0 表示 $Physical\ Mode$,设置高 8 位的 $Destination\ Field$ 为 0。在 $Physical\ Mode$ 模式下,$Destination\ Field$ 字段就表示 $LAPIC\ ID$,$LAPIC\ ID$ 又唯一标识一个 $CPU$,所以 $Destination\ Field$ 就表示此中断会路由到该 $CPU$,交由该 $CPU$ 来处理。
因此这里初始化将所有重定向表项设置为边沿触发,高电平有效,所有中断路由到 $CPU0$,但又将所有中断屏蔽的状态。 $xv6$ 的注释描述的是不会将中断路由到任何处理器,这里我认为是有误的,虽然屏蔽了所有中断,但是根据 $Destination\ Field$ 字段来看应该是路由到 $CPU0$ 的,若我理解错还请批评指正。
另外为什么要加上一个 $T_IRQ0$ 呢, $T_IRQ0$ 是个宏,值为 32,前 32 个中断向量号分配给了一些异常或者架构保留,后面的中断向量号 32~255 才是可以使用的。
上述 $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字段
}
$T_IRQ0 + irq$ 为中断向量号,填写到低 8 位 $vector$ 字段,表示此重定向表项处理该中断
$cpunum$ 为 CPU 的编号,$mp.c$ 文件中定义了关于 $CPU$ 的全局数组,存放着所有 $CPU$ 的信息。$xv6$ 里面,这个数组的索引是就是 $cpunum$ 也是 $LAPIC\ ID$,可以来唯一标识一个 $CPU$。初始化的时候 $Destination\ Mode$ 为 0,调用此函数没有改变该位,所以还是 0,为物理模式,所以将 $cpunum$ 写入 $Destination\ Field$ 字段表示将中断路由到该 $CPU$。
注,$LAPIC$ $ID$ 必须唯一,但可以不连续,经过测试,$xv6$ 里,$LAPIC$ $ID$ 是连续分配的,且从 0 开始。至于怎么测试的,就是在 $Makefile$ 里面修改 $CPU$ 数量,然后 $make$ $qemu$ 查看 $CPU$ 编号
这里我们来测试 $ioapicenable$ 函数,来看看是否如上所说,这个函数能指定某个 $CPU$ 来处理特定的中断,在磁盘相关代码文件 $ide.c$ 中函数 $ideinit$ 调用了 $ioapicenable$:
ioapicenable(IRQ_IDE, ncpu - 1); //让这个CPU来处理硬盘中断
根据上述讲的,这说明使用最后一个 $CPU$ 来处理磁盘中断,下面我们来验证,验证方式很简单,在中断处理程序当中打印 $CPU$ 编号就行
首先在 $Makefile$ 中将 $CPU$ 数量设为多个处理器,我设置的是 4:
ifndef CPUS
CPUS := 4
endif
接着在 $trap.c$ 文件中添加 $printf$ 语句:
case T_IRQ0 + IRQ_IDE: //如果是磁盘中断
ideintr(); //调用磁盘中断程序
lapiceoi(); //处理完写EOI表中断完成
cprintf("ide %d\n", cpuid()); //打印CPU编号
break;
这个函数我们后面会讲到,这里提前看一看,有注释应该还是很好理解的,来看看结果:
$CPU$ 的数量为 4,处理磁盘中断的 $CPU$ 编号为 3,符合预期,$IOAPIC$ 的初始化就说到这里,下面来看 $LAPIC$ 的初始化。
LAPIC 初始化
读写寄存器
LAPIC
的寄存器在内存中都有映射,起始地址一般默认为 $0xFEE0\ 0000$,但这个地址不是自己设置使用的,起始地址在 $MP$ $Table$ $Header$ 中可以获取,所以可以如下定义和获取 $lapic$ 地址
/*lapic.c*/
volatile uint *lapic; // Initialized in mp.c
/*mp.c*/
lapic = (uint*)conf->lapicaddr; //conf就是MP Table Header,其中记录着LAPIC地址信息
$lapic$ 也可以看作是 $uint$ 型的数组,一个元素 4 字节,所以计算各个寄存器的索引的时候要在偏移量的基础上除以 4。举个例子,ID
寄存器相对 $lapic$ 基地址偏移量为 $0x20$,那么 $ID$ 寄存器在 $lapic$ 数组里面的索引就该为 $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
如何初始化的,初始化函数为 $lapicinit$,我们分开来看:
使能LAPIC
lapicw(SVR, ENABLE | (T_IRQ0 + IRQ_SPURIOUS));
#define SVR (0x00F0/4) // Spurious Interrupt Vector
#define ENABLE 0x00000100 // Unit Enable
设置 $SVR$ 中的 $bit\ 8$ 置 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???应是2分频
#define PERIODIC 0x00020000 // Periodic
LAPIC
自带可编程定时器,可以用这个定时器来作为时钟,触发时钟中断。这需要 $TDCR(The\ Divide\ Configuration\ Register)$、$TICR(The\ Initial-Count\ Register)$、以及 $LVT\ Timer\ Register$ 配合使用,其实还有一个 $Current-count\ Register$,$xv6$ 没有使用,
设置 $Timer\ Mode$ 为 $Periodic$ 模式,周期性的从某个数递减到 0,如此循环往复。 $T_IRQ0 + IRQ_TIMER$ 是时钟中断的向量号,设置在 $Timer$ 寄存器的低 8 位
$TICR$ 寄存器来设置从哪个数开始倒数,$xv6$ 设置的值是 $10000000$
递减得有个频率,这个频率是系统的总线频率再分频,分频系数设置在 $TDCR$ 寄存器,$xv6$ 设置的是 2 分频,根据手册来看这里 $xv6$ 的原本注释应是错了。
另外 $T_IRQ0 + IRQ_TIMER$ 是时钟中断的向量号,设置在 $Timer$ 寄存器的低 8 位。
关于时钟中断的设置就是这么多,每个 $CPU$ 都有 $LAPIC$,所以每个 $CPU$ 上都会发生时钟中断,不像其他中断,指定了一个 $CPU$ 来处理。
其他
回到 LAPIC
的初始化上面来:
// Disable logical interrupt lines.
lapicw(LINT0, MASKED);
lapicw(LINT1, MASKED);
这部分用作$LINT0,LINT1$连接到了 $i8259A$ 和 $NMI$,但实际上只连接到了 $BSP$(最先启动的 $CPU$),只有 $BSP$ 能接收这两种中断。一般对于 $BSP$ 如果有 $PIC$ 模式(兼容$i8259$) $LINT0$ 设置为 $ExtINT$ 模式,$LINT1$ 设置为 $NMI$ 模式。如果是 $AP$ 直接设置屏蔽位将两种中断屏蔽掉。$xv6$ 简化了处理,只使用 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
的 $bit16-bit23$ 是 $LVT$ 本地中断的表项个数,如果超过了 4 项则屏蔽性能计数溢出中断。为什么这么操作,这个中断有什么用不太清楚,这个在 $intel$ 手册卷三有描述,看了之后还是懵懵懂懂,感觉平常不会接触,用到的少,就没深入的去啃了,也不能拿出来乱说,在此抱歉,有了解的大佬还请告知。
ERROR Register
,设置这个寄存器来映射 $ERROR$ 中断,当 $APIC $检测到内部错误的时候就会触发这个中断,中断向量号是 $T_IRQ0 + IRQ_ERROR$
$ESR(ERROR\ Status\ Register)$ 记录错误状态,初始化就是将其清零,而且需要连续写两次,手册里面规定的但没具体说明为什么,咱们也不深究。
lapicw(EOI, 0);
#define EOI (0x00B0/4) // EOI
EOI
($End\ of\ Interrupt$),中断处理完成之后要写 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
这个主要是来初始化 $Arb$ $ID$,$Arb$ $ID$ 主要用来 $LAPIC$ 对总线的竞争仲裁。ICR
($Interrupt\ Command\ Register$)中断指令寄存器,当一个 $CPU$ 想把中断发送给另一个 $CPU$ 时,就在 ICR 中填写相应的中断向量和目标 LAPIC
标识,然后通过总线向目标 LAPIC
发送消息。因为同样是向另一个 LAPIC
发送中断消息,所以ICR
寄存器的字段和 IOAPIC
重定向表项较为相似,都有 $Destination$ $Field$, $Delivery$ $Mode$, $Destination$ $Mode$, $Level$ 等等。
$Send\ an\ Init\ Level\ De-Assert\ to\ synchronise\ arbitration\ ID's$. 结合 $intel$ 手册,作用为将所有 $CPU$ 的 APIC
的 $Arb\ ID$ 设置为初始值 $APIC\ ID$。
关于 Arb
,引用 $Interrupt in Linux$ 中的解释:
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。
$Delivery$ $Status$,如果是 $Idle$ 表明这种处理器间中断当前没有活动事件,$Send$ $Pending$ 表示正在传送中断但是还没有被对方完全接收,所以这里初始化 $Arb$ 的方式就是发送 $INIT$-$Level$-$Deassert$ 消息,然后一直阻塞到 $Send$ $Pending$ 状态发生。
// Enable interrupts on the APIC (but not on the processor).
lapicw(TPR, 0);
#define TPR (0x0080/4) // Task Priority
任务优先级寄存器,确定当前 $CPU$ 能够处理什么优先级别的中断,$CPU$ 只处理比 $TPR$ 中级别更高的中断。比它低的中断暂时屏蔽掉,也就是在 $IRR$ 中继续等到。这里设置为 0,即响应所有的中断。
上述就是 $xv6$ 里面对 LAPIC
的一种简单的初始化方式,其实也不简单,涉及了挺多东西。接下来应该是 $CPU$ 来处理中断的部分,在这之前先来看看 $lapic.c$ 里面涉及到的两个用的比较多的函数:
获取CPU ID
int lapicid(void) //返回 CPU/LAPIC ID
{
if (!lapic)
return 0;
return lapic[ID] >> 24;
}
这个函数用来返回 $LAPIC\ ID$,ID
寄存器 $bit24$ 位后表示 $LAPIC\ ID$因为 $CPU$ 与 LAPIC
一一对应,所以这也相当于返回 $CPU\ ID$,同样也是 $CPU$ 数组中的索引。而前面在 $IOAPIC$ 一节中出现的 $cpuid$ 函数相当于就是这个函数的封装。
大家这里有没有这个疑惑,为什么不同 CPU 执行这段代码就能够获取到自己的 LAPIC ID 了,也就是说每个 CPU 都有自己的 LAPIC,但是为什么都是使用的地址 $0xFEE0\ 0000$,关于这个问题引用大佬 ZX WING 的解释:
每个 CPU都用同样的物理地址访问自己的 LAPIC。这说明除了 x86 平台的port I/O 具有 64K 独立的物理地址空间外,LAPIC 也拥有独立的物理地址。我能想到的理由是防止 CPU 访问不属于自己的 LAPIC。
struct cpu* mycpu(void) //获取当前CPU
{
int apicid, i;
if(readeflags()&FL_IF) //检查是否处于关中断的状态
panic("mycpu called with interrupts enabled\n");
apicid = lapicid(); //获取当前CPU的APIC ID,也就是CPU ID
// APIC IDs are not guaranteed to be contiguous. Maybe we should have
// a reverse map, or reserve a register to store &cpus[i].
//APIC ID不能够保证一定是连续的,但在这xv6中根据ioapicenable函数推测还有实际测试,
//APIC ID,CPU数据的索引(CPU ID) 是一样的
for (i = 0; i < ncpu; ++i) {
if (cpus[i].apicid == apicid) //比对哪个CPU结构体的ID是上述ID,返回其地址
return &cpus[i];
}
panic("unknown apicid\n");
}
这个函数就是根据 APIC ID 获取当前的 CPU,这个函数一定要在关中断的情况下执行,为什么呢?因为调度的问题,如果不关中断,期间可能发生调度,也就是说本来当前进程运行在 CPU0 上,读取的编号也应该是 0,结果在执行 mycpu 函数时发生了调度,当前进程让出来 CPU0,等到该进程再次上 CPU 执行的时候可能就不是 CPU0 了,那么之前读来的 CPU 编号就是错误的。
中断结束
void lapiceoi(void)
{
if(lapic)
lapicw(EOI, 0);
}
写 EOI
表中断完成,使得 $ISR$ 相应的位清 0。
环境准备(OS部分)
这部分继续来说环境准备 $OS$ 部分,主要就是构建中断描述符表 $IDT$,注册中断服务程序。构建 $IDT$ 就是构建一个个门描述符,它们集合起来就是 $IDT$。而所谓的注册中断服务程序其实就是在门描述符里面填写程序地址。
注册中断服务程序,首先得有中断服务程序是吧,我将 $xv6$ 里的中断服务程序分为三部分:
- 中断入口程序
- 中断处理程序
- 中断退出程序
中断处理程序每个中断是不同的,但是中断入口和中断的出口(退出)是基本是相同的,$xv6$ 在描述符里面填写的地址就是中断入口程序的地址。中断入口程序就是保存上下文,然后跳到真正的中断处理程序执行中断,之后再跳转到中断退出程序。
这里涉及到两个跳,第一个从中断入口程序跳到中断处理程序,一个相同的入口点是如何跳到不同的中断处理程序的呢?中断入口程序会压入向量号,可以根据向量号来调用不同的中断处理程序。
第二跳从中断处理跳到中断退出程序,这其实没什么特殊的处理,中断入口程序和中断退出程序在一个汇编文件里面,中断入口程序调用中断处理程序,中断处理程序执行完成之后自然会回到中断退出程序。
中断入口程序
上面其实就把中断的处理流程给说了一遍了,有了总体了解之后来看中断入口程序的构造。
中断入口程序主要就是要保存上下文,上下文可分为两部分,一部分是 $CPU$ 自动压入的,另一部部分是 $OS$ 来完成的。所以我们的中断入口也分为两部分,分别处理这两部分上下文。
您可能会说 CPU 那部分不是硬件自动压入的吗,有软件什么事?还记得前面说的错误码问题吗?因为有的中断会产生错误码,而有的不会,为了统一,不产生错误码的中断我们手动压入一个 0。另外 $xv6$ 在这部分也压入了向量号,之后就会跳到入口程序的共同部分保存剩下的上下文。来看码,更清晰:
第一部分
.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
IDT
中支持 256 个表项,支持 256 个中断,所以要有 256 个入口程序,入口程序所做的工作是类似的,所以 $xv6$ 使用了 $perl$ 脚本来批量产生代码。脚本文件是 $vectors.pl$,生成的代码如上所示,我列举了其中几个。
$vector##n$ 就是各个中断的入口程序的地址,将这些地址集合起来就是入口程序指针数组 $vectors$
入口程序的第一部分就主要做了三件事:
- 压入 0,如果该中断有错误码就不压入,比如 8 号异常
- 压入中断向量号
- 跳去 $alltraps$
第一项 压入 0 只有没有错误码产生的异常才会执行,而错误码主要部分就是选择子,一般不使用。但这是 $x86$ 架构特性,有错误码的时候会自动压入,所以在 $perl$ 脚本中对有错误码的异常做了特殊处理:
if(!($i == 8 || ($i >= 10 && $i <= 14) || $i == 17)){
print " pushl \$0\n";
表示向量号为 $8,10-14,17$ 号会产生错误码,不需要压入 0,这几个异常都出现在前 32 号,具体就不说是什么异常了,有兴趣的自己去看看吧。另外有时关于中断和异常的措辞不要太较真,它们的处理流程是一模一样的,很多书上对其都是混称。在不特指情况下我们一般统称中断,但是我们自己要知道它们的区别,异常来自内部,是指令执行过程中出了错,是个同步事件,而中断来自外部,是个异步事件。
第二部分
第二部分就是 $alltraps$,它位于一个汇编文件 $trapasm.S$ 中
.globl alltraps
alltraps:
# Build trap frame. 构建中断栈帧
pushl %ds
pushl %es
pushl %fs
pushl %gs
pushal
上面就是保存上下文的第二部分,很简单粗暴,将所有的寄存器给压栈就完事了。
构建 IDT
构建 $IDT$ 就是构建一个个门描述符,所以先来看看如何构建门描述符
构建门描述符使用宏 $SETGATE$:
#define SETGATE(gate, istrap, sel, off, d) \ //门描述符,是否是陷阱,选择子,偏移量,DPL
{
\
(gate).off_15_0 = (uint)(off) & 0xffff; \ //偏移量低16位
(gate).cs = (sel); \ //选择子
(gate).args = 0; \ //保留未使用
(gate).rsv1 = 0; \ //保留未使用
(gate).type = (istrap) ? STS_TG32 : STS_IG32; \ //类型是中断门还是陷阱门
(gate).s = 0; \ //系统段
(gate).dpl = (d); \ //DPL
(gate).p = 1; \ //存在位
(gate).off_31_16 = (uint)(off) >> 16; \ //偏移量高16位
}
构建 $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++) //循环256次构造门描述符
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");
}
这个函数就是循环 256 次构造门描述符,中断服务程序肯定都是内核函数,所以使用内核代码段的选择子,而段内偏移量就是各个中断入口程序的地址,存放在 $vectors$ 数组里面
再者就是设置特权级,中断的特权级检查不涉及 $RPL$,只是 $CPL$ 与 $DPL$ 之间的关系,这里的 $DPL$ 有两种:门描述符的 $DPL$,且称之为 $DPL_GATE$,门描述符中记录的将要执行的目标代码段 $DPL$,且称之为 $DPL_CODE$
如果是外部设备和异常引起的,只需要检查 $CPL \ge DPL_CODE$ ,因为触发中断前要么在用户态 3,要么在内核 0,而执行的中断服务程序也就是目标代码段的特权级一定是 0,所以有上述关系,再次注意数值与特权级高低是反的。
如果是系统调用,需要检查 $DPL_GATE \ge CPL \ge DPL_CODE$,因为使用 $int$ $n$ 指令来实现系统调用时一定处于用户态,即 $CPL$ 一定是 3,门描述符的 $DPL_GATE$ 设置为 3 能够检查 $CPL$ 是否为 3,即检查使用 $int$ $n$ 时是否处于用户态。
另外系统调用这里使用的是陷阱门实现的,试验过使用中断门实现也完全没得问题,两者差别不大,唯一区别就是通过中断门 $CPU$ 会自动关中断,而陷阱门不会。所以理论上执行系统调用时可能会被中断,但 $emmm$ 没验证过。
中断流程
上述就是中断机制的环境配置,也简要的说了部分中断流程,这儿来详细说明,还是那三个大步骤:
- 保存上下文,分为两部分,一部分 $CPU$ 自动压入,一部分执行中断入口程序压入
- 执行中断处理程序
- 恢复上下文,也分两部分,一部分执行中断退出程序弹出,一部分 iret 指令弹出
保存上下文
CPU 部分
如果有特权级转移且有错误码,$CPU$ 压入 $SS_OLD$, $ESP_OLD$, $EFLAGS$, $CS_OLD$, $EIP_OLD$, $ERROE_CODE$,若没有特权级转移便不会压入 $SS_OLD$, $ESP_OLD$,若没有错误码,便不会压入 $ERROR_CODE$ 而是由后面的中断入口程序压入 0.
中断入口程序
$CPU$ 根据向量号找到中断入口程序地址,便开始执行保存上下文
第一部分
vector1:
pushl $0 #错误码
pushl $1 #向量号
jmp alltraps #跳去alltraps
这部分压入错误码使得栈中格式一致,之后压入向量号为后面分支执行中断处理程序提供依据,最后跳去 $alltraps$ 保存剩下的上下文
第二部分
/*******trapasm.S********/
.globl alltraps
alltraps:
# Build trap frame. 构建中断栈帧
pushl %ds
pushl %es
pushl %fs
pushl %gs
pushal
这部分简单粗暴的将所有寄存器压入栈中
这个栈帧结构就是上下文结构,为此定义了一个结构体:
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;
};
与上面的栈中的结构一模一样的哈
中断处理程序
所有的中断先进入 $trap$ 这个函数,然后根据栈中的 $vector$ 执行各自的中断处理函数
/******trapasm.S*******/
# Set up data segments. 设置数据段为内核数据段
movw $(SEG_KDATA<<3), %ax
movw %ax, %ds
movw %ax, %es
# Call trap(tf), where tf=%esp
pushl %esp #压入参数
call trap #trap(tf)
addl $4, %esp #清理栈空间
这部分先是设置数据段 $DS$ 附加数据段 ES 的选择子为内核代码段,在根据向量号索引门描述符的时候已经进行了特权级检查,将门描述符中的段选择子——内核代码段选择子加载到了 CS,这里就只需要设置数据段寄存器为内核数据段。
之后就调用 $trap$,调用函数之前要压入参数,这里 $trap$ 的参数就是中断栈帧指针即当前的 $ESP$,所以 pushl %esp
就是压入参数了,之后 call trap
又会压入返回地址,即执行 $trap$ 时栈里面的情况应如下所示:
来看 $trap$ 函数:
void trap(struct trapframe *tf)
{
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++; //滴答数加1
wakeup(&ticks);
release(&tickslock);
}
lapiceoi(); //写EOI表中断结束
break;
case T_IRQ0 + IRQ_IDE: //磁盘中断
ideintr(); //磁盘中断处理程序
lapiceoi(); //写EOI表中断结束
break;
/********略********/
}
这里我们不讨论各个中断处理程序具体做些什么,只需了解流程,就是根据 $trapno$ 也就是 $vector$ 执行各个分支,具体的代码分析到后面各个部分会说明。
所以中断处理程序部分没啥说的,来看恢复上下文
恢复上下文
$emmm$ 其实恢复上下文也没啥说的,因为就是保存上下文的逆操作,还是来简单看看
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
先来说说从 $trap$ 回来需要调用方来清理栈空间的,平时使用高级语言编程对这方面可能没什么意识,但是使用汇编必须得遵循调用约定,使得栈空间结构正确。
清理了栈空间之后弹出各个寄存器,到错误码向量号的时候直接将 ESP
上移 8 字节跳过。
栈中变化情况如下:
这里说明两点:
- $pop$ 出栈操作并不会实际清理栈空间的内容,只是 $ESP$ 指针和弹出目的地寄存器会有相应变化,栈里面的内容不会变化。
- 返回地址什么时候跳过的?一般情况下 $call$ 与 $ret$ 是一对儿,$call$ 压入返回地址,$ret$ 弹出返回地址,可是没看到 $ret$ 啊?这里是汇编和 $C$ 语言混合编程,将 $C$ 代码 $trap.c$ 编译之后就有 $ret$ 了,所以弹出返回地址就发生在 $trap$ 执行完之后
现在 $ESP$ 指向的是 $EIP_OLD$,该执行 $iret$ 了,$iret$ 时先检查是否进行了特权级转移,如果没有特权级转移,那么就要弹出 EIP,CS 和 EFALGS
,如果有特权级转移则还要弹出 ESP,SS
。
原任务的所有状态都恢复了原样,则中断结束,继续原先的任务。
栈的问题
最后再来聊一聊栈的问题,栈一直是一个很困惑的问题,我一直认为,操作系统能把栈捋清楚那基本就没什么问题了。在进入中断的时候,如果特权级发生变化,会先将 SS,ESP
先压入内核栈,再压入 CS,EIP,EFLAGS
。
这句话看着没什么问题,但有没有想过这个问题:怎么找到内核栈的?切换到内核栈之后,ESP 已经指向内核栈,但是我们压入的 ESP 应该是切换栈之前的旧栈栈顶值,所以怎么得到旧栈的值再压入?再者 $iret$ 时如果按栈中的寄存器顺序只是简单的先 $popl\ \%esp$,再 $popl\ \%SS$ 那岂不是又乱套了?
首先怎么切换到内核栈的这个问题,硬件架构提供了一套方法。有个寄存器叫做 TR
寄存器,TR
寄存器存放着 TSS
段选择子,根据 TSS
段选择子去 GDT
中索引 TSS
段描述符,从中获取 TSS
。
那说了半天 TSS
是啥?TSS
($Task\ State\ Segment$),任务状态段,它是硬件支持的系统数据结构,各级(包括内核)栈的 SS
和 ESP
。所以当特权级变化的时候就会从这里获取内核栈的 SS
和 ESP
。这个 TSS
这里我们只是简介,TSS
什么样子的,怎么初始化,还有些什么用处,它的功能都用到了?这些三言两语说不完,也不是本文重点,后面进程的时候会再次讲述。
接着第二个问题,切换到新栈怎么压入旧栈信息,其实这个问题很简单,我先把旧栈信息保存到一个地方,换栈之后再压入不就行了。关于 $iret$ 时弹出栈中信息是一个道理,查看 $intel$ 手册第二卷可以找到答案,的确也是这样处理的,手册中关于指令的伪码明显表示了有 $temp$ 来作为中转站。但这个 $temp$ 具体是个啥就不知道了,手册中也没明确说明,可能是另外的寄存器?这个不得而知,也不是重点没必要研究那么深入。
本文中断关于栈还有一个地方值得聊聊,嗯其实也没多大聊的,就是解释一句。建立栈帧的时候 $pushal,popal$ 的问题,这个是用来压入和弹出那 8 个通用寄存器的,还记得中断栈帧结构体中关于 ESP
的注释吗?写的是 $useless\ ignore$,意思是无用忽略,这是为啥?
这得从 $pushal$ 说起,$pushal$ 中压入 ESP
的时候压入的是 执行到 $pushl\ esp$ 的值吗?非也,压入的是 执行 $pushal$ 前的栈顶值,在执行 $pushal$ 之前先将 ESP
的值保存到 $temp$,当压入 ESP
的时候执行的时 $push\ temp$。
所以 $popal$ 执行到弹出 $temp$ 的时候,就不能将其中的值弹入 ESP
,而是直接将 ESP
的值加 4 跳过 $temp$。因为将 $temp$ 弹入 ESP
的话等于换了一个栈了,本来只该跳 4 字节的,结果跳过了很多字节,那明显就不对了嘛。
可以来张图看看,红线叉叉表示出错:
关于 $pushal,popal$ 的伪码如下:
中断这一块关于栈方面的问题就是这么多吧,发生中断时有特权级变化就换栈,内核栈地址去 TSS
中找,中断完成后将所有的寄存器信息复原,其中就有 刚进入中断时压入的 SS ESP
(有特权级变化的时候),栈也就恢复到了用户态下的栈。当然如果发生中断时就在内核态,那栈就不用变换,当然这只是 $xv6$ 的处理方式,其他系统可能不同,但总的来说中断的处理过程就是这么一个过程。
时钟中断
本节最后把时钟中断说了,前面说过 $xv6$ 的时钟中断使用的定时器为 $LAPIC$ 的定时器
lapicw(TDCR, X1); //设置分频系数
lapicw(TIMER, PERIODIC | (T_IRQ0 + IRQ_TIMER)); //设置Timer的模式和中断向量号
lapicw(TICR, 10000000); //设置周期性计数的数字
case T_IRQ0 + IRQ_TIMER: //时钟中断
if(cpuid() == 0){
acquire(&tickslock); //取滴答数的锁
ticks++; //滴答数加1
wakeup(&ticks); //唤醒睡在滴答数上的进程
release(&tickslock); //解锁
}
lapiceoi(); //写EOI寄存器
break;
这就是时钟中断干的事,它将滴答数加 1,所以滴答数就表示系统从启动以来发生了多少次时钟中断,另外每次时钟中断还会唤醒睡眠在滴答数上的进程,比如某个进程执行了系统调用 $sleep(n)$,$sleep(n)$ 会休眠 n 个滴答数,每次时钟中断都要唤醒此进程让其检查是否已经睡了 n 个滴答了,这在后面进程在具体说明
if(myproc() && myproc()->state == RUNNING &&
tf->trapno == T_IRQ0+IRQ_TIMER)
yield(); //让出CPU,切换进程
另外发生时钟中断后,还可能会进行进程的切换,同样的这里过过眼了解就好,后面进程部分再详述。
从 CMOS 获取时间
在计算机领域,$CMOS$ 常指保存计算机基本启动信息(如日期、时间、启动设置等)的芯片。它是主板上的一块可读写的并行或串行 $FLASH$ 芯片,是用来保存 $BIOS$ 的硬件配置和用户对某些参数的设定。
在计算机系统中,对 $CMOS$ 中的数据的读写是通过两个 $I/O$ 端口来实现的,其中,端口 $70H$ 是一个字节的只写端口,用它来选择 $CMOS$ 的寄存器,然后再通过 $71H$ 端口来读写选择的寄存器,也就是前面所说的 $index/data$ 的方式访问 $CMOS$ 数据
实时时钟 $RTC$,$Real$ $Time$ $Clock$,这个时钟可以永久的存放系统时间,也就是说它在系统关闭,没有电源的情况下也能继续工作。这里说的是没有计算机的电源(那大个儿电池),电子设备要工作那肯定还是需要电源的,这个电源是主板上的一个微型电池,计算机断电后实时时钟RTC就靠它来供电继续工作,持续对系统计时。这也就是为什么关机重新启动后时间还是正确的原因。
CMOS 寄存器(index)
时间
- 00h,系统时间“秒数”字段
- 02h,系统时间“分钟”字段
- 04h,系统时间“小时”字段
- 07h,系统“日期”字段(0~31)
- 08h,系统“月份"字段(0~12)
- 09h,系统公元纪年的后两位(00 表示 2000,01 表示 2001,一次类推)
状态
- 0Ah,状态寄存器 A
- bit 7,0 表示目前可读时间,1 表示日期正在更新,稍后读取。
- 0Bh,状态寄存器B
- bit 2,0 表示使用 $bcd$ 格式,1 表示二进制格式
相关函数
读取CMOS寄存器
static uint
cmos_read(uint reg) //0x70端口选择寄存器,0x71端口读出来
{
outb(CMOS_PORT, reg); //选择寄存器:向70h端口写寄存器索引
microdelay(200); //等一会儿
return inb(CMOS_RETURN); //从71h端口将数据读出来
}
这个函数就是向 70h 端口写要读写的寄存器索引,然后再从 71h 端口操作该寄存器,这里就是从 71h 端口将数据给读出来
读取时间
struct rtcdate {
uint second;
uint minute;
uint hour;
uint day;
uint month;
uint year;
};
static void fill_rtcdate(struct rtcdate *r) //读取时间
{
r->second = cmos_read(SECS); //秒数
r->minute = cmos_read(MINS); //分钟
r->hour = cmos_read(HOURS); //小时
r->day = cmos_read(DAY); //日期
r->month = cmos_read(MONTH); //月份
r->year = cmos_read(YEAR); //年份
}c
这个函数就是调用 $cmos_read$ 将存储在 $CMOS$ 中的墙上时间给读取出来,这个函数之上还封装了一层 $cmostime$:
void cmostime(struct rtcdate *r)
{
struct rtcdate t1, t2;
int sb, bcd;
sb = cmos_read(CMOS_STATB); //读取状态寄存器B
bcd = (sb & (1 << 2)) == 0; //0是BCD格式,为默认值,1是二进制值
// make sure CMOS doesn't modify time while we read it
for(;;) {
fill_rtcdate(&t1); //读取时间
if(cmos_read(CMOS_STATA) & CMOS_UIP) //如果时间正更新,稍后读取
continue;
fill_rtcdate(&t2);
if(memcmp(&t1, &t2, sizeof(t1)) == 0) //如果两者一样,break,如此操作应是为了确保时间准确
break;
}
// convert
if(bcd) {
#define CONV(x) (t1.x = ((t1.x >> 4) * 10) + (t1.x & 0xf)) //BCD码转10进制
CONV(second);
CONV(minute);
CONV(hour );
CONV(day );
CONV(month );
CONV(year );
#undef CONV
}
*r = t1;
r->year += 2000; //读取出来的year位公元纪年的后两位,所以加上2000
}
整个流程应该是很简单的,主要注意一下 BCD 码如何转换成十进制数字
unsigned char bcd2dec(unsigned char bcd)
{
return ((bcd & 0xf) + ((bcd>>4)*10));
}
其实原理很简单,比如 BCD 码表示 15 这个数字,表示方式是:0001 1001,BCD 码是用四位来表示一个数的,前四位表示1,后四位表示 5,前四位对应十位,需要乘 10,再加上个位(后四位)就是对应的十进制数字了。
读取时间与中断关系不大,这部分代码也在 $lapic.c$ 里面,就顺便放这儿说了,好了中断就到这,最后再来看看总的中断流程图:
好了本节就这样吧,有什么问题还请批评指正,也欢迎大家来同我讨论交流学习进步。
首发公众号:Rand_cs