本文发自 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 执行以下流程:
- 根据向量号在 IDT 中找到对应的表项,即找到中断描述符
-
进行特权级检查
前文提到一个问题:我们能否通过 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 不会这样做。
-
切换到内核栈
这是通过 TSS(Task State Segment) 来实现的。它是 x86 架构特有的数据结构,用来描述当前 CPU 的任务信息,设计目的是用硬件实现上下文切换。但由于切换时强制保存一大堆寄存器,性能不好,同时这是 architecture-specific 的特性,不具备可移植性。为此 Linux 使用软件的方式来做上下文切换。
然而 TSS 在 x86 下是强制使用的,于是 Linux 只为每个 CPU 维护一个 TSS,根据手册,在发生特权级变化的上下文切换中,会加载 tss.esp0 到 esp 中, tss.ss0 到 ss 中,从而切换到内核栈。
-
切换到内核代码
根据中断描述符的 segment selector 在 GDT / LDT 中找到对应的段描述符,从段描述符拿到段的基址,加载到 cs 。将 offset 加载到 eip 。开始执行 handler 代码。
-
压栈
将当前上下文进行保存到内核栈,如果没有特权级的切换,无需进行栈切换,将 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://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
1F wellsleep 5 years, 2 months ago 回复
最近研究user-kernel权限提升时的软硬件状态变更流程到处找资料,多数只讲到int 0x80和TSS就下不去了。
多谢博主释疑。