前言
调试的这一站,对于市面上大部分的书籍都是缺失的,然而调试这个内容是非常重要的,尤其是在数据结构部分,将会频繁的使用,这也为我们后面讲解数据结构做一个铺垫。同时,在以后未来工作的时候,调试也是经常使用的一个小技巧,因为未来的项目代码量都是极大的,我们稍有不慎就会出现错误,即便是我们之前所说的三子棋和扫雷这两个小游戏,也仍然有很多人会出现各种各样奇奇怪怪的bug。这就要求我们实现一部分测一部分功能
目录
前言
一、什么是bug
二、调试是什么?有多重要?
1.调试是什么?
2.调试的基本步骤
3.Debug和Relelase的介绍
三、Windows环境调试介绍
1.调试环境的准备
2.学会使用快捷键
(1)F5和F9
(2)F10和F11
(3)Ctrl+F5
3.调试时查看程序当前信息
(1)查看临时变量的值
①自动窗口
②局部变量
③监视
④如何监视一个数组?
(2)查看内存信息
(3)查看汇编信息
(4)查看寄存器
(5)调用堆栈
(6)查看断点
总结
一、什么是bug
我们很多人在写代码的时候经常听到这样的话术,又出bug了。那么什么是bug呢?其实bug这个词来源于第一次将一次被发现的导致计算机错误的飞蛾,也是第一个计算机程序错误。从此我们就将bug称作计算机程序出现了问题。
二、调试是什么?有多重要?
所有发生的事情都一定有迹可循,如果问心无愧,就不需要掩盖也就没有迹象了,如果问心有愧, 就必然需要掩盖,那就一定会有迹象,迹象越多就越容易顺藤而上,这就是推理的途径。 顺着这条途径顺流而下就是犯罪,逆流而上,就是真相。
一名优秀的程序员是一名出色的侦探。
每一次调试都是尝试破案的过程
1.调试是什么?
调试(英语:Debugging / Debug),又称除错,是发现和减少计算机程序或电子仪器设备中程序 错误的一个过程。
2.调试的基本步骤
①发现程序错误的存在
②以隔离、消除等方式对错误进行定位
③确定错误产生的原因
④提出纠正错误的解决办法
⑤对程序错误予以改正,重新测试
3.Debug和Relelase的介绍
大家可能在visual studio上见到过这个东西,这里有一个Debug和一个Release,如下图所示
那么他们两个到底是什么呢?这里给出他们的概念
Debug通常称为调试版本,他包含调试信息,并且不做任何优化,便于程序员调试程序
Release通常称为发布版本,他往往是进行了各种优化,使得程序在代码大小和运行速度上都是最优的,以便用户很好的使用
那么要他们两个有什么用处呢?
首先我们得知道,Debug版本是可以进行调试的,Release版本是不可以进行调试的,这一点可以自行去验证
除此以外,他们还有什么区别呢?我们可以写一个简单的代码
#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
int main()
{
char* p = "hello world";
printf("%s", p);
return 0;
}
我们先打开我们的文件,找到我们这个解决方案,如下图所示
然后我们在Debug版本下进行运行
然后我们回到我们之间的文件,我们会发现多了一个x64文件
这个是因为我们目前的环境是64位环境,所以生成的是x64文件,如果是x86环境,就是x86文件,然后我们需要做的就是点开这个x64文件
我们会发现有一个Debug文件这就是我们的调试版本的可执行程序
我们也可以点进去进行观察,这个就是调试版本的可执行程序,这里要注意一点,他的大小是61KB
我们现在回到我们的visual studio 2022,我们将Debug改为Release环境
我们在Release这个环境下进行运行
然后回到我们的文件中
我们会发现,多了一个Release这个文件,这个就是我们的发布版本的可执行程序了
我们也可以打开这个文件,我们会注意到这个大小是11KB
我们会发现这两个版本的可执行程序大小其实是不一样的,这其实也就是验证了我们之前所说的
Debug通常称为调试版本,他包含调试信息,并且不做任何优化,便于程序员调试程序
Release通常称为发布版本,他往往是进行了各种优化,使得程序在代码大小和运行速度上都是最优的,以便用户很好的使用
Debug文件包含了各种的调试信息,所以导致他的内存占用比较大,而Release版本是进行了各种优化的,他没有调试信息,所以不能进行调试,所以大小会更小。
三、Windows环境调试介绍
在这里我们首先要纠正一个概念,很多人经常说自己是用vs2022编译器的,但是其实vs2022严格来说不能叫做一个编译器,他应该称作一个IDE——集成开发环境,集成开发环境我们在一开始是提到过的,在这里我们在提及一下,集成开发环境包含了三个部分,编辑器,编译器和调试器的。他是将这三个都放在一块了,所以vs的功能是很强大的。我们这里采用的是vs2022这个IDE,其他vs系列的IDE也都是大同小异的。
vs是Windows环境下的,在linux环境下也是有他的编译器和调试器的gcc是Linux环境下c/c++的编译器,gdb是Linux环境下c/c++的调试器
1.调试环境的准备
我们刚刚介绍了Debug和Release版本的区别,我们想要使用调试就必须得改为Debug版本,否则无法调试
2.学会使用快捷键
我们点击vs上面的调试,我们会发现有很多的快捷键,这些快捷键如果能够熟练使用,可以使我们的效率大幅度提高
我们这里列举常用的几个调试快捷键
F5
启动调试,经常用来直接跳到下一个断点处
F9
创建断点和取消断点
断点的重要作用:可以在程序的任意位置设置断点,这样就可以使程序在想要的位置随意停止执行,继而一步步进行下去
F10
逐过程,通常用来处理一个过程,一个过程可以是一次函数调用,或者是一条语句
F11
逐语句,就是每一次都来执行一条语句,但是这个快捷键可以使我们的执行逻辑进入函数内部,这也是我们最常使用的快捷键
Ctrl+F5
开始执行不调试,如果你想让程序直接运行起来而不调试就可以直接使用
我们这里详细讲解这些最常使用的调试快捷键
(1)F5和F9
F5
启动调试,经常用来直接跳到下一个断点处
F9
创建断点和取消断点
断点的重要作用:可以在程序的任意位置设置断点,这样就可以使程序在想要的位置随意停止执行,继而一步步进行下去
这两个快捷键一般来说都是搭配使用的,F5是不会单独使用的
比如说我们看这样一段代码
#include<stdio.h>
int main()
{
int n = 0;
int a = 0;
scanf("%d %d", &a, &n);
int sum = 0;
int i = 0;
int k = 0;
for (i = 0; i < n; i++)
{
k = k * 10 + a;
sum += k;
}
printf("%d", sum);
return 0;
}
我们对任意一个位置进行F9,打一个断点,如下图所示,这个位置就出现一个红点,这个红点就是断点,当然我们也可以再次F9,这样就取消断点了
而此时如果我们一旦使用F5,那么程序就会走到这个断点处,但是这条语句并没有执行,如下图所示,下图中没有跑到断点处的原因是前面有一个输入语句,我们得先进行输入,如果没有输入,则直接跳转到断点处
对其输入后,直接跳转至断点处
当然我们还可以继续设置一个断点
然后我们继续F5,就又会跑到这个断点处
那么我们往下面继续打一个断点,比如说打到printf处
我们继续使用F5,我们会发现,这个并没有跑到下一个断点处,这是为什么呢?其实这是因为,这个断点是在循环里面的,所以其实这个过程中,这个断点会陷入循环。直到循环打破
我们可以在监视窗口里面查看一下
我们随便打开一个监视,比如说我们打开监视1,在这里我们可以输入一个变量,然后查看他的值是多少
我们输入i
我们现在的i是1,但是我们使用一下F5,我们就会发现i变成了2,这足以说明,经历了一次循环
然后我们监视一下n,并使用F5,直到他离开循环,他就能进入到下一个断点处了
由此我们就了解了F9和F5的作用,虽然说,目前来看,断点的作用似乎并不是很强大,但是格局要放大,未来几百行几千行甚至几万行的代码,如果没有断点的话,是极其麻烦的,因此断点是非常强大并且有用的一个功能
(2)F10和F11
首先就是这两个快捷键的介绍了
F10
逐过程,通常用来处理一个过程,一个过程可以是一次函数调用,或者是一条语句
F11
逐语句,就是每一次都来执行一条语句,但是这个快捷键可以使我们的执行逻辑进入函数内部,这也是我们最常使用的快捷键
当然,只是看文字描述,肯定是看不太懂的,我们实践一下
我们使用这一段代码来演示
#include<stdio.h>
int Add(int x, int y)
{
return x + y;
}
int main()
{
int a = 0;
int b = 0;
scanf("%d %d", &a, &b);
int ret = Add(a, b);
printf("%d", ret);
return 0;
}
我们使用F10进入调试以后,继续按F10,我们会发现,他会一步一步执行,但是遇到函数的时候,他不会进入这些函数内部的细节,他会直接将函数执行完毕。
继续往下按F10,他直接就进行printf语句了。
因此F10是逐过程的快捷键
但是F11就不一样,他可以观察到函数内部的变化,我们这次使用F11进行调试,先按F10进入调试,然后F11逐步调试
这是没有进入之前的
继续按F11
此时他就进入到函数内部了,然后继续F11,就可以一直走完这个函数,并且最终跳出这个函数,回到原来的位置中
这就是F10和F11的区别了,其实我们也能看出来F11其实在实际中应该是更常用的
在这里我们又考虑到,如果是跨文件的代码呢?F11还会进入到这个函数内部吗?我们可以实践一下,如下所示,创建了三个文件
我们直接使用F11,我们发现是可以进入分文件的函数内部的
那么除了F11这种方式,还有其他的方式吗?其实是有的,我们可以打一个断点,然后使用F5,也可以进入函数内部
这时候我们又能联想到,如果打完断点以后在使用F10,他会进入到函数内部吗?我们尝试一下
经过实践后,我们发现是可以的。
(3)Ctrl+F5
Ctrl+F5
开始执行不调试,如果你想让程序直接运行起来而不调试就可以直接使用
他是只执行,不进入调试,也就是说,即便打了断点,他也不会停止
3.调试时查看程序当前信息
当我们调试的时候,我们肯定是需要看一些信息的,那么这些信息该如何查看呢
我们需要先进入调试,按F10,然后在屏幕上方的调试窗口处,可以发现很多的东西,这些东西就是我们需要查看的信息了
在这些窗口中,我们最常用的只有几个
接下来我们来详细介绍每一个功能的用处
(1)查看临时变量的值
我们调试程序
当我们想查看a,b这些局部变量的值的时候,我们在窗口会看到这几个东西,这三个都是可以查看这些局部变量的,但是他们有什么区别呢?
①自动窗口
我们先使用自动窗口,他一开始是什么都没有,也不允许让自己进行输入,那我们只能往下走
当我们按下F11的时候,他自动出现了a的信息
我们继续往下走,直到我们走到这个函数调用前,我们会发现,即便我们没有输入,我所需要的值都在上面自动出现了
但是当我继续往下走,进入函数以后,之前的信息都消失了,只剩下函数内部的信息了
当离开函数以后,他们又回来了,但是函数内部的信息又消失不见了
可见,这个自动窗口的功能就是,不需要输入变量,他自己为你输入好变量,这些变量也无法删除,这个自动窗口是一直动态变化的。这些变量可以查看,但是缺点也很明显,就是无法自己输入,而且调用一个函数的时候,就无法查看其他变量的变化了。所以这个其实不好用
②局部变量
既然自动窗口不好用,那么局部变量的那个好用吗?我们试一下
局部变量他是自动加载上来程序在运行过程中这些上下文中的局部变量
比如说我们尝试一下,我们直接F10进入调试,然后选中局部变量,我们会发现,这些变量直接就加载好了。跟自动窗口还是有一点不同的。
但是当他进入到函数内部以后,就跟自动窗口类似了,也会消除掉之前的变量,只剩下这个函数内部的变量了
我们会发现,自动窗口和局部变量其实都不好用,因为我们不能随心所欲的查看自己想要查看的值,所以我们使用最频繁的其实是监视,因为他可以随心所欲的查看我们想要查看的值
③监视
对于下图中这四个监视窗口而言,我们只需要任意打开其中一个即可
在这里我们可以随心所欲的添加和删除自己想要查看的变量
甚至也可以在监视窗口使用表达式
当我们进入函数中时,他们会变暗,并不会看不到这些变量的变化,但是并不会自动出现我们想要的值
虽然不会自动出现,但是我们可以自己随心所欲的输入我们想要查看的变量,使用起来十分方便
返回以后
④如何监视一个数组?
我们看下面的这个代码
我们定义一个数组,这个数组有12个元素,然后我们将他传入一个函数中,我们进行调试
当这个数组还在主函数内部的时候,他还好好着,可以看到12个元素
但是一旦传入一个函数中的时候,我们发现这个数组只能看见一个元素了,这是为什么呢?
其实这是因为,数组传参传的是首元素地址,所以此处的arr已经是一个指针了,而我们之前说过局部优先,所以这个局部变量会将原来数组的元素给覆盖掉
那么既然已经出现了这种情况,那么该如何解决呢?其实只需要在arr后面添加逗号12即可,这样我们就能看到后面的元素了
除此以外,我们也可以在后面随心所欲的添加10,15等数字,这些数字代表的是能看到后面几个元素
(2)查看内存信息
我们有时候也会需要去查看一些内存中的值
我们随机选中一个即可,为了方便观察,我们将环境修改为x86,也就是32位的环境下,因为32位环境下地址只有4个字节,易于观察,而64位环境下有8个字节,长度过长,不好观察
我们可以直接在上面输入数组名,因为数组名就是首元素地址
在这里我们也可以观察到数组里面的数据
对于内存这个窗口我们需要知道的是,这个位置可以修改一列有几个字节,这个由自己随心所欲的进行设置,我们设置成4个字节是因为他是一个整型数组,易于观察。
其次就是这个内存窗口一共有三列信息,从左到右依次代表着地址,地址中的数据,数据翻译成文本后的样子,而且他也只是一个参考信息。第三列其实没有多大的参考价值,我们一般只观察前两列
我们在看一个指针变量的例子
如下所示,我们使用&p,左边的就是p的地址,右边的就是他内部的值,他里面的值也是一个地址
(3)查看汇编信息
我们用这个代码来举例
#include<stdio.h>
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
int i = 0;
for (i = 0; i < 10; i++)
{
arr[i] = 0;
}
return 0;
}
我们直接进行调试F10,然后右击鼠标,会看到这个东西
这样显示出来的就是这段代码翻译成汇编代码的样子
当然也可以从窗口那块找到反汇编
(4)查看寄存器
我们可以直接在调试窗口这块找到寄存器
调用之后如下图所示
当然在这块可能有人不太理解寄存器和反汇编的这些窗口,这些东西我们将在后面的函数栈帧的创建与销毁中进行涉及
当然除此以外,也可以直接在监视窗口中观察寄存器的值,当然监视窗口默认是十进制的,我们直接右击鼠标就可以改为下图中的16进制
(5)调用堆栈
调用堆栈这个东西在当前来看其实用处不大,但当代码量很大的时候就会体现出他的作用了。
我们看这样一段代码
#include<stdio.h>
void test2()
{
printf("hehe");
}
void test1()
{
test2();
}
void test()
{
test1();
}
int main()
{
test();
return 0;
}
我们进入调试,选中调用堆栈
我们显示是这样的
我们先右击,点击显示外部代码
就会变成这个样子
这些是什么意思呢?其实这是我们vs在调用main函数的时候还做了一些准备,也就是先调用了mainCRTStartup(void * __formal)这个函数,然后这个函数调用了__scrt_common_main() 这个函数,接着这个函数又调用了 __scrt_common_main_seh()这个函数,然后这个函数又调用了invoke_main() 这个函数,最后这个函数才调用了main函数
所以,其实main函数也是被其他函数调用的
而我们刚刚选中的显示外部代码,其实就是显示main函数之前的调用关系,如果不选中的话就不会显示之前的调用关系了
而我们直接F11让代码调试起来,就会发现调用堆栈多了一共test函数
继续往下走,又有了test1函数
继续调用下一层函数
对于这个调用堆栈的这个图,我们也可以来仔细研究一下,这个堆栈其实是按照栈的方式来实现的,在数据结构中,有一种数据结构是栈和队列。栈的特点就是先进后出,他有一共栈顶和栈底,就像一个圆柱体,但是只有一个口,最先放进去的肯定就是最后出来的,最后放进来的东西就是最先出来的。而队列就是好比一个圆柱体,他上底和下底都是开口的,但是一个口只能进入,一个口只能出去,就像排队一样,先进先出,后进后出
而我们就发现这个堆栈就和栈的方式很相近
比如说我们继续调试下去,往下走,就会发现test2函数的堆栈被销毁了
接着往下走,就会发现test1,test都会销毁
当程序结束时候,main函数的堆栈也就被销毁了。
所以我们就发现,函数的调用其实就是用栈的方式模拟出来的调用逻辑,当未来在面对一个很复杂的代码时候,我们就可以使用调用堆栈来查看每个函数的调用关系
(6)查看断点
其实除了上面所说的常用的 窗口,还有一些其他的窗口也会使用,比如查看断点,我们先在程序上随便打几个断点,然后点击这个
就可以查到打了哪些断点了
包括不同文件的也可以看出他们的断点
当不想用这个断点的时候,也可以直接点击这个对号,就能擦掉这个断点了,在未来想要在用的时候可以再次勾选上
总结
本小节讲解了什么是bug,如何调试,调试的步骤,debug和release的区别,window环境下的调试功能介绍,如何进入调试环境,如何使用常用的快捷键,常用的调试快捷键功能是什么,查看一些调试的变量信息,如查看局部变量,自动窗口,局部变量,监视的区别,如何查看内存,如何调用堆栈,如何查看反汇编,如何查看寄存器等相关内容
切记只有多多动手,尝试调试,才能有进步
一定要熟练掌握调试技巧。初学者可能80%的时间在写代码,20%的时间在调试。但是一个程序员可能20%的时间在写程序,但是80%的时间在调试。 我们所讲的都是一些简单的调试。 以后可能会出现很复杂调试场景:多线程程序的调试等。 多多使用快捷键,提升效率。