大家好,我是努力学习游泳的鱼。要想深入学习C语言,就不能仅仅了解一些表层的知识,而应深入底层,修炼内功。今天我们就来学习数据的存储的相关知识,深入到内存,了解数据是如何存储的。
我在【C语言】数据类型这篇博客里详细讲解了数据类型的相关知识,忘记的朋友们可以先去复习一下。
我们已经学习了基本的内置类型,这些类型是C语言语法本身提供的。我们也了解了它们所占存储空间的大小。
char // 字符数据类型
short // 短整型
int // 整型
long // 长整型
long long // 更长的整型
float // 单精度浮点数
double // 双精度浮点数
类型的意义:
数据类型可以分为如下几类:
char
unsigned char
signed char
short [int]
unsigned short [int]
signed short [int]
int
unsigned int
signed int
long [int]
unsigned long [int]
signed long [int]
long long [int]
unsigned long long [int]
signed long long [int]
注意:
char
虽然是字符类型,但是字符类型存储的时候,存储的是字符的ASCII
码值,ASCII
码值是整数,所以也归类到整型家族。short
等类型,short
和short int
是等价的,也就是int
可以省略,以上可以省略的都用方括号括起来。signed
是有符号的意思,unsigned
是无符号的意思。有正负的数据可以存放在有符号的变量中,只有正数的数据可以存放在无符号的变量中。0
,表示正数;最高位是1
,表示负数。如果是无符号的数据,最高位也是数据位。short
,int
,long
,long long
等类型,默认都是有符号的,默认省略signed
。也就是说,short
等价于signed short
,int
等价于signed int
,以此类推。但是,C语言标准没有明确规定char
类型是有符号的还是无符号的。也就是说,char
到底是signed char
还是unsigned char
是不确定的,取决于编译器的实现。一般来说,大部分编译器中的char
都是signed char
。浮点数家族有float
,double
和long double
(C99新增)等。
构造类型有数组类型,结构体类型,枚举类型和联合类型。
struct
,enum
和union
。int arr[10];
,此时arr
的类型就是int [10]
。由于数组存储的元素的类型和个数都是可以自定义的,所以数组类型是自定义类型。指针类型有int*
,char*
,float*
,double*
,void*
等等。
指针变量是用来存放地址的。
void
表示空类型(无类型)。
通常应用于函数的返回类型、函数的参数、指针类型。
整数的二进制表示形式有三种,分别是原码、反码和补码。
直接把整数按照正负数的形式翻译成二进制就是原码。
正整数的原码、反码和补码相同,负整数的原码、反码和补码需要计算。
计算方式:负整数的原码的符号位不变,其他位按位取反(1
变成0
,0
变成1
)得到反码,反码+1
得到补码。反过来,补码-1
得到反码,反码的符号位不变,其他位按位取反(1
变成0
,0
变成1
)得到原码。除此之外,先对补码的符号位不变,其他位按位取反(1
变成0
,0
变成1
),再+1
也可以得到原码。
整数在内存中存储的是补码的二进制。
举例:
10
的原码、反码和补码:
原码:00000000000000000000000000001010
反码:00000000000000000000000000001010
补码:00000000000000000000000000001010
-10
的原码、反码和补码:
原码:10000000000000000000000000001010
反码:11111111111111111111111111110101
补码:11111111111111111111111111110110
此时我们有个疑问:为什么要有补码和反码呢?内存中如果直接存储原码,那该多简单呀!
理由如下:CPU只有加法器,所有的运算都会转换为加法。那么假设计算1-1
,就会转换为计算1 + (-1)
,如果用原码来算:
1
的原码:00000000000000000000000000000001
-1
的原码:10000000000000000000000000000001
然后把两者相加,得到:10000000000000000000000000000010
假设原码相加也得到原码,这个结果转换成十进制就是-2
,也就是说,1-1
的结果是-2
,这明显是错误的。
但是如果用补码来计算呢?
先求出补码:
1
的补码:00000000000000000000000000000001
-1
的原码:10000000000000000000000000000001
-1
的反码:11111111111111111111111111111110
-1
的补码:11111111111111111111111111111111
接着相加:
00000000000000000000000000000001 // 1的补码
11111111111111111111111111111111 // -1的补码
100000000000000000000000000000000 // 计算结果
由于int
类型最多存储32
位,所以最高位的1
被丢弃,最终结果是:00000000000000000000000000000000
,这是个补码,它的原码转换成十进制得到0
,也就是说1-1
的结果是0
,这样计算的结果就是正确的。
在计算机系统中,数值一律用补码来表示和存储。原因在于,使用补码,可以将符号位和数值域统一处理;同时,加法和减法也可以统一处理(CPU只有加法器)。此外,补码和原码相互转换,其运算过程是相同的,不需要额外的硬件电路。
接下来,我们研究研究有符号的char
类型在内存中的存储。由于char
类型是1
字节,即8
个比特位,最多存储8
个二进制位,所以内存中存储的值可以是:
00000000 // 0
00000001 // 1
00000010 // 2
00000011 // 3
...
01111111 // 127
10000000 // 会被直接解析为-128
10000001 // 反码:10000000 原码:11111111 即-127
...
11111110 // 反码:11111101 原码:10000010 即-2
11111111 // 反码:11111110 原码:10000001 即-1
从00000000
变化到01111111
,也就是0~127
,再从10000000
变化到11111111
,也就是-128~-1
。所以存储的范围是-128~127
。
当然,以上是对于有符号的char
类型,如果是无符号的char
类型,最高位也是数值位,那么取值范围就是从00000000
到11111111
即0~255
。
我们也可以用同样的方式研究其他类型,这里就不做演示了。
大小端的全称是大小端字节序存储,分为大端字节序存储和小端字节序存储。
大端字节序存储:把一个数据的低位字节处的数据存放在高地址处,把高位字节处的数据存放在低地址处。
小端字节序存储:把一个数据的高位字节处的数据存放在高地址处,把低位字节处的数据存放在低地址处。
假设我们要存储一个十六进制数字0x11223344
,由于一个十六进制位可以换算成4个二进制位,所以两个十六进制位可以换算成一个字节,也就是说,11
22
33
44
分别占一个字节,其中44
是最低位字节,11
是最高位字节。假设有四个连续的内存单元,每个内存单元大小是一个字节,会被分配一个地址,其中左边是低地址,右边是高地址,则大端字节序存储会这样存放:11
22
33
44
,小端字节序存储会这样存放:44
33
22
11
。可以简单记为:小端存储是倒着存的。
为什么会有大端和小端呢?
这是因为在计算机系统中,我们是以字节为单位的,每个地址单元都对应着一个字节,一个字节为8bit。但是在C语言中除了8bit的char外,还有16bit的short型,32bit的long型(要看具体的编译器),另外,对于位数大于8位的处理器,例如16位或者32位的处理器,由于寄存器宽度大于一个字节,那么必然存在着如何将多个字节安排的问题。因此就导致了大端存储模式和小端存储模式。
我们可以写一个函数来判断大小端。思路很简单,用一个int
型变量来存储1
,假设左边是低地址,右边是高地址,则小端存储是倒着存的,存储的是0x 01 00 00 00
,大端存储是正着存的,存储的是0x 00 00 00 01
。再用char*
指针来解引用这个变量的地址。由于char*
指针解引用访问1
个字节,小端存储就访问到1
,大端存储就访问到0
。
int check_sys()
{
int a = 1;
return *(char*)&a;
}
int main()
{
int ret = check_sys();
if (1 == ret)
{
printf("小端\n");
}
else
{
printf("大端\n");
}
return 0;
}
先看下面的代码:
#include
int main()
{
int n = 9;
float* pFloat = (float*)&n;
printf("n的值为:%d\n", n);
printf("*pFloat的值为:%f\n", *pFloat);
*pFloat = 9.0;
printf("num的值为:%d\n", n);
printf("*pFloat的值为:%f\n", *pFloat);
return 0;
}
输出结果是什么呢?
好奇怪,为什么是这么个结果呢?
先创建一个整型变量n
,并初始化为9
。接着float* pFloat = (float*)&n;
的意思是取出n
的地址,并用一个float*
的指针存储。那么pFloat
就指向了n
。接着printf("n的值为:%d\n", n);
的意思是直接打印出n
,由于n
里面是按照整型的方式放进去的9
,直接打印出来是9
非常合理。接着printf("*pFloat的值为:%f\n", *pFloat);
的意思是直接打印出pFloat
指向的空间的内容,由于pFloat
是浮点型指针,在它眼里,指向的空间存储的都是浮点数,那么解引用时是按照浮点数的方式去拿数据,拿出来的就不是9
了。接着*pFloat = 9.0;
,同理,pFloat
是浮点型指针,它认为内存中都是浮点数,那么解引用并放进去9.0
,是按照浮点数的方式放进去的。然后printf("num的值为:%d\n", n);
是按照整型的方式把n
里的值拿出来,就不是9.0
了。但是printf("*pFloat的值为:%f\n", *pFloat);
是按照浮点数的方式把数据拿出来,就成功拿出来了9.0
。
以上程序说明一点:整型和浮点型在内存中的存储方式是有所差异的!
那么浮点数在内存中是如何存储的呢?
根据IEEE(电气和电子工程协会)754,任意一个二进制浮点数V
可以表示成下面的形式:
(-1)^S * M * 2^E
(-1)^S
表示符号位,当S=0
,V
为正数,当S=1
,V
为负数。M
表示有效数字,大于等于1
,小于2
。2^E
表示指数位。举个例子:5.5
是如何表示的呢?
由于5.5
是一个正数,所以S=0
。
接下来看如何写成二进制的表示形式。5.5
的二进制是101.1
。小数点前的101
表示5
,小数点后的1
表示0.5
,因为小数点后的1
的权重在二进制中是2-1,即0.5
。
接着把101.1
用科学计数法表示,即1.011 * 2^2
。换算方法很简单,类比十进制的科学计数法,我们需要把101.1
的小数点向左移动两位,得到的1.011
介于1
到2
之间。记住:二进制的科学计数法是某个1~2
的数去乘2
的几次方。
再把正负加上,完整的表示是:(-1)^0 * 1.011 * 2^2
。对比(-1)^S * M * 2^E
,S
就是0
,M
就是1.011
,E
就是2
。
注意:大部分浮点数是不能精确保存的!像5.5
这样的能精确保存的浮点数是比较特殊的,大部分浮点数如果写成二进制,在小数点后有非常多位,很难精确保存。
如果我们想把一个浮点数存在内存中,就需要存储S
、M
和E
。
IEEE 754规定:
32
位的浮点数,最高的1
位是符号位S
,接着的8
位是指数E
,剩下的23
位为有效数字M
。64
位的浮点数,最高的1
位是符号位S
,接着的11
位是指数E
,剩下的52
位为有效数字M
。IEEE 754对有效数字M
和指数E
,还有一些特别规定。
前面说过,1≤M<2
,也就是说,M
可以写成1.xxxxxx
的形式,其中xxxxxx
表示小数部分。IEEE 754规定,在计算机内部保存M
时,默认这个数的第一位总是1
,因此可以被舍去,只保存后面的xxxxxx
部分。比如保存1.01
的时候,只保存01
,等到读取的时候,再把第一位的1
加上去。这样做的目的,是节省1
位有效数字。以32
位浮点数为例,留给M
只有23
位,将第一位的1
舍去以后,等于可以保存24
位有效数字。
至于指数E
,情况就比较复杂。首先,E
为一个无符号整数(unsigned int
)。这意味着,如果E
为8
位,它的取值范围为0~255
;如果E
为11
位,它的取值范围为0~2047
。但是,我们知道,科学计数法中的E
是可以出现负数的,所以IEEE 754规定,存入内存时E的真实值必须再加上一个中间数,对于8
位的E
,这个中间数是127
;对于11
位的E
,这个中间数是1023
。比如,2^10
的E
是10
,所以保存成32
位浮点数时,必须保存成10+127=137
,即10001001
。
对于5.5
,我们知道5.5 = (-1)^0 * 1.011 * 2^2
。那么如果要把5.5
存储在一个float
类型的变量里,第一个二进制位存储符号位S
,即0
。接着8
个二进制位存储指数位,即2
。但是根据上面的规则,应该加一个修正值127
,计算2+127 = 129
,存储在接下来的8
个二进制位里。所以这8
个二进制位存储的是无符号数129
,即10000001
。最后的23
个二进制位存放数值位M
,即1.011
,根据上面的规则,只存储小数位,即011
,再补全23
个二进制位,即01100000000000000000000
。综上,5,5
存储在内存中的完整的二进制序列是01000000101100000000000000000000
。
明确了如何把数据存进去,我们还要了解如何把数据取出来。
当E
不为全0
且不为全1
时,浮点数就采用下面的规则表示,即指数E的计算值减去127
(或1023
),得到真实值,再将有效数字M
前加上第一位的1
。简单来说,怎么放进去的,就怎么取出来。而当E
为全1
时,表示绝对值非常大的数据,可以理解为±∞。当E
为全0
时,表示绝对值非常小的数据,可以理解为±0,或很接近0
的数据。
我们再来研究一开始的代码:
#include
int main()
{
int n = 9;
float* pFloat = (float*)&n;
printf("n的值为:%d\n", n); // 9
printf("*pFloat的值为:%f\n", *pFloat); // 0.000000
*pFloat = 9.0;
printf("num的值为:%d\n", n); // 1091567616
printf("*pFloat的值为:%f\n", *pFloat); // 9.000000
return 0;
}
int n = 9;
把9
放到内存中,即把9
的补码放到内存中。
9
的补码:00000000000000000000000000001001
float* pFloat = (float*)&n;
执行完后,pFloat
就指向了上面的补码,由于是float*
的指针,所以认为这一串是浮点数。
接着printf("n的值为:%d\n", n);
是把9
的补码按照原码的形式打印出来,自然是9
。
printf("*pFloat的值为:%f\n", *pFloat);
由于pFloat
认为上面的补码是浮点数,所以就按照浮点数的存储规则把数据拿出来。
0 00000000 00000000000000000001001
由于E
全为0
,按照上面的说法,表示绝对值很小的数,打印出来就是0.000000
。
*pFloat = 9.0;
由于pFloat
认为内存中是浮点数,所以按照浮点数的存储方式把9.0
放进去。9.0
,即二进制中的1001.0
,转换一下,即(-1)^0 * 1.001 * 2^3
,所以S=0
,M=1.001
,E=3
,再计算E+127=130
,所以存储在内存中的值是:
0 10000010 00100000000000000000000
printf("num的值为:%d\n", n);
把这个值以%d
的形式拿出来,就认为内存中放的是一个有符号整数的补码,所以就把01000001000100000000000000000000
当做补码转换成原码,以十进制的形式拿出来,就是1091567616
。
最后printf("*pFloat的值为:%f\n", *pFloat);
就很好理解了,我们以浮点数的方式存进去,又以浮点数的形式拿出来,那自然就是9.000000
。