1. 调试理论

1.1 Debug

如果我们已经知道 bug 的存在

  • Segmentation Fault
  • Online Judge 拒绝
  • 虚拟机神秘重启
  • ……

怎么找到它

公理 1:机器永远是对的

  • CPU: “无情的、执行指令的机器”
  • Crash, Wrong Answer, 虚拟机神秘重启
    • 99.9999% 是自己的问题
    • 有亿点点概率是编译器错了 (但你可以知道)
    • 有亿点点点点概率是处理器错了 (你也可以知道)

公理 2:未测代码永远是错的

  • 反复测试过的代码都是错的
  • 你以为最不可能出 bug 的地方,往往 bug 就在那躺着

1.2 调试理论

“软件” 的两层含义

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

调试为什么困难?

  • Bug 的触发经历了漫长的过程
  • 可观测的现象未必能直接对应到 root cause 上

1.3 Fault,Error,和Failure

需求 → 设计 → 代码 (Fault/bug) → 执行 (Error) → 失败 (Failure)

  • 我们只能观测到 failure (可观测的结果错)
  • 我们可以检查状态的正确性 (但非常费时)
  • 无法预知 bug 在哪里 (每一行 “看起来” 都挺对的)
1
2
3
4
for (int i = 0; i < n; i++)
for (int j = 0; j < n; i++) {
...
}
  • 人总是 “默认” (不默认,浪费的时间就太多了)

1.4 调试理论

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

推论

  • 为什么我们喜欢 “单步调试”?
    • 从一个假定正确的状态出发
    • 每个语句的行为有限,容易判定是否是 error
  • 为什么调试理论看起来很没用?
    • “判定状态正确” 非常困难
    • (是否在调试 DP 题/图论算法时陷入时间黑洞?)

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

  • 缩小错误状态 (error) 可能产生的位置
  • 提出假设,作出验证
    ? 观察状态机执行的两个基本工具
  • printf → 自定义 log 的 trace
    • 灵活可控、能快速定位问题大概位置、适用于大型软件
    • 无法精确定位、大量的 logs 管理起来比较麻烦
  • gdb → 指令/语句级 trace
    • 精确、指令级定位、任意查看程序内部状态
    • 耗费大量时间

调试理论给了我们在遇到 “问题” 时候 self-check 的列表:

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

2. 调试一切状态机

计算机随时随地都在拒绝你

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

万能方法:假设你遇到的问题是别人也遇到的

  • 但如果这是一个全新的问题?

2.1 一切皆可调试

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

  • 机器永远是对的

UNIX 世界里你做任何事情都是在编程

  • “用编程语言把脑中所想传达给电脑”
  • 刚才的问题都可以看成是程序/输入/配置有 bug

调试理论可以用于解决任何 “问题”

  • curl: command not found
  • 'sys/cdefs.h': No such file or directory
  • Makefile:31: recipe for target ‘run’ failed

2.2 使用调试理论

Fault (程序/输入/配置错) → Error → Failure (可观测)

  • 大部分 Error 和 Failure 都比较接近
    • 出错时,使用 perror 打印日志

“找不到问题” 的原因

  • 出错原因报告不准确
  • 程序执行的过程看不到
    • 那我们想办法 “看到” 状态机的执行过程就好了!

理解状态机执行:不是 “调试”,也是 “调试”

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

调试:不仅是 “调试器”

  • Profiler: perf - “采样” 状态机
  • Trace: strace - 追踪系统调用

3. 调试理论的应用

需求 → 设计 → 代码 → Fault → Error → Failure

  • “Technical Debt”: 每当你写出不好维护的代码,你都在给你未来的调试/需求变更挖坑

中枪了?

  • 《计算机系统基础》为了赶紧实现指令,随手写的代码
  • 《操作系统》为了快点跑程序,随便写的 klib
  • 《我读研了》为了应付老板,随便写的系统实现

3.1 调试理论:推论

需求 → 设计 → 代码 → Fault

  • 写好代码:不要在写代码的时候忘记需求和设计
  • 不言自明 (Self-explanatory)
    • 能通过字面知道需求 (流程)
  • 不言自证 (Self-evident)
    • 能通过字面确认代码和需求一致

一个评判标准

  • AI 是否能正确理解/维护你的代码: toybox

Programs are meant to be read by humans and only incidentally for computers to execute. (Donald E. Knuth)

Fault → Error

  • 做好测试:未测代码永远是错的
    • 残酷的现实:相信自己写不对代码
    • LLM 一样经常犯 “傻” 错

Small Scope Hypothesis

If a system does not have a counterexample (i.e., an error or a bug) for a certain property within a small scope (a limited size or configuration), then it is unlikely to have a counterexample in a larger scope. (Daniel Jackson)

Error → Failure

  • 多写断言:把代码中的 “隐藏性质” 写出来
    • Error 暴露的越晚,调试越困难
    • 追溯导致 assert failure 的变量值 (slice) 通常可以快速定位到 bug

“There are two ways of constructing a software design: One way is to make it so simple that there are obviously no deficiencies, and the other way is to make it so complicated that there are no obvious deficiencies” (Tony Hoare)

如果我们观察到软件发生了 “超出预期的表现”,我们需要理解的是我们的 “预期” 经历了需求 → 设计 → 代码 → Fault → Error → Failure 的漫长过程,其中的每一个过程多多少少都有些失控:我们的预期本身可能有误,或是对软件需要实现的需求有误解。而设计失误、编码错误最终反应到可观测的表现时,有时可能已经太晚了。因此,我们 “写好代码、做好测试、多写断言” 是十分重要的。