di  -  venerdì 2 settembre 2011

Nel precedente articolo abbiamo parlato della separazione tra User e System Mode e abbiamo accennato al meccanismo delle syscalls, neessario per richiedere un servizio al kernel da parte di un task in User Mode.

Vediamo adesso come ciò avvenga concretamente nella piattaforma ARMv5 (tutti gli ARM della famiglia ARM9). Premetto che la piattaforma di riferimento è stata scelta appositamente per mostrare una tecnologia semplice da gestire, ma al tempo stesso molto diffusa sia in ambito industriale che in quello consumer. Architetture altrettanto semplici, come MIPS o AVR32, sono infatti meno note e diffuse, mentre gli ubiqui x86 sono troppo complessi per i nostri scopi didattici.

Attenzione: per comprendere fino in fondo questo articolo è necessaria un’infarinatura di programmazione, comprendere il significato dei puntatori e l’architettura basilare di un calcolatore (registri, memoria, ALU, Program Counter, ecc…). Gli esempi sono in codice Assembly ARM e in C.

Ecco come un task in User Mode effettua una syscall in modalità ARM (32 bit)

LDR r0, =parametro_1 ; carico il parametro1 sul registro r0
LDR r1, =parametro_2 ; ...
LDR r2, =parametro_3 ; ...
LDR r3, =parametro_4 ; ...
SWI 0xNN0000         ; chiamo la syscall numero NN (in esadecimale)

Analogamente in modalità Thumb (16 bit)

LDR r0, =parametro_1
LDR r1, =parametro_2
LDR r2, =parametro_3
LDR r3, =parametro_4
SWI 0xNN

Le prime quattro istruzioni LDR (LoaD Register), opzionali, servono a caricare sui primi 4 registri i parametri da passare alla syscall. Chiaramente il numero effettivo ed il significato di tali parametri dipendo dalla particolare syscall.
La notazione LDR rN, =indirizzo equivale a “carica nel registro N i 4 byte presenti a partire dall’indirizzo specificato”.
L’istruzione SWI (SoftWare Interrupt) presenta un parametro da 24 bit in modalità ARM e da 8 bit in modalità Thumb.
In codice macchina l’istruzione si presenta così suddivisa:

CCCC1111 - NNNNNNNN - NNNNNNNN -NNNNNNNN

Il primo byte contiene un campo di 4 bit, CCCC, che servono a stabilire la “condizione” di esecuzione. Quasi tutte le istruzioni ARM sono infatti condizionali. Ciò significa che impostando opportunamente questi bit posso risparmiare un’istruzione di salto. Ad esempio:

if (a == b)
    swi(0x420000);
else
    swi(0x320000);

diventerebbe

CMP a, b       
; confronta i due registri

SWIE 0x420000
; esegui SWI se l’ultimo confronto ha avuto esito Equal

SWINE 0x320000
; esegui SWI se l’ultimo confronto ha avuto esito Not Equal

Senza i codici condizionali, avrei dovuto effettuare un Jump (Branch in gergo ARM), penalizzando in questo modo le prestazioni a causa di un flush forzato della pipeline che così perderebbe almeno 5 cicli di clock, uno per ciascuno stadio della pipeline (ARM9 ha 5 stadi), invece di 1 ciclo soltanto.

Dopo i 4 bit del codice condizionale, ci sono 4 bit tutti pari ad 1. Questo è l’opcode vero e proprio, e serve a codificare l’istruzione SWI.

Seguono 3 byte il cui contenuto è ignorato dalla CPU (1 solo byte se in modalità Thumb). Questi byte supplementari possono essere usati per contenere delle informazioni. Nel nostro caso li utilizziamo per contenere il numero identificativo della syscall che ci interessa.

Dal momento che i sistemi ARM usano molto spesso la modalità Thumb a 16 bit, principalmente per risparmiare memoria, ci limiteremo a sfruttare solo il primo byte, lasciando a zero gli altri due. Questo ci consente comunque di specificare fino a 256 diversi numeri di syscall.

In molte situazioni, sopratutto nei sistemi a microkernel, questo numero è sufficientemente grande per contenere tutte le syscall del kernel. I kernel monolitici invece tendono ad avere alcune centinaia di syscalls, quindi sarebbe necessario usare anche gli altri 2 byte, arrivando ad un totale di 16 milioni di possibili syscalls (in pratica ne bastano meno di mille per accontentare anche i kernel più “estroversi”). Per fare ciò, si può obbligare il programmatore a chiamare l’istruzione SWI soltanto dalla modalità ARM 32 bit, oppure si possono posizionare le syscalls più usate nelle prime 256 posizioni, consentendo quindi di rimanere in modalità Thumb nella maggior parte dei casi.
Un ulteriore soluzione potrebbe essere quella di posizionare il numero della syscall in un registro, quindi sfruttando ben 32 bit, o 4 miliardi di possibili syscalls. Tuttavia questo diminuisce il numero di parametri passabili, senza portare nessun vantaggio, rendendo tale soluzione poco interessante.

Una volta eseguita l’istruzione SWI, il processore salva il contenuto del registro r15 (il Program Counter) dentro il registro r14 (Link Register) e vi somma 4 se in modalità ARM, 2 se in modalità Thumb, puntando quindi all’istruzione immediatamente successiva (NB: 4 byte sono 32 bit, 2 byte sono 16 bit). In questo modo, quando il processore dovrà riprendere l’esecuzione del task, sarà sufficiente rimettere il contenuto di r14 dentro r15 per continuare il programma esattamente da dove si era interrotto. A questo punto, la CPU passa alla modalità ARM a 32 bit, lo stato della CPU passa da User Mode a Supervisor Mode (SYS), e vengono disabilitate le interruzioni, il tutto impostando i relativi bit nel registro di stato CPSR (Current Program Status Register). Il CPSR viene salvato in SPSR (Saved Program Status Register).

Dopo aver salvato il Program Counter all’interno del Link Register, e dopo essere entrati in Kernel Mode (in questo caso Supervisor Mode), il processore effettua un Branch al terzo elemento del cosiddetto Exception Vector. Si tratta di una tabella di puntatori a funzione. Ciascun elemento della tabella contiene un Branch ad una funzione che gestirà un particolare tipo di eccezione, nello specifico abbiamo:

exception_vector: 0x00000000 B reset_function
                  0x00000004 B undefined_opcode_function
                  0x00000008 B swi_handler
                  0x0000000C B prefetch_abort_handler
                  0x00000010 B data_abort_handler
                  0x00000014 B irq_entrypoint
                  0x00000018 B fastirq_entrypoint

A questo punto, verrà invocata la funzione che abbiamo definito come swi_handler, che sarà l’entry point generale per tutte le syscalls del kernel.
Compito della funzione swi_handler è di individuare la syscall desiderata e di invocarla passandole i relativi parametri.
Una versione molto elementare di swi_handler potrebbe essere la seguente:

swi_handler:
STMFD r13, {r0-r3 , r14}
; push dei registri r0-r3 ed r14 (link register) nello stack (puntato da r13, stackpointer)

LDRB r4, [r14, #-2]
; carico il byte più basso della locazione r14 - 2 (*)

ADR r4, syscallTable, r4 LSL#2
; r4 = syscallTable + r4 * 4 ; prendo l’indirizzo della syscall cercata

BL r4
; salto all’indirizzo della syscall, i parametri sono ancora nei primi 4 registri r0-r3

LDMFD r13, {r0-r3, r15}^
; pop dei registri r0-r3 ed r15 con ripristino del CPSR (**)

(*) Questo codice presuppone che la CPU stia lavorando in modalità Little Endian. tale codice funziona sia per SWI eseguite in modalità Thumb 16 bit sia in modalità ARM 32 bit.
(**) I registri salvati sullo stack possono avere destinazioni diverse da quelle originali. In questo caso, il contenuto del registro r14 (Link Register) viene caricato sul registro r15 (Program Counter). Tale caricamento equivale in tutto e per tutto ad un Branch. Il simbolo ^ in questo opcode indica che i registri da salvare (in particolare r13, r14 ed r15) sono quelli utente e non quelli del Supervisor Mode, inoltre ripristina il contenuto di CPSR partendo dalla sua copia salvata in SPSR. Infatti, quando si passa in Supervisor Mode, è entrato in azione un set di registri parzialmente diverso, per consentire di risparmiare tempo nel salvataggio dei registri.

A seguito dell’ultima istruzione, che combina un pop con un branch ed un ripristino del registro di stato CPSR, la CPU si ritrova ad eseguire il programma da dove l’aveva lasciato, cioè esattamente all’istruzione dopo la SWI.

Questo frammento di codice, in parole povere, legge il numero della syscall desiderata, carica da una tabella delle syscalls un puntatore a funzione verso la syscall giusta, lancia la syscall (che eseguirà il servizio richiesto tramite i parametri passati in r0-r3), e poi ritorna al programma in User Mode.

Tipicamente la tabella delle syscall si implementa come un banale array di puntatori a funzione, che in C sarebbe più o meno così:

enum
{
    SYSCALL_READ = 0,
    SYSCALL_WRITE,
    SYSCALL_OPENFILE,
    SYSCALL_CLOSEFILE,
    // ecc...
    NUM_OF_SYSCALLS
} SyscallNumbers;

typedef void * (SyscallFunction *)(void *r0, void *r1, void *r2, void *r3);

SyscallFunction syscallTable[NUM_OF_SYSCALLS] =
{
    sys_fread,
    sys_fwrite,
    sys_fopen,
    sys_fclose
    // ecc...
};

void * sys_fopen(void * fileName, void * mode, void * ignored1, void *ignored2)
{
    FILE * fileHandler;
    const char * pFileName = (const char *) fileName;
    const char * pMode = (const char *) mode;
    //...
    // esegue il codice necessario per aprire un file,
    // magari inviando un messaggio ad un processo esterno
    // (tipico comportamento da microkernel), oppure
    // chiamando una funzione fopen dalla libreria interna del
    // filesystem (tipico comportamento da kernel monolitico).
    //...

    return fileHandler;
}

Abbiamo visto come si fa ad interfacciare il codice utente, che gira in User Mode, con il codice di sistema, che gira in Kernel Mode (Supervisor in ARM). Usando del codice analogo, si possono implementare altri handler per le varie eccezioni (Data Abort, Prefetch Abort, Undefined Opcode, Interrupt, Fast Interrupt, Reset), e che costituiscono il fondamento di un qualsiasi kernel.

Vediamo una carrellata veloce del significato di queste eccezioni usate nei processori ARM, e a cosa servono concretamente.

Reset
Viene eseguita in seguito ad un reset hardware della CPU. Si usa per lanciare il sistema operativo, o per uscire dallo standby (il reset non cancella necessariamente la memoria), o per altre funzioni legate alla reinizializzazione della CPU.

Interrupt
Eccezione lanciata in seguito alla selezione di una linea di interruzione (un particolare pin della CPU). Si usa per gestire le periferiche, che usano segnalare l’interruzione quando, ad esempio, hanno dei dati in ingresso da smistare, oppure quando scade un timer, oppure ancora quando un’operazione precedentemente richiesta si è conclusa.

Fast Interrupt
Come l’Interrupt. La differenza sta nel fatto che il FIQ (Fast Interrupt Request) non necessita di pushare nello stack i registri, perchè ha un banco di registri temporanei tutto per se (da r8 a r14). Viene usato in quei casi in cui è necessario un intervento estremamente rapido della CPU, ad esempio per gestire un chip di comunicazione radio, che richiede tempistiche molto strette di sincronizzazione.

Undefined Opcode
Questa eccezione viene lanciata quando la CPU si trova davanti ad una istruzione sconosciuta. Solitamente si tratta di un bug nel programma, perchè magari ha eseguito un jump su una locazione di memoria casuale o non inizializzata. Si può anche usare per emulare delle istruzioni supplementari. Ad esempio, l’handler relativo a questa eccezione, se rileva la presenza di una istruzione di calcolo in Floating Point, su una CPU sprovvista di unità FPU, potrebbe lanciare una routine software per emulare i calcoli in virgola mobile in modo trasparente per il software utente.

Data Abort (dati) e Prefetch Abort (istruzioni)
Queste eccezioni vengono lanciate quando si verifica un errore durante il caricamento di un operando o di una istruzione, oppure la scrittura di un registro in memoria. L’eccezione viene generata dal Memory Controller. In base alla configurazione del sistema, queste eccezioni possono servire per rilevare un accesso illegale ad un’area di memoria protetta dalla MPU (Memory Protection Unit). Processori dotati di MMU usano un meccanismo molto simile, ma più sofisticato perchè comprende anche l’eventuale traduzione degli indirizzi (la CPU vede sempre e solo gli indirizzi virtuali, mentre la MMU li traduce in indirizzi fisici).

Nel prossimo articolo vedremo come funziona il Multitasking, e come fa un Sistema Operativo a gestire più task contemporaneamente. Vedremo quindi la differenza tra Cooperative Multitasking e Preemptive Multitasking, vedremo cos’è il Context Switch e come tutto ciò viene implementato concretamente in un kernel.

riferimenti

10 Commenti »

I commenti inseriti dai lettori di AppuntiDigitali non sono oggetto di moderazione preventiva, ma solo di eventuale filtro antispam. Qualora si ravvisi un contenuto non consono (offensivo o diffamatorio) si prega di contattare l'amministrazione di Appunti Digitali all'indirizzo info@appuntidigitali.it, specificando quale sia il commento in oggetto.

  • # 1
    Cesare Di Mauro
     scrive: 

    In questo modo il kernel è vincolato ad avere API con un’interfaccia sempre uguale (4 parametri).

    Potrebbe essere uno spreco, anche perché vengono comunque salvati e ripristinati R0-R3, a prescindere che la specifica API le usi o meno.

    P.S. Se non ricordo male, con Thumb-2 è possibile eseguire le SWI con valore immediato di 24. Inoltre è possibile sicuramente gestire qualunque cosa, anche interrupt e syscall, anziché passare necessariamente alla modalità ARM.

  • # 2
    Antonio Barba (Autore del post)
     scrive: 

    Ho volutamente introdotto un’interfaccia con 4 parametri fissi per scopi “educhescional” :-D

    Già così l’articolo è un po’ ostico ai più, non volevo esagerare :-D

    Ovviamente ci sono altre cose che non ho considerato, e che sono normalmente gestite nei kernel moderni.
    Ad esempio ho accennato al gestore delle interruzioni, che di base presenta un entry point molto simile a quello delle SWI. In realtà le interruzioni possono accavallarsi e quindi bisogna gestire uno stack di chiamate ad interrupt. Nei sistemi Real Time tale situazione è complicata ulteriormente dal fatto che le stesse interruzioni possono essere interrotte da altre a priorità maggiore, quindi in quel caso non è nemmeno valido il semplice stack, ma è necessaria una priority queue.

    Insomma, non c’è la pretesa di efficienza o flessibilità in questo codice, solo un proof of concept :-)

  • # 3
    Cesare Di Mauro
     scrive: 

    Se vuoi approfondire per me non c’è problema, eh! :P

  • # 4
    Antonio Barba (Autore del post)
     scrive: 

    Come ti dicevo tempo fa, l’articolo originale stava diventando una sorta di tesi di laurea, così ho ritenuto opportuno sfoltire, spezzare e semplificare!

  • # 5
    Z80Fan
     scrive: 

    @Antonio:

    Mi pare un po’ strano il tuo codice…
    – Usi per i parametri R0, R1, R2 e *R4*
    – Nella gestione della syscall salvi solo i registri da 0 a 3 ma non il 4
    A meno che non sia voluto, questo mi puzza da… BUG! *gasp*

    Cmq un bell’articolo, non c’è che dire…

    Circa, quanto ci mette quell’ARM che hai scelto a gestire un context-switch di questo tipo? In cicli di clock ad esempio…
    È per fare un paragone con l’x86 che impiega centinaia se non migliaia di cicli.

  • # 6
    Antonio Barba (Autore del post)
     scrive: 

    @Z80Fan: Si era una svista :-D

    Grazie per la segnalazione!

    Ho preso del codice da un mio vecchio progetto (funzionante) ma l’ho rimaneggiato per la stesura dell’articolo, quindi ci potrebbero essere degli errori.

  • # 7
    Fog76
     scrive: 

    Bello!! Bello!! Ancoraaaa!!! :-)

    Non avevo mai visto assembler ARM, mi sembra molto potente ed elegante.

    Ciao e grazie.

  • # 8
    Antonio Barba (TheKaneB) (Autore del post)
     scrive: 

    @Z80Fan: per quanto riguarda il context switch, l’overhead è abbastanza ridotto, in un centinaio di cicli di clock si dovrebbe completare il tutto.

    @Fog76: Grazie :-)
    L’assembly ARM ha poche istruzioni (come ogni ISA di tipo RISC), ma una nutrita serie di optional molto utili. Ad esempio l’esecuzione condizionale e il barrel shifter che consentono di manipolare in modo efficiente le tabelle di puntatori, tipica caratteristica dei linguaggi ad alto livello. Inoltre ha una ISA ortogonale, nel senso che quasi tutte le istruzioni presentano le stesse “opzioni” (quindi operandi con barrel shifter, codice condizionale e modalità di indirizzamento), rendendo più semplice il lavoro di ottimizzazione da parte del compilatore.
    Di contro, trattandosi di un’ISA di tipo Load-Process-Store, necessita mediamente di un numero maggiore di istruzioni rispetto ad un’ISA di tipo CISC per compiere le stesse operazioni, e la lunghezza fissa a 32 bit delle istruzioni contribuisce ulteriormente ad aumentare l’overhead di memoria, quindi anche di cache e (corollario) nell’esecuzione del codice.

    Il set di istruzioni Thumb-2 migliora notevolmente la situazione, perchè mantiene un’efficienza di esecuzioni molto vicina alla modalità ARM a 32 bit ed allo stesso tempo comprime il codice quasi della metà.

  • # 9
    Cesare Di Mauro
     scrive: 

    Secondo ARM, le prestazioni con codice Thumb-2 sono circa il 98% dell’equivalente ISA ARM, mentre lo spazio è ridotto del 30%. Comunque penso si tratti di simulazioni fatte con campioni di codice (algoritmi) più o meno variegati, per cui dipende poi tutto dal codice della particolare applicazione.

    Riguardo alle istruzioni, per essere un RISC ne ha parecchie, e continuamente ne vengono aggiunte. :P

  • # 10
    Antonio Barba (Autore del post)
     scrive: 

    @Cesare: si, tra i RISC è quello forse più “pompato”. PowerPC e, soprattutto, MIPS sono più minimali e aderenti all’originale paradigma RISC.

    Anche per questa sua semplicità nei corsi universitari si studia spesso il MIPS e quasi mai ARM, sebbene abbia un’utilità sicuramente maggiore, vista l’ampia diffusione. :-)

Scrivi un commento!

Aggiungi il commento, oppure trackback dal tuo sito.

I commenti inseriti dai lettori di AppuntiDigitali non sono oggetto di moderazione preventiva, ma solo di eventuale filtro antispam. Qualora si ravvisi un contenuto non consono (offensivo o diffamatorio) si prega di contattare l'amministrazione di Appunti Digitali all'indirizzo info@appuntidigitali.it, specificando quale sia il commento in oggetto.