di Alessandro Rubini
Riprodotto con il permesso di Linux Magazine, Edizioni Master.
Le interruzioni ("interrupt" o IRQ per "interrupt request") sono alla base del funzionamento di un sistema multiprocesso. In questo articolo viene introdotta la gestione delle interruzioni del processore in un sistema Linux-2.6, presentando un modulo di esempio che gestisce interruzioni generate dalle periferiche già presenti nel sistema.
Il codice è stato provato sulla versione 2.4.9-rc4 come appare
su ftp.it.kernel.org
.
Normalmente l'esecuzione del codice da parte del processore ("CPU", "central processing unit") è sequenziale, seguendo il flusso logico del programma in esecuzione e senza distrazioni da questo compito. Inizialmente, con le prime macchine, non c'erano alternative a questo modo di funzionamento. Poco dopo, però, si è pensato di permettere l'interruzione del normale flusso di istruzioni seguito dal processore da parte di eventi esterni. Oggi questo meccanismo è ampiamente usato e gli eventi che interrompono il processore sono normalmente associati ad una qualche periferica che richiede attenzione: per esempio la pressione di un tasto, l'invio di un pacchetto dalla rete o lo scattare del tempo sull'orologio di sistema.
Richiedere un'interruzione al processore è come richiedere attenzione da una persona che sta svolgendo un compito: chiamandola, telefonandole o mettendo un biglietto sulla sua scrivania. In base all'importanza dell'attività in corso e al tipo di richiesta si otterrà risposta con più o meno sollecitudine.
Il meccanismo usato per riportare le interruzioni al processore può esser dei più vari. Nel caso più semplice si tratta di un unico filo collegato con il mondo esterno, attraverso il quale un circuito dedicato chiamato PIC ("programmable interrupt controller") comunica la sua richiesta di attenzione; la CPU interromperà quindi il suo lavoro e interrogherà il PIC per sapere quale periferica ha richiesto attenzione. In certi sistemi il processore viene raggiunto da vari segnali, corrispondenti a richieste di interruzioni con priorità diversa e potrebbe non esserci bisogno di un PIC esterno. In altri casi ancora molte periferiche risiedono fisicamente all'interno del processore stesso e viene realizzata un'architettura con più livelli di priorità con un solo segnale di IRQ proveniente dall'esterno, al quale può essere associato o meno un controllore programmabile.
Altre variazioni sul tema sono le cosiddette trap (si veda il riquadro 1), le interruzioni non mascherabili (NMI, "non maskable interrupt") e tutte le complicaazioni introdotte in mondo PC in questi ultimi anni (APIC, IO-APIC, MSI, MSI-X) che per fortuna possono essere ignorate tranquillamente in quanto l'interfaccia offerta dal kernel verso i suoi moduli è indipendente dalla gestione di basso livello implementata nel caso specifico. Anche la gestione dei livelli di priorità, quando presente, può essere ignorata dal codice dei driver che possono usare il semplice modello a due livelli in cui il driver può chiedere la disabilitazione temporanea delle interruzioni per poi riabilitarle. Oppure non toccare niente del tutto, come faremo nel semplice esempio presentato più avanti.
Riquadro 1 - Interruzioni, trap e richieste software
Le interruzioni sono eventi scatenati dall'esterno, ma il meccanismo di gestione di questi eventi è abbastanza generale da risultare estremamente utile anche per altri tipi di eventi, generati da errori nell'esecuzione del programma o da richieste esplicite del programmatore.
Le cosiddette trap (trappole), sono interruzioni generate dal
processore quando non riesce ad eseguire un'istruzione macchina, per
esempio perché si tratta di una divisione per zero, o l'indirizzo di
memoria cui deve accedere non è valido, oppure l'istruzione non è
definita nel set di istruzioni della CPU. In tutti questi casi
l'esecuzione passa al sistema operativo con un meccanismo simile o
identico (a seconda delle scelte dei progettisti hardware) a quello
utilizzato nella gestione di interruzioni esterne. Il sistema
operativo può analizzare la situazione e correggere il problema (per
esempio recuperando una pagina di dati dallo spazio di swap) oppure
"punire" il programma che si è comportato male. In un sistema Unix la
punizione consiste nell'invio al processo di un segnale; nei tre casi
elencati si tratta di SIGFPE
(floating point exception),
SIGSEGV
(segmentation violation) e SIGILL
(illegal intruction).
Il processo può essere predisposto per intercettare
questi segnali e cercare di recuperare la situazione, se non lo è
verrà ucciso senza pietà.
Le «interruzioni software» si avvalgono anche loro del meccanismo
hardware delle interruzioni (ancora una volta, un meccanismo simile o
identico) al fine di trasferire il controllo al sistema operativo.
Nel set di istruzioni del processore è in genere definita una
istruzione INT
(o SWI
-- software interrrupt -- o
equivalente) che trasferisce il controllo al sistema operativo
proprio come una trap o un'interruzione esterna. Il sistema operativo
analizzando lo stato del processore estrae gli argomenti passati
dal programma e provvede ad eseguire la richiesta o a ritornare
un codice di errore. Per esempio, su piattaforma x86 le chiamate
di sistema per il kernel Linux sono implementate dall'interruzione
numero 0x80; il registro EAX contiene il numero della chiamata
di sistema e, all'uscita, il valore di ritorno; gli altri registri
contengono gli argomenti della chiamata di sistema. Per i dettagli
implementativi è interessante leggere <asm/unistd.h>
per
la propria architettura.
Parlando di sgnali elettrici, le interruzioni possono essere attivate dal fronte (variazione del segnale) o dal livello di segnale; la scelta della modalità operativa deve essere condivisa tra la CPU e la periferica (o il PIC). In ogni caso, è comunque consuetudine usare segnali attivi bassi (quindi fronti discendenti, che i tecnici spesso chiamano "falling edge").
Molti anni fa, quando le favole erano ancora giovani, era comune l'uso di interruzioni attivate dal fronte, tradizione seguita in particolare nel mondo PC; stiamo parlando di ISA, un'architettura obsoleta fino dalla sua nascita, avvenuta negli anni '80. L'interruzione sul fronte è un meccanismo che teoricamente semplice: una volta che la periferica ha notificato la sua richiesta (attivando il segnale e disattivandolo in un futuro non meglio precisato) aspetta che le venga data risposta, senza disturbare ulteriormente il processore. È un po' come suonare il campanello e attendere con pazienza che ci venga aperta la porta. Molte periferiche mantengono il segnale attivo fino alla gestione dell'interruzione da parte del software, anche se l'evento scatenante è il fronte iniziale, indipendentemente dalla durata del livello basso del segnale. La gestione di un'interruzione di questo tipo, pur semplice, richiede una forma di memorizzazione: se il processore non può gestire subito l'interruzione deve ricordarsi di farlo in un secondo momento, per non lasciarci in strada ad aspettare per giorni e giorni -- per fortuna le persone non sono stupide come le macchine.
Nelle macchine recenti, insieme che comprende le macchine x86 con bus PCI, si usano interruzioni attivate dal livello; è come se suonando il campanello non si staccasse il dito finchè non viene aperta la porta: se chi ci deve rispondere è al momento impossibilitato a farlo può dilazionare la risposta senza necessità di memorizzare l'evento, in quanto la richiesta sarà ancora attiva quando potrà essere assolta.
Questa seconda modalità, pur se poco appropriata nelle relazioni interpersonali, è decisamente da preferirsi nella comunicazione tra circuiti perché da un lato semplifica l'interfaccia tra i componenti (eliminando il PIC o riducendone la complessità) e dall'altro permette la condivisione di interruzioni tra più periferiche senza possibilità di malfunzionamenti.
Per un autore di driver l'interruzione sul livello richiede
però una piccola attenzione in più rispetto all'interruzione sul fronte:
se ci si dimentica di comunicare alla periferica di aver evaso la
richiesta si può potenzialmente bloccare il sistema, perché
il processore sarà nuovamente interrotto dal livello ancora attivo all'uscita
dal gestore di interruzione. Un po' come se rispondendo al citofono
dimenticassimo di dire di staccare il dito dal campanello.
Un errore simile in un sistema con interruzioni sul fronte ha
come conseguenza la mancata richiesta di interruzioni successive,
senza blocco totale del sistema. Il problema, in effetti, non è raro come ci
si può aspettare, tanto che i manutentori di Linux-ARM hanno
predisposto un controllo apposito per disabilitare un'interruzione
"impazzita"; si veda check_irq_lock() in
arch/arm/kernel/irq.c
. Nello stesso file consiglio di leggere le
funzioni do_edge_IRQ() e do_level_IRQ() per una discussione
della qualità dei due approcci.
Riquadro 2 - Attivo alto e attivo basso
I segnali elettrici nei dispositivi digitali si dividono in segnali attivi alti e segnali attivi bassi. Con "alto" si intende un livello di tensione positivo, con "basso" si intende un livello di tensione vicino al quello di terra.
Quando l'elettronica digitale ha inziato a diffondersi massicciamente, negli anni '70, la famiglia di porte logiche che ha avuto più successo (TTL, transistor-transistor logic) aveva un comportamento asimmetrico a causa dell'uso di transistori di una sola polarità. Un segnale basso in un circuito TTL comporta il passaggio di una corrente molto maggiore a quella trasmessa da un segnale alto. Per risparmiare energia ed evitare il surriscaldamento dei componenti, si è perciò imposto l'uso di una convenzione attiva bassa per tutti i segnali che rimangono inattivi per la maggior parte del tempo.
Nonostante oggi quasi tutti i circuiti logici siano realizzati in tecnologia CMOS, che ha un comportamento simmetrico, si è mantenuta la convenzione dei segnali attivi bassi per tutte le forme di segnalazione asimmetrica: i segnali di reset e di abilitazione dei dispositivi (chip select), come i segnali di interruzione.
Una linea di interruzione può essere condivisa tra più periferiche semplicemente collegando insieme i segnali di attivazione dei vari dispositivi, a patto che ciascuno di essi agisca sul segnale solo per portarlo in stato di attivazione, lasciandolo tornare autonomamente in stato inattivo (che deve essere lo stato di default). In questo modo, quando solo un dispositivo attiva il segnale non si troverà in conflitto con altri che cercano di mantenere lo stato inattivo. In certi casi (per esempio nel caso del bus PCI) la condivisione avviene tramite circuiti di appoggio per ragioni di velocità nelle transizioni del segnale, ma il risultato non cambia: il segnale è attivo se almeno uno dei dispositivi ne chiede la attivazione.
Quando si lavora con interruzioni condivise, si evidenzia un serio problema dell'interruzione sul fronte: la possibilità per un dispositivo di non vedere evasa la sua richiesta, come rappresentato in figura 1. Nel caso presentato, la periferica A, in condivisione con la periferica più lenta B, richiede un'interruzione e poco dopo ne richiede un'altra. Il secondo fronte, però, non raggiunge la CPU a causa dell'intervento di B e la seconda richiesta della periferica A non sarà evasa; perciò la linea di interruzione rimarrà bloccata e né A né B potranno essere servite ulteriormente.
Nel caso di interruzioni sul livello problemi di questo tipo non possono avvenire in quanto il segnale rimane attivo finchè tutte le periferiche non vengono servite (finchè tutte le dita non vengono tolte dal pulsante del campanello). In figura 2 è rappresentata la stessa situazione: dopo aver gestito l'interruzione una prima volta, il kernel nota che il segnale è ancora attivo e reinvoca la procedura di gestione.
Come mostrato nelle figure, il comportamento del sistema quando una linea di interruzione è condivisa tra più periferiche consiste nell'invocare sequenzialmente tutti i gestori registrati ogni volta che è attiva una richiesta di interruzione; i gestori che non riscontrano una richiesta attiva nella propria periferica, devono semplicemente ignorare l'evento. Il driver di B, in figura 2, ignorerà la seconda invocazione del suo gestore.
Riquadro 3 - Likely e unlikely
Leggendo il codice codice del kernel relativo alla gestione delle interruzioni, è facile incontare costrutti come i seguenti:
if (likely(!(desc->status & IRQ_PENDING))) { ... }
if (unlikely(!action)) { ... }
Queste due macro, likely e unlikely, sono definite in
<linux/compiler.h>
e si appoggiano su __builtin_expect()
.
Quest'ultima è una funzione predefinita, presente dalla
versione 2.96 in poi di gcc, che permette l'ottimizzazione
dei blocchi condizionali in base a quale si aspetti
essere il risultato più probabile della condizione valutata.
Un driver che volesse gestire un'interruzione dovrà dichiarare al kernel il suo interesse tramite la funzione request_irq():
#include <linux/interrupt.h> int request_irq(unsigned int irqnr, irqreturn_t (*handler)(int, void *, struct pt_regs *), unsigned long flags, const char *name, void *devid);
L'argomento irqnr
indica il numero dell'interruzione cui si è
interessati: il suo significato dipende dalla piattaforma hardware su
cui si lavora; mentre sul PC si tratta in genere di un valore compreso
tra 0 e 15 su altre piattaforme non è raro vedere numeri molto più
alti. Il puntatore handler
indica la nostra funzione di gestione,
flags sarà tipicamente SA_SHIRQ
per indicare la possibilità di
condividere la linea di interruzione. Il nome indicato viene usato
semplicemente per diagnostica in /proc/interrupts mentre devid
deve essere un puntatore che indichi univocamente la periferica;
di solito viene usato a questo scopo il puntatore alla struttura dati
che descrive l'istanza di periferica. Una volta cessato l'interesse del driver
per l'interruzione, dovremo chiamare free_irq():
void free_irq(unsigned int irqnr, void *devid);
Il devid
usato nel liberare l'interruzione deve essere lo stesso puntatore
usato in request_irq
, in quanto il kernel lo usa per identificare
quale gestore rimuovere tra quelli associati ad irqnr
.
Il ruolo del gestore di interruzioni (handler
), una volta
registrato, è quello di evadere le richieste di interruzione e
notificare al chiamante cosa è successo. I possibili valori di
ritorno del gestore sono IRQ_HANDLED
e IRQ_NONE
, da usare
rispettivamente per comunicare di aver gestito l'interruzione oppure
di non averlo fatto; un driver ritornerà IRQ_NONE
quando
l'interruzione non è stata generata dalla sua periferica, situazione
comune in caso di condivisione di interruzioni tra più dispositiivi.
Nel file linux/interrupt.h è definita anche una terza forma per il
valore di ritorno di un gestore: IRQ_RETVAL(x)
: si tratta di una
semplice macro che prende un valore booleano e lo converte in
IRQ_HANDLED
o IRQ_NONE
.
È interessante notare come i nomi delle due funzioni (request
e
free
) non suonino appropriati al loro ruolo, bisogna però ricordare
che si tratta di funzioni che esistono dal 1991: all'inizio non
c'era modo di condividere le interruzioni tra più periferiche ed
effettivamente un driver che usasse una linea di interruzione
ne prendeva possesso ed impediva
a chiunque altro di usarla finchè non l'avesse «liberata».
Per vedere in pratica la gestione di un'interruzione si può caricare
il modulo tirq (test IRQ) il cui codice appare nel riquadro 4. È
disponibile in forma elettronica nel CD redazionale o in
http://www.linux.it/kerneldocs/irq/src.tar.gz
. Tale modulo si
registra come gestore di interruzione condivisa, e stampa una volta al
secondo il numero di interruzioni che gli sono state notificate, se ve
ne sono.
tirq gestisce l'interruzione il cui numero viene
specificato come parametro del modulo. Nel caso di default (0),
su una macchina x86 il caricamento del modulo fallirà con EBUSY
:
l'interruzione è associata all'orologio di sistema, il cui
driver non specifica SA_SHIRQ
quando si registra, perciò
nessun'altra funzione può condividere con lui la linea di interruzione.
burla% sudo insmod src/tirq.ko Error inserting 'src/tirq.ko': -1 Device or resource busy
Nell'esempio seguente, invece, il modulo viene caricato sull'interruzione
della scheda di rete e appare in /proc/interrupts fianco
a fianco con il gestore di eth0
:
burla% sudo insmod src/tirq.ko irq=10 burla% grep 10: /proc/interrupts 10: 121471 XT-PIC eth0, tirq burla% sudo tail -d /var/log/kern.log Oct 15 07:02:46 burla kernel: tirq: irq 10: got 4 events Oct 15 07:02:47 burla kernel: tirq: irq 10: got 5490 events Oct 15 07:02:48 burla kernel: tirq: irq 10: got 10990 events
IRQ_NONE
I valori di ritorno dei gestori di interruzione sono stati introdotti
per facilitare la diagnosi di potenziali errori dei programmatori o
dell'hardware: se viene riportata un'interruzione e nessuno dei driver
registrati dichiara di averla gestita ci troviamo potenzialmente in
situazione di errore; se tale evento avviene frequentemente il sistema
disabilita l'interruzione impazzita. Il codice deputato a questi
controlli è, ancora una volta, in arch/i386/kernel/irq.c
o nel
file equivalente per la vostra piattaforma preferita.
In Linux-2.4 e precedenti i gestori di interruzione ritornavano
void
e non era possibile una diagnosi accurata dei problemi; dove
tale diagnosi avviene riesce solo ad impedire le situazioni di blocco
completo della macchina, come nel caso della piattaforma ARM già
accennato. Per chi debba scrivere codice che funzioni sia con
Linux-2.4 sia con Linux-2.6, l'header <linux/interrupt.h>
suggerisce quattro semplici macro che nascondano la nuova API
quando si compila per un kernel precedente.
Riquadro 4 - tirq.c
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/moduleparam.h>
#include <linux/interrupt.h>
#include <linux/jiffies.h>
#include <linux/init.h>
MODULE_LICENSE("GPL");
int irq;
module_param(irq, int, 0);
/* this is used as devid in the handler */
struct tirq_data {
unsigned long seconds;
unsigned long count;
} data;
/* the handler */
static int tirq_handler(int irq, void *devid, struct pt_regs *regs)
{
struct tirq_data *d = (struct tirq_data *)devid;
unsigned long seconds = jiffies/HZ;
d->count++;
if (seconds != d->seconds) {
/* next second: print stats */
printk(KERN_INFO "tirq: irq %i: got %li events\n",
irq, d->count);
d->count = 0;
d->seconds = seconds;
}
return IRQ_NONE;
}
/* load time */
static int tirq_init(void)
{
int err;
err = request_irq(irq, tirq_handler, SA_SHIRQ, "tirq", &data);
if (err) return err;
return 0;
}
/* unload time */
static void tirq_exit(void)
{
free_irq(irq, &data);
}
module_init(tirq_init);
module_exit(tirq_exit);