RTOS alapok

A fejezet tartalma:

A többfeladatos rendszerek kihívásai

Egy hagyományos számítógép program általában egyszerre csak egy dologgal foglalkozik:  beolvas egy állományt, ezután végez valamilyen számolást a beolvasott adatokkal, végül kiírja az eredményeket. Egy beágyazott rendszer mikrovezérlője ezzel szemben számos dolgot végez egyidejűleg: például leolvas egy hőmérőt, működtet egy szelepet, gomblenyomásra figyel, ellenőrzi az eltelt időt, számlálja az elhaladó üvegeket egy palackozóban stb. A mikrovezérlőnek ez a képessége, hogy (látszólag) egyidejűleg több feladattal is tud foglalkozni az, amiről többfeladatos (multitasking) programozás szól.

Egy mikrovezérlő program természetesen egyszerre mindig csak egy dologgal tud foglalkozni. De ha mindezt nagyon gyorsan teszi (többmillió művelet másodpercenként), akkor megoldható, hogy a feladatok között gyorsan váltogasson, azt az illúziót keltve, mintha számos dolgot tényleg egyidejűleg végezne.  A kérdés csak az, hogy
hogyan írjuk meg a programot, hogy az megossza a figyelmét több feladat között, de ne zavarodjunk bele sem mi, sem a mikrovezérlő a folyamat bonyodalmaiba. Az alábbiakban felsorolunk néhány szokásos módszert:

Szuperhurok

A szuperhurok lényegében egy végtelen ciklusból álló programszerkezet, amely az összes végrehajtandó feladatot tartalmazza. Legegyszerűbb esetben a programszerkezet így néz ki:
int main()
{
initialisation(); //Kezdeti beállítások
while (true) { //Végtelen ciklus
Feladat_1(); //Feladatok
Feladat_2();
}
}
A szuperhurok módszer jellemzői:

Véges állapotgép módszer

A gyakorlati programozás során sokszor élünk szoftver absztrakcióval, ami abban segít bennünket, hogy egy komplex problémát néhány egyszerű alapelv segítségével fogalmazzunk meg. A véges állapotgép esetében például ezeket az absztrakt alapelemeket használjuk: bemenetek, kimenetek, állapotok, átmenetek egyik állapotból a másikba.

A véges állapotgép ( Finite State Machine, FSM) egy olyan absztrakció, amely a probléma megoldását az algoritmusokhoz hasonló módon fogalmazza meg.  Van azonban egy lényeges különbség: egy algoritmus azt adja meg, hogy milyen lépések elvégzése során jutunk el a megoldáshoz, a véges állapotgép pedig egy rendszer viselkedését írja le , mint egy automatát, amely a bemenetek hatására váltogatja az állapotait (a megoldást itt az jelenti, ha sikerül a modell viselkedését s programunkban realizálni).

Egy véges állapotgép az alábbi öt alapvető dologgal jellemezhető:
  1. A lehetséges állapotok véges halmaza. Ezen állapotok egyike a kiindulási állapot.
  2. A bejövő adatok véges halmaza.
  3. A rendszer által generált kimenő adatok véges halmaza
  4. Az állapotok közötti átmenetek explicit megadása. Az átmenet a pillanatnyi állapot és a bemenetek függvénye.
  5. A kimenő adatok előállításának szabályai (a kimenet a pillanatnyi állapot és a  bemenetek függvénye).
A véges állapotgép megadható irányított gráf formájában (a csomópontok jelentik a lehetséges állapotokat, az élek pedig az állapotok közötti lehetséges átmeneteket), vagy táblázatos formában.

Az alábbiakban egy nagyon egyszerű (talán ez a lehető legegyszerűbb) mintapéldát mutatunk be, amelyben egy LED-et kapcsolgatunk egy nyomógomb segítségével. Az SW1 nyomógombot a korábbi nyomógombos mintapéldához hasonlóan itt is a D3 (más néven PTA12) bemenet és a GND közé kell kötni. Az állapotgép a nyomógombbal kapcsolatban két állapotot tartalmaz:
  1. Nyomógomb lenyomásra várunk (hogy magas szintről alacsonyra váltson az SW1 bemenet).
  2. Nyomógomb felengedésre várunk (hogy alacsony szintről magas szintre váltson az SW1 bemenet).
A nyomógomb állapotainak nyilvántartásával és a lekérdezések között egy 20 ms késleltetéssel megoldottuk:

1. ábra: Az állapotgép megfogalmazása irányított gráffal



1. táblázat: Az állapotgép megadása táblázattal
Pillanatnyi állapot
Következő állapot
Állapotváltás feltétele
Tevékenység
Lenyomásra vár
Felengedésre vár SW1 == 0
LED1 = ! LED1
Felengedésre vár
Lenyomásra vár SW1 == 1

1. lista: A 09_ledswitch/main.cpp program listája
#include "mbed.h"

DigitalIn SW1(D3,PullUp);
DigitalOut LED_1(LED_RED);

typedef enum { //Set of possible states
STATE_WAIT_FOR_PRESS,
STATE_WAIT_FOR_RELEASE
} state_t;


int main()
{
//Define initial state
state_t mystate = STATE_WAIT_FOR_PRESS;
LED_1 = 1; //LED off at start
while (true) {
switch(mystate) {
case STATE_WAIT_FOR_PRESS:
if (SW1==0) { //if button pressed
LED_1 = !LED_1; //toggle LED_1
mystate = STATE_WAIT_FOR_RELEASE;
}
break;
case STATE_WAIT_FOR_RELEASE:
if (SW1==1) { //if button released
mystate = STATE_WAIT_FOR_PRESS;
}
}
wait_ms(20); //debounce delay
}
}

Egyszerű körkörös ütemezés

Az egyszerű szuperhurok módszert átalakíthatjuk úgy, hogy a főprogramban csak definiáljuk a feladatokat (task), majd végül átadjuk a vezérlést egy ütemezőnek, mely legegyszerűbb esetben körkörös ütemezés (round robin scheduling) valósít meg. Felmerülhet a kérdés: mit nyerünk ezzel? Nos, egyrészt azt, hogy az ütemező megírható újrafelhasználható kódként, másrészt továbbfejleszthető különféle szolgáltatásokkal, vagy ütemezési stratégiákkal, megtéve ezzel az első lépést az operációs rendszerek felé. Az operációs rendszerek kezdete a feladatütemező (scheduler)!

Kooperatív többfeladatú rendszer

Az egyszerű körkörös ütemezőt bővíthetjük például azzal, hogy megjegyzi, hogy az egyes feladatok hol tartották, amikor visszaadták a vezérlést az ütemezőnek, valamint lehetőség ad arra, hogy a feladatok megadhassák, hogy mennyi idő múlva akarják folytatni a tevékenységüket. Ilyen lehetőséget kínál Adam Dunkels: Protothreads (protoszálak) programkönyvtára, amely minimális erőforrás felhasználású program protoszálak megvalósítását támogatja. A protoszál itt azt jelenti, hogy "könnyűsúlyú", azaz saját veremtárral nem rendelkező programszálakról van szó. Az ütemező ezért protoszálanként csak egy visszatérési (pontosabban folytatási) címet és egy időzítési értéket kell, hogy megjegyezzen. A közös veremhasználat miatt azonban a protoszálak nem tárolhatnak lokális változókat a veremtárban.

Fontos továbbá, hogy a protoszálak záros határidőn belül visszaadják a vezérlést az ütemezőnek. Az ilyen többfeladatú rendszert kooperatív működésűnek nevezzük, mivel a működés a feladatok együttműködésén alapul. Ha valamelyik programszál önző módon magánál tartja a vezérlést, a többi programszál nem tud futni, mint ahogy a Windows 3.1 rendszerben is gyakran előfordult, hogy egy-egy rosszul megírt alkalmazás "lefogta" az egész rendszert. Egy rosszmájúan vicces megjegyzés szerint a  korai Windows verzióknál a többfeladatúság (multitasking) abban merül ki, hogy "bármely alkalmazás képes együtt futni a homokórával, ami megakadályozza további alkalmazások elindítását".

Megszakításos ütemezés

A prioritási szintek bevezetése és a megszakításos ütemezés (preemptive scheduling) a következő lépés a valós idejű operációs rendszerek (Real-Time Operating System, RTOS) megvalósítása felé. A programszálak megszakíthatósága azt jelenti, hogy ha egy magasabb prioritású programszál futtatható állapotba került (egy esemény bekövetkezte miatt), akkor az éppen futó, alacsonyabb prioritású programszál futása félbeszakad, s csak akkor folytatódik, ha a magasabb prioritású programszál befejezte a tevékenységét (újabb eseményre vagy időzítésre várakozó állapotba kerül). A megszakíthatóság azt is eredményezi, hogy - a kooperatív működéssel szemben - az ütemező akkor is megszakíthatja az éppen futó programszálat, ha egy vele azonos prioritású feladat már régóta futásra vár. Az azonos prioritású programszálak időszeleteléses alapon, körkörös ütemezéssel (round robin scheduling) futnak. Az RTOS kezdeti konfigurálásától függ, hogy egy-egy időszelet milyen időtartamú (általában 10 - 50 ms).

A kooperatív működés és a megszakításos ütemezés közötti különbséget az alábbi ábra szemlélteti. Az ábra felső részében (A) a kooperatív működés látható, ahol kezdetben egy alacsonyabb prioritású feladat fut (1a). Ezt megszakítja egy esemény (2a), s tegyük fel, hogy a megszakítás kiszolgálása során olyan eseményt észlelünk, amely futtathatóvá teszi a korábban erre az eseményre váró magasabb prioritású feladatot. A megszakításból azonban a vezérlés a megszakított programszálhoz kerül vissza (4a), s a kooperatív működési módnak megfelelően mindaddig az fut, amíg blokkoló várakozásra, vagy explicit kilépésre (visszaadjuk a vezérlést sz ütemezőnek) nem kerül sor (5a). Az ütemező (kernel) kiértékeli a helyzetet, s átadja a vezérlést a futtatható állapotba került magasabb prioritású programszálnak(6a).


2. ábra: A kooperatív működés és a megszakításos ütemezés különbségének szemléltetése

Az ábra alsó részében (B) a megszakításos ütemezés látható, ahol kezdetben egy alacsonyabb prioritású feladat fut (1b). Ezt megszakítja egy megszakítási kérelem (2b), s tegyük fel, hogy a megszakítás kiszolgálása során olyan eseményt észlelünk, amely futtathatóvá teszi a korábban erre az eseményre váró magasabb prioritású feladatot. A megszakítás kiszolgálása után a vezérlés az ütemezőhöz kerül (4b), s a megszakításos ütemezésnek megfelelően mindig a legmagasabb prioritású futtatható programszál kapja meg a vezérlést (5b), az alacsonyabb prioritású programszál futása pedig felfüggesztésre kerül mindaddig, amíg a magas prioritású programszál be nem fejezi a tevékenységét (6b) és az ütemező nem talál futtatható állapotú magasabb prioritású programszálat (7b).

A programszálak megszakíthatósága miatt - mivel az bármikor bekövetkezhet - szükséges azonban, hogy a programszálak folytatásához minden szükséges információt elmentsünk (CPU regiszterek, visszatérési cím, lokális változók). Emiatt a megszakításos ütemezést  használó operációs rendszerek erőforrásigénye jóval nagyobb a kooperatív ütemezéshez képest, s a feladatváltás is több időt vesz igénybe. Minden programszál saját veremterülettel és a taszkleíró táblában egy-egy bejegyzéssel kell, hogy rendelkezzen (ez utóbbira az ütemezőnek van szüksége, hogy a programszálak állapotát nyilvántartsa).

3. ábra: Programszálanként külön verem és taszkleíró táblabejegyzés szükséges


Az RTOS rendszerek fontos feladata az ütemezésen kívül az is, hogy a programszálak szinkronizálásához, az osztott használatú erőforrások ideiglenes lefoglalásához, valamint a programszálak közötti kommunikációhoz rendszerszintű szolgáltatásokat nyújtson. Jelzők, mutexek, szemaforok, várakozási sorok, üzenetküldés, memóriakészlet, időzítők jelentik a szokásos eszköztárat.

Kellemetlen következmények: prioritás inverzió, holtpont

Az előző bekezdésben említettük, hogy a programszálak a közös használatú erőforrásokat ideiglenesen lefoglalhatják (pl. mutex vagy szemafor segítségével), hogy amíg ezeket használják (pl. kiíratás soros porton, vagy adatbeolvasás memóriakártyáról), más programszál ne avatkozhasson közbe. Ez a lefoglalás azonban olyan mellékhatásokhoz vezethet, mint például a prioritás inverzió, amit az alábbi ábrán szemléltetünk.

4. ábra: Prioritás inverzió szemléltetése

Az ábra magyarázata:
  1. Az alacsony prioritású L programszál megszerez egy erőforrást (pl mutex)
  2. A magas prioritású H programszál félbeszakítja L futását (t1)
  3. A H programszál futása megszakad, ha az L által lefoglalt erőforrásra kell várnia (t2)
  4. Ha egy közepes prioritású M programszál ekkor megszerzi a futási jogot, akkor a prioritás megfordul, mert bármeddig futhat, blokkolva H és L működését.
A probléma megoldására a komolyabb RTOS rendszerek már fel vannak készítve: a fentihez hasonló esetben az erőforrást elsődlegesen lefoglaló L programszál ideiglenesen megörökli az általa feltartott H programszál prioritását, így t2 időpontban először L futása folytatódik, amíg vissza nem adja a lefoglalt erőforrást, majd  a H programszál folytatódik, s M futására csak ezt követően kerül sor.

A címben említett másik probléma a holtpont (deadlock) akkor következik be, ha két programszál kölcsönösen lefoglal olyan erőforrást, amelyre a másik programszál várakozik. Ennek elkerülése megfontolt programtervezést kíván. Elvileg az ütemező is figyelhetné a holtpont kialakulását, ám ennek megvalósítása túl sok erőforrást (memória és CPU idő) venne igénybe.

Valós idejű rendszerek jellemzői

Az általános célú operációs rendszerekkel (OS) szemben a beágyazott rendszerek többnyire valósidejű (Real-Time) követelményeket támasztanak, ami azt jelenti, hogy egy-egy esemény bekövetkezésére adott időkorláton belül reagálni kell. Az autó fékrendszerének csúszásgátló elektronikája vagy a légzsák esetében tizedmásodperces késedelmeken is emberéletek múlhatnak. A követelmények szempontjából nézve a valósidejű operációs rendszereket (RTOS) három nagy csoportra szokták osztani:
A valósidejű operációs rendszer megtervezésekor (vagy a piaci kínálatból a megfelelő RTOS kiválasztásakor)   egyensúlyt kell teremtenie az alkalmazásfejlesztést támogató gazdag funkcionalitás és a valós idejű alkalmazások kritikus határidőnek betartása, s  a kiszámíthatóság megőrzése között. Ez többnyire (de nem kizárólagosan) az alábbi szempontok vizsgálatát jelenti:

mbed-RTOS

Az mbed programkönyvtár 2012 óta RTOS támogatást is nyújt, amely a CMSIS 3.0-ban bevezetett RTOS API-n és a nyílt forrásúvá tett ARM/Keil RTX valósidejű operációs rendszeren alapul. A következő oldalakon az mbed-RTOS használatával fogunk megismerkedni.

Ízelítőül jöjjön egy egyszerű példa, amelyben a FRDM-KL25Z kártya RGB LED színkomponenseit más-más frekvenciával villogtatjuk. A feladatot természetesen egy egyszerű szuperhurokkal is megoldhattuk volna...

2. lista: A 09_rtos_basic/main.cpp program listája
#include "mbed.h"
#include "rtos.h"

DigitalOut led1(LED1);
DigitalOut led2(LED2);
DigitalOut led3(LED3);

void led2_thread(void const *args) {
while (true) {
led2 = !led2;
Thread::wait(2000);
}
}

void led3_thread(void const *args) {
while (true) {
led3 = !led3;
Thread::wait(4000);
}
}

int main() {
Thread thread2(led2_thread);
Thread thread3(led3_thread);

while (true) {
led1 = !led1;
Thread::wait(1000);
}
}

Az első szembetűnő különbség a korábbi programokhoz képest, hogy az rtos.h fejléc állományt is be kell csatolni. A main függvény maga az első programszál, s ennek elején még két másik programszálat indítunk (Thread objektumok példányosításával). A programszálakhoz egy-egy függvényt rendelünk, amelyek más-más frekvenciával villogtatnak egy-egy LED-et.

Fontos eltérés az is, hogy a korábban használt blokkoló várakozások (wait(), wait_ms(), wait_us() függvények) helyett most a Thread::wait() tagfüggvényt használjuk, amely úgy várakoztatja a programszálat, hogy a többi futását nem gátolja meg.