调试与规范 注:本文全部建立在VS2022的编译环境下,默认是x64环境
一、什么是bug
在上个世纪的时候,第一次被发现的导致计算机错误的飞蛾,也是第一个计算机程序错误。也就是臭虫,进而因此命名为bug了,并且广为流传一直到现在。
二、何为调试?调试的重要性
所有发生的事情都一定有迹可循,如果问心无愧,就不需要掩盖也就没有迹象了,如果问心有愧,就必然需要掩盖,那就一定会有迹象,迹象越多就越容易顺藤而上,这就是推理的途径。顺着这条途径顺流而下就是犯罪,逆流而上,就是真相。要像一个侦探,一点点一步步去解决问题,不要像一个无头苍蝇一样。
在讲解之前先说下一定会要拒绝-迷信式调试!不要没有思考,只是到处乱碰,这改一下,那动一下不知道错误的原因是什么,下面会为大家在调试的时候提供一些思路。
1.调试的基本步骤
1.发现程序错误的存在 (发现错误,并且承认错误的存在)
2.以隔离、消除等方式对错误进行定位 (定位到错误,一块一块的修改)
3.确定错误产生的原因
4.提出纠正错误的解决办法
5.对程序错误予以改正,重新测试
2.扩:Release和Debug版本区别
Debug 通常称为调试版本,它包含调试信息,并且不作任何优化,便于程序员调试程序。而且Debug版本可以通过一步步调试来解决一些隐含的问题。
Release 称为发布版本,它往往是进行了各种优化,使得程序在代码大小和运行速度上都是最优的,以便用户很好地使用,相对来讲它的调试就没那么细节和方便。(关于调试后续都会讲)
1) 存储上的优化
Debug版本下:
Release版本下:
可以明显看出Release版本比Debug版本下内存少很多,并且Release版本速度更快,Debug版本速度与之想反。
2)问题上的优化
相信很多人可以看出问题所在,这是数组越界访问,一般来讲都会报错的。但是并没有,他进入了死循环,这是为什么呢
解析:如下图 i的地址居然与arr[12](当然它是越界访问的结果)的地址一样,这说明上面的循环会发生什么? 当i等于12的时候,arr[12]= 0 然后i又变成0,然后相当于又进行一遍循环,所以会陷入死循环,都没时间结束程序来报错。
而在Realse版本下,则不需要,这就是Realse的优化
3) 不同编译环境下,变量开辟的顺序发生改变所带来的影响
变量开辟的顺序发生变化,也会有可能会影响程序的结果的。
下面两张图是在Debug(x86)环境下运行的,可以通过对比可以看出,因为i和arr数组都是局部变量,是放在内存中的栈区的,而栈区使用地址的习惯:先使用高地址处的空间再使用低地址处的空间。
下面这个是Debug版本(x64)可以看出 开辟顺序是先i再数组 并且可以看到 i的地址是比数组高的,也就是说他是先在高地址创建再在低地址创建。并且通过下面俩地址,可以看出数组随着下标的增长,地址是从低地址到高地址的(左图是运行结果是地址 右边的表格是实际存储结构)
下面两张图是在Debug(x64)环境下 ,先创建数组再创建i的情况 可以看出还是是先在高地址创建再在低地址创建,这说明什么?在Debug(x64)环境下都是先创建整形变量然后再创建数组变量。
大致就是下面这样,创建方式是固定的,这可能与编译器环境有关,但是可能这也是vs2022的在x64版本下的优化
下面的Release版本下的地址(左上角是版本)
x86环境下的两种开辟顺序:
x64环境下的两种开辟顺序:
上面这四种,不管哪种情况都是按照这个来的,只是地址分配不同。这就是Release版本下的优化,它能通过内存分配的优化来解决一些问题。
ps:这里就不得不提到软件开发的流程:
三、windows环境的介绍
1. 调试环境的准备
只有在Debug环境下才能正常进行调试。
2. 学会使用快捷键
F5
启动调试,经常用来直接跳到下一个断点处。(要与F9一起用才好用)
F9
创建断点和取消断点
断点的重要作用,可以在程序的任意位置设置断点。
这样就可以使得程序在想要的位置随意停止执行,继而一步步执行下去。
右键想要断点的那一句,就可以在里面找到:
F10
逐过程,通常用来处理一个过程,一个过程可以是一次函数调用,或者是一条语句。
F11
逐语句,就是每次都执行一条语句,但是这个快捷键可以使我们的执行逻辑进入函数内部(这是最常用的)。
CTRL + F5
开始执行不调试,如果你想让程序直接运行起来而不调试就可以直接使用。
3. 调试的时候查看程序当前信息
1)查看临时变量的值
然后出现如图所示的状态栏,可以添加想要监视的量,这样的操作是很方便的,之前不会监视的话,你可能想要查看一个数组里面的值,还要通过循环打印来实现,现在直接通过监视就可以看出错误的地方(切记:监视之前需要先调试,不调试是不能创建监视窗口的)
ps:甚至可以进行一些简单计算,也是可以求出结果的,很好用
2)查看内存
在调试开始之后,用于观察内存信息。
小细节:第一个是可以通过&变量名 来找到内存所存储的值 第二个是列数选几,那么它的字节就是几,前后地址的差也就是几。
例如: i的大小是1,在内存中存储的大小就是1,一个字节对应一个地址,
3)查看反汇编信息
用于调试之后,查看反汇编的
第一种方法和前面类似: 在调试里找到窗口,在窗口里面找到反汇编
第二种方法:右键想要查找的反汇编的代码段也是可行的
4)查看调用堆栈
通过调用堆栈,可以告诉你什么时候调用了函数 显示的数字就是整个函数结束的位置。
讲到了堆栈也就刚好扩展下数据结构中的队列和栈,下面就是两种数据结构的存入和读取方式:
队列在一般情况下只能从队尾进,从队头出
栈只能从栈顶进,并且也只能从栈顶出
5)查看寄存器信息
四、多多动手,尝试调试,才能有进步。
一定要熟练掌握调试技巧。初学者可能80%的时间在写代码,20%的时间在调试。但是一个程序员可能20%的时间在写程序,但是80%的时间在调试。
现在只是一些简单的调试。以后可能会出现很复杂调试场景:多线程程序的调试等。多多使用快捷键,提升效率。
五、调试技巧的练习
1.练习1:实现1+2!+3!+4!·······n!、
错误代码:
#include <stdio.h>
int main()
{
int i = 0;
int sum = 0;//保存最终结果
int n = 0;
int ret = 1;//保存n的阶乘
scanf("%d", &n);
for (i = 1; i <= n; i++)
{
int j = 0;
for (j = 1; j <= i; j++)
{
ret *= j;
}
sum += ret;
}
printf("%d\n", sum);
return 0;
}
可以看出结果是错的,因为应该是9。
我相信很多人都做过这个题目,可能一眼就看出错误在那,但是请跟我一步步看哪里错了,并且自己去调试研究下。
通过调试加分析看出问题就出现在:计算在3的阶乘
然后分析源代码不妥当的地方 在第三次循环开始的时候ret应该是从1开始而不是上一次循环计算出来的2
六、如何写出好(易于调试)的代码。
1.优秀的代码所具备的特征
1. 代码运行正常
2. bug很少
3. 效率高
4. 可读性高
5. 可维护性高
6. 注释清晰
7. 文档齐全
2.常见的coding技巧
1. 使用assert
2. 尽量使用const
3. 养成良好的编码风格
4. 添加必要的注释
5. 避免编码的陷阱。
3.实例练习
1)模拟实现strcpy函数
先介绍下strcpy函数
strcpy函数的使用实例:
strcpy函数的模拟实现
第一版:
#include<stdio.h>
void my_strcpy(char* a, char* b)
{
while (*b !='\0')
{
*a = *b;
a++;
b++;
}
}
int main()
{
char arr1[20] = "hello";
char arr2[20] = "***********";
my_strcpy(arr2, arr1);
printf("%s", arr2);
return 0;
}
解析:第一眼看你可能觉得是对的,但是如果重新看定义之后,就会发现这个是错误的,因为strcpy函数是会把字符串结尾即\0也会复制进去的
第二版:(稍微改进下)
#include<stdio.h>
//肯定是要地址 不然根本无法实现
void my_strcpy(char* a, char* b)
{
while (*b !='\0')
{
*a = *b;
a++;
b++;
if (*b=='\0')
{
*a = '\0';
}
}
}
int main()
{
char arr1[20] = "hello";
char arr2[20] = "***********";
my_strcpy(arr2, arr1);
printf("%s", arr2);
return 0;
}
运行结果:
这个运行结果才是正确的,肯定很多人第一反应是后面的**为什么没了,这是因为%s只能读取到第一个\0 所以如果数组里有\0 那么后面的*也就没了 所以会有这样的运行结果。
第三版:(这个代码只是任务完成了,还能继续改进)
例如:如果只是在刚才的基础上稍微改一下,就会报错
系统直接报错:
这是为什么呢?因为空指针(NULL)是没有指向任何地址的指针,所以如果对空指针进行解引用就会报错,所以这个时候就用到了assert来解决这个问题
只需要把代码稍稍改进下,就能避免很多问题。在函数部分用断言一下
改进部分:
运行结果:
这个意思就是,指针a/b其中有一个是空指针,指针不能运行,也就是说导致系统错误的原因显而易见,提前写出限制条件,也就避免了你在报错了寻找错误的时间,提高了效率,这也是一种好的调试和编译技巧。
第四版
因为‘\0’的ASCII码值是0,所以这个代码在简介层次上可以再优化(将拷贝这个操作进行了优化)
这个就是相当于把赋值操作在循环判断部分执行 一举两得
再细节一点就是:
然后他又双叒叕要优化了,可以看到这个函数的实际返回类型是char* 所以我们还需要改进
第五版
#include<stdio.h>
#include<assert.h>
//肯定是要传地址 不然根本无法实现
char* my_strcpy(char* a, char* b)
{
assert(a != NULL);
assert(b != NULL);
//原地址
char* flag = a;
while (*a++ = *b++); //空语句
return flag;
}
int main()
{
char arr1[20] = "hello world";
char arr2[20] = "***********";
char* p = NULL;
printf("%s", my_strcpy(arr2, arr1));
return 0;
}
解析:首先呢strcpy这个函数本身返回类型就是字符指针,所以我们模拟实现的指针也要满足其条件,所以结尾是flag。 其次呢,很多人肯定好奇为什么要专门创建一个临时变量,因为在拷贝过程中,a的地址在逐渐增加,所以返回的肯定不是初始地址,如果返回a的地址里面是从\0开始,所以才创建了一个临时指针变量,用来存首元素地址。
ps:我们之前打印字符串的时候,都是 %s,arr 其中arr是数组名,也就是首元素地址,所以我们写成my_strcpy这种形式也是完全可以的
第六版
如果你在实现的过程不小心把拷贝的内容搞反了,这时候如果去检查也会造成很多麻烦
解决方案: 利用const来优化你的代码,如果你修改了限定的变量,他会直接报错,不会按照错误的情况进行下去
2)const的用法分析(接上)
①修饰一般变量时
大家通过下面案例可以看出来这个是不能直接修改的
const直接改变用const修饰的a不可行,但是用地址引用可以实现(n本身这个变量不能改 但是地址里面存的值可以改)具体操作如下:
②修饰指针时
这个案例,接着上面,如果在指针前也加上const会怎么样?
那么结果也是如下,显而易见,不能随意p里面存的值了
但是呢?还是能通过一定技巧来实现,因为这个const只是让 * p 里面存的数值不能改变,操作对象是p 但是呢,可以再次改变p所指向的地址来改变运行结果,具体操作如下:
此时把const的位置改变:
上面可以看到将const移到int后面后,发现效果都是一样的,都是对*p这个值进行限制,不能操作(因为const只要在其左边,都是对其操作)
接下来是再将const的位置进行改变:
但是通过这次可以明显看出,现在限制的是对p这个地址的操作,但是可以操作*p中所存放的数值
③结论:
const修饰指针变量的时候:
1. const如果放在*的左边,修饰的是指针指向的内容,保证指针指向的内容不能通过指针来改
变。但是指针变量本身的内容可变。
2. const如果放在*的右边,修饰的是指针变量本身,保证了指针变量的内容不能修改,但是指
针指向的内容,可以通过指针改变。
3)模拟实现strlen函数
依旧是先介绍一下
第一版
#include<stdio.h>
int my_strlen(char*a)
{
int num = 0;
while (*a++ != '\0')
{
num++;
}
return num;
}
int main()
{
char arr1[100] = { 0 };
scanf("%s", arr1);
printf("%d", my_strlen(arr1));
}
但是这个有个致命缺点,就是输入上的,因为%s只能读取到第一个非空格/'\0’的地方
第二版
优化了下输入这个功能
#include<stdio.h>
int my_strlen(char*a)
{
int num = 0;
while (*a++ != '\0')
{
num++;
}
return num;
}
int main()
{
char arr1[100] = { 0 };
int i = 0;
while (1)
{
scanf("%c", &arr1[i]);
if (arr1[i] != '\n')
{
i++;
}
else
{
arr1[i] = '\0';
break;
}
}
printf("%d", my_strlen(arr1));
}
这样就已经能很好的完成任务了,但是考虑到为日后的维护节省时间,接下来继续优化下
第三版
加上断言后避免了对空指针进行解引用以导致的错误
第四版
最终优化版本:
#include <stdio.h>
int my_strlen(const char* str)
{
size_t count = 0;
assert(str != NULL);
while (*str) //判断字符串是否结束
{
count++;
str++;
}
return count;
}
int main()
{
char arr1[100] = { 0 };
int i = 0;
while (1)
{
scanf("%c", &arr1[i]);
if (arr1[i] != '\n')
{
i++;
}
else
{
arr1[i] = '\0';
break;
}
}
//测试
size_t len = my_strlen(arr1);
printf("len = %zd\n", len);
return 0;
}
这次的优化内容:首先就是在函数里面判断的部分进行了更改,因为前面讲过\0的ASCII值就是0 所以while循环里面条件直接写就行,如果是\0循环直接结束了,其次就是size_t(unsigned int / unsigned long)这个数据类型其实和unisigned int差不多 都是无负数的表达。
还有因为在一般情况下 null就是0 所以直接 assert(str)这种格式就行了 都不需要再写一个判断语句
七、常见的编译错误
1. 编译型错误(语法问题)
直接看错误提示信息(双击),解决问题。或者凭借经验就可以搞定。相对来说简单。
2. 链接型错误
看错误提示信息,主要在代码中找到错误信息中的标识符,然后定位问题所在。一般是标识符名不存在或者拼写错误。
3. 运行时错误
借助调试,逐步定位问题。最难搞。