本篇是《Linux内核学习笔记》系列的第五篇。
现代计算机带有众多的外部设备,它们需要与内核相互通信。但是外设的速度和处理器通常不在一个数量级,如果要求内核请求外设后主动等待其任务完成,会浪费相当多的时间,因此希望在外设执行的过程中,内核同时处理一些别的事情。
那么内核如何知道外设是否已经完成了任务呢?一种办法是轮询,但是如果内核多次轮询都得到了设备未完成的结果,这是一件效率低下的事情。最好的办法是让外设主动提醒内核它已完成任务,即向内核发出中断。
中断的预处理代码是内核的入口,不仅频繁执行,还需要对栈和寄存器进行各种精确操作,唯有高效、细致入微的汇编语言才能堪此重任。
异常和中断的概念特别重要,仅靠学习Linux内核处理方式还不够,本文会结合指令集体系结构(主要是IA-32)一并讲述。
1. 异常和中断简述
1.1. 中断
中断是一种电信号,它由硬件设备生成。
中断包括可屏蔽和不可屏蔽(NMI)两种。可屏蔽中断一般来自I/O设备,它产生后被送入中断控制器芯片。后者会根据配置,选择屏蔽中断信号,或者通知处理器有中断到来,要求处理器处理。处理器简单保存现场后,就会进入操作系统内核,开始进一步处理。 不可屏蔽中断会直接通过NMI线传到处理器,处理器必须尽快处理此类中断,通常有电源掉电、硬件线路故障等。
由于不同的设备可能发起不同的中断,需要将它们区分开来,所以可将中断用一些数字编号区分开来,称为中断请求(IRQ)线。它们可以静态或动态配置。
1.2. 异常
异常往往是在CPU执行特定指令过程中产生的,例如除0、缺页,等等。异常也有不同的编号,可以像中断一样处理。
1.3. 响应异常和中断
1.3.1. 保护断点
异常或中断处理完之后可能还要回到原程序执行,所以需要保存断点处的程序信息,例如标志信息、下一条指令寄存器等。这一过程通常由CPU完成,以保证效率。
1.3.2. 关中断
如果中断处理程序在保存现场的过程中产生了新的中断,一些还没来得及被保存的信息可能会被破坏。因此,必须在此过程中屏蔽外部中断。CPU在响应异常和中断时会自动关中断,以防可屏蔽中断干扰;同时,IA-32也提供了cli或sti指令,用于关中断或开中断。
1.3.3. 识别和跳转至对应异常或中断处理程序
异常的响应非常简单,它是同步发生的,与CPU的指令执行天然对应。中断的响应则稍显麻烦,因为它是异步的,与当前的指令无关,因此CPU需要在每一次取指令前都检查中断引脚上是否有有效值,有的话则处理中断。
异常和中断源的识别有两种方式。一种是软件识别,CPU上设一个cause寄存器,由内存中固定位置的查询程序进行检查,并跳转至相应的处理程序,这是MIPS的做法。另一种是硬件识别,内存中存放有中断向量表,与相应的中断向量对应,CPU就可以直接计算出对应处理程序的地址,这是IA-32的做法。
1.4. 中断处理上下半部
中断处理要很快才行,因为该过程已经关中断,也就是说整个系统都在等待这段代码执行完成。然而考虑这一种情况:我们通过高速网线和网卡下载一些大文件,因此每秒都有数千万甚至数亿字节进入网卡。网卡缓存相比于内存来说很小,不能等太长时间,否则后续来到的数据会被丢弃,因此内核中断处理程序要将到来的字节迅速拷贝至内存,并进行处理,听起来工作量不小。
等等,不是说整个系统都在等中断处理结束吗?这样子的话时间会不会太长了点?
我们可以看出,中断处理既要完成得快,又要完成得多,这是从根本上矛盾的事情。但好在上例的中断处理不一定要始终保持关中断,比如处理数据时容许被打断。为此,中断处理以开中断为界,分成上下两部分。上半部完成紧要的工作,而后开中断,下半部则会进入调度,因为它没那么着急。
2. 中断处理机制的数据结构
2.1. 实地址模式:IA-32中断向量表
中断向量表存放在物理内存中固定的位置,其每一个表项被称为“中断向量”。中断向量占4字节,包括高16位的段地址和低16位的偏移地址,它所表示的地址指向的是中断服务程序。这些程序由BIOS提供。
2.2. 保护模式:IA-32中断描述符表
实地址模式只提供了最为基础的功能,例如打印字符、读取磁盘扇区等,现代计算机一般只在刚开机时才在实地址模式中。一旦点亮CR0寄存器的保护位,就会打开保护模式,内核会靠更加强大的中断描述符表实现丰富的中断功能。
中断描述符表共有256个表项,其中前32个由处理器保留使用,后面的224个由用户(即操作系统编写者)自定义。例如,Linux将第128号中断设定为系统调用。中断描述符表的位置和限界存放在IDTR寄存器中,该寄存器可由lidt和sidt指令进行存取。

IDT门描述符包含三种:中断门、陷阱门和任务门。其格式如下:

在内核代码中,门描述符gate_desc的定义如下,与上表相对应:
enum {
GATE_INTERRUPT = 0xE,
GATE_TRAP = 0xF,
GATE_CALL = 0xC,
GATE_TASK = 0x5,
};
struct idt_bits {
u16 ist : 3,
zero : 5,
type : 5,
dpl : 2,
p : 1;
} __attribute__((packed));
struct gate_struct {
u16 offset_low;
u16 segment;
struct idt_bits bits;
u16 offset_middle;
#ifdef CONFIG_X86_64
u32 offset_high;
u32 reserved;
#endif
} __attribute__((packed));
typedef struct gate_struct gate_desc;
门描述符中的segment(或selector)表示中断或异常处理程序所在段的段描述符在段描述符表的位置。offset表示中断或异常处理程序第一条指令的偏移量。dpl表示门描述符的特权级,0为内核级,3为用户级。
如下是Linux中断描述符表的定义:
/* Must be page-aligned because the real IDT is used in the cpu entry area */
static gate_desc idt_table[IDT_ENTRIES] __page_aligned_bss;
2.3. Linux中描述符和描述符表的实现
2.3.1. Linux 2.6定义的描述符
- 中断门,类型为中断门,DPL=0。所有中断处理程序从这里进入。
- 系统中断门,类型为中断门,DPL=3。它对应且只对应一个指令,
int 3。 - 系统门,类型为陷阱门,DPL=3。它对应于系统调用、
into和bound指令。 - 陷阱门,类型为陷阱门,DPL=0。是大部分异常处理程序的入口。
- 任务门,类型为任务门,DPL=0。是双重错误异常处理程序的入口。
2.3.2. Linux 5.10定义的描述符
现在已经是5.x的时代了!我们来看看新时代的描述符是什么样的。
/* Interrupt gate */
#define INTG(_vector, _addr) \
G(_vector, _addr, DEFAULT_STACK, GATE_INTERRUPT, DPL0, __KERNEL_CS)
/* System interrupt gate */
#define SYSG(_vector, _addr) \
G(_vector, _addr, DEFAULT_STACK, GATE_INTERRUPT, DPL3, __KERNEL_CS)
/*
* Interrupt gate with interrupt stack. The _ist index is the index in
* the tss.ist[] array, but for the descriptor it needs to start at 1.
*/
#define ISTG(_vector, _addr, _ist) \
G(_vector, _addr, _ist + 1, GATE_INTERRUPT, DPL0, __KERNEL_CS)
/* Task gate */
#define TSKG(_vector, _gdt) \
G(_vector, NULL, DEFAULT_STACK, GATE_TASK, DPL0, _gdt << 3)
仔细看,前三种门都是中断门,最后一种是任务门。没有陷阱门。
- 中断门
INTG,DPL=0,包含很多门,涵盖了2.6中的大部分中断门、所有陷阱门、任务门(在未启用X86_32时)和系统门中的bound。 - 系统门
SYSG,DPL=3,涵盖了系统中断门、系统门中的系统调用和into。 - 带中断栈的中断门
ISTG,DPL=0,它们对应于2.6中的部分中断门。 - 任务门
TSKG,DPL=0,仅在启用X86_32宏时才会被用到。
ISTG包括:(笔者的内核配置只启用了其中的前三个门)
/*
* The exceptions which use Interrupt stacks. They are setup after
* cpu_init() when the TSS has been initialized.
*/
static const __initconst struct idt_data ist_idts[] = {
ISTG(X86_TRAP_DB, asm_exc_debug, IST_INDEX_DB),
ISTG(X86_TRAP_NMI, asm_exc_nmi, IST_INDEX_NMI),
ISTG(X86_TRAP_DF, asm_exc_double_fault, IST_INDEX_DF),
#ifdef CONFIG_X86_MCE
ISTG(X86_TRAP_MC, asm_exc_machine_check, IST_INDEX_MCE),
#endif
#ifdef CONFIG_AMD_MEM_ENCRYPT
ISTG(X86_TRAP_VC, asm_exc_vmm_communication, IST_INDEX_VC),
#endif
};
同时,我们刚才提到,into的异常处理程序由系统门触发,换言之,笔者猜测用户可以通过int 0x4来触发它。为了验证这个想法是否正确,我们来看一下into产生的异常#OF的处理程序:
DEFINE_IDTENTRY(exc_overflow)
{
do_error_trap(regs, 0, "overflow", X86_TRAP_OF, SIGSEGV, 0, NULL);
}
它无情地发送一个SIGSEGV就不管了。也就是说,用户执行int 0x4时,就会得到一个段错误。读者可自行编写代码进行验证。
但是,用户尝试非法执行其他的int n时,会触发#GP异常,一样收到SIGSEGV,产生段错误,我们不知道执行int 0x4时产生的段错误是由通用保护错产生的,还是由内核异常处理程序发出的。因此,我们将上面异常处理程序的代码注释掉,就像这样:
DEFINE_IDTENTRY(exc_overflow)
{
// do_error_trap(regs, 0, "overflow", X86_TRAP_OF, SIGSEGV, 0, NULL);
}
此时如果int 0x4触发#OF异常后,程序返回正常的控制流执行,就说明猜想正确。事实证明了笔者的猜想,即int 4是一个可在用户态执行的指令,像int 3、int 0x80那样,可以敲开内核的大门。(尽管这个指令属于是开门大寄

3. 保护模式下CPU对异常和中断的响应
- CPU确定中断或异常号
i,并取出IDT的第i个表项。 - 根据该表项中的段选择符,找到中断/异常处理程序的段描述符。
- 确保当前CPU的特权级不高于处理程序的特权级,即CPL不小于段描述符DPL。因为如果处理程序的特权级更低,那么它实际上是不可信的代码,因而不应当被执行。好在Linux中所有处理程序都位于内核段,所以CPU的特权级不可能比其更高,这种情况不会发生。
- 对于陷阱指令引起的异常,即编程异常,还要确保CPU的特权级不能低于中断门的特权级,即CPL不大于门描述符DPL。否则,来自不可信程序的参数会通过中断进入内核,可能执行恶意操作。Linux中,这种手段可以阻止用户态程序模拟非法异常,通过陷阱门进入内核,但正常情况产生的异常无需经过这一判断,所以可以通过陷阱门。
- 在Linux中,如果用户态下触发中断/异常,那么一定需要切换到内核态。此时,CPU会从TR寄存器获取TSS段的地址,再从后者拿到内核栈的栈段选择符和栈指针,装填到
SS:ESP,此时就完成了栈的切换。然后,拿到用户栈的栈段选择符和栈指针,保存到内核栈。 - 如果产生的是故障,那么向
CS:EIP装入当前逻辑地址,以期故障处理结束后能够重新执行产生故障的指令。 - 向当前栈中(在Linux中就是内核栈)保存
EFLAGS、CS、EIP的内容,以供结束中断处理时,iret指令从中恢复断点和程序状态。 - 如果异常产生了硬件出错码,将其保存到内核栈中。否则,保存一个无效值即可。
- 将
CS:EIP指向处理程序逻辑地址,接下来CPU就处于最高特权级,正式进入内核!
4. 保护模式下Linux对异常和中断的响应
4.1. 异常处理
所有的异常号都存放在如下文件中:
/* Interrupts/Exceptions */
#define X86_TRAP_DE 0 /* Divide-by-zero */
#define X86_TRAP_DB 1 /* Debug */
#define X86_TRAP_NMI 2 /* Non-maskable Interrupt */
#define X86_TRAP_BP 3 /* Breakpoint */
#define X86_TRAP_OF 4 /* Overflow */
#define X86_TRAP_BR 5 /* Bound Range Exceeded */
#define X86_TRAP_UD 6 /* Invalid Opcode */
#define X86_TRAP_NM 7 /* Device Not Available */
#define X86_TRAP_DF 8 /* Double Fault */
#define X86_TRAP_OLD_MF 9 /* Coprocessor Segment Overrun */
#define X86_TRAP_TS 10 /* Invalid TSS */
#define X86_TRAP_NP 11 /* Segment Not Present */
#define X86_TRAP_SS 12 /* Stack Segment Fault */
#define X86_TRAP_GP 13 /* General Protection Fault */
#define X86_TRAP_PF 14 /* Page Fault */
#define X86_TRAP_SPURIOUS 15 /* Spurious Interrupt */
#define X86_TRAP_MF 16 /* x87 Floating-Point Exception */
#define X86_TRAP_AC 17 /* Alignment Check */
#define X86_TRAP_MC 18 /* Machine Check */
#define X86_TRAP_XF 19 /* SIMD Floating-Point Exception */
#define X86_TRAP_VE 20 /* Virtualization Exception */
#define X86_TRAP_CP 21 /* Control Protection Exception */
#define X86_TRAP_VC 29 /* VMM Communication Exception */
#define X86_TRAP_IRET 32 /* IRET Exception */
回到idt.c。让我们随便展开一个INTG宏,例如:
INTG(X86_TRAP_DE, asm_exc_divide_error),
展开以后得到:
{
.vector = 0,
.bits.ist = 0,
.bits.type = GATE_INTERRUPT,
.bits.dpl = 0x0,
.bits.p = 1,
.addr = asm_exc_divide_error,
.segment = (2 * 8),
},
可以看到addr项,它的值asm_exc_divide_error就是异常处理程序的入口。该函数由下面的宏声明:
DECLARE_IDTENTRY(X86_TRAP_DE, exc_divide_error);
这是展开后的内容:
void asm_exc_divide_error(void);
void xen_asm_exc_divide_error(void);
void exc_divide_error(struct pt_regs *regs);
我们来看异常的入口函数里到底有什么。以asm_exc_divide_error为例,笔者的机器上,反汇编可重定位文件得到的结果如下:
0000000000000860 <asm_exc_divide_error>:
860: 90 nop
861: 90 nop
862: 90 nop
863: 6a ff pushq $0xffffffffffffffff
865: e8 16 08 00 00 callq 1080 <error_entry>
86a: 48 89 e7 mov %rsp,%rdi
86d: e8 00 00 00 00 callq 872 <asm_exc_divide_error+0x12>
872: e9 e9 08 00 00 jmpq 1160 <error_return>
877: 66 0f 1f 84 00 00 00 nopw 0x0(%rax,%rax,1)
87e: 00 00
第一步将一个八字节数压入栈,后面我们会分析它是什么。
第二步调用error_entry。它的内容很多,这里先不仔细列出,我们只需看源代码中的注释:
/*
* Save all registers in pt_regs, and switch GS if needed.
*/
SYM_CODE_START_LOCAL(error_entry)
...
第三步是一条神奇的指令mov %rsp,%rdi。它向接下来调用的函数传入一个参数。
第四步调用的应该就是异常处理函数exc_divide_error,这里不再通过查看重定位符号表来验证。上面我们已经有了它的原型,它的参数是一个pt_regs指针,来看看它是什么:
struct pt_regs {
/*
* C ABI says these regs are callee-preserved. They aren't saved on kernel entry
* unless syscall needs a complete, fully filled "struct pt_regs".
*/
unsigned long r15;
unsigned long r14;
unsigned long r13;
unsigned long r12;
unsigned long bp;
unsigned long bx;
/* These regs are callee-clobbered. Always saved on kernel entry. */
unsigned long r11;
unsigned long r10;
unsigned long r9;
unsigned long r8;
unsigned long ax;
unsigned long cx;
unsigned long dx;
unsigned long si;
unsigned long di;
/*
* On syscall entry, this is syscall#. On CPU exception, this is error code.
* On hw interrupt, it's IRQ number:
*/
unsigned long orig_ax;
/* Return frame for iretq */
unsigned long ip;
unsigned long cs;
unsigned long flags;
unsigned long sp;
unsigned long ss;
/* top of stack page */
}
说明error_entry在栈中产生了一个结构体!刚才第三步pushq进去的,就是结构体的地址。IA-32中,栈总是倒着生长的,所以push进去的是低地址,这是结构体头的起始位置。那么error_entry需要从结构体后面的成员开始push才行,一次存进sizeof(unsigned long)=8个字节。事实证明确实如此:
0000000000001080 <error_entry>:
1080: fc cld
1081: 56 push %rsi
1082: 48 8b 74 24 08 mov 0x8(%rsp),%rsi
1087: 48 89 7c 24 08 mov %rdi,0x8(%rsp)
108c: 52 push %rdx
108d: 51 push %rcx
108e: 50 push %rax
108f: 41 50 push %r8
1091: 41 51 push %r9
1093: 41 52 push %r10
1095: 41 53 push %r11
1097: 53 push %rbx
1098: 55 push %rbp
1099: 41 54 push %r12
109b: 41 55 push %r13
109d: 41 56 push %r14
109f: 41 57 push %r15
10a1: 56 push %rsi
结合这些,我们就可以推出来:pt_regs的orig_ax,由第一步的pushq填充,代表异常出错码。
那么结构体的最后五个成员是怎么来的呢?别忘记CS:RIP跳转到异常处理程序之前发生了什么,这部分内容在CPU发起中断/异常的时候就填充好了!
第五步,也是最后一步,跳转到error_return。返回有两种可能:一种是返回到内核,另一种是返回到用户空间。这取决于控制流是从哪里来的,体现在栈中保存的CS寄存器最后两位的值。段选择符的格式此处不再展开讲述。
0000000000001160 <error_return>:
1160: f6 84 24 88 00 00 00 testb $0x3,0x88(%rsp)
1167: 03
1168: 0f 84 02 fd ff ff je e70 <restore_regs_and_return_to_kernel>
116e: e9 7d fc ff ff jmpq df0 <__irqentry_text_end>
1173: 66 66 2e 0f 1f 84 00 data16 nopw %cs:0x0(%rax,%rax,1)
117a: 00 00 00 00
117e: 66 90 xchg %ax,%ax
error_return做和error_entry相反的事情,将寄存器值从栈中恢复,最后通过一个iretq结束异常处理。
4.2. 系统调用处理
系统调用虽然也是是编程异常,但它和into这种产生的异常处理方式又不一样。使用int 0x80,在保存现场之后,它进入另外一个函数do_int80_syscall_32。
/* Handles int $0x80 */
__visible noinstr void do_int80_syscall_32(struct pt_regs *regs)
{
unsigned int nr = syscall_32_enter(regs);
/*
* Subtlety here: if ptrace pokes something larger than 2^32-1 into
* orig_ax, the unsigned int return value truncates it. This may
* or may not be necessary, but it matches the old asm behavior.
*/
nr = (unsigned int)syscall_enter_from_user_mode(regs, nr);
instrumentation_begin();
do_syscall_32_irqs_on(regs, nr);
instrumentation_end();
syscall_exit_to_user_mode(regs);
}
除了这个函数之外,还有很多功能类似的处理函数,例如处理sysenter、syscall……等等。它们的实现思路都差不多,主要是先解决一些指令体系结构相关的问题,但最后殊途同归,从系统调用映射表中找到处理句柄,进入同一个函数。比如上面函数中调用do_syscall_32_irqs_on,最后会从映射表ia32_sys_call_table中进入对应的系统调用处理例程。
顺带提一下,小孩子才用int 0x80,Linux只是为保证兼容性而实现它。成熟的攻城狮在64位系统下都用syscall指令了,它不仅比传统的int 0x80快,还能够支持vDSO,让某些系统调用不经过内核就可以完成,快上加快。
4.3. 中断处理
对INT n指令,n在32和255之间,对应的都是中断门。
大部分硬中断处理的内核代码有两个入口代码数组irq_entries_start和spurious_entries_start,前者内容如下:
0000000000000160 <irq_entries_start>:
160: 6a 20 pushq $0x20
162: e9 19 0a 00 00 jmpq b80 <asm_common_interrupt>
167: 90 nop
168: 6a 21 pushq $0x21
16a: e9 11 0a 00 00 jmpq b80 <asm_common_interrupt>
16f: 90 nop
170: 6a 22 pushq $0x22
172: e9 09 0a 00 00 jmpq b80 <asm_common_interrupt>
177: 90 nop
...
32号,33号,34号……就这么一直往下,内容都差不多,先推入一个IRQ号,然后跳转到asm_common_interrupt。后面会讲common_interrupt的作用。
asm_common_interupt的内容和前面异常处理的asm_exc_xxx比较像,只不过保存中断号的步骤已在前面的入口完成。
0000000000000b80 <asm_common_interrupt>:
b80: 90 nop
b81: 90 nop
b82: 90 nop
b83: e8 f8 04 00 00 callq 1080 <error_entry>
b88: 48 89 e7 mov %rsp,%rdi
b8b: 48 8b 74 24 78 mov 0x78(%rsp),%rsi
b90: 48 c7 44 24 78 ff ff movq $0xffffffffffffffff,0x78(%rsp)
b97: ff ff
b99: e8 00 00 00 00 callq b9e <asm_common_interrupt+0x1e>
b9e: e9 bd 05 00 00 jmpq 1160 <error_return>
ba3: 66 66 2e 0f 1f 84 00 data16 nopw %cs:0x0(%rax,%rax,1)
baa: 00 00 00 00
bae: 66 66 2e 0f 1f 84 00 data16 nopw %cs:0x0(%rax,%rax,1)
bb5: 00 00 00 00
bb9: 0f 1f 80 00 00 00 00 nopl 0x0(%rax)
在保存现场之后,控制流进入函数common_interrupt,它可以处理大部分普通的中断。
/*
* common_interrupt() handles all normal device IRQ's (the special SMP
* cross-CPU interrupts have their own entry points).
*/
DEFINE_IDTENTRY_IRQ(common_interrupt)
{
struct pt_regs *old_regs = set_irq_regs(regs);
struct irq_desc *desc;
/* entry code tells RCU that we're not quiescent. Check it. */
RCU_LOCKDEP_WARN(!rcu_is_watching(), "IRQ failed to wake up RCU");
desc = __this_cpu_read(vector_irq[vector]);
if (likely(!IS_ERR_OR_NULL(desc))) {
handle_irq(desc, regs);
} else {
...
}
set_irq_regs(old_regs);
}
注意到__this_cpu_read,它拿到IRQ号对应的desc。所有的irq_desc链表都存放在如下的数组中:
typedef struct irq_desc* vector_irq_t[NR_VECTORS];
DECLARE_PER_CPU(vector_irq_t, vector_irq);
在handle_irq中,内核取出desc结构的handle函数,然后开始处理,具体的内容等到写代码实践的时候再学习。多处理器的东西这里也不再做深究,否则内容就太多了。
5. 希望完成的编程练习
创建一个新设备tik,该设备以每秒1次的频率向CPU发送中断信号
在中断处理例程中,内核将设备中的计数器+1。
通过一个系统调用以返回该设备存储的tik值