数据存储的相关知识(二进制码,大小端,浮点数存储)
前言
人们在使用计算机的过程之中,本质上是将生活中的信息,通过数字化技术存储在计算机之中,通过一系列操作来得到我们使用的结果。因此了解数据在计算机中的存储至关重要。通过对以下关于数据存储的介绍,相信能够更深入的理解数据在计算机中存储的方式方法。
无符号数和有符号数
#include <stdio.h>
int main()
{
int a = -10 , c = 10;//有符号
unsign int b = -20 , d = 20;//无符号
printf("%d %d",a,c);
printf("%u %u",b,d);
return
}
在以上程序中我们看到,数据分为有符号和无符号,无符号数无法正确表示负数,这是因为什么呢?想回答这个问题我们就要对其相关的概念进行了解。
无符号数(unsigned number):指的是整个机器字长的全部二进制位均表示数值位,相当于数的绝对值。
其中无符号数的取值范围为:0~2^n-1。
有符号数(signed number):一般用最高有效位(即符号位)来表示数的符号,正数用0表示,负数用1表示。
有符号数(补码表示)的取值范围是:-2^(n-1) ~ 2^(n-1)-1。
据此,我们还可以推出各数据的数据表示范围:
数据类型 | 数据范围 |
---|---|
unsigned char | 0 ~ 255 |
char | -128 ~ 127 |
unsigned short | 0 ~ 65535 |
short | -32768 ~ 32767 |
unsigned int | 0 ~ 2^32 |
int | - 2^31 ~(2^31)-1 |
了解完相关概念之后,我们明白了:
以a=10
为例,0 000 0000 0000 0000 0000 0000 0000 1010
b=-10
也可以表示为, 1 000 0000 0000 0000 0000 0000 0000 1010
由于b
被声明为无符号数,所以将所有位都当做数据来读,所以b
输出的值就是为2^31+10
原码,反码,补码
一、原码
**原码:**用最高位表示符号位,‘1’表示负号,‘0’表示正号。其他位存放该数的二进制的绝对值。
用四位带符号的2进制数来表示十进制数
十进制数
10
的二进制数为1010
,即0*(2^0)+ 1*(2^1) + 0*(2^2) + 1*(2^3) = 10
当我们拿到了数据的原码便高兴地开启了我们的加法运算
0001 + 0100 = 0101( 1 + 8 = 9 ) ——没有问题
1001 + 1010 = 0011 ((-1) + (-2)= 3)——开始奇怪起来了
0010 + 1010 = 1001 ( 2 + ( -2 ) = -4 )——???
通过上述例子我们发现,从计算机原理上看,正数与正数只有正数与正数的原码相加才能够得到正确的值,而负数+正数
或者负数+负数
似乎无法得到正确的结果。
深究其原因,正是我们用来表示符号的符号引起的问题。
虽然原码看似简单易懂,但要是其运用于实现数据的加减,运算规则在有些情况下过于复杂,于是就有了反码的发明。
二、反码
反码:
正数的反码还是等于原码
负数的反码就是除了符号位不变,其他位置上做取反
- 由刚刚的例子我们知道,原码最大的问题就是,一个数与它的相反数相加,并不能等于0,那么如果我们将二进制负数除去其符号位外的其他数位按位却反,是否就能够解决我们的问题呢?
实验开始(运算的数据皆为反码)
0010 + 1101 = 1111 ( 2 + ( -2 ) = -7 )
1110 + 1101 = 1011 ( (-1)+ (-2) = (-4) )
好像依然存在问题,仔细观察结果我们不难发现,似乎反码相加的结果和正确的结果的偏移量差了1,那如果我们在原码取反之后再进行+1的操作,会怎样呢?于是我们又引出了补码的概念
三、补码
补码
正数的补码还是正数原码本身
负数的补码就是该负数的反码加一
我们继续以反码中出现的例子进行关于补码的运算
0010 + 1110 = 0000( 2 + ( -2 ) = 0 )
1111 + 1110 = 1011 ( (-1)+ (-2) = (-3) )
我们发现,二进制中负数与负数,负数与正数相加的问题已经得到解决了
接下来我们便来继续深入学习关于补码的知识
补充:内存数据在计算机中都是以补码的形式存在
补码——简单来说可以理解为,代替负数进行运算的正数,有了补码,加减法都可以进行统一的运算。
那么我们怎么才能让补码来代替负数做运算呢?
以生活来举个例:
如果现在时钟指在8点钟,可以怎么调整来使它指向6点钟呢?
很简单
1.将时针顺时针拨动10个小时
2.将时针逆时针拨动2个小时
这样的思想,似乎与像是做了一个类似于取模的运算,10与2相加正好等于时针运算的模——12
再拿两位的10进制为例子,进行深入说明:
24 - 1 = 23
24 + 99 = (一百) 23
若我们放弃进位,只看最后两位数,我们可以发现-1
和+99
是等效的。
反码的原理就如同以上举出的例子一样,将负的二进制数转化为一个等价的正二进制数来代替进行运算。
通过以上的举例,相信大家也不难看得出来,正负数值的补码也可以通过公式直接推导出来,而不是用"原码取反再加一"进行运算了。
当 X 为正数时,X的补码 = X
当 X 为负数时,X的补码 = X + 2^n。 n 是二进制显示的位数。
有了这样的公式我们就可以更加简单地解决在八位二进制表示-128
的补码时遇到的问题了。
若根据"原码取反再加一"的原理,我们遇到-128时就不知从何下手了,它没有八位的原码和反码,我们应该怎么去求它的反码呢?
套用以上公式,我们不难得出,-128的补码 = -128 + 2^8 =128 =1000 0000
大小端
大端存储:是指数据的低位保存在内存的高地址中,而数据的高位,保存在内存的低地址中;
小端存储:是指数据的低位保存在内存的低地址中,而数据的高位,,保存在内存的高地址中。
对于单字节数据的存储,比如char
类型,是没有存在所谓的大小端存储的问题
大小端更多是对于多字节数据的存储,例如int
、long
等类型。
以0x12345678
例子吧,我们可以展示大小端的不同之处
知道了大小端的不同之处,那么大小端有分别有什么优势呢?
大端:在于第一个字节就是高位,所以计算机可以通过符号位,在第一时间判断数据的正负性。输出时从低地址向高地址输出。
小端:由于其第一个字节是低位,最后一个字节是高位,可以依次取出与之相对应的字节进行运算,并且最终会把符号位刷新,这样计算机的运行将会更高效。输出时从高地址向低地址输出。
那我们又该如何确定当前计算机的环境时大端还是小段呢?
我们接下来介绍两种方法:
(1)联合体
#include <stdio.h>
union MyUnion {
int value;
unsigned char bytes[4];
};
int main() {
union MyUnion x;
x.value = 258;
for (int i = 0; i < sizeof(x.bytes); i++) {
printf("%02x ", x.bytes[i]);//%02X 表示以两位宽度输出十六进制数值,并在不足两位时补零。
}
return 0;
}
我们都知道,在联合体当中,当将一个整数赋值给共用体中的成员变量时,该整数会被存储在共用体的内存空间中。因为共用体的所有成员共享同一块内存,所以这个整数的二进制表示也同时影响了其他成员的值。将整数 258
赋值给 x.value
,value
和 bytes
共享内存,因此 bytes
数组的内容也会被相应地修改。
因此,在以上程序中:
在大端序中,高位字节(值为1)存储在 bytes[0]
中,低位字节(值为2)存储在 bytes[1]
中。
在小端序中,低位字节(值为2)存储在 bytes[0]
中,高位字节(值为1)存储在 bytes[1]
中。
输出结果如下:
//输出结果
大端模式: 01 02
小端模式: 02 01
(2)指针转换
#include <stdio.h>
int main() {
int num = 1;
char *ptr = (char *)#//对ptr解引用得到第一个字节里存储的数据
if (*ptr == 1) {
printf("小端模式\n");
} else {
printf("大端模式\n");
}
return 0;
}
这段代码的运行原理如下:
- 首先,我们创建一个整型变量
num
并将其初始化为1。 - 接下来,我们创建一个指向整型变量的指针
ptr
,并将其指向num
的内存地址。这里使用了(char *)&num
的强制类型转换,将整型指针转换为字符指针。 - 然后,我们通过
*ptr
来访问指针所指向的内存位置,也就是num
的第一个字节。 - 如果系统是小端的,
num
的第一个字节存储的将会是最低有效字节,也就是1。所以,*ptr
的值将为1。 - 当我们检查
*ptr
的值时,如果它等于1,则表示系统是小端的。反之,如果不等于1,则表示系统是大端的。
练习习题
//假定程序运行环境为:CPU(32位小字节序处理器)
#include <stdio.h>
int main() {
union{
short k;
char i[2];
}*s,a;
s = &a;
s->i[0] = 0x39 ;
s->i[1] = 0x38 ;
printf("%x\n",a.k);//请说出输出的内容
return 0;
}
在题目中的小字节序即为小端模式,我们知道在小端模式当中,输出是以高地址向低地址输出的,我们i[0]
中的39存在低地址中,而i[1]
中的38存在高地址中,所以,题目答案的输出结果为3839
。
整型和浮点型的存储方式
在了解完关于整形数据的存储方式后,我们下一步就来了解浮点型数据的存储方式。
下面是float
和double
存储空间上的不同
float
单精度浮点数
double
双精度浮点数
在图中,我们不难看到,double
类型可以表示的小数点位数增多了,即说明double
类型的数据精度更高了,这也就是为什么float
被称为单精度浮点数而double
被称为双精度浮点数的原因了。
浮点数转化为二进制的方式:
1、首先将数据分为两部分:小数点前 和 小数点后。
2、将小数点前(整数部分)的二进制表示出来。小数点后的数据只能用0或1表示。以10.125为例子,在整数部分的5的二进制是1100
。
3.在处理小数部分,我们采取 "乘2取整,顺序排列"的方法。即用2乘十进制小数,可以得到积,将积的整数部分取出,再用2乘余下的小数 部分,又得到一个积,再将积的整数部分取出,如此进行,直到积中的小数部分为零,或者达到所要求的精度为止。
根据以上规则,我们不难得出10.125的二进制表示为1100.001
浮点数的表示规则
浮点数在计算机内的存储与整数的存储有较大差异。与整数不同,根据IEEE 754
规定浮点数除了表示正负的符号位S
外还包括阶码E
和尾码M
,可以使用如下形式进行表示:
任何一个浮点数在二进制中都可以转化为:((-1)^S) * M *(2^E)
其中S为0或1,0为整数,1为负数;
M表示有效数字, 1<=M<2;
E表示次方数,注意在二进制中科学计数法的底数为2。
例子:十进制数10.125,通过以上方法,我们得出它的二进制为
1100.001
在计算机当中,因为
1100.001
为正数,所以 S = 0;M要为1<=M<2
的有效数字,所以我们使用科学计数法将1100.001
化作1.100001
的形式,然后用E
来补充,使的数字大小不变,由于小数点向前移动了三位,于是E
即为2^3
1100.001
在计算机中就可以表示为 [((-1)^0) * 1.100001 * (2^3)]
了解了计算机中浮点型小数的存储方式,我们还有几个规则要探究一下:
- 隐藏高位:M可以写成
1.XXXXXX
的形式,其中xxxxxxxx
表示小数部分,由于默认整数部分为1,所以在存储的时候并不会将整数部分的1进行存储而是只会存储小数部分。 - 低位补0:有时候尾数会不够填满尾数位,所以我们要在后面补上0将
M
的内存补满。 - 精度范围存在的意义:对于0.3来说,不论怎么操作都无法将其数据完全表示出来,所以精度存在能够让我们控制误差,并且确保数值的范围,进一步的提高计算的效率。
- 指数 E的意义:E在计算机中存储的是一个无符号正数,这意味这,在32位中
E
的取值为0~255
(8位),在64位中E
的取值为0~2047
(11位)。我们不禁有了一个问题,在科学计数法中是允许存在负数的,我们应该怎么做呢?
针对此问题,在IEEE 754
中规定了,存入内存时 E 的真实值必须再加上一个中间数,对于 8 位的 E
,这个中间数是 127 ;对于 11 位的 E
,这个中间数是1023 。这样操作使得了指数部分肯定为一个非负整数。
就举几个例子吧:
在32位中,如果你运算后得到的指数是 -127 , 那么偏移后, 在指数位中就需要表示为: -127 + 127(偏移量) = 0
在64位中,如果你运算后得到的指数是 -10, 那么偏移后, 在指数位中需要表示为: -10 + 1023(偏移量) = 1013
E全为1:这时,如果有效数字 M 全为 0 ,表示 ± 无穷大(正负取决于符号位 s )
E全为0:意味着此时的指数是一个范围内的最小的负整数,表示的浮点数无限接近于0,
了解了这些规则之后我们就可以开始探究在内存当中浮点数是怎么存放的呢?
以32位的10.125
为例子吧,10.125
可表示为[((-1)^0) * 1.100001 * (2^3)]
10。125
为正数S = 0
;
E=3
加上偏移量之后得到E = 3 + 128 = 131
,用二进制表示就为1000 0010
;
M = 1.100001
由低位补0和隐藏高位的规则得,二进制的表示应该为100 0010 0000 0000 0000 0000
(共23位)
计算机中浮点数的存储如下表所示:
S | E | M |
---|---|---|
0 | 1000 0010 | 100 0010 0000 0000 0000 0000 |
如果将数据以一个字节为一组,我们就能得到:
0100 0001 0100 0010 0000 0000 0000 0000
在以十六进制来表示该二进制数,就得到了数据存储的地址
大端存储:41 42 00 00
小端存储:00 00 42 41
以上内容我们介绍了当E
不全为0或1时的情况,接下来我们来讨论一下,另外的两种情况
E全为0:
#include <stdio.h>
int main(){
int a = 10 ;
float *pf = (float*)&a;//将一个整型变量 a 的地址强制类型转换为指向浮点数类型的指针
printf("*pf = %f\n",*pf);
}
我们发现输出的值为0.000000
,这是为什么呢?
根据以上的原则,我们不难发现,S = 0
,E = 0000 0000
,M = 000 0000 0000 0000 0000 1010
;
再根据E全为0的规则,M
前不在补1,而是补0
即可以表示为:(( -1 )^ 0 ) * (0.000 0000 0000 0000 0000 1010) *(2^-126);
所以打印出来的值只能为0值。
E全为1:
#include <stdio.h>
int main(){
int a = -10 ;
float *pf = (float*)&a;//将一个整型变量 a 的地址强制类型转换为指向浮点数类型的指针
printf("*pf = %f\n",*pf);
}
当a=-10
时,计算机中保存的就是-10
的补码,所以我们得到了:
S = 1
,E = 1111 1111
,M = 111 1111 1111 1111 1111 0110
由E全为1的规则,我们不难知道打印出来的一定为无穷大值
总结发现
进行了一轮较为浅层的学习,我不禁感到计算机出现的伟大,自计算机时代的开启,它就在不断地凝结了历代伟人的智慧,就像一件艺术品般闪耀着过往人类智慧的光辉。在感慨之余,我也更加明白对自身眼界的狭隘和知识储备的不足,希望与诸君共勉,在计算机的路上继续前进。