c++ assembly x86

c++ - ¿Cómo funcionan los objetos en x86 a nivel de ensamblaje?



assembly (2)

(Lo siento, no puedo publicar esto como "comentario" a la respuesta de Peter Cordes debido a los ejemplos de código, así que tengo que publicar esto como "respuesta").

Los compiladores de C ++ antiguos generaban código C en lugar de código de ensamblaje. La siguiente clase:

class foo { int m_a; void inc_a(void); ... };

... daría como resultado el siguiente código C:

struct _t_foo_functions { void (*inc_a)(struct _class_foo *_this); ... }; struct _class_foo { struct _t_foo_functions *functions; int m_a; ... };

Una "clase" se convierte en una "estructura", un "objeto" se convierte en un elemento de datos del tipo de estructura. Todas las funciones tienen un elemento adicional en C (en comparación con C ++): el puntero "this". El primer elemento de la "estructura" es un puntero a una lista de todas las funciones de la clase.

Entonces el siguiente código C ++:

m_x=1; // implicit this->m_x thisMethod(); // implicit this->thisMethod() myObject.m_a=5; myObject.inc_a(); myObjectp->some_other_method(1,2,3);

... se verá de la siguiente manera en C:

_this->m_x=1; _this->functions->thisMethod(_this); myObject.m_a=5; myObject.functions->inc_a(&myObject); myObjectp->some_other_method(myObjectp,1,2,3);

Usando esos viejos compiladores, el código C se tradujo en ensamblador o código de máquina. Solo necesita saber cómo se manejan las estructuras en el código del ensamblador y cómo se manejan las llamadas a los punteros de función ...

Aunque los compiladores modernos ya no convierten el código C ++ en código C, el código ensamblador resultante todavía se ve de la misma manera como si fuera el paso C ++ a C primero.

"new" y "delete" darán como resultado una función que llama a funciones de memoria (puede llamar "malloc" o "free" en su lugar), la llamada del constructor o destructor y la inicialización de los elementos de la estructura.

Estoy tratando de entender cómo funcionan los objetos a nivel de ensamblaje. ¿Cómo se almacenan exactamente los objetos en la memoria y cómo acceden a ellos las funciones miembro?

(Nota del editor: la versión original era demasiado amplia y tenía cierta confusión sobre cómo funcionan los ensamblajes y estructuras en primer lugar).


Las clases se almacenan exactamente de la misma manera que las estructuras, excepto cuando tienen miembros virtuales. En ese caso, hay un puntero vtable implícito como primer miembro (ver más abajo).

Una estructura se almacena como un bloque contiguo de memoria ( si el compilador no la optimiza ni mantiene los valores de los miembros en los registros ). Dentro de un objeto de estructura, las direcciones de sus elementos aumentan en el orden en que se definieron los miembros. (fuente: http://en.cppreference.com/w/c/language/struct ). Enlacé la definición de C, porque en C ++ struct significa class (con public: como valor predeterminado en lugar de private: .

Piense en una struct o class como un bloque de bytes que puede ser demasiado grande para caber en un registro, pero que se copia como un "valor". El lenguaje ensamblador no tiene un sistema de tipos; Los bytes en la memoria son solo bytes y no se necesitan instrucciones especiales para almacenar un double de un registro de coma flotante y volver a cargarlo en un registro entero. O para hacer una carga no alineada y obtener los últimos 3 bytes de 1 int y el primer byte del siguiente. Una struct es solo parte de la construcción del sistema de tipos de C sobre bloques de memoria, ya que los bloques de memoria son útiles.

Estos bloques de bytes pueden tener almacenamiento estático (global o static ), dinámico ( malloc o new ) o automático (variable local: temporal en la pila o en registros, en implementaciones normales de C / C ++ en CPU normales). El diseño dentro de un bloque es el mismo independientemente (a menos que el compilador optimice la memoria real para una variable local de estructura; consulte el siguiente ejemplo de alinear una función que devuelve una estructura).

Una estructura o clase es igual a cualquier otro objeto. En terminología C y C ++, incluso un int es un objeto: http://en.cppreference.com/w/c/language/object . es decir, un bloque contiguo de bytes que puede recordar (excepto para los tipos que no son POD en C ++).

Las reglas ABI para el sistema para el que está compilando especifican cuándo y dónde se inserta el relleno para asegurarse de que cada miembro tenga una alineación suficiente, incluso si hace algo como struct { char a; int b; }; struct { char a; int b; }; (por ejemplo, el x86-64 System V ABI , utilizado en Linux y otros sistemas que no son Windows especifica que int es un tipo de 32 bits que obtiene una alineación de 4 bytes en la memoria. El ABI es lo que determina algunas cosas que el C y los estándares C ++ dejan "dependiente de la implementación", de modo que todos los compiladores para ese ABI pueden crear código que pueda llamarse mutuamente .)

Tenga en cuenta que puede usar offsetof(struct_name, member) para obtener información sobre el diseño de estructura (en C11 y C ++ 11). Ver también alignof en C ++ 11, o _Alignof en C11.

Depende del programador ordenar bien a los miembros de la estructura para evitar desperdiciar espacio en el relleno, ya que las reglas C no permiten que el compilador clasifique su estructura por usted. (por ejemplo, si tiene algunos miembros char , colóquelos en grupos de al menos 4, en lugar de alternar con miembros más anchos. Ordenar de grande a pequeño es una regla fácil, recordando que los punteros pueden ser de 64 o 32 bits en plataformas comunes).

Se pueden encontrar más detalles de las ABI y demás en https://.com/tags/x86/info . El excelente sitio de Agner Fog incluye una guía ABI, junto con guías de optimización.

Clases (con funciones miembro)

class foo { int m_a; int m_b; void inc_a(void){ m_a++; } int inc_b(void); }; int foo::inc_b(void) { return m_b++; }

compila a (usando gcc.godbolt.org ):

foo::inc_b(): # args: this in RDI mov eax, DWORD PTR [rdi+4] # eax = this->m_b lea edx, [rax+1] # edx = eax+1 mov DWORD PTR [rdi+4], edx # this->m_b = edx ret

Como puede ver, this puntero se pasa como un primer argumento implícito (en rdi, en el SysV AMD64 ABI). m_b se almacena a 4 bytes desde el inicio de la estructura / clase. Tenga en cuenta el uso inteligente de lea para implementar el operador posterior al incremento, dejando el valor anterior en eax .

No se emite ningún código para inc_a , ya que está definido dentro de la declaración de clase. Se trata igual que una función no miembro en inline . Si era realmente grande y el compilador decidió no incluirlo en línea, podría emitir una versión independiente.

Donde los objetos C ++ realmente difieren de las estructuras C es cuando están involucradas funciones miembro virtuales . Cada copia del objeto debe llevar un puntero adicional (a la tabla v para su tipo real).

class foo { public: int m_a; int m_b; void inc_a(void){ m_a++; } void inc_b(void); virtual void inc_v(void); }; void foo::inc_b(void) { m_b++; } class bar: public foo { public: virtual void inc_v(void); // overrides foo::inc_v even for users that access it through a pointer to class foo }; void foo::inc_v(void) { m_b++; } void bar::inc_v(void) { m_a++; }

compila a

; This time I made the functions return void, so the asm is simpler ; The in-memory layout of the class is now: ; vtable ptr (8B) ; m_a (4B) ; m_b (4B) foo::inc_v(): add DWORD PTR [rdi+12], 1 # this_2(D)->m_b, ret bar::inc_v(): add DWORD PTR [rdi+8], 1 # this_2(D)->D.2657.m_a, ret # if you uncheck the hide-directives box, you''ll see .globl foo::inc_b() .set foo::inc_b(),foo::inc_v() # since inc_b has the same definition as foo''s inc_v, so gcc saves space by making one an alias for the other. # you can also see the directives that define the data that goes in the vtables

Dato add m32, imm8 : add m32, imm8 es más rápido que inc m32 en la mayoría de las CPU de Intel (micro fusión de la carga + ALU uops); Uno de los raros casos en los que todavía se aplica el antiguo consejo Pentium4 para evitar inc . Sin embargo, gcc siempre evita inc , incluso cuando ahorraría el tamaño del código sin inconvenientes: / instrucción INC vs ADD 1: ¿Importa?

Despacho de funciones virtuales:

void caller(foo *p){ p->inc_v(); } mov rax, QWORD PTR [rdi] # p_2(D)->_vptr.foo, p_2(D)->_vptr.foo jmp [QWORD PTR [rax]] # *_3

(Esta es una llamada optimizada: jmp reemplazando call / ret ).

El mov carga la dirección vtable del objeto en un registro. El jmp es un salto indirecto de la memoria, es decir, cargar un nuevo valor RIP de la memoria. La dirección de destino de salto es vtable[0] , es decir, el primer puntero de función en vtable. Si hubiera otra función virtual, el mov no cambiaría pero el jmp usaría jmp [rax + 8] .

El orden de las entradas en el vtable presumiblemente coincide con el orden de la declaración en la clase, por lo que reordenar la declaración de la clase en una unidad de traducción daría como resultado que las funciones virtuales vayan al destino incorrecto. Al igual que reordenar los miembros de datos cambiaría la ABI de la clase.

Si el compilador tuviera más información, podría desvirtualizar la llamada . por ejemplo, si pudiera probar que foo * siempre apuntaba a un objeto de bar , podría bar::inc_v() línea bar::inc_v() .

GCC incluso se desvirtualizará especulativamente cuando pueda descubrir cuál es el tipo probablemente en tiempo de compilación. En el código anterior, el compilador no puede ver ninguna clase que herede de bar , por lo que es una buena apuesta que bar* esté apuntando a un objeto de bar , en lugar de alguna clase derivada.

void caller_bar(bar *p){ p->inc_v(); } # gcc5.5 -O3 caller_bar(bar*): mov rax, QWORD PTR [rdi] # load vtable pointer mov rax, QWORD PTR [rax] # load target function address cmp rax, OFFSET FLAT:bar::inc_v() # check it jne .L6 #, add DWORD PTR [rdi+8], 1 # inlined version of bar::inc_v() ret .L6: jmp rax # otherwise tailcall the derived class''s function

Recuerde, un foo * puede apuntar a un objeto de bar derivado, pero una bar * no puede apuntar a un objeto foo puro.

Sin embargo, es solo una apuesta; Parte del objetivo de las funciones virtuales es que los tipos se pueden extender sin volver a compilar todo el código que opera en el tipo base. Es por eso que tiene que comparar el puntero de función y recurrir a la llamada indirecta (jmp tailcall en este caso) si estaba equivocado. La heurística del compilador decide cuándo intentarlo.

Tenga en cuenta que está comprobando el puntero de la función real, en lugar de comparar el puntero vtable. Todavía puede usar la bar::inc_v() línea bar::inc_v() siempre que el tipo derivado no anule esa función virtual. Anular otras funciones virtuales no afectaría a esta, pero requeriría una vtable diferente.

Permitir la extensión sin recompilación es útil para las bibliotecas, pero también significa un acoplamiento más flexible entre las partes de un gran programa (es decir, no tiene que incluir todos los encabezados en cada archivo).

Pero esto impone algunos costos de eficiencia para algunos usos: el despacho virtual de C ++ solo funciona a través de punteros a objetos, por lo que no puede tener una matriz polimórfica sin hacks, o una indirecta costosa a través de una matriz de punteros (que vence muchas optimizaciones de hardware y software : ¿ La implementación más rápida de un patrón simple, virtual, de tipo observador en c ++? ).

Si desea algún tipo de polimorfismo / despacho pero solo para un conjunto cerrado de tipos (es decir, todos conocidos en tiempo de compilación), puede hacerlo manualmente con un switch union + enum + , o con std::variant<D1,D2> para hacer una unión y std::visit al despacho, o varias otras formas. Consulte también Almacenamiento contiguo de tipos polimórficos y la implementación más rápida de patrones simples, virtuales, de tipo observador, en c ++. .

Los objetos no siempre se almacenan en la memoria.

El uso de una struct no obliga al compilador a poner realmente cosas en la memoria , como tampoco lo hace una pequeña matriz o un puntero a una variable local. Por ejemplo, una función en línea que devuelve una struct por valor aún puede optimizarse por completo.

Se aplica la regla as-if: incluso si una estructura tiene lógicamente algo de almacenamiento de memoria, el compilador puede hacer un asm que mantenga todos los miembros necesarios en los registros (y hacer transformaciones que significan que los valores en los registros no corresponden a ningún valor de una variable o temporal en la máquina abstracta C ++ "ejecutando" el código fuente).

struct pair { int m_a; int m_b; }; pair addsub(int a, int b) { return {a+b, a-b}; } int foo(int a, int b) { pair ab = addsub(a,b); return ab.m_a * ab.m_b; }

Que compila (con g ++ 5.4) para :

# The non-inline definition which actually returns a struct addsub(int, int): lea edx, [rdi+rsi] # add result mov eax, edi sub eax, esi # sub result # then pack both struct members into a 64-bit register, as required by the x86-64 SysV ABI sal rax, 32 or rax, rdx ret # But when inlining, it optimizes away foo(int, int): lea eax, [rdi+rsi] # a+b sub edi, esi # a-b imul eax, edi # (a+b) * (a-b) ret

Observe cómo incluso devolver una estructura por valor no necesariamente la guarda en la memoria. El x86-64 SysV ABI pasa y devuelve pequeñas estructuras empaquetadas en registros. Diferentes ABI toman diferentes decisiones para esto.