==============================================================================
=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
--------------------[ previous ]---[ index ]---[ next ]---------------------
---------------------[ JAVA REVERSE ENGEENERiNG: LA JVM ]---------------------
---------------------------------[ LordFelix ]--------------------------------
0.0 Prefazione
1.0 Piccola introduzione al funzionamento di Java
1.1 Principi di funzionamento della JVM
1.2 I frame
2.0 Tools
3.0 Le istruzioni della JVM: un esempio
4.0 Conclusioni
0.0 Prefazione
----------
Al giorno d'oggi l'intero web e' infestato da una serie infinita di applet
java di ogni genere. Spesso mi e' sorta la curiosita' di capire come
funzionasse (almeno a grandi linee) l'interprete contenuto in ogni browser di
un certo livello (lynx a parte ;). Questo scritto vuole rappresentare solo una
prima lettura per chiunque voglia approfondire il funzionamento della Java
Virtual Machine (JVM) e dare un'occhiata al codice delle migliori applet
presenti in rete. Non e' mia intenzione sostituirmi a quella che e' la
"bibbia" della JVM, ovvero il libro "The Java Virtual Machine Specification"
di Tim Lindholm e Frank Yellin disponibile on line su http://java.sun.com
In questa breve panoramica ho eliminato tutti quegli aspetti che riguardano
l'implementazione di una JVM, focalizzando l'attenzione sul set di istruzioni
e sugli strumenti disponibili per il reverse engeenering delle classi java.
Nel seguito daro' per scontata la conoscenza di almeno i costrutti
fondamentali del linguaggio java.
Mi scuso in anticipo per eventuali imprecisioni. Vi prego di segnalarmi ogni
incongruenza scrivendomi all'indirizzo lordfelix@dislessici.org
1.0 Piccola introduzione al funzionamento di Java
---------------------------------------------
A differenza degli altri linguaggi di programmazione per Java lo scopo
fondamentale e' funzionare su ogni tipo di hadware che possegga una
implementazione della Java Virtual Machine (JVM). In pratica quando compiliamo
un programma in Java il .class che otteniamo non e' codificato per il
linguaggio macchina del nostro processore, ma e' "tradotto" in una specie di
"macrolinguaggio". Ad eseguire il nostro .class non sara', quindi, il
processore ma un programma che interpreta i bytecode e trasmette i comandi
corrispondenti al processore.
Rivediamo la cosa usando un po' di ascii-art:
---------- ------------ ---------- --------- -------
| File | | Compiler | | File | | JVM | | CPU |
| .java |-->| |-->| .class |-->| |-->| |
---------- ------------ ---------- --------- -------
chiaramente l'intermediazione della JVM per l'esecuzione dei programmi rende i
.class assai piu' lenti del codice nativo. Tuttavia l'obiettivo dei
progettisti e' stato quello di ottenere la portabilita' su diverse piattaforme
del compilato, e, devo dire, ci sono riusciti.
La JVM puo' essere utilizzata indipendentemente dal linguaggio Java vero e
proprio. I costrutti del linguaggio sono noti al solo compiler, mentre alla
JVM compete la sola esecuzione del macrocodice prodotto da quest'ultimo.
Cosi' come gli assembler consentono di programmare direttamente con il codice
della CPU allo stesso modo esistono delle applicazioni che consentono di
programmare direttamente la JVM.
1.1 Principi di funzionamento della JVM
-----------------------------------
Innanzitutto la JVM non include alcun meccanismo di controllo sulla coerenza
dei tipi di dati passati alle sue istruzioni. Si presuppone che il controllo
sui tipi sia stato gia' effettuato dal compilatore. Possiamo raggruppare i
tipi in due categorie: quelli primitivi e i reference. I dati primitivi sono
i classici int, long, float... mentre i reference rappresentano
*esplicitamente* gli oggetti che utilizza il programma.
Oltre ai tipi primitivi numerici sono presenti anche quelli del tipo
returnaddress usati dalle istruzioni di controllo come vedremo in seguito.
I returnaddress non hanno alcun tipo corrispondente nel linguaggio java ma
sono una produzione "esclusiva" del compilatore. Una attenzione particolare
merita il tipo boolean che non ha un corrispettivo nell'ambito della JVM e che
viene trattato come se fosse un int.
Per quanto riguarda i reference, possono "puntare" tre tipi di oggetti: le
classi, le intefacce e gli array. Un reference puo' assumere anche il valore
null (cioe' nessun oggetto).
Come un qualsiasi processore anche la JVM ha un suo insieme di registri. Il
registro pc assume lo stesso significato che ha il registro IP nei processori
Intel: e' il "program counter" ovvero contiene la posizione della istruzione
eseguita in quel momento. Il pc non e' definito se il metodo eseguito dalla
JVM e' "nativo", cioe' fa parte del set di metodi messi a disposizione del
linguaggio (come quelli delle awt, per esempio) oppure scritti in altri
linguaggi. Tali metodi, infatti, vengono eseguiti direttamente dal
microprocessore, bypassando la JVM.
La lunghezza del registro pc e' una word.
Nella JVM e' presente il cosiddetto Java Stack. Esso contene una serie di
sottostrutture, dette frame, che contengono le variabili locali, i risultati
parziali e le informazioni per le chiamate ai membri di altre classi. Inoltre
e' presente un heap in cui vengono memorizzati gli array e le istanze delle
varie classi.
Assai importante e' una particolare tabella di simboli, detta constant pool.
Essa contiene, per ogni classe, il valore delle costanti e i reference ai
metodi che la classe usa.
1.2 I frame
-------
Analizziamo con maggiore dettaglio questa particolare struttura. Essa viene
creata per ogni metodo che viene invocato. In ogni istante e' definito un
metodo corrente e il corrispettivo frame. Un frame cessa di essere il frame
corrente quando il metodo corrente chiama un altro metodo. Al ritorno della
chiamata il frame torna ad essere il "frame corrente". Ogni frame presenta
un'area dedicata alle variabili locali e un'altra che funge da stack per gli
operandi che di volta in volta sono necessari alle istruzioni della JVM.
Le variabili locali vengono memorizzate in un array i cui elementi possono
essere richiamati nel piu' classico dei modi: mediante il loro indice. Ogni
locazione di questo array e' sempre lunga una word. Per le variabili che
occupano piu' word viene riservata piu' di una locazione.
Per quanto riguarda lo stack operandi, esso viene usato per passare parametri
ai metodi e per riceverne i risultati, nonche' per fornire gli operandi alle
istruzioni della JVM (come vedremo in seguito).
2.0 Tools
-----
Prima di procedere all'analisi di un file .class vediamo di quali tools
abbiamo bisogno. E' inutile dire che avete bisogno del Java Development Kit,
necessario per compilare i file .java (http://java.sun.com). Per convertire i
file .class nel corrispondente bytecode avete bisogno di un disassembler: per
i nostri scopi esistono due programmi: D-Java e Jdis. Il primo e' scritto in
c e lo trovate sia per win che per solaris; tuttavia ho notato un certo
"impapocchiamento" del programma nell'assegnazione delle label. Non ci resta
che usare Jdis che e' scritto in java ed e' quindi eseguibile in tutte le
piattaforme con una JVM.
Ho stretto la cerchia dei disassembler a d-java e jdis perche' consentono di
avere l'output nel formato di jasmin; jasmin e' un assemblatore che, ricevuto
il bytecode in ingresso lo trasforma in un file .class. Infine, ma non ultimi,
troviamo due decompilatori veri e propri: jad e mocha. Jad e' scritto in c ed
e' reperibile in formato .exe per dos; mocha e' invece scritto in java.
Entrambi producono, a partire da un file .class il corrispondente .java .
Tutti questi "attrezzi" li trovate sul sito
http://Meurrens.ML.org/ip-Links/Java/codeEngineering/
3.0 Le istruzioni della JVM: un esempio
-----------------------------------
Introdurremo ora le varie istruzioni della JVM ricorrendo ad un esempio
significativo. Analizzeremo l'applet NervousText fornita nei demo del JDK
nella dir /demo/NervousText. Abbiamo gia' a disposizione il file .class
quindi non ci resta che disassemblarlo per carpirne i segreti. Dato che e'
molto interessante capire la corrispondenza bytecode/istruzioni java
utilizzeremo il jad usando il parametro -a (annotate) che pone le istruzioni
della JVM come commento alle linee di codice ricostruito:
jad -a NervousText.class
Quello che otteniamo e' un file di testo con estensione .jad che contiene
sorgente e assembly JVM. Analizziamolo.
In testa al sorgente troviamo il solito e familiare "preambolo" in cui vengono
dichiarate le classi java necessarie al corretto funzionamento dell'applet:
import java.applet.Applet;
import java.awt.*;
import java.awt.event.*;
public class NervousText extends Applet
implements Runnable, MouseListener
{
Vengono poi definiti i membri della classe NervousText. Da questo punto in poi
jad comincia a sfornare anche il codice della JVM.
public void init()
{
banner = getParameter("text");
// 0 0:aload_0
// 1 1:aload_0
// 2 2:ldc1 #6 <String "text">
// 3 4:invokevirtual #31 <Method String Applet.getParameter(String)>
// 4 7:putfield #25 <Field String banner>
Nelle prime tre righe abbiamo il codice sorgente mentre nei commenti sono
presenti le istruzioni JVM che lo implementano. Vediamo cosa significano e
come operano.
aload_0 Viene posto in cima allo stack operandi il riferimento alla
classe della variabile locale numero 0. In questo caso tale
variabile e' Banner ma di essa viene "pushato" solo
l'informazione "banner e' una stringa" e non il valore o
l'area di memoria ad essa associata. L'istruzione viene
eseguita due volte e vedremo fra poco il perche'.
ldc1 #6 Viene pescato l'elemento numero 6 del "constant pool". Il
constant pool puo' essere visto come un "array" in cui il
compilatore java memorizza le costanti usate dal programma
(ma non solo). In questo caso il numero 6 corrisponde alla
stringa "text" che viene posta in testa allo stack.
invokevirtual #31 Ecco l'istruzione che piu' spesso ricorre nell'assembly
JVM: invokevirtual. Essenzialmente "chiama" il metodo
numero 31 che in questo caso corrisponde alla getParameter
della classe applet. Anche questa volta il numero 31 fa
riferimento ad una posizione del constant pool che contiene
il nome del metodo da "invocare". Inoltre, sempre nel campo
31 del constant pool e' indicato il numero di argomenti
necessari alla funzione. Essi devono risiedere nello stack
secondo l'ordine:
argomento n
argomento n-1
...
argomento 1
riferimento all'oggetto che contiene il risultato
...
Quindi, appena dopo gli argomenti, in cima allo stack va
posto il riferimento all'oggetto che deve contenere il
risultato dell'elaborazione del metodo.
Nel caso specifico prima di invoke virtual abbiamo la
seguente situazione nello stack:
riferimento alla stringa "text"
riferimento ad un oggetto stringa
riferimento ad un oggetto stringa
...
dopo l'esecuzione della invokevirtual lo stack conterra' il
solo risultato della getParameter (oggetto stringa)
riferimento ad un oggetto stringa.
In pratica il riferimento "consumato" dalla invoke e'
servito a determinare solo il tipo di valore in uscita, in
accordo con quello che abbiamo detto in occasione della
aload_0. E' importante notare che l'assegnamento del
risultato alla variabile Banner non e' ancora avvenuto.
putfield #25 Finalmente viene prelevato il risultato della getParameter e
viene memorizzato nella posizione 25 del constant pool. Il
tipo dell'oggetto memorizzato al 25 viene determinato in base
al rifermento successivo al risultato. Nel nostro caso alla
fine dell'operazione lo stack e' vuoto. E' per questo motivo
che l'aload iniziale e' stato ripetuto due volte.
Da queste prime istruzioni si nota subito la cruciale importanza del constant
pool e del livello tutto sommato alto del bytecode. Gli oggetti e i metodi
vengono trattati in quanto tali e non vengono scomposti in tipi piu'
elementari come accade nei compilatori normali.
Proseguiamo nell'analisi...
if(banner == null)
//* 5 10:aload_0
//* 6 11:getfield #25 <Field String banner>
//* 7 14:ifnonnull 23
Di aload abbiamo gia' parlato: un riferimento al tipo stringa viene posto in
cima allo stack.
getfield #25 Si tratta dell'istruzione duale a putfield. Nel nostro caso lo
stack contiene unicamente il riferimento ad un oggetto stringa
...
dopo il getfield viene preso il campo #25 del constant pool e
il suo valore viene trattato come una instanza della classe
stringa: lo stack conterra' il risultato della getfield:
valore dell'oggetto stringa #25
...
ifnonnull 23 Ecco la prima istruzione di controllo che incontriamo. Il suo
uso e' intuitivo. Controlla che il valore in cima allo stack
non sia null e se non lo e' salta alla linea 23 altrimenti
prosegue nell'esecuzione. Come e' prassi il valore in cima
allo stack (utilizzato nel controllo) viene "poppato" via.
Chi ha un po' di esperienza nei linguaggi di alto livello notera' una certa
somiglianza col costrutto if...goto. Tutti i costrutti if..then..else..
vengono "compilati" ricorrendo all'uso di if...goto e goto semplici. Nel
nostro caso il ramo "then" e' costituito dalle istruzioni dalla 17 alla 20:
banner = "HotJava";
// 8 17:aload_0
// 9 18:ldc1 #1 <String "HotJava">
// 10 20:putfield #25 <Field String banner>
Da queste linee si nota che il ramo then non e' altro che un assegnamento di
una costante alla stringa banner. In particolare viene assegnata la stringa
contenuta nella posizione #1 del costant pool all'oggetto contenuto nella
posizione #25 (che abbiamo visto essere banner).
int i = banner.length();
// 11 23:aload_0
// 12 24:getfield #25 <Field String banner>
// 13 27:invokevirtual #32 <Method int String.length()>
// 14 30:istore_1
Le prime tre istruzioni si incaricano di richiamare il metodo length
dell'oggetto banner (in realta' viene invocato il metodo length della classe
string che agisce sul valore in testa allo stack). In questo caso la
definizione della variabile i e' associata alla sua dichiarazione
istore_1 Memorizza il valore in testa allo stack nella variabile locale
numero 1. Inoltre specifica che tale variabile e' un intero.
Come sempre il valore in testa allo stack viene eliminato.
bannerChars = new char[i];
// 15 31:aload_0
// 16 32:iload_1
// 17 33:newarray char[]
// 18 35:putfield #26 <Field char[] bannerChars>
Viene ora dichiarato un array di caratteri. La chiave dell'operazione e'
iload_1 Viene caricato sullo stack il riferimento alla variabile
locale intera posta nella posizione numero 1 del frame. Si
tratta della i usata per determinare la lunghezza dell'array
che si sta per creare.
newarray char[] Crea un array di caratteri di lunghezza pari al valore
intero che si trova in cima allo stack. Naturalmente e'
possibile creare array di altri tipi.
Dopo l'esecuzione nello stack troviamo un reference al
nuovo array. Il tipo di array da creare viene determinato a
partire dal byte successivo all'opcode di newarray, secondo
la tabella:
Array Type | atype
------------------
T_BOOLEAN 4
T_CHAR 5
T_FLOAT 6
T_DOUBLE 7
T_BYTE 8
T_SHORT 9
T_INT 10
T_LONG 11
putfield #26 Il riferimento al nuovo array va a occupare la posizione #26
del constant pool.
banner.getChars(0, banner.length(), bannerChars, 0);
// 19 38:aload_0
// 20 39:getfield #25 <Field String banner>
// 21 42:iconst_0
// 22 43:aload_0
// 23 44:getfield #25 <Field String banner>
// 24 47:invokevirtual #32 <Method int String.length()>
// 25 50:aload_0
// 26 51:getfield #26 <Field char[] bannerChars>
// 27 54:iconst_0
// 28 55:invokevirtual #30 <Method void String.getChars(int, int, char[], int)>
La serie di istruzioni prima dell'invoke finale serve a ricostruire i
parametri da passare al metodo getChars della classe string. Considerando
quando detto in precedenza non dovrebbe essere difficile rendersi conto di
come i vari parametri si avvicendano nello stack. L'unica novita' e' la
iconst_0 Inserisce la costante intera 0 in cima allo stack. Esistono
diverse alternative per questa istruzione:
Istruzione | Costante associata
-------------------------------
iconst_m1 -1
iconst_0 0
iconst_1 1
iconst_2 2
iconst_3 3
iconst_4 4
iconst_5 5
Non esistono altri tipi di comandi iconst.
threadSuspended = false;
// 29 58:aload_0
// 30 59:iconst_0
// 31 60:putfield #42 <Field boolean threadSuspended>
Ecco un semplice assegnamento: notiamo che il valore false e' in realta'
l'intero 0.
resize(15 * (i + 1), 50);
// 32 63:aload_0
// 33 64:bipush 15
// 34 66:iload_1
// 35 67:iconst_1
// 36 68:iadd
// 37 69:imul
// 38 70:bipush 50
// 39 72:invokevirtual #37 <Method void Applet.resize(int, int)>
Prima della chiamata alla funzione resize il compilatore produce una serie
di istruzioni necessarie a valutare le espressioni passate come argomenti di
resize.
bipush 15 Bipush inserisce un byte nello stack, nel caso particolare 15
iadd Somma due interi in cima allo stack e il risutato e' posto ancora
nello stack.
imul Funziona come iadd ma esegue la moltiplicazione
Anche in questo caso e' di fondamentale inportanza seguire i valori che si
susseguono nello stack (non mi stanchero' mai di dirlo).
setFont(new Font("TimesRoman", 1, 36));
// 40 75:aload_0
// 41 76:new #11 <Class Font>
// 42 79:dup
// 43 80:ldc1 #3 <String "TimesRoman">
// 44 82:iconst_1
// 45 83:bipush 36
// 46 85:invokespecial #23 <Method void Font(String, int, int)>
// 47 88:invokevirtual #39 <Method void Component.setFont(Font)>
new #11 Crea un nuovo oggetto, in questo caso della classe nella
posizione 11 del constant pool. Nello specifico si tratta di
un oggetto font.
Dopo la creazione e' necessario richiamare il costruttore dell'oggetto con i
propri parametri.
Dove devono risiedere questi parametri?? Ma nello stack naturalmente! Cosi' si
spiega il dup che duplica tutto cio' che si trova in testa allo stack (in
questo caso il reference all'oggetto font appena creato) e la serie di
aggiustamenti prima della chiamata al costruttore font() mediante
invokespecial #23 E' utilizzato, con le stesse regole di invokevirtual, per
richiamare il costruttore di un'istanza di una classe. In
particolare la posizione 23 del constant pool contiene
l'inizializzatore della classe font.
addMouseListener(this);
// 48 91:aload_0
// 49 92:aload_0
// 50 93:invokevirtual #24 <Method void Component.addMouseListener(MouseListener)>
// 51 96:return
Questa procedura informa la JVM che l'applet intercetta gli eventi relativi al
mouse. Da notare come il riferimento alla variabile locale 0 venga usato anche
come riferimento all'oggetto this (cioe' all'istanza della stessa classe che
si sta definendo).
return Termina il metodo ed elimina tutto cio' che e' contenuto nel
frame associato al metodo. Tipicamente si usa quando il metodo
ha alcun parametro in uscita.
public void destroy()
{
removeMouseListener(this);
// 0 0:aload_0
// 1 1:aload_0
// 2 2:invokevirtual #35 <Method void Component.removeMouseListener(MouseListener)>
// 3 5:return
}
Il distruttore si limita a rilasciare l'intercettazione degli eventi del
mouse. Da sottolineare l'uso del reference this.
public void start()
{
runner = new Thread(this);
// 0 0:aload_0
// 1 1:new #20 <Class Thread>
// 2 4:dup
// 3 5:aload_0
// 4 6:invokespecial #22 <Method void Thread(Runnable)>
// 5 9:putfield #38 <Field Thread runner>
runner.start();
// 6 12:aload_0
// 7 13:getfield #38 <Field Thread runner>
// 8 16:invokevirtual #41 <Method void Thread.start()>
// 9 19:return
}
Nulla da dire. Dovreste essere in grado di decifrare il bytecode di questo
metodo.
Se non ci riuscite... beh... rileggete dall'inizio :D
public synchronized void stop()
{
runner = null;
// 0 0:aload_0
// 1 1:aconst_null
// 2 2:putfield #38 <Field Thread runner>
if(threadSuspended)
//* 3 5:aload_0
//* 4 6:getfield #42 <Field boolean threadSuspended>
//* 5 9:ifeq 21
{
threadSuspended = false;
// 6 12:aload_0
// 7 13:iconst_0
// 8 14:putfield #42 <Field boolean threadSuspended>
notify();
// 9 17:aload_0
// 10 18:invokevirtual #33 <Method void Object.notify()>
}
// 11 21:return
}
Questo stralcio di codice ci consente di esaurire il discorso sulle varianti
dell'if nel set di istruzioni della JVM:
ifeq 21 Se il contenuto dello stack e' nullo salta all'istruzione 21.
Le istruzioni if<cond> operano su interi secondo la tabella:
ifeq salta solo se il valore sullo stack e' 0
ifne salta solo se il valore sullo stack e' non 0
iflt salta solo se il valore sullo stack e' minore di 0
ifle salta solo se il valore sullo stack e' minore o
uguale a 0
ifgt salta solo se il valore sullo stack e' maggiore di 0
ifge salta solo se il valore sullo stack e' maggiore o
uguale a 0
Esiste anche una variante che opera comparando i due interi
in cima allo stack (chiamiamoli int1 e int2):
if_icmpeq salta se e solo se int1 e' uguale a int2
if_icmpne salta se e solo se int1 e' diverso da int2
if_icmplt salta se e solo se int1 e' minore di int2
if_icmple salta se e solo se int1 e' minore o uguale a int2
if_icmpgt salta se e solo se int1 e' maggiore di int2
if_icmpge salta se e solo se int1 e' maggiore o uguale
a int2
Un'ulteriore situazione prevede l'uso di if_acmpeq e
if_acmpne che operano sui reference. Chiaramente in questo
caso non sono previste le relazioni d'ordine.
E' importante ricordare che gli operandi vengono rimossi dallo
stack.
Il decompilato di NervousText continua, ma a questo punto dovreste essere in
grado di seguirlo da soli. Prima di concludere vi accludo il quadro sinottico
in cui sono riportati i codici memonici di ogni opcode a seconda del tipo di
dato su cui esso opera.
Per la maggior parte li abbiamo visti gia' in azione su determianti tipi. Per
gli altri vi rimando alla bibbia ;)
opcode |byte |short |int |long |float |double |char |reference
-------------------------------------------------------------------------------------
Tipush bipush sipush
Tconst iconst lconst fconst dconst aconst
Tload iload lload fload dload aload
Tstore istore lstore fstore dstore astore
Tinc iinc
Taload baload saload iaload laload faload daload caload aload
Tastore bastore sastore iastore lastore fastore dastore castore aastore
Tadd iadd ladd fadd dadd
Tsub isub lsub fsub dsub
Tmul imul lmul fmul dmul
Tdiv idiv ldiv fdiv ddiv
Trem irem lrem frem drem
Tneg ineg lneg fneg dneg
Tshl ishl lshl
Tshr ishr lshr
Tushr iushr lushr
Tand iand land
Tor ior lor
Txor ixor lxor
i2T i2b i2s i2l i2f i2d
l2T l2i l2f l2d
f2T f2i f2l f2d
d2T d2i d2l d2f
Tcmp lcmp
Tcmpl fcmpl dcmpl
Tcmpg fcmpg dcmpg
if_TcmpOP if_icmpOP if_acmpOP
Treturn ireturn lreturn freturn dreturn areturn
5.0 Conclusioni
-----------
Come da piu' parti e' stato rilevato i tecnici della sun che hanno progettato
java hanno badato esclusivamente alla portabilita' e alla sicurezza della JVM
ma non hanno tenuto in conto gli interessi degli sviluppatori.
Se e' vero che e' possibile "incasinare" il meccanismo di decompilazione e
"offuscare" i nomi dei metodi e delle variabili e' anche vero che pubblicare
un .class equivale a fornirne il sorgente. E' sintomatico il fatto che una
delle piu' interessanti applicazioni scritte in java (il programma
JavaZip 2.0) e' facilmente decompilabile con lo jad e addirittura banale da
sproteggere. Per quel poco di esperienza che ho posso affermare che almeno il
90% delle applicazioni sono pienamente decompilabili, un 5% ha come unica
protezione l'"offuscamento" dei simboli e l'altro 5% mette in difficolta' i
decompilatori. Tuttavia e' sempre possibile disassemblare una applet e, data
la "potenza" del set di istruzioni JVM, e' molto facile esaminare e modificare
il programma con un approccio molto simile al death-listing.
Bene... con questo mi sembra di aver esaurito questa breve guida.
Un consiglio: nel momento in cui decidete di cominciare a scrivere
applicazioni java tenete bene in mente questo punto debole.
LordFelix [Dislessici]
--------------------[ previous ]---[ index ]---[ next ]---------------------
=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
==============================================================================