Post

深入探索一个浮点数问题

一段代码引发了我对浮点数的思考

开始头脑风暴

那么对于下面这种情况,会发生什么呢?

tp.c

运行得到的结果是:

1
float format: 0.000000

在计算机中,整数37的二进制表示为100101,经过强制转换后,数据将会重新解释为浮点数,100101转换成浮点数是多少?

float是32位数据类型,对32位机器规格化浮点数公式为: \((-1)^s\times 1.M \times 2^{E-127}\) 即对于100101而言,M=00101,E-127=5 => E = 132 == 1000 0100,故而在计算机中,应当将0...00100101重新解释为 0 10000100 00101000000000000000000

当然,这是预期结果🤣。

另一种方式

让我们换一种方式,请见如下代码:

tp.c

运行得到的结果是:

1
float format: 0.000000	37.000000

该死的指针

怀着敬畏之心去深究这件事情,让我们先看看下面这串代码,熟悉熟悉这该死的指针操作:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
int main()
{
    int i = 10;
    int *iptr = &i;
    printf("(float)* : %f\n", (float)*iptr); // 【1】
    printf("(float*) : %f\n", (float*)iptr); // 【2】
    printf("*(float*) : %f\n", *(float*)iptr); // 【3】
    return 0;
}

输出:

1
2
3
 (float)* : 10.000000
 (float*) : 10.000000
*(float*) : 0.000000

【1】中,我们将解引用得到的对象进行显式转换,结果符合预期;

【2】这种做法是不够稳妥的,如果编译的时候不忽略编译器的警告,是无法执行的,(float*)iptr是一个指针,应当用%p输出格式

【3】的另一种做法就是*(float*)&iptr,根据运算优先级1的规律,将iptr强制转换为float*类型后解引用。

那么,对于原先指向int类型的,声明为int*类型的iptr而言,在通过强制转换为float*类型,解引用得到的对象,会是float类型吗?

问一问编译器

带着这个问题,我们问一问编译器:

使用 gcc -Wall -Wextra -fstrict-aliasing -std=gnu2x -c tf.c -o tf来编译文件,我们得到编译器反馈的问题:

这看起来比较糟糕

发现,编译器唯独在涉及float f1 = *(float*)&i处弹出警告,那么这意味着什么?

让我们更深入的探索一下,这是翻译得到的汇编代码(使用risc-v32指令集),灰色部分对应的是float f1 = *(float*)&i;红色部分对应的是float f2 = *(char*)&i

image-20240324145629802

那么问题的关键就在于红色方框标注的部分。通过查询指令集,让我们理解一下这段汇编代码的含义:

from RISC-V Reference Card

尤其该注意的是fcvt.s.wu指令,它是将int类型转换为float类型的关键。

这让我们发现,通过char*的强制转换会有格外的fcvt.s.wu指令,那么为什么C语言要有这个意外呢?

我们知道,对于任何类型的指针而言,sizeof(int*) === sizeof(float*) === sizeof(char*) === sizeof(void*)... 都是八字节长度存储,为什么char*会如此的特别?

严格别名规则

先让我们回顾对象和指针(或者是,别名)

Object models and type systems capture some of the most fundamental principles of the C and C++ languages. Both languages rely on object identity: The fact that each named object is uniquely designated by its name, and that no other identifier denotes the same object. Because all objects must be distinct from each other, and because null is a valid pointer that doesn’t point to any object, no two object addresses can be equal to one another, or to null. (The notable exception is the equality of the address of the first sub-object to that of its enclosing object. It is also possible that a pointer just past the end of one object will compare equal to one to an unrelated object that happens to be stored at that location, but that is happenstance and never guaranteed.)2

这段可能太长了,让我来大致翻译一下,这段话的意思就是在强调:指针是对象,定义的变量也是对象,每个对象都匹配其相应的类型,而当你让编译器改变(即重新解释)指针的类型并不会改变它所指向的对象的类型。一旦对象声明,一直到它的生命周期结束,都无法改变其类型,但是可以改变它的值,或者说,它绑定的值。

这也就意味着,在float f1 = *(float*)&i中,指针&i的类型是int*类型,通过强制转换后,再解引用,这会让编译器很矛盾,因为原i的类型一直是int类型。通过指针操作改变所指向的对象的做法,应当左值与右值之间是兼容的,如果无法兼容,对编译器来说,你破坏了严格别名规则(strict aliasing rule),这会非常的麻烦。这意味着可能会导致未定义行为的错误。

当然,有什么方式能够绕过严格别名规则呢?通过查找资料发现,可以尝试了解Type-Punning方法。但,为什么一定需要破坏规则呢?

此外,字符类型是严格别名规则的一个例外(为之开了一道后门),例如char;而这也是为什么通过char*可以绕过别名规则悄悄做坏事情,例如常用的memcpy()函数就用到了这个例外实现。


回到最初的问题,以上的说明解释了float f2 = *(char*)&i的例外,但并没有解释为什么打印f1的结果是0.00000

  1. 分析一下该语句的含义:float f=*(float*)&i;
  • &i:取i的地址
  • (float*)&i:将i的地址转换成float指针
  • *(float*)&i:float指针指向的这个数 所以该语句的含义是:将存储i的内存单元中的内容理解成一个浮点数的内存表示
  1. 先分析i的内存表示:由于i是int型,即32位有符号数,故在内存中用补码表示,37为正数,故补码即为其二进制形式的真值。37=(100101)2,由于补码用32位表示,故37在计算机内部存储的形式为:00000000 00000000 00000000 00100101

  2. 将i的内存表示理解成是一个浮点数的表示,分析这个浮点数的真值是多少。

Float是32位浮点数,格式是S,E,M,因此将37的内存表示理解成浮点数时,有S=0,E=00000000,M=0000000 00000000 00100101 这里E是全0,M非零

根据 IEEE754 规定,表示非规格化数, 真值 \(x=(-1)^s\cdot (0.M)\cdot 2^{-126} \\ =1\cdot 0. 0000000 00000000 00100101 \cdot 2^{-126}\) 利用浮点数线上转换工具看一下上面的数是多少?

image-20240328183702171

上式的值是5.2*10-44,是一个绝对值非常接近于0的正数,在精度有限的情况下,近似为0 这就是为什么上面的程序运行结果是 0.000000

  1. 题外话,也许这里需要先复习复习C 运算符优先级?如果忘记了的话,见 https://c-cpp.com/c/language/operator_precedence.html ↩ ↩︎

  2. https://developers.redhat.com/blog/2020/06/02/the-joys-and-perils-of-c-and-c-aliasing-part-1# ↩︎

This post is licensed under CC BY 4.0 by the author.

Trending Tags