Bezpieczny i ściśle typowany port w C++ (1 z n)

Tu poruszamy tematy związane z pisaniem programów w języku C++ dla AVR.
Awatar użytkownika
mokrowski
User
User
Posty: 190
Rejestracja: czwartek 08 paź 2015, 20:50
Lokalizacja: Tam gdzie Centymetro

Bezpieczny i ściśle typowany port w C++ (1 z n)

Postautor: mokrowski » piątek 21 wrz 2018, 13:38

Język C++, coraz częściej jest wybierany do implementowania oprogramowania dla systemów wbudowanych. W tym artykule będę starał się wykazać że definicja bezpiecznego dostępu do portu z użyciem C++, jest jak najbardziej możliwa i posiada szereg zalet w stosunku do kodu pisanego w języku C (bez dodatkowych plusów). Moją ambicją nie jest jednocześnie wyczerpanie tematu. Chcę aby artykuł, mógł stać się dla osób chcących pogłębiać swoją wiedzę, punktem startu do własnych poszukiwań.

Jako platformę testową, użyję popularnego MCU AVR ATmega16. Na tym etapie, prędkość taktowania samego mikrokontrolera jest nieistotna. Podłączę do portu A tego MCU zestaw diod led tak aby obserwować efekty swoich działań.

Dla tych którzy liczą że będzie to opracowanie teoretyczne, małe ostrzeżenie. Uwaga: Pojawi się kod :) Nie będę oczywiście definiował w C++ numerów wszystkich portów tego MCU. Poprzestanę na kilku niezbędnych do samej implementacji.

Wymagania stawiane portom

Dostęp do portu danego MCU wykonywany jest najczęściej z użyciem:

1. Numeru tego portu - tu biblioteki oraz producenci starają się nie obciążać programujących pamiętaniem o konkretnych adresach w pamięci.
2. Maski dostępu do danych bitów portu - nie zawsze interesujące jest adresowanie wszystkich bitów. Czasem to bywa właściwie jeden.
3. Przesunięcia wartości - aby zapisać dane do portu lub je z niego odczytać, często trzeba je przesunąć bitowo o określoną wartość. To częste źródło błędów (w połączeniu z maskowaniem danych portu).

Dodatkowo dochodzą polityki dostępu do portu. Z najbardziej popularnych można wymienić:
1. Dostęp wyłącznie do odczytu - dane z portu można jedynie odczytać ale nie można do niego żadnych danych zapisać.
2. Dostęp wyłącznie do zapisu - dane do portu można zapisać ale czytanie ich nie ma żadnego sensu.
3. Dostęp do odczytu i zapisu - połączenie możliwości zapisania i odczytu danych do i z portu.
4. Wyzwalanie zapisem - wyłączenie lub włączenie danej funkcjonalności wykonane jest z użyciem zapisu danych do portu.
5. Wyzwalanie odczytem - wyłączenie lub włączenie danej funkcjonalności wykonywane jest poprzez odczytanie danych z portu.
6. Inne - do nich można zaliczyć wpisywanie ustalonych przez producenta "wartości magicznych" które udostępniają (często w reżimie czasowym) zmianę pewnej funkcjonalności. Jako przykład można podawać przełączenie sygnału domeny zegarowej, zapis do pamięci nieulotnej jednokrotnego zapisu, uruchomienie dostępu do zasobów w normalnym trybie niewidocznych.

Część z tych polityk dostępu pokażę w działaniu. Niestety objętość artykułu uniemożliwia pełną implementację.

No to do dzieła...

Pierwsze starcie

Podłączone do portu A ATmega16, będę zapalał stanem wysokim. Stąd niezbędne będzie:
1. Ustawienie portu kierunku danych na DDRA
2. Ustawienie stanu diod led na PORTA.

Zapalę diody odpowiadające bitom 0x3C. Na tym etapie nie będę silił się na wykonanie przesunięć. Zachowam prostotę przykładu.

Oto kod w języku C wykonujący tę operację:

Kod: Zaznacz cały

#include <avr/io.h>

__attribute__((noreturn)) int main(void) {
   DDRA = 0x3C;
   PORTA = 0x3C;
}


Użycie atrybutu noreturn wydaje się zbyt drastyczne w tym przypadku ale chcę odnieść się do argumentu objętości kodu C vs C++. Stąd zastosuję drastyczne środki.

Taki program w moim środowisku (avr-gcc 8.2.0), zajmuje 118 bajtów i większość z nich to tablice przerwań. Oczywiście kompiluję go z optymalizacja -Os.

Przejdę do równoważnego (i nieco przerażającego na pierwszy rzut oka przykładu w C++):

Kod: Zaznacz cały

#include <stdint.h>

__attribute__((noreturn)) int main() {
   // DDRA
   *reinterpret_cast<volatile uint8_t *>(0x1A + 0x20) = 0x3C;
   // PORTA
   *reinterpret_cast<volatile uint8_t *>(0x1B + 0x20) = 0x3C;
}


Pierwsza uderzająca różnica to "rozwlekły zapis" rzutowania. Rzutowanie w języku C++, posiada kilka rodzajów. W przypadku reinterpret_cast, mamy do czynienia ze zmianą znaczenia danych na te podane w argumencie szablonu ( https://en.cppreference.com/w/cpp/langu ... rpret_cast ).

Jest to najbardziej "brutalny" sposób rzutowania dopuszczający konwersje nawet pomiędzy strukturami a wskaźnikami na dane (aby dodać nieco kolorytu to tego technicznego artykułu, mogę porównać ten sposób rzutowania do dwuręcznego młotka którym chcesz naprawić precyzyjny mechanizm... jeśli wiesz co robisz to ... ok).

Typ danych to volatile uint8_t * bo dane w porcie są ulotne i powinny być przeczytane w momencie wykonania. Nie chcę mieć tu jakichkolwiek optymalizacji.

Numer portu w rodzinie ATmega, wymaga przesunięcia o wartość 0x20. Wtedy dajemy szansę kompilatorowi na wykrycie tego faktu i zastosowanie instrukcji asemblera in/out które będą mniejsze.

Operator wyłuskania wartości przed reinterpret_cast jest niezbędny do wpisania wartości do portu.

Dodatkowo warto spostrzec że nie korzystam z definicji <avr/io.h>. Włączam jedynie nagłówek z definicjami typów o stałej długości. To ostatnie jest powodem dlaczego porty są tak "niskopoziomowo-techniczne opisane" z użyciem stałych heksadecymalnych. Definicja portu DDRA dla AVR w języku C to przecież makro: (*(volatile uint8_t *)((0x1A) + 0x20))

Co ciekawe, objętość programu wynikowego to 112 bajtów (vs 118 dla C). Dlaczego jest ich mniej, pozostawię jako mały konkurs bez nagrody. Jeśli wiesz, napisz to w tym wątku.

Klasa portu i jego instancja

Takie operowanie portami jest niewygodne i powoduje dużą ilość błędów. Pójdę o krok dalej. Definuję klasę portu tak aby posługiwać się nią o wiele racjonalniej:

Kod: Zaznacz cały

#include <stdint.h>

class Port {
public:
   Port(uint8_t io_address_, uint8_t mask_, uint8_t shift_)
      : io_address{io_address_}, mask{mask_}, shift{shift_} {}

   void write(uint8_t value) {
      auto masked_value = *reinterpret_cast<volatile uint8_t *>(io_address) & ~mask;
      *reinterpret_cast<volatile uint8_t *>(io_address) = masked_value | (value << shift);
   }

   uint8_t read() const {
      return (*reinterpret_cast<volatile uint8_t *>(io_address) & mask) >> shift;
   }
private:
   const uint8_t io_address;
   const uint8_t mask;
   const uint8_t shift;
};

__attribute__((noreturn)) int main() {
   Port ddra = Port(0x1A + 0x20, 0x3C, 0x02);
   Port porta = Port(0x1B + 0x20, 0x3C, 0x02);
   ddra.write(0x0F);
   porta.write(0x0F);
}


Jest to ciągle szalenie naiwna ale poprawna implementacja obsługi portu.

Z zarzutów które można postawić takiej implementacji (a stoję na stanowisku że do każdego programu osoba nieżyczliwa czyli autor może mieć uwagi), to:
1. Zbędne definiowanie stałych atrybutów w części prywatnej klasy.
2. Niejasne przesyłanie przesunięcia (0x20) które powinno być wykonane w samej klasie.
3. Mało czytelny kod rzutowań i wyłuskania.

Na plus kodu można zaliczyć lepszą obsługę zapisu. Samodzielnie następuje tu przesunięcie danych tak przy zapisie jak i odczycie. Zwróć uwagę że np. ddra.write(...), zapisuje 0x0F a nie przesuniętą wartość 0x3C.

Szablon portu jako typ

Czy jednak potrzebna jest jakakolwiek instancja portu? Przecież jest on dobrze indentyfikowany przez kombinację 3 wartości. Numeru portu, maski i przesunięcia. W takich przypadkach intuicyjne jest przesunięcie części stałych do szablonu. W ten sposób można pozbyć się sekcji atrybutów prywatnych a nawet zbędnego konstruktora! Dodam tu jeszcze do przyszłych zastosowań, atrybuty stałe z przesunięciami, maską i adresem. Dopiero gdy będą użyte, mogą pojawić się w kodzie w postaci wartości.

Powtarza się także typ i adres wyliczonego portu. Wykonam na niego alias (do typu) oraz zdefiniuję statycznie sam adres:

Kod: Zaznacz cały

#include <stdint.h>

template<uint8_t IO_Address, uint8_t Mask, uint8_t Shift>
class Port {
public:
   // Stałe opisujące atrybuty portu
   constexpr static uint8_t io_address = IO_Address + 0x20;
   constexpr static uint8_t mask = Mask;
   constexpr static uint8_t shift = Shift;

   // Aliasy na typ i numer portu
   using port_type = volatile uint8_t * const;
   static port_type port_ptr;

   void write(uint8_t value) {
      auto masked_value = *port_ptr & ~mask;
      *port_ptr = masked_value | (value << shift);
   }

   uint8_t read() const {
      return (*port_ptr & mask) >> shift;
   }
};

// Konieczna definicja statyczna numeru portu.
template<uint8_t IO_Address, uint8_t Mask, uint8_t Shift>
typename Port<IO_Address, Mask, Shift>::port_type Port<IO_Address, Mask, Shift>::port_ptr = reinterpret_cast<port_type>(io_address);

__attribute__((noreturn)) int main() {
   Port<0x1A, 0x3C, 0x02> ddra;
   Port<0x1B, 0x3C, 0x02> porta;
   ddra.write(0x0F);
   porta.write(0x0F);
}


Warto zwrócić uwagę na subtelną różnicę w definicji typu portu. Pojawił się tam modyfikator const przed wskaźnikiem. Nie chcę przecież zmieniać numeru portu już zdefiniowanego.

Nieco przerażający wydaje się teraz zapis atrybutu statycznego. W dalszej implementacji będzie on zapewne zracjonalizowany.

Zgodnie z regułą DRY (ang. Don't Repeat Yourself) ( https://pl.wikipedia.org/wiki/DRY ), definicje elementów powtarzalnych zredukowałem do pojedynczego wystąpienia.

Jeśli jednak port jest poprawnie identyfikowany przez kombinację atrybutów, to po co mu metody właściwe obiektom? Powinny być statyczne.

Dodatkowo warto zauważyć że przesunięcie w większości wypadków można wywnioskować z maski obliczając najmłodsze zgaszone bity. Operację tę wykonam z użyciem funkcji constexpr która wykona się na etapie kompilacji. Jest to krok milowy w stosunku do makr udających funkcje w C. Implementacja jaką pokażę, wymaga kompilatora g++ wspierającego standard C++14:

Kod: Zaznacz cały

#include <stdint.h>

// Oblicza przesunięcie czyli ilość zer kończących maskę.
constexpr static uint8_t calculate_mask_shift(uint8_t mask) {
   uint8_t counter = 0;
   while (!(mask & 0x01)) {
      ++counter;
      mask >>= 1;
   }
   return counter;
}

template<uint8_t IO_Address, uint8_t Mask, uint8_t Shift = calculate_mask_shift(Mask)>
class Port {
public:
   // Stałe opisujące atrybuty portu
   constexpr static uint8_t io_address = IO_Address + 0x20;
   constexpr static uint8_t mask = Mask;
   constexpr static uint8_t shift = Shift;

   // Aliasy na typ i numer portu
   using port_type = volatile uint8_t * const;
   static port_type port_ptr;

   static void write(uint8_t value) {
      auto masked_value = *port_ptr & ~mask;
      *port_ptr = masked_value | (value << shift);
   }

   static uint8_t read() {
      return (*port_ptr & mask) >> shift;
   }
};

// Konieczna definicja statyczna numeru portu.
template<uint8_t IO_Address, uint8_t Mask, uint8_t Shift>
typename Port<IO_Address, Mask, Shift>::port_type Port<IO_Address, Mask, Shift>::port_ptr = reinterpret_cast<port_type>(io_address);

__attribute__((noreturn)) int main() {
   Port<0x1A, 0x3C>::write(0x0F);
   Port<0x1B, 0x3C>::write(0x0F);
}


Zarzutów do kodu można trochę postawić. Oczywiście to chwalebne że wywołania zostały zredukowane do w zasadzie 2 linii w main(). Niestety "wartości magiczne", czynią kod nieczytelnym. Będę chciał w przyszłości zdefiniować wszystkie porty danego MCU. Dobry początek uczynię teraz:

Kod: Zaznacz cały

#include <stdint.h>

// Oblicza przesunięcie czyli ilość zer kończących maskę.
constexpr static uint8_t calculate_mask_shift(uint8_t mask) {
   uint8_t counter = 0;
   while (!(mask & 0x01)) {
      ++counter;
      mask >>= 1;
   }
   return counter;
}

template<uint8_t IO_Address, uint8_t Mask, uint8_t Shift>
class Port {
public:
   // Stałe opisujące atrybuty portu
   constexpr static uint8_t io_address = IO_Address + 0x20;
   constexpr static uint8_t mask = Mask;
   constexpr static uint8_t shift = Shift;

   // Aliasy na typ i numer portu
   using port_type = volatile uint8_t * const;
   static port_type port_ptr;

   static void write(uint8_t value) {
      auto masked_value = *port_ptr & ~mask;
      *port_ptr = masked_value | (value << shift);
   }

   static uint8_t read() {
      return (*port_ptr & mask) >> shift;
   }
};

// Konieczna definicja statyczna numeru portu.
template<uint8_t IO_Address, uint8_t Mask, uint8_t Shift>
typename Port<IO_Address, Mask, Shift>::port_type Port<IO_Address, Mask, Shift>::port_ptr = reinterpret_cast<port_type>(io_address);

// Definicje numerów portów w oodzielnej przestrzeni adresowej
namespace io_port {

   constexpr static uint8_t io_portddra = 0x1A;
   constexpr static uint8_t io_porta = 0x1B;

} // namespace io_port

// Definicje szablonów portów. Będą kompletne dla danego typu MCU
template<uint8_t Mask, uint8_t Shift = calculate_mask_shift(Mask)>
using PortDDRA = Port<io_port::io_portddra, Mask, Shift>;

template<uint8_t Mask, uint8_t Shift = calculate_mask_shift(Mask)>
using PortA = Port<io_port::io_porta, Mask, Shift>;

// Nazwy portów odzwierciedlające rzeczywiste użycie, właściwe dla zastosowania (Led)
using PortLedDirection = PortDDRA<0x3C>;
using PortLedIO = PortA<PortLedDirection::mask>;

// I program główny..
__attribute__((noreturn)) int main() {
   PortLedDirection::write(0x0F);
   PortLedIO::write(0x0F);
}


Usunąłem domyślny argument szablonu dla Port (argument Shift) i przesunąłem go do definicji PortDDRA oraz PortA. Jak widać użyte słowo kluczowe using, pozwala "oszablonować" taki typ.

Numery portów właściwe typom MCU, przeniosłem do wydzielonej przestrzeni nazewniczej. Docelowo proste makro uzależnione od typu MCU, będzie przypisywało io_port_atmega16 do przestrzeni nazewniczej io_port. W ten sposób uniknę możliwego "przeplatania kodu z makrami" (a przynajmniej go zminimalizuję).

Na tym etapie, sensowne wydaje się także definiowanie nazw portów powiązanych z zastosowaniem. Zdaję sobie sprawę że użyłem "wartości magicznej" 0x3C w przypadku PortLedDirection. Zrobiłem to z tego powodu że w następnej linii chciałem pokazać uzyskanie tejże maski z istniejącej definicji (pomogły constexpr static ... z poprzednich etapów). Także aby pokazać że PortLedDirection oraz PortLedIO, są ze sobą powiązane funkcjonalnie (PORTA, DDRA, PINA to te 3 porty dla AVR które obsługują całość komunikacji IO danego portu).

Co dalej?

Sensowne będzie w dalszych etapach:
1. Połączenie definicji PORT*, DDR*, PIN* w jeden port ze wspólną maską.
2. Implementowanie wariantu portu dostępnego tylko w trybie do odczytu.

A później .. może zapis do EEPROM, może ADC..., może enkoder...

Ale to już zupełnie inna "para kaloszy" jak mówili deszczowcy ( https://pl.wikipedia.org/wiki/Porwanie_ ... G%C4%85bki ) :)

Jeśli ma mi się chcieć pisać, masz mnie do tego motywować. Domyślnym stanem jest stan nieustalony :)
,,Myślenie nie jest łatwe, ale można się do niego przyzwyczaić" - Alan Alexander Milne: Kubuś Puchatek

Awatar użytkownika
piotrek
User
User
Posty: 155
Rejestracja: niedziela 05 lis 2017, 02:46

Re: Bezpieczny i ściśle typowany port w C++

Postautor: piotrek » piątek 21 wrz 2018, 19:59

Pisz, pisz, ja np. jestem bardzo ciekawy jak się tworzy soft dla mcu w c++.
Przydałby się jakiś kontrprzykład bez użycia szablonów pokazujący niebezpieczeństwa.
Dlaczego używasz uintów jako typu szablonów? Nie lepiej zdefiniować klasy dla każdego z rejestrów? Coś w rodzaju boxingu / wrappingu.

Awatar użytkownika
acid3
User
User
Posty: 466
Rejestracja: czwartek 03 wrz 2015, 22:42
Lokalizacja: Kłopoty-Stanisławy
Kontaktowanie:

Re: Bezpieczny i ściśle typowany port w C++

Postautor: acid3 » piątek 21 wrz 2018, 20:20

Nauczaj !

Awatar użytkownika
mokrowski
User
User
Posty: 190
Rejestracja: czwartek 08 paź 2015, 20:50
Lokalizacja: Tam gdzie Centymetro

Re: Bezpieczny i ściśle typowany port w C++

Postautor: mokrowski » piątek 21 wrz 2018, 22:56

piotrek pisze:Dlaczego używasz uintów jako typu szablonów? Nie lepiej zdefiniować klasy dla każdego z rejestrów? Coś w rodzaju boxingu / wrappingu.


Na tym poziomie ograniczam się do obsłużenia wyłącznie dostępu do rejestrów. Tu instancja (czyli obiekt) nie ma racji bytu. Kombinacja numeru portu/maski/przesunięcia, dokładnie identyfikuje rejestr. Na ew. instancje (a i to sam zobaczysz czy rzeczywiście takie tradycyjne), przyjdzie czas jak wynurzę się do poziomu "obsługa portu A w sposób obiektowy". Czyli włączanie podciągania, zapis, konieczność zapisów buforowych itp.

Bazując na szablonach, całą pracę spycham na kompilator i na etap kompilacji a nie wykonania. Przez to kod powstaje bardzo optymalny. Poza tym wszyscy się tych szablonów boją a to konieczny (i zalecany przez normy branżowe) sposób programowania. Po prostu staram się oswoić temat :) Bez szablonów implementacja polityk dostępu, składania szyn, obsługi buforowania, wymaga karkołomnych wygibasów makrami i nie przynosi dobrych efektów.

PS. W drugiej części modularyzacja i zaprezentowane dwa z trzech racjonalnych podejść do definiowania całości obsługi portów.
,,Myślenie nie jest łatwe, ale można się do niego przyzwyczaić" - Alan Alexander Milne: Kubuś Puchatek


Wróć do „Programowanie AVR w C++”

Kto jest online

Użytkownicy przeglądający to forum: Obecnie na forum nie ma żadnego zarejestrowanego użytkownika i 1 gość