FreeRTOS è un sistema operativo real-time (RTOS) che permette la commutazione tra vari task assicurandone la corretta esecuzione in base alla priorità del task stesso. Questo meccanismo dipende fortemente dall’architettura del microcontrollore. Ecco come implementare un RTOS, nello specifico FreeRTOS, su un microcontrollore AVR.
Le applicazioni che utilizzano un sistema operativo real-time (RTOS) sono costituite da un insieme di processi (task) ed è compito del sistema operativo eseguire i vari task ad intervalli di tempo ben definiti. Il kernel del sistema operativo prepara i vari processi per l’esecuzione o la sospensione ed un processo può anche autosospendersi (ad esempio nel caso in cui sia in attesa che una determinata risorsa divenga disponibile).
L’UNITÀ DI TEMPO
Come sistema operativo di riferimento in questo articolo verrà considerato il FreeRTOS (www.freertos.org) i cui sorgenti sono disponibili come open source. FreeRTOS misura il tempo utilizzando una variabile tick di conteggio che viene incrementata ad intervalli temporali ben precisi grazie ad un interrupt handler scatenato da un timer (tick interrupt). Prima dell’autosospensione, un processo comunica al sistema operativo il tempo per il quale rimarrà in sospensione e sarà cura del kernel confrontare di volta in volta il raggiungimento del timeout specificato. Ecco come avviene il meccanismo di gestione dei processi:
- l’ISR del timer incrementa il tick;
- se l’incremento sblocca un task allora viene controllato che quel task sia pronto per l’esecuzione e che la sua priorità sia maggiore di quella del processo appena interrotto;
- in caso affermativo viene fatto un cambio di contesto e all’uscita dell’ISR il nuovo task passa in esecuzione.
Per implementare questa tecnica su un AVR viene utilizzato il TIMER1 per la generazione dei tick. La durata di un tick viene determinata sia dalla frequenza di aggiornamento del timer (eventualmente modificata dal prescaler) sia dal valore finale impostato per il timer. Quando il timer raggiunge il valore finale, l’ISR si preoccuperà anche di azzerare il timer. Nel listato 1 è rappresentato il codice sorgente in C per l’impostazione del timer1 (quindi per la determinazione della durata di un tick).
#define portCLEAR_COUNTER_ON_MATCH ( 0x08 ) #define portPRESCALE_256 ( 0x04 ) #define portCLOCK_PRESCALER ( 256 ) #define portCOMPARE_MATCH_A_INTERRUPT_ENABLE ( 0x10 ) static void prvSetupTimerInterrupt ( void ) { unsigned portLONG ulCompareMatch; unsigned portCHAR ucHighByte, ucLowByte; ulCompareMatch = portCPU_CLOCK_HZ / portTICK_RATE_HZ; ulCompareMatch /= portCLOCK_PRESCALER; ucLowByte = ulCompareMatch & 0xff; ulCompareMatch >>= 8; ucHighByte = ulCompareMatch & 0xff; outb( OCR1AH, ucHighByte ); outb( OCR1AL, ucLowByte ); ucLowByte = portCLEAR_COUNTER_ON_MATCH | portPRESCALE_256; outb( TCCR1B, ucLowByte ); ucLowByte = inb( TIMSK ); ucLowByte |= portCOMPARE_MATCH_A_INTERRUPT_ENABLE; outb( TIMSK, ucLowByte ); }
Listato 1 |
IL CONTESTO
Un processo (o task) è un frammento di codice sequenziale la cui esecuzione può essere sospesa o ripresa dal sistema operativo sia in maniera schedulata, sia in maniera asincrona. Viene definito contesto per un processo, l’insieme di tutti i registri in RAM e in ROM utilizzati dal processo. In altre parole il contesto può essere considerato come l’ambiente di lavoro di un processo, le sue variabili, i suoi puntatori, il suo stack e così via. Quando un task entra in sospensione viene salvato il suo contesto, quindi “congelata” la situazione al momento della sospensione. Il tutto viene poi ripristinato alla successiva esecuzione. Il salvataggio del contesto per un determinato task ed il ripristino del contesto per il successivo processo in esecuzione è noto come cambiamento di contesto (context switching). Su un microcontrollore AVR il contesto è costituito da (figura 1):
- 32 registri general purpose;
- Registro di stato (status register);
- Program counter;
- I due puntatori allo stack.
Nel listato 2 è riportata la sintassi per la scrittura dell’ISR, la routine che andrà in esecuzione a seguito dell’interrupt scatenata dal timer1 precedentemente impostato.
void SIG_OUTPUT_COMPARE1A( void ) __attribute__ ( ( signal ) ); void SIG_OUTPUT_COMPARE1A( void ) { /* ISR C code for RTOS tick. */ vPortYieldFromTick(); }
Listato 2 |
La direttiva attribute ((signal)) informa il compilatore che la funzione è in effetti una ISR il che comporta due importanti differenze nel codice generato dal compilatore:
- l’attributo signal assicura che ciascun registro dell’AVR modificato durante l’esecuzione dell’ISR sia ripristinato al valore originale all’uscita dell’ISR;
- l’attributo signal forza inoltre il compilatore ad utilizzare una istruzione di ritorno da interruzione (RETI) anziché una semplice RET (ritorno da funzione). Questo permette di abilitare nuovamente le interruzioni all’uscita dell’ISR.
Il listato 3 mostra il codice assembler generato dalla compilazione della funzione del listato 2. Si noti che parte del salvataggio del contesto avviene in maniera automatica (vengono salvati solo i registri general purpose utilizzati dalla ISR).
;void SIG_OUTPUT_COMPARE1A( void ) ;{ ; --------------------------------------- ; CODE GENERATED BY THE COMPILER TO SAVE ; THE REGISTERS THAT GET ALTERED BY THE ; APPLICATION CODE DURING THE ISR. PUSH R1 PUSH R0 IN R0,0x3F PUSH R0 CLR R1 PUSH R18 PUSH R19 PUSH R20 PUSH R21 PUSH R22 PUSH R23 PUSH R24 PUSH R25 PUSH R26 PUSH R27 PUSH R30 PUSH R31 ; --------------------------------------- ; CODE GENERATED BY THE COMPILER FROM THE ; APPLICATION C CODE. ;vTaskIncrementTick(); CALL 0x0000029B ;Call subroutine ;} ; --------------------------------------- ; CODE GENERATED BY THE COMPILER TO ; RESTORE THE REGISTERS PREVIOUSLY ; SAVED. POP R31 POP R30 POP R27 POP R26 POP R25 POP R24 POP R23 POP R22 POP R21 POP R20 POP R19 POP R18 POP R0 OUT 0x3F,R0 POP R0 POP R1 RETI ; ---------------------------------------
Listato 3 |
Come già accennato però, il cambiamento del contesto prevede il salvataggio dell’intero contesto ma provvedendo manualmente a salvare i 32 registri general purpose si avrebbero inevitabilmente salvataggi duplicati. Per evitare questo, il listato 2 può essere modificato come indicato nel listato 4, utilizzando cioè l’attributo naked.
void SIG_OUTPUT_COMPARE1A( void ) __attribute__ ( ( signal, naked ) ); void SIG_OUTPUT_COMPARE1A( void ) { /* ISR C code for RTOS tick. */ vPortYieldFromTick(); }
Listato 4 |
Compilando il listato 4 si otterrà il codice del listato 5. Si noti che in questo caso non viene generato alcun codice di ingresso e di uscita dalla funzione. Il salvataggio ed il ripristino del contesto dovrà quindi essere fatto manualmente da opportune macro come indicato nel listato 6. Si noti, nel listato 6, che anche l’istruzione RETI di ritorno da interrupt deve essere aggiunta manualmente.
;void SIG_OUTPUT_COMPARE1A( void ) ;{ ; --------------------------------------- ; NO COMPILER GENERATED CODE HERE TO SAVE ; THE REGISTERS THAT GET ALTERED BY THE ; ISR. ; --------------------------------------- ; CODE GENERATED BY THE COMPILER FROM THE ; APPLICATION C CODE. ;vTaskIncrementTick(); CALL 0x0000029B ;Call subroutine ; --------------------------------------- ; NO COMPILER GENERATED CODE HERE TO RESTORE ; THE REGISTERS OR RETURN FROM THE ISR. ; --------------------------------------- ;}
Listato 5 |
void SIG_OUTPUT_COMPARE1A( void ) __attribute__ ( ( signal, naked ) ); void SIG_OUTPUT_COMPARE1A( void ) { /* Salvataggio del contesto. */ portSAVE_CONTEXT(); /* ISR C code for RTOS tick. */ vPortYieldFromTick(); /* Ripristino del contesto. */ portRESTORE_CONTEXT(); /* The return from interrupt call must also be explicitly added. */ asm volatile ( "reti" ); }
Listato 6 |
Salvataggio del contesto
Il salvataggio del contesto viene fatto semplicemente salvando i vari registri nello stack (ciascun processo ha il proprio stack).
Il modo più semplice per fare questa operazione è utilizzare direttamente istruzioni assembler PUSH, per cui la portSAVE_CONTEXT() utilizzata nel listato 6 può essere realizzata come indicato nel listato 7.
#define portSAVE_CONTEXT() \ asm volatile ( \ "push r0 \n\t" \ "in r0, __SREG__ \n\t" \ "cli \n\t" \ "push r0 \n\t" \ "push r1 \n\t" \ "clr r1 \n\t" \ "push r2 \n\t" \ "push r3 \n\t" \ "push r4 \n\t" \ "push r5 \n\t" \ : : : "push r30 \n\t" \ "push r31 \n\t" \ "lds r26, pxCurrentTCB \n\t" \ "lds r27, pxCurrentTCB + 1 \n\t" \ "in r0, __SP_L__ \n\t" \ "st x+, r0 \n\t" \ "in r0, __SP_H__ \n\t" \ "st x+, r0 \n\t" \ );
Listato 7 |
Ripristino del contesto
Il ripristino del contesto avviene in modo del tutto simmetrico al salvataggio. Il listato 8 riporta la macro portRESTORE_CONTEXT() per il ripristino del contesto.
#define portRESTORE_CONTEXT() \ asm volatile ( "lds r26, pxCurrentTCB \n\t" \ "lds r27, pxCurrentTCB + 1 \n\t" \ "ld r28, x+ \n\t" \ "out __SP_L__, r28 \n\t" \ "ld r29, x+ \n\t" \ "out __SP_H__, r29 \n\t" \ "pop r31 \n\t" \ "pop r30 \n\t" \ : : : "pop r1 \n\t" \ "pop r0 \n\t" \ "out __SREG__, r0 \n\t" \ "pop r0 \n\t" \ );
Listato 8 |
CAMBIAMENTO DI CONTESTO: UN ESEMPIO
Come esempio di cambiamento di contesto verrà mostrato passo per passo come il sistema operativo sospende l’esecuzione di un task (TaskA) per avviare un secondo task (TaskB). Ecco il meccanismo descritto in dettaglio:
- Il TaskA è in esecuzione (il suo contesto è quello riportato in figura 2). Si supponga che l’interruzione del timer 1 avvenga quando TaskA sta per eseguire l’istruzione LDI R0,0 (situazione di figura2).
- All’occorrenza dell’interruzione viene salvato il program conuter del TaskA (PC(A)) nello stack del TaskA (figura 3), quindi viene eseguita la ISR.
- La routine ISR va a controllare se il nuovo valore tick risveglia qualche processo (si supponga che si debba risvegliare il processo TaskB e che questo abbia priorità maggiore rispetto a TaskA) quindi provvede al salvataggio del contesto per TaskA. Ovviamente il contesto di TaskA viene salvato nel proprio stack (figura 4). Il kernel conserva una copia dei puntatori allo stack di ogni processo.
- Poiché il TaskB deve andare in esecuzione, è necessario ripristinarne il proprio contesto. Per prima cosa viene recuperato il puntatore allo stack grazie alle copie conservate dal kernel (figura 5) quindi vengono estratti tutti i registri salvati al momento della sospensione del TaskB. A questo punto il contesto del TaskB è ripristinato. Resta solo da estrarre il Program Counter (PC(B)) il cui valore è ancora nello stack (figura 6).
- Il valore del program counter del TaskB viene ripristinato automaticamente all’esecuzione dell’istruzione RETI in uscita dalla routine ISR. A questo punto il TaskB risulta in esecuzione dal punto in cui era stato interrotto al momento della sua sospensione (figura 7).
Freertos e’ il principale OS real time per i sistemi embedded che si basa principalmente in C (e assembler), con un kernel che si compone di un paio di files. E’ molto utilizzato in applicazioni low power per alcune opzioni di gestione. Il meccanismo della priorita’ dei task dipende dall’architettura dei microcontrollori. Trova spazio in molte MCU, il sito di riferimento freertos.org ne conta una 40 credo.
Grazie bel articolo che chiaramente mostra cosa fa un RT OS.
Forse OT, ma ARM propone “cmsis rtos api” come interfaccia per i sistemi RTOS sui quali si possono importare tecnicamente tutti i sistemi RTOS per MCU cortex-M (freeRTOS, uC OS, RTX etcc etcc). Per chi vuole iniziare a smanettare con RTOS e architetture Cortex credo sia il punto migliore da dove partire.
Articolo molto interessante… mi ricorda molto le lezioni universitarie sulle routine in assembly su cpu mips… ovviamente dubito che rtos possa essere portato su arduino… o sbaglio?
In realtà ci sarebbe un progetto di porting su arduino https://github.com/greiman/FreeRTOS-Arduino
https://github.com/espressif/ESP8266_RTOS_SDK se può essere utile per la famiglia esp, funziona bene.
Saluti Claudio