本文详细论述UNIX环境上的进程异常退出,将导致进程异常退出的各种情景归纳为两类,对每类情况详细分析了问题出现的根本原因,同时添加了相应的实例以易于您更好地进行了解。在此基础上,文章最后论述了应该如何避免和调试进程异常退出问题。希望读者阅读此文后,对进程异常退出问题有更深层的认识,有更系统的梳理,对调试此类进程崩溃问题时也能有所帮助,写出更稳定、更可靠的软件。
进程异常退出
进程退出意味着进程生命期的结束,系统资源被回收,进程从操作系统环境中销毁。进程异常退出是进程在运行过程中被意外终止,从而导致进程本来应该继续执行的任务无法完成。进程异常退出可能给软件用户造成如下负面影响:
- 软件丧失部分或者全部功能性,无法完成既定任务。
- 如果进程正在处理数据,可能造成数据损坏。
- 如果是关键软件服务,必然导致服务异常中止,造成无法预计的损失。
- 进程异常退出或者进程崩溃,也会给软件用户造成恐慌和困惑。
进程异常退出是生产环境中经常遇到的问题,它会给软件用户造成很多负面影响,所以软件开发者应当避免这种问题的出现。但是导致进程异常退出的场景和原因是多种多样的,甚至令人琢磨不透。
本文将所有可能造成进程异常退出的原因归结为两类。系统地将其分类,使读者对此类问题能有清晰的认识。对每类情况详细论述,分析根本原因,然后分析了这两类情况之间的联系,也就是信号与进程异常退出的紧密关系。希望您读完此文后,能对此类问题有更加全面、深入的理解,对调试此类问题也能有所帮助,写出更加可靠、更加稳定性、更加健壮的软件。
首先我们来看导致进程异常退出的这两类情况:
第一类:向进程发送信号导致进程异常退出;
第二类:代码错误导致进程运行时异常退出。
第一类:向进程发送信号导致进程异常退出
信号:
UNIX系统中的信号是系统响应某些状况而产生的事件,是进程间通信的一种方式。信号可以由一个进程发送给另外进程,也可以由核发送给进程。
信号处理程序:
信号处理程序是进程在接收到信号后,系统对信号的响应。根据具体信号的涵义,相应的默认信号处理程序会采取不同的信号处理方式:
- 终止进程运行,并且产生core dump文件。
- 终止进程运行。
- 忽略信号,进程继续执行。
- 暂停进程运行。
- 如果进程已被暂停,重新调度进程继续执行。
前两种方式会导致进程异常退出,是本文讨论的范围。实际上,大多数默认信号处理程序都会终止进程的运行。
在进程接收到信号后,如果进程已经绑定自定义的信号处理程序,进程会在用户态执行自定义的信号处理程序;反之,内核会执行默认信号程序终止进程运行,导致进程异常退出。
图1.默认信号处理程序终止进程运行
所以,通过向进程发送信号可以触发默认信号处理程序,默认信号处理程序终止进程运行。在UNIX环境中我们有三种方式将信号发送给目标进程,导致进程异常退出。
方式一:调用函数kill()发送信号
我们可以调用函数kill(pid_t pid, int sig)向进程ID为pid的进程发送信号sig。这个函数的原型是:
- #include <sys/types.h>
- #include <signal.h>
- int kill(pid_t pid, int sig);
调用函数kill()后,进程进入内核态向目标进程发送指定信号;目标进程在接收到信号后,默认信号处理程序被调用,进程异常退出。
清单1.调用 kill() 函数发送信号
- /* sendSignal.c, send the signal ‘ SIGSEGV ’ to specific process*/
- 1 #include <sys/types.h>
- 2 #include <signal.h>
- 3
- 4 int main(int argc, char* argv[])
- 5 {
- 6 char* pid = argv[1];
- 7 int PID = atoi(pid);
- 8
- 9 kill(PID, SIGSEGV);
- 10 return 0;
- 11 }
上面的代码片段演示了如何调用kill()函数向指定进程发送SIGSEGV信号。编译并且运行程序:
- [root@machine ~]# gcc -o sendSignal sendSignal.c
- [root@machine ~]# top &
- [1] 22055
- [root@machine ~]# ./sendSignal 22055
- [1]+ Stopped top
- [root@machine ~]# fg %1
- top
- Segmentation fault (core dumped)
上面的操作中,我们在后台运行top,进程ID是22055,然后运行sendSignal向它发送SIGSEGV信号,导致top进程异常退出,产生core dump文件。
方式二:运行kill命令发送信号
用户可以在命令模式下运行kill命令向目标进程发送信号,格式为:
kill SIG*** PID
在运行 kill 命令发送信号后,目标进程会异常退出。这也是系统管理员终结某个进程的最常用方法,类似于在 Windows 平台通过任务管理器杀死某个进程。
在实现上,kill命令也是调用kill系统调用函数来发送信号。所以本质上,方式一和方式二是一样的。
操作演示如下:
- [root@machine ~]# top &
- [1] 22810
- [root@machine ~]# kill -SIGSEGV 22810
- [1]+ Stopped top
- [root@machine ~]# fg %1
- top
- Segmentation fault (core dumped)
方式三:在终端使用键盘发送信号
用户还可以在终端用键盘输入特定的字符(比如control-C或control-\)向前台进程发送信号,终止前台进程运行。常见的中断字符组合是,使用control-C发送SIGINT信号,使用control-\ 发送SIGQUIT信号,使用control-z发送SIGTSTP信号。
在实现上,当用户输入中断字符组合时,比如control-C,终端驱动程序响应键盘输入,并且识别control-C是信号SIGINT的产生符号,然后向前台进程发送SIGINT信号。当前台进程再次被调用时就会接收到SIGINT信号。
使用键盘中断组合符号发送信号演示如下:
- [root@machine ~]# ./loop.sh ( 注释:运行一个前台进程,任务是每秒钟打印一次字符串 )
- i'm looping ...
- i'm looping ...
- i'm looping ... ( 注释:此时,用户输入 control-C)
- [root@machine ~]# ( 注释:接收到信号后,进程退出 )
对这类情况的思考
这类情况导致的进程异常退出,并不是软件编程错误所导致,而是进程外部的异步信号所致。但是我们可以在代码编写中做的更好,通过调用signal函数绑定信号处理程序来应对信号的到来,以提高软件的健壮性。
signal函数的原型:
- #include <signal.h>
- void (*signal(int sig, void (*func)(int)))(int);
signal函数将信号sig和自定义信号处理程序绑定,即当进程收到信号sig时自定义函数func被调用。如果我们希望软件在运行时屏蔽某个信号,插入下面的代码,以达到屏蔽信号 SIGINT的效果:
- (void)signal(SIGINT, SIG_IGN);
执行这一行代码后,当进程收到信号SIGINT后,进程就不会异常退出,而是会忽视这个信号继续运行。
更重要的场景是,进程在运行过程中可能会创建一些临时文件,我们希望进程在清理这些文件后再退出,避免遗留垃圾文件,这种情况下我们也可以调用signal函数实现,自定义一个信号处理程序来清理临时文件,当外部发送信号要求进程终止运行时,这个自定义信号处理程序被调用做清理工作。代码清单2是具体实现。
清单2.调用signal函数绑定自定义信号处理程序
- /* bindSignal.c */
- 1 #include <signal.h>
- 2 #include <stdio.h>
- 3 #include <unistd.h>
- 4 void cleanTask(int sig) {
- 5 printf( "Got the signal, deleting the tmp file\n" );
- 6 if( access( "/tmp/temp.lock", F_OK ) != -1 ) {
- 7 if( remove( "/tmp/temp.lock" ) != 0 )
- 8 perror( "Error deleting file" );
- 9 else
- 10 printf( "File successfully deleted\n" );
- 11 }
- 12
- 13 printf( "Process existing...\n" );
- 14 exit(0);
- 15 }
- 16
- 17 int main() {
- 18 (void) signal( SIGINT, cleanTask );
- 19 FILE* tmp = fopen ( "/tmp/temp.lock", "w" );
- 20 while(1) {
- 21 printf( "Process running happily\n" );
- 22 sleep(1);
- 23 }
- 24
- 25 if( tmp )
- 26 remove( "/tmp/temp.lock" );
- 27 }
- 运行程序:
- [root@machine ~]# ./bindSignal
- Process running happily
- Process running happily
- Process running happily ( 注释:此时,用户输入 control-C)
- Got the signal, deleting the tmp file ( 注释:接收到信号后,cleanTask 被调用 )
- File successfully deleted ( 注释:cleanTask 删除临时文件 )
- Process existing... ( 注释:进程退出 )
第二类:编程错误导致进程运行时异常退出
相比于第一类情况,第二类情况在软件开发过程中是常客,是编程错误,进程运行过程中非法操作引起的。
操作系统和计算机硬件为应用程序的运行提供了硬件平台和软件支持,为应用程序提供了平台虚拟化,使进程运行在自己的进程空间。在进程看来,它自身独占整台系统,任何其它进程都无法干预,也无法进入它的进程空间。
但是操作系统和计算机硬件又约束每个进程的行为,使进程运行在用户态空间,控制权限,确保进程不会破坏系统资源,不会干涉进入其它进程的空间,确保进程合法访问内存。当进程尝试突破禁区做非法操作时,系统会立刻觉察,并且终止进程运行。
所以,第二类情况导致的进程异常退出,起源于进程自身的编程错误,错误的编码执行非法操作,操作系统和硬件制止它的非法操作,并且让进程异常退出。
在实现上,操作系统和计算机硬件通过异常和异常处理函数来阻止进程做非法操作。
异常和异常处理函数
当进程执行非法操作时,计算机会抛出处理器异常,系统执行异常处理函数以响应处理器异常,异常处理函数往往会终止进程运行。
广义的异常包括软中断(soft interrupts)和外设中断(I/O interrupts)。外设中断是系统外围设备发送给处理器的中断,它通知处理器I/O操作的状态,这种异常是外设的异步异常,与具体进程无关,所以它们不会造成进程的异常退出。本文讨论的异常是指soft interrupts,是进程非法操作所导致的处理器异常,这类异常是进程执行非法操作所产生的同步异常,比如内存保护异常,除 0 异常,缺页异常等等。
处理器异常有很多种,系统为每个异常分配异常号,每个异常有相对应的异常处理函数。以x86处理器为例,除0操作产生DEE异常(Divide Error Exception),异常号是0;内存非法访问产生GPF异常(General Protection Fault),异常号是13,而缺页(page fault)异常的异常号是14。当异常出现时,处理器挂起当前进程,读取异常号,然后执行相应的异常处理函数。如果异常是可修复,比如内存缺页异常,异常处理函数会修复系统错误状态,清除异常,然后重新执行一遍被中断的指令,进程继续运行;如果异常无法修复,比如内存非法访问或者除0操作,异常处理函数会终止进程运行,如图2:
图 2. 异常处理函数终止进程运行
#p#
实例以及分析
实例一:内存非法访问
这类问题中最常见的就是内存非法访问。内存非法访问在UNIX平台即segmentation fault,在Windows平台这类错误称为Access violation。
内存非法访问是指:进程在运行时尝试访问尚未分配(即,没有将物理内存映射进入进程虚拟内存空间)的内存,或者进程尝试向只读内存区域写入数据。当进程执行内存非法访问操作时,内存管理单元MMU会产生内存保护异常GPF(General Protection Fault),异常号是13。系统会立刻暂停进程的非法操作,并且跳转到GPF的异常处理程序,终止进程运行。
这种编程错误在编译阶段编译器不会报错,是运行时出现的错误。清单3是内存非法访问的一个简单实例,进程在执行第5行代码时执行非法内存访问,异常处理函数终止进程运行。
清单3.内存非法访问实例demoSegfault.c
- 1 #include<stdio.h>
- 2 int main()
- 3 {
- 4 char* str = "hello";
- 5 str[0] = 'H';
- 6 return 0;
- 7 }
- 编译并运行:
- [root@machine ~]# gcc demoSegfault.c -o demoSegfault
- [root@machine ~]# ./demoSegfault
- Segmentation fault (core dumped)
- [root@machine ~]# gdb demoSegfault core.24065
- ( 已省略不相干文本 )
- Core was generated by `./demoSegfault'.
- Program terminated with signal 11, Segmentation fault.
分析:实例中,字符串str是存储在内存只读区的字符串常量,而第5行代码尝试更改只读区的字符,所以这是内存非法操作。
进程从开始执行到异常退出经历如下几步:
进程执行第5行代码,尝试修改只读内存区的字符;
内存管理单元MMU检查到这是非法内存操作,产生保护内存异常GPF,异常号13;
处理器立刻暂停进程运行,跳转到 GPF 的异常处理函数,异常处理函数终止进程运行;
进程segmentation fault,并且产生core dump文件。GDB调试结果显示,进程异常退出的原因是segmentation fault。
实例二:除0操作
实例二是除0操作,软件开发中也会引入这样的错误。当进程执行除 0 操作时,处理器上的浮点单元FPU(Floating-point unit)会产生DEE除0异常(Divide Error Exception),异常号是0。
清单4.除0操作divide0.c
- 1 #include <stdio.h>
- 2
- 3 int main()
- 4 {
- 5 int a = 1, b = 0, c;
- 6 printf( "Start running\n" );
- 7 c = a/b ;
- 8 printf( "About to quit\n" );
- 9 }
- 译并运行:
- [root@machine ~]# gcc -o divide0 divide0.c
- [root@machine ~]# ./divide0 &
- [1] 1229
- [root@machine ~]# Start running
- [1]+ Floating point exception(core dumped) ./divide0
- [root@xbng103 ~]# gdb divide0 /corefiles/core.1229
- ( 已省略不相干文本 )
- Core was generated by `./divide0'.
- Program terminated with signal 8, Arithmetic exception.
分析:实例中,代码第7行会执行除0操作,导致异常出现,异常处理程序终止进程运行,并且输出错误提示:Floating point exception。#p#
异常处理函数内幕
异常处理函数在实现上,是通过向挂起进程发送信号,进而通过信号的默认信号处理程序终止进程运行,所以异常处理函数是“间接”终止进程运行。详细过程如下:
- 进程执行非法指令或执行错误操作;
- 非法操作导致处理器异常产生;
- 系统挂起进程,读取异常号并且跳转到相应的异常处理函数;
(1)异常处理函数首先查看异常是否可以恢复。如果无法恢复异常,异常处理函数向进程发送信号。发送的信号根据异常类型而定,比如内存保护异常GPF相对应的信号是SIGSEGV,而除0异常DEE相对应的信号是SIGFPE;
(2)异常处理函数调用内核函数 issig() 和 psig() 来接收和处理信号。内核函数 psig() 执行默认信号处理程序,终止进程运行;
4. 进程异常退出。
在此基础上,我们可以把图2进一步细化如下:
图3. 异常处理函数终止进程运行(细化)
异常处理函数执行时会检查异常号,然后根据异常类型发送相应的信号。
再来看一下实例一(代码清单 3)的运行结果:
- [root@machine ~]# ./demoSegfault
- Segmentation fault (core dumped)
- [root@machine ~]# gdb demoSegfault core.24065
- ( 已省略不相干文本 )
- Core was generated by `./demoSegfault'.
- Program terminated with signal 11, Segmentation fault.
运行结果显示进程接收到信号 11 后异常退出,在signal.h的定义里,11就是SIGSEGV。MMU产生内存保护异常GPF异常号 13时,异常处理程序发送相应信号SIGSEGV,SIGSEGV的默认信号处理程序终止进程运行。
再来看实例二(代码清单 4)的运行结果
- [root@machine ~]# ./divide0 &
- [1] 1229
- [root@machine ~]# Start running
- [1]+ Floating point exception(core dumped) ./divide0
- [root@xbng103 ~]# gdb divide0 /corefiles/core.1229
- ( 已省略不相干文本 )
- Core was generated by `./divide0'.
- Program terminated with signal 8, Arithmetic exception.
分析结果显示进程接收到信号8后异常退出,在signal.h 的定义里,8就是信号SIGFPE。除0操作产生异常(异常号 0),异常处理程序发送相应信号SIGFPE给挂起进程,SIGFPE 的默认信号处理程序终止进程运行。
“信号”是进程异常退出的直接原因
信号与进程异常退出有着紧密的关系:第一类情况是因为外部环境向进程发送信号,这种情况下发送的信号是异步信号,信号的到来与进程的运行是异步的;第二类情况是进程非法操作触发处理器异常,然后异常处理函数在内核态向进程发送信号,这种情况下发送的信号是同步信号,信号的到来与进程的运行是同步的。这两种情况都有信号产生,并且最终都是信号处理程序终止进程运行。它们的区别是信号产生的信号源不同,前者是外部信号源产生异步信号,后者是进程自身作为信号源产生同步信号。
所以,信号是进程异常退出的直接原因。当进程异常退出时,进程必然接收到了信号。#p#
避免和调试进程异常退出
建议
软件开发过程中,我们应当避免进程异常退出,针对导致进程异常退出的这两类问题,对软件开发者的几点建议:
- 通常情况无需屏蔽外部信号。信号作为进程间的一种通信方式,异步信号到来意味着外部要求进程的退出;
- 绑定自定义信号处理程序做清理工作,当外部信号到来时,确保进程异常退出前,自定义信号处理程序被调用做清理工作,比如删除创建的临时文件。
- 针对第二类情况,编程过程中确保进程不要做非法操作,尤其是在访问内存时,确保内存已经分配给进程(映射入进程虚拟地址空间),不要向只读区写入数据。
问题调试和定位
进程异常退出时,操作系统会产生 core dump 文件,cored ump 文件是进程异常退出前内存状态的快照,运行 GDB 分析 core dump 文件可以帮助调试和定位问题。
1) 首先,分析 core dump 查看导致进程异常退出的具体信号和退出原因。
使用 GDB 调试实例一(代码清单 3)的分析结果如下:
- [root@machine ~]# gdb demoSegfault core.24065
- ( 已省略不相干文本 )
- Core was generated by `./demoSegfault'.
- Program terminated with signal 11, Segmentation fault.
分析结果显示,终止进程运行的信号是 11,SIGSEGV,原因是内存非法访问。
2) 然后,定位错误代码。
在 GDB 分析 core dump 时,输入“bt”指令打印进程退出时的代码调用链,即 backtrace,就可以定位到错误代码。
用 gcc 编译程序时加入参数 -g 可以生成符号文件,帮助调试。
重新编译、执行实例一,并且分析 core dump 文件,定位错误代码:
- [root@machine ~]# gcc -o demoSegfault demoSegfault.c -g
- [root@machine ~]# ./demoSegfault &
- [1] 28066
- [1]+ Segmentation fault (core dumped) ./demoSegfault
- [root@machine ~]# gdb demoSegfault /corefiles/core.28066
- ( 已省略不相干文本 )
- Core was generated by `./demoSegfault'.
- Program terminated with signal 11, Segmentation fault.
- #0 0x0804835a in main () at demoSegfault.c:5
- 5 str[0] = 'H';
- (gdb) bt
- #0 0x0804835a in main () at demoSegfault.c:5
- (gdb)
在加了参数 -g 编译后,我们可以用 gdb 解析出更多的信息帮助我们调试。在输入“bt”后,GDB 输出提示错误出现在第 5 行。
3) 最后,在定位到错误代码行后,就可以很快知道根本原因,并且修改错误代码。