简单不先于复杂,而是在复杂之后。
目录
1. 什么是bug?
2. 调试是什么?
2.1 调试定义
2.2 调试的基本步骤
2.3 Debug 和 Release 的介绍
3. Windows 环境调试介绍
3.1 调试环境的准备
3.2 学会快捷键
3.3 调试的时候查看程序当前信息
3.3.1 查看临时变量的值
3.3.2 查看内存信息
3.3.3 调用堆栈
3.3.4 查看汇编信息
3.3.5 查看寄存器信息
4. 一些调试的实例
4.1 实例一
4.2 实例二
5. 如何写出好(易于调试)的代码
5.1 优秀的代码
5.2 示范:
5.3 const 的作用
5.4 模拟实现 strlen
6. 编程常见错误(语法错误)
6.1 编译型错误(语法错误)
6.2 链接型错误
6.3 运行时错误
1. 什么是bug?
导致计算机不能正常工作的错误叫做bug。
2. 调试是什么?
所有发生的事情都一定有迹可循
如果问心无愧,就不需要掩盖也就没有迹象了
如果问心有愧,那就一定会有迹象
迹象越多越容易顺藤而上,这就是推理的途径
顺着这条途径顺流而下就是犯罪,逆流而上,就是真相
一名优秀的程序员是一名优秀的侦探。
每一次调试都是尝试破案的过程。
一定要拒绝迷信式调试,也就是单纯靠猜去调试,而不去想产生 bug 的前因后果。
2.1 调试定义
调试(Debugging / Debug),又称除错,是发现和减少计算机程序或电子仪器设备中程序错误的一个过程。
2.2 调试的基本步骤
- 发现错误的存在
- 以隔离、消除等方式对错误进行定位
- 确定错误产生的原因
- 提出纠正错误的解决办法
- 对程序错误予以改正,重新测试
2.3 Debug 和 Release 的介绍
Debug 通常称为调试版本,它包含调试信息,并且不作任何优化,便于程序员调试程序。
Release 通常称为发布版本,它往往是进行了各种优化,使得程序在代码大小和运行速度上都是最优的,以便用户很好地使用。
下面是在文件夹中该程序保存路径下的 Debug 版本:
下面是 Release 版本:
其中 Release 版本中没有调试信息。
其实两者之间在其他方面也有差异,在以后的文章中会有体现。
3. Windows 环境调试介绍
注:Linux 开发环境调试工具是 gdb,在之后会有介绍。
3.1 调试环境的准备
我使用的是 vs2019 的编译器,首先把这个地方调成 Debug ,保证是一个可以让我们调试的版本。
3.2 学会快捷键
下面是最常用的几个快捷键:
F5:
启动调试,经常用来直接跳到下一个断点处。
注:在 vs2019 中按 F5 直接显示运行结果,并且运行结果的窗格不会出现一闪而过的情况,但是这只是 vs2019 的编译器的特点,不代表在其他的编译器不会出现这种问题,其实是我们没有按对。F5 仅仅是启动调试,代码一行行向下执行,如果没有一个东西拦截它,就会执行到程序结束为止。
所以 F5 并不是单独使用的,要和 F9 配合使用。
F9:
创建断点和取消断点。
断点的重要作用:可以在程序的任意位置设置断点
这样就可以使得程序在想要的位置随意停止执行,进而一步步执行下去。
(比如当我们觉得程序的错误在后面的部分,就可以借助打断点,在可能的错误部分前停止执行,然后一步一步执行下去,进行调试)
F10
逐过程,通常用来处理一个过程,一个过程可以是一次函数调用,或者是一条语句。
在调试时,点出了这样的断点,按下 F5 ,箭头并不会从第一个断点处直接跳到下一个断点,而是进入逻辑上的下一个断点,因为循环要执行10次,逻辑上还要再进入循环,所以按 F5 只会执行循环一次,所以 i 会 +1。
小技巧:如果想保留断点,但是不想让它起作用,可以禁用断点。
禁用断点之后断点就会变成空心的,如下图:
如果我们怀疑在循环里的第某次循环后有问题,可以将断点设置为调试断点。
右键断点选择条件,然后输出条件表达式,比如我怀疑该循环的第五次循环之后有问题,就写成下面的样子:
当我们按 F5 启动调试时,会自动执行到 i == 5 的地方
下附代码供大家亲手实践:
#define _CRT_SECURE_NO_WARNINGS 1 #include<stdio.h> int main() { int i = 0; int arr[10] = { 0 }; //赋值 for (i = 0; i < 10; i++) { scanf("%d", &arr[i]); } //打印 for (i = 0; i < 10; i++) { printf("%d ", i); } for (i = 0; i < 10; i++) { printf("%d ", i); } return 0; }
3.3 调试的时候查看程序当前信息
3.3.1 查看临时变量的值
在调试开始之后,我们可以用下面的方法观察变量的值。
以上都是我们在调试过程中可以观察的东西。
自动窗口:就是无需我们手动输入,而是自动地将我们上下文程序中创建的变量罗列到这个窗口供我们观察。
但是不够方便,因为如果进入 Add 函数,自动窗口就只会显示 x、y变量,之前的变量不显示,当我们想观察更多的信息的时候就非常不方便,所以不常用。
局部变量:会把程序执行过程中上下环境中的局部变量罗列到窗格中,进入函数时变量会反复切换,所以也不够方便。
监视: 我们可以输入任何想要观察的变量以及合法的表达式,上下文环境以及之前的变量数据都会保留,便于我们对比调试。
所以监视窗口是我们最常用的。
我们也可以同时打开多个监视窗口,如下图:
下附代码,供大家亲手实践:
#define _CRT_SECURE_NO_WARNINGS 1 #include<stdio.h> int Add(int x, int y) { return x + y; } int main() { int a = 10; int b = 20; int c = Add(a, b); printf("%d\n", c); return 0; }
小技巧:
我们发现,把数组名传过去,观察形参的数组名 a ,只能观察到数组内的第一个元素,因为实参是数组名,把数组首元素地址传了过去。
要想显示数组中更多的元素,需要用到一个小技巧,比如我们要观察数组的前四个元素,要写成下面的样子:
这样,我们就可以连续看到数组中的一串数据。
下附代码供大家亲手实践:
#define _CRT_SECURE_NO_WARNINGS 1 void test(int a[]) { // } int main() { int arr[10] = { 1,2,3,4,5,6,7,8,9,10 }; test(arr); return 0; }
3.3.2 查看内存信息
3.3.3 调用堆栈
通过调用堆栈,可以清晰地反映函数的调用关系以及当前调用所处的位置。
下附代码,供大家亲手实践:
#define _CRT_SECURE_NO_WARNINGS 1 #include<stdio.h> void test2() { printf("hehe\n"); } void test1() { test2(); } void test() { test1(); } int main() { test(); return 0; }
3.3.4 查看汇编信息
有两种方式转到反汇编
第一种:
第二种(直接鼠标右键):
然后我们就i可以切换到汇编代码:
下附代码,供大家亲手实践:
#define _CRT_SECURE_NO_WARNINGS 1 #include<stdio.h> int main() { char arr[] = "abcdef"; printf("%s\n", arr); return 0; }
3.3.5 查看寄存器信息
可以查看当前运行环境的寄存器的使用信息:
有两种方式可以观察寄存器的信息:
第一种:
第二种:
如果知道这些寄存器的名字,也可以用监视窗口来观察。
寄存器的信息会随着代码一行行的执行而发生变化。
下附代码供大家实践:
#define _CRT_SECURE_NO_WARNINGS 1 #include<stdio.h> int main() { int a = 10; int b = 20; int c = a + b; printf("%d\n", c); return 0; }
4. 一些调试的实例
4.1 实例一
实现代码:求 1!+2!+3! ...+ n! ;不考虑溢出
#define _CRT_SECURE_NO_WARNINGS 1 #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; }
我们输入3,期待输出9,但实际输出的是15
这个时候就需要我们上手一行一行地逐步调试,经过调试后我们发现,外层的第三次循环出了问题,ret 依次乘以2和3结果不是6,而是12,在监视窗口我们容易看到,ret 变量在每次计算阶乘之前没有重置为 1,所以会导致计算错误。
正确的代码如下:
#define _CRT_SECURE_NO_WARNINGS 1 #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; ret = 1; // 重置ret为1 for (j = 1; j <= i; j++) { ret *= j; } sum += ret; } printf("%d\n", sum); return 0; }
4.2 实例二
研究程序死循环的原因;
#define _CRT_SECURE_NO_WARNINGS 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; }
通过调试,我们发现,当 i == 12 时,arr[i]的值也变成了12,在执行 arr[i] = 0; 这条语句时,i 的值也变成了0,造成了死循环。
5. 如何写出好(易于调试)的代码
5.1 优秀的代码
- 代码运行正常
- bug 很少
- 效率高
- 可读性高
- 可维护性高
- 注释清晰
- 文档齐全
常见的 coding 技巧:
1. 使用 assert
2. 尽量使用 const
3. 养成良好的编码风格
4. 添加必要的注释
5. 避免编码的陷阱
5.2 示范:
模拟实现库函数:strcpy
下面是利用库函数 strcpy 解决问题的一个示例:
通过调试不难发现,在拷贝的时候,会把源字符串中的 \0 也拷贝过去。
下附代码:
#define _CRT_SECURE_NO_WARNINGS 1 #include<string.h> #include<stdio.h> int main() { char arr1[20] = "XXXXXXXXXXXXX"; char arr2[] = "hello,world!"; //strcpy 在拷贝的时候, 会把源字符串中的\0也拷贝过去 strcpy(arr1, arr2); printf("%s\n", arr1); return 0; }
接下来我们模拟实现 strcpy
#define _CRT_SECURE_NO_WARNINGS 1 #include<string.h> #include<stdio.h> void my_strcpy(char* dest, char* src) { while (*src != '\0') { *dest = *src; dest++; src++; } *dest = *src; } int main() { char arr1[20] = "XXXXXXXXXXXXX"; char arr2[] = "hello,world!"; //strcpy 在拷贝的时候, 会把源字符串中的\0也拷贝过去 my_strcpy(arr1, arr2); printf("%s\n", arr1); return 0; }
这个代码其实不算好,还可以改进优化:
#define _CRT_SECURE_NO_WARNINGS 1 #include<string.h> #include<stdio.h> void my_strcpy(char* dest, char* src) { while (*dest++ = *src++) { ; } } int main() { char arr1[20] = "XXXXXXXXXXXXX"; char arr2[] = "hello,world!"; //strcpy 在拷贝的时候, 会把源字符串中的\0也拷贝过去 my_strcpy(arr1, arr2); printf("%s\n", arr1); return 0; }
如果传到函数中的指针是空指针也会出现问题,所以我们使用断言,如果传了空指针就会报错。
#define _CRT_SECURE_NO_WARNINGS 1 #include<string.h> #include<stdio.h> #include<assert.h> void my_strcpy(char* dest, char* src) { //断言 assert(src != NULL); assert(dest != NULL); while (*dest++ = *src++) { ; } } int main() { char arr1[20] = "XXXXXXXXXXXXX"; char arr2[] = "hello,world!"; //strcpy 在拷贝的时候, 会把源字符串中的\0也拷贝过去 my_strcpy(arr1, arr2); printf("%s\n", arr1); return 0; }
strcpy 是把源字符串的字符拷贝到目标字符串,如果 while 循环中二者写反了也会出现问题,所以我们使用 const 令 src 所指向的对象不可修改:
#define _CRT_SECURE_NO_WARNINGS 1 #include<string.h> #include<stdio.h> #include<assert.h> void my_strcpy(char* dest, const char* src) { //断言 assert((dest && src) != NULL); while (*dest++ = *src++) { ; } } int main() { char arr1[20] = "XXXXXXXXXXXXX"; char arr2[] = "hello,world!"; //strcpy 在拷贝的时候, 会把源字符串中的\0也拷贝过去 my_strcpy(arr1, arr2); printf("%s\n", arr1); return 0; }
如果写代码是二者颠倒会出现这样的情况,const 对源字符串就起到了一个很好的保护作用。
如果还要继续追究的话,我们可以把函数返回值改为 char*
#define _CRT_SECURE_NO_WARNINGS 1 #include<string.h> #include<stdio.h> #include<assert.h> //返回 char* 是为了实现链式访问 //也就是把这个函数的返回值作为另一个函数的参数 //strcpy函数返回的是目标空间的起始地址 char* my_strcpy(char* dest, const char* src) { char* ret = dest; //断言 assert((dest && src) != NULL); while (*dest++ = *src++) { ; } return ret; } int main() { char arr1[20] = "XXXXXXXXXXXXX"; char arr2[] = "hello,world!"; //strcpy 在拷贝的时候, 会把源字符串中的\0也拷贝过去 my_strcpy(arr1, arr2); printf("%s\n", arr1); return 0; }
5.3 const 的作用
const 修饰指针变量 1. const 放在 * 的左边 const int* p = #//一般用这种 int const* p; p指向的对象不能通过p来改变了,但是p变量本身的值是可以改变的 2. const 放在* 的右边 int* const p = # p指向的对象是可以哦通过p来改变的,但是不能来修改p本身的值
5.4 模拟实现 strlen
#define _CRT_SECURE_NO_WARNINGS 1 #include<stdio.h> #include<assert.h> //求字符串长度 int my_strlen(const char* str) { int count = 0; assert(str); while (*str != '\0') { count++; str++; } return count; } int main() { char arr[] = "hello,world"; int len = my_strlen(arr); printf("%d\n", len); return 0; }
6. 编程常见错误(语法错误)
6.1 编译型错误(语法错误)
直接看错误提示信息(双击),解决问题。或者凭借经验就可以搞定。相对来说简单。
6.2 链接型错误
出现在链接期间
看错误提示信息,主要在代码中找到错误信息中的标识符,然后定位问题所在。
一般是标识符名不存在或者拼写错误。
而且这种错误无法通过双击错误信息来解决,可以用 Ctrl + F 来查找无法解析的符号来确定位置。
6.3 运行时错误
没有报错,但是结果没有符合预期。是最难解决的问题。
我们要借助调试,逐步定位问题。