这是一本关于调试的书。作为一名程序员,在多年的写代码和调试代码的过程中,我一次又一次地经历了过山车般的情绪变化:困惑,沮丧,兴奋,周而复始,特别是在处理看上去永无止境的程序错误(bug)时尤其如此。随着时间的推移,我掌握了更多的调试技能,对要支持的产品和架构有了更多的了解,大部分问题变得容易解决。然而,偶尔也会出现一些棘手的问题,试图缩小范围并解决一个真正困难的问题可能需要数小时甚至数天的时间。
记得有一次,我花了几个月的时间尝试修复一个问题,这个问题的奇怪之处在于它只在每个星期二在客户的服务器上发生(我将在稍后的内存损坏一章中讲述这个实战故事)。我相信这不仅仅是我的故事,很多软件工程师都曾有过同样的经历。因为计算机已经深入我们的生活几十年,软件行业积累了大量的遗留代码。因此,我们中的许多人不得不花费大量时间来维护和完善现有程序。即使你为全新的项目编写代码,迟早也要对它进行调试。不管喜欢与否,调试bug是不可避免的,它已经成为软件开发工程师日常工作的一部分。
另一方面,调试也可以有很多乐趣。在经历了许多挫折和无聊的时刻后,我学到了许多探索和寻找bug的技巧,并开始感到兴奋和满足。每当我解决了案子中具有挑战性的问题时,我都会获得同事们的感谢与赞许。这让我觉得自己像一个能解决问题的真正的侦探。在现实世界的程序中有很多看似很困难的bug,我常常听到类似的抱怨和借口——“这是我见过最奇怪的事情”,“这段代码存在了这么多年,如果它有bug,早该失败了”,或者“我已经审阅我的代码好多遍了,这是不可能发生的”。随着在实战中积累的经验的增加,我更加相信通过正确的解决方案和基本技能,都可以有效地揭示并解决bug。无论表面上看起来多么神秘或不可能的问题,当我们最终找到根本原因时,一切都说得通了,毕竟计算机程序是那么虔诚地完全地照着我们编写的方式运行,即使那是错误的。
本书讨论调试方法论。尽管关于这一主题已经有很多优秀的书籍,但我相信通过总结我个人的实战经验,可以为读者提供更多实用的观察方法和技巧。从学校毕业以后,我阅读了各种关于编程和调试的书籍,曾以为已经完全理解并对解决任何问题都充满信心。然而,实际问题往往比书中的例子更为复杂。我经常在工作中找不到任何线索,无法将书本知识应用于实际问题。
回想起那些初出茅庐的岁月,一方面是我没有完全理解书中的内容,另一方面是大部分书籍都是从设计和编程的角度出发的。它们可能充满了使用调试器命令的技巧,但当问题类型和维度迷雾重重时,它们缺乏如何起步、如何从最基础去分解问题,以及如何选择不同的调试策略和有效利用调试器的各种功能的介绍。我看到许多年轻的工程师在没有明确计划的情况下就急切地启动调试器。对于一些人来说,调试程序就是使用调试器而已。在本书中,我将通过深入挖掘一些内部数据结构,展示许多调试过程的实战例子,并提出可操作的实用建议,以缩小理论知识和可用技术的沟壑。
本书的示例包含了大量的代码片段和实际案例。在编写过程中,我尽可能地运用真实发生的例子,除非在某些情况下,理论性例子的简明性和清晰度优于实战例子。此外,本书还专门介绍了调试器插件和实用工具的开发。这些工具能够增强现有的调试器,拓宽我们的视野,要么提供新的角度审视问题,要么帮助我们更深入地研究问题。尽管本书主要探讨的是C/C++,但书中所介绍的方法和策略是通用的,独立于特定语言。
通常,教材并不覆盖特定调试器、内存管理库或者编译器的内部实现,许多软件开发人员也不熟悉这些知识,因为在设计和编程阶段通常并不需要关注这些内容,而且常规的调试工作也不需要。有些人可能认为,除了软件的开发者之外,其他人没有必要去学习这些知识。然而,这些知识对于我们对可能会观察到的情况以及在错误发生时可能会错过的细节具有深远的影响。
如果你在软件行业待了足够长的时间,就会遇到需要深入理解程序行为的情况。例如,由于代码优化或者缺少足够调试符号,调试器可能无法正确地显示局部变量;如果栈损坏极为严重,调试器无法正确打印调用栈,因为它依赖保存在栈上的特定数据结构;程序也可能在看起来不可能崩溃(crash)的地方崩溃了。在这些情况下,我们必须比普通程序员挖掘得更深:可能需要梳理编译器布局的栈空间,或者内存管理库的堆数据结构,甚至需要手动重新生成调用栈和数据对象。
在本书中,我尝试铺就调试符号、调试器内部实现、内存管理器的内部结构、分析优化后的程序和C++对象模型等基础知识。这些知识肯定可以帮助你突破学习瓶颈,进一步提高调试技能,从而更上一层楼。
许多非法操作的行为,如常见的内存溢出、重复释放内存块、访问释放后的对象、使用未初始化的变量等,根据编程语言的标准和文档都是未定义行为。这基本上意味着这些违规行为的实际结果完全是随机的或取决于具体实现;它们可能在一个环境无害,但是在另一个环境就是灾难性的。一个经典的例子是:同样有bug的代码在一个平台上没有发生任何问题,可以正常运行,但在另一个平台上,程序就会崩溃。最糟糕的情况是一个bug在初始阶段没有任何错误的迹象,在它完成了某些恶意操作很久以后,才出现奇怪和意料之外的行为。
从调试的角度看,理解特定实现中的“未定义”行为是必要的。这与我们不知道也不应该假设任何关于“未定义”行为的设计和编程实践相违背。一种实现的内部数据结构不同于另一种实现。因此,有些人可能选择忽略这些“未定义”行为。但是,当我们面临由未定义行为引起的未知问题时,对这些内部数据结构的理解可以带领我们走出迷雾,找到最终的解决方案。因此,在我看来,了解程序如何因这些“未定义”行为而失败对于调试许多棘手问题至关重要。我的工作经历也证明了这一点。本书中的许多示例将展示如何利用这些知识更有效地进行调试。
本书假设读者具有基本的计算机科学和软件开发学习经历。读者至少具有一年的实际编程经验,并且知道怎么使用调试器解决较为复杂的问题。在整本书中,我致力于关注书的主题——更高效的调试。为了避免偏离主题,一些相关的概念和术语被简要描述或者以跳跃性方式串联在一起。对于核心知识,我尽量以实际操作为主(可能不完全准确或者不具有学术性)来解释。我们的目的是帮助读者掌握基本的概念,并能够快速将这些知识应用到调试实践中。
通过互联网,可以方便地获取几乎所有事物的权威性定义。如果读者对书中提及的内容不太熟悉,或者需要更详细的解释,可以通过网上搜索来解决疑惑。本书末尾的引用也可以为读者提供线索。希望本书没有重复很多读者已经知晓的内容,或者一些可以轻松获取的信息,比如如何使用某个工具的命令,通常都可以在它的手册中找到清晰的解释。
本书的许多章节是独立的,读者可以跳到任何感兴趣或适合当前工作的章节;跳过熟悉或者不感兴趣的章节也没有问题。一些章节会介绍调试器、运行时或者语言的底层细节,也许这些知识并非必需,但它确实能够帮助你应对更复杂的问题。本书的许多例子都使用Linux/x86_64平台,但是底层方法通过微小的调整就可以应用到其他平台上。
附录提供了其他平台的丰富的示例,鼓励读者使用本书提供的源文件和链接生成对应的项目,并加以应用。这些实战的示例可以进一步帮助读者理解书中讨论的话题,也可以作为开发自己项目的起点。事实上,一些程序是我在工作中开发的,从那时起它们就成为不可或缺的工具。其中大部分源代码都是跨平台的,如果碰巧你使用其中某个平台,它可能会立即引起你的兴趣;如果不碰巧,那么当你理解这些设计背后的思路后,自己编写工具也并非难事。
根据我的个人经验,许多程序bug,特别是用C/C++编写的程序,都与内存相关。从各个角度理解内存怎么分配和使用非常必要。本书的大部分内容聚焦于应用程序、编译器、内存管理器、系统加载器/连接器和内核虚拟内存,以及如何从微观到宏观看待一块内存。
内存是动态资源,会在程序执行的各个阶段发生变化。在本书中,读者将了解内存管理器如何分配内存,编译器如何在分配的内存块中布局应用程序的数据结构,以及栈是如何被局部变量和函数参数使用的。此外,读者还将了解系统链接器和加载器如何跟系统虚拟内存管理器合作,创建进程的虚拟地址空间。应用程序以源文件声明的形式看待数据对象:它们要么是原始的数据类型,要么是其他类型的聚合。编译器会添加更多隐藏的数据成员,例如指向虚函数表的指针,并在必要时为了对齐而进行填充。为了满足对齐要求和其自身的隐藏标签,内存管理器会插入额外的字节。系统内核负责使用由页构成的段来记录进程的内存。
当研究一个有疑问的数据对象时,有经验的工程师可以理解以上组件的各个视角:从编译器的角度来看,该数据对象的大小和结构定义是怎样的;从内存管理器的角度看,该数据对象的内存块被释放了还是在使用中;从链接器和加载器的角度看,该数据对象是在代码段、全局数据段、堆数据还是栈段;从内核虚拟内存管理器的角度看,该数据对象是不是被某些权限保护着。所有这些信息可以作为创建一个理论的基石,验证或证伪程序错误原因的假设。毋庸置疑,当调试与内存相关的问题时,这些知识是无价的。
在许多情况下,调试是一个试错的过程。一个特定的问题有各种可能的原因,工程师通常通过分析问题的症状来开始调研,接着根据观察和推理提出一个可能的原因假设,然后证明这个假设,并给出一种修复方案,最后测试和验证修复方案。如果理论无法解释现象或者修复方案不行,该该参数需要重复上面的步骤。调试同一个问题有多种方法,每个人也有自己偏好的方法和风格。本书展示的例子和技巧是我在实践中积累的,旨在与读者分享其中的方法。当一种方法看上去没有出路时,另一种使用其他工具的方法可能就是你所需要的。同样地,非常欢迎读者跟我分享自己的经验和调试方法。
严琦