C,C++程序最常见的崩溃问题就是内存问题,内存越界,访问空指针,野指针等都会造成程序崩溃。Linux系统中当程序运行过程中出现非法操作,系统会先发送对应的错误信号,每种错误信号都有默认的处理方式,比如,当我们给一个空指针赋值的时候,系统会检测到这个内存错误,然后向进程发送SIGSEGV信号,该信号默认的处理方式是退出进程,这种情况下,只能看到进程挂掉,但无法定位错误。当出现这种问题的时候一般往往很难查找原因,下面介绍两种方式定位bug。

方法一:捕获系统信号

Linux操作系统提供了一组接口可以修改信号对应的默认回调。通过这组接口,当出现错误信号的时候,将程序的执行流程引导到我们自定义的回调函数中,在这里我们可以调用另外一组系统接口获得当前错误出现时的堆栈信息,有了堆栈信息,就可以很方便的定位到程序最后出错时的代码位置。

有些错误是致命的,无法再恢复到正常的程序流程,有些则时可以忽略的,比如SIGPIPE信号,可以忽略这个信号。

    __sighandler_t signal (int __sig, __sighandler_t __handler);
    void (*__sighandler_t) (int);

通过系统函数signal注册消息,第一个参数是要注册的系统消息,第二个参数是消息对应的回调函数。

回调函数是包含一个int参数无返回值的函数类型。

比如我们要捕获SIGSEGV信号,则需要在程序运行之前注册这个消息,如下:

 	#include <signal.h>
    #include <execinfo.h>
    #include <stdlib.h>
    #include <stdio.h>
    void sighandler(int sig)
    {
        // 处理消息回调
    }
    
    int main()
    {
        signal(SIGSEGV, sighandler);
        
        int* p = NULL;
        *p = 1;
        
        return 0;
    }

上面的代码编译之后,运行到*p = 1;这一行时就会出现段错误,sighandler函数会被调用。

接下来我们在sighandler函数中打印出当前程序执行的堆栈信息。

这里要用到下面两个函数:

    int backtrace (void **__array, int __size);
    char **backtrace_symbols (void *const *__array, int __size);

实现sighandler函数

    #include <signal.h>
    #include <execinfo.h>
    #include <stdlib.h>
    #include <stdio.h>
    
    
    void sighandler(int sig)
    {
         void *pTrace[256];
         char **ppszMsg = NULL;
         int uTraceSize = 0;
         if (0 == (uTraceSize = backtrace(pTrace, sizeof(pTrace) / sizeof(void *))))
         {
             return;
         }
         if (NULL == (ppszMsg = backtrace_symbols(pTrace, uTraceSize)))
         {
             return;
         }
         for (int i = 0; i < uTraceSize; ++i)
         {
             printf("%s\n", ppszMsg[i]);
         }
         free(ppszMsg);
    
         exit(0);
     }
    
    
     int main()
     {
         signal(SIGSEGV, sighandler);
    
         int* p = NULL;
         *p = 1;
    
         return 0;
     }

运行命令gcc -o a.out test.cpp编译这个文件得到a.out目标文件,当我们运行这个a.out的时候会得到下面输出:

    ./a.out() [0x4006e5]
    /lib64/libc.so.6(+0x35270) [0x7f0c972d3270]
    ./a.out() [0x40078a]
    /lib64/libc.so.6(__libc_start_main+0xf5) [0x7f0c972bfc05]
    ./a.out() [0x4005e9]

这个便是堆栈信息,只不过这个堆栈信息里面是地址,而不是我们想要的符号名称。这时需要借助另外一个命令工具来还原具体的符号信息

addr2line

addr2line命令可以将地址转换成该地址对应的符号以及位置

当我们运行下面命令:

addr2line 0x4006e5 -e a.out -f

输出如下:

_Z10sighandleri
??:?

这里打印出了sighandler这个函数名,但是第二行信息没有。这是因为我们编译的目标文件里面没有调试信息,因此,无法正常显示。

我们增加-g编译参数,再试一次。

// 编译
$ gcc -g -o a.out test.cpp

// 运行
$ ./a.out
./a.out() [0x4006e5]
/lib64/libc.so.6(+0x35270) [0x7fc43673f270]
./a.out() [0x40078a]
/lib64/libc.so.6(__libc_start_main+0xf5) [0x7fc43672bc05]
./a.out() [0x4005e9]

// 查看0x4006e5地址
$ addr2line 0x4006e5 -e a.out -f
_Z10sighandleri
/home/test/test.cpp:12

这一次我们得到了我们想要的堆栈信息,我们把剩下的两个地址信息也打印出来

addr2line 0x4006e5 -e a.out -f
_Z10sighandleri
/home/test/test.cpp:12

addr2line 0x40078a -e a.out -f
main
/home/test/test.cpp:35

addr2line 0x4005e9 -e a.out -f
_start
??:?

我们只需要a.out程序相关的堆栈信息,因此我们只打印了三个地址的堆栈信息,通过这个堆栈信息,我们就可以定位到出错的位置,在test.cpp的35行,正好是*p = 1;这句代码。

方法二:core dump

Linux程序在崩溃的时候可以产生core文件,这个文件记录了程序崩溃时的状态,有了core文件,就可以还原发生崩溃时的场景,并且还可以使用gdb进行调试。

一般情况下,Linux系统默认是不开启core文件生成的,需要自己手动设置。

在命令行运行ulimit -a可以查看各种参数的系统限制值

core file size          (blocks, -c) 0
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 30224
max locked memory       (kbytes, -l) 64
max memory size         (kbytes, -m) unlimited
open files                      (-n) 1048576
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) 819200
real-time priority              (-r) 0
stack size              (kbytes, -s) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 4096
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited

第一行信息 core file size 就是系统控制产生core文件的大小,默认是0,即程序崩溃时不会产生core文件。

运行命令ulimit -c unlimited 将core文件大小设置为不受限制,这样当程序崩溃时就可以产生core文件了。

继续运行上面的程序,并没有产生core文件,这是因为捕获了对应系统消息之后,就不会产生core文件。

去掉信号捕获之后的程序:

    #include <stdio.h>
    int main()
    {
        int* p = NULL;
        *p = 1;
    
        return 0;
    }

运行gcc -g -o a.out test.cpp

注意这里仍然需要-g选项,使目标文件包含调试信息

再次运行a.out

 ./a.out
段错误(吐核)

再看看当前目录

a.out  core.80798  test.cpp

多了一个core.80798文件,这个文件就是程序崩溃时产生的core文件。

使用gdb a.out core.80798就可以进入gdb调试

gdb a.out core.80798
GNU gdb (GDB) Red Hat Enterprise Linux 7.6.1-100.el7
Copyright (C) 2013 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-redhat-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /home/weiwei.wen/test/coretest/a.out...done.
[New LWP 80798]
Core was generated by `./a.out'.
Program terminated with signal 11, Segmentation fault.
#0  0x00000000004004fd in main () at test.cpp:6
6           *p = 1;
(gdb)

直接定位到了test.cpp第6行,执行 *p = 1;时出错。

这里可以用p命令(print的缩写)打印p值

(gdb) p p
$1 = (int *) 0x0

发现指针p的地址为0x0,是个空指针。

对于堆栈层数比较多的情况,使用bt命令打印所有堆栈信息,通过f命令查看每个堆栈帧的状态。

总结:

以上两种方式都可以定位崩溃bug,第一种方式稍微麻烦一点,需要注册消息回调,在消息回调里面获取堆栈信息,而且还需要通过addr2line命令再处理一次才能得到我们想要的信息。但也有好处,就是最终得到的堆栈信息数据量很小,可以通过脚本处理得到需要的信息,发送到后台管理系统,方便开发人员检查并处理。第二种方式可以完全还原出错的场景,因此可以获得更多的信息来定位bug。不方便的地方就是,core文件有可能很大,不方便传输,需要开发人员登陆到出错机器调试。两种方式各有好坏,使用哪种方式就看实际的应用场景了。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐