Jak przerobić bibliotekę na uniwersalną pod różne pinouty/procki i co za tym idzie - ułatwić sobie pracę w przyszłości?

Kolejny stary wpis dość wyciągnięty z notatek - bazując na dacie w repozytorium ma ponad 3 lata (jest z 2018 roku). Dokonałem kilku zmian oznaczonych TAGiem UPDATE reszta tekstu nawet się broni i aktualnie nadal się z tym zgadzam.

Jakie cechy powinna mieć taka biblioteka współdzielona pomiędzy różnymi projektami?

  • oczywiście odseparowane warstwy hardware/logika
  • brak zaincludowanych plików platformy/producenta - jeśli zawsze maina dla AVR zaczynasz od includa <avr/io.h> -> to nie tędy droga!
    • wyjściem pośrednim jest #definiowanie takich includów - ale są też inne opcje
  • przemyślana koncepcja reużywalności - co będzie jak znajdzie się błąd i trzeba będzie poprawić w kilku projektach? Będziecie dla klientów nagrywać filmiki na youtube, żeby podmienili wartości w kilku linijkach?
    • jeszcze inny motyw w podobnym tonie - co jeśli w jednym projekcie musimy coś zoptymalizować? jak się odciąć taką biblioteką od jej “głównej” wersji tak, żeby zachować oba kody w jakiś spójny sposób?
    • tutaj bez GITa ani rusz!!! ja nie będę wnikał w GITa - odsyłam do kursu GITa Macieja Aniserowicza - sam go przerobiłem i oprócz obsługi GITa wyniosłem też sporo rzeczy bez których teraz nie wyobrażam sobie pracy (mała reklama, ale jak kogoś namówię to na tym o wiele bardziej skorzysta niż na tym moim artykule).
  • płynnie wcześniejsze myśliniki można zebrać w hasło “dobra architektura” - a jak wymusić dobrą architekturę? tu mogą pomóc testy - pamiętajcie, że testować można tylko testowalny kod! - niby tak oczywiste, ale nie zawsze :P

UPDATE - Tych cech jest więcej i dokładniejszy opis znajduje się w tym wpisie: https://www.embedownik.pl/posts/005.html.

I teraz ważna kwestia - czy to wszystko otrzymamy “za darmo” - bez żadnego narzutu kodu w prockach? Otóż nie - klasycznie w programowaniu “coś kosztem czegoś”.

Opiszę to na praktycznym przykładzie - na warsztat bierzemy znaną bibliotekę do wyświetlaczy HD44780 od Radzia http://radzio.dxp.pl/hd44780/ - zapytałem autora o zgodę na takie jej wykorzystanie i ją uzyskałem - przy okazji pozdrawiam!

Więc tak - domyślnie działa ona dla AVRek i zawiera się w dwóch plikach HD44780.c i HD44780.h

Pinout mamy zdefiniowany bezpośrednio w bibliotece w pliku HD44780.h i wygląda to mniej więcej tak:

#define LCD_RS_DIR		DDRA
#define LCD_RS_PORT 	PORTA
#define LCD_RS			(1 << PA2)

#define LCD_E_DIR		DDRA
#define LCD_E_PORT		PORTA
#define LCD_E			(1 << PA3)

Potem w kodzie aplikacji mamy bezpośrednio odniesienia do tych makr:

  LCD_E_PORT |= LCD_E; //  E = 1
  _LCD_OutNibble(0x03); // tryb 8-bitowy
  LCD_E_PORT &= ~LCD_E; // E = 0
  _delay_ms(5); // czekaj 5ms

Pierwsze od czego zacznę modyfikację to pozbycie się dwuplikowej formy - ma ona wadę w tej postaci - że pomiędzy projektami jest kopiowana (łącznie z konfiguracją pod dany projekt!) - czyli jeśli w jednym projekcie coś poprawimy - to musimy pamietać o poprawieniu w reszcie. Osobiście wypracowałęm sobie takie podejście, gdzie biblioteka ma pliki “główne” - które są współdzielone pomiędzy wszystkie projekty z niej korzystające i pliki konfiguracyjne per projekt - i takie podejście zastosuję tutaj - oczywiście nie zakładam, że jest idealne itp, ale u mnie na dłuższą metę się sprawdza i pozwala ładnie poautomatyzować dodawanie bibliotek do golego szablonu projektu.

Więc wracając do portów - potrzebujemy w bibliotece zamiast tych makr dedykowanych dla AVR używać jakiejś innej warstwy abstrakcji - zrobiłem funkcje:

void HD44780HAL_init_GPIO(void);

void HD44780HAL_E_high(void);
void HD44780HAL_E_low(void);

void HD44780HAL_RS_high(void);
void HD44780HAL_RS_low(void);

void HD44780HAL_DB4_high(void);
void HD44780HAL_DB4_low(void);

void HD44780HAL_DB5_high(void);
void HD44780HAL_DB5_low(void);

void HD44780HAL_DB6_high(void);
void HD44780HAL_DB6_low(void);

void HD44780HAL_DB7_high(void);
void HD44780HAL_DB7_low(void);

void HD44780HAL_delay_ms(uint8_t ms);
void HD44780HAL_delay_us(uint8_t us);

Jak można przypuszczać po użytym prefixie w nazwach - wydzieliłem je do osobnego modułu - do plików HD44780_hal.c/h

Następnie podmieniłem w kodzie biblioteki wcześniejsze makra na wykorzystania tych funkcji. Co jeszcze w ten sposób juz zyskałem - z pliku HD44780.h mogłem się pozbyć includów “avr-owych” uniezależniając ją od platformy sprzętowej:

#include <avr/io.h>
#include <util/delay.h>

a zamiast tego w pliku HD44780.c zaincludować plik HD44780_hal.h - czyli uzyskaliśmy całkowitą niezależność tych dwóch plików od platformy! I o to chodziło!

Teraz pozostaje nam pouzupełniać ciała naszych funkcji w pliku c HALa. I też zwróćmy uwagę na kolejną rzecz - czy będziemy potrzebować kiedykolwiek modyfikować plik HD44780_hal.h? No właśnie nie - czyli on też jest już i niezależny od platformy i nie do zmieniania! Oczywiście moglibyśmy tam upchnąć też jakieś definicje portów per projekt - ale po co? Ja do tego wydzielę jeszcze jeden plik - HD44780_settings.h - i to on będzie kopiowany per projekt i bedzie zawierał pinout. A co do pliku HD44780_hal.c -> on będzie nie tyle per projekt - tylko per rodzina mikrokontrolera - dla AVRek będą inne sposoby ustawiania pinów i dla STM32 inne. Oczywiście moglibyśmy tu pójść o jeszcze jeden krok dalej - zrobić sobie uniwersalną bibliotekę “GPIO” i ją wykorzystać, tak, zeby plik C też był dla pinów niezależny od platformy, a definy z pinoutem przenieść wtedy do jeszcze innego pliku - ale aż tak nie będę wjeżdzał - to już każdy sam musi sobie założyć według potrzeb.

Taka sytuacja może wystąpić w firmie gdzie jednocześnie produkty rozwijane są na wielu platformach - wtedy opłacalne może okazać się pisanie firmowego HALa - książka w tym temacie to Beningo - “Reusable firmware development”.

Chociaż po zastanowieniu - taka bilbioteka dla GPIO i będzie mi potem potrzebna dla moich zastosowań - więc już teraz ją przygotuję.

Więc otrzymałem libkę “GPIO”, i wstępnie mamy tam taki interfejs:

#include <stm32f0xx.h>

void GPIO_InitOutput(GPIO_TypeDef *GPIO, uint8_t pin_number);

void GPIO_SetHigh(GPIO_TypeDef *GPIO, uint8_t pin_number);

void GPIO_SetLow(GPIO_TypeDef *GPIO, uint8_t pin_number);

i potem w libce HD w pliku HAL mamy takie użycia:

void HD44780HAL_E_high(void)
{
	GPIO_SetHigh(LCD_E_GPIO, LCD_E_PIN);
}

void HD44780HAL_E_low(void)
{
	GPIO_SetLow(LCD_E_GPIO, LCD_E_PIN);
}

i takie makra:

#define LCD_E_GPIO 	GPIOB
#define LCD_E_PIN 	3

i już mi tu zaświtała kolejna rzecz - co jeśli będą potrzebne jeszcze inne rzeczy - np. funkcja będzie przyjmować 3 argumenty?

więc zamiast pamiętać ile rzeczy potrzebujemy poustawiać - zbierzmy je przy uzupełnianiu w jedno makro ze wszystkim oznaczone jako przyrostkiem “CONFIG”

#define LCD_E_GPIO 	GPIOB
#define LCD_E_PIN 	3
#define LCD_E_GPIO_CONFIG LCD_E_GPIO,LCD_E_PIN

po zmodyfikowaniu w ten sposób ustawień dla HDHAL - uzyskaliśmy bibliotekę w której nie musimy kombinować nawet gdyby do funkcji GPIO coś jeszcze doszło w przyszłości

  • tzn. w ten sposób nawet pliki HDHAL są niezależne na modyfikacje biblioteki z której korzystają - czyli “GPIO”.

OK - bazowałem na examplu bez sprawdzania busy flag - ale dodajmy sobie to i to też trochę ładniej niż w defaultowej bibliotece.

W skrócie - dzięki sprawdzaniu flagiBUSY możemy pozbyć się delayów pomiędzy komendami. Czyli jeśli chcemy mieć wspólną bibliotekę to domyślamy się, ze wleci jakiś #ifdef. Funkcje przesyłające dane wyglądają tak:

dla wersji z delayem:

void _LCD_Write(unsigned char dataToWrite)
{
LCD_E_PORT |= LCD_E;
_LCD_OutNibble(dataToWrite >> 4);
LCD_E_PORT &= ~LCD_E;
LCD_E_PORT |= LCD_E;
_LCD_OutNibble(dataToWrite);
LCD_E_PORT &= ~LCD_E;
_delay_us(50);
}

dla wersji z busy flag:

void _LCD_Write(unsigned char dataToWrite)
{
LCD_DB4_DIR |= LCD_DB4;
LCD_DB5_DIR |= LCD_DB5;
LCD_DB6_DIR |= LCD_DB6;
LCD_DB7_DIR |= LCD_DB7;

LCD_RW_PORT &= ~LCD_RW;
LCD_E_PORT |= LCD_E;
_LCD_OutNibble(dataToWrite >> 4);
LCD_E_PORT &= ~LCD_E;
LCD_E_PORT |= LCD_E;
_LCD_OutNibble(dataToWrite);
LCD_E_PORT &= ~LCD_E;
while(LCD_ReadStatus()&0x80);
}

Widzimy też ustawienia dodatkowe odnosnie trybu pinów - ale to pominiemy na ten moment.

Mamy różnicę w postaci ostatniej funkcji - czyli to co pisałem z ifdefem zapewne wleciałoby u większości w taki sposób:

#define HD44780_USE_BUSY_FLAG 1

void _LCD_Write(unsigned char dataToWrite)
{
	// nieistotny aktualnie kod
	LCD_RW_PORT &= ~LCD_RW;
	LCD_E_PORT |= LCD_E;
	_LCD_OutNibble(dataToWrite >> 4);
	LCD_E_PORT &= ~LCD_E;
	LCD_E_PORT |= LCD_E;
	_LCD_OutNibble(dataToWrite);
	LCD_E_PORT &= ~LCD_E;

#if HD44780_USE_BUSY_FLAG
	while(LCD_ReadStatus()&0x80);
#else
	_delay_us(50);
#endif
}

no i oczywiście to zadziała. Ale też pamiętajmy, ze wcześniej też dojdą ustawienia pinów jako output też “okraszone” tym ifdefem. Osobiście wolę to robić trochę inaczej - moja propozycja:

#define HD44780_USE_BUSY_FLAG 1

static inline void _LCD_wait_after_commnand(void)
{
#if HD44780_USE_BUSY_FLAG
	while(LCD_ReadStatus()&0x80);
#else
	_delay_us(50);
#endif
}

void _LCD_Write(unsigned char dataToWrite)
{
	// nieistotny aktualnie kod
	LCD_RW_PORT &= ~LCD_RW;
	LCD_E_PORT |= LCD_E;
	_LCD_OutNibble(dataToWrite >> 4);
	LCD_E_PORT &= ~LCD_E;
	LCD_E_PORT |= LCD_E;
	_LCD_OutNibble(dataToWrite);
	LCD_E_PORT &= ~LCD_E;

	_LCD_wait_after_commnand();
}

W ten sposób ukryliśmy #if pod funkcją + dzięki jej nazwie zrobiliśmy sobie komentarz co się dzieje. I tak - ma to dwa końce - ktoś może powiedzieć ~”ile potem będzie funkcj i trzeba skakać po pliku itp” (z czym się już spotkałem od seniora embedded) - odpowiem tak - jak ktoś nie umie się poruszać po IDE, nie zna jego skrótów itp (ogólnie temat “known your tools” to motyw na wielki osobny wpis) - to już jego decyzja i niech sobie hejtuje takie coś. Pamiętajcie, ze kod napiszecie w 5 min, ale potem w czasie jego życia będzie czytany godzinami - w tym przez was. Więc jak funkcje mają zadnieżdzone w sobie ogromne sekcje #if - powodzenia w czytaniu i też w późniejszym szukaniu błędów, a co najgorsze - dodawaniu jakiś nowych opcji!

Zboczyliśmy z tematu - wracamy do HD44780. Jako, że pracuje na STMce - muszę najpierw sprawdzić, które piny są 5V tollerant - do nich będą podpięte piny LCDka. Mogą to być np:

  • PB2-15
  • PC6-9
  • PA8-13

więc podłączyłem sobie wszystko do GPIOB. I działa poprawnie :)

Z dalszych poprawek - funkcje z pliku HD44780.c, które nie są potrzebne użytkownikowi - dajemy jako static ograniczając ich widoczność!

I w tym miejscu utworzyłem repo na gicie na bibliotekę do wykorzystania jako submoduł -> https://github.com/dambo1993/HD44780.

Przy okazji zrobiłem też repozytorium na bibliotekę GPIO HAL - uzupełniony dla wersji STM32_F0.

Kilka komentarzy odnośnie wpisu jako UPDATE.

  • podczas rozbijania logiki bilioteki nie wnikałem w modyfikację komentarzy itp - dlatego są zostawione po polsku
  • przy następnym projekcie dostosuję też bibliotekę do swojego nowego podejścia zgodnego z wpisem - https://www.embedownik.pl/posts/005.html
  • po czasie widzę, że zrobiłem ogromny błąd - jako pierwszy commit powinna znaleźć się pobrana wersja biblioteki bez przeróbek co pozwoliłoby łatwo zobaczyć jakie dokładnie zaszły zmiany.
  • w przypadku tej biblioteki początkowym zamysłem było, aby to pliki HDHAL były abstrakcją nad HW, jednak doszła kolejna wartstwa w postaci biblioteki GPIO i to ona przejęła tą odpowiedzialność, pozostawiając HDHAL jako wraper
    • co nie jest idealnym rozwiązaniem jeśli chodzi o reużywalność - zmusza użytkownika do dodania w swoim projekcie użycia tej biblioteki GPIO - w przypadku zmiany platformy na inną rodzinę procków - będzie trzeba ją rozbudować, ale tylko ją - bez żadnej modyfikacji w HD44780
  • podsumowują przykład tej biblioteki jest banalny do modyfikacji - wystarczyło zrobić abstrakcje na:
    • funkcje inicjalizacji i dostępu do GPIO
    • funkcje obsługujące delay
  • i to wystarczyło do umożliwienia łatwego reużywania
  • biblioteka nie zakłada możliwości podłączenia kilku wyświetlaczy - takie wymaganie spowodowałoby zupełnie inny wygląd biblioteki + zastosowanie podejścia obiektowego