C语言- - 剖析数据在内存中的存储
前言
还记得之前的指针类文章,我说过数据在内存中的存储是倒着存的。那么为什么是倒着存的呢?今天我们就来剖析一下原理,找出这个问题的答案。
长文警告!
一、数据类型
我们先来复习常见的数据类型
- char --- ---字符数据类型。占用 1 个字节
- short --- --- 短整形。占用 2 个字节
- int --- --- 整形。占用 4 个字节
- long --- --- 长整形。在x64环境占 8 个字节,在x86环占 4 个字节
- long long --- ----更长的整形。C99独占,占 8 个字节
- float --- ---单精度浮点型。占用 4 个字节
- double --- --- 双精度浮点型。占用 8 个字节
那么,这么多数据类型,他们的意义是什么呢?
其实,当你使用某个数据类型时,实际上是向内存申请开辟某某个字节用来存储数据。
其中还能细分
char
unsigned cha r
signed char
...(其余的各种类型同理)
unsigned代表无符号型
signed代表有符号型
这里面,除了char的类型是未定义(取决于编译器)其他的比如int啊,long什么的都是默认signed类型。
那么,为什么要区分呢?
数据在存储时,我这举个例子:
比如温度。温度是有零下几度零上几度这一说的。这里以-5度与10度来举例。
int在内存中占 4 个字节,一个字节是八位(1 byte = 8 bit)所以要占32位
10000000000000000000000000000101(-5)
00000000000000000000000000001010(10)
这里:最高位代表的是符号位,1表示负数0表示正数。数据是这么存的,所以一共占用了2^31这么多位。
但是日常生活中总有那些没有负数的数据。比如身高,比如体重。他们就应该使用unsiged类型的数据。再举个例子比如身高是193cm
00000000000000000000000011000001(193)
因为unsigned是无符号型,所以最高位不用管是否是正负数,所以他一共会占用2^32这么多位。
所以,当你创建变量时,总是正数的变量应该使用unsigned类型比较规范。
1.构造类型
- 数组类型:
比如 int arr[3] 实际就是 int[3],只是写法不同 - 结构体类型: struct
- 枚举类型: enum
- 联合类型:union
2.指针类型
- int* pi;
- float* pf;
- char* pc;
- void* pv;
3.空类型
void就是空类型,或者叫无类型。
通常用于函数的返回类型、函数的参数、指针类型
这里的返回类型指的是:函数无返回类型
函数的参数表示:函数不需要传递参数
其中void* 也被称为最万能的指针类型
二、整形在内存中的存储
1.原码、反码、补码
注:正整数原反补码均相同
首先,必须先知道一个重要的概念:数据在内存中是以二进制的数进行存储的,并且是以补码的形式进行存储的(这里浮点数除外吗,我么后面会谈论到)
原码:原码是一种直接表示数值的方法
反码:反码是一种用于简化二进制加减运算的表示方法
补码:补码是目前计算机系统中最常用来表示的方法
原码反码补码的运算规律是什么呢?
比如25 与 -25
25的原码:
00000000 00000000 00000000 00011001
0x 00 00 00. 19
反码:
00000000 00000000 00000000 00011001
补码:
00000000 00000000 00000000 00011001
-25的原码:
10000000 00000000 00000000 00011001
0x 80 00 00 19
反码:
11111111 11111111 11111111 11100110
0x FF FF FF E6
补码:
11111111 11111111 11111111 11100111
0x FF FF FF E7
总结一下规律:
除了正数外,原码就等于是数据本身 ,符号位为1
反码就是原码符号位不变,其他位按位取反
补码就是反码+1
反过来也一样,补码取反+1就是原码
核心操作:取反+1
而对于二进制转16进制:
先写出十六进制的标识0x,
然后每四位二进制位计算一次,最后拼起来就是十六进制了
内存中存放的是二进制位,十六进制一般是因为二进制位看的不方便才显示的,本质上存放的是二进制位
对于整形来说:数据存放在内存中实际存的是补码。
为什么呢?
- 在计算机系统中,数值一律用补码来表示和存储。原因就是可以让符号位和数值域统一处理。
- 加减法也可以统一处理。然后,补码与原码互相转换其运算过程是相通的,不需要额外的硬件电路。
举个例子:
比如 1 - 1, 1- 1可以转换成1 + (-1)
1的补码: 00000000 00000000 00000000 00000001
-1的补码:11111111 11111111 11111111 11111111
让这俩相加:
11111111 11111111 11111111 11111111(+1)
得到1 00000000 00000000 00000000 00000000
变成33位了,但是最高位会抛弃,所以得到全零。同时这也是 0 的补码。
2.大端存储与小端存储
前面的文章我们都说数据是存储在内存中的,并且是以相反的顺序存储的。但是为什么是反着来的呢?下面,我们就来探讨一下这个问题。
要谈论这个问题,我们就要引申出两个概念:大端存储 与小端存储
那么,什么是大端存储什么是小端存储呢?我们来举个例子:
假设这里有一个地址:0x11223344
在大端存储模式中,它是0x11223344
在小端存储模式中,它是0x44332211
其实说白了,一个是正着来的一个反着来的。
这里的字节序我们可以举个简单的例子来帮助理解:
比如十进制的150。1表示百位,5表示十位,0表示个位。
那么,这里的高位就是1,地位就是0
大端字节序存储就是高位放在低地址处,低位放在高地址处 。在计算机的存储中,就是150
小端字节序存储就是高位放在高地址处,低位放在低地址处 。在计算机的存储中,就是051
注意了:这里的数值并不影响我们的存储顺序,它是以字节为单位来存储的,不是以数值大小来判定
Ⅰ.大端存储:
大端存储常见于网络通信上,如TCP/IP协议在传输整型数据时一般使用大端存储模式表示。
优点:
- 符号位在所表示的数据的内存的第一个字节中,便于快速判断数据的正负和大小。
- 大端存储方式符合人类的直观认识,因为他高位优先并且是按顺序存储。
缺点: 计算机在处理时需要进行额外的字节顺序调整。
Ⅱ.小端存储:
小端存储常见于本地主机上,以及某些需要频繁进行读写操作且内存空间有限的应用中。
优点:
- 节约空间:对于小型数据类型,小端存储可以将多个数据类型的低字节合并到一个字节中,从而有效地节约内存空间。
- 提高读写速度:在进行网络传输时,可以减少读写操作次数,提高传输效率。
缺点: 不太符合我们的阅读习惯,毕竟是从小到大也就是俗称的从后往前显示
三、signed 与 unsiged
1.signed
signed被称为有符号数,也就是最高位为符号位,这里我们以char a来演示
char类型数据占一个字节,也就是8位,如下图所示,图为signed char的最大表示范围
然后,我们来剖析一下这些值代表了什么
首先,数据在内存中以补码的形式存储,所以上图皆是补码
我补充了一下图,解释一下
首先,将补码按位取反 + 1 就能得到原码,我们把原码计算出来可以发现一个字节最大的存储范围i是-128 ~ 127
2.unsigned
unsigned是无符号的数,不需要判断最高位的符号位,所以它的原码反码补码皆是相同的。这也意味着能直接看出来数值且不需要计算
他的表示范围为0 ~ 255
同理,这只是char的类型,如果是short,那么就有16个比特位,只需要将他们罗列出来再进行计算,就能得到这个数据类型的取值范围。
其余的原理相同。包括int,long...
三.整型提升
c
int main() {
char a = -1;
signed char b = -1;
unsigned char c = -1;
printf("a=%d,b=%d,c=%d", a, b, c);
return 0;
}
请问a,b,c分别是什么呢?
要解决这个问题,我们得先学个知识,叫做整型提升
我们a的数据类型是char,但是打印的却是%d,%d是以整形打印的,可我们char只有1个字节,int是有4个字节的,所以我们需要进行整型提升 。
先把signed char b = -1 以二进制表示出来(char a 其实与 signed b 是一样的,char a 默认就是signed)
10000000 00000000 00000000 00000001
因为是signed,是有符号的,所以第一位是符号位,1代表负数,所以二进制如上。
然后这是补码,我们要转换成原码才能看得懂
11111111 11111111 11111111 1111110(反码)
11111111 11111111 11111111 1111111(原码)
然后,因为char只能存一个字节,我们要进行截断 操作
11111111(取最后一个字节)
然后前面补充24位1达到int类型的取值范围
11111111 11111111 11111111 1111111(现在这个为补码)
然后取反+1得到原码
10000000 00000000 00000000 00000001
得到的就是-1
那么unsigned char c = -1是怎么处理的呢?
首先补码为
10000000 00000000 00000000 00000001(这是-1的补码,先别管有无符号,上面的也一样)
求出原码
11111111 11111111 11111111 1111111(原码)
然后截断
11111111(补码)
然后,这里因为是unsigned类型的,所以没有符号位。前面直接补0
00000000 00000000 00000000 11111111(补码)
又因为无符号类型原码反码补码均相等 ,所以原码也是跟补码一样的。解出来得到值为255
所以
例子2
我们再来个例子可以很好的帮助我们理解整型提升
c
int main() {
char a = -10;
printf("a=%u", a);
return 0;
}
拿到手,首先不管三七二十一直接计算-10
10000000 00000000 00000000 00001010(补码)
11111111 11111111 11111111 11110110(原码)
然后因为char只有一个字节,所以我们截断拿最后一个字节
11110110,因为a又是有符号数,所以我们整型提升
11111111 11111111 11111111 11110110(补码)
到这,先打住。我们先看看题目打印的类型。题目要打印的是%u 也就是unsigned类型
unsigned类型是无符号的,所以这里我们没必要进行转换了,补码就等于原码
所以值为
反正就是很长很长。。。。。
到这整数的类型我们就讲完了。接下来,还有浮点数类型的呢
四、浮点数的存储
1.浮点数在计算机内部的表达方法
根据国际标准IEEE754,任何一个二进制V可以表示成如下形式
(-1)^S * M * 2^E
解释一下:
- (-1)^S表示符号位,S = 0 时,V表示正数;S = 1 时, V表示负数。
- M表示有效数字,大于等于1小于2
- 2^E表示指数位
假设V = 5.0f
5的二进制是101,5.0 就是 101.0
浮点型,点就是可以浮动的,那我们就把这个点放在第一个数的后面,也就是1.010 * 10^2
点向前移动几位就要乘上2的几次方
得到了1.010 * 10^2,那么根据上面的标准,我们可以得到 (-1) ^ 0 * 1.01 * 2^2
这样就能理解为什么M是要大于等于1小于2了
再举个例子
假设V = 9.5f
我们先处理下小数部分
图上蓝色字体为当前位的权重
比如1001
实际上就是1 * 2^3 + 0 * 2^2 + 0 * 2^1 + 1 * 2^0 = 9
-1也同理,但是-1实际上是2^-1次方,也就是1/2。而1/2就是0.5,所以
9.5 的二进制是1001.1
将小数点移动三位的1.0011 * 2^3
所以表示为(-1) ^ 0 * 1.0011 * 2^3
这就是浮点数的表达。
但是这种表达方式有弊端。比如9.6f
整数部分1001没有问题,但是小数部分呢?
先确定第一位是1也没问题,第二位呢?如果是1那么就是1/2^2 = 0.25。0.25 + 0.5超过了0.6
那么第二位就不能是1,只能为0。第三位如果是1那么就是1/2^3 = 0.125。0.125 + 0.5也超过了0.6,所以第三位也不能是1,只能这样反复排查下去寻找最最接近0.6的然后确认小数位。
这也是没什么浮点型数据在存储时会有误差的原因。
2.存储模型
float的存储模型如下
double的存储模型如下
这里,E的存储比较复杂
首先我们还是以0.5f为例子,它表示为(-1) ^ 0 * 1.0 * 2^-1
S = 0, M = 1.0,E为-1
那么-1要怎么存储呢?要知道,这里规定的E是无符号型的
所以,我们一般要在存入内存中时E的真实值要加上一个中间值。对于8bit的E,这个中间值为127;对于11bit的E,这个中间值为1023。
也就是说,float的0.5f里的E为-1, 要加上127 = 126。126转成二进制才是内存中所存储的E
double里的E要加上1023 = 1022,1022转成二进制才是内存中所存储的E
这里E = 2 需要加上127 = 129,再把129转成二进制序列(100 0000 1)后面M就是011,然后补零
注:1.011只要存011,不存1
这就是浮点数存入内存中的规则
内存中一般是小端的方式来显示的,所以是反着来输出的。
3.取出数据
说完了存入,那么,怎么取出来呢?
1.对于非全零或全一
- 将M完整取出来,并且前面加上1.
也就是1.011 0000 0000 000000000000 - 然后E - 127得到真实值,也就是:
100 0000 1 - 011 1111 1 = 129 - 127 = 2 - 最后就能得到 +1.011 0000 0000 000000000000 * 2^2 (+就是s为1的情况)
简化一下就是+1.011*2^2。
2.E为全0
当E为全零时,第一步需要改一下
- 将M完整取出来,前面不加上 1. 了,而是直接加上 0.,得到0.xxxxxxxx
- E直接就是1-127(或者1-1023),得到真实值(E不需要计算)
3.E为全1
这时,如果有效数字M全部都是0,那么直接表示为 ±无穷大
因为E想要全1,肯定就是初始值128,128+127=255就是全1
( ± 1.xxxxx * 2^128)不大才怪
五、浮点数指针
在进行这一板块之前,我们先来复习一下指针:
- 浮点数的指针类型通常是 float *(对于单精度浮点数)或 double *(对于双精度浮点数)。
- 指针类型决定了指针解引用时访问的数据大小。
- 指针变量中存储的是内存地址,这些地址指向了存储浮点数值的内存位置。
- 在32位系统上,指针通常占用4个字节;在64位系统上,指针通常占用8个字节。
复习完指针,我们就来看看一道经典的题目:
c
int main() {
int n = 9;
float* pFloat = (float*)&n;
printf("n的值为:%d\n", n);
printf("*p的值为:%f\n", *pFloat);
*pFloat = 9.0;
printf("n的值为:%d\n", n);
printf("*p的值为:%f\n", *pFloat);
return 0;
}
这道题目大多数人在做的时候总会有疑问。诶?为什么第一个*p的值为0?我不是取了n的地址并且解引用了吗?为什么第二个n的值不是9,而是一个那么大的数字?
我们来一步步分析代码。
- 第一个n打印的值是=9没有问题,因为前面赋值了。
- 第一个*p ,首先我们进行了&n的操作,去除了n的地址,然后强制类型转换为浮点型的指针。并且把这个浮点指针赋值给pFloat。但是,int 通常以二进制补码形式存储整数,而 float 则遵循IEEE 754标准存储浮点数,他们的存储规则不一样,所以在存储时会存在问题。
它是这么执行的:
n的 * int 强制类型转换成 * float,然后根据IEEE754规则改写成
1001.0 -> 1.001 * 2 ^ 3。此时,S = 0,E = 3, M = 1.001
然后E + 真实值127 得到E = 130,然后将整段代码改写成二进制码:
0 10000010 00100000000000000000000
因为符号位是0,程序会判定这个地址是一个正数,正数的原码与补码相同,所以就会直接打印。但是要注意,这是n的地址,不是* pFloat的地址
这也是为什么这里第二个*p不是我们想要的值的原因。至于为什么是0,当你通过 *pFloat 访问值时,编译器会尝试将该地址处的4个字节解释为 float 类型的值。由于 int 和 float 的表示方式不同,这4个字节在按照 float 的格式解释时,就会得到一个与原始 int 值完全不同的结果。而这个结果是不可预测的,可能在这个平台是0,在别的平台就不一定了。 - 第三个n其实就是上面那个n强制类型转换取地址后再解引用得到的值,因为发生了从 int* 到 float* 的显式类型转换。所以值很大而且也不是我们所想的9
- 第四个由于上面我们把*pFloat的值进行了赋值操作,所以打印的就是9
六、结语
以上就是数据在内存中的存储,也是我个人的学习经验总结。虽然可能是话痨了点,但大多确实是我的思考过程。希望以上的文章可有帮到你,如有错误也欢迎大佬指出。
那么,下篇文章再见,bye~
(PS.那一张signed的图解花了我不少心血,希望能够帮助你理解!)