中断和中断处理. Part 3.

异常处理

这是第三部分 chapter Linux内核中有关中断和异常处理 在前面的内容中 part 我们停止了 setup_arch 函数 arch/x86/kernel/setup.c 源代码文件.

我们已经知道该函数执行特定于体系结构的东西的初始化。 在我们的例子中,setup_arch函数执行与x86_64 architecture相关的初始化工作。 setup_arch是一个大功能,在上一部分中,我们停止了以下两个异常的两个异常处理程序的设置:

  • #DB - 调试异常,将控制从中断的进程转移到调试处理程序;
  • #BP - 由int指令引起的断点异常。

这些异常允许x86_64体系结构具有早期异常处理功能,以便于通过kgdb 进行调试 正如您记得的,我们在early_trap_init函数中设置了这些异常处理程序:

  1. void __init early_trap_init(void)
  2. {
  3. set_intr_gate_ist(X86_TRAP_DB, &debug, DEBUG_STACK);
  4. set_system_intr_gate_ist(X86_TRAP_BP, &int3, DEBUG_STACK);
  5. load_idt(&idt_descr);
  6. }

来自 arch/x86/kernel/traps.c. 我们已经在上一部分中看到了set_intr_gate_istset_system_intr_gate_ist函数的实现,现在我们将看看这两个异常处理程序的实现。

调试和断点异常

Ok,我们在early_trap_init函数中为#DB#BP异常设置了异常处理程序,现在是时候考虑它们的实现了。但是,在执行此操作之前,我们首先看一下这些异常的详细信息。

第一个异常-#DBdebug异常是在发生调试事件时发生的。例如-尝试更改debug register 的内容。debug register是从 Intel 80386 处理器开始在x86处理器中提供的特殊寄存器,从CPU扩展的名可以知道,这些寄存器中的正在调试这些功能。

这些寄存器允许在代码上设置断点,并读取或写入数据以对其进行跟踪。debug register只能在特权模式下访问,以任何其他特权级别执行时尝试读取或写入调试寄存器都会导致general protection fault 异常。这就是为什么我们对#DB异常使用了set_intr_gate_ist,而不对set_system_intr_gate_ist使用。

#DB异常的记录编号为1(我们将其作为X86_TRAP_DB传递),并且正如我们在规范中可能会看到的那样,该异常没有错误代码:

  1. +-----------------------------------------------------+
  2. |Vector|Mnemonic|Description |Type |Error Code|
  3. +-----------------------------------------------------+
  4. |1 | #DB |Reserved |F/T |NO |
  5. +-----------------------------------------------------+

第二个异常是处理器执行int 3 指令时发生的#BPbreakpointv异常。 与DB异常不同,#BP异常可能在用户空间中发生。 我们可以将其添加到代码中的任何位置,让我们看一下简单的程序:

  1. // breakpoint.c
  2. #include <stdio.h>
  3. int main() {
  4. int i;
  5. while (i < 6){
  6. printf("i equal to: %d\n", i);
  7. __asm__("int3");
  8. ++i;
  9. }
  10. }

如果我们编译并运行该程序,我们将看到以下输出:

  1. $ gcc breakpoint.c -o breakpoint
  2. i equal to: 0
  3. Trace/breakpoint trap

但是,如果将其与gdb一起运行,我们将看到断点并可以继续执行程序:

  1. $ gdb breakpoint
  2. ...
  3. ...
  4. ...
  5. (gdb) run
  6. Starting program: /home/alex/breakpoints
  7. i equal to: 0
  8. Program received signal SIGTRAP, Trace/breakpoint trap.
  9. 0x0000000000400585 in main ()
  10. => 0x0000000000400585 <main+31>: 83 45 fc 01 add DWORD PTR [rbp-0x4],0x1
  11. (gdb) c
  12. Continuing.
  13. i equal to: 1
  14. Program received signal SIGTRAP, Trace/breakpoint trap.
  15. 0x0000000000400585 in main ()
  16. => 0x0000000000400585 <main+31>: 83 45 fc 01 add DWORD PTR [rbp-0x4],0x1
  17. (gdb) c
  18. Continuing.
  19. i equal to: 2
  20. Program received signal SIGTRAP, Trace/breakpoint trap.
  21. 0x0000000000400585 in main ()
  22. => 0x0000000000400585 <main+31>: 83 45 fc 01 add DWORD PTR [rbp-0x4],0x1
  23. ...
  24. ...
  25. ...

从这一刻起,我们对这两个异常有所了解,我们可以把关注点转移到它们的处理程序。

异常处理程序之前的准备

正如您之前可能注意到的那样,set_intr_gate_istset_system_intr_gate_ist函数在其第二个参数中使用异常处理程序的地址。 否则,我们的两个异常处理程序将是:

  • debug;
  • int3.

你在C代码中找不到这些功能。这些所有功能都可以在内核的*.c/*.h文件中找到,这些功能的定义位于arch/x86/include/asm/traps.h内核头文件:

  1. asmlinkage void debug(void);

and

  1. asmlinkage void int3(void);

您可能会在这些函数的定义中注意到asmlinkage指令。 该指令是gcc 的特殊说明符。 实际上,对于从汇编中调用的C函数,我们需要显式声明函数调用约定。在我们的例子中,如果函数使用asmlinkage描述符创建,则gcc将编译该函数以从堆栈中检索参数。 So, both handlers are defined in the arch/x86/entry/entry_64.S assembly source code file with the idtentry macro:

因此,这两个处理程序都在arch/x86/entry/entry_64.S 汇编源代码文件中定义idtentry宏:

  1. idtentry debug do_debug has_error_code=0 paranoid=1 shift_ist=DEBUG_STACK

and

  1. idtentry int3 do_int3 has_error_code=0 paranoid=1 shift_ist=DEBUG_STACK

每个异常处理程序可以由两部分组成。 第一部分是通用部分,所有异常处理程序都相同。 异常处理程序应将general purpose registers 保存在堆栈上,如果异常来自用户空间,则应切换到内核堆栈,并将控制权转移到异常的第二部分 处理程序。 异常处理程序的第二部分完成某些工作取决于某些异常。 例如,页面错误异常处理程序应找到给定地址的虚拟页面,无效的操作码异常处理程序应发送SIGILL signal 等。 正如我们所见,异常处理程序从arch/x86/kernel/entry_64.S 汇编源代码文件,因此让我们看一下该宏的实现。 我们可以会看到,idtentry宏接受五个参数:

symglobl name定义全局符号,它将作为异常处理程序的入口; do_sym符号名称,代表异常处理程序的辅助条目; *has_error_code有关异常错误代码的存在的信息。

最后两个参数是可选的:

paranoid-向我们展示了如何检查当前模式(稍后将详细解释); shift_ist-显示我们是在“中断堆栈表”上运行的异常。

idtentry宏的定义如下:

  1. .macro idtentry sym do_sym has_error_code:req paranoid=0 shift_ist=-1
  2. ENTRY(\sym)
  3. ...
  4. ...
  5. ...
  6. END(\sym)
  7. .endm

Before we will consider internals of the idtentry macro, we should to know state of stack when an exception occurs. As we may read in the Intel® 64 and IA-32 Architectures Software Developer’s Manual 3A, the state of stack when an exception occurs is following:

在考虑identry宏的内部之前,我们应该知道发生异常时的堆栈状态。 正如我们可能会在Intel®64 and IA-32 Architectures Software Developer’s Manual 3A ,则发生异常时的堆栈状态如下:

  1. +------------+
  2. +40 | %SS |
  3. +32 | %RSP |
  4. +24 | %RFLAGS |
  5. +16 | %CS |
  6. +8 | %RIP |
  7. 0 | ERROR CODE | <-- %RSP
  8. +------------+

现在我们可以开始考虑idtmacro的实现了。 #DBBP异常处理程序都定义为:

  1. idtentry debug do_debug has_error_code=0 paranoid=1 shift_ist=DEBUG_STACK
  2. idtentry int3 do_int3 has_error_code=0 paranoid=1 shift_ist=DEBUG_STACK

如果我们看一下这些定义,我们可能知道编译器将生成两个带有debugint3名称的例程,并且这两个异常处理程序在经过一些准备后将调用do_debugdo_int3辅助处理程序。 第三个参数定义了错误代码的存在,并且我们可以看到我们的两个异常都没有它们。 如上图所示,如果有异常,处理器会将错误代码压入堆栈。 在我们的例子中,debugint3异常没有错误代码。 这可能会带来一些困难,因为对于提供错误代码的异常和未提供错误代码的异常,堆栈的外观会有所不同。 这就是为什么idtentry宏的实现始于在异常未提供的情况下将伪造的错误代码放入堆栈的原因:

  1. .ifeq \has_error_code
  2. pushq $-1
  3. .endif

这不仅是伪造的错误代码。此外,“-1”还代表无效的系统调用号码,因此系统调用重启逻辑将不会被触发。

idtentryshift_istparanoid的最后两个参数允许您知道是否从Interrupt Stack Table运行在堆栈上的异常处理程序。您可能已经知道系统中的每个内核线程都有自己的堆栈。除了这些堆栈外,还有一些专用堆栈与系统中的每个处理器相关联。这些堆栈之一是-异常堆栈。 x86_64 架构提供了称为中断堆栈表的特殊功能。此功能允许针对指定事件(例如原子异常(如double fault)等)切换到新堆栈。因此,使用shift_ist参数可以让我们知道是否需要为异常处理程序打开IST堆栈。

第二个参数paranoid定义了一种方法,该方法可以帮助我们知道我们是来自用户空间还是来自异常处理程序。确定这一点的最简单方法是通过CS段寄存器中的CPLCurrent Privilege Level。如果等于3,则来自用户空间;如果为零,则来自内核空间:

  1. testl $3,CS(%rsp)
  2. jnz userspace
  3. ...
  4. ...
  5. ...
  6. // 我们来自内核空间

但是不幸的是,这种方法不能100%的保证。如内核文档中所述:

if we are in an NMI/MCE/DEBUG/whatever super-atomic entry context, which might have triggered right after a normal entry wrote CS to the stack but before we executed SWAPGS, then the only safe way to check for GS is the slower method: the RDMSR.

换句话说,例如,NMI可能发生在swapgs 指令的关键部分内。 这样,我们应该检查MSR_GS_BASE 模型专用寄存器 的值,该值存储指向每个cpu区域开始的指针。 因此,要检查我们是否来自用户空间,我们应该检查MSR_GS_BASE模型特定寄存器的值,如果它是负数,则来自内核空间,否则来自用户空间:

  1. movl $MSR_GS_BASE,%ecx
  2. rdmsr
  3. testl %edx,%edx
  4. js 1f

在前两行代码中,我们将模型专用寄存器MSR_GS_BASE的值读入edx:eax对。 我们不能从用户空间为gs设置负值。 但是从另一面我们知道,物理内存的直接映射是从虚拟地址0xffff880000000000开始的。 这样,MSR_GS_BASE将包含从0xffff8800000000000xffffc7ffffffffff的地址。 执行完rdmsr指令后,%edx寄存器中的最小可能值为-0xffff8800,即无符号4个字节的-30720。 这就是指向每个CPU区域开始的内核空间gs包含负值的原因。 将伪错误代码压入堆栈后,我们应该使用以下命令为通用寄存器分配空间:

  1. ALLOC_PT_GPREGS_ON_STACK

arch / x86 / entry / calling.h 头文件中定义的宏。 该宏仅在堆栈上分配15 * 8字节空间以保留通用寄存器:

  1. .macro ALLOC_PT_GPREGS_ON_STACK addskip=0
  2. addq $-(15*8+\addskip), %rsp
  3. .endm

因此,在执行ALLOC_PT_GPREGS_ON_STACK之后,堆栈将如下所示:

  1. +------------+
  2. +160 | %SS |
  3. +152 | %RSP |
  4. +144 | %RFLAGS |
  5. +136 | %CS |
  6. +128 | %RIP |
  7. +120 | ERROR CODE |
  8. |------------|
  9. +112 | |
  10. +104 | |
  11. +96 | |
  12. +88 | |
  13. +80 | |
  14. +72 | |
  15. +64 | |
  16. +56 | |
  17. +48 | |
  18. +40 | |
  19. +32 | |
  20. +24 | |
  21. +16 | |
  22. +8 | |
  23. +0 | | <- %RSP
  24. +------------+

在为通用寄存器分配空间之后,我们进行一些检查以了解异常是否来自用户空间,如果是,则应移回中断的进程堆栈或保留在异常堆栈上:

  1. .if \paranoid
  2. .if \paranoid == 1
  3. testb $3, CS(%rsp)
  4. jnz 1f
  5. .endif
  6. call paranoid_entry
  7. .else
  8. call error_entry
  9. .endif

让我们考虑一下所有情况

用户空间中发生异常

首先,让我们考虑一个异常具有像我们的debugint3异常这样的paranoid = 1的情况。 在这种情况下,如果来自用户空间,否则我们将从CS段寄存器中检查选择器,并跳转到1f标签上,否则将以其他方式调用paranoid_entry。 Let’s consider first case when we came from userspace to an exception handler. As described above we should jump at 1 label. The 1 label starts from the call of the

  1. call error_entry

该例程将所有通用寄存器保存在堆栈中先前分配的区域中:

  1. SAVE_C_REGS 8
  2. SAVE_EXTRA_REGS 8

这两个宏都在arch/x86/entry/calling.h 头文件中定义并移动 通用寄存器的值到堆栈中的某个位置,例如:

  1. .macro SAVE_EXTRA_REGS offset=0
  2. movq %r15, 0*8+\offset(%rsp)
  3. movq %r14, 1*8+\offset(%rsp)
  4. movq %r13, 2*8+\offset(%rsp)
  5. movq %r12, 3*8+\offset(%rsp)
  6. movq %rbp, 4*8+\offset(%rsp)
  7. movq %rbx, 5*8+\offset(%rsp)
  8. .endm

执行SAVE_C_REGSSAVE_EXTRA_REGS之后,堆栈将如下所示:

  1. +------------+
  2. +160 | %SS |
  3. +152 | %RSP |
  4. +144 | %RFLAGS |
  5. +136 | %CS |
  6. +128 | %RIP |
  7. +120 | ERROR CODE |
  8. |------------|
  9. +112 | %RDI |
  10. +104 | %RSI |
  11. +96 | %RDX |
  12. +88 | %RCX |
  13. +80 | %RAX |
  14. +72 | %R8 |
  15. +64 | %R9 |
  16. +56 | %R10 |
  17. +48 | %R11 |
  18. +40 | %RBX |
  19. +32 | %RBP |
  20. +24 | %R12 |
  21. +16 | %R13 |
  22. +8 | %R14 |
  23. +0 | %R15 | <- %RSP
  24. +------------+

在内核将通用寄存器保存在堆栈中之后,应该使用以下命令再次检查来自用户空间:

  1. testb $3, CS+8(%rsp)
  2. jz .Lerror_kernelspace

因为如果报告文档中描述的%RIP被截断,我们可能有潜在的错误。 无论如何,在两种情况下,都将执行SWAPGS 指令,并且将交换MSR_KERNEL_GS_BASEMSR_GS_BASE中的值。 从这一刻开始,%gs寄存器将指向内核结构的基址。 因此,调用了SWAPGS指令,这是error_entry路由的要点。

现在我们可以回到idtentry宏。 调用error_entry之后,我们可能会看到以下汇编代码:

  1. movq %rsp, %rdi
  2. call sync_regs

在这里,我们将堆栈指针%rdi寄存器的基地址放入其中,这将是sync_regs的第一个参数(根据x86_64 ABI ) 函数并调用arch / x86 / kernel / traps.c 源代码中定义的函数 文件:

  1. asmlinkage __visible notrace struct pt_regs *sync_regs(struct pt_regs *eregs)
  2. {
  3. struct pt_regs *regs = task_pt_regs(current);
  4. *regs = *eregs;
  5. return regs;
  6. }

此函数采用在[arch/x86/include/asm/processor.h]中定义的task_ptr_regs宏的结果(https://github.com/torvalds/linux/blob/16f73eb02d7e1765ccab3d2018e0bd98eb93d973/arch/x86/ include / asm / processor.h)头文件,将其存储在堆栈指针中并返回。 宏task_ptr_regs扩展为thread.sp0的地址,该地址表示指向普通内核堆栈的指针:

  1. #define task_pt_regs(tsk) ((struct pt_regs *)(tsk)->thread.sp0 - 1)

正如来自用户空间一样,这意味着异常处理程序将在实际流程上下文中运行。 从sync_regs获取堆栈指针后,我们切换堆栈:

  1. movq %rax, %rsp

异常处理程序将调用辅助处理程序之前的最后两个步骤是:

1.传递指向pt_regs结构的指针,该结构包含保留的通用寄存器到%rdi寄存器:

  1. movq %rsp, %rdi

因为它将作为辅助异常处理程序的第一个参数传递。

2.将错误代码传递到%rsi寄存器,因为它将是异常处理程序的第二个参数,并在堆栈上将其设置为-1,其目的与我们之前相同 防止重新启动系统调用 :

  1. .if \has_error_code
  2. movq ORIG_RAX(%rsp), %rsi
  3. movq $-1, ORIG_RAX(%rsp)
  4. .else
  5. xorl %esi, %esi
  6. .endif

另外,如果异常不提供错误代码,可能会看到我们将上面的%esi寄存器清零了。

最后,我们只调用辅助异常处理程序:

  1. call \do_sym

which:

  1. dotraplinkage void do_debug(struct pt_regs *regs, long error_code);

将用于debug异常和:

  1. dotraplinkage void notrace do_int3(struct pt_regs *regs, long error_code);

将用于int 3例外。 在本部分中,我们将看不到辅助处理程序的实现,因为它们非常具体,但是在下一部分中将看到其中的一些。

我们只是考虑了在用户空间中发生异常的第一种情况。 我们考虑最后两个。

内核空间中发生了偏执> 0的异常

在这种情况下,内核空间中发生了异常,并且为该异常使用paranoid = 1定义了idtentry宏。 paranoid的值意味着我们应该使用在本部分开头看到的更慢的方式来检查我们是否真的来自内核空间。 paranoid_entry路由使我们知道这一点:

  1. ENTRY(paranoid_entry)
  2. cld
  3. SAVE_C_REGS 8
  4. SAVE_EXTRA_REGS 8
  5. movl $1, %ebx
  6. movl $MSR_GS_BASE, %ecx
  7. rdmsr
  8. testl %edx, %edx
  9. js 1f
  10. SWAPGS
  11. xorl %ebx, %ebx
  12. 1: ret
  13. END(paranoid_entry)

如您所见,此功能代表了我们之前介绍的功能。 我们使用第二(慢)方法来获取有关被中断任务的先前状态的信息。 当我们检查并在来自用户空间的情况下执行SWAPGS时,我们应该做与之前相同的操作:我们需要将指针指向一个结构,该结构将通用寄存器保存到%rdi( 将是辅助处理程序的第一个参数),如果异常将其提供给%rsi(将是辅助处理程序的第二个参数),则放置错误代码:

  1. movq %rsp, %rdi
  2. .if \has_error_code
  3. movq ORIG_RAX(%rsp), %rsi
  4. movq $-1, ORIG_RAX(%rsp)
  5. .else
  6. xorl %esi, %esi
  7. .endif

调用异常的辅助处理程序之前的最后一步是清理新的IST堆栈帧:

  1. .if \shift_ist != -1
  2. subq $EXCEPTION_STKSZ, CPU_TSS_IST(\shift_ist)
  3. .endif

您可能还记得我们将shift_ist作为iddentry宏的参数传递了。 在这里,我们检查其值,如果其值不等于-1,则通过shift_ist 索引从中断堆栈表中获取指向堆栈的指针并进行设置。

在第二种方法的结尾,我们只是像以前一样调用辅助异常处理程序:

  1. call \do_sym

最后一种方法与前面两种方法都相似,但是paranoid = 0发生了例外,我们可以使用快速方法确定我们的来源。

从异常处理程序退出

在辅助处理程序完成工作之后,我们将返回到idtentry宏,下一步将跳转到error_exit

  1. jmp error_exit

在相同的arch/x86/entry/entry_64.S 汇编源代码中定义的error_exit函数 文件,此功能的主要目标是从用户空间或内核空间知道我们的位置,并根据此位置执行SWPAGS。 将寄存器恢复到先前的状态,并执行iret指令将控制权转移到中断的任务。

That’s all.

总结完毕

第三部分到此结束,有关Linux内核中的中断和中断处理。 在上一部分中,我们看到了使用#DB#BP中断描述符表 的初始化,并开始进行控制之前的准备工作 将被转移到异常处理程序和这部分中某些中断处理程序的实现。 在下一部分中,我们将继续深入探讨该主题,然后通过setup_arch函数进行下一步,并尝试了解处理相关内容的中断。 如果您有任何疑问或建议,请在twitter 上给我写评论或ping我。 请注意,英语不是我的母语,对于由此带来的不便,我深表歉意。如果发现任何错误,请将PR发送给[linux-insides](https://github.com/0xAX/linux-insides)。

以下链接