Vediamo come avviene la compilazione di un programma scritto in linguaggio C.
Il livello più alto è appunto un file in linguaggio C.
Il livello più passo è un file binario (ISA).
Supponiamo di avere un pezzo di codice del genere in C:
void swap(long v[], int k){
// scambia l'elemento in posizione k con quello in k + 1
long temp;
temp = v[k];
v[k] = v[k + 1];
v[k + 1] = temp;
}
in RISC-V, avremmo:
swap:
slli x6, x11, 3 # x6 = k * 8
add x6, x10, x6 # x6 = v + (k * 8)
ld x5, 0(x6) # x5 (temp) = v[k]
ld x7, 8(x6) # x7 = v[k + 1]
sd x7, 0(x6) # v[k] = x7
sd x5, 8(x6) # v[k + 1] = x5 (temp)
jalr x0, 0(x1) # ritorno al chiamante
La compilazione avviene per mezzo di programmi chiamati compilatori.
La compilazione restituisce in output un file in linguaggio assemblativo (assembly).
Dopo di ché avviene una traduzione da linguaggio assembly a linguaggio macchina (ISA, binario).
Specifichiamo ISA, poiché non avviene una traduzione in binario, ma avviene una traduzione in binario che rispetti il set di istruzioni del processore in considerato, in questo caso l'ISA RISC-V.
Se si prendesse il binario di una delle istruzioni elencate nel binario (sopra), si potrebbe riconoscere il formato di una certa istruzione.
Ovviamente il binario generato per un certo set di istruzioni è diverso da quello generato per un altro.
Il programma Java è eseguito da un interprete, la Java Virtual Machine. La JVM può chiamara il compilatore Just In Time (JIT), la compilazione avviene sul posto, prima dell'esecuzione, in base alla macchina su cui deve essere eseguito il programma. Infatti il linguaggio Java, quando viene tradotto, in realtà non viene tradotto nel linguaggio macchina, ma in un linguaggio intermedio chiamato bytecode. Tale codice poi viene, facilmente, interpretato giusto prima dell'esecuzione, senza rendere necessaria una nuova compilazione, cosa che invece avviene per i linguaggi compilati (come C). I linguaggi interpretati come Java sono molto più flessibili, poiché funzionano in diversi sistemi, basta fornirsi della JVM.
Un linguaggio assemblativo è un linguaggio di programmazione a basso livello che rappresenta il set di istruzioni (ISA) di un processore specifico in una forma mnemonica leggibile dall'uomo. Il linguaggio assembly fornisce una corrispondenza diretta tra le istruzioni del processore e le operazioni da eseguire.
La forma mnemonica a cui si fa riferimento sono quei piccoli nomi add, sub, slli, ecc.. che corrispondono direttamente all'ISA del processore.
Istruzioni in forma mnemonica: linguaggio assemblativo che corrisponde ad un ISA.
ISA: set di istruzioni in binario.
In realtà il linguaggio assemblativo fornisce altre semplificazioni al programmatore, quali l'uso di etichette simboliche per variabili e indirizzi, primitive per allocazione in memoria di variabili, costanti, definizioni di macro.
Per passare dal programma in linguaggio assemblativo al programma eseguibile (ISA) si utilizza un programma traduttore detto assemblatore (assembler) che traduci i codice mnemonici nei codici binari corrispondenti alle istruzioni dell'ISA.
Il nome assembler (il nome che indica il programma che effettua la traduzione) viene da molti usato come sinonimo di linguaggio assemblativo (assembly). Il termine linguaggio macchina viene talvolta usato per indicare il linguaggio assemblativo, altre volte per le istruzioni dell'ISA.
Il linguaggio assemblativo consente al programmatore di specificare informazioni indispensabili per la traduzione del programma sorgete in programma oggetto.
Le pseudo-istruzioni non fanno parte del livello ISA, ma sono un alis per una o più istruzioni. Le pseudo-istruzioni quindi non compaiono come tali nel programma oggetto alla fine della fase di traduzione.
Nell'assembly di RISC-V esistono diverse pseudo-istruzioni, che semplificano lo svolgimento di altre operazioni.
Le direttive sono istruzioni speciali utilizzate nel linguaggio assembly per fornire informazioni all'assembler durante la fase di traduzione del codice sorgente. Esse non corrispondono direttamente ad istruzioni macchina, ma forniscono istruzioni all'assemblatore su come gestire e organizzare il codice. Ecco quelle per RISC-V.
Una definizione macro è un modo per assegnare un nome ad una sequenza di istruzioni.
Dopo aver scritto una macro, il programmatore può utilizzare il nome dato alla macro per eseguire la sequenza di istruzioni specificata nella macro.
Sintassi per scrivere una macro in RISC-V:
Se uso la macro:
Quello che otterrò dopo la compilazione, per le linee relative alla macro swap è:
Macro | Precedura |
---|---|
Il corpo di una macro viene sostituito alle chiamate di macro durante la fase di compilazione | Le chiamate a procedure vengono fatte durante l'esecuzione del programma, una chiamata a funzione non sostituisce una singola linea con quelle nel corpo della macro, ma viene effettuato un salto ad un altro punto del programma |
La macro, dopo la compilazione, è come se esistesse (il suo corpo è stato inserito nei punti in cui era chiamata) | La chiamata ad una procedura viene inserita nella traduzione del programma |
Una macro non può ritornare un valore | Una procedura può ritornare un valore |
L'assemblatore traduce un programma scritto in linguaggio assemblativo nel corrispettivo programma in linguaggio macchina eseguibile (ISA).
L'assemblatore legge tutte le istruzioni del programma assemblativo, traduce in linguaggio macchina i codici operativi, i dati e le etichette, controllandone la correttezza sintattica, e restituisce in output il file "oggetto" (binario).
In questo contesto giocano un ruolo importa gli ILC: instruction location counter. Si tratta di un contatore della posizione delle istruzioni, viene utilizzato in fase di assemblaggio dall'assembler per tenere traccia delle istruzioni che vengono via via elaborate. Tale contatore viene incrementato di volta in volta man mano che l'assemblaggio procede. In particolare gli ILC vengono usate per risolvere riferimenti a etichette o variabili all'interno del codice, in modo che possano essere stabiliti collegamenti corretti tra le istruzioni e i dati.
Come vedete: .L20 è un punto del programma a cui il PC dovrebbe saltare nel caso in cui la condizione specificata (bgt) si verifichi (forward reference), mentre .L18 è un altro punto in cui il PC dovrebbe tornare sopra in caso la condizione bne si verifichi (backward reference).
I programmi sono un insieme di procedure (moduli) tradotti separatamente dall'assemblatore. Ogni modulo ha il suo spazio di indirizzamento separato che parte dall'indirizzo 0.
Il linker è un programma che esegue la funzione di collegamento dei moduli oggetto in modo da formare un unico modulo.
Il linker fonde gli spazi di indirizzamento separati dei moduli oggetto in uno spazio lineare unico.
É possibile avere diversi moduli oggetto (file compilati) che fanno parte di un unico programma eseguibile. Il linker collega tutti questi moduli in unico programma binario eseguibile.
In Windows tali librerie terminano in .dll mentre in Linux in .so.
Il linker copia tutti i binari in unico file binario. Avendo riunito tutti i moduli in uno, devono anche essere aggiornati gli indirizzi di salto a quei moduli.
Nel file ELF:
Una volta creato l'eseguibile (ad opera del linker) esso viene memorizzato su un supporto di memoria secondaria, Al momento dell'esecuzione il sistema operativo lo carica in memoria centrale e ne avvia l'esecuzione.
Il loader (che è un programma del sistema operativo) si occupa di:
Come abbiamo descritto nella fase di linking, esso può avvenire in maniera dinamica o statica. Statica se le librerie sono pre-caricate all'interno dell'eseguibile finale.
Dinamica se le librerie sono caricate in fase di esecuzione.
Le DDL sono delle librerie dinamiche di Windows che vengono caricate a tempo di esecuzione.
Il linking statico, risolve il problema di non dover ricompilare tutto il programma, ogni volta che anche una sola libreria cambia, ma non risolve un altro problema: le librerie, anche se a momento di esecuzione, vengono tutte caricate in memoria.
Per risolvere questo problema si è inventato il collegamento pigro (lazy linking). In questo caso, una funzione di libreria viene caricata in memoria, solo dopo che viene chiamata per la prima volta.
Il trucco è che ciascuna procedura, chiamata all'interno di un programma, che è esterna, è indicata chiama una procedura fasulla (una per ogni procedura di libreria esterna diversa). Quando nel codice si effettua una chiamata ad una procedura di una libreria esterna, viene chiamata la procedura fasulla ad essa associata che contiene un'istruzione di salto indiretto. La prima volta che la funzione viene chiamata, il programma salta all'istruzione fasulla ad essa associata ed esegue il salto indiretto indicato. Il salto porta a una porzione di codice che inserisce un numero in un registro, il quale identifica la funzione di libreria desiderata, e quindi salta a sua volta al linker loader dinamico, che trova la funzione di libreria cercata e la rimappa in memoria e cambia l'indirizzo contenuto nell'istruzione di salto indiretto, in modo che la procedura fasulla, salti da quel momento in poi alla vera funzione di libreria.