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
。
详细过程如下:
&f
取得f
的首地址(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
能把我搞崩溃,我估计很多同学现在就是这种情况
C语言重点——指针篇(一文让你完全搞懂指针)| 从内存理解指针 | 指针完全解析的更多相关文章
- C\C++语言重点——指针篇 | 为什么指针被誉为 C 语言灵魂?(一文让你完全搞懂指针)
本篇文章来自小北学长的公众号,仅做学习使用,部分内容做了适当理解性修改和添加了博主的个人经历. 注:这篇文章好好看完一定会让你掌握好指针的本质! 看到标题有没有想到什么? 是的,这一篇的文章主题是「指 ...
- 一文带你快速搞懂动态字符串SDS,面试不再懵逼
目录 redis源码分析系列文章 前言 API使用 embstr和raw的区别 SDSHdr的定义 SDS具体逻辑图 SDS的优势 更快速的获取字符串长度 数据安全,不会截断 SDS关键代码分析 获取 ...
- 面试都在问的微服务、服务治理、RPC、下一代微服务框架... 一文带你彻底搞懂!
文章每周持续更新,「三连」让更多人看到是对我最大的肯定.可以微信搜索公众号「 后端技术学堂 」第一时间阅读(一般比博客早更新一到两篇) 单体式应用程序 与微服务相对的另一个概念是传统的单体式应用程序( ...
- 面试都在问的「微服务」「RPC」「服务治理」「下一代微服务」一文带你彻底搞懂!
❝ 文章每周持续更新,各位的「三连」是对我最大的肯定.可以微信搜索公众号「 后端技术学堂 」第一时间阅读(一般比博客早更新一到两篇) ❞ 单体式应用程序 与微服务相对的另一个概念是传统的「单体式应用程 ...
- 一文带大家彻底搞懂Hystrix!
前言? Netflix Hystrix断路器是什么? Netflix Hystrix是SOA/微服务架构中提供服务隔离.熔断.降级机制的工具/框架.Netflix Hystrix是断路器的一种实现,用 ...
- 一文让你彻底搞懂 vue-Router
路由是网络工程里面的专业术语,就是通过互联把信息从源地址传输到目的地址的活动.本质上就是一种对应关系.分为前端路由和后端路由. 后端路由: URL 的请求地址与服务器上的资源对应,根据不同的请求地址返 ...
- 《C语言程序设计》指针篇<一>
指针 指针是C语言的精华,同时也是其中的难点和重点,我在近日对这一部分内容进行了重新的研读,把其中的一些例子自己重新编写和理解了一遍.此篇博客的内容即是我自己对此书例子的一些理解和总结. 一.大问题: ...
- 瘋子C语言笔记(指针篇)
指针篇 1.基本指针变量 (1)定义 int i,j; int *pointer_1,*pointer_2; pointer_1 = &i; pointer_2 = &j; 等价于 i ...
- 深入理解C语言中的指针与数组之指针篇
转载于http://blog.csdn.net/hinyunsin/article/details/6662851 前言 其实很早就想要写一篇关于指针和数组的文章,毕竟可以认为这是C语言的根本 ...
随机推荐
- Solon详解(十)- 怎么用 Solon 开发基于 undertow jsp tld 的项目?
Solon详解系列文章: Solon详解(一)- 快速入门 Solon详解(二)- Solon的核心 Solon详解(三)- Solon的web开发 Solon详解(四)- Solon的事务传播机制 ...
- 实验 6:OpenDaylight 实验——OpenDaylight 及 Postman 实现流表下发
一.实验目的 熟悉 Postman 的使用;熟悉如何使用 OpenDaylight 通过 Postman 下发流表. 二.实验任务 流表有软超时和硬超时的概念,分别对应流表中的 idle_timeou ...
- CentOS 7安装Nginx 1.10.2
安装epel-release源并进行安装 yum install epel-release yum update(时间会有点长) yum install nginx 相关操作: systemctl s ...
- 手把手教你AspNetCore WebApi:Serilog(日志)
前言 小明目前已经把"待办事项"功能实现了,API文档也搞定了,但是马老板说过,绝对不能让没有任何监控的项目上线的. Serilog是什么? 在.NET使用日志框架第一时间会想到N ...
- jquery购物车全选,取消全选,计算总金额
这是html代码 <div class="gwcxqbj"> <div class="gwcxd center"> <div cl ...
- c#之task与thread区别及其使用
如果需要查看更多文章,请微信搜索公众号 csharp编程大全,需要进C#交流群群请加微信z438679770,备注进群, 我邀请你进群! ! ! --------------------------- ...
- Windows下CertUtil校验和编码文件
目录 前言 CertUtil计算文件hash 计算MD2 计算MD4 计算MD5 计算SHA1 计算SHA256 计算SHA384 计算SHA512 文件base64编码 文件base64解码 文件h ...
- 发布MeteoInfo 1.2.8
增加了对SYNOP数据的支持(功能从C#版移植过来).数据可以从这里下载:http://weather.cod.edu/digatmos/syn/SYNOP数据搞气象的人应该多少知道些,类似MICAP ...
- 50种编程语言,一句 “Hello, World”!展现编程语言七十年发展!
mod confinment { use std::os::raw::{c_char}; extern "C" { pub fn puts(txt: *const c_char); ...
- 【Windows编程】入门篇——win 32窗口的hello word!
✍ Windows编程基础 1.Win 32应用程序基本类型 1) 控制台程序 不需要完善的windows窗口,可以使用DOS窗口方式显示 2) Win 32窗口程序 包含窗口的程序,可以通过窗 ...