前几天,我们操作的一个产品当机了。我花了两天时间尝试用gdb分析coredump。虽然最后还是没找到bug,但还是觉得应该做点Summarize。该产品基于skynet开发。由于历史原因,它基于2015年天网1.0之前的一个版本。由于这两年没出问题,所以维护者懈怠了,也没有更新了。崩溃时Lua部分的代码缺少调试符号信息,增加了分析难度。现在的skynet在编译lua的时候加入了-g选项,以后出现类似问题应该可以帮助更好的定位问题。代码崩溃的直接原因是rip指向了一个数据段的地址,准确的说是跳转到了当前工作线程拥有的lua虚拟机的主线程L。很容易找到这个线索。skynet的其他部分都有调试符号,可以在crash的调用栈上看到。服务的回调函数的ud和crash地址是一致的,而lua服务的ud恰好是L。使用gdb的p(lua_State*)地址查看这个结构体,也可以观察到这个内容数据结构就是一个lua_State。由于bt查看的调用栈异常,可以断定函数调用链中应该发生了某种错误,改写了C栈的内容。在这种情况下,gdb主要是猜测重构调用链(即用bt看到的那些)。现代编译器对代码进行优化后,C栈上已经没有栈帧的基地址了,所以现在我们不能简单的看栈的数据内容来推断栈帧。也就是说,优化后的代码不一定会应用rbp来保存栈帧,也不一定会压入栈中。对于gcc,这个优化策略是通过-fomit-frame-pointer开启的,只要是用-O编译的,就必须开启。当栈本身出现问题时,gdb的猜测很可能不准确,手动猜测或者手动补全可能更靠谱。方法是先用x/40xg$rsp打印出C栈的内容,然后观察判断栈上有哪些数据落在了代码段上。有函数调用的地方,在代码段的某处一定有返回地址指针。主程序的代码段一般都是低地址,动态链接的代码段可以用infosharedlibrary查看。返回地址必须在函数代码内部,不能是函数入口。这些地址除了函数调用,不能用普通的C代码生成,所以辨识度高,不会有歧义。如果认为某个指针是函数返回地址,可以用x/10i地址反汇编确认。但需要注意的是,即使在C栈上找到了函数返回地址,也不代表函数调用还没有返回。只能说明这个函数至少被调用过一次。这是因为当程序集调用函数时,它会将当前调用的地址压入堆栈。调用结束后,ret指令的返回只是修改了栈指针rsp,数据本身还在栈中。这就是为什么gdb有时会猜错的原因。在这种情况下,当执行跳转到数据段时发生了崩溃,这可能是因为call指令调用了一个间接引用,在C层面称为函数指针。在这种情况下,跳转地址必须仍然在寄存器中。您可以使用信息寄存器查看它。(注意:在64bit平台上,检查寄存器内容很重要,因为在64bit下,函数调用的前四个参数是通过寄存器rdirsi传递的,而不是栈,经常需要结合disass反汇编看代码计算。)当然,按lua自己的正常逻辑是不可能把L当作函数指针来调用的。根据我的猜测,这里的大错误可能是longjmp的时候数据错了,恢复了错误的寄存器。btw,setjmp在生成jmp_buf时,对于rsp、rbp等很可能用于地址的寄存器,crt进行了mangling处理,所以单纯写越界很难写出巧合的错误值。对于调试lua内部的崩溃,关键线索通常是L本身的状态。因为主要的业务流程其实是由luavm驱动的,所以L的callinfo也是lua的stackframe获取更多信息。对于skynet,在正常运行期间通常有两个活动的L。一个是主线程,用于分发消息;但是消息本身是在一个单独的协程中执行的。以上可以确定主线程,子线程的L可以在寄存器和栈帧中找到。由于没有调试符号,靠猜测就能找到,也不算太麻烦。判断一个地址是否为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,也就是最终通过yield将“SLEEP”,session传递给主线程。要转移的金额可以在子线程的L->top找到。虽然lua本身已经把值pop出来了,但是pop本身并没有清栈,只是调整了栈顶指针,所以在gdb下还是可见的。主线程也接收传递过来的数据,在数据栈上可以看到。但是这次的悖论在于,lua线程间复制数据的过程是在lcorolib.c中的auxresume函数中执行的,而luaB_coresume中的result中需要插入一个boolean。而且我发现在coredump数据中copy过程已经完成,但是boolean还没有push。那么出事点只能在两者之间。但是auxresumereturn和后面的pushboolean之间只有几行汇编代码,绝对不可能出错。唯一可以解释的是,在lua_resume期间,子线程运行的进程破坏了C的stackframe,导致auxresume无法返回到正确调用它的luaB_coresume。但是如何造成这种情况,我暂时没有想法。在C级造成崩溃的可能性并不大。数据越界是一种常见的bug类型(这次不像);另一类是内存管理错误,比如多次释放同一个指针,导致内存管理器发生错误,将同一个地址分配给两个位置,导致两个对象地址重叠。后一类问题不太可能干扰C的栈帧,除非堆上有一个对象指针指向栈地址然后引用它。在这个bug中,最明显的线索就是L->errorJmp,也就是lua线程中指向恢复点的jmp_buf,在C栈上。L中有一些相关的变量可以推测resume/yield/pcall等的执行状态:L->nnyL->nCcallsL->ci->callstatus等我分析的结果是auxresume返回后,并没有继续运行luaB_coresume中的pushboolean过程,而是运行了新一轮的luaD_pcall,导致最终崩溃。L的errorJmp的状态可以证实这一点。但是C栈上并没有luaB_coresume的返回地址,这就不好解释了。只能说可能是运行错误的进程覆盖了。GC会是bug的频繁触发点,因为GC是和主进程同步并行执行的。崩溃点子线程的lua栈帧停留在yield函数上,之前确实调用了gcstep。但是通过查询L的gcstate变量,我们可以看到gc处于什么阶段。在这个事件的现场,可以看到gcstate是GCSpropagate,也就是mark阶段,所以不会触发任何__gc进程,并且没有内存释放。结论:对于bug,目前还没有定论。但是,对于用lua写的程序调试,我积累了一些经验:一定要在编译lua的时候加上-g。虽然lua本身出现严重bug的可能性极低,但是用gdb分析问题还是很方便的。在gdb中查看lua的调用栈是很有意义的,通过分析L->ci很容易得到调用栈信息。记得查看Lua数据栈的内容,包括已经pop出但还在内存中的数据,可以帮助分析crash的状态。记得查看L中保留的gcstateGCdebt等gc相关的变量,可以用来推断luagc的工作状态。L中的nnynCcallserrorJmp可以帮助确定从lua到C的调用层次。注意:对于yieldstate协程,errorJmp指针应该为NULL。另外,对skynet的gdb分析可以从以下线索入手:当前服务的地址、上次发出请求的session、收到了多少条消息等都可以在context对象中找到。结合日志文件,会有参考价值。如果要在内存中查找其他服务器对象(在当前线程上不活动),请尝试p*H。H是定义在skynet_handle.c中的一个数组,包含了所有服务的地址。如果想找内存中的定时器唤醒,可以试试p*TI。它在skynet_timer.c中定义。
