valores una tipos retornan que llamar funciones funcion ejemplos como c++ g++ compiler-optimization undefined-behavior clang++

c++ - una - La función no llamada en el código se llama en tiempo de ejecución



funciones que retornan valores en c++ (2)

A menos que una implementación especifique el efecto de intentar invocar un puntero de función nula, podría comportarse como una llamada a código arbitrario. Dicho código arbitrario podría perfectamente comportarse como una llamada a la función "foo ()". Si bien el Anexo L de la Norma C invitaría a las implementaciones a distinguir entre "UB críticas" y "UB no críticas", y algunas implementaciones de C ++ podrían aplicar una distinción similar, en cualquier caso, invocar un puntero de función no válido sería una UB crítica.

Tenga en cuenta que la situación en esta pregunta es muy diferente de, por ejemplo,

unsigned short q; unsigned hey(void) { if (q < 50000) do_something(); return q*q; }

En la última situación, un compilador que no pretende ser "analizable" puede reconocer que el código invocará si q es mayor que 46,340 cuando la ejecución alcance la declaración de return , y por lo tanto también podría invocar do_something() incondicionalmente. Si bien el Anexo L está mal escrito, parece que la intención sería prohibir tales "optimizaciones". Sin embargo, en el caso de llamar a un puntero de función no válido, incluso el código generado directamente en la mayoría de las plataformas puede tener un comportamiento arbitrario.

¿Cómo puede el siguiente programa llamar never_called si nunca se llama en el código?

#include <cstdio> static void never_called() { std::puts("formatting hard disk drive!"); } static void (*foo)() = nullptr; void set_foo() { foo = never_called; } int main() { foo(); }

Esto difiere de un compilador a otro. Al compilar con Clang con las optimizaciones never_called , la función never_called ejecuta en tiempo de ejecución.

$ clang++ -std=c++17 -O3 a.cpp && ./a.out formatting hard disk drive!

Compilando con GCC, sin embargo, este código simplemente falla:

$ g++ -std=c++17 -O3 a.cpp && ./a.out Segmentation fault (core dumped)

Versión de compiladores:

$ clang --version clang version 5.0.0 (tags/RELEASE_500/final) Target: x86_64-unknown-linux-gnu Thread model: posix InstalledDir: /usr/bin $ gcc --version gcc (GCC) 7.2.1 20171128 Copyright (C) 2017 Free Software Foundation, Inc. This is free software; see the source for copying conditions. There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.


El programa contiene un comportamiento indefinido, ya que eliminar la referencia de un puntero nulo (es decir, llamar a foo() en main sin asignarle una dirección válida de antemano) es UB, por lo tanto, el estándar no impone requisitos.

Ejecutar never_called en tiempo de ejecución es una situación válida perfecta cuando se ha afectado un comportamiento indefinido, es tan válido como simplemente fallar (como cuando se compila con GCC). Está bien, pero ¿por qué Clang está haciendo eso? Si lo compila con las optimizaciones desactivadas, el programa ya no generará "formatear la unidad de disco duro" y se bloqueará:

$ clang++ -std=c++17 -O0 a.cpp && ./a.out Segmentation fault (core dumped)

El código generado para esta versión es el siguiente:

main: # @main push rbp mov rbp, rsp call qword ptr [foo] xor eax, eax pop rbp ret

Intenta realizar una llamada a una función a la cual foo apunta, y como foo se inicializa con nullptr (o si no tuviera ninguna inicialización, este sería el caso), su valor es cero. Aquí, el comportamiento indefinido ha sido afectado, por lo que cualquier cosa puede suceder y el programa se vuelve inútil. Normalmente, hacer una llamada a dicha dirección no válida resulta en errores de falla de segmentación, de ahí el mensaje que recibimos cuando ejecutamos el programa.

Ahora examinemos el mismo programa pero compilándolo con optimizaciones en:

$ clang++ -std=c++17 -O3 a.cpp && ./a.out formatting hard disk drive!

El código generado para esta versión es el siguiente:

set_foo(): # @set_foo() ret main: # @main push rax mov edi, .L.str call puts xor eax, eax pop rcx ret .L.str: .asciz "formatting hard disk drive!"

Curiosamente, de alguna manera las optimizaciones modificaron el programa para que las llamadas main std::puts directamente. Pero ¿por qué Clang hizo eso? ¿Y por qué se compila set_foo en una sola instrucción ret ?

Volvamos a la norma (N4660, específicamente) por un momento. ¿Qué dice sobre el comportamiento indefinido?

3.27 comportamiento indefinido [defns.undefined]

Comportamiento para el que este documento no impone requisitos.

[Nota: se puede esperar un comportamiento indefinido cuando este documento omita cualquier definición explícita de comportamiento o cuando un programa utiliza una construcción errónea o datos erróneos. El comportamiento indefinido permisible va desde ignorar la situación completamente con resultados impredecibles, a comportarse durante la traducción o la ejecución del programa de una manera documentada característica del entorno (con o sin la emisión de un mensaje de diagnóstico), hasta terminar una traducción o ejecución (con la emisión de un mensaje de diagnóstico). Muchas construcciones de programas erróneas no generan un comportamiento indefinido; se requieren para ser diagnosticados. La evaluación de una expresión constante nunca muestra un comportamiento explícitamente especificado como no definido ([expr.const]). - nota final]

Énfasis mío.

Un programa que exhiba un comportamiento indefinido se vuelve inútil, ya que todo lo que ha hecho hasta ahora y seguirá funcionando no tiene sentido si contiene datos o construcciones erróneas. Con esto en mente, recuerde que los compiladores pueden ignorar completamente el caso cuando se golpea un comportamiento indefinido, y esto en realidad se usa como hechos descubiertos al optimizar un programa. Por ejemplo, una construcción como x + 1 > x (donde x es un entero con signo) se compilará como verdadera, incluso si el valor de x es desconocido en tiempo de compilación. El razonamiento es que el compilador quiere optimizar para los casos válidos, y la única manera de que esa construcción sea válida es si no x != std::numeric_limits<decltype(x)>::max() desbordamiento aritmético (es decir, si x != std::numeric_limits<decltype(x)>::max() ). Este es un nuevo hecho aprendido en el optimizador. Basado en eso, la construcción está probada para ser siempre cierta.

Nota : esta misma optimización no puede ocurrir para enteros sin signo, porque el desbordamiento de uno no es UB. Es decir, el compilador necesita mantener la expresión tal como es, porque puede tener una evaluación diferente cuando se desborda (sin signo es el módulo 2 N , donde N es el número de bits). La optimización para los enteros sin signo sería incompleta con el estándar (gracias aschopler).

Esto es útil ya que permite que se activen toneladas de optimizaciones . Hasta ahora, todo bien, pero ¿qué sucede si x mantiene su valor máximo en tiempo de ejecución? Bueno, ese es un comportamiento indefinido, por lo que no tiene sentido tratar de razonarlo, ya que puede ocurrir cualquier cosa y el estándar no impone requisitos.

Ahora tenemos suficiente información para examinar mejor su programa defectuoso. Ya sabemos que acceder a un puntero nulo es un comportamiento indefinido, y eso es lo que está causando el comportamiento divertido en el tiempo de ejecución. Así que intentemos entender por qué Clang (o técnicamente LLVM) optimizó el programa de la forma en que lo hizo.

static void (*foo)() = nullptr; static void never_called() { std::puts("formatting hard disk drive!"); } void set_foo() { foo = never_called; } int main() { foo(); }

Recuerde que es posible llamar a set_foo antes de que la entrada main comience a ejecutarse. Por ejemplo, cuando declara una variable de nivel superior, puede llamarla mientras inicializa el valor de esa variable:

void set_foo(); int x = (set_foo(), 42);

Si escribe este fragmento antes de la página main , el programa ya no muestra un comportamiento indefinido, y el mensaje "¡Formateando la unidad de disco duro!" se muestra, con optimizaciones activadas o desactivadas.

Entonces, ¿cuál es la única forma en que este programa es válido? Existe esta función set_foo que asigna la dirección de never_called a foo , por lo que podríamos encontrar algo aquí. Tenga en cuenta que foo está marcado como static , lo que significa que tiene un enlace interno y no se puede acceder desde fuera de esta unidad de traducción. En contraste, la función set_foo tiene un enlace externo, y se puede acceder desde el exterior. Si otra unidad de traducción contiene un fragmento de código como el de arriba, este programa se vuelve válido.

Genial, pero no hay nadie llamando a set_foo desde afuera. A pesar de que este es el hecho, el optimizador ve que la única forma de que este programa sea válido es si se llama a set_foo antes que main , de lo contrario es un comportamiento indefinido. Eso es un nuevo hecho aprendido, y se supone que set_foo en realidad se llama. Sobre la base de ese nuevo conocimiento, otras optimizaciones que se activen pueden aprovecharlo.

Por ejemplo, cuando se aplica el plegado constante , ve que la construcción foo() solo es válida si foo se puede inicializar correctamente. La única forma de que eso suceda es si se llama a set_foo fuera de esta unidad de traducción, por lo que foo = never_called .

La eliminación de código muerto y la optimización interprocedencial pueden descubrir que si foo == never_called , entonces el código dentro de set_foo es necesario, por lo que se transforma en una sola instrucción ret .

La optimización de expansión en línea ve que foo == never_called , por lo que la llamada a foo se puede reemplazar con su cuerpo. Al final, terminamos con algo como esto:

set_foo(): ret main: mov edi, .L.str call puts xor eax, eax ret .L.str: .asciz "formatting hard disk drive!"

Lo que es algo equivalente a la salida de Clang con optimizaciones activadas. Por supuesto, lo que realmente hizo Clang puede (y podría) ser diferente, pero las optimizaciones son, sin embargo, capaces de llegar a la misma conclusión.

Al examinar la salida de GCC con las optimizaciones activadas, parece que no se molestó en investigar:

.LC0: .string "formatting hard disk drive!" never_called(): mov edi, OFFSET FLAT:.LC0 jmp puts set_foo(): mov QWORD PTR foo[rip], OFFSET FLAT:never_called() ret main: sub rsp, 8 call [QWORD PTR foo[rip]] xor eax, eax add rsp, 8 ret

La ejecución de ese programa produce un fallo (falla de segmentación), pero si llama a set_foo en otra unidad de traducción antes de que se ejecute la función principal, entonces este programa ya no presenta un comportamiento indefinido.

Todo esto puede cambiar locamente a medida que se diseñan más y más optimizaciones, así que no confíe en la suposición de que su compilador se ocupará del código que contiene un comportamiento indefinido, ¡puede que también lo arruine (¡y formatee su disco duro de verdad! )

Le recomiendo que lea Lo que todo programador de C debería saber sobre el comportamiento indefinido y una guía sobre el comportamiento indefinido en C y C ++ , ambas series de artículos son muy informativas y pueden ayudarlo a comprender el estado del arte.