La CPU di un computer è il principale responsabile dei calcoli che vengono eseguiti sul sistema. La prima CPU prodotta in serie si chiamava Intel 4004: progettata alla fine degli anni ’60 dall’italiano Federico Faggin, utilizzava un’architettura a 4 bit invece dei 64 bit dei sistemi odierni. Abbiamo già parlato della corsa 8, 16, 32, 64 bit nel caso dei processori e perché non c’è alcuna necessità di migrare, in futuro, a 128 bit.
L’architettura di un Intel 4004 è ovviamente molto meno complessa rispetto a quella dei processori moderni ma gran parte delle sue caratteristiche rimangono valide: lo abbiamo visto nell’articolo su come funziona un processore.
Istruzioni, puntatori e registri
Le istruzioni che le CPU eseguono sono solo dati espressi in codice binario: un byte o due vengono utilizzati per rappresentare quale istruzione deve essere eseguita (codice operativo). Ciò che segue specifica i dati necessari per eseguire l’istruzione. Quello che chiamiamo codice macchina altro non è che una serie di queste istruzioni binarie in fila, l’una dopo l’altra.
Il linguaggio Assembly offre una sintassi utile a leggere e scrivere codice macchina, più vicina al modo di ragionare degli esseri umani ma ancora ben lontana da qualunque linguaggio ad alto livello che la maggior parte degli sviluppatori sono abituati a utilizzare. Il codice Assembly è sempre compilato nel binario che la CPU è capace di “leggere” ed elaborare. La CPU legge il codice macchina direttamente dalla memoria RAM; il codice non può essere eseguito se non è caricato nella RAM.
La CPU conserva un puntatore (instruction pointer) che fa riferimento alla posizione in RAM in corrispondenza della quale è disponibile l’istruzione successiva. Dopo aver eseguito ciascuna istruzione, la CPU sposta il puntatore e continua il lavoro con uno schema conosciuto come ciclo fetch-execute.
Alcune istruzioni possono indicare al puntatore di saltare da qualche altra parte, ad esempio su posizioni specifiche a seconda di una determinata condizione: questo approccio rende possibile la riutilizzazione del codice e abilita la cosiddetta logica condizionale (if...then...else
).
Ciascun puntatore è memorizzato in un registro: ogni architettura di CPU ha un insieme prefissato di registri. I registri sono utilizzati per ogni genere di esigenza: dalla memorizzazione temporanea di valori alla conservazione di informazioni sullo stato e sulla configurazione del processore. Alcuni registri sono direttamente accessibili dal codice macchina; altri vengono utilizzati solo internamente dalla CPU, ma spesso possono essere aggiornati o letti utilizzando istruzioni specializzate.
Prendiamo l’istruzione Assembly add ebx, 10
: essa esegue un’operazione di somma tra il registro EBX e il valore 10. In Assembly, EBX è un registro a 32 bit utilizzato per memorizzare dati e indirizzi di memoria. L’istruzione citata come esempio fa parte del set di istruzioni Assembly x86, che è ampiamente utilizzato nei computer basati su architettura x86 (Intel e AMD). Queste istruzioni Assembly vengono convertite in linguaggio macchina dal compilatore per essere eseguite direttamente dal processore.
L’istruzione add ebx, 10
in linguaggio Assembly viene tradotta in codice macchina come segue:
Codice macchina esadecimale (hex): 83 C3 0A
Codice macchina binario (binary):
- 83 in binario:
10000011
- C3 in binario:
11000011
- 0A in binario:
00001010
Quindi, l’istruzione add ebx, 10
tradotta in codice macchina binario è: 10000011 11000011 00001010
Cosa accade all’esecuzione di un programma
Si supponga di avviare l’eseguibile di un’applicazione. Il sistema operativo carica il codice del programma nella RAM e ordina alla CPU di posizionare l’instruction pointer all’inizio del flusso di dati. La CPU esegue il ciclo fetch-execute quindi l’applicazione viene avviata.
È opportuno evidenziare che la CPU ha un approccio estremamente semplificato avendo visibilità solo sull’instruction pointer e su alcuni indicatori di stato. Già oggetti come i processi sono astrazioni introdotte a livello di sistema operativo: non sono qualcosa che le CPU “comprendono” o gestiscono in modo nativo.
Per eseguire un file binario, il sistema operativo passa dapprima alla modalità utente e indirizza la CPU al punto di ingresso del codice nella RAM. I programmi che hanno la necessità di interagire con varie parti del sistema ampliando il loro “raggio d’azione”, devono necessariamente chiedere aiuto al sottostante sistema operativo e utilizzate le cosiddette chiamate di sistema o SYSCALL.
Queste ultime sono un modo standardizzato per i programmi di passare dalla modalità utente alla modalità kernel. I programmi solitamente si appoggiano ad apposite librerie condivise che racchiudono il codice macchina utile per eseguire SYSCALL e richiedere interruzioni software trasferendo il controllo al kernel del sistema operativo. Il kernel svolge il lavoro richiesto quindi ripristina la modalità utente e “ripassa la palla” al codice del programma in esecuzione.
Quando un programma viene avviato, si verifica la creazione di un processo userland necessario per la sua esecuzione. Tale processo si chiama così perché ha accesso solo alle risorse e alle funzionalità consentite dal sistema operativo per le applicazioni utente. Ad esempio, può accedere ai file del disco, alle periferiche di input e output, utilizzare la memoria del sistema assegnata. Tuttavia, il processo userland non ha accesso diretto alle risorse hardware o alle funzionalità del kernel del sistema operativo.
Abbiamo parlato brevemente del modello ad anelli del processore nell’articolo sull’architettura Intel x86S e sul passaggio a una CPU esclusivamente a 64 bit.
Se la CPU, di base, non conosce il multiprocessing ed esegue soltanto istruzioni in sequenza, perché non rimane bloccata all’interno di un qualunque programma? E come si possono invece eseguire più programmi contemporaneamente, esattamente come accade sui dispositivi che utilizziamo oggi?
Come eseguire più programmi alla volta sulla CPU
Gli interrupt software permettono di passare il controllo da un programma caricato nello spazio utente (userland) al sistema operativo. In questo caso il codice macchina eseguito dal processore nel normale ciclo fetch-execute, prescrive di passare il controllo al kernel.
Gli scheduler del sistema operativo sono componenti fondamentali che gestiscono l’assegnazione delle risorse della CPU ai processi e ai thread in esecuzione sul sistema. Il compito principale dello scheduler è quello di decidere quale processo o thread debba essere eseguito sulla CPU in un determinato momento.
In un altro articolo abbiamo parlato delle differenze tra core e thread in un processore.
Il multitasking preemptivo: cos’è e come funziona
Con i suoi scheduler, il sistema operativo attiva i cosiddetti timer chip e gestisce gli interrupt hardware. Questi ultimi sono segnali generati dai dispositivi hardware che compongono la macchina per notificare alla CPU la necessità di gestire un’attività o un evento particolare. Quando un dispositivo hardware vuole comunicare con il processore o richiedere la sua attenzione, genera un interrupt per interrompere il normale flusso di esecuzione del processore e richiedere una risposta immediata.
Se state leggendo questo articolo su un browser e ascoltando musica con lo stesso dispositivo, il sistema probabilmente sta eseguendo il ciclo chiamato preemptive multitasking migliaia di volte al secondo.
Il preemptive multitasking, noto anche come multitasking preemptivo, è una tecnica utilizzata dai sistemi operativi per gestire più processi o thread contemporaneamente sulla CPU. In questa modalità, il sistema operativo imposta un timer chip che di fatto risulta più ad alto livello ed è legato a ciascun processo o thread in esecuzione. Quando scade la finestra temporale fissata (time slice), il sistema operativo interrompe il processo o thread in esecuzione anche se non ha completato la sua esecuzione, e passa ad eseguire un altro processo o thread pronto per l’esecuzione.
La tecnica consente di gestire meglio le situazioni di concorrenza e garantire che più processi possano condividere le risorse della CPU senza interferire reciprocamente.
Il passato: multitasking cooperativo
I sistemi operativi più vecchi, comprese le versioni della piattaforma Microsoft antecedenti a Windows NT e le versioni di Mac OS più “stagionate”, usavano il multitasking cooperativo. In quel caso erano i singoli software in esecuzione ad attivare gli interrupt e a cedere volontariamente il controllo dell’esecuzione al sistema operativo e ad altri programmi. Questo approccio soffre di un paio di difetti principali: programmi dannosi o semplicemente mal progettati possono facilmente bloccare l’intero sistema operativo; inoltre, è quasi impossibile garantire la coerenza temporale per attività in tempo reale o comunque sensibili al tempo.
Per questi motivi, il mondo della tecnologia informatica è passato molto tempo fa al multitasking preemptivo e non ha mai guardato indietro.
Un esempio di SYSCALL che sostituisce un processo già in esecuzione sul sistema
Prendiamo come esempio una chiamata di sistema molto importante sui sistemi Unix-like (ad esempio su Linux): execve
. Essa permette di caricare un programma e, in caso di successo, sostituisce il processo corrente con quel programma.
La sintassi della SYSCALL execve
è la seguente:
int execve(const char *pathname, char *const argv[], char *const envp[]);
pathname
è il percorso del file eseguibile del nuovo programma da avviare.argv
è un array di puntatori a stringhe che rappresentano gli argomenti del programma.envp
è un array di puntatori a stringhe che rappresentano le variabili d’ambiente del programma.
Quando execve
viene chiamato, il sistema operativo carica il nuovo programma indicato da pathname
nella memoria del processo chiamante passando gli argomenti specificati in argv
e le variabili d’ambiente specificate in envp
. L’esecuzione del processo chiamante è interrotta e sostituita dall’esecuzione del nuovo programma.
Questa chiamata di sistema è utilizzata spesso in combinazione con altre chiamate di sistema, come fork
e wait
, per creare nuovi processi e avviare nuovi programmi all’interno di essi. Ad esempio, un processo padre può creare un nuovo processo figlio con fork
e poi utilizzare execve
nel processo figlio per eseguire un programma diverso dal padre.
Paginazione e gestione delle informazioni in RAM
Quando la CPU legge o scrive su un indirizzo di memoria, in realtà non si riferisce a quella specifica posizione nella memoria fisica (RAM). Piuttosto, punta a una posizione nello spazio di memoria virtuale.
La CPU comunica con un chip chiamato memory management unit (MMU): funziona come un traduttore che fa corrispondere le posizioni nella memoria virtuale alle corrette posizioni nella RAM.
Quando il computer si avvia per la prima volta, gli accessi alla memoria si riferiscono direttamente alla RAM fisica. Dopo l’avvio, il sistema operativo crea una sorta di dizionario di traduzione (page table) e indica alla CPU di iniziare a utilizzare la MMU. La paginazione è la procedura con la quale i vari blocchi di memoria virtuale sono mappati a livello di RAM fisica.
Dimensione delle pagine e attivazione all’avvio
Ogni architettura ha una dimensione di pagina diversa: nella piattaforma x86-64 misura 4 MB. Ciò significa che la mappatura dei blocchi in memoria è lunga 4.096 byte. x86-64 consente comunque al sistema operativo di utilizzare pagine più grandi: da 2 MB a 4 GB. La modifica della dimensione dei blocchi può migliorare il processo di traduzione degli indirizzi ma, di contro, aumentare la frammentazione e lo spreco di memoria.
Per abilitare il paging all’avvio, il kernel dapprima costruisce la tabella delle pagine nella RAM. Memorizza quindi l’indirizzo fisico cui corrisponde l’inizio della tabella delle pagine in un registro chiamato PTBR (Page Table Base Register). Infine, il kernel consente la traduzione di tutti gli accessi al contenuto della memoria tramite MMU.
Il vantaggio di questo sistema è che la tabella delle pagine può essere modificata “al volo”: ogni processo può così utilizzare il suo spazio di memoria isolato. Ne consegue che i processi non possono accedere alla memoria impegnata da altri processi.
Come viene gestita l’allocazione delle risorse in memoria per consentirne l’accesso da parte del kernel
Il kernel, viceversa, deve però avere “carta bianca”: deve ad esempio memorizzare molti dati propri per tenere traccia di tutti i processi in esecuzione e persino della tabella delle pagine. Ogni volta che viene attivato un interrupt hardware, un interrupt software o una chiamata di sistema e la CPU entra in modalità kernel, il codice del kernel deve accedere alle varie porzioni della memoria.
La soluzione di Linux è di destinare (“allocare”) la metà superiore dello spazio di memoria virtuale al kernel (Higher Half Kernel); Windows poggia su una tecnica simile mentre l’approccio adottato in macOS è più complicato.
Sarebbe terribile dal punto di vista della sicurezza se i processi userland potessero leggere o scrivere la memoria del kernel. A livello di paging, quindi, è previsto un flag di autorizzazione.
Un flag determina se la regione è scrivibile o solo leggibile, un altro indica alla CPU che solo la modalità kernel può accedere alla regione di memoria. Quest’ultimo flag è usato per proteggere l’intero spazio Higher Half Kernel.
La clonazione delle pagine di memoria a partire dal processo init
Abbiamo visto che la chiamata di sistema execve
sostituisce il processo corrente con un nuovo programma, ma ciò non spiega come è possibile avviare più processi. Sicuramente non spiega com’è eseguito il primissimo programma: quale gallina (processo) depone (genera) tutte le altre uova (altri processi)?
Se execve
avvia un nuovo programma sostituendo il processo corrente, come si avvia un nuovo programma separatamente e come nuovo processo? Quando si fa doppio clic su un’app per avviarla, l’applicazione si apre infatti automaticamente mentre il programma utilizzato in precedenza continua a funzionare.
Parlando di programmi in esecuzione sui sistemi Unix-like, visto che abbiamo preso come esempio la SYSCALL execve
, il sistema operativo lancia nuovi programmi chiamando fork
quindi eseguendo subito execve
nel processo figlio. È chiamato modello fork-exec.
Quest’operazione di clonazione è efficiente perché tutte le pagine di memoria sono COW (Copy-On-Write). Quando un processo padre crea un processo figlio con la chiamata di sistema fork
, vengono create copie dello spazio di indirizzamento del processo padre all’interno dello spazio di indirizzamento destinato al processo figlio. Spesso il processo figlio usa uno spazio di indirizzamento contenente gran parte delle pagine di memoria del processo padre.
Invece di creare copie fisiche delle pagine di memoria per il processo figlio, le pagine COW condividono la stessa porzione della memoria tra processo padre e figlio fino a quando uno dei due tenta di modificarlo. In altre parole, quando un processo cerca di modificare una pagina condivisa, il sistema operativo crea una nuova copia della pagina, la modifica e successivamente la associa al processo che ha effettuato la modifica.
Le pagine COW sono uno degli elementi chiave nella gestione efficiente della memoria nei sistemi operativi moderni e consentono la creazione di nuovi processi in modo rapido ed efficiente. Su Linux, la cosa è gestita attraverso l’uso della funzione copy_process
.
Ogni processo è eseguito a partire da un fork
di un programma genitore, tranne uno: il processo init
. Quest’ultimo è impostato manualmente, direttamente dal kernel. È il primo programma userland ad essere eseguito e l’ultimo ad essere “ucciso” allo spegnimento del sistema. Inoltre, è responsabile della generazione di tutti i programmi e servizi che compongono il sistema operativo.
Su Linux o macOS basta digitare sudo kill 1
per arrestare forzosamente il processo init
(PID 1): il risultato sarà una schermata completamente nera.