对C语言指针的底层理解
本篇从内存的角度分析C语言指针的使用。
C语言是相对比较底层的语言,C语言最初主要是为了替代汇编操作硬件而出现的,因此C语言对内存的操作是C语言的一大特点。
在分析指针使用之前,先说清一个基本的大小端序的概念。
大端序和小端序
- 何为低地址、高地址:地址编号小的叫做低地址,地址编号大的叫做高地址。
- 何为数据的高位、低位:对于一组十六进制数据:0x11223344,左侧为高位,右侧为低位。
一般我们都是在最小存储单元之间区分高低位,比如在内存中,一个地址存储一个byte的信息,那么对于0x11223344,11和22之间有高低位区分,对于单个存储单元内部的0x11或者0x22,是不需要区分高低位的。
小端序
数据低位放在低地址,数据高位放在高地址就是小端序模式
在小端序设备中,对于0x11223344这样的数据在内存中的存放方式如下:
内存地址 | 存放数据 |
---|---|
0x00 | 0x44 |
0x01 | 0x33 |
0x02 | 0x22 |
0x03 | 0x11 |
常见的Arm x86 CPU都是小端序,因此后续所有的内容都会基于小端序进行分析。
大端序
数据低位放在高地址,数据高位放在低地址就是大端序模式
在大端序设备中,对于0x11223344这样的数据在内存中的存放方式如下:
内存地址 | 存放数据 |
---|---|
0x00 | 0x11 |
0x01 | 0x22 |
0x02 | 0x33 |
0x03 | 0x44 |
以太网发送数据都是以大端序模式发送。
总结
大小端是CPU层面的概念,它决定了多字节数据在内存中的存储顺序。以汇编为例,假设内存地址bx
中存储的值为0xaa
,在bx+1
中存储的值为0xdd
,当要执行mov ax, [bx]
时,在小端序中,CPU会自动理解到ax
中存储的值为0xddaa
,因为低位在低地址(bx),高位在高地址(bx+1);同样的,在高级语言中,编译器对源文件的处理就需要去适配CPU的大小端序。
前面说到,大小端的高低位只关注最小存储单元之间的高低位关系,假设程序中所有的操作类型数都是char
,那么就不需要关注一个数据在内存中的排列顺序,但是在当下64位系统中,是不可能只去处理char
型数据的,因此接下来会结合C语言的基本类型分析一下指针操作对内存的影响。
指针与内存
从基本变量开始讲起
当执行int x = 0xa;
这句话时,内存到底发生了什么变化?
- 首先,栈中定义了一个变量,并且从内存中开辟了一个4字节(int类型)大小的空间,并把这片空间分配给
x
。 - 在
x
的这片空间中,会存放一个变量0xa,但是x
占据了4个字节的空间,在小端序前提下,这个0xa显然是最低字节,因此会放到x
拥有的四个字节的内存空间中最小内存地址的存储单元中,而其他的三个内存空间会写进去0x0。 - 此时内存存储应该是这样的:(以64位机为例)
内存地址 | 存放数据 |
---|---|
0x8000_0000_0000_0010 | 0x0a |
0x8000_0000_0000_0011 | 0x00 |
0x8000_0000_0000_0012 | 0x00 |
0x8000_0000_0000_0013 | 0x00 |
如果想要修改x的值,可以直接在代码中进行重新赋值操作,同样的,也可以通过使用指针的方式进行间接访问。
一级指针
指针的创建规范
- 先定义后赋值
1 |
|
- 定义时完成赋值
1 |
|
指针的存储过程
像前面创建一个int型变量一样,创建指针也需要开辟一段内存空间,那么创建一个指针需要多少内存空间呢?
指针变量是存储地址的变量,那么开辟的内存空间至少要能存放的下一个地址,在32位机中,地址32位宽,4个字节,那么指针变量就要开辟4个内存空间,每个内存地址存放1个字节的地址信息;同样的,在64位机中,地址64位宽,那么指针变量就要占据8个内存空间。
在64位机上可以做如下测试:
1 |
|
打印结果是这样的:
1 |
|
因此,在创建指针p之后,内存中会开辟8个内存空间,存放x
地址:0x8000_0000_0000_0010
,此时在小端序CPU中,p的内存空间是这样的:(假设p的地址是0x8000_0000_0000_1000)
内存地址 | 存放数据 |
---|---|
0x8000_0000_0000_1000 | 0x10 |
0x8000_0000_0000_1001 | 0x00 |
0x8000_0000_0000_1002 | 0x00 |
0x8000_0000_0000_1003 | 0x00 |
0x8000_0000_0000_1004 | 0x00 |
0x8000_0000_0000_1005 | 0x00 |
0x8000_0000_0000_1006 | 0x00 |
0x8000_0000_0000_1007 | 0x80 |
总结:
在这样一段代码中:
1 |
|
x
是一个整数型变量,&x
是x
的地址。p
是一个整数型指针变量,存储的信息是x
的地址。*p
是对指针p
的解引用,可以访问x
的值。
不同类型指针的区别
我们能够创建int *p
,当然也能创建char *p
,前面分析到指针变量是用来存储地址的变量,64位机下,所有的指针变量都要有8个内存空间,因此无论是int
型指针变量还是char
型指针变量,占据的内存空间都是相同的。可以用以下代码进行测试:
1 |
|
打印结果如下:
1 |
|
那么不同类型的指针常量到底有什么区别呢?——区别在于它们指向的数据类型。
即:操作不同类型的指针,产生不同的结果,本质上是因为它们指向了不同数据类型的变量。
数据大小的差异
以以下代码为例:
1 |
|
打印结果如下:
1 |
|
p1
指向的是一个char
类型的数据,只有1字节,因此解引用*p1
只访问一个字节的数据;p2
指向的是一个int
类型的数据,有4字节,因此解引用*p2
访问4字节的数据;
指针偏移的差异
指针偏移移动的长度由指针指向的数据类型决定。
- 对于
char *
,每次加1
,移动 1 字节。 - 对于
int *
,每次加1
,移动 4 字节。
其他类型以此类推,即移动的地址长度是数据类型所占内存长度
统一公式:(type *)(p + n)
即在p
原来指向的地址的基础上增加n * sizeof(type)
个地址空间。
用具体的内存变化分析一下:
内存地址 | 存放数据 |
---|---|
0x8000_0000_0000_0010 | 0x11 |
0x8000_0000_0000_0011 | 0x22 |
0x8000_0000_0000_0012 | 0x33 |
0x8000_0000_0000_0013 | 0x44 |
0x8000_0000_0000_0014 | 0x55 |
0x8000_0000_0000_0015 | 0x66 |
0x8000_0000_0000_0016 | 0x77 |
0x8000_0000_0000_0017 | 0x88 |
0x8000_0000_0000_0018 | 0x99 |
0x8000_0000_0000_0019 | 0xaa |
假设有一个int *p1
指向0x8000_0000_0000_0010
地址,同样有一个char *p2
也指向这个地址,那么p1 + 1
就指向0x8000_0000_0000_0014
这个地址,而p2 + 1
指向0x8000_0000_0000_0011
这个地址。
但是实际上使用中因为程序每次运行,内存地址都会发生变化,因此不会直接以常量的形式获取地址,那么如果p1
和p2
是指向同一个地址,那么意味着它们要绑定同一个变量,但是这个变量只能是一种类型,那么如何绑定到两个不同类型的指针变量上呢?
这就涉及到强制类型转换。可以看下面的代码示例
1 |
|
假设a
的地址就是0x8000_0000_0000_0010
,经过以上运算,p1
和p2
都指向了0x8000_0000_0000_0010
这个地址空间。
此时*p1 = 0x44332211
,而*p2 = 0x44
,同样的*(p1 + 1) = 0x88776655
,因为p1 + 1
指向0x8000_0000_0000_0014
,而*(p2 + 1) = 0x22
,因为p2 + 1
指向0x8000_0000_0000_0011
。
一个数组和指针的例子
接下来,通过一个使用指针访问数组的例子加深一下对这部分的理解。
1 |
|
打印结果如下:
1 |
|
从这个例子也可以看出,使用数组时,既可以通过下标获取数组特定位的值,也可以通过指针解引用的方法获得数组某位的值。
二级指针及多级指针
二级指针的定义和操作
1 |
|
p
的含义已经在前面解释了,那么q
又是什么呢?
q
是一个int *
类型的指针,也就是说q
存储的是一个int *
变量的地址,因此q
也是一个指针,所以它具备前面提到的指针的特性:
- 创建
q
时,也会开辟8个内存空间,用来存放p
地址信息。 - 对
q
进行加减操作时,移动地址空间时也符合<(type *)(p + n)
即在p
原来指向的地址的基础上增加n * sizeof(type)
个地址空间>这一个规则,只是此时的type
指代int *
,我们前面讨论过int *
的长度是8字节,因此q + 1
在内存中实际上移动了8个地址。
二级指针的基本指向逻辑是q->p->x,那么*q
是作为q
的解引用,能够得到p
,**q
是对*q
的再次解引用,得到变量x
。
对多级指针的理解
以此类推,可以使用多级指针,例如int ****
就是指向 int ***
类型数据的指针。
在二级指针之后,无论是几级指针,都必然遵循以下两条规则:
- 创建指针
p
时开辟8个内存空间,存放指向变量的地址。 - 对这个指针
p
进行加减p+n
操作时,相当于将p
指向地址的基础上移动n * 8
。
关于为什么这里移动的地址空间一定是8:多级指针必然指向另一个指针,而它指向的那个指针,都是占据8个内存空间,对于(type *)(p + n)
,只要这个type
是一个指针型变量,那么必然有sizeof(type) = 8
。
总结
对于以下几行代码:
1 |
|
p1
、p2
、p3
、p4
分别是一级、二级、三级、四级指针,各自指向x
、p1
、p2
、p3
,如果直接打印p1
、p2
、p3
、p4
,得到的结果是x
、p1
、p2
、p3
的地址。*p1
、*p2
、*p3
、*p4
分别是各自的解引用,分别得到x
、p1
、p2
、p3
。**p2
、**p3
、**p4
分别是各自的二次解引用,分别得到x
、p1
、p2
。- 以此类推,是几级指针就可以解几次引用,比如
****p4 = ***p3 = **p2 = *p1 = x