之中实用调试技巧
- 什么是bug?
- 调试是什么?有多重要?
- debug和release的介绍。
- windows环境调试介绍。
- 一些调试的实例。
- 如何写出好(易于调试)的代码。
- 编程常见的错误。
1. 什么是bug?
第一次被发现的导致计算机错误的飞蛾,也是第一个计算机程序错误。
注:参考资料
2. 调试是什么?有多重要?
所有发生的事情都一定有迹可循,如果问心无愧,就不需要掩盖也就没有迹象了,如果问心有愧,就必然需要掩盖,那就一定会有迹象,迹象越多就越容易顺藤而上,这就是推理的途径。
顺着这条途径顺流而下就是犯罪,逆流而上,就是真相。
一名优秀的程序员是一名出色的侦探。
每一次调试都是尝试破案的过程。
我们是如何写代码的?
又是如何排查出现的问题的呢?
拒绝-迷信式调试!!!!(以上的调试叫迷信式的调试)
2.1 调试是什么?
调试(英语:Debugging / Debug),又称除错,是发现和减少计算机程序或电子仪器设备中程序
错误的一个过程。
2.2 调试的基本步骤
- 发现程序错误的存在
- 以隔离、消除等方式对错误进行定位(隔离、消除指屏蔽)
- 确定错误产生的原因
- 提出纠正错误的解决办法
- 对程序错误予以改正,重新测试
2.3 Debug和Release的介绍。
Debug 通常称为调试版本,它包含调试信息,并且不作任何优化,便于程序员调试程序。
Release 称为发布版本,它往往是进行了各种优化,使得程序在代码大小和运行速度上都是最优
的,以便用户很好地使用。 (在vs左上角,调成Debug就是调试版本,调成Release是发布版本)
int main()
{
int arr[10] = { 0 };
int i = 0;
for (i = 0; i < 10; i++)
{
arr[i] = 10 - i;
}
for (i = 0; i < 10; i++)
{
printf("%d ", arr[i]);
}
return 0;
}
这里用Debug可以调试代码的结果
调试结果如下:
此时将Debug改成Release ,而Release没有调试信息。当F10走起时,结果如下:
此时,我们可以看到,到代码运行时,里面放着一些乱七八糟的值,当代码再次运行时,直接一次性运行好几个,没办法一步一步调试。
观察下图,可以看出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 模式去编译,程序没有死循环。
那他们之间有什么区别呢?
就是因为优化导致的。
变量在内存中开辟的顺序发生了变化,影响到了程序执行的结果。
3. Windows环境调试介绍
注:linux开发环境调试工具是gdb。
3.1 调试环境的准备
在环境中选择 debug 选项,才能使代码正常调试。
3.2 学会快捷键
最常使用的几个快捷键:
F5
启动调试,经常用来直接跳到下一个断点处。
F9
创建断点和取消断点(切换断点)
断点的重要作用,可以在程序的任意位置设置断点。
这样就可以使得程序在想要的位置随意停止执行,继而一步步执行下去。
int main()
{
int arr[10] = { 0 };
int i = 0;
test();
for (i = 0; i < 10; i++)
{
arr[i] = 10 - i;
}
for (i = 0; i < 10; i++)
{
printf("%d ", arr[i]);
}
return 0;
}
F9和F5配合使用
就是说如果向上面这行代码最后
for (i = 0; i < 10; i++)
{
printf("%d ", arr[i]);
}
这段出现了错误我们要观察它,就用F9设置一个断点,然后用F5开始调试,迅速往下走,然后到断点处停下,然后用F10往下走
可以设置多个断点,用F5直接到第一个断点,观察完第一个断点后,取消断点,再按F5,到下一个断点
这就是断点的好处,你想让他停那里,就把他设置在哪里,给他营造条件,让他跳到这个断点处
也可以设置条件,假如在i==5时你设置断点,你在条件中设置i==5,按F5,代码直接拉到I=5(就是说,你设置了条件,当条件满足你设置的条件,会触发断点
在这个里面用F10和F11看情况,想进入函数用F11,不想进入用F10
F10
逐过程,通常用来处理一个过程,一个过程可以是一次函数调用,或者是一条语句。
F11
逐语句,就是每次都执行一条语句,但是这个快捷键可以使我们的执行逻辑进入函数内部(这是最
长用的)。
遇到像下面这样的普通语句时,F10和F11没有区别
int main()
{
int arr[10] = { 0 };
int i = 0;
for (i = 0; i < 10; i++)
{
arr[i] = 10 - i;
}
for (i = 0; i < 10; i++)
{
printf("%d ", arr[i]);
}
return 0;
}
那区别是什么呢?请看下面代码:
void test()
{
printf("hehe\n");
printf("test\n");
}
int main()
{
int arr[10] = { 0 };
int i = 0;
test();
for (i = 0; i < 10; i++)
{
arr[i] = 10 - i;
}
for (i = 0; i < 10; i++)
{
printf("%d ", arr[i]);
}
return 0;
}
观察他们的区别,要函数调用。
F11当遇到函数调用时,更细致,会进入函数观察函数执行的过程
F10当遇到函数调用时,直接就执行完成,一步到位
CTRL + F5
开始执行不调试,如果你想让程序直接运行起来而不调试就可以直接使用。
(就是说如果你设置了断点,你不想执行断点,直接按ctrl+F5,会直接跳过这些断点,进行代码运行)
3.3 调试的时候查看程序当前信息
3.3.1 查看临时变量的值
在调试开始之后,用于观察变量的值。
这个窗口一定是调试之后才能看见。
自动窗口不用输入变量,他直接随着调试自动加入变量开始自己变化。
局部变量和自动窗口基本一样。
监视中变量和自动窗口中的不一样,他要我们输入变量,想监视谁监视谁,监视窗口非常好用,推荐用监视窗口。
3.3.2 查看内存信息
在调试开始之后,用于观察内存信息。
3.3.3 查看调用堆栈
void test2()
{
printf("test2\n");
}
void test1()
{
test2();
}
void test()
{
test1();
}
int main()
{
test();
return 0;
}
此代码逻辑:mine函数调用test,test调用test1,test1调用test2,此时为我们想知道调用逻辑时,用F10调试起来之后,在调试窗口里面找调用堆栈。
调用堆栈可以看函数调用的逻辑
下面通过图来看:
通过调用堆栈,可以清晰的反应函数的调用关系以及当前调用所处的位置。
3.3.4 查看汇编信息
在调试开始之后,有两种方式转到汇编:
(1)第一种方式:右击鼠标,选择【转到反汇编】:
(2)第二种方式:
可以切换到汇编代码。
3.3.5 查看寄存器信息
可以查看当前运行环境的寄存器的使用信息。
进入寄存器后,如果你想用16进制的表示,在窗口里面进入监视(或自动窗口),右击鼠标就可看见。如下图:
4.多多动手,尝试调试,才能有进步。
- 一定要熟练掌握调试技巧。
- 初学者可能80%的时间在写代码,20%的时间在调试。但是一个程序员可能20%的时间在写程序,但是80%的时间在调试。
- 我们所讲的都是一些简单的调试。 以后可能会出现很复杂调试场景:多线程程序的调试等。
- 多多使用快捷键,提升效率。
5. 一些调试的实例
5.1 实例一
实现代码:求 1!+2!+3! ...+ n! ;不考虑溢出。
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;
}
思路:假设n=3,此时for (i = 1; i <= n; i++)里面可以是1,2,3可以进入以下循环
for (i = 1; i <= n; i++)
{
int j = 0;
for (j = 1; j <= i; j++)
{
ret *= j;
}
sum += ret;
}
自己调试
正确代码:
int main()
{
int i = 0;
int sum = 0;//保存最终结果
int n = 0;
scanf("%d", &n);
for (i = 1; i <= n; i++)
{
int ret = 1;//保存n的阶乘
int j = 0;
for (j = 1; j <= i; j++)
{
ret *= j;
}
sum += ret;
}
printf("%d\n", sum);
return 0;
}
这时候我们如果3,期待输出9,但实际输出的是15。
why?
这里我们就得找我们问题。
1. 首先推测问题出现的原因。初步确定问题可能的原因最好。
2. 实际上手调试很有必要。
3. 调试的时候我们心里有数。
5.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;
}
研究程序死循环的原因。
猜想:
1.i和arr是局部变量,是放在内存栈区上的
2.栈区内存的使用习惯 :先使用高地址处的空间,再使用低地址处的空间
3.开辟空间的时候先写的i,则i在高地址处,arr后开辟,在低地址处
4.又因为数组,随着下标的增长,地址是由高到低变化的
但是这个代码最本质的原因是数组越界导致的死循环
但是在Release和Debug版本地下结果不同,如图:
在Dedug版本底下和Release版本底下情况刚好相反,Release版本中i和arr的位置发生了变化,i在低处,arr在高处,这就是Release编译器的优化。
注:
带学生看【nice公司的笔试题中的有关的题目】,讲解题目的重要性。(看回放2.41)
这道题的答案就是上面那些答案(1,2,3,4)
推荐书《c陷阱和缺陷》
6. 如何写出好(易于调试)的代码。
6.1 优秀的代码定义:
1. 代码运行正常
2. bug很少
3. 效率高
4. 可读性高
5. 可维护性高
6. 注释清晰
7. 文档齐全
常见的coding技巧:
1. 使用assert
2. 尽量使用const
3. 养成良好的编码风格
4. 添加必要的注释
5. 避免编码的陷阱。
6.2 示范:
模拟实现库函数:strcpy
第一种(打印方式):
#include<stdio.h>
#include<string.h>
int main()
{
char arr1[] = "hello bit";
char arr2[20] = { 0 };
strcpy(arr2, arr1);
printf("%s\n", arr2);
}
第二种(打印方式):
#include<stdio.h>
#include<string.h>
int main()
{
char arr1[] = "hello bit";
char arr2[20] = "xxxxxxxxxxxxx";
printf("%s\n", strcpy(arr2, arr1));
return 0;
}
模拟实现库函数:strcpy(那怎么实现呢?)
/模拟实现库函数:strcpy(拷贝字符串的)
#include<stdio.h>
#include <string.h>
void my_strcpy(char* dest, char* src)
{
while (*src != '\0') /这里也可以写成while (*src)
{
*dest = *src;/j就是指arr1中hello bit中h和arr2中第一个x相等(这里拷贝hello bit)
dest++;
src++;
}
*dest = *src;/ 这里拷贝\0
}
int main()
{
char arr1[] = "hello bit";/首先准备一个字符串
char arr2[20] = "xxxxxxxxxxxxx";
my_strcpy(arr2, arr1);
printf("%s\n", arr2);
return 0;
}
这个代码不好,他只是之一定程度上完成了任务而已,他并不是一个非常好的代码,原因如下:
此时我们可以看到编译器崩了,为什么呢?
因为把p传过去void my_strcpy(char* dest, char* src)中 *dest是空指针,而*dest = *src中*dest是空指针解引用,就有问题。此时我们不知道问题所在时,应该断言,如下:
断言:assert() 包含头文件:#include<assert.h>
运行结果如下:
这里直接告诉我们在43行报错,很方便,很清晰。
当我们希望某个事情不发生的时候,就断言一下,当他发生了时,就会报错
如果我们这里传得不是NULL,结果会怎样呢?如下:
此时,代码就像什么都没有发生一样。所以说使用断言,当他出现错误时会报错,不出现错误时毫不影响代码运行结果。
继优化代码:
void my_strcpy(char* dest, char* src)
{
//断言
assert(dest != NULL);
assert(src != NULL);
while (*src != '\0')
{
*dest = *src;/这里拷贝hello bit
dest++;
src++;
}
*dest = *src;/ 这里拷贝\0
}
这里上面既要拷贝hello bit,下面又要拷贝\0,能不能将这两个拷贝放在一起拷贝呢?
可以,如下:
#include<stdio.h>
#include <string.h>
#include <assert.h>
void my_strcpy(char* dest, char* src)
{
assert(dest != NULL);
assert(src != NULL);
while (*dest = *src) 这里就成了赋值表达式,把*src赋值给*dest ,一直赋值到\0,为假,循环停止
{
dest++;
src++;
}
}
int main()
{
char arr1[] = "hello bit";
char arr2[20] = "xxxxxxxxxxxxx";
char* p = NULL;
my_strcpy(arr2, arr1);
printf("%s\n", arr2);
return 0;
}
另一种方法:
#include<stdio.h>
#include <string.h>
#include <assert.h>
void my_strcpy(char* dest, char* src)
{
assert(dest != NULL);
assert(src != NULL);
while (*dest++ = *src++) 后置++,意思是,原来dest的值解引用,用完之后++,和上面的一样
{
;/空语句
}
}
int main()
{
char arr1[] = "hello bit";
char arr2[20] = "xxxxxxxxxxxxx";
char* p = NULL;
my_strcpy(arr2, arr1);
printf("%s\n", arr2);
return 0;
}
再优化:
上面我们说到strcpy函数的返回类型是char*,返回的char*返回的是目标函数的起始地址。
/ 函数返回的是目标空间的起始地址
#include<stdio.h>
#include <string.h>
#include <assert.h>
char* my_strcpy(char* dest, char * src)/返回的是目标函数的起始地址,这里就不能用void,要用char*
{
char* ret = dest;
//断言
assert(dest != NULL);
assert(src != NULL);
while (*dest++ = *src++)
;//空语句
return ret;/这里不能写成return dest,因为它加加往后走了,所以在前面留下一个值char* ret = dest,最终让他返回ret就可以了
}
int main()
{
char arr1[] = "hello bit";
char arr2[20] = "xxxxxxxxxxxxx";
char* p = NULL;
printf("%s\n", my_strcpy(arr2, arr1));
return 0;
}
此时,看下图:
我们可以发现,两个图是有区别的,我们发现,区别是上图中有const,那const有什么用呢?
接下来我们继续看。
#define _CRT_SECURE_NO_WARNINGS 1
#include <stdio.h>
#include <string.h>
#include <assert.h>
char* my_strcpy(char* dest, char * src)
{
char* ret = dest;
//断言
assert(dest != NULL);
assert(src != NULL);
while (*src++ = *dest++) 假如我们在这里不小心把*dest和src的位置交换了,这里就出现了错误
;//空语句
return ret;
}
int main()
{
char arr1[] = "hello bit";
char arr2[20] = "xxxxxxxxxxxxx";
char* p = NULL;
//my_strcpy(arr2, arr1);
//printf("%s\n", arr2);
printf("%s\n", my_strcpy(arr2, arr1));
return 0;
}
假如我们在while (*src++ = *dest++) 中不小心把*dest和src的位置交换了,这里就出现了错误,如下图:
但是当我们像上图中加上const,即使你里面写错了,他编译器会直接告诉你哪里写错了,如下图:
下面,我们就来了解一下const的使用方式:
/改值
int num = 10;num是变量
num = 20;可以把他的值改成这样
int* p = #把变量的地址交给指针,通过指针解引用,这样的方式也把num的值改了
*p = 200;
int n = 100;这里n=100,他是个变量,可以将他改成200,可以改
n = 200;
此时有人就想,给他加个const,会怎样呢?
const int n = 100;
n = 200;/err
这里一但加了const,这里的n就具有常属性,常属性的变量是不能改的,所以这样的写法是错误的
此时又有人想,既然不能如上改,那这样改呢?如下:
int n = 100;这里n=100,
int* p = &n;把n的地址取出来交给p
*p = 20;这里把*p改成20,能否改呢?我们来验证一下。
此时我们发现,n的值改了。 但是,这种写法是错误的,是违背原则的,因为我们利用const这个关键字,就是使其的值不能改,结果这里用其他途径把它的值给改了,这就是有违规则的。后来,人们根据这个漏洞,给出策略,如下继续看。
#include<stdio.h>
int main()
{
const int n = 100;
const int* p = &n;
*p = 20;
printf("%d\n", n);
return 0;
}
我们发现,给 int* p = &n加上const之久,程序报错,这里告诉我们,const是可以修饰指针的。
下面我们来了解一下,const修饰指针具有什么样的意思呢?
给一段新的代码:
int main()
{
int m = 10;
int * p = &m;
*p = 0;
printf("%d\n", m);
return 0;
}
正常情况下,这个代码的结果,被改成0了,接下来我们在p的前面加上const,如下:
int main()
{
int m = 10;
/cosnt 可以修饰指针
const int * p = &m;
*p = 0;//err 此时*p=0不行了
printf("%d\n", m);
return 0;
}
那我们换一种方式:
int main()
{
int m = 10;
//cosnt 可以修饰指针
int n = 100; 这里加个变量,方便理解
const int * p = &m; 这里加了const,p还是指针变量,但是*p = 0就无法运行,m改不了,而p = &n则可以运行,此时const在*左边
//*p = 0;//err
p = &n; //ok
printf("%d\n", m);
return 0;
}
另一种情况:
int main()
{
int m = 10;
//cosnt 可以修饰指针
int n = 100;
int const*p = &m;将const的位置放在这里,和上面情况一样,还是*p = 0报错,而p=&n可以运行
*p = 0;//err
p=&n; //ok
printf("%d\n", m);
return 0;
}
再看另一种情况:
int main()
{
int m = 10;
//cosnt 可以修饰指针
int n = 100;
int * const p = &m;此时当const放在*右边的时候,*p = 0可以运行,p = &n则报错,无法运行,和上面两种情况刚好相反
*p = 0;//ok
p = &n; //err
printf("%d\n", m);
return 0;
}
int main()
{
int m = 10;
//cosnt 可以修饰指针
int n = 100;
const int * const p = &m;此时,当左边和右边都加了const之后,*p = 0和p = &n都报错,都无法运行
*p = 0;//err
p = &n; //err
printf("%d\n", m);
return 0;
}
总结:
const 修饰指针的时候
1.当const 放在*的左边的时候,限制的是指针指向的内容,不能通过指针变量改变指针指向的内容,但是指针变量的本身是可以改变的
2.当const 放在*的右边的时候,限制的是指针变量本身,指针变量的本身是不能改变的,但是指针指向的内容是可以通过指针来改变的
此时,我们回过头来看上面代码
#define _CRT_SECURE_NO_WARNINGS 1
#include <stdio.h>
#include <string.h>
#include <assert.h>
char* my_strcpy(char* dest, const char * src)
{
char* ret = dest;
//断言
assert(dest != NULL);
assert(src != NULL);
while (*dest++ = *src++)
;//空语句
return ret;
}
int main()
{
char arr1[] = "hello bit";
char arr2[20] = "xxxxxxxxxxxxx";
char* p = NULL;
//my_strcpy(arr2, arr1);
//printf("%s\n", arr2);
printf("%s\n", my_strcpy(arr2, arr1));
return 0;
}
当我们 my_strcpy这个函数的目的,是把 char * src(原字符串)拷贝到char* dest(目标空间)里面去,
我们的目的就是不希望,原字符串被修改所以我们要加入const,const加在char * src的左边,限制了*src,
如果有人在while (*dest++ = *src++) 这里把dest赋值给src(如这种形式while (*src++ = *dest++) ),
就会报错
练习:
- 模拟实现一个strlen函数(自己动手模拟一下,运用到上面的assert和const函数)
#include <stdio.h>
int my_strlen(const char* str)
{
int count = 0;
assert(str != NULL);
while (*str)//判断字符串是否结束
{
count++;
str++;
}
return count;
}
int main()
{
const char* p = "abcdef";
//测试
int len = my_strlen(p);
printf("len = %d\n", len);
return 0;
}
模拟实现一个strlen函数
assert
const
size_t 是专门为sizeof 设计的一个返回类型
size_t的本质是unsigned int / unsigned long (无符号)
无符号数保证数的范围是>=0
%zd专门来打印size_t类型的值的
%u用来打印无符号整数的(当不支持%zd打印的时候,用%u打印,他们两个没太大区别)
size_t my_strlen(const char* str)
{
assert(str);
size_t count = 0;这里设置成size_t就和上面统一了
while (*str)
{
count++;
str++;
}
return count;
}
int main()
{
char arr[] = "abc";
size_t len = my_strlen(arr);
printf("%zd\n", len);
return 0;
}
7. 编程常见的错误
7.1 编译型错误
直接看错误提示信息(双击),解决问题。或者凭借经验就可以搞定。相对来说简单。
编译型错误一般都是语法错误,都是在编译期间产生的错误。他会直接告诉你哪里错啦
7.2 链接型错误
看错误提示信息,主要在代码中找到错误信息中的标识符,然后定位问题所在。一般是标识符名不
存在或者拼写错误。
链接型错误是链接期间发现的错误 。他前面都有一个LINK...,这种错误都是链接期间发生的错误。而link.exe是连接器,上面是人家缩写了,这个代码是拼写错误
标识符名指
int Add(int x, int y)
{
return x + y;
}
7.3 运行时错误
借助调试,逐步定位问题。最难搞。
#include<stdio.h>
int Add(int x, int y)
{
return x - y;
}
int main()
{
int ret = add(2, 3);
printf("%d\n", ret);
return 0;
}
本来是想加,但是结果错误
运行时错误就是代码能运行,没有语法错误,没有链接型错误,但是结果是错误的,逻辑上出现了问题。这时候我们要借助调试发现问题。
温馨提示
做一个有心人,积累排错经验。
讲解重点:
介绍每种错误怎么产生,出现之后如何解决。