Kurs STM32L4 – #11 – kolorowy wyświetlacz TFT (SPI)

Kurs STM32L4 – #11 – kolorowy wyświetlacz TFT (SPI)

Za nami podstawy pracy z SPI na STM32L4. Pora, aby zająć się obsługą wyświetlacza graficznego. Przy okazji poruszymy również temat stosu.

Przejdziemy od omówienia sterownika ST7735S, przez liczne testy, optymalizację kodu i DMA, aż po wykorzystanie gotowej biblioteki graficznej.

Czego dowiesz się z tej części kursu STM32L4?

Wykonując ćwiczenia z tej części kursu, dowiesz się, jak wykorzystać interfejs SPI w STM32L4 do obsługi kolorowego wyświetlacza graficznego ze sterownikiem ST7735S. Oczywiście nie ograniczamy się tylko do gotowego kodu, lecz przejdziemy przez kolejne etapy pracy z takim modułem – od poznania rejestrów sterownika, przez pierwsze (niezdarne) uruchomienie wyświetlacza, aż po zoptymalizowaną wersję, która będzie działała w sposób szybki i atrakcyjny dla oka.

Programowanie hobbystyczne vs. zawodowe

Użytkownicy Arduino są przyzwyczajeni do tego, że jeśli chcą np. uruchomić wyświetlacz graficzny, to pobierają dedykowaną bibliotekę, dołączają ją do projektu i gotowe – w tle dzieje się jakaś magia, coś nie działa lub całość działa bardzo wolno, ale działa. Typowy użytkownik Arduino nie zdaje sobie raczej sprawy, co dzieje się w tle i jak właściwie przebiega komunikacja z takim wyświetlaczem graficznym.

Osoby, które chcą programować systemy embedded zawodowo, powinny jednak być poziom wyżej. Oczywiście mogą one korzystać z gotowych bibliotek, ale w razie potrzeby powinny też samodzielnie (na podstawie dokumentacji) napisać dla siebie taką bibliotekę lub np. zoptymalizować programy, które są dostępne w sieci – i właśnie takie podejście omówimy w tej części naszego kursu STM32L4.

Wyświetlacz graficzny TFT ze sterownikiem ST7735S

Podczas eksperymentów z tej części kursu wykorzystamy moduł firmy Waveshare. Jest to wyświetlacz TFT o przekątnej 1,8″, wyposażony w matrycę o rozdzielczości 128 × 160 px – każdy piksel może świecić w jednym z 65 tysięcy kolorów.

Wyświetlacz graficzny TFT używany podczas tej części kursu STM32L4

Wyświetlacz graficzny TFT używany podczas tej części kursu STM32L4

Jednak wyświetlacz to nie tylko „zbiór pikseli” – taki moduł jest też wyposażony w popularny sterownik ST7735S. Układ ten pośredniczy między naszym mikrokontrolerem a matrycą. Sterowanie pracą tego modułu ogranicza się więc do wysyłania odpowiednich komend do sterownika ST7735S – to on bierze już na siebie faktyczne włączanie poszczególnych pikseli.

Podczas pracy z tak rozbudowanym modułem warto mieć pod ręką trzy źródła:

Szczególnie warto przynajmniej zajrzeć do ostatniego z plików, czyli dokumentacji sterownika – widać tam, że obsługa wyświetlacza to dużo bardziej skomplikowana sprawa, niż mogłoby się wydawać.

Gotowe zestawy do kursów Forbota

 Komplet elementów  Gwarancja pomocy  Wysyłka w 24h

Zamów zestaw elementów i wykonaj ćwiczenia z tego kursu! W komplecie płytka NUCLEO-L476RG oraz m.in. wyświetlacz graficzny, joystick, enkoder, czujniki (światła, temperatury, wysokości, odległości), pilot IR i wiele innych.

Zamów w Botland.com.pl »

Podłączenie wyświetlacza

W trakcie ćwiczeń z tej części kursu będzie dużo pracy programistycznej, ale strona elektroniczna będzie wyjątkowo prosta – wystarczy, że podłączymy wyświetlacz do NUCLEO-L476RG (nie będzie potrzebna nawet płytka stykowa).

Do komunikacji wykorzystamy oczywiście SPI, ale oprócz tego potrzebne będą też dodatkowe sygnały, które są opisane na odwrocie naszego wyświetlacza. Na podstawie wcześniejszych linków uda nam się szybko rozszyfrować opis wszystkich wyprowadzeń modułu:

    • VCC – napięcie zasilające moduł (w naszym przypadku 3,3 V)
    • GND – masa
    • DIN – linia danych wejściowych SPI (czyli MOSI) – łączymy z PC3
    • CLK – linia zegarowa SPI (czyli SCK) – łączymy z PB10
    • CS – linia CS interfejsu SPI – łączymy z PB12
    • DC – linia ustalająca, czy wysyłamy dane (stan wysoki), czy komendy (stan niski) – łączymy z PB11
    • BL – podświetlanie wyświetlacza – zostawiamy niepodłączone
    • RST – reset sterownika wyświetlacza – łączymy z PB2

    Jak widać, wyświetlacz obsługuje tylko jednokierunkową komunikację, bo brak tu linii MISO. Nie jest to nic nadzwyczajnego – takie układy również występują. Dla nas najważniejszy wniosek jest taki, że nie możemy odczytywać danych z wyświetlacza (nie sprawdzimy więc np. aktualnych ustawień).

    Schemat montażowy dla wszystkich ćwiczeń z tej części kursu

    Schemat montażowy dla wszystkich ćwiczeń z tej części kursu

    Wyprowadzenie sterujące podświetleniem wyświetlacza, czyli linię BL, zostawiliśmy niepodłączoną. Moduł ma wbudowany rezystor podciągający tę linię do zasilania, więc wyświetlacz będzie świecił z maksymalną jasnością. Gdybyśmy chcieli sami sterować podświetleniem, moglibyśmy podłączyć BL do mikrokontrolera, aby regulować płynnie jasność za pomocą kanału PWM.

    Konfiguracja projektu z wyświetlaczem

    Pora przejść do programowania. Zaczynamy od utworzenia typowego projektu: STM32L476RG, który pracuje z maksymalną częstotliwością, tj. 80 MHz. Włączamy debugger, następnie aktywujemy SPI2 w trybie Transmit Only Master (pin PB10 zostanie opisany jako SPI2_SCK, a PC3 jako SPI2_MOSI).

    Podobnie jak w przypadku ekspandera MCP23S08, tutaj też musimy sprawdzić maksymalną prędkość komunikacji, którą obsługuje nasz moduł – jest to 15 MHz. Ustawiamy więc poniższe parametry dla SPI:

    • Frame Format: Motorola
    • Data Size: 8 Bits
    • First Bit: MSB First
    • Prescaler (for Baud Rate): 8
    • NSSP  Mode: Disabled

    Opcja NSSP oznacza NSS Pulse, czyli tryb, w którym stan linii NSS (zanegowany Slave Select) jest na chwilę zmieniany na wysoki między transmisją kolejnych bajtów. Nie używamy sprzętowego sterowania tą linią, więc u nas ten tryb powodowałby tylko drobne opóźnienia.

    Pozostałe opcje zostawiamy z domyślnymi wartościami. Warto zwrócić tylko uwagę na szare pole Baud Rate. Jego wartość, na podstawie częstotliwości taktowania i wybranego przez nas preskalera, oblicza CubeMX – dlatego wynosi ona: 80 / 8 = 10 MBit/s.

    Interfejs SPI jest już skonfigurowany. Teraz musimy dodać do projektu trzy piny w trybie GPIO_Output. Tym razem będą to PB2 (z etykietą LCD_RST), PB11 (jako LCD_DC) oraz PB12 (jako LCD_CS). Ostatnia linia, czyli LCD_CS, powinna mieć zmieniony początkowy stan na wysoki (za pomocą GPIO output level).

    Konfiguracja SPI2 i GPIO dla wyświetlacza TFT

    Konfiguracja SPI2 i GPIO dla wyświetlacza TFT

    Na koniec, tak jak w poprzedniej części kursu, zmieniamy konfigurację projektu w CubeMX, tak aby każdy moduł peryferyjny posiadał oddzielne pliki źródłowe i nagłówkowe. Po wygenerowaniu kodu możemy od razu przejść do testów naszego wyświetlacza – pora napisać naszą „prostą” bibliotekę.

    Własna biblioteka dla wyświetlacza TFT

    Praca z wyświetlaczem jest dość zawiła, od razu warto zatem wydzielić cały kod do osobnych plików. Na początku tworzymy pliki lcd.c oraz lcd.h. Drugi z nich uzupełniamy natychmiast poniższym kodem:

    Jak pamiętamy, #pragma once sprawia, że nasz plik nagłówkowy nie będzie włączany więcej niż raz do innych plików. Dodaliśmy też informację o funkcji lcd_init, która będzie odpowiedzialna za inicjalizację wyświetlacza. W przypadku wyświetlaczy TFT jest to niestety nieco skomplikowane zadanie, więc zajmiemy się nim teraz, a później będzie już tylko łatwiej.

    Inicjalizacja wyświetlacza

    Kod inicjalizujący wyświetlacz umieszczamy w pliku lcd.c. Otwieramy go i zaczynamy od dodania plików nagłówkowych, które będą potrzebne:

    Tak samo jak podczas obsługi ekspandera, tutaj też komunikacja z wyświetlaczem graficznym będzie polegała na wysłaniu adresu układu, numeru rejestru, a następnie wysłaniu zawartości rejestru. Wyświetlacz nie obsługuje adresów urządzenia, ma natomiast odpowiednik rejestrów, które widzieliśmy wcześniej – w tym przypadku są to tzw. komendy sterujące wyświetlaczem.

    Opis jednej z komend sterownika ST7735S

    Opis jednej z komend sterownika ST7735S

    Opis wszystkich komend znajdziemy w długiej dokumentacji sterownika, ale dla nas najważniejsze jest, że komenda to po prostu jednobajtowy identyfikator, którego wywołanie sprawi, że sterownik zmieni jakieś ustawienie wyświetlacza lub przygotuje go do odbioru danych, które mają zostać wyświetlone.

    Żeby nie używać w kodzie „magicznych” liczb, możemy zdefiniować odpowiednie stałe. Dodajemy więc do pliku lcd.c następujące linie (wstawiamy je do tego pliku, a nie do lcd.h, bo nie będą potrzebne w żadnym innym module naszego programu):

    Kolejny krok to napisanie funkcji, dzięki której będziemy mogli wysyłać do wyświetlacza komendy. Najważniejsza różnica w porównaniu ze znaną nam poprzednio komunikacją to dodatkowa linia DC (podczas wysyłania kodu komendy musimy wystawić na niej stan niski).

    Możemy już napisać prostą funkcję, która wyśle komendę do wyświetlacza: na pinie DC ustawiamy stan niski, następnie taki sam stan ustawiamy na linii CS, co rozpocznie komunikację przez SPI. W kolejnym kroku możemy wykorzystać funkcję HAL_SPI_Transmit, dzięki której wyślemy bajt z kodem komendy, a po zakończeniu transmisji przywracamy stan wysoki na linii DC.

    Poniższą funkcję umieszczamy w pliku lcd.c:

    Nie jest to najszybsza możliwa wersja takiego kodu, ale ma ona dwie zalety: działa i jest stosunkowo łatwa w zrozumieniu. Komentarza może wymagać jedynie to, że funkcja została zadeklarowana jako statyczna. Oznaczanie funkcji jako statycznych sprawia, że nie są one widoczne dla innych plików. To bardzo ważne, bo dzięki temu nie pojawia się ryzyko konfliktu nazw, a nasze moduły są lepiej od siebie odizolowane. Funkcja lcd_cmd będzie używana tylko w pliku lcd.c i dlatego jest statyczna.

    Potrzebna nam będzie jeszcze funkcja do wysyłania danych (czyli np. tego, co ma być wyświetlone na naszym małym ekranie). Oczywiście nie chodzi tu o funkcję, która przyjmuje teksty i grafiki – mowa tu o znacznie niższym poziomie, bo o wysyłaniu pojedynczych bajtów.

    Funkcja ta będzie podobna do poprzedniej – jedyna różnica to ustawianie stanu wysokiego na linii DC:

    Mając te dwie funkcje, moglibyśmy już napisać cały program sterujący wyświetlaczem, ale inicjalizacja konieczna do rozpoczęcia działania modułu byłaby bardzo długa. Aby nieco uprościć sobie zadanie, wybierzemy bardziej skomplikowaną, ale o wiele krótszą wersję.

    Kody komend oraz dane przesyłane do wyświetlacza są liczbami 8-bitowymi. Aby uprościć program, utworzymy jeszcze funkcję lcd_send, która jako parametr będzie przyjmowała liczbę 16-bitową. Mniej znaczący bajt będzie zawierał wartość do wysłania, natomiast bardziej znaczący będzie określał rodzaj transmisji – jeśli będzie zerem, to znak, że wysyłamy jedną z danych, a jeśli jedynką – to komendę. 

    Mniej i bardziej znaczący bajt

    Mniej i bardziej znaczący bajt

    Dodatkowo definiujemy makro CMD, które będzie ustawiało dziewiąty bit liczby na 1 (oczywiście jest to realizowane za pomocą operatora bitowego – tym razem alternatywy bitowej). Dzięki temu łatwo sprawimy, że dana wartość zostanie wysłana później przez funkcję lcd_send jako komenda. Brzmi to dość zawile, ale za chwilę wszystko powinno się wyjaśnić.

    Operacja koniunkcji służąca sprawdzaniu wartości bitu

    Operacja koniunkcji służąca sprawdzaniu wartości bitu

    Nasze makro i funkcja będą wyglądały tak jak poniżej. Ważne jest, aby funkcja lcd_send została dodana do pliku lcd.c fizycznie pod wcześniejszymi funkcjami statycznymi. Jeśli dodamy ją wyżej, to kompilator poinformuje nas o błędach, bo podczas kompilowania nie będzie umiał odnaleźć funkcji lcd_cmd oraz lcd_data, które są używane wewnątrz naszej nowej funkcji lcd_send.

    Teraz musimy przystąpić do tego, co najtrudniejsze, czyli zdefiniowania początkowej konfiguracji wyświetlacza. Poniższe komendy i parametry pochodzą z przykładowych programów dołączonych do modułu przez producenta. Wygląda to strasznie, ale na spokojnie – po pierwsze nie trzeba teraz tego analizować, a po drugie w przyszłości i tak bardzo łatwo da się odszyfrować te zapiski, zaglądając do dokumentacji sterownika.

    Tablica poleceń inicjalizacji, którą wstawiamy do pliku lcd.c, wygląda następująco:

    Jest to tablica jednowymiarowa, która została sformatowana w sposób czytelny dla człowieka. Mamy tu po lewej stronie ciąg komend sterujących pracą wyświetlacza, które zostały wstawione do makra CMD (aby były wysyłane jako komendy). Dalej mamy wartości, które mają zostać wysłane do wyświetlacza po komendzie (może być to bajt lub kilka bajtów). Całość należy więc rozumieć następująco:

    1. wydaj komendę FRMCTR1 i prześlij dane 0x01, 0x2c, 0x2d,
    2. wydaj komendę FRMCTR2 i prześlij dane 0x01, 0x2c, 0x2d,
    3. wydaj komendę FRMCTR3 i prześlij dane 0x01, 0x2c, 0x2d, 0x01, 0x2c, 0x2d,
    4. itd.

    Nie będziemy omawiać teraz tych wartości, bo nie ma to nic wspólnego z głównym tematem kursu, czyli z naszymi mikrokontrolerami STM32L4. Zainteresowanych zachęcamy po prostu do lektury noty katalogowej używanego tu kontrolera. Teraz wystarczy wiedzieć, że te dane konfigurują wyświetlacz, pozwalają na wyregulowanie kolorów (np. korektę gamma), można dzięki nim obrócić obraz o 90, 180 lub 270 stopni, można też zmienić liczbę obsługiwanych kolorów (my korzystamy z trybu 16-bitowego).

    Teraz możemy napisać już funkcję inicjalizującą działanie wyświetlacza, czyli lcd_init. Jej kod dodajemy do pliku lcd.c. Funkcja ta będzie wywoływana z pliku main, więc nie oznaczamy jej jako statycznej.

    Na początku ustawiamy stan niski na LCD_RST i czekamy 100 ms, co daje sterownikowi wyświetlacza czas na reset – czas ten został zaczerpnięty z przykładów producenta modułu, więc prawdopodobnie trwa to krócej (można z tym eksperymentować). Następnie w pętli for wysyłamy początkową konfigurację zapisaną w tablicy init_table – to właśnie dlatego potrzebowaliśmy funkcji pomocniczej lcd_send, która „automatycznie” rozróżnia, czy wysyłamy komendę, czy dane.

    Wyjaśnienia może wymagać dziwny warunek pętli:

    Chcemy przejść przez wszystkie elementy tablicy i moglibyśmy po prostu policzyć, ile ich jest, ale byłoby to niełatwe i podatne na błędy, ponieważ po każdej zmianie init_table trzeba by uaktualniać zakres pętli. Wywołanie sizeof(init_table) zwraca liczbę bajtów zajmowaną przez init_table. Jednak każdy element tej tablicy zajmuje więcej niż jeden bajt, dlatego otrzymaną wartość dzielimy przez wielkość elementu obliczoną jako sizeof(uint16_t).

    Następnie wyświetlacz jest budzony z trybu uśpienia i włączany. W tym momencie jest gotowy do działania… czyli musieliśmy się tyle napracować tylko po to, by wyświetlacz się włączył. Jeszcze nie umiemy nawet nic na nim wyświetlić. Sprawdźmy jednak, co się stanie po wgraniu takiego kodu.

    Musimy jeszcze dodać do pliku main.c wywołanie naszej nowej funkcji. Najpierw dołączamy plik nagłówkowy lcd.h, czyli dodajemy na początku main.c linię:

    Następnie przed rozpoczęciem pętli głównej programu wywołujemy lcd_init:

    Po skompilowaniu i wgraniu tego kodu nasz wyświetlacz ożyje – zobaczymy na nim zupełnie losowe piksele. To już bardzo dobry znak – wiemy, że komunikacja działa.

    Efekt działania programu inicjalizującego wyświetlacz TFT na STM32L4

    Efekt działania programu inicjalizującego wyświetlacz TFT na STM32L4

    Teraz przejdziemy do tego, co łatwiejsze i przyjemniejsze, czyli wyświetlania informacji. Zaczniemy od funkcji, która rysuje wypełnione prostokąty.

    Rysowanie prostokątów

    Zaczynamy od wprowadzenia zmian w pliku nagłówkowym lcd.h. Dodamy tam definicje stałych, które będą reprezentowały kolory – dzięki temu nie będziemy musieli obliczać tych wartości w programie. Do tego dodajemy deklarację funkcji rysującej prostokąt lcd_fill_box, którą za chwilę napiszemy – funkcja ta będzie przyjmowała jako parametr współrzędne punktu startowego, szerokość i wysokość oraz kolor naszego prostokąta.

    Teraz przechodzimy do pliku lcd.c i zaczynamy od napisania kolejnej funkcji pomocniczej. Mamy już funkcję lcd_data, która wysyła jeden bajt danych, ale teraz będziemy chcieli wysyłać 16-bitowe słowa, dlatego piszemy funkcję, która „rozbija” za pomocą przesunięcia bitowego „dużą” liczbę i wysyła ją do wyświetlacza jako dwie liczby 8-bitowe (jedna po drugiej). Najpierw wysyłamy bardziej znaczący bajt, a dopiero później mniej znaczący – takiego formatu oczekuje od nas sterownik ST7735S.

    Powyższa funkcja jest potrzebna, ponieważ sterownik wyświetlacza oczekuje od nas czasami wartości 16-bitowych (np. kod koloru), ale za pomocą SPI możemy przesyłać pojedyncze bajty (8-bitów). Sami musimy więc zadbać o to, aby liczba 16-bitowa została „rozbita” i wysłana jako dwie wartości 8-bitowe. Sterownik wyświetlacza będzie wiedział, że musi je połączyć w jedną, 16-bitową liczbę.

    Podczas inicjalizacji wyświetlacza TFT wysyłaliśmy całe mnóstwo poleceń przy użyciu tablicy init_table i funkcji lcd_send. Do narysowania naszego prostokąta będziemy potrzebowali raptem trzech komend, więc będziemy wysyłać je za pomocą lcd_cmd oraz właśnie napisanej funkcji lcd_data16.

    W tym przypadku do sterowania wyświetlaczem wykorzystamy następujące komendy:

    • CASET – ustawia początkową i końcową kolumnę rysowanego obszaru,
    • RASET – ustawia początkowy i końcowy wiersz rysowanego obszaru,
    • RAMWR – rozpoczyna przesyłanie danych do zdefiniowanego obszaru.

    Pierwsze dwie komendy definiują okno, do którego zapisujemy dane przy użyciu trzeciego polecenia. Kolumny odpowiadają współrzędnym poziomym, czyli osi X, natomiast wiersze to oś Y. Możemy więc napisać kolejną pomocniczą funkcję, tym razem do ustawienia okna rysowania:

    Po wysłaniu komendy CASET wysyłamy dwie wartości: kolumnę początkową i końcową obszaru rysowania. Podobnie w przypadku RASET, ale tym razem przesyłane są wiersze. W efekcie działania tej funkcji nasz sterownik wyświetlacza będzie wiedział, że za chwilę otrzyma informacje o tym, na jaki kolor mają się włączać kolejne piksele w określonym przez nas obszarze.

    Nie możemy jeszcze przetestować tej funkcji, ale gdybyśmy to zrobili, okazałoby się, że rysowany obraz jest nieco przesunięty. Wyświetlacz ma rozdzielczość 160 × 128 px, ale kontroler ST7735S obsługuje rozdzielczość do 162 × 132 px. Można byłoby więc oczekiwać, że dwa wiersze i cztery kolumny na końcu są nieużywane, jest jednak nieco inaczej. Okazuje się, że musimy sami do współrzędnej X dodać 1, a do Y dodać 2 – inaczej obraz będzie przesunięty.

    Dodajemy zatem dwie definicje i zmieniamy funkcję, aby pasowała do sprzętu:

    Przy okazji warto tutaj zwrócić uwagę na zalety używania funkcji i własnych modułów bibliotecznych. Tak niskopoziomowe szczegóły jak przesunięcie podłączenia matrycy raczej nie powinny być widoczne nigdzie poza sterownikiem wyświetlacza – każdy programista oczekuje punktu początkowego o współrzędnych (0, 0), a nie (1, 2).

    Teraz możemy nareszcie napisać funkcję rysującą prostokąt. Najpierw wywoła ona lcd_set_window, aby ustawić obszar rysowania, a następnie wyśle komendę RAMWR i dane do wyświetlenia – w tym przypadku „danymi” jest po prostu kolor, który ma się pojawić na wszystkich pikselach. Dane te wysyłamy ciągiem. Sterownik wypełnia pierwszą linię naszego okna, następnie drugą itd.

    Kod tej funkcji może wyglądać następująco:

    Czas wrócić do pliku main.c i sprawdzić, czy nasza nowa funkcja działa zgodnie z oczekiwaniami. Od razu wywołajmy ją kilka razy – spróbujmy narysować osiem prostokątów w różnych kolorach:

    Po uruchomieniu zobaczymy, że nasz program jest powolny, ale działa. Warto pamiętać, że nasz kod rysujący prostokąty nie ma w sobie żadnych opóźnień, a wyświetlenie takiego ekranu testowego było tak powolne, że gołym okiem widać było pojawianie się kolejnych prostokątów.

    Efekt działania programu rysującego kolorowe prostokąty

    Efekt działania programu rysującego kolorowe prostokąty

    Za nami sporo pracy, której byśmy nawet nie zauważyli, gdybyśmy chcieli skorzystać z jakiejś gotowej biblioteki. Dzięki takiemu podejściu mamy jednak znacznie większą kontrolę nad działaniem układu i możemy pokusić się o kilka ciekawych eksperymentów i dalszą optymalizację kodu.

    Włączanie pojedynczych pikseli

    Najczęściej, gdy rozpoczynamy przygodę z jakimkolwiek wyświetlaczem graficznym, zależy nam na tym, aby mieć możliwość włączania pojedynczych pikseli. Kiedy opanujemy tę umiejętność, cała reszta będzie już prosta – przecież każda grafika to właśnie zbiór pojedynczych pikseli.

    W przypadku naszego modułu jest to jak najbardziej możliwe, ale niestety bardzo powolne. Problemem jest komenda RAMWR, która zawsze zaczyna rysowanie od lewego górnego rogu obszaru zdefiniowanego za pomocą CASET i RASET. Oznacza to, że chcąc zapalić jeden piksel, musimy wykonać tyle co przy narysowaniu prostokąta o wymiarach 1 × 1. Możemy więc napisać teraz kod, który wykorzysta naszą funkcję lcd_fill_box do rysowania pikseli.

    Najpierw do pliku lcd.h dodajemy deklarację funkcji lcd_put_pixel:

    Następnie w pliku lcd.c piszemy bardzo prostą implementację:

    Aby wypróbować działanie tego mechanizmu, wewnątrz funkcji main dodajemy pętlę, która narysuje na wyświetlaczu dwie skrzyżowane ze sobą linie:

    Po uruchomieniu programu zobaczymy, że nasza funkcja działa, ale rysowanie nie odbywa się zbyt szybko. Nasz dotychczasowy kod był pisany z myślą o czytelności i nauce, a nie optymalizacji. Moglibyśmy więc bardzo wiele w nim poprawić, ale zanim to zrobimy, warto przez chwilę zastanowić się, na co możemy liczyć w najlepszym przypadku.

    Test rysowania pojedynczych pikseli na wyświetlaczu TFT

    Test rysowania pojedynczych pikseli na wyświetlaczu TFT

    Ustawienie pozycji piksela, do którego będziemy się odwoływać, wymaga wysłania komendy CASET oraz RASET – każda z nich oznacza wysłanie aż 5 bajtów przez SPI. Wysyłanie danych rozpoczynamy komendą RAMWR – sama komenda to 1 bajt, a na koniec wysyłamy 2 bajty opisujące kolor piksela.

    Jak łatwo policzyć, sprawność tego sposobu sterowania to około 15%, a to oznacza, że nawet jeśli spędzimy godziny na optymalizacji tej wersji kodu, to i tak nie osiągniemy spektakularnych efektów. Za chwilę rozwiążemy ten sposób inną metodą (za pomocą bufora), ale na razie przetestujemy jeszcze możliwość wyświetlania dowolnych grafik za pomocą funkcji włączającej pojedyncze piksele.

    Wyświetlanie obrazów

    Mamy napisaną procedurę do rysowania prostokątów, która wysyła przez SPI ciągle te same dane. Jeśli będziemy podmieniać zawartość wysyłanych pikseli, to będziemy w stanie wyświetlić na ekranie grafikę.

    Do tego ćwiczenia można wybrać praktycznie dowolny plik graficzny – jego rozdzielczość nie może jednak przekraczać rozdzielczości wyświetlacza, tj. 128 × 160 px. My w trakcie dalszych ćwiczeń posłużymy się tym plikiem z fragmentem logotypu Forbota.

    Fragment logotypu

    Fragment logotypu

    Najprościej będzie zamienić obraz na tablicę bajtów w języku C i dołączyć ją do naszego programu. W Internecie znajdziemy sporo skryptów oraz aplikacji wykonujących takie zadanie – jedną z nich jest np. imageconverter. Jest to narzędzie online, którego obsługa sprowadza się do wybrania pliku, podania nazwy, formatu danych (C Array) oraz zapisania pliku uzyskanego tą metodą.

    Zamiana grafiki na tablicę danych

    Zamiana grafiki na tablicę danych

    W wyniku konwersji powstaje długi plik w języku C, którego początek wygląda następująco:

    Nie używamy tutaj biblioteki lvgl, więc tym, co potrzebujemy, jest zawartość tablicy forbot.c_map. Jeśli przyjrzymy się danym nieco dokładniej, zobaczymy, że zawiera ona kilka wersji logo. Musimy odszukać format pasujący do naszego wyświetlacza, czyli:

    Ten fragment po prostu kopiujemy do naszego projektu. Wracamy do STM32CubeIDE i tworzymy plik z kodem – nazwijmy go np. forbot_logo.c. Kasujemy jego domyślną zawartość i wstawiamy do środka tablicę, do wnętrza której wstawiamy to, co skopiowaliśmy z powyższego pliku:

    Teraz możemy wrócić do naszego pliku lcd.h i dodać deklarację nowej funkcji:

    Kod funkcji dodajemy do pliku lcd.c i będzie on bardzo podobny do funkcji rysującej prostokąty:

    Podczas rysowania prostokątów wysyłaliśmy kolor piksela jako 16-bitową liczbę, dlatego używaliśmy funkcji lcd_data16. Tym razem zamiast stałego koloru piksela będziemy wysyłać kolory pikseli, które razem utworzą na ekranie nasz obraz. Jedyna różnica jest taka, że dane te są teraz zapisane w tablicy jako bajty, wysyłamy je więc funkcją lcd_data, a w związku z tym, że na każdy piksel przypadają dwa bajty, to wysyłamy dwa razy więcej danych (stąd dodatkowe mnożenie razy 2 w warunku pętli for). 

    Teraz możemy wrócić do pliku main.c i przetestować nasze zmiany. Do rysowania obrazu potrzebujemy danych, które są w zmiennej forbot_logo, zdefiniowanej w pliku forbot_logo.c. Jak wiemy, zalecanym postępowaniem w trakcie dzielenia programów na moduły jest umieszczanie deklaracji zmiennych i funkcji w pliku nagłówkowym, a deklaracji w pliku źródłowym. Jednak dla kompilatora ten podział nie ma żadnego znaczenia. Program będzie w pełni poprawnie działał, jeśli napiszemy w main.c:

    Dyrektywa include po prostu wstawia plik na swoje miejsce, więc włączenie forbot_logo.c zadziała jak kopiuj-wklej, po prostu skopiuje zawartość pliku forbot_logo w to miejsce pliku main.c. Pokazujemy tę „sztuczkę”, ponieważ jest czasem używana w programach dla mikrokontrolerów i dobrze obrazuje, że biblioteki w języku C nie są niczym magicznym – to kopiuj-wklej wykonywane na początku kompilacji.

    Właśnie dlatego w plikach nagłówkowych umieszczamy #pragma once albo #ifndef/#endif. Inaczej preprocesor mógłby wstawiać te same pliki kilka razy, co spowalniałoby kompilację, a mogłoby nawet dojść do zapętlenia włączanych plików.

    Skoro mamy już dane obrazu, możemy napisać wywołanie funkcji lcd_draw_image:

    Po skompilowaniu i uruchomieniu najnowszej wersji programu zobaczymy już logo Forbota.

    Test wyświetlania grafik na wyświetlaczu TFT

    Test wyświetlania grafik na wyświetlaczu TFT

    Optymalizacja rysowania

    Programy z kursu służą głównie do nauki, zależy nam więc na prostocie i czytelności – w związku z tym nie są one zawsze optymalne. Tym razem zajmiemy się jednak dodatkowo optymalizacją. Dotychczas wszystkie dane do wyświetlacza wysyłaliśmy za pomocą funkcji lcd_data. Dla przypomnienia – jej treść jest następująca:

    Jak widzimy, dla każdego bajta sterujemy liniami DC, CS oraz wywołujemy HAL_SPI_Transmit. Ale jak już wiemy (z poprzedniej części kursu), funkcja ta może przesłać wiele bajtów naraz, moglibyśmy więc przesłać nawet wszystkie dane obrazu za jednym wywołaniem.

    Zmieńmy naszą funkcję lcd_draw_image na następującą postać:

    Teraz wysyłamy jednokrotnie komendę RAMWR, następnie ustawiamy linie DC i CS na transfer danych i jednym wywołaniem HAL_SPI_Transmit przesyłamy od razu całą grafikę z logo Forbota.

    Gdy przetestujemy nowy program, powinniśmy od razu zobaczyć różnicę. Teraz rysowanie logotypu działa bardzo szybko, o wiele szybciej niż rysowanie punktów czy prostokątów. Widzimy więc, że nasz wyświetlacz bardzo szybko rysuje obrazy, które ma już zapisane w swojej wewnętrznej pamięci, ale rysowanie pojedynczych punktów zajmuje mu sporo czasu.

    Buforowanie obrazu

    Umiemy efektywnie rysować gotowe obrazki, co przetestowaliśmy, wysyłając logo Forbota. Czyli równie dobrze moglibyśmy w pamięci mikrokontrolera tworzyć sobie „obrazek” dla całego wyświetlacza i taki gotowiec przesyłać do kontrolera – pominiemy wtedy mozolny etap rysowania punkt po punkcie.

    Sprawdźmy, czy taka metoda zadziała. Na początek zadeklarujmy sobie bufor dla małego obrazu, czyli tablicę o wymiarach np. 64 na 64. Jako typ wybierzemy uint16_t. Dodajmy do pliku main.c deklarację tablicy jednowymiarowej o 4096 elementach (rozmiar został zapisany jako 64*64 tylko w celu łatwiejszej interpretacji dla człowieka – to nadal jest tablica jednowymiarowa):

    Teraz możemy wypełnić nasz bufor np. kolorem białym, czyli po prostu wpisujemy w miejsce każdego elementu tej tablicy kod koloru białego, a potem wyświetlamy ten sam „obraz” w kilku miejscach na ekranie, aby zobaczyć, czy wszystko przebiegło poprawnie.

    Program powinien działać poprawnie. Rysujemy kilka prostokątów, aby pokazać różnicę w prędkości działania – teraz już wyraźnie widać, o ile wolniej działa nasz lcd_fill_box od lcd_draw_image

    Test wyświetlania białych kwadratów

    Test wyświetlania białych kwadratów

    Niestety nasza funkcja ma pewną wadę. Spróbujmy zmienić kolor i narysować niebieskie prostokąty:

    Gdy przetestujemy nasz program, zobaczymy, że zamiast niebieskiego uzyskaliśmy kolor zielony. I wcale nie wynika to z błędnej deklaracji stałej BLUE – problem ten wynika z kolejności danych.

    Test wyświetlania niebieskich kwadratów

    Test wyświetlania niebieskich kwadratów

    Wyświetlacz oczekuje, że najpierw prześlemy starszy bajt, a później młodszy. Dlatego nasz kod wysyłający dane 16-bitowe wyglądał następująco:

    Teraz wysyłamy dane tak jak są zapisane w pamięci. Nasz układ pracuje w tzw. trybie little-endian, więc w pamięci najpierw jest zapisany młodszy bajt, a następnie starszy. Dlatego gdy wysyłamy dane, bajty koloru są ustawione odwrotnie, niż oczekuje tego wyświetlacz.

    Definicja w programie mówi, że niebieski to 0x001f – w pamięci naszego mikrokontrolera jest to zapisane jako 0x1f, 0x00 i w takiej kolejności dane zostaną wysłane przez SPI. Wyświetlacz natomiast potraktuje 0x1f jako wyższy, a 0x00 jako niższy bajt, czyli łącznie odbierze on wartość 0x1f00 (zielony).

    Spróbujmy zmienić kod zapisujący dane w test_image na następujący:

    Jeśli przetestujemy program, to zobaczymy, że działa zgodnie z oczekiwaniami. Niestety te przesunięcia bitowe nie są szczególnie czytelne, możemy zatem poprawić nasz program, zmieniając stałe opisujące kolory albo wywołując funkcję __REV16.

    Test wyświetlania niebieskich kwadratów (poprawiona wersja)

    Test wyświetlania niebieskich kwadratów (poprawiona wersja)

    Jest to właściwie specjalne makro, które zostanie zastąpione jedną instrukcją asemblera – robi ona dokładnie to, co chcieliśmy, czyli zamienia kolejność bajtów w 16-bajtowej zmiennej. Okazuje się, że jest to tak popularna czynność, że doczekała się nawet własnej instrukcji sprzętowej.

    Powyższą informację warto potraktować jako ciekawostkę, bo w przypadku stałych, które opisują kolory, możemy po prostu zmienić kolejność bajtów w definicjach – i tak właśnie postąpimy w naszym przypadku.

    Nowa wersja biblioteki

    Teraz, gdy wiemy, jak szybko rysować dane na wyświetlaczu TFT, możemy napisać wydajniejszą wersję naszej małej biblioteki. Nowa wersja będzie zoptymalizowana do szybkiego rysowania pikseli, bo to jest tutaj najważniejsze. Rysowanie linii, okręgów czy prostokątów to operacje, które wykonujemy za pomocą tego samego mechanizmu – to kwestia ułożenia pojedynczych pikseli w odpowiedni sposób.

    Zacznijmy od zmiany pliku nagłówkowego lcd.h. Jego nowa wersja wygląda następująco:

    Na początku dodaliśmy definicję stałych LCD_WIDTH i LCD_HEIGHT, które oczywiście odpowiadają rozdzielczości wyświetlacza. Zamieniliśmy też kolejność bajtów w definicjach kolorów. W nowej wersji bufor ekranu umieścimy w module lcd.c, dzięki czemu pozostała część programu nie będzie musiała „znać” tych niskopoziomowych szczegółów. Dodaliśmy również nową funkcję lcd_copy, która będzie po prostu kopiowała bufor ekranu do wyświetlacza.

    Teraz możemy przejść do zmian w pliku lcd.c. Zaczynamy od dodania zmiennej z buforem obrazu. Jest to po prostu jednowymiarowa tablica, która posiada tyle elementów, aby można było w niej zapisać dane o kolorach wszystkich pikseli.

    Następnie zmieniamy treść lcd_put_pixel – zamiast wysyłać informację o jednym pikselu prosto do wyświetlacza, zapisujemy jego kolor do bufora. Musimy tylko przeliczyć współrzędne X i Y na miejsce w buforze, który jest jednowymiarowy:

    Potrzebujemy jeszcze funkcji lcd_copy – jej treść odpowiada funkcji lcd_draw_image. Ustawiamy okno rysowania na cały wyświetlacz, rozpoczynamy transmisję i kopiujemy za jednym razem cały bufor:

    Na koniec usuwamy nieużywane funkcje, czyli lcd_draw_image oraz lcd_fill_box. Biblioteka jest teraz o wiele mniejsza niż poprzednio, ale i tak powinniśmy sprawdzić, czy działa. Przechodzimy więc do pliku main.c i piszemy przykładowy program. Może to być właściwie dowolny kod – np. poniższy, który zapełnia cały wyświetlacz pikselami o różnym kolorze (stąd operacje matematyczne w miejscu koloru):

    Po kompilacji tego programu na wyświetlaczu powinien być widoczny kolorowy gradient. Rysowanie takiego efektu na wyświetlaczu powinno być wręcz błyskawiczne.

    Wyświetlanie kolorowego gradientu za pomocą nowej wersji biblioteki

    Wyświetlanie kolorowego gradientu za pomocą nowej wersji biblioteki

    Potrafimy szybko wyświetlać pojedyncze piksele. A co z liniami, okręgami i prostokątami lub tekstami? Tutaj do wyboru mamy dwie opcje: albo rozbudowujemy dalej swoją bibliotekę, albo wykorzystujemy naszą niskopoziomową bibliotekę do tego, aby uruchomić jakąś inną, gotową bibliotekę graficzną, która ma już w sobie np. funkcje pozwalające na wyświetlanie tekstu. Taką sytuacją teraz się zajmiemy!

    Wykorzystanie gotowej biblioteki graficznej

    W sieci znaleźć można liczne biblioteki, które pomagają w budowie interfejsów graficznych – od dużych komercyjnych bibliotek po małe rozwiązania hobbystyczne. Tym razem zdecydowaliśmy się pokazać, jak można wykorzystać jedną z bibliotek open source.

    Nasz wybór padł na stosunkowo prostą bibliotekę HAGL (ang. Hardware Agnostic Graphics Library), która wspiera m.in. rysowanie różnych kształtów i wyświetlanie tekstów – lista wszystkich funkcji jest dokładnie opisana w dokumencie tego projektu na GitHubie. Warto podkreślić, że biblioteka ta wcale nie była pisana pod kątem mikrokontrolerów STM32L4. Autor tego projektu przygotował ją ogólnie z myślą o systemach wbudowanych, w tym m.in. o Raspberry Pi Pico czy ESP32. Została ona jednak przygotowana w taki sposób, aby można było ją łatwo wykorzystać również na innych platformach.

    Zaczynamy od pobrania najnowszej wersji kodu biblioteki z GitHuba – metod jest kilka, ale najprościej po prostu kliknąć na zielony przycisk Code, a następnie wybrać opcję Download ZIP. Rozpocznie się wtedy pobieranie archiwum zawierającego kod biblioteki oraz przykłady, z których będziemy korzystać.

    Pobieranie kodu biblioteki HAGL z GitHuba

    Pobieranie kodu biblioteki HAGL z GitHuba

    Teraz interesują nas dwa katalogi z archiwum: include oraz src. Pierwszy zawiera pliki nagłówkowe, a w drugim znajdziemy oczywiście kody. Plików tych jest dość dużo – nie będziemy dodawać teraz każdego ręcznie do projektu, zrobimy to hurtowo.

    Wracamy do STM32CubeIDE i klikamy prawym przyciskiem myszy w eksploratorze projektów w nasz aktualny projekt. Następnie wybieramy opcję New > Source Folder (nie „Folder”, tylko „Source Folder”). Następnie podajemy nazwę dla nowego katalogu, np. hagl.

    Tworzenie katalogu dla plików biblioteki

    Tworzenie katalogu dla plików biblioteki

    Po tej operacji powinniśmy widzieć nowy katalog w eksploratorze projektów. Teraz możemy skopiować do niego katalogi include oraz src z pobranego archiwum. Możemy je np. przeciągnąć z eksploratora systemu Windows wprost do odpowiedniego miejsca w naszym środowisku.

    Nowa struktura projektu (katalogi include i src wewnątrz hagl)

    Nowa struktura projektu (katalogi include i src wewnątrz hagl)

    Trzeba pamiętać, że to nie koniec! Samo utworzenie katalogu i dodanie plików to jeszcze za mało. Teraz musimy jeszcze poinstruować nasze środowisko, aby korzystało z plików nagłówkowych, które właśnie dodaliśmy do projektu. W tym celu przechodzimy do ustawień: Project > Properties > C/C++ Build > Settings > Tool Settings > MCU GCC Compiler > Include paths, a następnie w obszarze Include paths klikamy przycisk Add (ikona z zielonym plusem). W nowym oknie klikamy Workspace, a potem wskazujemy tam folder include z biblioteki hagl.

    Dodawanie informacji o nowym katalogu

    Dodawanie informacji o nowym katalogu

    Jeśli spróbujemy skompilować program, to zobaczymy sporo błędów – głównie ze względu na brak plików sdkconfig.h oraz hagl_hal.h, których potrzebuje biblioteka HAGL.

    Dodanie brakujących plików

    Zaczynamy od utworzenia pliku z ustawieniami. Tym razem robimy to w obrębie naszego projektu, czyli w Core\Inc. Klikamy tam prawym przyciskiem myszy i tworzymy plik nagłówkowy sdkconfig.h, do wnętrza którego wklejamy tylko jedną, poniższą linię.

    Plik ten jest więc właściwie pusty, a to dlatego, że nie musimy i nie chcemy zmieniać nic w ustawieniach biblioteki HAGL. Wystarczą nam wartości domyślne, ale i tak musieliśmy utworzyć stosowny plik, aby kompilator nie zgłaszał błędów.

    Następnie tworzymy drugi plik nagłówkowy, o nazwie hagl_hal.h. Jest on znacznie ważniejszy, bo łączy bibliotekę HAGL z naszą niskopoziomową biblioteką DIY, która pozwala na szybkie rysowanie pikseli. Wstawiamy do niego następującą zawartość:

    Stałe DISPLAY_WIDTH, DISPLAY_HEIGHT oraz DISPLAY_DEPTH informują bibliotekę o rozdzielczości naszego ekranu oraz głębi kolorów (w bitach). Definiujemy również typ color_t, który będzie używany do reprezentowania koloru (w praktyce jest to uint16_t). Najważniejsza jest funkcja hagl_hal_put_pixel – to właśnie ona będzie wywoływana przez bibliotekę HAGL w celu narysowania punktu na ekranie. Jej wywołanie w sprytny sposób (za pomocą dyrektywy define) zastępujemy naszą funkcją lcd_put_pixel.

    Dla testu kompilujemy teraz nasz projekt – jeśli wszystko przebiegło poprawnie, to kompilacja powinna zakończyć się sukcesem (możliwe są tylko ostrzeżenia o niewykorzystywaniu parametrów przez kod w bibliotece HAGL). Brak błędów to znak, że możemy przejść do przetestowania biblioteki HAGL.

    Program demonstracyjny

    Funkcje, dzięki którym możemy rysować na ekranie, są opisane w dokumentacji biblioteki – raczej każdy poradzi sobie z wykorzystaniem tamtych informacji, więc ograniczymy się teraz tylko do jednego kodu demonstracyjnego.

    Wracamy do pliku main.c i na początku zamiast włączania pliku lcd.h wstawiamy:

    Następnie przed pętlą główną dodajemy poniższy kod, który wykorzystuje dwie funkcje z biblioteki HAGL. Pierwsza odpowiada za rysowanie zaokrąglonych prostokątów – wywołaliśmy ją w pętli kilka razy, aby uzyskać kolorową ramkę. Druga funkcja pozwala na wyświetlenie na ekranie tekstu – podajemy treść, pozycję tekstu, wybieramy kolor oraz rozmiar czcionki.

    Dopisek „L” przed ciągiem znaków, który ma być wyświetlany na ekranie, informuje kompilator o konieczności użycia tzw. szerokich znaków, czyli znaków typu wchar_t zamiast char. Biblioteka HAGL domyślnie używa wchar_t, dzięki czemu można za jej pomocą bez problemu wyświetlać polskie znaki, cyrylicę, a nawet chińskie znaki.

    Oczywiście oprócz funkcji z biblioteki HAGL wykorzystujemy również nasze dwie funkcje – na początku do inicjalizacji ekranu, a na koniec do tego, aby przesłać zawartość bufora z mikrokontrolera do TFT. Jeśli wszystko przebiegło poprawnie, to na ekranie powinniśmy zobaczyć efekt zbliżony do poniższego.

    Test biblioteki HAGL, dzięki której możliwe jest wyświetlanie tekstów

    Test biblioteki HAGL, dzięki której możliwe jest wyświetlanie tekstów

    Do eksperymentów z biblioteką HAGL jeszcze za chwilę wrócimy, ale najpierw musimy zająć się znów tematem optymalizacji. Pora przyjrzeć się zużyciu pamięci i zaprzęgnąć do pracy DMA.

    Użycie pamięci

    Dotychczas mało czasu poświęcaliśmy tematyce użycia pamięci. Temat ten jest bardzo ważny, ale nie jest kluczowy w trakcie pierwszych praktycznych eksperymentów. Tym razem jednak można się dość mocno zdziwić, bo niestety programy z „interfejsem graficznym” sprawiają, że zapotrzebowanie na pamięć znacznie wzrasta. Wyświetlacz działa szybko, bo utworzyliśmy w programie bufor danych. Przyspieszyliśmy więc program kosztem zużycia pamięci – można tutaj jednak wpaść w pułapkę.

    Warto zacząć od obliczenia, ile zużyliśmy pamięci na ten niepozorny bufor. Nasz mały wyświetlacz TFT ma rozdzielczość 160 × 128 px, a każdy piksel zajmuje 16 bitów, czyli 2 bajty. Mamy więc tutaj aż 40 KiB (160 * 128 * 2 = 40 960 bajtów). Jak na mikrokontroler jest to ogromna ilość pamięci, ale na szczęście nasz STM32L476RG ma wystarczająco dużo pamięci RAM. 

    Dokładne informacje na temat zapotrzebowania na pamięć możemy zobaczyć wśród komunikatów widocznych podczas kompilacji programu oraz w znanej nam już zakładce Build Analyzer:

    Przykładowa informacja o zużyciu pamięci RAM

    Przykładowa informacja o zużyciu pamięci RAM

    Po lewej stronie widzimy, że sekcja text zajmuje 30 780 bajtów, a sekcja data – 28 bajtów. Text to kod naszego programu i wraz z początkowymi wartościami z sekcji data jest zapisywany w pamięci flash, której mamy aż 1024 KiB. Po prawej stronie widzimy te same dane, ale w łatwiejszej do analizy formie – nasz program używa 30,09 KiB, czyli tylko 2,94% dostępnej pamięci flash.

    Sekcje data oraz bss są podczas działania programu alokowane w pamięci RAM. Mamy więc 28 bajtów w pierwszej z nich i aż 42 764 w drugiej. Razem daje to widoczne po prawej stronie 41,78 KiB, czyli 43,52% pamięci RAM. Nasz bufor ekranu wykorzystał mnóstwo zasobów, ale spełnił swoje zadanie.

    Okazuje się jednak, że powyższe analizy dotyczą tylko kodu programu oraz zmiennych globalnych i statycznych. Zmienne lokalne tworzone są na tzw. stosie, a ponieważ stos jest bardzo ważną strukturą dla działania naszego mikrokontrolera, to musimy bardzo uważać, aby nie utworzyć więcej zmiennych lokalnych, niż mamy na stosie miejsca.

    Pewną pomoc w oszacowaniu zapotrzebowania na stos daje nam narzędzie Static Stack Analyzer. Udostępnia ono dwie zakładki – pierwsza, o nazwie List, pozwala na wyświetlenie, ile danych na stosie wykorzystuje dana funkcja. Posortowanie tych informacji po kolumnie Max cost umożliwia szybkie wytypowanie funkcji, które mają duże zapotrzebowanie na pamięć.

    Analiza zapotrzebowania funkcji na pamięć

    Analiza zapotrzebowania funkcji na pamięć

    Jeszcze ciekawszy widok przedstawia nam druga z zakładek, czyli Call graph. Prezentuje ona krótkie zestawienie, dzięki któremu możemy zsumować zużycia stosu przez wszystkie wywołania funkcji.

    Zestawienie zużycia stosu przez wywołania funkcji

    Zestawienie zużycia stosu przez wywołania funkcji

    Jak widać, w tym przypadku najwyższe zużycie wskazuje Reset_Handler, bo jest to funkcja wywoływana podczas startu mikrokontrolera. Wyniki pokazywane przez Static Stack Analyzer mogą być zaniżone, bo używanie np. wskaźników do funkcji nie jest uwzględniane w obliczeniach, ale – jak widzimy – nasz program (nawet wedle zaniżonych statystyk) może używać aż 4344 bajtów stosu – to dość dużo. Przyglądając się danym z zakładki List, znajdziemy winowajcę, czyli funkcję hagl_put_char, dzięki której wypisujemy na wyświetlaczu teksty.

    Teraz najważniejsze pytanie: jaką pojemność ma nasz stos? Oczywiście informację na ten temat uda nam się znaleźć w konfiguracji projektu. Wracamy więc do CubeMX, przechodzimy do zakładki Project Manager (obok konfiguracji zegarów) i wybieramy sekcję Project.

    Aktualne ustawienia stosu i sterty

    Aktualne ustawienia stosu i sterty

    Znajdziemy tam pole Minimum Stack Size, w którym będzie wpisana wartość domyślna, np.: 0x400, co oznacza 1024 bajtów. Nasz stos został zadeklarowany jako 1024-bajtowy, natomiast program wymaga 4344 bajtów. To może (ale nie musi) powodować problemy podczas działania programu, więc lepiej zadeklarować dla stosu wymaganą ilość pamięci

    Na szczęście bardzo łatwo możemy naprawić program i zwiększyć wielkość stosu. Wystarczy zmienić wartość wpisaną w to pole z 0x400 np. na 0x2000, co da aż 8192 bajty stosu. Gdy teraz zapiszemy zmiany i skompilujemy program, zobaczymy, że zużycie pamięci wzrosło – nie musimy się jednak tym przejmować, najważniejsze, że obecnie nasz program nie powoduje przepełnienia stosu.

    Statystyki zużycia pamięci po kompilacji nowej wersji programu

    Statystyki zużycia pamięci po kompilacji nowej wersji programu

    Przy okazji warto wspomnieć o jeszcze jednym parametrze, który można ustawić w tym samym oknie, czyli Minimum Heap Size. Jest to minimalny obszar zarezerwowany na tzw. stertę – to miejsce w pamięci mikrokontrolera, które używane jest do dynamicznej alokacji pamięci. Wywołania funkcji malloc oraz free używają właśnie sterty. Domyślnie sterta jest wręcz mikroskopijna – jak widzimy, ma ona jedynie 0x200, czyli 512 bajtów.

    Wykorzystanie DMA

    Udało nam się już zauważyć, że przepełnialiśmy stos. Sytuacja została naprawiona, możemy więc zająć się dalszą optymalizacją programu. Nasz dotychczasowy kod miał jedną dość istotną wadę – podczas przesyłania danych z bufora do wyświetlacza procesor był w pełni zajęty kopiowaniem danych. Jak już wiemy z części opisującej ADC na STM32L4, takie kopiowanie może wykonywać w tle DMA.

    Zacznijmy od obliczenia, ile czasu trwa takie kopiowanie – warto najpierw się zastanowić, czy jest w ogóle sens coś poprawiać. Nasz wyświetlacz ma rozdzielczość 160 × 128 px, a każdy piksel opisywany jest przez 16 bitów, czyli razem mamy 327 680 bitów (160 * 128 * 16).

    Ustawiliśmy komunikację przez SPI na prędkość 10 Mbit/s, czyli – jak łatwo obliczyć – wysłanie tych danych zajmie 32,768 ms. To raptem 1/30 s, dla nas mgnienie oka, ale dla mikrokontrolera to mnóstwo czasu, który mógłby wykorzystać o wiele produktywniej, zamiast kopiować dane.

    W celu uruchomienia DMA wracamy do CubeMX i przechodzimy do ustawień modułu SPI2. Jak łatwo się domyślić, w dolnej części okna przechodzimy do zakładki DMA Settings i klikamy Add. W związku z tym, że wcześniej ustawiliśmy moduł SPI w tryb (tylko) wysyłania danych, to dostępny będzie zaledwie jeden kanał DMA – o nazwie SPI2_TX.

    Aktywacja kanału DMA dla SPI2

    Aktywacja kanału DMA dla SPI2

    Zostawiamy domyślne ustawienia: tryb Normal (ponieważ chcemy, aby dane były wysyłane raz, a nie w kółko, jak to było w trybie Circular), kierunek przesyłania danych Memory To Peripheral, rozmiar danych Byte. To tyle – zapisujemy projekt i generujemy nową wersję kodu.

    Dla przypomnienia – aktualna treść funkcji kopiującej dane do wyświetlacza wygląda następująco:

    Wywołanie funkcji HAL_SPI_Transmit blokuje działanie mikrokontroelra na ponad 32 ms, dlatego chcielibyśmy zastąpić ją HAL_SPI_Transmit_DMA. Tyle że wywołanie tej funkcji uruchamia transfer i natychmiast wraca, my natomiast musimy poczekać na koniec transmisji i dopiero wtedy ustawić na linii CS stan wysoki. Jednak po zakończeniu transmisji zostanie zgłoszone przerwanie, które możemy wykorzystać w tym celu (nie musielibyśmy tego robić, gdybyśmy korzystali ze sprzętowej obsługi CS).

    Zmieniamy więc kod funkcji lcd_copy na następujący:

    Następnie wracamy do pliku main.c i dodajemy w nim funkcję, która zostanie automatycznie wywołana po zakończeniu transmisji przez SPI.

    Najważniejsze już za nami. Jeśli uruchomimy ten program, to okaże się, że działa on zgodnie z naszymi oczekiwaniami. Widzimy więc, że używanie DMA to nic trudnego. Niestety ten kod ma dwie poważne wady. Pierwsza to umieszczenie fragmentu kodu związanego z wyświetlaczem w pliku main.c, a druga to brak informacji o tym, kiedy transfer do wyświetlacza zostanie zakończony. 

    Pierwszy problem moglibyśmy rozwiązać przez przeniesienie całej funkcji HAL_SPI_TxCpltCallback do pliku lcd.c, ale to wcale nie poprawiłoby sytuacji, bo przecież inne moduły SPI też mogą z tej funkcji korzystać. Drugi problem stanie się widoczny, jeśli zaczniemy zmieniać dane obrazu podczas transmisji poprzedniego. Możemy wówczas uzyskać niepoprawną zawartość, będącą niejako połączeniem obu wersji. Powinniśmy więc poczekać z rysowaniem do momentu zakończenia transmisji.

    W celu porządnego rozwiązania dwóch problemów przechodzimy do pliku lcd.h, dodajemy jeden plik nagłówkowy oraz deklarację dwóch funkcji:

    Pierwszą funkcję, czyli lcd_transfer_done, będziemy wywoływać po odebraniu przerwania, czyli w pliku main.c powinniśmy mieć następujący kod:

    Dzięki temu, jeśli w przyszłości dodamy obsługę jakichś innych modułów SPI, to wystarczy, że w pliku main.c odpowiednio „rozdzielimy” informację o zakończeniu transferu. Natomiast kod związany z wyświetlaczem pozostanie odizolowany w module lcd.c – wystarczy, że dodamy funkcję o następującej zawartości:

    Teraz nasza komunikacja z wyświetlaczem może odbywać się faktycznie w tle. Dzięki temu nasz układ może wykonywać w tle wiele innych czynności, np. odczyty z czujników. My musimy pamiętać tylko o tym, aby rysowanie kolejnej klatki (uzupełnianie bufora) rozpoczynało się dopiero po tym, jak nasza nowa funkcja lcd_is_busy zwróci false.

    Programy demonstracyjne

    Działanie naszej nowej obsługi wyświetlacza najlepiej sprawdzić za pomocą programów demo, które są przygotowane przez autora biblioteki HAGL – można je pobrać z tego repozytorium. Były one pisane z myślą o Raspberry Pi Pico, ale nie ma przeszkód, aby uruchomić je na STM32L4.

    Pobieramy i rozpakowujemy kody, a następnie kopiujemy je do projektu (można przeciągnąć pliki do odpowiedniego miejsca w eksploratorze projektów). Zaczniemy od dema, które nazywa się metaballs – w tym celu plik metaballs.c kopiujemy do katalogu Core\Src, a metaballs.h do Core\Inc.

    Następnie przechodzimy do pliku main.c i dodajemy na początku nowy plik nagłówkowy:

    Oprócz pliku nagłówkowego potrzebny będzie jeszcze poniższy kod:

    Przed pętlą główną wywołujemy funkcję metaballs_init, która inicjalizuje demo. Następnie w pętli wywołujemy metaballs_animate – jest to funkcja, która oblicza położenie symulowanych piłek. Później czekamy na zakończenie transferu poprzedniej klatki do wyświetlacza i renderujemy nową zawartość, wywołując metaballs_render. Później wywołujemy lcd_copy, aby przesłać dane do wyświetlacza.

    Warto tutaj zauważyć, że funkcja lcd_copy zwraca sterowanie natychmiast, gdy DMA jeszcze w tle kopiuje dane. Oznacza to, że metaballs_animate jest wywoływane w czasie, gdy dane z bufora nadal są przesyłane. Dzięki temu możemy uzyskać płynne animacje, które są poza zasięgiem np. Arduino.

    Kolejne demo nazywa się rotozoom i wymaga skopiowania pliku rotozoom.c do katalogu Core\Src oraz plików rotozoom.h i head.h do Core\Inc. W programie wystarczy włączyć plik rotozoom.h oraz zastąpić odwołania do funkcji metaballs_ takimi o przedrostku rotozoom_.

    Po uruchomieniu naszego programu zobaczymy, że animacja co prawda działa, ale nie jest wybitnie płynna. Możemy poprawić wydajność naszego programu, zmieniając poziom optymalizacji. Domyślnie używany jest najniższy, czyli zerowy poziom optymalizacji, co oznacza, że skompilowany program jest duży i powolny, ale łatwy do debugowania. 

    W celu włączenia optymalizacji programu wynikowego przechodzimy do Project > Properties > C/C++ Build > Settings > Tool Settings > MCU GCC Compiler > Optimization. Następnie w polu opisanym jako Opitmization level wybieramy Optimize for size (-Os) – to bardzo popularna opcja optymalizacji dla mikrokontrolerów. Po wgraniu i uruchomieniu tej wersji programu powinniśmy zobaczyć, że animacja jest jeszcze bardziej płynna (niewiele, ale jednak). Warto więc pamiętać o tej opcji.

    Na koniec warto jeszcze uruchomić demo plasma. Wystarczy analogicznie jak poprzednio skopiować pliki plasma.h oraz plasma.c, po czym podmienić wywołania funkcji na zaczynające się od plasma_.

    Zadanie domowe

    1. Zapoznaj się z funkcjami do rysowania linii, okręgów i prostokątów, które są dostępne w ramach biblioteki HAGL. Wykorzystaj je do narysowania małego żółtego autobusu z brązowymi kołami i niebieskimi oknami.
    2. Podłącz do układu fotorezystor i mierz za pomocą ADC natężenie światła. Informacje z czujnika powinny być wyświetlane w formie „paska postępu” na wyświetlaczu TFT (im ciemniej, tym pasek powinien wskazywać mniejszą wartość).
    3. Podłącz do układu dwa potencjometry i napisz program, w którym zmiana ich położenia będzie przemieszczała na ekranie kolorową piłkę. Jeden potencjometr powinien odpowiadać za oś X, a drugi za oś Y.

    Podsumowanie – co powinieneś zapamiętać?

    Za nami kolejna, bardzo długa część kursu STM32L4, dzięki której powinieneś zapamiętać, że obsługa wyświetlaczy graficznych to dość zawiły temat – głównie dlatego, że moduły takie wymagają transferu dużej ilości danych. Najważniejsze, abyś po lekturze tego poradnika wiedział, jak skonfigurować moduł SPI2 i jak wykorzystać bibliotekę niskopoziomową, którą napisaliśmy podczas naszych eksperymentów.

    Czy wpis był pomocny? Oceń go:

    Średnia ocena 5 / 5. Głosów łącznie: 15

    Nikt jeszcze nie głosował, bądź pierwszy!

    Artykuł nie był pomocny? Jak możemy go poprawić? Wpisz swoje sugestie poniżej. Jeśli masz pytanie to zadaj je w komentarzu - ten formularz jest anonimowy, nie będziemy mogli Ci odpowiedzieć!

    W kolejnej części kursu zajmiemy się następnym interfejsem komunikacyjnym. Tym razem opiszemy, jak wykorzystać I2C na STM32L4, aby podłączyć do mikrokontrolera zewnętrzną pamięć EEPROM. Ten sam interfejs wykorzystamy później do obsługi precyzyjnego czujnika ciśnienia i wysokości.

    Nawigacja kursu

    Główny autor kursu: Piotr Bugalski
    Współautor: Damian Szymański, ilustracje: Piotr Adamczyk
    Oficjalnym partnerem tego kursu jest firma STMicroelectronics
    Zakaz kopiowania treści kursów oraz grafik bez zgody FORBOT.pl

    kurs, kursSTM32L4, lcd, stm32l4, tft

    Trwa ładowanie komentarzy...