c++代码与调试(转)
转 自:http://www.vcgood.com/forum_posts.asp?TID=1177&PN=1
1 规范易懂的代码
现阶段软件开发,都要依靠团队的合作。程序员不再是个人英雄主义的代名词,程序员一方面要依赖大量其他程序员完成的代码,一方面又提供大量代码给其他人使 用,代码实际上具备了两个要素:首先是可靠的提供某种功能,其次是清楚地表达作者的思想。任何交流都必须有一定的规范才能进行,体现在代码中就是规范易 懂。另外,规范易懂的代码才是可重复使用的,规范的代码具有更长的寿命,具有更好的可维护性,也更方便后期的扩展。
1.1 好代码的几个特征
怎么样的代码才算规范易懂,体现在细节上会有无数的争论,实际上无论风格和习惯如何,好的代码具有几个共同的特征:
1. 良好的命名:好的变量名和函数名,让阅读代码的人马上就知道该变量或者函数的作用,很容易就能理解程序的大概结构和功能。程序员有必要理解匈牙利命名法。
2. 一致性:一致性带来更好的程序,一致的代码缩进风格能够显示出代码的结构,采用何种缩进风格并不重要,实际上,特定的代码风格远没有一致的使用它们重要。
3. 注释:注释是帮助程序读者的一种手段,程序作者也是未来的程序读者之一。最好的注释是简洁地点明程序的突出特征,或是提供一种概观,帮助别人理解程序;但 如果注释只是说明代码已经讲明的事情,或者与代码矛盾,或者以精心编排的形式迷惑干扰读者,那就是帮了倒忙。
1.2 养成好习惯
前面已经提过,特定的代码风格远没有一致的使用他们重要,所以,把过多的精力放到A or B的选择上是浪费时间,你要做的是坚持。如何书写规范易懂的代码,如何养成良好的习惯,下面是一些提示。
1. 按照匈牙利命名法给变量和函数命名。
2. 遵循国际流行的代码风格。
3. 写代码的同时就遵循你的命名规范和书写风格,千万不能事后补救。
4. 利用工具(Parasoft C++ Test)检查你的代码,评估一下自己形成良好的习惯没有。
5. 坚持不懈直到养成习惯。
2 编写安全可靠的代码
在大型应用软件系统中,各个代码片段共同构成完整的系统,代码间的交互非常频繁,程序崩溃往往并不在错误发生的时候就发生,而是延迟了一段时间,经过数个 函数之间的中转后才发生,此时定位和查找错误非常费时费力,如何才能及时反映程序中的错误,如何在代码中避免一些幼稚的语义错误呢?一个函数往往会被其他 程序员拿来使用,但是他怎么能够正确的使用其他人编写的函数呢?这部分内容能够(部分)帮助解决这些问题。
2.1 契约编程
契约编程(Design by Contract)的思想在C++圣经级的著作,C++之父Bjarne Stroustrup的《C++程序设计语言》中略微提到过,OO领域的圣经级著作《面向对象软件构造》以大篇幅阐释了契约编程,现在越来越多的软件开发 人员认识到契约编程的重要性,并逐步地在实际工作中采用契约编程。
对契约编程简单的解释是:对实现的代码块(函数、类)通过规定调用条件(约束)和输出结果,在功能的实现者和调用者之间定义契约。
具体到我们的工作,开发人员应该对完成的每个函数和类,定义契约。契约编程看似平淡无奇,对程序开发没有什么具体的帮助,实际上,契约编程在开发阶段就能 够最大程度的保证软件的可靠性和安全性。
在实际工作中,每当你需要使用其他程序员提供的模块,你并不知道如何调用,也不知道你传入的参数是否合法,有时候对于功能模块的处理结果也不敢相信。这些 本来应该很明显的信息因为模块提供者没有显式的提供,造成了调用者只能忐忑不安的摸着石头过河,浪费了大量时间,而且为了让自己的代码更安全可靠,在代码 中做了大量的判断和假设,造成代码结构的破坏和执行效率的损失,最后,调用者依旧不能确保自己的调用是正确的。
而契约编程通过严格规定函数(或类)的行为,在功能提供者和调用者之间明确了相互的权利和义务,避免了上述情况的发生,保证了代码质量和软件质量。
2.2 主动调试
主动调试指在写代码的时候,通过加入适量的调试代码,帮助我们在软件错误发生的时候迅速弹出消息框,告知开发人员错误发生地点,并中止程序。这些调试代码 只在Debug版中有效,当经过充分测试,发布Release版程序的时候,这些调试代码自动失效。
主动调试和契约编程相辅相成,共同保证软件开发的质量。契约编程相当于经济生活中签订的各种合同,而主动调试相当于某方不遵守合同时采取的法律惩罚措施。
各种开发语言和开发工具都提供这些调试语句,标准C++提供了assert函数,MFC提供了ASSERT调试宏帮助我们进行主动调试,在实际工作中,建 议统一使用MFC的ASSERT调试宏。
2.2.1 参数检查
对于编写的函数,除了明确的指定契约外,在函数开始处应该对传入的参数进行检查,确保非法参数传入时立即报告错误信息。例如:
BOOL GetPathItem ( int i , LPTSTR szItem , int iLen )
{
ASSERT ( i > 0 ) ;
ASSERT ( NULL != szItem ) ;
ASSERT ( ( iLen > 0 ) && ( iLen < MAX_PATH ) ) ;
ASSERT ( FALSE == IsBadWriteStringPtr ( szItem , iLen ) ) ;
}
对指针的检查尤其要注意,通常程序员会这样进行检查:
// An example of checking only a part of the error condition
BOOL EnumerateListItems ( PFNELCALLBACK pfnCallback )
{
ASSERT ( NULL != pfnCallback ) ;
}
这样的检查只能够排除指针为空的情况,但是如果指针指向的是非法地址,或者指针指向的对象并不是我们需要的类型,上面的例子就没有办法检查出来,而是统统 认为是正确的。完整的检查应该如下:
// An example of completely checking the error condition
BOOL EnumerateListItems ( PFNELCALLBACK pfnCallback )
{
ASSERT ( FALSE == IsBadCodePtr ( pfnCallback ) ) ;
}
2.2.2 内部检查
恰当地在代码中使用ASSERT,对bug检测和提高调试效率有极大的帮助,下面举个简单的例子加以说明。
switch( nType )
{
case GK_ENTITY_POINT:
// do something
break;
case GK_ENTITY_PLINE:
// do something
break;
default:
ASSERT( 0 );
}
在上面的例子中,switch语句仅仅处理了GK_ENTITY_POINT和GK_ENTITY_PLINE两种情况,应该是系统中当时只需要处理这两 种情况,但是如果后期系统需要处理更多的情况,而此时上面这部分代码又没有及时更新,或者是因为开发人员一时疏忽遗漏了。一个可能导致系统错误或者崩溃的 bug就出现了,而使用ASSERT可以及时地提醒开发人员他的疏忽,尽可能快的消灭这个bug。
还有一些情况,在开发人员编写代码时,如果能够确信在某一点出现情况A就是错误的,那么就可以在该处加上ASSERT,排除情况A。
综上所述,恰当、灵活的使用ASSERT进行主动调试,能够极大提高程序的稳定性和安全性,减少调试时间,提高工作效率。
2.3 有用的代码风格
一些好的代码风格也能够帮助你避免一些幼稚的、低级的错误,而这种错误又是很难检测到的。由于C++语言简洁灵活的特性,有时候敲错一个字符,或者漏敲一 个字符,都有可能造成极大的灾难,而这种错误并不是随着你的编程水平和经验的提高就能逐步避免的,谁都会敲错字符,对吧。
比如程序员经常将等于逻辑判断符==误敲成赋值运算符=,对于我来说就不太可能程序运行出错后才发现,因为我的习惯是,对于逻辑判断,将常量置于==的左 边,如果我误输入了=,那么编译的时候编译器就会报错。
if( INT_MAX == i )
3 Visual C++调试技术
检查代码直到头晕眼花也没有发现错误,一运行程序就死机,只好祭出最后的法宝:调试器。Visual C++调试器可以称得上Windows平台下最好的C/C++调试器了,而且Visual C++调试器还可以调试用其他语言如Delphi、Java编写的程序,可谓功能强大。
尽管Visual C++调试器具有如此大的威力,它也只能帮助你发现一些隐藏的逻辑错误,对于程序设计和结构的缺陷无能为力。
程序员最常用到的Visual C++调试技术有设置断点、跟踪调用堆栈和反汇编调试,其他编译器功能均为调试中的辅助工具,因为反汇编调试需要程序员具备汇编语言知识和语言底层结构, 这里不再介绍。
3.1 调试的先决条件
专业调试者有一个共同的特点,即他们同时也是优秀的开发者。显然,如果你不是一个优秀的开发者,那么你也不可能成为调试专家,反之亦然。以下是要成为一名 高水平的,至少是合格的调试者或者开发者所需要精通的领域。
1. 了解项目:对项目的了解是防范用户界面、逻辑及性能方面的错误的第一要素。了解各种功能如何在各种源文件里实现,以及在哪儿实现,你就能够缩小查找范围, 很快找出问题所在。
2. 掌握语言:掌握项目所使用的语言,调试者(开发者)既要知道如何使用这些语言进行编程,还要知道这些语言在后台作些什么。
3. 掌握技术:要解决棘手的问题,第一个重要步骤就是抓住所用技术的要领,这并不意味着你必须对所用技术的一切细节都一清二楚,而是说你应该对所使用的技术有 一个大概的了解,而且更重要的是,当需要更详细的信息时,你应该确切的知道在哪儿查找。
4. 操作系统和CPU:任何项目都实际运行在特定的操作系统和特定的CPU,对操作系统了解越多,对查找错误帮助越大;从理论上来说,掌握汇编语言,你就可以 调试解决任何bug。
无论从事什么工作,只要是经常从事技术工作的人,都必须不断地学习以跟上技术的发展,更不用说想干得更好或是想走在技术发展的前沿。经常阅读优秀的技术书 籍和杂志,多动手编写一些实用程序,阅读其他优秀开发者的代码,作一些反汇编工作,都会有效帮助你提高开发和调试水平(尤其当你将这四者有机结合起来)。
3.2 调试过程
确定一个适用于解决所有错误的调试过程有一定的难度,但John Robbins提出的调试过程应该说是最实用的:
1. 复制错误
2. 描述错误
3. 始终假定错误是自己的问题
4. 分解并解决错误
5. 进行有创见的思考
6. 使用调试辅助工具
7. 开始调试工作
8. 校验错误已被更正
9. 学习和交流
对错误进行描述有助于改正错误,同时也能够得到同事们的帮助。逐步缩小问题范围、排除不存在错误的代码段,直到找到问题所在,是解决所有问题的普遍适用方 法。有些奇怪的错误需要你把视线从代码堆转移到诸如操作系统、硬件环境等其他方面去。善用各种调试辅助工具能够节省你大量的时间,而且某些工具本身就不会 给你犯有些错误的机会。当你解决了一个bug,停下来思考一下,什么导致你(或他)犯了这样的错误,以后如何避免?
要记住调试器仅仅是个工具,就好比一只螺丝起子,你让它做什么它就只做什么,真正的调试器是你自己脑子中的调试思想。
3.3 断点及其用法
在Microsoft Visual C++调试器中在源代码行中设置一个断点很简单。只需要打开源文件,将光标放在想要设置断点的代码行上,按下F9快捷键就可以了,再次按下F9快捷键就会 取消断点。当运行该代码行的代码时,调试器将在所设置的位置处停止。这种简单的位置断点的功能极其强大,经过统计,只需要单独的使用这种断点,就可以解决 99.46%的调试问题。
如果程序并不是每次运行到断点处都会发生错误,那么不停地在调试器和应用程序之间穿梭很快就会让人厌倦,这时高级断点就派上了用场。从本质上来讲,高级断 点允许你将某些智慧写入到断点中,让调试器在执行到断点处时,只当程序内部状态符合你指定的条件时才在断点处中断程序运行,并切换到调试器中。
按下Alt+F9快捷键弹出Breakpoints对话框,浏览一下对话框发现该对话框分为Location、Data和Messages三页,分别对应 三种断点:
1. 位置断点:我们通常使用的简单断点均为位置断点,我们还可以设置断点在某个二进制地址或任何函数上,并通过指定各种限定条件来增强位置断点的功能。
2. 表达式和变量断点:调试器会让程序一直运行,直到满足所设的条件或者指定数据更改为止。在Intel CPU上,这两种断点都尽可能通过CPU的特定调试寄存器使用一个硬件断点,如果能够使用调试寄存器,那么程序将能够全速运行,否则调试器将单步执行每个 汇编指令,并每步都检查条件,程序的运行速度将极其缓慢甚至无法运行。
3. Windows消息断点:使用消息断点,可以让调试器在窗口过程接收到一个特定的Windows消息时中断。消息断点适用于C SDK类型的程序,对于使用MFC等C++类库的程序(应该是绝大多数)来说,消息断点并不实用,可以变通地使用位置断点来达到同样效果。
各种高级断点的设置在MSDN中有详细的介绍,请在Visual C++子集下搜索主题Using Breakpoints: Additional Information并阅读相关内容。
3.4 调用堆栈
有时候我们并不清楚应该在哪里设置断点,只知道程序正在运行就突然崩溃了,这时候如何定位到出错地点呢?这时的选择就是查看调用堆栈,调用堆栈可以帮助我 们确定某一特定时刻,程序中各个函数之间的相互调用关系。
方法是当程序执行到某断点处或者程序崩溃,控制权转到调试器后,按下Alt+7快捷键,弹出Call Stack窗口,你可以看到当前函数调用情况,当前函数在最上面,下面的函数依次调用其上面的函数。在Call Stack窗口的弹出菜单上选择Parameter Values和Parameter Types可以显示各个函数的参数类型和传入值。
3.5 使用跟踪工具
有些时候,我们希望了解程序中不同函数之间的协作关系,或者由于文档的缺失,希望能够确认函数在不同情况下被调用时的传入参数值。这时使用断点功能就过分 麻烦,而调用堆栈只能查看当前函数的被调用情况,一种较好的方法就是使用TRACE宏以及相对应的工具。
程序(Debug版)运行中,一旦运行到TRACE宏,就会向当前Windows系统的调试器输出TRACE宏内指定的字符串并显示出来,当在 Visual C++环境中调试运行(按F5键)程序时,可以在Output窗口的Debug页看到TRACE宏的输出内容。实际上,TRACE宏是封装了 Windows API函数OutputDebugString的功能,有些辅助工具可以在不惊动Visual C++调试器的前提下,拦截程序中TRACE宏的输出内容,比如《深入浅出MFC》的附录中提到的Microsoft System Journal(MSJ)1996年1月的C/C++专栏介绍的TraceWin工具(在较老版本的MSDN中可以找到源代码和文档)以及功能强大的免费 工具DebugView。
使用TRACE宏,我们可以轻松了解程序中各个函数之间的相互协作关系和被调用的先后顺序和时间,进一步说,你能够完全掌握程序的执行流程。
最后请注意,TRACE宏会对程序效率有所影响,所以,当前不用的TRACE宏最好删除或者注释掉。
4 阅读程序的技巧
对于程序员来说,无论是学习还是工作,经常要阅读其他程序员的源代码,如何快速领悟程序的思想,洞悉程序的结构和各个组成部分的功能,进而全面掌握程序所 涉及的方方面面,是程序员很重要的一项基本技能。下面介绍一些常用的技巧。
4.1 从功能、界面入手
一个完整的应用程序或者系统是由若干相对独立的功能构成,这些功能反应在与用户交互的图形界面上,就是各种菜单命令、工具栏按钮命令等等。所以如果当前只 对程序的某几个功能感兴趣,可以在程序中找到这些菜单命令、按钮命令等的ID响应函数,以此为起点,逐步深入到程序内部,直到完全理解该功能的实现为止。 此过程所花费的时间,很大程度上取决于程序员对调试技术的掌握程度。
需要强调的是,在不熟悉程序核心结构和实现技术的情况下,直接采用该方法探究程序,当逐步深入到程序核心时,涉及的程序模块数量会急剧增长,理解难度也会 骤然增大;一旦你对程序核心结构和实现技术了然于胸,采用该方法探究程序,会有势如破竹之感觉。
4.2 砍去枝叶,只留主干
前面已经提到,无论如何,最终你都要掌握程序核心结构和实现技术。如何掌握呢?方法是首先将拿到的程序进行完整的备份,然后将次要功能都从程序中去掉,只 留下的必须的部分。去除次要功能是一个反复多次的过程,花费的时间取决于程序员对行业知识的理解程度、编程技术的高低和经验的多少。
经常遇到无法在短时间内判断某个模块是否次要的情况(随着对程序的理解逐渐加深,以及经验和技术的积累,这种情况会越来越少),这时候建议直接将该模块去 除,然后重新编译连接程序,运行程序,看程序运行是否正常。
以上介绍的两种方法是使用比较频繁的,两种方法可以相互结合,交替使用。但无论采用什么方法探究阅读程序,都不要指望能够不费任何气力,花费一两个钟头就 能够将上万行的程序探究个明白。
1 规范易懂的代码
现阶段软件开发,都要依靠团队的合作。程序员不再是个人英雄主义的代名词,程序员一方面要依赖大量其他程序员完成的代码,一方面又提供大量代码给其他人使 用,代码实际上具备了两个要素:首先是可靠的提供某种功能,其次是清楚地表达作者的思想。任何交流都必须有一定的规范才能进行,体现在代码中就是规范易 懂。另外,规范易懂的代码才是可重复使用的,规范的代码具有更长的寿命,具有更好的可维护性,也更方便后期的扩展。
1.1 好代码的几个特征
怎么样的代码才算规范易懂,体现在细节上会有无数的争论,实际上无论风格和习惯如何,好的代码具有几个共同的特征:
1. 良好的命名:好的变量名和函数名,让阅读代码的人马上就知道该变量或者函数的作用,很容易就能理解程序的大概结构和功能。程序员有必要理解匈牙利命名法。
2. 一致性:一致性带来更好的程序,一致的代码缩进风格能够显示出代码的结构,采用何种缩进风格并不重要,实际上,特定的代码风格远没有一致的使用它们重要。
3. 注释:注释是帮助程序读者的一种手段,程序作者也是未来的程序读者之一。最好的注释是简洁地点明程序的突出特征,或是提供一种概观,帮助别人理解程序;但 如果注释只是说明代码已经讲明的事情,或者与代码矛盾,或者以精心编排的形式迷惑干扰读者,那就是帮了倒忙。
1.2 养成好习惯
前面已经提过,特定的代码风格远没有一致的使用他们重要,所以,把过多的精力放到A or B的选择上是浪费时间,你要做的是坚持。如何书写规范易懂的代码,如何养成良好的习惯,下面是一些提示。
1. 按照匈牙利命名法给变量和函数命名。
2. 遵循国际流行的代码风格。
3. 写代码的同时就遵循你的命名规范和书写风格,千万不能事后补救。
4. 利用工具(Parasoft C++ Test)检查你的代码,评估一下自己形成良好的习惯没有。
5. 坚持不懈直到养成习惯。
2 编写安全可靠的代码
在大型应用软件系统中,各个代码片段共同构成完整的系统,代码间的交互非常频繁,程序崩溃往往并不在错误发生的时候就发生,而是延迟了一段时间,经过数个 函数之间的中转后才发生,此时定位和查找错误非常费时费力,如何才能及时反映程序中的错误,如何在代码中避免一些幼稚的语义错误呢?一个函数往往会被其他 程序员拿来使用,但是他怎么能够正确的使用其他人编写的函数呢?这部分内容能够(部分)帮助解决这些问题。
2.1 契约编程
契约编程(Design by Contract)的思想在C++圣经级的著作,C++之父Bjarne Stroustrup的《C++程序设计语言》中略微提到过,OO领域的圣经级著作《面向对象软件构造》以大篇幅阐释了契约编程,现在越来越多的软件开发 人员认识到契约编程的重要性,并逐步地在实际工作中采用契约编程。
对契约编程简单的解释是:对实现的代码块(函数、类)通过规定调用条件(约束)和输出结果,在功能的实现者和调用者之间定义契约。
具体到我们的工作,开发人员应该对完成的每个函数和类,定义契约。契约编程看似平淡无奇,对程序开发没有什么具体的帮助,实际上,契约编程在开发阶段就能 够最大程度的保证软件的可靠性和安全性。
在实际工作中,每当你需要使用其他程序员提供的模块,你并不知道如何调用,也不知道你传入的参数是否合法,有时候对于功能模块的处理结果也不敢相信。这些 本来应该很明显的信息因为模块提供者没有显式的提供,造成了调用者只能忐忑不安的摸着石头过河,浪费了大量时间,而且为了让自己的代码更安全可靠,在代码 中做了大量的判断和假设,造成代码结构的破坏和执行效率的损失,最后,调用者依旧不能确保自己的调用是正确的。
而契约编程通过严格规定函数(或类)的行为,在功能提供者和调用者之间明确了相互的权利和义务,避免了上述情况的发生,保证了代码质量和软件质量。
2.2 主动调试
主动调试指在写代码的时候,通过加入适量的调试代码,帮助我们在软件错误发生的时候迅速弹出消息框,告知开发人员错误发生地点,并中止程序。这些调试代码 只在Debug版中有效,当经过充分测试,发布Release版程序的时候,这些调试代码自动失效。
主动调试和契约编程相辅相成,共同保证软件开发的质量。契约编程相当于经济生活中签订的各种合同,而主动调试相当于某方不遵守合同时采取的法律惩罚措施。
各种开发语言和开发工具都提供这些调试语句,标准C++提供了assert函数,MFC提供了ASSERT调试宏帮助我们进行主动调试,在实际工作中,建 议统一使用MFC的ASSERT调试宏。
2.2.1 参数检查
对于编写的函数,除了明确的指定契约外,在函数开始处应该对传入的参数进行检查,确保非法参数传入时立即报告错误信息。例如:
BOOL GetPathItem ( int i , LPTSTR szItem , int iLen )
{
ASSERT ( i > 0 ) ;
ASSERT ( NULL != szItem ) ;
ASSERT ( ( iLen > 0 ) && ( iLen < MAX_PATH ) ) ;
ASSERT ( FALSE == IsBadWriteStringPtr ( szItem , iLen ) ) ;
}
对指针的检查尤其要注意,通常程序员会这样进行检查:
// An example of checking only a part of the error condition
BOOL EnumerateListItems ( PFNELCALLBACK pfnCallback )
{
ASSERT ( NULL != pfnCallback ) ;
}
这样的检查只能够排除指针为空的情况,但是如果指针指向的是非法地址,或者指针指向的对象并不是我们需要的类型,上面的例子就没有办法检查出来,而是统统 认为是正确的。完整的检查应该如下:
// An example of completely checking the error condition
BOOL EnumerateListItems ( PFNELCALLBACK pfnCallback )
{
ASSERT ( FALSE == IsBadCodePtr ( pfnCallback ) ) ;
}
2.2.2 内部检查
恰当地在代码中使用ASSERT,对bug检测和提高调试效率有极大的帮助,下面举个简单的例子加以说明。
switch( nType )
{
case GK_ENTITY_POINT:
// do something
break;
case GK_ENTITY_PLINE:
// do something
break;
default:
ASSERT( 0 );
}
在上面的例子中,switch语句仅仅处理了GK_ENTITY_POINT和GK_ENTITY_PLINE两种情况,应该是系统中当时只需要处理这两 种情况,但是如果后期系统需要处理更多的情况,而此时上面这部分代码又没有及时更新,或者是因为开发人员一时疏忽遗漏了。一个可能导致系统错误或者崩溃的 bug就出现了,而使用ASSERT可以及时地提醒开发人员他的疏忽,尽可能快的消灭这个bug。
还有一些情况,在开发人员编写代码时,如果能够确信在某一点出现情况A就是错误的,那么就可以在该处加上ASSERT,排除情况A。
综上所述,恰当、灵活的使用ASSERT进行主动调试,能够极大提高程序的稳定性和安全性,减少调试时间,提高工作效率。
2.3 有用的代码风格
一些好的代码风格也能够帮助你避免一些幼稚的、低级的错误,而这种错误又是很难检测到的。由于C++语言简洁灵活的特性,有时候敲错一个字符,或者漏敲一 个字符,都有可能造成极大的灾难,而这种错误并不是随着你的编程水平和经验的提高就能逐步避免的,谁都会敲错字符,对吧。
比如程序员经常将等于逻辑判断符==误敲成赋值运算符=,对于我来说就不太可能程序运行出错后才发现,因为我的习惯是,对于逻辑判断,将常量置于==的左 边,如果我误输入了=,那么编译的时候编译器就会报错。
if( INT_MAX == i )
3 Visual C++调试技术
检查代码直到头晕眼花也没有发现错误,一运行程序就死机,只好祭出最后的法宝:调试器。Visual C++调试器可以称得上Windows平台下最好的C/C++调试器了,而且Visual C++调试器还可以调试用其他语言如Delphi、Java编写的程序,可谓功能强大。
尽管Visual C++调试器具有如此大的威力,它也只能帮助你发现一些隐藏的逻辑错误,对于程序设计和结构的缺陷无能为力。
程序员最常用到的Visual C++调试技术有设置断点、跟踪调用堆栈和反汇编调试,其他编译器功能均为调试中的辅助工具,因为反汇编调试需要程序员具备汇编语言知识和语言底层结构, 这里不再介绍。
3.1 调试的先决条件
专业调试者有一个共同的特点,即他们同时也是优秀的开发者。显然,如果你不是一个优秀的开发者,那么你也不可能成为调试专家,反之亦然。以下是要成为一名 高水平的,至少是合格的调试者或者开发者所需要精通的领域。
1. 了解项目:对项目的了解是防范用户界面、逻辑及性能方面的错误的第一要素。了解各种功能如何在各种源文件里实现,以及在哪儿实现,你就能够缩小查找范围, 很快找出问题所在。
2. 掌握语言:掌握项目所使用的语言,调试者(开发者)既要知道如何使用这些语言进行编程,还要知道这些语言在后台作些什么。
3. 掌握技术:要解决棘手的问题,第一个重要步骤就是抓住所用技术的要领,这并不意味着你必须对所用技术的一切细节都一清二楚,而是说你应该对所使用的技术有 一个大概的了解,而且更重要的是,当需要更详细的信息时,你应该确切的知道在哪儿查找。
4. 操作系统和CPU:任何项目都实际运行在特定的操作系统和特定的CPU,对操作系统了解越多,对查找错误帮助越大;从理论上来说,掌握汇编语言,你就可以 调试解决任何bug。
无论从事什么工作,只要是经常从事技术工作的人,都必须不断地学习以跟上技术的发展,更不用说想干得更好或是想走在技术发展的前沿。经常阅读优秀的技术书 籍和杂志,多动手编写一些实用程序,阅读其他优秀开发者的代码,作一些反汇编工作,都会有效帮助你提高开发和调试水平(尤其当你将这四者有机结合起来)。
3.2 调试过程
确定一个适用于解决所有错误的调试过程有一定的难度,但John Robbins提出的调试过程应该说是最实用的:
1. 复制错误
2. 描述错误
3. 始终假定错误是自己的问题
4. 分解并解决错误
5. 进行有创见的思考
6. 使用调试辅助工具
7. 开始调试工作
8. 校验错误已被更正
9. 学习和交流
对错误进行描述有助于改正错误,同时也能够得到同事们的帮助。逐步缩小问题范围、排除不存在错误的代码段,直到找到问题所在,是解决所有问题的普遍适用方 法。有些奇怪的错误需要你把视线从代码堆转移到诸如操作系统、硬件环境等其他方面去。善用各种调试辅助工具能够节省你大量的时间,而且某些工具本身就不会 给你犯有些错误的机会。当你解决了一个bug,停下来思考一下,什么导致你(或他)犯了这样的错误,以后如何避免?
要记住调试器仅仅是个工具,就好比一只螺丝起子,你让它做什么它就只做什么,真正的调试器是你自己脑子中的调试思想。
3.3 断点及其用法
在Microsoft Visual C++调试器中在源代码行中设置一个断点很简单。只需要打开源文件,将光标放在想要设置断点的代码行上,按下F9快捷键就可以了,再次按下F9快捷键就会 取消断点。当运行该代码行的代码时,调试器将在所设置的位置处停止。这种简单的位置断点的功能极其强大,经过统计,只需要单独的使用这种断点,就可以解决 99.46%的调试问题。
如果程序并不是每次运行到断点处都会发生错误,那么不停地在调试器和应用程序之间穿梭很快就会让人厌倦,这时高级断点就派上了用场。从本质上来讲,高级断 点允许你将某些智慧写入到断点中,让调试器在执行到断点处时,只当程序内部状态符合你指定的条件时才在断点处中断程序运行,并切换到调试器中。
按下Alt+F9快捷键弹出Breakpoints对话框,浏览一下对话框发现该对话框分为Location、Data和Messages三页,分别对应 三种断点:
1. 位置断点:我们通常使用的简单断点均为位置断点,我们还可以设置断点在某个二进制地址或任何函数上,并通过指定各种限定条件来增强位置断点的功能。
2. 表达式和变量断点:调试器会让程序一直运行,直到满足所设的条件或者指定数据更改为止。在Intel CPU上,这两种断点都尽可能通过CPU的特定调试寄存器使用一个硬件断点,如果能够使用调试寄存器,那么程序将能够全速运行,否则调试器将单步执行每个 汇编指令,并每步都检查条件,程序的运行速度将极其缓慢甚至无法运行。
3. Windows消息断点:使用消息断点,可以让调试器在窗口过程接收到一个特定的Windows消息时中断。消息断点适用于C SDK类型的程序,对于使用MFC等C++类库的程序(应该是绝大多数)来说,消息断点并不实用,可以变通地使用位置断点来达到同样效果。
各种高级断点的设置在MSDN中有详细的介绍,请在Visual C++子集下搜索主题Using Breakpoints: Additional Information并阅读相关内容。
3.4 调用堆栈
有时候我们并不清楚应该在哪里设置断点,只知道程序正在运行就突然崩溃了,这时候如何定位到出错地点呢?这时的选择就是查看调用堆栈,调用堆栈可以帮助我 们确定某一特定时刻,程序中各个函数之间的相互调用关系。
方法是当程序执行到某断点处或者程序崩溃,控制权转到调试器后,按下Alt+7快捷键,弹出Call Stack窗口,你可以看到当前函数调用情况,当前函数在最上面,下面的函数依次调用其上面的函数。在Call Stack窗口的弹出菜单上选择Parameter Values和Parameter Types可以显示各个函数的参数类型和传入值。
3.5 使用跟踪工具
有些时候,我们希望了解程序中不同函数之间的协作关系,或者由于文档的缺失,希望能够确认函数在不同情况下被调用时的传入参数值。这时使用断点功能就过分 麻烦,而调用堆栈只能查看当前函数的被调用情况,一种较好的方法就是使用TRACE宏以及相对应的工具。
程序(Debug版)运行中,一旦运行到TRACE宏,就会向当前Windows系统的调试器输出TRACE宏内指定的字符串并显示出来,当在 Visual C++环境中调试运行(按F5键)程序时,可以在Output窗口的Debug页看到TRACE宏的输出内容。实际上,TRACE宏是封装了 Windows API函数OutputDebugString的功能,有些辅助工具可以在不惊动Visual C++调试器的前提下,拦截程序中TRACE宏的输出内容,比如《深入浅出MFC》的附录中提到的Microsoft System Journal(MSJ)1996年1月的C/C++专栏介绍的TraceWin工具(在较老版本的MSDN中可以找到源代码和文档)以及功能强大的免费 工具DebugView。
使用TRACE宏,我们可以轻松了解程序中各个函数之间的相互协作关系和被调用的先后顺序和时间,进一步说,你能够完全掌握程序的执行流程。
最后请注意,TRACE宏会对程序效率有所影响,所以,当前不用的TRACE宏最好删除或者注释掉。
4 阅读程序的技巧
对于程序员来说,无论是学习还是工作,经常要阅读其他程序员的源代码,如何快速领悟程序的思想,洞悉程序的结构和各个组成部分的功能,进而全面掌握程序所 涉及的方方面面,是程序员很重要的一项基本技能。下面介绍一些常用的技巧。
4.1 从功能、界面入手
一个完整的应用程序或者系统是由若干相对独立的功能构成,这些功能反应在与用户交互的图形界面上,就是各种菜单命令、工具栏按钮命令等等。所以如果当前只 对程序的某几个功能感兴趣,可以在程序中找到这些菜单命令、按钮命令等的ID响应函数,以此为起点,逐步深入到程序内部,直到完全理解该功能的实现为止。 此过程所花费的时间,很大程度上取决于程序员对调试技术的掌握程度。
需要强调的是,在不熟悉程序核心结构和实现技术的情况下,直接采用该方法探究程序,当逐步深入到程序核心时,涉及的程序模块数量会急剧增长,理解难度也会 骤然增大;一旦你对程序核心结构和实现技术了然于胸,采用该方法探究程序,会有势如破竹之感觉。
4.2 砍去枝叶,只留主干
前面已经提到,无论如何,最终你都要掌握程序核心结构和实现技术。如何掌握呢?方法是首先将拿到的程序进行完整的备份,然后将次要功能都从程序中去掉,只 留下的必须的部分。去除次要功能是一个反复多次的过程,花费的时间取决于程序员对行业知识的理解程度、编程技术的高低和经验的多少。
经常遇到无法在短时间内判断某个模块是否次要的情况(随着对程序的理解逐渐加深,以及经验和技术的积累,这种情况会越来越少),这时候建议直接将该模块去 除,然后重新编译连接程序,运行程序,看程序运行是否正常。
以上介绍的两种方法是使用比较频繁的,两种方法可以相互结合,交替使用。但无论采用什么方法探究阅读程序,都不要指望能够不费任何气力,花费一两个钟头就 能够将上万行的程序探究个明白。