Por que printf (“% f”, 0); dar comportamento indefinido?

87

A declaração

printf("%f\n",0.0f);

imprime 0.

No entanto, a declaração

printf("%f\n",0);

imprime valores aleatórios.

Percebo que estou exibindo algum tipo de comportamento indefinido, mas não consigo descobrir o porquê especificamente.

Um valor de ponto flutuante em que todos os bits são 0 ainda é válido floatcom valor de 0.
floate inttêm o mesmo tamanho na minha máquina (se isso for relevante).

Por que usar um literal inteiro em vez de um literal de ponto flutuante printfcausa esse comportamento?

PS o mesmo comportamento pode ser visto se eu usar

int i = 0;
printf("%f\n", i);
Trevor Hickey
fonte
37
printfestá esperando um double, e você está dando um int. floate intpode ter o mesmo tamanho em sua máquina, mas 0.0fna verdade é convertido em um doublequando colocado em uma lista de argumentos variável (e printfespera isso). Resumindo, você não está cumprindo sua parte da barganha com printfbase nos especificadores que está usando e nos argumentos que está fornecendo.
WhozCraig
22
Funções Varargs não convertem argumentos de função automaticamente para o tipo do parâmetro correspondente, porque eles não podem. As informações necessárias não estão disponíveis para o compilador, ao contrário das funções não varargs com um protótipo.
EOF
3
Oooh ... "variadics." Acabei de aprender uma palavra nova ...
Mike Robinson
2
Possível duplicata: printf pode resultar em comportamento indefinido?
Khalil Khalaf
3
A próxima coisa é tentar passar uma (uint64_t)0vez de 0e veja se você ainda obter o comportamento aleatório (assumindo doublee uint64_ttêm o mesmo tamanho e alinhamento). Provavelmente, a saída ainda será aleatória em algumas plataformas (por exemplo, x86_64) devido a tipos diferentes sendo passados ​​em registros diferentes.
Ian Abbott

Respostas:

121

O "%f"formato requer um argumento do tipo double. Você está dando a ele um argumento do tipo int. É por isso que o comportamento é indefinido.

O padrão não garante que todos os bits zero sejam uma representação válida de 0.0(embora muitas vezes seja), ou de qualquer doublevalor, ou que inte doublesejam do mesmo tamanho (lembre-se de que doublenão é float), ou, mesmo que sejam iguais tamanho, que eles são passados ​​como argumentos para uma função variável da mesma maneira.

Pode acontecer de "funcionar" em seu sistema. Esse é o pior sintoma possível de comportamento indefinido, porque torna difícil diagnosticar o erro.

N1570 7.21.6.1 parágrafo 9:

... Se algum argumento não for do tipo correto para a especificação de conversão correspondente, o comportamento é indefinido.

Argumentos do tipo floatsão promovidos a double, e é por isso que printf("%f\n",0.0f)funciona. Argumentos de tipos inteiros mais estreitos do que intos promovidos para intou para unsigned int. Estas regras de promoção (especificadas por N1570 6.5.2.2 parágrafo 6) não ajudam no caso de printf("%f\n", 0).

Observe que se você passar uma constante 0para uma função não variada que espera um doubleargumento, o comportamento é bem definido, assumindo que o protótipo da função esteja visível. Por exemplo, sqrt(0)(after #include <math.h>) converte implicitamente o argumento 0de intpara double- porque o compilador pode ver pela declaração de sqrtque ele espera um doubleargumento. Não tem essa informação para printf. Funções variáveis ​​como printfsão especiais e requerem mais cuidado ao escrever chamadas para elas.

Keith Thompson
fonte
13
Alguns pontos centrais excelentes aqui. Em primeiro lugar, que é doublenão floattão largura suposição do OP não pode (provavelmente não) espera. Em segundo lugar, a suposição de que zero inteiro e zero de ponto flutuante têm o mesmo padrão de bits também não é válida. Bom trabalho
Lightness Races in Orbit
2
@LucasTrzesniewski: Ok, mas não vejo como minha resposta implora a pergunta. Afirmei que floaté promovido a doublesem explicar o porquê, mas esse não era o ponto principal.
Keith Thompson
2
@ robertbristow-johnson: Compiladores não precisam ter ganchos especiais para printf, embora o gcc, por exemplo, tenha alguns para que possa diagnosticar erros ( se a string de formato for literal). O compilador pode ver a declaração de printfde <stdio.h>, que informa que o primeiro parâmetro é a const char*e os demais são indicados por , .... Não, %fé para double(e floaté promovido para double) e %lfé para long double. O padrão C não diz nada sobre uma pilha. Ele especifica o comportamento de printfapenas quando é chamado corretamente.
Keith Thompson
2
@ robertbristow-johnson: No antigo atordoamento, "lint" frequentemente executava algumas das verificações extras que o gcc agora executa. Um floatpassado para printfé promovido para double; não há nada de mágico nisso, é apenas uma regra de linguagem para chamar funções variadas. printfele próprio sabe por meio da string de formato o que o chamador afirmou ter passado para ele; se essa afirmação estiver incorreta, o comportamento é indefinido.
Keith Thompson
2
Correção pequena: o lmodificador de comprimento "não tem efeito sobre a seguinte a, A, e, E, f, F, g, ou Gespecificador de conversão", o modificador de comprimento para uma long doubleconversão é L. (@ robertbristow-johnson também pode estar interessado)
Daniel Fischer
58

Primeiro, como tocou em várias outras respostas, mas não, na minha opinião, enunciado de forma suficientemente clara: Ele faz o trabalho para fornecer um número inteiro na maioria dos contextos em que uma função de biblioteca tem um doubleou floatargumento. O compilador irá inserir automaticamente uma conversão. Por exemplo, sqrt(0)é bem definido e se comportará exatamente como sqrt((double)0), e o mesmo é verdadeiro para qualquer outra expressão do tipo inteiro usada lá.

printfé diferente. É diferente porque leva um número variável de argumentos. Seu protótipo de função é

extern int printf(const char *fmt, ...);

Portanto, quando você escreve

printf(message, 0);

o compilador não tem nenhuma informação sobre que tipo printf espera que o segundo argumento seja. Ele tem apenas o tipo da expressão do argumento, ou seja int, para seguir. Portanto, ao contrário da maioria das funções de biblioteca, cabe a você, o programador, garantir que a lista de argumentos corresponda às expectativas da string de formato.

(Compiladores modernos podem olhar para uma string de formato e dizer que você tem uma incompatibilidade de tipo, mas eles não vão começar a inserir conversões para realizar o que você pretendia, porque melhor seu código deve quebrar agora, quando você perceber , do que anos depois, quando reconstruído com um compilador menos útil.)

Agora, a outra metade da pergunta era: Dado que (int) 0 e (float) 0,0 são, na maioria dos sistemas modernos, ambos representados como 32 bits, todos sendo zero, por que não funciona mesmo assim, por acidente? O padrão C diz apenas "isso não é necessário para funcionar, você está sozinho", mas deixe-me explicar os dois motivos mais comuns pelos quais não funcionaria; isso provavelmente ajudará você a entender por que não é obrigatório.

Primeiro, por razões históricas, quando você passa um floatpor uma lista de argumentos de variável, ele é promovido para double, que, na maioria dos sistemas modernos, tem 64 bits de largura. Portanto, printf("%f", 0)passa apenas 32 bits zero para um receptor que espera 64 deles.

A segunda razão, igualmente significativa, é que os argumentos da função de ponto flutuante podem ser passados ​​em um lugar diferente dos argumentos inteiros. Por exemplo, a maioria das CPUs tem arquivos de registro separados para valores inteiros e de ponto flutuante, então pode ser uma regra que os argumentos de 0 a 4 vão nos registros r0 a r4 se forem inteiros, mas f0 a f4 se forem de ponto flutuante. Portanto, printf("%f", 0)procura no registro f1 por esse zero, mas ele não está lá.

zwol
fonte
1
Existem arquiteturas que usam registradores para funções variáveis, mesmo entre aquelas que os usam para funções normais? Achei que essa era a razão pela qual as funções variadic devem ser declaradas corretamente, embora outras funções [exceto aquelas com argumentos float / short / char] possam ser declaradas com ().
Random832
3
@ Random832 Hoje em dia, a única diferença entre uma variável e a convenção de chamada de uma função normal é que pode haver alguns dados extras fornecidos a uma variável, como uma contagem do número verdadeiro de argumentos fornecidos. Caso contrário, tudo vai exatamente no mesmo lugar que em uma função normal. Veja, por exemplo, a seção 3.2 de x86-64.org/documentation/abi.pdf , onde o único tratamento especial para variáveis ​​é uma dica fornecidaAL . (Sim, isso significa que a implementação do va_argé muito mais complicada do que costumava ser.)
zwol
@ Random832: Sempre pensei que o motivo era que, em algumas arquiteturas, funções com um número e tipo de argumentos conhecidos podiam ser implementadas de maneira mais eficiente, usando instruções especiais.
celtschk
@celtschk Você pode estar pensando nas "janelas de registro" em SPARC e IA64, que deveriam acelerar o caso comum de chamadas de função com um pequeno número de argumentos (infelizmente, na prática, eles fazem exatamente o oposto). Eles não exigem que o compilador trate chamadas de função variadic especialmente, porque o número de argumentos em qualquer site de chamada é sempre uma constante de tempo de compilação, independentemente de o receptor ser variável.
zwol
@zwol: Não, eu estava pensando na ret ninstrução do 8086, onde nera um inteiro embutido em código, que, portanto, não era aplicável para funções variáveis. No entanto, não sei se algum compilador C realmente tirou vantagem disso (compiladores não-C certamente tiraram).
celtschk
13

Normalmente, quando você chama uma função que espera um double, mas você fornece um int, o compilador será automaticamente convertido em a doublepara você. Isso não acontece com o printf, porque os tipos dos argumentos não são especificados no protótipo da função - o compilador não sabe que uma conversão deve ser aplicada.

Mark Ransom
fonte
4
Além disso, printf() em particular, é projetado para que seus argumentos possam ser de qualquer tipo. Você deve saber qual tipo é esperado por cada elemento na string de formato e deve fornecê-lo corretamente.
Mike Robinson
@MikeRobinson: Bem, qualquer tipo C primitivo. Que é um subconjunto muito pequeno de todos os tipos possíveis.
MSalters
13

Por que usar um literal inteiro em vez de um literal flutuante causa esse comportamento?

Porque printf()não tem parâmetros digitados além doconst char* formatstring do primeiro. Ele usa reticências ( ...) no estilo c para todo o resto.

Ele apenas decide como interpretar os valores passados ​​de acordo com os tipos de formatação fornecidos na string de formato.

Você teria o mesmo tipo de comportamento indefinido de quando tenta

 int i = 0;
 const double* pf = (const double*)(&i);
 printf("%f\n",*pf); // dereferencing the pointer is UB
πάντα ῥεῖ
fonte
3
Algumas implementações específicas de printfpodem funcionar dessa maneira (exceto que os itens passados ​​são valores, não endereços). O padrão C não especifica como printf e outras funções variáveis ​​funcionam, apenas especifica seu comportamento. Em particular, não há menção de frames de pilha.
Keith Thompson
Um pequeno problema: printftem um parâmetro digitado, a string de formato, que é do tipo const char*. BTW, a questão está marcada com C e C ++, e C é realmente mais relevante; Eu provavelmente não teria usado reinterpret_castcomo exemplo.
Keith Thompson
Apenas uma observação interessante: mesmo comportamento indefinido e provavelmente devido a mecanismo idêntico, mas com uma pequena diferença em detalhes: passando um int como na pergunta, o UB acontece dentro de printf ao tentar interpretar o int como duplo - em seu exemplo , já acontece lá fora ao desreferenciar pf ...
Aconcágua
@Aconcagua Esclarecimento adicionado.
πάντα ῥεῖ
Este exemplo de código é UB para violação de aliasing estrito, um problema totalmente diferente do que a pergunta está perguntando. Por exemplo, você ignora completamente a possibilidade de que floats sejam passados ​​em registros diferentes para inteiros.
MM de
12

Usar um printf()especificador "%f"e tipo (int) 0incompatíveis leva a um comportamento indefinido.

Se uma especificação de conversão for inválida, o comportamento é indefinido. C11dr §7.21.6.1 9

Causas candidatas de UB.

  1. É UB por especificação e a compilação é complicada - disse nuf.

  2. doublee intsão de tamanhos diferentes.

  3. doublee intpode passar seus valores usando diferentes pilhas (geral vs. pilha FPU ).

  4. A double 0.0 pode não ser definido por um padrão de bits totalmente zero. (raro)

chux - Reintegrar Monica
fonte
10

Esta é uma daquelas grandes oportunidades de aprender com os avisos do compilador.

$ gcc -Wall -Wextra -pedantic fnord.c 
fnord.c: In function main’:
fnord.c:8:2: warning: format ‘%f expects argument of type double’, but argument 2 has type int [-Wformat=]
  printf("%f\n",0);
  ^

ou

$ clang -Weverything -pedantic fnord.c 
fnord.c:8:16: warning: format specifies type 'double' but the argument has type 'int' [-Wformat]
        printf("%f\n",0);
                ~~    ^
                %d
1 warning generated.

Então, printfestá produzindo um comportamento indefinido porque você está passando um tipo de argumento incompatível.

wyrm
fonte
9

Não tenho certeza do que é confuso.

Sua string de formato espera um double; em vez disso, você fornece um int.

Se os dois tipos têm a mesma largura de bits é totalmente irrelevante, exceto que pode ajudá-lo a evitar a obtenção de exceções de violação de memória física de código quebrado como este.

Lightness Races in Orbit
fonte
3
@Voo: Infelizmente, esse modificador de string de formato tem o nome, mas ainda não vejo por que você acha que um intseria aceitável aqui.
Lightness Races in Orbit
1
@Voo: "(o que também se qualificaria como um padrão flutuante válido)" Por que um seria intqualificado como um padrão flutuante válido? O complemento de dois e várias codificações de ponto flutuante não têm quase nada em comum.
Lightness Races in Orbit
2
É confuso porque, para a maioria das funções de biblioteca, fornecer o literal inteiro 0para um argumento digitado doublefará a coisa certa. Não é óbvio para um iniciante que o compilador não faz a mesma conversão para printfslots de argumento endereçados por %[efg].
zwol
1
@Voo: Se você estiver interessado em saber como isso pode dar errado, considere que no x86-64 SysV ABI, os argumentos de ponto flutuante são passados ​​em um conjunto de registradores diferente dos argumentos inteiros.
EOF
1
@LightnessRacesinOrbit Acho que é sempre apropriado discutir por que algo é UB, o que geralmente envolve falar sobre qual latitude de implementação é permitida e o que realmente acontece em casos comuns.
zwol
4

"%f\n"garante resultado previsível apenas quando o segundo printf()parâmetro tem tipo de double. Em seguida, argumentos extras de funções variáveis ​​estão sujeitos à promoção de argumento padrão. Argumentos inteiros se enquadram na promoção de inteiros, que nunca resulta em valores digitados de ponto flutuante. E os floatparâmetros são promovidos paradouble .

Para completar: standard permite que o segundo argumento seja ou floatou doublee nada mais.

Sergio
fonte
4

Por que é formalmente UB já foi discutido em várias respostas.

O motivo pelo qual você obtém especificamente esse comportamento depende da plataforma, mas provavelmente é o seguinte:

  • printfespera seus argumentos de acordo com a propagação padrão de vararg. Isso significa que um floatserá um doublee qualquer coisa menor que um intserá umint .
  • Você está passando um intonde a função espera um double. O seu inté provavelmente de 32 bits, o seu double64 bit. Isso significa que os quatro bytes da pilha começando no lugar onde o argumento deveria estar 0, mas os quatro bytes seguintes têm conteúdo arbitrário. Isso é o que é usado para construir o valor que é exibido.
glglgl
fonte
0

A principal causa deste problema de "valor indeterminado" está no elenco do ponteiro no intvalor passado para a printfseção de parâmetros da variável para um ponteiro nos doubletipos queva_arg macro executa.

Isso faz com que uma referência a uma área de memória que não foi completamente inicializada com o valor passado como parâmetro para o printf, porque o doubletamanho da área do buffer de memória é maior queint tamanho.

Portanto, quando este ponteiro é desreferenciado, ele retorna um valor indeterminado, ou melhor, um "valor" que contém em parte o valor passado como parâmetro para printf, e o restante pode vir de outra área de buffer de pilha ou mesmo de uma área de código ( levantando uma exceção de falha de memória), um estouro de buffer real .


Ele pode considerar essas partes específicas de implementações de código semplificado de "printf" e "va_arg" ...

printf

va_list arg;
....
case('%f')
      va_arg ( arg, double ); //va_arg is a macro, and so you can pass it the "type" that will be used for casting the int pointer argument of printf..
.... 


a implementação real em vprintf (considerando gnu impl.) de gerenciamento de caso de código de parâmetros de valor duplo é:

if (__ldbl_is_dbl)
{
   args_value[cnt].pa_double = va_arg (ap_save, double);
   ...
}



va_arg

char *p = (double *) &arg + sizeof arg;  //printf parameters area pointer

double i2 = *((double *)p); //casting to double because va_arg(arg, double)
   p += sizeof (double);



referências

  1. implementação glibc do projeto gnu de "printf" (vprintf))
  2. exemplo de código de semplificação de printf
  3. exemplo de código de semplificação de va_arg
Ciro Corvino
fonte