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++; }
; 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.