Linux Kernel:中断和异常处理程序的早期初始化

597 阅读21分钟

说明:

本文采用Linux 内核 v3.10 版本 x86_64架构

在开机启动之后,Linux 内核程序经历了实模式32位保护模式,最终稳定工作在长模式。在模式切换过程中,中断和异常处理程序也经历了多次初始化。我们按照 Linux 内核的启动过程,来梳理下不同阶段下中断和异常处理程序是如何进行初始化的。

一、最早的初始化

IDT_early_init.png

在 Linux 内核源码中,最早出现的与 x86 架构相关的中断代码,在文件 arch/x86/boot/pm.c中。此时, 内核正准备从实模式过渡到保护模式。在该文件的 go_to_protected_mode 函数中,调用了 setup_idt 函数,来构建早期的中断描述符表。

// file: arch/x86/boot/pm.c
/*
 * Actual invocation sequence
 */
void go_to_protected_mode(void)
{
	...

	/* Actual transition to protected mode... */
	setup_idt();
	
	...
}

1.1 setup_idt 函数

setup_idt函数位于同一文件中,其内部实现非常简单,只是加载了 NULL 中断描述符表。

// file: arch/x86/boot/pm.c
/*
 * 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));
}

1.2 gdt_ptr 结构体

gdt_ptr 结构体定义如下,其中成员 len表示中断描述符表的大小,成员 ptr 表示中断描述符表的起始地址。

// file: arch/x86/boot/pm.c
/*
 * Set up the GDT
 */

struct gdt_ptr {
	u16 len;
	u32 ptr;
} __attribute__((packed));

1.3 lidt 指令

lidt 指令的操作数是一个内存地址,该地址的低 16 位存储着中断描述符表(IDT)的限制大小,高 32位(x86 架构)或 高64位(x86_64 架构)存储着 IDT 的起始地址。 lidt 指令会把操作数指向的内存数据(48 位或 80 位)加载到中断描述符表寄存器(IDTR)中。

非 64 位模式下,操作数指定了包含 6 个字节数据的内存地址。这 6 个字节中包括 4 字节的基地址以及 2 字节的大小限制。其中,低16位为限制大小,高 32 位为表基地址。

根据操作数的位数大小,又细分为 2 种情况:

  • 当操作数大小为 32 位时,其低 16 位为大小限制,高 32 位为基址。
  • 当操作数大小为 16 位时,其低 16 位为大小限制,高 32 位中的低 24 位为基址,其余位置零。

64 位模式下,该指令的操作数为包含 8 个字节数据的内存地址。其中包括2个字节(低 16 位)的大小限制和 6 个字节(高 64 位)的基地址

此处内联汇编中,使用了lidtl指令,其后缀 l表示操作数是 32 位的,使用了16 位表限制和完整的 32 位基址。

关于lidt 指令的详细用法,可参考 Intel® 64 and IA-32 Architectures Software Developer’s Manual, Volume 2B Chapter 3 中的LIDT指令说明。

另外,可以看到,虽然是加载的中断描述符表,其名称却是 gdt_ptr。这是因为,在 x86 架构中,GDTR(全局描述符表寄存器) 和 IDTR(中断描述符表寄存器) 的结构是一样的,如下图所示:

IDTR_GDTR.png

所以,在内核中,使用了同一个数据结构来表示两种寄存器数据。

这里使用 NULL 来填充中断描述符表,是因为目前内核还处于启动阶段,在当前阶段,处理中断还为时过早。

1.4 变量属性packed

关键字__attribute__gcc 的特性,允许指定变量或结构体字段的特殊属性。packed变量属性用在结构体或联合体上以最小化内存需求。请看一个示例:

#include <stdio.h>
typedef unsigned short u16;
typedef unsigned int   u32;

struct gdt_ptr {
	u16 len;
	u32 ptr;
};

struct gdt_ptr_packed {
	u16 len;
	u32 ptr;
} __attribute__((packed));


int main()
{
    printf("Size of gdt_ptr: %lu\n", sizeof(struct gdt_ptr));
    printf("Size of packed gdt_ptr: %lu\n", sizeof(struct gdt_ptr));
    return 0;
}

下面是我在 64 位 Ubuntu 18.04.5 系统里的运行结果:

$ gcc -o test test.c 
$ ./test
Size of gdt_ptr: 8
Size of packed gdt_ptr: 6

在默认对齐规则下,为该变量分配了 8 个字节;使用 packed 属性修饰后,只需分配 6 个字节,节省了 2个字节的内存空间。

packed 属性抑制了编译器在进行内存对齐时所执行的 结构体填充(structure padding)

二、64 位模式下的早期初始化

IDT_init2.png

随着启动进程的进行,内核进入了 64 位模式。该模式下中断及异常处理的早期初始化,位于 arch/x86/kernel/head64.c文件的 x86_64_start_kernel函数中。

// file: arch/x86/kernel/head64.c
void __init x86_64_start_kernel(char * real_mode_data)
{
    ...

	for (i = 0; i < NUM_EXCEPTION_VECTORS; i++)
		set_intr_gate(i, &early_idt_handlers[i]);
	load_idt((const struct desc_ptr *)&idt_descr);

	...
}

2.1 设置中断处理程序

	for (i = 0; i < NUM_EXCEPTION_VECTORS; i++)
		set_intr_gate(i, &early_idt_handlers[i]);

NUM_EXCEPTION_VECTORS 宏定义在文件 arch/x86/include/asm/segment.h ,扩展为 32。正如我们所看到的,虽然 x86 架构支持最多 256 个中断向量,内核当前只设置了前 32 个。因为在早期内核启动阶段,外部中断一直都是禁止的,所以当前并不需要设置中断向量大于 32 的处理程序。

// file: arch/x86/include/asm/segment.h
#define IDT_ENTRIES 256
#define NUM_EXCEPTION_VECTORS 32
2.1.1 set_intr_gate 函数

set_intr_gate 函数定义在文件 arch/x86/include/asm/desc.h 中,该函数接收 2 个参数:中断向量号和中断处理程序地址。

// file: arch/x86/include/asm/desc.h
/*
 * This needs to use 'idt_table' rather than 'idt', and
 * thus use the _nonmapped_ version of the IDT, as the
 * Pentium F0 0F bugfix can have resulted in the mapped
 * IDT being write-protected.
 */
static inline void set_intr_gate(unsigned int n, void *addr)
{
	BUG_ON((unsigned)n > 0xFF);
	_set_gate(n, GATE_INTERRUPT, addr, 0, 0, __KERNEL_CS);
}

该函数首先会检查传入的向量号,如果向量号大于255(x86 架构只允许 0 ~ 255 共 256 个中断),则输出错误信息并将系统挂起;否则调用 _set_gate 函数设置门描述符。

2.1.2 _set_gate 函数

_set_gate 函数与 set_intr_gate 在同一文件内,该函数接受 6 个参数,分别是:

  • gate:中断或异常向量号

  • type:门类型,一共有四种,分别是:中断门、陷阱门、调用门和任务门。

    // file: arch/x86/include/asm/desc_defs.h
    enum {
    	GATE_INTERRUPT = 0xE,
    	GATE_TRAP = 0xF,
    	GATE_CALL = 0xC,
    	GATE_TASK = 0x5,
    };
    

    针对中断及异常处理程序,x86 模式允许使用其中的三种 -- 中断门、陷阱门和任务门; x86_64 模式下,仅保留了中断门和陷阱门。

  • addr:中断处理程序地址

  • dpl:门描述符中的特权等级,用于程序保护

  • ist:中断栈表,具体可参考这篇文章

  • seg:段选择子

// file: arch/x86/include/asm/desc.h
static inline void _set_gate(int gate, unsigned type, void *addr,
			     unsigned dpl, unsigned ist, unsigned seg)
{
	gate_desc s;

	pack_gate(&s, type, (unsigned long)addr, dpl, ist, seg);
	/*
	 * does not need to be atomic because it is only done once at
	 * setup time
	 */
	write_idt_entry(idt_table, gate, &s);
}

该函数主体流程如下:

  • 函数开始,声明了 gate_desc类型的变量 s
  • 然后,调用 pack_gate 函数将描述符需要的数据填充到变量 s 中;
  • 最后,调用 write_idt_entry 函数将描述符数据存入中断描述符表中。
2.1.3 门描述符结构

gate_desc 结构体定义如下:

// file: arch/x86/include/asm/desc.h
#ifdef CONFIG_X86_64
typedef struct gate_struct64 gate_desc;
...
#else
...
#endif
    
/* 16byte gate */
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 模式下,gate_desc 结构体是 struct gate_struct64 的别名;struct gate_struct64 结构体大小为 16 字节,其中的成员及顺序与 x86_64 模式下门描述符的结构一一对应。

IDT_gate_descriptor_64.png

2.1.4 组装门描述符

pack_gate 函数将门描述符需要的数据,填充到传入的结构体变量中。

// file: arch/x86/include/asm/desc.h
static inline void pack_gate(gate_desc *gate, unsigned type, unsigned long func,
			     unsigned dpl, unsigned ist, unsigned seg)
{
	gate->offset_low	= PTR_LOW(func);
	gate->segment		= __KERNEL_CS;
	gate->ist		= ist;
	gate->p			= 1;
	gate->dpl		= dpl;
	gate->zero0		= 0;
	gate->zero1		= 0;
	gate->type		= type;
	gate->offset_middle	= PTR_MIDDLE(func);
	gate->offset_high	= PTR_HIGH(func);
}

可以看到,其段选择子项,填充的是内核代码段(__KERNEL_CS);

// arch/x86/include/asm/segment.h
#define __KERNEL_CS	(GDT_ENTRY_KERNEL_CS*8)
#define GDT_ENTRY_KERNEL_CS 2

内核代码段选择子在全局描述符表(GDT)中的实际索引为 2,之所以乘以 8 ,是因为段选择子的低三位有其它用途,要把实际索引值左移三位(乘以 8),才能放到正确的位置。

SegmentSelector.png

另外,通过宏 PTR_LOWPTR_MIDDLEPTR_HIGH,分别获取到中断处理函数地址的低 16 位、中 16 位及高 32 位,填充到对应的字段中。

// file:arch/x86/include/asm/desc.h
#define PTR_LOW(x) ((unsigned long long)(x) & 0xFFFF)
#define PTR_MIDDLE(x) (((unsigned long long)(x) >> 16) & 0xFFFF)
#define PTR_HIGH(x) ((unsigned long long)(x) >> 32)

write_idt_entry会把组装好的门描述符,写入到中断描述符表的对应项中,其定义依赖于CONFIG_PARAVIRT 内核配置项。当该项未配置时,write_idt_entry宏扩展为native_write_idt_entry函数。

#ifdef CONFIG_PARAVIRT
#include <asm/paravirt.h>
#else
...
#define write_idt_entry(dt, entry, g)		native_write_idt_entry(dt, entry, g)
...
#endif	/* CONFIG_PARAVIRT */

当配置了 CONFIG_PARAVIRT 项时,write_idt_entry 被定义为内联函数。如果当前内核是作为宿主机系统运行,而不是客户机,那么 write_idt_entry最终还是会执行native_write_idt_entry函数,只不过是多了一层接口。pvPARAVIRT (半虚拟化)的缩写,半虚拟化的内容,本篇文章不会涉及。

// file: arch/x86/include/asm/paravirt.h
static inline void write_idt_entry(gate_desc *dt, int entry, const gate_desc *g)
{
	PVOP_VCALL3(pv_cpu_ops.write_idt_entry, dt, entry, g);
}
// file: arch/x86/kernel/paravirt.c
struct pv_cpu_ops pv_cpu_ops = {
	...
	
	.write_idt_entry = native_write_idt_entry,

	...
};

native_write_idt_entry 函数定义如下:

// file: arch/x86/include/asm/desc.h
static inline void native_write_idt_entry(gate_desc *idt, int entry, const gate_desc *gate)
{
	memcpy(&idt[entry], gate, sizeof(*gate));
}

_set_gate函数中,传给该函数的参数分别是:

  • 中断描述符表地址(idt_table
  • 中断向量号
  • 门描述符结构体
write_idt_entry(idt_table, gate, &s);

idt_table是一个全局变量,是一个拥有 256 个元素的数组(IDT_ENTRIES宏扩展为256),成员类型为 gate_desc结构体(我们在上文已经见过了),每个成员大小为 16 字节。

// file: arch/x86/include/asm/desc.h
extern gate_desc idt_table[];
// file: arch/x86/kernel/head_64.S
ENTRY(idt_table)
	.skip IDT_ENTRIES * 16
// file: arch/x86/include/asm/segment.h
#define IDT_ENTRIES 256

可以看到,native_write_idt_entry函数仅仅是执行了一次内存拷贝,将门描述符的数据拷贝到中断描述符表中以向量号为索引的对应位置。

2.1.5 中断处理函数实现

从上文可以看到,在设置门描述符时,中断处理函数是从 early_idt_handlers 数组获取到的。

early_idt_handlers是一个字节数组,该数组共有 32 个成员,每个成员大小为 9 (2+2+5)字节,共 288 个字节。

// file: arch/x86/include/asm/segment.h
extern const char early_idt_handlers[NUM_EXCEPTION_VECTORS][2+2+5];

#define NUM_EXCEPTION_VECTORS 32

early_idt_handlers 变量定义在文件 arch/x86/kernel/head_64.S 中,我们来分析下该变量的初始化过程。

// file: arch/x86/kernel/head_64.S
.globl early_idt_handlers
early_idt_handlers:
	# 104(%rsp) %rflags
	#  96(%rsp) %cs
	#  88(%rsp) %rip
	#  80(%rsp) error code
	i = 0
	.rept NUM_EXCEPTION_VECTORS
	.if (EXCEPTION_ERRCODE_MASK >> i) & 1
	ASM_NOP2
	.else
	pushq $0		# Dummy error code, to make stack frame uniform
	.endif
	pushq $i		# 72(%rsp) Vector number
	jmp early_idt_handler
	i = i + 1
	.endr

在上述代码中,宏 NUM_EXCEPTION_VECTORS 扩展为 32;宏 EXCEPTION_ERRCODE_MASK 定义如下:

// file: arch/x86/include/asm/segment.h
/* Bitmask of exception vectors which push an error code on the stack */
#define EXCEPTION_ERRCODE_MASK  0x00027d00

这篇文章里,我们介绍了中断和异常向量,其中会生成错误码的异常向量有如下几个:

向量助记描述类型错误码来源
..................
8#DFDouble FaultAbortYes
(zero)
Any instruction that can generate an
exception, an NMI, or an INTR.
..................
10#TSInvalid TSSFaultYesTask switch or TSS access.
11#NPSegment Not PresentFaultYesLoading segment registers or accessing system segments.
12#SSStack-Segment FaultFaultYesStack operations and SS register loads.
13#GPGeneral ProtectionFaultYesAny memory reference and other protection checks.
14#PFPage FaultFaultYesAny memory reference.
..................
17#ACAlignment CheckFaultYes
(Zero)
Any data reference in memory.
..................
21#CPControl Protection ExceptionFaultYesRET, IRET, RSTORSSP, and SETSSBSY
instructions can generate this exception.
When CET indirect branch tracking is
enabled, this exception can be generated
due to a missing ENDBRANCH instruction
at target of an indirect call or jump
..................

可以看到,除了向量 21,其它向量全部映射到位掩码0x00027d00上去了。向量 21 是由控制流执行技术(Control-Flow Enforcement Technology,CET)引入的,涉及到影子栈( shadow stack)及使用 iret 进行任务切换(即中断处理程序的任务门,x86_64 架构不支持),目前并未用到。

Control-Flow Enforcement Technology introduces a new exception (#CP) with interrupt vector 21.

ASM_NOP2x86_64 架构上,最终扩展为 ".byte 0x66,0x90",其表示 2 个字节的操作码 NOP。

// file: arch/x86/include/asm/nops.h
#define ASM_NOP2 _ASM_MK_NOP(K8_NOP2)

#ifdef __ASSEMBLY__
#define _ASM_MK_NOP(x) .byte x
#else
#define _ASM_MK_NOP(x) ".byte " __stringify(x) "\n"
#endif

#define K8_NOP2	0x66,K8_NOP1

#define K8_NOP1 GENERIC_NOP1

#define GENERIC_NOP1 0x90

x86x86_64 架构上,NOP 指令表示无操作。指令长度从 1 字节 到 9 字节,共有 9 个版本。其中 1 字节版本指令为 0x90,2 字节版本指令为 0x66 0x99。其它版本见下图:

NOP.png

关于 NOP 指令的详细信息,请参考 Intel® 64 and IA-32 Architectures Software Developer’s Manual, Volume 2B Chapter 4 中的NOP指令说明。

分析完了各个宏的定义,early_idt_handlers 的初始化流程就很清晰了:

  • 从向量 0 开始,循环32次,
    • 判断当前向量是否会产生错误码
      • 会产生错误码:错误码会被处理器自动压入栈中,那么生成 2 字节的 NOP 指令(ASM_NOP2),该指令不会执行具体操作,仅用作字节填充使用
      • 不会产生错误码:那么为了保持栈格式的一致性,向栈中压入伪错误码 0,压栈指令(pushq $0)大小为 2 个字节
    • 向栈中压入向量号,该指令(pushq $i)为 2 个字节。
    • 跳转到 early_idt_handler处执行,该指令(jmp early_idt_handler)为 5 个字节。

综上所述,early_idt_handlers变量拥有 32 个成员,每个成员 9 (2+2+5)个字节大小,包括 2 个字节的 NOP 或错误码压栈指令,2个字节的向量号压栈指令,以及 5 个字节的跳转指令。从变量形式上看,early_idt_handlers 是一个二维字节数组,实际是 32 组 9 字节指令。

我们看一下 vmlinux 中的输出:

$ nm vmlinux |sort -k1|grep early_idt_handlers -A 2
ffffffff81989000 T early_idt_handlers
ffffffff81989000 T _sinittext
ffffffff81989120 T early_idt_handler
$ objdump -d vmlinux --start-address=0xffffffff81989000 --stop-address=0xffffffff81989120

vmlinux:     文件格式 elf64-x86-64


Disassembly of section .init.text:

ffffffff81989000 <_sinittext>:
ffffffff81989000:	6a 00                	pushq  $0x0
ffffffff81989002:	6a 00                	pushq  $0x0
ffffffff81989004:	e9 17 01 00 00       	jmpq   ffffffff81989120 <early_idt_handler>
ffffffff81989009:	6a 00                	pushq  $0x0
ffffffff8198900b:	6a 01                	pushq  $0x1
ffffffff8198900d:	e9 0e 01 00 00       	jmpq   ffffffff81989120 <early_idt_handler>
ffffffff81989012:	6a 00                	pushq  $0x0
ffffffff81989014:	6a 02                	pushq  $0x2
ffffffff81989016:	e9 05 01 00 00       	jmpq   ffffffff81989120 <early_idt_handler>
ffffffff8198901b:	6a 00                	pushq  $0x0
ffffffff8198901d:	6a 03                	pushq  $0x3
ffffffff8198901f:	e9 fc 00 00 00       	jmpq   ffffffff81989120 <early_idt_handler>
ffffffff81989024:	6a 00                	pushq  $0x0
ffffffff81989026:	6a 04                	pushq  $0x4
ffffffff81989028:	e9 f3 00 00 00       	jmpq   ffffffff81989120 <early_idt_handler>
ffffffff8198902d:	6a 00                	pushq  $0x0
ffffffff8198902f:	6a 05                	pushq  $0x5
ffffffff81989031:	e9 ea 00 00 00       	jmpq   ffffffff81989120 <early_idt_handler>
ffffffff81989036:	6a 00                	pushq  $0x0
ffffffff81989038:	6a 06                	pushq  $0x6
ffffffff8198903a:	e9 e1 00 00 00       	jmpq   ffffffff81989120 <early_idt_handler>
ffffffff8198903f:	6a 00                	pushq  $0x0
ffffffff81989041:	6a 07                	pushq  $0x7
ffffffff81989043:	e9 d8 00 00 00       	jmpq   ffffffff81989120 <early_idt_handler>
ffffffff81989048:	66 90                	xchg   %ax,%ax
ffffffff8198904a:	6a 08                	pushq  $0x8
ffffffff8198904c:	e9 cf 00 00 00       	jmpq   ffffffff81989120 <early_idt_handler>
ffffffff81989051:	6a 00                	pushq  $0x0
ffffffff81989053:	6a 09                	pushq  $0x9
ffffffff81989055:	e9 c6 00 00 00       	jmpq   ffffffff81989120 <early_idt_handler>
ffffffff8198905a:	66 90                	xchg   %ax,%ax			// NOP2
ffffffff8198905c:	6a 0a                	pushq  $0xa
ffffffff8198905e:	e9 bd 00 00 00       	jmpq   ffffffff81989120 <early_idt_handler>
ffffffff81989063:	66 90                	xchg   %ax,%ax			// NOP2
ffffffff81989065:	6a 0b                	pushq  $0xb
ffffffff81989067:	e9 b4 00 00 00       	jmpq   ffffffff81989120 <early_idt_handler>
...
...
...

early_idt_handlers 的每组成员中,最后的 5 个字节都是跳转指令,会跳转到 early_idt_handler 处执行。接下来,我们看下 early_idt_handler 程序。

// file: arch/x86/kernel/head_64.S
/* This is global to keep gas from relaxing the jumps */
ENTRY(early_idt_handler)
	cld

	cmpl $2,early_recursion_flag(%rip)
	jz  1f
	incl early_recursion_flag(%rip)

	pushq %rax		# 64(%rsp)
	pushq %rcx		# 56(%rsp)
	pushq %rdx		# 48(%rsp)
	pushq %rsi		# 40(%rsp)
	pushq %rdi		# 32(%rsp)
	pushq %r8		# 24(%rsp)
	pushq %r9		# 16(%rsp)
	pushq %r10		#  8(%rsp)
	pushq %r11		#  0(%rsp)

	cmpl $__KERNEL_CS,96(%rsp)
	jne 11f

	cmpl $14,72(%rsp)	# Page fault?
	jnz 10f
	GET_CR2_INTO(%rdi)	# can clobber any volatile register if pv
	call early_make_pgtable
	andl %eax,%eax
	jz 20f			# All good

10:
	leaq 88(%rsp),%rdi	# Pointer to %rip
	call early_fixup_exception
	andl %eax,%eax
	jnz 20f			# Found an exception entry

11:
#ifdef CONFIG_EARLY_PRINTK
	GET_CR2_INTO(%r9)	# can clobber any volatile register if pv
	movl 80(%rsp),%r8d	# error code
	movl 72(%rsp),%esi	# vector number
	movl 96(%rsp),%edx	# %cs
	movq 88(%rsp),%rcx	# %rip
	xorl %eax,%eax
	leaq early_idt_msg(%rip),%rdi
	call early_printk
	cmpl $2,early_recursion_flag(%rip)
	jz  1f
	call dump_stack
#ifdef CONFIG_KALLSYMS	
	leaq early_idt_ripmsg(%rip),%rdi
	movq 40(%rsp),%rsi	# %rip again
	call __print_symbol
#endif
#endif /* EARLY_PRINTK */
1:	hlt
	jmp 1b

20:	# Exception table entry found or page table generated
	popq %r11
	popq %r10
	popq %r9
	popq %r8
	popq %rdi
	popq %rsi
	popq %rdx
	popq %rcx
	popq %rax
	addq $16,%rsp		# drop vector number and error code
	decl early_recursion_flag(%rip)
	INTERRUPT_RETURN
ENDPROC(early_idt_handler)

这段程序有点长,我们一段一段来分析。

在程序一开始,使用 cld 指令清除了 RFLAGS 寄存器中的 DF (Direction Flag)位。 当 DF 位被设置为 0 时, 字符串操作相关指令,比如 stosmovs 等,会增加索引寄存器(ESI 或 EDI)的值;也就是说处理字符串时,从低地址到高地址开始处理。否则,当 DF 位置 1 时(通过 std 指令),字符串从高地址到低地址开始处理。

	cld

	cmpl $2,early_recursion_flag(%rip)
	jz  1f
	incl early_recursion_flag(%rip)
	
	...
	
1:	hlt
	jmp 1b

在清除 DF 标志位之后,通过把递归标志 early_recursion_flag与常量 2 进行比较,检查是否发生了递归调用。

early_recursion_flag 标志与 early_idt_handler 定义在同一文件中,初始值为 0:

// file: arch/x86/kernel/head_64.S
early_recursion_flag:
	.long 0

early_idt_handler 每调用一次,early_recursion_flag 的值就会加 1。该值等于 1 时,说明中断程序正在执行;如果等于 2,说明发生了递归调用,那么跳转到标签 1处执行。1 处的代码,通过无限循环跳转,一直在执行 hlt 指令,将程序挂起。

另外,标签后缀有 bf 两种,bbackward 的缩写,1b表示向后查找到的离当前指令最近的标签1fforward 的缩写,1f 表示向前查找到的离当前指令最近的标签 1

然后,将一些通用寄存器的值存入栈中:

	pushq %rax		# 64(%rsp)
	pushq %rcx		# 56(%rsp)
	pushq %rdx		# 48(%rsp)
	pushq %rsi		# 40(%rsp)
	pushq %rdi		# 32(%rsp)
	pushq %r8		# 24(%rsp)
	pushq %r9		# 16(%rsp)
	pushq %r10		#  8(%rsp)
	pushq %r11		#  0(%rsp)

根据 x86-64 ABI ,这些都是需要调用者保存的通用寄存器,被调用函数有可能会破坏这些寄存器的值,所以需要把这些寄存器的值提前压入栈中保存。

再下来,检查栈中保存的代码段寄存器的值与当前的 %cs 值是否相等,不相等的话说明出错了,则跳转到标签 11处执行。

	cmpl $__KERNEL_CS,96(%rsp)
	jne 11f
	
	...
	
11:
#ifdef CONFIG_EARLY_PRINTK
	GET_CR2_INTO(%r9)	# can clobber any volatile register if pv
	movl 80(%rsp),%r8d	# error code
	movl 72(%rsp),%esi	# vector number
	movl 96(%rsp),%edx	# %cs
	movq 88(%rsp),%rcx	# %rip
	xorl %eax,%eax
	leaq early_idt_msg(%rip),%rdi
	call early_printk
	cmpl $2,early_recursion_flag(%rip)
	jz  1f
	call dump_stack
#ifdef CONFIG_KALLSYMS	
	leaq early_idt_ripmsg(%rip),%rdi
	movq 40(%rsp),%rsi	# %rip again
	call __print_symbol
#endif
#endif /* EARLY_PRINTK */
1:	hlt
	jmp 1b

根据 x86_64 架构的设计,当中断或异常发生时,处理器会自动把 SS、RSP、RFLAGS、CS、RIP、Error Code 存入栈中;在上文介绍的 early_idt_handlers中,内核还会把向量号存入栈中,所以在运行到 early_idt_handler程序时,栈内布局如下图所示: Interrupt_stack3.png

在把通用寄存器压入栈后,栈内布局如下图所示:

Interrupt_stack4.png

可以看到,距离当前 %rsp 偏移 96 字节处,即96(%rsp) 的值,正是中断发生时 %cs 寄存器的值。由于内核正处于启动阶段,不管是中断前还是中断后,都是在内核态执行的,所以当前 %cs 的值与栈中保存的 %cs 的值应该是一致的,否则就说明程序发生了错误,跳转到标签 11处执行。标签 11处的代码会输出一些错误信息,做一些错误处理等,然后就会进入标签 1 处将程序挂起。

接下来,会判断当前异常是否为缺页故障(Page Fault 的向量号为 14)。如果不是,跳转到标签 10 处执行。否则,从 CR2 寄存器中读取发生 Page Fault 故障的线性地址,存入 %rdi 寄存器,然后调用 early_make_pgtable 函数做页表映射。根据 x86_64 调用习惯,%rdi 寄存器的值是early_make_pgtable 函数的第一个参数。然后检查 early_make_pgtable返回值是否为 0(x86 架构中,函数返回值是存放在 %rax 寄存器中的)。如果为0,那么一切 OK,跳转到标签 20 处执行;否则,会执行到标签 11 处,打印错误信息并将程序挂起。

	cmpl $14,72(%rsp)	# Page fault?
	jnz 10f
	GET_CR2_INTO(%rdi)	# can clobber any volatile register if pv
	call early_make_pgtable
	andl %eax,%eax
	jz 20f			# All good
11:
#ifdef CONFIG_EARLY_PRINTK
...
...

CR2 寄存器保存着引起页故障(Page Fault)的线性地址。在 x86 架构下,CR2 寄存器是32位的;在 x86_64 架构下,该寄存器是 64 位的。

CR2.png

我们来看下 GET_CR2_INTO宏,其定义如下:

// file: arch/x86/kernel/head_64.S
#define GET_CR2_INTO(reg) movq %cr2, reg

该宏的效果,就是把 %cr2中的值,存入指定的寄存器中。

再来看标签 10 处的程序,除了Page Fault 异常,其它异常都会执行到该段程序:

10:
	leaq 88(%rsp),%rdi	# Pointer to %rip
	call early_fixup_exception
	andl %eax,%eax
	jnz 20f			# Found an exception entry

88(%rsp) 处保存的是被中断的程序的 RIP(见上图,栈中的内存布局),leaq 是加载有效地址的指令,该行指令会把栈中保存着 RIP 值的地址(即指向 RIP 的指针),存入 %rdi 寄存器。该寄存器的值,会做为参数,传递给随后调用的 early_fixup_exception 函数。然后通过 andl %eax,%eax,检测 early_fixup_exception 函数返回值(根据 x86_64调用习惯,函数的返回值是保存到 %rax 寄存器中的)是否为0,如果不为 0,说明找到了异常修复入口,该异常能够修复,跳转到标签 20 处执行。

看下标签 20 处的程序,程序能执行到该标签处,说明执行成功了,需要做一些收尾的工作。

20:	# Exception table entry found or page table generated
	popq %r11
	popq %r10
	popq %r9
	popq %r8
	popq %rdi
	popq %rsi
	popq %rdx
	popq %rcx
	popq %rax
	addq $16,%rsp		# drop vector number and error code
	decl early_recursion_flag(%rip)
	INTERRUPT_RETURN

首先,将原先存入栈中的通用寄存器弹出。addq $16,%rsp指令,使栈指针的值增加 16,由于栈是向低地址增长的,栈指针增加 16,相当于将这 16 个字节的数据丢弃了。这 16 个字节保存的是中断向量号以及错误码,中断程序执行完成后,这 2 个数据就没什么用处了,而且 iret 指令也不会自动弹出这 2 个数据,所以需要我们手动处理。接下来,将递归标志 early_recursion_flag 减 1。因为我们在程序入口处将该值加了1,此处,减 1 后恢复到初始值 0。最后,通过 INTERRUPT_RETURN 宏,恢复执行被中断的程序。INTERRUPT_RETURN宏被扩展成 iretq 指令,该指令会从栈中依次弹出 RIP、CS、RFLAGES、RSP、SS 到相应的寄存器,被中断程序的执行就会恢复执行。

// file: arch/x86/kernel/head_64.S
#define INTERRUPT_RETURN iretq

2.2 加载中断描述符表

在填充好中断描述符表之后,需要重新加载 IDTR 寄存器,让新的 IDT 生效。

load_idt((const struct desc_ptr *)&idt_descr);

load_idt 最终实现为 native_load_idt,该函数定义如下:

// file: arch/x86/include/asm/desc.h
static inline void native_load_idt(const struct desc_ptr *dtr)
{
	asm volatile("lidt %0"::"m" (*dtr));
}

可以看到,native_load_idt 函数内部,使用了 lidt 指令来刷新 IDTR 寄存器内容。

参数 idt_descr定义如下:

// file: arch/x86/kernel/cpu/common.c
struct desc_ptr idt_descr = { NR_VECTORS * 16 - 1, (unsigned long) idt_table };

参数类型为desc_ptr 结构体,该结构体内保存的是中断描述符表的限制值及起始地址,我们上文已经介绍过了。我们来看下 IDT 的大小,NR_VECTORS宏扩展为 256,而desc_ptr 结构体的大小为 16 字节,所以 IDT 的限制为 256 个门描述符大小。

// file: arch/x86/include/asm/irq_vectors.h
#define NR_VECTORS           256

为什么限制值要减 1 呢?因为限制值是从 0 开始计算的,当该值为 0 时,表示实际大小为 1。

The limit value is expressed in bytes and is added to the base address to get the address of the last valid byte.

A limit value of 0 results in exactly 1 valid byte.

中断描述符表的基地址idt_table, 我们在 _set_gate 函数中已经介绍过了,它是一个拥有 256 个元素的数组,每个元素 16 字节,用来存储 256 个门描述符,此处不再赘述。

三、总结

本篇文章,我们分析了 2 个阶段的初始化过程。

从实模式过渡到32位保护模式时,由于内核处于早期启动阶段,没什么中断需要处理,所以加载了空的中断描述符表。

在进入64位模式早期,重置了中断描述符表,但只填充了0~31 号的中断。对于 Page Fault 异常,调用 early_make_pgtable 来处理;其余异常,统一调用 early_fixup_exception 函数来处理。

可以看到,在当前阶段,很多变量或函数都带有 early字样。这些变量或函数是一些临时变量或函数,只会在内核启动早期使用。随着内核初始化的进行,内核会用其它变量或函数来替代这些临时数据或函数。

四、参考资料

1、Intel 开发者手册:Intel 64 and IA-32 Architectures Software Developer Manuals

2、x86_64 ABI

3、结构体填充(structure padding)

4、x86架构--中断及异常处理机制