layout | title | subtitle | date | author | header-img | catalog | tags | ||||
---|---|---|---|---|---|---|---|---|---|---|---|
post
|
Linux内核20-Linux内核的异常处理过程
|
Linux内核是如何响应异常信号的
|
2020-03-25
|
Tupelo Shen
|
img/post-bg-unix-linux.jpg
|
true
|
|
异常处理的基本过程
当异常发生时,Linux内核给造成异常的进程发送一个信号,告知其发生了异常。比如,如果一个进程尝试除零操作,CPU会产生除法错误
异常,相应的异常处理程序发送SIGFPE信号给当前进程,然后由其采取必要的步骤,恢复还是中止(如果该信号没有对应的处理程序,则中止)。
但是,除了这些常规的异常以外,Linux有时候会特意利用某些CPU异常管理硬件资源。比如,可以使用Device not available
这个异常,结合cr0寄存器中的TS标志,强迫内核重新加载CPU的浮点寄存器,从而更新最新的值。还可以使用Page Fault
页错误异常,用来推迟给进程分配新的页帧,直到该分配的时候。因为它的异常处理程序极其复杂,我们在后续的文章中再详细叙述这一部分的内容。
异常处理程序一般会执行下面三步:
- 保存内核态堆栈中的大部分寄存器内容(这一部分一般是汇编语言编写);
- 处理异常(一般使用C语言函数实现);
- 退出异常处理程序(调用ret_from_exception()函数)。
为了更好地处理异常,必须正确地初始化IDT表中的每一项。这部分的工作都是由trap_init()
函数实现的,通过调用set_trap_gate()、set_intr_gate()、set_system_gate()、set_system_intr_gate()和set_task_gate()这些辅助函数完成,trap_init()
函数的一部分代码片段,如下所示:
set_trap_gate(0,÷_error);
set_trap_gate(1,&debug);
set_intr_gate(2,&nmi);
set_system_intr_gate(3,&int3);
set_system_gate(4,&overflow);
set_system_gate(5,&bounds);
set_trap_gate(6,&invalid_op);
set_trap_gate(7,&device_not_available);
set_task_gate(8,31);
set_trap_gate(9,&coprocessor_segment_overrun);
set_trap_gate(10,&invalid_TSS);
set_trap_gate(11,&segment_not_present);
set_trap_gate(12,&stack_segment);
set_trap_gate(13,&general_protection);
set_intr_gate(14,&page_fault);
set_trap_gate(16,&coprocessor_error);
set_trap_gate(17,&alignment_check);
set_trap_gate(18,&machine_check);
set_trap_gate(19,&simd_coprocessor_error);
set_system_gate(128,&system_call);
值得特殊注意的是,中断号为8的异常Double fault
,将其设为一个任务门,而不是陷阱门或系统门,这是因为它标志着内核发生了一个严重的错误。此时,内核认为堆栈中的值已经不可信,异常处理程序会尝试直接从寄存器中打印各个寄存器的值。当发生这个异常的时候,CPU从IDT表中的第9项中取出任务门描述符。该描述符指向存储在GDT表中的第32项的特定TSS段描述符。接下来,CPU从该TSS段描述符中加载eip和esp寄存器的值,然后处理器在此堆栈上,执行doublefault_fn()异常处理程序。
现在,让我们看看典型的异常处理程序到底执行什么操作吧。
为调用C函数准备环境
下面的描述中我们使用handler_name
作为异常处理程序的名称。异常处理程序基本上都是下面这样的代码:(所有的异常和中断处理函数都可以在linux\arch\x86\entry\entry_32.S
文件中找到)
handler_name:
pushl $0 /* 部分异常处理程序 */
pushl $do_handler_name
jmp error_code
上面的pushl $0
汇编指令的作用就是在堆栈中本应该由控制单元自动插入硬件错误码的位置插入一个null值。然后就是把异常处理程序(C代码)的地址压栈。这个函数的命名方式是在异常处理函数的名称前缀do_
字符。除了异常Device not available
之外,error_code
对于所有的异常处理程序都是一样的。error_code
处的代码执行如下内容:
- 保存上面提到的C函数可能使用的寄存器。
- 发送cld指令,清除eflags中的DF方向标志,保证使用字符串指令的时候,edi和esi寄存器自增加。
- 拷贝保存在堆栈
esp+36
处的硬件错误码写入到edx寄存器中,并将该堆栈中的值改写为-1。后面我们还要研究内核如何使用这个值区分出0x80异常。 - 将堆栈
esp+32
处的C函数do_handler_name()
的地址写入到edi寄存器中,将es的内容写入到堆栈中。 - 将内核态堆栈的栈顶位置加载到eax寄存器中。
- 将用户数据段选择器加载到ds和es寄存器中。
- 调用edi寄存器中的C函数,此时,这个函数从eax和edx寄存器中获取参数,而不是从堆栈中。这种函数的调用方式,我们在学习__switch_to()函数时,已经了解过了。
总结:
我们前面已经提到过,异常处理程序和普通的进程是不一样的,它没有所谓的堆栈。但是,现在异常处理程序又是使用C语言编写的。要想使用C函数,必须手动构建好堆栈,所以,上面这7步的内容其实就是为执行
do_handler_name
函数构建好堆栈,而这个函数的特殊之处就是,参数是通过eax和edx寄存器传递过来的。
真正的异常处理程序
那do_handler_name
之类的函数到底要执行什么内容呢?其实,它们最终也是调用一个统一处理函数do_trap()
,它的主要代码如下所示。就是保存硬件错误码和异常号到当前进程描述符中,然后发送相应的信号给进程:
current->thread.error_code = error_code;
current->thread.trap_no = vector;
force_sig(sig_number, current);
异常处理程序终止后,当前进程接收到信号。如果进程是在用户态,则信号交给进程自身的信号处理程序(如果存在的话);如果是在内核态,则内核通常会杀死进程。
最后异常处理程序跳转到ret_from_exception()
函数地址处,从异常状态返回。