/ Autore: Ettore Pasquini, sett.-ott. 1999
/
Sommario:
/
Obiettivo
/
Analisi di progetto
/
Implementazione e Codifica
1.1 The Machine
1.1.1 Segnali
1.1.2
Ciclo fetch-execute
1.2
Rappresentazione delle istruzioni
1.3
Code Segment e Data Segment
1.4
Registri e altri componenti hardware
2. L'applet
3. Sorgenti
4.
Possibili aggiunte e modifiche
Lo scopo di questo progetto non è solo la costruzione di un simulatore di una essenziale stack-machine, ma anche la definizione di un modello generale di "macchina": la barriera di astrazione che si vuole costruire dovrebbe mettere l'utilizzatore di fronte ad una entità dotata di caratteristiche, struttura e funzionamento simile a quello di un processore reale. Questo tipo di approccio viene suggerito dal paradigma O.O.: infatti, la macchina (l'oggetto del discorso) è per sua natura osservabile da 3 punti di vista:
Il simulatore deve permettere l’esecuzione di istruzioni di un predefinito instruction-set, simulando ciò che avviene nel contempo all’interno di un processore reale. Accanto al processore, ed in stretto legame con esso, avremo due ulteriori fondamentali unità, logicamente distinte tra loro: la memoria dove risiede il codice e la memoria dati. Ulteriori dispositivi di contorno (ad es. per catturare il concetto di I/O – da/verso console, disco, ecc. - o per una gestione cache della memoria, ecc.) sono irrilevanti al fine di rappresentare l’elemento essenziale del sistema, cioè la computazione: tuttavia, siccome verrà garantita l’estendibilità, questi elementi potrebbero essere aggiunti in futuro.
L’idea è che il generico oggetto "machine" sia un contenitore di componenti: registri, a.l.u., stack, ecc.. Tali dispositivi saranno interconnessi tra loro al fine di poter realizzare le operazioni del set (che si è già progettato), in un modo che non interessa a chi usa la macchina: al programmatore in linguaggio assembly interessa conoscere, oltre al significato di ogni istruzione, solo lo stato della macchina, che in genere viene rappresentato da opportuni registri ed eventualmente segnalato all’esterno (per es. in caso di un errore) attraverso opportuni "pin " di interfacciamento. D’altra parte, all’interno della macchina viaggiano – tra i componenti – molti segnali che non riguardano il mondo esterno.
La CPU deve avere accesso diretto alla memoria, che è conveniente distingure in due unità logiche: Code Segment e Data Segment. Nel primo dovremo memorizzare le istruzioni in un formato conveniente: siccome è spontaneo pensare ad una gerarchia estendibile di processori, via via più potenti (ad es. essi potrebbero avere un set esteso di registri general purpose; oppure incorporare 2 A.L.U., per inserire un livello di parallelismo; ecc. ecc.), è altrettanto spontaneo pensare ad un set di istruzioni estendibile in due modi: (1) con istruzioni più potenti e complesse, e (2) con una ridefinizione delle interconnesioni tra i componenti "hardware" per permettere una realizzazione più efficiente – che tenga conto dei componenti HW aggiunti – della stessa istruzione (per es. su una register machine con un vasto set di registri la ADD sarà cablata in modo assai diverso rispetto ad una primitiva macchina ad accumulatore).
Potremmo quindi definire (con un probabile abuso di linguaggio) un iniziale information-space del nostro Machine-Simulator:
E’ conveniente costruire una classe astratta "Machine" che catturi tutti gli elementi comuni: ad es., ogni cpu ha un Istruction Register, un Program Counter, un riferimento al codice e ai dati. Avremo inoltre un metodo fondamentale: go(), cioè il messaggio da inviare alla macchina affinchè essa inizi a computare, ossia ad eseguire il ciclo fetch – execute.
abstract class Machine{
protected ProgramCounter PC;
protected InstructionRegister IR;
protected CodeSegment CS;
protected DataSegment DS;
public abstract void init();
public abstract void go();
protected abstract void fetch();
protected abstract void execute( Instruction i );
public ProgramCounter PC() { return PC; }
public CodeSegment getCodeSegment() { return CS; }
public DataSegment getDataSegment() { return DS; }
public void setCodeSegment( CodeSegment CSref ) { CS=CSref; }
public void setDataSegment( DataSegment DSref ) { DS=DSref; }
}
La definizione di una "macchina" concreta procede quindi in 2 fasi principali:
PUSH PUSHC PUSHADD POP STORE ADD SUB MUL DIV AND OR IMPL JUMP JUMPLE JUMPLT JUMPGE JUMPGT JUMPEQ JUMPNE NOP HALT.
Tali istruzioni corrisponderanno ad opportune micro-sequenze di operazioni sui componenti, come ad es. il caricamento di un registro con un valore letto dallo stack, l’azionamento della a.l.u., e così via. Ma per ora osserviamo che in pratica abbiamo già stabilito l’interfaccia della macchina:
public class StackMachine extends Machine{
public class StackMachine extends Machine{
public StackMachine( String programstring ){
[inizializzazione dei componenti]
[caricamento del programma]
}
public void PUSH(int addr){...}
public void PUSHC(int val){...}
public void PUSHADD(int addr){...}
public void STORE(){...}
public void ADD(){...}
(...)
}
La StackMachine non ha registri da fornire al programmatore, a parte quello usato per accedere ad un’area di memoria gestita come uno stack. Tale accesso avviene attraverso le istruzioni di PUSH e le varie operazioni di a.l.u. (che implicitamente fanno riferimento ai valori salvati sullo stack). Pertanto risulta intuitivo fare uso di alcune microistruzioni - riusabli - per la scrittura delle istruzioni più complesse. Di conseguenza, fissato un minimo di 3 registri temporanei interfacciati con la a.l.u., e un Flag-Register per gestire i salti condizionati, la scrittura dei passi elementari di ogni istruzione risulta molto semplice: consideriamo ad es. il caso della ADD.
public classStackMachine extends Machine{
protected Register
TR1, TR2, TR3; //Temporary Registers
protected FlagRegister FR;
protected IntStack SR; //Lo stack principale
protected Alu ALU1;
(...)
public void ADD(){
SR.pop(TR2);
SR.pop(TR1);
ALU1.enable(Alu.add, TR1, TR2, TR3);
FR.setZ(TR3);
FR.setN(TR3);
SR.push(TR3);
PC.inc();
}
Per quanto riguarda lo stack, esso concettualmente è simile al data segment: tuttavia è conveniente riusare i metodi push() e pop() della classe java.util.Stack, ridefiniti considerando che l’I/O avviene attraverso registri (volendo, è poi possibile definire un’interfaccia totalmente funzionale ( pop(TRx), oppure pop(SRx, TRy) nel caso siano presenti più stack)). Caricati i registri TR1, TR2 (collegati agli ingressi della a.l.u.), basterà abilitarla con l’opportuno opcode per avere il risultato in TR3 (che "campiona" l’uscita della a.l.u.), da salvare poi sullo stack.
Infine vi sono altri due registri speciali: CS e DS, per accedere al codice e ai dati. Come sono organizzati questi due blocchi per ora non ci interessa: ci basta sapere che con opportune micro-istruzioni fetch(), read(TRx), write(TR1,TR2) possiamo, rispettivamente: caricare sull’Instruction Register la prossima istruzione, leggere il dato contenuto all’indirizzo puntato dal registro TRx, scrivere il dato contenuto nel registro TR1 nella cella di memoria puntata da TR2.
Resta infine da implementare il metodo go() che era stato dichiarato astratto nella classe Machine. A questo proposito bisogna prima stabilire come avvenga l'elaborazione all'interno della macchina. Ispirandosi alla realtà, si può pensare alla presenza di segnali il cui compito è appunto di fare funzionare i vari componenti in modo armonioso e coordinato, nonchè quello di gestire i vari segnali d'errore. Infatti, sebbene il compilatore costruisca un codice che si suppone corretto, la macchina è indipendente da esso, quindi deve essere in grado di gestire un insieme di run-time-error (ad es. un uso scorretto dello stack). A tale scopo, lavorando in Java, si può definire una opportuna gerarchia di eccezioni.
Avremo 2 categorie principali di segnali: messaggi, che segnalano l'avvenuta (e corretta) esecuzione di qualcosa; segnali d'errore (eccezioni ), in caso di problemi. Inoltre avremo un'altra distinzione, più particolare: segnali che non escono dalla macchina, e segnali che invece vengono spediti all'esterno.
class MachineSignal
extends Exception{}
class MachineMessage
extends MachineSignal{}
class MachineException
extends MachineSignal{}
Chi lancia questi segnali ? Saranno le istruzioni stesse, o più precisamente i componenti usati dalle istruzioni. E' facile intuire che l'accesso alla memoria è una sorgente "privilegiata" di errori.
class HaltMessage extends
MachineMessage{
/** Tale
Messaggio è quello inviato quando viene incontrata l'istruzione
*
HALT: la macchina esce dal ciclo fetch-execute (entra nell'idle state)
*/
}
class InvalidDataReadAddressException
extends MachineException{}
class InvalidDataWriteAddressException
extends MachineException{}
class InvalidCodeReadAddressException
extends MachineException{}
class EmptyMachineStackException
extends MachineException{}
class HaltNotFoundException
extends MachineException{}
class NotRecognizedInstructionException
extends MachineException{}
Una volta innescato, tale ciclo prosegue finchè non viene incontrata l'istruzione HALT, che invia un segnale di stop. Possono tuttavia verificarsi errori imprevisti, e in questi casi l’azione da prendere è quella di bloccare la macchina e segnalare l’errore.
public void go()
throws MachineSignal{
Instruction i;
try{
do{
fetch();
i = IR.contents();
execute(i);
}while( true );
}catch( HaltMessage
m ){
throw m;
}catch( InvalidDataReadAddressException
e ){
throw e;
}catch( ....
) ....
1.2 Rappresentazione delle Istruzioni.
Avremo una gerarchia di istruzioni
la cui radice sia la classe (astratta) Instruction.
Ogni macchina particolare dovrà avere il suo set di istruzioni:
avremo allora un’altra classe astratta StackMachineInstruction
che raggruppa tutte le istruzioni relative alla StackMachine. Ognuna delle
classi concrete dovrà avere un metodo exec(Machine
m) la cui semantica
è: "esegui l’istruzione Y sulla macchina M".
Più in particolare, le
gerarchie Machine ed Instruction verranno estese in parallelo:
Machine Instruction
| |
StackMachine - - - - - - - - - - - StackMachineInstruction
| |
StackMachineV2 - - - - - - - - - StackMachineInstructionExp
| |
… …
Le istruzioni della nostra Stack Machine si dividono in 2 gruppi: quelle a 0 operandi e quelle ad 1 operando. Possiamo quindi scrivere una gerarchia del tipo:
abstract class Instruction
implements Cloneable{
public abstract
void
exec( Machine m );
public Instruction
copia(){…}
/* vedi più avanti */
}//Instruction
abstract class StackMachineInstruction
extends Instruction{
}//StackMachineInstruction
class ZeroOpStackMachineInstruction
extends StackMachineInstruction{
public void exec(Machine
m) throws MachineSignal{}
}
class OneOpStackMachineInstruction
extends StackMachineInstruction {
int operand;
public OneOpStackMachineInstruction(int
op){ operand=op; }
public void exec(Machine
m) throws MachineSignal{}
}
class Push extends
OneOpStackMachineInstruction{
public Push(int
a){ super(a); }
public void exec(
Machine m ) throws InvalidDataReadAddressException{
((StackMachine)
m).PUSH(operand());
}
}//Push
class Pushc ……
Dal lato del creatore della lista di istruzioni del programma comunicato in input (tramite file o al limite da tastiera) può risultare comodo un’interfaccia funzionale del tipo make(String opcode, int[] operands), il cui significato è:
"crea un’Instruction la cui classe concreta si ricava dalla stringa opcode, e i cui operandi sono contenuti nel vettore operands ".
Questo risulta utile poichè
il programma è comunicato attraverso una stringa (l’output del compilatore
è un file di testo), il cui formato è:
<Opcode> <operands>
(…)
<Opcode>
<operands>
datablock
N
Quindi scriveremo:
public abstract class
Instruction
implements Cloneable{
(…)
public static
Instruction make(String opcode, int[] operands){
return new NotRecognizedInstruction();
}
}//Instruction
abstract class StackMachineInstruction
extends Instruction{
public static
Instruction make( String opcode, int[] operands){
if
(opcode.equals("push")) return new Push(operands[0])
;
if
(opcode.equals("pushc")) return new Pushc(operands[0]) ;
if
(opcode.equals("pushadd")) return new Pushadd(operands[0]);
(…)
return
Instruction.make(opcode, operands);
}
}//StackMachineInstruction
Come si può vedere, qualora l’istruzione letta non rientri nel set della StackMachine verrà creata un’istanza di "istruzione-non-riconosciuta", o meglio, pensando che il set potrà essere esteso (ad es. StackMachineV2InstructionExp relativo ad una StackMachineV2), la gerarchia Instruction verrà percorsa dal basso verso l’alto fino ad arrivare, in caso di insuccesso, alla radice, a cui corrisponde appunto "istruzione-non-riconosciuta". In tal modo l’espansione StackMachineInstructionExp si aggiunge semplicemente – ossia senza dover fare niente – al set precedente. Infatti il lexical-parser (metodo caricaCSDS della classe StackMachine) del file che contiene il programma in assembly language chiamerà un metodo makeInstruction() – che ogni sottoclasse di Machine deve ridefinire – contenente la chiamata al metodo make() della relativa classe nella gerarchia Instruction:
class StackMachine extends
Machine{
(…)
protected Instruction
makeInstruction(
String opcode, int[] operands){
return
StackMachineInstruction.make( opcode, operands);
}
(…)
}//StackMachine
In questo modo dal file in formato testo si passa ad una lista di istruzioni, istanze delle sottoclassi di Instruction, che la StackMachine può comodamente scorrere ed "eseguire".
1.3 Code Segment e Data Segment
Un segmento è un’area organizzata di memoria, schematizzabile come un vettore. Oltre al vettore, un segmento include per definizione un base-address che indica l’indirizzo assoluto di partenza dell’area di memoria coperta dal segmento stesso. Nel caso del codice l’indirizzo della prossima istruzione si ottiene con CS:PC (vedi la fetch()), l’accesso ai dati avverrà tramite DS:<addr>.
abstract class Segment{
private int base;
public int base(){
return base; }
public void setBase(int
newbase){ base = newbase; }
public abstract
int length();
public abstract
void init();
}
Il Code e il Data Segment aggiungono
i costruttori e metodi di lettura – scrittura. Per avere un accesso diretto
è conveniente usare la classe java.util.Vector (specializzata in
2 sottoclassi CodeVector
e DataVector).
class CodeSegment
extends Segment{
protected CodeVector
theprogram;
/**
*
costruttori
*/
public CodeSegment(
CodeVector V ){
setBase(0);
theprogram
= new CodeVector();
}
public CodeSegment(
CodeVector V, int codebase ){
setBase(
codebase );
theprogram
= new CodeVector();
}
/**
*
metodi istanza
*/
public int length(){
return theprogram.size();}
public void init()
{
theprogram.removeAllElements();
setBase(0);
}
public Instruction
read( ProgramCounter PC ) throws InvalidCodeReadAddressException{
return
theprogram.readInstructionAt(PC.contents());
}
(…)
}//CodeSegment
classCodeVector
extends Vector{
public CodeVector(){
super(0); }
public Instruction
readInstructionAt( int addr) throws InvalidCodeReadAddressException{
try{
return (Instruction) elementAt(addr);
}
catch( ArrayIndexOutOfBoundsException aioobe ){
throw new InvalidCodeReadAddressException();
}
}
}
[il DataSegment è
analogo]
1.4 Registri e Altri Componenti Hardware
Un Registro è un oggetto il cui stato è il valore memorizzato:
class Register{
protected int
contents;
public Register()
{ contents=0; }
public Register(int
c) { contents=c; }
public void reset()
{ contents=0; }
public int contents()
{ return contents;}
public void setContents(int
val){ contents=val; }
public String
toString() { return Integer.toString( contents );}
}//Register
Nel FlagRegister abbiamo due flag: Zero e Negative. Quindi FlagRegister è una sottoclasse di Register in cui ci interessa leggere il contenuto bit x bit. Dovremo definire quindi alcune operazioni speciali di lettura/scrittura:
classFlagRegister
extends Register{
//
// [..............NZ]
//
// Z: flag di
zero (=true se risult. Alt è 0)
// N. flag di
negativo (=true se risult. Alu è neg)
protected static
final byte Z_mask = 1;
protected static
final byte N_mask = 2;
public FlagRegister(){
super();}
public FlagRegister(boolean
newZ, boolean newN){
super(
(newN ? 2:0) + (newZ ? 1:0) );
}
public void reset(){
super.reset();}
public boolean
Z(){ return ( (contents() & Z_mask) ? true:false ); }
public boolean
N(){ return ( (contents() & N_mask) ? true:false ); }
public void setZ(
Register TR ){
setContents(
( (TR.contents()==0) ? (contents()|Z_mask):0 );
}
public void setN(
Register TR ){
setContents(
( (TR.contents()< 0) ? (contents()|N_mask):0 );
}
public String
toString(){ return Integer.toBinaryString(contents());}
}//FlagRegister
Anche il program counter eredita da Register: esso in più è dotato di un "circuito dedicato" di somma unitaria in modo da potersi autoincrementare:
class ProgramCounter
extends Register{
public void inc()
{ contents++; }
}//ProgramCounter
L’ instruction register è un registro particolare: il suo contenuto è un oggetto Instruction, letto all’indirizzo CS:PC. Per coerenza, sull’Instruction Register bisognerebbe avere una copia completa della generica istruzione letta dal code segment, non un semplice riferimento. Quindi si dovrà implementare l’interfaccia Cloneable nella classe Instruction. Nel complesso la sua interfaccia sarà leggermante diversa:
class InstructionRegister{
private Instruction
I;
public InstructionRegister(
Instruction
inst ){ … }
public void setContents(
Instruction
newIref ){ … }
public Instruction
contents(){ return I; }
public String
toString(){ return I.toString(); }
}//InstructionRegister
La ALU è un componente che viene "abilitato" (comando enable) quando sono stati caricati gli operandi sui registri di input (TR1 e TR2). L’uscita della ALU è nel registro TR3.
class Alu{
// definitions
of operation codes
public static
final byte add = 0;
public static
final byte sub = 1;
public static
final byte mul = 2;
public static
final byte div = 3;
public static
final byte and = 4;
public static
final byte or = 5;
public static
final byte impl= 6;
public Alu(){}
public void reset(){}
public void enable(byte
opcode,Register TR1,Register TR2,Register TR3){
switch(
opcode ){
case add: TR3.setContents( TR1.contents() + TR2.contents()
);
break;
case sub: (…)
(…)
}
}
}//Alu
Pensando alla realizzazione di un'interfaccia grafica, possiamo organizzare lo spazio in 3 blocchi:
Per vedere un esempio (essenziale)
di una possibile applet, si può cliccare qui.
Per la gerarchia Machine, nonchè
per gli altri componenti (registri, alu, stack), si veda: StackMachine.java;
per la gerarchia Instruction,
si veda: Instruction.java;
per la gerarchia Segment: CodeSegment.java
e DataSegment.java;
per la gerarchia dei segnali
lanciati dalla macchina, si veda: MachineSignal.java;
per un esempio di espansione
del sistema: StackMachineV2.java;
per un esempio di front-end essenziale,
sotto DOS: Testm.java;
per una minimale interfaccia
grafica, sia come applet sia come stand-alone application: SMSmgui.java.
per un file zip comprendente
i sorgenti del simulatore con i relativi bytecode già compilati:
sms.zip
4. Possibili aggiunte e/o modifiche
Servendosi della classe java.util.Vector
(vettori estendibili a run-time) lo spazio di indirizzamento della macchina
è infinito. Per gestire un più realistico spazio di indirizzamento
finito si potrebbe intordurre un controllo tramite eccezioni: data la memoria
disponibile, la condizione che deve essere verificata ad ogni istante è
che lo spazio occupato da codice, dati e stack non deve superare il totale
disponibile: questa è quindi un invariante della classe Machine.
Qualora si verifichi una violazione la macchina dovrà arrestare
il suo funzionamento e lanciare un opportuno segnale d'errore all'esterno.
Nell'attuale versione di StackMachine
non si ha mai né espansione né contrazione del memory-array
(in particolare, del DataSegment): infatti non sono gestite le chiamate
a procedura.