六、进阶 | 中断和中断处理(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_errordo_divide_erroroverflowdo_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内核子系统中的上下文跟踪,提供了内核边界探针,以跟踪不同上下文级别之间的转换,有两个基本的初始上下文:userkernelexception_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_enterexception_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内核有一个特殊的机制,允许内核询问何时发生某事,这个机制称为notifiersnotifier chains。例如,这种机制用于USB热插拔事件(请参阅drivers/usb/core/notify.c),用于内存热插拔(请参阅include/linux/memory.hhotplug_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_blockatomic_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函数将返回-1do_trap函数的执行将继续。如果我们通过了do_trap_no_signal函数并在此后没有从do_trap中退出,这意味着先前的上下文是 - user。大多数由处理器引起的异常被Linux解释为错误条件,例如除以零、无效的操作码等。当异常发生时,Linux内核会向引起异常的进程发送信号,以通知它有不正确的条件。因此,在do_trap函数中,我们需要发送具有给定编号的信号(对于除以零错误为SIGFPE,对于非法指令为SIGILL等)。首先,我们使用填充thread.error_codethread_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寄存器的MPTS标志被设置的情况下执行waitfwait指令;
  • 处理器在控制寄存器cr0中的TS标志被设置并且EM标志被清除的情况下执行x87 FPUMMXSSE指令。

设备不可用异常的处理程序是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(&current->thread.fpu);

一般保护故障异常处理程序

下一个异常是#GP一般保护故障。当处理器检测到一类称为一般保护违规的违规行为时,就会发生这种异常。可能是:

  • 超出访问csdsesfsgs段时的段限制;
  • 用系统段的选择符加载ssdsesfsgs寄存器;
  • 违反任何特权规则;
  • 以及其他......

这种异常的处理程序是arch/x86/kernel/traps.c中的do_general_protectiondo_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协处理器异常的处理程序等等。

链接

#嵌入式##面试##面经#
Linux嵌入式必考必会 文章被收录于专栏

"《Linux嵌入式必考必会》专栏,专为嵌入式开发者量身打造,聚焦Linux环境下嵌入式系统面试高频考点。涵盖基础架构、内核原理、驱动开发、系统优化等核心技能,实战案例与理论解析并重。助你快速掌握面试关键,提升竞争力。

全部评论

相关推荐

球Offer上岸👑:可能是大环境太差了 太卷了 学历也很重要 hc也不是很多 所以很难
点赞 评论 收藏
分享
评论
点赞
收藏
分享

创作者周榜

更多
牛客网
牛客企业服务