Benvenuti alle dispense di Sistemi e Reti - CPU
In queste dispense vedremo come funziona una CPU, in particolare la traduzione in assembly e le differenze tra CISC e RISC.
Ricordatevi che il lavoro dell'informatico non è aggiustare computer, passare dei cavi o scrivere un'applicazione. Il nostro mestiere è aiutare le persone a risolvere problemi. Dobbiamo quindi sempre avere bene in mente quale problema stiamo risolvendo e per chi, per fare in modo che il nostro lavoro sia veramente utile agli altri.
Materiale di studio per il compito in classe:
- queste dispense
- gli appunti integrativi
- pagine del libro da 121 a 134 (Unità 3)
Il libro di testo adottato è "INTERNETWORKING - VOLUME 3 - ED 2021", E.Baldino et al., ed. JUVENILIA.
Gestione della memoria
La memoria è uno dei due componenti principali di un elaboratore elettronico, insieme alla CPU.
Quando parliamo di memoria in questo capitolo, intendiamo sempre la memoria primaria, quella a cui il processore ha accesso diretto; generalmente è costituita dalla RAM, ovvero Random Access Memory, che si può tradure in memoria ad accesso casuale o meglio in memoria ad accesso non sequenziale.
Diversi tipi di RAM. Fonte: wikipedia
La caratteristica principale della memoria RAM è che, come dice il nome, si può accedere a qualsiasi parte di questo tipo di memoria semplicemente conoscendone l'indirizzo, rappresentato da un numero intero.
Al contrario, altri tipi di memoria come l'hard-disk, devono essere acceduti a cluster: per poter accedere ad una certa porzione di memoria, bisogna sapere in che cluster appartiene. In base alla posizione del cluster, potrebbe volerci più o meno tempo per accedere ad un certo dato.
Possiamo immaginare la RAM come un lunghissimo nastro, diviso in caselle, proprio come il nastro della macchina di Turing che abbiamo studiato ad inizio anno.
Un esempio di macchina di Turing. Fonte: wikipedia
Per convenzione e comodità, si usano i numeri esadecimali per indicare l'indirizzo di memoria a cui vogliamo accedere. Useremo inoltre la convenzione di scrivere gli indirizzi di memoria in verticale, con i numeri più bassi in alto.
Facciamo un esempio: immaginiamo una memoria RAM di 4GB, ovvero 2^32 bit. L'indirizzo più alto è quindi 2^32 che rappresentato in esadecimale è 0xFFFFFFFF (ricordiamoci che ogni lettera corrisponde a 4 bit). La rappresentazione sarà quindi come segue:
Indirizzo |
---|
0x00 |
0x01 |
0x02 |
... |
0xFFFFFFFF |
Esercizio
Un dispositivo ha una memoria fisica di 8GB. Qual è l'ultimo indirizzo della memoria fisica? Soluzione: 8GB è pari a 2^33, quindi l'ultimo indirizzo in binario si scrive con 33 cifre 1. Per la conversione in esadecimale, partendo da destra, a gruppetti di 4 bit, traduco nella corrispondente lettera. Il risultato finale è quindi 0x1FFFFFFFF.
Memoria fisica e memoria virtuale
Quando noi eseguiamo un programma cliccando sull'icona del suo eseguibile, accadono le seguenti cose:
- il sistema operativo cerca un'area di memoria libera nella RAM
- il sistema operativo copia il programma dall'hard-disk nella memoria RAM, creando un processo
- il processo viene eseguito a partire dalle istruzioni contenute nel main (nel caso di C/C++)
La zona di memoria in cui viene esattamente copiato il programma dipende da quale zona è libera nel preciso momento in cui viene eseguito, cambiando quindi ad ogni esecuzione. Questa cosa è molto scomoda in generale per il programmatore, che si troverà ogni volta ad avere dei puntatori a posizione diverse della memoria. Inoltre espone il sistema operativo a diversi tipi di attacchi informatici, perché svela delle informazioni sullo stato attuale della macchina.
Per questi motivi, il sistema operativo crea uno spazio di indirizzamento virtuale, sempre uguale per tutte le applicazioni, e poi si occupa di mappare gli indirizzi virtuali in indirizzi fisici durante l'esecuzione. Più esattamente questa operazione è svolta dal kernel, la componente del sistema operativo che gestisce le componenti hardware.
Il kernel di riferimento da noi utilizzato è Linux, essendo open-source, gratuito ed è il più utilizzato in ambito server.
La virtualizzazione offre anche un altro vantaggio: la possibilità di mappare parte della memoria virtuale sull'hard-disk, in caso la memoria fisica fosse esaurita. Questa operazione si chiama swap.
Memoria fisica e virtuale. Fonte: wikipedia
Per convenzione, la memoria virtuale di ogni processo inizia sempre da 0x00; il valore finale dipende dal kernel e dall'architettura della macchina, per il kernel linux e processori x86 a 64 bit, l'ultimo valore è 0x7FFFFFFFFFFF.
Indirizzo virtuale |
---|
0x00 |
0x01 |
0x02 |
... |
0x7FFFFFFFFFFF |
Da notare che questo intervallo è lo stesso indipendentemente dalla quantità di memoria fisica installata sulla macchina.
Segmenti di memoria
La memoria assegnata ad un certo processo è divisa in segmenti, ovvero porzioni di memoria ognuna con un compito specifico. Di seguito i più importanti.
Indirizzo | Settore |
---|---|
0x00 | Codice (text) |
... | Variabili Statiche |
... | Heap |
... | |
0x7FFFFFFFFFFF | Stack |
Segmento del codice (text)
La prima porzione di memoria contiene le istruzioni che dovranno essere eseguite durante il processo; questo settore viene chiamato code o anche text. Le istruzioni vengono memorizzate in memoria in accordo con l'architettura specifica del processore, e possono essere visualizzate in maniera comprensibile all'essere umano attraverso la rappresentazione Assembly.
Prendiamo ad esempio una funzione in C che esegue la somma di due numeri. Di seguito vediamo come questo viene tradotto in Assembly, e come ad ogni riga di Assembly corrispondano dei valori esadecimali. Questi valori sono quelli che si trovano nel segmento di memoria del codice.
Segmento delle variabili statiche
Subito dopo vengono memorizzate tutte quelle variabili che nascono con l'avvio del programma e vengono distrutte solo alla conclusione dello stesso. Rientrano in questa categoria ad esempio tutte le variabili globali e le variabili locali che vengono dichiarate con il modificatore static.
Segmenti Stack & Heap
I settori precedenti hanno una dimensione fissa, nota all'avvio del programma. Tuttavia, durante l'esecuzione del programma stesso, le variabili che creiamo all'interno delle funzioni non sono note a priori, perché una funzione potrebbe essere chiamata più volte, oppure mai.
Esistono quindi due ulteriori settori che evolvono con l'esecuzione del programma stesso, chiamate stack (catasta, a sinistra nella foto) e heap (mucchio, a destra nella foto).
Stack
La stack è una memoria a cui si può aggiungere una nuova variabile solo aggiungendola in cima, ed analogamente si possono togliere variabili solo dalla cima. Questa memoria viene gestita automaticamente dal compilatore e dal processore: ogni volta che creiamo una variabile, viene aggiunta alla stack; ogni volta che usciamo dall'ambito della variabile (in inglese, scope), questa variabile viene distrutta. Si esce dallo scope della variabile appena si raggiunge la parentesi graffa in cui è contenuta.
int somma(int a, int b) {
if (a > 0) {
int c = a+b;
return c;
} // <-- qui viene distrutta la variabile c
} // qui vengono distrutte le variabili a e b
Per convenzione, la stack parte nello spazio di indirizzamento più alto disponibile della memoria virtuale e cresce verso gli indirizzi più bassi.
Heap
La gestione automatica della stack a volte può essere limitante, perché non sempre lo scope reale della variabile corrisponde con quello del blocco in cui si trova. Per questo motivo esiste la heap, dove si ha maggiore controllo su quando distruggere la variabile, che può quindi passare da un blocco ad un altro (ad esempio può essere passato facilmente ad una o più funzioni).
Se non si cancellano correttamente le variabili nella heap, può succedere che aree di memoria vengano sprecate. Questo fenomeno si chiama memory leak ed è un problema a cui bisogna prestare la massima attenzione. Alcuni linguaggi come Java, Python o Rust hanno dei meccanismi per evitare che si verifichino i memory leaks.
Compiler Explorer
Vediamo nel dettaglio cosa succede quando compiliamo un programma.
Useremo come liguaggio C, ma qualsiasi linguaggio viene convertito in codice Assembly (ASM).
Usiamo il sito Compiler Explorer, che permette di vedere in modo interattivo cosa succede ad un codice compilato.
Da linea di comando, usando
gcc
, è possibile vedere il codice ASM usando l'opzione-S
.
Come caso di studio, riprendiamo l'esempio della somma.
Vediamo passo passo come le 3 linee di codice C diventano le linee di codice ASM.
Linea 1: definizione di una funzione
Per la prima linea abbiamo la seguente traduzione:
// Codice C
int somma(int a,int b) {
; Codice ASM
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov DWORD PTR [rbp-8], esi
Quando viene chiamata una funzione, per prima cosa con il comando push
viene salvato il vecchio valore del Base Pointer (rbp
), il registro che punta sempre all'indirizzo di memoria all'inizio della funzione corrente. Questo valore verrà ripristinato alla fine della funzione.
Quindi, con mov rbp, rsp
, il valore del Base Pointer viene assegnato all'indirizzo dell'attuale cima della stack.
Nota: essendo un'architettura a 64bit (Intel
x86_64
), i registri degli indirizzi sono a 64bit, e questo è visibile perché i nomi cominciano con la letterar
(comerbp
). I registri a 32bit cominciano con lae
(comeeax
) ed i registri a 16 bit sono formati da solo due lettere (ex.ax
). Altre architetture (es.arm
) usano altre convenzioni.
Infine, con le ultime due istruzioni vengono copiati nella stack i valori dei registri edi
ed esi
, che contengono i parametri di ingresso a
e b
. La posizione in cui vengono copiati dipende dalla dimensione dei valori da copiare: in questo caso essendo int
, hanno una dimensione di 4 byte, quindi il primo viene copiato in rbp - 4
ed il secondo in rbp - 8
. Questi valori detti di offset sono negativi perché, come detto in precedenza, la stack parte dai valori alti di memoria e cresce verso i valori bassi, quindi gli offset devono essere sottratti all'indirizzo di partenza della funzione.
Linea 2: corpo della funzione
Abbiamo la seguente traduzione della seconda linea.
return a+b;
mov edx, DWORD PTR [rbp-4]
mov eax, DWORD PTR [rbp-8]
add eax, edx
Nelle prime due linee, il processore copia i valori dalla stack nei registri edx
e eax
; questi sono i registri che vengono usati dall'ALU per le operazioni.
Nell'ultima riga, l'ALU fa la somma dei valori nei registri indicati e salva il risultato dell'operazione nel primo registro indicato, in questo caso eax
.
Linea 3: uscita da una funzione
Abbiamo infine la traduzione dell'ultima linea.
}
pop rbp
ret
Il comando pop
riassegna nuovamente al registro rbp
il valore salvato nel comando push
, infine il comando ret
fa saltare l'esecuzione alla funzione che ha chiamato.
Ricordiamo che il valore ritornato dalla funzione è sempre all'interno del registro eax
.
CISC & RISC
Vedi libro pag. 130-138.
Fin dai primi processori, si sono distinte due diverse filosofie per i processori:
- CISC: ha tante istruzioni (op.code) diverse, ognuna molto complessa; servono meno istruzioni per realizzare un programma dato, ma ogni istruzione è più lenta.
- RISC: ha poche istruzioni (op.code) diverse, ognuna semplice; servono più istruzioni per realizzare un programma dato, ma ogni istruzione è più veloce, inoltre è particolarmene adatta alla tecnica del pipelining.
La formula per calcolare il tempo complessivo di un dato programma è:
\[ tempo = \frac{tempo}{cicli} \frac{cicli}{istruzioni} istruzioni \]
Il rapporto \(\frac{tempo}{cicli}\) è la velocità di clock del processore. CISC cerca di ridurre il numero di \(istruzioni\) sacrificando il rapporto \(\frac{cicli}{istruzioni}\); l'architettura RISC all'opposto cerca di ridurre il rapporto \(\frac{cicli}{istruzioni}\) sacrificando il numero di \(istruzioni\).
Vedi per approfondimenti questa pagina.
Un altro fattore da tenere in considerazione è la potenza consumata, che viene calcolata come:
\[ Energia = Potenza * Tempo \]
Le architetture RISC hanno una potenza dissipata molto minore rispetto alle architetture CISC, anche se il tempo può essere un po' maggiore, quindi le rendono una scelta quasi obbligata per i dispositivi mobile. Anche per i data center, un minore consumo di energia vuol dire risparmiare sui costi di gestione, sia sull'alimentazione diretta dei server che per il sistema di raffreddamento.
I data center consumano moltissima energia e sono a rischio incendio. Un incidente abbastanza recente è stato quello del gestore OVH a Strasburgo, in Francia.
In generale, negli ultimi anni stiamo vedendo un vantaggio competitivo netto del RISC rispetto a CISC, che ne stanno determinando una sempre maggiore diffusione.
Chip & Open Hardware
Il mondo dei chip è popolato da diverse entità:
-
FAB: quelli che si occupano solo della produzione fisica dei chip (non di progettarli). Tra tutti, la taiwanese TSMC.
-
FABLESS: quelli che progettano chip per specifiche applicazioni (Smartphone, Smart Car, IoT) e se li fanno produrre (Qualcomm, AMD Nvidia)
-
SIP: i Semiconductor Intellectual Property sono quelli che rivendono in licenza le tecnologie per fare la progettazione dei chip e anche i set di istruzioni per i processori. Tra tutti la oggi giappo-inglese Arm.
-
IDM: Sono quelli che si fanno tutto da se (Integrated Device Manufacturer): Intel, NEC, Texas Instruments, IBM
Grazie ad Adriano Parracciani per il materiale di questa pagina, per ulteriori informazioni si rimanda alla newsletter di TRON.
L'equilibrio fra tutti questi attori è molto delicato. Uno dei nodi nevralgici di tutto il sistema è Taiwan, dove vengono prodotti la maggior parte dei chip ad alta tecnologia attualmente usati in tutto il mondo.
Recentemente le elezioni a Taiwan hanno confermato una linea filo-indipendentista, quindi per ora le cose rimangono come sono, ma il futuro è incerto.
Open-hardware
Il difficile rapporto fra paesi ed aziende è anche esasperato da una battaglia di brevetti, che rende la situazione molto più complessa. Tuttavia, analogamente a quanto è successo nel mondo del software, dove l'open-source ha favorito l'innovazione, la collaborazione e l'abbattimento dei costi, anche nel mondo hardware sta succedendo qualcosa di simile.
Ad esempio Qualcomm ha annunciato una collaborazione con Google per sviluppare RISC-V Snapdragon Wear, una piattaforma hardware orientata al mondo wearable, in particolare per dispositivi basati Wear OS di Google (es smartwatch di varie marche).
Quest’annuncio apre la strada una possibile rivoluzione nel mondo dei chip.
RISC-V, ad esempio, è un’architettura aperta per la progettazione di processori e chip e si mette in competizione con le più note architetture proprietarie come Arm.
Qualcomm è un chipmaker e utilizza la tecnolgia di Arm per progettare i suoi chip, che poi fa produrre principalmente da TSMC.
Passare a RISC-V significa abbattere i costi dovuti ad Arm per le licenze, ed anche maggiore flessibilità.
Vedremo cosa succederà.