内存就是计算机的存储空间,用于存储程序的指令、数据和状态。在 C 语言中,内存被组织成一系列的字节,每个字节都有一个唯一的地址。程序中的变量和数据结构存储在这些字节中。根据变量的类型和作用域,内存分为几个区域,如栈(stack)、堆(heap)和全局/静态存储区。
内存编址
计算机的内存是一块用于存储数据的空间,由一系列连续的存储单元组成,就像下面这样,
byte 作为内存寻址的最小单元,也就是给每个 byte 一个编号,这个编号就叫内存的地址。
内存地址空间
上面我们说给内存中每个 byte 唯一的编号,那么这个编号的范围就决定了计算机可寻址内存的范围。
所有编号连起来就叫做内存的地址空间,这和大家平时常说的电脑是 32 位还是 64 位有关。
早期 Intel 8086、8088 的 CPU 就是只支持 16 位地址空间,寄存器和地址总线都是 16 位,这意味着最多对 2^16 = 64 Kb 的内存编号寻址。
这点内存空间显然不够用,后来,80286 在 8086 的基础上将地址总线和地址寄存器扩展到了20 位,也被叫做 A20 地址总线。
当时在写 mini os 的时候,还需要通过 BIOS 中断去启动 A20 地址总线的开关。
但是,现在的计算机一般都是 32 位起步了,32 位意味着可寻址的内存范围是 2^32 byte = 4GB。
所以,如果你的电脑是 32 位的,那么你装超过 4G 的内存条也是无法充分利用起来的。
好了,这就是内存和内存地址空间。
变量的本质
内存是什么?
有了内存,接下来我们需要考虑,int、double 这些变量是如何存储在 0、1 单元格的。 在 C 语言中我们会这样定义变量:
int a = 999;
char c = 'c';
当你写下一个变量定义的时候,实际上是向内存申请了一块空间来存放你的变量。 我们都知道 int 类型占 4 个字节,并且在计算机中数字都是用补码(不了解补码的记得去百度)表示的。
999
换算成补码就是:0000 0011 1110 0111
这里有 4 个byte,所以需要四个单元格来存储:
有没有注意到,我们把高位的字节放在了低地址的地方。 那能不能反过来呢? 当然,这就引出了大端和小端。 像上面这种将高位字节放在内存低地址的方式叫做大端 反之,将低位字节放在内存低地址的方式就叫做小端。
深入理解C/C++指针
指针是什么东西?
变量放在哪?
上面我说,定义一个变量实际就是向计算机申请了一块内存来存放。那如果我们要想知道变量到底放在哪了呢?可以通过运算符&来取得变量实际的地址,这个值就是变量所占内存块的起始地址。大概会是像这样的一串数字:0x7ffcad3b8f3c
指针本质
上面说,我们可以通过&符号获取变量的内存地址,那获取之后如何来表示这是一个地址,而不是一个普通的值呢? 也就是在 C 语言中如何表示地址这个概念呢? 对,就是指针,你可以这样:
int *pa = &a;
pa 中存储的就是变量 a
的地址,也叫做指向 a
的指针。
为什么我们需要指针?直接用变量名不行吗?:变量是变量地址的符号化,变量是为了让我们编程时更加方便,对人友好,可计算机可不认识什么变量 a,它只知道地址和指令。编译器会自动维护一个映射,将我们程序中的变量名转换为变量所对应的地址,然后再对这个地址去进行读写。
解引用
pa中存储的是a变量的内存地址,那如何通过地址去获取a的值呢?这个操作就叫做解引用,在 C 语言中通过运算符 就可以拿到一个指针所指地址的内容了。比如pa就能获得a的值。我们说指针存储的是变量内存的首地址,那编译器怎么知道该从首地址开始取多少个字节呢?这就是指针类型发挥作用的时候,编译器会根据指针的所指元素的类型去判断应该取多少个字节。如果是 int 型的指针,那么编译器就会产生提取四个字节的指令,char 则只提取一个字节,以此类推。
pa 指针首先是一个变量,它本身也占据一块内存,这块内存里存放的就是 a 变量的首地址。
当解引用的时候,就会从这个首地址连续划出 4 个 byte,然后按照 int 类型的编码方式解释。
活学活用
float f = 1.0;
short c = *(short*)&f;
实际上,从内存层面来说,f
什么都没变。
假设这是f
在内存中的位模式,这个过程实际上就是把 f
的前两个byte
取出来然后按照 short
的方式解释,然后赋值给 c
。 详细过程如下:
&f
取得f
的首地址(short*)&f
最后当去解引用的时候(short)&f时,编译器会取出前面两个字节,并且按照 short 的编码方式去解释,并将解释出的值赋给 c 变量。
这个过程 f
的位模式没有发生任何改变,变的只是解释这些位的方式。当然,这里最后的值肯定不是 1,至于是什么,大家可以去真正算一下。 那反过来,这样呢?
short c = 1;
float f = *(float*)&c;
具体过程和上述一样,但上面肯定不会报错,这里却不一定。
为什么?
(float*)&c
会让我们从c
的首地址开始取四个字节,然后按照 float
的编码方式去解释。 但是c
是 short
类型只占两个字节,那肯定会访问到相邻后面两个字节,这时候就发生了内存访问越界。当然,如果只是读,大概率是没问题的。
但是,有时候需要向这个区域写入新的值,比如:
*(float*)&c = 1.0;
那么就可能发生 coredump,也就是访存失败。另外,就算是不会 coredump,这种也会破坏这块内存原有的值,因为很可能这是是其它变量的内存空间,而我们去覆盖了人家的内容,肯定会导致隐藏的 bug。如果你理解了上面这些内容,那么使用指针一定会更加的自如。
结构体和指针
结构体内包含多个成员,这些成员之间在内存中是如何存放的呢?比如:
struct fraction {
int num; // 整数部分
int denom; // 小数部分
};
struct fraction fp;
fp.num = 10;
fp.denom = 2;
这是一个定点小数结构体,它在内存占 8 个字节(这里不考虑内存对齐),两个成员域是这样存储的:
我们把 10 放在了结构体中基地址偏移为 0 的域,2 放在了偏移为 4 的域。
接下来我们做一个正常人永远不会做的操作:
((fraction*)(&fp.denom))->num = 5;
((fraction*)(&fp.denom))->denom = 12;
printf("%d\n", fp.denom); // 输出多少?
上面这个究竟会输出多少呢?自己先思考下噢~
接下来我分析下这个过程发生了什么:
首先,&fp.denom
表示取结构体 fp
中 denom
域的首地址,然后以这个地址为起始地址取 8 个字节,并且将它们看做一个 fraction
结构体。
在这个新结构体中,最上面四个字节变成了 denom
域,而 fp
的 denom
域相当于新结构体的 num
域。
多级指针
int a;
int *pa = &a;
int **ppa = &pa;
int ***pppa = &ppa;
不管几级指针有两个最核心的东西:
- 指针本身也是一个变量,需要内存去存储,指针也有自己的地址
- 指针内存存储的是它所指向变量的地址
指针与数组
数组是 C 自带的基本数据结构,彻底理解数组及其用法是开发高效应用程序的基础。数组和指针表示法紧密关联,在合适的上下文中可以互换。
如下:
int array[10] = {10, 9, 8, 7};
printf("%d\n", *array); // 输出 10
printf("%d\n", array[0]); // 输出 10
printf("%d\n", array[1]); // 输出 9
printf("%d\n", *(array+1)); // 输出 9
int *pa = array;
printf("%d\n", *pa); // 输出 10
printf("%d\n", pa[0]); // 输出 10
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差别
最典型的地方就是在 sizeof:
printf("%u", sizeof(array));
printf("%u", sizeof(pa));
第一个将会输出 40,因为 array包含有 10 个int类型的元素,而第二个在 32 位机器上将会输出 4,也就是指针的长度。
站在编译器的角度讲,变量名、数组名都是一种符号,它们都是有类型的,它们最终都要和数据绑定起来。变量名用来指代一份数据,数组名用来指代一组数据(数据集合),它们都是有类型的,以便推断出所指代的数据的长度。
对,数组也有类型,我们可以将 int、float、char 等理解为基本类型,将数组理解为由基本类型派生得到的稍微复杂一些的类型,数组的类型由元素的类型和数组的长度共同构成。而 sizeof 就是根据变量的类型来计算长度的,并且计算的过程是在编译期,而不会在程序运行时。
编译器在编译过程中会创建一张专门的表格用来保存变量名及其对应的数据类型、地址、作用域等信息。sizeof 是一个操作符,不是函数,使用 sizeof 时可以从这张表格中查询到符号的长度。
所以,这里对数组名使用sizeof
可以查询到数组实际的长度。pa 仅仅是一个指向 int 类型的指针,编译器根本不知道它指向的是一个整数,还是一堆整数。虽然在这里它指向的是一个数组,但数组也只是一块连续的内存,没有开始和结束标志,也没有额外的信息来记录数组到底多长。
所以对 pa 使用 sizeof
只能求得的是指针变量本身的长度。也就是说,编译器并没有把 pa 和数组关联起来,pa 仅仅是一个指针变量,不管它指向哪里,sizeof
求得的永远是它本身所占用的字节数。
二维数组
大家不要认为二维数组在内存中就是按行、列这样二维存储的,实际上,不管二维、三维数组... 都是编译器的语法糖。
存储上和一维数组没有本质区别,举个例子:
int array[3][3] = {{1, 2,3}, {4, 5,6},{7, 8, 9}};
array[1][1] = 5;
1 2 3 4 5 6 7 8 9
和一维数组没有什么区别,都是一维线性排列。 当我们像 array[1][1]
这样去访问的时候,编译器会怎么去计算我们真正所访问元素的地址呢? 为了更加通用化,假设数组定义是这样的: int array[n][m]
访问 array[a][b]
,计算方式就是array + (a * m + b)
, 这个就是二维数组在内存中的本质,其实和一维数组是一样的,只是语法糖包装成一个二维的样子。
void指针
应用场景
void 指针最大的用处就是在 C 语言中实现泛型编程,因为任何指针都可以被赋给 void 指针,void 指针也可以被转换回原来的指针类型, 并且这个过程指针实际所指向的地址并不会发生变化。 比如:
int num;
int *pi = #
printf("address of pi: %p\n", pi);
void* pv = pi;
pi = (int*) pv;
printf("address of pi: %p\n", pi);
这两次输出的值都会是一样:
平常可能很少会这样去转换,但是当你用 C 写大型软件或者写一些通用库的时候,一定离不开 void 指针,这是 C 泛型的基石,比如 std 库里的 sort 函数申明是这样的:
void qsort(void *base,int nelem,int width,int (*fcmp)(const void *,const void *));
所有关于具体元素类型的地方全部用 void 代替。
不能对 void 指针解引用
int num;
void *pv = (void*)#
*pv = 4; // 错误
因为解引用的本质就是编译器根据指针所指的类型,然后从指针所指向的内存连续取 N 个字节,然后将这 N 个字节按照指针的类型去解释。
比如 int *型指针,那么这里 N 就是 4,然后按照 int 的编码方式去解释数字。
但是 void,编译器是不知道它到底指向的是 int、double、或者是一个结构体,所以编译器没法对 void 型指针解引用。
快速搞懂指针声明
int p; // 普通变量
int *p; // 普通指针
int p[3]; // 数组
int* p[3]; //指针数组
int (*p)[3]; //数组指针
int **p; // 二级指针
int p(int); //普通函数
int (*p)(int); // 函数指针
int* (*p(int))[3];
/* p 开始,先与()结合,说明 p 是一个函数。然后进入()里面,与int结合,说明函数有一个整型变量参数。然后再与外面的 * 结合,说明函数返回的是一个指针。之后到最外面一层,先与[]结合,说明返回的指针指向的是一个数组。接着再与结合,说明数组里的元素是指针,最后再与int结合,说明指针指向的内容是整型数据。所以 p 是一个参数为一个整数据且返回一个指向由整型指针变量组成的数组的指针变量的函数。*/
简单总结下如何解释复杂一点的 C 语言声明(暂时不考虑 const 和 volatile):
指针声明阅读顺序
- 先抓住 标识符(即变量名或者函数名)
- 从距离标识符最近的地方开始,按照优先顺序解释派生类型(也就是指针、数组、函数),顺序如下:
- 用于改变优先级的括弧
- 用于表示数组的[],用于表示函数的()
- 用于表示指针的
-
解释完成派生类型,使用“of”、“to”、“returning”将它们连接起来。
-
最后,追加数据类型修饰符(一般在最左边,int、double等)。
数组元素个数和函数的参数属于类型的一部分。应该将它们作为附属于类型的属性进行解释。
标签:变量,int,地址,内存,数组,指针 From: https://www.cnblogs.com/sfbslover/p/18407348