1.2 软件调试的分类
按照不同的标准,可以对软件调试方法进行不同的分类,如表1-1所示。如果以调试所处的阶段来分,可以分为静态调试和动态调试两类;如果以调试器所处理的指令粒度来分,可以分为机器级调试和源码级调试;如果以被调程序是运行在用户空间还是内核空间来分,可以分为任务级调试和系统级调试;如果以被调程序是否与调试器位于同一运行环境来分,可以分为本地调试和远程(交叉)调试等,具体的分类方法多种多样。
表1-1 软件调试的一个简单分类
值得说明的是,这里的分类主要是针对具体的调试手段而言的。对于调试器而言,这样的分类界限却很模糊。比如,以本书中将要重点讲述的GDB调试器为例,尽管多数情况下是作为源码级调试器被使用,但是它也同样可以用于机器级调试;同时,尽管通常情况下它是一个任务级调试器,但是对于打了KGDB补丁的内核而言,它又是一个系统级调试器;至于本地调试与远程调试的区分,则更是显得模糊,GDB独特的设计理念使得它在成为UNIX平台下标准的本地调试工具的同时,也成了应用频繁的远程调试器。下面将对这些分类逐一介绍。
1.2.1 静态调试和动态调试
本书以运行时调试技术为主,但在调试技术发展的早期,还有一种对程序源代码进行静态分析的调试技术[4]。静态调试技术一般都是由编译器或专门的静态分析工具完成的,它对源代码进行语法及安全性方面的分析,防止程序语句的非法跳转或引用未初始化的变量等。相对于编译器所进行的语法检查及安全性检查而言,静态分析/调试工具所做的检查更为严厉。通过采用静态调试工具,可以把很多潜在的错误扼杀在摇篮之中。
但是,即使使用了静态调试工具,也不能保证程序的逻辑一定是对的。这时,就该轮到动态调试器发挥它的作用了。动态调试器是最常用的调试器,通常看到的调试器基本上都属于动态调试器的范畴,如DOS下的debug、Windows下的windbg、Linux下的GDB等。动态调试器也常被称为“断点”调试器,它与用户的交互方式有两种:命令行接口或图形用户接口。DOS下的debug调试器就只提供命令行接口,而Linux下的GDB调试器则既有命令行接口,也有图形用户接口(ddd就是常用的GDB图形前端)[5]。用户通过发出各种调试命令来控制被调程序的执行。一般地,动态调试器都采用在被调程序中插入断点的方式实现对被调程序的控制,当被调程序运行到断点位置时,会触发一个中断或异常,在中断处理或异常处理程序中用户就可以检测被调程序的各种状态,如变量值、寄存器值、函数堆栈和函数调用层次等。动态调试器最常见的用途是用于循环调试(cyclical debugging)。所谓循环调试,是指在程序发生错误之后,在可疑的地方设置断点,反复运行被调程序,检查程序运行过程中的状态,逐步缩小可疑范围,直到找出错误的根源。
1.2.2 机器级调试与源码级调试
除此之外,还可以从调试器所“看见”的指令粒度(granularity of instruction)上把调试器分为两类:机器级调试器和源码级调试器。
机器级调试器处理的是最底层的机器指令。通过它可以观察到计算机最基本的工作情况,可以观察到每一条指令执行后对寄存器、存储器产生的影响。机器级调试不是一件快乐的事情,如果要调试的是嵌入式系统的板级支持包(Board-level Support Package,BSP)或操作系统引导程序的话,可能就会面对机器级调试的痛苦经历。
在高级程序设计语言出现以前,程序员们能使用的编程语言只有汇编语言,自然,采用的调试手段也只有机器级调试。可是,随着高级程序设计语言的出现,程序员们开始了新的时代。高级程序设计语言提供了比汇编语言更好的抽象,底层的机器指令变成了更容易理解和使用的数学语言或公式语言(如Pascal、C、Fortran),程序员们摆脱了直接面对所有底层硬件细节的噩梦。随后,源码级调试器也紧跟着“出世”了。源码级调试器面向高级程序设计语言,屏蔽了大量机器底层细节,允许程序员把精力集中到高级程序设计语言的框架里对程序的执行路径进行追踪。
为了实现源码级调试,调试器必须知道高级语言中的每一条语句与汇编代码的对应关系。多数现代编译器都提供了一个编译选项,用以在编译时生成调试信息。这些调试信息包含了源代码行和编译生成的汇编指令之间的对应关系。当在源代码的某一行上设置断点的时候,实际上是在对应的汇编指令上设置了一条断点指令或非法指令,同时把原来位于该位置处的指令保存到了某个地方。在触发断点之后,调试器把控制权交给用户,此时用户可以查看机器的各种状态。在用户把控制权还给调试器之后,调试器将此前保存的指令恢复,继续往下执行。
1.2.3 任务级调试与系统级调试
从调试范围来分,可以将调试分为任务级调试和系统级调试。
任务级调试运行在操作系统的支持下。任务级调试的范围仅限于任务(某些情况下等价于进程),不包括操作系统内核。
系统级调试的对象通常运行在内核空间,如驱动程序或中断处理程序(Interrupt Service Routine,ISR)。内核通常直接和底层硬件打交道,同时内核关注的重点往往和同步、互斥、共享之类精细的操作有关,一旦内核出现错误,那么整个系统就都无法继续运行了,因为内核被带到了一个不确定的状态。更糟的是,内核错误可能非常难以重现。比如,如果内核中存在一个竞争条件,那么很可能这个错误出现的概率只有万分之一,而在其余的情况下都是正确的。系统级调试往往比任务级调试要困难得多。
1.2.4 本地调试与远程(交叉)调试
前面已经提到了本地调试与远程(交叉)调试。调试器和被调程序运行在同一平台下的调试方法称为本地调试;而调试器和被调程序运行于不同平台下的调试方法称为远程(交叉)调试。
本地调试通常采用进程间通信(Inter-Process Communication,IPC)对被调程序进行控制[3];而远程(交叉)调试则往往是通过某种物理通信媒介控制被调程序。无论本地调试还是远程(交叉)调试,最核心的功能无非两个:通信和控制。在通信功能上,远程(交叉)调试要稍微复杂一些,因为它涉及远程调试协议[6]等一些额外的论题,有兴趣的读者可以参阅参考文献[3][4][7]。