本文摘抄自张银奎老师的软件调试一书,主要概述各种常用的软件调试技术。
断点(breakpoint)是使用调试器进行调试时最常用的技术之一。其基本思想是在一个位置设置一个“陷阱”,当CPU执行到这个位置时便“跌入陷阱”,即停止执行被调试程序,中断到调试器(break into debugger)中,让调试者进行分析和调试。调试者分析结束后,可以让被调试程序恢复执行。
根据断点的设置空间可以把断点分为如下几种。
根据断点的设置方法,我们可以把断点分为软件断点和硬件断点。软件断点通常是通过向指定代码位置插入专用的断点指令来实现的,比如IA32 CPU的INT 3指令(机器码为0xCC)就是断点指令。硬件断点通常是通过设置CPU的调试寄存器来设置的。IA32 CPU定义了8个调试寄存器:DR0~DR7,可以同时设置最多4个硬件断点(对于一个调试会话)。通过调试寄存器可以设置以上3种断点中的任意一种,但是通过断点指令只可以设置代码断点。
当中断到调试器时,系统或调试器会将被调试程序的状态保存到一个数据结构中——通常称为执行上下文(CONTEXT)。中断到调试器后,被调试程序是处于静止状态的,知道用户输入恢复执行命令。
追踪点(tracepoint)是断点的一种衍生形式。其基本思路是:当设置一个追踪点时,调试器内部会当作特殊的断点来处理。当执行到追踪点时,系统会向调试器报告断点事件,在调试器收到后,会检查内部维护的断点列表,发现目前发生的是追踪点后,便执行这个追踪点所定义的行为,通常是打印提示信息和变量的值,然后变直接恢复被调试程序执行。因为调试器是在执行追踪动作后立刻恢复被调试程序执行的,所以调试者没有感觉到被调试程序中断到调试器的过程,尽管实际上是发生的。
条件断点(conditional breakpoint)的工作方式也与此类似。当用户设置一个条件断点时,调试器实际插入的还是一个无条件断点,在断点命中、调试器收到调试事件后,它会检查这个断点的附加条件。如果条件满足,便中断给用户,让用户开始交互调试;如果不满足,那么便立刻恢复被调试程序执行。
单步执行(step by step)是最早的调试方式之一。简单来说,就是让应用程序按照某一步骤单位一步一步执行。根据每次要执行的步骤单位,又分为如下几种。
单步执行可以跟踪执行每一个步骤,观察代码的执行路线和数据的变化过程,是深入诊断软件动态特征的一种有效方法。但是随着软件向大型化方向的发展,从头到尾跟踪执行一个软件乃至一个模块,一般都不再可行了。一般的做法是先使用断点功能将程序中断到感兴趣的位置,然后再单步执行关键的代码。
打印和输出调试信息(debug output/print)是一种简单而”古老“的软件调试方式。其基本思想就是在程序中编写专门用于输出调试信息的语句,将程序运行的位置、状态和变量取值等信息以文本的形式输出到某一个可以观察到的地方,可以是控制台、窗口、文件或者调试器。
比如,在Windows平台上,驱动程序可以使用DbgPring/DbgPrintEx来输出调试信息,应用程序可以调用OuputDebugString,控制台程序可以直接使用printf系列函数打印信息。在Linux平台上,驱动程序可以使用printk来输出调试信息,应用程序可以使用printf系列函数。
以上方法的优点是简单方便、不依赖于调试器和复杂的工具,因此至今仍在很多场合广泛使用。
不过这种简单方式也有一些明显的缺点,比如需要在被调试的程序中加入代码,如果被调试程序的某个位置没有打印语句,那么便无法观察到那里的信息,如果要增加打印语句,那么需要重新编译和更新程序。另外,这种方法容易影响到程序的执行效率,打印出的文字所包含的信息有限,容易泄露程序的技术细节,通常不可以动态开启、信息不是结构化的、难以分析和整理等。
与输出调试信息类似,写日志(log)是另一种被调试程序自发的辅助调试手段。其基本思想是在编写程序时加入特定代码将程序运行的状态信息写到日志文件或数据库中。
日志文件通常自动按时间去文件名,每一条记录也有详细的时间信息,因此适合长期保存以及事后检查与分析。因此很多需要连续长时间在后台运行的服务器程序都有日志机制。
Windows操作系统提供了基本的日志记录、观察和管理(删除和备份)功能。Windows Vista新引入了名为Common Log File System(CLFS.SYS)的内核模块,用于进一步加强日志功能。Syslog是Linux系统下常用的日志设施。
打印信息和日志都是以文本形式来输出和记录信息的,因此不适合处理数据量庞大且速度要求高的情况。事件追踪机制(Event Trace)正是针对这一需求设计的,它使用结构化的二进制形式来记录数据,观察时再根据格式文件将信息转化为文本形式,因此适用于监视频繁且复杂的软件过程,比如监视文件访问和网络通信等。
ETW(Event Trace for Windows)是Windows操作系统内建的一种事件追踪机制,Windows内核本身和很多Windows下的软件工具(如Bootvis、TCP/IP View)都使用了该机制。
某些情况下,我们希望将发生问题时的系统状态像拍照一样永久保存下来,发送或带走后再进一步分析和调试,这就是转储文件(dump file)的基本用途。立项情况下,转储文件是转储时目标程序运行系统的一个快照,包含了当时内存中的所有信息,包括代码和各种数据。但在实际情况下,考虑到转储文件过大时不但要占用大量的磁盘空间,而且不便于发送和传递,因此转储文件通常分为小、中、大几种规格,最小的通常称为mini dump。
Windows操作系统提供了为应用程序和整个系统产生转储文件的机制,可以在不停止程序或系统运行的情况下转储文件。Linux系统下的转储文件有个更好听的名字,叫做core文件或core转储文件,这个名字应该来源于20世纪50~70年代时流行的磁核内存技术。当时,大块头的磁核存储器是计算机系统中不可或缺的主流内存设备,知道被SRAM和DRAM这样的半导体存储产品所取代。
目前的主流CPU架构都是用栈来进行函数调用的,栈上记录了函数的返回地址,因此通过递归式寻找放在栈上的函数返回地址,便可以追溯出当前线程的函数调用序列,这便是栈回溯(stack backtrace)的基本原理。通过栈回溯产生的函数调用信息称为call stack(函数调用栈)。
栈回溯是记录和探索程序执行踪迹的极佳方法,使用这种方法,可以快速了解程序的运行轨迹,看其”从哪里来,向哪里去“。
因为从栈上得到的只是函数返回地址(数值),不是函数名称,所以为了便于理解,可以利用调试符号(debug symbol)文件将返回地址翻译成函数名。大多数编译器都支持在编译时生成调试符号。微软的调试符号服务器包含了多个Windows版本的系统文件的调试符号。
大多数调试器都提供了栈回溯的功能,比如WinDBG的k命令和GDB的bt命令,他们都是用来观察栈回溯信息的,某些非调试工具也可以记录和呈现栈回溯信息。
所谓反汇编(disassemble),就是将目标代码(指令)翻译为汇编代码。因为汇编代码与机器码有着简单的对应关系,所以反汇编是了解程序目标代码的一种非常直接而且有效的方式。有时我们对高级语言的某一条语句的执行结果百思不得其解,就可以看一下它所对应的汇编代码,这时往往可以更快地发现问题地症结。以bad_div函数为例,看一下它所对应的汇编代码,我们就可知道编译器是将C++中的除法操作编译为无符号整除指令(DIV),而不是有符号整除指令(IDIV)。这正是错误所在。
另外,反汇编的依赖性非常小,根据二进制的可执行文件就可以得到汇编语言表示的程序。这也是反汇编的一大优点。
调试符号对于反汇编有着积极的意义,反汇编工具可以根据调试符号得到函数名和变量名等信息,这样产生的汇编代码具有更好的可读性。
大多数调试器提供了反汇编和跟踪汇编代码的能力。一些工具也提供了反汇编功能,IDA(Interactive Disassembler)是其中非常著名的一个。
观察被调试程序的数据是了解程序内存状态的一种直接方法。很多调试器提供了观察和修改数据的功能,包括变量和程序的栈及堆等重要数据结构。在调试符号的支持下,我们可以按照数据类型来显示结构化的数据。
寄存器值代表了程序运行的瞬时状态。观察和修改寄存器的值也是一种常见的调试技术。
像WinDBG这样的调试器支持同时调试多个进程,每个进程又可以包含多个线程。调试器提供了单独挂起和恢复某一个或多个线程的功能,这对于调试多线程和分布式软件是很有帮助的。