导读:程序core是指应用程序无法保持正常running状态而发生的崩溃行为。程序core时会生成相关的core-dump文件,是程序崩溃时程序状态的数据备份。core-dump文件中包含内存、处理器、寄存器、程序计数器、栈指针等状态信息。本文将介绍一些利用core-dump文件定位程序core原因的方法和技巧。
一、程序Core定义及分类
程序core是指应用程序无法保持正常running状态而发生的崩溃行为。程序core时会生成相关的core-dump文件,core-dump文件是程序崩溃时程序状态的状态数据备份。core-dump文件包含内存、处理器、寄存器、程序计数器、栈指针等状态信息。我们可以借助core-dump文件来分析定位程序Core的原因。
这里我们从三个方面对程序Core进行分类:机器、资源、程序Bug。下表对常见的Core原因进行了分类:
二、函数栈介绍
当我们打开core文件时,首先关注的是程序崩溃时的函数调用栈状态,为了方便理解后续定位core的一些技巧,这里先简单介绍一下函数栈。
2.1 寄存器介绍
目前生产环境都为64位机,这里只介绍64位机的寄存器,如下:
对于x86-64架构,共有16个64位寄存器,每个寄存器的用途并不单一,如%rax通常保存函数返回结果,但也被应用于imul和idiv指令。这里重点关注%rsp(栈顶指针寄存器)、%rbp(栈底指针寄存器)、%rdi、%rsi、%rdx、%rcx、%r8、%r9(分别对应第1~6函数参数)。
Callee Save说明是否需要被调用者保存寄存器的值。
2.2 函数调用
2.2.1 调用函数栈帧:
在调用一个函数时首先进行的是参数压栈,参数压栈的顺序跟参数定义的顺序相反。注意,并不是参数一定会压栈,在x86-64架构中会针对可以使用寄存器传递的变量,直接通过寄存器传值,如数字、指针、引用等。
接着是返回地址压栈,返回地址为被调用函数执行完后,调用函数执行的下一个指令地址。这里牢记返回地址的位置,后续章节会利用到这个返回地址的特性。
针对上面的介绍举个例子说明:
如上图,在main函数中调用了foo函数,首先对参数压栈,三个参数都可以直接用寄存器传递(分别对应%edi、%esi、%edx),然后call指令将下一个指令压栈。
2.2.2 被调用函数栈帧:
被调用函数首先会将上一个函数的栈底指针(%rbp)保存,即%rbp压栈。然后再保存需要被保存的寄存器值,即Callee Save为True的寄存器。接着为临时变量、局部变量申请栈空间。
针对被调用函数,举个例子说明:
如上图,在foo函数执行时,先对main函数的%rbp压栈,再把寄存器中的参数值存放到局部变量(a, b, c)中。
2.3 总结
通过对函数调用的简单介绍,我们可以发现函数栈是一个缜密且脆弱的结构,内存结构必须按照严格的方式被访问,如稍有不慎就可能导致程序崩溃。
三、GDB定位Core
这一节将介绍从core文件打开到定位全流程中可能会遇到的问题以及解决技巧。
3.1 Core文件
core文件在哪里?
查看“/proc/sys/kernel/core_pattern”确定core文件生成规则。
3.2 变量打印
程序debug过程中常常要查看各种变量(内存、寄存器、函数表等)的值是否正确,维持单独用一节介绍下常用的变量打印方法以及一些冷门小技巧。
3.2.1 print命令
print [Expression]
print $[Previous value number]
print {[Type]}[Address]
print [First element]@[Element count]
print /[Format] [Expression]
Format格式:
o - 8进制
x - 16进制
u - 无符号十进制
t - 二进制
f - 浮点数
a - 地址
c - 字符
s - 字符串
3.2.2 x命令
x /<n/f/u> <addr>
n:是正整数,表示需要显示的内存单元的个数,即从当前地址向后显示n个内存单元的内容,
一个内存单元的大小由第三个参数u定义。
f:表示addr指向的内存内容的输出格式,s对应输出字符串,此处需特别注意输出整型数据的格式:
x 按十六进制格式显示变量.
d 按十进制格式显示变量。
u 按十进制格式显示无符号整型。
o 按八进制格式显示变量。
t 按二进制格式显示变量。
a 按十六进制格式显示变量。
c 按字符格式显示变量。
f 按浮点数格式显示变量。
u:就是指以多少个字节作为一个内存单元-unit,默认为4。u还可以用被一些字符表示:
如b=1 byte, h=2 bytes,w=4 bytes,g=8 bytes.
<addr>:表示内存地址。
3.2.3 容器对象打印
利用上面的print和x命令,再结合容器的数据结构,我们就能知道容器的详细信息。这里举个完整打印二进制string的例子,string的数据结构如下:
string为空时,_M_dataplus._M_p是指向nullptr的。当赋值后会在堆上申请一段内存,分为两段,前半段是meta信息(类型为std::string::_Rep),如length、capacity、refcount,后半段为数据区,_M_p指向数据区。
通常情况下非二进制的string,直接print即可显示数据内容,但当数据为二进制时,’\0’会截断打印内容。因此,打印二进制string的首要任务是确认string的size。
string的size信息保存在std::string::_Rep结构体中,根据上面的数据结构可以发现,_Rep与_M_dataplus._M_p相差一个结构体大小,因此打印_Rep结构体的命令为:
#先把_M_p转成_Rep指针,再让指针向低地址偏移一个结构体大小
p *((std::string::_Rep*)(s._M_dataplus._M_p) - 1)
找到string的size(_M_length)后,再通过x命令打印相关的内存区即可,命令为:
#这里的n是_Rep._M_length
x /ncb s._M_dataplus._M_p
运行效果如下:
为了方便,这里推荐一个方便的脚本:stl-views.gdb(链接:https://sourceware.org/gdb/wiki/STLSupport?action=AttachFile&do=view&target=stl-views-1.0.3.gdb,直接在gdb终端source stl-views.gdb即可,支持常见的容器打印,如vector、map、list、string等。
3.2.4 静态变量打印
程序中经常会使用到静态变量,有时我们需要查看某个静态对象的值是否正确,就涉及到静态对象的打印。看如下例子:
void foo() {
static std::string s_foo("foo");
}
这里可以借助nm -C ./bin | grep xx找到静态变量的内存地址,再通过gdb的print打印。
3.2.5 内存dump
dump [format] memory filename start_addr end_addr
dump [format] value filename expr
format一般使用binary,其他的可以查看gdb手册。
比如我们可以结合上面查看string内容的例子dump整个string数据到文件中。
dump binary memory file1 s._M_dataplus._M_p s._M_dataplus._M_p + length
如果想查看文件内容的话可把vim -b和xxd结合使用。
接上面string的例子,举一个dump string内存数据到文件的例子:
3.3 定位代码行
定位core的原因,首先要定位崩溃时正在执行的代码行,这一节主要介绍一些定位代码行的方法。通常情况下直接通过gdb的breaktrace即可一览整个函数栈,但有时候函数栈信息并非如此清晰明了,这时就可利用一些小技巧来查看函数栈。
3.3.1 去编译优化
有时候会发现core的函数栈跟实际的代码行不匹配,如果是在线下环境中,可以尝试把编译优化设置成-O0,然后再重新复现core问题。
3.3.2 程序计数器 + addr2line
对于线上core问题,一般没法再对程序进行去编译优化操作,只能在现有的core文件基础上进行代码定位。这一节我们采用一个例子来介绍如何使用程序计数器 + addr2line来定位代码行。
从截图可以发现frame 20指示的代码行与实际的代码行是不匹配的,定位步骤如下:
# 跳转到第20号栈
frame 20
# 使用display命令显示程序计数器
display /i $rip
# 使用addrline工具做地址转换
shell /opt/compiler/gcc-8.2/bin/addr2line -e bin address
3.3.3 函数栈修复
有时候我们会发现函数调用栈里面会出现很多??的情况,这常发生于栈被写花,某些情况下手动进行修复。函数栈的修复利用的函数栈内存分布知识,见第一节。
-----------------------------------
Low addresses
-----------------------------------
0(%rsp) | top of the stack frame
| (this is the same as -n(%rbp))
---------|-------------------------
-n(%rbp) | variable sized stack frame
-8(%rbp) | varied
0(%rbp) | previous stack frame address
8(%rbp) | return address
-----------------------------------
High addresses
从上面的栈示意图可以发现,利用%rbp寄存器即可找到上一个函数的返回地址和栈底指针,再利用addr2line命令找到对应的代码行。这里举一个例子:
#首先找到当前被调用栈上一个栈的栈底指针值和返回地址
x /2ag $rbp # 2个单位,a=十六进制,g=8字节单元
#使用上一条命令得到的栈底指针值依次递归
x /2ag address
3.3.4 无规律core栈
无规律core栈问题一般发生于堆内存写坏。函数调用是一个非常精密的过程,任何一个位置发生非预期的读写都会导致程序崩溃。这里可以举个小例子来说明:
int main(int argc, char* argv[]) {
std::string s("abcd");
*reinterpret_cast<uint64_t*>(&s) = 0x11;
return 0;
}
上面的例子core在string析构上,原因是因为string的_M_ptr被改写成了0x11,析构流程变成了非法内存操作。
同理,由于进程堆空间是共享的,一个线程对堆的非法操作就可能会影响另一个线程的正常操作,由于堆分配的随机性,表现出来的现象就是无规律core栈。
针对无规律core栈最好的方式还是借助AddressSanitizer。
#设置编译参数CXXFLAGS
CXXFLAGS="-fPIC -fsanitize=address -fno-omit-frame-pointer"
#设置链接参数
LDFLAGS="-lasan"
# 设置启动环境变量
export ASAN_OPTIONS=halt_on_error=0:abort_on_error=1:disable_coredump=0
# 启动
LD_PRELOAD=/opt/compiler/gcc-8.2/lib/libasan.so ./bin/xxx
3.3.5 总结
上面提到的几种方法都是为了找到具体的问题代码行,为后续分析core的具体原因提供线索。
3.4 定位Core原因
这一节主要介绍定位Core原因的方法以及一些常见原因的介绍。
3.4.1 确认信号量
从上面的Core分类我们可以发现某些场景的core是由于机器故障导致的,如SIGBUS,因此可以先通过信号量排除掉一些core原因。
3.4.2 定位异常汇编指令
通过上面的代码行定位我们可以大致找到程序core在哪一行,比较简单的core直接print程序上下文即可找到core的原因。
但有些场景下,通过排查上下文无任何异常,这个时候就需要准确定位具体的异常汇编指令,根据指令找原因。
查看汇编指令比较简单的方法是使用layout asm命令,frame指向那个栈,就显示对应栈的汇编。这里举个core例子,如下:
程序显示core在start函数,查看相关上下文变量均无异常。使用layout asm打开正在执行的汇编指令,如下:
查看汇编定位到程序core在mov指令,mov指令上一个指令为sub,为栈申请了3M空间,怀疑是栈空间不足。采用frame 0的%rsp - frame N的%rbp排查为栈空间不足。
通过上面的例子,可以发现定位异常汇编指令位置后,我们能够把异常点进一步压缩,定位到是哪个指令、变量、地址导致的core问题。
3.4.3 排查异常变量
通过上面的操作我们可以准确定位到具体是哪一行代码的哪一条指令出现了问题,根据异常指令我们可以排查相关的变量,确定变量值是否符合预期。
这里举一个比较经典的空指针例子,如下:
int main(int argc, char* argv[]) {
int* a = nullptr;
*a = 1;
return *a;
}
通过汇编指令我们可以发现是movl $0x1, (%rax)出现了问题,%rax的值来自于0x8(%rbp),x命令打印相关的地址就可以发现为空指针错误。
3.4.4 查看被优化变量
通常情况下程序都是开启了编译优化的,就会出现变量无法被print,提示变量被优化,有时可利用汇编 + 寄存器的方式查看被优化的变量。
这里举一个例子说明下:
void foo(char const* str) {
char buf[1024] = {'\0'};
memcpy(buf, str, sizeof(buf));
}
int main(int argc, char* argv[]) {
foo("abcd");
return 0;
}
通常情况下在foo函数内部,str变量是会直接别优化掉的,因为可以直接利用%rdi寄存器传递参数。为了能够打印出str的值,这个时候我们可以借助汇编 + 寄存器的方式找到具体的变量值,如下:
首先找到main函数调用foo函数的参数压栈汇编:mov $0x402011, %edi,这里的0x402011即为str的内存地址,通过x命令即可显示str的值了。
比较复杂的场景可能没法直接找到被优化变量,这时可以采用汇编回溯的方式找到变量。
3.4.5 异常函数地址排查
有时的core问题是因为数据异常导致,有时也可能是优化函数地址导致,如调用虚函数地址错误、函数返回地址错误、函数指针值错误。
异常函数地址排查同理于异常变量排查,根据汇编指令确认调用是否异常即可。这里举一个虚函数地址异常的例子,如下:
class
A {
public:
virtual ~A() = default;
virtual void foo() = 0;
};
class
B : public A {
public:
void foo() {}
};
int main(int argc, char* argv[])
{
A* a = new B;
a->foo();
A* b = new B;
*reinterpret_cast<void**>(b) = 0x0;
b->foo();
return 0;
}
从汇编指令看是core在了mov (%rax), %rax,结合指令上下文可发现是在虚函数地址寻址操作,对比两个变量的虚函数表即可发现是函数地址load错误导致的core。
3.4.6 总结
定位core的基本流程可总结为以下几步:
- 明确core的大致触发原因。机器问题?自身程序问题?
- 定位代码行。哪一行代码出现了问题。
- 定位执行指令。哪一行指令干了什么事。
- 定位异常变量。指令不会有问题,是指令操作的变量不符合预期。
善于利用汇编指令以及打印指令(x、print、display)可以更有效的定位Core。
参考资料:
汇编查看工具:https://godbolt.org/ https://cppinsights.io/
标准GDB文档:https://sourceware.org/gdb/current/onlinedocs/gdb/
原文链接:
如何快速定位程序Core?