Kurs Raspberry Pi Pico – #10 – tablice, struktury i maszyna stanów

Czas czytania: 16 min.

W ostatnim poradniku wspólnymi siłami uruchomiliśmy dobrze już znany projekt blink led, jednak pozbawiony był on blokujących procesor funkcji sleep_ms. Poza tym po raz pierwszy skorzystaliśmy ze wskaźników oraz konstrukcji nazywanej strukturą, którą tak jak obiecałem ,zajmiemy się w tym artykule. Poza tym opowiem wam też, w jaki sposób budować maszyn stanów w języku C.

Kup zestaw do nauki programowania z Raspberry Pi Pico W i skorzystaj z kursu dostępnego na Blog Botland!

W zestawie: moduł Raspberry Pi Pico W, płytka stykowa, przewody, diody LED, rezystory, przyciski, fotorezystory, cyfrowe czujniki światła, temperatury, wilgotności i ciśnienia, wyświetlacz OLED i przewód USB-microUSB.

Przed wyruszeniem w drogę należy zebrać drużynę

Zestaw elementów do kursu Raspberry Pi Pico.

Chcąc uczyć się programowania, bazując na rzeczywistych projektach, potrzebny będzie oczywiście odpowiedni sprzęt, ale bez obaw – nie musisz teraz skakać między kolejnymi artykułami i przygotowywać listę niezbędnych elektronicznych elementów. W sklepie Botland dostępny jest gotowy zestaw, zawierający wszystkie komponenty niezbędne do wykonania projektów opisanych w serii poradników o Raspberry Pi Pico. 

gotowym zestawie elementów znajdziecie:

  • Raspberry Pi Pico W,
  • Przewód microUSB,
  • Płytkę stykową,
  • Zestaw przewodów połączeniowych w trzech rodzajach,
  • Zestaw diod LED w trzech kolorach,
  • Zestaw najczciej stosowanych w elektronice rezystorów,
  • Przyciski Tact Switch,
  • Fotorezystory,
  • Cyfrowy czujnik światła,
  • Cyfrowy czujnik wilgotności, temperatury i ciśnienia,
  • Wyświetlacz OLED.

Tablice jedno i wielowymiarowe

Na początek warto byłoby wrócić do tematu struktur, które pojawiły się już przy okazji ostatnio omawianego projektu. Jednak zanim przejdziemy do tego zagadnienia, spróbujemy uruchomić kod oparty na nieco prostszych konstrukcjach, czyli tablicach.

Do tej pory dość powszechnie korzystaliśmy z różnego rodzaju zmiennych, mogły one przechowywać liczby całkowite, zmiennoprzecinkowe, jak i stany logiczne. Jednak co warto zauważyć, pojedyncza zmienna przechowywać mogła tylko pojedynczą daną. Gdy odczytywaliśmy temperaturę z wbudowanego w RP2040 sensora, w zmiennej zapisana była zawsze jej ostatnia wartość i nie było możliwości porównać jej ze wcześniejszym pomiarem.

Książkowe porównanie zmiennych i tablic.

Gdybyśmy przyjęli, że pojedyncza zmienna może być kartką papieru, na której zapisany tekst jest jej wartością, to tablica mogłaby być książką. Innymi słowy, tablicami w języku C nazywamy zbiory zmiennych tego samego typu, których wartość może być różna. Stosowanie tablic może być całkiem przydatne, zwłaszcza gdy potrzebujemy zapisywać wiele wartości tego samego typu, deklarowanie pojedynczych zmiennych w takim przypadku jest po prostu zbędne i niepotrzebnie komplikuje kod. Poza tym za pomocą tablic tworzyć można tak zwane bufory, szczególnie przydatne przy okazji różnego rodzaju komunikacji, jak i ciągi tekstu, tak zwane stringi (od angielskiego string, czyli ciąg). Wspomnieć trzeba też, że tablice mogą mieć wiele wymiarów, ale tym zagadnieniem zajmiemy się za chwilę. Na pierwszy ogień weźmy prosty program, który skorzysta z jednowymiarowej tablicy.

Obwód znany z poprzednich poradników.

Na ten moment nie musimy zmieniać przygotowanego wcześniej obwodu. W pierwszym projekcie, który opisałem jako arrays_test sterować będziemy diodami LED. Jednak tym razem o tym, czy dioda będzie uruchomiona, czy też nie decydować będzie wartość zapisana w tablicy. Poza tym spróbujemy też zmodyfikować konkretną wartość w tablicy, tak więc przejdźmy do pierwszego programu.

				
					#include "pico/stdlib.h"

#define GREEN_LED 0
#define YELLOW_LED 1
#define RED_LED 2

bool led_state_array[3] = {1, 0, 1}; // array declaration

int main() {
    stdio_init_all(); //initialization of the stdio.h library

    gpio_init(GREEN_LED);
    gpio_init(YELLOW_LED);
    gpio_init(RED_LED);
    gpio_set_dir(GREEN_LED, GPIO_OUT);
    gpio_set_dir(YELLOW_LED, GPIO_OUT);
    gpio_set_dir(RED_LED, GPIO_OUT);

    while(true) {

            gpio_put(GREEN_LED, led_state_array[0]);
            gpio_put(YELLOW_LED, led_state_array[1]);
            gpio_put(RED_LED, led_state_array[2]);

            sleep_ms(3000);

            led_state_array[0] = 0; // changing the value of an element with index zero

    }
}

				
			

 Struktura kodu jest w gruncie rzeczy dość prosta. Mamy tutaj dołączoną bibliotekę standardową, definicję pinów, do których podłączone są diody LED, które następnie inicjalizowane są na początku głównej funkcji main. Cała magia związana z tablicami dzieje się wewnątrz nieskończonej pętli while oraz powyżej funkcji main.

Deklaracja tablicy.

Na początek zwróćmy uwagę na deklarację samej tablicy. Wygląda ona bardzo podobnie jak kod, który powołuje do życia zmienną. Tutaj również wyróżnić możemy typ tablicy, a konkretniej typ danych, jaki znajdować będzie się w tablicy, w przykładzie jest to bool, ponieważ przechowywać będziemy wartości logiczne odpowiadające stanowi, w jakim znajduje się dioda LED. Dalej umieszczona została nazwa tablicy, tutaj jest to po prostu led_state_array. W nawiasie kwadratowym umieszczamy liczbę odpowiadającą ilości elementów w tablicy, sterować będziemy trzema diodami, dlatego umieściłem tutaj cyfrę trzy. Co ważne, przy deklaracji tablicy nie musimy określać jej wielkości, nawias kwadratowy mógłby pozostać pusty i kompilator sam sprawdziłby ilość zadeklarowanych później elementów, jednak dobrą praktyką jest samodzielne określanie wielkości tablicy, ponieważ „automatyczne” tablice potrafią być zwodnicze. Po znaku równości w nawiasach klamrowych umieszczone zostały domyślne wartości wszystkich elementów w tablicy. Każdy z nich określany jest indeksem [0], [1] i [2]. Co ważne, wartość indeksu zwiększa się od lewej strony, co może być nieintuicyjne, zwłaszcza dla osób, które miały już do czynienia z elektroniką cyfrową, gdzie najmłodszy bit z indeksem zerowym umieszczany jest zazwyczaj na skrajnej prawej stronie.

Tak zadeklarowana tablica przechowuje trzy zmienne typu bool, których wartości już na starcie określiliśmy.

W pętli while zobaczyć możemy trzy funkcje, które sterują kolejnymi diodami LED. W tym przypadku stan świecącej konstrukcji określamy przez konkretny element tablicy, podając jej nazwę oraz indeks umieszczony w nawiasie kwadratowym. Tym sposobem stan zielonej diody będzie zgodny z pierwszym elementem w tablicy, żółta dioda przyjmie stan drugiego elementu, a do czerwonego LEDa przypisana zostanie wartość ostatniego elementu w tablicy. Jak można się domyślić, po uruchomieniu programu zaświecić powinny się dwie skrajne diody, czyli zielona i czerwona.

Jednak, żeby nie było tak prosto, po odczekaniu trzech sekund zmodyfikujemy wartość pierwszego elementu w tablicy. Odwołanie do niego wygląda identycznie jak w funkcji sterującej diodą LED. Najpierw umieszczamy nazwę tablicy, a w nawiasie kwadratowym indeks odpowiedniego elementu. Podobnie jak przy zmiennych wartość zero przypisujemy dzięki znaku równości.

Po uruchomieniu programu zobaczyć możemy efekt, którego się spodziewaliśmy. W pierwszym kroku uruchomione zostały dwie skrajne diody, czyli zielona i czerwona. Następnie po odczekaniu trzech sekund mikrokontroler zmodyfikował pierwszy element w tablicy i wrócił na początek pętli while, raz jeszcze wysterowując diody LED. Tym sposobem wyłączony został zielony element świecący, ponieważ to właśnie jego stan opisany był przez element w tablicy z indeksem zero.

Deklaracja tablicy dwuwymiarowej.

W pierwszym przykładzie skorzystaliśmy z tablicy jednowymiarowej. Kolejne dane w niej umieszczone opisywane były pojedynczym indeksem, ale warto wiedzieć, że w języku C tworzyć można też tablice wielowymiarowe i taki właśnie przypadek przeanalizujemy tym razem. Zanim jednak przejdziemy do programu, zobaczmy w jaki sposób deklarowane są tego typu tablice, na przykładzie, który za moment wykorzystamy.

W porównaniu do poprzedniego powołania tablicy, struktura tego polecenia uległa nieznacznej rozbudowie. Na początek zauważmy, że po nazwie tablicy znalazły się dwa nawiasy kwadratowe, w których określamy wymiary tablicy, innymi słowy, ilość wierszy i kolumn. W naszym programie nadal będziemy sterować trzema diodami LED, dlatego tablica będzie miała trzy kolumny. Natomiast wierszy będzie osiem, tak aby wykorzystać wszystkie możliwe stany, jakie możemy uzyskać na trzech elementach. Jeśli przyjrzycie się stanom, w kolejnych wierszach tablicy zauważycie, że odpowiadają one kolejnym liczbom w kodzie binarnym. W tak przygotowanej tablicy, każdy z elementów opisywany jest własnym indeksem odpowiadającym wierszowi i kolumnie. Kolumny tak jak wspomniałem w poprzednim przykładzie, określamy od lewej do prawej, natomiast wiersze z góry na dół.

				
					#include "pico/stdlib.h"

#define GREEN_LED 0
#define YELLOW_LED 1
#define RED_LED 2

// declaration of a multidimensional array
bool led_state_array[8][3] = {{0, 0, 0},
                              {0, 0, 1},
                              {0, 1, 0},
                              {0, 1, 1},
                              {1, 0, 0},
                              {1, 0, 1},
                              {1, 1, 0},
                              {1, 1, 1}};

int main() {
    stdio_init_all(); //initialization of the stdio library

    gpio_init(GREEN_LED);
    gpio_init(YELLOW_LED);
    gpio_init(RED_LED);
    gpio_set_dir(GREEN_LED, GPIO_OUT);
    gpio_set_dir(YELLOW_LED, GPIO_OUT);
    gpio_set_dir(RED_LED, GPIO_OUT);

    while (true) {
        for (int i = 0; i < 8; i++) {
            // Set the diode states according to the current row of the table
            gpio_put(GREEN_LED, led_state_array[i][0]);
            gpio_put(YELLOW_LED, led_state_array[i][1]);
            gpio_put(RED_LED, led_state_array[i][2]);

            // delay
            sleep_ms(1000);
        }
    }
}

				
			

Tym razem w testowanym kodzie umieszczona została nowa deklaracja tablicy, którą widzieliście już wcześniej. Zmianie uległa też nieskończona pętla while. W jej wnętrzu znalazła się pętla for wykonująca osiem skoków, tak aby diody LED wysterowane zostały przez element z każdego kolejnego wiersza tablicy. Pętla wykona się osiem razy, a w jej wnętrzu umieszczone zostały polecenia sterujące diodami. Za stan każdej z diod odpowiada element z tablicy umieszczony pod indeksem [i] oraz [0], [1] i [2]. Za „i” przy każdym kolejnym przebiegu pętli podstawiona zostaje kolejna liczba od zera do ośmiu, tak aby do diod przypisane zostały wartości spod kolejnych wierszy tablicy. Poza tym w kodzie umieszczone zostało sekundowe opóźnienie, dzięki czemu widoczne będą zmiany świecenia diod.

Po uruchomieniu kodu zobaczyć możemy zmieniające się stany na diodach LED. Jeśli przyjrzymy się dokładniej, zauważymy, że są one zgodne z wartościami zapisanymi w tablicy.

Warto wiedzieć, że w języku C budować możemy tablice nie tylko dwu i jedno wymiarowe, nic nie stoi na przeszkodzie, aby zainicjować tablicę trójwymiarową, czy nawet czterowymiarową. Jednak takie przykłady są już nieco bardziej zaawansowane i nie będziemy ich tutaj analizować. Dwie podstawowe formy tablic na ten moment nam wystarczą.

Struktury, czyli pseudo obiektowość w C

Bazując na prostych przykładach, poznaliśmy tablice, które opisać można jako mniejsze, bądź większe zbiory tego samego typu zmiennych, które możemy od siebie odróżnić dzięki indeksom. Jednak ograniczeniem tego typu konstrukcji jest właśnie wspomniany typ danych. W tablicach nie mamy możliwości zapisu kilku różnych zmiennych, które to opisałyby nieco większy obiekt. Weźmy na ten przykład człowieka, ma on imię, wiek oraz wzrost. Imię jest ciągiem znaków, wiek liczbą całkowitą, natomiast wzrost liczbą z przecinkiem. Takich danych nijak nie można umieścić w tablicy, musielibyśmy stworzyć trzy odrębne zmienne i tak właśnie zrobimy, ale umieścimy je wewnątrz struktury. W języku C istnieje pewna złożona konstrukcja nazywana właśnie strukturą, pozwala ona grupować różne typy zmiennych i dzięki nim budować większe obiekty, które mają wiele atrybutów, tak jak wspomniany człowiek. W kolejnym przykładzie zbudujemy właśnie taką strukturę opisującą człowieka, która przechowywać będzie informację o jego imieniu, wieku oraz wzroście. Dane te po zapisaniu wyślemy do komputera i wyświetlimy na monitorze portu szeregowego. Dla tego programu utworzyłem kolejny projekt nazwany struct_test, jednak zanim do niego przejdziemy, przyjrzyjmy się bliżej sposobie deklaracji struktury, z którego za moment skorzystamy.

Deklaracja struktury.

Struktury w języku C powołujemy do życia w dość prosty i intuicyjny sposób. Zaczynamy od słów kluczowych „typdef struct”, następnie w nawiasie klamrowym umieszczamy tak zwaną listę zmiennych strukturalnych, czyli po prostu listę elementów, które przypisane będą do obiektów bazujących na tej strukturze. W naszym przykładzie opisywać będziemy chcieli człowieka, dlatego opiszemy go za pomocą tablicy typu char, w której zapiszemy imię, zmiennej uint8_t przechowującej wiek oraz zmiennoprzecinkowego float, który odpowiadać będzie za wzrost opisanej osoby. Definicję struktury kończymy, podając jej nazwę, w tym przypadku będzie to po prostu Person.

Tak przygotowana struktura pozwoli nam tworzyć w kodzie swego rodzaju „obiekty”, ale do tego przejdziemy za moment. Tutaj muszę tylko wspomnieć, że tego typu opis nie jest tak naprawdę zgodny z teorią języka C. W „czystym” przykładzie nie pojawia się słowo kluczowe typedef, a nazwa struktury umieszczona jest zaraz po słowie struct. Jednak ta nieco rozbudowana definicja struktury, którą przedstawiłem, jest zgodna z niespisanymi zasadami języka C. Dzięki niej przy powoływaniu obiektów nie będziemy musieli za każdym razem stosować słowa struct, ponieważ dzięki typedef, staje się ona swego rodzaju „stałą definicją typu”.

#include "pico/stdlib.h"
#include <stdio.h>

// structure declaration
typedef struct {
char name[20];
u_int8_t age;
float height;
} Person;

int main() {
stdio_init_all(); //initialization of the stdio library

// Create an instance of the Person structure
Person Rafal;
Person Kuba;

// Assigning values to structure fields
snprintf(Rafal.name, sizeof(Rafal.name), "Rafal");
Rafal.age = 24;
Rafal.height = 180.5;

snprintf(Kuba.name, sizeof(Kuba.name), "Kuba");
Kuba.age = 28;
Kuba.height = 176.0;

while (true) {
printf("Name: %s\n", Rafal.name);
printf("Age: %d\n", Rafal.age);
printf("Height: %.1f cm\n", Rafal.height);

printf("Name: %s\n", Kuba.name);
printf("Age: %d\n", Kuba.age);
printf("Height: %.1f cm\n", Kuba.height);

// delay
sleep_ms(1000);

}
}

Program, który pozwoli nam powołać do życia obiekty strukturalne, zaczynamy od klasycznej już implementacji bibliotek. W drugim kroku definiujemy strukturę, zgodnie z opisanym powyżej przykładem, dzięki niej możliwe będzie „wygenerowanie” osoby z własnym imieniem, wiekiem oraz wzrostem. W kontekście wcześniej omawianych tablic zwróćcie uwagę, że imię jest właśnie dwudziestoelementową tablicą znaków char, w której umieścimy imię będące z perspektywy mikrokontrolera po prostu ciągiem znaków.

W głównej funkcji main, po inicjalizacji standardowych poleceń biblioteki studio tworzymy obiekty, innymi słowy, osoby bazujące na opisanej strukturze. Będzie to Rafał i Kuba. Samo utworzenie obiektu strukturalnego jest niezwykle proste i polega na podaniu nazwy struktury „Person” oraz wymyślonej nazwy obiektu. Gdybyśmy skorzystali z teoretycznej dla języka C metody definicji struktury, bez „typedef” to właśnie w tym miejscu musielibyśmy stosować dodatkowe polecenie struct, a tak nie ma takie potrzeby.

Gdy obiekty, czyli osoby zostały już powołane, można przypisać im odpowiednie atrybuty, to właśnie zadanie zrealizowane zostało w kolejnych instrukcjach. Do pojedynczej zmiennej strukturalnej dostajemy się poprzez podanie nazwy obiektu i pojedynczej zmiennej w strukturze rozdzielonych kropką. Tak więc, aby dodać wiek Rafała, korzystamy z Rafal.age i dzięki operatorowi przypisania w zmiennej „age” obiektu „Rafal” umieszczone zostaje 24. W podobny sposób dodajemy wzrost, choć tutaj możemy skorzystać z dokładniejsze liczby, ponieważ ten opisany jest zmienną float. Zdecydowanie najbardziej złożonym procesem jest tutaj przypisanie samego imienia. Choć wiemy, że imię jest ciągiem znaków to dla mikrokontrolera nie jest to takie oczywiste i nie możemy tak po prostu skorzystać z przykładowo „Kuba.name = ‘’Kuba’’;”. Aby dodać imię skorzystamy z polecenia snprintf, które stworzy odpowiedni ciąg znaków. Funkcja ta wymaga trzech parametrów, pierwszym jest miejsce, gdzie umieszczony ma być wygenerowany bufor, w naszym przypadku oczekujemy, że zapisany zostanie w zmiennej strukturalnej odpowiadającej danemu obiektowi, dlatego umieszczamy tutaj Rafal.name oraz Kuba.name. Drugim parametrem jest wielkość miejsca, w którym zapisujemy dane. Moglibyśmy wpisać je na stałe, jako dwadzieścia, ponieważ właśnie takiej wielkości są tablice char, ale lepiej skorzystać tutaj z polecenia, które zrobi to za nas automatycznie. Tak właśnie działa sizeof, zwracając wielkość elementu podanego w nawiasie. Ostatnim argumentem snprintf jest ciąg znaków, który umieścimy w podanym wcześniej miejscu, czyli po prostu imiona Rafał i Kuba zapisane jako ciągi znaków.

W nieskończonej pętli while skorzystamy z dobrze już znanego printf i w co sekundowym odstępie będziemy wysyłać na monitor portu szeregowego informacje zapisane w zmiennych strukturalnych.

Monitor portu szeregowego po uruchomieniu programu.

Po wrzuceniu kodu na Raspberry Pi Pico w serial monitorze powinniśmy zobaczyć opisy utworzonych w kodzie osób.

W tym projekcie warto zwrócić uwagę na jeden szczegół. Struktura, którą utworzyliśmy, jest tylko jedna, natomiast obiekty są dwa i odwołują się niejako do tych samych zmiennych i nic nie stoi na przeszkodzie, aby dodać jeszcze więcej obiektów. Na tym właśnie polega magia struktur, które bywają nazywane pseudoobiektowymi konstrukcjami w języku C. Dzieje się tak, ponieważ praca z nimi przypomina nieco programowanie znane z języków obiektowych, takich jak C++, czy Python. Struktura pozwala grupować pewne dane w nieco bardziej wyszukane paczki, co bywa całkiem przydatne. Wyobraźcie sobie, że pracujemy z dowolnym czujnikiem, (to już w kolejnym artykule) jego obsługę moglibyśmy oprzeć na zmiennych, czy też stałych, program działałby poprawnie, ale wprowadzanie różnego rodzaju zmian mogłoby być utrudnione. Znacznie lepiej z takiego czujnika zrobić obiekt o własnych atrybutach, wówczas kod wygląda znacznie lepiej, jak i sama komunikacja zrealizowana jest lepiej niż przeciętnie. Na ten moment jednak wystarczy nam podstawowa wiedza jak utworzyć i korzystać ze struktur, bardziej złożonymi przykładami zajmiemy się w przyszłości.

Przekazywanie danych do funkcji przez wskaźnik

Tematem, który poruszyliśmy już wcześniej, są wskaźniki. Dotychczas opracowaliśmy proste przykłady, w których odczytywaliśmy adresy zapisanych w pamięci mikrokontrolera zmiennych. Nadszedł jednak czas, aby przyjrzeć się programowi, który tak naprawdę wykorzysta esencję tych elementów języka C, czyli przekazywaniem danych do funkcji poprzez wskaźnik. Choć może brzmieć to nieco skomplikowanie, proces ten wcale nie jest przesadnie złożony, a dzięki niemu zyskujemy pewien olbrzymi atut.

Dla tego przykładu utworzyłem kolejny projekt o nazwie increase_variable, w którym napiszemy funkcję zwiększającą zmienną o jeden przy każdorazowym wywołaniu. Będzie to proste zadanie, bo zwiększanie wartości o jeden znamy już z wcześniejszych materiałów, tym razem jednak zrealizujemy to w nieco bardziej wyrafinowany sposób. Tak więc przejdźmy od razu do kodu.

#include <stdio.h>
#include "pico/stdlib.h"

uint8_t number = 0;

// A function that increases the value of the indicated variable by 1
void increase_variable(uint8_t *num) {
(*num)++;
}

int main() {
stdio_init_all();

while (true) {

printf("Before incrementation: %d\n", number);
increase_variable(&number);
printf("After inctementation: %d\n", number);
sleep_ms(500);
}

}

Program rozpoczynamy standardowo od dołączenia bibliotek oraz powołania zmiennej „number”, jej wartością początkową będzie zero, i to właśnie ją w dalszej części kodu będziemy chcieli modyfikować. Dalej utworzyłem funkcję increase_variable typu void, także nie będzie ona zawracać nam żadnej wartości, natomiast w argumentach oczekiwać będzie podania wskaźnika na dane typu uint8_t, którego dalej określać będziemy jako „num”. Funkcja realizować będzie tak naprawdę tylko jedną instrukcję inkrementacji wartości spod podanego w argumencie wskaźnika. Tak więc możemy oczekiwać, że po wywołaniu funkcji dana, do której podamy wskaźnik, zostanie zwiększona o jeden.

Główna część programu umieszczona została w pętli while. Wyświetlimy aktualny stan zmiennej number, następnie wywołujemy funkcję increase_variable i dzięki operatorowi wyłuskania adresu w argumencie podajemy miejsce w pamięci, w którym zapisany został number. Po skończeniu tej operacji program ponownie wyśle na serial monitor wartość zapisaną w naszej zmiennej.

Wartości number widoczne w monitorze portu szeregowego.

Po uruchomieniu programu na ekranie komputera powinny być widoczne kolejne wartości zmiennej number, zwiększające się co pół sekundy o jeden. Tym prostym programem zrealizowaliśmy przekazywanie danych do funkcji poprzez wskaźnik i mogłoby się wydawać, że to nic szczególnego, ale największym atutem tego rozwiązania jest coś, czego nie widzimy.

Zastanówmy się, co by się stało, gdybyśmy zdecydowali się umieścić w argumencie funkcji zwyczajną zmienną. W takim przypadku po przekazaniu do increase_variable zmiennej generowana byłaby jej kopia, na której to wykonałaby się operacja inkrementacji. I to dopiero z kopii moglibyśmy wyciągnąć zwiększaną o jeden wartość. Myślę, że już wicie, o co chodzi, w takim przypadku całkowicie niepotrzebnie tworzymy sobie dodatkową zmienną, której nie widzimy, ale która zajmuje miejsce w pamięci. Dzięki wskaźnikowi możemy tego uniknąć, podajemy tylko i wyłącznie adres, dzięki czemu cała operacja wykonywana jest na oryginalnej wartości. Takie rozwiązanie ma sporo zalet, przede wszystkim możemy manipulować wartościami zmiennych bezpośrednio, co zwiększa też efektywność kodu. Poza tym takie rozwiązanie jest bezpieczniejsze w przyszłości, gdy pisać będziemy własne biblioteki, z których korzystać będą mogli inni użytkownicy, uodparniamy się na wprowadzone przez nich nieoprawne dane. Nasz kod oczekiwać będzie tylko adresu, wykonamy własne operacje i nie będzie nas interesować, jak to wpłynie na kod osób trzecich, niejako umywamy ręce od potencjalnych problemów. To czy użytkownik poda odpowiedni wskaźnik, zależeć będzie tylko od niego. Choć wspomnieć muszę też, że w języku C coś takiego jak konflikt typów, który może pojawić się przy okazji przekazywania danych przez wskaźnik, rozwiązywany jest przez tak zwane rzutowanie, ale tym tematem zajmiemy się kiedy indziej.

Projekt maszyny stanów

Do tej pory nasze programy tworzyliśmy przede wszystkim z myślą o przetestowaniu konkretnej funkcjonalności, sterowaniu wyprowadzeniami RPI, obsłudze ADC czy też komunikacji po USB. Kody te analizowaliśmy pod względem funkcji, ale niekoniecznie działania jako całości, dlatego czas to zmienić. W tym podrozdziale przygotujemy nieco większy projekt, bazując na znanym w języku C koncepcie maszyny stanów. Jako że do Raspberry Pi Pico podłączone są trzy diody LED, możemy przygotować program symulujący sygnalizator świetlny. Jednak poza klasyczną zmianą z czerwonego na zielone i odwrotnie zaimplementujemy też specjalny tryb serwisowy, w którym pulsować będzie tylko żółty sygnalizator. Dla tego ćwiczenia przygotowałem kolejny projekt nazwany state_machine, jednak zanim przejdziemy do kodu, przyjrzyjmy się bliżej koncepcji maszyny stanów.

Graficzna reprezentacja kolejnych stanów sygnalizacji świetlnej.

Według książkowej definicji maszynę stanów określić możemy jako matematyczny model obliczeniowy, który reprezentuje system poprzez skończoną liczbę stanów, przejść między nimi oraz działań wywoływanych w wyniku tych przejść. Brzmi to nieco skomplikowanie i znacznie lepiej powiedzieć, że maszyna stanów to po prostu model, zazwyczaj graficzny, który opisuje krok po kroku działanie danego programu lub urządzenia. Takim właśnie modelem możemy opisać też funkcjonowanie ulicznego sygnalizatora świetlnego. Na początku aktywne jest światło czerwone, następnie przechodzi w żółte, aby po chwili uruchomiony został zielony sygnalizator, natomiast ostatnim stanem poprzedzającym powrót do czerwonego koloru jest światło żółte (jest to trójfazowy układ świetlny znany między innymi z USA i Francji, w Polsce stosowany jest model czterofazowy). Dysponując tego typu opisem, znacznie łatwiej jest przygotować realizujący to zadanie program. Oczywiście moglibyśmy napisać prosty kod, aktywujący kolejno odpowiednie diody LED, odpowiadające wspomnianemu sygnalizatorowi, ale w projekcie tym zastosujemy znacznie ciekawszą funkcję switch…case. Poza tym, aby nie było zbyt prosto, spróbujemy przygotować też specjalny tryb, w którym to potencjalny użytkownik drogi powinien stosować się do znaków pionowych, a wówczas sygnalizator będzie mrugać żółtym światłem.

Zanim jednak przejdziemy do kodu, poznać musicie kolejną nowinkę języka C, czyli tak zwany typ enumeracyjny, który pozwoli nam opisać stany, w jakich znaleźć może się sygnalizator.

Deklaracja typu wyliczeniowego.

Tak samo jak w programach umieszczamy zmienne, podobnie w jakiś sposób musimy nazwać i określić stany, w jakich znajdować może się sygnalizator świetlny. Najłatwiej jest odnosić się do aktywnego w danej chwili koloru, jeśli świecić będzie zielona dioda, nazwiemy ten stan zielonym, analogicznie wygląda to w przypadku światła czerwonego. Jak warto zauważyć, światło żółte świecić będzie w dwóch przypadkach, przejścia z koloru zielonego na czerwony lub odwrotnie, dlatego takie stany również będziemy musieli opisać. Poza tym pamiętać musimy też o specjalnym trybie serwisowym.

Poszczególne stany moglibyśmy zapisać w różnych zmiennych, ale istnieje znacznie lepszy sposób zwany typem enumeracyjnym. Do tej pory zmienne, które używaliśmy dedykowane były do konkretnych typów danych, przykładowo float przechowuje zawsze liczby zmiennoprzecinkowe. Enum, nazywany też typem wyliczeniowym pozwala nam w pewien sposób przygotować własną zmienną, która przechowywać będzie nasze własne wartości.

Typ wyliczeniowy deklarujemy w podobny sposób jak struktury. Zaczynamy od słów kluczowych „typedef enum”, a w nawiasie klamrowym umieszczamy własne typy wartości, które będzie mogła przyjąć, opisane przez enum zmienna. Na samym końcu dodajemy też nazwę, którą posługiwać będziemy się w dalszej części kodu.

W ten sposób stworzyliśmy niejako własny typ zmiennej o nazwie traffic_light_state_t, która przechowywać będzie aktualny stan naszej maszyny stanów, innymi słowy, stan, w jakim znajdować będzie się sygnalizator. Będzie to przykładowo STATE_GREEN, który odpowiadać będzie zielonemu sygnalizatorowi.

W tym przypadku podobnie jak przy strukturze zdecydowałem się od razu przedstawić wam nieco rozbudowaną deklarację enuma, ze słowem typedef na początku. Motywacja była tutaj identyczna, jak poprzednio i dzięki temu w dalszej części kodu nie będzie potrzeby stosowania przedrostka enum.

				
					#include "pico/stdlib.h"

#define GREEN_LED 0
#define YELLOW_LED 1
#define RED_LED 2
#define BUTTON_PIN 16

// States of the state machine for traffic lights
typedef enum {
    STATE_GREEN,
    STATE_YELLOW_TO_RED,
    STATE_RED,
    STATE_YELLOW_TO_GREEN,
    STATE_SERVICE
} traffic_light_state_t;

// Function to set the status of the LED
void set_traffic_light(traffic_light_state_t state) {
    gpio_put(GREEN_LED, state == STATE_GREEN);
    gpio_put(YELLOW_LED, state == STATE_YELLOW_TO_RED || state == STATE_YELLOW_TO_GREEN);
    gpio_put(RED_LED, state == STATE_RED);
}

int main() {
    stdio_init_all();

    gpio_init(GREEN_LED);
    gpio_set_dir(GREEN_LED, GPIO_OUT);

    gpio_init(YELLOW_LED);
    gpio_set_dir(YELLOW_LED, GPIO_OUT);

    gpio_init(RED_LED);
    gpio_set_dir(RED_LED, GPIO_OUT);

    gpio_init(BUTTON_PIN);
    gpio_set_dir(BUTTON_PIN, GPIO_IN);
    gpio_pull_up(BUTTON_PIN);  // Włączenie wewnętrznego rezystora podciągającego

    // Initial signaling status
    traffic_light_state_t state = STATE_GREEN;
    bool service_mode = false;

    while (true) {
        // Checking the status of the button
            if (gpio_get(BUTTON_PIN) == 0) {
                service_mode = !service_mode;
                while (gpio_get(BUTTON_PIN) == 0);  // Waiting for the button to be released

                if (service_mode) {
                    state = STATE_SERVICE;
                } else {
                    state = STATE_GREEN; // Return to normal state after service mode
                }
        }

        
        switch (state) {
            case STATE_GREEN:
                set_traffic_light(STATE_GREEN);
                sleep_ms(5000); // Zielone światło świeci przez 5 sekund
                if (!service_mode) state = STATE_YELLOW_TO_RED;
                break;

            case STATE_YELLOW_TO_RED:
                set_traffic_light(STATE_YELLOW_TO_RED);
                sleep_ms(2000); // Yellow light illuminates for 2 seconds
                if (!service_mode) state = STATE_RED;
                break;

            case STATE_RED:
                set_traffic_light(STATE_RED);
                sleep_ms(5000); // Red light illuminates for 5 seconds
                if (!service_mode) state = STATE_YELLOW_TO_GREEN;
                break;

            case STATE_YELLOW_TO_GREEN:
                set_traffic_light(STATE_YELLOW_TO_GREEN);
                sleep_ms(2000); // Yellow light illuminates for 2 seconds
                if (!service_mode) state = STATE_GREEN;
                break;

            case STATE_SERVICE:
                gpio_put(GREEN_LED, 0);
                gpio_put(RED_LED, 0);
                // Blinking yellow light
                gpio_put(YELLOW_LED, 1);
                sleep_ms(500);
                gpio_put(YELLOW_LED, 0);
                sleep_ms(500);
                break;
        }
    }

}

				
			

Program, jak to zwykle bywa, zaczynamy od dołączenia biblioteki oraz zdefiniowania pinów, do których podłączone są diody LED oraz przycisk. W kolejnym kroku umieszczona została deklaracja typu enumeracyjnego, którą opisałem powyżej, a także funkcja, ustawiająca odpowiedni stan diod LED, ale do nie przejdziemy za moment.

Na początku głównej funkcji main znalazła się inicjalizacja wykorzystywanych przez nas wyprowadzeń Raspberry Pi Pico oraz ustawienie początkowego stanu naszej sygnalizacji. Tworzymy tutaj dwie zmienne state oraz service_mode. Pierwsza z nich jest wartością naszego specjalnego typu, dlatego przypisać możemy do niej tylko wybrane stany, domyślnym będzie STATE_GREEN, czyli aktywne zielone światło. Zmienna typu bool o nazwie service_mode posłuży nam do aktywowania trybu serwisowego sygnalizacji, jej domyślnym stanem będzie false, ponieważ nie ma powodu, aby stan ten był aktywny od razu po starcie programu.

Działanie sygnalizacji świetlnej opiszemy w pętli while. W jej wnętrzu musi znaleźć się obsługa przycisku, który aktywuje tryb serwisowy, innymi słowy, zmieni wartość state na STATE_SERVICE oraz zależność, która pozwoli zmieniać i odpowiednio reagować na kolejne stany sygnalizacji świetlnej. Na pierwszy ogień weźmy obsługę przycisku, która zrealizowana jest za pomocą krótkiego fragmentu kodu na początku nieskończonej pętli while. Na sam początek sprawdzić musimy stan przycisku funkcją gpio_get, jeśli odczytana wartość będzie równa zero, czyli przycisk zostanie wciśnięty, a wyprowadzenie RPI zwarte do masy, możemy zmienić na przeciwny wartość zmiennej service_mode. Korzystamy tutaj z operatora „!”, przypisując zmiennej stan odwrotny do poprzedniego. W kolejnym kroku musimy zaczekać na zwolnienie przycisku, korzystamy tutaj ze sprytnej konstrukcji opartej na pętli while. Po raz drugi sprawdzamy stan przycisku, i jeśli ten nadal jest wciśnięty wpadamy do pętli. W jej wnętrzu funkcją warunkową zależną od zmiennej service_mode, przypisujemy odpowiedni stan do state. Jeśli zmienna będzie logiczną jedynką, aktywujemy STATE_SERVICE, w przeciwnym razie uruchamiamy domyślny stan światła zielonego. Pętla ta aktywna będzie do czasu zwolnienia przycisku, po którym zawsze będziemy w trybie serwisowym lub domyślnym.

Zasada użycia funkcji switch…case.

Funkcją, która tak naprawdę zdefiniuje działanie naszej sygnalizacji, będzie wspomniany już wcześniej switch…case. Jest to ciekawa konstrukcja, którą moglibyśmy nazwać w pewien sposób warunkową, zależną od pojedynczej zmiennej. Po słowie kluczowym „switch” umieszczamy zmienną, od której zależny będzie wykonywany dalej kod. W naszym przypadku będzie to „state”, ponieważ właśnie tutaj przechowywany jest aktualny stan sygnalizacji świetlnej. W dalszej części reagujemy na każdą możliwą wartość „state”. Domyślnie po uruchamianiu programu wykonany zostanie fragment kodu opisany poniżej case STATE_GREEN. W stanie tym wywołamy funkcję set_traffic_light i w jej argumencie umieścimy STATE_GREEN. Gdy cofniemy się na górę programu, gdzie funkcja ta została opisana zauważymy, że uruchomienie diody LED zależne jest właśnie od wartości przekazanej w argumencie. Gdy znajdzie się tam STATE_GREEN aktywowana zostanie zielona dioda. Po wywołaniu funkcji ustawiającej odpowiedni stan na wyprowadzeniu RPI program odczekuje pięć sekund, a następnie sprawdza, czy przypadkiem nie został aktywowany tryb serwisowy, jeśli nie to do wartości state przypisana zostaje wartość STATE_YELLOW_TO_RED, ponieważ właśnie w takim stanie znajdować powinna się teraz sygnalizacja. Zauważcie, że w tym miejscu zastosowałem ciekawą konstrukcję z operatorem if pozbawionym nawiasów klamrowych. Jeśli kod wykonywany w warunku jest tylko pojedynczą funkcją możemy umieścić go w tej samej linii z poleceniem if, co nieco skraca nasz program i polepsza jego czytelność. Po wykonaniu tego kodu funkcja switch…case zostaje przerwana przez słowo kluczowe „break”, a program wraca na początek pętli while.

Tutaj po raz kolejny sprawdzany jest stan przycisku i, gdy ten nie został wciśnięty, program przechodzi do switch…case, tym razem jednak według stanu zmiennej state, który zmienił się przy poprzednim przebiegu pętli, mikrokontroler wykona kod spod wartości case STATE_YELLOW_TO_RED. Instrukcje tutaj wyglądają analogicznie jak poprzednio i po zmianie aktywnej diody LED, do „state” przypisany zostaje kolejny stan, tak aby przy kolejnym przebiegu pętli mógł zostać wykonany kod skojarzony z STATE_RED. W ten właśnie sposób działa funkcja switch…case, pozwala ona reagować na różne stany tej samej zmiennej.

W ostatnim warunku switch…case opisana została reakcja na STATE_SERVICE. Jeśli przycisk zostanie wciśnięty, to właśnie taka wartość przypisana zostanie do zmiennej state i wówczas żółta dioda LED zacznie mrugać.

Po uruchamianiu kodu możemy zobaczyć nasz miniaturowy sygnalizator świetlny w czasie pracy. Pierwszym stanem jest światło zielone, które przechodzi kolejno w żółte i czerwone tak jak ma to miejsce w rzeczywistości. Gdy przytrzymamy przez moment przycisk, aktywujemy tryb sygnalizatora, w którym będzie on mrugał światłem żółtym. Wciskając przycisk ponownie, możemy wrócić do trybu domyślnego.

Wiem, że projekt maszyny stanów może wydawać się nieco skomplikowany, dlatego, jeśli nie czujesz go w stu procentach, zachęcam do ponownej analizy i własnych eksperymentów, możesz zmieniać pewne wartości i stany, a następnie sprawdzać, jak reaguje program. Pamiętaj, że wszystko zależne jest tak naprawdę od zmiennej state, to ona definiuje, jaki aktualnie stan ma sygnalizator i jaki będzie kolejny. I to na jej podstawie działa funkcja switch…case, która wysterowuje odpowiednio diody, wywołując polecenie set_traffic_light.

Kilka słów na koniec…

Za nami jeden ze zdecydowanie większy artykułów w tej serii. Poznaliśmy dzisiaj całkiem sporo nowości – tablice, struktury, przekazywanie danych przez wskaźnik, a także uruchomiliśmy projekt sygnalizatora świetlnego bazujący na maszynie stanów. Po dziesięciu materiałach mamy już solidne podstawy języka C, które testowaliśmy na rzeczywistych przykładach, dlatego w kolejnym artykule zaczniemy prawdziwe eksperymenty i podłączymy do naszego Raspberry cyfrowy czujnik światła.

Źródła:

  • https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf
  • https://datasheets.raspberrypi.com/picow/pico-w-datasheet.pdf
  • https://www.raspberrypi.com/products/rp2040/
  • https://www.raspberrypi.com/documentation/microcontrollers/raspberry-pi-pico.html

Jak oceniasz ten wpis blogowy?

Kliknij gwiazdkę, aby go ocenić!

Średnia ocena: 4.8 / 5. Liczba głosów: 12

Jak dotąd brak głosów! Bądź pierwszą osobą, która oceni ten wpis.

Podziel się:

Picture of Rafał Bartoszak

Rafał Bartoszak

Współpracujący z Botlandem elektronik, który dzieli się swoją wiedzą w  internecie. Entuzjasta systemów cyfrowych, układów programowalnych i mikroelektroniki. Pasjonat historii, ze szczególnym naciskiem na wiek XX.

Zobacz więcej:

Mateusz Mróz

Ranking lutownic

Szukasz idealnej lutownicy do swojego warsztatu? Sprawdź nasz ranking najlepszych modeli! Przedstawiamy różne opcje dla majsterkowiczów i profesjonalistów.

Sandra Marcinkowska

Fototranzystor – zastosowanie

Fototranzystor to niezwykle wszechstronny element optoelektroniczny, który reaguje na światło, przekształcając je w sygnał elektryczny. W naszym artykule dowiesz się, gdzie i jak znajduje zastosowanie

Masz pytanie techniczne?
Napisz komentarz lub zapytaj na zaprzyjaźnionym forum o elektronice.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Ze względów bezpieczeństwa wymagane jest korzystanie z usługi Google reCAPTCHA, która podlega Polityce prywatności i Warunkom użytkowania.