练习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
循环并使它退出。
$ 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.
There is absolutely no warranty for GDB. Type "show warranty" for details.
/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) {
$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.”的错误。这种情况下,你需要停止gdb
和ex31
程序,并重新启动程序使它工作,只要你成功输入了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
运行程序,当它崩溃时你会得到执行轨迹。
?
如果你有其它技巧,在评论中写下它吧。
附加题
- 找到一个图形化的调试器,将它与原始的相比。它们在本地调试程序时非常有用,但是对于在服务器上调试没有任何意义。