xv6(5)中断代码部分

中断代码部分

本文来说码,看看中断到底是个啥样,同前面启动先来看看总图:

image.png

另外我说明一下我画的流程图啊,的确是不标准的,有很多环了,我有试过只画一根线比如说 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\ IDMP 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 直接屏蔽忽略。
  • 设置 bit13bit15 为 0, 分别表示管脚高电平有效,触发模式为边沿触发,这是数字逻辑中的概念,应该都知道吧,不知的话需要去补补了,基本东西还是需要知道。
  • 设置 bit11 为 0 表示 Physical\ Mode,设置高 8 位的 Destination\ Field 为 0。Physical\ Mode 模式下,Destination\ Field 字段就表示 LAPIC\ IDLAPIC\ 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;

这个函数我们后面会讲到,这里提前看一看,有注释应该还是很好理解的,来看看结果:

image.png

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\ Registerxv6 没有使用,

设置 Timer\ ModePeriodic 模式,周期性的从某个数递减到 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连接到了 i8259ANMI,但实际上只连接到了 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 Registerbit16-bit23LVT 本地中断的表项个数,如果超过了 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 IDArb ID 主要用来 LAPIC 对总线的竞争仲裁。ICR(Interrupt\ Command\ Register)中断指令寄存器,当一个 CPU 想把中断发送给另一个 CPU 时,就在 ICR 中填写相应的中断向量和目标 LAPIC 标识,然后通过总线向目标 LAPIC 发送消息。因为同样是向另一个 LAPIC 发送中断消息,所以ICR 寄存器的字段和 IOAPIC 重定向表项较为相似,都有 Destination Field, Delivery Mode, Destination Mode, Level 等等。

image.png

Send\ an\ Init\ Level\ De-Assert\ to\ synchronise\ arbitration\ ID's. 结合 intel 手册,作用为将所有 CPUAPICArb\ 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\ IDID 寄存器 bit24 位后表示 LAPIC\ ID因为 CPULAPIC 一一对应,所以这也相当于返回 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

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

  1. 压入 0,如果该中断有错误码就不压入,比如 8 号异常
  2. 压入中断向量号
  3. 跳去 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,只是 CPLDPL 之间的关系,这里的 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.

image.png

中断入口程序

CPU 根据向量号找到中断入口程序地址,便开始执行保存上下文

第一部分
vector1:
  pushl $0   #错误码
  pushl $1   #向量号
  jmp alltraps #跳去alltraps

这部分压入错误码使得栈中格式一致,之后压入向量号为后面分支执行中断处理程序提供依据,最后跳去 alltraps 保存剩下的上下文

image.png
第二部分
/*******trapasm.S********/
.globl alltraps
alltraps:
  # Build trap frame.  构建中断栈帧
  pushl %ds
  pushl %es
  pushl %fs
  pushl %gs
  pushal

这部分简单粗暴的将所有寄存器压入栈中

image.png

这个栈帧结构就是上下文结构,为此定义了一个结构体:

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 时栈里面的情况应如下所示:

image.png

来看 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 字节跳过。

栈中变化情况如下:

image.png

这里说明两点:

  • pop 出栈操作并不会实际清理栈空间的内容,只是 ESP 指针和弹出目的地寄存器会有相应变化,栈里面的内容不会变化。
  • 返回地址什么时候跳过的?一般情况下 callret 是一对儿,call 压入返回地址,ret 弹出返回地址,可是没看到 ret 啊?这里是汇编和 C 语言混合编程,将 C 代码 trap.c 编译之后就有 ret 了,所以弹出返回地址就发生在 trap 执行完之后

现在 ESP 指向的是 EIP\_OLD,该执行 iret 了,iret 时先检查是否进行了特权级转移,如果没有特权级转移,那么就要弹出 EIP,CS 和 EFALGS,如果有特权级转移则还要弹出 ESP,SS

image.png

原任务的所有状态都恢复了原样,则中断结束,继续原先的任务。

栈的问题

最后再来聊一聊栈的问题,栈一直是一个很困惑的问题,我一直认为,操作系统能把栈捋清楚那基本就没什么问题了。在进入中断的时候,如果特权级发生变化,会先将 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 字节的,结果跳过了很多字节,那明显就不对了嘛。

可以来张图看看,红线叉叉表示出错:

image.png

关于 pushal,popal 的伪码如下:

image.png

中断这一块关于栈方面的问题就是这么多吧,发生中断时有特权级变化就换栈,内核栈地址去 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 数据

实时时钟 RTCReal 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 里面,就顺便放这儿说了,好了中断就到这,最后再来看看总的中断流程图:

image.png

好了本节就这样吧,有什么问题还请批评指正,也欢迎大家来同我讨论交流学习进步。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,039评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,426评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,417评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,868评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,892评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,692评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,416评论 3 419
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,326评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,782评论 1 316
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,957评论 3 337
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,102评论 1 350
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,790评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,442评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,996评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,113评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,332评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,044评论 2 355

推荐阅读更多精彩内容