多处理器下的中断机制

Linux阅码场 2021-09-22 19:18

INTERRUPT

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

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

中断控制器

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

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

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

计算机启动的时候要先对 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,地址为 IOWIN,地址为 IOREGSEL 用来指定要读写的寄存器,然后从 IOWIN 中读写。也就是常说的 index/data 访问方式,或者说 ,用 index 端口指定寄存器,从 data 端口读写寄存器,data 端口就像是所有寄存器的窗口。

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

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

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

填充 字节是因为 IOREGSEL,长度为 4 字节,IOWIN,两者中间差了 2 字节,所以填充 字节补上空位方便操作。

通过 IOREGSEL 选定寄存器,然后从IOWIN中读写相应寄存器,因此也能明白下面两个读写函数:

static uint ioapicread(int reg)
{
  ioapic->reg = reg;    //选定寄存器reg
  return ioapic->data;  //从窗口寄存器中读出寄存器reg数据
}

static void ioapicwrite(int reg, uint data)
{
  ioapic->reg = reg;    //选定寄存器reg
  ioapic->data = data;  //向窗口寄存器写就相当于向寄存器reg写
}

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

IOAPIC 寄存器

ID Register

  • 索引为 0

  • :ID

Version Register

  • 索引为 1

  • 表示版本,

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

重定向表项

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

来源于 interrupt in linux
来源于 interrupt in linux
来源于 interrupt 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+10);   //设置高32位
  }
}

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

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

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

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

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

另外为什么要加上一个 呢, 是个宏,值为 32,前 32 个中断向量号分配给了一些异常或者保留,后面的中断向量号 32~255 才是一些外部中断或者 INT n 指令可以使用的

上述 IOAPIC 初始化的时候直接将管脚对应的中断全都给屏蔽了,那总得有开启的时候吧,不然无法工作也就无意义了,“开启”函数如下所示:

void ioapicenable(int irq, int cpunum)
{
  // Mark interrupt edge-triggered, active high,
  // enabled, and routed to the given cpunum,
  // which happens to be that cpu's APIC ID.     调用此函数使能相应的中断
  ioapicwrite(REG_TABLE+2*irq, T_IRQ0 + irq);
  ioapicwrite(REG_TABLE+2*irq+1, cpunum << 24);  //左移24位是填写 destination field字段
}

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

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

来做个简单测试,在磁盘相关代码文件 中函数 调用了

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

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

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

ifndef CPUS
CPUS := 4
endif

接着在 文件中添加 语句:

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

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

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

LAPIC

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

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

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

/*lapic.c*/
volatile uint *lapic;  // Initialized in mp.c

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

也可以看作是 型的数组,一个元素 4 字节,所以计算各个寄存器的索引的时候要在偏移量的基础上除以 4。举个例子,ID 寄存器相对 基地址偏移量为 ,那么 ID 寄存器在 数组里面的索引就该为 0x20/4。各个寄存器的偏移量见文末链接(说了太多次,希望不要觉得太啰嗦,因为内容实在太多,又想说明白那就只能这样放链接)

因为是 LAPIC 的寄存器是内存映射,所以设置寄存器就是直接读写相应内存,因此读写寄存器的函实现是很简单的:

static void lapicw(int index, int value)   //向下标为index的寄存器写value
{
  lapic[index] = value;
  lapic[ID];  // wait for write to finish, by reading  
}

这里看着是写内存,但是实际上这部分地址已经分配给了 LAPIC,对硬件的写操作一般要停下等一会儿待写操作完成,可以去看看磁盘键盘等硬件初始配置的时候都有类似的等待操作,这里直接采用读数据的方式来等待写操作完成。

LAPIC 初始化

有了读写 LAPIC 寄存器的函数,接着就来看看 LAPIC 如何初始化的,初始化函数为 ,我们分开来看:

lapicw(SVR, ENABLE | (T_IRQ0 + IRQ_SPURIOUS));

#define SVR     (0x00F0/4)   // Spurious Interrupt Vector
  #define ENABLE     0x00000100   // Unit Enable

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

中的字段还有其他作用, 置 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 自带可编程定时器,可以用这个定时器来作为时钟,触发时钟中断。这需要 、以及 配合使用,其实还有一个 没有使用,这些寄存器的具体配置如上代码所示,解释如下:

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

从上图可以看出 寄存器 设置 设置为 模式,从名字就可以看出这是周期性模式,周期性的从某个数递减到 0,如此循环往复。

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

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

另外 是时钟中断的向量号,设置在 寄存器的低 8 位。

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

回到 LAPIC 的初始化上面来:

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

连接到了 ,但实际上只连接到了 (最先启动的 ),只有 能接收这两种中断。一般对于 如果有 模式(兼容) 设置为 模式, 设置为 模式。如果是 直接设置屏蔽位将两种中断屏蔽掉。 简化了处理,只使用 APIC 模式,所有的 LAPIC 都将两种中断给屏蔽掉了。

if(((lapic[VER]>>16) & 0xFF) >= 4)
 lapicw(PCINT, MASKED);

// Map error interrupt to IRQ_ERROR.
lapicw(ERROR, T_IRQ0 + IRQ_ERROR);

// Clear error status register (requires back-to-back writes).
lapicw(ESR, 0);
lapicw(ESR, 0);

#define VER     (0x0030/4)   // Version
#define ERROR   (0x0370/4)   // Local Vector Table 3 (ERROR)
#define PCINT   (0x0340/4)   // Performance Counter LVT
#define ESR     (0x0280/4)   // Error Status

Version Register 本地中断的表项个数,如果超过了 4 项则屏蔽性能计数溢出中断。为什么这么操作,这个中断有什么用不太清楚,这个在 intel 手册卷三有描述,看了之后还是懵懵懂懂,感觉平常不会接触,用到的少,就没深入的去啃了,所以也不能拿出来乱说,在此抱歉,有了解的大佬还请告知。

ERROR Register,设置这个寄存器来映射 中断,当 检测到内部错误的时候就会触发这个中断,中断向量号是

记录错误状态,初始化就是将其清零,而且需要连续写两次。

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

EOI(),中断处理完成之后要写 EOI 寄存器来显示表示中断处理已经完成。重置初始化后的值应为 0.

lapicw(ICRHI, 0);
lapicw(ICRLO, BCAST | INIT | LEVEL);
while(lapic[ICRLO] & DELIVS)
 ;

#define ICRHI   (0x0310/4)   // Interrupt Command [63:32]
#define TIMER   (0x0320/4)   // Local Vector Table 0 (TIMER)
//ICR寄存器的各字段取值意义
  #define INIT       0x00000500   // INIT/RESET
  #define STARTUP    0x00000600   // Startup IPI
  #define DELIVS     0x00001000   // Delivery status
  #define ASSERT     0x00004000   // Assert interrupt (vs deassert)
  #define DEASSERT   0x00000000
  #define LEVEL      0x00008000   // Level triggered
  #define BCAST      0x00080000   // Send to all APICs, including self.
  #define BUSY       0x00001000
  #define FIXED      0x00000000

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

. 结合 手册,作用为将所有 APIC 设置为初始值

关于 Arb,引用 中的解释:

Arb,Arbitration Register,仲裁寄存器。该寄存器用 4 个 bit 表示 0~15 共 16 个优先级(15 为最高优先级),用于确定 LAPIC 竞争 APIC BUS 的优先级。系统 RESET 后,各 LAPIC 的 Arb 被初始化为其 LAPIC ID。总线竞争时,Arb 值最大 的 LAPIC 赢得总线,同时将自身的 Arb 清零,并将其它 LAPIC 的 Arb 加一。由 此可见,Arb 仲裁是一个轮询机制。Level 触发的 INIT IPI 可以将各 LAPIC 的 Arb 同步回当前的 LAPIC ID。

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

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

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

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

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

void lapiceoi(void)
{
  if(lapic)
    lapicw(EOI, 0);
}

EOI 表中断完成,这个函数在中断服务程序中会经常用到用到,下面再来看看 LAPIC 中两个比较重要的寄存器:

  • IRR 中断请求寄存器,256 位,每位代表着一个中断。当某个中断消息发来时,如果该中断没有被屏蔽,则将 IRR 对应的 bit 置 1,表示收到了该中断请求但 CPU 还未处理

  • ISR 服务中寄存器 ,256 位,每位代表着一个中断。当 IRR 中某个中断请求发送给 CPU 时,ISR 对应的 bit 上便置 1,表示 CPU 正在处理该中断

上述就是 APIC 的初始化和一些重要函数的讲解,有了这些了解之后,来总体的看一看 APIC 部分的中断过程:

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

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

CPU 部分

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

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

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

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

构建 IDT

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

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

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

struct gatedesc {
  uint off_15_0 : 16;   // low 16 bits of offset in segment
  uint cs : 16;         // code segment selector
  uint args : 5;        // # args, 0 for interrupt/trap gates
  uint rsv1 : 3;        // reserved(should be zero I guess)
  uint type : 4;        // type(STS_{IG32,TG32})
  uint s : 1;           // must be 0 (system)
  uint dpl : 2;         // descriptor(meaning new) privilege level
  uint p : 1;           // Present
  uint off_31_16 : 16;  // high bits of offset in segment
};
  • :中断服务程序在目标代码段中的偏移量 0~15 位
  • :中断服务程序所在段的段选择子
  • :中断门的 值为 1110,陷阱门为 1111
  • :S 字段为 0 表示系统段,各种门结构都是系统段,意为这是硬件需要的结构,反之软件需要的则是非系统段,包括平常所说的数据段和代码段,这不是硬件必须的,为非系统段。
  • ,描述符特权级,进入中断时会用来特权级检查。
  • 该段在内存中是否存在,存在为 1,否则为 0
  • :中断服务程序在内核代码段中的偏移量 16~31 位

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

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

.globl alltraps  

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

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

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

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

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

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

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

struct gatedesc idt[256];
extern uint vectors[];  // in vectors.S: array of 256 entry pointers

void tvinit(void)   //根据外部的vectors数组构建中断门描述符
{
  int i;

  for(i = 0; i < 256; i++)
    SETGATE(idt[i], 0, SEG_KCODE<<3, vectors[i], 0);
  SETGATE(idt[T_SYSCALL], 1, SEG_KCODE<<3, vectors[T_SYSCALL], DPL_USER);

  initlock(&tickslock, "time");
}

#define SETGATE(gate, istrap, sel, off, d)                \  //门描述符,是否是陷阱,选择子,偏移量,DPL
{                                                         \
  (gate).off_15_0 = (uint)(off) & 0xffff;                \
  (gate).cs = (sel);                                      \
  (gate).args = 0;                                        \
  (gate).rsv1 = 0;                                        \
  (gate).type = (istrap) ? STS_TG32 : STS_IG32;           \
  (gate).s = 0;                                           \
  (gate).dpl = (d);                                       \
  (gate).p = 1;                                           \
  (gate).off_31_16 = (uint)(off) >> 16;                  \
}

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

中断服务程序属于内核程序,段选择子为内核代码段, 设置为 0,但是系统调用需要特殊处理, 字段必须设置为 3。为什么这么设置,原由与特权级检查有关:当前代码段寄存器的 ,也就是 。是不是很绕,没办法,事实就是这样。

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

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

void idtinit(void)
{
  lidt(idt, sizeof(idt));      //加载IDT地址到IDTR
}

static inline void lidt(struct gatedesc *p, int size)   //构造idtr需要的48位数据,然后重新加载到idtr寄存器
{
  volatile ushort pd[3];

  pd[0] = size-1;
  pd[1] = (uint)p;
  pd[2] = (uint)p >> 16;

  asm volatile("lidt (%0)" : : "r" (pd));
}

IDTR 寄存器有 48 位

  • 表示 IDT 的界限,也就是这个表有好大,表示的最大范围为 ,也就是 ,一个门描述符 8 字节,所以描述符最多 ,但是处理器只支持 256 个中断,也就是 256 个门描述符。
  • 表示 IDT 基地址

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

中断服务程序

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

中断入口程序

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

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

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

.globl alltraps
alltraps:
# Build trap frame. 构建中断栈帧
pushl %ds
pushl %es
pushl %fs
pushl %gs
pushal

# Set up data segments. 设置数据段为内核数据段
movw $(SEG_KDATA<<3), %ax
movw %ax, %ds
movw %ax, %es

# Call trap(tf), where tf=%esp 调用trap.c()
pushl %esp
call trap
addl $4, %esp

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

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

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

建立栈帧保存上下文就是将各类寄存器资源压栈保存在栈中, 直接暴力地将所有的寄存器直接压进去。先是压入各段寄存器,再 压入所有的通用寄存器,顺序为

所以现下栈中的情况为:

所以如此定义栈帧:

struct trapframe {
  // registers as pushed by pusha
  uint edi;
  uint esi;
  uint ebp;
  uint oesp;      // useless & ignored esp值无用忽略
  uint ebx;
  uint edx;
  uint ecx;
  uint eax;

  // rest of trap frame
  ushort gs;
  ushort padding1;
  ushort fs;
  ushort padding2;
  ushort es;
  ushort padding3;
  ushort ds;
  ushort padding4;
  uint trapno;       //向量号

  // below here defined by x86 hardware
  uint err;
  uint eip;
  ushort cs;
  ushort padding5;
  uint eflags;

  // below here only when crossing rings, such as from user to kernel
  uint esp;
  ushort ss;
  ushort padding6;
};

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

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

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

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

3、调用中断处理程序

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

此时栈中情况:

中断处理程序

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

if(tf->trapno == T_SYSCALL){    //系统调用
    if(myproc()->killed)  //如果当前进程已经被杀死
      exit();             //退出
    myproc()->tf = tf;    //当前进程的栈帧
    syscall();            //系统调用入口
    if(myproc()->killed)  //再次确认进程状态
      exit();
    return;        //返回
  }

如果向量号表示这是一个系统调用,则进行系统调用,这部分放在后面文章讲解。

switch(tf->trapno){
  case T_IRQ0 + IRQ_TIMER:     //时钟中断
    if(cpuid() == 0){
      acquire(&tickslock);
      ticks++;
      wakeup(&ticks);
      release(&tickslock);
    }
    lapiceoi();
    break;
  case T_IRQ0 + IRQ_IDE:  //磁盘中断
    ideintr();
    lapiceoi();
    break;
/*****************************/

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

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

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

中断退出程序

执行完 函数之后,回到汇编程序

# Call trap(tf), where tf=%esp
pushl %esp
call trap
addl $4, %esp

# Return falls through to trapret...
.globl trapret #中断返回退出
trapret:
popal
popl %gs
popl %fs
popl %es
popl %ds
addl $0x8, %esp # trapno and errcode
iret

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

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

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

栈中变化情况如下:

这里说明两点:

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

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

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

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

下面来看一看过程图:

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

栈的问题

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

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

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

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

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

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

这得从 说起, 中压入 ESP 的时候压入的是 执行到 的值吗非也,压入的是 执行 前的栈顶值,在执行 之前先将 ESP 的值保存到 ,当压入 ESP 的时候执行的时

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

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

关于 的伪码如下:

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

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

相关链接:

https://wiki.osdev.org/APIC#Local_APIC_configuration

https://wiki.osdev.org/IOAPIC

http://blog.chinaunix.net/uid-20499746-id-1663122.html

还有一些手册资料可在我的公众号后台获取


Linux阅码场 专业的Linux技术社区和Linux操作系统学习平台,内容涉及Linux内核,Linux内存管理,Linux进程管理,Linux文件系统和IO,Linux性能调优,Linux设备驱动以及Linux虚拟化和云计算等各方各面.
评论
  • 本文介绍Linux系统(Ubuntu/Debian通用)挂载exfat格式U盘的方法,触觉智能RK3562开发板演示,搭载4核A53处理器,主频高达2.0GHz;内置独立1Tops算力NPU,可应用于物联网网关、平板电脑、智能家居、教育电子、工业显示与控制等行业。修改对应的内核配置文件# 进入sdk目录cdrk3562_linux# 编辑内核配置文件vi./kernel-5.10/arch/arm64/configs/rockchip_linux_defconfig注:不清楚内核使用哪个defc
    Industio_触觉智能 2024-12-10 09:44 90浏览
  • 概述 通过前面的研究学习,已经可以在CycloneVGX器件中成功实现完整的TDC(或者说完整的TDL,即延时线),测试结果也比较满足,解决了超大BIN尺寸以及大量0尺寸BIN的问题,但是还是存在一些之前系列器件还未遇到的问题,这些问题将在本文中进行详细描述介绍。 在五代Cyclone器件内部系统时钟受限的情况下,意味着大量逻辑资源将被浪费在于实现较大长度的TDL上面。是否可以找到方法可以对此前TDL的长度进行优化呢?本文还将探讨这个问题。TDC前段BIN颗粒堵塞问题分析 将延时链在逻辑中实现后
    coyoo 2024-12-10 13:28 101浏览
  • 近日,搭载紫光展锐W517芯片平台的INMO GO2由影目科技正式推出。作为全球首款专为商务场景设计的智能翻译眼镜,INMO GO2 以“快、准、稳”三大核心优势,突破传统翻译产品局限,为全球商务人士带来高效、自然、稳定的跨语言交流体验。 INMO GO2内置的W517芯片,是紫光展锐4G旗舰级智能穿戴平台,采用四核处理器,具有高性能、低功耗的优势,内置超微高集成技术,采用先进工艺,计算能力相比同档位竞品提升4倍,强大的性能提供更加多样化的应用场景。【视频见P盘链接】 依托“
    紫光展锐 2024-12-11 11:50 47浏览
  •         在有电流流过的导线周围会感生出磁场,再用霍尔器件检测由电流感生的磁场,即可测出产生这个磁场的电流的量值。由此就可以构成霍尔电流、电压传感器。因为霍尔器件的输出电压与加在它上面的磁感应强度以及流过其中的工作电流的乘积成比例,是一个具有乘法器功能的器件,并且可与各种逻辑电路直接接口,还可以直接驱动各种性质的负载。因为霍尔器件的应用原理简单,信号处理方便,器件本身又具有一系列的du特优点,所以在变频器中也发挥了非常重要的作用。  &nb
    锦正茂科技 2024-12-10 12:57 76浏览
  •         霍尔传感器是根据霍尔效应制作的一种磁场传感器。霍尔效应是磁电效应的一种,这一现象是霍尔(A.H.Hall,1855—1938)于1879年在研究金属的导电机构时发现的。后来发现半导体、导电流体等也有这种效应,而半导体的霍尔效应比金属强得多,利用这现象制成的各种霍尔元件,广泛地应用于工业自动化技术、检测技术及信息处理等方面。霍尔效应是研究半导体材料性能的基本方法。通过霍尔效应实验测定的霍尔系数,能够判断半导体材料的导电类型、载流子浓度及载流子
    锦正茂科技 2024-12-10 11:07 64浏览
  • 时源芯微——RE超标整机定位与解决详细流程一、 初步测量与问题确认使用专业的电磁辐射测量设备,对整机的辐射发射进行精确测量。确认是否存在RE超标问题,并记录超标频段和幅度。二、电缆检查与处理若存在信号电缆:步骤一:拔掉所有信号电缆,仅保留电源线,再次测量整机的辐射发射。若测量合格:判定问题出在信号电缆上,可能是电缆的共模电流导致。逐一连接信号电缆,每次连接后测量,定位具体哪根电缆或接口导致超标。对问题电缆进行处理,如加共模扼流圈、滤波器,或优化电缆布局和屏蔽。重新连接所有电缆,再次测量
    时源芯微 2024-12-11 17:11 70浏览
  • 智能汽车可替换LED前照灯控制运行的原理涉及多个方面,包括自适应前照灯系统(AFS)的工作原理、传感器的应用、步进电机的控制以及模糊控制策略等。当下时代的智能汽车灯光控制系统通过车载网关控制单元集中控制,表现特殊点的有特斯拉,仅通过前车身控制器,整个系统就包括了灯光旋转开关、车灯变光开关、左LED前照灯总成、右LED前照灯总成、转向柱电子控制单元、CAN数据总线接口、组合仪表控制单元、车载网关控制单元等器件。变光开关、转向开关和辅助操作系统一般连为一体,开关之间通过内部线束和转向柱装置连接为多,
    lauguo2013 2024-12-10 15:53 81浏览
  • 天问Block和Mixly是两个不同的编程工具,分别在单片机开发和教育编程领域有各自的应用。以下是对它们的详细比较: 基本定义 天问Block:天问Block是一个基于区块链技术的数字身份验证和数据交换平台。它的目标是为用户提供一个安全、去中心化、可信任的数字身份验证和数据交换解决方案。 Mixly:Mixly是一款由北京师范大学教育学部创客教育实验室开发的图形化编程软件,旨在为初学者提供一个易于学习和使用的Arduino编程环境。 主要功能 天问Block:支持STC全系列8位单片机,32位
    丙丁先生 2024-12-11 13:15 49浏览
  • 我的一台很多年前人家不要了的九十年代SONY台式组合音响,接手时只有CD功能不行了,因为不需要,也就没修,只使用收音机、磁带机和外接信号功能就够了。最近五年在外地,就断电闲置,没使用了。今年9月回到家里,就一个劲儿地忙着收拾家当,忙了一个多月,太多事啦!修了电气,清理了闲置不用了的电器和电子,就是一个劲儿地扔扔扔!几十年的“工匠式”收留收藏,只能断舍离,拆解不过来的了。一天,忽然感觉室内有股臭味,用鼻子的嗅觉功能朝着臭味重的方向寻找,觉得应该就是这台组合音响?怎么会呢?这无机物的东西不会腐臭吧?
    自做自受 2024-12-10 16:34 136浏览
  • 一、SAE J1939协议概述SAE J1939协议是由美国汽车工程师协会(SAE,Society of Automotive Engineers)定义的一种用于重型车辆和工业设备中的通信协议,主要应用于车辆和设备之间的实时数据交换。J1939基于CAN(Controller Area Network)总线技术,使用29bit的扩展标识符和扩展数据帧,CAN通信速率为250Kbps,用于车载电子控制单元(ECU)之间的通信和控制。小北同学在之前也对J1939协议做过扫盲科普【科普系列】SAE J
    北汇信息 2024-12-11 15:45 74浏览
  • RK3506 是瑞芯微推出的MPU产品,芯片制程为22nm,定位于轻量级、低成本解决方案。该MPU具有低功耗、外设接口丰富、实时性高的特点,适合用多种工商业场景。本文将基于RK3506的设计特点,为大家分析其应用场景。RK3506核心板主要分为三个型号,各型号间的区别如下图:​图 1  RK3506核心板处理器型号场景1:显示HMIRK3506核心板显示接口支持RGB、MIPI、QSPI输出,且支持2D图形加速,轻松运行QT、LVGL等GUI,最快3S内开
    万象奥科 2024-12-11 15:42 66浏览
  • 【萤火工场CEM5826-M11测评】OLED显示雷达数据本文结合之前关于串口打印雷达监测数据的研究,进一步扩展至 OLED 屏幕显示。该项目整体分为两部分: 一、框架显示; 二、数据采集与填充显示。为了减小 MCU 负担,采用 局部刷新 的方案。1. 显示框架所需库函数 Wire.h 、Adafruit_GFX.h 、Adafruit_SSD1306.h . 代码#include #include #include #include "logo_128x64.h"#include "logo_
    无垠的广袤 2024-12-10 14:03 69浏览
  • 全球知名半导体制造商ROHM Co., Ltd.(以下简称“罗姆”)宣布与Taiwan Semiconductor Manufacturing Company Limited(以下简称“台积公司”)就车载氮化镓功率器件的开发和量产事宜建立战略合作伙伴关系。通过该合作关系,双方将致力于将罗姆的氮化镓器件开发技术与台积公司业界先进的GaN-on-Silicon工艺技术优势结合起来,满足市场对高耐压和高频特性优异的功率元器件日益增长的需求。氮化镓功率器件目前主要被用于AC适配器和服务器电源等消费电子和
    电子资讯报 2024-12-10 17:09 84浏览
  • 习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习笔记&记录学习习笔记&记学习学习笔记&记录学习学习笔记&记录学习习笔记&记录学习学习笔记&记录学习学习笔记记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记
    youyeye 2024-12-10 16:13 105浏览
我要评论
0
点击右上角,分享到朋友圈 我知道啦
请使用浏览器分享功能 我知道啦