一篇blog,显得略长,本文对应第1-4章,第5-8章请参考Part 2。
宏观策略
Bug追踪系统
Issue Tracking System,可供选择的工具:GitHub、GitLab、JIRA、Bugzilla、Launchpad、OTRS、Redmine、Trac。
使用Bug追踪系统的好处:
- 可看见调试工作所取得的进展;
- 可对软件的发行进行追踪与规划;
- 协助确定各种工作项(work item)之间的优先次序;
- 协助把常见的事务及其解决方案整理成文档;
- 防止遗漏某些问题;
- 可以自动生成发行说明,即release note;
- 可用作知识库,对软件中的缺陷进行估量及反思,并从中总结经验。
有些公司规定:在修改代码之前,必须先指明这次修改所涉及的事务(对应英文是Issue,可理解为Bug)。
SSCCE:每一项事务都能够精确地描述问题的重现方式。最好能在其中给出一个简短(short)、自足(self-contained)且正确(correct,可正确编译并运行)的例子(example)。参考SSCCE。
错误报告必须具备如下几方面内容:
- Precise Title:精准的标题,方便在事务汇总报告中迅速找出某个Bug;
- Severity:严重程度,用于判断优先级。如:数据丢失有关是严重级别;
- Priority:优先级,一般由开发者或PM(项目主管,或产品经理)来设置;
- Stakeholder:利益相关者,可帮助团队获知与该事务有关信息,并帮助产品拥有者来决定事务的优先次序;
- Environment:情境描述,对于某些难以捕获的Bug,可提供线索,方便重现Bug。
不要用百度,用Google。Chrome搜索技巧。
Black Duck Open Hub CodeSearch:专门用来搜索代码的引擎,该服务已停止运营,可考虑使用SearchCode。
善用StackOverflow。
你不太可能是第一个遇到某问题的人。如果万一真的遇上,大概率是以下几种情形之一:
- 你使用的搜索关键词不够精确;
- 你使用的软件太冷门,如付费的;
- 你对问题的判断有偏差,方式不对等。
如果实在找不到答案,可考虑在GitHub或StackOverflow新增问题。提问是一门艺术。最好要遵循上面提到的SSCCE原则。
在线IDE,很多。
确保前置条件与后置条件都能够得到满足
入口点:entry point,前置条件,precondition,指的是程序在即将执行例程时所具备的状态,以及传递给该例程的输入值。如果前置条件得不到满足,那说明用来设置这些前置条件的代码里面有错误;
出口点:exit poit,后置条件,postcondition,指的是程序执行完例程之后的状态及其返回值。若是后置条件得不到满足,则说明该例程本身有问题。
为了判断前置条件是否得到满足,应该仔细检查算法的参数,包括传入的参数值,调用方法时所针对的对象,以及可疑代码所使用的全局状态。注意点:
- 找出那些本来不应为null,但实际上却为null的值;
- 调用数学函数时,确保传入的值位于该函数的定义域之内;
- 查看对象、结构体与数组的内部细节,确保其内容符合要求。可查出无效指针;
- 检查变量的取值是否在合理范围之内。如果变量具有可疑取值,那通常表明它还没有初始化;
- 检查传给例程的数据结构是否正确;
出口点检查要点:
- 计算出来的结果看上去合理吗?有没有处在预期的范围之内?
- 如果结果合理,而且位于预期的范围之内,那么实际的值是否正确?我们可以通过手算来演练相应的代码,以验证计算机的执行结果是否正确,也可以将执行结果与已知的正确值相对比,或是采用其他工具或方法来进行验算;
- 例程的副作用是否符合预期?可疑代码所接触到的其他数据是否遭到破坏或拥有不正确的取值?有些算法在遍历数据结构时,会把一些维护其工作所用的信息记录在数据结构中,对于这些算法来说,尤其应该进行这样的检查;
- 算法所获得的资源,如文件句柄及锁,有没有正确地释放?
必须要亲自验证(verify),而不能想当然地接受假设(assume)。
从下往上与从上往下
要想确定问题的来源,通常有两种办法:
- 从下往上:从问题的具体表现入手,向上追查其来源;
- 从上往下:从应用程序或系统的顶层入手,逐步向下探查,直至找到其根源。
如果问题表现得很明确,则应该从发生问题的地方入手,向上追查bug。分三种情况:
- 程序崩溃:
- 程序冻结:
- 错误消息:
如果无法确定与故障有关的代码到底在哪里,就应该从顶层系统开始,逐步向下查找故障原因。这种故障通常属于系统的涌现属性(emergent property),也就是无法与某个具体部分直接对应起来的属性,如性能、安全、可靠性问题等。
由上而下地排查错误时,需要把整个程序分成多个部分,然后分别判断每一部分在引发当前故障的各种因素中可能占多大的比例。
在能够正常运作的系统与发生故障的系统之间寻找差别
之所以能根据新旧系统间的差距来进行调试,其原因在于:尽管各人所经历的问题有所不同,但计算机的底层运作方式却是十分确定的,也就是说,同样的输入会产生同样的输出。
工具:DTrace和SystemTap;追踪对操作系统的调用(strace、truss、Procmon)、对动态链接库的调用(ltrace、Procmon)、网络包(tcpdump、Wireshark)、SQL数据库调用。
在两个系统的输入文件之间进行对比,如果它们都比较庞大并且离得比较远,那可以考虑对比它们的MD5校验和。
ldd:Unix系统下的命令,用于打印程序或者库文件所依赖的共享库列表。
在进行更深层次的探查之前,应该先考虑一下有没有其他因素会影响程序的执行情况,环境变量就是这样一个容易忽视的因素,即便是没有特权的用户,也依然可以通过设置环境变量来破坏程序的正常执行。
如果正常系统和故障系统的区别位于源代码中,对这两个版本之间的历次修改进行二分搜索,以确定问题所在。要熟悉git bisect
命令的使用。
comm:在排好顺序的两个集合中找寻不同的元素。
使用软件自身的调试机制
调试机制的好处:
- 可以通过禁用后台执行或多线程执行等特性来简化程序的调试工作;
- 可以有选择地执行其中某一部分功能,以便通过测试用例来精确地再现相关的故障;
- 程序可以给我们提供与性能有关的报表及其他信息;
- 程序可以把更多的信息记录在日志文件中。
用多种工具构建软件,并将其放在不同的环境下执行
任务优先级
通用的方法
相信自己能够把问题调试好
你的心理状态会对调试的结果造成影响,专家们把这叫做感受到的挑战与自身技能之间的一场对抗
。
调试时,需要有两个重要的支持:
- 对数据的访问权,使我们能够访问到所需的全部数据;
- 功能强大的计算机,它使我们能够对这些数据进行处理。
排除一切干扰,力争进入心流状态。
遇到难题时,带着问题先睡一觉。
调试时需要的四项基础设施:
- 把健壮的最小测试用例准备好;
- 对Bug的重现加以自动化;
- 用脚本来分析日志文件;
- 了解API或语言特性的实际运作方式。
高效地重现程序中的问题
tellg:C++函数。用于输入流,返回流中get指针
当前的位置;在读取文件时,可返回当前位置在文件流中的偏移量。
修改完代码之后,要能够尽快看到结果
构建工具。
软件自动化应用程序,如适用于网页浏览器的Selenium、适用于Windows的AutoHotkey、适用于OS X的Automator,以及适用于Linux的AutoKey。
要点:
- 设法在修改代码后尽快看到其结果,以提升调试效率;
- 配置一套快速的自动化构建及部署流程;
- 测试软件时,要令其尽快地将故障暴露出来。
将复杂的测试场景自动化
使自己尽可能多地观察到与调试有关的数据
要点:
- 如果能够同时看到比较多的数据,那我们就可以更加专注地进行调试,从而找到数据所体现出的模式以及数据之间的相互关系;
- 尽可能地将显示区域扩至最大;
- 把相对静态的数据打印到纸上。
考虑对软件进行更新
查看第三方组件的源代码,以了解其用法
为了能够迅速地查询函数或方法,可用ctags或etags程序为代码编制索引(大多数编辑器都支持这两个程序所输出的索引),也可采用IDE来查看代码。与ctags相比,IDE能够更好地处理复杂的语言特性,如重载、覆盖、模板等,而ctags的优势则在于支持的语言数量比较多,5.8版本支持41种语言。在源代码的目录下运行命令ctags -R .
可给其中的所有文件创建索引。
把自己的代码与这个调试版的第三方程序库相链接,在第三方程序库的代码里面进行单步调试。使用符号调试器(Symbolic Debugger)来检视其中的变量。点评:对Java而言,调试三方库是最常见不过的事情。
迫不得已,才修改源码;三方库发布新版本后,如果还存在相同问题,都需要做修改;修改面及影响面可能会非常大;还要确保不会违背相关的法律条款。
要点:
- 如果你依赖某个三方组件,那么就应该获取其源代码;
- 通过查看第三方组件的源代码探寻与第三方API及一些奇怪的错误消息有关的问题;
- 要和第三方程序库的debug版本相链接;
- 只有当其他办法都不可行的时候,才需要对第三方的源代码进行修改。
使用专门的监测及测试设备
使故障更加突出
从自己的桌面计算机上调试那些不太好用的系统
设备模拟器:Device Emulator,以便通过计算机屏幕和键盘来调试移动App。
软件楔子:Software Shim,以便使用自己计算机中的工具来调试嵌入式代码。
远程访问:TeamViewer,以便能够远程调试客户的计算机。
KVM over IP设备:能够通过IP网络来远程访问服务器的键盘(keyboard)、显示器(video)及鼠标(mouse)。
使调试任务自动化
调试前与调试后都要把程序清理干净
调试时,应该优先关注当前区域中最容易解决的问题。
为了调试某个重大Bug,会阅读很多个类文件、几十个方法,这些代码看起来可能不够整洁甚至有问题,要不要优化或解决问题,需要加以权衡。
找到并修复程序Bug后,不要急着去做其他事情,还有两项任务未完成:
- 在代码中寻找类似错误,并将其修复;
- 要把寻找问题时所做的那些修改整理好,分两种情况:
- 临时改动的,不能提交的代码,予以还原;
- 方法重命名、重复代码片段提取等优化,或加日志、断言等性质的代码,清理干净并提交上去。
要点:
- 在开始调试重大Bug前,先要确保代码能够达到一定整洁程度;
- 调试完毕后,要把调试过程中对代码所做的临时改动还原回去,有用的代码提交到代码库。
把属于同一个类型的所有问题全都修复好
通用的工具与技术
命令行的艺术。
用Unix命令行工具对调试数据进行分析
掌握命令行工具的各种选项及习惯用法
用编辑器对调试程序时所需的数据进行浏览
搜索词根而不搜索原词。
优化工作环境
包括:
- 确保PATH环境变量中含有你所需的各种目录,使得你要运行的所有程序都能够涵盖在这些目录的范围之内。
- 对shell和编辑器进行配置,使其可以推断出你要输入的内容,并自动把命令补齐。
- 对shell界面的命令提示符进行设置,使其可以显示出登录所用的身份、当前的目录以及主机的名称。
- 把各种命令行编辑按键,设置得与你习惯的编辑器相符。
- 为常用或是容易打错的命令设置别名或快捷方式。
- 把与各种实用工具有关的环境变量设置好。
- 把输入的命令都记录到历史文件里面,方便后续搜索到当时所执行的调试命令。
- 使shell的路径名扩展机制(即globbing机制,如对*进行扩展)能够涵盖子目录中的文件。
调试时要操作多台计算机的技巧:
- 无需输入密码,即可登录远程主机(或可执行命令)
- 给主机设置既简短又好记的别名
- 设法寻找能够从桌面直接登录远程主机,并在其中执行GUI应用程序的方式
用VCS寻找Bug发生的原因及经过
很多Bug都与软件的改动有关,改动就是一个新版本,一定要确保项目在使用某种VCS。
每一次修改都应该单独提交,并附加有意义的提交消息,如果有可能,还应该链接到对应的Bug ID。
一定要熟知git log、git blame、git show、git diff、git rev-list、git bisect等命令的使用。
监测工具
被动记录系统,passive recording system:collectd、RRDtool;
主动记录系统,active recording system:Nagios。
Nagios功能:具有经过测试的服务检查程序及通知程序(被动与主动的都有)、管理面板、轮询事件数据库、非侵入式的监测计划安排功能、可扩展性、丰富的插件。
全方位的监测:
- 从最底层的资源开始,要监测每一台主机的健康状况,包括:CPU负载、内存使用量、网络是否可达、正在执行的进程数量、已登录用户数量、可更新的软件、磁盘剩余空间、已打开的文件描述符、已经占用的网络带宽与磁盘带宽、系统日志、安全性以及远程访问。
- 软件服务:数据库、电子邮件服务器、应用程序服务器、缓存、网络连接、备份、队列、消息传递、软件授权、Web服务器以及目录。
- 应用程序:
- 能否正确处理整个流程;
- 各个部分是否正常,如Web服务、数据库表格、静态网页、交互式Web表单及报告机制
- 某些关键指标是否正常,如响应延迟、已加入队列和已完成的订单、活跃用户数量、失败交易、发生错误及得到报告的程序故障等。
调试器使用技巧
每种语言都有各自可使用的各种IDE,IDE自带调试器。
命令行调试器:gdb。
编译代码时把符号信息包含进来,以便于调试
单步调试
熟悉常用的IDE的调试快捷键。
要点:
- 通过单步调试来查看语句的执行顺序及程序的状态;
- 为了提升调试速度,直接跳过某些与Bug无关的部分,而不用进入其中;
- 如果发现程序所经过的某个例程有问题,那就给该例程设置断点,重新运行程序,并进入例程中进行单步调试,以求缩小有待排查的范围。
设置代码断点和数据断点
有些Bug,只有当程序沿着特定的路径执行时,才会表现出来。即需要满足特定的前置条件。
还有一类代码断点也很有用,它们令你能够在程序崩溃之前,有机会查看当前的状态。
迷途指针:stray/wild/dangling pointer,流浪/野/悬挂指针,可能会改动内存中任意位置上面的数据。
所幸当前的CPU都提供适当的机制,使得调试器能够通过指定内存位置及对应的变量大小来设置数据断点。每次发生内存写入操作时,CPU就检查正在写入的这个地址,是否位于指定的数据断点范围之内,如果确实位于该范围内,那么就打断程序的执行过程,并将其控制权交给调试器。由于CPU的这种检查机制与普通的内存写入操作是同时发生的,因此通常不会拖慢程序的执行速度。
调试器还能设置带有条件的断点(必须满足一定条件)、带有命中次数的断点(其出现次数必须达到一定值),以及带有过滤器的断点(必须位于特定的线程之内)。这些断点机制在某些情况下很好用,但如果你过份依赖它们,那就说明你应该考虑改用更为强大的调试手段,如精准的测试用例或丰富的日志记录等。
反向调试
一般来说,为了实现逆向执行(反向调试),调试器需要把每条指令对程序状态所做的修改记录下来,然后在逆向执行时,撤销这些修改。
gdb的几条命令:
- reverse-next:反向执行
- reverse-step:不会直接把某个例程执行完,而是会进入那个例程里面进行单步执行
- reverse-continue:可使程序一直反向执行下去,直至遇到断点
反向执行的距离有限度。
查看例程之间的相互调用情况
查看变量及表达式的值,以寻找程序中的错误
了解怎样把调试器连接到正在运行的进程上
很难重现的Bug,生产可重现,开发环境无法重现。对于Java来说,可远程调试。
要点:
- 把调试器连接到正在运行的进程上面,以便对其进行调试;
- 通过远程调试机制,对运行在资源受限设备上面的应用程序进行调试。
核心转储信息
运行在Unix系统上面的原生应用程序能够生成核心转储文件,这是系统对应用程序崩溃时的情况所做的内存镜像。
把调试工具设置好
一些思路:
- 使用图形化的用户界面;
- 把调试时所用到的一些实用命令保存到文件里面,以后每次开始调试时,都把这份文件执行一遍。
DDD:Data Display Debugger,基于Unix的前端工具,含有完备的图形界面,支持gdb,还支持其他一些运行在命令行界面中的调试器,还可以很好地展示程序的数据结构。
要点:
- 使用带有图形界面的调试器;
- 对gdb进行配置,使它能够把输入过的命令保存下来,并设置一套符合自己使用习惯的快捷键;
- 把常用的命令放在gdb脚本中;
- 修改完源代码之后,可以不重新启动gdb,而是直接在gdb里面构建程序,以便保留你在这次调试会话中所输入过的命令。
学会查看汇编代码及原始内存