如何快速定位程序Core?

1. 程序Core定义及分类

程序core是指应用程序无法在保持正常running状态而发生的崩溃行为。程序core时会生成相关的core-dump文件,core-dump文件是程序崩溃时程序状态的数据备份。core-dump文件包含内存、处理器、寄存器、程序计数器、栈指针等状态信息。我们可以借助core-dump文件分析定位程序Core的原因。

这里我们从三个方面对程序Core进行分类:机器、资源、程序Bug。下表对常见的Core原因进行了分类:

分类

原因

备注

机器硬件故障(SIGBUG、SIGEMT、SIGIOT)首先查看程序core时的信号,判断core是否由硬件故障导致
资源内存超限资源超限,通过dmseg发现
栈空间超过设置大小

pthread(ulimit -s)

bthread(--stack_size_normal)

线程超限检查程序运行线程数是否超过容器线程限制(默认1000)
程序BUGAssert问题常见于一些初始化代码中,容易发现
内存问题数组越界、空指针、类型强转等
栈问题越界写坏栈状态信息、栈超限
并发问题多线程操作同一段内存空间
程序指令错乱一般由堆栈写坏触发
退出问题析构顺序问题、线程未主动join等

2. 函数栈介绍

当我们打开core文件时,首先关注的就是程序崩溃时的函数调用栈状态,为了方便理解后续定位core的一些技巧,首先简单介绍一下函数栈。

i. 寄存器介绍

目前生产环境都为64位机,这里只介绍64位机的寄存器,如下:

对于x86-64架构,共有16个64位寄存器,每个寄存器的用途并不单一,如%rax通常保存函数返回结果,但也应用于imul和idiv指令。

这里我们重点关注%rsp(栈顶指针寄存器)、%rbp(栈底指针寄存器)、%rdi、%rsi、%rdx、%rcx、%r8、%r9(分别对应第1~6函数参数)。

Callee Save用于说明是否需要被调用者保存寄存器的值。

ii. 函数调用

调用函数栈帧:

在调用一个函数时首先进行的是参数压栈,参数压栈的顺序跟参数定义的顺序相反。这里需要注意,并不是参数一定会压栈,在x86-64架构中会针对一些可以使用寄存器传递的变量,直接通过寄存器传值,如数字、指针、引用等。

参数压栈后会将返回地址压栈,返回地址为被调用函数执行完后,调用函数执行的下一个指令地址。这里牢记返回地址的位置,后续章节后利用到这个返回地址的特性。

在这里,针对上面的介绍举个例子说明:

如上图,在main函数中调用了foo函数,首先对参数压栈,三个参数都可以直接只用寄存器传递(分别对应%edi、%esi、%edx),然后call指令将下一个指令压栈。

被调用函数栈帧:

被调用函数首先会将上一个函数的栈底指针(%rbp)保存,即%rbp压栈

然后再保存需要保存的寄存器值,即Callee Save为True的寄存器。接着为临时变量、局部变量申请栈空间。

针对被调用函数,举个例子说明:

如上图,在foo函数执行时,先对main函数的%rbp压栈,再把寄存器中的参数值存放到局部变量(a, b, c)中。

iii. 总结

通过对函数调用的简单介绍,我们可以发现函数栈是一个缜密且脆弱的结构,内存中各个位置的变量只能按照正确的方式一步步执行,稍有不慎就可能导致程序崩溃。

3.GDB定位Core

这一节将介绍从core文件打开到定位全流程中可能会遇到的问题以及解决技巧。

i. Core文件

core文件在哪里?

Sofacloud集群:/home/work/opdir/coresave

Beehive集群:/home/matrix/containers/xxxx/home/coredump

core文件与二进制不匹配?

这类问题常见于模块上线程序退出core,原因是模块上线后二进制被替换,导致core文件和二进制不匹配。遇到这类问题时,先确定当前core文件对应的二进制版本,download到/tmp(一般RD没有线上机器权限,可以先暂存到/tmp目录下,记得使用完成后rm),然后使用gdb打开正确的版本即可。

core文件被截断?

截断问题一般是因为core dump进程未完全dump数据就被kill导致的,常发生于模块上线。可调整下容器退出等待时间,如下:

noahdes/sofacloud.conf

#默认是130s,当dump的数据量非常大时很容易超时

node_kill_wait_sec=600

ii. 定位代码行

定位core的原因,首先要定位崩溃时正在执行的代码行,这一节主要介绍一些定位代码行的方法。通常情况下我们是可以直接通过gdb的breaktrace一览整个函数栈,但有时候函数栈并非如此清晰明了。

1)去编译优化

有时候会发现core的函数栈跟实际的代码行不匹配,如果是在线下环境中,可以尝试把编译优化设置成-O0,然后再重新复现core问题。

2)汇编 + addr2line

对于线上core问题,一般没法再对程序进行去编译优化操作,只能在现有的core文件基础上进行代码定位,这一节我们采用一个例子来介绍如何使用汇编 + addr2line来定位代码行。

从截图可以发现frame 20指示的代码行与实际的代码行是不匹配的,这里我们采用汇编 + addr2line进行修复。

frame 20

layout asm

shell /opt/compiler/gcc-8.2/bin/addr2line -e bin address

这里直接使用了layout asm命令显示了汇编代码,命令使用传送门。最后通过addr2line命令把汇编地址转化成实际代码行。

3)函数栈修复

有时候我们会发现函数调用栈里面会出现很多??的情况,常发生于栈被写花。函数栈的修复可利用到上面介绍的函数栈的分布知识。

-----------------------------------
Low addresses
-----------------------------------
0(%rsp)  | top of the stack frame 
         | (this is the same as -n(%rbp))
---------|-------------------------
-n(%rbp) | variable sized stack frame
-4(%rbp) | varied
0(%rbp)  | previous stack frame address
4(%rbp)  | return address
-----------------------------------
High addresses

从上面的栈示意可以清楚的发现可以利用%rbp寄存器找到上一个函数的返回地址和栈底指针。在知道函数地址后再利用addr2line命令找到对应的代码行。这里举一个例子:

首先切换到frame 0,利用%rbp找到frame 1的返回地址,再依次利用栈的内存分布特性找到剩下的返回地址。

这里使用到的'x'命令后面单独讲解。

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栈最好的方式还是借助asan。

# BCLOUD设置编译参数

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

asan的相关介绍即相关原理可以参考:AddressSanitizer

5)总结

上面提到的几种方法都是为了找到具体的问题代码行,为后续分析core的具体原因提供线索。

iii. 变量打印

确认代码行后,我们需要根据栈的上下文定位core的原因,因此打印相关的数据是分析异常的基础。

变量打印涉及的点比较多,因此在一个小节单独介绍。

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 - 字符串

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>:表示内存地址。

上面介绍了‘x’命令的使用方式,比如我们在栈修复的时候使用x /2ag %rbp即可打印上一个栈栈底指针和返回地址。

3)容器对象打印

常见的容器如vector、map、list等我们可以采用gdb脚本查看,这里推荐一个方便的脚本:stl-views.gdb,直接在gdb终端source stl-views.gdb即可。

只要理解了容器对象的内存布局就可以找到想要的数据,这里举个打印string::size的例子:

正常情况下对于空string,_M_dataplus._M_p是指向nullptr的,当赋值后会在堆上申请一段内存保存,分为两段,前半段是meta信息,如length、capacity、refcount,后半段为实际数据区,_M_p指向数据区。

当我们需要处理一段二进制数据时,直接使用print未必能完全打印整个string数据,需要先拿到string的length,操作如下:

p *((std::string::_Rep*)(s._M_dataplus._M_p) - 1)
x /ncb s._M_dataplus._M_p #这里的n是_Rep._M_length

4) 静态变量打印

我们的程序中经常会使用到静态变量,分为局部的和全局的,有时我们需要查看某个静态对象的值是否正确,就需要涉及到静态对象的打印。看如下例子:

void foo() {

    static std::string s_foo("c");

}

这里可以借助nm -C ./bin  | grep xx找到静态变量的内存地址,再通过gdb的print打印。

5)内存dump

内存dump常用于验证数据正确性上,如一个client发送了一段异常数据导致了core,这个时候需要回放这段数据来排查这段数据中的异常,就需要用到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结合使用。

iv. 定位Core原因

这一节主要介绍定位Core原因的方法以及一些常见原因的介绍。

1)确认信号量

从上面的Core分类我们可以发现某些场景的core是由于机器故障导致的,如SIGBUS,因此可以先通过信号量排除掉一些core原因。

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)排查异常变量

通过上面的操作我们可以准确定位到具体是哪一行代码的哪一条指令出现了问题,根据异常指令我们可以排查相关的变量,确定变量值是否符合预期。

这里举一个比较经典的空指针例子,如下:

int main(int argc, char* argv[]) {

    int* a = nullptr;

    *a = 1;

    return *a;

}

通过汇编指令我们可以发现是movl $0x1, (%rax)出现了问题,%rax的值来自于0x8(%rbp)x命令打印相关的地址就可以发现为空指针错误。

4)查看被优化变量

通常情况下程序都是开启了编译优化的,就会出现变量无法在gdb中被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的值了。

比较复杂的场景可能需要对寄存器值做回溯,知道还原被优化的变量。这里可以查看一些比较复杂的例子:Core了咋办啊?

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。

6)总结

我们定位core的时候最主要是定位哪行代码执行的哪行汇编指令所关联的数据or地址有异常。善于利用汇编指令以及打印指令(x、print、display)可以更有效的定位Core。

4. 参考资料

常用GDB命令。https://wizardforcel.gitbooks.io/100-gdb-tips/content/display-instruction-pc.html
函数调用基础知识:https://zhuanlan.zhihu.com/p/27339191
变量打印基础:https://www.jianshu.com/p/589308dd36dc
汇编基础认识:https://blog.csdn.net/hiawui/article/details/6173107 
汇编查看工具:https://godbolt.org/ https://cppinsights.io/
标准GDB文档:https://sourceware.org/gdb/current/onlinedocs/gdb/

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值