1.1 什么是软件调试
尽管调试技术在通常情况下是对计算机领域而言的,但它并不是计算机领域的专利,调试也常常发生在CPU与存储器之外的场合。著名的计算机科学家塔嫩鲍姆(Andrew S. Tanenbaum)教授曾在他的经典著作[1]里把程序比喻为菜谱和原料的有机组合。他这样写到,“想象一位有一手好厨艺的计算机科学家正在为他的女儿烘制生日蛋糕。他有做生日蛋糕的食谱,厨房里有所需的原料:面粉、鸡蛋、糖和香草汁等。做蛋糕的食谱即是程序(即用适当形式描述的算法),计算机科学家就是CPU,而做蛋糕的各种原料就是输入的数据”。这里为了说明调试的原理,现在假设这位计算机科学家并没有一手好厨艺(事实上这更接近客观情况一些,尽管塔嫩鲍姆教授可能是一个特例),同时菜谱本身因为年代久远(很可能是从这位计算机科学家的祖母那里传下来的),其中的某些步骤已经不正确了。那么理所当然地,当这位计算机科学家做出生日蛋糕的第一版时,可能会发现蛋糕并没有预期的那么好,蛋糕显得太甜了,而计算机科学家的女儿并不喜欢过于甜腻的食品(很可能是因为电视上正在宣传过量食用甜食的害处)。无论如何,计算机科学家需要重新做一个更好的生日蛋糕。这一次,每做完食谱上的一个步骤之后,计算机科学家都会用汤匙尝一下处于半成品状态的蛋糕是否过甜了。由于计算机科学家是一位很细心的人,因此他很快就发现问题出在香草汁上。半个世纪前,当计算机科学家的祖母为孩子们做生日蛋糕时,那个时候的香草汁还没有加入蔗糖。可是,计算机科学家现在厨房里放的香草汁却是加了30%蔗糖的。于是,计算机科学家决定减少糖的用量,因为香草汁里面已经含有足够多的糖了。这样,通过一步一步的“跟踪调试”,一个漂亮、美味的生日蛋糕就制作完成了。
在上面这个假想的场景中,计算机科学家在完成生日蛋糕第二版的过程中,在食谱中的每一个步骤上停下来,用汤匙品尝蛋糕是否过甜,最后发现问题所在的整个过程,就是调试。计算机科学家在这里采用的“调试”手段有两种:“单步执行”和“观察变量”。除了厨房里的调试之外,这里再假想一个需要“调试”的场景。假设一位驯兽师正在训练一只海豚表演水上钻火圈,尽管这只海豚很聪明,可是驯兽师还是发现当它跃过火圈的时候,动作有点不够流畅。由于海豚跃过火圈的动作很快,并且驯兽师也没有办法让海豚在刚好穿过火圈的时候停下来,所以驯兽师无法看清越过火圈时的细节。于是驯兽师采用了这样一个办法,他在火圈侧面安装了一个数字摄影机。当海豚跃过火圈的时候,摄影机将把全过程录制下来。这样,驯兽师就可以在一台数字播放机或个人计算机上观察海豚完成特技动作的整个过程,可以随时定格画面、回退或者快进,以方便地观察海豚所做的特技动作中的每一个细节。最后,驯兽师发现问题出在海豚的尾鳍上。在这个假想的场景中,驯兽师所做的工作也可以看成是一种“调试”。
不过,厨房里或海洋馆里的“调试”并不是本书关心的重点,本书关心的是计算机系统中的调试技术。一般地,可以给软件调试进行如下的定义。
1. 定义1.1
软件调试是为了发现并排除软件程序中的错误,而通过某种方法控制被调试程序的执行过程,以便随时查看和修改被调试程序执行状态的方法。
在一个具体的程序中,指令与数据从来就是一个不可分割的整体。控制程序的执行过程和查看程序执行状态是任何调试工具和调试手段都必须提供的两个功能。通常情况下,控制程序执行状态的方法有单步执行、设置断点、从指定的地址开始执行和运行到函数(过程)返回等。所有这些方法中,最重要的手段是设置断点。如果说控制程序执行过程是针对指令的调试手段的话,那么查看被调试程序的执行状态实际上就是针对数据所提供的调试手段。这两者是密不可分的。针对数据的调试手段有设置观察点(watchpoint)、追踪点(tracepoint)、查看堆栈、查看CPU寄存器值和查看内存空间等。
上面给出了软件调试的简单定义,那么软件调试的目的又是什么呢?为什么需要软件调试技术呢?软件调试能够解决什么样的问题呢?其实答案很简单,程序员并非超人,除了最简单的hello world程序之外,任何程序员都可能犯错误(有时甚至hello world程序也会存在错误)。正所谓“当局者迷,旁观者清”,为了能够发现程序中的问题,程序员需要一种使自己“置身事外”的方法来观察程序的运行情况。调试技术和调试手段恰好为程序员提供了这样一种能力,使得程序员能够站在被调试程序之外来观察程序中所发生的事情,从而发现程序中存在的逻辑错误或由粗心导致的错误,防止自己迷失在程序本身的迷宫之中。但是,与物理学家们所面临的问题一样,这种“置身事外,冷眼旁观”的手段也是有限度的[2]。在20世纪20年代,量子物理学家发现了所谓的“测不准原理”,即在量子尺度上(也就是在亚原子尺寸上),观察者已经不能把自己与他们正在测量的东西割裂开了。换句话说,观察活动本身就会对实验的结果产生影响。观察者实际已经成为了实验的一个部分。这是亚原子尺度上一个不可避免的事实。当计算机科学家们所要调试的对象在时序上的要求越来越精细、越来越严格时,他们也将会面临与被调试的对象无法割裂开来的尴尬。而这并不是由于调试工具的缺陷导致的,而是由于调试技术本身就无法避免这一问题的困扰。尽管如此,在绝大多数情况下,调试工具仍然是计算机工程人员在万不得已的最后关头使出的终极“杀手锏”。这样的调试工具通常被称为调试器(debugger)。
2. 定义1.2
调试器(debugger)是一种软件工具,通过它可以控制程序的执行流程,并允许开发者查看和/或修改机器状态,从而发现和排除程序中存在的错误。
可以发现,定义1.1和定义1.2有些相似,但不同的是,定义1.1定义的是一种方法,而定义1.2定义的则是一种软件开发工具。
调试器提供了一个受控的执行环境,在这个环境中,程序运行过程中的一切对于程序员都是清清楚楚、明明白白的,不存在“雾里看花”的情况。被调程序成了一个受调试器控制的“傀儡”,调试器可以支配它的一切行动,随时冻结它的状态。任何时候,只要调试器大喝一声“站住”,被调程序就得乖乖地停住;而当调试器发出“继续前进”的命令之后,被调程序才能继续运行。如此这般走走停停,直到调试器认为被调程序已经可以正确工作不用再“发号施令”了为止。
通常,在桌面环境下,调试器都是像上面描述的这样工作的,也就是调试器直接控制被调程序的运行。桌面环境下的调试模型如图1-1所示。表面上看来,调试器直接控制被调程序的运行,但实际上,这是通过操作系统内核来完成的。比如,在Linux内核上,这就是通过ptrace()系统调用来实现的[3]。
图1-1 桌面环境下的调试模型
可是,在嵌入式环境下,一切都变了。因为嵌入式系统往往是资源受限的计算机系统,因此不仅CPU速度可能会比较慢,存储器空间可能也不大,甚至没有键盘鼠标等人机交互设备。这些限制导致了什么样的问题呢?由于CPU速度比较慢和内存空间比较小,因此可能根本无法运行调试器;其次,由于没有人机界面,即使能运行调试器,也可能无法对调试器进行操作和控制,更不要说通过调试器去控制被调试的程序了。上面给出的是硬件资源方面的一些考虑。对于软件环境而言,也存在一些限制。有相当一部分嵌入式操作系统没有提供完善的操作系统功能,可能缺少操作系统的某一项功能(如文件系统或内存管理)。典型的,如μC/OS就只提供了基本的进程管理和内存管理,文件系统并不是默认包含的。而要想运行调试器,文件系统几乎是必不可少的。因此,直接在嵌入式系统上运行调试器的做法非常罕见,通常的做法是采用宿主机(host)加目标机(target)的方式。在目标机上运行被调程序,宿主机则运行调试器。目标机和宿主机之间通过某种媒介通信,典型的媒介有串口、并口、以太网或JTAG。目标机的类型多种多样,但宿主机的角色通常由PC担任。同时,在宿主机上还要配备一套相应的开发工具,如交叉编译器、交叉调试器、性能分析器和映像下载程序等。这一整套工具被称为工具链(Toolchain)。
3. 定义1.3
交叉编译器:一个运行在宿主机平台上的编译程序,它编译出的程序可以在目标机上运行。
至于交叉调试器,后面再进行说明。这里之所以把编译器和调试器都冠以“交叉”(cross)二字,主要是因为在这样的开发方式下,目标机和宿主机往往属于异构的平台,因此用“交叉”二字以示与本地(local)编译器和调试器相区别。
由于目标机和宿主机在物理上是分离的,因此其调试方式也就和通常的桌面环境下的调试有了区别。目前采用得最多的是调试代理(Debug Agent)加调试器的方式。调试代理运行在目标机上,而调试器则运行在宿主机上,它们之间采用某种协议通信,比如GDB调试器采用RSP(Remote Serial Protocol,远程串行协议),而ARM公司的调试工具Angel采用的则是ADP(Angel Debug Protocol,Angel调试规程)。采用这样的调试方式以后,真正控制程序的是调试代理,而调试代理又受宿主机上的调试器控制。
4. 定义1.4
调试代理是在目标程序的可执行映像中加入的一个用于调试用途的特殊软件成分,它可以和运行在宿主机端的调试器通信,并根据调试器发出的指令控制目标程序的运行,同时将目标程序的执行状态反馈给调试器。
这就好比两军对阵的时候,我方在敌人的军营里面安插了一名卧底,每当我方希望控制敌人的行踪的时候,就通过某个秘密的通信渠道给卧底布置任务,由他去完成。在调试嵌入式软件的时候,调试代理扮演的就是“卧底”的角色,它不仅要和自己的上司——调试器联络、接受任务,而且还要控制被调程序的执行过程,并把结果反馈给调试器。嵌入式环境下的调试模型如图1-2所示。
图1-2 嵌入式环境下的调试模型
对比图1-1和图1-2可以发现,两种调试模型在结构上其实有很相似的地方,那就是抽象地看,调试器对被调程序的控制,都是以某种通信方式来实现的。然而两者间的区别也很明显,在桌面环境下,这种通信过程由位于底层的操作系统内核完成;而嵌入式环境下的调试则是经由某种通信介质来完成的,并且还在被调程序与通信介质之间插入了一层调试代理,调试器与调试代理的通信需要遵守某种通信协议。