Popularny post Elvis Napisano Styczeń 29, 2020 Popularny post Udostępnij Napisano Styczeń 29, 2020 W poprzednich odcinkach zobaczyliśmy jak działa program, który przesyła do wyświetlacza dane dla każdego piksela osobno, następnie przetestowaliśmy wersję z buforem dla całej pamięci obrazu. Pierwsza wersja działała bardzo wolno, ale zużywała mało pamięci RAM. Druga działała bardzo szybko, ale bufor zajmował ogromną jak na mikrokontroler ilość pamięci. Teraz spróbujemy przygotować wersję pośrednią - tym razem użyjemy mniej pamięci, ale więcej czasu procesora. Spis treści: Sterowanie wyświetlaczem TFT - część 1 - wstęp, podstawowe informacje Sterowanie wyświetlaczem TFT - część 2 - analiza problemu Sterowanie wyświetlaczem TFT - część 3 - testy prędkości na STM32 Sterowanie wyświetlaczem TFT - część 4 - własny program Sterowanie wyświetlaczem TFT - część 5 - optymalizacja programu Obliczanie danych obrazu Pełny bufor obrazu jak pamiętamy zajmuje 160 x 128 x 2 = 40960 bajtów pamięci. Takie rozwiązanie zapewniło nam możliwość szybkiego tworzenia grafiki w pełnej rozdzielczości oraz 65 tysiącach kolorów. Jednak w wielu zastosowaniach wystarczyłaby nieco mniejsze możliwości, przykładowo gdybyśmy zamiast 16-bitów zastosowali 8, uzyskalibyśmy 256 kolorów, a jednocześnie zmniejszyli zużycie pamięci o połowę. W wielu przypadkach nawet 16 kolorów, czyli 4 bity mogłyby wystarczyć, a jak łatwo policzyć bufor zajmowałby wówczas 10240 bajtów. Ten wpis brał udział konkursie na najlepszy artykuł o elektronice lub programowaniu. Sprawdź wyniki oraz listę wszystkich prac » Partnerem tej edycji konkursu (marzec 2020) był popularny producent obwodów drukowanych, firma PCBWay. Podobnie z rozdzielczością, jeśli tworzymy np. mini konsolę do grania, tryb 80x64 mógłby nam wystarczyć, nadałby nawet nieco stylu "retro". Ogólna idea jest więc taka, że spróbujemy przechowywać mniejszą ilość danych, a następnie przeliczać je na reprezentację oczekiwaną przez wyświetlacz dopiero przed wysłaniem. Tryb z paletą kolorów Metod generowania obrazu jest mnóstwo, ja spróbuję przedstawić bardzo prosty, czyli użycie 8-bitowej palety. Bufor obrazu będzie wyglądał podobnie jak wcześniej, ale zamiast typu uint16_t użyjemy uint8_t: uint8_t lcd_framebuffer[LCD_WIDTH * LCD_HEIGHT]; Jak łatwo policzyć zajmuje on teraz 160 x 128 = 20480 bajtów, czyli nadal sporo, ale zawsze można zastosować kolejne optymalizacje. Wyświetlacz oczekuje danych w postaci RGB565, czyli 16-bitowych wartości gdzie 5-bitów określa składową czerwoną, 6-zieloną, a 5-niebieską. Paleta to po prostu 256-elementowa tablica, która zawiera wartości opisujące dany kolor: uint16_t palette[256]; W docelowym programie moglibyśmy dobrać idealną paletę do naszego zastosowania i zapisać ją w pamięci flash (albo w samym programie). Jak wspomniałem tutaj prezentuję jedynie demo, więc paletę będę obliczać: Prawdę mówiąc takie rozwiązanie nie wyglądało najładniej, bo nie można w nim reprezentować bieli, więc zamiast uzupełniać zerami, uzupełniłem jedynkami - jak napisałem, to tylko demo. Bardzo prosty kod przeliczający 8-bitową paletę, na 16-bitowy kolor dla wyświetlacza wygląda następująco: static inline uint16_t palette2rgb(uint8_t color) { uint16_t r = ((uint16_t)color & 0xe0) << 8; uint16_t g = ((uint16_t)color & 0x1c) << 6; uint16_t b = ((uint16_t)color & 0x03) << 3; return __REV16(0x18e7 | r | g | b); } Ta magiczna wartość 0x18e7 to właśnie uzupełnienie jedynkami - wiem że to brzydki kod, z góry za niego przepraszam, ale to mało istotny fragment, zachęcam oczywiście do zastosowania o wiele lepszych rozwiązań. Teraz przechodzimy do najważniejszego, czyli przeliczania 8-bitowych danych w buforze, na docelowe 16-bitowe przeznaczone dla wyświetlacza. Przesyłanie po jednym bajcie nie działa wydajnie, więc utworzymy nieduży bufor tymczasowy, ja ustaliłem jego wielkość na 512 pikseli: #define TX_BUF_SIZE 512 static uint16_t tx_buf[TX_BUF_SIZE]; Skoro wyświetlacz ma rozdzielczość 160 x 128, więc jak łatwo policzyć będziemy ten bufor wypełniać i wysyłać 40 razy, aby przesłać cały obraz. Funkcja do wypełniania bufora wygląda następująco: static void fill_tx_buf(uint32_t part) { uint16_t *dest = tx_buf; uint8_t *src = lcd_framebuffer + part * TX_BUF_SIZE; for (uint32_t i = 0; i < TX_BUF_SIZE; i++) *dest++ = palette2rgb(*src++); } Jako parametr podajemy indeks bufora, czyli wartość 0..39, po jej wykonaniu bufor tx_buf będzie zawierał dane do przesłania. Teraz możemy napisać funkcję rysującą zawartość naszego ekranu: void lcd_copy(void) { lcd_cmd(ST7735S_RAMWR); HAL_GPIO_WritePin(LCD_DC_GPIO_Port, LCD_DC_Pin, GPIO_PIN_SET); HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_RESET); for (uint32_t i = 0; i < LCD_WIDTH * LCD_HEIGHT / TX_BUF_SIZE; i++) { fill_tx_buf(i); HAL_SPI_Transmit(&hspi2, (uint8_t*)tx_buf, 2 * TX_BUF_SIZE, HAL_MAX_DELAY); } HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_SET); } Musimy jeszcze zmodyfikować naszą bibliotekę graficzną, tak żeby pracowała z 8-bitowymi kolorami, ale to właściwie kosmetyczna zmiana. Czas skompilować program: Zgodnie z oczekiwaniami zużycie pamięci RAM znacznie spadło i wynosi niecałe 23KB. Przetestujmy wydajność naszego programu: Jak widzimy rysowanie w lokalnym buforze zajmuje tyle samo czasu, czyli 7ms, natomiast kopiowanie trochę więcej niż poprzednio, bo 37ms zamiast 33ms. Warto przy okazji przetestować wydajność funkcji wypełniającej bufor, czyli fill_tx_buf: void lcd_test(void) { uint32_t start = HAL_GetTick(); for (uint32_t i = 0; i < 1000; i++) fill_tx_buf(i % (LCD_WIDTH * LCD_HEIGHT / TX_BUF_SIZE)); uint32_t end = HAL_GetTick(); printf("lcd_test: %ld us\r\n", end - start); } Wywołanie 1000 razy fill_tx_buf zajmuje 110ms, czyli jedno jej wywołanie ok. 110us. Jak pamiętamy używamy jej 40 razy, co zgadza się z pozostałymi pomiarami - trochę ponad 4ms zużyliśmy na obliczenia, ale zaoszczędziliśmy prawie 20KB pamięci. Użycie tablicy zamiast obliczeń pewnie pozwoliłoby skrócić ten czas, ale jak wspominałem, to tylko przykład. Nie będę wstawiał kolejnego filmu, bo już tyle razy widzieliśmy ekran testowy, że chyba każdy ma go dosyć. Czas udoskonalić nasz program. Użycie DMA W poprzedniej części korzystaliśmy z DMA, więc nowy program jest pod wieloma względami "gorszy". Użyjmy więc HAL_SPI_Transmit_DMA zamiast, HAL_SPI_Transmit. Procedura wysyłania będzie wyglądała następująco: void lcd_copy(void) { lcd_wait_ready(); lcd_busy = true; lcd_cmd(ST7735S_RAMWR); HAL_GPIO_WritePin(LCD_DC_GPIO_Port, LCD_DC_Pin, GPIO_PIN_SET); HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_RESET); tx_part = 0; fill_tx_buf(tx_part++); HAL_SPI_Transmit_DMA(&hspi2, (uint8_t*)tx_buf, 2 * TX_BUF_SIZE); } Wypełniamy w niej bufor pierwszym fragmentem obrazu i rozpoczynamy wysyłanie. W zmiennej tx_part przechowujemy informację o numerze kolejnego fragmentu do wysłania. Musimy teraz obsłużyć przerwanie informujące o zakończeniu transmisji i w nim przygotować dane dla następnego fragmentu, albo zakończyć całą operację: void lcd_copy_done(void) { if (tx_part < LCD_WIDTH * LCD_HEIGHT / TX_BUF_SIZE) { fill_tx_buf(tx_part++); HAL_SPI_Transmit_DMA(&hspi2, (uint8_t*)tx_buf, 2 * TX_BUF_SIZE); } else { HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_SET); lcd_busy = false; } } Po uruchomieniu zobaczymy, że program działa tak samo jak poprzednio. Różnica jest jednak taka, że podczas transmisji przez DMA procesor może wykonywać inne zadania. Jednak czas kopiowania obrazu nadal wynosi 37ms. Jeszcze jedna ważna uwaga - w przerwaniu generujemy dane dla kolejnego bufora, więc na 110us blokujemy przerwanie. Długie procedury obsługi przerwań to nic dobrego, ale STM32 pozwala na szczęście na ustawienie priorytetów przerwań. Możemy więc przerwaniu od DMA nadać niski priorytet i dzięki temu nasze obliczenia nie będą opóźniały innych, pilniejszych zadań: Użycie podwójnego bufora Jeśli podłączymy analizator logiczny to zobaczymy, że komunikacja z wyświetlaczem ma "przerwy". Wynika to stąd, że gdy obliczamy dane dla kolejnego fragmentu ekranu komunikacja jest zatrzymywana. Możemy trochę skomplikować nasz program, ale jednocześnie przyspieszyć działanie. Tym razem użyjemy dwóch buforów, albo raczej jednego większego. Gdy DMA będzie wysyłało jedną część danych, będziemy mieli czas na przygotowanie następnej. Deklarujemy więc większy bufor: #define TX_BUF_SIZE 512 static uint16_t tx_buf[TX_BUF_SIZE * 2]; Funkcja wypełniania bufora będzie teraz zapisywać parzyste fragmenty w pierwszej części tx_buf, a nie nieparzyste w drugiej: static void fill_tx_buf(uint32_t part) { uint16_t *dest = (part % 2 == 0) ? tx_buf : tx_buf + TX_BUF_SIZE; uint8_t *src = lcd_framebuffer + part * TX_BUF_SIZE; for (uint32_t i = 0; i < TX_BUF_SIZE; i++) *dest++ = palette2rgb(*src++); } Przed rozpoczęciem transmisji wypełnimy nie jeden, ale dwa bufory: void lcd_copy(void) { lcd_wait_ready(); lcd_busy = true; lcd_cmd(ST7735S_RAMWR); HAL_GPIO_WritePin(LCD_DC_GPIO_Port, LCD_DC_Pin, GPIO_PIN_SET); HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_RESET); tx_part = 0; fill_tx_buf(tx_part++); fill_tx_buf(tx_part++); HAL_SPI_Transmit_DMA(&hspi2, (uint8_t*)tx_buf, 4 * TX_BUF_SIZE); } Ostatnia zmiana to obsługa nowego przerwania, które będzie wywoływane po przesłaniu połowy danych: void HAL_SPI_TxHalfCpltCallback(SPI_HandleTypeDef *hspi) { lcd_copy_halfdone(); } Gdy otrzymamy to przerwanie, będziemy po prostu wypełniać następny bufor: void lcd_copy_halfdone(void) { fill_tx_buf(tx_part++); } Natomiast procedura obsługi końca transmisji prawie się nie zmieniła, jedyna różnica to wielkość bufora: void lcd_copy_done(void) { if (tx_part < LCD_WIDTH * LCD_HEIGHT / TX_BUF_SIZE) { HAL_SPI_Transmit_DMA(&hspi2, (uint8_t*)tx_buf, 4 * TX_BUF_SIZE); fill_tx_buf(tx_part++); } else { HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_SET); lcd_busy = false; } } Program jest nieco bardziej skomplikowany, ale w nagrodę otrzymaliśmy czas kopiowania identyczny jak w wersji z poprzedniego odcinka, ale zużyliśmy mniej pamięci: Na zakończenie jeszcze mały przykład wykorzystania naszego nowego programu. Program demonstracyjny Jakiś czas temu na forum pojawił się wątek z pytaniem o sposób wyświetlania kołowego progres baru. Pytanie sprowokowało ciekawą dyskusję na temat możliwości wydajnej realizacji takiego zadania. Skoro mamy opanowane sterowanie wyświetlacza TFT, możemy spróbować narysować nieco podobny element, a przy okazji sprawdzić jak nasza "biblioteka" sprawdzi się w realnym przykładzie. Zacznijmy od małej powtórki z matematyki oraz pewnego uproszczenia. Dla ułatwienia rysujmy tylko połowę progres-bara, zawsze możemy później rozbudować program. Rysowanie pionowych linii jest na ogół dość szybką operacją, więc zastanówmy się jak narysować wykres pionowymi (albo poziomymi) liniami. Kąt alfa oraz promienie r1 i r2 to nasze dane. Ja wybrałem rysowanie pionowych linii, więc x będzie zmienną. Wszyscy pamiętamy z matematyki wzór okręgu: x2 + y2 = r2. Po karkołomnych przejściach matematycznych uzyskujemy więc: y1 = sqrt(r1 - x2) y3 = sqrt(r2 - x2) Funkcja sqrt to pierwiastek. Jeśli ktoś jest miłośnikiem optymalizacji to wartości y1 i y2 może raz policzyć i trzymać w tablicy (najlepiej w pamięci Flash). Jak wspominałem program to demo, więc na razie nie będzie aż tak optymalny. Zostaje jeszcze obliczenie y2. Jest to odrobinę trudniejsze, może wymagać szybkiej powtórki z trygonometrii, a jak pamiętamy tg(alpha) = y/x, więc: y2 = x * tg(alpha) Tutaj znowu możemy tablicować wartości, ale na początek zostawmy prostą wersję. Mamy więc dla każdego x obliczone y1, y2 i y3. Teraz wystarczy sprawdzić jak y2 ma się do pozostałych i są możliwe 3 przypadki: jeśli y2 <= y1 to nic nie rysujemy jeśli y2 >= y3 to rysujemy "pełną" linię od y1 do y3 a jeśli y1 < y2 < y3 to linię od y1 do y2 Rysowanie możemy więc wykonać następującym programem: void draw_bar(uint32_t r1, uint32_t r2, uint32_t alpha) { float t = tan(alpha * M_PI / 180.0); bar_circle(0, 0, r1, WHITE); bar_circle(0, 0, r2, WHITE); bar_line(0, 0, r1 * cos(alpha * M_PI / 180.0) * 0.8f, r1 * sin(alpha * M_PI / 180.0) * 0.8f, WHITE); for (uint32_t x = 0; x <= r2; x++) { uint32_t y1 = (x < r1) ? sqrt(r1 * r1 - x * x) : 0; uint32_t y2 = sqrt(r2 * r2 - x * x); uint32_t y3 = x * t; if (y3 > y2) bar_line(x, y1, x, y2, WHITE); else if (y3 > y1) bar_line(x, y1, x, y3, WHITE); } } Funkcje bar_circle i bar_line są dodane aby "przenieść" nasz początek współrzędnych w odpowiednie miejsce: static void bar_circle(uint32_t x, uint32_t y, uint16_t r, uint8_t color) { lcd_circle(LCD_WIDTH - 1 - x, LCD_HEIGHT - 1 - y, r, color); } static void bar_line(uint32_t x1, uint32_t y1, uint32_t x2, uint32_t y2, uint8_t color) { lcd_line(LCD_WIDTH - 1 - x1, LCD_HEIGHT - 1 - y1, LCD_WIDTH - 1 - x2, LCD_HEIGHT - 1 - y2, color); } Dodajmy jeszcze "wskazówkę", pomiar czasu działania oraz wyświetlanie wartości: void draw_test_screen(uint32_t value) { char buf[32]; uint32_t start = HAL_GetTick(); lcd_clear(BLUE); draw_bar(117, 127, value); sprintf(buf, "%ld", value); lcd_fill_rect(110, 90, 150, 120, BLACK); lcd_draw_string(120, 100, buf, &Font16, WHITE, BLACK); lcd_copy(); uint32_t time = HAL_GetTick() - start; printf("drawing: %lu ms\r\n", time); } Teraz program jest gotowy: Nawet bez tablicowania wartości i z arytmetyką zmiennopozycyjną rysowanie zajmuje ok 22ms. Kopiowanie to 33ms, mamy więc prawie 20 klatek na sekundę, ale pewnie dałoby się więcej. Podsumowanie Początkowo planowałem napisanie jednego, może dwóch artykułów odnośnie sterowania wyświetlaczem TFT. Okazało się jednak, że temat jest o wiele obszerniejszy i ciekawszy, a 5 części to właściwie dopiero wstęp. Mam nadzieję, że udało mi się pokazać jak można sterować wyświetlaczem kolorowym oraz zachęcić do własnych eksperymentów i udoskonalania zaprezentowanych rozwiązań. Spis treści: Sterowanie wyświetlaczem TFT - część 1 - wstęp, podstawowe informacje Sterowanie wyświetlaczem TFT - część 2 - analiza problemu Sterowanie wyświetlaczem TFT - część 3 - testy prędkości na STM32 Sterowanie wyświetlaczem TFT - część 4 - własny program Sterowanie wyświetlaczem TFT - część 5 - optymalizacja programu 10 2
Treker (Damian Szymański) Styczeń 30, 2020 Udostępnij Styczeń 30, 2020 Bardzo ciekawa seria! Potwierdzam, że artykuł został poprawnie zgłoszony do konkursu. Przy okazji dodałem we wszystkich częściach prostą nawigację 1
MR1979 Styczeń 31, 2020 Udostępnij Styczeń 31, 2020 (edytowany) A tyle da się wycisnąć z STM32F103 przy odpowiedniej optymalizacji drivera. Podłączyłem TFT bazujący na ILI9341 16-bitową magistralą. Z pomiarów wynika że udało się osiągnąć szybkość 5.5Mpix/s (pomiar oscyloskopem na lini WR). Mikrokontroler pracował na 64MHz, więc zostało jeszcze kilka MHz w zapasie. Jeszcze muszę dodać wyświetlanie tekstu. Pozdr! EDIT: Faktycznie po ustawieniu optymalizacji na -O3, prędkość zapisu wzrosła do 8Mpix/s. Możemy w ten sposób wypełnić ekran jednolitym kolorem ponad 100 razy na sekundę. Edytowano Styczeń 31, 2020 przez MR1979 2
SOYER Luty 3, 2020 Udostępnij Luty 3, 2020 Gratulacje dla @Elvis za świetny cykl artykułów na świetny temat. Niestety to zupełnie nie mój poziom wiedzy i umiejętności, ale zawsze dobrze dowiedzieć się czegoś nowego:). 1
ethanak Luty 3, 2020 Udostępnij Luty 3, 2020 @SOYER spoko - właśnie siedzę nad artykułem, mogę powalczyć o trzecie miejsce (bo tego co zrobił @Elvis raczej nie przeskoczę). Wielu rzeczy się można dowiedzieć, sam zaczynam na tej podstawie pisać coś na esp32... Na pewno jest to lepsze niż wypociny w stylu "Arduino jest fajne i nadaje się do nauki programowania" czy (nie do końca zgodne z rzeczywistością) wymienianie funkcji pinów w esp32... 2
Pomocna odpowiedź
Bądź aktywny - zaloguj się lub utwórz konto!
Tylko zarejestrowani użytkownicy mogą komentować zawartość tej strony
Utwórz konto w ~20 sekund!
Zarejestruj nowe konto, to proste!
Zarejestruj się »Zaloguj się
Posiadasz własne konto? Użyj go!
Zaloguj się »