关键要点
- 有效的调试取决于正确的策略、方法、实践、工具和技巧。
- 找到 bug,并且能够通过详细的日志记录、故障报告、防御性编程和专业工具重现它。
- 修复一个故障之后,能够发现和修复类似的问题并采取一些措施确保他们以后不再发生。
- 给同事详解一个失败的代码片段以帮助你发现其中的问题。
- 通过静态和动态程序分析工具精确定位难以捉摸的 bug。
Diomidis Spinellis 撰写的《有效的调试》一书描述了66 种不同的方法来有效地调试应用和系统。其中提供了定位与解决错误的方法、策略、技巧和工具,同时也给出了在不同环境下应用他们的案例。
InfoQ 的读者可以下载有效的调试的摘要。
InfoQ 就调试软件的不同方法采访了 Spinellis,讨论了如何使用不同的编译器和执行平台来帮助调试软件与重现错误;如何在发现和解决一个错误后同时解决相同的错误;为什么使用图形用户界面来调试更好;如何寻求同事的帮助;如果你想要找到错误的代码,使用动态分析工具和静态程序分析工具来调试代码;程序员如何提升自己调试的技巧。
InfoQ:是什么促使你写了这本关于调试的书?
Diomidis Spinellis:我热爱编码是因为它挑战我的思维。几年前,我意识到调试是一个更为艰难和有趣的任务。编码主要工作是将需求翻译为程序语句,而调试涉及许多不同的创造性方法。我还发现,与阅读代码相比,很少有人教导或写作调试相关的东西。
从那时开始,每当我调试一些东西的时候,我就观察自己,并记录下我使用的方法。我对于在清单中记录下如此多的方法感到很惊讶,列表清单越来越长。因此,我意识到,如果我将列表编撰成册,很多人会受益。有效编程系列完美地匹配了我已经放在一起的材料,很荣幸我的建议被接受为该系列中的一个项目。
InfoQ:这本书的目标读者是那些人?
Diomidis Spinellis:我这本书的目标读者为中级和高级程序员:他们知道如何写代码,但还没有认真正式培训过如何调试自己的代码。如今代码往往最终为一个正在运行的服务(或应用),而不是执行一些任务然后就终止的程序。以至于,我这本书的目标读者也包括开发人员和每天要面对时不时的调试任务的系统管理员。
书中的一些条目,如使用断点或调试程序的堆栈遍历命令,是相当基础的知识。我在这本书中囊括这些内容是因为我发现,程序员经常采用的调试方法与他们所使用的平台有关。因此,即使一些高级程序员也可能不熟悉其他人经常使用的调试方法。
InfoQ:此书中描述了哪些调试软件的不同种类的方法?
Diomidis Spinellis:在最开始的时候,这本书包含了三章内容,囊括了适用于大多数调试任务的条目。也包含高级别策略,涉及到许多其他方法来解决特定问题,例如逐步缩小良好工作的系统和一个失败系统之间的差异。当你调试问题的时候,通用的方法和做法帮助你表现得更好。这个类别中的一个重要的做法是自动化你的测试场景和任务。这样做不仅提高了你的工作效率,同时会常常为你创造出设计更为复杂的调试方案的机会。接着,你可以将通用工具和技术应用在不同的调试任务中。这些措施包括有效地利用 Unix 命令行工具、编辑器、版本控制系统和追踪工具。
接下来的四个章节叙述了针对特定平台的方法:利用一个调试器,如何通过调整程序的代码来逐步逼近 bug,以及可以使用哪些方法来构建和运行你的系统。虽然这些方法的具体细节取决于你的平台,但大部分平台都会提供工具,如能够帮你找出含有错误的具体类的静态程序分析器和剖析工具。
InfoQ:如何使用不同的编译器和执行平台帮助调试软件?
Diomidis Spinellis:汽车制造商在所有条件下测试新车型:从北极苔原到撒哈拉大沙漠。这有助于他们发现 bug,这是无法通过开着汽车围绕汽车厂的停车场转圈来找到的。类似的想法适用于软件。现代编程语言和 API 是庞大而复杂的 ; 不同的编译器和运行平台会以不同的方式处理代码。例如,一个编译器可能会愉快地接受导致你的程序有不确定表现方式的代码,另一个遇到相同的代码将会发出警告。这同样适用于 API 的实现:当参数错误的时候一个实现方式可以产生令人困惑的行为,而另一个可以立即报出有用的异常。甚至 CPU 的多样性也能帮助你:在一些 ARM CPUs 中的奇存储地址访问双字节值会产生一些故障,而另一些可能会导致非原子行为。
一个有趣的例子发生在上世纪 80 年代,Linux 运行在当时流行的 VAX 架构,它有一种特性是访问空指针时会返回 0。当 Sun 公司改变了系统中的那种写法后,产生了一个异常,随之一系列的 bug 浮出了水面。
InfoQ:你能做些什么来重现错误?
Diomidis Spinellis:一旦你能够准确高效地重现一个错误,你就已经完成了修复这个 bug 所需的一半的工作量。这里有两个主要的方法。其中包括从一个导致失败的复杂场景开始,逐步简化它。另一种方法是,猜测导致失败的相关原因并构建一个场景重现它。这两种方法都可以推导出一个能够引起该故障的微小简单的测试用例。详细的日志记录、故障报告和防御性编程往往能为你的行动提供引导。
在手头一个小的测试用例的情况下,可以大幅削减调试问题所需的工作。你一步通过数十行代码而不是数百行,你检查满屏的日志数据而不是自动过滤数据流,你放大仔细观察几个关键变量而不是观察程序的全局状态,你可以在数秒钟之内运行这个测试用例而不需要花费数个小时。
我最近在调试一些处理接近 1TB 大小文件数据的程序所产生的错误,这些程序运行可能需要数天。为了解决这些问题,我首先为处理程序添加了许多可以生成详细日志的调试选项。我还添加了许多内部一致性检查和相应的报告。因而我能够将问题相关的数据缩小到 1GB,处理不超过 1 分钟就能够产生错误。然后,我写了一个小过滤器,将可能导致失败的记录从庞大的数据中隔离出来。进一步将可疑数据减少到几 KB 大小,然后我可以手动检查,以查明问题所在。为了确保我的解决方案是正确的,并会在未来保持如此,我还添加了一个测试用例模拟错误并验证程序能够正确处理它。
最后我要说的是,当遇到一些难以重现的 bug 的时候,我要介绍一些专门的工具和方法。这些方法包括验尸调试(使用崩溃的程序的内存映像调试你的代码)、捕获和复制工具(他们对工作在多线程代码的非确定性的 bug 有神奇的效果)和后台实时调试(当你跨过一个很少失败的函数调用是非常有效的)。
InfoQ:解决错误之后,如何才能找到和修复其他类似的错误?
Diomidis Spinellis:这个问题非常好。相同的故障通常会发生在一个以上的地方。造成这种现象通常是因为代码随意地复制粘贴,或者因为一个 API 很容易被滥用了。一旦发现故障,了解它为什么发生,发现和解决类似的问题,并确保其他类似的问题不会在未来出现,这是职业精神的标志。
通常,你可以通过使用能够匹配任何可以代码的正则表达式搜索并找出相似的错误。你可以用你喜欢的编辑器或 IDE 做到这一点,但我更喜欢使用 Unix 的命令行工具 grep。通过在管道中指定能够匹配那些错误的模式并上报忽略的模式(就是我传递给 grep -v 命令实例的东西),我可以很容易地缩小我的搜索范围。从而修复这些问题,这节省了我和我的同事们的时间,使得我们可以调试更多潜在的错误。
确保类似的错误不会在未来发生是一件更具挑战性的事情。如果故障是与 API 函数的误用有关,一招是创建一个包装函数,用它来捕获和报告测试过程中的错误。你还可以查看是否可以在持续集成的过程中配置一个代码分析工具捕获这样的错误。
InfoQ:为什么使用图形用户界面来调试更好?
Diomidis Spinellis:虽然我喜欢命令行界面,同时当我使用它们时我觉得自己非常有生产力,但我依然认为调试工作是少数几个使用 GUI 会更有效率的工作之一。这样认为的原因是,同时呈现多种数据对调试工作很有帮助:源代码、局部变量、调用栈、日志消息甚至 CPU 寄存器。图形界面让你对所有这些显示在独立窗口的数据一目了然,并能够同时更新。另外,你的鼠标指向一个变量、一行代码或者调用堆栈帧通常比手打指令更加高效。
有时候,我发现自己正在调试一个系统,缺少一个 GUI 调试器。我就会通过配置我的命令行窗口和编辑器窗口创造出一个简陋版本的 GUI 调试器。一个窗口可能包含相关的源代码,一个列出测试数据,另外一个显示持续更新的日志文件,还有一个可以提供一个命令行提示符。
InfoQ:如果你想要发现代码中的错误,如何寻求同事的帮助?
Diomidis Spinellis:最有效的方法可能就是橡皮鸭技术。它涉及到向其他人解释你的代码是如何运行的。通常情况下,在你解释的中途,你就会惊呼“哦,等等,我怎么这么傻的,这就是问题所在!”,问题解决。发生这种情况时请放心,这不是一个你粗心大意忽略了的愚蠢的错误。通过向你的同事解释代码,你调动到了大脑的不同部分,而这些部分精确定位到了问题。在大多数情况下,你的同事扮演了一个很小的角色。这就是该技术名字的由来:向橡皮鸭解释问题会有同等的效果。
当然还有其他更正式的方式让同事帮助你捕捉错误,如结对编程和代码审查。在某些情况下,你甚至可以搞角色扮演。例如,如果你正在调试一个通信协议,可以扮演一方,一位同事扮演另一方,然后你可以轮流试图打破协议(或尝试使其工作) 。这样的方式对于其他领域包括安全性(你可以玩 Alice 和 Bob)、人机交互和工作流同样有效。在这里向周围传递物体,如一个“编辑”令牌就可以帮助你。
InfoQ:你对于使用静态程序分析工具有什么建议?
Diomidis Spinellis:使用静态程序分析工具分析你的程序可以发现许多类型的问题,从格式化故障(想想 Python 代码中的 Pylint)到可能会导致程序崩溃或行为异常的代码结构(Coverity 和 FindBugs 是这个领域中两个著名的工具)。此类别中的另一个被低估的工具是你的编译器或解释器。几个命令行选项(例如许多编译器的 -Wall、-Wextra 和 -Wshadow)或声明(例如 JavaScript 代码中的 “use strict”)可以触发许多有用的警告消息。
如果你在这个领域是新手,我的建议是先配置能够产生与实践中一致的产生警告的工具。例如,如果由于某种原因,你已经有很多仅有首字母大小写不同的方法,你可能会禁用 FindBugs 的“混乱的方法名”的警告。如果警告级别可以调节,选择不会让你淹没在警告中的最高级别的警告水平。然后有条不紊地移除所有其他的警告。这可能会解决你要找的问题,也更容易看到未来的其他故障。最后,确保你的构建和持续集成进程中运行了静态程序分析工具,这样你的代码就不会在将来出现同样的问题。
InfoQ:如何使用动态分析工具来调试代码?
Diomidis Spinellis:动态程序分析工具能够提供有关你的代码的终极真理,因为他们在程序运行时进行分析。你通常在这样的工具下运行你的程序,并查看它产生的报告。例如,Valgrind 可以发现内存泄露、非法内存访问以及使用未初始化的内存。执行如内置到现代 IDE 或者成为独立工具(如 VisualVM、JProfiler 和 Java Mission Control)的 profiler 能帮助你定位到性能黑洞。其它工具,如 Intel Inspector,能让你发现死锁和竞争条件。
一个常常被忽略的动态分析工具类型是执行追踪器。他们会显示程序与操作系统交互或运行时系统的库的所有信息。这类工具包括 ltrace、strace、ktrace、truss 和各种 Unix 系统中的 SystemTap 以及 Windows 中的进程监视器。这些工具的优点是,你可以在不需要源码的情况下在一个可执行程序中应用他们。他们通过揭示一个特定的程序是如何运行失败的而无数次帮了我的大忙。
InfoQ:程序员可以做些什么来提升他们的调试技巧?
Diomidis Spinellis:飞行员经常以飞行小时数来报告他们的经验和熟练程度。调试(和编程)技巧还取决于你对这些活动花费了多少时间。然后,铭记调试不是向错误随机扔飞镖可以使你的调试技术和效率有长足的进步。这包括细心谨慎选择最好的方法来解决这个问题、在能使你有生产力的环境中持续投入、配置和运用特殊的工具和学习并深刻理解你在代码中使用的语言特性和 APIs。
关于本文作者
Diomidis Spinellis 是雅典经济和商业大学的科学与技术管理系的一位教授。他写过多本广受赞誉并被广泛翻译的书:“阅读代码”、“代码质量:开源透视”与 2016 年发布的“有效的调试”。Spinellis 担任了十年的 IEEE 软件编委会成员,编撰了规范的“贸易工具”栏目。他还为 Apple 的 OS X 与 BSD Linux 贡献了代码,同时也是 UMLGraph、CScout 和其他开源软件包、库、工具的开发者。他拥有伦敦帝国学院的软件工程工程硕士和计算机科学博士学位。 Spinellis 是 ACM 和 IEEE 的高级成员。从 2015 年 1 月起他成为了 IEEE 软件的主编。在以前的生活中,他四次成为国际 C 语言混乱代码大赛的冠军。如今,他试图让他的代码保持无聊。Twitter: @CoolSWEng
作者: Ben Linders ,阅读英文原文: Q&A with Diomidis Spinellis on Effective Debugging
评论