🤡博客主页:醉竺
🥰本文专栏:《C语言深度解剖》
😻欢迎关注:感谢大家的点赞评论+关注,祝您学有所成!
✨✨💜💛想要学习更多数据结构与算法点击专栏链接查看💛💜✨✨
1. 什么是bug?
第一次被发现的导致计算机错误的飞蛾,也是第一个计算机程序错误。
2. 调试是什么?有多重要?
所有发生的事情都一定有迹可循,如果问心无愧,就不需要掩盖也就没有迹象了,如果问心有愧, 就必然需要掩盖,那就一定会有迹象,迹象越多就越容易顺藤而上,这就是推理的途径。 顺着这条途径顺流而下就是犯罪,逆流而上,就是真相。
一名优秀的程序员是一名出色的侦探。
每一次调试都是尝试破案的过程。
我们是如何写代码的?
又是如何排查出现的问题的呢?
下面进入正题!
2.1 调试是什么?
调试(英语:Debugging / Debug),又称除错,是发现和减少计算机程序或电子仪器设备中程序 错误的一个过程。
2.2 调试的基本步骤
- 发现程序错误的存在
- 以隔离、消除等方式对错误进行定位
- 确定错误产生的原因
- 提出纠正错误的解决办法
- 对程序错误予以改正,重新测试
2.3 Debug和Release的介绍
Debug 通常称为调试版本,它包含调试信息,并且不作任何优化,便于程序员调试程序。 Release 称为发布版本,它往往是进行了各种优化,使得程序在代码大小和运行速度上都是最优 的,以便用户很好地使用。
代码:
#include <stdio.h>
int main()
{
char* p = "hello bit.";
printf("%s\n", p);
return 0;
}
上述代码在Debug环境的结果展示:
上述代码在Release环境的结果展示:
Debug和Release反汇编展示对比:
所以我们说调试就是在Debug版本的环境中,找代码中潜伏的问题的一个过程。
那编译器进行了哪些优化呢? 请看如下代码:
#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 模式去编译,程序没有死循环。
那他们之间有什么区别呢? 就是因为release模式优化导致的。
变量在内存中开辟的顺序发生了变化,影响到了程序执行的结果。
上述代码在下面调试案例中会详细讲解,这里只是简单说一些该程序在Debug模式和Release模式下的区别。
3. Windows环境调试介绍
注:linux开发环境调试工具是gdb,后面我会单独开一个专栏进行学习。
3.1 调试环境的准备
在环境中选择 debug 选项,才能使代码正常调试。
3.2 学会快捷键
最常用的调试快捷键其实就5个:
- F10 逐过程,通常用来处理一个过程,一个过程可以是一次函数调用,或者是一条语句。
- F11 逐语句,就是每次都执行一条语句,这个快捷键可以使我们的执行逻辑进入函数内部(这是最常用的)。
- F9 创建断点和取消断点 ,可以在程序的任意位置设置断点。
- F5 启动调试,经常用来直接跳到下一个断点处。
- F9和F5这样就可以使得程序在想要的位置随意停止执行,继而一步步执行下去。
- CTRL + F5 开始执行程序,如果你想让程序直接运行起来而不调试就可以直接使用。
3.2.1 演示CTRL + F5
3.2.2. 演示F10和F11
在着手调试程序时,开发者通常会依赖于F10和F11这两个关键的快捷键。这两个键的主要区别在于它们对函数调用的处理方式。
- 当你按下F11时,调试器会进入函数的内部,允许你逐行执行函数中的代码,这被称为“单步执行进入”。这种方式非常适合于深入理解函数的具体行为和逻辑。
- 相对地,F10键用于“单步执行”,当遇到函数调用时,它不会进入函数内部,而是将整个函数视为一个步骤来执行。这意味着它会直接跳过函数的内部细节,只显示函数调用的结果。这种方法在你知道函数已经正常工作,或者不关心函数内部细节时非常有用。
- 总结来说,F11用于深入函数内部进行详细调试,而F10则用于快速遍历代码而不深入每个函数的细节。合理使用这两个快捷键可以大大提高调试效率。
接下来演示F10和F11实际中的区别:
- 按下 F10 或者 F11进入调试过程
- 按F10,执行 test() 函数
我们发现,按F10逐过程遇到test()函数时,会直接显示该函数运行的最终结果并不会进入test()函数的内部细节。
我们看看如果运行到test()函数时,按F11是怎样的
- 按F11,执行 test() 函数
我们发现,按F11逐语句遇到test()函数时,调试器箭头会进入该函数的内部,允许我们逐条执行并函数内部的代码细节。直到该函数执行完毕,然后箭头跳转到main()函数中该函数调用语句的下一条指令。
3.2.3 演示F5和F9
F5和F9通常是配合着使用的。
在处理较长的程序时,手动逐句执行到特定代码行会非常耗时。为了提高效率,我们通常会使用断点来标记我们关心的代码行。
- 按下F9键,这样可以在特定的代码行上设置一个标记,指示调试器在该行暂停执行。如果需要取消断点,再次按下F9键即可。
- 一旦断点设置完毕,我们可以通过按下F5键启动调试。程序将开始执行,直到遇到第一个断点。
- 如果程序中有多个断点,我们可以继续按下F5键,让程序从一个断点跳转到下一个断点
- 这时,执行将暂停,允许我们检查当前状态、变量值以及其他相关的调试信息。
- 这样就可以只关注那些我们怀疑可能存在问题或者想要深入了解的代码区域。
下面这张图是一个断点的情况:
下面这张图是多个断点的情况:
1.分别在13行和22行按 F9 设置两个断点
2.按下F5键,程序跳转到第一个断点处,并且断点之前的代码都已执行
3.继续按下F5,程序从第一个断点处跳到第二个断点处,第二个断点之前的程序都已执行。
补充1:条件断点
在调试过程中,我们可以通过为断点添加条件来进一步细化控制程序执行的行为。这样,即使程序运行到该断点,也不会立即暂停,除非满足我们设定的条件。设置条件断点的方法通常是在断点属性中进行配置,这可以通过右键点击断点并选择“条件”来设置。
例如,假设我们有一个循环,我们只想在循环变量达到特定值时才暂停执行。我们可以在循环内的代码行上设置一个断点,并为其添加一个条件,比如“当循环变量等于某个值时暂停”。这样,当我们按下F5键启动调试时,程序将正常运行,直到循环变量满足我们设定的条件,此时程序会在该断点处暂停。
下面将演示条件断点的设置:在for循环中,我们在设置i==5时,程序会停下来。
具体步骤:1.按F9设置断点 —>2.鼠标右键断点—>3.点击条件并设置—>4.按F5执行调试—>5.执行到条件断点处程序停下。
补充2:查看断点的数量和信息
当程序特别长有设置了特别多的断点时,我们想查看所有断点的数量和信息可以按照以下步骤:
点击 1.调试 —> 2.窗口—>3.断点
3.3 调试的时候查看程序当前信息
调试的时候除了上述的几个快捷键,最重要的就是使用调试窗口中的几个功能。如下图所示:
注意:下面窗口的功能,首先进入调试中才会有,正常运行程序是不会有的。
3.3.1 自动窗口和临时变量窗口(了解)
点击自动窗口或者局部变量窗口,逐步调试时,自动窗口和局部窗口中会短暂的自动展示一些变量的值,自动窗口中包括全局变量以及局部变量;局部变量窗口则只展示局部变量的值。但是继续调试过程中,两个窗口编译器就不再显示变量值,我们就无法观察了。所以说这两个窗口并不实用也不常用,这里只是简单提一下。
后续主要用的就是监视窗口。
1.自动变量窗口
2.局部变量窗口
3.3.2 查看变量的值
点击“监视”,可以打开好几个窗口,同时窗口的位置可以长按拖动。
监视窗口可以添加你想观察的成员信息,包括并不限于各种变量,数组,结构体,指针等,以及它们的地址。如下图所示:
3.3.2 查看内存信息
内存窗口显示的内容所代表的具体含义:
3.3.3 查看调用堆栈
当多个函数嵌套调用的时候,如果想理清每个函数之间的关系,可以查看调用堆栈窗口
下面请看函数栈的堆叠以及释放:
通过调用堆栈,可以清晰的反应函数的调用关系以及当前调用所处的位置。
3.3.4 查看汇编信息
在调试开始之后,有两种方式转到汇编:
(1) 第一种方式:右击鼠标,选择【转到反汇编】:
就能看到汇编代码了:
(2)第二种方式:
3.3.5 查看寄存器信息
可以查看当前运行环境的寄存器的使用信息。
3.3.6 小细节补充
在C或C++等编程语言中,数组作为函数参数传递时,它们会退化为指针。这意味着数组不再携带其原始的长度信息,因此在调试器的监视窗口中查看这样的参数时,我们无法直接看到整个数组的细节。
解决方法: arr,number
4.多动手,尝试调试,才能进步
- 一定要熟练掌握调试技巧。
- 初学者可能80%的时间在写代码,20%的时间在调试。
- 但是一个程序员可能20%的时间在写程序,但是80%的时间在调试。
- 我们所讲的都是一些简单的调试。
- 以后可能会出现很复杂调试场景:多线程程序的调试等。
- 多多使用快捷键,提升效率。
5. 调试实战
5.1 实例一
实现代码:求 1!+2!+3! ...+ n! ;不考虑溢出。
下面代码语法没问题,但是逻辑有问题,我们需要调试查出来:
这时候我们如果3,期待输出9,但实际输出的是15。 why? 这里我们就得找我们问题。
1. 首先推测问题出现的原因。初步确定问题可能的原因最好。
2. 实际上手调试很有必要。
3. 调试的时候一些中间过程和结果我们至少是心里有数的。
所以正确代码应该是:
5.2 实例二
看下面这段代码有没有什么问题?运行结果是什么?
运行结果:程序陷入了死循环!
这是怎么回事?
我们第一眼观察到这个代码的时候,首先观察到的应该是“数组的越界访问”,因为这类数组只有10个数,下标访问应该到9。一般情况下越界访问应该直接会报错,非法访问,但是这里为什么会陷入死循环呢?
所以我们需要对程序进行调试,研究程序死循环的原因。
在上面调试过程中,我们发现了一个现象,i 从 1 变成 12 的过程中,arr[12]的数值跟i一模一样!
因此,我猜想 i 和 arr[12] 处于同一块空间?例如下图这样?
为了验证这个猜想,在监视窗口中,添加 “&i” 和 “&arr[]12”,对比一下它们俩的地址。请看下图:
我们发现, i 和 arr[12] 确实处于同一块空间,因此当arr[12] 赋值为0的时候,i 也变成了 0.因此for循环从0又开始继续增长,最后陷入了死循环。
为什么会这样呢?为什么 i 和arr[12] 处于同一块空间?
拓展:
上述代码的运行结果其实是跟编译环境有关系的。
这个例子呢并不是探究编译器的内存分配的,不过这个确实是一个利用调试技巧来探究代码运行结果的好例子。
6. 如何写出好(易于调试)的代码
6.1 优秀的代码:
1. 代码运行正常
2. bug很少
3. 效率高
4. 可读性高
5. 可维护性高
6. 注释清晰
7. 文档齐全
常见的coding技巧:
1. 使用assert
2. 尽量使用const
3. 养成良好的编码风格
4. 添加必要的注释
5. 避免编码的陷阱。
这里先简单介绍一个assert和const的用法:
6.1.1 assert
在C语言中,assert
宏定义在头文件assert.h
中。要使用它,需要在源文件顶部包含这个头文件:
#include <assert.h>
assert
宏的原型如下:
void assert(int expression);
它接收一个表达式,并检查表达式的值是否为非零(true)。如果表达式的计算结果为0(即为false),assert将输出一条错误消息到标准错误输出,并终止程序执行。
6.1.2 const的作用
结论:
const修饰指针变量的时候:
1. const如果放在*的左边,修饰的是指针指向的内容,保证指针指向的内容不能通过指针来改 变。但是指针变量本身的内容可变。
2. const *的右边,修饰的是指针变量本身,保证了指针变量的内容不能修改,但是指针指向的内容,可以通过指针改变。
// const修饰指针变量的时候:
//代码1 测试无cosnt的
void test1()
{
int n = 10;
int m = 20;
int* p = &n;
*p = 20; // right
p = &m; // right
}
//代码2 测试const放在*的左边
void test2()
{
//代码2
int n = 10;
int m = 20;
const int* p = &n;
*p = 20; // error
p = &m; // right
}
//代码3 测试const放在*的右边
void test3()
{
int n = 10;
int m = 20;
int* const p = &n;
*p = 20; // right
p = &m; // error
}
//代码4 测试const放在*的两边
void test4()
{
int n = 10;
int m = 20;
const int* const p = &n;
*p = 20; // error
p = &m; // error
}
6.2 示范:
模拟实现库函数:strcpy
#include<assert.h>
// 原版
char* my_strcpy(char* dest, const char* src)
{
assert(dest != NULL);
assert(src != NULL);
while (*src != '\0')
{
*dest = *src;
dest++;
src++;
}
*dest = *src; // 拷贝'\0'结束符
return dest;
}
// 1.优化断言
char* my_strcpy(char* dest, const char* src)
{
assert(dest && src);
while (*src != '\0')
{
*dest = *src;
dest++;
src++;
}
*dest = *src; // 拷贝'\0'结束符
return dest;
}
// 2.优化赋值
char* my_strcpy(char* dest, const char* src)
{
assert(dest && src);
while (*src != '\0')
{
*dest++ = *src++;
}
*dest = *src; // 拷贝'\0'结束符
return dest;
}
// 3.优化循环和拷贝'\0'结束符
char* my_strcpy(char* dest, const char* src)
{
assert(dest && src);
while (*dest++ = *src++)
{
;
}
return dest;
}
// 4.优化返回值
char* my_strcpy(char* dest, const char* src)
{
assert(dest && src);
char* ret = dest;
while (*dest++ = *src++)
{
;
}
return ret;
}
int main()
{
char str1[] = "hello world";
char str2[100];
my_strcpy(str2, str1);
printf("%s\n", str2);
return 0;
}
模拟实现一个strlen函数
参考代码:
#include<assert.h>
size_t my_strlen(const char* str)
{
assert(str != NULL);
size_t len = 0;
while (*str) // 判断字符串是否结束
{
len++;
str++;
}
return len;
}
int main()
{
char str[] = "hello world";
size_t len = my_strlen(str);
printf("len = %d\n", len);
return 0;
}
7. 编程常见的错误
7.1 编译型错误
直接看错误提示信息(双击),解决问题。或者凭借经验就可以搞定。相对来说简单。
7.2 链接型错误
看错误提示信息,主要在代码中找到错误信息中的标识符,然后定位问题所在。一般是标识符名不 存在或者拼写错误。
7.3 运行时错误
借助调试,逐步定位问题。最难搞。
温馨提示: 做一个有心人,积累排错经验。
以上就是visual stdio 进行调试的教程了,后续会专门出一个visual 调试的专栏,以及Linux环境下GDB调试的专栏,会有更加高阶的调试技巧!敬请期待!