Claudio Brotto

MethodDesc: come ti passo un parametro ...

Quando il loader del CLR carica un tipo, va a leggere dal modulo che lo contiene i metadati relativi e prepara una struttura in memoria, che altro non è se non una rappresentazione del tipo (quindi dei suoi metadati) ottimizzata per l'esecuzione.

Questa struttura di memoria è complessa, piuttosto distribuita e non documentata, per quanto molte informazioni si riescano ad ottenere con l'ausilio dei sorgenti di Rotor e di un buon debugger.

La struttura chiave, che contiene le informazioni a più frequente accesso è chiamata MethodTable. Per inciso, vi si accede da codice gestito tramite la classe RuntimeTypeHandle, esposta da Type tramite la property TypeHandle.

Ogni riferimento ad un oggetto di tipo reference punta ad una locazione di memoria nello heap gestito dal garbage collector, la quale è a sua volta un puntatore all'inizio di tale struttura.

La MethodTable di un tipo contiene diverse informazioni vitali affinchè il runtime possa gestire gli oggetti in fase di esecuzione: dimensioni del componente, riferimento alla mappa delle interfacce implementate e, alla fine della struttura, un vettore di puntatori ai metodi che il tipo (ri)definisce.

Ora, poichè i metodi non vengono compilati dal Jitter se non in occasione della prima esecuzione, è necessario un meccanismo che:

  • scateni la compilazione just-in-time quando necessario ...
  • salvi in memoria la versione compilata del metodo ...
  • e infine richiami quest'ultima, se presente.

Il CLR ottiene questo scopo inserendo un'istruzione JMP in ogni slot della tabella dei metodi. In fase iniziale, ossia quando ancora il metodo non è stato compilato in codice nativo, il jump porta il percorso di codice attraverso i meandri del Jitter, in modo tale che questo possa effettuare la compilazione. Una volta compilato, il codice viene salvato in un'area di memoria privata del runtime, e l'istruzione JMP viene patchata in modo tale da puntare direttamente alla versione eseguibile del metodo in questione. Ad ogni esecuzione successiva, la fase di compilazione verrà saltata a piè pari in maniera del tutto automatica e trasparente, e il metodo sarà eseguito senza ulteriori overhead, a parte quello (minimo) dell'indirezione dovuta all'istruzione di salto.

Una domanda interessante, a questo punto, potrebbe essere: come vengono passate al Jitter le informazioni sul metodo da compilare (in primis la locazione dello stream di codice IL) ?

La risposta è altrettanto interessante e, a mio avviso, anche piuttosto sorprendente.

Innanzitutto una breve premessa: come affermato in precedenza, le informazioni sul tipo in memoria sono numerose e "ramificate", nel senso che non sono necessariamente presenti in zone di memoria contigue e, quindi, "staticamente" accessibili le une dalle altre tramite offset fisso. Ogni metodo, ad esempio, è identificato da uno slot nella tabella dei metodi (di cui abbiamo appena parlato), ma è altresì descritto da una struttura dati denominata MethodDesc, che non è accessibile a partire dalla MethodTable se non attraverso diverse indirezioni.

Ogni MethodDesc, come il nome fa presupporre, contiene informazioni sul metodo in questione, tra le quali spicca l'indirizzo del codice IL che lo rappresenta (quando è ancora nella forma pre-jit).

Facendola semplice, il runtime dovrebbe indicare al Jitter l'indirizzo di memoria che contiene la MethodDesc, affinchè questo ne possa ricavare le informazioni necessarie e procedere alla compilazione. Tutto questo a partire da un JUMP dalla MethodTable.

Quindi ... soluzione 1 (quella che se me lo avessero chiesto in un colloquio avrei proposto, nda): ogni volta che viene invocato un metodo, il runtime passa sullo stack l'indirizzo della MethodTable (o dello slot del metodo, la scelta è indifferente poichè la distanza di questo dall'inizio della MethodTable è nota al runtime), quindi effettua il JMP. Se il salto porta alla compilazione JIT, il JMP punterà ad un (unico) helper che darà inizio alla compilazione, ricavando l'indirizzo della MethodDesc a partire dalla MethodTable tramite una serie di indirezioni. E il gioco è fatto (per modo di dire, giacchè spero che domande di questo genere non siano la quotidianità di un colloquio per un posto da programmatore).

In realtà, la soluzione adottata è un po' più perversa e geniale.

Innanzitutto, ogni elemento della tabella dei metodi contiene un'istruzione JMP ad una diversa locazione di memoria: 10 metodi definiti, 10 slot nella tabella dei metodi, 10 istruzioni JMP con operandi differenti.

Il target di ogni istruzione di salto è una locazione di memoria molto particolare: esattamente 5 byte prima dell'indirizzo della rispettiva MethodDesc.

???

Stiamo saltando ad un'area dati, non dovremmo saltare ad un'area di memoria contenente codice eseguibile ?!?

Sì e no !

In quei 5 byte, il loader ha emesso il codice x86 che implementa un'istruzione call (E8 + <indirizzo della funzione che inizia la compilazione JIT>).

L'istruzione call in assembler è in pratica un comando di salto incondizionato "con memoria": è del tutto equivalente ad un JMP, ma in aggiunta l'indirizzo successivo al corrente (quello contenuto in ogni momento nel registro EIP) viene posto sullo stack. E' il funzionamento di una chiamata a funzione in assembler: l'indirizzo di ritorno viene salvato, e l'istruzione ret che conclude la procedura lo preleva, lo salva in EIP e fa in modo di riprendere l'esecuzione dal punto di chiamata (per la gioia degli attacchi da buffer overflow, ma questa è un'altra storia).

Il trucco sta nel porre il codice per l'istruzione call esattamente prima dell'indirizzo della struttura MethodDesc. In tal modo non facciamo che ingannare l'assembler, che crede di trovarsi di fronte ad un'istruzione di chiamata e memorizza l'indirizzo di memoria successivo al corrente sullo stack. Peccato che tale indirizzo sia proprio quello della MethodDesc ! Non ci sarà nessuna istruzione ret che ci riporterà a questa locazione: è stato solo sfruttato un side-effect dell'istruzione x86 call, cioè il passaggio dell'indirizzo di esecuzione successivo sullo stack.

Il vantaggio di una soluzione di questo tipo sta nell'aver evitato tutte le ricerche necessarie a ricavare l'indirizzo di una struttura MethodDesc a partire da quello di una struttura MethodTable: come detto, anche se logicamente correlate, le informazioni sui tipi sono disposte in maniera piuttosto ramificata nella memoria del CLR, tanto che a volte sono necessarie alcune indirezioni (dereferenziazioni, offset su vettori, ricerche su liste concatenate) per ottenere le une dalle altre.

In uno scenario (come questo) dove va ricercata anche la minima ottimizzazione, un "trucco" del genere vien giusto bene !

Posted: lug 25 2005, 07:09 by devlizard | with 1 comment(s)
Filed under:

Comments

marco said:

Bentornato, Claudio, e complimenti per l'ottimo post.
La tecnica che hai descritto non è nuova e l'ho vista qualche anno fa su 68000 (e forse anche su altri processori incluso lo Z80, ma spero di non ricordare male) anche se per scopi diversi dall'invocazione del JIT.
In Assembler quello che sembra un "trucco" è perfettamente lecito, perché a quel livello non ci sono più altri "ottimizzatori" (anche se i processori più recenti fanno cose sofisticate come il branch prediction) che possono darti una mano. Cosa che invece avviene normalmente quando programmi con qualsiasi linguaggio di alto livello, dal C in su.
Comunque interessante, non sapevo che il JIT usasse questa tecnica.

Marco
# luglio 26, 2005 12:56