乐趣区

关于c:C语言重点指针篇一文让你完全搞懂指针-从内存理解指针-指针完全解析

注:这篇文章好好看完肯定会让你把握好指针的实质

C 语言最外围的常识就是指针,所以,这一篇的文章主题是「指针与内存模型」

说到指针,就不可能脱离开内存,学会指针的人分为两种,一种是不理解内存模型,另外一种则是理解。

不理解的对指针的了解就停留在“指针就是变量的地址”这句话,会比拟胆怯应用指针,特地是各种高级操作。

而理解内存模型的则能够把指针用得炉火纯青,各种 byte 随便操作,让人直呼 666。

一、内存实质

编程的实质其实就是操控数据,数据寄存在内存中。

因而,如果能更好地了解内存的模型,以及 C 如何治理内存,就能对程序的工作原理洞若观火,从而使编程能力更上一层楼。

大家真的别认为这是空话,我大一整年都不敢用 C 写上千行的程序也很抗拒写 C。

因为一旦上千行,经常出现各种莫名其妙的内存谬误,一不小心就产生了 coredump…… 而且还无从排查,剖析不出起因。

相比之下,那时候最喜爱 Java,在 Java 里轻易怎么写都不会产生相似的异样,顶多偶然来个 NullPointerException,也是比拟好排查的。

直到起初对内存和指针有了更加粗浅的意识,才缓缓会用 C 写上千行的我的项目,也很少会再有内存问题了。(过于自信

「指针存储的是变量的内存地址」这句话应该任何讲 C 语言的书都会提到吧。

所以,要想彻底了解指针,首先要了解 C 语言中变量的存储实质,也就是内存。

1.1 内存编址

计算机的内存是一块用于存储数据的空间,由一系列间断的存储单元组成,就像上面这样,

每一个单元格都示意 1 个 Bit,一个 bit 在 EE 业余的同学看来就是高下电位,而在 CS 同学看来就是 0、1 两种状态。

因为 1 个 bit 只能示意两个状态,所以大佬们规定 8 个 bit 为一组,命名为 byte。

并且将 byte 作为内存寻址的最小单元,也就是给每个 byte 一个编号,这个编号就叫内存的 地址

这就相当于,咱们给小区里的每个单元、每个住户都调配一个门牌号:301、302、403、404、501……

在生活中,咱们须要保障门牌号惟一,这样就能通过门牌号很精准的定位到一家人。

同样,在计算机中,咱们也要保障给每一个 byte 的编号都是惟一的,这样才可能保障每个编号都能拜访到惟一确定的 byte。

1.2 内存地址空间

下面咱们说给内存中每个 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 的内存条也是无奈充分利用起来的。

好了,这就是内存和内存编址。

1.3 变量的实质

有了内存,接下来咱们须要思考,int、double 这些变量是如何存储在 0、1 单元格的。

在 C 语言中咱们会这样定义变量:

int a = 999;
char c = 'c';

当你写下一个变量定义的时候,实际上是向内存申请了一块空间来寄存你的变量。

咱们都晓得 int 类型占 4 个字节,并且在计算机中数字都是用补码(不理解补码的记得去百度)示意的。

999 换算成补码就是:0000 0011 1110 0111

这里有 4 个 byte,所以须要四个单元格来存储:

有没有留神到,咱们把高位的字节放在了低地址的中央。

那能不能反过来呢?

当然,这就引出了 大端和小端。

像下面这种将高位字节放在内存低地址的形式叫做 大端

反之,将低位字节放在内存低地址的形式就叫做 小端

下面只阐明了 int 型的变量如何存储在内存,而 float、char 等类型实际上也是一样的,都须要先转换为补码。

对于多字节的变量类型,还须要依照大端或者小端的格局,顺次将字节写入到内存单元。

记住下面这两张图,这就是编程语言中所有变量的在内存中的样子,不论是 int、char、指针、数组、构造体、对象 … 都是这样放在内存的。

二、指针是什么货色?

2.1 变量放在哪?

下面我说,定义一个变量理论就是向计算机申请了一块内存来寄存。

那如果咱们要想晓得变量到底放在哪了呢?

能够通过运算符 & 来获得变量理论的地址,这个值就是变量所占内存块的起始地址。

(PS: 实际上这个地址是虚拟地址,并不是真正物理内存上的地址

咱们能够把这个地址打印进去:

printf("%x", &a);

大略会是像这样的一串数字:0x7ffcad3b8f3c

2.2 指针实质

下面说,咱们能够通过 & 符号获取变量的内存地址,那获取之后如何来示意这是一个 地址,而不是一个一般的值呢?

也就是在 C 语言中如何示意地址这个概念呢?

对,就是指针,你能够这样:

int *pa = &a; 

pa 中存储的就是变量 a 的地址,也叫做指向 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 类型的值,还是另外一个变量的地址(即指针)。

这如果齐全靠咱们编程人员去人脑记忆了,会引入复杂性,并且无奈通过编译器检测一些语法错误。

而通过 int * 去定义一个指针变量,会十分明确: 这就是另外一个 int 型变量的地址。

编译器也能够通过类型查看来排除一些编译谬误。

这就是指针存在的必要性。

实际上任何语言都有这个需要,只不过很多语言为了安全性,给指针戴上了一层桎梏,将指针包装成了援用。

可能大家学习的时候都是自然而然的承受指针这个货色,然而还是心愿这段啰嗦的解释对你有肯定启发。

同时,在这里提点小问题:

既然指针的实质都是变量的内存首地址,即一个 int 类型的整数。

那为什么还要有各种类型呢?

比方 int 指针,float 指针,这个类型影响了指针自身存储的信息吗?

这个类型会在什么时候发挥作用?

2.3 解援用

下面的问题,就是为了引出指针解援用的。

pa中存储的是 a 变量的内存地址,那如何通过地址去获取 a 的值呢?

这个操作就叫做 解援用 ,在 C 语言中通过运算符 * 就能够拿到一个指针所指地址的内容了。

比方 *pa 就能取得 a 的值。

咱们说指针存储的是变量内存的首地址,那编译器怎么晓得该从首地址开始取多少个字节呢?

这就是指针类型发挥作用的时候,编译器会依据指针的所指元素的类型去判断应该取多少个字节。

如果是 int 型的指针,那么编译器就会产生提取四个字节的指令,char 则只提取一个字节,以此类推。

上面是指针内存示意图:

pa 指针首先是一个变量,它自身也占据一块内存,这块内存里寄存的就是 a 变量的首地址。

当解援用的时候,就会从这个首地址间断划出 4 个 byte,而后依照 int 类型的编码方式解释。

2.4 活学活用

别看这个中央很简略,但却是深刻理解指针的要害。

举两个例子来具体阐明:

比方:

float f = 1.0;
short c = *(short*)&f; 

你能解释分明下面过程,对于 f 变量,在内存层面产生了什么变动吗?

或者 c 的值是多少?1?

实际上,从内存层面来说,f 什么都没变。

如图:

假如这是 f 在内存中的位模式,这个过程实际上就是把 f 的前两个 byte 取出来而后依照 short 的形式解释,而后赋值给 c

具体过程如下:

  1. &f获得 f 的首地址
  2. (short*)&f

下面第二步什么都没做,这个表达式只是说:

“噢,我认为 f 这个地址放的是一个 short 类型的变量”

最初当去解援用的时候 *(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。

如果你了解了下面这些内容,那么应用指针肯定会更加的自若。

2.6 看个小问题

讲到这里,咱们来看一个问题,这是一位群友问的,这是他的需要:

这是他写的代码:

他把 double 写进文件再读出来,而后发现打印的值对不上。

而要害的中央就在于这里:

char buffer[4];
...
printf("%f %x\n", *buffer, *buffer);

他可能认为 buffer 是一个指针(精确说是数组),对指针解援用就该拿到外面的值,而外面的值他认为是从文件读出来的 4 个 byte,也就是之前的 float 变量。

留神,这一切都是他认为的,实际上编译器会认为:

“哦,buffer 是 char 类型的指针,那我取第一个字节进去就好了”。

而后把第一个字节的值传递给了 printf 函数,printf 函数会发现,%f 要求接管的是一个 float 浮点数,那就会主动把第一个字节的值转换为一个浮点数打印进去。

这就是整个过程。

谬误要害就是,这个同学误认为,任何指针解援用都是拿到外面“咱们认为的那个值”,实际上编译器并不知道,编译器只会傻傻的依照指针的类型去解释。

所以这里改成:

printf("%f %x\n", *(float*)buffer, *(float*)buffer);

相当于明确的通知编译器:

buffer 指向的这个中央,我放的是一个 float,你给我依照 float 去解释”

三、构造体和指针

构造体内蕴含多个成员,这些成员之间在内存中是如何寄存的呢?

比方:

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 域。

因而:

((fraction*)(&fp.denom))->num = 5

实际上扭转的是 fp.denom,而

((fraction*)(&fp.denom))->denom = 12

则是将最下面四个字节赋值为 12。

当然,往那四字节内存写入值,后果是无奈预测的,可能会造成程序解体,因为兴许那里恰好存储着函数调用栈帧的要害信息,也可能那里没有写入权限。

大家初学 C 语言的很多 coredump 谬误都是相似起因造成的。

所以最初输入的是 5。

为什么要讲这种看起来莫名其妙的代码?

就是为了阐明构造体的实质其实就是一堆的变量打包放在一起,而拜访构造体中的域,就是通过构造体的起始地址,也叫基地址,而后加上域的偏移。

其实,C++、Java 中的对象也是这样存储的,无非是他们为了实现某些面向对象的个性,会在数据成员以外,增加一些 Head 信息,比方 C ++ 的虚函数表。

实际上,咱们是齐全能够用 C 语言去模拟的。

这就是为什么始终说 C 语言是根底,你真正懂了 C 指针和内存,对于其它语言你也会很快的了解其对象模型以及内存布局。

四、多级指针

说起多级指针这个货色,我以前大一,最多了解到 2 级,再多真的会把我绕晕,常常也会写错代码。

你要是给我写个这个:int ******p 能把我搞解体,我预计很多同学当初就是这种状况????

其实,多级指针也没那么简单,就是指针的指针的指针的指针 …… 非常简单。

明天就带大家认识一下多级指针的实质。

首先,我要说一句话,没有多级指针这种货色,指针就是指针,多级指针只是为了咱们不便表白而取的逻辑概念。

首先看下生存中的快递柜:

这种大家都用过吧,丰巢或者超市储物柜都是这样,每个格子都有一个编号,咱们只须要拿到编号,而后就能找到对应的格子,取出外面的货色。

这里的格子就是内存单元,编号就是地址,格子里放的货色就对应存储在内存中的内容。

假如我把一本书,放在了 03 号格子,而后把 03 这个编号通知你,你就能够依据 03 去取到外面的书。

那如果我把书放在 05 号格子,而后在 03 号格子只放一个小纸条,下面写着:「书放在 05 号」。

你会怎么做?

当然是关上 03 号格子,而后取出了纸条,依据下面内容去关上 05 号格子失去书。

这里的 03 号格子就叫指针,因为它外面放的是指向其它格子的小纸条(地址)而不是具体的书。

明确了吗?

那我如果把书放在 07 号格子,而后在 05 号格子 放一个纸条:「书放在 07 号」,同时在 03 号格子放一个纸条「书放在 05 号」

这里的 03 号格子就叫二级指针,05 号格子就叫指针,而 07 号就是咱们平时用的变量。

顺次,可类推出 N 级指针。

所以你明确了吗?同样的一块内存,如果寄存的是别的变量的地址,那么就叫指针,寄存的是理论内容,就叫变量。

int a;
int *pa = &a;
int **ppa = &pa;
int ***pppa = &ppa;

下面这段代码,pa 就叫一级指针,也就是平时常说的指针,ppa 就是二级指针。

内存示意图如下:

不论几级指针有两个最外围的货色:

  • 指针自身也是一个变量,须要内存去存储,指针也有本人的地址
  • 指针内存存储的是它所指向变量的地址

这就是我为什么多级指针是逻辑上的概念,实际上一块内存要么放理论内容,要么放其它变量地址,就这么简略。

怎么去解读 int **a 这种表白呢?

int ** a 能够把它分为两局部看,即 int**a,前面 *a 中的* 示意 a 是一个指针变量,后面的 int* 示意指针变量 a

只能寄存 int* 型变量的地址。

对于二级指针甚至多级指针,咱们都能够把它拆成两局部。

首先不论是多少级的指针变量,它首先是一个指针变量,指针变量就是一个 *,其余的* 示意的是这个指针变量只能寄存什么类型变量的地址。

比方 int****a 示意指针变量 a 只能寄存 int*** 型变量的地址。

五、指针与数组

5.1 一维数组

数组是 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:

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求得的永远是它自身所占用的字节数。

5.2 二维数组

大家不要认为二维数组在内存中就是按行、列这样二维存储的,实际上,不论二维、三维数组 … 都是编译器的语法糖。

存储上和一维数组没有本质区别,举个例子:

int array[3][3] = {{1, 2,3}, {4, 5,6},{7, 8, 9}};
array[1][1] = 5;

或者你认为在内存中 array 数组会像一个二维矩阵:

1        2        3
4        5        6
7        8        9

可实际上它是这样的:

1        2        3        4        5        6        7        8        9

和一维数组没有什么区别,都是一维线性排列。

当咱们像 array[1][1] 这样去拜访的时候,编译器会怎么去计算咱们真正所拜访元素的地址呢?

为了更加通用化,假如数组定义是这样的:

int array[n][m]

拜访: array[a][b]

那么被拜访元素地址的计算形式就是: array + (m * a + b)

这个就是二维数组在内存中的实质,其实和一维数组是一样的,只是语法糖包装成一个二维的样子。

六、神奇的 void 指针

想必大家肯定看到过 void 的这些用法:

void func();
int func1(void);

在这些状况下,void 表白的意思就是没有返回值或者参数为空。

然而对于 void 型指针却示意通用指针,能够用来寄存任何数据类型的援用。

上面的例子就 是一个 void 指针:

void *ptr;

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 还能够用来实现 C 语言中的多态,这是一个挺好玩的货色。

不过也有须要留神的:

  • 不能对 void 指针解援用

    比方:

int num;
void *pv = (void*)#
*pv = 4; // 谬误

为什么?

因为解援用的实质就是编译器依据指针所指的类型,而后从指针所指向的内存间断取 N 个字节,而后将这 N 个字节依照指针的类型去解释。

比方 int * 型指针,那么这里 N 就是 4,而后依照 int 的编码方式去解释数字。

然而 void,编译器是不晓得它到底指向的是 int、double、或者是一个构造体,所以编译器没法对 void 型指针解援用。

七、花式秀技

很多同学认为 C 就只能面向过程编程,实际上利用指针,咱们一样能够在 C 中模拟出对象、继承、多态等货色。

也能够利用 void 指针实现泛型编程,也就是 Java、C++ 中的模板。

大家如果对 C 实现面向对象、模板、继承这些感兴趣的话,能够踊跃一点,点赞,留言~ 呼声高的话,我就再写一篇。

实际上也是很乏味的货色,当你晓得了如何用 C 去实现这些货色,那你对 C++ 中的对象、Java 中的对象也会了解得更加透彻。

比方为啥有 this 指针,或者 Python 中的 self 到底是个啥?

对于指针想写的内容还有很多,这其实也只算是开了个头,限于篇幅,当前有机会补齐以下内容:

  • 二维数组和二维指针
  • 数组指针和指针数组
  • 指针运算
  • 函数指针
  • 动态内存调配: malloc 和 free
  • 堆、栈
  • 函数参数传递形式
  • 内存泄露
  • 数组进化成指针
  • const 润饰指针

絮叨

我其实挺想写一个系列,大略就是对于内存、指针、援用、函数调用、堆栈、面向对象实现机制等等这样的底层一点的货色。

不晓得大家有趣味没有,有趣味的话,那就给我点个赞或者在看,数量够多,我就会写下去。

文章继续更新,能够微信搜一搜「编程指北」第一工夫浏览,有我筹备的编程电子书和一线大厂面试材料

退出移动版