Creiamo insieme un microcomputer anni ’80 programmabile in Basic usando un FPGA economico

Supponiamo di voler realizzare un microcomputer anni 80; procedendo via hardware, in maniera tradizionale, siamo costretti ad utilizzare componenti commerciali; possiamo progettare il circuito come preferiamo, ma alla fine il nostro campo di azione sarà sempre limitato dalle caratteristiche dei componenti che abbiamo selezionato, soprattutto della CPU, ovvero il processore principale.

Il vantaggio degli FPGA è che ci consentono di superare questi limiti; possiamo progettare la nostra CPU, semplice o complessa che sia, partendo da nostre specifiche arbitrarie e creando un’architettura dedicata ad un’applicazione specifica; oppure replicarne una esistente, come faremo noi in questo articolo.

Gli unici limiti sono quelli imposti dalla nostra fantasia e, ovviamente, dalle caratteristiche dell’FPGA che abbiamo intenzione di utilizzare.

L’obbiettivo di questo articolo è imparare a progettare un softcore e i suoi chip associati (RAM, ROM, UART, I/O), realizzando da zero un microcomputer funzionante basato sul glorioso MOS-6502, presente nell’Apple II, Vic 20, Commodore C64 e tanti altri dispositivi dell’epoca.

Ho evidenziato il termine “funzionante” perché il nostro SBC (Single Board Computer) è in grado di ospitare l’interprete Basic di Microsoft ed essere programmato via seriale dal nostro PC, con un semplice emulatore di terminale. Troverete su GitHub cinque progetti pronti per altrettante board FPGA, tutte economiche.

Quindi, seguendo l’approccio che preferisco, vi darò nozioni base ma associate ad una realizzazione concreta.

L’architettura è molto semplice, tuttavia, per un principiante potrebbe risultare difficile affrontare direttamente la modellazione di un processore reale, e come in tutte le cose, conviene procedere per gradi: prima di partecipare all’America’s Cup è preferibile esercitarsi con una deriva (piccola barca a vela) sotto costa.

Per questo motivo, inizieremo nel progettare una piccola CPU didattica ma funzionante, la quale ci permetterà di prendere confidenza con i meccanismi interni di esecuzione delle istruzioni. Fatto questo la implementeremo in Verilog e simuleremo tutte le sue istruzioni con un software free (Icarus) il quale ci permetterà di verificare il lavoro svolto e prendere pratica con la simulazione di un FPGA.

Dopo questo primo passo, affrontare il progetto del nostro microcomputer sarà come bere un bicchier d’acqua.

Mi rendo conto che il doppio passaggio appesantisce la trattazione, facendo diventare questo articolo più simile ad un minicorso; però vi assicuro che un piccolo investimento della vostra pazienza all’inizio, vi farà risparmiare tanto tempo in seguito se vorrete immergervi nel mondo degli FPGA.

Prerequisiti e materiale introduttivo

L’articolo è rivolto a principianti che però abbiano almeno la conoscenza di base del funzionamento di un computer attraverso i suoi componenti (CPU, RAM, ROM, ecc.) e sappiano cosa sia un FPGA.

Se avete qualche dubbio niente panico, posso consigliarvi due articoli introduttivi che vi aiuteranno a colmare eventuali gap.

Il primo è un’introduzione agli FPGA per principianti assoluti che ho pubblicato un po’ di tempo fa, il secondo, scritto da Massimo Sanna, è un ottimo articolo, molto dettagliato e semplice, che spiega il funzionamento di una CPU e come emularla via software. Troverete i link alla fine dell’articolo.

Cominciamo.


Progettare da zero o replicare?

Ovviamente la risposta è: dipende dal nostro obbiettivo. Se è realizzabile con componenti commerciali è senz’altro meglio replicare. Se invece le nostre esigenze sono particolari perché magari abbiamo bisogno di una CPU molto piccola che vogliamo istanziare molte volte nello stesso FPGA o ancora, abbiamo bisogno di un set d’istruzioni specifico, allora dobbiamo progettare la nostra CPU da zero.

Una CPU non è un sistema isolato, per funzionare ha bisogno di codice macchina che qualcuno compili a partire da un linguaggio evoluto, per cui con una CPU arbitraria si pone il problema della toolchain, ovvero tutti i programmi di servizio per generare/gestire il codice macchina.

In realtà esistono dei sistemi in grado di generare un compilatore, o per lo meno lo scanner ed il parser a partire da una grammatica formale, per cui il compito potrebbe essere meno arduo di quello che si pensi; tuttavia, vi assicuro che non è banale.

Viceversa, per una CPU replica possiamo sfruttare tutto quello che esiste, se poi la CPU è molto diffusa abbiamo solo l’imbarazzo della scelta.

Il consiglio è quello di fare un’attenta analisi di quello che c’è in giro prima di imbarcarsi in compiti improbi.

Progettiamo mini-CPU

Progettare una CPU significa definire la sua architettura interna ed il suo set d’istruzioni.

Vediamo rapidamente i due concetti.

L’architettura di una CPU definisce come sono organizzate le sue risorse interne, registri, unità di calcolo, unità di controllo ecc., e stabilisce le modalità di accesso verso l’esterno, cioè la memoria e gli I/O.

Le due principali sono Von Neumann e Harvard; per semplificare, la differenza fondamentale è che nella prima architettura la memoria esterna contiene sia il codice da eseguire che i dati. La seconda, al contrario, prevede memorie separate.

Contenuto dell’articolo

Nessuna delle due architetture è “migliore” dell’altra, entrambe presentano vantaggi e svantaggi; come sempre tutto dipende dal campo di applicazione.

Il Set d’istruzioni, conosciuto come ISA (Instruction Set Architecture) è l’elenco delle istruzioni in codice macchina che la CPU è in grado di eseguire. Anche qui troviamo due architetture: CISC (Complex Instruction Set Computing) e RISC (Reduced Instruction Set Computing).

Non esistono delle specifiche formali, ed il confine, oggi, diventa sempre più sfumato. Sostanzialmente, nella famiglia CISC, un’istruzione è in grado di eseguire più operazioni e viene completata in più cicli di clock. Al contrario, le istruzioni RISC eseguono una o al massimo due operazioni, però sono più numerose e vengono eseguite in pochi cicli di clock, a volte persino uno.

Infine, le architetture CISC hanno pochi registri ma specializzati, mentre quelle RISC ne hanno un elevato numero, tutti (o quasi) generici e il set d’istruzioni è ortogonale.

A prima vista un processore RISC potrebbe sembrare meno potente, però la maggiore granularità delle istruzioni consente un’ottimizzazione molto spinta dei tempi di esecuzione, del prefetch ecc. e soprattutto permette di contenere i consumi.

Per fare un esempio, i processori Intel/AMD presenti nei nostri PC sono CISC, mentre quelli dei nostri smartphone sono tutti RISC.

Formalmente non ci sono relazioni fra l’architettura interna e quella del set d’istruzioni, nella realtà però troviamo due accoppiate abbastanza consolidate: Von Neumann – CISC e Harvard – RISC.

Il processore che andiamo a definire presenta un’architettura Von Neumann CISC a 8 bit. Sono presenti cinque registri, per semplificare, tutti ad 8 bit:

  • A: il registro accumulatore
  • PC: il Program Counter
  • SP: lo Stack Pointer
  • DP: il Data Pointer.
  • IR: L’Instruction Register.

L’accumulatore serve a contenere i risultati delle operazioni intermedie, è in pratica il factotum della nostra CPU.

Il Program Counter indicizza le istruzioni del programma da eseguire.

Lo Stack Pointer indicizza lo Stack (che vedremo in seguito).

Il Data Pointer e l’Instruction Register sono registri “nascosti”, nel senso che non ci sono istruzioni che possano modificarli esplicitamente, essi entrano in gioco quando abbiamo necessità di accedere alla memoria dati. La loro esistenza deriva direttamente dall’architettura Von Neumann: la memoria esterna è condivisa fra dati e programma quindi i BUS indirizzi e dati sono unici, per cui abbiamo necessità di due puntatori indipendenti (PC e DP) per gli indirizzi ed un buffer intermedio di ausilio all’accumulatore.

Contenuto dell’articolo

In questo schema mancano molte cose, tipo l’unità di controllo e l’unità aritmetico/logica, ma è sufficiente i nostri scopi.

Definiamo ora il Set d’istruzioni.

Contenuto dell’articolo

La prima colonna contiene il codice mnemonico, in sostanza il nome dell’istruzione, cioè quello che scriviamo nel nostro programma e che viene interpretato dall’Assembler (il compilatore per i programmi in linguaggio macchina); nella seconda troviamo l’opcode (codice operativo) che viene prodotto dall’assembler.

Se progettiamo una CPU arbitraria da zero possiamo decidere liberamente il valore degli opcode; al contrario, se stiamo implementando una CPU commerciale, ovviamente, dovremo acquisire questi valori dalla tabella delle istruzioni del costruttore, in questo caso ho utilizzato gli stessi opcode del 6502.

L’opcode è il livello di codifica più basso in assoluto di qualunque programma e per qualunque CPU; noi non lo vediamo direttamente e rappresenta il codice macchina, l’unica cosa che è in grado di comprendere la CPU.

Abbiamo nove istruzioni diverse, alcune fanno riferimento all’accumulatore e altre sono di controllo programma, cioè ne regolano il flusso.

Il linguaggio assembly è noto per essere 1:1, cioè ogni istruzione che scriviamo viene eseguita dalla CPU così com’è, senza ulteriori scomposizioni; tuttavia, osserviamo che ad alcuni codici mnemonici vengono associati due opcode (nella CPU 6502 si arriva fino ad otto opcode per istruzione), scopriamo il mistero.

Osserviamo la prima istruzione, LDA, che riempie l’accumulatore; le prime due domande che ci poniamo sono: con cosa? e, dove lo trovo?

Il “cosa”, a cui fanno riferimento le istruzioni di tutte le CPU è l’operando, il “dove” è il suo indirizzo e quindi la modalità con cui lo andiamo a recuperare.

Esistono due indirizzamenti: quello immediato e quello in memoria. Il primo prevede che l’operando abbia un valore costante e sia contenuto nell’istruzione, il secondo, o meglio la seconda famiglia, ha nell’istruzione i riferimenti utili per “raggiugere” l’operando in memoria.

Vediamo subito la differenza fra le due modalità.

Contenuto dell’articolo

Nel primo caso, #$19 indica il valore costante (#) del numero esadecimale ($) 19. Nel secondo caso non abbiamo il prefisso di costante e l’Assembler “capisce” che $19 non è un valore, bensì l’indirizzo a cui troveremo il nostro valore. Questo indirizzamento si chiama Assoluto perché l’indirizzo viene utilizzato così com’è senza ulteriori elaborazioni.

In sostanza, al termine dell’istruzione, nel primo caso in A troveremo 19, nel secondo troveremo il numero presente in memoria alla diciannovesima locazione.

Quando l’assembler produrrà il codice macchina per questa istruzione, utilizzerà, a seconda dei casi, due codici operativi differenti, in modo che la CPU possa capire se il valore a seguire è un numero costante o un indirizzo. Ecco spiegato il motivo per cui alcune istruzioni hanno opcode multipli: uno per ogni schema di indirizzamento.

Esistono infine istruzioni chiamate Implicite, o a zero operandi. Per esse non sono necessarie ulteriori informazioni: la CPU quando incontra il loro opcode sa già cosa fare. Ad esempio, PHA salva il valore dell’accumulatore nello Stack, non è necessario specificare altro.

In sostanza, il programma per una CPU è una serie di istruzioni memorizzate sequenzialmente, composte da un opcode seguito da 0, 1 o più operandi.

Contenuto dell’articolo

Supponiamo di voler realizzare un piccolo pezzo di codice che scriva il valore $F4 nella cella di memoria $02, preservando però il contenuto dall’accumulatore.

L’ipotetico Assembler simil-6502 (perché il nostro set d’istruzioni è simil-6502) produrrebbe la sequenza in figura.

Contenuto dell’articolo

Adesso che abbiamo il nostro programma in codice macchina, vediamo come viene eseguito dalla CPU.

Ciclo istruzione

Il ciclo istruzione sono tutte le operazioni che la CPU svolge per eseguire un’istruzione. Ovviamente queste operazioni non saranno tutte uguali dato che, come abbiamo visto, il numero degli operandi e la loro gestione varia da un’istruzione all’altra.

Riprendiamo il concetto del Program Counter (PC), il suo nome deriva dal fatto che è un registro che “punta” alla memoria programma, ovvero, in ogni istante contiene l’indirizzo di un opcode o del suo operando.

Tranne che nei casi di salto, esso procede sequenzialmente; vediamo intuitivamente cosa succede nelle prime fasi del programmino che abbiamo appena visto.

  • PC contiene il valore $A0 che è l’indirizzo del primo opcode; la CPU carica il codice operativo ed esegue direttamente l’istruzione (PHA) perché sa che non ci sono operandi.
  • PC viene incrementato per puntare alla prossima istruzione e quindi conterrà $A1.
  • La CPU carica l’opcode $A9 e, dopo averlo analizzato, capisce che è necessario acquisire una costante per completare le informazioni dell’istruzione.
  • PC viene incrementato e la CPU acquisisce l’operando (#$F4), a questo punto le informazioni dell’istruzione sono complete, per cui può essere eseguita.
  • PC viene incrementato per puntare alla prossima istruzione. E così via.

Quello che abbiamo appena visto prende il nome di ciclo FETCH-DECODE-EXECUTE (F-D-E), che può essere schematizzato come segue.

Contenuto dell’articolo

La fase Get Operand(s) viene eseguita solo se l’istruzione contiene uno o più operandi, quella di Store Result solo nel caso in cui il risultato debba essere conservato in memoria.

Stack

L’ultimo concetto necessario, prima di proseguire, è quello dello stack e le sue operazioni associate.

Lo Stack è una struttura dinamica contenuta in memoria conosciuta anche come LIFO (Last-In, First-Out), cioè l’ultimo elemento inserito è il primo ad essere estratto.

Allo stack sono associate due operazioni: PUSH e PULL (o POP).

Immaginatelo come un porta monete a molla. Ogni volta che inseriamo una moneta (PUSH) le monete già presenti scorreranno verso il basso. Quando abbiamo bisogno di una moneta, possiamo estrarre solo l’ultima inserita (PULL) e non quella che vogliamo.

Contenuto dell’articolo

In realtà, nello stack in informatica, i valori non subiscono alcuno spostamento, è SP (lo Stack Pointer) che si sposta verso l’alto o verso il basso per puntare al valore da estrarre o alla locazione in cui inserire il successivo.

Se abbiamo necessità di inserire o prelevare dei valori in posizioni arbitrarie, utilizziamo un qualunque metodo di indirizzamento che ci permette un accesso diretto a qualunque locazione di memoria, la quale però dev’essere identificata in qualche modo. Con lo stack, invece, non abbiamo necessità di specificare tale locazione, essa sarà sempre quella puntata da SP.

Lo Stack può “crescere” o “decrescere”, questa è una caratteristica specifica che dipende dal tipo di CPU.

Nel MOS 6502, lo stack decresce nelle operazioni di PUSH e cresce in quelle di PULL.

In pratica, quando viene eseguita l’istruzione PHA (Push A), il processore inserisce l’accumulatore nella locazione puntata da SP che poi viene decrementato. Viceversa, nell’esecuzione dell’istruzione PLA (Pull A), SP viene prima incrementato e quindi il valore a cui punta viene copiato in A.

PHA e PLA sono istruzioni Stack esplicite. Lo stack viene utilizzato anche implicitamente nelle istruzioni di salto a subroutine. Quando noi eseguiamo una subroutine mediante l’istruzione JSR (Jump to Subroutine), la CPU conserva nello stack il valore della prossima istruzione che avrebbe eseguito e quindi salta, ovvero carica nel program counter il nuovo indirizzo specificato come operando dall’istruzione. Terminata la subroutine, cosa che avviene in presenza dell’istruzione RTS (Return From Subroutine), la CPU recupera dallo stack l’indirizzo salvato e lo carica nel program counter.

In questo modo è possibile nidificare le subroutine come da figura.

Contenuto dell’articolo

Spieghiamo “PC+1”. Quando la CPU incontra l’istruzione JSR, incrementa PC e carica $C2. A questo punto PC contiene l’indirizzo della locazione di memoria contenente $C2. Quando terminerà la subroutine, dovremo ritornare all’indirizzo della locazione successiva (quindi PC + 1) che conterrà la prossima istruzione.

Ricapitoliamo quanto abbiamo visto.

  • Una CPU è caratterizzata dalla sua architettura interna e dall’architettura del set d’istruzioni in grado di eseguire.
  • Un programma eseguibile da una CPU, prodotto da un compilatore ad alto livello o un assembler, è composto da una sequenza di istruzioni in memoria.
  • Ogni istruzione contiene sempre un codice operativo, e, opzionalmente, può contenere uno o più operandi.

Ora che abbiamo progettato la nostra piccola CPU, diamo un’occhiata alle FSM.

FSM

La Macchina a Stati Finiti (FSM: Finite State Machine) conosciuto anche come Automa a Stati Finiti, è un modello di calcolo sincrono che trova infine applicazioni sia in informatica che nello studio dei processi.

C’è una letteratura molto formale e molto ampia associata alle macchine a stati. Per motivi di spazio, ma anche perché non è necessario, cercherò di essere molto sintetico e descrittivo limitandomi agli aspetti che ci interessano.

Una macchina a stati è un modello molto semplice ed efficace caratterizzato da due entità, gli stati e le transizioni. Un sistema rimane in uno stato finché non si realizza una condizione o si verifica un evento asincrono che lo fa transitare in un altro stato. Graficamente una FSM si formalizza, appunto, attraverso un diagramma stati-transizioni.

Se avete notato, finora, riferendoci al ciclo di vita delle istruzioni ci siamo sempre espressi in termini sequenziali-temporali: ora fai questo, poi fai quest’altro, ecc.

Questo modo di procedere a “passi predefiniti” trova la sua naturale modellazione proprio in una macchina a stati. Proviamo a farlo:

Contenuto dell’articolo

Come vedete ritroviamo lo stesso flusso visto in precedenza a meno di un particolare, il (macro)stato ASYNC. In tutte le CPU, ed il 6502 non fa eccezione, possono verificarsi eventi asincroni tipo gli Interrupt; questi vengono campionati in modo asincrono, cioè quando si verificano, ma vengono gestiti sempre al termine dell’istruzione corrente, in sostanza sono sincronizzati con il flusso del ciclo di vita delle istruzioni.

Vediamo cosa accade in un FPGA.

Esistono vari modi per modellare una FSM in Verilog, quello canonico prevede due “blocchi” di codice: quello sequenziale e quello combinatorio.

Quello sequenziale è sincrono al clock, si occupa di scandire il tempo, quello combinatorio effettua le operazioni mediante appunto logica combinatoria.

Contenuto dell’articolo

La comunicazione avviene mediante lo stato (da non confondere con il singolo stato che abbiamo visto prima) che è rappresentato da un set di registri, tanti quanti ne servono, che esistono in doppia copia: la parte corrente e quella successiva.

Nel progetto, per ogni registro, troverete la sua controparte out, la quale rappresenta il “next”; le parti di codice deputate al trasferimento sono i due task set_next() e set_current().

Questo modo di procedere può sembrare controintuitivo, o per lo meno inutilmente complesso, l’equivoco deriva dal voler considerare i registri come delle normali variabili di un linguaggio di programmazione.

In realtà essi sono fisicamente dei registri realizzati mediante le composizioni di flip-flop; quindi, devono avere sempre l’ingresso valorizzato ed il trasferimento dell’informazione dall’ingresso all’uscita deve sempre avvenire sincrono ad un clock. Inoltre, è caldamente sconsigliato collegare l’uscita di un registro direttamente al suo ingresso.

Mi rendo conto che l’argomento andrebbe approfondito parecchio, purtroppo non c’è modo di farlo ora, vi chiedo di fidarvi.  Alla fine del precedente articolo, quello introduttivo sugli FPGA, potete trovare i riferimenti per degli ottimi testi con cui approfondire questo concetto.

Implementazione del softcore mini-CPU

Ora siamo pronti per implementare la nostra mini-CPU in Verilog.

Premessa

Esistono vari modi per implementare una CPU. Il primo, completamente RTL (Register Transfer Logic) prevede la modellazione 1:1 dei componenti interni, ALU (Arithmetic Logic Unit), CU (Control Unit), registri ecc. in moduli separati.

L’esecuzione avviene mediante la scansione di una tabella “microcode” che contiene la descrizione delle istruzioni ed il loro ciclo di vita (carica opcode, carica operando, ecc.); la stessa presente in una CPU reale.

Il vantaggio di questo approccio, nel modellare una CPU commerciale, è che è difficile sbagliarsi, nel senso che si riproducono le unità interne ed il loro funzionamento così come le ha pensate, ottimizzate e debuggate il progettista della CPU. Inoltre, è una soluzione molto scalabile e modulare; Il risultato è un processore praticamente identico all’originale. Il termine associato a questo approccio è emulazione.

Lo svantaggio è che il risultato finale è abbastanza complicato da comprendere se si è alle prime armi.

Il secondo approccio è quello behavioral, ovvero ci si concentra sulla funzionalità della CPU tralasciando i dettagli interni: l’unico obbiettivo è che le istruzioni eseguite producano gli stessi risultati (negli stessi tempi) di quelle eseguite dalla controparte reale. Qui al contrario parliamo di simulazione.

Il vantaggio è che è molto più semplice da comprendere in quanto più vicino al nostro modo di pensare.

Lo svantaggio è che si perde completamente il contatto con l’hardware sottostante, e questo può presentare molte insidie, inoltre, dobbiamo essere molto attendi a gestire i cicli di clock, altrimenti corriamo il rischio che la nostra CPU non sia “exact-cycle”.

Per questa mini-CPU ma soprattutto per il softcore 6502 ho utilizzato volutamente un approccio misto. L’obbiettivo di questo lavoro è didattico ma allo stesso tempo è importante non perdere il contatto con l’hardware reale.

In sostanza ho replicato l’architettura lato registri, quindi ho utilizzato una piccola tabella di decodifica delle istruzioni, la quale però non ne comprende il ciclo di vita ma contiene solo i salti ai cicli della macchina a stati, per cui è molto intuitiva. In questo modo, come vedremo, le fasi di Fetch e Decode sono molto efficienti.

L’acquisizione degli operandi e la risoluzione degli indirizzi, secondo i vari schemi previsti (assoluto, indiretto, indicizzato ecc.) avviene completamente nella macchina a stati, in questo modo possiamo ritrovare, in modo evidente, tutti i concetti di fetch-decode-execute visti prima.

Il risultato è un softcore exact-cycle, spero molto più comprensibile.

Un’ultima considerazione.

Anche se più complesso, l’approccio RTL è il modo canonico di procedere, se deciderete di affrontarlo in un secondo momento, sono convinto che la sua comprensione risulterà molto più semplice dopo questo tutorial.  O magari, se è d’interesse comune, possiamo vederlo insieme in un prossimo articolo.

Entriamo nel vivo

Lo scopo della mini-CPU è quello di evidenziare il ciclo di vita delle istruzioni ricalcando esattamente quello che abbiamo visto nel precedente capitolo, per cui ho inserito la memoria all’interno del processore in modo da non disperderci con i segnali di interfacciamento. Questo si traduce nel fatto che il nostro modulo ha solo due linee di interfaccia: il clock ed il reset.

Fate riferimento ai sorgenti che trovate nella cartella miniCPU nel repository github (link in fondo all’articolo) ed aprite miniCPU.v con il vostro editor per una lettura più agevole con la sintassi colorata.

Al netto delle dichiarazioni delle costanti, dei commenti e del programma di esempio che ci permetterà di collaudare la CPU, il codice necessario occupa meno di 180 righe, non è tanto considerando che parliamo di una CPU piccola ma funzionante.

Prima parte: definizione costanti e dichiarazioni

Nella prima parte trovate le dichiarazioni delle costanti, è sempre buona norma evitare di inserire nei programmi dei numeri che fra qualche mese saranno incomprensibili.

In questa sezione troviamo l’elenco dei cicli macchina e quello degli opcode, non c’è davvero altro da aggiungere.

Successivamente c’è l’allocazione delle risorse interne, ovvero i registri. Ognuno esiste in doppia copia, come abbiamo visto, per gestire lo scambio di informazioni fra la parte sequenziale da quella combinatoria della macchina a stati.

Dopo questa parte dichiarativa inizia il codice vero e proprio.

Seconda parte: Task di esecuzione

Ne troverete tre.

  • execute_control()
  • execute_memory_register()
  • execute_register_memory()

I task sono delle strutture di codice che ci permettono di suddividere il nostro programma affinché sia più comprensibile, ma non sono delle procedure, le quali esistono in una sola copia in memoria e poi possono essere richiamate più volte.

Essi sono sostanzialmente delle macro, questo significa che il codice di un task viene ricopiato ogni volta che esiste una sua chiamata; è sempre preferibile (quando possibile) concentrare la chiamata ad un task in un unico punto per evitare un “non evidente” spreco di risorse.

Se analizziamo il flusso dei dati delle nostre istruzioni, possiamo individuare tre categorie.

La prima, di controllo, che non ha bisogno di scambiare dati, la seconda che durante la sua esecuzione copia i dati dalla memoria ai registri, infine la terza che copia i dati dai registri alla memoria.

L’identificatore di questa categoria è contenuto nella variabile direction ed esplicitato dalle costanti dir_MR, dir_RM e dir_CTRL.

// Direction
localparam dir_RM   = 2'b00; // Register -> Memory
localparam dir_MR   = 2'b01; // Memory -> Register 
localparam dir_CTRL = 2'b10; // Control Instruction (No Direction)

In definitiva, i tre task verranno richiamati in accordo a queste costanti nel ciclo CY_EXECUTE (che vedremo dopo in dettaglio)

Terza parte: FSM

A questo punto, finalmente, troviamo il cuore della nostra CPU: la macchina a stati, che, come abbiamo visto in precedenza, è composta da due parti: quella sequenziale e quella combinatoria.

La parte sequenziale, sincrona al clock, in questa CPU fa ben poco, si occupa di scambiare lo stato interno con la parte combinatoria e di verificare la linea di reset: qualora questa fosse attiva (livello logico basso) l’esecuzione verrebbe dirottata sul ciclo di reset (CY_RESET).

//===================================================================    
// FSM: Sequential
//===================================================================    
always @(posedge CLK) 
begin
    if (!RST_)
        cycle <= CY_RESET;
    else
        cycle <= next_cycle;
    // Shift latched values
    A <= A_out;
    PC <= PC_out;
    DP <= DP_out;
    SP <= SP_out;
    IR <= IR_out;
    opcode <= opcode_out;
    instruction <= instruction_out;
end

La parte combinatoria è quella che esegue il lavoro vero e proprio e ricalca il ciclo di vita delle istruzioni visto in precedenza, è composta principalmente da un’istruzione case nella quale verrà eseguito solo il ciclo attivo.

Ciclo di RESET

È sempre presente in qualunque macchina a stati, a prescindere che questa implementi una CPU o meno.

Come da specifica (di tutte le CPU) si permane in questo stato fintanto che il segnale è attivo, dopo di che si inizializzano i registri interni e si setta il Program Counter ad un indirizzo predeterminato.

In questa CPU saltiamo a $00, nel 6502 il PC dovrà contenere $FFFC, chiamato Reset Vector, che è la locazione nella quale troveremo l’indirizzo effettivo della prima istruzione dopo il reset. La sequenza di reset del 6502 è abbastanza complessa.

Una volta ottenuto l’indirizzo di partenza, possiamo partire con il ciclo infinito di FETCH-DECODE-EXECUTE.

CY_RESET: begin
    if (RST_) begin // Waits until RST_ is high again
        // Registers initialization
        SP_out = 8'hFF;
        A_out  = 8'h00;
        PC_out = 8'h00;
        DP_out = 8'h00;
        // Jump to the first Fetch
        next_cycle = CY_FETCH_DECODE;    
    end
end

Fase congiunta di FETCH-DECODE

Partiamo dall’ instruction table. Questa è una tabella che contiene 256 righe, tante quante sono le combinazioni possibili di un opcode a 8 bit (2^8 = 256).

// Instruction Table
reg[7:0]  itable[0:255];
reg[7:0]  instruction     = 8'd0;  // instruction record
reg[7:0]  instruction_out = 8'd0;

// Instruction fields
`define   direction    instruction[7:6]
`define   cycle_start  instruction_out[5:0]

Ogni riga contiene il record descrittore di un opcode (che ho chiamato instruction), cioè le informazioni necessarie all’esecuzione dell’istruzione; nella mini-CPU troviamo solo due campi, nel softcore 6502 ce n’è qualcuno in più perché vengono implementati tutti gli schemi di indirizzamento.

Il suo utilizzo concettualmente è molto semplice e lo esprimo in modo discorsivo:

La CPU carica l’opcode dalla memoria (fase di FETCH), a questo punto ha necessità di capire chi ha di fronte e cosa fare (DECODE), per fare questo utilizza l’opcode come indice della itable ed estrae il descrittore. Esattamente quello che facciamo in un qualunque linguaggio di programmazione quando recuperiamo i dati dalla i-esima posizione di un array.

Il descrittore dice alla CPU la direzione dei dati (memoria registro, registro memoria o controllo) ma soprattutto contiene il ciclo (lo stato macchina) a cui saltare per eseguire l’istruzione associata. Il nostro ciclo congiunto di FETCH-DECODE è quindi tutto racchiuso in queste quattro righe.

CY_FETCH_DECODE: begin
    // Fetch
    opcode_out = mem[PC];
    // Decode (get Instruction Info)
    instruction_out = itable[opcode_out];
    // Increment PC for pointing to the next byte
    PC_out = PC + 8'd1;
    // Jumps to the first Instruction sequence step
    next_cycle = `cycle_start;
end

L’array itable va inizializzato in questo modo (lo trovate alla fine del programma):

// Init the instruction Table
itable[LDA_I] = {dir_MR,   CY_GET_IMMEDIATE};
itable[LDA_M] = {dir_MR,   CY_GET_OPERAND};
itable[STA_M] = {dir_RM,   CY_GET_OPERAND};
itable[ADC_I] = {dir_MR,   CY_GET_IMMEDIATE};
itable[ADC_M] = {dir_MR,   CY_GET_OPERAND};
itable[NOP  ] = {dir_CTRL, CY_EXECUTE};
itable[JMP  ] = {dir_MR,   CY_GET_IMMEDIATE};
itable[JSR  ] = {dir_MR,   CY_GET_IMMEDIATE};
itable[RTS  ] = {dir_CTRL, CY_EXECUTE};
itable[PHA  ] = {dir_CTRL, CY_EXECUTE};
itable[PLA  ] = {dir_CTRL, CY_EXECUTE};

Si tratta semplicemente di riempire l’array andando ad inserire ad ogni riga indicizzata dall’opcode (del quale abbiamo già la costante) le informazioni necessarie.

Vi spiego il costrutto Verilog molto potente della concatenazione perché lo troverete fino alla nausea.

La struttura {variabile 1, variabile 2, …} ha come risultato la concatenazione geometrica delle sue componenti, cioè una variabile la cui lunghezza è uguale alla somma della lunghezza delle sue parti. Il concetto, come funzionamento, è esattamente identico alla concatenazione di stringhe che incontriamo nei linguaggi evoluti:

“hello “+”world” -> “hello world”.

Per concludere, durante la fase di inizializzazione la tabella viene prima riempita con i descrittori:

itable[i] = {2'b00, CY_ILLEGAL_OPCODE};

Il motivo è semplice: non abbiamo 256 opcode diversi, nemmeno nella CPU 6502, per cui è necessario capire cosa fare se per errore (in genere un salto sbagliato) la CPU preleva un opcode non implementato. In questo caso l’esecuzione si sposta nel ciclo di CY_ILLEGAL_OPCODE, dove potremo decidere come gestire la situazione. Nell’implementazione di una CPU commerciale dovremo seguire il datasheet del costruttore.

CY_ILLEGAL_OPCODE: begin
    next_cycle = CY_ILLEGAL_OPCODE; // stay here forever
end

Fase di EXECUTE: recupero operando

Terminate le fasi di Fetch e Decode abbiamo la necessità di recuperare l’operando, il quale, come abbiamo visto, può essere immediato e quindi racchiuso nell’istruzione stessa, oppure indicizzato, per cui l’istruzione conterrà il suo indirizzo.

L’acquisizione dell’operando o del suo indirizzo avviene nei seguenti cicli:

// Get the operand address
CY_GET_OPERAND: begin
    DP_out = mem[PC];     // <-- Get the address
    next_cycle = CY_EXECUTE;
end
// Get the operand value
CY_GET_IMMEDIATE: begin
    IR_out = mem[PC];    // <-- Get the value
    next_cycle = CY_EXECUTE;
end

Nel primo caso ne acquisiamo direttamente il valore che memorizziamo nel registro IR, nel secondo caso acquisiamo solo il suo indirizzo che memorizziamo in DP. Non possiamo ancora prendere il valore perché potremmo trovarci in presenza di un’istruzione Register->Memory nella quale noi utilizzeremmo l’indirizzo per scrivere e non per leggere.

Fase di EXECUTE: esecuzione dell’operazione

È la fase finale del ciclo istruzione, arriviamo qui dopo aver acquisito l’operando o il suo indirizzo, oppure direttamente da FETCH-DECODE se la nostra istruzione non ha operandi. A questo punto, come abbiamo visto in precedenza, ci serve la variabile direzione per selezionare il task adeguato.

CY_EXECUTE: begin
    case(`direction)
        dir_CTRL: execute_control();
        dir_MR: begin
            PC_out = PC + 8'd1;
            execute_memory_register();
        end
        dir_RM: begin
            PC_out = PC + 8'd1;
            execute_register_memory();
        end
    endcase
    next_cycle = CY_FETCH_DECODE; // On the road again...
end

Nota: Non incrementiamo PC in concomitanza di un istruzione di controllo perché, non avendo operandi, PC punta già all’istruzione successiva.

TASK Memory->Register

Secondo la suddivisione che abbiamo fatto, In questo task, trovano posto tutte le istruzioni che modificano un registro a partire da un valore costante o dal suo indirizzo.

Nel primo caso faremo riferimento direttamente al registro IR, nel secondo utilizzeremo il registro DP per recuperare il valore dalla memoria.

task execute_memory_register();
begin
    case(opcode)
        // Loads an immediate value to the accumulator
        LDA_I: begin
            A_out = IR;
        end
        // Loads a memory value to the accumulator
        LDA_M: begin
            A_out = mem[DP];
        end
        // Jumps to a new location
        JMP: begin
            PC_out = IR;
        end
        // Adds a constant to the accumulator
        ADC_I: begin
            A_out = A + IR;
        end
        // Adds a memory value to the accumulator
        ADC_M: begin
            A_out = A + mem[DP];
        end
        // Jump to subroutine
        JSR: begin
            mem[SP] = PC + 8'd1; // Push return address (PC + 1)
            SP_out = SP - 8'd1;  // Decrements the Stack Pointer
            PC_out = IR;         // Loads the new address
        end
    endcase
end
endtask

TASK Register->Memory

Questo task è più piccolo, contiene una sola istruzione, la memorizzazione dell’accumulatore in memoria, per cui utilizzeremo il registro DP come puntatore.

task execute_register_memory();
begin
    case(opcode)
        // Store the accumulator in the memory
        STA_M: begin
            mem[DP] = A;
        end
    endcase
end
endtask

TASK CTRL

In questo task trovano posto tutte le istruzioni che non hanno operandi, chiamate anche implicite. Il loro comportamento è completamente descritto dall’opcode.

task execute_control();
begin
    case(opcode)
        NOP: begin
            // Nothing to do 
        end
        // Return from subroutine
        RTS: begin
            SP_out = SP + 8'd1;
            PC_out = mem[SP_out];
        end
        // Push Accumulator
        PHA: begin
            mem[SP] = A;
            SP_out = SP - 8'd1;
        end
        // Pull Accumulator
        PLA: begin
            SP_out = SP + 8'd1;
            A_out = mem[SP_out];
        end
    endcase
end
endtask

Test della mini-CPU

A questo punto dobbiamo capire se tutto quello che abbiamo scritto funziona correttamente; per fare questo dobbiamo progettare il test.

Ho evidenziato il termine progettare perché questa è una fase molto importante e va pianificata a dovere; il test deve essere quanto più esaustivo possibile, eseguire un test alla rinfusa potrebbe produrre informazioni ridondanti e nasconderne altre essenziali.

Nel caso di una CPU dobbiamo provare tutte le istruzioni, anche quelle che all’apparenza “sembrano scritte bene”. Il mio consiglio è quello di partire dalle istruzioni di spostamento dati fra registri, in questo modo possiamo iniziare a testare il fetch ed il decode. Successivamente passiamo alle istruzioni che coinvolgono la memoria e infine quelle più complesse.

Per fare questo test abbiamo quindi necessità di scrivere un programma ad hoc per la nostra CPU, non in assembly, ma direttamente in codice macchina; state tranquilli, dopo aver progettato la CPU, che ora conosciamo bene, è un’attività abbastanza semplice.

Il programma lo andremo a scrivere a partire dall’indirizzo post-reset, nel nostro caso $00. Sostanzialmente andiamo a riempire la memoria con una sequenza di istruzioni. Trovate tutto nella sezione di inizializzazione alla fine del file. Ne riproduco solo un piccolo pezzo.

//-----------------------------------------------------
// A small program to test all the instructions
//-----------------------------------------------------
//                          checkpoint
// Load 4F in A             
mem[8'h00] = LDA_I;        
mem[8'h01] = 8'h4F;         // A = 4F 
// Store A in $21
mem[8'h02] = STA_M;         
mem[8'h03] = 8'h21;       
// Clear A
mem[8'h04] = LDA_I;
mem[8'h05] = 8'h00;         // A = 00
// Load $21 in A to verify the previous store
mem[8'h06] = LDA_M;
mem[8'h07] = 8'h21;         // A = 4F

Per testare il comportamento della nostra piccola CPU non abbiamo necessità di un FPGA fisico, possiamo utilizzare un simulatore.

Nell’articolo introduttivo sugli FPGA (link alla fine dell’articolo) ho spiegato a grandi linee il processo di simulazione, qui lo possiamo vedere in pratica.

Nella cartella /miniCPU trovate il file miniCPU_tb.v; il mondo FPGA è fatto di specifiche e abitudini/convenzioni che aiutano a riconoscere le cose a colpo d’occhio: <nome file>_tb conterrà sempre il testbench associato a <nome_file>.

La nostra CPU ha solo due linee di interfaccia: clock e reset. Il simulatore, lo ricordo, fornisce i segnali di ingresso e acquisisce i segnali di uscita dal nostro modulo secondo lo schema prestabilito nel programma testbench. Diamo un’occhiata a quello nostro, che peraltro molto semplice.

`timescale 10ns/10ps
`include "miniCPU.v" 
module miniCPU_tb;

reg clk;
reg rst_;

always 
    #1 clk = ~clk; // Clock generator

// mini-CPU instance
miniCPU CPU(
	.CLK(clk),
	.RST_(rst_)
);

initial begin
    $dumpfile("signals.vcd"); // Name of the signal dump file
    $dumpvars(0, miniCPU_tb); // Signals to dump
    clk     = 1'b0; // *Everything* needs to be inited before a simulation
    rst_    = 1'b0; 
    #2 rst_ = 1'b1; // Reset remains LOW for two time units
    #85;  // Wait 85 time units (meanwhile the clock is running)
    $finish(); 
end

endmodule

In testa al file c’è l’indicazione della nostra unità di tempo / la risoluzione.

#N indica un’attesa di N unità di tempo.

Il clock viene generato molto semplicemente invertendo il registro clk ogni unità di tempo, mentre il segnale di reset rimane basso per due unità di tempo alla partenza. Risulta subito evidente che il ciclo del nostro clock è di 20 ns, per cui la nostra CPU lavora a 50 MHz.

Per la simulazione ho utilizzato Icarus, un software free e molto semplice, vi consiglio di installarlo (trovate il link in fondo all’articolo) perché è agnostico, veloce e vi permette di testare il vostro codice Verilog in modo molto flessibile.

Il processo è diviso in due fasi, simulazione con Icarus, il quale salva dei files risultati, e la loro visualizzazione mediante GTKWave, un altro programma free che viene installato automaticamente in bundle con Icarus.

Ho preparato due files batch per facilitarvi la vita; aprite una finestra CMD o utilizzate il TERMINAL se lavorate con Visual Studio Code.

Per simulare la CPU lanciate: ./iv miniCPU

Per visualizzare i dati dopo la simulazione: è sufficiente lanciare ./gv senza parametri.

GTKWave, una volta partito, ha necessità della lista di tutte le variabili che si vogliono visualizzare. Per agevolarvi ho preparato un template (miniCPU.gtkw) che potete caricare in File->Read Save File.

Il risultato lo vedete in figura.

Contenuto dell’articolo

Questo è un dettaglio

Contenuto dell’articolo

Se non riuscite a vedere bene l’immagine potete visualizzare quella che ho salvato nella cartella /miniCPU su github.

Non spaventatevi, tenete sempre come riferimento la traccia opcode che deve corrispondere esattamente alla sequenza delle istruzioni che abbiamo scritto.

Analizzate il susseguirsi dei valori nei registri e confrontateli con i checkpoint, cioè i valori che dovete aspettarvi dopo l’esecuzione di ogni istruzione, e che dovete aver cura di preparare in fase di pianificazione del test.

Procedete ordinatamente e scrivete tutto, riuscirete a simulare in questo modo qualunque CPU voi scriviate.

Implementazione del nostro SBC

Per rendere più evidente la trasposizione Hardware->FPGA, ho utilizzato il seguente approccio.

Per prima cosa ho realizzato lo schema elettrico inserendo tutti i componenti (simil) commerciali necessari; quindi, li ho implementati in Verilog e li ho interconnessi.

Le specifiche del nostro SBC sono:

  • CPU MOS 6502
  • 16K ROM (simil 27128)
  • 32K RAM (simil 62256)
  • 1 byte DI + 1 byte DO
  • UART (simil 6850) per comunicare con l’esterno.

Questo è lo schema, se non è perfettamente visibile potete visionare il file PDF nella cartella /Schematic

Contenuto dell’articolo

Analizzando lo schema elettrico noterete immediatamente qualcosa di strano: le memorie (RAM e ROM) hanno la linea di clock, mentre sappiamo bene che questi componenti, nella realtà, sono completamente asincroni.

Il motivo dipende dalla gestione delle risorse in un FPGA.

L’FPGA contiene dei blocchi specializzati, chiamati BRAM, pensati proprio per essere utilizzati nell’implementazione di memorie. Affinché però il sintetizzatore utilizzi questi blocchi, è necessario che la loro gestione sia sincrona al clock.

È possibile implementare delle memorie completamente asincrone, andando ad emulare perfettamente i componenti commerciali tipo la M62256 (RAM da 32K), in questo caso però esse verranno sintetizzate utilizzando i registri e non i BRAM. E questo, oltre a rappresentare uno spreco di risorse, in molti dispositivi non consentirebbe l’allocazione completa di tutta la memoria che a noi serve.

Seconda stranezza: la ROM presenta dei pin ausiliari all’interno del contesto “sd-option”.

Questa non è una costrizione tecnica ma una comodità che ho introdotto.

Quando si realizza un SBC abbiamo necessità di programmare la ROM caricando al suo interno un bios o, come vedremo nel nostro caso, l’interprete Basic.

Questa attività è abbastanza semplice, è sufficiente trasformare il nostro file binario in un file testuale contenente tutti i byte in formato esadecimale ed istruire il tool di sviluppo, mediante l’istruzione $readmemh, a caricarlo nella nostra memoria.

Lo svantaggio di questo approccio è che se siamo in fase di debug del bios ed abbiamo necessità quindi di fare frequenti modifiche, saremo costretti ogni volta a trasformare il file, ricompilare tutto il progetto e trasferirlo nell’FPGA; operazione che con un PC non velocissimo ed un sistema di sviluppo tipo Xilinx Vivado, può portar via parecchi minuti.

Quindi, per semplificarci la vita, ho realizzato una ROM che si auto-inizializza alla partenza andando a caricare il bios direttamente da un file presente in una micro-SD (anche shdc).

Per effettuare delle prove quindi, è sufficiente assemblare il bios e copiare il file binario su una micro-SD formattata FAT32 senza dover toccare il progetto FPGA.

Lo svantaggio è che avete necessità di un adattatore micro-SD a 3,3V, tipo quello in figura, da collegare all’FPGA, il quale comunque può essere reperito in rete per pochi euro.

Contenuto dell’articolo

Ho implementato entrambe le versioni di ROM. Nel software che trovate su github, il progetto per Tang Nano 20K utilizza la micro-SD perché questo dispositivo ne comprende già l’adattatore. Tutti gli altri progetti caricano il file esadecimale in fase di compilazione.

Altra considerazione. Ho mappato l’UART all’indirizzo $A000, in questo modo, per provare la scheda, è possibile utilizzare l’interprete Basic Microsoft modificato da Grant Searle per i propri progetti, senza la necessità di scriversi un bios in Assemby 6502. Troverete tutti i files nella cartella /OSI-Basic.

Infine, non poteva mancare un circuito di Power On Reset (POR) per inizializzare correttamente il sistema all’accensione. Nei vecchi microcomputer economici era rappresentato da una rete R-C, la versione luxury prevedeva anche un Trigger di Smith.

Noi qui non possiamo gestire componenti analogici, per cui ho realizzato un circuito che mantiene bassa la linea di reset all’accensione per un numero di millisecondi impostabili.

Ricapitolando, il nostro SBC, oltre al reset all’accensione, è dotata di due linee di reset esterne: COLD RESET e WARM RESET. La prima è presente solo con l’opzione SD e fornisce lo start alla ROM a caricare i dati dalla micro-SD; durante tutto il caricamento, la ROM mantiene basso un segnale di BUSY che blocca la CPU nello stato di reset. Il caricamento dei dati da SD comunque avviene automaticamente all’accensione.

Il secondo reset, presente in entrambe le opzioni, agisce direttamente sulla linea di reset della CPU.

Diamo un’occhiata ora ai nostri IP (Intellectual Property), che lo ricordo, è la denominazione storica dei moduli autoconsistenti implementati per un FPGA.

Implementazione del softcore MOS 6502

Il 6502 è una CPU commerciale, per cui non possiamo prenderci le stesse libertà di mini-CPU, dobbiamo seguire fedelmente i datasheet del costruttore.

In mini-CPU abbiamo inserito una memoria generica all’interno del chip, qui le nostre memorie sono esterne, per cui dobbiamo occuparci della temporizzazione dei segnali di controllo.

Non vedremo qui in dettaglio il funzionamento del 6502, esistono dei libri, anche abbastanza corposi che lo fanno. In fondo all’articolo troverete il link ad un portale che contiene un’infinità di informazioni: datasheet, analisi dettagliata delle istruzioni ecc.

Il 6502 è un processore dalla doppia anima: semplice come architettura interna ma complesso dal punto di vista della temporizzazione. Ha inoltre un ricchissimo set di schemi di indirizzamento realizzati mediante due registri indice.

È tuttora in commercio ed è possibile anche acquistare dal produttore il softcore ed inserirlo, come core “lite”, all’interno chip più complessi per eseguire delle funzioni ausiliarie.

Dopo la trattazione completa di mini-CPU, andrò avanti rapidamente nella descrizione di questo softcore, evidenziando solo le differenze principali e gli aspetti più salienti che ho tralasciato in mini-CPU per semplificare l’esposizione.

Prima di strutturare la macchina a stati o la rom microcode, il primo problema da affrontare nell’implementazione di una CPU è la gestione del clock; bisogna comprendere a fondo come viene eseguito il ciclo F-D-E in rapporto al clock di sistema.

Gestione del Clock

Purtroppo, la gestione del clock del 6502 è tutt’altro che intuitiva. Alcune scelte possono sembrare poco eleganti (e in realtà lo sono), ma non dimentichiamoci del contesto: il 6502 doveva costare poco e potersi interfacciare con le periferiche di metà anni 70.

Il 6502, grazie ad un clock a doppia fase generato internamente, effettua operazioni sia durante la fase alta che quella bassa del clock. Inoltre, tali operazioni non sono equamente distribuite, la maggior parte di loro avviene alla fine, a cavallo del ciclo successivo.

Il motivo deriva dal voler fornire tutto il tempo possibile alle memorie e agli I/O che, all’epoca, avevano dei tempi di setup e hold abbastanza alti. Osservate l’istante di campionamento dei dati dalla RAM nella figura in basso a destra: avviene sul fronte di discesa di PHI2, mentre su quello di salita di PH1, praticamente in contemporanea, si preparano gli indirizzi per la fase successiva.

Contenuto dell’articolo

Il 6502 sfrutta i ritardi interni, questo è il motivo per cui non è un chip statico, nel senso che funziona solo in un determinato range di frequenze. I softcore, per loro natura, ed il nostro non fa eccezione, sono sempre statici, e possono essere fermati in qualunque momento fornendo un clock a frequenza zero senza pregiudicarne il funzionamento.

Primo problema: la nostra FSM sequenziale può lavorare sul fronte di salita del clock (posedge clk) oppure su quello di discesa (negedge clk), ma non contemporaneamente su entrambi.

Per lavorare su entrambi i fronti abbiamo necessità di due costrutti “always” separati e indipendenti; è possibile farlo però questi dovranno lavorare su registri separati e ciò ci creerebbe parecchi fastidi.

Lavorare su un solo fronte, con memorie sincrone, purtroppo non è sufficiente, infatti, se ad esempio dobbiamo leggere un valore dalla memoria abbiamo bisogno almeno di due fasi:

  1. emettere l’indirizzo e settare le linee di controllo
  2. leggere il bus dei dati

Le due fasi, inoltre, è necessario che siano separate da un tempo congruo a quello operativo della memoria.

Per risolvere questo problema ho creato il seguente schema di clock.

Contenuto dell’articolo

Sono partito da un clock di frequenza quadrupla rispetto a quella di lavoro della CPU; a questo clock (sysck) ho associato un contatore perpetuo a 3 bit (ck).

A partire dai bit del contatore, con delle semplici associazioni, ho creato PH1, PH2 e PH3.

PHI1 e PHI2 sono le fasi canoniche del 6502, PHI3 è il clock della nostra macchina a stati composto da due parti attive: T0 e T1, poste a cavallo del fronte di salita di PHI2.

La logica è la seguente:

Ogni ciclo della macchina a stati verrà eseguito due volte e, a seconda del tipo di istruzione, se è attivo T0 farà alcune operazioni, se invece è attivo T1 ne farà altre.

Se la nostra istruzione è del tipo Memoria->Registro caricheremo l’operando in T0 ed effettueremo le operazioni in T1.

Se viceversa la nostra istruzione è del tipo Registro->Memoria, eseguiremo prima l’istruzione in T0, la quale produrrà un valore che successivamente scriveremo in T1.

Le istruzioni Memoria->Memoria, a causa del doppio trasferimento, il primo in lettura ed il secondo in scrittura, necessiteranno di due cicli gestiti come segue.

  • T0 – primo ciclo: carichiamo l’operando conservando il suo indirizzo.
  • T1 – primo ciclo: eseguiamo l’operazione e conserviamo il risultato.

A questo punto abbiamo il risultato ed il suo indirizzo in memoria, è sufficiente predisporre il bus degli indirizzi, il bus dei dati e la linea di write, per cui:

  • T0 – secondo ciclo: attiviamo la linea di write.
  • T1 – secondo ciclo: disattiviamo la linea di write.

Anche nel 6502 reale le istruzioni Memoria->Memoria prevedono più cicli di clock.

Questa è l’implementazione dei cicli:

Register->Memory

//=======================================================================
// Execute Register -> Memory
//=======================================================================
CY_EXECUTE_RM: begin
  if (T0) begin
      execute_Register_Memory();
      // &lt;-- Here the result is ready to be written
      reg_MAR_out = calc_pointer(reg_DP); // Get effective address 
      write_enable_out = true;
  end
  // &lt;-- During this time (from T0 to T1) write line is LOW 
  // and the result is physically written to the memory
  else begin
      write_enable_out = false;
      if (empty_count != 0) 
          next_empty();
      else
          next_fetch();
      end
end

Memory->Register

//======================================================================
// Execute Memory -> Register 
//======================================================================
CY_EXECUTE_MR: begin
    if (T0) begin
        reg_MAR_out = calc_pointer(reg_DP); // Get effective address 
        // &lt;-- Here we emit the address of which we want to read
    end
    // &lt;-- During this time (from T0 to T1) write line is HIGH and 
    // the data is physically read from the memory
    else begin
        // &lt;-- Here DBUS contains the operand ...
        execute_Memory_Register();	// ..and we can use it
        if (empty_count != 0) 
            next_empty();
        else
            next_fetch();
    end
end 

Memory->Memory

//=======================================================================
// Execute Memory -> Memory 
//=======================================================================
CY_EXECUTE_MM: begin
    if (T0) begin
        reg_MAR_out = calc_pointer(reg_DP);
    end
    else begin
        // Here DBUS contains the Memory value
        execute_Memory_Memory();
        next_cycle = CY_STORE_MM;
    end
end
//=======================================================================
// Store Memory -> Memory
//=======================================================================
CY_STORE_MM: begin
    if (T0) begin
        // Here reg_DO_out contains the Memory value of the former cycle
        // and reg_MAR_out contains the Memory address already adjusted
        write_enable_out = true;
    end
    else begin
        write_enable_out = false;
        if (empty_count != 0) 
            next_empty();
        else
            next_fetch();
    end
end

Eventi asincroni

Sono tutti quegli eventi che possono verificarsi in un qualunque istante e non sono correlati al programma in esecuzione. Sto parlano degli Interrupt e del Reset.

Nel 6502 esistono dui tipi di interrupt, quelli mascherabili, che hanno come trigger la linea IRQ e quelli non mascherabili scatenati dalla linea NMI (No Maskable Interrupt). Sia gli interrupt che il reset vengono gestiti da una sequenza ben precisa che prevede il salvataggio del registro di stato e dell’indirizzo di partenza ed il caricamento dell’indirizzo di salto contenuto in un vettore memorizzato nella parte alta della memoria.

Questa sequenza, o almeno la prima parte, sembra inutile per il reset; infatti, non ha senso salvare delle risorse che contengono valori casuali, ricordiamoci però sempre del mantra “6502”: dobbiamo risparmiare. Durante la sequenza di reset, mediante un “trucco elettronico” la linea di scrittura assume lo stato di lettura e i valori letti vengono scartati.

Trovate l’implementazione di questa sequenza nei cicli CY_VECTOR_SEQUENCE da 0 a 4.

La gestione degli eventi asincroni, prevede l’esecuzione nella parte combinatoria, però il loro campionamento che deve essere fatto nella parte sequenziale della FSM.

Questa è, infine, la parte sequenziale dell’FSM.

//======================================================================
// FSM: Sequential part
//======================================================================
always @(posedge CLK)
begin
    if (PH0) begin
    // Phases generation
    ck &lt;= RES_ ? ck + 3'd1 : 3'd0;
    if (async_pending[`RST])  // RST
        res_latch &lt;= 0; 
    else
        if (!RES_) 
            res_latch &lt;= true;	

    // Asynchronous events and state shift
    // 2-nd Stage divider
    if (PHI3) begin               
        // Check nested RST and Interrupts
        // RST has the max priority and clears all others
        // NMI is fired only if there isn't an RST in progress
        // IRQ is fired only if there aren't other event pending and 
        // I_flag is not set
        if (res_latch) begin
            async_pending &lt;= 3'b100; // set RST flag and clear all others
            write_enable &lt;= false;
            cycle &lt;= CY_RESET; // This is immediate
        end
        else begin
            if (!NMI_ &amp;&amp; (!async_pending_out[`RST])) begin // Not RES 
                async_pending[`NMI] &lt;= 1'b1;  // set NMI flag
            end	
            else
                if (!IRQ_ &amp;&amp; !reg_ST_out[I_flag] &amp;&amp; (async_pending_out == no_pending)) 
                    async_pending[`IRQ] &lt;= 1'b1; // set IRQ flag
                else
                    async_pending &lt;= async_pending_out;

                cycle &lt;= next_cycle;					
            end
            set_current();
            if (T1 &amp;&amp; ~SO_) // Seldom used, just for the "exactness"
                reg_ST[V_flag] &lt;= 1'b1;
	end
    end
end

Exact-cycle

La nostra CPU ricalca fedelmente le operazioni svolte da un 6502, il quale però, per motivi interni, impiega più cicli di clock per eseguire alcune istruzioni.

Per ovviare a questo inconveniente ho inserito un ciclo di compensazione: CY_EMPTY, il quale, in accordo alla variabile empty_count, valorizzata opportunamente volta per volta, attende il numero di cicli di clock necessario per allinearsi all’esecuzione di un 6502 reale.

In definitiva, ogni istruzione verrà eseguita nello stesso numero di cicli di clock di una 6502 reale.

Compensare il tempo alla fine di un’istruzione non è una deroga molto pesante, in quanto la granularità del 6502 è a livello d’istruzione: tutte le attività, anche quelle asincrone come la gestione degli interrupt, avvengono sempre al termine dell’istruzione corrente.

Per riepilogare, il softcore 6502, anche se più complesso, è composto dalle stesse sezioni di quello di mini-CPU:

  • Definizione costanti
  • Allocazione risorse interne
  • Macchina a stati (parte sequenziale e parte combinatoria)
  • Task di esecuzione delle istruzioni

Dovendo gestire le periferiche esterne c’è l’ultima parte da analizzare: la gestione dei segnali di I/O:

//---------------------------------------------------------------
// Physical lines
//---------------------------------------------------------------
assign PHI1 = ~ck[2];
assign PHI2 = ck[2];

wire canwrite = write_enable &amp;&amp; (ck > 1);
assign RW_ = BE? !canwrite : 1'bz; 

assign DBUS = (BE &amp;&amp; canwrite) ? reg_DO : {8{1'bz}};

assign ABUS = BE ? reg_MAR_out : {16{1'bz}};
assign SYNC = (cycle == CY_FETCH_DECODE);

assign TRAP = (cycle == CY_TRAP);

Questa parte collega i registri interni con i wire d’interfaccia.

Notate la presenza di BE (Bus Enable), questo segnale è stato introdotto nel 6502 successivamente; esso rende 3-state le uscite del 6502 e permette di porle in alta impedenza quando è basso.

È molto utile per poter “prendere possesso” dei bus; l’ho ritenuto molto comodo per espansioni future, per cui l’ho implementato. Nella nostra SBC, tuttavia, è mantenuto sempre alto.

Linee SYNC/RDY

Gestendo queste linee è possibile introdurre dei wait-state nelle istruzioni oppure realizzare un circuito step-by-step per eseguire un’istruzione per volta.

La linea SYNC, nel 6502, viene emessa durante la fase di Fetch dopo un ritardo “fisiologico”, nel nostro caso è attiva da ck=2 a ck=6. Se al termine della fase di Fetch il segnale RDY è basso, la CPU rimane in questo stato ed attende un ciclo di clock.

Nel nostro SBC questi segnali sono “cortocircuitati”, se volete realizzare dei circuiti step-by-step o altro, potete gestirle, avendo cura di utilizzare un circuito one-shot perché il flickering dei pulsanti meccanici non è compatibile con queste frequenze di clock, correreste il rischio di eseguire una decina di istruzioni per volta.

Io ho utilizzato lo step-by-step per debuggare la CPU e visualizzare lo stato interno dei registri sul display a causa di un bug subdolo che mi ha fatto impazzire per qualche giorno.

Se progetterete delle CPU arbitrarie, questa sarà una feature che vi consiglio assolutamente di implementare.

Implementazione del Chipset

I chip presenti, RAM, ROM e I/O digitale sono implementati completamente, il chip UART no, lavora ad un baud-rate selezionabile in fase di progetto ma non segue la programmazione del registro di controllo, inoltre non gestisce l’handshake RTS/CTS e lavora con i parametri fissi N, 8, 1.

Trovate i files Verilog di questi chip nelle varie cartelle, sono molto piccoli, questo evidenzia ancora una volta come sia flessibile e potente il lavoro con gli FPGA.

Vediamo la RAM

module rtl_ram
#(
    parameter ADDR_WIDTH = 15,
    parameter DATA_WIDTH = 8,
    parameter DEPTH = 1 &lt;&lt; ADDR_WIDTH
)    
(
    input wire CLK,
    input wire[ADDR_WIDTH-1:0] A,   // Address bus
    inout wire[DATA_WIDTH-1:0] DIO, // Data bus
    input wire CS_N,                // Chip Select (negated)
    input wire OE_N,                // Output Enable (negated)
    input wire WR_N                 // Write Enable (negated)
);

`ifdef VENDOR_GOWIN
    (* RAM_STYLE = "block" *)  
`endif 
`ifdef VENDOR_ALTERA
    (* ramstyle = "M9K" *)
`endif 
`ifdef VENDOR_XILINX
    (* RAM_STYLE = "block" *)  
`endif 
reg[DATA_WIDTH-1:0] mem[0:DEPTH-1];
reg[DATA_WIDTH-1:0] data_out;

always @(posedge CLK)
begin
    if (!CS_N &amp;&amp; !OE_N) 
        data_out &lt;= mem[A];
end

always @(posedge CLK)
begin
    if (!CS_N &amp;&amp; !WR_N) 
        mem[A] &lt;= DIO;
end
assign DIO = (!OE_N &amp;&amp; !CS_N &amp;&amp; WR_N) ? data_out : {DATA_WIDTH{1'bz}};

initial begin
    data_out = 0;
end

endmodule

Implementare una RAM significa semplicemente definire un Array e delle regole per accedere in lettura e scritture, tutto qui.

Non ci sono evidenze di rilievo, voglio solo segnalarvi la particolarità dei defines.

Per comunicare al sintetizzatore la direttiva di utilizzo dei blocchi BRAM esiste in Verilog la variabile RAM_STYLE, la quale “dovrebbe” essere standard. Purtroppo, qualche costruttore, tipo Altera, preferisce una propria denominazione.

Per cui, per rendere il progetto multipiattaforma, ho creato dei “defines” che selezionano la variabile corretta. È l’unica particolarità, il resto del progetto è identico per tutti i brand.

Questa è la ROM (della versione che non utilizza la micro-SD).

module rtl_rom
#(
    parameter ADDR_WIDTH = 14,
    parameter DATA_WIDTH = 8,
    parameter DEPTH = 1 &lt;&lt; ADDR_WIDTH
)    
(
    input  wire CLK,
    // ROM Interface
    input  wire[ADDR_WIDTH-1:0] A,  // Address bus
    output wire[DATA_WIDTH-1:0] DO, // Data bus
    input  wire CS_N,               // Chip Select (negated)
    input  wire OE_N                // Output Enable (negated)
);
 
`ifdef VENDOR_GOWIN
    (* RAM_STYLE = "block" *)  
`endif 
`ifdef VENDOR_ALTERA
    (* ramstyle = "M9K" *)
`endif 
`ifdef VENDOR_XILINX
    (* RAM_STYLE = "block" *)  
`endif 
    reg[DATA_WIDTH-1:0]	mem[0:DEPTH-1];
    reg[DATA_WIDTH-1:0] data_out;

    always @(posedge CLK)
    begin
        if (!CS_N &amp;&amp; !OE_N) 
            data_out &lt;= mem[A];
    end

//----------------------------------------------------------------------
// ROM Interface
//----------------------------------------------------------------------

   assign DO = (!OE_N &amp;&amp; !CS_N) ? data_out : {DATA_WIDTH{1'bz}};
    
   integer i;
   initial begin
       data_out = 8'h0;
       $readmemh("osi_bas.hex", mem); // Loads the Bios
   end

endmodule

Il buffer 3-state per leggere i Digital Inputs è ancora più semplice.

Nota: è così semplice che tutti i sintetizzatori eliminano questo modulo in fase di ottimizzazione, andando ad utilizzare sostanzialmente l’unica riga utile.

Io l’ho lasciato per mantenere la corrispondenza 1:1 fra hardware e software, per cui potete ignorare il warning corrispondente.

module rtl_octal_3state_buffer
(
    input wire[7:0]  D,
    output wire[7:0] Q,
    input wire OE_N     // Output Enable (negated)
);

    assign Q = !OE_N ? D : {8{1'bz}};

endmodule

Infine, questo è il latch di uscita:

module rtl_octal_ff
(
    input wire CLK,
    input wire[7:0]  D,
    output wire[7:0] Q,
    input wire OE_N,     // Output Enable (negated)
    input wire WR       // Output Enable (negated)
);

    reg[7:0] latch;
	
    always @(posedge CLK) 
    begin
        if (WR)
            latch &lt;= D;
    end

    assign Q = !OE_N ? latch : {8{1'bz}};

    initial latch = 8'h00;

endmodule

Realizzazione pratica

Siamo finalmente giunti alla parte divertente.

Il progetto che ho realizzato è multipiattaforma, l’ho testato con chip Altera, Xilinx e Gowin. Per massimizzare la portabilità non ho utilizzato i PLL specifici dei vari produttori per la generazione del clock: ho implementato un divisore a due stadi direttamente in Verilog che trovate subito prima della FSM sequenziale. Questo assicura sempre una frequenza di lavoro finale di circa 3 MHz.

Purtroppo, non è possibile inserire delle direttive di compilazione condizionali nei files dei pin, per cui troverete due versioni della board, una che contiene i pin per l’adattatore SD ed una senza. Quella per l’adattatore, come già detto, è utilizzata dal Tang Nano 20K. Il resto degli IP (CPU, RAM, ecc.) è comune.

Board utilizzate

Nel repository github troverete cinque progetti pronti per essere compilati e trasferiti nelle seguenti board. Le elenco a partire dalla più economica, la sigla fra parentesi è il chip di riferimento:

  • Sipeed Tang Nano 9K (Gowin GW1NR-9)
  • Sipeed Tang Nano 20K (Gowin GW2AR-18)
  • Sipeed Tang Primer 25K (Gowin GW5A-LV25MG121)
  • Digilent CMOD-A7 35T (Xilinx Artix-7 – XC7A35T)
  • Terasic DE10-Lite (Altera MAX 10 – 10M50DAF484C7G)

Anche se non ne troverete il progetto, ho effettuato con successo delle prove intermedie con una board equipaggiata con il chip Altera CYCLONE 1010CL025, la quale fa parte della pesca “a strascico” che faccio spesso su Aliexpress. Non essendo però dotata di una documentazione adeguata è consigliabile solo agli utenti più smaliziati, per cui ho preferito privilegiare schede ben documentate (ma economiche) di brand conosciuti e che avessero una diffusione capillare. La considerazione è che se avete una board con Cyclone 10 quasi sicuramente potete installare questo progetto con poche modifiche a partire da quello per DE-10 Lite.

Hardware ausiliario

L’unico hardware esterno strettamente indispensabile è un convertitore USB/Seriale a 3,3V. Io ho utilizzato un adattatore FTDI, ma dovrebbe andar bene qualunque adattatore, l’importante è che il modello sia a 3,3V che è la tensione di lavoro degli FPGA. È sufficiente collegare solo RXD, TDX e GND. Alcuni adattatori lavorano a 3,3V ma hanno un pin di uscita a 5V per alimentare piccole periferiche; vanno bene ugualmente, dovete solo evitare di collegare questo pin utilizzando solo RXD, TXD e GND.

Il collegamento dei terminali RS232 andrà incrociato, ovvero quello che lato cavo è RDX dovrà essere collegato al pin TXD della board FPGA, e viceversa.

Contenuto dell’articolo

Tastiera con display

Per le prime quattro board, nei progetti trovate anche il driver e la gestione che ho scritto di una piccola tastiera con display basata sul chip Titan TM1638. La trovate per 4€ in qualunque store online. Non ha un nome specifico, anche se spesso la si trova come “LED&KEY”, ma viene prodotta da decine di costruttori e sono tutte uguali. Ha otto display a 7 segmenti, otto led ed otto pulsanti.

Contenuto dell’articolo

Per evitare il fastidio di scollegare e collegare i terminali per i diversi prototipi che realizzo, visto il costo, ne ho acquistate quattro in passato, e funzionano tutte egregiamente.

È sufficiente istanziare il driver e leggere/scrivere le variabili di scambio, è possibile scrivere un numero esadecimale a 32 bit o pilotare i singoli segmenti dei display in modo indipendente. Lo stato dei pulsanti è sempre disponibile in una variabile ad 8 bit, ed allo stesso modo piloteremo i led oppure i punti decimali dei display.

È tutto molto semplice da usare, il vantaggio elettrico è che per comunicare sono sufficienti solo tre fili, per cui non abbiamo un grosso impatto sul numero degli I/O sottratti all’FPGA.

Connessioni elettriche

Queste sono le connessioni da utilizzare per le varie board con qualche nota ove necessario; ovviamente potete cambiarle andando a modificare la sezione del pin planning nei vari progetti.

Tang Nano 9K

Questa è la board più economica contenente l’FPGA più piccolo. Ad ogni modo le risorse logiche occupate ammontano al 21%, ed i registri al 6%.

Contenuto dell’articolo

La memoria è al 93%, ne utilizziamo 48K. Oggettivamente 32K di RAM sono tanti per l’interprete Basic; se avete intenzione di utilizzare il Tang Nano 9K ed inserire altra logica che richiede BRAM potete ridurre tranquillamente la RAM a 8K. Per fare questo, nel file SBC6502.v modificate il parametro RAMWIDTH da 15 a 13.

Contenuto dell’articolo

Tang Nano 20K

Ricordate di inserire in questa scheda una micro-SD formattata FAT32 contenente il file osi_bas.bin. Se volete fare delle prove, utilizzando altri files, ricordate di specificare la lunghezza del nome del file nel parametro ROM_FILE_NAME_LEN il quale deve essere comprensivo del punto, ad esempio: osi_bas.bin -> 11.

Contenuto dell’articolo

Queste sono le sue connessioni

Contenuto dell’articolo

Tang Primer 25K

Ho utilizzato questa board, di recente produzione, molto potente ed economica, con la sua mini-docking board (senza la quale, per un hobbista è impossibile da utilizzare a causa di due connettori ad alta densità). Le connessioni quindi si riferiscono a quest’ultima. Il tastierino esterno va collegato ai pin della fila inferiore del connettore PMOD, come da foto.

Contenuto dell’articolo

Questo è il prototipo

Contenuto dell’articolo

E queste le connessioni da realizzare

Contenuto dell’articolo

CMOD-A7

Per massimizzare il numero degli I/O, questa scheda, purtroppo, non presenta in uscita la tensione di servizio di 3,3V, utile per alimentare periferiche esterne.

Al fine di ovviare a questo inconveniente, per alimentare il tastierino esterno, ho montato uno step-down 5V->3,3V da 800 ma come da figura.

Contenuto dell’articolo

Questo è il prototipo

Contenuto dell’articolo

E queste le sue connessioni, i terminali RS232 vanno collegati alla fila esterna del connettore PMOD.

Contenuto dell’articolo

DE10-Lite

Questa schedina ha già display pulsanti e microswitch, per cui nel suo progetto non troverete l’istanza al driver TM1638, ho utilizzato le risorse interne; dovete solo collegare il cavo RS232.

Prototipo

Contenuto dell’articolo

Connessioni

Contenuto dell’articolo

Infine, questi progetti non usano memoria esterna tipo SDRAM presente in parecchie board, ma solo i blocchi BRAM contenuti all’interno dell’FPGA.

Potrebbe capitare che qualche chip tipo il Cyclone IV EP4CE6F17 non riesca a contenere tutto il progetto, in tal caso potete ridurre la RAM a 8K, il sistema funziona ugualmente.

Test del sistema

Per testare il Basic avrete necessità di un emulatore di terminale, io sono affezionato a Putty, ma ce ne sono di più evoluti; vanno tutti bene.

Collegate la schedina FPGA ad una porta USB del vostro PC ed il cavo USB/RS232 ad un’altra porta (anche di un altro PC). Aprite una sessione sull’emulatore di terminale specificando la porta COM utilizzata e 115200 N, 8, 1 come parametri senza nessun tipo di handshake

Se non vedete nulla nella finestra del terminale è perché avete attivato la porta seriale dopo l’accensione dell’FPGA e quindi avete perso il messaggio iniziale, per cui premete il pulsante di reset sulla board, oppure premete direttamente il tasto “C”.

Attivate il CAPS-Lock perché il Basic vuole i comandi in maiuscolo.

Contenuto dell’articolo

Alla prima domanda rispondete con “C”, come dimensione della memoria impostate 32768 (oppure 8192 se l’avete ridotta) e come numero di colonne 80.

Dopo il “banner” il sistema è pronto per ricevere i programmi in Basic.

Se avete collegato il tastierino esterno è anche possibile effettuare il test dell’I/O direttamente in Basic.

Il 6502 non ha uno spazio d’indirizzamento separato per gli I/O che quindi vengono definiti “mappati in memoria”. I due byte, nella nostra board, sono mappati a $8000 (32768 in decimale).

Scrivete il seguente programmino:

10 POKE 32768, PEEK(32768)
20 GOTO 10

E digitate il comando RUN.

Questo semplice programma in sostanza ridireziona il byte di input su quello di output, per cui premendo un pulsante sul tastierino vedrete accendersi il led corrispondente.

Crediti

Nella realizzazione di questo progetto ho utilizzato due IP Open Source molto affidabili che uso da tempo:

  1. FPGA-SDcard-Reader di WangXuan95
  2. RS232-sync-uart di Brian Guralnick

Inoltre, l’interprete Basic, come già detto, è quello modificato da Grant Searle. Nel repository trovate anche i sorgenti ed un Assembler lo volete ulteriormente modificare.

Come da buona norma, trovate i link a tutto questo materiale nel file readme del progetto.

Link utili

Il progetto completo lo trovate qui

Qui trovate Icarus.

Articoli consigliati

Conclusioni

Se avete avuto la pazienza di leggere fin, qui meritate sicuramente un premio.

Spero di non avervi annoiato troppo; come sempre sono graditi i vostri commenti, anche con critiche e soprattutto con spunti di miglioramento.

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *