Appunti del corso di ARCHITETTURA DEI CALCOLATORI

Autore: professor Giovanni Chiola; email: chiola@disi.unige.it
Modifiche successive da parte di Davide Ancona
Ultima modifica di questo documento: Genova, 21 febbraio 2005 (Davide Ancona)

Copyright © 1996-2003.

Questo documento é protetto dalla legge sul diritto di autore, e di proprietà esclusiva dell'autore che se ne riserva tutti i diritti.

L'autore concede a chiunque il permesso di copiare gratuitamente qualunque sottoinsieme dei files che compongono questi appunti per qualsiasi uso e su qualsiasi supporto, purchè la copia di ciascun file sia integrale, senza alcuna modifica ad eccezione di un eventuale cambiamento di formato di rappresentazione per necessità di stampa o memorizzazione su sistemi diversi, e purchè ogni copia sia sempre accompagnata dalla presente clausola completa di Copyright.

Le informazioni presenti in questo documento distribuito dall'autore in forma gratuita vengono fornite in quanto tali, senza nessuna garanzia di correttezza, consistenza o assenza di errori da parte dell'autore stesso, il quale per altro declina ogni responsabilità per qualsiasi danno o inconveniente che potesse derivare dall'uso del documento stesso, anche se si dovesse verificare il caso che talune informazioni qui contenute non fossero corrette.

É permessa anche la riutilizzazione parziale o totale del testo e delle figure per la produzione di lavori derivati, purchè i lavori derivati siano soggetti alle condizioni di diffusione ed uso di questo documento originale, con particolare riferimento alla possibilità per chiunque di copiare liberamente e senza restrizione alcuna il documento in forma integrale e di usarne parti per la creazione di lavori derivati soggetti alle medesime condizioni di diffusione ed uso. Nel caso di creazione di lavori derivati mediante inclusione e/o modifica sostanziale del testo originale, l'autore delle modifiche si assumerà la paternità delle aggiunte e delle modifiche, ed il nome dell'autore del testo originale non potrà essere utilizzato senza il suo preventivo ed esplicito consenso per patrocinare l'opera derivata.

Le condizioni di questo Copyright sono liberamente ispirate al "copyleft" originariamente introdotto da Richard Stallman, e dovrebbero essere una versione semplificata ma sostanzialmente equivalente alla "Free Documentation License" raccomandata dalla "Free Software Foundation".



Indice




Introduzione

NOTA PRELIMINARE: questa prima parte introduttiva agli argomenti del corso, insieme con una parte del materiale sui codici viene ora svolta nell'ambito del corso di Informatica Generale. Viene mantenuta in questi appunti per completezza di trattazione, e costituisce un prerequisito per la comprensione del resto del programma effettivamente svolto nel corso di Architettura dei Calcolatori.

Una delle caratteristiche peculiari dei Sistemi di Calcolo, che ne ha accompagnato l'evoluzione sin dalle origini negli anni '40, é la complessità. Tale caratteristica si riperquote in diverse attività di un "informatico" quali la progettazione, realizzazione, uso, comprensione di un sistema di calcolo.

Al fine di poter trattare sistemi molto complessi, gli informatici hanno sviluppato una serie di "contromisure", tra le quali la più efficace é la decomposizione in livelli di astrazione diversi. Tale decomposizione viene ottenuta normalmente attraverso l'introduzione dei concetti di Macchina Virtuale, Linguaggio e di Codifica delle informazioni.

Si possono dare delle definizioni matematiche rigorose di tali concetti. Definiamo l'alfabeto di una macchina virtuale come un insieme finito di simboli diversi tra loro riconoscibili ed utilizzabili dalla macchina virtuale. Il linguaggio della macchina virtuale é definito come la descrizione di tutte le sequenze di simboli dell'alfabeto che identificano dei comandi eseguibili dalla macchina virtuale o dei dati usati o prodotti dalla macchina virtuale durante l'esecuzione dei comandi.




Macchine Virtuali ed esperienza quotidiana

Prima di addentrarci nello studio dei sistemi di calcolo con l'ausilio del concetto di macchina virtuale é utile fare qualche considerazione "di buon senso" basata sulla esperienza quotidiana di ciascuno di noi. É infatti importante comprendere che l'informatica ed i sistemi di calcolo non sono estranei alla vita quotidiana, e questo non soltanto perché una quota sempre maggiore di elettrodomestici e strumenti di uso comune fanno uso di tecnologie informatiche, ma anche perché molto spesso noi stessi ragioniamo in termini informatici (probabilmente senza neanche rendercene conto).

La macchina lavabiancheria

Cominciamo quindi a considerare un esempio dalla vita domestica: l'uso di una lavabiancheria per il bucato. Indipendentemente dalla presenza di un "microprocessore" all'interno della macchina (molto probabile in un modello recente), possiamo subito individuare una peculiarità di questo elettrodomestico di potersi adattare a diversi tipi di tessuti da lavare ed a diversi livelli di risultati in termini di bucato prodotto.

Tipicamente, dopo aver inserito il bucato ed il detersivo, dobbiamo girare una o più manopole (oppure premere pulsanti) per la scelta del tipo di lavaggio desiderato. Alternative tipiche possone essere: "Cotone", "Colorati", "Sintetici", "Lana", ecc. Tali nomi formano ciò che in termini informatici viene detto un "linguaggio" (nel nostro caso particolare si tratta di un linguaggio degenere che si riduce ad un semplice vocabolario) mediante il quale noi possiamo impartire ordini che verranno eseguiti alla lettera dalla macchina, senza alcun controllo "intelligente". Per esempio, se inseriamo nella macchina dei tessuti con colori delicati, e la programmiamo per l'attivazione di un ciclo di lavaggio adatto per lenzuola bianche, la lavabiancheria eseguirà l'ordine ricevuto riscaldando l'acqua alla temperatura di 90 gradi, con risultati disastrosi per il bucato. É compito dell'utilizzatore di impartire alla macchina ordini coerenti utilizzando il linguaggio di programmazione descritto sul manuale d'uso.

La comprensione del manuale d'uso e l'uso della lavabiancheria prescindono da come la macchina realizza effettivamente il lavaggio. Quando la nostra macchina si guasta, il tecnico della manutenzione che viene ad effettuare la riparazione userà delle descrizioni dello stesso elettrodomestico diverse dal manuale d'uso (schemi elettrici, catalogo delle parti, ecc.) per portare a termine la riparazione, evidenziando quindi un modo totalmente diverso di osservare e manipolare la stessa macchina (un diverso livello di astrazione).

La produzione di Pizze

Passiamo ad un esempio più complesso: il problema di procurarsi una pizza per mangiare. Possiamo anzitutto scegliere tra due alternative: cucinare la pizza in proprio, oppure recarsi in una pizzeria. Cominciamo ad esaminare la prima alternativa.

Il "fai da te"

Per cucinare la pizza in casa occorre procurarsi gli ingredienti (acqua, farina, lievito, ecc.), la ricetta (per esempio da un libro di cucina), ed una serie di attrezzi per la realizzazione della ricetta (mattarello, tavolo, forno, ecc.). Una volta procurate tutte queste cose possiamo passare alla fase di preparazione, seguendo i passi descritti nella ricetta.

La ricetta farà uso di un linguaggio che noi dobbiamo essere in grado di comprendere correttamente e dovrà descrivere il procedimento di preparazione in termini di passi elementari che noi dobbiamo essere in grado di realizzare. Se per esempio la ricetta fosse scritta in dialetto napoletano noi dovremmo sapere che il termine "pummarola" indica la passata di pomodoro. D'altra parte, se leggendo la ricetta incontriamo l'istruzione "scaldare il forno", dobbiamo essere in grado di accendere il forno e portarlo alla temperatura corretta; le procedure possono essere diverse per un forno a legna, a gas o elettrico, e non é detto che una persona in grado di scaldare un forno elettrico sia capace di scaldare correttamente anche un forno a legna, nel qual caso avrebbe bisogno di istruzioni più dettagliate per poter portare a termine correttamente l'operazione.

In termini informatici, la realizzazione di una pizza seguendo passo passo le indicazioni di una ricetta costituisce una attività detta interpretazione da parte di una macchina virtuale (noi stessi nella fattispecie).

L'eventuale interruzione della interpretazione della ricetta al momento del riscaldamento del forno per andare a leggere su un altro manuale le istruzioni dettagliate di accensione e regolazione della temperatura, in termini informatici costituisce una estensione procedurale per sopperire ad una mancanza di corrispondenza precisa tra le istruzioni contenute nella ricetta (programma da eseguire) e l'insieme delle istruzioni che la macchina virtuale (noi stessi) conosce ed é in grado di eseguire direttamente. Al termine dell'interpretazione della procedura "scaldare il forno" la macchina virtuale può tornare all'interpretazione del programma principale, dal punto in cui l'aveva interrotto.

La pizzeria

La seconda alternativa può risultare più dispendiosa ma anche più comoda, in quanto qualcun altro svolgerà il ruolo di macchina virtuale al posto nostro. Normalmente, in pizzeria l'interazione avviene con un cameriere, il quale assume agli occhi del cliente il ruolo di macchina virtuale.

Inizialmente, il linguaggio usato per la comunicazione tra cliente e cameriere sarà quello dei nomi delle pizze scritte sul menù ("margherita", "quattro stagioni", "napoletana", ecc.). Tale linguaggio può essere considerato ad alto livello, in quanto ogni parola corrisponde direttamente con una particolare esigenza dell'utilizzatore della macchina. Ordinando al cameriere "una quattro stagioni" noi comandiamo alla macchina virtuale pizzeria l'esecuzione di una attività complessa, il cui risultato é quello di far arrivare al nostro tavolo la pizza desiderata. L'utente tuttavia non é normalmente interessato ai dettagli di realizzazione della pizza, per cui si pone ad un livello di astrazione superiore e percepisce l'attività della macchina virtuale pizzeria come un tutt'uno che termina con il recapito della pizza desiderata al proprio tavolo dopo una certa quantità di tempo.

Il cameriere si pone invece ad un livello di astrazione inferiore per poter realizzare la macchina virtuale con le funzionalità richieste dal cliente sulla base delle macchine virtuali di cui lui stesso può disporre. Tipicamente il cameriere scriverà l'ordine su un pezzo di carta col duplice scopo di passare l'ordine al pizzaiolo e di tener traccia della consumazione per poter poi presentare il conto. Normalmente, per risparmiare tempo, userà delle convenzioni per codificare in forma abbreviata l'ordine ricevuto dal cliente; per esempio qualcuno potrebbe scrivere "4S" (al posto di "quattro stagioni"), qualcun altro potrebbe scrivere "X4", qualcun altro ancora potrebbe scrivere "2" (se il nome "quattro stagioni" compare al secondo posto nella lista dei nomi del menù, come nel nostro esempio).

Una volta trascritto l'ordine, il cameriere passa l'ordine al pizzaiolo e può dedicarsi al servizio di un altro tavolo, demandando quindi il completamento dell'esecuzione dell'istruzione ad un'altra macchina virtuale (per un produttore di automobili questa tecnica verrebbe chiamata "catena di montaggio", mentre gli informatici preferiscono usare il termine pipeline).

La macchina virtuale pizzaiolo interpreta gli ordini ricevuti dal cameriere mediante applicazione di una serie di passi elementari che ha imparato ad eseguire una volta per tutte e che ricorda in permanenza (stendere la pasta, aggiungere la passata di pomodoro, aggiungere la mozzarella, ecc.). In termini informatici, il pizzaiolo esegue una istruzione della sua macchina virtuale interpretando una sequenza di micro istruzioni presenti in una sua memoria permanente (firmware) ed univocamente associate a ciascun codice che il cameriere scrive sull'ordine.

L'esecuzione delle micro istruzioni può essere interrotta solo nel caso si manifestino delle eccezioni (o trap), ad esempio per esaurimento degli ingredienti o per mancato riconoscimento del codice di un comando. Il trattamento di una eccezione può comportare un ritardo nel completamento dell'esecuzione del comando (per esempio chiedendo a voce al cameriere di chiarire che tipo di pizza era stato richiesto, a causa della sua brutta calligrafia) o l'aborto (col cameriere che torna dal cliente e si scusa di non poter soddisfare l'ordine a causa dell'esaurimento dei carciofini).



Stratificazione in livelli di un sistema di calcolo

Storicamente si é venuta consolidando una stratificazione dei livelli di astrazione utilizzati per progettare e per descrivere le funzionalità di un sistema di calcolo secondo il seguente schema:
L 5 Linguaggi di alto livello
Il livello di macchina virtuale normalmente usato dal programmatore di applicazioni eseguite dal sistema di calcolo;
L 4 Assembler e Librerie
Il livello di macchina virtuale più basso utilizzabile dal programmatore di applicazioni eseguite dal sistema di calcolo;
L 3 Nucleo del Sistema Operativo
Il livello di macchina virtuale che permette l'attivazione di programmi indipendenti (processi) e l'uso delle risorse fisiche del sistema da parte di questi programmi, senza interazioni logiche tra loro;
L 2 Macchina convenzionale
Il livello di definizione delle istruzioni base del computer e degli altri dispositivi fisici che compongono il sistema;
L 1 Micro Architettura o Trasferimento tra Registri
Il livello di definizione del funzionamento dei singoli componenti fisici del sistema in termini di interconnessione e spostamento di informazioni tra circuiti logici elementari;
L 0 Logica Circuitale
Il livello di realizzazione dei circuiti logici elementari basato sulla logica Booleana;
L-1 Elettronica Circuitale e/o Fotonica
Il livello di progettazione dei circuiti logici elementari in termini di transistor o dispositivi optoelettronici;
L-2 Fisica dello stato solido
Il livello di progettazione dei dispositivi elettronici integrati sulla base delle caratteristiche fisiche dei semiconduttori.
Ciascun livello elencato nella tabella può essere formalizzato ed analizzato come una macchina virtuale dotata di un proprio linguaggio a cui corrisponde un insieme di istruzioni eseguibili. Normalmente le istruzioni realizzate da una macchina virtuale di livello Li comportano una realizzazione diretta mediante istruzioni di una macchina virtuale di livello Lj (dove j é strettamente minore di i), oppure una interpretazione in termini di un insieme di istruzioni di una macchina virtuale di livello inferiore Lj, oppure una estensione procedurale di un insieme di istruzioni allo stesso livello Li.

Perché stratificare?

Ragionando in termini puramente astratti possiamo quindi concludere che ogni istruzione del livello L5 (per esempio ogni istruzione del linguaggio Pascal) può essere descritta in termini di sequenze di istruzioni del livello L1. Questo é vero anche in termini pratici: potremmo pensare di monitorare e registrare il comportamento dei dispositivi fisici che compongono il sistema di calcolo al livello L1, ed associare una serie complessa di attività all'esecuzione di una singola istruzione di livello L5. Concettualmente potremmo quindi arrivare a concludere che la stratificazione proposta nella tabella non ha motivazioni teoriche per esistere, in quanto i programmi potrebbero essere scritti dal programmatore direttamente usando il linguaggio della macchina virtuale L1, invece del Pascal. Questo tentativo di "semplificazione" del problema é destinato a fallire miseramente in quanto non tiene conto di un vincolo fondamentale: il limite intrinseco alle capacità cognitive del programmatore destinato ad usare la macchina virtuale sistema di calcolo. Nessuno di noi riuscirebbe a raccapezzarsi nella descrizione di alcune decine o centinaia di migliaia di istruzioni elementari del livello di astrazione L1 corrispondenti a poche istruzioni di un programma Pascal per la somma di due numeri interi e la stampa del risultato.

Scopo della stratificazione in livelli di astrazione é dunque quello di colmare il divario (semantic gap) tra il modo di pensare alla soluzione dei problemi da parte del programmatore ed il modo di realizzare delle macchine che sfruttando fenomeni fisici conosciuti siano in grado di eseguire il programma che porta alla soluzione del problema. Tale tecnica viene applicata più volte nel caso di soluzione di problemi complessi: anche lavorando con una macchina virtuale di livello L5 il programmatore può sentire la necessità (o sfruttare l'opportunità) di definire una ulteriore stratificazione in livelli di astrazione diversi, a cui corrisponde una pila di macchine virtuali destinate a colmare il divario tra le istruzioni fornite dal linguaggio di programmazione e il suo modo di risolvere concettualmente un problema complesso.

Quali livelli studiamo?

In questo corso introduttivo non verranno trattati i livelli di indice inferiore a zero per diversi motivi, tra i quali la mancanza delle basi matematiche per la comprensione delle equazioni che descrivono il comportamento dei semiconduttori e la limitata offerta di lavoro (anche a livello internazionale) per persone specializzate in questi aspetti. I livelli da L0, L1 e L2 costituiscono il punto di vista hardware di un sistema di calcolo, mentre dal livello L3 in su si passa al punto di vista del software (di base, o di sistema). Del livello software L3 verrano trattati solo alcuni aspetti strettamente legati all'interazione con l'hardware demandando al corso di Sistemi Operativi tutti gli aspetti di gestione delle risorse. Gli argomenti del corso di Architettura vertono essenzialmente sui livelli L1 e L2, dopo una breve introduzione sul livello L0.

Prima di passare ad una analisi dal basso verso l'alto (bottom-up) della pila dei livelli di astrazione da L0 ad L2 durante la quale definiremo delle macchine virtuali via via più complesse a partire da funzioni Booleane elementari, apriremo tuttavia una parentesi introduttiva sulla realizzazione di macchine a livello L2, prendendo spunto dalla effettiva evoluzione storica dei primi sistemi di calcolo negli anni '40 e '50. Useremo una versione "astratta" riveduta e semplificata della Macchina di Von Neumann per cominciare ad avere un esempio dei livelli L1 ed L2 su cui verificare la comprensione dei concetti fondamentali, e da intendere anche come motivazione per lo studio del livello L0. Nella seconda parte del corso verranno poi introdotte man mano le caratteristiche delle odierne architetture RISC e CISC, in modo da comprendere quanto meno su quali principi si basano i sistemi odierni in grado di fornire prestazioni superiori di parecchi ordini di grandezza rispetto ai sistemi tradizionali.



La nostra Macchina di Von Neumann

Facciamo qui riferimento ad una organizzazione di un sistema di calcolo effettivamente realizzato e reso operativo nell'ambito di un progetto dell'Università di Princeton sotto la guida del matematico John Von Neumann. Prescinderemo ovviamente da alcuni dettagli operativi derivanti dai limiti della tecnologia elettronica degli anni '40, oggi ampiamente superati, e ci concentreremo sulle intuizioni di base, che ancor oggi stanno dietro la progettazione e la realizzazione dei sistemi di calcolo.

Struttura della Macchina

La macchina é realizzata mediante l'interconnessione di quattro dispositivi complessi: memoria, unità operativa, ingresso e uscita, come illustrato in figura:

RAM (Random Access Memory, memoria ad accesso arbitrario mediante indirizzo)
Realizza la memorizzazione di un vettore di numeri interi. Sia la dimensione del vettore (numero di elementi componenti) che il massimo valore memorizzabile in ogni elemento del vettore sono predeterminati al momento della costruzione e/o assemblaggio del dispositivo. Inteso come macchina virtuale, supporta l'esecuzione di due istruzioni fondamentali: memorizza V nella cella N; recupera il valore precedentemente memorizzato nella cella N.
Input (unità di ingresso)
Permette all'utente di interagire con la macchine (per esempio attraverso l'uso di una tastiera numerica) per l'introduzione di valori di tipo intero, uno alla volta.
Output (unità di uscita)
Permette alla macchina di stampare in un formato numerico leggibile dall'utente un valore intero per volta.
Control Unit (unità di controllo)
realizza il funzionamento della macchina secondo le modalità spiegate nel seguito. Contiene due registri in grado di memorizzare valori numerici interi, esattamente come le celle della RAM: un registro accumulatore ed un registro contatore di programma (Program Counter, abbreviato PC).
Per fissare le idee possiamo ipotizzare che l'unità RAM disponga di mille elementi (o celle, o parole) di memoria, e che ciascuna cella di memoria sia quindi univocamente individuata da un numero compreso tra 0 e 999. Potremmo quindi indicare con la notazione RAM[5] la sesta parola di memoria.

L'unità di controllo é realizzata in modo da poter eseguire (direttamente oppure comandando parte dell'esecuzione alle altre unità della macchina) un insieme finito di istruzioni. Sempre a titolo esemplificativo potremmo individuare un insieme di nove istruzioni base.

Istruzioni della Macchina

L'idea fondamentale di Von Neumann, che viene ancora oggi seguita nella realizzazione dei sistemi di calcolo fu quella di codificare le istruzioni in forma numerica, e di inserirle, insieme agli eventuali dati, nella unità RAM della macchina. Un modo molto semplice per ottenere una codifica numerica per l'identificazione delle istruzioni é quella di elencarle in una lista ordinata, e di usare il numero d'ordine all'interno della lista per individuare l'istruzione. Proviamo a considerare il seguente esempio:

l'Insieme delle istruzioni della nostra Macchina di Von Neumann

istruzione 0
somma di due numeri interi; in particolare, somma il valore memorizzato nel registro accumulatore col valore memorizzato in una cella di memoria, individuata dall'indirizzo N; Il risultato della somma viene memorizzato nel registro accumulatore al posto del primo addendo.
istruzione 1
differenza tra due numeri interi; in particolare, sottrae il valore contenuto nella cella RAM[N] al valore contenuto nel registro accumulatore; Il risultato della differenza viene memorizzato nel registro accumulatore al posto del primo operando.
istruzione 2
lettura di un valore numerico dal dispositivo di ingresso; Il valore letto viene memorizzato nel registro accumulatore (perdendo memoria del valore precedentemente memorizzato nell'accumulatore stesso).
istruzione 3
scrittura di un valore numerico sul dispositivo di uscita; Il valore memorizzato nel registro accumulatore viene trasferito per la stampa sul dispositivo di uscita (senza perdere memoria del valore nel registri accumulatore).
istruzione 4
memorizza il valore contenuto nel registro accumulatore anche all'interno della cella RAM[N] (senza perdita di memoria da parte del registro accumulatore).
istruzione 5
copia il valore memorizzato nella cella RAM[N] anche nel registro accumulatore; il registro accumulatore perde traccia del valore in esso precedentemente memorizzato, mentre la cella di RAM[N] mantiene invariata la sua memoria.
istruzione 6
memorizza il valore numerico N nel registro PC, perdendo quindi memoria del valore precedente;
istruzione 7
controlla il valore numerico contenuto nel registro accumulatore; se il valore é zero allora esegui le stesse operazioni definite per l'istruzione 6, altrimenti non fare niente;
istruzione 8
ferma l'interpretazione del programma.
Tralasciando per il momento le istruzioni 6, 7 e 8, possiamo fare tre considerazioni essenziali: Per esempio, se vogliamo stampare la somma tra due numeri usando la nostra macchina, potremmo leggere il primo addendo (istruzione 2), poi memorizzarlo (per esempio) nella cella RAM[50] (istruzione 4), poi leggere il secondo addendo (istruzione 2 di nuovo), poi sommare il contenuto di RAM[50] (istruzione 0), ed infine stampare il risultato (istruzione 3).

Interpretazione dei programmi

Supponendo di saper codificare qualsiaso istruzione (compresi i suoi eventuali operandi) sotto forma numerica in una singola cella di RAM, allora possiamo individuare una modalità di funzionamento sequenziale per la nostra macchina facendo uso del registro PC, esprimibile con il seguente algoritmo:

inizializzazione:
memorizza il valore 0 nel registro PC;
fetch:
recupera il valore contenuto nella cella RAM[PC] (ovvero, la cella RAM[i], dove i é il valore memorizzato correntemente in PC) e memorizzalo in un terzo registro chiamato delle istruzioni (instruction register, abbreviato IR);
somma il valore costante 1 al valore contenuto nel registro PC e sostituisci il valore contenuto in PC col risultato di questa somma;
decodifica:
confronta il valore contenuto nel registro IR con la tabella delle istruzioni della macchina per individuare quale istruzione é codificata da tale numero;
esecuzione:
realizza l'istruzione codificata dal valore numerico contenuto nel registro IR;
alla fine torna ad eseguire il passo fetch, a meno che l'istruzione eseguita non fosse la 8.
Applicando questo semplice algoritmo, l'unità di controllo é in grado di eseguire programmi sequenziali le cui istruzioni sono codificate sotto forma numerica nelle celle della RAM a partire dall'indirizzo 0. Ovvero, RAM[0] contiene la codifica della prima istruzione del programma, RAM[1] contiene la codifica della seconda istruzione del programma, ecc.

Il caricamento di programmi sequenziali nella RAM (codificati in forma numerica) costituisce quindi un mezzo semplice ma molto efficace per definire sequenze di istruzioni (anche molto lunghe) che la macchina dovrà eseguire una volta attivata (per esempio attraverso la pressione di un apposito pulsante).

Le istruzioni 6, 7 e 8 servono per alterare l'esecuzione sequenziale di programmi codificati in memoria. L'istruzione 6 viene chiamata normalmente salto di programma, in quanto determina una interruzione nella continuità della scansione della RAM in forma sequenziale: fa si che la prossima istruzione da eseguire venga decodificata a partire dal contenuto della cella RAM[N] anziché dal contenuto della cella di indirizzo successivo rispetto a quella che contiene la codifica dell'istruzione di salto. L'istruzione 7 viene chiamata (per ovvie ragioni) salto condizionale. Notare che anche le istruzioni 6 e 7 richiedono la codifica di un parametro numerico oltre all'individuazione della istruzione, mentre l'istruzione 8 é priva di parametri.

Un algoritmo semplice per ottenere la codifica numerica di tutte le istruzioni, comprese quelle con un operando, consiste nel moltiplicare per 1000 il numero d'ordine dell'istruzione e sommare il valore dell'eventuale operando (sempre compreso tra 0 e 999 a causa della limitazione sulla dimensione della RAM). La decodifica verra' effettuata prendendo la parte intera della divisione del valore numerico diviso 1000 per ottenere il numero d'ordine dell'istruzione, ed il resto di questa stessa divisione come valore dell'indirizzo della cella di memoria da usare come operando.

Esempi di programmi

Il nostro programma per la stampa della somma di due numeri corrisponderà quindi a caricare in memoria i seguenti valori prima di attivare l'esecuzione della macchina:

RAM[0] = 2000 (istruzione 2)
RAM[1] = 4050 (istruzione 4 con parametro N=50)
RAM[2] = 2000 (istruzione 2)
RAM[3] = 50 (istruzione 0 con parametro N=50)
RAM[4] = 3000 (istruzione 3)
RAM[5] = 8000 (istruzione 8)

Notare che il programma potrebbe funzionare altrettanto bene se sostituissimo i valori contenuti nelle celle RAM[1] con 4000 e RAM[3] con 0. Notare anche che con tale sostituzione la stessa cella RAM[0] verrebbe utilizzata (in tempi diversi) per contenere prima la codifica della prima istruzione del programma e poi (dopo che tale istruzione é stata eseguita) il valore del primo addendo letto dal dispositivo di ingresso. Una tale variazione potrebbe aver senso in questo caso per "risparmiare" l'uso di una cella di memoria, tuttavia riduce la capacità del programmatore di comprendere il funzionamento del programma e garantirne la correttezza. In effetti, per garantire la correttezza di una tale utilizzazione ottimizzata della memoria bisogna essere certi che l'istruzione di cui si perde memoria non dovrà mai più essere eseguita nel prosieguo dell'interpretazione del programma. Per esempio l'utilizzazione della cella RAM[2] (ottenuta sostituendo i valori 4002 nella cella RAM[1] e 2 nella cella RAM[3]) porterebbe ad un programma errato (a meno che, per pura coincidenza, l'utente non decida di specificare il valore 2000 come primo addendo).

Per verificare il livello di comprensione circa la possibilità di programmare una macchina di questo genere proviamo ad organizzare una modifica dell'esempio proposto in modo da poter ripetere indefinitamente l'operazione di somma tra due numeri e stampa del risultato, fin quando non si incontra un addendo uguale a zero (nel qual caso il programma deve terminare). Per realizzare questa modifica occorre prevedere l'aggiunta di istruzioni di tipo 6 (per ripetere indefinitamente la sequenza delle istruzioni dall'inizio) e di tipo 7 (per distinguere il caso di addendo uguale a zero da quello di addendo diverso da zero). Una possibile realizzazione sotto forma di programma é la seguente:

RAM[0] = 2000 (istruzione 2)
RAM[1] = 7009 (istruzione 7 con parametro N=9)
RAM[2] = 4008 (istruzione 4 con parametro N=8)
RAM[3] = 2000 (istruzione 2)
RAM[4] = 7009 (istruzione 7 con parametro N=9)
RAM[5] = 8 (istruzione 0 con parametro N=8)
RAM[6] = 3000 (istruzione 3)
RAM[7] = 6000 (istruzione 6 con parametro N=0)
RAM[8] = 0 (usata per dati, non per istruzioni)
RAM[9] = 8000 (istruzione 8)
Notare che questa volta il programma non può essere automodificante in quanto le istruzioni devono poter essere eseguite per un numero indefinito di volte a causa della presenza di un ciclo da ripetere per un numero di volte non determinabile a priori (dipendente dalla sequenza di valori inseriti dall'utente).

Istruzioni o dati?

In effetti, l'unità di controllo applica l'algoritmo di fetch e decodifica delle istruzioni in modo indiscriminato, sulla base del valore assunto dal registro PC, senza aver alcun modo per sapere se il valore inserito nel registro IR fosse stato effettivamente memorizzato nella cella RAM[PC] con lo scopo di codificare una istruzione oppure di mantenere un valore numerico. É responsabilità del programmatore far si che la cella da cui viene fatto il fetch contenga effettivamente un numero che rappresenta l'istruzione che si vuol eseguire in quel momento e non un qualsiasi altro numero.

Un programma che prima inserisce un dato in una cella di memoria e poi successivamente passa ad interpretare il contenuto di quella stessa cella di memoria come la codifica di una istruzione (accedendovi in fase di fetch) viene detto automodificante. Un programma automodificante non é necessariamente errato, anzi nella macchina di von Neumann alcuni problemi possono essere risolti solo mediante l'uso di programmi automodificanti. Oggi nessun programmatore concepisce programmi automodificanti, anche grazie ai progressi fatti nella organizzazione delle macchine convenzionali: al contrario, nei moderni sistemi di calcolo la memoria viene partizionata in aree specializzato per contenere i dati distinte da quelle destinate a contenere il codice del programma da eseguire, ed il tentativo di cambiare il contenuto delle celle contenenti il programma provoca normalmente la terminazione del programma stesso con un messaggio di errore.

Un programma di Bootstrap (solo accennato a lezione)

Fin'ora abbiamo considerato il caso in cui il programma desiderato dall'utente sia stato in qualche modo (magicamente?) inserito nella RAM sotto forma di codifica numerica delle istruzioni. Da un punto di vista pratico si pone però il problema di inserire questi numeri all'interno della RAM prima di far partire l'unità di controllo col fetch della prima istruzione. Per risolvere questo problema la macchina viene normalmente fornita dal costruttore provvista di un insieme di programmi predefiniti, comunemente indicati col termine Sistema Operativo, che hanno lo scopo di facilitare l'uso della macchina stessa fornendo all'utente delle modalità semplici di caricamento dei programmi in memoria e lancio della loro esecuzione.

In particolare nel nostro caso possiamo pensare di risolvere il problema con un semplice programma di Bootstrap che legga dal dispositivo di ingresso prima l'indirizzo della prima cella del programma, poi la sequenza dei codici operativi delle istruzioni del programma, li inserisca in celle di memoria consecutive, ed alla fine passi (mediante l'esecuzione di una istruzione di salto condizionale) al fetch della cella di memoria nella quale é stata caricata la codifica numerica della prima istruzione.

RAM[ 0] = 2000 (istruzione 2 - lettura)
RAM[ 1] = 13 (istruzione 0 con parametro N=13 - somma)
RAM[ 2] = 4006 (istruzione 4 con parametro N=6 - memorizza)
RAM[ 3] = 14 (somma il contenuto della cella 14)
RAM[ 4] = 4008 (memorizza nella cella 8)
RAM[ 5] = 2000 (lettura)
RAM[ 6] = 4000+i (memorizza nella cella di indirizzo calcolato)
RAM[ 7] = 1015 (istruzione 1 con parametro N=15 - sottrai)
RAM[ 8] = 7000+i (istruzione 7 con parametro N=i - salto condizionale)
RAM[ 9] = 5016 (istruzione 5 con parametro N=16 - recupera)
RAM[10] = 6 (somma il contenuto della cella 6)
RAM[11] = 4006 (memorizza nella cella 6)
RAM[12] = 6005 (istruzione 6 con parametro N=5 - salto)
RAM[13] = 4000 (dato costante)
RAM[14] = 3000 (dato costante)
RAM[15] = 8000 (dato costante)
RAM[16] = 1 (dato costante)
Notare che in questo caso il programma é automodificante: il contenuto delle celle di indirizzo 6 e 8 viene prima inserito come dato e poi interpretato come codice operativo di una istruzione. Solo così é possibile indirizzare celle di memoria consecutive mediante un programma compatto basato sulla ripetizione di un ciclo di istruzioni.

Stratificazione della nostra macchina

La nostra variante di macchina di Von Neumann é stata introdotta come un esempio di macchina convenzionale. La descrizione che ne abbiamo dato per descriverne il funzionamento usa i concetti, la terminologia e la notazione del livello di microarchitettura.

In realtà la nostra stratificazione ha senso solo quando consideriamo due o più livelli diversi contemporaneamente. In particolare, l'uso che facciamo della stratificazione é quello di descrivere (o progettare, o realizzare) un livello di astrazione più alto utilizzando uno (o più) livelli di astrazione noti (o disponibili). Ciò vale per qualsiasi livello di macchina virtuale. É naturale quindi aspettarsi che per la descrizione di una macchina virtuale di livello Li si faccia uso di descrizioni appartenenti a livelli Lj, con j minore o uguale ad i.

La macchina convenzionale propriamente detta (ossia il livello di astrazione L2 della nostra macchina) comprende la definizione dei registri accumulatore e PC, la dimensione della memoria, e l'insieme delle istruzioni (ossia la specifica di come manipolano il contenuto delle celle di memoria e dei registri) della macchina. Il resto della descrizione (registro IR, algoritmo di fetch, ecc.) appartengono al livello di astrazione L1.

In questo caso il livello 3 della macchina potrebbe essere costituito semplicemente dall'esempio di programma di bootstrap. Il livello 4 potrebbe prevedere la definizione una volta per tutte di alcuni "pezzi di programma" per risolvere semplici problemi, da poter riutilizzare in diverse circostanze. Tuttavia la preparazione di vere e proprie librerie di funzioni predefinite si scontra con la rigidità del meccanismo di indirizzamento (solo diretto) e con l'assenza di istruzioni di chiamata e ritorno da sottoprogramma. Per risolvere entrambi i problemi occorre in questo caso sfruttare appropriatamente la possibilità di definire programmi automodificanti.

Ulteriori esempi di programmi automodificanti

Una delle tecniche classiche di programmazione é l'estensione procedurale. L'idea consiste nel definire una volta per tutte dei "sottoprogrammi" di utilità generale, che possano essere richiamati da altri programmi per portare a termine parte del compito prefisso. L'esigenza di questi sottoprogrammi nasce dalla limitatezza dell'insieme delle istruzioni base della macchina: la mancanza di una istruzione macchina capace di risolvere immediatamente un sottoproblema stimola l'introduzione di una estensione procedurale che permetta la soluzione di tale sottoproblema una volta per tutte.

Consideriamo il caso di una istruzione di moltiplicazione tra due numeri. Tale istruzione non é presente nel repertorio della nostra macchina di Von Neumann, tuttavia possiamo individuare una sequenza di istruzioni macchina esistenti equivalente a tale istruzione mancante. Per esempio, potremmo pensare di caricare una volta per tutte il seguente insieme di valori in memoria:

RAM[981] = 7995 (se ACC=0 salta alla cella 995)
RAM[982] = 4998 (memorizza nella cella 998)
RAM[983] = 5999 (recupera dalla cella 999)
RAM[984] = 7995 (se ACC=0 salta alla cella 995)
RAM[985] = 4997 (memorizza nella cella 997)
RAM[986] = 5998 (recupera dalla cella 998)
RAM[987] = 1996 (sottrai il contenuto della cella 996)
RAM[988] = 7994 (se ACC=0 salta alla cella 994)
RAM[989] = 4998 (memorizza nella cella 998)
RAM[990] = 5999 (recupera dalla cella 999)
RAM[991] = 997 (somma il contenuto della cella 997)
RAM[992] = 4997 (memorizza nella cella 997)
RAM[993] = 6986 (salta alla cella 986)
RAM[994] = 5997 (recupera dalla cella 997)
RAM[995] = 6000+i (salta alla cella i)
RAM[996] = 1 (costante 1)
RAM[997] = 0 (valore irrilevante)
RAM[998] = 0 (valore irrilevante)
RAM[999] = 0 (valore primo operando)
L'effetto dell'esecuzione di questa parte di programma é di sommare tante volte il numero contenuto nella cella 999 quanto é il valore contenuto inizialmente nel registro accumulatore, lasciando il risultato del prodotto nel registro accumulatore. Alla fine, si passa al fetch dell'istruzione contenuta nella cella di indice i, come specificato dalla codifica nella cella 995. Questo sottoprogramma può essere sfruttato per esempio per stampare il prodotto tra due numeri letti dal dispositivo di ingresso, mediante l'esecuzione del seguente "programma principale":
RAM[0] = 2000 (leggi operando)
RAM[1] = 4999 (memorizza nella cella 999)
RAM[2] = 2000 (leggi operando)
RAM[3] = 6981 (passa ad eseguire il sottoprogramma)
RAM[4] = 3000 (stampa il risultato)
RAM[5] = 8000 (ferma)
a patto che il valore contenuto nella cella 995 sia 6004 (ovvero a patto che i sia stato definito uguale all'indirizzo della cella di memoria che contiene la prima istruzione del programma principale successiva al salto verso il sottoprogramma.

Questa condizione relativa al "salto indietro" verso il programma principale può essere garantita dalla seguente variante di programma principale, che prima di attivare il sottoprogramma provvede a modificarne il codice inserendo l'indirizzo di ritorno corretto:

RAM[0] = 2000 (leggi operando)
RAM[1] = 4999 (memorizza nella cella 999)
RAM[2] = 5006 (recupera dalla cella 6)
RAM[3] = 4995 (salva nella cella 995)
RAM[4] = 2000 (leggi operando)
RAM[5] = 6981 (passa ad eseguire il sottoprogramma)
RAM[6] = 6007 (valore costante corrispondente a salto a cella 7)
RAM[7] = 3000 (stampa il risultato)
RAM[8] = 8000 (ferma)

Questo modo di organizzare il passaggio dal programma principale al sottoprogramma e viceversa, non é particolarmente comodo da usare, in quanto costringe a conoscere l'indirizzo della cella di memoria corrispondente alla istruzione di ritorno del sottoprogramma (995, nel nostro caso) per consentire l'inserimento da parte del programma principale dell'istruzione di salto col corretto indirizzo di ritorno. Tuttavia permette di richiamare lo stesso sottoprogramma da punti diversi del programma principale, consentendo comunque il ritorno alla esecuzione della istruzione seguente del programma principale.

Esempio: stampa del cubo di un numero letto dall'ingresso

RAM[0] = 2000 (leggi operando)
RAM[1] = 4016 (memorizza nella cella 16)
RAM[2] = 4999 (memorizza nella cella 999)
RAM[3] = 5007 (recupera dalla cella 7)
RAM[4] = 4995 (salva nella cella 995)
RAM[5] = 5016 (recupera dalla cella 16)
RAM[6] = 6981 (passa ad eseguire il sottoprogramma)
RAM[7] = 6008 (valore costante corrispondente a salto a cella 8)
RAM[8] = 4999 (salva nella cella 999)
RAM[9] = 5013 (recupera dalla cella 13)
RAM[10] = 4995 (salva nella cella 995)
RAM[11] = 5016 (recupera dalla cella 16)
RAM[12] = 6981 (passa ad eseguire il sottoprogramma)
RAM[13] = 6014 (valore costante corrispondente a salto a cella 14)
RAM[14] = 3000 (stampa il risultato)
RAM[15] = 8000 (ferma)
RAM[16] = 0 (valore operando)

Un esercizio

Per verificare il livello di comprensione circa la possibilità di scomporre un programma complesso in una serie di chiamate di procedure predefinite, provate ad organizzare una modifica dell'esempio proposto per il sottoprogramma di calcolo del prodotto secondo il seguente schema di comunicazione delle informazioni. Provate poi a confrontare la vostra soluzione con la soluzione proposta alla fine di questo file.

Usare 3 celle di memoria contigue per la memorizzazione dei due operandi e dell'indirizzo di ritorno. L'indirizzo della prima cella di memoria per la comunicazione viene stabilito dal programma principale e passato al sottoprogramma come valore nel registro accumulatore. Il valore calcolato del prodotto viene scritto dal sottoprogramma nella cella di memoria corrispondente al primo operando.




Le macchine convenzionali moderne

Dal punto di vista della organizzazione della macchina come composizione di elementi costruttivi, la macchina di Von Neumann soffriva di un gravissimo difetto, che avrebbe impedito l'evoluzione dei sistemi di calcolo così come l'abbiamo conosciuto in questi ultimi anni: la mancanza di modularità. Nella struttura della nostra macchina il comportamento del sistema é completamente determinato dalla Control Unit. Se per esempio volessimo cambiare l'unità di ingresso con un'altra (anche con funzionalità simili ma comandata da segnali di controllo diversi), allora dovremmo modificare più o meno drasticamente anche la Control Unit. Se volessimo aggiungere un secondo dispositivo di uscita, allora dovremmo cambiare (ampliare) l'insieme delle istruzioni della macchina convenzionale.

L'interconnessione Bus

Per aumentare la modularità di un sistema di calcolo, fin dagli anni '60 venne introdotta una innovazione architetturale fondamentale: la interconnessione di dispositivi mediante bus. Questa connessione é schematizzata nella seguente figura:

La sua caratteristica principale é quella di svincolare l'unità di controllo (chiamata normalmente CPU, da Central Processing Unit) dagli altri dispositivi.

Ogni dispositivo aggiuntivo (unità di ingresso, di uscita, dischi, ecc.) interagisce autonomamente col resto del sistema mediante un controllore connesso al bus di sistema. Normalmente questi controllori sono a loro volta dispositivi programmabili in grado di accedere alla RAM senza l'intervento della CPU.

L'aggiunta di un nuovo controllore connesso al bus permette quindi di aggiungere dispositivi inizialmente non previsti nel sistema, senza modifiche ai dispositivi esistenti.

Vedremo poi nel seguito del corso come la CPU può programmare i controllori degli altri dispositivi mediante la tecnica dei registri mappati in memoria, e come questi possano usare il bus per accedere alla RAM in competizione con la CPU grazie ad un arbitro per l'uso del bus.

Decomposizione funzionale

La struttura di interconnessione di tipo bus permette di suddividere il sistema di calcolo in tante unità tra loro indipendenti, ciascuna specializzata per portare avanti un particolare compito (la stampa, la memorizzazione su disco, la comunicazione su una rete di calcolatori).

Tale approccio viene chiamato di decomposizione funzionale. Così come la stratificazione in diversi livelli di astrazione, la decomposizione funzionale può essere efficacemente utilizzata per dominare la complessità intrinseca dei sistemi di calcolo consentendoci di concentrare l'attenzione su una singola funzionalità del sistema stesso.

Notare che stratificazione in diversi livelli di astrazione e decomposizione funzionale sono metodi di "semplificazione" dei problemi tra loro ortogonali (ovvero totalmente indipendenti l'uno dall'altro). Dato un problema complesso possiamo decidere di applicare l'una o l'altra tecnica, o entrambe simultaneamente, secondo la nostra convenienza.

Esempi di uso combinato delle due tecniche portano normalmente alla identificazione di alcune funzionalità di base dei sistemi di calcolo (quali ad esempio il meccanismo delle interruzioni, della memoria virtuale, del multitasking, il file system, la connessione in rete, ecc.) alla cui realizzazione concorrono tecniche, dispositivi e programmi concepiti e realizzati a diversi livelli di astrazione. Ritorneremo ad approfondire queste considerazioni man mano che troveremo esempi di applicazione di questo principio generale.

Supporto per linguaggi ad alto livello

Provando a scrivere programmi per la nostra macchina di Von Neumann ci siamo resi conto della difficoltà pratica di strutturare il codice in modo da poter per esempio identificare dei sottoprogrammi di utilità generale e preparare delle librerie (pessima trasposizione in italiano del termine inglese program library, che significa biblioteca di programmi).

Dal punto di vista matematico, si può dimostrare formalmente che qualunque funzione calcolabile può essere espressa sotto forma di un programma che faccia uso delle sole istruzioni della nostra macchina di Von Neumann (a parte il problema di utilizzo di una unità di memoria sufficientemente capiente). Quindi potremmo affermare che, in teoria, una macchina di tipo Von Neumann é sufficiente per soddisfare qualsiasi esigenza.

La differenza fondamentale tra il punto di vista di un Matematico ed il punto di vista di un Informatico é che il matematico non ha necessità di usare effettivamente un sistema di calcolo, per cui si accontenta di sapere che una macchina di tipo Von Neumann potrebbe bastare per soddisfare qualsiasi esigenza di calcolo ipotetica. L'informatico é invece la persona che deve usare effettivamente un sistema di calcolo per risolvere problemi concreti, quindi il problema di sapere quanta fatica costa la creazione di un programma e quante risorse (quantità di memoria, tempo di calcolo, ecc.) sono necessarie per portare a termine una computazione diventa rilevante.

Gli informatici hanno sviluppato a partire dagli anni '60 ad oggi delle tecniche di programmazione che fanno normalmente uso di linguaggi ad alto livello, e (giustamente) si rifiutano ormai di programmare in linguaggio macchina. Il criterio principale che viene oggi usato per giudicare la "bontà" di una macchina convenzionale é quindi costituito dal grado di semplicità e di efficienza con cui questa macchina convenzionale consente di riprodurre i costrutti di programmazione presenti nei moderni linguaggi ad alto livello.

Per esempio, tutte le macchine convenzionali moderne offrono dei meccanismi ottimizzati per la soluzione del problema di attivazione di un sottoprogramma, passaggio dei parametri dal programma principale verso il sottoprogramma e ritorno dei risultati dal sottoprogramma al programma principale, e riattivazione del programma principale dal punto in cui era stato interrotto.

Altro punto debole del nostro esempio di macchina di Von Neumann sono le modalità di accesso alla memoria (solo indirizzamento diretto, usando la terminologia che introdurremo più avanti nel corso). Tutte le macchine convenzionali moderne realizzano modalità di accesso più sofisticate, che permettono di evitare completamente la tecnica di automodificazione delle istruzioni del programma. Ciò consente di proteggere le celle di memoria contenenti il codice del programma contro i cambiamenti dei valori memorizzati durante l'esecuzione, offrendo quindi un maggior livello di sicurezza nei confronti di possibili errori di programmazione. Normalmente vengono poi usate tecniche di accesso alla memoria mediante indirizzamento virtuale, che impediscono ad un programma di accedere a celle di memoria senza un esplicito permesso del sistema operativo.























Soluzione dell'esercizio di programmazione automodificante

Procedura di moltiplicazione:

RAM[963] = 4991 (automodifica somma col risultato parziale)
RAM[964] = 998 (codice scrittura risultato)
RAM[965] = 4984 (automodifica scrittura risultato)
RAM[966] = 4992 (automodifica scrittura risultato)
RAM[967] = 997 (codice lettura secondo oper.)
RAM[968] = 4983 (automodifica accesso al secondo oper.)
RAM[969] = 4990 (automodifica accesso al secondo oper.)
RAM[970] = 996 (codice lettura indirizzo di ritorno)
RAM[971] = 4977 (automodifica accesso all'indir. di rit.)
RAM[972] = 5991 (recupera indirizzo primo oper.)
RAM[973] = 998 (codice scrittura primo oper.)
RAM[974] = 997 (codice lettura secondo oper.)
RAM[975] = 1996 (codice lettura primo oper.)
RAM[976] = 4980 (automodifica accesso al primo oper.)
RAM[977] = 5000+j (recupera indirizzo di ritorno)
RAM[978] = 995 (codice salto a indirizzo di ritorno)
RAM[979] = 4994 (automodifica istruzione di ritorno)
RAM[980] = 5000+i (recupera primo operando)
RAM[981] = 7994 (se ACC=0 salta alla cella 994)
RAM[982] = 4999 (memorizza nella cella 999)
RAM[983] = 5000+i+1 (recupera secondo operando)
RAM[984] = 4000+i (memorizza risultato parz.)
RAM[985] = 7994 (se ACC=0 salta alla cella 994)
RAM[986] = 5999 (recupera dalla cella 999)
RAM[987] = 1996 (sottrai il contenuto della cella 996)
RAM[988] = 7994 (se ACC=0 salta alla cella 994)
RAM[989] = 4999 (memorizza nella cella 999)
RAM[990] = 5000+i+1 (recupera secondo operando)
RAM[991] = i (somma il risultato parziale)
RAM[992] = 4000+i (memorizza risultato parziale)
RAM[993] = 6986 (salta alla cella 986)
RAM[994] = 6000+k (salta alla cella k)
RAM[995] = 6000 (codice salto)
RAM[996] = 1 (costante 1)
RAM[997] = 1001 (incremento per codice)
RAM[998] = 4000 (codice memorizzazione)
RAM[999] = 0 (valore irrilevante)

Programma principale per prodotto tra due valori letti e stampa del risultato:

RAM[0] = 2000 (leggi primo operando)
RAM[1] = 4007 (memorizza primo operando)
RAM[2] = 2000 (leggi secondo operando)
RAM[3] = 4008 (memorizza secondo operando)
RAM[4] = 5006 (recupera indirizzo parametri)
RAM[5] = 6963 (salta al sottoprogramma))
RAM[6] = 7 (indirizzo parametri)
RAM[7] = 0 (valore irrilevante)
RAM[8] = 0 (valore irrilevante)
RAM[9] = 10 (indirizzo di ritorno)
RAM[10] = 5007 (recupera risultato)
RAM[11] = 3000 (stampa risultato)
RAM[12] = 8000 (ferma)

Notare che con questa strutturazione il programmatore del programma principale deve conoscere solo l'entry point (ossia l'indirizzo della prima istruzione) del sottoprogramma ed il numero dei parametri di comunicazione (ossia che la procedura richiede 2 operandi e restituisce un solo risultato che ricopre il primo operando). La parte di automodificazione del codice viene invece interamente confinata nel sottoprogramma, riducendo quindi la possibilità di commettere errori di programmazione.