0x01 实验
我们都知道,在做数学运算时,是不能除以0的,但如果我们在代码中除以0,会发生什么呢?
我们来做个实验:
在上图中,我用c写了一段代码,return的时候用1除以0。
之所以选择c语言,是因为它比较简单,比较贴近操作系统,而且它在语言层面,没有各种封装,这使我们分析这个问题更加容易一些。
但是本文对除以0的底层分析,对于其他语言来说,也都是适用的。
然后,我们用gcc编译这段代码,在编译时,gcc其实已经给我们警告了,告诉我们除以0了,但我们先不管。
接着,我们运行这个程序,不过,它直接退出了,并输出了一行异常信息。
最后,我们用echo命令查看一下它的退出状态,结果值是136。
那输出的这行异常信息是什么意思呢?退出状态136又代表了什么呢?以及产生这个问题的底层原因又是什么呢?
0x02 问题分析
如果我们去读一下c语言的标准规范,就会发现下面一句话:
它的意思就是说,当我们在代码中除以0时,会产生一个SIGFPE信号。
那这个信号是在哪里产生的呢?它又是如何导致上面实验中的各种现象呢?
这些疑问在c语言层面就解答不了了,我们看一下c语言的下一层语言,汇编。
我们知道,任何程序都是运行在cpu上的,而cpu只能识别机器语言,汇编语言其实是机器语言的一种符号化形式。
任何其他高级语言编写的程序,想要在cpu上运行,都要先将其编译成该cpu对应的汇编语言,进而再转成机器语言,这样它才可以运行。
所以,当我们在高级语言层面遇到问题找不到答案时,不妨看一下它的底层语言,没准一下就豁然开朗了。
对于我们当前的情况来说,c语言层面解答不了这些疑问,我们直接看下它对应的汇编代码:
上图中,我用objdump命令反汇编了main函数,从输出我们可以看到,除法在汇编层面使用的是idiv这个汇编指令。
我们看下这个汇编指令对应的文档:
在这个汇编指令对应的文档中,有一部分介绍了它可能产生的各种异常,见上图,从中我们可以看到,当除数是0时,会产生#DE这个异常。
有关#DE异常更详细的介绍,我们可以看下面这部分文档:
总之,当cpu执行idiv这个汇编指令,并且除数是0时,cpu就会产生#DE这个异常。
0x03 中断和异常
在我们继续分析这个问题之前,我们先来了解一些背景知识,即什么是中断和异常。
中断和异常是一种通知机制,它是用来告诉cpu有某事发生了,要cpu先中断一下当前程序的执行,优先处理一下这个事情。
比如我们常见的就是,当在键盘上按下一个键时,键盘就会发送一个中断给cpu,告诉它有用户输入产生,要它优先处理下。
又比如,当cpu执行一行代码是除以0时,cpu自身就会产生一个异常,即上面我们说的#DE异常,cpu发现这个异常产生后,会立即放弃当前代码的执行,转而执行这个异常对应的在内核里的处理函数。
严格意义上来说,中断和异常其实是不一样的,中断一般是外部设备发送给cpu的,它只是一种通知,且是一种表示正常情况的通知。
而异常一般是cpu在执行代码时,检测到代码有问题,比如说除以0,比如说要访问的内存地址非法,它表示的是这些不正常的情况发生了。
虽然中断和异常是不同的,但cpu处理它们的方式却是一样的。
cpu会要求内核为它提供一个数组,这个数组的起始地址,要被放到IDTR寄存器里。
这个数组的下标,就是中断或异常的编号,而数组里每一项的值,指向的就是该中断或异常的处理函数。
当cpu接收到一个中断或异常时,就会根据接收到的中断或异常编号,以它作为下标,获取IDTR寄存器指向的数组里的对应值,然后根据这个值,找到这个中断或异常的处理函数,接着执行这个处理函数。
所以,如果我们想要知道一个中断或异常的处理函数是什么,我们首先要知道这个中断或异常的编号是多少,然后根据这个编号,到IDTR寄存器指向的数组里找到其对应项,然后根据这一项的值,我们就可以找到其处理函数了。
0x04 寻找#DE异常的处理函数
那针对我们的问题来说,我们要找的是#DE异常的处理函数,而寻找它的第一步,就是要先找到#DE异常对应的编号是多少。
其实这个答案非常简单,不同于中断的动态分配方式,异常都是cpu预定义的,即它们的编号都是提前定好了的。
直接看文档:
上图就是x86体系cpu预定义的部分异常,其中vector列就是该异常对应的编号,比如我们常见的page fault异常,它的编号就是14,以及我们在找的#DE异常,或者说是divide error异常,它的编号就是0。
那我们在找到#DE异常的编号后,就可以根据这个编号,到IDTR寄存器指向的数组里,找该编号对应的处理函数了。
IDTR寄存器指向的数组,是内核在启动过程中,逐步初始化的,其中和#DE异常初始化相关的代码为:
上图这个数组,就定义了部分异常编号与该异常处理函数的对应关系。
在内核启动阶段,内核就会把这个数组里的内容,设置到IDTR寄存器指向的那个数组里,这样cpu在接收到某个异常时,就可以在那个数组里找到对应的处理函数了。
通过上图我们可以看到,#DE异常对应的编号是由X86_TRAP_DE定义的,而这个值如果你去看一下,会发现就是0,这个和我们上面通过文档获取的信息是一致的。
而#DE异常对应的处理函数就是asm_exc_divide_error。
这个函数是在arch/x86/entry/entry_64.S文件里,由idtentry这个宏定义的一段汇编代码:
这段汇编代码的主要目的,就是把各寄存器的值保存到栈里,这样栈里的内容就构成了一个struct pt_regs结构体,然后,这个汇编函数会调用对应的c函数,在c函数里,就可以根据需要,修改struct pt_regs结构体里的内容。
在c函数返回之后,这个汇编函数就又会把栈里存放的最新的寄存器的值,重新赋值回各寄存器里,这样就相当于,我们在c函数里对struct pt_regs结构体的修改,其实等价于是对对应寄存器的修改。
这部分代码我们就不详细看了,我们接下来看下asm_exc_divide_error这个汇编函数对应的c函数:
这个函数就是用于处理#DE异常的真正函数,其实通过函数名,我们也可以确认这一点。
我们看在这个函数里,它调用了do_error_trap函数,并且在第五个参数位置,传入了SIGFPE。
SIGFPE表示的是要发送这个信号给产生异常的进程,注意这个信号,和上文中我们提到的,c语言规范里定义的要发送的信号是一致的。
我们沿着这个函数往下看,会找到这里:
我们看show_signal这个函数,在这个函数内部,输出了一行内核日志,也就是说,当我们执行上面的测试程序时,应该是有一条对应的内核日志的,我们查看下。
看上图中最后一行,在内核日志里确实有这样的一条日志,且格式和show_signal函数里的输出格式是完全一样的。
接着,在do_trap函数里又调用了force_sig_fault函数,该函数的作用就是向当前进程发送一个signr信号。
对于上文的实验来说,当前进程指的就是运行a.out的进程,而signr信号指的就是SIGFPE信号。
0x05 寻找SIGFPE信号的处理函数
上面我们说到,force_sig_fault函数向我们的测试进程发送了一个SIGFPE信号,但这仅仅是发送过程,那这个信号是在哪里处理的呢?
在回答这个问题之前,我们先梳理下目前的流程。
cpu最开始是在用户态执行我们的测试程序,当执行到除0的汇编指令时,触发#DE异常,cpu进入到内核态,开始执行该异常的处理函数exc_divide_error。
在exc_divide_error函数中,内核向测试进程发送了一个SIGFPE信号,然后,#DE异常的处理函数结束,cpu开始要进入到用户态,重新执行用户态逻辑。
但是,在进入到用户态之前,内核会检查该进程是否有未处理的信号,如果有的话,这些信号就在此时开始处理。
内核对应的逻辑为:
看上图中的选中行,就是在检查当前进程是否有未处理的信号,如果有的话,就调用arch_do_signal_or_restart函数来处理。
我们看下处理信号的逻辑,沿着arch_do_signal_or_restart函数一路往下看的话,我们会找到这个函数:
上图中,我把无关行都折叠了,只保留了主体逻辑,我们来具体讲下。
这个函数使用for循环,不断的从当前进程中获取未处理信号,该信号值放到了signr变量里。
然后根据这个信号值,从sighand->action数组里找到该信号对应的处理函数。
信号的处理函数分成三类,第一类是SIG_IGN,即忽略,什么也不处理,第二类是SIG_DFL,即使用信号对应的默认处理方式处理,第三类是自定义处理函数,即使用用户提供的处理函数处理。
那对于我们上面的测试程序来说,我们并没有为SIGFPE信号指定自定义的处理函数,也没有把它的处理函数设置为SIG_IGN,所以内核就是在使用默认逻辑来处理SIGFPE信号的。
有关各信号的默认处理逻辑是什么,我们可以查看 signal 这个man文档。
根据上面的man文档可知,SIGFPE信号的默认处理逻辑就是Core,即终止当前进程并产生core dump文件。
这一逻辑和上图中的get_signal函数里的处理逻辑也是一致的,在get_signal函数里,它先用sig_kernel_coredump函数检测该信号是否需要产生core dump,如果需要则调用do_coredump函数,来产生这个进程的core dump文件,然后再调用do_group_exit函数,终止当前进程。
注意,传入do_group_exit函数的参数为进程的退出码,这个退出码此时就是信号值,对于我们的测试程序来说,就是SIGFPE信号,它的值为8。
0x06 core dump文件
因为SIGFPE信号是要产生core dump文件的,所以get_signal函数里会调用do_coredump函数,do_coredump函数的具体内容我们就不看了,我们只讲一下它的主体逻辑。
它会先从/proc/sys/kernel/core_pattern文件里,获取用于接收core dump文件的用户程序,然后创建一个新进程来启动这个程序,最后把core dump文件内容发给这个程序。
对于我当前的机器来说,/proc/sys/kernel/core_pattern文件里设置的程序为systemd提供的systemd-coredump:
systemd-coredump的作用就是把core dump文件保存到硬盘的某个目录,并在systemd的journal里记录一条日志。
我们可以用coredumpctl命令,来获取这个core dump文件的各种信息:
我们还可以通过journalctl命令,来获取systemd-coredump写到systemd的journal里的日志:
看上图中的第一行日志,它是内核输出的除0异常的一条日志,这个上面我们也看过,注意它和systemd-coredump写的core dump日志是在相邻位置。
0x07 bash的作用
在生成core dump文件之后,get_singal函数就会调用do_group_exit来终止目标进程,对于我们上面的实验来说,这个进程就是a.out进程。
至此,#DE异常,或者说是除0异常的处理就全部结束了。
但到这里,有同学可能会有疑问,在整个#DE异常的处理过程中,我们并没有看到在哪里输出了上面实验中的 Floating point exception (core dumped) 内容,并且,因为#DE异常的处理都在内核态,即使内核输出日志,也不会直接输出到我们的终端控制台。
那 Floating point exception (core dumped) 是谁输出的呢?
其实是bash。
我们在bash中执行a.out程序,bash会先用fork创建一个子进程,然后再在这个子进程里执行a.out程序,而bash的主进程呢,会使用waitpid等待这个子进程的结束。
子进程在执行除0运算时,会触发#DE异常,然后就会执行上文讲的#DE异常的处理逻辑,该处理逻辑的最终结果,就是生成core dump文件,并终止子进程。
子进程在终止终止之后,bash主进程的waitpid函数就会返回,并且获取到子进程退出的原因,即被SIGFPE信号杀死,并且产生了core dump文件。
根据这些信息,bash就输出了 Floating point exception (core dumped) 这段内容。
对应的bash源码为:
还有另外一个问题就是,为什么在上面实验中,a.out的退出码是136,内核在调用do_group_exit函数终止a.out进程时,传入的退出码明明是信号值啊,也就是SIGFPE,它的值是8,那a.out的退出码应该是8啊。
这个其实也是因为bash内部处理了一下,这次我们就不看源码了,因为bash的man文档就直接告诉我们了:
看上图中的选中行,它说的就是,当进程是被信号终止的时候,那这个进程的退出状态或者说是退出码,就是128加上这个信号值。
对于本文的测试进程来说,它是被SIGFPE信号终止的,那它的退出码就应该是128加上SIGFPE的值,也就是128加上8,结果是136,和上面我们实验中的输出是完全一致的。
0x08 全景图
到这里,有关除0问题的所有疑问,就都讲清楚了,最后,我们再画一张全景图,梳理下完整流程。
0x09 其他
如果有对linux及linux内核感兴趣的,可以扫描右侧二维码添加我的微信。
另外,我开设了一门 linux内核启动流程源码分析 课程,有对内核源码感兴趣的,欢迎报名。