编程的本质其实就是操控数据,数据存放在内存中。因此操作数据,实际就是操作内存,而定位到需要操作的内存就需要知道内存地址,即为指针。
了解内存模型可以把指针用得炉火纯青,各种对内存基本单元——字节(byte) 随意操作
计算机的内存是一块用于存储数据的空间,由一系列连续的存储单元组成,每个存储单元为1bit, 1个 bit 只能表示两个状态,所以大佬们规定 8个 bit 为一组,命名为 byte。
将 byte 作为内存寻址的最小单元,也就是给每个 byte 一个编号,这个编号就叫内存的地址。
在计算机中,我们也要保证给每一个 byte 的编号都是唯一的,这样才能够保证每个编号都能访问到唯一确定的 byte。
内存中每个 byte 对应唯一的编号,这个编号的范围就决定了计算机可寻址内存的范围,所有编号连起来就叫做内存的地址空间。内
存地址空间范围越大,对应的可以访问到的byte数量越多。
这个范围由什么来决定?
寻址能力,地址总线为可寻址的范围。
这和大家平时常说的电脑是 32 位还是 64 位有关。意味着CPU处理一次命令的数据字节数。
早期 Intel 8086、8088 的 CPU 就是只支持 16 位地址空间,寄存器和地址总线都是 16 位,这意味着最多对 2^16 = 64 Kb的内存编号寻址。
80286 在 8086 的基础上将地址总线和地址寄存器扩展到了20 位,也被叫做 A20 地址总线。
32 位CPU一次可提取32位数据。对应可以达到最大的可寻址的内存范围:
2^32 byte = 4GB。
因此32位电脑4GB内存即可。多了浪费。32bit只支持到4G内存,同时计算机还要接外设(鼠标,打印机,键盘,网卡,声卡,显卡等等)这些外设也是需要占用地址空间的。所以在设计系统初期就预留了一部分空间给这些设备,这样一来,win7 32位虽然能支持4G内存,但是不能达到4G内存,一般win7 32位显示的内存是3.25G左右。也就是说,win7 32位操作系统安装了8G内存条,但是实际识别的还是不到4G。
64 位CPU一次可提取64位数据。对应可以达到最大的可寻址的内存范围:
(264) / (10243)
=171,7986,9184GB
=16777246.09375TB
=16384.0293884PB
=16.0000287EB
现实的问题是现在的CPU没有必要做到支持那么大的内存,而且基于技术和成本也不可能(就是买不起的意思)做那么宽的地址线,于是地址位数普遍都没有做到64位,支持不到16EB,究其原因还是地址线没有做到。
PS:
(win10操作系统支持的内存最大可以支持2TB,受主板和cpu限制,单条最大支持到128G内存,大型工作站可达到共1TB内存)
当你写下一个变量定义的时候,实际上是向内存申请了一块空间来存放你的变量。
以int为例, int 类型占 4 个字节,并且在计算机中数字都是用补码表示的,存储在内存空间中。
999换算成补码就是:0000 0011 1110 0111
将高位字节放在内存低地址的方式叫做大端,
反之,将低位字节放在内存低地址的方式就叫做小端:
float、char 等类型实际上也是一样的,都需要先转换为补码。对于多字节的变量类型,还需要按照大端或者小端的格式,依次将字节写入到内存单元。
记住上面这两张图,这就是编程语言中所有变量的在内存中的样子,不管是 int、char、指针、数组、结构体、对象… 都是这样放在内存的。
补充:
给出不同位数编译器下的基本数据类型所占的字节数:
16位编译器
char :1个字节
char*(即指针变量): 2个字节
short int : 2个字节
int: 2个字节
unsigned int : 2个字节
float: 4个字节
double: 8个字节
long: 4个字节
long long: 8个字节
unsigned long: 4个字节
32位编译器
char :1个字节
char*(即指针变量): 4个字节(32位的寻址空间是2^32, 即32个bit,也就是4个字节。同理64位编译器)
short int : 2个字节
int: 4个字节
unsigned int : 4个字节
float: 4个字节
double: 8个字节
long: 4个字节
long long: 8个字节
unsigned long: 4个字节
64位编译器
char :1个字节
char*(即指针变量): 8个字节
short int : 2个字节
int: 4个字节 取值范围:-2^31~2^31-1
unsigned int : 4个字节 取值范围:0 ~ 2^32
float: 4个字节
double: 8个字节
long: 8个字节
long long: 8个字节
unsigned long: 8个字节
在内存中,数值以补码的形式存储。
在所有被int类型占用的比特位中,左起第一个位(即最高位)就是符号位。int类型的符号位上,0表示正数,1表示负数。在32位操作系统下,其余后面31位是数值位。
特例 ±0
数字0采用“+0”的表示方法,即0000000000000000 00000000;而“-0”这个特殊的数字被定义为了-2^31
计算机中的符号数有三种表示方法,即原码、反码和补码。三种表示方法均有符号位和数值位两部分,符号位都是用0表示“正”,用1表示“负”。
加减运算法则用哪种是对的呢?
补码正是基于反码的“-0”问题诞生的,可以解决这个问题。
正数和+0的补码是其原码,负数则先计算其反码,然后反码加上1,得到补码。
正确
(-1)+(-127)=-128
补码10000000具有特殊性,计算机在编写底层算法时,将其规定为该取值范围中的最小数-128,其值与(-1)+(-127)的计算结果正好符合。即对于八位二进制,-0为-128(十进制)
补充一点,8位二进制补码1000 0000没有对应的反码和原码,其他位数的二进制补码与此类似。
定义一个变量实际就是向计算机申请了一块内存来存放。
为了找到存储数据地址,可以通过运算符&来取得变量实际的地址,这个值就是变量所占内存块的起始地址。
(PS: 实际上这个地址是虚拟地址,并不是真正物理内存上的地址)
int *pa = &a;
为什么我们需要指针?直接用取变量名地址&不行吗?
变量名的本质是什么?
是变量地址的符号化,变量是为了让我们编程时更加方便,对人友好,可计算机可不认识什么变量 a,它只知道地址和指令。
去查看 C 语言编译后的汇编代码,就会发现变量名消失了,取而代之的是一串串抽象的地址。
你可以认为,编译器会自动维护一个映射,将我们程序中的变量名转换为变量所对应的地址,然后再对这个地址去进行读写。也就是有这样一个映射表存在,将变量名自动转化为地址
a | 0x7ffcad3b8f3c
c | 0x7ffcad3b8f2c
h | 0x7ffcad3b8f4c
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 类型的值,char , double, 还是另外一个变量的地址(即指针)?
这如果完全靠我们编程人员去人脑记忆了,会引入复杂性,并且无法通过编译器检测一些语法错误。
而通过 int* 去定义一个指针变量,会非常明确:这就是一个int 型变量的地址, 编译器会根据指针的所指元素的类型去判断应该取多少个字节。
如果是 int 型的指针,那么编译器就会产生提取四个字节的指令,char 则只提取一个字节,以此类推。
编译器也可以通过类型检查来排除一些编译错误。
这就是指针存在的必要性。
实际上任何语言都有这个需求,只不过很多语言为了安全性,给指针戴上了一层枷锁,将指针包装成了引用。
pa中存储的是a变量的内存地址,那如何通过地址去获取a的值呢?
这个操作就叫做解引用,在 C 语言中通过运算符 就可以拿到一个指针所指地址的内容了。
比如pa就能获得a的值。
这就是指针类型发挥作用的时候,编译器会根据指针的所指元素的类型去判断应该取多少个字节。
float f = 1.0;
short c = *(short*)&f;
short c = 1;
float f = *(float*)&c;
f = 1.0;
可能发生 coredump,也就是访存失败。
另外,就算是不会 coredump,这种也会破坏这块内存原有的值,因为很可能这是是其它变量的内存空间,而我们去覆盖了人家的内容,肯定会导致隐藏的 bug。
结构体的本质其实就是一堆的变量打包放在一起,而访问结构体中的域,就是通过结构体的起始地址,也叫基地址,然后加上域的偏移。
struct fraction {
int num; // 整数部分
int denom; // 小数部分
};
struct fraction fp;
fp.num = 10;
fp.denom = 2;
如果把书放在 07 号格子,然后在 05 号格子 放一个纸条:「书放在 07号」,同时在03号格子放一个纸条「书放在 05号」
同样的一块内存,如果存放的是别的变量的地址,那么就叫指针,存放的是实际内容,就叫变量。
不管几级指针有两个最核心的东西:
对于二级指针甚至多级指针,我们都可以把它拆成两部分。
int ** a可以把它分为两部分看,即int*和 *a,后面 * a中的* 表示 a是一个指针变量,前面的 int * 表示指针变量a,只能存放 int * 型变量的地址。
不管是多少级的指针变量,它首先是一个指针变量,指针变量就是一个*,其余的*表示的是这个指针变量只能存放什么类型变量的地址。
比如int** * * a表示指针变量 a只能存放int***型变量的地址。
在内存中,数组是一块连续的内存空间:
a[2]={1,2};
printf(%x\n,a);//16进制
printf(%x\n,a+1);
printf(%d\n,*(a+1));
这里,如果a为0x0001,a+1应为0x0005。
*(a+1)=2
第 0 个元素的地址称为数组的首地址,数组名实际就是指向数组首地址,当我们通过array[1]或者*(array + 1)去访问数组元素的时候。
实际上可以看做 address[offset],address为起始地址,offset为偏移量,但是注意这里的偏移量offset不是直接和 address相加,而是要乘以数组类型所占字节数,也就是:
address + sizeof(int) * offset
学过汇编的同学,一定对这种方式不陌生,这是汇编中寻址方式的一种:基址变址寻址。
尽管数组名字有时候可以当做指针来用,但数组的名字不是指针。
最典型的地方就是在 sizeof:
printf("%u", sizeof(a));//8,整个数组长度
printf("%u", sizeof(pa));//4
数组的类型由元素的类型和数组的长度共同构成。而 sizeof就是根据变量的类型来计算长度的,并且计算的过程是在编译期,而不会在程序运行时。
编译器在编译过程中会创建一张专门的表格用来保存变量名及其对应的数据类型、地址、作用域等信息。
sizeof是一个操作符,不是函数,使用 sizeof时可以从这张表格中查询到符号的长度。
所以,这里对数组名使用sizeof可以查询到数组实际的长度。
pa仅仅是一个指向 int 类型的指针,编译器根本不知道它指向的是一个整数,还是一堆整数。
存储上和一维数组没有本质区别。连续内存
int array[n][m]
访问: array[a][b]
那么被访问元素地址的计算方式就是: array + (m * a + b)
这个就是二维数组在内存中的本质,其实和一维数组是一样的,只是语法糖包装成一个二维的样子。
因此,才会有直接用指针操作效率更高。
void 表达的意思就是没有返回值或者参数为空。
但是对于 void 型指针却表示通用指针,可以用来存放任何数据类型的引用。
void 指针最大的用处就是在 C 语言中实现泛型编程,因为任何指针都可以被赋给 void 指针,void 指针也可以被转换回原来的指针类型, 并且这个过程指针实际所指向的地址并不会发生变化。
不能对 void 指针解引用
指针数组:
ref
https://blog.csdn.net/qq_28114615/article/details/86434837
https://blog.csdn.net/TheSkyLee/article/details/109543418?utm_medium=distribute.pc_relevant.none-task-blog-OPENSEARCH-3.control&depth_1-utm_source=distribute.pc_relevant.none-task-blog-OPENSEARCH-3.control
https://blog.csdn.net/ly_w1989/article/details/50213011
https://blog.csdn.net/qq_22654611/article/details/52838622