luglio 2005 - Posts
In questo
caso SOS sta per Son Of
Strike.
Alcuni
debugger (tra gli altri CDB e WinDBG) espongono un meccanismo
di "estensione" tramite il quale DLL di terze parti, purchè ovviamente scritte
in maniera opportuna, possono aggiungere funzionalità a quelle native
del debugger stesso. Una sorta di meccanismo a plug-in,
insomma.
SOS è,
infatti, una dll di estensione ai debugger sopra citati che viene distribuita con l'SDK del .NET Framework e che
consente di visualizzare alcune strutture
interne del Common Language Runtime: informazioni su tipi, GC, thread e via
discorrendo.
Personalmente non sono un amante dei debugger a linea di
comando (come CDB) e non mi trovo spesso ad utilizzare WinDBG (forse perchè mi è
capitato raramente di dover fare debug su una macchina di produzione, dove
VisualStudio non è quasi mai installato, nè tanto meno installabile).
In realtà SOS è utilizzabile in modo estremamente semplice anche da VS.NET.
E'
sufficiente "attaccarsi" ad un processo col debugger in modalità nativa
(almeno), sospendere l'esecuzione, aprire la finestra Immediate
(Debug->Windows->Immediate) e passare (se già non è attiva) alla
modalità immediata digitando immed
al prompt.
Non resta che caricare la dll tramite:
.load sos.dll
(il path completo non dovrebbe
essere necessario, poichè l'installazione di Visual Studio ha messo a posto le variabili di
ambiente in modo opportuno).
SOS mette a disposizione una serie piuttosto
ampia (e ampliata col procedere delle versioni del CLR, anche in modo notevole con
la 2.0) di comandi.
Non ne riporto la lista poiché basta digitare
!help per ottenere l'elenco completo. L'ultima release, che
accompagna la beta 2, consente anche di ottenere alcune righe di informazione su
ogni singolo comando, semplicemente digitando !help
<nomecomando>.
L'utilità di questa "cosa" ?
Poca dal punto di vista meramente pratico (se dobbiamo
sviluppare un sito in ASP.NET o un'applicazione Windows probabilmente riusciamo a
farne a meno senza troppa difficoltà ...).
Molta dal
punto di vista "accademico": perchè, appunto, permette di catturare
informazioni sulle strutture interne al runtime e, quindi, sul reale
funzionamento di alcune delle sue componenti.
Per
rendere la cosa un po' meno astratta, riporto
un esempio che mostra come sia possibile visualizzare il codice x86 emesso
dal Jitter nella compilazione di un metodo.
Innanzitutto, scriviamoci un programma "scratch",
anche banale, come questo:
using System;
using System.Runtime.CompilerServices;
namespace SOS {
class Program {
static void Main() {
Console.Write("Aggiungo 10 a 5. Il risultato è: ");
Console.WriteLine(Add(5, 10).ToString());
Console.ReadLine();
}
[MethodImpl(MethodImplOptions.NoInlining)]
static Int32 Add(Int32 first, Int32 second) {
return first + second;
}
}
}
Il metodo Add, che è quello che andremo ad
esaminare, è volutamente marcato come "non-inlinabile", in modo tale che il
Jitter non ne inserisca il corpo sostituendolo alla chiamata in Main e, di
fatto, evitandone la compilazione ... dedicata.
Compiliamo in Release ed eseguiamo. Attacchiamo il
debugger al processo e sospendiamone l'esecuzione.Non resta che caricare SOS all'interno del processo
ed iniziare ad utilizzarne le funzionalità (.load sos.dll).
Il percorso che dovremo seguire per scoprire il
codice macchina generato dal Jitter per il metodo Add è un po' lungo, benchè
piuttosto lineare:
- si parte dalla struttura che rappresenta il tipo
Program in memoria ...
- dalla quale si ottiene l'elenco dei metodi
definiti o ridefiniti ...
- nel quale si cerca il metodo Add ...
- che
infine si decompila !
(1) Per visualizzare le informazioni su
un tipo al runtime si può utilizzare il comando
!DumpMT <indirizzo MethodTable> . L'indirizzo di memoria della MethodTable relativa al
tipo Program non è ovviamente noto a priori: esistono diversi modi per
ottenerlo, ma il più semplice consiste nel chiedere ad SOS di indicarcelo, a
partire dal nome del tipo e del modulo che lo contiene, tramite
il comando !Name2EE <nome modulo> <nome tipo>.
Quindi:
!Name2EE SOSTest.exe SOS.Program
--------------------------------------
MethodTable: 009350a8
EEClass: 00c233a4
Name: SOS.Program
(2) Poi ci facciamo elencare i metodi definiti da
Sos.Program. Lo switch MD (MethodDesc) indica a SOS la richiesta di un elenco
completo con le informazioni su ciascuna entry della tabella dei
metodi:
!DumpMT -MD 009350a8
EEClass : 00c233a4
Module : 00161f20
Name: SOS.Program
mdToken: 02000002 (D:\Test\.NET\SOSTest\bin\Release\SOSTest.exe)
MethodTable Flags : 80000
Number of IFaces in IFaceMap : 0
Interface Map : 009350f0
Slots in VTable : 7
--------------------------------------
MethodDesc Table
Entry MethodDesc JIT Name
79b9300b 79b93010 None [DEFAULT] [hasThis] String
System.Object.ToString()
79b9301b 79b93020 None [DEFAULT] [hasThis] Boolean
System.Object.Equals(Object)
79b9304b 79b93050 None [DEFAULT] [hasThis] I4 System.Object.GetHashCode()
79b9306b 79b93070 None [DEFAULT] [hasThis] Void System.Object.Finalize()
00c30058 00935080 JIT [DEFAULT] Void SOS.Program.Main()
00c300c8 00935090 JIT [DEFAULT] I4
SOS.Program.Add(I4,I4)
0093509b 009350a0 None [DEFAULT] [hasThis] Void
SOS.Program..ctor()
(3) Le informazioni disponibili sono diverse:
la locazione di memoria che contiene il codice del metodo (o il rimando al thunk
di compilazione), l'indirizzo della relativa MethodDesc, il fatto che il metodo
sia già stato compilato o meno dal Jitter (JIT/None), che sia un metodo statico
o di istanza ([hasThis]). Si può ottenere un dettaglio ulteriore
tramite:
!DumpMD 00935090
Method Name : [DEFAULT] I4 SOS.Program.Add(I4,I4)
MethodTable 9350a8
Module: 161f20
mdToken: 06000002 (D:\Test\.NET\SOSTest\bin\Release\SOSTest.exe)
Flags : 30
Method VA :
00c300c8
(4) In fondo si nota l'indirizzo (Virtual Address)
del codice del metodo in memoria. Non resta che decompilarlo:
!u 00c300c8
Normal JIT generated code
[DEFAULT] I4 SOS.Program.Add(I4,I4)
Begin 00c300c8, size 5
00C300C8 add ecx,edx
00C300CA mov eax,ecx
00C300CC ret
Voila, gioco finito :-)
Se vi
interessa, vi lascio un paio di link con qualche approfondimento e qualche
ulteriore esempio d'uso.
http://blogs.msdn.com/yunjin/archive/2005/05/15/417569.aspx
http://blogs.msdn.com/mvstanton/archive/2004/04/05/108023.aspx
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
!
Sono stato assente dal mio blog per un
po' di tempo, sigh.
Potrei addurre a mia scusante un
po' di impegni che ho avuto nell'ultimo periodo, ma in realtà ci sono stati
momenti più densi di attività che non mi hanno impedito di scrivere con maggiore
regolarità.
Vabbè.
Nel mentre sono successe un po'
di cosine: un motore ed un look rinnovato per il sito ed i blog Devleap, due
TechEd con qualche novità (no, non ne ho frequentato nemmeno uno, ma per
fortuna c'è chi scrive sempre rapporti completi !), un nuovo nome per Longhorn
(che brutto !), strepitose imprese di Valentino Rossi (scusate l'OT, ma quando
ci vuole ci vuole, ragazzi che fenomeno !).
Sto finendo di scrivere un post
abbastanza lungo (che dovrebbe seguire a breve), ma nel frattempo ecco il
mio personale welcome back a me stesso, sperando di essere uno scrittore
un po' più costante in futuro.