MIT_6.031之系统调试

系统调试的过程

在整个系统连接在一起后发现错误,往往是在用户报告问题后进行的调试。以下是处理这类问题的步骤:

1. 重现错误

重现错误是调试过程中至关重要的一步。以下是详细步骤和示例,帮助理解如何有效地重现错误:

找到可重复的测试用例

  • 首先,定义问题
    • 用户报告的问题可能不够具体,您需要明确知道错误发生的上下文。例如,如果用户发现应用程序崩溃,您需要了解崩溃发生时的操作步骤。
  • 创建小的测试用例
    • 设法从用户的使用场景中提取出最小的、可重复的代码片段。例如,如果用户报告在处理长文本时出现问题,可以尝试创建一个只包含必要文本的小字符串作为输入。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class WordCounter {
public String mostCommonWord(String text) {
// 逻辑处理
return "unexpected"; // 假设错误结果
}
}

// 测试用例
public class WordCounterTest {
@Test
public void testMostCommonWord() {
WordCounter wc = new WordCounter();
String result = wc.mostCommonWord("the quick brown fox jumps over the lazy dog");
assertEquals("the", result); // 期望结果
}
}

挑战

  • 多线程和事件依赖
    • 如果程序是多线程的,错误可能与线程的执行顺序相关。例如,在图形用户界面应用中,某些操作可能依赖于用户的点击顺序或系统事件的发生。

示例

1
2
3
4
5
6
7
8
9
public class ButtonClick {
public void onClick() {
// 多线程操作
new Thread(() -> {
// 可能导致竞争条件的代码
processData();
}).start();
}
}

在这种情况下,您可能需要使用工具如 JUnitMockito 来模拟并发情境,确保一致性。

回归测试

  • 将测试用例添加到回归测试套件
    • 在成功修复错误后,确保将测试用例添加到您的回归测试中,以便将来可以自动检测该错误是否重新出现。

示例

1
2
3
4
5
6
7
8
public class RegressionTestSuite {
@Test
public void regressionTestMostCommonWord() {
WordCounter wc = new WordCounter();
String result = wc.mostCommonWord("the quick brown fox jumps over the lazy dog");
assertEquals("the", result); // 确保修复后仍然正确
}
}

2. 减少错误范围

  • 缩小问题范围
    • 如果用户传递了莎士比亚剧本的整个文本并发现错误,例如在调用 mostCommonWord(allShakespearesPlaysConcatenated) 时未返回预期结果(如“the”或“a”),而是返回意外结果(如“e”),需要通过分步调试来缩小错误范围。
  • 分步调试方法
    • 检查上半部:确定莎士比亚的上半部是否显示相同错误。
    • 单个剧本:检查单个剧本是否产生相同错误。
    • 单个语句:进一步细化到单个语句,查看是否会重复错误。
  • 回归测试样例
    • 将简单的测试样例加入回归测试,确保日后不会再次出现相同问题。

3. 了解错误的位置和原因

  • 研究数据
    • 查看导致错误的测试输入、错误结果、失败的断言及其对应的堆栈跟踪。
  • 假设
    • 提出假设,找出错误可能发生的地方,确保假设尽可能普遍。

4. 实验与重复

  • 设计实验
    • 设计实验以测试假设,观察问题并尽量减少对原系统的干扰。
  • 收集数据
    • 将实验中收集的数据与原始内容结合,形成新的假设。通过实验,逐步排除一些可能性,并缩小错误位置和原因的范围。

5. 分析堆栈跟踪

阅读堆栈跟踪是调试过程中的关键部分,以下是如何解读的示例:

1
2
3
4
5
6
java.lang.NullPointerException
at java.util.Objects.requireNonNull(Objects.java:203)
at java.util.AbstractSet.removeAll(AbstractSet.java:169)
at turtle.TurtleSoup.drawPersonalArt(TurtleSoup.java:29)
at turtle.TurtleSoupTest.testPersonalArt(TurtleSoupTest.java:39)
...
  • 最上层调用:通常在库代码内部(如 java.util),表示抛出异常的地方。
  • 自己的代码:在堆栈中间,指向自定义代码(如 turtle.TurtleSoupTest.testPersonalArt),需重点关注。

解读步骤

  1. 识别异常类型
    • 堆栈跟踪的第一行通常包含异常类型。在这个例子中,java.lang.NullPointerException 表示程序试图访问一个为 null 的对象。
  2. 查看最上层调用
    • 堆栈跟踪的最上层是引发异常的地方,通常是在库代码内部。这是异常首次发生的位置。在上面的例子中,错误发生在 java.util.Objects.requireNonNull(Objects.java:203)。这行代码尝试确保输入不为 null,因此您需要检查是什么导致了这个输入为 null
  3. 跟踪调用链
    • 向下查看调用栈中的每一行,以了解程序的执行顺序:
      • at java.util.AbstractSet.removeAll(AbstractSet.java:169) 表示在尝试移除集合中的元素时,也可能存在空引用的问题。
      • at turtle.TurtleSoup.drawPersonalArt(TurtleSoup.java:29) 表示在 TurtleSoup 类的 drawPersonalArt 方法中调用了 removeAll,您需要检查该方法在第 29 行的实现,以了解输入数据来源。
      • at turtle.TurtleSoupTest.testPersonalArt(TurtleSoupTest.java:39) 显示错误是由测试用例触发的,因此您还应该检查测试代码,确保传递给 drawPersonalArt 的参数都是有效的。
  4. 聚焦于自己的代码
    • 自定义代码的位置在堆栈的中间和底部部分。在这个示例中,turtle.TurtleSoupTest.testPersonalArt 可能是一个测试方法,您需要重点关注这个测试用例,分析其如何调用 drawPersonalArt 以及输入参数是什么。
  5. 研究数据
    • 检查导致异常的输入数据。根据 drawPersonalArt 方法的实现,查找可能的 null 引用并加以处理。可以通过调试器或在关键代码段插入打印语句来检查变量值。
  6. 假设和实验
    • 根据分析,提出可能的假设。例如,您可以假设某个参数未初始化,或者某个集合在调用 removeAll 前未正确定义。然后设计实验来验证这些假设,比如在测试中添加断言来确保输入不为 null

6. 假设与实验

  • 假设错误来源
    • 假设错误可能存在于 splitIntoWords() 函数中,并进行实验验证该假设。
  • 二分搜索
    • 通过二分搜索方法,将工作流分成两半,优先验证旧代码相对新代码的可靠性。

7. 运行其他测试用例

  • 插入打印语句或断言
    • 在程序中插入打印语句或断言,以检查内部状态。
  • 使用调试器
    • 设置断点,单步执行代码,观察变量和对象的值。

8. 避免不当做法

  • 不要将错误视为特殊案例
    • 避免将错误作为特殊案例解决,因为这会增加复杂性并掩盖问题。

9. 更换组件

  • 组件替换
    • 在怀疑特定组件时,尝试更换实现(例如,用线性搜索替代二分搜索)。但需谨慎,避免不必要的时间浪费。

10. 修正错误

  • 识别错误类型
    • 判断是编码错误还是设计错误。设计错误可能需要更全面的审查,以查看其他客户端是否受到影响。
  • 影响分析
    • 考虑修复程序是否可能引入新的问题。

11. 回归测试

  • 将错误的测试用例添加到回归测试套件,并运行所有测试以验证修复是否有效。

12. 总结

  • 错误重现
    • 将错误作为测试用例重现并纳入回归测试中。
  • 科学方法
    • 采用科学方法发现和修复错误,确保认真且不草率的修正,记录详细笔记以备后续查阅。