C语言初阶-实用调试技巧(2)

简介: C语言初阶-实用调试技巧

3.3.5查看寄存器信息

4.一些调试的实例

实例1:

题目:实现1!+2!+3!+4!+......+n!

#define  _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
int main()
{
  int n = 0;
  scanf("%d", &n);
  int i = 0;
  int j = 0;
  int ret = 1;
  int sum = 0;
  for (i = 1; i <= n; i++)
  {
    for (j = 1; j <= i; j++)
    {
      ret *= j;
    }
    sum += ret;
  }
  printf("%d\n", sum);
  return 0;
}

如果我们输入3,应该输出9,结果输出的是15,为什么?

自行尝试调试以上代码,找出错误,并修正代码。

实例2:

再看一段代码:

#define  _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
int main()
{
  int i = 0;
  int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
  for (i = 0; i <= 12; i++)
  {
    arr[i] = 0;
    printf("hehe\n");
  }
  return 0;
}

在X86环境下运行,会出现怎样的结果?

运行一下会发现,一直在打印hehe,陷入死循环。

根据上述代码能够发现,for循环在访问数组时存在越界访问的问题,但这解释不了死循环,一般数组越界访问,运行时程序会崩溃。

那造成死循环的原因是什么呢?

先来调试一下会发现:

虽然存在数组越界访问的问题,但是这段代码依然将arr[10]、arr[11]、arr[12]的值改了,此时如果再按F10调试一次会发现i又变成了0,循环又重新开始了。到下一次i又会变成0,i永远满足循环条件,就陷入了死循环。

问题又来了,按照代码,arr[10]和arr[11]确实改成了0,但是为什么arr[12]的值和i的值一样呢?

打开监视窗口,查看一下i和arr[12]的地址会发现:

它们的地址空间竟然是同一个。原来在修改arr[12]的同时,由于它和i的地址空间是同一个,所以i的值也被修改成0。

那为什么它们的地址空间能重合呢?

这就涉及到数据存储的知识了。

前面我们讲过,数据在内存中的存储有三个区域,栈区、堆区、静态区。因为代码中的i和数组arr[10]都是局部变量,所以存放在栈区。

首先我们要知道:栈区的使用习惯是:先使用高地址处的空间,然后使用低地址处的空间

而我们在创建局部变量i和arr[10]时,先创建的i,后创建的arr[10],所以在栈区i的地址空间应该在数组arr[10]之上,又因为数组元素随着下标的增长,地址是由低到高变换的。所以i和arr[10]在内存中的存储应该如下图所示:

随着对数组元素的访问,总有一天会访问到arr[12],巧合的是i地址空间和arr[12]地址空间重合了,导致每次到修改arr[12]的值的时候,都是在同时修改i的值,i的值永远不可能大于12,这就是造成死循环的原因。

如果我们将for循环中的循环条件改为 i <= 11;运行一下会发现,程序崩溃了。

这是因为对数组元素的访问到不了arr[12],所以就不会发生i的值也被修改的问题,此时程序崩溃的原因是数组的越界访问,而之前也越界访问但没有报错的原因是,程序陷入死循环,根本没时间报错。

上文讲过,编译环境分为Debug和Realse两种,案例二是在Realse版本上运行,下面我们试试在Realse版本上编译一下:

#define  _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
int main()
{
  int i = 0;
  int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
  printf("%p\n", &i);
  printf("%p\n", &arr[0]);
  printf("%p\n", &arr[9]);
  for (i = 0; i <= 12; i++)
  {
    arr[i] = 0;
    printf("hehe\n");
  }
  return 0;
}

运行结果:

可以发现,此时并没有发生死循环,并且我们通过打印i、arr[0]、arr[9]的地址得知,此时i的地址空间在数组arr的下面:

这其实就是编译器默认的优化方式。

5.如何写出优秀(易于调试)的代码

5.1优秀的代码:

1.代码运行正常

2.bug很少

3.效率高

4.可读性高

5.可维护高

6.注释清晰

7.文档齐全

常见的coding技巧:

1.使用assert

2.尽量使用const

3.养成良好的编码风格

4.添加必要的注释

5.避免编码陷阱

5.2示范:

模拟实现strcpy函数的功能:

前面讲过strcpy函数,它的功能是拷贝字符串(注意拷贝时将原字符串的“\0”也会拷贝过去):

#define  _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
int main()
{
  char arr1[] = "hello bit";
  char arr2[20] = { 0 };
  strcpy(arr2, arr1);
  printf("%s\n", arr2);
  return 0;
}

运行结果:

下面我们来模拟实现一下:

#define  _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
my_strcpy(char* arr1, char* arr2)
{
  while (*arr1 != '\0')
  {
    *arr2 = *arr1;
    arr1++;
    arr2++;
  }
  *arr2 = *arr1;//\0的拷贝
}
int main()
{
  char arr1[] = "hello bit";
  char arr2[20] = { 0 };
  my_strcpy(arr1,arr2);
  printf("%s\n", arr2);
  return 0;
}

以上代码就可以实现拷贝字符串的功能了,但是上述代码好吗?

不见得。为什么呢?

我们想一下,如果有人在写代码传参时,传了一个空指针,那我们在函数中对空指针解引用是很危险的,代码还能正常运行吗?

很明显,不能了。所以当前代码的处理并不是最优的。那怎么处理呢?

这时候就要用到断言了。

#define  _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<assert.h>
my_strcpy(char* arr1, char* arr2)
{
  assert(arr1 != NULL);//断言
  assert(arr2 != NULL);//断言
  while (*arr1 != '\0')
  {
    *arr2 = *arr1;
    arr1++;
    arr2++;
  }
  *arr2 = *arr1;//\0的拷贝
}
int main()
{
  char arr1[] = "hello bit";
  char arr2[20] = { 0 };
  char* p = NULL;
  my_strcpy(p,arr2);
  printf("%s\n", arr2);
  return 0;
}

此时如果传的是空指针NULL,遇见断言程序就会报错,那么我们就很清楚地知道错误所在,可以及时对代码进行修正。相反,如果没有写断言程序,我们在程序崩了之后还要一步一步的调试寻找错误,这就大大浪费了时间。

注意:在使用断言时,一定要引用头文件<arrert.h>

我们还可以对代码进行优化,

#define  _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<assert.h>
my_strcpy(char* arr1, char* arr2)
{
  assert(arr1 != NULL);//断言
  assert(arr2 != NULL);//断言
  while (*arr2 = *arr1)
  {
    arr1++;
    arr2++;
  }
}
int main()
{
  char arr1[] = "hello bit";
  char arr2[20] = { 0 };
  char* p = NULL;
  my_strcpy(arr1,arr2);
  printf("%s\n", arr2);
  return 0;
}

当然也可以直接将while循环写为:

while (*arr2++ = *arr1++)
  {
    ;
  }

因为strcpy函数返回的是目标数组的起始地址,所以我们还可以对代码进行优化:

 

#define  _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<assert.h>
char* my_strcpy(char* arr1, char* arr2)
{
  char* ret = arr2;
  assert(arr1 != NULL);//断言
  assert(arr2 != NULL);//断言
  while (*arr2++ = *arr1++)
  {
    ;
  }
  return arr2;
}
int main()
{
  char arr1[] = "hello bit";
  char arr2[20] = { 0 };
  char* p = NULL;
  my_strcpy(arr1,arr2);
  printf("%s\n", arr2);
  return 0;
}

那么此时大家就觉得代码已经完美了吗?

当然没有,如果有个程序员在写代码时不小心将while循环中的交换的代码写反了怎么办?

我们要及时发现这个问题呀,这时候就可以用到 const 了。

#define  _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<assert.h>
char* my_strcpy(const char* arr1, char* arr2)//const修饰arr1
{
  char* ret = arr2;
  assert(arr1 != NULL);//断言
  assert(arr2 != NULL);//断言
  while (*arr1++ = *arr2++)//交换顺序写反
  {
    ;
  }
  return arr2;
}
int main()
{
  char arr1[] = "hello bit";
  char arr2[20] = { 0 };
  char* p = NULL;
  my_strcpy(arr1, arr2);
  printf("%s\n", arr2);
  return 0;
}

运行结果:

此时编译器会报错,即使写错了,也可以及时发现错误并修改。

下面我们来仔细讲一下const的用法:

下面我们使用两种方法对定义好的变量num的值进行修改:

//法1:
  int num = 0;
  num = 100;
  //法2:
  int* p = &num;
  *p = 200;
  return 0;

但是我们加上const之后呢?

const int num = 0;
  num = 100;

编译器出现报错:

为什么呢?

我们说过,const修饰的变量具有常属性,它的值不能再被修改。

但是此时能不能用指针来修改变量num的值呢?

int main()
{
  const int num = 0;
  int* p = &num;
  *p = 200;
  printf("%d\n", num);
  return 0;
}

运行结果:

答案是可以的。

那如果用const修饰指针,num的值还能被修改吗?

int main()
{
  const int num = 0;
  const int* p = &num;
  *p = 200;
  printf("%d\n", num);
  return 0;
}

大家自行运行一下会发现,也是不行的,说明const也是可以修饰指针的,并且经过const修饰之后,通过指针对指针所指对象的值也不能进行修改了.

那const修饰指针的时候有什么作用呢?

当我们把const修饰在int前面会发现:

此时通过*p改变变量num的值已经不行了,但是可以改变指针p的指向。

如果const放在int的后面,* 的前面(即int const *p=&num;),运行一下我们会发现结果和上面相同,下面我们将const放在*的后面看结果是否相同:

此时我们发现可以通过*p改变变量num的值,但是不能改变指针p的指向了。

下面我们就可以来总结一下const修饰指针时的作用:

当const 放在*的左边的时候,限制的是指针指向的内容,不能通过指针变量改变指针指向的内容,但是指针变量的本身是可以改变的。

当const 放在*的右边的时候,限制的是指针变量本身,指针变量的本身是不能改变的,但是指针指向的内容是可以通过指针来改变的。

那如果在*号的前后都加上const,那既不能通过*p改变变量num的值,也不能改变指针p的指向。

以上就是我们讲的如何写出一篇优秀代码,下面我们也可以写一段模拟实现strlen函数功能的代码应用一下:

#define  _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<assert.h>
int my_strlen(const char* str)
{
  assert(str);
  int count = 0;
  while (*str)
  {
    count++;
    str++;
  }
  return count;
}
int main()
{
  char arr[] = "abc";
  int len = my_strlen(arr);
  printf("%d\n", len);
  return 0;
}

6.编程常见的错误

6.1编译型错误

直接看错误提示信息(双击),解决问题。或者凭借经验就可以搞定。相对来说简单

例如:

#define  _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
int main()
{
  int n=0
  return 0;
}

像这种忘记写分号的语法错误就是编译型错误,编译器报错后,直接点击报错就能找到错误的代码行进行修改。

6.2链接型错误

看错误提示信息,主要在代码中找到错误信息中的标识符,然后定位问题所在。一般是标识符名不存在或者拼写错误

例如:

#define  _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
int Add(int x, int y)
{
  return x + y;
}
int main()
{
  int ret = add(2, 3);
  return 0;
}

像这种将函数名Add写成add破坏了主函数和Add函数链接,就叫做链接型错误。

编译器报错时,一般前面都有LNK,而且在报错中双击,并不能找到错误的代码行,比较难解决:

上述代码只需将add改为Add就可以成功运行了。

6.3运行时错误

借助调试,逐步定位问题。最难搞

例如:

我们想要实现加法,却写成了减法。

像这种能够成功运行,但是结果不是我们想要的,这就是逻辑上出了问题,要重新考虑写代码的逻辑,这种问题最难解决。需要我们重新将代码梳理一遍甚至几遍才能找到错误。

今天就学到这,未完待续。。。

目录
相关文章
|
24天前
|
NoSQL 编译器 C语言
C语言调试是开发中的重要技能,涵盖基本技巧如打印输出、断点调试和单步执行,以及使用GCC、GDB、Visual Studio和Eclipse CDT等工具。
C语言调试是开发中的重要技能,涵盖基本技巧如打印输出、断点调试和单步执行,以及使用GCC、GDB、Visual Studio和Eclipse CDT等工具。高级技巧包括内存检查、性能分析和符号调试。通过实践案例学习如何有效定位和解决问题,同时注意保持耐心、合理利用工具、记录过程并避免过度调试,以提高编程能力和开发效率。
39 1
|
25天前
|
存储 算法 C语言
用C语言开发游戏的实践过程,包括选择游戏类型、设计游戏框架、实现图形界面、游戏逻辑、调整游戏难度、添加音效音乐、性能优化、测试调试等内容
本文探讨了用C语言开发游戏的实践过程,包括选择游戏类型、设计游戏框架、实现图形界面、游戏逻辑、调整游戏难度、添加音效音乐、性能优化、测试调试等内容,旨在为开发者提供全面的指导和灵感。
42 2
|
6月前
|
C语言
C语言初阶:如何判断是否为素数并且输出
C语言初阶:如何判断是否为素数并且输出
50 0
|
2月前
|
C语言
C语言调试
C语言调试
24 0
|
4月前
|
C语言 索引
C语言编译环境中的 调试功能及常见错误提示
这篇文章介绍了C语言编译环境中的调试功能,包括快捷键操作、块操作、查找替换等,并详细分析了编译中常见的错误类型及其解决方法,同时提供了常见错误信息的索引供参考。
|
5月前
|
编译器 C语言
【C语言初阶】指针篇—下
【C语言初阶】指针篇—下
|
5月前
|
存储 C语言
【C语言初阶】指针篇—上
【C语言初阶】指针篇—上
|
6月前
|
安全 编译器 程序员
【C语言】:VS实用调试技巧和举例详解
【C语言】:VS实用调试技巧和举例详解
56 1
|
6月前
|
存储 编译器 C语言
C语言学习记录——调试技巧(VS2019环境下)
C语言学习记录——调试技巧(VS2019环境下)
61 2
|
6月前
|
C语言
【初始C语言8】详细讲解初阶结构体的知识
【初始C语言8】详细讲解初阶结构体的知识