六、进阶 | 中断和中断处理(5)
中断和中断处理。第5部分。
异常处理程序的实现
这是关于Linux内核中断和异常处理的第五部分,在之前的部分中,我们停在了将中断门设置到中断描述符表。我们在arch/x86/kernel/traps.c源代码文件中的trap_init
函数中做到了这一点。我们在上一部分中只看到了这些中断门的设置,而在当前部分中,我们将看到这些门的异常处理程序的实现。异常处理程序执行前的准备工作在arch/x86/entry/entry_64.S汇编文件中,发生在定义异常入口点的idtentry宏中:
idtentry divide_error do_divide_error has_error_code=0
idtentry overflow do_overflow has_error_code=0
idtentry invalid_op do_invalid_op has_error_code=0
idtentry bounds do_bounds has_error_code=0
idtentry device_not_available do_device_not_available has_error_code=0
idtentry coprocessor_segment_overrun do_coprocessor_segment_overrun has_error_code=0
idtentry invalid_TSS do_invalid_TSS has_error_code=1
idtentry segment_not_present do_segment_not_present has_error_code=1
idtentry spurious_interrupt_bug do_spurious_interrupt_bug has_error_code=0
idtentry coprocessor_error do_coprocessor_error has_error_code=0
idtentry alignment_check do_alignment_check has_error_code=1
idtentry simd_coprocessor_error do_simd_coprocessor_error has_error_code=0
idtentry
宏在实际的异常处理程序(例如divide_error
的do_divide_error
,overflow
的do_overflow
等)获得控制之前执行以下准备工作。换句话说,idtentry
宏在栈上为寄存器( pt_regs结构)分配空间,如果中断/异常没有错误代码,则为栈一致性推送虚拟错误代码,检查cs
段寄存器中的段选择器,并根据先前的状态(用户空间或内核空间)进行切换。在所有这些准备工作之后,它调用实际的中断/异常处理程序:
.macro idtentry sym do_sym has_error_code:req paranoid=0 shift_ist=-1
ENTRY(\sym)
...
...
...
call \do_sym
...
...
...
END(\sym)
.endm
在异常处理程序完成其工作后,idtentry
宏恢复被中断任务的栈和通用寄存器,并执行iret指令:
ENTRY(paranoid_exit)
...
...
...
RESTORE_EXTRA_REGS
RESTORE_C_REGS
REMOVE_PT_GPREGS_FROM_STACK 8
INTERRUPT_RETURN
END(paranoid_exit)
其中INTERRUPT_RETURN
是:
#define INTERRUPT_RETURN jmp native_iret
...
ENTRY(native_iret)
.global native_irq_return_iret
native_irq_return_iret:
iretq
关于idtentry
宏的更多信息,你可以在章节的第三部分中阅读。好的,现在我们看到了异常处理程序执行前的准备工作,现在是时候看看处理程序了。首先让我们看看以下处理程序:
- divide_error
- overflow
- invalid_op
- coprocessor_segment_overrun
- invalid_TSS
- segment_not_present
- stack_segment
- alignment_check
所有这些处理程序都在arch/x86/kernel/traps.c源代码文件中使用DO_ERROR
宏定义:
DO_ERROR(X86_TRAP_DE, SIGFPE, "divide error", divide_error)
DO_ERROR(X86_TRAP_OF, SIGSEGV, "overflow", overflow)
DO_ERROR(X86_TRAP_UD, SIGILL, "invalid opcode", invalid_op)
DO_ERROR(X86_TRAP_OLD_MF, SIGFPE, "coprocessor segment overrun", coprocessor_segment_overrun)
DO_ERROR(X86_TRAP_TS, SIGSEGV, "invalid TSS", invalid_TSS)
DO_ERROR(X86_TRAP_NP, SIGBUS, "segment not present", segment_not_present)
DO_ERROR(X86_TRAP_SS, SIGBUS, "stack segment", stack_segment)
DO_ERROR(X86_TRAP_AC, SIGBUS, "alignment check", alignment_check)
正如我们所看到的,DO_ERROR
宏接受4个参数:
- 中断的向量号;
- 将发送给被中断进程的信号号;
- 描述异常的字符串;
- 异常处理程序的入口点。
此宏在同一个源代码文件中定义,并展开为具有do_handler
名称的函数:
#define DO_ERROR(trapnr, signr, str, name) \
dotraplinkage void do_##name(struct pt_regs *regs, long error_code) \
{ \
do_error_trap(regs, error_code, str, trapnr, signr); \
}
注意##
标记。这是一个特殊功能 - GCC宏连接,它连接两个给定的字符串。例如,我们示例中的第一个DO_ERROR
将展开为:
dotraplinkage void do_divide_error(struct pt_regs *regs, long error_code) \
{
...
}
我们可以看到,由DO_ERROR
宏生成的所有函数只是调用了arch/x86/kernel/traps.c中的do_error_trap
函数。让我们看看do_error_trap
函数的实现。
陷阱处理程序
do_error_trap
函数从以下两个函数开始和结束:
enum ctx_state prev_state = exception_enter();
...
...
...
exception_exit(prev_state);
来自include/linux/context_tracking.h。Linux内核子系统中的上下文跟踪,提供了内核边界探针,以跟踪不同上下文级别之间的转换,有两个基本的初始上下文:user
或kernel
。exception_enter
函数检查上下文跟踪是否已启用。之后,如果启用了,exception_enter
读取先前的上下文,并将其与CONTEXT_KERNEL
进行比较。如果先前的上下文是user
,我们调用kernel/context_tracking.c中的context_tracking_exit
函数,该函数通知上下文跟踪子系统,处理器正在退出用户模式并进入内核模式:
if (!context_tracking_is_enabled())
return 0;
prev_ctx = this_cpu_read(context_tracking.state);
if (prev_ctx != CONTEXT_KERNEL)
context_tracking_exit(prev_ctx);
return prev_ctx;
如果先前的上下文不是user
,我们只是返回它。pre_ctx
具有enum ctx_state
类型,定义在include/linux/context_tracking_state.h中,如下所示:
enum ctx_state {
CONTEXT_KERNEL = 0,
CONTEXT_USER,
CONTEXT_GUEST,
} state;
第二个函数是exception_exit
,在同一个include/linuxcontext_tracking.h文件中定义,检查上下文跟踪是否已启用,并在先前上下文为user
时调用context_tracking_enter
函数:
static inline void exception_exit(enum ctx_state prev_ctx)
{
if (context_tracking_is_enabled()) {
if (prev_ctx != CONTEXT_KERNEL)
context_tracking_enter(prev_ctx);
}
}
context_tracking_enter
函数通知上下文跟踪子系统,处理器将从内核模式进入用户模式。在exception_enter
和exception_exit
之间,我们可以看到以下代码:
if (notify_die(DIE_TRAP, str, regs, error_code, trapnr, signr) !=
NOTIFY_STOP) {
conditional_sti(regs);
do_trap(trapnr, signr, str, regs, error_code,
fill_trap_info(regs, signr, trapnr, &info));
}
首先,它调用notify_die
函数,该函数定义在kernel/notifier.c中。为了获得内核恐慌、内核oops、不可屏蔽中断或其他事件的通知,调用者需要将自己插入到notify_die
链中,notify_die
函数就是执行这个操作的。Linux内核有一个特殊的机制,允许内核询问何时发生某事,这个机制称为notifiers
或notifier chains
。例如,这种机制用于USB
热插拔事件(请参阅drivers/usb/core/notify.c),用于内存热插拔(请参阅include/linux/memory.h、hotplug_memory_notifier
宏等...),系统重启等。一个通知链就是这样一个简单的单链表。当Linux内核子系统想要被特定事件通知时,它填写一个特殊的notifier_block
结构,并将其传递给notifier_chain_register
函数。一个事件可以通过调用notifier_call_chain
函数来发送。首先,notify_die
函数用陷阱号、陷阱字符串、寄存器等值填充die_args
结构:
struct die_args args = {
.regs = regs,
.str = str,
.err = err,
.trapnr = trap,
.signr = sig,
}
并返回atomic_notifier_call_chain
函数的结果与die_chain
:
static ATOMIC_NOTIFIER_HEAD(die_chain);
return atomic_notifier_call_chain(&die_chain, val, &args);
它只是展开为包含锁和notifier_block
的atomic_notifier_head
结构:
struct atomic_notifier_head {
spinlock_t lock;
struct notifier_block __rcu *head;
};
atomic_notifier_call_chain
函数依次调用通知链中的每个函数,并返回最后一个被调用的通知函数的值。如果do_error_trap
中的notify_die
没有返回NOTIFY_STOP
,我们执行arch/x86/kernel/traps.c中的conditional_sti
函数,该函数检查中断标志的值,并根据需要启用中断:
static inline void conditional_sti(struct pt_regs *regs)
{
if (regs->flags & X86_EFLAGS_IF)
local_irq_enable();
}
关于local_irq_enable
宏的更多信息,你可以在本章的第二部分链接中阅读。do_error_trap
中的下一个也是最后一个调用是do_trap
函数。首先,do_trap
函数定义了tsk
变量,该变量具有task_struct
类型,代表当前被中断的进程。在定义了tsk
之后,我们可以看到对do_trap_no_signal
函数的调用:
struct task_struct *tsk = current;
if (!do_trap_no_signal(tsk, trapnr, str, regs, error_code))
return;
do_trap_no_signal
函数执行两项检查:
- 我们是否来自虚拟8086模式;
- 我们是否来自内核空间。
if (v8086_mode(regs)) {
...
}
if (!user_mode(regs)) {
...
}
return -1;
我们将不考虑第一种情况,因为长模式不支持虚拟8086模式。在第二种情况下,我们调用fixup_exception
函数,该函数将尝试恢复故障,如果无法恢复则die
:
if (!fixup_exception(regs)) {
tsk->thread.error_code = error_code;
tsk->thread.trap_nr = trapnr;
die(str, regs, error_code);
}
die
函数定义在arch/x86/kernel/dumpstack.c源代码文件中,打印有关栈、寄存器、内核模块和引起的内核oops的有用信息。如果我们来自用户空间,do_trap_no_signal
函数将返回-1
,do_trap
函数的执行将继续。如果我们通过了do_trap_no_signal
函数并在此后没有从do_trap
中退出,这意味着先前的上下文是 - user
。大多数由处理器引起的异常被Linux解释为错误条件,例如除以零、无效的操作码等。当异常发生时,Linux内核会向引起异常的进程发送信号,以通知它有不正确的条件。因此,在do_trap
函数中,我们需要发送具有给定编号的信号(对于除以零错误为SIGFPE
,对于非法指令为SIGILL
等)。首先,我们使用填充thread.error_code
和thread_trap_nr
保存错误代码和向量号到当前中断进程:
tsk->thread.error_code = error_code;
tsk->thread.trap_nr = trapnr;
在此之后,我们检查我们是否需要打印有关中断进程未处理信号的信息。我们检查show_unhandled_signals
变量是否设置,unhandled_signal
函数从kernel/signal.c将返回未处理的信号(S)和printk速率限制:
#ifdef CONFIG_X86_64
if (show_unhandled_signals && unhandled_signal(tsk, signr) &&
printk_ratelimit()) {
pr_info("%s[%d] trap %s ip:%lx sp:%lx error:%lx",
tsk->comm, tsk->pid, str,
regs->ip, regs->sp, error_code);
print_vma_addr(" in ", regs->ip);
pr_cont("\n");
}
#endif
并向中断进程发送给定的信号:
force_sig_info(signr, info ?: SEND_SIG_PRIV, tsk);
这是do_trap
的结尾。我们刚刚看到了八个不同异常的通用实现,这些异常是用DO_ERROR
宏定义的。现在让我们来看看其他异常处理程序。
双重故障
下一个异常是#DF
或双重故障
。当处理器在调用先前异常的处理程序时检测到第二个异常时,就会发生这种异常。我们在上一部分中为这个异常设置了陷阱门:
set_intr_gate_ist(X86_TRAP_DF, &double_fault, DOUBLEFAULT_STACK);
注意,这个异常在DOUBLEFAULT_STACK
中断栈表上运行,其索引为-1
:
#define DOUBLEFAULT_STACK 1
double_fault
是这个异常的处理程序,在arch/x86/kernel/traps.c源代码文件中定义。double_fault
处理程序从定义两个变量开始:描述异常的字符串和被中断进程,像其他异常处理程序一样:
static const char str[] = "double fault";
struct task_struct *tsk = current;
双重故障异常的处理程序分为两部分。第一部分是检查,检查是否在espfix64
栈上发生了non-IST
故障。实际上,iret
指令仅在返回到16
位段时恢复底16
位。espfix
功能解决了这个问题。因此,如果在espfix64栈上发生non-IST
故障,我们修改栈,使其看起来像General Protection Fault
:
struct pt_regs *normal_regs = task_pt_regs(current);
memmove(&normal_regs->ip, (void *)regs->sp, 5*8);
ormal_regs->orig_ax = 0;
regs->ip = (unsigned long)general_protection;
regs->sp = (unsigned long)&normal_regs->orig_ax;
return;
在第二种情况下,我们所做的几乎与之前处理程序中所做的相同。首先是调用ist_enter
函数,该函数丢弃先前的上下文,在我们的例子中是user
:
ist_enter(regs);
在此之后,我们像在之前的处理程序中所做的那样,用Double fault
异常的向量号和错误代码填充被中断进程:
tsk->thread.error_code = error_code;
tsk->thread.trap_nr = X86_TRAP_DF;
接下来我们打印有关双重故障的有用信息(PID号,寄存器内容):
#ifdef CONFIG_DOUBLEFAULT
df_debug(regs, error_code);
#endif
然后进入死循环:
for (;;)
die(str, regs, error_code);
就这样。
设备不可用异常处理程序
下一个异常是#NM
或设备不可用
。根据以下情况,可能会发生设备不可用
异常:
- 处理器在控制寄存器
cr0
中的EM标志被设置的情况下执行x87 FPU浮点指令; - 处理器在
cr0
寄存器的MP
和TS
标志被设置的情况下执行wait
或fwait
指令; - 处理器在控制寄存器
cr0
中的TS
标志被设置并且EM
标志被清除的情况下执行x87 FPU、MMX或SSE指令。
设备不可用
异常的处理程序是do_device_not_available
函数,它也在arch/x86/kernel/traps.c源代码文件中定义。它从获取先前上下文开始和结束,就像我们在这部分开头看到的其他陷阱一样:
enum ctx_state prev_state;
prev_state = exception_enter();
...
...
...
exception_exit(prev_state);
接下来我们检查FPU
是否不急切:
BUG_ON(use_eager_fpu());
当我们切换到一个任务或中断时,我们可以避免加载FPU
状态。如果一个任务将使用它,我们会捕获Device not Available exception
异常。如果我们在任务切换期间加载FPU
状态,那么FPU
就是急切的。接下来我们检查cr0
控制寄存器上的EM
标志,它可以告诉我们x87
浮点单元是否存在(标志清除)或不存在(标志设置):
#ifdef CONFIG_MATH_EMULATION
if (read_cr0() & X86_CR0_EM) {
struct math_emu_info info = { };
conditional_sti(regs);
info.regs = regs;
math_emulate(&info);
exception_exit(prev_state);
return;
}
#endif
如果x87
浮点单元不存在,我们使用conditional_sti
启用中断,用被中断任务的寄存器填充math_emu_info
(定义在arch/x86/include/asm/math_emu.h)结构,并调用arch/x86/math-emu/fpu_entry.c中的math_emulate
函数。正如从函数名称可以理解的那样,它模拟了X87 FPU
单元(关于x87
我们将在专门章节中了解更多)。另一方面,如果X86_CR0_EM
标志清除,这意味着x87 FPU
单元存在,我们调用arch/x86/kernel/fpu/core.c中的fpu__restore
函数,该函数将FPU
寄存器从fpustate
复制到现场硬件寄存器。之后可以使用FPU
指令:
fpu__restore(¤t->thread.fpu);
一般保护故障异常处理程序
下一个异常是#GP
或一般保护故障
。当处理器检测到一类称为一般保护违规
的违规行为时,就会发生这种异常。可能是:
- 超出访问
cs
、ds
、es
、fs
或gs
段时的段限制; - 用系统段的选择符加载
ss
、ds
、es
、fs
或gs
寄存器; - 违反任何特权规则;
- 以及其他......
这种异常的处理程序是arch/x86/kernel/traps.c中的do_general_protection
。do_general_protection
函数像其他异常处理程序一样,从获取先前上下文开始和结束:
prev_state = exception_enter();
...
exception_exit(prev_state);
在此之后,如果它们被禁用,我们启用中断,并检查我们是否来自虚拟8086模式:
conditional_sti(regs);
if (v8086_mode(regs)) {
local_irq_enable();
handle_vm86_fault((struct kernel_vm86_regs *) regs, error_code);
goto exit;
}
由于长模式不支持此模式,我们将不考虑这种情况的异常处理。接下来检查先前模式是否为内核模式,并尝试修复陷阱。如果我们无法修复当前的一般保护故障异常,我们用异常的向量号和错误代码填充被中断进程,将其添加到notify_die
链中:
if (!user_mode(regs)) {
if (fixup_exception(regs))
goto exit;
tsk->thread.error_code = error_code;
tsk->thread.trap_nr = X86_TRAP_GP;
if (notify_die(DIE_GPF, "general protection fault", regs, error_code,
X86_TRAP_GP, SIGSEGV) != NOTIFY_STOP)
die("general protection fault", regs, error_code);
goto exit;
}
如果我们能够修复异常,我们跳转到exit
标签,该标签从异常状态退出:
exit:
exception_exit(prev_state);
如果我们来自用户模式,我们像在do_trap
函数中所做的那样,向用户模式中的被中断进程发送SIGSEGV
信号:
if (show_unhandled_signals && unhandled_signal(tsk, SIGSEGV) &&
printk_ratelimit()) {
pr_info("%s[%d] general protection ip:%lx sp:%lx error:%lx",
tsk->comm, task_pid_nr(tsk),
regs->ip, regs->sp, error_code);
print_vma_addr(" in ", regs->ip);
pr_cont("\n");
}
force_sig_info(SIGSEGV, SEND_SIG_PRIV, tsk);
就这样。
结论
这是中断和中断处理章节的第五部分的结尾,我们在这部分看到了一些中断处理程序的实现。在下一部分中,我们将继续深入研究中断和异常处理程序,并将看到不可屏蔽中断的处理程序,数学协处理器和SIMD协处理器异常的处理程序等等。
链接
- 中断描述符表
- iret指令
- GCC宏连接
- 内核恐慌
- 内核oops
- 不可屏蔽中断
- 热插拔
- 中断标志
- 长模式
- 信号
- printk
- 协处理器
- SIMD
- 中断栈表
- PID
- x87 FPU
- 控制寄存器
- MMX
- 上一部分
"《Linux嵌入式必考必会》专栏,专为嵌入式开发者量身打造,聚焦Linux环境下嵌入式系统面试高频考点。涵盖基础架构、内核原理、驱动开发、系统优化等核心技能,实战案例与理论解析并重。助你快速掌握面试关键,提升竞争力。