开始调试之前
- 机器永远是对的: 不管是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的列表
- 是怎样的程序(状态机)在运行?
- 我们遇到了怎么的failure?
- 我们能从状态机的运行中从易到难得到上面信息?
- 如何二分检查这些信息和error之间的关联?
调试”任何问题”
计算机世界: 一切皆可调试
程序 = 计算机系统 = 状态机
- 机器永远是对的
- UNIX世界里你做任何事情都是在编程
- 因此配置错, make错等, 都是程序或输入/配置有bug
- 输入/配置可以看出是程序的一部分
所有问题都可以用调试理论解决
使用调试理论
Debug(fault localization)的基本理论回顾:
- Fault(程序/输入/配置错) -> Error -> Failure(可观测)
- 绝大部分工具的Failure都有”原因报告”
- 因此能帮助你快速定位Fault
- man perror: 标准库有打印error message的函数
- 绝大部分工具的Failure都有”原因报告”
如果问题不能帮你定位到fault/error?
- 出错原因报告不准确或不够详细
- 程序执行的过程不够详细
- 既然我们有需求, 那别人肯定也会有这个需求
- 一定有消息能帮助我们
- 既然我们有需求, 那别人肯定也会有这个需求
正确的方法: 理解程序(状态机)的执行过程
- ssh: 使用-v选项检查日志
- gcc: 使用-v选项打印各种过程
- make: 使用-n选项查看完整命令
各个工具普遍提供调试功能, 帮助用户/开发者了解程序的行为
标准的解决问题办法: 自己动手排查, 在面对复杂/小众问题时比STFW/ChatGPT有效
只要能在”检查状态机的状态”, 就可以调试/诊断计算机系统中遇到的”任何问题”, 有些看似莫名情况的问题, 在trace/log的帮助下, 也就自然有了头绪
Take-away Messages
调试理论: bug是如何发生的?
- Fault -> (测试) -> Error -> (断言) -> Failure
- 调试一切问题的checklist
- 是怎样的程序(状态机)在运行?
- 我们遇到了怎么的failure?
- 我们能从状态机的运行中从易到难得到上面信息?
- 如何二分检查这些信息和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理解程序的精确行为