指针理解一点通

210 阅读10分钟

本篇文章的主角是指针,但并不会过多描述指针的使用技法,而是将笔墨放在了指针的认识与理解上,我们将从内存的角度尝试详细描述指针。

1、内存地址

“指针就是存放内存地址的变量”,我们在初学指针时肯定没少听到这句话,所以本文还是从内存讲起吧。

CPU 的工作就是操作数据,而数据是存放在内存中的,所以从内存中读取数据是 CPU 的必要工作。诚如我们多年的学习直觉,内存也是有个基本单元的,是由一块块基本单元连续组成的。我们可以把整个内存想象成一片土地,地上有一个个的萝卜坑,每个萝卜坑就是一个内存的基本单元,萝卜坑里的萝卜就是数据了。

内存地址示意图

每个萝卜坑的大小都是 1 byte(B), 这就是内存的基本单元。我们给每个萝卜坑编号,其编号就是所谓的内存地址。我们常见的 4 GB 内存有多少个地址编号呢?4 GB = 4 * 2^30 B = 2^32 B,答案是 2^32 个。

看到这,我们发现内存其实就只有萝卜坑编号(地址)和坑里的萝卜(地址中的内容)这两个东西,那变量又是怎么一回事呢?

2、变量和地址

指针也是变量,要认识指针,难免要先学习一下变量。假设有下面的语句,定义了 int 型变量 n,在 32 位机器上它占 4 个字节,也就是 4 个萝卜坑,我们现在把这个变量放到萝卜坑 1~4 里去。

注意:本文后续也都已 32 位机器的情况为标准

int n = 6;

变量 n 在内存中的保存示意

其内存示意如上图所示,此后我们在代码中使用 n 就可以取得整数 6。但我们刚提到内存中只有地址和地址中的内容这两个概念,并不存在所谓变量名,那地址中的内容和变量名称 n 是如何关联的呢?编译器功不可没,简单来说,在程序编译过程中会生成一张表,记录变量名和变量地址的对应关系,系统就能找到该变量名对应的地址以及地址中的内容了(感兴趣的可以去编译原理里中找一下具体过程)。

变量名称到变量地址的映射示意

我们发现上图中 n 是对应到 1 号坑的,也就是变量 n 存储内容的首地址,但实际上变量 n 却占着 14 号坑呢。我知道你肯定有疑问,但你先别问,请别忘了变量 n 还具有类型信息,此处是 int 型的,我们将其派上用场吧。整个过程其实是,系统从上图所示的表里找到了变量 n 的坑号是 1,又知道 n 是 int 型的,要占 4 个 坑位,所以得出它的完整坑号是 14,取出其中的内容就是 n 的值了。

我们再梳理一遍从变量名到变量值的过程吧,如下图所示,大概分为两个步骤:

  1. 通过查表获得变量名对应内容的首地址;
  2. 通过变量类型信息,确定变量所占地址的长度,确定变量内容的完整地址信息,取出相应的值。

从萝卜名称找到萝卜值的过程

3、指针与间接访问

上文第二步中从萝卜坑号直接得到萝卜值的过程叫做直接访问,那么当然也有间接访问,这就是指针了,终于到主角登场了,我们一开始就提到指针是一个变量,存储着其他变量的内存地址。

现在先简单定义一个指针 p,指向整形变量 n,也就是让指针 p 存放变量 n 的地址。

int n =6;
int* p = &n;

32 位机器上指针的大小是 4 字节,后续会展开讨论。我们假定指针变量占着的 4 个坑位是 21~24 号,那么整个内存情况如下图所示。

变量 p 在内存中的保存示意

话不多说,我们直接看下如何通过指针 p 获得其所指向变量 n 的值。

指针也是一个变量,所以肯定要先经历第 2 节的两个步骤,从萝卜名称找到萝卜值。p 这个萝卜的值是 1,而且这个值是一个地址,所以还没完,我们接着去编号 1 的这个地址找,因为 p 指向的类型是 int,所以从 1 号开始取四个坑,在坑 1~4 中的值就是变量 n 的值了。

通过变量 n 直接取值和通过指针 p 间接取值的过程对比

不难体会到,指针变量虽然是变量,但访问其指向内容的值时,却比普通变量多了一个步骤。这也是为什么指针需要和普通变量有不同的定义形式,因为要区分直接访问和间接访问。

4、变量类型的作用验证

前文说到,内存中只有地址和地址中对应的内容这两个概念,并没有所谓的变量类型。变量类型有什么作用呢?在得出变量首地址后,需要用变量类型来确定连续往后取几个字节,以取出变量的值。下面将通过一些变量类型转换的例子,来验证这一作用。

4.1 指针类型间相互转换

我们先看一个指针变量类型互相转换的例子,来说明同一个地址,用不同类型的指针去解读,会获得不同的结果。

//
#include <iostream>

int main()
{
    int n = 6;
    int* p = &n;
    char* pc = (char*)p;
    double* pd = (double*)pc;
    int* pi = (int*)pd;
}

下面是我使用 x86 调试器的结果,首先获得变量 n 的地址是 0x0137FECC,从该地址开始,连续 4 个字节中的内容为图中红框部分。

变量 n 的地址

变量 n 地址对应的内容

我们通过强制转换,让不同类型的指针都指向了同一地址,但是它们对该地址对应的内容有不同解读,获得的值不相同(下图第二列,花括号里就是地址对应的值)。具体说,通过 int* 指针访问时,会取四个字节的内容来作为结果,也就是 06 00 00 00;通过 char* 指针访问时,只会取一个字节的内容,也就是 06;通过 double* 指针访问时,会取连续 8 个字节的内容,也就是 06 00 00 00 cc cc cc cc。

通过不同类型指针来访问地址 0x0137FECC

代码最后一行又把指针类型转换回了 int*,用 pi 表示,它也正确解读出了变量 n 的值。这个例子说明指针进行类型转换,转换的只是对地址的解读方式,并不会有数据的损失,因为所有类型指针的大小都是 4 个字节。

4.2 指针和 int 相互转换

int 型的特殊之处在于它和指针一样都是 4 个字节。这个例子是用展示指针拥有的普通变量属性,和普通变量之间可以来回转换。

//
#include <iostream>

int main()
{
    int n = 300;
    int* p = &n;
    int value = (int)p;
    int* pi = (int*)value;
}

还是先获得变量 n 的地址,为 0x005BF990,该地址连续四个字节的内容是 2c 01 00 00。

变量 n 的地址

变量 n 地址的内容

指针 p 是通过取址运算获得了 n 的地址。随后我们把 int* 类型强制转换成 int 型并赋值给变量 value,下图里的 value = 0x005BF990 = 6027664,也就是直接把 16 进制表示的地址换算成了 10 进制的数字;最后再把 int 型的 value 转换成 int* 类型,赋值给 pi,这时通过 pi 也是可以获得正确的 n 的值。

指针和 int 类型相互转换结果

这个例子展现了一个有趣的现象,把地址 0x005BF990 赋值给普通变量,访问时会采用直接访问的方式取变量值,也就是 0x005BF990 本身就是值;而把地址 0x005BF990 赋值给指针,访问时会采用间接访问的方式去该地址中取对应的值。

4.3 指针和其他普通类型的相互转换

我们已经知道指针可以和普通变量进行类型转换,所有指针的长度是 4 个字节,所以可以和 int 类型相互转换而不丢失信息。那当指针转换成 char(1 个字节)、转换成 double(8 个字节)又会怎样呢?这里就简单给一下结论,不再展开了。

假设指针 int* p 指向的地址是 0x005BF990。如果强制转换成 char 类型,就只会取第一个字节的内容 90 转换成 char,其他三个字节的内容就会丢失,当再次转换回 int* 后其地址已经变成了 0x00000090;如果转换成 8 个字节的 double,再转换回去,则不会丢失地址信息。

5、指针的大小

在第二节一开始,我们就说在 32 位机器上指针的大小是 4 个字节,那么指针的大小是由什么决定的呢?答案是 cpu 的寻址位数,或者说寻址范围。比如 x86 机器上,寻址位数是 32 位,那么能寻找的地址个数就是 2^32 个,对应的寻址范围就是 2^32 * 1 B = 4 * 2^30 B = 4 GB,一个 4 字节的指针就是 32 位,刚好涵盖了 cpu 的寻址范围。

听起来有点抽象,所以我们还是回到萝卜坑的那个比喻吧,现在我们把 cpu 比作兔子,cpu 从内存中取数,就是兔子去萝卜坑里拔萝卜。假设现在有一片土地,地上有 1000 个萝卜坑,但兔子比较笨,数字只能数到 256,再大就不认识了,也就是它只找得到 1256 号萝卜坑,其他坑里就算有萝卜,兔子也找不到(哦,感觉笨的有点离谱,但意思就是这样)。这种场景,兔子的寻址范围就是 1256,寻址位数是 8,因为 2^8 = 256,指针的大小只需要足够记录兔子能找到的萝卜坑编号就行了,毕竟大点号的记了也没用,兔子又不认识。记录 1~256 的坑号,只需要 8 位就够了,所以此时的指针就只需要 8 位,也就是一个字节。

本篇关于指针的理解就到这里了,错误之处,烦请指针,喜欢的朋友麻烦点个赞,感谢。