C语言运用VS调试讲解

C语言运用VS调试讲解

插入断点

调试(Debug),就是让代码一步一步慢慢执行,跟踪程序的运行过程。比如,可以让程序停在某个地方,查看当前所有变量的值,或者内存中的数据;也可以让程序一次只执行一条或者几条语句,看看程序到底执行了哪些代码。

在调试的过程中,我们可以监控程序的每一个细节,包括变量的值、函数的调用过程、内存中数据、线程的调度等,从而发现隐藏的错误或者低效的代码。

默认情况下,程序不会进入调试模式,代码会瞬间从开头执行到末尾。要想观察程序的内部细节,就得让程序在某个地方停下来,我们可以在这个地方设置断点。

所谓断点(BreakPoint),可以理解为障碍物,人遇到障碍物不能行走,程序遇到断点就暂停执行。
在这里插入图片描述
上图中,我们希望让程序在第4行代码处暂停执行,那么在第4行代码左侧的灰色部分单击鼠标即可插入断点。


调试相关窗口

在IDE的上方出现了与调试相关的工具条,下方也多出了几个与调试相关的窗口:

  • 调用堆栈可以看到当前函数的调用关系。
  • 断点窗口可以看到当前设置的所有断点。
  • 即时窗口可以让我们临时运行一段代码,后续我们会重点讲解。
  • 输出窗口和我们之前看到的没有,用来显示程序的运行过程,给出错误信息和警告信息。
  • 自动窗口会显示当前代码行和上一代码行中所使用到的变量。
  • 局部变量窗口会显示当前函数中的所有局部变量。
  • 线程和模块窗口暂时无需理会。

点击“运行”按钮或者按F5键即可跳过断点,让程序恢复正常状态,继续执行后面的代码,直到程序结束或者遇到下一个断点。

在调试模式下,把鼠标移动到要查看的变量的上方,即可看他它的值。

如果你希望长时间观测某个变量,还可以将该变量添加到监视窗口。在要监视的变量处单击鼠标右键,弹出如下菜单,选择添加监视。


不同调试方法

逐过程(F10)和逐语句(F11)都可以用来进行单步调试,但是它们有所区别
逐过程(F10)在遇到函数时,会把函数从整体上看做一条语句,不会进入函数内部;
逐语句(F11)在遇到函数时,认为函数由多条语句构成,会进入函数内部。

逐语句(F10)不仅可以进入库函数的内部,还可以进入自定义函数内部。在实际的调试过程中,两者结合可以发挥更大的威力。

断点 + 查看/修改变量 + 逐过程调试 + 逐语句调试,这足以解决绝大多数逻辑问题,到此,初学者就已经学到了调试的基本技能

修改代码运行位置

在VS中,调试器还允许我们直接跳过一段代码,不去执行它们
例:

#include <stdio.h>
int main(){
    printf("111\n");
    printf("222\n");
    printf("333\n");
    printf("444\n");
    printf("555\n");
    printf("666\n");
    return 0;
}

在第3行设置断点,开始单步调试。假设我们不希望执行4~6行的代码,那么当程序执行到第4行时,可以将鼠标移动到黄色箭头处,直接向下拖动到第7行,如下图所示:
在这里插入图片描述
程序执行完成后,在控制台上会输出:
111
555
666

注意:随意修改程序运行位置是非常危险的行为,假设我们定义了一个指针,在第N行代码中让它指向了一个数组,如果我们在修改程序运行位置的时候跳过了第N行代码,并且后面也使用到了该指针,那么就极有可能导致程序崩溃。

即时窗口的使用

即时窗口”是VS提供的一项非常强大的功能,在调试模式下,我们可以在即时窗口中输入C语言代码并立即运行,如下图所示:

在这里插入图片描述
在即时窗口中可以使用代码中的变量,可以输出变量或表达式的值(无需使用printf()函数),也可以修改变量的值。

即时窗口本质上是一个命令解释器,它负责解释我们输入的代码,再由VS中的对应模块执行,最后将输出结果呈现到即时窗口

需要注意的是,在即时窗口中不能定义新的变量,因为程序运行时 Windows 已经为它分配好了只够刚好使用的内存,定义变量是需要额外分配内存的,所以调试器不允许在程序运行的过程中定义变量,因为这可能会导致不可预知的后果。

即时窗口中除了可以使用代码中的变量,也可以调用代码中的函数。将下面代码复制到源文件中:

int plus(int x, int y){
    return x + y;
}
int main(){
    return 0;
}

在这里插入图片描述
将来在工作开发中用处很大

查看、修改运行时的内存

在 Visual Studio 的调试过程中,有时候我们可能需要对程序的运行内存进行查看,修改该怎么办?Visual Studio 也为我们提供了很好的解决方案。那就是使用 Visual Studio 自带的内存查看窗口。

首先我们通过内存窗口查看变量的值,我们启动 Visual Studio,创建一个工程,输入如下代码:

#include <stdio.h>
int main()
{
    int testNumber = 5678;
    printf("testNumber 的内存地址为 0x00%x \n", &testNumber);
    //输出内存地址
    //TODO:在这里插入断点
    return 0;
}

我们在第七行设置好断点,然后按 F5 启动调试,等待触发断点。触发断点后,我们发现,IDE中并没有显示内存窗口(默认设置下),这时,我们点击菜单 -> 调试(D) -> 窗口 (W) -> 内存 (M) -> 内存1(1),就可以调出内存窗口了,如图:
在这里插入图片描述
我们看到,内存窗口里面显示着一大堆乱七八糟的数据,这里面的这些就是我们内存中的数据啦,我们可以通过变量 testNumber 的内存地址跳转到器对应的内存空间,我们看到 testNumber 的地址为 0x0018f830 (在不同的进程是不一样的),我们把这个地址输入到我们的内存窗口的地址栏。如图:
在这里插入图片描述
我们看到,尽管我们已经输入了正确地地址,但是我们还是没有看到正确的数据显示,其实原因非常简单,我们来回顾一下 C 语言的一些入门知识:我们知道,在我们的源代码中,我们的 testNumber 变量被定义为 int 整形,我们再想想 int 整形在内存中占几个字节?没错,是4个字节。所以我们应该以四字节的形式格式化这些内存数据,这是我们在内存窗口中单击我们的鼠标右键,在弹出的菜单中选择“4字节整数(4)”,然后就能正确地显示相关的数据了,如图:
在这里插入图片描述
没错,查看内存就是这么的简单。接下来我们就来查看与修改浮点数的内存数据,我们看下面这段代码:

#include <stdio.h>
int main()
{
    double pi = 3.141592653589;
    printf("pi 的内存地址为 %x \n", &pi);
    //输出内存地址
    //TODO:在这里插入断点
    return 0;
}

同样的,我们在第7行设置断点,按F5启动调试,等待断点被触发:
在这里插入图片描述
这时我们看到的内存地址是这样的,与我们在内存窗口看到的不同,我们需要将其补齐,在我们现阶段编写的小程序中,显示的内存地址基本上都是六位的,我们在前面加上 “0x00”,将其补到八位(内存窗口上的地址栏里有几位就补到几位)。然后我们将其输入到内存窗口上的地址栏。

在这里插入图片描述
我们发现,现在显示的数据依然是错误的,因为查看器现在还是在使用我们之前设置的 4位整形格式 格式化我们的内存数据呢,我们知道,我们的 double 属于64位浮点数,所以我们在查看窗口点击鼠标右键,在弹出的菜单中选择“64位浮点(6)”,然后我们就能看到它正确地输出数据了。
在这里插入图片描述
我们注意到,在我们设置的变量pi的值的基础上,内存窗口里显示的数据多了几个0,这些0所代表的就是 double 型数据的最高精度。接下来我们尝试在内存窗口修改变量 pi 的值,为其补齐精度。现在我们用鼠标右键点击我们的pi的内存数据,在弹出的菜单中选择编辑值(E),在显示的输入框中我们输入 3.1415926535897931,按回车即可保存。我们看看效果:
在这里插入图片描述
怎么样,内存的查看与修改是不是很简单呢?其实我们只要记住下面的几个对应关系,常用的数值数据类型的内存查看与修改都不在话下:
在这里插入图片描述
在修改内存的时候要注意安全,防止随意修改导致的程序崩溃,甚至是无法结束进程!


有条件断点的设置

大家有没有碰到这样的情况,在一个循环体中设置断点,假设有一千次循环,我们想在第五百次循环中设置断点,这该怎么办?反正设置断点不断按 F5 继续运行四百九十九次是不可能的。那该怎么办呢?其实我们的断点是可以设置各种各样的条件的,对于上述情况,我们可以对断点的命中次数做一个限制。

我们首先在 Visual Studio 中创建一个工程,并且输入如下代码:

#include <stdio.h>
int main(){
    for ( int i=1 ; i <= 1000 ; i++ ) {
        //TODO:插入计次断点
        printf("我真行!\n");
    }
}

首先,我们在第4行插入断点,分析代码,我们不难得出它会输出 1000 行“我真行!”,那么我们思考一下,在不修改代码的情况下,如何才能让他输出 1499 行“我真行!”呢,其实很简单,我们只要在i 等于500的时候暂停程序,再将变量 i 的值修改为 1 即可,思路很简单,接下来我们就来实现这个命中条件的限制吧。

首先我们用鼠标右键单击第4行的断点图标,在弹出的菜单中选择 命中次数(H) ,接下来会弹出如下图的一个对话框,我们在中间的选择框中选择 “中断,条件是命中次数等于”,我们在右边的编辑框输入 500。
在这里插入图片描述
我们点击确定,断点就设置到位了,接下来我们按 F5 运行调试。
在这里插入图片描述
我们看到,在输出四百九十九行“我真行!”后,程序进入了中断状态,这是我们注意到自动窗口中的变量 i 的值为 500,接下来我们把这个 i 的值改为 1,点击 继续© 继续程序的运行,这样程序就再输出了一千行“我真行!”,然后退出。没错,命中次数限制的使用就是这么简单。

我们再次用鼠标右键单击第4行的断点图标,在弹出的菜单中选择 命中次数(H) ,大家如果有兴趣的话,可以试试中间的选择框中其他的条件选项,使用方法基本一致,这里不再赘述。


在 Visual Studio 的调试器中,我们可以对断点设置断点触发条件,这个条件可以引用我们程序中的变量,比如我们程序中有两个变量 a、b ,我们的命中条件可以是 a == b 、 a >= b 、 a != b 甚至是 (a - b)(a2 - b) > 0 这样的复杂条件。

#include <stdio.h>
#include <stdlib.h>
#include <time.h> //time函数所在头文件
int main()
{
    int a, b;
    int randNumber;
    srand((unsigned)time(NULL));
    //设置随机数种子,以产生不同随机数
    for (int i = 0; i<50; i++)
    {
        a = rand() % 7; //产生0-6的随机数
        b = rand() % 7; //产生0-6的随机数
        //TODO:在这里插入条件断点: a == b
    }
    return 0;
}

我们让程序运行过程中 a 等于 b 的时候触发断点,首先,我们在第十四行插入断点,然后我们鼠标右键单击左侧的断点图标,在弹出的菜单中选择条件©,IDE会弹出如下对话框,我们在条件输入框中输入 a==b ,然后在下面选择 为 true ,然后点击确定即可。


assert断言函数

在我们的实际开发过程之中,常常会出现一些隐藏得很深的BUG,或者是一些概率性发生的BUG,通常这些BUG在我们调试的过程中不会出现很明显的问题,但是如果我们将其发布,在用户的各种运行环境下,这些程序可能就会露出马脚了。那么,如何让我们的程序更明显的暴露出问题呢?这种情况下,我们一般都会使用 assert 断言函数,这是C语言标准库提供的一个函数,也就是说,它的使用与操作系统平台,调试器种类无关。我们只要学会了它的使用,便可一次使用,处处开花。

这个函数在 assert.h 头文件中被定义,assert 函数的用法很简单,我们只要传入一个表达式即可,它会计算我们传入的表达式的结果,如果为真,则不会有任何操作,但是如果我们传入的表达式的计算结果为假,它就会像 stderr (标准错误输出)打印一条错误信息,然后调用 abort 函数直接终止程序的运行。

#include <stdio.h>
#include <assert.h>
int main()
{
    printf("assert 函数测试:");
    assert(true); //表达式为真
    assert(1 >= 2); //表达式为假
    return 0;
}

启动调试
在这里插入图片描述
我们看到,我们的输出窗口打印出了断言失败的信息,并且 Visual Studio 弹出了一个对话框询问我们是否继续执行。但是如果我们不绑定调试器,构建发布版程序,按 Ctrl + F5 直接运行呢?是的,这个 assert 语句就无效了,原因其实很简单,我们看看 assert.h 头文件中的这部分代码:

#ifdef NDEBUG
#define assert(_Expression)     ((void)0)
#else  /* NDEBUG */

我们看到,只要我们定义了 NDEBUG 宏,assert 就会失效,而 Visual Studio 的默认的发布版程序编译参数中定义了 NDEBUG 宏,所以我们不用额外定义,但是在其他编译器中,我们在发布程序的时候就必须在包含 assert.h 头文件前定义 NDEBUG 宏,避免 assert 生效,否则总是让用户看到“程序已经停止运行,正在寻找解决方案 . . .”的 Windows 系统对话框可就不妙了。

下面我们来了解一下 assert 的常用情境以及一些注意事项。

在我们的实际使用过程中,我们需要注意一些使用 assert 的问题。首先我们看看下面的这个断言语句:

//...
assert( c1 /*条件1*/ && c2 /*条件2*/ );
//...

我们思考一下:如果我们的程序在这里断言失败了,我们如何知道是 c1 断言失败还是 c2 断言失败呢,答案是:没有办法。在这里我们应该遵循使用 assert 函数的第一个原则:每次断言只能检验一个条件,所以上面的代码应该改成这样:

//...
assert(c1 /*条件1*/);
assert(c2 /*条件2*/);
//...

这样,一旦出现问题,我们就可以通过行号知道是哪个条件断言失败了。

下面我们通过代码展示使用 assert 的另外一个注意事项,我们新建一个工程,输入如下代码:

#include <stdio.h>
#include <assert.h>
int main(void)
{
    int i = 0;
    for ( ; ; )
    {
        assert(i++ <= 100);
        printf("我是第%d行\n",i);
    }
    return 0;
}

我们按 F5 运行调试器,我们会看到这样的情景:
在这里插入图片描述
这是正常的,我们按 Shift + F5 终止调试,接下来,我们切换一下编译模式到发布模式:

在这里插入图片描述
接下来我们按 Ctrl+F5 不绑定调试器直接运行:
在这里插入图片描述
我们看到了一个完全不相同的运行结果,这是为什么呢?其实原因很简单,我们注意这段代码:

assert(i++ <= 100);

我们的条件表达式为 i++ <= 100,这个表达式会更改我们的运行环境(变量i的值),在发布版程序中,所有的 assert 语句都会失效,那么这条语句也就被忽略了,但是我们可以把它改为 i++ ; assert(i <= 100); ,这样程序就能正常运行了。所以请记住:不要使用会改变环境的语句作为断言函数的参数,这可能导致实际运行中出现问题。

最后,我们再来探讨一下,什么时候应该用 assert 语句?一个健壮的程序,都会有30%~50%的错误处理代码,几乎用不上 assert 断言函数,我们应该将 assert 用到那些极少发生的问题下,比如Object* pObject = new Object,返回空指针,这一般都是指针内存分配出错导致的,不是我们可以控制的。这时即使你使用了容错语句,后面的代码也不一定能够正常运行,所以我们也就只能停止运行报错了。


调试信息的输出

在这里,我们将用到一个 Windows 操作系统提供的函数 —— OutputDebugString,这个函数非常常用,他可以向调试输出窗口输出信息(无需设置断点,执行就会输出调试信息),并且一般只在绑定了调试器的情况下才会生效,否则会被 Windows 直接忽略。接下来我们了解一下这个函数的使用方法。

这个函数在 windows.h 中被定义,所以我们需要包含 windows.h 这个头文件才能使用 OutputDebugString 函数。这个函数的使用方式非常的简单,它只有简单的一个参数——我们要输出的调试信息。但是有一点值得注意:准确来说 OutputDebugString 并不是一个函数,他是一个宏。OutputDebugString 实际上等价于 OutputDebugStringW,这就意味着我们必须传入宽字符串(事实上只要定义了 UNICODE ,调用所有 Windows 提供的函数都需要使用宽字符),或者使用 TEXT 或 _T 宏,并且这是最好的方法

我们除了在调试器中可以看到调试字符串的输出,我们还可以借助 Sysinternals 软件公司研发的一个相当高级的工具 —— DebugView 调试信息捕捉工具,这个工具可以在随时随地捕捉 OutputDebugString 调试字符串的输出(包括发布模式构建的程序),可以说这是个神器,大家可以在微软 MSDN 库上搜索下载。


一些调试参数

在Visual Studio 中,可以切换 Release发布模式和Debug编译模式,
Release构建模式下构建的程序为发行版,而Debug构建模式下构建的程序为调试版。在 Visual Studio 中调试模式还会定义两个宏 _DEBUG 和 DEBUG。

常常我们需要在调试的时候额外运行一段代码,但是实际发布的时候却不需要这段代码呢。那该怎么办,绝大多数数的初学者会选择使用注释,即在发布的时候将无用的测试代码注释掉。但是这样很麻烦,下面我们就为大家介绍一种全新的方法——使用调试标记。事实上这种方法我们在前面使用过,但是没有详细讲解。

这种方法借助了预处理指令,方法很简单,我们首先定义一个宏作为处于调试状态的标记,后面的代码我们用 #ifdef 和 #endif 预处理指令检测宏是否被定义,然后由编译器判断是否编译其中的代码。这么做的好处就是可以减少发布程序的体积,另一方面可以提高发布程序的运行效率。下面是一段示范代码:

#include <stdio.h>
#define _DEBUGNOW
int main(){
    #ifdef _DEBUGNOW
    printf("正在为调试做准备...");
    #endif // _DEBUGNOW
    printf("程序正在运行...");
    return 0;
}

当我们要发布上面的这个程序的时候,我们只要将 #define _DEBUGNOW 注释掉即可,无需进行任何额外的操作。怎么样?是不是很方便呢?善用调试标记可以大大地提高我们的调试效率,但是有一点记住,调试标记名不要过于简单,否则可能和程序中的变量/常量产生冲突。

在我们的调试过程中,我们常常会需要在不触发断点的情况下输出一些数值,这时我们一般会这么做:

printf("%d\n",Value/*要输出的数值*/);

但是像这种代码写多了我们可能会对它产生一种厌恶之情,这是我们的预处理器又可以派上用场了,我们可以定义一个宏解决这个问题,定义宏的代码如下:

#define _PUTINT(NUM) printf("%d\n",NUM)

然后我们只要这样调用我们的宏:

_PUTINT(45/*要输出的数值*/);

示例程序:

#include <stdio.h>
#include <stdlib.h>
#define _PAUSE() system("pause");
#if (defined DEBUG) || (defined _DEBUG) //检测构建模式是否为调试模式
//如果构建模式为调试模式,这里定义几个宏
#define _DEBUGNOW
#define _PUTSIL(NUM) printf("%d\n",NUM) //输出整数
#define _PUTFD(NUM) printf("%f\n",NUM) //输出浮点数
#else
//如果构建模式为发布模式,自动忽略这些宏的存在
#define _PUTSIL(NUM) ((void)0)
#define _PUTFD(NUM) ((void)0)
#endif
int main(){
    #ifdef _DEBUGNOW 
    printf("正在为调试做准备...\n");
    #endif // _DEBUGNOW
    printf("程序正在运行...\n");
    _PUTSIL(12666);
    _PUTFD(3.1415926535898);
    printf("程序运行完毕...\n");
    _PAUSE(); // 暂停程序
    return 0;
}

这样做带来的好处就是,我们可以不用频繁的判断是否处于调试状态,一次定义,一直有效。

为了方便我们的调试(检查)操作以及日后的团队合作,我们在编写函数的时候应该为其加上 Visual Studio 的智能提示,方法比较简单,我们只要在函数定义前面加上提示注释即可(格式自由),Visual Studio 便会自动分析我们的代码并加入其智能提示列表,下面我们举一个定义函数设置智能提示的例子:

//
//  函数:  Convert2Jpeg(wchar_t*, wchar_t, int)
//
//  目的:    转换图片到 Jpeg 格式
//
//  orgiPath : 源文件路径
//  destPath : 目标路径
//  quality  : 图像质量
//
bool Convert2Jpeg(wchar_t* orgiPath, wchar_t* destPath, int quality){
    return true;
}

我们在自动完成或将鼠标移动到编辑器内函数名上,就有了智能提示
在这里插入图片描述


总结

当我们运行我们编写的程序发现运行结果与我们预想的不同的时候,我们可以先用即时窗口,使用一些比较简单的数据来测试我们的各个函数运行结果是否符合我们的预期,如果都符合的话,我们可以使用程序中产生的一些比较复杂的数据来进一步测试我们的各个函数,直至找到可能导致错误的函数。

找到可能导致错误的函数之后,我们就可以使用逐语句调试来一步步跟踪运行程序了,渐渐的的我们就可以缩小范围直至定位错误(无关代码可以考虑暂时注释掉),在这期间,我们要仔细观察程序运行过程中各个数据的变化情况,观察的仔细与否直接与我们能否找到错误直接挂钩。

如果上一步运行的数据一直是正常的,我们就可以排除这个函数的嫌疑了(减少对他的调试次数)。此时,我们就应该考虑问题是否出现在之前的函数上了,可能因为偶然性,我们第一次测试函数的时候并没有发现其错误,导致范围锁定产生偏差,此时我们需要再次耐心的对所有未排除嫌疑的进行调试,直至再次找到出错的函数。再重复上一步,直至找到错误。

可以看到,调试其实是一项比较复杂的活,需要大量的操作,所以在我们编写代码的时候要万分谨慎!因为很多时候,BUG都是因为我们的粗心大意导致的笔误引起的!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值