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


¿Cómo puede llamar el siguiente programa never_called si nunca ¿llamada en 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. Compilar con Clang con optimizaciones en, la función never_called se 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 se bloquea:

$ 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.
Author: Mário Feroldi, 2018-01-02

2 answers

El programa contiene un comportamiento indefinido, como desreferenciar un puntero nulo (es decir, llamando a foo() en main sin asignarle una dirección válida de antemano) es UB, por lo tanto, no hay requisitos impuestos por la norma.

Ejecutar never_called en tiempo de ejecución es una situación válida perfecta cuando el comportamiento indefinido ha sido golpeado, es tan válido como simplemente estrellarse (como cuando se compila con GCC). Vale, ¿pero por qué Clang hace eso? Si compilar con optimizaciones desactivadas, el programa ya no salida "formatear la unidad de disco duro", y simplemente 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 hacer una llamada a una función a la que foo apunta, y como foo se inicializa con nullptr (o si no tenía ninguna inicialización, este seguiría siendo el caso), su valor es cero. Aquí, indefinido comportamiento ha sido golpeado, por lo que cualquier cosa puede suceder en absoluto y el programa se vuelve inútil. Normalmente, hacer una llamada a tal inválido dirección resultados en errores de falla de segmentación, de ahí el mensaje que recibimos cuando ejecutando el programa.

Ahora examinemos el mismo programa pero compilándolo con optimizaciones en:{[46]]}

$ 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 main llama a std::puts directamente. ¿Pero por qué Clang hizo eso? Y por qué es set_foo compilado en una única instrucción ret?

Volvamos a la estándar (N4660, específicamente) por un momento. Lo ¿dice sobre comportamiento indefinido?

3.27 comportamiento indefinido [defns.undefined]

Comportamiento para el cual este documento no impone requisitos{[46]]}

[Nota: Se puede esperar un comportamiento indefinido cuando este documento omite cualquier definición explícita de comportamiento o cuando un programa utiliza una construir o datos erróneos. Rangos de comportamiento indefinidos permitidos de ignorando completamente la situación con resultados impredecibles, para comportarse durante la traducción o la ejecución del programa de una manera documentada característica del medio ambiente (con o sin la emisión de un diagnóstico), para terminar una traducción o ejecución (con el emisión de un mensaje de diagnóstico). Muchas construcciones erróneas del programa no engendrar un comportamiento indefinido; se requiere que sean diagnosticados. Evaluación de una expresión constante nunca exhibe el comportamiento explícitamente especificado como undefined ([expr.const]). - nota final]

Énfasis mío.

Un programa que exhibe un comportamiento indefinido se vuelve inútil, ya que todo lo ha hecho hasta ahora y lo hará aún más no tiene sentido si contiene datos o construcciones erróneas. Con eso en mente, recuerda que los compiladores pueden ignorar completamente para el caso cuando el comportamiento indefinido se golpea, y esto en realidad se utiliza como hechos descubiertos al optimizar un programa. Para instancia, una construcción como x + 1 > x (donde x es un entero con signo) se compilará a true, incluso si el valor de x es desconocido en tiempo de compilación. Razonamiento es que el compilador quiere optimizar para casos válidos, y el único la manera de que esa construcción sea válida es si no activa la aritmética desbordamiento (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 es demostrado ser siempre cierto.

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

Esto es útil ya que permite toneladas de optimizaciones para patear in . Tan lejos, tan bueno, pero lo que sucede si x mantiene su ¿valor máximo en tiempo de ejecución? Bueno, ese es un comportamiento indefinido, así que no tiene sentido tratar de razonar sobre esto, como cualquier cosa puede suceder y la norma no impone requisitos.

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

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 set_foo antes de la entrada main comienza a ejecutarse. Por ejemplo, cuando se declara una variable de nivel superior, puede llamarlo mientras inicializa el valor de esa variable:

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

Si escribe este fragmento antes de main, el programa no más largo exhibe un comportamiento indefinido, y el mensaje " formatear duro unidad de disco!se muestra " , con optimizaciones activadas o desactivadas.

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

Genial, pero no hay nadie llamando set_foo desde fuera. Aunque este es el hecho, el optimizador ve que la única manera de que este programa be valid is if set_foo is called before main, otherwise it's sólo un comportamiento indefinido. Ese es un nuevo hecho aprendido, y asume set_foo de hecho se llama. Basado en ese nuevo conocimiento, otras optimizaciones que patear puede aprovecharlo.

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

Eliminación de código muerto y optimización interprocedural podría descubrir que si foo == never_called, entonces el código dentro de set_foo es innecesario, así que se transforma en una única instrucción ret.

Optimización de expansión inline ve que foo == never_called, por lo que la llamada a foo puede ser reemplazada 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!"

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

Examinando la salida de GCC con 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

Ejecutar ese programa resulta en un bloqueo (error de segmentación), pero si llama a set_foo en otra unidad de traducción antes de que main obtenga ejecutado, entonces este programa ya no exhibe 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 encargará del código que contiene un comportamiento indefinido, también podría arruinarlo (¡y formatear su disco duro de verdad!)


Te recomiendo que leas Lo que todo programador de C debe saber sobre el Comportamiento Indefinido y Una Guía para Undefined Comportamiento en C y C++, ambas series de artículos son muy informativas y podrían ayudarlo a comprender el estado del arte.

 37
Author: Mário Feroldi,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2018-03-03 11:53:15

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

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 esta última situación, un compilador que no afirma ser "analizable" podría reconocer que el código se invocará si q es mayor que 46.340 cuando la ejecución alcanza la instrucción return, y por lo tanto también podría invocar do_something() incondicionalmente. Si bien el anexo L está mal redactado, parecería que la intención sería prohibir tales "optimizaciones". En el caso de llamar a una función no válida puntero, sin embargo, incluso el código generado directamente en la mayoría de las plataformas podría tener un comportamiento arbitrario.

 0
Author: supercat,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2018-01-03 02:19:33