导读:指针是 C 语言的灵魂,该如何真正理解并运用呢?这篇文章告诉你答案!
终于到了 C 语言中最为重要的指针环节了。之前一直以积累为主,不敢写,或者说不愿意写,因为没有足够的高度写出来的东西很多都是片面的,当然现在我也不敢说我目前写出来的就一定是全面的,只是对于普通的程序员来说,也算比较全面了吧!当然因为东西太多,有可能会有忘记的地方,到时候再更新补充吧。 这里将数组和指针放在一块介绍,是因为他们很像,很像的东西放在一块比较能找出它们的差异性了,以后使用的时候也就不容易犯迷糊了。并且这里将穿插测试代码和从汇编的角度去理解 C 语言,这样可能更容易掌握指针。 注意,所有的测试工作都是在 KEIL 、STM32开发平台下的,其他平台不敢保证这是对的。 先概括一下本文大纲(如果道友看到的不全,请关注公众号查看): 1、解析指针的过程与意义(重点) 2、指针大小、强制转换、二级指针、结构体指针、函数指针 3、指针和数组 4、指针与寄存器 5、指针与外设 6、溢出与使用效率问题
解析指针的过程与意义(重点)
什么是指针?鱼鹰不想用教科书式的语句去介绍,这样就失去了本篇笔记的意义了。 现在我们想象这样一个场景,广场里有一个个由学生组成的方阵,每个方阵有大有小,这些学生由教官统一指挥(军训场景)。
现在教官想要通知第一方阵的2号同学唱首军歌该怎么做?正常情况下应该是教官眼睛先看向第一方阵的位置,然后看向2号同学(发现是黄四郎),“抬起头来,唱首《中国军魂》”,实际上他想走个“走个虎虎生风,走个一日千里,走个恍如隔世”(教官寻找2号同学的过程其实就是结构体内数据寻址的过程)。
一个、两个人的通知,对教官来说不算啥,但是如果有很多人需要分别通知到呢? 怎么办?分级管理。
既然力不从心,那么我就找几个助手呗(也是学生,但不属于各个方阵内的学生,上图蓝色部分学生,学生助手比较特殊,占用位置空间大小是方阵内学生的4倍,毕竟是小官了)。 刚开始这些学生助手满脑子都是在想着怎么偷懒,不想军训,怎么办,只能先来个思想教育(指针清零),然后让每个助手记忆各自方阵的成员(指针赋值),还有可能时间紧,思想教育都省了,直接记忆。 一段时间后,每个助手对方阵内的各个成员都熟悉起来了。 熟悉到什么程度呢?你随便问一个位置他都知道那里站的是谁,比如10号位置就是张三,12号位置是李四。 但是你要问他其它方阵内的同学,他就支支吾吾了。 现在假设他们管理的方阵如下:
现在管理很明确了,咱再来通知一遍! 为了通知黄四郎唱军歌给教官听,教官就得先告诉助手学生A,再让A同学通知2号唱歌。 其他方阵内的通知也是同样道理。 这样一来,想通知哪个方阵的哪位同学,只要通知管理该方阵的助手即可,该助手自会通知到具体的同学的。 如此这般,教官只要管理六个人,就能把六个方阵的所有学生都管理起来了,大大减少了教官的管理负担,但是好处仅是如此吗?非也! 现在假设第一方阵的学生都有一个替身(可理解为影分身,特点就是基因相同,也长得一样,比如1号同学很聪明,那它的替身也聪明,3号同学比较笨,它的替身也比较笨,但是它们的记忆可能不一样):
现在助手人数只有那么多,不够该咋办? 从道理上讲,应该找管理第一方阵的助手A代理替身第一方阵,为啥,因为他最熟悉第一方阵的人员配置啊(比如他知道21号同学有事请假了,所以替身第一方阵也没有它的替身,这样教官要找21号时助手A就可以马上他说不在了),但是学生助手A不愿意啊,这怎么行,一个人管两个方阵,会累死人的,可教官不管,毕竟上哪找一个能很快熟悉第一方阵的人啊。 但是既想让助手A管理分身方阵,又要照顾助手A的心情,不能有让他抗拒心理,咋办? 很快,教官想出了办法:催眠(换句话说,就是修改记忆)。 教官是个催眠高手,很快助手A就被催眠了,虽然第一方阵和替身方阵长得一模一样,但是它们站的位置是不一样的,所以他认定了离教官远的那个方阵是他管理的方阵,而旁边那个才是分身方阵。
现在继续通知2号黄四郎(“咋又是我啊”),不过这一次通知的是他的替身,这下该怎么做呢。 因为事先助手A已经被催眠了,所以当教官告诉助手A要找黄四郎时,助手A自然而然的通知了替身方阵内的黄四郎,而且他很高兴的执行了,因为不用多管一个方阵了。 当教官要通知真正的黄四郎时,又得催眠一遍,让助手A认定离教官近的是自己管理的方队,没办法,缺人嘛,只能教官辛苦一下了。 现在咱们再假设一种情况,第四方阵的替身组成了一个新方阵,有几个推迟上学的赶回来军训了,教官将他们安排在第四方阵后面:
同样的,还是缺人,这个时候应该催眠谁呢? 当然是学生助手D了,为啥,因为第四方阵他最熟悉啊,第四方阵(部分替身)和第四方阵只有两点不同: 第一:站的位置不一样。 第二:多了后面部分迟到同学。 催眠他去管理这部分学生是不错的选择,但是因为学生D只对原生的第四方阵熟悉,对后面赶来的同学不熟悉,无法管理,也不知道原来30号同学后面还有一堆人(嗯,近视了),所以教官问催眠后的助手D方阵共有几人时,只会说共有30人,4人未到。
所以教官被学生D误导了,他很相信学生D报告的结果,但是如果他去替身方阵后面查看的话,会发现那里还站着不少人呢! 上述场景描述应该很容易就理解了,那么如何和我们的C语言指针联系起来呢? 教官是CPU,一个个学生所站的位置就是内存空间,而学生是这块空间的属性(聪明还是笨,或者其他属性),而学生的记忆就是内存空间的内容。 那么方阵是什么?由程序定义的数据结构。这个数据结构需要占用空间,有大有小,也可能内部空缺(内存对齐需要)。 那么那些助手又是什么?本篇的主角:指针。虽然它担任着管理任务,但是它的本质还是学生,只是赋予了管理职责(它也可以只管理一个学生(字节),不一定是方阵(结构体等))。 那么那两个假设在C语言中又代表了什么? 重新赋值和强制转换赋值(强行把一个大方阵交给一个只能管理小方阵的助手,但是他自己本身是不知道自己管理了超出自己能力的方阵的)。 现在我们再往细了说,和实际C语言代码联系起来:
上图定义了三个方阵,每个方阵的结构是不太一样的(关于typedef和struct关键字可看鱼鹰相关笔记),为了更好的理解接下来的知识,以方阵 1 为例介绍上述代码的含义。 注释“方阵蓝图”部分,就如注释一般,就是一个蓝图,它只是告诉编译器,这个方阵1应该怎么安排,但实际上根本还不存在这个东西,这就是一张蓝图,只用于参考之用(可理解为建筑工人拿着一张建筑图,准备按建筑图的模样建造一栋房子,但还没开始建)。 那怎么利用这个蓝图搭建一个方阵出来呢(按建筑图搭建出一个实实在在的房子)?在C语言里面只需要一句话即可完成,就是上图中另一个方框内的代码。 那么第一个假设在C语言里是怎样的呢? 首先需要一个替身方阵,这个方阵和方阵1应该是一样的,因为方阵 1 是按照蓝图设计的实物(在内存中占有空间,而方阵蓝图不存在内存中),所以咱们可以用蓝图继续建一个方阵出来:
可以看到这个替身方阵1(SquareMatrix_1_StandIn)和方阵1的创建过程没有两样,换句话说,两者是等价的,只有一点区别,就是占用的内存位置不一样(可理解为你在北京建了一个房子,然后又在深圳按照北京的房子样式又建了一栋一模一样的房子,区别就是现实中你可以直接以北京的房子为蓝图建造,而在C语言中,你必须先有一个蓝图,才能建一栋北京的房子(SquareMatrix_1)和深圳的房子(SquareMatrix_1_StandIn))。 现在我们理解了替身方阵和方阵之间的关系,再来说说前面所说的催眠问题,在C语言中是如何实现的呢? 首先需要一个助手,这样才有催眠对象嘛。这个助手有什么特别(属性)呢:他只能管理以方阵1为蓝图构建的方阵。在C语言怎么达到这个要求呢?
这样符合要求的助手A就诞生了。 现在教练(CPU)让他管理去管理方阵1:
可以看到,在实际代码中根本没有CPU(教官)的身影,但它却无处不在,因为代码就是靠CPU一步步执行的。 现在又想让他管理替身方阵,就得催眠一下:
可以看到,你理解的催眠在C代码上和开始教练就让助手A管理方阵1没啥区别,从宏观上理解,开始就让助手A管理方阵1也可以认为是一次催眠操作,只是在这次催眠之前助手A是没有任何管理对象的。从这里也可以理解,单独的没有指向一块内存的指针是没有意义的,就和光杆司令一样,只有一个司令,没有兵,怎么打仗,这样理解之后,你就不会让光杆司令(没有分配士兵)去打仗了,而只要有一个小兵,那么司令就能指挥了。 那么第二个假设在C语言中又是怎么回事呢?
按照C语言要求,必须先有一个蓝图(专业术语称为“声明”),然后才能创建一个新方阵出来,并且需要一个能管理方阵2的助手B:
因为在诞生助手B的时候,基因上决定了他只能管理方阵1,对使用新蓝图SquareMatrix_2_PartTypedef创建的SquareMatrix_2_Part是无法管理的,但是因为SquareMatrix_2_Part前面部分是利用方阵2的蓝图构建的,所以让他管理前面部分的倒是没有问题,这个和催眠又有点差别:
前面说了C语言,现在又不得不说一说汇编语言与编译器。那么怎么理解它们之间的关系呢? C语言和汇编语言之间隔了一个编译器。 用通俗的话介绍就是,一个不懂鸟语的人(C程序员),要和鸟(单片机)对话,就要一个懂鸟语的人做翻译官,通过这个翻译官将人类的语言(C语言)和转化成鸟语(汇编语言),从而实现对话。 只不过,他们的交流是一次性完成的,就是说不懂鸟语的人把所有要说的话(C语言代码)一次性告诉翻译官,翻译官翻译好了之后(汇编语言代码),再告诉鸟(单片机),“你应该干啥、不该干啥”,小鸟记住了之后,就一遍遍按照要求重复执行了。 从这里可以明白,单片机存放的是汇编代码(更准确是0、1的二进制),而不是我们程序员看到的C语言代码。 另外还可以知道的一点就是,与其说我们程序员是在和单片机打交道,不如说同时在和单片机与编译器打交道,既要了解单片机(鸟)能干什么,也要清楚编译器的规则,不能让翻译官译错了你的意思,否则你说往东,他还以为你说往西呢! 看过一个故事,说Unix操作系统的创造者,总是能很快的黑进任何一个Unix系统,别人一直以为是Unix代码中留下了暗门,但查了很久也没发现,后来才知道是编译源码的编译器留下了暗门。 不管这个故事是否真实,但编译器的重要性毋庸置疑。 现在我们再简单聊聊编译器的一点好处,让我们知道为什么需要编译器。 了解汇编的人都知道,单片机的内存都是程序员分配的,而且是直接和内存地址打交道。 比如1号内存放一个同学,2号内存放另一个同学(所有内存空间都进行了编号,就是地址),找人的时候就通过这个编号找。但是数字记忆不是人类的强项,所以就给这些编号命名了一个别名,比如1号叫做张三,2号是李四,当叫2号唱歌时,只要叫李四即可。
但事实上很少有人这么干!毕竟如果只是取个别名也没方便到哪里去。 更多的时候,我们都是告诉编译器,我们需要两个空间,一个空间放张三,一个空间放李四,但具体放在哪,我不管:
编译后,从 .map 文件中(如何打开该文件可参考鱼鹰以前的笔记)可知道,编译器将这两个命名为张三和李四的空间放在了0x20000018 和 0x20000019 (注意这里没有4字节对齐)里面(事实上,每次改变代码后编译,这些空间地址可能会发生变化,不变的是这个空间名,你总是能通过这个空间名去访问一块内存,只是可能两次编译后再访问时,它所在的空间地址不一样罢了,一般来说,这没什么大不了的,毕竟每次使用这块空间前都会进行初始化)。 所以在这件事上,编译器为我们做了两件事:第一,分配内存地址;第二,命名这块空间名字(事实上还有第三件事情,规定这个空间只能放char类型数据)。 而张三、李四这个名字不仅代表了两块空间,还有一个额外的空间属性要求:只能存放char类型数据,操作这些空间时一定要注意这一点(有些错误操作可能编译器会发出提示信息,而有些操作编译器发现不了,所以需要额外注意)! 其实引入编译器的好处不止于此,这里只是简单介绍,不深入讲述。 现在我们再来从汇编语言的角度去看指针赋值与强制转化过程。
从上面可以看到,所谓的指针赋值和强制转化,在汇编代码的层面上可以说完全一样,都只是赋值操作,都是将方阵的地址赋值给寄存器R0,方阵指针的地址给寄存器R1,最后方阵的地址赋值给方阵指针所在的地址(从这可以了解到,内存和内存之间不可以直接操作,必须通过寄存器中转,这就是为什么明明只有一条C语言代码,汇编语句却有多条的原因之一了,C语言封装了很多操作细节,虽然我们可以不去深究,但必须了解它的存在)。 以第一条C语句为例,用示意图表示(只把涉及到的内存空间画出,填充颜色部分为实际内存空间,未填充部分用于说明空间地址或者寄存器,xxxxxxxx表示不必关心原来的内容是什么,红色表示操作后发生的变化):
而操作结构体内的变量Student_12如下:
首先把0赋值给R0,把指针所在的空间地址(0x080010CC处存在一个地址)赋值给R1,再把R1存在的地址的内容赋值给R1(这时这个R1存放的就是方阵结构体的首地址),最后把R0赋值给相对R1地址偏移4的Student_12中。 示意图如下:
而直接操作结构体的方式如下(不采用指针):
首先将0x01赋值给R0,然后将0x080010CC处的内容赋值给R1,最后把R1的内容当做地址,并将R0赋值给这个地址相对偏移0x04的地址处。 示意图如下:
从中对比两者操作可以发现,当不使用指针操作0x20000054空间时,只需要三条语句,而使用指针,因为涉及到对0x20000010内存空间的操作,多增加了一条指令,即从0x20000010(指针)处获得操作基地址0x20000050,再做最终的赋值操作,除此之外的操作指令都是类似的。
(通过KEIL查看结构体内的变量在内存中的地址) 从上面也可了解到,所谓的指针,只是人为的把内存里面的内容当做地址而已,因为你把存在0x20000010处的0x20000050当做了地址去操作,才存在如下关系:
你使用C语言去操作0x20000010时才会影响到0x20000050处的变量。 但是如果你不把它0x20000050当做地址,而只是当做普通数据处理也是可以的:
(事实上,当增加变量data 编译之后,SquareMatrix_1_Assistant的内容不再是0x20000050,而是变成了0x20000058,这里我们假设编译器为之前的变量分配的空间地址不变) 当然你现在又希望把这个数据当成地址,咋办?
这样一来,不仅把数据0x20000050当成了地址,还改变了可访问的数据大小,即只能访问int大小数据,结构体内的其他数据无法访问:
我们尝试对0x20000058这个地址赋值:
当你理解了上述内容,你会发现,指针,不过如此! 其实通过以上内容的讲述,我们也可以总结使用一块内存需要注意的三个要素:地址、基因(属性)、内容。 所谓地址,就是这个空间所在的地址;属性,就是规定这块内存能存放什么东西,char还是int,或者其他自定义类型等等;而内容就是内存中存放的东西,这是程序员真正需要的东西,前两者都是为其服务的。 那这个和指针有啥关系,别忘了前面所说,指针也是一块内存,只是这个内存的属性规定了两个,第一,存放指针(这个也只是普通数据,只是需要按照C语言要求处理),第二,这个指针指向的内容是char、int……数据类型而已(也可能会规定其他属性,这里不讨论),从内存空间的角度上,所谓的指针和普通的数据类型没有本质区别。 那么学习指针有什么好办法吗?鱼鹰认为,除了要深刻理解指针外,还有就是要像鱼鹰一样,画图去表示它们之间的关系(不用像前面一样画的那么清楚,画个示意图即可),只有这样,你才不会被那些指向关系搞得稀里糊涂。也只有这样,你才能在代码中灵活运行指针去做你任何想做的事情。
这篇笔记修修改改不知道多少次,原以为能比较快就能写好的,但事实上花了好几天才写完,因为鱼鹰要尽可能的将故事贴合实际的 C语言运行情况,所以花了不少时间去思考,但真正难的还在于如何把心中所想画出相应示意图,这个是最耗费时间的。
尽管如此,指针这一块还是没有完成,道友看了前面大纲也可了解,这只是第一点内容,后面还有五点没写,以后有时间再说吧。
编辑:黄飞
评论
查看更多