《Effective Debugging:软件和系统个调试的 66 个有效方法》

1 章 宏观策略

1条:通过事务追踪系统处理所有问题

开源的有 Bugzilla/Launch-pad/OTRS/Redmine/Trace 等,或者 JIRA 这种专有系统。

2条:在网上确切地查询你遇到的问题,以寻求解决灵感

简单、自足而且正确的范例(SSCCE)。搜索的时候用双引号可以更加精确搜索。搜不到可以去 stackoverflow 提问。

3条:确保前置条件和后置条件都满足

  • 不应该为 null,却为 null 的值
  • 数学函数的参数保证在定义域之内
  • 对象, 结构体和数组内部细节
  • 变量是否在范围之内
  • 传递的数据结构是否正确,map 有没有包含预期的 key/val,链表是否可以正常遍历

4条:从具体问题入手向上追查 bug,或从高层程序入手向下追查 bug

  • 程序崩溃。通常null/未初始化的值容易引发崩溃
  • 程序冻结(freeze):找出循环的终止条件和没有满足的原因
  • 错误消息:grep 找到错误消息位置

5条:在正常运作的系统和发生故障的系统之间寻找差别

影响因素:代码、输入、参数、环境变量、动态链接库

  • 二分搜索
  • 日志对比,grep/diff/comm

6条:使用软件自身调试机制

  • 很多命令有 debug 选项。比如 sh -x, mysql explain

7条:试着多种工具构建软件,并放在不同环境执行

  • 用多种编译工具构建软件,不同平台执行
  • 考虑用更高级的语言重新实现

8条:工作焦点放在最重要问题上

高优先级的 bug:

  • 数据丢失
  • 数据安全
  • 服务可用性降低
  • 使用安全
  • 程序崩溃或者冻结(freeze)
  • 代码质量

低优先级:

  • 支持遗留系统
  • 向后兼容
  • 有临时解决方案的问题
  • 很少用的特性

2 章 通用的方法与做法

9 条:相信自己能把问题调试好

  • 确信问题是可以排查的
  • 流出足够的调试时间
  • 安排好环境不受干扰。进入心流
  • 睡一觉
  • 学习环境和工具想关知识

    • 准备好健壮的最小测试用例
    • bug 重现自动化
    • 脚本分析日志文件
    • 了解 API 或语言特性的运作方式

10 条:高效地重建程序中的问题

sscce: 短小的(short),自足的(self-contained),正确的(correct),范例(example)

  • 准确重现
  • 短小正确的范例
  • 创建执行环境
  • 用版本管理打上标记

11 条:修改完代码后,要能尽快看到结果

12 条:将复杂的测试场景自动化

通过脚本语言执行复杂的测试用例

13 条:使自己尽可能多地观察到与调试有关的数据

  • 扩大日志显示区域

14 条:考虑对软件进行更新

  • 更新后重新尝试你的代码,是否还会出错?
  • 谨慎考虑第三方出问题的可能,你自己出问题的几率更高

15 条:查看第三方组件源代码,了解其用法

16 条:使用专门的监测和测试设备

协议分析工具,比如 wireshark, tcpdump 等监测网络数据包

17 条:使故障更加突出

  • 迫使软件去执行可疑路径
  • 提升某些效果的幅度,令其更加突出,以便于研究
  • 对软件加压,暴露出负载下的状态
  • 所有修改都要在版本管理下做

18 条:从自己的桌面计算机上调试那些不太好用的系统

19 条:使调试任务自动化

20 条:开始调试之前与调试完毕之后把程序清理干净

  • 确保调试之前代码整洁
  • 调试完毕,把临时改动还原回去,只把有用的代码提交上去(考虑单独用一个分支)

21 条:把属于同一个类型的所有问题全部都修复好

  • 修复一个错误之后,搜索代码类似的地方是否有一样的问题需要修改

3章:通用的技术和工具

22 条:用 unix 命令行工具对调试数据进行分析

获取--筛选--处理--汇总

  • nm 查看目标文件,获取哪些文件调用了 exit 函数。 nm -A *.o | grep 'U exit$'
  • tar/jar/ar 查看压缩包内容
  • cut 裁剪,sed 正则提取
  • 大量文本使用 more/less
  • xargs 命令输入端

23 条:掌握命令行工具的各种选项和习惯用法

  • fgrep -lr 'Missing foo' 搜索所有包含错误消息的文件
  • 对标准错误重定向以便于分析 ( 2>&1 )
  • tail -f 监控持续增加的日志文件

24 条:用编辑器对调试程序时所需的数据进行浏览

  • 使用编辑器搜索来查找拼写有误的单词
  • 编辑文本突出不同点
  • 编辑日志让其更加易读

25 条:优化工作环境

  • 配置工具以提升效率。 alias/editor
  • 通过版本控制共享 配置文件 (dotfiles)

26 条:用版本控制寻找 bug 发生的原因和经过

每一次修改都应该单独提交,而且要写上有意义的提交信息,如果有可能,还应该链接到对应的事务(比如 jira)上。

  • git log somefile 查看某个文件变化
  • git blame file
  • git rev-list --all | xargs git grep extinctMethodName 在过去的版本中搜索指定字符串
  • git log v1.2.3.. 只看某一个版本后开始发生的变化
  • git rev-list -n 1 --before=2015-08-01 master 获取该日期之前最后一次提交所对应的 SHA hash
  • git log --all --grep='Issue #1234' 搜索与某个事务有关的提交
  • git show lcb634f6 指定展示某个 hash 的有关修改
  • git diff v1.2.3..v1.3.2 显示两个版本之间的变化
  • git checkout v1.1.0 回退到某个版本

二分查找并且锁定测试没有通过的版本

git bisect start V1.1.0 V1.2.3
git bisect run test.sh
git reset

如果正在做某一件事情,然后突然要去解决另外一件事,可以把当前的变更先隐藏,处理完别的事情之后再恢复。

git stash save interrupted-to-work-on-V1234
git stash pop
  • 用 git 查看文件的修改记录,确定 bug 何时以何种方式引入
  • 用 git 查看正在运行的版本和故障版本之间的区别

27 条:用工具监测多个独立程序构成的系统

  • 主机健康。

    • cpu 内存 网络可达性 进程数量 登录用户数量 可以更新的软件 剩余磁盘容量 打开的文件描述符 网络和磁盘带宽 系统日志 安全性 远程访问
  • 服务健康。
    • 数据库 邮件服务器 应用程序服务器 缓存 网络连接 备份 队列 消息传递 软件授权过期 web 服务器和目录

最好可以监测到:

  • 能否正确处理整个流程
  • 应用程序各个部分是否正常
  • 某些关键指标是否正常。响应延迟,队列堆积,活跃用户数,失败交易,发生的错误和异常报告

Nagios 系统可以用来检测。

要点:

  • 基础设施检查机制,各个部分是否正常
  • 使自己可以在故障发生的时候迅速得到通知
  • 查阅故障记录,发现规律或许可以帮你找出问题原因

4章:调试器的使用技巧

28 条:编译代码时把符号信息包含进来,以便于调试

发布的时候记得删掉这些信息。

  • 大多数 unix 编译器支持用 -g 选项加入调试信息
  • 调整,禁用优化选项

29 条:对代码进行单步调试

  • 通过单步调试查看语句执行顺序和状态
  • 跳过和 bug 无关部分
  • 设置断点缩小范围

30 条:设置代码断点和数据断点

  • 通过断点缩减范围
  • 先在上游设置断点,然后再给需要的代码设置断点
  • 针对异常或者程序退出的子程序设置断点
  • 可以在调试器里终止没有响应的程序
  • 数据断点锁定意外值导致的 bug

31 条:了解反向调试功能

  • gdb 提供了 record reverse-next reverse-step
  • 反向调试对性能影响很大

32 条:查看例程之间的相互调用情况

  • gbd frame n 切换第 n 帧,up 和 down 上下移动
  • 如果栈信息比较乱,代码可能写得有问题

33 条:查看变量和表达式的值,以寻找错误

  • gdb 中叫做 pretty-printer
  • python ,引入 pprint 模块
  • python tutor 可视化执行过程

34 条:了解如何把调试器连接到正在运行的进程

# 先查找进程 pid
ps -u apache
gdb -p pid

链接到正在运行的进程之后可以打断其执行过程 (gdb:break),还可以设置断点

35 条:了解 core dump 信息进行调试

unix 可以通过 kill-ABRT pid 发送 SIGABRT 信号,迫使其生成转储文件。终端里可以用 ctrl-\ 组合键发送这个信号。

36 条:把调试工具设置好

  • cgdb/ddd 图形界面调试工具。
  • gdbinit 文件
  • gdb 可以运行 make 命令

37 条:学会查看汇编代码和原始内存

查看机器码错误

  • 不必要的类型转换
  • 误解了操作符优先级
  • 无意中使用重载之后的操作符
  • 没有配对的括号
  • 错误的数值类型
  • 不当的多态例程

gdb 执行 display/i$pc 显示反汇编之后的指令,然后可以用 stepi, nexti 单步调试。 info registers 查看寄存器的值, 通过 display$r0 或者 display$eas 持续显示某个寄存器。 使用 x/10xb&a 字节为单位,用十六进制显示 a 中的 10 个元素。

  • 小端序(little-endian),先保存最低有效位字节
  • 大端序(big-endian,大端在前,也称为网络序)

5章:编程技术

38 条:对可疑代码进行评审,并手工演练这些代码

遵守约定,比如用括号理清楚运算符优先级。使用静态检查工具

需要关注的错误:

  • 操作符优先级是否有误(尤其是位操作)
  • 缺少必要的括号和 break 语句
  • 多谢了分号
  • 比较操作误写为赋值 (== vs =)
  • 变量没有初始化,或是初始化为错误的值
  • 循环中缺少必要语句
  • off-by-one 错误
  • 类型转化错误
  • 拼写错误
  • 缺少必要方法
  • 特定编程语言陷阱

重点:

  • 检查代码常见错误
  • 用铅笔手工执行代码,验证是否正确
  • 通过画图解析复杂的数据结构

39 条:审读代码和同事讨论

橡皮鸭技术(rubber duck technique)。 专业和礼貌的方式评审。

40 条:给软件增加调试机制

  • 根据变异选项是否进入调试模式
  • 通过命令行选项决定是否进入调试模式
  • 发送 signal
  • 通过命令行打开调试模式

41 条:添加日志(log)语句

关键例程的入口和出口、重要数据结构内容、状态的变化和用户操作的回应等,注意不要在生产环境启用

42 条:对软件进行单元测试

  • 通过单测检查可疑例程,发现其中错误
  • 使用合适的单测框架

43 条:用断言进行调试

从前置条件、不变条件、后置条件思考,设置断言验证

  • 开头断言,验证 cpu 架构属性
  • 例程入口断言,验证参数类型,是否有效(null)而且合理
  • 例程出口验证是否正确
  • 复杂的方法设置断言,验证状态
  • 断言不会出错的 api
  • 验证资源是否正确加在
  • 验证复杂表达式的值
  • switch 断言分支处理
  • 断言数据结构的初始化是否正确

44 条:改动受测程序,验证推想

  • 手工设定代码中的某些值,验证哪些取值是正确的,那些错误的
  • 试着用其他的方式 替换 当前实现

45 条:尽量缩小正确范例与错误代码之间的差距

  • 缩减你的代码使其与范例代码相符,或者逐渐修改范例代码,使其与你的代码相符,有利于找到错误原因

46 条:简化可疑代码

  • 复杂的代码会增加调试工作量,可以临时简化,删除不必要的代码,使错误更加突出
  • 大函数拆分成小部分,单独测试一部分
  • 弃用某些复杂的算法、数据结构、程序逻辑。 精巧但是很复杂的代码,容易出 bug,对性能没有那么高要求的地方可以替换成简单的实现版本

47 条:将可疑代码替换成另一种编程语言编写

  • 使用表达能力更强的语言改写难以修复的代码,减少可能出错的语句数量
  • 移植代码到更好的编程环境,用更强大的调试工具解决
  • 参照新代码修正旧代码

48 条:改善可疑代码的可读性与结构

混乱糟糕的代码容易滋生 bug。

装饰性的调整,代码重构,bug 修复工作要分开,并且要分别提交。

《重构》

49 条:清除 bug 根源,而不是仅仅消除症状

  • 不要采用临时代码绕开程序表面症状,而是要查找 bug 深层原因并且修复
  • 尽可能采用通用方式处理复杂情况,而不要只修复某些特例

6.章 编译时的调试技术

50 条:对生成的代码进行检视

  • 查看自动生成的代码(字节码,汇编等),理解编译时和运行时问题
  • 通过工具展示成容易阅读的形式

51 条:使用静态程序分析工具

lint 工具。最好加入到构建流程中

  • null 进行解引用
  • 并发错误和竞争条件
  • 拼写有误的变量名
  • 下标越界
  • 错误的条件、循环、case,还有不会执行到的代码
  • 未处理异常
  • 没有用到的变量和例程
  • 数学错误
  • 代码重复
  • 未实现接口
  • 资源泄露
  • 安全漏洞
  • 特定编程语言问题

gcc -Wall

52 条:对项目进行配置,让程序以固定方式构建和运行

  • gcc 随机选取符号名称
  • 输入给编译器的文件顺序可能不同
  • 软件中构建表示的时间戳会发生变化
  • 哈希表或map 遍历后顺序不同。防止算法复杂度攻击
  • 加密盐

53 条:对调试所用的程序库和构建代码执行环境进行配置

  • 启用编译器所支持的运行时调试功能
  • 构建过程中引入一些第三方库检查代码

7章:运行时调试技术

54 条:通过构建测试用例寻找错误

  • 创建一个可靠并且最简单的测试用例
  • 加入到回归测试防止重复犯错

55 条:让软件遇到问题时及早退出

测试环境中,及早退出有利于发现问题

  • 添加并启动断言
  • 配置程序库进行严格检查
  • unix shell 开启 -e 选项,让 shell 在错误时候(return !=0)终止

56 条:检查日志文件

分析日志:

  • gui 事件查看器
  • 文本编辑器。比如 vim 可以用g/regular-ex-pression/d 删除无关的日志
  • unix 工具过滤、汇总、筛选
  • ELK、Logstash、loggly、Splunk

57 条:对系统和进程所执行的操作进行性能评测

  • (unix)对于 cpu 来说,负载是否高于核心数量
  • 内存,虚拟内存页面写入到磁盘的频率
  • 网络 IO,丢包和重传。 iostat, netstat, nfsstat, vmstat 等工具
  • 虚拟设备 IO,请求队列长度和操作延迟

58 条:追踪程序的执行情况

ltrace(追踪对程序库调用),strace, ktrace, truss 追踪对操作系统调用

Dtrace。dtrace -n 'syscall:::entry'

59 条:使用动态程序分析工具

Valgrind 内存检查

8章:调试多线程代码

60 条:通过事后调试分析死锁问题

  • gdb
# get pid
ps
# kill pid
kill -QUIT pid
# gdb deadlock core
>(gdb) info threads
>(gdb) thread 2
>(gdb) backtrace
  • jdk jstack 排查 java 死锁

61 条:捕获并且重放

  • 开启记录功能,反复运行直到重现
  • 分析记录结果
  • 程序放在调试器中运行,重放 bug
  • 对程序该点的状态分析,找到错误原因
# 程序名 race
gdb_record race
(gdb) break main
(gdb) continue
(gdb) pin record on
(gdb) continue
(gdb) quit

可以用 replay 命令重放,程序按照和当初相同的顺序操作内存,也会表现出同样的错误行为。

replay pinball/log_0

gdb_replay pinball/log_0 ./race

62 条:用专门的工具探查死锁和竞争条件问题

  • FindBugs: java -jar findbugs.jar -textui Counter.class
  • Intel Inspector
  • valgrind --tool=helgrind deadlock

63 条:把不确定因素隔离出来,或将其移除

  • 行为不确定的代码和其他代码隔开
  • 对这些代码适当的实现和配置,然行为变确定
  • 移除法:用可以预测的实体替换掉难以预测的部分
  • 创建 mock 对象

64 条:检查资源争用情况,以解决伸缩性问题

用 profiling 工具探查引发竞争现象的原因,以解决多线程代码中与可伸缩性有关的问题。

  • Oracle Java Flight Recorder
  • Inter VTune Amplifier

65 条:用性能计数器寻找伪共享问题

伪共享(false sharing)问题。cpu 核心的同步协议(缓存一致性协议),保证各线程总是可以看到一致的内存数据。 每个线程最好操作不同的内存区域(栈变量),不要干涉其他线程所操作的内存数据

cpu 性能计数器。

用 perf 统计程序的末级缓存(last-level cache,简称 LLC)未命中次数。如果对缓存一致性协议的触发次数比较多, LLC-loads 就会随之增大。

per stat --event=LLC-loads ./sum-seq

66 条:考虑用高级的抽象机制重写代码

GNU parallel

使用更加高级的并发原语、编程语言、工具、框架等重新实现有 bug 的并发代码。