Skocz do zawartości

Wielozadaniowość cz. 1.5 - Ostrzeżenia i pułapki


Pomocna odpowiedź

Napisano

Ten wpis powinienem opublikować po części pierwszej, gdyż zawiera informacje niezbędne zarówno dla części 1, ( arytmetyka modulo i funkcja millis() ), jak i dla kolejnych części. Powstał on dzięki uwagom Kolegów @ethanak@Santiago @orb777  @_LM_.

1. Wbudowana funkcja millis()

 

Bezpośrednio po zainstalowaniu środowisko Arduino dostarcza funkcje wbudowane, dostępne w aplikacjach bez deklaracji włączania bibliotek (#include). Jedną z nich, jest funkcja millis(), która podaje czas od uruchomienia procesora ze średnią (uśrednioną w czasie np. 1 s) dokładnością równą 1 ms. Dostarcza ona aktualną zawartość wewnętrznego 32-bitowego licznika, który jest zwiększany najczęściej o 1 w wyniku obsługi przerwania związanego z licznikiem nr 1 procesora ATMega, zainstalowanego na płytce Arduino. Dlaczego najczęściej? Ponieważ przerwanie jest zgłaszane co 1,024 milisekundy. Aby skompensować błąd czasu wynikający z takiej częstotliwości zgłaszania przerwań, w obsłudze przerwania programiści dodali drugi (pomocniczy) licznik 8-bitowy, który jest zwiększany każdorazowo o 3. Po osiągnięciu przez niego wartości 125 lub większej licznik czasu jest dodatkowo zwiększany o 1, a od niego jest odejmowana liczba 125. Zatem na 1000 zmian zawartości licznika mamy 41 – 42 chwile, w których licznik jest zwiększany o 2. Skąd biorą się te liczby? Otóż przerwanie jest zgłaszane przez sprzętowy licznik Timer0 co 1,024 milisekundy. Zliczenie 1000 przerwań dałoby wartość 1000 w czasie 1,024 sekundy. Natomiast licznik powinien zawierać wartość 1024. Zatem w czasie zliczania 1000 przerwań należy zwiększyć dodatkowo wartość licznika o 24. Czyli 24/1000 po skróceniu daje 3/125. Dlatego licznik pomocniczy zwiększa się o 3, a po osiągnięciu lub przekroczeniu wartości 125 dodaje się 1 do licznika głównego i zmniejsza się licznik pomocniczy o 125.

Algorytm jest zaprojektowany dla zegara 16 MHz (Arduino zasilane 5V, zegar 16 MHz). W przypadku płytek zasilanych napięciem 3,3 V z zegarem 8 MHz sytuacja wygląda w ten sposób, że algorytm pozostaje bez zmian, zmieniają się natomiast jego parametry. Przerwanie zwiększa licznik główny o 2, zaś licznik pomocniczy o 6. Wynikiem jest także dokładny czas, lecz z różnicą wartości pomiędzy dwoma kolejnymi odczytami równą 2 lub 3.

Przy okazji funkcja mikros(). Zwraca ona wartość unsigned long, złożoną z aktualnej wartości rejestru licznika Timer0 (najmłodszy bajt) i licznika funkcji millis() przesuniętego o 8 bitów w lewo. Zatem funkcja mikros() dla zegara 16 MHz ma rozdzielczość 4 mikrosekundy i dodatkowo jest obciążona przeskokiem o 2 (patrz wyżej) funkcji millis(). Dla zegara 8 MHz odpowiednio rozdzielczość 8 mikrosekund i przeskok taki, jak dla millis() dla zegara 8 MHz.

 

Program demo. Kropki na konsoli wskazują zwiększenie wartości funkcji millis() o 2 w stosunku do wartości poprzedniej.

short aktualnyCzas = 0;
short zapamietanyCzas = 0;
short roznicaCzasu = 0;
short poprzedniCzas = 0;
void setup(){
Serial.begin(9600);
}
void loop(){
poprzedniCzas = aktualnyCzas;
aktualnyCzas = (short) millis(); // pobierz aktualny czas
roznicaCzasu = aktualnyCzas - zapamietanyCzas;
if (aktualnyCzas - poprzedniCzas > 1) Serial.print(".");
//Jeśli różnica wynosi ponad sekundę
if (roznicaCzasu >= 1000) {
//Zapamietaj aktualny czas
zapamietanyCzas = aktualnyCzas;
//Wyslij do PC
Serial.println("!"); // koniec testu
Serial.print(aktualnyCzas);
Serial.print(" - ");
Serial.println(roznicaCzasu);
}
}

obraz.png.c28fdb0c5d488cd8f34e59eedaf4ce5a.png

 

2. Operator porównywania „==”

Z powyższego wynika, że używanie operatora „==” do porównywania danych zawierających czas jest bardzo niewskazane, gdyż wynik takiego porównania zależy od niepewnej wartości bieżącej funkcji millis() i dla liczb 32-bitowych może powodować, że system będzie się zawieszał losowo na ok. 50 dni. Systemy wbudowane natomiast powinny pracować bezawaryjnie latami. Poza tym system operacyjny kooperujący nie gwarantuje, że funkcja zostanie wywołana dokładnie w zadanym momencie. Poprzednio wywołana funkcja mogła wykonywać się trochę dłużej z jakiegoś powodu. Stąd też znowu aktualna funkcja może zostać zablokowana. Do porównywania zmiennych zawierających czas wskazane jest używanie operatorów „>=” i „<=” . Stąd też wniosek do konstruowania watchdoga: trzeba sprawdzać działanie każdej funkcji, a nie tylko reagować na ogólne pójście w maliny.

 

3. Format zmiennych zawierających czas i pomiar odcinka czasu

Czas komputera nie jest czasem ciągłym, lecz czasem skwantowanym. Przechowywany jest w zmiennych o skończonej długości (16, 32, 64 bity) i system (sprzęt lub oprogramowanie) zwiększa licznik czasu o zdefiniowaną w systemie wartość (najczęściej 1) co zdefiniowany w systemie odcinek czasu. Powoduje to, że każda zmienna po pewnym czasie przekroczy wartość maksymalną, co powoduje, że czas zacznie być liczony od początku.

Dla kwantu czasu 1 ms przepełnienie następuje:

Dla 16 bitów – po 65,535 sekundy

Dla 32 bitów – po 49 dni, 17 godzin, 2 minuty, 47 sekund

Dla 64 bitów – po 584 542 046 lat (~584,5 mln lat)

2. Operator porównywania „==”

 

Z powyższego wynika, że używanie operatora „==” do porównywania danych zawierających czas jest bardzo niewskazane, gdyż wynik takiego porównania zależy od niepewnej wartości bieżącej funkcji millis() i dla liczb 32-bitowych może powodować, że system będzie się zawieszał losowo na ok. 50 dni. Systemy wbudowane natomiast powinny pracować bezawaryjnie latami. Poza tym system operacyjny kooperujący nie gwarantuje, że funkcja zostanie wywołana dokładnie w zadanym momencie. Poprzednio wywołana funkcja mogła wykonywać się trochę dłużej z jakiegoś powodu. Stąd też znowu aktualna funkcja może zostać zablokowana. Do porównywania zmiennych zawierających czas wskazane jest używanie operatorów „>=” i „<=” . Stąd też wniosek do konstruowania watchdoga: trzeba sprawdzać działanie każdej funkcji, a nie tylko reagować na ogólne pójście w maliny.

 

3. Format zmiennych zawierających czas i pomiar odcinka czasu

 

Czas komputera nie jest czasem ciągłym, lecz czasem skwantowanym. Przechowywany jest w zmiennych o skończonej długości (16, 32, 64 bity) i system (sprzęt lub oprogramowanie) zwiększa licznik czasu o zdefiniowaną w systemie wartość (najczęściej 1) co zdefiniowany w systemie odcinek czasu. Powoduje to, że każda zmienna po pewnym czasie przekroczy wartość maksymalną, co powoduje, że czas zacznie być liczony od początku.

 

Dla kwantu czasu 1 ms przepełnienie następuje:

Dla 16 bitów – po 65,535 sekundy

Dla 32 bitów – po 49 dni, 17 godzin, 2 minuty, 47 sekund

Dla 64 bitów – po 584 542 046 lat (~584,5 mln lat)

Dla liczb ze znakiem pierwsze przepełnienie nastąpi po upływie połowy podanego czasu. Kolejne po upływie podanego czasu. Bezpośrednio długość mierzonego odcinka czasu musi być krótsza od czasu przepełnienia. Dłuższe niż maksymalny odcinki czasu można mierzyć dzieląc go na krótsze odcinki i zliczając ilość takich odcinków.

Wbrew rozpowszechnionym poglądom (także w dokumentacji) znak (signed/unsigned) i długość (long/int) nie mają znaczenia podczas wyliczania odcinka czasu. Istotny jest sposób, w jaki to robimy. Znacznik czasu możemy ustawiać albo w danym momencie i testować, jak daleko bieżący czas oddalił się od znacznika, albo ustawić znacznik gdzieś w przyszłości i testować, czy bieżący czas minął znacznik. Każda z tych metod zachowuje się inaczej przy przepełnieniu licznika. Pierwsza metoda (znacznik w chwili bieżącej) jest niewrażliwa na przepełnienie licznika, druga metoda (znacznik w przyszłości) – nie. Przy okazji ostrzeżenie dotyczące liczb ze znakiem: o ile teoretycznie zachowują się bez zarzutu, to niektóre kompilatory potrafią tu namieszać. W językach C i C++ przepełnienie liczb ze znakiem (Signed Overflow) to tzw. Undefined Behavior (UB), czyli zachowanie niezdefiniowane przez standard języka. W praktyce (na 99% kompilatorów dla mikrokontrolerów, np. GCC dla Arduino) kompilator wygeneruje kod, który zadziała poprawnie, bo procesor operuje na kodzie uzupełnień do dwóch (U2). Jednak zaawansowane optymalizacje kompilatora (np. -O2 lub -O3) potrafią "wyciąć" lub zniekształcić warunek if (current - last >= interval), jeśli kompilator uzna, że logicznie czas nie może być ujemny. Dlatego standardem i dobrą praktyką w systemach operacyjnych jest trzymanie się typów unsigned.

DEMO

Dla liczb bez znaku (unsigned). Działa

unsigned short aktualnyCzas = 0;
unsigned short zapamietanyCzas = 0;
unsigned short roznicaCzasu = 0;
void setup(){
Serial.begin(9600);
}
void loop(){
//Pobierz liczbe milisekund od startu
aktualnyCzas = (short) millis();
roznicaCzasu = aktualnyCzas - zapamietanyCzas;
//Jeśli różnica wynosi ponad sekundę
if (roznicaCzasu >= 1000) {
//Zapamietaj aktualny czas
//Wyslij do PC
Serial.print(aktualnyCzas);
Serial.print("-");
Serial.print(zapamietanyCzas);
Serial.print("=");
Serial.println(roznicaCzasu);
zapamietanyCzas = aktualnyCzas;
}
}

obraz.png.5693dba6612d8d558f419da24154d3a4.png

Dla liczb ze znakiem (signed). Też działa, ale kompilator może sprawiać czasami kłopoty

short aktualnyCzas = 0;
short zapamietanyCzas = 0;
short roznicaCzasu = 0;
void setup(){
Serial.begin(9600);
}
void loop(){
//Pobierz liczbe milisekund od startu
aktualnyCzas = (short) millis();
roznicaCzasu = aktualnyCzas - zapamietanyCzas;
//Jeśli różnica wynosi ponad sekundę
if (roznicaCzasu >= 1000) {
//Zapamietaj aktualny czas
//Wyslij do PC
Serial.print(aktualnyCzas);
Serial.print("-");
Serial.print(zapamietanyCzas);
Serial.print("=");
Serial.println(roznicaCzasu);
zapamietanyCzas = aktualnyCzas;
}
}

obraz.png.0cceba9b1d84b78058e385acea71c21d.png

Znacznik czasu ustawiony w przyszłości. KATASTROFA, gdy zmienna przyszlyCzas przepełni się. Gdy zmienna aktualnyCzas osiągnie 0 (przepełnienie) program znowu zacznie działać normalnie (do następnego przepełnienia).

unsigned short aktualnyCzas = 0;
unsigned short przyszlyCzas = 1000;
unsigned short roznicaCzasu = 0;
void setup(){
Serial.begin(9600);
}
void loop(){
//Pobierz liczbe milisekund od startu
aktualnyCzas = (short) millis();
roznicaCzasu = aktualnyCzas - przyszlyCzas;
//Jeśli różnica wynosi ponad sekundę
if (aktualnyCzas >= przyszlyCzas) {
//Zapamietaj aktualny czas
//Wyslij do PC
Serial.print(aktualnyCzas);
Serial.print("-");
Serial.print(przyszlyCzas);
Serial.print("=");
Serial.println(roznicaCzasu);
przyszlyCzas = aktualnyCzas + 1000;
}
}

obraz.png.bb467cd96a2df16372437bbf3193706a.png

I ostatnie ostrzeżenie dotyczące bibliotek – użycie biblioteki, która wewnątrz używają delay() lub delaymicroseconds() - wprowadzają one niekontrolowane przez nas opóźnienie. Można używać funkcji delaymicroseconds(), ale trzeba pamiętać, żeby opóźnienia były krótkie. Pętla wykonująca się 10 razy na milisekundę ma opóźnienie 100 mikrosekund. Aktualnie pętla loop() w moich przykładach wykonuje się powyżej 50 razy na milisekundę. Moim zdaniem użycie delaymicroseconds() ma sens dla wygenerowania np. impulsu o długości 10 mikrosekund, ale dla impulsu o długości 1000 mikrosekund już nie. Dla przykładu funkcje wbudowane: – funkcja analogWrite() niczego nie blokuje (PWM generuje bezpośrednio sprzęt), funkcja analogRead() blokuje wykonanie na okres 104/208 mikrosekundy dla zegara 16/8 MH. Powód? Po włączeniu odczytu trzeba poczekać na wynik. Przetwornik ADC w Arduino jest typu SAR. Sam przetwornik potrzebuje 13 cykli zegara ADC na wykonanie jednej konwersji (pierwsza konwersja po uruchomieniu trwa 25 cykli), do tego narzut kompilatora.

PODSUMOWANIE

🛑 Nigdy nie używaj operatora == do badania czasu. Czas płynie asynchronicznie do Twojego kodu. Zawsze używaj porównań >= lub <= i odejmowania wstecznego.

🛑 Nigdy nie wyliczaj znaczników w przyszłości (obecny_czas + interwał). To pułapka, która przy przepełnieniu typu (szczególnie ze znakiem) wywoła fałszywy alarm i natychmiastowe wykonanie zadania.

🟢 Zawsze badaj przeszłość poprzez odejmowanie (obecny_czas - poprzedni_czas >= interwał). Ta konstrukcja (na liczbach bez znaku) opiera się na matematyce modularnej i jest w 100% odporna na skoki licznika, przepełnienia oraz błędy kompensacji mikrokontrolera.

 

Inne ostrzeżenia (np. użycie przerwań) będę sygnalizował w razie potrzeby.

Powyższy tekst napisałem wykorzystując sztuczną inteligencję (chatgpt). Jak do tej pory wykorzystuję ją jako sekretarza – przeszukanie zapisów w internecie dotyczących opisywanych tematów, podsumowanie zapytania, namiary na dokumenty źródłowe. Niestety, nie jest dokładna. Fragment o przejściu przez 0 dla liczb ze znakiem i implementacji języka C znalazł sam w trzecim podejściu. Ja bym go pewnie nie znalazł. Daje dużo informacji, ale trzeba je analizować. Testowanie pomysłów – czy mają sens. Pytania były bardzo szczegółowe. Tu dostaję ostrzeżenia dotyczące wykonalności i jeśli wykonalne – kody. Kody nie są najlepszej jakości – za każdym razem potrafiłem napisać krótsze i bardziej zrozumiałe. Nigdy też nie dostałem propozycji lepszego rozwiązania niż moje. Spróbuję teraz przetestować SI jako drugą parę oczu. Wnioski do tej pory: SI jest mnożnikiem moich możliwości – głównie oszczędza czas. Jednak jako mnożnik ma oczywistą własność – trzeba coś wiedzieć, im więcej, tym lepsze efekty. Zero pomnożone przez cokolwiek to dalej zero.

 

 

Bądź aktywny - zaloguj się lub utwórz konto!

Tylko zarejestrowani użytkownicy mogą komentować zawartość tej strony

Utwórz konto w ~20 sekund!

Zarejestruj nowe konto, to proste!

Zarejestruj się »

Zaloguj się

Posiadasz własne konto? Użyj go!

Zaloguj się »
×
×
  • Utwórz nowe...