学了编程却写出错误代码?程序运行结果与想象不符?当bug出现时该何去何从,别担心,这篇文章统统告诉你!手把手带你调试代码,让bug原形毕露!

Posted Stella_sss

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了学了编程却写出错误代码?程序运行结果与想象不符?当bug出现时该何去何从,别担心,这篇文章统统告诉你!手把手带你调试代码,让bug原形毕露!相关的知识,希望对你有一定的参考价值。

之前我们对C语言的知识进行了一一的讲解,但是当我们自己真正写代码的时候,又会发现很多问题,比如程序运行时崩溃、程序运行的结果不是我们想要的等等,虽然代码已经能够跑起来了,但是依然不是理想中的状态。

这时候应该怎么办呢?

别急,现在立即教你使用调试技巧。

手把手带你搞定bug。

什么是bug

大家看看下面这张图:

这张图记录了历史上的第一个bug。

bug原意是臭虫,现在我们的程序漏洞之所以被称为bug。就是因为历史上的第一个bug。

某一天,科学家在使用计算机的时候,发现计算机出现了故障,经过各种检查之后,发现原来是一直飞蛾掉进机器里,导致机器运转不了,当把飞蛾清除了之后,计算机又正常运行了。

从此,计算机领域的漏洞就被诙谐地称为bug了。

找出计算机里出错的因素就被称为找bug。

调试及调试的重要性

我们知道,侦探相信任何事情都是有迹可循的,只要我们顺藤摸瓜,就能够慢慢地摸出事情的真相。

而我们在程序中找bug的时候也是通过一步一步地调试,找出问题的所在的。这和侦探找真相的过程是非常相似的。

所以,可以说每一次调试都是尝试破案的过程。

一名优秀的程序员首先应该是一名找bug好手。

什么是调试

那么调试到底是什么呢?

调试(debugging/debug)其实就是发现和减少计算机程序或电子仪器设备中程序错误的一个过程。

所以调试又可以称为除错。

曾经有人画了这样一幅漫画:

我们可能每天都会碰到各种各样的问题,但是是不是遇到了问题就像上图一样,感觉可能是这里出了问题,就改改这里试试看,又或者改改那里,实际上到底是为什么却不知道呢?

这种调试的方法被形象地称为迷信式调试,我们只是跟着感觉走,但是却不明就里。所以,我们要拒绝迷信式的调试,从先在开始,学会科学的调试方法。

而且,最重要的是,我们要在调试的过程中充分思考,把思维转起来,所以,在往下阅读文章之前,一定一定一定要先动起来给博主点个赞。

调试的基本步骤

那具体调试的时候是怎么调试的呢?

首先我们来看看调试的基本步骤。

1. 发现程序错误的存在

有错误我我们要先发现错误的存在。

通常最先发现程序有错误的人是程序员,如果程序员没有发现程序中的问题,那么在软件交由测试人员测试,所以测试人员是第二类发现错误存在的人员。而第三类发现错误的就是用户了。

当测试人员或者用户反馈软件有问题的时候,我们作为程序员应该及时做出回应,确认并承认错误的存在。

2. 以隔离、消除等方式对错误进行定位

有可能我们写了几千行代码,但是真正发生错误的地方可能就在其中的某几行代码,所以这时候我们就要通过隔离、消除等等方法来找到错误发生的地方

3. 确定错误产生的原因

当我们发现代码存在错误之后,不仅要找出错误代码,更要找到错误产生的原因。

知道错误是为什么产生的,才能更好地去解决它。

4. 提出纠正错误的解决办法

接下来就是提出解决方案,对错误进行修正。

5. 对程序错误予以改正,重新测试

改正时候要进行重新测试,这是非常重要的一步。

不仅程序员要对程序进行重新测试,测试人员也要对其进行重新测试,以确保错误真的被改正了。

Debug和Release

在VS2019中,我们在下图中可以看到有Debug和Release两个版本。

Debug 通常称为调试版本,它包含调试信息,并且不作任何优化,便于程序员调试程序。
Release 称为发布版本,它往往是进行了各种优化,使得程序在代码大小和运行速度上都是最优的,以便用户很好地使用。


当程序运行之后,我们打开项目所在文件夹,会发现里面创建了一个叫Debug的文件夹,打开里面有一个exe文件,打开就会运行我们刚才的程序,打印出数组arr中的每个元素。

当我们回到VS2019,把Debug版本改成Release版本之后,同样地,运行程序,再打开项目所在文件夹。

会发现文件夹中多了一个Release文件夹,打开文件夹,里面也有一个exe文件。

对比Debug文件夹和Release文件夹中的两个exe文件,我们会发现Release文件夹中的exe文件大小明显小于Debug文件夹中的exe文件。

这是因为Release 版本进行了各种优化,使程序在代码大小和运行速度上都是最优的。但也因此,Release版本是不能进行调试的。

在大部分情况下,Release版本中的exe文件是可以直接发给其他人使用的,比如我们在写关机程序的时候,就直接把Release版本的关机程序发给了我们的好朋友。(详见:【一张图搞定关机程序】让你的代码有趣起来!送兄弟送闺蜜,快乐原来如此简单!(赋全过程和结果,超详细解说)

以上是Debug和Release版本的一个简单区别,但是Release版本除了在大小上会进行优化,还会对代码进行优化。

下面我们分别在Debug版本和Release版本中对代码进行调试。

会发现Debug版本可以一步一步地进行调试,在屏幕上依次打印出arr数组的元素。

而在Release版本中则不能清楚地看到每一步代码是如何运行的,它会跳过其中一些步骤,比如跳过了循环,直接在屏幕上打印出了arr数组的10个元素。


所以如果我们要对代码进行调试,找出程序中的问题,就得用Debug版本。

我们知道Debug是调试版本,Release是发布版本,那么这里有一个问题:

测试人员测试的是哪个版本呢?

答案应该是发布版本。因为测试人员应该是站在用户的角度对软件进行测试的,所以应该用发布版本。并且调试版本在测试完之后转为发布版本时也可能因为优化而导致问题,所以测试人员要测试的是发布版本。

windows环境调试介绍

那我们在Windows环境下是怎么调试的呢?

调试环境的准备

首先我们得准备好我们的调试环境。

即把程序改成Debug版本。

使用快捷键

其次,我们要了解相关快捷键的使用。

F5:启动调试。经常和F9配合使用,用来直接调到下一个断点处,如果没有设置断点,则程序执行到底。

F9:创建断点和取消断点。

我们可以通过F9在程序的任意位置设置断点。

这样就可以使得程序在想要的位置随意停止执行,继而用F10一步步调试下去。

在上图中,如果我们希望程序在第五次执行的时候停下:

这时候再按住F5调试代码。

F10:逐过程。通常用来处理一个过程,这个过程可以是一次函数调用(即一次执行一个完整的函数调用过程,不进入函数内部),或者是一条语句。

F11:逐语句。即每次都执行一条语句,这个快捷键可以使我们进入函数内部观察每一步的细节,所以这也是最常用的快捷键。

CTRL + F5:开始执行不调试。即直接彻底地执行程序,即使有断点也不会停下。

以上快捷键是我们日常调试中最常用的,当然,VS中还有很多快捷键,详情请点这儿

调试的时候查看程序当前信息

除了调试让代码一步步地走起来,我们还要关注上下文以及程序当前的信息,那么有哪些方法呢?

当我们的代码调试起来之后,在调试栏目中的窗口中我们可以找到很多窗口,通过这些窗口我们可以看到很多信息。

监视窗口

比如监视窗口:我们可以通过监视窗口观察任何我们想观察的变量。比如arr[4]、i+8、或者&arr等等,只要我们输进去即可。

自动窗口

它其实是一个自动监视窗口,会自动把一些认为我们想要观察的变量的信息展示出来,不需要我们自己输入,为我们提供了极大的便利。

局部变量

内存窗口


通过内存窗口我们可以观察内存中的信息,比如内存中的arr。

我们也可以设置每一行显示几列(几个字节的内存)

调用堆栈

当我们调用的函数很多时,调用堆栈可以很好地帮我们理清代码的调用逻辑。

这样当以后我们遇到一些很复杂的代码的时候,就可以根据这些信息更好地理解代码,对于代码的整个运行逻辑也更加清晰。

除了以上窗口可以帮助我们看到程序的即时信息。

还有以下的窗口可以帮助我们了解更多信息。

反汇编

即通过查看C语言的汇编信息。

或者在调试过程中右击想要查看的内容,找到“传到反汇编”这一项。

寄存器

断点

除了以上窗口之外,还有输出窗口、诊断分析等等工具,帮助我们更好地观察了解程序。

多动手

但是无论工具有多少,我们都一定要多动手尝试,才能有进步。

调用程序员来说,调试是非常重要的。我们可能前期更多的时间是用于写代码上,但是到了后期,写代码已经不是一件难事,这时候我们会话更多的时间在找bug上,这时候会调试就显得至关重要了。

当前我们涉及的代码都比较简单,调试起来比较容易。但是未来我们可能会遇到很复杂的调试场景,这是熟练掌握调试技巧,就能给我们提供很大的帮助,同时学会使用快捷键,也能帮助我们提高效率。

调试实例

下面我们来看一些调试的实例

实例一


上面的程序是在运行时结果出现了问题,所以我们称为运行时错误。

那么接下来我们就按F10进入调试。

打开监视窗口,输入我们要监视的值:n、ret、sum、i

重新调试确认问题:

确定了问题所在:ret在每次求完阶乘之后没有重置,所以我们应该把ret进行重置。

有一下两种解决方案:

或者直接把ret定义在循环内:

从以上的例子中,我们可以看到,当程序出了问题的时候,我们要对代码进行调试,定位出问题出现的大致位置。

然后推测问题出现的原因,初步确定问题可能的原因,然后再着手调试代码确定问题所在。

我们应该对自己写的代码做到心里有数,只有这样才能在调试的时候确定代码是不是按照我们的思路走的,然后才能找出问题的原因。

实例二

接下来我们再来看一个例子。

我们观察这段代码,问题很明显,数组越界了。

那我们将程序运行起来之后,会出现什么问题呢?

我们来看一看。

我们知道这段代码是有问题的,数组只是越界了,为什么程序会造成死循环呢?

我们可以通过调试的方法找一下原因。

程序继续调试,直到i=10。

我们也可以直接设置断点,然后调试,让程序在断点处停下。

我们继续调试:

而我们之前在操作符讲解中就提过,arr是数组首元素地址,arr[9]相当于arr+9,即根据arr首元素的地址向后跳过9个元素。那么现在arr[10]也就相当于arr+10,即向后跳过10个元素。

我们打开内存窗口,继续调试。

继续调试。

接下来我们调试看看是否是这样的。

以上正好说明了arr[12]中放的就是i的值,那么当将arr[i]即arr[12]赋为0的时候,i也会变为0。程序由此进入死循环。

由此,我们找到了上面这段代码出现死循环的原因。

那么我们从更深层次来解释一下代码出现死循环的原因。

因为程序中的变量i和数组arr是局部变量,而局部变量是在栈区分配中间的。

在栈区中,空间的分配习惯是先使用高地址的空间,再使用低地址的空间。

所以根据程序的执行顺序,在栈区中地址相对高的位置先放置了i,然后在相对地的地址处放置了arr。

当arr[12]相当于arr+12,即向高地址处跳过了12个int的长度,此时就正好找到了放置i的空间,当执行arr[12]=0时,arr[12]这块空间中的i就被更改为了0。

所以从本质上来说,是因为局部变量在栈区中分配空间是从高地址到低地址,并且随着下标的增长,数组元素的地址是由低到高增长的。

而在VE2019的编译器中,变量i和数组arr之间的间隔刚好是两个int的长度,所以程序执行到这里就把控制循环的i的值更改,导致死循环。

但是注意,并不是所有的编译器中这个“间隔”都是2(VS2013~VS2019),在有的编译器中,这个“间隔”是1(gcc),而在有的编译器中则arr和i则是紧挨着的(VC6.0)。

而如果我们将循环中的判断条件改为i<=10,再运行程序,会发现程序并不能打印出11个hehe,而是报错了。

按理说当i<=12的时候程序更应该报错了,

但是为什么这时候却不会报错呢?

原因是此时程序正忙着执行死循环,还没有空闲下来给我们报错……

如果我们把程序换成Release版本:

可以看到Release版本确实对程序进行了优化。

这段代码出自《C陷阱和缺陷》,感兴趣的同学可以自己去看看噢~

编程常见错误

编译型错误

编译型错误通常是语法错误。

发生编译型错误,程序编译不过去。

这时候双击下面的问题,编译器就会帮你定位发生错误的位置,找到之后更改即可。

链接型错误

其实我们的程序在运行的时候,是从一个.c文件进行编译、链接,生成一个.exe的运行文件的。

所以如果产生了编译型错误和链接型错误,程序就无法生成.exe.文件。

而当程序调用函数的时候,是在链接器里面找这个函数,所以当程序找不到该函数的时候,就会报连接错误。

通常链接型错误产生的原因都是符号未定义,或者符号写错了。所以当遇到链接型错误我们应该先找符号名。

运行时错误

当程序执行起来后,却发现执行的结果我们想要的不一样,这时候我们称之为运行时错误。

这种错误相对前面两种比较难搞,我们需要借助调试,逐步定位,找出问题的原因。

如何写出好的代码

看了以上那么多有问题的代码之后,我们应该如何写出一段好的代码呢?

好的代码应具备的特性

说明我们来看看什么样的代码可以称得上是好的代码呢?

  1. 至少保证代码运行正常
  2. bug尽量少(我们难以保证一段代码没有bug,但是应该尽量向没有靠近)
  3. 可读性高,即当我们的代码交给其他人的时候,其他人能够很容易地读懂我们代码的意思
  4. 可维护性高,即当我们的代码交给其他人维护的时候,他们能够很容易地上手,而不是无从下手。
  5. 注释清晰,在必要的地方加上注释方便以后自己或者他人理解代码
  6. 文档齐全。

常见的代码技巧

那么我们应该如何写出上面所说的好的代码呢?

有没有什么方法呢?

  1. 使用assert
  2. 尽量使用const
  3. 养成良好的编码风格(即让代码看起来更美观,读起来更易懂)
  4. 添加必要的注释
  5. 避免编码的陷阱

下面我们用模拟实现strcpy这个函数作为例子来感受一下。

首先我们在cplucplu.com上面看看strcpy这个函数。

注:null和Null在习惯上认为是’\\0’,而NULL认为是空指针。

然后着手写出我们自己的strcpy函数。

但是上面这段代码虽然完成了字符串的拷贝,但是还不够完美。

比如,我们可以这样优化:


我们暂且称上面的代码为第二个版本。

但是能不能再简化呢?

看第三个版本~

因为’\\0’的ASCII码值是0,所以我们可以这样优化代码:

1. 使用assert

看到这里,你是否觉得上面的代码已经写得很好了呢?

其实不然。

上面的代码虽然看起来精简,但是它有一个很危险的地方,那就是万一哪个兄弟不小心把一个空指针传给了这个函数呢?

这时候代码就无法正常运行了。

那这时候我们应该在使用指针之前,对指针的有效性进行检验。

但是如果我们每次使用指针都要这样写一个检验代码是不是有点麻烦呢?

这时候我们就要引入一个宏 - assert

所以我们发现,assert其实是对程序员非常友好的一个东西,因为它能够帮我我们检验指针的有效性,不仅能够发现问题,而且还能够报告问题,为我们找问题节省了很多麻烦。

我们还可以这样写:

但是分开写的话代码会更容易理解。

2. 尽量使用const

上面这段代码现在看起来好像已经很好了,但是如果有人非常不小心地在函数中把要拷贝和要被拷贝的字符串地址写反了怎么办呢?

我们重新看cplusplus.com里面strcpy的参数:会发现在source类型的前面会有一个const。

我们知道,const修饰一个变量,这个变量就具有了常属性,它就不能被修改了。

那么这里的source指针被const修饰之后,我们就不能够通过传过来的地址对它,进行修改了,实际上对于刚才所提的错误起到了一层保护作用。

但是我们要注意以下的区别:


我们可以用一个例子来生动地说明上面的代码:
假设我们有男孩m和男孩n,男孩m兜里有10块钱,而男孩n兜里有100块钱。

这时候有一个女孩p是男孩m的女朋友,记为:int* p=&m。

秋天来了,女孩想喝秋天的第一杯奶茶,但是男孩m兜里只有10块钱,如果男孩兜里的钱被女孩拿去买秋天的第一杯奶茶的话,那么男孩m的兜里就没有钱了,记为:*p=0。

于是男孩m就决定不给女孩买奶茶,不让女孩拿走男孩m兜里的钱,我们就用const来表示,记为:const *p=&m。

于是女孩就不愿意了,对m说,说:“连秋天的第一杯奶茶都不愿意给我买,我要和你分手!”然后女孩p就甩了男孩m,和男孩n在一起了。记为:int* p=&n。

男孩m知道了之后,赶紧把女孩哄好,说:“你别跟我分手,我给你买秋天的第一杯奶茶就是啦!”

男孩m要女孩答应,不许跟他分手,记为: int* const p = &m。

女孩答应之后,男孩向女孩说明了情况:“咱们现在情况不好,就先缓一缓,不要和秋天的第一杯奶茶了好吗?”

女孩最后还是答应了,所以做最后女孩既喝不到秋天的第一杯奶茶,又换不了男朋友。记为:const int*const p =&m。

所以语法上是支持在*的左右两侧都加上const的。

所以我们现在再回头把模拟字符串拷贝的函数进行优化。

在char* src前面加上const,这样如果不小心把字符串的拷贝写反了,编译时程序就会给我们报错,把错误扼杀在摇篮中。

这样我们的代码才算是完美了,因为如果程序我们称这样的一个代码为一个健壮性和鲁棒性都比较高的代码。

当我们再对比我们写的函数和strcpy这个函数,会发现它返回了char* 。

这个返回值是被拷贝的字符串的地址。

这样做的为了我们可以通过返回的地址来观察字符串被拷贝的结果。

所以我们也把最后把我们的代码完善一下吧~

当我们这个代码完成了之后,我们在使用这个函数的时候仍然要注意一些问题:

比如:

  1. 如果目标字符串比被拷贝的字符串长,那么拷贝后打印出来的字符串也不会有原来字符串的内容。

因为在拷贝的时候把‘\\0’也拷贝过去了,打印字符串的时候只会打印到‘\\0’之前的内容。

  1. 目标字符串的空间应该足够大

  1. 目标空间应该可以修改

  1. 源字符串必须有‘\\0’。


今天的文章就到这里啦~

以上所讲的调试方法和代码技巧,你学废了吗??

希望下次我们遇到代码问题的时候,不再是凭着感觉该代码。而是先思考问题的所在,然后运用调试的技巧,找出代码的bug,成为一名能写出好代码,又会找bug的程序员!

最后最后的最后,听说点赞的小伙伴都是又帅又牛的程序员噢!~

关注我,一起精进C语言吧!~

本文代码已上传至Gitee,各位按需自取噢~

https://gitee.com/fang-qiuhui/my-code/blob/my_code/blog_2021_8_26_debug/blog_2021_8_26_debug.c

以上是关于学了编程却写出错误代码?程序运行结果与想象不符?当bug出现时该何去何从,别担心,这篇文章统统告诉你!手把手带你调试代码,让bug原形毕露!的主要内容,如果未能解决你的问题,请参考以下文章

python初体验-常见错误类型

为啥打印结果与实物不符

Hibernate查询结果与实际结果不符

重定位

c语言辗转相除法 将十进制转换成任意进制的数 运行结果出错,与正确不符,求指导哪里出错

cppcheck 错误与我看到的不符