segmentation怎么看堆栈(段错误segmentation fault)
segmentation怎么看堆栈(段错误segmentation fault)gcc -g segfault.c -o segfault在GDB中运行程序:编译时加上-g选项:日志是一种非常实用的调试手段,我们可以从系统日志中获得很多非常有用的信息,从而反推问题出现的前后系统中究竟发生了什么异常状况。printf可能是最简单的日志记录方法,大家都懂的,不再赘述。GDB的强大无需多言,对于段错误,利用GDB很容易就能定位到触发问题的那一行代码。如下图示例代码:
引言每个在Linux环境下工作的程序员,都遇到过段错误(segmentation fault)。所谓段错误,本质上是程序访问了非法内存地址而引起的一种错误类型。
导致程序访问非法地址的原因有很多,如野指针、内存被踩、栈溢出、访问没有权限的内存等。
之前更新调试专题文章时,有朋友问到段错误的调试方法,我承诺会更新文章专门介绍,本文就是来填这个坑的。
本文将介绍9种非常实用的段错误调试方法。
1. 日志日志是一种非常实用的调试手段,我们可以从系统日志中获得很多非常有用的信息,从而反推问题出现的前后系统中究竟发生了什么异常状况。
printf可能是最简单的日志记录方法,大家都懂的,不再赘述。
2. GDBGDB的强大无需多言,对于段错误,利用GDB很容易就能定位到触发问题的那一行代码。如下图示例代码:
编译时加上-g选项:
gcc -g segfault.c -o segfault
在GDB中运行程序:
段错误触发时,GDB会直接告诉我们问题出现在哪一行代码,并且可以利用backtrace命令查看完整调用栈信息。此外,还可以利用其他常规调试命令来查看参数、变量、内存等数据。
这种方式虽然非常有效,但很多时候,问题并不是100%必现的,我们不可能一直把程序运行在GDB中,这对程序的执行性能等会有很大的影响。
这时,我们可以让程序在异常终止时生成core dump文件,然后用调试工具对它进行离线调试。
3. Core Dump GDBCore dump是Linux提供的一种非常实用的程序调试手段,在程序异常终止时,Linux会把程序的上下文信息记录在一个core文件中,然后可以利用GDB等调试工具对core文件进行离线调试。
很多系统中,根据默认配置,程序异常退出时不会产生core dump文件。可以通过下面这条命令查看:
ulimit -c
如果值是0,则默认不会产生core dump文件。可以用下面命令设置生成core dump文件的大小:
ulimit -c 10240
上面命令把core dump文件大小设置为10MB。如果存储空间不受限的话,可以直接取消大小限制:
ulimit -c unlimited
设置core dump文件大小
然后重新运行示例程序,段错误触发后,默认会在当前目录下生产一个core文件:
然后用GDB加载调试core文件。调试时,除了core dump文件外,GDB还需要从可执行文件中加载调试信息。
gdb segfault core
结果如下图:
GDB调试core dump
与直接在GDB运行程序类似,core dump文件加载起来之后,GDB会直接显示触发问题的那一行代码,也可以使用backtrace、print等常规命令从core dump文件中获取信息。
在大多数系统中,这种core dump GDB的手段非常有效,而且应该优先考虑使用。
但是有时候,由于某种原因,系统可能无法生存core dump文件。比如出于安全考虑,core dump功能可能是被彻底禁止的,或者在一些存储空间受限的嵌入式系统中,也无法生成core dump文件。
此时,我们就不得不考虑其它的调试手段了。
4. signal capture backtrace4.1 段错误在Linux系统上的处理过程
在Linux系统中,程序访问非法地址时,会被CPU捕获后触发硬件异常处理机制,并通知Linux kernel程序运行出现异常,kernel会对各种异常进行区分,然后向应用程序发送不同的signal,由应用程序自己进行故障恢复处理。
对于访问非法地址引起的段错误,Linux kernel会向应用程序发送11号signal,也就是SIGSEGV信号,该信号的默认处理是终止程序运行。
我们可以注册一个信号处理函数,当接受到Linux kernel发送过来的SIGSEGV信号后,在信号处理函数中把当前程序的上下文信息记录下来,方面后续问题定位。
4.2 两个有用的函数
int backtrace(void **buffer int size);
void backtrace_symbols_fd(void *const *buffer int size int fd);
backtrace获取程序的调用栈地址信息,并存储在buffer指定的一个数组中,数组大小为size。
backtrace_symbols_fd根据backtrace得到的调用栈地址数据,获取地址对应的符号信息,并把结果写到fd指定的文件中。
4.3 示例
对上面的示例做下修改,增加一个信号处理函数,如下图所示:
信号处理函数
在信号处理函数signal_handler中,先把寄存器信息打印出来,然后用backtrace和backtrace_symbols_fd获取调用栈信息,并写入stdout。
然后,在main函数中注册SIGSEGV的信号处理函数,如下图:
注册信号处理函数
编译一下:
gcc -rdynamic segfault.c -o segfault
看下运行结果:
运行结果
为了方便演示,示例中的信号处理函数只记录了寄存器和调用栈信息,实际项目中根据需求,可以同时记录其它重要信息,如stack dump、全局变量、数据段dump等。
有两点需要注意:
- 示例信号处理函数中打印寄存器的部分是针对x64 CPU的,其它CPU请参考sys/ucontext.h文件中对mcontext_t的定义。
- 编译时需要加上-rdynamic选项,否则backtrace_symbols_fd无法正确获取符号信息。
有些问题很难重现,直接在GDB里运行调试的话,可能要浪费很多时间去不停的尝试重现它。
那有没有一种方式,可以让问题重现时自动启动GDB呢?当然有!
与上面的一种方法类似,我们仍然利用signal capture的方式。只不过,在信号处理函数中,我们不再使用backtrace获取调用栈信息,而是直接启动GDB:
对信号处理函数作一些修改,如下图:
原理很简单,就是段错误发生时,在SIGSEGV信号处理函数中执行命令:
gdb--pid=xxx -ex bt -q
启动GDB,并attach到当前进程,然后执行backtrace命令打印调用栈信息。-q选项只是让GDB启动时不要打印版本信息,避免视觉干扰。
编译一下,需要加上-g选项:
gcc -g siggdb.c -o siggdb
运行,结果如下图:
注意:这种方法只能在测试环境中使用,且要确保GDB可以正常使用。生产环境中不要使用!
6. libSegFault.so除了上面提到的几种方式外,其实glibc也已经很贴心地提供了一种问题定位的方案:libSegFault.so
libSegFault.so是glibc提供的一个动态链接库,用于捕捉程序运行异常并记录调用栈等调试信息。
它的实现原理和上面提到的第4种方法是一样的,即通过signal capture的方式,程序发生异常时,在信号处理函数中记录调试信息。
使用时,先确定系统中是否存在这个动态链接库。在我的系统中,有这么几个:
根据自己的实际情况,选择一个使用。比如我的测试环境是x64的,我选择使用:
/usr/lib/x86_64-linux-gnu/libSegFault.so
然后利用环境变量LD_PRELOAD,在测试程序运行前,把libSegFault.so链接进来。
LD_PRELOAD=/usr/lib/debug/lib/x86_64-linux-gnu/libSegFault.so ./myapp
仍以本文第一个测试程序为例:
编译:
gcc -rdynamic segfault.c -o segfault
运行:
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libSegFault.so ./segfault
测试程序触发段错误后,libSegFault.so中的信号处理函数会把寄存器、调用栈、内存映射全部dump出来。结果如下图(信息太多,分成了两张图片):
libSegFault.so运行结果
libSegFault.so运行结果(续)
libSegFault.so默认只捕捉SIGSEGV,可以通过设置环境变量SEGFAULT_SIGNALS指定要捕捉的信号,如:
export SEGFAULT_SIGNALS="all" # "all" signals
export SEGFAULT_SIGNALS="segv bus abrt " #SIGSEGV SIGBUS and SIGABRT
环境变量SEGFAULT_USE_ALTSTACK可以指定是否让信号处理函数使用独立的栈,这在程序发送栈溢出时会很有用。
exportSEGFAULT_USE_ALTSTACK=1
libSegFault.so默认把调试信息输出到stderr,可以通过设置环境变量SEGFAULT_OUTPUT_NAME,指定调试信息记录到一个文件中。比如:
exportSEGFAULT_OUTPUT_NAME="./debug.log"
此外,为了方便用户使用,很多系统中还提供了一个名为catchsegv的脚本:
catchsegv ./segfault
其效果与通过LD_PRELOAD加载libSegFault.so是相同的:
7. ValgrindValgrind是一个很强大的工具集,它可以检测内存泄露、栈溢出、非法内存访问等多种内存相关的错误,还可以对程序进行性能剖析、生成函数调用关系图、统计Cache命中率、监测多线程竞争等,是程序调试的利器。
Valgrind功能非常强大,但文章篇幅有限,不对其展开讨论,后续会更新文章专门讲解它的各种功能,感兴趣的朋友可以右上角关注一下。
下面演示用Valgrind检测示例程序的内存访问错误:
编译时加上-g选项:
gcc -g segfault.c -o segfault
然后用Valgrind启动示例程序:
valgrind --tool=memcheck --leak-check=yes -v --leak-check=full --show-reachable=yes ./segfault
显示数据较多,仅截取感兴趣的部分信息,如下图所示:
Valgrind成功检测出地址0x12345678既不是栈地址,也不是malloc分配的动态内存。并且它也会把调用栈信息dump出来。
Valgrind虽然在检测内存相关的错误时非常强大,但是它有一个致命的缺点,就是慢。据统计,通过Valgrind运行程序时,速度会降低10倍。这在调试大型项目时,尤其是对实时性非常敏感的程序,是无法接受的。
不过,我们还有一个更好的选择 — AddressSanitizer。
8. AddressSanitizerAddressSanitizer最初是Google开发的一个检测多种内存相关问题的工具,AddressSanitizer现在已经集成到GCC和LLVM中。它最大的特点是:
- 功能强大。它可以检测内存泄露、访问越界、栈溢出、多次释放等各种内存问题。
- 快。使用AddressSanitizer检测内存问题时,原始程序运行速度只会降低2倍左右,相比Vagrind来说,运行效率有了很大的提升。
本文只简单演示用AddressSanitizer检测示例程序中的内存访问错误,后续会专门更新文章详细讲解它的各种功能,感兴趣的朋友可以关注一下。
AddressSanitizer的使用方法也非常简单,只需要在编译时加上相应的编译选项,然后正常运行程序即可。
这里,我只使用最简单的一个编译选项-fsanitize=address开启AddressSanitizer功能。
gcc -g -fsanitize=address segfault.c -o segfault
然后正常运行即可,截图如下图:
9. dmesg objdump有时,可能由于各种原因,以上几种方法都不适用,比如程序中无法添加调试信息、程序无法重新编译、没有GDB和Valgrind等调试工具等。
这种情况下,调试起来,会相对比较困难一些,但也并不是完全不可能。
大多数情况下,程序发生segmentation fault而异常退出时,会在系统日志中记录一些信息,可以用dmesg查看:
可以从中得到触发异常的指令地址和被访问的内存地址,然后利用系统中现有的一些工具进行调试,如利用objdump对可执行文件进行反汇编,然后从汇编代码入手进行分析,限于篇幅,不再展开讨论。
Linux下有很多非常有用的工具,如binutils工具集(objdump、nm、readelf等)、strace等,熟悉并善用这些工具,会事半功倍。
结语本文简单介绍了段错误的常用的9种调试方式,其中很多方法都是值得深入探讨的。
比如signal capture、Valgrind、AddressSanitizer、GDB等,都有很多更为高阶的使用技巧,但限于篇幅,无法展开讲解,后续会更新相关文章进一步深入讲解。
除了文中介绍的9中方法外,还有其它一些相似或衍生的方法,文中并未提及,欢迎童鞋们留言补充,相互学习!
本文是程序调试系列专题的第六篇。本系列专题旨在介绍一些高阶调试技巧、调试器的工作原理以及常见问题的定位方法和思路等内容。
其它已更新内容:
GDB动态打印:让你随时随地printf,不需修改代码,不需重新编译
调试引入的不确定性:必现的BUG神秘消失,断点改变代码执行逻辑
Linux调试技巧:GDB自定义命令,按需定制适合自己的调试工具
C语言:当GDB遇到复杂数据结构,两分钟带你掌握四个高效调试技巧
C语言:GDB调试时遇到宏定义怎么办?一个小技巧帮你一秒钟搞定
若对文中内容有疑问,欢迎留言讨论,对本系列专题有任何建议也欢迎提出!
原创不易,别忘了转发点赞,把知识分享给志同道合的朋友,谢谢!
对编译器、OS内核、性能调优、虚拟化等技术感兴趣的童鞋,欢迎右上角关注!
版权声明:未经允许,禁止转载。文中部分图片来源网络,如有侵权,请通知删除!