Otrzymałem ostatnio maila z prośbą o przybliżenie tematu "wielozadaniowości" w Arduino. Autor listu szukał rozwiązania pozornie prostego problemu, mianowicie kontroli i sterowania jednocześnie wieloma wejściami/wyjściami naszego układu. Arduino posiada system przerwań (a raczej mikrokontrolery Atmela taki system posiadają), ale przerwania mogą być wywoływane przez konkretne zdarzenia, co nie zawsze daje zamierzony efekt. Jednym z sygnałów przerwań może być port szeregowy, którego asynchroniczną obsługę pokazałem w innym artykule. Co jednak zrobić, gdy chcemy mieć wpływ na współbieżną pracę wielu elementów systemu podłączonych do portów Arduino? Postaram się wyjaśnić to zagadnienie poniżej.
A właściwie, to o co chodzi...?
Rozpocznijmy od tego, na czym polega problem. Ustalenie sposobu wykonywania programu ma znaczenie dla zrozumienia problemu "wielozadaniowości". Słowo wielozadaniowość celowo umieściłem w cudzysłowie. Właściwa wielozadaniowość polega na jednoczesnym wykonywaniu kilku zadań. Możliwe jest to w sytuacji, gdy system posiada kilka niezależnych ośrodków obliczeniowych (procesorów lub rdzeni). Arduino to układ, który posiada jeden mikrokontroler wykonujący jednocześnie jedno zadanie. W normalnych komputerach również mamy do czynienia z sytuacją, gdzie procesorów/rdzeni jest mniej niż zadań do wykonania w tym samym momencie. W tym miejscu dochodzimy do sedna sprawy: W komputerach PC problem wielozadaniowości rozwiązywany jest przez system operacyjny. W dużym skrócie system operacyjny zarządza zasobami i umożliwia wykonanie naprzemienne wielu fragmentów programów. Jeżeli dołożymy do tego, że w ciągu sekundy system będzie w stanie przełączyć zadania kilkadziesiąt razy, to mamy wrażenie, że wszystkie nasze programy wykonują się jednocześnie.
Zaraz, czy ta sytuacja nie jest nam znana z Arduino? Funkcja loop wykonuje się w niekończącej się pętli, co oznacza, że odczytując stan pinów po kolei mamy wrażenie, jakby odczyt ten był wykonany w tej samej chwili. Co więcej, gdy nasz program będzie bardziej skomplikowany i skorzystamy z własnych, czy innych funkcji bibliotecznych Arduino zachowa się podobnie. Przeanalizujmy poniższy kod programu:
void loop()
{
if(digitalRead(10)==HIGH)
digitalWrite(13,HIGH);
else
digitalWrite(13,LOW);
if(digitalRead(11)==HIGH)
digitalWrite(12,HIGH);
else
digitalWrite(12,LOW);
}
Sprawdzamy stan pinu 10 i odpowiednio ustawiamy stan wyjścia dla pinu 13. To samo robimy dla pary 11 i 12. W efekcie naciskając i puszczając przyciski podłączone do pinów 10,11 sterujemy zapalaniem diód podłączonych do pinów 12 i 13. Cieszymy się wielozadaniowością naszego układu Arduino. Co jednak, gdy postanowimy, że odczytana jedynka z portu 10 spowoduje świecenie diody na pinie 13 przez np. 5 sekund? Zmodyfikujemy nasz program stosownie do założeń:
void loop()
{
if(digitalRead(10)==HIGH)
{
digitalWrite(13,HIGH);
delay(5000);
digitalWrite(13,LOW);
}
if(digitalRead(11)==HIGH)
{
digitalWrite(12,HIGH);
delay(5000);
digitalWrite(12,LOW);
}
}
Po wgraniu programu ustawiamy stan pinu 10 na wysoki i dioda na pinie 13 się zapala na 5 sekund, po czym gaśnie, ustawiamy stan wysoki na pinie 11, dioda na pinie 11 zapala się i po 5 sekundach gaśnie. Niby wszystko jest tak jak być powinno ale....
W momencie, gdy świeci się jedna, lub druga dioda przyciski nie są obsługiwane przez Arduino... no właśnie co z tą wielozadaniowością? Przecież niby wszystko wykonuje się cyklicznie, ale chcielibyśmy trochę inaczej... W komputerach PC tego typu problem rozwiązalibyśmy z wykorzystaniem funkcji systemowych tworząc wątki dla poszczególnych zadań (jedna para jeden wątek). Jest to proste, bo system umożliwia nam takie rozwiązanie. W przypadku Arduino nie mamy systemu operacyjnego i wszystko, co ma dziać się w naszym układzie musimy wykonać (napisać) sami. W tym miejscu mógłby się zakończyć artykuł z informacją, że wiemy już w czym problem i w ramach zadania domowego napiszemy sobie rozwiązanie sami...
Spróbujmy rozwiązać problem - czyli przykład wielozadaniowości w Arduino
W przykładowym kodzie powyżej problemem jest funkcja delay(). Jej wywołanie powoduje zatrzymanie wykonywania programu na określony czas. Najłatwiej byłoby usunąć funkcję delay() z kodu, wtedy wróci "wielozadaniowość", ale diodka nie będzie się świeciła przez 5 sekund. Oczywiście jest na to rada. Poniżej przedstawię propozycję przykładowego rozwiązania problemu. Każdy programista ma swoje przyzwyczajenia i sposoby, ja postaram się przedstawić rozwiązanie proste do zrozumienia, niekoniecznie idealne programistycznie. Na początek szczegółowe założenia naszego programu:
- program ma sterować pracą 3 diód (trzy wyjścia 11,12,14),
- diody mają się zapalać po podaniu stanu wysokiego na piny (odpowiednio 8->11, 9->12, 10->13),
- dioda na wyjściu 11 ma świecić przez 3 sekundy,
- dioda na wyjściu 12 ma świecić przez 1 sekundę,
- dioda na wyjściu 13 ma świecić przez 0,5 sekundy,
- dla uproszczenia kodu nie sprawdzamy, czy przycisk został naciśnięty i puszczony, tylko sprawdzamy stan wysoki na wejściu,
- świecąca dioda nie blokuje możliwości zapalenia pozostałych diod,
- możliwe jest przedłużenie świecenia diody przez przytrzymanie lub ponowne naciśnięcie odpowiedniego przycisku.
Dysponując założeniami możemy przejść do napisania odpowiedniego programu. Zaczniemy od funkcji setup i definicji pinów jako wejścia lub wyjścia. Dobrą praktyką jest oczywiście zdefiniowanie nazw dla wykorzystywanych pinów. Dodatkowo pojawiły się tu zmienne, które będą wykorzystane w dalszej części programu.
#define P1 8
#define P2 9
#define P3 10
#define D1 11
#define D2 12
#define D3 13
int maxD1=0, maxD2=0, maxD3=0;
void setup()
{
pinMode(P1,INPUT);
pinMode(P2,INPUT);
pinMode(P3,INPUT);
pinMode(D1,OUTPUT);
pinMode(D2,OUTPUT);
pinMode(D3,OUTPUT);
}
Teraz możemy napisać właściwy kod programu. Na początek kilka słów na temat jego idei... Podstawowa zasada brzmi: "Jeżeli nie możesz pokonać wroga, to się z nim zaprzyjaźnij" :) Nie mamy wpływu na działanie funkcji delay(), więc musimy z niej skorzystać w sposób umiejętny, taki, który nie spowoduje zablokowania programu. Jak to zrobić? Najprościej jest podzielić cały wymagany czas oczekiwania na mniejsze odcinki. W przykładzie podstawowym czasem oczekiwania będzie 1ms. W funkcji loop() umieścimy funkcję delay(), która będzie zatrzymywała wykonywanie programu na jedną milisekundę przy każdym wywołaniu funkcji loop(). Teraz wystarczy tylko zliczać 1ms impulsy i odpowiednio sterować diodami. 1s to 1000ms. Jeżeli dioda ma się świecić przez 1s, to znaczy, że powinna świecić się przez tysiąc wywołań pętli z przerwą milisekundową. {Dla dociekliwych: w rzeczywistości nie jest to do końca prawda, bo polecenia wykonujące się w pętli również zajmują pewien czas, więc można by przyjąć np. 999 wykonań pętli. } Przejdźmy do właściwego kodu programu:
void loop()
{
if(digitalRead(P1)==HIGH) maxD1=3000;
if(digitalRead(P2)==HIGH) maxD2=1000;
if(digitalRead(P3)==HIGH) maxD3=500;
if(maxD1>0)
digitalWrite(D1,HIGH);
else
digitalWrite(D1,LOW);
if(maxD2>0)
digitalWrite(D2,HIGH);
else
digitalWrite(D2,LOW);
if(maxD3>0)
digitalWrite(D3,HIGH);
else
digitalWrite(D3,LOW);
if(maxD1>0) maxD1--;
if(maxD2>0) maxD2--;
if(maxD3>0) maxD3--;
delay(1);
}
Program podzieliłem na 4 bloki, żeby łatwiej było go przeanalizować. W pierwszym bloku sprawdzamy stany przycisków P1-P3. Stan wysoki spowoduje ustawienie licznika dla odpowiedniej diody. 3000x1ms=3s, 1000x1ms=1s, 500x1ms=0,5s. W drugim bloku sprawdzamy, czy liczniki mają wartość większą od zera. Jeżeli tak, to odpowiednia dioda zostaje zapalona (stan wysoki), w przeciwnym wypadku dioda zostanie zgaszona (stan niski). Trzeci blok odpowiedzialny jest za zliczanie impulsów 1ms. Zmniejszamy liczniki od ustawionej wartości do zera. Ten blok można połączyć z blokiem drugim (ten sam sprawdzany warunek "czy maxDx>0"), ale dla czytelności kodu postanowiłem go zapisać oddzielnie. Ostatni element to odczekanie 1ms i wyjście z funkcji loop(). Podsumowując: funkcja loop() będzie się wykonywała cyklicznie, co spowoduje wrażenie wielozadaniowości. funkcja delay() będzie spowalniała program na 1ms przy każdym wywołaniu funkcji loop(). W efekcie 1000 razy na sekundę Arduino sprawdzi stan przycisków, a diody będą zapalone przez trzy, jedną i pół sekundy od naciśnięcia odpowiedniego przycisku. Jeżeli przycisk zostanie wciśnięty przed zgaszeniem diody odliczanie zacznie się od nowa. Jak widać na powyższym przykładzie spowodowanie, że Arduino będzie udawało wielozadaniowość jest możliwe, ale kłopotliwe. Niestety nie ma jednego słusznego rozwiązania tego problemu. Analizując powyższy przykład możemy zauważyć, że nie będzie żadnego problemu, żeby rozbudować go dla 4,5,10 diod i przycisków (wystarczy zadeklarować dodatkowe zmienne i dodać wpisy dla kolejnych diod). A co w sytuacji, gdy diody nr 1 i 2 miałyby się zachowywać tak jak w przykładzie powyżej, a dioda nr 3 miałaby cyklicznie zapalać się i gasić? W tym momencie program się już trochę komplikuje... o tym postaram się napisać w części poniżej.
Własna funkcja do sterowania diodą
W tym miejscu warto przypomnieć o jednym dobrym zwyczaju... warto pisać własne funkcje i korzystać z nich w funkcji loop(). Takie rozwiązanie ma same zalety. Program staje się bardziej czytelny. Jednocześnie możemy w łatwy sposób wykorzystać raz napisany kod w wielu projektach. Między innymi z tego właśnie powodu napiszemy teraz funkcję umożliwiającą zaawansowane sterowanie diodą. Założenie do projektu jest proste. Dioda D3 ma zaświecić się trzy razy po naciśnięciu przycisku P3. Zanim przejdę do właściwego kodu programu jeszcze jedno spostrzeżenie. Na wejścia Arduino podajemy sygnał cyfrowy (stan niski lub wysoki). Stan niski to 0V, a wysoki to 5V (zakładając wersje 5V a nie 3,3V). Problem pojawia się, gdy do pinu Arduino nie podłączymy żadnego napięcia. Mikrokontroler w większości przypadków interpretuje to jako stan niski. Jest to zgodne z logiką i większość nieelektroników przechodzi nad tym do porządku dziennego. Niestety nie jest to takie oczywiste i czasem może się zdarzyć, że Arduino odczyta stan z niepodłączonego pinu jako wysoki. Co wtedy? Wtedy zaczynają się problemy, bo układ niby poprawnie złożony, program napisany, ale działanie jakieś inne niż oczekiwane. Stąd kolejna dobra praktyka, to stosowanie rezystorów podciągających napięcie. W efekcie na pinie niepodłączonym mamy zawsze stan wysoki, a zmieniamy go poprzez zwarcie pinu do masy. Mikrokontrolery AT Mega mają wbudowane rezystory podciągające i możemy je uaktywnić poprzez konfigurację początkową pinów jako: INPUT_PULLUP. Ten przydługi opis jest po to, by wyjaśnić, zmiany w nowej wersji programu w stosunku do poprzedniej. Teraz przechodzimy już do właściwego programu. W pierwszej kolejności definiujemy niezbędne zmienne, stałe i sposób obsługi pinów Arduino:
#define P1 8
#define P2 9
#define P3 10
#define D1 11
#define D2 12
#define D3 13
#define MAX_CZAS 200
int maxD1=0, maxD2=0, maxD3=0,maxMig=0;
void setup()
{
pinMode(P1,INPUT_PULLUP);
pinMode(P2,INPUT_PULLUP);
pinMode(P3,INPUT_PULLUP);
pinMode(D1,OUTPUT);
pinMode(D2,OUTPUT);
pinMode(D3,OUTPUT);
}
Jak widać dodałem zdefiniowaną stałą MAX_CZAS, która określa czas zapalenia lub zgaszenia diody oraz zmienną maxMig, którą wykorzystam do określenia ilości włączeń i wyłączeń diody D3. W funkcji setup() zamieniłem definicję pinów wejściowych z INPUT na INPUT_PULLUP. Czyli domyślnie niepodłączony pin P1 - P3 będzie miał stan wysoki. W funkcji loop() zmienił się sposób obsługi przycisków oraz została dodana funkcja mig() do wykonania.
void loop()
{
if(digitalRead(P1)==LOW) maxD1=3000;
if(digitalRead(P2)==LOW) maxD2=1000;
if(digitalRead(P3)==LOW) maxMig=6;
if(maxD1>0)
digitalWrite(D1,HIGH);
else
digitalWrite(D1,LOW);
if(maxD2>0)
digitalWrite(D2,HIGH);
else
digitalWrite(D2,LOW);
if(maxD1>0) maxD1--;
if(maxD2>0) maxD2--;
mig();
delay(1);
}
Podłączenie rezystorów PULL_UP wymusiło zmianę sprawdzenia naciśnięcia przycisków - w tym przykładzie powinny zwierać do masy układu. Część dotycząca obsługi P1 i P2 sprowadza się obecnie do sprawdzenie stanu niskiego (zwarty do masy - załączony). Obsługa D1, D2 pozostała niezmieniona od poprzedniego przykładu. Zmiany dotyczą obsługi przycisku P3, którego naciśnięcie powoduje ustawienie wartości dla zmiennej maxMig. W naszym przykładzie wartość ta wynosi 6. Oznacza to, że dioda D3 przyjmie kolejno 6 różnych stanów (ich obsługa znajduje się w funkcji mig()). Przed funkcją delay() dodana została fonkcja mig(), która jest odpowiedzialna za zapalanie diody D3. Poniżej znajduje się kod funkcji mig():
void mig()
{
if((maxD3==0)&&(maxMig>0))
{
maxD3=MAX_CZAS;
digitalWrite(D3,!digitalRead(D3));
maxMig--;
if(maxMig==0) digitalWrite(D3,LOW);
}
if(maxD3>0) maxD3--;
}
W pierwszej kolejności sprawdzamy jednocześnie dwa warunki:
- czy zmienna maxMig jest większa od zera,
- czy zmienna maxD3 jest równa zero.
Wartość zmiennej maxMig większa od zera przekazuje informację, że funkcja mig ma wykonać dodatkową akcję (przycisk został naciśnięty i jeszcze nie zakończono obsługi tego zdarzenia). Zmienna maxD3 określa czas przez jaki dioda ma się świecić, lub być zgaszona. W przypadku, gdy oba warunki są spełnione następuje obsługa zmiany stanu diody. W przykładzie skorzystałem z negacji odczytanego stanu portu. Takie rozwiązanie pozwala w prosty sposób zmienić stan portu wyjściowego Arduino bez konieczności jego zapamiętywania. funkcja digitalWrite() ustawia stan portu na inny niż odczytany. Był stan wysoki, będzie niski, był stan niski, będzie wysoki. Po zmianie stanu portu wartość zmiennej maxMig jest zmniejszana o jeden. Jeżeli wartość zmiennej maxMig osiągnie wartość "0" ustawiamy niski stan na wyjściu diody D3. Ta linia kodu nie jest niezbędna, ale zabezpiecza nas przed "przypadkowym" ustawieniem stanu wysokiego po zakończeniu migania diodą. Linia maxD3=MAX_CZAS restartuje licznik czasu świecenia lub zgaszenia diody D3.
Jak widać na powyższych przykładach obsługa wielu zdarzeń z wykorzystaniem funkcji czasu (delay()) nie jest trudna, ale wymaga pewnego przemyślenia poszczególnych kroków. Najważniejsze jest zapobieganie blokowaniu się programu w sytuacji, gdy coś ma wykonywać się dłużej. Oczywiście określenie "dłużej" zależne jest od konkretnego przypadku. Mam nadzieję, że tym artykułem przybliżyłem nieco zagadnienia programowania "wielozadaniowego" w Arduino. Zdaję sobie sprawę, że jest to tylko część możliwości, a zagadnienie nadaje się do opisania w dobrej książce...