xv6(5) 中断代码部分

首发公众号: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
");

  // 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
", 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 表示使能 LAPICLAPIC 需要在使能状态下工作。

时钟中断
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
");

  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
");
}

这个函数就是根据 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$

入口程序的第一部分就主要做了三件事:

  1. 压入 0,如果该中断有错误码就不压入,比如 8 号异常
  2. 压入中断向量号
  3. 跳去 $alltraps$

第一项 压入 0 只有没有错误码产生的异常才会执行,而错误码主要部分就是选择子,一般不使用。但这是 $x86$ 架构特性,有错误码的时候会自动压入,所以在 $perl$ 脚本中对有错误码的异常做了特殊处理:

if(!($i == 8 || ($i >= 10 && $i <= 14) || $i == 17)){
   
        print "  pushl \$0
";

表示向量号为 $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$),任务状态段,它是硬件支持的系统数据结构,各级(包括内核)栈的 SSESP所以当特权级变化的时候就会从这里获取内核栈的 SSESP。这个 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

发表评论