Programowanie STM32 w C++ – szablony

W poprzednich artykułach tej serii przyglądaliśmy się mechanizmom języka C++, które pozwalają uporządkować kod w projektach embedded bez utraty kontroli nad sprzętem. Był już temat klas, abstrakcji czy polimorfizmu. Tym razem przechodzimy do narzędzia, które w świecie mikrokontrolerów bywa niedoceniane, a jednocześnie potrafi rozwiązać wiele problemów architektonicznych — szablonów (templates).

W środowisku STM32 temat ten jest szczególnie interesujący. Z jednej strony mamy ograniczenia pamięci, deterministyczny czas wykonania i często brak systemu operacyjnego. Z drugiej — chcemy pisać kod skalowalny, bezpieczny i możliwy do ponownego użycia. Szablony znajdują się dokładnie na styku tych dwóch światów.

Czy są one „zbyt abstrakcyjne” na embedded? A może właśnie pozwalają pisać kod bliższy sprzętowi, a jednocześnie bardziej uporządkowany? W tym artykule przyjrzymy się temu, jak programowanie generyczne może pomóc w budowie sterowników i komponentów sprzętowych na STM32 — bez narzutu runtime.

Czy szablony na mikrokontrolerach mają sens?

W poprzednich artykułach wspominał już o szablonach przy okazji innych elementów języka C++. Dzisiaj chciałbym skupić się na nich i dokładniej omówić do czego mogą nam posłużyć w embedded.

Na poziomie aplikacji embedded bardzo często spotykamy się z powtarzalnością:

  • kilka LED-ów podłączonych do różnych pinów,
  • różne instancje UART działające według tej samej logiki,
  • timery konfigurowane podobnie, ale dla innych częstotliwości,
  • czujniki o identycznym interfejsie, lecz podłączone do innych kanałów.

W języku C typowym rozwiązaniem jest:

  • przekazywanie struktur konfiguracyjnych,
  • stosowanie makr,
  • lub kopiowanie kodu.

Każde z tych podejść działa — ale każde ma też swoje ograniczenia. Kod zaczyna być mniej czytelny, łatwiej o błędy konfiguracji, a wiele problemów ujawnia się dopiero w czasie działania programu.

Szablony pozwalają przenieść część decyzji konfiguracyjnych z runtime do compile-time. A to zmienia bardzo dużo. Wbrew obiegowej opinii, C++ w embedded nie oznacza automatycznie cięższej binarki. Szablony są generowane przez kompilator i nie wymagają mechanizmów runtime takich jak vtable czy RTTI.

Szablony jako narzędzie konfiguracji sprzętu

Najważniejsza różnica między klasycznym podejściem a podejściem szablonowym polega na tym, że konfiguracja przestaje być danymi — a zaczyna być częścią typu. Oznacza to, że zamiast przechowywać informacje o sprzęcie w strukturach lub przekazywać je jako argumenty funkcji w czasie działania programu, przenosimy je bezpośrednio do definicji komponentu już na etapie kompilacji.

Brzmi to abstrakcyjnie, ale w praktyce sprowadza się do bardzo konkretnej zmiany sposobu myślenia o kodzie: konfiguracja nie jest czymś, co można zmienić w runtime, lecz czymś, co definiuje samą naturę obiektu. Dzięki temu zamiast przekazywać numer pinu jako parametr funkcji, możemy „wbudować” go w definicję klasy, co eliminuje potrzebę dodatkowych sprawdzeń i upraszcza zarówno strukturę kodu, jak i jego działanie.

W pierwszym artykule zaimplementowaliśmy klasę Led z konstruktorem:

class led {
private:
    GPIO_TypeDef* const port;
    const uint16_t pin;
 
public:
    led(GPIO_TypeDef *gpioPort, uint16_t gpioPin) : port(gpioPort), pin(gpioPin) {}
 
    void on() {
        HAL_GPIO_WritePin(port, pin, GPIO_PIN_SET);
    }
 
    void off() {
        HAL_GPIO_WritePin(port, pin, GPIO_PIN_RESET);
    }
 
    void toggle() {
        HAL_GPIO_TogglePin(port, pin);
    }
};

Tę samą funkcjonalność możemy otrzymać za pomocą szablonów. Pierwszą myślą przy implementacji szablonu byłoby użycie takiej deklaracji:

template<GPIO_TypeDef* Port, uint16_t Pin>
class Led
{
public:
    void on() {
        HAL_GPIO_WritePin(Port, Pin, GPIO_PIN_SET);
    }
    void off() {
        HAL_GPIO_WritePin(Port, Pin, GPIO_PIN_RESET);
    }
    void toggle() {
        HAL_GPIO_TogglePin(port, pin);
    }
};

Tworząc obiekt:

Led<GPIOA, GPIO_PIN_5> statusLed;

tworzymy konkretny typ LED-a, a nie uniwersalny sterownik wymagający konfiguracji w runtime. Dla kompilatora Led<GPIOA, GPIO_PIN_5> i Led<GPIOB, GPIO_PIN_3> to dwie różne klasy.

Jednak przy próbie zbudowania projektu otrzymujemy błąd:

../Core/Src/main.cpp:177:29: error: '((1073741824 + 134217728) + 0)' is not a valid template argument for 'GPIO_TypeDef*' because it is not the address of a variable

Ten błąd wynika z bardzo ważnego szczegółu: argument szablonu musi być znany kompilatorowi jako stały adres obiektu, a nie jako makro rozwijające się do liczby. A template non-type parameter typu wskaźnikowego musi wskazywać na obiekt posiadający symbol, np. zmienną globalną
lub obiekt statyczny. Trzeba wprowadzić symbol, który ma adres, aby kompilator mógł potraktować port jako legalny argument szablonu. To jest problem tego, że HAL nie został zaprojektowany z myślą o compile-time konfiguracji – język C nie daje takich możliwości, a HAL był pisany pod C.

Aby poradzić sobie z tym problemem utworzymy dodatkowy symbol w postaci struktury, który będzie mógł być użyty jako argument szablonu, a jednocześnie nadal będziemy mogli korzystać z HAL-a. Coś w rodzaju adaptera.

struct PortA {
    static GPIO_TypeDef* get() { return GPIOA; }
};

Teraz szablon będzie wyglądał tak:

template<typename port, uint16_t pin>
class Led
{
public:
    void on() {
        HAL_GPIO_WritePin(port::get(), pin, GPIO_PIN_SET);
    }
    void off() {
        HAL_GPIO_WritePin(port::get(), pin, GPIO_PIN_RESET);
    }
    void toggle() {
        HAL_GPIO_TogglePin(port::get(), pin);
    }
};
Led<PortA, GPIO_PIN_5> statusLed;

Podejście z użyciem szablonów sprawia, że konfiguracja sprzętowa staje się częścią typu, co pozwala kompilatorowi generować maksymalnie zoptymalizowany kod bez narzutu runtime. Dzięki temu wywołania metod takich jak on() czy off() mogą zostać w pełni zinline’owane, co eliminuje potrzebę przekazywania parametrów i dodatkowych warstw abstrakcji. Zyskujemy również większe bezpieczeństwo typów, ponieważ nie da się przypadkowo użyć niewłaściwego pinu – każdy LED jest osobnym, jednoznacznie zdefiniowanym bytem.

Wadą jest jednak to, że rośnie liczba typów w systemie, co może zwiększać czas kompilacji i utrudniać zarządzanie kodem w większych projektach. Dodatkowo tracimy elastyczność runtime, ponieważ nie możemy łatwo zmienić konfiguracji bez rekompilacji programu. W praktyce jest to więc podejście idealne dla systemów wbudowanych, gdzie liczy się wydajność i deterministyczność, ale mniej wygodne tam, gdzie potrzebna jest dynamiczna konfiguracja.

Programowanie generyczne przy użyciu szblonów

Programowanie generyczne w C++ to podejście polegające na tworzeniu kodu, który nie jest związany z jednym konkretnym typem danych lub jedną konfiguracją sprzętową, lecz może działać z wieloma wariantami dzięki parametrom szablonów. W systemach embedded oznacza to możliwość napisania jednego komponentu, który będzie obsługiwał różne peryferia mikrokontrolera bez konieczności powielania logiki.

W praktyce programowanie generyczne polega na tym, że logika sterownika pozostaje taka sama, a zmieniają się jedynie parametry przekazywane do szablonu. Przykładowo można stworzyć klasę sterownika PWM, która przyjmuje jako parametry timer oraz kanał, dzięki czemu jeden kod obsługuje wiele konfiguracji sprzętowych. Taki sterownik może wyglądać następująco:

template<typename Timer, uint32_t Channel>
class Pwm 
{ 
    public: void setDuty(uint16_t duty) 
    {
        __HAL_TIM_SET_COMPARE(Timer::instance(), Channel, duty);
    } 
};

Następnie dla konkretnej konfiguracji sprzętowej wystarczy zdefiniować typ timera, np.

struct Timer1 { 
	static TIM_HandleTypeDef* instance() { return &htim1; }
};

Dzięki temu możemy utworzyć instancję

Pwm<Timer1, TIM_CHANNEL_1> motorPwm;

która steruje konkretnym wyjściem PWM.

Analogicznie można stworzyć sterownik ADC, który jako parametr przyjmuje kanał pomiarowy, np.

template<uint32_t Channel>
class AdcReader
{ 
	public: uint16_t read()
	{ 
		/* konfiguracja i odczyt kanału */ 
	} 
};

Wtedy AdcReader<ADC_CHANNEL_5> temperatureSensor; i AdcReader<ADC_CHANNEL_8> batteryMonitor; korzystają z tego samego kodu, ale obsługują różne wejścia analogowe. Dzięki programowaniu generycznemu jeden dobrze zaprojektowany sterownik może obsłużyć wiele wariantów sprzętowych, co w większych projektach STM32 pozwala zaoszczędzić nawet setki linii powtarzalnego kodu.

Szablony i optymalizacja kodu

Szablony w C++ mają również istotny wpływ na optymalizację kodu wynikowego, co w systemach embedded jest szczególnie ważne ze względu na ograniczone zasoby mikrokontrolera.

Jedną z głównych zalet jest to, że kompilator może łatwo zastosować inlining funkcji, ponieważ wszystkie parametry szablonu są znane w czasie kompilacji. W praktyce oznacza to, że wywołanie metody klasy szablonowej często zostaje bezpośrednio wstawione w miejsce użycia, bez dodatkowego narzutu wywołania funkcji. Dzięki temu kod wykonywany przez mikrokontroler jest prostszy i szybszy, a kompilator może dodatkowo usuwać niepotrzebne instrukcje lub warunki.

Warto jednak pamiętać, że każda instancja szablonu generuje oddzielną wersję funkcji w kodzie wynikowym, co w niektórych przypadkach może zwiększyć rozmiar programu w pamięci Flash. Jeśli tworzymy wiele wariantów tej samej klasy dla różnych konfiguracji sprzętowych, kompilator wygeneruje dla nich osobne fragmenty kodu. Dlatego przy projektowaniu bibliotek embedded warto zachować równowagę między elastycznością a liczbą instancji szablonów.

Dodatkową optymalizację można osiągnąć poprzez połączenie szablonów z constexpr, które pozwala wykonywać obliczenia jeszcze na etapie kompilacji. W ten sposób wartości takie jak preskalery timerów czy częstotliwości pracy mogą zostać wyliczone zanim powstanie binarka programu. Przykładowo, dla klasy Led<PortA, 5> kompilator wygeneruje osobną funkcję obsługującą konkretny pin, ale kod będzie maksymalnie prosty i pozbawiony zbędnych instrukcji warunkowych, takich jak if sprawdzające numer pinu w czasie działania programu.

Zaawansowane możliwości szablonów

Szablony w C++ oferują także bardziej zaawansowane mechanizmy, które pozwalają budować bardzo elastyczne i jednocześnie wydajne komponenty w systemach embedded. Jedną z takich możliwości jest przekazywanie złożonych typów jako parametrów szablonu, na przykład klas opisujących konkretne porty, konfiguracje sprzętowe lub strategie działania komponentu. Dzięki temu logika sterownika może pozostać uniwersalna, a szczegóły implementacji – takie jak port GPIO czy sposób aktywacji pinu – są definiowane przez przekazany typ. W praktyce oznacza to oddzielenie warstwy logiki aplikacyjnej od warstwy konfiguracji sprzętowej, co ułatwia utrzymanie kodu i jego ponowne wykorzystanie w innych projektach.

W bardziej zaawansowanych przypadkach można również korzystać z warunków kompilacyjnych, takich jak std::enable_if czy if constexpr, które pozwalają kompilatorowi wybierać różne fragmenty implementacji w zależności od parametrów szablonu. Dzięki temu jedna klasa może obsługiwać różne przypadki bez wprowadzania instrukcji if wykonywanych w czasie działania programu. Kod pozostaje więc czytelny, a jednocześnie w pełni zoptymalizowany przez kompilator.

Jednym z popularnych podejść wykorzystujących te mechanizmy jest policy-based design, czyli wzorzec projektowy, w którym zachowanie klasy jest definiowane przez dodatkowy typ przekazywany jako parametr szablonu. Taki typ nazywany jest polityką (policy) i może określać na przykład sposób sterowania pinem GPIO. Dobrym przykładem jest sterownik LED, który w zależności od zastosowanego układu elektronicznego może być aktywny stanem wysokim lub aktywny stanem niskim. Zamiast tworzyć dwie oddzielne klasy, można przekazać odpowiednią politykę jako parametr szablonu. Przykładowa implementacja może wyglądać następująco:

struct ActiveHigh {
    static constexpr GPIO_PinState on  = GPIO_PIN_SET;
    static constexpr GPIO_PinState off = GPIO_PIN_RESET;
};

struct ActiveLow {
    static constexpr GPIO_PinState on  = GPIO_PIN_RESET;
    static constexpr GPIO_PinState off = GPIO_PIN_SET;
};

template<typename Port, uint16_t Pin, typename Policy>
class Led {
public:
    void on() {
        HAL_GPIO_WritePin(Port::port(), Pin, Policy::on);
    }    void off() {
        HAL_GPIO_WritePin(Port::port(), Pin, Policy::off);
    }
};

Dzięki takiemu podejściu jedna klasa Led może obsługiwać różne warianty sprzętowe bez zmiany implementacji. Wystarczy wybrać odpowiednią politykę podczas tworzenia obiektu, np. Led<PortA, 5, ActiveHigh> lub Led<PortA, 5, ActiveLow>. Kompilator wybiera właściwe wartości już na etapie kompilacji, dzięki czemu kod wynikowy nie zawiera dodatkowych warunków sprawdzających typ sterowania. W większych projektach embedded takie podejście pozwala budować modularne i łatwo rozszerzalne biblioteki, które zachowują wysoką wydajność i deterministyczny czas wykonania.

Pułapki i ograniczenia

Choć szablony w C++ są bardzo potężnym narzędziem, ich stosowanie w systemach embedded (w tym na mikrokontrolerach STM32) wiąże się również z pewnymi pułapkami i ograniczeniami, o których warto pamiętać podczas projektowania architektury kodu.

Jednym z najczęstszych problemów jest nadmierne użycie szablonów, które może prowadzić do wzrostu rozmiaru programu w pamięci Flash. Wynika to z faktu, że dla każdej instancji szablonu kompilator generuje oddzielną wersję funkcji lub klasy, co przy dużej liczbie wariantów konfiguracji sprzętowych może znacząco zwiększyć wielkość binarki. W praktyce oznacza to, że należy zachować rozsądną równowagę między elastycznością kodu a liczbą instancji szablonów generowanych przez kompilator.

Kolejnym wyzwaniem jest debugowanie błędów kompilacji, ponieważ komunikaty związane z szablonami potrafią być bardzo długie i trudne do zinterpretowania, szczególnie gdy dotyczą zagnieżdżonych typów lub skomplikowanych zależności między parametrami szablonów. W takich przypadkach pomocne jest analizowanie komunikatu od jego pierwszej części, która zazwyczaj wskazuje miejsce, gdzie pojawił się rzeczywisty problem, a nie kolejne linie pokazujące tylko konsekwencje błędu. Warto także stosować czytelne nazwy typów oraz rozbijać złożone konstrukcje szablonowe na mniejsze komponenty, co znacznie ułatwia diagnozowanie problemów.

Dodatkową trudnością mogą być konflikty z bibliotekami napisanymi w C, takimi jak HAL czy LL, które nie zostały zaprojektowane z myślą o programowaniu generycznym i operują głównie na makrach oraz wskaźnikach przekazywanych w runtime. W takich sytuacjach często konieczne jest wprowadzenie cienkiej warstwy pośredniej, która mapuje elementy biblioteki C na typy używane w szablonach. Dzięki temu można zachować kompatybilność z istniejącymi bibliotekami, a jednocześnie korzystać z zalet programowania generycznego w C++.

Podsumowanie

Szablony w C++ często bywają postrzegane jako skomplikowana lub „ciężka” abstrakcja, jednak w rzeczywistości w programowaniu embedded pełnią bardzo praktyczną rolę. Pozwalają one przenieść konfigurację systemu z czasu działania programu (runtime) do czasu kompilacji (compile-time), dzięki czemu wiele decyzji dotyczących sprzętu i konfiguracji jest podejmowanych jeszcze zanim kod trafi na mikrokontroler.

W kontekście STM32 oznacza to przede wszystkim większe bezpieczeństwo typów, ponieważ kompilator może wykryć błędy konfiguracji jeszcze na etapie kompilacji. Dodatkową zaletą jest przewidywalność działania, ponieważ kod wygenerowany przez kompilator nie zawiera dodatkowych warunków ani mechanizmów dynamicznych. Szablony umożliwiają również tworzenie elastycznych komponentów sprzętowych, które można łatwo dostosować do różnych konfiguracji pinów, peryferiów czy wariantów hardware’u. Co ważne, wszystko to odbywa się bez narzutu wykonania w runtime, ponieważ kompilator generuje bezpośredni kod dla konkretnej konfiguracji.

W praktyce oznacza to, że dobrze zaprojektowane szablony pozwalają tworzyć biblioteki i komponenty, które są jednocześnie uniwersalne i bardzo wydajne. Ten sam sterownik może obsługiwać różne porty GPIO, różne kanały ADC czy różne konfiguracje timerów, bez konieczności powielania kodu. Jednocześnie każda instancja takiego komponentu jest optymalizowana przez kompilator dokładnie pod kątem konkretnego użycia.

Oczywiście, jak w przypadku każdego narzędzia w C++, kluczowe jest świadome i umiarkowane stosowanie szablonów, aby nie doprowadzić do niepotrzebnego wzrostu rozmiaru programu czy nadmiernej komplikacji kodu. Używane rozsądnie szablony pozwalają budować kod, który jest skalowalny, wydajny i łatwy w utrzymaniu.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *