Kernel: come e perchè?

Con questo articolo, vorrei condividere ed analizzare il mio punto di vista sui kernel dei sistemi operativi real-time e fare una panoramica su quali e quante funzionalità di multitasking BeRTOS riesce a implementare.

Devo dire con un po' di orgoglio che BeRTOS è in grado di adattarsi con una flessibilità mai vista finora, alle esigenze di programmazione embedded. Può sembrare un'affermazione un po' presuntuosa, ma qui di seguito indicherò i motivi che mi portano a dire questo.

Prima di tutto però, analizziamo quali sono le richieste e i pattern tipici a cui va incontro un programmatore embedded.

Perché?

Questa è la domanda principale che viene posta di sovente: perché dovrei utilizzare un kernel nella mia applicazione, mi trovo tanto bene senza, che vantaggi avrei? La risposta è semplice: non sempre serve un kernel.

A dire la verità, noi usiamo il modulo kernel di BeRTOS in solo una metà dei progetti che sviluppiamo. In genere i nostri progetti sono medio/grandi e devono essere mantenuti per molto tempo. Nonostante questo riusciamo a farcela egregiamente anche senza kernel. In alcune applicazioni semplicemente il kernel non serve. Proprio per questo BeRTOS è utilizzabile anche con il modulo kernel completamente disattivato.

A differenza di altri RTOS paragonabili, BeRTOS inoltre fornisce anche una vasta libreria di driver, algoritmi e protocolli generici. La maggior parte delle volte è sufficiente mettere insieme qualche modulo già pronto per arrivare molto vicini al risultato.

Il caso tipico di applicazione embedded, infatti, prevede di dover eseguire diversi compiti, possibilmente in parallelo, magari ad intervalli di tempo prefissati, e niente altro. Se i compiti sono pochi (diciamo massimo una decina), tutto può essere risolto con un caro vecchio ciclo infinito e qualche if, senza bisogno di kernel.

Ma allora, vi chiederete, perché avete sviluppato il kernel? Perché in molti altri casi invece serve e semplifica il lavoro.

Un applicativo che usa i processi scala meglio quando la complessità aumenta. L'applicativo è molto più reattivo e veloce, più facile da modificare, più facile da debuggare. Tutta l'infrastruttura di gestione dei processi è infatti demandata al sistema operativo e a noi non resta altro che scrivere il codice applicativo. Non è il numero di compiti da svolgere che secondo me fa da spartiacque fra usare un kernel e non usarlo, quanto piuttosto la latenza richiesta, la complessità delle relazioni e l'interdipendenze tra di esse.

Kernel, soluzione 1: niente kernel

In questa sezione presenterò un applicativo tipico e mostrerò come è possibile implementarlo utilizzando le infrastrutture e il kernel che BeRTOS mette a disposizione. Per ogni implementazione analizzeremo pregi e difetti.

Per prima cosa descrivo il problema: dobbiamo sviluppare un firmware per una scheda elettronica a microcontrollore che deve gestire un macchinario.

I suoi compiti sono essenzialmente di controllo e supervisione.

Per semplicità supponiamo che, tra le altre cose, la scheda debba:

  1. Acquisire una temperatura e attivare delle protezioni a certi livelli.
  2. Acquisire dei livelli di tensione che rappresentano grandezze fisiche e farci dei calcoli per poi comandare un'uscita.
  3. Carica/Salvare? delle impostazioni da una memoria, alla pressione di un tasto.
  4. Effettuare un controllo di sicurezza: se un pin di allarme va alto, bisogna leggere un valore dall'ADC, effettuare dei calcoli e regolare la velocità di un motore di potenza nel più breve tempo possibile.

Le temperature e le tensioni non devono essere acquisite di continuo, ma ad intervalli di tempo regolari.

Implementazione

Ecco uno pseudo codice di come potrebbe essere impostata la parte principale dell'applicazione, utilizzando le API di sistema di BeRTOS ma senza usare nessun kernel o scheduler.

/* Global system status */
Parameter prm;

ticks_t temp_time, voltage_time, now;

init();

for (;;)
{
    now = timer_clock();

    /* Temperature */
    if (now - temp_time > TEMP_PERIOD)
    {
        temp_time = now;
        /* Check current temperature state. */
        temp = adc_read(TEMP_CH);
        temp *= T_SCALE + T_OFFSET;
        temp_check(temp);
        prm.temp = temp;
    } 

    /* Voltage levels  */
    if (now - voltage_time > VOLTAGE_PERIOD)
    {
        voltage_time = now;
        /* Acquire and check voltages */
        v = adc_read(VOLT_CH);
        v = v * V_SCALE + V_OFFSET;
        valve = voltage_check(&prm, v);
        /* Update output */
        prm.valve = vale;
        setvalve(valve)
    }

    /* Handle parameter save/load */
    if (load_pressed())
    {
        /* Load */
        load_preset(&prm);
    }

    if (save_pressed())
    {
        /* Save */
        save_preset(&prm);
    }

    /* Check motor alarm */
    if (motor_alarmOn())
    {
        a = adc_read(SPEED_CH);
        a *= SPEED_SCALE;
        motor_setSpeed(a);
    }

    ...
}

Kernel, soluzione 2: BeRTOS synctimer scheduler

Per cercare di ovviare ad alcuni dei problemi sopra esposti, BeRTOS mette a disposizione uno strumento potente e allo stesso tempo semplice: i synctimers.

Con i synctimer di BeRTOS è possibile creare delle callback (semplici funzioni) e far sì che vengano eseguite in modo completamente sincrono ad intervalli regolari. Questo è molto utile per semplificare la gestione di tutti i compiti temporizzati.

Per utilizzarli, prima di tutto è bene incapsulare in funzioni indipendenti i compiti temporizzati, che nel nostro caso sono la gestione della temperatura e del controllo tensioni:

List timer_list;
Timer temp_timer, voltage_timer;

void temp_handler(void)
{
    /* Check current temperature state. */
    temp = adc_read(TEMP_CH);
    temp *= T_SCALE + T_OFFSET;
    temp_check(temp);
    prm.temp = temp;
    synctimer_add(&temp_timer, &timer_list);
}

void voltage_handler(void)
{
    /* Acquire and check voltages */
    v = adc_read(VOLT_CH);
    v = v * V_SCALE + V_OFFSET;
    valve = voltage_check(v);
    /* Update output */
    setvalve(valve)
    prm.valve = valve;
    synctimer_add(&voltage_timer, &timer_list);
}

Nelle ultime righe, viene chiamata la funzione synctimer_add che ha il compito di reinserire l'evento nella lista dei task da svolgere. Senza di essa la callback verrebbe chiamata una sola volta.

Fatto questo, è necessario inizializzare i synctimer:

void init(void)
{
    timer_init();
    
    LIST_INIT(&timer_list);

    /* Set period for temperature control */
    timer_setDelay(&temp_timer, TEMP_PERIOD);
    /* Set callback for temperature control */
    timer_setSoftint(&temp_timer, temp_handler, NULL);
    /* Add temperature control to event list */   
    synctimer_add(&temp_timer, &timer_list);


    /* Same here for voltage check */
    ....
}

E' da notare che questa sequenza di operazioni va fatta solo una volta, all'inizio dell'applicazione.

Se le cose da fare sono molte, è possibile, per una maggiore chiarezza, separare l'applicativo in vari file, in ognuno dei quali può risiedere tutta la logica legata a quel particolare modulo.

Così, per esempio, le operazioni legate al controllo di temperatura, possono stare in un modulo ben separato. In questo modo le cose si gestiscono meglio se diventano numerose. Anche l'inizializzazione indicata sopra può essere inserita nel modulo specifico, è sufficiente passare la lista dei timer (timer_list) alla funzione di init del modulo, che per esempio chiamiamo temp.c:

void temp_init(List *list)
{
    ...module init...

    /* Set period for temperature control */
    timer_setDelay(&temp_timer, TEMP_PERIOD);
    /* Set callback for temperature control */
    timer_setSoftint(&temp_timer, temp_handler, NULL);
    /* Add temperature control to event list */   
    synctimer_add(&temp_timer, list);
}

void temp_handler(void)
{
   ...
}

...

Mentre il file originale rimane in questo modo estremamente pulito:

void init(void)
{
    timer_init();
    
    LIST_INIT(&timer_list);

    /* Init temp check driver */
    temp_init(&timer_list);


    /* Init voltage check driver */
    voltage_init(&timer_list);

   ...
}

Fatto questo, nel ciclo principale dell'applicazione, è sufficiente chiamare la synctimer_poll() per eseguire automaticamente tutte le callback con gli intervalli impostati:

/* Global system status */
Parameter prm;
List timer_list;

init();

for (;;)
{
    /* Handle events. */
    synctimer_poll(&timer_list);

    /* Handle parameter save/load */
    if (load_pressed())
    {
        /* Load */
        load_preset(&prm);
    }

    if (save_pressed())
    {
        /* Save */
        save_preset(&prm);
    }

    /* Check motor alarm */
    if (motor_alarmOn())
    {
        a = adc_read(SPEED_CH);
        a *= SPEED_SCALE;
        motor_setSpeed(a);
    }

}

Kernel, soluzione 3: BeRTOS cooperative kernel

Per cercare di migliorare ulteriormente le prestazioni, è giunta l'ora di tirare fuori il kernel.

BeRTOS fornisce un potente modulo kernel, che di default è configurato per funzionare in multitasking cooperativo. L'approccio usato per lo sviluppo è sempre stato quello di prediligere il minimalismo e la compattezza. Utilizzando il kernel di BeRTOS e attivando tutti i moduli opzionali (semafori, segnali, messaggi, etc...) esso occupa solo pochi kilobyte di memoria flash.

Ma veniamo alla prestazioni: contrariamente a quanto si crede, anche un kernel cooperativo fornisce ottime prestazioni di real-time, se usato nel modo giusto. Un altro mito da sfatare è che, con un kernel cooperativo, bisogna riempire il codice di sleep per far sì che gli altri processi abbiano modo di girare.

Questo sarebbe vero nel caso di processi che usano intensamente la CPU, ma nella programmazione embedded, la stragrande maggioranza dei processi sono I/O bound, cioè in attesa che succeda qualcosa dalle periferiche.

Anche nel nostro esempio accade questo: la temperatura e la tensione devono essere lette una volta ogni X secondi, quando si salvano/caricano i parametri dobbiamo attendere la pressione di un tasto e dialogare con una memoria, e nel caso della protezione del motore, siamo perennemente in attesa che essa scatti. Quindi, è sufficiente fare in modo che i driver che dialogano con l'hardware ritornino il controllo della CPU quando sono in attesa ed il gioco è fatto. Naturalmente tutti i driver di BeRTOS sono fatti in questo modo, quindi non ci sono problemi.

Implementazione

Ma iniziamo a vedere come si può fare ad usare il kernel. Per prima cosa è necessario spezzare l'applicativo in flussi di esecuzione separati e paralleli, che in BeRTOS chiamiamo processi. Viene naturale pensare che nel nostro esempio avremo 4 processi: uno che gestisce il controllo temperatura, uno il controllo tensione, uno il caricamento/salvataggio e uno per la protezione motore.

Ogni processo va pensato come se fosse il solo a girare sulla CPU, quindi se vogliamo che la sua esecuzione duri per sempre dovremo utilizzare un ciclo infinito.

Ecco come si presentano i processi della temperatura e del controllo tensione:

void temp_handler(void)
{
    while (1)
    {
        /* Check current temperature state. */
        temp = adc_read(TEMP_CH);
        temp *= T_SCALE + T_OFFSET;
        temp_check(temp);
        prm.temp = temp;
        timer_delay(TEMP_PERIOD);
    }
}

void voltage_handler(void)
{
    while (1)
    {
        /* Acquire and check voltages */
        v = adc_read(VOLT_CH);
        v = v * V_SCALE + V_OFFSET;
        valve = voltage_check(v);
        /* Update output */
        setvalve(valve)
        prm.valve = valve;
        timer_delay(VOLTAGE_PERIOD);
    }
}

Come avrete notato, alla fine di entrambi, è presente una timer_delay() che fa sì che i processi facciano un giro solo una volta ogni TEMP_PERIOD o VOLTAGE_PERIOD millisecondi. In questo modo otteniamo lo stesso effetto che avevamo con i synctimer o con il loop infinito.

Naturalmente sia la funzione timer_delay() che la funzione adc_read() passano il controllo ad altri processi quando sono in attesa, quindi come anticipato, anche se il kernel è cooperativo, non è necessario rilasciare esplicitamente il controllo della CPU.

Per i processi dediti al salvataggio/caricamento parametri e alla protezione motore dobbiamo fare una premessa. Per ottenere il massimo delle prestazioni e sprecare il meno possibile il tempo di CPU è necessario far sì che gli ingressi che svegliano entrambi i processi siano forniti da interrupt. Quindi sia i bottoni di save/load che il pin di allarme motore devono triggerare degli interrupt. E' possibile fare anche un controllo in polling, ma non si ottengono le stesse prestazioni. Abbiamo quindi bisogno di 2 interrupt: uno generato quando si preme un bottone della tastiera (per i pulsanti save/load) e uno generato quando scatta la protezione motore. Poi è sufficiente utilizzare i segnali del kernel di BeRTOS per collegare in modo efficiente questi eventi.

Anzitutto vediamo come sono le ISR che gestiscono questi interrupt:

void keyboard_isr(void)
{
    /*Read key*/
    .....


    /* Signal load/save process */
    sig_signal(loadsave_proc, SIG_KEYBOARD);
}



void motor_isr(void)
{
    /* Signal motor process */
    sig_signal(motor_proc, SIG_MOTORALARM);
}

A questo punto è possibile scrivere i processi che gestiscono il save/load parametri e la protezione motore in modo molto semplice:

void loadsave_handler(void)
{
    while(1)
    {
        /* Wait for a keypress */
        sig_wait(SIG_KEYBOARD);

        Parameter prm_cpy;

        /* Handle parameter save/load */
        if (load_pressed())
        {
            /* Load */
            load_preset(&prm_cpy);
            memcpy(&prm, &prm_cpy, sizeof(prm));
        }

        if (save_pressed())
        {
            memcpy(&prm_cpy, &prm, sizeof(prm));
            /* Save */
            save_preset(&prm_cpy);
        }
    }
}


void motor_handler(void)
{
    while(1)
    {
        /* Wait for an alarm signal */
        sig_wait(SIG_MOTORALARM);

        /* Check motor alarm */
        a = adc_read(SPEED_CH);
        a *= SPEED_SCALE;
        motor_setSpeed(a);
    }
}

La funzione sig_wait() è il cuore di questi due processi. Essa fa sì che i processi siano in attesa fino all'arrivo del segnale indicato. Quando arriva il segnale, il processo fa un giro e si rimette in attesa. Questo meccanismo è molto efficiente, perché quando il processo è in attesa del segnale, la CPU viene automaticamente rilasciata ad altri. Anche qui, non è necessario rilasciarla esplicitamente.

Altra cosa che mi preme far notare è che nonostante i vari processi utilizzino strutture dati condivise (la struttura prm) non c'è bisogno di meccanismi complicati per regolarne l'accesso.

Questo perché essendo il kernel cooperativo, è facilmente prevedibile dove avverrà il cambio di contesto, e quindi molto spesso non è necessario proteggere le strutture dati dall'accesso concorrente tramite semafori o lock. In temp_handler() e voltage_handler() si accede alla struttura prm senza problemi, dato che siamo sicuri che tutte le operazioni su di essa non verranno interrotte da altri processi.

Nel processo loadsave_handler(), invece, noterete che il load e il save vengono fatti su una copia della struttura. Questo perché le funzioni load_preset() e save_preset(), dialogando con una memoria, potrebbero potenzialmente rilasciare la CPU proprio nel mezzo di un operazione, ottenendo un salvataggio/caricamento non coerente. Per ovviare a questo problema, lavorare con una copia della struttura nello stack è sufficiente e non occupa troppe risorse.

Per finire, vediamo come fare ad avviare questi quattro processi creati:


/* Allocate process stacks */
PROC_DEFINESTACK(temp_stack, KERN_MINSTACKSIZE);
PROC_DEFINESTACK(voltage_stack, KERN_MINSTACKSIZE);
PROC_DEFINESTACK(loadsave_stack, KERN_MINSTACKSIZE);
PROC_DEFINESTACK(motor_stack, KERN_MINSTACKSIZE);

Process *temp_proc;
Process *voltage_proc;
Process *loadsave_proc;
Process *motor_proc;

void init(void)
{
    /* Init timer*/
    timer_init();
    /* Init kernel */
    proc_init();

    /* init other modules */
    
    ...

    /* Create processes */
    temp_proc     = proc_new(temp_handler, NULL, temp_stack, sizeof(temp_stack));
    voltage_proc  = proc_new(voltage_handler, NULL, voltage_stack, sizeof(voltage_stack));
    loadsave_proc = proc_new(loadsave_handler, NULL, loadsave_stack, sizeof(load_save_stack));
    motor_proc    = proc_new(motor_handler, NULL, motor_stack, sizeof(motor_stack));

    /* Raise priority */
    proc_setPri(motor_proc, HIGH_PRIORITY);
}

void main(void)
{
    init();
    while (1)
    {
        monitor_report();
        timer_delay(1000);
    }
}

Analizziamo un po' quello che è stato fatto: per prima cosa si dichiara un'area di memoria da usare come stack per ogni processo. Questa è la principale differenza nell'uso di un kernel: ogni processo ha bisogno di un po' di memoria da usare come stack personale.

La funzione di init() è semplice: si inizializzano i driver e vengono creati i processi. L'unica cosa degna di nota è l'innalzamento della priorità del processo di protezione del motore. Normalmente, i processi, quando vengono creati hanno tutti una stessa priorità standard. Tramite la proc_setPri() questa priorità può essere alzata o abbassata. Quando più processi potrebbero essere messi in esecuzione, il kernel sceglie prima quello a priorità più alta.

Il main() è ancora più semplice: non si fa altro che chiamare la init() e poi si rimane perennemente in loop facendo un report sullo stato di occupazione di memoria una volta al secondo. monitor_report() è una funzione fornita dal kernel di BeRTOS che permette di stampare sulla console di debug informazioni sullo stato di occupazione della memoria fornita ai processi, in modo da verificare e valutare bene se la memoria che gli abbiamo assegnato è sufficiente.

Kernel, soluzione 4: BeRTOS preemptive kernel

Ed eccoci giunti alla soluzione che garantisce le massime prestazioni in termini di latenza. Questa è la soluzione più potente disponibile, ma, come ci insegna Spiderman, "da un grande potere derivano grandi responsabilità".

Con un kernel preemptive, il cambio di contesto avviene sotto interrupt, quindi il nostro processo di protezione del motore riceverà subito il controllo della CPU non appena il pin di allarme farà scattare l'interrupt. Naturalmente esso deve essere configurato in modo da avere una priorità più elevata rispetto agli altri processi in esecuzione. In questo modo la latenza si abbassa ad alcuni microsecondi o anche sotto il microsecondo se la CPU è particolarmente veloce!

Il codice sorgente per ottenere questo rimane identico al caso del kernel cooperativo. Per attivare la modalità preemptive è sufficiente cambiare la configurazione del kernel tramite il comodo Wizard di BeRTOS o editando a mano il file cfg_proc.h di configurazione. Sì può quindi cambiare molto velocemente da un modo all'altro a seconda delle esigenze.

C'è comunque una grande differenza rispetto al caso cooperativo: adesso un processo può essere davvero interrotto in qualunque momento, e quindi ogni struttura dati condivisa deve essere protetta dall'accesso concorrente di più processi.

Nel nostro caso, ogni accesso alla struttura prm deve essere protetto tramite un semaforo o tramite lock.

Analizziamo entrambe le soluzioni:

  • Il lock consiste nel disabilitare completamente la preemption (ovvero il cambio di contesto) in particolari sezioni del codice. Va da sé che se la sezione dura troppo, si perdono i vantaggi di bassa latenza dati dal preemptive.
  • Il semaforo è un meccanismo che permette un controllo più locale dell'accesso alle risorse condivise, perché blocca l'accesso solo ai processi che tentano di accedere a quella particolare risorsa. Nel nostro particolare caso il processo di controllo motore non accede alla struttura prm, quindi è la soluzione preferibile.

Implementazione

Vediamo come diventano i processi che accedono a prm nel caso preemptive con l'accesso regolato tramite semaforo:


Semaphore prm_sem;

void temp_handler(void)
{
    while (1)
    {
        /* Check current temperature state. */
        temp = adc_read(TEMP_CH);
        temp *= T_SCALE + T_OFFSET;
        temp_check(temp);
  
        /* Lock prm access */
        sem_obtain(&prm_sem);
        prm.temp = temp;
        sem_release(&prm_sem);

        timer_delay(TEMP_PERIOD);
    }
}

void voltage_handler(void)
{
    while (1)
    {
        /* Acquire and check voltages */
        v = adc_read(VOLT_CH);
        v = v * V_SCALE + V_OFFSET;
        valve = voltage_check(v);
        /* Update output */
        setvalve(valve)

        /* Lock prm access */
        sem_obtain(&prm_sem);
        prm.valve = valve;
        sem_release(&prm_sem);

        timer_delay(VOLTAGE_PERIOD);
    }
}

void loadsave_handler(void)
{
    while(1)
    {
        /* Wait for a keypress */
        sig_wait(SIG_KEYBOARD);

        /* Lock prm access */
        sem_obtain(&prm_sem);

        /* Handle parameter save/load */
        if (load_pressed())
        {
            /* Load */
            load_preset(&prm);
        }

        if (save_pressed())
        {
            /* Save */
            save_preset(&prm);
        }
        sem_release(&prm_sem);
    }
}


Come si vede, prima di toccare la struttura prm è necessario ottenere il semaforo corrispondente. Se ci dimentichiamo di farlo per un accesso, cose casuali e terribili possono accadere. L'applicativo funzionerà la maggior parte delle volte, ma ogni tanto darà risultati strani e inattesi, dovuti al fatto che a volte la struttura si trova in uno stato inconsistente. Sono i problemi tipici dovuti alla concorrenza e sono anche i più difficili da risolvere.

Per usare i semafori inoltre, dovremo inizializzarli, e la funzione init() dovrà essere leggermente modificata per questo:

void init(void)
{
    /* Init timer*/
    timer_init();
    /* Init kernel */
    proc_init();

    /* init other modules */
    
    ...


    /* Init prm semaphore */
    sem_init(&prm_sem);

    /* Create processes */
    temp_proc     = proc_new(temp_handler, NULL, temp_stack, sizeof(temp_stack));
    voltage_proc  = proc_new(voltage_handler, NULL, voltage_stack, sizeof(voltage_stack));
    loadsave_proc = proc_new(loadsave_handler, NULL, loadsave_stack, sizeof(load_save_stack));
    motor_proc    = proc_new(motor_handler, NULL, motor_stack, sizeof(motor_stack));

    /* Raise priority */
    proc_setPri(motor_proc, HIGH_PRIORITY);
}

La funzione main() invece rimane la stessa del caso cooperativo.

Kernel: comparazione delle soluzioni

A questo punto è giunto il momento di tirare le conclusioni. Conoscendo i pro e i contro di ciascuna soluzione vi permetterà di fare la scelta ottimale nei vostri progetti.

Soluzione 1

Vi ricordo che questa soluzione usa le API di BeRTOS ma nessuna feature specifica del kernel.

Pregi

  • Se le cose da fare sono poche è abbastanza immediato da capire
  • Completamente sincrono
  • Uso della memoria estremamente ridotto

Difetti

  • Poco scalabile: se le cose da fare sono molte, il ciclo si allunga e diventa difficile da seguire
  • Alta latenza: essendo completamente sincrono, se una delle funzioni chiamate rimane in attesa, questo tempo va perduto.

Questa architettura funziona bene quando le cose da fare sono poche e veloci. Se, per esempio, durante il caricamento impostazioni si deve comunicare con una memoria seriale lenta, non si potrà eseguire il controllo di allarme. La CPU in questo caso rimane in attesa che la memoria risponda, senza possibilità di usare questo tempo per fare altro.

Soluzione 2

La synctimer_poll() esegue automaticamente il controllo di temperatura e delle tensioni senza nessun intervento da parte dell'utente. I synctimer di BeRTOS possono essere considerati uno scheduler sincrono a tutti gli effetti. Hanno un bassissimo impatto di occupazione di RAM e ROM e non richiedono il kernel. Inoltre, possono essere usati anche per eventi one-shot (ovvero non ricorrenti), basta non inserire la synctimer_add() nelle callback di gestione.

Pregi

  • Scalabilità elevata, anche con un alto numero di eventi ricorrenti
  • Completamente sincrono, niente problemi di concorrenza
  • Basso uso della memoria (solo una decina di byte per ogni timer allocato)

Difetti

  • Alta latenza: essendo completamente sincrono, se una delle funzioni chiamate rimane in attesa, questo tempo va perduto.

In definitiva, questa soluzione ha il pregio di richiedere poca manutenzione ed è capace di gestire con pochissima memoria decine o centinaia di eventi. E' una soluzione quindi adatta quando le cose da fare sono molte e veloci. L'esecuzione è sempre sincrona e rimane quindi il problema della latenza: mentre sto eseguendo una callback che magari prende tempo non posso eseguire altri computi.

Soluzione 3

La soluzione 3 è quella che utilizza il kernel cooperativo di BeRTOS. La domanda che spesso molti si fanno è: quanto è real-time questa soluzione? Possiamo rispondere: abbastanza.

Se i processi non usano troppo la CPU (e nella grande maggioranza dei progetti embedded è così) un processo ad alta priorità viene svegliato entro alcune decine/centinaia di microsecondi. Notate che in ogni caso tutte le attese dovute ad I/O sui driver sono azzerate perché essi sono fatti in modo da rilasciare la CPU quando sono in attesa.

Normalmente, questa è una soluzione ottimale che consente di ottenere buone prestazioni senza usare troppa memoria o complicarsi troppo la vita.

Nel nostro caso, la velocità con cui verrebbe attivata la protezione del motore probabilmente sarebbe più che sufficiente.

Veniamo quindi alle conclusioni:

Pregi

  • Scalabilità: si gestiscono con facilità molti processi
  • Cambio di contesto sincrono e prevedibile: problemi di sincronizzazione ridotti
  • Bassa latenza: nessun spreco di tempo durante l'I/O con i driver

Difetti

  • Media occupazione di memoria (alcune centinaia di byte per processo)
  • Non deterministico nella latenza

In definitiva, il kernel cooperativo di BeRTOS è una buona soluzione, che si adatta a molte esigenze. E' molto più veloce e reattivo delle classiche soluzioni sincrone presentate precedentemente e consente di mantenere facilmente applicativi anche molto complessi e con diverse interazioni fra i processi. Chiaramente questa reattività e flessibilità si paga con una superiore occupazione di memoria rispetto alle precedente soluzioni.

Quello che il kernel cooperativo non è in grado di fare, è servire con un tempo predeterminato un processo ad alta priorità.

In un kernel cooperativo infatti, quando un processo viene svegliato, non viene messo in esecuzione immediatamente, ma è necessario attendere che il processo corrente faccia uno switch di contesto. Questo, se i processi usano molto la CPU o se sono richieste latenze bassissime, può essere un problema.

Soluzione 4

Vi ricordo che questa soluzione prevede l'utilizzo del kernel preemptive. Vediamone i pregi e difetti:

Pregi

  • Scalabilità: si gestiscono con facilità molti processi
  • Latenza bassissima e deterministica

Difetti

  • Media occupazione di memoria (alcune centinaia di byte per processo)
  • Problemi di sincronizzazione importanti

Il kernel preemptive ha tutti i vantaggi del cooperative, con in più una latenza di risposta molto più bassa. Di contro, l'occupazione di memoria RAM è sempre superiore alla soluzioni semplici e soprattutto bisogna stare molto attenti a proteggere l'accesso ai dati condivisi. Questo può sembrare una cosa da poco, ma non è così. Se per esempio, la nostra funzione di protezione motore avesse dovuto accedere anch'essa alla struttura prm, ci sarebbero stati grossi problemi di latenza. Infatti avremmo dovuto usare il semaforo anche lì, e il semaforo è usato anche da processi a bassa priorità. Può accadere quindi un fenomeno conosciuto come inversione di priorità: siccome il semaforo è bloccato da processi a bassa priorità, anche i processi che hanno priorità elevata non possono essere messi in esecuzione.

In un caso come questo, paradossalmente, il kernel cooperativo ha prestazioni migliori!

Per questo ho indicato che ad usare il kernel preemptive si hanno grandi responsabilità: l'applicativo va accuratamente progettato in modo da evitare queste situazioni. Altrimenti si rischia di perdere tutti i vantaggi dati dalla preemption.

Visti questi rischi, è necessario valutare bene se si ha davvero bisogno di bassissima latenza. Nel nostro caso, probabilmente, la latenza non deterministica data dal cooperative sarebbe stata comunque sufficiente e ci avrebbe quindi risparmiato qualche grattacapo.

Nel caso di un processo che richiede bassissima latenza, secondo me è sempre il caso di chiedersi "perché?". Se il compito svolto è importante per la sicurezza, è davvero il caso di affidarsi ad una protezione software? Forse una protezione implementata in hardware causerebbe meno problemi e sarebbe più veloce.

Purtroppo molte volte queste scelte non sono sotto il nostro controllo (magari le specifiche non sono modificabili oppure ormai l'hardware è stato fatto), ma bisogna comunque essere consci che anche un kernel preemptive ha i suoi limiti.