C语言指针详解1 —— 指针基础知识

  在本篇文章中,主要是针对C语言中指针部分进行基础知识的讲解,分为指针的概念,指针变量,指针的运算,const关键字,野指针,assert断言以及函数的传址调用7个板块。

目录

一   指针的概念

1  指针

2  编址

二  指针变量

1  取地址操作符 -- &

2  指针变量

1) 概念

2) 指针变量的类型

(1) 普通类型

(2) void* 类型

3) 指针变量的大小 

3 解引用操作符 --  *

三  指针的运算 

1  指针加/减整数

1) 指针加/减整数的理解

2) 指针类型的特殊意义

2) 指针 - 指针

3) 指针的关系运算

四  const关键字 

1  const修饰普通变量

2  const修饰指针 

1) const放在 * 之前

​编辑 2)const放在 * 之后

​编辑 3) 模拟实现strlen函数

五  野指针 

1  野指针的概念

2  野指针的成因

1) 指针未初始化

2) 指针越界访问

3) 指针指向的空间被释放 

3  如何规避野指针

1) 对指针变量进行初始化

2) 避免越界访问

3) 避免返回局部变量的地址  

 六  assert断言

七  传值调用与传址调用 

1) 传值调用

2) 传址调用


一   指针的概念

1  指针

  首先呢,我们通过一个日常生活中的例子来引入指针。假设有一栋大楼,里面有许多房间,每个房间上呢都有门牌号,比如101,201,301等,你的朋友告诉你他所在房间是101,那么你到了那所大楼,就能通过101门牌号快速找到你的朋友。再比如,一栋学生宿舍楼,里面每个宿舍房间是8人间,且每个房间都有宿舍号,102,303等,每个学生就能通过宿舍号快速找到自己所住的宿舍。

  类比以上生活中的例子,内存就像那一栋大楼或者学生宿舍,然后内存也被划分为了一个一个的小房间,每个小房间被称为内存单元,每个内存单元就像一个8人间的学生宿舍,是一个字节(8个比特位),为了像上面快速找到房间一样,每个内存单元也有一个编号,比如0x0023ff20(十六进制),这个编号就叫做地址,而在C语言中,就给这个地址起了一个特殊的名字——指针,所以在C语言中,指针 == 地址 == 内存单元的编号

2  编址

  有了以上关于指针的理解,那么内存单元的编号又该如果理解呢?其实在内存中,并不会真正写着内存单元的编号,而是通过硬件设计来完成的。

  在计算机中,一共有三条线,分别是控制总线,数据总线和地址总线,显然根据名字来看,控制总线是用来控制数据的写入与读取的,数据总线是用来传输数据的,而地址总线就是用来传输地址的。地址总线呢,根据计算机位数不同,线数不同,32位的机器上有32根地址线,64位的机器上有64根地址线,每根地址线都有两种状态,电脉冲有或者无,反映在计算机上就是0或者1,通过这些地址线的状态,就可以实现地址的传输,然后通过硬件的协同工作就可以实现数据的传输,如图:

所以通过以上描述我们可以看到,在32位机器上,因为有32根地址线,所以一个内存的编号就是32位,也就是4个字节,所以内存单元的地址,也就是指针在32个机器上就是4个字节,同样的,在64位机器上就是8个字节

二  指针变量

1  取地址操作符 -- &

  在C语言中,其实创建变量的本质就是像内存申请一块空间,那么这就意味着每个变量都是有地址的,那么怎么取出一个变量的地址呢?这时候就不得不提到一个单目操作符,&,这个操作符叫做去地址操作符(地址用%p占位符打印),如:

#include<stdio.h>

int main()
{
  int a = 10;
 
  printf("%p\n", &a);

  return 0;
}

运行结果:

x64(64位)环境下:

 

x86(86位)环境下:

值得注意的一点是这些地址都是以16进制的形式打印的,所以我们可以看到在64位环境下,地址确实是8个字节,在32位环境下,地址是4个字节。

2  指针变量

1) 概念

   在C语言中,整型变量用来存储整型,字符变量用来存储字符,浮点型变量用来存储小数,那么可以类比其他变量类型,有一种变量,可以专门用来存储地址,这种变量就叫指针变量,如:

#include<stdio.h>

int main()
{
  int a = 10;
  
  int* pa = &a;

  return 0;
}

在以上代码中,我们可以看到把a变量的地址赋给了pa变量,所以pa变量就叫做指针变量。

  我们知道一个整型有4个字节,那就对应着有4个地址,那么指针变量中存储的是哪一个地址呢?

我们可以打开调试界面的内存窗口看一下:

可以看到, 内存窗口中有四个地方变红了,所有那四个地址所对应的内存单元就是为a变量开辟的4个字节的内存空间,再看下pa中的内容:

可以在调试的监视窗口中看到pa中存放的是a的四个地址中较小的那个地址,所以指针变量中存放的是它所指向对象的地址中较小的那一个

2) 指针变量的类型

(1) 普通类型

  我们知道在进行一个变量的创建时,要包括一个变量的类型和标识符,所以除去一个变量的标识符,剩下的就是它的类型。所以在上面那个代码中,pa指针变量的类型就是int*,那么指针变量的类型我们该如何理解呢?我们可以这样来理解指针的类型,就比如:

int a = 10;
int* pa = &a;

*代表其后面的变量pa是一个指针变量,前面的int代表指针所指向的对象是一个整型,这样就可以举一反三出其他指针变量类型,如:字符指针类型就是char*,结构体指针类型(假设这里结构体是struct Stu)就是struct Stu*,长整型指针类型就是long*等等,有了指针类型就可以创建指针变量了,如:

#include<stdio.h>

struct Stu
{
  char name[20];
  int age;
};

int main()
{
  char a = 'x';
  long b = 200;
  struct Stu s = {"zhangsan", 18};

  char* pa = &a;
  long* pb = &b;
  struct Stu* ps = &s;

  return 0;
}

  需要注意的一点是指针变量的类型具有独特的意义,这部分到指针加减那部分再详细讲解。

(2) void* 类型

  在这里呢有一个特殊的指针类型是void*类型,其叫做无具体类型的指针(泛型指针),其特殊就特殊在这个指针类型的变量可以接受任意类型的地址,比如:int*,char*,float*,struct Stu*等等,这种指针类型的指针变量一般用在函数参数的部分,用来实现泛型编程,但这种指针类型也有其局限性,到后面再详细讲解。

3) 指针变量的大小 

  既然指针变量是一个变量,那么指针变量就有大小,比如整型变量是4个字节,字符变量是一个字节,那么指针变量是几个字节呢?我们这样来看,指针中既然存储的是地址,那么地址多大,一个指针变量就应该多大,所以指针变量的大小是跟环境有关的,在32位环境下,一个指针变量大小是4个字节,在64位环境下,一个指针变量是8个字节。

  还有一点需要提醒的是,既然指针变量的大小只跟地址有关,而地址的字节数只跟环境有关,所以指针变量的大小跟其指针变量的类型是没有关系的,无论是什么类型的指针变量,其大小都是4/8个字节。

3 解引用操作符 --  *

  地址存储到了指针变量中,那么我们日后如果想使用指针变量所指向的内容,就需要使用一个单目操作符,叫做解引用操作符,*,只要把这个操作符加在指针变量或者地址前面,就可以拿到那个指针变量或者地址所指向的内容了,如:

#include<stdio.h>

int main()
{
  int a = 10;
  
  int* pa = &a;
  
  printf("%d\n",*pa);
  printf("%d\n",*&a);

  return 0;
}

 输出结果是:

  所以,解引用操作符(*)和取地址操作符(&)就像两个负号一样,能够相互抵消,但是这里的抵消关系是单向的,只能是*抵消&,而不能&抵消*。

三  指针的运算 

  在C语言中,指针是可以进行运算的,包括三种运算,分别是指针加/减整数,指针减去指针,以及指针的关系运算。

1  指针加/减整数

1) 指针加/减整数的理解

  对于指针加/减整数,有些人是有些疑惑的,指针不是地址吗,地址怎么能加或者减去整数呢?其实,这里可以把加或者减去的整数理解为一个偏移量,由于内存空间是连续的,所以指针通过加或者减偏移量,就可以找到不同的地址,进而访问不同的内存空间,比如:

#include<stdio.h>

int main()
{
  int arr[10] = {1,2,3,4,5,6,7,8,9,10};
  int sz = sizeof(arr)/sizeof(arr[0]);
  
  int* p = &arr[0];
  
  int i = 0;
  for (i = 0;i < sz; i++)
  {
    printf("%d ",*(p + i));
  }
  
  return 0;
}

运行结果是: 

在上面那个代码中,首先创建了10个元素的整型数组,然后把首元素的地址赋给了整型指针变量p,接下来用了一个循环来进行数组元素的打印,由于数组元素在内存中是连续存放的,所以每次p通过加循环变量i这个整数来找到数组中的每个元素,然后解引用,打印出数组元素,那么大家到这里会不会有一个问题就是,为什么指针变量p加上一个整数i会找到数组中的每个元素,接下里我们就来解决这个问题。

2) 指针类型的特殊意义

  首先,我们先看一段代码:

#include<stdio.h>

int main()
{
  int a = 10;
  char b = 'x';
  
  int* pa = &a;
  char* pb = &b;

  printf("pa   = %p\n", pa);
  printf("pa+1 = %p\n", pa + 1);
  printf("pb   = %p\n", pb);
  printf("pb+1 = %p\n", pb + 1);

  return 0;
}

 运行结果是:

在这里我们可以看到,pa是int*类型的指针变量,加1呢是跳过了四个字节,而pb是char*类型的变量,加1跳过了1个字节,所以通过这个例子,我们可以看到指针类型呢,决定了指针加1或者减1跳过几个字节。

我们再来看一段代码:

#include<stdio.h>

int main()
{
  int a = 0x11223344;
  int b = 0x11223344;
  
  int* pa = &a;
  char* pb = &b;

  *pa = 0;
  *pb = 0;

  printf("%x\n", a);
  printf("%x\n", b);

  return 0;
}

运行结果是:

​在上面那个代码我们可以看到,对pa解引用,再赋值0,把a变量的四个字节全都改为了0,而对pb解引用再赋值0,只改了一个字节,所以通过整个例子,我们也可以看到指针类型可以决定指针变量解引用访问多少个字节。

  总结一下,就是指针类型决定了指针变量加减整数是,向前向后走多大距离,比如int*类型就一次走4个字节,也决定了对指针变量解引用时访问多少个字节。

  知道了指针类型的特殊意义,那么我们就可以来讲讲void*类型指针变量的局限性了。由于这个指针类型是无具体类型的指针,所以在对void*类型的指针变量加减整数或者解引用操作时,编译器并不知道要跳过多少个字节或者访问多少个字节,故void*类型的指针变量的局限性就是:void*类型的指针是不能进行加减整数和解引用操作的。 

2) 指针 - 指针

  我们先来看一段代码;

#include<stdio.h>

int main()
{
  int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

  printf("%d\n", &arr[9] - &arr[0]);

  return 0;
}

运行结果是:

我们画图来理解一下:

假设图中绿色的是内存空间,红色的是在内存中开辟的数组的存储空间,&arr[9]和&arr[0]分别是图中绿色指针所指向的位置,我们可以看到指针之间是隔了9个数组的元素的,所以指针减去指针的结果就是两个指针之间相差的元素个数,当然这个运算的前提是这两个指针必须指向同一块空间

3) 指针的关系运算

  指针变量中存储的是变量的地址,地址也是有大有小的,所以指针之间是可以进行关系运算的,比如,用指针的关系运算来打印数组:

#include<stdio.h>

int main()
{
  int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
  int sz = sizeof(arr)/sizeof(arr[0]);
  
  int* p = &arr[0];

  while(p < &arr[0] + sz)//p < &arr[sz]
  {
    printf("%d ",*p);
    p++;
  }
  
  return 0;
}

四  const关键字 

1  const修饰普通变量

  const关键字来源于英文单词constant,意思是“常量,恒定的”,所以const关键字修饰变量是类似于改变了一个变量的可变属性的一个作用,如:、

#include<stdio.h>

int main()
{
  const int a = 10;

  a = 20;
 
  printf("%d\n", a);
  return 0;
}

运行时可以看到编译器报了错误:

所以const修饰变量,那个变量是不可被修改的,但是在本质上那个变量还是个变量,而在C++语言中,const修饰变量,就使那个变量变为了常量。

  既然const修饰变量不可通过变量本身修改,有没有别的办法修改呢?这时候就想到指针了,比如下面这个代码:

#include<stdio.h>

int main()
{
  const int a = 10;
  int* p = &a;

  *p = 20;
 
  printf("%d\n", a);
  return 0;
}

运行结果是:

我们可以看到结果确实被修改了,那么这就违反了初衷,本来const修饰变量是不想它被修改,现在却可以通过指针来修改它,所以就出现了const修饰指针。

2  const修饰指针 

  const修饰指针变量有两种,一种是放在*之前,一种是放在修饰之后。

1) const放在 * 之前

  const放在*之前有两种写法:

const int* p;
int const * p;

这两种写法呢,都是代表一个含义,就是const修饰*p,代表指针变量本身是可以修改的,其所指向的对象不可通过指针本身来修改,但是可以通过变量本身来修改,如:

#include<stdio.h>

int main()
{
  int a = 10;
  const int* p = &a;

  *p = 20;
 
  printf("%d\n", a);
  return 0;
}

代码运行时,报了错误:

但是如果改为以下代码:

#include<stdio.h>

int main()
{
  int a = 10;
  int b = 20;
  const int* p = &a;

  a = 20;
  p = &b;
 
  printf("%d\n", a);
  printf("%d\n", *p);
  return 0;
}

运行结果是: 

 2)const放在 * 之后

   对比上面那种情况,const放在*后面就代表const修饰的是p指针变量本身,如:

#include<stdio.h>

int main()
{
  int a = 10;
  int b = 20;

  int* const pa = &a;
  pa = &b;

  return 0;
}

运行时,编译器报了错误:

 3) 模拟实现strlen函数

  学习了const关键字和指针减去指针之后我们就可以模拟实现strlen函数了,先看一下strlen函数的原型:

可以看到strlen函数的参数是const char*类型的指针变量,为什么要用const关键字呢?是因为strlen函数只希望数出字符串'\0'之前的字符个数,并不希望字符串本身被修改,返回类型为size_t类型,是因为字符串的长度是不会为负数的。

  我们知道指针减去指针是指针之间字符的个数,所以只要用指向'\0'的那个指针减去指向字符串首元素的指针,就可以得到正确的字符串个数了,所以代码就可以这样写:

size_t my_strlen(const char* str)
{
  char* start = str;

  while(*str)
  {
    str++;
  }
  
  return str - start;
}

五  野指针 

1  野指针的概念

  如果一个指针所指向的空间是不可知的,或者指向的空间不属于自己,那么这个指针就叫做野指针。

2  野指针的成因

1) 指针未初始化

  一个指针在创建后,如果不初始化,那么那个指针所指向的空间就是未知的,就造成了野指针,如:

#include<stdio.h>

int main()
{
  int* p;
  
  *p = 20;
  return 0;
}

上面代码如果运行的话,在VS编译器好中,会报出警告和错误:

2) 指针越界访问

  指针如果越界访问,就会使得指针访问不属于自己的空间,就会造成野指针,如:

#include<stdio.h>

int main()
{
  int arr[10] = {0};
  
  int* p = &arr[0];

  int i = 0;
  for (i = 0;i <= 11;i++)
  {
    *(p++) = i;
  }
  
  return 0;
}

3) 指针指向的空间被释放 

  这个成因呢,是比较隐蔽的,比较难以发现,一不小心就可能造成野指针,如:

#include<stdio.h>

int* test()
{
  int n = 100;
  return &n;
}

int main()
{
  int* p = test();

  printf("%d\n", *p);
  return 0;
}

在上面那个代码中,是把test函数的返回值也就是n的地址赋给了p指针变量,然后打印p所指向的对象,但是由于test函数调用完后,原本属于test函数的空间也被释放了,所以p所指向的那块空间就不属于main函数本身了,所以就造成了野指针。

3  如何规避野指针

  根据以上成因我们可以提出规避野指针的措施:

1) 对指针变量进行初始化

  我们在创建指针变量是,就要对其进行初始化,如果不知道指针指向哪里就可以对其赋值一个指针NULL,这是C语言中定义的一个符号常量:

可以看到,NULL在C语言中是被被强制转换成void*类型的0的地址,所以它的值是0,而且由于是void*类型的指针,所以是不能进行指针运算和解引用操作的。 

  如果不再使用指针,也要把指针变量赋值为NULL,因为有一个约定俗成的规定就是:只要是NULL指针,我们就不去访问,使用指针之前也要检查是否是NULL指针。

2) 避免越界访问

  在使用指针时,一定要注意不要让指针越界访问,否则指针就访问了不属于自己的空间了。

3) 避免返回局部变量的地址  

   由于局部变量的作用域只是其所对应的那个代码块,所以局部变量的空间很容易在代码运行过程中被释放等,所以不要返回局部变量的地址。

 六  assert断言

  在野指针部分我们说了在使用指针之前,要检查指针是否是NULL指针,所以assert.h头文件中定义了一个宏assert(),专门供程序员来检查某些情况,避免出现问题。

  assert()来源于英文单词assert,意思是“断言”,assert()宏接受一个表达式作为参数,如果表达式为真(返回值非0),那么程序就继续运行,如果表达式为假(返回值为0),那么就会直接报错,终止程序的运行,如:

#include<stdio.h>
#include<assert.h>

int main()
{
  int* p = NULL;

  assert(p);

  return 0;
}

 运行结果:

上面代码中给指针变量p赋值了一个NULL变量,由于NULL值为0,所以assert()宏中结果为假,所以会终止程序,并在控制台中显示哪个文件,那一行出现的错误。

  assert也可以用来判断其他条件,并不只能用来判断是否是NULL指针。assert对于程序员来说可以是非常友好的,可以用来避免一些错误的产生,但同时也增加了程序的运行时间。

  如果无需再断言,就直接在#include<assert.h>语句之前,加一句#define NDEBUG即可,如:

#include<stdio.h>
#define NDEBUG
#include<assert.h>

int main()
{
  int* p = NULL;

  assert(p);

  return 0;
}

 运行结果是:

可以看到,加上之后,就不会报错了。

  会了assert之后,我们就可以对上面strlen模拟实现的代码进行优化一下了,可以在使用str指针之前断言一下,判断str是否是NULL指针:

size_t my_strlen(const char* str)
{
  assert(str);

  char* start = str;

  while(*str)
  {
    str++;
  }
  
  return str - start;
}

七  传值调用与传址调用 

  在函数调用时,有两种函数参数传递方式,一种是传值调用,顾名思义,就是把实参的值传递给形参,另一种是传址调用,是把实参的地址传递给形参,显然上面那个strlen函数的模拟实现就是用的传址调用。

  我们通过一个函数来理解传值调用与传址调用,这个函数是实现两个变量的交换。

1) 传值调用

  传值调用的代码是这样写的:

#include<stdio.h>

void Swap(int x,int y)
{
  int z = x;
  x = y;
  y = z;
}

int main()
{
  int a = 10;
  int b = 20;

  Swap(a,b);

  printf("%d %d\n", a, b);

  return 0;
}

运行结果是:

 我们看到变量并未交换,因为形参只是实参的一块临时拷贝,形参的存储空间与实参的存储空间并不是同一块空间,所以形参的交换并不会影响形参。

2) 传址调用

  传址调用的代码是这样的:

#include<stdio.h>

void Swap(int* p1,int* p2)
{
  int z = *p1;
  *p1 = *p2;
  *p2 = z;
}

int main()
{
  int a = 10;
  int b = 20;

  Swap(&a,&b);

  printf("%d %d\n", a, b);

  return 0;
}

 运行结果是:

我们可以看到结果交换了,因为传的是a,b变量的地址,所以Swap函数中交换就是a,b两个变量。

  总结一下,就是如果以后函数中要涉及到实参,那就要用传址调用,如果不涉及到实参,那就要用传值调用。

  • 23
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值