sobota, 8 maja 2021

Maszyna stanów przyjaciel embedownika :)


Maszyna stanów skończonych to pojęcie abstrakcyjne ,  definiuje zachowanie systemów dynamicznych jako maszyny o  skończonej liczbie stanów i  skończonej liczbie przejść pomiędzy tymi stanami. Definicja ta wydaje się dość abstrakcyjna , ale lepszej chwilowo nie znalazłem :).  Maszyny stanów skończonych odgrywają bardzo ważną rolę w  programowaniu w szczególności w świecie mikrokontrolerów. Umożliwiają m.in panowanie nad rozbudowaną sekwencją zdarzeń w programie i zwiększają znacząco niezawodność kodu.  W wielu przypadkach maszyna stanów w połączeniu z przerwaniami może okazać się lepszym wyborem niż np. RTOS ,choć z drugiej strony nie w każdym przypadku maszyna stanów znajdzie zastosowanie. Bardzo powszechnym obecnie nurtem jest stosowanie maszyny stanów do nieblokującej obsługi interfejsów np.I2C, SPI, UART. W szczególności ten nurt możemy zaobserwować w bibliotekach Microchipa gdzie np. taki framework jak HARMONY "wypluwa" standardowo kod oparty o maszynę stanów. Ale i w MCC widzę , że maszyna stanów zaczyna być częstym gościem . Skoro producenci MCU opierają swoje biblioteki o maszynę stanów to znaczy, że coś w tym musi być  I dlatego warto temu zagadnieniu przyjrzeć się bliżej.

Aby jakoś ugryźć ten temat , rozważę prosty przykład w oparciu o jedną diodę LED :). Im prościej tym lepiej będzie można zrozumieć zagadnienie. Zbudujmy sobie zatem super maszynę :) w oparciu o maszynę stanów skończonych. W projektowaniu maszyn stanów często wykorzystuje się grafy opisane w języku UML. Opis graficzny działania maszyny stanów jaki chcę zaimplementować w przykładzie wygląda mniej więcej jak poniżej :
Maszyna stanów będzie nam kontrolować zapalenie i gaszenie diody LED w sposób nieblokujący :). Zdefiniujmy sobie na początku stany dla naszej maszyny  , będą to INITIALOFF_STATEON_STATE

INITIAL

OFF_STATE

ON_STATE 

Mając zdefiniowane stany w których nasza maszyna może się znajdować, kolejnym krokiem w projektowaniu  jest zdefiniowane możliwych przejść pomiędzy stanami.

INITIAL - > OFF_STATE

OFF_STATE - > ON_STATE

ON_STATE - > OFF_STATE


Ostatnim krokiem w projektowaniu maszyny stanów jest zdefiniowanie warunków jakie muszą być spełnione aby nastąpiła zmiana stanu bieżącego na następny dozwolony. W moim przykładzie warunkiem przejścia będzie upływ czasu 

INITIAL - > OFF_STATE  / warunek przejścia TIMEOUT (np. 1s)

OFF_STATE - > ON_STATE  warunek przejścia TIMEOUT (np. 2s)

ON_STATE - > OFF_STATE  warunek przejścia TIMEOUT (np. 3s)

Do dalszych rozważań musimy przenieść się na sprzęt czyli MCU + programator + IDE. Ja w przykładzie posłużę się moją płytką dla STM32G0 , programatorem J-LINK EDU mini i IDE SEGGERA. Bo ten zestaw mam aktualnie na stole :).

Tworzę nowy projekt w IDE SEGGERA. W projekcie konfiguruje PA8 , tym pinem będę sterował diodą LED. Tworzę mechanizm TIMEOUT -u programowego w oparciu o SysTicka



Teraz czas na funkcje realizujące działania w poszczególnych stanach. Powołuję dwie funkcje : LED_OFF() i LED_ON() :



Definiuję stany naszej maszyny , powołuję w tym celu nowy typ state_t ze stanami maszyny , tworzę zgodną z nowym typem zmienną MyMachine , ustawiam stan początkowy tej zmiennej na INITIAL.


Teraz można napisać funkcję obsługującą życie wewnętrzne naszej maszyny stanów. Tworzę zatem taką funkcję i dodaję jej wywołanie do pętli głównej programu :
 
 
Konstrukcja funkcji opiera się o selektor wyboru switch/case, jest to popularna forma konstrukcji dla maszyn stanów ale nie optymalna i nawet uważana przez niektórych "specjalsów" za antywzorzec. Słabą stroną tej konstrukcji jest m.in wielokrotne nadmiarowe odpytywanie stanów zamiast wykonywanie tylko tego jednego aktualnego stanu. Jedną z takich zoptymalizowanych konstrukcji do współpracy z maszynami stanów ale też i mniej znanych jest użycie tablicy ze wskaźnikami do funkcji dla poszczególnych stanów. Zamiast switch-case wywołujemy funkcję z tablicy , która jest umieszczona na pozycji wskazywanej przez bieżący stan maszyny. Osiąga się w ten sposób izolację obsługi zdarzeń dla poszczególnych stanów. Kod jest bardziej czytelny i wywoływana jest tylko ta część kodu, która jest związana z bieżącym stanem maszyny.


Zbudujmy teraz maszynę w oparciu o optymalną metodę jej implementacji. Uwaga bo teraz to będzie się działo. Mocne wrażenia gwarantowane :)

Warunki brzegowe te same co w przykładzie powyżej, czyli stany maszyny, przejścia i warunki przejścia te same. Zaczynamy od punktu wyjścia jak w przykładzie powyżej czyli budujemy sobie mechanizm software-owy do TIMEOUT-u i powołujemy dwie funkcje wykonawcze LED_OFF() i LED_ON() :


Definiuję stany naszej maszyny , powołuję w tym celu nowy typ state_t ze stanami maszyny. I tu STOP od tego momentu będzie wszystko inaczej niż wcześniej. Powołuje nowy typ strukturalny state_machine_t z jednym elementem o typie state_t , tworzę zgodną z nowym typem zmienną MyMachine , ustawiam stan początkowy na INITIAL (MyMachine.state = INITIAL).


Po co nam ten dodatkowy typ strukturalny ?. Po pierwsze umożliwia nam powoływanie wielu instancji maszyny stanów tego samego typu. Na przykład jeśli chcemy dołączyć do naszego programu kolejną diodę LED i nią niezależnie sterować. W strukturze umieściłem tylko jedną zmienną typu state_t, w zmiennej tej będzie przechowywany aktualny stan maszyny dla powołanej instancji. Ale w przyszłości może się zdarzyć ,że potrzebne będą kolejne zmienne opisujące np. jakieś prywatne dane maszyny , liczbę stanów czy np. dodanie do struktury tablicy handlerów dla poszczególnych stanów.  Istnieje wiele możliwości aby taką strukturę zagospodarować sprytnie. Więc lepiej ją mieć niż nie mieć :)

Teraz zaczynają się ciekawe rzeczy i pewna magia programistyczna .Powołuję nowy typ  sm_handler_t, wskaźnika na funkcję , typ ten wykorzystam przy tworzeniu tablicy wskaźników na funkcję :


Tutaj jeśli ktoś wymiękł :) to proponuje poczytać sobie o wskaźnikach na funkcje, prędzej czy później zrozumiemy ten mechanizm. Dla mnie też to była kiedyś czarna magia.

Kolejnym krokiem jest deklaracja i definicja funkcji handlerów dla poszczególnych stanów maszyny. Każda z tych funkcji realizuje zadania maszyny w poszczególnych stanach. Takie rozbicie na oddzielne funkcje dla poszczególnych stanów i potem stablicowanie wskaźników do nich, stanowi bardzo elastyczny i responsywny mechanizm ułatwiający rozbudowę programu np. o nowe funkcjonalności dla maszyny.


Argumentem dla naszych funkcji / handlerów będzie wskaźnik na instancję powołanej maszyny czyli nasze MyMachine.


Funkcji powyższych nie będziemy wywoływać jawnie ale za pomocą wskaźników. I teraz czas na stablicowanie wskaźników do naszych funkcji handlerów :


Powyższym zapisem stworzyliśmy tablicę handlers[] typu wskaźnikowego sm_handler_t. Tablica ta przechowuje nam wskaźniki na nasze funkcje handlery do obsługi poszczególnych stanów maszyny.
Tablicę wypełniamy wskaźnikami na funkcję czyli nazwa funkcji bez nawiasu (). Kolejność funkcji dla porządku musi być zgodna z tym co podawaliśmy w typie state_t , czyli na pierwszym miejscu wskaźnik na funkcję obsługującą stan INITIAL itd.  Zdaję sobie sprawę , że może to być jeszcze na tym etapie niezrozumiałe , po co tworzyć takie abstrakcyjne konstrukcje. Proszę jednak przyjąć to jako aksjomat do momentu zrozumienia kiedyś całej idei, że to jest bardzo dobre rozwiązanie programistyczne optymalizujące zagadnienie i ułatwiające w przyszłości jego rozbudowę i panowanie nad kodem.

Powoli zbliżamy się do końca i to jest dobra wiadomość. Teraz można by było zadać pytanie jak taką maszynę wprawić w ruch ? jak te wskaźniki na funkcje z tablicy wywoływać ?.  Rozwiązanie tego rebusa jest bardzo proste. Musimy w tym celu powołać dodatkową funkcję , która będzie nam w prosty sposób zarządzać ruchem naszej maszyny to ona właśnie wprawi nam maszynę w ruch , poniżej deklaracja i definicja tej funkcji :




Funkcja powyższa jak widzimy zwraca nam tylko wywołanie wskaźnika na funkcję z tablicy (zwracam uwagę na konwencję takiego zapisu). Indeksem tej tablicy jest aktualny stan naszej maszyny , na starcie jest to stan INITIAL. Tutaj odbywa się cała magia wprawienia maszyny w ruch. Ostatnim elementem naszej układanki jest uruchomienie tej funkcji w pętli głównej :


Od tego momentu nasza maszyna ożyje i będzie "zarąbiście" działać. Proponuję sobie prześledzić na spokojnie działanie za pomocą debuggera wtedy można zobaczyć jak to fajnie żyje od środka. 

Poniżej dowód życia :) :



Na koniec posłużę się pewnym cytatem jaki znalazłem w sieci, który bardzo celnie oddaje podejście do maszyn stanów :

cytat ".... początkowo miałem obawy, że rosnąca ilość maszyn stanów spowoduje utratę panowania nad "tym wszystkim", ale po pewnym czasie nastąpiło przestawienie myślenia na "myślenie maszynami stanów" i już nie czuję zagubienia. Wszystko tu ma określony logiczny porządek i działa zgodnie z założeniami - a podzielenie na stany doskonale temu sprzyja (skupiasz się na małym wycinku działania programu i to Cię w danym momencie tylko interesuje). Stany zazwyczaj są bardzo krótkie i składają się z kilku-kilkunastu instrukcji - trochę podobnie jak wygląda zazwyczaj obsługa przerwania.
Zmiennych globalnych aż tak dużo wcale nie jest - najwięcej pamięci i tak zżerają różnego rodzaju bufory na dane z/do transmisji.
Tak więc programowanie w ten sposób wydaje mi się teraz czymś naturalnym (choć na początku był to pewnego rodzaju szok). "

kolejny cytat :

cytat"....Używanie maszyn stanów zapisanych explicite bardzo poprawia czytelność i późniejsze zrozumienie kodu. Co więcej, wymyślenie jak nasza maszyna stanów ma działać już samo w sobie wymaga zastanowienia się (co jest chyba wystarczającym argumentem za), często pokazuje istotne luki w założeniach (nie przewidzieliśmy wszystkiego co tu wychodzi czarno na białym), pomaga znaleźć i reagować na zdarzenia/stany niezwykłe a także ułatwia "gromadzenie" pewnych funkcjonalności w jednym miejscu kodu. Jasne, czasem wymaga to rozpisania wszystkiego na papierze i odrobiny wysiłku bo większe automaty mogą mieć dziesiątki stanów, ale łatwość pisania oraz późniejsze uruchamianie i testowanie oddają to z nawiązką. A jeśli do tego dorzucisz prawie 1:1 przeniesienie algorytmu z kartki/głowy na kod i łatwość jego modyfikacji/rozbudowy - masz potężną argumentację by stosować to gdziekolwiek się da. "

Maszyny stanów wbrew pozorom to bardzo szerokie zagadnienie . Stosowanie ich jest coraz bardziej powszechne przez programistów. Warto temu zagadnieniu poświecić czas. W linkach poniżej ciekawa rozprawa implementacji maszyny stanów w procesie przemysłowym.

4 komentarze:

  1. Bardzo przydatna instrukcja. Co prawda jeszcze nie próbowałem w praktyce, ale wydaje mi się, że jest to dość czytelnie i przejrzyście wyjaśnione. Pozdrawiam serdecznie :)

    OdpowiedzUsuń
  2. solą programowania embedownika jest maszyna stanów , ja to zrobiłem za pomocą prostych instrukcji warunkowej case w pętli while . Tu widzę wyższą ligę i spróbuje to przenieść na swoje podwórko . Świetny artykuł, bo jak dochodzą przerwania i przesyły DMA to bez higieny i porządku w kodzie robi się lokal z czerwonymi firankami ;-)

    OdpowiedzUsuń
  3. Ciekawy wpis. Dużo przydatnych informacji

    OdpowiedzUsuń
  4. Bardzo dobry poradnik pozdrawiam

    OdpowiedzUsuń