A conversão dupla para int não assinado no Win32 está truncando para 2.147.483.648

86

Compilando o seguinte código:

double getDouble()
{
    double value = 2147483649.0;
    return value;
}

int main()
{
     printf("INT_MAX: %u\n", INT_MAX);
     printf("UINT_MAX: %u\n", UINT_MAX);

     printf("Double value: %f\n", getDouble());
     printf("Direct cast value: %u\n", (unsigned int) getDouble());
     double d = getDouble();
     printf("Indirect cast value: %u\n", (unsigned int) d);

     return 0;
}

Saídas (MSVC x86):

INT_MAX: 2147483647
UINT_MAX: 4294967295
Double value: 2147483649.000000
Direct cast value: 2147483648
Indirect cast value: 2147483649

Saídas (MSVC x64):

INT_MAX: 2147483647
UINT_MAX: 4294967295
Double value: 2147483649.000000
Direct cast value: 2147483649
Indirect cast value: 2147483649

Na documentação da Microsoft, não há menção ao valor máximo de inteiro assinado em conversões de doublea unsigned int.

Todos os valores acima INT_MAXestão sendo truncados para 2147483648quando for o retorno de uma função.

Estou usando o Visual Studio 2019 para construir o programa. Isso não acontece no gcc .

Estou fazendo algo errado? Existe uma maneira segura de converter doublepara unsigned int?

Matheus Rossi Saciotto
fonte
24
E não, você não está fazendo nada errado (talvez além de tentar usar o compilador "C" da Microsoft)
Antti Haapala
5
Funciona em minha máquina ™, testado em VS2017 v15.9.18 e VS2019 v16.4.1. Use Ajuda> Enviar feedback> Relatar um bug para informá-los sobre sua versão.
Hans Passant
5
Consigo reproduzir, tenho os mesmos resultados que os do OP. VS2019 16.7.3.
anastaciu
2
@EricPostpischil de fato, é o padrão de bits deINT_MIN
Antti Haapala
6
Correção pendente
Antti Haapala

Respostas:

71

Um bug do compilador ...

Da montagem fornecida por @anastaciu, o código de elenco direto chama __ftol2_sse, o que parece converter o número para um longo assinado . O nome da rotina é ftol2_sseporque esta é uma máquina habilitada para sse - mas o float está em um registrador de ponto flutuante x87.

; Line 17
    call    _getDouble
    call    __ftol2_sse
    push    eax
    push    OFFSET ??_C@_0BH@GDLBDFEH@Direct?5cast?5value?3?5?$CFu?6@
    call    _printf
    add esp, 8

O elenco indireto, por outro lado,

; Line 18
    call    _getDouble
    fstp    QWORD PTR _d$[ebp]
; Line 19
    movsd   xmm0, QWORD PTR _d$[ebp]
    call    __dtoui3
    push    eax
    push    OFFSET ??_C@_0BJ@HCKMOBHF@Indirect?5cast?5value?3?5?$CFu?6@
    call    _printf
    add esp, 8

que exibe e armazena o valor duplo para a variável local, em seguida, carrega-o em um registro SSE e chama __dtoui3que é uma rotina de conversão dupla para não assinada ...

O comportamento do elenco direto não está em conformidade com C89; nem está de acordo com qualquer revisão posterior - mesmo C89 diz explicitamente que:

A operação de remaindering feita quando um valor do tipo integral é convertido para o tipo sem sinal não precisa ser feita quando um valor do tipo flutuante é convertido para o tipo sem sinal. Portanto, a faixa de valores portáteis é [0, Utype_MAX + 1) .


Eu acredito que o problema pode ser uma continuação disso desde 2005 - havia uma função de conversão chamada __ftol2que provavelmente teria funcionado para este código, ou seja, teria convertido o valor para um número sinalizado -2147483647, o que teria produzido o correto resultado quando interpretado como um número sem sinal.

Infelizmente, __ftol2_ssenão é um substituto imediato para __ftol2, já que - em vez de apenas tomar os bits de valor menos significativo como estão - sinalizará o erro fora do intervalo retornando LONG_MIN/ 0x80000000, que, interpretado como sem sinal há muito tempo, não é tudo o que era esperado. O comportamento de __ftol2_sseseria válido para signed long, pois a conversão de um valor duplo a> LONG_MAXpara signed longteria comportamento indefinido.

Antti Haapala
fonte
23

Seguindo a resposta de @ AnttiHaapala , testei o código usando otimização /Oxe descobri que isso removerá o bug, pois __ftol2_ssenão é mais usado:

//; 17   :     printf("Direct cast value: %u\n", (unsigned int)getDouble());

    push    -2147483647             //; 80000001H
    push    OFFSET $SG10116
    call    _printf

//; 18   :     double d = getDouble();
//; 19   :     printf("Indirect cast value: %u\n", (unsigned int)d);

    push    -2147483647             //; 80000001H
    push    OFFSET $SG10117
    call    _printf
    add esp, 28                 //; 0000001cH

As otimizações embutiram getdouble()e adicionaram avaliação de expressão constante, removendo assim a necessidade de uma conversão em tempo de execução, eliminando o bug.

Só por curiosidade, fiz mais alguns testes, ou seja, alterando o código para forçar a conversão float para int no tempo de execução. Nesse caso o resultado ainda está correto, o compilador, com otimização, usa __dtoui3nas duas conversões:

//; 19   :     printf("Direct cast value: %u\n", (unsigned int)getDouble(d));

    movsd   xmm0, QWORD PTR _d$[esp+24]
    add esp, 12                 //; 0000000cH
    call    __dtoui3
    push    eax
    push    OFFSET $SG9261
    call    _printf

//; 20   :     double db = getDouble(d);
//; 21   :     printf("Indirect cast value: %u\n", (unsigned int)db);

    movsd   xmm0, QWORD PTR _d$[esp+20]
    add esp, 8
    call    __dtoui3
    push    eax
    push    OFFSET $SG9262
    call    _printf

No entanto, a prevenção do inlining __declspec(noinline) double getDouble(){...}trará o bug de volta:

//; 17   :     printf("Direct cast value: %u\n", (unsigned int)getDouble(d));

    movsd   xmm0, QWORD PTR _d$[esp+76]
    add esp, 4
    movsd   QWORD PTR [esp], xmm0
    call    _getDouble
    call    __ftol2_sse
    push    eax
    push    OFFSET $SG9261
    call    _printf

//; 18   :     double db = getDouble(d);

    movsd   xmm0, QWORD PTR _d$[esp+80]
    add esp, 8
    movsd   QWORD PTR [esp], xmm0
    call    _getDouble

//; 19   :     printf("Indirect cast value: %u\n", (unsigned int)db);

    call    __ftol2_sse
    push    eax
    push    OFFSET $SG9262
    call    _printf

__ftol2_sseé chamado em ambas as conversões fazendo a saída 2147483648em ambas as situações, as suspeitas @zwol estavam corretas.


Detalhes da compilação:

  • Usando a linha de comando:
cl /permissive- /GS /analyze- /W3 /Gm- /Ox /sdl /D "WIN32" program.c        
  • No Visual Studio:

    • Desativando RTCem Project -> Properties -> Code Generatione configuração de tempo de execução básico Cheques de default .

    • Habilitando a otimização Project -> Properties -> Optimizatione configurando a Otimização para / Ox .

    • Com o depurador no x86modo.

Anastaciu
fonte
5
Engraçado como eles dizem "ok com otimizações habilitadas, comportamento indefinido será realmente indefinido" => o código realmente funciona corretamente: F
Antti Haapala
3
@AnttiHaapala, sim, sim, a Microsoft no seu melhor.
anastaciu,
1
As otimizações aplicadas foram inlining e, em seguida, avaliação de expressão constante. Ele não está mais fazendo uma conversão float para int em tempo de execução. Gostaria de saber se o bug voltará se você forçar o getDoublefora da linha e / ou alterá-lo para retornar um valor que o compilador não pode provar ser constante.
zwol
1
@zwol, você estava certo, forçar o out-of-line e evitar avaliações constantes trará o bug de volta, mas desta vez nas duas conversões.
anastaciu
7

Ninguém olhou para o ASM para MS __ftol2_sse.

A partir do resultado, podemos inferir que ele provavelmente foi convertido de x87 para assinado int/ long(ambos os tipos de 32 bits no Windows), em vez de com segurança para uint32_t.

x86 FP -> instruções de inteiro que estouram o resultado de inteiro não apenas quebram / truncam: elas produzem o que a Intel chama de "inteiro indefinido" quando o valor exato não é representável no destino: conjunto de bits alto, outros bits limpos. ie0x80000000 .

(Ou, se a exceção inválida de FP não for mascarada, ela será acionada e nenhum valor será armazenado. Mas no ambiente de FP padrão, todas as exceções de FP são mascaradas. É por isso que para cálculos de FP você pode obter um NaN em vez de uma falha.)

Isso inclui instruções x87 como fistp(usando o modo de arredondamento atual) e instruções SSE2 como cvttsd2si eax, xmm0(usando truncamento em direção a 0, é isso que tsignifica o extra ).

Portanto, é um bug para compilar double-> unsignedconversão em uma chamada para __ftol2_sse.


Nota lateral / tangente:

Em x86-64, FP -> uint32_t pode ser compilado cvttsd2si rax, xmm0, convertendo para um destino assinado de 64 bits, produzindo o uint32_t que você deseja na metade inferior (EAX) do destino inteiro.

É C e C ++ UB se o resultado estiver fora do intervalo 0..2 ^ 32-1, então está tudo bem que grandes valores positivos ou negativos deixarão a metade inferior de RAX (EAX) zero do padrão de bits indefinido inteiro. (Ao contrário das conversões de inteiros-> inteiros, a redução do módulo do valor não é garantida. O comportamento de converter um duplo negativo para um int sem sinal definido no padrão C? Comportamento diferente no ARM vs. x86 . Para ser claro, nada na questão é um comportamento indefinido ou mesmo definido pela implementação. Estou apenas apontando que se você tiver FP-> int64_t, poderá usá-lo para implementar FP-> uint32_t de forma eficiente. Isso inclui x87fistp que pode gravar um destino de inteiro de 64 bits mesmo no modo de 32 e 16 bits, ao contrário das instruções SSE2 que só podem lidar diretamente com inteiros de 64 bits no modo de 64 bits.

Peter Cordes
fonte
1
Eu ficaria tentado a olhar para esse código, mas felizmente não tenho MSVC ...: D
Antti Haapala
@AnttiHaapala: Sim, nem eu
Peter Cordes,