易飞滔Todd | 次生进化

《调试九法》:调试是个技术活

一般当工程师把一个东西称为艺术甚至玄学的时候,说明这个东西难度很大,没有太多规律可以遵循,调试就属于此列。几乎每个程序员都有被bug搞到死去活来的经验,有时候颠来倒去,问题似乎解决了,但也不知道为什么,就把它当作不可解释的玄学现象,最烦人的一种bug是偶尔出现难以复现的,学名海森堡bug。

不过毕竟软件还是属于科学技术的范畴,调试也应当是门技术活。《调试九法》是一本少见的讲调试技术的书,九个普遍性的原则不仅适用于软硬件开发维护,甚至还能运用到日常生活,为了找到这本书我也是费了点神,幸好图灵社区还有正版的电子书卖。

说实话,作者举的很多例子偏硬件,所以理解起来有点隔膜,但是九个原则确实很实用,在我以前的开发调试经历中可能也不自觉的总结过一些,但是看了本书,还是有拿到武功心法的感觉。看过之后,下面再把九个原则遍历一下,加深理解。

1 理解系统

这是最重要的一条原则,要分析bug,自然要理解系统是如何运作的,这就需要学习掌握一些基本原理,对具体的类库、工具、技术都需要认真的去读相关文档。我最近几年才养成了认真读官方文档的习惯,搜索引擎的发达,使得我们养成了面向google编程的习惯,面对问题,总是想一蹴而就的解决,结果往往是走了很多弯路,因为舍不得花时间看路标。本书还特别强调“逐字逐句”读手册,简直是对浮躁的人当头棒喝。不理解系统就开始调试和不理解原理就写代码是一脉相承的,俗称“面向巧合编程”。

2 制造失败

这条原则讲的是复现bug的重要性与方法,一个bug,肯定是在某个特定的条件下发生的,抽丝剥茧找到这个特定的条件,就成功了一半。以我的硬件维护经验来看,发现bug的人如果能详细的记录整个过程是非常有帮助的。有很多bug出现的条件比较苛刻,所以程序员的口头禅之一就是“在我这里没问题啊”。针对这类bug,首先最好能找到模拟方法,比如加快软件运行交互的速度,进行压力测试等,其次就是在软件内部能有详细合理的bug记录机制,便于从内部找到复现的条件。

3 不要想,而要看

这一条强调的是观测的重要性,面对现实比胡思乱想重要。语言影响思考,所以遇到bug的口头禅应该是“我看看“而不是”我猜可能是因为“。我们应该想尽办法去看清楚出bug的细节,所以成熟的程序中都应该有用于调试的基础设施,个人经验至少日志是必不可少的,初级程序员习惯于完全靠打断点调试,问题是断点本身会改变程序执行的流程(尤其是多线程的情况下)。当然,猜测依然是有用的,可以帮我们缩小观察的范围,或者至少拟定一个观察的优先级,这样经验就能发挥作用,但是不管怎样,经验不能替代观测。

4 分而治之

二分查找法可以把查找的时间复杂度从线性变成对数,不仅是程序中的查找算法,也是调试时的方法论,一个系统有了这个意识后,关键是如何划分系统,又回到了原则1。至少在打断点调试时,要找到出bug的位置,也是可以用二分法的,或者插入日志记录时也可以应用这个原则。另外,书中还提到了bug间相互影响的现象,我觉得甚至又bug跷跷板的现象,解决之道是一个都不要放过。调试bug时发现代码质量实在太差,重构一下有时候也是有必要的,否则浮沙之上筑不了高台啊,甚至经过有效的重构,bug很自然的就发现并消除了。

5 一次只改一个地方

这条原则类似实验科学中的对照原则,一次只考察一个变量,比对正常情况和异常情况,一定不能忽略任何一个测试条件的差异。上一条说bug一个都不放过,但也得一条条过,重构的时候也要注意不要对不懂的代码乱改一气,很多代码的危险性在于牵一发而动全身,我觉得这也是修复bug时的最大风险,往往是消除一个bug同时增加几个bug,还是回到原则1,要理解系统。不过有时候,理解前人的代码谈何容易,这也启示我们写代码时要多积德,不要以为代码就是给编译器看的,而应该是给以后维护我们代码的人看的。

6 保持审计跟踪

这条原则讲的是记录的重要性与方法,我想这就像医生问诊一样,需要问合适的问题,给出具体的有效答案。我的个人经验是一旦开始调试bug,可能整个人就彻底陷进去出不来,通过记录的方式可以把自己从思维的泥潭中抽身出来,不断修正调试bug的计划与方法。对一些专用系统软件,需要培训软件的使用者如何用有效的语言来记录出现bug的情况,如果没有记录,那么调试的人就需要用适当的问题帮助使用者唤醒记忆。此外,我觉得半夜调不出的bug需要睡一觉,让发散思维自动起作用,一般早上就解决了。

7 检查插头

这条原则大概说的是所谓”低级失误“了,类似我妈跟我说电脑音箱不响了,我首先得问电源开了没这种。又比如把main函数写成mian函数,有一次我打断点调试就是不进断点,后来发现是因为有两段代码比较类似,我断点打错地方了。根据场景和经验,问问自己是否犯了该场景下常见的低级失误,往往针对能立竿见影的解决很多问题,就像很多电器设备说明书的故障FAQ中,也会强调检查插头。可以说,低级失误低级的是难度,而不是频度。

8 获得全新观点

这条原则说的是求助和交流,这里面很重要的是通过他人的观点来破除自己的思维定势,所以最好是只给别人详细描述现象,不要说自己的猜测,以免污染别人的判断。对程序员而言,这时候面向google编程的力量是惊人的,可能九成的问题都不需要你真正的再去提问,而是找到合适的搜索词去搜索即可,对中国程序员而言,用英文搜索是必须的技能,当然搜到东西后真正理解也是非常重要的,不能见到药就吃。如果遇到的问题google真的搜不出来,证明你层次稍微高了点,这时候可以上stackoverflow之类的社区提问。

9 如果你不修复bug,它将依然存在

这条原则说的是不要心存侥幸,不要因为bug只是闪现了一下就采取鸵鸟策略,该来的总会来,不是不报,时候未到。当然实际情况可能很复杂,软件工程本来就是时间、成本和质量的妥协体,但即使放过某些bug,也要分析出这样可能造成的后果,建立充分的防护机制,因此在很多软件认证体系中,都会有软件安全等级标准。这里还涉及bug复现和举一反三的问题,需要确认真正找到了bug的原因提供了有效的修复手段,并排查所有类似的问题。比如修复了一个内存泄露问题,就要修复所有导致这类泄露的代码。

附记:如何写出适合调试的代码

从调试的原则反观,对软件构建本身也很有帮助,毕竟调试只是查漏补缺的,最好还是写出来的代码少一些bug,为了调试的时候方便多预留一些手段。这里也简要总结一下个人经验,可能需要不断完善。

  1. 程序中应该提供日志机制,在程序的debug版便于输出调试信息,即使在release版,也要记录软件发生问题(如C#中抛出异常)时的详细情况;
  2. 要写出适合人类阅读的代码,否则后续维护的人看都看不懂,要如何调试?怎么写《代码大全》中说得最详细了,我觉得除了各种习惯写法之外将心比心也是很重要的;
  3. 使用各种类库或工具时,尽量弄懂原理,仔细的阅读官方文档,比如有些类库会详细说明如何防止内存泄露,如何应对多线程情况,看清楚再用,就会少种下祸根,至少不用等到调试的时候再无头苍蝇一样猜测原因;
  4. 写便于测试的代码,这样在调试时,容易剥离问题,或者使用二分法,这方面TDD编程实践讲得比较充分,我个人也需要加强。