调试理论与实践
背景回顾:在快节奏的《操作系统》课中,插入一些 (重要得要命但与操作系统不完全相关的) 休闲内容。
本讲内容:调试理论:Fault, Error 和 Failure,以及我们应该如何应对程序里层出不穷的 bugs。
调试理论
如果我们已经知道 bug 的存在, 如何找到它 ?
摆正心态 - 调试两大公理
- 公理 1 : 机器永远是对的
- 因为机器是 无情执行指令的客观存在
- 代码出现 bug 之后:
- 99.99 % 是你编程层面的问题
- 亿点点概率是编译器的错误优化 (但你可以知道)
- 亿点点点点概率是硬件层面上的错误 (但你也可以知道)
- 公理 2 : 未测代码永远是错的
- 反复测试过的代码都有可能是错的
- 我们 "默认" 不可能出现 bug 的地方,往往 bug 就在那里躺着
- 并发 bug 的触发需要:编译器 + 编译选项 + 特别的机器 + 特别的运气
直面 Fault,Error,Failure
"软件" 这个词有两方面的含义:
- 人类需求在信息世界上的 投影
- 需求分析错误 -> bug
- 计算过程中的 精确数学描述
- 实现错误 -> bug
调试软件为什么困难?
- Bug 的触发经历了漫长的过程 (Fault, Error)
- 可观测的现象未必能直接对应到 root cause 上
- 我们只能观测到 failure (可观测的结果错)
- 我们可以检查状态的正确性 (但非常费时)
- 无法预知 bug 在哪里 (每一行 “看起来” 都挺对的)
调试理论 & 简单推论
调试理论
如果我们能判定任意程序状态的正确性,那么给定一个 failure,我们可以通过 二分查找 (听起来很诱人,我们可以在
这条理论 (个人认为) 基于如下假设:
- 程序 / 指令执行的顺序性 (程序状态从 Correct 到 Error 只需要 错误的一步 !)
- 你真的能够判定 任意 状态的正确性吗 ? ( DP ? 图论 ? 😭)
推论 :
- 为什么我们喜欢 “单步调试”?
- 从一个假定正确的状态出发
- 每个语句的行为有限,容易判定是否是 error
调试理论其实已经指明了一个我们经常在用 (但是可能没有意识到的) 调试方法 —— 分块 。
我们可以借助分块来 缩小错误可能存在的区间,进一步提出假设并作出验证 。
根据调试理论,我们还可以得到调试的一般方法:
初始状态 -> 进行状态迁移 -> 判定迁移后的状态正确性 -> 缩小调试区域 (分块) -> 不断循环,直至定位到 bug
而我们如何判断状态的正确性呢?
- 看状态内部的特定变量值 (人人喜欢用的 printf 大法)
- 查看执行到该状态时的日志内容
- ...
实质上,调试 = 观察状态机执行 (trace) 的某个侧面 + 分块
观察状态机执行的工具 (调试工具)
printf
→ 自定义 log 的 trace- 灵活可控、能快速定位问题大概位置、适用于大型软件
- 无法精确定位、大量的 logs 管理起来比较麻烦
gdb
→ 指令/语句级 trace- 精确、指令级定位、任意查看程序内部状态
- 耗费大量时间
Self-check-list
调试理论给了大家在遇到 “任何问题” 时候 self-check 的列表:
- 是怎样的程序 (状态机) 在运行?
- 我们遇到了怎样的 failure?
- 我们能从状态机的运行中从易到难得到什么信息?
- 如何二分检查这些信息和 error 之间的关联?
调试一切状态机
bug 与 debug 并不只出现在你的代码中 —— 计算机随时随地都在拒绝你 :
bash: curl: command not found
fatal error: 'sys/cdefs.h': No such file or directory
#include <sys/cdefs.h>
/usr/bin/ld: cannot find -lgcc: No such file or directory
make[2]: *** run: No such file or directory. Stop.
Makefile:31: recipe for target 'run' failed
遇到这些问题,有一个 几乎 万能的方法是:相信自己遇到的问题别人也遇到过 。
但假如说这种问题 别人也没遇到过 呢?
使用调试理论调试计算机世界的一切
- 合理论证 计算机世界的一切都可被调试 这一观点:
- 机器
(还是)永远是对的
实质上,在 UNIX 这类主要与命令行打交道的系统中,你做的任何事件都是在 编程 :
- 我们只是用一系列命令来完成将 需求 传递给 机器 这一事件
- 上面提到的问题都可以看成是 程序 / 输入 / 配置有 bug
因此,调试可以解决计算机世界中的一切 bug !
使用调试理论进行调试
大部分 bug 都是经过如下转化,最终呈现在程序结果上的:
Fault (程序/输入/配置错) → Error → Failure (可被人类观测)
大部分的 Error 与 Failure 是比较相近的: 程序捕捉到 Error , 使用 perror
等API 对错误日志进行输出,呈现为我们可以观测到的 Failure。
但是,总有那么一些时候,给出的错误日志并不是那么直观 —— 它甚至可能与实际的 Error / Fault 相差甚远 :
- 出错原因报告不准确
- 程序执行的过程看不到
- 那我们 想办法 “看到” 状态机的执行过程 就好了!(可能存在的
--verbose
选项、perror
、或者使用strace
等)
- 那我们 想办法 “看到” 状态机的执行过程 就好了!(可能存在的
- 这种时候我们就很难根据观测到的 Failure 来确定 bug 的根源
- 但是根据 Everything is a state machine. 这一观点,我们总能够顺着错误信息来寻找到 bug 的根源。
- 计算机中没有玄学!
一个非常强大的调试器 —— GDB
调试理论 - 推论
“Technical Debt” : 每当你写出不好维护的代码,你都在给你未来的调试/需求变更挖坑
推论 (1) - 扣紧需求和设计以减少出现 Fault 的可能性
需求 → 设计 → 代码 → Fault → Error → Failure
- 写好代码:不要在写代码的时候忘记需求和设计
- 不言自明 (Self-explanatory)
- 能通过字面知道需求 (流程)
- 不言自证 (Self-evident)
- 能通过字面确认代码和需求一致
一个评判标准 :
- AI 是否能正确理解/维护你的代码: toybox
Programs are meant to be read by humans and only incidentally for computers to execute. (Donald E. Knuth)
推论 (2) - 通过测试将 Fault 尽可能暴露成 Error
需求 → 设计 → 代码 → Fault → Error → Failure
- 做好测试 :未测代码永远是错的
- 残酷的现实:相信自己写不对代码
- 不进行测试的话,我们甚至看不到 Failure
- 观测到 Failure 的先决条件 : 暴露出 Error
推论 (3) - 多写断言来拉近 Error 和 Failure 的距离
需求 → 设计 → 代码 → Fault → Error → Failure
- 多写断言:把代码中的 “隐藏性质” 写出来
- Error 暴露的越晚,调试越困难
- 追溯导致 assert failure 的变量值 (slice) 通常可以快速定位到 bug
总结
Take-away Messages from jyy
如果我们观察到软件发生了 “超出预期的表现”,我们需要理解的是我们的 “预期” 经历了 需求 → 设计 → 代码 → Fault → Error → Failure 的漫长过程,其中的每一个过程多多少少都有些失控:我们的预期本身可能有误,或是对软件需要实现的需求有误解。而设计失误、编码错误最终反应到可观测的表现时,有时可能已经太晚了。因此,我们 “写好代码、做好测试、多写断言” 是十分重要的。