用gdb分析coredump的一些技巧

开发 前端
前几天我们正在运营的一款产品发生了崩溃,我花了两天尝试用 gdb 分析了 coredump ,虽然最后还是没能找到 bug ,但还是觉得应该做一些总结。

前几天我们正在运营的一款产品发生了崩溃,我花了两天尝试用 gdb 分析了 coredump ,虽然最后还是没能找到 bug ,但还是觉得应该做一些总结。

产品是基于 skynet 开发的,由于历史原因,它基于的是 skynet 1.0 之前 2015 年中的一个版本,由于这两年一直没出过什么问题,所以维护人员懈怠而没有更新。

崩溃的时候,关于 Lua 部分的代码缺少调试符号信息,这加大了分析难度。现在的 skynet 在编译 lua 时,加入了 -g 选项,这应该可以帮助未来出现类似问题时更好的定位问题。

[[190796]]

导致代码崩溃的直接原因是 rip 指向了一个数据段的地址,准确的说,跳转到了当前工作线程拥有的 lua 虚拟机的主线程 L 那里。

发现这条线索很容易,skynet 的其它部分是有调试符号的,可以在崩溃的调用栈上看到,服务的 callback 函数的 ud 和崩溃地址一致,而 lua 服务的 ud 正是 L 。用 gdb 的 p ( lua_State *)地址 查看这个结构,也能观察到这个数据结构的内容正是一个 lua_State 。

由于用 bt 查看的调用栈是不正常的,所以可以断定在函数调用链的过程中应该是发生了某种错误改写了 C 栈的内容。在这种情况下,gdb 多半靠猜测来重建调用链(就是用 bt 看到的那些)。

现代编译器经过优化代码之后, C 栈上已经没有 stack frame 的基地址了,所以现在不能简单的看堆栈的数据内容来推测 stack frame 。也就是经过优化的代码不一定适用 rbp 来保存 stack frame ,它也不一定入栈。对于 gcc ,这个优化策略是通过 -fomit-frame-pointer 开启的,只要用 -O 编译,就一定打开的。在 stack 本身出问题时,gdb 的猜测很可能不准确,人工来猜或手工补全或许更靠谱一些。方法就是先用 x/40xg $rsp 打印出 C stack 的内容,然后观察确定 stack 上的哪些数据落在代码段上。所有有函数调用的地方,一定有处于代码段上的某处返回地址指针。

主程序的代码段一般都地址偏低,动态链入的代码段可以用 info sharedlibrary 来查看。返回地址肯定是落在函数代码的内部,而肯定不会是函数入口,而这些地址除了函数调用外,都不可能用正常的 C 代码生成出来,所以识别性很强,不会有歧义。

如果觉得某个指针是函数返回地址,可以用 x/10i 地址 来反汇编确认。

但是需要注意的是,即使在 C stack 上发现一个函数返回地址,并不说明这个函数调用尚未返回。它只能说明这个函数至少被调用过。这是因为,汇编在 call 一个函数时,会把当前调用处的地址压栈。而调用结束后,ret 指令返回只是修改了 rsp 这个栈指针,而数据本身是残留在栈上的。这也是为什么 gdb 有时候也会猜错。

在这次的案例里,崩溃发生在执行跳转到了数据段,这种情况多半是因为 call 指令调用的是一个间接引用,在 C 层面来看,就是调用了一个函数指针。这种情况下,跳转地址肯定还在寄存器里。用 info registers 可以查看。(注:在 64bit 平台下,查看寄存器内容非常重要,因为 64bit 下,函数调用的前四个参数是通过寄存器 rdi rsi 传递而非堆栈,往往需要结合 disass 反汇编看代码去推算。)

当然,按 lua 自己的正常逻辑,是不可能把 L 作为一个函数指针来调用的。按我的猜测,这里出错比较大的可能是 longjmp 的时候数据出错,恢复了错误的寄存器。btw, setjmp 在生成 jmp_buf 时,对于 rsp rbp 这类很可能用于地址的寄存器,crt 做了变形(mangling)处理,所以很难简单的靠写越界写出一个巧合的错误值。

对于调试崩溃在 lua 内部的情况,比较关键的线索通常是 L 本身的状态。因为业务的主流程其实是用 lua vm 驱动的,L 的 callinfo 也就是 lua 的 stack frame 信息更多。

对于 skynet ,在正常运行的时候通常会有两个活动的 L 。一个是主线程,用来分发消息;但消息本身是在一个独立的 coroutine 中进行的。以上可以确定主线程,而子线程的 L 可以在寄存器和 stack frame 里找。由于没有调试符号,所以可以靠猜来寻找,这并不算太麻烦。要确定一个地址是否是 L ,只需要查看 L->l_G 看是否和前面找到的主线程 L 的对应值是否相同。

在缺少调试符号的情况下,会发现 lua 下的一些内部数据结构 gdb 无法识别。这个时候可以用 add-symbol-file 来导入需要的结构信息。方法是加上 -g 重新编译一下 lua ,把一些包含这些结构的文件,例如 ldo.o 加进来。

我在分析这次的问题时,写了脚本查看两个 L 的 lua 调用栈,这些脚本只要对 lstate.h 里的 callinfo 数据结构熟悉就很容易写出来。lua 的调试信息很丰富,找到源文件名和行号都很容易。另外,L 栈顶的数据是什么也是重要的线索,可以推导出崩溃发生时 Lua 的状态。

这次我们崩溃的程序最后停在主线程的 resume 调用子线程上。子线程调用了 skynet.sleep ,也就是最后把 "SLEEP", session 通过 yield 传给了主线程。这些要传出的量可以在子线程的 L->top 上查到。虽然 lua 本身已经把值 pop 出去了,但 pop 本身是不清空栈的,只是调整了栈顶指针,所以在 gdb 下依然可见。主线程也接收到了传过来的数据,数据栈上可见。

不过这次的吊诡之处在于,lua 线程间拷贝数据这个过程是在 lcorolib.c 中的 auxresume 函数中执行的,在 luaB_coresume 里还需要在结果中插入一个 boolean 。而我在 coredump 数据中发现了拷贝过程已经完成,但是 boolean 却没有压入。那么事故发生点只可能在两者之间。不过在 auxresume 返回到后续 push boolean 之间只有几行汇编代码,绝对不可能出错。

唯一能解释的就是在 lua_resume 期间,子线程运行的流程破坏了 C 的 stackframe ,让 auxresume 没能正确的返回到调用它的 luaB_coresume 中。但怎样才能制造出这种情况,我暂时没有想法。

在 C 层面制造出崩溃的可能性并不是很多,数据越界是一类常见的 bug (这次并不像);另一类是内存管理出错,比如对同一个指针 free 多次,导致内存管理器出错,把同一个地址分配给两个位置,导致两个对象地址重叠。后一类问题能干扰到 C 的 stack frame 可能性比较小,除非有堆上的对象指针指向了栈地址,然后并引用。这次的 bug 中,最打的线索是 L->errorJmp ,也就是 lua 线程中指向恢复点的 jmp_buf ,它是在 C 栈上的。

L 中有一些相关变量可以推测 resume/yield/pcall 等的执行状态: L->nny L->nCcalls L->ci->callstatus 等都是。我分析的结果是在 auxresume 返回后,没有继续运行 luaB_coresume 中的 push boolean 过程,却又运行了新的一轮 luaD_pcall ,导致了最终的崩溃。这可以通过 L 的 errorJmp 的 status 得到一定的佐证。不过 C stack 上没有 luaB_coresume 的返回地址比较难解释,只能说是可能被错误的运行流程覆盖掉了。

gc 会是触发 bug 的多发点,因为 gc 是平行于主流程同步进行的。这次崩溃点的子线程的 lua 栈帧停留在 yield 函数上,在此之前也的确调用了 gc step 。但是,我们可以通过查阅 L 的 gcstate 变量查看 gc 处于什么阶段。在这次的事发现场,可以看到 gcstate 为 GCSpropagate 也就是 mark 阶段,所以并不会引发任何 __gc 流程,也没有内存释放。

结论:对于 bug ,暂时没有结论。不过对于调试 lua 编写的程序,还是积累了一些经验:

  1. 一定要在编译 lua 时加 -g ,虽然 lua 本身出严重 bug 的可能性极低,但可以方便在出问题时用 gdb 分析。
  2. 在 gdb 中查看 lua 的调用栈很有意义,分析 L->ci 很容易拿到调用栈信息。
  3. 记得查看一下 lua 的数据栈内容,包括已经 pop 出去,但还残留在内存中的数据,可以帮助分析崩溃时的状态。
  4. 记得查看 L 中保留的 gcstate GCdebt 等 gc 相关变量,可以用于推断 lua gc 的工作状态。
  5. L 中的 nny nCcalls errorJmp 可以帮助确定 lua 到 C 的调用层次。注意:一个 yield 状态的 coroutine ,errorJmp 指针应该为 NULL 。

另外,gdb 分析 skynet 可以从下面的线索入手:

  1. context 对象里能找到当前服务的地址、最后一个向外提起的请求的 session 、接收过多少条消息等。结合 log 文件来看会有参考价值。
  2. 如果想找到内存中其它的服务对象(非当前线程上活动的),可以试试 p *H 。 H 是个数组,定义在 skynet_handle.c 中,里面有所有服务的地址。
  3. 如果想找到内存中待唤醒的 timer ,可以试试 p *TI 。它定义在 skynet_timer.c 中。
责任编辑:未丽燕 来源: 云风的 BLOG
相关推荐

2018-01-09 18:06:41

Python爬虫技巧

2011-06-01 16:50:21

JAVA

2012-05-21 10:13:05

XCode调试技巧

2013-03-29 13:17:53

XCode调试技巧iOS开发

2011-10-26 20:55:43

ssh 安全

2011-07-12 09:47:53

WebService

2011-05-23 18:06:24

站内优化SEO

2021-10-12 23:10:58

UnsafeJavaJDK

2022-12-02 14:58:27

JavaScript技巧编程

2020-04-08 10:21:58

bash脚本语言

2009-11-26 10:32:57

PHP代码优化

2020-04-14 09:22:47

bash脚本技巧

2017-09-20 15:07:32

数据库SQL注入技巧分享

2022-02-17 13:58:38

Linux技巧文件

2018-05-07 08:22:19

LinuxImageMagick查看图片

2024-03-11 15:08:26

Linux操作系统进程

2021-06-18 07:35:46

Java接口应用

2021-04-16 08:49:55

JavaScript技巧参数

2016-09-12 17:19:51

JavaScriptArray操作技巧

2011-07-03 19:07:47

关键词
点赞
收藏

51CTO技术栈公众号