wtorek, 18 maja 2021

Interfejsy jako jedna z technik tworzenia kodu w embedded - zajęcia praktyczne

Sztuką jest biegle opanować tworzenie modułów oprogramowania nadających się do wielokrotnego wykorzystania, które stanowią  budulec dla dużych i niezawodnych aplikacji. W przeciwieństwie do nowoczesnych języków obiektowych, język C nie ma zbyt wielu mechanizmów pozwalających łatwo tworzyć interfejsy programisty (API), a tym bardziej mechanizmów zachęcających do opracowywania takich interfejsów. Większość programistów C korzysta na co dzień z różnego rodzaju gotowych API i bibliotek je implementujących, ale względnie niewielu tworzy i udostępnia nowe API ogólnego przeznaczenia. 

Ubolewam nad faktem, że tak mało jest u nas informacji na temat programowania komercyjnego w języku C. Nie wielu jest chętnych aby dzielić się wiedzą w tym zakresie. Brakuje przykładów jak się tworzy i prowadzi takie projekty, jakich narzędzi używa , jakich wzorców i technik programowania. Człowiek chciałby kiedyś wyjść z tej piaskownicy , którą uprawia na co dzień i zobaczyć jak to robią zawodowi programiści embedded. 

W artykule spróbuje podjąć temat tworzenia interfejsów czyli jednej z technik tworzenia oprogramowania w embedded. Oczywiście rozważam to z punktu widzenia amatora, więc za wszelkie szkody wynikłe ze stosowania przedstawionej w artykule techniki nie odpowiadam :).

Interfejs w dużym skrócie określa nam co powinno być zrobione i dystansuje nas od tego jak to ma być zrobione. W dobrze zaprojektowanej architekturze warstwy znajdujące się na wyższym poziomie abstrakcji nie zależą od konkretnej implementacji elementów z warstw niższych. Dzięki temu łatwiej jest wprowadzać zmiany w projekcie. Poniżej obrazek z moją wariacją interfejsu :

Najlepiej zrozumiemy zagadnienie rozważając konkretny przykład. Do ćwiczenia wykorzystam zestaw jaki mam najbliżej pod ręką czyli : moją płytkę dla STM32G0 , programatorem J-LINK EDU mini i IDE SEGGERA. W zasadzie to bez znaczenia jakim sprzętem i jakim IDE się posłużymy, bo skupię się bardziej na samej istocie zagadnienia a nie na sprzęcie. Nie mniej jakiś sprzęt jest potrzebny. Obiektem do którego będziemy tworzyć interfejs będzie driver dotyku CAP1296 firmy Microchip.
Poniżej przejdziemy przez kolejne kroki tworzenia interfejsu.
 
Tworzę nowy projekt w IDE SEGGERA, wypełniam go swoim szablonem projektu z konfiguracją sprzętu, struktura mojego projektu widoczna po lewej stronie :


Nie omawiam tutaj konfiguracji sprzętu. Pierwszą czynnością w projektowaniu interfejsu jest zdefiniowanie akcji jakie będziemy wykonywać na naszym obiekcie (CAP1296). Mamy tutaj sprawę prostą bo tylko dwie akcje możemy sobie wyobrazić tak na szybko, zapis do rejestru i odczyt z rejestru. Gdyby w przyszłości zaistniała konieczność dodania kolejnej akcji np. uśpić obiekt, to dodanie takiej funkcjonalności do interfejsu byłoby bardzo prostą operacją nie wywracającą do góry nogami projektu.

Zatem mamy dwie akcje : zapis do rejestru w CAP1296  i odczyt z rejestru w CAP1296

Kolejnym krokiem jest zbudowanie funkcji niskopoziomowych realizujących fizycznie nasze dwie akcje. Czyli to co robimy na co dzień w piaskownicy bawiąc się MCU. Te funkcje będziemy nazywali driverami. Dodaję do projektu dwa pliki I2C_cap1296_driver.hI2C_cap1296_driver.c w których deklaruję i definiuję funkcje/drivery obsługujące w najniższej warstwie mojego projektu moje akcje.






Tworzenie funkcji niskopoziomowych jest najbardziej pracochłonnym elementem tworzenia interfejsu ale dającym najwięcej satysfakcji i nauki w poznaniu MCU. W typowym podejściu byśmy poprzestali w projekcie na użyciu tych funkcji niskopoziomowych . Ale żonglowanie funkcjami niskopoziomowymi w projekcie w wyższych warstwach logiki projektu nie jest dobrym pomysłem i kończy się zawsze tym samym czyli tzw. kodem Spaghetti. Będąc w piaskownicy w większości przypadków posługujemy się takim właśnie kodem.

Mając funkcje drivery realizujące (implementujące) nam nasze akcje, przechodzimy do kolejnego etapu tworzenia interfejsu. Teraz będziemy tworzyć szablon interfejsu. W tym celu dodaję do projektu dwa pliki API_cap1296.hAPI_cap1296.c

Deklaruję typy wskaźników na nasze dwie funkcje/drivery :


Tworzę typ strukturalny i wypełniam go wskaźnikami , które będą wskazywać na funkcje/drivery. W tej strukturze  nazywamy przyjaźnie nasze akcje.


Deklaruję funkcję inicjalizującą interfejs. Funkcja zwraca typ strukturalny utworzony powyżej. Po co zwraca ? a to wyjdzie na końcu :)


Teraz w pliku API_cap1296.c definiuję funkcję tworzącą nasz interfejs :


W powyższej funkcji znajduje się definicja interfejsu. W tym celu tworzymy nową zmienną i2c_driver_interface o typie strukturalnym. W strukturze tej jak pamiętamy znajdują się dwa wskaźniki na funkcje z naszymi akcjami. Musimy przypisać tym wskaźnikom konkretne funkcje/drivery. Ostatnią czynnością funkcji definiującej interfejs jest zwrócenie na zewnątrz zdefiniowanego w funkcji interfejsu. Zaraz zobaczymy po co ta zwrotka. Przechodzimy do pliku main.c

Deklarujemy interfejs CAP1296_My_Interface, którym będziemy się posługiwać w aplikacji , "includujemy" plik API_cap1296.h:



Inicjujemy zadeklarowany w main.c interfejs CAP1296_My_Interface i tutaj posługujemy się wartością zwracaną przez funkcję o której mowa była wcześniej.


Przykład użycia naszego interfejsu na obiekcie CAP1296, czyli generalnie jak tego używamy w aplikacji.



Zabawa z interfejsami w języku C jest bardzo pożyteczna, ponieważ transportuje nas na "wyższy" level tworzenia oprogramowania, nabywamy też ogłady z użyciem np. wskaźników na funkcję i żonglowania nimi. Przykład , który pokazałem jest może nazbyt prymitywny i naiwny . Na pewno da się to zoptymalizować tak aby stworzyć szablon interfejsu doskonałego :). Nie mniej interfejsy są bardzo pożądaną techniką tworzenia kodu w embedded i naturalnie zaimplementowaną w języki obiektowe. Warto tym tropem podążać.


Pozdrawiam

PICmajster
picmajster.blog@gmail.com



Dodatek :

Interfejs do SPI – przykład


W pliku spi_interface.h :


- deklarujemy strukturę ze wskaźnikami na funkcję :


struct SPI_INTERFACE

{

void (*Initialize)(void);

void (*Close)(void);

bool (*Open)(uint8_t spiConfigIndex);

void (*BufferExchange)(void *bufferData, size_t bufferSize);

void (*BufferRead)(void *bufferData, size_t bufferSize);

void (*BufferWrite)(void *bufferData, size_t bufferSize);

uint8_t (*ByteExchange)(uint8_t byteData);

uint8_t (*ByteRead)(void);

void (*ByteWrite)(uint8_t byteData);

};


W pliku spi1.h :


- deklarujemy nazwę naszego interfejsu SPI1 o typie struktury SPI_INTERFACE


extern const struct SPI_INTERFACE SPI1;


- deklarujemy funkcje , które będą użyte w naszym interfejsie


void SPI1_Initialize(void);

void SPI1_Close(void);

bool SPI1_Open(uint8_t spiConfigIndex);

void SPI1_BufferExchange(void * bufferData, size_t bufferSize);

void SPI1_BufferRead(void * bufferData, size_t bufferSize);

void SPI1_BufferWrite(void * bufferData, size_t bufferSize);

uint8_t SPI1_ByteExchange(uint8_t byteData);

uint8_t SPI1_ByteRead(void);

void SPI1_ByteWrite(uint8_t byteData);


W pliku spi1.c :


- przypisujemy poszczególnym wskaźnikom struktury/obiektu SPI1 nasze funkcje :


const struct SPI_INTERFACE SPI1 = {

.Initialize = SPI1_Initialize,

.Close = SPI1_Close,

.Open = SPI1_Open,

.BufferExchange = SPI1_BufferExchange,

.BufferRead = SPI1_BufferRead,

.BufferWrite = SPI1_BufferWrite,

.ByteExchange = SPI1_ByteExchange,

.ByteRead = SPI1_ByteRead,

.ByteWrite = SPI1_ByteWrite,

};


- definjujemy poszczególne funkcje :


np.:


void SPI1_Close(void)

{

SPI1CON0bits.EN = 0;

}


void SPI1_ByteWrite(uint8_t byte)

{

SPI1TXB = byte;

}


Używamy nasz interfejs SPI1 :


SPI1.Close() ;

lub

SPI1.Close;


SPI1.ByteWrite(0x15) ;



Interfejs do I2C – przykład


W pliku i2c_interface.h :


- deklarujemy typ strukturalny i2c_host_interface_t ze wskaźnikami na funkcję :


typedef struct

{

void (*Initialize)(void);

void (*Deinitialize)(void);

bool (*Write)(uint16_t address, uint8_t *data, size_t dataLength);

bool (*Read)(uint16_t address, uint8_t *data, size_t dataLength);

bool (*WriteRead)(uint16_t address, uint8_t *writeData, size_t writeLength, uint8_t *readData, size_t readLength);

bool (*TransferSetup)(i2c_host_transfer_setup_t* setup, uint32_t srcClkFreq);

i2c_host_error_t (*ErrorGet)(void);

bool (*IsBusy)(void);

void (*CallbackRegister)(void (*callback)(void));

void (*Tasks)(void);

} i2c_host_interface_t;


W pliku i2c.h :


- deklarujemy nazwę i2c1_host_interface naszego interfejsu I2C1 o typie struktury i2c_host_interface_t


extern const i2c_host_interface_t i2c1_host_interface;


- deklarujemy funkcje , które będą użyte w naszym interfejsie


void I2C1_Initialize(void);

void I2C1_Deinitialize(void);

bool I2C1_Write(uint16_t address, uint8_t *data, size_t dataLength);

bool I2C1_Read(uint16_t address, uint8_t *data, size_t dataLength);

bool I2C1_WriteRead(uint16_t address, uint8_t *writeData, size_t writeLength, uint8_t *readData, size_t readLength);

i2c_host_error_t I2C1_ErrorGet(void);

bool I2C1_IsBusy(void);

void I2C1_CallbackRegister(void (*callbackHandler)(void));

void I2C1_Tasks(void);


W pliku i2c1.c :


- przypisujemy poszczególnym wskaźnikom struktury/obiektu i2c1_host_interface nasze funkcje :


const i2c_host_interface_t i2c1_host_interface = {

.Initialize = I2C1_Initialize,

.Deinitialize = I2C1_Deinitialize,

.Write = I2C1_Write,

.Read = I2C1_Read,

.WriteRead = I2C1_WriteRead,

.TransferSetup = NULL,

.ErrorGet = I2C1_ErrorGet,

.IsBusy = I2C1_IsBusy,

.CallbackRegister = I2C1_CallbackRegister,

.Tasks = I2C1_Tasks

};

- definjujemy ciala poszczególnych funkcji :

np.:

void I2C1_Initialize(void){

//ciało funkcji

}

Używamy nasz interfejs I2C1 :

i2c1_host_interface.I2C1_ Initialize() ;

lub

i2c1_host_interface.I2C1_ Initialize ;


9 komentarzy:

  1. Ten PICkit 4 pierońsko podrożał czy mi się zdaje ? Do nowych AVR potrzebuje ;/

    OdpowiedzUsuń
  2. Spójrz na to inaczej czy to PICkit 4 podrożał czy to pieniądz tak drastycznie stracił na wartości . Rozejrzyj się dookoła, u mnie warzywa pierońsko podrożały i wszystko co mnie otacza , pieniądz teraz to gorący kartofel. Banksterka światowa okrada nas inflacją z ciężko zarobionej kasy , żeby zdewaluować swoje długi . Dodatkowo przykre jest , że jesteśmy liderami w Europie jeśli chodzi o okradanie narodu i mamy najwyższe wskaźniki inflacyjne .

    OdpowiedzUsuń
  3. Gdzie relacja z wakacji? bo weny potrzebuję :)

    OdpowiedzUsuń
  4. Proponuje wyprawę rowerową nad morze, nocowanie w namiocie na dziko, na polu biwakowym lub u gospodarza. Wzdłuż morza jest szlak rowerowy R10 ok 480 km. Ja bym startował z poziomu mazur , do morza mamy wtedy ok 250 km . Dziennie robimy ok 60 km . Dojazd do morza zajmie ok 4-5 dni. Potem Brylowanie wzdłuż morza w lewo lub w prawo :) . Zaletą takiej formy wakacji jest to, że pozbędziemy się zbędnego tłuszczyku po-covidovego.
    Lepszego pomysłu nie mam. Wiem , że ci co spróbowali takiej aktywnej formy wypoczynku bardzo ją chwalili. Jedna wielka przygoda. Można też pociągiem podjechać z rowerami bliżej morza.

    OdpowiedzUsuń
  5. Panie PICmajster kiedy kolejne wpisy na blogu?

    OdpowiedzUsuń
  6. Pewnie w zimie bo póki jeszcze słońce świeci i ciepło jest to wolę przebywać na śœieżym powietrzu niż przed kompem siedzieć :).

    OdpowiedzUsuń
  7. Po takim czasie nieobecności pamiętasz jeszcze jak zmienić stan na porcie ? :) pytam całkiem serio, bo z doskoku człowiek tym się zajmuje i sporo drobiazgów umyka. Wiadomo mały drobiazg a potem coś nie działa.

    OdpowiedzUsuń
  8. Zamawiał ktoś z Was z pl.Farnell ? jakieś cła i inne ustrojstwa trzeba doliczyć? do prywatnego odbiorcy wysyłają paczki ? tanio dziady mają :)

    OdpowiedzUsuń
  9. Odnośnie pamiętania. Pewnie, że pamięć jest ulotna ale zmysł poznawczy mam na tyle wytrenowany na dokumentacjach technicznych, że przypomnieć sobie jak zmienić stan na porcie w MCU wystarczy mi zerknąć w rozdział o GPIO w datasheet.

    OdpowiedzUsuń