GDB 自动化操作的技术

lbzu7977 8年前
   <p>程序员在调试时往往分成两派,一派用debugger另一派用print。至于本人嘛,是一个“机会主义者”,有时用print,有时却改投debugger阵营。</p>    <p>实话说,print要比用debugger设下断点更为简单粗暴,有时甚至会更有用。不过debugger对比于print有三个优点:</p>    <ol>     <li> <p>无需重新编译</p> </li>     <li> <p>可以在调试时改变变量</p> </li>     <li> <p>debugger可以实现print做不到的复杂操作</p> </li>    </ol>    <p>在本文,我会介绍一些在gdb中自动化操作的技术,保证可以让你大开眼界,见识下gdb真正的力量。</p>    <h2>会话/历史/命令文件</h2>    <p>通常我们只有在程序出问题才会启动gdb,开始调试工作,调试完毕后退出。不过,让gdb一直开着未尝不是更好的做法。每个gdb老司机都懂得,gdb在 r 的时候会加载当前程序的最新版本。也即是说,就算不退出gdb,每次运行的也会是当前最新的版本。不退出当前调试会话有两个好处:</p>    <ol>     <li> <p>调试上下文可以得到保留。不用每次运行都重新设一轮断点。</p> </li>     <li> <p>一旦core dump了,可以显示core dump的位置,无需带着core重新启动一次。</p> </li>    </ol>    <p>在开发C/C++项目,我一般是这样的工作流程:一个窗口开着编辑器,编译也在这个窗口执行;另一个窗口开着gdb,这个窗口同时也用来运行程序。一旦要调试了(或者,又segment fault了),随手就可以开始干活。</p>    <p>当然了,劳作一天之后,总需要关电脑回家。这时候只能退出gdb。不想明天一早再把断点设上一遍?gdb提供了保留断点的功能。输入 save br .gdb_bp ,gdb会把本次会话的断点存在 .gdb_bp 中。明天早上一回来,启动gdb的时候,加上 -x .gdb_bp ,让gdb把 .gdb_bp 当做命令文件逐条重新执行,一切又回到昨晚。</p>    <h2>condition break/watch/catch</h2>    <p>下面是一个带bug的二分查找实现:</p>    <pre>  <code class="language-cpp">#include <iostream>  using std::cout;  using std::endl;    int binary_search(int *ary, unsigned int ceiling, int target)  {      unsigned int floor = 0;      while (ceiling > floor) {          unsigned int pivot = (ceiling + floor) / 2;          if (ary[pivot] < target)              floor = pivot + 1;          else if (ary[pivot] > target)              ceiling = pivot - 1;          else              return pivot;      }      return -1;  }    int main()  {      int a[] = {1, 2, 4, 5, 6};      cout << binary_search(a, 5, 7) << endl; // -1      cout << binary_search(a, 5, 6) << endl; // 4      cout << binary_search(a, 5, 5) << endl; // 期望3,实际运行结果是-1      return 0;  }</code></pre>    <p>你打算调试下 binary_search(a, 5, 5) 这个组合。若如果用print大法,就在 binary_search 中插入几个print,运行后扫一眼,看看 target=5 的时候运行流是怎样的。</p>    <p>debugger大法看似会复杂一点,如果在 binary_search 中插断点,那么前两次调用只能连按 c 跳过。其实没那么复杂,gdb允许用户设置条件断点。你可以这么设置:</p>    <pre>  <code class="language-cpp">b binary_search if target == 5</code></pre>    <p>现在就只有第三次调用会触发断点。</p>    <p>问题看上去跟 floor 和 ceiling 值的变化有关。要想观察它们的值,可以 p floor 和 p ceiling 。不过有个简单的方法,你可以对它们设置watch断点: wa floor if target == 5 。当 floor 的值变化时,就会触发断点。</p>    <p>对于我们的示例程序来说,靠脑补也能算出这两个值的变化,专门设置断点似乎小题大做。不过在调试真正的程序时,watch断点非常实用,尤其当你对相关代码不熟悉时。使用watch断点可以更好地帮助你理解程序流程,有时甚至会有意外惊喜。另外结合debugger运行时修改值的能力,你可以在值变化的下一刻设置目标值,观察走不同路径会不会出现类似的问题。如果有需要的话,还可以给某个内存地址设断点: wa *0x7fffffffda40 。</p>    <p>除了watch之外,gdb还有一类catch断点,可以用来捕获异常/系统调用/信号。因为用途不大(我从没实际用过),就不介绍了,感兴趣的话在gdb里面 help catch 看看。</p>    <h2>commands/define</h2>    <p>gdb提供名为 commands 的机制,可以给某个断点挂上待触发的命令。举个例子, b binary_search if target == 5 之后,输入:</p>    <pre>  <code class="language-cpp">comm  i locals  i args  end</code></pre>    <p>这样当上面的断点被触发时, i locals 和 i args 命令会被触发,列出当前上下文内的变量。这个功能挺废的,因为你完全可以在断点被触发后才敲入这几个命令。要不是有 define , commands 就真成摆设了。接下来我们要介绍 commands 的好基友、最强大的gdb命令之一, define 命令。</p>    <p>一如unix世界里面的许多程序一样,gdb内部实现了一门DSL(领域特定语言)。用户可以通过这门DSL来编写自定义的宏,甚至编写调试用的自动化脚本。我们可以用 define 命令编写自定义的宏。</p>    <p>继续上面的例子,你可以自定义一个命令代替 b xxx comm ... :</p>    <pre>  <code class="language-cpp">(gdb) define br_info  Type commands for definition of "br_info".  End with a line saying just "end".  >b $arg0  >comm  >i locals  >i args  >end  (gdb) br_info binary_search if target == 5</code></pre>    <p>当 if target == 5 条件满足时, br_info binary_search 会被执行。 br_info 展开成为一系列命令,并用 binary_search 替换掉 $arg0 。一行顶过去五行!</p>    <p>除了在会话内创建自定义宏外,我们还可以用gdb的DSL编写宏文件,并导入到gdb中。</p>    <p>举个有实际意义的例子。由于源代码的改变,我们需要更新断点的位置。通常的做法是删掉原来的断点,并新设一个。让我们现学现用,用宏把这两步合成一步:</p>    <pre>  <code class="language-cpp"># gdb_macro  define mv      if $argc == 2          delete $arg0          # 注意新创建的断点编号和被删除断点的编号不同          break $arg1      else          print "输入参数数目不对,help mv以获得用法"      end  end    # (gdb) help mv 会输出以下帮助文档  document mv  Move breakpoint.  Usage: mv old_breakpoint_num new_breakpoint  Example:      (gdb) mv 1 binary_search -- move breakpoint 1 to `b binary_search`    end  # vi:set ft=gdb ts=4 sw=4 et</code></pre>    <p>使用方法:</p>    <pre>  <code class="language-cpp">(gdb) b binary_search  Breakpoint 1 at 0x40083b: file binary_search.cpp, line 7.  (gdb) source ~/gdb_macro  (gdb) help mv  Move breakpoint.  Usage: mv old_breakpoint_num new_breakpoint  Example:      (gdb) mv 1 binary_search -- move breakpoint 1 to `b binary_search`    (gdb) mv 1 binary_search.cpp:18  Breakpoint 2 at 0x4008ab: file binary_search.cpp, line 18.</code></pre>    <p>还可以进一步,把 source ~/gdb_macro 也省掉。你可以创建gdb配置文件 ~/.gdbinit ,让gdb启动时自动执行里面的指令。如果把自己常用的宏写在该文件中,就能直接在gdb里面使用了,用起来如内置命令一般顺滑。</p>    <h2>调试脚本</h2>    <p>在第一节 会话/历史/命令文件 结尾,我提到用 -x 指定命令文件来回放断点。那时的命令文件也算是一种用gdb的DSL编写的调试脚本。由于调试是件交互性的活,需要事先写好调试脚本的场景不多。即使如此,除了让gdb自动设置断点,依然有不少场景下可以用上调试脚本。其中之一,就是让gdb自动采集特定函数调用的上下文数据。我把这种方法称为“拖网法”,因为它就像拖网捕鱼一样,把逮到的东西都一股脑带上来。</p>    <p>设想如下的情景:某个项目出现内存泄露的迹象。事先分配好的内存池用着用着就满了,一再地吞噬系统的内存。内存管理是自己实现的,所以无法用valgrind来分析。鉴于内存管理部分代码最近几个版本都没有改动过,猜测是业务逻辑代码里面有谁借了内存又不还。现在你需要把它揪出来。一个办法是给内存的分配和释放加上日志,再编译,然后重新运行程序,谋求复现内存泄露的场景。不过更快的办法是,敲上这一段代码:</p>    <p>(假设分配内存的接口是 my_malloc(char *p, size_t size) ,释放内存的接口是 free(char *p) )</p>    <pre>  <code class="language-cpp"># /tmp/malloc_free  # 设置输出不要分屏  set pagination off  b my_malloc  comm  silent  printf "malloc 0x%x %lu\n", p, size  bt  c  end    b my_free  comm  silent  printf "free 0x%x\n", p  bt  c  end  c</code></pre>    <p>直接让gdb执行它:</p>    <pre>  <code class="language-cpp">sudo gdb -q -p $(pidof $your_project) -x /tmp/malloc_free > log</code></pre>    <p>运行一段时间后kill掉gdb,打开log看看里面的内容:</p>    <pre>  <code class="language-cpp">$ less log  Attaching to process 8738  Reading symbols from ...done.  Reading symbols from /lib/x86_64-linux-gnu/libc.so.6...Reading symbols from /usr/  lib/debug//lib/x86_64-linux-gnu/libc-2.19.so...done.  done.  Loaded symbols for /lib/x86_64-linux-gnu/libc.so.6  ......  malloc 0x0 82  #0  my_malloc (p=0x0, size=82) at memory.cpp:8  #1  0x0000000000400657 in write_buffer (p=0x0, size=82) at memory.cpp:17  #2  0x00000000004006b6 in main () at memory.cpp:25  malloc 0x852c39c0 13  #0  my_malloc (p=0x7ffd852c39c0 "\001", size=13) at memory.cpp:8  #1  0x0000000000400657 in write_buffer (p=0x7ffd852c39c0 "\001", size=13) at memory.cpp:17  #2  0x00000000004006b6 in main () at memory.cpp:25  free 0x400780  #0  my_free (p=0x400780 <__libc_csu_init> "AWA\211\377AVI\211\366AUI\211\325ATL\215%x\006 ") at memory.cpp:14  #1  0x0000000000400632 in read_buffer (p=0x400780 <__libc_csu_init> "AWA\211\377AVI\211\366AUI\211\325ATL\215%x\006 ") at memory.cpp:16  #2  0x00000000004006fe in main () at memory.cpp:28  free 0x0  ......</code></pre>    <p>现在我们可以写个脚本对下帐。每次解析到 malloc 时,在对应指针的名下记下一项借出。解析到 free 时,表示销掉对应最近一次借出的还款。把全部输出解析完后,困扰已久的坏账情况就将水落石出,欠钱不还的老赖也将无可遁形。这种“拖网法”真的是简单粗暴又有效。</p>    <p>我们还可以用这种“拖网法”获取指定函数的调用者比例、调用参数的分布范围等等。注意,不要在生产环境撒网,毕竟这么做对性能有显著影响。而且要做统计的话,也有更好的方法可以选。</p>    <h2>用python拓展gdb</h2>    <p>除了用gdb自身的DSL,我们还可以使用python来给gdb写脚本。凭借python的力量,我们甚至可以在gdb里跟外部程序交互,展示更多的可能性。“你们对力量一无所知”。</p>    <p>欲知后事如何,请听下回分解。</p>    <p> </p>    <p>来自: <a href="/misc/goto?guid=4959673872854962517" rel="nofollow">https://segmentfault.com/a/1190000005367875</a></p>    <p> </p>