« 《冰气时代》末日下的人性考验 | 返回首页 | Ericsson Texture 压缩贴图 EAC 的编码器 »

断点单步跟踪是一种低效的调试方法

断点单步跟踪的交互式调试器是软件开发史上的一项重大发明。但我认为,它和图形交互界面一样,都是用牺牲效率来降低学习门槛。本质上是一种极其低效的调试方法。

我在年少的时候( 2005 年以前的十多年开发经历)都极度依赖这类调试器,从 Turbo C 到 Visual C++ ,各个版本都仔细用过。任何工具用上十年后熟能生巧是很自然的事。我认为自己已经可以随心所欲用这类工具高效的定位出 bug 了。但在 2005 年之后转向跨平台开发后,或许是因为一开始没能找到 Linux 平台上合适的图形工具,我有了一些时间反思调试方法的问题。GDB 固然强大,但当时的图形交互外壳并不像今天的版本这么完善。当时比较主流的 insight ddd 都有些小问题,用起来不是十分顺手。我开始转换自己平时做开发的方式。除了尽量提高自己的代码质量:写简洁的、明显没有问题的代码之外,多采用不断的代码复核(Code Review),有意识地增加日志输出,来定位 Bug 。

后来开发重心从客户端图形开发逐步转向服务器,更加显露出用调试器中断程序运行的劣势来。对于 C/S 结构的软件,中断一边的代码运行,用人的交互频率单步跟踪运行,而另一边是以机器的交互频率运作,像让软件运行流程保持正常是非常困难的。

这些年的工作中又慢慢加入一些 Windows 下的开发工作。我发现经过了再一个十年的训练,即使偶尔用上交互式调试器,也体会不到什么优势了。往往手指按在跟踪调试按键上机械的操作,脑子里想的却不是眼前看到的屏幕上的代码。往往都没执行到触发 Bug 的位置,已经恍然大悟发现写错的地方了。这种事情多了,自然会对过去的方法质疑,是什么导致了调试器的低效。

有时和人聊天,谈及该怎么定位 Bug 。我总是半开玩笑的说,你就打开编辑器,盯着代码看啊。盯久了,Bug 自然就高亮出来了。这固然是玩笑,但我的理念中,一切调试方法都比不上 Code Review 。无论是自己写的代码,还是半途介入的别人的代码。第一要务就是要先理解程序的总体结构。

程序总是由一段段顺序执行的小片代码段辅以分支结构构成。顺序执行的代码段是很稳定的,它的代码段入口的输入状态决定了输出结果。我们关心的是输入状态是什么,多半可以跳过过程,直接看结果。因为这样一段代码无论多长,都有唯一的执行流程。而分支结构的存在会让执行流依据不同的中间状态做不同的数据处理。考虑代码的正确性时,所有的分支点都需要考虑。是什么条件导致代码会走向这条分支,什么条件导致代码走向那条分支。可以说分支的多少决定了代码的复杂度。现在比较主流的衡量代码复杂度的方法 McCabe 代码复杂度大致就是这样。

一个软件的整体 McCabe 复杂度一定远超人脑可以一次处理的极限。但通常我们可以对软件进行模块划分,高内聚低耦合的结构能减少软件复杂度。一个高内聚的模块,可以和外部隔离,方便我们聚焦到模块内部来分析。当焦点代码的规模足够小的时候,包含一切分支结构的所有流程就能一次性的被大脑处理了。对于用调试器辅助观察程序的执行流程来说,每次用真实的输入数据驱动的执行过程一定是沿唯一的路径运行的。为了定位 Bug ,我们需要设计出可以触发 Bug 的输入状态。对于一个局部模块来说,这并不总是容易的事。但靠大脑分析一个模块则不同,在 McCabe 复杂度不高时,几乎是可以并行的处理所有的执行路径的。也就是说,你在扫描代码的同时,大脑其实是在同时分析所有可能的情况,同时还能对不太重要的分支做剪枝。当然,和所有技能一样,分析速度和能分析的宽度(复杂度)以及剪枝的正确性是需要反复训练才能拓展的。过于依赖交互式调试工具会影响这种训练,大脑受工具的影响,会更关心眼下的状态:目前运行到哪里了,(为了提高调试效率)下个断点设到哪里去,现在这组变量的值是什么…… 而不太关心:如果输入是另外一种情况,程序将怎么运行。因为工具已经把这些没有发生的过程剪掉了,等着你设计另一组输入下次再展示给你。

交互调试工具通常缺乏回溯能力,也就是它们通常反应当下的状态,而不记录过去的。这有些可以通过改进工具来完善,有些则不能。一个常见的场景是,你定下了下一个断点的位置,当调试器停下来的时候,发现状态异常,只能确定问题出在上次断点到当前的位置之间,但想回溯到底发生了什么,某个中间状态是什么,工具却无能为力。而靠大脑推演程序的运行过程的话,一切都是静态图谱,回溯和前行并无太大区别,只是聚焦到时间轴上某个位置而已。这就是为什么受过良好训练的程序员可以一眼看出 Bug 在哪里,而调试器运用高手却需要反复运行两三次才能找到 Bug 的缘故。

在大脑中正确运行程序当然需要足够的训练,比训练使用调试器难的多,但却是值得的。不知道其它同学有没有类似经历:我在中学时代参加信息学竞赛的时候,考卷并不全是编程题,尤其是初赛阶段,一般是纸面考卷,有很多题目都是给出程序和输入,写出输出结果。感谢这段经历,我不得不在初学编程的时候就进行这类训练。初中的时候,每天可以摸到真机的时间是按小时计的,大部分时间还是在传统的学业上。为了编写自己玩的游戏程序,我只能在上课的时候偷偷的在本子上手写代码。写完了后如果没有下课,我会在大脑中模拟运行一下,看看有没有 bug ,能在上机前改过来,就可以更有效的利用每天有限的上机时间。这些经历让我觉得读代码其实没那么枯燥,是提高效率的一种方法。

用 Code Review 作为主要的定位 Bug 的手段,可以促进你写出复杂度更小(更不容易出错)的程序。因为知道以你目前的能力大脑能一次处理的复杂极限在哪。在减少分支方面,我看过 Linus 的一个访谈节目。他谈及代码品位,举了一个很小的例子:一段对链表的处理程序。链表的头部通常和中间的结构不同,头部之外的节点都有一个 next 指针引用下一个节点,而头节点是个例外,是由不同的数据结构引用的。再 Linus 列出的反面例子中,代码判断了头指针是否为空;而在正面例子中,next 指针是用一个指针引用变量实现的,对于头节点,它引用在不同的数据结构变量上,这样就回避了多一次的例外(对于头节点)判断。代码可以一致的处理。在那个只有 5,6 行代码的小片段中,似乎判断语义非常清晰,多一次判断微不足道,但 Linus 强调这是品位选择的问题。我认为,这其实就是将减少代码复杂度提升到书写代码的本能中。

对于中途介入的他人的项目,你无法控制代码的质量。但长期的 Code Review 训练可以帮助你快速切分软件的模块。通常,你需要运用你对相关领域的知识,和同类软件通常的设计模式,预设软件可能的模块划分方式。这个过程需要对领域的理解,不应过度陷入代码实现细节。一上手就开调试器先跑跑软件的大致运行流程是我不太推荐的方法。这样视野太狭窄了,花了不少时间只观察到了局部。其实不必执着于从顶向下还是从下置上。可以先大致看看源代码的文件结构做个模块划分猜测,然后随便挑选一个模块,找到关联的部分再顺藤摸瓜。对于需要构建的项目,摸清程序脉络的时间甚至可以在第一次等待编译构建的时间同步完成,而不需要等待构建完毕在一步步跟踪运行,甚至不需要下载代码到本地,github 这种友好的 web 界面已经可以舒适的在浏览器里阅读了,有个 ipad 就可以舒服的躺在床上进行。

我不太喜欢 C++ 的一个原因是:C++ 代码从一个局部去阅读,很难有唯一的解释。它的代码字面意思很可能对应有多种实际操作含义,确定性不足。函数名重载、操作符重载都是隐藏在局部代码之外的。甚至你看到一个变量名,不去同时翻阅上下文及头文件的话,都很难确定这是一个局部变量还是一个类成员变量(前者的影响范围和后者大为不同,大脑在做分析的时候剪枝的策略完全不同);看到一个变量,原本以为是一个输入值,直到看到最后,发现它还可以做输出,回头一看函数声明,其实它是一个引用量。如果用到模板泛型就更可怕,连数据类型都不确定。只从局部代码无法得知模板实例化之后那些关联的操作到底做了些什么。阅读 C++ 项目往往需要在代码间相互参考,增加了大脑太多的负担。

那么,光靠大脑 Code Review 是不是就够了呢?如果自身能力无限提高,我认为有可能。通过积累经验,我这些年能直接分度阅读的代码复杂程度明显超过往年。但总有人力所不及的时候。这时候最好的方法是加入日志输出作为辅助手段。

试想我们在用交互调试工具时,其实是想知道些什么?无非是程序的运行路径,是不是真的走到了这里,以及程序运行到这里的时候,变量的状态是怎样的,有没有异常情况。日志输出其实在做同样的工作。关键路径上输出一行日志,可以表达程序的运行路径。把重要的变量输出在日志里,可以查询当时的程序运行状态。怎样有效的输出日志自然也是需要训练的技能。不要过于担心日志输出对性能的影响,最终软件有 20% 上下的性能波动对于软件的可维护性来说是微不足道的。

和外挂的调试工具相比,日志具备良好的回溯查询能力。作为 Code Review 的一个辅助,我们大脑其实需要的只是对判断的一个修正:确认程序是否是沿着脑中模拟的路线在行进,内部状态是否一致正常。和调试工具不同,日志不会打断运行过程,对多个程序并行运行的软件,例如 C/S 结构的系统就更为重要了。

其实保留状态信息在交互调试工具中也是非常重要的技巧。我相信很多人和我一样,在调试程序时有时会增加一些临时的全局变量,把一些中间状态写到这些变量中。在交互调试过程中偶尔需要去查看这些状态值。这种临时状态暂存变量,其实也充当了日志的功能。

文本日志的好处是可以利用文本处理工具做信息二次提取。grep awk vim python lua 都是分析日志的好手段。如果日志巨大,且存在在远程机器上,你很可能找不到更有效快捷的手段。很多时候,不断的重新运行有 bug 的程序的代价,是远超一次运行得到详细日志后再对日志做分析的。

那么,学会使用交互调试工具重要吗?我认为依然重要。偶尔用之,也能起到奇效。尤其是程序崩溃的时候,attach 到进程中观察崩溃时的状态。操作系统大多也能 dump 出崩溃时的进程状态供事后分析。这些都需要你会用调试工具。但通过静态状态的草灰蛇线反推出崩溃前到底发生了些什么,却也更需要对代码本身有足够的理解。因为用的时机不多,我认为命令行的 gdb 就足够用了。在分析损坏的栈帧、编写脚本分析一些复杂数据结构方面,命令行版本更具灵活性,应用范围也较广。而交互上的不便,增加的学习成本,都是可以接受的。

Comments

感触比较深,突然要线上排查一个别人写的bug的时候,需要先通过bug问题定位请求,然后我的第一个想法是能否拿到请求入参,在本地模拟下,通过断点调试看是什么问题,这篇文章给了我一个新的方向,先看下其代码脉络,没必要这么着急的直接调试,陷入局部
断点+code review+日志输出 混合搭配使用才是最高效的debug手段!
调试上,io输出 + 热更 效率最高。特别是高并发场景下。 设断点点的手疼。
赞 真正的好代码,是对人也很友好的。代码的作用除了要能体现逻辑,更要能体现设计思想。
赞同
总结 如何写出高质量的代码 1 写复杂度小的代码 2 代码复核 如何高效的定位BUG 1 理解代码的总结构 在脑中模拟运行 2 日志
其实只能说微软和visual studio把图形化和DEBUGGER做到极致了,唯一最好用的调试器了,而乔布斯和Xcode又是另外一个高度,但在游戏行业里。。。。。好像真和这2个体系完全不搭边,在游戏行业里谁都不服谁,而又都想创造些东西出来,最终也有足够利润传统的商业模式想过来分蛋糕也很难。。。。
c++那个例子,命名规范问题么。变量名加m加o/out呗。
mark
最好的查BUG方式是用客户来查。
@xx, 不同意,大工程反而不适合断点,这时查 log 反而更快。做好设计的目的是少写甚至不写代码,划分好模块的目的是应对问题不需要在电脑和人脑里把不必要的代码装载起来。
赞同 尤其在写一些复杂的算法,用脑袋去运行比调试器要快得多,也方便的多。唯一的不好就是要忍受下先期大脑得费点劲。 调试器容易让人陷入到无关紧要的细节里面去,读别人的代码用断掉调试走绝对是个糟糕的办法。
调试绝对好,这个是一项伟大的变革,当一个系统多达几十m以上的g代码的时候,你研究代码的几乎为零,写代码,只需要将断电放在错误前面,立马就能知道,对于复杂的代码,特别是分离式的代码,是非常好的
有太多好用的 GDB 前端了: http://www.skywind.me/blog/archives/2036
云风这篇文章的本意是如何训练提高自己的debug能力,可惜很多人没仔细看就开始根据自己的经验批判,人在不同的阶段和环境,要选择适合自己的工具和方法,但是永远也不能放弃对更高技艺的追求。
这个观点只是某些理解的换一种表述,其实也没有什么新意。当你已经对某个功能相当了解,并且那部分的代码都是你写的情况下,你只要一看到现象,其实已经基本知道造成这个现象的原因。 云风的表述如果换一下,估计大家能够更好的理解和接受,不过感觉云风没有想明白,所以引起大家误会了。 云风更多想的应该是大家写代码时,一开始有比较完整的思考,给出比较清晰的设计,那么写代码都会很顺手。这种方式是建立在写之前有充分的思考,所以程序的第一次运行准确性很高,同时维护时也因为熟悉领域,并且清晰的知道实现,所以很容易发现问题了。 “断点单步跟踪是一种低效的调试方法”应该改为: 使用调试的方式来编程,是很低效的。 国内的程序员最大的问题是使用调试来代替思考。往往代码只写了一小半,后续就是通过不断的调试来完善,这种情况多如牛毛。在国内多数都是非系统的程序制作人员,对于 需求分析, 设计 并没有很重视,所以大多数时候,都是大概了解了一下需求就直接开干。
再说一下 code review. 任何依赖于人的主动性做质量保证都不靠谱。人本身就不是很稳定的思考机器, 情绪会变化,责任心每天也不同。更别说逻辑复杂一些的代码 code review 根本看不出来。 code review 最适合做可读性检查, 命名混乱,注释不够一眼就看出来了。
调试的定义是遇到问题以后查找原因的手段。 而 code review 是保证软件质量的手段,不是用来的调试的。 这是2个不同的概念
skynet没好用的ide调试,没在你博客看到实际可用的ide调试的。你的ejoy2d的网页版ide调试器挺好的(不是你写的),可惜skynet没。希望还是有个好用的ide调试器。babelua没法调试skynet,自造lua框架可以。
做复杂一点的3D渲染你不用调试器试一试?
"我在年少的时候( 2005 年以前的十多年开发经历)" 那时我还没出生....这大概是中国写代码时间最长的程序员啦:)
@thousails 关于 C 语言的特点(以及和 C++ 的区别)参考这篇 blog 的最后几段。 https://blog.codingnow.com/2009/01/the_new_c_standard.html
c和c++都是基于副作用编程,为什么说c的代码更好理解?
我很能理解云风作出的结论, 而且即使不说也相信他不喜欢依赖调试器. 主要是从历史blog就能看出云风是一个希望把所用的东西完全精通才会下手写的人, 这种情况已经对代码结构流程了如指掌, 大部分问题都靠直觉,少部分再靠log就能解决, 当然没有必要依赖断点跟踪(除非去查编译器/CPU的bug吧:). 当然, 这就限制了所用所写的东西都尽量简化,不要复杂, 所以选择C和Lua,以及各种造精巧的轮子就是情理之中的事. 可惜绝大部分人做开发都不是这个理念, 尤其是国内一些不好的风气, 如浮躁, 赶进度只看结果, 重效益为了赚钱而写代码带来对开发的兴趣降低...因此能稳下心来反复Code Review, 研究整个运行体系, 只靠直觉就能纠错的人就极少了.
quot:'程序员要以使用debug断点为耻' 尽管是一家之言,但这毫无疑问是误导, 一个真正的程序员不会被debugger影响自己代码质量,也不会有debugger这种生产力工具而放置不用 在web领域,不用chrome断点和debugger工具的请举手。
@henix "Code Review 做多了之后,对常见的容易犯错误的地方都能总结出 pattern 了" 我发现代码写多了之后, 常犯的错误已经不可能用pattern检查了, 如">"写成"@henix "Code Review 做多了之后,对常见的容易犯错误的地方都能总结出 pattern 了" 我发现代码写多了之后, 常犯的错误已经不可能用pattern检查了, 如">"写成"<", 字段名写错成另一个同类型字段名. 各种静态代码分析工具我都用过, 虽然有些用, 但真的只能查很低级的bug.
Code Review,单元测试,日志,断点跟踪这些都是调试手段, 哪个都在特定条件下有最适用的情况. Code Review确实是比较重要的方法, 甚至可以很大部分替代单元测试, 只是有些时候会让人感到无趣, 能坚持还是应该坚持的. 单元测试在某些情况比较合适, 全面使用恐怕很多是为了测试而测试(这种一般只在有专人写测试的情况下适用). 日志是任何情况都需要的, 查bug首先要靠直觉,经验和日志来分析, 如果发现很难搞定或花费精力预估过多, 那么断点跟踪就要优先考虑了, 这对经验不足的人以及查别人代码bug的情况下效率比临时加一些日志高得多. 因此我经常提倡写服务器程序考虑跨平台运行, 在windows/mac上用IDE可以利用上可视化调试器, 只是为了提高某些情况查bug的效率, 以及降低用人成本. "最终软件有 20% 上下的性能波动对于软件的可维护性来说是微不足道的。" 有这个意识的话, 真的应该多考虑用Java/C#/Go来替代C/C++了, 而且还是天生跨平台, 避免专门考虑某平台特殊API开发.
减少代码量,日志分析bug,图形界面用得越来越少,这几点品位同感。
我觉得单步调试没什么可耻的,最早我用 skynet 时,对于没法单步调试很不习惯,因为之前都是用 VS 的,后来跟一朋友吐槽时,他说你能用Log调试就知足吧,他以前做路由器时,连Log调试都没有,要想调试就只能用“点灯法”,代码走到哪了,让哪个灯亮一下。现在想想还是觉得很搞笑:) 后来虽然还是很不习惯,但也一直是用Log调试了,中间也折腾过skynet的调试,但实在太麻烦用的也不多,还是以Log为主。 我很同意云风说的单步调试偶尔会有奇效,所以不要一棒子打死,市面上这么多超大型的IDE,像VS、PyCharm,如果调试不好用,别人吃饱没事费这么多劲开发?
很对,身边也有很多人研究一段代码/写代码 其实就是在用IDE debug。低效不说,有人问别人问题也开启debug断点,浪费彼此时间。 我建议程序员要以使用debug断点为耻
赞同,从 C++ 的例子可以进一步延伸,就是代码本身需要写得容易理解,尽量只通过局部的推理就知道这段代码在干什么。我认为函数式编程提倡的无副作用、不可变数据结构等就是很好的经验法则。 Code Review 做多了之后,对常见的容易犯错误的地方都能总结出 pattern 了,把这些 pattern 规则化,写成一个自动化找 bug 的工具,这就是静态代码分析(Static Code Analysis)。这方面静态类型的语言更容易做,比如 Java 的 Findbugs 找 bug 的能力那是很强的。
gdb 这种工具我主要是用来处理崩溃的,检查崩溃现场情况,看看该加哪些日志什么的。复杂的情况会用 rr,回溯到故障点之前的状态。再不行就 watch,或者条件断点。单步用得最少,因为太慢了。我不如打几个GB的日志出来然后逐步找到问题所在。 调试器,特别是单步还有一点不好的是:它是强交互的。而基本上你不可能为了调试去中断线上服务,只能事后分析日志然后尝试在测试环境下重现。我曾经还仅靠日志和源码定位到了一次故障。
很多bug都可以推敲出来, 唯一的问题是整个项目中, 只有你一个人认真写代码, 其他人都是浑水摸鱼, 代码能work就行, 这种情况下就很尴尬了
个人感觉对于高级语言日志和分析代码没问题,但是对于写汇编优化的时候,不用gdb够呛,没办法知道上下寄存器和内存分布。
文本日志可以进一步到结构化日志,有的时候程序日志量巨大,分析工具都 handle 不了,或是日志 rotate 了。可以把日志结构化,存到数据库,建好索引,在这种结构化的日志里面分析起来比较快速
命令行难道也不是一种图形化工具?难道就不会再有交互上的发展?人们从需要工具到依赖工具到正确认识工具是一种进步的代价。
赞同 :)

Post a comment

非这个主题相关的留言请到:留言本