放假在家,继续调试《家园》。目前的进度是MinGW上的编译链接都已通过,游戏程序也已经可以跑起来并进入主菜单界面,但加载关卡之后就会闪退。这让我想起了以前上中学时玩盗版游戏的日子。那个年代的单机游戏估计大多是用C/C++写的,一个不小心的内存操作就会让进程崩掉;而且那个年代的操作系统没现在稳定,可能破解技术也不够先进,从电脑城里买来的五六块钱的盗版游戏质量参差不齐。很多游戏跑着跑着就闪退,有的甚至连打都打不开,让人甚为恼火。如今源代码在手,并且我也是程序员了,可以对闪退的原因一探究竟,再也不用怕。
不过让人失望的是,用MinGW构建出的程序不会像Linux程序那样在崩溃时吐核。还好这回的闪退是可以必现的,所以就在gdb中运行程序,看看它崩在什么地方。
结果程序如预期崩溃后,调用栈成了下面这个样子:
Program received signal SIGSEGV, Segmentation fault.
0x0ddcf5c0 in ?? ()
(gdb) bt
#0 0x0ddcf5c0 in ?? ()
#1 0xabababab in ?? ()
#2 0x0000abab in ?? ()
#3 0x00000000 in ?? ()
(gdb)
看样子调用栈已经坏掉了。记得以前在老东家遇到过这种损坏的调用栈,但后来很喜感地发现原来是机器的内存坏了。我相信我家电脑还没有到如此风烛残年的地步。
在StackOverflow上搜到一篇帖子:http://stackoverflow.com/questions/9809810/gdb-corrupted-stack-frame-how-to-debug,正好是我想问的问题:如何在gdb中调试这种已经损坏的调用栈。帖子里的答案说这种情况99%是因为调用了非法的函数指针。在32位环境中可以用如下方法恢复调用栈:
(gdb) set $pc = *(void **)$esp
(gdb) set $esp = $esp + 4
可是我检查了一下esp寄存器指向的内存:
(gdb) p $esp
$1 = (void *) 0x22fa34
(gdb) x $esp
0x22fa34: 0x0000007f
(gdb) x/i 0x7f
0x7f: Cannot access memory at address 0x7f
(gdb)
0x7f显然不可能是一个合法的指令地址。看来我落到剩下那1%的区间里了=A=。
把这事情分享到朋友圈里后,主程建议我让程序链接tcmalloc试试,看看能否让程序在应用代码进行非法内存操作时就崩溃,兴许那时调用栈还没损坏。可是试过后情况并无改变。不过这倒是提醒了我,以后不妨让自己的程序都链接tcmalloc,这样可以让很多问题都提前暴露。顺便写下,我的tcmalloc链接选项是-L/local/lib -ltcmalloc_minimal -fno-builtin-malloc -fno-builtin-calloc -fno-builtin-realloc -fno-builtin-free。
无奈,最后还是通过打日志和单步调试的方法,通过应用代码本身的逻辑定位到了具体崩溃位置。原来程序崩在了一个OpenGL接口——glDrawElements的调用。多留日志和熟练掌握项目代码逻辑真是重要啊。
如果是在工作中,我的排查工作一般在这一步也就该结束了。因为我从未接触过OpenGL,所以此时我应该让有OpenGL经验的同事来帮忙处理。不过这回不是在工作而是在玩耍,所以我打算满足下自己用牛刀杀鸡、用导弹打蚊子的癖好,好好研究一番,一是看看能否在gdb中恢复出调用栈,二是研究下glDrawElements这个调用为啥会崩溃,趁机接触下OpenGL。
调用栈的恢复
先看看崩溃的直接原因是什么,看看崩溃时执行的汇编指令是什么:
(gdb) x/i $pc
=> 0xddcf5c0: mov (%esi),%edi
看来esi寄存器里存了一个非法内存地址。
(gdb) p/x $esi
$3 = 0xfeee4
(gdb) p *(void **)$esi
Cannot access memory at address 0xf00d4
(gdb)
果然如此。
再看看这之前还执行了什么指令。
(gdb) x/40i $pc-90
0xc54f7a6: add %al,(%eax)
0xc54f7a8: add %al,(%eax)
0xc54f7aa: add %al,(%eax)
0xc54f7ac: add %al,(%eax)
0xc54f7ae: add %al,(%eax)
0xc54f7b0: add %al,(%eax)
0xc54f7b2: add %al,(%eax)
0xc54f7b4: add %al,(%eax)
0xc54f7b6: add %al,(%eax)
0xc54f7b8: cmp $0xff,%bh
0xc54f7bb: incl 0x550000f7(%eax)
0xc54f7c1: mov %esp,%ebp
0xc54f7c3: push %ebx
0xc54f7c4: push %esi
0xc54f7c5: push %edi
0xc54f7c6: mov 0x8(%ebp),%ebx
0xc54f7c9: mov 0xc(%ebp),%eax
0xc54f7cc: mov 0x14(%ebp),%ebp
0xc54f7cf: mov %ebp,%edi
0xc54f7d1: shl $0x14,%edi
0xc54f7d4: lea 0x40003640(%edi),%esi
0xc54f7da: mov %esi,(%eax)
0xc54f7dc: add $0x4,%eax
0xc54f7df: mov 0x1c(%esp),%edx
0xc54f7e3: lea (%edx,%ebp,2),%ebp
0xc54f7e6: mov %ebp,0x20(%esp)
0xc54f7ea: movzwl (%edx),%ecx
0xc54f7ed: add $0x2,%edx
0xc54f7f0: mov 0xc509890,%esi
0xc54f7f6: mov 0x4(%esi),%esi
0xc54f7f9: mov %ecx,%edi
0xc54f7fb: shl $0x4,%edi
0xc54f7fe: add %edi,%esi
=> 0xc54f800: mov (%esi),%edi
0xc54f802: mov 0x4(%esi),%ebp
0xc54f805: mov %edi,(%eax)
0xc54f807: mov %ebp,0x4(%eax)
0xc54f80a: mov 0x8(%esi),%edi
0xc54f80d: mov %edi,0x8(%eax)
0xc54f810: mov 0xc509890,%esi
(gdb)
看样子在0xc54f7bb附近很可能有一个函数头。函数开头通常由两条汇编指令组成——第一条指令保存当前栈帧的帧底地址,第二条指令将当前的栈顶指为栈帧底,开启新栈帧:
push %ebp
move %esp %ebp
于是从0xc54f7bc开始,一路用x命令检查:
(gdb) x/40i 0xc54f7bc
...
(gdb) x/40i 0xc54f7bd
...
(gdb) x/40i 0xc54f7be
0xc54f7be: add %al,(%eax)
0xc54f7c0: push %ebp
0xc54f7c1: mov %esp,%ebp
0xc54f7c3: push %ebx
0xc54f7c4: push %esi
0xc54f7c5: push %edi
0xc54f7c6: mov 0x8(%ebp),%ebx
0xc54f7c9: mov 0xc(%ebp),%eax
0xc54f7cc: mov 0x14(%ebp),%ebp
0xc54f7cf: mov %ebp,%edi
0xc54f7d1: shl $0x14,%edi
0xc54f7d4: lea 0x40003640(%edi),%esi
0xc54f7da: mov %esi,(%eax)
0xc54f7dc: add $0x4,%eax
0xc54f7df: mov 0x1c(%esp),%edx
0xc54f7e3: lea (%edx,%ebp,2),%ebp
0xc54f7e6: mov %ebp,0x20(%esp)
0xc54f7ea: movzwl (%edx),%ecx
0xc54f7ed: add $0x2,%edx
0xc54f7f0: mov 0xc509890,%esi
0xc54f7f6: mov 0x4(%esi),%esi
0xc54f7f9: mov %ecx,%edi
0xc54f7fb: shl $0x4,%edi
0xc54f7fe: