¿Por qué printf ("% f", 0); dar un comportamiento indefinido?

87

La declaración

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

imprime 0.

Sin embargo, la declaración

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

imprime valores aleatorios.

Me doy cuenta de que estoy exhibiendo algún tipo de comportamiento indefinido, pero no puedo entender por qué específicamente.

Un valor de punto flotante en el que todos los bits son 0 sigue siendo válido floatcon un valor de 0
floaty intson del mismo tamaño en mi máquina (si eso es incluso relevante).

¿Por qué el uso de un literal entero en lugar de un literal de punto flotante printfcausa este comportamiento?

PD: se puede ver el mismo comportamiento si uso

int i = 0;
printf("%f\n", i);
Trevor Hickey
fuente
37
printfestá esperando un double, y le está dando un int. floaty intpuede ser del mismo tamaño en su máquina, pero en 0.0frealidad se convierte en a doublecuando se inserta en una lista de argumentos variadic (y printfespera eso). En resumen, no está cumpliendo con su parte del trato en printffunción de los especificadores que utiliza y los argumentos que proporciona.
WhozCraig
22
Las funciones de Varargs no convierten automáticamente los argumentos de la función al tipo del parámetro correspondiente, porque no pueden. La información necesaria no está disponible para el compilador, a diferencia de las funciones no varargs con un prototipo.
EOF
3
Oooh ... "variadics". Acabo de aprender una nueva palabra ...
Mike Robinson
2
Posible duplicado: ¿Puede printf producir un comportamiento indefinido?
Khalil Khalaf
3
Lo siguiente que debe intentar es pasar un en (uint64_t)0lugar de 0y ver si todavía obtiene un comportamiento aleatorio (asumiendo doubley uint64_ttiene el mismo tamaño y alineación). Es probable que la salida siga siendo aleatoria en algunas plataformas (por ejemplo, x86_64) debido a que se pasan diferentes tipos en diferentes registros.
Ian Abbott

Respuestas:

121

El "%f"formato requiere un argumento de tipo double. Le está dando un argumento de tipo int. Por eso el comportamiento no está definido.

La norma no garantiza que todos los bits de cero es una representación válida de 0.0(aunque a menudo es), o de cualquier doublevalor, o que inty doubleson del mismo tamaño (recuerda que es double, no float), o, incluso si son de la misma size, que se pasan como argumentos a una función variable de la misma manera.

Puede suceder que "funcione" en su sistema. Ese es el peor síntoma posible de comportamiento indefinido, porque dificulta el diagnóstico del error.

N1570 7.21.6.1 párrafo 9:

... Si algún argumento no es del tipo correcto para la especificación de conversión correspondiente, el comportamiento no está definido.

floatSe promueven argumentos de tipo double, razón por la cual printf("%f\n",0.0f)funciona. Argumentos de tipos enteros más estrechos que los que intse promueven intao a unsigned int. Estas reglas de promoción (especificadas por N1570 6.5.2.2 párrafo 6) no ayudan en el caso de printf("%f\n", 0).

Tenga en cuenta que si pasa una constante 0a una función no variada que espera un doubleargumento, el comportamiento está bien definido, asumiendo que el prototipo de la función es visible. Por ejemplo, sqrt(0)(after #include <math.h>) convierte implícitamente el argumento 0de inta double, porque el compilador puede ver en la declaración sqrtque espera un doubleargumento. No tiene tal información para printf. Las funciones variables como printfson especiales y requieren más cuidado al escribirles las llamadas.

Keith Thompson
fuente
13
Un par de puntos centrales excelentes aquí. En primer lugar, que es double, no floatpor lo que espera supuesto el ancho de la OP no puede (probablemente no lo hace). En segundo lugar, la suposición de que el cero entero y el cero en coma flotante tienen el mismo patrón de bits tampoco se cumple. Buen trabajo
Lightness Races in Orbit
2
@LucasTrzesniewski: Ok, pero no veo cómo mi respuesta plantea la pregunta. Dije que floatse asciende a doublesin explicar por qué, pero ese no era el punto principal.
Keith Thompson
2
@ robertbristow-johnson: Los compiladores no necesitan tener ganchos especiales printf, aunque gcc, por ejemplo, tiene algunos para que pueda diagnosticar errores ( si la cadena de formato es literal). El compilador puede ver la declaración de printffrom <stdio.h>, que le dice que el primer parámetro es a const char*y el resto se indica con , .... No, %fes para double(y floatse asciende a double) y %lfes para long double. El estándar C no dice nada sobre una pila. Especifica el comportamiento de printfsolo cuando se llama correctamente.
Keith Thompson
2
@ robertbristow-johnson: En el antiguo aturdimiento, "lint" solía realizar algunas de las comprobaciones adicionales que ahora realiza gcc. Un floatpasado a printfse promueve a double; no hay nada mágico en eso, es solo una regla de lenguaje para llamar a funciones variadas. printfél mismo sabe, a través de la cadena de formato, lo que la persona que llama afirmó pasarle; si esa afirmación es incorrecta, el comportamiento no está definido.
Keith Thompson
2
Pequeña corrección: el lmodificador de longitud "no tiene efecto en un siguiente a, A, e, E, f, F, g, o Gespecificador de conversión", el modificador de longitud para una long doubleconversión es L. (@ robertbristow-johnson también podría estar interesado)
Daniel Fischer
58

En primer lugar, como tocado en varias otras respuestas, pero no, en mi opinión, explicado con suficiente claridad: Se hace el trabajo para proporcionar un número entero en la mayoría de los contextos en los que una función de biblioteca toma una doubleo floatargumento. El compilador insertará automáticamente una conversión. Por ejemplo, sqrt(0)está bien definido y se comportará exactamente como sqrt((double)0), y lo mismo ocurre con cualquier otra expresión de tipo entero que se use allí.

printfes diferente. Es diferente porque requiere un número variable de argumentos. Su función prototipo es

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

Por tanto, cuando escribes

printf(message, 0);

el compilador no tiene ninguna información sobre qué tipo printf espera que sea ese segundo argumento. Solo tiene el tipo de expresión de argumento, que es int, para pasar. Por lo tanto, a diferencia de la mayoría de las funciones de la biblioteca, depende de usted, el programador, asegurarse de que la lista de argumentos coincida con las expectativas de la cadena de formato.

(Los compiladores modernos pueden buscar en una cadena de formato y decirle que tiene una falta de coincidencia de tipos, pero no van a comenzar a insertar conversiones para lograr lo que quería decir, porque mejor su código debería romperse ahora, cuando lo notará , que años después cuando se reconstruyó con un compilador menos útil).

Ahora, la otra mitad de la pregunta era: dado que (int) 0 y (float) 0.0 están, en la mayoría de los sistemas modernos, ambos representados como 32 bits, todos los cuales son cero, ¿por qué no funciona de todos modos, por accidente? El estándar C simplemente dice "esto no es necesario para funcionar, estás solo", pero déjame explicarte las dos razones más comunes por las que no funcionaría; eso probablemente le ayudará a comprender por qué no es necesario.

Primero, por razones históricas, cuando se pasa a floattravés de una lista de argumentos variables, se asciende a la doubleque, en la mayoría de los sistemas modernos, tiene 64 bits de ancho. Por lo tanto, printf("%f", 0)pasa solo 32 bits cero a un destinatario que espera 64 de ellos.

La segunda razón, igualmente significativa, es que los argumentos de la función de punto flotante pueden pasarse en un lugar diferente al de los argumentos enteros. Por ejemplo, la mayoría de las CPU tienen archivos de registro separados para enteros y valores de punto flotante, por lo que podría ser una regla que los argumentos del 0 al 4 vayan en los registros r0 a r4 si son enteros, pero de f0 a f4 si son de punto flotante. Entonces printf("%f", 0)busca en el registro f1 ese cero, pero no está allí en absoluto.

zwol
fuente
1
¿Existen arquitecturas que utilicen registros para funciones variadas, incluso entre aquellas que los utilizan para funciones normales? Pensé que esa era la razón por la que se requiere que las funciones variadas se declaren correctamente aunque otras funciones [excepto aquellas con argumentos float / short / char] se pueden declarar con ().
Random832
3
@ Random832 Hoy en día, la única diferencia entre una convención de llamada de una función variadic y una normal es que puede haber algunos datos adicionales suministrados a una variadic, como un recuento del número real de argumentos proporcionados. De lo contrario, todo irá exactamente en el mismo lugar que para una función normal. Consulte, por ejemplo, la sección 3.2 de x86-64.org/documentation/abi.pdf , donde el único tratamiento especial para las variantes es una pista pasada AL. (Sí, esto significa que la implementación de va_arges mucho más complicada de lo que solía ser.)
zwol
@ Random832: Siempre pensé que la razón era que en algunas arquitecturas las funciones con un número y tipo de argumentos conocidos podían implementarse de manera más eficiente, usando instrucciones especiales.
celtschk
@celtschk Puede que esté pensando en las "ventanas de registro" en SPARC e IA64, que se suponía que aceleraban el caso común de llamadas a funciones con una pequeña cantidad de argumentos (por desgracia, en la práctica, hacen todo lo contrario). No requieren que el compilador trate las llamadas a funciones variadas de manera especial, porque el número de argumentos en cualquier sitio de llamada es siempre una constante en tiempo de compilación, independientemente de si el destinatario es variado.
zwol
@zwol: No, estaba pensando en la ret ninstrucción del 8086, donde nera un número entero codificado, que por lo tanto no era aplicable para funciones variadas. Sin embargo, no sé si algún compilador de C realmente lo aprovechó (los compiladores que no son de C ciertamente lo hicieron).
celtschk
13

Por lo general, cuando llama a una función que espera un double, pero proporciona un int, el compilador se convertirá automáticamente en a doublepor usted. Eso no sucede con printf, porque los tipos de argumentos no se especifican en el prototipo de la función; el compilador no sabe que se debe aplicar una conversión.

Mark Ransom
fuente
4
Además, printf() en particular, está diseñado para que sus argumentos puedan ser de cualquier tipo. Debe saber qué tipo espera cada elemento en la cadena de formato y debe proporcionarlo correctamente.
Mike Robinson
@MikeRobinson: Bueno, cualquier tipo C primitivo. Que es un subconjunto muy, muy pequeño de todos los tipos posibles.
MSalters
13

¿Por qué el uso de un literal entero en lugar de un literal flotante causa este comportamiento?

Porque printf()no tiene parámetros escritos además const char* formatstringdel primero. Utiliza una elipsis de estilo c ( ...) para el resto.

Simplemente decide cómo interpretar los valores pasados ​​allí de acuerdo con los tipos de formato dados en la cadena de formato.

Tendrías el mismo tipo de comportamiento indefinido que cuando intentas

 int i = 0;
 const double* pf = (const double*)(&i);
 printf("%f\n",*pf); // dereferencing the pointer is UB
πάντα ῥεῖ
fuente
3
Algunas implementaciones particulares de printfpodrían funcionar de esa manera (excepto que los elementos pasados ​​son valores, no direcciones). El estándar C no especifica cómo funcionan printf y otras funciones variadas, solo especifica su comportamiento. En particular, no se mencionan los marcos de pila.
Keith Thompson
Una pequeña objeción: printftiene un parámetro escrito, la cadena de formato, que es de tipo const char*. Por cierto, la pregunta está etiquetada como C y C ++, y C es realmente más relevante; Probablemente no lo hubiera usado reinterpret_castcomo ejemplo.
Keith Thompson
Solo una observación interesante: el mismo comportamiento indefinido, y muy probablemente debido a un mecanismo idéntico, pero con una pequeña diferencia en los detalles: al pasar un int como en la pregunta, el UB ocurre dentro de printf cuando intenta interpretar el int como doble, en su ejemplo , ya pasa afuera al desreferenciar pf ...
Aconcagua
@Aconcagua Añadida aclaración.
πάντα ῥεῖ
Este ejemplo de código es UB para violación estricta de aliasing, un problema completamente diferente al que se refiere la pregunta. Por ejemplo, ignora por completo la posibilidad de que los flotantes se pasen en diferentes registros a enteros.
MM
12

El uso de un printf()especificador "%f"y un tipo (int) 0que no coinciden conduce a un comportamiento indefinido.

Si una especificación de conversión no es válida, el comportamiento no está definido. C11dr §7.21.6.1 9

Causas candidatas de UB.

  1. Es UB por especificación y la compilación es insoportable '', dijo Nuf.

  2. doubley intson de diferentes tamaños.

  3. doubley intpueden pasar sus valores usando diferentes pilas (general frente a pila FPU ).

  4. Es double 0.0 posible que A no esté definido por un patrón de bits completamente cero. (raro)

chux - Restablecer a Monica
fuente
10

Esta es una de esas grandes oportunidades para aprender de las advertencias de su 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);
  ^

o

$ 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.

Entonces, printfestá produciendo un comportamiento indefinido porque le está pasando un tipo de argumento incompatible.

wyrm
fuente
9

No estoy seguro de qué es confuso.

Su cadena de formato espera un double; en su lugar, proporciona un int.

Si los dos tipos tienen el mismo ancho de bits es completamente irrelevante, excepto que puede ayudarlo a evitar las excepciones de violación de la memoria por un código roto como este.

Carreras de ligereza en órbita
fuente
3
@Voo: Desafortunadamente, ese modificador de cadena de formato tiene un nombre, pero todavía no veo por qué pensaría que intsería aceptable aquí.
Lightness Races in Orbit
1
@Voo: "(que también calificaría como un patrón flotante válido)" ¿Por qué intcalificaría como un patrón flotante válido? El complemento a dos y las diversas codificaciones de punto flotante no tienen casi nada en común.
Lightness Races in Orbit
2
Es confuso porque, para la mayoría de las funciones de biblioteca, proporcionar el literal entero 0a un argumento escrito doublehará lo correcto. No es obvio para un principiante que el compilador no haga la misma conversión para los printfespacios de argumentos abordados por %[efg].
zwol
1
@Voo: Si está interesado en cuán horriblemente mal puede ir esto, considere que en x86-64 SysV ABI, los argumentos de punto flotante se pasan en un conjunto de registros diferente al de los argumentos enteros.
EOF
1
@LightnessRacesinOrbit Creo que siempre es apropiado discutir por qué algo es UB, lo que generalmente implica hablar sobre qué latitud de implementación está permitida y qué sucede realmente en casos comunes.
zwol
4

"%f\n"garantiza un resultado predecible solo cuando el segundo printf()parámetro tiene el tipo de double. A continuación, los argumentos adicionales de las funciones variadas están sujetos a la promoción de argumentos predeterminados. Los argumentos enteros se incluyen en la promoción de enteros, lo que nunca da como resultado valores con tipo de punto flotante. Y los floatparámetros se promueven a double.

Para colmo: estándar permite que el segundo argumento sea o floato doubley nada más.

Sergio
fuente
4

Por qué es formalmente UB ahora se ha discutido en varias respuestas.

La razón por la que obtiene específicamente este comportamiento depende de la plataforma, pero probablemente sea la siguiente:

  • printfespera sus argumentos de acuerdo con la propagación estándar de vararg. Eso significa que un floatserá un doubley cualquier cosa más pequeña que un intserá unint .
  • Estás pasando un intdonde la función espera un double. Su intes probablemente de 32 bits, el double64 bits. Eso significa que los cuatro bytes de la pila que comienzan en el lugar donde se supone que debe estar el argumento están 0, pero los siguientes cuatro bytes tienen contenido arbitrario. Eso es lo que se utiliza para construir el valor que se muestra.
glglgl
fuente
0

La causa principal de este problema de "valor indeterminado" se encuentra en la conversión del puntero en el intvalor pasado a la printfsección de parámetros de variable a un puntero en los doubletipos queva_arg realiza la macro.

Esto provoca una referencia a un área de memoria que no se inicializó completamente con el valor pasado como parámetro a printf, porque el doubletamaño del área de la memoria intermedia de memoria es mayor que el inttamaño.

Por lo tanto, cuando este puntero es desreferenciado, se devuelve un valor indeterminado, o mejor un "valor" que contiene en parte el valor pasado como parámetro printf, y la parte restante podría provenir de otra área de búfer de pila o incluso un área de código ( levantando una excepción de falla de memoria), un desbordamiento real del búfer .


Puede considerar estas porciones específicas de implementaciones de código semplificado de "printf" y "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..
.... 


la implementación real en vprintf (considerando gnu impl.) de la gestión de casos de código de parámetros de doble valor es:

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);



referencias

  1. implementación glibc del proyecto gnu de "printf" (vprintf))
  2. ejemplo de código de amplificación de printf
  3. ejemplo de código de amplificación de va_arg
Ciro Corvino
fuente