Assemblatore, Loader e Dynamic linker

Pasted image 20230618121728.png
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.

Pasted image 20230621171756.png
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.

Traduzione Java

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.

Linguaggio assemblativo
...

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.

Attenzione c'è un po' di confusione sulla nomenclatura

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.

Pseudo-istruzioni
...

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.

Assembly directives
...

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.
Pasted image 20230621175005.pngPasted image 20230621175025.png

Macro
...

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:
Pasted image 20230621175447.png

Se uso la macro:
Pasted image 20230621175505.png

Quello che otterrò dopo la compilazione, per le linee relative alla macro swap è:
Pasted image 20230621175536.png

MacroPrecedura
Il corpo di una macro viene sostituito alle chiamate di macro durante la fase di compilazioneLe 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 valoreUna procedura può ritornare un valore

Assembler
...

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).
Pasted image 20230621180706.png
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.
Pasted image 20230621182215.png
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).

Assemblatore a due passi
...
  1. Individuazione di tutti i nomi (etichette) che compaiono come riferimento simbolico di dati (.word array ...) o di istruzioni (macro, pseudoistruzioni), dopo di ché effettua la creazione di una tabella dei simboli (symbol table) che contiene le etichette con la loro posizione relativa all'interno del programma.
  2. Traduzione dei codici mnemonici delle istruzioni, degli operandi e delle etichette, mediante la consultazione della tabella dei simboli creata nel passo 1.
    Pasted image 20230621181018.png
Primo passo
...

Pasted image 20230621181425.png
Pasted image 20230621182553.png

Secondo passo
...

Pasted image 20230621182712.png

Linker
...

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.

  • Costruisce una tabella di tutti i moduli oggetto e le loro lunghezze.
  • Assegna un indirizzo di inizio ad ogni modulo oggetto.
  • Trova tutte le istruzioni che accedono alla memoria e aggiunge a ciascun indirizzo una relocation costant corrispondente all'indirizzo di partenza del suo modulo.
  • Trova tutte le istruzioni che fanno riferimento ad altri moduli e le aggiorna con l'indirizzo corretto.

É 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.
Pasted image 20230618121820.png

  • Linker Statico: con il linker statico, la risoluzione dei riferimenti avviene durante la fase di compilazione e di creazione dell'eseguibile. Durante questa fase, il linker unisce (linka) tutti i moduli oggetto generati dalla compilazione in un singolo file eseguibile. Tutte le librerie necessarie vengono incluse direttamente nell'eseguibile. In pratica, ogni volta che si esegue il programma, le funzioni e le librerie utilizzate sono già presenti all'interno dell'eseguibile stesso. Ciò significa che il programma può essere eseguito indipendentemente dalla presenza delle librerie esterne nel sistema. L'eseguibile risultante dal linking statico tende ad essere più grande rispetto a quello generato dal linking dinamico, poiché include tutte le librerie necessarie.
  • Linker Dinamico: con il linker dinamico, la risoluzione dei riferimenti avviene a tempo di esecuzione, quando il programma viene caricato in memoria. L'eseguibile prodotto dal linker dinamico contiene solo le informazioni necessarie per individuare le librerie esterne richieste. Durante l'esecuzione del programma, il linker dinamico si occupa di caricare le librerie esterne necessarie in memoria e di risolvere i riferimenti a tempo di esecuzione. In questo modo, più programmi possono condividere le stesse librerie, riducendo la dimensione complessiva del codice eseguibile. Inoltre, le modifiche alle librerie esterne possono essere applicate senza dover ricompilare l'intero programma. Tuttavia, il linker dinamico richiede che le librerie necessarie siano presenti nel sistema durante l'esecuzione del programma. Anche la gestione delle versioni è un problema. Se una libreria viene modificata/aggiornata, il programma che la utilizzava precedentemente si aspetta di usarla con le vecchie modifiche e potrebbe non funzionare o funzionare male: richiede l'aggiornamento anche del programma che usa la libreria.

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.
Pasted image 20230618121844.png

Pasted image 20230618122149.png

Formato binario
...

Pasted image 20230618122450.png
Nel file ELF:

  • header: descrive la dimensione a la posizione degli altri segmenti del file oggetto stesso
  • text segment: contiene il codice in linguaggio macchina
  • static data segmente: contiene i dati allocati per tutta la durata del programma (sia statici che dinamici)
  • relocation information: identificano le istruzioni e i dati che, quando il programma è posto in memoria, dipendono da indirizzi assoluti
  • *symbol table:* contiene le etichette di cui non è stata trovata una definizione (moduli esterni)
  • debugging information: informazioni per il debugger, che permette di associare le istruzioni in linguaggio macchina al codice sorgente C.

Loader
...

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:

  • leggere l'intestazione del file eseguibile per determinare la dimensione del programma e dei dati
  • riservare uno spazio in memoria sufficiente per contenerli
  • copiare programma e dati nello spazio riservato
  • copiare nello stack i parametri (se presenti) passati al main
  • inizializzare tutti i registri e lo stack pointer (ma anche gli altri del modelli di memoria)
  • saltare ad un procedura che copia i parametri dallo stack ai registri e che poi invoca il main

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.

Come fa a funzionare questo collegamento lazy?

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.