UP | HOME

初始化中断

Table of Contents

目前已经处于解压缩后的Linux内核中了,有了用于初期启动的基本的 分页 机制

目标是在内核的主体代码执行前做好准备工作

接下来会继续分析关于中断和异常处理部分的代码

上一部分最后来到了下面这个循环:

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

这段代码位于 arch/x86/kernel/head64.c

在分析这段代码之前,先来了解一些关于中断和中断处理程序的知识

中断

中断 是一种由 软件硬件 产生的、向 CPU 发出的 事件

例如,如果用户按下了键盘上的一个按键时,就会产生中断

此时CPU将会暂停当前的任务,并且将控制流转到特殊的程序中 中断处理程序 Interrupt Handler 。一个中断处理程序会对中断进行处理,然后将控制权交还给之前暂停的任务中。中断分为三类:

  1. 软件中断:当一个软件可以向CPU发出信号,表明它需要系统内核的相关功能时产生

    这些中断通常用于系统调用
    
  2. 硬件中断:当一个硬件有任何事件发生时产生

    例如键盘的按键被按下
    
  3. 异常:当CPU检测到错误时产生

    例如 发生了除零错误或者访问了一个不存在的内存页
    

每一个中断和异常都可以由一个数来表示,这个数叫做 向量号 ,它可以取从 0255 中的任何一个数

通常在实践中前 32 个向量号用来表示异常,32 到 255 用来表示用户定义的中断

在上面的代码中, NUM_EXCEPTION_VECTORS 就定义为:

#define NUM_EXCEPTION_VECTORS 32

CPU会从 APIC 或者 CPU 引脚接收中断,并使用中断向量号作为 中断描述符表 的索引。下面的表中列出了 0-31 号异常:

Table 1: 中断描述符表
Vector Mnemonic Description Type Error Code Source
0 #DE Divide Error Fault NO DIV and IDIV
1 #DB Reserved F/T NO  
2 --- NMI INT NO external NMI
3 #BP Breakpoint Trap NO INT 3
4 #OF Overflow Trap NO INTO instruction
5 #BR Bound Range Exceeded Fault NO BOUND instruction
6 #UD Invalid Opcode Fault NO UD2 instruction
7 #NM Device Not Available Fault NO Floating point or [F]WAIT
8 #DF Double Fault Abort YES Ant instrctions which can generate NMI
9 --- Reserved Fault NO  
10 #TS Invalid TSS Fault YES Task switch or TSS access
11 #NP Segment Not Present Fault NO Accessing segment register
12 #SS Stack-Segment Fault Fault YES Stack operations
13 #GP General Protection Fault YES Memory reference
14 #PF Page fault Fault YES Memory reference
15 --- Reserved   NO  
16 #MF x87 FPU fp error Fault NO Floating point or [F]Wait
17 #AC Alignment Check Fault YES Data reference
18 #MC Machine Check Abort NO  
19 #XM SIMD fp exception Fault NO SSE[2,3] instructions
20 #VE Virtualization exc. Fault NO EPT violations
21-31 --- Reserved INT NO External interrupts

为了能够对中断进行处理,CPU使用了一种特殊的结构 中断描述符表 IDT 。IDT 是一个由 描述符 组成的数组,其中每个描述符都为 8个字节 ,与全局描述附表一致;不过不同的是,把IDT中的每一项叫做 gate

  • 为了获得某一项描述符的起始地址,CPU 会把向量号乘以8,在64位模式中则会乘以16
  • CPU使用一个特殊的 GDTR 寄存器来存放全局描述符表的地址,中断描述符表也有一个类似的 寄存器 IDTR ,同时还有用于将基地址加载入这个寄存器的指令 lidt

64位模式下 IDT 的每一项的结构如下:

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                                   15 16                                      0
 --------------------------------------------------------------------------------
|                                      |                                        |
|          Segment Selector            |                 Offset 15..0           |
|                                      |                                        |
 --------------------------------------------------------------------------------

其中:

  • Offset: 到中断处理程序入口点的偏移
  • DPL: 描述符特权级别
  • P: Segment Present 标志
  • Segment selector: 在GDT或LDT中的代码段选择子
  • IST: 用来为中断处理提供一个新的栈
  • Type 域描述了这一项的类型,中断处理程序共分为三种:
    • 任务描述符
    • 中断描述符:中断和陷阱描述符包含了一个指向中断处理程序的 far 指针。二者唯一的不同在于CPU处理 IF 标志

      如果是由中断门进入中断处理程序的,CPU 会清除 IF 标志位
      
      这样当当前中断处理程序执行时,CPU 不会对其他的中断进行处理
      
      只有当当前的中断处理程序返回时,CPU 才在 iret 指令执行时重新设置 IF 标志位
      
    • 陷阱描述符

接下来看一下 CPU 是如何处理中断的:

  • CPU 会在 上保存 标志寄存器cs段寄存器程序计数器IP
  • 如果中断是由错误码引起的(比如 #PF), CPU会在栈上保存 错误码
  • 在中断处理程序执行完毕后,由 iret 指令返回

设置并加载 IDT

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

这里循环内部调用了 set_intr_gate ,它接受两个参数:

  1. 中断号,即 向量号
  2. 中断处理程序的地址

同时,这个函数还会将 中断门 插入IDT 表中,代码中的 &idt_descr 数组 即为 IDT

首先来看一下 early_idt_handler_array 数组 ,它定义在 arch/x86/include/asm/segment.h 头文件中,包含了前32个异常处理程序的地址:

#define EARLY_IDT_HANDLER_SIZE   9
#define NUM_EXCEPTION_VECTORS   32

extern const char early_idt_handler_array[NUM_EXCEPTION_VECTORS][EARLY_IDT_HANDLER_SIZE];

early_idt_handler_array 是一个大小为 288 字节 的数组,每一项为 9 个字节 ,其中

  • 2个字节的备用指令:向栈中 压入 默认错误码 (如果异常本身没有提供错误码的话)
  • 2个字节的指令:向栈中 压入 向量号
  • 剩余5个字节: 跳转异常处理程序
在上面的代码中,通过一个循环向 IDT 中填入了前32项内容,这是因为在整个初期设置阶段,中断是禁用的

early_idt_handler_array 数组中的每一项指向的都是同一个通用中断处理程序,定义在 arch/x86/kernel/head_64.S 

先暂时跳过这个数组的内容,看一下 set_intr_gate 的定义。set_intr_gate 宏定义在 arch/x86/include/asm/desc.h

#define set_intr_gate(n, addr)                                          \
                do {                                                            \
                                BUG_ON((unsigned)n > 0xFF);                             \
                                _set_gate(n, GATE_INTERRUPT, (void *)addr, 0, 0,        \
                                                  __KERNEL_CS);                                 \
                                _trace_set_gate(n, GATE_INTERRUPT, (void *)trace_##addr, \
                                                                0, 0, __KERNEL_CS);                     \
                } while (0)
BUG_ON 宏确保了传入的中断向量号不会大于255,因为最多只有 256 个中断

调用了 _set_gate 函数,它会将中断门写入 IDT:

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);
                write_idt_entry(idt_table, gate, &s);
                write_trace_idt_entry(gate, &s);
}

在 _set_gate 函数的开始,它调用了 pack_gate 函数。这个函数会使用给定的参数填充 gate_desc 结构

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);
}

这里把从主循环中得到的中断处理程序入口点地址拆成三个部分,填入门描述符中。下面的三个宏就用来做这个拆分工作:

#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)
调用 PTR_LOW 可以得到 x 的低 2 个字节

调用 PTR_MIDDLE 可以得到 x 的中间 2 个字节

调用 PTR_HIGH 则能够得到 x 的高 4 个字节

接下来中断处理程序设置段选择子,即内核代码段 __KERNEL_CS

将 Interrupt Stack Table 和 描述符特权等级 (最高特权等级)设置为0

最后设置 GAT_INTERRUPT 类型

现在已经设置好了IDT中的一项,那么通过调用 native_write_idt_entry 函数来把复制到 IDT:

static inline void native_write_idt_entry(gate_desc *idt, int entry, const gate_desc *gate)
{
        memcpy(&idt[entry], gate, sizeof(*gate));
}
主循环结束后,idt_table 就已经设置完毕了

然后就可以通过下面的代码 加载 中断描述符表

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

idt_descr 为:

struct desc_ptr idt_descr = { NR_VECTORS * 16 - 1, (unsigned long) idt_table };

load_idt 函数只是执行了一下 lidt 指令:

asm volatile("lidt %0"::"m" (*dtr));
在代码中还有对 _trace_* 函数的调用。这些函数会用跟 _set_gate 同样的方法对 IDT 门进行设置,但仅有一处不同

这些函数并不设置 idt_table ,而是 trace_idt_table ,用于设置追踪点(tracepoint,将会在其他章节介绍这一部分)。

好了,至此已经了解到,通过设置并加载 中断描述符表 ,能够让CPU在发生中断时做出相应的动作

下面来看一下如何编写中断处理程序

初期中断处理程序

在上面的代码中,用 early_idt_handler_array 的地址来填充了 IDT ,这个 early_idt_handler_array 定义在 arch/x86/kernel/head_64.S

        .globl early_idt_handler_array
early_idt_handlers:
        i = 0
        .rept NUM_EXCEPTION_VECTORS
        .if (EXCEPTION_ERRCODE_MASK >> i) & 1
        pushq $0
        .endif
        pushq $i
        jmp early_idt_handler_common
        i = i + 1
        .fill early_idt_handler_array + i*EARLY_IDT_HANDLER_SIZE - ., 1, 0xcc
        .endr

这段代码自动为前 32 个异常生成了中断处理程序:

  1. 首先,为了统一栈的布局,如果一个异常没有返回错误码,那么就手动在栈中压入一个 0
  2. 再在栈中压入中断向量号
  3. 跳转至通用的中断处理程序 early_idt_handler_common

可以通过 objdump 命令的输出一探究竟:

$ objdump -D vmlinux
...
...
...
ffffffff81fe5000 <early_idt_handler_array>:
ffffffff81fe5000:       6a 00                   pushq  $0x0
ffffffff81fe5002:       6a 00                   pushq  $0x0
ffffffff81fe5004:       e9 17 01 00 00          jmpq   ffffffff81fe5120 <early_idt_handler_common>
ffffffff81fe5009:       6a 00                   pushq  $0x0
ffffffff81fe500b:       6a 01                   pushq  $0x1
ffffffff81fe500d:       e9 0e 01 00 00          jmpq   ffffffff81fe5120 <early_idt_handler_common>
ffffffff81fe5012:       6a 00                   pushq  $0x0
ffffffff81fe5014:       6a 02                   pushq  $0x2
...
...
...

由于在中断发生时,CPU 会自动在栈上压入 标志寄存器CS 段寄存器RIP 寄存器 的内容。因此在 early_idt_handler 执行前,栈的布局如下:

|--------------------|
| %rflags            |
| %cs                |
| %rip               |
| rsp --> error code |
|--------------------|

下面来看一下 early_idt_handler_common 的实现。它也定义在 arch/x86/kernel/head_64.S 文件中。首先它会检查当前中断是否为 不可屏蔽中断 NMI ,如果是则简单地忽略它们:

cmpl $2,(%rsp)
je .Lis_nmi

其中 is_nmi 为:

is_nmi:
        addq $16,%rsp // 从栈顶弹出错误码和中断向量号
        INTERRUPT_RETURN // 调用 INTERRUPT_RETURN ,即 iretq 指令直接返回

如果当前中断不是 NMI ,则首先检查 early_recursion_flag 以避免在 early_idt_handler_common 程序中递归地产生中断。如果一切都没问题,就先在 栈上 保存 通用寄存器 ,为了防止中断返回时寄存器的内容错乱:

pushq %rax
pushq %rcx
pushq %rdx
pushq %rsi
pushq %rdi
pushq %r8
pushq %r9
pushq %r10
pushq %r11

然后检查栈上的 段选择子

cmpl $__KERNEL_CS,96(%rsp) // 段选择子必须为内核代码段,如果不是则跳转到标签 11 
jne 11f // 输出 PANIC 信息并打印栈的内容

然后检查向量号,如果是 #PF缺页中断 Page Fault ,那么就把 cr2 寄存器 中的值赋值给 rdi ,然后调用 early_make_pgtable

cmpl $14,72(%rsp)
jnz 10f
GET_CR2_INTO(%rdi) // 把 cr2 寄存器中的值赋值给 rdi
call early_make_pgtable // 调用 early_make_pgtable
andl %eax,%eax
jz 20f

如果向量号不是 #PF ,那么就恢复通用寄存器:

popq %r11
popq %r10
popq %r9
popq %r8
popq %rdi
popq %rsi
popq %rdx
popq %rcx
popq %rax

并调用 iret 从中断处理程序返回

第一个中断处理程序到这里就结束了

由于它只是一个初期中段处理程序,因此只处理缺页中断

下面首先来看一下缺页中断处理程序,其他中断的处理程序之后再进行分析

缺页中断处理程序

前面第一次见到了初期中断处理程序,它检查了缺页中断的中断号,并调用了 early_make_pgtable 来建立新的页表

接下来需要提供 #PF 中断处理程序,以便为之后将内核加载至 4G 地址以上,并且能访问位于4G以上的 boot_params 结构体

early_make_pgtable 的实现在 arch/x86/kernel/head64.c ,它接受一个参数: cr2 寄存器得到的地址 ,这个地址引发了 内存中断

int __init early_make_pgtable(unsigned long address)
{
                unsigned long physaddr = address - __PAGE_OFFSET;
                unsigned long i;
                pgdval_t pgd, *pgd_p;
                pudval_t pud, *pud_p;
                pmdval_t pmd, *pmd_p;
                // ...
                // ...
                // ...
}

首先它定义了一些 val_t 类型的变量。这些类型均为:

typedef unsigned long   pgdval_t;

这些类型都定义在 arch/x86/include/asm/pgtable_types.h,形式如下:

typedef struct { pgdval_t pgd; } pgd_t;

例如:

extern pgd_t early_level4_pgt[PTRS_PER_PGD];
这里的 early_level4_pgt 代表了 初期顶层页表目录 ,它是一个 pdg_t 类型的数组,

其中的 pgd 指向了 下一级页表

在确认不是非法地址后,取得页表中包含引起 #PF 中断的地址的那一项,将其赋值给 pgd 变量:

pgd_p = &early_level4_pgt[pgd_index(address)].pgd;
pgd = *pgd_p;

接下来检查一下 pgd ,如果它包含了正确的全局页表项的话,就把这一项的物理地址处理后赋值给 pud_p :

// 把全局页表中这一项的物理地址处理后赋值给 pud_p
pud_p = (pudval_t *)((pgd & PTE_PFN_MASK) + __START_KERNEL_map - phys_base); 

其中 PTE_PFN_MASK 是一个宏:

#define PTE_PFN_MASK            ((pteval_t)PHYSICAL_PAGE_MASK)

展开后将为:

(~(PAGE_SIZE-1)) & ((1 << 46) - 1) = 0b1111111111111111111111111111111111111111111111

它是一个46bit大小的页帧屏蔽值

如果 pgd 没有包含有效的地址,就检查 next_early_pgtEARLY_DYNAMIC_PAGE_TABLES (64)的大小。如果 next_early_pgt 比 EARLY_DYNAMIC_PAGE_TABLES 大,就用一个 上层页目录指针 指向 当前的动态页表 ,并将它的 物理地址_KERNPG_TABLE 访问权限 一起写入 全局页目录表

if (next_early_pgt >= EARLY_DYNAMIC_PAGE_TABLES) {
                reset_early_page_tables();
                goto again;
}

pud_p = (pudval_t *)early_dynamic_pgts[next_early_pgt++]; // 用一个上层页目录指针指向当前的动态页表 
for (i = 0; i < PTRS_PER_PUD; i++)
                pud_p[i] = 0; // 初始化当前动态页表
*pgd_p = (pgdval_t)pud_p - __START_KERNEL_map + phys_base + _KERNPG_TABLE; // 将它的物理地址与 _KERPG_TABLE 访问权限一起写入全局页目录表
EARLY_DYNAMIC_PAGE_TABLES 它是一个固定大小的缓冲区,用来在需要的时候建立新的页表

最后来修正上层页目录的地址:

pud_p += pud_index(address);
pud = *pud_p;

下面对 中层页目录 重复上面同样的操作

if (pud)
                pmd_p = (pmdval_t *)((pud & PTE_PFN_MASK) + __START_KERNEL_map - phys_base);
else {
                if (next_early_pgt >= EARLY_DYNAMIC_PAGE_TABLES) {
                                reset_early_page_tables();
                                goto again;
                }

                pmd_p = (pmdval_t *)early_dynamic_pgts[next_early_pgt++];
                for (i = 0; i < PTRS_PER_PMD; i++)
                                pmd_p[i] = 0;
                *pud_p = (pudval_t)pmd_p - __START_KERNEL_map + phys_base + _KERNPG_TABLE;
}

pmd = (physaddr & PMD_MASK) + early_pmd_flags;
pmd_p[pmd_index(address)] = pmd;
到此缺页中断处理程序就完成了它所有的工作

此时 early_level4_pgt 就包含了指向合法地址的项

接下来将会看到进入内核入口点 start_kernel 函数之前剩下所有的准备工作
Next: 进入内核入口点之前最后的准备工作 Previous: 内核解压之后的首要步骤 Home:内核初始化