小数在内存中是以浮点数的形式存储的。浮点数并不是一种数值分类,它和整数、小数、实数等不是一个层面的概念。浮点数是数字(或者说数值)在内存中的一种存储格式,它和定点数是相对的。
C语言使用定点数格式来存储 short、int、long 类型的整数,使用浮点数格式来存储 float、double 类型的小数。整数和小数在内存中的存储格式不一样。
我们在学习C语言时,通常认为浮点数和小数是等价的,并没有严格区分它们的概念,这也并没有影响到我们的学习,原因就是浮点数和小数是绑定在一起的,只有小数才使用浮点格式来存储。
其实,整数和小数可以都使用定点格式来存储,也可以都使用浮点格式来存储,但实际情况却是,C语言使用定点格式存储整数,使用浮点格式存储小数,这是在“数值范围”和“数值精度”两项重要指标之间追求平衡的结果,稍后我会给大家带来深入的剖析。
计算机的设计是一门艺术,很多实用技术都是权衡和妥协的结果。浮点数和定点数中的“点”指的就是小数点!对于整数,可以认为小数点后面都是零,小数部分是否存在并不影响整个数字的值,所以干脆将小数部分省略,只保留整数部分。
所谓定点数,就是指小数点的位置是固定的,不会向前或者向后移动。
假设我们用4个字节(32位)来存储无符号的定点数,并且约定,前16位表示整数部分,后16位表示小数部分,如下图所示:
如此一来,小数点就永远在第16位之后,整数部分和小数部分一目了然,不管什么时候,整数部分始终占用16位(不足16位前置补0),小数部分也始终占用16位(不足16位后置补0)。例如,在内存中存储了 10101111 00110001 01011100 11000011,那么对应的小数就是 10101111 00110001 . 01011100 11000011,非常直观。
这里所说的最小值不是 0 值,而是最接近 0 的那个值。
太阳的质量为:
如果使用定点数,那么只能按照=前面的格式来存储,这将需要很大的一块内存,大到需要几十个字节。
更加科学的方案是按照=后面的指数形式来存储,这样不但节省内存,也非常直观。这种以指数的形式来存储小数的解决方案就叫做浮点数。浮点数是对定点数的升级和优化,克服了定点数取值范围太小的缺点。
下面我们以 19.625 为例来演示如何将小数转换为浮点格式。
当 base 取值为 10 时,19.625 的浮点形式为:
19.625 整数部分的二进制形式为:可以看出,当基数(进制)base 确定以后,指数 exponent 实际上就成了小数点的移动位数:
19 = 1×24 + 0×23 + 0×22 + 1×21 + 1×20 = 10011
小数部分的二进制形式为:
0.625 = 1×2-1 + 0×2-2 + 1×2-3 = 101
将整数部分和小数部分合并在一起:
19.625 = 10011.101
虽然C语言标准没有规定 base 使用哪种进制,但是在实际应用中,各种编译器都将 base 实现为二进制,这样不仅贴近计算机硬件(任何数据在计算机底层都以二进制形式表示),还能减少转换次数。
接下来我们就讨论一下如何将二进制形式的浮点数放入内存中。
原则上讲,上面的科学计数法公式中,符号 sign、尾数 mantissa、基数 base 和指数 exponent 都是不确定因素,都需要在内存中体现出来。但是现在基数 base 已经确定是二进制了,就不用在内存中体现出来了,这样只需要在内存中存储符号 sign、尾数 mantissa、指数 exponent 这三个不确定的元素就可以了。
仍然以 19.625 为例,将它转换成二进制形式的浮点数格式:
当采用二进制形式后,尾数部分的取值范围为 1 ≤ mantissa < 2,这意味着:尾数的整数部分一定为 1,是一个恒定的值,这样就无需在内存中提现出来,可以将其直接截掉,只要把小数点后面的二进制数字放入内存中即可。对于 1.0011101,就是把 0011101 放入内存。
我们不妨将真实的尾数命名为 mantissa,将内存中存储的尾数命名为 mant,那么它们之间的关系为:
如果 base 采用其它进制,那么尾数的整数部分就不是固定的,它有多种取值的可能,以十进制为例,尾数的整数部分可能是 1~9 之间的任何一个值,这样一来尾数的整数部分就不能省略了,必须在内存中体现出来。而将 base 设置为二进制就可以节省掉一个位(Bit)的内存,这也算是采用二进制的一点点优势。
指数是一个整数,并且有正负之分,不但需要存储它的值,还得能区分出正负号来。
short、int、long 等类型的整数在内存中的存储采用的是补码加符号位的形式,数值在写入内存之前必须先进行转换,读取以后还要再转换一次。但是为了提高效率,避免繁琐的转换,指数的存储并没有采用补码加符号位的形式,而是设计了一套巧妙的解决方案,稍等我会为您解开谜团。
C语言中常用的浮点数类型为 float 和 double;float 始终占用 4 个字节,double 始终占用 8 个字节。
下图演示了 float 和 double 的存储格式:
浮点数的内存被分成了三部分,分别用来存储符号 sign、尾数 mantissa 和指数 exponent ,当浮点数的类型确定后,每一部分的位数就是固定的。
符号 sign 可以不加修改直接放入内存中,尾数 mantissa 只需要将小数部分放入内存中,最让人疑惑的是指数 exponent 如何放入内存中,这也是我们在前面留下的一个谜团,下面我们以 float 为例来揭开谜底。
float 的指数部分占用 8 Bits,能表示从 0~255 的值,取其中间值 127,指数在写入内存前先加上127,读取时再减去127,正数负数就显而易见了。19.625 转换后的指数为 4,4+127 = 131,131 换算成二进制为 1000 0011,这就是 19.626 的指数部分在 float 中的最终存储形式。
先确定内存中指数部分的取值范围,得到一个中间值,写入指数时加上这个中间值,读取指数时减去这个中间值,这样符号和值就都能确定下来了。
中间值的求取有固定的公式。设中间值为 median,指数部分占用的内存为 n 位,那么中间值为:
对于 float,中间值为 28-1 - 1 = 127;对于 double,中间值为 211-1 -1 = 1023。
我们不妨将真实的指数命名为 exponent,将内存中存储的指数命名为 exp,那么它们之间的关系为:
也可以写作:
为了方便后续文章的编写,这里我强调一下命名:
- mantissa 表示真实的尾数,包括整数部分和小数部分;mant 表示内存中存储的尾数,只有小数部分,省略了整数部分。
- exponent 表示真实的指数,exp 表示内存中存储的指数,exponent 和 exp 并不相等,exponent 加上中间数 median 才等于 exp。
此时符号为 0;尾数为 1.0011101,截掉整数部分后为 0011101,补齐到 23 Bits 后为 001 1101 0000 0000 0000 0000;指数为 4,4+127 = 131,131 换算成二进制为 1000 0011。
综上所述,float 类型的 19.625 在内存中的值为:0 - 10000011 - 001 1101 0000 0000 0000 0000。
下面我们通过代码来验证一下:
#include <stdio.h>
#include <stdlib.h>
//浮点数结构体
typedef struct {
unsigned int nMant : 23; //尾数部分
unsigned int nExp : 8; //指数部分
unsigned int nSign : 1; //符号位
} FP_SINGLE;
int main()
{
char strBin[33] = { 0 };
float f = 19.625;
FP_SINGLE *p = (FP_SINGLE*)&f;
itoa(p->nSign, strBin, 2);
printf("sign: %s\n", strBin);
itoa(p->nExp, strBin, 2);
printf("exp: %s\n", strBin);
itoa(p->nMant, strBin, 2);
printf("mant: %s\n", strBin);
return 0;
}
运行结果:
mant 的位数不足,在前面补齐两个 0 即可。
printf() 不能直接输出二进制形式,这里我们借助 itoa() 函数将十进制数转换成二进制的字符串,再使用%s输出。itoa() 虽然不是标准函数,但是大部分编译器都支持。不过 itoa() 在 C99 标准中已经被指定为不可用函数,在一些严格遵循 C99 标准的编译器下会失效,甚至会引发错误,例如在 Xcode(使用 LLVM 编译器)下就会编译失败。如果 itoa() 无效,请使用%X输出十六进制形式,十六进制能够很方便地转换成二进制。
对于十进制小数,整数部分转换成二进制使用“展除法”(就是不断除以 2,直到余数为 0),一个有限位数的整数一定能转换成有限位数的二进制。但是小数部分就不一定了,小数部分转换成二进制使用“乘二取整法”(就是不断乘以 2,直到小数部分为 0),一个有限位数的小数并不一定能转换成有限位数的二进制,只有末位是 5 的小数才有可能转换成有限位数的二进制,其它的小数都不行。
float 和 double 的尾数部分是有限的,固然不能容纳无限的二进制;即使小数能够转换成有限的二进制,也有可能会超出尾数部分的长度,此时也不能容纳。这样就必须“四舍五入”,将多余的二进制“处理掉”,只保留有效长度的二进制,这就涉及到了精度的问题。也就是说,浮点数不一定能保存真实的小数,很有可能保存的是一个近似值。
对于 float,尾数部分有 23 位,再加上一个隐含的整数 1,一共是 24 位。最后一位可能是精确数字,也可能是近似数字(由四舍五入、向零舍入等不同方式得到);除此以外,剩余的23位都是精确数字。从二进制的角度看,这种浮点格式的小数,最多有 24 位有效数字,但是能保证的是 23 位;也就是说,整体的精度为 23~24 位。如果转换成十进制,224 = 16 777 216,一共8位;也就是说,最多有 8 位有效数字,但是能保证的是 7 位,从而得出整体精度为 7~8 位。
对于 double,同理可得,二进制形式的精度为 52~53 位,十进制形式的精度为 15~16 位。
浮点数的存储以及加减乘除运算是一个比较复杂的问题,很多小的处理器在硬件指令方面甚至不支持浮点运算,其他的则需要一个独立的协处理器来处理这种运算,只有最复杂的处理器才会在硬件指令集中支持浮点运算。省略浮点运算,可以将处理器的复杂度减半!如果硬件不支持浮点运算,那么只能通过软件来实现,代价就是需要容忍不良的性能。
PC 和智能手机上的处理器就是最复杂的处理器了,它们都能很好地支持浮点运算。
在六七十年代,计算机界对浮点数的处理比较混乱,各家厂商都有自己的一套规则,缺少统一的业界标准,这给数据交换、计算机协同工作带来了很大不便。
作为处理器行业的老大,Intel 早就意识到了这个问题,并打算一统浮点数的世界。Intel 在研发 8087 浮点数协处理器时,聘请到加州大学伯克利分校的 William Kahan 教授(最优秀的数值分析专家之一)以及他的两个伙伴,来为 8087 协处理器设计浮点数格式,他们的工作完成地如此出色,设计的浮点数格式具有足够的合理性和先进性,被 IEEE 组织采用为浮点数的业界标准,并于 1985 年正式发布,这就是 IEEE 754 标准,它等同于国际标准 ISO/IEC/IEEE 60559。
IEEE 是 Institute of Electrical and Electronics Engineers 的简写,中文意思是“电气和电子工程师协会”。
IEEE 754 简直是天才一般的设计,William Kahan 教授也因此获得了 1987 年的图灵奖。图灵奖是计算机界的“诺贝尔奖”。
目前,几乎所有的计算机都支持 IEEE 754 标准,大大改善了科学应用程序的可移植性,C语言编译器在实现浮点数时也采用了该标准。
不过,IEEE 754 标准的出现晚于C语言标准(最早的 ANSI C 标准于 1983 年发布),C语言标准并没有强制编译器采用 IEEE 754 格式,只是说要使用科学计数法的形式来表示浮点数,但是编译器在实现浮点数时,都采用了 IEEE 754 格式,这既符合C语言标准,又符合 IEEE 标准,何乐而不为。
IEEE 754 标准规定,当指数 exp 的所有位都为 1 时,不再作为“正常”的浮点数对待,而是作为特殊值处理:
当指数 exp 的所有二进制位都为 0 时,情况也比较特殊。
对于“正常”的浮点数,尾数 mant 隐含的整数部分为 1,并且在读取浮点数时,内存中的指数 exp 要减去中间值 median 才能还原真实的指数 exponent,也即:
但是当指数 exp 的所有二进制位都为 0 时,一切都变了!尾数 mant 隐含的整数部分变成了 0,并且用 1 减去内存中的指数 exp 才能还原真实的指数 exponent,也即:
对于 float,exponent = 1 - 127 = -126,指数 exponent 的值恒为 -126;对于 double,exponent = 1 - 1023 = -1022,指数 exponent 的值恒为 -1022。
当指数 exp 的所有二进制位都是 0 时,我们将这样的浮点数称为“非规格化浮点数”;当指数 exp 的所有二进制位既不全为 0 也不全为 1 时,我们称之为“规格化浮点数”;当指数 exp 的所有二进制位都是 1 时,作为特殊值对待。 也就是说,究竟是规格化浮点数,还是非规格化浮点数,还是特殊值,完全看指数 exp。
对于非规格化浮点数,当尾数 mant 的所有二进制位都为 0 时,整个浮点数的值就为 0:
我们以 float 类型为例来说明。
对于规格化浮点数,当尾数 mant 的所有位都为 0、指数 exp 的最低位为 1 时,浮点数的绝对值最小(符号 sign 的取值不影响绝对值),为 1.0 × 2-126,也即 2-126。
对于一般的计算,这个值已经很小了,非常接近 0 值了,但是对于科学计算,它或许还不够小,距离 0 值还不够近,非规格化浮点数就是来弥补这一缺点的:非规格化浮点数可以让最小值更小,更加接近 0 值。
对于非规格化浮点数,当尾数的最低位为 1 时,浮点数的绝对值最小,为 2-23 × 2-126 = 2-149,这个值比 2-126 小了 23 个数量级,更加即接近 0 值。
让我更加惊讶的是,规格化浮点数能够很平滑地过度到非规格化浮点数,它们之间不存在“断层”,下表能够让读者看得更加直观。
说明 | float 内存 | exp | exponent | mant | mantissa | 浮点数的值 flt |
---|---|---|---|---|---|---|
0值最小非规格化数最大非规格化数 | 0 - 00...00 - 00...000 - 00...00 - 00...010 - 00...00 - 00...100 - 00...00 - 00...11……0 - 00...00 - 11...100 - 00...00 - 11...11 | 0000……00 | -126-126-126-126……-126-126 | 02^-232^-221.1 × 2^-22……0.11...100.11...11 | 02^-232^-221.1 × 2^-22……0.11...100.11...11 | +02^-1492^-1481.1 × 2^-148……1.11...10 × 2^-1271.11...11 × 2^-127 |
最小规格化数最大规格化数 | 0 - 00...01 - 00...000 - 00...01 - 00...01……0 - 00...10 - 00...000 - 00...10 - 00...01……0 - 11...10 - 11...100 - 11...10 - 11...11 | 11……22……254254 | -126-126……-125-125127127 | 0.00.1……0.00.1……0.11...100.11...11 | 1.01.1……1.01.1……1.11...100.11...11 | 1.0 × 2^-1261.1 × 2^-126……1.0 × 2^-1251.1 × 2^-125……1.11...10 × 2^1271.11...11 × 2^127 |
0 - 11...11 - 00...00 | - | - | - | - | +∞ | |
0 - 11...11 - 00...01……0 - 11...11 - 11...11 | - | - | - | - | NaN |
^ 表示次方,例如 2^10 表示 2 的 10 次方。
上表演示了正数时的情形,负数与此类似。请读者注意观察最大非规格化数和最小规格化数,它们是连在一起的,是平滑过渡的。
浮点数的尾数部分 mant 所包含的二进制位有限,不可能表示太长的数字,如果尾数部分过长,在放入内存时就必须将多余的位丢掉,取一个近似值。究竟该如何来取这个近似值,IEEE 754 列出了四种不同的舍入模式。
就是将结果舍入为最接近且可以表示的值,这是默认的舍入模式。最近舍入模式和我们平时所见的“四舍五入”非常类似,但有一个细节不同。
对于最近舍入模式,IEEE 754 规定,当有两个最接近的可表示的值时首选“偶数”值;而对于四舍五入模式,当有两个最接近的可表示的值时要选较大的值。以十进制为例,就是对.5的舍入上采用偶数的方式,请看下面的例子。
最近舍入模式:Round(0.5) = 0、Round(1.5) = 2、Round(2.5) = 2
四舍五入模式:Round(0.5) = 1、Round(1.5) = 2、Round(2.5) = 3
会将结果朝正无穷大的方向舍入。标准库函数 ceil() 使用的就是这种舍入模式,例如,ceil(1.324) = 2,Ceil(-1.324) = -1。
会将结果朝负无穷大的方向舍入。标准库函数 floor() 使用的就是这种舍入模式,例如,floor(1.324) = 1,floor(-1.324) = -2。
会将结果朝接近 0 的方向舍入,也就是将多余的位数直接丢掉。C语言中的类型转换使用的就是这种舍入模式,例如,(int)1.324 = 1,(int) -1.324 = -1。
与定点数相比,浮点数在精度方面损失不小,但是在取值范围方面增大很多。牺牲精度,换来取值范围,这就是浮点数的整体思想。
IEEE 754 标准其实还规定了浮点数的加减乘除运算,不过本文的重点是讲解浮点数的存储,所以对于浮点数的运算不再展开讨论。
上节我们还留下了一个疑问,就是用 %f 输出 128.101 时得到的是一个近似值,而不是一个精确值,这是因为,128.101 转换为浮点格式后,尾数部分过长,被丢掉了,不能“真实”地存储了。
128.101 转换成二进制为:
向左移动 7 位后为:
由此可见,尾数部分为:
将多出的二进制丢掉后为:
使用 printf 输出时,还需要进行还原,还原后的二进制为:
转换成十进制为 128.1009979248046875,按照四舍五入的原则取 6 位小数,就是128.100998。