【C语言】使用指针时出现的各种错误总结以及处理方法

本文详细介绍了C语言中野指针的概念、成因及如何规避,包括指针未初始化、越界访问等问题。同时,讨论了动态内存分配的常见错误,如对NULL指针解引用、越界访问、释放非动态内存、释放内存一部分和重复释放等问题,提供了相应的解决方案。文章强调了初始化指针、避免指针越界、正确释放内存等最佳实践,并通过实例和经典题目帮助读者巩固理解。
摘要由CSDN通过智能技术生成

🏖️作者:@malloc不出对象
⛺专栏:《初识C语言》
👦个人简介:一名双非本科院校大二在读的科班编程菜鸟,努力编程只为赶上各位大佬的步伐🙈🙈
在这里插入图片描述


前言

今天我们要来谈论的是使用指针会出现的各种错误以及如何规避错误的方法,其实呢大家也可以看到关于指针部分我也是花了很多功夫给大家进行讲解总结,由此可见指针的重要性,它也是我们即将学习数据结构的基石,,可以说指针没学好那么你数据结构一定是学不明白的,所以这里强烈建议大家把基础打扎实了再进行下一阶段的学习。

一、什么是野指针

概念:野指针就是指针指向的位置是不可预知的(随机的、不正确的、没有明确限制的)。

对于这个概念我们来看看一个形象的比喻:野指针是十分危险的,我们之前把它比作野狗,野狗是没有主人的,所以它随时可能对路人造成危险到处咬人,那该怎么去解决这个问题呢?最好的办法是限制它的行动嘛,就用一根铁链栓住这条野狗,这样就大概率不会出现咬人的风险了。

二、野指针出现的成因

2.1 指针未进行初始化

我们来简单的看一个例子:

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

大家觉得会打印出什么结果呢?我们一起来看看:

在这里插入图片描述

为什么编译器也会给我们直接就报错了呢?下面我们来分析一下:

首先我们知道局部变量未进行初始化,默认是随机值;那么指针未进行初始化是不是也默认为随机地址,但是你也不知道到底是哪块地址,属不属于这个程序的地址,然后随便通过地址找到了这块内容并且将20给它放进去,这样可能就造成了非法访问内存,所以编译器是不允许这种行为的。

下面再给大家分享一道选择题:
在这里插入图片描述

这道题最主要考的就是指针初始化/赋值的问题,你注意到这个问题了,那么这道题可以直接就选出来了,答案是选D,下面我们简单的分析一下:

选项D,因为指针变量p没有进行初始化,所以指针变量p指向的地址是不确定的,那么scanf(在窗口输入内容)就不能随便对这块空间相当于进行赋值操作,而有的读者又问了,你这是不是由于没加&地址符而导致的呢?我们继续来想一下,&p就是指针变量的地址对吧,那么scanf往这块地址里面输入的内容是不是就是地址(指针)呢?因为指针变量p的内容就是地址(指针)嘛,但是输入的内容(地址)你怎么确定它就是该程序内的地址呢?是你有权限使用的地址呢?所以对于指针变量来说一定要进行初始化/赋值操作,scanf才能往指针变量所指向的地址空间内输入内容,,所以其实大家也可以发现scanf本质上是对指针变量所指向的地址空间进行赋值操作,而不是直接对指针变量进行赋值操作!!

下面还是举个简单的例子让大家明白清楚这个过程,大家先想一想这个过程实际上是在干嘛呢?打印出来的结果取决于什么?又或者是一个错误的程序呢?

#include<stdio.h>

int main()
{
	int a = 10;
	int* p = &a;
	scanf("%d", p);

	printf("a = %d\n", a);
	printf("*p = %d\n", *p);

	return 0;
}

下面我画个图向大家简单描述一下这个过程:

在这里插入图片描述

我们来看看答案:

在这里插入图片描述

2.2 指针越界访问

#include<stdio.h>
int main()
{
    int arr[10]={0};
    int* p = arr;
    for(int i = 0; i < 11; i++)
    {
        *(p+i) = i;    
        printf("%d ",arr[i]);
    }
    return 0;
}

这段代码很明显的指针访问越界了,它访问的最多元素个数只有10个现在让它访问11个,这时候指针就越界非法访问内存空间了,此时当指针指向的范围超出数组arr的范围时,p就是野指针。

在这里插入图片描述

2.3 指针指向已经被释放的空间

其实这个知识点已经在之前的一篇博客中提及过这个问题了,大家可以跳转到这篇博客在讲解free函数部分时讲的非常清楚,这里就不做赘述了。

2.4 return返回局部/临时变量的地址

#include <stdio.h>

char* show()
{
    char str[] = "hello world";
    return str;
} 

int main()
{
    char *s = show();
    printf("%s\n", s);
    
    return 0;
}

接下来我采用调试的方式让大家看的更加清楚:
起始指针变量s存的地址:

在这里插入图片描述

调用完show函数时指针变量s存的地址:

在这里插入图片描述

调用完printf函数之后,指针变量s存的地址未知:

在这里插入图片描述

所以最终我们打印出来的结果为乱码,并且也出现了一个警告,下面我们来解释这个现象:

在这里插入图片描述

函数栈帧的销毁并不是将返回函数的数据真正删除了,而是将该函数代码块内的数据设置为无效。在这篇博客讲解空间复杂度时我详细的讲了计算机中的删除问题,大家如果有不明白的话可以看下这个知识点。

下面看向这段代码,main函数调用show函数,show函数返回到main函数时show函数栈帧被销毁,但此时show函数代码块的数据还是原来的内容"hello world",只不过这个数据是无效的,在下次加载数据时可以被直接覆盖。换而言之,此时main函数内部指针变量s还是保存着"hello world"首字符的地址,但在进行下一步打印结果的时候,我们又调用了printf函数,printf函数开辟一块新的栈帧覆盖掉了原来被销毁的show函数的栈帧,调用完之后printf的栈帧又被销毁,此时“hello world”才是真正的不存在了,所以此时我们的指针变量存的地址就不可预知了,所以结果打印出随机值。

同时,我们可以得出一个结论:return语句不可返回指向“栈内存”的指针(地址),因为该内存在函数体结束时被自动销毁,随时被覆盖掉。

下面继续给大家拓展一下,我们来看看这段代码它会不会打印出随机值呢?

#include <stdio.h>

char* show()
{
    char* p = "hello world";
    return p;
} 

int main()
{
    char* s = show();
    printf("%s\n", s);
    

    return 0;
}

首先我们来分析一下这段代码跟上段代码有什么区别,上段代码的这样子来初始化的char str[] = “hello world”;此时的str是一个字符数组它是在栈上开辟的一块空间,它的内容是可以修改的;而这段代码char* p = “hello world”,字符指针变量p它存放的是首字符的地址,它的内容是不可进行更改的,那么它们的区别又在哪里呢?

我们从内存分布的角度来讲,常量字符串放在常量区,而上段代码的str是一个字符数组它是在栈上开辟一段连续的空间的。换而言之,上段代码的str数组在show函数返回时,执行到printf函数时,show函数里面的内容被覆盖掉了,而该段代码是放在常量区的,它的内容是不会被修改覆盖的,return p;拷贝一份p的地址,main函数中指针变量s接收该地址的信息,然后根据接收到的字符首地址访问这块空间,所以打印出来就是“hello world”。其实关于字符数组与字符串的区别我是早就讲过了的,如果是一直把我博客全部认真看完的伙计,这道题肯定能迅速的反应出来它们之间的区别。

下面我们来看看结果:

在这里插入图片描述

三、常见的动态内存分配错误

3.1 对NULL指针的解引用操作

我们来看一个例子:

#include<stdio.h>
#include<stdlib.h>
int main()
{
    int* p = (int*)malloc(INT_MAX);
    *p = 0;

    return 0;
}

我们来分析一下这个问题,malloc申请空间可能会返回空指针,对空指针解引用会造成内存的非法访问,可能会导致程序崩溃等一系列的问题,所以我们再开辟完空间之后要先进行判空操作。

正确的写法应该为这样:

#include<stdio.h>
#include<stdlib.h>
int main()
{
    int* p = (int*)malloc(INT_MAX); 
    if(p == NULL)
    {
         return 0;
    }
    *p = 0;

    return 0;
}

3.2 对动态开辟空间的越界访问

#include <stdio.h>
#include <stdlib.h>

int main()
{
     int *p = (int *)malloc(10*sizeof(int)); 
     if(NULL == p) 
     { 
         exit(-1);
     } 
     
     int i = 0; 
     for(i = 0; i <= 10; i++) 
     { 
         *(p+i) = i;
     } 
     free(p);
     p = NULL;
     
     return 0;
}

这段代码跟之前讲到的指针越界问题本质上都是因为指针越界访问了,只不过这里是动态申请的堆空间访问越界了,报错信息与上面稍有不同,这其实没啥影响我们明白原理就好了。

在这里插入图片描述

我们的解决方案是:编译器是不会提示这种错误的,所以是需要我们自行去检查是否越界问题的,其实越界了编译器也会出现一系列问题提示的,我们根据提示找到问题所在就可以了,但还是要避免少出现这种错误,毕竟bug少一事就是一事嘛🙈🙈

3.3 对非动态开辟内存使用free释放

#include<stdio.h>
int main()
{
    int a = 10; 
    int *p = &a; 
    free(p);//对非动态开辟内存进行释放
    p = NULL;
    return 0;
}

我们知道指针p是在栈区上的,而free只能释放堆区动态开辟的空间;动态内存也就是分布在堆区,非动态内存也就是分布在栈区,栈是系统管理的,当然不能free;你申请内存时,实际上系统是把一块标记为未使用的内存地址返回给你,然后把那个地址标记为已使用;你释放的时候,实际上就是把那块内存标记为未使用,你要对一个已经标记为使用的内存再标记成未使用,当然就不可以了!

在这里插入图片描述

3.4 使用free释放一块动态开辟内存的一部分

#include<stdio.h>
#include<stdlib.h>

int main()
{
    int *p = (int*)malloc(100); 
    if(p == NULL)
    {
        return 0;    
    }
    p++; 
    free(p);
    p = NULL;
    return 0;
}

在动态内存分配的那篇文章我提及过这个问题:free只能是从指向动态内存的起始位置开始释放的,它要释放的是一个完整的空间,而不能分块进行多次释放。这里的指针变量p改变了,那么此时的起始地址就变化了,所以free时会发生错误。

在这里插入图片描述

关于这个知识点我们之后也常见的会先用一个指针变量保存着一块空间的起始地址,这样对这块空间进行指针偏移操作时我们的指向的位置发生变化,而最后我们返回的要是起始地址,所以一开始的指针变量此时就起了作用,它指向的是这块空间的起始地址,所以最后返回它就可以了。为什么一定要返回起始地址,,关于指针部分最重要的其实就是起始地址,只有找到了起始地址你才能进行相应的指针偏移操作访问到这块空间访问内的其他内容,起始地址就是老大!!

3.5 对同一块动态内存多次释放

#include<stdio.h>
#include<stdlib.h>

int main()
{


    int* p = (int*)malloc(100);
    if(p == NULL)
    {
        return 0;    
    }
    free(p);
    free(p);

    p = NULL;

    return 0;
}

这段代码的指针变量p指向的空间重复进行释放了,指针指向的开辟空间只能释放一次,若多次free可能引发不可预测的问题,你想一下如果我们指向的空间释放之后,这块空间立马又被系统分配给其他程序,你再次进行释放编译器会让你进行嘛?

在这里插入图片描述

那么我们应该如何避免这种问题呢?我们应该在每次free掉原来的地址之后,将原地址置为NULL,这样即使再进行free,我们也知道free空指针是不执行任何操作的。

在这里插入图片描述

此时这样就不会出现问题了。

3.6 动态开辟内存忘记释放(内存泄漏)

关于这点其实很容易理解,我们可以这样来想:只要动态申请了堆空间没有进行free释放都可以叫内存泄露,关于内存泄露我在动态内存分配那篇文章也已经讲过。对于一般的程序来说就比如你动态开辟了一个节点等没有及时释放,对于程序来说其实是无伤大雅的,因为进程结束了内存泄露就不存在了,但我们平时就要养成好的习惯有借有还;而对于永远不会结束的进程来说,内存泄露就是一个非常大的问题了,所以我们的解决方法就是随时记得释放。

3.7 几个经典的动态内存分配题

题目一:

大家想一想这段代码中出现了哪些错误:

#include<stdio.h>
#include<stdlib.h>

void GetMemory(char* p)
{
    p = (char*)malloc(100);
}

void Test(void)
{
    char* str = NULL;
    GetMemory(str);
    strcpy(str, "hello world");
    printf(str);//相当于printf("%s",str);举个例子char* str = "abcdefg" == > str = "abcdefg"; printf("abcdefg") == printf(str)
}

int main()
{
    Test();

    return 0;
}

首先映入眼帘的是GetMemory函数,它在内部开辟了一块空间但是并没有释放,这就会造成内存泄露,这是第一个错误的地方。
第二个错误,str其实还是NULL,在strcpy时它不能被访问,所以会造成程序崩溃。我们来分析一下,在main函数中我们先调用Test函数,进入Test函数内部时,首先将NULL赋值给str,之后调用GetMemory函数,str是一个指针变量,它进行的是值传递传递的是变量本身而非地址,所以GetMemory函数的形参的改变并不会影响str,进入GetMemory函数内部,首先开辟一块100个字节大小的空间,指针变量p存放的是开辟的这块空间的首地址;而在GetMemory函数结束之后指针变量p就被销毁了(注意了此时的指针变量p虽然是一个局部变量,但是它保存的是堆空间的起始地址),无法通过这个地址找到它的内容了;所以此时我们的str还是为NULL,我们知道NULL是不能被访问的,所以在strcpy时程序会崩溃。
通过这段代码的分析其实我们很快能想出一个解决方案那就是在指针变量p销毁之前返回它指向的地址,因为我们知道堆空间是随着程序的退出而退出的,换而言之,这块空间是不会随着GetMemory函数的调用结束而销毁的,str接收到这块空间的起始地址就能使用这块空间了。

如果我们想得到我们想要的结果就应该这么写:

//第一种改正方式:址传递,改变实参会影响实参,所以p与str的操作可以认为是同步的,都是对同一块空间进行操作
#include<stdio.h>
#include<stdlib.h>
void GetMemory(char** p)
{
    *p = (char*)malloc(100);
}

void Test(void)
{
    char* str = NULL;
    GetMemory(&str);
    strcpy(str, "hello world"); 
    printf(str);
    
    free(str);
    str = NULL;                           
}

int main()
{
    Test();

    return 0;
}

//第二种改正方式:返回p的地址(此时return p;是堆空间的起始地址,而非局部/临时变量的地址),指针变量p在GetMemory函数里面接收到的是一块动态开辟的起始地址,而我们知道堆空间是随着程序的退出而退出的,
//换而言之,这块空间是不会随着GetMemory函数的调用结束而销毁的,所以str接收到这块空间的起始地址就能使用这块空间了。
#include<stdio.h>
#include<stdlib.h>

char* GetMemory(char* p)
{
    p = (char*)malloc(100); 
    return p;                  
}

void Test(void)
{
    char* str = NULL;
    str = GetMemory(str);
    strcpy(str, "hello world");        
    printf(str);
    
    free(str);
    str = NULL;
}

int main()
{
    Test();

    return 0;
}

题目二

#include<stdio.h>
char* GetMemory()
{
    char p[] = "hello world";   
    return p;
}

void Test(void)
{
    char* str = NULL;
    str = GetMemory();
    printf(str);
}

int main()
{
    Test();

    return 0;
}

大家有没有感觉这段代码很熟悉,其实在上面是已经讲解过几乎差不多的题了,那么这里就不做说明了,这里打印的结果会是一个随机值。

题目三

#include<stdio.h>
#include<stdlib.h>
void GetMemory(char** p, int num)
{
    *p = (char*)malloc(num);
}

void Test(void)
{
    char* str = NULL;
    GetMemory(&str, 100);
    strcpy(str, "hello");
    printf(str);
}

int main()
{
    Test();

    return 0;
}

这段代码进行的是址传递,打印的结果也是如我们所想的那样,只有一个问题就是动态申请的空间没有手动释放掉,会导致内存泄露。所以正确的写法就是将str使用完之后free释放掉,然后将其置为NULL。

题目四

#include<stdio.h>
#include<stdlib.h>
void Test(void)
{
    char* str = (char*)malloc(100);
    strcpy(str, "hello");
    free(str);
    if (str != NULL)
    {
        strcpy(str, "world");
        printf(str);
    }
}

int main()
{
    Test();

    return 0;
}

这段代码呢其实主要的是想让我们了解free的作用,free释放的本质并不是将这块空间的起始地址置为NULL,而是将这块空间做了一个标识,标识这块空间已经被释放了不能使用这块空间的内容了,所以这段代码free释放掉str之后,实际上呢str的地址还是没变,在判空之后会进入if条件执行strcpy操作,但我们知道这段已经被释放的空间是不能进行随便访问的,会造成非法访问内存,这就相当于没系保险在开车,不出事故算是运气,出了事故就是一命呜呼。问题造成的结果是“轻则扣奖金,重则被鄙视”!更有甚者数据完全改写,而找不到问题所在!所以打印出world是一个偶然事件,因为free之后str就成了一个野指针。

在VS下确实打印出world了:

在这里插入图片描述

在devc++上我们看到运行时程序已经崩溃了:

在这里插入图片描述

正确的写法就是释放完str之后将其置为NULL,它也就不会进入if条件中执行strcpy相关操作了。

关于这几道经典的题目其实是来自《高质量的C/C++编程》这本经典的书籍,大家有兴趣的话可以好好看看这本书,也是可以规范平时我们的代码风格习惯。


四、如何规避野指针以及动态内存分配时出现的各种错误

其实通过上述讲解动态内存分配出现的各种错误,我们也是发现部分其实就是野指针的问题所导致的,下面我对这部分进行总结一下。

如何规避野指针?

1. 一定要对指针进行初始化/赋值操作。
2. 小心指针越界访问。这个问题需自己去注意,如果没注意到其实程序也会崩溃停止也能说明问题。
3. 指针指向的空间释放之后及时置空,为了防止释放之后这段空间又被使用(此时我们是不确定这段空间是否有权限能访问的)。
4. 避免返回局部/临时变量的地址,因为它是随着函数栈帧的销毁而销毁的。(ps:是避免返回局部/临时变量的地址,千万不要一概认为return虽然是返回地址了主函数的指针变量也接收到这个地址了,但是由于这块空间是随着函数调用的结束而就销毁了;我们上面也提到一个局部变量p保存的是堆空间的地址,虽然函数调用结束了,但是堆空间依旧存在,所以主函数接收到这块堆空间的起始地址就能访问这块空间了)。
5. 指针使用之前检查有效性,这是由动态内存分配所产生的返回值问题,malloc、calloc以及realloc开辟空间失败都会返回NULL,它是不能被访问的。

如何避免动态内存分配造成的错误?

1. 动态申请的空间一定要记得free释放掉,并且将其置空也是一个好的习惯。
2. free函数的参数必须是要与malloc、calloc以及realloc动态申请的起始地址一致,它必须释放一块完整的空间。
3. free只能释放动态申请的空间(堆空间)。
4. 同一块动态内存空间不要释放多次。

最后我想说的一点:其实关于指针出现的各种问题本质上都是因为访问权限的问题而导致的,我们在使用指针时要时刻注意此时指针的状态,根据指针的状态来判断是否此时它能否被访问操作等…

本篇文章是关于指针部分最后总结的一篇文章了,如果你将我总结的几篇指针文章的知识点全部吸收了,相信对于指针部分理解起来是应该是没什么问题了!那么最后还是如果文章有任何疑问或者错处,欢迎大家评论区相互交流啊orz~🙈🙈

  • 20
    点赞
  • 35
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 17
    评论
评论 17
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

malloc不出对象

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值