C语言初阶笔记程序员必须要会的实用调试技巧(含库函数strcpy和strlen的模拟实现)!!

Posted  Do

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C语言初阶笔记程序员必须要会的实用调试技巧(含库函数strcpy和strlen的模拟实现)!!相关的知识,希望对你有一定的参考价值。

目录

 

为什么要学习调试?

什么是bug?

调试是什么,有多重要?

调试的概念及重要性

调试的步骤

Debug和Release的介绍

windows环境调试介绍

1. 调试环境的准备

2. 学会快捷键

F5

F9

F10

F11

CTRL + F5

3. 调试的时候查看程序当前信息

查看临时变量的值

查看内存信息

查看调用堆栈

查看汇编信息

查看寄存器信息

4.自己多动手,尝试调试,才有进步

一些调试的实例

实例一:

实例二:

如何写出好(易于调试)的代码

优秀的代码

常见的coding技巧:

示例1:

模拟实现库函数:strcpy

初始my_strcpy函数

优化my_strcpy函数的while部分

assert()的作用

加入assert优化my_strcpy函数

 const 的作用

完整版优化的my_strcpy函数

示例2:

模拟库函数strlen函数的实现

编程常见的错误

常见的错误分类:

1.编译型错误

2.链接型错误

3.运行时错误

总结


为什么要学习调试?

调试(Debug)是作为一个程序员必须要学会的东西,学会调试可以极大的提高开发效率,排错时间,很多人不喜欢调试,但我认为这是一个很不可取的选择,调试的时候能让我们看到程序的执行顺序、步骤以及过程等,调试的时候可以让我们监视代码中各个变量的情况,调试让我们可以让我们快速的找出错误的根源。可见调试是至关重要的。

什么是bug?

 bug(计算机领域漏洞),bug是计算机领域专业术语,bug原意是“臭虫”,现在用来指代计算机上存在的漏洞,原因是系统安全策略上存在的缺陷,有攻击者能够在未授权的情况下访问的危害。bug狭义的概念是指软件程序漏洞或缺陷广义的概念还包括测试工程师或用户所发现和提出的软件可更改的细节、或与需求文档存在差异的功能实现等。

调试是什么,有多重要?

调试的概念及重要性

c语言常用的编译器就是visual stdio,visual stdio功能强大,特别好用的一款工具,所以这里我们讲的调试围绕visual stdio展开:

程序调试是将编制的程序投入实际运行前,用手工或编译程序等方法进行测试,修正语法错误和逻辑错误的过程。这是保证计算机信息系统正确性的必不可少的步骤。编完计算机程序,必须送入计算机中测试。根据测试时所发现的错误,进一步诊断,找出原因和具体的位置进行修正。

简单来说,调试(英语:Debugging / Debug),又称除错,是发现和减少计算机程序或电子仪器设备中程序错误的一个过程

调试的步骤

  • 发现程序错误的存在
  • 以隔离、消除等方式对错误进行定位
  • 确定错误产生的原因
  • 提出纠正错误的解决办法
  • 对程序错误予以改正,重新测试

 

Debug和Release的介绍

Debug和Release是visual stdio的两种调试模式或者说调试环境,是我们调试程序前必备的前提。

Debug 通常称为调试版本,它包含调试信息,并且不作任何优化,便于程序员调试程序。

Release 称为发布版本,它往往是进行了各种优化,使得程序在代码大小和运行速度上都是最优 的,以便用户很好地使用。

请看代码:

#include<stdio.h>
int main()
{
	int a = 3;
	int b = 5;
	printf("%d\\n", a + b);
	return 0;
}

这是在Debug环境下生成的程序文件:

然而这是在Release环境下生成的文件:

再看Debug和Release下的反汇编代码对比:

 

所以我们说调试就是在Debug版本的环境中,找代码中潜伏的问题的一个过程。 那编译器进行了哪些优化呢? 请看如下代码:

#include <stdio.h>
int main()
{
    int i = 0;
    int arr[10] = { 0 };、
    printf("%p\\n",&i);
    printf("%P\\n",&arr[i]);
    for (i = 0; i <= 12; i++)
    {
        arr[i] = 0;
        printf("hehe\\n");
    }
    return 0;
}

如果是 debug 模式去编译,程序的结果是死循环:

如果是 release 模式去编译,程序没有死循环。 那他们之间有什么区别呢? 就是因为优化导致的:

 

变量在内存中开辟的顺序发生了变化,影响到了程序执行的结果。


windows环境调试介绍

1. 调试环境的准备

在环境中选择 debug 选项,才能使代码正常调试。

 

2. 学会快捷键

最常使用的几个快捷键:

F5

启动调试,经常用来直接调到下一个断点处。如果没有设置断点则按f5就直接会跳到return 0那里或者是输出窗口一闪而过,什么也不干。

F9

创建断点和取消断点

断点的重要作用,可以在程序的任意位置设置断点。这样就可以使得程序在想要的位置随意停止执行,继而一步步执行下去(通常配合f5使用)

就想这样:

 

将鼠标光标点到自己想要观察的地方,按下f9就成功设置了断点,当按下f5时,调试直接从这个地方开始,而要执行下一步语句又要怎么办呢?

那么f10就发挥作用了:

F10

逐过程,通常用来处理一个过程,一个过程可以是一次函数调用,或者是一条语句

按下f10就到下一步了,虽然f10是逐语句执行,那遇到函数调用,他还会进入函数内部吗?

答案是不会的,这里就不得不说f11的作用了。

F11

逐语句,就是每次都执行一条语句,但是这个快捷键可以使我们的执行逻辑进入函数内部(这是最常用的)。

f10和f11最明显的区别就是按f11可以进入调用函数内部,但是f10不可以。

CTRL + F5

开始执行不调试,如果你想让程序直接运行起来而不调试就可以直接使用。

这些都是最常用的快捷键,只要会用这些,相信对大家的帮助会非常大的。当然还有很多快捷键,想知道的话就参考ta吧:VS常用快捷键,里面介绍的很详细哦。

 

在调试的时候,我们不能光知道怎么调试,也要知道怎么观察一些变量的变化过程,明白代码运行的逻辑原理。所以接下来给大家介绍如何查看当前程序的信息:

3. 调试的时候查看程序当前信息

查看临时变量的值

在调试开始之后,用于观察变量的值。

图示:

 

查看内存信息

在调试开始之后,用于观察内存信息

图示:

查看调用堆栈

 

通过调用堆栈,可以清晰的反应函数的调用关系以及当前调用所处的位置

查看汇编信息

在调试开始之后,有两种方式转到汇编:

(1)第一种方式:右击鼠标,选择【转到反汇编】:

 

(2)第二种方式:

这两种方式都可以切换到汇编代码。

查看寄存器信息

查看寄存器有两种方式:

(1)在监视窗口,输入要查看的寄存器信息

 

(2)第二种方式:

 

 

这样就可以查看当前运行环境的寄存器的使用信息啦。

4.自己多动手,尝试调试,才有进步

  • 一定要熟练掌握调试技巧。
  • 初学者可能80%的时间在写代码,20%的时间在调试。但是一个程序员可能20%的时间在写程序,但是80%(可能有点夸张)的时间在调试。
  • 我们所讲的都是一些简单的调试。 以后可能会出现很复杂调试场景:多线程程序的调试等。
  • 多多使用快捷键,提升效率。

一些调试的实例

实例一:

求 1!+2!+3! ...+ n! (不考虑溢出

#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
int main()
{
    int i = 0;
    int sum = 0;
    int n = 0;
    int ret = 1;
    scanf("%d", &n);
    for (i = 1; i <= n; i++)
    {
        int j = 0;
        for (j = 1; j <= i; j++)
        {
            ret *= j;//计算n的阶乘,每个数的阶乘都是从1开始乘
        }
        sum += ret;//计算阶乘的和
    }
    printf("%d\\n", sum);
    return 0;
}

通过分析我们知道这是一个有bug的代码,那我们就可以通过调试来看代码在哪个地方出错了:

 

经过调试发现,当i=3时,ret应该等于6,但是程序显示的是12,说明在i=3时,上一次的ret并没有重置为1,而是用的上一次的2,所以这里的ret就是2*3*2=12。正确的代码应该是每次阶乘的时候都要把ret置为1,从1开始乘,正确的代码应该是这样的:

#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
int main()
{
    int i = 0;
    int sum = 0;
    int n = 0;
    int ret = 1;
    scanf("%d", &n);
    for (i = 1; i <= n; i++)
    {
        int j = 0;
        ret = 1;
        for (j = 1; j <= i; j++)
        {
            ret *= j;//计算n的阶乘,每个数的阶乘都是从1开始乘
        }
        sum += ret;//计算阶乘的和
    }
    printf("%d\\n", sum);
    return 0;
}

所以说调试能够帮我们查找到我们表面上看不出来的错误。当然这是一个比较简单的代码,我们还是可以通过代码分析,不调试就能解决bug的。那么当我们遇到一些不容易发现的bug的时候又该怎么办呢:

实例二:

#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;
}

通过分析我们会认为打印12个“hehe”;但是这会出现死循环:

那么为什么会出现死循环呢?我们可以通过调试来看看:

通过调试的监视窗口和输入窗口我们发现,当i=12时,屏幕打印了12个hehe,但是下一次按f10执行下一语句时,i却变成了0,所以又是一次新的循环,这样下去,就会不断打印hehe。造成死循环。那么为什么会出现把i置0的情况呢?

其实原因是这样的:

  1. 首先i和arr是局部变量,被存放在栈区,栈区的使用原则是先使用高地址空间再使用低地址空间,所以先创建的变量会先放在高地址空间。
  2. 由于数值随着下标的增大,地址由低到高,所以arr数组 下标i到12时,已经发生了越界了,导致arr数组把i覆盖,造成了当arr[i]等于0时,i也被改成了0。

所以正确的代码应该是这样的:

有了这些经验,那么我们应该怎样才能写出个更好的代码:

如何写出好(易于调试)的代码

优秀的代码

1.代码运行正常

2. bug很少

3. 效率高

4. 可读性高

5. 可维护性高

6. 注释清晰

7. 文档齐全

常见的coding技巧:

1.使用assert

2. 尽量使用const

3. 养成良好的编码风格

4. 添加必要的注释

5. 避免编码的陷阱


这里我们通过两个实例展示如何一步一步优化代码:

示例1:

模拟实现库函数:strcpy

首先我们来看看标准的库函数strcpy是怎么样的:

下面我们主要针对上图中圈出的这三个点进行一步步优化:

首先拿到这个题,我们写的应该是模仿strcpy的功能自己实现一个函数:

初始my_strcpy函数

#include<stdio.h>
void my_strcpy(char* dest, char* src)
{
	while (*src != '\\0')
	{
		*dest = *src;//把来源的字符串复制到目标字符串
		dest++;
		src++;
	}
	*dest = *src;//循环里没有把‘\\0'复制过去,所以循环跳出时,再复制一次就复制'\\0'了;
}
int main()
{
	char arr1[20] = "***********";
	char arr2[] = "hello";
	my_strcpy(arr1, arr2);
	printf("%s\\n", arr1);

}

但其实这样还能优化,在哪里优化呢?

应该在while循环部分,循环体和循环条件都能优化:

优化my_strcpy函数的while部分

#include<stdio.h>
void my_strcpy(char* dest, char* src)
{
	while (*dest++=*src++)//这样既拷贝了字符串内容,也拷贝了'\\0',拷贝完'\\0'之后,条件为假,就退出拷贝。
	{
		;
	}
	
}
int main()
{
	char arr1[20] = "***********";
	char arr2[] = "hello";
	my_strcpy(arr1, arr2);
	printf("%s\\n", arr1);

}

虽然这样是能运行了,但是由于传参是指针,如果其中有一个是空指针的话,就会出现错误,所以这样的写法缺少安全性,应该加入空指针的判断;

说到空指针判断不得不提到assert函数,下面来看一下关于assert的介绍:

assert()的作用

assert(),断言函数,用于在调试过程中捕捉程序错误

断言”在语文中的意思是“断定”、“十分肯定地说”,在编程中是指对某种假设条件进行检测,如果条件成立就不进行任何操作,如果条件不成立就捕捉到这种错误,并打印出错误信息,终止程序执行。

 

assert(expression) 会对表达式expression进行检测:

  • 如果expression的结果为 0(条件不成立),那么断言失败,表明程序出错,assert() 会向标准输出设备(一般是显示器)打印一条错误信息,并调用 abort() 函数终止程序的执行。
  • 如果expression的结果为非 0(条件成立),那么断言成功,表明程序正确,assert() 不进行任何操作。

 

要打印的错误信息的格式和内容没有统一的规定,这和标准库的具体实现有关(也可以说和编译器有关),但是错误信息至少应该包含以下几个方面的信息:

  • 断言失败的表达式,也即expression
  • 源文件名称;
  • 断言失败的代码的行号。

总结来说,assert用于判断表达式的真假,如果条件为真,则继续执行下面的语句,如果条件为假,则不执行下面的语句。当你出现错误时,运行出来的结果会告诉你哪里出现了错误,帮助程序员去修改bug,就像这样:

当我们把第二个实参改为空指针时,程序出现错误,并且有了assert的判断电脑会告诉我们代码的哪一行出错了,甚至告诉我们哪个文件,哪个项目出错了,就会非常详细。所以说有了assert的判断会使得代码和项目更加安全,当然assert的参数不止是指针,它的表达式是这样的:

void assert (int expression);

还有一点要注意的就是,使用assert函数必须定义头文件<assert.h>。

所以有了assert之后,优化应该是这样的:

加入assert优化my_strcpy函数

#include<stdio.h>
#include<assert.h>
void my_strcpy(char* dest, char* src)
{
	assert(dest);
	//assert(dest != NULL);效果一样
	//assert(src != NULL);
	assert(src);
	while (*dest++=*src++)//这样既拷贝了字符串内容,也拷贝了'\\0',拷贝完'\\0'之后,条件为假,就退出拷贝。
	{
		;
	}
	
}
int main()
{
	char arr1[20] = "***********";
	char arr2[] = "hello";
	my_strcpy(arr1, arr2);
	printf("%s\\n", arr1);

}

上面提到了assert的作用让代码更加安全,这就像代码的安全保障一样,从代码整体性上做了优化,那么下面从strcpy函数本身来做一些优化:

试想一下,如果我们把上面的while循环的判断部分变成*src++=*dest,程序会报错吗?

答案是肯定会的但是如果在*src前面加上const的话,即使写反了,那编译器也会报错,显示语法错误,让程序员不得不改,就像这样:

那么加了const以后到底有什么作用呢,下面来看看:

 const 的作用

前面初始c语言的时候,已经介绍了const,const修饰的这个变量为常变量,不能被修改,但本质是变量。

假设有这样一段代码:

#include <stdio.h>
int main()
{
	
	const int m = 20;
	int* p = &m;
	*p = 20;
	printf("%d\\n", m);
}

const修饰m,那么是不是m的值不会被改变呢?

通过运行结果我们看到,m的值还是被改了,因为指针p得到了m的地址,再对地址解引用改变m的值。

那么怎样才能保证地址不被改变呢,根据前面的经验是不是只要在指向地址的指针p前面加上const就可以呢?

答案是的,我们可以来看看指针前面加了const后变成什么样了:

结果是运行不了,出现错误了。

因为在这里const放在*的左边,修饰的*p,表示指针指向的内容m的数值不能通过指针来改变,但是可以指针可以被改变。

那如果把const放在*的右边,又会是什么样的结果呢:

然而在这里,const放在*的右边,修饰的是指针p,表示指针p不能被改变,但是指针p指向的内容m可以被改变

也就可以这么理解:

(1)当const放在*左边时

(2)当const放在*右边时:


 

综合以上assert,const的作用以及考虑strcpy的返回值,我们可以对我们上面写的my_strcpy函数进行如下优化:

完整版优化的my_strcpy函数

#include<stdio.h>
#include<assert.h>
char* my_strcpy(char* dest, const char* src)
{
	assert(dest);//断言,防止dest为空指针,如果为空指针,则为假,不执行下面语句。
	assert(src);//同上
	char* ret = dest;//strcpy函数返回目标空间的起始地址,ret为起始地址。
	while (*dest++ = *src++)
	{
		;//后置++,先解引用,让src指向的字符赋值给dest指向的字符,直到把'\\0'也赋值,结果为假,不在赋值。
	}
	return ret;//返回目标空间起始地址。
}
int main()
{
	char arr1[10] = "*********";
	char arr2[10] = "yes";
	printf("%s", my_strcpy(arr1, arr2));//返回目标空间的地址所代表的字符串。
	return 0;
}

在这里我们也知道了const的作用:

const修饰指针变量的时候:

1. const如果放在*的左边,修饰的是指针指向的内容,保证指针指向的内容不能通过指针来改变。但是指针变量本身的内容可变。

2. const如果放在*的右边,修饰的是指针变量本身,保证了指针变量的内容不能修改,但是指针指向的内容,可以通过指针改变。


知道了strcpy函数的模拟实现,我们也可以来实现一下strlen的模拟:

示例2:

模拟库函数strlen函数的实现

首先我们需要了解库函数strlen的语法结构:

所以我们要从返回值和const修饰指针参数入手:

#include<stdio.h>
#include<assert.h>
size_t  my_strlen(const char* arr)//模拟库函数strlen的实现
{
	//size_t换为int也行,长度都是整数
	int ret = 0;
	assert(arr);//断言,防止arr为空指针
	while (*arr++)//先解引用,在++,当*arr为'\\0'时,退出。
	{
		ret++;//字符个数++
	}
	return ret;
}
int main()
{
	char arr[20] = "abcdefg";
	
	printf("字符串长度为:%d\\n", my_strlen(arr));
}

这是不是跟我们之前写的my_strlen有很大的差别,加入了assert和const之后让代码变得更加安全可靠,我们以后写代码的时候可以加入assert和const。


在我们编程时会出现各种错误,我们也要知道他们分别是什么错误:

编程常见的错误

常见的错误分类:

1.编译型错误

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

2.链接型错误

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

3.运行时错误

借助调试和上面的快捷键,逐步定位问题。


总结

作为程序员我们不仅要会写代码,更要会调试的技巧。掌握调试的技能会让我们对编程有更深刻的理解,也帮助我们减少编程的错误。博主总结的这些内容虽然有点多,但是都是干货,相信会让大家收获很多,在编程这条道路上,我们要做一个有心人,积累排错经验,这样才能成为一个大佬。好了,本章的内容就结束了,谢谢大家的阅读,请给我一键三连吧!!

 

以上是关于C语言初阶笔记程序员必须要会的实用调试技巧(含库函数strcpy和strlen的模拟实现)!!的主要内容,如果未能解决你的问题,请参考以下文章

C语言初阶笔记程序员必须要会的实用调试技巧(含库函数strcpy和strlen的模拟实现)!!

C语言初阶笔记解题篇必须要会的循环试题!!

React Native调试实用技巧,React Native开发者必会的调试技巧

C语言进阶学习笔记七程序执行+调试技巧(实用技巧篇)

C语言学习笔记实用的调试技巧

08实用调试技巧