Introduzione ad Assembly
Un'introduzione pratica all'Assembly x86-64 su Linux: dall'ISA e dai registri della CPU alle system call, fino alla creazione e all'esecuzione di un programma ELF. Un percorso pensato per comprendere cosa succede realmente dietro le quinte di un semplice programma.
L'ISA definisce il set di istruzioni di un microprocessore, con la famiglia x86, evolutasi da 16 a 64 bit, che rappresenta l'architettura dominante in PC e server. La transizione da x86 (32-bit) a x64 (64-bit) aumenta significativamente la memoria indirizzabile e abilita funzionalità di sicurezza e prestazioni avanzate.
Cos'è l'ISA
L'ISA (Instruction Set Architecture) è l'insieme di istruzioni che un microprocessore è in grado di capire ed eseguire. Le operazioni matematiche, i confronti logici nonché la lettura e la scrittura di dati. Possiamo immaginarla come una specie di lingua comune tra hardware e software. E' grazie all'ISA che un programma sa come interagire con la CPU e con la memoria del computer.
La famiglia x86
Un esempio famosissimo di ISA è la famiglia x86, usata ancora oggi nella stragrande maggioranza di PC e server. Tutto nasce nel 1978, con l'uscita del microprocessore Intel 8086, un chip a 16 bit, che per l'epoca rappresentava un salto in avanti notevole rispetto ai processori precedenti.
Da lì in poi Intel ha continuato a evolvere questa architettura, passando per modelli come l'80286, l'80386, l'80486, fino ad arrivare ai Pentium e, più recentemente, ai processori Intel Core. Anche altri produttori, come AMD, realizzano oggi chip basati sulla stessa architettura x86.
x86 vs x64: qual è la differenza
La differenza principale riguarda quanti bit la CPU riesce a gestire in una volta sola:
- x86 (spesso chiamata anche x86-32) indica l'architettura a 32 bit
- x64 (o x86-64, o AMD64) indica l'architettura a 64 bit
Va detto che, storicamente, x86 indicava tutta la famiglia di processori derivati dall'8086 (compresi quindi anche i modelli a 16 e a 32 bit), non solo la versione a 32 bit. Nel linguaggio comune, però, x86 viene usato quasi sempre come sinonimo di 32 bit, proprio per contrapporlo a x64.
Perché conta il numero di bit
Il numero di bit determina anche quanta memoria RAM la CPU riesce a indirizzare (cioè ad usare):
- Con 32 bit lo spazio di indirizzamento teorico è di 4 GiB
- Con 64 bit lo spazio di indirizzamento teorico arriva teoricamente a 16 EiB
Questi 16 exabyte, però, sono un limite puramente teorico, che deriva dal calcolo matematico 2⁶⁴. Nella pratica, i processori e i sistemi operativi attuali supportano molta meno memoria (di solito qualche centinaio di terabyte), per via di limiti fisici e progettuali reali. Resta comunque un numero enorme rispetto ai sistemi a 32 bit.
Questo salto di capacità permette ai sistemi a 64 bit di lavorare con dataset più grandi ed eseguire calcoli più complessi, rendendoli più adatti a compiti impegnativi. In più, l'architettura a 64 bit introduce funzionalità di sicurezza più avanzate (come la protezione contro l'esecuzione di codice malevolo in memoria) e prestazioni migliori, grazie anche a un numero maggiore di registri general-purpose e a set di istruzioni più moderni.
Come funziona una CPU, in breve
Una CPU x86_64 esegue in fondo un numero limitato di operazioni elementari, tutte combinabili tra loro per costruire programmi complessi. A livello concettuale, possiamo ricondurre molte operazioni a tre categorie: sposta dati, modifica dati e prende decisioni in base ai dati.
I dati su cui la CPU lavora vengono conservati principalmente nei registri, piccole memorie interne alla CPU, estremamente veloci ma anche costose e limitate. L'ISA x86-64 mette a disposizione 16 registri general-purpose a 64 bit, ciascuno pensato per contenere valori numerici che il processore può leggere e scrivere in tempi minimi.
Panoramica dei registri
I primi otto sono le estensioni a 64 bit dei classici registri x86 a 32 bit, mentre gli ultimi otto sono stati aggiunti specificamente per la modalità a 64 bit.
- RAX (Accumulatore)
- RBX (Registro base)
- RCX (Contatore)
- RDX (Registro dati)
- RSI (Source Index)
- RDI (Destination Index)
- RBP (Base Pointer dello stack)
- RSP (Stack Pointer)
- R8
- R9
- R10
- R11
- R12
- R13
- R14
- R15
Il registro rax e l'istruzione mov
Tra i registri di uso generale, rax è uno dei più usati. Può contenere un valore numerico, e i dati vengono inseriti nei registri tramite l’istruzione mov.
La sintassi di mov è semplice, il primo operando è la destinazione, il sevondo è la sorgente e i due sono sempre separati da una virgola.
mov rax, 1337
In questo esempio il valore 1337 viene copiato dentro il registro rax.
Precisiamo che gli articoli su Assembly faranno riferimento alla sintassi Intel, originale di x86, e non la sintassi AT&T, un dettagio da tenere presente perché l'ordine degli operandi cambia se si sceglie una sintassi diversa.
Le System Call (chiamate di sistema)
Una System Call è il meccanismo con cui un programma comunica con il sistema operativo. È il sistema operativo, infatti, a gestire l’avvio e la terminazione dei programmi, l’accesso all’hardware, la rete e le altre risorse di sistema.
Come abbiamo detto, le istruzioni Assembly interagiscono direttamente con la CPU, ma per richiedere determinati servizi al kernel, il programma utilizza il meccanismo delle system call. Su Linux x86-64, l'istruzione syscall è il meccanismo utilizzato per effettuare una system call.
La syscall exit
La prima chiamata di sistema che vale la pena imparare è exit, che serve a terminare esplicitamente il processo e a restituire al sistema operativo un codice di uscita. Il numero associato a exit è 60.
mov rax, 60
syscall
Ogni syscall è identificata da un numero, il valore viene caricato in rax, l'istruzione syscall esegue la richiesta, e exit (60) termina correttamente il programma.
Quando un programma termina restituisce un codice di uscita attraverso la syscall exit, la domanda è: come si comunica questo codice di uscita?
Banalmente, la risposta è tramite i registri. Il primo parametro di una syscall viene inserito nel registro rdi. Nel caso di exit questo parametro è esattamente il codice di uscita.
Per fare un'analogia con Python, la syscall
exitè simile alla chiamata a una funzioneexit()a cui passiamo un parametro, ad esempioexit(42). Il valore42rappresenta il codice di uscita con cui il programma termina.
mov rdi, 42
mov rax, 60
syscall
A differenza di altri linguaggi di programmazione, in Assembly si lavora direttamente con i registri della CPU. Alla CPU non interessa quindi se impostiamo prima il codice di uscita o il numero della syscall
exit: ciò che conta è che, al momento dell'esecuzione disyscall, i valori siano nei registri corretti. I valori, invece, non vanno assegnati ai registri sbagliati:RAXdeve contenere il numero della syscall eRDIil codice di uscita. Inoltre Con GNU assembler, le istruzioni e i nomi dei registri x86 non sono case sensitive.
| Registro | Scopo |
|---|---|
| rax | Numero della chiamata di sistema |
| rdi | Primo parametro della chiamata (codice di uscita per exit) |
| rsi | Registro di uso generale, utile per memorizzare valori intermedi o copiarli in altri registri |
Dal codice sorgente all'eseguibile
Scrivere codice Assembly non basta per eseguirlo, serve trasformarlo in un file eseguibile
attraverso tre passaggi principali:
- Scrivere il codice in un file .s.
- Assemblarlo in un file oggetto .o con as.
- Collegarlo (linkarlo) in un eseguibile con ld.
1. Scivere il file Assembly
Sentiti libero di usare qualsiasi editor di testo per salvare il codice in un file, ad esempio creiamo un file nominato program.s. All'inizio del file è molto importante aggiungere la direttiva .intel_syntax noprefix, che indica all'assembler di utilizzare la sintassi Intel senza prefissi.
.intel_syntax noprefix
mov rdi, 42
mov rax, 60
syscall
Questa direttiva riguarda esclusivamente l'assembler e non riguarda in nessun modo la CPU.
Assemblare il codice
Il file Assembly (codice sorgente) viene convertito in un file oggetto (codice macchina e informazioni necessarie al linking) con il comando seguente:
as -o program.o program.s
Collegare il file oggetto
L'eseguibile si crea con il comando:
ld -o program.elf program.o
Il programma può essere eseguito con ./program.elf, e il codice di uscita si controlla con echo $? ($? è una variabile speciale della shell che contiene lo status di uscita dell'ultimo comando eseguito), che nel nostro esempio restituisce 42.
Il formato ELF
Su Linux, un programma eseguibile è generalmente un file in formato ELF (Executable and Linkable Format). Possiamo quindi trovare un eseguibile chiamato program.elf, ma .elf è solo un'estensione convenzionale: potremmo chiamarlo semplicemente program e funzionerebbe comunque.
Questo perché, a differenza di Windows, Linux non usa l'estensione del file per determinarne il tipo o la sua eseguibilità. Ciò che conta è il contenuto del file e, per l'esecuzione, anche la presenza dei permessi di esecuzione.
In Linux l'estensione è principalmente una convenzione per l'utente; il formato ELF è determinato dal contenuto del file, non dal suo nome.
Compilato vs Assemblato
Assembly è un linguaggio di programmazione a basso livello, assemblato, attenzione per chi viene da linguaggi come Java, C#, C o C++, è comune associare il termine compilato al processo di traduzione del codice sorgente. In senso generale, anche l'Assembly viene tradotto prima dell'esecuzione; tuttavia, tecnicamente è più corretto parlare di assemblaggio, perché il programma che effettua la traduzione è un assembler, non un compiler (compilato e assemblato non sono necessariamente categorie opposte).
La differenza fondamentale è che il compiler traduce un linguaggio di programmazione in un'altra rappresentazione, effettuando anche analisi e trasformazioni del programma, mentre l'assembler traduce le istruzioni Assembly nella corrispondente rappresentazione in linguaggio macchina dell'architettura target, in questo caso x86-64.
Durante il collegamento può comparire l’avviso cannot find entry symbol _start. Significa che il linker non trova il simbolo _start indicato come punto di ingresso.
Il simbolo _start
Per eliminare l'avviso si può definire esplicitamente un punto di ingresso (_start) in questo modo:
.intel_syntax noprefix
.global _start
_start:
mov rdi, 42
mov rax, 60
syscall
Ricapitolando, .global _start rende il simbolo _start visibile al linker, mentre _start: definisce l'etichetta utilizzata come punto di ingresso del programma.
È importante ricordare che _start NON è una parola riservata dell'Assembly: è semplicemente il nome di un'etichetta, scelto per convenzione. Potremmo quindi chiamarla, ad esempio, inizio:
.intel_syntax noprefix
.global inizio
inizio:
mov rdi, 42
mov rax, 60
syscall
In questo caso, però, dovremmo indicare inizio come punto di ingresso al linker, ad esempio con ld -e inizio -o program program.o.
| Comando | Funzione |
|---|---|
| as -o program.o program.s | Assembla il codice Assembly in un file oggetto |
| ld -o program.elf program.o | Crea l'eseguibile |
| ./program.elf | Esegue il programma |
| echo $? | Mostra il codice di uscita del programma |
Il registro RSI e la copia tra registri
Un altro registro di uso generale è rsi, che come gli alti, può essere usato per memorizzare dati, per esempio:
mov rsi, 42
Abbiamo registrato il valore 42 nel registro rsi, ma possiamo fare di più, per esempio copiare il contenuto di un registro in un altro registro:
mov rsi, 42
mov rdi, rsi
La seconda istruzione non fa altro che copiare il valore del registro rsi in rdi, dopo l'esecuzione del codice ambo i registri conterranno il valore 42. Un dettaglio da tenere presente è che, nonostante il nome dell’istruzione sia mov (abbreviazione di move), essa non sposta realmente il valore da un registro all’altro, ma lo copia. Il motivo storico per cui l’istruzione si chiami mov invece di qualcosa come set o copy non è del tutto chiaro, si tratta semplicemente di una convenzione consolidata dell’assembly.
Buone pratiche (che avrei voluto conosce prima) per chi cominca con Assembly
- Non dare mai per scontato un valore di registro nel non specificatamente inizializzato proprio codice, anche se in un ambiente controllato di apprendimento un registro può sembrare vuoto o a zero.
- Tenere presente che secondo la convenzione di chiamata System V AMD64 ABI comunemente usata su Linux, alcuni registri devono essere preservati dalla funzione chiamata (i cosiddetti registri callee-saved), un aspetto rilevante non appena si inizia a scrivere funzioni piu complesse delle semplici sequenze lineari viste fin ora.
- Verificare sempre il codice di uscita del proprio programma con echo $? subito dopo l’esecuzione, è il modo più rapido per capire se la logica implementata è corretta.
- Per ispezionare il contenuto di un file oggetto o di un eseguibile durante il debug, strumenti come objdump o gdb (presente nei prossimi articoli) permettono di vedere le istruzioni effettivamente generate, un passaggio utile quando il comportamento del programma non corrisponde alle aspettative.