La gestione delle interruzioni

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.

Le interruzioni

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.

Interruzioni sul fronte e sul livello

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.


Condivisione di interruzioni

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.



Figura 1
Condivisione di interruzioni sul fronte

La figura è anche disponibile in PostScript



Figura 2
Condivisione di interruzioni sul livello

La figura è anche disponibile in PostScript

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.

Registrazione di un gestore

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

Uso di 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);