练习31:代码调试

原文:Exercise 31: Debugging Code

译者:飞龙

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

调试输出、GDB或Valgrind

我主要按照一种“科学方法”的方式来调试,我会提出可能的所有原因,之后排除它们或证明它们导致了缺陷。许多程序员拥有的问题是它们对解决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,因为你需要将假写为打印语句。

使用 GDB

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

#include <unistd.h>

int main(int argc, char *argv[])
{
    int i = 0;

    while(i < 100) {
        usleep(3000);
    }

    return 0;
}

像往常一样编译,并且在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

将变量X设置为Y

print X

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

ENTER

重复上一条命令。

quit

退出gdb

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

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

附加到进程

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

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

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

$ ps ax | grep ex31
10026 s000  S+     0:00.11 ./ex31
10036 s001  R+     0:00.00 grep ex31

$ gdb ./ex31 10026
GNU gdb 6.3.50-20050815 (Apple version gdb-1705) (Fri Jul  1 10:50:06 UTC 2011)
Copyright 2004 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public License, and you are
welcome to change it and/or distribute copies of it under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB.  Type "show warranty" for details.
This GDB was configured as "x86_64-apple-darwin"...Reading symbols for shared libraries .. done

/Users/zedshaw/projects/books/learn-c-the-hard-way/code/10026: No such file or directory
Attaching to program: `/Users/zedshaw/projects/books/learn-c-the-hard-way/code/ex31', process 10026.
Reading symbols for shared libraries + done
Reading symbols for shared libraries ++........................ done
Reading symbols for shared libraries + done
0x00007fff862c9e42 in __semwait_signal ()

(gdb) break 8
Breakpoint 1 at 0x107babf14: file ex31.c, line 8.

(gdb) break ex31.c:11
Breakpoint 2 at 0x107babf1c: file ex31.c, line 12.

(gdb) cont
Continuing.

Breakpoint 1, main (argc=1, argv=0x7fff677aabd8) at ex31.c:8
8      while(i < 100) {

(gdb) p i
$1 = 0

(gdb) cont
Continuing.

Breakpoint 1, main (argc=1, argv=0x7fff677aabd8) at ex31.c:8
8      while(i < 100) {

(gdb) p i
$2 = 0

(gdb) list
3  
4  int main(int argc, char *argv[])
5  {
6      int i = 0;
7  
8      while(i < 100) {
9          usleep(3000);
10     }
11
12     return 0;

(gdb) set var i = 200

(gdb) p i
$3 = 200

(gdb) next

Breakpoint 2, main (argc=1, argv=0x7fff677aabd8) at ex31.c:12
12     return 0;

(gdb) cont
Continuing.

Program exited normally.
(gdb) quit
$

在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打印出我需要了解的变量(argcargv),以及停下来的位置,之后打印出断点的行号。

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的一些小技巧:

gdb --args

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

thread apply all bt

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

gdb --batch --ex r --ex bt --ex q --args

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

?

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

附加题

  • 找到一个图形化的调试器,将它与原始的gdb相比。它们在本地调试程序时非常有用,但是对于在服务器上调试没有任何意义。
  • 你可以开启OS上的“核心转储”,当程序崩溃时你会得到一个核心文件。这个核心文件就像是对程序的解剖,便于你了解崩溃时发生了什么,以及由什么原因导致。修改ex31.c使它在几个迭代之后崩溃,之后尝试得到它的核心转储并分析。

书籍推荐