伯乐在线补充:
《Linux Insides》是 0xAX 童鞋在编写的一本将 Linux 内核的书。之前伯乐在线已摘译其中几篇:《Linux 内核数据结构:双向链表》、《Linux 内核数据结构:Radix 树》、《Linux 内核内存管理(1)》。
简介
这是《Linux Insides》新章节的第一部分。我们已经学习了本书的前几个章节。从最开始内核的初始化过程到最终第一个init进程的启动。这其中包含很多内核子系统相关的初始化过程。但是,我们并未深入探讨这些子系统的实现细节。本章中,我们将尝试理解各种内核子系统是如何工作和实现的。正如本章标题,第一个研究的子系统是中断。
什么是中断?
在本书的许多章节中,我们都遇到中断一词,甚至碰到过一些中断处理例程。本节将从理论部分开始,即:
然后将继续深入探讨中断的实现细节,以及Linux内核是如何处理中断的。
那么,首先中断是什么?中断是软件或硬件在需要CPU关注时发出的一个事件。例如,按下键盘上的按键,我们期望发生什么?操作系统和计算机在按下键后应该进行哪些操作?为简化问题,假设每个外设均有一条连接至CPU的中断线。设备可以通过中断线向CPU发送中断信号。但是,中断信号并非直接发送至CPU。在传统计算机中,由PIC芯片负责串行处理来自多个设备的多重中断请求。在现代计算机中,由称为APIC的高级可编程中断控制器负责。APIC由两个独立设备组成:
第一个,LAPIC位于每一个CPU核心内。LAPIC负责处理CPU相关的中断配置。通常用于管理来自APIC定时器、温度传感器和其他所有本地连接的I/O设备的中断。
第二个,IOAPIC提供多处理器中断管理。它将外部中断分配给各个CPU内核。更多关于LAPIC和IOAPIC的内容将在本章后续内容中讨论。通常,中断的发生是随机的。一旦中断触发,操作系统必须立即处理。但是处理中断意味着什么?当中断触发时,操作系统必须执行以下步骤:
- 内核必须匹配中断处理程序并转移控制;(执行中断处理程序)
- 在中断处理程序执行完成后,被中断进程能够恢复执行;
当然,处理中断的过程涉及很多复杂操作。但是以上三个步骤是这一过程的基本框架。
每一个中断处理的地址都存放在固定位置,称为中断描述符表或者IDT。处理器使用唯一的数值来标识中断或异常类型,该数值被称为中断向量。中断向量是IDT表中的一个序号。中断向量的数量是有限的,从0到255。在Linux内核源代码中对中断向量范围进行了如下检查:
BUG_ON((unsigned)n > 0xFF);
你可以在Linux内核源码中断建立相关部分找到该检查(例如arch/x86/include/asm/desc.h中的set_intr_gate、void set_system_intr_gate)。0到31这32个初始向量保留给处理器使用,用于处理体系定义的异常和中断。你可以在Linux内核初始化进程第二部分找到这些向量的描述表——初始中断和异常处理。32-255号向量是提供给用户自定义的中断,未保留给处理器使用。这些中断通常分配给外围I/O设备,允许这些设备向处理器发送中断请求。
下面讨论中断的类型。广义上讲,中断可以分为两大类:
第一个,外部中断通过LAPIC或连接至LAPIC的处理器引脚传输至处理器。第二个,软件中断是由于处理器自身异常(有时使用架构相关的特殊指令)造成。常见例子是除零异常。另一个例子是使用syscall指令退出程序。
正如前面提到的,中断可以在任何时候因代码和CPU失控引发。另一方面,异常和程序执行是同步的,可以分为3类:
错误是在引起错误的指令(可以恢复)执行之前发出的“异常”。如果恢复,被中断的程序可以恢复。
下一个,陷阱(trap)是在引起陷阱的指令执行之后发出的异常。陷阱和错误一样,也允许被中断的程序恢复。
最后,终止(abort)是不报告引起异常具体指令的一种异常,不允许被中断程序恢复。
从前面部分,我们知道中断可分为可屏蔽型(naskable)和非屏蔽型。可屏蔽中断是可以使用指令屏蔽的中断,x86_64下可使用sti和cli指令。在Linux内核源代码中可以找到它们:
static inline void native_irq_disable(void) { asm volatile("cli": : :"memory"); }
static inline void native_irq_enable(void) { asm volatile("sti": : :"memory"); }
这两条指令修改了中断寄存器中的IF标志位。sti指令将IF标志位置位,cli指令将该标志位复位。非屏蔽中断必须响应。通常,所有硬件错误都会被映射成非屏蔽中断。
如果多个异常或中断同时触发,处理器会按照预定义优先级进行处理。我们可以从下表中判断最高到最低优先级:
+----------------------------------------------------------------+ | | | | 优先级 | 描述 | | | | +--------------+-------------------------------------------------+ | | 硬件复位和机器检查 | | 1 | - RESET | | | - 机器检查 | +--------------+-------------------------------------------------+ | | 在任务切换上陷入 | | 2 | - 在TSS中的T标志设置 | | | | +--------------+-------------------------------------------------+ | | 外部硬件干涉 | | | - FLUSH | | 3 | - STOPCLK | | | - SMI | | | - INIT | +--------------+-------------------------------------------------+ | | 在上一条指令上陷入 | | 4 | - 断点 | | | - 调试陷入异常 | +--------------+-------------------------------------------------+ | 5 | 非可屏蔽中断 | +--------------+-------------------------------------------------+ | 6 | 可屏蔽硬件中断 | +--------------+-------------------------------------------------+ | 7 | 代码断点故障 | +--------------+-------------------------------------------------+ | 8 | 取下一条指令故障 | | | 代码段界限违反 | | | 码段页故障 | +--------------+-------------------------------------------------+ | | 解码下一条指令故障 | | | 指令长度 > 15字节 | | 9 | 无效操作码 | | | 协处理器不可用 | | | | +--------------+-------------------------------------------------+ | 10 | 执行指令故障 | | | 溢出 | | | 边界出错 | | | 无效TSS | | | 段不存在 | | | 堆栈故障 | | | 通用保护 | | | 数据页故障 | | | 对齐检查 | | | x87 FPU 浮点异常 | | | SIMD 浮点异常 | | | 虚拟化异常 | +--------------+-------------------------------------------------+
既然对各种中断和异常有了初步了解,现在可以深入到更加实用的部分。从中断描述符表的描述开始。正如前面所提到的,IDT中存放了中断和异常处理的入口点。IDT在结构上与Kernel启动第二部分的全局描述符表类似。但肯定有些许区别。与描述符不同,IDT表项称为门(gate)。它包含下列门中的一种:
在x86架构中。x86_64下只能引用长模式(long mode)中断门和陷阱门。和全局描述符表相同的是中断描述符表是一个门数组,在x86上为8字节门数组,x86_64上为16字节门数组。回想内核启动过程第二部分,全局描述符表必须包含空描述符作为其第一个元素。和全局描述符表不同的是,中断描述符表可包含一个门,但并非强制要求。例如,你也许记得在过渡到保护模式的最初阶段,中断描述符表加载过NULL门:
/* * Set up the IDT */ static void setup_idt(void) { static const struct gdt_ptr null_idt = {0, 0}; asm volatile("lidtl %0" : : "m" (null_idt)); }
在arch/x86/boot/pm.c文件中。中断描述符表可以驻留在线性地址空间的任何地方,并且其基址在x86上必须和8字节边界对齐,在x86_64上必须和16字节边界对齐。IDT的基地址存放在指定寄存器——IDTR。在x86兼容处理器上有两条指令可以修改IDTR寄存器:
第一条指令LIDT用于加载IDT的基址,即指定操作数到IDTR。第二条指令SIDT用于读取并保存IDTR的内容到指定操作数。IDTR寄存器在x86上是48位,包含以下信息:
+-----------------------------------+----------------------+ | | | | Base address of the IDT | Limit of the IDT | | | | +-----------------------------------+----------------------+ 47 16 15 0
分析setup_idt的实现,我们定义了一个null_idt并使用lidt指令将其加载到IDTR寄存器中。需要注意的是,null_idt包含的gdt_ptr类型定义如下:
struct gdt_ptr { u16 len; u32 ptr; } __attribute__((packed));
这里我们可以看到结构体的定义如图所示,内部由两字节和四字节(总共48位)的两个域组成。下面我们来分析一下IDT表项的结构。IDT表项结构体在x86_64中是一个称为门的16字节数组。其结构如下:
127 96 +-------------------------------------------------------------------------------+ | | | Reserved | | | +-------------------------------------------------------------------------------- 95 64 +-------------------------------------------------------------------------------+ | | | Offset 63..32 | | | +-------------------------------------------------------------------------------+ 63 48 47 46 44 42 39 34 32 +-------------------------------------------------------------------------------+ | | | D | | | | | | | | Offset 31..16 | P | P | 0 |Type |0 0 0 | 0 | 0 | IST | | | | L | | | | | | | -------------------------------------------------------------------------------+ 31 16 15 0 +-------------------------------------------------------------------------------+ | | | | Segment Selector | Offset 15..0 | | | | +-------------------------------------------------------------------------------+
为了构成IDT表中的一个索引值,处理器将异常或中断向量号乘以16。处理器处理异常和中断,就像遇到调用指令执行程序调用一样。处理器使用唯一的中断或异常数值或向量值作为查找相应中断描述符表的表项的编号。下面我们具体分析一下IDT表项。
正如我们看到的,图表中的IDT表项由以下域组成:
- 0-15位 —— 相对于段选择器的偏移地址,处理器将段选择器作为中断处理入口的基地址;
- 16-31位 —— 中断处理入口所在段选择基地址;
- IST —— x86_64中新的特殊机制,稍后介绍;
最后一个Type域描述了IDT表项的类型。中断处理有三种类型:
IST或中断栈表是x86_64中采用的新机制。它是传统堆栈切换机制的一种替换。传统x86体系为中断响应提供自动切换栈帧机制。IST是x86栈切换模式的演化版本。当该机制开启并用于特定中断(我们很快能看到)相关IDT表项的所有中断时,该机制会无条件切换栈。从这一点可以看出,IST并非对所有中断都必要。一些中断可以沿用传统栈切换模式。IST机制为包含进程信息的特殊结构体——任务状态段或TSS提供多至7个IST指针。TSS用于Linux内核中断或异常执行期间的栈切换。每一个指针均由IDT中的中断门引用。
中断描述符表由gate_desc结构体数组表示:
extern gate_desc idt_table[];
gate_desc定义如下:
#ifdef CONFIG_X86_64 ... ... ... typedef struct gate_struct64 gate_desc; ... ... ... #endif
gate_struct64定义如下:
struct gate_struct64 { u16 offset_low; u16 segment; unsigned ist : 3, zero0 : 5, type : 5, dpl : 2, p : 1; u16 offset_middle; u32 offset_high; u32 zero1; } __attribute__((packed));
x86_64架构下,每一个活动线程在Linux内核中都拥有一个很大的栈。栈尺寸定义为THREAD_SIZE,定义如下:
#define PAGE_SHIFT 12 #define PAGE_SIZE (_AC(1,UL) << PAGE_SHIFT) ... ... ... #define THREAD_SIZE_ORDER (2 + KASAN_STACK_ORDER) #define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER)
PAGE_SIZE为4096字节,THREAD_SIZE_ORDER取决于KASAN_STACK_ORDER。如上所示,KASAN_STACK取决于CONFIG_KASAN内核配置参数,定义如下:
#ifdef CONFIG_KASAN #define KASAN_STACK_ORDER 1 #else #define KASAN_STACK_ORDER 0 #endif
KASan是运行时内存调试器。那么…,如果未配置CONFIG_KASAN,THREAD_SIZE为16384字节,如果在内核中配置该选项时是32768字节。只要一个线程处于活动或者残留状态,这些栈就包含有用数据。当线程在用户空间时,除栈底的thread_info结构体外(该结构体的详细信息,可参见Linux内核初始化过程的第四部分),内核栈是空的。活动线程或僵尸线程不是唯一拥有栈的线程。还存在与每一个可用CPU相关的特殊栈。当内核在该CPU上运行时,这些栈就有用。当用户空间程序在CPU上执行时,这些栈不包含任何有用信息。每一个CPU都包含一些专门的cpu特有栈。首先是用于外部硬件中断的中断栈。它的尺寸取决于下面的定义:
#define IRQ_STACK_ORDER (2 + KASAN_STACK_ORDER) #define IRQ_STACK_SIZE (PAGE_SIZE << IRQ_STACK_ORDER)
或者16384字节。cpu特有中断栈在x86_64中由Linux内核中的irq_stack_union联合体表示:
union irq_stack_union { char irq_stack[IRQ_STACK_SIZE]; struct { char gs_base[40]; unsigned long stack_canary; }; };
irq_stack第一个域是一个16KB的数组。irq_stack_union还包含有两个域的结构体:
- gs_base —— gs寄存器总是指向irqstack联合体的底部。在x86_64上,cpu特有区域和stack canary(更多关于cpu特有变量的内容,可以阅读专栏部分)共享gs寄存器。所有cpu特有符号都是都是基于零索引,gs指向cpu特有区域的基地址。分段内存模型在长模式下不可用,但我们可以设置MSR寄存器的fs和gs两个段寄存器的基地址,这些寄存器仍可作为地址寄存器。如果你还记得Linux内核初始化过程的第一部分,你可能记得我们设置了gs寄存器:
movl $MSR_GS_BASE,%ecx movl initial_gs(%rip),%eax movl initial_gs+4(%rip),%edx wrmsr
这里的 initial_gs 指向irq_stack_union:
GLOBAL(initial_gs) .quad INIT_PER_CPU_VAR(irq_stack_union)
- stack_canary —— 中断栈的stack canary是一个栈保护器,用于检测栈没有被覆盖。注意gs_base是一个40字节的数组。GCC要求stack canary相对gs基地址的偏移是固定,并且其值在x86_64上必须是40,在x86上必须是20。
irq_stack_union是cpu特有区域的第一个数据块,在System.map中定义如下:
0000000000000000 D __per_cpu_start 0000000000000000 D irq_stack_union 0000000000004000 d exception_stacks 0000000000009000 D gdt_page ... ... ...
在代码中定义如下:
DECLARE_PER_CPU_FIRST(union irq_stack_union, irq_stack_union) __visible;
接下来,分析一下irq_stack_union的初始化。除irq_stack_union定义外,arch/x86/include/asm/processor.h中还定义了如下cpu特有变量:
DECLARE_PER_CPU(char *, irq_stack_ptr); DECLARE_PER_CPU(unsigned int, irq_count);
首先是irq_stack_ptr。从变量名可知,很明显这是一个指向栈顶的指针。第二个,irq_count用于检查CPU是否处于中断栈。irq_stack_ptr的初始化位于arch/x86/kernel/setup_percpu.c文件中的setup_per_cpu_areas函数:
void __init setup_per_cpu_areas(void) { ... ... #ifdef CONFIG_X86_64 for_each_possible_cpu(cpu) { ... ... ... per_cpu(irq_stack_ptr, cpu) = per_cpu(irq_stack_union.irq_stack, cpu) + IRQ_STACK_SIZE - 64; ... ... ... #endif ... ... }
该函数逐个遍历所有CPU并设置irq_stack_ptr。结果相当于将栈顶减去64。为何是64?如果你记得,在init/main.c中的start_kernel函数开始时调用boot_init_stack_canary函数设置了stack canary:
static __always_inline void boot_init_stack_canary(void) { u64 canary; ... ... ... #ifdef CONFIG_X86_64 BUILD_BUG_ON(offsetof(union irq_stack_union, stack_canary) != 40); #endif // // getting canary value here // this_cpu_write(irq_stack_union.stack_canary, canary); ... ... ... }
注意canary是64位。这是为何需要将中断栈的大小减去64,从而避免stack canary值重叠。irq_stack_union.gs_base在load_percpu_segment函数中初始化,该函数定义于arch/x86/kernel/cpu/common.c文件:
更多内容可参见wrmsl指令。
void load_percpu_segment(int cpu) { ... ... ... loadsegment(gs, 0); wrmsrl(MSR_GS_BASE, (unsigned long)per_cpu(irq_stack_union.gs_base, cpu)); }
并且据我们所知,gs寄存器指向中断栈的底部:
movl $MSR_GS_BASE,%ecx movl initial_gs(%rip),%eax movl initial_gs+4(%rip),%edx wrmsr GLOBAL(initial_gs) .quad INIT_PER_CPU_VAR(irq_stack_union)
这里的wrmsr指令用于将edx:eax中的数据加载到由ecx寄存器指向的MSR寄存器。这里的MSR寄存器是MSR_GS_BASE,该寄存器包含gs寄存器指向的内存段的基地址。edx:eax指向initial_gs的地址,该地址是irq_stack_union的基地址。
我们知道,x86_64具备中断栈表或IST功能。该特性为非屏蔽中断、双故障异常等事件提供了切换到新栈的功能。每个cpu最多可达7个IST表项。其中一些如下:
#define DOUBLEFAULT_STACK 1 #define NMI_STACK 2 #define DEBUG_STACK 3 #define MCE_STACK 4
所有使用IST切换到新栈的中断门描述符使用set_intr_gate_ist函数初始化。例如:
set_intr_gate_ist(X86_TRAP_NMI, &nmi, NMI_STACK); ... ... ... set_intr_gate_ist(X86_TRAP_DF, &double_fault, DOUBLEFAULT_STACK);
其中&nmi和&double_fault是给定中断处理的入口地址:
asmlinkage void nmi(void); asmlinkage void double_fault(void);
定义在arch/x86/kernel/entry_64.S文件中
idtentry double_fault do_double_fault has_error_code=1 paranoid=2 ... ... ... ENTRY(nmi) ... ... ... END(nmi)
当一个中断或异常发生时,新ss选择器强制置空,且ss选择器的rpl域为新cpl。原来的ss、rsp、register flag、cs、rip被推入新栈。在64位模式下,推入的栈帧尺寸固定在8字节,所以我们得到的栈如下:
+---------------+ | | | SS | 40 | RSP | 32 | RFLAGS | 24 | CS | 16 | RIP | 8 | Error code | 0 | | +---------------+
如果中断门中的IST域不为零,我们将IST指针读到rsp中。如果中断向量值有一个与其相关的错误码,我们将错误码推入栈中。如果中断向量值没有错误码,我们继续执行,并将伪错误码推入栈中。这样做是为了确保栈的兼容性。接下来,将门描述符中的段选择加载到CS寄存器,并通过检查第21位,即全局描述符表的L位,来验证目标代码段是64位模式代码段。最后,将门描述符中的offset域加载到rip中,这是中断处理的入口点。然后中断处理开始执行。完成中断处理后,必须使用iret指令将控制权交还被中断进程。iret指令无条件弹出栈指针(ss:rsp)恢复被中断进程的栈,不取决于cpl的改变。
就这些。
总结
关于Linux内核中断与处理的第一部分就到这里。内容涉及一些理论只是以及与中断和异常相关的初始化。接下来的部分,我们将继续深入探讨中断和中断处理 —— 深入更加实际的方面。
如果你有任何问题或建议,给我留言或在twitter上联系我。