Obecnie coraz więcej urządzeń elektronicznych budowanych jest w oparciu różnorodne mikrokontrolery. Są to funkcjonalnie bardzo uniwersalne elementy, na bazie których poprzez odpowiednie oprogramowanie sterujące pracą takiego mikrokontrolera możliwe jest zbudowanie dowolnego urządzenia. Jakakolwiek funkcja zostanie przyporządkowana budowanemu urządzeniu, zawsze w jego strukturze można wyodrębnić część odpowiedzialną za sterowanie jego działaniem. Przykładowym elementem pełniącym taką funkcję w klasycznych komputerach jest klawiatura. Podobnie, w każdym budowanym urządzeniu zawierającym mikrokontroler można wyodrębnić zestaw przycisków, za pomocą których istnieje możliwość wpływania na działanie urządzenia. Najprostszym rozwiązaniem sprzętowym jest przyłączenie do wyprowadzenia portu mikrokontrolera przycisku, którego naciśnięcie łączy go z potencjałem masy. W sytuacji, gdy przycisk nie jest naciśnięty na wejściu występuje stan wymuszany przez rezystor (rysunek 1). Z punktu widzenia mikrokontrolera, pokazany przycisk jest zespołem cyfrowym, w którym stan naciśnięcia jest odczytywany z portu jako zero logiczne. W sytuacji, gdy przycisk nie jest naciśnięty, mikrokontroler na odpowiednim wejściu odczytuje stan logicznej jedynki. Ta prosta realizacja klawiatury ma jedną istotną wadę. Każde naciśnięcie (oraz zwolnienie) przycisku nie daje „czystego” sygnału. Z każdą zmianą stanu związane jest wygenerowanie ciągu impulsów. Ich liczba jest zależna od konstrukcji i rodzaju materiału z jakiego wykonany jest sam przycisk i właściwie należy ją uznać za losową (rysunek 2). Ten występujący efekt nazywany jest dzwonieniem styków i trwa kilka milisekund. Z punktu widzenia człowieka ten czas jest niezauważalny, natomiast z punktu widzenia mikrokontrolera wygląda to odmiennie. Mając na uwadze, że przeciętny mikrokontroler wykonuje kilka milionów instrukcji na sekundę, to w czasie fazy związanej z dzwonieniem styków zostanie wykonanych kilkaset tysięcy instrukcji. Oznacza to, że każda zmiana stanu zaistniała w tej fazie przejściowej może zostać rozpoznana jako ciąg szybkich naciśnięć i zwolnień przycisku. Temu nieoczekiwanemu zjawisku można przeciwdziałać. Istnieje wiele rozwiązań pozwalających na jego eliminację. Najprostszym rozwiązaniem jest „przeczekanie” tej fazy. Sprowadza się to do próbkowania stanu generowanego przez przycisk z odpowiednią częstotliwością pozwalającą nie zauważać szybkich zmian stanu a dodatkowo traktować, że został osiągnięty stan stabilny w sytuacji, gdy w czasie kilu kolejnych próbkowań nie nastąpiła jego zmiana.
Rozpatrując tematykę dotyczącą mikrokontrolerów można zadać proste pytanie: co wspólnego ma programowanie mikrokontrolerów z elektroniką cyfrową? Okazuje się, że całkiem sporo. Należy pamiętać, że układy logiczne, cyfrowe mają znacząco dłuższą historii niż sztuka tworzenia oprogramowania dla mikrokontrolerów. Bardzo burzliwy rozwój w ostatnich czasach techniki mikroprocesorowej doprowadził do rozwoju dziedziny wiedzy dotyczącej sposobu budowania i tworzenia algorytmów. Algorytm to nic innego jako szczegółowy (nawet bardzo) przepis na postępowanie, który prowadzi do uzyskania określonej funkcjonalności lub efektu końcowego. Są one wszechobecne w naszym życiu a często wielu z nas nie zdaje sobie z tego sprawy. Wystarczy spojrzeć na przepisy kulinarne, które w rzeczywistości są zapisem algorytmu. Zawierają one informacje związane z: co, ile, w jakiej kolejności, w jaki sposób itp. prowadzące do uzyskania przykładowo pysznego deseru. To jest nic innego jak algorytm uzyskania deseru. Skoro wielu ludzi dzieli się między sobą algorytmami kulinarnymi, sądzę, że nic nie stoi na przeszkodzie do dzielenia się algorytmami trochę z bardziej inżynierskich działań.
Podglądając rozwiązania, jakich dopracowała się technika układów logicznych, można wiele z nich przenieść do świata związanym z programowaniem mikrokontrolerów. Taką inspiracją będącą dobrym wzorcem zapożyczonym z układów logicznych są automaty synchroniczne. To dość tajemnicze pojęcie spróbuję wyjaśnić na prostym przykładzie, jednak wcześniej należy wyjaśnić znaczenie przymiotnika „synchroniczny”. Oznacza on, że coś dzieje się w układzie w ściśle określonych chwilach czasu, niejako jednocześnie (czyli synchronicznie) z pewnym sygnałem sterującym, którym jest sygnał taktujący. Skoro jest to układ logiczny, to jego wyjścia będą zmieniać stan zgodnie (synchronicznie) z sygnałem zegarowym. Przykładem automatu może być dwubitowy licznik synchroniczny. Układy kombinacyjne wypracowywują nowy stan do zapisu w rejestrze. Wpis ten następuje w wyniku wystąpienia zbocza sygnału zegarowego. Całą akcję zmiany stanu na wyjściu rejestru wywołuje zdarzenie: właściwe zbocze sygnału zegarowego, po czym znowu następuje długa, błoga chwila ciszy i spokoju.
Analogią do sygnału taktującego jest realizacja instrukcji zawartych w pewnej funkcji. Tak jak w układach synchronicznych całość jest taktowana stałym sygnałem zegarowym, tak tutaj elementem pobudzającym wykonanie instrukcji zawartych w określonym algorytmie jest reakcja mikrokontrolera na przerwanie od zegara/licznika. Przy odpowiedniej konfiguracji tego zespołu można uzyskać stałe, równomierne pobudzanie. Ma to istotne znaczenie, gdyż obsługa prostej klawiatury jako zespołu przycisków jest uwarunkowana czasowo.
Przed prezentacją algorytmu ważnym elementem jest zrozumienie istotnej różnicy wynikającej z architektury mikrokontrolerów. Generalnie możliwe są dwa warianty, w oparciu o które są one budowane: architektura harwardzka (której przykładem są mikrokontrolery z rodziny ARV z modelem ATMEGA32) oraz architektura von Neumanna (której przykładem są mikrokontrolery z rodziny ARM z przykładowym modelem LPC2138). Różnica polega na stworzeniu różnych przestrzeni adresowych przewidzianych dla kodu programu oraz danych. W przypadku mikrokontrolerów AVR są dwie oddzielne przestrzenie: przestrzeń przeznaczona na kod programu (identyfikowana jako pamięć FLASH) oraz przestrzeń przeznaczoną na dane (zmienne programu, stos w programie w tym również zawierają się wszystkie rejestry sterujące i konfiguracyjne mikrokontrolera). Bezpośrednim skutkiem tego rozdzielenia przestrzeni jest oddzielny zestaw instrukcji maszynowych do pobierania danych z przestrzeni RAM (jak odczyt i zapis zawartości zmiennych) i przestrzeni FLASH (jak odczyt stałych umieszczonych w pamięci FLASH). W mikrokontrolerach opartych na architekturze von Neumanna również rozróżnia się pamięć na kod programu (pamięć FLASH) jak i pamięć zmiennych (umiejscowiona w RAM) jednak należą one do tej samej przestrzeni adresowej, czyli z punktu widzenia działania mikrokontrolera do odczytu zawartości zmiennej znajdującej się w pamięci RAM oraz do odczytu wartości stałej zapisanej w pamięci FLASH używane są dokładnie te same instrukcje maszynowe. Z kolei kompilatory języka C są „filozoficznie” przystosowane do architektury von Neumanna. Oznacza to, że kompilator nie „zna pojęcia rozdzielenia” przestrzeni adresowych i wygeneruje kod programu odpowiadający przypadkowi jakby zmienna była umieszczona w przestrzeni RAM. Z tego względu, w programie (dla procesorów o architekturze harwardzkiej → mikrokontrolery AVR) należy zastosować specjalne „triki”, które spowodują właściwą realizację kodu programu. W celu optymalizacji wykorzystania pamięci RAM w mikrokontrolerach (a pamięci RAM często jest mało), obszary stałych programu, a takim obszarem jest przykładowo obszar tablic do przekodowania stanu przycisków na generowane kody znaków, są umieszczone w przestrzeni pamięci programu (pamięci FLASH). Do „zmuszenia” kompilatora by dany obszar został umieszczony w przestrzeni programu służy kwalifikator PROGMEM (którego przykładowe użycie pokazuje poniższy fragment programu).
Kod: Zaznacz cały
static KeybEncoderRecT LongEncodeTabe [ LongEncodeTabeSize ] PROGMEM =
{
/* 00 */ { ( 1 << L0Key ) , LongKey0Code } ,
/* 01 */ { ( 1 << L1Key ) , LongKey1Code } ,
/* 02 */ { ( 1 << L2Key ) , LongKey2Code } ,
/* 03 */ { ( 1 << L3Key ) , LongKey3Code } ,
/* 04 */ { ( 1 << L0Key ) | ( 1 << L1Key ) , LongKey01Code } ,
/* 05 */ { ( 1 << L0Key ) | ( 1 << L2Key ) , LongKey02Code } ,
/* 06 */ { ( 1 << L0Key ) | ( 1 << L3Key ) , LongKey03Code } ,
/* 07 */ { ( 1 << L1Key ) | ( 1 << L2Key ) , LongKey12Code } ,
/* 08 */ { ( 1 << L1Key ) | ( 1 << L3Key ) , LongKey13Code } ,
/* 09 */ { ( 1 << L2Key ) | ( 1 << L3Key ) , LongKey23Code } ,
} ;
Tych problemów nie ma w przypadku architektury von Neumanna, gdyż to rozwiązanie jest wręcz naturalne dla kompilatora C. Oczywiście w tym przypadku również warto pokusić się o „optymalizację” zajętości pamięci RAM. Sprowadza się to do zastosowania specjalnego kwalifikatora przy deklaracji zmiennej. Oczywiście ma to również pewne implikacje przy używaniu takich zmiennych, jednak z racji jednolitej adresacji zmiennych w pamięci RAM jak i stałych obszarów w obrębie kodu programu, nie są potrzebne specjalizowane funkcje do sięganie do tych zmiennych.
By uzyskać dużą uniwersalność rozwiązania w oprogramowaniu, kod obsługi jest wyniesiony do oddzielnego pliku, który może być dołączony do programu jako „moduł biblioteczny”. Oczekiwane wymagania funkcjonalne w stosunku do oprogramowania modułu są określone w następujący sposób:
- program rozpoznaje typowe naciśnięcie przycisku (w sensie czasu naciśnięcia),
- program rozpoznaje „przedłużone” naciśnięcie przycisku (naciśnięcie i przytrzymanie naciśnięcia przez znacząco dłuższy czas),
- program dopuszcza jednoczesne naciśnięcie dowolnego zestawu przycisków z typowym naciśnięciem (jednoczesne w kategorii człowieka jako użytkownika, bo z punktu widzenia elektroniki czas pomiędzy naciśnięciem pierwszego przycisku a drugiego to prawie „wieczność” oraz z praktycznego punktu widzenia warto ograniczyć się do pary przycisków, chociaż algorytm nie narzuca takiego ograniczenia),
- program dopuszcza jednoczesne naciśnięcie dowolnej pary przycisków z przedłużonym czasem trwania naciśnięcia,
- nie jest realizowana funkcja autorepetycji.
Rozwiązanie bazuje na realizacji wielostanowego automatu sterującego, takiego ekwiwalentu synchronicznych automatów cyfrowych. W fizycznych układach cyfrowych, synchroniczność automatu implikuje, że układ zmienia swoje stany z jakimś taktem zegarowym. To samo zagadnienie w ujęciu oprogramowania oznacza cykliczne wywoływanie funkcji obsługi. Jeżeli to wywołanie będzie pochodzić od przerwań generowanych przez licznik/zegar (chociaż nie jest to absolutnie konieczne, możliwe jest dowolne zorganizowanie cykliczności), to uzyskuje się rozwiązaniu w pełni autonomiczne. Uwzględniając powyższe założenia, bazując na klawiaturze 4-przyciskowej (przyciski identyfikowane jako KEY0 .. KEY3) możliwe jest rozróżnienie 20 kombinacji, są to:
- standardowe naciśnięcie przycisku KEY0,
- standardowe naciśnięcie przycisku KEY1,
- standardowe naciśnięcie przycisku KEY2,
- standardowe naciśnięcie przycisku KEY3,
- standardowe naciśnięcie przycisku KEY0 i KEY1,
- standardowe naciśnięcie przycisku KEY0 i KEY2,
- standardowe naciśnięcie przycisku KEY0 i KEY3,
- standardowe naciśnięcie przycisku KEY1 i KEY2,
- standardowe naciśnięcie przycisku KEY1 i KEY3,
- standardowe naciśnięcie przycisku KEY2 i KEY3,
- przedłużone naciśnięcie przycisku KEY0,
- przedłużone naciśnięcie przycisku KEY1,
- przedłużone naciśnięcie przycisku KEY2,
- przedłużone naciśnięcie przycisku KEY3,
- przedłużone naciśnięcie przycisku KEY0 i KEY1,
- przedłużone naciśnięcie przycisku KEY0 i KEY2,
- przedłużone naciśnięcie przycisku KEY0 i KEY3,
- przedłużone naciśnięcie przycisku KEY1 i KEY2,
- przedłużone naciśnięcie przycisku KEY1 i KEY3,
- przedłużone naciśnięcie przycisku KEY2 i KEY3.
Stan automatu → Znaczenie
KeybIdle → Stan jałowy automatu, automat pozostaje w tym stanie tak długo, aż pojawi się zdarzenie naciśnięcia jakiegokolwiek przycisku klawiatury. Wykrycie stanu aktywnego powoduje przejście do stanu oczekiwania na stabilny stan naciśniętego przycisku (eliminacja dzwonienia styków).
KeybWaitOnPress → Stan odczekania związany z eliminacją dzwonienia styków przy naciśnięciu przycisku. Jeżeli po upływie odpowiedniego czasu nadal będzie występował stabilny stan naciśniętego przycisku, to automat przechodzi do stanu obsługi (KeybReady) lub wraca do stanu jałowego (KeybIdle).
KeybReady → Stan gotowości związany z rzeczywistym wykryciem naciśniętego przycisku. Automat bezwarunkowo przechodzi do stanu rozróżnienia naciśnięcia standardowego lub przedłużonego (KeybActive).
KeybActive → Stan przewidziany do rozróżnienia standardowego lub przedłużonego naciśnięcia przycisku, który polega na pomiarze czasu trwania stanu naciśnięcia. Jeżeli przed upływem odpowiedniego czasu przycisk zostanie zwolniony, to przyciśnięcie jest standardowe i automat przechodzi do eliminacji dzwonienia styków związanego z puszczeniem przycisku. W przeciwnym wypadku, zdarzenie jest interpretowane jako naciśnięcie przedłużone.
KeybWaitOnFree → Stan odczekania związany ze zwolnieniem przycisku.
Z przedstawianym rozwiązaniem związana jest pewna kwestia „filozofii w programowaniu”: co by tu zrobić by się nie narobić. Może warto stworzyć kawałek oprogramowania o dużym stopniu niezależności od czegokolwiek. Napisać moduł oprogramowania, coś na kształt biblioteki, który będzie dołączany jako #include. Zawierałby ten moduł w sobie wszystko co jest niezbędne: zestaw funkcji i własne zmienne. Jest raczej oczywiste, że jest to nie możliwe, gdyż występują różnorodne rodziny mikrokontrolerów (pomijając wspomnianą wyżej kwestię architektury: harwardzka, von Neumanna) co implikuje odmienną obsługę portów, do których przyłączona jest klawiatura. A co, gdyby newralgiczne fragmenty wynieść poza tworzony moduł? Pozostałaby w nim jedynie części niezależne, czysty, abstrakcyjny algorytm. Nawet można w pewnym stopniu uzyskać niedostępność zmiennych lokalnych modułu, gdyż jeżeli elementy (zmienne, funkcje) zostaną opatrzone kwalifikatorem static, to nie będą widoczne na zewnątrz modułu (brak static jest równoznaczne z public). Przecież język C oferuje takie możliwości: można utworzyć zmienną będącą funkcją o określonym typie parametrowym. Pod tą zmienną można podstawić dowolną funkcję (byle był zgodny typ funkcji i lista parametrów wywołania). Dodatkowo, łatwo można zbadać, czy taka zmienna ma podstawioną wartość czy nie, co oznaczałoby, że jakaś funkcjonalność jest aktywowana lub nie. Jeżeli zmienne typu funkcyjnego będą miały zawartość NULL (w języku C odpowiada stałej 0), to znaczy, że dana funkcjonalność nie jest aktywowana. Przecież wywołanie takiej funkcji w większości przypadków mikroprocesorów spowoduje reset. Jeżeli zmienna będzie przechowywać inną wartość, to znaczy, że wskazuje na funkcję obsługi.
Ka koncepcja prowadzi do utworzenia niepublicznej zmiennej typu jakiejś struktury. Struktura ta zawierałaby wszystkie niezbędne zmienne modułu oraz zmienne będące wskazaniami do wydzielonych fragmentów obsługi. Zmienna tego typu zostanie określona jako instancja. Filozoficznie jest to zbliżone do koncepcji komponentu w językach obiektowych.
Do "manipulowania" zawartości zmiennych z prywatnej instancji modułu obsługi klawiatury zostają wyeksportowane z tego modułu odpowiednie funkcje. Przy takim podejściu, to nawet nie jest konieczne umieszczenie typu struktury w pliku nagłówkowym z wyjątkiem niezbędnych typów (głównie typów zmiennych stanowiących wskazaniami do funkcji wynikających z defragmentacji algorytmu, taki koncepcyjny odpowiednik metody z filozofii komponentów). Z punktu widzenia programu, do którego dołączony jest moduł obsługi klawiatury, jego zawartość zaczyna przypominać "czarną skrzynkę", nie jest istotne co ma w środku, istotne stają się jedynie "złączki" prowadzące do środka.