从整数到浮点数再返回时,符号会发生变化。

42

请考虑以下代码,它是我的实际问题的SSCCE:

#include <iostream>

int roundtrip(int x)
{
    return int(float(x));
}

int main()
{
    int a = 2147483583;
    int b = 2147483584;
    std::cout << a << " -> " << roundtrip(a) << '\n';
    std::cout << b << " -> " << roundtrip(b) << '\n';
}

我的电脑(Xubuntu 12.04.3 LTS)的输出结果是:

2147483583 -> 2147483520
2147483584 -> -2147483648

注意在往返过程中正数b最终变成了负数。这种行为是否有明确定义?我原本期望从整数到浮点数再回来后至少能正确地保留符号...

嗯,在ideone上输出不同:

2147483583 -> 2147483520
2147483584 -> 2147483647

g++团队在此期间修复了一个错误,还是两个输出都是完全有效的?


我可以确认你所描述的行为(不是ideone)在x86_64上使用“g++(GCC)4.8.2 20131017(Red Hat 4.8.2-1)”时发生。 - Jonas Schäfer
@Mat:可以确认一下,无论是哪个“-O{s,1,2,3}”,都没有关系。 - Jonas Schäfer
1
数字是否超出了尾数的范围? - Fiddling Bits
5
在我看来,这似乎是整数通常无法由浮点数表示和整数溢出/环绕的混合体。尝试在roundtrip()函数中输出临时变量。 - blackbird
2个回答

69

由于从浮点数到整数的转换溢出,你的程序正在调用未定义的行为。在x86处理器上,您所看到的只是通常的症状。

最接近2147483584float值恰好是2的31次方(将整数转换为浮点数通常会四舍五入至最近的值,可能是向上的,而在这种情况下是向上的。具体而言,从整数到浮点数的转换行为是实现定义的,大多数实现定义舍入方式为“根据FPU舍入模式”,而FPU的默认舍入模式是四舍五入至最近)。

然后,在从表示2的31次方的浮点数到int的转换时,发生了溢出。这个溢出是未定义的行为。一些处理器会引发异常,而其他处理器则会饱和。编译器通常生成的IA-32指令cvttsd2si在溢出的情况下通常会始终返回INT_MIN,无论浮点数是正数还是负数。

即使您知道您的目标是英特尔处理器,也不应依赖此行为:在定位x86-64时,编译器可以发出从浮点数到整数的指令序列,利用未定义的行为返回与目标整数类型不同的结果


有趣。那么我们可以得出结论,ideone不在x86上运行吗? :) - fredoverflow
1
谢谢你提供的信息:“x86处理器总是返回INT_MIN”,这对于调试程序很有帮助。 - user2249683
1
将-fsanitize=float-cast-overflow传递给clang将在运行时捕获任何此类情况。http://clang.llvm.org/docs/UsersManual.html#controlling-code-generation - strcat
@strcat 我不会假设-fsanitize=float-cast-overflow能够捕获所有这样的溢出,除非我已经彻底测试过了。在没有从一开始就为此设计的编译器中添加可靠的运行时检查是很困难的,因为前端和现有的优化可能会干扰。但它肯定可以捕获这个普通的溢出。 - Pascal Cuoq
“-fsanitize=float-cast-overflow” 检查是由前端实现的。如果优化过程无法证明这些检查是不必要的,那么它们就不能删除这些检查。 - strcat
显示剩余3条评论

11

Pascal的答案没问题,但缺乏细节,这导致一些用户理解困难;-)。如果你对低级别的情况感兴趣(假设协处理器而不是软件处理浮点运算),请继续阅读。

在32位浮点数(IEEE 754)中,您可以存储范围在 [-224 ... 224] 内的所有整数。 超出此范围的整数也可能具有精确表示为浮点数,但并非所有整数都具有。问题在于,在浮点数中,您只能使用24个有效位。

以下是从int->float转换通常在低级别上看起来如何:

fild dword ptr[your int]
fstp dword ptr[your float]

这只是2个协处理器指令的序列。第一个将32位整数加载到协处理器的堆栈上,并将其转换为80位宽的浮点数。

Intel® 64 和 IA-32 架构软件开发人员手册:

(使用 X87 FPU 进行编程):

当从内存中载入浮点、整数或打包BCD整数值到任何 x87 FPU 数据寄存器时,这些值会自动转换为双扩展精度浮点格式(如果它们还不是该格式)。

由于FPU寄存器是80位宽的浮点数 - 在这里使用 fild 没有问题,因为32位整数完全适合浮点格式的64位尾数。

到了第二部分 - fstp 就有点棘手并且可能令人惊讶。它应该将80位浮点数存储在32位浮点数中。虽然该问题涉及整数值,但协处理器实际上可能执行 '舍入'。怎么可能会对整数值进行舍入,即使它存储在浮点格式中呢?;-)。

我简单地解释一下 - 让我们先看看 x87 提供了哪些舍入模式(它们是IEEE 754 舍入模式的体现)。x87 FPU 有4个舍入模式,由fpu控制字的第10位和第11位控制:

  • 00 - 最近偶数 - 舍入结果最接近无限精确结果。如果两个值相等,则结果为偶数值(即,最低有效位为零的值)。默认
  • 01 - 向 -Inf 舍入
  • 10 - 向 +inf 舍入
  • 11 - 向0(即截断)

您可以使用以下简单代码玩转舍入模式(尽管可以以不同的方式完成 - 这里展示了底层操作):

enum ROUNDING_MODE
{
    RM_TO_NEAREST  = 0x00,
    RM_TOWARD_MINF = 0x01,
    RM_TOWARD_PINF = 0x02,
    RM_TOWARD_ZERO = 0x03 // TRUNCATE
};

void set_round_mode(enum ROUNDING_MODE rm)
{
    short csw;
    short tmp = rm;

    _asm
    {
        push ax
        fstcw [csw]
        mov ax, [csw]
        and ax, ~(3<<10)
        shl [tmp], 10
        or ax, tmp
        mov [csw], ax
        fldcw [csw]
        pop ax
    }
}

好的,不错,但这和整数值有什么关系呢?耐心一点……要理解为什么在将整数转换为浮点数时需要涉及舍入模式,请先看最明显的方法——截断(非默认)的整数到浮点数的转换方式,可能是这样的:

  • 记录符号
  • 如果小于零,则取反整数
  • 找到最左边的1的位置
  • 将整数向右/左移位,使得上面找到的1位于第23位
  • 记录在过程中移位的次数,以便计算指数

模拟此行为的代码可以如下所示:

float int2float(int value)
{
    // handles all values from [-2^24...2^24]
    // outside this range only some integers may be represented exactly
    // this method will use truncation 'rounding mode' during conversion

    // we can safely reinterpret it as 0.0
    if (value == 0) return 0.0;

    if (value == (1U<<31)) // ie -2^31
    {
        // -(-2^31) = -2^31 so we'll not be able to handle it below - use const
        value = 0xCF000000;
        return *((float*)&value);
    }

    int sign = 0;

    // handle negative values
    if (value < 0)
    {
        sign = 1U << 31;
        value = -value;
    }

    // although right shift of signed is undefined - all compilers (that I know) do
    // arithmetic shift (copies sign into MSB) is what I prefer here
    // hence using unsigned abs_value_copy for shift
    unsigned int abs_value_copy = value;

    // find leading one
    int bit_num = 31;
    int shift_count = 0;

    for(; bit_num > 0; bit_num--)
    {
        if (abs_value_copy & (1U<<bit_num))
        {
            if (bit_num >= 23)
            {
                // need to shift right
                shift_count = bit_num - 23;
                abs_value_copy >>= shift_count;
            }
            else
            {
                // need to shift left
                shift_count = 23 - bit_num;
                abs_value_copy <<= shift_count;
            }
            break;
        }
    }

    // exponent is biased by 127
    int exp = bit_num + 127;

    // clear leading 1 (bit #23) (it will implicitly be there but not stored)
    int coeff = abs_value_copy & ~(1<<23);

    // move exp to the right place
    exp <<= 23;

    int ret = sign | exp | coeff;

    return *((float*)&ret);
}

现在的例子 - 截断模式将 2147483583 转换为 2147483520

2147483583 = 01111111_11111111_11111111_10111111

在int->float转换期间,您必须将最左边的1向左移动到第23位。现在领先的1在第30位。为了将其放置在第23位,您必须执行向右移位7个位置。在此过程中,您会失去(它们不适合32位浮点格式)从右边截取的7个lsb位。它们是:

01111111 = 63

63是原数损失的数量:

2147483583 -> 2147483520 + 63

截断很容易,但并不一定是您想要的,也不一定适用于所有情况。考虑以下示例:

67108871 = 00000100_00000000_00000000_00000111

上述值不能精确地由浮点数表示,但是要检查截断对其的影响。与以前一样 - 我们需要将最左边的1位移动到第23位。这需要将值向右移动3个位置,丢失3个LSB位(现在我将以不同的方式编写数字,显示浮点数的隐式第24位,并用括号括起来显示显式的23位有效数字):

00000001.[0000000_00000000_00000000] 111 * 2^26 (3 bits shifted out)

截断会截掉3个末尾的位,留下67108864(67108864+7(3被截掉的位)) = 67108871(记住我们通过指数操作来补偿位移——这里省略了)。

这样就足够好了吗?嘿,67108872是32位浮点数可以完美表示的值,应该比67108864更好,对吧?是正确的。这也是你可能想要讨论将整数转换为32位浮点数时舍入的位置。

现在让我们看看默认的“最近偶数舍入”模式是如何工作的,以及它对OP案例的影响。再考虑同一个例子。

67108871 = 00000100_00000000_00000000_00000111

我们知道,我们需要进行3次右移来将最左边的1位放到第23位:

00000000_1.[0000000_00000000_00000000] 111 * 2^26 (3 bits shifted out)

'四舍六入五成双'的过程涉及查找两个数字,它们从下面和上面尽可能接近地包围输入值67108871。请记住,我们仍然在80位FPU中运行,因此尽管我展示了一些位被移出,但它们仍然在FPU寄存器中,但在存储输出值时将在舍入操作中删除。

00000000_1.[0000000_00000000_00000000] 111 * 2^26 (3 bits shifted out)

00000000_1.[0000000_00000000_00000000] 111 * 2^26非常接近的2个值是:

从顶部开始:

  00000000_1.[0000000_00000000_00000000] 111 * 2^26
                                     +1
= 00000000_1.[0000000_00000000_00000001] * 2^26 = 67108872

并从下面开始:

  00000000_1.[0000000_00000000_00000000] * 2^26 = 67108864

显然,6710887267108864更接近于67108871,因此将32位整数值67108871转换为67108872(在最近的偶数模式下四舍五入)。

现在是OP的数字(仍然采用最近的偶数四舍五入):

 2147483583 = 01111111_11111111_11111111_10111111
= 00000000_1.[1111111_11111111_11111111] 0111111 * 2^30

方括号值:

顶部:

  00000000_1.[1111111_111111111_11111111] 0111111 * 2^30
                                      +1
= 00000000_10.[0000000_00000000_00000000] * 2^30
=  00000000_1.[0000000_00000000_00000000] * 2^31 = 2147483648

底部:

00000000_1.[1111111_111111111_11111111] * 2^30 = 2147483520

请记住,“四舍五入到最近偶数”中的even一词仅在输入值恰好处于括号值之间时才有意义。只有在这种情况下,even一词才会起作用并“决定”选择哪个括号值。在上面的情况下,even并不重要,我们必须简单地选择更接近的值,即2147483520

上一个 OP 的案例展示了even一词很重要的问题:

 2147483584 = 01111111_11111111_11111111_11000000
= 00000000_1.[1111111_11111111_11111111] 1000000 * 2^30

方括号值与先前相同:

顶部:00000000_1.[0000000_00000000_00000000] * 2^31 = 2147483648

底部:00000000_1.[1111111_111111111_11111111] * 2^30 = 2147483520

现在没有更接近的值了(2147483648-2147483584=64=2147483584-2147483520),所以我们必须依赖偶数并选择顶部(偶数)值2147483648

这里OP的问题是Pascal曾经描述过的。FPU仅适用于有符号值,而2147483648无法作为有符号整数存储,因为它的最大值为2147483647,因此存在问题。

不使用文档引用的简单证明,即FPU仅适用于有符号值,即将每个值视为有符号值,是通过调试此代码实现的:

unsigned int test = (1u << 31);

_asm
{
    fild [test]
}
尽管测试值看起来应该被视为无符号值,但由于没有将带符号和无符号值加载到FPU的单独指令,它将被加载为-2 31 。 同样,您将找不到允许您从FPU存储无符号值到内存的指令。 不管您在程序中如何声明,一切都只是被视为有符号的位模式。长话短说,这个问题就是缺少相应的指令。希望这篇文章能对大家有所启示。

不必假设OP的编译器是针对387进行优化的。现代的编译器针对现代英特尔指令集将会生成"cvttsd2si",该指令会在发生溢出时返回使用它的寄存器大小(32位或64位)的最小值。 - Pascal Cuoq
1
@PascalCuoq:是的,你说得对。我的帖子已经够长了,不需要再添加更多内容。 - Artur
很好的详细解释。不幸的是,我认为编译器不再使用x87指令了。 - Mark Ransom

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