Capitolo 3.   I linguaggi a basso livello

I linguaggi a basso livello sono quei linguaggi di programmazione che hanno un basso livello di astrazione e sono orientati alla macchina dipendendo in modo molto accentuato dalle caratteristiche hardware di quest'ultima e in particolare della CPU.

Nella scala di astrazione dei linguaggi al gradino più basso troviamo il linguaggio macchina, seguito da vicino dai linguaggi assembly; tutti gli altri linguaggi di programmazione hanno un grado di astrazione molto più elevato e vengono denominati linguaggi di alto livello o orientati al problema.

3.1   Il linguaggio macchina

Si dice istruzione macchina una istruzione corrispondente ad una operazione direttamente eseguibile dall'Hardware dell'elaboratore; l'insieme di tali istruzioni si chiama Linguaggio macchina.

I programmi che devono essere eseguiti da un elaboratore elettronico devono risiedere in memoria centrale, secondo quanto proposto da John Von Neumann nel 1946.

L'esecuzione avviene istruzione dopo istruzione in modo sequenziale e le istruzioni devono essere espresse in linguaggio macchina.

Le istruzioni macchina hanno le seguenti caratteristiche:

3.1.1   Il ciclo istruzione

Come abbiamo visto in precedenza, l'esecuzione di ogni istruzione avviene grazie ad una serie di operazioni controllate dalla CU, che coinvolgono la memoria centrale, la ALU ed eventualmente altri dispositivi del sistema di elaborazione; tale serie di operazioni è chiamata ciclo istruzione.

Un ciclo istruzione è costituito dalle seguenti fasi:

  1. Fetch o prelevamento: l'istruzione il cui indirizzo è specificato nel registro PC viene prelevata dalla memoria centrale e portata nel registro IR; al termine di tale operazione il valore del PC viene incrementato in modo da fare riferimento all'indirizzo della prossima istruzione da eseguire;

  2. Decode o decodofica: il decodificatore delle istruzioni interpreta il codice operativo dell'istruzione e invia opportuni segnali di attivazione dei dispositivi (ALU, controllori di I/O ecc.) che devono eseguire l'istruzione;

  3. Execute o esecuzione: l'operazione corrispondente all'istruzione viene eseguita.

Ovviamente al termine di un ciclo istruzione inizia immediatamente quello riguardante la successiva istruzione.

3.1.2   Descrizione e classificazione delle istruzioni macchina

In generale una istruzione macchina può essere così schematizzata:

Codice operativo Operandi

La parte codice operativo indica il tipo di istruzione (ad esempio 00000011 per la somma, 00000001 per lo spostamento ecc.); la sua lunghezza in bit è in relazione con il numero di istruzioni eseguibili dall'elaboratore: 2**K istruzioni se i codici sono lunghi K bit.

La parte operandi è variabile; la sua lunghezza e la sua presenza dipendono dal tipo di istruzione; in essa si fa riferimento a registri, ad indirizzi di celle di memoria o a entrambi.

Naturalmente anche gli operandi sono indicati in binario.

La lunghezza totale di una istruzione dipende dall'elaboratore e nello stesso elaboratore possono essere previste istruzioni di lunghezza diversa; essa è comunque sempre un multiplo della lunghezza di una cella di memoria; avremo quindi istruzioni lunghe 2, 4, 6 .... Byte.

3.1.2.1   Classificazione delle istruzioni per funzione svolta

Le istruzioni macchina possono essere classificate nel seguente modo in base ai tipi di operazioni svolte:

Fra le istruzioni di salto ne esistono di due tipi:

Per il sistema l'esecuzione di un istruzione di salto è molto semplice: viene scritto l'indirizzo a cui si deve saltare nel registro PC al posto dell'indirizzo della prossima istruzione.

Supponiamo ad esempio che la CPU stia eseguendo l'istruzione memorizzata all'indirizzo 150 e che tale istruzione (qui scritta per semplicità in Italiano e non in binario) sia salta a 200 e occupi 2 byte; al termine della fase di fetch di tale istruzione il valore del PC viene posto a 152 (indirizzo della prossima istruzione da eseguire senza salti); dopo la fase di decode, che riconosce l'istruzione di salto incondizionato, quest'ultimo viene eseguito aggiornando il valore del PC a 200.

3.1.2.2   Classificazione delle istruzioni per numero di indirizzi

Quando una istruzione macchina ha fra i suoi operandi dei dati contenuti nella memoria centrale deve far riferimento agli indirizzi in cui essi risiedono.

Vediamo come si possono classificare le istruzioni in base al numero di indirizzo esaminando un piccolo esempio concreto:

supponiamo di avere un elaboratore in cui gli indirizzi di memoria vengono indicati con 16 bit e i codici operativi con 8 bit; supponiamo inoltre di voler svolgere una somma tra il contenuto delle celle di memoria 10 e 12 ponendo il risultato nella cella 20 e che il codice operativo della somma sia 00000011.

Si noti che il concetto di unità centrale a stack è indipendente dall'utilizzo di parte della memoria centrale come stack, effettuato da molti elaboratori grazie ad un apposito registro detto stack pointer (SP) che è il registro che contiene l'indirizzo della locazione di testa della pila.

3.1.2.3   Classificazione delle istruzioni per modo di indirizzamento

Le istruzioni macchina si possono classificare anche in base al modo in cui viene fatto riferimento agli indirizzi di memoria dei dati coinvolti in esse.

Si ha quindi la classificazione in base al modo di indirizzamento:

Sono previsti anche altri tipi di indirizzamento più complessi, come indiretto indexato, allo scopo di potenziare le possibilità della programmazione a basso livello (in assembly); d'altra parte questi indirizzamenti richiedono vari accessi in memoria e molti calcoli e quindi diminuiscono l'efficienza dei programmi.

3.1.3   Esempio di programma in linguaggio macchina

Vediamo, in un ipotetico linguaggio macchina, un banale programma che effettui la sottrazione tra due numeri interi positivi, di cui il primo è il maggiore, togliendo ciclicamente una unità ad entrambi finché il secondo numero non si azzera; a quel punto il primo numero sarà il risultato.

Prima, per i lettori che già conoscono linguaggi più evoluti, vediamo una possibile soluzione in linguaggio C.

int main()
{
    int dato1,dato2,ris;
    dato1=15;
    dato2=12;
    while (dato2!=0) {
        dato1=dato1-1;
        dato2=dato2-1;
    }
    ris=dato1;
}

Supponiamo di avere un elaboratore con parole a 16 bit, con 16 registri numerati da 0 a 15 ciascuno dei quali può contenere un numero intero.

Supponiamo poi che le istruzioni macchina siano composte da tre campi: il primo di 8 bit è il codice operativo, il secondo di 4 bit indica il registro, il terzo di 16 bit indica l'indirizzo di memoria della cella alla quale ci si vuole riferire.

Le istruzioni da utilizzare sono le seguenti:

Istruzione Significato
01011000 X Y sposta nel registro X il contenuto della cella di indirizzo Y
01010000 X Y sposta il contenuto del registro X nella cella di indirizzo Y
01011011 X Y sottrai dal contenuto del registro X il contenuto cella di indirizzo Y
00000111 X Y salta all'istruzione di indirizzo Y se il contenuto del registro X è diverso da zero, altrimenti prosegui in sequenza
10000010 stop

I due numeri da sottrarre si trovano nelle posizioni di indirizzo:

0000000010010110  = 150  e
0000000010011000  = 152

mentre la costante 1 è nella cella di indirizzo:

0000000010011010  = 154

il risultato verrà memorizzato nella cella di indirizzo:

0000000010011100  = 156

Il programma è posto in memoria a partire dalla posizione:

0000000001111010  = 122 

Ogni istruzione occupa due parole (32 bit di cui 4 inutilizzati).

Il programma in linguaggio macchina è il seguente:

Indirizzo               Istruzione

0000000001111010 (122)  01011000 0001 0000000010010110
0000000001111110 (126)  01011000 0010 0000000010011000
0000000010000010 (130)  01011011 0001 0000000010011010
0000000010000110 (134)  01011011 0010 0000000010011010
0000000010001010 (138)  00000111 0010 0000000010000010
0000000010001110 (142)  01010000 0001 0000000010011100
0000000010010010 (146)  10000010 0000 0000000000000000

Le prime due istruzioni caricano i contenuti delle posizioni 150 e 152 nei registri 1 e 2 rispettivamente; con la terza e la quarta si sottrae 1 ad entrambe; l'istruzione all'indirizzo 138 esegue il test sul secondo registro: se diverso da zero torna all'istruzione della cella 130 altrimenti passa alla successiva istruzione nella quale si memorizza il contenuto del registro 1 (che contiene il risultato) nella cella 156; quindi il programma termina.

3.2   Il linguaggio assembly

Il linguaggio assembly è il linguaggio di programmazione simbolico più a basso livello cioè più vicino alla logica di funzionamento della macchina.

Esso si distingue dai linguaggi di alto livello come il C, il Pascal, il COBOL e qualche altro centinaio che invece sono più vicini al modo di ragionare e operare dell'uomo e in varia misura specializzati per la realizzazione di soluzioni di problemi applicativi negli ambiti più vari.

Il linguaggio assembly comunque costituisce già un importante passo in avanti rispetto al linguaggio macchina permettendo di superare alcuni grossi limiti di quest'ultimo.

3.2.1   Problemi nell'uso del linguaggio macchina

Da quanto visto circa le caratteristiche del linguaggio macchina emergono in modo abbastanza netto le difficoltà nello scrivere programmi servendosi di questo strumento; in sintesi:

Questi problemi vengono risolti nei linguaggi di programmazione, sia a basso che alto livello con l'uso di:

3.2.2   Traduzione dei programmi assembly

E' opportuno parlare di una pluralità di linguaggi assemblativi in quanto, esattamente come per il linguaggio macchina, ogni tipo di elaboratore (o meglio, di processore) ha il proprio.

Infatti si può affermare che l'assembly costituisce la rappresentazione simbolica del linguaggio macchina e che c'è una corrispondenza «uno a uno» tra le istruzioni in assembly e quelle in linguaggio macchina.

L'assembly, come accennato, ha però il grosso vantaggio di prevedere l'uso di codici operativi mnemonici, nomi di variabili e etichette per le istruzioni e quindi permette una scrittura dei programmi meno laboriosa anche se ancora vincolata alla conoscenza delle caratteristiche logiche e fisiche del sistema di elaborazione.

Naturalmente un programma scritto in questo linguaggio (programma sorgente) non può essere immediatamente eseguito dall'elaboratore; esso deve essere prima tradotto in linguaggio macchina.

Questa operazione viene svolta da programmi traduttori detti assemblatori o assembler.

Quindi la scrittura (editing) di un programma in linguaggio assembly è solo la prima operazione da compiere per arrivare a poter eseguire il programma stesso; nello schema della figura 3.4 vediamo le varie fasi che conducono dalla scrittura del sorgente all'esecuzione del programma con l'indicazione del risultato prodotto da ogni fase e, in corsivo, dello strumento da usare per svolgere la fase stessa.

Figura 3.4.

figure/schema-traduzione-assembly

Nello schema i flussi di ritorno sono dovuti all'esigenza di dover correggere il programma sorgente a seguito di eventuali errori riscontrati nella fase di traduzione e di linking (errori di sintassi) oppure in sede di esecuzione (errori run-time o errori logici).

Nel seguito descriviamo in dettaglio solo le fasi di traduzione, linking e caricamento; per le altre diamo solo qualche indicazione.

La stesura dei sorgenti può essere fatta con un qualsiasi editor di testo, anche molto elementare e questa può essere la scelta da preferire specialmente a fini didattici.

Per ottimizzare e velocizzare le attività di produzione dei programmi esistono invece degli strumenti, chiamati IDE (Integrated Development Environment), che mettono a disposizione tutto l'occorrente per gestire le varie fasi, dalla stesura del sorgente al collaudo.

Riguardo al collaudo notiamo che esso può essere svolto dal programmatore che ha scritto il programma o anche a altre persone; può avvenire eseguendo il programma con dei dati di prova e, in caso si riscontrino errori o bug, può avvalersi di strumenti di aiuto per la loro individuazione chiamati debugger (il cui uso qui non approfondiamo).

3.2.2.1   Il programma assemblatore

Una volta scritto il programma sorgente occorre passarlo in input all'assemblatore in modo da ottenerne la traduzione in codice macchina.

Prima di illustrare come avviene tale operazione, ricordiamo che ogni categoria di processore ha il proprio repertorio di istruzioni e quindi il proprio linguaggio macchina; di conseguenza anche il linguaggio assembly si differenzia in base al tipo di CPU e, in qualche caso, si hanno anche versioni diverse del linguaggio per lo stessa architettura di processore.

Nel caso dei processori Intel x86 si hanno poi linguaggi assembly che sono basati su due modi diversi di scrivere le istruzioni e si parla di sintassi Intel e sintassi AT&T; nel primo caso si tratta prevalentemente di prodotti per le piattaforme DOS/Windows, nel secondo per le piattaforme Unix/Linux (il riferimento a AT&T è dovuto al fatto che il primo Unix è nato presso tale azienda).

Possiamo elencare alcune delle versioni di assembly x86 più diffuse e usate:

L'uso di un linguaggio assembly piuttosto di un altro fa si che la stessa istruzione venga scritta in modi anche molto diversi; ad esempio lo spostamento del contenuto del registro bx in ax viene scritto con le seguenti istruzioni, rispettivamente in GAS e in NASM:

      1 ; GAS
      2 movw %bx, %ax
      3 ;NASM
      4 mov  ax, bx

Dopo la traduzione però si ottiene la stessa istruzione macchina (il linguaggio macchina per quel tipo di CPU è unico) espressa in esadecimale come: 89d8.

Lo scopo di questo paragrafo è fornire alcune conoscenze generali su come viene reso eseguibile un sorgente assembly e su come opera un assemblatore e non illustrare tutti i dettagli sull'uso di una delle possibili versioni alternative del linguaggio; di conseguenza non approfondiamo, in questa sede, neppure le differenze esistenti tra sintassi Intel e sintassi AT&T.

Per questi aspetti più pratici, relativi alla programmazione assembly, si possono consultare le dispense dello stesso autore dal titolo: «Programmare in assembly in GNU/Linux con sintassi AT&T» reperibili all'indirizzo <http://www.maxplanck.it/materiali> selezionando la scelta «VEDI MATERIALE» e cercando poi in base al docente scegliendo il nome Ferroni Fulvio.

Ci serviamo comunque di un piccolo esempio di sorgente scritto in assembly AT&T in modo da poter spiegare più agevolmente in che modo opera il programma assemblatore.

      1 // Dati
      2 .data
      3 num1:    .byte 150
      4 num2:    .byte 74
      5 tot:     .word 0
      6 /* Istruzioni */
      7 .text
      8 .globl _start
      9 _start:
     10    movw   $0, %ax     # pulisce il reg. ax
     11    movb   (num1), %al
     12    addb   (num2), %al
     13    movw   %ax, (tot)
     14 fine:
     15    movl   $1, %eax
     16    int    $0x80

Nel listato la numerazione è stata aggiunta per facilitarne la descrizione; le righe o le stringhe che iniziano con «//» e con «#» oppure che iniziano con «/*» e terminano con «*/», sono dei commenti.

Il programma svolge una elaborazione molto banale che consiste nel sommare due valori, grandi un byte ciascuno, contenuti nelle celle di memoria corrispondenti alle etichette num1 e num2, ponendo poi il risultato nella cella identificata dall'etichetta tot grande due byte (una word).

Il calcolo viene fatto usando il registro accumulatore a 16 bit ax, o meglio gli 8 bit della sua metà «bassa» al, e avviene nelle righe tra la 10 e la 13.

Alla riga 15 si fa invece uso del registro accumulatore esteso eax a 32 bit.

I codici operativi delle istruzioni comprendono un suffisso che indica la lunghezza dei dati coinvolti:

A riga 14 è definita una etichetta istruzioni fine: che è inserita solo a scopo dimostrativo; servirebbe per i salti, ma qui non viene utilizzata.

Le altre istruzioni (righe 9, 15, 16) sono «fisse» ed essenziali per il corretto funzionamento di qualsiasi programma scritto in assembly AT&T.

Nella prima parte del listato (righe 2-5) sono definiti i dati che il programma deve utilizzare; si tratta del segmento dati (.data), seguito poi dal segmento istruzioni (.text).

Il fatto che il programma sia suddiviso in segmenti (ci sarebbero anche un segmento stack e un segmento extra che qui non vengono usati) rispecchia la gestione segmentata della memoria da parte dei processori x86.

In questo piccolo esempio sono presenti tutti i tre tipi di istruzioni che si possono trovare in un sorgente assembly, e precisamente:

Per la precisione, nel nostro esempio, le direttive sono alle righe 2, 7 e 8 e informano l'assemblatore di quali sono il segmento dati e il segmento istruzioni e di dove inizi la parte esecutiva del listato.

Alle righe 3, 4, 5 abbiamo tre istruzioni dichiarative contenenti etichette dati, alle righe 9 e 14 due etichette istruzioni, le altre (a parte i commenti) sono esecutive.

In precedenza avevamo accennato al fatto che esista una corrispondenza «uno a uno» tra istruzioni macchina e istruzioni assembly; questo è vero se si considerano tra queste ultime solo le esecutive e se non si considerano quelle che richiamano sottoprogrammi o routine come int $0x80 (genera un interrupt software corrispondente al codice esadecimale 80 che fa eseguire la routine di chiusura dell'esecuzione).

L'assemblatore non traduce in binario le istruzioni direttive e neppure le dichiarative; le prime, come detto servono a passargli informazioni circa lo svolgimento del suo compito, le altre le usa per una prima fase della traduzione che ci accingiamo a descrivere.

I compiti di un assemblatore sono i seguenti:

Tutti gli assemblatori svolgono la loro funzione di traduzione in due passate:

Il motivo per cui sono necessarie due passate è che durante la traduzione si possono incontrare riferimenti simboli ancora non definiti che l'assemblatore non saprebbe come «risolvere».

Vediamo più in dettaglio cosa avviene nella prima passata:

Anche per la seconda passata entriamo più in dettaglio:

Nella descrizione delle operazioni svolte nelle due passate si è fatto più volte riferimento a indirizzi di dati e istruzioni; essi si riferiscono ad uno spazio «fittizio» di memoria detto spazio logico, che può o non può coincidere con lo spazio fisico che verrà assegnato al programma eseguibile.

Nel primo caso si parla di programma assoluto, nell'altro di programma rilocabile.

Se il programma è rilocabile, gli indirizzi dello spazio logico sono indirizzi relativi, calcolati rispetto all'inizio del programma (indirizzo relativo zero); essi potranno essere variati in sede di linking quando, come vedremo tra breve, il nostro modulo oggetto viene unito ad altri moduli oggetto e lo spazio di memoria diviene unico.

La loro trasformazione in indirizzi reali (o assoluti) avviene con una operazione denominata rilocazione che può essere:

Un'altra osservazione importante riguarda i simboli o etichette che, come abbiamo visto, possono essere predefiniti oppure no; nel secondo caso si possono ulteriormente distinguere in:

Nel caso dei simboli esterni l'associazione ai rispettivi indirizzi relativi non può essere fatta dall'assemblatore in quanto essi non sono definiti nel programma che è in fase di traduzione; tale operazione è «rimandata» alla fase di linking, nella quale il nostro modulo oggetto è unito ad altri moduli oggetto in cui deve esistere la definizione di tali simboli (altrimenti viene segnalato un errore fatale che impedisce la creazione del programma eseguibile).

3.2.2.2   La fase di linking

La fase di traduzione non conduce alla creazione del programma eseguibile perché è necessaria una ulteriore operazione chiamata linking (l'unico termine italiano che possa essere una traduzione accettabile è «collazione», ma si preferisce usare il termine inglese) compiuta da un programma linker.

In questa fase il nostro modulo oggetto viene unito ad altri moduli oggetto che possono essere:

Contemporaneamente viene anche completata l'assegnazione degli indirizzi ai simboli definiti come esterni in qualche modulo e vengono aggiornati tutti gli indirizzi rilocabili.

Al termine si ha la creazione del programma eseguibile con il suo spazio logico di memoria, pronto per essere caricato nella memoria centrale per l'esecuzione.

Quello appena descritto è però solo uno dei modi in cui può avvenire il linking; abbiamo infatti:

Il linking statico ha il vantaggio di assicurare migliori prestazioni da parte dell'eseguibile, che non deve interrompersi per il caricamento di moduli ulteriori ed è preferibile se si vuole creare un programma il più possibile indipendente dall'ambiente in cui sarà eseguito (in quanto ha al suo interno tutti i moduli che servono per farlo funzionare).

Il linking dinamico, d'altra parte, permette di ottenere programmi eseguibili molto più snelli e non costringe a ricreare di nuovo l'eseguibile ogni volta che viene fatta una piccola modifica ad un qualsiasi modulo (basta tradurre solo il modulo modificato in modo da avere il rispettivo modulo oggetto aggiornato).

Nei moderni ambienti operativi si preferisce l'alternativa del linking dinamico ma si può ricorrere a quello statico qualora esigenze particolari lo richiedano.

3.2.2.3   Il caricamento del programma

Quando un programma viene lanciato in esecuzione c'è un'ulteriore fase che deve essere svolta e che consiste nel caricamento del programma in memoria centrale; talvolta non ci si accorge della presenza di questa operazione a meno che il programma non sia molto grande e richieda quindi un tempo abbastanza lungo per essere trasferito.

Tale compito è svolto dal programma loader che copia l'eseguibile dalla memoria di massa in cui esso è conservato nella RAM.

Ricordiamo che questo passo è essenziale affinché il programma possa essere eseguito in accordo con il concetto di programma memorizzato espresso da Von Neumann nel 1946.

Possiamo avere due tipi di caricamento:

Notiamo anche che il caricamento in memoria può essere dinamico solo se il programma è rilocabile.