六、进阶 | 中断和中断处理(3)
中断和中断处理。第3部分。
异常处理
这是关于Linux内核中断和异常处理的章节的第三部分,我们在前一部分停在了arch/x86/kernel/setup.c源代码文件中的setup_arch函数。
我们已经知道这个函数执行特定于架构的初始化。在我们的情况下,setup_arch函数执行x86_64架构相关的初始化。setup_arch是一个大函数,在前一部分我们停在了为以下两个异常设置两个异常处理器:
#DB- 调试异常,将控制权从被中断的进程转移到调试处理器;#BP- 断点异常,由int 3指令引起。
这些异常允许x86_64架构通过kgdb进行早期异常处理以用于调试。
正如你可能记得的,我们在early_trap_init函数中设置了这些异常处理器:
void __init early_trap_init(void)
{
set_intr_gate_ist(X86_TRAP_DB, &debug, DEBUG_STACK);
set_system_intr_gate_ist(X86_TRAP_BP, &int3, DEBUG_STACK);
load_idt(&idt_descr);
}
来自arch/x86/kernel/traps.c。我们已经在前一部分看到了set_intr_gate_ist和set_system_intr_gate_ist函数的实现,现在我们将看看这两个异常处理器的实现。
调试和断点异常
好的,我们在early_trap_init函数中为#DB和#BP异常设置了异常处理器,现在是时候考虑它们的实现了。但在我们这样做之前,首先让我们看看这些异常的细节。
第一个异常 - #DB或debug异常发生在发生调试事件时。例如 - 尝试更改调试寄存器的内容。调试寄存器是特殊的寄存器,从Intel 80386处理器开始出现在x86处理器中,正如你从这个CPU扩展的名称中理解的,这些寄存器的主要目的是调试。
这些寄存器允许在代码上设置断点并读取或写入数据以追踪它。调试寄存器只能在特权模式下访问,任何时候尝试在任何其他权限级别下读取或写入调试寄存器都会导致一般保护故障异常。这就是为什么我们为#DB异常使用了set_intr_gate_ist而不是set_system_intr_gate_ist。
#DB异常的向量号是1(我们将其作为X86_TRAP_DB传递),正如我们可能在规范中读到的,这个异常没有错误代码:
+-----------------------------------------------------+
|Vector|Mnemonic|Description |Type |Error Code|
+-----------------------------------------------------+
|1 | #DB |Reserved |F/T |NO |
+-----------------------------------------------------+
第二个异常是#BP或breakpoint异常,当处理器执行int 3指令时发生。与DB异常不同,#BP异常可能发生在用户空间。我们可以在我们的代码中的任何地方添加它,例如让我们看看一个简单的程序:
// breakpoint.c
#include <stdio.h>
int main() {
int i;
while (i < 6){
printf("i equal to: %d\n", i);
__asm__("int3");
++i;
}
}
如果我们编译并运行这个程序,我们将看到以下输出:
$ gcc breakpoint.c -o breakpoint
$ ./breakpoint
i equal to: 0
Trace/breakpoint trap
但如果我们用gdb运行它,我们将看到我们的断点并可以继续执行我们的程序:
$ gdb breakpoint
...
...
...
(gdb) run
Starting program: /home/alex/breakpoints
i equal to: 0
Program received signal SIGTRAP, Trace/breakpoint trap.
0x0000000000400585 in main ()
=> 0x0000000000400585 <main+31>: 83 45 fc 01 add DWORD PTR [rbp-0x4],0x1
(gdb) c
Continuing.
i equal to: 1
Program received signal SIGTRAP, Trace/breakpoint trap.
0x0000000000400585 in main ()
=> 0x0000000000400585 <main+31>: 83 45 fc 01 add DWORD PTR [rbp-0x4],0x1
(gdb) c
Continuing.
i equal to: 2
Program received signal SIGTRAP, Trace/breakpoint trap.
0x0000000000400585 in main ()
=> 0x0000000000400585 <main+31>: 83 45 fc 01 add DWORD PTR [rbp-0x4],0x1
...
...
...
从这一点开始,我们对这两种异常有了一点了解,我们可以继续考虑它们的处理器。
异常处理器之前的准备
正如你可能注意到的,set_intr_gate_ist和set_system_intr_gate_ist函数在其第二个参数中接收异常处理器的地址。在我们的案例中,我们的两个异常处理器将是:
debug;int3。
你不会在C代码中找到这些函数。所有这些都可以在内核的*.c/*.h文件中找到,这些函数的定义位于arch/x86/include/asm/traps.h内核头文件中:
asmlinkage void debug(void);
和
asmlinkage void int3(void);
你可能注意到了这些函数定义中的asmlinkage指令。该指令是gcc的特殊特定说明符。实际上,对于从汇编中调用的C函数,我们需要明确声明函数调用约定。在我们的情况下,如果函数用asmlinkage描述符制作,那么gcc将编译函数以从栈中检索参数。
因此,两个处理器都在arch/x86/entry/entry_64.S汇编源代码文件中用idtentry宏定义:
idtentry debug do_debug has_error_code=0 paranoid=1 shift_ist=DEBUG_STACK
和
idtentry int3 do_int3 has_error_code=0 paranoid=1 shift_ist=DEBUG_STACK
每个异常处理器可能由两部分组成。第一部分是通用部分,对所有异常处理器都是一样的。一个异常处理器应该在栈上保存通用寄存器,如果异常来自用户空间,则切换到内核栈,并将控制权转移到异常处理器的第二部分。异常处理器的第二部分执行特定于特定异常的某些工作。例如,页面错误异常处理器应该为给定地址找到虚拟页面,无效操作码异常处理器应该发送SIGILL信号等。
正如我们刚刚看到的,异常处理器从arch/x86/entry/entry_64.S汇编源代码文件中的idtentry宏的定义开始,那么让我们看看这个宏的实现。正如我们可能看到的,idtentry宏接受五个参数:
sym- 定义全局符号与.globl name,它将是异常处理器的入口;do_sym- 符号名称,代表异常处理器的次要入口;has_error_code- 有关异常错误代码存在性的信息。
最后两个参数是可选的:
paranoid- 显示我们需要检查当前模式的方法(稍后将详细解释);shift_ist- 显示异常是否在Interrupt Stack Table上运行。
.idtentry宏的定义如下:
.macro idtentry sym do_sym has_error_code:req paranoid=0 shift_ist=-1
ENTRY(\sym)
...
...
...
END(\sym)
.endm
在我们考虑idtentry宏的内部之前,我们应该
知道异常发生时栈的状态。正如我们可能在Intel® 64和IA-32架构软件开发人员手册3A中读到的,异常发生时栈的状态如下:
+------------+
+40 | %SS |
+32 | %RSP |
+24 | %RFLAGS |
+16 | %CS |
+8 | %RIP |
0 | ERROR CODE | <-- %RSP
+------------+
现在我们可以开始考虑idtmacro的实现。两个#DB和BP异常处理器被定义为:
idtentry debug do_debug has_error_code=0 paranoid=1 shift_ist=DEBUG_STACK
idtentry int3 do_int3 has_error_code=0 paranoid=1 shift_ist=DEBUG_STACK
如果我们看这些定义,我们可以知道编译器将生成两个名为debug和int3的例程,这两个异常处理器在一些准备之后都将调用do_debug和do_int3次要处理器。第三个参数定义了错误代码的存在,正如我们所看到的,我们两个异常都没有它们。正如我们上面在图表中看到的,如果异常提供了它,处理器会将错误代码压入栈。在我们的情况下,debug和int3异常没有错误代码。这可能会带来一些困难,因为对于提供错误代码的异常和没有提供错误代码的异常,栈看起来会有所不同。这就是为什么idtentry宏的实现从在栈上放置一个假错误代码开始,如果异常没有提供它:
.ifeq \has_error_code
pushq $-1
.endif
但它不仅仅是一个假错误代码。此外,-1还代表无效的系统调用号,这样系统调用重启逻辑就不会被触发。
idtentry宏的最后两个参数shift_ist和paranoid允许我们知道异常处理器是否在Interrupt Stack Table的栈上运行。你可能已经知道,系统中的每个内核线程都有自己的栈。除了这些栈之外,还有一些与系统中的每个处理器相关联的专用栈。其中之一是 - 异常栈。x86_64架构提供了一个称为 - Interrupt Stack Table的特殊功能。这个功能允许我们在像double fault等原子异常等指定事件时切换到新栈。所以shift_ist参数允许我们知道我们是否需要为异常处理器切换到IST栈。
第二个参数 - paranoid定义了一种方法,帮助我们了解我们是否来自用户空间到异常处理器。最简单的方法是通过CS段寄存器中的CPL或Current Privilege Level。如果它等于3,我们来自用户空间,如果是零,我们来自内核空间:
testl $3,CS(%rsp)
jnz userspace
...
...
...
// 我们来自内核空间
但不幸的是,这种方法不能100%保证。正如内核文档中描述的:
如果我们在一个正常入口写入CS到栈之后但在我们执行SWAPGS之前,可能触发了一个NMI/MCE/DEBUG/whatever超原子入口上下文,
那么唯一安全的方法就是检查GS的较慢方法:RDMSR。
换句话说,例如NMI可能发生在swapgs指令的临界部分内部。在这种情况下,我们应该检查MSR_GS_BASE模型特定寄存器的值,该寄存器存储指向每个CPU区域起始位置的指针。所以为了检查我们是否来自用户空间,我们应该检查MSR_GS_BASE模型特定寄存器的值,如果它是负数,我们来自内核空间,否则我们来自用户空间:
movl $MSR_GS_BASE,%ecx
rdmsr
testl %edx,%edx
js 1f
在代码的前两行中,我们将MSR_GS_BASE模型特定寄存器的值读入edx:eax对。我们不能从用户空间设置gs的负值。但从另一方面,我们知道物理内存的直接映射从虚拟地址0xffff880000000000开始。因此,MSR_GS_BASE将包含从0xffff880000000000到0xffffc7ffffffffff的地址。在执行rdmsr指令后,%edx寄存器中可能的最小值将是-0xffff8800,即无符号4字节中的-30720。这就是为什么内核空间gs指向per-cpu区域起始地址将包含负值。
在我们把假错误代码压入栈之后,我们应该为通用寄存器分配空间:
ALLOC_PT_GPREGS_ON_STACK
宏在arch/x86/entry/calling.h头文件中定义。这个宏只是在栈上分配15*8字节的空间来保存通用寄存器:
.macro ALLOC_PT_GPREGS_ON_STACK addskip=0
addq $-(15*8+\addskip), %rsp
.endm
所以在执行ALLOC_PT_GPREGS_ON_STACK之后,栈将如下所示:
+------------+
+160 | %SS |
+152 | %RSP |
+144 | %RFLAGS |
+136 | %CS |
+128 | %RIP |
+120 | ERROR CODE |
|------------|
+112 | |
+104 | |
+96 | |
+88 | |
+80 | |
+72 | |
+64 | |
+56 | |
+48 | |
+40 | |
+32 | |
+24 | |
+16 | |
+8 | |
+0 | | <- %RSP
+------------+
在我们为通用寄存器分配空间之后,我们做一些检查以了解异常是否来自用户空间,如果是,我们应该返回到被中断进程的栈或留在异常栈上:
.if \paranoid
.if \paranoid == 1
testb $3, CS(%rsp)
jnz 1f
.endif
call paranoid_entry
.else
call error_entry
.endif
让我们考虑这三种情况中的所有这些。
用户空间中发生的异常
首先,让我们考虑一个异常具有paranoid=1的情况,就像我们的debug和int3异常一样。在这种情况下,我们检查CS段寄存器的选择器,并在来自用户空间的情况下跳转到1f标签,或者在其他情况下调用paranoid_entry。
让我们考虑第一种情况,我们从用户空间来到异常处理器。正如上面所描述的,我们应该跳到1标签。1标签从调用开始
call error_entry
例程,它将所有通用寄存器保存在栈上之前分配的区域:
SAVE_C_REGS 8
SAVE_EXTRA_REGS 8
这两个宏在arch/x86/entry/calling.h头文件中定义,只是将通用寄存器的值移动到栈上的某个位置,例如:
.macro SAVE_EXTRA_REGS offset=0
movq %r15, 0*8+\offset(%rsp)
movq %r14, 1*8+\offset(%rsp)
movq %r13, 2*8+\offset(%rsp)
movq %r12, 3*8+\offset(%rsp)
movq %rbp, 4*8+\offset(%rsp)
movq %rbx, 5*8+\offset(%rsp)
.endm
在执行SAVE_C_REGS和SAVE_EXTRA_REGS之后,栈将如下所示:
+------------+
+160 | %SS |
+152 | %RSP |
+144 | %RFLAGS |
+136 | %CS |
+128 | %RIP |
+120 | ERROR CODE |
|------------|
+112 | %RDI |
+104 | %RSI |
+96 | %RDX |
+88 | %RCX |
+80 | %RAX |
+72 | %R8 |
+64 | %R9 |
+56| %R10 |
+48 | %R11 |
+40 | %RBX |
+32 | %RBP |
+24 | %R12 |
+16 | %R13 |
+8 | %R14 |
+0 | %R15 | <- %RSP
+------------+
在内核将通用寄存器保存在栈上之后,我们应该再次检查我们是否来自用户空间:
testb $3, CS+8(%rsp)
jz .Lerror_kernelspace
因为我们可能会有潜在的故障,正如文档中描述的,如果报告了截断的%RIP。无论如何,在两种情况下都会执行SWAPGS指令,并且MSR_KERNEL_GS_BASE和MSR_GS_BASE的值将被交换。从这一点开始,%gs寄存器将指向内核结构的基地址。所以,SWAPGS指令被调用,并且它是error_entry例程的主要点。
现在我们可以回到idtentry宏。我们在调用error_entry之后可以看到以下汇编代码:
movq %rsp, %rdi
call sync_regs
在这里我们将栈指针%rsp的基础地址放入%rdi寄存器,这将是sync_regs函数的第一个参数(根据x86_64 ABI),并调用这个函数,它在arch/x86/kernel/traps.c源代码文件中定义:
asmlinkage __visible notrace struct pt_regs *sync_regs(struct pt_regs *eregs)
{
struct pt_regs *regs = task_pt_regs(current);
*regs = *eregs;
return regs;
}
这个函数获取task_ptr_regs宏的结果,它在arch/x86/include/asm/processor.h头文件中定义,将其存储在栈指针中并返回它。task_ptr_regs宏展开为thread.sp0的地址,它代表指向正常内核栈的指针:
#define task_pt_regs(tsk) ((struct pt_regs *)(tsk)->thread.sp0 - 1)
由于我们来自用户空间,这意味着异常处理器将在实际的进程上下文中运行。在我们从sync_regs获取栈指针之后,我们切换栈:
movq %rax, %rsp
异常处理器在调用次级处理器之前的最后两步是:
- 将包含保留的通用寄存器的
pt_regs结构的指针传递给%rdi寄存器:
movq %rsp, %rdi
它将作为次级异常处理器的第一个参数。
- 将错误代码传递给
%rsi寄存器,它将是异常处理器的第二个参数,并将栈上的它设置为-1,目的与我们之前所做的相同 - 为了防止系统调用的重启:
.if \has_error_code
movq ORIG_RAX(%rsp), %rsi
movq $-1, ORIG_RAX(%rsp)
.else
xorl %esi, %esi
.endif
此外,你可以看到我们在上面将%esi寄存器置零,以防异常不提供错误代码。
最后,我们只是调用次级异常处理器:
call \do_sym
这将是debug异常的:
dotraplinkage void do_debug(struct pt_regs *regs, long error_code);
和int 3异常的:
dotraplinkage void notrace do_int3(struct pt_regs *regs, long error_code);
在这一部分,我们将不会看到次级处理器的实现,因为它们非常特定,但我们将在未来的部分中看到其中的一些。
我们刚刚考虑了第一种情况,即异常发生在用户空间。让我们考虑最后两种。
在内核空间中发生的paranoid > 0的异常
在这种情况下,异常发生在内核空间,并且idtentry宏为这个异常定义为paranoid=1。这个paranoid值意味着我们应该使用我们在本部分开头看到的较慢的方法来检查我们是否真的来自内核空间。paranoid_entry例程允许我们知道这一点:
ENTRY(paranoid_entry)
cld
SAVE_C_REGS 8
SAVE_EXTRA_REGS 8
movl $1, %ebx
movl $MSR_GS_BASE, %ecx
rdmsr
testl %edx, %edx
js 1f
SWAPGS
xorl %ebx, %ebx
1: ret
END(paranoid_entry)
如你所见,这个函数表示我们之前所涵盖的相同内容。我们使用第二种(慢速)方法来获取有关被中断任务之前状态的信息。由于我们检查了这一点,并且在我们来自用户空间的情况下执行了SWAPGS,我们应该做我们之前做过的同样的事情:我们需要将包含通用寄存器的结构的指针放入%rdi(它将是次级处理器的第一个参数),并且如果异常提供错误代码,则将其放入%rsi(它将是次级处理器的第二个参数):
movq %rsp, %rdi
.if \has_error_code
movq ORIG_RAX(%rsp), %rsi
movq $-1, ORIG_RAX(%rsp)
.else
xorl %esi, %esi
.endif
异常次级处理器被调用之前的最后一步是清理新的IST栈帧:
.if \shift_ist != -1
subq $EXCEPTION_STKSZ, CPU_TSS_IST(\shift_ist)
.endif
你可能还记得我们将shift_ist作为idtentry宏的参数传递。在这里我们检查它的值,如果它不等于-1,我们通过shift_ist索引获取Interrupt Stack Table的栈指针并设置它。
在这第二种方法的最后,我们只是像之前一样调用异常的次级处理器:
call \do_sym
最后一种方法与之前的两种方法类似,但是异常发生在paranoid=0,并且我们可以使用快速方法来确定我们来自哪里。
从异常处理器退出
次级处理器完成工作后,我们将返回到idtentry宏,下一步将是跳转到error_exit:
jmp error_exit
例程。error_exit函数定义在同一个arch/x86/entry/entry_64.S汇编源代码文件中,这个函数的主要目标是要知道我们来自哪里(来自用户空间还是内核空间),并根据这一点执行SWPAGS。恢复寄存器到之前的状态,并执行iret指令,将控制权转移给被中断的任务。
就这样。
结论
这是关于Linux内核中断和中断处理的第三部分的结束。我们在前一部分看到了中断描述符表的初始化,带有#DB和#BP门,在这一部分我们开始深入了解在控制权转移给异常处理器之前的准备和一些中断处理器的实现。在下一部分,我们将继续深入探讨这个话题,接下来是setup_arch函数,并尝试理解与中断处理相关的内容。
链接
- 调试寄存器
- Intel 80385
- INT 3
- gcc
- TSS
- GNU汇编.error指令
- dwarf2
- CFI指令
- IRQ
- 系统调用
- swapgs
- SIGTRAP
- 每个CPU变量
- kgdb
- ACPI
- 上一部分
"《Linux嵌入式必考必会》专栏,专为嵌入式开发者量身打造,聚焦Linux环境下嵌入式系统面试高频考点。涵盖基础架构、内核原理、驱动开发、系统优化等核心技能,实战案例与理论解析并重。助你快速掌握面试关键,提升竞争力。

