测试工具与原理

开始调试之前

  • 机器永远是对的: 不管是crash, Wrong Answer了, 还是虚拟机神秘重启, 都是自己背锅
  • 未测代码永远是错的: 你意味最不可能出bug的地方, 往往bug就在那躺着

调试理论

“软件”的两层含义

  • 人类需求在信息世界的投影
    • 理解错需求 -> bug
  • 计算过程的精确(数学)描述
    • 实现错误 -> bug

调试困难的根本原因

因为bug的触发经历了漫长的过程

  • 需求->设计->代码(状态机)->Fault(bug)->Error(程序状态错)->Failure
    • 我们只能观测到failure(可观测的结果错)
    • 我们可以检查状态的正确性(但非常费时)
    • 无法预知bug在哪里(每一行”看起来”都挺对的)

首先, 机器永远是对的. 如果程序出了错, 先怀疑自己的代码有bug. 比如由于你的疏忽, 你编写了if(p==NULL)这样的代码.但执行到这一行代码的时候, 也只是p被赋值成NULL, 程序还会往下执行. 然而等到将来对p进行了解引用的时候, 才会触发段错误,程序彻底崩溃.

我们可以从上面的例子中抽象出一些软件工程的相关概念:

  • Fault: 实现错误的代码, 例如if(P==NULL)
  • Error: 程序执行时不符合预期的状态, 例如p被错误地赋值成NULL
  • Failure: 能之间观测到的错误, 例如程序触发了段错误

调试其实就是从预测到的failure一步一步回溯寻找fault的过程, 找到了fault之后, 我们就很快知道应该如何修改错误的代码了. 但从方面的例子也可以看出, 调试之所以不容易, 恰恰 是因为:

  • fault不一定马上触发error
  • 触发了error也不一定马上转变成可预测的failure
  • error会像滚雪球一般越积越多, 当我们预测到failure的时候, 其实已经距离fault非常遥远了

何为调试理论

如果我们能判定任意程序状态的正确性, 那么给定一个failure, 我们可以通过二分查找定位到第一个error的状态, 此时的代码就是fault(bug)

  • 调试理论:推论
    • 为什么我们喜欢”单步调试”?
      • 从一个假定正确的状态出发
      • 每个语句的行为优先, 容易判定是否是Error
    • 为什么调试理论看起来很没用?
      • 因为判定程序状态的正确性非常困难

实际上的调试

观察状态机执行(trace)的某个侧面

  • 缩小错误状态(error)可能产生的位置
  • 作出适当的假设
  • 在进行细粒度的定位和判断

最重要的两个工具

  • printf -> 自定义log的trace
    • 灵活可控, 能快速定位问题大概位置, 适用于大型软件
    • 无法精确定位, 大量的logs管理起来比较麻烦
  • gdb -> 指令/语句级trace
    • 精确, 指令级定位, 任意查看程序内部状态
    • 耗费大量时间

调试理论给了大叫在遇到”任何问题”时候self-check的列表

  1. 是怎样的程序(状态机)在运行?
  2. 我们遇到了怎么的failure?
  3. 我们能从状态机的运行中从易到难得到上面信息?
  4. 如何二分检查这些信息和error之间的关联?

调试”任何问题”

计算机世界: 一切皆可调试

程序 = 计算机系统 = 状态机

  • 机器永远是对的
  • UNIX世界里你做任何事情都是在编程
    • 因此配置错, make错等, 都是程序输入/配置有bug
    • 输入/配置可以看出是程序的一部分

所有问题都可以用调试理论解决

使用调试理论

Debug(fault localization)的基本理论回顾:

  • Fault(程序/输入/配置错) -> Error -> Failure(可观测)
    • 绝大部分工具的Failure都有”原因报告”
      • 因此能帮助你快速定位Fault
    • man perror: 标准库有打印error message的函数

如果问题不能帮你定位到fault/error?

  • 出错原因报告不准确或不够详细
  • 程序执行的过程不够详细
    • 既然我们有需求, 那别人肯定也会有这个需求
      • 一定有消息能帮助我们

正确的方法: 理解程序(状态机)的执行过程

  • ssh: 使用-v选项检查日志
  • gcc: 使用-v选项打印各种过程
  • make: 使用-n选项查看完整命令

各个工具普遍提供调试功能, 帮助用户/开发者了解程序的行为

标准的解决问题办法: 自己动手排查, 在面对复杂/小众问题时比STFW/ChatGPT有效

只要能在”检查状态机的状态”, 就可以调试/诊断计算机系统中遇到的”任何问题”, 有些看似莫名情况的问题, 在trace/log的帮助下, 也就自然有了头绪

Take-away Messages

调试理论: bug是如何发生的?

  • Fault -> (测试) -> Error -> (断言) -> Failure
  • 调试一切问题的checklist
    1. 是怎样的程序(状态机)在运行?
    2. 我们遇到了怎么的failure?
    3. 我们能从状态机的运行中从易到难得到上面信息?
    4. 如何二分检查这些信息和error之间的关联?

残酷的现实和难听的本质

  • 道理都懂, 但出bug了, 还是不知道怎么办? 一句难听的话: 你对代码的关键部分还不熟悉. 根据调试理论, 你还不知道如何判定此程序状态是否正确, 或是对如何简化程序状态还没有经验:
    • 编程基础不牢固
    • 对项目代码不理解
    • 根本的原因: 抱有”我不理解这个也行”的侥幸心理

调试策略

在了解了这些原因之后, 我们就可以制定相应策略:

  • 尽可能把fault转变成error.
  • 尽早观测到error的存在. 观测到error的时机直接决定了调试的难度: 如果等到触发failure的时候才发现error的存在, 调试就会比较困难; 但如果能在error刚刚触发的时候就观测到它, 调试难度也就大大降低了. 事实上, 你已经见识过一些有用的工具了:
  • -Wall, -Werror: 在编译时刻把潜在的fault直接转变成failure, 这种工具的作用很有限, 只能寻找一些在编译时刻也觉得可疑的fault, 例如if(p=NULL). 不过随着编译器版本的增强, 编译器也能发现代码中一些未定义行为.
  • assert(): 在运行时刻把error之间转变成failure. assert()是一个很简单却又非常强大的工具, 只要在代码中 定义好程序应该满足的特征, 就一定能在运行时刻讲不满足这些特征的error拦截下来. 例如链表的实现, 我们只需要在代码中插入一些简单的assert()(例如程序解引用时不为空), 就能够几乎告别段错误. 但是, 编写这些assert()其实需要我们对程序的行为有一定的了解, 同时在程序特征不易表达的时候, assert()作用也较为有限.
  • printf: 通过输出的方式观察潜在error. 这是用于回溯fault时最常用的工具, 用于观测程序中的变量是否进入了错误的状态.
  • 宏Log(), 它实际上封装了printf()的功能, 但由于printf()需要根据输出的结果人工判断是否正确, 在便利程度上相对于assert()的自动判断逊色了不少.
  • GDB: 随时随地观测程序的任何状态. 调试器是最强大的工具, 但你需要在程序行为的茫茫大海中观测那些可疑的状态, 因此使用起来的代价也是最大的

根据上面的分许, 我们就可以总结出一些调试的建议:

  • 总是使用-Wall和-Werror
  • 尽可能多地在代码中插入assert()
  • assert()无法捕捉到error时, 通过printf()输出可疑的变量, 期望能观测到error
  • printf()不易观测error时, 通过GDB理解程序的精确行为