C++ 黑客编程揭秘与防范(第3版)
上QQ阅读APP看书,第一时间看更新

1.5.3 VC6调试工具介绍

在编写代码的过程中,经常需要查找逻辑上的问题,或者是查找一些原因不明的问题。在这种情况下,就需要使用调试工具对编写的代码进行调试,以便能够找到代码中的问题。

1.调试器

调试的一般过程是让程序在调试的状态下运行。什么是调试状态呢?其实很简单,就是让程序在调试器的控制下运行。调试器可以对程序做多方面的控制,这里举几个简单的方面。

· 调试器对程序设置断点,使程序产生中断从而停止下来。

· 调试器可以使程序进行单步执行,即执行一条语句(也可以是一条汇编指令,因为高级语言的一条语句可能会对应汇编的多条指令)就停下来。

· 调试器可以让程序运行到光标指定的位置。

· 调试器在程序处于中断的情况下可以查看程序的各种执行状态,查看变量的当前值、内存当前的布局、当前的调用栈情况。

对于调试器的诸多功能,无法全面介绍,各种使用技巧及方法需要读者慢慢体会。下面的内容将针对上面的介绍来说明VC6中提供的调试器的使用。

2.被调试程序的代码

调试器具有的功能在前面已经进行了简单的说明。前面介绍调试器的功能不单单针对VC6提供的调试器,几乎任何调试器都支持以上功能,而且专业的调试器功能远不止如此。下面举例介绍说明VC6的调试器。

首先新建一个VC6的控制台应用程序,输入如下代码:

        #include <iostream.h>

        int main(int argc, char* argv[])
        {
            // 定义3个整型的指针变量
            int *p = NULL;       // 32位的整型变量指针
            __int64 *q = NULL;  // 64位的整型变量指针
            int *m = NULL;       // 32位的整型变量指针

            // 使用new分配一个整型的内存空间
            // 用指针变量p指向该内存空间
            p = new int;
            if ( p == NULL )
            {
                return -1;
            }

            // 为指针变量p指向的内存空间赋值
            *p = 0x11223344;

            // q和m操作同p
            q = new __int64;
            if ( q == NULL )
            {
                return -1;
            }

            *q = 0x1122334455667788;

            m = new int;
            if ( m == NULL )
            {
                return -1;
            }

            *m = 0x11223344;

            // 释放3个变量指向的地址空间
            // 释放顺序依次是q、m、p
            delete q;
            q = NULL;

            delete m;
            m = NULL;

            delete p;
            p = NULL;

            return 0;
        }

写完该程序后,按F7键进行编译连接,生成可执行文件。上面的步骤属于代码编辑、编译、连接的过程。接下来要完成的工作是对这段源代码生成的可执行文件进行调试,目的是熟悉VC6的调试器,以及熟悉VC6下Debug编译方式下生成的可执行文件是如何对“堆”空间进行管理的。

:堆空间是在程序运行时由程序员自己申请的空间,该空间同样需要程序员自己进行释放。在C++语言中,使用new关键字申请堆空间,使用delete关键字可以对堆空间进行释放。C语言中的malloc()和free()函数也是申请和释放堆空间的函数。在程序中,除了有“堆”空间以外,还有另一种称为“栈”的内存空间,栈空间是由系统进行维护的空间。局部变量和函数的参数使用的都是栈空间,栈空间的分配和回收是由系统自动进行维护的。这里的“堆”与数据结构中的“堆排序”没有任何关系。

3.认识调试窗口

在编辑完以上的代码后,按F10键让程序处于调试状态,开始对编译生成的程序进行调试,程序的窗口界面如图1-20所示。

图1-20 VC的调试界面

VC的调试界面分为5个区域,(从左到右、从上到下)依次是调试工作区、寄存器窗口、调用栈窗口、监视窗口和内存窗口。除了调试工作区外,其余几个窗口都不是必需的。根据环境的不同,不是每个VC6在调试状态下都会出现这些窗口。除了这几个窗口外,还有其他关于调试方面的窗口。各种调试窗口的打开方式可以通过菜单进行,如图1-21所示。

图1-21 打开调试窗口的菜单

VC6的调试环境提供了6个调试窗口,均是常用的调试窗口。调试窗口的使用非常容易,这里不做过多的介绍。

程序在进入调试状态后,不可能始终通过单步方式让程序一步一步执行。调试器提供了多种调试运行方式,通过调试器控制可以使程序按照不同的方式运行。VC6提供了几种调试运行的方式,如图1-22所示。

图1-22 调试菜单

图1-22中的4种运行方式分别如下。

Step Into:这种方式称为单步步入方式,快捷键是F11键。单步步入的意思是当单步调试时,遇到函数调用时会进入被调用的函数体内。

Step Over:这种方式称为单步步过方式,快捷键是F10键。单步步过的意思是当单步调试时,遇到函数调用时不会进入被调用的函数体内。

Step Out:这种方式称为执行到函数返回处。当调试进入某个函数时,这个函数又不是调试的关键函数,可以通过该方式快速返回。

Run to Cursor:这种方式称为执行到光标处。当调试时明确知道要调试的地方时,可以使程序运行至光标指定的位置,这样会节省很多因为单步调试而浪费的时间。

除了上面几个调试命令外,再介绍3个调试的命令,分别是F9、F5和F7键。F9键是在光标指定的位置设置断点,当程序在调试状态下运行时遇到断点,会产生中断(程序在调试器中被中断后可以观察被调试程序的变量值,某块内存中的内容);F5键使程序进入调试状态运行,如果代码中有断点,则会在断点处产生中断,如果没有断点,程序会运行到界面启动或等待用户的交互,或者直接执行完程序自动结束调试状态;F7键是结束调试状态下运行的程序。

在调试程序时,尤其是调试代码量非常大的程序时,往往不可能通过单步执行一直来进行调试。通常情况是在某个或某几个关键的位置设置断点,然后让程序处于调试运行,当运行到断点处,程序会产生中断,这时再通过单步调试方法调试重要的代码部分,观察变量、内存、调用栈等数据的实时变化情况。一般调试时,都是调试部分代码的上下文,很少有从头开始调试的,那样效率就太低下了。

4.调试程序

前面的准备工作都已经完成了,接下来就来调试上面编辑的代码。按F10键,让程序处于调试状态,在监视窗口(Alt+F3组合键显示的Watch窗口)添加要监视的变量,分别是p、q、m、&p、&q、&m。当前调试的光标在main()函数的第一个花括号处,按F10键单步执行一步观察监视窗口,如图1-23所示。

图1-23 Watch窗口的说明

观察如图1-23所示的Watch窗口,通过&p、&q和&m可以看出,3个指针变量pqm已经分配了变量的空间,分别是0x0012ff7c、0x0012ff78、0x0012ff74(如果没有Watch窗口,可以按照前面的介绍打开Watch窗口,如果在Watch窗口中没有内容,可以在Watch窗口中进行添加)。从这里可以看出,在主函数中先定义的变量的地址(局部变量使用的是栈地址)要大于后定义的变量的地址。由于在Win32系统下指针变量所占用的空间大小为4字节,通过3个地址值可以看出,3个变量的地址按照定义顺序依次紧挨。变量pqm的值为0xcccccccc,这是VC6 Debug编译方式下默认对局部变量初始化的值。

单步执行到p=new int;代码处,观察监视窗口,这时可以看到3个变量的值为0,因为3个变量经过初始化后值都被赋为NULL。

在if( p==NULL )代码处按F10键,观察p指向地址的值,如图1-24所示。在VC6的Debug编译方式下,未进行赋值的堆空间的值为0xCDCDCDCD。

图1-24 未赋值的堆空间的值为0xCDCDCDCD

按F10键单步到q=new __int64;代码处,观察监视窗口和内存窗口(内存窗口调整为每行显示16字节),如图1-25所示。

图1-25 通过监视窗口的地址观察内存窗口

在监视窗口中,将&p、&q和&m进行修改,修改为(int *)&p、(__int *)&q和(int *)&m。这里简单说明一下,指针变量p的地址为0x0012ff7c,p指向的地址为0x00382e50,p指向的地址中的值为0x11223344。观察内存窗口,在0x00382e50处保存的值为44 33 22 11(相当于0x11223344。关于为什么顺序是反的,在后面的章节中会给出解释)。

:有些C语言的书中说道,指针就是地址。这样的说法是不严密的,准确来说,指针是有类型的地址。“*”操作需要根据指针的类型来进行取值。对于一个指针,要了解其4个方面,分别是指针的类型、指针的地址、指针指向的地址和指针指向地址的值。如果对这里的解释不明白,请复习C语言关于介绍指针的部分,这里不对C语言的语法知识进行过多的介绍。

按F10键单步执行到delete q;代码处,将p指向的地址减0x20字节,即0x00382e50-0x20=0x00382e30,然后在内存窗口中观察,如图1-26所示。

图1-26 内存窗口

现在来分析图1-26中的内容,通过监视窗口可以看出p指向的空间为0x00382e50,q指向的空间为0x00382e98,m指向的空间为0x00382ee0。这3个变量指向的空间比较近。再来观察内存窗口,0x00382e30地址处的值为“98 07 38 00 78 2e 38 00”,这里是两个地址,分别是0x00380798和0x00382e78;0x00382e78地址处的值为“30 2e 38 00 c0 2e 38 00”,这里也是两个地址,分别是0x00382e30和0x00382ec0。0x00382e30是不是看着比较眼熟?这个值就是内存窗口中第一个地址的位置。0x00382ec0地址处的值为“78 2e 38 00 00 00 00 00”,这里同样是两个地址,分别是0x00382e78和0x00000000。0x00382e78是不是看着比较眼熟?整理一下这几个地址,如图1-27所示。从图1-27中可以看出,使用new申请的堆空间是通过双向链表进行链式管理的。图1-27所示为最后一个节点的0x00000000表示链表的结尾。

图1-27 堆的链式管理

明白了链表是链式管理后,接着我们分析其他相关数据。当使用new申请的空间不再使用时,会使用delete释放空间,那么delete要释放多大的空间呢?堆空间的首地址处是管理双向链表的指针,在首地址偏移0x10的位置记录了堆空间的大小,第一个堆空间的首地址是0x00382e30,偏移0x10的位置是0x00382e40,在0x00382e40地址保存的值为4。其余几个用new申请的空间的大小通过这种方式也可以找到。

在堆空间偏移0x18的位置记录堆的一个序号,程序中通过new申请的第1块堆空间的序号为30,第2块为31,第3块为32。

在图1-26中,每个数值的前后(对pqm赋的值)都有4个“FD FD FD FD 44 33 22 11 FD FD FD FD”,前后的FD是用来在调试时检测溢出的。当为指向整型地址的p变量赋值超过4字节时,就会覆盖数值后面的FD;当调试程序时,通过查看FD的值,就可以观察到赋值溢出了。

关于堆的管理结构就介绍这么多,继续按F10键单步执行,执行到q=NULL语句处,观察内存窗口,如图1-28所示。

图1-28 释放q指向的内存后的内存布局

通过图1-28可以看出,释放后的堆空间会被赋值为“EE FE”。观察堆链表的指针的变化,第1块堆的后继链表指针指向了第3块堆,第3块堆的前驱链表指针指向了第1块堆。关于链表的具体操作,需要学习和阅读关于数据结构的知识。

提示:VC默认提供2种编译方式,分别为DEBUG和RELEASE。以上堆管理方法为DEBUG编译方式,RELEASE编译方式并不是该种管理方法。