DA MASER A MONFUMO | MOTO GUZZI V85TT | PURE SOUND POV 4K

🏍️ Il mio nuovo canale YouTube: giri in moto in POV, solo audio, tra le Dolomiti in 4K. Niente musica, niente parole — solo il motore e le Alpi. Vieni a fare un giro!

Iscriviti

Di recente ho rivisitato i miei vecchi appunti di sistemi operativi dell'università e mi sono reso conto di quanto tutto sembrasse troppo astratto: avevo studiato processi, scheduling e gestione della memoria, ma principalmente in senso teorico. È stato allora che ho deciso di prendere il libro Linux Kernel Development di Robert Love. Nonostante sia scritto per la serie di kernel 2.6 (ormai piuttosto datata), le sue pagine offrono ancora buone intuizioni sulle idee fondamentali alla base degli internals di Linux. Leggerlo mi ha ricordato che, mentre le API specifiche e le strutture dati evolvono nel tempo, i principi fondamentali del design rimangono piuttosto costanti.

Volevo una comprensione concreta di come i sistemi operativi "reali" risolvono i problemi quotidiani — cose come schedulare i processi in modo equo, gestire gli interrupt prontamente e gestire la memoria senza sprecare cicli CPU. Il libro di Love è stato un ottimo punto di partenza, e mi ha spinto ad approfondire ulteriormente nei sorgenti e nella documentazione del kernel attuale per vedere cosa era cambiato (e, molto spesso, cosa era rimasto uguale). In questo post, vi illustrerò alcune delle principali conclusioni del mio approfondimento sul kernel, con qualche commento personale accanto ai dettagli tecnici.

Iniziare nello "Kernel Land"

Sviluppare all'interno del kernel è vastamente diverso dalla programmazione userspace quotidiana. Prima di tutto, bisogna abbandonare molte comodità familiari. La libreria standard C è vietata, anche se il kernel fornisce varianti semplificate di alcune funzioni libc nella propria directory lib/. Non ci si può affidare a tutti quegli header che normalmente si includono nello user space, e bisogna essere consapevoli che il kernel ha le proprie convenzioni d'uso per le estensioni GNU C.

Altrettanto importante: la protezione della memoria non è la stessa di nello user space. Nello user space, se si dereferenzia accidentalmente un puntatore che non appartiene al proprio processo, probabilmente si scatenerà una segmentation fault e il processo potrà crashare. Nel kernel, un accesso errato alla memoria può portare a un messaggio fatale di "oops", destabilizzando potenzialmente l'intero sistema. Non si possono nemmeno fare operazioni in virgola mobile nel kernel — almeno non nel modo usuale — perché il kernel evita di usare l'unità in virgola mobile per impostazione predefinita, per ragioni di prestazioni e complessità.

C'è poi lo stack. Invece del grande stack espandibile a cui si è abituati, il kernel fornisce uno stack piccolo e di dimensioni fisse (spesso solo poche pagine, es. 8 KB su molti sistemi a 64 bit). Gli stack overflow possono avvenire silenziosamente, quindi bisogna essere disciplinati con le variabili locali. E mentre la concorrenza è una preoccupazione per qualsiasi progetto software moderno, nel kernel è a un livello completamente diverso. Gli interrupt possono arrivare in qualsiasi momento, il preemption può verificarsi in posti inaspettati, e il symmetric multiprocessing (SMP) significa che più CPU potrebbero eseguire il vostro codice kernel simultaneamente. È tutto un altro mondo!

Processi: Molto Più di Semplici "Task"

Una delle prime grandi sorprese per me è stata la realizzazione che, in Linux, processi e thread non sono davvero entità distinte sotto il cofano. Entrambi sono rappresentati dalla stessa struttura dati fondamentale, la task_struct. Quando si usa clone(), fork(), o pthread_create() nello user space, si stanno fondamentalmente solo creando variazioni sullo stesso tema di "task". Gli argomenti passati a clone() decidono essenzialmente quali risorse — come memoria, file descriptor, o signal handler — vengono condivise e quali vengono duplicate.

Un trucco interessante che Linux usa dopo aver creato un processo figlio è far girare prima il figlio. Se il figlio chiama exec(), sostituisce immediatamente il suo spazio degli indirizzi con un nuovo programma, rendendo le ottimizzazioni copy-on-write molto efficienti. Se al genitore fosse permesso girare prima e scrivere in memoria, il kernel dovrebbe creare copie extra di pagine che potrebbero risultare inutili se il figlio chiama presto exec().

Per accedere al task corrente, il codice del kernel tipicamente usa una macro chiamata current. La sua implementazione dipende dall'architettura. Su alcune architetture, current risiede in un registro specifico, mentre su altre (come x86), si accede attraverso il fondo dello stack.

Infine, terminare un processo non significa che scompaia immediatamente. Un task defunto diventa uno "zombie" finché il suo genitore non chiama wait() (o equivalente) per leggere il suo codice di uscita. Se un genitore crasha, i suoi figli vengono "adottati" da init (PID 1), che li pulirà alla fine. Quindi se vedete mai processi "zombie" in giro, è esattamente quello che sta succedendo.

Scheduling: Equità per Tutti… o No

Lo scheduling è uno dei compiti fondamentali del kernel: decidere quale processo (o thread) deve girare successivamente. Linux usa classi di scheduling per gestire diverse policy, con il Completely Fair Scheduler (CFS) che è quella principale per i task normali non real-time. La funzione di scheduling di primo livello, schedule(), controlla ogni classe di scheduling in ordine di priorità. Una volta trovata una classe con un processo eseguibile, affida le decisioni di scheduling a quella classe.

CFS mira all'equità: cerca di dare a ogni processo eseguibile una quota della CPU, guidata dai valori nice (che vanno da -20 per la priorità alta a +19 per la priorità bassa). Sotto CFS, non c'è un timeslice fisso. Invece, lo scheduler tiene traccia di qualcosa chiamato virtual runtime (vruntime), che si accumula più velocemente per i task a bassa priorità e più lentamente per quelli ad alta priorità. Strutture dati come un red-black tree aiutano a tenere traccia di quale processo dovrebbe girare successivamente (quello con il vruntime più piccolo).

Per i task real-time (con priorità da 0 a 99), lo scheduler li privilegia rispetto ai task normali basati sul "nice value". Linux supporta anche il kernel preemption, il che significa che anche il codice in modalità kernel può essere interrotto forzatamente per eseguire un task a priorità più alta, purché non siano tenuti spinlock o altre regioni non preemptable. Tutto questo fa parte dello sforzo del kernel di minimizzare la latenza e mantenere il sistema responsivo.

Chiamate nel Kernel: Le System Call

Le system call formano il confine tra lo user space e il kernel space. Sebbene le chiamiamo "funzioni", le system call sono in realtà speciali entry point attivate da istruzioni specifiche dell'architettura come syscall o int 0x80 su x86. Una volta nel kernel, il gestore delle system call vi indirizza alla funzione corretta tramite una tabella di system call. Ogni architettura ha la propria tabella, quindi se aggiungete una nuova system call, dovete aggiungere un entry per ogni architettura che volete supportare.

Poiché lo user space può passare puntatori malformati o malevoli, le system call devono validare attentamente i loro parametri — da qui funzioni come copy_from_user() e copy_to_user() che spostano i dati in sicurezza tra user space e kernel space. Queste funzioni possono bloccarsi, e restituiranno errori se l'accesso alla memoria non è valido.

In pratica, i moderni sviluppatori del kernel raramente aggiungono nuove system call se non strettamente necessario. Più comunemente, si espone la funzionalità tramite file device o l'interfaccia sysfs, lasciando che i programmi utente interagiscano attraverso operazioni di read/write o file di sistema specializzati.

Strutture Dati del Kernel: Liste, Alberi e Altro

Inutile reinventare la ruota: il kernel Linux include una serie di strutture dati integrate. L'implementazione delle liste concatenate, per esempio, è una lista doppiamente concatenata circolare che potete incorporare direttamente nelle vostre strutture. C'è un insieme di macro e funzioni di supporto per aggiungere, rimuovere e iterare su queste liste senza problemi. Potete costruire stack, code o altri pattern sopra di esse.

Per le mappe, il kernel offre strutture specializzate spesso indicizzate per ID utente o altri ID integrali, tipicamente usando red-black tree sotto il cofano. C'è anche l'interfaccia kfifo per i ring buffer (un classico approccio a coda FIFO) e macro per costruire le proprie strutture specializzate attorno ad esse. Il design complessivo è minimalista ma potente, e serve a evitarvi di dover scrivere il vostro codice per questi pattern comuni.

Interrupt e Handler degli Interrupt

Gli interrupt sono il modo in cui i dispositivi hardware fanno sapere alla CPU e al kernel che qualcosa richiede attenzione. Ogni interrupt ha un numero univoco e una corrispondente Interrupt Service Routine (ISR) registrata nel kernel. Quando arriva un interrupt, la CPU si ferma, salta all'ISR, e (idealmente) ritorna dopo che quel codice ha fatto il minimo necessario.

Perché solo il minimo? Perché mentre si è nella "top half" dell'ISR, gli interrupt su quella linea sono disabilitati, e le prestazioni dell'intero sistema possono degradarsi se si rimane lì troppo a lungo. Il lavoro pesante avviene in una "bottom half", che il kernel implementa usando meccanismi come tasklets, softirqs, o workqueues. Questo modello di elaborazione differita aiuta a mantenere bassa la latenza degli interrupt.

Ogni handler di interrupt in Linux restituisce IRQ_HANDLED per confermare "sì, questo era per me," o IRQ_NONE se non è stato effettivamente generato da quel particolare dispositivo. In molti sistemi moderni, più dispositivi condividono gli interrupt, quindi serve un modo rapido per rilevare se si è il destinatario previsto.

Bottom Half e Lavoro Differito

Per evitare di passare troppo tempo con gli interrupt disabilitati, Linux differisce la maggior parte del lavoro alle "bottom half". Queste bottom half girano con meno restrizioni e in momenti più opportuni. Il kernel offre tre principali facility per questo: Softirq (allocati staticamente, possono girare su più CPU), Tasklet (costruiti sopra i softirq, creati dinamicamente, non possono girare due dello stesso tipo su due CPU simultaneamente), e Workqueue (girano nel contesto di processo, possono bloccarsi/dormire, ogni CPU ha il proprio thread worker del kernel).

Se la vostra routine differita deve dormire (es. aspettare una risorsa), dovete usare una workqueue. Altrimenti, potete usare tasklet o softirq per le prestazioni. La maggior parte degli autori di driver finisce per usare tasklet o workqueue, poiché i softirq richiedono una gestione della concorrenza più complessa e devono essere registrati staticamente.

Sincronizzazione del Kernel: L'Arte di Stare al Sicuro

I problemi di concorrenza sono ovunque nel kernel. Si hanno interrupt, bottom half, kernel preemption, SMP, sleep e molto altro. Per sopravvivere in questo ambiente, è necessario fare affidamento su vari primitivi di sincronizzazione e pattern di design che garantiscono un accesso sicuro ai dati.

Al livello più basilare, il kernel fornisce operazioni atomiche (atomic_t, atomic64_t) e operazioni bitwise che girano atomicamente. Poi ci sono i lock:

  • Spinlock: Lock a busy-wait che possono essere usati nel contesto degli interrupt (perché non bloccano). Se li usate in un interrupt handler, dovete anche disabilitare gli interrupt mentre tenete il lock.
  • Reader-writer spinlock: Permettono a più lettori contemporaneamente ma solo uno scrittore alla volta. I lettori possono però affamare lo scrittore.
  • Semafori: Semafori di conteggio che permettono di bloccarsi se la risorsa non è disponibile. Per un singolo "token", agiscono come un mutex.
  • Mutex: Simili ai semafori, ma solo il thread che ha acquisito il lock può rilasciarlo. Nessun locking ricorsivo consentito.
  • Completion: Segnali leggeri che un thread può usare per notificare a un altro che "hey, ho finito il mio lavoro."

Ci sono anche costrutti avanzati come i sequence lock, che danno preferenza agli scrittori. E se avete bisogno di coordinare con interrupt, bottom half, o kernel preemption, potete disabilitarli in modo localizzato per proteggere le vostre sezioni critiche. È meglio assumere che tutto stia accadendo contemporaneamente perché, nel kernel, spesso è così.

Timer e Gestione del Tempo

Un altro pezzo della magia del kernel è come viene tracciato e usato il tempo. La piattaforma hardware fornisce un timer di sistema che ticca a una frequenza rappresentata dalla costante HZ nel kernel. Ogni "tick" scatena un interrupt del timer, e in risposta il kernel aggiorna il contatore "jiffies" (la variabile globale che tiene il numero di tick dal boot), mantiene l'uptime del sistema, aggiorna la load average e così via.

I timer nel kernel sono o periodici o dinamici. I timer dinamici vi permettono di schedulare una funzione da eseguire dopo un certo ritardo. Quando un timer dinamico scade, il softirq dei timer del kernel esegue il callback appropriato nel contesto di bottom half. Se avete mai bisogno di cancellare un timer, assicuratevi di usare le versioni "sync" delle funzioni di cancellazione — come del_timer_sync() — se c'è qualche possibilità che il timer stia girando su un'altra CPU.

Per ritardi brevi dove non è possibile dormire (come un'attesa in scala di microsecondi nel codice di un driver), potete chiamare udelay(), ndelay(), o mdelay(). Altrimenti, se potete permettervi di cedere la CPU, schedule_timeout() è il vostro amico.

Gestione della Memoria: Pagine e Allocazioni

All'interno del kernel, la memoria fisica è suddivisa in pagine. Ogni pagina è solitamente tracciata da una struct page, che descrive chi la possiede (processi utente, allocazioni del kernel, ecc.). Le pagine sono raggruppate in zone come ZONE_DMA, ZONE_NORMAL e ZONE_HIGHMEM. Ogni architettura può variare in quante zone ha.

Se avete bisogno di memoria fisicamente contigua di dimensione 2^order pagine, potete usare alloc_pages(). Per la maggior parte delle piccole allocazioni, userete kmalloc(), che restituisce un chunk di memoria fisicamente contiguo. C'è anche vmalloc() per allocazioni che devono essere contigue solo nello spazio virtuale (con un costo in termini di prestazioni), ma non fisicamente contigue.

Un intero sottosistema chiamato "slab allocator" (o SLUB, a seconda della versione del kernel) gestisce la creazione di cache per gli oggetti allocati comunemente. Questo aiuta a riciclare la memoria in modo efficiente. Potete persino creare le vostre cache personalizzate per strutture che allocate frequentemente nel vostro driver o sottosistema.

Il kernel fornisce anche variabili e API per-CPU dedicate in modo da poter memorizzare dati separatamente su ogni CPU senza doverle bloccare. L'accesso a quelle variabili richiede cautela: di solito è una cattiva idea leggere la variabile per-CPU di un'altra CPU senza bloccare.

Il Virtual Filesystem (VFS)

Se avete mai usato I/O su file in Linux (e chi non lo ha?), avete interagito indirettamente con il VFS. Presenta un'interfaccia singola e uniforme per tutti i filesystem — ext4, XFS, NFS, e così via. Sotto il cofano, il VFS usa un insieme di tipi di oggetti comuni come le strutture superblock, inode, dentry e file. Ogni oggetto ha una tabella associata di puntatori a funzione — metodi per leggere, scrivere, cercare entry, ecc.

Un inode memorizza i metadati del file (proprietà, permessi, timestamp, ecc.). Una dentry rappresenta un entry di directory per un file o un componente di directory di un percorso. Il kernel memorizza nella cache queste dentry nella dcache per velocizzare la ricerca dei percorsi. Nel frattempo, l'oggetto superblock contiene informazioni sull'intero filesystem montato, e l'oggetto file rappresenta un file aperto con specifiche modalità di accesso. Il genio qui è che quando aprite un file, non avete davvero bisogno di sapere se si trova su una partizione ext4, una condivisione di rete o un ramdisk. Funziona tutto, grazie al layer VFS.

Il Block I/O Layer

I dispositivi di archiviazione sono solitamente trattati come block device in Linux, il che significa che il kernel può leggerli o scriverli in multipli di settori di dimensioni fisse. Sopra c'è il layer del filesystem, che tratta con blocchi (multipli di settori) fino a un massimo della dimensione della pagina del sistema.

I kernel più vecchi usavano una struttura "buffer head" per rappresentare ogni blocco in memoria. I kernel moderni spesso si affidano alle strutture bio, che permettono scatter-gather I/O (cioè, pagine non contigue che formano una singola operazione). Quando si avvia una lettura o scrittura, il kernel costruisce una bio, imposta un elenco di pagine, offset e lunghezze, e poi accoda la richiesta nella coda delle richieste del dispositivo a blocchi. Un I/O scheduler specializzato poi unisce e ordina quelle richieste per minimizzare le ricerche sul disco — cruciale per i dischi rotanti. Sugli SSD, lo scheduler NOOP o deadline potrebbe essere sufficiente perché c'è poca penalità per l'"accesso casuale".

Debug del Kernel

Fare debug del codice del kernel può essere intimidatorio. Fortunatamente, c'è la funzione printk(), che è simile a printf() nello user space ma funziona affidabilmente in quasi qualsiasi contesto del kernel. Se le cose vanno davvero storte, potreste vedere un "oops" o un kernel panic completo. Un "oops" è un errore da cui il kernel cerca di riprendersi; un panic significa che il kernel non può riprendersi affatto.

A volte il messaggio di oops o panic stampa indirizzi grezzi. Strumenti come ksymoops (o abilitare CONFIG_KALLSYMS_ALL) possono decodificare quegli indirizzi in nomi di funzione e numeri di riga. Il kernel supporta anche kgdb, che abilita il debugging a livello sorgente su una connessione seriale o di rete, anche se può essere difficile da configurare.

Se vi trovate a sospettare un bug, potete usare macro come BUG_ON() o panic() per crashare intenzionalmente quando certe condizioni sono soddisfatte. Sembra estremo, ma un crash intenzionale può a volte essere il modo più rapido per diagnosticare un difetto fatale — soprattutto quando si ha a che fare con race condition o corruzione della memoria.

Portabilità: Perché Linux Gira su Tutto

Una delle qualità più forti del kernel Linux è la sua portabilità. Che giri su una minuscola scheda embedded, un PC x86 standard, o un mainframe massiccio, Linux mantiene la maggior parte del suo codice agnostico rispetto all'architettura. Ogni piattaforma implementa le proprie routine a basso livello (come come si cambiano i processi, si gestiscono le eccezioni, o si gestisce la MMU), ma i sottosistemi del kernel comune rimangono gli stessi.

Se vi trovate a scrivere codice del kernel, non assumete mai cose come la dimensione delle parole, l'endianness, o anche la frequenza del timer. Usate le macro del kernel e i tipi opachi (u32, atomic_t, ecc.) e fate affidamento sulle appropriate API per l'allineamento o la conversione tra big-endian e little-endian. Il kernel è pieno di questi tipi di helper proprio perché il codice deve girare correttamente su più architetture.

Conclusione

Immergersi nello sviluppo del kernel può sembrare disorientante all'inizio. Lo stack limitato, le sfide di concorrenza e la gestione specializzata della memoria lo rendono abbastanza diverso dalla programmazione user-space. Ma una volta che si comincia a capire come le cose si incastrano, è gratificante — si ottiene il controllo diretto sull'hardware, si progettano strutture dati a basso livello, e si vede l'impatto delle proprie modifiche in tempo reale.

Detto questo, non è sempre una navigazione tranquilla. Il debugging è più difficile, gli errori possono mandare in crash l'intero sistema, e non ci sono reti di sicurezza come nello user space. Ma per gli ingegneri interessati a capire come funzionano davvero i computer, lo sviluppo del kernel fornisce preziose intuizioni sulla concorrenza, le prestazioni e i trade-off alla base di un sistema ben progettato e portabile.

Se siete interessati, un buon punto di partenza è sperimentare con i moduli del kernel, esplorare lo scheduler in sched.c, o scoprire come il block layer gestisce l'I/O. Anche risorse più datate come Linux Kernel Development di Robert Love offrono un contesto utile per capire i principi che ancora plasmano i kernel moderni. C'è sempre altro da esplorare.