C | 从研究pow(3, 3)打印结果为0到学习IEEE浮点标准

前言

先说一下写这篇文章的起因,源于昨天一个朋友问我问题:

image-20200924165525936

代码对应如下:

#include <stdio.h>
#include <math.h>

int main () {
    
    
    int a, b;
    scanf("%d%d", &a, &b);
    printf("%d", pow(a, b));
    return 0;
}

相信知道pow返回值为double的同学,应该都可以大概可以猜到原因就和double为64位,在进行整型格式化输出时,导致高32位截断,低32位全为0有关,下面是pow函数的函数声明,可以看到pow返回类型为double

_CRTIMP	double __cdecl pow (double, double);

接下来就来具体探究原因。

IEEE 浮点表示

C语言的浮点数表示采取了IEEE浮点标准,这里不详细展开,具体信息可以查看IEEE 754浮点数标准详解,或者查看《深入理解计算机系统》这本书(以下简称为CSAPP)的 2.4 浮点数中的部分,该书的 pdf 文件链接也在文末给出,在正式讲解之前先说明本文并不会详细讲明浮点数的底层表示标准,将尽可能用简单且容易理解的方式向你介绍,并让你了解造成这一结果的原因,以及以后编程中需要注意的东西。

IEEE浮点标准使用 V = (-1)s * M * 2E 的形式来表示一个数(相信学过科学计数法的对这一表示都感到熟悉,下列解释为了便于理解有删减,但不影响整体的认识,想深入学习的还是建议查看CSAPP):

  • 符号(sign) s是决定该数(V)为正(s = 0)还是为负(s = 1);
  • 尾数(significand) M是一个二进制小数,一般都是1 ~ 2之间,就像在十进制中,这个对应的范围是1 ~ 10之间一样;
  • 阶码(exponent) E可以简单理解为我们在十进制科学计数法中的指数(可为负)。

此外浮点数的位表示被划分成了三部分:

  • 使用最高的一位代表符号位(s);
  • k 位的阶码 exp (由上述的 E 计算得出);
  • n 位的小数段 frac (由上述的 M 计算得出)。

在 C 语言中,我们以 32 位的float和 64 位的double为例:

  1. 对于float而言,上述的s, exp, frac分别取值为 1,8,23;
  2. 对于double而言,上述的s, exp, frac分别取值为 1,11,52。

对应下图:

image-20200924210633116

在讲解如何讲一个浮点数转换为上图表示之前,我们先了解一下,如何计算浮点数的二进制表示。

小数的二进制

我们先看一个十进制的例子:对于小数0.29,即对应 2 * 10-1 + 9 * 10-2,而二进制的表示也是同理:对于二进制小数 0.11,即对应1 * 2-1 + 1 * 2-2(0.5 + 0.25 = 0.75)。那么我们又如何从一个十进制小数得到对应的二进制小数呢,我们以一个能够精确转换的0.625为例,由于整数部分为 0,所以对应二进制小数的整数部分也为 0,对于小数部分的计算使用一下规则:从第一个小数位开始,每个小数位等于当前十进制小数位乘 2 后的整数位(为 0 或者 1 ),然后当前数变为乘 2 后的小数位,直到当前数为 0 为止,这样解释可能有点绕,下面就通过0.625的例子来详细展示:

当前十进制小数 乘 2 后的结果 整数部分 小数部分 得到的二进制小数位
0.625 1.25 1 0.25 1
0.25 0.5 0 0.5 0
0.5 1.0 1 0 1

因此,通过查看最后一列,我们就得到了0.625对应的二进制小数0.101,下面我就开始步入正题啦!

由十进制数得到 IEEE 表示数

在这里我们仅以十进制转化为double为例(为了方便等下讲解的开篇的pow函数问题),float转换也同理,我们就先以pow(3, 0)的结果27为例,对应二进制数为11011,先转换为IEEE浮点标准的形式:1.1011 * 24,然后我们来得到s, exp, frac的对应结果:

  • 27 为正,故最高位 s 为0;
  • exp为 1023 + E = 1023 + 4 = 1027(在float中这个1023需要改变为127),对于 1027,其对应的 11 位二进制为1000 0000 011(在floatexp只有 8 位);
  • frac取 M 的小数段并在末尾补0,补够 52 位(在float中为 23 位),因此frac对应的二进制结果是 1011后面再加 48 个 0。

因此,我们最终得到27对应的二进制数为0100 0000 0011 1011 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

我们可以在Java中通过以下代码来验证:

public static void main(String[] args) {
    
    
    // output: 100000000111011000000000000000000000000000000000000000000000000
    // 注意:打印结果是从第一个不为0的位开始打印的,上述结果对应我们自己计算得到的后63位
	System.out.println(Long.toBinaryString(Double.doubleToLongBits(27)));
}

由于我们运行的环境中int为32位,而double为 64 位,因此格式化输出时会对原数进行截断处理,只取低 32 位,也就得到了0。

当然你可能还是不太相信格式输出的时候做了这些东西,那么我们就来自己模拟一个数来进行验证,假如我们想让一个不是 29 的double数字(我们要反推计算出的数字),在格式化输出后打印了 29,那么我们就来开始模拟:

  • 首先 29 对应的二进制数为11101,由于最终要打印 29,我们就需要将低 32 位设置为11101并在前面加上27个0,而为了方便,加上doublefrac部分共有 52 位,因此我们就将frac部分设置为 11101前面加上 47 个 0;
  • 然后由于 29 为正数,因此最高位 s 对应为0,然后我们再计算稍微麻烦些的exp部分;
  • 由于frac部分是由 M 的小数部分得到的,而我们这次小数部分已经占用到了frac的最后部分(29frac的末尾),因此可知 E 对应为 52,所以 exp为 1023 + E = 1023 + 52 = 1075,对应的 11 位二进制位为 1000 0110 011
  • 最终我们得到了 0100 0011 0011 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0001 1101这个数。

我们可以在Java中通过以下代码得到其对应的浮点数:

public static void main(String[] args) {
    
    
    // output: 4.503599627370525E15
	System.out.println(
		Double.longBitsToDouble(0b0100001100110000000000000000000000000000000000000000000000011101L)
	);
}

然后我们可以在 C 代码中来验证这个浮点数如果使用整型格式化输出是不是会得到 29:

#include <stdio.h>

int main () {
    
    
    printf("%d", 4.503599627370525E15);
    return 0;
}

通过运行可以发现,我们的确得到了 29:

image-20200924223717613

总结

本文简单介绍了IEEE浮点标准的一些知识,当然深入了解还是需要多查阅资料,比如推荐的CSAPP这本书,除此之外,也可以看到,在日常的编码中,我们还是需要非常注意一些类型转换的,尤其是缩窄变换,我们需要特别注意需不需要进行强制类型转换,例如开篇的代码我们在pow函数前加上int的强制类型转换也可以正常得到 27(但是还是需要特别注意某些情况下可能无法得到预期的结果,毕竟int只有32位,而double有64位):

#include <stdio.h>
#include <math.h>

int main () {
    
    
    int a, b;
    scanf("%d%d", &a, &b);
    printf("%d", (int)pow(a, b));
    return 0;
}

希望本文能够对你有所帮助,激起你看CSAPP这本书的欲望。

参考资料

  • 《深入理解计算机系统》

资料链接

链接:https://pan.baidu.com/s/19REaaTdx3SALq9vZBlmjrA
提取码:e5sl

猜你喜欢

转载自blog.csdn.net/qq_41698074/article/details/108785267