实用调试技巧(c语言)

之中实用调试技巧

  • 什么是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;把变量的地址交给指针,通过指针解引用,这样的方式也把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;
}
本来是想加,但是结果错误

运行时错误就是代码能运行,没有语法错误,没有链接型错误,但是结果是错误的,逻辑上出现了问题。这时候我们要借助调试发现问题。

温馨提示

做一个有心人,积累排错经验。

讲解重点:

介绍每种错误怎么产生,出现之后如何解决。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值