多处理器下的中断机制

383 阅读22分钟

INTERRUPT

  • 公号:Rand_cs

中断是硬件和软件交互的一种机制,可以说整个操作系统,整个架构都是由中断来驱动的。中断的机制分为两种,中断和异常,中断通常为 IOIO 设备触发的异步事件,而异常是 CPUCPU 执行指令时发生的同步事件。本文主要来说明 IOIO 外设触发的中断,总的来说一个中断的起末会经历设备,中断控制器,CPU 三个阶段:设备产生中断信号,中断控制器翻译信号,CPU 来实际处理信号

本文用 xv6xv6 的实例来讲解多处理器下的中断机制,从头至尾的来看一看,中断经历的三个过程。其中第一个阶段设备如何产生信号不讲,超过了操作系统的范围,也超过了我的能力范围。各种硬件外设有着自己的执行逻辑,有各种形式的中断触发机制,比如边沿触发,电平触发等等。总的来说就是向中断控制器发送一个中断信号,中断控制器再作翻译发送给 CPUCPUCPUCPU 再执行中断服务程序对中断进行处理。

中断控制器

说到中断控制器,是个什么东西?中断控制器可以看作是中断的代理,外设是很多的,如果没有一个中断代理,外设想要给 CPUCPU 发送中断信号来处理中断,那只能是外设连接在 CPUCPU 的管脚上,CPUCPU 的管脚是很宝贵的,不可能拿出那么多管脚去连接外设。所以就有了中断控制器这个中断代言人,所有的 IOIO 外设连接其上,发送中断请求时就向中断控制器发送信号,中断控制器再通知 CPU,如此便解决了上述问题。

中断控制器有很多,前文讲过 PIC​,PIC 只用于单处理器,对于如今的多核多处理器时代,PIC 无能为力,所以出现了更高级的中断控制器 APICAPIC(Advanced Programmable Interrupt ControllerAdvanced\ Programmable\ Interrupt\ Controller) 高级可编程中断控制器,APIC 分成两部分 LAPICIOAPIC,前者 LAPIC 位于 CPUCPU 内部,每个 CPUCPU 都有一个 LAPIC,后者 IOAPIC 与外设相连。外设发出的中断信号经过 IOAPIC 处理之后发送给 LAPIC,再由 LAPIC 决定是否交由 CPUCPU 进行实际的中断处理。

可以看出每个 CPUCPU 上有一个 LAPICIOAPIC 是系统芯片组一部分,各个中断消息通过总线发送接收。关于 APIC 的内容很多也很复杂,详细描述的可以参考 intelintel 开发手册卷三,本文不探讨其中的细节,只在上层较为抽象的层面讲述,理清 APIC 模式下中断的过程。

计算机启动的时候要先对 APIC 进行初始化,后续才能正确使用,下面来看看 APIC 在一种较为简单的工作模式下的初始化过程:

IOAPIC

初始化 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​,地址为 0xFEC0 00000xFEC0\ 0000;​IOWIN​,地址为 0xFEC0 0010h0xFEC0\ 0010hIOREGSEL 用来指定要读写的寄存器,然后从 IOWIN 中读写。也就是常说的 index/data 访问方式,或者说 adress/dataadress/data,用 index 端口指定寄存器,从 data 端口读写寄存器,data 端口就像是所有寄存器的窗口。

而所谓内存映射,就是把这些寄存器看作内存的一部分,读写内存,就是读写寄存器,可以用访问内存的指令比如 mov 来访问寄存器。还有一种是 IO端口映射,这种映射方式是将外设的 IO端口(外设的一些寄存器) 看成一个独立的地址空间,访问这片空间不能用访问内存的指令,而需要专门的 in/out 指令来访问

通过 IOREGSELIOWIN 既可以访问到 IOAPIC 所有的寄存器,所以结构体 ioapicioapic 如下定义:

struct ioapic {
  uint reg;       //IOREGSEL
  uint pad[3];    //填充12字节
  uint data;      //IOWIN
};

填充 1212 字节是因为 IOREGSEL0xFEC0 00000xFEC0\ 0000,长度为 4 字节,IOWIN0xFEC0 00100xFEC0\ 0010,两者中间差了 112 字节,所以填充 1212 字节补上空位方便操作。

通过 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写
}

这两个函数就是根据 index/dataindex/data 来读写 IOAPIC 的寄存器。下面来看看 IOAPIC 寄存器分别有些什么意义,了解了之后自然就知道为什么要这样那样的初始化了。下面只说 xv6xv6 中涉及到的寄存器,其他的有兴趣见文末链接。

IOAPIC 寄存器

ID Register

  • 索引为 0

  • bit24bit27bit24 - bit27:ID

Version Register

  • 索引为 1

  • bit0bit7bit0-bit7 表示版本,

  • bit16bit23bit16-bit23 表示重定向表项最多有几个,这里就是 23(从 0 开始计数)

重定向表项

IOAPIC 有 24 个管脚,每个管脚都对应着一个 64 位的重定向表项(也相当于 64 位的寄存器),保存在 0x100x3F0x10-0x3F,重定向表项的格式如下所示:

来源于 interrupt in linux

来源于 interrupt in linux

来源于 interrupt in linux

这是 ZX_WINGZX\_WING 大佬在他的 Interrupt in LinuxInterrupt\ in\ Linux 中总结出来的,很全面也很复杂,这里有所了解就好,配合着下面的初始化代码对部分字段作相应的解释。

IOAPIC 初始化

#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位
  }
}

宏定义 IOAPICIOAPIC 是个地址值,这个地址就是 IOREGSEL 寄存器在内存中映射的位置,通过 index/dataindex/data 方式读取 ID,支持的中断数等信息。

IOAPIC IDIOAPIC\ IDMP Configuration Table EntryMP\ Configuration\ Table\ Entry 中有记录,关于 MP TableMP\ Table 我们在 @@@@@@@@@@@ 一文中提到过,简单来说,MP TableMP\ Table 有各种表项,记录了多处理器下的一些配置信息,计算机启动的时候可以从中获取有用信息。多处理器下的计算机启动@@@@一文只说明了处理器类型的表项,有多少个处理器类型的表项表示有多少个处理器。IOAPIC 同样的道理,然后每个 IOAPICIOAPIC 类型的表项中有其 IDID 记录。关于 MP TableMP\ Table 咱们就点到为止,有兴趣的可以去公众号后台获取 MP SpecMP\ Spec 的资料文档,有详细的解释。

接着就是一个 forfor 循环,来初始化 24 个重定向表项。来看看设置了哪些内容:

  • T_IRQ0+iT\_IRQ0+i,这个表示中断向量号,一个中断向量号就表示一个中断。表明此重定向表项处理 T_IRQ0+iT\_IRQ0+i 这个中断。
  • #define  INT_DISABLED  0x00010000\#define\ \ INT\_DISABLED\ \ 0x00010000,设置此位来屏蔽与该重定向表项相关的中断,也就是说当硬件外设向 IOAPIC 发送中断信号时,IOAPIC 直接屏蔽忽略。
  • 设置 bit13bit13bit15bit15 为 0, 分别表示管脚高电平有效,触发模式为边沿触发,这是数字逻辑中的概念,应该都知道吧,不知的话需要去补补了,基本东西还是需要知道。
  • 设置 bit11bit11 为 0 表示 Physical ModePhysical\ Mode,设置高 8 位的 Destination FieldDestination\ Field 为 0。Physical ModePhysical\ Mode 模式下,Destination FieldDestination\ Field 字段就表示 LAPIC IDLAPIC\ IDLAPIC IDLAPIC\ ID 又唯一标识一个 CPUCPU,所以 Destination FieldDestination\ Field 就表示此中断会路由到该 CPUCPU,交由该 CPUCPU 来处理

因此这里初始化将所有重定向表项设置为边沿触发,高电平有效,所有中断路由到 CPU0CPU0,但又将所有中断屏蔽的状态。 xv6xv6 的注释描述的是不会将中断路由到任何处理器,这里我认为是有误的,虽然屏蔽了所有中断,但是根据 Destination FieldDestination\ Field 字段来看应该是路由到 CPU0CPU0 的,若我理解错还请批评指针。

另外为什么要加上一个 T_IRQ0T\_IRQ0 呢, T_IRQ0T\_IRQ0 是个宏,值为 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字段
}

T_IRQ0+irqT\_IRQ0 + irq 为中断向量号,填写到低 8 位 vector 字段,表示此重定向表项处理该中断

cpunumcpunum 为 CPU 的编号,mp.cmp.c 文件中定义了关于 CPUCPU 的全局数组,存放着所有 CPUCPU 的信息。xv6xv6 里面,这个数组的索引是就是 cpunumcpunum 也是 LAPIC IDLAPIC\ ID,可以来唯一标识一个 CPUCPU。初始化的时候 Destination ModeDestination\ Mode 为 0,调用此函数没有改变该位,所以还是 0,为物理模式,所以将 cpunumcpunum 写入 Destination FieldDestination\ Field 字段表示将中断路由到该 CPUCPU

来做个简单测试,在磁盘相关代码文件 ide.cide.c 中函数 ideinit()ideinit() 调用了 ioapicenable()ioapicenable()

ioapicenable(IRQ_IDE, ncpu - 1);     //让这个CPU来处理硬盘中断

根据上述讲的,这说明使用最后一个 CPUCPU 来处理磁盘中断,下面我们来验证,验证方式很简单,在中断处理程序当中打印 CPUCPU 编号就行:

首先在 MakefileMakefile 中将 CPUCPU 数量设为多个处理器,我设置的是 4:

ifndef CPUS
CPUS := 4
endif

接着在 trap.ctrap.c 文件中添加 printfprintf 语句:

case T_IRQ0 + IRQ_IDE:    //如果是磁盘中断
    ideintr();            //调用磁盘中断程序
    lapiceoi();           //处理完写EOI表中断完成
    cprintf("ide %d\n", cpuid());  //打印CPU编号
    break;

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

CPUCPU 的数量为 4,处理磁盘中断的 CPUCPU 编号为 3,符合预期,IOAPICIOAPIC 的初始化就说到这里,下面来看 LAPICLAPIC 的初始化。

LAPIC

LAPIC 要比 IOAPIC 复杂的多,放张总图:

xv6xv6 不会涉及这么复杂,其主要功能是接收 IOAPIC 发来的中断消息然后交由 CPUCPU 处理,再者就是自身也能作为中断源产生中断发送给自身或其他 CPUCPU。同样的初始化 LAPIC 就是设置相关寄存器,但是 LAPIC 的寄存器实在太多了,本文只是说明 xv6 涉及到的寄存器,其他的可以参考前文@@@@@@@@@@@,或者文末的链接。

LAPIC 的寄存器在内存中都有映射,起始地址一般默认为 0xFEE0 00000xFEE0\ 0000,但这个地址不是自己设置使用的,起始地址在 MP Table HeaderMP\ Table\ Header 中可以获取,详见文末链接@@@@@@@@@@,所以可以如下定义和获取 lapiclapic 地址

/*lapic.c*/volatile uint *lapic;  // Initialized in mp.c/*mp.c*/lapic = (uint*)conf->lapicaddr;  //conf就是MP Table Header,其中记录着LAPIC地址信息

lapiclapic 也可以看作是 uintuint 型的数组,一个元素 4 字节,所以计算各个寄存器的索引的时候要在偏移量的基础上除以 4。举个例子,ID 寄存器相对 lapiclapic 基地址偏移量为 0x200x20,那么 ID 寄存器在 lapiclapic 数组里面的索引就该为 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 寄存器的函数,接着就来看看 LAPIC 如何初始化的,初始化函数为 lapicinit()lapicinit(),我们分开来看:

lapicw(SVR, ENABLE | (T_IRQ0 + IRQ_SPURIOUS));#define SVR     (0x00F0/4)   // Spurious Interrupt Vector  #define ENABLE     0x00000100   // Unit Enable

SVR 伪中断寄存器,CPUCPU 每响应一次 INTRINTR(可屏蔽中断),就会连续执行两个 INTAINTA 周期。在 MP SpecMP\ Spec 中有描述,当一个中断在第一个 INTAINTA 周期后,第二个 INTAINTA 周期前变为无效,则为伪中断,也就是说伪中断就是中断引脚没有维持足够的有效电平而产生的。这主要涉及到电气方面的东西,我们了解就好。

SVRSVR 中的字段还有其他作用,bit 8bit\ 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  #define PERIODIC   0x00020000   // Periodic

LAPIC 自带可编程定时器,可以用这个定时器来作为时钟,触发时钟中断。这需要 TDCR(The Divide Configuration Register)TDCR(The\ Divide\ Configuration\ Register)TICR(The InitialCount Register)TICR(The\ Initial-Count\ Register)、以及 LVT Timer RegisterLVT\ Timer\ Register 配合使用,其实还有一个 Currentcount RegisterCurrent-count\ Registerxv6xv6 没有使用,这些寄存器的具体配置如上代码所示,解释如下:

这几个寄存器表示 LVT(Local Vector Table)LVT(Local\ Vector\ Table) 本地中断,LAPIC 除了可以接收 IOAPIC 发来的中断之外,自己也可以产生中断,就是上述列出来的这几种。

从上图可以看出 TimerTimer 寄存器 bit17,bit18bit17,bit18 设置 Timer ModeTimer\ Modexv6xv6 设置为 0101PeriodicPeriodic 模式,从名字就可以看出这是周期性模式,周期性的从某个数递减到 0,如此循环往复。

这个数设置在 TICRTICR 寄存器,xv6xv6 设置的值是 1000000010000000

递减得有个频率,这个频率是系统的总线频率再分频,分频系数设置在 TDCRTDCR 寄存器,xv6xv6 设置的是 1 分频,也就相当于没有分频,就是使用的是总线频率。

另外 T_IRQ0+IRQ_TIMERT\_IRQ0 + IRQ\_TIMER 是时钟中断的向量号,设置在 TimerTimer 寄存器的低 8 位。

关于时钟中断的设置就是这么多,每个 CPUCPU 都有 LAPICLAPIC,所以每个 CPUCPU 上都会发生时钟中断,不像其他中断,指定了一个 CPUCPU 来处理。

回到 LAPIC 的初始化上面来:

// Disable logical interrupt lines.lapicw(LINT0, MASKED);lapicw(LINT1, MASKED);

LINT0LINT1LINT0,LINT1连接到了 i8259Ai8259ANMINMI,但实际上只连接到了 BSPBSP(最先启动的 CPUCPU),只有 BSPBSP 能接收这两种中断。一般对于 BSPBSP 如果有 PICPIC 模式(兼容i8259i8259) LINT0LINT0 设置为 ExtINTExtINT 模式,LINT1LINT1 设置为 NMINMI 模式。如果是 APAP 直接设置屏蔽位将两种中断屏蔽掉。xv6xv6 简化了处理,只使用 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​ 的 bit16bit23bit16-bit23LVTLVT 本地中断的表项个数,如果超过了 4 项则屏蔽性能计数溢出中断。为什么这么操作,这个中断有什么用不太清楚,这个在 intel 手册卷三有描述,看了之后还是懵懵懂懂,感觉平常不会接触,用到的少,就没深入的去啃了,所以也不能拿出来乱说,在此抱歉,有了解的大佬还请告知。

ERROR Register​,设置这个寄存器来映射 ERRORERROR 中断,当 APICAPIC 检测到内部错误的时候就会触发这个中断,中断向量号是 T_IRQ0+IRQ_ERRORT\_IRQ0 + IRQ\_ERROR

ESR(ERROR Status Register)ESR(ERROR\ Status\ Register) 记录错误状态,初始化就是将其清零,而且需要连续写两次。

lapicw(EOI, 0);#define EOI     (0x00B0/4)   // EOI

EOI(End of InterruptEnd\ 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

ICR(Interrupt Command RegisterInterrupt\ Command\ Register)中断指令寄存器,当一个 CPUCPU 想把中断发送给另一个 CPUCPU 时,就在 ICR 中填写相应的中断向量和目标 LAPIC 标识,然后通过总线向目标 LAPIC 发送消息。因为同样是向另一个 LAPIC 发送中断消息,所以ICR 寄存器的字段和 IOAPIC 重定向表项较为相似,都有 Destination Field,Delivery Mode,Destination Mode,LevelDestination\ Field, Delivery\ Mode, Destination\ Mode, Level 等等。

Send an Init Level DeAssert to synchronise arbitration IDsSend\ an\ Init\ Level\ De-Assert\ to\ synchronise\ arbitration\ ID's. 结合 intelintel 手册,作用为将所有 CPUCPUAPICArb IDArb\ ID 设置为初始值 APIC IDAPIC\ ID

关于 Arb,引用 InterruptinLinuxInterrupt 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。

// Enable interrupts on the APIC (but not on the processor).lapicw(TPR, 0);#define TPR     (0x0080/4)   // Task Priority

任务优先级寄存器,确定当前 CPU 能够处理什么优先级别的中断,CPU 只处理比 TPR 中级别更高的中断。比它低的中断暂时屏蔽掉,也就是在 IRR 中继续等到

上述就是 xv6xv6 里面对 LAPIC 的一种简单的初始化方式,其实也不简单,涉及了挺多东西。接下来应该是 CPU 来处理中断的部分,在这之前先来看看 lapic.clapic.c 里面涉及到的两个用的比较多的函数:

int lapicid(void)   //返回 CPU/LAPIC ID{  if (!lapic)    return 0;  return lapic[ID] >> 24;}

这个函数用来返回 LAPIC IDLAPIC\ IDID 寄存器 bit24bit24 位后表示 LAPIC IDLAPIC\ ID因为 CPUCPULAPIC 一一对应,所以这也相当于返回 CPU IDCPU\ ID,同样也是 CPUCPU 数组中的索引。而前面在 IOAPICIOAPIC 一节中出现的 cpuid()cpuid() 函数相当于就是这个函数的封装。

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 部分的中断过程:

  1. 外设触发中断,发送中断信号给 IOAPIC
  2. IOAPIC 根据 PRTPRT 表将中断信号翻译成中断消息,然后发送给 Destination FieldDestination\ Field 字段列出的 LAPICLAPIC
  3. LAPIC 根据消息中的 Destination ModeDestination\ ModeDestination FieldDestination\ Field,自身的寄存器 ID 来判断自己是否接收该中断消息,设置 IRR 相应的 bitbit 位,不是则忽略
  4. CPUCPU 在可以处理下一个中断时,从 IRR 中挑选优先级最大的中断,相应位置 0,ISR 相应位置 1,然后送 CPUCPU 执行。
  5. CPUCPU 执行中断服务程序处理中断
  6. 中断处理完成后写 EOI 表示中断处理已经完成,写 EOI 导致 ISR 相应位置 0,对于 levellevel 触发的中断,还会向所有的 IOAPIC 发送 EOI 消息,通知中断处理已经完成。

上述的过程只是一个很简单的大致过程,没有涉及到不可屏蔽中断,一些特殊的中断,中断嵌套等等,只是来简单认识一下 APIC 在中断时是如何工作的,接下来重点看看 CPUCPU 部分对中断的处理。

CPU 部分

上述就是 APICAPIC 的初始化部分,被 main.cmain.c 中的 main()main() 调用,是计算机启动时环境初始化的一部分。下面来看 CPUCPU 处理中断的部分。先来复习一下 CPUCPU 部分大致是如何处理中断的:

  • CPUCPU 收到中断控制器发来的中断向量号
  • 根据中断向量号去 IDTIDT 索引门描述符,根据门描述符中的段选择子去 GDTGDT 中索引段描述符
  • 这期间 CPUCPU 会进行特权级检查,如果特权级有变化,如用户态进入内核态,压入原栈 SSSSESPESP 到内核栈,如果没有变化则不用压入。之后压入 CSCSEIPEIPEFLAGSEFLAGS,该中断有错误码的话还需要压入错误码。
  • 根据段描述符中的段基址和中断描述符中的偏移量取得中断服务程序的地址
  • 执行中断服务程序,这期间会压入寄存器等资源,保存上下文
  • 执行完成后恢复上下文,写 EOI​ 表中断完成

所以在中断正式处理之前就压入一些寄存器,栈中情况如下:

接下来便就是去 IDTGDT 中索引门描述符和段描述符,寻找中断服务程序,本文主要讲述中断,所以只来看看 IDTGDT 相关内容我在 @@@@@@@@@有所讲述,可以参考参考。

构建 IDT

IDT(Interrupt Descriptor Table)(Interrupt\ Descriptor\ Table),中断描述符表,我们得先有这么一个表,CPUCPU 才能使用中断控制器发送来的向量号去 IDTIDT 中索引门描述符。

所以得构建一个 IDT​,构建 IDT 就是构建一个个中断描述符,一般称作门描述符,IDT​ 里面可以存放几种门描述符,如调用门描述符,陷阱门描述符,任务门描述符,中断门描述符。大多数中断都使用中断门描述符,来看看中断门描述符的格式:

其实上述也可以作为陷阱门描述符,两者几乎一模一样,只有 TYPETYPE 字段不一样,所以如下定义中断门/陷阱门描述符:

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};
  • bit0bit15bit0-bit15:中断服务程序在目标代码段中的偏移量 0~15 位
  • bit16bit31bit16-bit31:中断服务程序所在段的段选择子
  • bit40bit43bit40-bit43:中断门的 TYPETYPE 值为 1110,陷阱门为 1111
  • bit44bit44:S 字段为 0 表示系统段,各种门结构都是系统段,意为这是硬件需要的结构,反之软件需要的则是非系统段,包括平常所说的数据段和代码段,这不是硬件必须的,为非系统段。
  • bit45bit46bit45-bit46DPL(Descriptor Privilege Level)DPL(Descriptor\ Privilege\ Level),描述符特权级,进入中断时会用来特权级检查。
  • bit47bit47P(Present)P(Present) 该段在内存中是否存在,存在为 1,否则为 0
  • bit48bit63bit48-bit63:中断服务程序在内核代码段中的偏移量 16~31 位

从上面部分字段代表的意义可以看出,构建中断门描述符还需要中断服务程序的地址信息,所以咱们首先还得准备好各个中断服务程序,取得它们的地址信息。在 xv6xv6 中​,所有的中断都有相同的入口程序,而在中断门描述符中填写的就是这个入口程序的地址。

IDT 中支持 256 个表项,支持 256 个中断,所以要有 256 个入口程序,入口程序所做的工作是类似的,所以 xv6xv6 使用了 perlperl 脚本来批量产生代码。脚本文件是 vectors.plvectors.pl,生成的代码如下所示:

.globl alltraps  .globl vector0   #向量号为0的入口程序vector0:  pushl $0  pushl $0  jmp alltraps#############################.globl vector8vector8:  pushl $8  jmp alltraps##############################.globl vectors  #入口程序数组vectors:  .long vector0  .long vector1  .long vector2

这是一段汇编代码,所有的中断入口程序都做了相同的三件事或两件事:

  • 压入 0,其实这个位置是错误码的位置,有些中断会产生错误码压入栈中,所以为了统一,没有错误码的中断也压入一个东西:0
  • 压入自己的中断向量号
  • 跳到 alltrapsalltraps 去执行中断处理程序

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

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

表示向量号为 81014178,10-14,17 号会产生错误码,不需要压入 0。

这 256 个中断入口程序地址写入一个大数组 vectorsvectors,所以中断门描述符要的地址信息不就来了,因此 IDT 的构建如下:

struct gatedesc idt[256];extern uint vectors[];  // in vectors.S: array of 256 entry pointersvoid 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;                  \}

SEGGATESEGGATE 宏就是根据信息构建一个中断描述符,应该很容易看懂。

中断服务程序属于内核程序,段选择子为内核代码段,DPLDPL 设置为 0,但是系统调用需要特殊处理,DPLDPL 字段必须设置为 3。为什么这么设置,原由与特权级检查有关:当前代码段寄存器的 RPL(RequestPrivilegeLevel,请求特权级)RPL(Request Privilege Level,请求特权级)CPL(Current Previlege Level,当前特权级)CPL(Current\ Previlege\ Level,当前特权级),也就是 CPL=CS.RPLCPL=CS.RPL。是不是很绕,没办法,事实就是这样。

作何特权级检查呢?CPLCPL 需要大于等于门描述符中选择子的 DPLDPL,而对于系统调用 CPLCPL 还需要小于等于门描述符的 DPLDPL,不然就会触发一般保护性错异常。系统调用特权级肯定是要转移的,也就是从用户态到内核态,用户态下 CPL=3CPL = 3,门描述符 DPLDPL 如果还为 0 的话,那特权级检查不能通过,是要触发异常的,所以对于系统调用 DPLDPL 得设置为 3。

这说的有点远了,特权级检查是个很复杂的东西,上面还没有加入 RPLRPL 的检查呢。这里只是稍作了解就好,后面有机会写一篇捋一捋特权级检查,下面回到 IDT 本身上来,IDT 构建好了之后需要将其地址加载到 IDTR 寄存器,如此 CPUCPU 才晓得去哪儿找 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 位

  • bit0bit15bit0-bit15 表示 IDT 的界限,也就是这个表有好大,表示的最大范围为 0xFFFF0xFFFF,也就是 64KB64KB,一个门描述符 8 字节,所以描述符最多 64KB/8B=819264KB/8B = 8192,但是处理器只支持 256 个中断,也就是 256 个门描述符。
  • bit16bit48bit16-bit48 表示 IDT 基地址

上述代码中数组 pdpd 就是这 48 位数据,先构造这个数据,然后使用内联汇编,指令 lidtlidt 将其加载到 IDTR 寄存器,关于内联汇编不多说,可以参考我前面的文章:@@@@@@@@

中断服务程序

IDTIDT 准备好之后,这一小节就正式来看中断服务程序的流程,我将其分为三个阶段:中断入口,中断处理,中断退出,咱们一个个来看:

中断入口程序

中断入口程序主要是保存中断上下文,vectorsvectors 数组中记录的入口程序只能算是一部分,这一部分做了三件事:压入 0/错误码,压入向量号,跳到 alltrapsalltraps

所以现阶段栈中情况如下:

紧接着程序跳到了 alltrapsalltraps,来看看这是个什么玩意儿:

.globl alltrapsalltraps:  # 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

可以看出 alltrapsalltraps 也主要干了三件事:

  • 建立栈帧,保存上下文
  • 设置数据段寄存器为内核数据段
  • 传参调用 trap.c()trap.c() 中断处理程序

1、建立栈帧,保存上下文

建立栈帧保存上下文就是将各类寄存器资源压栈保存在栈中,xv6xv6 直接暴力地将所有的寄存器直接压进去。先是压入各段寄存器,再 pushalpushal 压入所有的通用寄存器,顺序为 eax,ecx,edx,ebx,esp,ebp,esi,edieax, ecx, edx, ebx, esp, ebp, esi, edi

所以现下栈中的情况为:

所以如此定义栈帧:

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

可以看出定义的中断栈帧结构体与前面的操作是一一对应的,说明两点:

  • 段寄存器只有 16 位 2 字节,压栈段寄存器时用的 pushlpushl,压入了一个双字 4 字节,所以需要 shortshort 类型的来填充 2 字节。也可以直接将段寄存器定义为 uintuint 类型的,省去定义填充变量。
  • pushalpushal 时压入通用寄存器,这些寄存器加上进入中断时 CPUCPU 自动压入的值就是中断发生前一刻进程的上下文。这里 pushalpushal 压入的 ESPESP 后面注释写着无用忽略,为什呢?买个关子,后面和栈的问题一起说。

2、设置数据段寄存器为内核数据段

在根据向量号索引门描述符的时候已经进行了特权级检查,将门描述符中的段选择子——内核代码段选择子加载到了 CS,这里就只需要设置数据段寄存器为内核数据段。附加段,附加的数据段,通常与数据段进行一样的设置,在串操作指令中,将附加段作为目的操作数的存放区域,详见前文内联汇编@@@@@@@@@@@@@@@@@@@

3、调用中断处理程序

pushpush 之后 callcall,标准的函数调用方式,先 pushpush 参数,再 callcall 调用函数。push %esppush\ \%esp,此时的 esp 是中断栈帧的栈顶元素的地址,也就是说传递的参数是中断栈帧的首地址。随后 call trapcall\ trap 调用中断处理程序,压入返回地址(callcall 指令后面那条指令的地址,也就是 addl\ \4, %esp语句的地址),之后跳转到语句的地址),之后跳转到trap()$ 执行程序。

此时栈中情况:

中断处理程序

上述操作已经将中断处理程序 trap(struct trapframe)trap(struct\ *trapframe) 需要的参数中断栈帧 trapframetrapframe 的地址压入栈中。其实 trap()trap() 也像是中断服务程序的入口,整个程序就是由许多条件语句组成,根据 trapframetrapframe 的向量号去执行不同分支中的中断处理程序,来随便看几个:

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;/*****************************/

如果是时钟中断,并且是 CPU0CPU0 发出的时钟中断,就将滴答数 ticksticks 加 1。每个 CPUCPU 都有自己的 LAPIC,也就都有自己的 APIC Timer,都能够触发时钟中断。ticksticks 记录系统从开始到现在的滴答数,作为系统的时间,发生一次时钟中断其数值就加 1,但是能修改 ticksticks 的应该只能有一个 CPU,不然如果所有的 CPUCPU 都能修改 ticksticks 的值的话,那岂不是乱套了?所以这里就选择 CPU0CPU0 也是 BSPBSP 来修改 ticksticks 的值。处理完之后写 EOI 表时钟中断完成

如果是磁盘发出的中断,就调用磁盘中断处理程序,也是磁盘驱动程序的主体,详见前文带你了解磁盘驱动@@@@@@@@@@@@@@@@。处理完之后就写 EOI​ 表中断完成

其他的中断都是这样处理,就不一一举例说明了,其中有一些中断还没有讲到,但所有中断的处理都是如此,根据向量号调用不同的中断处理程序,处理完之后写 EOI 表中断完成

中断退出程序

执行完 trap()trap() 函数之后,回到汇编程序 trapasm.Strapasm.S

# 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

中断退出程序基本上就是中断入口程序的逆操作。

首先从 trap()trap() 返回之后清理参数占用的栈空间,将 ESP 上移 4 字节。一般系统的源码就是汇编和 C 程序,所以使用 cdeclcdecl 调用约定,该约定规定了参数从右往左入栈,EAX,ECX,EDX 由调用者保存,也是调用者来清理栈空间等等。而清理栈空间呢?其实就是为了栈里面的数据正确,显然要当前栈顶指针需要向上移动 4 字节,后面的操作 popalpopal 才正确。

清理了栈空间之后弹出各个寄存器,到错误码向量号的时候直接将 ESP 上移 8 字节跳过。

栈中变化情况如下:

这里说明两点:

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

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

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

中断的总体过程大致就是这样,不只是 xv6xv6 如此,所有基于 x86x86 架构的系统都有类似的过程,只不过复杂的操作对中断的处理有着更微妙的操作,但总体上看大致过程就是如此。

下面来看一看过程图:

这主要是定位中断服务程序的图,至于实际处理中断的过程图就不画了,把握上面的栈的变化就行了,而栈的变化情况上述的图应该描述的很清楚了,所以这里就不再赘述,说起栈,关于栈上述我们还遗留了一些问题,在这儿解答:

栈的问题

最后再来聊一聊栈的问题,栈一直是一个很困惑的问题,我一直认为,操作系统能把栈捋清楚那基本就没什么问题了。在进入中断的时候,如果特权级发生变化,会先将 SS,ESP 先压入内核栈,再压入 CS,EIP,EFLAGS

这句话看着没什么问题,但有没有想过这个问题:怎么找到内核栈的?切换到内核栈之后,ESP 已经指向内核栈,但是我们压入的 ESP 应该是切换栈之前的旧栈栈顶值,所以怎么得到旧栈的值再压入再者 iretiret 时如果按栈中的寄存器顺序只是简单的先 popl %esppopl\ \%esp,再 popl %SSpopl\ \%SS 那岂不是又乱套了?

首先怎么切换到内核栈的这个问题,硬件架构提供了一套方法。有个寄存器叫做 TR 寄存器,TR 寄存器存放着 TSS 段选择子,根据 TSS 段选择子去 GDT 中索引 TSS 段描述符,从中获取 TSS

那说了半天 TSS 是啥?TSS(Task State SegmentTask\ State\ Segment),任务状态段,它是硬件支持的系统数据结构,各级(包括内核)栈的 SSESP所以当特权级变化的时候就会从这里获取内核栈的 SSESP。这个 TSS 这里我们只是简介,TSS 什么样子的,怎么初始化,还有些什么用处,它的功能都用到了?这些三言两语说不完,也不是本文重点,后面进程的时候会再次讲述。

接着第二个问题,切换到新栈怎么压入旧栈信息,其实这个问题很简单,我先把旧栈信息保存到一个地方,换栈之后再压入不就行了。关于 iretiret 时弹出栈中信息是一个道理,查看 intelintel 手册第二卷可以找到答案,的确也是这样处理的,手册中的伪码明显表示了有 temptemp 来作为中转站。但这个 temptemp 具体是个啥就不知道了,手册中也没明确说明,可能是另外的寄存器?这个不得而知,也不是重点没必要研究那么深入。

本文中断关于栈还有一个地方值得聊聊,嗯其实也没多大聊的,就是解释一句。建立栈帧的时候 pushalpopalpushal,popal 的问题,这个是用来压入和弹出那 8 个通用寄存器的,还记得中断栈帧结构体中关于 ESP 的注释吗?写的是 useless ignoreuseless\ ignore,意思是无用忽略,这是为啥?

这得从 pushalpushal 说起,pushalpushal 中压入 ESP 的时候压入的是 执行到 pushl esppushl\ esp 的值吗非也,压入的是 执行 pushalpushal 前的栈顶值,在执行 pushalpushal 之前先将 ESP 的值保存到 temptemp,当压入 ESP 的时候执行的时 push temppush\ temp

所以 popalpopal 执行到弹出 temptemp 的时候,就不能将其中的值弹入 ESP,而是直接将 ESP 的值加 4 跳过 temptemp。因为将 temptemp 弹入 ESP 的话等于换了一个栈了,本来只该跳 4 字节的,结果跳过了很多字节,那明显就不对了嘛。

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

关于 pushal,popalpushal,popal 的伪码如下:

中断这一块关于栈方面的问题就是这么多吧,发生中断时有特权级变化就换栈,内核栈地址去 TSS 中找,中断完成后将所有的寄存器信息复原,其中就有 刚进入中断时压入的 SS ESP(有特权级变化的时候),栈也就恢复到了用户态下的栈。当然如果发生中断时就在内核态,那栈就不用变换,当然这只是 xv6xv6 的处理方式,其他系统可能不同,但总的来说中断的处理过程就是这么一个过程。

当然这只是一个普通外设触发的中断,一些特殊中断,中断嵌套开关中断的内容都没有讲述,中断是个很大的概念,内容也很庞杂,本文利用 xv6xv6 将一个普通外设触发的中断的处理机制说明的应该还是很清楚的,好啦本文就到这里,有什么错误还请批评指正,也欢迎大家来同我交流讨论学习进步。

wiki.osdev.org/APIC#Local_…

wiki.osdev.org/IOAPIC

blog.chinaunix.net/uid-2049974…