c++ gcc function-pointers

c++ - ¿Por qué esta llamada de función se comporta sensiblemente después de llamarla a través de un puntero de función tipificado?



gcc function-pointers (4)

Como han señalado otros, es un comportamiento totalmente indefinido, y lo que obtenga dependerá del compilador. Solo funcionará si tiene una convención de llamada específica, que no usa la pila pero se registra para pasar los parámetros.

Usé Godbolt para ver el ensamblado generado, que puede consultar en su totalidad here

La llamada a la función relevante está aquí:

mov edi, 10 mov esi, 20 mov edx, 30 call f(int, int) #clang totally knows you''re calling f by the way

No inserta parámetros en la pila, simplemente los coloca en registros. Lo más interesante es que la instrucción mov no cambia solo los 8 bits más bajos del registro, sino que todos ellos son un movimiento de 32 bits. Esto también significa que no importa lo que estaba en el registro anterior, siempre obtendrá el valor correcto cuando lea 32 bits como f lo hace.

Si se pregunta por qué el movimiento de 32 bits, resulta que en casi todos los casos, en una arquitectura x86 o AMD64, los compiladores siempre usarán movimientos literales de 32 bits o movimientos literales de 64 bits (si y solo si el valor es demasiado grande para 32 bits). Mover un valor de 8 bits no pone a cero los bits superiores (8-31) del registro, y puede crear problemas si el valor terminaría promoviéndose. Usar una instrucción literal de 32 bits es más simple que tener una instrucción adicional para poner a cero el registro primero.

Sin embargo, una cosa que debes recordar es que realmente está intentando llamar f como si tuviera parámetros de 8 bits, por lo que si pones un valor alto, truncará el literal. Por ejemplo, 1000 se convertirá en -24 , ya que los bits más bajos de 1000 son E8 , que es -24 cuando se usan enteros con signo. También recibirá una advertencia

<source>:13:7: warning: implicit conversion from ''int'' to ''signed char'' changes value from 1000 to -24 [-Wconstant-conversion]

tengo el siguiente código. Hay una función que lleva dos int32. Luego tomo un puntero hacia él y lo convierto en una función que toma tres int8 y lo llamo. Esperaba un error de ejecución, pero el programa funciona bien. ¿Por qué esto es posible?

main.cpp:

#include <iostream> using namespace std; void f(int32_t a, int32_t b) { cout << a << " " << b << endl; } int main() { cout << typeid(&f).name() << endl; auto g = reinterpret_cast<void(*)(int8_t, int8_t, int8_t)>(&f); cout << typeid(g).name() << endl; g(10, 20, 30); return 0; }

Salida:

PFviiE PFvaaaE 10 20

Como puedo ver, la firma de la primera función requiere dos ints y la segunda función requiere tres caracteres. Char es más pequeño que int y me pregunté por qué a y b siguen siendo iguales a 10 y 20.


Como otros lo han señalado, es probable que sea un comportamiento indefinido , pero los programadores de C de la vieja escuela saben que este tipo de cosas funcionan.

Además, debido a que puedo sentir a los abogados lingüísticos redactando sus documentos de litigio y peticiones judiciales para lo que voy a decir, voy a lanzar un hechizo de undefined behavior discussion . Se emite diciendo undefined behavior tres veces mientras se tocan mis zapatos. Y eso hace que los abogados lingüísticos desaparezcan, por lo que puedo explicar por qué las cosas raras simplemente funcionan sin ser demandadas.

De vuelta a mi respuesta:

Todo lo que discuto a continuación es el comportamiento específico del compilador. Todas mis simulaciones son con Visual Studio compilado como código x86 de 32 bits. Sospecho que funcionará igual con gcc y g ++ en una arquitectura de 32 bits similar.

Aquí es por qué su código simplemente funciona y algunas advertencias.

  1. Cuando los argumentos de llamada de función se insertan en la pila, se empujan en orden inverso. Cuando f se invoca normalmente, el compilador genera código para insertar el argumento b en la pila antes del argumento a. Esto ayuda a facilitar funciones de argumento variad como printf. Entonces, cuando tu función, f está accediendo a b , solo está accediendo a los argumentos en la parte superior de la pila. Cuando se invoca a través de g , hubo un argumento adicional empujado a la pila (30), pero se empujó primero. 20 fue empujado a continuación, seguido de 10 que está en la parte superior de la pila. f solo está mirando los dos argumentos superiores en la pila.

  2. IIRC, al menos en ANSI C clásico, caracteres y shorts, siempre se promociona a int antes de ser colocado en la pila. Por eso, cuando lo invocó con g , los literales 10 y 20 se colocan en la pila como ints de tamaño completo en lugar de ints de 8 bits. Sin embargo, en el momento en que redefine f para tomar largos de 64 bits en lugar de ints de 32 bits, la salida de su programa cambia.

void f(int64_t a, int64_t b) { cout << a << " " << b << endl; }

Resultados en esto obteniendo salida por tu main (con mi compilador)

85899345930 48435561672736798

Y si te conviertes a hexadecimal:

140000000a effaf00000001e

14 es 20 y 0A es 10 . Y sospecho que 1e es tu 30 siendo empujado a la pila. Así que los argumentos fueron empujados a la pila cuando fueron invocados a través de g , pero fueron agrupados de alguna manera específica del compilador. ( comportamiento indefinido de nuevo, pero se pueden ver los argumentos que fueron empujados).

  1. Cuando invocas una función, el comportamiento habitual es que el código de llamada reparará el puntero de la pila al regresar de una función llamada. Nuevamente, esto es por el bien de las funciones variadas y otras razones heredadas de compatibilidad con K&R C. printf no tiene idea de cuántos argumentos le pasaste realmente, y confía en la persona que llama para arreglar la pila cuando regresa. Así que cuando invocó a través de g , el compilador generó un código para empujar 3 enteros a la pila, invocar la función y luego codificar para quitar esos mismos valores. En el momento, cambia la opción del compilador para que la persona que llama limpie la pila (ala __stdcall en Visual Studio):

void __stdcall f(int32_t a, int32_t b) { cout << a << " " << b << endl; }

Ahora estás claramente en territorio de comportamiento indefinido. La invocación a través de g colocó tres argumentos int en la pila, pero el compilador solo generó código para que f saque dos argumentos int de la pila cuando regresa. El puntero de la pila está dañado al regresar.


Como otros lo han señalado, este es un comportamiento indefinido, por lo que todas las apuestas están fuera de lo que en principio puede suceder. Pero suponiendo que estás en una máquina x86, hay una explicación plausible de por qué estás viendo esto.

En x86, el compilador g ++ no siempre pasa argumentos empujándolos en la pila. En cambio, guarda los primeros argumentos en registros. Si desmontamos la función f , observe que las primeras instrucciones mueven los argumentos fuera de los registros y explícitamente en la pila:

push rbp mov rbp, rsp sub rsp, 16 mov DWORD PTR [rbp-4], edi # <--- Here mov DWORD PTR [rbp-8], esi # <--- Here # (many lines skipped)

Del mismo modo, observe cómo se genera la llamada en main . Los argumentos se colocan en esos registros:

mov rax, QWORD PTR [rbp-8] mov edx, 30 # <--- Here mov esi, 20 # <--- Here mov edi, 10 # <--- Here call rax

Como todo el registro se usa para mantener los argumentos, el tamaño de los argumentos no es relevante aquí.

Además, debido a que estos argumentos se pasan a través de registros, no hay ninguna preocupación sobre el cambio de tamaño de la pila de manera incorrecta. Algunas convenciones de llamadas ( cdecl ) dejan a la persona que llama para realizar la limpieza, mientras que otras ( stdcall ) le piden a la persona que llama que realice la limpieza. Sin embargo, ninguno de los dos importa aquí, porque la pila no se toca.


El primer compilador de C, así como la mayoría de los compiladores que precedieron a la publicación del Estándar C, procesaría una llamada de función empujando los argumentos en el orden de derecha a izquierda, usaría la instrucción de "llamada subrutina de la plataforma" de la plataforma para invocar la función, y luego luego, después de que se devolvió la función, resalte los argumentos que fueron empujados. Las funciones asignarían direcciones a sus argumentos en orden secuencial, comenzando justo después de cualquier información que haya sido empujada por la instrucción "llamada".

Incluso en plataformas como Classic Macintosh, donde la responsabilidad de hacer estallar los argumentos normalmente recaería en la función llamada (y donde la falta de empujar el número correcto de argumentos a menudo dañaría la pila), los compiladores de C usualmente usaron una convención de llamada que se comportó como la primera Compilador de c Se necesitaba un calificador "pascal" al llamar, o en funciones que fueron llamadas por, código escrito en otros idiomas (como Pascal).

En la mayoría de las implementaciones del lenguaje que existían antes del Estándar, se podría escribir una función:

int foo(x,y) int x,y { printf("Hey/n"); if (x) { y+=x; printf("y=%d/n", y); } }

e invocarlo como, por ejemplo, foo(0) o foo(0,0) , siendo el primero un poco más rápido. Intentando llamarlo como por ejemplo foo(1); probablemente dañaría la pila, pero si la función nunca usaba el objeto y no había necesidad de pasarlo. Sin embargo, el soporte de dicha semántica no hubiera sido práctico en todas las plataformas, y en la mayoría de los casos los beneficios de la validación de argumentos superan el costo, por lo que el Estándar no requiere que las implementaciones sean capaces de soportar ese patrón, pero permite que aquellos que pueden soportar el patrón Convenientemente para extender el idioma al hacerlo.