软件调试(第2版)卷1:硬件基础

978-7-115-49250-0
作者: 张银奎
译者:
编辑: 陈冀康

图书目录:

详情

本书是当前集中介绍软件调试主题的、国内较为权威的著作。全书围绕如何实现高效调试这一主题,深入系统地介绍了以调试器为核心的各种软件调试技术。随着软件技术发展,这本新版新增很多重要内容,所以计划分3卷出版,第一卷为硬件基础,第二卷为Windows平台调试,第三卷为Linux平台调试。第一卷共14章,分别介绍软件调试基础,CPU技术、机器架构、JTAG调试、GPU技术、可调式设计和实现等专业性的话题。

图书摘要

版权信息

书名:软件调试(第2版)卷1:硬件基础

ISBN:978-7-115-49250-0

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

著    张银奎

责任编辑 陈冀康

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315


本书堪称是软件调试的“百科全书”。作者围绕软件调试的“生态”系统(ecosystem)、异常(exception)和调试器 3 条主线,介绍软件调试的相关原理和机制,探讨可调试性(debuggability)的内涵、意义以及实现软件可调试性的原则和方法,总结软件调试的方法和技巧。

第1卷主要围绕硬件技术展开介绍。全书分为4篇,共16章。第一篇“绪论”(第1章),介绍了软件调试的概念、基本过程、分类和简要历史,并综述了本书后面将详细介绍的主要调试技术。第二篇“CPU及其调试设施”(第2~7章),以英特尔和ARM架构的CPU为例系统描述了CPU的调试支持。第三篇“GPU及其调试设施”(第8~14章),深入探讨了Nvidia、AMD、英特尔、ARM和Imagination 这五大厂商的GPU。第四篇“可调试性”(第15~16章),介绍了提高软件可调试性的意义、基本原则、实例和需要注意的问题,并讨论了如何在软件开发实践中实现可调试性。

本书理论与实践紧密结合,既涵盖了相关的技术背景知识,又针对大量具有代表性和普遍意义的技术细节进行了讨论,是学习软件调试技术的宝贵资料。本书适合所有从事软件开发工作的读者阅读,特别适合从事软件开发、测试、支持的技术人员,从事反病毒、网络安全、版权保护等工作的技术人员,以及高等院校相关专业的教师和学生学习参考。


我是1949年进入麻省理工学院(MIT)的。就在那一年,第一台存储程序计算机在英国的剑桥和曼彻斯特开始运行。我的一个本科同学Kenneth Ralston是学数学的,他偶尔会和我如痴如醉地谈起一台神秘的机器,说这台机器当时正在MIT附近的Smart街上的Barta楼内组装。我的好奇心后来在1954年的秋天得到了满足,那时我开始学习我的第一门计算机课程“数字计算机编码与逻辑”。那门课程是Charles Adams教的,他是自动编程(现在称为编译)领域的先锋。当时使用的机器叫作“旋风”,被放置在一间充满了真空管电路的房间内。它由美国海军投资建立,用来研究飞机模拟。

因为我的知识背景及我所完成的电子工程专业的硕士课程,一个助研基金约请我在旋风计算机上用“最速下降法”解决一个最优化问题。这让我彻底熟悉了那一套烦琐的程序准备工作。我们以旋风机器的汇编语言编写程序,然后使用Friden电传打字机将以字符和数字表示的代码以打孔的方式输出到纸带上。纸带是用一个Ferrante光电读出器读入计算机的,然后交给“综合系统2”的“系统软件”进行处理。处理结果是一个二进制纸带,以大约每秒钟10行的速度打孔出来,每行代表一个6位字符。而后,用户可以调用一个简单的装载程序(装载程序是保存在几个可以来回交换的内存单元中的)将二进制的纸带装入2048字的内存中,之后就期待着程序的正常运行。用户也可以在控制台的电传打字机上调用“综合系统”的输出例程来把结果打印出来,或者把它们写到一个原始的磁带单元中,留待以后离线打印。

那时最漂亮的输出设备是CRT显示屏,用户可以在上面一个点一个点地画出图表和图片。上面配备了一部照相机,可以把显示的图片录制在胶片上。系统程序员已经开发好了“崩溃照相”功能,可以把程序出错时内存中的内容显示在CRT显示屏上。用户可以在第二天早上取到显影后的胶片,然后用一个缩微胶卷阅读器来研究上面的八进制数字。在那时,这是调试旋风程序的主要方法,除此之外,就是把中间结果打印出来。

大多数我们这样的普通用户不知道的是,在Barta楼里有一个后屋,在那里第一个基于计算机的飞机跟踪和威胁检测系统上的分类工作正在进行。那里放置了一些更先进的设备,有很多台PPI(计划和位置标识器)显示器,并且已经开发出了第一个定点设备—— 光笔,用来跟计算机实时交互。

旋风计算机最初的主内存是威廉斯管型的,这还不足以满足实时操作的可靠性标准。这一需求带动了相关研究工作并促进了磁心内存的产生。旋风工程师建造了一个非常简单的计算机,称作内存测试机(MTC),用来测试新的内存。因为新内存表现良好,所以立刻把它安装在旋风计算机上,而后MTC也就功成身退了。

旋风计算机上的工作促使了MIT林肯实验室的成立,实验室的主要责任是基于旋风计算机上的实时系统技术开发一个美国国家空中防御系统。同时,林肯实验室也进行了计算机技术的研究,并建立了两台使用新的晶体管技术的机器TX-0和TX-2。之所以编号都是偶数,是因为奇数(odd)在英文中同时有古怪的意思,主管设计者之一Wesley Clark曾经说:“林肯不做奇数的(古怪的)计算机”。TX-0和TX-2的关系类似于MTC和旋风的关系:TX-0用于测试非常大的(按当时标准)内存,然后这些内存再用于功能更强大的TX-2。这些新机器继承了旋风系统中使用CRT显示屏和发光笔这些与用户实时交互的能力,同时也保留了使用纸带作为程序的主要介质。

在开发TX-0的同时,在MIT安装了一台IBM 704机器。它用来补充并最终接替了旋风作为MIT一般用户的主计算机。当林肯实验室不再需要TX-0后,MIT电子工程系长期租用了它。MIT的师生(特别是电子研究实验室的师生),都为拥有了一台计算机而大喜过望,因为从此研究人员便可以自由使用并亲手操作这台计算机,这要比IBM 704计算机采用的批处理方式方便得多。

我于1958年8月完成了我的博士论文,成为一个四处寻找机遇的学校教员。我的新办公室在康普顿实验室楼(26号楼)的二楼。有一天那里发生的事情引起了我的注意,人们正在一块宽广的区域安装一台TX-0,它的位置就在IBM 704计算机的正上方。

与TX-0一起到来的软件工具只有两个,一个是简单的汇编器程序,另一个是“UT-3”(3号工具纸带)。两个程序都是二进制打孔纸带的形式,没有源代码。因为它们是以八进制代码手工输入的。UT-3通过一个控制台打字机与用户交互(这里仍然是一个电传打字机,它包含了普通打字机的功能,可以被用户或被TX-0所驱动,将输入的字符传递到计算机或打印在纸上;这台打字机还带有一个机械纸带打孔器和阅读器,可以将字符打在纸带上或从纸带把字符读入计算机中)。用户可以以八进制形式把数据输入到指定的内存位置,也可以要求打印指定内存位置或区域的内容。在MIT,我们马上着手给这两个程序增加功能。汇编器最后演化为一个叫作MACRO的程序,除了有其他熟悉的汇编语言功能外,它还支持宏指令(宏功能是从Doug McIlroy在贝尔实验室的研究工作中得到启发的)。

有了汇编器后,就使得大范围重写和扩展UT-3成为可能。Tom Stockham和我使新的程序支持符号,新的程序可以使用汇编器生成的符号表。我们把这个程序称作FLIT(电传打字机询问纸带),这个名字仿用了当时一个很常用的杀虫喷雾剂的名字(当Grace Hopper在哈佛的继电器计算机上工作时,跟踪到一次故障是由于继电器触点上的一只飞蛾造成的,从此人们开始把计算机的问题称作bug,即“臭虫”)。FLIT最重要的功能是为调试程序(“除虫”)提供了断点设施。用户可以要求FLIT在被测试程序中向指定的指令位置插入最多4个断点。当被测试的程序遇到一个断点时,FLIT 会通知用户,并且允许用户分析或修改内存的内容。分析结束后,用户可以要求FLIT继续执行程序,就像没有中断过一样。FLIT程序是后来的DDT(另一种杀虫剂)调试程序的典范,DDT是MIT的学生为DEC公司生产的PDP-1计算机开发的。

FLIT(以及TX-0)的缺点之一是,没有办法防止被测试程序向调试程序占用的内存里存储数据,这会使调试程序停止工作。在给DEC PDP-1建立分时系统时,我们做了特别的设计,使得DDT与待测试的程序在各自的地址空间中执行,但DDT仍可以观察和改变被测试程序中的信息。我们把它称为“隐身调试器”。为了提供这种保护,需要对PDP-1增加一些逻辑,它们是随着为支持分时系统而做的更改和补充一起安装的。这个系统在1963年前后开始运行。

PDP-1上的分时系统为伯克利加州大学在SDS 940上建立的分时系统提供了典范(L. Perter Deutsch兜里装着的那个小操作系统从MIT转移到了伯克利加州大学)。我隐约地相信,隐身调试器的机制对于DEC PDP-11/45的设计产生了重要影响,贝尔实验室就为这个系统开发了UNIX。

Jack B. Dennis

2008年4月于马萨诸塞州贝尔蒙特


在900多年前的一个秋夜,一轮明月高高地挂在黄州的天空。夜深了,很多人都已经入睡。但在承天寺的庭院里,还有两个人在散步。他们一边交谈,一边欣赏美丽的夜景。洁白的月光泼洒在庭院里,像是往庭院里注入了一汪汪清水,把地面变成了水面,清澈透明。翠竹和松柏的影子映在其中,随风摇摆,仿佛水草在晃动。这两个人中,一位是大文豪苏轼,另一位是他的好朋友张怀民。这一年是公元1083年,苏轼46岁。

可能是在当晚,也可能是在第二日,苏轼写了一篇短文来记录这次夜游。这篇短文便是著名的《记承天寺夜游》。第一次看到这篇散文,我便爱不释手。每次读苏轼文集,都喜欢把这一篇再读一遍。文章很短,不足百字,但意境隽永,令人回味无穷。

“元丰六年十月十二日夜,解衣欲睡,月色入户,欣然起行。 念无与为乐者,遂至承天寺寻张怀民。怀民亦未寝,相与步于中庭。庭下如积水空明,水中藻荇交横,盖竹柏影也。”

文末的议论尤其脍炙人口:“何夜无月?何处无竹柏?但少闲人如吾两人者耳。”

诚然,月夜常有,竹子和松树也很平常,但是这样的夜游不常有。

2013年深秋,与十几位喜欢调试技术的朋友在庐山五老峰下的白鹿洞书院聚会,吃过晚饭大家坐在古老的书院里交流调试技术,直到夜里10点左右。然后,大家又聚集在延宾馆的庭院里,一边海阔天空地聊天,一边欣赏美丽的夜景。说话的间隙可以听见院子外面贯道溪的哗哗水声;抬起头,便看到满天的星斗。

2008年6月3日,作者收到了出版社快递给我的《软件调试》第1版,喜不自禁,写了一篇博客,名为“手捧汗水的感觉”。

弹指一挥间,十年过去了。十年中,因为《软件调试》作者认识了很多朋友。他们有不同的年龄,不同的背景,工作在不同的地方,但都有一个共同点——读过《软件调试》。

2011年9月,《软件调试》第1版出版3年后,作者便开始计划和写作第2版。但只坚持了一年便停顿了。之后写写停停,进展很缓慢。直到2016年年底,从工作了十几年的英特尔公司辞职后,作者才又“重操旧业”。

过去的十年中,计算机领域发生了很多重大的变革。顺应这些变革,新的版本需要增加很多内容。简单来说,第2版卷1新增了以下内容。

新增这些内容后,如果再装订成一本书,那么肯定比砖头还厚。经过反复思考和调整,最后终于确定了分卷出版的方案。卷1覆盖处理器等基础内容,卷2、卷3分别介绍Windows系统和Linux系统的调试。

确定了新的分卷结构后,作者强迫自己投入更多的时间写作,快步向前推进。终于在2018年6月把卷1的书稿发给了出版社。

卷1共16章,分为4篇。

第一篇:绪论(第1章)

作为全书的开篇,这一篇介绍了软件调试的概念、基本过程、分类和简要历史,并综述了本书后面将详细介绍的主要调试技术。

第二篇:CPU及其调试设施(第2~7章)

CPU是计算机系统的硬件核心。这一篇以英特尔和ARM架构的CPU为例,系统描述了CPU的调试支持,包括如何支持软件断点、硬件断点和单步调试(参见第4章),如何支持硬件调试器(参见第7章),记录分支、中断、异常和支持性能分析的方法(参见第5章),以及支持硬件可调试性的错误检查和报告机制—— MCA(机器检查架构)(参见第6章)。为了帮助读者理解这些内容,以及本书后面的内容,第2章介绍了关于CPU的一些基础知识,包括指令集、寄存器和保护模式,第3章深入介绍了与软件调试关系密切的中断和异常机制。与第1版相比,第2版不仅扩展了原来关于x86处理器的内容,还新增了ARM处理器的内容。

第三篇:GPU及其调试设施(第8~14章)

这是第1版没有的全新内容,分7章深入探讨了Nvidia、AMD、英特尔、ARM和Imagination这五大厂商的GPU。从某种程度上说,CPU的时代已经过去,GPU的时代正在开启。经历了半个多世纪的发展,CPU已经很成熟,CPU领域的创新机会越来越少。CPU仍会存在,但不会再热门。而GPU领域则像是一块新大陆,有很多地方还是荒野,等待开垦,仿佛19世纪的美国西部,或者20世纪末的上海浦东。

第四篇:可调试性(第15~16章)

提高软件调试效率是一项系统的工程,除了CPU、操作系统和编译器所提供的调试支持外,被调试软件本身的可调试性也是至关重要的。这一篇首先介绍了提高软件可调试性的意义、基本原则、实例和需要注意的问题(参见第15章),然后讨论了如何在软件开发实践中实现可调试性(参见第16章)。第16章的内容包括软件团队中各个角色应该承担的职责,实现可追溯性、可观察性和自动报告的方法。

在内容格式上,第2版也有所变化。首先,新增了名为“格物致知”的实践模块。读者可以下载试验材料,然后按照书中的指导步骤进行操作。在理论和实践方面,朱熹曾说:“言理则无可捉摸,物有时而离。言物则理自在,自是离不得。”这句话的意思是,空讲理论可能让人摸不着头脑,把理论和实践分离开来;相反,讲具体的事物,自然就包含了道理,二者是分不开的。好一个“言物则理自在”,真是至理名言。其实,“言物”除了有朱熹说的“言物则理自在”好处外,还有生动有趣的优点。为此,第2版不仅新增了专门言物的“格物致知”模块,很多章节的正文内容也是本着这个思想来写作的。

另外,第2版还增加了评点模块——“老雷评点”和“格友评点”。“老雷评点”是“格蠹老雷”所评,“格友评点”为“格友”评点。“格蠹老雷”是作者的绰号。“格友”者,“格蠹老雷”之友也。“格”字源于上文所说之格物。在古老的《易经》中,8个基本符号中有一个为震,象征雷,代表着锐意创新和开拓进取。

感谢苏轼,他用优美的文字清晰记录了900多年前的那个夜晚表现了作者心向往之的那种意境,让我们可以穿越时空,领略一代文豪的生活和心灵世界。感谢更多曾经著书立说的前辈,他们用文字向我们传递了他们的思想和智慧。

感谢缔造软件的前辈们,他们创造了一种新的形式来传递智慧。感谢父母,把我生在这个美好的软件时代。乐哉,三生有幸做软件。

因为书,自古便有读书之乐,穿越时空,悟前人心境,获前人智慧。因为软件,今天有调试之乐,电波传语,与硅片对谈,赏匠心之美,品设计之妙。希望本书可以让读者同时体验读书之乐和调试之乐。当然,如果读者能以此结缘,结交一两个可以在月朗星稀之夜“相与步于中庭”的朋友就更好了。

张银奎(Raymond Zhang)

2018年7月25日于上海格蠹轩


现代计算机是从20世纪40年代开始出现的。当时的计算机比今天的要庞大很多,很多部件也不一样,但是有一点是完全相同的,那就是靠执行指令而工作。

一台计算机认识的所有指令被称为它的指令集(instruction set)。按照一定格式编写的指令序列被称为程序(program)。在同一台计算机中,执行不同的程序,便可以完成不同的任务,因此,现代计算机在诞生之初常被冠以通用字样,以突出其通用性。在带来好处的同时,通用性也意味着当人们需要让计算机完成某一件事情时,首先要编写一个能够完成这件事的程序,然后才执行这个程序来真正做这件事。使用这种方法的过程中,人们很快就意识到了两个严峻的问题:一是编写程序需要很多时间;二是当把编写好的程序输入计算机中执行时,有时它会表现出某些出乎意料的怪异行为。因此,首先不得不寻找怪异行为的根源,然后改写程序,如此循环,直到目标基本实现为止,或者因没有时间和资源继续做这件事而不得不放弃。

程序对计算机的重要性和编写程序的复杂性让一些人看到了商机。大约在20世纪50年代中期,专门编写程序的公司出现了。几年后,模仿硬件(hardware)一词,人们开始使用软件(software)这个词来称呼计算机程序和它的文档,并把将用户需求转化为软件产品的整个过程称为软件开发(software development),将大规模生产软件产品的社会活动称为软件工程(software engineering)。

如今,几十年过去了,我们看到的是一个繁荣而庞大的软件产业。但是前面描述的两个问题依然存在:一是编写程序仍然需要很多时间;二是编写出的程序在运行时仍然会出现意料外的行为。同时,后一个问题的表现形式越来越多,在运行过程中,程序可能会突然报告一个错误,可能会给出一个看似正确却并非需要的结果,可能会自作聪明地自动执行一大堆无法取消的操作,可能会忽略用户的命令,可能会长时间没有反应,可能会直接崩溃或者永远僵死在那里……而且总是可能有无法预料的其他情况出现。这些“可能”大多是因为隐藏在软件中的设计失误而导致的,即所谓的软件臭虫(bug),或者软件缺欠(defect)。

计算机是在软件指令的指挥下工作的,让存在缺欠的软件指挥强大的计算机硬件工作是件危险的事,可能导致惊人的损失和灾难性的事件发生。2003年8月14日,北美大停电(Northeast Blackout of 2003)使50万人受到影响,直接经济损失60亿美元,其主要原因是软件缺欠导致报警系统没有报警。1999年9月23日,美国的火星气象探测船因为没有进入预定轨道从而导致受到大气压力和摩擦而被摧毁,其原因是不同模块使用的计算单位不同,使计算出的轨道数据出现严重错误。1990年1月15日,AT&T公司的100多台交换机崩溃并反复重新启动,导致6万用户在9h中无法使用长途电话,其原因是新使用的软件在接收到某一种消息后会导致系统崩溃,并把这种症状传染给与它相邻的系统。1962年7月22日,“水手一号”太空船发射293s后因为偏离轨道而被销毁,其原因也与软件错误有直接关系。类似的故事还有很多,尽管我们不希望它们发生。

一方面,软件缺欠难以避免;另一方面,软件缺欠的危害很大。这使得消除软件缺欠成为软件工程中的一项重要任务。消除软件缺欠的前提是要找到导致缺欠的根本原因。我们把探索软件缺欠的根源并寻求其解决方案的过程称为软件调试(software debugging)。

软件调试是在复杂的计算机系统中寻找软件缺欠的根源。这是让软件从业者头疼的一项任务。要在软件调试中游刃有余,需要对软件和计算机系统有深刻的理解,选用科学的方法,并使用强有力的工具。

在复杂的计算机系统中寻找软件缺欠的根源不是一个简单的任务,需要对软件和计算机系统有深刻的理解,选用科学的方法,并使用强有力的工具。这些正是作者写作本书的初衷。具体来说,写作本书的3个主要目的如下。

第2版说明

 

上一段所描述内容将在后续分卷中单独介绍。

总之,作者希望通过本书让读者懂得软件调试的原理,意识到软件可调试性的重要性,学会基本的软件调试方法和调试工具的使用,并能应用这些方法和工具解决问题和学习其他软硬件知识。历史证明,所有软件技术高手都是软件调试高手,或者说不精通软件调试技术不可能成为(也不能算是)软件技术高手。本书希望带领读者走上这条高手之路。

第一,本书是写给所有程序员的。程序员是软件开发的核心力量。他们花大量的时间来调试他们所编写的代码,有时为此工作到深夜。作者希望程序员读完本书后能自觉地在代码中加入支持调试的代码,使调试能力和调试效率大大提高,不再因为调试程序而加班。本书中关于CPU、中断、异常和操作系统的介绍,是很多程序员需要补充的知识,因为对硬件和系统底层的深刻理解不但有利于写出好的应用程序,而且对于程序员的职业发展也是有利的。之所以说写给“所有”程序员是因为本书主要讨论的是一般原理和方法,没有限定某种编程语言和某个编程环境,也没有局限于某个特定的编程领域。

第二,本书是写给从事测试、验证、系统集成、客户支持、产品销售等工作的软件工程师或IT工程师的。他们的职责不是编写代码,因此软件缺欠与他们不直接相关,但是他们也经常因为软件缺欠而万分焦急。他们不需要负责修改代码并纠正问题,但是他们需要知道找谁来解决这个问题。因此,他们需要把错误定位到某个模块,或者至少定位到某个软件。本书介绍的工具和方法对于实现这个目标是非常有益的。另外,他们也可以从关于软件可调试性的内容中得到启发。本书关于CPU、操作系统和编译器的内容对于提高他们的综合能力并巩固软硬件知识也是有益的。

第三,本书是写给从事反病毒、网络安全、版权保护等工作的技术人员的。他们经常面对各种怪异的代码,需要在没有代码和文档的情况下做跟踪和分析。这是计算机领域中非常具有挑战性的工作。关于调试方法和WinDBG的内容有利于提高他们的效率。很多恶意软件故意加入了阻止调试和跟踪的机制,本书的原理性内容有助于理解这些机制。

第四,本书是写给计算机、软件、自动控制、电子学等专业的研究生或高年级本科生的。他们已经学习了程序设计、操作系统、计算机原理等课程,阅读本书可以帮助他们把这些知识联系起来,并深入到一个新的层次。学会使用调试器来跟踪和分析软件,可以让他们在指令一级领悟计算机软硬件的工作方式,深入核心,掌握本质,把学到的书本知识与计算机系统的实际情况结合起来。同时,可以提高他们的自学能力,使他们养成乐于钻研和探索的良好习惯。软件调试是从事计算机软硬件开发等工作的一项基本功,在学校里就掌握了这门技术,对于以后快速适应工作岗位是大有好处的。

第五,本书是写给勇于挑战软件问题的硬件工程师和计算机用户的。他们是软件缺欠的受害者。除了要忍受软件缺欠带来的不便之外,有时软件生产方还将责任推卸给他们,找借口说是硬件问题或使用不当造成的bug。使用本书的工具和方法,他们可以找到很充足的证据来为自己说话。另外,本书的大多数内容不需要很深厚的软件背景,有基本的计算机知识就可以读懂。

或许不属于上面5种类型的读者也可以阅读本书。比如,软件公司或软件团队的管理者、软件方面的咨询师和培训师、大学和研究机构的研究人员、非计算机专业的学生、自由职业者、编程爱好者、黑客,等等。

要读懂和领会本书的内容,读者应具备以下基础。

尽管本书给出了一些汇编代码和C/C++代码,但是其目的只是在代码层次直截了当地阐述问题。本书的目标不是讨论编程语言和编程技巧,也不要求读者已经具备丰富的编程经验。

第2版说明

 

根据读者意见,第2版将分多卷组织。第1版中的第一篇、第二篇和第五篇包含在卷1中,第1版中的第三篇、第四篇和第六篇将包含在后续分卷中。新的结构以卷1为公共基础,其他分卷结合各自的平台环境深入介绍。因为结构变化较大,所以此处关于第1版内容结构的介绍删除。

第2版说明

 

针对第2版的变化,关于本书线索和本书阅读方法的内容略有调整。

本书的内容是按照以下三条线索来组织的。

第一条线索是软件调试的“生态”系统(ecosystem)。

第二条线索是异常(exception)。异常是计算机系统中的一个重要概念,出现在CPU、操作系统、编程语言、编译器、调试器等多个领域,本书逐一对其做了解析。

第三条线索是调试器。调试器是解决软件问题非常有力的工具,它是逐步发展到今天这个样子的。第1章介绍了单纯依赖硬件的调试方法。第4章分析了DOS下调试器的实现方法。第7章介绍硬件仿真和基于JTAG标准的硬件调试器。后续分卷将基于不同的操作系统平台详细介绍调试器的工作原理和用法。另外,全书很多地方都使用了调试器输出的结果,穿插了使用调试器解决软件问题的方法。

本书的厚度决定了不适合一口气将它看完。以下是作者给出的阅读建议。

下载并安装WinDBG调试器。如果你还不了解它的基本用法,那么请先参考WinDBG的帮助文件,学会它的基本用法,能读懂栈回溯结果。有了这个工具后,你就可以尝试本书所描述的相关调试方法,自己在系统中探索书中提到的内容。

建议选择前面提到的三条线索中的一条来阅读。如果你有充裕的时间,那么可以按第一条线索来阅读。如果你想深入了解异常,那么可以按第二条线索来阅读。如果你有难题等待解决,希望快速了解基本的调试方法,那么你可以选择第三条线索,选择阅读与调试器有关的内容。

先阅读每一篇开始处的简介,了解各篇的概况,浏览主要章节,建立一个初步的印象。当需要时,再仔细查阅感兴趣的细节。

以上建议中,第一条是希望读者遵循的,其他建议谨供参考。

这是一本关于软件调试的书,同时它的大多数内容也是依靠软件调试技术来探索得到的。在作者使用的计算机系统中,一个名为Toolbox的文件夹下保存了100多个不同功能的工具软件。当然,使用最多的还是调试器。书中给出的大多数栈回溯结果是使用WinDBG调试器产生的。

写作本书的一个基本原则是首先从有代表性的实例出发,然后从这个实例推广到其他情况和一般规律。例如,在CPU方面作者选择的是IA-32 CPU;在操作系统方面选择NT系列的Windows操作系统;在编译器方面选择的是Visual Studio系列;在调试器方面选择的是Visual Studio内置的调试器和WinDBG。

本书的示例、工具和代码可以从高端调试网站(advdbg.org)免费下载,单击该网站左下方的“特别链接”中的《软件调试》即可。

第2版说明

 

第2版的电子资源网站为advdbg.org网站。

尽管作者和编辑已经尽了最大努力,但是本书中仍然可能存在这样那样的疏漏,欢迎读者通过上面的网站反馈给我们。

人们遇到百思不得其解或者难以解释清楚的问题时可能不由自主地说:“见鬼了。”在软件开发和调试中也时常有这样的情况。钟馗是传说中的“捉鬼”能手,因此我们选取他作为本书的封面人物,希望这本书能够帮助读者轻松化解“见鬼了”这样的复杂问题。

第2版说明

 

第1版书出版后某月,在上海长风公园偶然购得一幅皮影材质的钟馗画像,拿回家后把它装在玻璃镜框中,尺寸大约为50cm×38cm。在英特尔公司工作的几年里,这幅画一直挂在我的办公桌前。

本书的内容完全是作者本人的观点,不代表任何公司和单位。你可以自由地使用本书的示例代码和附属工具,但是作者不对因使用本书内容和附带资料而导致的任何直接和间接后果承担任何责任。

第2版说明

 

倏忽之间,十年过去了,重读这个致谢名单,一个个熟悉的面孔浮现在眼前。时光流转,真情不变,对各位朋友的感激之情永存我心。

首先感谢Jack B. Dennis教授,他向我讲述了大型机时代的编程环境和调试方法以及他和Thomas G. Stockman为TX-0计算机编写FLIT调试器的经过,并专门为本书撰写了短文“历史回眸”。FLIT调试器是作者追溯到的最早的调试器程序。

感谢Windows领域的著名专家David Solomon先生,他回答了我的很多提问,并为本书第1版写了推荐序。

第2版说明

 

因为David Solomon先生所写推荐序主要与Windows系统有关,所以把该推荐序放到了本书后续分卷中。

感谢《Showstopper》一书的作者Greg Pascal Zachary先生,他允许我引用他书中的内容和该书的照片。

感谢CPU和计算机硬件方面的权威Tom Shanley先生,他在计算机领域的著作有十几本,他关于IA-32架构方面的培训享誉全球。感谢他允许我在本书中使用他绘制的关于CPU寄存器的大幅插图(因篇幅所限最终没有使用)。

探索Windows调试子系统让我感受到了软件之美,创造这种美感的一个主要技术专家便是Mark Lucovsky先生,感谢他在邮件中给予我的鼓励。

感谢DOS之父Tim Paterson先生,他向我介绍了他编写8086 Monitor的经过,并允许我使用这个调试器程序的源代码。

感谢Syser调试器的作者吴岩峰先生,我们多次讨论了如何在单机上实现内核调试的技术细节,他始终关心着本书的进度。

感谢我的老板和同事,他们是:Kenny、Michael、Feng、Adam、Jim、Neal、Harold、Cui Yi、Keping、Eric、Yu、Wei、Min、Fred、Rick、Shirley、Vivian、Luke、Caleb、Christina和Starry(请原谅,我无法列出所有名字)。

感谢我的好朋友刘伟力,我们一起加班解决了一个大bug后,他感慨地说“断点真神奇”,这句话让我产生了写作本书的念头。

感谢曾华军(我们共同翻译了《机器学习》)与李妍帮助我翻译了Jack B. Dennis为本书写的“历史回眸”和David Solomon为本书第1版写的推荐序。

感谢以下朋友阅读了本书的草稿,提出了很多宝贵的意见:王毅鹏、王宇、施佳、夏桅、周祥、李晓宁、侯伟和吴巍。

第2版说明

 

以下朋友帮助检查了第2版卷1的初稿,在此表示感谢!

感谢谭添升、彭广杰、郁丛祥、邹冠群、卜道成、金睿、Yajun Yang、张耀欣和黎小红。

感谢本书第1版的编辑周筠和陈元玉,感谢两位编辑对我的一贯支持,以及编辑本书所花费的大量时间。感谢本书第1版美术编辑胡文佳,她的精心设计让本书的封面如此美丽。

第2版说明

 

特别感谢人民邮电出版社为本书安排强大的编辑团队:陈冀康、谢晓芳、吴晋瑜,他们出色的工作让我非常感动。也要感谢我的好朋友张文杰,他为本书第2版精心绘制了很多幅插图。

感谢我的家人,在写作本书的漫长而且看似没有尽头的日子里,她们承担了繁重的家务,让我有时间完成本书。

最后,感谢你阅读本书,并希望你能从中受益!

张银奎(Raymond Zhang)

2008年4月于上海

2018年7月26日更新于上海863软件园


本书由异步社区出品,社区(https://www.epubit.com/)为您提供相关资源和后续服务。

本书提供如下资源:

要获得以上配套资源,请在异步社区本书页面中点击,跳转到下载界面,按提示进行操作即可。注意:为保证购书读者的权益,该操作会给出相关提示,要求输入提取码进行验证。

如果您是教师,希望获得教学配套资源,请在社区本书页面中直接联系本书的责任编辑。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,点击“提交勘误”,输入勘误信息,点击“提交”按钮即可。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以到异步社区在线提交投稿(直接访问www.epubit.com/selfpublish/submission即可)。

如果您是学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT技术图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT技术图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近30年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、AI、测试、前端、网络技术等。

异步社区

微信服务号


1955年,一家名为Computer Usage Corporation(CUC)的公司诞生了,它是世界上第一家专门从事软件开发和服务的公司。CUC公司的创始人是Elmer Kubie和John W. Sheldon,他们都在IBM工作过。他们从当时计算机硬件的迅速发展中看到了软件方面所潜在的机遇。CUC的诞生标志着一个新兴的产业正式起步了。

与其他产业相比,软件产业的发展速度是惊人的。短短60余年后,我们已经难以统计世界上共有多少家软件公司,只知道这一定是一个很庞大的数字,而且这个数字还在不断增大。与此同时,软件产品的数量也达到了难以统计的程度,各种各样的软件已经渗透到人类生产和生活的各个领域,越来越多的人开始依赖软件工作和生活。

与传统的产品相比,软件产品具有根本的不同,其生产过程也有着根本的差异。在开发软件的整个过程中,存在非常多的不确定性因素。在一个软件真正完成之前,它的完成日期是很难预计的。很多软件项目都经历了多次的延期,还有很多中途夭折了。直到今天,人们还没有找到一种有效的方法来控制软件的生产过程。导致软件生产难以控制的根本原因是源自软件本身的复杂性。一个软件的规模越大,它的复杂度也越高。

简单来说,软件是程序(program)和文档(document)的集合,程序的核心内容便是按一定顺序排列的一系列指令(instruction)。如果把每个指令看作一块积木,那么软件开发就是使用这些积木修建一个让CPU(中央处理器)在其中运行的交通系统。这个系统中有很多条不同特征的道路(函数)。有些道路只允许一辆车在上面行驶,一辆车驶出后另一辆才能进入;有些道路可以让无数辆车同时在上面行驶。这些道路都是单行道,只可以沿一个方向行驶。在这些道路之间,除了明确的入口(entry)和出口(exit)之外,还可以通过中断和异常等机制从一条路飞越到另一条,再由另一条飞转到第三条或直接飞回到第一条。在这个系统中行驶的车辆也很特殊,它们速度很快,而且“无人驾驶”,完全不知道会跑到哪里,唯一的原则就是驶入一条路便沿着它向前跑……

如果说软件的执行过程就像是CPU在无数条道路(指令流)间飞奔,那么开发软件的过程就是设计和构建这个交通网络的过程。其基本目标是要让CPU在这个网络中奔跑时可以完成需求(requirement)中所定义的功能。对这个网络的其他要求通常还有可靠(reliable)、灵活(flexible)、健壮(robust)和易于维护(maintainable),开发者通过简单的改造就能让其他类型的车辆(CPU)在上面行驶(portable)……

开发一个满足以上要求的软件系统不是一件简单的事,通常需要经历分析(analysis)、设计(design)、编码(code)和测试(test)等多个环节。通过测试并发布(release)后,还需要维护(maintain)和支持(support)工作。在以上环节中,每一步都可能遇到这样那样的技术难题。

在软件世界中,螺丝刀、万用表等传统的探测工具和修理工具都不再适用了,取而代之的是以调试器为核心的各种软件调试(software debugging)工具。

软件调试的基本手段有断点、单步执行、栈回溯等,其初衷就是跟踪和记录CPU执行软件的过程,把动态的瞬间“凝固”下来,以供检查和分析。

软件调试的基本目标是定位软件中存在的设计错误(bug)。但除此之外,软件调试技术和工具还有很多其他用途,比如分析软件的工作原理、分析系统崩溃、辅助解决系统和硬件问题等。

综上所述,软件是通过指令的组合来指挥硬件,既简单又复杂,是个充满神秘与挑战的世界。而软件调试是帮助人们探索和征服这个神秘世界的有力工具。


著名的计算机科学家布莱恩·柯林汉(Brian Kernighan)说过,“软件调试要比编写代码困难一倍,如果你发挥了最大才智编写代码,那么你的智商便不足以调试它。”

此外,软件调试是软件开发和维护中非常繁重的一项任务,几乎在软件生命周期的每个阶段,都有很多问题需要调试。

一方面是难度很高,另一方面是任务很多。因此,在一个典型的软件团队中,花费在软件调试上的人力和时间通常是很可观的。据不完全统计,一半以上的软件工程师把一半以上的时间用在软件调试上。很多时候,调试一个软件问题可能就需要几天乃至几周的时间。从这个角度来看,提高软件工程师的调试效率对于提高软件团队的工作效率有着重要意义。

本书旨在从多个角度和多个层次解析软件调试的原理、方法和技巧。在深入介绍这些内容之前,本章将做一个概括性的介绍,让读者了解一个简单的全貌,为阅读后面的章节做准备。

本节首先给出软件调试的解释性定义,然后介绍软件调试的基本过程。

什么是软件调试?我们不妨从英文的原词software debug说起。debug是在bug一词前面加上词头de,意思是分离和去除bug。

bug的本意就是“昆虫”,但早在19世纪时,人们就开始用这个词来描述电子设备中的设计缺欠。著名发明家托马斯·阿尔瓦·爱迪生(1847—1931)就用这个词来描述电路方面的设计错误。

关于bug一词在计算机方面的应用,业内流传着一个有趣的故事。20世纪40年代,当时的电子计算机体积非常庞大,数量也非常少,主要用在军事领域。1944年制造完成的Mark I、1946年2月开始运行的ENIAC(Electronic Numerical Integrator and Computer)和1947年完成的Mark II是其中赫赫有名的几台。Mark I是由哈佛大学的Howard Aiken教授设计,由IBM公司制造的。Mark II是由美国海军出资制造的。与使用电子管制造的ENIAC不同,Mark I和Mark II主要是用开关和继电器制造的。另外,Mark I和Mark II都是从纸带或磁带上读取指令并执行的,因此它们不属于从内存读取和执行指令的存储程序计算机(stored-program computer)。

1947年9月9日,当人们测试Mark II计算机时,它突然发生了故障。经过几个小时的检查后,工作人员发现一只飞蛾被打死在面板F的第70号继电器中。取出这只飞蛾后,计算机便恢复了正常。当时为Mark II计算机工作的著名女科学家Grace Hopper将这只飞蛾粘贴到了当天的工作手册中(见图1-1),并在上面加了一行注释——“First actual case of bug being found”,当时的时间是15:45。随着这个故事的广为流传,越来越多的人开始用bug一词来指代计算机中的设计错误,并把Grace Hopper登记的那只飞蛾看作计算机历史上第一个记录于文档(documented)中的bug。

图1-1 计算机历史上第一个记录于文档中的bug

在bug一词广泛使用后,人们自然地开始用debug这个词来泛指排除错误的过程。关于谁最先创造和使用了这个词,目前还没有公认的说法,但可以肯定的是,Grace Hopper在20世纪50年代发表的很多论文中就已频繁使用这个词了。因此可以肯定地说,在20世纪50年代,人们已经开始用这个词来表达软件调试这一含义,而且一直延续到了今天。

尽管从字面上看,debug的直接意思就是去除bug,但它实际上包含了寻找和定位bug。因为去除bug的前提是要找到bug,如何找到bug大都比发现后去除它要难得多。而且,随着计算机系统的发展,软件调试已经变得越来越不像在继电器间“捉虫”那样轻而易举了。因此,在我国台湾地区,人们把software debug翻译为“软件侦错”。这个翻译没有按照英文原词直译,超越了单指“去除”的原意,融入了“侦查”的含义,是个很不错的意译。

在我国,我们通常将software debug翻译为“软件调试”,泛指重现软件故障(failure)、定位故障根源并最终解决软件问题的过程。这种理解与英语文献中对software debug的深层解释也是一致的,如《微软计算机综合词典》(第5版)对debug一词的解释是:

debug vb. To detect, locate, and correct logical or syntactical errors in a program or malfunctions in hardware.

对软件调试另一种更宽泛的解释是指使用调试工具求解各种软件问题的过程,例如跟踪软件的执行过程,探索软件本身或与其配套的其他软件,或者硬件系统的工作原理等,这些过程有可能是为了去除软件缺欠,也可能不是。

尽管取出那只飞蛾非常轻松,但为了找到它还是耗费了几个小时的时间。因此,软件调试从一开始实际上就包含了定位错误和去除错误这两个基本步骤。进一步讲,一个完整的软件调试过程是图1-2所示的循环过程,它由以下几个步骤组成。

图1-2 软件调试过程

第一,重现故障,通常是在用于调试的系统上重复导致故障的步骤,使要解决的问题出现在被调试的系统中。

第二,定位根源,即综合利用各种调试工具,使用各种调试手段寻找导致软件故障的根源(root cause)。通常测试人员报告和描述的是软件故障所表现出的外在症状,比如界面或执行结果中所表现出的异常;或者是与软件需求(requirement)和功能规约(function specification)不符的地方,即所谓的软件缺欠(defect)。而这些表面的缺欠总是由一个或多个内在因素导致的,这些内因要么是代码的行为错误,要么是“不行为”(该做而未做)错误。定位根源就是要找到导致外在缺欠的内因。

 老雷评点 

“不行为”三字应连读,本书第1版中无引号,有读者断句为“要么-是不-行为”,问我“是不”是否该为“不是”。在此致谢。

第三,探索和实现解决方案,即根据找到的故障根源、资源情况、紧迫程度等设计和实现解决方案。

第四,验证方案,在目标环境中测试方案的有效性,又称为回归(regress)测试。如果问题已经解决,那么就可以关闭问题;如果没有解决,则回到第三步调整和修改解决方案。

在以上各步骤中,定位根源常常是最困难也是最关键的步骤,它是软件调试过程的核心。如果没有找到故障根源,那么解决方案便很可能是隔靴搔痒或者头痛医脚,有时似乎缓解了问题,但事实上没有彻底解决问题,甚至是白白浪费时间。

1.1节介绍了软件调试的定义和基本过程。本节将进一步从3个方面介绍它的基本特征。

诚如Brian Kernighan先生所说的,软件调试是一项复杂度高、难度大的任务。以下是导致这种复杂性的几个主要因素。

第一,如果把定位软件错误看作一种特殊的搜索问题,那么它通常是个很复杂的搜索问题。首先,被搜索的目标空间是软件问题所发生的系统,从所包含的信息量来看,这个空间通常是很庞大的,因为一个典型的计算机系统中包含着成百上千的硬件部件和难以计数的软件模块,每个模块又常常包含着数以百万计的指令(代码)。其次,这个搜索问题并没有明确的目标和关键字,通常只知道不是非常明确的外在症状,必须通过大量的分析才能逐步接近真正的内在原因。

第二,为了探寻问题的根源,很多时候必须深入到被调试模块或系统的底层,研究内部的数据和代码。与顶层不同,底层的数据大多是以原始形态存在的,理解和分析的难度比顶层要大。举例来说,对于顶层看到的文字信息,在底层看到的可能只是这些文字的某种编码(ANSI或UNICODE等)。对于代码而言,底层意味着低级语言或汇编语言甚至机器码,因为当无法进行源代码级的调试时,我们不得不进行汇编一级的跟踪和分析。对于通信有关的问题,底层意味着需要观察原始的通信数据包和检查包的各个部分。另外,很多底层的数据和行为是没有文档的,不得不做大量的跟踪和分析才能摸索出一些线索和规律。从API的角度来看,底层意味着不仅要理解API的原型和使用方法,有时还必须知道它内部是如何实现的、执行了哪些操作,这一点也证实了Brian Kernighan所说的“调试要比编写代码困难”。

 老雷评点  

人生的境界在于高度,有高度方能俯瞰世间万物,超然物外。软件的境界在于深度,有深度方能穿透纷纭表象,直击内里。从业十几年中,老雷的电脑中一直有一个名叫dig的目录,里面放着老雷最看重的文档和资料,包括《软件调试》的书稿。表象浮华如过眼烟云,深挖、深挖再深挖,挖之弥深,意志弥坚。

第三,因为要在一个较大的问题域内定位错误,所以要求调试者必须有丰富的知识,熟悉问题域内的各个软硬件模块以及它们之间的协作方式。从纵向来看,要理解系统从最上层到最下层的各个层次。从横向来看,要理解每个层次内的各个模块。对于每个模块,不仅要知道其概况,有时还必须深刻理解其细节。举例来说,对于那些包含驱动程序的软件,有时必须同时进行用户态调试和内核态调试,这就要求调试者对应用程序、操作系统和硬件都要有比较深刻的理解。

第四,每个软件调试任务都有很多特殊性,或者说很难找到两个完全相同的调试任务。这意味着,在执行一个软件调试任务时,很难找到可以模仿或借鉴的先例,几乎每一步都必须靠自己的探索来完成。而编写代码和其他软件活动通常有示例代码或模板可以参考或套用。

第五,软件的大型化、层次的增多、多核和多处理器系统的普及都令软件调试的难度增加了。

以上介绍的第一、第二、第五个因素是软件调试所固有的,第三、第四个因素是可以随着软件技术的发展和人们对软件调试重视程度的不断提高而改善的。

就像侦破一个案件所需的日期很难确定一样,对于一个软件错误,到底需要多久才能定位到它的根源并解决这个问题是一个很难回答的问题。这是因为软件调试问题的问题域比较大,调试过程中包含的随机性和不确定性很多,调试人员对问题及相关模块和系统的熟悉程度、对调试技术的熟练程度也会加入很多不确定性。

调试任务的难以预测性经常给软件工程带来重大的麻烦,其中最常见的便是导致项目延期。事实上,很多软件项目的延期是与无法定位和解决存留的bug有关的。Grey Pascal Zachary[2]的著作生动地讲述了Windows NT(3.1)内核开发中因严重bug而多次延期的故事(详见本书后续分卷)。比NT 3.1还不幸的项目有很多,在它们被多次延期后,仍然有大量的问题无法解决,最后因为资金等问题不得不被取消和放弃。

在现实中,很多软件难题经常成为整个项目的瓶颈,是项目团队中所有人关注的焦点,包括市场部门和一些高级管理者。这时,对于接受调试任务的工程师来说,除了要面对技术上的难题外,还要承受很多其他方面的压力。这种压力有时会加快问题的解决,有时会使他们手忙脚乱而变得效率更低。

对于如何才能更好地预测软件调试任务的完成时间,目前还没有很有效的方法,为了降低风险,项目团队应该尽可能地让经验丰富的工程师来做预测,并综合考虑多个人的估计结果。

 老雷评点  

沧海横流,方显英雄本色。面对高难的bug,当芸芸众生都望而却步的时候,真的高手会知难而上,力挽狂澜,并因此脱颖而出,建立起在团队中的声望。很多程序员同行常常为自己的职业方向困惑,不知道做技术的出路在哪里,年纪大了怎么办。老雷的经验是选择软件调试这样有难度的技术方向钻研下去,不断提升自身的价值。调试技术不仅本身具有很强的实用性,还可以以它作为工具快速学习其他技术,不断增强自己的技术。

很多调试机制是操作系统、中央处理器和调试器相互协作的复杂过程,比如Windows本地调试中的软件断点功能通常是依赖于CPU的断点指令(对于x86,即INT 3)的,CPU执行到断点指令时中断下来,并以异常的方式报告给操作系统,再由操作系统将这个事件分发给调试器。

另外,软件调试与编译器有着密切的关系。软件的调试版本包含了很多用来辅助软件调试的信息,具有更好的可调试性。调试信息中很重要的一个部分便是调试符号,它是进行源代码级调试所必需的。

综上所述,软件调试与计算机系统的硬件核心(CPU)和软件核心(操作系统)都有着很紧密的耦合关系,与软件生产的主要机器——编译器也息息相关。因此,可以说软件调试具有广泛的关联性,这有时也被称为系统性。

软件调试的广泛关联性增加了理解软件调试过程的难度,同时也导致软件调试技术难以在短时间内迅速发展和升级。因为要开发一种新的调试手段,通常需要硬件、操作系统和工具软件三个环节的支持,要涉及很多厂商或组织。这也是软件调试技术滞后于其他技术的一个原因。一般来说,对于一种新出现的软硬件技术,对应的有效软件调试技术要滞后一段时间才出现。

从学习的角度来看,软件调试的广泛关联性使其成为让学习者达到融会贯通境界的一种绝好途径。在基本掌握对CPU、操作系统、编译器、编程语言等知识后,学习者可以通过学习软件调试技术和实践来加深对这些知识的理解,并把它们联系起来。

 老雷评点  

无论学习什么技术或者学问,要达到融会贯通的境界,都要付出大量辛勤的汗水。使用调试方法的好处是有针对性,生动高效,不枯燥。比如今日要学习文件系统,那么便把断点设在文件系统的函数上,命中后观察谁在调用它,它又去调用谁,如此坚持不懈,“至于用力之久,而一旦豁然贯通焉,则众物之表里精粗无不到,而吾心之全体大用无不明矣。”(朱熹语)

计算机领域的拓荒者们在设计最初的计算机系统时,就考虑到了调试问题——既包括如何调试系统中的硬件,又包括如何调试系统中的软件。现代计算机是从20世纪40年代开始出现并迅速发展起来的,经历了从大型机到小型机再到微型机的几个主要发展阶段。

关于早期大型机和小型机的原始文档已经成为珍贵的历史资料了,大多被收藏在博物馆中。但幸运的是,在作者收集到的关于早期计算机的有限资料中,几乎每一本都包含了关于调试的内容。这不仅是因为运气,更是因为当时人们就非常重视调试。

本节将以大型机、小型机和微型机三个阶段中有代表性的计算机系统为例,介绍它们实现调试功能的方式,旨在勾勒出软件调试的简要发展历史,帮助读者了解典型软件调试功能的演进过程。

UNIVAC Ⅰ(Universal Automatic ComputerⅠ)是世界上最早大规模生产的商用现代计算机,之前的计算机都是只生产一台而且用于军事和学术领域。从1951年开始,共有46台UNIVAC Ⅰ销售给不同的公司和组织,每台的售价都高于100万美元,其中一些一直工作到1970年。1952年,哥伦比亚广播公司租用UNIVAC Ⅰ准确预测出了当年美国总统的大选结果,这不仅使UNIVAC声名大振,也使人们对计算机的功能有了新的认识。

与需要一个楼面来安放的ENIAC相比,UNIVAC Ⅰ已经小了很多,但整个系统仍然需要一个30多平方米的房间才能放得下。典型的UNIVAC Ⅰ系统由主机(central computer)、磁带驱动器(名为UNISERVO,最多可配置10台)、打印机(uniprinter)、打字机(typewriter)、监视控制台(supervisory control)和用于维护的示波器所组成。

在写字台大小的UNIVAC Ⅰ监视控制台上有很多指示灯和开关。其中有一个名为Interrupted Operation Switch(IOS)的开关(见图1-3)与软件调试有着密切的关系。

图1-3 UNIVAC Ⅰ监视控制台上的IOS开关

IOS开关共有中间和上、下、左、右5个位置,分别代表5种运行模式。中间位置代表正常模式,在此模式下计算机会连续执行内存中的程序指令,因此这个模式又称为连续(continuous)模式。其他4 个位置代表不同作用的“单步”模式,分别为ONE OPERATION(上)、ONE INSTRUCTION(下)、ONE STEP(左)和ONE ADDITION(右),即一次执行一个操作,一次执行一条指令,一次执行一步,一次执行一次加法运算。

当IOS开关位于4种单步模式之一时,CPU执行完一条指令或一个操作后便会停下来,让用户检查当前的寄存器和内存状态。在检查后,只要按键盘(监视控制台的一部分)上的开始键(START BAR),便可以让系统继续执行。

UNIVAC Ⅰ的操作手册详细介绍了IOS开关的使用方法、如何使用不同的模式来启动和调试程序以及诊断软硬件问题。

 老雷评点  

2011年9月,在位于加州山景城(Moutain View)的计算机历史博物馆中,老雷意外看到一台UNIVAC Ⅰ 陈列在那里,上文提到的IOS开关赫然在眼前。这让老雷几乎泫然欲泣,于是从不同角度拍照,流连许久不忍离去。

作者不能确认在UNIVAC Ⅰ之前的计算机是否使用了类似IOS这样的硬件开关来控制程序单步执行。但可以说,这是比较早的单步执行方式,而且这种方式一直延续到小型机时代。在图1-4所示的著名小型机PDP-1的控制面板照片上,右上角的3个开关中,中间一个便是SINGLE STEP(单步),其下方是SINGLE INST(Single Instruction)(单指令)。

1971年,Intel成功推出了世界上第一款微处理器4004,标志着计算机开始向微型化方向发展。1978年,x86 CPU的第一代8086 CPU问世,在其标志寄存器(FLAGS)中(见图1-5),专门设计了一个用于软件调试的标志位,叫作TF(Trace Flag),在第8位(Bit 8)。

图1-4 PDP-1的控制面板

图1-5 8086 CPU的标志寄存器(FLAGS)

TF位主要是供调试器软件来使用的,当用户需要单步跟踪时,调试器会设置TF位,当CPU执行完一条指令后会检查TF位,如果这个位为1,那么便会产生一个调试异常(INT 1),目的是停止执行当前的程序,中断到调试器中。

从上面的介绍中,我们看到了单步执行功能从专门的硬件开关向寄存器中的一个标志位演进的过程。这种变化趋势是与计算机软硬件的总体发展相适应的。因为在UNIVAC Ⅰ时代,还没有完善的软件环境和调试器软件,所以使用一个专门的硬件开关是一种很合理有效的方案。在微处理器出现的时代,软件已经大大发展起来,操作系统和调试器都已经比较成熟,因此,使用寄存器的一个标志位来代替专门的硬件也变得水到渠成,因为这样不仅简化了硬件设计、降低了成本,还适合让调试器软件以程序方式控制。

在UNIVAC Ⅰ的43条指令中,有一条使用逗号(,)表示的指令,是专门用来支持断点功能的,称为逗号断点(comma breakpoint)指令。同时,在UNIVAC Ⅰ的监视控制台上有一个名为逗号断点的两态开关(comma breakpoint switch),如果按下这个开关,那么当计算机执行到逗号断点指令时就会停下来,让用户检查程序状态,进行调试。如果没有按下开关,那么计算机会将其视作跳过(skip)指令,不做任何操作,执行后面的指令。

除了逗号断点指令,UNIVAC的打印指令50m(m为内存地址)也可以产生断点效果。它是与监视控制台上的输出断点开关(output breakpoint switch)配合工作的。这个开关有3个状态(位置):正常(normal)、跳过(skip)和断点(breakpoint)。如果这个开关在正常位置,那么执行50m指令输出内存地址m的内容;如果开关在跳过位置,那么这条指令会被忽略;如果开关在断点位置,那么执行到这里时计算机会中断。可见这条指令不仅实现了一种可随时开启关闭的监视点功能,还可以根据需要停在监视点位置,这时又相当于一种外部可控的断点。

综上所述,UNIVAC Ⅰ提供了两种断点指令,并配备了与指令协同工作的硬件开关,实现了主要靠硬件工作的非常简朴的断点功能。这种实现方式不需要软件调试器参与,也没有为实现软件调试器提供足够支持。

我们再来看一下小型机PDP-1上是如何提供断点支持的。概括来讲,PDP-1提供了一条名为jda的指令,供调试器开发者来实现断点功能。这条指令的语法是:

jda Y

它执行的操作是将AC(Accumulator)寄存器的内容存入地址Y,然后把程序计数器(Program Counter,相当于IP)的值放入AC寄存器,并跳转到Y+1。利用这条指令,调试器可以这样实现断点功能。

在x86系列CPU中,有一条使用异常机制的断点指令,即INT 3,供调试器来设置断点。调试器会在合适的时机将断点处的指令替换为INT 3,当CPU执行到这里时,会产生断点异常,跳转到异常处理例程。我们将在以后的章节中详细介绍其细节。

程序中的分支和跳转指令对于软件的执行流程和执行结果起着关键作用,不恰当的跳转往往是很多软件问题的错误根源。有时跟踪一个程序,是为了检查它的跳转时机和跳转方向。因此,监视和报告程序的分支位置和当时的状态对软件调试是很有意义的。

UNIVAC Ⅰ的条件转移断点(conditional transfer breakpoint)功能正是针对这一需求而设计的。同样,这一机制由两个部分组成:一个部分是条件转移指令Qn m和Tn m;另一部分是监视控制台上的按钮和指示灯。指令中的m是跳转的目标地址,n是0到9的10个值之一,与控制台上的0~9这10组按键(称为条件转移断点选择按钮)及指示灯相对应。图1-6是控制面板的相关部分,下面一排共有12个按钮,上面一排为指示灯,当某个按钮按下时,它上面的指示灯会变亮。最左侧红色按钮(位于ALL按钮左侧)的作用是将所有按钮复位。当程序执行到Qn和Tn指令时,系统会检查对应的条件转移断点选择按钮是否被按下。如果按钮未被按下,那么系统会正常执行;如果按钮ALL被按下,那么系统会中断执行,相当于遇到一个断点。

当UNIVAC Ⅰ因为条件转移断点而停止后,图1-6中的条件转移(CONDITIONAL TRANSFER)指示灯会根据指令的比较结果,显示即将跳转与否。如果调试人员希望执行与比较结果相反的动作,那么可以通过右侧的开关强制跳转或不跳转。

图1-6 UNIVAC Ⅰ的条件转移断点控制按钮和指示灯

英特尔P6系列CPU引入了记录分支、中断和异常的功能,以及针对分支设置断点和单步执行,我们将在第2篇详细介绍这些功能。

本节简要介绍了3种调试功能的发展历史,我们从中可以看出从单纯的硬件机制到软硬件相互配合来调试软件的基本规律。使用软件来调试软件的最重要工具就是调试器(debugger)。关于调试器的详细发展历史参见卷2。

根据被调试软件的特征、所使用的调试工具以及软件的运行环境等要素,可以把软件调试分成很多个子类。本节将介绍几种常用的分类方法,并介绍每一种分类方法中的典型调试任务。

软件调试所使用的工具和方法与操作系统有着密切的关系。例如,很多调试器是针对操作系统所设计的,只能在某一种或几种操作系统上运行。对软件调试的一种基本分类标准就是被调试程序(调试目标)所运行的系统环境(操作系统)。按照这个标准,我们可以把调试分为Windows下的软件调试、Linux下的软件调试、DOS下的软件调试,等等。

这种分类方法主要是针对编译为机器码的本地(native)程序而言的。对于使用Java和.NET等动态语言所编写的运行在虚拟机中的程序,它们具有较好的跨平台特性,与操作系统的关联度较低,因此不适用于这种分类方法(见下文)。

脚本语言具有简单易学、不需要编译等优点,比如网页开发中广泛使用的JavaScript和VBScript。脚本程序是由专门的解释程序解释执行的,不需要产生目标代码,与编译执行的程序有很多不同。调试使用脚本语言编写的脚本程序的过程称为脚本调试。所使用的调试器称为脚本调试器。

编译执行的程序又主要分成两类:一类是先编译为中间代码,在运行时再动态编译为当前CPU能够执行的目标代码,典型的代表便是使用C#开发的.NET程序。另一类是直接编译和链接成目标代码的程序,比如传统的C/C++程序。为了便于区分,针对前一类代码的调试一般称为托管调试,针对后一类程序的调试称为本地调试(native debugging)。如果希望在同一个调试会话中既调试托管代码又调试本地代码,那么这种调试方式称为混合调试(inter-op debugging)。

图1-7归纳出了按照执行和编译方式来对软件调试进行分类的判断方法和步骤。

图1-7 按照执行和编译方式对软件调试进行分类的判断方法和步骤

本书重点讨论本地调试。

在Windows这样的多任务操作系统中,作为保证安全和秩序的一个根本措施,系统定义了两种执行模式,即低特权级的用户模式(user mode)和高特权级的内核模式(kernel mode)。应用程序代码是运行在用户模式下的,操作系统的内核、执行体和大多数设备驱动程序则是运行在内核模式的。因此,根据被调试程序的执行模式,我们可以把软件调试分为用户态调试(user mode debugging)和内核态调试(kernel mode debugging)。

因为运行在内核态的代码主要是本地代码以及很少量的脚本,例如ASL语言编写的ACPI脚本,所以内核态调试主要是调试本地代码。而用户态调试包括调试本地应用程序和调试托管应用程序等。

本书后面的章节将详细介绍Windows下的用户态调试和内核态调试。

根据被调试软件所处的开发阶段,我们可以把软件调试分为开发期调试和产品期调试。二者的分界线是产品的正式发布。

产品期调试旨在解决产品发布后才发现的问题,问题的来源主要是客户通过电子邮件、电话等方式报告的,或者通过软件的自动错误报告机制(见分卷)得到的。与开发期调试相比,产品期调试具有如下特征。

总之,产品期调试的难度一般更大,对调试者的要求更高。

如果被调试程序(调试目标)和调试器在同一个计算机系统中,那么这种调试称为本机调试(local debugging)。这里的同一个计算机系统是指在同一台计算机上的同一个操作系统中,不包括运行在同一个物理计算机上的多个虚拟机。

如果调试器和被调试程序分别位于不同的计算机系统中,它们通过以太网络或其他网络进行通信,那么这种调试方式称为远程调试(remote debugging)。远程调试通常需要在被调试程序所在的系统中运行一个调试服务器程序。这个服务器程序和远程的调试器相互联系,向调试器报告调试事件,并执行调试器下达的命令。在本书后续分册讨论调试器时,我们将进一步讨论远程调试的工作方式。

利用Windows内核调试引擎所做的活动内核调试需要使用两台机器,两者之间通过串行接口、1394接口或USB 2.0进行连接。尽管这种调试的调试器和调试目标也在两台机器中,但是通常不将其归入远程调试的范畴。

软件调试的目标通常是当时在实际运行的程序,但也可以是转储文件(dump file)。因此,根据调试目标的活动性,可以把软件调试分为活动目标调试(live target debugging)和转储文件调试(dump file debugging)。转储文件以文件的形式将调试目标的内存状态凝固下来,包含了某一时刻的程序运行状态。转储文件调试是定位产品期问题以及调试系统崩溃和应用程序崩溃的一种简便而有效的方法。

软件调试也可以根据所使用的工具进行分类。最简单的就是按照调试时是否使用调试器分为使用调试器的软件调试和不使用调试器的软件调试。使用调试器的调试可以使用断点、单步执行、跟踪执行等强大的调试功能。不使用调试器的调试主要依靠调试信息输出、日志文件、观察内存和文件等。后者具有简单的优点,适用于调试简单的问题或无法使用调试器的情况。

以上介绍了软件调试的几种常见分类方法,目的是让读者对典型的软件调试任务有概括性的了解。有些分类方法是有交叉性的,比如调试浏览器中的JavaScript属于脚本调试,也属于用户态调试。

深入介绍各种软件调试技术是本书的主题,本着循序渐进的原则,在本节中,我们先概述各种常用的软件调试技术,帮助大家建立起一个总体印象。在后面的各章中,我们还会从不同角度做更详细的讨论。

断点(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)是最早的调试方式之一。简单来说,就是让应用程序按照某一步骤单位一步一步执行。根据每次要执行的步骤单位,又分为如下几种。

单步执行可以跟踪程序执行的每一个步骤,观察代码的执行路线和数据的变化过程,是深入诊断软件动态特征的一种有效方法。但是随着软件向大型化方向的发展,从头到尾跟踪执行一个软件乃至一个模块,一般都不再可行了。一般的做法是先使用断点功能将程序中断到感兴趣的位置,然后再单步执行关键的代码。我们将在第4章详细介绍CPU的单步执行调试。

打印和输出调试信息(debug output/print)是一种简单而“古老”的软件调试方式。其基本思想就是在程序中编写专门用于输出调试信息的语句,将程序运行的位置、状态和变量取值等信息以文本的形式输出到某一个可以观察到的地方,可以是控制台、窗口、文件或者调试器。

比如,在Windows平台上,驱动程序可以使用DbgPrint/DbgPrintEx来输出调试信息,应用程序可以调用OutputDebugString,控制台程序可以直接使用printf系列函数打印信息。在Linux平台上,驱动程序可以使用printk来输出调试信息,应用程序可以使用printf系列函数。

以上方法的优点是简单方便、不依赖于调试器和复杂的工具,因此至今仍在很多场合广泛使用。

不过这种简单方式也有一些明显的缺点,比如需要在被调试程序中加入代码,如果被调试程序的某个位置没有打印语句,那么便无法观察到那里的信息,如果要增加打印语句,那么需要重新编译和更新程序。另外,这种方法容易影响程序的执行效率,打印出的文字所包含的信息有限,容易泄漏程序的技术细节,通常不可以动态开启、信息不是结构化的、难以分析和整理等。我们将在16.5.5节介绍使用这种方法应该注意的一些细节。

与输出调试信息类似,写日志(log)是另一种被调试程序自发的辅助调试手段。其基本思想是在编写程序时加入特定的代码将程序运行的状态信息写到日志文件或数据库中。

日志文件通常自动按时间取文件名,每一条记录也有详细的时间信息,因此适合长期保存以及事后检查与分析。因此很多需要连续长时间在后台运行的服务器程序都有日志机制。

Windows操作系统提供了基本的日志记录、观察和管理(删除和备份)功能。Windows Vista新引入了名为Common Log File System(CLFS.SYS)的内核模块,用于进一步加强日志功能。Syslog是Linux系统下常用的日志设施。我们将在第15章详细介绍这些调试支持的内容。

打印调试信息和日志都是以文本形式来输出和记录信息的,因此不适合处理数据量庞大且速度要求高的情况。事件追踪机制(Event Trace)正是针对这一需求设计的,它使用结构化的二进制形式来记录数据,观察时再根据格式文件将信息格式转化为文本形式,因此适用于监视频繁且复杂的软件过程,比如监视文件访问和网络通信等。

ETW(Event Trace for Windows)是Windows操作系统内建的一种事件追踪机制,Windows内核本身和很多Windows下的软件工具(如Bootvis、TCP/IP View)都使用了该机制。我们将在第15章详细介绍事件追踪机制及其应用。

某些情况下,我们希望将发生问题时的系统状态像拍照片一样永久保存下来,发送或带走后再进一步分析和调试,这就是转储文件(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),就是将目标代码(指令)翻译为汇编代码。因为汇编代码与机器码有着简单的对应关系,所以反汇编是了解程序目标代码的一种非常直接而且有效的方式。有时我们对高级语言的某一条语句的执行结果百思不得其解,就可以看一下它所对应的汇编代码,这时往往可以更快地发现问题的症结。以1.6.1节将介绍的bad_div函数为例,看一下汇编指令,我们就可知道编译器是将C++中的除法操作编译为无符号整除指令(DIV),而不是有符号整除指令(IDIV)。这正是错误所在。

另外,反汇编的依赖性非常小,根据二进制的可执行文件就可以得到汇编语言表示的程序。这也是反汇编的一大优点。

调试符号对于反汇编有着积极的意义,反汇编工具可以根据调试符号得到函数名和变量名等信息,这样产生的汇编代码具有更好的可读性。

大多数调试器提供了反汇编和跟踪汇编代码的能力。一些工具也提供了反汇编功能,IDA(Interactive Disassembler)是其中非常著名的一个。

观察被调试程序的数据是了解程序内部状态的一种直接方法。很多调试器提供了观察和修改数据的功能,包括变量和程序的栈及堆等重要数据结构。在调试符号的支持下,我们可以按照数据类型来显示结构化的数据。

寄存器值代表了程序运行的瞬时状态。观察和修改寄存器的值也是一种常见的调试技术。

像WinDBG这样的调试器支持同时调试多个进程,每个进程又可以包含多个线程。调试器提供了单独挂起和恢复某一个或多个线程的功能,这对于调试多线程和分布式软件是很有帮助的。我们将在本书后续分卷详细介绍控制进程和线程的方法。

软件缺欠是软件调试和测试过程的主要工作对象。现实中,人们经常交替使用几个名词来称呼软件问题,比如error、bug、fault、failure和defect。本节将介绍对这几个名词的一种常见区分方法,说明本书的用法,并讨论有关的几个问题。

区分以上几个术语的一种方法是从内因和表面现象的角度来分析。一般认为,failure(失败)是用来描述软件问题的可见部分,即外在的表现和症状(symptom)。而error是导致这种表象的内因(root cause)。fault是指由内因导致表象出现的那个错误状态。而bug和defect是对软件错误和失败的通用说法,二者之间没有显著的差异,或许bug一词更通俗和口语化,而defect(缺欠)一词正式一些。

以第一个登记到文档中的bug为例,那只飞蛾是error,计算机停止工作是failure,70#继电器断路是fault。当不区分内因和表象时,便可以模糊地说是Mark II中的一个缺欠或者bug。

进一步来说,一方面,一个错误(error)可能导致很多个失败(failure),也就是所谓的多个问题是同样根源(same root cause)。另一方面,如果没有满足特定的条件,那么“错误”是不会导致“失败”的,或者说错误是在一定条件下才表现出来的,表现的形式可能有多种。

以下面的函数为例:

int bad_div(int n,unsigned int m)
{
   return n/m;
}

当这样调用它时:

printf("%d/%d=%d!\n",6,3,bad_div(6,3));

打印出的结果是正确的:

6/3=2!

但是当这样调用它时:

printf("%d/%d=%d!\n",-6,3,bad_div(-6,3));

打印出的结果却是错误的:

-6/3=1431655763!

当然,如果参数n为−10,m为2,那么结果也是错误的。其中的原因为参数m是无符号整数,所以编译器在编译n/m时采用了无符号除法指令(DIV),这相当于把参数n也假设为无符号整数。因此,当n为负数时,实际上被当作了一个较大的正数来做除法,除后的商被返回。

对于这个例子,函数bad_div的代码存在错误,不应该将有符号整数和无符号整数直接做除法。这个错误当两个参数都为正数6和3时不会体现出来,但是当参数n为负数、m不等于1时可以体现出来,会导致“失败”症状,特别的−6除以+3会得到结果1431655763。

在本书中,除非特别指出,我们通常用bug或软件缺欠(defect)来描述软件调试所面对的软件问题。

在软件工程中,一个值得注意的问题是不要把bug轻易归咎于某一个程序员。讨论bug时,不要使用“你的 bug”这样的说法,因为这样可能是不公平的,容易伤害程序员的自尊心,不利于调动他们的积极性。

简单来说,测试过程中发现的与软件需求规约不一致的任何现象都可以当作bug/defect报告出来。其中有些可能是因为代码中确实存在过失而导致的,而有些可能是与需求定义和前期设计有关的。因此,把和某个模块有关的bug都归咎于负责这个模块的程序员可能是不恰当的。

一种较好的方式是称呼“××模块的bug”,而不要说成是“××人的bug”。这样,与这个模块有关的人员可以相互协作,共同努力,迅速将其解决,这对于个人和整个团队都是有好处的。

 老雷评点  

在《周易》中,有一句关于语言之重要性的话,即“言行,君子之枢机,枢机之发,荣辱之主也”。有时,一字之差就会让人暴跳如雷,换一种说法则让人心悦诚服。在技术书中有此一段,作者煞费苦心也。

图1-8描述了一个典型的软件bug从被发现到被消除所经历的主要过程。其中不带格线的矩形框代表的是测试人员的活动,而带格线的矩形框代表的是开发和调试人员的活动。

图1-8 bug的生命周期

当登记一个bug时,通常要为其指定如下属性。

bug被登录到系统(如Bugzilla)中后,它会被指派一个负责人,这个负责人会先在自己的系统中重现问题,然后调试和定位问题的根源,找到根源后,修正代码,并进行初步的测试,没有问题后将修正载入即将发布给测试人员的下一个版本中,并将系统中的bug状态修改为resolved。而后由测试人员进行测试和验证。如果经过一段时间的测试证明问题确实解决了,那么就可以关闭这个问题。对于严重程度很高的问题,可能需要通过团队会议讨论后才能关闭。

我们把与一个软件错误直接相关的人力投入和物力投入称为此软件错误的开支(cost)。

如果一个错误在设计或编码阶段就被发现和解决了,那么它所导致的开支主要是设计者或开发者所用的时间。

如果一个错误是在发布给测试团队后由测试人员所发现的,那么其开支便要包括测试过程的各种投入、测试团队和开发团队相互沟通所需的人力和时间开销、重现问题和定位问题根源所需的投入、设计和实现解决方案及重新验证解决方案的投入。

如果一个错误是在软件正式发布后才发现的,那么其导致的危害通常会更大,可能的开支项目有处理客户投诉、远程支持、开发及发布补丁程序、客户退货、产品召回、赔偿导致的其他损失等。

不难看出,软件错误被发现和纠正得越早,其开支就越小。如果在开发阶段发现和得到纠正,那么就不需要测试阶段的开支了。如果等产品都已经发布给最终用户才发现问题,那么其导致的开支会是以前的数十倍乃至更多。Barry W. Boehm在《Software Engineering Economics》一书中给出了在软件生命周期的不同阶段修正软件错误的相对成本(见表1-1)。

表1-1 软件错误的相对开支

错误被检测和纠正的阶段

相对开支的中值

需求

2

设计

5

编码

10

开发测试(development test)

20

接受测试(acceptance test)

50

运行

150

图1-9是根据表1-1中的数据画出的曲线,其中横轴代表软件生命周期的各个阶段(时间),纵轴代表发现和纠正软件错误的相对成本(中值)。

图1-9 软件错误开支相对于软件生命周期各阶段的曲线

根据图1-9中的曲线,软件错误的开支是随着发现的时间呈指数形式上升的,所以应该尽可能早地发现和纠正问题。要做到这一点,需要软件团队中所有成员的共同努力,从一开始就注重程序的可测试性和可调试性。我们将在本书后续分卷详细讨论可调试性和更多有关的问题。

从软件工程的角度来讲,软件调试是软件工程的一个重要部分,软件调试出现在软件工程的各个阶段。从最初的可行性分析、原型验证到开发和测试阶段,再到发布后的维护与支持,都需要软件调试技术。

定位和修正bug是几乎所有软件项目的重要问题,越临近发布,这个问题的重要性越高!很多软件项目的延期是由于无法在原来的期限内修正bug所造成的。为了解决这个问题,整个软件团队都应该重视软件的可调试性,重视对软件调试风险的评估和预测,并预留时间。本节先介绍软件调试与软件工程中其他活动的关系,然后介绍学习调试技术的意义。

调试与编码(coding)是软件开发中不同但联系密切的两个过程。在软件的开发阶段,对于一个模块(一段代码)来说,它的编写者通常也是它的调试者。或者说,一个程序员要负责调试他所编写的代码。这样做有两个非常大的好处。

编码和调试是程序员日常工作中两项最主要的任务,这两项任务是相辅相成的,编写具有可调试性的高质量代码可以明显提高调试效率,节约调试时间。此外,调试可以让程序员真切感受程序的实际执行过程,反思编码和设计中的问题,加深对软件和系统的理解,提高对代码的感知力和控制力。

在软件发布后,有些调试任务是由技术支持人员来完成的,但是当他们将错误定位到某个模块并且无法解决时,有时还要找到它的本来设计者。

很多经验丰富的程序员都把调试放在头等重要的位置,他们会利用各种调试手段观察、跟踪和理解代码的执行过程。通过调试,他们可以发现编码和设计中的问题,并把这些问题在发布给测试人员之前便纠正了。于是,人们认为他们编写代码的水平非常高,没有或者很少有bug,在团队中有非常好的口碑。对于测试人员发现的问题,他们也仿佛先知先觉,看了测试人员的描述后,一般很快就能意识到问题所在,因为他们已经通过调试把代码的静态和动态特征都放在大脑中了,对其了然于胸。

但也有些程序员很少跟踪和调试他们编写的代码,也不知道这些代码何时被执行以及执行多少次。对于测试人员报告的一大堆问题,他们也经常是一头雾水,不知所措。

毋庸置疑,忽视调试对于提高程序员的编程水平和综合能力都是很不利的。因此,《Debugging by Thinking》一书的作者Robert Charles Metzger说道:“导致今天的软件有如此多缺欠的原因有很多,其中之一就是很多程序员不擅长调试,一些程序员对待软件调试就像对待个人所得税申报表那样消极。”

 老雷评点  

多年来,我所遇到的编程高手无不深谙调试技术,而那些摸不到编程门道的门外汉则大多不知调试为何物。亦有貌似深谙软件之道者,说调试无足轻重,真是大言不惭。

简单地说,测试的目的是在不知道有问题存在的情况下去寻找和发现问题,而调试是在已经知道问题存在的情况下定位问题根源。从因果关系的角度来看,测试是旨在发现软件“表面”的不当行为和属性,而调试是寻找这个表象下面的内因。因此二者是有明显区别的,尽管有些人时常将它们混淆在一起。

如果说代码是联系调试与编码的桥梁,那么软件缺欠便是联系调试与测试的桥梁。缺欠是测试过程的成果(输出),是调试过程的输入。测试的目标首先是要发现缺欠,其次是如何协助关闭这些缺欠。

测试与调试的宗旨是一致的,那就是软件的按期交付。为了实现这一共同目标,测试人员与调试人员应该相互尊重,密切配合。例如,测试人员应该尽可能准确详细地描述缺欠,说明错误的症状、实际的结果和期待的结果、发现问题的软硬件环境、重现问题的方法以及需要注意的细节。测试人员应该在软件中加入检查错误和辅助调试的手段,以便更快地定位问题。

软件的调试版本应包含更多的错误检查环节,以便更容易测试出错误,因此除了测试软件的发布版本外,测试调试版本是提高测试效率、加快整个项目进度的有效措施。著名的调试专家John Robbins建议根据软件的开发阶段来安排测试调试版本的时间,在项目的初始阶段,对两个版本的测试时间应该是基本一样的,随着软件的成熟,逐渐过渡到只测试发布版本。

为了使以上方法更有效,编码时应该加入恰当的断言并建立合适的错误报告和记录机制。我们将在本书后续分册中介绍运行期检查时更详细地讨论这个问题。

典型的软件开发过程是设计、编码,再编译为可执行文件(目标程序)的过程。因此,所谓逆向工程(reverse engineering)就是根据可执行文件反向推导出编码方式和设计方法的过程。

调试器是逆向工程中的一种主要工具。符号文件、跟踪执行、变量监视和观察以及断点这些软件调试技术都是实施逆向工程时经常使用的技术手段。

逆向工程的合法性依赖于很多因素,需要视软件的授权协议、所在国家的法律、逆向工程的目的等具体情况而定,其细节超出了本书的讨论范围。

为什么要学习软件调试技术呢?原因如下。

首先,软件调试技术是解决复杂软件问题最强大的工具。如果把解决复杂软件问题看作一场战斗,那么软件调试技术便是一种可以直击要害而且锐不可当的武器。说直击要害,是因为利用调试技术可以从问题的正面迎头而上,从问题症结着手,直接深入内部。而不像很多其他技术那样需要从侧面探索,间接地推测,然后做大量的排查。说锐不可当是因为核心的调试技术大多来源于CPU和操作系统的直接支持,所以具有非常好的健壮性和稳定性,有较高的优先级。

其次,提高调试技术水平有利于提高软件工程师特别是程序员的工作效率,降低他们的工作强度。很多软件工程师都认为调试软件花去了他们大半的工作时间。因此提高调试软件的技术水平和效率对于提高总的工作效率是非常有意义的。

再次,调试技术是学习其他软硬件技术的一个极好工具。通过软件调试技术的强大观察能力和断点、栈回溯、跟踪等功能可以快速地了解一个软件和系统的模块、架构和工作流程,因此是学习其他软硬件技术的一种快速而有效的方法。作者经常使用这种方法来学习新的开发工具、应用软件和操作系统。

最后,相对其他软件技术,软件调试技术具有更好的稳定性,不会在短时间内被淘汰。事实上,我们前面介绍的大多数调试技术都有几十年的历史了。因此,可以说软件调试技术是一门一旦掌握便可以长期受用的技术。

尽管软件调试始终是软件开发中必不可少的一步,但至今没有得到应有的重视。

在教育和研究领域,软件调试技术尚未像软件测试和编译原理那样成为一个独立的学科,有关的理论和知识尚未系统化,专门讨论软件调试的书籍和资料非常有限。根据作者的了解,还没有一所大学或软件学院开设专门关于软件调试的课程。这导致很多软件工程师没有接受过系统的软件调试培训,对软件调试的基本原理知之甚少。

在软件工程中,很多时候,软件调试还处于被忽略的位置。当定义日程表时,开发团队很少专门评估软件调试方面的风险,为其预留专门的时间;当设计架构时,他们很少考虑软件的可调试性;在开发阶段,针对调试方面的管理和约束也很薄弱—— 一个项目中经常存在着多种调试机制,相互重叠,而且都很简陋。在员工培训方面,针对软件调试的培训也比较少。

我们将在第四篇(第15~16章)进一步讨论软件调试与软件工程的更多话题,特别是软件的可调试性。

 老雷评点  

近年来,欣然看到招聘广告中有时出现调试工程师(debug engineer)的职位,且有些公司开始设立专职的调试团队(debug team),这或为软件调试从隐学变为显学之征兆。

本章的前两节介绍了软件调试的解释性定义、基本过程(见1.1节)和特征(见1.2节)。1.3节讨论了断点、单步执行和分支监视3种基本的软件调试技术的简要发展历史。1.4节从多个角度介绍了常见的软件调试任务。1.5节介绍了软件调试所使用的基本技术。1.6节探讨了关于软件错误的一些术语和概念。最后一节介绍了软件调试的重要性及其与软件工程中其他活动的关系。

作为全书的开篇,本章旨在为读者勾勒出一个关于软件调试的总体轮廓,帮助读者建立一些初步的概念和印象。所以,本章的内容大多是概括性的介绍,没有深入展开,如果读者不能理解其中的某些术语和概念,也不必担心,因为后面章节中会有更详细的介绍。

[1] Robert Charles Metzger. Debugging by Thinking[M]. Holland: Elsevier Digital Press, 2003.

[2] G Pascal Zachary. Showstopper: The Breakneck Race to Create Windows NT and the Next Generation at Microsoft[M]. New York: The Free Press, 1994.

[3] Manual of Operations for UNIVAC System. Remington Rand Inc., 1954.


相关图书

现代软件测试技术之美
现代软件测试技术之美
渗透测试技术
渗透测试技术
JUnit实战(第3版)
JUnit实战(第3版)
深入理解软件性能——一种动态视角
深入理解软件性能——一种动态视角
云原生测试实战
云原生测试实战
Android自动化测试实战:Python+Appium +unittest
Android自动化测试实战:Python+Appium +unittest

相关文章

相关课程