2023年1月

调试的关键在于推断程序内部的错误位置及原因,可以采用以下方法:

1、分析和推理

设计人员和开发人员根据软件缺陷问题的信息, 分析和推理调试软件。

(1)根据软件程序架构自顶向下缩小定位范围, 确定可能发生问题的软件组件。

(2)根据软件功能,软件运行时序定位软件问题。

(3)根据算法原理,分析和确定缺陷问题发生的 根源。

2、归纳类比法

归纳法是一种从特殊推断一般的系统化思考方法,归纳法调试的基本思想是:从一些线索(错误征兆)着手,通过分析它们之间的关系来找出错误。该方法主要是根据积累的工作经验和案例处理调试工作。

(1)根据工作经验和比对程序设计中类似问题的 处理方式进行调试工作。

(2)咨询相关部门和有经验的相关人员。

(3)查找相关文档和案例,为处理问题提供思路 和方法。 在软件开发过程中,通常对每个缺陷问 题进行跟踪管理,将解决问题的方案和过程详细记录。

(4)收集出错的信息,列出数据,包括输入,输出,归纳整理,发现规律,从线索除法,寻找线索之间的联系。也就意味着:从特殊到一般。归纳调试的步骤可以概括为以下一个图

3、跟踪回朔

在小程序中常用的一种有效的调试方法,一旦发现了错误,人们先分析错误的征兆,确定最先发现“症状“的位置然后,人工沿程序的控制流程,向回追踪源程序代码,直到找到错误根源或确定错误产生的范围,例如,程序中发现错误处是某个打印语句,通过输出值可推断程序在这一点上变量的值,再从这一点出发,回溯程序的执行过程,反复思考:“如果程序在这一点上的状态(变量的值)是这样,那么程序在上一点的状态一定是这样···“直到找到错误所在。

在软件开发通常采用基线与版本管理。 基线为 程序代码开发提供统一的开发基点,基线的建立有助 于分清楚各个阶段存在的问题,便于对缺陷问题定位。 软件版本在软件产品的开发过程中生成了一个版本 树。 软件产品实际上是某个软件版本,新产品的开发 通常是在某个软件版本的基础上进行开发。

(1)开发过程中发现有问题,可以回退至版本树上的稳定版本,查找问题根源。

(2)通过基线版本序列可以追踪产品的各种问 题,可以重新建立基于某个版本的配置,可以重现软件 开发过程中的软件缺陷和各种问题,进行定位并查找 问题根源。

4、增量调试

软件开发大多采用软件配置管理和持续集成 技术。 开发人员每天将评代码提交到版本库。 持续集 成人员完成集成构建工作。 可以通过控制持续集成的 粒度(构建时间间隔),控制开发人员提交到版本库的 程序代码量,从而便于对缺陷问题定位。 通常每天晚 上进行持续集成工作,发现问题时,开发人员实际上只需要调试处理当天编写的代码。

5、写出能重现问题的最短代码

采用程序切片和插桩技术写出能重现问题的最短 代码调试软件模块。

(1)程序切片
程序切片是通过在特定位置消除那些不影响表达 式计算的所有语句,把程序减少到最小化形式,并仍能 产生给定的行为。 使用切片技术,可以把一个规模较 大并且较复杂的软件模块转换成多个切片程序。 这些 切片程序相对原来的程序,简单并且易于调试和测试。

(2)程序插桩
程序插桩方法是在被测程序中插入某些语句或者 程序段来获取各种信息。 通过这些信息进一步了解执 行过程中程序的一些动态特性。 一个软件组件的独立 调试和测试需要采用插桩技术,该组件调用或运行需 要桩模块。 在软件模块的调试过程中程序切片和程序插桩可 以结合起来使用。

6、日志追踪技术

日志是一种记录机制,软件模块持续集成构建过 程中,日志文件记录了有用信息。 若构建失败,通过查 看日志文件,将信息反馈给相关人员进行软件调试。

7、调试和测试融合的技术

(1)测试驱动开发

测试驱动开发是一种不同于传统软件开发流程的 开发方法。 在编写某个功能的代码之前先编写测试代 码,然后编写测试通过的功能代码,这有助于编写简洁 可用和高质量的代码。

(2)开发与测试融合

程序开发人员除了进行程序代码的编写,白盒测 试,也要完成基本的功能测试设计和执行。 这样有助 于程序开发人员更好地开展调试工作。 程序开发人员 可以通过交叉测试来解决测试心理学的问题(不能自 己测试自己)。 采用这种模式测试人员的数量会减少,专业的测 试人员去做其他复杂的测试工作。 研发中的很多低级 缺陷会尽早在开发过程中被发现,从而减少缺陷后期 发现的成本。

8、强行排错

这种调试方法目前使用较多,效率较低,它不需要过多的思考,比较省脑筋。例如:

(1)通过内存全部打印来调试,在这大量的数据中寻找出错的位置。

(2)在程序特定位置设置打印语句,把打印语句插在出错的源程序的各个关键变量改变部位,重要分支部位,子程序调用部位,跟踪程序的执行,监视重要变量的变化

(3)自动调用工具,利用某些程序语言的调试功能或专门的交互式调试工具,分析程序的动态过程,而不必修改程序。

应用以上任一种方法之前,都应当对错误的征兆进行全面彻底的分析,得出对出错位置及错误性质的推测,再使用一种适当的调试方法来检验推测的正确性。

9、演绎法调试

演绎法是一种从一般原理或前提出发,经过排除和精华的过程来推导出结论的思考方法,演绎法排错是测试人员首先根据已有的测试用例,设想及枚举出所有可能出错的原因作为假设,然后再用原始测试数据或新的测试,从中逐个排除不可能正确的假设,最后,再用测试数据验证余下的假设确是出错的原因。

(1) 列举所有可能出错原因的假设,把所有可能的错误原因列成表,通过它们,可以组织,分析现有数据

(2) 利用已有的测试数据,排除不正确的假设

仔细分析已有的数据,寻找矛盾,力求排除前一步列出所有原因,如果所有原因都被排除了,则需要补充一些数据(测试用例),以建立新的假设。

(3)改进余下的假设

利用已知的线索,进一步改进余下的假设,使之更具体化,以便可以精确地确定出错位置

(4)证明余下的假设

这就个规则来自于书籍《调试九法:软硬件错误的排查之道》,记录下来:

规则1:理解系统

你必须掌握系统的工作原理以及它是如何设计的,在某些情况下还要知道为什么这样设计。如果你没有理解系统中的某个部分,那么这通常是出问题的地方。(这不仅仅是墨菲定律的问题,如果你不能理解你所设计的系统,你的工作可能会变得一团糟)。

如何理解系统呢?

  • 阅读手册
  • 逐字逐句阅读手册,仔细理解每个细节
  • 知道什么是正常的,知道什么是正常的可以帮助你注意到什么是不正常的
  • 知道工作流程,要理解业务,要讲系统的工作过程对应到具体要解决的现实问题
  • 选择合适的工具,选择合适的辅助(监控、插桩)工具可以帮你理解系统
  • 查阅细节,经验有时候会骗人,记忆有时候会出错

规则2:制造失败

这一点比较容易理解,就是问题复现,在日常工作中,你在排查一个问题的过程中,最重要的一步就是复现问题——能复现的问题都能解决。

这里有几个要点需要注意:

  • 引发失败,而不要模拟失败,不要尝试用不同的方式去模拟问题,而要模拟和构建引发bug发生的条件
  • debug的动作,不要影响错误的发生方式,可以影响错误的发生频率
  • 从头开始,需要有一个正常的状态到不正常的状态的过程,从开始正常的状态开始观察,直到问题发生;
  • 终极方案,控制变量法,将可能引发错误的因素依次排除;排除所有可能的原因后,剩下那个答案,无论多么不可思议,都是事实。

规则3:不要想,而要看

亲眼看到底层的失败是非常重要的,如果你猜测失败是如何发生的,那常常会修复一些根本不是bug的问题。

在软件世界里,观察意味着设置断点、添加调试语句、监视程序值以及检查内存;在医学领域,需要测试血样和进行X光透视。

对细节的观察应该到什么程度合适呢?简单的答案是:一直观察,直到把问题的原因锁定在几种可能之内。

在系统设计的时候,就要考虑到将来调试、排查问题的情况,将日志视为系统设计的一部分—打印一些关键日志,或者设计一些打开日志的开关,以便在生产环境针对某个case进行调试。

日常生活中有很多插桩的case:

  • 体温计测量体温
  • 自行车轮胎漏气时,都是将轮胎打满气,然后放在水里检查哪里漏气
  • 天然气中加入了臭鸡蛋的气味

规则4:分而治之

反复将问题分成好的一半和坏的一半,然后缩小搜索范围,然后进一步研究有问题的那一半链路。

规则5:一次只改一个地方

初中就学过的控制变量法。 在修改bug时候,如果某个改动没有修复bug,就应该立即把它改回来。

规则6: 保持审计跟踪

记下你的每步操作、顺序和结果; 魔鬼藏在细节中; 将一些事情关联起来思考; 好记性不如烂笔头;

规则7:检查插头

一些显而易见的假设可能是错误的;是不是运行了正确的代码?是不是打了正确的包?插头是不是掉了?从一些最基本的问题开始确认,很多时候问题就出在这里。对自己使用的工具进行测试,因为工具也是一种软件,难保不会出问题。

规则8:获得全新观点

向别人解释问题的过程,会让你对问题进行重新的梳理和理解,这时候可能发现之前没有发现的问题。

bug发生了,以除掉bug为自豪,而不是非得以自己除掉bug才自豪。

不管你是跟什么人求助,或者需要别人什么样的帮助(征求意见、获取专业知识、听取经验),在向别人描述问题的时候,一定要记住一件事——报告症状、而不是讲你的理论;另外,有些症状你可能不是十分确定,也可以描述出来。

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

如果你不修复bug,它不会自动消失。按照前面的规则解决问题后,要进行一次回归验证,确保已经修复问题,并且没有引入新的问题。

《调试九法:软硬件错误的排查之道》

调试符号文件(pdb)是一种很复杂的文件,由于这种文件格式微软并不公开,所以至今为止,并没有一篇文章或资料敢说自己对pdb文件进行了深入剖析。更重要的原因是,我们为了研究调试技术,需要知道一些系统(操作系统,编译器,连接器,调试器等)调试支持,仅仅知道即可,没必要深究微软为了实现调试而做出的每一个细节。
首先,我先问几个问题:

  1. 我们经常用的调试方法,下断点,是如何实现的呢?
  2. 我们可以在程序还没有执行起来的时候就可以下断点,等调试启动的时候,就可以命中这个断点。这个是怎么实现的?
  3. 当断点命中时,我们可以观察一个变量的值,这是怎么实现的?

先简单讲解本文中用到的两个概念:

  1. OFFSET,文件中的偏移。
  2. VA,程序加载到内存后的一个虚拟地址。

假设在一个EXE文件中,有一个全局变量a,距离文件起始的偏移为0x10,此时文件的起始位置为0x00000000,那么该全局变量a的OFFSET就是0x00000010。当这个exe执行起来,加载到内存后,这个exe本身所加载到的内存位置称为基地址。假设基地址为0x00400000,那么这个全局变量a的VA便是0x00400010。可见,exe本身所加载到的基地址不一样的话,那么a的VA就不能确定。
依然使用最简单的例子,来阐述原理,代码如下:

 

可以观察到,此时,笔者并未调试启动程序,而这个断点,就已经打上了。接下来我们调试启动程序,如下图:

 

此时,我们已经进入断点,并中断下来,我们可以观察到全局变量g_nVar的值。想必这个过程,有过VC++开发经验的开发者,再熟悉不过了。下面,详细分析一下这个过程。
当我们鼠标点击下断点的时候,我们的程序还没有启动,VS是不可能知道这个断点应该打在内存中的哪一条指令的地址处的(此时,VS顶多知道断点所在的OFFSET,但是无法知道断点所在的VA),但是VS可以记录到一条重要的信息,就是当前断点在哪个源文件的哪个行号上。
接下来,我们调试启动程序,exe的镜像加载到内存后,所有代码段的指令的VA便是真实可用的了。但此时调试器是如何根据断点所在源文件和行号,来找到断点所在的VA的呢?现在,你应该想到本文在讲什么,哈哈,就是pdb啦。那么pdb文件中到底存了什么,才让调试器可以根据源文件及行号来找到对应的VA呢?

默认情况下,在pdb文件中,保存了可执行文件中所有的符号(函数名、变量名等)所在源文件、行号、OFFSET等信息。但是这些信息,是在什么时间得到的呢?很明显是编译阶段,编译器在编译每个cpp的过程中,就可以把这些符号的相关信息收集起来,存放在各个cpp所生成的obj文件中,然后在链接的时候,提取每个obj中的这些信息,生成一个单独的pdb文件。这样,以后调试程序的时候,调试器只要找得到这个pdb,就可以知道可执行文件中,所有符号所在的源文件、行号和OFFSET了。反过来说,当给出一个源文件和行号,就可以拿到对应的OFFSET了,所以在还没有启动调试的时候,我们下的断点,实际上调试器是知道这个断点应该在哪个OFFSET上了,等启动调试的时候,用这个OFFSET加上这个模块所加载到的基地址值,就可以得到这个断点所在的VA了,然后在这个VA处强行写上int
3指令,并继续执行,当执行到这里,便中断下来给我们一个调试机会了。想想,如果没有pdb,这个断点还能用么?
当我们鼠标放在某个变量上时,调试器可以拿到这个变量的名称,根据我们前面说的,用这个名称去pdb中查找,自然就可以找到pdb文件中保存的OFFSET了,加上这个模块的基地址,就找到了这个变量所在内存的VA,剩下的就是读一下这个VA内存中的内容了。这样也就实现了观察变量值得功能。
下面证实一下,pdb文件中确实存储了源文件、行号、OFFSET等信息。将上面例子代码放到VC6中编译,然后到debug目录中使用dumpbin来查看CodeTest.obj文件中的符号信息,如图:

 

可见,add函数和main函数所在行号和起始行号和结束行号都是有记录的,那源文件是哪个呢?哈哈,当然是CodeTest.cpp了,我们查看的是CodeTest.obj文件嘛。。。
但是这里并没有add或者main函数的OFFSET啊,为什么呢?想想,此时只有一堆obj,真正的可执行模块还没有生成出来呢,何来的可执行模块的OFFSET呢。。。由此可以知道,这个OFFSET要在链接过程中,才可以确定。经过了链接之后,这些本来在obj里的调试信息,也就被收集到pdb文件中了,下面我们来找找add函数的OFFSET到底在哪里?使用SymView工具打开CodeTest.pdb文件,如下图:

 

可见,pdb中存储了add函数相关信息,不仅仅只有offset,而且此处并未直接记载add函数在哪个cpp里,这些关系都是通过索引来查找的,其实pdb文件的内部结构是很复杂的,要想解释清楚,其实很不容易,大家如果想知道pdb内部到底都有什么东西,可以参考一下《软件调试》第25章,但也是讲了个大概。

在我们观察的过程中,我们可以发现两个很主要的特征:

  1. 可执行模块中,保存了当前模块的调试符号文件的路径,而且是绝对路径,如下图:
  2. Pdb文件中保存了每个cpp文件的路径,而且也是用的绝对路径。如下图:

 这样,我们可以得出一个结论:

  1. 在同一台开发者的机器上,如果被调试的exe放在了其他目录里,而pdb依然在原来生成时所在的位置,那么调试exe时,依然可以找到对应的pdb文件。
  2. 如果exe和pdb都换了路径,只要调试的时候,我们手动指定了pdb所在的位置,如果源码文件还在原来的路径,那么调试时,依然可以找得到源码文件。

Diomidis Spinellis是《代码阅读与代码质量》一书的作者。在GOTO阿姆斯特丹2016大会上,他就如何有效地调试软件和预防错误做了演讲。InfoQ采访了Spinellis,内容涉及发现和修复软件中的错误、软件调试的原则、如何提高调试效率、如何编写出不怎么需要调试的代码以及管理人员如何为错误预防和处理提供支持。

InfoQ:是什么让软件错误的发现和修复如此困难?

Diomidis Spinellis:如果你认为编写代码困难,那就尝试下代码调试吧。你编写了一个新的函数或方法,并加上一些语句形成某个只是貌似正确的东西。当你调试一个程序的时候,你要面对数千或数百万行貌似正确的代码,并设法找出其中的错误。这必定要困难许多。然后,你还要应对各种系统和层次之间的复杂交互、每秒执行数十亿次的CPU指令、难以再现的Bug以及来自生产环境系统的压力。

而且,课堂上很少教调试;从一切可能出错的东西辛苦得来的经验很难压缩到一次演讲中。此外,由于系统失败的方式各不相同,你必须不断地改进和调整你所用的工具和方法。你可以从观察开始,继之以数据分析,然后做一些试验,最后推断出Bug的原因。没有什么标准的初学者技能。


InfoQ:软件调试有什么一般原则吗?


Spinellis:很遗憾,由于软件会出现各种难以想象的错误情况,我不认为有什么可以在软件调试过程中遵循的一般原则。退而求其次,我归纳出了三大类方法:

高级策略,比如由故障特征推断出原因,或者确保某些代码满足了其前提条件;

方法和实践,比如确保Bug可以有效地再现,或者着重突出故障的影响;

通用工具,比如Unix命令行工具、跟踪(考虑下strace、dtrace和systemtap)工具和版本控制系统。


InfoQ:程序员做什么能够提高调试效率?


Spinellis:首先要为调试成功做好准备。让自己相信问题将会得到解决,留出足够的时间用于调试,不要分心,要坚持不懈,必要的时候,留待第二天解决。重要的是,要不断地在环境、工具和知识上投入。购买高效工作所需的软件和硬件。例如,如果软件生成大量的调试日志文件,你就应该有足够的磁盘空间、CPU处理能力和带宽,以便可以高效地处理它们。在调试的过程中,你很容易遇到千奇百怪的问题,因此,花些精力管理和优化自己的环境和工具配置。这包括按键绑定、别名、辅助脚本、快捷方式和工具配置;所有这些都可以显著地提高调试生产力。


InfoQ:有什么技术或方法可以编写出不怎么需要调试的代码吗?


Spinellis:当然!编写可维护的代码——可读、稳定、易于分析和修改的代码——带来的Bug也比较少。此外,像单元测试、代码审查这样的方法以及使用断言都有助于最小化进入生产环境的错误。

在设计时使用高级抽象(例如使用一个框架的算法或容器数据结构,而不是选择自己开发一种方案)可以减少代码和错误。另外,让程序易于调试也很重要。这包括为详细地记录日志提供便利,当出现内部错误时报告丰富的上下文信息,并将问题及崩溃的详细信息发送到一个中央存储库。


InfoQ:管理人员如何为组织里的错误预防和处理提供支持?


Spinellis:设定基本的过程有助于确保软件错误不会失控。部署并采用一个问题跟踪系统,用它把要处理的问题分类并排定优先级。将软件变更恰当地记录在进一个运作良好的版本管理系统里,并将它与问题跟踪系统联系起来;我经常仅仅通过仔细研究一个文件的历史和变更就修复了Bug。在软件建设方面,推广单元测试的应用,把软件的构建性能分析、静态分析和动态分析包含进来,并维护一个快速、精益、均衡的构建-测试周期。这有助于帮助开发人员尽早捕获Bug,并迅速修复。最后,在运维方面,逐步部署软件,允许新旧版本对比,努力确保所使用的工具和所部署的环境的多样性,并有组织地升级工具和库。

转自:http://lf.lnu.edu.cn/detail.jsp?id=55243