Come funzionano gli oggetti in x86 a livello di assieme?

Sto cercando di capire come funzionano gli oggetti a livello di assemblaggio. In che modo esattamente gli oggetti sono memorizzati in memoria e in che modo le funzioni membro li accedono?

(nota del redattore: la versione originale era troppo ampia e aveva una certa confusione su come funzionano in primo luogo il assembly e le strutture).

Le classi sono memorizzate esattamente nello stesso modo delle strutture, tranne quando hanno membri virtuali. In quel caso, c’è un puntatore implicito come il primo membro (vedi sotto).

Una struct è memorizzata come un blocco contiguo di memoria ( se il compilatore non lo ottimizza o mantiene i valori dei membri nei registri ). All’interno di un object struct, gli indirizzi dei suoi elementi aumentano nell’ordine in cui sono stati definiti i membri. (fonte: http://en.cppreference.com/w/c/language/struct ). Ho collegato la definizione C, perché in C ++ struct significa class (con public: come default invece che private: .

Pensa a una struct o class come a un blocco di byte che potrebbe essere troppo grande per rientrare in un registro, ma che viene copiata come “valore”. Il linguaggio assembly non ha un sistema di tipi; i byte in memoria sono solo byte e non richiede alcuna istruzione speciale per memorizzare un double da un registro a virgola mobile e ricaricarlo in un registro intero. O per fare un carico non allineato e ottenere gli ultimi 3 byte di 1 int e il primo byte del successivo. Una struct è solo una parte della costruzione del sistema di tipi di C in cima ai blocchi di memoria, poiché i blocchi di memoria sono utili.

Questi blocchi di byte possono essere statici (globali o static ), dinamici ( malloc o new ) o storage automatici (variabili locali: temporanee nello stack o in registri, in normali implementazioni C / C ++ su CPU normali). Il layout all’interno di un blocco è lo stesso a prescindere (a meno che il compilatore non ottimizzi la memoria effettiva per una variabile locale struct, vedere l’esempio seguente di inlining di una funzione che restituisce una struct).

Una struct o una class è uguale a qualsiasi altro object. Nella terminologia C e C ++, anche un int è un object: http://en.cppreference.com/w/c/language/object . cioè un blocco contiguo di byte che puoi memcpy intorno (tranne che per i tipi non POD in C ++).

Le regole ABI per il sistema che stai compilando specificano quando e dove viene inserito il padding per assicurarsi che ogni membro abbia un allineamento sufficiente anche se fai qualcosa come struct { char a; int b; }; struct { char a; int b; }; (ad esempio, il System V ABI x86-64 , utilizzato su Linux e su altri sistemi non Windows, specifica che int è un tipo a 32 bit che ottiene l’allineamento a 4 byte in memoria. L’ABI è ciò che inchioda alcune cose che il C e gli standard C ++ lasciano “l’implementazione dipendente”, in modo che tutti i compilatori per quell’ABI possano creare codice in grado di chiamare le rispettive funzioni .)

Si noti che è ansible utilizzare offsetof(struct_name, member) per scoprire il layout di struct (in C11 e C ++ 11). Vedi anche alignof in C ++ 11 o _Alignof in C11.

Spetta al programmatore ordinare bene i membri della struttura per evitare di sprecare spazio nel padding, poiché le regole C non consentono al compilatore di ordinare la struttura per te. Ad esempio, se si dispone di membri char , inserirli in gruppi di almeno 4, anziché alternare con membri più grandi L’ordinamento da grande a piccolo è una regola semplice, ricordando che i puntatori possono essere 64 o 32 bit su piattaforms comuni.

Maggiori dettagli su ABI e così via sono disponibili su https://stackoverflow.com/tags/x86/info . L’ eccellente sito di Agner Fog include una guida ABI e guide all’ottimizzazione.


Classi (con funzioni membro)

 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 http://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 

Come si può vedere, il puntatore viene passato come primo argomento implicito (in rdi, nell’ABI SysV AMD64). m_b è memorizzato a 4 byte dall’inizio della struct / class. Si noti l’uso intelligente di lea per implementare l’operatore post-incremento, lasciando il vecchio valore in eax .

Non viene emesso alcun codice per inc_a , poiché è definito all’interno della dichiarazione della class. È trattato come una funzione non membro incorporata. Se fosse davvero grande e il compilatore decidesse di non farlo, potrebbe emetterne una versione standalone.


Dove gli oggetti C ++ differiscono realmente dalle strutture C è quando sono coinvolte le funzioni dei membri virtuali . Ogni copia dell’object deve portare un puntatore in più (verso il vtable per il suo tipo effettivo).

 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 

Fatto divertente: add m32, imm8 è più veloce di inc m32 sulla maggior parte delle CPU Intel (micro-fusione del carico + UOP di ALU); uno dei rari casi in cui si applica ancora il vecchio consiglio Pentium4 per evitare inc . gcc evita sempre inc , anche se risparmierebbe le dimensioni del codice senza alcun aspetto negativo: / Istruzione INC vs ADD 1: Ha importanza?


Invio di funzioni virtuali:

 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 

(Questa è una chiamata ottimizzata: jmp sostituisce call / ret ).

Il mov carica l’indirizzo vtable dall’object in un registro. Il jmp è un salto indiretto in memoria, cioè carica un nuovo valore RIP dalla memoria. L’indirizzo di destinazione del salto è vtable[0] , cioè il primo puntatore di funzione nel vtable. Se ci fosse un’altra funzione virtuale, la mov non cambierebbe ma la jmp userebbe jmp [rax + 8] .

L’ordine delle voci nel vtable corrisponde presumibilmente all’ordine di dichiarazione nella class, pertanto riordinare la dichiarazione della class in una unità di traduzione comporterebbe la consegna di funzioni virtuali all’objective sbagliato. Proprio come riordinare i dati, i membri cambiano l’ABI della class.

Se il compilatore avesse più informazioni, potrebbe ridistribuire la chiamata . per esempio se bar::inc_v() che foo * puntava sempre su un object bar , poteva inline bar::inc_v() .

GCC sarà anche speculativamente devirtualize quando può capire quale sia il tipo probabilmente in fase di compilazione. Nel codice precedente, il compilatore non può vedere alcuna class che eredita dalla bar , quindi è una buona scommessa che bar* stia puntando a un object bar , piuttosto che a una class derivata.

 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 

Ricorda, un foo * può effettivamente puntare a un object bar derivato, ma una bar * non può puntare a un object foo puro.

È solo una scommessa però; parte del punto delle funzioni virtuali è che i tipi possono essere estesi senza ricompilare tutto il codice che opera sul tipo di base. Questo è il motivo per cui deve confrontare il puntatore della funzione e ricorrere alla chiamata indiretta (jmp tailcall in questo caso) se era sbagliato. L’euristica del compilatore decide quando tentarlo.

Si noti che sta controllando il puntatore effettivo della funzione, piuttosto che confrontare il puntatore vtable. Può ancora usare la bar::inc_v() inline bar::inc_v() a condizione che il tipo derivato non abbia sovrascritto quella funzione virtuale. L’override di altre funzioni virtuali non influirebbe su questo, ma richiederebbe un vtable differente.

Consentire l’estensione senza ricompilazione è utile per le librerie, ma significa anche un accoppiamento più libero tra le parti di un grande programma (cioè non è necessario includere tutte le intestazioni in ogni file).

Ma questo impone alcuni costi di efficienza per alcuni usi: il dispatch virtuale C ++ funziona solo attraverso i puntatori agli oggetti, quindi non si può avere un array polimorfico senza hack, o una costata indiretta attraverso una serie di puntatori (che sconfigge molte ottimizzazioni hardware e software : Implementazione più veloce di pattern di tipo osservatore semplice, virtuale, in c ++? ).

Se vuoi un qualche tipo di polimorfismo / dispatch ma solo per un insieme chiuso di tipi (cioè tutti noti al momento della compilazione), puoi farlo manualmente con un union + enum + switch , o con std::variant fare un sindacato e std::visit alla spedizione, o vari altri modi. Vedi anche Memorizzazione contigua di tipi polimorfi e implementazione più veloce di pattern di tipo osservatore semplice, virtuale, in c ++? .


Gli oggetti non sono sempre memorizzati in memoria.

L’utilizzo di una struct non costringe il compilatore a mettere effettivamente le cose in memoria , non più di un piccolo array o un puntatore a una variabile locale. Ad esempio, una funzione inline che restituisce una struct base al valore può ancora essere ottimizzata completamente.

La regola as-if vale: anche se una struct ha logicamente qualche memoria, il compilatore può creare asm che mantiene tutti i membri necessari nei registri (e fare trasformazioni che significano che i valori nei registri non corrispondono a nessun valore di una variabile o temporaneo nella macchina astratta C ++ “eseguendo” il codice sorgente).

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

Che compila (con g ++ 5.4) a :

 # 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 # ab imul eax, edi # (a+b) * (ab) ret 

Si noti come persino restituire una struttura in base al valore non lo metta necessariamente in memoria. L’SISV ABI x86-64 passa e restituisce piccole strutture riunite in registri. Diverse ABI fanno scelte diverse per questo.

(Mi dispiace, non posso postare questo come “commento” alla risposta di Peter Cordes a causa degli esempi di codice, quindi devo postare questo come “risposta”.)

I vecchi compilatori C ++ hanno generato codice C anziché codice assembly. La seguente class:

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

… risulterebbe nel seguente codice C:

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

Una “class” diventa una “struct”, un “object” diventa un elemento di dati del tipo struct. Tutte le funzioni hanno un elemento aggiuntivo in C (rispetto a C ++): il puntatore “questo”. Il primo elemento della “struct” è un puntatore a un elenco di tutte le funzioni della class.

Quindi il seguente codice 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); 

… apparirà nel modo seguente in 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 quei vecchi compilatori il codice C è stato tradotto in assemblatore o codice macchina. Hai solo bisogno di sapere come vengono gestite le strutture nel codice assembler e come vengono gestite le chiamate ai puntatori di funzione …

Sebbene i compilatori moderni non convertano più codice C ++ in codice C, il codice assembler risultante sembra ancora lo stesso come se si facesse prima il passo C ++-to-C.

“new” e “delete” comporteranno una funzione di chiamata alle funzioni di memoria (invece si può chiamare “malloc” o “free”), la chiamata del costruttore o del distruttore e l’inizializzazione degli elementi della struttura.