写点什么

你真的理解 C 语言的灵魂 “ 指针 ” 吗?(初阶篇)

作者:Albert Edison
  • 2022 年 9 月 06 日
    四川
  • 本文字数:8720 字

    阅读完需:约 29 分钟

你真的理解C语言的灵魂 “ 指针 ” 吗?(初阶篇)

🌟 前言


本篇是关于指针初阶的一个总结,其实指针被誉为 C 语言的灵魂!


如果你不深入理解指针的话,你对指针的认识可能仅仅只停留在 指针就是变量的地址,会比较害怕使用指针,特别是一些高级操作!


但是,万事开头难!Let’s get it!



一、内存本质

1. 内存编址

什么是内存?


计算机的内存是一块用于存储数据的空间由一系列连续的存储单元组成,就像下面这样:

每一个单元格都表示 1 个 bit,一个bitCS 同学看来就是 0、1 两种状态。 由于 1 个bit只能表示两个状态,所以规定 8 个 bit 为一组,命名为 byte(字节)。 并且将 byte 作为内存寻址的最小单元,也就是给每个 byte 一个编号,这个编号就叫内存的地址

这就相当于,我们给小区里的每个单元、每个住户都分配一个门牌号: 301、302、401、402、501...... 在生活中,我们需要保证门牌号唯一,这样就能通过门牌号很精准的定位到一家人。 同样,在计算机中,我们也要保证给每一个 byte 的编号都是唯一的,这样才能够保证每个编号都能访问到唯一确定的 byte

2. 内存地址空间

上面我们说给内存中每个 byte 唯一的编号,那么这个编号的范围就决定了计算机可寻址内存的范围。


所有编号连起来就叫做内存的地址空间,这和大家平时常说的电脑是 32 位还是 64 位有关。


早期 Intel 80868088 的 CPU 就是只支持 16 位地址空间,寄存器地址总线都是 16 位,这意味着最多对 2^16 = 64 Kb 的内存编号寻址。 这点内存空间显然不够用,后来,802868086 的基础上将地址总线地址寄存器扩展到了 20 位,也被叫做 A20 地址总线。 但是,现在的计算机一般都是 32 位起步了,32 位意味着可寻址的内存范围是 2^32 byte = 4GB。 所以,如果你的电脑是 32 位的,那么你装超过 4G 的内存条也是无法充分利用起来的。


这就是内存和内存编址。

3. 变量的本质

有了内存,接下来我们需要考虑,intdouble 这些变量是如何存储在 0、1 单元格的。


在 C 语言中我们会这样定义变量:


int a = 999;char c = 'c';
复制代码


当你写下一个变量定义的时候,实际上是向内存申请了一块空间来存放你的变量。


我们都知道 int 类型占 4 个字节,并且在计算机中数字都是用补码表示的。


(不了解补码的可以看我这篇文章,在 移位操作符 这里详细介绍过:史上最全C语言操作符详解


999 换算成补码就是:0000 0011 1110 0111 这里有 4byte,所以需要四个单元格来存储:

有没有注意到,我们把高位的字节放在了低地址的地方。 那能不能反过来呢? 当然,这就引出了大端和小端。(关于大端和小端后期会专门写一篇文章介绍) 上面这种将高位字节放在内存低地址的方式叫做大端; 反之,将低位字节放在内存低地址的方式就叫做小端; 如图,小端存储:


上面只说明了int型的变量如何存储在内存,而 floatchar 等类型实际上也是一样的,都需要先转换为补码


对于多字节的变量类型,还需要按照大端或者小端的格式,依次将字节写入到内存单元。


记住上面这两张图,这就是编程语言中所有变量的在内存中的样子,不管是 intchar指针数组结构体对象.等等都是这样放在内存的。

二、指针是什么?

指针(Pointer)是编程语言中的一个对象,利用地址,它的值直接指向(points to)存在电脑存储器中另一个地方的值。由于通过地址能找到所需的内存单元,可以说地址指向该内存单元。


因此,将地址形象化的称为“指针”。意思是通过它能找到以它为地址的内存单元


通过上面对内存的解释,那我们可以画个图来理解内存:

1. 变量放在哪里?

上面我说,定义一个变量实际就是向计算机申请了一块内存来存放。 那如果我们要想知道变量到底放在哪了呢? 可以通过运算符&(取地址) 来取得变量实际的地址,这个值就是变量所占内存块的起始地址。(PS: 实际上这个地址是虚拟地址,并不是真正物理内存上的地址) 那获取之后如何来表示这是一个地址,而不是一个普通的值呢?


也就是在 C 语言中如何表示地址这个概念呢?


对,就是指针


代码示例:


int main(){  int a = 10;//在内存中开辟一块空间存放变量a=10
int* p = &a;//p为一个整形指针变量,int* 是p的类型,即整形指针 //这里我们对变量a,取出它的地址,可以使用&(取地址)操作符。 //将a的地址存放在p变量中,p就是一个之指针变量。 return 0;}
复制代码


解析:


上面代码中,指针 p 指向变量 a,意思就是指针变量 p 里面存放的是变量 a 的地址


p 中存储的就是变量 a 的地址,也叫做指向 a 的指针。


如图:



关于指针的大小为什么是 4 个字节


这里再说明一下:


32 位操作系统之所以被称之为 32 位操作系统, 是因为 CPU 所能处理的数据的最大位数是 32 位; 32 位操作系统所能支持的最大内存的大小是(2^32-1)Byte ≈ 4G


指针存放的是一个地址,地址是一个 8 位十六进制的数字,也就是 32 位二进制的数字,而一个字节刚好是 8 位二进制数字,因此是 4 个字节;


任何类型的指针其大小都是 4,因为其存放的都是一个地址,地址的大小就是 4 个字节。


那如果是 64 位操作系统呢?


在 64 位机器上,如果有 64 个地址线,那一个指针变量的大小是 8 个字节,才能存放一个地址。

2. 总结

  • 指针是个变量,存放内存单元的地址。

  • 指针是用来存放地址的,地址是唯一标识一块地址空间的。

  • 指针的大小在 32 位平台是 4 个字节,在 64 位平台是 8 个字节。

3. 思考

为什么我们需要指针?直接用变量名不行吗? 当然可以,但是变量名是有局限的。


变量名的本质是什么? 是变量地址的符号化,变量是为了让我们编程时更加方便,对人友好,可计算机可不认识什么变量 a,它只知道地址和指令。


假设我有一个需求:


代码示例:


int func(...) {  ...};
int main() { int a; func(...);};
复制代码


要求在func 函数里要能够修改 main 函数里的变量 a,这下咋整?在 main 函数里可以直接通过变量名去读写 a 所在内存。 但是在 func 函数里是看不见a 的呀!


你说可以通过&取地址符号,将 a 的地址传递进去:


int func(int address) {  ....};
int main() { int a; func(&a);};
复制代码


这样在func 里就能获取到 a 的地址,进行读写了。


理论上这是完全没有问题的,但是问题在于:


编译器该如何区分一个int里你存的到底是int类型的值,还是另外一个变量的地址(即指针)。 这如果完全靠我们编程人员去人脑记忆了,会引入复杂性,并且无法通过编译器检测一些语法错误。 而通过int * 去定义一个指针变量,会非常明确:这就是另外一个 int 型变量的地址。 编译器也可以通过类型检查来排除一些编译错误。


这就是指针存在的必要性。


实际上任何语言都有这个需求,只不过很多语言为了安全性,给指针戴上了一层枷锁,将指针包装成了引用。


同时,在这里提点小问题:


既然指针的本质都是变量的内存首地址,即一个 int 类型的整数。


那为什么还要有各种类型呢?


比如 int 指针,float 指针,这个类型影响了指针本身存储的信息吗?


这个类型会在什么时候发挥作用?

三、指针和指针类型

我们都知道,变量有不同的类型,整形,浮点型等。


那指针有没有类型呢?当然有!


指针的定义方式是: type + *


不同类型的指针用来存放对应类型的变量:


  • char* 类型的指针是为了存放 char 类型变量的地址;

  • int* 类型的指针存放的是 int 类型的变量地址;

  • float* 类型的指针存放的是 float 类型的变量地址等。


代码示例:


char  *pc = NULL;int   *pi = NULL;short *ps = NULL;long  *pl = NULL;float *pf = NULL;double *pd = NULL;
复制代码


那指针类型的意义是什么?

1. 指针 ± 整数

若指针类型为int * 的指针+1,那么它将跳过 4 个字节的大小指向 4 个字节以后的内容:



若指针类型为char * 的指针+1,那么它只会跳过 1 个字节的大小指向下一个字节的内容,以此类推。


总结: 指针的类型决定了指针向前或者向后走一步有多大(距离)。

2. 解引用

上面的思考问题,就是为了引出指针解引用的。


pa中存储的是a变量的内存地址,那如何通过地址去获取a的值呢?


这个操作就叫做解引用,在 C 语言中通过运算符 *就可以拿到一个指针所指地址的内容了。


比如*pa就能获得a的值。


下面是指针内存示意图:

pa 指针首先是一个变量,它本身也占据一块内存,这块内存里存放的就是 a 变量的首地址。 当解引用的时候,就会从这个首地址连续划出 4 个 byte,然后按照 int 类型的编码方式解释。 指针的类型决定了指针解引用的时候能够访问几个字节的内容。 若指针类型为int *,那么将它进行解引用操作,它将可以访问从指向位置开始向后 4 个字节的内容:

若指针类型为char *,那么将它进行解引用操作,它将可以访问从指向位置开始向后 1 个字节的内容,以此类推。


总结: 指针的类型决定了,对指针解引用的时候有多大的权限(能操作几个字节)。


比如:char* 的指针解引用就只能访问一个字节,而 int* 的指针的解引用就能访问四个字节。

四、野指针

概念:


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


指针越界也会导致野指针问题;


这里解释下:就是当你指针指向的范围超过数组名范围时,那么那个指针就是野指针了。

1. 野指针成因

  • 指针未初始化


int main(){    int* p;//局部变量指针未初始化,默认为随机值
*p = 20;
return 0;}
复制代码


局部指针变量 p 未初始化,默认为随机值,所以这个时候的 p 就是野指针。


  • 指针越界访问


int main(){    int arr[10] = { 0 };    int* p = arr;//p指向数组的首元素    int i = 0;
for (i = 0; i < 12; i++) { //当指针指向的范围超出数组arr的范围时,p就是野指针 *(p++) = i;//*(p+1)不断访问数组中的元素 }
return 0;}
复制代码


p 为 11 时,此时 p 的地址已经不是数组内元素的地址了,它已经指向了数组后面的内存,这块内存属于哪个变量是未知的,因此访问这块内存空间是非法的。


简单来说:当指针指向的范围超出 arr 数组时,p 就是野指针。


  • 指针指向的空间释放


int* test() //返回值类型是int* 型{  int a = 10;  return &a;}int main(){  int* p = test();  return 0;}
复制代码


这样子写就会有问题,因为test()函数里面的 a 在出函数时候就被销毁了,a 的空间就还给系统了;


这里执行很可能还是 10,这个空间的内容还没有清除;


在 c 语言陷阱中有这个解释:(有的时候可能已经清除);总之不建议这样写


PS:其实你可以把野指针理解为 野狗🤣

2. 规避野指针

🌳 指针初始化

在定义指针时就对其进行初始化,即指向一个变量的地址,如果没有变量地址可指向,就赋值为空指针 NULL;


  int a = 10;         int* pa = &a;    //指针变量的初始化
复制代码


指针常见错误:引用未初始化的指针变量


试图引用未初始化的指针变量是初学者最容易犯的错误。 未初始化的指针变量就是“野”指针,它指向的是无效的地址。 有些书上说:“如果指针变量不初始化,那么它可能指向内存中的任何一个存储单元,这样就会很危险。 如果正好指向存储着重要数据的内存单元,而且又不小心向这个内存单元中写入了数据,把原来的重要数据给覆盖了,这样就会导致系统崩溃。 ”这种说法是不正确的!如果真是这样的话就是编译器的一个严重的 BUG!

🌳 小心指针越界

特别是在数组的访问中,注意不要越界访问;


#include<stdio.h> int main(void){  int arr[10] = { 0 };  int i = 0;          int* p = arr;       //接收arr数组首元素的地址  for (i = 0; i <= 12; i++)//当i=10的时候已经是非法访问内存了,因为,我数组名的常量表达式内容只有10个元素。  {    *p = i;        //i每次循环赋值给指针p    p++;        //指针自增+1,代指arr元素+1     //*p++ = i 也是可以,这里虽说++优先级更高,但是它是后置运算符  }  return 0;}
复制代码


指针变量越界数组导致野指针问题!

🌳 指针指向空间释放即使置 NULL

NULL:空指针,用来初始化指针或者给指针赋值;


说的简单一点就是:当你指针不知道怎么赋值,就赋值给一个空指针 NULL


Null 是在计算中具有保留的值,用于指示指针不引用有效对象。 程序通常使用空指针来表示条件,例如未知长度列表的结尾或未执行某些操作; 这种空指针的使用可以与可空类型和选项类型中的 Nothing 值进行比较。 **空指针不应与未初始化的指针混淆:**保证空指针与指向有效对象的任何指针进行比较。 但是,根据语言和实现,未初始化的指针可能没有任何此类保证。 它可能与其他有效指针相等; 或者它可能比较等于空指针。它可能在不同的时间做两件事。


代码示例:


  int a = 10;        int* pa = &a;   printf("%d\n", *pa);   *pa = 20;        //此时当我们不想用它时候  pa = NULL;        //就把pa指针置成NULL   printf("%d\n",pa);
复制代码

🌳 指针使用之前检查有效性

当你指针变量不可以用的时候就把它设置成NULL,当你指针变量可以用的时候就不是NULL


就是当我们对这个指针进行初始化的话,那么它就是有效的,如果没有初始化那么就是无效的。


在使用指针前可以先判断指针是否为空指针NULL


int main(){    int* p = NULL;    //....    int a = 10;    p = &a;    if (p != NULL)    {        *p = 20;    }    return 0;}
复制代码

五、指针运算

1. 指针 ± 整数(加减)

指针一个整数,指针的内存将跳过指针类型个大小,比如:


int main(){  int arr[10] = { 0 };  int* p = arr;//p指向arr的首元素地址  int i = 0;      for (i = 0; i < 10; i++)  {    *(p + i) = i;  }      for (i = 0; i < 10; i++)  {    printf("%d\n", arr[i]);  }    return 0;}
复制代码


运行结果:



解析:


因为pint* 型的指针,当p+i 时,p跳过 ix(乘)4个字节的内存; 而数组中一个元素的大小就是四个字节,这个时候会跳过 i 个元素,p 将直接指向数组第 i 个元素的地址,此时 *p访问四个字节的内存,就可以访问到第i个元素,并将第i个元素的值赋值为i

2. 指针 - 指针(减)

指针减去指针的绝对值得到的是指针和指针中间的元素个数。


代码示例:


int main(){  int arr[10] = { 0 };
printf("%d\n", &arr[5] - &arr[0]);
printf("%d\n", &arr[0] - &arr[5]);
return 0;}
复制代码


运行结果:



解析:


当两个指针相减的时候,两个指针必须指向同一块空间,比如指向同一个数组。 如果两个指针指向的空间不同,那么结果是随机的;


代码示例:


int main(){  int arr1[10] = { 0 };    int arr2[10] = { 0 };
int arr3[10] = { 0 };
printf("%d\n", &arr2[5] - &arr1[0]);
printf("%d\n", &arr3[5] - &arr1[0]);
return 0;}
复制代码


运行结果:


3. 指针的关系运算

指针和指针也是可以比较大小的,数组的地址从首元素到最后一个元素的地址依次增加。


注意:


C 语言中允许指向数组元素的指针与指向数组最后一个元素后面的那个内存位置的指针比较; 但是不允许与指向第一个元素之前的那个内存位置的指针进行比较。


如图:

允许指向arr数组中元素的指针p2与指向数组最后一个元素后面的指向变量 b 的指针比较; 但是不允许p2与指向第一个元素前面的变量 a 的指针比较。

六、指针和数组

1. 一维数组

数组是 C 自带的基本数据结构,彻底理解数组及其用法是开发高效应用程序的基础。


数组和指针表示法紧密关联,在合适的上下文中可以互换。


代码示例:


int main(){  int array[10] = { 10, 9, 8, 7 };
printf("%d\n", *array); // 输出 10 printf("%d\n", array[0]); // 输出 10 printf("\n");
printf("%d\n", array[1]); // 输出 9 printf("%d\n", *(array + 1)); // 输出 9 printf("\n");
int* pa = array; printf("%d\n", *pa); // 输出 10 printf("%d\n", pa[0]); // 输出 10 printf("\n");
printf("%d\n", pa[1]); // 输出 9 printf("%d\n", *(pa + 1)); // 输出 9}
复制代码


运行结果:



分析:


在内存中,数组是一块连续的内存空间:

第 0 个元素的地址称为数组的首地址,数组名实际就是指向数组首地址,当我们通过array[1]或者*(array + 1) 去访问数组元素的时候。 实际上可以看做 address[offset]address 为起始地址,offset 为偏移量,但是注意这里的偏移量offset 不是直接和 address相加,而是要乘以数组类型所占字节数,也就是: address + sizeof(int) * offset。 看完上面的代码,你可能会认为指针和数组完全一致,可以互换,这是完全错误的。尽管数组名字有时候可以当做指针来用,但数组的名字不是指针


最典型的地方就是在 sizeof


int main(){  int array[10] = { 10, 9, 8, 7 };
int *pa = array;
printf("%u\n", sizeof(array));
printf("%u\n", sizeof(pa));
return 0;}
复制代码


第一个将会输出 40,因为 array包含有 10 个 int 类型的元素,而第二个在 32 位机器上将会输出 4,也就是指针的长度。



为什么会这样呢?


站在编译器的角度讲,变量名、数组名都是一种符号,它们都是有类型的,它们最终都要和数据绑定起来。 变量名用来指代一份数据,数组名用来指代一组数据(数据集合),它们都是有类型的,以便推断出所指代的数据的长度。 对,数组也有类型,我们可以将 intfloatchar 等理解为基本类型,将数组理解为由基本类型派生得到的稍微复杂一些的类型, 数组的类型由元素的类型和数组的长度共同构成。而 sizeof 就是根据变量的类型来计算长度的,并且计算的过程是在编译期,而不会在程序运行时。 编译器在编译过程中会创建一张专门的表格用来保存变量名及其对应的数据类型、地址、作用域等信息。 sizeof 是一个操作符,不是函数,使用 sizeof 时可以从这张表格中查询到符号的长度。


所以,这里对数组名使用sizeof可以查询到数组实际的长度。


pa 仅仅是一个指向 int 类型的指针,编译器根本不知道它指向的是一个整数,还是一堆整数。 虽然在这里它指向的是一个数组,但数组也只是一块连续的内存,没有开始和结束标志,也没有额外的信息来记录数组到底多长。 所以对 pa 使用 sizeof 只能求得的是指针变量本身的长度。


也就是说,编译器并没有把 pa 和数组关联起来,pa 仅仅是一个指针变量,不管它指向哪里,sizeof求得的永远是它本身所占用的字节数。

七、多级指针

说起多级指针这个东西,我最多理解到 2 级,再多真的会把我绕晕,经常也会写错代码。


你要是给我写个这个:int ******p 能把我搞崩溃,我估计很多同学现在就是这种情况🤣


其实,多级指针也没那么复杂,就是指针的指针的指针的指针......非常简单。


今天就带大家认识一下多级指针的本质。


⭐首先,我要说一句话,没有多级指针这种东西,指针就是指针,多级指针只是为了我们方便表达而取的逻辑概念。 首先看下生活中的快递柜:

这种大家都用过吧,丰巢或者超市储物柜都是这样,每个格子都有一个编号,我们只需要拿到编号,然后就能找到对应的格子,取出里面的东西。 这里的格子就是内存单元编号就是地址格子里放的东西就对应存储在内存中的内容。 假设我把一本书,放在了 03 号格子,然后把 03 这个编号告诉你,你就可以根据 03 去取到里面的书。 那如果我把书放在 05 号格子,然后在 03 号格子只放一个小纸条,上面写着:「书放在 05 号」。 你会怎么做? 当然是打开 03 号格子,然后取出了纸条,根据上面内容去打开 05 号格子得到书。 这里的 03 号格子就叫指针,因为它里面放的是指向其它格子的小纸条(地址)而不是具体的书。


明白了吗?


那我如果把书放在 07 号格子,然后在 05 号格子 放一个纸条:「书放在 07 号」,同时在 03 号格子放一个纸条「书放在 05 号」

这里的 03 号格子就叫二级指针,05 号格子就叫指针,而 07 号就是我们平常用的变量。


依次,可类推出 N 级指针。


所以你明白了吗?同样的一块内存,如果存放的是别的变量的地址,那么就叫指针,存放的是实际内容,就叫变量。


int a;int *pa = &a;int **ppa = &pa;int ***pppa = &ppa;
复制代码


上面这段代码,pa就叫一级指针,也就是平时常说的指针,ppa 就是二级指针。


内存示意图如下:


不管几级指针有两个最核心的东西:


  • 指针本身也是一个变量,需要内存去存储,指针也有自己的地址

  • 指针内存存储的是它所指向变量的地址


这就是我为什么多级指针是逻辑上的概念,实际上一块内存要么放实际内容,要么放其它变量地址,就这么简单。


怎么去解读int **a这种表达呢? int ** a 可以把它分为两部分看: ①即int**a; ②后面 *a 中的*表示 a 是一个指针变量; 前面的 int* 表示指针变量a;只能存放 int* 型变量的地址。 对于二级指针甚至多级指针,我们都可以把它拆成两部分。 首先不管是多少级的指针变量,它首先是一个指针变量,指针变量就是一个*,其余的*表示的是这个指针变量只能存放什么类型变量的地址。 比如int****a表示指针变量 a 只能存放int*** 型变量的地址。

八、指针数组

指针可以和数组结合产生指针数组数组指针,这两个东西完全不相同,不要搞混!


在此先介绍下指针数组 (数组指针将在指针(进阶)中讲解)。


我们前面学过整形数组存放的是多个整形变量,那么顾名思义指针数组存放的就是多个指针。


那指针数组是怎样的?


int* arr3[5];//是什么?
复制代码


arr3是一个数组,有五个元素,每个元素是一个整形指针


代码示例:


int main(){  int a = 1;  int b = 2;  int c = 3;
int* arr[3] = { &a,&b,&c };
int i = 0; for (i = 0; i < 3; i++) { printf("%d\n", *arr[i]); } return 0;}
复制代码


运行结果:



解析:


可以看到,指针数组arr的类型是整形指针int* 型,里面存放的都是指针指向的地址&a,&b,&c; 此时通过for循环可以依次拿到指针数组的元素,再通过***(解引用)**即可得到各自指针指向的值。

九、总结

关于指针初阶的总结基本就到这里了,其实还有很多深入的东西没有写到;


比如:


二维数组和二维指针、数组指针和指针数组、指针运算、函数指针.....等等;


这些东西想要全部理解的话,短时间是不现实的,只有通过自己去慢慢摸索!


指针是 C 语言的灵魂,把指针学扎实,后面的路会好走很多!


🌟你知道的越多,你不知道越多,我们下期见!

发布于: 10 小时前阅读数: 3
用户头像

Albert Edison

关注

目前在某大厂担任后端开发,欢迎交流🤝 2022.03.08 加入

🏅️平台:InfoQ 签约作者、阿里云 专家博主、CSDN 优质创作者 🛫领域:专注于C语言、数据结构与算法、C++、Linux、MySQL、云原生的研究 ✨成就:2021年CSDN博客新星Top9,算法领域优质创作者,全网累计粉丝4W+

评论

发布
暂无评论
你真的理解C语言的灵魂 “ 指针 ” 吗?(初阶篇)_指针_Albert Edison_InfoQ写作社区