缘起: 一个网友问我为什么他在java中
System.out.println( 2.0 - 1.1 );
得到的结果是
8.999999999
而C/C++中的
printf("%f\n", 2.0 - 1.1);
得到的是
9.0
白天有事忙, 没功夫深究.
数字的编码属于机器表示和体系结构的问题, 也跟FPU的设计有关. 我曾经怀疑JAVA通过JVM对它的float和double数据类型另有表示, 所以可以得到的精度更高, 但Gosling的<
> 打消了这个疑虑, JAVA是新生派语言, IEEE 754的浮点数标准早就制定出来并已经成为了事实上的标准. 与标准不兼容是冒语言设计之大不韪的做法. JAVA没这么干, C#也没这么干, 幸好如此.
早有印象<<深入理解计算机系统>>这本书中讲述浮点数的部分十分精彩, 打开该书, 它却没有一个例子是直接decode 一个真正的IEEE 754中的float单精度类型的, 而是为了通用角度, 以不同长度的bits作为指数和尾数部分进行讨论.
为了查看它的位级表示, 我设计了下面的一个结构:
union {
float d;
struct
{
unsigned int tail1 :23;//Mantissa
unsigned int exp :8; //Exp
unsigned int sign :1; //Sign
}bits;
} data = { 2.0f - 1.1f } ;
缺少了上下文, 可能很多对C不那么熟的人已经开始怀疑这是不是C语言了, 正宗的C语言. 为了避免double到float的精度丢失, 我在2.0和1.1的后面都有指定单精度.
这个结构是研究编译器把 2.0f - 1.1f 这个在编译期可计算的常数究竟在每个bit的布局上是如何表示的.
下面的语句把这个浮点数所占用的4个字节的内容强制以十六进制显示出来:
printf("data.float = %#010x\n", * ((int*)&data.d) );
结果是 0x3f666666
计算机教育的表示法有个不约而成的规矩: 对于
0x3f66, 假设是在intel机器上以Little endian表示法存储, 则往往书上会把各个位写成
0011 1111 0110 0110
而在内存中依地址递增(从左到右)的顺序其表示是
0x66 0x3f
假设 0x66的地址是0x100, 则0x3f的地址是0x101, 内存编码的基本单位是字节. 而这两个字节合在一起作为一个short其地址当然仍然是 0x100.
注意Little endian是以字节为单位对各个字节逆序存放, 并非是以并字节, 至少我不止一次认为在一个字节的内部的半字节(nibble)也是以逆序存放的.
对于一个十六进制表示的字节, 习惯的写法是最左边是它的最高位. 即most significant bit(MSB), 最右边是其最低位, 即least significant bit(LSB), 这样0x3f 就是
0011 1111
0x66是
0110 0110
所以如果以这种视角来想象各个BIT在一个字节的内部也是从左到右排列的话会造成一个困难:
0110 0110 0011 1111
最终的 0x3f66 连直接书写的BIT 排布既不是两个字节的各个bit自然地从左到右摆放, 如上面; 也不是其逆序. 如下面:
1111 1100 0110 0110
而是要将后面的8个Bits作为一个整体(其内部的各个bit顺序不能乱)调整到0x66的前面.
0011 1111 ^ 0110 0110
而内存的物理表示中一个字节内部是不是按照这种理想的头脑模型来进行的? 我猜很大可能上不是, 而且这也是物理实现的细节, 不影响上层的逻辑概念.
到这里, 可以用各个位来准确定义浮点数格式中的各个部分, 对于float:
共32个bits, Bit 31是MSB, Bit 0是LSB, 则
Bit 31是符号位, 接下来的8位是指数位, 指数位被视为一个无符号的数, 它与127的差就是以2为底的指数的部分. 最后的23位是小数部分. 而小数部分是这样规定的:
11001100110011001100110
这是0.9f 的小数部分, 共23位, 它表示
1.11001100110011001100110
这样一个以2为基数的小数, 小数点前面的1是隐含的, 这是一个小技巧以获得额外的一位表示. 所以上面的整个数是
1 + (1/2+1/4) + (1/32+1/64) + (1/512+1/1024) + 1/(1024*8)+1/(1024*16) + 1/(1024*16*8)+1/(1024*16*16)
有意思的是上面这个式子可以直接COPY到windows的计算器中, 可能很多人还不知道计算器是可以接受一个式子, 并且这个式子中还可以通过括号来改变优先级的. 当然在计算器中CTRL-C 也是可以把计算结果复制到系统剪贴板的, 不知道有多少人还是在求得结果后通过默念把各个数字再搬运到另外的文件中去.
它的结果是 1.7999999523162841796875, 加上前面的指数部分值为-1, 效果也就是除以2, 得到的值就是
0.89999997615814208984375
数一数小数点后面的数字长度, 是23位. 把printf的输入精度也调整到这么多小数点:
printf("%.23f\n", 2.0f - 1.1f );
得到
0.89999997615814209000000
最后的7位与理论上可以得到的最精确结果不符, 这是因为ANSI C标准中printf对其精度有一个上限.
至此可以知道并不是C/C++语言对浮点数的内部表示与JAVA不同, 而是因为printf的精度要求没有指定, 默认的精度是小数点后6位, 后面的进行四舍五入处理, 所以结果就是0.9了, 而java的println 在Java之前并没有任何标准需要遵循, 所以它可以自作主张地把默认精度搞大一点.
这位朋友也告诉我java中的printf(Java的新版由于考虑到printf的广大用户, 加入了与C兼容的printf, C++的IO流机制也同样遭到了喜爱printf的C用户的抵制, 可见习惯的力量是多么强大)中也可以输出与C/C++中同样的结果, 原因很简单, JAVA新加的printf就是为了讨C程序员的欢心, 自然与C语言中的printf行为一致.
<<深入理解计算机系统>>中说, 很多程序员认为浮点数的表示最没意思, 而且深奥难懂. 的确是比整数要复杂不少.
对自己琢磨半天的东西小结一下备忘:
- 计算机中数的表示远远比一般人想象的复杂, 不要认为自己学过初等数学就自然地理解了计算机中的数. 如果你不相信, 就问问自己下面的问题:
- 有符号整数表示的范围是对称的吗
- 一个IEEE 754 的float跟典型的计算机上的int都占用4个字节, 为什么float可以表示比int甚至是unsigned int大的多的数? 它的代价是什么? 任何数都能被float表示吗?
- float中-0和+0 在哪些方面一样, 哪些方面不一样.
- NaN与另一个NaN 作数学运算结果是什么? NaN与另一个NaN作比较相等吗?
- C语言中两个浮点数用 == 逻辑判断符作比较的含义是什么?
- 为什么C#语言中的Math.Floor( double) 函数为什么返回一个double, 而不是对一个double向上取整返回一个整数? 它的文档中说的 "Returns the largest whole number less than or equal to the specified number"是什么意思? 这句话的神秘含义是什么?
- 不要以为IEEE 754的定义是乱来的, 正如不要以为以补码表示负数是专门为了让别人学计算机更困难一些, 看看计算机组成原理, 想想怎么用门电路自动实现这些简单的数学运算. 你就不那么容易站着说话不腰痛了.
- 不要以为自己是一个从业多年的IT人就对这些基础的东西不屑一顾, 多想想: 这个貌似简单的问题, 自己真懂了吗, 你能把你的理解向一个目不识丁的人解释清楚吗, 不要以为这样很过分, 白居易写诗的一个非功能性需求就是要能让普通老太太也能念懂. 而且, 这将真正检验你自己的理解. 据调查, 90%的计算机科学家在1个半小时内写不出正确的快速查找算法. 我也深信10年经验的C/C++程序员未必知道printf/scanf的一些用法. 知易行难, 细节很重要.
- C/C++/C#/Java语言中默认地写一个小数的数字, 它的类型是double, 不是float, 要想显式地表达它是float, 在数字后附加一个f或F.
- C语言中的printf的%f 格式符不是代表 float, 而是代表fixed, 即以定点小数的形式显示, 而printf根本就没有显示一个float的格式符, 对于原型如printf(const char *, ...) 这样的函数, 传递一个 float给函数, 也会被先转换成double 进行调用. 这是C语言的类型提升规则.
- 与printf不对称的是, scanf家族的函数却有一个 %f来输入一个float, 以%lf来输入一个double, 注意不是%d来输入double, 因为printf中已经用了%d来表示一个decimal(十进制)的整数, scanf的设计为了尽量与之对称, 目的是减少程序员的记忆量.
- 不要以为C/C++语言中的强制类型转换一定不消耗CPU时间, 不对应CPU指令. 检查你的编译器的汇编输出.
- 不要还以为浮点运算一定比整数运算慢, 现在的多数处理器都做得到一样快甚至更快, 每过10年都得重新评估什么是可计算的, 什么是可行的, 都得重新查视以前的一些结论, 对于很多领域, 时间周期还远远不需10年.
- 这个网页至少在2006/12/15 是有效的, 对理解浮点数的表示原理很有用.
阅读(2805) | 评论(0) | 转发(0) |