引言

这是一本关于调试的书。多年以来,作为一名程序员写代码和调试,我经历了过山车般的情绪变化:困惑,失望,兴奋,以及不停地重复这些。当处理看上去永无止境的bugs时,这是非常真实的。随着我获得了更多的调试技巧,学习到更多产品和架构知识,大部分问题变得容易解决。但是,还是时不时有一些极难的bugs看上去不能解决,需要花费好多小时甚至是好几天去缩小范围和修复极难的问题。

我记得有一次,花了我几个月的时间去修复一个在客户服务器上, 只在星期二出现的问题(稍后的章节我会讲这个实战例子)。当然,这并不仅仅是只发生在我身上的故事。很多软件工程师具有同样的经历。因为计算机在几十年前就已经侵入到了我们的生活中,软件行业已经积累了一页接着一页的遗留代码。我们中的很多人需要投入大量的时间来维护和完善已有的程序。即使你为新的项目写新的代码,迟早也要调试它。喜欢与否,调试bug是不可避免的。它是我们作为软件开发工程师每天工作的一部分。

另一方面,调试也可以有乐趣。我学到了许多揭露和寻找bugs的技巧,尝到了许多经历挫折和无聊时间后的兴奋。每当我解决具有挑战性的案子,我获得同事们的赞美。它也使得我感觉像一个真正的侦探。随着我在实战中积累更多的经验,我更加相信通过正确的解决方案和基础的技能,bugs可以被有效地发现。这么多年,我总是听到”它是我见过的最奇怪的事情“,”这块代码存在了好多年。如果它有bug,它早该失败了“,或者”我已经审阅我的代码好多遍了,它不可能发生“。

不管一个问题表面上是多么的奇怪或者不可能,当我们在一天结束的时候找到原因,一切都说得通了。毕竟,计算机是那么的虔诚地,它完全地照着我们编写的方式运行,即使那是错误的方式。

这本书讨论调试方法论。我知道已经有很多关于这个话题的优秀书籍。但是,我觉得我可以从我个人现场实战经历贡献一点。从学校毕业以后,跟每一个人一样,我读了各种关于编程和调试的书籍。我曾经以为我理解它们,并且有信心解决任何问题。

但是现实的问题总是比书上给的例子更复杂。我经常在工作上没有任何线索,不能把书上的知识应用到实际的问题。

回想起那些稚嫩的开始时光,一部分原因是我没有完全理解书里的内容,一部分原因是大部分书籍都是从设计和编程的角度。它们可能填充着如何使用调试器的命令,但是当一个问题的类型和维度迷雾重重的时候,它们缺乏如何从开始去分解问题,以及对比调试策略和不同调试器的优劣。我看到很多资历浅的程序员饥渴地启动一个调试器,但是缺乏一个清晰的计划如何去使用它。对于一些人来说,调试就是使用调试器而已。

在这本书,我将通过深入某些内部的结构,展示许多调试过程的实战例子和可操作性的建议,缩小理论知识和可用技能的沟壑。

本书的例子包含了许多代码片段和实战故事。我尽可能地使用实际发生的例子,除非某些例子中,简明性和清楚程度优于实战例子。里面也有许多页用于讲述一些调试器插件和工具的开发。这些工具增强了现有的调试器并且拓展了我们的视野。它们要么提供新的角度或者帮助我们更深入地查看问题。尽管本书主要阐述C/C++,但是底层的方法是通用和独立于具体语言的。

特定调试器、内存管理器或者编译器的内部实现通常不被教材覆盖。这些知识不被大部分程序员熟悉,因为它不是设计和编程阶段关注的,常规调试也不需要。一些人可能认为除了写这些软件的人,没有必要去学习这些知识。但是,它对我们可以观察的和当bug活跃时什么不可以看到,有深远的作用。

如果你在这个方面呆了足够长的时间,你会遇到需要足够深入的理解程序行为的情况。举个例子,调试器可能不可以正确地显示一个局部变量,因为代码优化或者缺少足够的调试符号;如果栈被极大损坏,调试器不可以打印正确的调用栈,因为它依赖保存在栈上的特定数据结构;程序可以在看起来不可能crash的地方crash。在这些情况下,我们需要挖掘得比通常用户更深:我们需要审查编译器布置的栈空间,或者内存管理管理的堆数据结构,或者任何需要手动重新生成调用栈和数据对象的过程。

在本书中,我尝试铺就调试符号,调试器内部实现,内存管理器的内部结构,分析优化后的程序和C++对象模型等等基础知识。这些知识肯定可以帮助你打败学习瓶颈和进一步让你的调试技能进入下一个水平。

许多非法操作如常见的内存溢出,重复释放内存块,访问释放后的对象,使用未初始化的变量等等的后果,根据标准和文档是未定义行为。这基本上说违背的后果的实际行为是完全随机或者依赖实现;它可能在一个环境无害,但是在另一个环境就是灾难性的。经典的例子是:同样有bug的代码在一个平台没发生任何事,工作正常,但是在另一个环境,程序就会crash。最坏的情况是一个bug在开始的时候没有任何错误的迹象,但是在它干完坏事很久以后出现意料外的行为。

从调试的角度看,明白在特定实现的”未定义“的行为是必要的。这跟我们不知道也不应该假设任何关于”未定义“行为的设计和编程实践过程是相违背的。一个具体实现的内部数据结构不同于另一个实现。所以有些人会不去关心学习”未定义“行为的一丝一毫。但是,我们面对可能由未定义行为导致的问题时,这些关于内部数据结构的知识是引领我们走出迷雾,通向最后的解决办法。所以在我看来,知道一个程序在这些未定义行为下如何错误是调试许多困难问题的基本技能。这已经被我自己的工作经历证实。本书中很多例子显示了我们怎样可以借助这些知识来更有效率的调试。

本书假设读者具有基本的计算机科学和软件开发学习经历。他/她至少具有一年的实际编程经验和知道怎么使用一个调试器解决复杂一点的问题。整本书中,我尽量关注书的主题——更有效率地调试。为了不跑题,一些相关的概念和术语被简短描述或者跳跃性地串联在一起。对于核心知识,我尽量以一种实操性方式,可能不完全准确或者学术性,来解释它们。我们的目的是帮助你掌握基本的概念,基于此,你可以快速的应用这些知识到你的调试实践中。

在今天的互联网,可以很方便地从网络获取几乎所有事物的权威性定义。如果你不熟悉一些在书中提及且没有解释很详细的点,或者你只是想知道某个话题更多的细节。你应该可以通过搜索解决疑惑。本书末尾的引用也可以给你提供线索。希望本书没有重复很多你已经知道或者一些可以很容易获取的东西,如怎么使用一个工具性的命令肯定在它的手册中清楚地解释了。

许多章节是独立的,你可以跳跃到任何你感兴趣或者适合你当前工作的章节。跳过你熟悉或者不感兴趣的章节是没有问题的。一些章节进入到调试器、运行时或者语言的底层细节。也许这些知识不是必须的,但是它确实武装你面对更复杂的问题。许多本书的例子都是Linux/x86_64平台的。但是,它的基础方法通过微小的调整可以应用到其他平台。

附录提供了其他平台丰富的例子。鼓励读者阅读跟随本书的源文件,生成对应的项目和把玩它们。这些实战的例子可以进一步帮助你理解书中讨论的话题。你也可以基于它们开发你自己的项目。实际上,一些程序是我为了工作开发的,从此以后变得不可或缺。源代码大部分都是跨平台的。如果你碰巧工作在其中一些平台,它可能马上可以引起你的兴趣。如果不碰巧,那么当你理解这些设计背后的思路,自己写工具也不是那么困难。在附录中,你可以找到更多平台(AIX/PowerPC, Solaris/SPARC, Windows/x86)相关信息,当你需要的时候,它们可以作为参考。

根据我的个人经验,许多程序bugs,特别是用C/C++写的程序,是内存相关的问题。从各个角度理解内存是怎么分配和使用是非常有必要的。本书的大部分聚焦应用程序、编译器、内存管理器、系统加载器/连接器和内核虚拟内存如何从微观到宏观看待一块内存。

内存是动态资源,在程序执行的各个阶段会改变。你将看到内存管理器是如何分配内存,编译器怎么在分配的内存块中布局应用程序的数据结构,栈是如何被局部变量和函数参数使用的,以及系统链接器和加载器跟系统虚拟内存管理器合作创建进程的虚拟地址空间。

应用程序以源文件声明的形式看数据对象:要么是原始的数据类型,要么是其他类型的聚合。编译器会添加更多的隐藏数据成员如指向虚拟函数表的指针和必要时为了对齐的填充。为了满足对齐要求和它自己的隐藏标签,内存管理器会插入额外的字节。系统内核负责使用由页构成的段来记录进程的内存。当研究一个有疑问的数据对象时,一个有经验的工程师可以理解以上全部组件的视角:从编译器视角,它的大小和结构定义;从内存管理器的视角,内存块被释放了还是使用中?从链接器和加载器的角度,它是在代码段,全局数据段、堆数据还是栈段?从内核虚拟内存管理器的角度看,它是不是被某些权限比特保护着?所有这些信息可以作为创建一个理论的基石,验证或证伪程序错误原因的假设。不用说,当调试跟内存相关的问题时,这些知识是无价的。

在许多情况下,调试是一个试错的过程。一个特定的问题有各种可能的原因。一个工程师通常通过分析问题的症状来开始调研,接着根据观察和推理,提出一个原因的假设,证明这个假设,建议一种修复方案,测试和验证修复方案。如果理论无法解释现象或者修复方案不行,他/她需要重复上面的步骤。每个人有他喜欢的方法,风格和工作来完成这个任务。调试同一个问题有许多种方法。这本书展示的例子和技巧是作者过去使用的方法。从各个角度看,他们不是最有效率的方式。我的目的是与你分享这些点子,这样如果你还没有它们,那么你可以把它们加入到你的工具箱里。很多时候,当一种方法看上去没有出路,另外一个使用其他工具的方式可能就是你所需要的。同样地,我也非常欢迎你可以跟我们分享你的经验和调试方法论。

非常感谢李燕启发和鼓励我开始和完成这本书。Ryan Richardson博士指出了这本书许多语法错误。对于他的评语和纠正,我向他表示我深深的感谢。