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
!