贡献者: addis
对于 Matlab 或 Python 这样的动态语言,由于它们的解释器(interpreter)执行代码的方式可以大致看作是从代码中读一行执行一行,所以对它们的来说,调试(debug)是一件很自然的事情,当某个地方出错了,解释器会告诉你出错的原因,代码的位置,以及函数调用的顺序(也就是说如果错误出现在某个函数中,解释器就会告诉你例如程序的第几行调用了 A 函数,A 函数中第几行调用了 B 函数,B 函数的第几行哪里出错了)。然而对于编译语言,由于在编译过程中代码被转换成二进制命令,程序在执行二进制命令时如果没有额外的调试信息,则没有办法对应到代码的相应位置。
而使调试更难的是,表面上代码出错的位置并不是最开始出错的位置,或者程序根本不会在运行时检测到错误,但输出了错误的结果。这就需要从发现错误的位置逆向顺藤摸瓜找到错误的根源。而这么做就需要确定并检查代码中一连串的位置。例如一个函数中的某个错误可能是被调用的时候被传递了错误的参数,这时就要去检查调用它的函数,以此类推。
无论使用什么编程语言,调试程序通常有两种方法:一种叫做 print debug,就是手动在代码中插入许多额外的输出命令,运行的过程中在屏幕上(或文件中)不断输出信息,如果哪里出错了,我们就可以根据最后输出的信息对应到出错代码的位置。第二种方法是使用某种调试器(debugger),例如下面要介绍的 GNU Debugger,简称 gdb。
下面我们来简单讨论 C++ 这样的静态语言中两种调试方法的优势和劣势。print debug 的劣势显而易见,如果你的代码本来没有任何输出,那么程序运行过程中崩溃或者结果错误的时候你将完全不知道发生了什么,只能先在代码的一些主要部分插入一些输出命令,重新编译,复现错误,把出错的位置缩小一些,然后再在这个小范围进一步插入更详细的输出命令,重新编译,复现错误,直到找到最开始的原因。更糟糕的是,如果一个函数被层层调用,即使你知道这个函数中出错了,也可能不知道是谁调用它的时候出错的。如果这个函数在循环中被调用,那输出的调试信息可能会多得难以阅读。除了输出命令,也可以插入一些额外的检查命令,例如检查数组的索引是否超出了数组的长度,某些值是否不在某个范围内,如果不符合就输出错误信息并终止程序。最后,当 bug 被修复以后,我们往往还需要手动删除用于 debug 的输出代码。
如果使用调试器,你不需要修改程序就能调试程序。调试器通常是和编译器配套的,例如 gcc(C 语言)或者 g++(C++)的编译器对应的调试器都是 gdb。编译器在编译程序的时候如果使用调试选项(-g
),那么它们在编译时就会在生成的可执行文件中插入一些调试信息,例如每个命令对应到代码的哪一行,每个变量的名称是什么,等。编译完以后,我们并不直接运行程序,而是将其放到调试器中运行,这样如果出错了调试器就可以根据编译时插入的额外信息显示出错的行号,以及函数调用顺序。调试器甚至还可以像动态语言一样,逐行运行程序,或者给某行添加断点(breakpoint),当程序运行到断点处或者触发特定的条件时就会暂停(就像Matlab 的程序调试中介绍的那样)。当程序在某行被暂停时,我们还能直接检查每个变量当前的值(同样无须修改程序)。
但调试器也有一些弊端。在编译器正常编译程序时往往会进行一些优化使程序运行得更快,而这些优化使得二进制文件中的命令并不完全和程序代码一一对应。例如编译器可能会把某个中间变量 “优化掉”,这时如果进行调试,就无法查看这个变量的值。所以当我们调试程序时,往往不希望编译器进行任何优化(例如 -O1
到 -O3
等选项)。不优化会导致程序运行变慢,但如果程序本身运行的时间不长,你可能感觉不到太大区别。另一个弊端是,学习和掌握调试器的各种命令(或者 GUI 界面)需要一定的时间。这两个缺点都可以使用 print debug 避免。
在实践中我们往往会根据情况选择其中一种调试方法或者两种混用。通常要写好一个程序,我们花在调试和修改上的时间往往比写程序本身要多许多,要更高效地调试,通常只能从实践中慢慢积累经验,用更少的步骤更多的推理得出问题根源所在。