返回浮点值为什么会改变其值?

21
以下代码在 Red Hat 5.4 32 位系统上引发了 assert,但在 Red Hat 5.4 64 位系统(或 CentOS)上却可以正常工作。
在 32 位系统中,我必须将 millis2seconds 函数的返回值存储在一个变量中,否则会触发 assert,并显示函数返回的 double 值与传递给该函数的值不同。
如果注释掉 "#define BUG" 行,则可以正常工作。
感谢 @R,向编译器传递 -msse2 -mfpmath 选项可以使 millis2seconds 函数的两个变体都正常工作。
/*
 * TestDouble.cpp
 */

#include <assert.h>
#include <stdint.h>
#include <stdio.h>

static double millis2seconds(int millis) {
#define BUG
#ifdef BUG
    // following is not working on 32 bits architectures for any values of millis
    // on 64 bits architecture, it works
    return (double)(millis) / 1000.0;
#else
    //  on 32 bits architectures, we must do the operation in 2 steps ?!? ...
    // 1- compute a result in a local variable, and 2- return the local variable
    // why? somebody can explains?
    double result = (double)(millis) / 1000.0;
    return result;
#endif
}

static void testMillis2seconds() {
    int millis = 10;
    double seconds = millis2seconds(millis);

    printf("millis                  : %d\n", millis);
    printf("seconds                 : %f\n", seconds);
    printf("millis2seconds(millis)  : %f\n", millis2seconds(millis));
    printf("seconds <  millis2seconds(millis)  : %d\n", seconds < millis2seconds(millis));
    printf("seconds >  millis2seconds(millis)  : %d\n", seconds > millis2seconds(millis));
    printf("seconds == millis2seconds(millis)  : %d\n", seconds == millis2seconds(millis));

    assert(seconds == millis2seconds(millis));
}

extern int main(int argc, char **argv) {
    testMillis2seconds();
}

8
永远不要、绝对不要、千万不要将浮点数进行相等比较。该变量会强制将值从80位精度截断为64位精度,通常如此。 - Hans Passant
22
@HansPassant:有很多情况下,比较浮点数的相等性是完全正确的做法,包括在这里:OP想要理解为什么会出现意外的结果。 - R.. GitHub STOP HELPING ICE
4
每当您需要精确结果或保证正确舍入的结果时进行操作。 - R.. GitHub STOP HELPING ICE
3
“1.0”可以总是被准确地表示,许多其他值也是如此。有些计算在数学上会产生“1.0”,但在浮点计算中不一定如此(例如,“1.0 / 3.0 * 3.0”),但这个代码片段:“double d = 1.0; if (d == 1.0) ...” 应该总是可靠地工作。 - Keith Thompson
2
@microtherion 还有很多时候,您想要执行比较,但两个值都不是表达式求值的结果。比如,您将一个变量设置为默认值 360.0。然后稍后您想要检查该值是否与默认值不同。在这种情况下,您应该编写 x != 360.0 - David Heffernan
显示剩余14条评论
4个回答

37

在 Linux x86 系统上使用 cdecl 调用约定时,一个双精度浮点数是通过 st0 x87 寄存器从函数中返回的。所有 x87 寄存器的精度为 80 位。使用以下代码:

static double millis2seconds(int millis) {
    return (double)(millis) / 1000.0;
};
编译器使用80位精度进行除法计算。当gcc使用标准的GNU方言(默认情况下),它会将结果保留在st0寄存器中,因此完整的精度将返回给调用者。汇编代码的结尾看起来像这样:
fdivrp  %st, %st(1)  # Divide st0 by st1 and store the result in st0
leave
ret                  # Return

使用这段代码,

static double millis2seconds(int millis) {
    double result = (double)(millis) / 1000.0;
    return result;
}

结果存储在64位内存位置中,这会丢失一些精度。在返回之前,64位值将被加载回80位的st0寄存器,但损失已经发生:

fdivrp  %st, %st(1)   # Divide st0 by st1 and store the result in st0
fstpl   -8(%ebp)      # Store st0 onto the stack
fldl    -8(%ebp)      # Load st0 back from the stack
leave
ret                   # Return

在你的主函数中,第一个结果存储在一个64位内存位置中,所以无论哪种方式,额外的精度都会丢失:

double seconds = millis2seconds(millis);

但在第二次调用中,返回值被直接使用,因此编译器可以将其保存在寄存器中:

assert(seconds == millis2seconds(millis));

使用第一个版本的millis2seconds时,你将比较已被截断为64位精度的值与具有完整80位精度的值之间的差异。

在x86-64上,计算使用SSE寄存器执行,它们只有64位,因此不会出现这个问题。

另外,如果你使用-std=c99,以避免使用GNU方言,计算出的值将存储在内存中,并在返回之前重新加载到寄存器中,以符合标准。


我觉得这很令人惊讶,因为IEC 9899:201x说:“如果返回表达式在与返回类型不同的浮点格式中计算,则该表达式会被转换为函数的返回类型,就像通过赋值一样[这将删除任何额外的范围和精度],并将结果值返回给调用者。” - poolie
1
这个答案对于C语言来说是不正确的。请使用“-std=c99”。GCC默认情况下可能不符合标准。 - R.. GitHub STOP HELPING ICE
@poolie:是的,这是一个很好的观点。GCC默认情况下不符合标准,因为它使用GNU方言。我已经更新了我的答案以反映这一点。 - Vaughn Cato
实际上,我更深入地研究了一下,并且我认为gcc确实符合当前标准,只是不符合当前的草案(这是公平的)。 - poolie
3
@poolie:那不再只是一个草案了,它是C11。 - R.. GitHub STOP HELPING ICE
显示剩余4条评论

8
在 i386(32 位 x86)上,所有浮点表达式都被评估为 80 位 IEEE 扩展浮点类型。这反映在 float.h 中定义为 2 的 FLT_EVAL_METHOD 中。将结果存储到变量中或对结果应用强制转换会通过舍入删除多余精度,但这仍然不足以保证在没有多余精度的实现(如 x86_64)上看到与之相同的结果,因为两次舍入可能会导致与执行计算和在同一步骤中舍入不同的结果。
解决这个问题的一种方法是即使在 x86 目标上也使用 SSE 数学构建,使用 -msse2 -mfpmath = sse。

刚试图将这些标志添加到编译器中,但结果相同。 - armand nissim bendanan
你确定你已经正确添加了它们吗?使用它们应该会产生与在x86_64上看到的相同的行为。 - R.. GitHub STOP HELPING ICE
嘿,不知道,这是我的命令行: g++ -O0 -g3 -Wall -c -fmessage-length=0 -msse -mfpmath=sse -MMD -MP -MF"TestDouble.d" -MT"TestDouble.d" -o "TestDouble.o" "../TestDouble.cpp" - armand nissim bendanan
-msse 不是 -msse2。请修复并重试。 - R.. GitHub STOP HELPING ICE
@R,是的,你说得对,我漏掉了“2”。使用-msse2 -mfpmath=sse,它可以工作。我会更新我的问题(答案)。 - armand nissim bendanan

4
首先值得注意的是,由于该函数在调用时隐式纯粹且带有一个不变的参数,编译器有权省略计算和比较。
clang-3.0-6ubuntu3使用-O9可以消除纯函数调用,并在编译时执行所有浮点计算,因此程序成功。
C99标准 ISO/IEC 9899说:
浮点运算数的值和浮点表达式的结果可以用比类型要求更高的精度和范围表示;类型不因此而改变。
所以编译器可以返回80位的值,就像其他人所描述的那样。然而,标准继续解释道:
强制转换和赋值运算符仍然需要执行其指定的转换。
这就解释了为什么特定地将值分配为double会强制将其值降低到64位,而从函数返回时将其作为double返回则不会。我觉得这很惊讶。

然而,看起来C11标准将通过添加以下文本使其不再混乱:

如果返回表达式在与返回类型不同的浮点格式中求值,则该表达式将被转换为函数返回类型(通过赋值方式[从而删除任何额外的范围和精度]),并将结果值返回给调用者。

因此,这段代码基本上是在测试未指定的行为,即在各个点上是否会截断值。


在Ubuntu Precise上,对于-m32
  • clang通过测试
  • clang -O9也通过测试
  • gcc失败了
  • gcc -O9通过测试,因为它也消除了常量表达式
  • gcc -std=c99失败了
  • gcc -std=c1x也失败了(但它可能在后续的gcc版本中可以使用)
  • gcc -ffloat-store通过测试,但似乎具有常量消除的副作用

我认为这不是gcc的错误,因为标准允许这种行为,但clang的行为更好。


在转换中没有未定义行为(UB)。 - R.. GitHub STOP HELPING ICE
我刚刚使用一个值为10的int进行了问题编辑。 断言不应该引发异常!! - armand nissim bendanan
@R.. 在浮点数级别上不是未定义的。但是,C标准表示编译器可以使用更高的精度,也可以不使用。 - poolie
谢谢Poolie,我想我明白了! - armand nissim bendanan
1
“-ffloat-store”是一个相当有害的选项。它会使代码变得更慢、更臃肿,并且不能解决符合性问题;它只是近似正确行为。 “-fexcess-precision = standard”是正确的选项来解决问题,但它被“-std=c99”或“-std=c11”隐含了。 - R.. GitHub STOP HELPING ICE
1
@poolie 另一个有趣的选项集是 -msse2 -fpmath=sse。它们使GCC生成SSE2浮点指令。 - Pascal Cuoq

2
除了其他答案中解释的所有细节,我想说几乎在任何编程语言中都有一个非常简单的规则,涉及使用浮点类型: 永远不要检查浮点值是否精确相等。关于80位和64位值的所有知识是正确的,但它仅适用于某种硬件和某种编译器(是的,如果您更改编译器甚至打开或关闭优化,某些内容可能会发生变化)。更普遍的规则(适用于任何旨在具有可移植性的代码)是浮点值通常不像整数或字节序列,并且可以更改,例如在复制时,并且对它们进行相等性检查通常具有不可预测的结果。

因此,即使在测试中运行良好,通常最好不要这样做。当某些内容发生变化时,它可能会在以后失败。

更新:尽管有些人已经投票反对,但我坚持认为该建议通常是正确的。看起来只是复制一个值的东西(从高级编程语言程序员的角度来看,它们看起来是如此;初始示例中发生的事情是一个典型的例子,该值被返回并放入变量中,然后 - 哇 - 它被更改!)可能会更改浮点值。比较浮点值的相等或不相等通常是一种不好的做法,只有在您知道为什么可以在您的某种情况下这样做时才允许。编写可移植程序通常需要最小化低级别知识。是的,当将整数值如0或1放入浮点变量或复制时,它们很少更改。但是更复杂的值(在上面的示例中,我们可以看到一个简单算术表达式的结果发生了什么!)可能会更改。


1
在我看来,这是最明智的答案。我不知道你提到的第二点(复制时更改)-谢谢! - Valentin H
2
负一:关于浮点数的恐惧宣传是不正确的。有很多地方可以比较浮点数的精确相等性。例如,Lua中的所有数字都是浮点数,但没有人会说你永远不应该使用相等运算符。声称复制会改变值通常是错误的,除非你打算以某种奇怪的方式进行解释。 - R.. GitHub STOP HELPING ICE
2
@Ellioh:Lua中的浮点数与C中的浮点数行为完全相同。这里有一个明确的浮点使用示例:for (double x=0; x<100; x++) { if (fmod(x,3)==1) ... }如果x将仅在浮点表达式中使用,则可能希望使用此形式而不是整数变量x,以便将其保留在浮点寄存器中而不是反复转换它。 - R.. GitHub STOP HELPING ICE
1
显然,复制它们并不会改变机器级别上的值。但是这个bug是一个很好的例子,除非你对编译器如何解释标准的特定版本有相当详细的了解,否则它们可能会被概念上的复制操作所改变,例如将函数返回值分配给同类型的变量。 - poolie
1
@poolie,完全同意。关于复制。将返回值并将其分配给变量在高级编程语言程序员的角度下(或可能)是复制。似乎很不可能仅通过将值分配给新变量就可以更改该值。但实际上,没有人知道用于该变量的存储类型。因此,从FPU寄存器复制值到内存可能会产生相当不可预测的结果(尽管通常不适用于整数值)。在可移植代码中,应尽可能减少对这种东西的了解,如果可能的话甚至消除对它的了解。 - Ellioh
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接