练习31:代码调试

    我已经教给你一些关于我的强大的调试宏的技巧,并且你已经开始用它们了。当我调试代码时,我使用宏,分析发生了什么以及跟踪问题。在这个练习中我打算教给你一些使用gdb的技巧,用于监视一个不会退出的简单程序。你会学到如何使用gdb附加到运行中的进程,并挂起它来观察发生了什么。在此之后我会给你一些用于gdb的小提示和小技巧。

    我主要按照一种“科学方法”的方式来调试,我会提出可能的所有原因,之后排除它们或证明它们导致了缺陷。许多程序员拥有的问题是它们对解决bug的恐慌和急躁使他们觉得这种方法会“拖慢”他们。它们并没有注意到,它们已经失败了,并且在收集无用的信息。我发现日志(调试输出)会强迫我科学地解决bug,并且在更多情况下易于收集信息。

    此外,使用调试输出来作为我的首要调试工具的理由如下:

    • 你可以使用变量的调试输出,来看到程序执行的整个轨迹,它让你跟踪变量是如何产生错误的。使用gdb的话,你必须为每个变量放置查看和调试语句,并且难以获得执行的实际轨迹。
    • 调试输出存在于代码中,当你需要它们是你可以重新编译使它们回来。使用gdb的话,你每次调试都需要重新配置相同的信息。
    • 当服务器工作不正常时,它的调试日志功能易于打开,并且在它运行中可以监视日志来查看哪里不对。系统管理员知道如何处理日志,他们不知道如何使用gdb。
    • 打印信息更加容易。调试器通常由于它奇特的UI和前后矛盾显得难用且古怪。debug("Yo, dis right? %d", my_stuff);就没有那么麻烦。
    • 编写调试输出来发现缺陷,强迫你实际分析代码,并且使用科学方法。你可以认为它是,“我假设这里的代码是错误的”,你可以运行它来验证你的假设,如果这里没有错误那么你可以移动到其它地方。这看起来需要更长时间,但是实际上更快,因为你经历了“鉴别诊断”的过程,并排除所有可能的原因,直到你找到它。
    • 调试输入更适于和单元测试一起运行。你可以实际上总是编译调试语句,单元测试时可以随时查看日志。如果你用gdb,你需要在gdb中重复运行单元测试,并跟踪他来查看发生了什么。
    • 使用Valgrind可以得到和调试输出等价的内存相关的错误,所以你并不需要使用类似gdb的东西来寻找缺陷。

    尽管所有原因显示我更倾向于debug而不是gdb,我还是在少数情况下回用到gdb,并且我认为你应该选择有助于你完成工作的工具。有时,你只能够连接到一个崩溃的程序并且四处转悠。或者,你得到了一个会崩溃的服务器,你只能够获得一些核心文件来一探究竟。这些货少数其它情况中,gdb是很好的办法。你最好准备尽可能多的工具来解决问题。

    接下来我会通过对比gdb、调试输出和Valgrind来详细分析,像这样:

    • Valgrind用于捕获所有内存错误。如果Valgrind中含有错误或Valgrind会严重拖慢程序,我会使用gdb。
    • 调试输出用于诊断或修复有关逻辑或使用上的缺陷。在你使用Valgrind之前,这些共计90%的缺陷。
    • 使用gdb解决剩下的“谜之bug”,或如要收集信息的紧急情况。如果Valgrind不起作用,并且我不能打印出所需信息,我就会使用gdb开始四处搜索。这里我仅仅使用gdb来收集信息。一旦我弄清发生了什么,我会回来编程单元测试来引发缺陷,之后编程打印语句来查找原因。

    调试策略

    这一过程适用于你打算使用任何调试技巧,无论是Valgrind、调试输出,或者使用调试器。我打算以使用gdb的形式来描述他,因为似乎人们在使用调试器是会跳过它。但是应当对每个bug使用它,直到你只需要在非常困难的bug上用到。

    • 创建一个小型文本文件叫做notes.txt,并且将它用作记录想法、bug和问题的“实验记录”。
    • 在你使用gdb之前,写下你打算修复的bug,以及可能的产生原因。
    • 对于每个原因,写下你所认为的,问题来源的函数或文件,或者仅仅写下你不知道。
    • 现在启动gdb并且使用file:function挑选最可能的因素,之后在那里设置断点。
    • 使用gdb运行程序,并且确认它是否是真正原因。查明它的最好方式就是看看你是否可以使用set命令,简单修复问题或者重现错误。
    • 如果它不是真正原因,则在notes.txt中标记它不是,以及理由。移到下一个可能的原因,并且使最易于调试的,之后记录你收集到的信息。

    这里你并没有注意到,它是最基本的科学方法。你写下一些假设,之后调试来证明或证伪它们。这让你洞察到更多可能的因素,最终使你找到他。这个过程有助于你避免重复步入同一个可能的因素,即使你发现它们并不可能。

    你也可以使用调试输出来执行这个过程。唯一的不同就是你实际在源码中编写假设来推测问题所在,而不是notes.txt中。某种程度上,调试输出强制你科学地解决bug,因为你需要将假写为打印语句。

    我将在这个练习中调试下面这个程序,它只有一个不会正常终止的while循环。我在里面放置了一个usleep调用,使它循环起来更加有趣。

    像往常一样编译,并且在gdb下启动它,例如:gdb ./ex31

    一旦它运行之后,我打算让你使用这些gdb命令和它交互,并且观察它们的作用以及如何使用它们。

    help COMMAND

    获得COMMAND的简单帮助。

    break file.c:(line|function)

    在你希望暂停之星的地方设置断点。你可以提供行号或者函数名称,来在文件中的那个地方暂停。

    run ARGS

    运行程序,使用ARGS作为命令行参数。

    cont

    继续执行程序,直到断点或错误。

    step

    单步执行代码,但是会进入函数内部。使用它来跟踪函数内部,来观察它做了什么。

    next

    就像是step,但是他会运行函数并步过它们。

    backtrace (or bt)

    执行“跟踪回溯”,它会转储函数到当前执行点的执行轨迹。对于查明如何执行到这里非常有用,因为它也打印出传给每个函数的参数。它和Valgrind报告内存错误的方式很接近。

    set var X = Y

    print X

    打印出X的值,你通常可以使用C的语法来访问指针的值或者结构体的内容。

    ENTER

    重复上一条命令。

    quit

    退出gdb

    这些都是我使用gdb时的主要命令。你现在的任务是玩转它们和ex31,你会对它的输出更加熟悉。

    一旦你熟悉了gdb之后,你会希望多加使用它。尝试在更复杂的程序,例如devpkg上使用它,来观察你是否能够改函数的执行或分析出程序在做什么。

    附加到进程

    gdb最实用的功能就是附加到运行中的程序,并且就地调试它的能力。当你拥有一个崩溃的服务器或GUI程序,你通常不需要像之前那样在gdb下运行它。而是可以直接启动它,希望它不要马上崩溃,之后附加到它并设置断点。练习的这一部分中我会向你展示怎么做。

    当你退出gdb之后,如果你停止了ex31我希望你重启它,之后开启另一个中断窗口以便于启动gdb并附加。进程附加就是你让gdb连接到已经运行的程序,以便于你实时监测它。它会挂起程序来让你单步执行,当你执行完之后程序会像往常一样恢复运行。

    下面是一段会话,我对ex31做了上述事情,单步执行它,之后修改while循环并使它退出。

    1. $ ps ax | grep ex31
    2. 10026 s000 S+ 0:00.11 ./ex31
    3. 10036 s001 R+ 0:00.00 grep ex31
    4. $ gdb ./ex31 10026
    5. GNU gdb 6.3.50-20050815 (Apple version gdb-1705) (Fri Jul 1 10:50:06 UTC 2011)
    6. Copyright 2004 Free Software Foundation, Inc.
    7. GDB is free software, covered by the GNU General Public License, and you are
    8. welcome to change it and/or distribute copies of it under certain conditions.
    9. There is absolutely no warranty for GDB. Type "show warranty" for details.
    10. /Users/zedshaw/projects/books/learn-c-the-hard-way/code/10026: No such file or directory
    11. Attaching to program: `/Users/zedshaw/projects/books/learn-c-the-hard-way/code/ex31', process 10026.
    12. Reading symbols for shared libraries + done
    13. Reading symbols for shared libraries ++........................ done
    14. Reading symbols for shared libraries + done
    15. 0x00007fff862c9e42 in __semwait_signal ()
    16. (gdb) break 8
    17. Breakpoint 1 at 0x107babf14: file ex31.c, line 8.
    18. (gdb) break ex31.c:11
    19. Breakpoint 2 at 0x107babf1c: file ex31.c, line 12.
    20. (gdb) cont
    21. Continuing.
    22. Breakpoint 1, main (argc=1, argv=0x7fff677aabd8) at ex31.c:8
    23. 8 while(i < 100) {
    24. (gdb) p i
    25. $1 = 0
    26. (gdb) cont
    27. Continuing.
    28. Breakpoint 1, main (argc=1, argv=0x7fff677aabd8) at ex31.c:8
    29. 8 while(i < 100) {
    30. $2 = 0
    31. (gdb) list
    32. 3
    33. 4 int main(int argc, char *argv[])
    34. 5 {
    35. 6 int i = 0;
    36. 7
    37. 8 while(i < 100) {
    38. 9 usleep(3000);
    39. 10 }
    40. 11
    41. 12 return 0;
    42. (gdb) set var i = 200
    43. (gdb) p i
    44. $3 = 200
    45. (gdb) next
    46. Breakpoint 2, main (argc=1, argv=0x7fff677aabd8) at ex31.c:12
    47. 12 return 0;
    48. (gdb) cont
    49. Continuing.
    50. Program exited normally.
    51. (gdb) quit
    52. $

    在OSX上你可能会看到输入root密码的GUI输入框,并且即使你输入了密码还是会得到来自gdb的“Unable to access task for process-id XXX: (os/kern) failure.”的错误。这种情况下,你需要停止gdbex31程序,并重新启动程序使它工作,只要你成功输入了root密码。

    我会遍历整个会话,并且解释我做了什么:

    gdb:1

    使用ps来寻找我想要附加的ex31的进程ID。

    gdb:5

    我使用gdb ./ex31 PID来附加到进程,其中PID替换为我所拥有的进程ID。

    gdb:6-19

    gdb打印出了一堆关于协议的信息,接着它读取了所有东西。

    gdb:21

    程序被附加,并且在当前执行点上停止。所以现在我在文件中的第8行使用break设置了断点。我假设我这么做的时候,已经在这个我想中断的文件中了。

    gdb:24

    执行break的更好方式,是提供file.c line的格式,便于你确保定位到了正确的地方。我在这个break中这样做。

    gdb:27

    我使用cont来继续运行,直到我命中了断点。

    gdb:30-31

    gdb:33-34

    我使用print的缩写p来打印出i变量的值,它是0。

    gdb:36

    继续运行来查看i是否改变。

    gdb:42

    再次打印出i,显然它没有变化。

    gdb:45-55

    使用list来查看代码是什么,之后我意识到它不可能退出,因为我没有自增i

    gdb:57

    确认我的假设是正确的,即i需要使用set命令来修改为i = 200。这是gdb最优秀的特性之一,让你“修改”程序来让你快速知道你是否正确。

    gdb:59

    打印i来确保它已改变。

    gdb:62

    使用next来移到下一段代码,并且我发现命中了ex31.c:12的断点,所以这意味着while循环已退出。我的假设正确,我需要修改i

    gdb:67

    使用cont来继续运行,程序像往常一样退出。

    gdb:71

    最后我使用quit来退出gdb

    下面是你可以用于GDB的一些小技巧:

    gdb —args

    通常gdb获得你提供的变量并假设它们用于它自己。使用--args来向程序传递它们。

    thread apply all bt

    转储所有线程的执行轨迹,非常有用。

    gdb —batch —ex r —ex bt —ex q —args

    运行程序,当它崩溃时你会得到执行轨迹。

    ?

    如果你有其它技巧,在评论中写下它吧。

    附加题

    • 找到一个图形化的调试器,将它与原始的相比。它们在本地调试程序时非常有用,但是对于在服务器上调试没有任何意义。