对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;这句话时,内存到底发生了什么变化?

  1. 首先,栈中定义了一个变量,并且从内存中开辟了一个4字节(int类型)大小的空间,并把这片空间分配给x
  2. x的这片空间中,会存放一个变量0xa,但是x占据了4个字节的空间,在小端序前提下,这个0xa显然是最低字节,因此会放到x拥有的四个字节的内存空间中最小内存地址的存储单元中,而其他的三个内存空间会写进去0x0。
  3. 此时内存存储应该是这样的:(以64位机为例)
内存地址 存放数据
0x8000_0000_0000_0010 0x0a
0x8000_0000_0000_0011 0x00
0x8000_0000_0000_0012 0x00
0x8000_0000_0000_0013 0x00

如果想要修改x的值,可以直接在代码中进行重新赋值操作,同样的,也可以通过使用指针的方式进行间接访问。

一级指针

指针的创建规范

  • 先定义后赋值
1
2
int *p;
p = &x;
  • 定义时完成赋值
1
int *p = &x

指针的存储过程

像前面创建一个int型变量一样,创建指针也需要开辟一段内存空间,那么创建一个指针需要多少内存空间呢?

指针变量是存储地址的变量,那么开辟的内存空间至少要能存放的下一个地址,在32位机中,地址32位宽,4个字节,那么指针变量就要开辟4个内存空间,每个内存地址存放1个字节的地址信息;同样的,在64位机中,地址64位宽,那么指针变量就要占据8个内存空间。

在64位机上可以做如下测试:

1
2
3
4
5
6
7
int main(){
int x = 10;
int *p = &x;
printf("sizeof(x) = %ld\n", sizeof(x));
printf("sizeof(p) = %ld\n", sizeof(p));
return 0;
}

打印结果是这样的:

1
2
sizeof(x) = 4
sizeof(p) = 8

因此,在创建指针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
2
int x = 10;       
int *p = &x;
  • x是一个整数型变量,&xx的地址。
  • p是一个整数型指针变量,存储的信息是x的地址。
  • *p是对指针p的解引用,可以访问x的值。

不同类型指针的区别

我们能够创建int *p,当然也能创建char *p,前面分析到指针变量是用来存储地址的变量,64位机下,所有的指针变量都要有8个内存空间,因此无论是int型指针变量还是char型指针变量,占据的内存空间都是相同的。可以用以下代码进行测试:

1
2
3
4
5
6
7
#include <stdio.h>
int main(){
printf("sizeof(char *) = %ld\n", sizeof(char *));
printf("sizeof(short *) = %ld\n", sizeof(short *));
printf("sizeof(int *) = %ld\n", sizeof(int *));
return 0;
}

打印结果如下:

1
2
3
sizeof(char *) = 8
sizeof(short *) = 8
sizeof(int *) = 8

那么不同类型的指针常量到底有什么区别呢?——区别在于它们指向的数据类型。

即:操作不同类型的指针,产生不同的结果,本质上是因为它们指向了不同数据类型的变量。

数据大小的差异

以以下代码为例:

1
2
3
4
5
6
char c = 0x0a;
int i = 0x11223344;
char *p1 = &c;
int *p2 = &i;
printf("sizeof(*p1) = %ld\n", sizeof(*p1));
printf("sizeof(*p2) = %ld\n", sizeof(*p2));

打印结果如下:

1
2
sizeof(*p1) = 1
sizeof(*p2) = 4

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这个地址。

但是实际上使用中因为程序每次运行,内存地址都会发生变化,因此不会直接以常量的形式获取地址,那么如果p1p2是指向同一个地址,那么意味着它们要绑定同一个变量,但是这个变量只能是一种类型,那么如何绑定到两个不同类型的指针变量上呢?

这就涉及到强制类型转换。可以看下面的代码示例

1
2
3
int a = 0x44332211;
int *p1 = &a;
char *p2 = (char *)&a;

假设a的地址就是0x8000_0000_0000_0010,经过以上运算,p1p2都指向了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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
int main(){
int arr[4] = {0x3344, 0x1122, 0xaabb, 0xeeff};

int *p1 = arr;
char *p2 = (char *)arr;

printf("*arr = %0x\n", *arr);
printf("*(arr + 1) %0x\n", *(arr + 1));

printf("*p1 = %0x\n", *p1);
printf("*(p1 + 1) = %0x\n", *(p1 + 1));
printf("*p2 = %0x\n", *p2);
printf("*(p2 + 1) = %0x\n", *(p2 + 1));

return 0;
}

打印结果如下:

1
2
3
4
5
6
*arr = 3344
*(arr + 1) 1122
*p1 = 3344
*(p1 + 1) = 1122
*p2 = 44
*(p2 + 1) = 33

从这个例子也可以看出,使用数组时,既可以通过下标获取数组特定位的值,也可以通过指针解引用的方法获得数组某位的值。

二级指针及多级指针

二级指针的定义和操作

1
2
3
int x = 0x2a;
int *p = &x;
int **q = &p;

p的含义已经在前面解释了,那么q又是什么呢?

q是一个int *类型的指针,也就是说q存储的是一个int *变量的地址,因此q也是一个指针,所以它具备前面提到的指针的特性:

  1. 创建q时,也会开辟8个内存空间,用来存放p地址信息。
  2. 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
2
3
4
5
int x = 10;
int *p1 = &x;
int **p2 = &p1;
int ***p3 = &p2;
int ****p4 = &p2;
  • p1p2p3p4分别是一级、二级、三级、四级指针,各自指向xp1p2p3,如果直接打印p1p2p3p4,得到的结果是xp1p2p3的地址。
  • *p1*p2*p3*p4分别是各自的解引用,分别得到xp1p2p3
  • **p2**p3**p4分别是各自的二次解引用,分别得到xp1p2
  • 以此类推,是几级指针就可以解几次引用,比如****p4 = ***p3 = **p2 = *p1 = x

对C语言指针的底层理解
https://bingbytebard.github.io/2025/01/14/对C语言指针的底层理解/
Author
Hazel
Posted on
January 14, 2025
Licensed under