一、 Bug介绍
在正式开始讲解调试技巧之前先介绍一下我们亦敌亦友的Bug。
程序错误,即英文的Bug,也称为缺陷、臭虫,是指在软件运行中因为程序本身有错误而造成的功能不正常、死机、数据丢失、非正常中断等现象。 早期的计算机由于体积非常庞大,有些小虫子可能会钻入机器内部,造成计算机工作失灵。史上的第一只 “Bug” ,真的是因为一只飞蛾意外走入一电脑而引致故障,因此Bug从原意为臭虫引申为程序错误。——百度百科
说其是敌,是因为Bug的出现总让人苦恼于如何修复Bug;说其是友,是因为它让我们意识到自己尚存不足之处,需要进一步的学习;它还帮助我们积累了经验,而谨防它下一次出现。
那么接下来我们将开始分享和介绍与它打交道的方法——调试
二、 调试
“所有发生的事情都一定有迹可循,如果问心无愧,就不需要掩盖也就没有迹象了,如果问心有愧,
就必然需要掩盖,那就一定会有迹象,迹象越多就越容易顺藤而上,这就是推理的途径。
顺着这条途径顺流而下就是犯罪,逆流而上,就是真相。”
一名优秀的程序员是一名出色的侦探。
每一次调试都是尝试破案的过程
这里先来页漫画自我反省一下,因为在学调试之前,我一直都是如下漫画的迷信式调试:
学会调试之后,一定要拒绝这种迷信式调试!
1. 调试的概念
调试(英语:Debugging / Debug),又称除错,是发现和减少计算机程序或电子仪器设备中程序错误的一个过程。
2. 调试的基本过程
(1)发现程序错误的存在
(2)以隔离、消除等方式对错误进行定位
(3)确定错误产生的原因
(4)提出纠正错误的解决办法
(5)对程序错误予以改正,重新测试
(6)“循环”执行上述过程,直至程序运行结果符合预期
3. 调试环境介绍
这里以Windows环境与VS 2022 社区版为基础来介绍调试的具体方法和用途
(PS:以后学习Linux系统的开发后会再介绍Linux环境下的调试)
(1)调试环境的准备
在环境中选择Debug选项才能使代码正常调试
(Releas和Debug的区别后文有说明)
(2)常用快捷键
- F5:启动调试,常用来直接跳到下一个断点处
- F9:创建断点和取消断点。可以在程序的任意位置设置断点,这样就可以使得程序在想要的位置随意停止执行,继而一步步执行下去。右击断点还能执行更多操作。
F9常与F5配合使用,用于应对多行代码及跨文件代码的调试。 - F10:逐过程,通常用于处理一个过程,可以是一次函数调用或者是一条语句。
- F11:逐语句,即每次都执行一条语句,但与F10不同的是这个快捷键可以使我们的执行逻辑进入到函数的内部而观察函数的具体执行过程。(这是最常用的!!!)
- Ctrl + F5:程序直接开始运行而不进行调试。
下面给附上一篇介绍快捷键大全的博客的链接:
更多快捷键
(3)调试时查看程序程序当前信息
- 查看临时变量的值
在调试开始后可以在调试一栏找到监视窗口并打开(调试开始后一般自动打开),从监视窗口中,我们能观察变量的当前值及随程序运行的变化情况。
如下段代码:
int main()
{
int i = 0;
int arr[3] = { 1,2,3 };
for (i = 0; i < 3; i++)
{
printf("%d", arr[i]);
}
return 0;
}
当程序执行完前两句语句时,我们通过监视窗口就能观察到变量i
和数组arr
值的情况。
其中自动窗口展现的是编译器自动判断当前语句执行可能会影响到的变量的值的情况:
局部变量窗口展现的是当前函数内所有局部变量的值的情况:
监视窗口展示的是我们自己想观察的变量的值的情况:
在监视窗口中,我们想查看哪个变量的值就可以输入对应的变量名,除此之外,还可以输入表达式,观察整个表达式的结果等,是最常用的窗口!!!
- 查看内存信息
在开始调试后,同样在窗口一栏,找到内存并打开,通过内存窗口我们就可以观察到变量的内存信息。
打开内存窗口后,我们就能查看变量的地址,及地址中对应的内容:
中间一栏是以十六进制展现的内容,可以在右上角改变其显示的列数,也就是显示的字节数,当前显示的是4个字节的内容(图中显示8个十六进制数,1个十六进制位为4个比特位,1个字节 = 8比特)
在搜索栏中输入地址就能观察地址所对应的内存情况,还是上面那段代码,当语句执行完前两句时,我们输入&i
就能看到局部变量i的地址及对应内容:
(PS:在输入完按下回车后搜索栏会自动变为搜索内容的地址)
可以看到,i
初始化为0,同样我们可以输入数组名(代表数组首元素的地址),查看整个数组:
可以看到,数组的三个元素分别被初始化为1,2,3
通过内存窗口,我们还可以观察每个字节的变化情况,具体可见【逐步剖C】-第五章-指针初阶中对指针类型意义的介绍部分。 - 查看调用堆栈
同样在开始调试之后窗口一栏中找到调用堆栈打开,通过调用堆栈,可以清晰地反映函数的调用关系及当前调用所处的位置。
如下面这段代码:
void test2()
{
printf("hehe\n");
}
void test1()
{
test2();
}
void test()
{
test1();
}
int main()
{
test();
return 0;
}
按分析来看,在main函数中调用了test函数,在test函数中又调用了test1函数,在test1函数中又调用了test2函数。这个逻辑关系在调用堆栈的窗口中展现地很清楚:
当我们通过F11来到test2函数内部时,调用堆栈情况如下:
函数的调用其实就是我们数据结构中所对应的栈的结构。
接下来介绍的两个通常配套使用,来观察系统中函数栈帧的创建和销毁情况*(PS:函数栈帧的创建和销毁,增加我们“内功”必不可少的知识,后续补上链接)*
-
查看汇编信息
有两种查看方法:
开始调试后,在窗口一栏找到反汇编
或者通过鼠标右键点击转到反汇编
-
查看寄存器信息
这里也有两种方式
同样在开始调试后,可以在窗口一栏中找到打开
或者直接在监视窗口输入寄存器的名字,如ebp,eax等
关于调试环境的准备就介绍的这,接下来我们来看一个经典实例
三、 实战
1. 分析代码结果
看如下一段代码:
#include <stdio.h>
int main()
{
int i = 0;
int arr[10] = { 0 };
for (i = 0; i <= 12; i++)
{
arr[i] = 0;
printf("hehe\n");
}
return 0;
}
相信大家一眼就看出来了数组越界的问题,但实际运行起来程序会怎么样呢?
答案是:死循环打印 “hehe”。接下来我们就通过调试的方法来探究一下死循环原因。
(1)调试过程:
开始调试,当i的值为9时,屏幕上打了9个hehe:
目前一切正常,再进行下一次循环,就要产生越界访问了
- 当
i = 10
时,通过监视窗口我们可以看到,arr[10]
是一个随机值:
(PS:越界访问内存中的值都是随机值)
执行循环体内容后,这个随机值被改为0:
- 当
i = 11
时,arr[11]
同样也是个随机值:
执行循环体内容后,随机值被改为0:
- 当
i = 12
时,神奇的一幕发生了:
我们惊人地发现arr[12]
的值竟然和i的值一样!
接着我们再用“ & ”
取地址符来看看它们的地址:
发现它们的地址也是一样的!那么死循环的原因我们就找到了:
局部变量i
的地址与数组往后越界到下标为12时(arr[12]
) 的地址相同,当i = 12
时,执行语句arr[i] = 0;
也就相当于把i
的值也置为了0,改变了控制循环的变量的值,让循环重新开始。
看到这我们不禁想问,变量i
的地址怎么就恰好等于arr[12]
的地址呢,接下来我们通过画图来讲述一下这个重要的知识点
(2)重要知识点:
局部变量是存放在栈区中的,而栈区的使用习惯是:
先使用低地址,再使用高地址。
那么如上问题代码,就可以用一张图来表示,如图:
问:那把语句int i = 0;
放在语句int arr[10] = {0};
之后是不是就不会死循环了呢?
答:按照上面栈区的使用习惯确实是这样的,此时当程序运行起来就会报一个数组越界访问的错。
问:对于每个编译器数组可访问的最后一个元素(arr[9]
)的地址和变量i
的地址都是刚好差两个整型的大小吗?
答:不是的,这只是一种在VS2022编译器环境下测得的一种结果。更多的测试结果大家可以看看一本书叫做《C陷阱和缺陷》,作者对这个问题做了较多的测试。
四、Debug与Releas版本区别
上面我们所说的所有关于调试的内容都是在Debug版本下进行的,这一点在调试的环境准备中也提到了。那么接下来我们简单介绍一下Debug版本与Release版本有什么区别。
1. 概念
Debug 通常称为调试版本,它包含调试信息,并且不作任何优化,便于程序员调试程序。
Release 称为发布版本,它往往是进行了各种优化,使得程序在代码大小和运行速度上都是最优的,以便用户很好地使用
2. 区别
由概念我们可以知道,它们本质上的区别就在于有没有对程序进行优化。
下面通过两个方面来感受一下它们的区别
(1)二者反汇编代码的对比:
有这样一段简单的代码:
int main()
{
int i = 0;
printf("%d", i);
return 0;
}
Debug版本下的反汇编代码:
Release版本下的反汇编代码:
可以看到Release版本下的反汇编代码比Debug版本下的简单了不少
(2)对于相同代码程序的不同执行结果:
这里的例子是第三部分中我们所展示的会让程序死循环的代码:
#include <stdio.h>
int main()
{
int i = 0;
int arr[10] = { 0 };
for (i = 0; i <= 12; i++)
{
arr[i] = 0;
printf("hehe\n");
}
return 0;
}
由前面我们知道,如上代码在Debug版本中运行,程序会陷入死循环;但若在Release版本中运行,程序就会按规定循环次数打印hehe。
如下是Release版本下运行的结果:
我们在变量和数组创建后分别打印它们的地址(Release版本下不能进行调试,所以无法通过监视窗口来观察)就可以知道原因了:
#include <stdio.h>
int main()
{
int i = 0;
int arr[10] = { 0 };
printf("%p\n", &i);
printf("%p\n", arr);
for (i = 0; i <= 12; i++)
{
arr[i] = 0;
printf("hehe\n");
}
return 0;
}
地址结果:
可以看到变量 i
的地址比数组首元素的地址还要小,即i
的内存空间使用的是更低的地址,故不会造成死循环。
所以原因就是,Release版本改变了变量在内存中开辟的顺序,从而影响到了程序执行的结果。
五、如何写好(易于调试)的代码
1. 优秀的代码
(1)优秀的代码一般都有以下几个标准:
- 代码运行正常
- bug很少
- 效率高
- 可读性高
- 可维护性高
- 注释清晰
- 文档齐全
(2)可以通过以下几点来学习写出优秀的代码:
- 使用断言assert
- 尽量使用关键字const
- 养成良好的编码风格
- 添加必要的注释
- 避免编码的陷阱
下面我们就以模拟系统库函数strcpy为例,来说明如何一步步让代码变得“优秀”。
2. 模拟实现strcpy
这里先简单说明一下这个函数的功能:把一个字符传中的内容拷贝到另一个字符串当中。
假设字符串1为“xxxxxx”
,字符串2为“abc”
那么strcpy("xxxxxx","abc")
就实现了将字符串2拷贝到了字符串1中,从而字符串1变为了"abcxxx"
。(完整的函数信息大家可参考:strcpy - C++ Reference)
我们把字符串1叫做destination
(拷贝目的地),字符串2叫做source
(拷贝来源)
(1) 那么由上面例子我们可以知道,实现这个函数,无非就是用source中的字符串对应着逐一替换destination中的内容,如下图所示:
先替换第一位,然后让destination
和source
两个指针++,替换下一位,循环直到source
指针遇到字符串结束标志 ‘\0’ ,跳出循环后再拷贝最后的 ‘\0’ 写成代码就是:
void my_strcpy(char*dest, char* src)
{
while (*src != '\0')
{
*dest = *src;
dest++;
src++;
}
*dest = *src;//拷贝\0
}
有了上面代码以后,我们开始考虑对其进行优化。优化的思路总共有两点:
- 既然是后置++,那么循环体中的内容就可以简化为
*dest++ = *src++;
即先进行当前位的替换,再进行指针的后移进行下一位的替换; - 语句
*dest++ = *src++;
是赋值表达式,而赋值表达式的结果就为所赋的值,所以我们可以将这个语句作为循环的判断条件,这样最后在拷贝完 ‘/0’ (赋值表达式的值为‘\0’,也就是0)之后循环也刚好停止。
那么整体的代码就优化为:
void my_strcpy(char* dest, char* src)
{
while (*dest++ = *src++)
{
;
}
}
(2) 到这我们发现代码其实还存在问题:若不小心传了一个空指针,那对空指针进行解引用操作就是非法访问内存的行为了,所以我们需要在函数的一开始对参数进行有效性的检验,那么这里可以用if语句实现:
if (src == NULL || dest == NULL)
{
return;
}
但if语句有一个缺点:但某个参数是空指针时,函数返回,这样确实规避了问题,但没有真正暴露出问题,这样就不利于对整体代码的维护。想要真正地暴露问题我们就需要用到断言函数assert。
- 断言assert的运用
使用这个函数需要包含的头文件为<assert.h>
;格式为assert(表达式);
当表达式为真时,什么是都不会发生,而当表达式为假时,程序就会报错,并且会给出相关的错误信息。
如下面这段代码:
运行结果:
可以看到,程序报错,并且在屏幕上给出了错误信息:发生错误的文件位置以及发生错误的行数。
规避了问题的同时,又帮我们找出了问题(๑•̀ㅂ•́)👍。
(3)到这代码基本上没有什么大问题了,但其实我们还可以继续对其进行优化。
假设我们不小心把*dest++ = *src++
写反了,变为*src++ = *dest++
,此时用assert语句就不能很好地发现问题了,此时若运行代码,最终程序会错误终止。为了应对这个问题,我们就需要用到关键字const
了
- 关键字const的运用
我们都知道,被const修饰的常量无法修改。那么回到函数中,我们的期望是把字符串source
中的内容拷贝到字符串destination
中,也就是不去改变source
字符串中的内容,那么我们就可以在参数部分用const
修饰source
字符串,如:void my_strcpy(char* dest, const char* src)
。这样一来,若粗心写成了*src++ = *dest++
,编译器会直接指出错误信息,而不给你运行的机会了。
所以,在以后实现某个函数时,希望某个参数在函数的实现的过程中不被改变就可以用const
来进行修饰。
补充:关于const,这里补充两个小知识点:
第一就是有关const修饰指针问题,所谓“指针常量”和“常量指针”的区别。
也就是const int*
和int* const
的区别,它们分别叫什么不重要,重要的是看到要能区分开:
对于const int *
,const修饰的是指针指向的内容,保证指针指向的内容不能通过指针来改变。但是指针变量本身的内容可变;
对于int * const
,const修饰的是指针变量本身,保证了指针变量的内容不能修改,但是指针指向的内容,可以通过指针改变。
这里有一个我个人的理解小技巧,分享给大家:const在“ * ”
号左边,此时把“ * ”
号理解为解引用操作符,那么就可以认为const修饰到了这个解引用操作符,故认为不能对指针进行解引用操作,即指针指向的内容不能通过指针来改变。但是指针变量本身的内容可变;同理,const在“ * ”
号右边就是没有修饰到这个解引用操作符,那就认为可以对指针进行解引用操作,也就是指针指向的内容可以通过指针改变,对应地,指针变量本身的内容就不能修改。
第二,有关const修饰变量的问题
我们知道当有这样的语句const int n = 10;
时,n的值就不能被改变,但这里准确来说是不能通过赋值表达式改变,因为此时通过取n的地址,然后解引用仍能改变n的值,“不让进门可以翻窗进”,要注意这一点。
(4) 最后其实还有一个可以改变的地方——函数的返回类型
我们上面所模拟的strcpy的返回类型为void
型,但对比原函数我们发现,原函数的返回类型为char*
型,相比于void
型,其优点在于可以实现函数的链式访问,即我们可以这样写:
printf("%s\n", my_strcpy(str1, str2));
//字符串拷贝完成后直接打印输出
对于返回类型为void
型,函数在完成拷贝函数自动返回,不需要返回值,所以也就不需要额外的变量来保存字符串的地址;但对于返回类型为char*
型,我们需要返回拷贝完成后的字符串,也就是destination,所以在进行拷贝之前需要创建一个变量来保存字符串destination的地址。
代码实现如下:
char* my_strcpy(char* dest, const char* src)
{
assert(dest && src);//断言指针的有效性
char* ret = dest; //保存地址
while (*dest++ = *src++)
{
;
}
return ret;
}
以上就是全部对模拟实现strcpy函数的优化过程了,一步步的优化其实就是一个个写出优秀代码的关键点,我们平常写代码时也应该多多考虑这些问题,养成良好的编程习惯。
下面附上VS2022中strcpy函数的源码供大家参考:
七、常见的编译错误
最后给大家介绍三种编译错误,希望我们都能做一个有心人,积累排错经验,不断朝着写出优秀代码的目标前进。
1. 编译型错误
通过直接看错误提示信息一般就可以解决问题,或者凭借经验也可以搞定,解决难度相对来说简单
2. 链接型错误
看错误提示信息,主要在代码中找到错误信息中的标识符,然后定位问题所在。一般是标识符名不
存在或者拼写错误
3. 运行时错误
只能借助调试,逐步定位问题。解决难度最棘手
本章完。
看完觉得有觉得帮助的话不妨点赞收藏鼓励一下,有疑问或有误地方的地方还恳请过路的朋友们留个评论,多多指点,谢谢朋友们!🌹🌹🌹