浮点数在内存中的存储方式与运算陷阱

我们知道,数据在内存中都是以二进制形式存储的,因此,我们的计算机底层除了0和1外,其余的是不认识的,因此无论是高级语言还是较底层的汇编语言,都是转换成机器语言来使计算机识别的。

接下来我们介绍一下关于浮点数存储及其运算的有关问题。

二进制小数

首先来看一下十进制小数如何用二进制表示:

十进制小数分为两个部分:整数部分和小数部分

整数部分直接转换成二进制

小数部分的计算相对复杂:

  • 拿出小数部分 0.xx,乘2,保留整数位

  • 将上述结果在取出小数部分乘2,保留整数位

  • 直到小数部分为0,如果永远不为零,则按要求保留足够位数的小数,最后一位做0舍1入。将取出的整数顺序排列。

整数部分:除以2,取出余数,商继续除以2,直到得到0为止,将取出的余数逆序

小数部分:乘以2,然后取出整数部分,将剩下的小数部分继续乘以2,然后再取整数部分,一直取到小数部分为零为止。如果永远不为零,则按要求保留足够位数的小数,最后一位做0舍1入。将取出的整数顺序排列。

举例:22.8125转二进制的计算过程

整数部分:即22的二进制为 10110

小数部分:
浮点数在内存中的存储方式与运算陷阱

得到0.8125的二进制是 0.1101

十进制 22.8125 等于二进制 10110.1101

浮点数在内存中的存储

C语言和C#语言中,对于浮点类型的数据采用单精度类型(float)和双精度类型(double)来存储,float数据占用32bit,double数据占用64bit,我们在声明一个变量float f= 2.25f的时候,是如何分配内存的呢?

其实不论是float还是double在存储方式上都是遵从IEEE的规范的,float遵从的是IEEE R32.24 ,而double 遵从的是R64.53。

无论是单精度还是双精度在存储中都分为三个部分:

  • 符号位(Sign):0代表正,1代表为负

  • 指数位(Exponent):用于存储科学计数法中的指数数据,并且采用移位存储

  • 尾数部分(Mantissa):尾数部分

其中float的存储方式如下图所示:
浮点数在内存中的存储方式与运算陷阱
而双精度的存储方式为:
浮点数在内存中的存储方式与运算陷阱

R32.24和R64.53的存储方式都是用科学计数法来存储数据的,在计算机存储中,首先要将二进制小数改为二进制的科学计数法表示,8.25用二进制表示可表示为1000.01

用二进制的科学计数法表示1000.01可以表示为1.0001 × 2 ^ 3

1110110.1可以表示为1.11110001 × 2 ^ 6

任何一个数都的科学计数法表示都为1.XX × 2 ^ n,尾数部分就可以表示为XX。可以将小数点前面的1省略,所以23bit的尾数部分,可以表示的精度却变成了24bit,那24bit能精确到小数点后几位呢?

我们知道0 - 9的二进制表示为0000 - 1001,所以4bit能精确十进制中的1位小数点,24bit就能使float能精确到小数点后6位

而对于指数部分,因为指数可正可负,8位的指数位能表示的指数范围就应该为:-127 - 128了,所以指数部分的存储采用移位存储,存储的数据为 元数据 + 127,下面就看看 8.25 和 120.5 在内存中真正的存储方式。

首先看下8.25,用二进制的科学计数法表示为 1.0001 × 2 ^ 3

按照上面的存储方式,符号位为 0,表示为,指数位为 :3 + 127 = 130,尾数部分为 000100···00,故8.25的存储方式如下图所示:
浮点数在内存中的存储方式与运算陷阱
我们以相同的方式计算一下单精度浮点数 -523.75 在内存中的存储方式,分为下述几个步骤求解:

  • 十进制小数转为二进制小数
  • 将二进制小数转为科学计数法表示
  • 分别计算符号位、指数位、尾数部分
    浮点数在内存中的存储方式与运算陷阱

523.75 转为 二进制为 ‭1000001011‬.11

1000001011‬.11 = 1.00000101111 × 2^9

符号位为 1,表示;指数位十进制表示为 9 + 127 = 136;尾数部分为0000010111100·····0
浮点数在内存中的存储方式与运算陷阱

程序验证

#include <iostream>
using namespace std;

void printBinary(const unsigned long val, int index)
{
	for (int i = 7; i >= 0; i--)	// 将每个字节的8位二进制输出
	{
		if (index == 1 && i == 6)
		{
			cout << " - ";
		}
		else if (index == 2 && i == 6)
		{
			cout << " - ";
		}
		if (val & (1 << i))
			cout << "1";
		else
			cout << "0";
	}
}

int main()
{
	float data = -523.75;

	unsigned char *buffer = (unsigned char *)&data;

	static int index = 1;

	for (int i = sizeof(float) - 1; i >= 0; --i, ++index)
	{
		printBinary(buffer[i], index);	// 传入每个字节
	}
	cout << endl;
}
浮点数在内存中的存储方式与运算陷阱

从上述运行结果我们可以看到 -573.75 的二进制表现形式,与我们上面推导出的结果一致,接下来我们去往内存中看一下其存储是怎样的:
浮点数在内存中的存储方式与运算陷阱

我们看到了在内存(寄存器)中存储为 C4 02 F0 00,与我们推导出的结果相同。

那么如果给出内存中一段数据,并且告诉你是单精度存储的话,你如何知道该数据的十进制数值呢?其实就是对上面的反推过程

而双精度浮点数的存储和单精度的存储大同小异,不同的是指数部分和尾数部分的位数。所以这里不再详细的介绍双精度的存储方式了。

含有浮点数的运算错误

首先,我们来看一个计算机运算错误(无法得到正确结果)的例子。

#include <stdio.h>

int main()
{
	float sum;
	int i;

	// 将保存总和的变量清零
	sum = 0;

	for (i = 0; i <= 100; i++)
	{
		sum += 0.1;
	}

	// 显示结果
	printf("sum = %f\n", sum);
}
浮点数在内存中的存储方式与运算陷阱

我们可以看到,程序将0.1累加100次,本应结果为10,但是输出结果却并不是10。

我们在上边讲述了将十进制小数转换成二进制小数的方法,接下来我们讲解一下将二进制小数如何转换成对应的十进制数。

示例:二进制小数 1011.0011 转换成十进制数
浮点数在内存中的存储方式与运算陷阱
那么上述程序之所以会出现运算错误,是因为“有一些十进制的小数无法转换成二进制数“,例如十进制数0.1,就无法用二进制数正确表示。

上图中,小数点后4位用二进制数表示时的数值范围是0.0001 - 0.1111,因此这里只能表示0.5、0.25、0.125、0.0625这四个二进制小数点后面的位权组合而成(相加总和)的小数。将这些数值组合后能够表示的数值,即为下表所示的无序的十进制数。
浮点数在内存中的存储方式与运算陷阱

在上表中,十进制0的下一位是0.0625,因此这中间的小数,就无法用小数点后4位数的二进制数来表示。同样,0.0625的下一位数一下子变成了0.125。这时,如果增加二进制小数点后边的位数,与其相对应的十进制数的个数也会增加,但不管增加多少位,都无法得到0.1这个结果。实际上,十进制数0.1转换成二进制后,会变成0.00011001100···(1100循环)这样的循环小数。这和无法用十进制数来表示1/3是一样的道理。

上述程序中因为无法正确的表示数值,最后都变成了近似值。计算机这个功能有限的机器设备,是无法处理无限循环小数的。因此,**在遇到循环小数时,计算机就会根据变量的数据类型所对应的长度将数值从中间截断或者四舍五入。**我们知道,将0.3333···这样的循环小数从中间截断会变成0.33333,这时它的3倍是无法得出1的,计算机运算出错的原因也是同样的道理。