段错误(segmentation fault ):9种实用调试方法,你用过几种?

引言

每个在Linux环境下工作的程序员,都遇到过段错误(segmentation fault)。所谓段错误,本质上是程序访问了非法内存地址而引起的一种错误类型。

导致程序访问非法地址的原因有很多,如野指针、内存被踩、栈溢出、访问没有权限的内存等。

之前更新调试专题文章时,有朋友问到段错误的调试方法,我承诺会更新文章专门介绍,本文就是来填这个坑的。

本文将介绍9种非常实用的段错误调试方法。

1. 日志

日志是一种非常实用的调试手段,我们可以从系统日志中获得很多非常有用的信息,从而反推问题出现的前后系统中究竟发生了什么异常状况。

printf可能是最简单的日志记录方法,大家都懂的,不再赘述。

2. GDB

GDB的强大无需多言,对于段错误,利用GDB很容易就能定位到触发问题的那一行代码。如下图示例代码:

段错误(segmentation fault ):9种实用调试方法,你用过几种?

编译时加上-g选项:

<code>gcc -g segfault.c -o segfault/<code>

在GDB中运行程序:

段错误(segmentation fault ):9种实用调试方法,你用过几种?

段错误触发时,GDB会直接告诉我们问题出现在哪一行代码,并且可以利用backtrace命令查看完整调用栈信息。此外,还可以利用其他常规调试命令来查看参数、变量、内存等数据。

这种方式虽然非常有效,但很多时候,问题并不是100%必现的,我们不可能一直把程序运行在GDB中,这对程序的执行性能等会有很大的影响。

这时,我们可以让程序在异常终止时生成core dump文件,然后用调试工具对它进行离线调试。

3. Core Dump + GDB

Core dump是Linux提供的一种非常实用的程序调试手段,在程序异常终止时,Linux会把程序的上下文信息记录在一个core文件中,然后可以利用GDB等调试工具对core文件进行离线调试。

很多系统中,根据默认配置,程序异常退出时不会产生core dump文件。可以通过下面这条命令查看:

<code>ulimit -c/<code>

如果值是0,则默认不会产生core dump文件。可以用下面命令设置生成core dump文件的大小:

<code>ulimit -c 10240/<code>

上面命令把core dump文件大小设置为10MB。如果存储空间不受限的话,可以直接取消大小限制:

<code>ulimit -c unlimited/<code>
段错误(segmentation fault ):9种实用调试方法,你用过几种?

设置core dump文件大小

然后重新运行示例程序,段错误触发后,默认会在当前目录下生产一个core文件:

段错误(segmentation fault ):9种实用调试方法,你用过几种?

然后用GDB加载调试core文件。调试时,除了core dump文件外,GDB还需要从可执行文件中加载调试信息。

<code>gdb segfault core/<code>

结果如下图:

段错误(segmentation fault ):9种实用调试方法,你用过几种?

GDB调试core dump

与直接在GDB运行程序类似,core dump文件加载起来之后,GDB会直接显示触发问题的那一行代码,也可以使用backtrace、print等常规命令从core dump文件中获取信息。

在大多数系统中,这种core dump + GDB的手段非常有效,而且应该优先考虑使用。

但是有时候,由于某种原因,系统可能无法生存core dump文件。比如出于安全考虑,core dump功能可能是被彻底禁止的,或者在一些存储空间受限的嵌入式系统中,也无法生成core dump文件。

此时,我们就不得不考虑其它的调试手段了。

4. signal capture + backtrace

4.1 段错误在Linux系统上的处理过程

在Linux系统中,程序访问非法地址时,会被CPU捕获后触发硬件异常处理机制,并通知Linux kernel程序运行出现异常,kernel会对各种异常进行区分,然后向应用程序发送不同的signal,由应用程序自己进行故障恢复处理。

对于访问非法地址引起的段错误,Linux kernel会向应用程序发送11号signal,也就是SIGSEGV信号,该信号的默认处理是终止程序运行。

我们可以注册一个信号处理函数,当接受到Linux kernel发送过来的SIGSEGV信号后,在信号处理函数中把当前程序的上下文信息记录下来,方面后续问题定位。

4.2 两个有用的函数

<code>int backtrace(void **buffer, int size);
void backtrace_symbols_fd(void *const *buffer, int size, int fd);/<code>

backtrace获取程序的调用栈地址信息,并存储在buffer指定的一个数组中,数组大小为size。

backtrace_symbols_fd根据backtrace得到的调用栈地址数据,获取地址对应的符号信息,并把结果写到fd指定的文件中。

4.3 示例

对上面的示例做下修改,增加一个信号处理函数,如下图所示:

段错误(segmentation fault ):9种实用调试方法,你用过几种?

信号处理函数

在信号处理函数signal_handler中,先把寄存器信息打印出来,然后用backtrace和backtrace_symbols_fd获取调用栈信息,并写入stdout。

然后,在main函数中注册SIGSEGV的信号处理函数,如下图:

段错误(segmentation fault ):9种实用调试方法,你用过几种?

注册信号处理函数

编译一下:

<code>gcc -rdynamic segfault.c -o segfault/<code>

看下运行结果:

段错误(segmentation fault ):9种实用调试方法,你用过几种?

运行结果

为了方便演示,示例中的信号处理函数只记录了寄存器和调用栈信息,实际项目中根据需求,可以同时记录其它重要信息,如stack dump、全局变量、数据段dump等。

有两点需要注意:

  1. 示例信号处理函数中打印寄存器的部分是针对x64 CPU的,其它CPU请参考sys/ucontext.h文件中对mcontext_t的定义。
  2. 编译时需要加上-rdynamic选项,否则backtrace_symbols_fd无法正确获取符号信息。

5. signal capture + GDB

有些问题很难重现,直接在GDB里运行调试的话,可能要浪费很多时间去不停的尝试重现它。

那有没有一种方式,可以让问题重现时自动启动GDB呢?当然有!

与上面的一种方法类似,我们仍然利用signal capture的方式。只不过,在信号处理函数中,我们不再使用backtrace获取调用栈信息,而是直接启动GDB:

对信号处理函数作一些修改,如下图:

段错误(segmentation fault ):9种实用调试方法,你用过几种?

原理很简单,就是段错误发生时,在SIGSEGV信号处理函数中执行命令:

<code>gdb --pid=xxx -ex bt -q/<code>

启动GDB,并attach到当前进程,然后执行backtrace命令打印调用栈信息。-q选项只是让GDB启动时不要打印版本信息,避免视觉干扰。

编译一下,需要加上-g选项:

<code>gcc -g siggdb.c -o siggdb/<code>

运行,结果如下图:

段错误(segmentation fault ):9种实用调试方法,你用过几种?

注意:这种方法只能在测试环境中使用,且要确保GDB可以正常使用。生产环境中不要使用!

6. libSegFault.so

除了上面提到的几种方式外,其实glibc也已经很贴心地提供了一种问题定位的方案:libSegFault.so

libSegFault.so是glibc提供的一个动态链接库,用于捕捉程序运行异常并记录调用栈等调试信息。

它的实现原理和上面提到的第4种方法是一样的,即通过signal capture的方式,程序发生异常时,在信号处理函数中记录调试信息。

使用时,先确定系统中是否存在这个动态链接库。在我的系统中,有这么几个:

段错误(segmentation fault ):9种实用调试方法,你用过几种?

根据自己的实际情况,选择一个使用。比如我的测试环境是x64的,我选择使用:

<code>/usr/lib/x86_64-linux-gnu/libSegFault.so/<code>

然后利用环境变量LD_PRELOAD,在测试程序运行前,把libSegFault.so链接进来。

<code>LD_PRELOAD=/usr/lib/debug/lib/x86_64-linux-gnu/libSegFault.so   ./myapp/<code>

仍以本文第一个测试程序为例:

段错误(segmentation fault ):9种实用调试方法,你用过几种?

编译:

<code>gcc -rdynamic segfault.c -o segfault/<code>

运行:

<code>LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libSegFault.so ./segfault/<code>

测试程序触发段错误后,libSegFault.so中的信号处理函数会把寄存器、调用栈、内存映射全部dump出来。结果如下图(信息太多,分成了两张图片):

段错误(segmentation fault ):9种实用调试方法,你用过几种?

libSegFault.so运行结果

段错误(segmentation fault ):9种实用调试方法,你用过几种?

libSegFault.so运行结果(续)

libSegFault.so默认只捕捉SIGSEGV,可以通过设置环境变量SEGFAULT_SIGNALS指定要捕捉的信号,如:

<code>export SEGFAULT_SIGNALS="all"          # "all" signals
export SEGFAULT_SIGNALS="segv bus abrt "  #SIGSEGV, SIGBUS and SIGABRT/<code>

环境变量SEGFAULT_USE_ALTSTACK可以指定是否让信号处理函数使用独立的栈,这在程序发送栈溢出时会很有用。

<code>export SEGFAULT_USE_ALTSTACK=1/<code>

libSegFault.so默认把调试信息输出到stderr,可以通过设置环境变量SEGFAULT_OUTPUT_NAME,指定调试信息记录到一个文件中。比如:

<code>export SEGFAULT_OUTPUT_NAME="./debug.log"/<code>

此外,为了方便用户使用,很多系统中还提供了一个名为catchsegv的脚本:

<code>catchsegv ./segfault/<code>

其效果与通过LD_PRELOAD加载libSegFault.so是相同的:

段错误(segmentation fault ):9种实用调试方法,你用过几种?

7. Valgrind

Valgrind是一个很强大的工具集,它可以检测内存泄露、栈溢出、非法内存访问等多种内存相关的错误,还可以对程序进行性能剖析、生成函数调用关系图、统计Cache命中率、监测多线程竞争等,是程序调试的利器。

Valgrind功能非常强大,但文章篇幅有限,不对其展开讨论,后续会更新文章专门讲解它的各种功能,感兴趣的朋友可以右上角关注一下。

下面演示用Valgrind检测示例程序的内存访问错误:

编译时加上-g选项:

<code>gcc -g segfault.c -o segfault/<code>

然后用Valgrind启动示例程序:

<code>valgrind --tool=memcheck --leak-check=yes -v --leak-check=full --show-reachable=yes ./segfault/<code>

显示数据较多,仅截取感兴趣的部分信息,如下图所示:

段错误(segmentation fault ):9种实用调试方法,你用过几种?

Valgrind成功检测出地址0x12345678既不是栈地址,也不是malloc分配的动态内存。并且它也会把调用栈信息dump出来。

Valgrind虽然在检测内存相关的错误时非常强大,但是它有一个致命的缺点,就是慢。据统计,通过Valgrind运行程序时,速度会降低10倍。这在调试大型项目时,尤其是对实时性非常敏感的程序,是无法接受的。

不过,我们还有一个更好的选择 — AddressSanitizer。

8. AddressSanitizer

AddressSanitizer最初是Google开发的一个检测多种内存相关问题的工具,AddressSanitizer现在已经集成到GCC和LLVM中。它最大的特点是:

  • 功能强大。它可以检测内存泄露、访问越界、栈溢出、多次释放等各种内存问题。
  • 快。使用AddressSanitizer检测内存问题时,原始程序运行速度只会降低2倍左右,相比Vagrind来说,运行效率有了很大的提升。

本文只简单演示用AddressSanitizer检测示例程序中的内存访问错误,后续会专门更新文章详细讲解它的各种功能,感兴趣的朋友可以关注一下。

AddressSanitizer的使用方法也非常简单,只需要在编译时加上相应的编译选项,然后正常运行程序即可。

这里,我只使用最简单的一个编译选项-fsanitize=address开启AddressSanitizer功能。

<code>gcc -g -fsanitize=address segfault.c -o segfault/<code>

然后正常运行即可,截图如下图:

段错误(segmentation fault ):9种实用调试方法,你用过几种?

9. dmesg + objdump

有时,可能由于各种原因,以上几种方法都不适用,比如程序中无法添加调试信息、程序无法重新编译、没有GDB和Valgrind等调试工具等。

这种情况下,调试起来,会相对比较困难一些,但也并不是完全不可能。

大多数情况下,程序发生segmentation fault而异常退出时,会在系统日志中记录一些信息,可以用dmesg查看:

段错误(segmentation fault ):9种实用调试方法,你用过几种?

可以从中得到触发异常的指令地址和被访问的内存地址,然后利用系统中现有的一些工具进行调试,如利用objdump对可执行文件进行反汇编,然后从汇编代码入手进行分析,限于篇幅,不再展开讨论。

Linux下有很多非常有用的工具,如binutils工具集(objdump、nm、readelf等)、strace等,熟悉并善用这些工具,会事半功倍。

结语

本文简单介绍了段错误的常用的9种调试方式,其中很多方法都是值得深入探讨的。

比如signal capture、Valgrind、AddressSanitizer、GDB等,都有很多更为高阶的使用技巧,但限于篇幅,无法展开讲解,后续会更新相关文章进一步深入讲解。

除了文中介绍的9中方法外,还有其它一些相似或衍生的方法,文中并未提及,欢迎童鞋们留言补充,相互学习!

本文是程序调试系列专题的第六篇。本系列专题旨在介绍一些高阶调试技巧、调试器的工作原理以及常见问题的定位方法和思路等内容。

其它已更新内容:

GDB动态打印:让你随时随地printf,不需修改代码,不需重新编译

调试引入的不确定性:必现的BUG神秘消失,断点改变代码执行逻辑

Linux调试技巧:GDB自定义命令,按需定制适合自己的调试工具

C语言:当GDB遇到复杂数据结构,两分钟带你掌握四个高效调试技巧

C语言:GDB调试时遇到宏定义怎么办?一个小技巧帮你一秒钟搞定


段错误(segmentation fault ):9种实用调试方法,你用过几种?

若对文中内容有疑问,欢迎留言讨论,对本系列专题有任何建议也欢迎提出!

原创不易,别忘了转发点赞,把知识分享给志同道合的朋友,谢谢!

对编译器、OS内核、性能调优、虚拟化等技术感兴趣的童鞋,欢迎右上角关注!

版权声明:未经允许,禁止转载。文中部分图片来源网络,如有侵权,请通知删除!


分享到:


相關文章: