本文发自 http://www.binss.me/blog/interrupt-and-exception/,转载请注明出处。

概述

在 CPU 的运行过程中,有两种特殊机制能够使 CPU 暂时放弃当前执行的代码,跳转到相应的处理例程上进行处理的事件。这两种机制就是 中断异常

这两种机制对于操作系统的运行十分重要,能够及时地让 CPU 响应突发事件,而不会对当前的执行流程造成负面影响。

然而在日常谈论中,我总是将 中断、异常,还有一些概念比如 trap 、fault、abort、call 混淆,分不清它们的关系到底是什么。因此通过一番查阅资料,补全知识。

中断

一般来说,中断分为两种。一种是外部中断,另一种是软件中断。在收到中断时,CPU 会把当前那条指令执行完,然后转移到中断处理程序,返回后执行的是当前的下一条指令。

外部中断

外部中断是外部硬件硬件给 CPU 发送的一种信号,比如说你按下了键盘的某一个按键,键盘控制器于是向 CPU 发送一个中断。

外部中断又分为 INTR(可屏蔽中断) 和 NMI(不可屏蔽中断) 。这两种中断的区别在于,当 CPU 设置 flags 寄存器的 IF bit 为 1 时,CPU 能够收到 INTR 中断,而当 IF bit 为 0 时,INTR 将被屏蔽。而 NMI 将不会受到影响。

在没有 APIC 的年代(如 PIC,即 8259A),CPU 有两个中断引脚,分别为 INTR(普通中断) 引脚 和 NMI(不可屏蔽中断) 引脚。当外部设备要发送中断时,拉一下引脚,引发电位变化,于是 CPU 收到中断。然后 CPU 到系统总线上读取中断芯片(如 8259A)提供的中断向量号,然后调用中断向量号对应的中断处理函数。

在 APIC 年代,CPU 还是有两个中断引脚,只是这时候被称为 LINT0 和 LINT1 。如果启用了 LAPIC ,则这两个引脚被用于 APIC LVT(Local Vector Table):

可以看到,当 LVT LINT0 Register 和 LVT LINT1 Register 定义了这两个引脚的行为。

当然,在刚开机时,LAPIC 还没启用,此时进入 legacy 模式,LINT0 被配置为 INTR 引脚,而 LINT1 被配置为 NMI 引脚。

软件中断

指的是通过 INT n 指令触发的中断。比如 INT 0x80 会触发一个向量号为 0x80 的软件中断。

那我们能否通过 INT n 指令来干坏事?比如通过代码触发一个外部中断?嘿嘿,在 Linux 下是不可行的,它通过 gate 来进行限制,这点下文会提到。

注意软件中断(Software-Generated Interrupt) 和 软中断(softirq) 不是一个东西,后者是 Linux 中定义的一种下半部执行方式。

异常

异常是在 CPU 执行指令期间发生一些错误情况。比如说除 0 异常,ALU 发现除 0 ,于是触发一个异常。再比如说 page fault,MMU 发现当前虚拟地址没有对应的物理地址,于是触发一个异常。

异常可以细分为以下三种类型:

  • trap

    执行引起陷阱的指令后产生的异常,处理后返回原程序从下一条指令开始继续执行。如 syscall 。

    按照我的理解,软件中断算是一种 trap,如 INT 3 产生断点异常。

  • fault

    有可能能够恢复,处理后返回原程序原指令继续执行(产生 fault 指令会重新执行)。如:Page Fault,General Protection

  • abort

    不可恢复。不会获得产生异常的精确位置,也不允许异常程序继续执行。中断处理程序通常用于记录信息,处理后不会返回,而是关闭程序或系统。如:Machine Check ,Double Fault

    P.S. Triple Fault 不属于 abort 异常的范畴,它没有对应的中断向量号,而是直接挂掉

处理

在异常 / 中断发生时,CPU 需要进行相应处理。这就需要一张描述收到什么中断 / 异常时执行什么行为的表,称为中断描述符表(IDT, Interrupt Descriptor Table)。每项被称为 gate ,一共有 256 项。

IDTR 寄存器存放了 IDT 的起始地址和长度:

查询时,从 IDTR 拿到 base address ,加上向量号 * IDT entry size,即可以定位到对应的表项(gate)。

32bit

在 32 位下,IDT 的一个表项长为 8 bytes,表项(gate)中主要包含 segment selector 、 offset 和 DPL 。segment selector 和 offset 用于找到处理异常 / 中断的 handler ,而 DPL 全称 Descriptor Privilege Level,用于权限控制。

在收到一个中断 / 异常后,CPU 执行以下流程:

  1. 根据向量号在 IDT 中找到对应的表项,即找到中断描述符
  2. 进行特权级检查

    前文提到一个问题:我们能否通过 INT n 指令来干坏事?比如通过代码触发一个外部中断?这种行为正是 DPL 所要限制的。对于外部中断和异常,在 Linux 中都是通过 set_intr_gate 来设置的,DPL 为 0。在当前版本中虽然保留了该函数,但绝大部分都是通过 idt_data 进行初始化的,由于 idt_data 通过 INTG 宏来定义,因此 DPL 也是 0。

    DPL 为 0 意味着不可能在用户态通过 INT n 的形式来触发外部中断和异常的 handler ,因为用户态 CPL(Current Privilege Level) 为 3,DPL < CPL,权限检查不通过,产生一个 general-protection(#GP) 异常。而对于允许使用的 INT n ,比如说 INT 0x80 、INT 0x3 来说,它们通过 set_system_intr_gate 来设置,DPL 为 3。在当前版本中 idt_data 通过 SYSG 宏来定义,因此 DPL 也是 3。这样 DPL >= CPL ,于是允许调用。

    我们特意淡化 gate 的概念,根据手册,允许用户态调用的 IDT 表项,称为 call gate 。处理中断的门,称为 interrupt gate ,处理 trap 的 IDT 表项,称为 trap gate。还有更为特殊的 task gate,在处理中断和异常时,将当前上下文保存到 TSS 寄存器中,然后切换到一个新的 task 上去处理,Linux 中并没有使用该机制。感兴趣可以阅读 Intel SDM 3 6.11 + 6.14 。但值得注意的是,为了防止中断重入,interrupt gate 在执行时会清掉 eflags 寄存器的 IF bit,而 trap gate 不会这样做。

  3. 切换到内核栈

    这是通过 TSS(Task State Segment) 来实现的。它是 x86 架构特有的数据结构,用来描述当前 CPU 的任务信息,设计目的是用硬件实现上下文切换。但由于切换时强制保存一大堆寄存器,性能不好,同时这是 architecture-specific 的特性,不具备可移植性。为此 Linux 使用软件的方式来做上下文切换。

    然而 TSS 在 x86 下是强制使用的,于是 Linux 只为每个 CPU 维护一个 TSS,根据手册,在发生特权级变化的上下文切换中,会加载 tss.esp0 到 esp 中, tss.ss0 到 ss 中,从而切换到内核栈。

  4. 切换到内核代码

    根据中断描述符的 segment selector 在 GDT / LDT 中找到对应的段描述符,从段描述符拿到段的基址,加载到 cs 。将 offset 加载到 eip 。开始执行 handler 代码。

  5. 压栈

    将当前上下文进行保存到内核栈,如果没有特权级的切换,无需进行栈切换,将 eflags / cs / ip / error code 依次压栈。如果发生了特权级的切换,需要将原来的 ss / sp / eflags / cs / ip / error code 依次压到里面(硬件实现)。error code 用于向 handler 传递相关信息,比如对于 page fault handler 来说,error code 定义如下:

执行 handler 之后,通过 iret 返回,依次将当前栈上的内容设置到相应的寄存器,恢复用户态上下文:

64bit

在 64 位下,情况和 32 位有了一些区别。最明显的区别是 IDT 表项变为 16 bytes 了:

可以发现 byte 4-7 的 bit 0-4 由原来的 reserved 变成了 IST(Interrupt Stack Table),而 offset 在 64 位下需要扩展为 64 bit,因此 byte 8-11 将保存 offset 的 bit 32-63 。

IST 是 64 位引入的新的栈切换机制。在收到中断 / 异常时,如果中断对应的 IDT 表项中 IST 字段非 0,则硬件会自动切换到对应的中断栈(中断栈的指针存放在 TSS 中,被加载到 rsp)。IST 最多有 7 项,它们指向的中断栈的大小都可以不同。目前实现的栈有:

  • DOUBLEFAULT_STACK:专门用于 Double Fault Exception ,因为 double fault 时不应该再用原来的中断栈。大小为 EXCEPTION_STKSZ
  • NMI_STACK:专门用于不可屏蔽中断,因为 NMI 可能在任意时刻到来,如果此时正在切换栈则会引起混乱。大小为 EXCEPTION_STKSZ
  • DEBUG_STACK:专门用于 debug 中断,因为 debug 中断可能在任意时刻到来。大小为 DEBUG_STKSZ
  • MCE_STACK:专门用于 Machine Check Exception ,因为 MCE 中断可能在任意时刻到来。大小为 EXCEPTION_STKSZ

此外在 32 位下,会根据有没有特权级切换决定是否压 ss 和 sp,而在 64 位下无论如何都会压。这样一来,保证了所有中断和异常的栈帧(stackframe)都是一样大的。在 iret 时也不必进行区分,都弹出相同数量的寄存器值。

代码分析

首先找到中断向量表 idt_table 。不同于之前版本通过 set_intr_gate 一个个设置,当前版本 kernel (patch x86/idt: Move regular trap init to tables 引入) 直接定义了一些默认的 IDT entry data ,然后通过 idt_setup_from_table 来批量设置:

static inline void native_write_idt_entry(gate_desc *idt, int entry, const gate_desc *gate)
{
    memcpy(&idt[entry], gate, sizeof(*gate));
}

#define write_idt_entry(dt, entry, g)       native_write_idt_entry(dt, entry, g)

static inline void idt_init_desc(gate_desc *gate, const struct idt_data *d)
{
    unsigned long addr = (unsigned long) d->addr;

    gate->offset_low    = (u16) addr;
    gate->segment       = (u16) d->segment;
    gate->bits      = d->bits;
    gate->offset_middle = (u16) (addr >> 16);
#ifdef CONFIG_X86_64
    gate->offset_high   = (u32) (addr >> 32);
    gate->reserved      = 0;
#endif
}

static void
idt_setup_from_table(gate_desc *idt, const struct idt_data *t, int size, bool sys)
{
    gate_desc desc;

    for (; size > 0; t++, size--) {
        idt_init_desc(&desc, t);
        write_idt_entry(idt, t->vector, &desc);
        if (sys)
            set_bit(t->vector, used_vectors);
    }
}

idt_setup_from_table 创建一个新的 gate_desc,根据传入的 idt_data 进行设置,然后通过 memcpy 拷到 idt_table 中。

对于 trap gate,在

start_kernel => setup_arch
             => trap_init

有设置:

=> idt_setup_early_pf => idt_setup_from_table(idt_table, early_pf_idts, ARRAY_SIZE(early_pf_idts), true)   用 early_pf_idts 初始化 idt_table
=> idt_setup_traps => idt_setup_from_table(idt_table, def_idts, ARRAY_SIZE(def_idts), true)       用 def_idts 初始化 idt_table
=> idt_setup_ist_traps => idt_setup_from_table(idt_table, ist_idts, ARRAY_SIZE(ist_idts), true)   用 ist_idts 初始化 idt_table

对于 interrupt gate ,在 start_kernel => init_IRQ => x86_init.irqs.intr_init (native_init_IRQ) 中设置:

=> idt_setup_apic_and_irq_gates => idt_setup_from_table(idt_table, apic_idts, ARRAY_SIZE(apic_idts), true)  用 apic_idts 初始化 idt_table
                                => 如果开启了 LAPIC,对于先前未设置过的向量号,通过 set_intr_gate 在 idt_table 设置 handler 为 spurious_interrupt

idt_table 被包装为中断描述符,然后设置到 IDTR ,于是在每个 CPU 执行 load_current_idt 时会将 idt_descr 地址加载到 IDTR,使之生效:

struct desc_ptr idt_descr __ro_after_init = {
    .size       = (IDT_ENTRIES * 2 * sizeof(unsigned long)) - 1,
    .address    = (unsigned long) idt_table,
};

static inline void load_current_idt(void)
{
    if (is_debug_idt_enabled())
        load_debug_idt();
    else
        load_idt((const struct desc_ptr *)&idt_descr);
}

前文提到,在收到一个中断 / 异常后,CPU 会先进行寄存器压栈操作,然后根据向量号在 LDT 中找到对应的表项,然后根据它的 segment selector 在 GDT / LDT 中找到对应的段描述符,从段描述符拿到段的基址,加上偏移地址 offset ,得到 handler 代码段的地址,开始执行 handler 代码。

以 Local timer 中断为例,其在 apic_idts 的定义为 INTG(LOCAL_TIMER_VECTOR, apic_timer_interrupt),这里 apic_timer_interrupt 只是它的符号,实际上的定义在 entry_64.S 中:

apicinterrupt LOCAL_TIMER_VECTOR    apic_timer_interrupt    smp_apic_timer_interrupt

可以发现它由 apicinterrupt 宏定义,而 apicinterrupt 又由 apicinterrupt3 宏定义,其中又涉及了 interrupt 宏:

.macro apicinterrupt num sym do_sym
PUSH_SECTION_IRQENTRY
apicinterrupt3 \num \sym \do_sym
POP_SECTION_IRQENTRY
.endm

.macro apicinterrupt3 num sym do_sym
ENTRY(\sym)
  UNWIND_HINT_IRET_REGS
  ASM_CLAC
  pushq $~(\num)
.Lcommon_\sym:
  interrupt \do_sym
  jmp ret_from_intr
END(\sym)
.endm


  .macro interrupt func
  cld

  testb $3, CS-ORIG_RAX(%rsp)
  jz  1f
  SWAPGS
  call  switch_to_thread_stack
1:

  PUSH_AND_CLEAR_REGS
  ENCODE_FRAME_POINTER

  testb $3, CS(%rsp)
  jz  1f

  /*
   * IRQ from user mode.
   *
   * We need to tell lockdep that IRQs are off.  We can't do this until
   * we fix gsbase, and we should do it before enter_from_user_mode
   * (which can take locks).  Since TRACE_IRQS_OFF idempotent,
   * the simplest way to handle it is to just call it twice if
   * we enter from user mode.  There's no reason to optimize this since
   * TRACE_IRQS_OFF is a no-op if lockdep is off.
   */
  TRACE_IRQS_OFF

  CALL_enter_from_user_mode

1:
  ENTER_IRQ_STACK old_rsp=%rdi
  /* We entered an interrupt context - irqs are off: */
  TRACE_IRQS_OFF

  call  \func /* rdi points to pt_regs */
  .endm

也就是说对于 LOCAL_TIMER_VECTOR 号中断,实际上执行的是由 apicinterrupt - apicinterrupt3 - interrupt 包装的 smp_apic_timer_interrupt 。

因此在执行 smp_apic_timer_interrupt 之前,会执行以下操作:

=> 将向量号 (LOCAL_TIMER_VECTOR) 压栈
=> 判断发生中断时是否是在用户态,如果是,执行 SWAPGS 也就是 swapgs 指令,将 gs 和 IA32_KERNEL_GS_BASE MSR 中的内容进行交换。然后调用 switch_to_thread_stack
    => 将 rdi 压栈,因为后续会修改 rdi
    => SWITCH_TO_KERNEL_CR3 scratch_reg=%rdi    KPTI 引入,用户态切换到内核态需要换页表,更新 cr3 。scratch_reg 用于指定可以不必保存直接改动的寄存器
    => 将 rsp 保存到 rdi
    => 将 PER_CPU_VAR(cpu_current_top_of_stack) 加载到 rsp ,切换到 thread stack
    => 由于我们切换了栈,为了能够在后续使用压栈的寄存器,需要倒腾一番:依次将原栈(rdi)上的 ss / rsp / eflags / cs / ip / error code / 返回地址 压到新栈上
    => 恢复 rdi 为原先压栈的值
=> 用户态还需要执行 CALL_enter_from_user_mode => enter_from_user_mode
=> 如果发生中断时在内核态,直接跳过前两步到达此处
=> ENTER_IRQ_STACK old_rsp=%rdi    切换到中断栈,将原来的栈指针保存到 irq_stack 的栈顶,同时设置到 rdi
=> 调用被包装函数 (如 smp_apic_timer_interrupt),根据函数传参规则,rdi 将作为第一个参数,因此在函数内可通过第一个参数(pt_regs)访问到压栈内容
=> ret_from_intr       根据发生中断时是否在用户态分别执行 retint_user / retint_kernel
    => 对于 retint_user ,为了能够使用 iret 返回,我们需要切换到临时栈 PER_CPU_VAR(cpu_tss_rw + TSS_sp0) ,并根据 PER_CPU_VAR(cpu_current_top_of_stack) 依次压入 ss / rsp / eflags / cs / ip ,然后调用 SWITCH_TO_USER_CR3_STACK 更新 cr3 切换回用户态页表,swapgs 交换回之前的 gs 。最后执行 iret
    => 对于 retint_kernel ,直接通过 rsp += 8 跳过先前压入的 error code ,然后执行 iret 。

于是我们返回到了 中断 / 异常 前执行的代码,继续往下执行。

类似的,对于异常的 handler 来说,也是通过了一番包装,比如对于 X86_TRAP_PF,实际上执行的是由 idtentry - sym 包装的 do_page_fault 。这里不再分析了。

参考

https://stackoverflow.com/questions/40583848/differences-among-various-interrupts-sci-smi-nmi-and-normal-interrupt

https://www.kernel.org/doc/Documentation/x86/kernel-stacks

Intel SDM 2 Chapter 3

Intel SDM 3 Chapter 6

Linux kernel 4.14.33 Source Code