Programowanie STM32 w C++ – polimorfizm
Polimorfizm to jedno z tych pojęć w C++, które brzmi poważnie i często wywołuje mieszane reakcje — szczególnie w świecie systemów wbudowanych. Jedni widzą w nim klucz do czystej i elastycznej architektury, inni od razu myślą o dodatkowym narzucie, tablicach wirtualnych i niepotrzebnym komplikowaniu kodu na mikrokontrolerze.
W przypadku STM32 to temat wyjątkowo ciekawy, bo zderzają się tu dwa światy: nowoczesne mechanizmy języka C++ oraz bardzo konkretne ograniczenia sprzętowe. Czy polimorfizm faktycznie jest zbyt „ciężki” na MCU? A może — użyty świadomie — pomaga pisać kod prostszy w utrzymaniu i rozbudowie?
Ten artykuł skupia się właśnie na tym pytaniu. Przyjrzymy się, czym polimorfizm jest w praktyce, jakie ma zastosowanie w programowaniu mikrokontrolerów i gdzie przebiega granica między elegancką abstrakcją a niepotrzebnym narzutem. 😉
Czym jest polimorfizm i dlaczego w ogóle o nim mówić w świecie mikrokontrolerów?
Polimorfizm pozwala traktować różne obiekty w jednakowy sposób, nawet jeśli ich wewnętrzne działanie się różni. W praktyce oznacza to, że kod korzysta z wspólnego interfejsu, a szczegóły implementacji są ukryte „pod spodem”.
W projektach na STM32 takie sytuacje pojawiają się częściej, niż mogłoby się wydawać:
- kilka czujników realizujących ten sam pomiar, ale na różne sposoby,
- różne interfejsy sprzętowe, które z punktu widzenia aplikacji robią to samo,
- wiele wariantów hardware’u, które mają wspólne zachowanie, ale inną konfigurację.
Dzięki polimorfizmowi możemy:
- uprościć kod aplikacji,
- oddzielić logikę biznesową od sprzętu,
- pisać kod łatwiejszy do testowania i rozbudowy.
A to bardzo konkretne korzyści – nawet (a może szczególnie) w systemach wbudowanych.
Różnice między C++ „desktopowym” a C++ na MCU
Już na początku warto jasno podkreślić, że C++ używany na mikrokontrolerach nie jest C++ bez żadnych ograniczeń. W przypadku STM32 pracujemy w środowisku, w którym pamięć RAM i Flash są ściśle limitowane, często nie mamy do dyspozycji pełnoprawnego systemu operacyjnego, a jedynie prosty RTOS lub wręcz działamy w trybie bare-metal. Do tego dochodzi jeszcze jeden istotny aspekt — deterministyczny czas wykonania, który w systemach wbudowanych bywa ważniejszy niż sama wygoda pisania kodu.
Wszystko to sprawia, że mechanizmy dobrze znane z aplikacji desktopowych nie zawsze mogą być stosowane bezrefleksyjnie. Część z nich wymaga większej ostrożności, a z niektórych w określonych miejscach projektu po prostu lepiej zrezygnować. Dotyczy to również polimorfizmu, który w embedded nie jest czymś zakazanym, ale wymaga świadomości związanych z nim kosztów — zarówno pod względem zużycia pamięci, jak i czasu wykonania — oraz przemyślanych decyzji architektonicznych. Innymi słowy, problemem nie jest „C++ kontra mikrokontrolery”, lecz umiejętność korzystania z C++ w sposób dostosowany do realiów MCU.
Typowe mity: „C++ i polimorfizm są za ciężkie na embedded”
Jednym z najczęściej powtarzanych haseł w kontekście C++ na mikrokontrolerach jest stwierdzenie, że „polimorfizm i virtual zabiją wydajność STM32”. Brzmi groźnie, ale w praktyce rzeczywistość jest znacznie spokojniejsza. Nie każdy polimorfizm oznacza duży narzut, a nowoczesne kompilatory potrafią bardzo skutecznie optymalizować kod. W wielu miejscach koszt wywołań wirtualnych okazuje się wręcz pomijalny w porównaniu do korzyści, jakie daje lepsza struktura i czytelność programu.
Oczywiście nie oznacza to pełnej dowolności. W systemach wbudowanych obowiązuje kilka zdroworozsądkowych zasad:
- nie stosuje się polimorfizmu w przerwaniach ani w krytycznych pętlach czasowych,
- unika się nadużywania alokacji dynamicznej,
- i nie traktuje się C++ jak „magii, która wszystko załatwi” bez konsekwencji.
Krótko mówiąc, polimorfizm w embedded nie jest problemem sam w sobie — problemem jest brak świadomości, kiedy i jak go używać.
W dalszej części artykułu przyjrzymy się więc:
- które rodzaje polimorfizmu są szczególnie przydatne na STM32,
- kiedy użycie
virtualma sens, a kiedy lepiej sięgnąć po szablony, - oraz jak pisać kod, który pozostaje elastyczny, a jednocześnie lekki i przewidywalny w działaniu.
Polimorfizm w C++
Polimorfizm w C++ to mechanizm, który pozwala temu samemu interfejsowi prowadzić do różnych zachowań w zależności od konkretnej implementacji, bez konieczności ręcznego sterowania logiką wyboru. W języku C podobny efekt osiąga się zwykle za pomocą struktur z wskaźnikami na funkcje, co działa, ale szybko prowadzi do kodu trudniejszego w utrzymaniu. C++ porządkuje ten schemat, oferując polimorfizm statyczny i dynamiczny jako elementy samego języka.
- Polimorfizm statyczny działa w czasie kompilacji (np. przez przeciążanie funkcji lub szablony) i nie generuje narzutu runtime, co jest szczególnie istotne w embedded.
- Polimorfizm dynamiczny opiera się na klasach bazowych i dziedziczeniu, gdzie decyzja o tym, którą metodę wywołać, zapada w czasie działania programu. Klasy bazowe pełnią rolę interfejsów, a klasy pochodne dostarczają konkretne implementacje. Słowo kluczowe
virtualumożliwia wywołania polimorficzne,overridezabezpiecza przed błędami przy nadpisywaniu metod, afinalpozwala zablokować dalsze rozszerzanie hierarchii. Dzięki temu C++ pozwala pisać kod czytelniejszy, bezpieczniejszy i łatwiejszy w rozbudowie niż klasyczne rozwiązania znane z C.
Dla porównania, prosty „polimorfizm” w C może wyglądać tak:
typedef struct {
void (*read)(void);
} Sensor;
void temp_read(void) { /* ... */ }
void pressure_read(void) { /* ... */ }
Sensor tempSensor = { temp_read };
Sensor pressureSensor = { pressure_read };
W C++ ten sam problem można zapisać w sposób bardziej naturalny:
class Sensor {
public:
virtual void read() = 0;
};
class TempSensor : public Sensor {
public:
void read() override { /* ... */ }
};
class PressureSensor : public Sensor {
public:
void read() override { /* ... */ }
};
Oba podejścia działają podobnie, ale C++ sprawia, że intencja kodu jest czytelniejsza, a kompilator pomaga pilnować poprawności — co w większych projektach embedded robi ogromną różnicę.
Polimorfizm dynamiczny
Polimorfizm dynamiczny, czyli polimorfizm działający w czasie wykonania, pozwala wywołać metodę klasy pochodnej przez wskaźnik lub referencję do klasy bazowej, bez konieczności wiedzy, która konkretna implementacja zostanie użyta. Pod maską kompilator tworzy tablicę wirtualną (vtable) dla każdej klasy, która zawiera wskaźniki do funkcji wirtualnych. Każdy obiekt klasy zawierającej metody wirtualne przechowuje wskaźnik do odpowiedniej vtable, dzięki czemu wywołanie obj->metoda() trafia na właściwą funkcję w runtime.
Mechanizm ten daje ogromną elastyczność, ale ma też swoje koszty. Po pierwsze, vtable zajmuje miejsce w pamięci Flash, a każdy obiekt z metodami wirtualnymi ma dodatkowy wskaźnik w RAM. Po drugie, wywołanie metody wirtualnej wymaga pośredniego dostępu przez wskaźnik, co jest wolniejsze niż zwykłe wywołanie funkcji. Po trzecie, dynamiczny polimorfizm może wpływać na deterministyczność czasu wykonania, co w systemach embedded czasem jest krytyczne.

Na schemacie każdy obiekt posiada wskaźnik do swojej vtable, która wskazuje konkretne funkcje klasy pochodnej. Wywołanie metody przez wskaźnik do klasy bazowej trafia automatycznie do właściwej implementacji w runtime. Dzięki temu możemy traktować różne obiekty jednakowo, a kod pozostaje elastyczny. Taki kod możemy zapisać w sposób już przedstawiony w poprzednim akapicie:
class Sensor {
public:
virtual void read() = 0;
};
class TempSensor : public Sensor {
public:
void read() override { /* ... */ }
};
class PressureSensor : public Sensor {
public:
void read() override { /* ... */ }
};
Klasy używamy później tak:
int main() {
TempSensor tempSensor;
tempSensor.read();
PressureSensor pressureSensor;
pressureSensor.read();
}
Z tego powodu w STM32 i innych mikrokontrolerach warto stosować go tylko tam, gdzie korzyści z elastycznej architektury przeważają nad kosztami. Przykładowe zastosowania to abstrakcje sterowników sprzętowych, obsługa różnorodnych czujników czy modułów komunikacyjnych. W takich przypadkach dynamiczny polimorfizm upraszcza kod, pozwala stosować wspólne interfejsy i łatwiej rozszerzać projekt.
Jeśli jednak działamy w krytycznej pętli czasowej, w ISR lub na bardzo ograniczonym MCU, lepiej rozważyć polimorfizm statyczny lub szablony, które nie generują narzutu runtime. Warto pamiętać, że świadome użycie virtual, override i final pozwala kontrolować strukturę hierarchii i czas wykonania. W połączeniu z analizą zużycia pamięci i testami można bezpiecznie korzystać z polimorfizmu dynamicznego nawet na małych mikrokontrolerach. Dzięki temu kod pozostaje czytelny, elastyczny i łatwy w utrzymaniu, co jest dużą zaletą przy rozwijaniu projektów embedded w C++.
Wyłączanie RTTI – oszczędność pamięci bez utraty polimorfizmu
Jednym z mechanizmów C++ przy programowaniu mikrokontrolerów, który często jest wyłączany, jest RTTI, czyli Run-Time Type Information. RTTI pozwala na sprawdzanie typów obiektów w czasie działania programu, co umożliwia użycie dynamic_cast czy typeid. Wyłączenie RTTI powoduje, że te operacje przestają działać, ale nie wpływa na polimorfizm realizowany przez vtable. Nawet przy wyłączonym RTTI, wirtualne funkcje działają normalnie, a wywołania przez wskaźnik lub referencję do klasy bazowej trafiają do odpowiedniej implementacji klasy pochodnej.
Wyłączenie RTTI pozwala znacząco zmniejszyć rozmiar binarki, ponieważ nie są generowane dodatkowe struktury typu information tables. W przypadku mikrokontrolerów z ograniczoną pamięcią flash i RAM może to być różnica kilku kilobajtów lub więcej. Mechanizm vtable pozostaje w pełni funkcjonalny, co oznacza, że polimorfizm dynamiczny jest zachowany. Dzięki temu można nadal projektować elastyczne systemy z wirtualnymi funkcjami, nie obciążając mikrokontrolera zbędnymi danymi RTTI. Wyłączenie RTTI jest szczególnie przydatne w aplikacjach czasu rzeczywistego, gdzie deterministyczność i niski narzut pamięci mają kluczowe znaczenie.
Kompilatory C++ oferują flagi, takie jak -fno-rtti w GCC, które umożliwiają całkowite wyłączenie tego mechanizmu. W STM32CubeIDE RTTI jest domyślnie wyłączone dla projektów w C++. Możemy to sprawdzić w ustawieniach projektów w sekcji „Properties -> C/C++ Build -> Settings”.

Dla przykładu z klasami Sensor, TempSensor oraz PressureSensor, rozmiar kodu z wyłączonym RTTI jest znaczniej mniejszy.
| Wyłączone RTTI | Włączone RTTI |
|---|---|
| RAM: 1572 B | RAM: 1892 B |
| Flash: 6108 B | Flash: 7244 B |
Programiści muszą jednak pamiętać, że po jego wyłączeniu nie można korzystać z dynamicznego rzutowania między klasami bazowymi i pochodnymi. W praktyce wiele bibliotek embedded jest projektowanych tak, aby RTTI nie było potrzebne, co sprawia, że można bezpiecznie je wyłączyć. Nawet z wyłączonym RTTI, dziedziczenie i wirtualne funkcje działają identycznie jak wcześniej. Warto zatem świadomie planować architekturę klas, aby nie polegać na dynamic_cast. Wyłączenie RTTI jest jednym z najprostszych sposobów na redukcję śladu pamięciowego w projektach MCU. Pozwala to tworzyć wydajne, małe i szybkie programy, które wciąż korzystają z zalet obiektowego modelu programowania. W skrócie, wyłączenie RTTI w embedded to klasyczna optymalizacja pamięciowa, która nie ogranicza mocy C++ w zakresie polimorfizmu.
Polimorfizm statyczny – elastyczność bez narzutu runtime
Innym sposobem na ograniczenie rozmiaru programu może być polimorfizm statyczny. Polimorfizm statyczny w C++ to rodzaj polimorfizmu, który działa w czasie kompilacji, w przeciwieństwie do polimorfizmu dynamicznego, który rozstrzyga, którą funkcję wywołać, dopiero w czasie działania programu. W praktyce oznacza to, że kompilator już na etapie kompilacji decyduje, która funkcja lub implementacja szablonu zostanie użyta.
Najprostsze przykłady polimorfizmu statycznego to przeciążanie funkcji i przeciążanie operatorów, gdzie te same nazwy funkcji obsługują różne typy argumentów. Kolejnym, bardziej zaawansowanym mechanizmem są szablony (templates), które pozwalają tworzyć uniwersalne klasy i funkcje działające na różnych typach danych bez narzutu runtime. Szablony są szeroko stosowane w embedded, ponieważ umożliwiają pisanie elastycznego kodu przy minimalnym koszcie pamięci i czasu wykonania. Polimorfizm statyczny może być również realizowany za pomocą wzorca CRTP (Curiously Recurring Template Pattern), który pozwala klasom bazowym wywoływać metody klas pochodnych bez użycia vtable. Dzięki temu możemy uzyskać efekt polimorfizmu dynamicznego, ale bez wskaźnika do tablicy wirtualnej i bez kosztów pośrednich wywołań funkcji.
Jedną z głównych zalet polimorfizmu statycznego w systemach embedded jest brak narzutu runtime, co oznacza, że czas wykonania programu jest przewidywalny i deterministyczny. To sprawia, że mechanizm jest idealny do krytycznych pętli czasowych i przerwań (ISR), gdzie każda mikrosekunda ma znaczenie. Dodatkowo, ponieważ nie ma wskaźnika do vtable w obiekcie, oszczędzamy RAM, co w małych mikrokontrolerach jest kluczowe. Polimorfizm statyczny pozwala również tworzyć modularny i elastyczny kod, który jest łatwy do testowania i rozszerzania. Przykładem może być prosty szablon klasy Sensor, który obsługuje różne typy czujników:
// Definiujemy klasy czujników, każda z własną funkcją read()
class TempSensor {
public:
void read() { std::cout << "Reading temperature\n"; }
};
class PressureSensor {
public:
void read() { std::cout << "Reading pressure\n"; }
};
// Szablon klasy Sensor obsługujący różne typy czujników
template <typename T>
class Sensor {
public:
void read() {
sensor.read(); // wywołanie konkretnej implementacji typu T
}
private:
T sensor; // konkretna instancja czujnika
};
Szablonu możemy użyć np. w sposób przedstawiony poniżej:
int main() {
Sensor<TempSensor> tempSensor;
tempSensor.read(); // wywołuje TempSensor::read()
Sensor<PressureSensor> pressureSensor;
pressureSensor.read(); // wywołuje PressureSensor::read()
}
Dzięki temu szablon Sensor działa dla różnych klas czujników bez konieczności używania virtual ani vtable. Kompilator generuje konkretne wersje funkcji dla każdego typu szablonu, co sprawia, że kod jest szybki i bezpieczny. Polimorfizm statyczny zmniejsza też ryzyko błędów w runtime, ponieważ większość sprawdzania odbywa się już w kompilacji.
W embedded często stosuje się go do obsługi różnorodnych sensorów, algorytmów przetwarzania danych czy konfiguracji komunikacji sprzętowej. Jest idealny, gdy chcemy uniknąć pośrednich wywołań funkcji i zminimalizować użycie pamięci RAM i Flash. Polimorfizm statyczny pozwala także tworzyć biblioteki generyczne, które działają zarówno na STM32, jak i na innych mikrokontrolerach. W połączeniu ze wzorcami projektowymi można osiągnąć czytelny kod bez kompromisów w wydajności. Kod napisany ze szablonami jest bardziej przewidywalny, co ułatwia testowanie i debugowanie.
W wielu projektach STM32 użycie szablonów pozwala redukować rozmiar binarki i przyspieszać wykonywanie kodu w porównaniu do analogicznych klas wirtualnych. Jednak nie zawsze tak jest. Wszystko zależy od złożoności kodu, ilości przeciążonych metod i ustawień kompilatora. Dla dzisiejszych przykładów z klasami Sensor, TempSensor i PressureSensor, rozmiar kodu wyjściowego przy optymalizacji „-O0” dla polimorfizmu statycznego jest nieznacznie większy.
| Polimorfizm dynamiczny (vtable) | Polimorfizm statyczny (template) |
|---|---|
| RAM: 1572 B | RAM: 1572 B |
| Flash: 6108 B | Flash: 6112 B |
W przypadku optymalizacji „-O3” lub „-Os”, rozmiary są identyczne.
| Polimorfizm dynamiczny (vtable) | Polimorfizm statyczny (template) |
|---|---|
| RAM: 1572 B | RAM: 1572 B |
| Flash: 5928 B | Flash: 5928 B |
Dzięki temu można budować elastyczne i modułowe sterowniki, które nie obciążają ograniczonych zasobów mikrokontrolera. W skrócie, polimorfizm statyczny w C++ daje wszystkie korzyści związane z elastycznością i abstrakcją, ale bez kosztów polimorfizmu dynamicznego, co czyni go idealnym narzędziem w świecie embedded.
Podsumowanie
Polimorfizm w C++ może być bezpiecznie stosowany w systemach embedded, jeśli użyjemy go świadomie i zgodnie z wymaganiami projektu. Mechanizmy takie jak virtual pozwalają pisać elastyczny i czytelny kod, a jednocześnie ułatwiają rozbudowę i testowanie. Wyłączenie RTTI w mikrokontrolerach nie wpływa na działanie polimorfizmu dynamicznego, a pozwala znacząco zmniejszyć zużycie pamięci Flash i RAM.
Polimorfizm statyczny, realizowany za pomocą szablonów lub CRTP, umożliwia generowanie kodu w czasie kompilacji, eliminując narzut runtime i wskaźniki do vtable. Dzięki temu w krytycznych pętlach czasowych lub przerwaniach możemy osiągnąć deterministyczny czas wykonania.
W praktyce najlepsze efekty daje mieszanie polimorfizmu statycznego i dynamicznego – tam, gdzie zależy nam na wydajności, używamy szablonów, a w mniej krytycznych modułach virtual. Świadome projektowanie hierarchii klas i interfejsów pozwala optymalizować zarówno pamięć, jak i czas działania programu. W efekcie można tworzyć wydajne, elastyczne i łatwe w utrzymaniu projekty embedded na STM32 i innych mikrokontrolerach.
