2. L'ambiente di compilazione

Gli strumenti di compilazione sono programmi che spostano un programma da un livello di astrazione incentrato sull’uomo a un livello di astrazione incentrato sulla macchina. La principale interfaccia dei programmatori con la macchina è l’ambiente di traduzione dei programmi in un linguaggio affine alla macchina. In questa pagina, introdurremo il lettore agli strumenti di compilazione e i vari passaggi che il programma affronta prima di poter essere un file binario pronto ad essere eseguito.

Linguaggio compilato vs interpretato

I linguaggi di programmazione che normalmente utilizziamo per scrivere software di tutti i giorni possono essere raggruppati in due principali categorie: compilato oppure interpretato. La principale differenza tra queste due categoria si basa sul modo in cui avviene la traduzione dal codice ad alto livello (molto simile al ragionamento che gli esseri umani possono comprendere) al codice a basso livello (ovvero il codice binario).

La traduzione può essere fatta al momento dell’esecuzione, anche detta traduzione Just In Time (o JIT) oppure nei momenti precedenti all’esecuzione del programma, anche detta traduzione ahead of time (o AoT). Questi due approcci sono considerati all’estremità di una lunga serie di possibili mix tra i due approcci: il Just In Time è anche detto linguaggio interpretato, mentre ahead of time è detto linguaggio compilato. Quello che cambia tra i due approcci riguarda la tempistica della traduzione: come avviene il passaggio dal codice ad alto livello (molto astratto, pesante per la macchina) al linguaggio binario (poco astratto, più leggero per l’elaboratore)?

È bene precisare che la maggior parte dei moderni linguaggi di programmazione adotta un approccio che è un misto tra le due tecniche. In definitiva, non è sempre possibile dire che un linguaggio sia sempre compilato o un linguaggio sia sempre interpretato.

Diremo però che un linguaggio viene interpretato quando c’è un programma chiamato interprete che traduce in tempo reale il codice ad alto livello in codice a basso livello per una certa macchina. L’inteprete prende in input il programma da eseguire sotto forma di codice ad alto livello e un input del programma: l’interprete lo esegue e restituisce in output una certa sequenza di bit in funzione del codice e dell’input passato al programma da interpretare.

Diremo invece che un linguaggio è compilato quando il programma viene eseguito partendo da un file eseguibile. Per questo approccio, esiste un programma speciale chiamato compilatore il cui compito è tradurre il sorgente in un file pronto per essere caricato. Il principale vantaggio rispetto all’interprete è che per poter essere eseguito, il programma non ha bisogno altro di istruzioni e dati (oltre ad essere già tradotti). L’interprete invece necessita del codice sorgente originale per effettuare la traduzione, comportando una perdita di tempo, oltre a tutte le conseguenze del caso.

Per distribuire un programma, ad esempio, il fornitore è più propenso a distribuirlo attraverso una compilazione invece di distribuire il sorgente (o una rappresentazione di esso in binario) e interpretarlo. Avere un programma compilato consente di risparmiare notevoli risorse, sia dal punto di vista del tempo (nessun tempo per la traduzione!) sia in termini di spazio (compilatore utilizza solo ciò che serve).

Gli strumenti di compilazione

L’ambiente di compilazione prende in input uno o più file sorgente in un linguaggio di alto livello come C o C++, più una serie di file di supporto come risorse e librerie, e li converte tutti in un eseguibile per un particolare ambiente di esecuzione, ad esempio Linux o Windows (non stiamo considerando i formati di rappresentazione dei programmi di livello superiore come UML, sebbene anch’essi possano essere oggetto di decompilazione).

Ciò avviene attraverso una serie di passaggi che coinvolgono i singoli programmi:

  • Ogni file sorgente di linguaggio ad alto livello viene compilato in assembly da un compilatore per quel linguaggio ad alto livello. Il compilatore utilizza l’analisi lessicale e l’analisi sintattica per verificare la correttezza sintattica del programma. Sviluppando poi una rappresentazione intermedia, il compilatore provvede ad ottimizzare il flusso d’esecuzione, eliminando i commenti e iniziando a separare i dati dalle istruzioni. Il compilatore restituisce una serie di file contenente le istruzioni e i dati in formato assembly.

  • Ogni file in linguaggio assembly, creato da un compilatore o direttamente dal programmatore, viene convertito in un file oggetto rilocabile da un programma assemblatore (assembler). L’assemblatore non si preoccupa di quale linguaggio sia stato usato per scrivere il file sorgente di alto livello. Si preoccupa solamente di quale processore eseguirà il codice binario: se è x86, ARM o una qualsiasi altra tipologia. Questo è il primo passo in cui le informazioni possono andare perse, poiché l’assemblatore potrebbe non vedere molte delle informazioni importanti per il programmatore, come i nomi delle variabili locali e i tipi.

  • Ogni file oggetto rilocabile viene combinato dal linker con una serie di librerie che supportano l’ambiente di esecuzione di destinazione. Al linker può non interessare il processore che eseguirà il programma. Può solo preoccuparsi delle informazioni necessarie affinché il programma venga caricato dal sistema operativo di destinazione. Il linker può decidere di rimuovere dal file binario generato le informazioni che ritiene non necessarie per l’esecuzione del programma.

Come si può notare, in ogni fase alcune informazioni che erano fondamentali per il programmatore quando ha scritto il programma vengono rimosse dall’output di ogni strumento perché non sono necessarie per l’esecuzione finale del programma. In informatica, si tende per ragioni di spazio e di risorse finite a eliminare tutto ciò che non è necessario.

Nota interessante da menzionare riguarda il ruolo del programmatore: esso può istruire ogni strumento a generare o rimuovere informazioni preziose tramite alcuni comandi. Ad esempio, quando si usa un compilatore, l’utente può decidere di:

  • generare informazioni aggiuntive per migliorare la debuggabilità del codice (a questo scopo si usa l’opzione -g dei compilatori Unix) e di generare dei simboli (ovvero dei collegamenti più comprensibili per l’utente tra quello che accade e il codice sorgente). Questo include alcuni elementi come ad esempio l’inserimento della linea di codice o riferimenti al codice sorgente quando il programma si blocca o esegue alcune azioni.

  • generare codice più difficile da capire per l’uomo, ma meglio eseguibile dai processori; cioè, generare codice ottimizzato attraverso le opzioni da riga di comando -O1, -O2 o superiori. I compilatori ottimizzati, attraverso una serie di trasformazioni che eseguono sulle istruzioni generate, rendono il codice finale meno leggibile anche quando nel file finale sono presenti informazioni di debug e il sorgente originale è disponibile e ispezionabile attraverso un debugger.

Il debug del codice ottimizzato è un’area di ricerca a sé stante che non verrà presa in considerazione in questo corso, anche se molte delle tecniche possono essere applicate a un “debugger de-ottimizzante”. Quando si utilizza uno degli altri strumenti, l’utente può anche influenzare il funzionamento di un decompilatore, ad esempio istruendo il linker a rimuovere qualsiasi informazione simbolica dal file binario.

Consideriamo poi il caso in cui un’utente voglia rendere difficile la decompilazione di proposito. Questo spesso accade in contesti aziendali in cui la segretezza dell’algoritmo rappresenta la principale misura di difesa dell’azienda contro il furto di proprietà intellettuale. A proprio piacimento, l’utente può decidere di offuscare parte delle istruzioni e dati per ostacolare la decompilazione. Alcune tecniche di offuscamento come il codice interleaving (istruzioni messe una dopo l’altra secondo un ordine non preciso), la generazione di codice pesante e il confusioning typing sono affrontate in un capitolo separato.

Da questo punto in poi, qualsiasi strumento che possiamo utilizzare per comprendere il programma può essere considerato uno strumento di reverse engineering.

Biografia

Sono uno specialista in materia di sicurezza informatica, scrittore, contributore per il progetto Monero, una criptovaluta che è focalizzata nel proteggere le informazioni sulle transazioni. Il libro che ho pubblicato Mastering Monero è diventato una delle migliori risorse per padroneggiare Monero. Più informazioni su di me

Seguimi su Twitter o scrivimi una e-mail. Le donazioni sono molto apprezzate, mi permettono di continuare a lavorare e a scrivere.

Mastering Monero Book