调试理论

背景

调试是一门十分重要,但却很少有地方专门教授的进阶知识。是我们解决计算机问题的重要手段,对工程能力的提升十分重要。通过学习调试理论,我们可以提升定位 bug 的效率、分析陌生的代码流程,甚至解决计算机系统的“任何问题”。

尤其是调试理论中所蕴含的思想,有助于提升我们的视野,写出更好的代码。

什么是调试?

调试被称为 debug,而 debug 的对象是 bug,我们先看一个最早的 bug:

First Computer Bug.png

在 1947 年,有一个虫子(bug)把当时最先进计算机的继电器卡住了(不能表示 0 和 1),导致计算机无法工作,而 debug 就是已知计算机有问题找到这个虫子(bug)的过程

时间来到 21 世纪,在今天的软件研发领域,我们对 bug 都不陌生,大家是否思考过 bug 是怎么来的呢?软件到底是什么?已知软件有 bug,如何找到?

什么是软件?

我们是否真的清楚什么是软件?

任何软件都是为人类需求服务的,软件是需求在信息世界的投影。而我们很清楚,需求经常是模糊的、混乱的(此处吐槽 PM),即使是比较清晰的需求,也是 informal(非规范的) 的,因为需求是用自然语言编写的(也许 XX 年以后 AI 能正确的理解与执行)。

对于需求我们会有一个大概的设计,使用什么数据结构,先做什么再做什么(算法)等等,这是一层转化,转化意味着可能有信息的错误或丢失。

然后我们根据设计写代码,此时发生了一个核心转化:需求变成了一个 formal(规范的) 的数学对象(软件)。无论我们使用什么样的编程语言,我们的核心工作就是把非规范的需求变成规范的软件。在需求 -> 设计 -> 软件的这个流程里我们很难谈 bug,那么 bug 在哪里呢?在软件的代码实现里,所谓的 bug 就是实现和需求不匹配

这里解释一下数学对象,我们的程序最终会变成指令在 CPU 上逐条执行,而 CPU 是一个无情地执行指令的机器(stupid and fast),对就是对,错就是错,纯粹的数学。这些指令构成的程序会继承 CPU 的特点,也就是所谓的数学对象。

软件这个数学对象的执行对应一个状态机,每一条指令的执行都对应一次状态的变更(变量、寄存器...),也可以说:软件 == 状态机。

调试为什么困难?

既然 bug 是实现与需求的不匹配,我们怎么知道程序有 bug?怎么找?

我们刚写完代码时,对代码正确性是没有任何信心的,最常见的操作就是不管三七二十一,先 run 一下,看看报不报错,然后再请求一下试试,看看结果是否符合预期,如果不对再去修改。

What is Failuer? 上面的操作是有深刻含义的,我们可能会遇到 Compile XXX、Wrong XXX 等错误,而这些我们能观测到的错误结果统称为 Failure。

软件 -> ... -> Failure

What is Fault? 但我们并不知道哪一行代码导致了这个 Failure,我们对导致问题的这一行代码称为 Fault(也叫 Bug),这一行代码执行后,软件状态就错了。

软件 -> Fault(Bug) -> ...(状态出错) -> Failure

What is Error? 在 Fault 出现到我们观测到 Failure 之间还有很多的状态,把这些状态定义为 Error,也就是程序运行过程中的错误状态

软件 -> Fault(Bug) -> Error -> Error -> ... -> Failure

因此,当我们写了一个 Fault 之后的执行过程可能会产生很多 Error,最终导致 Failure 被观测到。

举个例子:

debug_for_loop

如果这段代码混在几百行代码里面,肉眼看上去是没什么问题的。但可能需要花很长时间才能找到问题,因为这段代码产生的 Failure 是 Timeout,在 (1, 0) 的时候已经错了,但我们看不到,只有在 Timeout 的时候才被我们观测到,才能感觉到程序不对。

假设你在一条比较罕见的路径上有这样的循环,然后其它分支都是对的,然后你执行代码可能给你返回一个 Timeout Exced,你会想是不是性能有问题,然后你就走在了优化性能的错误道路上

所以,调试为什么困难?

  1. 我们只能观测到 Failure,而从 Fault 的触发到观测到 Failure 经历了漫长的过程,中间可能隔了几百上千个错误的状态,但你看不到,程序的执行是一个黑盒(两者之间没有显然的路径),无法预知 Bug 在哪里(每一行“看起来”都挺对的)
  2. Failure 可能把我们引入错误的方向
  3. 我们可以检查状态的正确性,但非常费时

随着编程经验的提升,你会知道如何把常见的 Failure 和 Fault 联系起来,但这都是踩坑踩出来的血的教训。

如何调试?

摆正心态

1、机器永远是对的

不管是 Crash 了,Wrong Answer 了,还是软件神秘重启,都是自己背锅。这句话听起来有点绝对,机器确实也可能有问题,但是计算机系统也是人设计的,不管遇到了什么样的问题,这个系统都会给出一个看得见的结果,而这个结果一定有一个解释。这个解释有可能是你的程序错了,假设程序代码没问题,是编译器的 bug 导致的,最终也能够定位到编译器的问题;假设编译器也没有问题,是操作系统、处理器、内存有问题,这也是能定位到的,最终最终我们会来到物理世界上,比如主板的一部分短路了。

因此机器永远是对的所隐含的意思是:遇到任何不符合预期的输出,我们都可以想办法去搞清楚为什么,遇到难诊断的问题不要怪到机器上,静下心来想一想哪里没考虑对。

2、没有测过的代码永远是错的

你以为最不可能出 bug 的地方,往往 bug 就在那躺着(不假设没有测过的代码没问题)。测试就是检测特定输入下的输出是否符合预期,相信没有测试 == 有 bug,不假设没有测过的代码没问题。

理想中的调试

理论上,如果我们能拿到程序的所有状态,且能判定任意程序状态的正确性,那么给定一个 Failure,我们可以通过二分查找定位到第一个 Error 的状态,通过判断状态正确性,快速缩小定位 Fault 的范围(也就说我们有了一个万能的调试方法)。

但是,理论不等于现实,我们的软件有太多的状态,大量的分支,要判断一个程序状态的正确性非常困难(例如平衡树),所以调试理论没用?

并不尽然,从另一个角度来说,调试理论很有用,它让我们看到了, 调试困难的根本原因在于:检查状态很难。反之,也就是说,我们应该怎么去更好的检查程序的状态。

现实中的调试

实际中的调试一定是:观察程序状态机执行的某一个侧面,也就是相当于一个简化的小状态机,而这个小状态机可以和大状态机的 Error、Failure 产生关联。

通过对一部分状态的观察,我们可以缩小 Error 可能产生的位置,作出适当的假设,再进行细粒度的定位和诊断。

为了能方便的观测到程序执行某个侧面的状态,我们有两个重要的工具,它们代表了两种不同的关于状态机执行的侧面。

打地鼠调试法(错误示范)

如果没有正确的方法,这里改一下,不对;那里改一下,还是不对;再改回去看看……这就是打地鼠调试法......

如果没有掌握正确的调试方法,那无论你用什么工具、方式,都是打地鼠。

Log 大法

这个方法大家都不陌生,不知道变量值是什么 log 一下,请求下游服务出错了 log 一下。那么 log 帮我们做了什么?我们为什么要在代码里插入一个 log?

本质上我们插入 log 是为了:打印出程序状态机某个状态中与错误相关的一些最重要的信息,通过这些信息帮着我们评估状态的对错,从而缩小 debug 的范围

所以,为什么很多时候 log 没有效果?

  1. 打印的信息不对,无法帮助我们评估状态的对错,例如 log("error") 除了知道出错了,啥也不知道,写代码的时候想想如果这里出现问题,我需要什么信息才能快速定位问题,就知道日志该在哪里记录以及记录什么了。5W2H 上下文(WHO、WHEN、WHERE、WHAT、HOW... 谁、做什么、出了什么问题...)
  2. 打印的信息太多,淹没在信息的海洋里(实践中 log 往往是太少而不是太多)
  3. 打印的信息没有格式化,可读性差

通过 log 我们能灵活快速的定位问题的大概位置,尤其适用于大型软件。但 log 的缺点在于无法精确定位,大量的 logs 需要好的管理方式(ELK)。

JavaScript Log

逐步跟踪数据流,逐步调试,log 大法。

var log = function() {
    console.log.apply(console, arguments)
}

上线的时候直接修改 log 为空函数

调试器

当我们通过 log 初步定位之后,就可以使用 gdb 单步跟踪,一条语句一条语句的 check 程序状态,精确地定位到 Bug。

  • 代码运行原理
  • 调试器
  • gdb
  • Delve(Golang)
    • delve、dlv 调试
    • 远程调试
  • JDB(Java Debug)
  • ...

调试器的使用与技巧是一个很大的话题,但结合调试理论你可以更好地使用它们。

总结

我们可以结合 log 和调试器快速精确的定位到 bug,这样问题就变成了:

  • 我们需要从这个状态机 log 哪些重要的信息?
  • 怎样更好的采集、查询 log?
  • 从哪里开始跟踪可以快速的判定 Bug?

解决这些问题,调试的问题也就迎刃而解了。

当我们遇到软件问题的时候,可以遵循下面的 Check List 解决问题(方法论):

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

下面我会讲一些例子,相信你会对调试理论有更好的理解。

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

调试理论可以给我们解决“任何(计算机)问题”的信心!我们只要能 “检查状态机的状态”,就可以调试/诊断计算机系统中遇到的 “任何问题”,有些看似莫名其妙的问题,在 trace/log 的帮助下,也就自然有了头绪。当网上的方法都失效的时候,你还可以依赖调试理论。

你是否遇到过以下令人抓狂的情况?

  • bash: curl: command not found
  • fatal error: 'sys/cdefs.h': No such file or directory
  • #include <sys/cdefs.h>
  • make[2]: *** run: No such file or directory. Stop. Makefile:31: recipe for target 'run' failed make[1]: *** [run] Error 2
  • ...

当我们使用 UNIX 的时候,你在 UNIX 世界里所做的任何事情都是在编程,当遇到上面这些问题的时候,其实就是你在调试/诊断一个程序的问题,只是这个程序 99.9% 的代码都是资深程序员写好的,而且久经考验,不太可能有什么大问题。

大概率是我们给程序的输入或配置是有 bug 的,例如 bash 的 curl: command not found,是因为没有给到正确的 PATH 路径或程序没安装。从另一个角度来看,当 bash 的状态机执行的时候,我们没有提供它预期的环境(Bug),在某个状态找不到程序,触发 Bug,进入 Error 状态,并很快的报出 Failure。

程序、输入、配置都可能出错,而这些都是 Bug,会导致状态机进入 Error 状态,最终在某个时刻报出 Failure。

因此,绝大部分工具的 Failure 都有比较详细的原因,能帮我们快速定位到 Bug

如果 Failure 不准确?不够详细?或者完全没有 Failure,无法帮助我们定位到 Bug 或 Error 该怎么办?

我们需要有办法把状态机的执行过程打开,去观测状态机执行的重要步骤状态,获取更多的信息实现问题的诊断。

SSH Debug

假设我们 ssh baidu.com 会卡住很长一段时间,为什么会卡住连接不上?

想想我们的 Check List:我们能从状态机的运行中从易到难得到什么信息?

我们可以增加参数 -vvv 让 ssh 输出更多的 log:

 ssh -vvv baidu.com
OpenSSH_9.3p2, LibreSSL 3.3.6
debug1: Reading configuration data /Users/x/.ssh/config
debug1: /Users/x/.ssh/config line 1: Applying options for *
debug2: checking match for 'all' host baidu.com originally baidu.com
debug2: match found
debug3: vdollar_percent_expand: expand ${USER} -> 'x'
debug1: Reading configuration data /etc/ssh/ssh_config
debug1: /etc/ssh/ssh_config line 21: include /etc/ssh/ssh_config.d/* matched no files
debug1: /etc/ssh/ssh_config line 54: Applying options for *
debug3: expanded UserKnownHostsFile '~/.ssh/known_hosts' -> '/Users/x/.ssh/known_hosts'
debug3: expanded UserKnownHostsFile '~/.ssh/known_hosts2' -> '/Users/x/.ssh/known_hosts2'
debug1: Authenticator provider $SSH_SK_PROVIDER did not resolve; disabling
debug1: auto-mux: Trying existing master
debug1: Control socket "/Users/x/.ssh/ssh_mux_baidu.com_22" does not exist
debug1: Connecting to baidu.com port 22.
ssh: connect to host baidu.com port 22: Operation timed out

ssh 的详细 log 打印出状态机的关键步骤,通过这些 log 可以很轻易地判断出 Fault 是连接不上 baidu.com 的 22 端口。

UNIX 下的工具普遍提供调试功能(verbore model),帮助用户/开发者了解程序的行为

我们自己开发的工具也可以参照这种设计。

此外,UNIX 程序有一个很重要的设计,可以通过管道的形式与其它工具连接起来,共同完成任务(这也是一种编程)。为了实现这个设计,这些工具有一个基本假设:输出里一句废话也不多说,否则会影响到下游,对于 Error/Failure 则 log 输出到 stderr(这也是为什么有 stdout 和 stderr 的区分),避免污染管道。

调试理论解释云原生可观测性(Trace、Log)

我们的各种服务为什么要做监控、Trace、Log?

本质都是为了在出现问题的时候更好的观测到 Failure,从而定位到 Error 的范围。比如通过监控发现一个接口的 P99 达到 1000ms,那么通过这个 Failuer 我们可以定位到具体的接口有性能问题,再通过接口的 log 缩小有性能问题的代码范围进行修复。

监控、Trace、Log 为调试服务。

  • Trace & Log 系统在调试中的位置:
    • Trace 定位到哪个服务出了问题,找到一个大致范围
    • Log 定位到服务中的问题,继续缩小状态范围

调试是手段,不是目的

调试虽好,不调试更好!

调试手段必不可少,但手段不是目的,我们的目的是没有 Bug,是少出现 Bug。

每当你写出不好维护的代码,都是在给未来的调试/需求变更挖坑,留下技术债(Technical Debt)。

我们写的是 Fault(Bug),看到的是 Failure,所以,代码写的好不好,对能不能找到 Bug 至关重要。

如果代码不好维护,就会在泥潭中越陷越深,比如下面的情况:

  • 为了快速实现需求,随手写的代码
  • 在屎山上增加需求,重构屎山
  • ...

中枪了?日常血压升高?又不是不能用?

Pasted image 20231127162603

如果出现 Bug 我们应该做的是:

  • 重构代码,让代码更佳清晰,Bug 不容易发生
  • 增加更多的测试,让 Bug 更加容易显形

而不是使用 debug 让自己陷入到细节中。

写出更好的代码

Programs are meant to be read by humans and only incidentally for computers to execute -- D. E. Knuth

代码首先是拿给人读的,其次才是被机器执行。

1、少出 Fault

什么样的代码是好代码?如何减少出 Fault 的可能性?

第一层:不言自明(Self-explanatory)

看代码能知道需求(流程)。

  • 代码不仅仅是代码,更是需求和设计的承载,与其需求和设计是两份单独的文档,它们和代码实现完全匹配不起来,如果能把需求和设计写在实现里,这显然是更好的(完善了上下文),这样就可以不停地检查需求、设计和你的实现是否一致
    • 可以探讨,个人觉得部分关键的信息注释在代码里很有用
  • 可读性好,因此代码风格很重要:命名、函数、模块、注释...,明确的知道每块代码是解决什么问题(抽象、领域建模...)
  • KISS:Keep It Simple and Stupid / Keep It Super Simple
    • Simple is Beautiful

第二层:不言自证(Self-evident)

能确认代码和需求一致。

这是更难的要求,代码里有辅助的逻辑能证明你的代码真正正确的实现了需求,例如 TDD、逻辑全面的单测、集成测试...

另外,有种东西叫形式化验证(代码证明),可以证明一个软件比如操作系统是没有 bug 的,听起来就很 fancy,实际上你转眼一想,程序就是一个数学对象,数学对象你凭什么不能证明? 至少觉得证明不是一个不可能是事情,在合理的假设下总能证明一些东西

2、早发现早预防

如果我们的写代码犯的错是 Fault(不可观测的 Bug)看到的是莫名其妙的 Failure,那么我们能做的事情是什么?

测试

通过测试尽可能的把 Fault 变成 Error(考虑各种环境),然后在程序有 Error 以后尽可能早的把 Failure 报告出来(Error 暴露的越晚,越难调试),把两者之间的逻辑链条缩短

所以,未测代码永远是错的,只有你用不同条件的测试测了代码,才能触发 Fault 变成 Error,并在第一时间抛出 Failure,让我们观测到有问题。

手动跑一下也是测试,包括特定的机器、环境、库

Small Scope Hypothesis

代码断言

另一个尽早抛出 Failure 的方法是在代码中添加断言(assertions),这是一种写好读、易验证代码的方法,把代码中隐藏的假设写出来,检测有没有 Error:

  • 程序里面有很多隐含的假设:
    • 比如这个指针不为空,平衡树的节点比左边大比右边小、平衡性质
    • 比如账户余额大于 0
  • 这些假设都是在程序实现正确的前提下才正确的,所以我们要做的是:把这些隐含的假设通过断言的形式写出来,如果这个节点不对了,立即报告,就很有可能在刚进入 Error 状态的时候逮捕一个 Failure
  • 追溯导致 Assert Failure 的变量值通常可以快速定位到 Bug

例子:维护父亲节点的平衡树

Pasted image 20231125121020
// 结构约束 
assert(u->parent == u ||
	u->parent->left == u ||
	u->parent->right == u)
)
assert(!u->left || u->left->parent == u)
assert(!u->right || u->right->parent == u)
 
// 数值约束
assert(!u->left || u->left->val < u->val)
assert(!u->right || u->right->val > u->val)

这样一来,只需要测很小的测试用例,没有逮不出来的问题。

assert 是一个非常非常有用的技巧。

No Code No Bug

No code is the best way to write secure and reliable applications. Write nothing; deploy nowhere.

https://github.com/kelseyhightower/nocode 58.1k Star

Debug Driven Development

Debug Driven Development

总结

软件是状态机,调试的本质就是通过更好的方法获取状态机内部的信息,从而缩短 Fault 到 Failure 之间的距离。

资料