六、进阶 | 中断和中断处理(6)
中断和中断处理。第6部分。
不可屏蔽中断处理程序
这是Linux内核中断和中断处理章节的第六部分,上一部分我们看到了实现一些异常处理程序,例如一般保护故障、除法异常、无效操作码异常等。正如我在上一部分所写的,我们将在这一部分看到剩余异常的实现。我们将看到以下处理程序的实现:
现在,让我们开始。
不可屏蔽中断处理
不可屏蔽中断是一种硬件中断,不能通过标准的屏蔽技术忽略。一般来说,不可屏蔽中断可以通过以下两种方式之一产生:
- 外部硬件在CPU上断言不可屏蔽中断引脚。
- 处理器在系统总线或APIC串行总线上接收到一个带有
NMI
传递模式的消息。
当处理器从这些源之一接收到NMI
时,它会立即通过调用中断向量号为2
的NMI
处理程序来处理它(参见第一部分的表)。我们已经用中断描述符表填充了向量号、nmi
中断处理程序的地址和NMI_STACK
中断栈表条目:
set_intr_gate_ist(X86_TRAP_NMI, &nmi, NMI_STACK);
在定义在arch/x86/kernel/traps.c源代码文件中的trap_init
函数中。在之前的部分中,我们看到所有中断处理程序的入口点都是用以下宏定义的:
.macro idtentry sym do_sym has_error_code:req paranoid=0 shift_ist=-1
ENTRY(\sym)
...
...
...
END(\sym)
.endm
来自arch/x86/entry/entry_64.S汇编源代码文件。但是,Non-Maskable
中断的处理程序不是用这个宏定义的。它有自己的入口点:
ENTRY(nmi)
...
...
...
END(nmi)
在同一个arch/x86/entry/entry_64.S汇编文件中。让我们深入了解它,并尝试理解Non-Maskable
中断处理程序是如何工作的。nmi
处理程序从调用以下宏开始:
PARAVIRT_ADJUST_EXCEPTION_FRAME
但我们不会在这一部分深入细节,因为这个宏与准虚拟化有关,我们将在另一章中看到。在这之后,将rdx
寄存器的内容保存到栈上:
pushq %rdx
并分配检查cs
在非屏蔽中断发生时是否不是内核段:
cmpl $__KERNEL_CS, 16(%rsp)
jne first_nmi
__KERNEL_CS
宏定义在arch/x86/include/asm/segment.h中,并表示全局描述符表中的第二个描述符:
#define GDT_ENTRY_KERNEL_CS 2
#define __KERNEL_CS (GDT_ENTRY_KERNEL_CS*8)
关于GDT
的更多信息,你可以在Linux内核启动过程的第二部分中阅读。如果cs
不是内核段,这意味着它不是嵌套的NMI
,我们跳到first_nmi
标签。让我们考虑这种情况。首先,我们在first_nmi
标签中将当前栈指针的地址放入rdx
,并在栈上推送1
:
first_nmi:
movq (%rsp), %rdx
pushq $1
我们为什么要在栈上推送1
?正如注释所说:“我们允许在NMIs中设置断点”。在x86_64上,像其他架构一样,CPU在完成第一个NMI
之前不会执行另一个NMI
。NMI
中断像其他中断和异常一样,以iret指令结束。如果NMI
处理程序触发了页面错误或断点或其他使用iret
指令的异常,如果这发生在NMI
上下文中,CPU将离开NMI
上下文,新的NMI
可能进来。用于从这些异常返回的iret
将重新启用NMIs
,我们将得到嵌套的非屏蔽中断。问题是NMI
处理程序不会返回到它触发异常时的状态,而是返回到一个将允许新的NMIs
抢占运行中的NMI
处理程序的状态。如果另一个NMI
在第一个NMI处理程序完成之前进来,新的NMI将覆盖被抢占的NMIs
栈。我们可以有嵌套的NMIs
,下一个NMI
使用前一个NMI
的栈顶。这意味着我们不能执行它,因为嵌套的非屏蔽中断会破坏前一个非屏蔽中断的栈。这就是为什么我们在栈上为临时变量分配了空间。我们将检查这个变量是否在执行前一个NMI
时被设置,并在不是嵌套NMI
时清除。我们在这里推送1
到之前在栈上分配的空间,以表示当前正在执行non-maskable
中断。记住,当NMI
或其他异常发生时,我们有以下堆栈帧:
+------------------------+
| SS |
| RSP |
| RFLAGS |
| CS |
| RIP |
+------------------------+
如果异常有错误代码,还会有错误代码。所以,在所有这些操作之后,我们的堆栈帧将看起来像这样:
+------------------------+
| SS |
| RSP |
| RFLAGS |
| CS |
| RIP |
| RDX |
| 1 |
+------------------------+
接下来,我们在栈上分配另外40
个字节:
subq $(5*8), %rsp
并在分配的空间后面推送原始堆栈帧的副本:
.rept 5
pushq 11*8(%rsp)
.endr
用.rept汇编指令。我们需要原始中断堆栈的副本。一般来说,我们需要两份中断堆栈的副本。第一个是copied
中断堆栈:saved
堆栈帧和copied
堆栈帧。现在我们将原始堆栈帧推送到saved
堆栈帧,它位于刚刚分配的40
字节之后(copied
堆栈帧)。这个堆栈帧用于修复嵌套NMI可能更改的copied
堆栈帧。第二个是copied
堆栈帧,由任何嵌套的NMIs
修改,以让第一个NMI
知道我们触发了第二个NMI
,我们应该重复第一个NMI
处理程序。好的,我们已经制作了原始堆栈帧的第一份副本,现在该制作第二份副本了:
assembly
addq $(10*8), %rsp
.rept 5 pushq -68(%rsp) .endr subq $(58), %rsp
经过所有这些操作后,我们的堆栈帧将如下所示:
+-------------------------+ | original SS | | original Return RSP | | original RFLAGS | | original CS | | original RIP | +-------------------------+ | temp storage for rdx | +-------------------------+ | NMI executing variable | +-------------------------+ | copied SS | | copied Return RSP | | copied RFLAGS | | copied CS | | copied RIP | +-------------------------+ | Saved SS | | Saved Return RSP | | Saved RFLAGS | | Saved CS | | Saved RIP | +-------------------------+
之后,我们在栈上推送一个虚拟错误代码,就像我们在之前的异常处理程序中已经做过的那样,并为通用寄存器在栈上分配空间:
```assembly
pushq $-1
ALLOC_PT_GPREGS_ON_STACK
我们已经在中断的第三部分看到了ALLOC_PT_GPREGS_ON_STACK
宏的实现。这个宏定义在arch/x86/entry/calling.h中,并且又一次在栈上分配了120
字节用于通用寄存器,从rdi
到r15
:
.macro ALLOC_PT_GPREGS_ON_STACK addskip=0
addq $-(15*8+\addskip), %rsp
.endm
为通用寄存器分配空间后,我们可以看到调用paranoid_entry
:
call paranoid_entry
我们可以从之前的部分中记住这个标签。它将通用寄存器推送到栈上,读取MSR_GS_BASE
模型特定寄存器并检查其值。如果MSR_GS_BASE
的值为负,我们来自内核模式并且只是从paranoid_entry
返回,否则意味着我们来自用户模式并且需要执行swapgs
指令,这将更改用户gs
与内核gs
:
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
指令后,我们将ebx
寄存器清零。下次我们将检查这个寄存器的内容,如果执行了swapgs
,那么ebx
必须包含0
,否则为1
。下一步,我们将cr2
控制寄存器的值存储到r12
寄存器中,因为NMI
处理程序可能会导致页面错误
并破坏这个控制寄存器的值:
movq %cr2, %r12
现在是时候调用实际的NMI
处理程序了。我们将pt_regs
的地址推送到rdi
,错误代码推送到rsi
,并调用do_nmi
处理程序:
movq %rsp, %rdi
movq $-1, %rsi
call do_nmi
我们将在这部分稍后回到do_nmi
,但现在让我们看看do_nmi
执行完毕后会发生什么。do_nmi
处理程序完成后,我们检查cr2
寄存器,因为我们在执行do_nmi
期间可能会遇到页面错误,如果遇到,我们恢复原始的cr2
,否则我们跳到标签1
。之后,我们测试ebx
寄存器的内容(记住,如果我们使用了swapgs
指令,它必须包含0
,如果我们没有使用它,它包含1
)并执行SWAPGS_UNSAFE_STACK
,如果它包含1
,或者跳到nmi_restore
标签。SWAPGS_UNSAFE_STACK
宏只是展开为swapgs
指令。在nmi_restore
标签中,我们恢复通用寄存器,清除为这些寄存器分配的栈空间,清除我们的临时变量,并使用INTERRUPT_RETURN
宏退出中断处理程序:
movq %cr2, %rcx
cmpq %rcx, %r12
je 1f
movq %r12, %cr2
1:
testl %ebx, %ebx
jnz nmi_restore
nmi_swapgs:
SWAPGS_UNSAFE_STACK
nmi_restore:
RESTORE_EXTRA_REGS
RESTORE_C_REGS
/* Pop the extra iret frame at once */
REMOVE_PT_GPREGS_FROM_STACK 6*8
/* Clear the NMI executing stack variable */
movq $0, 5*8(%rsp)
INTERRUPT_RETURN
其中INTERRUPT_RETURN
定义在arch/x86/include/asm/irqflags.h中,只是展开为iret
指令。就这些。
现在让我们考虑另一种情况,当之前的NMI
中断还没有完成执行时,另一个NMI
中断发生了。你可以从这部分的开始记住,我们已经检查了我们是否来自用户空间,并在这种情况下跳转到first_nmi
:
cmpl $__KERNEL_CS, 16(%rsp)
jne first_nmi
请注意,在这种情况下,每次都是非屏蔽NMI
,因为如果第一个NMI
捕获了页面错误、断点或其他异常,它将在内核模式下执行。如果我们不是来自用户空间,首先我们测试我们的临时变量:
cmpl $1, -8(%rsp)
je nested_nmi
如果它设置为1
,我们跳到nested_nmi
标签。如果它不是1
,我们测试IST
栈。在嵌套NMIs
的情况下,我们检查我们是否在repeat_nmi
之上。在这种情况下,我们忽略它,否则我们检查我们是否高于end_repeat_nmi
并跳到nested_nmi_out
标签。
现在让我们看看do_nmi
异常处理程序。这个函数定义在arch/x86/kernel/nmi.c源代码文件中,并且接受两个参数:
pt_regs
的地址;- 错误代码。
像所有异常处理程序一样。do_nmi
从调用nmi_nesting_preprocess
函数开始,以调用nmi_nesting_postprocess
结束。nmi_nesting_preprocess
函数检查我们可能不在调试栈上工作,如果我们在调试栈上,将update_debug_stack
每个CPU变量设置为1
并调用arch/x86/kernel/cpu/common.c中的debug_stack_set_zero
函数。这个函数增加了每个CPU的debug_stack_use_ctr
变量并加载新的中断描述符表
:
static inline void nmi_nesting_preprocess(struct pt_regs *regs)
{
if (unlikely(is_debug_stack(regs->sp))) {
debug_stack_set_zero();
this_cpu_write(update_debug_stack, 1);
}
}
nmi_nesting_postprocess
函数检查我们在nmi_nesting_preprocess
中设置的update_debug_stack
每个CPU变量,并重置调试栈,或者换句话说,它加载原始的中断描述符表
。在调用nmi_nesting_preprocess
函数后,我们可以看到在do_nmi
中的nmi_enter
调用。nmi_enter
增加了被中断进程的lockdep_recursion
字段,更新抢占计数器并通知RCU子系统关于NMI
。还有一个nmi_exit
函数,它和nmi_enter
做同样的事情,但是反过来。在nmi_enter
之后,我们在irq_stat
结构中增加__nmi_count字段,并调用
default_do_nmi函数。首先在
default_do_nmi`中我们检查前一个nmi的地址并更新最后一个nmi的地址为当前的:
if (regs->ip == __this_cpu_read(last_nmi_rip))
b2b = true;
else
__this_cpu_write(swallow_nmi, false);
__this_cpu_write(last_nmi_rip, regs->ip);
在这之后,我们首先需要处理特定于CPU的NMIs
:
handled = nmi_handle(NMI_LOCAL, regs, b2b);
__this_cpu_add(nmi_stats.normal, handled);
然后是非特定的NMIs
,取决于它的原因:
reason = x86_platform.get_nmi_reason();
if (reason & NMI_REASON_MASK) {
if (reason & NMI_REASON_SERR)
pci_serr_error(reason, regs);
else if (reason & NMI_REASON_IOCHK)
io_check_error(reason, regs);
__this_cpu_add(nmi_stats.external, 1);
return;
}
就这些。
范围超出异常
下一个异常是BOUND
范围超出异常。BOUND
指令确定第一个操作数(数组索引)是否在第二个操作数(界限操作数)指定的数组界限之内。如果索引超出界限,将发生BOUND
范围超出异常或#BR
。#BR
异常的处理程序是do_bounds
函数,定义在arch/x86/kernel/traps.c。do_bounds
处理程序从调用exception_enter
函数开始,以调用exception_exit
结束:
prev_state = exception_enter();
if (notify_die(DIE_TRAP, "bounds", regs, error_code,
X86_TRAP_BR, SIGSEGV) == NOTIFY_STOP)
goto exit;
...
...
...
exception_exit(prev_state);
return;
在我们得到前一个上下文的状态后,我们将异常添加到notify_die
链中,如果它返回NOTIFY_STOP
,我们从异常中返回。关于通知链和上下文跟踪
函数的更多信息,你可以在上一部分中阅读。接下来,我们使用contidional_sti
函数启用中断,该函数检查IF
标志,并根据其值调用local_irq_enable
:
conditional_sti(regs);
if (!user_mode(regs))
die("bounds", regs, error_code);
并检查我们是否没有来自用户模式,我们使用die
函数发送SIGSEGV
信号。在这之后,我们检查MPX是否启用,如果这个特性被禁用,我们跳到exit_trap
标签:
if (!cpu_feature_enabled(X86_FEATURE_MPX)) {
goto exit_trap;
在那里我们执行do_trap
函数(关于它的更多信息,你可以在上一部分找到):
exit_trap:
do_trap(X86_TRAP_BR, SIGSEGV, "bounds", regs, error_code, NULL);
exception_exit(prev_state);
如果MPX
特性被启用,我们使用get_xsave_field_ptr
函数检查BNDSTATUS
,如果它为零,这意味着MPX
不是这个异常的原因:
bndcsr = get_xsave_field_ptr(XSTATE_BNDCSR);
if (!bndcsr)
goto exit_trap;
在这之后,仍然只有一种方式MPX
是这个异常的原因。我们不会在这一部分深入Intel内存保护扩展的细节,但将在另一章中看到。
协处理器异常和SIMD异常
接下来的两个异常是x87 FPU 浮点错误异常或#MF
和SIMD 浮点异常或#XF
。第一个异常发生在x87 FPU
检测到浮点错误时。例如,除以零、数值溢出等。第二个异常发生在处理器检测到SSE/SSE2/SSE3 SIMD
浮点异常时。它可能与x87 FPU
相同。这些异常的处理程序是do_coprocessor_error
和do_simd_coprocessor_error
,定义在arch/x86/kernel/traps.c,彼此非常相似。它们都调用同一个源代码文件中的math_error
函数,但传递不同的向量号。do_coprocessor_error
向math_error
传递X86_TRAP_MF
向量号:
dotraplinkage void do_coprocessor_error(struct pt_regs *regs, long error_code)
{
enum ctx_state prev_state;
prev_state = exception_enter();
math_error(regs, error_code, X86_TRAP_MF);
exception_exit(prev_state);
}
和do_simd_coprocessor_error
向math_error
传递X86_TRAP_XF
:
dotraplinkage void
do_simd_coprocessor_error(struct pt_regs *regs, long error_code)
{
enum ctx_state prev_state;
prev_state = exception_enter();
math_error(regs, error_code, X86_TRAP_XF);
exception_exit(prev_state);
}
首先,math_error
函数定义当前被中断的任务,其FPU的地址,描述异常的字符串,将其添加到notify_die
链中,并在它返回NOTIFY_STOP
时从异常处理程序返回:
struct task_struct *task = current;
struct fpu *fpu = &task->thread.fpu;
siginfo_t info;
char *str = (trapnr == X86_TRAP_MF) ? "fpu exception" :
"simd exception";
if (notify_die(DIE_TRAP, str, regs, error_code, trapnr, SIGFPE) == NOTIFY_STOP)
return;
在这之后,我们检查我们是否来自内核模式,如果是,我们将尝试使用fixup_exception
函数修复异常。如果我们不能,我们用异常的错误代码和向量号填充任务,并终止:
if (!user_mode(regs)) {
if (!fixup_exception(regs)) {
task->thread.error_code = error_code;
task->thread.trap_nr = trapnr;
die(str, regs, error_code);
}
return;
}
如果我们来自用户模式,我们保存fpu
状态,用异常的向量号填充任务结构,并用信号编号、errno
、异常发生地址和信号代码填充siginfo_t
:
fpu__save(fpu);
task->thread.trap_nr = trapnr;
task->thread.error_code = error_code;
info.si_signo = SIGFPE;
info.si_errno = 0;
info.si_addr = (void __user *)uprobe_get_trap_addr(regs);
info.si_code = fpu__exception_code(fpu, trapnr);
在这之后,我们检查信号代码,如果它非零,我们返回:
if (!info.si_code)
return;
或者在最后发送SIGFPE
信号:
force_sig_info(SIGFPE, &info, task);
就这些。
结论
这是中断和中断处理章节的第六部分的结尾,我们在这一部分看到了一些异常处理程序的实现,比如non-maskable
中断、SIMD和x87 FPU浮点异常。最后,我们在这一部分以trap_init
函数结束,并将进入下一部分。我们接下来关注的是外部中断和init/main.c中的early_irq_init
函数。
如果你有任何问题或建议,请在评论中告诉我,或者在twitter上联系我。
请注意,英语不是我的第一语言,如果有任何不便,我深表歉意。如果你发现任何错误,请向我发送PR到linux-insides。
链接
- 一般保护故障
- 操作码
- 不可屏蔽
- BOUND指令
- CPU插座
- 中断描述符表
- 中断栈表
- 准虚拟化
- .rept
- SIMD
- 协处理器
- x86_64
- iret
- 页面错误
- 断点
- 全局描述符表
- 堆栈帧
- 模型特定寄存器
- percpu
- RCU
- MPX
- x87 FPU
- 上一部分
"《Linux嵌入式必考必会》专栏,专为嵌入式开发者量身打造,聚焦Linux环境下嵌入式系统面试高频考点。涵盖基础架构、内核原理、驱动开发、系统优化等核心技能,实战案例与理论解析并重。助你快速掌握面试关键,提升竞争力。