Imparare il linguaggio di assemblaggio

Il linguaggio di assemblaggio è l’equivalente leggibile dall’uomo al più basso livello di software di programmazione del computer – il codice macchina.

Mentre il computer capisce tutti i programmi come numeri, dove vari numeri diversi istruiscono il computer a fare diverse operazioni, questo è troppo noioso per il consumo umano (figuriamoci per l’autore). Pertanto, gli umani programmano usando il linguaggio assembly, che ha una corrispondenza quasi 1:1 con il codice macchina.

Impara il linguaggio di programmazione C. Se conosci il C (o C++), l’assemblaggio sarà più facile.

C# e Java sono buoni linguaggi, ma ci sono pochi o nessun strumento per mostrarti come appare il codice C# e Java in linguaggio assembly. (Infatti Java e C# hanno il loro linguaggio assembly byte code che è piuttosto diverso, e distante, dalle reali architetture di istruzioni della CPU.)

Dal codice C (e C++) possiamo usare i compilatori per mostrarci come appare il linguaggio assembly. Il C è un linguaggio di basso livello, e se sapete come ogni costrutto del C si traduce in linguaggio assembly, conoscerete il linguaggio assembly! Esempi dei costrutti C che vogliamo conoscere per il linguaggio assembly sono:

C Construct Assembly Language Equivalent
variabile locale registroCPU o posizione di memoria stack
variabile globale accesso globale o posizione di memoria const
riferimento ad una matrice un modello di addizione e accesso alla memoria
goto istruzione di diramazione incondizionata
if statement un modello in if-goto: test condizionale & istruzioni di diramazione
while loop un modello in if-goto
assegnazione modifica del registro o della memoria
istruzioni aritmetiche aritmetiche
parametro passaggio modifica del registro o della memoria dello stack
parametro ricezione accesso al registro o alla memoria dello stack
funzione call istruzione call
funzione entrata prologo
funzione uscita epilogo
valore di ritorno modifica del registro ed epilogo

Come il linguaggio assembly differisce dagli altri linguaggi di programmazione

Nel linguaggio assembly, sarete esposti alle nude risorse dell’hardware della CPU sottostante. (Inoltre, le cose che possiamo scrivere in C in una riga richiedono diverse righe in assembly.)

  • Sarà consapevole di cose come i registri della CPU e lo stack delle chiamate – queste risorse devono essere gestite per far funzionare un programma (e in altri linguaggi, queste risorse sono gestite automaticamente per noi). (I registri della CPU sono poco importanti in confronto alla memoria, tuttavia, sono superveloci, e anche consistenti nelle loro prestazioni.)

  • Non ci sono istruzioni strutturate nel linguaggio assembly – quindi, tutti i costrutti di flusso di controllo devono essere programmati in uno stile “If-Goto” (in altri linguaggi, il compilatore traduce le istruzioni strutturate in If-Goto.) Lo stile if-goto usa le etichette come obiettivi. (Mentre il C supporta l’uso di goto ed etichette, i programmatori preferiscono dichiarazioni strutturate per la maggior parte del tempo, quindi non vediamo spesso goto ed etichette in C.)

Linguaggio Assembly vs. Codice macchina

Il linguaggio assembly è pensato per essere letto e scritto dagli umani.

Etichette

In linguaggio assembly, usiamo molte etichette. L’uso delle etichette si traduce in numeri nel codice macchina e le etichette scompaiono nel codice macchina. Le etichette (rispetto all’uso diretto dei numeri) rendono possibile fare piccoli cambiamenti ai programmi – senza di loro anche un piccolo cambiamento sarebbe in realtà un enorme cambiamento.

Il processore ha bisogno di sapere dove si trova un operando, forse un operando di dati o forse una locazione di codice. Per le posizioni di codice, per esempio, tipicamente il processore vorrà sapere quanto lontano è quell’operando dalla posizione di codice che sta attualmente eseguendo – questo è chiamato indirizzamento pc-relativo. Così, per eseguire un’istruzione if-then, potremmo dire al processore: sotto certe condizioni, salta avanti N istruzioni, dove le N rappresentano la dimensione o il conteggio delle istruzioni da saltare (o meno), cioè N è la dimensione della parte then – che sarebbe in codice macchina. Se aggiungiamo un’istruzione all’then-part, il numero di istruzioni da saltare sale (e se togliamo un’istruzione dall’then-part, scende).

L’uso delle etichette tiene automaticamente conto dei cambiamenti (ad es. aggiungendo o rimuovendo istruzioni) – un’istruzione di diramazione che usa un’etichetta come destinazione si adatta ai cambiamenti nel conteggio di quante istruzioni devono essere saltate condizionatamente.

Istruzioni pseudo

La maggior parte delle istruzioni in linguaggio assembly si traduce 1:1 in istruzioni del codice macchina. Tuttavia, occasionalmente, dobbiamo dire all’assemblatore qualcosa che non si traduce direttamente in codice macchina. Esempi tipici sono dire all’assemblatore che le seguenti istruzioni sono codice (di solito tramite una direttiva .text) contro dati (di solito tramite una direttiva .data). Come le istruzioni regolari, queste istruzioni sono tipicamente scritte sulla propria linea, tuttavia, informano l’assemblatore piuttosto che generare direttamente istruzioni di codice macchina.

Scrivere il linguaggio assembly

In C, scriviamo dichiarazioni; queste dichiarazioni vengono eseguite consecutivamente una dopo l’altra, per default in modo sequenziale, finché qualche costrutto di flusso di controllo (loop, if) cambia il flusso. Nel linguaggio assembly è vero lo stesso.

In C, gli statement hanno effetti sulle variabili – possiamo considerare che statement separati comunicano tra loro attraverso le variabili. Lo stesso è vero nel linguaggio assembly: le istruzioni comunicano tra loro attraverso l’effetto che hanno sulle variabili, anche se poiché ci sono più istruzioni (necessariamente, poiché le istruzioni sono più semplici), ci saranno anche necessariamente più variabili, molte sono temporanee di breve durata usate per comunicare da un’istruzione alla successiva.

Programmazione di base in linguaggio assembly

Ci alterniamo tra la selezione del registro e la selezione delle istruzioni. Poiché le istruzioni del linguaggio assembly sono abbastanza semplici, spesso abbiamo bisogno di usare diverse istruzioni, sì, interconnesse tramite valori come variabili nei registri della cpu. Quando questo accade, avremo bisogno di selezionare un registro per tenere i risultati intermedi.

Selezione del registro

Per selezionare il registro, abbiamo bisogno di un modello mentale di quali registri sono occupati e quali sono liberi. Con questa conoscenza possiamo scegliere un registro libero da usare per tenere un risultato temporaneo (di breve durata). Quel registro rimarrà occupato fino al nostro ultimo utilizzo, poi tornerà ad essere libero – una volta libero può essere riproposto per qualcosa di completamente diverso. (In un certo senso, il processore non sa davvero che stiamo facendo questa costante riorganizzazione poiché non conosce l’intento del programma.)

Selezione dell’istruzione

Una volta stabilite le risorse da usare, possiamo scegliere l’istruzione specifica di cui abbiamo bisogno. Spesso non troveremo un’istruzione che faccia esattamente quello che vogliamo, quindi dovremo fare un lavoro con 2 o più istruzioni, il che significa, sì, più selezione di registri per i temporanei.

Per esempio, MIPS e RISC V sono architetture simili che forniscono un’istruzione di ramo compare &, ma possono confrontare solo due registri della cpu. Quindi, se vogliamo vedere se un byte in una stringa è un certo carattere (come un newline), allora dobbiamo fornire (caricare) quel valore di newline (una costante numerica) in un registro della cpu prima di poter usare l’istruzione di ramo compare &.

Chiamata di funzione & Ritorno

Un programma consiste forse di migliaia di funzioni! Anche se queste funzioni devono condividere la memoria, la memoria è vasta, e quindi possono andare d’accordo. Tuttavia, con una CPU c’è un numero limitato e fisso di registri (come 32 o 16), e tutte le funzioni devono condividere questi registri!!!

Per far funzionare questo, i progettisti di software definiscono delle convenzioni – che possono essere considerate come regole – per condividere queste risorse fisse limitate. Inoltre, queste convenzioni sono necessarie affinché una funzione (un chiamante) possa chiamare un’altra funzione (un chiamante). Le regole si riferiscono alla convenzione di chiamata, che identifica per il chiamante/calcolatore, dove posizionare/trovare argomenti e valori di ritorno. Una convenzione di chiamata è parte dell’Application Binary Interface, che in generale ci dice come una funzione dovrebbe comunicare con il mondo esterno (altre funzioni) nel codice macchina. La convenzione di chiamata è definita per l’uso del software (all’hardware non interessa).

Questi documenti ci dicono cosa fare nel passaggio dei parametri, nell’uso del registro, nell’allocazione/deallocazione dello stack e nei valori di ritorno. Questi documenti sono pubblicati dal fornitore del set di istruzioni, o dal fornitore del compilatore, e sono ben noti in modo che il codice da vari posti possa interoperare correttamente. Le convenzioni di chiamata pubblicate dovrebbero essere prese come date, anche se l’hardware non lo sa.

Senza tale documentazione, non sapremmo come fare queste cose (passaggio di parametri, ecc.). Non dobbiamo usare convenzioni di chiamata standard, ma è poco pratico inventare le nostre per qualsiasi cosa tranne un semplice programma giocattolo eseguito su un simulatore. Lanciare la nostra convenzione di chiamata significa nessuna interoperabilità con altro codice, cosa di cui abbiamo bisogno in qualsiasi programma realistico, come fare chiamate di sistema e di libreria.

L’hardware non conosce né si preoccupa delle convenzioni di utilizzo del software. Semplicemente esegue ciecamente istruzione dopo istruzione (molto velocemente). Per esempio, per l’hardware, la maggior parte dei registri sono equivalenti, anche se i registri sono partizionati o assegnati ad usi particolari dalle convenzioni del software.

Registri dedicati

Alcuni registri della CPU sono dedicati a scopi specifici. Per esempio, la maggior parte delle CPU al giorno d’oggi ha una qualche forma di puntatore allo stack. Su MIPS e RISC V il puntatore allo stack è un registro ordinario indifferenziato dagli altri, tranne che per la definizione delle convenzioni software – la scelta di quale registro è arbitraria, ma è effettivamente scelta, e questa scelta è pubblicata in modo che tutti sappiamo quale registro è. Su x86, invece, il puntatore allo stack è supportato da istruzioni dedicate push & pop (così come le istruzioni di chiamata e ritorno), quindi per il registro del puntatore allo stack c’è solo una scelta logica.

Partizionamento dei registri

Per condividere gli altri registri tra migliaia di funzioni, essi sono partizionati, per convenzione software, tipicamente in quattro gruppi:

  • Registri per passare parametri
  • Registri per ricevere valori di ritorno
  • Registri che sono “volatili”
  • Registri che sono “non volatili”

Parametro & Registri di ritorno

I registri dei parametri sono usati per passare parametri! Aderendo alla convenzione di chiamata, i chiamanti sanno dove trovare i loro argomenti, e i chiamanti sanno dove mettere i valori degli argomenti. I registri dei parametri sono usati per il passaggio dei parametri secondo le regole stabilite nella convenzione di chiamata, e, quando vengono passati più parametri di quanti ne permetta la convenzione per la CPU, il resto dei parametri viene passato usando la memoria dello stack.

I registri dei valori di ritorno contengono i valori di ritorno delle funzioni, così quando una funzione completa, il suo compito è di mettere il suo valore di ritorno nel registro appropriato, prima di restituire il flusso di controllo al chiamante. Aderendo alla convenzione di chiamata, sia il chiamante che il destinatario sanno dove trovare il valore di ritorno della funzione.

Registri volatili & non volatili

I registri volatili sono liberi di essere usati da qualsiasi funzione senza preoccuparsi dell’uso precedente o dei valori in quel registro. Questi sono buoni per i temporanei necessari per interconnettere una sequenza di istruzioni, e, possono anche essere usati per le variabili locali. Tuttavia, poiché questi registri sono liberi di essere usati da qualsiasi funzione, non possiamo contare sul fatto che mantengano i loro valori attraverso una chiamata di funzione – per definizione (convenzione software)! Così dopo il ritorno della funzione il suo chiamante non può dire quali valori ci sarebbero in quei registri – ogni funzione è libera di riutilizzarli, anche se con la consapevolezza che non possiamo contare sul fatto che i loro valori siano conservati dopo il ritorno di una funzione chiamata. Così, questi registri sono usati per variabili e temporanei che non hanno bisogno di attraversare una chiamata di funzione.

Per le variabili che hanno bisogno di attraversare una chiamata di funzione, ci sono registri non volatili (e anche la memoria dello stack). Questi registri sono conservati attraverso una chiamata di funzione (dal software che aderisce alla convenzione di chiamata). Così, se una funzione desidera avere una variabile in un registro veloce della CPU, ma questa variabile è viva attraverso una chiamata (impostata prima di una chiamata, e usata dopo la chiamata), possiamo scegliere un registro non volatile, con il requisito (definito dalla convenzione di chiamata) che il valore precedente del registro sia ripristinato al ritorno al suo chiamante. Questo richiede di salvare il valore corrente del registro non volatile (nella memoria dello stack) prima di riproporre il registro, e di ripristinarlo (usando il valore precedente dalla memoria dello stack) al ritorno.

Lascia un commento

Il tuo indirizzo email non sarà pubblicato.