分析没有核心文件的段错误
Analyzing segmentation fault without core file
假设我的二进制文件 运行 在我无法使用 ulimit -c
启用 core dump
生成的客户站点中。工程师如何在这种真实场景中调试 segmentation faults
?是否有任何其他方法可以在不生成 core dumps
的情况下调试或识别崩溃。
过去,我曾多次遇到这种限制。必须调查分段错误,或者更一般地说,异常进程终止,但要注意核心转储不可用。
对于 Linux,我们为本演练选择的平台,我想到了几个原因:
- 完全禁用核心转储生成(使用
limits.conf
或 ulimit
)
- 目标目录(当前工作目录或
/proc/sys/kernel/core_pattern
中的目录)不存在或由于文件系统权限或 SELinux 而无法访问
- 目标文件系统磁盘空间不足space导致部分转储
对于所有这些,最终结果是相同的:没有(有效的)核心转储可用于分析。幸运的是,post-mortem 调试存在一个解决方法,它有可能挽救局面,但鉴于它的固有局限性,您的里程可能因情况而异。
识别错误指令
以下示例包含典型的释放后使用内存错误:
#include <iostream>
struct Test
{
const std::string &m_value;
Test(const std::string &value):
m_value(value)
{
}
void print()
{
std::cout << m_value << std::endl;
}
};
int main()
{
std::string *value = new std::string("this is a test");
Test test(*value);
delete value;
test.print();
return 0;
}
在 delete value
之后,std::string
引用 Test::m_value
指向无法访问的内存。因此,运行宁它导致分段错误:
$ ./a.out
Segmentation fault
当进程因访问冲突而终止时,Linux 内核会创建一个可通过 dmesg
访问的日志条目,并且根据系统的配置,系统日志(通常是 /var/log/messages
).该示例(使用 -O0
编译)创建以下条目:
$ dmesg | grep segfault
[80440.957955] a.out[7098]: segfault at ffffffffffffffe8 ip 00007f9f2c2b56a3 sp 00007ffc3e75bc48 error 5 in libstdc++.so.6.0.19[7f9f2c220000+e9000]
对应的Linux内核源码来自arch/x86/mm/fault.c
:
printk("%s%s[%d]: segfault at %lx ip %px sp %px error %lx",
loglvl, tsk->comm, task_pid_nr(tsk), address,
(void *)regs->ip, (void *)regs->sp, error_code);
错误 (error_code
) 揭示了触发器是什么。这是一个 CPU 特定的位集 (x86)。在我们的例子中,值 5
(二进制中的 101
)表示错误地址 0xffffffffffffffe8
表示的页面已被映射,但由于页面保护而无法访问,并且已尝试读取。
日志消息标识了执行错误指令的模块:libstdc++.so.6.0.1
。该示例未经优化编译,因此未内联对 std::basic_ostream<char, std::char_traits<char> >& std::operator<< <char, std::char_traits<char>, std::allocator<char> >(std::basic_ostream<char, std::char_traits<char> >&, std::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)
的调用:
400bef: e8 4c fd ff ff callq 400940 <_ZStlsIcSt11char_traitsIcESaIcEERSt13basic_ostreamIT_T0_ES7_RK
SbIS4_S5_T1_E@plt>
STL 执行读取访问。了解了这些基础知识,我们如何才能准确地确定分段错误发生的位置?日志条目包含我们这样做所需的两个基本地址:
ip 00007f9f2c2b56a3 [...] error 5 in
^^^^^^^^^^^^^^^^
libstdc++.so.6.0.19[7f9f2c220000+e9000]
^^^^^^^^^^^^
第一个是访问冲突时的指令指针 (rip
),第二个是库的 .text
部分映射到的地址。通过从rip
中减去.text
基地址,我们得到库中指令的相对地址,并且可以使用objdump
反汇编实现(您可以简单地搜索偏移量):
0x7f9f2c2b56a3-0x7f9f2c220000=0x956a3
$ objdump --demangle -d /usr/lib64/libstdc++.so.6
[...]
00000000000956a0 <std::basic_ostream<char, std::char_traits<char> >& std::operator<< <char, std::char_traits<char>, s
td::allocator<char> >(std::basic_ostream<char, std::char_traits<char> >&, std::basic_string<char, std::char_traits<ch
ar>, std::allocator<char> > const&)@@GLIBCXX_3.4>:
956a0: 48 8b 36 mov (%rsi),%rsi
956a3: 48 8b 56 e8 mov -0x18(%rsi),%rdx
^^^^^
956a7: e9 24 4e fc ff jmpq 5a4d0 <std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)@plt>
956ac: 0f 1f 40 00 nopl 0x0(%rax)
[...]
这是正确的指示吗?我们可以咨询GDB来确认我们的分析:
Program received signal SIGSEGV, Segmentation fault.
0x00007ffff7b686a3 in std::basic_ostream<char, std::char_traits<char> >& std::operator<< <char, std::char_traits<char>, std::allocator<char> >(std::basic_ostream<char, std::char_traits<char> >&, std::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) () from /lib64/libstdc++.so.6
Missing separate debuginfos, use: debuginfo-install glibc-2.17-323.el7_9.x86_64 libgcc-4.8.5-44.el7.x86_64 libstdc++-4.8.5-44.el7.x86_64
(gdb) disass
Dump of assembler code for function _ZStlsIcSt11char_traitsIcESaIcEERSt13basic_ostreamIT_T0_ES7_RKSbIS4_S5_T1_E:
0x00007ffff7b686a0 <+0>: mov (%rsi),%rsi
=> 0x00007ffff7b686a3 <+3>: mov -0x18(%rsi),%rdx
0x00007ffff7b686a7 <+7>: jmpq 0x7ffff7b2d4d0 <_ZSt16__ostream_insertIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_PKS3_l@plt>
End of assembler dump.
GDB 显示了完全相同的指令。我们还可以使用调试会话来验证读取地址:
(gdb) print /x $rsi-0x18
= 0xffffffffffffffe8
此值与日志条目中的读取地址匹配。
识别来电者
因此,尽管没有核心转储,但内核输出使我们能够确定分段错误的确切位置。但是,在许多情况下,这还远远不够。一方面,我们缺少使我们达到这一点的调用列表 - 调用堆栈或堆栈跟踪。
如果背包中没有转储,您有两种选择来控制调用者:您可以使用 catchsegv
(一个 glibc 实用程序)启动您的进程,或者您可以实现自己的信号处理程序。
catchsegv
用作包装器,生成堆栈跟踪,还转储寄存器值和内存映射:
$ catchsegv ./a.out
*** Segmentation fault
Register dump:
RAX: 0000000002158040 RBX: 0000000002158040 RCX: 0000000002158000
[...]
Backtrace:
/lib64/libstdc++.so.6(_ZStlsIcSt11char_traitsIcESaIcEERSt13basic_ostreamIT_T0_ES7_RKSbIS4_S5_T1_E+0x3)[0x7f1794fd36a3]
??:?(_ZN4Test5printEv)[0x400bf4]
??:?(main)[0x400b2d]
/lib64/libc.so.6(__libc_start_main+0xf5)[0x7f179467a555]
??:?(_start)[0x4009e9]
Memory map:
00400000-00401000 r-xp 00000000 08:02 50331747 /home/user/a.out
[...]
7f1794f3e000-7f1795027000 r-xp 00000000 08:02 33600977 /usr/lib64/libstdc++.so.6.0.19
7f1795027000-7f1795227000 ---p 000e9000 08:02 33600977 /usr/lib64/libstdc++.so.6.0.19
7f1795227000-7f179522f000 r--p 000e9000 08:02 33600977 /usr/lib64/libstdc++.so.6.0.19
7f179522f000-7f1795231000 rw-p 000f1000 08:02 33600977 /usr/lib64/libstdc++.so.6.0.19
[...]
catchsegv
是如何工作的?它本质上是使用 LD_PRELOAD
和库 libSegFault.so
注入信号处理程序。如果您的应用程序已经为 SIGSEGV
安装了信号处理程序并且您打算利用 libSegFault.so
,您的信号处理程序需要将信号转发给原始处理程序(由 sigaction(SIGSEGV, NULL)
返回) ).
第二个选项是使用自定义信号处理程序和 backtrace()
自己实现堆栈跟踪功能。这允许您自定义输出位置和输出本身。
根据这些信息,我们基本上可以做与之前相同的事情 (0x7f1794fd36a3-0x7f1794f3e000=0x956a3
)。这一次,我们可以回到来电者那里进行更深入的挖掘。第二帧由以下行表示:
??:?(_ZN4Test5printEv)[0x400bf4]
0x400bf4
是被调用者returns到Test::print()
之后的地址,它位于可执行文件中。我们可以将调用站点可视化如下:
$ objdump --demangle -d ./a.out
[...]
400bea: bf a0 20 60 00 mov [=22=]x6020a0,%edi
400bef: e8 4c fd ff ff callq 400940 <std::basic_ostream<char, std::char_traits<char> >& std::operator<< <char, std:
:char_traits<char>, std::allocator<char> >(std::basic_ostream<char, std::char_traits<char> >&, std::basic_string<char, std::char_trai
ts<char>, std::allocator<char> > const&)@plt>
400bf4: be 70 09 40 00 mov [=22=]x400970,%esi
^^^^^^
400bf9: 48 89 c7 mov %rax,%rdi
400bfc: e8 5f fd ff ff callq 400960 <std::ostream::operator<<(std::ostream& (*)(std::ostream&))@plt>
[...]
请注意,objdump 的输出与此实例中的地址相匹配,因为我们 运行 它针对可执行文件,它在 x86_64 上具有默认基地址 0x400000
- objdump 采用考虑到这一点。启用地址 space 布局随机化 (ASLR)(用 -fpie
编译,用 -pie
链接),必须按照前面所述考虑基地址。
进一步返回涉及相同的步骤:
??:?(main)[0x400b2d]
$ objdump --demangle -d ./a.out
[...]
400b1c: e8 af fd ff ff callq 4008d0 <operator delete(void*)@plt>
400b21: 48 8d 45 d0 lea -0x30(%rbp),%rax
400b25: 48 89 c7 mov %rax,%rdi
400b28: e8 a7 00 00 00 callq 400bd4 <Test::print()>
400b2d: b8 00 00 00 00 mov [=24=]x0,%eax
^^^^^^
400b32: eb 2a jmp 400b5e <main+0xb1>
[...]
到目前为止,我们一直在手动将绝对地址转换为相对地址。相反,模块的基地址可以通过 --adjust-vma=<base-address>
传递给 objdump。这样,rip
的值或调用者的地址就可以直接使用了。
添加调试符号
我们已经走了很长一段路,没有垃圾场。然而,要使调试有效,还缺少另一个关键拼图:调试符号。没有它们,可能很难将程序集映射到相应的源代码。使用 -O3
且没有调试信息编译示例说明了问题:
[98161.650474] a.out[13185]: segfault at ffffffffffffffe8 ip 0000000000400a4b sp 00007ffc9e738270 error 5 in a.out[400000+1000]
作为内联的结果,日志条目现在指向我们的可执行文件作为触发器。使用 objdump 让我们得到以下结果:
400a3e: e8 dd fe ff ff callq 400920 <operator delete(void*)@plt>
400a43: 48 8b 33 mov (%rbx),%rsi
400a46: bf a0 20 60 00 mov [=26=]x6020a0,%edi
400a4b: 48 8b 56 e8 mov -0x18(%rsi),%rdx
^^^^^^
400a4f: e8 4c ff ff ff callq 4009a0 <std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)@plt>
400a54: 48 89 c5 mov %rax,%rbp
400a57: 48 8b 00 mov (%rax),%rax
部分流实现是内联的,因此更难识别相关的源代码。没有symbols,就得用export symbols,调用(like operator delete(void*)
)和周围的指令(mov [=71=]x6020a0
加载std::cout
的地址:00000000006020a0 <std::cout@@GLIBCXX_3.4>
)来定位.
使用调试符号 (-g
),通过使用 --source
:
调用 objdump
可以获得更多上下文
400a43: 48 8b 33 mov (%rbx),%rsi
operator<<(basic_ostream<_CharT, _Traits>& __os,
const basic_string<_CharT, _Traits, _Alloc>& __str)
{
// _GLIBCXX_RESOLVE_LIB_DEFECTS
// 586. string inserter not a formatted function
return __ostream_insert(__os, __str.data(), __str.size());
400a46: bf a0 20 60 00 mov [=27=]x6020a0,%edi
400a4b: 48 8b 56 e8 mov -0x18(%rsi),%rdx
^^^^^^
400a4f: e8 4c ff ff ff callq 4009a0 <std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)@plt>
400a54: 48 89 c5 mov %rax,%rbp
按预期工作。在现实世界中,调试符号并未嵌入二进制文件中——它们在单独的 debuginfo 包中进行管理。在那些情况下,objdump
会忽略调试符号,即使它们已安装。要解决此限制,必须将符号重新添加到受影响的二进制文件中。以下过程创建分离符号并使用 eu-unstrip
从 elfutils
重新添加它们,以便 objdump:
# compile with debug info
g++ segv.cxx -O3 -g
# create detached debug info
objcopy --only-keep-debug a.out a.out.debug
# remove debug info from executable
strip -g a.out
# re-add debug info to executable
eu-unstrip ./a.out ./a.out.debug -o ./a.out-debuginfo
# objdump with executable containing debug info
objdump --demangle -d ./a.out-debuginfo --source
使用 GDB 代替 objdump
到目前为止,我们一直在使用 objdump,因为它通常可用,甚至在生产系统上也是如此。我们可以只使用 GDB 吗?是的,通过对感兴趣的模块执行 gdb
。我在之前的 objdump 调用中使用 0x0x400a4b
:
$ gdb ./a.out
[...]
(gdb) disass 0x400a4b
Dump of assembler code for function main():
[...]
0x0000000000400a43 <+67>: mov (%rbx),%rsi
0x0000000000400a46 <+70>: mov [=29=]x6020a0,%edi
0x0000000000400a4b <+75>: mov -0x18(%rsi),%rdx
0x0000000000400a4f <+79>: callq 0x4009a0 <_ZSt16__ostream_insertIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_PKS3_l@plt>
0x0000000000400a54 <+84>: mov %rax,%rbp
与objdump相比,GDB可以毫不费力地处理外部符号信息。 disass /m
对应 objdump --source
:
(gdb) disass /m 0x400a4b
Dump of assembler code for function main():
[...]
21 Test test(*value);
22 delete value;
0x0000000000400a25 <+37>: test %rbx,%rbx
0x0000000000400a28 <+40>: je 0x400a43 <main()+67>
0x0000000000400a3b <+59>: mov %rbx,%rdi
0x0000000000400a3e <+62>: callq 0x400920 <_ZdlPv@plt>
23 test.print();
24 return 0;
25 }
0x0000000000400a88 <+136>: add [=30=]x18,%rsp
[...]
End of assembler dump.
在优化二进制文件的情况下,如果无法明确映射源代码,GDB 可能会跳过此模式下的指令。我们在 0x400a4b
的说明未列出。 objdump 从不跳过指令,而是可能会跳过源上下文——我更喜欢在这个级别进行调试的一种方法。这并不意味着 GDB 对这项任务没有用,它只是需要注意的地方。
最后的想法
终止原因、寄存器、内存映射和堆栈跟踪。一切都在那里,甚至没有核心转储的痕迹。虽然绝对有用(我用这种方式修复了很多崩溃),但您必须记住,通过这种方式您仍然会丢失有价值的信息,最显着的是堆栈和堆以及每线程数据(线程元数据,寄存器、堆栈)。
因此,无论是什么情况,您都应该认真考虑启用核心转储生成,并确保在关键时刻能够成功生成转储。调试本身就已经足够复杂了,没有您 技术上可能拥有的信息进行调试 会不必要地增加复杂性和周转时间,更重要的是,会显着降低找到根本原因并解决问题的可能性及时。
假设我的二进制文件 运行 在我无法使用 ulimit -c
启用 core dump
生成的客户站点中。工程师如何在这种真实场景中调试 segmentation faults
?是否有任何其他方法可以在不生成 core dumps
的情况下调试或识别崩溃。
过去,我曾多次遇到这种限制。必须调查分段错误,或者更一般地说,异常进程终止,但要注意核心转储不可用。
对于 Linux,我们为本演练选择的平台,我想到了几个原因:
- 完全禁用核心转储生成(使用
limits.conf
或ulimit
) - 目标目录(当前工作目录或
/proc/sys/kernel/core_pattern
中的目录)不存在或由于文件系统权限或 SELinux 而无法访问
- 目标文件系统磁盘空间不足space导致部分转储
对于所有这些,最终结果是相同的:没有(有效的)核心转储可用于分析。幸运的是,post-mortem 调试存在一个解决方法,它有可能挽救局面,但鉴于它的固有局限性,您的里程可能因情况而异。
识别错误指令
以下示例包含典型的释放后使用内存错误:
#include <iostream>
struct Test
{
const std::string &m_value;
Test(const std::string &value):
m_value(value)
{
}
void print()
{
std::cout << m_value << std::endl;
}
};
int main()
{
std::string *value = new std::string("this is a test");
Test test(*value);
delete value;
test.print();
return 0;
}
在 delete value
之后,std::string
引用 Test::m_value
指向无法访问的内存。因此,运行宁它导致分段错误:
$ ./a.out
Segmentation fault
当进程因访问冲突而终止时,Linux 内核会创建一个可通过 dmesg
访问的日志条目,并且根据系统的配置,系统日志(通常是 /var/log/messages
).该示例(使用 -O0
编译)创建以下条目:
$ dmesg | grep segfault
[80440.957955] a.out[7098]: segfault at ffffffffffffffe8 ip 00007f9f2c2b56a3 sp 00007ffc3e75bc48 error 5 in libstdc++.so.6.0.19[7f9f2c220000+e9000]
对应的Linux内核源码来自arch/x86/mm/fault.c
:
printk("%s%s[%d]: segfault at %lx ip %px sp %px error %lx",
loglvl, tsk->comm, task_pid_nr(tsk), address,
(void *)regs->ip, (void *)regs->sp, error_code);
错误 (error_code
) 揭示了触发器是什么。这是一个 CPU 特定的位集 (x86)。在我们的例子中,值 5
(二进制中的 101
)表示错误地址 0xffffffffffffffe8
表示的页面已被映射,但由于页面保护而无法访问,并且已尝试读取。
日志消息标识了执行错误指令的模块:libstdc++.so.6.0.1
。该示例未经优化编译,因此未内联对 std::basic_ostream<char, std::char_traits<char> >& std::operator<< <char, std::char_traits<char>, std::allocator<char> >(std::basic_ostream<char, std::char_traits<char> >&, std::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)
的调用:
400bef: e8 4c fd ff ff callq 400940 <_ZStlsIcSt11char_traitsIcESaIcEERSt13basic_ostreamIT_T0_ES7_RK
SbIS4_S5_T1_E@plt>
STL 执行读取访问。了解了这些基础知识,我们如何才能准确地确定分段错误发生的位置?日志条目包含我们这样做所需的两个基本地址:
ip 00007f9f2c2b56a3 [...] error 5 in
^^^^^^^^^^^^^^^^
libstdc++.so.6.0.19[7f9f2c220000+e9000]
^^^^^^^^^^^^
第一个是访问冲突时的指令指针 (rip
),第二个是库的 .text
部分映射到的地址。通过从rip
中减去.text
基地址,我们得到库中指令的相对地址,并且可以使用objdump
反汇编实现(您可以简单地搜索偏移量):
0x7f9f2c2b56a3-0x7f9f2c220000=0x956a3
$ objdump --demangle -d /usr/lib64/libstdc++.so.6
[...]
00000000000956a0 <std::basic_ostream<char, std::char_traits<char> >& std::operator<< <char, std::char_traits<char>, s
td::allocator<char> >(std::basic_ostream<char, std::char_traits<char> >&, std::basic_string<char, std::char_traits<ch
ar>, std::allocator<char> > const&)@@GLIBCXX_3.4>:
956a0: 48 8b 36 mov (%rsi),%rsi
956a3: 48 8b 56 e8 mov -0x18(%rsi),%rdx
^^^^^
956a7: e9 24 4e fc ff jmpq 5a4d0 <std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)@plt>
956ac: 0f 1f 40 00 nopl 0x0(%rax)
[...]
这是正确的指示吗?我们可以咨询GDB来确认我们的分析:
Program received signal SIGSEGV, Segmentation fault.
0x00007ffff7b686a3 in std::basic_ostream<char, std::char_traits<char> >& std::operator<< <char, std::char_traits<char>, std::allocator<char> >(std::basic_ostream<char, std::char_traits<char> >&, std::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) () from /lib64/libstdc++.so.6
Missing separate debuginfos, use: debuginfo-install glibc-2.17-323.el7_9.x86_64 libgcc-4.8.5-44.el7.x86_64 libstdc++-4.8.5-44.el7.x86_64
(gdb) disass
Dump of assembler code for function _ZStlsIcSt11char_traitsIcESaIcEERSt13basic_ostreamIT_T0_ES7_RKSbIS4_S5_T1_E:
0x00007ffff7b686a0 <+0>: mov (%rsi),%rsi
=> 0x00007ffff7b686a3 <+3>: mov -0x18(%rsi),%rdx
0x00007ffff7b686a7 <+7>: jmpq 0x7ffff7b2d4d0 <_ZSt16__ostream_insertIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_PKS3_l@plt>
End of assembler dump.
GDB 显示了完全相同的指令。我们还可以使用调试会话来验证读取地址:
(gdb) print /x $rsi-0x18
= 0xffffffffffffffe8
此值与日志条目中的读取地址匹配。
识别来电者
因此,尽管没有核心转储,但内核输出使我们能够确定分段错误的确切位置。但是,在许多情况下,这还远远不够。一方面,我们缺少使我们达到这一点的调用列表 - 调用堆栈或堆栈跟踪。
如果背包中没有转储,您有两种选择来控制调用者:您可以使用 catchsegv
(一个 glibc 实用程序)启动您的进程,或者您可以实现自己的信号处理程序。
catchsegv
用作包装器,生成堆栈跟踪,还转储寄存器值和内存映射:
$ catchsegv ./a.out
*** Segmentation fault
Register dump:
RAX: 0000000002158040 RBX: 0000000002158040 RCX: 0000000002158000
[...]
Backtrace:
/lib64/libstdc++.so.6(_ZStlsIcSt11char_traitsIcESaIcEERSt13basic_ostreamIT_T0_ES7_RKSbIS4_S5_T1_E+0x3)[0x7f1794fd36a3]
??:?(_ZN4Test5printEv)[0x400bf4]
??:?(main)[0x400b2d]
/lib64/libc.so.6(__libc_start_main+0xf5)[0x7f179467a555]
??:?(_start)[0x4009e9]
Memory map:
00400000-00401000 r-xp 00000000 08:02 50331747 /home/user/a.out
[...]
7f1794f3e000-7f1795027000 r-xp 00000000 08:02 33600977 /usr/lib64/libstdc++.so.6.0.19
7f1795027000-7f1795227000 ---p 000e9000 08:02 33600977 /usr/lib64/libstdc++.so.6.0.19
7f1795227000-7f179522f000 r--p 000e9000 08:02 33600977 /usr/lib64/libstdc++.so.6.0.19
7f179522f000-7f1795231000 rw-p 000f1000 08:02 33600977 /usr/lib64/libstdc++.so.6.0.19
[...]
catchsegv
是如何工作的?它本质上是使用 LD_PRELOAD
和库 libSegFault.so
注入信号处理程序。如果您的应用程序已经为 SIGSEGV
安装了信号处理程序并且您打算利用 libSegFault.so
,您的信号处理程序需要将信号转发给原始处理程序(由 sigaction(SIGSEGV, NULL)
返回) ).
第二个选项是使用自定义信号处理程序和 backtrace()
自己实现堆栈跟踪功能。这允许您自定义输出位置和输出本身。
根据这些信息,我们基本上可以做与之前相同的事情 (0x7f1794fd36a3-0x7f1794f3e000=0x956a3
)。这一次,我们可以回到来电者那里进行更深入的挖掘。第二帧由以下行表示:
??:?(_ZN4Test5printEv)[0x400bf4]
0x400bf4
是被调用者returns到Test::print()
之后的地址,它位于可执行文件中。我们可以将调用站点可视化如下:
$ objdump --demangle -d ./a.out
[...]
400bea: bf a0 20 60 00 mov [=22=]x6020a0,%edi
400bef: e8 4c fd ff ff callq 400940 <std::basic_ostream<char, std::char_traits<char> >& std::operator<< <char, std:
:char_traits<char>, std::allocator<char> >(std::basic_ostream<char, std::char_traits<char> >&, std::basic_string<char, std::char_trai
ts<char>, std::allocator<char> > const&)@plt>
400bf4: be 70 09 40 00 mov [=22=]x400970,%esi
^^^^^^
400bf9: 48 89 c7 mov %rax,%rdi
400bfc: e8 5f fd ff ff callq 400960 <std::ostream::operator<<(std::ostream& (*)(std::ostream&))@plt>
[...]
请注意,objdump 的输出与此实例中的地址相匹配,因为我们 运行 它针对可执行文件,它在 x86_64 上具有默认基地址 0x400000
- objdump 采用考虑到这一点。启用地址 space 布局随机化 (ASLR)(用 -fpie
编译,用 -pie
链接),必须按照前面所述考虑基地址。
进一步返回涉及相同的步骤:
??:?(main)[0x400b2d]
$ objdump --demangle -d ./a.out
[...]
400b1c: e8 af fd ff ff callq 4008d0 <operator delete(void*)@plt>
400b21: 48 8d 45 d0 lea -0x30(%rbp),%rax
400b25: 48 89 c7 mov %rax,%rdi
400b28: e8 a7 00 00 00 callq 400bd4 <Test::print()>
400b2d: b8 00 00 00 00 mov [=24=]x0,%eax
^^^^^^
400b32: eb 2a jmp 400b5e <main+0xb1>
[...]
到目前为止,我们一直在手动将绝对地址转换为相对地址。相反,模块的基地址可以通过 --adjust-vma=<base-address>
传递给 objdump。这样,rip
的值或调用者的地址就可以直接使用了。
添加调试符号
我们已经走了很长一段路,没有垃圾场。然而,要使调试有效,还缺少另一个关键拼图:调试符号。没有它们,可能很难将程序集映射到相应的源代码。使用 -O3
且没有调试信息编译示例说明了问题:
[98161.650474] a.out[13185]: segfault at ffffffffffffffe8 ip 0000000000400a4b sp 00007ffc9e738270 error 5 in a.out[400000+1000]
作为内联的结果,日志条目现在指向我们的可执行文件作为触发器。使用 objdump 让我们得到以下结果:
400a3e: e8 dd fe ff ff callq 400920 <operator delete(void*)@plt>
400a43: 48 8b 33 mov (%rbx),%rsi
400a46: bf a0 20 60 00 mov [=26=]x6020a0,%edi
400a4b: 48 8b 56 e8 mov -0x18(%rsi),%rdx
^^^^^^
400a4f: e8 4c ff ff ff callq 4009a0 <std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)@plt>
400a54: 48 89 c5 mov %rax,%rbp
400a57: 48 8b 00 mov (%rax),%rax
部分流实现是内联的,因此更难识别相关的源代码。没有symbols,就得用export symbols,调用(like operator delete(void*)
)和周围的指令(mov [=71=]x6020a0
加载std::cout
的地址:00000000006020a0 <std::cout@@GLIBCXX_3.4>
)来定位.
使用调试符号 (-g
),通过使用 --source
:
objdump
可以获得更多上下文
400a43: 48 8b 33 mov (%rbx),%rsi
operator<<(basic_ostream<_CharT, _Traits>& __os,
const basic_string<_CharT, _Traits, _Alloc>& __str)
{
// _GLIBCXX_RESOLVE_LIB_DEFECTS
// 586. string inserter not a formatted function
return __ostream_insert(__os, __str.data(), __str.size());
400a46: bf a0 20 60 00 mov [=27=]x6020a0,%edi
400a4b: 48 8b 56 e8 mov -0x18(%rsi),%rdx
^^^^^^
400a4f: e8 4c ff ff ff callq 4009a0 <std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)@plt>
400a54: 48 89 c5 mov %rax,%rbp
按预期工作。在现实世界中,调试符号并未嵌入二进制文件中——它们在单独的 debuginfo 包中进行管理。在那些情况下,objdump
会忽略调试符号,即使它们已安装。要解决此限制,必须将符号重新添加到受影响的二进制文件中。以下过程创建分离符号并使用 eu-unstrip
从 elfutils
重新添加它们,以便 objdump:
# compile with debug info
g++ segv.cxx -O3 -g
# create detached debug info
objcopy --only-keep-debug a.out a.out.debug
# remove debug info from executable
strip -g a.out
# re-add debug info to executable
eu-unstrip ./a.out ./a.out.debug -o ./a.out-debuginfo
# objdump with executable containing debug info
objdump --demangle -d ./a.out-debuginfo --source
使用 GDB 代替 objdump
到目前为止,我们一直在使用 objdump,因为它通常可用,甚至在生产系统上也是如此。我们可以只使用 GDB 吗?是的,通过对感兴趣的模块执行 gdb
。我在之前的 objdump 调用中使用 0x0x400a4b
:
$ gdb ./a.out
[...]
(gdb) disass 0x400a4b
Dump of assembler code for function main():
[...]
0x0000000000400a43 <+67>: mov (%rbx),%rsi
0x0000000000400a46 <+70>: mov [=29=]x6020a0,%edi
0x0000000000400a4b <+75>: mov -0x18(%rsi),%rdx
0x0000000000400a4f <+79>: callq 0x4009a0 <_ZSt16__ostream_insertIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_PKS3_l@plt>
0x0000000000400a54 <+84>: mov %rax,%rbp
与objdump相比,GDB可以毫不费力地处理外部符号信息。 disass /m
对应 objdump --source
:
(gdb) disass /m 0x400a4b
Dump of assembler code for function main():
[...]
21 Test test(*value);
22 delete value;
0x0000000000400a25 <+37>: test %rbx,%rbx
0x0000000000400a28 <+40>: je 0x400a43 <main()+67>
0x0000000000400a3b <+59>: mov %rbx,%rdi
0x0000000000400a3e <+62>: callq 0x400920 <_ZdlPv@plt>
23 test.print();
24 return 0;
25 }
0x0000000000400a88 <+136>: add [=30=]x18,%rsp
[...]
End of assembler dump.
在优化二进制文件的情况下,如果无法明确映射源代码,GDB 可能会跳过此模式下的指令。我们在 0x400a4b
的说明未列出。 objdump 从不跳过指令,而是可能会跳过源上下文——我更喜欢在这个级别进行调试的一种方法。这并不意味着 GDB 对这项任务没有用,它只是需要注意的地方。
最后的想法
终止原因、寄存器、内存映射和堆栈跟踪。一切都在那里,甚至没有核心转储的痕迹。虽然绝对有用(我用这种方式修复了很多崩溃),但您必须记住,通过这种方式您仍然会丢失有价值的信息,最显着的是堆栈和堆以及每线程数据(线程元数据,寄存器、堆栈)。
因此,无论是什么情况,您都应该认真考虑启用核心转储生成,并确保在关键时刻能够成功生成转储。调试本身就已经足够复杂了,没有您 技术上可能拥有的信息进行调试 会不必要地增加复杂性和周转时间,更重要的是,会显着降低找到根本原因并解决问题的可能性及时。